@guanlin4924/devflow-pi 0.1.60 → 0.1.63
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 +1 -36
- package/package.json +1 -1
- package/plugins/devflow-core/extensions/devflow-core.ts +150 -4
- package/plugins/devflow-core/skills/wireframe-render/SKILL.md +1 -1
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/test_command_policy.py +42 -5
- package/plugins/devflow-core/skills/workflow-orchestrator/templates/feature-development.yaml +5 -0
- package/plugins/devflow-core/skills/workflow-project-context/SKILL.md +62 -787
- package/plugins/devflow-core/skills/workflow-project-context/scripts/validate_initialization_database.py +156 -0
- package/plugins/devflow-intel/extensions/devflow-intel.ts +12 -2
- package/plugins/devflow-intel/skills/workflow-code-index/scripts/adapters/generic_text.py +14 -10
- package/plugins/devflow-intel/skills/workflow-code-index/scripts/adapters/python_ast.py +124 -0
- package/plugins/devflow-intel/skills/workflow-code-index/scripts/adapters/registry.py +11 -2
- package/plugins/devflow-intel/skills/workflow-code-index/scripts/adapters/structured_scope.py +121 -0
- package/plugins/devflow-intel/skills/workflow-code-index/scripts/code_index.py +107 -9
- package/src/cli.ts +12 -2
- package/src/constants.ts +1 -1
- package/src/initialization-store.ts +656 -0
- package/src/paths.ts +9 -0
- package/src/project-context.ts +17 -2
- package/src/project-documents.ts +111 -0
- package/src/project-initializer.ts +251 -0
- package/src/project-migration.ts +66 -0
|
@@ -0,0 +1,656 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { copyFile, cp, mkdir, readFile, realpath, stat, writeFile, rename, rm } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { Database } from "bun:sqlite";
|
|
5
|
+
|
|
6
|
+
import { projectControlDatabasePath, projectDataDir } from "./paths.ts";
|
|
7
|
+
|
|
8
|
+
export const INITIALIZATION_SCHEMA_VERSION = 2;
|
|
9
|
+
export const FACT_KINDS = [
|
|
10
|
+
"asset", "technology", "api", "page", "data_model", "integration",
|
|
11
|
+
"rule", "toolchain", "evidence", "capability",
|
|
12
|
+
] as const;
|
|
13
|
+
export type FactKind = typeof FACT_KINDS[number];
|
|
14
|
+
export type CoverageStatus = "complete" | "partial" | "unsupported" | "not_applicable" | "skipped" | "failed";
|
|
15
|
+
export type RunStatus = "preparing" | "collecting" | "validating" | "finished" | "failed" | "cancelled";
|
|
16
|
+
export type EvidenceLevel = "observed" | "explicit_rule" | "historical" | "candidate";
|
|
17
|
+
|
|
18
|
+
export interface InitializationContextLike {
|
|
19
|
+
root: string;
|
|
20
|
+
manifest: { projectId: string; name: string };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface ScanRepository {
|
|
24
|
+
name: string;
|
|
25
|
+
relativePath: string;
|
|
26
|
+
role: "frontend" | "backend" | "shared" | "other";
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface ScanConfigInput {
|
|
30
|
+
mode: "full" | "fast";
|
|
31
|
+
repositories: ScanRepository[];
|
|
32
|
+
includes?: string[];
|
|
33
|
+
excludes?: string[];
|
|
34
|
+
requiredKinds?: FactKind[];
|
|
35
|
+
adapterVersions?: Record<string, string>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface FactInput {
|
|
39
|
+
kind: FactKind;
|
|
40
|
+
objectKey: string;
|
|
41
|
+
value: Record<string, unknown>;
|
|
42
|
+
evidenceLevel: EvidenceLevel;
|
|
43
|
+
repository?: string;
|
|
44
|
+
sourcePath?: string;
|
|
45
|
+
sourceLine?: number;
|
|
46
|
+
sourceFingerprint?: string;
|
|
47
|
+
extractor: string;
|
|
48
|
+
confidence?: "exact" | "inferred" | "heuristic";
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const SCHEMA = `
|
|
52
|
+
PRAGMA foreign_keys = ON;
|
|
53
|
+
CREATE TABLE IF NOT EXISTS schema_meta(version INTEGER NOT NULL);
|
|
54
|
+
CREATE TABLE IF NOT EXISTS projects(id TEXT PRIMARY KEY, name TEXT NOT NULL, created_at TEXT NOT NULL);
|
|
55
|
+
CREATE TABLE IF NOT EXISTS workspaces(
|
|
56
|
+
id TEXT PRIMARY KEY, project_id TEXT NOT NULL REFERENCES projects(id), location TEXT NOT NULL,
|
|
57
|
+
repository_identity TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL,
|
|
58
|
+
UNIQUE(project_id, location)
|
|
59
|
+
);
|
|
60
|
+
CREATE TABLE IF NOT EXISTS scan_configs(
|
|
61
|
+
id TEXT PRIMARY KEY, project_id TEXT NOT NULL REFERENCES projects(id), revision INTEGER NOT NULL,
|
|
62
|
+
content_hash TEXT NOT NULL, config_json TEXT NOT NULL, created_at TEXT NOT NULL,
|
|
63
|
+
UNIQUE(project_id, revision), UNIQUE(project_id, content_hash)
|
|
64
|
+
);
|
|
65
|
+
CREATE TABLE IF NOT EXISTS input_snapshots(
|
|
66
|
+
id TEXT PRIMARY KEY, project_id TEXT NOT NULL REFERENCES projects(id), workspace_id TEXT NOT NULL REFERENCES workspaces(id),
|
|
67
|
+
config_id TEXT NOT NULL REFERENCES scan_configs(id), fingerprint TEXT NOT NULL, manifest_json TEXT NOT NULL,
|
|
68
|
+
stable INTEGER NOT NULL CHECK(stable IN (0,1)), created_at TEXT NOT NULL,
|
|
69
|
+
UNIQUE(workspace_id, config_id, fingerprint)
|
|
70
|
+
);
|
|
71
|
+
CREATE TABLE IF NOT EXISTS initialization_runs(
|
|
72
|
+
id TEXT PRIMARY KEY, project_id TEXT NOT NULL REFERENCES projects(id), workspace_id TEXT NOT NULL REFERENCES workspaces(id),
|
|
73
|
+
config_id TEXT NOT NULL REFERENCES scan_configs(id), input_snapshot_id TEXT NOT NULL REFERENCES input_snapshots(id),
|
|
74
|
+
request_id TEXT, request_hash TEXT NOT NULL, status TEXT NOT NULL,
|
|
75
|
+
lease_token TEXT NOT NULL, lease_expires_at TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL,
|
|
76
|
+
published_snapshot_id TEXT, error TEXT, UNIQUE(workspace_id, request_id)
|
|
77
|
+
);
|
|
78
|
+
CREATE TABLE IF NOT EXISTS collection_results(
|
|
79
|
+
id TEXT PRIMARY KEY, run_id TEXT NOT NULL REFERENCES initialization_runs(id), repository TEXT NOT NULL DEFAULT '',
|
|
80
|
+
kind TEXT NOT NULL, status TEXT NOT NULL, adapter TEXT NOT NULL, capability_json TEXT NOT NULL,
|
|
81
|
+
diagnostics_json TEXT NOT NULL, created_at TEXT NOT NULL, UNIQUE(run_id, repository, kind)
|
|
82
|
+
);
|
|
83
|
+
CREATE TABLE IF NOT EXISTS facts(
|
|
84
|
+
id TEXT PRIMARY KEY, run_id TEXT NOT NULL REFERENCES initialization_runs(id), kind TEXT NOT NULL,
|
|
85
|
+
object_key TEXT NOT NULL, repository TEXT NOT NULL DEFAULT '', value_json TEXT NOT NULL,
|
|
86
|
+
evidence_level TEXT NOT NULL, extractor TEXT NOT NULL, confidence TEXT NOT NULL,
|
|
87
|
+
created_at TEXT NOT NULL, UNIQUE(run_id, kind, repository, object_key)
|
|
88
|
+
);
|
|
89
|
+
CREATE TABLE IF NOT EXISTS evidence(
|
|
90
|
+
id TEXT PRIMARY KEY, fact_id TEXT NOT NULL REFERENCES facts(id), source_path TEXT NOT NULL,
|
|
91
|
+
source_line INTEGER, source_fingerprint TEXT NOT NULL, method TEXT NOT NULL, created_at TEXT NOT NULL,
|
|
92
|
+
UNIQUE(fact_id, source_path, source_line, method)
|
|
93
|
+
);
|
|
94
|
+
CREATE TABLE IF NOT EXISTS context_snapshots(
|
|
95
|
+
id TEXT PRIMARY KEY, project_id TEXT NOT NULL REFERENCES projects(id), workspace_id TEXT NOT NULL REFERENCES workspaces(id),
|
|
96
|
+
config_id TEXT NOT NULL REFERENCES scan_configs(id), input_snapshot_id TEXT NOT NULL REFERENCES input_snapshots(id),
|
|
97
|
+
run_id TEXT NOT NULL UNIQUE REFERENCES initialization_runs(id), completeness TEXT NOT NULL,
|
|
98
|
+
content_hash TEXT NOT NULL, index_ref_json TEXT, created_at TEXT NOT NULL
|
|
99
|
+
);
|
|
100
|
+
CREATE TABLE IF NOT EXISTS current_context(
|
|
101
|
+
workspace_id TEXT NOT NULL REFERENCES workspaces(id), config_id TEXT NOT NULL REFERENCES scan_configs(id),
|
|
102
|
+
snapshot_id TEXT NOT NULL REFERENCES context_snapshots(id), revision INTEGER NOT NULL,
|
|
103
|
+
PRIMARY KEY(workspace_id, config_id)
|
|
104
|
+
);
|
|
105
|
+
CREATE TABLE IF NOT EXISTS document_jobs(
|
|
106
|
+
id TEXT PRIMARY KEY, snapshot_id TEXT NOT NULL REFERENCES context_snapshots(id), target_path TEXT NOT NULL,
|
|
107
|
+
ownership TEXT NOT NULL CHECK(ownership IN ('generated','human','mixed')), template_version TEXT NOT NULL,
|
|
108
|
+
model_role TEXT, status TEXT NOT NULL, input_hash TEXT NOT NULL, output_hash TEXT,
|
|
109
|
+
conflict_path TEXT, error TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL,
|
|
110
|
+
UNIQUE(snapshot_id, target_path, template_version)
|
|
111
|
+
);
|
|
112
|
+
CREATE TABLE IF NOT EXISTS authority_state(
|
|
113
|
+
project_id TEXT PRIMARY KEY REFERENCES projects(id), authority TEXT NOT NULL CHECK(authority IN ('legacy','shadow','database')),
|
|
114
|
+
revision INTEGER NOT NULL, switched_at TEXT NOT NULL
|
|
115
|
+
);
|
|
116
|
+
CREATE TABLE IF NOT EXISTS historical_imports(
|
|
117
|
+
id TEXT PRIMARY KEY, project_id TEXT NOT NULL REFERENCES projects(id), workspace_id TEXT NOT NULL REFERENCES workspaces(id),
|
|
118
|
+
source_path TEXT NOT NULL, content_hash TEXT NOT NULL, status TEXT NOT NULL CHECK(status IN ('shadow','accepted','rejected')),
|
|
119
|
+
snapshot_id TEXT REFERENCES context_snapshots(id), created_at TEXT NOT NULL,
|
|
120
|
+
UNIQUE(workspace_id, source_path, content_hash)
|
|
121
|
+
);
|
|
122
|
+
CREATE TABLE IF NOT EXISTS content_objects(
|
|
123
|
+
content_hash TEXT PRIMARY KEY, relative_path TEXT NOT NULL UNIQUE, size_bytes INTEGER NOT NULL, created_at TEXT NOT NULL
|
|
124
|
+
);
|
|
125
|
+
CREATE TABLE IF NOT EXISTS snapshot_relations(
|
|
126
|
+
id TEXT PRIMARY KEY, snapshot_id TEXT NOT NULL REFERENCES context_snapshots(id), source_key TEXT NOT NULL,
|
|
127
|
+
relation_type TEXT NOT NULL, target_key TEXT, external_target TEXT, resolution TEXT NOT NULL,
|
|
128
|
+
confidence TEXT NOT NULL, evidence_json TEXT NOT NULL,
|
|
129
|
+
CHECK((target_key IS NOT NULL) != (external_target IS NOT NULL))
|
|
130
|
+
);
|
|
131
|
+
CREATE INDEX IF NOT EXISTS idx_facts_snapshot_query ON facts(kind, repository, object_key);
|
|
132
|
+
CREATE INDEX IF NOT EXISTS idx_evidence_path ON evidence(source_path);
|
|
133
|
+
CREATE INDEX IF NOT EXISTS idx_runs_workspace_status ON initialization_runs(workspace_id, status);
|
|
134
|
+
CREATE INDEX IF NOT EXISTS idx_snapshot_relations_source ON snapshot_relations(snapshot_id, source_key, relation_type);
|
|
135
|
+
CREATE INDEX IF NOT EXISTS idx_snapshot_relations_target ON snapshot_relations(snapshot_id, target_key, relation_type);
|
|
136
|
+
CREATE TRIGGER IF NOT EXISTS validate_run_status_insert BEFORE INSERT ON initialization_runs
|
|
137
|
+
WHEN NEW.status NOT IN ('preparing','collecting','validating','finished','failed','cancelled')
|
|
138
|
+
BEGIN SELECT RAISE(ABORT, 'invalid initialization run status'); END;
|
|
139
|
+
CREATE TRIGGER IF NOT EXISTS validate_collection_insert BEFORE INSERT ON collection_results
|
|
140
|
+
WHEN NEW.kind NOT IN ('asset','technology','api','page','data_model','integration','rule','toolchain','evidence','capability')
|
|
141
|
+
OR NEW.status NOT IN ('complete','partial','unsupported','not_applicable','skipped','failed')
|
|
142
|
+
BEGIN SELECT RAISE(ABORT, 'invalid collection kind or status'); END;
|
|
143
|
+
CREATE TRIGGER IF NOT EXISTS validate_collection_update BEFORE UPDATE ON collection_results
|
|
144
|
+
WHEN NEW.kind NOT IN ('asset','technology','api','page','data_model','integration','rule','toolchain','evidence','capability')
|
|
145
|
+
OR NEW.status NOT IN ('complete','partial','unsupported','not_applicable','skipped','failed')
|
|
146
|
+
BEGIN SELECT RAISE(ABORT, 'invalid collection kind or status'); END;
|
|
147
|
+
CREATE TRIGGER IF NOT EXISTS validate_fact_insert BEFORE INSERT ON facts
|
|
148
|
+
WHEN NEW.kind NOT IN ('asset','technology','api','page','data_model','integration','rule','toolchain','evidence','capability')
|
|
149
|
+
OR NEW.evidence_level NOT IN ('observed','explicit_rule','historical','candidate')
|
|
150
|
+
OR NEW.confidence NOT IN ('exact','inferred','heuristic')
|
|
151
|
+
BEGIN SELECT RAISE(ABORT, 'invalid fact type, evidence level, or confidence'); END;
|
|
152
|
+
CREATE TRIGGER IF NOT EXISTS validate_fact_update BEFORE UPDATE ON facts
|
|
153
|
+
WHEN NEW.kind NOT IN ('asset','technology','api','page','data_model','integration','rule','toolchain','evidence','capability')
|
|
154
|
+
OR NEW.evidence_level NOT IN ('observed','explicit_rule','historical','candidate')
|
|
155
|
+
OR NEW.confidence NOT IN ('exact','inferred','heuristic')
|
|
156
|
+
BEGIN SELECT RAISE(ABORT, 'invalid fact type, evidence level, or confidence'); END;
|
|
157
|
+
CREATE TRIGGER IF NOT EXISTS validate_document_job_insert BEFORE INSERT ON document_jobs
|
|
158
|
+
WHEN NEW.ownership NOT IN ('generated','human','mixed') OR NEW.status NOT IN ('queued','generating','pending_review','complete','failed','conflict')
|
|
159
|
+
BEGIN SELECT RAISE(ABORT, 'invalid document ownership or status'); END;
|
|
160
|
+
CREATE TRIGGER IF NOT EXISTS validate_document_job_update BEFORE UPDATE ON document_jobs
|
|
161
|
+
WHEN NEW.ownership NOT IN ('generated','human','mixed') OR NEW.status NOT IN ('queued','generating','pending_review','complete','failed','conflict')
|
|
162
|
+
BEGIN SELECT RAISE(ABORT, 'invalid document ownership or status'); END;
|
|
163
|
+
CREATE TRIGGER IF NOT EXISTS validate_context_snapshot_binding BEFORE INSERT ON context_snapshots
|
|
164
|
+
WHEN NOT EXISTS(
|
|
165
|
+
SELECT 1 FROM initialization_runs r JOIN input_snapshots i ON i.id=NEW.input_snapshot_id
|
|
166
|
+
WHERE r.id=NEW.run_id AND r.project_id=NEW.project_id AND r.workspace_id=NEW.workspace_id
|
|
167
|
+
AND r.config_id=NEW.config_id AND r.input_snapshot_id=NEW.input_snapshot_id
|
|
168
|
+
AND i.project_id=NEW.project_id AND i.workspace_id=NEW.workspace_id AND i.config_id=NEW.config_id
|
|
169
|
+
)
|
|
170
|
+
BEGIN SELECT RAISE(ABORT, 'context snapshot crosses project or input boundary'); END;
|
|
171
|
+
CREATE TRIGGER IF NOT EXISTS validate_current_context_binding_insert BEFORE INSERT ON current_context
|
|
172
|
+
WHEN NOT EXISTS(
|
|
173
|
+
SELECT 1 FROM context_snapshots s
|
|
174
|
+
WHERE s.id=NEW.snapshot_id AND s.workspace_id=NEW.workspace_id AND s.config_id=NEW.config_id
|
|
175
|
+
)
|
|
176
|
+
BEGIN SELECT RAISE(ABORT, 'current context crosses workspace or configuration boundary'); END;
|
|
177
|
+
CREATE TRIGGER IF NOT EXISTS validate_current_context_binding_update BEFORE UPDATE ON current_context
|
|
178
|
+
WHEN NOT EXISTS(
|
|
179
|
+
SELECT 1 FROM context_snapshots s
|
|
180
|
+
WHERE s.id=NEW.snapshot_id AND s.workspace_id=NEW.workspace_id AND s.config_id=NEW.config_id
|
|
181
|
+
)
|
|
182
|
+
BEGIN SELECT RAISE(ABORT, 'current context crosses workspace or configuration boundary'); END;
|
|
183
|
+
CREATE TRIGGER IF NOT EXISTS immutable_context_snapshot_update BEFORE UPDATE ON context_snapshots
|
|
184
|
+
BEGIN SELECT RAISE(ABORT, 'context snapshots are immutable'); END;
|
|
185
|
+
CREATE TRIGGER IF NOT EXISTS immutable_context_snapshot_delete BEFORE DELETE ON context_snapshots
|
|
186
|
+
BEGIN SELECT RAISE(ABORT, 'context snapshots are immutable'); END;
|
|
187
|
+
CREATE TRIGGER IF NOT EXISTS stable_input_immutable BEFORE UPDATE ON input_snapshots
|
|
188
|
+
WHEN OLD.stable=1 OR NEW.id!=OLD.id OR NEW.project_id!=OLD.project_id OR NEW.workspace_id!=OLD.workspace_id
|
|
189
|
+
OR NEW.config_id!=OLD.config_id OR NEW.fingerprint!=OLD.fingerprint OR NEW.manifest_json!=OLD.manifest_json
|
|
190
|
+
BEGIN SELECT RAISE(ABORT, 'stable input snapshots are immutable'); END;
|
|
191
|
+
CREATE TRIGGER IF NOT EXISTS published_fact_immutable_update BEFORE UPDATE ON facts
|
|
192
|
+
WHEN EXISTS(SELECT 1 FROM initialization_runs r WHERE r.id=OLD.run_id AND r.status='finished')
|
|
193
|
+
BEGIN SELECT RAISE(ABORT, 'published facts are immutable'); END;
|
|
194
|
+
CREATE TRIGGER IF NOT EXISTS published_fact_immutable_delete BEFORE DELETE ON facts
|
|
195
|
+
WHEN EXISTS(SELECT 1 FROM initialization_runs r WHERE r.id=OLD.run_id AND r.status='finished')
|
|
196
|
+
BEGIN SELECT RAISE(ABORT, 'published facts are immutable'); END;
|
|
197
|
+
`;
|
|
198
|
+
|
|
199
|
+
function now(): string { return new Date().toISOString(); }
|
|
200
|
+
function canonicalJson(value: unknown): string {
|
|
201
|
+
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
|
|
202
|
+
if (value && typeof value === "object") return `{${Object.entries(value as Record<string, unknown>).sort(([a], [b]) => a.localeCompare(b)).map(([key, item]) => `${JSON.stringify(key)}:${canonicalJson(item)}`).join(",")}}`;
|
|
203
|
+
return JSON.stringify(value);
|
|
204
|
+
}
|
|
205
|
+
function hash(value: string | Uint8Array): string { return `sha256:${createHash("sha256").update(value).digest("hex")}`; }
|
|
206
|
+
function parse<T>(value: string): T { return JSON.parse(value) as T; }
|
|
207
|
+
function validateRelative(relative: string): string {
|
|
208
|
+
const normalized = relative.replaceAll("\\", "/").replace(/^\.\//, "");
|
|
209
|
+
if (!normalized || normalized === ".." || normalized.startsWith("../") || path.isAbsolute(normalized)) throw new Error(`Path escapes project boundary: ${relative}`);
|
|
210
|
+
return normalized;
|
|
211
|
+
}
|
|
212
|
+
function validateFactShape(fact: FactInput): void {
|
|
213
|
+
if (!fact.value || Array.isArray(fact.value) || typeof fact.value !== "object") throw new Error(`${fact.kind} fact value must be a structured object`);
|
|
214
|
+
if (fact.evidenceLevel === "historical") {
|
|
215
|
+
if (typeof fact.value.sourcePath !== "string" || typeof fact.value.contentHash !== "string") throw new Error("Historical facts require sourcePath and contentHash metadata");
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
const has = (...keys: string[]) => keys.some(key => typeof fact.value[key] === "string" && String(fact.value[key]).length > 0);
|
|
219
|
+
const valid = fact.kind === "asset" ? has("path")
|
|
220
|
+
: fact.kind === "technology" ? has("name", "extension", "path")
|
|
221
|
+
: fact.kind === "api" ? has("path")
|
|
222
|
+
: fact.kind === "page" ? has("route", "componentPath")
|
|
223
|
+
: fact.kind === "data_model" ? has("name")
|
|
224
|
+
: fact.kind === "integration" ? has("host", "kind", "name")
|
|
225
|
+
: fact.kind === "rule" ? has("path")
|
|
226
|
+
: fact.kind === "toolchain" ? has("name", "testDefinition")
|
|
227
|
+
: fact.kind === "evidence" ? has("source")
|
|
228
|
+
: fact.kind === "capability" ? Array.isArray(fact.value.supported)
|
|
229
|
+
: false;
|
|
230
|
+
if (!valid) throw new Error(`${fact.kind} fact does not satisfy its structured value contract`);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
async function repositoryIdentity(root: string): Promise<string | null> {
|
|
234
|
+
const child = Bun.spawn(["git", "-C", root, "rev-parse", "--git-common-dir"], { stdout: "pipe", stderr: "ignore" });
|
|
235
|
+
if (await child.exited !== 0) return null;
|
|
236
|
+
const value = (await new Response(child.stdout).text()).trim();
|
|
237
|
+
if (!value) return null;
|
|
238
|
+
return realpath(path.resolve(root, value)).catch(() => path.resolve(root, value));
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export class InitializationStore {
|
|
242
|
+
readonly db: Database;
|
|
243
|
+
readonly databasePath: string;
|
|
244
|
+
private constructor(readonly context: InitializationContextLike, databasePath: string, db: Database) {
|
|
245
|
+
this.databasePath = databasePath;
|
|
246
|
+
this.db = db;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
static async open(context: InitializationContextLike): Promise<InitializationStore> {
|
|
250
|
+
const directory = projectDataDir(context.manifest.projectId);
|
|
251
|
+
await mkdir(path.join(directory, "content"), { recursive: true });
|
|
252
|
+
const databasePath = projectControlDatabasePath(context.manifest.projectId);
|
|
253
|
+
const db = new Database(databasePath, { create: true });
|
|
254
|
+
const hasMeta = db.query("SELECT 1 FROM sqlite_master WHERE type='table' AND name='schema_meta'").get();
|
|
255
|
+
if (hasMeta) {
|
|
256
|
+
const existing = db.query("SELECT version FROM schema_meta LIMIT 1").get() as { version: number } | null;
|
|
257
|
+
if (existing && existing.version > INITIALIZATION_SCHEMA_VERSION) {
|
|
258
|
+
db.close();
|
|
259
|
+
throw new Error(`Initialization database schema ${existing.version} is newer than supported ${INITIALIZATION_SCHEMA_VERSION}`);
|
|
260
|
+
}
|
|
261
|
+
if (existing && existing.version < INITIALIZATION_SCHEMA_VERSION) {
|
|
262
|
+
const migrationBackup = `${databasePath}.pre-v${existing.version}-${Date.now()}.backup`;
|
|
263
|
+
await writeFile(migrationBackup, db.serialize(), { flag: "wx" });
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
db.transaction(() => {
|
|
267
|
+
db.exec(SCHEMA);
|
|
268
|
+
const meta = db.query("SELECT version FROM schema_meta LIMIT 1").get() as { version: number } | null;
|
|
269
|
+
if (!meta) db.query("INSERT INTO schema_meta(version) VALUES (?)").run(INITIALIZATION_SCHEMA_VERSION);
|
|
270
|
+
else if (meta.version < INITIALIZATION_SCHEMA_VERSION) db.query("UPDATE schema_meta SET version=?").run(INITIALIZATION_SCHEMA_VERSION);
|
|
271
|
+
})();
|
|
272
|
+
db.query("INSERT OR IGNORE INTO projects(id,name,created_at) VALUES (?,?,?)").run(context.manifest.projectId, context.manifest.name, now());
|
|
273
|
+
db.query("INSERT OR IGNORE INTO authority_state(project_id,authority,revision,switched_at) VALUES (?,?,?,?)").run(context.manifest.projectId, "legacy", 0, now());
|
|
274
|
+
return new InitializationStore(context, databasePath, db);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
close(): void { this.db.close(); }
|
|
278
|
+
|
|
279
|
+
async registerWorkspace(options: { rebindWorkspaceId?: string } = {}): Promise<{ id: string; location: string; created: boolean }> {
|
|
280
|
+
const location = await realpath(this.context.root);
|
|
281
|
+
const existing = this.db.query("SELECT id, location FROM workspaces WHERE project_id=? AND location=?").get(this.context.manifest.projectId, location) as { id: string; location: string } | null;
|
|
282
|
+
if (existing) return { ...existing, created: false };
|
|
283
|
+
const repository = await repositoryIdentity(location);
|
|
284
|
+
if (options.rebindWorkspaceId) {
|
|
285
|
+
const changed = this.db.query("UPDATE workspaces SET location=?, repository_identity=?, updated_at=? WHERE id=? AND project_id=?").run(location, repository, now(), options.rebindWorkspaceId, this.context.manifest.projectId);
|
|
286
|
+
if (changed.changes !== 1) throw new Error(`Unknown workspace for rebind: ${options.rebindWorkspaceId}`);
|
|
287
|
+
return { id: options.rebindWorkspaceId, location, created: false };
|
|
288
|
+
}
|
|
289
|
+
const id = randomUUID();
|
|
290
|
+
this.db.query("INSERT INTO workspaces(id,project_id,location,repository_identity,created_at,updated_at) VALUES (?,?,?,?,?,?)").run(id, this.context.manifest.projectId, location, repository, now(), now());
|
|
291
|
+
return { id, location, created: true };
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
putScanConfig(input: ScanConfigInput): { id: string; revision: number; created: boolean } {
|
|
295
|
+
const repositories = input.repositories.map(item => ({
|
|
296
|
+
...item,
|
|
297
|
+
relativePath: item.relativePath === "." || path.isAbsolute(item.relativePath)
|
|
298
|
+
? item.relativePath
|
|
299
|
+
: validateRelative(item.relativePath),
|
|
300
|
+
}));
|
|
301
|
+
const requiredKinds = input.requiredKinds ?? [...FACT_KINDS];
|
|
302
|
+
if (input.mode === "fast" && requiredKinds.length === FACT_KINDS.length) throw new Error("Fast mode must explicitly limit required fact kinds");
|
|
303
|
+
const config = { ...input, repositories, requiredKinds, includes: input.includes ?? ["**/*"], excludes: input.excludes ?? [".git/**", ".planning/**", ".devflow/**"], adapterVersions: input.adapterVersions ?? {} };
|
|
304
|
+
const configJson = canonicalJson(config);
|
|
305
|
+
const contentHash = hash(configJson);
|
|
306
|
+
const existing = this.db.query("SELECT id, revision FROM scan_configs WHERE project_id=? AND content_hash=?").get(this.context.manifest.projectId, contentHash) as { id: string; revision: number } | null;
|
|
307
|
+
if (existing) return { ...existing, created: false };
|
|
308
|
+
const row = this.db.query("SELECT COALESCE(MAX(revision),0)+1 AS revision FROM scan_configs WHERE project_id=?").get(this.context.manifest.projectId) as { revision: number };
|
|
309
|
+
const id = randomUUID();
|
|
310
|
+
this.db.query("INSERT INTO scan_configs(id,project_id,revision,content_hash,config_json,created_at) VALUES (?,?,?,?,?,?)").run(id, this.context.manifest.projectId, row.revision, contentHash, configJson, now());
|
|
311
|
+
return { id, revision: row.revision, created: true };
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
createInputSnapshot(workspaceId: string, configId: string, files: Array<{ repository: string; path: string; contentHash: string; size?: number }>, stable = true): { id: string; fingerprint: string; created: boolean } {
|
|
315
|
+
const normalized = files.map(file => ({ ...file, path: validateRelative(file.path) })).sort((a, b) => `${a.repository}/${a.path}`.localeCompare(`${b.repository}/${b.path}`));
|
|
316
|
+
const manifestJson = canonicalJson(normalized);
|
|
317
|
+
const fingerprint = hash(manifestJson);
|
|
318
|
+
const existing = this.db.query("SELECT id,stable FROM input_snapshots WHERE workspace_id=? AND config_id=? AND fingerprint=?").get(workspaceId, configId, fingerprint) as { id: string; stable: number } | null;
|
|
319
|
+
if (existing) {
|
|
320
|
+
if (stable && !existing.stable) this.db.query("UPDATE input_snapshots SET stable=1 WHERE id=?").run(existing.id);
|
|
321
|
+
return { id: existing.id, fingerprint, created: false };
|
|
322
|
+
}
|
|
323
|
+
const id = randomUUID();
|
|
324
|
+
this.db.query("INSERT INTO input_snapshots(id,project_id,workspace_id,config_id,fingerprint,manifest_json,stable,created_at) VALUES (?,?,?,?,?,?,?,?)").run(id, this.context.manifest.projectId, workspaceId, configId, fingerprint, manifestJson, stable ? 1 : 0, now());
|
|
325
|
+
return { id, fingerprint, created: true };
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
confirmInputSnapshot(inputSnapshotId: string, fingerprint: string): void {
|
|
329
|
+
const result = this.db.query("UPDATE input_snapshots SET stable=1 WHERE id=? AND fingerprint=? AND stable=0").run(inputSnapshotId, fingerprint);
|
|
330
|
+
if (result.changes !== 1) {
|
|
331
|
+
const current = this.db.query("SELECT fingerprint,stable FROM input_snapshots WHERE id=?").get(inputSnapshotId) as { fingerprint: string; stable: number } | null;
|
|
332
|
+
if (!current) throw new Error(`Unknown input snapshot: ${inputSnapshotId}`);
|
|
333
|
+
if (current.fingerprint !== fingerprint) throw new Error("Source changed during initialization; refusing to publish mixed input");
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
startRun(input: { workspaceId: string; configId: string; inputSnapshotId: string; requestId?: string; leaseMs?: number }): { id: string; leaseToken: string; reused: boolean } {
|
|
338
|
+
const binding = this.db.query(`
|
|
339
|
+
SELECT i.id FROM input_snapshots i
|
|
340
|
+
JOIN workspaces w ON w.id=i.workspace_id
|
|
341
|
+
JOIN scan_configs c ON c.id=i.config_id
|
|
342
|
+
WHERE i.id=? AND i.workspace_id=? AND i.config_id=?
|
|
343
|
+
AND i.project_id=? AND w.project_id=i.project_id AND c.project_id=i.project_id
|
|
344
|
+
`).get(input.inputSnapshotId, input.workspaceId, input.configId, this.context.manifest.projectId);
|
|
345
|
+
if (!binding) throw new Error("Run input, workspace, and scan config do not belong to the same project boundary");
|
|
346
|
+
const requestHash = hash(canonicalJson({ workspaceId: input.workspaceId, configId: input.configId, inputSnapshotId: input.inputSnapshotId }));
|
|
347
|
+
if (input.requestId) {
|
|
348
|
+
const prior = this.db.query("SELECT id, lease_token AS leaseToken, request_hash AS requestHash FROM initialization_runs WHERE workspace_id=? AND request_id=?").get(input.workspaceId, input.requestId) as { id: string; leaseToken: string; requestHash: string } | null;
|
|
349
|
+
if (prior) {
|
|
350
|
+
if (prior.requestHash !== requestHash) throw new Error(`Idempotency conflict for request ${input.requestId}`);
|
|
351
|
+
return { id: prior.id, leaseToken: prior.leaseToken, reused: true };
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
const id = randomUUID(); const leaseToken = randomUUID(); const timestamp = now();
|
|
355
|
+
const expires = new Date(Date.now() + (input.leaseMs ?? 15 * 60_000)).toISOString();
|
|
356
|
+
this.db.query("INSERT INTO initialization_runs(id,project_id,workspace_id,config_id,input_snapshot_id,request_id,request_hash,status,lease_token,lease_expires_at,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)").run(id, this.context.manifest.projectId, input.workspaceId, input.configId, input.inputSnapshotId, input.requestId ?? null, requestHash, "collecting", leaseToken, expires, timestamp, timestamp);
|
|
357
|
+
return { id, leaseToken, reused: false };
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
private assertWritableRun(runId: string, leaseToken: string): { status: RunStatus; inputSnapshotId: string; workspaceId: string; configId: string } {
|
|
361
|
+
const row = this.db.query("SELECT status,input_snapshot_id AS inputSnapshotId,workspace_id AS workspaceId,config_id AS configId,lease_token AS leaseToken,lease_expires_at AS expires FROM initialization_runs WHERE id=?").get(runId) as ({ status: RunStatus; inputSnapshotId: string; workspaceId: string; configId: string; leaseToken: string; expires: string }) | null;
|
|
362
|
+
if (!row) throw new Error(`Unknown initialization run: ${runId}`);
|
|
363
|
+
if (row.leaseToken !== leaseToken) throw new Error("Initialization lease token mismatch");
|
|
364
|
+
if (Date.parse(row.expires) < Date.now()) throw new Error("Initialization lease expired; recover the run before writing");
|
|
365
|
+
if (["cancelled", "failed", "finished"].includes(row.status)) throw new Error(`Initialization run is not writable: ${row.status}`);
|
|
366
|
+
return row;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
recordCollection(runId: string, leaseToken: string, input: { repository?: string; kind: FactKind; status: CoverageStatus; adapter: string; capability?: Record<string, unknown>; diagnostics?: unknown[]; facts?: FactInput[] }): void {
|
|
370
|
+
this.assertWritableRun(runId, leaseToken);
|
|
371
|
+
const transaction = this.db.transaction(() => {
|
|
372
|
+
this.db.query("INSERT INTO collection_results(id,run_id,repository,kind,status,adapter,capability_json,diagnostics_json,created_at) VALUES (?,?,?,?,?,?,?,?,?) ON CONFLICT(run_id,repository,kind) DO UPDATE SET status=excluded.status,adapter=excluded.adapter,capability_json=excluded.capability_json,diagnostics_json=excluded.diagnostics_json").run(randomUUID(), runId, input.repository ?? "", input.kind, input.status, input.adapter, canonicalJson(input.capability ?? {}), canonicalJson(input.diagnostics ?? []), now());
|
|
373
|
+
for (const fact of input.facts ?? []) {
|
|
374
|
+
if (fact.kind !== input.kind) throw new Error(`Fact kind ${fact.kind} does not match collection ${input.kind}`);
|
|
375
|
+
if (!fact.objectKey || !fact.extractor) throw new Error("Facts require objectKey and extractor");
|
|
376
|
+
if (fact.evidenceLevel === "observed" && !fact.sourcePath) throw new Error("Observed facts require source evidence");
|
|
377
|
+
validateFactShape(fact);
|
|
378
|
+
const factId = randomUUID();
|
|
379
|
+
this.db.query("INSERT INTO facts(id,run_id,kind,object_key,repository,value_json,evidence_level,extractor,confidence,created_at) VALUES (?,?,?,?,?,?,?,?,?,?) ON CONFLICT(run_id,kind,repository,object_key) DO UPDATE SET value_json=excluded.value_json,evidence_level=excluded.evidence_level,extractor=excluded.extractor,confidence=excluded.confidence").run(factId, runId, fact.kind, fact.objectKey, fact.repository ?? input.repository ?? "", canonicalJson(fact.value), fact.evidenceLevel, fact.extractor, fact.confidence ?? "inferred", now());
|
|
380
|
+
const stored = this.db.query("SELECT id FROM facts WHERE run_id=? AND kind=? AND repository=? AND object_key=?").get(runId, fact.kind, fact.repository ?? input.repository ?? "", fact.objectKey) as { id: string };
|
|
381
|
+
if (fact.sourcePath) this.db.query("INSERT OR IGNORE INTO evidence(id,fact_id,source_path,source_line,source_fingerprint,method,created_at) VALUES (?,?,?,?,?,?,?)").run(randomUUID(), stored.id, validateRelative(fact.sourcePath), fact.sourceLine ?? null, fact.sourceFingerprint ?? "unavailable", fact.extractor, now());
|
|
382
|
+
}
|
|
383
|
+
});
|
|
384
|
+
transaction();
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
cancelRun(runId: string): boolean {
|
|
388
|
+
return this.db.query("UPDATE initialization_runs SET status='cancelled',updated_at=? WHERE id=? AND status IN ('preparing','collecting','validating')").run(now(), runId).changes === 1;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
failRun(runId: string, leaseToken: string, error: string): boolean {
|
|
392
|
+
return this.db.query("UPDATE initialization_runs SET status='failed',error=?,updated_at=? WHERE id=? AND lease_token=? AND status IN ('preparing','collecting','validating')").run(error.slice(0, 4000), now(), runId, leaseToken).changes === 1;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
recoverRun(runId: string, leaseMs = 15 * 60_000): { leaseToken: string } {
|
|
396
|
+
const row = this.db.query("SELECT status,lease_expires_at AS expires FROM initialization_runs WHERE id=?").get(runId) as { status: RunStatus; expires: string } | null;
|
|
397
|
+
if (!row) throw new Error(`Unknown initialization run: ${runId}`);
|
|
398
|
+
if (row.status === "cancelled") throw new Error("Cancelled runs cannot resume automatically");
|
|
399
|
+
if (["failed", "finished"].includes(row.status)) throw new Error(`Run cannot be recovered from ${row.status}`);
|
|
400
|
+
if (Date.parse(row.expires) >= Date.now()) throw new Error("Run lease is still active");
|
|
401
|
+
const leaseToken = randomUUID();
|
|
402
|
+
this.db.query("UPDATE initialization_runs SET lease_token=?,lease_expires_at=?,updated_at=? WHERE id=?").run(leaseToken, new Date(Date.now() + leaseMs).toISOString(), now(), runId);
|
|
403
|
+
return { leaseToken };
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
publishRun(runId: string, leaseToken: string, options: { expectedRevision?: number; completeness?: "complete" | "partial"; indexRef?: Record<string, unknown>; updateCurrent?: boolean } = {}): { snapshotId: string; revision: number } {
|
|
407
|
+
const run = this.assertWritableRun(runId, leaseToken);
|
|
408
|
+
const input = this.db.query("SELECT stable,fingerprint FROM input_snapshots WHERE id=?").get(run.inputSnapshotId) as { stable: number; fingerprint: string };
|
|
409
|
+
if (!input.stable) throw new Error("Cannot publish an unstable input snapshot");
|
|
410
|
+
const config = this.db.query("SELECT config_json AS json FROM scan_configs WHERE id=?").get(run.configId) as { json: string };
|
|
411
|
+
const parsedConfig = parse<ScanConfigInput>(config.json);
|
|
412
|
+
const required = parsedConfig.requiredKinds ?? [...FACT_KINDS];
|
|
413
|
+
const results = this.db.query("SELECT repository,kind,status FROM collection_results WHERE run_id=?").all(runId) as Array<{ repository: string; kind: FactKind; status: CoverageStatus }>;
|
|
414
|
+
const coverage = new Map(results.map(row => [`${row.repository}\0${row.kind}`, row.status]));
|
|
415
|
+
const blockers = parsedConfig.repositories.flatMap(repository => required.flatMap(kind => {
|
|
416
|
+
// Empty repository is the v1/global collection form. Keep it readable
|
|
417
|
+
// while all new collectors write repository-scoped coverage.
|
|
418
|
+
const status = coverage.get(`${repository.name}\0${kind}`) ?? coverage.get(`\0${kind}`);
|
|
419
|
+
return !status || ["partial", "unsupported", "skipped", "failed"].includes(status) ? [`${repository.name}:${kind}`] : [];
|
|
420
|
+
}));
|
|
421
|
+
const completeness = options.completeness ?? (blockers.length ? "partial" : "complete");
|
|
422
|
+
if (completeness === "complete" && blockers.length) throw new Error(`Required collection coverage is incomplete: ${blockers.join(", ")}`);
|
|
423
|
+
const current = this.db.query("SELECT revision FROM current_context WHERE workspace_id=? AND config_id=?").get(run.workspaceId, run.configId) as { revision: number } | null;
|
|
424
|
+
const revision = (current?.revision ?? 0) + 1;
|
|
425
|
+
if (options.expectedRevision !== undefined && options.expectedRevision !== (current?.revision ?? 0)) throw new Error(`Current context revision conflict: expected ${options.expectedRevision}, actual ${current?.revision ?? 0}`);
|
|
426
|
+
const facts = this.db.query("SELECT kind,repository,object_key,value_json,evidence_level,extractor,confidence FROM facts WHERE run_id=? ORDER BY kind,repository,object_key").all(runId);
|
|
427
|
+
const snapshotId = randomUUID();
|
|
428
|
+
const transaction = this.db.transaction(() => {
|
|
429
|
+
this.db.query("UPDATE initialization_runs SET status='validating',updated_at=? WHERE id=? AND lease_token=?").run(now(), runId, leaseToken);
|
|
430
|
+
this.db.query("INSERT INTO context_snapshots(id,project_id,workspace_id,config_id,input_snapshot_id,run_id,completeness,content_hash,index_ref_json,created_at) VALUES (?,?,?,?,?,?,?,?,?,?)").run(snapshotId, this.context.manifest.projectId, run.workspaceId, run.configId, run.inputSnapshotId, runId, completeness, hash(canonicalJson(facts)), options.indexRef ? canonicalJson(options.indexRef) : null, now());
|
|
431
|
+
if (options.updateCurrent === false) {
|
|
432
|
+
// Shadow imports are immutable and queryable without becoming current.
|
|
433
|
+
} else if (current) {
|
|
434
|
+
const updated = this.db.query("UPDATE current_context SET snapshot_id=?,revision=? WHERE workspace_id=? AND config_id=? AND revision=?").run(snapshotId, revision, run.workspaceId, run.configId, current.revision);
|
|
435
|
+
if (updated.changes !== 1) throw new Error("Current context changed while publishing");
|
|
436
|
+
}
|
|
437
|
+
else this.db.query("INSERT INTO current_context(workspace_id,config_id,snapshot_id,revision) VALUES (?,?,?,?)").run(run.workspaceId, run.configId, snapshotId, revision);
|
|
438
|
+
this.db.query("UPDATE initialization_runs SET status='finished',published_snapshot_id=?,updated_at=? WHERE id=? AND lease_token=?").run(snapshotId, now(), runId, leaseToken);
|
|
439
|
+
});
|
|
440
|
+
transaction();
|
|
441
|
+
return { snapshotId, revision };
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
getRun(runId: string): Record<string, unknown> | null {
|
|
445
|
+
const run = this.db.query("SELECT id,status,workspace_id AS workspaceId,config_id AS configId,input_snapshot_id AS inputSnapshotId,published_snapshot_id AS publishedSnapshotId,error,created_at AS createdAt,updated_at AS updatedAt FROM initialization_runs WHERE id=?").get(runId) as Record<string, unknown> | null;
|
|
446
|
+
if (!run) return null;
|
|
447
|
+
return { ...run, collections: this.db.query("SELECT repository,kind,status,adapter,capability_json AS capability,diagnostics_json AS diagnostics FROM collection_results WHERE run_id=? ORDER BY repository,kind").all(runId).map((row: any) => ({ ...row, capability: parse(row.capability), diagnostics: parse(row.diagnostics) })) };
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
queryContext(input: { workspaceId?: string; configId?: string; snapshotId?: string; kind?: FactKind; repository?: string; after?: string; limit?: number }): Record<string, unknown> {
|
|
451
|
+
let snapshotId = input.snapshotId;
|
|
452
|
+
let revision: number | undefined;
|
|
453
|
+
if (!snapshotId) {
|
|
454
|
+
if (!input.workspaceId || !input.configId) throw new Error("Current context queries require workspaceId and configId");
|
|
455
|
+
const current = this.db.query("SELECT snapshot_id AS snapshotId,revision FROM current_context WHERE workspace_id=? AND config_id=?").get(input.workspaceId, input.configId) as { snapshotId: string; revision: number } | null;
|
|
456
|
+
if (!current) throw new Error("No current initialization snapshot");
|
|
457
|
+
snapshotId = current.snapshotId; revision = current.revision;
|
|
458
|
+
}
|
|
459
|
+
const snapshot = this.db.query("SELECT id,run_id AS runId,completeness,content_hash AS contentHash,index_ref_json AS indexRef,created_at AS createdAt FROM context_snapshots WHERE id=?").get(snapshotId) as Record<string, unknown> | null;
|
|
460
|
+
if (!snapshot) throw new Error(`Unknown context snapshot: ${snapshotId}`);
|
|
461
|
+
if (typeof snapshot.indexRef === "string") snapshot.indexRef = parse(snapshot.indexRef);
|
|
462
|
+
const clauses = ["run_id=?"]; const params: unknown[] = [snapshot.runId];
|
|
463
|
+
if (input.kind) { clauses.push("kind=?"); params.push(input.kind); }
|
|
464
|
+
if (input.repository !== undefined) { clauses.push("repository=?"); params.push(input.repository); }
|
|
465
|
+
if (input.after) {
|
|
466
|
+
let cursor: [string, string, string];
|
|
467
|
+
try { cursor = JSON.parse(Buffer.from(input.after, "base64url").toString("utf8")); }
|
|
468
|
+
catch { throw new Error("Invalid context pagination cursor"); }
|
|
469
|
+
if (!Array.isArray(cursor) || cursor.length !== 3 || cursor.some(item => typeof item !== "string")) throw new Error("Invalid context pagination cursor");
|
|
470
|
+
clauses.push("(kind>? OR (kind=? AND repository>?) OR (kind=? AND repository=? AND object_key>?))");
|
|
471
|
+
params.push(cursor[0], cursor[0], cursor[1], cursor[0], cursor[1], cursor[2]);
|
|
472
|
+
}
|
|
473
|
+
const limit = Math.min(Math.max(input.limit ?? 100, 1), 500); params.push(limit + 1);
|
|
474
|
+
const rows = this.db.query(`SELECT id,kind,object_key AS objectKey,repository,value_json AS value,evidence_level AS evidenceLevel,extractor,confidence FROM facts WHERE ${clauses.join(" AND ")} ORDER BY kind,repository,object_key LIMIT ?`).all(...params as any[]) as any[];
|
|
475
|
+
const truncated = rows.length > limit; const page = rows.slice(0, limit).map(row => ({ ...row, value: parse(row.value) }));
|
|
476
|
+
const last = page.at(-1);
|
|
477
|
+
const nextCursor = truncated && last ? Buffer.from(JSON.stringify([last.kind, last.repository, last.objectKey])).toString("base64url") : null;
|
|
478
|
+
return { snapshot: { ...snapshot, revision }, facts: page, truncated, nextCursor };
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
queryCurrent(workspaceId: string, input: { kind?: FactKind; repository?: string; after?: string; limit?: number } = {}): Record<string, unknown> {
|
|
482
|
+
const current = this.db.query("SELECT config_id AS configId FROM current_context WHERE workspace_id=? ORDER BY revision DESC LIMIT 1").get(workspaceId) as { configId: string } | null;
|
|
483
|
+
if (!current) throw new Error("No current initialization snapshot");
|
|
484
|
+
return this.queryContext({ workspaceId, configId: current.configId, ...input });
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
getInitializationConfiguration(workspaceId: string): Record<string, unknown> {
|
|
488
|
+
const row = this.db.query(`SELECT c.id,c.revision,c.content_hash AS contentHash,c.config_json AS config
|
|
489
|
+
FROM scan_configs c LEFT JOIN current_context p ON p.config_id=c.id AND p.workspace_id=?
|
|
490
|
+
WHERE c.project_id=? ORDER BY (p.workspace_id IS NOT NULL) DESC,c.revision DESC LIMIT 1`).get(workspaceId, this.context.manifest.projectId) as { id: string; revision: number; contentHash: string; config: string } | null;
|
|
491
|
+
if (!row) throw new Error("No initialization scan configuration");
|
|
492
|
+
return { ...row, config: parse(row.config) };
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
listWorkspaces(): Array<Record<string, unknown>> {
|
|
496
|
+
return this.db.query("SELECT id,location,repository_identity AS repositoryIdentity,created_at AS createdAt,updated_at AS updatedAt FROM workspaces WHERE project_id=? ORDER BY created_at").all(this.context.manifest.projectId) as Array<Record<string, unknown>>;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
compareSnapshots(fromSnapshotId: string, toSnapshotId: string): Record<string, unknown> {
|
|
500
|
+
if (fromSnapshotId === toSnapshotId) return { comparable: true, fromSnapshotId, toSnapshotId, added: [], changed: [], disappeared: [] };
|
|
501
|
+
const snapshots = this.db.query("SELECT id,config_id AS configId FROM context_snapshots WHERE id IN (?,?)").all(fromSnapshotId, toSnapshotId) as Array<{ id: string; configId: string }>;
|
|
502
|
+
if (snapshots.length !== 2) throw new Error("Both context snapshots must exist");
|
|
503
|
+
const fromMeta = snapshots.find(item => item.id === fromSnapshotId)!;
|
|
504
|
+
const toMeta = snapshots.find(item => item.id === toSnapshotId)!;
|
|
505
|
+
if (fromMeta.configId !== toMeta.configId) return { comparable: false, reason: "scan_config_changed", added: [], changed: [], disappeared: [] };
|
|
506
|
+
const rows = (snapshotId: string) => this.db.query(`SELECT f.kind,f.repository,f.object_key AS objectKey,f.value_json AS value
|
|
507
|
+
FROM facts f JOIN context_snapshots s ON s.run_id=f.run_id WHERE s.id=?`).all(snapshotId) as Array<{ kind: string; repository: string; objectKey: string; value: string }>;
|
|
508
|
+
const key = (row: { kind: string; repository: string; objectKey: string }) => `${row.kind}\0${row.repository}\0${row.objectKey}`;
|
|
509
|
+
const before = new Map(rows(fromSnapshotId).map(row => [key(row), row]));
|
|
510
|
+
const after = new Map(rows(toSnapshotId).map(row => [key(row), row]));
|
|
511
|
+
const added = [...after].filter(([item]) => !before.has(item)).map(([, row]) => ({ ...row, value: parse(row.value) }));
|
|
512
|
+
const disappeared = [...before].filter(([item]) => !after.has(item)).map(([, row]) => ({ ...row, value: parse(row.value) }));
|
|
513
|
+
const changed = [...after].filter(([item, row]) => before.has(item) && before.get(item)!.value !== row.value).map(([, row]) => ({ before: parse(before.get(key(row))!.value), after: parse(row.value), kind: row.kind, repository: row.repository, objectKey: row.objectKey }));
|
|
514
|
+
return { comparable: true, fromSnapshotId, toSnapshotId, added, changed, disappeared };
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
createDocumentJob(snapshotId: string, input: { targetPath: string; ownership: "generated" | "human" | "mixed"; templateVersion: string; modelRole?: string; input: unknown }): string {
|
|
518
|
+
const id = randomUUID(); const timestamp = now();
|
|
519
|
+
const targetPath = validateRelative(input.targetPath);
|
|
520
|
+
this.db.query("INSERT OR IGNORE INTO document_jobs(id,snapshot_id,target_path,ownership,template_version,model_role,status,input_hash,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?,?)").run(id, snapshotId, targetPath, input.ownership, input.templateVersion, input.modelRole ?? null, input.ownership === "human" ? "complete" : "queued", hash(canonicalJson(input.input)), timestamp, timestamp);
|
|
521
|
+
const row = this.db.query("SELECT id FROM document_jobs WHERE snapshot_id=? AND target_path=? AND template_version=?").get(snapshotId, targetPath, input.templateVersion) as { id: string };
|
|
522
|
+
return row.id;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
latestDocumentHash(targetPath: string): string | null {
|
|
526
|
+
const row = this.db.query("SELECT output_hash AS outputHash FROM document_jobs WHERE target_path=? AND status='complete' AND output_hash IS NOT NULL ORDER BY updated_at DESC LIMIT 1").get(validateRelative(targetPath)) as { outputHash: string } | null;
|
|
527
|
+
return row?.outputHash ?? null;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
documentJobs(snapshotId: string): Array<Record<string, unknown>> {
|
|
531
|
+
return this.db.query("SELECT id,target_path AS targetPath,ownership,template_version AS templateVersion,model_role AS modelRole,status,output_hash AS outputHash,conflict_path AS conflictPath,error FROM document_jobs WHERE snapshot_id=? ORDER BY target_path").all(snapshotId) as Array<Record<string, unknown>>;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
failDocumentJob(jobId: string, error: string): void {
|
|
535
|
+
this.db.query("UPDATE document_jobs SET status='failed',error=?,updated_at=? WHERE id=? AND status NOT IN ('complete','conflict')").run(error.slice(0, 4000), now(), jobId);
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
async writeDocument(jobId: string, content: string, options: { expectedCurrentHash?: string; generatedRegion?: { start: string; end: string } } = {}): Promise<{ status: "complete" | "conflict"; path: string }> {
|
|
539
|
+
const job = this.db.query("SELECT target_path AS targetPath,ownership,status FROM document_jobs WHERE id=?").get(jobId) as { targetPath: string; ownership: string; status: string } | null;
|
|
540
|
+
if (!job) throw new Error(`Unknown document job: ${jobId}`);
|
|
541
|
+
if (job.ownership === "human") throw new Error("Human-owned documents cannot be generated");
|
|
542
|
+
this.db.query("UPDATE document_jobs SET status='generating',error=NULL,updated_at=? WHERE id=? AND status IN ('queued','failed','pending_review')").run(now(), jobId);
|
|
543
|
+
const target = path.resolve(this.context.root, job.targetPath);
|
|
544
|
+
if (target !== this.context.root && !target.startsWith(`${this.context.root}${path.sep}`)) throw new Error("Document path escapes project root");
|
|
545
|
+
let existing: string | null = null;
|
|
546
|
+
try { existing = await readFile(target, "utf8"); } catch {}
|
|
547
|
+
if (existing !== null && options.expectedCurrentHash && hash(existing) !== options.expectedCurrentHash) {
|
|
548
|
+
const conflict = `${target}.devflow-pending`;
|
|
549
|
+
await writeFile(conflict, content, "utf8");
|
|
550
|
+
this.db.query("UPDATE document_jobs SET status='conflict',conflict_path=?,updated_at=? WHERE id=?").run(path.relative(this.context.root, conflict), now(), jobId);
|
|
551
|
+
return { status: "conflict", path: conflict };
|
|
552
|
+
}
|
|
553
|
+
let output = content;
|
|
554
|
+
if (job.ownership === "mixed") {
|
|
555
|
+
if (!options.generatedRegion) throw new Error("Mixed documents require a generated region contract");
|
|
556
|
+
const current = existing ?? ""; const start = current.indexOf(options.generatedRegion.start); const end = current.indexOf(options.generatedRegion.end, start + options.generatedRegion.start.length);
|
|
557
|
+
if (start < 0 || end < 0) throw new Error("Mixed document generated region is missing");
|
|
558
|
+
output = `${current.slice(0, start + options.generatedRegion.start.length)}\n${content}\n${current.slice(end)}`;
|
|
559
|
+
}
|
|
560
|
+
await mkdir(path.dirname(target), { recursive: true });
|
|
561
|
+
const temporary = `${target}.${randomUUID()}.tmp`; await writeFile(temporary, output, { encoding: "utf8", flag: "wx" }); await rename(temporary, target);
|
|
562
|
+
this.db.query("UPDATE document_jobs SET status='complete',output_hash=?,updated_at=? WHERE id=?").run(hash(output), now(), jobId);
|
|
563
|
+
return { status: "complete", path: target };
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
async backup(target: string): Promise<void> {
|
|
567
|
+
const resolved = path.resolve(target);
|
|
568
|
+
await mkdir(path.dirname(resolved), { recursive: true });
|
|
569
|
+
this.db.exec("PRAGMA wal_checkpoint(TRUNCATE)");
|
|
570
|
+
await writeFile(resolved, this.db.serialize(), { flag: "wx" });
|
|
571
|
+
const contentStatus = await this.verifyContent();
|
|
572
|
+
const invalid = contentStatus.filter(item => item.status !== "valid");
|
|
573
|
+
if (invalid.length) { await rm(resolved, { force: true }); throw new Error(`Cannot back up missing or corrupt content: ${invalid.map(item => item.contentHash).join(", ")}`); }
|
|
574
|
+
const contentRoot = path.join(projectDataDir(this.context.manifest.projectId), "content");
|
|
575
|
+
const sidecar = `${resolved}.content`;
|
|
576
|
+
await cp(contentRoot, sidecar, { recursive: true, errorOnExist: true, force: false });
|
|
577
|
+
await writeFile(`${resolved}.manifest.json`, `${JSON.stringify({ schemaVersion: INITIALIZATION_SCHEMA_VERSION, projectId: this.context.manifest.projectId, databaseHash: hash(await readFile(resolved)), content: contentStatus }, null, 2)}\n`, { flag: "wx" });
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
getAuthority(): { authority: "legacy" | "shadow" | "database"; revision: number; switchedAt: string } {
|
|
582
|
+
const row = this.db.query("SELECT authority,revision,switched_at AS switchedAt FROM authority_state WHERE project_id=?").get(this.context.manifest.projectId) as { authority: "legacy" | "shadow" | "database"; revision: number; switchedAt: string };
|
|
583
|
+
return row;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
switchAuthority(authority: "legacy" | "shadow" | "database", expectedRevision: number): { authority: string; revision: number } {
|
|
587
|
+
if (authority === "database") {
|
|
588
|
+
const published = this.db.query("SELECT 1 FROM current_context c JOIN context_snapshots s ON s.id=c.snapshot_id WHERE s.project_id=? LIMIT 1").get(this.context.manifest.projectId);
|
|
589
|
+
if (!published) throw new Error("Cannot switch to database authority without a current published snapshot");
|
|
590
|
+
}
|
|
591
|
+
const result = this.db.query("UPDATE authority_state SET authority=?,revision=revision+1,switched_at=? WHERE project_id=? AND revision=?").run(authority, now(), this.context.manifest.projectId, expectedRevision);
|
|
592
|
+
if (result.changes !== 1) throw new Error("Initialization authority revision conflict");
|
|
593
|
+
return { authority, revision: expectedRevision + 1 };
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
registerHistoricalImport(workspaceId: string, sourcePath: string, contentHash: string, snapshotId?: string): string {
|
|
597
|
+
const id = randomUUID();
|
|
598
|
+
this.db.query("INSERT OR IGNORE INTO historical_imports(id,project_id,workspace_id,source_path,content_hash,status,snapshot_id,created_at) VALUES (?,?,?,?,?,'shadow',?,?)").run(id, this.context.manifest.projectId, workspaceId, validateRelative(sourcePath), contentHash, snapshotId ?? null, now());
|
|
599
|
+
const row = this.db.query("SELECT id FROM historical_imports WHERE workspace_id=? AND source_path=? AND content_hash=?").get(workspaceId, validateRelative(sourcePath), contentHash) as { id: string };
|
|
600
|
+
return row.id;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
listHistoricalImports(workspaceId: string): Array<Record<string, unknown>> {
|
|
604
|
+
return this.db.query("SELECT id,source_path AS sourcePath,content_hash AS contentHash,status,snapshot_id AS snapshotId,created_at AS createdAt FROM historical_imports WHERE workspace_id=? ORDER BY created_at").all(workspaceId) as Array<Record<string, unknown>>;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
async putContent(content: Uint8Array): Promise<{ contentHash: string; path: string; size: number }> {
|
|
608
|
+
const contentHash = hash(content); const name = contentHash.slice("sha256:".length); const relative = path.join("content", name.slice(0, 2), name);
|
|
609
|
+
const target = path.join(projectDataDir(this.context.manifest.projectId), relative);
|
|
610
|
+
const existing = this.db.query("SELECT relative_path AS path,size_bytes AS size FROM content_objects WHERE content_hash=?").get(contentHash) as { path: string; size: number } | null;
|
|
611
|
+
if (existing) return { contentHash, ...existing };
|
|
612
|
+
await mkdir(path.dirname(target), { recursive: true });
|
|
613
|
+
const temporary = `${target}.${randomUUID()}.tmp`;
|
|
614
|
+
await writeFile(temporary, content, { flag: "wx" }); await rename(temporary, target);
|
|
615
|
+
this.db.query("INSERT INTO content_objects(content_hash,relative_path,size_bytes,created_at) VALUES (?,?,?,?)").run(contentHash, relative, content.byteLength, now());
|
|
616
|
+
return { contentHash, path: relative, size: content.byteLength };
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
async verifyContent(): Promise<Array<{ contentHash: string; status: "valid" | "missing" | "corrupt" }>> {
|
|
620
|
+
const rows = this.db.query("SELECT content_hash AS contentHash,relative_path AS relativePath FROM content_objects").all() as Array<{ contentHash: string; relativePath: string }>;
|
|
621
|
+
return Promise.all(rows.map(async row => {
|
|
622
|
+
try {
|
|
623
|
+
const content = await readFile(path.join(projectDataDir(this.context.manifest.projectId), row.relativePath));
|
|
624
|
+
return { contentHash: row.contentHash, status: hash(content) === row.contentHash ? "valid" as const : "corrupt" as const };
|
|
625
|
+
} catch { return { contentHash: row.contentHash, status: "missing" as const }; }
|
|
626
|
+
}));
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
static async restore(context: InitializationContextLike, backupPath: string): Promise<void> {
|
|
630
|
+
const resolvedBackup = path.resolve(backupPath);
|
|
631
|
+
const manifest = JSON.parse(await readFile(`${resolvedBackup}.manifest.json`, "utf8")) as { schemaVersion?: number; projectId?: string; databaseHash?: string; content?: Array<{ contentHash: string; status: string }> };
|
|
632
|
+
if (manifest.projectId !== context.manifest.projectId || manifest.databaseHash !== hash(await readFile(resolvedBackup))) throw new Error("Backup manifest validation failed");
|
|
633
|
+
const backup = new Database(resolvedBackup, { readonly: true });
|
|
634
|
+
try {
|
|
635
|
+
const meta = backup.query("SELECT version FROM schema_meta LIMIT 1").get() as { version: number } | null;
|
|
636
|
+
const project = backup.query("SELECT 1 FROM projects WHERE id=?").get(context.manifest.projectId);
|
|
637
|
+
const violations = backup.query("PRAGMA foreign_key_check").all();
|
|
638
|
+
if (!meta || meta.version > INITIALIZATION_SCHEMA_VERSION || !project || violations.length) throw new Error("Backup validation failed");
|
|
639
|
+
} finally { backup.close(); }
|
|
640
|
+
const sidecar = `${resolvedBackup}.content`;
|
|
641
|
+
for (const expected of manifest.content ?? []) {
|
|
642
|
+
const name = expected.contentHash.slice("sha256:".length); const source = path.join(sidecar, name.slice(0, 2), name);
|
|
643
|
+
if (hash(await readFile(source)) !== expected.contentHash) throw new Error(`Backup content validation failed: ${expected.contentHash}`);
|
|
644
|
+
}
|
|
645
|
+
const target = projectControlDatabasePath(context.manifest.projectId); const temporary = `${target}.${randomUUID()}.restore`;
|
|
646
|
+
await copyFile(resolvedBackup, temporary); await rename(temporary, target).catch(async error => { await rm(temporary, { force: true }); throw error; });
|
|
647
|
+
await cp(sidecar, path.join(projectDataDir(context.manifest.projectId), "content"), { recursive: true, force: true });
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
export async function fingerprintFile(root: string, relativePath: string): Promise<{ path: string; contentHash: string; size: number }> {
|
|
652
|
+
const relative = validateRelative(relativePath); const target = path.resolve(root, relative);
|
|
653
|
+
if (!target.startsWith(`${path.resolve(root)}${path.sep}`)) throw new Error(`Path escapes project boundary: ${relativePath}`);
|
|
654
|
+
const [content, info] = await Promise.all([readFile(target), stat(target)]);
|
|
655
|
+
return { path: relative, contentHash: hash(content), size: info.size };
|
|
656
|
+
}
|