project-knowledge 0.1.0 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +41 -0
- package/README.md +184 -58
- package/_site/_test/ai-profile-test.js +59 -1
- package/_site/_test/baseline-schema-test.js +4 -3
- package/_site/_test/claude-workbench-test.js +72 -0
- package/_site/_test/draft-apply-test.js +12 -6
- package/_site/_test/kb-v2-templates-test.js +31 -43
- package/_site/_test/knowledge-store-logs-supervision-test.js +143 -0
- package/_site/_test/project-control-panel-task14-test.js +151 -0
- package/_site/_test/task15-20-integration-test.js +194 -0
- package/_site/_test/task15-20-ui-flow-test.js +144 -0
- package/_site/_test/ui-smoke-test.js +2 -2
- package/_site/index.html +1640 -90
- package/_site/lib/ai-adapter.js +3 -3
- package/_site/lib/ai-workspace.js +120 -0
- package/_site/lib/analysis-orchestrator.js +117 -32
- package/_site/lib/claude-cli-runner.js +862 -0
- package/_site/lib/context-pack-builder.js +19 -11
- package/_site/lib/draft-apply.js +80 -31
- package/_site/lib/index-builder.js +100 -0
- package/_site/lib/job-orchestrator.js +12 -9
- package/_site/lib/kb-v3.js +188 -0
- package/_site/lib/kb-validator.js +84 -0
- package/_site/lib/knowledge-store.js +141 -0
- package/_site/lib/llm-client.js +103 -56
- package/_site/lib/prompt-registry.js +102 -0
- package/_site/lib/structured-logger.js +120 -0
- package/_site/lib/supervision.js +103 -0
- package/_site/server.js +835 -19
- package/_site/vendor/tailwind-browser.js +947 -0
- package/_site/vendor/vue.global.prod.js +9 -0
- package/ai-profiles.json +12 -10
- package/docs/development-progress.md +141 -0
- package/package.json +7 -2
package/_site/server.js
CHANGED
|
@@ -15,6 +15,14 @@ const { applyDrafts, rejectDrafts, readDraftContent } = require('./lib/draft-app
|
|
|
15
15
|
const { runJob, makeJob, readJobLog, KNOWN_MODES } = require('./lib/job-orchestrator');
|
|
16
16
|
const { validateKb, buildPrContextPack } = require('./lib/kb-validator');
|
|
17
17
|
const { installHook, uninstallHook, readHookStatus } = require('./lib/hook-manager');
|
|
18
|
+
const { completeText, readConfig: readLlmConfig } = require('./lib/llm-client');
|
|
19
|
+
const claudeCliRunner = require('./lib/claude-cli-runner');
|
|
20
|
+
const promptRegistry = require('./lib/prompt-registry');
|
|
21
|
+
const knowledgeStore = require('./lib/knowledge-store');
|
|
22
|
+
const structuredLogger = require('./lib/structured-logger');
|
|
23
|
+
const supervision = require('./lib/supervision');
|
|
24
|
+
const aiWorkspace = require('./lib/ai-workspace');
|
|
25
|
+
const kbV3 = require('./lib/kb-v3');
|
|
18
26
|
|
|
19
27
|
const KB_ROOT = path.resolve(__dirname, '..');
|
|
20
28
|
const SITE_ROOT = __dirname;
|
|
@@ -23,11 +31,13 @@ const HOST = process.env.KB_SITE_HOST || '127.0.0.1';
|
|
|
23
31
|
const TASK_NAME = 'KB-GitCommits-Daily';
|
|
24
32
|
const SCRIPT = 'D:\\SanQian.Xu\\project-knowledge-base\\scripts\\gen-commit-doc.ps1';
|
|
25
33
|
const SAFE_RUNNER = 'D:\\SanQian.Xu\\project-knowledge-base\\_site\\scripts\\safe-runner.js';
|
|
26
|
-
const PROJECT_SCHEMA_VERSION = '
|
|
34
|
+
const PROJECT_SCHEMA_VERSION = 'v3';
|
|
27
35
|
const DEFAULT_AI_PROFILE_ID = 'mock-agent';
|
|
28
36
|
const DEFAULT_KNOWLEDGE_LANGUAGE = 'zh-CN';
|
|
29
37
|
const AI_PROFILES_PATH = path.join(KB_ROOT, 'ai-profiles.json');
|
|
30
38
|
const JOBS_LOG_PATH = path.join(KB_ROOT, '.jobs-log.json');
|
|
39
|
+
const KNOWLEDGE_STORE_PATH = path.join(KB_ROOT, 'knowledge-store.json');
|
|
40
|
+
const LOGGING_CONFIG_PATH = path.join(KB_ROOT, 'logging.json');
|
|
31
41
|
|
|
32
42
|
// ---- state ----
|
|
33
43
|
let lastRun = { time: null, status: null, slug: null, output: '' };
|
|
@@ -77,7 +87,30 @@ function normalizeKnowledgeLanguage(value) {
|
|
|
77
87
|
}
|
|
78
88
|
|
|
79
89
|
function defaultProjectKbPath(slug) {
|
|
80
|
-
return
|
|
90
|
+
return knowledgeStore.defaultProjectKbPath(slug, KNOWLEDGE_STORE_PATH, KB_ROOT);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function readKnowledgeStore() {
|
|
94
|
+
return knowledgeStore.readConfig(KNOWLEDGE_STORE_PATH, KB_ROOT);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function readLoggingConfig() {
|
|
98
|
+
return structuredLogger.readConfig(LOGGING_CONFIG_PATH, KB_ROOT);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function logEvent(level, event, message, meta = {}) {
|
|
102
|
+
try {
|
|
103
|
+
structuredLogger.appendLog(LOGGING_CONFIG_PATH, KB_ROOT, {
|
|
104
|
+
level,
|
|
105
|
+
event,
|
|
106
|
+
message,
|
|
107
|
+
projectSlug: meta.projectSlug || meta.slug || '',
|
|
108
|
+
source: meta.source || 'server',
|
|
109
|
+
jobId: meta.jobId || '',
|
|
110
|
+
runId: meta.runId || '',
|
|
111
|
+
meta,
|
|
112
|
+
});
|
|
113
|
+
} catch {}
|
|
81
114
|
}
|
|
82
115
|
|
|
83
116
|
function isLegacyKbPath(value) {
|
|
@@ -88,7 +121,20 @@ function isLegacyKbPath(value) {
|
|
|
88
121
|
function readAiProfiles() {
|
|
89
122
|
if (!fs.existsSync(AI_PROFILES_PATH)) {
|
|
90
123
|
return { schema: 'ai-profiles/v1', defaultProfileId: DEFAULT_AI_PROFILE_ID, profiles: listAdapters().map(a => ({
|
|
91
|
-
id: a.id,
|
|
124
|
+
id: a.id,
|
|
125
|
+
name: a.name,
|
|
126
|
+
description: a.description,
|
|
127
|
+
enabled: a.id !== 'claude-code-agent',
|
|
128
|
+
implementation: a.id,
|
|
129
|
+
...(a.id === 'claude-code-agent' ? {
|
|
130
|
+
baseUrl: 'https://api.anthropic.com',
|
|
131
|
+
apiKey: '',
|
|
132
|
+
model: 'claude-haiku-4-5',
|
|
133
|
+
version: '2023-06-01',
|
|
134
|
+
temperature: 0.2,
|
|
135
|
+
maxTokens: 4096,
|
|
136
|
+
timeoutMs: 300000,
|
|
137
|
+
} : {}),
|
|
92
138
|
})) };
|
|
93
139
|
}
|
|
94
140
|
return JSON.parse(fs.readFileSync(AI_PROFILES_PATH, 'utf-8'));
|
|
@@ -98,6 +144,28 @@ function writeAiProfiles(cfg) {
|
|
|
98
144
|
writeJson(AI_PROFILES_PATH, cfg);
|
|
99
145
|
}
|
|
100
146
|
|
|
147
|
+
function findAiProfile(profileId, cfg = readAiProfiles()) {
|
|
148
|
+
return (cfg.profiles || []).find(item => item && item.id === profileId) || null;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function profileImplementation(profile) {
|
|
152
|
+
return profile && (profile.implementation || profile.id);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function validateUsableAiProfile(profileId) {
|
|
156
|
+
if (!profileId) return { ok: false, status: 400, error: 'aiProfileId required' };
|
|
157
|
+
const profile = findAiProfile(profileId);
|
|
158
|
+
if (!profile && profileId === DEFAULT_AI_PROFILE_ID && getAdapter(DEFAULT_AI_PROFILE_ID)) {
|
|
159
|
+
return { ok: true, profile: { id: DEFAULT_AI_PROFILE_ID, enabled: true, implementation: DEFAULT_AI_PROFILE_ID }, implementation: DEFAULT_AI_PROFILE_ID, adapter: getAdapter(DEFAULT_AI_PROFILE_ID) };
|
|
160
|
+
}
|
|
161
|
+
if (!profile) return { ok: false, status: 400, error: `AI profile not configured: ${profileId}` };
|
|
162
|
+
const implementation = profileImplementation(profile);
|
|
163
|
+
const adapter = getAdapter(implementation);
|
|
164
|
+
if (!adapter) return { ok: false, status: 400, error: `unknown adapter: ${implementation}` };
|
|
165
|
+
if (profile.enabled === false) return { ok: false, status: 400, error: `AI profile disabled: ${profileId}` };
|
|
166
|
+
return { ok: true, profile, implementation, adapter };
|
|
167
|
+
}
|
|
168
|
+
|
|
101
169
|
function validateProfileConfig(cfg) {
|
|
102
170
|
const errors = [];
|
|
103
171
|
if (!cfg || typeof cfg !== 'object') return ['not an object'];
|
|
@@ -109,9 +177,21 @@ function validateProfileConfig(cfg) {
|
|
|
109
177
|
if (!p.id) { errors.push(`profiles[${i}] missing id`); continue; }
|
|
110
178
|
if (ids.has(p.id)) errors.push(`duplicate id: ${p.id}`);
|
|
111
179
|
ids.add(p.id);
|
|
112
|
-
|
|
180
|
+
const implementation = profileImplementation(p);
|
|
181
|
+
if (!implementation || typeof implementation !== 'string') errors.push(`profiles[${i}].implementation must be a string`);
|
|
182
|
+
else if (!getAdapter(implementation)) errors.push(`unknown adapter implementation: ${implementation}`);
|
|
183
|
+
if (p.baseUrl && typeof p.baseUrl !== 'string') errors.push(`profiles[${i}].baseUrl must be a string`);
|
|
184
|
+
if (p.apiKey && typeof p.apiKey !== 'string') errors.push(`profiles[${i}].apiKey must be a string`);
|
|
185
|
+
if (p.apiKeyEnv && typeof p.apiKeyEnv !== 'string') errors.push(`profiles[${i}].apiKeyEnv must be a string`);
|
|
186
|
+
if (p.model && typeof p.model !== 'string') errors.push(`profiles[${i}].model must be a string`);
|
|
187
|
+
if (p.version && typeof p.version !== 'string') errors.push(`profiles[${i}].version must be a string`);
|
|
188
|
+
if (p.temperature != null && (typeof p.temperature !== 'number' || p.temperature < 0 || p.temperature > 2)) errors.push(`profiles[${i}].temperature must be a number between 0 and 2`);
|
|
189
|
+
if (p.maxTokens != null && (!Number.isInteger(p.maxTokens) || p.maxTokens < 1)) errors.push(`profiles[${i}].maxTokens must be a positive integer`);
|
|
190
|
+
if (p.timeoutMs != null && (!Number.isInteger(p.timeoutMs) || p.timeoutMs < 1000)) errors.push(`profiles[${i}].timeoutMs must be an integer >= 1000`);
|
|
113
191
|
}
|
|
114
192
|
if (cfg.defaultProfileId && !ids.has(cfg.defaultProfileId)) errors.push(`defaultProfileId not in profiles: ${cfg.defaultProfileId}`);
|
|
193
|
+
const defaultProfile = cfg.defaultProfileId ? (cfg.profiles || []).find(p => p && p.id === cfg.defaultProfileId) : null;
|
|
194
|
+
if (defaultProfile && defaultProfile.enabled === false) errors.push(`defaultProfileId is disabled: ${cfg.defaultProfileId}`);
|
|
115
195
|
return errors;
|
|
116
196
|
}
|
|
117
197
|
|
|
@@ -192,6 +272,82 @@ function listSubTree(root, prefix, depth, max) {
|
|
|
192
272
|
return out;
|
|
193
273
|
}
|
|
194
274
|
|
|
275
|
+
function dirStats(root) {
|
|
276
|
+
const result = { fileCount: 0, kbSizeBytes: 0 };
|
|
277
|
+
if (!root || !fs.existsSync(root)) return result;
|
|
278
|
+
const walk = (dir) => {
|
|
279
|
+
let entries = [];
|
|
280
|
+
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
281
|
+
for (const entry of entries) {
|
|
282
|
+
const full = path.join(dir, entry.name);
|
|
283
|
+
if (entry.isDirectory()) walk(full);
|
|
284
|
+
else if (entry.isFile()) {
|
|
285
|
+
result.fileCount += 1;
|
|
286
|
+
try { result.kbSizeBytes += fs.statSync(full).size; } catch {}
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
};
|
|
290
|
+
walk(root);
|
|
291
|
+
return result;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function hasRunningProjectJob(slug) {
|
|
295
|
+
for (const job of runningJobs.values()) {
|
|
296
|
+
if (!job || job.status !== 'running') continue;
|
|
297
|
+
if (job.slug === slug || job.slug === 'ALL') return true;
|
|
298
|
+
}
|
|
299
|
+
return false;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function allowedKbDeletionRootPaths() {
|
|
303
|
+
const cfg = readKnowledgeStore();
|
|
304
|
+
return [
|
|
305
|
+
path.join(KB_ROOT, 'projects'),
|
|
306
|
+
cfg.rootPath,
|
|
307
|
+
].filter(Boolean).map(p => path.resolve(p));
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function validateKbDeletionPath(kbPath) {
|
|
311
|
+
if (!kbPath || typeof kbPath !== 'string') return { ok: false, error: 'kbPath is required' };
|
|
312
|
+
if (!path.isAbsolute(kbPath)) return { ok: false, error: 'kbPath must be absolute' };
|
|
313
|
+
const resolved = path.resolve(kbPath);
|
|
314
|
+
const roots = allowedKbDeletionRootPaths();
|
|
315
|
+
for (const root of roots) {
|
|
316
|
+
if (resolved !== root && knowledgeStore.isInside(resolved, root)) {
|
|
317
|
+
return { ok: true, path: resolved, root };
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
return { ok: false, path: resolved, error: 'kbPath is outside configured knowledge roots' };
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function removeKnowledgeStoreProjectOverride(slug) {
|
|
324
|
+
if (!fs.existsSync(KNOWLEDGE_STORE_PATH)) return;
|
|
325
|
+
let cfg;
|
|
326
|
+
try { cfg = JSON.parse(fs.readFileSync(KNOWLEDGE_STORE_PATH, 'utf-8')); } catch { return; }
|
|
327
|
+
if (cfg && cfg.projectOverrides && Object.prototype.hasOwnProperty.call(cfg.projectOverrides, slug)) {
|
|
328
|
+
delete cfg.projectOverrides[slug];
|
|
329
|
+
writeJson(KNOWLEDGE_STORE_PATH, cfg);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function projectRemovePreview(slug, cfg) {
|
|
334
|
+
const kbPath = path.resolve(cfg.kbPath || defaultProjectKbPath(slug));
|
|
335
|
+
const stats = dirStats(kbPath);
|
|
336
|
+
const hook = readHookStatus({ repoPath: cfg.gitPath || cfg.localPath || '' });
|
|
337
|
+
return {
|
|
338
|
+
slug,
|
|
339
|
+
displayName: cfg.displayName || slug,
|
|
340
|
+
kbPath,
|
|
341
|
+
kbExists: fs.existsSync(kbPath),
|
|
342
|
+
kbSizeBytes: stats.kbSizeBytes,
|
|
343
|
+
fileCount: stats.fileCount,
|
|
344
|
+
hasRunningJobs: hasRunningProjectJob(slug),
|
|
345
|
+
hookInstalled: hook.installed === true,
|
|
346
|
+
kbManagedHook: hook.kbManaged === true,
|
|
347
|
+
isReference: cfg.isReference === true,
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
|
|
195
351
|
function shellEscape(s) {
|
|
196
352
|
return '"' + String(s).replace(/"/g, '""').replace(/`/g, '``') + '"';
|
|
197
353
|
}
|
|
@@ -308,6 +464,7 @@ function startJob({ mode, slug }) {
|
|
|
308
464
|
if (!KNOWN_MODES.has(mode)) return { ok: false, status: 400, error: `unknown mode: ${mode}` };
|
|
309
465
|
const job = makeJob({ mode, slug: slug || 'ALL' });
|
|
310
466
|
runningJobs.set(job.jobId, job);
|
|
467
|
+
logEvent('info', 'job_started', `${job.mode} job started`, { source: 'job-orchestrator', jobId: job.jobId, projectSlug: job.slug, mode: job.mode });
|
|
311
468
|
// Run the job in the background so the HTTP request returns immediately.
|
|
312
469
|
// The job orchestrator updates `job` in place; the route handler returns
|
|
313
470
|
// just the jobId so the UI can poll.
|
|
@@ -320,14 +477,18 @@ function startJob({ mode, slug }) {
|
|
|
320
477
|
projectsPath: path.join(KB_ROOT, 'projects.json'),
|
|
321
478
|
jobsLogPath: JOBS_LOG_PATH,
|
|
322
479
|
writeProjects: () => writeJson(path.join(KB_ROOT, 'projects.json'), projects),
|
|
480
|
+
defaultProjectKbPath,
|
|
481
|
+
log: (level, event, message, meta = {}) => logEvent(level, event, message, { ...meta, source: meta.source || 'job-orchestrator', jobId: job.jobId, projectSlug: meta.projectSlug || job.slug }),
|
|
323
482
|
});
|
|
324
483
|
} catch (e) {
|
|
325
484
|
job.status = 'failed';
|
|
326
485
|
job.endTime = new Date().toISOString();
|
|
327
486
|
job.exitCode = 1;
|
|
328
487
|
job.output += `\n[dispatch error] ${e.message}\n${e.stack || ''}`;
|
|
488
|
+
logEvent('error', 'job_dispatch_failed', e.message, { source: 'job-orchestrator', jobId: job.jobId, projectSlug: job.slug });
|
|
329
489
|
} finally {
|
|
330
490
|
lastRun = { time: job.endTime, status: job.status, slug: job.slug, mode: job.mode, output: (job.output || '').slice(-6000) };
|
|
491
|
+
logEvent(job.status === 'success' ? 'info' : 'warn', 'job_finished', `${job.mode} job ${job.status}`, { source: 'job-orchestrator', jobId: job.jobId, projectSlug: job.slug, mode: job.mode, status: job.status });
|
|
331
492
|
// Keep the live record for 10 minutes so the UI can poll completion.
|
|
332
493
|
setTimeout(() => runningJobs.delete(job.jobId), 10 * 60 * 1000);
|
|
333
494
|
}
|
|
@@ -431,6 +592,203 @@ async function validateAndPersistProject(slug, projects) {
|
|
|
431
592
|
return { ok: true, slug, inspection };
|
|
432
593
|
}
|
|
433
594
|
|
|
595
|
+
async function projectImportPreflight({ localPath, gitPath }) {
|
|
596
|
+
const targetPath = gitPath || localPath;
|
|
597
|
+
const result = {
|
|
598
|
+
ok: false,
|
|
599
|
+
localPath: localPath || '',
|
|
600
|
+
gitPath: targetPath || '',
|
|
601
|
+
exists: false,
|
|
602
|
+
canImport: false,
|
|
603
|
+
needsGitInit: false,
|
|
604
|
+
canInitGit: false,
|
|
605
|
+
inspection: null,
|
|
606
|
+
error: null,
|
|
607
|
+
};
|
|
608
|
+
if (!targetPath) {
|
|
609
|
+
result.error = 'localPath or gitPath is required';
|
|
610
|
+
return result;
|
|
611
|
+
}
|
|
612
|
+
result.exists = fs.existsSync(targetPath);
|
|
613
|
+
if (!result.exists) {
|
|
614
|
+
result.error = `path not found: ${targetPath}`;
|
|
615
|
+
return result;
|
|
616
|
+
}
|
|
617
|
+
const stat = fs.statSync(targetPath);
|
|
618
|
+
if (!stat.isDirectory()) {
|
|
619
|
+
result.error = `path is not a directory: ${targetPath}`;
|
|
620
|
+
return result;
|
|
621
|
+
}
|
|
622
|
+
const inspection = await inspectGit(targetPath);
|
|
623
|
+
result.inspection = inspection;
|
|
624
|
+
result.canImport = ['ok', 'empty'].includes(inspection.repoStatus);
|
|
625
|
+
result.needsGitInit = inspection.repoStatus === 'not-git';
|
|
626
|
+
result.canInitGit = result.needsGitInit;
|
|
627
|
+
result.ok = result.canImport || result.canInitGit;
|
|
628
|
+
if (!result.ok) result.error = inspection.error || 'project cannot be imported';
|
|
629
|
+
return result;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
async function initializeLocalGit({ repoPath, createInitialCommit = false, remoteUrl = '' }) {
|
|
633
|
+
const result = {
|
|
634
|
+
ok: false,
|
|
635
|
+
repoPath: repoPath || '',
|
|
636
|
+
initialized: false,
|
|
637
|
+
initialCommit: false,
|
|
638
|
+
remoteConfigured: false,
|
|
639
|
+
inspection: null,
|
|
640
|
+
steps: [],
|
|
641
|
+
error: null,
|
|
642
|
+
};
|
|
643
|
+
if (!repoPath || !fs.existsSync(repoPath) || !fs.statSync(repoPath).isDirectory()) {
|
|
644
|
+
result.error = 'repoPath must be an existing directory';
|
|
645
|
+
return result;
|
|
646
|
+
}
|
|
647
|
+
let inspection = await inspectGit(repoPath);
|
|
648
|
+
if (inspection.repoStatus === 'not-git') {
|
|
649
|
+
const init = await execGit(repoPath, ['init']);
|
|
650
|
+
result.steps.push({ step: 'git init', ok: init.ok, stderr: init.stderr });
|
|
651
|
+
if (!init.ok) {
|
|
652
|
+
result.error = init.stderr || init.error || 'git init failed';
|
|
653
|
+
return result;
|
|
654
|
+
}
|
|
655
|
+
result.initialized = true;
|
|
656
|
+
}
|
|
657
|
+
await execGit(repoPath, ['config', 'user.name', 'Project Knowledge Base']);
|
|
658
|
+
await execGit(repoPath, ['config', 'user.email', 'project-knowledge-base@example.local']);
|
|
659
|
+
await execGit(repoPath, ['config', 'commit.gpgsign', 'false']);
|
|
660
|
+
|
|
661
|
+
inspection = await inspectGit(repoPath);
|
|
662
|
+
if (createInitialCommit && inspection.repoStatus === 'empty') {
|
|
663
|
+
const add = await execGit(repoPath, ['add', '-A'], 30000);
|
|
664
|
+
result.steps.push({ step: 'git add -A', ok: add.ok, stderr: add.stderr });
|
|
665
|
+
if (!add.ok) {
|
|
666
|
+
result.error = add.stderr || add.error || 'git add failed';
|
|
667
|
+
return result;
|
|
668
|
+
}
|
|
669
|
+
const commit = await execGit(repoPath, ['commit', '-m', 'chore: initial import'], 30000);
|
|
670
|
+
result.steps.push({ step: 'git commit', ok: commit.ok, stderr: commit.stderr });
|
|
671
|
+
if (!commit.ok) {
|
|
672
|
+
result.error = commit.stderr || commit.error || 'git commit failed';
|
|
673
|
+
return result;
|
|
674
|
+
}
|
|
675
|
+
result.initialCommit = true;
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
if (remoteUrl && String(remoteUrl).trim()) {
|
|
679
|
+
const remote = await execGit(repoPath, ['remote', 'get-url', 'origin']);
|
|
680
|
+
const args = remote.ok ? ['remote', 'set-url', 'origin', remoteUrl.trim()] : ['remote', 'add', 'origin', remoteUrl.trim()];
|
|
681
|
+
const setRemote = await execGit(repoPath, args);
|
|
682
|
+
result.steps.push({ step: args.join(' '), ok: setRemote.ok, stderr: setRemote.stderr });
|
|
683
|
+
if (!setRemote.ok) {
|
|
684
|
+
result.error = setRemote.stderr || setRemote.error || 'git remote setup failed';
|
|
685
|
+
return result;
|
|
686
|
+
}
|
|
687
|
+
result.remoteConfigured = true;
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
result.inspection = await inspectGit(repoPath);
|
|
691
|
+
result.ok = ['ok', 'empty'].includes(result.inspection.repoStatus);
|
|
692
|
+
if (!result.ok) result.error = result.inspection.error || 'git initialization did not produce a usable repository';
|
|
693
|
+
return result;
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
async function runKnowledgeUpdate(slug) {
|
|
697
|
+
const projects = readProjects({ persistMigrations: true });
|
|
698
|
+
if (!projects[slug]) return { ok: false, status: 404, error: 'Slug not in projects.json' };
|
|
699
|
+
const project = projects[slug];
|
|
700
|
+
const kbPath = project.kbPath || defaultProjectKbPath(slug);
|
|
701
|
+
if (!fs.existsSync(kbPath) || !fs.existsSync(path.join(kbPath, 'README.md'))) {
|
|
702
|
+
const init = initProjectDirs(slug, kbPath);
|
|
703
|
+
project.kbSchemaVersion = init.kbSchemaVersion || 'v3';
|
|
704
|
+
logEvent('info', 'kb_initialized', 'Knowledge base initialized before update.', { source: 'knowledge-update', projectSlug: slug, kbPath });
|
|
705
|
+
init.created = init.created || [];
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
const scanResult = await scanAndPersistProject(slug, projects, { maxCommits: 200 });
|
|
709
|
+
if (!scanResult.ok) return scanResult;
|
|
710
|
+
const scan = scanResult.scan;
|
|
711
|
+
if (scan.repoStatus !== 'ok') {
|
|
712
|
+
writeJson(path.join(KB_ROOT, 'projects.json'), projects);
|
|
713
|
+
logEvent('error', 'knowledge_update_blocked', `Knowledge update blocked: git status ${scan.repoStatus}`, { source: 'knowledge-update', projectSlug: slug, repoStatus: scan.repoStatus, error: scan.error });
|
|
714
|
+
return { ok: false, status: 400, slug, stage: 'scan', scan, error: scan.error || `git status ${scan.repoStatus}` };
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
const isV3 = project.kbSchemaVersion === 'v3' || fs.existsSync(path.join(kbPath, 'GOAL.md'));
|
|
718
|
+
const goalPath = path.join(kbPath, isV3 ? 'GOAL.md' : 'project-goal.md');
|
|
719
|
+
const needsInitial = project.goalStatus !== 'accepted' || !fs.existsSync(goalPath) || !project.lastAnalyzedCommit;
|
|
720
|
+
let analysis = null;
|
|
721
|
+
let applyResult = null;
|
|
722
|
+
let validation = null;
|
|
723
|
+
let reviewRequired = false;
|
|
724
|
+
let reviewReason = '';
|
|
725
|
+
|
|
726
|
+
if (needsInitial) {
|
|
727
|
+
analysis = await runInitialAnalysis({ slug, ...project, kbPath });
|
|
728
|
+
reviewRequired = true;
|
|
729
|
+
reviewReason = 'initial analysis may create or update project-goal.md';
|
|
730
|
+
logEvent(analysis.ok ? 'warn' : 'error', analysis.ok ? 'initial_analysis_review_required' : 'initial_analysis_failed', analysis.ok ? 'Initial analysis produced drafts that require review.' : analysis.error, { source: 'knowledge-update', projectSlug: slug, runId: analysis.runId || '' });
|
|
731
|
+
} else if ((scan.pendingCount || 0) > 0) {
|
|
732
|
+
analysis = await runCommitAnalysis({ slug, ...project, kbPath });
|
|
733
|
+
if (analysis.ok && !analysis.noop && analysis.runId) {
|
|
734
|
+
const drafts = listDrafts(kbPath, analysis.runId);
|
|
735
|
+
const safeDrafts = [];
|
|
736
|
+
const blocked = [];
|
|
737
|
+
for (const draft of drafts) {
|
|
738
|
+
if (draft.path === 'project-goal.md' || draft.path === 'GOAL.md' || draft.path === 'ARCHITECTURE.md') {
|
|
739
|
+
blocked.push(draft.path);
|
|
740
|
+
continue;
|
|
741
|
+
}
|
|
742
|
+
const content = readDraftContent(kbPath, analysis.runId, draft.path);
|
|
743
|
+
if (content != null) safeDrafts.push({ path: draft.path, content });
|
|
744
|
+
}
|
|
745
|
+
if (safeDrafts.length) {
|
|
746
|
+
applyResult = applyDrafts({
|
|
747
|
+
kbPath,
|
|
748
|
+
slug,
|
|
749
|
+
runId: analysis.runId,
|
|
750
|
+
drafts: safeDrafts,
|
|
751
|
+
allowGoalEdit: false,
|
|
752
|
+
headCommitAtRun: analysis.runRecord && analysis.runRecord.headCommitAtRun,
|
|
753
|
+
});
|
|
754
|
+
if (applyResult.ok && analysis.runRecord && analysis.runRecord.headCommitAtRun) {
|
|
755
|
+
projects[slug].lastAnalyzedCommit = analysis.runRecord.headCommitAtRun;
|
|
756
|
+
} else if (!applyResult.ok) {
|
|
757
|
+
reviewRequired = true;
|
|
758
|
+
reviewReason = applyResult.error || 'auto apply failed';
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
if (blocked.length) {
|
|
762
|
+
reviewRequired = true;
|
|
763
|
+
reviewReason = `blocked goal-related drafts: ${blocked.join(', ')}`;
|
|
764
|
+
}
|
|
765
|
+
logEvent(applyResult && applyResult.ok ? 'info' : 'warn', 'commit_analysis_auto_apply', applyResult && applyResult.ok ? 'Commit analysis drafts auto-applied.' : 'Commit analysis requires review.', { source: 'knowledge-update', projectSlug: slug, runId: analysis.runId, applied: applyResult && applyResult.applied || [], reviewRequired });
|
|
766
|
+
} else if (analysis.ok && analysis.noop) {
|
|
767
|
+
logEvent('info', 'knowledge_update_noop', 'No pending commits to analyze.', { source: 'knowledge-update', projectSlug: slug });
|
|
768
|
+
} else if (!analysis.ok) {
|
|
769
|
+
logEvent('error', 'commit_analysis_failed', analysis.error, { source: 'knowledge-update', projectSlug: slug, runId: analysis.runId || '' });
|
|
770
|
+
}
|
|
771
|
+
} else {
|
|
772
|
+
analysis = { ok: true, noop: true, message: 'no pending commits' };
|
|
773
|
+
logEvent('info', 'knowledge_update_noop', 'No knowledge update needed.', { source: 'knowledge-update', projectSlug: slug });
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
validation = validateKb(kbPath);
|
|
777
|
+
writeJson(path.join(KB_ROOT, 'projects.json'), projects);
|
|
778
|
+
return {
|
|
779
|
+
ok: !!(analysis && analysis.ok) && validation.ok !== false,
|
|
780
|
+
status: validation.ok === false ? validation.status || 422 : 200,
|
|
781
|
+
slug,
|
|
782
|
+
kbPath,
|
|
783
|
+
scan,
|
|
784
|
+
analysis,
|
|
785
|
+
applyResult,
|
|
786
|
+
validation,
|
|
787
|
+
reviewRequired,
|
|
788
|
+
reviewReason,
|
|
789
|
+
};
|
|
790
|
+
}
|
|
791
|
+
|
|
434
792
|
// ---- project init (create dir structure) ----
|
|
435
793
|
const V2_DIRS = [
|
|
436
794
|
'architecture', 'modules', 'commits', 'operations', 'references',
|
|
@@ -452,8 +810,12 @@ function todayIso() {
|
|
|
452
810
|
return new Date().toISOString().slice(0, 10);
|
|
453
811
|
}
|
|
454
812
|
|
|
455
|
-
function initProjectDirs(slug) {
|
|
456
|
-
|
|
813
|
+
function initProjectDirs(slug, kbPath = null) {
|
|
814
|
+
return kbV3.initV3ProjectDirs(slug, kbPath || defaultProjectKbPath(slug));
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
function initProjectDirsV2(slug, kbPath = null) {
|
|
818
|
+
const base = kbPath || defaultProjectKbPath(slug);
|
|
457
819
|
const created = [];
|
|
458
820
|
for (const sub of V2_DIRS) {
|
|
459
821
|
const p = path.join(base, sub);
|
|
@@ -509,8 +871,8 @@ function initProjectDirs(slug) {
|
|
|
509
871
|
}
|
|
510
872
|
|
|
511
873
|
// Idempotently add v2 layout to an existing project. Used by migration endpoint.
|
|
512
|
-
function ensureV2Layout(slug) {
|
|
513
|
-
return
|
|
874
|
+
function ensureV2Layout(slug, kbPath = null) {
|
|
875
|
+
return initProjectDirsV2(slug, kbPath);
|
|
514
876
|
}
|
|
515
877
|
|
|
516
878
|
// ---- routing ----
|
|
@@ -559,7 +921,133 @@ const server = http.createServer(async (req, res) => {
|
|
|
559
921
|
}
|
|
560
922
|
}
|
|
561
923
|
const schedule = await getScheduleInfo();
|
|
562
|
-
return send(res, 200, {
|
|
924
|
+
return send(res, 200, {
|
|
925
|
+
projects,
|
|
926
|
+
schedule,
|
|
927
|
+
lastRun,
|
|
928
|
+
kbRoot: KB_ROOT,
|
|
929
|
+
knowledgeStore: readKnowledgeStore(),
|
|
930
|
+
logging: readLoggingConfig(),
|
|
931
|
+
projectSchemaVersion: PROJECT_SCHEMA_VERSION,
|
|
932
|
+
});
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
// GET /api/knowledge-store/config
|
|
936
|
+
if (m === 'GET' && p === '/api/knowledge-store/config') {
|
|
937
|
+
const cfg = readKnowledgeStore();
|
|
938
|
+
const validation = knowledgeStore.validateRoot(cfg.rootPath);
|
|
939
|
+
return send(res, 200, { ok: validation.ok, config: cfg, validation });
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
// PUT /api/knowledge-store/config
|
|
943
|
+
if (m === 'PUT' && p === '/api/knowledge-store/config') {
|
|
944
|
+
const body = JSON.parse(await readBody(req));
|
|
945
|
+
const validation = knowledgeStore.validateRoot(body.rootPath);
|
|
946
|
+
if (!validation.ok) return send(res, 400, { ok: false, error: validation.error, validation });
|
|
947
|
+
const cfg = knowledgeStore.writeConfig(KNOWLEDGE_STORE_PATH, KB_ROOT, body);
|
|
948
|
+
logEvent('info', 'knowledge_store_config_updated', 'Knowledge store configuration updated.', { source: 'knowledge-store', rootPath: cfg.rootPath });
|
|
949
|
+
return send(res, 200, { ok: true, config: cfg, validation });
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
// POST /api/knowledge-store/migrate { execute?, overwrite?, move? }
|
|
953
|
+
if (m === 'POST' && p === '/api/knowledge-store/migrate') {
|
|
954
|
+
const body = await readBody(req).catch(() => '{}');
|
|
955
|
+
const parsed = (() => { try { return JSON.parse(body); } catch { return {}; } })();
|
|
956
|
+
const projects = readProjects({ persistMigrations: true });
|
|
957
|
+
const storeConfig = readKnowledgeStore();
|
|
958
|
+
if (!parsed.execute) {
|
|
959
|
+
const plan = knowledgeStore.buildMigrationPlan({ projects, appRoot: KB_ROOT, storeConfig });
|
|
960
|
+
return send(res, 200, { ok: true, execute: false, plan });
|
|
961
|
+
}
|
|
962
|
+
const result = knowledgeStore.migrateProjects({
|
|
963
|
+
projects,
|
|
964
|
+
appRoot: KB_ROOT,
|
|
965
|
+
storeConfig,
|
|
966
|
+
overwrite: parsed.overwrite === true,
|
|
967
|
+
move: parsed.move === true,
|
|
968
|
+
});
|
|
969
|
+
writeJson(path.join(KB_ROOT, 'projects.json'), projects);
|
|
970
|
+
logEvent('info', 'knowledge_store_migrated', 'Knowledge store migration executed.', { source: 'knowledge-store', count: result.migrated.length });
|
|
971
|
+
return send(res, 200, { ok: true, execute: true, ...result });
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
// GET /api/logging/config
|
|
975
|
+
if (m === 'GET' && p === '/api/logging/config') {
|
|
976
|
+
const cfg = readLoggingConfig();
|
|
977
|
+
return send(res, 200, { ok: true, config: cfg });
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
// PUT /api/logging/config
|
|
981
|
+
if (m === 'PUT' && p === '/api/logging/config') {
|
|
982
|
+
const body = JSON.parse(await readBody(req));
|
|
983
|
+
const cfg = structuredLogger.writeConfig(LOGGING_CONFIG_PATH, KB_ROOT, body);
|
|
984
|
+
logEvent('info', 'logging_config_updated', 'Logging configuration updated.', { source: 'logging', rootPath: cfg.rootPath });
|
|
985
|
+
return send(res, 200, { ok: true, config: cfg });
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
// GET /api/logs
|
|
989
|
+
if (m === 'GET' && p === '/api/logs') {
|
|
990
|
+
const logs = structuredLogger.readLogs(LOGGING_CONFIG_PATH, KB_ROOT, {
|
|
991
|
+
dateFrom: url.searchParams.get('dateFrom') || '',
|
|
992
|
+
dateTo: url.searchParams.get('dateTo') || '',
|
|
993
|
+
level: url.searchParams.get('level') || '',
|
|
994
|
+
projectSlug: url.searchParams.get('projectSlug') || '',
|
|
995
|
+
source: url.searchParams.get('source') || '',
|
|
996
|
+
q: url.searchParams.get('q') || '',
|
|
997
|
+
limit: Number(url.searchParams.get('limit') || 500),
|
|
998
|
+
});
|
|
999
|
+
return send(res, 200, { ok: true, logs, config: readLoggingConfig() });
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
// GET /api/supervision/issues
|
|
1003
|
+
if (m === 'GET' && p === '/api/supervision/issues') {
|
|
1004
|
+
const projects = readProjects({ persistMigrations: false });
|
|
1005
|
+
const history = readJobLog(JOBS_LOG_PATH);
|
|
1006
|
+
const issues = [];
|
|
1007
|
+
for (const [slug, cfg] of Object.entries(projects)) {
|
|
1008
|
+
issues.push(...supervision.projectIssues(slug, cfg, cfg.kbPath || defaultProjectKbPath(slug)));
|
|
1009
|
+
}
|
|
1010
|
+
issues.push(...supervision.jobIssues(history));
|
|
1011
|
+
return send(res, 200, { ok: true, issues });
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
// GET /api/supervision/pending-commits
|
|
1015
|
+
if (m === 'GET' && p === '/api/supervision/pending-commits') {
|
|
1016
|
+
const projects = readProjects({ persistMigrations: false });
|
|
1017
|
+
const items = await supervision.pendingCommits(projects, (slug, cfg) => cfg.kbPath || defaultProjectKbPath(slug));
|
|
1018
|
+
return send(res, 200, { ok: true, items });
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
// GET /api/supervision/summary
|
|
1022
|
+
if (m === 'GET' && p === '/api/supervision/summary') {
|
|
1023
|
+
const projects = readProjects({ persistMigrations: false });
|
|
1024
|
+
const history = readJobLog(JOBS_LOG_PATH);
|
|
1025
|
+
const issues = [
|
|
1026
|
+
...Object.entries(projects).flatMap(([slug, cfg]) => supervision.projectIssues(slug, cfg, cfg.kbPath || defaultProjectKbPath(slug))),
|
|
1027
|
+
...supervision.jobIssues(history),
|
|
1028
|
+
];
|
|
1029
|
+
return send(res, 200, { ok: true, summary: supervision.summary(projects, [...runningJobs.values()], issues), issues });
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
// POST /api/projects/import-preflight { localPath, gitPath? }
|
|
1033
|
+
if (m === 'POST' && p === '/api/projects/import-preflight') {
|
|
1034
|
+
const body = await readBody(req).catch(() => '{}');
|
|
1035
|
+
const parsed = (() => { try { return JSON.parse(body); } catch { return {}; } })();
|
|
1036
|
+
const result = await projectImportPreflight({ localPath: parsed.localPath, gitPath: parsed.gitPath });
|
|
1037
|
+
return send(res, result.ok ? 200 : 400, result);
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
// POST /api/git/init { path, createInitialCommit?, remoteUrl? }
|
|
1041
|
+
if (m === 'POST' && p === '/api/git/init') {
|
|
1042
|
+
const body = await readBody(req).catch(() => '{}');
|
|
1043
|
+
const parsed = (() => { try { return JSON.parse(body); } catch { return {}; } })();
|
|
1044
|
+
const result = await initializeLocalGit({
|
|
1045
|
+
repoPath: parsed.path || parsed.repoPath,
|
|
1046
|
+
createInitialCommit: parsed.createInitialCommit === true,
|
|
1047
|
+
remoteUrl: parsed.remoteUrl || '',
|
|
1048
|
+
});
|
|
1049
|
+
logEvent(result.ok ? 'info' : 'error', result.ok ? 'git_initialized' : 'git_init_failed', result.ok ? 'Local Git repository is ready.' : result.error, { source: 'git-import', repoPath: result.repoPath });
|
|
1050
|
+
return send(res, result.ok ? 200 : 400, result);
|
|
563
1051
|
}
|
|
564
1052
|
|
|
565
1053
|
// GET /api/projects
|
|
@@ -576,6 +1064,16 @@ const server = http.createServer(async (req, res) => {
|
|
|
576
1064
|
if (typeof body.config !== 'object' || body.config === null) {
|
|
577
1065
|
return send(res, 400, { error: 'Invalid config' });
|
|
578
1066
|
}
|
|
1067
|
+
const importOptions = body.importOptions && typeof body.importOptions === 'object' ? body.importOptions : {};
|
|
1068
|
+
const targetPathBeforeImport = body.config.gitPath || body.config.localPath;
|
|
1069
|
+
if (importOptions.initGit === true) {
|
|
1070
|
+
const gitInit = await initializeLocalGit({
|
|
1071
|
+
repoPath: targetPathBeforeImport,
|
|
1072
|
+
createInitialCommit: importOptions.createInitialCommit === true,
|
|
1073
|
+
remoteUrl: importOptions.remoteUrl || '',
|
|
1074
|
+
});
|
|
1075
|
+
if (!gitInit.ok) return send(res, 400, { ok: false, error: gitInit.error, gitInit });
|
|
1076
|
+
}
|
|
579
1077
|
projects[body.slug] = normalizeProjectConfig(body.slug, body.config).config;
|
|
580
1078
|
// Auto-validate git on add/update — populates repoStatus, headCommit, etc.
|
|
581
1079
|
const targetPath = projects[body.slug].gitPath || projects[body.slug].localPath;
|
|
@@ -592,17 +1090,81 @@ const server = http.createServer(async (req, res) => {
|
|
|
592
1090
|
return send(res, 400, { error: 'Need { slug, config } or { projects }' });
|
|
593
1091
|
}
|
|
594
1092
|
|
|
1093
|
+
// GET /api/projects/:slug/remove-preview
|
|
1094
|
+
if (m === 'GET' && p.startsWith('/api/projects/') && p.endsWith('/remove-preview')) {
|
|
1095
|
+
const slug = p.split('/')[3];
|
|
1096
|
+
if (!isSafeSlug(slug)) return send(res, 400, { error: 'Invalid slug' });
|
|
1097
|
+
const projects = readProjects({ persistMigrations: false });
|
|
1098
|
+
if (!projects[slug]) return send(res, 404, { error: 'Slug not in projects.json' });
|
|
1099
|
+
return send(res, 200, { ok: true, preview: projectRemovePreview(slug, projects[slug]) });
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
// POST /api/projects/:slug/remove { deleteKb?, reason? }
|
|
1103
|
+
if (m === 'POST' && p.startsWith('/api/projects/') && p.endsWith('/remove')) {
|
|
1104
|
+
const slug = p.split('/')[3];
|
|
1105
|
+
if (!isSafeSlug(slug)) return send(res, 400, { error: 'Invalid slug' });
|
|
1106
|
+
const body = await readBody(req).catch(() => '{}');
|
|
1107
|
+
const parsed = (() => { try { return JSON.parse(body); } catch { return {}; } })();
|
|
1108
|
+
const projects = readProjects({ persistMigrations: false });
|
|
1109
|
+
if (!projects[slug]) return send(res, 404, { error: 'Slug not in projects.json' });
|
|
1110
|
+
if (hasRunningProjectJob(slug)) return send(res, 409, { ok: false, error: 'project has a running job' });
|
|
1111
|
+
const cfg = projects[slug];
|
|
1112
|
+
const kbPath = path.resolve(cfg.kbPath || defaultProjectKbPath(slug));
|
|
1113
|
+
const deleteKb = parsed.deleteKb === true;
|
|
1114
|
+
let removedKb = false;
|
|
1115
|
+
let hookResult = null;
|
|
1116
|
+
if (deleteKb) {
|
|
1117
|
+
const safe = validateKbDeletionPath(kbPath);
|
|
1118
|
+
if (!safe.ok) return send(res, 400, { ok: false, error: safe.error, kbPath: safe.path || kbPath });
|
|
1119
|
+
}
|
|
1120
|
+
try {
|
|
1121
|
+
hookResult = uninstallHook({ repoPath: cfg.gitPath || cfg.localPath || '' });
|
|
1122
|
+
} catch (e) {
|
|
1123
|
+
hookResult = { ok: false, warning: e.message };
|
|
1124
|
+
}
|
|
1125
|
+
delete projects[slug];
|
|
1126
|
+
writeJson(path.join(KB_ROOT, 'projects.json'), projects);
|
|
1127
|
+
removeKnowledgeStoreProjectOverride(slug);
|
|
1128
|
+
if (deleteKb && fs.existsSync(kbPath)) {
|
|
1129
|
+
fs.rmSync(kbPath, { recursive: true, force: true });
|
|
1130
|
+
removedKb = true;
|
|
1131
|
+
}
|
|
1132
|
+
logEvent('info', 'project_removed', `Project removed: ${slug}`, {
|
|
1133
|
+
source: 'project-remove',
|
|
1134
|
+
projectSlug: slug,
|
|
1135
|
+
kbPath,
|
|
1136
|
+
deleteKb,
|
|
1137
|
+
removedKb,
|
|
1138
|
+
reason: parsed.reason || '',
|
|
1139
|
+
hookResult,
|
|
1140
|
+
});
|
|
1141
|
+
return send(res, 200, { ok: true, slug, kbPath, removedKb, hookResult });
|
|
1142
|
+
}
|
|
1143
|
+
|
|
595
1144
|
// POST /api/projects/:slug/init
|
|
596
1145
|
if (m === 'POST' && p.startsWith('/api/projects/') && p.endsWith('/init')) {
|
|
597
1146
|
const slug = p.split('/')[3];
|
|
598
1147
|
if (!isSafeSlug(slug)) return send(res, 400, { error: 'Invalid slug' });
|
|
599
1148
|
const projects = readProjects({ persistMigrations: true });
|
|
600
1149
|
if (!projects[slug]) return send(res, 404, { error: 'Slug not in projects.json' });
|
|
601
|
-
const result = initProjectDirs(slug);
|
|
602
|
-
// Mark project as
|
|
603
|
-
projects[slug].kbSchemaVersion = '
|
|
1150
|
+
const result = initProjectDirs(slug, projects[slug].kbPath || defaultProjectKbPath(slug));
|
|
1151
|
+
// Mark project as v3 after init
|
|
1152
|
+
projects[slug].kbSchemaVersion = 'v3';
|
|
604
1153
|
writeJson(path.join(KB_ROOT, 'projects.json'), projects);
|
|
605
|
-
return send(res, 200, { ok: true, ...result, kbSchemaVersion: '
|
|
1154
|
+
return send(res, 200, { ok: true, ...result, kbSchemaVersion: 'v3' });
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
// POST /api/projects/:slug/migrate-v3
|
|
1158
|
+
if (m === 'POST' && p.startsWith('/api/projects/') && p.endsWith('/migrate-v3')) {
|
|
1159
|
+
const slug = p.split('/')[3];
|
|
1160
|
+
if (!isSafeSlug(slug)) return send(res, 400, { error: 'Invalid slug' });
|
|
1161
|
+
const projects = readProjects({ persistMigrations: true });
|
|
1162
|
+
if (!projects[slug]) return send(res, 404, { error: 'Slug not in projects.json' });
|
|
1163
|
+
const result = kbV3.migrateToV3({ slug, kbPath: projects[slug].kbPath || defaultProjectKbPath(slug), preserveLegacyAI: false });
|
|
1164
|
+
projects[slug].kbSchemaVersion = 'v3';
|
|
1165
|
+
writeJson(path.join(KB_ROOT, 'projects.json'), projects);
|
|
1166
|
+
logEvent('info', 'kb_v3_migrated', 'Knowledge base migrated to v3.', { source: 'kb-v3', projectSlug: slug, kbPath: result.kbPath });
|
|
1167
|
+
return send(res, 200, { ok: true, ...result });
|
|
606
1168
|
}
|
|
607
1169
|
|
|
608
1170
|
// POST /api/projects/:slug/migrate-v2 — add v2 layout to an existing project
|
|
@@ -611,7 +1173,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
611
1173
|
if (!isSafeSlug(slug)) return send(res, 400, { error: 'Invalid slug' });
|
|
612
1174
|
const projects = readProjects({ persistMigrations: true });
|
|
613
1175
|
if (!projects[slug]) return send(res, 404, { error: 'Slug not in projects.json' });
|
|
614
|
-
const result = ensureV2Layout(slug);
|
|
1176
|
+
const result = ensureV2Layout(slug, projects[slug].kbPath || defaultProjectKbPath(slug));
|
|
615
1177
|
projects[slug].kbSchemaVersion = 'v2';
|
|
616
1178
|
writeJson(path.join(KB_ROOT, 'projects.json'), projects);
|
|
617
1179
|
return send(res, 200, { ok: true, ...result, kbSchemaVersion: 'v2' });
|
|
@@ -700,6 +1262,13 @@ const server = http.createServer(async (req, res) => {
|
|
|
700
1262
|
}
|
|
701
1263
|
|
|
702
1264
|
// POST /api/scan-all — scan every enabled project
|
|
1265
|
+
if (m === 'POST' && p.startsWith('/api/projects/') && p.endsWith('/knowledge-update')) {
|
|
1266
|
+
const slug = p.split('/')[3];
|
|
1267
|
+
if (!isSafeSlug(slug)) return send(res, 400, { error: 'Invalid slug' });
|
|
1268
|
+
const result = await runKnowledgeUpdate(slug);
|
|
1269
|
+
return send(res, result.status || (result.ok ? 200 : 500), result);
|
|
1270
|
+
}
|
|
1271
|
+
|
|
703
1272
|
if (m === 'POST' && p === '/api/scan-all') {
|
|
704
1273
|
const projects = readProjects({ persistMigrations: false });
|
|
705
1274
|
const results = [];
|
|
@@ -726,13 +1295,56 @@ const server = http.createServer(async (req, res) => {
|
|
|
726
1295
|
return send(res, 200, { ok: true });
|
|
727
1296
|
}
|
|
728
1297
|
|
|
1298
|
+
// POST /api/ai-profiles/test { profileId, prompt? }
|
|
1299
|
+
if (m === 'POST' && p === '/api/ai-profiles/test') {
|
|
1300
|
+
const body = await readBody(req).catch(() => '{}');
|
|
1301
|
+
const parsed = (() => { try { return JSON.parse(body); } catch { return {}; } })();
|
|
1302
|
+
const cfg = readAiProfiles();
|
|
1303
|
+
const profileId = parsed.profileId || cfg.defaultProfileId || DEFAULT_AI_PROFILE_ID;
|
|
1304
|
+
const profile = (cfg.profiles || []).find(item => item && item.id === profileId);
|
|
1305
|
+
if (!profile) return send(res, 404, { ok: false, error: `profile not found: ${profileId}` });
|
|
1306
|
+
const implementation = profileImplementation(profile);
|
|
1307
|
+
if (!getAdapter(implementation)) return send(res, 400, { ok: false, error: `unknown adapter: ${implementation}` });
|
|
1308
|
+
|
|
1309
|
+
const prompt = parsed.prompt || 'what model are you?';
|
|
1310
|
+
if (implementation === 'mock-agent') {
|
|
1311
|
+
return send(res, 200, {
|
|
1312
|
+
ok: true,
|
|
1313
|
+
profileId,
|
|
1314
|
+
model: 'mock-agent',
|
|
1315
|
+
text: `[mock-agent] received: ${prompt}`,
|
|
1316
|
+
usage: null,
|
|
1317
|
+
});
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
try {
|
|
1321
|
+
const llmCfg = readLlmConfig({ profileId });
|
|
1322
|
+
const result = await completeText({
|
|
1323
|
+
profileId,
|
|
1324
|
+
user: prompt,
|
|
1325
|
+
maxTokens: Math.min(Number(profile.testMaxTokens || 128), 512),
|
|
1326
|
+
temperature: profile.testTemperature != null ? Number(profile.testTemperature) : 0,
|
|
1327
|
+
});
|
|
1328
|
+
return send(res, 200, {
|
|
1329
|
+
ok: true,
|
|
1330
|
+
profileId,
|
|
1331
|
+
model: result.model || llmCfg.model,
|
|
1332
|
+
baseUrl: llmCfg.baseUrl,
|
|
1333
|
+
text: result.text,
|
|
1334
|
+
usage: result.usage,
|
|
1335
|
+
});
|
|
1336
|
+
} catch (e) {
|
|
1337
|
+
return send(res, 400, { ok: false, profileId, error: e.message });
|
|
1338
|
+
}
|
|
1339
|
+
}
|
|
1340
|
+
|
|
729
1341
|
// PUT /api/projects/:slug/ai-profile — set a project's AI profile
|
|
730
1342
|
if (m === 'PUT' && p.startsWith('/api/projects/') && p.endsWith('/ai-profile')) {
|
|
731
1343
|
const slug = p.split('/')[3];
|
|
732
1344
|
if (!isSafeSlug(slug)) return send(res, 400, { error: 'Invalid slug' });
|
|
733
1345
|
const body = JSON.parse(await readBody(req));
|
|
734
|
-
|
|
735
|
-
if (!
|
|
1346
|
+
const profileCheck = validateUsableAiProfile(body.aiProfileId);
|
|
1347
|
+
if (!profileCheck.ok) return send(res, profileCheck.status, { error: profileCheck.error });
|
|
736
1348
|
const projects = readProjects({ persistMigrations: true });
|
|
737
1349
|
if (!projects[slug]) return send(res, 404, { error: 'Slug not in projects.json' });
|
|
738
1350
|
projects[slug].aiProfileId = body.aiProfileId;
|
|
@@ -749,8 +1361,8 @@ const server = http.createServer(async (req, res) => {
|
|
|
749
1361
|
if (!projects[slug]) return send(res, 404, { error: 'Slug not in projects.json' });
|
|
750
1362
|
|
|
751
1363
|
if (body.aiProfileId != null) {
|
|
752
|
-
|
|
753
|
-
if (!
|
|
1364
|
+
const profileCheck = validateUsableAiProfile(body.aiProfileId);
|
|
1365
|
+
if (!profileCheck.ok) return send(res, profileCheck.status, { error: profileCheck.error });
|
|
754
1366
|
projects[slug].aiProfileId = body.aiProfileId;
|
|
755
1367
|
}
|
|
756
1368
|
|
|
@@ -795,12 +1407,47 @@ const server = http.createServer(async (req, res) => {
|
|
|
795
1407
|
}
|
|
796
1408
|
|
|
797
1409
|
// POST /api/projects/:slug/analyze/initial — run initial analysis (TASK-007)
|
|
1410
|
+
// ?mode=cli → spawn Claude Code subprocess and return sessionId for SSE streaming
|
|
798
1411
|
if (m === 'POST' && p.startsWith('/api/projects/') && p.endsWith('/analyze/initial')) {
|
|
799
1412
|
const slug = p.split('/')[3];
|
|
800
1413
|
if (!isSafeSlug(slug)) return send(res, 400, { error: 'Invalid slug' });
|
|
801
1414
|
const projects = readProjects({ persistMigrations: false });
|
|
802
1415
|
if (!projects[slug]) return send(res, 404, { error: 'Slug not in projects.json' });
|
|
803
1416
|
const kbPath = projects[slug].kbPath || defaultProjectKbPath(slug);
|
|
1417
|
+
|
|
1418
|
+
// CLI mode: kick off Claude Code subprocess, return sessionId immediately
|
|
1419
|
+
if (url.searchParams.get('mode') === 'cli') {
|
|
1420
|
+
const projectPath = projects[slug].gitPath || projects[slug].localPath;
|
|
1421
|
+
if (!projectPath) return send(res, 400, { error: 'Project has no gitPath/localPath' });
|
|
1422
|
+
const aiProfileId = projects[slug].aiProfileId || readAiProfiles().defaultProfileId || DEFAULT_AI_PROFILE_ID;
|
|
1423
|
+
const profileCheck = validateUsableAiProfile(aiProfileId);
|
|
1424
|
+
if (!profileCheck.ok) return send(res, profileCheck.status, { ok: false, error: profileCheck.error });
|
|
1425
|
+
if (profileCheck.implementation !== 'claude-code-agent') {
|
|
1426
|
+
return send(res, 400, { ok: false, error: `Claude terminal requires claude-code-agent implementation, got ${profileCheck.implementation}` });
|
|
1427
|
+
}
|
|
1428
|
+
const runner = url.searchParams.get('runner') === 'sdk' || profileCheck.profile.runner === 'sdk' ? 'sdk' : 'cli';
|
|
1429
|
+
try {
|
|
1430
|
+
const { sessionId } = claudeCliRunner.startSession({
|
|
1431
|
+
slug,
|
|
1432
|
+
projectPath,
|
|
1433
|
+
kbPath,
|
|
1434
|
+
promptKey: 'initial-analysis',
|
|
1435
|
+
aiProfile: profileCheck.profile,
|
|
1436
|
+
runner,
|
|
1437
|
+
vars: {
|
|
1438
|
+
SLUG: slug,
|
|
1439
|
+
PROJECT_PATH: projectPath,
|
|
1440
|
+
KB_PATH: kbPath,
|
|
1441
|
+
PRIMARY_LANGUAGE: projects[slug].primaryLanguage || '',
|
|
1442
|
+
KNOWLEDGE_LANGUAGE: projects[slug].knowledgeLanguage || 'zh-CN',
|
|
1443
|
+
},
|
|
1444
|
+
});
|
|
1445
|
+
return send(res, 200, { ok: true, slug, sessionId, mode: 'cli', runner, aiProfileId, pendingPermission: claudeCliRunner.getState(sessionId)?.pendingPermission || null });
|
|
1446
|
+
} catch (e) {
|
|
1447
|
+
return send(res, 500, { ok: false, error: e.message });
|
|
1448
|
+
}
|
|
1449
|
+
}
|
|
1450
|
+
|
|
804
1451
|
const result = await runInitialAnalysis({ slug, ...projects[slug], kbPath });
|
|
805
1452
|
if (!result.ok) {
|
|
806
1453
|
return send(res, result.status, { ok: false, error: result.error, runId: result.runId, run: result.runRecord });
|
|
@@ -808,6 +1455,137 @@ const server = http.createServer(async (req, res) => {
|
|
|
808
1455
|
return send(res, 200, { ok: true, slug, runId: result.runId, run: result.runRecord });
|
|
809
1456
|
}
|
|
810
1457
|
|
|
1458
|
+
// GET /api/claude/sessions — list active sessions
|
|
1459
|
+
if (m === 'GET' && p === '/api/claude/sessions') {
|
|
1460
|
+
const slug = url.searchParams.get('slug');
|
|
1461
|
+
if (slug && !isSafeSlug(slug)) return send(res, 400, { error: 'Invalid slug' });
|
|
1462
|
+
return send(res, 200, { sessions: claudeCliRunner.listSessions({ projectSlug: slug || null }) });
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1465
|
+
// GET /api/claude/sessions/:id — session state
|
|
1466
|
+
if (m === 'GET' && p.startsWith('/api/claude/sessions/') && !p.includes('/')) {
|
|
1467
|
+
// skipped — path has slashes; handled by next branch
|
|
1468
|
+
}
|
|
1469
|
+
if (m === 'GET' && p.startsWith('/api/claude/sessions/') && p.split('/').length === 5) {
|
|
1470
|
+
// /api/claude/sessions/:id → parts = ['', 'api', 'claude', 'sessions', '<id>']
|
|
1471
|
+
const sessionId = p.split('/')[4];
|
|
1472
|
+
const st = claudeCliRunner.getState(sessionId);
|
|
1473
|
+
if (!st) return send(res, 404, { error: 'session not found' });
|
|
1474
|
+
return send(res, 200, st);
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1477
|
+
// GET /api/claude/sessions/:id/stream — SSE stream of events
|
|
1478
|
+
if (m === 'GET' && p.startsWith('/api/claude/sessions/') && p.endsWith('/stream')) {
|
|
1479
|
+
const sessionId = p.split('/')[4];
|
|
1480
|
+
if (!claudeCliRunner.getSession(sessionId)) return send(res, 404, { error: 'session not found' });
|
|
1481
|
+
res.writeHead(200, {
|
|
1482
|
+
'Content-Type': 'text/event-stream',
|
|
1483
|
+
'Cache-Control': 'no-store, no-transform',
|
|
1484
|
+
'Connection': 'keep-alive',
|
|
1485
|
+
'Access-Control-Allow-Origin': '*',
|
|
1486
|
+
'X-Accel-Buffering': 'no',
|
|
1487
|
+
});
|
|
1488
|
+
res.write(`event: claude/hello\ndata: ${JSON.stringify({ sessionId, time: new Date().toISOString() })}\n\n`);
|
|
1489
|
+
const unsubscribe = claudeCliRunner.subscribe(sessionId, (event) => {
|
|
1490
|
+
try {
|
|
1491
|
+
res.write(`event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`);
|
|
1492
|
+
} catch (e) {
|
|
1493
|
+
// client gone
|
|
1494
|
+
}
|
|
1495
|
+
});
|
|
1496
|
+
const heartbeat = setInterval(() => {
|
|
1497
|
+
try { res.write(`: keepalive ${Date.now()}\n\n`); } catch {}
|
|
1498
|
+
}, 15000);
|
|
1499
|
+
const cleanup = () => {
|
|
1500
|
+
clearInterval(heartbeat);
|
|
1501
|
+
try { unsubscribe(); } catch {}
|
|
1502
|
+
};
|
|
1503
|
+
req.on('close', cleanup);
|
|
1504
|
+
req.on('error', cleanup);
|
|
1505
|
+
return; // do NOT call send() — connection stays open
|
|
1506
|
+
}
|
|
1507
|
+
|
|
1508
|
+
// POST /api/claude/sessions/:id/input — send follow-up prompt (uses --resume)
|
|
1509
|
+
if (m === 'POST' && p.startsWith('/api/claude/sessions/') && p.endsWith('/input')) {
|
|
1510
|
+
const sessionId = p.split('/')[4];
|
|
1511
|
+
if (!claudeCliRunner.getSession(sessionId)) return send(res, 404, { error: 'session not found' });
|
|
1512
|
+
let body;
|
|
1513
|
+
try { body = JSON.parse(await readBody(req)); } catch { return send(res, 400, { error: 'invalid JSON body' }); }
|
|
1514
|
+
if (!body || typeof body.text !== 'string' || !body.text.trim()) {
|
|
1515
|
+
return send(res, 400, { error: 'body.text (non-empty string) required' });
|
|
1516
|
+
}
|
|
1517
|
+
try {
|
|
1518
|
+
const st = claudeCliRunner.getState(sessionId);
|
|
1519
|
+
const projects = readProjects({ persistMigrations: false });
|
|
1520
|
+
const project = st && st.projectSlug && projects[st.projectSlug] ? projects[st.projectSlug] : null;
|
|
1521
|
+
let profile = null;
|
|
1522
|
+
let runner = st && st.runner === 'sdk' ? 'sdk' : null;
|
|
1523
|
+
if (project) {
|
|
1524
|
+
const aiProfileId = project.aiProfileId || readAiProfiles().defaultProfileId || DEFAULT_AI_PROFILE_ID;
|
|
1525
|
+
const profileCheck = validateUsableAiProfile(aiProfileId);
|
|
1526
|
+
if (!profileCheck.ok) return send(res, profileCheck.status, { ok: false, error: profileCheck.error });
|
|
1527
|
+
if (profileCheck.implementation !== 'claude-code-agent') {
|
|
1528
|
+
return send(res, 400, { ok: false, error: `Claude workbench requires claude-code-agent implementation, got ${profileCheck.implementation}` });
|
|
1529
|
+
}
|
|
1530
|
+
profile = profileCheck.profile;
|
|
1531
|
+
runner = body.runner === 'sdk' || profile.runner === 'sdk' || runner === 'sdk' ? 'sdk' : 'cli';
|
|
1532
|
+
}
|
|
1533
|
+
const result = await claudeCliRunner.sendInput(sessionId, body.text, profile, runner);
|
|
1534
|
+
return send(res, 200, { ok: true, sessionId, pendingPermission: result && result.pendingPermission || null });
|
|
1535
|
+
} catch (e) {
|
|
1536
|
+
return send(res, 400, { ok: false, error: e.message });
|
|
1537
|
+
}
|
|
1538
|
+
}
|
|
1539
|
+
|
|
1540
|
+
// POST /api/claude/sessions/:id/permission
|
|
1541
|
+
if (m === 'POST' && p.startsWith('/api/claude/sessions/') && p.endsWith('/permission')) {
|
|
1542
|
+
const sessionId = p.split('/')[4];
|
|
1543
|
+
if (!claudeCliRunner.getSession(sessionId)) return send(res, 404, { error: 'session not found' });
|
|
1544
|
+
let body;
|
|
1545
|
+
try { body = JSON.parse(await readBody(req)); } catch { return send(res, 400, { error: 'invalid JSON body' }); }
|
|
1546
|
+
if (!body || typeof body.requestId !== 'string') {
|
|
1547
|
+
return send(res, 400, { error: 'body.requestId required' });
|
|
1548
|
+
}
|
|
1549
|
+
try {
|
|
1550
|
+
const result = claudeCliRunner.resolvePermission(sessionId, body.requestId, {
|
|
1551
|
+
allow: body.allow === true,
|
|
1552
|
+
message: typeof body.message === 'string' ? body.message : '',
|
|
1553
|
+
});
|
|
1554
|
+
return send(res, 200, { ok: true, sessionId, ...result });
|
|
1555
|
+
} catch (e) {
|
|
1556
|
+
return send(res, 400, { ok: false, error: e.message });
|
|
1557
|
+
}
|
|
1558
|
+
}
|
|
1559
|
+
|
|
1560
|
+
// POST /api/claude/sessions/:id/abort — terminate current subprocess
|
|
1561
|
+
if (m === 'POST' && p.startsWith('/api/claude/sessions/') && p.endsWith('/abort')) {
|
|
1562
|
+
const sessionId = p.split('/')[4];
|
|
1563
|
+
if (!claudeCliRunner.getSession(sessionId)) return send(res, 404, { error: 'session not found' });
|
|
1564
|
+
try {
|
|
1565
|
+
claudeCliRunner.abort(sessionId);
|
|
1566
|
+
return send(res, 200, { ok: true, sessionId });
|
|
1567
|
+
} catch (e) {
|
|
1568
|
+
return send(res, 400, { ok: false, error: e.message });
|
|
1569
|
+
}
|
|
1570
|
+
}
|
|
1571
|
+
|
|
1572
|
+
// GET /api/prompts — read prompt registry
|
|
1573
|
+
if (m === 'GET' && p === '/api/prompts') {
|
|
1574
|
+
return send(res, 200, promptRegistry.readPrompts());
|
|
1575
|
+
}
|
|
1576
|
+
|
|
1577
|
+
// PUT /api/prompts — write prompt registry
|
|
1578
|
+
if (m === 'PUT' && p === '/api/prompts') {
|
|
1579
|
+
let body;
|
|
1580
|
+
try { body = JSON.parse(await readBody(req)); } catch { return send(res, 400, { error: 'invalid JSON body' }); }
|
|
1581
|
+
try {
|
|
1582
|
+
const written = promptRegistry.writePrompts(body);
|
|
1583
|
+
return send(res, 200, { ok: true, prompts: written });
|
|
1584
|
+
} catch (e) {
|
|
1585
|
+
return send(res, 400, { ok: false, error: e.message });
|
|
1586
|
+
}
|
|
1587
|
+
}
|
|
1588
|
+
|
|
811
1589
|
// POST /api/projects/:slug/analyze/commits — run incremental commit analysis (TASK-008)
|
|
812
1590
|
if (m === 'POST' && p.startsWith('/api/projects/') && p.endsWith('/analyze/commits')) {
|
|
813
1591
|
const slug = p.split('/')[3];
|
|
@@ -859,6 +1637,27 @@ const server = http.createServer(async (req, res) => {
|
|
|
859
1637
|
}
|
|
860
1638
|
|
|
861
1639
|
// GET /api/projects/:slug/drafts/:runId/raw?path=... — read a single draft's text
|
|
1640
|
+
if (m === 'GET' && p.match(/^\/api\/projects\/[a-z0-9-]+\/drafts-by-branch$/)) {
|
|
1641
|
+
const slug = p.split('/')[3];
|
|
1642
|
+
if (!isSafeSlug(slug)) return send(res, 400, { error: 'Invalid slug' });
|
|
1643
|
+
const branch = url.searchParams.get('branch') || '';
|
|
1644
|
+
const status = url.searchParams.get('status') || 'pending';
|
|
1645
|
+
const projects = readProjects({ persistMigrations: false });
|
|
1646
|
+
if (!projects[slug]) return send(res, 404, { error: 'Slug not in projects.json' });
|
|
1647
|
+
const kbPath = projects[slug].kbPath || defaultProjectKbPath(slug);
|
|
1648
|
+
const runs = listRuns(kbPath);
|
|
1649
|
+
const drafts = [];
|
|
1650
|
+
for (const run of runs) {
|
|
1651
|
+
if (status === 'pending' && (run.applyStatus === 'applied' || run.applyStatus === 'rejected')) continue;
|
|
1652
|
+
const sourceBranch = run.sourceBranch ?? 'unknown (pre-TASK-016)';
|
|
1653
|
+
if (branch && sourceBranch !== branch) continue;
|
|
1654
|
+
for (const draft of listDrafts(kbPath, run.runId)) {
|
|
1655
|
+
drafts.push({ ...draft, runId: run.runId, runStatus: run.status, applyStatus: run.applyStatus || 'pending', sourceBranch });
|
|
1656
|
+
}
|
|
1657
|
+
}
|
|
1658
|
+
return send(res, 200, { ok: true, slug, branch: branch || null, status, drafts });
|
|
1659
|
+
}
|
|
1660
|
+
|
|
862
1661
|
if (m === 'GET' && p.match(/^\/api\/projects\/[a-z0-9-]+\/drafts\/[A-Za-z0-9_-]+\/raw$/)) {
|
|
863
1662
|
const parts = p.split('/');
|
|
864
1663
|
const slug = parts[3];
|
|
@@ -1023,7 +1822,9 @@ const server = http.createServer(async (req, res) => {
|
|
|
1023
1822
|
if (m === 'GET' && p.startsWith('/api/dirs/')) {
|
|
1024
1823
|
const slug = p.split('/')[3];
|
|
1025
1824
|
if (!isSafeSlug(slug)) return send(res, 400, { error: 'Invalid slug' });
|
|
1026
|
-
const
|
|
1825
|
+
const projects = readProjects({ persistMigrations: false });
|
|
1826
|
+
if (!projects[slug]) return send(res, 404, { error: 'Slug not in projects.json' });
|
|
1827
|
+
const kbPath = projects[slug].kbPath || defaultProjectKbPath(slug);
|
|
1027
1828
|
if (!fs.existsSync(kbPath)) return send(res, 404, { error: 'Project KB not initialized' });
|
|
1028
1829
|
return send(res, 200, listSubTree(kbPath, '', 0, 3));
|
|
1029
1830
|
}
|
|
@@ -1044,6 +1845,21 @@ const server = http.createServer(async (req, res) => {
|
|
|
1044
1845
|
return send(res, 200, { type: 'file', content: buf.toString('utf-8'), size: stat.size }, 'text/plain');
|
|
1045
1846
|
}
|
|
1046
1847
|
|
|
1848
|
+
// Static vendor assets (Vue / Tailwind browser bundles)
|
|
1849
|
+
if (m === 'GET' && p.startsWith('/vendor/')) {
|
|
1850
|
+
const rel = decodeURIComponent(p.slice('/vendor/'.length));
|
|
1851
|
+
if (rel.includes('..') || path.isAbsolute(rel)) return send(res, 400, { error: 'bad path' });
|
|
1852
|
+
const vendorRoot = path.join(SITE_ROOT, 'vendor');
|
|
1853
|
+
const abs = path.resolve(vendorRoot, rel);
|
|
1854
|
+
if (!abs.startsWith(vendorRoot + path.sep)) return send(res, 403, { error: 'outside vendor' });
|
|
1855
|
+
if (!fs.existsSync(abs) || !fs.statSync(abs).isFile()) return send(res, 404, { error: 'not found' });
|
|
1856
|
+
const text = fs.readFileSync(abs, 'utf-8');
|
|
1857
|
+
const ct = abs.endsWith('.js') ? 'application/javascript'
|
|
1858
|
+
: abs.endsWith('.css') ? 'text/css'
|
|
1859
|
+
: 'application/octet-stream';
|
|
1860
|
+
return send(res, 200, text, ct);
|
|
1861
|
+
}
|
|
1862
|
+
|
|
1047
1863
|
send(res, 404, { error: 'Not found', path: p });
|
|
1048
1864
|
} catch (e) {
|
|
1049
1865
|
console.error('[server error]', e);
|