From 5db9646bc92832ddcce274b90e6284f5b0d80ddb Mon Sep 17 00:00:00 2001 From: Kushal Date: Tue, 28 Jul 2026 18:51:52 -0700 Subject: [PATCH 01/27] feat: add multi-contributor managed memory CLI --- README.md | 18 +- action.yml | 44 +++ apps/cli/main.ts | 355 ++++++++++++++++-- apps/cli/managed-cli.ts | 26 ++ apps/cli/reconcile-cli.ts | 179 +++++++++ libs/hooks/runtime-store.ts | 2 +- libs/install/install.ts | 2 +- libs/install/repo-installation-store.ts | 6 +- .../graph-view/build-graph-view.ts | 126 ++++++- libs/knowledge-graph/local-provider.ts | 45 ++- libs/knowledge-graph/managed-client.ts | 167 +++++++- libs/knowledge-graph/provider.ts | 22 +- libs/managed/control-client.ts | 20 +- libs/managed/protocol.ts | 283 +++++++++++++- libs/storage/sqlite/migrate.ts | 55 +++ libs/storage/sqlite/repository.ts | 2 +- libs/storage/sqlite/schema.ts | 2 +- package.json | 3 +- scripts/check-managed-collaboration.js | 343 +++++++++++++++++ 19 files changed, 1627 insertions(+), 73 deletions(-) create mode 100644 action.yml create mode 100644 apps/cli/reconcile-cli.ts create mode 100644 scripts/check-managed-collaboration.js diff --git a/README.md b/README.md index 37661f1..baa99c5 100644 --- a/README.md +++ b/README.md @@ -77,7 +77,23 @@ The link works for multiple GitHub users until an admin revokes it. Greplica nev Replace `codex` with your agent platform. Login uses GitHub's browser device flow; do not share GitHub credentials or Greplica tokens. Interactive install can accept a matching invitation and automatically map a public fork to its upstream namespace. The GitHub App therefore does not need access to each contributor fork. -Organization admins and members inherit read access to every organization repository. Guests can read only explicitly granted repositories. Repository writes always require an explicit `memory_admin` grant; ordinary contributors remain read-only. Managed graph data stays on the server, while local SQLite stores only the repository binding, role cache, hook policy, and runtime session metadata. +Organization admins and members inherit read access to every organization repository. Guests can read only explicitly granted repositories. A `contributor` writes proposals to their own persistent personal working scope; `memory_admin` additionally manages repository memory access. Managed graph data stays on the server, while local SQLite stores only the repository binding, role cache, hook policy, and runtime session metadata. + +Managed GitHub repositories can reconcile Memory PRs against the exact merged code with the bundled Action: + +```yaml +permissions: + contents: read + id-token: write + +steps: + - uses: Autoloops/greplica@ + with: + managed-repo: + merge-sha: ${{ github.event.pull_request.merge_commit_sha }} +``` + +The Action checks out `merge-sha` with full history, installs the CLI from the same pinned Action source, verifies every candidate Git head is an ancestor, audits version-keyed anchors, and attests through GitHub OIDC. It does not use a repository or model secret. Local mode remains independent and does not require login or a server: diff --git a/action.yml b/action.yml new file mode 100644 index 0000000..fbc1b11 --- /dev/null +++ b/action.yml @@ -0,0 +1,44 @@ +name: Reconcile Greplica memory +description: Audit a Memory PR against an exact GitHub merge SHA and attest the result with GitHub OIDC. +inputs: + managed-repo: + description: Managed Greplica repository UUID. + required: true + merge-sha: + description: Exact merged/default-branch commit to check out and audit. + required: true + api-url: + description: Managed Greplica API URL. + required: false + default: https://memory.autoloops.ai + oidc-audience: + description: GitHub OIDC audience expected by the managed service. + required: false + default: greplica-managed +runs: + using: composite + steps: + - name: Check out exact merge commit + uses: actions/checkout@v4 + with: + ref: ${{ inputs.merge-sha }} + fetch-depth: 0 + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: "22" + - name: Audit and attest Memory PR + shell: bash + env: + GREPLICA_ACTION_MANAGED_REPO: ${{ inputs.managed-repo }} + GREPLICA_ACTION_MERGE_SHA: ${{ inputs.merge-sha }} + GREPLICA_ACTION_API_URL: ${{ inputs.api-url }} + GREPLICA_ACTION_OIDC_AUDIENCE: ${{ inputs.oidc-audience }} + run: | + npm ci --prefix "$GITHUB_ACTION_PATH" --include=dev + npm run --prefix "$GITHUB_ACTION_PATH" build + node "$GITHUB_ACTION_PATH/dist/apps/cli/main.js" memory reconcile \ + --managed-repo "$GREPLICA_ACTION_MANAGED_REPO" \ + --merge-sha "$GREPLICA_ACTION_MERGE_SHA" \ + --api-url "$GREPLICA_ACTION_API_URL" \ + --oidc-audience "$GREPLICA_ACTION_OIDC_AUDIENCE" diff --git a/apps/cli/main.ts b/apps/cli/main.ts index 35db06d..6ea8a84 100644 --- a/apps/cli/main.ts +++ b/apps/cli/main.ts @@ -14,6 +14,12 @@ import { } from "../../libs/config/greplica-config.js"; import { createGraphMemoryProvider } from "../../libs/knowledge-graph/provider-factory.js"; import type { GraphMemoryProvider } from "../../libs/knowledge-graph/provider.js"; +import type { + ManagedGraphView, + ManagedMemoryPr, + ManagedMemoryStatus, + ManagedProposal, +} from "../../libs/managed/protocol.js"; import { createEmbedder } from "../../libs/knowledge-graph/graph-context/embedder.js"; import { renderGraphContextMarkdown } from "../../libs/knowledge-graph/graph-context/render.js"; import { buildGraphFolderExport } from "../../libs/knowledge-graph/folder-export.js"; @@ -54,6 +60,8 @@ import { runRepoEnrollGithub, runRepoGithubInstall, runRepoGrantMemoryAdmin, + runRepoGrantContributor, + runRepoInviteContributor, runRepoInviteReader, runRepoInviteLinkCreate, runRepoInviteLinkList, @@ -63,8 +71,10 @@ import { runRepoPublish, runRepoRestore, runRepoRevokeMemoryAdmin, + runRepoRevokeContributor, runWhoami, } from "./managed-cli.js"; +import { runMemoryReconcile } from "./reconcile-cli.js"; interface CommandContext { repo: RepoRef; @@ -138,11 +148,14 @@ const cliCommands = [ { key: "repoRestore", path: ["repo", "restore"], usage: "repo restore", handler: runRepoRestore, showInTopLevelHelp: true }, { key: "repoDiscovery", path: ["repo", "discovery"], usage: "repo discovery --discovery listed|unlisted", handler: runRepoDiscovery, showInTopLevelHelp: true }, { key: "repoInviteReader", path: ["repo", "invite-reader"], usage: "repo invite-reader --github-user ", handler: runRepoInviteReader, showInTopLevelHelp: true }, + { key: "repoInviteContributor", path: ["repo", "invite-contributor"], usage: "repo invite-contributor --github-user ", handler: runRepoInviteContributor, showInTopLevelHelp: true }, { key: "repoInviteLinkCreate", path: ["repo", "invite-link", "create"], usage: "repo invite-link create", handler: runRepoInviteLinkCreate, showInTopLevelHelp: true }, { key: "repoInviteLinkList", path: ["repo", "invite-link", "list"], usage: "repo invite-link list", handler: runRepoInviteLinkList, showInTopLevelHelp: true }, { key: "repoInviteLinkRevoke", path: ["repo", "invite-link", "revoke"], usage: "repo invite-link revoke --link ", handler: runRepoInviteLinkRevoke, showInTopLevelHelp: true }, { key: "repoGrantMemoryAdmin", path: ["repo", "grant-memory-admin"], usage: "repo grant-memory-admin --user ", handler: runRepoGrantMemoryAdmin, showInTopLevelHelp: true }, + { key: "repoGrantContributor", path: ["repo", "grant-contributor"], usage: "repo grant-contributor --user ", handler: runRepoGrantContributor, showInTopLevelHelp: true }, { key: "repoRevokeMemoryAdmin", path: ["repo", "revoke-memory-admin"], usage: "repo revoke-memory-admin --user ", handler: runRepoRevokeMemoryAdmin, showInTopLevelHelp: true }, + { key: "repoRevokeContributor", path: ["repo", "revoke-contributor"], usage: "repo revoke-contributor --user ", handler: runRepoRevokeContributor, showInTopLevelHelp: true }, { key: "repoAccessRequest", path: ["repo", "request-access"], usage: "repo request-access --managed-repo ", handler: runRepoAccessRequest, showInTopLevelHelp: true }, { key: "repoAccessList", path: ["repo", "access-requests"], usage: "repo access-requests", handler: runRepoAccessList, showInTopLevelHelp: true }, { key: "repoAccessApprove", path: ["repo", "approve-access"], usage: "repo approve-access --request ", handler: (args) => runRepoAccessDecision(args, "approve"), showInTopLevelHelp: true }, @@ -172,14 +185,14 @@ const cliCommands = [ { key: "graphRead", path: ["graph", "read"], - usage: "graph read", + usage: "graph read [--with-working ...] [--memory-pr ] [--main-only] [--include-quarantined] [--json]", handler: withCommandContext(runGraphReadCommand), showInTopLevelHelp: true, }, { key: "graphContext", path: ["graph", "context"], - usage: "graph context [--debug]", + usage: "graph context [--with-working ...] [--memory-pr ] [--main-only] [--include-quarantined] [--json|--debug]", handler: withCommandContext(runGraphContextCommand), showInTopLevelHelp: true, helpMode: "query-aware", @@ -201,7 +214,7 @@ const cliCommands = [ { key: "graphView", path: ["graph", "view"], - usage: "graph view [--out ] [--no-open]", + usage: "graph view [--with-working ...] [--memory-pr ] [--main-only] [--include-quarantined] [--json] [--out ] [--no-open]", handler: withCommandContext(runGraphViewCommand), showInTopLevelHelp: true, }, @@ -219,6 +232,63 @@ const cliCommands = [ handler: withCommandContext(runProposalApplyCommand), showInTopLevelHelp: true, }, + { + key: "proposalList", + path: ["proposal", "list"], + usage: "proposal list [--json]", + handler: withCommandContext(runProposalListCommand), + showInTopLevelHelp: true, + }, + { + key: "proposalShow", + path: ["proposal", "show"], + usage: "proposal show [--json]", + handler: withCommandContext(runProposalShowCommand), + showInTopLevelHelp: true, + }, + { + key: "memoryPrList", + path: ["memory", "pr", "list"], + usage: "memory pr list [--json]", + handler: withCommandContext(runMemoryPrListCommand), + showInTopLevelHelp: true, + }, + { + key: "memoryPrShow", + path: ["memory", "pr", "show"], + usage: "memory pr show [--json]", + handler: withCommandContext(runMemoryPrShowCommand), + showInTopLevelHelp: true, + }, + { + key: "memoryPrContext", + path: ["memory", "pr", "context"], + usage: "memory pr context [--json]", + handler: withCommandContext(runMemoryPrContextCommand), + showInTopLevelHelp: true, + helpMode: "query-aware", + }, + { + key: "memoryPrRetry", + path: ["memory", "pr", "retry"], + usage: "memory pr retry [--json]", + handler: withCommandContext(runMemoryPrRetryCommand), + showInTopLevelHelp: true, + }, + { + key: "memoryStatus", + path: ["memory", "status"], + usage: "memory status [--json]", + handler: withCommandContext(runMemoryStatusCommand), + showInTopLevelHelp: true, + }, + { + key: "memoryReconcile", + path: ["memory", "reconcile"], + usage: "memory reconcile --managed-repo --merge-sha [--api-url ] [--oidc-audience ] [--repair-proposal ]", + handler: runMemoryReconcile, + showInTopLevelHelp: true, + }, { key: "sessionMarkMemoryCurrent", path: ["session", "mark-memory-current"], @@ -385,10 +455,16 @@ function runRepoStatusCommand(args: string[]): void { } } -async function runGraphReadCommand(_args: string[], getContext: CommandContextProvider): Promise { +async function runGraphReadCommand(args: string[], getContext: CommandContextProvider): Promise { + const options = parseGraphSelectionArgs(args, new Set(["--json"])); + if (options.remaining.length > 0) throw new Error(usage("graphRead")); const { service } = getContext(); - const graph = await service.readGraph(); - console.log("Current graph view: main + working"); + const graph = await service.readGraph(options.view); + if (options.json) { + console.log(JSON.stringify(graph, null, 2)); + return; + } + console.log(`Current graph view: ${graphViewLabel(options.view)}`); printSection("Components", graph.components, (item) => `${named(item)} ${anchor(item)}`.trim()); printSection("Flows", graph.flows, named); printSection("Claims", graph.claims, (item) => `${field(item, "kind")}: ${field(item, "text")}`); @@ -397,12 +473,13 @@ async function runGraphReadCommand(_args: string[], getContext: CommandContextPr } async function runGraphContextCommand(args: string[], getContext: CommandContextProvider): Promise { - const output = parseGraphContextOutput(args); - const query = args.filter((arg) => arg !== "--debug").join(" ").trim(); + const options = parseGraphSelectionArgs(args, new Set(["--json", "--debug"])); + const output = options.json || args.includes("--debug") ? "json" : "markdown"; + const query = options.remaining.filter((arg) => arg !== "--debug").join(" ").trim(); if (query.length === 0) throw new Error(usage("graphContext")); const { service } = getContext(); - const result = await service.contextGraph(query); - if (output === "debug") { + const result = await service.contextGraph(query, options.view); + if (output === "json") { console.log(JSON.stringify(result, null, 2)); } else { console.log(renderGraphContextMarkdown(result)); @@ -428,7 +505,18 @@ async function runGraphExportCommand(args: string[], getContext: CommandContextP async function runGraphViewCommand(args: string[], getContext: CommandContextProvider): Promise { const options = parseGraphViewArgs(args); const { repo, service } = getContext(); - const graph = await service.readGraph(); + if (options.json) { + const json = `${JSON.stringify(await service.viewData(options.view), null, 2)}\n`; + if (options.outputPath === undefined) { + console.log(json.trimEnd()); + } else { + mkdirSync(dirname(options.outputPath), { recursive: true }); + writeFileSync(options.outputPath, json, "utf8"); + console.log(`Wrote graph view data to ${options.outputPath}`); + } + return; + } + const graph = await service.readGraph(options.view); if (graph.components.length === 0) { console.log("No components to visualize. Bootstrap memory first."); process.exitCode = 1; @@ -437,7 +525,7 @@ async function runGraphViewCommand(args: string[], getContext: CommandContextPro const outputPath = options.outputPath ?? defaultGraphViewOutputPath(repo.repo_name); mkdirSync(dirname(outputPath), { recursive: true }); - const html = await service.buildGraphView(); + const html = await service.buildGraphView(options.view); writeFileSync(outputPath, html, "utf8"); console.log(`Wrote graph view to ${outputPath}`); @@ -486,6 +574,78 @@ async function runProposalApplyCommand(args: string[], getContext: CommandContex markProposalApplyMemoryUpdated(repo, proposal); } +async function runProposalListCommand(args: string[], getContext: CommandContextProvider): Promise { + const json = onlyJsonFlag(args, "proposalList"); + const proposals = await getContext().service.listProposals(); + if (json) { + console.log(JSON.stringify(proposals, null, 2)); + return; + } + for (const proposal of proposals) printProposalSummary(proposal); +} + +async function runProposalShowCommand(args: string[], getContext: CommandContextProvider): Promise { + const { positional, json } = positionalWithJson(args, "proposalShow"); + const proposal = await getContext().service.showProposal(positional); + if (json) { + console.log(JSON.stringify(proposal, null, 2)); + return; + } + printProposalSummary(proposal); + console.log(JSON.stringify(proposal.proposal, null, 2)); +} + +async function runMemoryPrListCommand(args: string[], getContext: CommandContextProvider): Promise { + const json = onlyJsonFlag(args, "memoryPrList"); + const memoryPrs = await getContext().service.listMemoryPrs(); + if (json) { + console.log(JSON.stringify(memoryPrs, null, 2)); + return; + } + for (const memoryPr of memoryPrs) printMemoryPrSummary(memoryPr); +} + +async function runMemoryPrShowCommand(args: string[], getContext: CommandContextProvider): Promise { + const { positional, json } = positionalWithJson(args, "memoryPrShow"); + const memoryPr = await getContext().service.showMemoryPr(positional); + if (json) { + console.log(JSON.stringify(memoryPr, null, 2)); + return; + } + printMemoryPrSummary(memoryPr); + printPromotionCleanup(memoryPr); +} + +async function runMemoryPrContextCommand(args: string[], getContext: CommandContextProvider): Promise { + const json = args.includes("--json"); + const positional = args.filter((arg) => arg !== "--json"); + const memoryPrId = positional.shift(); + const query = positional.join(" ").trim(); + if (memoryPrId === undefined || query.length === 0) throw new Error(usage("memoryPrContext")); + const result = await getContext().service.contextGraph(query, { base: "main", memory_pr_id: memoryPrId }); + console.log(json ? JSON.stringify(result, null, 2) : renderGraphContextMarkdown(result)); +} + +async function runMemoryPrRetryCommand(args: string[], getContext: CommandContextProvider): Promise { + const { positional, json } = positionalWithJson(args, "memoryPrRetry"); + const memoryPr = await getContext().service.retryMemoryPr(positional); + if (json) console.log(JSON.stringify(memoryPr, null, 2)); + else { + console.log(`Queued Memory PR ${memoryPr.id} for reconciliation.`); + printMemoryPrSummary(memoryPr); + } +} + +async function runMemoryStatusCommand(args: string[], getContext: CommandContextProvider): Promise { + const json = onlyJsonFlag(args, "memoryStatus"); + const status = await getContext().service.memoryStatus(); + if (json) { + console.log(JSON.stringify(status, null, 2)); + return; + } + printMemoryStatus(status); +} + function printAnchorAudit(result: ClaimAnchorAuditResult): void { console.log("Code anchor audit"); console.log(""); @@ -1005,8 +1165,8 @@ function printInstallResult(result: Awaited>) console.log(`Project rules: ${result.rules.configFiles.join(", ")}`); console.log("- note: reload your editor if the new project rule does not appear immediately."); } - if (result.mode === "managed" && result.autoMemoryUpdates && result.installation.managedRole !== "memory_admin") { - console.log("Automatic memory updates: enabled when memory_admin access is granted."); + if (result.mode === "managed" && result.autoMemoryUpdates && result.installation.managedRole === "reader") { + console.log("Automatic memory updates: enabled when contributor access is granted."); } else { console.log(`Automatic memory updates: ${result.autoMemoryUpdates ? "enabled" : "disabled"}.`); } @@ -1080,21 +1240,17 @@ function parseRequiredOption(args: string[], name: string, usage: string): strin throw new Error(usage); } -function parseGraphContextOutput(args: string[]): "markdown" | "debug" { - if (args.includes("--json")) throw new Error("greplica graph context --json was removed; use Markdown output or --debug."); - const debug = args.includes("--debug"); - if (debug) return "debug"; - return "markdown"; -} - interface GraphViewOptions { outputPath?: string; noOpen: boolean; + json: boolean; + view?: ManagedGraphView; } function parseGraphViewArgs(args: string[]): GraphViewOptions { let outputPath: string | undefined; let noOpen = false; + const selectionArgs: string[] = []; for (let index = 0; index < args.length; index += 1) { const arg = args[index]; @@ -1111,10 +1267,84 @@ function parseGraphViewArgs(args: string[]): GraphViewOptions { outputPath = resolve(arg.slice("--out=".length)); continue; } - throw new Error(usage("graphView")); + selectionArgs.push(arg); } - return { outputPath, noOpen }; + const selection = parseGraphSelectionArgs(selectionArgs, new Set(["--json"])); + if (selection.remaining.length > 0) throw new Error(usage("graphView")); + return { outputPath, noOpen, json: selection.json, view: selection.view }; +} + +interface GraphSelectionArgs { + view?: ManagedGraphView; + json: boolean; + remaining: string[]; +} + +function parseGraphSelectionArgs(args: string[], passthroughFlags: ReadonlySet): GraphSelectionArgs { + const workingUsers: string[] = []; + let memoryPrId: string | undefined; + let mainOnly = false; + let includeQuarantined = false; + let json = false; + const remaining: string[] = []; + + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + if (arg === "--json") { + json = true; + continue; + } + if (arg === "--main-only") { + mainOnly = true; + continue; + } + if (arg === "--include-quarantined") { + includeQuarantined = true; + continue; + } + if (arg === "--with-working") { + workingUsers.push(requireFlagValue(args, index, "--with-working")); + index += 1; + continue; + } + if (arg.startsWith("--with-working=")) { + workingUsers.push(requireFile(arg.slice("--with-working=".length), "Missing value for --with-working.")); + continue; + } + if (arg === "--memory-pr") { + if (memoryPrId !== undefined) throw new Error("Specify --memory-pr only once."); + memoryPrId = requireFlagValue(args, index, "--memory-pr"); + index += 1; + continue; + } + if (arg.startsWith("--memory-pr=")) { + if (memoryPrId !== undefined) throw new Error("Specify --memory-pr only once."); + memoryPrId = requireFile(arg.slice("--memory-pr=".length), "Missing value for --memory-pr."); + continue; + } + if (passthroughFlags.has(arg)) { + remaining.push(arg); + continue; + } + remaining.push(arg); + } + + if (mainOnly && (workingUsers.length > 0 || memoryPrId !== undefined || includeQuarantined)) { + throw new Error("--main-only cannot be combined with working, Memory PR, or quarantine overlays."); + } + const uniqueWorkingUsers = [...new Set(workingUsers)]; + const hasView = mainOnly || uniqueWorkingUsers.length > 0 || memoryPrId !== undefined || includeQuarantined; + const view = hasView + ? { + base: "main" as const, + ...(mainOnly ? { working_users: [] } : {}), + ...(uniqueWorkingUsers.length === 0 ? {} : { working_users: uniqueWorkingUsers }), + ...(memoryPrId === undefined ? {} : { memory_pr_id: memoryPrId }), + ...(includeQuarantined ? { include_quarantined: true } : {}), + } + : undefined; + return { view, json, remaining }; } function defaultGraphViewOutputPath(repoName: string): string { @@ -1161,6 +1391,85 @@ function printSection(title: string, items: T[], forma } } +function graphViewLabel(view: ManagedGraphView | undefined): string { + if (view === undefined) return "main + working (mine)"; + const layers = ["main"]; + if (view.working_users !== undefined) { + if (view.working_users.length > 0) layers.push("working (mine)"); + for (const user of view.working_users) layers.push(`working/${user}`); + } else { + layers.push("working (mine)"); + } + if (view.memory_pr_id !== undefined) layers.push(`Memory PR ${view.memory_pr_id}`); + if (view.include_quarantined === true) layers.push("quarantine"); + return layers.join(" + "); +} + +function onlyJsonFlag(args: string[], command: CommandKey): boolean { + if (args.length === 0) return false; + if (args.length === 1 && args[0] === "--json") return true; + throw new Error(usage(command)); +} + +function positionalWithJson(args: string[], command: CommandKey): { positional: string; json: boolean } { + const json = args.includes("--json"); + const positionals = args.filter((arg) => arg !== "--json"); + if (positionals.length !== 1 || positionals[0].startsWith("--")) throw new Error(usage(command)); + return { positional: positionals[0], json }; +} + +function printProposalSummary(proposal: ManagedProposal): void { + const commit = proposal.memory_commit; + const sessions = commit.session_refs.map((session) => session.id).join(",") || "-"; + console.log([ + proposal.id, + commit.state, + commit.author.github_login, + commit.git?.branch ?? "-", + commit.code_pr?.number === undefined ? "-" : `#${commit.code_pr.number}`, + commit.memory_pr_id ?? "-", + sessions, + ].join("\t")); +} + +function printMemoryPrSummary(memoryPr: ManagedMemoryPr): void { + console.log([ + memoryPr.id, + memoryPr.state, + `code-pr:#${memoryPr.code_pr.number}`, + memoryPr.contributor_logins.join(",") || "-", + `${memoryPr.direct_commit_ids.length} direct`, + `${memoryPr.dependency_commit_ids.length} dependencies`, + memoryPr.latest_job_state ?? "-", + ].join("\t")); +} + +function printPromotionCleanup(memoryPr: ManagedMemoryPr): void { + const promotion = memoryPr.promotion; + if (promotion === undefined) return; + console.log(`Main head: ${promotion.new_main_head}`); + console.log(`Cleared commits: ${promotion.cleared_commit_ids.join(", ") || "none"}`); + console.log(`Already canonical: ${promotion.already_canonical_commit_ids.join(", ") || "none"}`); + console.log(`Quarantined: ${promotion.quarantined_commit_ids.join(", ") || "none"}`); + for (const [login, cleanup] of Object.entries(promotion.cleared_by_user)) { + console.log( + `${login}: cleared ${cleanup.cleared_objects} objects; ${cleanup.remaining_active_objects} active working objects remain`, + ); + } +} + +function printMemoryStatus(status: ManagedMemoryStatus): void { + console.log(`Reconciliation jobs: ${status.queued} queued, ${status.running} running, ${status.failed} failed`); + console.log(`Last sweep: ${status.last_sweep_at ?? "never"}`); + console.log(`Last promotion: ${status.last_promotion_at ?? "never"}`); + console.log(`Repair attempts: ${status.repair_attempts}`); + console.log(`Repaired commits: ${status.repaired_commits}`); + console.log(`Promoted commits: ${status.promoted_commits}`); + console.log(`Quarantined commits: ${status.quarantined_commits}`); + console.log(`Cleared working commits: ${status.cleared_working_commits}`); + console.log(`Active working commits: ${status.remaining_active_working_commits}`); +} + function named(item: { id: string; name?: string }): string { return item.name ?? item.id; } diff --git a/apps/cli/managed-cli.ts b/apps/cli/managed-cli.ts index 040e774..7afac2b 100644 --- a/apps/cli/managed-cli.ts +++ b/apps/cli/managed-cli.ts @@ -213,6 +213,14 @@ export async function runRepoInviteReader(args: string[]): Promise { console.log(`Reader invitation ${invite.id} targets ${invite.target_github_login}.`); } +export async function runRepoInviteContributor(args: string[]): Promise { + const invite = await controlClient().inviteRepoContributor( + repoBinding().managedRepoId, + required(args, "--github-user"), + ); + console.log(`Contributor invitation ${invite.id} targets ${invite.target_github_login}.`); +} + export async function runRepoInviteLinkCreate(args: string[]): Promise { requireNoArgs(args, "Usage: greplica repo invite-link create"); const created = await controlClient().createRepoInviteLink(repoBinding().managedRepoId); @@ -242,6 +250,24 @@ export async function runRepoGrantMemoryAdmin(args: string[]): Promise { console.log(`${grant.user.github_login} is now memory_admin for this memory.`); } +export async function runRepoGrantContributor(args: string[]): Promise { + const grant = await controlClient().grantRepoRole( + repoBinding().managedRepoId, + required(args, "--user"), + "contributor", + ); + console.log(`${grant.user.github_login} is now contributor for this memory.`); +} + +export async function runRepoRevokeContributor(args: string[]): Promise { + const result = await controlClient().revokeRepoRole( + repoBinding().managedRepoId, + required(args, "--user"), + "contributor", + ); + console.log(result.revoked ? "contributor grant revoked." : "No contributor grant existed."); +} + export async function runRepoRevokeMemoryAdmin(args: string[]): Promise { const result = await controlClient().revokeRepoRole(repoBinding().managedRepoId, required(args, "--user"), "memory_admin"); console.log(result.revoked ? "memory_admin grant revoked." : "No memory_admin grant existed."); diff --git a/apps/cli/reconcile-cli.ts b/apps/cli/reconcile-cli.ts new file mode 100644 index 0000000..bb29d6a --- /dev/null +++ b/apps/cli/reconcile-cli.ts @@ -0,0 +1,179 @@ +import { execFileSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { auditClaimCodeAnchors } from "../../libs/knowledge-graph/code-anchors/audit.js"; +import { fingerprintClaimAnchors } from "../../libs/knowledge-graph/code-anchors/fingerprint.js"; +import { ensureGreplicaConfig, managedApiUrl } from "../../libs/config/greplica-config.js"; +import type { + ManagedReconciliationAttestation, + ManagedReconciliationAttestationResult, + ManagedReconciliationCandidate, +} from "../../libs/managed/protocol.js"; + +const defaultOidcAudience = "greplica-managed"; + +export async function runMemoryReconcile(args: string[]): Promise { + const managedRepoId = requiredOption(args, "--managed-repo"); + const mergeSha = requiredOption(args, "--merge-sha"); + const apiUrl = (optionalOption(args, "--api-url") ?? managedApiUrl(ensureGreplicaConfig())).replace(/\/+$/, ""); + const audience = optionalOption(args, "--oidc-audience") ?? defaultOidcAudience; + const repository = optionalOption(args, "--repository") ?? process.env.GITHUB_REPOSITORY; + if (repository === undefined || !repository.includes("/")) { + throw new Error("--repository or GITHUB_REPOSITORY must identify the GitHub owner/repository."); + } + const repoRoot = resolve(optionalOption(args, "--repo-root") ?? process.cwd()); + assertExactCheckout(repoRoot, mergeSha); + + const oidcToken = await githubOidcToken(audience); + const headers = { + authorization: `Bearer ${oidcToken}`, + accept: "application/json", + }; + const candidate = await jsonRequest( + `${apiUrl}/v1/repos/${encodeURIComponent(managedRepoId)}/memory/reconcile/candidate?merge_sha=${encodeURIComponent(mergeSha)}`, + { method: "GET", headers }, + ); + if (candidate.merge_sha !== mergeSha) { + throw new Error(`Managed reconciliation candidate is bound to ${candidate.merge_sha}, not ${mergeSha}.`); + } + if (candidate.memory_commit_ids.length === 0) throw new Error("Managed reconciliation candidate has no memory commits."); + const candidateIds = [...candidate.memory_commit_ids].sort(); + const commitIds = candidate.commits.map((commit) => commit.memory_commit_id).sort(); + if (JSON.stringify(candidateIds) !== JSON.stringify(commitIds)) { + throw new Error("Managed reconciliation candidate commit metadata does not match its selected commit IDs."); + } + const ancestry = candidate.commits.map((commit) => ({ + memory_commit_id: commit.memory_commit_id, + git_head: commit.git_head, + is_ancestor: isAncestor(repoRoot, commit.git_head, mergeSha), + })); + + const auditClaims = candidate.claim_versions.map(({ version_id, claim }) => ({ ...claim, id: version_id })); + const result = await auditClaimCodeAnchors(repoRoot, auditClaims); + const fingerprints: Record> = {}; + for (const claim of auditClaims) { + if (claim.code_anchors === undefined || claim.code_anchors.length === 0) continue; + const values = await fingerprintClaimAnchors(repoRoot, claim.code_anchors); + if (Object.keys(values).length > 0) fingerprints[claim.id] = values; + } + const repairProposalPath = optionalOption(args, "--repair-proposal"); + const repairProposalValue = repairProposalPath === undefined + ? undefined + : JSON.parse(readFileSync(resolve(repairProposalPath), "utf8")) as unknown; + if (repairProposalValue !== undefined && !isRecord(repairProposalValue)) { + throw new Error("--repair-proposal must contain a JSON object."); + } + const repairProposal = repairProposalValue; + const attestation: ManagedReconciliationAttestation = { + managed_repo_id: managedRepoId, + repository, + merge_sha: mergeSha, + memory_pr_id: candidate.memory_pr_id, + memory_commit_ids: candidate.memory_commit_ids, + ancestry, + audit_key: "version_id", + anchor_audit: { result, fingerprints }, + ...(repairProposal === undefined ? {} : { repair_proposal: repairProposal }), + ref: process.env.GITHUB_REF, + run_id: process.env.GITHUB_RUN_ID, + run_attempt: process.env.GITHUB_RUN_ATTEMPT, + workflow_ref: process.env.GITHUB_WORKFLOW_REF, + }; + const response = await jsonRequest( + `${apiUrl}/v1/repos/${encodeURIComponent(managedRepoId)}/memory/reconcile/attest`, + { + method: "POST", + headers: { ...headers, "content-type": "application/json" }, + body: JSON.stringify(attestation), + }, + ); + console.log(JSON.stringify({ + ...response, + memory_pr_id: response.memory_pr_id ?? candidate.memory_pr_id, + merge_sha: mergeSha, + audited_claim_versions: candidate.claim_versions.length, + memory_commit_ids: candidate.memory_commit_ids, + }, null, 2)); +} + +function isAncestor(repoRoot: string, gitHead: string, mergeSha: string): boolean { + try { + execFileSync("git", ["-C", repoRoot, "merge-base", "--is-ancestor", gitHead, mergeSha], { + stdio: "ignore", + }); + return true; + } catch { + return false; + } +} + +function assertExactCheckout(repoRoot: string, mergeSha: string): void { + const git = (arguments_: string[]): string => execFileSync("git", ["-C", repoRoot, ...arguments_], { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }).trim(); + const head = git(["rev-parse", "HEAD"]); + if (head !== mergeSha) throw new Error(`Checked-out HEAD ${head} does not equal requested merge SHA ${mergeSha}.`); + const status = git(["status", "--porcelain", "--untracked-files=no"]); + if (status.length > 0) throw new Error("Reconciliation requires a clean exact-SHA checkout."); +} + +async function githubOidcToken(audience: string): Promise { + const requestUrl = process.env.ACTIONS_ID_TOKEN_REQUEST_URL; + const requestToken = process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN; + if (requestUrl === undefined || requestToken === undefined) { + throw new Error("GitHub Actions OIDC is unavailable; grant the workflow `id-token: write`."); + } + const url = new URL(requestUrl); + url.searchParams.set("audience", audience); + const response = await fetch(url, { + headers: { authorization: `Bearer ${requestToken}`, accept: "application/json" }, + }); + const payload = await response.json() as unknown; + if (!response.ok || !isRecord(payload) || typeof payload.value !== "string") { + throw new Error(`GitHub Actions OIDC request failed (${response.status}).`); + } + return payload.value; +} + +async function jsonRequest(url: string, init: RequestInit): Promise { + const response = await fetch(url, init); + const text = await response.text(); + let payload: unknown = {}; + if (text.length > 0) { + try { + payload = JSON.parse(text); + } catch { + throw new Error(`Managed Greplica returned invalid JSON (${response.status}).`); + } + } + if (!response.ok) { + const message = isRecord(payload) && typeof payload.message === "string" + ? payload.message + : `Managed Greplica reconciliation failed (${response.status}).`; + throw new Error(message); + } + return payload as T; +} + +function requiredOption(args: string[], name: string): string { + const value = optionalOption(args, name); + if (value === undefined) throw new Error(`Missing ${name}.`); + return value; +} + +function optionalOption(args: string[], name: string): string | undefined { + for (let index = 0; index < args.length; index += 1) { + if (args[index] === name) { + const value = args[index + 1]; + if (value === undefined || value.startsWith("--")) throw new Error(`Missing value for ${name}.`); + return value; + } + if (args[index]?.startsWith(`${name}=`)) return args[index]?.slice(name.length + 1); + } + return undefined; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/libs/hooks/runtime-store.ts b/libs/hooks/runtime-store.ts index e73940a..daba18f 100644 --- a/libs/hooks/runtime-store.ts +++ b/libs/hooks/runtime-store.ts @@ -64,7 +64,7 @@ export class LocalAgentRuntimeStore { repos.active_mode = 'local' OR ( repos.active_mode = 'managed' - AND repos.managed_role = 'memory_admin' + AND repos.managed_role IN ('contributor', 'memory_admin') AND repos.managed_access_status = 'active' ) ) diff --git a/libs/install/install.ts b/libs/install/install.ts index f0f84d5..f6ee880 100644 --- a/libs/install/install.ts +++ b/libs/install/install.ts @@ -120,7 +120,7 @@ export async function installGreplica(options: InstallOptions): Promise row.managed_role !== "memory_admin")) { + if ((role === "contributor" || role === "memory_admin") && + before.some((row) => row.managed_role !== "contributor" && row.managed_role !== "memory_admin")) { this.db .prepare( `UPDATE agent_sessions @@ -216,7 +217,8 @@ export class RepoInstallationStore { export function canScheduleMemoryUpdates(installation: RepoInstallation): boolean { if (installation.status !== "active" || !installation.hooksEnabled || !installation.autoMemoryUpdates) return false; if (installation.activeMode === "local") return true; - return installation.managedRole === "memory_admin" && installation.managedAccessStatus === "active"; + return (installation.managedRole === "contributor" || installation.managedRole === "memory_admin") && + installation.managedAccessStatus === "active"; } function toInstallation(row: RepoRecord): RepoInstallation { diff --git a/libs/knowledge-graph/graph-view/build-graph-view.ts b/libs/knowledge-graph/graph-view/build-graph-view.ts index f3ff469..0fb0a71 100644 --- a/libs/knowledge-graph/graph-view/build-graph-view.ts +++ b/libs/knowledge-graph/graph-view/build-graph-view.ts @@ -55,6 +55,16 @@ export interface GraphViewClaimRow { flowIds: string[]; createdAt: string | null; memoryCommitId: string | null; + provenance?: { + version_id: string; + scope_kind: "main" | "working" | "memory_pr" | "quarantine"; + scope_name?: string; + author_github_login?: string; + author_github_login_snapshot?: string; + memory_commit_state?: "active" | "promoted" | "quarantined"; + memory_pr_id?: string; + commit_role?: "direct" | "dependency" | "repair"; + }; } export interface GraphViewTimelineEvent { @@ -392,7 +402,22 @@ function kindColor(kind: string): string { function renderClaimRow(claim: GraphViewClaimRow): string { const badge = `${escapeHtml(claim.kind)}`; - return ` ${escapeHtml(claim.text)}
${escapeHtml(claim.id)}
${escapeHtml(claim.session)}${badge}${escapeHtml(formatDateTime(claim.createdAt))}`; + const provenance = claim.provenance; + const provenanceBadges = provenance === undefined + ? "" + : `
${[ + provenance.scope_kind, + provenance.author_github_login ?? provenance.author_github_login_snapshot, + provenance.commit_role, + provenance.memory_commit_state, + provenance.memory_pr_id === undefined ? undefined : `Memory PR ${provenance.memory_pr_id}`, + ].filter((value): value is string => value !== undefined) + .map((value) => `${escapeHtml(value)}`) + .join("")}
`; + const version = provenance === undefined + ? "" + : ` version ${escapeHtml(provenance.version_id)}`; + return ` ${escapeHtml(claim.text)}
${escapeHtml(claim.id)}${version}
${provenanceBadges}${escapeHtml(claim.session)}${badge}${escapeHtml(formatDateTime(claim.createdAt))}`; } function renderHtml(data: GraphViewData, title: string): string { @@ -609,6 +634,22 @@ function renderHtml(data: GraphViewData, title: string): string { td.claim-text .claim-id code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; } + .claim-version { margin-left: 0.45rem; } + .provenance-badges { + display: flex; + flex-wrap: wrap; + gap: 0.3rem; + margin-top: 0.45rem; + } + .provenance-badge { + display: inline-block; + padding: 0.1rem 0.45rem; + border-radius: 999px; + background: var(--accent-soft); + color: var(--accent); + font-size: 0.72rem; + font-weight: 600; + } td.session { min-width: 180px; font-size: 0.9rem; } td.kind-cell { white-space: nowrap; } .kind-badge { @@ -643,6 +684,32 @@ function renderHtml(data: GraphViewData, title: string): string { background: var(--panel); color: var(--text); } + .provenance-filters { + display: flex; + flex-wrap: wrap; + gap: 0.65rem; + margin-top: 0.75rem; + } + .provenance-filters label { + display: flex; + flex-direction: column; + gap: 0.2rem; + color: var(--muted); + font-size: 0.72rem; + font-weight: 600; + text-transform: uppercase; + } + .provenance-filter { + min-width: 130px; + padding: 0.4rem 0.55rem; + border: 1px solid var(--line); + border-radius: 7px; + background: var(--panel); + color: var(--text); + font: inherit; + font-size: 0.82rem; + text-transform: none; + } .claims-search:focus { outline: none; border-color: var(--accent); @@ -845,6 +912,13 @@ ${flowRows}

Claims

+
+ + + + + +

${escapeHtml(defaultClaimsMeta)}

@@ -905,6 +979,13 @@ ${timelineEvents} const claimRows = document.querySelectorAll("#claims-table tbody tr[data-id]"); const claimsMeta = document.getElementById("claims-meta"); const claimsSearchInput = document.getElementById("claims-search"); + const provenanceFilterSelects = { + scope: document.getElementById("claims-filter-scope"), + author: document.getElementById("claims-filter-author"), + memoryState: document.getElementById("claims-filter-memory-state"), + memoryPrId: document.getElementById("claims-filter-memory-pr"), + commitRole: document.getElementById("claims-filter-commit-role"), + }; const defaultClaimsMeta = ${JSON.stringify(defaultClaimsMeta)}; const CLAIM_KIND_ORDER = ${JSON.stringify(CLAIM_KIND_ORDER)}; @@ -921,6 +1002,27 @@ ${timelineEvents} let activeFilter = null; + function provenanceValue(claim, key) { + const provenance = claim.provenance || {}; + if (key === "scope") return provenance.scope_kind || ""; + if (key === "author") return provenance.author_github_login || provenance.author_github_login_snapshot || ""; + if (key === "memoryState") return provenance.memory_commit_state || ""; + if (key === "memoryPrId") return provenance.memory_pr_id || ""; + if (key === "commitRole") return provenance.commit_role || ""; + return ""; + } + + for (const [key, select] of Object.entries(provenanceFilterSelects)) { + if (!select) continue; + const values = [...new Set(allClaims.map((claim) => provenanceValue(claim, key)).filter(Boolean))].sort(); + for (const value of values) { + const option = document.createElement("option"); + option.value = value; + option.textContent = value; + select.appendChild(option); + } + } + function escapeHtmlClient(value) { return String(value) .replace(/&/g, "&") @@ -1173,6 +1275,17 @@ ${timelineEvents} return true; } + function rowMatchesProvenanceFilters(row) { + return Object.entries(provenanceFilterSelects).every(([key, select]) => { + if (!select || !select.value) return true; + return (row.dataset[key] || "") === select.value; + }); + } + + function hasProvenanceFilter() { + return Object.values(provenanceFilterSelects).some((select) => select && select.value); + } + function applyClaims() { const query = (claimsSearchInput && claimsSearchInput.value ? claimsSearchInput.value : "").trim().toLowerCase(); const filter = activeFilter; @@ -1182,13 +1295,13 @@ ${timelineEvents} const matchesFilter = rowMatchesFilter(row, filter); const text = (claimTextById.get(id) || "").toLowerCase(); const matchesSearch = !query || id.toLowerCase().includes(query) || text.includes(query); - const vis = matchesFilter && matchesSearch; + const vis = matchesFilter && matchesSearch && rowMatchesProvenanceFilters(row); row.classList.toggle("claim-row-hidden", !vis); if (vis) visible += 1; } if (!claimsMeta) return; - if (!filter && !query) { + if (!filter && !query && !hasProvenanceFilter()) { claimsMeta.textContent = defaultClaimsMeta; return; } @@ -1202,6 +1315,7 @@ ${timelineEvents} let meta = visible + " of " + base + " claims"; if (filter) meta += " " + describeFilter(filter); if (query) meta += ' matching "' + escapeHtmlClient(query) + '"'; + if (hasProvenanceFilter()) meta += " matching provenance filters"; meta += ' · Clear filter'; claimsMeta.innerHTML = meta; } @@ -1240,6 +1354,9 @@ ${timelineEvents} if (filterClear) { event.preventDefault(); if (claimsSearchInput) claimsSearchInput.value = ""; + for (const select of Object.values(provenanceFilterSelects)) { + if (select) select.value = ""; + } history.replaceState(null, "", "#claims"); viewFromHash(); return; @@ -1254,6 +1371,9 @@ ${timelineEvents} }); if (claimsSearchInput) claimsSearchInput.addEventListener("input", applyClaims); + for (const select of Object.values(provenanceFilterSelects)) { + if (select) select.addEventListener("change", applyClaims); + } const overviewNavLink = document.querySelector('nav a[data-view="claims-overview"]'); if (overviewNavLink) { diff --git a/libs/knowledge-graph/local-provider.ts b/libs/knowledge-graph/local-provider.ts index d2631b4..5506ddd 100644 --- a/libs/knowledge-graph/local-provider.ts +++ b/libs/knowledge-graph/local-provider.ts @@ -3,6 +3,7 @@ import type { RepoInstallation } from "../install/repo-installation-store.js"; import type { RepoRef } from "./service.js"; import { KnowledgeGraphService } from "./service.js"; import type { GraphMemoryProvider } from "./provider.js"; +import type { ManagedGraphView } from "../managed/protocol.js"; export class LocalGraphMemoryProvider implements GraphMemoryProvider { readonly mode = "local" as const; @@ -18,19 +19,23 @@ export class LocalGraphMemoryProvider implements GraphMemoryProvider { if (initialized.repo_id !== installation.id) throw new Error("Resolved repository installation does not match its local graph."); } - async readGraph() { + async readGraph(view?: ManagedGraphView) { + assertLocalDefaultView(view); return this.service.readGraph(this.repo); } - async contextGraph(query: string) { + async contextGraph(query: string, view?: ManagedGraphView) { + assertLocalDefaultView(view); return this.service.contextGraph(this.repo, query); } - async viewData() { + async viewData(view?: ManagedGraphView) { + assertLocalDefaultView(view); return this.service.graphViewData(this.repo); } - async buildGraphView() { + async buildGraphView(view?: ManagedGraphView) { + assertLocalDefaultView(view); return this.service.buildGraphView(this.repo); } @@ -46,7 +51,39 @@ export class LocalGraphMemoryProvider implements GraphMemoryProvider { return this.service.applyProposal(this.repo, proposal); } + listProposals(): Promise { + return Promise.reject(managedCollaborationOnly()); + } + + showProposal(_proposalId: string): Promise { + return Promise.reject(managedCollaborationOnly()); + } + + listMemoryPrs(): Promise { + return Promise.reject(managedCollaborationOnly()); + } + + showMemoryPr(_memoryPrId: string): Promise { + return Promise.reject(managedCollaborationOnly()); + } + + retryMemoryPr(_memoryPrId: string): Promise { + return Promise.reject(managedCollaborationOnly()); + } + + memoryStatus(): Promise { + return Promise.reject(managedCollaborationOnly()); + } + close(): void { this.db.close(); } } + +function assertLocalDefaultView(view: ManagedGraphView | undefined): void { + if (view !== undefined) throw managedCollaborationOnly(); +} + +function managedCollaborationOnly(): Error { + return new Error("Personal working scopes and Memory PRs require a managed Greplica repository."); +} diff --git a/libs/knowledge-graph/managed-client.ts b/libs/knowledge-graph/managed-client.ts index 823c40e..21ed461 100644 --- a/libs/knowledge-graph/managed-client.ts +++ b/libs/knowledge-graph/managed-client.ts @@ -17,6 +17,12 @@ import { normalizeProposal } from "./proposal.js"; import type { GraphMemoryProvider, ManagedProposalReviewResult } from "./provider.js"; import type { ApplyProposalResult, GraphReadResult, RepoRef } from "./service.js"; import type { GraphContextResult } from "./graph-context/types.js"; +import type { + ManagedGraphView, + ManagedMemoryPr, + ManagedMemoryStatus, + ManagedProposal, +} from "../managed/protocol.js"; export interface ManagedGraphClientOptions { apiUrl: string; @@ -33,8 +39,11 @@ interface AnchorDataResponse { interface ApplyRequest { proposal: unknown; working_head: string; + working_revision?: number; + main_head?: string; anchor_audit: ProposalAnchorAudit; - commit?: { git_head?: string; branch?: string; dirty?: boolean }; + commit?: ProposalCommitContext; + context?: ProposalCommitContext; } interface ProposalAnchorAudit { @@ -42,6 +51,16 @@ interface ProposalAnchorAudit { fingerprints: Record>; } +interface ProposalCommitContext { + git_head?: string; + head_repository?: string; + head_ref?: string; + branch?: string; + dirty?: boolean; + session_refs?: Array<{ id: string; agent_platform?: string }>; + agent_platform?: string; +} + export class ManagedGraphMemoryClient implements GraphMemoryProvider { readonly mode = "managed" as const; private readonly apiUrl: string; @@ -64,12 +83,16 @@ export class ManagedGraphMemoryClient implements GraphMemoryProvider { private readonly credentials?: ManagedCredentials; - readGraph(): Promise { - return this.request("/graph", { method: "GET" }); + readGraph(view?: ManagedGraphView): Promise { + return this.request(`/graph${viewQuery(this.requestView(view))}`, { method: "GET" }); } - async contextGraph(query: string): Promise { - const result = await this.request("/graph/context", { method: "POST", body: { query } }); + async contextGraph(query: string, view?: ManagedGraphView): Promise { + const requestView = this.requestView(view); + const result = await this.request("/graph/context", { + method: "POST", + body: { query, ...(requestView === undefined ? {} : { view: requestView }) }, + }); const resolver = new CodeAnchorResolver(); const resolved = new Map>>(); for (const claim of result.claims) { @@ -83,12 +106,12 @@ export class ManagedGraphMemoryClient implements GraphMemoryProvider { return result; } - viewData(): Promise { - return this.request("/graph/view-data", { method: "GET" }); + viewData(view?: ManagedGraphView): Promise { + return this.request(`/graph/view-data${viewQuery(this.requestView(view))}`, { method: "GET" }); } - async buildGraphView(): Promise { - return buildGraphViewHtmlFromData(await this.viewData(), { repoName: this.repo.repo_name }); + async buildGraphView(view?: ManagedGraphView): Promise { + return buildGraphViewHtmlFromData(await this.viewData(view), { repoName: this.repo.repo_name }); } async auditCodeAnchors(): Promise { @@ -103,6 +126,7 @@ export class ManagedGraphMemoryClient implements GraphMemoryProvider { async reviewProposal(proposal: unknown): Promise { const anchorAudit = await this.proposalAnchorAudit(proposal); + const context = localProposalContext(this.repo, proposal); if (anchorAudit.result.missing_anchors.length > 0 || anchorAudit.result.missing_files.length > 0 || anchorAudit.result.missing_symbols.length > 0 || @@ -114,14 +138,18 @@ export class ManagedGraphMemoryClient implements GraphMemoryProvider { duplicate_warnings: {}, }; } - return this.request("/proposals/review", { method: "POST", body: { proposal, anchor_audit: anchorAudit } }); + return this.request("/proposals/review", { + method: "POST", + body: { proposal, anchor_audit: anchorAudit, ...(context === undefined ? {} : { context }) }, + }); } async applyProposal(proposal: unknown): Promise { const anchorAudit = await this.proposalAnchorAudit(proposal); + const context = localProposalContext(this.repo, proposal); const review = await this.request("/proposals/review", { method: "POST", - body: { proposal, anchor_audit: anchorAudit }, + body: { proposal, anchor_audit: anchorAudit, ...(context === undefined ? {} : { context }) }, }); if (!review.valid) { throw new Error(`Proposal is invalid:\n${review.errors.map((error) => `- ${error}`).join("\n")}`); @@ -130,14 +158,50 @@ export class ManagedGraphMemoryClient implements GraphMemoryProvider { const body: ApplyRequest = { proposal, working_head: review.working_head, + working_revision: review.working_revision, + main_head: review.main_head, anchor_audit: anchorAudit, - commit: localGitState(this.repo.repo_root), + commit: legacyCommitContext(context), + context, }; return this.request("/proposals/apply", { method: "POST", body }); } + listProposals(): Promise { + return this.request("/proposals", { method: "GET" }); + } + + showProposal(proposalId: string): Promise { + return this.request(`/proposals/${encodeURIComponent(proposalId)}`, { method: "GET" }); + } + + listMemoryPrs(): Promise { + return this.request("/memory-prs", { method: "GET" }); + } + + showMemoryPr(memoryPrId: string): Promise { + return this.request(`/memory-prs/${encodeURIComponent(memoryPrId)}`, { method: "GET" }); + } + + retryMemoryPr(memoryPrId: string): Promise { + return this.request(`/memory-prs/${encodeURIComponent(memoryPrId)}/retry`, { method: "POST" }); + } + + memoryStatus(): Promise { + return this.request("/memory/status", { method: "GET" }); + } + close(): void {} + private requestView(view: ManagedGraphView | undefined): ManagedGraphView | undefined { + if (view?.working_users === undefined || view.working_users.length === 0) return view; + const login = this.credentials?.user.githubLogin; + return { + ...view, + working_users: [...new Set(login === undefined ? view.working_users : [login, ...view.working_users])], + }; + } + private async proposalAnchorAudit(proposal: unknown): Promise { const normalized = normalizeProposal(proposal); const claims = normalized.creates.claims ?? []; @@ -190,7 +254,7 @@ export class ManagedGraphMemoryClient implements GraphMemoryProvider { } const role = response.headers.get("x-greplica-repo-role"); const access = response.headers.get("x-greplica-access-status"); - if ((role === "reader" || role === "memory_admin") && + if ((role === "reader" || role === "contributor" || role === "memory_admin") && (access === "active" || access === "pending" || access === "suspended" || access === "revoked")) { const db = openDatabase(); try { @@ -226,8 +290,20 @@ function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } -function localGitState(repoRoot: string | undefined): ApplyRequest["commit"] { - if (repoRoot === undefined) return undefined; +function localProposalContext(repo: RepoRef, proposal: unknown): ProposalCommitContext | undefined { + const repoRoot = repo.repo_root; + const sessionRefIds = proposalSessionRefs(proposal); + const agentPlatform = proposalAgentPlatform(sessionRefIds); + const sessionRefs = sessionRefIds.map((id) => ({ id, agent_platform: platformForSessionRef(id) })); + const headRepository = githubRepository(repo.remote_url); + if (repoRoot === undefined) { + if (sessionRefs.length === 0 && agentPlatform === undefined && headRepository === undefined) return undefined; + return { + head_repository: headRepository, + session_refs: sessionRefs.length === 0 ? undefined : sessionRefs, + agent_platform: agentPlatform, + }; + } const git = (args: string[]): string | undefined => { try { const value = execFileSync("git", ["-C", repoRoot, ...args], { @@ -242,6 +318,63 @@ function localGitState(repoRoot: string | undefined): ApplyRequest["commit"] { const gitHead = git(["rev-parse", "HEAD"]); const branch = git(["branch", "--show-current"]); const dirtyOutput = git(["status", "--porcelain"]); - if (gitHead === undefined && branch === undefined && dirtyOutput === undefined) return undefined; - return { git_head: gitHead, branch, dirty: dirtyOutput !== undefined }; + if (gitHead === undefined && branch === undefined && dirtyOutput === undefined && + sessionRefs.length === 0 && agentPlatform === undefined && headRepository === undefined) return undefined; + return { + git_head: gitHead, + head_repository: headRepository, + head_ref: branch, + branch, + dirty: dirtyOutput === undefined ? undefined : dirtyOutput.length > 0, + session_refs: sessionRefs.length === 0 ? undefined : sessionRefs, + agent_platform: agentPlatform, + }; +} + +function legacyCommitContext(context: ProposalCommitContext | undefined): ProposalCommitContext | undefined { + if (context === undefined) return undefined; + const { git_head, branch, dirty } = context; + if (git_head === undefined && branch === undefined && dirty === undefined) return undefined; + return { git_head, branch, dirty }; +} + +function proposalSessionRefs(proposal: unknown): string[] { + if (!isRecord(proposal) || !isRecord(proposal.creates) || !Array.isArray(proposal.creates.sources)) return []; + const refs = proposal.creates.sources.flatMap((source) => + isRecord(source) && source.kind === "session" && typeof source.ref === "string" ? [source.ref] : []); + return [...new Set(refs)]; +} + +function proposalAgentPlatform(sessionRefs: string[]): string | undefined { + const platforms = new Set(sessionRefs.map(platformForSessionRef).filter((value): value is string => value !== undefined)); + return platforms.size === 1 ? [...platforms][0] : undefined; +} + +function platformForSessionRef(ref: string): string | undefined { + const separator = ref.indexOf(":"); + if (separator <= 0) return undefined; + const prefix = ref.slice(0, separator); + if (prefix === "claude-code-session") return "claude"; + if (prefix === "factory-droid-session") return "factory-droid"; + return prefix.endsWith("-session") ? prefix.slice(0, -"-session".length) : prefix; +} + +function githubRepository(remoteUrl: string | undefined): string | undefined { + if (remoteUrl === undefined) return undefined; + const match = /github\.com[/:]([^/]+)\/([^/]+?)(?:\.git)?$/i.exec(remoteUrl); + return match === null ? undefined : `${match[1]}/${match[2]}`; +} + +function viewQuery(view: ManagedGraphView | undefined): string { + if (view === undefined) return ""; + const query = new URLSearchParams({ base: view.base }); + if (view.working_users?.length === 0 && + view.memory_pr_id === undefined && + view.include_quarantined !== true) { + query.set("main_only", "true"); + } + for (const user of view.working_users ?? []) query.append("working_user", user); + if (view.memory_pr_id !== undefined) query.set("memory_pr_id", view.memory_pr_id); + if (view.include_quarantined === true) query.set("include_quarantined", "true"); + return `?${query.toString()}`; } diff --git a/libs/knowledge-graph/provider.ts b/libs/knowledge-graph/provider.ts index 5d135c3..28025b6 100644 --- a/libs/knowledge-graph/provider.ts +++ b/libs/knowledge-graph/provider.ts @@ -2,6 +2,12 @@ import type { RepoInstallation } from "../install/repo-installation-store.js"; import type { ClaimAnchorAuditResult } from "./code-anchors/types.js"; import type { GraphContextResult } from "./graph-context/types.js"; import type { GraphViewData } from "./graph-view/build-graph-view.js"; +import type { + ManagedGraphView, + ManagedMemoryPr, + ManagedMemoryStatus, + ManagedProposal, +} from "../managed/protocol.js"; import type { ApplyProposalResult, GraphReadResult, @@ -12,19 +18,27 @@ export type GraphMemoryProviderMode = "local" | "managed"; export interface ManagedProposalReviewResult extends ProposalReviewResult { working_head?: string; + working_revision?: number; + main_head?: string; } export interface GraphMemoryProvider { readonly mode: GraphMemoryProviderMode; readonly installation: RepoInstallation; - readGraph(): Promise; - contextGraph(query: string): Promise; - viewData(): Promise; - buildGraphView(): Promise; + readGraph(view?: ManagedGraphView): Promise; + contextGraph(query: string, view?: ManagedGraphView): Promise; + viewData(view?: ManagedGraphView): Promise; + buildGraphView(view?: ManagedGraphView): Promise; auditCodeAnchors(): Promise; reviewProposal(proposal: unknown): Promise; applyProposal(proposal: unknown): Promise; + listProposals(): Promise; + showProposal(proposalId: string): Promise; + listMemoryPrs(): Promise; + showMemoryPr(memoryPrId: string): Promise; + retryMemoryPr(memoryPrId: string): Promise; + memoryStatus(): Promise; close(): void; } diff --git a/libs/managed/control-client.ts b/libs/managed/control-client.ts index f719790..48f1e40 100644 --- a/libs/managed/control-client.ts +++ b/libs/managed/control-client.ts @@ -165,6 +165,13 @@ export class ManagedControlClient { return this.request("POST", `/v1/repos/${encodeURIComponent(repoId)}/invites`, { github_user: githubUser }, true, repoId); } + inviteRepoContributor(repoId: string, githubUser: string): Promise { + return this.request("POST", `/v1/repos/${encodeURIComponent(repoId)}/invites`, { + github_user: githubUser, + role: "contributor", + }, true, repoId); + } + createRepoInviteLink(repoId: string): Promise { return this.request("POST", `/v1/repos/${encodeURIComponent(repoId)}/invite-links`, {}, true, repoId); } @@ -187,11 +194,11 @@ export class ManagedControlClient { return this.request("POST", "/v1/invite-links/claim", { token }); } - grantRepoRole(repoId: string, userId: string, role: "reader" | "memory_admin"): Promise { + grantRepoRole(repoId: string, userId: string, role: "reader" | "contributor" | "memory_admin"): Promise { return this.request("POST", `/v1/repos/${encodeURIComponent(repoId)}/grants`, { user_id: userId, role }, true, repoId); } - revokeRepoRole(repoId: string, userId: string, role: "reader" | "memory_admin"): Promise<{ revoked: boolean }> { + revokeRepoRole(repoId: string, userId: string, role: "reader" | "contributor" | "memory_admin"): Promise<{ revoked: boolean }> { return this.request("DELETE", `/v1/repos/${encodeURIComponent(repoId)}/grants`, { user_id: userId, role }, true, repoId); } @@ -300,14 +307,14 @@ export class ManagedControlClient { private captureRepoAccess(response: Response, managedRepoId: string): void { const role = response.headers.get("x-greplica-repo-role"); const status = response.headers.get("x-greplica-access-status"); - if ((role !== "reader" && role !== "memory_admin") || + if ((role !== "reader" && role !== "contributor" && role !== "memory_admin") || (status !== "active" && status !== "pending" && status !== "suspended" && status !== "revoked")) return; this.updateRepoAccess(managedRepoId, role, status); } private updateRepoAccess( managedRepoId: string, - role: "reader" | "memory_admin" | undefined, + role: "reader" | "contributor" | "memory_admin" | undefined, status: "active" | "pending" | "suspended" | "revoked", ): void { const db = openDatabase(); @@ -322,9 +329,10 @@ export class ManagedControlClient { function isRepositoryAccessPayload( value: unknown, managedRepoId: string, -): value is { id: string; effective_role: "reader" | "memory_admin"; access_status: "active" | "pending" | "suspended" | "revoked" } { +): value is { id: string; effective_role: "reader" | "contributor" | "memory_admin"; access_status: "active" | "pending" | "suspended" | "revoked" } { if (!isRecord(value) || value.id !== managedRepoId) return false; - return (value.effective_role === "reader" || value.effective_role === "memory_admin") && + return (value.effective_role === "reader" || value.effective_role === "contributor" || + value.effective_role === "memory_admin") && (value.access_status === "active" || value.access_status === "pending" || value.access_status === "suspended" || value.access_status === "revoked"); } diff --git a/libs/managed/protocol.ts b/libs/managed/protocol.ts index 915079f..82a4157 100644 --- a/libs/managed/protocol.ts +++ b/libs/managed/protocol.ts @@ -35,7 +35,11 @@ export const OrgRoleSchema = Type.Union([ Type.Literal("member"), Type.Literal("guest"), ]); -export const RepoRoleSchema = Type.Union([Type.Literal("reader"), Type.Literal("memory_admin")]); +export const RepoRoleSchema = Type.Union([ + Type.Literal("reader"), + Type.Literal("contributor"), + Type.Literal("memory_admin"), +]); export const AccessStatusSchema = Type.Union([ Type.Literal("active"), Type.Literal("pending"), @@ -60,7 +64,11 @@ export const OrgMembershipSchema = Type.Object({ updated_at: Type.String({ format: "date-time" }), }); -export const InvitationKindSchema = Type.Union([Type.Literal("org_member"), Type.Literal("repo_reader")]); +export const InvitationKindSchema = Type.Union([ + Type.Literal("org_member"), + Type.Literal("repo_reader"), + Type.Literal("repo_contributor"), +]); export const InvitationSchema = Type.Object({ id: Type.String({ format: "uuid" }), kind: InvitationKindSchema, @@ -132,6 +140,74 @@ export const AccessRequestSchema = Type.Object({ decided_at: Type.Optional(Type.String({ format: "date-time" })), }); +export const ManagedGraphViewSchema = Type.Object({ + base: Type.Literal("main"), + working_users: Type.Optional(Type.Array(Type.String({ minLength: 1 }), { uniqueItems: true })), + memory_pr_id: Type.Optional(Type.String({ minLength: 1 })), + include_quarantined: Type.Optional(Type.Boolean()), +}); + +export const ManagedGraphViewQuerySchema = Type.Object({ + base: Type.Optional(Type.Literal("main")), + working_user: Type.Optional(Type.Union([Type.String({ minLength: 1 }), Type.Array(Type.String({ minLength: 1 }))])), + memory_pr_id: Type.Optional(Type.String({ minLength: 1 })), + include_quarantined: Type.Optional(Type.Boolean()), + main_only: Type.Optional(Type.Boolean()), +}); + +export const MemoryCommitStateSchema = Type.Union([ + Type.Literal("active"), + Type.Literal("promoted"), + Type.Literal("quarantined"), +]); +export const MemoryPrStateSchema = Type.Union([ + Type.Literal("open"), + Type.Literal("awaiting_default"), + Type.Literal("reconciling"), + Type.Literal("merged"), + Type.Literal("merged_with_quarantine"), + Type.Literal("quarantined"), +]); +export const ReconciliationJobStateSchema = Type.Union([ + Type.Literal("queued"), + Type.Literal("running"), + Type.Literal("succeeded"), + Type.Literal("failed"), +]); + +export const ManagedObjectProvenanceSchema = Type.Object({ + version_id: Type.String(), + scope_kind: Type.Union([ + Type.Literal("main"), + Type.Literal("working"), + Type.Literal("memory_pr"), + Type.Literal("quarantine"), + ]), + scope_name: Type.Optional(Type.String()), + author_user_id: Type.Optional(Type.String({ format: "uuid" })), + author_github_login: Type.Optional(Type.String()), + author_github_login_snapshot: Type.Optional(Type.String()), + proposal_id: Type.Optional(Type.String()), + memory_commit_id: Type.Optional(Type.String()), + memory_commit_state: Type.Optional(MemoryCommitStateSchema), + session_refs: Type.Optional(Type.Array(Type.Object({ + id: Type.String(), + agent_platform: Type.Optional(Type.String()), + }))), + agent_platform: Type.Optional(Type.String()), + git_head: Type.Optional(Type.String()), + branch: Type.Optional(Type.String()), + code_pr_number: Type.Optional(Type.Integer({ minimum: 1 })), + memory_pr_id: Type.Optional(Type.String()), + commit_role: Type.Optional(Type.Union([ + Type.Literal("direct"), + Type.Literal("dependency"), + Type.Literal("repair"), + ])), + promotion_id: Type.Optional(Type.String()), + quarantine_reason: Type.Optional(Type.String()), +}); + export const CodeAnchorSchema = Type.Object({ file: Type.String(), symbol: Type.Optional(Type.String()), @@ -140,8 +216,13 @@ export const ComponentSchema = Type.Object({ id: Type.String(), name: Type.String(), code_anchor: Type.Optional(Type.String()), + provenance: Type.Optional(ManagedObjectProvenanceSchema), +}); +export const FlowSchema = Type.Object({ + id: Type.String(), + name: Type.String(), + provenance: Type.Optional(ManagedObjectProvenanceSchema), }); -export const FlowSchema = Type.Object({ id: Type.String(), name: Type.String() }); export const ClaimSchema = Type.Object({ id: Type.String(), kind: Type.Union([ @@ -156,12 +237,14 @@ export const ClaimSchema = Type.Object({ truth: Type.Union([Type.Literal("code_verified"), Type.Literal("source_verified"), Type.Literal("unknown")]), intent: Type.Union([Type.Literal("intended"), Type.Literal("accidental"), Type.Literal("unknown")]), code_anchors: Type.Optional(Type.Array(CodeAnchorSchema)), + provenance: Type.Optional(ManagedObjectProvenanceSchema), }); export const SourceSchema = Type.Object({ id: Type.String(), kind: Type.Literal("session"), ref: Type.String(), title: Type.Optional(Type.String()), + provenance: Type.Optional(ManagedObjectProvenanceSchema), }); export const GraphObjectTypeSchema = Type.Union([ Type.Literal("component"), @@ -184,6 +267,7 @@ export const EdgeSchema = Type.Object({ Type.Literal("evidenced_by"), ]), metadata: Type.Optional(Type.Record(Type.String(), Type.Unknown())), + provenance: Type.Optional(ManagedObjectProvenanceSchema), }); export const GraphReadSchema = Type.Object({ @@ -229,11 +313,18 @@ export const ProposalReviewSchema = Type.Object({ Type.Array(Type.Object({ claim_id: Type.String(), similarity: Type.Number() })), ), working_head: Type.String(), + working_revision: Type.Optional(Type.Integer({ minimum: 0 })), + main_head: Type.Optional(Type.String()), }); export const ApplyProposalResultSchema = Type.Object({ memory_commit_id: Type.String(), scope_id: Type.String(), + proposal_id: Type.Optional(Type.String()), + author: Type.Optional(UserSchema), + working_scope_revision: Type.Optional(Type.Integer({ minimum: 0 })), + memory_commit_state: Type.Optional(MemoryCommitStateSchema), + memory_pr_id: Type.Optional(Type.String()), embedding_status: Type.Object({ checked_objects: Type.Integer({ minimum: 0 }), created: Type.Integer({ minimum: 0 }), @@ -374,10 +465,12 @@ export const GraphViewClaimRowSchema = Type.Object({ flowIds: Type.Array(Type.String()), createdAt: Type.Union([Type.String({ format: "date-time" }), Type.Null()]), memoryCommitId: Type.Union([Type.String(), Type.Null()]), + provenance: Type.Optional(ManagedObjectProvenanceSchema), }); export const GraphViewDataSchema = Type.Object({ generatedAt: Type.String({ format: "date-time" }), + view: Type.Optional(ManagedGraphViewSchema), counts: Type.Object({ components: Type.Integer({ minimum: 0 }), flows: Type.Integer({ minimum: 0 }), @@ -416,10 +509,150 @@ export const GraphViewDataSchema = Type.Object({ export const MemoryCommitMetadataSchema = Type.Object({ git_head: Type.Optional(Type.String()), + head_repository: Type.Optional(Type.String()), + head_ref: Type.Optional(Type.String()), branch: Type.Optional(Type.String()), dirty: Type.Optional(Type.Boolean()), }); +export const ProposalContextSchema = Type.Intersect([ + MemoryCommitMetadataSchema, + Type.Object({ + session_refs: Type.Optional(Type.Array(Type.Object({ + id: Type.String(), + agent_platform: Type.Optional(Type.String()), + }))), + agent_platform: Type.Optional(Type.String()), + }), +]); + +export const CodePrReferenceSchema = Type.Object({ + id: Type.Optional(Type.String()), + number: Type.Integer({ minimum: 1 }), + url: Type.Optional(Type.String()), + head_repository: Type.Optional(Type.String()), + head_ref: Type.Optional(Type.String()), + head_sha: Type.Optional(Type.String()), + base_ref: Type.Optional(Type.String()), + merge_sha: Type.Optional(Type.String()), +}); + +export const MemoryCommitRecordSchema = Type.Object({ + id: Type.String(), + proposal_id: Type.Optional(Type.String()), + scope_id: Type.String(), + scope_name: Type.String(), + state: MemoryCommitStateSchema, + author: UserSchema, + session_refs: Type.Array(Type.Object({ + id: Type.String(), + agent_platform: Type.Optional(Type.String()), + })), + agent_platform: Type.Optional(Type.String()), + git: Type.Optional(MemoryCommitMetadataSchema), + code_pr: Type.Optional(CodePrReferenceSchema), + memory_pr_id: Type.Optional(Type.String()), + created_at: Type.String({ format: "date-time" }), + promoted_at: Type.Optional(Type.String({ format: "date-time" })), + quarantined_at: Type.Optional(Type.String({ format: "date-time" })), + quarantine_reason: Type.Optional(Type.String()), +}); + +export const ProposalRecordSchema = Type.Object({ + id: Type.String(), + memory_commit: MemoryCommitRecordSchema, + proposal: MemoryProposalSchema, + anchor_audit: Type.Optional(ProposalAnchorAuditSchema), + created_at: Type.String({ format: "date-time" }), +}); + +export const PromotionCleanupSchema = Type.Object({ + id: Type.String(), + status: Type.String(), + new_main_head: Type.String(), + canonical_memory_commit_id: Type.Optional(Type.String()), + cleared_commit_ids: Type.Array(Type.String()), + already_canonical_commit_ids: Type.Array(Type.String()), + quarantined_commit_ids: Type.Array(Type.String()), + cleared_by_user: Type.Record(Type.String(), Type.Object({ + cleared_objects: Type.Integer({ minimum: 0 }), + remaining_active_commits: Type.Optional(Type.Integer({ minimum: 0 })), + remaining_active_objects: Type.Integer({ minimum: 0 }), + })), + promoted_at: Type.String({ format: "date-time" }), +}); + +export const MemoryPrSchema = Type.Object({ + id: Type.String(), + code_pr: CodePrReferenceSchema, + state: MemoryPrStateSchema, + direct_commit_ids: Type.Array(Type.String()), + dependency_commit_ids: Type.Array(Type.String()), + repair_commit_ids: Type.Array(Type.String()), + contributor_logins: Type.Array(Type.String()), + latest_job_state: Type.Optional(ReconciliationJobStateSchema), + promotion: Type.Optional(PromotionCleanupSchema), + created_at: Type.String({ format: "date-time" }), + updated_at: Type.String({ format: "date-time" }), +}); + +export const MemoryStatusSchema = Type.Object({ + queued: Type.Integer({ minimum: 0 }), + running: Type.Integer({ minimum: 0 }), + failed: Type.Integer({ minimum: 0 }), + last_sweep_at: Type.Optional(Type.String({ format: "date-time" })), + last_promotion_at: Type.Optional(Type.String({ format: "date-time" })), + repair_attempts: Type.Integer({ minimum: 0 }), + repaired_commits: Type.Integer({ minimum: 0 }), + promoted_commits: Type.Integer({ minimum: 0 }), + quarantined_commits: Type.Integer({ minimum: 0 }), + cleared_working_commits: Type.Integer({ minimum: 0 }), + remaining_active_working_commits: Type.Integer({ minimum: 0 }), +}); + +export const ReconciliationCandidateSchema = Type.Object({ + memory_pr_id: Type.String(), + merge_sha: Type.String({ minLength: 7 }), + memory_commit_ids: Type.Array(Type.String(), { minItems: 1, uniqueItems: true }), + commits: Type.Array(Type.Object({ + memory_commit_id: Type.String(), + git_head: Type.String({ minLength: 7 }), + head_repository: Type.Optional(Type.String()), + head_ref: Type.Optional(Type.String()), + }), { minItems: 1 }), + claim_versions: Type.Array(Type.Object({ + version_id: Type.String(), + claim: ClaimSchema, + })), +}); + +export const ReconciliationAttestationSchema = Type.Object({ + managed_repo_id: Type.String({ format: "uuid" }), + repository: Type.String({ minLength: 3 }), + merge_sha: Type.String({ minLength: 7 }), + memory_pr_id: Type.String(), + memory_commit_ids: Type.Array(Type.String(), { minItems: 1, uniqueItems: true }), + ancestry: Type.Array(Type.Object({ + memory_commit_id: Type.String(), + git_head: Type.String({ minLength: 7 }), + is_ancestor: Type.Boolean(), + }), { minItems: 1 }), + audit_key: Type.Literal("version_id"), + anchor_audit: ProposalAnchorAuditSchema, + repair_proposal: Type.Optional(MemoryProposalSchema), + ref: Type.Optional(Type.String()), + run_id: Type.Optional(Type.String()), + run_attempt: Type.Optional(Type.String()), + workflow_ref: Type.Optional(Type.String()), +}); + +export const ReconciliationAttestationResultSchema = Type.Object({ + accepted: Type.Boolean(), + memory_pr_id: Type.Optional(Type.String()), + job_id: Type.Optional(Type.String()), + state: Type.Optional(ReconciliationJobStateSchema), +}); + export const routeSchemas = { authDeviceStart: route(Type.Object({}), Type.Object({ device_code: Type.String(), @@ -464,7 +697,10 @@ export const routeSchemas = { repoLinkGithub: route(Type.Object({ installation_id: Type.String(), github_repository_id: Type.String() }), ManagedRepositorySchema), repoArchive: route(Type.Object({}), ManagedRepositorySchema), repoRestore: route(Type.Object({}), ManagedRepositorySchema), - repoInvite: route(Type.Object({ github_user: Type.String({ minLength: 1 }) }), InvitationSchema), + repoInvite: route(Type.Object({ + github_user: Type.String({ minLength: 1 }), + role: Type.Optional(Type.Union([Type.Literal("reader"), Type.Literal("contributor")])), + }), InvitationSchema), repoInviteLinkCreate: route(Type.Object({}), RepoInviteLinkCreatedSchema), repoInviteLinkList: route(Type.Object({}), Type.Array(RepoInviteLinkSchema)), repoInviteLinkRevoke: route(Type.Object({}), RepoInviteLinkSchema), @@ -474,20 +710,41 @@ export const routeSchemas = { accessRequestCreate: route(Type.Object({}), AccessRequestSchema), accessRequestList: route(Type.Object({}), Type.Array(AccessRequestSchema)), accessRequestDecision: route(Type.Object({ decision: Type.Union([Type.Literal("approve"), Type.Literal("deny")]) }), AccessRequestSchema), - graphRead: route(Type.Object({}), GraphReadSchema), - graphContext: route(Type.Object({ query: Type.String({ minLength: 1 }) }), GraphContextSchema), - graphViewData: route(Type.Object({}), GraphViewDataSchema), + graphRead: route(ManagedGraphViewQuerySchema, GraphReadSchema), + graphContext: route(Type.Object({ + query: Type.String({ minLength: 1 }), + view: Type.Optional(ManagedGraphViewSchema), + }), GraphContextSchema), + graphViewData: route(ManagedGraphViewQuerySchema, GraphViewDataSchema), graphAnchorData: route(Type.Object({}), Type.Object({ claims: Type.Array(ClaimSchema), fingerprints: Type.Record(Type.String(), Type.Record(Type.String(), Type.String())), })), - proposalReview: route(Type.Object({ proposal: MemoryProposalSchema, anchor_audit: ProposalAnchorAuditSchema }), ProposalReviewSchema), + proposalReview: route(Type.Object({ + proposal: MemoryProposalSchema, + anchor_audit: ProposalAnchorAuditSchema, + context: Type.Optional(ProposalContextSchema), + }), ProposalReviewSchema), proposalApply: route(Type.Object({ proposal: MemoryProposalSchema, working_head: Type.String(), + working_revision: Type.Optional(Type.Integer({ minimum: 0 })), + main_head: Type.Optional(Type.String()), anchor_audit: ProposalAnchorAuditSchema, commit: Type.Optional(MemoryCommitMetadataSchema), + context: Type.Optional(ProposalContextSchema), }), ApplyProposalResultSchema), + proposalList: route(Type.Object({}), Type.Array(ProposalRecordSchema)), + proposalShow: route(Type.Object({}), ProposalRecordSchema), + memoryPrList: route(Type.Object({}), Type.Array(MemoryPrSchema)), + memoryPrShow: route(Type.Object({}), MemoryPrSchema), + memoryPrRetry: route(Type.Object({}), MemoryPrSchema), + memoryStatus: route(Type.Object({}), MemoryStatusSchema), + reconciliationCandidate: route( + Type.Object({ merge_sha: Type.String({ minLength: 7 }) }), + ReconciliationCandidateSchema, + ), + reconciliationAttest: route(ReconciliationAttestationSchema, ReconciliationAttestationResultSchema), repoImport: route(Type.Object({ graph: GraphReadSchema, anchor_audit: ProposalAnchorAuditSchema, @@ -508,6 +765,16 @@ export type ManagedGraphRead = Static; export type ManagedGraphContext = Static; export type ManagedGraphViewData = Static; export type ManagedProposalReview = Static; +export type ManagedGraphView = Static; +export type ManagedObjectProvenance = Static; +export type ManagedMemoryCommit = Static; +export type ManagedProposal = Static; +export type ManagedMemoryPr = Static; +export type ManagedMemoryStatus = Static; +export type ManagedPromotionCleanup = Static; +export type ManagedReconciliationAttestation = Static; +export type ManagedReconciliationCandidate = Static; +export type ManagedReconciliationAttestationResult = Static; function route(request: TRequest, response: TResponse) { return { request, response, error: ManagedErrorSchema } as const; diff --git a/libs/storage/sqlite/migrate.ts b/libs/storage/sqlite/migrate.ts index 94b5449..213971b 100644 --- a/libs/storage/sqlite/migrate.ts +++ b/libs/storage/sqlite/migrate.ts @@ -6,12 +6,67 @@ export function migrate(db: Database.Database): void { db.exec(schemaSql); migrateReposTable(db); migrateRepoInstallationState(db); + migrateManagedRepoRoleConstraint(db); migrateClaimsTable(db); migrateGraphObjectTables(db); migrateSourceMemberships(db); migrateClaimAnchorFingerprints(db); } +function migrateManagedRepoRoleConstraint(db: Database.Database): void { + const table = db.prepare( + "SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'repos'", + ).get() as { sql: string | null } | undefined; + if (table?.sql?.includes("'contributor'")) return; + + const foreignKeys = db.pragma("foreign_keys", { simple: true }) as number; + db.pragma("foreign_keys = OFF"); + try { + db.exec(` + BEGIN; + CREATE TABLE repos_with_contributor ( + id TEXT PRIMARY KEY, + repo_key TEXT UNIQUE, + remote_url TEXT UNIQUE, + root_path TEXT UNIQUE, + repo_name TEXT NOT NULL, + default_branch TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'inactive' CHECK(status IN ('active', 'inactive')), + active_mode TEXT NOT NULL DEFAULT 'local' CHECK(active_mode IN ('local', 'managed')), + managed_repo_id TEXT, + managed_role TEXT CHECK(managed_role IN ('reader', 'contributor', 'memory_admin')), + managed_access_status TEXT CHECK(managed_access_status IN ('active', 'pending', 'suspended', 'revoked')), + managed_access_refreshed_at TEXT, + hooks_enabled INTEGER NOT NULL DEFAULT 1 CHECK(hooks_enabled IN (0, 1)), + auto_memory_updates INTEGER NOT NULL DEFAULT 1 CHECK(auto_memory_updates IN (0, 1)), + created_at TEXT NOT NULL DEFAULT '1970-01-01T00:00:00.000Z', + updated_at TEXT NOT NULL DEFAULT '1970-01-01T00:00:00.000Z' + ); + INSERT INTO repos_with_contributor ( + id, repo_key, remote_url, root_path, repo_name, default_branch, status, active_mode, + managed_repo_id, managed_role, managed_access_status, managed_access_refreshed_at, + hooks_enabled, auto_memory_updates, created_at, updated_at + ) + SELECT + id, repo_key, remote_url, root_path, repo_name, default_branch, status, active_mode, + managed_repo_id, managed_role, managed_access_status, managed_access_refreshed_at, + hooks_enabled, auto_memory_updates, created_at, updated_at + FROM repos; + DROP TABLE repos; + ALTER TABLE repos_with_contributor RENAME TO repos; + CREATE UNIQUE INDEX IF NOT EXISTS repos_repo_key_idx ON repos(repo_key) WHERE repo_key IS NOT NULL; + CREATE INDEX IF NOT EXISTS repos_managed_repo_idx ON repos(managed_repo_id); + CREATE INDEX IF NOT EXISTS repos_status_idx ON repos(status, active_mode); + COMMIT; + `); + } catch (error) { + db.exec("ROLLBACK;"); + throw error; + } finally { + db.pragma(`foreign_keys = ${foreignKeys ? "ON" : "OFF"}`); + } +} + function migrateRepoInstallationState(db: Database.Database): void { const columns = new Set( (db.prepare("PRAGMA table_info(repos)").all() as Array<{ name: string }>).map((column) => column.name), diff --git a/libs/storage/sqlite/repository.ts b/libs/storage/sqlite/repository.ts index a9eaab8..1b65410 100644 --- a/libs/storage/sqlite/repository.ts +++ b/libs/storage/sqlite/repository.ts @@ -12,7 +12,7 @@ import type { ClaimProvenanceRecord, GraphReadRepository } from "../../knowledge export type RepoStatus = "active" | "inactive"; export type RepoMode = "local" | "managed"; -export type ManagedRepoRole = "reader" | "memory_admin"; +export type ManagedRepoRole = "reader" | "contributor" | "memory_admin"; export type ManagedAccessStatus = "active" | "pending" | "suspended" | "revoked"; export interface RepoRecord { diff --git a/libs/storage/sqlite/schema.ts b/libs/storage/sqlite/schema.ts index e66e03d..ad6b2a6 100644 --- a/libs/storage/sqlite/schema.ts +++ b/libs/storage/sqlite/schema.ts @@ -9,7 +9,7 @@ CREATE TABLE IF NOT EXISTS repos ( status TEXT NOT NULL DEFAULT 'inactive' CHECK(status IN ('active', 'inactive')), active_mode TEXT NOT NULL DEFAULT 'local' CHECK(active_mode IN ('local', 'managed')), managed_repo_id TEXT, - managed_role TEXT CHECK(managed_role IN ('reader', 'memory_admin')), + managed_role TEXT CHECK(managed_role IN ('reader', 'contributor', 'memory_admin')), managed_access_status TEXT CHECK(managed_access_status IN ('active', 'pending', 'suspended', 'revoked')), managed_access_refreshed_at TEXT, hooks_enabled INTEGER NOT NULL DEFAULT 1 CHECK(hooks_enabled IN (0, 1)), diff --git a/package.json b/package.json index 6953acf..ea1b50b 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,8 @@ "smoke:copilot": "npm run build && node scripts/smoke-copilot-install.mjs", "smoke:opencode": "npm run build && node scripts/smoke-opencode-install.mjs", "smoke:cursor": "npm run build && node scripts/smoke-cursor-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-repo-installations.js && node scripts/check-managed-cli.js && node scripts/check-graph-view.js && node scripts/check-graph-view-offline-browser.js && node scripts/check-source-memberships.js && node scripts/check-proposal-validate.js && node scripts/check-bm25-tokenizer.js && node scripts/check-anchor-drift.js && node scripts/check-find-similar-claims.js && node scripts/check-apply-proposal-dedupe.js && node scripts/check-opencode-sqlite-transcript.js && node scripts/check-agent-runner-spawn-error.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-repo-installations.js && node scripts/check-managed-cli.js && node scripts/check-managed-collaboration.js && node scripts/check-graph-view.js && node scripts/check-graph-view-offline-browser.js && node scripts/check-source-memberships.js && node scripts/check-proposal-validate.js && node scripts/check-bm25-tokenizer.js && node scripts/check-anchor-drift.js && node scripts/check-find-similar-claims.js && node scripts/check-apply-proposal-dedupe.js && node scripts/check-opencode-sqlite-transcript.js && node scripts/check-agent-runner-spawn-error.js", + "test:managed-collaboration": "npm run build && node scripts/check-managed-collaboration.js", "test:repo-installations": "npm run build && node scripts/check-repo-installations.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", diff --git a/scripts/check-managed-collaboration.js b/scripts/check-managed-collaboration.js new file mode 100644 index 0000000..101cd82 --- /dev/null +++ b/scripts/check-managed-collaboration.js @@ -0,0 +1,343 @@ +import assert from "node:assert/strict"; +import { execFileSync, spawn } from "node:child_process"; +import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { createServer } from "node:http"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import Database from "better-sqlite3"; + +const root = new URL("..", import.meta.url); +const cliPath = fileURLToPath(new URL("dist/apps/cli/main.js", root)); +const temporary = mkdtempSync(join(tmpdir(), "greplica-managed-collaboration-")); +process.env.GREPLICA_HOME = join(temporary, "greplica-home"); + +const { ManagedGraphMemoryClient } = await import("../dist/libs/knowledge-graph/managed-client.js"); +const { canScheduleMemoryUpdates } = await import("../dist/libs/install/repo-installation-store.js"); +const { buildGraphViewHtmlFromData } = await import("../dist/libs/knowledge-graph/graph-view/build-graph-view.js"); +const { migrate } = await import("../dist/libs/storage/sqlite/migrate.js"); + +const action = readFileSync(fileURLToPath(new URL("../action.yml", import.meta.url)), "utf8"); +assert.match(action, /npm ci --prefix "\$GITHUB_ACTION_PATH" --include=dev/); +assert.match(action, /node "\$GITHUB_ACTION_PATH\/dist\/apps\/cli\/main\.js" memory reconcile/); +assert.doesNotMatch(action, /greplica@latest/); + +const repoRoot = join(temporary, "repo"); +exec("git", ["init", "--quiet", repoRoot]); +exec("git", ["-C", repoRoot, "config", "user.email", "test@example.com"]); +exec("git", ["-C", repoRoot, "config", "user.name", "Test"]); +writeFileSync(join(repoRoot, "example.ts"), "export const example = true;\n"); +exec("git", ["-C", repoRoot, "add", "example.ts"]); +exec("git", ["-C", repoRoot, "commit", "--quiet", "-m", "example"]); +const mergeSha = exec("git", ["-C", repoRoot, "rev-parse", "HEAD"]).trim(); + +const calls = []; +let applyBody; +const graph = { components: [], flows: [], claims: [], sources: [], edges: [] }; +const viewData = { + generatedAt: "2026-07-28T00:00:00.000Z", + counts: { components: 0, flows: 0, claims: 0, superseded: 0 }, + components: [], + flows: [], + claims: [], + supersededClaims: [], + claimsTimeline: { summary: { total: 0, sessionPct: 0, codePct: 0 }, events: [] }, +}; +const fetchImpl = async (input, init) => { + const url = String(input); + const body = init?.body === undefined ? undefined : JSON.parse(String(init.body)); + calls.push({ url, method: init?.method, body }); + if (url.endsWith("/proposals/review")) { + return jsonResponse({ + valid: true, + errors: [], + duplicate_warnings: {}, + working_head: "working-1", + working_revision: 3, + main_head: "main-1", + }); + } + if (url.endsWith("/proposals/apply")) { + applyBody = body; + return jsonResponse({ + memory_commit_id: "commit-1", + scope_id: "working-user-1", + embedding_status: { checked_objects: 0, created: 0, reused: 0 }, + created: { components: 0, flows: 0, claims: 0, sources: 1, edges: 0 }, + }); + } + if (url.includes("/graph/view-data")) return jsonResponse(viewData); + if (url.endsWith("/graph/context")) { + return jsonResponse({ + query: body.query, + search_config_version: "test", + embedding_status: { checked_objects: 0, created: 0, reused: 0 }, + claims: [], + components: [], + flows: [], + ranked_results: [], + sources: [], + }); + } + if (url.includes("/graph")) return jsonResponse(graph); + if (url.endsWith("/proposals")) return jsonResponse([]); + if (url.includes("/proposals/")) return jsonResponse({ id: "proposal-1" }); + if (url.endsWith("/memory-prs")) return jsonResponse([]); + if (url.includes("/memory-prs/")) return jsonResponse({ id: "memory-pr-1" }); + if (url.endsWith("/memory/status")) return jsonResponse({ + queued: 0, + running: 0, + failed: 0, + repair_attempts: 0, + repaired_commits: 0, + promoted_commits: 0, + quarantined_commits: 0, + cleared_working_commits: 0, + remaining_active_working_commits: 0, + }); + throw new Error(`Unexpected client URL ${url}`); +}; + +const installation = { + id: "local-repo-1", + repoKey: "github:example/project", + remoteUrl: "https://github.com/example/project.git", + rootPath: repoRoot, + repoName: "project", + defaultBranch: "main", + status: "active", + activeMode: "managed", + managedRepoId: "11111111-1111-4111-8111-111111111111", + managedRole: "contributor", + managedAccessStatus: "active", + hooksEnabled: true, + autoMemoryUpdates: true, + createdAt: "2026-07-28T00:00:00.000Z", + updatedAt: "2026-07-28T00:00:00.000Z", +}; +const client = new ManagedGraphMemoryClient(installation, { + repo_root: repoRoot, + remote_url: installation.remoteUrl, + repo_name: "project", + default_branch: "main", +}, { + apiUrl: "https://memory.example.test", + token: "managed-token", + credentials: { + version: 2, + apiUrl: "https://memory.example.test", + token: "managed-token", + user: { id: "user-1", githubLogin: "me", githubUserId: "1" }, + }, + fetchImpl, +}); + +await client.readGraph({ base: "main", working_users: [] }); +let request = new URL(calls.at(-1).url); +assert.equal(request.searchParams.get("main_only"), "true"); +await client.contextGraph("auth", { base: "main", working_users: ["alice", "alice"] }); +assert.deepEqual(calls.at(-1).body.view.working_users, ["me", "alice"]); +await client.viewData({ base: "main", memory_pr_id: "memory-pr-1" }); +request = new URL(calls.at(-1).url); +assert.equal(request.searchParams.get("memory_pr_id"), "memory-pr-1"); + +await client.applyProposal({ + title: "Session memory", + creates: { sources: [{ id: "source-1", kind: "session", ref: "codex-session:session-1" }] }, +}); +assert.equal(applyBody.working_revision, 3); +assert.equal(applyBody.main_head, "main-1"); +assert.equal(applyBody.context.git_head, mergeSha); +assert.equal(applyBody.context.head_repository, "example/project"); +assert.deepEqual(applyBody.context.session_refs, [{ id: "codex-session:session-1", agent_platform: "codex" }]); +assert.equal("author" in applyBody, false); +assert.equal("username" in applyBody, false); + +await client.listProposals(); +await client.showProposal("proposal/1"); +await client.listMemoryPrs(); +await client.showMemoryPr("memory/pr"); +await client.retryMemoryPr("memory/pr"); +await client.memoryStatus(); +assert.ok(calls.some((call) => call.url.endsWith("/proposals/proposal%2F1"))); +assert.ok(calls.some((call) => call.url.endsWith("/memory-prs/memory%2Fpr/retry"))); + +assert.equal(canScheduleMemoryUpdates(installation), true); +assert.equal(canScheduleMemoryUpdates({ ...installation, managedRole: "reader" }), false); + +const legacyDb = new Database(":memory:"); +legacyDb.exec(` + CREATE TABLE repos ( + id TEXT PRIMARY KEY, + repo_key TEXT UNIQUE, + remote_url TEXT UNIQUE, + root_path TEXT UNIQUE, + repo_name TEXT NOT NULL, + default_branch TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'inactive' CHECK(status IN ('active', 'inactive')), + active_mode TEXT NOT NULL DEFAULT 'local' CHECK(active_mode IN ('local', 'managed')), + managed_repo_id TEXT, + managed_role TEXT CHECK(managed_role IN ('reader', 'memory_admin')), + managed_access_status TEXT CHECK(managed_access_status IN ('active', 'pending', 'suspended', 'revoked')), + managed_access_refreshed_at TEXT, + hooks_enabled INTEGER NOT NULL DEFAULT 1 CHECK(hooks_enabled IN (0, 1)), + auto_memory_updates INTEGER NOT NULL DEFAULT 1 CHECK(auto_memory_updates IN (0, 1)), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); +`); +migrate(legacyDb); +legacyDb.prepare( + `INSERT INTO repos ( + id, repo_key, repo_name, default_branch, managed_role, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?)`, +).run("repo-1", "repo-key", "repo", "main", "contributor", "2026-07-28T00:00:00.000Z", "2026-07-28T00:00:00.000Z"); +assert.equal(legacyDb.prepare("SELECT managed_role FROM repos").get().managed_role, "contributor"); +legacyDb.close(); + +const html = buildGraphViewHtmlFromData({ + ...viewData, + counts: { ...viewData.counts, claims: 1 }, + claims: [{ + id: "claim.logical", + text: "A personal draft", + kind: "fact", + session: "codex-session:session-1", + source: "session", + freshness: "active", + componentIds: [], + flowIds: [], + createdAt: "2026-07-28T00:00:00.000Z", + memoryCommitId: "commit-1", + provenance: { + version_id: "version-1", + scope_kind: "working", + author_github_login: "alice", + memory_commit_state: "active", + memory_pr_id: "memory-pr-1", + commit_role: "repair", + }, + }], + claimsTimeline: { + summary: { total: 1, sessionPct: 100, codePct: 0 }, + events: [], + }, +}); +assert.match(html, /data-version-id="version-1"/); +assert.match(html, /data-author="alice"/); +assert.match(html, /provenance-badge[^>]*>repair { + const url = new URL(incoming.url, "http://127.0.0.1"); + const chunks = []; + for await (const chunk of incoming) chunks.push(chunk); + const body = chunks.length === 0 ? undefined : JSON.parse(Buffer.concat(chunks).toString("utf8")); + const send = (status, value) => { + response.writeHead(status, { "content-type": "application/json" }); + response.end(JSON.stringify(value)); + }; + if (url.pathname === "/oidc") { + assert.equal(incoming.headers.authorization, "Bearer oidc-request-token"); + assert.equal(url.searchParams.get("audience"), "greplica-managed"); + send(200, { value: "github-oidc-token" }); + return; + } + assert.equal(incoming.headers.authorization, "Bearer github-oidc-token"); + if (url.pathname.endsWith("/memory/reconcile/candidate")) { + assert.equal(url.searchParams.get("merge_sha"), mergeSha); + send(200, { + memory_pr_id: "memory-pr-1", + merge_sha: mergeSha, + memory_commit_ids: ["commit-1"], + commits: [{ memory_commit_id: "commit-1", git_head: mergeSha, head_repository: "example/project" }], + claim_versions: [{ + version_id: "version-1", + claim: { + id: "claim.logical", + kind: "fact", + text: "Version-keyed audit", + truth: "code_verified", + intent: "intended", + }, + }], + }); + return; + } + if (url.pathname.endsWith("/memory/reconcile/attest")) { + attestation = body; + send(200, { accepted: true, memory_pr_id: "memory-pr-1", job_id: "job-1", state: "queued" }); + return; + } + send(404, { message: `Unexpected ${incoming.method} ${url.pathname}` }); +}); +await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); +}); +try { + const address = server.address(); + assert.ok(address && typeof address === "object"); + const apiUrl = `http://127.0.0.1:${address.port}`; + const result = await run(process.execPath, [ + cliPath, + "memory", + "reconcile", + "--managed-repo", + installation.managedRepoId, + "--merge-sha", + mergeSha, + "--api-url", + apiUrl, + ], repoRoot, { + ...process.env, + ACTIONS_ID_TOKEN_REQUEST_URL: `${apiUrl}/oidc?api-version=1`, + ACTIONS_ID_TOKEN_REQUEST_TOKEN: "oidc-request-token", + GITHUB_REPOSITORY: "example/project", + GITHUB_REF: "refs/heads/main", + GITHUB_RUN_ID: "123", + }); + assert.match(result.stdout, /"accepted": true/); + assert.equal(attestation.audit_key, "version_id"); + assert.deepEqual(attestation.memory_commit_ids, ["commit-1"]); + assert.deepEqual(attestation.ancestry, [{ memory_commit_id: "commit-1", git_head: mergeSha, is_ancestor: true }]); + assert.equal(attestation.anchor_audit.result.missing_anchors[0].claim_id, "version-1"); + assert.equal(attestation.repository, "example/project"); +} finally { + await new Promise((resolve) => server.close(resolve)); +} + +console.log("Managed collaboration checks passed."); + +function exec(command, args) { + return execFileSync(command, args, { encoding: "utf8" }); +} + +function jsonResponse(value) { + return new Response(JSON.stringify(value), { + status: 200, + headers: { "content-type": "application/json" }, + }); +} + +function run(command, args, cwd, env) { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { cwd, env, stdio: ["ignore", "pipe", "pipe"] }); + let stdout = ""; + let stderr = ""; + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + child.stdout.on("data", (chunk) => { stdout += chunk; }); + child.stderr.on("data", (chunk) => { stderr += chunk; }); + child.once("error", reject); + child.once("close", (code) => { + if (code === 0) resolve({ stdout, stderr }); + else reject(new Error(`${command} ${args.join(" ")} failed (${code})\n${stderr}`)); + }); + }); +} From 5022e5af3fddf26ea23e7e424b3bcff4efe06af6 Mon Sep 17 00:00:00 2001 From: Kushal Date: Tue, 28 Jul 2026 19:01:06 -0700 Subject: [PATCH 02/27] fix: reconcile every memory candidate safely --- apps/cli/reconcile-cli.ts | 164 ++++++++++++++++--------- libs/managed/protocol.ts | 9 +- scripts/check-managed-collaboration.js | 9 ++ 3 files changed, 124 insertions(+), 58 deletions(-) diff --git a/apps/cli/reconcile-cli.ts b/apps/cli/reconcile-cli.ts index bb29d6a..d4ed69a 100644 --- a/apps/cli/reconcile-cli.ts +++ b/apps/cli/reconcile-cli.ts @@ -29,33 +29,6 @@ export async function runMemoryReconcile(args: string[]): Promise { authorization: `Bearer ${oidcToken}`, accept: "application/json", }; - const candidate = await jsonRequest( - `${apiUrl}/v1/repos/${encodeURIComponent(managedRepoId)}/memory/reconcile/candidate?merge_sha=${encodeURIComponent(mergeSha)}`, - { method: "GET", headers }, - ); - if (candidate.merge_sha !== mergeSha) { - throw new Error(`Managed reconciliation candidate is bound to ${candidate.merge_sha}, not ${mergeSha}.`); - } - if (candidate.memory_commit_ids.length === 0) throw new Error("Managed reconciliation candidate has no memory commits."); - const candidateIds = [...candidate.memory_commit_ids].sort(); - const commitIds = candidate.commits.map((commit) => commit.memory_commit_id).sort(); - if (JSON.stringify(candidateIds) !== JSON.stringify(commitIds)) { - throw new Error("Managed reconciliation candidate commit metadata does not match its selected commit IDs."); - } - const ancestry = candidate.commits.map((commit) => ({ - memory_commit_id: commit.memory_commit_id, - git_head: commit.git_head, - is_ancestor: isAncestor(repoRoot, commit.git_head, mergeSha), - })); - - const auditClaims = candidate.claim_versions.map(({ version_id, claim }) => ({ ...claim, id: version_id })); - const result = await auditClaimCodeAnchors(repoRoot, auditClaims); - const fingerprints: Record> = {}; - for (const claim of auditClaims) { - if (claim.code_anchors === undefined || claim.code_anchors.length === 0) continue; - const values = await fingerprintClaimAnchors(repoRoot, claim.code_anchors); - if (Object.keys(values).length > 0) fingerprints[claim.id] = values; - } const repairProposalPath = optionalOption(args, "--repair-proposal"); const repairProposalValue = repairProposalPath === undefined ? undefined @@ -64,38 +37,110 @@ export async function runMemoryReconcile(args: string[]): Promise { throw new Error("--repair-proposal must contain a JSON object."); } const repairProposal = repairProposalValue; - const attestation: ManagedReconciliationAttestation = { - managed_repo_id: managedRepoId, - repository, - merge_sha: mergeSha, - memory_pr_id: candidate.memory_pr_id, - memory_commit_ids: candidate.memory_commit_ids, - ancestry, - audit_key: "version_id", - anchor_audit: { result, fingerprints }, - ...(repairProposal === undefined ? {} : { repair_proposal: repairProposal }), - ref: process.env.GITHUB_REF, - run_id: process.env.GITHUB_RUN_ID, - run_attempt: process.env.GITHUB_RUN_ATTEMPT, - workflow_ref: process.env.GITHUB_WORKFLOW_REF, - }; - const response = await jsonRequest( - `${apiUrl}/v1/repos/${encodeURIComponent(managedRepoId)}/memory/reconcile/attest`, - { - method: "POST", - headers: { ...headers, "content-type": "application/json" }, - body: JSON.stringify(attestation), - }, - ); + + const reconciliations: Array<{ + response: ManagedReconciliationAttestationResult; + memory_pr_id: string; + audited_claim_versions: number; + memory_commit_ids: string[]; + }> = []; + const excludedMemoryPrIds: string[] = []; + while (true) { + const candidate = await reconciliationCandidate( + apiUrl, + managedRepoId, + mergeSha, + excludedMemoryPrIds, + headers, + ); + if (candidate === undefined) break; + if (excludedMemoryPrIds.includes(candidate.memory_pr_id)) { + throw new Error(`Managed reconciliation returned duplicate Memory PR ${candidate.memory_pr_id}.`); + } + verifyCandidate(candidate, mergeSha); + const ancestry = candidate.commits.map((commit) => ({ + memory_commit_id: commit.memory_commit_id, + git_head: commit.git_head, + is_ancestor: isAncestor(repoRoot, commit.git_head, mergeSha), + })); + const auditClaims = candidate.claim_versions.map(({ version_id, claim }) => ({ ...claim, id: version_id })); + const result = await auditClaimCodeAnchors(repoRoot, auditClaims); + const fingerprints: Record> = {}; + for (const claim of auditClaims) { + if (claim.code_anchors === undefined || claim.code_anchors.length === 0) continue; + const values = await fingerprintClaimAnchors(repoRoot, claim.code_anchors); + if (Object.keys(values).length > 0) fingerprints[claim.id] = values; + } + const attestation: ManagedReconciliationAttestation = { + managed_repo_id: managedRepoId, + repository, + merge_sha: mergeSha, + memory_pr_id: candidate.memory_pr_id, + memory_commit_ids: candidate.memory_commit_ids, + ancestry, + audit_key: "version_id", + anchor_audit: { result, fingerprints }, + ...(repairProposal === undefined ? {} : { repair_proposal: repairProposal }), + ref: process.env.GITHUB_REF, + run_id: process.env.GITHUB_RUN_ID, + run_attempt: process.env.GITHUB_RUN_ATTEMPT, + }; + const response = await jsonRequest( + `${apiUrl}/v1/repos/${encodeURIComponent(managedRepoId)}/memory/reconcile/attest`, + { + method: "POST", + headers: { ...headers, "content-type": "application/json" }, + body: JSON.stringify(attestation), + }, + ); + reconciliations.push({ + response, + memory_pr_id: response.memory_pr_id ?? candidate.memory_pr_id, + audited_claim_versions: candidate.claim_versions.length, + memory_commit_ids: candidate.memory_commit_ids, + }); + excludedMemoryPrIds.push(candidate.memory_pr_id); + } console.log(JSON.stringify({ - ...response, - memory_pr_id: response.memory_pr_id ?? candidate.memory_pr_id, + accepted: reconciliations.every(({ response }) => response.accepted), merge_sha: mergeSha, - audited_claim_versions: candidate.claim_versions.length, - memory_commit_ids: candidate.memory_commit_ids, + reconciliation_count: reconciliations.length, + reconciliations, }, null, 2)); } +async function reconciliationCandidate( + apiUrl: string, + managedRepoId: string, + mergeSha: string, + excludedMemoryPrIds: string[], + headers: Record, +): Promise { + const query = new URLSearchParams({ merge_sha: mergeSha }); + for (const memoryPrId of excludedMemoryPrIds) query.append("exclude_memory_pr", memoryPrId); + try { + return await jsonRequest( + `${apiUrl}/v1/repos/${encodeURIComponent(managedRepoId)}/memory/reconcile/candidate?${query.toString()}`, + { method: "GET", headers }, + ); + } catch (error) { + if (error instanceof ManagedReconciliationHttpError && error.status === 404) return undefined; + throw error; + } +} + +function verifyCandidate(candidate: ManagedReconciliationCandidate, mergeSha: string): void { + if (candidate.merge_sha !== mergeSha) { + throw new Error(`Managed reconciliation candidate is bound to ${candidate.merge_sha}, not ${mergeSha}.`); + } + if (candidate.memory_commit_ids.length === 0) throw new Error("Managed reconciliation candidate has no memory commits."); + const candidateIds = [...candidate.memory_commit_ids].sort(); + const commitIds = candidate.commits.map((commit) => commit.memory_commit_id).sort(); + if (JSON.stringify(candidateIds) !== JSON.stringify(commitIds)) { + throw new Error("Managed reconciliation candidate commit metadata does not match its selected commit IDs."); + } +} + function isAncestor(repoRoot: string, gitHead: string, mergeSha: string): boolean { try { execFileSync("git", ["-C", repoRoot, "merge-base", "--is-ancestor", gitHead, mergeSha], { @@ -114,7 +159,7 @@ function assertExactCheckout(repoRoot: string, mergeSha: string): void { }).trim(); const head = git(["rev-parse", "HEAD"]); if (head !== mergeSha) throw new Error(`Checked-out HEAD ${head} does not equal requested merge SHA ${mergeSha}.`); - const status = git(["status", "--porcelain", "--untracked-files=no"]); + const status = git(["status", "--porcelain", "--untracked-files=all"]); if (status.length > 0) throw new Error("Reconciliation requires a clean exact-SHA checkout."); } @@ -151,11 +196,18 @@ async function jsonRequest(url: string, init: RequestInit): Promise { const message = isRecord(payload) && typeof payload.message === "string" ? payload.message : `Managed Greplica reconciliation failed (${response.status}).`; - throw new Error(message); + throw new ManagedReconciliationHttpError(response.status, message); } return payload as T; } +class ManagedReconciliationHttpError extends Error { + constructor(readonly status: number, message: string) { + super(message); + this.name = "ManagedReconciliationHttpError"; + } +} + function requiredOption(args: string[], name: string): string { const value = optionalOption(args, name); if (value === undefined) throw new Error(`Missing ${name}.`); diff --git a/libs/managed/protocol.ts b/libs/managed/protocol.ts index 82a4157..801fa17 100644 --- a/libs/managed/protocol.ts +++ b/libs/managed/protocol.ts @@ -643,7 +643,6 @@ export const ReconciliationAttestationSchema = Type.Object({ ref: Type.Optional(Type.String()), run_id: Type.Optional(Type.String()), run_attempt: Type.Optional(Type.String()), - workflow_ref: Type.Optional(Type.String()), }); export const ReconciliationAttestationResultSchema = Type.Object({ @@ -741,7 +740,13 @@ export const routeSchemas = { memoryPrRetry: route(Type.Object({}), MemoryPrSchema), memoryStatus: route(Type.Object({}), MemoryStatusSchema), reconciliationCandidate: route( - Type.Object({ merge_sha: Type.String({ minLength: 7 }) }), + Type.Object({ + merge_sha: Type.String({ minLength: 7 }), + exclude_memory_pr: Type.Optional(Type.Union([ + Type.String({ minLength: 1 }), + Type.Array(Type.String({ minLength: 1 })), + ])), + }), ReconciliationCandidateSchema, ), reconciliationAttest: route(ReconciliationAttestationSchema, ReconciliationAttestationResultSchema), diff --git a/scripts/check-managed-collaboration.js b/scripts/check-managed-collaboration.js index 101cd82..72b9d65 100644 --- a/scripts/check-managed-collaboration.js +++ b/scripts/check-managed-collaboration.js @@ -233,6 +233,7 @@ assert.match(html, /id="claims-filter-memory-pr"/); assert.match(html, /id="claims-filter-commit-role"/); let attestation; +let candidateCalls = 0; const server = createServer(async (incoming, response) => { const url = new URL(incoming.url, "http://127.0.0.1"); const chunks = []; @@ -250,7 +251,13 @@ const server = createServer(async (incoming, response) => { } assert.equal(incoming.headers.authorization, "Bearer github-oidc-token"); if (url.pathname.endsWith("/memory/reconcile/candidate")) { + candidateCalls += 1; assert.equal(url.searchParams.get("merge_sha"), mergeSha); + if (url.searchParams.has("exclude_memory_pr")) { + assert.deepEqual(url.searchParams.getAll("exclude_memory_pr"), ["memory-pr-1"]); + send(404, { message: "No Memory PR is ready for this merged checkout." }); + return; + } send(200, { memory_pr_id: "memory-pr-1", merge_sha: mergeSha, @@ -303,6 +310,8 @@ try { GITHUB_RUN_ID: "123", }); assert.match(result.stdout, /"accepted": true/); + assert.match(result.stdout, /"reconciliation_count": 1/); + assert.equal(candidateCalls, 2); assert.equal(attestation.audit_key, "version_id"); assert.deepEqual(attestation.memory_commit_ids, ["commit-1"]); assert.deepEqual(attestation.ancestry, [{ memory_commit_id: "commit-1", git_head: mergeSha, is_ancestor: true }]); From 38e477bbc10ac01ff01d497e2011cfecb5e33897 Mon Sep 17 00:00:00 2001 From: Kushal Date: Tue, 28 Jul 2026 19:02:32 -0700 Subject: [PATCH 03/27] fix: skip non-ancestor memory candidates --- apps/cli/reconcile-cli.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/apps/cli/reconcile-cli.ts b/apps/cli/reconcile-cli.ts index d4ed69a..9570d56 100644 --- a/apps/cli/reconcile-cli.ts +++ b/apps/cli/reconcile-cli.ts @@ -44,6 +44,11 @@ export async function runMemoryReconcile(args: string[]): Promise { audited_claim_versions: number; memory_commit_ids: string[]; }> = []; + const skipped: Array<{ + memory_pr_id: string; + reason: "git_head_not_ancestor"; + memory_commit_ids: string[]; + }> = []; const excludedMemoryPrIds: string[] = []; while (true) { const candidate = await reconciliationCandidate( @@ -63,6 +68,16 @@ export async function runMemoryReconcile(args: string[]): Promise { git_head: commit.git_head, is_ancestor: isAncestor(repoRoot, commit.git_head, mergeSha), })); + const nonAncestors = ancestry.filter((entry) => !entry.is_ancestor); + if (nonAncestors.length > 0) { + skipped.push({ + memory_pr_id: candidate.memory_pr_id, + reason: "git_head_not_ancestor", + memory_commit_ids: nonAncestors.map((entry) => entry.memory_commit_id), + }); + excludedMemoryPrIds.push(candidate.memory_pr_id); + continue; + } const auditClaims = candidate.claim_versions.map(({ version_id, claim }) => ({ ...claim, id: version_id })); const result = await auditClaimCodeAnchors(repoRoot, auditClaims); const fingerprints: Record> = {}; @@ -105,7 +120,9 @@ export async function runMemoryReconcile(args: string[]): Promise { accepted: reconciliations.every(({ response }) => response.accepted), merge_sha: mergeSha, reconciliation_count: reconciliations.length, + skipped_count: skipped.length, reconciliations, + skipped, }, null, 2)); } From b594b261d6fbeec590d2b475397aa0e426f88ee3 Mon Sep 17 00:00:00 2001 From: Kushal Date: Tue, 28 Jul 2026 19:03:09 -0700 Subject: [PATCH 04/27] feat: add trusted reusable reconciliation workflow --- .github/workflows/reconcile.yml | 50 ++++++++++++++++++++++++++ README.md | 25 ++++++++++--- scripts/check-managed-collaboration.js | 12 +++++++ 3 files changed, 82 insertions(+), 5 deletions(-) create mode 100644 .github/workflows/reconcile.yml diff --git a/.github/workflows/reconcile.yml b/.github/workflows/reconcile.yml new file mode 100644 index 0000000..a5e34cb --- /dev/null +++ b/.github/workflows/reconcile.yml @@ -0,0 +1,50 @@ +name: Reconcile Greplica memory + +on: + workflow_call: + inputs: + managed-repo: + description: Managed Greplica repository UUID. + required: true + type: string + merge-sha: + description: Exact default-branch commit to audit. + required: true + type: string + api-url: + description: Managed Greplica API URL. + required: false + default: https://memory.autoloops.ai + type: string + oidc-audience: + description: GitHub OIDC audience expected by the managed service. + required: false + default: greplica-managed + type: string + +permissions: + contents: read + id-token: write + +concurrency: + group: greplica-memory-${{ github.repository }}-${{ inputs.merge-sha }} + cancel-in-progress: false + +jobs: + reconcile: + name: Audit exact default-branch source + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: read + id-token: write + steps: + # Keep this immutable. The managed service separately allowlists this + # reusable workflow's signed job_workflow_sha. + - name: Reconcile Memory PRs + uses: Autoloops/greplica@38e477bbc10ac01ff01d497e2011cfecb5e33897 + with: + managed-repo: ${{ inputs.managed-repo }} + merge-sha: ${{ inputs.merge-sha }} + api-url: ${{ inputs.api-url }} + oidc-audience: ${{ inputs.oidc-audience }} diff --git a/README.md b/README.md index baa99c5..6b3e0a1 100644 --- a/README.md +++ b/README.md @@ -79,21 +79,36 @@ Replace `codex` with your agent platform. Login uses GitHub's browser device flo Organization admins and members inherit read access to every organization repository. Guests can read only explicitly granted repositories. A `contributor` writes proposals to their own persistent personal working scope; `memory_admin` additionally manages repository memory access. Managed graph data stays on the server, while local SQLite stores only the repository binding, role cache, hook policy, and runtime session metadata. -Managed GitHub repositories can reconcile Memory PRs against the exact merged code with the bundled Action: +Managed GitHub repositories reconcile Memory PRs against exact default-branch code through the official reusable workflow. Pin the reusable workflow to a full commit SHA; do not use a branch or movable tag: ```yaml +name: Greplica memory + +on: + push: + branches: [main] # Replace with the repository's default branch. + permissions: contents: read id-token: write -steps: - - uses: Autoloops/greplica@ +jobs: + reconcile-memory: + permissions: + contents: read + id-token: write + uses: Autoloops/greplica/.github/workflows/reconcile.yml@ with: managed-repo: - merge-sha: ${{ github.event.pull_request.merge_commit_sha }} + merge-sha: ${{ github.sha }} ``` -The Action checks out `merge-sha` with full history, installs the CLI from the same pinned Action source, verifies every candidate Git head is an ancestor, audits version-keyed anchors, and attests through GitHub OIDC. It does not use a repository or model secret. +The workflow checks out `merge-sha` with full history, installs an immutable Greplica Action revision, audits every eligible Memory PR and its version-keyed anchors, and attests through GitHub OIDC. It does not use a repository or model secret. Managed Greplica must allowlist both signed reusable-workflow claims for the pinned revision: + +- `job_workflow_ref=Autoloops/greplica/.github/workflows/reconcile.yml@` +- `job_workflow_sha=` + +Those claims prove the trusted reusable workflow ran. A token issued directly to a consumer-authored workflow or a composite Action alone is not sufficient. Local mode remains independent and does not require login or a server: diff --git a/scripts/check-managed-collaboration.js b/scripts/check-managed-collaboration.js index 72b9d65..5299cae 100644 --- a/scripts/check-managed-collaboration.js +++ b/scripts/check-managed-collaboration.js @@ -21,6 +21,18 @@ const action = readFileSync(fileURLToPath(new URL("../action.yml", import.meta.u assert.match(action, /npm ci --prefix "\$GITHUB_ACTION_PATH" --include=dev/); assert.match(action, /node "\$GITHUB_ACTION_PATH\/dist\/apps\/cli\/main\.js" memory reconcile/); assert.doesNotMatch(action, /greplica@latest/); +const reusableWorkflow = readFileSync( + fileURLToPath(new URL("../.github/workflows/reconcile.yml", import.meta.url)), + "utf8", +); +assert.match(reusableWorkflow, /workflow_call:/); +assert.match(reusableWorkflow, /contents: read/); +assert.match(reusableWorkflow, /id-token: write/); +assert.match( + reusableWorkflow, + /uses: Autoloops\/greplica@38e477bbc10ac01ff01d497e2011cfecb5e33897/, +); +assert.doesNotMatch(reusableWorkflow, /uses: Autoloops\/greplica@(main|refs\/heads\/|v\d)/); const repoRoot = join(temporary, "repo"); exec("git", ["init", "--quiet", repoRoot]); From b147dab2568227e96f2d3f14b16261e4042b44c3 Mon Sep 17 00:00:00 2001 From: Kushal Date: Tue, 28 Jul 2026 19:24:33 -0700 Subject: [PATCH 05/27] fix: harden managed collaboration clients --- .github/workflows/reconcile.yml | 16 +- README.md | 25 +- action.yml | 4 +- apps/cli/main.ts | 9 +- apps/cli/reconcile-cli.ts | 106 ++++- libs/knowledge-graph/graph-context/render.ts | 33 +- .../graph-view/build-graph-view.ts | 73 +++- libs/knowledge-graph/managed-client.ts | 64 ++- libs/knowledge-graph/service.ts | 10 + libs/managed/control-client.ts | 8 + libs/managed/protocol.ts | 24 +- scripts/check-managed-cli.js | 2 + scripts/check-managed-collaboration.js | 392 +++++++++++++++++- 13 files changed, 675 insertions(+), 91 deletions(-) diff --git a/.github/workflows/reconcile.yml b/.github/workflows/reconcile.yml index a5e34cb..19e23e9 100644 --- a/.github/workflows/reconcile.yml +++ b/.github/workflows/reconcile.yml @@ -11,16 +11,6 @@ on: description: Exact default-branch commit to audit. required: true type: string - api-url: - description: Managed Greplica API URL. - required: false - default: https://memory.autoloops.ai - type: string - oidc-audience: - description: GitHub OIDC audience expected by the managed service. - required: false - default: greplica-managed - type: string permissions: contents: read @@ -46,5 +36,7 @@ jobs: with: managed-repo: ${{ inputs.managed-repo }} merge-sha: ${{ inputs.merge-sha }} - api-url: ${{ inputs.api-url }} - oidc-audience: ${{ inputs.oidc-audience }} + # These trust endpoints are deliberately not caller-controlled: the + # Action sends its short-lived GitHub OIDC bearer to this API. + api-url: https://memory.autoloops.ai + oidc-audience: greplica-managed diff --git a/README.md b/README.md index 6b3e0a1..b8d12f4 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,14 @@ Replace `codex` with your agent platform. Login uses GitHub's browser device flo Organization admins and members inherit read access to every organization repository. Guests can read only explicitly granted repositories. A `contributor` writes proposals to their own persistent personal working scope; `memory_admin` additionally manages repository memory access. Managed graph data stays on the server, while local SQLite stores only the repository binding, role cache, hook policy, and runtime session metadata. +Managed retrieval defaults to canonical `main` plus your own persistent working memory. Add other contributors explicitly when reviewing related work: + +```bash +greplica graph context "How does authentication work?" \ + --with-working alice \ + --with-working bob +``` + Managed GitHub repositories reconcile Memory PRs against exact default-branch code through the official reusable workflow. Pin the reusable workflow to a full commit SHA; do not use a branch or movable tag: ```yaml @@ -87,6 +95,9 @@ name: Greplica memory on: push: branches: [main] # Replace with the repository's default branch. + schedule: + - cron: "17 * * * *" + workflow_dispatch: permissions: contents: read @@ -103,7 +114,7 @@ jobs: merge-sha: ${{ github.sha }} ``` -The workflow checks out `merge-sha` with full history, installs an immutable Greplica Action revision, audits every eligible Memory PR and its version-keyed anchors, and attests through GitHub OIDC. It does not use a repository or model secret. Managed Greplica must allowlist both signed reusable-workflow claims for the pinned revision: +The push trigger reconciles immediately after default-branch updates; the hourly schedule retries unmatched and stalled work without a human memory-admin step. The workflow checks out `merge-sha` with full history, installs an immutable Greplica Action revision, audits every eligible Memory PR and its version-keyed anchors (including code drift from stored baselines), and attests through GitHub OIDC. It does not use a repository or model secret. Managed Greplica must allowlist both signed reusable-workflow claims for the pinned revision: - `job_workflow_ref=Autoloops/greplica/.github/workflows/reconcile.yml@` - `job_workflow_sha=` @@ -196,19 +207,25 @@ greplica install --mode managed [--platform codex|claude|copilot|cursor|opencode greplica install --invite-link --platform codex|claude|copilot|cursor|opencode|openhands|factory-droid|antigravity greplica repo invite-link create|list greplica repo invite-link revoke --link +greplica repo invite-contributor --github-user +greplica repo grant-contributor --user +greplica repo revoke-contributor --user greplica logout greplica whoami greplica repo status greplica config greplica doctor [--check-embeddings] greplica embeddings prewarm -greplica graph read -greplica graph context "" [--debug] +greplica graph read [--with-working ...] [--memory-pr ] [--main-only] [--include-quarantined] [--json] +greplica graph context "" [--with-working ...] [--memory-pr ] [--main-only] [--include-quarantined] [--json|--debug] greplica graph audit anchors -greplica graph view [--out ] [--no-open] +greplica graph view [--with-working ...] [--memory-pr ] [--main-only] [--include-quarantined] [--json] [--out ] [--no-open] greplica graph export greplica proposal validate greplica proposal apply +greplica proposal list|show +greplica memory pr list|show|context|retry +greplica memory status [--json] greplica session mark-memory-current --session-ref greplica transcript bundle --platform codex|claude|copilot|opencode --file [--file ...] --out ``` diff --git a/action.yml b/action.yml index fbc1b11..0d5b006 100644 --- a/action.yml +++ b/action.yml @@ -19,12 +19,12 @@ runs: using: composite steps: - name: Check out exact merge commit - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 with: ref: ${{ inputs.merge-sha }} fetch-depth: 0 - name: Set up Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 with: node-version: "22" - name: Audit and attest Memory PR diff --git a/apps/cli/main.ts b/apps/cli/main.ts index 6ea8a84..57461eb 100644 --- a/apps/cli/main.ts +++ b/apps/cli/main.ts @@ -285,7 +285,7 @@ const cliCommands = [ { key: "memoryReconcile", path: ["memory", "reconcile"], - usage: "memory reconcile --managed-repo --merge-sha [--api-url ] [--oidc-audience ] [--repair-proposal ]", + usage: "memory reconcile --managed-repo --merge-sha [--api-url ] [--oidc-audience ]", handler: runMemoryReconcile, showInTopLevelHelp: true, }, @@ -561,8 +561,15 @@ async function runProposalApplyCommand(args: string[], getContext: CommandContex const proposal = readProposal(file); const result = await service.applyProposal(proposal); console.log("Applied proposal to working memory."); + if (result.author !== undefined) console.log(`Author: ${result.author.github_login} (${result.author.id})`); + if (result.proposal_id !== undefined) console.log(`Proposal: ${result.proposal_id}`); console.log(`Memory commit: ${result.memory_commit_id}`); console.log(`Scope: ${result.scope_id}`); + if (result.working_scope_revision !== undefined) { + console.log(`Working revision: ${result.working_scope_revision}`); + } + if (result.memory_commit_state !== undefined) console.log(`State: ${result.memory_commit_state}`); + if (result.memory_pr_id !== undefined) console.log(`Memory PR: ${result.memory_pr_id}`); console.log(`Components: ${result.created.components}`); console.log(`Flows: ${result.created.flows}`); console.log(`Claims: ${result.created.claims}`); diff --git a/apps/cli/reconcile-cli.ts b/apps/cli/reconcile-cli.ts index 9570d56..c616a0a 100644 --- a/apps/cli/reconcile-cli.ts +++ b/apps/cli/reconcile-cli.ts @@ -1,5 +1,4 @@ import { execFileSync } from "node:child_process"; -import { readFileSync } from "node:fs"; import { resolve } from "node:path"; import { auditClaimCodeAnchors } from "../../libs/knowledge-graph/code-anchors/audit.js"; import { fingerprintClaimAnchors } from "../../libs/knowledge-graph/code-anchors/fingerprint.js"; @@ -9,6 +8,12 @@ import type { ManagedReconciliationAttestationResult, ManagedReconciliationCandidate, } from "../../libs/managed/protocol.js"; +import { + managedCapabilitiesHeader, + managedClientCapabilities, + managedClientVersion, + managedClientVersionHeader, +} from "../../libs/managed/protocol.js"; const defaultOidcAudience = "greplica-managed"; @@ -28,16 +33,9 @@ export async function runMemoryReconcile(args: string[]): Promise { const headers = { authorization: `Bearer ${oidcToken}`, accept: "application/json", + [managedClientVersionHeader]: managedClientVersion, + [managedCapabilitiesHeader]: managedClientCapabilities.join(","), }; - const repairProposalPath = optionalOption(args, "--repair-proposal"); - const repairProposalValue = repairProposalPath === undefined - ? undefined - : JSON.parse(readFileSync(resolve(repairProposalPath), "utf8")) as unknown; - if (repairProposalValue !== undefined && !isRecord(repairProposalValue)) { - throw new Error("--repair-proposal must contain a JSON object."); - } - const repairProposal = repairProposalValue; - const reconciliations: Array<{ response: ManagedReconciliationAttestationResult; memory_pr_id: string; @@ -63,11 +61,19 @@ export async function runMemoryReconcile(args: string[]): Promise { throw new Error(`Managed reconciliation returned duplicate Memory PR ${candidate.memory_pr_id}.`); } verifyCandidate(candidate, mergeSha); - const ancestry = candidate.commits.map((commit) => ({ - memory_commit_id: commit.memory_commit_id, - git_head: commit.git_head, - is_ancestor: isAncestor(repoRoot, commit.git_head, mergeSha), - })); + const ancestry = candidate.commits.map((commit) => { + const proofMode = commit.proof_mode ?? "default_ancestry"; + const targetSha = proofMode === "pr_head" + ? verifiedPrHead(repoRoot, commit.code_pr_number, commit.verified_head_sha) + : mergeSha; + return { + memory_commit_id: commit.memory_commit_id, + git_head: commit.git_head, + proof_mode: proofMode, + ...(proofMode === "pr_head" ? { verified_head_sha: targetSha } : {}), + is_ancestor: isAncestor(repoRoot, commit.git_head, targetSha), + }; + }); const nonAncestors = ancestry.filter((entry) => !entry.is_ancestor); if (nonAncestors.length > 0) { skipped.push({ @@ -79,7 +85,17 @@ export async function runMemoryReconcile(args: string[]): Promise { continue; } const auditClaims = candidate.claim_versions.map(({ version_id, claim }) => ({ ...claim, id: version_id })); - const result = await auditClaimCodeAnchors(repoRoot, auditClaims); + const baselineFingerprints = new Map( + candidate.claim_versions + .filter(({ baseline_fingerprints }) => baseline_fingerprints !== undefined) + .map(({ version_id, baseline_fingerprints }) => [version_id, baseline_fingerprints!]), + ); + const result = await auditClaimCodeAnchors( + repoRoot, + auditClaims, + undefined, + baselineFingerprints, + ); const fingerprints: Record> = {}; for (const claim of auditClaims) { if (claim.code_anchors === undefined || claim.code_anchors.length === 0) continue; @@ -95,7 +111,6 @@ export async function runMemoryReconcile(args: string[]): Promise { ancestry, audit_key: "version_id", anchor_audit: { result, fingerprints }, - ...(repairProposal === undefined ? {} : { repair_proposal: repairProposal }), ref: process.env.GITHUB_REF, run_id: process.env.GITHUB_RUN_ID, run_attempt: process.env.GITHUB_RUN_ATTEMPT, @@ -116,14 +131,16 @@ export async function runMemoryReconcile(args: string[]): Promise { }); excludedMemoryPrIds.push(candidate.memory_pr_id); } + const accepted = reconciliations.every(({ response }) => response.accepted); console.log(JSON.stringify({ - accepted: reconciliations.every(({ response }) => response.accepted), + accepted, merge_sha: mergeSha, reconciliation_count: reconciliations.length, skipped_count: skipped.length, reconciliations, skipped, }, null, 2)); + if (!accepted) process.exitCode = 1; } async function reconciliationCandidate( @@ -156,6 +173,15 @@ function verifyCandidate(candidate: ManagedReconciliationCandidate, mergeSha: st if (JSON.stringify(candidateIds) !== JSON.stringify(commitIds)) { throw new Error("Managed reconciliation candidate commit metadata does not match its selected commit IDs."); } + for (const commit of candidate.commits) { + if (!/^[0-9a-f]{40}$/i.test(commit.git_head)) { + throw new Error(`Memory commit ${commit.memory_commit_id} has an invalid Git head.`); + } + if (commit.proof_mode === "pr_head" && + (commit.code_pr_number === undefined || commit.verified_head_sha === undefined)) { + throw new Error(`Memory commit ${commit.memory_commit_id} is missing its verified PR-head proof.`); + } + } } function isAncestor(repoRoot: string, gitHead: string, mergeSha: string): boolean { @@ -169,6 +195,50 @@ function isAncestor(repoRoot: string, gitHead: string, mergeSha: string): boolea } } +function verifiedPrHead( + repoRoot: string, + pullRequestNumber: number | undefined, + verifiedHeadSha: string | undefined, +): string { + if (pullRequestNumber === undefined || !Number.isSafeInteger(pullRequestNumber) || pullRequestNumber < 1) { + throw new Error("PR-head proof is missing a valid code PR number."); + } + if (verifiedHeadSha === undefined || !/^[0-9a-f]{40}$/i.test(verifiedHeadSha)) { + throw new Error(`PR #${pullRequestNumber} proof is missing a full verified head SHA.`); + } + try { + execFileSync( + "git", + ["-C", repoRoot, "fetch", "--no-tags", "origin", `refs/pull/${pullRequestNumber}/head`], + { stdio: "ignore" }, + ); + } catch { + throw new Error(`Could not fetch the verified head for PR #${pullRequestNumber}.`); + } + const fetchedHead = execFileSync("git", ["-C", repoRoot, "rev-parse", "FETCH_HEAD"], { + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }).trim().toLowerCase(); + if (fetchedHead !== verifiedHeadSha.toLowerCase()) { + throw new Error( + `PR #${pullRequestNumber} head ${fetchedHead} does not match verified head ${verifiedHeadSha}.`, + ); + } + if (!hasGitCommit(repoRoot, verifiedHeadSha)) { + throw new Error(`Verified head ${verifiedHeadSha} for PR #${pullRequestNumber} is unavailable.`); + } + return verifiedHeadSha.toLowerCase(); +} + +function hasGitCommit(repoRoot: string, sha: string): boolean { + try { + execFileSync("git", ["-C", repoRoot, "cat-file", "-e", `${sha}^{commit}`], { stdio: "ignore" }); + return true; + } catch { + return false; + } +} + function assertExactCheckout(repoRoot: string, mergeSha: string): void { const git = (arguments_: string[]): string => execFileSync("git", ["-C", repoRoot, ...arguments_], { encoding: "utf8", diff --git a/libs/knowledge-graph/graph-context/render.ts b/libs/knowledge-graph/graph-context/render.ts index 1c0fc3a..dea5a67 100644 --- a/libs/knowledge-graph/graph-context/render.ts +++ b/libs/knowledge-graph/graph-context/render.ts @@ -2,6 +2,7 @@ import type { GraphContextResult, RankedGraphContextResult, } from "./types.js"; +import type { ManagedObjectProvenance } from "../../managed/protocol.js"; export function renderGraphContextMarkdown(result: GraphContextResult): string { const rankedComponents = result.ranked_results.filter((item) => item.type === "component"); @@ -36,7 +37,7 @@ function renderRankedComponents( const relation = component.context_relation === "additional" ? " additional" : ""; const anchor = component.object.code_anchor === undefined ? "" : ` Anchor: \`${component.object.code_anchor}\`.`; const claims = component.matched_claim_ids.length === 0 ? "" : ` Supporting claims: ${component.matched_claim_ids.map((id) => `\`${id}\``).join(", ")}.`; - return `- ${index + 1}. ${component.object.name}${relation}. ID: \`${component.object.id}\`.${anchor}${claims}`; + return `- ${index + 1}. ${component.object.name}${relation}. ID: \`${component.object.id}\`.${anchor}${claims}${provenanceLabel(component.object)}`; }); } @@ -47,7 +48,7 @@ function renderRankedFlows( return flows.map((flow, index) => { const relation = flow.context_relation === "additional" ? " additional" : ""; const claims = flow.matched_claim_ids.length === 0 ? "" : ` Supporting claims: ${flow.matched_claim_ids.map((id) => `\`${id}\``).join(", ")}.`; - return `- ${index + 1}. ${flow.object.name}${relation}. ID: \`${flow.object.id}\`.${claims}`; + return `- ${index + 1}. ${flow.object.name}${relation}. ID: \`${flow.object.id}\`.${claims}${provenanceLabel(flow.object)}`; }); } @@ -65,12 +66,38 @@ function renderRankedClaims( "", claim.object.text, "", - `${anchors}${about}`.trim(), + `${anchors}${about}${provenanceLabel(claim.object)}`.trim(), "", ]; }); } +function provenanceLabel(object: object): string { + const provenance = (object as { provenance?: ManagedObjectProvenance }).provenance; + if (provenance === undefined) return ""; + const currentLogin = provenance.author_github_login; + const historicalLogin = provenance.author_github_login_snapshot; + const values = [ + provenance.scope_kind, + `version ${provenance.version_id}`, + currentLogin === undefined ? undefined : `@${currentLogin}`, + historicalLogin === undefined || historicalLogin === currentLogin ? undefined : `formerly @${historicalLogin}`, + provenance.proposal_id === undefined ? undefined : `proposal ${provenance.proposal_id}`, + provenance.memory_commit_id === undefined ? undefined : `commit ${provenance.memory_commit_id}`, + ...(provenance.session_refs ?? []).map((session) => `session ${session.id}`), + provenance.agent_platform === undefined ? undefined : `agent ${provenance.agent_platform}`, + provenance.branch === undefined ? undefined : `branch ${provenance.branch}`, + provenance.git_head === undefined ? undefined : `git ${provenance.git_head}`, + provenance.code_pr_number === undefined ? undefined : `code PR #${provenance.code_pr_number}`, + provenance.memory_pr_id === undefined ? undefined : `Memory PR ${provenance.memory_pr_id}`, + provenance.commit_role, + provenance.memory_commit_state, + provenance.promotion_id === undefined ? undefined : `promotion ${provenance.promotion_id}`, + provenance.quarantine_reason === undefined ? undefined : `quarantine ${provenance.quarantine_reason}`, + ].filter((value): value is string => value !== undefined); + return ` Provenance: ${values.join("; ")}.`; +} + function anchorLabel(anchor: Extract["code_anchors"][number]): string { const base = anchor.symbol === undefined ? anchor.file : `${anchor.file}#${anchor.symbol}`; if (anchor.status === "resolved" && anchor.start_line !== undefined) { diff --git a/libs/knowledge-graph/graph-view/build-graph-view.ts b/libs/knowledge-graph/graph-view/build-graph-view.ts index 0fb0a71..0eeadca 100644 --- a/libs/knowledge-graph/graph-view/build-graph-view.ts +++ b/libs/knowledge-graph/graph-view/build-graph-view.ts @@ -6,6 +6,7 @@ import type { Edge } from "../edge.js"; import type { GraphReadResult } from "../service.js"; import type { Component, Flow, Source } from "../schema.js"; import type { ClaimProvenanceRecord } from "../repository.js"; +import type { ManagedGraphView, ManagedObjectProvenance } from "../../managed/protocol.js"; const require = createRequire(import.meta.url); @@ -55,16 +56,7 @@ export interface GraphViewClaimRow { flowIds: string[]; createdAt: string | null; memoryCommitId: string | null; - provenance?: { - version_id: string; - scope_kind: "main" | "working" | "memory_pr" | "quarantine"; - scope_name?: string; - author_github_login?: string; - author_github_login_snapshot?: string; - memory_commit_state?: "active" | "promoted" | "quarantined"; - memory_pr_id?: string; - commit_role?: "direct" | "dependency" | "repair"; - }; + provenance?: ManagedObjectProvenance; } export interface GraphViewTimelineEvent { @@ -77,6 +69,7 @@ export interface GraphViewTimelineEvent { export interface GraphViewData { generatedAt: string; + view?: ManagedGraphView; counts: { components: number; flows: number; @@ -154,6 +147,7 @@ export function buildGraphViewData( flowIds: flowIdsForClaim(claim.id, graph.edges), createdAt: record?.created_at ?? null, memoryCommitId: record?.memory_commit_id ?? null, + provenance: managedProvenance(claim), }; }; @@ -182,6 +176,10 @@ export function buildGraphViewData( }; } +function managedProvenance(value: object): ManagedObjectProvenance | undefined { + return (value as { provenance?: ManagedObjectProvenance }).provenance; +} + export function buildGraphViewHtml( graph: GraphReadResult, provenance: ClaimProvenanceRecord[], @@ -403,21 +401,33 @@ function kindColor(kind: string): string { function renderClaimRow(claim: GraphViewClaimRow): string { const badge = `${escapeHtml(claim.kind)}`; const provenance = claim.provenance; + const currentLogin = provenance?.author_github_login; + const historicalLogin = provenance?.author_github_login_snapshot; const provenanceBadges = provenance === undefined ? "" : `
${[ provenance.scope_kind, - provenance.author_github_login ?? provenance.author_github_login_snapshot, + currentLogin === undefined ? undefined : `@${currentLogin}`, + historicalLogin === undefined || historicalLogin === currentLogin ? undefined : `formerly @${historicalLogin}`, + provenance.proposal_id === undefined ? undefined : `proposal ${provenance.proposal_id}`, + provenance.memory_commit_id === undefined ? undefined : `commit ${provenance.memory_commit_id}`, + ...(provenance.session_refs ?? []).map((session) => `session ${session.id}`), + provenance.agent_platform === undefined ? undefined : `agent ${provenance.agent_platform}`, + provenance.git_head === undefined ? undefined : `git ${provenance.git_head}`, + provenance.branch === undefined ? undefined : `branch ${provenance.branch}`, + provenance.code_pr_number === undefined ? undefined : `code PR #${provenance.code_pr_number}`, provenance.commit_role, provenance.memory_commit_state, provenance.memory_pr_id === undefined ? undefined : `Memory PR ${provenance.memory_pr_id}`, + provenance.promotion_id === undefined ? undefined : `promotion ${provenance.promotion_id}`, + provenance.quarantine_reason === undefined ? undefined : `quarantine: ${provenance.quarantine_reason}`, ].filter((value): value is string => value !== undefined) .map((value) => `${escapeHtml(value)}`) .join("")}
`; const version = provenance === undefined ? "" : ` version ${escapeHtml(provenance.version_id)}`; - return `
`; + return ` `; } function renderHtml(data: GraphViewData, title: string): string { @@ -915,9 +925,16 @@ ${flowRows}
+ + + + + + +

${escapeHtml(defaultClaimsMeta)}

@@ -982,9 +999,16 @@ ${timelineEvents} const provenanceFilterSelects = { scope: document.getElementById("claims-filter-scope"), author: document.getElementById("claims-filter-author"), + authorSnapshot: document.getElementById("claims-filter-author-snapshot"), + proposalId: document.getElementById("claims-filter-proposal"), + memoryCommitId: document.getElementById("claims-filter-memory-commit"), + agent: document.getElementById("claims-filter-agent"), + branch: document.getElementById("claims-filter-branch"), + codePr: document.getElementById("claims-filter-code-pr"), memoryState: document.getElementById("claims-filter-memory-state"), memoryPrId: document.getElementById("claims-filter-memory-pr"), commitRole: document.getElementById("claims-filter-commit-role"), + promotion: document.getElementById("claims-filter-promotion"), }; const defaultClaimsMeta = ${JSON.stringify(defaultClaimsMeta)}; @@ -994,9 +1018,15 @@ ${timelineEvents} const FRESHNESS_COLORS = { active: "#59a14f", superseded: "#bab0ac" }; const allClaims = graphData.claims.concat(graphData.supersededClaims); - const claimTextById = new Map(allClaims.map((claim) => [claim.id, claim.text])); - const componentIdsByClaim = new Map(graphData.claims.map((claim) => [claim.id, claim.componentIds || []])); - const flowIdsByClaim = new Map(graphData.claims.map((claim) => [claim.id, claim.flowIds || []])); + const claimVersionKey = (claim) => (claim.provenance && claim.provenance.version_id) || claim.id; + const rowVersionKey = (row) => row.dataset.versionId || row.dataset.id || ""; + const claimTextByVersion = new Map(allClaims.map((claim) => [claimVersionKey(claim), claim.text])); + const componentIdsByClaimVersion = new Map( + graphData.claims.map((claim) => [claimVersionKey(claim), claim.componentIds || []]) + ); + const flowIdsByClaimVersion = new Map( + graphData.claims.map((claim) => [claimVersionKey(claim), claim.flowIds || []]) + ); const componentNameById = new Map(graphData.components.map((component) => [component.id, component.name])); const flowNameById = new Map(graphData.flows.map((flow) => [flow.id, flow.name])); @@ -1006,9 +1036,16 @@ ${timelineEvents} const provenance = claim.provenance || {}; if (key === "scope") return provenance.scope_kind || ""; if (key === "author") return provenance.author_github_login || provenance.author_github_login_snapshot || ""; + if (key === "authorSnapshot") return provenance.author_github_login_snapshot || ""; + if (key === "proposalId") return provenance.proposal_id || ""; + if (key === "memoryCommitId") return provenance.memory_commit_id || claim.memoryCommitId || ""; + if (key === "agent") return provenance.agent_platform || ""; + if (key === "branch") return provenance.branch || ""; + if (key === "codePr") return provenance.code_pr_number ? String(provenance.code_pr_number) : ""; if (key === "memoryState") return provenance.memory_commit_state || ""; if (key === "memoryPrId") return provenance.memory_pr_id || ""; if (key === "commitRole") return provenance.commit_role || ""; + if (key === "promotion") return provenance.promotion_id || ""; return ""; } @@ -1267,10 +1304,10 @@ ${timelineEvents} if (filter.type === "source") return row.dataset.source === filter.value; if (filter.type === "commit") return row.dataset.memoryCommitId === filter.value; if (filter.type === "component") { - return (componentIdsByClaim.get(row.dataset.id) || []).includes(filter.value); + return (componentIdsByClaimVersion.get(rowVersionKey(row)) || []).includes(filter.value); } if (filter.type === "flow") { - return (flowIdsByClaim.get(row.dataset.id) || []).includes(filter.value); + return (flowIdsByClaimVersion.get(rowVersionKey(row)) || []).includes(filter.value); } return true; } @@ -1293,7 +1330,7 @@ ${timelineEvents} for (const row of claimRows) { const id = row.dataset.id || ""; const matchesFilter = rowMatchesFilter(row, filter); - const text = (claimTextById.get(id) || "").toLowerCase(); + const text = (claimTextByVersion.get(rowVersionKey(row)) || "").toLowerCase(); const matchesSearch = !query || id.toLowerCase().includes(query) || text.includes(query); const vis = matchesFilter && matchesSearch && rowMatchesProvenanceFilters(row); row.classList.toggle("claim-row-hidden", !vis); diff --git a/libs/knowledge-graph/managed-client.ts b/libs/knowledge-graph/managed-client.ts index 21ed461..7e8e465 100644 --- a/libs/knowledge-graph/managed-client.ts +++ b/libs/knowledge-graph/managed-client.ts @@ -23,6 +23,13 @@ import type { ManagedMemoryStatus, ManagedProposal, } from "../managed/protocol.js"; +import { + managedCapabilitiesHeader, + managedClientCapabilities, + managedClientVersion, + managedClientVersionHeader, + type ManagedClientCapability, +} from "../managed/protocol.js"; export interface ManagedGraphClientOptions { apiUrl: string; @@ -84,30 +91,42 @@ export class ManagedGraphMemoryClient implements GraphMemoryProvider { private readonly credentials?: ManagedCredentials; readGraph(view?: ManagedGraphView): Promise { - return this.request(`/graph${viewQuery(this.requestView(view))}`, { method: "GET" }); + return this.request( + `/graph${viewQuery(this.requestView(view))}`, + { method: "GET" }, + view === undefined ? undefined : "graph-selectors-v1", + ); } async contextGraph(query: string, view?: ManagedGraphView): Promise { const requestView = this.requestView(view); - const result = await this.request("/graph/context", { - method: "POST", - body: { query, ...(requestView === undefined ? {} : { view: requestView }) }, - }); + const result = await this.request( + "/graph/context", + { + method: "POST", + body: { query, ...(requestView === undefined ? {} : { view: requestView }) }, + }, + view === undefined ? undefined : "graph-selectors-v1", + ); const resolver = new CodeAnchorResolver(); const resolved = new Map>>(); for (const claim of result.claims) { const anchors = await resolver.resolveMany(this.repo.repo_root, claim.object.code_anchors); claim.code_anchors = anchors; - resolved.set(claim.object.id, anchors); + resolved.set(managedObjectKey(claim.object), anchors); } for (const item of result.ranked_results) { - if (item.type === "claim") item.code_anchors = resolved.get(item.object.id) ?? []; + if (item.type === "claim") item.code_anchors = resolved.get(managedObjectKey(item.object)) ?? []; } return result; } viewData(view?: ManagedGraphView): Promise { - return this.request(`/graph/view-data${viewQuery(this.requestView(view))}`, { method: "GET" }); + return this.request( + `/graph/view-data${viewQuery(this.requestView(view))}`, + { method: "GET" }, + view === undefined ? undefined : "graph-selectors-v1", + ); } async buildGraphView(view?: ManagedGraphView): Promise { @@ -218,6 +237,7 @@ export class ManagedGraphMemoryClient implements GraphMemoryProvider { private async request( path: string, input: { method: "GET" | "POST"; body?: unknown }, + requiredCapability?: ManagedClientCapability, ): Promise { const managedRepoId = this.installation.managedRepoId as string; const response = await this.fetchImpl(`${this.apiUrl}/v1/repos/${encodeURIComponent(managedRepoId)}${path}`, { @@ -225,11 +245,19 @@ export class ManagedGraphMemoryClient implements GraphMemoryProvider { headers: { authorization: `Bearer ${this.token}`, accept: "application/json", + [managedClientVersionHeader]: managedClientVersion, + [managedCapabilitiesHeader]: managedClientCapabilities.join(","), ...(input.body === undefined ? {} : { "content-type": "application/json" }), }, body: input.body === undefined ? undefined : JSON.stringify(input.body), }); await this.captureResponseMetadata(response, managedRepoId); + if (response.ok && requiredCapability !== undefined && !responseCapabilities(response).has(requiredCapability)) { + throw new Error( + `Managed Greplica server does not acknowledge ${requiredCapability}; ` + + "upgrade the server before using personal graph selectors.", + ); + } const payload = await readJson(response); if (!response.ok) { const message = isRecord(payload) && typeof payload.message === "string" @@ -304,20 +332,20 @@ function localProposalContext(repo: RepoRef, proposal: unknown): ProposalCommitC agent_platform: agentPlatform, }; } - const git = (args: string[]): string | undefined => { + const git = (args: string[], preserveEmpty = false): string | undefined => { try { const value = execFileSync("git", ["-C", repoRoot, ...args], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], }).trim(); - return value.length === 0 ? undefined : value; + return value.length === 0 && !preserveEmpty ? undefined : value; } catch { return undefined; } }; const gitHead = git(["rev-parse", "HEAD"]); const branch = git(["branch", "--show-current"]); - const dirtyOutput = git(["status", "--porcelain"]); + const dirtyOutput = git(["status", "--porcelain"], true); if (gitHead === undefined && branch === undefined && dirtyOutput === undefined && sessionRefs.length === 0 && agentPlatform === undefined && headRepository === undefined) return undefined; return { @@ -378,3 +406,17 @@ function viewQuery(view: ManagedGraphView | undefined): string { if (view.include_quarantined === true) query.set("include_quarantined", "true"); return `?${query.toString()}`; } + +function responseCapabilities(response: Response): Set { + return new Set( + (response.headers.get(managedCapabilitiesHeader) ?? "") + .split(",") + .map((capability) => capability.trim()) + .filter(Boolean), + ); +} + +function managedObjectKey(object: { id: string }): string { + const provenance = (object as { provenance?: { version_id?: unknown } }).provenance; + return typeof provenance?.version_id === "string" ? provenance.version_id : object.id; +} diff --git a/libs/knowledge-graph/service.ts b/libs/knowledge-graph/service.ts index 7a5c23f..010b91b 100644 --- a/libs/knowledge-graph/service.ts +++ b/libs/knowledge-graph/service.ts @@ -48,6 +48,16 @@ export interface GraphReadResult { export interface ApplyProposalResult { memory_commit_id: string; scope_id: string; + proposal_id?: string; + author?: { + id: string; + github_user_id: string; + github_login: string; + created_at: string; + }; + working_scope_revision?: number; + memory_commit_state?: "active" | "promoted" | "quarantined"; + memory_pr_id?: string; embedding_status: EmbeddingStatus; created: { components: number; diff --git a/libs/managed/control-client.ts b/libs/managed/control-client.ts index 48f1e40..d758e66 100644 --- a/libs/managed/control-client.ts +++ b/libs/managed/control-client.ts @@ -19,6 +19,12 @@ import type { ManagedRepoGrant, ManagedUser, } from "./protocol.js"; +import { + managedCapabilitiesHeader, + managedClientCapabilities, + managedClientVersion, + managedClientVersionHeader, +} from "./protocol.js"; export interface DeviceLoginStart { device_code: string; @@ -269,6 +275,8 @@ export class ManagedControlClient { method, headers: { accept: "application/json", + [managedClientVersionHeader]: managedClientVersion, + [managedCapabilitiesHeader]: managedClientCapabilities.join(","), ...(authenticated ? { authorization: `Bearer ${this.token}` } : {}), ...(body === undefined ? {} : { "content-type": "application/json" }), }, diff --git a/libs/managed/protocol.ts b/libs/managed/protocol.ts index 801fa17..471b0a7 100644 --- a/libs/managed/protocol.ts +++ b/libs/managed/protocol.ts @@ -1,5 +1,16 @@ import { Type, type Static, type TSchema } from "@sinclair/typebox"; +export const managedClientVersion = "0.2.1"; +export const managedClientCapabilities = [ + "personal-working-v1", + "graph-selectors-v1", + "memory-pr-v1", + "oidc-reconciliation-v1", +] as const; +export type ManagedClientCapability = (typeof managedClientCapabilities)[number]; +export const managedClientVersionHeader = "x-greplica-client-version"; +export const managedCapabilitiesHeader = "x-greplica-capabilities"; + export const managedErrorCodes = [ "invalid_request", "authentication_required", @@ -619,10 +630,17 @@ export const ReconciliationCandidateSchema = Type.Object({ git_head: Type.String({ minLength: 7 }), head_repository: Type.Optional(Type.String()), head_ref: Type.Optional(Type.String()), + proof_mode: Type.Optional(Type.Union([ + Type.Literal("pr_head"), + Type.Literal("default_ancestry"), + ])), + code_pr_number: Type.Optional(Type.Integer({ minimum: 1 })), + verified_head_sha: Type.Optional(Type.String({ minLength: 7 })), }), { minItems: 1 }), claim_versions: Type.Array(Type.Object({ version_id: Type.String(), claim: ClaimSchema, + baseline_fingerprints: Type.Optional(Type.Record(Type.String(), Type.String())), })), }); @@ -636,10 +654,14 @@ export const ReconciliationAttestationSchema = Type.Object({ memory_commit_id: Type.String(), git_head: Type.String({ minLength: 7 }), is_ancestor: Type.Boolean(), + proof_mode: Type.Optional(Type.Union([ + Type.Literal("pr_head"), + Type.Literal("default_ancestry"), + ])), + verified_head_sha: Type.Optional(Type.String({ minLength: 7 })), }), { minItems: 1 }), audit_key: Type.Literal("version_id"), anchor_audit: ProposalAnchorAuditSchema, - repair_proposal: Type.Optional(MemoryProposalSchema), ref: Type.Optional(Type.String()), run_id: Type.Optional(Type.String()), run_attempt: Type.Optional(Type.String()), diff --git a/scripts/check-managed-cli.js b/scripts/check-managed-cli.js index b146c65..2151a7b 100644 --- a/scripts/check-managed-cli.js +++ b/scripts/check-managed-cli.js @@ -32,6 +32,8 @@ let importedSnapshot; const server = createServer(async (request, response) => { requestCount += 1; + assert.equal(request.headers["x-greplica-client-version"], "0.2.1"); + assert.match(request.headers["x-greplica-capabilities"] ?? "", /personal-working-v1/); const chunks = []; for await (const chunk of request) chunks.push(chunk); const body = chunks.length === 0 ? undefined : JSON.parse(Buffer.concat(chunks).toString("utf8")); diff --git a/scripts/check-managed-collaboration.js b/scripts/check-managed-collaboration.js index 5299cae..d7e1c72 100644 --- a/scripts/check-managed-collaboration.js +++ b/scripts/check-managed-collaboration.js @@ -14,12 +14,24 @@ process.env.GREPLICA_HOME = join(temporary, "greplica-home"); const { ManagedGraphMemoryClient } = await import("../dist/libs/knowledge-graph/managed-client.js"); const { canScheduleMemoryUpdates } = await import("../dist/libs/install/repo-installation-store.js"); -const { buildGraphViewHtmlFromData } = await import("../dist/libs/knowledge-graph/graph-view/build-graph-view.js"); +const { + buildGraphViewData, + buildGraphViewHtmlFromData, +} = await import("../dist/libs/knowledge-graph/graph-view/build-graph-view.js"); +const { renderGraphContextMarkdown } = await import( + "../dist/libs/knowledge-graph/graph-context/render.js" +); +const { fingerprintClaimAnchors } = await import( + "../dist/libs/knowledge-graph/code-anchors/fingerprint.js" +); const { migrate } = await import("../dist/libs/storage/sqlite/migrate.js"); const action = readFileSync(fileURLToPath(new URL("../action.yml", import.meta.url)), "utf8"); assert.match(action, /npm ci --prefix "\$GITHUB_ACTION_PATH" --include=dev/); assert.match(action, /node "\$GITHUB_ACTION_PATH\/dist\/apps\/cli\/main\.js" memory reconcile/); +assert.match(action, /actions\/checkout@11d5960a326750d5838078e36cf38b85af677262/); +assert.match(action, /actions\/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020/); +assert.doesNotMatch(action, /uses: actions\/(?:checkout|setup-node)@v\d/); assert.doesNotMatch(action, /greplica@latest/); const reusableWorkflow = readFileSync( fileURLToPath(new URL("../.github/workflows/reconcile.yml", import.meta.url)), @@ -28,6 +40,9 @@ const reusableWorkflow = readFileSync( assert.match(reusableWorkflow, /workflow_call:/); assert.match(reusableWorkflow, /contents: read/); assert.match(reusableWorkflow, /id-token: write/); +assert.match(reusableWorkflow, /api-url: https:\/\/memory\.autoloops\.ai/); +assert.match(reusableWorkflow, /oidc-audience: greplica-managed/); +assert.doesNotMatch(reusableWorkflow, /\$\{\{ inputs\.(?:api-url|oidc-audience) \}\}/); assert.match( reusableWorkflow, /uses: Autoloops\/greplica@38e477bbc10ac01ff01d497e2011cfecb5e33897/, @@ -38,10 +53,36 @@ const repoRoot = join(temporary, "repo"); exec("git", ["init", "--quiet", repoRoot]); exec("git", ["-C", repoRoot, "config", "user.email", "test@example.com"]); exec("git", ["-C", repoRoot, "config", "user.name", "Test"]); -writeFileSync(join(repoRoot, "example.ts"), "export const example = true;\n"); +writeFileSync(join(repoRoot, "example.ts"), "export function example() { return 0; }\n"); exec("git", ["-C", repoRoot, "add", "example.ts"]); exec("git", ["-C", repoRoot, "commit", "--quiet", "-m", "example"]); +const baseSha = exec("git", ["-C", repoRoot, "rev-parse", "HEAD"]).trim(); +const defaultBranch = exec("git", ["-C", repoRoot, "branch", "--show-current"]).trim(); +exec("git", ["-C", repoRoot, "checkout", "--quiet", "-b", "feature"]); +writeFileSync(join(repoRoot, "example.ts"), "export function example() { return 1; }\n"); +exec("git", ["-C", repoRoot, "add", "example.ts"]); +exec("git", ["-C", repoRoot, "commit", "--quiet", "-m", "feature"]); +const featureSha = exec("git", ["-C", repoRoot, "rev-parse", "HEAD"]).trim(); +exec("git", ["-C", repoRoot, "checkout", "--quiet", defaultBranch]); +writeFileSync(join(repoRoot, "example.ts"), "export function example() { return 2; }\n"); +exec("git", ["-C", repoRoot, "add", "example.ts"]); +exec("git", ["-C", repoRoot, "commit", "--quiet", "-m", "squash feature"]); const mergeSha = exec("git", ["-C", repoRoot, "rev-parse", "HEAD"]).trim(); +assert.equal(gitIsAncestor(repoRoot, featureSha, mergeSha), false, "fixture must model a squash/rebase merge"); +const originRoot = join(temporary, "origin.git"); +exec("git", ["init", "--quiet", "--bare", originRoot]); +exec("git", ["-C", repoRoot, "remote", "add", "origin", originRoot]); +exec("git", ["-C", repoRoot, "push", "--quiet", "origin", `${defaultBranch}:refs/heads/${defaultBranch}`]); +exec("git", ["-C", repoRoot, "push", "--quiet", "origin", `${featureSha}:refs/pull/7/head`]); +const versionOneAnchor = { file: "example.ts", symbol: "example" }; +exec("git", ["-C", repoRoot, "checkout", "--quiet", "--detach", featureSha]); +const versionOneBaseline = await fingerprintClaimAnchors(repoRoot, [versionOneAnchor]); +exec("git", ["-C", repoRoot, "checkout", "--quiet", "--detach", mergeSha]); +assert.notEqual( + (await fingerprintClaimAnchors(repoRoot, [versionOneAnchor]))["example.ts#example"], + versionOneBaseline["example.ts#example"], + "fixture must change the anchored symbol body on the merged checkout", +); const calls = []; let applyBody; @@ -58,7 +99,7 @@ const viewData = { const fetchImpl = async (input, init) => { const url = String(input); const body = init?.body === undefined ? undefined : JSON.parse(String(init.body)); - calls.push({ url, method: init?.method, body }); + calls.push({ url, method: init?.method, body, headers: new Headers(init?.headers) }); if (url.endsWith("/proposals/review")) { return jsonResponse({ valid: true, @@ -84,10 +125,106 @@ const fetchImpl = async (input, init) => { query: body.query, search_config_version: "test", embedding_status: { checked_objects: 0, created: 0, reused: 0 }, - claims: [], + claims: [{ + object: { + id: "claim.conflict", + kind: "fact", + text: "First personal version", + truth: "code_verified", + intent: "intended", + code_anchors: [{ file: "example.ts", symbol: "example" }], + provenance: { + version_id: "context-version-1", + scope_kind: "working", + author_github_login: "alice", + author_github_login_snapshot: "alice-old", + proposal_id: "context-proposal-1", + memory_commit_id: "context-commit-1", + session_refs: [{ id: "codex-session:context-1", agent_platform: "codex" }], + agent_platform: "codex", + git_head: mergeSha, + branch: "feature", + code_pr_number: 7, + memory_pr_id: "memory-pr-1", + commit_role: "repair", + memory_commit_state: "active", + promotion_id: "promotion-1", + quarantine_reason: "superseded repair", + }, + }, + code_anchors: [], + about: [], + evidence: [], + }, { + object: { + id: "claim.conflict", + kind: "fact", + text: "Second personal version", + truth: "code_verified", + intent: "intended", + code_anchors: [{ file: "example.ts", symbol: "notThere" }], + provenance: { + version_id: "context-version-2", + scope_kind: "working", + author_github_login: "bob", + }, + }, + code_anchors: [], + about: [], + evidence: [], + }], components: [], flows: [], - ranked_results: [], + ranked_results: [{ + type: "claim", + object: { + id: "claim.conflict", + kind: "fact", + text: "First personal version", + truth: "code_verified", + intent: "intended", + code_anchors: [{ file: "example.ts", symbol: "example" }], + provenance: { + version_id: "context-version-1", + scope_kind: "working", + author_github_login: "alice", + author_github_login_snapshot: "alice-old", + proposal_id: "context-proposal-1", + memory_commit_id: "context-commit-1", + session_refs: [{ id: "codex-session:context-1", agent_platform: "codex" }], + agent_platform: "codex", + git_head: mergeSha, + branch: "feature", + code_pr_number: 7, + memory_pr_id: "memory-pr-1", + commit_role: "repair", + memory_commit_state: "active", + promotion_id: "promotion-1", + quarantine_reason: "superseded repair", + }, + }, + code_anchors: [], + about: [], + evidence: [], + }, { + type: "claim", + object: { + id: "claim.conflict", + kind: "fact", + text: "Second personal version", + truth: "code_verified", + intent: "intended", + code_anchors: [{ file: "example.ts", symbol: "notThere" }], + provenance: { + version_id: "context-version-2", + scope_kind: "working", + author_github_login: "bob", + }, + }, + code_anchors: [], + about: [], + evidence: [], + }], sources: [], }); } @@ -147,8 +284,21 @@ const client = new ManagedGraphMemoryClient(installation, { await client.readGraph({ base: "main", working_users: [] }); let request = new URL(calls.at(-1).url); assert.equal(request.searchParams.get("main_only"), "true"); -await client.contextGraph("auth", { base: "main", working_users: ["alice", "alice"] }); +assert.match(calls.at(-1).headers.get("x-greplica-capabilities"), /graph-selectors-v1/); +assert.equal(calls.at(-1).headers.get("x-greplica-client-version"), "0.2.1"); +const contextResult = await client.contextGraph("auth", { base: "main", working_users: ["alice", "alice"] }); assert.deepEqual(calls.at(-1).body.view.working_users, ["me", "alice"]); +assert.equal(contextResult.ranked_results[0].code_anchors[0].status, "resolved"); +assert.equal(contextResult.ranked_results[1].code_anchors[0].status, "missing_symbol"); +const contextMarkdown = renderGraphContextMarkdown(contextResult); +assert.match(contextMarkdown, /version context-version-1/); +assert.match(contextMarkdown, /formerly @alice-old/); +assert.match(contextMarkdown, /proposal context-proposal-1/); +assert.match(contextMarkdown, /session codex-session:context-1/); +assert.match(contextMarkdown, /branch feature/); +assert.match(contextMarkdown, /code PR #7/); +assert.match(contextMarkdown, /promotion promotion-1/); +assert.match(contextMarkdown, /quarantine superseded repair/); await client.viewData({ base: "main", memory_pr_id: "memory-pr-1" }); request = new URL(calls.at(-1).url); assert.equal(request.searchParams.get("memory_pr_id"), "memory-pr-1"); @@ -161,6 +311,7 @@ assert.equal(applyBody.working_revision, 3); assert.equal(applyBody.main_head, "main-1"); assert.equal(applyBody.context.git_head, mergeSha); assert.equal(applyBody.context.head_repository, "example/project"); +assert.equal(applyBody.context.dirty, false); assert.deepEqual(applyBody.context.session_refs, [{ id: "codex-session:session-1", agent_platform: "codex" }]); assert.equal("author" in applyBody, false); assert.equal("username" in applyBody, false); @@ -177,6 +328,21 @@ assert.ok(calls.some((call) => call.url.endsWith("/memory-prs/memory%2Fpr/retry" assert.equal(canScheduleMemoryUpdates(installation), true); assert.equal(canScheduleMemoryUpdates({ ...installation, managedRole: "reader" }), false); +const legacySelectorClient = new ManagedGraphMemoryClient(installation, { + repo_root: repoRoot, + remote_url: installation.remoteUrl, + repo_name: "project", + default_branch: "main", +}, { + apiUrl: "https://legacy-memory.example.test", + token: "managed-token", + fetchImpl: async () => jsonResponse(graph, false), +}); +await assert.rejects( + legacySelectorClient.readGraph({ base: "main", working_users: [] }), + /does not acknowledge graph-selectors-v1/, +); + const legacyDb = new Database(":memory:"); legacyDb.exec(` CREATE TABLE repos ( @@ -225,9 +391,37 @@ const html = buildGraphViewHtmlFromData({ version_id: "version-1", scope_kind: "working", author_github_login: "alice", + author_github_login_snapshot: "alice-old", + proposal_id: "proposal-1", + memory_commit_id: "commit-1", + session_refs: [{ id: "codex-session:session-1", agent_platform: "codex" }], + agent_platform: "codex", + git_head: mergeSha, + branch: "feature", + code_pr_number: 7, memory_commit_state: "active", memory_pr_id: "memory-pr-1", commit_role: "repair", + promotion_id: "promotion-1", + }, + }, { + id: "claim.logical", + text: "A conflicting personal draft", + kind: "decision", + session: "claude-session:session-2", + source: "session", + freshness: "active", + componentIds: ["component.other"], + flowIds: [], + createdAt: "2026-07-28T00:01:00.000Z", + memoryCommitId: "commit-2", + provenance: { + version_id: "version-2", + scope_kind: "working", + author_github_login: "bob", + memory_commit_id: "commit-2", + memory_commit_state: "active", + commit_role: "direct", }, }], claimsTimeline: { @@ -236,15 +430,47 @@ const html = buildGraphViewHtmlFromData({ }, }); assert.match(html, /data-version-id="version-1"/); +assert.match(html, /data-version-id="version-2"/); assert.match(html, /data-author="alice"/); assert.match(html, /provenance-badge[^>]*>repair { const url = new URL(incoming.url, "http://127.0.0.1"); @@ -262,35 +488,84 @@ const server = createServer(async (incoming, response) => { return; } assert.equal(incoming.headers.authorization, "Bearer github-oidc-token"); + assert.match(incoming.headers["x-greplica-capabilities"], /oidc-reconciliation-v1/); + assert.equal(incoming.headers["x-greplica-client-version"], "0.2.1"); if (url.pathname.endsWith("/memory/reconcile/candidate")) { candidateCalls += 1; assert.equal(url.searchParams.get("merge_sha"), mergeSha); - if (url.searchParams.has("exclude_memory_pr")) { - assert.deepEqual(url.searchParams.getAll("exclude_memory_pr"), ["memory-pr-1"]); + const excluded = url.searchParams.getAll("exclude_memory_pr"); + if (excluded.length === 3) { + assert.deepEqual(excluded, ["memory-pr-1", "memory-pr-2", "memory-pr-3"]); send(404, { message: "No Memory PR is ready for this merged checkout." }); return; } + if (excluded.length === 2) { + assert.deepEqual(excluded, ["memory-pr-1", "memory-pr-2"]); + send(200, { + memory_pr_id: "memory-pr-3", + merge_sha: mergeSha, + memory_commit_ids: ["commit-3"], + commits: [{ + memory_commit_id: "commit-3", + git_head: featureSha, + head_repository: "example/project", + proof_mode: "default_ancestry", + }], + claim_versions: [], + }); + return; + } + if (excluded.length === 1) { + assert.deepEqual(excluded, ["memory-pr-1"]); + send(200, { + memory_pr_id: "memory-pr-2", + merge_sha: mergeSha, + memory_commit_ids: ["commit-2"], + commits: [{ + memory_commit_id: "commit-2", + git_head: baseSha, + head_repository: "example/project", + proof_mode: "default_ancestry", + }], + claim_versions: [], + }); + return; + } send(200, { memory_pr_id: "memory-pr-1", merge_sha: mergeSha, memory_commit_ids: ["commit-1"], - commits: [{ memory_commit_id: "commit-1", git_head: mergeSha, head_repository: "example/project" }], + commits: [{ + memory_commit_id: "commit-1", + git_head: featureSha, + head_repository: "example/project", + proof_mode: "pr_head", + code_pr_number: 7, + verified_head_sha: featureSha, + }], claim_versions: [{ version_id: "version-1", + baseline_fingerprints: versionOneBaseline, claim: { id: "claim.logical", kind: "fact", text: "Version-keyed audit", truth: "code_verified", intent: "intended", + code_anchors: [versionOneAnchor], }, }], }); return; } if (url.pathname.endsWith("/memory/reconcile/attest")) { - attestation = body; - send(200, { accepted: true, memory_pr_id: "memory-pr-1", job_id: "job-1", state: "queued" }); + attestations.push(body); + send(200, { + accepted: true, + memory_pr_id: body.memory_pr_id, + job_id: `job-${attestations.length}`, + state: "queued", + }); return; } send(404, { message: `Unexpected ${incoming.method} ${url.pathname}` }); @@ -322,13 +597,72 @@ try { GITHUB_RUN_ID: "123", }); assert.match(result.stdout, /"accepted": true/); - assert.match(result.stdout, /"reconciliation_count": 1/); - assert.equal(candidateCalls, 2); - assert.equal(attestation.audit_key, "version_id"); - assert.deepEqual(attestation.memory_commit_ids, ["commit-1"]); - assert.deepEqual(attestation.ancestry, [{ memory_commit_id: "commit-1", git_head: mergeSha, is_ancestor: true }]); - assert.equal(attestation.anchor_audit.result.missing_anchors[0].claim_id, "version-1"); - assert.equal(attestation.repository, "example/project"); + assert.match(result.stdout, /"reconciliation_count": 2/); + assert.match(result.stdout, /"skipped_count": 1/); + assert.equal(candidateCalls, 4); + assert.equal(attestations[0].audit_key, "version_id"); + assert.deepEqual(attestations[0].memory_commit_ids, ["commit-1"]); + assert.deepEqual(attestations[0].ancestry, [{ + memory_commit_id: "commit-1", + git_head: featureSha, + proof_mode: "pr_head", + verified_head_sha: featureSha, + is_ancestor: true, + }]); + assert.equal(attestations[0].anchor_audit.result.drifted[0].claim_id, "version-1"); + assert.notEqual( + attestations[0].anchor_audit.fingerprints["version-1"]["example.ts#example"], + versionOneBaseline["example.ts#example"], + ); + assert.equal(attestations[0].repository, "example/project"); + assert.deepEqual(attestations[1].ancestry, [{ + memory_commit_id: "commit-2", + git_head: baseSha, + proof_mode: "default_ancestry", + is_ancestor: true, + }]); + assert.equal( + exec("git", ["-C", repoRoot, "rev-parse", "FETCH_HEAD"]).trim(), + featureSha, + "PR-head proof must fetch the base repository pull ref even when the object already exists", + ); + + exec("git", [ + "-C", + repoRoot, + "push", + "--quiet", + "--force", + "origin", + `${mergeSha}:refs/pull/7/head`, + ]); + const attestationsBeforeMismatch = attestations.length; + await assert.rejects( + run(process.execPath, [ + cliPath, + "memory", + "reconcile", + "--managed-repo", + installation.managedRepoId, + "--merge-sha", + mergeSha, + "--api-url", + apiUrl, + ], repoRoot, { + ...process.env, + ACTIONS_ID_TOKEN_REQUEST_URL: `${apiUrl}/oidc?api-version=1`, + ACTIONS_ID_TOKEN_REQUEST_TOKEN: "oidc-request-token", + GITHUB_REPOSITORY: "example/project", + GITHUB_REF: "refs/heads/main", + GITHUB_RUN_ID: "124", + }), + /does not match verified head/, + ); + assert.equal( + attestations.length, + attestationsBeforeMismatch, + "a mismatched base-repository PR ref must never be attested", + ); } finally { await new Promise((resolve) => server.close(resolve)); } @@ -339,13 +673,29 @@ function exec(command, args) { return execFileSync(command, args, { encoding: "utf8" }); } -function jsonResponse(value) { +function jsonResponse(value, capabilities = true) { return new Response(JSON.stringify(value), { status: 200, - headers: { "content-type": "application/json" }, + headers: { + "content-type": "application/json", + ...(capabilities + ? { "x-greplica-capabilities": "personal-working-v1,graph-selectors-v1,memory-pr-v1" } + : {}), + }, }); } +function gitIsAncestor(repoRoot, ancestor, descendant) { + try { + execFileSync("git", ["-C", repoRoot, "merge-base", "--is-ancestor", ancestor, descendant], { + stdio: "ignore", + }); + return true; + } catch { + return false; + } +} + function run(command, args, cwd, env) { return new Promise((resolve, reject) => { const child = spawn(command, args, { cwd, env, stdio: ["ignore", "pipe", "pipe"] }); From f4908cb8efe27bffcab6a41be9e9dfa9427cd5b5 Mon Sep 17 00:00:00 2001 From: Kushal Date: Tue, 28 Jul 2026 19:25:01 -0700 Subject: [PATCH 06/27] ci: pin managed reconciliation action --- .github/workflows/reconcile.yml | 2 +- scripts/check-managed-collaboration.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/reconcile.yml b/.github/workflows/reconcile.yml index 19e23e9..95a78cc 100644 --- a/.github/workflows/reconcile.yml +++ b/.github/workflows/reconcile.yml @@ -32,7 +32,7 @@ jobs: # Keep this immutable. The managed service separately allowlists this # reusable workflow's signed job_workflow_sha. - name: Reconcile Memory PRs - uses: Autoloops/greplica@38e477bbc10ac01ff01d497e2011cfecb5e33897 + uses: Autoloops/greplica@b147dab2568227e96f2d3f14b16261e4042b44c3 with: managed-repo: ${{ inputs.managed-repo }} merge-sha: ${{ inputs.merge-sha }} diff --git a/scripts/check-managed-collaboration.js b/scripts/check-managed-collaboration.js index d7e1c72..eeaeaaa 100644 --- a/scripts/check-managed-collaboration.js +++ b/scripts/check-managed-collaboration.js @@ -45,7 +45,7 @@ assert.match(reusableWorkflow, /oidc-audience: greplica-managed/); assert.doesNotMatch(reusableWorkflow, /\$\{\{ inputs\.(?:api-url|oidc-audience) \}\}/); assert.match( reusableWorkflow, - /uses: Autoloops\/greplica@38e477bbc10ac01ff01d497e2011cfecb5e33897/, + /uses: Autoloops\/greplica@b147dab2568227e96f2d3f14b16261e4042b44c3/, ); assert.doesNotMatch(reusableWorkflow, /uses: Autoloops\/greplica@(main|refs\/heads\/|v\d)/); From 267e25545a07571506bfb5d60f63f7d6e9d6dba0 Mon Sep 17 00:00:00 2001 From: Kushal Date: Tue, 28 Jul 2026 19:57:22 -0700 Subject: [PATCH 07/27] fix: attest current default and pull request delta --- apps/cli/main.ts | 18 ++- apps/cli/reconcile-cli.ts | 157 ++++++++++++++++++++++--- libs/managed/protocol.ts | 14 ++- scripts/check-managed-cli.js | 70 +++++++++++ scripts/check-managed-collaboration.js | 85 ++++++++++++- 5 files changed, 319 insertions(+), 25 deletions(-) diff --git a/apps/cli/main.ts b/apps/cli/main.ts index 57461eb..5925d64 100644 --- a/apps/cli/main.ts +++ b/apps/cli/main.ts @@ -629,7 +629,11 @@ async function runMemoryPrContextCommand(args: string[], getContext: CommandCont const memoryPrId = positional.shift(); const query = positional.join(" ").trim(); if (memoryPrId === undefined || query.length === 0) throw new Error(usage("memoryPrContext")); - const result = await getContext().service.contextGraph(query, { base: "main", memory_pr_id: memoryPrId }); + const result = await getContext().service.contextGraph(query, { + base: "main", + working_users: [], + memory_pr_id: memoryPrId, + }); console.log(json ? JSON.stringify(result, null, 2) : renderGraphContextMarkdown(result)); } @@ -1443,7 +1447,7 @@ function printMemoryPrSummary(memoryPr: ManagedMemoryPr): void { console.log([ memoryPr.id, memoryPr.state, - `code-pr:#${memoryPr.code_pr.number}`, + memoryPr.code_pr === undefined ? "direct-default" : `code-pr:#${memoryPr.code_pr.number}`, memoryPr.contributor_logins.join(",") || "-", `${memoryPr.direct_commit_ids.length} direct`, `${memoryPr.dependency_commit_ids.length} dependencies`, @@ -1467,6 +1471,16 @@ function printPromotionCleanup(memoryPr: ManagedMemoryPr): void { function printMemoryStatus(status: ManagedMemoryStatus): void { console.log(`Reconciliation jobs: ${status.queued} queued, ${status.running} running, ${status.failed} failed`); + if (status.action_verified !== undefined) console.log(`Action ready: ${status.action_verified ? "yes" : "no"}`); + if (status.action_verified_at !== undefined) console.log(`Action verified: ${status.action_verified_at}`); + if (status.action_workflow_ref !== undefined) console.log(`Action workflow ref: ${status.action_workflow_ref}`); + if (status.action_workflow_sha !== undefined) console.log(`Action workflow SHA: ${status.action_workflow_sha}`); + if (status.repair_service !== undefined) { + console.log( + `Repair service: ${status.repair_service}` + + (status.repair_service_detail === undefined ? "" : ` (${status.repair_service_detail})`), + ); + } console.log(`Last sweep: ${status.last_sweep_at ?? "never"}`); console.log(`Last promotion: ${status.last_promotion_at ?? "never"}`); console.log(`Repair attempts: ${status.repair_attempts}`); diff --git a/apps/cli/reconcile-cli.ts b/apps/cli/reconcile-cli.ts index c616a0a..0d0e9e9 100644 --- a/apps/cli/reconcile-cli.ts +++ b/apps/cli/reconcile-cli.ts @@ -44,11 +44,12 @@ export async function runMemoryReconcile(args: string[]): Promise { }> = []; const skipped: Array<{ memory_pr_id: string; - reason: "git_head_not_ancestor"; + reason: "git_head_not_ancestor" | "git_head_not_in_pr_delta"; memory_commit_ids: string[]; }> = []; const excludedMemoryPrIds: string[] = []; while (true) { + assertCurrentRemoteDefaultHead(repoRoot, mergeSha, process.env.GITHUB_REF); const candidate = await reconciliationCandidate( apiUrl, managedRepoId, @@ -61,29 +62,69 @@ export async function runMemoryReconcile(args: string[]): Promise { throw new Error(`Managed reconciliation returned duplicate Memory PR ${candidate.memory_pr_id}.`); } verifyCandidate(candidate, mergeSha); - const ancestry = candidate.commits.map((commit) => { + const verifiedRanges = new Map(); + const proofs = candidate.commits.map((commit) => { const proofMode = commit.proof_mode ?? "default_ancestry"; - const targetSha = proofMode === "pr_head" - ? verifiedPrHead(repoRoot, commit.code_pr_number, commit.verified_head_sha) - : mergeSha; + let targetSha = mergeSha; + let verifiedBaseSha: string | undefined; + let mergeBaseSha: string | undefined; + if (proofMode === "pr_head") { + const key = [ + commit.code_pr_number, + commit.verified_head_sha, + commit.verified_base_sha ?? "", + ].join(":"); + let range = verifiedRanges.get(key); + if (range === undefined) { + range = verifiedPrRange( + repoRoot, + commit.code_pr_number, + commit.verified_head_sha, + commit.verified_base_sha, + ); + verifiedRanges.set(key, range); + } + targetSha = range.headSha; + verifiedBaseSha = range.baseSha; + mergeBaseSha = range.mergeBaseSha; + } + const isAncestorOfTarget = isAncestor(repoRoot, commit.git_head, targetSha); + const isInPrDelta = proofMode !== "pr_head" || + mergeBaseSha === undefined || + ( + isAncestorOfTarget && + commit.git_head.toLowerCase() !== mergeBaseSha && + isAncestor(repoRoot, mergeBaseSha, commit.git_head) + ); return { - memory_commit_id: commit.memory_commit_id, - git_head: commit.git_head, - proof_mode: proofMode, - ...(proofMode === "pr_head" ? { verified_head_sha: targetSha } : {}), - is_ancestor: isAncestor(repoRoot, commit.git_head, targetSha), + ancestry: { + memory_commit_id: commit.memory_commit_id, + git_head: commit.git_head, + proof_mode: proofMode, + ...(proofMode === "pr_head" ? { + verified_head_sha: targetSha, + ...(verifiedBaseSha === undefined ? {} : { verified_base_sha: verifiedBaseSha }), + } : {}), + is_ancestor: isAncestorOfTarget, + }, + isInPrDelta, }; }); - const nonAncestors = ancestry.filter((entry) => !entry.is_ancestor); - if (nonAncestors.length > 0) { + const nonAncestors = proofs.filter((proof) => !proof.ancestry.is_ancestor); + const outsidePrDelta = proofs.filter((proof) => + proof.ancestry.is_ancestor && !proof.isInPrDelta + ); + if (nonAncestors.length > 0 || outsidePrDelta.length > 0) { + const failures = nonAncestors.length > 0 ? nonAncestors : outsidePrDelta; skipped.push({ memory_pr_id: candidate.memory_pr_id, - reason: "git_head_not_ancestor", - memory_commit_ids: nonAncestors.map((entry) => entry.memory_commit_id), + reason: nonAncestors.length > 0 ? "git_head_not_ancestor" : "git_head_not_in_pr_delta", + memory_commit_ids: failures.map((proof) => proof.ancestry.memory_commit_id), }); excludedMemoryPrIds.push(candidate.memory_pr_id); continue; } + const ancestry = proofs.map((proof) => proof.ancestry); const auditClaims = candidate.claim_versions.map(({ version_id, claim }) => ({ ...claim, id: version_id })); const baselineFingerprints = new Map( candidate.claim_versions @@ -102,6 +143,11 @@ export async function runMemoryReconcile(args: string[]): Promise { const values = await fingerprintClaimAnchors(repoRoot, claim.code_anchors); if (Object.keys(values).length > 0) fingerprints[claim.id] = values; } + const observedDefaultHeadSha = assertCurrentRemoteDefaultHead( + repoRoot, + mergeSha, + process.env.GITHUB_REF, + ); const attestation: ManagedReconciliationAttestation = { managed_repo_id: managedRepoId, repository, @@ -111,6 +157,7 @@ export async function runMemoryReconcile(args: string[]): Promise { ancestry, audit_key: "version_id", anchor_audit: { result, fingerprints }, + observed_default_head_sha: observedDefaultHeadSha, ref: process.env.GITHUB_REF, run_id: process.env.GITHUB_RUN_ID, run_attempt: process.env.GITHUB_RUN_ATTEMPT, @@ -181,6 +228,9 @@ function verifyCandidate(candidate: ManagedReconciliationCandidate, mergeSha: st (commit.code_pr_number === undefined || commit.verified_head_sha === undefined)) { throw new Error(`Memory commit ${commit.memory_commit_id} is missing its verified PR-head proof.`); } + if (commit.verified_base_sha !== undefined && !/^[0-9a-f]{40}$/i.test(commit.verified_base_sha)) { + throw new Error(`Memory commit ${commit.memory_commit_id} has an invalid verified PR base SHA.`); + } } } @@ -195,11 +245,18 @@ function isAncestor(repoRoot: string, gitHead: string, mergeSha: string): boolea } } -function verifiedPrHead( +interface VerifiedPrRange { + headSha: string; + baseSha?: string; + mergeBaseSha?: string; +} + +function verifiedPrRange( repoRoot: string, pullRequestNumber: number | undefined, verifiedHeadSha: string | undefined, -): string { + verifiedBaseSha: string | undefined, +): VerifiedPrRange { if (pullRequestNumber === undefined || !Number.isSafeInteger(pullRequestNumber) || pullRequestNumber < 1) { throw new Error("PR-head proof is missing a valid code PR number."); } @@ -227,7 +284,28 @@ function verifiedPrHead( if (!hasGitCommit(repoRoot, verifiedHeadSha)) { throw new Error(`Verified head ${verifiedHeadSha} for PR #${pullRequestNumber} is unavailable.`); } - return verifiedHeadSha.toLowerCase(); + const headSha = verifiedHeadSha.toLowerCase(); + if (verifiedBaseSha === undefined) return { headSha }; + if (!/^[0-9a-f]{40}$/i.test(verifiedBaseSha)) { + throw new Error(`PR #${pullRequestNumber} proof is missing a full verified base SHA.`); + } + const baseSha = verifiedBaseSha.toLowerCase(); + if (!hasGitCommit(repoRoot, baseSha)) { + throw new Error(`Verified base ${verifiedBaseSha} for PR #${pullRequestNumber} is unavailable.`); + } + let mergeBaseSha: string; + try { + mergeBaseSha = execFileSync("git", ["-C", repoRoot, "merge-base", baseSha, headSha], { + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }).trim().toLowerCase(); + } catch { + throw new Error(`Could not establish the verified commit range for PR #${pullRequestNumber}.`); + } + if (!/^[0-9a-f]{40}$/.test(mergeBaseSha)) { + throw new Error(`PR #${pullRequestNumber} has an invalid verified merge base.`); + } + return { headSha, baseSha, mergeBaseSha }; } function hasGitCommit(repoRoot: string, sha: string): boolean { @@ -239,6 +317,51 @@ function hasGitCommit(repoRoot: string, sha: string): boolean { } } +function assertCurrentRemoteDefaultHead( + repoRoot: string, + mergeSha: string, + githubRef: string | undefined, +): string { + if (githubRef === undefined || !githubRef.startsWith("refs/heads/")) { + throw new Error("Reconciliation requires GITHUB_REF to identify the default branch."); + } + try { + execFileSync("git", ["-C", repoRoot, "check-ref-format", githubRef], { stdio: "ignore" }); + } catch { + throw new Error(`Reconciliation default ref ${githubRef} is invalid.`); + } + const observedRef = "refs/greplica/reconciliation-default-head"; + try { + execFileSync( + "git", + [ + "-C", + repoRoot, + "fetch", + "--force", + "--no-tags", + "--no-write-fetch-head", + "origin", + `+${githubRef}:${observedRef}`, + ], + { stdio: "ignore" }, + ); + } catch { + throw new Error(`Could not fetch current default ref ${githubRef} from the caller repository.`); + } + const observed = execFileSync("git", ["-C", repoRoot, "rev-parse", observedRef], { + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }).trim().toLowerCase(); + if (observed !== mergeSha.toLowerCase()) { + throw new Error( + `Current remote default head ${observed} does not equal requested merge SHA ${mergeSha}; ` + + "historical workflow reruns cannot reconcile memory.", + ); + } + return observed; +} + function assertExactCheckout(repoRoot: string, mergeSha: string): void { const git = (arguments_: string[]): string => execFileSync("git", ["-C", repoRoot, ...arguments_], { encoding: "utf8", diff --git a/libs/managed/protocol.ts b/libs/managed/protocol.ts index 471b0a7..aaf70a1 100644 --- a/libs/managed/protocol.ts +++ b/libs/managed/protocol.ts @@ -595,7 +595,7 @@ export const PromotionCleanupSchema = Type.Object({ export const MemoryPrSchema = Type.Object({ id: Type.String(), - code_pr: CodePrReferenceSchema, + code_pr: Type.Optional(CodePrReferenceSchema), state: MemoryPrStateSchema, direct_commit_ids: Type.Array(Type.String()), dependency_commit_ids: Type.Array(Type.String()), @@ -611,6 +611,15 @@ export const MemoryStatusSchema = Type.Object({ queued: Type.Integer({ minimum: 0 }), running: Type.Integer({ minimum: 0 }), failed: Type.Integer({ minimum: 0 }), + action_verified: Type.Optional(Type.Boolean()), + action_verified_at: Type.Optional(Type.String({ format: "date-time" })), + action_workflow_ref: Type.Optional(Type.String()), + action_workflow_sha: Type.Optional(Type.String()), + repair_service: Type.Optional(Type.Union([ + Type.Literal("ready"), + Type.Literal("degraded"), + ])), + repair_service_detail: Type.Optional(Type.String()), last_sweep_at: Type.Optional(Type.String({ format: "date-time" })), last_promotion_at: Type.Optional(Type.String({ format: "date-time" })), repair_attempts: Type.Integer({ minimum: 0 }), @@ -636,6 +645,7 @@ export const ReconciliationCandidateSchema = Type.Object({ ])), code_pr_number: Type.Optional(Type.Integer({ minimum: 1 })), verified_head_sha: Type.Optional(Type.String({ minLength: 7 })), + verified_base_sha: Type.Optional(Type.String({ minLength: 7 })), }), { minItems: 1 }), claim_versions: Type.Array(Type.Object({ version_id: Type.String(), @@ -659,9 +669,11 @@ export const ReconciliationAttestationSchema = Type.Object({ Type.Literal("default_ancestry"), ])), verified_head_sha: Type.Optional(Type.String({ minLength: 7 })), + verified_base_sha: Type.Optional(Type.String({ minLength: 7 })), }), { minItems: 1 }), audit_key: Type.Literal("version_id"), anchor_audit: ProposalAnchorAuditSchema, + observed_default_head_sha: Type.Optional(Type.String({ minLength: 7 })), ref: Type.Optional(Type.String()), run_id: Type.Optional(Type.String()), run_attempt: Type.Optional(Type.String()), diff --git a/scripts/check-managed-cli.js b/scripts/check-managed-cli.js index 2151a7b..19b577a 100644 --- a/scripts/check-managed-cli.js +++ b/scripts/check-managed-cli.js @@ -29,6 +29,7 @@ const managedRepository = { let deviceStarts = 0; let requestCount = 0; let importedSnapshot; +let memoryPrContextBody; const server = createServer(async (request, response) => { requestCount += 1; @@ -134,6 +135,53 @@ const server = createServer(async (request, response) => { }); return; } + if (request.method === "POST" && request.url === `/v1/repos/${managedRepoId}/graph/context`) { + memoryPrContextBody = body; + send(200, { + query: body.query, + search_config_version: "test", + embedding_status: { checked_objects: 0, created: 0, reused: 0 }, + claims: [], + components: [], + flows: [], + ranked_results: [], + sources: [], + }, { "x-greplica-capabilities": "personal-working-v1,graph-selectors-v1,memory-pr-v1" }); + return; + } + if (request.method === "GET" && request.url === `/v1/repos/${managedRepoId}/memory/status`) { + send(200, { + queued: 0, + running: 0, + failed: 0, + action_verified: true, + action_verified_at: now, + action_workflow_ref: `Autoloops/greplica/.github/workflows/reconcile.yml@${"a".repeat(40)}`, + action_workflow_sha: "a".repeat(40), + repair_service: "degraded", + repair_service_detail: "repair proxy is not configured", + repair_attempts: 0, + repaired_commits: 0, + promoted_commits: 0, + quarantined_commits: 0, + cleared_working_commits: 0, + remaining_active_working_commits: 0, + }); + return; + } + if (request.method === "GET" && request.url === `/v1/repos/${managedRepoId}/memory-prs`) { + send(200, [{ + id: "direct-default-memory-pr", + state: "reconciling", + direct_commit_ids: ["direct-default-commit"], + dependency_commit_ids: [], + repair_commit_ids: [], + contributor_logins: ["contributor-1"], + created_at: now, + updated_at: now, + }]); + return; + } if (request.method === "POST" && request.url === `/v1/repos/${managedRepoId}/import`) { importedSnapshot = body; send(200, { @@ -243,6 +291,28 @@ try { db.close(); const managedGraph = await run(process.execPath, [cliPath, "graph", "read"], managedRepo, env); assert.match(managedGraph.stdout, /Current graph view: main \+ working/); + const memoryPrContext = await run(process.execPath, [ + cliPath, "memory", "pr", "context", "memory-pr-1", "authentication", "--json", + ], managedRepo, env); + assert.match(memoryPrContext.stdout, /"query": "authentication"/); + assert.deepEqual(memoryPrContextBody.view, { + base: "main", + working_users: [], + memory_pr_id: "memory-pr-1", + }, "Memory PR context must not include the caller's unrelated personal working scope"); + const memoryStatus = await run(process.execPath, [cliPath, "memory", "status"], managedRepo, env); + assert.match(memoryStatus.stdout, /Action ready: yes/); + assert.match(memoryStatus.stdout, new RegExp(`Action verified: ${now}`)); + assert.match(memoryStatus.stdout, /Action workflow ref: Autoloops\/greplica\/\.github\/workflows\/reconcile\.yml@/); + assert.match(memoryStatus.stdout, new RegExp(`Action workflow SHA: ${"a".repeat(40)}`)); + assert.match(memoryStatus.stdout, /Repair service: degraded \(repair proxy is not configured\)/); + const directDefaultMemoryPr = await run( + process.execPath, + [cliPath, "memory", "pr", "list"], + managedRepo, + env, + ); + assert.match(directDefaultMemoryPr.stdout, /direct-default-memory-pr\s+reconciling\s+direct-default/); const requestsBeforeHook = requestCount; const hook = await run(process.execPath, [cliPath, "hook", "ingest", "--platform", "codex"], managedRepo, env, JSON.stringify({ diff --git a/scripts/check-managed-collaboration.js b/scripts/check-managed-collaboration.js index eeaeaaa..e210389 100644 --- a/scripts/check-managed-collaboration.js +++ b/scripts/check-managed-collaboration.js @@ -494,9 +494,28 @@ const server = createServer(async (incoming, response) => { candidateCalls += 1; assert.equal(url.searchParams.get("merge_sha"), mergeSha); const excluded = url.searchParams.getAll("exclude_memory_pr"); + if (excluded.length === 4) { + assert.deepEqual(excluded, ["memory-pr-1", "memory-pr-2", "memory-pr-3", "memory-pr-4"]); + send(404, { message: "No Memory PR is ready for this merged checkout." }); + return; + } if (excluded.length === 3) { assert.deepEqual(excluded, ["memory-pr-1", "memory-pr-2", "memory-pr-3"]); - send(404, { message: "No Memory PR is ready for this merged checkout." }); + send(200, { + memory_pr_id: "memory-pr-4", + merge_sha: mergeSha, + memory_commit_ids: ["commit-common-base"], + commits: [{ + memory_commit_id: "commit-common-base", + git_head: baseSha, + head_repository: "example/project", + proof_mode: "pr_head", + code_pr_number: 7, + verified_head_sha: featureSha, + verified_base_sha: baseSha, + }], + claim_versions: [], + }); return; } if (excluded.length === 2) { @@ -534,7 +553,7 @@ const server = createServer(async (incoming, response) => { send(200, { memory_pr_id: "memory-pr-1", merge_sha: mergeSha, - memory_commit_ids: ["commit-1"], + memory_commit_ids: ["commit-1", "commit-dependency-1"], commits: [{ memory_commit_id: "commit-1", git_head: featureSha, @@ -542,6 +561,15 @@ const server = createServer(async (incoming, response) => { proof_mode: "pr_head", code_pr_number: 7, verified_head_sha: featureSha, + verified_base_sha: baseSha, + }, { + memory_commit_id: "commit-dependency-1", + git_head: featureSha, + head_repository: "example/project", + proof_mode: "pr_head", + code_pr_number: 7, + verified_head_sha: featureSha, + verified_base_sha: baseSha, }], claim_versions: [{ version_id: "version-1", @@ -598,17 +626,27 @@ try { }); assert.match(result.stdout, /"accepted": true/); assert.match(result.stdout, /"reconciliation_count": 2/); - assert.match(result.stdout, /"skipped_count": 1/); - assert.equal(candidateCalls, 4); + assert.match(result.stdout, /"skipped_count": 2/); + assert.match(result.stdout, /"reason": "git_head_not_in_pr_delta"/); + assert.equal(candidateCalls, 5); assert.equal(attestations[0].audit_key, "version_id"); - assert.deepEqual(attestations[0].memory_commit_ids, ["commit-1"]); + assert.deepEqual(attestations[0].memory_commit_ids, ["commit-1", "commit-dependency-1"]); assert.deepEqual(attestations[0].ancestry, [{ memory_commit_id: "commit-1", git_head: featureSha, proof_mode: "pr_head", verified_head_sha: featureSha, + verified_base_sha: baseSha, + is_ancestor: true, + }, { + memory_commit_id: "commit-dependency-1", + git_head: featureSha, + proof_mode: "pr_head", + verified_head_sha: featureSha, + verified_base_sha: baseSha, is_ancestor: true, }]); + assert.equal(attestations[0].observed_default_head_sha, mergeSha); assert.equal(attestations[0].anchor_audit.result.drifted[0].claim_id, "version-1"); assert.notEqual( attestations[0].anchor_audit.fingerprints["version-1"]["example.ts#example"], @@ -663,6 +701,43 @@ try { attestationsBeforeMismatch, "a mismatched base-repository PR ref must never be attested", ); + + const advancedDefaultSha = exec( + "git", + ["-C", repoRoot, "commit-tree", `${mergeSha}^{tree}`, "-p", mergeSha, "-m", "advance default"], + ).trim(); + exec("git", [ + "-C", + repoRoot, + "push", + "--quiet", + "origin", + `${advancedDefaultSha}:refs/heads/${defaultBranch}`, + ]); + const candidateCallsBeforeReplay = candidateCalls; + await assert.rejects( + run(process.execPath, [ + cliPath, + "memory", + "reconcile", + "--managed-repo", + installation.managedRepoId, + "--merge-sha", + mergeSha, + "--api-url", + apiUrl, + ], repoRoot, { + ...process.env, + ACTIONS_ID_TOKEN_REQUEST_URL: `${apiUrl}/oidc?api-version=1`, + ACTIONS_ID_TOKEN_REQUEST_TOKEN: "oidc-request-token", + GITHUB_REPOSITORY: "example/project", + GITHUB_REF: "refs/heads/main", + GITHUB_RUN_ID: "125", + }), + /historical workflow reruns cannot reconcile memory/, + ); + assert.equal(candidateCalls, candidateCallsBeforeReplay, + "a historical workflow rerun must fail before requesting a reconciliation candidate"); } finally { await new Promise((resolve) => server.close(resolve)); } From e172a68666d67115cd9445ba8b76a8550b46b281 Mon Sep 17 00:00:00 2001 From: Kushal Date: Tue, 28 Jul 2026 19:57:51 -0700 Subject: [PATCH 08/27] ci: repin managed reconciliation action --- .github/workflows/reconcile.yml | 2 +- scripts/check-managed-collaboration.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/reconcile.yml b/.github/workflows/reconcile.yml index 95a78cc..deed223 100644 --- a/.github/workflows/reconcile.yml +++ b/.github/workflows/reconcile.yml @@ -32,7 +32,7 @@ jobs: # Keep this immutable. The managed service separately allowlists this # reusable workflow's signed job_workflow_sha. - name: Reconcile Memory PRs - uses: Autoloops/greplica@b147dab2568227e96f2d3f14b16261e4042b44c3 + uses: Autoloops/greplica@267e25545a07571506bfb5d60f63f7d6e9d6dba0 with: managed-repo: ${{ inputs.managed-repo }} merge-sha: ${{ inputs.merge-sha }} diff --git a/scripts/check-managed-collaboration.js b/scripts/check-managed-collaboration.js index e210389..a8ea047 100644 --- a/scripts/check-managed-collaboration.js +++ b/scripts/check-managed-collaboration.js @@ -45,7 +45,7 @@ assert.match(reusableWorkflow, /oidc-audience: greplica-managed/); assert.doesNotMatch(reusableWorkflow, /\$\{\{ inputs\.(?:api-url|oidc-audience) \}\}/); assert.match( reusableWorkflow, - /uses: Autoloops\/greplica@b147dab2568227e96f2d3f14b16261e4042b44c3/, + /uses: Autoloops\/greplica@267e25545a07571506bfb5d60f63f7d6e9d6dba0/, ); assert.doesNotMatch(reusableWorkflow, /uses: Autoloops\/greplica@(main|refs\/heads\/|v\d)/); From b2f120919fc3b0d693b6fafaa76a39a195078a81 Mon Sep 17 00:00:00 2001 From: Kushal Date: Tue, 28 Jul 2026 20:01:18 -0700 Subject: [PATCH 09/27] fix: persist rejected reconciliation proofs --- apps/cli/reconcile-cli.ts | 62 +++++++++++- libs/managed/protocol.ts | 52 +++++++--- scripts/check-managed-collaboration.js | 125 ++++++++++++++++++++----- 3 files changed, 199 insertions(+), 40 deletions(-) diff --git a/apps/cli/reconcile-cli.ts b/apps/cli/reconcile-cli.ts index 0d0e9e9..14cb452 100644 --- a/apps/cli/reconcile-cli.ts +++ b/apps/cli/reconcile-cli.ts @@ -7,6 +7,8 @@ import type { ManagedReconciliationAttestation, ManagedReconciliationAttestationResult, ManagedReconciliationCandidate, + ManagedReconciliationRejection, + ManagedReconciliationRejectionResult, } from "../../libs/managed/protocol.js"; import { managedCapabilitiesHeader, @@ -46,9 +48,16 @@ export async function runMemoryReconcile(args: string[]): Promise { memory_pr_id: string; reason: "git_head_not_ancestor" | "git_head_not_in_pr_delta"; memory_commit_ids: string[]; + response: ManagedReconciliationRejectionResult; }> = []; const excludedMemoryPrIds: string[] = []; + const rejectedGenerations = new Set(); + let candidateIterations = 0; while (true) { + candidateIterations += 1; + if (candidateIterations > 1_000) { + throw new Error("Managed reconciliation exceeded its bounded candidate iteration limit."); + } assertCurrentRemoteDefaultHead(repoRoot, mergeSha, process.env.GITHUB_REF); const candidate = await reconciliationCandidate( apiUrl, @@ -114,17 +123,62 @@ export async function runMemoryReconcile(args: string[]): Promise { const outsidePrDelta = proofs.filter((proof) => proof.ancestry.is_ancestor && !proof.isInPrDelta ); + const ancestry = proofs.map((proof) => proof.ancestry); if (nonAncestors.length > 0 || outsidePrDelta.length > 0) { const failures = nonAncestors.length > 0 ? nonAncestors : outsidePrDelta; + const reason = nonAncestors.length > 0 ? "git_head_not_ancestor" as const : "git_head_not_in_pr_delta" as const; + const rejectedMemoryCommitIds = failures.map((proof) => proof.ancestry.memory_commit_id); + const generationKey = JSON.stringify({ + memory_pr_id: candidate.memory_pr_id, + memory_commit_ids: [...candidate.memory_commit_ids].sort(), + rejected_memory_commit_ids: [...rejectedMemoryCommitIds].sort(), + reason, + ancestry, + }); + if (rejectedGenerations.has(generationKey)) { + throw new Error( + `Managed reconciliation returned rejected Memory PR generation ${candidate.memory_pr_id} again.`, + ); + } + rejectedGenerations.add(generationKey); + const observedDefaultHeadSha = assertCurrentRemoteDefaultHead( + repoRoot, + mergeSha, + process.env.GITHUB_REF, + ); + const rejection: ManagedReconciliationRejection = { + managed_repo_id: managedRepoId, + repository, + merge_sha: mergeSha, + memory_pr_id: candidate.memory_pr_id, + memory_commit_ids: candidate.memory_commit_ids, + rejected_memory_commit_ids: rejectedMemoryCommitIds, + ancestry, + reason, + observed_default_head_sha: observedDefaultHeadSha, + ref: process.env.GITHUB_REF, + run_id: process.env.GITHUB_RUN_ID, + run_attempt: process.env.GITHUB_RUN_ATTEMPT, + }; + const response = await jsonRequest( + `${apiUrl}/v1/repos/${encodeURIComponent(managedRepoId)}/memory/reconcile/reject`, + { + method: "POST", + headers: { ...headers, "content-type": "application/json" }, + body: JSON.stringify(rejection), + }, + ); + if (!response.accepted || response.memory_pr_id !== candidate.memory_pr_id) { + throw new Error(`Managed service did not persist rejected Memory PR ${candidate.memory_pr_id}.`); + } skipped.push({ memory_pr_id: candidate.memory_pr_id, - reason: nonAncestors.length > 0 ? "git_head_not_ancestor" : "git_head_not_in_pr_delta", - memory_commit_ids: failures.map((proof) => proof.ancestry.memory_commit_id), + reason, + memory_commit_ids: rejectedMemoryCommitIds, + response, }); - excludedMemoryPrIds.push(candidate.memory_pr_id); continue; } - const ancestry = proofs.map((proof) => proof.ancestry); const auditClaims = candidate.claim_versions.map(({ version_id, claim }) => ({ ...claim, id: version_id })); const baselineFingerprints = new Map( candidate.claim_versions diff --git a/libs/managed/protocol.ts b/libs/managed/protocol.ts index aaf70a1..3d9f559 100644 --- a/libs/managed/protocol.ts +++ b/libs/managed/protocol.ts @@ -654,23 +654,25 @@ export const ReconciliationCandidateSchema = Type.Object({ })), }); +export const ReconciliationProofSchema = Type.Object({ + memory_commit_id: Type.String(), + git_head: Type.String({ minLength: 7 }), + is_ancestor: Type.Boolean(), + proof_mode: Type.Optional(Type.Union([ + Type.Literal("pr_head"), + Type.Literal("default_ancestry"), + ])), + verified_head_sha: Type.Optional(Type.String({ minLength: 7 })), + verified_base_sha: Type.Optional(Type.String({ minLength: 7 })), +}); + export const ReconciliationAttestationSchema = Type.Object({ managed_repo_id: Type.String({ format: "uuid" }), repository: Type.String({ minLength: 3 }), merge_sha: Type.String({ minLength: 7 }), memory_pr_id: Type.String(), memory_commit_ids: Type.Array(Type.String(), { minItems: 1, uniqueItems: true }), - ancestry: Type.Array(Type.Object({ - memory_commit_id: Type.String(), - git_head: Type.String({ minLength: 7 }), - is_ancestor: Type.Boolean(), - proof_mode: Type.Optional(Type.Union([ - Type.Literal("pr_head"), - Type.Literal("default_ancestry"), - ])), - verified_head_sha: Type.Optional(Type.String({ minLength: 7 })), - verified_base_sha: Type.Optional(Type.String({ minLength: 7 })), - }), { minItems: 1 }), + ancestry: Type.Array(ReconciliationProofSchema, { minItems: 1 }), audit_key: Type.Literal("version_id"), anchor_audit: ProposalAnchorAuditSchema, observed_default_head_sha: Type.Optional(Type.String({ minLength: 7 })), @@ -686,6 +688,31 @@ export const ReconciliationAttestationResultSchema = Type.Object({ state: Type.Optional(ReconciliationJobStateSchema), }); +export const ReconciliationRejectionSchema = Type.Object({ + managed_repo_id: Type.String({ format: "uuid" }), + repository: Type.String({ minLength: 3 }), + merge_sha: Type.String({ minLength: 7 }), + memory_pr_id: Type.String(), + memory_commit_ids: Type.Array(Type.String(), { minItems: 1, uniqueItems: true }), + rejected_memory_commit_ids: Type.Array(Type.String(), { minItems: 1, uniqueItems: true }), + ancestry: Type.Array(ReconciliationProofSchema, { minItems: 1 }), + reason: Type.Union([ + Type.Literal("git_head_not_ancestor"), + Type.Literal("git_head_not_in_pr_delta"), + ]), + observed_default_head_sha: Type.String({ minLength: 7 }), + ref: Type.Optional(Type.String()), + run_id: Type.Optional(Type.String()), + run_attempt: Type.Optional(Type.String()), +}); + +export const ReconciliationRejectionResultSchema = Type.Object({ + accepted: Type.Boolean(), + memory_pr_id: Type.String(), + removed_commit_ids: Type.Optional(Type.Array(Type.String())), + remaining_commit_ids: Type.Optional(Type.Array(Type.String())), +}); + export const routeSchemas = { authDeviceStart: route(Type.Object({}), Type.Object({ device_code: Type.String(), @@ -784,6 +811,7 @@ export const routeSchemas = { ReconciliationCandidateSchema, ), reconciliationAttest: route(ReconciliationAttestationSchema, ReconciliationAttestationResultSchema), + reconciliationReject: route(ReconciliationRejectionSchema, ReconciliationRejectionResultSchema), repoImport: route(Type.Object({ graph: GraphReadSchema, anchor_audit: ProposalAnchorAuditSchema, @@ -814,6 +842,8 @@ export type ManagedPromotionCleanup = Static; export type ManagedReconciliationAttestation = Static; export type ManagedReconciliationCandidate = Static; export type ManagedReconciliationAttestationResult = Static; +export type ManagedReconciliationRejection = Static; +export type ManagedReconciliationRejectionResult = Static; function route(request: TRequest, response: TResponse) { return { request, response, error: ManagedErrorSchema } as const; diff --git a/scripts/check-managed-collaboration.js b/scripts/check-managed-collaboration.js index a8ea047..d74cb77 100644 --- a/scripts/check-managed-collaboration.js +++ b/scripts/check-managed-collaboration.js @@ -471,7 +471,10 @@ const builtViewData = buildGraphViewData({ assert.equal(builtViewData.claims[0].provenance.version_id, "version-built"); const attestations = []; +const rejections = []; +const rejectedMemoryPrIds = new Set(); let candidateCalls = 0; +let forcePushMode = false; const server = createServer(async (incoming, response) => { const url = new URL(incoming.url, "http://127.0.0.1"); const chunks = []; @@ -494,24 +497,23 @@ const server = createServer(async (incoming, response) => { candidateCalls += 1; assert.equal(url.searchParams.get("merge_sha"), mergeSha); const excluded = url.searchParams.getAll("exclude_memory_pr"); - if (excluded.length === 4) { - assert.deepEqual(excluded, ["memory-pr-1", "memory-pr-2", "memory-pr-3", "memory-pr-4"]); - send(404, { message: "No Memory PR is ready for this merged checkout." }); - return; - } - if (excluded.length === 3) { - assert.deepEqual(excluded, ["memory-pr-1", "memory-pr-2", "memory-pr-3"]); + if (forcePushMode) { + assert.deepEqual(excluded, []); + if (rejectedMemoryPrIds.has("memory-pr-force-push")) { + send(404, { message: "No Memory PR is ready for this merged checkout." }); + return; + } send(200, { - memory_pr_id: "memory-pr-4", + memory_pr_id: "memory-pr-force-push", merge_sha: mergeSha, - memory_commit_ids: ["commit-common-base"], + memory_commit_ids: ["commit-force-pushed-away"], commits: [{ - memory_commit_id: "commit-common-base", - git_head: baseSha, + memory_commit_id: "commit-force-pushed-away", + git_head: featureSha, head_repository: "example/project", proof_mode: "pr_head", code_pr_number: 7, - verified_head_sha: featureSha, + verified_head_sha: mergeSha, verified_base_sha: baseSha, }], claim_versions: [], @@ -520,18 +522,40 @@ const server = createServer(async (incoming, response) => { } if (excluded.length === 2) { assert.deepEqual(excluded, ["memory-pr-1", "memory-pr-2"]); - send(200, { - memory_pr_id: "memory-pr-3", - merge_sha: mergeSha, - memory_commit_ids: ["commit-3"], - commits: [{ - memory_commit_id: "commit-3", - git_head: featureSha, - head_repository: "example/project", - proof_mode: "default_ancestry", - }], - claim_versions: [], - }); + if (!rejectedMemoryPrIds.has("memory-pr-3")) { + send(200, { + memory_pr_id: "memory-pr-3", + merge_sha: mergeSha, + memory_commit_ids: ["commit-3"], + commits: [{ + memory_commit_id: "commit-3", + git_head: featureSha, + head_repository: "example/project", + proof_mode: "default_ancestry", + }], + claim_versions: [], + }); + return; + } + if (!rejectedMemoryPrIds.has("memory-pr-4")) { + send(200, { + memory_pr_id: "memory-pr-4", + merge_sha: mergeSha, + memory_commit_ids: ["commit-common-base"], + commits: [{ + memory_commit_id: "commit-common-base", + git_head: baseSha, + head_repository: "example/project", + proof_mode: "pr_head", + code_pr_number: 7, + verified_head_sha: featureSha, + verified_base_sha: baseSha, + }], + claim_versions: [], + }); + return; + } + send(404, { message: "No Memory PR is ready for this merged checkout." }); return; } if (excluded.length === 1) { @@ -596,6 +620,17 @@ const server = createServer(async (incoming, response) => { }); return; } + if (url.pathname.endsWith("/memory/reconcile/reject")) { + rejections.push(body); + rejectedMemoryPrIds.add(body.memory_pr_id); + send(200, { + accepted: true, + memory_pr_id: body.memory_pr_id, + removed_commit_ids: body.rejected_memory_commit_ids, + remaining_commit_ids: [], + }); + return; + } send(404, { message: `Unexpected ${incoming.method} ${url.pathname}` }); }); await new Promise((resolve, reject) => { @@ -629,6 +664,14 @@ try { assert.match(result.stdout, /"skipped_count": 2/); assert.match(result.stdout, /"reason": "git_head_not_in_pr_delta"/); assert.equal(candidateCalls, 5); + assert.equal(rejections.length, 2); + assert.equal(rejections[0].memory_pr_id, "memory-pr-3"); + assert.equal(rejections[0].reason, "git_head_not_ancestor"); + assert.deepEqual(rejections[0].rejected_memory_commit_ids, ["commit-3"]); + assert.equal(rejections[1].memory_pr_id, "memory-pr-4"); + assert.equal(rejections[1].reason, "git_head_not_in_pr_delta"); + assert.deepEqual(rejections[1].rejected_memory_commit_ids, ["commit-common-base"]); + assert.ok(rejections.every((rejection) => rejection.observed_default_head_sha === mergeSha)); assert.equal(attestations[0].audit_key, "version_id"); assert.deepEqual(attestations[0].memory_commit_ids, ["commit-1", "commit-dependency-1"]); assert.deepEqual(attestations[0].ancestry, [{ @@ -702,6 +745,38 @@ try { "a mismatched base-repository PR ref must never be attested", ); + forcePushMode = true; + const candidateCallsBeforeDurableRejection = candidateCalls; + const rejectionsBeforeDurableRejection = rejections.length; + const forcePushResult = await run(process.execPath, [ + cliPath, + "memory", + "reconcile", + "--managed-repo", + installation.managedRepoId, + "--merge-sha", + mergeSha, + "--api-url", + apiUrl, + ], repoRoot, { + ...process.env, + ACTIONS_ID_TOKEN_REQUEST_URL: `${apiUrl}/oidc?api-version=1`, + ACTIONS_ID_TOKEN_REQUEST_TOKEN: "oidc-request-token", + GITHUB_REPOSITORY: "example/project", + GITHUB_REF: "refs/heads/main", + GITHUB_RUN_ID: "125", + }); + assert.match(forcePushResult.stdout, /"reconciliation_count": 0/); + assert.match(forcePushResult.stdout, /"skipped_count": 1/); + assert.match(forcePushResult.stdout, /"memory_pr_id": "memory-pr-force-push"/); + assert.equal(candidateCalls, candidateCallsBeforeDurableRejection + 2, + "the Action must refetch after the managed service removes a rejected selection"); + assert.equal(rejections.length, rejectionsBeforeDurableRejection + 1); + assert.deepEqual(rejections.at(-1).rejected_memory_commit_ids, ["commit-force-pushed-away"]); + assert.equal(rejections.at(-1).ancestry[0].verified_head_sha, mergeSha); + assert.equal(rejections.at(-1).ancestry[0].verified_base_sha, baseSha); + assert.equal(rejections.at(-1).ancestry[0].is_ancestor, false); + const advancedDefaultSha = exec( "git", ["-C", repoRoot, "commit-tree", `${mergeSha}^{tree}`, "-p", mergeSha, "-m", "advance default"], @@ -732,7 +807,7 @@ try { ACTIONS_ID_TOKEN_REQUEST_TOKEN: "oidc-request-token", GITHUB_REPOSITORY: "example/project", GITHUB_REF: "refs/heads/main", - GITHUB_RUN_ID: "125", + GITHUB_RUN_ID: "126", }), /historical workflow reruns cannot reconcile memory/, ); From 9704266b11498b717fd9eabac20669eccede5439 Mon Sep 17 00:00:00 2001 From: Kushal Date: Tue, 28 Jul 2026 20:01:30 -0700 Subject: [PATCH 10/27] ci: repin reconciliation proof reporting --- .github/workflows/reconcile.yml | 2 +- scripts/check-managed-collaboration.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/reconcile.yml b/.github/workflows/reconcile.yml index deed223..6d343a7 100644 --- a/.github/workflows/reconcile.yml +++ b/.github/workflows/reconcile.yml @@ -32,7 +32,7 @@ jobs: # Keep this immutable. The managed service separately allowlists this # reusable workflow's signed job_workflow_sha. - name: Reconcile Memory PRs - uses: Autoloops/greplica@267e25545a07571506bfb5d60f63f7d6e9d6dba0 + uses: Autoloops/greplica@b2f120919fc3b0d693b6fafaa76a39a195078a81 with: managed-repo: ${{ inputs.managed-repo }} merge-sha: ${{ inputs.merge-sha }} diff --git a/scripts/check-managed-collaboration.js b/scripts/check-managed-collaboration.js index d74cb77..a403aa8 100644 --- a/scripts/check-managed-collaboration.js +++ b/scripts/check-managed-collaboration.js @@ -45,7 +45,7 @@ assert.match(reusableWorkflow, /oidc-audience: greplica-managed/); assert.doesNotMatch(reusableWorkflow, /\$\{\{ inputs\.(?:api-url|oidc-audience) \}\}/); assert.match( reusableWorkflow, - /uses: Autoloops\/greplica@267e25545a07571506bfb5d60f63f7d6e9d6dba0/, + /uses: Autoloops\/greplica@b2f120919fc3b0d693b6fafaa76a39a195078a81/, ); assert.doesNotMatch(reusableWorkflow, /uses: Autoloops\/greplica@(main|refs\/heads\/|v\d)/); From 5eff39aeeed18a998335cef645422360d1080e8d Mon Sep 17 00:00:00 2001 From: Kushal Date: Tue, 28 Jul 2026 20:03:45 -0700 Subject: [PATCH 11/27] test: use detected default branch in reconciliation fixture --- scripts/check-managed-collaboration.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/scripts/check-managed-collaboration.js b/scripts/check-managed-collaboration.js index a403aa8..d195556 100644 --- a/scripts/check-managed-collaboration.js +++ b/scripts/check-managed-collaboration.js @@ -656,7 +656,7 @@ try { ACTIONS_ID_TOKEN_REQUEST_URL: `${apiUrl}/oidc?api-version=1`, ACTIONS_ID_TOKEN_REQUEST_TOKEN: "oidc-request-token", GITHUB_REPOSITORY: "example/project", - GITHUB_REF: "refs/heads/main", + GITHUB_REF: `refs/heads/${defaultBranch}`, GITHUB_RUN_ID: "123", }); assert.match(result.stdout, /"accepted": true/); @@ -734,7 +734,7 @@ try { ACTIONS_ID_TOKEN_REQUEST_URL: `${apiUrl}/oidc?api-version=1`, ACTIONS_ID_TOKEN_REQUEST_TOKEN: "oidc-request-token", GITHUB_REPOSITORY: "example/project", - GITHUB_REF: "refs/heads/main", + GITHUB_REF: `refs/heads/${defaultBranch}`, GITHUB_RUN_ID: "124", }), /does not match verified head/, @@ -763,7 +763,7 @@ try { ACTIONS_ID_TOKEN_REQUEST_URL: `${apiUrl}/oidc?api-version=1`, ACTIONS_ID_TOKEN_REQUEST_TOKEN: "oidc-request-token", GITHUB_REPOSITORY: "example/project", - GITHUB_REF: "refs/heads/main", + GITHUB_REF: `refs/heads/${defaultBranch}`, GITHUB_RUN_ID: "125", }); assert.match(forcePushResult.stdout, /"reconciliation_count": 0/); @@ -806,7 +806,7 @@ try { ACTIONS_ID_TOKEN_REQUEST_URL: `${apiUrl}/oidc?api-version=1`, ACTIONS_ID_TOKEN_REQUEST_TOKEN: "oidc-request-token", GITHUB_REPOSITORY: "example/project", - GITHUB_REF: "refs/heads/main", + GITHUB_REF: `refs/heads/${defaultBranch}`, GITHUB_RUN_ID: "126", }), /historical workflow reruns cannot reconcile memory/, From fb14d063e9394e8b25ab2cf7c00398cbe6d25be9 Mon Sep 17 00:00:00 2001 From: Kushal Date: Tue, 28 Jul 2026 20:40:48 -0700 Subject: [PATCH 12/27] fix: attest exact code merge ancestry --- README.md | 2 +- apps/cli/reconcile-cli.ts | 31 ++++++- libs/managed/protocol.ts | 8 +- scripts/check-managed-collaboration.js | 114 ++++++++++++++++++++++++- 4 files changed, 147 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index b8d12f4..ff8dc02 100644 --- a/README.md +++ b/README.md @@ -114,7 +114,7 @@ jobs: merge-sha: ${{ github.sha }} ``` -The push trigger reconciles immediately after default-branch updates; the hourly schedule retries unmatched and stalled work without a human memory-admin step. The workflow checks out `merge-sha` with full history, installs an immutable Greplica Action revision, audits every eligible Memory PR and its version-keyed anchors (including code drift from stored baselines), and attests through GitHub OIDC. It does not use a repository or model secret. Managed Greplica must allowlist both signed reusable-workflow claims for the pinned revision: +The push trigger reconciles immediately after default-branch updates; the hourly schedule retries unmatched and stalled work without a human memory-admin step. The workflow checks out `merge-sha` with full history, installs an immutable Greplica Action revision, proves that each code PR's recorded merge commit is contained in that exact checkout, audits every eligible Memory PR and its version-keyed anchors (including code drift from stored baselines), and attests through GitHub OIDC. It does not use a repository or model secret. Managed Greplica must allowlist both signed reusable-workflow claims for the pinned revision: - `job_workflow_ref=Autoloops/greplica/.github/workflows/reconcile.yml@` - `job_workflow_sha=` diff --git a/apps/cli/reconcile-cli.ts b/apps/cli/reconcile-cli.ts index 14cb452..20b2625 100644 --- a/apps/cli/reconcile-cli.ts +++ b/apps/cli/reconcile-cli.ts @@ -46,8 +46,10 @@ export async function runMemoryReconcile(args: string[]): Promise { }> = []; const skipped: Array<{ memory_pr_id: string; - reason: "git_head_not_ancestor" | "git_head_not_in_pr_delta"; + reason: "code_merge_not_ancestor" | "git_head_not_ancestor" | "git_head_not_in_pr_delta"; memory_commit_ids: string[]; + code_merge_sha?: string; + code_merge_is_ancestor?: boolean; response: ManagedReconciliationRejectionResult; }> = []; const excludedMemoryPrIds: string[] = []; @@ -71,6 +73,10 @@ export async function runMemoryReconcile(args: string[]): Promise { throw new Error(`Managed reconciliation returned duplicate Memory PR ${candidate.memory_pr_id}.`); } verifyCandidate(candidate, mergeSha); + const codeMergeSha = candidate.code_merge_sha?.toLowerCase(); + const codeMergeIsAncestor = codeMergeSha === undefined + ? undefined + : hasGitCommit(repoRoot, codeMergeSha) && isAncestor(repoRoot, codeMergeSha, mergeSha); const verifiedRanges = new Map(); const proofs = candidate.commits.map((commit) => { const proofMode = commit.proof_mode ?? "default_ancestry"; @@ -124,14 +130,22 @@ export async function runMemoryReconcile(args: string[]): Promise { proof.ancestry.is_ancestor && !proof.isInPrDelta ); const ancestry = proofs.map((proof) => proof.ancestry); - if (nonAncestors.length > 0 || outsidePrDelta.length > 0) { + if (codeMergeIsAncestor === false || nonAncestors.length > 0 || outsidePrDelta.length > 0) { const failures = nonAncestors.length > 0 ? nonAncestors : outsidePrDelta; - const reason = nonAncestors.length > 0 ? "git_head_not_ancestor" as const : "git_head_not_in_pr_delta" as const; - const rejectedMemoryCommitIds = failures.map((proof) => proof.ancestry.memory_commit_id); + const reason = codeMergeIsAncestor === false + ? "code_merge_not_ancestor" as const + : nonAncestors.length > 0 + ? "git_head_not_ancestor" as const + : "git_head_not_in_pr_delta" as const; + const rejectedMemoryCommitIds = reason === "code_merge_not_ancestor" + ? [] + : failures.map((proof) => proof.ancestry.memory_commit_id); const generationKey = JSON.stringify({ memory_pr_id: candidate.memory_pr_id, memory_commit_ids: [...candidate.memory_commit_ids].sort(), rejected_memory_commit_ids: [...rejectedMemoryCommitIds].sort(), + code_merge_sha: codeMergeSha, + code_merge_is_ancestor: codeMergeIsAncestor, reason, ancestry, }); @@ -150,6 +164,8 @@ export async function runMemoryReconcile(args: string[]): Promise { managed_repo_id: managedRepoId, repository, merge_sha: mergeSha, + code_merge_sha: codeMergeSha, + code_merge_is_ancestor: codeMergeIsAncestor, memory_pr_id: candidate.memory_pr_id, memory_commit_ids: candidate.memory_commit_ids, rejected_memory_commit_ids: rejectedMemoryCommitIds, @@ -175,6 +191,8 @@ export async function runMemoryReconcile(args: string[]): Promise { memory_pr_id: candidate.memory_pr_id, reason, memory_commit_ids: rejectedMemoryCommitIds, + code_merge_sha: codeMergeSha, + code_merge_is_ancestor: codeMergeIsAncestor, response, }); continue; @@ -206,6 +224,8 @@ export async function runMemoryReconcile(args: string[]): Promise { managed_repo_id: managedRepoId, repository, merge_sha: mergeSha, + code_merge_sha: codeMergeSha, + code_merge_is_ancestor: codeMergeIsAncestor, memory_pr_id: candidate.memory_pr_id, memory_commit_ids: candidate.memory_commit_ids, ancestry, @@ -269,6 +289,9 @@ function verifyCandidate(candidate: ManagedReconciliationCandidate, mergeSha: st throw new Error(`Managed reconciliation candidate is bound to ${candidate.merge_sha}, not ${mergeSha}.`); } if (candidate.memory_commit_ids.length === 0) throw new Error("Managed reconciliation candidate has no memory commits."); + if (candidate.code_merge_sha !== undefined && !/^[0-9a-f]{40}$/i.test(candidate.code_merge_sha)) { + throw new Error("Managed reconciliation candidate has an invalid code merge SHA."); + } const candidateIds = [...candidate.memory_commit_ids].sort(); const commitIds = candidate.commits.map((commit) => commit.memory_commit_id).sort(); if (JSON.stringify(candidateIds) !== JSON.stringify(commitIds)) { diff --git a/libs/managed/protocol.ts b/libs/managed/protocol.ts index 3d9f559..66a214f 100644 --- a/libs/managed/protocol.ts +++ b/libs/managed/protocol.ts @@ -633,6 +633,7 @@ export const MemoryStatusSchema = Type.Object({ export const ReconciliationCandidateSchema = Type.Object({ memory_pr_id: Type.String(), merge_sha: Type.String({ minLength: 7 }), + code_merge_sha: Type.Optional(Type.String({ pattern: "^[0-9a-fA-F]{40}$" })), memory_commit_ids: Type.Array(Type.String(), { minItems: 1, uniqueItems: true }), commits: Type.Array(Type.Object({ memory_commit_id: Type.String(), @@ -670,6 +671,8 @@ export const ReconciliationAttestationSchema = Type.Object({ managed_repo_id: Type.String({ format: "uuid" }), repository: Type.String({ minLength: 3 }), merge_sha: Type.String({ minLength: 7 }), + code_merge_sha: Type.Optional(Type.String({ pattern: "^[0-9a-fA-F]{40}$" })), + code_merge_is_ancestor: Type.Optional(Type.Boolean()), memory_pr_id: Type.String(), memory_commit_ids: Type.Array(Type.String(), { minItems: 1, uniqueItems: true }), ancestry: Type.Array(ReconciliationProofSchema, { minItems: 1 }), @@ -692,11 +695,14 @@ export const ReconciliationRejectionSchema = Type.Object({ managed_repo_id: Type.String({ format: "uuid" }), repository: Type.String({ minLength: 3 }), merge_sha: Type.String({ minLength: 7 }), + code_merge_sha: Type.Optional(Type.String({ pattern: "^[0-9a-fA-F]{40}$" })), + code_merge_is_ancestor: Type.Optional(Type.Boolean()), memory_pr_id: Type.String(), memory_commit_ids: Type.Array(Type.String(), { minItems: 1, uniqueItems: true }), - rejected_memory_commit_ids: Type.Array(Type.String(), { minItems: 1, uniqueItems: true }), + rejected_memory_commit_ids: Type.Array(Type.String(), { uniqueItems: true }), ancestry: Type.Array(ReconciliationProofSchema, { minItems: 1 }), reason: Type.Union([ + Type.Literal("code_merge_not_ancestor"), Type.Literal("git_head_not_ancestor"), Type.Literal("git_head_not_in_pr_delta"), ]), diff --git a/scripts/check-managed-collaboration.js b/scripts/check-managed-collaboration.js index d195556..60af4f3 100644 --- a/scripts/check-managed-collaboration.js +++ b/scripts/check-managed-collaboration.js @@ -67,8 +67,18 @@ exec("git", ["-C", repoRoot, "checkout", "--quiet", defaultBranch]); writeFileSync(join(repoRoot, "example.ts"), "export function example() { return 2; }\n"); exec("git", ["-C", repoRoot, "add", "example.ts"]); exec("git", ["-C", repoRoot, "commit", "--quiet", "-m", "squash feature"]); +const codeMergeSha = exec("git", ["-C", repoRoot, "rev-parse", "HEAD"]).trim(); +exec("git", ["-C", repoRoot, "commit", "--quiet", "--allow-empty", "-m", "default branch descendant"]); const mergeSha = exec("git", ["-C", repoRoot, "rev-parse", "HEAD"]).trim(); +assert.equal(gitIsAncestor(repoRoot, codeMergeSha, mergeSha), true, + "fixture must place the code PR merge before the exact default checkout"); assert.equal(gitIsAncestor(repoRoot, featureSha, mergeSha), false, "fixture must model a squash/rebase merge"); +const unrelatedCodeMergeSha = exec( + "git", + ["-C", repoRoot, "commit-tree", `${featureSha}^{tree}`, "-p", featureSha, "-m", "force-pushed merge"], +).trim(); +assert.equal(gitIsAncestor(repoRoot, unrelatedCodeMergeSha, mergeSha), false, + "fixture must include a code merge outside the exact default checkout"); const originRoot = join(temporary, "origin.git"); exec("git", ["init", "--quiet", "--bare", originRoot]); exec("git", ["-C", repoRoot, "remote", "add", "origin", originRoot]); @@ -475,6 +485,9 @@ const rejections = []; const rejectedMemoryPrIds = new Set(); let candidateCalls = 0; let forcePushMode = false; +let codeMergeFailureMode = false; +let codeMergeFailureSha = unrelatedCodeMergeSha; +let codeMergeFailureMemoryPrId = "memory-pr-code-merge-away"; const server = createServer(async (incoming, response) => { const url = new URL(incoming.url, "http://127.0.0.1"); const chunks = []; @@ -497,6 +510,27 @@ const server = createServer(async (incoming, response) => { candidateCalls += 1; assert.equal(url.searchParams.get("merge_sha"), mergeSha); const excluded = url.searchParams.getAll("exclude_memory_pr"); + if (codeMergeFailureMode) { + assert.deepEqual(excluded, []); + if (rejectedMemoryPrIds.has(codeMergeFailureMemoryPrId)) { + send(404, { message: "No Memory PR is ready for this merged checkout." }); + return; + } + send(200, { + memory_pr_id: codeMergeFailureMemoryPrId, + merge_sha: mergeSha, + code_merge_sha: codeMergeFailureSha, + memory_commit_ids: ["commit-code-merge-proof"], + commits: [{ + memory_commit_id: "commit-code-merge-proof", + git_head: baseSha, + head_repository: "example/project", + proof_mode: "default_ancestry", + }], + claim_versions: [], + }); + return; + } if (forcePushMode) { assert.deepEqual(excluded, []); if (rejectedMemoryPrIds.has("memory-pr-force-push")) { @@ -577,6 +611,7 @@ const server = createServer(async (incoming, response) => { send(200, { memory_pr_id: "memory-pr-1", merge_sha: mergeSha, + code_merge_sha: codeMergeSha, memory_commit_ids: ["commit-1", "commit-dependency-1"], commits: [{ memory_commit_id: "commit-1", @@ -627,7 +662,9 @@ const server = createServer(async (incoming, response) => { accepted: true, memory_pr_id: body.memory_pr_id, removed_commit_ids: body.rejected_memory_commit_ids, - remaining_commit_ids: [], + remaining_commit_ids: body.reason === "code_merge_not_ancestor" + ? body.memory_commit_ids + : [], }); return; } @@ -690,6 +727,9 @@ try { is_ancestor: true, }]); assert.equal(attestations[0].observed_default_head_sha, mergeSha); + assert.equal(attestations[0].code_merge_sha, codeMergeSha); + assert.equal(attestations[0].code_merge_is_ancestor, true, + "the Action must attest that the code PR merge is an ancestor of the later exact checkout"); assert.equal(attestations[0].anchor_audit.result.drifted[0].claim_id, "version-1"); assert.notEqual( attestations[0].anchor_audit.fingerprints["version-1"]["example.ts#example"], @@ -702,6 +742,10 @@ try { proof_mode: "default_ancestry", is_ancestor: true, }]); + assert.equal(Object.hasOwn(attestations[1], "code_merge_sha"), false, + "legacy candidates without code-merge proof must remain compatible"); + assert.equal(Object.hasOwn(attestations[1], "code_merge_is_ancestor"), false, + "legacy attestations must not send unsupported proof fields"); assert.equal( exec("git", ["-C", repoRoot, "rev-parse", "FETCH_HEAD"]).trim(), featureSha, @@ -777,6 +821,72 @@ try { assert.equal(rejections.at(-1).ancestry[0].verified_base_sha, baseSha); assert.equal(rejections.at(-1).ancestry[0].is_ancestor, false); + forcePushMode = false; + codeMergeFailureMode = true; + const candidateCallsBeforeCodeMergeRejection = candidateCalls; + const rejectionsBeforeCodeMergeRejection = rejections.length; + const codeMergeResult = await run(process.execPath, [ + cliPath, + "memory", + "reconcile", + "--managed-repo", + installation.managedRepoId, + "--merge-sha", + mergeSha, + "--api-url", + apiUrl, + ], repoRoot, { + ...process.env, + ACTIONS_ID_TOKEN_REQUEST_URL: `${apiUrl}/oidc?api-version=1`, + ACTIONS_ID_TOKEN_REQUEST_TOKEN: "oidc-request-token", + GITHUB_REPOSITORY: "example/project", + GITHUB_REF: `refs/heads/${defaultBranch}`, + GITHUB_RUN_ID: "126", + }); + assert.match(codeMergeResult.stdout, /"reconciliation_count": 0/); + assert.match(codeMergeResult.stdout, /"skipped_count": 1/); + assert.match(codeMergeResult.stdout, /"reason": "code_merge_not_ancestor"/); + assert.match(codeMergeResult.stdout, /"memory_commit_ids": \[\]/); + assert.equal(candidateCalls, candidateCallsBeforeCodeMergeRejection + 2, + "a candidate-level code-merge rejection must be persisted before candidate refetch"); + assert.equal(rejections.length, rejectionsBeforeCodeMergeRejection + 1); + const codeMergeRejection = rejections.at(-1); + assert.equal(codeMergeRejection.memory_pr_id, "memory-pr-code-merge-away"); + assert.equal(codeMergeRejection.code_merge_sha, unrelatedCodeMergeSha); + assert.equal(codeMergeRejection.code_merge_is_ancestor, false); + assert.equal(codeMergeRejection.reason, "code_merge_not_ancestor"); + assert.deepEqual(codeMergeRejection.rejected_memory_commit_ids, [], + "a code-merge failure must not be mislabeled as a per-memory-commit failure"); + assert.deepEqual(codeMergeRejection.memory_commit_ids, ["commit-code-merge-proof"]); + assert.equal(codeMergeRejection.ancestry[0].is_ancestor, true, + "independent memory-commit ancestry may pass while the code merge proof fails"); + + codeMergeFailureSha = "f".repeat(40); + codeMergeFailureMemoryPrId = "memory-pr-code-merge-missing"; + const missingCodeMergeResult = await run(process.execPath, [ + cliPath, + "memory", + "reconcile", + "--managed-repo", + installation.managedRepoId, + "--merge-sha", + mergeSha, + "--api-url", + apiUrl, + ], repoRoot, { + ...process.env, + ACTIONS_ID_TOKEN_REQUEST_URL: `${apiUrl}/oidc?api-version=1`, + ACTIONS_ID_TOKEN_REQUEST_TOKEN: "oidc-request-token", + GITHUB_REPOSITORY: "example/project", + GITHUB_REF: `refs/heads/${defaultBranch}`, + GITHUB_RUN_ID: "127", + }); + assert.match(missingCodeMergeResult.stdout, /"reason": "code_merge_not_ancestor"/); + assert.equal(rejections.at(-1).code_merge_sha, "f".repeat(40)); + assert.equal(rejections.at(-1).code_merge_is_ancestor, false, + "an unavailable code merge object must never be accepted as trusted ancestry"); + + codeMergeFailureMode = false; const advancedDefaultSha = exec( "git", ["-C", repoRoot, "commit-tree", `${mergeSha}^{tree}`, "-p", mergeSha, "-m", "advance default"], @@ -807,7 +917,7 @@ try { ACTIONS_ID_TOKEN_REQUEST_TOKEN: "oidc-request-token", GITHUB_REPOSITORY: "example/project", GITHUB_REF: `refs/heads/${defaultBranch}`, - GITHUB_RUN_ID: "126", + GITHUB_RUN_ID: "128", }), /historical workflow reruns cannot reconcile memory/, ); From af065885147f11f05e61a721cabdaed60b74eb62 Mon Sep 17 00:00:00 2001 From: Kushal Date: Tue, 28 Jul 2026 20:41:10 -0700 Subject: [PATCH 13/27] ci: repin exact code merge proof --- .github/workflows/reconcile.yml | 2 +- scripts/check-managed-collaboration.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/reconcile.yml b/.github/workflows/reconcile.yml index 6d343a7..1e31506 100644 --- a/.github/workflows/reconcile.yml +++ b/.github/workflows/reconcile.yml @@ -32,7 +32,7 @@ jobs: # Keep this immutable. The managed service separately allowlists this # reusable workflow's signed job_workflow_sha. - name: Reconcile Memory PRs - uses: Autoloops/greplica@b2f120919fc3b0d693b6fafaa76a39a195078a81 + uses: Autoloops/greplica@fb14d063e9394e8b25ab2cf7c00398cbe6d25be9 with: managed-repo: ${{ inputs.managed-repo }} merge-sha: ${{ inputs.merge-sha }} diff --git a/scripts/check-managed-collaboration.js b/scripts/check-managed-collaboration.js index 60af4f3..a7c9c45 100644 --- a/scripts/check-managed-collaboration.js +++ b/scripts/check-managed-collaboration.js @@ -45,7 +45,7 @@ assert.match(reusableWorkflow, /oidc-audience: greplica-managed/); assert.doesNotMatch(reusableWorkflow, /\$\{\{ inputs\.(?:api-url|oidc-audience) \}\}/); assert.match( reusableWorkflow, - /uses: Autoloops\/greplica@b2f120919fc3b0d693b6fafaa76a39a195078a81/, + /uses: Autoloops\/greplica@fb14d063e9394e8b25ab2cf7c00398cbe6d25be9/, ); assert.doesNotMatch(reusableWorkflow, /uses: Autoloops\/greplica@(main|refs\/heads\/|v\d)/); From e9d22a982f499159efc0a321fee21e858151efb4 Mon Sep 17 00:00:00 2001 From: Kushal Date: Tue, 28 Jul 2026 20:55:15 -0700 Subject: [PATCH 14/27] feat: preserve collaborative audit provenance --- README.md | 4 +- apps/cli/main.ts | 24 +- apps/cli/reconcile-cli.ts | 46 ++- .../code-anchors/fingerprint.ts | 10 +- libs/knowledge-graph/graph-context/render.ts | 15 +- .../graph-view/build-graph-view.ts | 307 +++++++++++++----- libs/managed/protocol.ts | 52 +++ scripts/check-anchor-drift.js | 9 +- scripts/check-graph-view-offline-browser.js | 109 +++++++ scripts/check-graph-view.js | 2 +- scripts/check-managed-cli.js | 78 +++++ scripts/check-managed-collaboration.js | 162 ++++++++- 12 files changed, 723 insertions(+), 95 deletions(-) diff --git a/README.md b/README.md index ff8dc02..467305c 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,8 @@ greplica graph context "How does authentication work?" \ --with-working bob ``` +Context and generated graph views retain stable user IDs, current and historical GitHub logins, the full Git head/ref/dirty envelope, and every source origin when identical contributor drafts coalesce into one canonical object. Components, flows, and claims expose the same provenance badges and filters. + Managed GitHub repositories reconcile Memory PRs against exact default-branch code through the official reusable workflow. Pin the reusable workflow to a full commit SHA; do not use a branch or movable tag: ```yaml @@ -114,7 +116,7 @@ jobs: merge-sha: ${{ github.sha }} ``` -The push trigger reconciles immediately after default-branch updates; the hourly schedule retries unmatched and stalled work without a human memory-admin step. The workflow checks out `merge-sha` with full history, installs an immutable Greplica Action revision, proves that each code PR's recorded merge commit is contained in that exact checkout, audits every eligible Memory PR and its version-keyed anchors (including code drift from stored baselines), and attests through GitHub OIDC. It does not use a repository or model secret. Managed Greplica must allowlist both signed reusable-workflow claims for the pinned revision: +The push trigger reconciles immediately after default-branch updates; the hourly schedule retries unmatched and stalled work without a human memory-admin step. The workflow checks out `merge-sha` with full history, installs an immutable Greplica Action revision, proves that each code PR's recorded merge commit is contained in that exact checkout, audits every eligible Memory PR's version-keyed claim and component anchors (including code drift from stored baselines), and attests through GitHub OIDC. It does not use a repository or model secret. Managed Greplica must allowlist both signed reusable-workflow claims for the pinned revision: - `job_workflow_ref=Autoloops/greplica/.github/workflows/reconcile.yml@` - `job_workflow_sha=` diff --git a/apps/cli/main.ts b/apps/cli/main.ts index 5925d64..81e6d35 100644 --- a/apps/cli/main.ts +++ b/apps/cli/main.ts @@ -1432,10 +1432,15 @@ function positionalWithJson(args: string[], command: CommandKey): { positional: function printProposalSummary(proposal: ManagedProposal): void { const commit = proposal.memory_commit; const sessions = commit.session_refs.map((session) => session.id).join(",") || "-"; + const currentLogin = commit.author.github_login; + const historicalLogin = commit.author_github_login_snapshot; + const author = historicalLogin === undefined || historicalLogin === currentLogin + ? currentLogin + : `${currentLogin} (formerly ${historicalLogin})`; console.log([ proposal.id, commit.state, - commit.author.github_login, + author, commit.git?.branch ?? "-", commit.code_pr?.number === undefined ? "-" : `#${commit.code_pr.number}`, commit.memory_pr_id ?? "-", @@ -1462,9 +1467,22 @@ function printPromotionCleanup(memoryPr: ManagedMemoryPr): void { console.log(`Cleared commits: ${promotion.cleared_commit_ids.join(", ") || "none"}`); console.log(`Already canonical: ${promotion.already_canonical_commit_ids.join(", ") || "none"}`); console.log(`Quarantined: ${promotion.quarantined_commit_ids.join(", ") || "none"}`); - for (const [login, cleanup] of Object.entries(promotion.cleared_by_user)) { + for (const [stableKey, cleanup] of Object.entries(promotion.cleared_by_user)) { + const currentLogin = cleanup.github_login; + const formerLogins = (cleanup.github_login_snapshots ?? []) + .filter((login) => login !== currentLogin); + const userLabel = currentLogin ?? stableKey; + const identity = [ + userLabel, + formerLogins.length === 0 ? undefined : `(formerly ${formerLogins.join(", ")})`, + cleanup.user_id === undefined || cleanup.user_id === userLabel ? undefined : `[${cleanup.user_id}]`, + ].filter((value): value is string => value !== undefined).join(" "); + const remainingCommits = cleanup.remaining_active_commits === undefined + ? "" + : `; ${cleanup.remaining_active_commits} active working commits remain`; console.log( - `${login}: cleared ${cleanup.cleared_objects} objects; ${cleanup.remaining_active_objects} active working objects remain`, + `${identity}: cleared ${cleanup.cleared_objects} objects` + + `${remainingCommits}; ${cleanup.remaining_active_objects} active working objects remain`, ); } } diff --git a/apps/cli/reconcile-cli.ts b/apps/cli/reconcile-cli.ts index 20b2625..045aad3 100644 --- a/apps/cli/reconcile-cli.ts +++ b/apps/cli/reconcile-cli.ts @@ -42,6 +42,7 @@ export async function runMemoryReconcile(args: string[]): Promise { response: ManagedReconciliationAttestationResult; memory_pr_id: string; audited_claim_versions: number; + audited_component_versions: number; memory_commit_ids: string[]; }> = []; const skipped: Array<{ @@ -198,14 +199,27 @@ export async function runMemoryReconcile(args: string[]): Promise { continue; } const auditClaims = candidate.claim_versions.map(({ version_id, claim }) => ({ ...claim, id: version_id })); + const componentAuditClaims = (candidate.component_versions ?? []).flatMap(({ version_id, component }) => { + const anchors = componentCodeAnchors(component.code_anchor); + if (anchors.length === 0) return []; + return [{ + id: version_id, + kind: "fact" as const, + text: `Component anchor for ${component.name}`, + truth: "code_verified" as const, + intent: "intended" as const, + code_anchors: anchors, + }]; + }); + const auditedObjects = [...auditClaims, ...componentAuditClaims]; const baselineFingerprints = new Map( - candidate.claim_versions + [...candidate.claim_versions, ...(candidate.component_versions ?? [])] .filter(({ baseline_fingerprints }) => baseline_fingerprints !== undefined) .map(({ version_id, baseline_fingerprints }) => [version_id, baseline_fingerprints!]), ); const result = await auditClaimCodeAnchors( repoRoot, - auditClaims, + auditedObjects, undefined, baselineFingerprints, ); @@ -215,6 +229,9 @@ export async function runMemoryReconcile(args: string[]): Promise { const values = await fingerprintClaimAnchors(repoRoot, claim.code_anchors); if (Object.keys(values).length > 0) fingerprints[claim.id] = values; } + for (const component of componentAuditClaims) { + fingerprints[component.id] = await fingerprintClaimAnchors(repoRoot, component.code_anchors); + } const observedDefaultHeadSha = assertCurrentRemoteDefaultHead( repoRoot, mergeSha, @@ -248,6 +265,7 @@ export async function runMemoryReconcile(args: string[]): Promise { response, memory_pr_id: response.memory_pr_id ?? candidate.memory_pr_id, audited_claim_versions: candidate.claim_versions.length, + audited_component_versions: candidate.component_versions?.length ?? 0, memory_commit_ids: candidate.memory_commit_ids, }); excludedMemoryPrIds.push(candidate.memory_pr_id); @@ -297,6 +315,21 @@ function verifyCandidate(candidate: ManagedReconciliationCandidate, mergeSha: st if (JSON.stringify(candidateIds) !== JSON.stringify(commitIds)) { throw new Error("Managed reconciliation candidate commit metadata does not match its selected commit IDs."); } + const objectVersionIds = [ + ...candidate.claim_versions.map((version) => version.version_id), + ...(candidate.component_versions ?? []).map((version) => version.version_id), + ]; + if (new Set(objectVersionIds).size !== objectVersionIds.length) { + throw new Error("Managed reconciliation candidate has duplicate graph object version IDs."); + } + for (const version of candidate.component_versions ?? []) { + if ( + version.component.code_anchor !== undefined && + typeof version.component.code_anchor !== "string" + ) { + throw new Error(`Component version ${version.version_id} has an invalid code anchor.`); + } + } for (const commit of candidate.commits) { if (!/^[0-9a-f]{40}$/i.test(commit.git_head)) { throw new Error(`Memory commit ${commit.memory_commit_id} has an invalid Git head.`); @@ -311,6 +344,15 @@ function verifyCandidate(candidate: ManagedReconciliationCandidate, mergeSha: st } } +function componentCodeAnchors(codeAnchor: string | undefined): Array<{ file: string }> { + if (codeAnchor === undefined) return []; + return codeAnchor + .split(",") + .map((file) => file.trim()) + .filter((file) => file.length > 0) + .map((file) => ({ file })); +} + function isAncestor(repoRoot: string, gitHead: string, mergeSha: string): boolean { try { execFileSync("git", ["-C", repoRoot, "merge-base", "--is-ancestor", gitHead, mergeSha], { diff --git a/libs/knowledge-graph/code-anchors/fingerprint.ts b/libs/knowledge-graph/code-anchors/fingerprint.ts index 5a43b26..dfd482b 100644 --- a/libs/knowledge-graph/code-anchors/fingerprint.ts +++ b/libs/knowledge-graph/code-anchors/fingerprint.ts @@ -26,8 +26,14 @@ export async function fingerprintAnchor( anchor: ClaimCodeAnchor, resolver: CodeAnchorResolver, ): Promise { - const signature = await resolver.codeSignatureForAnchor(repoRoot, anchor); - return signature === undefined ? undefined : hashText(signature); + try { + const signature = await resolver.codeSignatureForAnchor(repoRoot, anchor); + return signature === undefined ? undefined : hashText(signature); + } catch { + // Existing directory anchors and transient unreadable paths can resolve as + // navigation targets without yielding a stable content signature. + return undefined; + } } /** diff --git a/libs/knowledge-graph/graph-context/render.ts b/libs/knowledge-graph/graph-context/render.ts index dea5a67..8742ccf 100644 --- a/libs/knowledge-graph/graph-context/render.ts +++ b/libs/knowledge-graph/graph-context/render.ts @@ -2,7 +2,7 @@ import type { GraphContextResult, RankedGraphContextResult, } from "./types.js"; -import type { ManagedObjectProvenance } from "../../managed/protocol.js"; +import type { ManagedObjectOrigin, ManagedObjectProvenance } from "../../managed/protocol.js"; export function renderGraphContextMarkdown(result: GraphContextResult): string { const rankedComponents = result.ranked_results.filter((item) => item.type === "component"); @@ -75,6 +75,14 @@ function renderRankedClaims( function provenanceLabel(object: object): string { const provenance = (object as { provenance?: ManagedObjectProvenance }).provenance; if (provenance === undefined) return ""; + const origins = (provenance.origins ?? []) + .map((origin) => `[${provenanceValues(origin).join("; ")}]`) + .join(" "); + return ` Provenance: ${provenanceValues(provenance).join("; ")}.` + + (origins.length === 0 ? "" : ` Origins: ${origins}.`); +} + +function provenanceValues(provenance: ManagedObjectProvenance | ManagedObjectOrigin): string[] { const currentLogin = provenance.author_github_login; const historicalLogin = provenance.author_github_login_snapshot; const values = [ @@ -88,6 +96,9 @@ function provenanceLabel(object: object): string { provenance.agent_platform === undefined ? undefined : `agent ${provenance.agent_platform}`, provenance.branch === undefined ? undefined : `branch ${provenance.branch}`, provenance.git_head === undefined ? undefined : `git ${provenance.git_head}`, + provenance.head_repository === undefined ? undefined : `head repository ${provenance.head_repository}`, + provenance.head_ref === undefined ? undefined : `head ref ${provenance.head_ref}`, + provenance.dirty === undefined ? undefined : provenance.dirty ? "dirty working tree" : "clean working tree", provenance.code_pr_number === undefined ? undefined : `code PR #${provenance.code_pr_number}`, provenance.memory_pr_id === undefined ? undefined : `Memory PR ${provenance.memory_pr_id}`, provenance.commit_role, @@ -95,7 +106,7 @@ function provenanceLabel(object: object): string { provenance.promotion_id === undefined ? undefined : `promotion ${provenance.promotion_id}`, provenance.quarantine_reason === undefined ? undefined : `quarantine ${provenance.quarantine_reason}`, ].filter((value): value is string => value !== undefined); - return ` Provenance: ${values.join("; ")}.`; + return values; } function anchorLabel(anchor: Extract["code_anchors"][number]): string { diff --git a/libs/knowledge-graph/graph-view/build-graph-view.ts b/libs/knowledge-graph/graph-view/build-graph-view.ts index 0eeadca..e52e2ce 100644 --- a/libs/knowledge-graph/graph-view/build-graph-view.ts +++ b/libs/knowledge-graph/graph-view/build-graph-view.ts @@ -6,7 +6,11 @@ import type { Edge } from "../edge.js"; import type { GraphReadResult } from "../service.js"; import type { Component, Flow, Source } from "../schema.js"; import type { ClaimProvenanceRecord } from "../repository.js"; -import type { ManagedGraphView, ManagedObjectProvenance } from "../../managed/protocol.js"; +import type { + ManagedGraphView, + ManagedObjectOrigin, + ManagedObjectProvenance, +} from "../../managed/protocol.js"; const require = createRequire(import.meta.url); @@ -35,6 +39,7 @@ export interface GraphViewComponentRow { flowCount: number; claimCount: number; subcomponentCount: number; + provenance?: ManagedObjectProvenance; } export interface GraphViewFlowRow { @@ -43,6 +48,7 @@ export interface GraphViewFlowRow { folder: string; touchedComponentFolders: string[]; claimCount: number; + provenance?: ManagedObjectProvenance; } export interface GraphViewClaimRow { @@ -91,6 +97,23 @@ export interface BuildGraphViewOptions { } const CLAIM_KIND_ORDER = ["fact", "decision", "requirement", "task", "risk", "question"]; +const PROVENANCE_FILTERS = [ + { key: "scope", slug: "scope", label: "Scope", all: "All scopes" }, + { key: "author", slug: "author", label: "Author", all: "All authors" }, + { key: "authorSnapshot", slug: "author-snapshot", label: "Historical author", all: "All historical authors" }, + { key: "proposalId", slug: "proposal", label: "Proposal", all: "All proposals" }, + { key: "memoryCommitId", slug: "memory-commit", label: "Memory commit", all: "All commits" }, + { key: "agent", slug: "agent", label: "Agent", all: "All agents" }, + { key: "branch", slug: "branch", label: "Branch", all: "All branches" }, + { key: "headRepository", slug: "head-repository", label: "Head repository", all: "All head repositories" }, + { key: "headRef", slug: "head-ref", label: "Head ref", all: "All head refs" }, + { key: "dirty", slug: "dirty", label: "Working tree", all: "All working tree states" }, + { key: "codePr", slug: "code-pr", label: "Code PR", all: "All code PRs" }, + { key: "memoryState", slug: "memory-state", label: "Memory state", all: "All states" }, + { key: "memoryPrId", slug: "memory-pr", label: "Memory PR", all: "All Memory PRs" }, + { key: "commitRole", slug: "commit-role", label: "Commit role", all: "All roles" }, + { key: "promotion", slug: "promotion", label: "Promotion", all: "All promotions" }, +] as const; const CLAIM_KIND_COLORS: Record = { fact: "#4e79a7", @@ -117,6 +140,7 @@ export function buildGraphViewData( flowCount: countFlowsForComponent(component.id, graph.edges), claimCount: countClaimsForComponent(component.id, graph.edges), subcomponentCount: countSubcomponents(component.id, graph.edges), + provenance: managedProvenance(component), })); const topLevelFlows = selectTopLevelFlows(graph.flows, graph.edges); @@ -130,6 +154,7 @@ export function buildGraphViewData( .map((componentId) => segmentForComponentId(componentId)) .sort((left, right) => left.localeCompare(right)), claimCount: countClaimsForFlow(flow.id, graph.edges), + provenance: managedProvenance(flow), }; }); @@ -398,36 +423,127 @@ function kindColor(kind: string): string { return CLAIM_KIND_COLORS[kind] ?? "#cdd2da"; } +type ProvenanceEntry = ManagedObjectProvenance | ManagedObjectOrigin; + +function provenanceEntries(provenance: ManagedObjectProvenance | undefined): ProvenanceEntry[] { + return provenance === undefined ? [] : [provenance, ...(provenance.origins ?? [])]; +} + +function provenanceEntryValue(entry: ProvenanceEntry, key: string): string | undefined { + if (key === "scope") return entry.scope_kind; + if (key === "author") return entry.author_github_login ?? entry.author_github_login_snapshot; + if (key === "authorSnapshot") return entry.author_github_login_snapshot; + if (key === "proposalId") return entry.proposal_id; + if (key === "memoryCommitId") return entry.memory_commit_id; + if (key === "agent") return entry.agent_platform; + if (key === "branch") return entry.branch; + if (key === "headRepository") return entry.head_repository; + if (key === "headRef") return entry.head_ref; + if (key === "dirty") return entry.dirty === undefined ? undefined : entry.dirty ? "dirty" : "clean"; + if (key === "codePr") return entry.code_pr_number?.toString(); + if (key === "memoryState") return entry.memory_commit_state; + if (key === "memoryPrId") return entry.memory_pr_id; + if (key === "commitRole") return entry.commit_role; + if (key === "promotion") return entry.promotion_id; + return undefined; +} + +function provenanceFieldValues( + provenance: ManagedObjectProvenance | undefined, + key: string, +): string[] { + return [...new Set(provenanceEntries(provenance) + .map((entry) => provenanceEntryValue(entry, key)) + .filter((value): value is string => value !== undefined && value.length > 0))]; +} + +function provenanceDataAttributes( + provenance: ManagedObjectProvenance | undefined, + fallbackMemoryCommitId?: string | null, +): string { + const fields = [ + ["scope", "scope"], + ["author", "author"], + ["author-snapshot", "authorSnapshot"], + ["proposal-id", "proposalId"], + ["memory-commit-id", "memoryCommitId"], + ["agent", "agent"], + ["branch", "branch"], + ["head-repository", "headRepository"], + ["head-ref", "headRef"], + ["dirty", "dirty"], + ["code-pr", "codePr"], + ["memory-state", "memoryState"], + ["memory-pr-id", "memoryPrId"], + ["commit-role", "commitRole"], + ["promotion", "promotion"], + ] as const; + return fields + .map(([attribute, key]) => { + const values = provenanceFieldValues(provenance, key); + if (key === "memoryCommitId" && values.length === 0 && fallbackMemoryCommitId) { + values.push(fallbackMemoryCommitId); + } + return ` data-${attribute}="${escapeHtml(values.join(","))}"`; + }) + .join(""); +} + +function provenanceBadgeValues(entry: ProvenanceEntry, origin: boolean): string[] { + const currentLogin = entry.author_github_login; + const historicalLogin = entry.author_github_login_snapshot; + const prefix = origin ? "origin " : ""; + return [ + entry.scope_kind === undefined ? undefined : `${prefix}${entry.scope_kind}`, + currentLogin === undefined ? undefined : `${prefix}@${currentLogin}`, + historicalLogin === undefined || historicalLogin === currentLogin + ? undefined + : `${prefix}formerly @${historicalLogin}`, + entry.proposal_id === undefined ? undefined : `${prefix}proposal ${entry.proposal_id}`, + entry.memory_commit_id === undefined ? undefined : `${prefix}commit ${entry.memory_commit_id}`, + ...(entry.session_refs ?? []).map((session) => `${prefix}session ${session.id}`), + entry.agent_platform === undefined ? undefined : `${prefix}agent ${entry.agent_platform}`, + entry.git_head === undefined ? undefined : `${prefix}git ${entry.git_head}`, + entry.head_repository === undefined ? undefined : `${prefix}head repository ${entry.head_repository}`, + entry.head_ref === undefined ? undefined : `${prefix}head ref ${entry.head_ref}`, + entry.branch === undefined ? undefined : `${prefix}branch ${entry.branch}`, + entry.dirty === undefined ? undefined : `${prefix}${entry.dirty ? "dirty" : "clean"}`, + entry.code_pr_number === undefined ? undefined : `${prefix}code PR #${entry.code_pr_number}`, + entry.commit_role === undefined ? undefined : `${prefix}${entry.commit_role}`, + entry.memory_commit_state === undefined ? undefined : `${prefix}${entry.memory_commit_state}`, + entry.memory_pr_id === undefined ? undefined : `${prefix}Memory PR ${entry.memory_pr_id}`, + entry.promotion_id === undefined ? undefined : `${prefix}promotion ${entry.promotion_id}`, + entry.quarantine_reason === undefined ? undefined : `${prefix}quarantine: ${entry.quarantine_reason}`, + ].filter((value): value is string => value !== undefined); +} + +function renderProvenanceBadges(provenance: ManagedObjectProvenance | undefined): string { + if (provenance === undefined) return ""; + const values = [ + ...provenanceBadgeValues(provenance, false), + ...(provenance.origins ?? []).flatMap((origin) => provenanceBadgeValues(origin, true)), + ]; + return `
${values + .map((value) => `${escapeHtml(value)}`) + .join("")}
`; +} + +function renderProvenanceFilters(prefix: "components" | "flows" | "claims"): string { + return `
${PROVENANCE_FILTERS + .map((filter) => + `` + ) + .join("")}
`; +} + function renderClaimRow(claim: GraphViewClaimRow): string { const badge = `${escapeHtml(claim.kind)}`; const provenance = claim.provenance; - const currentLogin = provenance?.author_github_login; - const historicalLogin = provenance?.author_github_login_snapshot; - const provenanceBadges = provenance === undefined - ? "" - : `
${[ - provenance.scope_kind, - currentLogin === undefined ? undefined : `@${currentLogin}`, - historicalLogin === undefined || historicalLogin === currentLogin ? undefined : `formerly @${historicalLogin}`, - provenance.proposal_id === undefined ? undefined : `proposal ${provenance.proposal_id}`, - provenance.memory_commit_id === undefined ? undefined : `commit ${provenance.memory_commit_id}`, - ...(provenance.session_refs ?? []).map((session) => `session ${session.id}`), - provenance.agent_platform === undefined ? undefined : `agent ${provenance.agent_platform}`, - provenance.git_head === undefined ? undefined : `git ${provenance.git_head}`, - provenance.branch === undefined ? undefined : `branch ${provenance.branch}`, - provenance.code_pr_number === undefined ? undefined : `code PR #${provenance.code_pr_number}`, - provenance.commit_role, - provenance.memory_commit_state, - provenance.memory_pr_id === undefined ? undefined : `Memory PR ${provenance.memory_pr_id}`, - provenance.promotion_id === undefined ? undefined : `promotion ${provenance.promotion_id}`, - provenance.quarantine_reason === undefined ? undefined : `quarantine: ${provenance.quarantine_reason}`, - ].filter((value): value is string => value !== undefined) - .map((value) => `${escapeHtml(value)}`) - .join("")}
`; + const provenanceBadges = renderProvenanceBadges(provenance); const version = provenance === undefined ? "" : ` version ${escapeHtml(provenance.version_id)}`; - return `
`; + return ` `; } function renderHtml(data: GraphViewData, title: string): string { @@ -441,7 +557,7 @@ function renderHtml(data: GraphViewData, title: string): string { component.claimCount > 0 ? `${component.claimCount}` : `${component.claimCount}`; - return ` `; + return ` `; }) .join("\n"); @@ -455,7 +571,7 @@ function renderHtml(data: GraphViewData, title: string): string { flow.claimCount > 0 ? `${flow.claimCount}` : `${flow.claimCount}`; - return ` `; + return ` `; }) .join("\n"); @@ -897,6 +1013,7 @@ function renderHtml(data: GraphViewData, title: string): string {

Components

${data.components.length} top-level components · click to see claims

+ ${renderProvenanceFilters("components")}
${escapeHtml(claim.text)}
${escapeHtml(claim.id)}${version}
${provenanceBadges}
${escapeHtml(claim.session)}${badge}${escapeHtml(formatDateTime(claim.createdAt))}
${escapeHtml(claim.text)}
${escapeHtml(claim.id)}${version}
${provenanceBadges}
${escapeHtml(claim.session)}${badge}${escapeHtml(formatDateTime(claim.createdAt))}
${escapeHtml(claim.text)}
${escapeHtml(claim.id)}${version}
${provenanceBadges}
${escapeHtml(claim.session)}${badge}${escapeHtml(formatDateTime(claim.createdAt))}
${escapeHtml(claim.text)}
${escapeHtml(claim.id)}${version}
${provenanceBadges}
${escapeHtml(claim.session)}${badge}${escapeHtml(formatDateTime(claim.createdAt))}
${escapeHtml(component.folder)}${escapeHtml(component.name)}${anchors}${component.flowCount}${claimsCell}${component.subcomponentCount}
${escapeHtml(component.folder)}${escapeHtml(component.name)}${renderProvenanceBadges(component.provenance)}${anchors}${component.flowCount}${claimsCell}${component.subcomponentCount}
${escapeHtml(flow.folder)}${escapeHtml(flow.name)}${touchedComponents}${claimsCell}
${escapeHtml(flow.folder)}${escapeHtml(flow.name)}${renderProvenanceBadges(flow.provenance)}${touchedComponents}${claimsCell}
@@ -909,6 +1026,7 @@ ${componentRows}

Flows

${data.flows.length} top-level flows · click to see claims

+ ${renderProvenanceFilters("flows")}
NameDescriptionCode AnchorsFlowsClaimsSubcomponents
@@ -922,20 +1040,7 @@ ${flowRows}

Claims

-
- - - - - - - - - - - - -
+ ${renderProvenanceFilters("claims")}

${escapeHtml(defaultClaimsMeta)}

NameDescriptionTouched ComponentsClaims
@@ -994,22 +1099,20 @@ ${timelineEvents} const links = document.querySelectorAll("nav a[data-view]"); const views = document.querySelectorAll(".view[data-view]"); const claimRows = document.querySelectorAll("#claims-table tbody tr[data-id]"); + const componentRows = document.querySelectorAll("#view-components tbody tr[data-id]"); + const flowRows = document.querySelectorAll("#view-flows tbody tr[data-id]"); const claimsMeta = document.getElementById("claims-meta"); const claimsSearchInput = document.getElementById("claims-search"); - const provenanceFilterSelects = { - scope: document.getElementById("claims-filter-scope"), - author: document.getElementById("claims-filter-author"), - authorSnapshot: document.getElementById("claims-filter-author-snapshot"), - proposalId: document.getElementById("claims-filter-proposal"), - memoryCommitId: document.getElementById("claims-filter-memory-commit"), - agent: document.getElementById("claims-filter-agent"), - branch: document.getElementById("claims-filter-branch"), - codePr: document.getElementById("claims-filter-code-pr"), - memoryState: document.getElementById("claims-filter-memory-state"), - memoryPrId: document.getElementById("claims-filter-memory-pr"), - commitRole: document.getElementById("claims-filter-commit-role"), - promotion: document.getElementById("claims-filter-promotion"), - }; + const provenanceFilterDefinitions = ${JSON.stringify(PROVENANCE_FILTERS)}; + function provenanceFilterMap(prefix) { + return Object.fromEntries(provenanceFilterDefinitions.map((filter) => [ + filter.key, + document.getElementById(prefix + "-filter-" + filter.slug), + ])); + } + const provenanceFilterSelects = provenanceFilterMap("claims"); + const componentProvenanceFilterSelects = provenanceFilterMap("components"); + const flowProvenanceFilterSelects = provenanceFilterMap("flows"); const defaultClaimsMeta = ${JSON.stringify(defaultClaimsMeta)}; const CLAIM_KIND_ORDER = ${JSON.stringify(CLAIM_KIND_ORDER)}; @@ -1020,6 +1123,7 @@ ${timelineEvents} const allClaims = graphData.claims.concat(graphData.supersededClaims); const claimVersionKey = (claim) => (claim.provenance && claim.provenance.version_id) || claim.id; const rowVersionKey = (row) => row.dataset.versionId || row.dataset.id || ""; + const claimByVersion = new Map(allClaims.map((claim) => [claimVersionKey(claim), claim])); const claimTextByVersion = new Map(allClaims.map((claim) => [claimVersionKey(claim), claim.text])); const componentIdsByClaimVersion = new Map( graphData.claims.map((claim) => [claimVersionKey(claim), claim.componentIds || []]) @@ -1032,33 +1136,50 @@ ${timelineEvents} let activeFilter = null; - function provenanceValue(claim, key) { - const provenance = claim.provenance || {}; - if (key === "scope") return provenance.scope_kind || ""; - if (key === "author") return provenance.author_github_login || provenance.author_github_login_snapshot || ""; - if (key === "authorSnapshot") return provenance.author_github_login_snapshot || ""; - if (key === "proposalId") return provenance.proposal_id || ""; - if (key === "memoryCommitId") return provenance.memory_commit_id || claim.memoryCommitId || ""; - if (key === "agent") return provenance.agent_platform || ""; - if (key === "branch") return provenance.branch || ""; - if (key === "codePr") return provenance.code_pr_number ? String(provenance.code_pr_number) : ""; - if (key === "memoryState") return provenance.memory_commit_state || ""; - if (key === "memoryPrId") return provenance.memory_pr_id || ""; - if (key === "commitRole") return provenance.commit_role || ""; - if (key === "promotion") return provenance.promotion_id || ""; + function provenanceEntryValue(entry, key) { + if (key === "scope") return entry.scope_kind || ""; + if (key === "author") return entry.author_github_login || entry.author_github_login_snapshot || ""; + if (key === "authorSnapshot") return entry.author_github_login_snapshot || ""; + if (key === "proposalId") return entry.proposal_id || ""; + if (key === "memoryCommitId") return entry.memory_commit_id || ""; + if (key === "agent") return entry.agent_platform || ""; + if (key === "branch") return entry.branch || ""; + if (key === "headRepository") return entry.head_repository || ""; + if (key === "headRef") return entry.head_ref || ""; + if (key === "dirty") return entry.dirty === undefined ? "" : entry.dirty ? "dirty" : "clean"; + if (key === "codePr") return entry.code_pr_number ? String(entry.code_pr_number) : ""; + if (key === "memoryState") return entry.memory_commit_state || ""; + if (key === "memoryPrId") return entry.memory_pr_id || ""; + if (key === "commitRole") return entry.commit_role || ""; + if (key === "promotion") return entry.promotion_id || ""; return ""; } - for (const [key, select] of Object.entries(provenanceFilterSelects)) { - if (!select) continue; - const values = [...new Set(allClaims.map((claim) => provenanceValue(claim, key)).filter(Boolean))].sort(); - for (const value of values) { - const option = document.createElement("option"); - option.value = value; - option.textContent = value; - select.appendChild(option); + function provenanceValues(object, key) { + const provenance = object.provenance; + const entries = provenance ? [provenance, ...(provenance.origins || [])] : []; + const values = entries.map((entry) => provenanceEntryValue(entry, key)).filter(Boolean); + if (key === "memoryCommitId" && values.length === 0 && object.memoryCommitId) { + values.push(object.memoryCommitId); + } + return [...new Set(values)]; + } + + function populateProvenanceFilters(objects, selects) { + for (const [key, select] of Object.entries(selects)) { + if (!select) continue; + const values = [...new Set(objects.flatMap((object) => provenanceValues(object, key)))].sort(); + for (const value of values) { + const option = document.createElement("option"); + option.value = value; + option.textContent = value; + select.appendChild(option); + } } } + populateProvenanceFilters(allClaims, provenanceFilterSelects); + populateProvenanceFilters(graphData.components, componentProvenanceFilterSelects); + populateProvenanceFilters(graphData.flows, flowProvenanceFilterSelects); function escapeHtmlClient(value) { return String(value) @@ -1302,7 +1423,10 @@ ${timelineEvents} if (freshness !== "active") return false; if (filter.type === "kind") return row.dataset.kind === filter.value; if (filter.type === "source") return row.dataset.source === filter.value; - if (filter.type === "commit") return row.dataset.memoryCommitId === filter.value; + if (filter.type === "commit") { + const claim = claimByVersion.get(rowVersionKey(row)); + return claim ? provenanceValues(claim, "memoryCommitId").includes(filter.value) : false; + } if (filter.type === "component") { return (componentIdsByClaimVersion.get(rowVersionKey(row)) || []).includes(filter.value); } @@ -1313,9 +1437,11 @@ ${timelineEvents} } function rowMatchesProvenanceFilters(row) { + const claim = claimByVersion.get(rowVersionKey(row)); + if (!claim) return false; return Object.entries(provenanceFilterSelects).every(([key, select]) => { if (!select || !select.value) return true; - return (row.dataset[key] || "") === select.value; + return provenanceValues(claim, key).includes(select.value); }); } @@ -1323,6 +1449,17 @@ ${timelineEvents} return Object.values(provenanceFilterSelects).some((select) => select && select.value); } + function applyObjectProvenanceFilters(rows, objects, selects) { + const byId = new Map(objects.map((object) => [object.id, object])); + for (const row of rows) { + const object = byId.get(row.dataset.id || ""); + const visible = object && Object.entries(selects).every(([key, select]) => + !select || !select.value || provenanceValues(object, key).includes(select.value) + ); + row.classList.toggle("claim-row-hidden", !visible); + } + } + function applyClaims() { const query = (claimsSearchInput && claimsSearchInput.value ? claimsSearchInput.value : "").trim().toLowerCase(); const filter = activeFilter; @@ -1411,6 +1548,18 @@ ${timelineEvents} for (const select of Object.values(provenanceFilterSelects)) { if (select) select.addEventListener("change", applyClaims); } + for (const select of Object.values(componentProvenanceFilterSelects)) { + if (select) select.addEventListener("change", () => + applyObjectProvenanceFilters(componentRows, graphData.components, componentProvenanceFilterSelects) + ); + } + for (const select of Object.values(flowProvenanceFilterSelects)) { + if (select) select.addEventListener("change", () => + applyObjectProvenanceFilters(flowRows, graphData.flows, flowProvenanceFilterSelects) + ); + } + applyObjectProvenanceFilters(componentRows, graphData.components, componentProvenanceFilterSelects); + applyObjectProvenanceFilters(flowRows, graphData.flows, flowProvenanceFilterSelects); const overviewNavLink = document.querySelector('nav a[data-view="claims-overview"]'); if (overviewNavLink) { diff --git a/libs/managed/protocol.ts b/libs/managed/protocol.ts index 66a214f..bdfce3a 100644 --- a/libs/managed/protocol.ts +++ b/libs/managed/protocol.ts @@ -186,6 +186,42 @@ export const ReconciliationJobStateSchema = Type.Union([ Type.Literal("failed"), ]); +export const ManagedObjectOriginSchema = Type.Object({ + version_id: Type.String(), + scope_kind: Type.Optional(Type.Union([ + Type.Literal("main"), + Type.Literal("working"), + Type.Literal("memory_pr"), + Type.Literal("quarantine"), + ])), + scope_name: Type.Optional(Type.String()), + author_user_id: Type.Optional(Type.String({ format: "uuid" })), + author_github_login: Type.Optional(Type.String()), + author_github_login_snapshot: Type.Optional(Type.String()), + proposal_id: Type.Optional(Type.String()), + memory_commit_id: Type.Optional(Type.String()), + memory_commit_state: Type.Optional(MemoryCommitStateSchema), + session_refs: Type.Optional(Type.Array(Type.Object({ + id: Type.String(), + agent_platform: Type.Optional(Type.String()), + }))), + agent_platform: Type.Optional(Type.String()), + git_head: Type.Optional(Type.String()), + head_repository: Type.Optional(Type.String()), + head_ref: Type.Optional(Type.String()), + branch: Type.Optional(Type.String()), + dirty: Type.Optional(Type.Boolean()), + code_pr_number: Type.Optional(Type.Integer({ minimum: 1 })), + memory_pr_id: Type.Optional(Type.String()), + commit_role: Type.Optional(Type.Union([ + Type.Literal("direct"), + Type.Literal("dependency"), + Type.Literal("repair"), + ])), + promotion_id: Type.Optional(Type.String()), + quarantine_reason: Type.Optional(Type.String()), +}); + export const ManagedObjectProvenanceSchema = Type.Object({ version_id: Type.String(), scope_kind: Type.Union([ @@ -207,7 +243,10 @@ export const ManagedObjectProvenanceSchema = Type.Object({ }))), agent_platform: Type.Optional(Type.String()), git_head: Type.Optional(Type.String()), + head_repository: Type.Optional(Type.String()), + head_ref: Type.Optional(Type.String()), branch: Type.Optional(Type.String()), + dirty: Type.Optional(Type.Boolean()), code_pr_number: Type.Optional(Type.Integer({ minimum: 1 })), memory_pr_id: Type.Optional(Type.String()), commit_role: Type.Optional(Type.Union([ @@ -217,6 +256,7 @@ export const ManagedObjectProvenanceSchema = Type.Object({ ])), promotion_id: Type.Optional(Type.String()), quarantine_reason: Type.Optional(Type.String()), + origins: Type.Optional(Type.Array(ManagedObjectOriginSchema)), }); export const CodeAnchorSchema = Type.Object({ @@ -496,6 +536,7 @@ export const GraphViewDataSchema = Type.Object({ flowCount: Type.Integer({ minimum: 0 }), claimCount: Type.Integer({ minimum: 0 }), subcomponentCount: Type.Integer({ minimum: 0 }), + provenance: Type.Optional(ManagedObjectProvenanceSchema), })), flows: Type.Array(Type.Object({ id: Type.String(), @@ -503,6 +544,7 @@ export const GraphViewDataSchema = Type.Object({ folder: Type.String(), touchedComponentFolders: Type.Array(Type.String()), claimCount: Type.Integer({ minimum: 0 }), + provenance: Type.Optional(ManagedObjectProvenanceSchema), })), claims: Type.Array(GraphViewClaimRowSchema), supersededClaims: Type.Array(GraphViewClaimRowSchema), @@ -555,6 +597,7 @@ export const MemoryCommitRecordSchema = Type.Object({ scope_name: Type.String(), state: MemoryCommitStateSchema, author: UserSchema, + author_github_login_snapshot: Type.Optional(Type.String()), session_refs: Type.Array(Type.Object({ id: Type.String(), agent_platform: Type.Optional(Type.String()), @@ -586,6 +629,9 @@ export const PromotionCleanupSchema = Type.Object({ already_canonical_commit_ids: Type.Array(Type.String()), quarantined_commit_ids: Type.Array(Type.String()), cleared_by_user: Type.Record(Type.String(), Type.Object({ + user_id: Type.Optional(Type.String({ format: "uuid" })), + github_login: Type.Optional(Type.String()), + github_login_snapshots: Type.Optional(Type.Array(Type.String(), { uniqueItems: true })), cleared_objects: Type.Integer({ minimum: 0 }), remaining_active_commits: Type.Optional(Type.Integer({ minimum: 0 })), remaining_active_objects: Type.Integer({ minimum: 0 }), @@ -653,6 +699,11 @@ export const ReconciliationCandidateSchema = Type.Object({ claim: ClaimSchema, baseline_fingerprints: Type.Optional(Type.Record(Type.String(), Type.String())), })), + component_versions: Type.Optional(Type.Array(Type.Object({ + version_id: Type.String(), + component: ComponentSchema, + baseline_fingerprints: Type.Optional(Type.Record(Type.String(), Type.String())), + }))), }); export const ReconciliationProofSchema = Type.Object({ @@ -839,6 +890,7 @@ export type ManagedGraphContext = Static; export type ManagedGraphViewData = Static; export type ManagedProposalReview = Static; export type ManagedGraphView = Static; +export type ManagedObjectOrigin = Static; export type ManagedObjectProvenance = Static; export type ManagedMemoryCommit = Static; export type ManagedProposal = Static; diff --git a/scripts/check-anchor-drift.js b/scripts/check-anchor-drift.js index 6a372e7..f3f61af 100644 --- a/scripts/check-anchor-drift.js +++ b/scripts/check-anchor-drift.js @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import { mkdtempSync, writeFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -13,6 +13,13 @@ const file = join(repo, "mod.py"); const anchor = { file: "mod.py", symbol: "foo" }; const claim = { id: "claim.foo", kind: "fact", text: "foo returns 3", truth: "code_verified", intent: "intended", code_anchors: [anchor] }; +mkdirSync(join(repo, "src")); +assert.deepEqual( + await fingerprintClaimAnchors(repo, [{ file: "src" }], new CodeAnchorResolver()), + {}, + "directory component anchors remain valid navigation targets without crashing fingerprinting", +); + // Baseline fingerprint captured when the fact was "written". writeFileSync(file, "def foo():\n # returns the threshold\n return 3\n"); const baseline = new Map([["claim.foo", await fingerprintClaimAnchors(repo, [anchor], new CodeAnchorResolver())]]); diff --git a/scripts/check-graph-view-offline-browser.js b/scripts/check-graph-view-offline-browser.js index 5510077..6b0de20 100644 --- a/scripts/check-graph-view-offline-browser.js +++ b/scripts/check-graph-view-offline-browser.js @@ -22,6 +22,9 @@ 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 { buildGraphViewHtmlFromData } = await import( + new URL("dist/libs/knowledge-graph/graph-view/build-graph-view.js", root) +); function findBrowserBinary() { const candidates = [ @@ -130,6 +133,112 @@ try { assert.equal(result.chartDefined, true, "window.Chart must be defined after loading the page with no network access"); assert.equal(result.dataLabelsDefined, true, "window.ChartDataLabels must be defined after loading the page with no network access"); assert.equal(result.chartInstanceCount, 3, "expected all 3 overview pie charts (type/source/freshness) to have rendered"); + + const provenance = { + version_id: "canonical-version", + scope_kind: "main", + author_github_login: "alice", + memory_commit_id: "alice-commit", + origins: [{ + version_id: "bob-source-version", + scope_kind: "working", + author_github_login: "bob", + memory_commit_id: "bob-commit", + }], + }; + const provenanceHtml = buildGraphViewHtmlFromData({ + generatedAt: new Date().toISOString(), + counts: { components: 1, flows: 1, claims: 1, superseded: 0 }, + components: [{ + id: "component.provenance", + name: "Provenance Component", + folder: "provenance", + anchors: [], + flowCount: 1, + claimCount: 1, + subcomponentCount: 0, + provenance, + }], + flows: [{ + id: "flow.provenance", + name: "Provenance Flow", + folder: "provenance", + touchedComponentFolders: ["provenance"], + claimCount: 1, + provenance, + }], + claims: [{ + id: "claim.provenance", + text: "Provenance claim", + kind: "fact", + session: "", + source: "code", + freshness: "active", + componentIds: ["component.provenance"], + flowIds: ["flow.provenance"], + createdAt: new Date().toISOString(), + memoryCommitId: "alice-commit", + provenance, + }], + supersededClaims: [], + claimsTimeline: { + summary: { total: 1, sessionPct: 0, codePct: 100 }, + events: [], + }, + }); + const provenanceHarness = ` + `; + const provenancePath = join(tmp, "provenance.html"); + writeFileSync(provenancePath, provenanceHtml.replace("", `${provenanceHarness}\n`)); + const provenanceDom = execFileSync( + browser, + [ + "--headless=new", + "--disable-gpu", + "--no-sandbox", + "--host-resolver-rules=MAP * 0.0.0.0", + "--virtual-time-budget=4000", + "--dump-dom", + `file://${provenancePath}#claims`, + ], + { encoding: "utf8", timeout: 30_000, stdio: ["ignore", "pipe", "ignore"] }, + ); + const provenanceMatch = provenanceDom.match(/id="provenance-browser-test-result" data-result="([^"]+)"/); + assert.ok(provenanceMatch, "expected the provenance browser harness marker"); + const provenanceResult = JSON.parse(provenanceMatch[1].replace(/"/g, '"')); + assert.deepEqual(provenanceResult.claimAuthors, ["", "alice", "bob"]); + assert.deepEqual(provenanceResult.componentAuthors, ["", "alice", "bob"]); + assert.deepEqual(provenanceResult.flowAuthors, ["", "alice", "bob"]); + assert.equal(provenanceResult.claimVisible, true, "claims must filter by a coalesced origin author"); + assert.equal(provenanceResult.componentVisible, true, "components must filter by a coalesced origin author"); + assert.equal(provenanceResult.flowVisible, true, "flows must filter by a coalesced origin author"); } finally { db.close(); } diff --git a/scripts/check-graph-view.js b/scripts/check-graph-view.js index f68d48f..5911117 100644 --- a/scripts/check-graph-view.js +++ b/scripts/check-graph-view.js @@ -208,7 +208,7 @@ async function checkRichGraphIsSelfContained() { // Token Store is nested under Auth Service via "contains", so it's rolled // up into the parent's subcomponent count rather than shown as its own // top-level row — this exercises the contains-edge path in buildGraphViewData. - const authRowMatch = html.match(/[\s\S]*?<\/tr>/); + const authRowMatch = html.match(/]*>[\s\S]*?<\/tr>/); assert.ok(authRowMatch, "expected a table row for component.auth"); assert.match(authRowMatch[0], /
1<\/td><\/tr>$/, "expected Auth Service to report 1 subcomponent"); assert.match(html, /Login Flow/); diff --git a/scripts/check-managed-cli.js b/scripts/check-managed-cli.js index 19b577a..0f398eb 100644 --- a/scripts/check-managed-cli.js +++ b/scripts/check-managed-cli.js @@ -169,6 +169,30 @@ const server = createServer(async (request, response) => { }); return; } + if (request.method === "GET" && request.url === `/v1/repos/${managedRepoId}/proposals`) { + send(200, [{ + id: "proposal-renamed-author", + memory_commit: { + id: "memory-commit-renamed-author", + proposal_id: "proposal-renamed-author", + scope_id: "working-user-1", + scope_name: "working/contributor-1", + state: "active", + author: { + id: "10000000-0000-4000-8000-000000000000", + github_user_id: "1", + github_login: "contributor-current", + created_at: now, + }, + author_github_login_snapshot: "contributor-old", + session_refs: [], + created_at: now, + }, + proposal: { title: "Rename-safe provenance" }, + created_at: now, + }]); + return; + } if (request.method === "GET" && request.url === `/v1/repos/${managedRepoId}/memory-prs`) { send(200, [{ id: "direct-default-memory-pr", @@ -182,6 +206,41 @@ const server = createServer(async (request, response) => { }]); return; } + if ( + request.method === "GET" && + request.url === `/v1/repos/${managedRepoId}/memory-prs/cleanup-memory-pr` + ) { + send(200, { + id: "cleanup-memory-pr", + state: "merged", + direct_commit_ids: ["cleared-commit"], + dependency_commit_ids: [], + repair_commit_ids: [], + contributor_logins: ["contributor-current"], + promotion: { + id: "promotion-cleanup", + status: "merged", + new_main_head: "main-head", + cleared_commit_ids: ["cleared-commit"], + already_canonical_commit_ids: [], + quarantined_commit_ids: [], + cleared_by_user: { + "10000000-0000-4000-8000-000000000000": { + user_id: "10000000-0000-4000-8000-000000000000", + github_login: "contributor-current", + github_login_snapshots: ["contributor-old", "contributor-current"], + cleared_objects: 3, + remaining_active_commits: 2, + remaining_active_objects: 4, + }, + }, + promoted_at: now, + }, + created_at: now, + updated_at: now, + }); + return; + } if (request.method === "POST" && request.url === `/v1/repos/${managedRepoId}/import`) { importedSnapshot = body; send(200, { @@ -306,6 +365,14 @@ try { assert.match(memoryStatus.stdout, /Action workflow ref: Autoloops\/greplica\/\.github\/workflows\/reconcile\.yml@/); assert.match(memoryStatus.stdout, new RegExp(`Action workflow SHA: ${"a".repeat(40)}`)); assert.match(memoryStatus.stdout, /Repair service: degraded \(repair proxy is not configured\)/); + const proposalList = await run( + process.execPath, + [cliPath, "proposal", "list"], + managedRepo, + env, + ); + assert.match(proposalList.stdout, /contributor-current \(formerly contributor-old\)/, + "proposal summaries must preserve both current and historical GitHub logins"); const directDefaultMemoryPr = await run( process.execPath, [cliPath, "memory", "pr", "list"], @@ -313,6 +380,17 @@ try { env, ); assert.match(directDefaultMemoryPr.stdout, /direct-default-memory-pr\s+reconciling\s+direct-default/); + const cleanupMemoryPr = await run( + process.execPath, + [cliPath, "memory", "pr", "show", "cleanup-memory-pr"], + managedRepo, + env, + ); + assert.match( + cleanupMemoryPr.stdout, + /contributor-current \(formerly contributor-old\) \[10000000-0000-4000-8000-000000000000\]: cleared 3 objects; 2 active working commits remain; 4 active working objects remain/, + "cleanup output must use the friendly current login while retaining stable user identity and rename history", + ); const requestsBeforeHook = requestCount; const hook = await run(process.execPath, [cliPath, "hook", "ingest", "--platform", "codex"], managedRepo, env, JSON.stringify({ diff --git a/scripts/check-managed-collaboration.js b/scripts/check-managed-collaboration.js index a7c9c45..c90ad3d 100644 --- a/scripts/check-managed-collaboration.js +++ b/scripts/check-managed-collaboration.js @@ -85,8 +85,10 @@ exec("git", ["-C", repoRoot, "remote", "add", "origin", originRoot]); exec("git", ["-C", repoRoot, "push", "--quiet", "origin", `${defaultBranch}:refs/heads/${defaultBranch}`]); exec("git", ["-C", repoRoot, "push", "--quiet", "origin", `${featureSha}:refs/pull/7/head`]); const versionOneAnchor = { file: "example.ts", symbol: "example" }; +const componentAnchor = { file: "example.ts" }; exec("git", ["-C", repoRoot, "checkout", "--quiet", "--detach", featureSha]); const versionOneBaseline = await fingerprintClaimAnchors(repoRoot, [versionOneAnchor]); +const componentBaseline = await fingerprintClaimAnchors(repoRoot, [componentAnchor]); exec("git", ["-C", repoRoot, "checkout", "--quiet", "--detach", mergeSha]); assert.notEqual( (await fingerprintClaimAnchors(repoRoot, [versionOneAnchor]))["example.ts#example"], @@ -153,13 +155,32 @@ const fetchImpl = async (input, init) => { session_refs: [{ id: "codex-session:context-1", agent_platform: "codex" }], agent_platform: "codex", git_head: mergeSha, + head_repository: "example/project", + head_ref: "feature", branch: "feature", + dirty: false, code_pr_number: 7, memory_pr_id: "memory-pr-1", commit_role: "repair", memory_commit_state: "active", promotion_id: "promotion-1", quarantine_reason: "superseded repair", + origins: [{ + version_id: "source-version-bob", + scope_kind: "working", + author_user_id: "22222222-2222-4222-8222-222222222222", + author_github_login: "bob", + author_github_login_snapshot: "bob-old", + proposal_id: "origin-proposal-bob", + memory_commit_id: "origin-commit-bob", + session_refs: [{ id: "claude-session:origin-bob", agent_platform: "claude" }], + agent_platform: "claude", + git_head: featureSha, + head_repository: "bob/project", + head_ref: "feature", + branch: "feature", + dirty: true, + }], }, }, code_anchors: [], @@ -204,13 +225,32 @@ const fetchImpl = async (input, init) => { session_refs: [{ id: "codex-session:context-1", agent_platform: "codex" }], agent_platform: "codex", git_head: mergeSha, + head_repository: "example/project", + head_ref: "feature", branch: "feature", + dirty: false, code_pr_number: 7, memory_pr_id: "memory-pr-1", commit_role: "repair", memory_commit_state: "active", promotion_id: "promotion-1", quarantine_reason: "superseded repair", + origins: [{ + version_id: "source-version-bob", + scope_kind: "working", + author_user_id: "22222222-2222-4222-8222-222222222222", + author_github_login: "bob", + author_github_login_snapshot: "bob-old", + proposal_id: "origin-proposal-bob", + memory_commit_id: "origin-commit-bob", + session_refs: [{ id: "claude-session:origin-bob", agent_platform: "claude" }], + agent_platform: "claude", + git_head: featureSha, + head_repository: "bob/project", + head_ref: "feature", + branch: "feature", + dirty: true, + }], }, }, code_anchors: [], @@ -306,9 +346,17 @@ assert.match(contextMarkdown, /formerly @alice-old/); assert.match(contextMarkdown, /proposal context-proposal-1/); assert.match(contextMarkdown, /session codex-session:context-1/); assert.match(contextMarkdown, /branch feature/); +assert.match(contextMarkdown, /head repository example\/project/); +assert.match(contextMarkdown, /head ref feature/); +assert.match(contextMarkdown, /clean working tree/); assert.match(contextMarkdown, /code PR #7/); assert.match(contextMarkdown, /promotion promotion-1/); assert.match(contextMarkdown, /quarantine superseded repair/); +assert.match(contextMarkdown, /Origins: \[working; version source-version-bob; @bob; formerly @bob-old/); +assert.match(contextMarkdown, /origin-proposal-bob/); +assert.match(contextMarkdown, /claude-session:origin-bob/); +assert.match(contextMarkdown, /head repository bob\/project/); +assert.match(contextMarkdown, /dirty working tree/); await client.viewData({ base: "main", memory_pr_id: "memory-pr-1" }); request = new URL(calls.at(-1).url); assert.equal(request.searchParams.get("memory_pr_id"), "memory-pr-1"); @@ -385,7 +433,35 @@ legacyDb.close(); const html = buildGraphViewHtmlFromData({ ...viewData, - counts: { ...viewData.counts, claims: 1 }, + counts: { ...viewData.counts, components: 1, flows: 1, claims: 1 }, + components: [{ + id: "component.provenance", + name: "Provenance component", + folder: "provenance", + anchors: ["example.ts"], + flowCount: 1, + claimCount: 1, + subcomponentCount: 0, + provenance: { + version_id: "component-version", + scope_kind: "working", + author_github_login: "component-author", + memory_commit_id: "component-commit", + }, + }], + flows: [{ + id: "flow.provenance", + name: "Provenance flow", + folder: "provenance", + touchedComponentFolders: ["provenance"], + claimCount: 1, + provenance: { + version_id: "flow-version", + scope_kind: "working", + author_github_login: "flow-author", + memory_commit_id: "flow-commit", + }, + }], claims: [{ id: "claim.logical", text: "A personal draft", @@ -407,12 +483,28 @@ const html = buildGraphViewHtmlFromData({ session_refs: [{ id: "codex-session:session-1", agent_platform: "codex" }], agent_platform: "codex", git_head: mergeSha, + head_repository: "example/project", + head_ref: "feature", branch: "feature", + dirty: false, code_pr_number: 7, memory_commit_state: "active", memory_pr_id: "memory-pr-1", commit_role: "repair", promotion_id: "promotion-1", + origins: [{ + version_id: "origin-version-1", + scope_kind: "working", + author_github_login: "carol", + author_github_login_snapshot: "carol-old", + proposal_id: "origin-proposal-1", + memory_commit_id: "origin-commit-1", + session_refs: [{ id: "origin-session-1", agent_platform: "claude" }], + head_repository: "carol/project", + head_ref: "memory", + branch: "memory", + dirty: true, + }], }, }, { id: "claim.logical", @@ -441,10 +533,20 @@ const html = buildGraphViewHtmlFromData({ }); assert.match(html, /data-version-id="version-1"/); assert.match(html, /data-version-id="version-2"/); -assert.match(html, /data-author="alice"/); +assert.match(html, /data-author="alice,carol"/); assert.match(html, /provenance-badge[^>]*>repair]*>clean]*data-author="component-author"/); +assert.match(html, /data-id="flow\.provenance"[^>]*data-author="flow-author"/); +assert.match(html, /id="components-filter-author"/); +assert.match(html, /id="flows-filter-author"/); assert.match(html, /claimTextByVersion/); assert.match(html, /componentIdsByClaimVersion/); assert.match(html, /id="claims-filter-scope"/); @@ -454,15 +556,35 @@ assert.match(html, /id="claims-filter-proposal"/); assert.match(html, /id="claims-filter-memory-commit"/); assert.match(html, /id="claims-filter-agent"/); assert.match(html, /id="claims-filter-branch"/); +assert.match(html, /id="claims-filter-head-repository"/); +assert.match(html, /id="claims-filter-head-ref"/); +assert.match(html, /id="claims-filter-dirty"/); assert.match(html, /id="claims-filter-code-pr"/); assert.match(html, /id="claims-filter-memory-state"/); assert.match(html, /id="claims-filter-memory-pr"/); assert.match(html, /id="claims-filter-commit-role"/); assert.match(html, /id="claims-filter-promotion"/); +assert.match(html, /provenance\.origins/); const builtViewData = buildGraphViewData({ - components: [], - flows: [], + components: [{ + id: "component.built", + name: "Built component", + provenance: { + version_id: "component-version-built", + scope_kind: "working", + author_github_login: "component-builder", + }, + }], + flows: [{ + id: "flow.built", + name: "Built flow", + provenance: { + version_id: "flow-version-built", + scope_kind: "working", + author_github_login: "flow-builder", + }, + }], claims: [{ id: "claim.provenance", kind: "fact", @@ -479,6 +601,8 @@ const builtViewData = buildGraphViewData({ edges: [], }, [], []); assert.equal(builtViewData.claims[0].provenance.version_id, "version-built"); +assert.equal(builtViewData.components[0].provenance.version_id, "component-version-built"); +assert.equal(builtViewData.flows[0].provenance.version_id, "flow-version-built"); const attestations = []; const rejections = []; @@ -642,6 +766,23 @@ const server = createServer(async (incoming, response) => { code_anchors: [versionOneAnchor], }, }], + component_versions: [{ + version_id: "version-component-1", + baseline_fingerprints: componentBaseline, + component: { + id: "component.logical", + name: "Version-keyed component audit", + code_anchor: "example.ts", + }, + }, { + version_id: "version-component-missing", + baseline_fingerprints: {}, + component: { + id: "component.missing", + name: "Missing component anchor", + code_anchor: "missing-component.ts", + }, + }], }); return; } @@ -698,6 +839,7 @@ try { }); assert.match(result.stdout, /"accepted": true/); assert.match(result.stdout, /"reconciliation_count": 2/); + assert.match(result.stdout, /"audited_component_versions": 2/); assert.match(result.stdout, /"skipped_count": 2/); assert.match(result.stdout, /"reason": "git_head_not_in_pr_delta"/); assert.equal(candidateCalls, 5); @@ -731,10 +873,22 @@ try { assert.equal(attestations[0].code_merge_is_ancestor, true, "the Action must attest that the code PR merge is an ancestor of the later exact checkout"); assert.equal(attestations[0].anchor_audit.result.drifted[0].claim_id, "version-1"); + assert.ok(attestations[0].anchor_audit.result.drifted.some((issue) => + issue.claim_id === "version-component-1" + ), "component anchors must be audited under immutable component version IDs"); assert.notEqual( attestations[0].anchor_audit.fingerprints["version-1"]["example.ts#example"], versionOneBaseline["example.ts#example"], ); + assert.notEqual( + attestations[0].anchor_audit.fingerprints["version-component-1"]["example.ts"], + componentBaseline["example.ts"], + ); + assert.ok(attestations[0].anchor_audit.result.missing_files.some((issue) => + issue.claim_id === "version-component-missing" && + issue.anchor.file === "missing-component.ts" + ), "missing component anchors must fail under immutable component version IDs"); + assert.deepEqual(attestations[0].anchor_audit.fingerprints["version-component-missing"], {}); assert.equal(attestations[0].repository, "example/project"); assert.deepEqual(attestations[1].ancestry, [{ memory_commit_id: "commit-2", From df175890144f85ab085b98514f78f92cad0e7f15 Mon Sep 17 00:00:00 2001 From: Kushal Date: Tue, 28 Jul 2026 20:55:33 -0700 Subject: [PATCH 15/27] ci: repin collaborative provenance audit --- .github/workflows/reconcile.yml | 2 +- scripts/check-managed-collaboration.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/reconcile.yml b/.github/workflows/reconcile.yml index 1e31506..bf2dd73 100644 --- a/.github/workflows/reconcile.yml +++ b/.github/workflows/reconcile.yml @@ -32,7 +32,7 @@ jobs: # Keep this immutable. The managed service separately allowlists this # reusable workflow's signed job_workflow_sha. - name: Reconcile Memory PRs - uses: Autoloops/greplica@fb14d063e9394e8b25ab2cf7c00398cbe6d25be9 + uses: Autoloops/greplica@e9d22a982f499159efc0a321fee21e858151efb4 with: managed-repo: ${{ inputs.managed-repo }} merge-sha: ${{ inputs.merge-sha }} diff --git a/scripts/check-managed-collaboration.js b/scripts/check-managed-collaboration.js index c90ad3d..842b5b0 100644 --- a/scripts/check-managed-collaboration.js +++ b/scripts/check-managed-collaboration.js @@ -45,7 +45,7 @@ assert.match(reusableWorkflow, /oidc-audience: greplica-managed/); assert.doesNotMatch(reusableWorkflow, /\$\{\{ inputs\.(?:api-url|oidc-audience) \}\}/); assert.match( reusableWorkflow, - /uses: Autoloops\/greplica@fb14d063e9394e8b25ab2cf7c00398cbe6d25be9/, + /uses: Autoloops\/greplica@e9d22a982f499159efc0a321fee21e858151efb4/, ); assert.doesNotMatch(reusableWorkflow, /uses: Autoloops\/greplica@(main|refs\/heads\/|v\d)/); From b7a7969b786f9af4f257fa4564628d2e40874454 Mon Sep 17 00:00:00 2001 From: Kushal Date: Tue, 28 Jul 2026 21:13:23 -0700 Subject: [PATCH 16/27] fix: report memory retry state truthfully --- apps/cli/main.ts | 10 ++++++- scripts/check-managed-cli.js | 58 ++++++++++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+), 1 deletion(-) diff --git a/apps/cli/main.ts b/apps/cli/main.ts index 81e6d35..a8bb52f 100644 --- a/apps/cli/main.ts +++ b/apps/cli/main.ts @@ -642,7 +642,15 @@ async function runMemoryPrRetryCommand(args: string[], getContext: CommandContex const memoryPr = await getContext().service.retryMemoryPr(positional); if (json) console.log(JSON.stringify(memoryPr, null, 2)); else { - console.log(`Queued Memory PR ${memoryPr.id} for reconciliation.`); + if (memoryPr.latest_job_state === "queued") { + console.log(`Memory PR ${memoryPr.id} is queued for reconciliation.`); + } else if (memoryPr.latest_job_state === "running") { + console.log(`Memory PR ${memoryPr.id} reconciliation is already running.`); + } else { + console.log( + `Memory PR ${memoryPr.id} reconciliation state is ${memoryPr.latest_job_state ?? memoryPr.state}.`, + ); + } printMemoryPrSummary(memoryPr); } } diff --git a/scripts/check-managed-cli.js b/scripts/check-managed-cli.js index 0f398eb..a51cbec 100644 --- a/scripts/check-managed-cli.js +++ b/scripts/check-managed-cli.js @@ -241,6 +241,24 @@ const server = createServer(async (request, response) => { }); return; } + const retryMatch = request.url.match( + new RegExp(`^/v1/repos/${managedRepoId}/memory-prs/(queued|running|neutral)-memory-pr/retry$`), + ); + if (request.method === "POST" && retryMatch !== null) { + const responseKind = retryMatch[1]; + send(200, { + id: `${responseKind}-memory-pr`, + state: responseKind === "neutral" ? "open" : "reconciling", + direct_commit_ids: [`${responseKind}-commit`], + dependency_commit_ids: [], + repair_commit_ids: [], + contributor_logins: ["contributor-current"], + ...(responseKind === "neutral" ? {} : { latest_job_state: responseKind }), + created_at: now, + updated_at: now, + }); + return; + } if (request.method === "POST" && request.url === `/v1/repos/${managedRepoId}/import`) { importedSnapshot = body; send(200, { @@ -391,6 +409,46 @@ try { /contributor-current \(formerly contributor-old\) \[10000000-0000-4000-8000-000000000000\]: cleared 3 objects; 2 active working commits remain; 4 active working objects remain/, "cleanup output must use the friendly current login while retaining stable user identity and rename history", ); + const queuedRetry = await run( + process.execPath, + [cliPath, "memory", "pr", "retry", "queued-memory-pr"], + managedRepo, + env, + ); + assert.match(queuedRetry.stdout, /^Memory PR queued-memory-pr is queued for reconciliation\./); + assert.doesNotMatch(queuedRetry.stdout, /Queued Memory PR/, + "retry output must not claim the client itself queued an already-queued job"); + const runningRetry = await run( + process.execPath, + [cliPath, "memory", "pr", "retry", "running-memory-pr"], + managedRepo, + env, + ); + assert.match(runningRetry.stdout, /^Memory PR running-memory-pr reconciliation is already running\./); + assert.doesNotMatch(runningRetry.stdout, /queued for reconciliation/); + const neutralRetry = await run( + process.execPath, + [cliPath, "memory", "pr", "retry", "neutral-memory-pr"], + managedRepo, + env, + ); + assert.match( + neutralRetry.stdout, + /^Memory PR neutral-memory-pr reconciliation state is open\./, + ); + assert.doesNotMatch(neutralRetry.stdout, /queued for reconciliation/); + const queuedRetryJson = await run( + process.execPath, + [cliPath, "memory", "pr", "retry", "queued-memory-pr", "--json"], + managedRepo, + env, + ); + const queuedRetryRecord = JSON.parse(queuedRetryJson.stdout); + assert.equal(queuedRetryRecord.id, "queued-memory-pr"); + assert.equal(queuedRetryRecord.latest_job_state, "queued"); + assert.equal(queuedRetryRecord.state, "reconciling"); + assert.doesNotMatch(queuedRetryJson.stdout, /queued for reconciliation/, + "JSON mode must remain a plain ManagedMemoryPr response without status prose"); const requestsBeforeHook = requestCount; const hook = await run(process.execPath, [cliPath, "hook", "ingest", "--platform", "codex"], managedRepo, env, JSON.stringify({ From 1d5b4746574a52894a352c861314d9f46ca5062c Mon Sep 17 00:00:00 2001 From: Kushal Date: Tue, 28 Jul 2026 21:13:44 -0700 Subject: [PATCH 17/27] ci: repin truthful retry output --- .github/workflows/reconcile.yml | 2 +- scripts/check-managed-collaboration.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/reconcile.yml b/.github/workflows/reconcile.yml index bf2dd73..4f9340e 100644 --- a/.github/workflows/reconcile.yml +++ b/.github/workflows/reconcile.yml @@ -32,7 +32,7 @@ jobs: # Keep this immutable. The managed service separately allowlists this # reusable workflow's signed job_workflow_sha. - name: Reconcile Memory PRs - uses: Autoloops/greplica@e9d22a982f499159efc0a321fee21e858151efb4 + uses: Autoloops/greplica@b7a7969b786f9af4f257fa4564628d2e40874454 with: managed-repo: ${{ inputs.managed-repo }} merge-sha: ${{ inputs.merge-sha }} diff --git a/scripts/check-managed-collaboration.js b/scripts/check-managed-collaboration.js index 842b5b0..506ad80 100644 --- a/scripts/check-managed-collaboration.js +++ b/scripts/check-managed-collaboration.js @@ -45,7 +45,7 @@ assert.match(reusableWorkflow, /oidc-audience: greplica-managed/); assert.doesNotMatch(reusableWorkflow, /\$\{\{ inputs\.(?:api-url|oidc-audience) \}\}/); assert.match( reusableWorkflow, - /uses: Autoloops\/greplica@e9d22a982f499159efc0a321fee21e858151efb4/, + /uses: Autoloops\/greplica@b7a7969b786f9af4f257fa4564628d2e40874454/, ); assert.doesNotMatch(reusableWorkflow, /uses: Autoloops\/greplica@(main|refs\/heads\/|v\d)/); From 39e0753400119868e63c932b17db322124b1963d Mon Sep 17 00:00:00 2001 From: Kushal Date: Tue, 28 Jul 2026 21:42:29 -0700 Subject: [PATCH 18/27] feat: attest bounded exact-code evidence --- README.md | 6 +- apps/cli/main.ts | 23 +- apps/cli/reconcile-cli.ts | 30 + libs/knowledge-graph/code-anchors/evidence.ts | 748 ++++++++++++++++++ libs/knowledge-graph/managed-client.ts | 18 +- libs/managed/protocol.ts | 57 ++ package.json | 5 +- scripts/check-managed-cli.js | 197 ++++- scripts/check-managed-collaboration.js | 46 +- scripts/check-reconciliation-code-evidence.js | 402 ++++++++++ 10 files changed, 1499 insertions(+), 33 deletions(-) create mode 100644 libs/knowledge-graph/code-anchors/evidence.ts create mode 100644 scripts/check-reconciliation-code-evidence.js diff --git a/README.md b/README.md index 467305c..80060c5 100644 --- a/README.md +++ b/README.md @@ -116,7 +116,11 @@ jobs: merge-sha: ${{ github.sha }} ``` -The push trigger reconciles immediately after default-branch updates; the hourly schedule retries unmatched and stalled work without a human memory-admin step. The workflow checks out `merge-sha` with full history, installs an immutable Greplica Action revision, proves that each code PR's recorded merge commit is contained in that exact checkout, audits every eligible Memory PR's version-keyed claim and component anchors (including code drift from stored baselines), and attests through GitHub OIDC. It does not use a repository or model secret. Managed Greplica must allowlist both signed reusable-workflow claims for the pinned revision: +The push trigger reconciles immediately after default-branch updates; the hourly schedule retries unmatched and stalled work without a human memory-admin step. The workflow checks out `merge-sha` with full history, installs an immutable Greplica Action revision, proves that each code PR's recorded merge commit is contained in that exact checkout, audits every eligible Memory PR's version-keyed claim and component anchors (including code drift from stored baselines), and attests through GitHub OIDC. It does not use a repository or model secret. + +When a compatible managed server requests repair evidence, the Action also submits a deterministic packet bound to that exact SHA. It reads only the anchors named by the selected claim and component versions—never a repository-wide content scan—and limits the packet to 128 anchors, 4 KiB and 80 lines per snippet, and 64 KiB of snippets total. Snippets can come only from regular blobs tracked at the attested commit, with the checkout bytes verified against that blob. Traversal is rejected; ignored/untracked files, symlinks, submodules, sensitive paths, binary or oversized files, and high-confidence credential content are represented only as non-content omissions. Managed repair may use only `resolved` or `file_only` entries whose transmitted snippet hash and current anchor fingerprint are present and valid. Older servers retain the prior attestation shape. + +Managed Greplica must allowlist both signed reusable-workflow claims for the pinned revision: - `job_workflow_ref=Autoloops/greplica/.github/workflows/reconcile.yml@` - `job_workflow_sha=` diff --git a/apps/cli/main.ts b/apps/cli/main.ts index a8bb52f..3ae172f 100644 --- a/apps/cli/main.ts +++ b/apps/cli/main.ts @@ -1349,10 +1349,10 @@ function parseGraphSelectionArgs(args: string[], passthroughFlags: ReadonlySet 0 || memoryPrId !== undefined || includeQuarantined)) { - throw new Error("--main-only cannot be combined with working, Memory PR, or quarantine overlays."); + if (mainOnly && workingUsers.length > 0) { + throw new Error("--main-only cannot be combined with working overlays."); } - const uniqueWorkingUsers = [...new Set(workingUsers)]; + const uniqueWorkingUsers = uniqueGithubLogins(workingUsers); const hasView = mainOnly || uniqueWorkingUsers.length > 0 || memoryPrId !== undefined || includeQuarantined; const view = hasView ? { @@ -1366,6 +1366,16 @@ function parseGraphSelectionArgs(args: string[], passthroughFlags: ReadonlySet(); + return logins.filter((login) => { + const key = login.toLowerCase(); + if (seen.has(key)) return false; + seen.add(key); + return true; + }); +} + function defaultGraphViewOutputPath(repoName: string): string { const safeName = repoName.replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "") || "repo"; return join(tmpdir(), `greplica-graph-${safeName}.html`); @@ -1440,6 +1450,11 @@ function positionalWithJson(args: string[], command: CommandKey): { positional: function printProposalSummary(proposal: ManagedProposal): void { const commit = proposal.memory_commit; const sessions = commit.session_refs.map((session) => session.id).join(",") || "-"; + const agentPlatforms = [ + commit.agent_platform, + ...commit.session_refs.map((session) => session.agent_platform), + ].filter((value): value is string => value !== undefined); + const agents = [...new Set(agentPlatforms)].join(",") || "-"; const currentLogin = commit.author.github_login; const historicalLogin = commit.author_github_login_snapshot; const author = historicalLogin === undefined || historicalLogin === currentLogin @@ -1450,6 +1465,8 @@ function printProposalSummary(proposal: ManagedProposal): void { commit.state, author, commit.git?.branch ?? "-", + `head:${commit.git?.git_head ?? "-"}`, + `agent:${agents}`, commit.code_pr?.number === undefined ? "-" : `#${commit.code_pr.number}`, commit.memory_pr_id ?? "-", sessions, diff --git a/apps/cli/reconcile-cli.ts b/apps/cli/reconcile-cli.ts index 045aad3..9b51012 100644 --- a/apps/cli/reconcile-cli.ts +++ b/apps/cli/reconcile-cli.ts @@ -1,6 +1,7 @@ import { execFileSync } from "node:child_process"; import { resolve } from "node:path"; import { auditClaimCodeAnchors } from "../../libs/knowledge-graph/code-anchors/audit.js"; +import { buildReconciliationCodeEvidence } from "../../libs/knowledge-graph/code-anchors/evidence.js"; import { fingerprintClaimAnchors } from "../../libs/knowledge-graph/code-anchors/fingerprint.js"; import { ensureGreplicaConfig, managedApiUrl } from "../../libs/config/greplica-config.js"; import type { @@ -43,6 +44,7 @@ export async function runMemoryReconcile(args: string[]): Promise { memory_pr_id: string; audited_claim_versions: number; audited_component_versions: number; + code_evidence_entries?: number; memory_commit_ids: string[]; }> = []; const skipped: Array<{ @@ -232,6 +234,29 @@ export async function runMemoryReconcile(args: string[]): Promise { for (const component of componentAuditClaims) { fingerprints[component.id] = await fingerprintClaimAnchors(repoRoot, component.code_anchors); } + const codeEvidence = candidate.code_evidence_required === true + ? await buildReconciliationCodeEvidence(repoRoot, { + managedRepoId, + repository, + attestedGitSha: mergeSha, + anchors: [ + ...candidate.claim_versions.flatMap(({ version_id, claim }) => + (claim.code_anchors ?? []).map((anchor) => ({ + versionId: version_id, + objectType: "claim" as const, + anchor, + })) + ), + ...(candidate.component_versions ?? []).flatMap(({ version_id, component }) => + componentCodeAnchors(component.code_anchor).map((anchor) => ({ + versionId: version_id, + objectType: "component" as const, + anchor, + })) + ), + ], + }) + : undefined; const observedDefaultHeadSha = assertCurrentRemoteDefaultHead( repoRoot, mergeSha, @@ -248,6 +273,7 @@ export async function runMemoryReconcile(args: string[]): Promise { ancestry, audit_key: "version_id", anchor_audit: { result, fingerprints }, + code_evidence: codeEvidence, observed_default_head_sha: observedDefaultHeadSha, ref: process.env.GITHUB_REF, run_id: process.env.GITHUB_RUN_ID, @@ -266,6 +292,7 @@ export async function runMemoryReconcile(args: string[]): Promise { memory_pr_id: response.memory_pr_id ?? candidate.memory_pr_id, audited_claim_versions: candidate.claim_versions.length, audited_component_versions: candidate.component_versions?.length ?? 0, + ...(codeEvidence === undefined ? {} : { code_evidence_entries: codeEvidence.entries.length }), memory_commit_ids: candidate.memory_commit_ids, }); excludedMemoryPrIds.push(candidate.memory_pr_id); @@ -310,6 +337,9 @@ function verifyCandidate(candidate: ManagedReconciliationCandidate, mergeSha: st if (candidate.code_merge_sha !== undefined && !/^[0-9a-f]{40}$/i.test(candidate.code_merge_sha)) { throw new Error("Managed reconciliation candidate has an invalid code merge SHA."); } + if (candidate.code_evidence_required !== undefined && candidate.code_evidence_required !== true) { + throw new Error("Managed reconciliation candidate has an invalid code evidence requirement."); + } const candidateIds = [...candidate.memory_commit_ids].sort(); const commitIds = candidate.commits.map((commit) => commit.memory_commit_id).sort(); if (JSON.stringify(candidateIds) !== JSON.stringify(commitIds)) { diff --git a/libs/knowledge-graph/code-anchors/evidence.ts b/libs/knowledge-graph/code-anchors/evidence.ts new file mode 100644 index 0000000..4e5ac34 --- /dev/null +++ b/libs/knowledge-graph/code-anchors/evidence.ts @@ -0,0 +1,748 @@ +import { execFileSync } from "node:child_process"; +import { createHash, timingSafeEqual } from "node:crypto"; +import { + lstatSync, + readFileSync, + realpathSync, + statSync, +} from "node:fs"; +import { + isAbsolute, + join, + posix, + relative, + resolve, + sep, +} from "node:path"; +import type { + ManagedReconciliationCodeEvidence, + ManagedReconciliationCodeEvidenceEntry, +} from "../../managed/protocol.js"; +import type { ClaimCodeAnchor } from "../claim.js"; +import { fingerprintAnchor } from "./fingerprint.js"; +import { CodeAnchorResolver } from "./resolver.js"; + +export const reconciliationCodeEvidenceLimits = { + maxEntries: 128, + maxPathBytes: 512, + maxSymbolBytes: 512, + maxSnippetBytes: 4_096, + maxSnippetLines: 80, + maxTotalSnippetBytes: 65_536, + maxReadableFileBytes: 1_048_576, +} as const; + +export interface VersionedCodeAnchor { + versionId: string; + objectType: "claim" | "component"; + anchor: ClaimCodeAnchor; +} + +export interface BuildReconciliationCodeEvidenceInput { + managedRepoId: string; + repository: string; + attestedGitSha: string; + anchors: VersionedCodeAnchor[]; +} + +export type ReconciliationCodeEvidencePayload = + Omit; +type OmissionReason = NonNullable; + +interface AttestedTreeEntry { + mode: string; + type: string; + objectId: string; + path: string; +} + +/** + * Build a bounded, deterministic packet from the exact clean checkout used by + * reconciliation. Only candidate-provided anchors are inspected; this function + * never scans the repository for content or writes it. + */ +export async function buildReconciliationCodeEvidence( + repoRoot: string, + input: BuildReconciliationCodeEvidenceInput, +): Promise { + validateEnvelope(input); + const canonicalRoot = realpathSync(resolve(repoRoot)); + assertExactEvidenceCheckout(canonicalRoot, input.attestedGitSha); + + const anchors = normalizeAndDedupeAnchors(input.anchors); + if (anchors.length > reconciliationCodeEvidenceLimits.maxEntries) { + throw new Error( + `Reconciliation code evidence exceeds ${reconciliationCodeEvidenceLimits.maxEntries} unique anchors.`, + ); + } + + const resolver = new CodeAnchorResolver(); + const treeEntries = attestedTreeEntriesForAnchors( + canonicalRoot, + input.attestedGitSha, + anchors, + ); + const entries: ManagedReconciliationCodeEvidenceEntry[] = []; + let remainingSnippetBytes = reconciliationCodeEvidenceLimits.maxTotalSnippetBytes; + let packetTruncated = false; + + for (const item of anchors) { + const entry = await evidenceForAnchor( + canonicalRoot, + item, + resolver, + remainingSnippetBytes, + treeEntries, + ); + entries.push(entry); + const snippetBytes = entry.snippet === undefined ? 0 : Buffer.byteLength(entry.snippet, "utf8"); + remainingSnippetBytes -= snippetBytes; + if ( + entry.truncated === true || + entry.status === "omitted" || + entry.omission_reason === "sensitive_path" || + entry.omission_reason === "binary" || + entry.omission_reason === "file_too_large" || + entry.omission_reason === "unreadable" || + entry.omission_reason === "total_budget" + ) { + packetTruncated = true; + } + } + + const packet: ReconciliationCodeEvidencePayload = { + managed_repo_id: input.managedRepoId, + repository: input.repository, + attested_git_sha: input.attestedGitSha.toLowerCase(), + truncated: packetTruncated, + entries, + }; + return { + ...packet, + evidence_sha256: reconciliationCodeEvidenceHash(packet), + }; +} + +/** Hash the canonical packet payload, intentionally excluding the hash field. */ +export function reconciliationCodeEvidenceHash( + packet: ReconciliationCodeEvidencePayload, +): string { + return createHash("sha256").update(canonicalJson(packet), "utf8").digest("hex"); +} + +/** Verify the packet's canonical root hash without trusting its field order. */ +export function hasValidReconciliationCodeEvidenceHash( + packet: ManagedReconciliationCodeEvidence, +): boolean { + if (!/^[0-9a-f]{64}$/.test(packet.evidence_sha256)) return false; + const { evidence_sha256, ...payload } = packet; + return timingSafeEqual( + Buffer.from(evidence_sha256, "hex"), + Buffer.from(reconciliationCodeEvidenceHash(payload), "hex"), + ); +} + +/** + * Whether an entry can authorize repair against its exact transmitted subset. + * Unresolved and omitted entries are diagnostic only. Even resolved/file-only + * entries need a verified snippet and current checkout fingerprint. + */ +export function isReconciliationCodeEvidenceEntryActionable( + entry: ManagedReconciliationCodeEvidenceEntry, +): boolean { + if ( + entry.status !== "resolved" && + entry.status !== "file_only" + ) { + return false; + } + if ( + entry.snippet === undefined || + entry.snippet_sha256 === undefined || + entry.anchor_fingerprint === undefined + ) { + return false; + } + const actualSnippetHash = createHash("sha256").update(entry.snippet, "utf8").digest("hex"); + return entry.snippet_sha256 === actualSnippetHash; +} + +function validateEnvelope(input: BuildReconciliationCodeEvidenceInput): void { + if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(input.managedRepoId)) { + throw new Error("Reconciliation code evidence requires a valid managed repository UUID."); + } + if ( + input.repository.length < 3 || + input.repository.length > 255 || + !/^[^/\s]+\/[^/\s]+$/.test(input.repository) + ) { + throw new Error("Reconciliation code evidence requires an owner/repository name."); + } + if (!/^[0-9a-f]{40}$/i.test(input.attestedGitSha)) { + throw new Error("Reconciliation code evidence requires a full 40-character Git SHA."); + } +} + +function normalizeAndDedupeAnchors(anchors: VersionedCodeAnchor[]): VersionedCodeAnchor[] { + const deduped = new Map(); + for (const item of anchors) { + if ( + item.versionId.length === 0 || + Buffer.byteLength(item.versionId, "utf8") > 512 + ) { + throw new Error("Reconciliation code evidence version IDs must be 1-512 UTF-8 bytes."); + } + const file = normalizeAnchorPath(item.anchor.file); + const symbol = normalizeAnchorSymbol(item.anchor.symbol); + const normalized: VersionedCodeAnchor = { + versionId: item.versionId, + objectType: item.objectType, + anchor: { + file, + ...(symbol === undefined ? {} : { symbol }), + }, + }; + const key = JSON.stringify([ + normalized.versionId, + normalized.objectType, + normalized.anchor.file, + normalized.anchor.symbol ?? "", + ]); + deduped.set(key, normalized); + } + return [...deduped.values()].sort(compareVersionedAnchors); +} + +function normalizeAnchorPath(file: string): string { + if ( + file.length === 0 || + file.includes("\0") || + file.includes("\\") || + /[\u0000-\u001f\u007f]/.test(file) || + isAbsolute(file) || + /^[a-zA-Z]:/.test(file) || + Buffer.byteLength(file, "utf8") > reconciliationCodeEvidenceLimits.maxPathBytes + ) { + throw new Error(`Unsafe reconciliation code anchor path: ${JSON.stringify(file)}.`); + } + const normalized = posix.normalize(file); + const segments = file.split("/"); + if ( + normalized !== file || + normalized === "." || + normalized.startsWith("../") || + segments.some((segment) => segment.length === 0 || segment === "." || segment === "..") + ) { + throw new Error(`Reconciliation code anchor path is not normalized: ${JSON.stringify(file)}.`); + } + return normalized; +} + +function normalizeAnchorSymbol(symbol: string | undefined): string | undefined { + if (symbol === undefined) return undefined; + if ( + symbol.length === 0 || + symbol.includes("\0") || + Buffer.byteLength(symbol, "utf8") > reconciliationCodeEvidenceLimits.maxSymbolBytes + ) { + throw new Error("Reconciliation code anchor symbols must be 1-512 UTF-8 bytes."); + } + return symbol; +} + +async function evidenceForAnchor( + repoRoot: string, + item: VersionedCodeAnchor, + resolver: CodeAnchorResolver, + remainingSnippetBytes: number, + treeEntries: Map, +): Promise { + const base = { + version_id: item.versionId, + object_type: item.objectType, + anchor: item.anchor, + normalized_path: item.anchor.file, + } as const; + if (crossesGitlink(item.anchor.file, treeEntries)) { + return omitted(base, "submodule"); + } + const treeEntry = treeEntries.get(item.anchor.file); + if (treeEntry?.mode === "120000") return omitted(base, "symlink"); + const contained = resolveContainedPath(repoRoot, item.anchor.file); + if (!contained.exists) { + return { + ...base, + status: "missing_file", + omission_reason: "missing_file", + }; + } + if (treeEntry === undefined) return omitted(base, "not_in_attested_tree"); + if ( + treeEntry.type !== "blob" || + (treeEntry.mode !== "100644" && treeEntry.mode !== "100755") + ) { + return omitted(base, "not_regular_blob"); + } + if (isSensitivePath(item.anchor.file) || isSensitivePath(contained.realRelativePath)) { + return omitted(base, "sensitive_path"); + } + if (crossesNestedGitRepository(repoRoot, item.anchor.file)) { + return omitted(base, "submodule"); + } + + let stats; + try { + stats = statSync(contained.realPath); + } catch { + return omitted(base, "unreadable"); + } + if (!stats.isFile()) return omitted(base, "not_regular_blob"); + + const blobSize = attestedBlobSize(repoRoot, treeEntry.objectId); + if (blobSize > reconciliationCodeEvidenceLimits.maxReadableFileBytes) { + return omitted(base, "file_too_large"); + } + if (stats.size !== blobSize) { + throw new Error( + `Reconciliation code anchor ${item.anchor.file} does not match its attested Git blob.`, + ); + } + + let bytes: Buffer; + try { + bytes = readAttestedBlob(repoRoot, treeEntry.objectId); + } catch { + return omitted(base, "unreadable"); + } + let workingBytes: Buffer; + try { + workingBytes = readFileSync(contained.realPath); + } catch { + return omitted(base, "unreadable"); + } + if (!workingBytes.equals(bytes)) { + throw new Error( + `Reconciliation code anchor ${item.anchor.file} does not match its attested Git blob.`, + ); + } + if (looksBinary(bytes)) return omitted(base, "binary"); + + let source: string; + try { + source = new TextDecoder("utf-8", { fatal: true }).decode(bytes); + } catch { + return omitted(base, "binary"); + } + if (containsHighConfidenceSecret(source)) { + return omitted(base, "sensitive_content"); + } + + const resolved = await resolver.resolve(repoRoot, item.anchor); + if (resolved.status !== "resolved" && resolved.status !== "file_only") { + return { + ...base, + status: resolved.status, + ...(resolved.start_line === undefined ? {} : { anchor_start_line: resolved.start_line }), + ...(resolved.end_line === undefined ? {} : { anchor_end_line: resolved.end_line }), + omission_reason: resolved.status === "missing_file" ? "missing_file" : "no_resolved_span", + }; + } + + const records = sourceLineRecords(source); + if (records.length === 0) { + return { + ...base, + status: resolved.status, + ...(resolved.start_line === undefined ? {} : { anchor_start_line: resolved.start_line }), + ...(resolved.end_line === undefined ? {} : { anchor_end_line: resolved.end_line }), + omission_reason: "no_resolved_span", + }; + } + const anchorStart = resolved.status === "resolved" + ? clampLine(resolved.start_line ?? 1, records.length) + : 1; + const anchorEnd = resolved.status === "resolved" + ? clampLine(resolved.end_line ?? anchorStart, records.length) + : records.length; + const requestedStart = resolved.status === "resolved" ? Math.max(1, anchorStart - 2) : 1; + const requestedEnd = resolved.status === "resolved" + ? Math.min(records.length, anchorEnd + 2) + : records.length; + const lineEnd = Math.min( + requestedEnd, + requestedStart + reconciliationCodeEvidenceLimits.maxSnippetLines - 1, + ); + const selected = records.slice(requestedStart - 1, lineEnd).join(""); + const entryBudget = Math.min( + reconciliationCodeEvidenceLimits.maxSnippetBytes, + remainingSnippetBytes, + ); + if (entryBudget === 0) { + return { + ...base, + status: "omitted", + anchor_start_line: anchorStart, + anchor_end_line: anchorEnd, + truncated: true, + omission_reason: "total_budget", + }; + } + const snippet = utf8Prefix(selected, entryBudget); + if (snippet.length === 0) { + return { + ...base, + status: "omitted", + anchor_start_line: anchorStart, + anchor_end_line: anchorEnd, + truncated: true, + omission_reason: "total_budget", + }; + } + const snippetBytes = Buffer.byteLength(snippet, "utf8"); + const truncatedByLines = lineEnd < requestedEnd; + const truncatedByBytes = snippetBytes < Buffer.byteLength(selected, "utf8"); + const truncatedByTotalBudget = + remainingSnippetBytes < reconciliationCodeEvidenceLimits.maxSnippetBytes && + truncatedByBytes; + const snippetEnd = requestedStart + representedLineCount(snippet) - 1; + const fingerprint = await fingerprintAnchor(repoRoot, item.anchor, resolver); + + return { + ...base, + status: resolved.status, + anchor_start_line: anchorStart, + anchor_end_line: anchorEnd, + snippet_start_line: requestedStart, + snippet_end_line: snippetEnd, + snippet, + snippet_sha256: createHash("sha256").update(snippet, "utf8").digest("hex"), + ...(fingerprint === undefined ? {} : { anchor_fingerprint: fingerprint }), + ...(!truncatedByLines && !truncatedByBytes ? {} : { truncated: true }), + ...(truncatedByTotalBudget ? { omission_reason: "total_budget" as const } : {}), + }; +} + +function resolveContainedPath( + repoRoot: string, + normalizedPath: string, +): { exists: boolean; realPath: string; realRelativePath: string } { + const lexicalPath = join(repoRoot, ...normalizedPath.split("/")); + assertContained(repoRoot, lexicalPath, "lexical"); + let current = repoRoot; + for (const segment of normalizedPath.split("/")) { + current = join(current, segment); + let stats; + try { + stats = lstatSync(current); + } catch (error) { + if (isMissingPathError(error)) { + return { + exists: false, + realPath: lexicalPath, + realRelativePath: normalizedPath, + }; + } + throw new Error(`Cannot inspect reconciliation code anchor ${normalizedPath}.`); + } + if (stats.isSymbolicLink()) { + throw new Error( + `Reconciliation code anchor ${normalizedPath} escapes the repository or crosses a symlink boundary.`, + ); + } + let realCurrent: string; + try { + realCurrent = realpathSync(current); + } catch { + throw new Error(`Cannot resolve reconciliation code anchor ${normalizedPath}.`); + } + assertContained(repoRoot, realCurrent, "real"); + } + const realPath = realpathSync(lexicalPath); + assertContained(repoRoot, realPath, "real"); + return { + exists: true, + realPath, + realRelativePath: relative(repoRoot, realPath).split(sep).join("/"), + }; +} + +function attestedTreeEntriesForAnchors( + repoRoot: string, + attestedGitSha: string, + anchors: VersionedCodeAnchor[], +): Map { + const prefixes = new Set(); + for (const { anchor } of anchors) { + const segments = anchor.file.split("/"); + for (let index = 1; index <= segments.length; index += 1) { + prefixes.add(segments.slice(0, index).join("/")); + } + } + if (prefixes.size === 0) return new Map(); + let output: string; + try { + output = execFileSync( + "git", + [ + "-C", + repoRoot, + "ls-tree", + "-z", + "--full-tree", + attestedGitSha, + "--", + ...[...prefixes].sort().map((prefix) => `:(literal)${prefix}`), + ], + { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }, + ); + } catch { + throw new Error("Cannot inspect the attested Git tree for reconciliation code evidence."); + } + const entries = new Map(); + for (const record of output.split("\0")) { + if (record.length === 0) continue; + const separator = record.indexOf("\t"); + if (separator === -1) { + throw new Error("Attested Git tree returned malformed code evidence metadata."); + } + const metadata = record.slice(0, separator).split(" "); + const path = record.slice(separator + 1); + if (metadata.length !== 3 || metadata.some((value) => value.length === 0)) { + throw new Error("Attested Git tree returned malformed code evidence metadata."); + } + entries.set(path, { + mode: metadata[0], + type: metadata[1], + objectId: metadata[2], + path, + }); + } + return entries; +} + +function crossesGitlink( + normalizedPath: string, + treeEntries: Map, +): boolean { + const segments = normalizedPath.split("/"); + for (let index = 1; index <= segments.length; index += 1) { + if (treeEntries.get(segments.slice(0, index).join("/"))?.mode === "160000") { + return true; + } + } + return false; +} + +function attestedBlobSize(repoRoot: string, objectId: string): number { + let output: string; + try { + output = execFileSync("git", ["-C", repoRoot, "cat-file", "-s", objectId], { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }).trim(); + } catch { + throw new Error("Cannot inspect an attested Git blob for reconciliation code evidence."); + } + const size = Number(output); + if (!Number.isSafeInteger(size) || size < 0) { + throw new Error("Attested Git blob has an invalid size."); + } + return size; +} + +function readAttestedBlob(repoRoot: string, objectId: string): Buffer { + return execFileSync("git", ["-C", repoRoot, "cat-file", "blob", objectId], { + encoding: "buffer", + maxBuffer: reconciliationCodeEvidenceLimits.maxReadableFileBytes + 1, + stdio: ["ignore", "pipe", "pipe"], + }); +} + +function crossesNestedGitRepository(repoRoot: string, normalizedPath: string): boolean { + let current = repoRoot; + for (const segment of normalizedPath.split("/")) { + current = join(current, segment); + try { + lstatSync(current); + } catch (error) { + if (isMissingPathError(error)) return false; + throw new Error(`Cannot inspect repository boundary for code anchor ${normalizedPath}.`); + } + const realCurrent = realpathSync(current); + assertContained(repoRoot, realCurrent, "real"); + let stats; + try { + stats = statSync(realCurrent); + } catch { + return false; + } + if (!stats.isDirectory()) continue; + try { + lstatSync(join(realCurrent, ".git")); + return true; + } catch (error) { + if (!isMissingPathError(error)) { + throw new Error(`Cannot inspect nested repository boundary for code anchor ${normalizedPath}.`); + } + } + } + return false; +} + +function assertContained(repoRoot: string, candidate: string, kind: "lexical" | "real"): void { + const pathFromRoot = relative(repoRoot, candidate); + if ( + pathFromRoot === ".." || + pathFromRoot.startsWith(`..${sep}`) || + isAbsolute(pathFromRoot) + ) { + throw new Error(`Reconciliation code anchor escapes the repository (${kind} path).`); + } +} + +function isSensitivePath(file: string): boolean { + const segments = file.toLowerCase().split("/"); + const basename = segments.at(-1) ?? ""; + if ( + segments.includes(".git") || + segments.includes(".ssh") || + segments.includes(".aws") || + segments.includes(".gnupg") || + segments.includes(".kube") + ) { + return true; + } + if ( + basename === ".npmrc" || + basename === ".pypirc" || + basename === ".netrc" || + basename === "credentials" || + basename === "credentials.json" || + basename === "secret.json" || + basename === "secrets.json" || + /^id_(?:rsa|dsa|ecdsa|ed25519)(?:\.pub)?$/.test(basename) || + /\.(?:pem|key|p12|pfx|jks|keystore)$/.test(basename) + ) { + return true; + } + if (/^\.env(?:\.|$)/.test(basename)) { + return !/\.(?:example|sample|template)$/.test(basename); + } + return false; +} + +function looksBinary(bytes: Buffer): boolean { + if (bytes.includes(0)) return true; + const sample = bytes.subarray(0, Math.min(bytes.length, 8_192)); + let controls = 0; + for (const byte of sample) { + if (byte < 9 || (byte > 13 && byte < 32)) controls += 1; + } + return sample.length > 0 && controls / sample.length > 0.1; +} + +function containsHighConfidenceSecret(source: string): boolean { + const secretPatterns = [ + /-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----/, + /\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/, + /\bgh[pousr]_[A-Za-z0-9]{36,}\b/, + /\bgithub_pat_[A-Za-z0-9_]{22,}\b/, + /\bxox[baprs]-[A-Za-z0-9-]{20,}\b/, + /\bAIza[0-9A-Za-z_-]{35}\b/, + /\bsk-(?:(?:proj|ant)-)?[A-Za-z0-9_-]{20,}\b/, + /\bsk_(?:live|test)_[A-Za-z0-9]{16,}\b/, + ]; + return secretPatterns.some((pattern) => pattern.test(source)); +} + +function sourceLineRecords(source: string): string[] { + if (source.length === 0) return []; + return source.match(/[^\r\n]*(?:\r\n|\r|\n|$)/g)?.filter((record) => record.length > 0) ?? []; +} + +function utf8Prefix(value: string, maxBytes: number): string { + let used = 0; + let prefix = ""; + for (const character of value) { + const bytes = Buffer.byteLength(character, "utf8"); + if (used + bytes > maxBytes) break; + prefix += character; + used += bytes; + } + return prefix; +} + +function representedLineCount(snippet: string): number { + const newlines = snippet.match(/\r\n|\r|\n/g)?.length ?? 0; + return newlines + (/(?:\r\n|\r|\n)$/.test(snippet) ? 0 : 1); +} + +function clampLine(value: number, maximum: number): number { + return Math.max(1, Math.min(maximum, value)); +} + +function omitted( + base: { + version_id: string; + object_type: "claim" | "component"; + anchor: ClaimCodeAnchor; + normalized_path: string; + }, + reason: OmissionReason, +): ManagedReconciliationCodeEvidenceEntry { + return { + ...base, + status: "omitted", + truncated: true, + omission_reason: reason, + }; +} + +function compareVersionedAnchors(left: VersionedCodeAnchor, right: VersionedCodeAnchor): number { + return compareStrings(left.versionId, right.versionId) || + compareStrings(left.objectType, right.objectType) || + compareStrings(left.anchor.file, right.anchor.file) || + compareStrings(left.anchor.symbol ?? "", right.anchor.symbol ?? ""); +} + +function compareStrings(left: string, right: string): number { + return left < right ? -1 : left > right ? 1 : 0; +} + +function assertExactEvidenceCheckout(repoRoot: string, attestedGitSha: string): void { + const git = (arguments_: string[]): string => execFileSync("git", ["-C", repoRoot, ...arguments_], { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }).trim(); + const head = git(["rev-parse", "HEAD"]).toLowerCase(); + if (head !== attestedGitSha.toLowerCase()) { + throw new Error( + `Reconciliation code evidence checkout ${head} does not equal attested SHA ${attestedGitSha}.`, + ); + } + if (git(["status", "--porcelain", "--untracked-files=all"]).length > 0) { + throw new Error("Reconciliation code evidence requires a clean exact-SHA checkout."); + } +} + +function canonicalJson(value: unknown): string { + if (Array.isArray(value)) return `[${value.map((entry) => canonicalJson(entry)).join(",")}]`; + if (value !== null && typeof value === "object") { + const record = value as Record; + return `{${Object.keys(record).sort().map((key) => + `${JSON.stringify(key)}:${canonicalJson(record[key])}` + ).join(",")}}`; + } + const serialized = JSON.stringify(value); + if (serialized === undefined) throw new Error("Cannot canonicalize undefined reconciliation evidence."); + return serialized; +} + +function isMissingPathError(error: unknown): boolean { + return error instanceof Error && + "code" in error && + (error as NodeJS.ErrnoException).code === "ENOENT"; +} diff --git a/libs/knowledge-graph/managed-client.ts b/libs/knowledge-graph/managed-client.ts index 7e8e465..99ba7ef 100644 --- a/libs/knowledge-graph/managed-client.ts +++ b/libs/knowledge-graph/managed-client.ts @@ -217,7 +217,9 @@ export class ManagedGraphMemoryClient implements GraphMemoryProvider { const login = this.credentials?.user.githubLogin; return { ...view, - working_users: [...new Set(login === undefined ? view.working_users : [login, ...view.working_users])], + working_users: uniqueGithubLogins( + login === undefined ? view.working_users : [login, ...view.working_users], + ), }; } @@ -396,9 +398,7 @@ function githubRepository(remoteUrl: string | undefined): string | undefined { function viewQuery(view: ManagedGraphView | undefined): string { if (view === undefined) return ""; const query = new URLSearchParams({ base: view.base }); - if (view.working_users?.length === 0 && - view.memory_pr_id === undefined && - view.include_quarantined !== true) { + if (view.working_users?.length === 0) { query.set("main_only", "true"); } for (const user of view.working_users ?? []) query.append("working_user", user); @@ -407,6 +407,16 @@ function viewQuery(view: ManagedGraphView | undefined): string { return `?${query.toString()}`; } +function uniqueGithubLogins(logins: string[]): string[] { + const seen = new Set(); + return logins.filter((login) => { + const key = login.toLowerCase(); + if (seen.has(key)) return false; + seen.add(key); + return true; + }); +} + function responseCapabilities(response: Response): Set { return new Set( (response.headers.get(managedCapabilitiesHeader) ?? "") diff --git a/libs/managed/protocol.ts b/libs/managed/protocol.ts index bdfce3a..644cfdc 100644 --- a/libs/managed/protocol.ts +++ b/libs/managed/protocol.ts @@ -6,6 +6,7 @@ export const managedClientCapabilities = [ "graph-selectors-v1", "memory-pr-v1", "oidc-reconciliation-v1", + "reconciliation-code-evidence-v1", ] as const; export type ManagedClientCapability = (typeof managedClientCapabilities)[number]; export const managedClientVersionHeader = "x-greplica-client-version"; @@ -356,6 +357,58 @@ export const ProposalAnchorAuditSchema = Type.Object({ fingerprints: Type.Record(Type.String(), Type.Record(Type.String(), Type.String())), }); +export const ReconciliationCodeEvidenceAnchorSchema = Type.Object({ + file: Type.String({ minLength: 1, maxLength: 512 }), + symbol: Type.Optional(Type.String({ minLength: 1, maxLength: 512 })), +}); + +export const ReconciliationCodeEvidenceEntrySchema = Type.Object({ + version_id: Type.String({ minLength: 1, maxLength: 512 }), + object_type: Type.Union([Type.Literal("claim"), Type.Literal("component")]), + anchor: ReconciliationCodeEvidenceAnchorSchema, + status: Type.Union([ + Type.Literal("resolved"), + Type.Literal("file_only"), + Type.Literal("missing_file"), + Type.Literal("missing_symbol"), + Type.Literal("ambiguous_symbol"), + Type.Literal("unsupported_language"), + Type.Literal("omitted"), + ]), + normalized_path: Type.String({ minLength: 1, maxLength: 512 }), + anchor_start_line: Type.Optional(Type.Integer({ minimum: 1 })), + anchor_end_line: Type.Optional(Type.Integer({ minimum: 1 })), + snippet_start_line: Type.Optional(Type.Integer({ minimum: 1 })), + snippet_end_line: Type.Optional(Type.Integer({ minimum: 1 })), + snippet: Type.Optional(Type.String({ maxLength: 4096 })), + snippet_sha256: Type.Optional(Type.String({ pattern: "^[0-9a-f]{64}$" })), + anchor_fingerprint: Type.Optional(Type.String({ pattern: "^[0-9a-f]{16}$" })), + truncated: Type.Optional(Type.Boolean()), + omission_reason: Type.Optional(Type.Union([ + Type.Literal("missing_file"), + Type.Literal("no_resolved_span"), + Type.Literal("sensitive_path"), + Type.Literal("binary"), + Type.Literal("file_too_large"), + Type.Literal("unreadable"), + Type.Literal("total_budget"), + Type.Literal("submodule"), + Type.Literal("sensitive_content"), + Type.Literal("not_in_attested_tree"), + Type.Literal("symlink"), + Type.Literal("not_regular_blob"), + ])), +}); + +export const ReconciliationCodeEvidenceSchema = Type.Object({ + managed_repo_id: Type.String({ format: "uuid" }), + repository: Type.String({ minLength: 3, maxLength: 255 }), + attested_git_sha: Type.String({ pattern: "^[0-9a-fA-F]{40}$" }), + truncated: Type.Boolean(), + entries: Type.Array(ReconciliationCodeEvidenceEntrySchema, { maxItems: 128 }), + evidence_sha256: Type.String({ pattern: "^[0-9a-f]{64}$" }), +}); + export const ProposalReviewSchema = Type.Object({ valid: Type.Boolean(), errors: Type.Array(Type.String()), @@ -680,6 +733,7 @@ export const ReconciliationCandidateSchema = Type.Object({ memory_pr_id: Type.String(), merge_sha: Type.String({ minLength: 7 }), code_merge_sha: Type.Optional(Type.String({ pattern: "^[0-9a-fA-F]{40}$" })), + code_evidence_required: Type.Optional(Type.Literal(true)), memory_commit_ids: Type.Array(Type.String(), { minItems: 1, uniqueItems: true }), commits: Type.Array(Type.Object({ memory_commit_id: Type.String(), @@ -724,6 +778,7 @@ export const ReconciliationAttestationSchema = Type.Object({ merge_sha: Type.String({ minLength: 7 }), code_merge_sha: Type.Optional(Type.String({ pattern: "^[0-9a-fA-F]{40}$" })), code_merge_is_ancestor: Type.Optional(Type.Boolean()), + code_evidence: Type.Optional(ReconciliationCodeEvidenceSchema), memory_pr_id: Type.String(), memory_commit_ids: Type.Array(Type.String(), { minItems: 1, uniqueItems: true }), ancestry: Type.Array(ReconciliationProofSchema, { minItems: 1 }), @@ -897,6 +952,8 @@ export type ManagedProposal = Static; export type ManagedMemoryPr = Static; export type ManagedMemoryStatus = Static; export type ManagedPromotionCleanup = Static; +export type ManagedReconciliationCodeEvidenceEntry = Static; +export type ManagedReconciliationCodeEvidence = Static; export type ManagedReconciliationAttestation = Static; export type ManagedReconciliationCandidate = Static; export type ManagedReconciliationAttestationResult = Static; diff --git a/package.json b/package.json index ea1b50b..d6dd889 100644 --- a/package.json +++ b/package.json @@ -24,8 +24,9 @@ "smoke:copilot": "npm run build && node scripts/smoke-copilot-install.mjs", "smoke:opencode": "npm run build && node scripts/smoke-opencode-install.mjs", "smoke:cursor": "npm run build && node scripts/smoke-cursor-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-repo-installations.js && node scripts/check-managed-cli.js && node scripts/check-managed-collaboration.js && node scripts/check-graph-view.js && node scripts/check-graph-view-offline-browser.js && node scripts/check-source-memberships.js && node scripts/check-proposal-validate.js && node scripts/check-bm25-tokenizer.js && node scripts/check-anchor-drift.js && node scripts/check-find-similar-claims.js && node scripts/check-apply-proposal-dedupe.js && node scripts/check-opencode-sqlite-transcript.js && node scripts/check-agent-runner-spawn-error.js", - "test:managed-collaboration": "npm run build && node scripts/check-managed-collaboration.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-repo-installations.js && node scripts/check-managed-cli.js && node scripts/check-managed-collaboration.js && node scripts/check-reconciliation-code-evidence.js && node scripts/check-graph-view.js && node scripts/check-graph-view-offline-browser.js && node scripts/check-source-memberships.js && node scripts/check-proposal-validate.js && node scripts/check-bm25-tokenizer.js && node scripts/check-anchor-drift.js && node scripts/check-find-similar-claims.js && node scripts/check-apply-proposal-dedupe.js && node scripts/check-opencode-sqlite-transcript.js && node scripts/check-agent-runner-spawn-error.js", + "test:managed-collaboration": "npm run build && node scripts/check-managed-collaboration.js && node scripts/check-reconciliation-code-evidence.js", + "test:reconciliation-code-evidence": "npm run build && node scripts/check-reconciliation-code-evidence.js", "test:repo-installations": "npm run build && node scripts/check-repo-installations.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", diff --git a/scripts/check-managed-cli.js b/scripts/check-managed-cli.js index a51cbec..8bc38ab 100644 --- a/scripts/check-managed-cli.js +++ b/scripts/check-managed-cli.js @@ -30,6 +30,41 @@ let deviceStarts = 0; let requestCount = 0; let importedSnapshot; let memoryPrContextBody; +const graphReadUrls = []; +const graphViewUrls = []; +const graphContextBodies = []; +const proposalRecord = { + id: "proposal-renamed-author", + memory_commit: { + id: "memory-commit-renamed-author", + proposal_id: "proposal-renamed-author", + scope_id: "working-user-1", + scope_name: "working/contributor-1", + state: "active", + author: { + id: "10000000-0000-4000-8000-000000000000", + github_user_id: "1", + github_login: "contributor-current", + created_at: now, + }, + author_github_login_snapshot: "contributor-old", + session_refs: [{ + id: "codex-session:proposal-1", + agent_platform: "codex", + }], + agent_platform: "codex", + git: { + git_head: "b".repeat(40), + head_repository: "example/project", + head_ref: "feature/memory", + branch: "feature/memory", + dirty: false, + }, + created_at: now, + }, + proposal: { title: "Rename-safe provenance" }, + created_at: now, +}; const server = createServer(async (request, response) => { requestCount += 1; @@ -128,15 +163,21 @@ const server = createServer(async (request, response) => { }); return; } - if (request.method === "GET" && request.url === `/v1/repos/${managedRepoId}/graph`) { + if ( + request.method === "GET" && + new URL(request.url, "http://127.0.0.1").pathname === `/v1/repos/${managedRepoId}/graph` + ) { + graphReadUrls.push(request.url); send(200, { components: [], flows: [], claims: [], sources: [], edges: [] }, { "x-greplica-repo-role": managedRepository.effective_role, "x-greplica-access-status": "active", + "x-greplica-capabilities": "personal-working-v1,graph-selectors-v1,memory-pr-v1", }); return; } if (request.method === "POST" && request.url === `/v1/repos/${managedRepoId}/graph/context`) { memoryPrContextBody = body; + graphContextBodies.push(body); send(200, { query: body.query, search_config_version: "test", @@ -149,6 +190,16 @@ const server = createServer(async (request, response) => { }, { "x-greplica-capabilities": "personal-working-v1,graph-selectors-v1,memory-pr-v1" }); return; } + if ( + request.method === "GET" && + new URL(request.url, "http://127.0.0.1").pathname === `/v1/repos/${managedRepoId}/graph/view-data` + ) { + graphViewUrls.push(request.url); + send(200, {}, { + "x-greplica-capabilities": "personal-working-v1,graph-selectors-v1,memory-pr-v1", + }); + return; + } if (request.method === "GET" && request.url === `/v1/repos/${managedRepoId}/memory/status`) { send(200, { queued: 0, @@ -170,27 +221,14 @@ const server = createServer(async (request, response) => { return; } if (request.method === "GET" && request.url === `/v1/repos/${managedRepoId}/proposals`) { - send(200, [{ - id: "proposal-renamed-author", - memory_commit: { - id: "memory-commit-renamed-author", - proposal_id: "proposal-renamed-author", - scope_id: "working-user-1", - scope_name: "working/contributor-1", - state: "active", - author: { - id: "10000000-0000-4000-8000-000000000000", - github_user_id: "1", - github_login: "contributor-current", - created_at: now, - }, - author_github_login_snapshot: "contributor-old", - session_refs: [], - created_at: now, - }, - proposal: { title: "Rename-safe provenance" }, - created_at: now, - }]); + send(200, [proposalRecord]); + return; + } + if ( + request.method === "GET" && + request.url === `/v1/repos/${managedRepoId}/proposals/proposal-renamed-author` + ) { + send(200, proposalRecord); return; } if (request.method === "GET" && request.url === `/v1/repos/${managedRepoId}/memory-prs`) { @@ -391,6 +429,121 @@ try { ); assert.match(proposalList.stdout, /contributor-current \(formerly contributor-old\)/, "proposal summaries must preserve both current and historical GitHub logins"); + assert.match(proposalList.stdout, /head:b{40}/, + "human proposal list output must expose the immutable Git head"); + assert.match(proposalList.stdout, /agent:codex/, + "human proposal list output must expose the creating agent platform"); + const proposalShow = await run( + process.execPath, + [cliPath, "proposal", "show", "proposal-renamed-author"], + managedRepo, + env, + ); + assert.match(proposalShow.stdout, /head:b{40}/, + "human proposal show output must expose the immutable Git head"); + assert.match(proposalShow.stdout, /agent:codex/, + "human proposal show output must expose the creating agent platform"); + + const selectedContext = await run(process.execPath, [ + cliPath, + "graph", + "context", + "authentication", + "--with-working", + "alice", + "--with-working=bob", + "--with-working", + "ALICE", + "--memory-pr", + "memory-pr-selector", + "--include-quarantined", + "--json", + ], managedRepo, env); + assert.match(selectedContext.stdout, /"query": "authentication"/); + assert.deepEqual(graphContextBodies.at(-1).view, { + base: "main", + working_users: ["contributor-1", "alice", "bob"], + memory_pr_id: "memory-pr-selector", + include_quarantined: true, + }, "context selectors must deduplicate users and preserve every explicit overlay"); + + await run(process.execPath, [ + cliPath, + "graph", + "view", + "--with-working", + "alice", + "--with-working=alice", + "--with-working", + "bob", + "--with-working", + "ALICE", + "--memory-pr=memory-pr-selector", + "--include-quarantined", + "--json", + "--no-open", + ], managedRepo, env); + const selectedViewUrl = new URL(graphViewUrls.at(-1), "http://127.0.0.1"); + assert.deepEqual(selectedViewUrl.searchParams.getAll("working_user"), ["contributor-1", "alice", "bob"]); + assert.equal(selectedViewUrl.searchParams.get("memory_pr_id"), "memory-pr-selector"); + assert.equal(selectedViewUrl.searchParams.get("include_quarantined"), "true"); + assert.equal(selectedViewUrl.searchParams.get("base"), "main"); + + await run(process.execPath, [ + cliPath, "graph", "context", "canonical only", "--main-only", "--json", + ], managedRepo, env); + assert.deepEqual(graphContextBodies.at(-1).view, { + base: "main", + working_users: [], + }); + await run(process.execPath, [ + cliPath, + "graph", + "context", + "canonical quarantine", + "--main-only", + "--include-quarantined", + "--json", + ], managedRepo, env); + assert.deepEqual(graphContextBodies.at(-1).view, { + base: "main", + working_users: [], + include_quarantined: true, + }, "main-only must suppress personal working without suppressing an explicit quarantine overlay"); + await run(process.execPath, [ + cliPath, + "graph", + "view", + "--main-only", + "--memory-pr", + "memory-pr-selector", + "--json", + "--no-open", + ], managedRepo, env); + const mainOnlyViewUrl = new URL(graphViewUrls.at(-1), "http://127.0.0.1"); + assert.equal(mainOnlyViewUrl.searchParams.get("main_only"), "true"); + assert.equal(mainOnlyViewUrl.searchParams.get("memory_pr_id"), "memory-pr-selector"); + assert.deepEqual(mainOnlyViewUrl.searchParams.getAll("working_user"), []); + await run(process.execPath, [ + cliPath, "graph", "read", "--main-only", "--json", + ], managedRepo, env); + const mainOnlyReadUrl = new URL(graphReadUrls.at(-1), "http://127.0.0.1"); + assert.equal(mainOnlyReadUrl.searchParams.get("main_only"), "true"); + + const requestsBeforeInvalidSelectors = requestCount; + for (const incompatibleArgs of [ + [cliPath, "graph", "context", "query", "--main-only", "--with-working", "alice"], + [cliPath, "graph", "view", "--memory-pr", "one", "--memory-pr=two", "--json"], + ]) { + const incompatible = await runFailure(process.execPath, incompatibleArgs, managedRepo, env); + assert.match( + incompatible.stderr, + /--main-only cannot be combined|Specify --memory-pr only once/, + "incompatible graph selectors must fail before a managed request", + ); + } + assert.equal(requestCount, requestsBeforeInvalidSelectors, + "invalid selector combinations must be rejected before network access"); const directDefaultMemoryPr = await run( process.execPath, [cliPath, "memory", "pr", "list"], diff --git a/scripts/check-managed-collaboration.js b/scripts/check-managed-collaboration.js index 506ad80..e8c7c3a 100644 --- a/scripts/check-managed-collaboration.js +++ b/scripts/check-managed-collaboration.js @@ -1,5 +1,6 @@ import assert from "node:assert/strict"; import { execFileSync, spawn } from "node:child_process"; +import { createHash } from "node:crypto"; import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; import { createServer } from "node:http"; import { tmpdir } from "node:os"; @@ -24,6 +25,9 @@ const { renderGraphContextMarkdown } = await import( const { fingerprintClaimAnchors } = await import( "../dist/libs/knowledge-graph/code-anchors/fingerprint.js" ); +const { reconciliationCodeEvidenceHash } = await import( + "../dist/libs/knowledge-graph/code-anchors/evidence.js" +); const { migrate } = await import("../dist/libs/storage/sqlite/migrate.js"); const action = readFileSync(fileURLToPath(new URL("../action.yml", import.meta.url)), "utf8"); @@ -629,6 +633,7 @@ const server = createServer(async (incoming, response) => { } assert.equal(incoming.headers.authorization, "Bearer github-oidc-token"); assert.match(incoming.headers["x-greplica-capabilities"], /oidc-reconciliation-v1/); + assert.match(incoming.headers["x-greplica-capabilities"], /reconciliation-code-evidence-v1/); assert.equal(incoming.headers["x-greplica-client-version"], "0.2.1"); if (url.pathname.endsWith("/memory/reconcile/candidate")) { candidateCalls += 1; @@ -736,6 +741,7 @@ const server = createServer(async (incoming, response) => { memory_pr_id: "memory-pr-1", merge_sha: mergeSha, code_merge_sha: codeMergeSha, + code_evidence_required: true, memory_commit_ids: ["commit-1", "commit-dependency-1"], commits: [{ memory_commit_id: "commit-1", @@ -772,7 +778,7 @@ const server = createServer(async (incoming, response) => { component: { id: "component.logical", name: "Version-keyed component audit", - code_anchor: "example.ts", + code_anchor: "example.ts, example.ts", }, }, { version_id: "version-component-missing", @@ -840,6 +846,7 @@ try { assert.match(result.stdout, /"accepted": true/); assert.match(result.stdout, /"reconciliation_count": 2/); assert.match(result.stdout, /"audited_component_versions": 2/); + assert.match(result.stdout, /"code_evidence_entries": 3/); assert.match(result.stdout, /"skipped_count": 2/); assert.match(result.stdout, /"reason": "git_head_not_in_pr_delta"/); assert.equal(candidateCalls, 5); @@ -890,6 +897,41 @@ try { ), "missing component anchors must fail under immutable component version IDs"); assert.deepEqual(attestations[0].anchor_audit.fingerprints["version-component-missing"], {}); assert.equal(attestations[0].repository, "example/project"); + assert.equal(attestations[0].code_evidence.managed_repo_id, installation.managedRepoId); + assert.equal(attestations[0].code_evidence.repository, "example/project"); + assert.equal(attestations[0].code_evidence.attested_git_sha, mergeSha); + assert.equal(attestations[0].code_evidence.entries.length, 3, + "duplicate component anchors must not inflate the repair evidence packet"); + const { + evidence_sha256: submittedEvidenceHash, + ...submittedEvidencePayload + } = attestations[0].code_evidence; + assert.equal( + submittedEvidenceHash, + reconciliationCodeEvidenceHash(submittedEvidencePayload), + "the Action must hash the deterministic packet bound to the exact checkout", + ); + const exactClaimEvidence = attestations[0].code_evidence.entries.find((entry) => + entry.version_id === "version-1" + ); + assert.equal(exactClaimEvidence.status, "resolved"); + assert.match(exactClaimEvidence.snippet, /return 2/); + assert.doesNotMatch(exactClaimEvidence.snippet, /return 1/); + assert.equal( + exactClaimEvidence.snippet_sha256, + createHash("sha256").update(exactClaimEvidence.snippet, "utf8").digest("hex"), + ); + const exactComponentEvidence = attestations[0].code_evidence.entries.filter((entry) => + entry.version_id === "version-component-1" + ); + assert.equal(exactComponentEvidence.length, 1); + assert.equal(exactComponentEvidence[0].status, "file_only"); + assert.match(exactComponentEvidence[0].snippet, /return 2/); + const missingComponentEvidence = attestations[0].code_evidence.entries.find((entry) => + entry.version_id === "version-component-missing" + ); + assert.equal(missingComponentEvidence.status, "missing_file"); + assert.equal(Object.hasOwn(missingComponentEvidence, "snippet"), false); assert.deepEqual(attestations[1].ancestry, [{ memory_commit_id: "commit-2", git_head: baseSha, @@ -900,6 +942,8 @@ try { "legacy candidates without code-merge proof must remain compatible"); assert.equal(Object.hasOwn(attestations[1], "code_merge_is_ancestor"), false, "legacy attestations must not send unsupported proof fields"); + assert.equal(Object.hasOwn(attestations[1], "code_evidence"), false, + "legacy candidates must retain their exact attestation shape"); assert.equal( exec("git", ["-C", repoRoot, "rev-parse", "FETCH_HEAD"]).trim(), featureSha, diff --git a/scripts/check-reconciliation-code-evidence.js b/scripts/check-reconciliation-code-evidence.js new file mode 100644 index 0000000..f554186 --- /dev/null +++ b/scripts/check-reconciliation-code-evidence.js @@ -0,0 +1,402 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { execFileSync } from "node:child_process"; +import { + lstatSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + readlinkSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join, relative } from "node:path"; + +const { + buildReconciliationCodeEvidence, + hasValidReconciliationCodeEvidenceHash, + isReconciliationCodeEvidenceEntryActionable, + reconciliationCodeEvidenceHash, + reconciliationCodeEvidenceLimits, +} = await import("../dist/libs/knowledge-graph/code-anchors/evidence.js"); + +const temporary = mkdtempSync(join(tmpdir(), "greplica-code-evidence-")); +const repoRoot = join(temporary, "repo"); +const submoduleSource = join(temporary, "submodule-source"); +const outsideSecretPath = join(temporary, "outside-secret.txt"); +mkdirSync(repoRoot); +mkdirSync(submoduleSource); +writeFileSync(outsideSecretPath, "OUTSIDE_SECRET_MUST_NEVER_BE_READ\n"); +exec("git", ["init", "--quiet", submoduleSource]); +exec("git", ["-C", submoduleSource, "config", "user.email", "test@example.com"]); +exec("git", ["-C", submoduleSource, "config", "user.name", "Test"]); +writeFileSync(join(submoduleSource, "submodule-secret.ts"), "SUBMODULE_SECRET_MUST_NEVER_BE_READ\n"); +exec("git", ["-C", submoduleSource, "add", "."]); +exec("git", ["-C", submoduleSource, "commit", "--quiet", "-m", "submodule fixture"]); +exec("git", ["init", "--quiet", repoRoot]); +exec("git", ["-C", repoRoot, "config", "user.email", "test@example.com"]); +exec("git", ["-C", repoRoot, "config", "user.name", "Test"]); + +mkdirSync(join(repoRoot, "src")); +const exactPath = join(repoRoot, "src", "exact.ts"); +writeFileSync(join(repoRoot, ".gitignore"), "ignored-secret.txt\noutside-link.txt\n"); +writeFileSync(join(repoRoot, "ignored-secret.txt"), "IGNORED_SECRET_MUST_NEVER_BE_READ\n"); +symlinkSync("ignored-secret.txt", join(repoRoot, "ignored-link.txt")); +writeFileSync(exactPath, [ + "export function exactValue() {", + " return 1;", + "}", + "", +].join("\n")); +writeFileSync( + join(repoRoot, "large.txt"), + Array.from({ length: 300 }, (_, index) => `line-${String(index + 1).padStart(3, "0")}-${"x".repeat(80)}`).join("\n") + "\n", +); +writeFileSync(join(repoRoot, ".env"), "RECONCILIATION_TEST_SECRET=must-not-appear\n"); +const providerToken = `ghp_${"A".repeat(36)}`; +writeFileSync(join(repoRoot, "leaky.ts"), `export const credential = "${providerToken}";\n`); +writeFileSync(join(repoRoot, "private-material.txt"), [ + "-----BEGIN PRIVATE KEY-----", + "PEM_SECRET_MUST_NEVER_BE_READ", + "-----END PRIVATE KEY-----", + "", +].join("\n")); +writeFileSync(join(repoRoot, "binary.bin"), Buffer.from([0, 1, 2, 3, 255, 0, 7])); +writeFileSync( + join(repoRoot, "huge.txt"), + Buffer.alloc(reconciliationCodeEvidenceLimits.maxReadableFileBytes + 1, "h"), +); +symlinkSync(outsideSecretPath, join(repoRoot, "outside-link.txt")); +exec("git", [ + "-c", + "protocol.file.allow=always", + "-C", + repoRoot, + "submodule", + "add", + "--quiet", + submoduleSource, + "embedded", +]); +exec("git", ["-C", repoRoot, "add", "."]); +exec("git", ["-C", repoRoot, "commit", "--quiet", "-m", "evidence fixture"]); +const oldSha = git(repoRoot, ["rev-parse", "HEAD"]); + +writeFileSync(exactPath, [ + "export function exactValue() {", + " return 2;", + "}", + "", +].join("\n")); +exec("git", ["-C", repoRoot, "add", "src/exact.ts"]); +exec("git", ["-C", repoRoot, "commit", "--quiet", "-m", "change exact code"]); +const exactSha = git(repoRoot, ["rev-parse", "HEAD"]); + +const envelope = { + managedRepoId: "11111111-1111-4111-8111-111111111111", + repository: "example/project", +}; +const hashVectorSnippet = "const x = 1;\n"; +const hashVectorPayload = { + managed_repo_id: envelope.managedRepoId, + repository: envelope.repository, + attested_git_sha: "a".repeat(40), + truncated: false, + entries: [{ + version_id: "v1", + object_type: "claim", + anchor: { file: "src/a.ts", symbol: "run" }, + status: "resolved", + normalized_path: "src/a.ts", + anchor_start_line: 2, + anchor_end_line: 3, + snippet_start_line: 1, + snippet_end_line: 4, + snippet: hashVectorSnippet, + snippet_sha256: "95befdd6e691d4d89031a2a2901cc74fc6242109980b060e08ddf87829924483", + anchor_fingerprint: "0123456789abcdef", + }], +}; +assert.equal( + reconciliationCodeEvidenceHash(hashVectorPayload), + "d78af6307c46ac5f0f974d16c0d97b6ee992464b7f28c976591c8681432b66cc", + "canonical evidence hashing must remain interoperable with managed validation", +); +const exactAnchor = { + versionId: "version-exact", + objectType: "claim", + anchor: { file: "src/exact.ts", symbol: "exactValue" }, +}; + +exec("git", ["-C", repoRoot, "checkout", "--quiet", "--detach", oldSha]); +const oldEvidence = await buildReconciliationCodeEvidence(repoRoot, { + ...envelope, + attestedGitSha: oldSha, + anchors: [exactAnchor], +}); +assert.match(oldEvidence.entries[0].snippet, /return 1/); +exec("git", ["-C", repoRoot, "checkout", "--quiet", "--detach", exactSha]); +const exactEvidence = await buildReconciliationCodeEvidence(repoRoot, { + ...envelope, + attestedGitSha: exactSha, + anchors: [exactAnchor], +}); +assert.equal(exactEvidence.attested_git_sha, exactSha); +assert.equal(exactEvidence.entries[0].status, "resolved"); +assert.match(exactEvidence.entries[0].snippet, /return 2/); +assert.equal( + exactEvidence.entries[0].snippet_sha256, + sha256(exactEvidence.entries[0].snippet), + "snippet hash must cover the exact transmitted UTF-8 bytes", +); +assert.notEqual(exactEvidence.entries[0].snippet_sha256, oldEvidence.entries[0].snippet_sha256); +assert.notEqual(exactEvidence.entries[0].anchor_fingerprint, oldEvidence.entries[0].anchor_fingerprint); +assert.notEqual(exactEvidence.evidence_sha256, oldEvidence.evidence_sha256); + +const { evidence_sha256: evidenceHash, ...evidencePayload } = exactEvidence; +assert.equal(evidenceHash, reconciliationCodeEvidenceHash(evidencePayload)); +assert.equal(hasValidReconciliationCodeEvidenceHash(exactEvidence), true); +assert.equal(isReconciliationCodeEvidenceEntryActionable(exactEvidence.entries[0]), true); +const tamperedPayload = structuredClone(evidencePayload); +tamperedPayload.entries[0].snippet = tamperedPayload.entries[0].snippet.replace("return 2", "return 3"); +assert.notEqual(evidenceHash, reconciliationCodeEvidenceHash(tamperedPayload)); +const tamperedPacket = { + ...tamperedPayload, + evidence_sha256: evidenceHash, +}; +assert.equal(hasValidReconciliationCodeEvidenceHash(tamperedPacket), false); +assert.equal(isReconciliationCodeEvidenceEntryActionable(tamperedPacket.entries[0]), false); + +const deterministic = await buildReconciliationCodeEvidence(repoRoot, { + ...envelope, + attestedGitSha: exactSha, + anchors: [exactAnchor, exactAnchor, structuredClone(exactAnchor)], +}); +assert.equal(deterministic.entries.length, 1, "identical version anchors must be deduplicated"); +assert.deepEqual(deterministic, exactEvidence, "packet ordering and hashing must be deterministic"); + +const bounded = await buildReconciliationCodeEvidence(repoRoot, { + ...envelope, + attestedGitSha: exactSha, + anchors: [{ + versionId: "version-large", + objectType: "component", + anchor: { file: "large.txt" }, + }], +}); +const boundedEntry = bounded.entries[0]; +assert.equal(boundedEntry.status, "file_only"); +assert.equal(boundedEntry.truncated, true); +assert.ok(Buffer.byteLength(boundedEntry.snippet, "utf8") <= reconciliationCodeEvidenceLimits.maxSnippetBytes); +assert.ok( + boundedEntry.snippet_end_line - boundedEntry.snippet_start_line + 1 <= + reconciliationCodeEvidenceLimits.maxSnippetLines, +); + +const aggregate = await buildReconciliationCodeEvidence(repoRoot, { + ...envelope, + attestedGitSha: exactSha, + anchors: Array.from({ length: 20 }, (_, index) => ({ + versionId: `version-large-${String(index).padStart(2, "0")}`, + objectType: "claim", + anchor: { file: "large.txt" }, + })), +}); +const aggregateBytes = aggregate.entries.reduce( + (total, entry) => total + Buffer.byteLength(entry.snippet ?? "", "utf8"), + 0, +); +assert.ok(aggregateBytes <= reconciliationCodeEvidenceLimits.maxTotalSnippetBytes); +assert.equal(aggregate.truncated, true); +assert.ok( + aggregate.entries.some((entry) => entry.omission_reason === "total_budget"), + "aggregate budget exhaustion must be explicit", +); + +const omissions = await buildReconciliationCodeEvidence(repoRoot, { + ...envelope, + attestedGitSha: exactSha, + anchors: [{ + versionId: "version-env", + objectType: "claim", + anchor: { file: ".env" }, + }, { + versionId: "version-binary", + objectType: "claim", + anchor: { file: "binary.bin" }, + }, { + versionId: "version-huge", + objectType: "claim", + anchor: { file: "huge.txt" }, + }, { + versionId: "version-missing", + objectType: "claim", + anchor: { file: "missing.ts" }, + }, { + versionId: "version-sensitive-content", + objectType: "claim", + anchor: { file: "leaky.ts" }, + }, { + versionId: "version-submodule", + objectType: "claim", + anchor: { file: "embedded/submodule-secret.ts" }, + }, { + versionId: "version-private-key", + objectType: "claim", + anchor: { file: "private-material.txt" }, + }, { + versionId: "version-ignored", + objectType: "claim", + anchor: { file: "ignored-secret.txt" }, + }, { + versionId: "version-ignored-link", + objectType: "claim", + anchor: { file: "ignored-link.txt" }, + }], +}); +assert.equal(omissions.entries.find((entry) => entry.version_id === "version-env").omission_reason, "sensitive_path"); +assert.equal(omissions.entries.find((entry) => entry.version_id === "version-binary").omission_reason, "binary"); +assert.equal(omissions.entries.find((entry) => entry.version_id === "version-huge").omission_reason, "file_too_large"); +assert.equal(omissions.entries.find((entry) => entry.version_id === "version-missing").status, "missing_file"); +assert.equal( + omissions.entries.find((entry) => entry.version_id === "version-sensitive-content").omission_reason, + "sensitive_content", +); +assert.equal( + omissions.entries.find((entry) => entry.version_id === "version-submodule").omission_reason, + "submodule", +); +assert.equal( + omissions.entries.find((entry) => entry.version_id === "version-private-key").omission_reason, + "sensitive_content", +); +assert.equal( + omissions.entries.find((entry) => entry.version_id === "version-ignored").omission_reason, + "not_in_attested_tree", +); +assert.equal( + omissions.entries.find((entry) => entry.version_id === "version-ignored-link").omission_reason, + "symlink", +); +assert.doesNotMatch(JSON.stringify(omissions), /must-not-appear/); +assert.doesNotMatch(JSON.stringify(omissions), new RegExp(providerToken)); +assert.doesNotMatch(JSON.stringify(omissions), /SUBMODULE_SECRET_MUST_NEVER_BE_READ/); +assert.doesNotMatch(JSON.stringify(omissions), /PEM_SECRET_MUST_NEVER_BE_READ|BEGIN PRIVATE KEY/); +assert.doesNotMatch(JSON.stringify(omissions), /IGNORED_SECRET_MUST_NEVER_BE_READ/); +assert.ok(omissions.entries.every((entry) => + entry.omission_reason === "missing_file" || entry.snippet === undefined +)); +assert.ok(omissions.entries.every((entry) => !isReconciliationCodeEvidenceEntryActionable(entry))); + +await assert.rejects( + buildReconciliationCodeEvidence(repoRoot, { + ...envelope, + attestedGitSha: exactSha, + anchors: [{ + versionId: "version-outside", + objectType: "claim", + anchor: { file: "outside-link.txt" }, + }], + }), + /escapes the repository/, +); +for (const unsafePath of [ + "../outside-secret.txt", + "./exact.ts", + "nested/../exact.ts", + "/tmp/exact.ts", + "a".repeat(reconciliationCodeEvidenceLimits.maxPathBytes + 1), +]) { + await assert.rejects( + buildReconciliationCodeEvidence(repoRoot, { + ...envelope, + attestedGitSha: exactSha, + anchors: [{ + versionId: "version-unsafe", + objectType: "claim", + anchor: { file: unsafePath }, + }], + }), + /anchor path/, + ); +} + +await assert.rejects( + buildReconciliationCodeEvidence(repoRoot, { + ...envelope, + attestedGitSha: exactSha, + anchors: Array.from({ length: reconciliationCodeEvidenceLimits.maxEntries + 1 }, (_, index) => ({ + versionId: `version-${String(index).padStart(3, "0")}`, + objectType: "claim", + anchor: { file: "src/exact.ts" }, + })), + }), + /exceeds 128 unique anchors/, +); +await assert.rejects( + buildReconciliationCodeEvidence(repoRoot, { + ...envelope, + attestedGitSha: oldSha, + anchors: [exactAnchor], + }), + /does not equal attested SHA/, +); + +const cleanSnapshot = workingTreeSnapshot(repoRoot); +const beforeStatus = git(repoRoot, ["status", "--porcelain", "--untracked-files=all"]); +await buildReconciliationCodeEvidence(repoRoot, { + ...envelope, + attestedGitSha: exactSha, + anchors: [exactAnchor], +}); +assert.equal(git(repoRoot, ["status", "--porcelain", "--untracked-files=all"]), beforeStatus); +assert.deepEqual(workingTreeSnapshot(repoRoot), cleanSnapshot, "evidence collection must not write repository files"); + +writeFileSync(exactPath, readFileSync(exactPath, "utf8").replace("return 2", "return 9")); +await assert.rejects( + buildReconciliationCodeEvidence(repoRoot, { + ...envelope, + attestedGitSha: exactSha, + anchors: [exactAnchor], + }), + /clean exact-SHA checkout/, +); + +console.log("reconciliation code evidence checks passed"); + +function exec(command, args) { + return execFileSync(command, args, { encoding: "utf8" }); +} + +function git(root, args) { + return exec("git", ["-C", root, ...args]).trim(); +} + +function sha256(value) { + return createHash("sha256").update(value, "utf8").digest("hex"); +} + +function workingTreeSnapshot(root) { + const snapshot = []; + visit(root); + return snapshot; + + function visit(directory) { + for (const name of readdirSync(directory).sort()) { + if (directory === root && name === ".git") continue; + const path = join(directory, name); + const relativePath = relative(root, path); + const stats = lstatSync(path); + if (stats.isSymbolicLink()) { + snapshot.push([relativePath, "symlink", readlinkSync(path)]); + } else if (stats.isDirectory()) { + snapshot.push([relativePath, "directory"]); + visit(path); + } else { + snapshot.push([relativePath, "file", createHash("sha256").update(readFileSync(path)).digest("hex")]); + } + } + } +} From 64cfa2aa21a6061815eabb680ad26abf25c89a4e Mon Sep 17 00:00:00 2001 From: Kushal Date: Tue, 28 Jul 2026 21:42:53 -0700 Subject: [PATCH 19/27] ci: pin exact-code evidence action --- .github/workflows/reconcile.yml | 2 +- scripts/check-managed-collaboration.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/reconcile.yml b/.github/workflows/reconcile.yml index 4f9340e..007ecb9 100644 --- a/.github/workflows/reconcile.yml +++ b/.github/workflows/reconcile.yml @@ -32,7 +32,7 @@ jobs: # Keep this immutable. The managed service separately allowlists this # reusable workflow's signed job_workflow_sha. - name: Reconcile Memory PRs - uses: Autoloops/greplica@b7a7969b786f9af4f257fa4564628d2e40874454 + uses: Autoloops/greplica@39e0753400119868e63c932b17db322124b1963d with: managed-repo: ${{ inputs.managed-repo }} merge-sha: ${{ inputs.merge-sha }} diff --git a/scripts/check-managed-collaboration.js b/scripts/check-managed-collaboration.js index e8c7c3a..31816ba 100644 --- a/scripts/check-managed-collaboration.js +++ b/scripts/check-managed-collaboration.js @@ -49,7 +49,7 @@ assert.match(reusableWorkflow, /oidc-audience: greplica-managed/); assert.doesNotMatch(reusableWorkflow, /\$\{\{ inputs\.(?:api-url|oidc-audience) \}\}/); assert.match( reusableWorkflow, - /uses: Autoloops\/greplica@b7a7969b786f9af4f257fa4564628d2e40874454/, + /uses: Autoloops\/greplica@39e0753400119868e63c932b17db322124b1963d/, ); assert.doesNotMatch(reusableWorkflow, /uses: Autoloops\/greplica@(main|refs\/heads\/|v\d)/); From 225c2cf42db9b8085c0d10867b2e6a1ba3ba977c Mon Sep 17 00:00:00 2001 From: Kushal Date: Tue, 28 Jul 2026 21:44:47 -0700 Subject: [PATCH 20/27] fix: leave actor working composition to server --- libs/knowledge-graph/managed-client.ts | 8 ++++---- scripts/check-managed-cli.js | 6 +++--- scripts/check-managed-collaboration.js | 3 ++- 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/libs/knowledge-graph/managed-client.ts b/libs/knowledge-graph/managed-client.ts index 99ba7ef..5dbcb3b 100644 --- a/libs/knowledge-graph/managed-client.ts +++ b/libs/knowledge-graph/managed-client.ts @@ -214,12 +214,12 @@ export class ManagedGraphMemoryClient implements GraphMemoryProvider { private requestView(view: ManagedGraphView | undefined): ManagedGraphView | undefined { if (view?.working_users === undefined || view.working_users.length === 0) return view; - const login = this.credentials?.user.githubLogin; return { ...view, - working_users: uniqueGithubLogins( - login === undefined ? view.working_users : [login, ...view.working_users], - ), + // The managed server always composes the authenticated user's working + // scope when one exists. Send only explicit additional contributors so + // readers without a personal scope can still inspect someone else's. + working_users: uniqueGithubLogins(view.working_users), }; } diff --git a/scripts/check-managed-cli.js b/scripts/check-managed-cli.js index 8bc38ab..f9025ff 100644 --- a/scripts/check-managed-cli.js +++ b/scripts/check-managed-cli.js @@ -462,10 +462,10 @@ try { assert.match(selectedContext.stdout, /"query": "authentication"/); assert.deepEqual(graphContextBodies.at(-1).view, { base: "main", - working_users: ["contributor-1", "alice", "bob"], + working_users: ["alice", "bob"], memory_pr_id: "memory-pr-selector", include_quarantined: true, - }, "context selectors must deduplicate users and preserve every explicit overlay"); + }, "a reader without personal working sends only deduplicated explicit contributor overlays"); await run(process.execPath, [ cliPath, @@ -484,7 +484,7 @@ try { "--no-open", ], managedRepo, env); const selectedViewUrl = new URL(graphViewUrls.at(-1), "http://127.0.0.1"); - assert.deepEqual(selectedViewUrl.searchParams.getAll("working_user"), ["contributor-1", "alice", "bob"]); + assert.deepEqual(selectedViewUrl.searchParams.getAll("working_user"), ["alice", "bob"]); assert.equal(selectedViewUrl.searchParams.get("memory_pr_id"), "memory-pr-selector"); assert.equal(selectedViewUrl.searchParams.get("include_quarantined"), "true"); assert.equal(selectedViewUrl.searchParams.get("base"), "main"); diff --git a/scripts/check-managed-collaboration.js b/scripts/check-managed-collaboration.js index 31816ba..48a280c 100644 --- a/scripts/check-managed-collaboration.js +++ b/scripts/check-managed-collaboration.js @@ -341,7 +341,8 @@ assert.equal(request.searchParams.get("main_only"), "true"); assert.match(calls.at(-1).headers.get("x-greplica-capabilities"), /graph-selectors-v1/); assert.equal(calls.at(-1).headers.get("x-greplica-client-version"), "0.2.1"); const contextResult = await client.contextGraph("auth", { base: "main", working_users: ["alice", "alice"] }); -assert.deepEqual(calls.at(-1).body.view.working_users, ["me", "alice"]); +assert.deepEqual(calls.at(-1).body.view.working_users, ["alice"], + "the server adds the authenticated user's working scope; the client sends only explicit overlays"); assert.equal(contextResult.ranked_results[0].code_anchors[0].status, "resolved"); assert.equal(contextResult.ranked_results[1].code_anchors[0].status, "missing_symbol"); const contextMarkdown = renderGraphContextMarkdown(contextResult); From 86b83557578ffe7bd14e9fcdd44f49162ff69cc5 Mon Sep 17 00:00:00 2001 From: Kushal Date: Tue, 28 Jul 2026 21:45:02 -0700 Subject: [PATCH 21/27] ci: repin server-composed working views --- .github/workflows/reconcile.yml | 2 +- scripts/check-managed-collaboration.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/reconcile.yml b/.github/workflows/reconcile.yml index 007ecb9..dfcf08b 100644 --- a/.github/workflows/reconcile.yml +++ b/.github/workflows/reconcile.yml @@ -32,7 +32,7 @@ jobs: # Keep this immutable. The managed service separately allowlists this # reusable workflow's signed job_workflow_sha. - name: Reconcile Memory PRs - uses: Autoloops/greplica@39e0753400119868e63c932b17db322124b1963d + uses: Autoloops/greplica@225c2cf42db9b8085c0d10867b2e6a1ba3ba977c with: managed-repo: ${{ inputs.managed-repo }} merge-sha: ${{ inputs.merge-sha }} diff --git a/scripts/check-managed-collaboration.js b/scripts/check-managed-collaboration.js index 48a280c..48fd05a 100644 --- a/scripts/check-managed-collaboration.js +++ b/scripts/check-managed-collaboration.js @@ -49,7 +49,7 @@ assert.match(reusableWorkflow, /oidc-audience: greplica-managed/); assert.doesNotMatch(reusableWorkflow, /\$\{\{ inputs\.(?:api-url|oidc-audience) \}\}/); assert.match( reusableWorkflow, - /uses: Autoloops\/greplica@39e0753400119868e63c932b17db322124b1963d/, + /uses: Autoloops\/greplica@225c2cf42db9b8085c0d10867b2e6a1ba3ba977c/, ); assert.doesNotMatch(reusableWorkflow, /uses: Autoloops\/greplica@(main|refs\/heads\/|v\d)/); From 2c1e8a152126e3b156c2c8b924201bb27db66c07 Mon Sep 17 00:00:00 2001 From: Kushal Date: Tue, 28 Jul 2026 21:50:47 -0700 Subject: [PATCH 22/27] fix: derive anchor audit from exact evidence --- README.md | 2 +- apps/cli/reconcile-cli.ts | 134 +++++++++++++++++++++---- libs/managed/protocol.ts | 33 +++--- scripts/check-managed-collaboration.js | 37 ++++++- 4 files changed, 169 insertions(+), 37 deletions(-) diff --git a/README.md b/README.md index 80060c5..3deff9e 100644 --- a/README.md +++ b/README.md @@ -118,7 +118,7 @@ jobs: The push trigger reconciles immediately after default-branch updates; the hourly schedule retries unmatched and stalled work without a human memory-admin step. The workflow checks out `merge-sha` with full history, installs an immutable Greplica Action revision, proves that each code PR's recorded merge commit is contained in that exact checkout, audits every eligible Memory PR's version-keyed claim and component anchors (including code drift from stored baselines), and attests through GitHub OIDC. It does not use a repository or model secret. -When a compatible managed server requests repair evidence, the Action also submits a deterministic packet bound to that exact SHA. It reads only the anchors named by the selected claim and component versions—never a repository-wide content scan—and limits the packet to 128 anchors, 4 KiB and 80 lines per snippet, and 64 KiB of snippets total. Snippets can come only from regular blobs tracked at the attested commit, with the checkout bytes verified against that blob. Traversal is rejected; ignored/untracked files, symlinks, submodules, sensitive paths, binary or oversized files, and high-confidence credential content are represented only as non-content omissions. Managed repair may use only `resolved` or `file_only` entries whose transmitted snippet hash and current anchor fingerprint are present and valid. Older servers retain the prior attestation shape. +When a compatible managed server requests repair evidence, the Action also submits a deterministic packet bound to that exact SHA. It reads only the anchors named by the selected claim and component versions—never a repository-wide content scan—and limits the packet to 128 anchors, 4 KiB and 80 lines per snippet, and 64 KiB of snippets total. Snippets can come only from regular blobs tracked at the attested commit, with the checkout bytes verified against that blob. Traversal is rejected; ignored/untracked files, symlinks, submodules, sensitive paths, binary or oversized files, and high-confidence credential content are represented only as non-content omissions. For evidence-enabled candidates, the version-keyed audit and fingerprints are derived from that same packet; omissions become explicit `unverifiable` failures rather than independently appearing clean. Managed repair may use only `resolved` or `file_only` entries whose transmitted snippet hash and current anchor fingerprint are present and valid. Older servers retain the prior attestation shape. Managed Greplica must allowlist both signed reusable-workflow claims for the pinned revision: diff --git a/apps/cli/reconcile-cli.ts b/apps/cli/reconcile-cli.ts index 9b51012..ab18ffb 100644 --- a/apps/cli/reconcile-cli.ts +++ b/apps/cli/reconcile-cli.ts @@ -1,13 +1,20 @@ import { execFileSync } from "node:child_process"; import { resolve } from "node:path"; import { auditClaimCodeAnchors } from "../../libs/knowledge-graph/code-anchors/audit.js"; -import { buildReconciliationCodeEvidence } from "../../libs/knowledge-graph/code-anchors/evidence.js"; -import { fingerprintClaimAnchors } from "../../libs/knowledge-graph/code-anchors/fingerprint.js"; +import { + buildReconciliationCodeEvidence, + isReconciliationCodeEvidenceEntryActionable, +} from "../../libs/knowledge-graph/code-anchors/evidence.js"; +import { + anchorFingerprintKey, + fingerprintClaimAnchors, +} from "../../libs/knowledge-graph/code-anchors/fingerprint.js"; import { ensureGreplicaConfig, managedApiUrl } from "../../libs/config/greplica-config.js"; import type { ManagedReconciliationAttestation, ManagedReconciliationAttestationResult, ManagedReconciliationCandidate, + ManagedReconciliationCodeEvidence, ManagedReconciliationRejection, ManagedReconciliationRejectionResult, } from "../../libs/managed/protocol.js"; @@ -219,21 +226,6 @@ export async function runMemoryReconcile(args: string[]): Promise { .filter(({ baseline_fingerprints }) => baseline_fingerprints !== undefined) .map(({ version_id, baseline_fingerprints }) => [version_id, baseline_fingerprints!]), ); - const result = await auditClaimCodeAnchors( - repoRoot, - auditedObjects, - undefined, - baselineFingerprints, - ); - const fingerprints: Record> = {}; - for (const claim of auditClaims) { - if (claim.code_anchors === undefined || claim.code_anchors.length === 0) continue; - const values = await fingerprintClaimAnchors(repoRoot, claim.code_anchors); - if (Object.keys(values).length > 0) fingerprints[claim.id] = values; - } - for (const component of componentAuditClaims) { - fingerprints[component.id] = await fingerprintClaimAnchors(repoRoot, component.code_anchors); - } const codeEvidence = candidate.code_evidence_required === true ? await buildReconciliationCodeEvidence(repoRoot, { managedRepoId, @@ -257,6 +249,27 @@ export async function runMemoryReconcile(args: string[]): Promise { ], }) : undefined; + let anchorAudit: ManagedReconciliationAttestation["anchor_audit"]; + if (codeEvidence !== undefined) { + anchorAudit = anchorAuditFromCodeEvidence(candidate, codeEvidence); + } else { + const result = await auditClaimCodeAnchors( + repoRoot, + auditedObjects, + undefined, + baselineFingerprints, + ); + const fingerprints: Record> = {}; + for (const claim of auditClaims) { + if (claim.code_anchors === undefined || claim.code_anchors.length === 0) continue; + const values = await fingerprintClaimAnchors(repoRoot, claim.code_anchors); + if (Object.keys(values).length > 0) fingerprints[claim.id] = values; + } + for (const component of componentAuditClaims) { + fingerprints[component.id] = await fingerprintClaimAnchors(repoRoot, component.code_anchors); + } + anchorAudit = { result, fingerprints }; + } const observedDefaultHeadSha = assertCurrentRemoteDefaultHead( repoRoot, mergeSha, @@ -272,7 +285,7 @@ export async function runMemoryReconcile(args: string[]): Promise { memory_commit_ids: candidate.memory_commit_ids, ancestry, audit_key: "version_id", - anchor_audit: { result, fingerprints }, + anchor_audit: anchorAudit, code_evidence: codeEvidence, observed_default_head_sha: observedDefaultHeadSha, ref: process.env.GITHUB_REF, @@ -374,6 +387,87 @@ function verifyCandidate(candidate: ManagedReconciliationCandidate, mergeSha: st } } +function anchorAuditFromCodeEvidence( + candidate: ManagedReconciliationCandidate, + evidence: ManagedReconciliationCodeEvidence, +): ManagedReconciliationAttestation["anchor_audit"] { + type AuditResult = ManagedReconciliationAttestation["anchor_audit"]["result"]; + const unverifiable: NonNullable = []; + const result: AuditResult = { + missing_anchors: candidate.claim_versions + .filter(({ claim }) => + claim.truth === "code_verified" && + (claim.code_anchors === undefined || claim.code_anchors.length === 0) + ) + .map(({ version_id }) => ({ + claim_id: version_id, + status: "missing_anchors" as const, + })) + .sort((left, right) => compareText(left.claim_id, right.claim_id)), + missing_files: [], + missing_symbols: [], + ambiguous_symbols: [], + unsupported_languages: [], + drifted: [], + unverifiable, + }; + const fingerprints: ManagedReconciliationAttestation["anchor_audit"]["fingerprints"] = {}; + const baselines = new Map( + [...candidate.claim_versions, ...(candidate.component_versions ?? [])] + .map(({ version_id, baseline_fingerprints }) => [version_id, baseline_fingerprints]), + ); + + for (const entry of evidence.entries) { + fingerprints[entry.version_id] ??= {}; + if (entry.anchor_fingerprint !== undefined) { + fingerprints[entry.version_id][anchorFingerprintKey(entry.anchor)] = entry.anchor_fingerprint; + } + const issue = { + claim_id: entry.version_id, + anchor: entry.anchor, + }; + switch (entry.status) { + case "missing_file": + result.missing_files.push({ ...issue, status: "missing_file" }); + break; + case "missing_symbol": + result.missing_symbols.push({ ...issue, status: "missing_symbol" }); + break; + case "ambiguous_symbol": + result.ambiguous_symbols.push({ ...issue, status: "ambiguous_symbol" }); + break; + case "unsupported_language": + result.unsupported_languages.push({ ...issue, status: "unsupported_language" }); + break; + case "omitted": + unverifiable.push({ + ...issue, + status: "unverifiable", + omission_reason: entry.omission_reason ?? "no_resolved_span", + }); + break; + case "resolved": + case "file_only": { + if (!isReconciliationCodeEvidenceEntryActionable(entry)) { + unverifiable.push({ + ...issue, + status: "unverifiable", + omission_reason: entry.omission_reason ?? "no_resolved_span", + }); + break; + } + const stored = baselines.get(entry.version_id)?.[anchorFingerprintKey(entry.anchor)]; + if (stored !== undefined && stored !== entry.anchor_fingerprint) { + result.drifted.push({ ...issue, status: "drifted" }); + } + break; + } + } + } + + return { result, fingerprints }; +} + function componentCodeAnchors(codeAnchor: string | undefined): Array<{ file: string }> { if (codeAnchor === undefined) return []; return codeAnchor @@ -383,6 +477,10 @@ function componentCodeAnchors(codeAnchor: string | undefined): Array<{ file: str .map((file) => ({ file })); } +function compareText(left: string, right: string): number { + return left < right ? -1 : left > right ? 1 : 0; +} + function isAncestor(repoRoot: string, gitHead: string, mergeSha: string): boolean { try { execFileSync("git", ["-C", repoRoot, "merge-base", "--is-ancestor", gitHead, mergeSha], { diff --git a/libs/managed/protocol.ts b/libs/managed/protocol.ts index 644cfdc..adea2d1 100644 --- a/libs/managed/protocol.ts +++ b/libs/managed/protocol.ts @@ -332,6 +332,21 @@ export const GraphReadSchema = Type.Object({ export const MemoryProposalSchema = Type.Object({}, { additionalProperties: true }); +export const ReconciliationCodeEvidenceOmissionReasonSchema = Type.Union([ + Type.Literal("missing_file"), + Type.Literal("no_resolved_span"), + Type.Literal("sensitive_path"), + Type.Literal("binary"), + Type.Literal("file_too_large"), + Type.Literal("unreadable"), + Type.Literal("total_budget"), + Type.Literal("submodule"), + Type.Literal("sensitive_content"), + Type.Literal("not_in_attested_tree"), + Type.Literal("symlink"), + Type.Literal("not_regular_blob"), +]); + export const AnchorAuditIssueSchema = Type.Object({ claim_id: Type.String(), anchor: Type.Optional(CodeAnchorSchema), @@ -342,7 +357,9 @@ export const AnchorAuditIssueSchema = Type.Object({ Type.Literal("ambiguous_symbol"), Type.Literal("unsupported_language"), Type.Literal("drifted"), + Type.Literal("unverifiable"), ]), + omission_reason: Type.Optional(ReconciliationCodeEvidenceOmissionReasonSchema), }); export const AnchorAuditSchema = Type.Object({ missing_anchors: Type.Array(AnchorAuditIssueSchema), @@ -351,6 +368,7 @@ export const AnchorAuditSchema = Type.Object({ ambiguous_symbols: Type.Array(AnchorAuditIssueSchema), unsupported_languages: Type.Array(AnchorAuditIssueSchema), drifted: Type.Array(AnchorAuditIssueSchema), + unverifiable: Type.Optional(Type.Array(AnchorAuditIssueSchema)), }); export const ProposalAnchorAuditSchema = Type.Object({ result: AnchorAuditSchema, @@ -384,20 +402,7 @@ export const ReconciliationCodeEvidenceEntrySchema = Type.Object({ snippet_sha256: Type.Optional(Type.String({ pattern: "^[0-9a-f]{64}$" })), anchor_fingerprint: Type.Optional(Type.String({ pattern: "^[0-9a-f]{16}$" })), truncated: Type.Optional(Type.Boolean()), - omission_reason: Type.Optional(Type.Union([ - Type.Literal("missing_file"), - Type.Literal("no_resolved_span"), - Type.Literal("sensitive_path"), - Type.Literal("binary"), - Type.Literal("file_too_large"), - Type.Literal("unreadable"), - Type.Literal("total_budget"), - Type.Literal("submodule"), - Type.Literal("sensitive_content"), - Type.Literal("not_in_attested_tree"), - Type.Literal("symlink"), - Type.Literal("not_regular_blob"), - ])), + omission_reason: Type.Optional(ReconciliationCodeEvidenceOmissionReasonSchema), }); export const ReconciliationCodeEvidenceSchema = Type.Object({ diff --git a/scripts/check-managed-collaboration.js b/scripts/check-managed-collaboration.js index 48fd05a..23e96b5 100644 --- a/scripts/check-managed-collaboration.js +++ b/scripts/check-managed-collaboration.js @@ -58,7 +58,8 @@ exec("git", ["init", "--quiet", repoRoot]); exec("git", ["-C", repoRoot, "config", "user.email", "test@example.com"]); exec("git", ["-C", repoRoot, "config", "user.name", "Test"]); writeFileSync(join(repoRoot, "example.ts"), "export function example() { return 0; }\n"); -exec("git", ["-C", repoRoot, "add", "example.ts"]); +writeFileSync(join(repoRoot, ".env"), "MANAGED_ACTION_SECRET_MUST_NOT_LEAK=1\n"); +exec("git", ["-C", repoRoot, "add", "example.ts", ".env"]); exec("git", ["-C", repoRoot, "commit", "--quiet", "-m", "example"]); const baseSha = exec("git", ["-C", repoRoot, "rev-parse", "HEAD"]).trim(); const defaultBranch = exec("git", ["-C", repoRoot, "branch", "--show-current"]).trim(); @@ -789,6 +790,14 @@ const server = createServer(async (incoming, response) => { name: "Missing component anchor", code_anchor: "missing-component.ts", }, + }, { + version_id: "version-component-sensitive", + baseline_fingerprints: {}, + component: { + id: "component.sensitive", + name: "Sensitive component anchor", + code_anchor: ".env", + }, }], }); return; @@ -846,8 +855,8 @@ try { }); assert.match(result.stdout, /"accepted": true/); assert.match(result.stdout, /"reconciliation_count": 2/); - assert.match(result.stdout, /"audited_component_versions": 2/); - assert.match(result.stdout, /"code_evidence_entries": 3/); + assert.match(result.stdout, /"audited_component_versions": 3/); + assert.match(result.stdout, /"code_evidence_entries": 4/); assert.match(result.stdout, /"skipped_count": 2/); assert.match(result.stdout, /"reason": "git_head_not_in_pr_delta"/); assert.equal(candidateCalls, 5); @@ -897,12 +906,24 @@ try { issue.anchor.file === "missing-component.ts" ), "missing component anchors must fail under immutable component version IDs"); assert.deepEqual(attestations[0].anchor_audit.fingerprints["version-component-missing"], {}); + assert.deepEqual(attestations[0].anchor_audit.fingerprints["version-component-sensitive"], {}); + assert.deepEqual(attestations[0].anchor_audit.result.unverifiable, [{ + claim_id: "version-component-sensitive", + anchor: { file: ".env" }, + status: "unverifiable", + omission_reason: "sensitive_path", + }], "evidence policy omissions must be explicit failing audit issues"); assert.equal(attestations[0].repository, "example/project"); assert.equal(attestations[0].code_evidence.managed_repo_id, installation.managedRepoId); assert.equal(attestations[0].code_evidence.repository, "example/project"); assert.equal(attestations[0].code_evidence.attested_git_sha, mergeSha); - assert.equal(attestations[0].code_evidence.entries.length, 3, + assert.equal(attestations[0].code_evidence.entries.length, 4, "duplicate component anchors must not inflate the repair evidence packet"); + assert.doesNotMatch( + JSON.stringify(attestations[0]), + /MANAGED_ACTION_SECRET_MUST_NOT_LEAK/, + "sensitive anchor bytes must not enter either evidence or its derived audit", + ); const { evidence_sha256: submittedEvidenceHash, ...submittedEvidencePayload @@ -933,6 +954,12 @@ try { ); assert.equal(missingComponentEvidence.status, "missing_file"); assert.equal(Object.hasOwn(missingComponentEvidence, "snippet"), false); + const sensitiveComponentEvidence = attestations[0].code_evidence.entries.find((entry) => + entry.version_id === "version-component-sensitive" + ); + assert.equal(sensitiveComponentEvidence.status, "omitted"); + assert.equal(sensitiveComponentEvidence.omission_reason, "sensitive_path"); + assert.equal(Object.hasOwn(sensitiveComponentEvidence, "snippet"), false); assert.deepEqual(attestations[1].ancestry, [{ memory_commit_id: "commit-2", git_head: baseSha, @@ -945,6 +972,8 @@ try { "legacy attestations must not send unsupported proof fields"); assert.equal(Object.hasOwn(attestations[1], "code_evidence"), false, "legacy candidates must retain their exact attestation shape"); + assert.equal(Object.hasOwn(attestations[1].anchor_audit.result, "unverifiable"), false, + "legacy anchor audits must retain their exact result shape"); assert.equal( exec("git", ["-C", repoRoot, "rev-parse", "FETCH_HEAD"]).trim(), featureSha, From f88edc0a2c02fe213f3bcc7166aac3b8928a42d7 Mon Sep 17 00:00:00 2001 From: Kushal Date: Tue, 28 Jul 2026 21:51:03 -0700 Subject: [PATCH 23/27] ci: repin canonical evidence audit --- .github/workflows/reconcile.yml | 2 +- scripts/check-managed-collaboration.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/reconcile.yml b/.github/workflows/reconcile.yml index dfcf08b..e9b332a 100644 --- a/.github/workflows/reconcile.yml +++ b/.github/workflows/reconcile.yml @@ -32,7 +32,7 @@ jobs: # Keep this immutable. The managed service separately allowlists this # reusable workflow's signed job_workflow_sha. - name: Reconcile Memory PRs - uses: Autoloops/greplica@225c2cf42db9b8085c0d10867b2e6a1ba3ba977c + uses: Autoloops/greplica@2c1e8a152126e3b156c2c8b924201bb27db66c07 with: managed-repo: ${{ inputs.managed-repo }} merge-sha: ${{ inputs.merge-sha }} diff --git a/scripts/check-managed-collaboration.js b/scripts/check-managed-collaboration.js index 23e96b5..5b4236c 100644 --- a/scripts/check-managed-collaboration.js +++ b/scripts/check-managed-collaboration.js @@ -49,7 +49,7 @@ assert.match(reusableWorkflow, /oidc-audience: greplica-managed/); assert.doesNotMatch(reusableWorkflow, /\$\{\{ inputs\.(?:api-url|oidc-audience) \}\}/); assert.match( reusableWorkflow, - /uses: Autoloops\/greplica@225c2cf42db9b8085c0d10867b2e6a1ba3ba977c/, + /uses: Autoloops\/greplica@2c1e8a152126e3b156c2c8b924201bb27db66c07/, ); assert.doesNotMatch(reusableWorkflow, /uses: Autoloops\/greplica@(main|refs\/heads\/|v\d)/); From 3aa909a12be71cac2fe34a75a841e2969768224a Mon Sep 17 00:00:00 2001 From: Kushal Date: Tue, 28 Jul 2026 22:35:51 -0700 Subject: [PATCH 24/27] Add automated repair provenance --- apps/cli/main.ts | 17 +- apps/cli/reconcile-cli.ts | 6 +- libs/knowledge-graph/graph-context/render.ts | 15 ++ .../graph-view/build-graph-view.ts | 58 ++++- libs/managed/protocol.ts | 24 +- scripts/check-managed-cli.js | 54 +++- scripts/check-managed-collaboration.js | 246 ++++++++++++++++-- 7 files changed, 395 insertions(+), 25 deletions(-) diff --git a/apps/cli/main.ts b/apps/cli/main.ts index 3ae172f..4869881 100644 --- a/apps/cli/main.ts +++ b/apps/cli/main.ts @@ -1455,11 +1455,20 @@ function printProposalSummary(proposal: ManagedProposal): void { ...commit.session_refs.map((session) => session.agent_platform), ].filter((value): value is string => value !== undefined); const agents = [...new Set(agentPlatforms)].join(",") || "-"; - const currentLogin = commit.author.github_login; + const currentLogin = commit.author?.github_login; const historicalLogin = commit.author_github_login_snapshot; - const author = historicalLogin === undefined || historicalLogin === currentLogin - ? currentLogin - : `${currentLogin} (formerly ${historicalLogin})`; + const humanAuthor = currentLogin === undefined + ? historicalLogin + : historicalLogin === undefined || historicalLogin === currentLogin + ? currentLogin + : `${currentLogin} (formerly ${historicalLogin})`; + const sourceAuthors = [...new Set((commit.repair_sources ?? []) + .map((source) => source.contributor_github_login ?? source.contributor_github_login_snapshot) + .filter((value): value is string => value !== undefined))]; + const author = humanAuthor ?? + (commit.automation_identity === undefined + ? "unknown" + : `${commit.automation_identity.kind} [sources: ${sourceAuthors.join(",") || "-"}]`); console.log([ proposal.id, commit.state, diff --git a/apps/cli/reconcile-cli.ts b/apps/cli/reconcile-cli.ts index ab18ffb..e34e4a9 100644 --- a/apps/cli/reconcile-cli.ts +++ b/apps/cli/reconcile-cli.ts @@ -630,6 +630,7 @@ async function githubOidcToken(audience: string): Promise { url.searchParams.set("audience", audience); const response = await fetch(url, { headers: { authorization: `Bearer ${requestToken}`, accept: "application/json" }, + redirect: "error", }); const payload = await response.json() as unknown; if (!response.ok || !isRecord(payload) || typeof payload.value !== "string") { @@ -639,7 +640,10 @@ async function githubOidcToken(audience: string): Promise { } async function jsonRequest(url: string, init: RequestInit): Promise { - const response = await fetch(url, init); + const response = await fetch(url, { + ...init, + redirect: "error", + }); const text = await response.text(); let payload: unknown = {}; if (text.length > 0) { diff --git a/libs/knowledge-graph/graph-context/render.ts b/libs/knowledge-graph/graph-context/render.ts index 8742ccf..16db6be 100644 --- a/libs/knowledge-graph/graph-context/render.ts +++ b/libs/knowledge-graph/graph-context/render.ts @@ -85,6 +85,7 @@ function provenanceLabel(object: object): string { function provenanceValues(provenance: ManagedObjectProvenance | ManagedObjectOrigin): string[] { const currentLogin = provenance.author_github_login; const historicalLogin = provenance.author_github_login_snapshot; + const automation = provenance.automation_identity; const values = [ provenance.scope_kind, `version ${provenance.version_id}`, @@ -94,6 +95,20 @@ function provenanceValues(provenance: ManagedObjectProvenance | ManagedObjectOri provenance.memory_commit_id === undefined ? undefined : `commit ${provenance.memory_commit_id}`, ...(provenance.session_refs ?? []).map((session) => `session ${session.id}`), provenance.agent_platform === undefined ? undefined : `agent ${provenance.agent_platform}`, + automation === undefined + ? undefined + : `automation ${automation.kind} (job ${automation.reconciliation_job_id}, attempt ${automation.repair_attempt})`, + ...(provenance.repair_sources ?? []).map((source) => { + const current = source.contributor_github_login; + const snapshot = source.contributor_github_login_snapshot; + const contributor = current === undefined + ? snapshot === undefined ? "" : ` by @${snapshot}` + : snapshot === undefined || snapshot === current + ? ` by @${current}` + : ` by @${current} (formerly @${snapshot})`; + const proposal = source.proposal_id === undefined ? "" : ` proposal ${source.proposal_id}`; + return `repair source commit ${source.memory_commit_id}${contributor}${proposal}`; + }), provenance.branch === undefined ? undefined : `branch ${provenance.branch}`, provenance.git_head === undefined ? undefined : `git ${provenance.git_head}`, provenance.head_repository === undefined ? undefined : `head repository ${provenance.head_repository}`, diff --git a/libs/knowledge-graph/graph-view/build-graph-view.ts b/libs/knowledge-graph/graph-view/build-graph-view.ts index e52e2ce..8d7f1c9 100644 --- a/libs/knowledge-graph/graph-view/build-graph-view.ts +++ b/libs/knowledge-graph/graph-view/build-graph-view.ts @@ -104,6 +104,7 @@ const PROVENANCE_FILTERS = [ { key: "proposalId", slug: "proposal", label: "Proposal", all: "All proposals" }, { key: "memoryCommitId", slug: "memory-commit", label: "Memory commit", all: "All commits" }, { key: "agent", slug: "agent", label: "Agent", all: "All agents" }, + { key: "automation", slug: "automation", label: "Automation", all: "All automation" }, { key: "branch", slug: "branch", label: "Branch", all: "All branches" }, { key: "headRepository", slug: "head-repository", label: "Head repository", all: "All head repositories" }, { key: "headRef", slug: "head-ref", label: "Head ref", all: "All head refs" }, @@ -436,6 +437,7 @@ function provenanceEntryValue(entry: ProvenanceEntry, key: string): string | und if (key === "proposalId") return entry.proposal_id; if (key === "memoryCommitId") return entry.memory_commit_id; if (key === "agent") return entry.agent_platform; + if (key === "automation") return entry.automation_identity?.kind; if (key === "branch") return entry.branch; if (key === "headRepository") return entry.head_repository; if (key === "headRef") return entry.head_ref; @@ -448,12 +450,32 @@ function provenanceEntryValue(entry: ProvenanceEntry, key: string): string | und return undefined; } +function repairSourceValues(entry: ProvenanceEntry, key: string): string[] { + return (entry.repair_sources ?? []).flatMap((source) => { + if (key === "author") { + const value = source.contributor_github_login ?? source.contributor_github_login_snapshot; + return value === undefined ? [] : [value]; + } + if (key === "authorSnapshot") { + return source.contributor_github_login_snapshot === undefined + ? [] + : [source.contributor_github_login_snapshot]; + } + if (key === "proposalId") return source.proposal_id === undefined ? [] : [source.proposal_id]; + if (key === "memoryCommitId") return [source.memory_commit_id]; + return []; + }); +} + function provenanceFieldValues( provenance: ManagedObjectProvenance | undefined, key: string, ): string[] { return [...new Set(provenanceEntries(provenance) - .map((entry) => provenanceEntryValue(entry, key)) + .flatMap((entry) => [ + provenanceEntryValue(entry, key), + ...repairSourceValues(entry, key), + ]) .filter((value): value is string => value !== undefined && value.length > 0))]; } @@ -468,6 +490,7 @@ function provenanceDataAttributes( ["proposal-id", "proposalId"], ["memory-commit-id", "memoryCommitId"], ["agent", "agent"], + ["automation", "automation"], ["branch", "branch"], ["head-repository", "headRepository"], ["head-ref", "headRef"], @@ -503,6 +526,20 @@ function provenanceBadgeValues(entry: ProvenanceEntry, origin: boolean): string[ entry.memory_commit_id === undefined ? undefined : `${prefix}commit ${entry.memory_commit_id}`, ...(entry.session_refs ?? []).map((session) => `${prefix}session ${session.id}`), entry.agent_platform === undefined ? undefined : `${prefix}agent ${entry.agent_platform}`, + entry.automation_identity === undefined + ? undefined + : `${prefix}automation ${entry.automation_identity.kind} job ${entry.automation_identity.reconciliation_job_id} attempt ${entry.automation_identity.repair_attempt}`, + ...(entry.repair_sources ?? []).map((source) => { + const current = source.contributor_github_login; + const snapshot = source.contributor_github_login_snapshot; + const contributor = current === undefined + ? snapshot === undefined ? "" : ` by @${snapshot}` + : snapshot === undefined || snapshot === current + ? ` by @${current}` + : ` by @${current} (formerly @${snapshot})`; + const proposal = source.proposal_id === undefined ? "" : ` proposal ${source.proposal_id}`; + return `${prefix}repair source commit ${source.memory_commit_id}${contributor}${proposal}`; + }), entry.git_head === undefined ? undefined : `${prefix}git ${entry.git_head}`, entry.head_repository === undefined ? undefined : `${prefix}head repository ${entry.head_repository}`, entry.head_ref === undefined ? undefined : `${prefix}head ref ${entry.head_ref}`, @@ -1143,6 +1180,7 @@ ${timelineEvents} if (key === "proposalId") return entry.proposal_id || ""; if (key === "memoryCommitId") return entry.memory_commit_id || ""; if (key === "agent") return entry.agent_platform || ""; + if (key === "automation") return (entry.automation_identity && entry.automation_identity.kind) || ""; if (key === "branch") return entry.branch || ""; if (key === "headRepository") return entry.head_repository || ""; if (key === "headRef") return entry.head_ref || ""; @@ -1155,10 +1193,26 @@ ${timelineEvents} return ""; } + function repairSourceValues(entry, key) { + return (entry.repair_sources || []).flatMap((source) => { + if (key === "author") { + const value = source.contributor_github_login || source.contributor_github_login_snapshot; + return value ? [value] : []; + } + if (key === "authorSnapshot") return source.contributor_github_login_snapshot ? [source.contributor_github_login_snapshot] : []; + if (key === "proposalId") return source.proposal_id ? [source.proposal_id] : []; + if (key === "memoryCommitId") return [source.memory_commit_id]; + return []; + }); + } + function provenanceValues(object, key) { const provenance = object.provenance; const entries = provenance ? [provenance, ...(provenance.origins || [])] : []; - const values = entries.map((entry) => provenanceEntryValue(entry, key)).filter(Boolean); + const values = entries.flatMap((entry) => [ + provenanceEntryValue(entry, key), + ...repairSourceValues(entry, key), + ]).filter(Boolean); if (key === "memoryCommitId" && values.length === 0 && object.memoryCommitId) { values.push(object.memoryCommitId); } diff --git a/libs/managed/protocol.ts b/libs/managed/protocol.ts index adea2d1..e6c0c68 100644 --- a/libs/managed/protocol.ts +++ b/libs/managed/protocol.ts @@ -187,6 +187,20 @@ export const ReconciliationJobStateSchema = Type.Union([ Type.Literal("failed"), ]); +export const ManagedAutomationIdentitySchema = Type.Object({ + kind: Type.Literal("managed_repair_agent"), + reconciliation_job_id: Type.String({ minLength: 1 }), + repair_attempt: Type.Integer({ minimum: 1 }), +}); + +export const ManagedRepairSourceSchema = Type.Object({ + memory_commit_id: Type.String({ minLength: 1 }), + contributor_user_id: Type.Optional(Type.String({ format: "uuid" })), + contributor_github_login: Type.Optional(Type.String({ minLength: 1 })), + contributor_github_login_snapshot: Type.Optional(Type.String({ minLength: 1 })), + proposal_id: Type.Optional(Type.String({ minLength: 1 })), +}); + export const ManagedObjectOriginSchema = Type.Object({ version_id: Type.String(), scope_kind: Type.Optional(Type.Union([ @@ -207,6 +221,8 @@ export const ManagedObjectOriginSchema = Type.Object({ agent_platform: Type.Optional(Type.String()), }))), agent_platform: Type.Optional(Type.String()), + automation_identity: Type.Optional(ManagedAutomationIdentitySchema), + repair_sources: Type.Optional(Type.Array(ManagedRepairSourceSchema)), git_head: Type.Optional(Type.String()), head_repository: Type.Optional(Type.String()), head_ref: Type.Optional(Type.String()), @@ -243,6 +259,8 @@ export const ManagedObjectProvenanceSchema = Type.Object({ agent_platform: Type.Optional(Type.String()), }))), agent_platform: Type.Optional(Type.String()), + automation_identity: Type.Optional(ManagedAutomationIdentitySchema), + repair_sources: Type.Optional(Type.Array(ManagedRepairSourceSchema)), git_head: Type.Optional(Type.String()), head_repository: Type.Optional(Type.String()), head_ref: Type.Optional(Type.String()), @@ -654,13 +672,15 @@ export const MemoryCommitRecordSchema = Type.Object({ scope_id: Type.String(), scope_name: Type.String(), state: MemoryCommitStateSchema, - author: UserSchema, + author: Type.Optional(UserSchema), author_github_login_snapshot: Type.Optional(Type.String()), session_refs: Type.Array(Type.Object({ id: Type.String(), agent_platform: Type.Optional(Type.String()), })), agent_platform: Type.Optional(Type.String()), + automation_identity: Type.Optional(ManagedAutomationIdentitySchema), + repair_sources: Type.Optional(Type.Array(ManagedRepairSourceSchema)), git: Type.Optional(MemoryCommitMetadataSchema), code_pr: Type.Optional(CodePrReferenceSchema), memory_pr_id: Type.Optional(Type.String()), @@ -950,6 +970,8 @@ export type ManagedGraphContext = Static; export type ManagedGraphViewData = Static; export type ManagedProposalReview = Static; export type ManagedGraphView = Static; +export type ManagedAutomationIdentity = Static; +export type ManagedRepairSource = Static; export type ManagedObjectOrigin = Static; export type ManagedObjectProvenance = Static; export type ManagedMemoryCommit = Static; diff --git a/scripts/check-managed-cli.js b/scripts/check-managed-cli.js index f9025ff..0a7a9f4 100644 --- a/scripts/check-managed-cli.js +++ b/scripts/check-managed-cli.js @@ -65,6 +65,41 @@ const proposalRecord = { proposal: { title: "Rename-safe provenance" }, created_at: now, }; +const automatedRepairProposalRecord = { + id: "proposal-automated-repair", + memory_commit: { + id: "memory-commit-automated-repair", + proposal_id: "proposal-automated-repair", + scope_id: "repair-scope-1", + scope_name: "repair/memory-pr-1", + state: "promoted", + session_refs: [{ + id: "managed-repair:job-1:1", + agent_platform: "managed-repair", + }], + agent_platform: "managed-repair", + automation_identity: { + kind: "managed_repair_agent", + reconciliation_job_id: "job-1", + repair_attempt: 1, + }, + repair_sources: [{ + memory_commit_id: "source-commit-alice", + contributor_user_id: "30000000-0000-4000-8000-000000000000", + contributor_github_login: "alice", + proposal_id: "source-proposal-alice", + }, { + memory_commit_id: "source-commit-bob", + contributor_user_id: "40000000-0000-4000-8000-000000000000", + contributor_github_login: "bob", + proposal_id: "source-proposal-bob", + }], + created_at: now, + promoted_at: now, + }, + proposal: { title: "Automated merged-code repair" }, + created_at: now, +}; const server = createServer(async (request, response) => { requestCount += 1; @@ -221,7 +256,7 @@ const server = createServer(async (request, response) => { return; } if (request.method === "GET" && request.url === `/v1/repos/${managedRepoId}/proposals`) { - send(200, [proposalRecord]); + send(200, [proposalRecord, automatedRepairProposalRecord]); return; } if ( @@ -231,6 +266,13 @@ const server = createServer(async (request, response) => { send(200, proposalRecord); return; } + if ( + request.method === "GET" && + request.url === `/v1/repos/${managedRepoId}/proposals/proposal-automated-repair` + ) { + send(200, automatedRepairProposalRecord); + return; + } if (request.method === "GET" && request.url === `/v1/repos/${managedRepoId}/memory-prs`) { send(200, [{ id: "direct-default-memory-pr", @@ -433,6 +475,8 @@ try { "human proposal list output must expose the immutable Git head"); assert.match(proposalList.stdout, /agent:codex/, "human proposal list output must expose the creating agent platform"); + assert.match(proposalList.stdout, /managed_repair_agent \[sources: alice,bob\]/, + "automated repair summaries must identify automation and every source contributor without a fake human author"); const proposalShow = await run( process.execPath, [cliPath, "proposal", "show", "proposal-renamed-author"], @@ -443,6 +487,14 @@ try { "human proposal show output must expose the immutable Git head"); assert.match(proposalShow.stdout, /agent:codex/, "human proposal show output must expose the creating agent platform"); + const automatedProposalShow = await run( + process.execPath, + [cliPath, "proposal", "show", "proposal-automated-repair"], + managedRepo, + env, + ); + assert.match(automatedProposalShow.stdout, /managed_repair_agent \[sources: alice,bob\]/, + "proposal show must remain usable when an automated repair has no human author"); const selectedContext = await run(process.execPath, [ cliPath, diff --git a/scripts/check-managed-collaboration.js b/scripts/check-managed-collaboration.js index 5b4236c..5ab0c27 100644 --- a/scripts/check-managed-collaboration.js +++ b/scripts/check-managed-collaboration.js @@ -153,12 +153,27 @@ const fetchImpl = async (input, init) => { provenance: { version_id: "context-version-1", scope_kind: "working", - author_github_login: "alice", - author_github_login_snapshot: "alice-old", proposal_id: "context-proposal-1", memory_commit_id: "context-commit-1", - session_refs: [{ id: "codex-session:context-1", agent_platform: "codex" }], - agent_platform: "codex", + session_refs: [{ id: "managed-repair:job-1:1", agent_platform: "managed-repair" }], + agent_platform: "managed-repair", + automation_identity: { + kind: "managed_repair_agent", + reconciliation_job_id: "job-1", + repair_attempt: 1, + }, + repair_sources: [{ + memory_commit_id: "source-commit-alice", + contributor_user_id: "11111111-1111-4111-8111-111111111111", + contributor_github_login: "alice", + contributor_github_login_snapshot: "alice-old", + proposal_id: "source-proposal-alice", + }, { + memory_commit_id: "source-commit-bob", + contributor_user_id: "22222222-2222-4222-8222-222222222222", + contributor_github_login: "bob", + proposal_id: "source-proposal-bob", + }], git_head: mergeSha, head_repository: "example/project", head_ref: "feature", @@ -171,6 +186,30 @@ const fetchImpl = async (input, init) => { promotion_id: "promotion-1", quarantine_reason: "superseded repair", origins: [{ + version_id: "repair-version-1", + scope_kind: "working", + proposal_id: "repair-proposal-1", + memory_commit_id: "repair-commit-1", + session_refs: [{ id: "managed-repair:job-1:1", agent_platform: "managed-repair" }], + agent_platform: "managed-repair", + automation_identity: { + kind: "managed_repair_agent", + reconciliation_job_id: "job-1", + repair_attempt: 1, + }, + repair_sources: [{ + memory_commit_id: "source-commit-alice", + contributor_user_id: "11111111-1111-4111-8111-111111111111", + contributor_github_login: "alice", + contributor_github_login_snapshot: "alice-old", + proposal_id: "source-proposal-alice", + }, { + memory_commit_id: "source-commit-bob", + contributor_user_id: "22222222-2222-4222-8222-222222222222", + contributor_github_login: "bob", + proposal_id: "source-proposal-bob", + }], + }, { version_id: "source-version-bob", scope_kind: "working", author_user_id: "22222222-2222-4222-8222-222222222222", @@ -223,12 +262,27 @@ const fetchImpl = async (input, init) => { provenance: { version_id: "context-version-1", scope_kind: "working", - author_github_login: "alice", - author_github_login_snapshot: "alice-old", proposal_id: "context-proposal-1", memory_commit_id: "context-commit-1", - session_refs: [{ id: "codex-session:context-1", agent_platform: "codex" }], - agent_platform: "codex", + session_refs: [{ id: "managed-repair:job-1:1", agent_platform: "managed-repair" }], + agent_platform: "managed-repair", + automation_identity: { + kind: "managed_repair_agent", + reconciliation_job_id: "job-1", + repair_attempt: 1, + }, + repair_sources: [{ + memory_commit_id: "source-commit-alice", + contributor_user_id: "11111111-1111-4111-8111-111111111111", + contributor_github_login: "alice", + contributor_github_login_snapshot: "alice-old", + proposal_id: "source-proposal-alice", + }, { + memory_commit_id: "source-commit-bob", + contributor_user_id: "22222222-2222-4222-8222-222222222222", + contributor_github_login: "bob", + proposal_id: "source-proposal-bob", + }], git_head: mergeSha, head_repository: "example/project", head_ref: "feature", @@ -241,6 +295,30 @@ const fetchImpl = async (input, init) => { promotion_id: "promotion-1", quarantine_reason: "superseded repair", origins: [{ + version_id: "repair-version-1", + scope_kind: "working", + proposal_id: "repair-proposal-1", + memory_commit_id: "repair-commit-1", + session_refs: [{ id: "managed-repair:job-1:1", agent_platform: "managed-repair" }], + agent_platform: "managed-repair", + automation_identity: { + kind: "managed_repair_agent", + reconciliation_job_id: "job-1", + repair_attempt: 1, + }, + repair_sources: [{ + memory_commit_id: "source-commit-alice", + contributor_user_id: "11111111-1111-4111-8111-111111111111", + contributor_github_login: "alice", + contributor_github_login_snapshot: "alice-old", + proposal_id: "source-proposal-alice", + }, { + memory_commit_id: "source-commit-bob", + contributor_user_id: "22222222-2222-4222-8222-222222222222", + contributor_github_login: "bob", + proposal_id: "source-proposal-bob", + }], + }, { version_id: "source-version-bob", scope_kind: "working", author_user_id: "22222222-2222-4222-8222-222222222222", @@ -350,7 +428,10 @@ const contextMarkdown = renderGraphContextMarkdown(contextResult); assert.match(contextMarkdown, /version context-version-1/); assert.match(contextMarkdown, /formerly @alice-old/); assert.match(contextMarkdown, /proposal context-proposal-1/); -assert.match(contextMarkdown, /session codex-session:context-1/); +assert.match(contextMarkdown, /session managed-repair:job-1:1/); +assert.match(contextMarkdown, /automation managed_repair_agent \(job job-1, attempt 1\)/); +assert.match(contextMarkdown, /repair source commit source-commit-alice by @alice \(formerly @alice-old\)/); +assert.match(contextMarkdown, /repair source commit source-commit-bob by @bob/); assert.match(contextMarkdown, /branch feature/); assert.match(contextMarkdown, /head repository example\/project/); assert.match(contextMarkdown, /head ref feature/); @@ -358,7 +439,10 @@ assert.match(contextMarkdown, /clean working tree/); assert.match(contextMarkdown, /code PR #7/); assert.match(contextMarkdown, /promotion promotion-1/); assert.match(contextMarkdown, /quarantine superseded repair/); -assert.match(contextMarkdown, /Origins: \[working; version source-version-bob; @bob; formerly @bob-old/); +assert.match(contextMarkdown, /Origins: \[working; version repair-version-1; proposal repair-proposal-1; commit repair-commit-1/); +assert.match(contextMarkdown, /Origins: .*automation managed_repair_agent \(job job-1, attempt 1\)/); +assert.match(contextMarkdown, /Origins: .*repair source commit source-commit-alice by @alice \(formerly @alice-old\) proposal source-proposal-alice/); +assert.match(contextMarkdown, /\[working; version source-version-bob; @bob; formerly @bob-old/); assert.match(contextMarkdown, /origin-proposal-bob/); assert.match(contextMarkdown, /claude-session:origin-bob/); assert.match(contextMarkdown, /head repository bob\/project/); @@ -482,12 +566,27 @@ const html = buildGraphViewHtmlFromData({ provenance: { version_id: "version-1", scope_kind: "working", - author_github_login: "alice", - author_github_login_snapshot: "alice-old", proposal_id: "proposal-1", memory_commit_id: "commit-1", - session_refs: [{ id: "codex-session:session-1", agent_platform: "codex" }], - agent_platform: "codex", + session_refs: [{ id: "managed-repair:job-1:1", agent_platform: "managed-repair" }], + agent_platform: "managed-repair", + automation_identity: { + kind: "managed_repair_agent", + reconciliation_job_id: "job-1", + repair_attempt: 1, + }, + repair_sources: [{ + memory_commit_id: "source-commit-alice", + contributor_user_id: "11111111-1111-4111-8111-111111111111", + contributor_github_login: "alice", + contributor_github_login_snapshot: "alice-old", + proposal_id: "source-proposal-alice", + }, { + memory_commit_id: "source-commit-bob", + contributor_user_id: "22222222-2222-4222-8222-222222222222", + contributor_github_login: "bob", + proposal_id: "source-proposal-bob", + }], git_head: mergeSha, head_repository: "example/project", head_ref: "feature", @@ -499,6 +598,30 @@ const html = buildGraphViewHtmlFromData({ commit_role: "repair", promotion_id: "promotion-1", origins: [{ + version_id: "repair-origin-version-1", + scope_kind: "working", + proposal_id: "repair-proposal-1", + memory_commit_id: "repair-commit-1", + session_refs: [{ id: "managed-repair:job-1:1", agent_platform: "managed-repair" }], + agent_platform: "managed-repair", + automation_identity: { + kind: "managed_repair_agent", + reconciliation_job_id: "job-1", + repair_attempt: 1, + }, + repair_sources: [{ + memory_commit_id: "source-commit-alice", + contributor_user_id: "11111111-1111-4111-8111-111111111111", + contributor_github_login: "alice", + contributor_github_login_snapshot: "alice-old", + proposal_id: "source-proposal-alice", + }, { + memory_commit_id: "source-commit-bob", + contributor_user_id: "22222222-2222-4222-8222-222222222222", + contributor_github_login: "bob", + proposal_id: "source-proposal-bob", + }], + }, { version_id: "origin-version-1", scope_kind: "working", author_github_login: "carol", @@ -539,9 +662,15 @@ const html = buildGraphViewHtmlFromData({ }); assert.match(html, /data-version-id="version-1"/); assert.match(html, /data-version-id="version-2"/); -assert.match(html, /data-author="alice,carol"/); +assert.match(html, /data-author="alice,bob,carol"/); +assert.match(html, /data-memory-commit-id="commit-1,source-commit-alice,source-commit-bob,repair-commit-1,origin-commit-1"/); assert.match(html, /provenance-badge[^>]*>repair { const url = new URL(incoming.url, "http://127.0.0.1"); const chunks = []; @@ -629,6 +762,11 @@ const server = createServer(async (incoming, response) => { }; if (url.pathname === "/oidc") { assert.equal(incoming.headers.authorization, "Bearer oidc-request-token"); + if (redirectOidcUrl !== undefined) { + response.writeHead(307, { location: redirectOidcUrl }); + response.end(); + return; + } assert.equal(url.searchParams.get("audience"), "greplica-managed"); send(200, { value: "github-oidc-token" }); return; @@ -803,6 +941,11 @@ const server = createServer(async (incoming, response) => { return; } if (url.pathname.endsWith("/memory/reconcile/attest")) { + if (redirectAttestationUrl !== undefined) { + response.writeHead(307, { location: redirectAttestationUrl }); + response.end(); + return; + } attestations.push(body); send(200, { accepted: true, @@ -831,10 +974,49 @@ await new Promise((resolve, reject) => { server.once("error", reject); server.listen(0, "127.0.0.1", resolve); }); +const redirectSink = createServer(async (incoming, response) => { + const chunks = []; + for await (const chunk of incoming) chunks.push(chunk); + redirectedAttestationBodies.push(Buffer.concat(chunks).toString("utf8")); + response.writeHead(200, { "content-type": "application/json" }); + response.end(JSON.stringify({ accepted: true })); +}); +await new Promise((resolve, reject) => { + redirectSink.once("error", reject); + redirectSink.listen(0, "127.0.0.1", resolve); +}); try { const address = server.address(); + const redirectSinkAddress = redirectSink.address(); assert.ok(address && typeof address === "object"); + assert.ok(redirectSinkAddress && typeof redirectSinkAddress === "object"); const apiUrl = `http://127.0.0.1:${address.port}`; + redirectOidcUrl = `http://127.0.0.1:${redirectSinkAddress.port}/capture-oidc`; + await assert.rejects( + run(process.execPath, [ + cliPath, + "memory", + "reconcile", + "--managed-repo", + installation.managedRepoId, + "--merge-sha", + mergeSha, + "--api-url", + apiUrl, + ], repoRoot, { + ...process.env, + ACTIONS_ID_TOKEN_REQUEST_URL: `${apiUrl}/oidc?api-version=1`, + ACTIONS_ID_TOKEN_REQUEST_TOKEN: "oidc-request-token", + GITHUB_REPOSITORY: "example/project", + GITHUB_REF: `refs/heads/${defaultBranch}`, + GITHUB_RUN_ID: "122", + }), + /fetch failed/, + ); + redirectOidcUrl = undefined; + assert.deepEqual(redirectedAttestationBodies, [], + "an OIDC redirect must never reach another origin with the request-token exchange"); + const result = await run(process.execPath, [ cliPath, "memory", @@ -980,6 +1162,35 @@ try { "PR-head proof must fetch the base repository pull ref even when the object already exists", ); + redirectAttestationUrl = `http://127.0.0.1:${redirectSinkAddress.port}/capture`; + const attestationsBeforeRedirect = attestations.length; + await assert.rejects( + run(process.execPath, [ + cliPath, + "memory", + "reconcile", + "--managed-repo", + installation.managedRepoId, + "--merge-sha", + mergeSha, + "--api-url", + apiUrl, + ], repoRoot, { + ...process.env, + ACTIONS_ID_TOKEN_REQUEST_URL: `${apiUrl}/oidc?api-version=1`, + ACTIONS_ID_TOKEN_REQUEST_TOKEN: "oidc-request-token", + GITHUB_REPOSITORY: "example/project", + GITHUB_REF: `refs/heads/${defaultBranch}`, + GITHUB_RUN_ID: "129", + }), + /fetch failed/, + ); + redirectAttestationUrl = undefined; + assert.equal(attestations.length, attestationsBeforeRedirect, + "a redirected attestation must not be accepted by the original service"); + assert.deepEqual(redirectedAttestationBodies, [], + "a 307 response must never forward the signed attestation body to another origin"); + exec("git", [ "-C", repoRoot, @@ -1152,7 +1363,10 @@ try { assert.equal(candidateCalls, candidateCallsBeforeReplay, "a historical workflow rerun must fail before requesting a reconciliation candidate"); } finally { - await new Promise((resolve) => server.close(resolve)); + await Promise.all([ + new Promise((resolve) => server.close(resolve)), + new Promise((resolve) => redirectSink.close(resolve)), + ]); } console.log("Managed collaboration checks passed."); From 8eae7247cc72b7fdb0b38f1c1100b11df1880472 Mon Sep 17 00:00:00 2001 From: Kushal Date: Tue, 28 Jul 2026 22:36:30 -0700 Subject: [PATCH 25/27] Pin repair provenance Action implementation --- .github/workflows/reconcile.yml | 2 +- scripts/check-managed-collaboration.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/reconcile.yml b/.github/workflows/reconcile.yml index e9b332a..f6c4f49 100644 --- a/.github/workflows/reconcile.yml +++ b/.github/workflows/reconcile.yml @@ -32,7 +32,7 @@ jobs: # Keep this immutable. The managed service separately allowlists this # reusable workflow's signed job_workflow_sha. - name: Reconcile Memory PRs - uses: Autoloops/greplica@2c1e8a152126e3b156c2c8b924201bb27db66c07 + uses: Autoloops/greplica@3aa909a12be71cac2fe34a75a841e2969768224a with: managed-repo: ${{ inputs.managed-repo }} merge-sha: ${{ inputs.merge-sha }} diff --git a/scripts/check-managed-collaboration.js b/scripts/check-managed-collaboration.js index 5ab0c27..cc531bd 100644 --- a/scripts/check-managed-collaboration.js +++ b/scripts/check-managed-collaboration.js @@ -49,7 +49,7 @@ assert.match(reusableWorkflow, /oidc-audience: greplica-managed/); assert.doesNotMatch(reusableWorkflow, /\$\{\{ inputs\.(?:api-url|oidc-audience) \}\}/); assert.match( reusableWorkflow, - /uses: Autoloops\/greplica@2c1e8a152126e3b156c2c8b924201bb27db66c07/, + /uses: Autoloops\/greplica@3aa909a12be71cac2fe34a75a841e2969768224a/, ); assert.doesNotMatch(reusableWorkflow, /uses: Autoloops\/greplica@(main|refs\/heads\/|v\d)/); From 62b0a363d49fe41dcdaa1a1bbf0b2c8c7c9f1c43 Mon Sep 17 00:00:00 2001 From: Kushal Date: Tue, 28 Jul 2026 23:11:00 -0700 Subject: [PATCH 26/27] Separate agent sessions from graph evidence --- libs/knowledge-graph/managed-client.ts | 23 +++++++--- scripts/check-managed-collaboration.js | 60 ++++++++++++++++++++++++++ 2 files changed, 76 insertions(+), 7 deletions(-) diff --git a/libs/knowledge-graph/managed-client.ts b/libs/knowledge-graph/managed-client.ts index 5dbcb3b..4fb4628 100644 --- a/libs/knowledge-graph/managed-client.ts +++ b/libs/knowledge-graph/managed-client.ts @@ -371,7 +371,12 @@ function legacyCommitContext(context: ProposalCommitContext | undefined): Propos function proposalSessionRefs(proposal: unknown): string[] { if (!isRecord(proposal) || !isRecord(proposal.creates) || !Array.isArray(proposal.creates.sources)) return []; const refs = proposal.creates.sources.flatMap((source) => - isRecord(source) && source.kind === "session" && typeof source.ref === "string" ? [source.ref] : []); + isRecord(source) && + source.kind === "session" && + typeof source.ref === "string" && + platformForSessionRef(source.ref) !== undefined + ? [source.ref] + : []); return [...new Set(refs)]; } @@ -381,12 +386,16 @@ function proposalAgentPlatform(sessionRefs: string[]): string | undefined { } function platformForSessionRef(ref: string): string | undefined { - const separator = ref.indexOf(":"); - if (separator <= 0) return undefined; - const prefix = ref.slice(0, separator); - if (prefix === "claude-code-session") return "claude"; - if (prefix === "factory-droid-session") return "factory-droid"; - return prefix.endsWith("-session") ? prefix.slice(0, -"-session".length) : prefix; + const prefixes = [ + ["claude-code-session:", "claude"], + ["factory-droid-session:", "factory-droid"], + ["codex-session:", "codex"], + ["copilot-session:", "copilot"], + ["cursor-session:", "cursor"], + ["opencode-session:", "opencode"], + ["openhands-session:", "openhands"], + ] as const; + return prefixes.find(([prefix]) => ref.startsWith(prefix))?.[1]; } function githubRepository(remoteUrl: string | undefined): string | undefined { diff --git a/scripts/check-managed-collaboration.js b/scripts/check-managed-collaboration.js index cc531bd..5e580a8 100644 --- a/scripts/check-managed-collaboration.js +++ b/scripts/check-managed-collaboration.js @@ -464,6 +464,66 @@ assert.deepEqual(applyBody.context.session_refs, [{ id: "codex-session:session-1 assert.equal("author" in applyBody, false); assert.equal("username" in applyBody, false); +await client.applyProposal({ + title: "Source-free code memory", + creates: { + claims: [{ + id: "claim-source-free-code", + kind: "fact", + text: "The example function is present.", + truth: "code_verified", + intent: "intended", + code_anchors: [{ file: "example.ts", symbol: "example" }], + }], + }, +}); +assert.equal(applyBody.context.agent_platform, undefined); +assert.equal(applyBody.context.session_refs, undefined); + +await client.applyProposal({ + title: "GitHub artifact memory", + creates: { + sources: [{ + id: "source-github-pr", + kind: "session", + ref: "https://github.com/example/project/pull/7", + }], + }, +}); +assert.equal(applyBody.context.agent_platform, undefined); +assert.equal(applyBody.context.session_refs, undefined, + "an evidence URL is not the proposal-producing agent session"); + +await client.applyProposal({ + title: "Mixed session and artifact memory", + creates: { + sources: [{ + id: "source-codex", + kind: "session", + ref: "codex-session:session-2", + }, { + id: "source-github", + kind: "session", + ref: "https://github.com/example/project/issues/8", + }], + }, +}); +assert.equal(applyBody.context.agent_platform, "codex"); +assert.deepEqual(applyBody.context.session_refs, [{ + id: "codex-session:session-2", + agent_platform: "codex", +}]); + +await client.applyProposal({ + title: "Unrecognized session-shaped memory", + creates: { + sources: [{ id: "source-unrecognized", kind: "session", ref: "session:forged" }], + }, +}); +assert.equal(applyBody.context.agent_platform, undefined); +assert.equal(applyBody.context.session_refs, undefined, + "an arbitrary evidence ref must not be re-labelled as agent provenance"); + await client.listProposals(); await client.showProposal("proposal/1"); await client.listMemoryPrs(); From c83d112a4b792c863165d0d950cba6ead7b6f974 Mon Sep 17 00:00:00 2001 From: Kushal Date: Wed, 29 Jul 2026 00:05:14 -0700 Subject: [PATCH 27/27] Handle closed child stdin in CLI checks --- scripts/check-managed-cli.js | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/scripts/check-managed-cli.js b/scripts/check-managed-cli.js index 0a7a9f4..19c8e5d 100644 --- a/scripts/check-managed-cli.js +++ b/scripts/check-managed-cli.js @@ -782,7 +782,7 @@ function run(command, args, cwd, env = process.env, input = "") { if (code === 0) resolve({ stdout, stderr }); else reject(new Error(`${command} ${args.join(" ")} failed (${code})\n${stderr}`)); }); - child.stdin.end(input); + endChildInput(child, input, reject); }); } @@ -800,6 +800,13 @@ function runFailure(command, args, cwd, env = process.env, input = "") { if (code !== 0) resolve({ stdout, stderr, code }); else reject(new Error(`${command} ${args.join(" ")} unexpectedly succeeded`)); }); - child.stdin.end(input); + endChildInput(child, input, reject); }); } + +function endChildInput(child, input, reject) { + child.stdin.on("error", (error) => { + if (error?.code !== "EPIPE") reject(error); + }); + child.stdin.end(input); +}