project-knowledge 0.1.0 → 1.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.
- package/CHANGELOG.md +41 -0
- package/README.md +201 -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/package-startup-test.js +108 -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 +15 -11
- 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 +887 -30
- package/_site/vendor/tailwind-browser.js +947 -0
- package/_site/vendor/vue.global.prod.js +9 -0
- package/ai-profiles.json +13 -3
- package/bin/project-knowledge.js +51 -0
- package/docs/development-progress.md +141 -0
- package/package.json +11 -2
- package/scripts/gen-commit-doc.ps1 +1 -1
- package/scripts/list-features.ps1 +1 -1
- package/scripts/register-scheduled-task.bat +3 -1
package/_site/server.js
CHANGED
|
@@ -15,19 +15,30 @@ 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;
|
|
21
29
|
const PORT = parseInt(process.env.KB_SITE_PORT || '7777', 10);
|
|
22
30
|
const HOST = process.env.KB_SITE_HOST || '127.0.0.1';
|
|
23
31
|
const TASK_NAME = 'KB-GitCommits-Daily';
|
|
24
|
-
const SCRIPT = '
|
|
25
|
-
const SAFE_RUNNER = '
|
|
26
|
-
const PROJECT_SCHEMA_VERSION = '
|
|
32
|
+
const SCRIPT = path.join(KB_ROOT, 'scripts', 'gen-commit-doc.ps1');
|
|
33
|
+
const SAFE_RUNNER = path.join(SITE_ROOT, 'scripts', 'safe-runner.js');
|
|
34
|
+
const PROJECT_SCHEMA_VERSION = 'v3';
|
|
27
35
|
const DEFAULT_AI_PROFILE_ID = 'mock-agent';
|
|
28
36
|
const DEFAULT_KNOWLEDGE_LANGUAGE = 'zh-CN';
|
|
37
|
+
const PROJECTS_PATH = path.join(KB_ROOT, 'projects.json');
|
|
29
38
|
const AI_PROFILES_PATH = path.join(KB_ROOT, 'ai-profiles.json');
|
|
30
39
|
const JOBS_LOG_PATH = path.join(KB_ROOT, '.jobs-log.json');
|
|
40
|
+
const KNOWLEDGE_STORE_PATH = path.join(KB_ROOT, 'knowledge-store.json');
|
|
41
|
+
const LOGGING_CONFIG_PATH = path.join(KB_ROOT, 'logging.json');
|
|
31
42
|
|
|
32
43
|
// ---- state ----
|
|
33
44
|
let lastRun = { time: null, status: null, slug: null, output: '' };
|
|
@@ -36,7 +47,7 @@ const runningJobs = new Map();
|
|
|
36
47
|
// Read a fresh copy of projects.json (re-loaded on every dispatch so that
|
|
37
48
|
// background jobs see the latest registry state).
|
|
38
49
|
function readProjectsForJob() {
|
|
39
|
-
return
|
|
50
|
+
return readProjects({ persistMigrations: true });
|
|
40
51
|
}
|
|
41
52
|
|
|
42
53
|
// ---- helpers ----
|
|
@@ -67,9 +78,43 @@ function readJson(p) {
|
|
|
67
78
|
}
|
|
68
79
|
|
|
69
80
|
function writeJson(p, obj) {
|
|
81
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
70
82
|
fs.writeFileSync(p, JSON.stringify(obj, null, 2) + '\n', 'utf-8');
|
|
71
83
|
}
|
|
72
84
|
|
|
85
|
+
function backupInvalidJson(filePath, raw) {
|
|
86
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
87
|
+
const backupPath = `${filePath}.invalid-${stamp}.bak`;
|
|
88
|
+
try {
|
|
89
|
+
fs.writeFileSync(backupPath, raw, 'utf-8');
|
|
90
|
+
} catch {}
|
|
91
|
+
return backupPath;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function readJsonOrDefault(filePath, defaultValue, options = {}) {
|
|
95
|
+
const { persistDefault = true, backupInvalid = true } = options;
|
|
96
|
+
let raw = '';
|
|
97
|
+
try {
|
|
98
|
+
raw = fs.readFileSync(filePath, 'utf-8');
|
|
99
|
+
} catch (error) {
|
|
100
|
+
if (error && error.code !== 'ENOENT') throw error;
|
|
101
|
+
if (persistDefault) writeJson(filePath, defaultValue);
|
|
102
|
+
return defaultValue;
|
|
103
|
+
}
|
|
104
|
+
if (!raw.trim()) {
|
|
105
|
+
if (persistDefault) writeJson(filePath, defaultValue);
|
|
106
|
+
return defaultValue;
|
|
107
|
+
}
|
|
108
|
+
try {
|
|
109
|
+
return JSON.parse(raw);
|
|
110
|
+
} catch {
|
|
111
|
+
const backupPath = backupInvalid ? backupInvalidJson(filePath, raw) : '';
|
|
112
|
+
if (persistDefault) writeJson(filePath, defaultValue);
|
|
113
|
+
console.warn(`[project-knowledge] Recovered invalid JSON at ${filePath}${backupPath ? `; backup: ${backupPath}` : ''}`);
|
|
114
|
+
return defaultValue;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
73
118
|
function isSafeSlug(s) { return typeof s === 'string' && /^[a-z0-9][a-z0-9-]{0,40}$/.test(s); }
|
|
74
119
|
|
|
75
120
|
function normalizeKnowledgeLanguage(value) {
|
|
@@ -77,7 +122,30 @@ function normalizeKnowledgeLanguage(value) {
|
|
|
77
122
|
}
|
|
78
123
|
|
|
79
124
|
function defaultProjectKbPath(slug) {
|
|
80
|
-
return
|
|
125
|
+
return knowledgeStore.defaultProjectKbPath(slug, KNOWLEDGE_STORE_PATH, KB_ROOT);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function readKnowledgeStore() {
|
|
129
|
+
return knowledgeStore.readConfig(KNOWLEDGE_STORE_PATH, KB_ROOT);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function readLoggingConfig() {
|
|
133
|
+
return structuredLogger.readConfig(LOGGING_CONFIG_PATH, KB_ROOT);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function logEvent(level, event, message, meta = {}) {
|
|
137
|
+
try {
|
|
138
|
+
structuredLogger.appendLog(LOGGING_CONFIG_PATH, KB_ROOT, {
|
|
139
|
+
level,
|
|
140
|
+
event,
|
|
141
|
+
message,
|
|
142
|
+
projectSlug: meta.projectSlug || meta.slug || '',
|
|
143
|
+
source: meta.source || 'server',
|
|
144
|
+
jobId: meta.jobId || '',
|
|
145
|
+
runId: meta.runId || '',
|
|
146
|
+
meta,
|
|
147
|
+
});
|
|
148
|
+
} catch {}
|
|
81
149
|
}
|
|
82
150
|
|
|
83
151
|
function isLegacyKbPath(value) {
|
|
@@ -85,19 +153,58 @@ function isLegacyKbPath(value) {
|
|
|
85
153
|
}
|
|
86
154
|
|
|
87
155
|
// ---- AI profiles (TASK-005) ----
|
|
88
|
-
function
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
156
|
+
function defaultAiProfilesConfig() {
|
|
157
|
+
return { schema: 'ai-profiles/v1', defaultProfileId: DEFAULT_AI_PROFILE_ID, profiles: listAdapters().map(a => ({
|
|
158
|
+
id: a.id,
|
|
159
|
+
name: a.name,
|
|
160
|
+
description: a.description,
|
|
161
|
+
enabled: a.id !== 'claude-code-agent',
|
|
162
|
+
implementation: a.id,
|
|
163
|
+
...(a.id === 'claude-code-agent' ? {
|
|
164
|
+
baseUrl: 'https://api.anthropic.com',
|
|
165
|
+
apiKey: '',
|
|
166
|
+
model: 'claude-haiku-4-5',
|
|
167
|
+
version: '2023-06-01',
|
|
168
|
+
temperature: 0.2,
|
|
169
|
+
maxTokens: 4096,
|
|
170
|
+
timeoutMs: 300000,
|
|
171
|
+
} : {}),
|
|
92
172
|
})) };
|
|
93
|
-
|
|
94
|
-
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function readAiProfiles() {
|
|
176
|
+
return readJsonOrDefault(AI_PROFILES_PATH, defaultAiProfilesConfig(), {
|
|
177
|
+
persistDefault: true,
|
|
178
|
+
backupInvalid: true,
|
|
179
|
+
});
|
|
95
180
|
}
|
|
96
181
|
|
|
97
182
|
function writeAiProfiles(cfg) {
|
|
98
183
|
writeJson(AI_PROFILES_PATH, cfg);
|
|
99
184
|
}
|
|
100
185
|
|
|
186
|
+
function findAiProfile(profileId, cfg = readAiProfiles()) {
|
|
187
|
+
return (cfg.profiles || []).find(item => item && item.id === profileId) || null;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function profileImplementation(profile) {
|
|
191
|
+
return profile && (profile.implementation || profile.id);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function validateUsableAiProfile(profileId) {
|
|
195
|
+
if (!profileId) return { ok: false, status: 400, error: 'aiProfileId required' };
|
|
196
|
+
const profile = findAiProfile(profileId);
|
|
197
|
+
if (!profile && profileId === DEFAULT_AI_PROFILE_ID && getAdapter(DEFAULT_AI_PROFILE_ID)) {
|
|
198
|
+
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) };
|
|
199
|
+
}
|
|
200
|
+
if (!profile) return { ok: false, status: 400, error: `AI profile not configured: ${profileId}` };
|
|
201
|
+
const implementation = profileImplementation(profile);
|
|
202
|
+
const adapter = getAdapter(implementation);
|
|
203
|
+
if (!adapter) return { ok: false, status: 400, error: `unknown adapter: ${implementation}` };
|
|
204
|
+
if (profile.enabled === false) return { ok: false, status: 400, error: `AI profile disabled: ${profileId}` };
|
|
205
|
+
return { ok: true, profile, implementation, adapter };
|
|
206
|
+
}
|
|
207
|
+
|
|
101
208
|
function validateProfileConfig(cfg) {
|
|
102
209
|
const errors = [];
|
|
103
210
|
if (!cfg || typeof cfg !== 'object') return ['not an object'];
|
|
@@ -109,9 +216,21 @@ function validateProfileConfig(cfg) {
|
|
|
109
216
|
if (!p.id) { errors.push(`profiles[${i}] missing id`); continue; }
|
|
110
217
|
if (ids.has(p.id)) errors.push(`duplicate id: ${p.id}`);
|
|
111
218
|
ids.add(p.id);
|
|
112
|
-
|
|
219
|
+
const implementation = profileImplementation(p);
|
|
220
|
+
if (!implementation || typeof implementation !== 'string') errors.push(`profiles[${i}].implementation must be a string`);
|
|
221
|
+
else if (!getAdapter(implementation)) errors.push(`unknown adapter implementation: ${implementation}`);
|
|
222
|
+
if (p.baseUrl && typeof p.baseUrl !== 'string') errors.push(`profiles[${i}].baseUrl must be a string`);
|
|
223
|
+
if (p.apiKey && typeof p.apiKey !== 'string') errors.push(`profiles[${i}].apiKey must be a string`);
|
|
224
|
+
if (p.apiKeyEnv && typeof p.apiKeyEnv !== 'string') errors.push(`profiles[${i}].apiKeyEnv must be a string`);
|
|
225
|
+
if (p.model && typeof p.model !== 'string') errors.push(`profiles[${i}].model must be a string`);
|
|
226
|
+
if (p.version && typeof p.version !== 'string') errors.push(`profiles[${i}].version must be a string`);
|
|
227
|
+
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`);
|
|
228
|
+
if (p.maxTokens != null && (!Number.isInteger(p.maxTokens) || p.maxTokens < 1)) errors.push(`profiles[${i}].maxTokens must be a positive integer`);
|
|
229
|
+
if (p.timeoutMs != null && (!Number.isInteger(p.timeoutMs) || p.timeoutMs < 1000)) errors.push(`profiles[${i}].timeoutMs must be an integer >= 1000`);
|
|
113
230
|
}
|
|
114
231
|
if (cfg.defaultProfileId && !ids.has(cfg.defaultProfileId)) errors.push(`defaultProfileId not in profiles: ${cfg.defaultProfileId}`);
|
|
232
|
+
const defaultProfile = cfg.defaultProfileId ? (cfg.profiles || []).find(p => p && p.id === cfg.defaultProfileId) : null;
|
|
233
|
+
if (defaultProfile && defaultProfile.enabled === false) errors.push(`defaultProfileId is disabled: ${cfg.defaultProfileId}`);
|
|
115
234
|
return errors;
|
|
116
235
|
}
|
|
117
236
|
|
|
@@ -162,11 +281,13 @@ function normalizeProjects(rawProjects) {
|
|
|
162
281
|
}
|
|
163
282
|
|
|
164
283
|
function readProjects(options = {}) {
|
|
165
|
-
const
|
|
166
|
-
|
|
284
|
+
const rawProjects = readJsonOrDefault(PROJECTS_PATH, {}, {
|
|
285
|
+
persistDefault: true,
|
|
286
|
+
backupInvalid: true,
|
|
287
|
+
});
|
|
167
288
|
const result = normalizeProjects(rawProjects);
|
|
168
289
|
if (options.persistMigrations && result.changed) {
|
|
169
|
-
writeJson(
|
|
290
|
+
writeJson(PROJECTS_PATH, result.projects);
|
|
170
291
|
}
|
|
171
292
|
return result.projects;
|
|
172
293
|
}
|
|
@@ -192,6 +313,82 @@ function listSubTree(root, prefix, depth, max) {
|
|
|
192
313
|
return out;
|
|
193
314
|
}
|
|
194
315
|
|
|
316
|
+
function dirStats(root) {
|
|
317
|
+
const result = { fileCount: 0, kbSizeBytes: 0 };
|
|
318
|
+
if (!root || !fs.existsSync(root)) return result;
|
|
319
|
+
const walk = (dir) => {
|
|
320
|
+
let entries = [];
|
|
321
|
+
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
322
|
+
for (const entry of entries) {
|
|
323
|
+
const full = path.join(dir, entry.name);
|
|
324
|
+
if (entry.isDirectory()) walk(full);
|
|
325
|
+
else if (entry.isFile()) {
|
|
326
|
+
result.fileCount += 1;
|
|
327
|
+
try { result.kbSizeBytes += fs.statSync(full).size; } catch {}
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
};
|
|
331
|
+
walk(root);
|
|
332
|
+
return result;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function hasRunningProjectJob(slug) {
|
|
336
|
+
for (const job of runningJobs.values()) {
|
|
337
|
+
if (!job || job.status !== 'running') continue;
|
|
338
|
+
if (job.slug === slug || job.slug === 'ALL') return true;
|
|
339
|
+
}
|
|
340
|
+
return false;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function allowedKbDeletionRootPaths() {
|
|
344
|
+
const cfg = readKnowledgeStore();
|
|
345
|
+
return [
|
|
346
|
+
path.join(KB_ROOT, 'projects'),
|
|
347
|
+
cfg.rootPath,
|
|
348
|
+
].filter(Boolean).map(p => path.resolve(p));
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function validateKbDeletionPath(kbPath) {
|
|
352
|
+
if (!kbPath || typeof kbPath !== 'string') return { ok: false, error: 'kbPath is required' };
|
|
353
|
+
if (!path.isAbsolute(kbPath)) return { ok: false, error: 'kbPath must be absolute' };
|
|
354
|
+
const resolved = path.resolve(kbPath);
|
|
355
|
+
const roots = allowedKbDeletionRootPaths();
|
|
356
|
+
for (const root of roots) {
|
|
357
|
+
if (resolved !== root && knowledgeStore.isInside(resolved, root)) {
|
|
358
|
+
return { ok: true, path: resolved, root };
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
return { ok: false, path: resolved, error: 'kbPath is outside configured knowledge roots' };
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function removeKnowledgeStoreProjectOverride(slug) {
|
|
365
|
+
if (!fs.existsSync(KNOWLEDGE_STORE_PATH)) return;
|
|
366
|
+
let cfg;
|
|
367
|
+
try { cfg = JSON.parse(fs.readFileSync(KNOWLEDGE_STORE_PATH, 'utf-8')); } catch { return; }
|
|
368
|
+
if (cfg && cfg.projectOverrides && Object.prototype.hasOwnProperty.call(cfg.projectOverrides, slug)) {
|
|
369
|
+
delete cfg.projectOverrides[slug];
|
|
370
|
+
writeJson(KNOWLEDGE_STORE_PATH, cfg);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function projectRemovePreview(slug, cfg) {
|
|
375
|
+
const kbPath = path.resolve(cfg.kbPath || defaultProjectKbPath(slug));
|
|
376
|
+
const stats = dirStats(kbPath);
|
|
377
|
+
const hook = readHookStatus({ repoPath: cfg.gitPath || cfg.localPath || '' });
|
|
378
|
+
return {
|
|
379
|
+
slug,
|
|
380
|
+
displayName: cfg.displayName || slug,
|
|
381
|
+
kbPath,
|
|
382
|
+
kbExists: fs.existsSync(kbPath),
|
|
383
|
+
kbSizeBytes: stats.kbSizeBytes,
|
|
384
|
+
fileCount: stats.fileCount,
|
|
385
|
+
hasRunningJobs: hasRunningProjectJob(slug),
|
|
386
|
+
hookInstalled: hook.installed === true,
|
|
387
|
+
kbManagedHook: hook.kbManaged === true,
|
|
388
|
+
isReference: cfg.isReference === true,
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
|
|
195
392
|
function shellEscape(s) {
|
|
196
393
|
return '"' + String(s).replace(/"/g, '""').replace(/`/g, '``') + '"';
|
|
197
394
|
}
|
|
@@ -308,6 +505,7 @@ function startJob({ mode, slug }) {
|
|
|
308
505
|
if (!KNOWN_MODES.has(mode)) return { ok: false, status: 400, error: `unknown mode: ${mode}` };
|
|
309
506
|
const job = makeJob({ mode, slug: slug || 'ALL' });
|
|
310
507
|
runningJobs.set(job.jobId, job);
|
|
508
|
+
logEvent('info', 'job_started', `${job.mode} job started`, { source: 'job-orchestrator', jobId: job.jobId, projectSlug: job.slug, mode: job.mode });
|
|
311
509
|
// Run the job in the background so the HTTP request returns immediately.
|
|
312
510
|
// The job orchestrator updates `job` in place; the route handler returns
|
|
313
511
|
// just the jobId so the UI can poll.
|
|
@@ -320,14 +518,18 @@ function startJob({ mode, slug }) {
|
|
|
320
518
|
projectsPath: path.join(KB_ROOT, 'projects.json'),
|
|
321
519
|
jobsLogPath: JOBS_LOG_PATH,
|
|
322
520
|
writeProjects: () => writeJson(path.join(KB_ROOT, 'projects.json'), projects),
|
|
521
|
+
defaultProjectKbPath,
|
|
522
|
+
log: (level, event, message, meta = {}) => logEvent(level, event, message, { ...meta, source: meta.source || 'job-orchestrator', jobId: job.jobId, projectSlug: meta.projectSlug || job.slug }),
|
|
323
523
|
});
|
|
324
524
|
} catch (e) {
|
|
325
525
|
job.status = 'failed';
|
|
326
526
|
job.endTime = new Date().toISOString();
|
|
327
527
|
job.exitCode = 1;
|
|
328
528
|
job.output += `\n[dispatch error] ${e.message}\n${e.stack || ''}`;
|
|
529
|
+
logEvent('error', 'job_dispatch_failed', e.message, { source: 'job-orchestrator', jobId: job.jobId, projectSlug: job.slug });
|
|
329
530
|
} finally {
|
|
330
531
|
lastRun = { time: job.endTime, status: job.status, slug: job.slug, mode: job.mode, output: (job.output || '').slice(-6000) };
|
|
532
|
+
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
533
|
// Keep the live record for 10 minutes so the UI can poll completion.
|
|
332
534
|
setTimeout(() => runningJobs.delete(job.jobId), 10 * 60 * 1000);
|
|
333
535
|
}
|
|
@@ -431,6 +633,203 @@ async function validateAndPersistProject(slug, projects) {
|
|
|
431
633
|
return { ok: true, slug, inspection };
|
|
432
634
|
}
|
|
433
635
|
|
|
636
|
+
async function projectImportPreflight({ localPath, gitPath }) {
|
|
637
|
+
const targetPath = gitPath || localPath;
|
|
638
|
+
const result = {
|
|
639
|
+
ok: false,
|
|
640
|
+
localPath: localPath || '',
|
|
641
|
+
gitPath: targetPath || '',
|
|
642
|
+
exists: false,
|
|
643
|
+
canImport: false,
|
|
644
|
+
needsGitInit: false,
|
|
645
|
+
canInitGit: false,
|
|
646
|
+
inspection: null,
|
|
647
|
+
error: null,
|
|
648
|
+
};
|
|
649
|
+
if (!targetPath) {
|
|
650
|
+
result.error = 'localPath or gitPath is required';
|
|
651
|
+
return result;
|
|
652
|
+
}
|
|
653
|
+
result.exists = fs.existsSync(targetPath);
|
|
654
|
+
if (!result.exists) {
|
|
655
|
+
result.error = `path not found: ${targetPath}`;
|
|
656
|
+
return result;
|
|
657
|
+
}
|
|
658
|
+
const stat = fs.statSync(targetPath);
|
|
659
|
+
if (!stat.isDirectory()) {
|
|
660
|
+
result.error = `path is not a directory: ${targetPath}`;
|
|
661
|
+
return result;
|
|
662
|
+
}
|
|
663
|
+
const inspection = await inspectGit(targetPath);
|
|
664
|
+
result.inspection = inspection;
|
|
665
|
+
result.canImport = ['ok', 'empty'].includes(inspection.repoStatus);
|
|
666
|
+
result.needsGitInit = inspection.repoStatus === 'not-git';
|
|
667
|
+
result.canInitGit = result.needsGitInit;
|
|
668
|
+
result.ok = result.canImport || result.canInitGit;
|
|
669
|
+
if (!result.ok) result.error = inspection.error || 'project cannot be imported';
|
|
670
|
+
return result;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
async function initializeLocalGit({ repoPath, createInitialCommit = false, remoteUrl = '' }) {
|
|
674
|
+
const result = {
|
|
675
|
+
ok: false,
|
|
676
|
+
repoPath: repoPath || '',
|
|
677
|
+
initialized: false,
|
|
678
|
+
initialCommit: false,
|
|
679
|
+
remoteConfigured: false,
|
|
680
|
+
inspection: null,
|
|
681
|
+
steps: [],
|
|
682
|
+
error: null,
|
|
683
|
+
};
|
|
684
|
+
if (!repoPath || !fs.existsSync(repoPath) || !fs.statSync(repoPath).isDirectory()) {
|
|
685
|
+
result.error = 'repoPath must be an existing directory';
|
|
686
|
+
return result;
|
|
687
|
+
}
|
|
688
|
+
let inspection = await inspectGit(repoPath);
|
|
689
|
+
if (inspection.repoStatus === 'not-git') {
|
|
690
|
+
const init = await execGit(repoPath, ['init']);
|
|
691
|
+
result.steps.push({ step: 'git init', ok: init.ok, stderr: init.stderr });
|
|
692
|
+
if (!init.ok) {
|
|
693
|
+
result.error = init.stderr || init.error || 'git init failed';
|
|
694
|
+
return result;
|
|
695
|
+
}
|
|
696
|
+
result.initialized = true;
|
|
697
|
+
}
|
|
698
|
+
await execGit(repoPath, ['config', 'user.name', 'Project Knowledge Base']);
|
|
699
|
+
await execGit(repoPath, ['config', 'user.email', 'project-knowledge-base@example.local']);
|
|
700
|
+
await execGit(repoPath, ['config', 'commit.gpgsign', 'false']);
|
|
701
|
+
|
|
702
|
+
inspection = await inspectGit(repoPath);
|
|
703
|
+
if (createInitialCommit && inspection.repoStatus === 'empty') {
|
|
704
|
+
const add = await execGit(repoPath, ['add', '-A'], 30000);
|
|
705
|
+
result.steps.push({ step: 'git add -A', ok: add.ok, stderr: add.stderr });
|
|
706
|
+
if (!add.ok) {
|
|
707
|
+
result.error = add.stderr || add.error || 'git add failed';
|
|
708
|
+
return result;
|
|
709
|
+
}
|
|
710
|
+
const commit = await execGit(repoPath, ['commit', '-m', 'chore: initial import'], 30000);
|
|
711
|
+
result.steps.push({ step: 'git commit', ok: commit.ok, stderr: commit.stderr });
|
|
712
|
+
if (!commit.ok) {
|
|
713
|
+
result.error = commit.stderr || commit.error || 'git commit failed';
|
|
714
|
+
return result;
|
|
715
|
+
}
|
|
716
|
+
result.initialCommit = true;
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
if (remoteUrl && String(remoteUrl).trim()) {
|
|
720
|
+
const remote = await execGit(repoPath, ['remote', 'get-url', 'origin']);
|
|
721
|
+
const args = remote.ok ? ['remote', 'set-url', 'origin', remoteUrl.trim()] : ['remote', 'add', 'origin', remoteUrl.trim()];
|
|
722
|
+
const setRemote = await execGit(repoPath, args);
|
|
723
|
+
result.steps.push({ step: args.join(' '), ok: setRemote.ok, stderr: setRemote.stderr });
|
|
724
|
+
if (!setRemote.ok) {
|
|
725
|
+
result.error = setRemote.stderr || setRemote.error || 'git remote setup failed';
|
|
726
|
+
return result;
|
|
727
|
+
}
|
|
728
|
+
result.remoteConfigured = true;
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
result.inspection = await inspectGit(repoPath);
|
|
732
|
+
result.ok = ['ok', 'empty'].includes(result.inspection.repoStatus);
|
|
733
|
+
if (!result.ok) result.error = result.inspection.error || 'git initialization did not produce a usable repository';
|
|
734
|
+
return result;
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
async function runKnowledgeUpdate(slug) {
|
|
738
|
+
const projects = readProjects({ persistMigrations: true });
|
|
739
|
+
if (!projects[slug]) return { ok: false, status: 404, error: 'Slug not in projects.json' };
|
|
740
|
+
const project = projects[slug];
|
|
741
|
+
const kbPath = project.kbPath || defaultProjectKbPath(slug);
|
|
742
|
+
if (!fs.existsSync(kbPath) || !fs.existsSync(path.join(kbPath, 'README.md'))) {
|
|
743
|
+
const init = initProjectDirs(slug, kbPath);
|
|
744
|
+
project.kbSchemaVersion = init.kbSchemaVersion || 'v3';
|
|
745
|
+
logEvent('info', 'kb_initialized', 'Knowledge base initialized before update.', { source: 'knowledge-update', projectSlug: slug, kbPath });
|
|
746
|
+
init.created = init.created || [];
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
const scanResult = await scanAndPersistProject(slug, projects, { maxCommits: 200 });
|
|
750
|
+
if (!scanResult.ok) return scanResult;
|
|
751
|
+
const scan = scanResult.scan;
|
|
752
|
+
if (scan.repoStatus !== 'ok') {
|
|
753
|
+
writeJson(path.join(KB_ROOT, 'projects.json'), projects);
|
|
754
|
+
logEvent('error', 'knowledge_update_blocked', `Knowledge update blocked: git status ${scan.repoStatus}`, { source: 'knowledge-update', projectSlug: slug, repoStatus: scan.repoStatus, error: scan.error });
|
|
755
|
+
return { ok: false, status: 400, slug, stage: 'scan', scan, error: scan.error || `git status ${scan.repoStatus}` };
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
const isV3 = project.kbSchemaVersion === 'v3' || fs.existsSync(path.join(kbPath, 'GOAL.md'));
|
|
759
|
+
const goalPath = path.join(kbPath, isV3 ? 'GOAL.md' : 'project-goal.md');
|
|
760
|
+
const needsInitial = project.goalStatus !== 'accepted' || !fs.existsSync(goalPath) || !project.lastAnalyzedCommit;
|
|
761
|
+
let analysis = null;
|
|
762
|
+
let applyResult = null;
|
|
763
|
+
let validation = null;
|
|
764
|
+
let reviewRequired = false;
|
|
765
|
+
let reviewReason = '';
|
|
766
|
+
|
|
767
|
+
if (needsInitial) {
|
|
768
|
+
analysis = await runInitialAnalysis({ slug, ...project, kbPath });
|
|
769
|
+
reviewRequired = true;
|
|
770
|
+
reviewReason = 'initial analysis may create or update project-goal.md';
|
|
771
|
+
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 || '' });
|
|
772
|
+
} else if ((scan.pendingCount || 0) > 0) {
|
|
773
|
+
analysis = await runCommitAnalysis({ slug, ...project, kbPath });
|
|
774
|
+
if (analysis.ok && !analysis.noop && analysis.runId) {
|
|
775
|
+
const drafts = listDrafts(kbPath, analysis.runId);
|
|
776
|
+
const safeDrafts = [];
|
|
777
|
+
const blocked = [];
|
|
778
|
+
for (const draft of drafts) {
|
|
779
|
+
if (draft.path === 'project-goal.md' || draft.path === 'GOAL.md' || draft.path === 'ARCHITECTURE.md') {
|
|
780
|
+
blocked.push(draft.path);
|
|
781
|
+
continue;
|
|
782
|
+
}
|
|
783
|
+
const content = readDraftContent(kbPath, analysis.runId, draft.path);
|
|
784
|
+
if (content != null) safeDrafts.push({ path: draft.path, content });
|
|
785
|
+
}
|
|
786
|
+
if (safeDrafts.length) {
|
|
787
|
+
applyResult = applyDrafts({
|
|
788
|
+
kbPath,
|
|
789
|
+
slug,
|
|
790
|
+
runId: analysis.runId,
|
|
791
|
+
drafts: safeDrafts,
|
|
792
|
+
allowGoalEdit: false,
|
|
793
|
+
headCommitAtRun: analysis.runRecord && analysis.runRecord.headCommitAtRun,
|
|
794
|
+
});
|
|
795
|
+
if (applyResult.ok && analysis.runRecord && analysis.runRecord.headCommitAtRun) {
|
|
796
|
+
projects[slug].lastAnalyzedCommit = analysis.runRecord.headCommitAtRun;
|
|
797
|
+
} else if (!applyResult.ok) {
|
|
798
|
+
reviewRequired = true;
|
|
799
|
+
reviewReason = applyResult.error || 'auto apply failed';
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
if (blocked.length) {
|
|
803
|
+
reviewRequired = true;
|
|
804
|
+
reviewReason = `blocked goal-related drafts: ${blocked.join(', ')}`;
|
|
805
|
+
}
|
|
806
|
+
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 });
|
|
807
|
+
} else if (analysis.ok && analysis.noop) {
|
|
808
|
+
logEvent('info', 'knowledge_update_noop', 'No pending commits to analyze.', { source: 'knowledge-update', projectSlug: slug });
|
|
809
|
+
} else if (!analysis.ok) {
|
|
810
|
+
logEvent('error', 'commit_analysis_failed', analysis.error, { source: 'knowledge-update', projectSlug: slug, runId: analysis.runId || '' });
|
|
811
|
+
}
|
|
812
|
+
} else {
|
|
813
|
+
analysis = { ok: true, noop: true, message: 'no pending commits' };
|
|
814
|
+
logEvent('info', 'knowledge_update_noop', 'No knowledge update needed.', { source: 'knowledge-update', projectSlug: slug });
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
validation = validateKb(kbPath);
|
|
818
|
+
writeJson(path.join(KB_ROOT, 'projects.json'), projects);
|
|
819
|
+
return {
|
|
820
|
+
ok: !!(analysis && analysis.ok) && validation.ok !== false,
|
|
821
|
+
status: validation.ok === false ? validation.status || 422 : 200,
|
|
822
|
+
slug,
|
|
823
|
+
kbPath,
|
|
824
|
+
scan,
|
|
825
|
+
analysis,
|
|
826
|
+
applyResult,
|
|
827
|
+
validation,
|
|
828
|
+
reviewRequired,
|
|
829
|
+
reviewReason,
|
|
830
|
+
};
|
|
831
|
+
}
|
|
832
|
+
|
|
434
833
|
// ---- project init (create dir structure) ----
|
|
435
834
|
const V2_DIRS = [
|
|
436
835
|
'architecture', 'modules', 'commits', 'operations', 'references',
|
|
@@ -452,8 +851,12 @@ function todayIso() {
|
|
|
452
851
|
return new Date().toISOString().slice(0, 10);
|
|
453
852
|
}
|
|
454
853
|
|
|
455
|
-
function initProjectDirs(slug) {
|
|
456
|
-
|
|
854
|
+
function initProjectDirs(slug, kbPath = null) {
|
|
855
|
+
return kbV3.initV3ProjectDirs(slug, kbPath || defaultProjectKbPath(slug));
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
function initProjectDirsV2(slug, kbPath = null) {
|
|
859
|
+
const base = kbPath || defaultProjectKbPath(slug);
|
|
457
860
|
const created = [];
|
|
458
861
|
for (const sub of V2_DIRS) {
|
|
459
862
|
const p = path.join(base, sub);
|
|
@@ -509,8 +912,8 @@ function initProjectDirs(slug) {
|
|
|
509
912
|
}
|
|
510
913
|
|
|
511
914
|
// Idempotently add v2 layout to an existing project. Used by migration endpoint.
|
|
512
|
-
function ensureV2Layout(slug) {
|
|
513
|
-
return
|
|
915
|
+
function ensureV2Layout(slug, kbPath = null) {
|
|
916
|
+
return initProjectDirsV2(slug, kbPath);
|
|
514
917
|
}
|
|
515
918
|
|
|
516
919
|
// ---- routing ----
|
|
@@ -559,7 +962,133 @@ const server = http.createServer(async (req, res) => {
|
|
|
559
962
|
}
|
|
560
963
|
}
|
|
561
964
|
const schedule = await getScheduleInfo();
|
|
562
|
-
return send(res, 200, {
|
|
965
|
+
return send(res, 200, {
|
|
966
|
+
projects,
|
|
967
|
+
schedule,
|
|
968
|
+
lastRun,
|
|
969
|
+
kbRoot: KB_ROOT,
|
|
970
|
+
knowledgeStore: readKnowledgeStore(),
|
|
971
|
+
logging: readLoggingConfig(),
|
|
972
|
+
projectSchemaVersion: PROJECT_SCHEMA_VERSION,
|
|
973
|
+
});
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
// GET /api/knowledge-store/config
|
|
977
|
+
if (m === 'GET' && p === '/api/knowledge-store/config') {
|
|
978
|
+
const cfg = readKnowledgeStore();
|
|
979
|
+
const validation = knowledgeStore.validateRoot(cfg.rootPath);
|
|
980
|
+
return send(res, 200, { ok: validation.ok, config: cfg, validation });
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
// PUT /api/knowledge-store/config
|
|
984
|
+
if (m === 'PUT' && p === '/api/knowledge-store/config') {
|
|
985
|
+
const body = JSON.parse(await readBody(req));
|
|
986
|
+
const validation = knowledgeStore.validateRoot(body.rootPath);
|
|
987
|
+
if (!validation.ok) return send(res, 400, { ok: false, error: validation.error, validation });
|
|
988
|
+
const cfg = knowledgeStore.writeConfig(KNOWLEDGE_STORE_PATH, KB_ROOT, body);
|
|
989
|
+
logEvent('info', 'knowledge_store_config_updated', 'Knowledge store configuration updated.', { source: 'knowledge-store', rootPath: cfg.rootPath });
|
|
990
|
+
return send(res, 200, { ok: true, config: cfg, validation });
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
// POST /api/knowledge-store/migrate { execute?, overwrite?, move? }
|
|
994
|
+
if (m === 'POST' && p === '/api/knowledge-store/migrate') {
|
|
995
|
+
const body = await readBody(req).catch(() => '{}');
|
|
996
|
+
const parsed = (() => { try { return JSON.parse(body); } catch { return {}; } })();
|
|
997
|
+
const projects = readProjects({ persistMigrations: true });
|
|
998
|
+
const storeConfig = readKnowledgeStore();
|
|
999
|
+
if (!parsed.execute) {
|
|
1000
|
+
const plan = knowledgeStore.buildMigrationPlan({ projects, appRoot: KB_ROOT, storeConfig });
|
|
1001
|
+
return send(res, 200, { ok: true, execute: false, plan });
|
|
1002
|
+
}
|
|
1003
|
+
const result = knowledgeStore.migrateProjects({
|
|
1004
|
+
projects,
|
|
1005
|
+
appRoot: KB_ROOT,
|
|
1006
|
+
storeConfig,
|
|
1007
|
+
overwrite: parsed.overwrite === true,
|
|
1008
|
+
move: parsed.move === true,
|
|
1009
|
+
});
|
|
1010
|
+
writeJson(path.join(KB_ROOT, 'projects.json'), projects);
|
|
1011
|
+
logEvent('info', 'knowledge_store_migrated', 'Knowledge store migration executed.', { source: 'knowledge-store', count: result.migrated.length });
|
|
1012
|
+
return send(res, 200, { ok: true, execute: true, ...result });
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
// GET /api/logging/config
|
|
1016
|
+
if (m === 'GET' && p === '/api/logging/config') {
|
|
1017
|
+
const cfg = readLoggingConfig();
|
|
1018
|
+
return send(res, 200, { ok: true, config: cfg });
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
// PUT /api/logging/config
|
|
1022
|
+
if (m === 'PUT' && p === '/api/logging/config') {
|
|
1023
|
+
const body = JSON.parse(await readBody(req));
|
|
1024
|
+
const cfg = structuredLogger.writeConfig(LOGGING_CONFIG_PATH, KB_ROOT, body);
|
|
1025
|
+
logEvent('info', 'logging_config_updated', 'Logging configuration updated.', { source: 'logging', rootPath: cfg.rootPath });
|
|
1026
|
+
return send(res, 200, { ok: true, config: cfg });
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
// GET /api/logs
|
|
1030
|
+
if (m === 'GET' && p === '/api/logs') {
|
|
1031
|
+
const logs = structuredLogger.readLogs(LOGGING_CONFIG_PATH, KB_ROOT, {
|
|
1032
|
+
dateFrom: url.searchParams.get('dateFrom') || '',
|
|
1033
|
+
dateTo: url.searchParams.get('dateTo') || '',
|
|
1034
|
+
level: url.searchParams.get('level') || '',
|
|
1035
|
+
projectSlug: url.searchParams.get('projectSlug') || '',
|
|
1036
|
+
source: url.searchParams.get('source') || '',
|
|
1037
|
+
q: url.searchParams.get('q') || '',
|
|
1038
|
+
limit: Number(url.searchParams.get('limit') || 500),
|
|
1039
|
+
});
|
|
1040
|
+
return send(res, 200, { ok: true, logs, config: readLoggingConfig() });
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
// GET /api/supervision/issues
|
|
1044
|
+
if (m === 'GET' && p === '/api/supervision/issues') {
|
|
1045
|
+
const projects = readProjects({ persistMigrations: false });
|
|
1046
|
+
const history = readJobLog(JOBS_LOG_PATH);
|
|
1047
|
+
const issues = [];
|
|
1048
|
+
for (const [slug, cfg] of Object.entries(projects)) {
|
|
1049
|
+
issues.push(...supervision.projectIssues(slug, cfg, cfg.kbPath || defaultProjectKbPath(slug)));
|
|
1050
|
+
}
|
|
1051
|
+
issues.push(...supervision.jobIssues(history));
|
|
1052
|
+
return send(res, 200, { ok: true, issues });
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
// GET /api/supervision/pending-commits
|
|
1056
|
+
if (m === 'GET' && p === '/api/supervision/pending-commits') {
|
|
1057
|
+
const projects = readProjects({ persistMigrations: false });
|
|
1058
|
+
const items = await supervision.pendingCommits(projects, (slug, cfg) => cfg.kbPath || defaultProjectKbPath(slug));
|
|
1059
|
+
return send(res, 200, { ok: true, items });
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
// GET /api/supervision/summary
|
|
1063
|
+
if (m === 'GET' && p === '/api/supervision/summary') {
|
|
1064
|
+
const projects = readProjects({ persistMigrations: false });
|
|
1065
|
+
const history = readJobLog(JOBS_LOG_PATH);
|
|
1066
|
+
const issues = [
|
|
1067
|
+
...Object.entries(projects).flatMap(([slug, cfg]) => supervision.projectIssues(slug, cfg, cfg.kbPath || defaultProjectKbPath(slug))),
|
|
1068
|
+
...supervision.jobIssues(history),
|
|
1069
|
+
];
|
|
1070
|
+
return send(res, 200, { ok: true, summary: supervision.summary(projects, [...runningJobs.values()], issues), issues });
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
// POST /api/projects/import-preflight { localPath, gitPath? }
|
|
1074
|
+
if (m === 'POST' && p === '/api/projects/import-preflight') {
|
|
1075
|
+
const body = await readBody(req).catch(() => '{}');
|
|
1076
|
+
const parsed = (() => { try { return JSON.parse(body); } catch { return {}; } })();
|
|
1077
|
+
const result = await projectImportPreflight({ localPath: parsed.localPath, gitPath: parsed.gitPath });
|
|
1078
|
+
return send(res, result.ok ? 200 : 400, result);
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
// POST /api/git/init { path, createInitialCommit?, remoteUrl? }
|
|
1082
|
+
if (m === 'POST' && p === '/api/git/init') {
|
|
1083
|
+
const body = await readBody(req).catch(() => '{}');
|
|
1084
|
+
const parsed = (() => { try { return JSON.parse(body); } catch { return {}; } })();
|
|
1085
|
+
const result = await initializeLocalGit({
|
|
1086
|
+
repoPath: parsed.path || parsed.repoPath,
|
|
1087
|
+
createInitialCommit: parsed.createInitialCommit === true,
|
|
1088
|
+
remoteUrl: parsed.remoteUrl || '',
|
|
1089
|
+
});
|
|
1090
|
+
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 });
|
|
1091
|
+
return send(res, result.ok ? 200 : 400, result);
|
|
563
1092
|
}
|
|
564
1093
|
|
|
565
1094
|
// GET /api/projects
|
|
@@ -576,6 +1105,16 @@ const server = http.createServer(async (req, res) => {
|
|
|
576
1105
|
if (typeof body.config !== 'object' || body.config === null) {
|
|
577
1106
|
return send(res, 400, { error: 'Invalid config' });
|
|
578
1107
|
}
|
|
1108
|
+
const importOptions = body.importOptions && typeof body.importOptions === 'object' ? body.importOptions : {};
|
|
1109
|
+
const targetPathBeforeImport = body.config.gitPath || body.config.localPath;
|
|
1110
|
+
if (importOptions.initGit === true) {
|
|
1111
|
+
const gitInit = await initializeLocalGit({
|
|
1112
|
+
repoPath: targetPathBeforeImport,
|
|
1113
|
+
createInitialCommit: importOptions.createInitialCommit === true,
|
|
1114
|
+
remoteUrl: importOptions.remoteUrl || '',
|
|
1115
|
+
});
|
|
1116
|
+
if (!gitInit.ok) return send(res, 400, { ok: false, error: gitInit.error, gitInit });
|
|
1117
|
+
}
|
|
579
1118
|
projects[body.slug] = normalizeProjectConfig(body.slug, body.config).config;
|
|
580
1119
|
// Auto-validate git on add/update — populates repoStatus, headCommit, etc.
|
|
581
1120
|
const targetPath = projects[body.slug].gitPath || projects[body.slug].localPath;
|
|
@@ -592,17 +1131,81 @@ const server = http.createServer(async (req, res) => {
|
|
|
592
1131
|
return send(res, 400, { error: 'Need { slug, config } or { projects }' });
|
|
593
1132
|
}
|
|
594
1133
|
|
|
1134
|
+
// GET /api/projects/:slug/remove-preview
|
|
1135
|
+
if (m === 'GET' && p.startsWith('/api/projects/') && p.endsWith('/remove-preview')) {
|
|
1136
|
+
const slug = p.split('/')[3];
|
|
1137
|
+
if (!isSafeSlug(slug)) return send(res, 400, { error: 'Invalid slug' });
|
|
1138
|
+
const projects = readProjects({ persistMigrations: false });
|
|
1139
|
+
if (!projects[slug]) return send(res, 404, { error: 'Slug not in projects.json' });
|
|
1140
|
+
return send(res, 200, { ok: true, preview: projectRemovePreview(slug, projects[slug]) });
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
// POST /api/projects/:slug/remove { deleteKb?, reason? }
|
|
1144
|
+
if (m === 'POST' && p.startsWith('/api/projects/') && p.endsWith('/remove')) {
|
|
1145
|
+
const slug = p.split('/')[3];
|
|
1146
|
+
if (!isSafeSlug(slug)) return send(res, 400, { error: 'Invalid slug' });
|
|
1147
|
+
const body = await readBody(req).catch(() => '{}');
|
|
1148
|
+
const parsed = (() => { try { return JSON.parse(body); } catch { return {}; } })();
|
|
1149
|
+
const projects = readProjects({ persistMigrations: false });
|
|
1150
|
+
if (!projects[slug]) return send(res, 404, { error: 'Slug not in projects.json' });
|
|
1151
|
+
if (hasRunningProjectJob(slug)) return send(res, 409, { ok: false, error: 'project has a running job' });
|
|
1152
|
+
const cfg = projects[slug];
|
|
1153
|
+
const kbPath = path.resolve(cfg.kbPath || defaultProjectKbPath(slug));
|
|
1154
|
+
const deleteKb = parsed.deleteKb === true;
|
|
1155
|
+
let removedKb = false;
|
|
1156
|
+
let hookResult = null;
|
|
1157
|
+
if (deleteKb) {
|
|
1158
|
+
const safe = validateKbDeletionPath(kbPath);
|
|
1159
|
+
if (!safe.ok) return send(res, 400, { ok: false, error: safe.error, kbPath: safe.path || kbPath });
|
|
1160
|
+
}
|
|
1161
|
+
try {
|
|
1162
|
+
hookResult = uninstallHook({ repoPath: cfg.gitPath || cfg.localPath || '' });
|
|
1163
|
+
} catch (e) {
|
|
1164
|
+
hookResult = { ok: false, warning: e.message };
|
|
1165
|
+
}
|
|
1166
|
+
delete projects[slug];
|
|
1167
|
+
writeJson(path.join(KB_ROOT, 'projects.json'), projects);
|
|
1168
|
+
removeKnowledgeStoreProjectOverride(slug);
|
|
1169
|
+
if (deleteKb && fs.existsSync(kbPath)) {
|
|
1170
|
+
fs.rmSync(kbPath, { recursive: true, force: true });
|
|
1171
|
+
removedKb = true;
|
|
1172
|
+
}
|
|
1173
|
+
logEvent('info', 'project_removed', `Project removed: ${slug}`, {
|
|
1174
|
+
source: 'project-remove',
|
|
1175
|
+
projectSlug: slug,
|
|
1176
|
+
kbPath,
|
|
1177
|
+
deleteKb,
|
|
1178
|
+
removedKb,
|
|
1179
|
+
reason: parsed.reason || '',
|
|
1180
|
+
hookResult,
|
|
1181
|
+
});
|
|
1182
|
+
return send(res, 200, { ok: true, slug, kbPath, removedKb, hookResult });
|
|
1183
|
+
}
|
|
1184
|
+
|
|
595
1185
|
// POST /api/projects/:slug/init
|
|
596
1186
|
if (m === 'POST' && p.startsWith('/api/projects/') && p.endsWith('/init')) {
|
|
597
1187
|
const slug = p.split('/')[3];
|
|
598
1188
|
if (!isSafeSlug(slug)) return send(res, 400, { error: 'Invalid slug' });
|
|
599
1189
|
const projects = readProjects({ persistMigrations: true });
|
|
600
1190
|
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 = '
|
|
1191
|
+
const result = initProjectDirs(slug, projects[slug].kbPath || defaultProjectKbPath(slug));
|
|
1192
|
+
// Mark project as v3 after init
|
|
1193
|
+
projects[slug].kbSchemaVersion = 'v3';
|
|
604
1194
|
writeJson(path.join(KB_ROOT, 'projects.json'), projects);
|
|
605
|
-
return send(res, 200, { ok: true, ...result, kbSchemaVersion: '
|
|
1195
|
+
return send(res, 200, { ok: true, ...result, kbSchemaVersion: 'v3' });
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
// POST /api/projects/:slug/migrate-v3
|
|
1199
|
+
if (m === 'POST' && p.startsWith('/api/projects/') && p.endsWith('/migrate-v3')) {
|
|
1200
|
+
const slug = p.split('/')[3];
|
|
1201
|
+
if (!isSafeSlug(slug)) return send(res, 400, { error: 'Invalid slug' });
|
|
1202
|
+
const projects = readProjects({ persistMigrations: true });
|
|
1203
|
+
if (!projects[slug]) return send(res, 404, { error: 'Slug not in projects.json' });
|
|
1204
|
+
const result = kbV3.migrateToV3({ slug, kbPath: projects[slug].kbPath || defaultProjectKbPath(slug), preserveLegacyAI: false });
|
|
1205
|
+
projects[slug].kbSchemaVersion = 'v3';
|
|
1206
|
+
writeJson(path.join(KB_ROOT, 'projects.json'), projects);
|
|
1207
|
+
logEvent('info', 'kb_v3_migrated', 'Knowledge base migrated to v3.', { source: 'kb-v3', projectSlug: slug, kbPath: result.kbPath });
|
|
1208
|
+
return send(res, 200, { ok: true, ...result });
|
|
606
1209
|
}
|
|
607
1210
|
|
|
608
1211
|
// POST /api/projects/:slug/migrate-v2 — add v2 layout to an existing project
|
|
@@ -611,7 +1214,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
611
1214
|
if (!isSafeSlug(slug)) return send(res, 400, { error: 'Invalid slug' });
|
|
612
1215
|
const projects = readProjects({ persistMigrations: true });
|
|
613
1216
|
if (!projects[slug]) return send(res, 404, { error: 'Slug not in projects.json' });
|
|
614
|
-
const result = ensureV2Layout(slug);
|
|
1217
|
+
const result = ensureV2Layout(slug, projects[slug].kbPath || defaultProjectKbPath(slug));
|
|
615
1218
|
projects[slug].kbSchemaVersion = 'v2';
|
|
616
1219
|
writeJson(path.join(KB_ROOT, 'projects.json'), projects);
|
|
617
1220
|
return send(res, 200, { ok: true, ...result, kbSchemaVersion: 'v2' });
|
|
@@ -700,6 +1303,13 @@ const server = http.createServer(async (req, res) => {
|
|
|
700
1303
|
}
|
|
701
1304
|
|
|
702
1305
|
// POST /api/scan-all — scan every enabled project
|
|
1306
|
+
if (m === 'POST' && p.startsWith('/api/projects/') && p.endsWith('/knowledge-update')) {
|
|
1307
|
+
const slug = p.split('/')[3];
|
|
1308
|
+
if (!isSafeSlug(slug)) return send(res, 400, { error: 'Invalid slug' });
|
|
1309
|
+
const result = await runKnowledgeUpdate(slug);
|
|
1310
|
+
return send(res, result.status || (result.ok ? 200 : 500), result);
|
|
1311
|
+
}
|
|
1312
|
+
|
|
703
1313
|
if (m === 'POST' && p === '/api/scan-all') {
|
|
704
1314
|
const projects = readProjects({ persistMigrations: false });
|
|
705
1315
|
const results = [];
|
|
@@ -726,13 +1336,56 @@ const server = http.createServer(async (req, res) => {
|
|
|
726
1336
|
return send(res, 200, { ok: true });
|
|
727
1337
|
}
|
|
728
1338
|
|
|
1339
|
+
// POST /api/ai-profiles/test { profileId, prompt? }
|
|
1340
|
+
if (m === 'POST' && p === '/api/ai-profiles/test') {
|
|
1341
|
+
const body = await readBody(req).catch(() => '{}');
|
|
1342
|
+
const parsed = (() => { try { return JSON.parse(body); } catch { return {}; } })();
|
|
1343
|
+
const cfg = readAiProfiles();
|
|
1344
|
+
const profileId = parsed.profileId || cfg.defaultProfileId || DEFAULT_AI_PROFILE_ID;
|
|
1345
|
+
const profile = (cfg.profiles || []).find(item => item && item.id === profileId);
|
|
1346
|
+
if (!profile) return send(res, 404, { ok: false, error: `profile not found: ${profileId}` });
|
|
1347
|
+
const implementation = profileImplementation(profile);
|
|
1348
|
+
if (!getAdapter(implementation)) return send(res, 400, { ok: false, error: `unknown adapter: ${implementation}` });
|
|
1349
|
+
|
|
1350
|
+
const prompt = parsed.prompt || 'what model are you?';
|
|
1351
|
+
if (implementation === 'mock-agent') {
|
|
1352
|
+
return send(res, 200, {
|
|
1353
|
+
ok: true,
|
|
1354
|
+
profileId,
|
|
1355
|
+
model: 'mock-agent',
|
|
1356
|
+
text: `[mock-agent] received: ${prompt}`,
|
|
1357
|
+
usage: null,
|
|
1358
|
+
});
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1361
|
+
try {
|
|
1362
|
+
const llmCfg = readLlmConfig({ profileId });
|
|
1363
|
+
const result = await completeText({
|
|
1364
|
+
profileId,
|
|
1365
|
+
user: prompt,
|
|
1366
|
+
maxTokens: Math.min(Number(profile.testMaxTokens || 128), 512),
|
|
1367
|
+
temperature: profile.testTemperature != null ? Number(profile.testTemperature) : 0,
|
|
1368
|
+
});
|
|
1369
|
+
return send(res, 200, {
|
|
1370
|
+
ok: true,
|
|
1371
|
+
profileId,
|
|
1372
|
+
model: result.model || llmCfg.model,
|
|
1373
|
+
baseUrl: llmCfg.baseUrl,
|
|
1374
|
+
text: result.text,
|
|
1375
|
+
usage: result.usage,
|
|
1376
|
+
});
|
|
1377
|
+
} catch (e) {
|
|
1378
|
+
return send(res, 400, { ok: false, profileId, error: e.message });
|
|
1379
|
+
}
|
|
1380
|
+
}
|
|
1381
|
+
|
|
729
1382
|
// PUT /api/projects/:slug/ai-profile — set a project's AI profile
|
|
730
1383
|
if (m === 'PUT' && p.startsWith('/api/projects/') && p.endsWith('/ai-profile')) {
|
|
731
1384
|
const slug = p.split('/')[3];
|
|
732
1385
|
if (!isSafeSlug(slug)) return send(res, 400, { error: 'Invalid slug' });
|
|
733
1386
|
const body = JSON.parse(await readBody(req));
|
|
734
|
-
|
|
735
|
-
if (!
|
|
1387
|
+
const profileCheck = validateUsableAiProfile(body.aiProfileId);
|
|
1388
|
+
if (!profileCheck.ok) return send(res, profileCheck.status, { error: profileCheck.error });
|
|
736
1389
|
const projects = readProjects({ persistMigrations: true });
|
|
737
1390
|
if (!projects[slug]) return send(res, 404, { error: 'Slug not in projects.json' });
|
|
738
1391
|
projects[slug].aiProfileId = body.aiProfileId;
|
|
@@ -749,8 +1402,8 @@ const server = http.createServer(async (req, res) => {
|
|
|
749
1402
|
if (!projects[slug]) return send(res, 404, { error: 'Slug not in projects.json' });
|
|
750
1403
|
|
|
751
1404
|
if (body.aiProfileId != null) {
|
|
752
|
-
|
|
753
|
-
if (!
|
|
1405
|
+
const profileCheck = validateUsableAiProfile(body.aiProfileId);
|
|
1406
|
+
if (!profileCheck.ok) return send(res, profileCheck.status, { error: profileCheck.error });
|
|
754
1407
|
projects[slug].aiProfileId = body.aiProfileId;
|
|
755
1408
|
}
|
|
756
1409
|
|
|
@@ -795,12 +1448,47 @@ const server = http.createServer(async (req, res) => {
|
|
|
795
1448
|
}
|
|
796
1449
|
|
|
797
1450
|
// POST /api/projects/:slug/analyze/initial — run initial analysis (TASK-007)
|
|
1451
|
+
// ?mode=cli → spawn Claude Code subprocess and return sessionId for SSE streaming
|
|
798
1452
|
if (m === 'POST' && p.startsWith('/api/projects/') && p.endsWith('/analyze/initial')) {
|
|
799
1453
|
const slug = p.split('/')[3];
|
|
800
1454
|
if (!isSafeSlug(slug)) return send(res, 400, { error: 'Invalid slug' });
|
|
801
1455
|
const projects = readProjects({ persistMigrations: false });
|
|
802
1456
|
if (!projects[slug]) return send(res, 404, { error: 'Slug not in projects.json' });
|
|
803
1457
|
const kbPath = projects[slug].kbPath || defaultProjectKbPath(slug);
|
|
1458
|
+
|
|
1459
|
+
// CLI mode: kick off Claude Code subprocess, return sessionId immediately
|
|
1460
|
+
if (url.searchParams.get('mode') === 'cli') {
|
|
1461
|
+
const projectPath = projects[slug].gitPath || projects[slug].localPath;
|
|
1462
|
+
if (!projectPath) return send(res, 400, { error: 'Project has no gitPath/localPath' });
|
|
1463
|
+
const aiProfileId = projects[slug].aiProfileId || readAiProfiles().defaultProfileId || DEFAULT_AI_PROFILE_ID;
|
|
1464
|
+
const profileCheck = validateUsableAiProfile(aiProfileId);
|
|
1465
|
+
if (!profileCheck.ok) return send(res, profileCheck.status, { ok: false, error: profileCheck.error });
|
|
1466
|
+
if (profileCheck.implementation !== 'claude-code-agent') {
|
|
1467
|
+
return send(res, 400, { ok: false, error: `Claude terminal requires claude-code-agent implementation, got ${profileCheck.implementation}` });
|
|
1468
|
+
}
|
|
1469
|
+
const runner = url.searchParams.get('runner') === 'sdk' || profileCheck.profile.runner === 'sdk' ? 'sdk' : 'cli';
|
|
1470
|
+
try {
|
|
1471
|
+
const { sessionId } = claudeCliRunner.startSession({
|
|
1472
|
+
slug,
|
|
1473
|
+
projectPath,
|
|
1474
|
+
kbPath,
|
|
1475
|
+
promptKey: 'initial-analysis',
|
|
1476
|
+
aiProfile: profileCheck.profile,
|
|
1477
|
+
runner,
|
|
1478
|
+
vars: {
|
|
1479
|
+
SLUG: slug,
|
|
1480
|
+
PROJECT_PATH: projectPath,
|
|
1481
|
+
KB_PATH: kbPath,
|
|
1482
|
+
PRIMARY_LANGUAGE: projects[slug].primaryLanguage || '',
|
|
1483
|
+
KNOWLEDGE_LANGUAGE: projects[slug].knowledgeLanguage || 'zh-CN',
|
|
1484
|
+
},
|
|
1485
|
+
});
|
|
1486
|
+
return send(res, 200, { ok: true, slug, sessionId, mode: 'cli', runner, aiProfileId, pendingPermission: claudeCliRunner.getState(sessionId)?.pendingPermission || null });
|
|
1487
|
+
} catch (e) {
|
|
1488
|
+
return send(res, 500, { ok: false, error: e.message });
|
|
1489
|
+
}
|
|
1490
|
+
}
|
|
1491
|
+
|
|
804
1492
|
const result = await runInitialAnalysis({ slug, ...projects[slug], kbPath });
|
|
805
1493
|
if (!result.ok) {
|
|
806
1494
|
return send(res, result.status, { ok: false, error: result.error, runId: result.runId, run: result.runRecord });
|
|
@@ -808,6 +1496,137 @@ const server = http.createServer(async (req, res) => {
|
|
|
808
1496
|
return send(res, 200, { ok: true, slug, runId: result.runId, run: result.runRecord });
|
|
809
1497
|
}
|
|
810
1498
|
|
|
1499
|
+
// GET /api/claude/sessions — list active sessions
|
|
1500
|
+
if (m === 'GET' && p === '/api/claude/sessions') {
|
|
1501
|
+
const slug = url.searchParams.get('slug');
|
|
1502
|
+
if (slug && !isSafeSlug(slug)) return send(res, 400, { error: 'Invalid slug' });
|
|
1503
|
+
return send(res, 200, { sessions: claudeCliRunner.listSessions({ projectSlug: slug || null }) });
|
|
1504
|
+
}
|
|
1505
|
+
|
|
1506
|
+
// GET /api/claude/sessions/:id — session state
|
|
1507
|
+
if (m === 'GET' && p.startsWith('/api/claude/sessions/') && !p.includes('/')) {
|
|
1508
|
+
// skipped — path has slashes; handled by next branch
|
|
1509
|
+
}
|
|
1510
|
+
if (m === 'GET' && p.startsWith('/api/claude/sessions/') && p.split('/').length === 5) {
|
|
1511
|
+
// /api/claude/sessions/:id → parts = ['', 'api', 'claude', 'sessions', '<id>']
|
|
1512
|
+
const sessionId = p.split('/')[4];
|
|
1513
|
+
const st = claudeCliRunner.getState(sessionId);
|
|
1514
|
+
if (!st) return send(res, 404, { error: 'session not found' });
|
|
1515
|
+
return send(res, 200, st);
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1518
|
+
// GET /api/claude/sessions/:id/stream — SSE stream of events
|
|
1519
|
+
if (m === 'GET' && p.startsWith('/api/claude/sessions/') && p.endsWith('/stream')) {
|
|
1520
|
+
const sessionId = p.split('/')[4];
|
|
1521
|
+
if (!claudeCliRunner.getSession(sessionId)) return send(res, 404, { error: 'session not found' });
|
|
1522
|
+
res.writeHead(200, {
|
|
1523
|
+
'Content-Type': 'text/event-stream',
|
|
1524
|
+
'Cache-Control': 'no-store, no-transform',
|
|
1525
|
+
'Connection': 'keep-alive',
|
|
1526
|
+
'Access-Control-Allow-Origin': '*',
|
|
1527
|
+
'X-Accel-Buffering': 'no',
|
|
1528
|
+
});
|
|
1529
|
+
res.write(`event: claude/hello\ndata: ${JSON.stringify({ sessionId, time: new Date().toISOString() })}\n\n`);
|
|
1530
|
+
const unsubscribe = claudeCliRunner.subscribe(sessionId, (event) => {
|
|
1531
|
+
try {
|
|
1532
|
+
res.write(`event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`);
|
|
1533
|
+
} catch (e) {
|
|
1534
|
+
// client gone
|
|
1535
|
+
}
|
|
1536
|
+
});
|
|
1537
|
+
const heartbeat = setInterval(() => {
|
|
1538
|
+
try { res.write(`: keepalive ${Date.now()}\n\n`); } catch {}
|
|
1539
|
+
}, 15000);
|
|
1540
|
+
const cleanup = () => {
|
|
1541
|
+
clearInterval(heartbeat);
|
|
1542
|
+
try { unsubscribe(); } catch {}
|
|
1543
|
+
};
|
|
1544
|
+
req.on('close', cleanup);
|
|
1545
|
+
req.on('error', cleanup);
|
|
1546
|
+
return; // do NOT call send() — connection stays open
|
|
1547
|
+
}
|
|
1548
|
+
|
|
1549
|
+
// POST /api/claude/sessions/:id/input — send follow-up prompt (uses --resume)
|
|
1550
|
+
if (m === 'POST' && p.startsWith('/api/claude/sessions/') && p.endsWith('/input')) {
|
|
1551
|
+
const sessionId = p.split('/')[4];
|
|
1552
|
+
if (!claudeCliRunner.getSession(sessionId)) return send(res, 404, { error: 'session not found' });
|
|
1553
|
+
let body;
|
|
1554
|
+
try { body = JSON.parse(await readBody(req)); } catch { return send(res, 400, { error: 'invalid JSON body' }); }
|
|
1555
|
+
if (!body || typeof body.text !== 'string' || !body.text.trim()) {
|
|
1556
|
+
return send(res, 400, { error: 'body.text (non-empty string) required' });
|
|
1557
|
+
}
|
|
1558
|
+
try {
|
|
1559
|
+
const st = claudeCliRunner.getState(sessionId);
|
|
1560
|
+
const projects = readProjects({ persistMigrations: false });
|
|
1561
|
+
const project = st && st.projectSlug && projects[st.projectSlug] ? projects[st.projectSlug] : null;
|
|
1562
|
+
let profile = null;
|
|
1563
|
+
let runner = st && st.runner === 'sdk' ? 'sdk' : null;
|
|
1564
|
+
if (project) {
|
|
1565
|
+
const aiProfileId = project.aiProfileId || readAiProfiles().defaultProfileId || DEFAULT_AI_PROFILE_ID;
|
|
1566
|
+
const profileCheck = validateUsableAiProfile(aiProfileId);
|
|
1567
|
+
if (!profileCheck.ok) return send(res, profileCheck.status, { ok: false, error: profileCheck.error });
|
|
1568
|
+
if (profileCheck.implementation !== 'claude-code-agent') {
|
|
1569
|
+
return send(res, 400, { ok: false, error: `Claude workbench requires claude-code-agent implementation, got ${profileCheck.implementation}` });
|
|
1570
|
+
}
|
|
1571
|
+
profile = profileCheck.profile;
|
|
1572
|
+
runner = body.runner === 'sdk' || profile.runner === 'sdk' || runner === 'sdk' ? 'sdk' : 'cli';
|
|
1573
|
+
}
|
|
1574
|
+
const result = await claudeCliRunner.sendInput(sessionId, body.text, profile, runner);
|
|
1575
|
+
return send(res, 200, { ok: true, sessionId, pendingPermission: result && result.pendingPermission || null });
|
|
1576
|
+
} catch (e) {
|
|
1577
|
+
return send(res, 400, { ok: false, error: e.message });
|
|
1578
|
+
}
|
|
1579
|
+
}
|
|
1580
|
+
|
|
1581
|
+
// POST /api/claude/sessions/:id/permission
|
|
1582
|
+
if (m === 'POST' && p.startsWith('/api/claude/sessions/') && p.endsWith('/permission')) {
|
|
1583
|
+
const sessionId = p.split('/')[4];
|
|
1584
|
+
if (!claudeCliRunner.getSession(sessionId)) return send(res, 404, { error: 'session not found' });
|
|
1585
|
+
let body;
|
|
1586
|
+
try { body = JSON.parse(await readBody(req)); } catch { return send(res, 400, { error: 'invalid JSON body' }); }
|
|
1587
|
+
if (!body || typeof body.requestId !== 'string') {
|
|
1588
|
+
return send(res, 400, { error: 'body.requestId required' });
|
|
1589
|
+
}
|
|
1590
|
+
try {
|
|
1591
|
+
const result = claudeCliRunner.resolvePermission(sessionId, body.requestId, {
|
|
1592
|
+
allow: body.allow === true,
|
|
1593
|
+
message: typeof body.message === 'string' ? body.message : '',
|
|
1594
|
+
});
|
|
1595
|
+
return send(res, 200, { ok: true, sessionId, ...result });
|
|
1596
|
+
} catch (e) {
|
|
1597
|
+
return send(res, 400, { ok: false, error: e.message });
|
|
1598
|
+
}
|
|
1599
|
+
}
|
|
1600
|
+
|
|
1601
|
+
// POST /api/claude/sessions/:id/abort — terminate current subprocess
|
|
1602
|
+
if (m === 'POST' && p.startsWith('/api/claude/sessions/') && p.endsWith('/abort')) {
|
|
1603
|
+
const sessionId = p.split('/')[4];
|
|
1604
|
+
if (!claudeCliRunner.getSession(sessionId)) return send(res, 404, { error: 'session not found' });
|
|
1605
|
+
try {
|
|
1606
|
+
claudeCliRunner.abort(sessionId);
|
|
1607
|
+
return send(res, 200, { ok: true, sessionId });
|
|
1608
|
+
} catch (e) {
|
|
1609
|
+
return send(res, 400, { ok: false, error: e.message });
|
|
1610
|
+
}
|
|
1611
|
+
}
|
|
1612
|
+
|
|
1613
|
+
// GET /api/prompts — read prompt registry
|
|
1614
|
+
if (m === 'GET' && p === '/api/prompts') {
|
|
1615
|
+
return send(res, 200, promptRegistry.readPrompts());
|
|
1616
|
+
}
|
|
1617
|
+
|
|
1618
|
+
// PUT /api/prompts — write prompt registry
|
|
1619
|
+
if (m === 'PUT' && p === '/api/prompts') {
|
|
1620
|
+
let body;
|
|
1621
|
+
try { body = JSON.parse(await readBody(req)); } catch { return send(res, 400, { error: 'invalid JSON body' }); }
|
|
1622
|
+
try {
|
|
1623
|
+
const written = promptRegistry.writePrompts(body);
|
|
1624
|
+
return send(res, 200, { ok: true, prompts: written });
|
|
1625
|
+
} catch (e) {
|
|
1626
|
+
return send(res, 400, { ok: false, error: e.message });
|
|
1627
|
+
}
|
|
1628
|
+
}
|
|
1629
|
+
|
|
811
1630
|
// POST /api/projects/:slug/analyze/commits — run incremental commit analysis (TASK-008)
|
|
812
1631
|
if (m === 'POST' && p.startsWith('/api/projects/') && p.endsWith('/analyze/commits')) {
|
|
813
1632
|
const slug = p.split('/')[3];
|
|
@@ -859,6 +1678,27 @@ const server = http.createServer(async (req, res) => {
|
|
|
859
1678
|
}
|
|
860
1679
|
|
|
861
1680
|
// GET /api/projects/:slug/drafts/:runId/raw?path=... — read a single draft's text
|
|
1681
|
+
if (m === 'GET' && p.match(/^\/api\/projects\/[a-z0-9-]+\/drafts-by-branch$/)) {
|
|
1682
|
+
const slug = p.split('/')[3];
|
|
1683
|
+
if (!isSafeSlug(slug)) return send(res, 400, { error: 'Invalid slug' });
|
|
1684
|
+
const branch = url.searchParams.get('branch') || '';
|
|
1685
|
+
const status = url.searchParams.get('status') || 'pending';
|
|
1686
|
+
const projects = readProjects({ persistMigrations: false });
|
|
1687
|
+
if (!projects[slug]) return send(res, 404, { error: 'Slug not in projects.json' });
|
|
1688
|
+
const kbPath = projects[slug].kbPath || defaultProjectKbPath(slug);
|
|
1689
|
+
const runs = listRuns(kbPath);
|
|
1690
|
+
const drafts = [];
|
|
1691
|
+
for (const run of runs) {
|
|
1692
|
+
if (status === 'pending' && (run.applyStatus === 'applied' || run.applyStatus === 'rejected')) continue;
|
|
1693
|
+
const sourceBranch = run.sourceBranch ?? 'unknown (pre-TASK-016)';
|
|
1694
|
+
if (branch && sourceBranch !== branch) continue;
|
|
1695
|
+
for (const draft of listDrafts(kbPath, run.runId)) {
|
|
1696
|
+
drafts.push({ ...draft, runId: run.runId, runStatus: run.status, applyStatus: run.applyStatus || 'pending', sourceBranch });
|
|
1697
|
+
}
|
|
1698
|
+
}
|
|
1699
|
+
return send(res, 200, { ok: true, slug, branch: branch || null, status, drafts });
|
|
1700
|
+
}
|
|
1701
|
+
|
|
862
1702
|
if (m === 'GET' && p.match(/^\/api\/projects\/[a-z0-9-]+\/drafts\/[A-Za-z0-9_-]+\/raw$/)) {
|
|
863
1703
|
const parts = p.split('/');
|
|
864
1704
|
const slug = parts[3];
|
|
@@ -1023,7 +1863,9 @@ const server = http.createServer(async (req, res) => {
|
|
|
1023
1863
|
if (m === 'GET' && p.startsWith('/api/dirs/')) {
|
|
1024
1864
|
const slug = p.split('/')[3];
|
|
1025
1865
|
if (!isSafeSlug(slug)) return send(res, 400, { error: 'Invalid slug' });
|
|
1026
|
-
const
|
|
1866
|
+
const projects = readProjects({ persistMigrations: false });
|
|
1867
|
+
if (!projects[slug]) return send(res, 404, { error: 'Slug not in projects.json' });
|
|
1868
|
+
const kbPath = projects[slug].kbPath || defaultProjectKbPath(slug);
|
|
1027
1869
|
if (!fs.existsSync(kbPath)) return send(res, 404, { error: 'Project KB not initialized' });
|
|
1028
1870
|
return send(res, 200, listSubTree(kbPath, '', 0, 3));
|
|
1029
1871
|
}
|
|
@@ -1044,6 +1886,21 @@ const server = http.createServer(async (req, res) => {
|
|
|
1044
1886
|
return send(res, 200, { type: 'file', content: buf.toString('utf-8'), size: stat.size }, 'text/plain');
|
|
1045
1887
|
}
|
|
1046
1888
|
|
|
1889
|
+
// Static vendor assets (Vue / Tailwind browser bundles)
|
|
1890
|
+
if (m === 'GET' && p.startsWith('/vendor/')) {
|
|
1891
|
+
const rel = decodeURIComponent(p.slice('/vendor/'.length));
|
|
1892
|
+
if (rel.includes('..') || path.isAbsolute(rel)) return send(res, 400, { error: 'bad path' });
|
|
1893
|
+
const vendorRoot = path.join(SITE_ROOT, 'vendor');
|
|
1894
|
+
const abs = path.resolve(vendorRoot, rel);
|
|
1895
|
+
if (!abs.startsWith(vendorRoot + path.sep)) return send(res, 403, { error: 'outside vendor' });
|
|
1896
|
+
if (!fs.existsSync(abs) || !fs.statSync(abs).isFile()) return send(res, 404, { error: 'not found' });
|
|
1897
|
+
const text = fs.readFileSync(abs, 'utf-8');
|
|
1898
|
+
const ct = abs.endsWith('.js') ? 'application/javascript'
|
|
1899
|
+
: abs.endsWith('.css') ? 'text/css'
|
|
1900
|
+
: 'application/octet-stream';
|
|
1901
|
+
return send(res, 200, text, ct);
|
|
1902
|
+
}
|
|
1903
|
+
|
|
1047
1904
|
send(res, 404, { error: 'Not found', path: p });
|
|
1048
1905
|
} catch (e) {
|
|
1049
1906
|
console.error('[server error]', e);
|