@sanqianx/project-knowledge 4.8.0 → 5.0.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.
Files changed (52) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +32 -0
  3. package/README.md +11 -6
  4. package/_modules/knowledge-engine/README.md +9 -6
  5. package/_modules/knowledge-engine/package.json +1 -1
  6. package/_modules/knowledge-engine/src/coordinator.js +11 -3
  7. package/_modules/knowledge-engine/src/server.js +9 -10
  8. package/_modules/knowledge-engine/src/store.js +9 -12
  9. package/_modules/knowledge-engine/ui/app.js +1 -1
  10. package/_site/_test/fresh-hook-reimport-test.js +53 -0
  11. package/_site/_test/fresh-product-data-test.js +42 -0
  12. package/_site/_test/git-path-compatibility-test.js +80 -0
  13. package/_site/_test/module-model-configuration-test.js +2 -1
  14. package/_site/_test/product-diagnostics-ui-test.js +17 -3
  15. package/_site/_test/product-import-ui-test.js +12 -0
  16. package/_site/_test/tracking-start-test.js +1 -0
  17. package/_site/lib/data-dir.js +13 -81
  18. package/_site/lib/engine-connection.js +67 -0
  19. package/_site/lib/git-path.js +14 -0
  20. package/_site/lib/hook-manager.js +5 -5
  21. package/_site/lib/integration-installer.js +2 -1
  22. package/_site/lib/module-bridge.js +6 -16
  23. package/_site/lib/post-commit-automation.js +1 -1
  24. package/_site/lib/product-data.js +24 -0
  25. package/_site/lib/project-lifecycle-service.js +29 -8
  26. package/_site/lib/requirement-recorder.js +2 -2
  27. package/_site/lib/server-app.js +13 -24
  28. package/_site/scripts/hook-trigger.js +3 -2
  29. package/docs/README.zh-CN.md +8 -4
  30. package/docs/fresh-v5-start.md +34 -0
  31. package/module-runtime-manifest.json +5 -5
  32. package/package.json +3 -2
  33. package/plugins/project-knowledge/.claude-plugin/plugin.json +1 -1
  34. package/plugins/project-knowledge/.codex-plugin/plugin.json +1 -1
  35. package/plugins/project-knowledge/.mcp.json +1 -1
  36. package/ui/index.html +3 -2
  37. package/ui/product.js +57 -22
  38. package/vendor-manifest.json +7 -8
  39. package/_modules/knowledge-engine/src/migration.js +0 -88
  40. package/_site/_test/data-dir-migration-test.js +0 -77
  41. package/_site/_test/knowledge-migration-test.js +0 -75
  42. package/_site/_test/legacy-forward-compat-test.js +0 -257
  43. package/_site/_test/legacy-project-upgrade-e2e-test.js +0 -363
  44. package/_site/_test/p0-data-migration-characterization-test.js +0 -37
  45. package/_site/_test/p0-e2e-gate-test.js +0 -347
  46. package/_site/_test/project-layout-v2-migration-test.js +0 -127
  47. package/_site/_test/server-runtime-migration-safety-test.js +0 -27
  48. package/_site/_test/v4122-upgrade-data-contract-test.js +0 -56
  49. package/_site/lib/data-state-classifier.js +0 -40
  50. package/_site/lib/engine-cutover.js +0 -97
  51. package/_site/lib/legacy-data-manifest.js +0 -26
  52. package/_site/lib/migration-service.js +0 -442
