septum 0.1.6 → 0.1.7
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 +23 -3
- package/package.json +5 -5
- package/src/cli/commands/check.ts +32 -122
- package/src/cli/commands/hook.ts +9 -4
- package/src/cli/index.ts +2 -1
- package/src/core/boundary/evaluator.ts +21 -25
- package/src/core/boundary/staged-auditor.ts +47 -0
- package/src/core/config/loader.ts +21 -24
- package/src/core/database/repositories/dependency.repository.ts +3 -3
- package/src/core/database/repositories/symbol.repository.ts +71 -0
- package/src/core/ingestion/pipeline.ts +50 -36
- package/src/core/parser/extractors/frontend.ts +1 -0
- package/src/core/parser/extractors/go.ts +2 -2
- package/src/core/parser/extractors/php.ts +2 -2
- package/src/core/parser/extractors/python.ts +2 -2
- package/src/core/parser/extractors/typescript.ts +2 -2
- package/src/core/parser/tree-sitter.ts +6 -1
- package/src/core/parser/wasm-assets.ts +17 -0
- package/src/core/parser/wasm-loader.ts +3 -0
- package/src/core/resolver/module-resolver.ts +1 -10
- package/src/core/resolver/vertical-slice-tracer.ts +1 -0
- package/src/core/session/session-manager.ts +55 -39
- package/src/mcp/server.ts +99 -88
- package/src/mcp/tools/check-boundary.ts +15 -33
- package/src/mcp/tools/get-domain-catalog.ts +6 -1
- package/src/mcp/tools/locate-symbol.ts +25 -2
- package/src/mcp/tools/register-feature.ts +9 -7
- package/src/types/index.ts +20 -1
- package/src/types/wasm.d.ts +4 -0
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
2
|
-
import { basename, dirname, join, relative, resolve } from "node:path";
|
|
2
|
+
import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
3
3
|
import type { ParsedFileAST } from "../../types/index.ts";
|
|
4
4
|
import type { ValidatedSeptumConfig } from "../config/schema.ts";
|
|
5
5
|
import type { SeptumRepository } from "../database/repository.ts";
|
|
@@ -8,6 +8,7 @@ import { SemanticSliceExtractorRegistry } from "../parser/extractors/registry.ts
|
|
|
8
8
|
import { TopologyDetector } from "../discovery/topology-detector.ts";
|
|
9
9
|
import { ASTParserEngine } from "../parser/tree-sitter.ts";
|
|
10
10
|
import { CallGraphTracer } from "../resolver/call-graph-tracer.ts";
|
|
11
|
+
import { ModuleResolver } from "../resolver/module-resolver.ts";
|
|
11
12
|
import { computeContentHash } from "./hasher.ts";
|
|
12
13
|
|
|
13
14
|
export interface IngestionMetrics {
|
|
@@ -33,13 +34,15 @@ export class IngestionPipeline {
|
|
|
33
34
|
}
|
|
34
35
|
}
|
|
35
36
|
|
|
36
|
-
public async run(config: ValidatedSeptumConfig): Promise<IngestionMetrics> {
|
|
37
|
+
public async run(config: ValidatedSeptumConfig, options: { verifyContent?: boolean } = {}): Promise<IngestionMetrics> {
|
|
38
|
+
this.ensureParserRevision();
|
|
37
39
|
const startTime = performance.now();
|
|
38
40
|
let filesScanned = 0;
|
|
39
41
|
let filesUpdated = 0;
|
|
40
42
|
let filesSkipped = 0;
|
|
41
43
|
let symbolsIndexed = 0;
|
|
42
44
|
let dependenciesIndexed = 0;
|
|
45
|
+
const unsupportedFiles: string[] = [];
|
|
43
46
|
|
|
44
47
|
let domainNames = Object.keys(config.domains);
|
|
45
48
|
const projectRoot = this.customWorkspaceRoot || process.cwd();
|
|
@@ -71,17 +74,16 @@ export class IngestionPipeline {
|
|
|
71
74
|
const absDomainRoot = resolve(workspaceRoot, domainCfg.root);
|
|
72
75
|
|
|
73
76
|
if (!existsSync(absDomainRoot)) {
|
|
77
|
+
this.repo.files.deleteFilesNotInPaths(domainId, []);
|
|
74
78
|
continue;
|
|
75
79
|
}
|
|
76
80
|
|
|
77
|
-
const normCurrentRoot = domainCfg.root.replace(/\\/g, "/").replace(/^\.\/?/, "");
|
|
78
81
|
const childDomainRoots = domainNames
|
|
79
|
-
.filter(
|
|
80
|
-
.map(
|
|
81
|
-
.filter(
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
return r.startsWith(normCurrentRoot + "/");
|
|
82
|
+
.filter(name => name !== domainName)
|
|
83
|
+
.map(name => relative(workspaceRoot, resolve(workspaceRoot, config.domains[name].root)).replace(/\\/g, "/"))
|
|
84
|
+
.filter(child => {
|
|
85
|
+
const fromParent = relative(absDomainRoot, resolve(workspaceRoot, child)).replace(/\\/g, "/");
|
|
86
|
+
return fromParent !== "" && fromParent !== ".." && !fromParent.startsWith("../") && !isAbsolute(fromParent);
|
|
85
87
|
});
|
|
86
88
|
|
|
87
89
|
const collectedFiles = this.collectFiles(
|
|
@@ -120,12 +122,13 @@ export class IngestionPipeline {
|
|
|
120
122
|
continue;
|
|
121
123
|
}
|
|
122
124
|
|
|
123
|
-
const mtimeMs =
|
|
125
|
+
const mtimeMs = stat.mtimeMs;
|
|
124
126
|
const sizeBytes = stat.size;
|
|
125
127
|
const existingFile = existingFilesMap.get(relPath);
|
|
126
128
|
|
|
127
129
|
// Tier 1: Fast OS metadata check (inode mtime + size + domain match)
|
|
128
130
|
if (
|
|
131
|
+
!options.verifyContent &&
|
|
129
132
|
existingFile &&
|
|
130
133
|
existingFile.domain_id === domainId &&
|
|
131
134
|
existingFile.mtime_ms === mtimeMs &&
|
|
@@ -150,11 +153,17 @@ export class IngestionPipeline {
|
|
|
150
153
|
continue;
|
|
151
154
|
}
|
|
152
155
|
|
|
153
|
-
filesUpdated++;
|
|
154
156
|
const lineCount = content.split("\n").length;
|
|
155
157
|
const archetype = detectArchetype(relPath, domainCfg.archetypes ?? {});
|
|
156
158
|
const ast = await this.parserEngine.parseFile(fullPath, content);
|
|
159
|
+
if (ast.status === "unsupported") {
|
|
160
|
+
unsupportedFiles.push(relPath);
|
|
161
|
+
this.repo.deleteFileByPath(relPath);
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
if (ast.status === "degraded") throw new Error(`Cannot reliably index '${relPath}': parser is degraded.`);
|
|
157
165
|
|
|
166
|
+
filesUpdated++;
|
|
158
167
|
pendingUpdates.push({
|
|
159
168
|
fullPath,
|
|
160
169
|
relPath,
|
|
@@ -229,6 +238,7 @@ export class IngestionPipeline {
|
|
|
229
238
|
this.repo.setMeta("files_updated", String(filesUpdated));
|
|
230
239
|
this.repo.setMeta("symbols_indexed", String(symbolsIndexed));
|
|
231
240
|
this.repo.setMeta("dependencies_indexed", String(dependenciesIndexed));
|
|
241
|
+
this.repo.setMeta("unsupported_files", JSON.stringify(unsupportedFiles));
|
|
232
242
|
});
|
|
233
243
|
|
|
234
244
|
return {
|
|
@@ -514,15 +524,27 @@ export class IngestionPipeline {
|
|
|
514
524
|
}
|
|
515
525
|
|
|
516
526
|
private isSourceFile(path: string): boolean {
|
|
517
|
-
return
|
|
527
|
+
return ASTParserEngine.isSourceFile(path);
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
private ensureParserRevision(): void {
|
|
531
|
+
const revision = "2";
|
|
532
|
+
if (this.repo.getMeta("parser_revision") === revision) return;
|
|
533
|
+
this.repo.runInTransaction(() => {
|
|
534
|
+
for (const file of this.repo.getAllFiles()) {
|
|
535
|
+
this.repo.upsertFile(file.domain_id, file.path, file.archetype, "", file.line_count, -1, -1);
|
|
536
|
+
}
|
|
537
|
+
this.repo.clearVerticalSlices();
|
|
538
|
+
this.repo.setMeta("parser_revision", revision);
|
|
539
|
+
});
|
|
518
540
|
}
|
|
519
541
|
|
|
520
542
|
/**
|
|
521
543
|
* JIT single-file ingestion: quickly verifies and updates a single file's AST,
|
|
522
544
|
* symbols, and dependencies in SQLite if its mtime or content hash has changed.
|
|
523
|
-
* Runs in < 5ms for rapid delta sync without full project scans.
|
|
524
545
|
*/
|
|
525
546
|
public async ingestFile(targetFilePath: string, config: ValidatedSeptumConfig): Promise<boolean> {
|
|
547
|
+
this.ensureParserRevision();
|
|
526
548
|
const fullPath = resolve(targetFilePath);
|
|
527
549
|
if (!existsSync(fullPath)) return false;
|
|
528
550
|
|
|
@@ -534,7 +556,7 @@ export class IngestionPipeline {
|
|
|
534
556
|
return false;
|
|
535
557
|
}
|
|
536
558
|
|
|
537
|
-
const mtimeMs =
|
|
559
|
+
const mtimeMs = stat.mtimeMs;
|
|
538
560
|
const sizeBytes = stat.size;
|
|
539
561
|
|
|
540
562
|
const projectRoot = this.customWorkspaceRoot || process.cwd();
|
|
@@ -544,18 +566,8 @@ export class IngestionPipeline {
|
|
|
544
566
|
return false;
|
|
545
567
|
}
|
|
546
568
|
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
for (const [dName, dCfg] of Object.entries(config.domains)) {
|
|
551
|
-
const absRoot = resolve(projectRoot, dCfg.root).replace(/\\/g, "/");
|
|
552
|
-
const normFullPath = fullPath.replace(/\\/g, "/");
|
|
553
|
-
if (normFullPath === absRoot || normFullPath.startsWith(absRoot + "/")) {
|
|
554
|
-
targetDomainName = dName;
|
|
555
|
-
targetDomainCfg = dCfg;
|
|
556
|
-
break;
|
|
557
|
-
}
|
|
558
|
-
}
|
|
569
|
+
const targetDomainName = new ModuleResolver(projectRoot, this.repo).resolveSourceDomain(fullPath, config);
|
|
570
|
+
const targetDomainCfg = targetDomainName ? config.domains[targetDomainName] : null;
|
|
559
571
|
|
|
560
572
|
if (!targetDomainName || !targetDomainCfg) {
|
|
561
573
|
return false;
|
|
@@ -564,20 +576,11 @@ export class IngestionPipeline {
|
|
|
564
576
|
const domainId = this.repo.upsertDomain(targetDomainName, targetDomainCfg);
|
|
565
577
|
const existingFile = this.repo.getFileByPath(relPath);
|
|
566
578
|
|
|
567
|
-
// Tier 1: Fast OS metadata check
|
|
568
|
-
if (
|
|
569
|
-
existingFile &&
|
|
570
|
-
existingFile.mtime_ms === mtimeMs &&
|
|
571
|
-
existingFile.size_bytes === sizeBytes
|
|
572
|
-
) {
|
|
573
|
-
return false; // Completely up to date
|
|
574
|
-
}
|
|
575
|
-
|
|
576
579
|
const content = readFileSync(fullPath, "utf-8");
|
|
577
580
|
const currentHash = computeContentHash(content);
|
|
578
581
|
|
|
579
582
|
// Tier 2: Hash verification
|
|
580
|
-
if (existingFile && existingFile.content_hash === currentHash) {
|
|
583
|
+
if (existingFile && existingFile.domain_id === domainId && existingFile.content_hash === currentHash) {
|
|
581
584
|
this.repo.updateFileMetadataOnly(existingFile.id, mtimeMs, sizeBytes);
|
|
582
585
|
return false;
|
|
583
586
|
}
|
|
@@ -585,6 +588,14 @@ export class IngestionPipeline {
|
|
|
585
588
|
const lineCount = content.split("\n").length;
|
|
586
589
|
const archetype = detectArchetype(relPath, targetDomainCfg.archetypes ?? {});
|
|
587
590
|
const ast = await this.parserEngine.parseFile(fullPath, content);
|
|
591
|
+
if (ast.status === "unsupported") {
|
|
592
|
+
this.repo.deleteFileByPath(relPath);
|
|
593
|
+
const unsupported = new Set<string>(JSON.parse(this.repo.getMeta("unsupported_files") || "[]"));
|
|
594
|
+
unsupported.add(relPath);
|
|
595
|
+
this.repo.setMeta("unsupported_files", JSON.stringify([...unsupported]));
|
|
596
|
+
return false;
|
|
597
|
+
}
|
|
598
|
+
if (ast.status === "degraded") throw new Error(`Cannot reliably index '${relPath}': parser is degraded.`);
|
|
588
599
|
|
|
589
600
|
this.repo.runInTransaction(() => {
|
|
590
601
|
const fileId = this.repo.upsertFile(
|
|
@@ -598,6 +609,9 @@ export class IngestionPipeline {
|
|
|
598
609
|
);
|
|
599
610
|
this.repo.replaceFileSymbols(fileId, ast.symbols);
|
|
600
611
|
this.repo.replaceFileDependencies(fileId, ast.dependencies);
|
|
612
|
+
this.repo.clearVerticalSlices();
|
|
613
|
+
const unsupported = JSON.parse(this.repo.getMeta("unsupported_files") || "[]") as string[];
|
|
614
|
+
this.repo.setMeta("unsupported_files", JSON.stringify(unsupported.filter(path => path !== relPath)));
|
|
601
615
|
});
|
|
602
616
|
|
|
603
617
|
return true;
|
|
@@ -123,6 +123,7 @@ export class FrontendExtractor implements CodeExtractor {
|
|
|
123
123
|
dependencies: ExtractedDependency[]
|
|
124
124
|
): Promise<void> {
|
|
125
125
|
const parsed = await this.tsExtractor.extract("component-script.ts", scriptContent);
|
|
126
|
+
if (parsed.status === "degraded") throw new Error("Cannot reliably parse component script.");
|
|
126
127
|
const delta = Math.max(0, startLine - 1);
|
|
127
128
|
|
|
128
129
|
for (const dep of parsed.dependencies) {
|
|
@@ -67,10 +67,10 @@ export class GoExtractor implements CodeExtractor {
|
|
|
67
67
|
this.extractFromAst(rootNode, filePath, content)
|
|
68
68
|
);
|
|
69
69
|
if (astResult) {
|
|
70
|
-
return astResult;
|
|
70
|
+
return { ...astResult, status: "parsed" };
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
-
return this.extractFallback(filePath, content);
|
|
73
|
+
return { ...this.extractFallback(filePath, content), status: "degraded" };
|
|
74
74
|
}
|
|
75
75
|
|
|
76
76
|
private extractFallback(filePath: string, content: string): ParsedFileAST {
|
|
@@ -44,10 +44,10 @@ export class PHPExtractor implements CodeExtractor {
|
|
|
44
44
|
this.extractFromAst(rootNode, content)
|
|
45
45
|
);
|
|
46
46
|
if (astResult) {
|
|
47
|
-
return astResult;
|
|
47
|
+
return { ...astResult, status: "parsed" };
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
-
return this.extractFallback(content);
|
|
50
|
+
return { ...this.extractFallback(content), status: "degraded" };
|
|
51
51
|
}
|
|
52
52
|
|
|
53
53
|
private isExternalNamespace(cleaned: string): boolean {
|
|
@@ -79,10 +79,10 @@ export class PythonExtractor implements CodeExtractor {
|
|
|
79
79
|
this.extractFromAst(rootNode, content)
|
|
80
80
|
);
|
|
81
81
|
if (astResult) {
|
|
82
|
-
return astResult;
|
|
82
|
+
return { ...astResult, status: "parsed" };
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
-
return this.extractFallback(content);
|
|
85
|
+
return { ...this.extractFallback(content), status: "degraded" };
|
|
86
86
|
}
|
|
87
87
|
|
|
88
88
|
private extractFallback(content: string): ParsedFileAST {
|
|
@@ -46,10 +46,10 @@ export class TypeScriptExtractor implements CodeExtractor {
|
|
|
46
46
|
);
|
|
47
47
|
|
|
48
48
|
if (treeSitterResult) {
|
|
49
|
-
return treeSitterResult;
|
|
49
|
+
return { ...treeSitterResult, status: "parsed" };
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
-
return this.extractFallback(content);
|
|
52
|
+
return { ...this.extractFallback(content), status: "degraded" };
|
|
53
53
|
}
|
|
54
54
|
|
|
55
55
|
private resolveLanguageKey(filePath: string): string {
|
|
@@ -8,6 +8,9 @@ import { PythonExtractor } from "./extractors/python.ts";
|
|
|
8
8
|
import { TypeScriptExtractor } from "./extractors/typescript.ts";
|
|
9
9
|
|
|
10
10
|
export class ASTParserEngine {
|
|
11
|
+
public static isSourceFile(filePath: string): boolean {
|
|
12
|
+
return /\.(ts|tsx|mts|cts|js|jsx|mjs|cjs|php|py|go|rs|java|cs|rb|vue|astro|svelte)$/i.test(filePath);
|
|
13
|
+
}
|
|
11
14
|
private extractors: CodeExtractor[];
|
|
12
15
|
|
|
13
16
|
constructor() {
|
|
@@ -24,12 +27,14 @@ export class ASTParserEngine {
|
|
|
24
27
|
public async parseFile(filePath: string, content: string): Promise<ParsedFileAST> {
|
|
25
28
|
for (const extractor of this.extractors) {
|
|
26
29
|
if (extractor.canHandle(filePath)) {
|
|
27
|
-
|
|
30
|
+
const result = await extractor.extract(filePath, content);
|
|
31
|
+
return { status: "parsed", ...result };
|
|
28
32
|
}
|
|
29
33
|
}
|
|
30
34
|
|
|
31
35
|
// Default fallback extractor for unknown file types
|
|
32
36
|
return {
|
|
37
|
+
status: "unsupported",
|
|
33
38
|
symbols: [],
|
|
34
39
|
dependencies: [],
|
|
35
40
|
};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import runtime from "web-tree-sitter/tree-sitter.wasm" with { type: "file" };
|
|
2
|
+
import typescript from "tree-sitter-wasms/out/tree-sitter-typescript.wasm" with { type: "file" };
|
|
3
|
+
import tsx from "tree-sitter-wasms/out/tree-sitter-tsx.wasm" with { type: "file" };
|
|
4
|
+
import javascript from "tree-sitter-wasms/out/tree-sitter-javascript.wasm" with { type: "file" };
|
|
5
|
+
import php from "tree-sitter-wasms/out/tree-sitter-php.wasm" with { type: "file" };
|
|
6
|
+
import python from "tree-sitter-wasms/out/tree-sitter-python.wasm" with { type: "file" };
|
|
7
|
+
import go from "tree-sitter-wasms/out/tree-sitter-go.wasm" with { type: "file" };
|
|
8
|
+
|
|
9
|
+
export const WASM_ASSETS: Record<string, string> = {
|
|
10
|
+
"tree-sitter.wasm": runtime,
|
|
11
|
+
"tree-sitter-typescript.wasm": typescript,
|
|
12
|
+
"tree-sitter-tsx.wasm": tsx,
|
|
13
|
+
"tree-sitter-javascript.wasm": javascript,
|
|
14
|
+
"tree-sitter-php.wasm": php,
|
|
15
|
+
"tree-sitter-python.wasm": python,
|
|
16
|
+
"tree-sitter-go.wasm": go,
|
|
17
|
+
};
|
|
@@ -2,6 +2,7 @@ import * as fs from "node:fs";
|
|
|
2
2
|
import * as path from "node:path";
|
|
3
3
|
import { fileURLToPath } from "node:url";
|
|
4
4
|
import Parser from "web-tree-sitter";
|
|
5
|
+
import { WASM_ASSETS } from "./wasm-assets.ts";
|
|
5
6
|
|
|
6
7
|
const GRAMMAR_BY_KEY: Record<string, string> = {
|
|
7
8
|
// Extensions
|
|
@@ -74,6 +75,8 @@ export function resolveWasmPath(fileName: string, isCoreRuntime: boolean = false
|
|
|
74
75
|
candidates.push(path.join(envDir, "out", fileName));
|
|
75
76
|
}
|
|
76
77
|
|
|
78
|
+
if (WASM_ASSETS[fileName]) candidates.push(WASM_ASSETS[fileName]);
|
|
79
|
+
|
|
77
80
|
// Tier 2: Walk up from import.meta.dir to package root
|
|
78
81
|
let currentDir = getModuleDir();
|
|
79
82
|
for (let depth = 0; depth < 7; depth++) {
|
|
@@ -97,16 +97,7 @@ export class ModuleResolver {
|
|
|
97
97
|
filePath: string,
|
|
98
98
|
config: ValidatedSeptumConfig
|
|
99
99
|
): string | null {
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
for (const [domainName, domainCfg] of Object.entries(config.domains)) {
|
|
103
|
-
const domainAbsRoot = this.toAbsolutePath(domainCfg.root);
|
|
104
|
-
if (this.isSubPathOrSame(absPath, domainAbsRoot)) {
|
|
105
|
-
return domainName;
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
return null;
|
|
100
|
+
return this.matchDomainByPath(filePath, config);
|
|
110
101
|
}
|
|
111
102
|
|
|
112
103
|
/**
|
|
@@ -81,6 +81,7 @@ export class VerticalSliceTracer {
|
|
|
81
81
|
if (exact) {
|
|
82
82
|
return this.formatSuccessResponse(trimmed, exact, "exact", undefined, effectiveMaxDepth);
|
|
83
83
|
}
|
|
84
|
+
return { query: trimmed, found: false, max_depth_applied: effectiveMaxDepth, message: `No vertical slice found matching query '${trimmed}'.` };
|
|
84
85
|
}
|
|
85
86
|
|
|
86
87
|
// 2. URI-only match: "/orders" or "orders/{id}"
|
|
@@ -1,56 +1,72 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
3
4
|
import type { ActiveFeatureSession } from "../../types/index.ts";
|
|
5
|
+
import { SeptumDatabase } from "../database/client.ts";
|
|
4
6
|
|
|
5
7
|
export class SessionManager {
|
|
6
8
|
private static readonly SESSION_FILE = ".septum/session.json";
|
|
7
9
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
*/
|
|
11
|
-
public static saveActiveSession(workspaceRoot: string, session: ActiveFeatureSession): void {
|
|
12
|
-
const septumDir = join(workspaceRoot, ".septum");
|
|
13
|
-
if (!existsSync(septumDir)) {
|
|
14
|
-
mkdirSync(septumDir, { recursive: true });
|
|
15
|
-
}
|
|
16
|
-
const sessionPath = join(workspaceRoot, SessionManager.SESSION_FILE);
|
|
17
|
-
writeFileSync(sessionPath, JSON.stringify(session, null, 2), "utf-8");
|
|
10
|
+
public static getOwnerId(): string {
|
|
11
|
+
return process.env.SEPTUM_SESSION_ID || `process:${process.pid}`;
|
|
18
12
|
}
|
|
19
13
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
14
|
+
private static mutate<T>(workspaceRoot: string, fn: () => T): T {
|
|
15
|
+
const db = new SeptumDatabase(join(workspaceRoot, ".septum/septum.db"));
|
|
16
|
+
try {
|
|
17
|
+
// Serialize ownership checks with writes across CLI and MCP processes.
|
|
18
|
+
return db.raw.transaction(fn).immediate();
|
|
19
|
+
} finally {
|
|
20
|
+
db.close();
|
|
27
21
|
}
|
|
22
|
+
}
|
|
28
23
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
24
|
+
public static saveActiveSession(
|
|
25
|
+
workspaceRoot: string,
|
|
26
|
+
session: ActiveFeatureSession,
|
|
27
|
+
ownerId: string = SessionManager.getOwnerId()
|
|
28
|
+
): void {
|
|
29
|
+
SessionManager.mutate(workspaceRoot, () => {
|
|
30
|
+
const current = SessionManager.getActiveSession(workspaceRoot);
|
|
31
|
+
if (current && current.owner_id !== ownerId) {
|
|
32
|
+
throw new Error(`Workspace already has an active feature session owned by '${current.owner_id || "legacy-session"}'. Release it with its owner, or explicitly run 'septum feature clear --force'.`);
|
|
34
33
|
}
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
34
|
+
const septumDir = join(workspaceRoot, ".septum");
|
|
35
|
+
mkdirSync(septumDir, { recursive: true });
|
|
36
|
+
const sessionPath = join(workspaceRoot, SessionManager.SESSION_FILE);
|
|
37
|
+
const temporaryPath = `${sessionPath}.${randomUUID()}.tmp`;
|
|
38
|
+
try {
|
|
39
|
+
writeFileSync(temporaryPath, JSON.stringify({ ...session, owner_id: ownerId }, null, 2), "utf-8");
|
|
40
|
+
renameSync(temporaryPath, sessionPath);
|
|
41
|
+
} finally {
|
|
42
|
+
rmSync(temporaryPath, { force: true });
|
|
43
|
+
}
|
|
44
|
+
});
|
|
39
45
|
}
|
|
40
46
|
|
|
41
|
-
|
|
42
|
-
* Clear the active feature session
|
|
43
|
-
*/
|
|
44
|
-
public static clearActiveSession(workspaceRoot: string): boolean {
|
|
47
|
+
public static getActiveSession(workspaceRoot: string): ActiveFeatureSession | null {
|
|
45
48
|
const sessionPath = join(workspaceRoot, SessionManager.SESSION_FILE);
|
|
46
|
-
if (existsSync(sessionPath))
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
49
|
+
if (!existsSync(sessionPath)) return null;
|
|
50
|
+
const parsed = JSON.parse(readFileSync(sessionPath, "utf-8")) as ActiveFeatureSession;
|
|
51
|
+
if (parsed && typeof parsed.feature_key === "string" && typeof parsed.domain === "string" && Array.isArray(parsed.touchpoints)) return parsed;
|
|
52
|
+
throw new Error(`Invalid active feature session in '${sessionPath}'. Release it explicitly with 'septum feature clear --force'.`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
public static clearActiveSession(
|
|
56
|
+
workspaceRoot: string,
|
|
57
|
+
ownerId: string = SessionManager.getOwnerId(),
|
|
58
|
+
force: boolean = false
|
|
59
|
+
): boolean {
|
|
60
|
+
if (!existsSync(join(workspaceRoot, SessionManager.SESSION_FILE))) return false;
|
|
61
|
+
return SessionManager.mutate(workspaceRoot, () => {
|
|
62
|
+
const sessionPath = join(workspaceRoot, SessionManager.SESSION_FILE);
|
|
63
|
+
if (!existsSync(sessionPath)) return false;
|
|
64
|
+
if (!force) {
|
|
65
|
+
const current = SessionManager.getActiveSession(workspaceRoot);
|
|
66
|
+
if (current?.owner_id !== ownerId) throw new Error("Only the active session owner can release it. Use 'septum feature clear --force' for explicit recovery.");
|
|
52
67
|
}
|
|
53
|
-
|
|
54
|
-
|
|
68
|
+
rmSync(sessionPath);
|
|
69
|
+
return true;
|
|
70
|
+
});
|
|
55
71
|
}
|
|
56
72
|
}
|