Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 20 additions & 11 deletions libs/knowledge-graph/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
44 changes: 44 additions & 0 deletions libs/storage/sqlite/repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -302,6 +302,19 @@ export class SqliteRepository {
return memoryCommit;
}

createMemoryCommitWithProposal(
input: CreateMemoryCommitInput,
proposal: MemoryCommitProposal,
anchorFingerprints?: Map<string, Record<string, string>>,
): 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,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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 }
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
119 changes: 119 additions & 0 deletions scripts/check-proposal-apply-atomicity.js
Original file line number Diff line number Diff line change
@@ -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");