@@ -1,97 +0,0 @@
1
- const fs = require('fs');
2
- const path = require('path');
3
- const crypto = require('crypto');
4
- const AtomicFile = require('./atomic-file');
5
- const { DomainError } = require('./contracts');
6
- const { execGit } = require('./git-runner');
7
-
8
- function engineRegistration(runtime, projectId) {
9
- const config = runtime.projectStore.readConfig(projectId);
10
- const state = runtime.projectStore.readState(projectId);
11
- return { projectId, name: config.displayName || projectId, repoPath: config.repoPath, knowledgePath: config.knowledgePath, knowledgeLanguage: config.knowledgeLanguage || 'zh-CN', brainId: config.brainId || 'brain-default', domainId: config.domainId || null, repoIdentity: config.repoIdentity, trackingStartCommit: state.trackingStartCommit || null, conversationBaselineCursor: state.conversationBaselineCursor || 0, conversationBaselineCursors: state.conversationBaselineCursors || {}, journalId: state.modules?.eventBridge?.projectId || null, vectorProjectId: state.modules?.vectorHub?.projectName || projectId };
12
- }
13
-
14
- async function prepareEngineCutover(runtime) {
15
- const markerPath = runtime.layout.getRuntimePath('engine-cutover.json');
16
- if (fs.existsSync(markerPath)) return AtomicFile.readJsonStrict(markerPath);
17
- if (runtime.activeTasks.size || runtime.indexService.pending?.size) throw new DomainError('PROJECT_BUSY', '请等待旧维护任务结束后再升级。', { status: 409 });
18
- const migrationId = `cutover-${crypto.randomUUID()}`;
19
- const backup = runtime.layout.getRecoveryPath('engine-cutover', migrationId);
20
- fs.mkdirSync(backup, { recursive: true, mode: 0o700 });
21
- function copy(source, target) {
22
- if (!fs.existsSync(source)) return;
23
- const stat = fs.lstatSync(source);
24
- if (stat.isSymbolicLink() || !runtime.layout.isPathInside(runtime.dataPath, source, { realpath: true }) || !runtime.layout.isPathInside(backup, target, { realpath: true })) throw new DomainError('PATH_OUTSIDE_ROOT', '升级备份包含不安全路径。', { status: 409 });
25
- if (stat.isDirectory()) { for (const name of fs.readdirSync(source)) copy(path.join(source, name), path.join(target, name)); }
26
- else if (stat.isFile()) AtomicFile.writeFileAtomic(target, fs.readFileSync(source));
27
- }
28
- for (const name of ['settings.json', 'projects.json']) copy(path.join(runtime.dataPath, name), path.join(backup, name));
29
- for (const name of ['runs', 'claims', 'promotions', 'processed-commits']) copy(runtime.layout.getRuntimePath(name), path.join(backup, 'runtime', name));
30
- copy(runtime.layout.getRecoveryPath('promotions'), path.join(backup, 'recovery', 'promotions'));
31
- const projects = runtime.registryStore.listIds().map(projectId => {
32
- const state = runtime.projectStore.readState(projectId);
33
- copy(runtime.layout.getProjectMetadataDir(projectId), path.join(backup, 'projects', projectId));
34
- return { registration: engineRegistration(runtime, projectId), runtime: { lastAnalyzedCommit: state.lastAnalyzedCommit, conversationBaselineCursor: state.conversationBaselineCursor, conversationBaselineCursors: state.conversationBaselineCursors || {}, analysis: state.analysis, index: { dirty: state.index.dirty } } };
35
- });
36
- const snapshot = { migrationId, sourceDataDir: runtime.dataPath, projects, createdAt: new Date().toISOString() };
37
- AtomicFile.writeJsonAtomic(path.join(backup, 'snapshot.json'), snapshot);
38
- const marker = { schema: 'engine-cutover/v1', migrationId, phase: 'prepared', backupPath: backup, createdAt: snapshot.createdAt };
39
- AtomicFile.writeJsonAtomic(markerPath, marker);
40
- return marker;
41
- }
42
-
43
- async function finishEngineCutover(runtime) {
44
- if (runtime.analysisOwner === 'legacy' || runtime.cutoverRunning) return;
45
- runtime.cutoverRunning = true;
46
- const markerPath = runtime.layout.getRuntimePath('engine-cutover.json');
47
- try {
48
- const marker = await prepareEngineCutover(runtime);
49
- const bridge = runtime.moduleBridge;
50
- const headers = { 'Content-Type': 'application/json', Authorization: `Bearer ${bridge.runtimeToken}` };
51
- if (!marker.profilesMigrated) {
52
- const settings = runtime.settingsStore.read();
53
- if (settings.ai.profiles.length) {
54
- const imported = await bridge._fetch(`${bridge.terminalUrl}/api/claude-workbench/v1/internal/import-profiles`, { method: 'POST', headers, body: JSON.stringify({ migrationId: marker.migrationId, profiles: settings.ai.profiles, defaultProfileId: settings.ai.defaultProfileId }) });
55
- if (!imported.ok) throw new Error('PROFILE_MIGRATION_PENDING');
56
- // Protected rollback copies retain the old secrets; active settings only
57
- // retain a reference to the Workbench configuration from this point.
58
- await runtime.settingsStore.updatePatch({ ai: { profiles: [], defaultProfileId: null }, modelConfigurationRef: 'workbench' });
59
- }
60
- marker.profilesMigrated = true; AtomicFile.writeJsonAtomic(markerPath, marker);
61
- }
62
- if (marker.phase !== 'complete') {
63
- const snapshot = AtomicFile.readJsonStrict(path.join(marker.backupPath, 'snapshot.json'));
64
- const migrated = await bridge._fetch(`${bridge.engineUrl}/api/migrate`, { method: 'POST', headers, body: JSON.stringify(snapshot), timeoutMs: 60000 });
65
- if (!migrated.ok) throw new Error('MIGRATION_PENDING');
66
- for (const id of runtime.registryStore.listIds()) {
67
- const config = runtime.projectStore.readConfig(id);
68
- const modules = await bridge.registerProjectLinks({ projectId: id, name: config.displayName, workspacePath: config.repoPath, knowledgePath: config.knowledgePath });
69
- if (!modules.engine.ok || !modules.eventBridge.ok) throw new Error('WIRING_PENDING');
70
- }
71
- marker.phase = 'complete'; marker.completedAt = new Date().toISOString();
72
- AtomicFile.writeJsonAtomic(markerPath, marker);
73
- }
74
- await bridge.syncEngineConfiguration();
75
- const activated = await bridge._fetch(`${bridge.engineUrl}/api/control`, { method: 'POST', headers, body: JSON.stringify({ enabled: true }) });
76
- if (!activated.ok) throw new Error('ACTIVATION_PENDING');
77
- runtime.analysisOwner = 'engine';
78
- runtime.ownership = { owner: 'engine', ready: true, backupPath: marker.backupPath };
79
- } catch {
80
- runtime.ownership = { owner: 'engine', ready: false, message: '引擎接管待恢复;旧分析已停用,提交证据与升级备份保留。' };
81
- } finally { runtime.cutoverRunning = false; }
82
- }
83
-
84
- async function forwardCommitToBridge(runtime, event) {
85
- const config = runtime.projectStore.readConfig(event.projectId);
86
- if (!runtime.layout.pathsEqual(config.repoPath, event.repoRoot) || !/^[a-f0-9]{40}$/i.test(event.head || '')) throw new DomainError('INVALID_ARGUMENT', '提交通知与项目绑定不匹配。');
87
- const exists = await execGit(config.repoPath, ['cat-file', '-e', `${event.head}^{commit}`]);
88
- if (!exists.ok) throw new DomainError('INVALID_ARGUMENT', '提交不属于当前仓库。');
89
- // Even legacy notifications enter the same journal; they never execute analysis.
90
- const identity = await runtime.bridgeAdapter.resolveRepoIdentity(config.repoPath);
91
- const result = await runtime.bridgeAdapter.appendCommitBoundary({ projectId: event.projectId, repoIdentity: identity.repoIdentity, commitSha: event.head, branch: event.branch || null, operationId: event.operationId });
92
- if (result.status !== 'captured') throw new DomainError('MODULE_UNREACHABLE', '事件记录失败,请恢复事件模块后重试。', { status: 503 });
93
- runtime.moduleBridge._fetch(`${runtime.moduleBridge.engineUrl}/api/bridge/notify`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' }).catch(() => {});
94
- return { journalSequence: result.boundary?.journalSequence || result.boundary?.sequence };
95
- }
96
-
97
- module.exports = { prepareEngineCutover, finishEngineCutover, forwardCommitToBridge, engineRegistration };
@@ -1,26 +0,0 @@
1
- // The complete v4.1.x runtime asset inventory. Both relocation from an
2
- // installed package and layout-v2 migration consume this manifest.
3
- const path = require('path');
4
-
5
- const LEGACY_ASSETS = Object.freeze([
6
- { source: 'projects.json', target: 'projects.json', kind: 'file', authority: 'user' },
7
- { source: 'ai-profiles.json', target: 'ai-profiles.json', kind: 'file', authority: 'user' },
8
- { source: 'knowledge-store.json', target: 'knowledge-store.json', kind: 'file', authority: 'user' },
9
- { source: 'embedding-config.json', target: 'embedding-config.json', kind: 'file', authority: 'user' },
10
- { source: 'logging.json', target: 'logging.json', kind: 'file', authority: 'user' },
11
- { source: 'claude-prompts.json', target: 'claude-prompts.json', kind: 'file', authority: 'user' },
12
- { source: 'github-team.json', target: 'github-team.json', kind: 'file', authority: 'user' },
13
- { source: 'team-git-providers.json', target: 'team-git-providers.json', kind: 'file', authority: 'user' },
14
- { source: 'knowledge-scopes.json', target: 'knowledge-scopes.json', kind: 'file', authority: 'user' },
15
- { source: '.jobs-log.json', target: '.jobs-log.json', kind: 'file', authority: 'history' },
16
- { source: '.hook-trigger-errors.log', target: '.hook-trigger-errors.log', kind: 'file', authority: 'history' },
17
- { source: 'projects', target: 'projects', kind: 'dir', authority: 'user' },
18
- { source: 'logs', target: 'logs', kind: 'dir', authority: 'history' },
19
- { source: 'models', target: 'models', kind: 'dir', authority: 'cache-but-expensive' },
20
- { source: path.join('_site', '_ai'), target: '_ai', kind: 'dir', authority: 'history' },
21
- ]);
22
-
23
- function getLegacyAsset(source) { return LEGACY_ASSETS.find(asset => asset.source === source) || null; }
24
- function assetPath(root, asset, side = 'source') { return path.join(root, asset[side] || asset.source); }
25
-
26
- module.exports = { LEGACY_ASSETS, getLegacyAsset, assetPath };
@@ -1,442 +0,0 @@
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
- };