diff --git a/libs/knowledge-graph/service.ts b/libs/knowledge-graph/service.ts index ef969dd..1c4b262 100644 --- a/libs/knowledge-graph/service.ts +++ b/libs/knowledge-graph/service.ts @@ -159,20 +159,29 @@ export class KnowledgeGraphService { } const working = this.repository.requireWorkingScope(initialized.repo_id); - const memoryCommit = this.repository.createMemoryCommit({ - scope_id: working.id, - title: normalizedProposal.title, - summary: normalizedProposal.summary, - }); - const anchorFingerprints = await computeAnchorFingerprints(input.repo_root, normalizedProposal.creates.claims ?? []); - this.repository.createProposalRecords(working.id, memoryCommit.id, normalizedProposal, anchorFingerprints); - const embeddingStatus = await this.contextBuilder.ensureForGraph( - initialized.repo_id, - this.repository.readGraphView(initialized.repo_id), - this.contextConfig, + const memoryCommit = this.repository.createMemoryCommitWithProposal( + { + scope_id: working.id, + title: normalizedProposal.title, + summary: normalizedProposal.summary, + }, + normalizedProposal, + anchorFingerprints, ); + let embeddingStatus: EmbeddingStatus; + try { + embeddingStatus = await this.contextBuilder.ensureForGraph( + initialized.repo_id, + this.repository.readGraphView(initialized.repo_id), + this.contextConfig, + ); + } catch (error: unknown) { + this.repository.rollbackProposalRecords(working.id, memoryCommit.id, normalizedProposal); + throw error; + } + return { memory_commit_id: memoryCommit.id, scope_id: working.id, diff --git a/libs/storage/sqlite/repository.ts b/libs/storage/sqlite/repository.ts index 6851c8d..21d88d4 100644 --- a/libs/storage/sqlite/repository.ts +++ b/libs/storage/sqlite/repository.ts @@ -302,6 +302,19 @@ export class SqliteRepository { return memoryCommit; } + createMemoryCommitWithProposal( + input: CreateMemoryCommitInput, + proposal: MemoryCommitProposal, + anchorFingerprints?: Map>, + ): MemoryCommit { + const write = this.db.transaction(() => { + const memoryCommit = this.createMemoryCommit(input); + this.createProposalRecords(input.scope_id, memoryCommit.id, proposal, anchorFingerprints); + return memoryCommit; + }); + return write() as MemoryCommit; + } + createProposalRecords( scopeId: string, memoryCommitId: string, @@ -362,6 +375,22 @@ export class SqliteRepository { write(); } + rollbackProposalRecords(scopeId: string, memoryCommitId: string, proposal: MemoryCommitProposal): void { + const write = this.db.transaction(() => { + const repoId = this.repoIdForScope(scopeId); + this.deleteEmbeddings(repoId, "component", proposal.creates.components?.map((component) => component.id) ?? []); + this.deleteEmbeddings(repoId, "flow", proposal.creates.flows?.map((flow) => flow.id) ?? []); + this.deleteEmbeddings(repoId, "claim", proposal.creates.claims?.map((claim) => claim.id) ?? []); + this.deleteByIds(repoId, "edges", proposal.creates.edges?.map((edge) => edge.id) ?? []); + this.deleteByIds(repoId, "components", proposal.creates.components?.map((component) => component.id) ?? []); + this.deleteByIds(repoId, "flows", proposal.creates.flows?.map((flow) => flow.id) ?? []); + this.deleteByIds(repoId, "claims", proposal.creates.claims?.map((claim) => claim.id) ?? []); + this.deleteByIds(repoId, "sources", proposal.creates.sources?.map((source) => source.id) ?? []); + this.db.prepare("DELETE FROM memory_commits WHERE id = ? AND scope_id = ?").run(memoryCommitId, scopeId); + }); + write(); + } + subjectExists(repoId: string, type: GraphObjectType, id: string): boolean { const table = tableForType(type); const row = this.db.prepare(`SELECT id FROM ${table} WHERE repo_id = ? AND id = ?`).get(repoId, id); @@ -421,6 +450,21 @@ export class SqliteRepository { .run(scopeId, subjectType, subjectId, memoryCommitId); } + private deleteEmbeddings(repoId: string, objectType: EmbeddingObjectType, ids: string[]): void { + if (ids.length === 0) return; + this.db + .prepare( + `DELETE FROM graph_object_embeddings + WHERE repo_id = ? AND object_type = ? AND object_id IN (${placeholders(ids)})`, + ) + .run(repoId, objectType, ...ids); + } + + private deleteByIds(repoId: string, table: string, ids: string[]): void { + if (ids.length === 0) return; + this.db.prepare(`DELETE FROM ${table} WHERE repo_id = ? AND id IN (${placeholders(ids)})`).run(repoId, ...ids); + } + private repoIdForScope(scopeId: string): string { const row = this.db.prepare("SELECT repo_id FROM graph_scopes WHERE id = ?").get(scopeId) as | { repo_id: string } diff --git a/package.json b/package.json index cccc5eb..3d085a8 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,7 @@ "smoke:openhands": "npm run build && node scripts/smoke-openhands-install.mjs", "smoke:copilot": "npm run build && node scripts/smoke-copilot-install.mjs", "smoke:opencode": "npm run build && node scripts/smoke-opencode-install.mjs", - "test": "npm run build && node scripts/check-transcript-bundle.js && node scripts/check-repo-context.js && node scripts/check-install-options.js && node scripts/check-graph-view.js && node scripts/check-graph-view-offline-browser.js && node scripts/check-proposal-validate.js && node scripts/check-bm25-tokenizer.js && node scripts/check-anchor-drift.js", + "test": "npm run build && node scripts/check-transcript-bundle.js && node scripts/check-repo-context.js && node scripts/check-install-options.js && node scripts/check-graph-view.js && node scripts/check-graph-view-offline-browser.js && node scripts/check-proposal-validate.js && node scripts/check-proposal-apply-atomicity.js && node scripts/check-bm25-tokenizer.js && node scripts/check-anchor-drift.js", "test:transcript-bundle": "npm run build && node scripts/check-transcript-bundle.js", "test:repo-context": "npm run build && node scripts/check-repo-context.js", "eval:bootstrap-current": "npm run build && node dist/evals/cases/bootstrap-current-repo-at-8038fe8/run.js", diff --git a/scripts/check-proposal-apply-atomicity.js b/scripts/check-proposal-apply-atomicity.js new file mode 100644 index 0000000..1e220cc --- /dev/null +++ b/scripts/check-proposal-apply-atomicity.js @@ -0,0 +1,119 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const root = new URL("..", import.meta.url); +const { graphContextConfig } = await import(new URL("dist/libs/knowledge-graph/graph-context/config.js", root)); +const { KnowledgeGraphService } = await import(new URL("dist/libs/knowledge-graph/service.js", root)); +const { openDatabase } = await import(new URL("dist/libs/storage/sqlite/db.js", root)); +const { SqliteRepository } = await import(new URL("dist/libs/storage/sqlite/repository.js", root)); + +const tmp = mkdtempSync(join(tmpdir(), "greplica-proposal-apply-atomicity-test-")); +const db = openDatabase(join(tmp, "graph.db")); + +const repoRef = { + repo_root: join(tmp, "repo"), + repo_name: "proposal-apply-atomicity", + default_branch: "main", +}; +const proposal = { + title: "Proposal apply atomicity", + creates: { + components: [{ id: "component.atomicity", name: "Atomicity Component" }], + flows: [{ id: "flow.atomicity", name: "Atomicity Flow", touches: "component.atomicity" }], + claims: [ + { + id: "claim.atomicity", + kind: "fact", + text: "Proposal apply is atomic when embedding generation fails.", + truth: "source_verified", + intent: "intended", + about: "component.atomicity", + }, + ], + sources: [ + { + id: "source.atomicity", + kind: "session", + ref: "test:proposal-apply-atomicity", + }, + ], + edges: [ + { + kind: "evidenced_by", + from: "claim.atomicity", + to: "source.atomicity", + metadata: { reason: "Deterministic proposal apply test." }, + }, + ], + }, +}; + +try { + const repository = new SqliteRepository(db); + const failingContextBuilder = { + async ensureForGraph(repoId, graph, config) { + repository.insertGraphObjectEmbeddings([ + { + repo_id: repoId, + object_type: "component", + object_id: graph.components[0].id, + provider: config.embedding.provider, + model: config.embedding.model, + dimensions: config.embedding.dimensions, + embedding: Buffer.alloc(config.embedding.dimensions * Float32Array.BYTES_PER_ELEMENT), + }, + ]); + throw new Error("synthetic embedding failure"); + }, + }; + const failingService = new KnowledgeGraphService(repository, graphContextConfig, failingContextBuilder); + failingService.initRepo(repoRef); + + await assert.rejects( + failingService.applyProposal(repoRef, proposal), + /synthetic embedding failure/, + ); + + const failedGraph = failingService.readGraph(repoRef); + assert.deepEqual(failedGraph.components, []); + assert.deepEqual(failedGraph.flows, []); + assert.deepEqual(failedGraph.claims, []); + assert.deepEqual(failedGraph.sources, []); + assert.deepEqual(failedGraph.edges, []); + + for (const table of [ + "components", + "flows", + "claims", + "sources", + "edges", + "graph_memberships", + "memory_commits", + "graph_object_embeddings", + ]) { + const row = db.prepare(`SELECT COUNT(*) AS count FROM ${table}`).get(); + assert.equal(row.count, 0, `${table} must not retain rows from a failed proposal apply`); + } + + const successfulContextBuilder = { + async ensureForGraph(_repoId, graph) { + const checkedObjects = graph.components.length + graph.flows.length + graph.claims.length; + return { checked_objects: checkedObjects, created: 0, reused: checkedObjects }; + }, + }; + const successfulService = new KnowledgeGraphService(repository, graphContextConfig, successfulContextBuilder); + await successfulService.applyProposal(repoRef, proposal); + + const successfulGraph = successfulService.readGraph(repoRef); + assert.deepEqual(successfulGraph.components.map((component) => component.id), ["component.atomicity"]); + assert.deepEqual(successfulGraph.flows.map((flow) => flow.id), ["flow.atomicity"]); + assert.deepEqual(successfulGraph.claims.map((claim) => claim.id), ["claim.atomicity"]); + assert.deepEqual(successfulGraph.sources.map((source) => source.id), ["source.atomicity"]); +} finally { + db.close(); + rmSync(tmp, { recursive: true, force: true }); +} + +console.log("check-proposal-apply-atomicity: ok");