septum 0.1.7 → 0.1.8
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/README.md +36 -55
- package/package.json +3 -2
- package/src/cli/commands/check.ts +2 -0
- package/src/cli/commands/hook.ts +59 -12
- package/src/cli/commands/init.ts +440 -121
- package/src/cli/commands/sync.ts +27 -17
- package/src/cli/commands/topology.ts +89 -47
- package/src/cli/index.ts +5 -67
- package/src/cli/scaffold/harness-config.ts +697 -0
- package/src/cli/templates/hooks.ts +6 -6
- package/src/cli/templates/rules.ts +8 -8
- package/src/cli/templates/skill.ts +11 -18
- package/src/cli/ui/prompts.ts +122 -0
- package/src/core/boundary/staged-auditor.ts +5 -2
- package/src/core/database/client.ts +8 -0
- package/src/core/database/repositories/dependency.repository.ts +77 -13
- package/src/core/database/repositories/domain.repository.ts +86 -33
- package/src/core/database/repositories/symbol.repository.ts +128 -117
- package/src/core/database/repository.ts +17 -5
- package/src/core/database/schema.ts +1 -1
- package/src/core/discovery/topology-detector.ts +71 -19
- package/src/core/ingestion/pipeline.ts +228 -94
- package/src/core/parser/extractors/laravel-semantic.ts +9 -8
- package/src/core/parser/extractors/mcp-cli-semantic.ts +56 -16
- package/src/core/parser/extractors/nestjs-semantic.ts +3 -2
- package/src/core/parser/schema/providers/laravel-migration-provider.ts +4 -3
- package/src/core/parser/schema/providers/prisma-schema-provider.ts +2 -1
- package/src/core/resolver/call-graph-tracer.ts +4 -3
- package/src/core/resolver/path-resolver.ts +57 -4
- package/src/core/resources/ignore.ts +35 -0
- package/src/core/resources/limits.ts +14 -0
- package/src/core/resources/source-files.ts +62 -0
- package/src/core/resources/work-queue.ts +74 -0
- package/src/core/session/session-manager.ts +19 -6
- package/src/core/telemetry/telemetry.ts +12 -1
- package/src/core/topology/environment-detector.ts +21 -11
- package/src/mcp/schemas.ts +381 -8
- package/src/mcp/server.ts +321 -535
- package/src/mcp/tools/boundary.ts +17 -0
- package/src/mcp/tools/check-boundary.ts +83 -11
- package/src/mcp/tools/domain.ts +40 -0
- package/src/mcp/tools/feature.ts +67 -0
- package/src/mcp/tools/get-domain-catalog.ts +9 -6
- package/src/mcp/tools/get-environment-topology.ts +2 -2
- package/src/mcp/tools/get-feature-context.ts +9 -12
- package/src/mcp/tools/get-symbol-impact.ts +5 -2
- package/src/mcp/tools/get-symbol.ts +2 -1
- package/src/mcp/tools/locate-symbol.ts +3 -3
- package/src/mcp/tools/register-domain.ts +25 -9
- package/src/mcp/tools/register-feature.ts +34 -17
- package/src/mcp/tools/slice.ts +15 -0
- package/src/mcp/tools/symbol.ts +82 -0
- package/src/mcp/workspace-manager.ts +159 -0
- package/src/mcp/workspace-watcher.ts +202 -0
- package/src/types/index.ts +1 -0
- package/src/cli/commands/ingest.ts +0 -29
- package/src/cli/commands/locate.ts +0 -61
- package/src/cli/commands/query.ts +0 -104
- package/src/cli/commands/slice.ts +0 -50
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { readOptionalSourceFile } from "../../resources/source-files.ts";
|
|
1
2
|
import * as fs from "node:fs";
|
|
2
3
|
import * as path from "node:path";
|
|
3
4
|
import type { SemanticSliceExtractor, VerticalSliceCandidate } from "./semantic-extractor.interface.ts";
|
|
@@ -14,7 +15,7 @@ export class NestJsSemanticExtractor implements SemanticSliceExtractor {
|
|
|
14
15
|
const pkgPath = path.join(projectRoot, "package.json");
|
|
15
16
|
if (fs.existsSync(pkgPath)) {
|
|
16
17
|
try {
|
|
17
|
-
const pkg = JSON.parse(
|
|
18
|
+
const pkg = JSON.parse(readOptionalSourceFile(pkgPath, "utf-8"));
|
|
18
19
|
const deps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
19
20
|
if (deps["@nestjs/core"]) return true;
|
|
20
21
|
} catch {}
|
|
@@ -32,7 +33,7 @@ export class NestJsSemanticExtractor implements SemanticSliceExtractor {
|
|
|
32
33
|
|
|
33
34
|
for (const file of controllerFiles) {
|
|
34
35
|
const relPath = path.relative(projectRoot, file).replace(/\\/g, "/");
|
|
35
|
-
const content =
|
|
36
|
+
const content = readOptionalSourceFile(file, "utf-8");
|
|
36
37
|
|
|
37
38
|
// Extract @Controller('prefix')
|
|
38
39
|
const controllerMatch = /@Controller\s*\(\s*['"]?([^'")]*)['"]?\s*\)[\s\S]*?export\s+class\s+([A-Za-z0-9_]+)/.exec(
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { readOptionalSourceFile } from "../../../resources/source-files.ts";
|
|
1
2
|
import * as fs from "node:fs";
|
|
2
3
|
import * as path from "node:path";
|
|
3
4
|
import type {
|
|
@@ -35,7 +36,7 @@ export class LaravelMigrationSchemaProvider implements StackSchemaProvider {
|
|
|
35
36
|
for (const migFile of migrationFiles) {
|
|
36
37
|
const fullPath = path.join(migrationsDir, migFile);
|
|
37
38
|
try {
|
|
38
|
-
const content =
|
|
39
|
+
const content = readOptionalSourceFile(fullPath, "utf-8");
|
|
39
40
|
this.extractColumnsFromMigration(content, tableName, fieldsMap);
|
|
40
41
|
} catch {
|
|
41
42
|
// Skip unreadable files
|
|
@@ -66,7 +67,7 @@ export class LaravelMigrationSchemaProvider implements StackSchemaProvider {
|
|
|
66
67
|
: path.join(projectRoot, entityFilePath);
|
|
67
68
|
if (fs.existsSync(fullEntityPath)) {
|
|
68
69
|
try {
|
|
69
|
-
const content =
|
|
70
|
+
const content = readOptionalSourceFile(fullEntityPath, "utf-8");
|
|
70
71
|
const tableMatch = /protected\s+\$table\s*=\s*['"]([^'"]+)['"]/.exec(content);
|
|
71
72
|
if (tableMatch) {
|
|
72
73
|
return tableMatch[1];
|
|
@@ -120,7 +121,7 @@ export class LaravelMigrationSchemaProvider implements StackSchemaProvider {
|
|
|
120
121
|
if (matched.length === 0) {
|
|
121
122
|
for (const f of files) {
|
|
122
123
|
try {
|
|
123
|
-
const content =
|
|
124
|
+
const content = readOptionalSourceFile(path.join(migrationsDir, f), "utf-8");
|
|
124
125
|
if (
|
|
125
126
|
content.includes(`Schema::create('${tableName}'`) ||
|
|
126
127
|
content.includes(`Schema::create("${tableName}"`) ||
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { readOptionalSourceFile } from "../../../resources/source-files.ts";
|
|
1
2
|
import * as fs from "node:fs";
|
|
2
3
|
import * as path from "node:path";
|
|
3
4
|
import type {
|
|
@@ -38,7 +39,7 @@ export class PrismaSchemaProvider implements StackSchemaProvider {
|
|
|
38
39
|
if (!schemaPath) return null;
|
|
39
40
|
|
|
40
41
|
try {
|
|
41
|
-
const content =
|
|
42
|
+
const content = readOptionalSourceFile(schemaPath, "utf-8");
|
|
42
43
|
const modelRegex = new RegExp(`model\\s+${entitySymbol}\\s*\\{([^}]+)\\}`, "m");
|
|
43
44
|
const match = modelRegex.exec(content);
|
|
44
45
|
if (!match) return null;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { readOptionalSourceFile } from "../resources/source-files.ts";
|
|
1
2
|
import { existsSync, readFileSync } from "node:fs";
|
|
2
3
|
import { dirname, join, normalize, relative, resolve } from "node:path";
|
|
3
4
|
import type {
|
|
@@ -86,7 +87,7 @@ export class CallGraphTracer {
|
|
|
86
87
|
if (!existsSync(fullPath)) continue;
|
|
87
88
|
|
|
88
89
|
try {
|
|
89
|
-
const content =
|
|
90
|
+
const content = readOptionalSourceFile(fullPath, "utf-8");
|
|
90
91
|
const lines = content.split("\n");
|
|
91
92
|
|
|
92
93
|
for (let i = 0; i < lines.length; i++) {
|
|
@@ -414,7 +415,7 @@ export class CallGraphTracer {
|
|
|
414
415
|
fileSymbols[0];
|
|
415
416
|
|
|
416
417
|
const symbolDisplay = mainSymbol
|
|
417
|
-
?
|
|
418
|
+
? mainSymbol.name
|
|
418
419
|
: resolvedFile.path.split("/").pop()?.replace(/\.[^.]+$/, "") || "Module";
|
|
419
420
|
|
|
420
421
|
// Prevent adding duplicate stages if already present, or add as refinement
|
|
@@ -428,7 +429,7 @@ export class CallGraphTracer {
|
|
|
428
429
|
symbol: symbolDisplay,
|
|
429
430
|
file: resolvedFile.path,
|
|
430
431
|
line: mainSymbol?.line_start || 1,
|
|
431
|
-
description: `${stage.toUpperCase()}: ${symbolDisplay}`,
|
|
432
|
+
description: `${stage.toUpperCase()} (dependency): ${symbolDisplay}`,
|
|
432
433
|
});
|
|
433
434
|
}
|
|
434
435
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { existsSync, statSync } from "node:fs";
|
|
2
|
-
import { dirname, isAbsolute, join, normalize, resolve } from "node:path";
|
|
1
|
+
import { existsSync, statSync, realpathSync } from "node:fs";
|
|
2
|
+
import { dirname, isAbsolute, join, normalize, relative, resolve } from "node:path";
|
|
3
3
|
|
|
4
4
|
export const PROJECT_ROOT_MARKERS = [
|
|
5
5
|
"package.json",
|
|
@@ -26,6 +26,7 @@ export function canonicalizePath(targetPath: string): string {
|
|
|
26
26
|
return targetPath;
|
|
27
27
|
}
|
|
28
28
|
let abs = normalize(resolve(targetPath));
|
|
29
|
+
try { if (existsSync(abs)) abs = realpathSync(abs); } catch { /* Keep lexical paths for missing or inaccessible files. */ }
|
|
29
30
|
if (process.platform === "win32") {
|
|
30
31
|
abs = abs.replace(/^[a-zA-Z]:/, (m) => m.toUpperCase());
|
|
31
32
|
}
|
|
@@ -104,7 +105,7 @@ export function findProjectRoot(fromPath: string): string {
|
|
|
104
105
|
// Filesystem traversal error fallback
|
|
105
106
|
}
|
|
106
107
|
|
|
107
|
-
return canonicalizePath(dirname(resolve(fromPath)));
|
|
108
|
+
return canonicalizePath(existsSync(fromPath) && statSync(fromPath).isDirectory() ? fromPath : dirname(resolve(fromPath)));
|
|
108
109
|
}
|
|
109
110
|
|
|
110
111
|
/**
|
|
@@ -117,7 +118,8 @@ export function findProjectRoot(fromPath: string): string {
|
|
|
117
118
|
export function resolveWorkspaceRoot(pathHint?: string): string {
|
|
118
119
|
if (pathHint && typeof pathHint === "string" && pathHint.trim()) {
|
|
119
120
|
const trimmed = pathHint.trim().replace(/^['"]|['"]$/g, "");
|
|
120
|
-
const
|
|
121
|
+
const absHint = resolve(trimmed);
|
|
122
|
+
const candidate = existsSync(absHint) && statSync(absHint).isDirectory() ? canonicalizePath(absHint) : findProjectRoot(trimmed);
|
|
121
123
|
if (candidate && existsSync(candidate) && !isAppHostDirectory(candidate)) {
|
|
122
124
|
setLastKnownProjectRoot(candidate);
|
|
123
125
|
return candidate;
|
|
@@ -145,3 +147,54 @@ export function resolveWorkspaceRoot(pathHint?: string): string {
|
|
|
145
147
|
// Fallback if inside app host directory: keep looking in lastKnown or return cwd
|
|
146
148
|
return canonicalizePath(cwd);
|
|
147
149
|
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Verifies that candidatePath is strictly contained within workspaceRoot.
|
|
153
|
+
* Resolves symlinks (realpath) if the path exists, and checks ancestor realpath if not yet created.
|
|
154
|
+
*/
|
|
155
|
+
export function isPathContained(candidatePath: string, workspaceRoot: string): boolean {
|
|
156
|
+
if (!candidatePath || !workspaceRoot) return false;
|
|
157
|
+
try {
|
|
158
|
+
const canonicalWorkspace = canonicalizePath(workspaceRoot);
|
|
159
|
+
const resolvedTarget = isAbsolute(candidatePath) ? normalize(candidatePath) : resolve(canonicalWorkspace, candidatePath);
|
|
160
|
+
|
|
161
|
+
// Lexical containment
|
|
162
|
+
const relLexical = relative(canonicalWorkspace, resolvedTarget).replace(/\\/g, "/");
|
|
163
|
+
if (relLexical === ".." || relLexical.startsWith("../") || isAbsolute(relLexical)) {
|
|
164
|
+
return false;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Realpath / symlink containment
|
|
168
|
+
if (existsSync(resolvedTarget)) {
|
|
169
|
+
const realTarget = realpathSync(resolvedTarget);
|
|
170
|
+
const relReal = relative(canonicalWorkspace, realTarget).replace(/\\/g, "/");
|
|
171
|
+
if (relReal === ".." || relReal.startsWith("../") || isAbsolute(relReal)) {
|
|
172
|
+
return false;
|
|
173
|
+
}
|
|
174
|
+
} else {
|
|
175
|
+
let cur = dirname(resolvedTarget);
|
|
176
|
+
while (!existsSync(cur)) {
|
|
177
|
+
const parent = dirname(cur);
|
|
178
|
+
if (parent === cur) break;
|
|
179
|
+
cur = parent;
|
|
180
|
+
}
|
|
181
|
+
if (existsSync(cur)) {
|
|
182
|
+
const realCur = realpathSync(cur);
|
|
183
|
+
const relCur = relative(canonicalWorkspace, realCur).replace(/\\/g, "/");
|
|
184
|
+
if (relCur === ".." || relCur.startsWith("../") || isAbsolute(relCur)) {
|
|
185
|
+
return false;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return true;
|
|
191
|
+
} catch {
|
|
192
|
+
return false;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export function assertPathContained(candidatePath: string, workspaceRoot: string, contextDescription = "Path"): void {
|
|
197
|
+
if (!isPathContained(candidatePath, workspaceRoot)) {
|
|
198
|
+
throw new Error(`${contextDescription} '${candidatePath}' escapes workspace boundary '${workspaceRoot}'.`);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export function shouldIgnoreSourcePath(filePath: string, ignorePatterns: string[]): boolean {
|
|
2
|
+
if (filePath.replace(/\\/g, "/").split("/").some(segment => ["node_modules", "vendor", ".git", ".septum", ".strata", ".codegraph", "dist", "build", ".output", ".nuxt", ".next", ".cache", "coverage", ".turbo"].includes(segment))) return true;
|
|
3
|
+
const normPath = filePath.replace(/\\/g, "/").replace(/^\.\//, "");
|
|
4
|
+
for (const pattern of ignorePatterns) {
|
|
5
|
+
const normPattern = pattern.replace(/\\/g, "/").replace(/^\.\//, "").trim();
|
|
6
|
+
if (!normPattern) continue;
|
|
7
|
+
|
|
8
|
+
if (normPattern === "*" || normPattern === "**") {
|
|
9
|
+
return true;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
if (normPath === normPattern) return true;
|
|
13
|
+
|
|
14
|
+
if (normPattern.endsWith("/**")) {
|
|
15
|
+
const prefix = normPattern.slice(0, -3);
|
|
16
|
+
if (normPath === prefix || normPath.startsWith(prefix + "/")) return true;
|
|
17
|
+
} else if (normPattern.endsWith("/*")) {
|
|
18
|
+
const prefix = normPattern.slice(0, -2);
|
|
19
|
+
if (normPath === prefix || normPath.startsWith(prefix + "/")) return true;
|
|
20
|
+
} else if (normPath.startsWith(normPattern + "/")) {
|
|
21
|
+
return true;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (normPattern.startsWith("*.") && normPath.endsWith(normPattern.slice(1))) {
|
|
25
|
+
return true;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const segments = normPath.split("/");
|
|
29
|
+
if (segments.includes(normPattern)) {
|
|
30
|
+
return true;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export const RESOURCE_LIMITS = Object.freeze({
|
|
2
|
+
workspaces: 4,
|
|
3
|
+
workspaceIdleMs: 10 * 60_000,
|
|
4
|
+
watches: 2048,
|
|
5
|
+
pendingPaths: 1000,
|
|
6
|
+
debounceMs: 300,
|
|
7
|
+
pollMs: 5000,
|
|
8
|
+
verifyMs: 60_000,
|
|
9
|
+
sourceBytes: 2 * 1024 * 1024,
|
|
10
|
+
batchFiles: 50,
|
|
11
|
+
batchBytes: 1024 * 1024,
|
|
12
|
+
maxQueuePending: 50,
|
|
13
|
+
shutdownMs: 5000,
|
|
14
|
+
});
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
2
|
+
import { closeSync, fstatSync, openSync, readSync, statSync } from 'node:fs';
|
|
3
|
+
import { relative } from 'node:path';
|
|
4
|
+
import { computeContentHash } from "../ingestion/hasher.ts";
|
|
5
|
+
import { RESOURCE_LIMITS } from './limits.ts';
|
|
6
|
+
|
|
7
|
+
export interface SkippedSourceFile { path: string; reason: 'file_too_large' }
|
|
8
|
+
export class SourceFileLimitError extends Error {
|
|
9
|
+
constructor(public readonly filePath: string) { super(`Source file exceeds ${RESOURCE_LIMITS.sourceBytes} bytes: ${filePath}`); }
|
|
10
|
+
}
|
|
11
|
+
export interface SourceReadFingerprint { path: string; hash: string; size: number; mtime: number }
|
|
12
|
+
const reads = new AsyncLocalStorage<{skipped: Map<string, SkippedSourceFile>; inputs: Map<string, SourceReadFingerprint>}>();
|
|
13
|
+
|
|
14
|
+
// Read at most the admission limit, even if the file grows after stat.
|
|
15
|
+
export function readSourceFile(filePath: string, _encoding?: string): string {
|
|
16
|
+
const fd = openSync(filePath, 'r');
|
|
17
|
+
try {
|
|
18
|
+
const size = fstatSync(fd).size;
|
|
19
|
+
if (size > RESOURCE_LIMITS.sourceBytes) throw new SourceFileLimitError(filePath);
|
|
20
|
+
let buffer = Buffer.allocUnsafe(Math.min(Math.max(size + 1, 4096), RESOURCE_LIMITS.sourceBytes + 1));
|
|
21
|
+
let count = 0;
|
|
22
|
+
while (true) {
|
|
23
|
+
const read = readSync(fd, buffer, count, buffer.length - count, null);
|
|
24
|
+
if (!read) return buffer.toString('utf8', 0, count);
|
|
25
|
+
count += read;
|
|
26
|
+
if (count > RESOURCE_LIMITS.sourceBytes) throw new SourceFileLimitError(filePath);
|
|
27
|
+
if (count === buffer.length) {
|
|
28
|
+
const expanded = Buffer.allocUnsafe(Math.min(buffer.length * 2, RESOURCE_LIMITS.sourceBytes + 1));
|
|
29
|
+
buffer.copy(expanded, 0, 0, count);
|
|
30
|
+
buffer = expanded;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
} finally { closeSync(fd); }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function readOptionalSourceFile(filePath: string, encoding?: string): string {
|
|
37
|
+
try {
|
|
38
|
+
const content = readSourceFile(filePath, encoding);
|
|
39
|
+
const collector = reads.getStore();
|
|
40
|
+
if (collector) {
|
|
41
|
+
const stat = statSync(filePath);
|
|
42
|
+
collector.inputs.set(filePath, {path:filePath,hash:computeContentHash(content),size:stat.size,mtime:stat.mtimeMs});
|
|
43
|
+
}
|
|
44
|
+
return content;
|
|
45
|
+
}
|
|
46
|
+
catch (error) {
|
|
47
|
+
if (!(error instanceof SourceFileLimitError)) throw error;
|
|
48
|
+
const collector = reads.getStore();
|
|
49
|
+
if (!collector) throw error;
|
|
50
|
+
collector.skipped.set(filePath, { path: filePath, reason: 'file_too_large' });
|
|
51
|
+
const stat = statSync(filePath);
|
|
52
|
+
collector.inputs.set(filePath, {path:filePath,hash:'file_too_large',size:stat.size,mtime:stat.mtimeMs});
|
|
53
|
+
return '';
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export async function collectSourceSkips<T>(root: string, read: () => Promise<T>): Promise<{ value: T; skipped: SkippedSourceFile[]; inputs: SourceReadFingerprint[] }> {
|
|
58
|
+
const collector = {skipped: new Map<string, SkippedSourceFile>(), inputs: new Map<string, SourceReadFingerprint>()};
|
|
59
|
+
const value = await reads.run(collector, read);
|
|
60
|
+
const rel = (file: {path:string}) => relative(root, file.path).replace(/\\/g, '/');
|
|
61
|
+
return {value, skipped:[...collector.skipped.values()].map(file => ({...file,path:rel(file)})), inputs:[...collector.inputs.values()].map(file => ({...file,path:rel(file)}))};
|
|
62
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { RESOURCE_LIMITS } from "./limits.ts";
|
|
2
|
+
|
|
3
|
+
/** One mutation/parse lane per server; failures do not poison later work. */
|
|
4
|
+
export class SerialWorkQueue {
|
|
5
|
+
private tail: Promise<void> = Promise.resolve();
|
|
6
|
+
private pending = 0;
|
|
7
|
+
private readonly maxPending: number;
|
|
8
|
+
|
|
9
|
+
constructor(maxPending = RESOURCE_LIMITS.maxQueuePending) {
|
|
10
|
+
this.maxPending = maxPending;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
get pendingCount(): number {
|
|
14
|
+
return this.pending;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
run<T>(work: () => Promise<T>, signal?: AbortSignal): Promise<T> {
|
|
18
|
+
if (signal?.aborted) {
|
|
19
|
+
return Promise.reject(signal.reason ?? new Error("Request was cancelled before queueing"));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (this.pending >= this.maxPending) {
|
|
23
|
+
const busyError = new Error(`Server is busy: request admission limit (${this.maxPending}) exceeded. Retry after current tasks finish.`);
|
|
24
|
+
(busyError as any).code = "SERVER_BUSY";
|
|
25
|
+
return Promise.reject(busyError);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
this.pending++;
|
|
29
|
+
|
|
30
|
+
return new Promise<T>((resolve, reject) => {
|
|
31
|
+
let cancelled = false;
|
|
32
|
+
|
|
33
|
+
const abortHandler = () => {
|
|
34
|
+
cancelled = true;
|
|
35
|
+
reject(signal?.reason ?? new Error("Request was cancelled while queued"));
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
if (signal) {
|
|
39
|
+
signal.addEventListener("abort", abortHandler, { once: true });
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const execute = async () => {
|
|
43
|
+
if (signal) {
|
|
44
|
+
signal.removeEventListener("abort", abortHandler);
|
|
45
|
+
}
|
|
46
|
+
if (cancelled || signal?.aborted) {
|
|
47
|
+
if (!cancelled) {
|
|
48
|
+
reject(signal?.reason ?? new Error("Request was cancelled before execution"));
|
|
49
|
+
}
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
try {
|
|
54
|
+
const res = await work();
|
|
55
|
+
resolve(res);
|
|
56
|
+
} catch (err) {
|
|
57
|
+
reject(err);
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const next = this.tail.then(execute, execute);
|
|
62
|
+
this.tail = next.finally(() => {
|
|
63
|
+
this.pending--;
|
|
64
|
+
if (this.pending === 0) {
|
|
65
|
+
this.tail = Promise.resolve();
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
drain(): Promise<void> {
|
|
72
|
+
return this.tail;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { randomUUID } from "node:crypto";
|
|
4
|
+
import type { SeptumRepository } from "../database/repository.ts";
|
|
4
5
|
import type { ActiveFeatureSession } from "../../types/index.ts";
|
|
5
6
|
import { SeptumDatabase } from "../database/client.ts";
|
|
6
7
|
|
|
@@ -11,7 +12,8 @@ export class SessionManager {
|
|
|
11
12
|
return process.env.SEPTUM_SESSION_ID || `process:${process.pid}`;
|
|
12
13
|
}
|
|
13
14
|
|
|
14
|
-
private static mutate<T>(workspaceRoot: string, fn: () => T): T {
|
|
15
|
+
private static mutate<T>(workspaceRoot: string, fn: () => T, repo?: SeptumRepository): T {
|
|
16
|
+
if (repo) return repo.runInTransaction(fn, true);
|
|
15
17
|
const db = new SeptumDatabase(join(workspaceRoot, ".septum/septum.db"));
|
|
16
18
|
try {
|
|
17
19
|
// Serialize ownership checks with writes across CLI and MCP processes.
|
|
@@ -24,7 +26,8 @@ export class SessionManager {
|
|
|
24
26
|
public static saveActiveSession(
|
|
25
27
|
workspaceRoot: string,
|
|
26
28
|
session: ActiveFeatureSession,
|
|
27
|
-
ownerId: string = SessionManager.getOwnerId()
|
|
29
|
+
ownerId: string = SessionManager.getOwnerId(),
|
|
30
|
+
persistence?: { repo: SeptumRepository; save: () => void }
|
|
28
31
|
): void {
|
|
29
32
|
SessionManager.mutate(workspaceRoot, () => {
|
|
30
33
|
const current = SessionManager.getActiveSession(workspaceRoot);
|
|
@@ -37,18 +40,28 @@ export class SessionManager {
|
|
|
37
40
|
const temporaryPath = `${sessionPath}.${randomUUID()}.tmp`;
|
|
38
41
|
try {
|
|
39
42
|
writeFileSync(temporaryPath, JSON.stringify({ ...session, owner_id: ownerId }, null, 2), "utf-8");
|
|
43
|
+
// Metadata and ownership use one write lock; a failed rename rolls back SQLite.
|
|
44
|
+
persistence?.save();
|
|
40
45
|
renameSync(temporaryPath, sessionPath);
|
|
41
46
|
} finally {
|
|
42
47
|
rmSync(temporaryPath, { force: true });
|
|
43
48
|
}
|
|
44
|
-
});
|
|
49
|
+
}, persistence?.repo);
|
|
45
50
|
}
|
|
46
51
|
|
|
47
52
|
public static getActiveSession(workspaceRoot: string): ActiveFeatureSession | null {
|
|
48
53
|
const sessionPath = join(workspaceRoot, SessionManager.SESSION_FILE);
|
|
49
54
|
if (!existsSync(sessionPath)) return null;
|
|
50
|
-
|
|
51
|
-
|
|
55
|
+
let parsed: any;
|
|
56
|
+
try {
|
|
57
|
+
parsed = JSON.parse(readFileSync(sessionPath, "utf-8")) as ActiveFeatureSession;
|
|
58
|
+
} catch (e: any) {
|
|
59
|
+
throw new Error(`Corrupted active feature session JSON in '${sessionPath}': ${e.message}. Release it explicitly with 'septum feature clear --force' or 'septum_feature({action: "release", force: true})'.`);
|
|
60
|
+
}
|
|
61
|
+
if (parsed && typeof parsed.feature_key === "string" && parsed.feature_key.trim() &&
|
|
62
|
+
typeof parsed.domain === "string" && parsed.domain.trim() &&
|
|
63
|
+
Array.isArray(parsed.touchpoints) && parsed.touchpoints.every((tp: unknown) => typeof tp === "string") &&
|
|
64
|
+
(parsed.owner_id === undefined || typeof parsed.owner_id === "string")) return parsed;
|
|
52
65
|
throw new Error(`Invalid active feature session in '${sessionPath}'. Release it explicitly with 'septum feature clear --force'.`);
|
|
53
66
|
}
|
|
54
67
|
|
|
@@ -65,7 +78,7 @@ export class SessionManager {
|
|
|
65
78
|
const current = SessionManager.getActiveSession(workspaceRoot);
|
|
66
79
|
if (current?.owner_id !== ownerId) throw new Error("Only the active session owner can release it. Use 'septum feature clear --force' for explicit recovery.");
|
|
67
80
|
}
|
|
68
|
-
rmSync(sessionPath);
|
|
81
|
+
rmSync(sessionPath, { force: true });
|
|
69
82
|
return true;
|
|
70
83
|
});
|
|
71
84
|
}
|
|
@@ -56,13 +56,24 @@ export class SeptumTelemetry {
|
|
|
56
56
|
console.error(`[Septum Telemetry SLOW_WARN] ${jsonLine}`);
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
+
this.appendLog(jsonLine, workspaceRoot);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
public static recordMemorySample(metrics: Record<string, number>, workspaceRoot: string = process.cwd()): void {
|
|
63
|
+
const jsonLine = JSON.stringify({timestamp: new Date().toISOString(), event: "memory_sample", metrics});
|
|
64
|
+
console.error(`[Septum Memory] ${jsonLine}`);
|
|
65
|
+
this.appendLog(jsonLine, workspaceRoot);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
private static appendLog(jsonLine: string, workspaceRoot: string): void {
|
|
59
69
|
// Sink 2: Append-only persistent file recorder in .septum/septum.log
|
|
60
70
|
try {
|
|
61
71
|
const logPath = this.getLogPath(workspaceRoot);
|
|
62
72
|
const logDir = dirname(logPath);
|
|
63
73
|
|
|
74
|
+
// SV-03: Do not auto-scaffold .septum directory for telemetry if workspace has not been initialized
|
|
64
75
|
if (!existsSync(logDir)) {
|
|
65
|
-
|
|
76
|
+
return;
|
|
66
77
|
}
|
|
67
78
|
|
|
68
79
|
// Check for rotation if file exceeds 2MB
|
|
@@ -18,8 +18,8 @@ export class EnvironmentDetector {
|
|
|
18
18
|
): EnvironmentTopology {
|
|
19
19
|
// 1. Fetch any previously verified layers and nodes from SQLite SSOT
|
|
20
20
|
const savedLayers: Partial<Record<EnvironmentLayer, EnvironmentLayerInfo>> =
|
|
21
|
-
repo
|
|
22
|
-
const savedNodes = repo
|
|
21
|
+
repo ? repo.environment.getAllLayers() : {};
|
|
22
|
+
const savedNodes = repo ? repo.environment.getAllNodes() : [];
|
|
23
23
|
const savedNodeMap = new Map(savedNodes.map((n) => [n.id, n]));
|
|
24
24
|
|
|
25
25
|
// 2. Perform root file-based heuristic inspection for each of the 6 layers
|
|
@@ -31,19 +31,28 @@ export class EnvironmentDetector {
|
|
|
31
31
|
const detectedTelemetry = this.inspectTelemetryLayer(workspaceRoot);
|
|
32
32
|
|
|
33
33
|
// 3. Merge layers: Database verified layers take precedence
|
|
34
|
+
const resolveLayer = (layerKey: EnvironmentLayer, detected: EnvironmentLayerInfo): EnvironmentLayerInfo => {
|
|
35
|
+
const saved = savedLayers[layerKey];
|
|
36
|
+
if (saved && saved.status === "VERIFIED") {
|
|
37
|
+
return saved;
|
|
38
|
+
}
|
|
39
|
+
return detected;
|
|
40
|
+
};
|
|
41
|
+
|
|
34
42
|
const layers: Record<EnvironmentLayer, EnvironmentLayerInfo> = {
|
|
35
|
-
edge:
|
|
36
|
-
gateway:
|
|
37
|
-
host:
|
|
38
|
-
runtime:
|
|
39
|
-
storage:
|
|
40
|
-
telemetry:
|
|
43
|
+
edge: resolveLayer("edge", detectedEdge),
|
|
44
|
+
gateway: resolveLayer("gateway", detectedGateway),
|
|
45
|
+
host: resolveLayer("host", detectedHost),
|
|
46
|
+
runtime: resolveLayer("runtime", detectedRuntime),
|
|
47
|
+
storage: resolveLayer("storage", detectedStorage),
|
|
48
|
+
telemetry: resolveLayer("telemetry", detectedTelemetry),
|
|
41
49
|
};
|
|
42
50
|
|
|
43
|
-
// 4. Save layers to SQLite if not already stored
|
|
51
|
+
// 4. Save layers to SQLite if not already stored (refresh only non-VERIFIED layers)
|
|
44
52
|
if (repo) {
|
|
45
53
|
for (const layerKey of Object.keys(layers) as EnvironmentLayer[]) {
|
|
46
|
-
|
|
54
|
+
const saved = savedLayers[layerKey];
|
|
55
|
+
if (!saved || (forceRefresh && saved.status !== "VERIFIED")) {
|
|
47
56
|
repo.environment.saveLayer(layers[layerKey]);
|
|
48
57
|
}
|
|
49
58
|
}
|
|
@@ -231,7 +240,8 @@ export class EnvironmentDetector {
|
|
|
231
240
|
// 7. Save detected nodes to SQLite SSOT
|
|
232
241
|
if (repo) {
|
|
233
242
|
for (const node of finalNodes) {
|
|
234
|
-
|
|
243
|
+
const saved = savedNodeMap.get(node.id);
|
|
244
|
+
if (!saved || (forceRefresh && saved.status !== "VERIFIED")) {
|
|
235
245
|
repo.environment.saveNode(node);
|
|
236
246
|
}
|
|
237
247
|
}
|