diff --git a/apps/cli/main.ts b/apps/cli/main.ts index d6c0f42..1007c20 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, @@ -105,6 +105,13 @@ const cliCommands = [ handler: runGraphAuditAnchorsCommand, showInTopLevelHelp: true, }, + { + key: "graphPromote", + path: ["graph", "promote"], + usage: "graph promote", + handler: runGraphPromoteCommand, + showInTopLevelHelp: true, + }, { key: "graphExport", path: ["graph", "export"], @@ -279,6 +286,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 ef969dd..ea4a622 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"; @@ -17,6 +18,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; @@ -132,6 +134,18 @@ export class KnowledgeGraphService { return auditClaimCodeAnchors(input.repo_root, claims, new CodeAnchorResolver(), baselineFingerprints); } + 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 6851c8d..6221e53 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"; import { installCommandSuggestion } from "../../install/paths.js"; export interface RepoRecord { @@ -302,6 +303,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, diff --git a/package.json b/package.json index 8575f7c..30c376d 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 && 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-proposal-validate.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.");