From 47d4aedb5b3536a0e140ca4c0271d7ef7e2dc2c9 Mon Sep 17 00:00:00 2001 From: burrows99 Date: Mon, 6 Jul 2026 19:12:12 +0100 Subject: [PATCH] feat: add `greplica graph promote` to commit working memory into main Working memory (the working scope) kept accumulating with no way to move it into the committed main scope, so the boundary between stable repo facts and active session edits kept blurring. This adds `greplica graph promote`. It moves every working-scope membership into the main scope under one new main memory commit, then clears working. Claims travel together with their edges, so a `supersedes` edge that retires a main claim is promoted along with its new claim and the retirement survives once working is empty. Object rows are keyed by (repo_id, id) and are left in place; both scopes belong to the same repo, so promotion only re-homes this repo's memberships. Running it on an empty working scope is a no-op. A scripts/check-graph-promote.js smoke test covers the move, the cleared working scope, the preserved supersession, and the no-op, and is wired into npm test. Closes #22 Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/cli/main.ts | 31 ++++++- libs/knowledge-graph/graph-promote.ts | 38 +++++++++ libs/knowledge-graph/service.ts | 14 ++++ libs/storage/sqlite/repository.ts | 39 +++++++++ package.json | 2 +- scripts/check-graph-promote.js | 115 ++++++++++++++++++++++++++ 6 files changed, 237 insertions(+), 2 deletions(-) create mode 100644 libs/knowledge-graph/graph-promote.ts create mode 100644 scripts/check-graph-promote.js diff --git a/apps/cli/main.ts b/apps/cli/main.ts index e18df82..7e7012b 100644 --- a/apps/cli/main.ts +++ b/apps/cli/main.ts @@ -5,7 +5,7 @@ import { isatty } from "node:tty"; import { basename, dirname, join, resolve } from "node:path"; import { tmpdir } from "node:os"; import { createLocalKnowledgeGraphService, KnowledgeGraphService } from "../../libs/knowledge-graph/service.js"; -import type { ClaimAnchorAuditResult, RepoRef } from "../../libs/knowledge-graph/service.js"; +import type { ClaimAnchorAuditResult, PromoteReport, RepoRef } from "../../libs/knowledge-graph/service.js"; import { envVarSource, loadRepoEnv, type LoadedRepoEnv } from "../../libs/env/load-local-env.js"; import { ensureGreplicaConfig, @@ -104,6 +104,13 @@ const cliCommands = [ handler: runGraphAuditAnchorsCommand, showInTopLevelHelp: true, }, + { + key: "graphPromote", + path: ["graph", "promote"], + usage: "graph promote", + handler: runGraphPromoteCommand, + showInTopLevelHelp: true, + }, { key: "graphExport", path: ["graph", "export"], @@ -278,6 +285,28 @@ async function runGraphAuditAnchorsCommand(_args: string[]): Promise { if (anchorAuditIssueCount(result) > 0) process.exitCode = 1; } +function runGraphPromoteCommand(args: string[]): void { + if (args.length > 0) throw new Error(usage("graphPromote")); + const { repo, service } = createCommandContext(); + const report = service.promoteWorking(repo); + printPromoteReport(report); +} + +function printPromoteReport(report: PromoteReport): void { + if (report.total === 0) { + console.log("Working memory is already empty. Nothing to promote."); + return; + } + const { components, flows, claims, edges } = report.promoted; + console.log("Promoted working memory into main."); + console.log(`Memory commit: ${report.memory_commit_id}`); + console.log(`Components: ${components}`); + console.log(`Flows: ${flows}`); + console.log(`Claims: ${claims}`); + console.log(`Edges: ${edges}`); + console.log("Working scope cleared."); +} + function runGraphExportCommand(args: string[]): void { const outputDir = requireFile(args[0], usage("graphExport")); const { repo, service } = createCommandContext(); diff --git a/libs/knowledge-graph/graph-promote.ts b/libs/knowledge-graph/graph-promote.ts new file mode 100644 index 0000000..454e260 --- /dev/null +++ b/libs/knowledge-graph/graph-promote.ts @@ -0,0 +1,38 @@ +export type PromoteSubjectType = "component" | "flow" | "claim" | "edge"; + +export interface PromoteSubjectRef { + type: PromoteSubjectType; + id: string; +} + +export interface PromotedCounts { + components: number; + flows: number; + claims: number; + edges: number; +} + +export interface PromoteReport { + /** + * The new main-scope memory commit that now owns the promoted subjects, or + * undefined when the working scope was already empty. + */ + memory_commit_id: string | undefined; + promoted: PromotedCounts; + total: number; +} + +/** + * Tally promoted working memberships by subject type. Pure (no database) so the + * counting stays unit-testable independently of the SQLite move it summarizes. + */ +export function countPromotedSubjects(refs: readonly PromoteSubjectRef[]): PromotedCounts { + const counts: PromotedCounts = { components: 0, flows: 0, claims: 0, edges: 0 }; + for (const ref of refs) { + if (ref.type === "component") counts.components += 1; + else if (ref.type === "flow") counts.flows += 1; + else if (ref.type === "claim") counts.claims += 1; + else counts.edges += 1; + } + return counts; +} diff --git a/libs/knowledge-graph/service.ts b/libs/knowledge-graph/service.ts index e805969..43152ea 100644 --- a/libs/knowledge-graph/service.ts +++ b/libs/knowledge-graph/service.ts @@ -1,5 +1,6 @@ import { normalizeProposal } from "./proposal.js"; import { validateProposal, type ProposalValidationResult } from "./validate-proposal.js"; +import { countPromotedSubjects, type PromoteReport } from "./graph-promote.js"; import type { Claim } from "./claim.js"; import type { Edge } from "./edge.js"; import type { Component, Flow, GraphObjectType, Source } from "./schema.js"; @@ -15,6 +16,7 @@ import { SqliteRepository as SqliteKnowledgeGraphRepository } from "../storage/s export type { GraphContextResult } from "./graph-context/types.js"; export type { ClaimAnchorAuditResult } from "./code-anchors/types.js"; +export type { PromoteReport } from "./graph-promote.js"; export interface RepoRef { repo_root?: string; @@ -125,6 +127,18 @@ export class KnowledgeGraphService { return auditClaimCodeAnchors(input.repo_root, this.repository.readGraphView(initialized.repo_id).claims); } + promoteWorking(input: RepoRef): PromoteReport { + const initialized = this.requireRepo(input); + const result = this.repository.promoteWorkingToMain(initialized.repo_id, { + title: "Promote working memory to main", + }); + return { + memory_commit_id: result.memory_commit_id, + promoted: countPromotedSubjects(result.refs), + total: result.refs.length, + }; + } + async validateProposal(input: RepoRef, proposal: unknown): Promise { const initialized = this.requireRepo(input); const subjectLookup = this.subjectLookup(initialized.repo_id); diff --git a/libs/storage/sqlite/repository.ts b/libs/storage/sqlite/repository.ts index 1019a92..cf5c076 100644 --- a/libs/storage/sqlite/repository.ts +++ b/libs/storage/sqlite/repository.ts @@ -6,6 +6,7 @@ import type { MemoryCommitProposal } from "../../knowledge-graph/proposal.js"; import type { Component, Flow, GraphObjectType, Source } from "../../knowledge-graph/schema.js"; import type { Claim } from "../../knowledge-graph/claim.js"; import type { GraphScope, GraphScopeKind } from "../../knowledge-graph/scope.js"; +import type { PromoteSubjectRef } from "../../knowledge-graph/graph-promote.js"; export interface RepoRecord { id: string; @@ -286,6 +287,44 @@ export class SqliteRepository { return memoryCommit; } + /** + * Move every membership in this repo's working scope into its main scope under + * a single new main memory commit, then clear the working scope. Claims travel + * with their edges (including `supersedes` edges), so supersession that retires + * a main claim survives the move: promoting a claim without its `supersedes` + * edge would resurrect the stale claim once working is cleared. Object rows are + * keyed by (repo_id, id) and are never touched here; both scopes belong to the + * same repo, so promotion only re-homes this repo's memberships. `INSERT OR + * IGNORE` keeps a subject that is already in main on its existing membership. + */ + promoteWorkingToMain( + repoId: string, + commit: { title: string; summary?: string }, + ): { memory_commit_id: string | undefined; refs: PromoteSubjectRef[] } { + const working = this.requireWorkingScope(repoId); + const main = this.requireMainScope(repoId); + const rows = this.db + .prepare("SELECT subject_type, subject_id FROM graph_memberships WHERE scope_id = ?") + .all(working.id) as { subject_type: PromoteSubjectRef["type"]; subject_id: string }[]; + const refs: PromoteSubjectRef[] = rows.map((row) => ({ type: row.subject_type, id: row.subject_id })); + if (refs.length === 0) return { memory_commit_id: undefined, refs }; + + const insertMembership = this.db.prepare( + `INSERT OR IGNORE INTO graph_memberships (scope_id, subject_type, subject_id, memory_commit_id) + VALUES (?, ?, ?, ?)`, + ); + const clearWorking = this.db.prepare("DELETE FROM graph_memberships WHERE scope_id = ?"); + + const write = this.db.transaction(() => { + const memoryCommit = this.createMemoryCommit({ scope_id: main.id, title: commit.title, summary: commit.summary }); + for (const ref of refs) insertMembership.run(main.id, ref.type, ref.id, memoryCommit.id); + clearWorking.run(working.id); + return memoryCommit.id; + }); + + return { memory_commit_id: write(), refs }; + } + createProposalRecords(scopeId: string, memoryCommitId: string, proposal: MemoryCommitProposal): void { const write = this.db.transaction(() => { const repoId = this.repoIdForScope(scopeId); diff --git a/package.json b/package.json index 7cce4b1..32b2014 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-proposal-validate.js && node scripts/check-bm25-tokenizer.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-promote.js && node scripts/check-proposal-validate.js && node scripts/check-bm25-tokenizer.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-graph-promote.js b/scripts/check-graph-promote.js new file mode 100644 index 0000000..966e795 --- /dev/null +++ b/scripts/check-graph-promote.js @@ -0,0 +1,115 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, mkdirSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const root = new URL("..", import.meta.url); +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 { KnowledgeGraphService } = await import(new URL("dist/libs/knowledge-graph/service.js", root)); +const { countPromotedSubjects } = await import(new URL("dist/libs/knowledge-graph/graph-promote.js", root)); + +const tmp = mkdtempSync(join(tmpdir(), "greplica-graph-promote-test-")); +const repoRoot = join(tmp, "repo"); +mkdirSync(repoRoot, { recursive: true }); + +const db = openDatabase(join(tmp, "graph.db")); + +const membershipCount = (scopeId) => + db.prepare("SELECT count(*) AS c FROM graph_memberships WHERE scope_id = ?").get(scopeId).c; +const claimIds = (graph) => new Set(graph.claims.map((claim) => claim.id)); + +try { + const repository = new SqliteRepository(db); + const service = new KnowledgeGraphService(repository); + const repo = { repo_root: repoRoot, repo_name: "graph-promote", default_branch: "main" }; + + const initialized = service.initRepo(repo); + + // --- main already holds a v1 claim about onboarding --- + const mainSeed = repository.createMemoryCommit({ scope_id: initialized.main_scope_id, title: "Main seed" }); + repository.createProposalRecords(initialized.main_scope_id, mainSeed.id, { + title: "Main seed", + creates: { + components: [{ id: "component.onboarding", name: "Onboarding", code_anchor: "src/onboarding.ts" }], + claims: [ + { id: "claim.v1", kind: "fact", text: "Onboarding is a 5-step form.", truth: "code_verified", intent: "intended", code_anchors: [{ file: "src/onboarding.ts" }] }, + ], + }, + }); + + // --- a session rewrites onboarding in the working scope: a new component, a v2 + // claim, and a supersedes edge retiring the v1 main claim --- + const workingSeed = repository.createMemoryCommit({ scope_id: initialized.working_scope_id, title: "Working seed" }); + repository.createProposalRecords(initialized.working_scope_id, workingSeed.id, { + title: "Working seed", + creates: { + components: [{ id: "component.worker", name: "Build Worker", code_anchor: "src/worker.ts" }], + claims: [ + { id: "claim.v2", kind: "fact", text: "Onboarding is a 7-stage chat.", truth: "code_verified", intent: "intended", code_anchors: [{ file: "src/onboarding.ts" }] }, + ], + edges: [ + { id: "edge.supersede", from_id: "claim.v2", from_type: "claim", to_id: "claim.v1", to_type: "claim", kind: "supersedes" }, + ], + }, + }); + + // --- before promote: working holds the 3 new memberships, main holds 2, and the + // union view already retires v1 in favour of v2 --- + assert.equal(membershipCount(initialized.working_scope_id), 3, "working starts with 3 memberships"); + assert.equal(membershipCount(initialized.main_scope_id), 2, "main starts with 2 memberships"); + + let graph = service.readGraph(repo); + assert.ok(claimIds(graph).has("claim.v2"), "v2 is live before promote"); + assert.ok(!claimIds(graph).has("claim.v1"), "v1 is superseded before promote"); + + // --- promote: every working membership moves into main under one commit --- + const report = service.promoteWorking(repo); + assert.deepEqual(report.promoted, { components: 1, flows: 0, claims: 1, edges: 1 }, "promoted counts"); + assert.equal(report.total, 3, "promoted total"); + assert.ok(report.memory_commit_id, "a main memory commit owns the promoted subjects"); + + // --- after promote: working is empty, main owns everything, and NOTHING was + // deleted (object rows are global) --- + assert.equal(membershipCount(initialized.working_scope_id), 0, "working scope cleared"); + assert.equal(membershipCount(initialized.main_scope_id), 5, "main now owns all 5 subjects"); + for (const [type, id] of [ + ["component", "component.onboarding"], + ["component", "component.worker"], + ["claim", "claim.v1"], + ["claim", "claim.v2"], + ["edge", "edge.supersede"], + ]) { + assert.equal(repository.subjectExists(initialized.repo_id, type, id), true, `${id} row preserved`); + } + + // --- the design fork: because the supersedes edge travelled with the claim, + // v1 stays retired even now that working is empty. If the edge had been + // left behind, v1 would resurface here. --- + graph = service.readGraph(repo); + assert.ok(claimIds(graph).has("claim.v2"), "v2 still live after promote"); + assert.ok(!claimIds(graph).has("claim.v1"), "v1 still superseded after promote (supersedes edge promoted too)"); + + // --- idempotent: a second promote finds nothing in working --- + const again = service.promoteWorking(repo); + assert.deepEqual(again.promoted, { components: 0, flows: 0, claims: 0, edges: 0 }, "second promote is a no-op"); + assert.equal(again.total, 0, "second promote total is zero"); + assert.equal(again.memory_commit_id, undefined, "no commit created when working is empty"); + + // --- pure counting helper stands alone --- + assert.deepEqual( + countPromotedSubjects([ + { type: "component", id: "c1" }, + { type: "claim", id: "k1" }, + { type: "claim", id: "k2" }, + { type: "edge", id: "e1" }, + ]), + { components: 1, flows: 0, claims: 2, edges: 1 }, + "countPromotedSubjects tallies by type", + ); +} finally { + db.close(); + rmSync(tmp, { recursive: true, force: true }); +} + +console.log("Graph promote checks passed.");