diff --git a/.github/workflows/reconcile.yml b/.github/workflows/reconcile.yml new file mode 100644 index 0000000..f6c4f49 --- /dev/null +++ b/.github/workflows/reconcile.yml @@ -0,0 +1,42 @@ +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 + +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@3aa909a12be71cac2fe34a75a841e2969768224a + with: + managed-repo: ${{ inputs.managed-repo }} + merge-sha: ${{ inputs.merge-sha }} + # 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 37661f1..3deff9e 100644 --- a/README.md +++ b/README.md @@ -77,7 +77,55 @@ 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 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 +``` + +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 +name: Greplica memory + +on: + push: + branches: [main] # Replace with the repository's default branch. + schedule: + - cron: "17 * * * *" + workflow_dispatch: + +permissions: + contents: read + id-token: write + +jobs: + reconcile-memory: + permissions: + contents: read + id-token: write + uses: Autoloops/greplica/.github/workflows/reconcile.yml@ + with: + managed-repo: + 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. + +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: + +- `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: @@ -165,19 +213,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 new file mode 100644 index 0000000..0d5b006 --- /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@11d5960a326750d5838078e36cf38b85af677262 + with: + ref: ${{ inputs.merge-sha }} + fetch-depth: 0 + - name: Set up Node.js + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + 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..4869881 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 ]", + 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}`); @@ -473,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}`); @@ -486,6 +581,90 @@ 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", + working_users: [], + 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 { + 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); + } +} + +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 +1184,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 +1259,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 +1286,94 @@ function parseGraphViewArgs(args: string[]): GraphViewOptions { outputPath = resolve(arg.slice("--out=".length)); continue; } - throw new Error(usage("graphView")); + selectionArgs.push(arg); + } + + 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); } - return { outputPath, noOpen }; + if (mainOnly && workingUsers.length > 0) { + throw new Error("--main-only cannot be combined with working overlays."); + } + const uniqueWorkingUsers = uniqueGithubLogins(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 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 defaultGraphViewOutputPath(repoName: string): string { @@ -1161,6 +1420,129 @@ 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(",") || "-"; + 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 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, + 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, + ].join("\t")); +} + +function printMemoryPrSummary(memoryPr: ManagedMemoryPr): void { + console.log([ + memoryPr.id, + memoryPr.state, + 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`, + 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 [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( + `${identity}: cleared ${cleanup.cleared_objects} objects` + + `${remainingCommits}; ${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`); + 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}`); + 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..e34e4a9 --- /dev/null +++ b/apps/cli/reconcile-cli.ts @@ -0,0 +1,692 @@ +import { execFileSync } from "node:child_process"; +import { resolve } from "node:path"; +import { auditClaimCodeAnchors } from "../../libs/knowledge-graph/code-anchors/audit.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"; +import { + managedCapabilitiesHeader, + managedClientCapabilities, + managedClientVersion, + managedClientVersionHeader, +} 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", + [managedClientVersionHeader]: managedClientVersion, + [managedCapabilitiesHeader]: managedClientCapabilities.join(","), + }; + const reconciliations: Array<{ + response: ManagedReconciliationAttestationResult; + memory_pr_id: string; + audited_claim_versions: number; + audited_component_versions: number; + code_evidence_entries?: number; + memory_commit_ids: string[]; + }> = []; + const skipped: Array<{ + memory_pr_id: string; + 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[] = []; + 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, + 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 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"; + 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 { + 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 = proofs.filter((proof) => !proof.ancestry.is_ancestor); + const outsidePrDelta = proofs.filter((proof) => + proof.ancestry.is_ancestor && !proof.isInPrDelta + ); + const ancestry = proofs.map((proof) => proof.ancestry); + if (codeMergeIsAncestor === false || nonAncestors.length > 0 || outsidePrDelta.length > 0) { + const failures = nonAncestors.length > 0 ? nonAncestors : outsidePrDelta; + 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, + }); + 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, + 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, + 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, + memory_commit_ids: rejectedMemoryCommitIds, + code_merge_sha: codeMergeSha, + code_merge_is_ancestor: codeMergeIsAncestor, + response, + }); + 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.component_versions ?? [])] + .filter(({ baseline_fingerprints }) => baseline_fingerprints !== undefined) + .map(({ version_id, baseline_fingerprints }) => [version_id, baseline_fingerprints!]), + ); + 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; + 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, + process.env.GITHUB_REF, + ); + const attestation: ManagedReconciliationAttestation = { + 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, + audit_key: "version_id", + anchor_audit: anchorAudit, + code_evidence: codeEvidence, + 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/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, + 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); + } + const accepted = reconciliations.every(({ response }) => response.accepted); + console.log(JSON.stringify({ + accepted, + merge_sha: mergeSha, + reconciliation_count: reconciliations.length, + skipped_count: skipped.length, + reconciliations, + skipped, + }, null, 2)); + if (!accepted) process.exitCode = 1; +} + +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."); + 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)) { + 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.`); + } + 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.`); + } + 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.`); + } + } +} + +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 + .split(",") + .map((file) => file.trim()) + .filter((file) => file.length > 0) + .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], { + stdio: "ignore", + }); + return true; + } catch { + return false; + } +} + +interface VerifiedPrRange { + headSha: string; + baseSha?: string; + mergeBaseSha?: string; +} + +function verifiedPrRange( + repoRoot: string, + pullRequestNumber: number | undefined, + verifiedHeadSha: string | undefined, + 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."); + } + 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.`); + } + 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 { + try { + execFileSync("git", ["-C", repoRoot, "cat-file", "-e", `${sha}^{commit}`], { stdio: "ignore" }); + return true; + } catch { + return false; + } +} + +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", + 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=all"]); + 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" }, + redirect: "error", + }); + 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, + redirect: "error", + }); + 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 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}.`); + 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/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/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 1c0fc3a..16db6be 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 { ManagedObjectOrigin, 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,64 @@ 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 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 automation = provenance.automation_identity; + 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}`, + 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}`, + 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, + 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 values; +} + 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 f3ff469..8d7f1c9 100644 --- a/libs/knowledge-graph/graph-view/build-graph-view.ts +++ b/libs/knowledge-graph/graph-view/build-graph-view.ts @@ -6,6 +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, + ManagedObjectOrigin, + ManagedObjectProvenance, +} from "../../managed/protocol.js"; const require = createRequire(import.meta.url); @@ -34,6 +39,7 @@ export interface GraphViewComponentRow { flowCount: number; claimCount: number; subcomponentCount: number; + provenance?: ManagedObjectProvenance; } export interface GraphViewFlowRow { @@ -42,6 +48,7 @@ export interface GraphViewFlowRow { folder: string; touchedComponentFolders: string[]; claimCount: number; + provenance?: ManagedObjectProvenance; } export interface GraphViewClaimRow { @@ -55,6 +62,7 @@ export interface GraphViewClaimRow { flowIds: string[]; createdAt: string | null; memoryCommitId: string | null; + provenance?: ManagedObjectProvenance; } export interface GraphViewTimelineEvent { @@ -67,6 +75,7 @@ export interface GraphViewTimelineEvent { export interface GraphViewData { generatedAt: string; + view?: ManagedGraphView; counts: { components: number; flows: number; @@ -88,6 +97,24 @@ 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: "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" }, + { 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", @@ -114,6 +141,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); @@ -127,6 +155,7 @@ export function buildGraphViewData( .map((componentId) => segmentForComponentId(componentId)) .sort((left, right) => left.localeCompare(right)), claimCount: countClaimsForFlow(flow.id, graph.edges), + provenance: managedProvenance(flow), }; }); @@ -144,6 +173,7 @@ export function buildGraphViewData( flowIds: flowIdsForClaim(claim.id, graph.edges), createdAt: record?.created_at ?? null, memoryCommitId: record?.memory_commit_id ?? null, + provenance: managedProvenance(claim), }; }; @@ -172,6 +202,10 @@ export function buildGraphViewData( }; } +function managedProvenance(value: object): ManagedObjectProvenance | undefined { + return (value as { provenance?: ManagedObjectProvenance }).provenance; +} + export function buildGraphViewHtml( graph: GraphReadResult, provenance: ClaimProvenanceRecord[], @@ -390,9 +424,163 @@ 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 === "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; + 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 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) + .flatMap((entry) => [ + provenanceEntryValue(entry, key), + ...repairSourceValues(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"], + ["automation", "automation"], + ["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.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}`, + 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)}`; - return ` ${escapeHtml(claim.text)}
${escapeHtml(claim.id)}
${escapeHtml(claim.session)}${badge}${escapeHtml(formatDateTime(claim.createdAt))}`; + const provenance = claim.provenance; + const provenanceBadges = renderProvenanceBadges(provenance); + 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 { @@ -406,7 +594,7 @@ function renderHtml(data: GraphViewData, title: string): string { component.claimCount > 0 ? `${component.claimCount}` : `${component.claimCount}`; - return ` ${escapeHtml(component.folder)}${escapeHtml(component.name)}${anchors}${component.flowCount}${claimsCell}${component.subcomponentCount}`; + return ` ${escapeHtml(component.folder)}${escapeHtml(component.name)}${renderProvenanceBadges(component.provenance)}${anchors}${component.flowCount}${claimsCell}${component.subcomponentCount}`; }) .join("\n"); @@ -420,7 +608,7 @@ function renderHtml(data: GraphViewData, title: string): string { flow.claimCount > 0 ? `${flow.claimCount}` : `${flow.claimCount}`; - return ` ${escapeHtml(flow.folder)}${escapeHtml(flow.name)}${touchedComponents}${claimsCell}`; + return ` ${escapeHtml(flow.folder)}${escapeHtml(flow.name)}${renderProvenanceBadges(flow.provenance)}${touchedComponents}${claimsCell}`; }) .join("\n"); @@ -609,6 +797,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 +847,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); @@ -820,6 +1050,7 @@ function renderHtml(data: GraphViewData, title: string): string {

Components

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

+ ${renderProvenanceFilters("components")} @@ -832,6 +1063,7 @@ ${componentRows}

Flows

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

+ ${renderProvenanceFilters("flows")}
NameDescriptionCode AnchorsFlowsClaimsSubcomponents
@@ -845,6 +1077,7 @@ ${flowRows}

Claims

+ ${renderProvenanceFilters("claims")}

${escapeHtml(defaultClaimsMeta)}

NameDescriptionTouched ComponentsClaims
@@ -903,8 +1136,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 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)}; @@ -913,14 +1158,83 @@ ${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 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 || []]) + ); + 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])); let activeFilter = null; + 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 === "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 || ""; + 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 ""; + } + + 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.flatMap((entry) => [ + provenanceEntryValue(entry, key), + ...repairSourceValues(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) .replace(/&/g, "&") @@ -1163,16 +1477,43 @@ ${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 (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; } + 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 provenanceValues(claim, key).includes(select.value); + }); + } + + function hasProvenanceFilter() { + 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; @@ -1180,15 +1521,15 @@ ${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; + 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 +1543,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 +1582,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 +1599,21 @@ ${timelineEvents} }); if (claimsSearchInput) claimsSearchInput.addEventListener("input", applyClaims); + 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/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..4fb4628 100644 --- a/libs/knowledge-graph/managed-client.ts +++ b/libs/knowledge-graph/managed-client.ts @@ -17,6 +17,19 @@ 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"; +import { + managedCapabilitiesHeader, + managedClientCapabilities, + managedClientVersion, + managedClientVersionHeader, + type ManagedClientCapability, +} from "../managed/protocol.js"; export interface ManagedGraphClientOptions { apiUrl: string; @@ -33,8 +46,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 +58,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,31 +90,47 @@ 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" }, + view === undefined ? undefined : "graph-selectors-v1", + ); } - 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 }) }, + }, + 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(): 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" }, + view === undefined ? undefined : "graph-selectors-v1", + ); } - 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 +145,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 +157,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 +177,52 @@ 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; + return { + ...view, + // 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), + }; + } + private async proposalAnchorAudit(proposal: unknown): Promise { const normalized = normalizeProposal(proposal); const claims = normalized.creates.claims ?? []; @@ -154,6 +239,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}`, { @@ -161,11 +247,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" @@ -190,7 +284,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,22 +320,122 @@ 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; - const git = (args: string[]): string | 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[], 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"]); - if (gitHead === undefined && branch === undefined && dirtyOutput === undefined) return undefined; - return { git_head: gitHead, branch, dirty: dirtyOutput !== undefined }; + const dirtyOutput = git(["status", "--porcelain"], true); + 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" && + platformForSessionRef(source.ref) !== undefined + ? [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 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 { + 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) { + 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()}`; +} + +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) ?? "") + .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/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/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 f719790..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; @@ -165,6 +171,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 +200,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); } @@ -262,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" }), }, @@ -300,14 +315,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 +337,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..e6c0c68 100644 --- a/libs/managed/protocol.ts +++ b/libs/managed/protocol.ts @@ -1,5 +1,17 @@ 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", + "reconciliation-code-evidence-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", @@ -35,7 +47,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 +76,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 +152,132 @@ 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 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([ + 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()), + 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()), + 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([ + 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()), + 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()), + 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()), + origins: Type.Optional(Type.Array(ManagedObjectOriginSchema)), +}); + export const CodeAnchorSchema = Type.Object({ file: Type.String(), symbol: Type.Optional(Type.String()), @@ -140,8 +286,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 +307,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 +337,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({ @@ -196,6 +350,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), @@ -206,7 +375,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), @@ -215,12 +386,52 @@ 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, 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(ReconciliationCodeEvidenceOmissionReasonSchema), +}); + +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()), @@ -229,11 +440,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 +592,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 }), @@ -392,6 +612,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(), @@ -399,6 +620,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), @@ -416,10 +638,218 @@ 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: 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()), + 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({ + 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 }), + })), + promoted_at: Type.String({ format: "date-time" }), +}); + +export const MemoryPrSchema = Type.Object({ + id: Type.String(), + code_pr: Type.Optional(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 }), + 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 }), + 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 }), + 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(), + 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 })), + verified_base_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())), + })), + 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({ + 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 }), + 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 }), + 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()), +}); + +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 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(), { 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"), + ]), + 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(), @@ -464,7 +894,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 +907,48 @@ 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 }), + exclude_memory_pr: Type.Optional(Type.Union([ + Type.String({ minLength: 1 }), + Type.Array(Type.String({ minLength: 1 })), + ])), + }), + ReconciliationCandidateSchema, + ), + reconciliationAttest: route(ReconciliationAttestationSchema, ReconciliationAttestationResultSchema), + reconciliationReject: route(ReconciliationRejectionSchema, ReconciliationRejectionResultSchema), repoImport: route(Type.Object({ graph: GraphReadSchema, anchor_audit: ProposalAnchorAuditSchema, @@ -508,6 +969,23 @@ export type ManagedGraphRead = Static; 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; +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; +export type ManagedReconciliationRejection = Static; +export type ManagedReconciliationRejectionResult = 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..d6dd889 100644 --- a/package.json +++ b/package.json @@ -24,7 +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-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-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-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 b146c65..19c8e5d 100644 --- a/scripts/check-managed-cli.js +++ b/scripts/check-managed-cli.js @@ -29,9 +29,82 @@ const managedRepository = { 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 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; + 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")); @@ -125,10 +198,144 @@ 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", + 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" && + 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, + 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}/proposals`) { + send(200, [proposalRecord, automatedRepairProposalRecord]); + 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}/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", + 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 === "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; + } + 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; } @@ -241,6 +448,212 @@ 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 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"); + 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"); + 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"], + 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 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, + "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: ["alice", "bob"], + memory_pr_id: "memory-pr-selector", + include_quarantined: true, + }, "a reader without personal working sends only deduplicated explicit contributor overlays"); + + 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"), ["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"], + managedRepo, + 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 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({ @@ -369,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); }); } @@ -387,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); } diff --git a/scripts/check-managed-collaboration.js b/scripts/check-managed-collaboration.js new file mode 100644 index 0000000..5e580a8 --- /dev/null +++ b/scripts/check-managed-collaboration.js @@ -0,0 +1,1476 @@ +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"; +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 { + 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 { 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"); +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)), + "utf8", +); +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@3aa909a12be71cac2fe34a75a841e2969768224a/, +); +assert.doesNotMatch(reusableWorkflow, /uses: Autoloops\/greplica@(main|refs\/heads\/|v\d)/); + +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 function example() { return 0; }\n"); +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(); +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 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]); +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"], + versionOneBaseline["example.ts#example"], + "fixture must change the anchored symbol body on the merged checkout", +); + +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, headers: new Headers(init?.headers) }); + 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: [{ + 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", + proposal_id: "context-proposal-1", + memory_commit_id: "context-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", + }], + 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: "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", + 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: [], + 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: [{ + 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", + proposal_id: "context-proposal-1", + memory_commit_id: "context-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", + }], + 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: "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", + 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: [], + 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: [], + }); + } + 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"); +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, ["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); +assert.match(contextMarkdown, /version context-version-1/); +assert.match(contextMarkdown, /formerly @alice-old/); +assert.match(contextMarkdown, /proposal context-proposal-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/); +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 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/); +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"); + +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.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); + +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(); +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 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 ( + 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, 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", + 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", + proposal_id: "proposal-1", + memory_commit_id: "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", + }], + 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: "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", + 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", + 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: { + summary: { total: 1, sessionPct: 100, codePct: 0 }, + events: [], + }, +}); +assert.match(html, /data-version-id="version-1"/); +assert.match(html, /data-version-id="version-2"/); +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]*>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"/); +assert.match(html, /id="claims-filter-author"/); +assert.match(html, /id="claims-filter-author-snapshot"/); +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-automation"/); +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: [{ + 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", + text: "Managed provenance survives row construction", + truth: "source_verified", + intent: "intended", + provenance: { + version_id: "version-built", + scope_kind: "main", + memory_commit_state: "promoted", + }, + }], + sources: [], + 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 = []; +const rejectedMemoryPrIds = new Set(); +const redirectedAttestationBodies = []; +let candidateCalls = 0; +let forcePushMode = false; +let codeMergeFailureMode = false; +let codeMergeFailureSha = unrelatedCodeMergeSha; +let codeMergeFailureMemoryPrId = "memory-pr-code-merge-away"; +let redirectAttestationUrl; +let redirectOidcUrl; +const server = createServer(async (incoming, response) => { + 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"); + 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; + } + 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; + 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")) { + send(404, { message: "No Memory PR is ready for this merged checkout." }); + return; + } + send(200, { + memory_pr_id: "memory-pr-force-push", + merge_sha: mergeSha, + memory_commit_ids: ["commit-force-pushed-away"], + commits: [{ + 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: mergeSha, + verified_base_sha: baseSha, + }], + claim_versions: [], + }); + return; + } + if (excluded.length === 2) { + assert.deepEqual(excluded, ["memory-pr-1", "memory-pr-2"]); + 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) { + 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, + code_merge_sha: codeMergeSha, + code_evidence_required: true, + memory_commit_ids: ["commit-1", "commit-dependency-1"], + 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, + 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", + baseline_fingerprints: versionOneBaseline, + claim: { + id: "claim.logical", + kind: "fact", + text: "Version-keyed audit", + truth: "code_verified", + intent: "intended", + 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, example.ts", + }, + }, { + version_id: "version-component-missing", + baseline_fingerprints: {}, + component: { + id: "component.missing", + 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; + } + 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, + memory_pr_id: body.memory_pr_id, + job_id: `job-${attestations.length}`, + state: "queued", + }); + 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: body.reason === "code_merge_not_ancestor" + ? body.memory_commit_ids + : [], + }); + 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); +}); +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", + "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: "123", + }); + assert.match(result.stdout, /"accepted": true/); + assert.match(result.stdout, /"reconciliation_count": 2/); + 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); + 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, [{ + 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].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.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.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, 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 + } = 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); + 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, + 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(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, + "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, + "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/${defaultBranch}`, + GITHUB_RUN_ID: "124", + }), + /does not match verified head/, + ); + assert.equal( + attestations.length, + attestationsBeforeMismatch, + "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/${defaultBranch}`, + 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); + + 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"], + ).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/${defaultBranch}`, + GITHUB_RUN_ID: "128", + }), + /historical workflow reruns cannot reconcile memory/, + ); + assert.equal(candidateCalls, candidateCallsBeforeReplay, + "a historical workflow rerun must fail before requesting a reconciliation candidate"); +} finally { + await Promise.all([ + new Promise((resolve) => server.close(resolve)), + new Promise((resolve) => redirectSink.close(resolve)), + ]); +} + +console.log("Managed collaboration checks passed."); + +function exec(command, args) { + return execFileSync(command, args, { encoding: "utf8" }); +} + +function jsonResponse(value, capabilities = true) { + return new Response(JSON.stringify(value), { + status: 200, + 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"] }); + 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}`)); + }); + }); +} 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")]); + } + } + } +}