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: 30 additions & 1 deletion apps/cli/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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"],
Expand Down Expand Up @@ -279,6 +286,28 @@ async function runGraphAuditAnchorsCommand(_args: string[]): Promise<void> {
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();
Expand Down
38 changes: 38 additions & 0 deletions libs/knowledge-graph/graph-promote.ts
Original file line number Diff line number Diff line change
@@ -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;
}
14 changes: 14 additions & 0 deletions libs/knowledge-graph/service.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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;
Expand Down Expand Up @@ -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<ProposalValidationResult> {
const initialized = this.requireRepo(input);
const subjectLookup = this.subjectLookup(initialized.repo_id);
Expand Down
39 changes: 39 additions & 0 deletions libs/storage/sqlite/repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
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-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",
Expand Down
115 changes: 115 additions & 0 deletions scripts/check-graph-promote.js
Original file line number Diff line number Diff line change
@@ -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.");