From ec6204fb25ec5aaae10e616b3557c287a914bd7d Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Thu, 28 May 2026 04:56:57 -0700 Subject: [PATCH 1/2] feat(agent): add deterministic base-agent orchestrator --- CHANGELOG.md | 2 + README.md | 29 +- migrations/0010_agent_orchestrator.sql | 55 ++ package-lock.json | 2 +- package.json | 2 +- packages/gittensory-mcp/CHANGELOG.md | 6 + packages/gittensory-mcp/README.md | 25 +- packages/gittensory-mcp/bin/gittensory-mcp.js | 169 +++- packages/gittensory-mcp/package.json | 7 +- site/guide/github-app-setup.md | 15 + site/guide/install.md | 1 + site/guide/maintainers.md | 13 + site/guide/mcp.md | 20 +- site/guide/miners.md | 27 +- site/index.md | 20 +- site/reference/api.md | 11 + src/api/routes.ts | 83 ++ src/auth/rate-limit.ts | 1 + src/db/repositories.ts | 204 +++++ src/db/schema.ts | 68 ++ src/github/backfill.ts | 2 +- src/github/commands.ts | 128 +++ src/github/comments.ts | 28 +- src/mcp/server.ts | 123 +++ src/openapi/schemas.ts | 71 ++ src/openapi/spec.ts | 37 + src/queue/processors.ts | 119 ++- src/services/agent-orchestrator.ts | 586 ++++++++++++++ src/types.ts | 81 ++ test/integration/api.test.ts | 120 ++- test/integration/routes-errors.test.ts | 51 ++ test/unit/agent-orchestrator.test.ts | 762 ++++++++++++++++++ test/unit/backfill.test.ts | 20 +- test/unit/github-commands.test.ts | 263 ++++++ test/unit/mcp-cli.test.ts | 67 +- test/unit/openapi.test.ts | 8 + test/unit/queue.test.ts | 235 +++++- 37 files changed, 3413 insertions(+), 48 deletions(-) create mode 100644 migrations/0010_agent_orchestrator.sql create mode 100644 src/github/commands.ts create mode 100644 src/services/agent-orchestrator.ts create mode 100644 test/unit/agent-orchestrator.test.ts create mode 100644 test/unit/github-commands.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a5039a25e..70cb3b413b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,6 +49,8 @@ - Refresh Gittensor-aligned homepage +- Add deterministic base-agent orchestrator + ### Fixes diff --git a/README.md b/README.md index 71ae621a75..9c24549664 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,10 @@ # Gittensory -Gittensory is a backend-only intelligence layer for Gittensor registered repositories. +Gittensory is the deterministic base-agent layer for Gittensor OSS contribution mining. -It helps miners and contributors make better decisions before they open work, and it helps maintainers review Gittensor-driven PRs with less noise. The product is the signal: role-aware contributor context, official Gittensor stats, local MCP preflight, queue health, collision risk, reviewability, and repo configuration quality. +It helps miners and contributors make better decisions before they open work, and it helps maintainers review Gittensor-driven PRs with less noise. The product is the signal: role-aware contributor context, official Gittensor stats, local MCP preflight, queue health, collision risk, reviewability, repo configuration quality, and copilot-only next-action planning. -Gittensory is not a Gittensor frontend, not a public leaderboard, and not an auto-close or auto-merge bot. +Gittensory is not a Gittensor frontend, not a public leaderboard, and not an autonomous PR bot. V1 ranks, explains, preflights, and drafts public-safe packets; it does not edit code, open PRs, post comments, close, merge, or label without an explicit user or maintainer-triggered flow. ## What It Does @@ -14,12 +14,13 @@ Gittensory is not a Gittensor frontend, not a public leaderboard, and not an aut - Generates public-safe PR packets that help contributors write cleaner submissions. - Gives maintainers private PR reviewability packets through the API/MCP, while the GitHub App stays public-safe. - Tracks repository intelligence: lane correctness, registry changes, queue health, label/config quality, collisions, bounties, and sync fidelity. +- Runs deterministic base-agent plans that rank next actions, explain blockers, prepare PR packets, and record auditable run/action snapshots. ## Surfaces - Worker API: Cloudflare Workers + Hono + D1 + Queues. -- MCP package: `@jsonbored/gittensory-mcp`, a local stdio wrapper for coding agents. -- GitHub App: quiet PR inspection, public-safe sticky comments, and maintainer-configured labels only for officially confirmed Gittensor miners. +- MCP package: `@jsonbored/gittensory-mcp`, a local stdio wrapper for coding agents and the first-class base-agent surface. +- GitHub App: quiet PR inspection, public-safe sticky comments, maintainer-configured labels, and explicit `@gittensory` commands on installed repos. - Docs site: VitePress under `site/`, deployed at `https://gittensory.aethereal.dev/`. ## MCP Install @@ -33,6 +34,15 @@ gittensory-mcp doctor gittensory-mcp --stdio ``` +Base-agent CLI commands: + +```sh +gittensory-mcp agent plan --login jsonbored --json +gittensory-mcp agent packet --login jsonbored --json +gittensory-mcp agent status --json +gittensory-mcp agent explain --json +``` + Local checkout: ```sh @@ -115,6 +125,12 @@ Protected endpoints use `Authorization: Bearer ` or a Gitt - `GET /v1/contributors/:login/profile` - `GET /v1/contributors/:login/decision-pack` - `GET /v1/contributors/:login/repos/:owner/:repo/decision` +- `POST /v1/agent/runs` +- `GET /v1/agent/runs/:id` +- `POST /v1/agent/plan-next-work` +- `POST /v1/agent/preflight-branch` +- `POST /v1/agent/prepare-pr-packet` +- `POST /v1/agent/explain-blockers` - `POST /v1/preflight/pr` - `POST /v1/preflight/local-diff` - `POST /v1/local/branch-analysis` @@ -141,11 +157,12 @@ Required events: - Pull request - Issues +- Issue comment - Repository If GitHub shows `Installation target`, select it. Gittensory should not block install health on event names that GitHub does not show in the app UI. -Default GitHub App behavior is low-noise: non-miner, bot, and maintainer-associated PR authors produce no public output. Confirmed Gittensor miners get one sticky public-safe PR comment and the configured label, defaulting to `gittensor`. Private reviewability, scoring, wallet, hotkey, and reward/risk context never appears in public GitHub comments or checks. +Default GitHub App behavior is low-noise: non-miner, bot, and maintainer-associated PR authors produce no public output. Confirmed Gittensor miners get one sticky public-safe PR comment and the configured label, defaulting to `gittensor`. Maintainers and authorized PR authors can explicitly invoke public-safe `@gittensory` commands. Private reviewability, scoring, wallet, hotkey, and reward/risk context never appears in public GitHub comments or checks. ## Docs diff --git a/migrations/0010_agent_orchestrator.sql b/migrations/0010_agent_orchestrator.sql new file mode 100644 index 0000000000..37b9401b93 --- /dev/null +++ b/migrations/0010_agent_orchestrator.sql @@ -0,0 +1,55 @@ +CREATE TABLE agent_runs ( + id TEXT PRIMARY KEY NOT NULL, + objective TEXT NOT NULL, + actor_login TEXT NOT NULL, + surface TEXT NOT NULL, + mode TEXT NOT NULL DEFAULT 'copilot', + status TEXT NOT NULL DEFAULT 'queued', + data_quality_status TEXT NOT NULL DEFAULT 'unknown', + error_summary TEXT, + payload_json TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX agent_runs_actor_updated_idx ON agent_runs (actor_login, updated_at); +CREATE INDEX agent_runs_status_updated_idx ON agent_runs (status, updated_at); +CREATE INDEX agent_runs_surface_updated_idx ON agent_runs (surface, updated_at); + +CREATE TABLE agent_actions ( + id TEXT PRIMARY KEY NOT NULL, + run_id TEXT NOT NULL, + action_type TEXT NOT NULL, + target_repo_full_name TEXT, + target_pull_number INTEGER, + target_issue_number INTEGER, + status TEXT NOT NULL, + recommendation TEXT NOT NULL, + why_json TEXT NOT NULL DEFAULT '[]', + scoreability_impact TEXT, + risk_impact TEXT, + maintainer_impact TEXT, + blocked_by_json TEXT NOT NULL DEFAULT '[]', + rerun_when TEXT, + public_safe_summary TEXT NOT NULL, + approval_required INTEGER NOT NULL DEFAULT 1, + safety_class TEXT NOT NULL, + payload_json TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX agent_actions_run_action_idx ON agent_actions (run_id, action_type); +CREATE INDEX agent_actions_target_repo_idx ON agent_actions (target_repo_full_name, created_at); + +CREATE TABLE agent_context_snapshots ( + id TEXT PRIMARY KEY NOT NULL, + run_id TEXT NOT NULL, + decision_pack_version TEXT, + repo_signal_snapshot_ids_json TEXT NOT NULL DEFAULT '[]', + scoring_model_id TEXT, + freshness_warnings_json TEXT NOT NULL DEFAULT '[]', + payload_json TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX agent_context_snapshots_run_created_idx ON agent_context_snapshots (run_id, created_at); diff --git a/package-lock.json b/package-lock.json index 60a526d934..e03b30830f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7594,7 +7594,7 @@ }, "packages/gittensory-mcp": { "name": "@jsonbored/gittensory-mcp", - "version": "0.1.4", + "version": "0.2.0", "license": "AGPL-3.0-only", "dependencies": { "@modelcontextprotocol/sdk": "^1.26.0", diff --git a/package.json b/package.json index 5eeb71264c..8e73988730 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "packages/*" ], "type": "module", - "description": "GitHub-native advisory backend for Gittensor registered repositories.", + "description": "Deterministic base-agent backend for Gittensor OSS contribution mining.", "scripts": { "dev": "wrangler dev", "deploy": "wrangler deploy", diff --git a/packages/gittensory-mcp/CHANGELOG.md b/packages/gittensory-mcp/CHANGELOG.md index 83617a9876..62806de8ee 100644 --- a/packages/gittensory-mcp/CHANGELOG.md +++ b/packages/gittensory-mcp/CHANGELOG.md @@ -3,6 +3,12 @@ ## Unreleased +### Features + +- Add deterministic base-agent orchestrator + + + ### Fixes - Create GitHub releases for MCP tag publishes diff --git a/packages/gittensory-mcp/README.md b/packages/gittensory-mcp/README.md index 89071c5e23..3cfb85dd77 100644 --- a/packages/gittensory-mcp/README.md +++ b/packages/gittensory-mcp/README.md @@ -1,8 +1,8 @@ # @jsonbored/gittensory-mcp -Local stdio MCP wrapper for Gittensory contributor intelligence. +Local stdio MCP wrapper for the Gittensory base-agent layer. -It inspects local git metadata and calls the Gittensory API for branch preflight, score blockers, reward/risk reasoning, contributor decision packs, and public-safe PR packets. It does not upload source contents in v1. +It inspects local git metadata and calls the Gittensory API for branch preflight, score blockers, reward/risk reasoning, contributor decision packs, deterministic next-action planning, and public-safe PR packets. It does not upload source contents in v1. ## Status @@ -38,6 +38,10 @@ gittensory-mcp init-client --print claude gittensory-mcp init-client --print cursor gittensory-mcp analyze-branch --login jsonbored --json gittensory-mcp preflight --login jsonbored --json +gittensory-mcp agent plan --login jsonbored --json +gittensory-mcp agent packet --login jsonbored --json +gittensory-mcp agent status --json +gittensory-mcp agent explain --json gittensory-mcp --stdio ``` @@ -62,6 +66,23 @@ gittensory-mcp login --github-token "$(gh auth token)" The wrapper stores a Gittensory session token, not a GitHub token. +## Base-Agent Mode + +The agent commands are copilot-only. They rank, explain, preflight, and draft public-safe packets, but they do not edit code, open PRs, post comments, close, merge, or label from the local wrapper. + +```sh +gittensory-mcp agent plan --login jsonbored --repo we-promise/sure --json +gittensory-mcp agent packet --login jsonbored --repo we-promise/sure --base origin/main --json +``` + +The same capabilities are exposed to MCP clients as: + +- `gittensory_agent_plan_next_work` +- `gittensory_agent_start_run` +- `gittensory_agent_get_run` +- `gittensory_agent_explain_next_action` +- `gittensory_agent_prepare_pr_packet` + ## Environment - `GITTENSORY_API_URL` diff --git a/packages/gittensory-mcp/bin/gittensory-mcp.js b/packages/gittensory-mcp/bin/gittensory-mcp.js index 819d3a0af8..1f7c4ea559 100755 --- a/packages/gittensory-mcp/bin/gittensory-mcp.js +++ b/packages/gittensory-mcp/bin/gittensory-mcp.js @@ -9,7 +9,7 @@ import { buildBranchAnalysisPayload, collectLocalDiff, collectLocalBranchMetadat const defaultApiUrl = "https://gittensory-api.zeronode.workers.dev"; const packageName = "@jsonbored/gittensory-mcp"; -const packageVersion = "0.1.4"; +const packageVersion = "0.2.0"; const changelogPath = new URL("../CHANGELOG.md", import.meta.url); const configPath = process.env.GITTENSORY_CONFIG_PATH ?? @@ -116,6 +116,24 @@ const currentBranchVariantsShape = { variants: z.array(z.object(currentBranchShape)).min(1).max(10), }; +const agentPlanShape = { + login: z.string().min(1), + objective: z.string().optional(), + repoFullName: z.string().min(3).optional(), +}; + +const agentRunShape = { + objective: z.string().min(1), + actorLogin: z.string().min(1), + targetRepoFullName: z.string().min(3).optional(), + targetPullNumber: z.number().int().positive().optional(), + targetIssueNumber: z.number().int().positive().optional(), +}; + +const agentRunIdShape = { + runId: z.string().min(1), +}; + const cliArgs = process.argv.slice(2); if (cliArgs[0] && cliArgs[0] !== "--stdio") { await runCli(cliArgs); @@ -357,11 +375,76 @@ server.registerTool( }, ); +server.registerTool( + "gittensory_agent_plan_next_work", + { + description: "Run the deterministic Gittensory base-agent planner for a GitHub login.", + inputSchema: agentPlanShape, + }, + async (input) => toolResult(`Gittensory base-agent plan for ${input.login}.`, await apiPost("/v1/agent/plan-next-work", input)), +); + +server.registerTool( + "gittensory_agent_start_run", + { + description: "Create a queued copilot-only Gittensory base-agent run.", + inputSchema: agentRunShape, + }, + async (input) => + toolResult( + `Queued Gittensory base-agent run for ${input.actorLogin}.`, + await apiPost("/v1/agent/runs", { + objective: input.objective, + actorLogin: input.actorLogin, + surface: "mcp", + target: stripUndefined({ + repoFullName: input.targetRepoFullName, + pullNumber: input.targetPullNumber, + issueNumber: input.targetIssueNumber, + }), + }), + ), +); + +server.registerTool( + "gittensory_agent_get_run", + { + description: "Fetch a persisted Gittensory base-agent run.", + inputSchema: agentRunIdShape, + }, + async ({ runId }) => toolResult(`Gittensory base-agent run ${runId}.`, await apiGet(`/v1/agent/runs/${encodeURIComponent(runId)}`)), +); + +server.registerTool( + "gittensory_agent_explain_next_action", + { + description: "Explain the next deterministic action and blocker context for a GitHub login.", + inputSchema: agentPlanShape, + }, + async (input) => { + const result = await apiPost("/v1/agent/explain-blockers", input); + return toolResult(`Gittensory base-agent next-action explanation for ${input.login}.`, { + ...result, + topAction: result.actions?.[0] ?? null, + }); + }, +); + +server.registerTool( + "gittensory_agent_prepare_pr_packet", + { + description: "Prepare a public-safe PR packet from current branch metadata. Sends metadata only.", + inputSchema: currentBranchShape, + }, + async (input) => toolResult("Gittensory base-agent public-safe PR packet.", await agentPreparePrPacket(input)), +); + await server.connect(new StdioServerTransport()); async function runCli(args) { const command = args[0]; if (command === "--help" || command === "help") return printHelp(); + if (command === "agent") return runAgentCli(args.slice(1)); const options = parseOptions(args.slice(1)); if (command === "login") return login(options); if (command === "logout") return logout(options); @@ -410,6 +493,68 @@ async function runCli(args) { process.stdout.write(`Source upload: disabled\n`); } +async function runAgentCli(args) { + const subcommand = args[0] ?? "help"; + if (subcommand === "--help" || subcommand === "help") return printAgentHelp(); + const options = parseOptions(args.slice(1)); + if (subcommand === "plan") { + const login = options.login ?? process.env.GITTENSORY_LOGIN ?? process.env.GITHUB_LOGIN; + if (!login) throw new Error("Pass --login or set GITTENSORY_LOGIN."); + const payload = await apiPost("/v1/agent/plan-next-work", stripUndefined({ login, repoFullName: options.repo, objective: options.objective, surface: "mcp" })); + return outputAgentPayload(payload, options, `Gittensory agent plan: ${payload.summary ?? payload.run?.status ?? "ready"}`); + } + if (subcommand === "status") { + const runId = args[1] && !args[1].startsWith("--") ? args[1] : options.runId; + if (!runId) throw new Error("Usage: gittensory-mcp agent status "); + const payload = await apiGet(`/v1/agent/runs/${encodeURIComponent(runId)}`); + return outputAgentPayload(payload, options, `Gittensory agent run ${runId}: ${payload.run?.status ?? "unknown"}`); + } + if (subcommand === "explain") { + const runId = args[1] && !args[1].startsWith("--") ? args[1] : options.runId; + if (!runId) throw new Error("Usage: gittensory-mcp agent explain "); + const payload = await apiGet(`/v1/agent/runs/${encodeURIComponent(runId)}`); + const topAction = payload.actions?.[0] ?? null; + return outputAgentPayload({ ...payload, topAction }, options, topAction ? `Top action: ${topAction.recommendation}` : "No top action is available yet."); + } + if (subcommand === "packet") { + const login = options.login ?? process.env.GITTENSORY_LOGIN ?? process.env.GITHUB_LOGIN; + if (!login) throw new Error("Pass --login or set GITTENSORY_LOGIN."); + const payload = await agentPreparePrPacket({ + login, + cwd: options.cwd, + repoFullName: options.repo, + baseRef: options.base, + title: options.title, + body: options.body, + labels: options.label, + linkedIssues: options.issue?.map((value) => Number(value)).filter((value) => Number.isInteger(value) && value > 0), + pendingMergedPrCount: optionalInteger(options.pendingMergedPrs), + pendingClosedPrCount: optionalInteger(options.pendingClosedPrs), + approvedPrCount: optionalInteger(options.approvedPrs), + expectedOpenPrCountAfterMerge: optionalInteger(options.expectedOpenPrs), + projectedCredibility: optionalNumber(options.projectedCredibility), + scenarioNotes: options.scenarioNote, + validation: validationFromOptions(options), + scorePreviewCommand: options.scorePreviewCommand, + }); + return outputAgentPayload(payload, options, "Gittensory public-safe PR packet prepared."); + } + throw new Error(`Unknown agent command: ${subcommand}`); +} + +function outputAgentPayload(payload, options, summary) { + if (options.json) { + process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`); + return; + } + process.stdout.write(`${summary}\n`); + const actions = payload.actions ?? []; + for (const action of actions.slice(0, 3)) { + process.stdout.write(`- ${action.actionType}: ${action.recommendation}\n`); + if (action.rerunWhen) process.stdout.write(` rerun: ${action.rerunWhen}\n`); + } +} + function printHelp() { process.stdout.write(`Usage: gittensory-mcp --stdio @@ -422,6 +567,10 @@ function printHelp() { gittensory-mcp init-client --print codex|claude|cursor [--json] gittensory-mcp analyze-branch --login [--repo owner/repo] [--base origin/main] [--pending-merged-prs 3] [--expected-open-prs 0] [--projected-credibility 0.8] [--scenario-note "..."] [--validation "passed|npm test|summary"] [--json] gittensory-mcp preflight --login [--repo owner/repo] [--base origin/main] [--pending-merged-prs 3] [--expected-open-prs 0] [--projected-credibility 0.8] [--validation "passed|npm test|summary"] [--json] + gittensory-mcp agent plan --login [--repo owner/repo] [--json] + gittensory-mcp agent status [--json] + gittensory-mcp agent explain [--json] + gittensory-mcp agent packet --login [--repo owner/repo] [--base origin/main] [--json] Environment: GITTENSORY_API_URL @@ -434,6 +583,18 @@ Environment: `); } +function printAgentHelp() { + process.stdout.write(`Usage: + gittensory-mcp agent plan --login [--repo owner/repo] [--objective "..."] [--json] + gittensory-mcp agent status [--json] + gittensory-mcp agent explain [--json] + gittensory-mcp agent packet --login [--repo owner/repo] [--base origin/main] [--validation "passed|command|summary"] [--json] + +The agent is copilot-only: it ranks, explains, and drafts public-safe packets. It does not edit code, open PRs, or post comments from the local MCP wrapper. +Source upload remains disabled. +`); +} + function parseOptions(args) { const options = {}; const repeatable = new Set(["label", "issue", "validation", "validationCommand", "validationStatus", "validationSummary", "scenarioNote"]); @@ -812,6 +973,12 @@ async function analyzeCurrentBranch(input) { }; } +async function agentPreparePrPacket(input) { + const payload = buildBranchAnalysisPayload(input); + const { localScorerStatus: _localScorerStatus, ...body } = payload; + return apiPost("/v1/agent/prepare-pr-packet", body); +} + async function previewLocalScore(input) { const cwd = input.cwd ?? process.cwd(); const diff = collectLocalDiff(cwd, input.baseRef); diff --git a/packages/gittensory-mcp/package.json b/packages/gittensory-mcp/package.json index 07ad914ec0..35f35b52d6 100644 --- a/packages/gittensory-mcp/package.json +++ b/packages/gittensory-mcp/package.json @@ -1,9 +1,9 @@ { "name": "@jsonbored/gittensory-mcp", - "version": "0.1.4", + "version": "0.2.0", "license": "AGPL-3.0-only", "type": "module", - "description": "Local stdio MCP wrapper for Gittensory contributor intelligence.", + "description": "Local stdio MCP wrapper for the Gittensory Gittensor base-agent.", "repository": { "type": "git", "url": "git+https://github.com/JSONbored/gittensory.git", @@ -19,7 +19,8 @@ "model-context-protocol", "github", "contributors", - "preflight" + "preflight", + "agent" ], "publishConfig": { "access": "public" diff --git a/site/guide/github-app-setup.md b/site/guide/github-app-setup.md index 173421d29a..9b967643de 100644 --- a/site/guide/github-app-setup.md +++ b/site/guide/github-app-setup.md @@ -27,6 +27,7 @@ Subscribe to: - Pull request - Issues +- Issue comment - Repository If GitHub shows `Installation target`, select it. Some installation-related events are hidden in parts of GitHub’s UI; Gittensory health should not fail on event names that are not selectable. @@ -42,6 +43,19 @@ Gittensory inspects PR webhooks quietly first. It publishes a public surface onl Check runs default to off. If enabled later, they stay minimal and do not include private reviewability, scoring, wallet, hotkey, or reward/risk context. +## Mention Commands + +`Issue comment` events let maintainers and authorized PR authors invoke Gittensory explicitly: + +- `@gittensory help` +- `@gittensory preflight` +- `@gittensory blockers` +- `@gittensory duplicate-check` +- `@gittensory miner-context` +- `@gittensory next-action` + +Replies are public-safe. Private scoreability, reward/risk, wallet, hotkey, raw trust, and maintainer-only reviewability context stay in authenticated MCP/API surfaces. + ::: warning Not an official Gittensor frontend The app adds maintainer workflow context around Gittensor participation. It does not replace the official Gittensor site or dashboard. ::: @@ -53,3 +67,4 @@ The app adds maintainer workflow context around Gittensor participation. It does 3. Refresh installation health. 4. Confirm comments and labels are enabled only when `Issues: write` is available. 5. Confirm checks are off unless the repo explicitly opted into minimal check runs. +6. Confirm `Issue comment` is subscribed if mention commands should work. diff --git a/site/guide/install.md b/site/guide/install.md index 9898f0aa81..f5a6a7da9a 100644 --- a/site/guide/install.md +++ b/site/guide/install.md @@ -39,6 +39,7 @@ If a client cannot find `gittensory-mcp`, use an absolute command path in that c From any GitHub repository: ```sh +gittensory-mcp agent plan --login YOUR_GITHUB_LOGIN --json gittensory-mcp analyze-branch --login YOUR_GITHUB_LOGIN --json gittensory-mcp preflight --login YOUR_GITHUB_LOGIN --json ``` diff --git a/site/guide/maintainers.md b/site/guide/maintainers.md index 7690171f74..71633c990e 100644 --- a/site/guide/maintainers.md +++ b/site/guide/maintainers.md @@ -26,6 +26,19 @@ Private API/MCP surfaces can explain: The GitHub App stays low-noise. It does not close, merge, rewrite, or publicly judge contributor work. +## Explicit Commands + +Installed repos can use public-safe mention commands when a maintainer or authorized PR author wants Gittensory context without enabling noisy checks: + +- `@gittensory help` +- `@gittensory preflight` +- `@gittensory blockers` +- `@gittensory duplicate-check` +- `@gittensory miner-context` +- `@gittensory next-action` + +Maintainers can invoke commands on installed repos. PR authors can invoke commands on their own PRs only when official Gittensor miner detection succeeds. If miner detection is unavailable, Gittensory fails closed and avoids public output. + ## Intake Health Repo owners can use Gittensory signals to inspect: diff --git a/site/guide/mcp.md b/site/guide/mcp.md index da6d7930e5..20fa0e0ab4 100644 --- a/site/guide/mcp.md +++ b/site/guide/mcp.md @@ -1,6 +1,6 @@ # MCP Clients -The MCP package is the contributor-facing surface. Your coding agent can ask Gittensory what the current branch means in Gittensor terms before you open or update a PR. +The MCP package is the contributor-facing surface and the first base-agent interface. Your coding agent can ask Gittensory what the current branch means in Gittensor terms, what to do next, and how to draft a public-safe packet before you open or update a PR. ## What Agents Can Ask @@ -9,6 +9,7 @@ The MCP package is the contributor-facing surface. Your coding agent can ask Git - whether local work looks stale, broad, duplicate-prone, or missing validation evidence - what to clean up before opening more PRs - what public-safe PR packet should be included for a maintainer +- which ranked next action has the best current scoreability/risk/reviewability tradeoff ::: warning Private score/reward-risk only Scoreability projections and reward/risk reasoning are private MCP/API output. Public GitHub comments stay sanitized. @@ -68,6 +69,23 @@ args = ["--stdio"] - `gittensory_rank_local_next_actions` - `gittensory_explain_local_blockers` - `gittensory_prepare_pr_packet` +- `gittensory_agent_plan_next_work` +- `gittensory_agent_start_run` +- `gittensory_agent_get_run` +- `gittensory_agent_explain_next_action` +- `gittensory_agent_prepare_pr_packet` + +## Base-Agent CLI + +```sh +gittensory-mcp agent plan --login jsonbored --json +gittensory-mcp agent plan --login jsonbored --repo we-promise/sure --json +gittensory-mcp agent packet --login jsonbored --repo we-promise/sure --json +gittensory-mcp agent status --json +gittensory-mcp agent explain --json +``` + +Agent mode is copilot-only. It ranks and explains actions; it does not edit code, open PRs, post comments, close, merge, or label from the local MCP wrapper. ## Runtime Rules diff --git a/site/guide/miners.md b/site/guide/miners.md index a8e79a2f46..306736511c 100644 --- a/site/guide/miners.md +++ b/site/guide/miners.md @@ -1,6 +1,22 @@ # For Miners -Gittensory helps miners choose work with evidence: lane fit, score blockers, queue pressure, local diff quality, and realistic scenario projections. +Gittensory helps miners choose work with evidence: lane fit, score blockers, queue pressure, local diff quality, realistic scenario projections, and deterministic base-agent next actions. + +## Agent Planning Flow + +Run this when you are deciding what to work on next: + +```sh +gittensory-mcp agent plan --login YOUR_GITHUB_LOGIN --json +``` + +For a specific repo: + +```sh +gittensory-mcp agent plan --login YOUR_GITHUB_LOGIN --repo owner/repo --json +``` + +The agent returns ranked actions with why they matter, what is blocked, how they affect scoreability, how they affect maintainer review load, and when to rerun. It is copilot-only: it does not edit code, open PRs, post comments, close, merge, or label. ## Branch Analysis Flow @@ -47,11 +63,20 @@ gittensory-mcp preflight --login YOUR_GITHUB_LOGIN --json Use preflight when you need a quick answer on linked issues, tests, duplicate risk, lane fit, and maintainer review friction. +## PR Packet + +```sh +gittensory-mcp agent packet --login YOUR_GITHUB_LOGIN --repo owner/repo --json +``` + +This prepares a public-safe maintainer packet from metadata only. Private scoreability and reward/risk context stays out of the packet. + ## When To Use Gittensory - before opening a PR - after PR approvals but before rerunning score projections - when deciding whether to clean up existing PRs first - when choosing between direct PR work and issue-discovery flow +- when you want an agent to rank the next Gittensor action before you spend time coding Gittensory does not promise payouts. It explains scoreability, risk, and what actions make the work more likely to be reviewable and scoreable. diff --git a/site/index.md b/site/index.md index 2277c1ba84..7fb9a892db 100644 --- a/site/index.md +++ b/site/index.md @@ -3,8 +3,8 @@ layout: home hero: name: Gittensory - text: Know the Gittensor lane before you submit. - tagline: MCP branch analysis and GitHub App context for score blockers, queue pressure, lane fit, and maintainer review load. Not a Gittensor frontend. + text: The base agent layer for Gittensor OSS work. + tagline: MCP branch analysis, deterministic next-action planning, and GitHub App context for Gittensor miners and maintainers. Not a Gittensor frontend. image: src: /images/gittensor-home-signal.webp alt: Gittensor homepage showing live miner, reward, and repository activity. @@ -22,10 +22,10 @@ hero: features: - title: MCP branch preflight details: Local metadata-only checks for lane fit, stale base risk, validation evidence, and score blockers. + - title: Base-agent planning + details: Copilot-only runs rank next actions, explain blockers, and prepare public-safe PR packets. - title: Quiet maintainer surface details: Confirmed-miner comments and labels without public check-run noise or private signal leakage. - - title: Private scoreability context - details: Current, ungated, and scenario-based scoreability reasoning stays in authenticated MCP/API output. --- @@ -33,12 +33,12 @@ features:

Start now

-

Analyze the branch before it becomes review load.

-

One local command gives your agent lane fit, queue pressure, score blockers, and a public-safe PR packet.

+

Let the agent rank the next move before the work becomes review load.

+

One local command gives your agent lane fit, queue pressure, score blockers, next actions, and a public-safe PR packet.

- Metadata-only branch analysis - gittensory-mcp analyze-branch --login YOUR_GITHUB_LOGIN --json + Metadata-only base-agent planning + gittensory-mcp agent plan --login YOUR_GITHUB_LOGIN --json
@@ -48,7 +48,7 @@ Prefer a full setup first? Start with [install](/guide/install), run `gittensory - [Install the MCP package](/guide/install): get the CLI, authenticate with GitHub Device Flow, and verify local setup with `doctor`. - [Connect an MCP client](/guide/mcp): print Codex, Claude Desktop, or Cursor config without mutating local files. -- [Check miner work](/guide/miners): run branch analysis, scenario projections, and preflight before opening a PR. +- [Check miner work](/guide/miners): run agent planning, branch analysis, scenario projections, and preflight before opening a PR. - [Set up the GitHub App](/guide/github-app-setup): give maintainers confirmed-miner comments and labels without noisy checks. - [Review maintainer behavior](/guide/maintainers): understand quiet-by-default PR visibility and public-safe output boundaries. - [Read the API contract](/reference/api): inspect the modern private API for decision packs, branch analysis, reviewability, and readiness. @@ -59,7 +59,7 @@ Prefer a full setup first? Start with [install](/guide/install), run `gittensory | --- | --- | | Gittensor miners | Scoreability blockers, lane fit, queue pressure, local diff quality, and cleanup-first guidance. | | Maintainers | Confirmed-miner comments, configured labels, and private reviewability packets. | -| Coding agents | Structured MCP tools for repo context, current branch preflight, next actions, and PR packet drafting. | +| Coding agents | Structured MCP tools for repo context, base-agent plans, current branch preflight, next actions, and PR packet drafting. | | Repo owners | Config quality, label readiness, maintainer-lane handling, and contribution intake health. | ## Gittensor Context diff --git a/site/reference/api.md b/site/reference/api.md index 8a4d5abe23..4ca8a549e8 100644 --- a/site/reference/api.md +++ b/site/reference/api.md @@ -38,6 +38,17 @@ curl "$GITTENSORY_API_URL/openapi.json" \ | `POST /v1/preflight/pr` | Planned PR metadata preflight. | | `POST /v1/preflight/local-diff` | Local-diff metadata preflight. | +## Agent APIs + +| Endpoint | Purpose | +| --- | --- | +| `POST /v1/agent/runs` | Queue a copilot-only agent run and persist run/action context. | +| `GET /v1/agent/runs/:id` | Fetch a persisted agent run, ranked actions, and context snapshots. | +| `POST /v1/agent/plan-next-work` | Return deterministic next-action planning for a GitHub login. | +| `POST /v1/agent/preflight-branch` | Run base-agent preflight against local branch metadata. | +| `POST /v1/agent/prepare-pr-packet` | Prepare a public-safe PR packet from branch metadata. | +| `POST /v1/agent/explain-blockers` | Explain scoreability, queue, role, duplicate, and reviewability blockers privately. | + ## Ops APIs | Endpoint | Purpose | diff --git a/src/api/routes.ts b/src/api/routes.ts index bd04e57995..c2a5f9cadf 100644 --- a/src/api/routes.ts +++ b/src/api/routes.ts @@ -64,6 +64,14 @@ import { generateSignalSnapshots } from "../queue/processors"; import { getLatestRegistrySnapshot, listLatestRegistrySnapshots, refreshRegistry } from "../registry/sync"; import { getOrCreateScoringModelSnapshot, refreshScoringModelSnapshot } from "../scoring/model"; import { buildScorePreview, makeScorePreviewRecord } from "../scoring/preview"; +import { + explainBlockersWithAgent, + getAgentRunBundle, + planNextWork, + preparePrPacketWithAgent, + preflightBranchWithAgent, + startAgentRun, +} from "../services/agent-orchestrator"; import { buildAndPersistContributorDecisionPack, loadContributorDecisionPack, @@ -202,6 +210,35 @@ const scorePreviewSchema = z.object({ scenarioNotes: z.array(z.string()).max(20).optional(), }); +const agentSurfaceSchema = z.enum(["api", "mcp", "github_comment"]).default("api"); + +const agentRunSchema = z + .object({ + objective: z.string().min(1).max(500), + actorLogin: z.string().min(1), + surface: agentSurfaceSchema.optional(), + target: z + .object({ + repoFullName: z.string().min(3).optional(), + pullNumber: z.number().int().positive().optional(), + issueNumber: z.number().int().positive().optional(), + }) + .strict() + .optional(), + }) + .strict(); + +const agentPlanSchema = z + .object({ + login: z.string().min(1), + objective: z.string().min(1).max(500).optional(), + repoFullName: z.string().min(3).optional(), + surface: agentSurfaceSchema.optional(), + }) + .strict(); + +const agentExplainBlockersSchema = z.union([localBranchAnalysisSchema, agentPlanSchema]); + const repositorySettingsSchema = z.object({ commentMode: z.enum(["off", "detected_contributors_only", "all_prs"]).default("detected_contributors_only"), publicSignalLevel: z.enum(["minimal", "standard"]).default("standard"), @@ -684,6 +721,52 @@ export function createApp() { return c.json(response); }); + app.post("/v1/agent/runs", async (c) => { + const body = await c.req.json().catch(() => null); + const parsed = agentRunSchema.safeParse(body); + if (!parsed.success) return c.json({ error: "invalid_agent_run_request", issues: parsed.error.issues }, 400); + const bundle = await startAgentRun(c.env, parsed.data); + return c.json(bundle, 202); + }); + + app.get("/v1/agent/runs/:id", async (c) => { + const bundle = await getAgentRunBundle(c.env, c.req.param("id")); + if (!bundle) return c.json({ error: "agent_run_not_found" }, 404); + return c.json(bundle); + }); + + app.post("/v1/agent/plan-next-work", async (c) => { + const body = await c.req.json().catch(() => null); + const parsed = agentPlanSchema.safeParse(body); + if (!parsed.success) return c.json({ error: "invalid_agent_plan_request", issues: parsed.error.issues }, 400); + const bundle = await planNextWork(c.env, parsed.data); + return c.json(bundle, bundle.run.status === "needs_snapshot_refresh" ? 202 : 200); + }); + + app.post("/v1/agent/preflight-branch", async (c) => { + const body = await c.req.json().catch(() => null); + const parsed = localBranchAnalysisSchema.safeParse(body); + if (!parsed.success) return c.json({ error: "invalid_agent_preflight_branch_request", issues: parsed.error.issues }, 400); + const bundle = await preflightBranchWithAgent(c.env, parsed.data); + return c.json(bundle); + }); + + app.post("/v1/agent/prepare-pr-packet", async (c) => { + const body = await c.req.json().catch(() => null); + const parsed = localBranchAnalysisSchema.safeParse(body); + if (!parsed.success) return c.json({ error: "invalid_agent_prepare_pr_packet_request", issues: parsed.error.issues }, 400); + const bundle = await preparePrPacketWithAgent(c.env, parsed.data); + return c.json(bundle); + }); + + app.post("/v1/agent/explain-blockers", async (c) => { + const body = await c.req.json().catch(() => null); + const parsed = agentExplainBlockersSchema.safeParse(body); + if (!parsed.success) return c.json({ error: "invalid_agent_explain_blockers_request", issues: parsed.error.issues }, 400); + const bundle = await explainBlockersWithAgent(c.env, parsed.data); + return c.json(bundle, bundle.run.status === "needs_snapshot_refresh" ? 202 : 200); + }); + app.get("/v1/bounties", async (c) => c.json(await listBounties(c.env))); app.get("/v1/bounties/:id/advisory", async (c) => { diff --git a/src/auth/rate-limit.ts b/src/auth/rate-limit.ts index 8d43739e9c..7bad0a92a4 100644 --- a/src/auth/rate-limit.ts +++ b/src/auth/rate-limit.ts @@ -98,6 +98,7 @@ export function routeClassForPath(path: string): RateLimitClass { if (path.startsWith("/v1/auth/")) return "strict"; if ( path.includes("/branch-analysis") || + path.includes("/v1/agent/") || path.includes("/scoring/preview") || path.includes("/decision-pack") || path.includes("/internal/jobs/generate-signal-snapshots") || diff --git a/src/db/repositories.ts b/src/db/repositories.ts index 14a9529199..dd5195274b 100644 --- a/src/db/repositories.ts +++ b/src/db/repositories.ts @@ -2,6 +2,9 @@ import { and, desc, eq, not, sql } from "drizzle-orm"; import { getDb } from "./client"; import { advisories, + agentActions, + agentContextSnapshots, + agentRuns, auditEvents, authSessions, bounties, @@ -38,6 +41,15 @@ import { } from "./schema"; import type { Advisory, + AgentActionRecord, + AgentActionStatus, + AgentActionType, + AgentContextSnapshotRecord, + AgentMode, + AgentRunRecord, + AgentRunStatus, + AgentSafetyClass, + AgentSurface, AuditEventRecord, AuthSessionRecord, BountyLifecycleEventRecord, @@ -1354,6 +1366,101 @@ export async function listSignalSnapshots(env: Env, signalType: string, targetKe return rows.map(toSignalSnapshotRecord); } +export async function createAgentRun(env: Env, run: AgentRunRecord): Promise { + const db = getDb(env.DB); + await db.insert(agentRuns).values({ + id: run.id, + objective: run.objective, + actorLogin: run.actorLogin, + surface: run.surface, + mode: run.mode, + status: run.status, + dataQualityStatus: run.dataQualityStatus, + errorSummary: run.errorSummary ?? null, + payloadJson: jsonString(run.payload), + createdAt: run.createdAt ?? nowIso(), + updatedAt: run.updatedAt ?? nowIso(), + }); +} + +export async function updateAgentRun( + env: Env, + runId: string, + patch: Partial>, +): Promise { + const db = getDb(env.DB); + await db + .update(agentRuns) + .set({ + ...(patch.status ? { status: patch.status } : {}), + ...(patch.dataQualityStatus ? { dataQualityStatus: patch.dataQualityStatus } : {}), + ...(patch.errorSummary !== undefined ? { errorSummary: patch.errorSummary } : {}), + ...(patch.payload ? { payloadJson: jsonString(patch.payload) } : {}), + updatedAt: nowIso(), + }) + .where(eq(agentRuns.id, runId)); +} + +export async function getAgentRun(env: Env, runId: string): Promise { + const db = getDb(env.DB); + const [row] = await db.select().from(agentRuns).where(eq(agentRuns.id, runId)).limit(1); + return row ? toAgentRunRecord(row) : null; +} + +export async function listAgentActions(env: Env, runId: string): Promise { + const db = getDb(env.DB); + const rows = await db.select().from(agentActions).where(eq(agentActions.runId, runId)).orderBy(agentActions.createdAt).limit(100); + return rows.map(toAgentActionRecord); +} + +export async function replaceAgentActions(env: Env, runId: string, actions: AgentActionRecord[]): Promise { + const db = getDb(env.DB); + await db.delete(agentActions).where(eq(agentActions.runId, runId)); + for (const action of actions) { + await db.insert(agentActions).values({ + id: action.id, + runId, + actionType: action.actionType, + targetRepoFullName: action.targetRepoFullName ?? null, + targetPullNumber: action.targetPullNumber ?? null, + targetIssueNumber: action.targetIssueNumber ?? null, + status: action.status, + recommendation: action.recommendation, + whyJson: jsonString(action.why), + scoreabilityImpact: action.scoreabilityImpact ?? null, + riskImpact: action.riskImpact ?? null, + maintainerImpact: action.maintainerImpact ?? null, + blockedByJson: jsonString(action.blockedBy), + rerunWhen: action.rerunWhen ?? null, + publicSafeSummary: action.publicSafeSummary, + approvalRequired: action.approvalRequired, + safetyClass: action.safetyClass, + payloadJson: jsonString(action.payload), + createdAt: action.createdAt ?? nowIso(), + }); + } +} + +export async function persistAgentContextSnapshot(env: Env, snapshot: AgentContextSnapshotRecord): Promise { + const db = getDb(env.DB); + await db.insert(agentContextSnapshots).values({ + id: snapshot.id, + runId: snapshot.runId, + decisionPackVersion: snapshot.decisionPackVersion ?? null, + repoSignalSnapshotIdsJson: jsonString(snapshot.repoSignalSnapshotIds), + scoringModelId: snapshot.scoringModelId ?? null, + freshnessWarningsJson: jsonString(snapshot.freshnessWarnings), + payloadJson: jsonString(snapshot.payload), + createdAt: snapshot.createdAt ?? nowIso(), + }); +} + +export async function listAgentContextSnapshots(env: Env, runId: string): Promise { + const db = getDb(env.DB); + const rows = await db.select().from(agentContextSnapshots).where(eq(agentContextSnapshots.runId, runId)).orderBy(desc(agentContextSnapshots.createdAt)).limit(50); + return rows.map(toAgentContextSnapshotRecord); +} + export async function upsertInstallationHealth(env: Env, health: InstallationHealthRecord): Promise { const db = getDb(env.DB); await db @@ -1861,6 +1968,59 @@ function toSignalSnapshotRecord(row: typeof signalSnapshots.$inferSelect): Signa }; } +function toAgentRunRecord(row: typeof agentRuns.$inferSelect): AgentRunRecord { + return { + id: row.id, + objective: row.objective, + actorLogin: row.actorLogin, + surface: parseAgentSurface(row.surface), + mode: parseAgentMode(row.mode), + status: parseAgentRunStatus(row.status), + dataQualityStatus: parseDataQualityStatus(row.dataQualityStatus), + errorSummary: row.errorSummary, + payload: parseJson>(row.payloadJson, {}), + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }; +} + +function toAgentActionRecord(row: typeof agentActions.$inferSelect): AgentActionRecord { + return { + id: row.id, + runId: row.runId, + actionType: parseAgentActionType(row.actionType), + targetRepoFullName: row.targetRepoFullName, + targetPullNumber: row.targetPullNumber, + targetIssueNumber: row.targetIssueNumber, + status: parseAgentActionStatus(row.status), + recommendation: row.recommendation, + why: parseJson(row.whyJson, []), + scoreabilityImpact: row.scoreabilityImpact, + riskImpact: row.riskImpact, + maintainerImpact: row.maintainerImpact, + blockedBy: parseJson(row.blockedByJson, []), + rerunWhen: row.rerunWhen, + publicSafeSummary: row.publicSafeSummary, + approvalRequired: row.approvalRequired, + safetyClass: parseAgentSafetyClass(row.safetyClass), + payload: parseJson>(row.payloadJson, {}), + createdAt: row.createdAt, + }; +} + +function toAgentContextSnapshotRecord(row: typeof agentContextSnapshots.$inferSelect): AgentContextSnapshotRecord { + return { + id: row.id, + runId: row.runId, + decisionPackVersion: row.decisionPackVersion, + repoSignalSnapshotIds: parseJson(row.repoSignalSnapshotIdsJson, []), + scoringModelId: row.scoringModelId, + freshnessWarnings: parseJson(row.freshnessWarningsJson, []), + payload: parseJson>(row.payloadJson, {}), + createdAt: row.createdAt, + }; +} + function toInstallationHealthRecord(row: typeof installationHealth.$inferSelect): InstallationHealthRecord { return { installationId: row.installationId, @@ -1893,6 +2053,50 @@ function toAuthSessionRecord(row: typeof authSessions.$inferSelect): AuthSession }; } +function parseAgentSurface(value: string): AgentSurface { + if (value === "mcp" || value === "github_comment") return value; + return "api"; +} + +function parseAgentMode(_value: string): AgentMode { + return "copilot"; +} + +function parseAgentRunStatus(value: string): AgentRunStatus { + if (value === "running" || value === "completed" || value === "failed" || value === "needs_snapshot_refresh") return value; + return "queued"; +} + +function parseDataQualityStatus(value: string): AgentRunRecord["dataQualityStatus"] { + if (value === "complete" || value === "degraded" || value === "blocked") return value; + return "unknown"; +} + +function parseAgentActionType(value: string): AgentActionType { + if ( + value === "cleanup_existing_prs" || + value === "preflight_branch" || + value === "explain_score_blockers" || + value === "prepare_pr_packet" || + value === "check_duplicate_risk" || + value === "monitor_existing_pr" || + value === "explain_repo_fit" + ) { + return value; + } + return "choose_next_work"; +} + +function parseAgentActionStatus(value: string): AgentActionStatus { + if (value === "ready" || value === "blocked" || value === "watch" || value === "needs_input") return value; + return "recommended"; +} + +function parseAgentSafetyClass(value: string): AgentSafetyClass { + if (value === "public_safe" || value === "approval_required") return value; + return "private"; +} + function parseCommentMode(value: string): RepositorySettings["commentMode"] { if (value === "detected_contributors_only" || value === "all_prs") return value; return "off"; diff --git a/src/db/schema.ts b/src/db/schema.ts index bc07b84a49..a547907d53 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -405,6 +405,74 @@ export const signalSnapshots = sqliteTable("signal_snapshots", { generatedAt: text("generated_at").notNull().default("CURRENT_TIMESTAMP"), }); +export const agentRuns = sqliteTable( + "agent_runs", + { + id: text("id").primaryKey(), + objective: text("objective").notNull(), + actorLogin: text("actor_login").notNull(), + surface: text("surface").notNull(), + mode: text("mode").notNull().default("copilot"), + status: text("status").notNull().default("queued"), + dataQualityStatus: text("data_quality_status").notNull().default("unknown"), + errorSummary: text("error_summary"), + payloadJson: text("payload_json").notNull().default("{}"), + createdAt: text("created_at").notNull().default("CURRENT_TIMESTAMP"), + updatedAt: text("updated_at").notNull().default("CURRENT_TIMESTAMP"), + }, + (table) => ({ + actorUpdated: index("agent_runs_actor_updated_idx").on(table.actorLogin, table.updatedAt), + statusUpdated: index("agent_runs_status_updated_idx").on(table.status, table.updatedAt), + surfaceUpdated: index("agent_runs_surface_updated_idx").on(table.surface, table.updatedAt), + }), +); + +export const agentActions = sqliteTable( + "agent_actions", + { + id: text("id").primaryKey(), + runId: text("run_id").notNull(), + actionType: text("action_type").notNull(), + targetRepoFullName: text("target_repo_full_name"), + targetPullNumber: integer("target_pull_number"), + targetIssueNumber: integer("target_issue_number"), + status: text("status").notNull(), + recommendation: text("recommendation").notNull(), + whyJson: text("why_json").notNull().default("[]"), + scoreabilityImpact: text("scoreability_impact"), + riskImpact: text("risk_impact"), + maintainerImpact: text("maintainer_impact"), + blockedByJson: text("blocked_by_json").notNull().default("[]"), + rerunWhen: text("rerun_when"), + publicSafeSummary: text("public_safe_summary").notNull(), + approvalRequired: integer("approval_required", { mode: "boolean" }).notNull().default(true), + safetyClass: text("safety_class").notNull(), + payloadJson: text("payload_json").notNull().default("{}"), + createdAt: text("created_at").notNull().default("CURRENT_TIMESTAMP"), + }, + (table) => ({ + runAction: index("agent_actions_run_action_idx").on(table.runId, table.actionType), + targetRepo: index("agent_actions_target_repo_idx").on(table.targetRepoFullName, table.createdAt), + }), +); + +export const agentContextSnapshots = sqliteTable( + "agent_context_snapshots", + { + id: text("id").primaryKey(), + runId: text("run_id").notNull(), + decisionPackVersion: text("decision_pack_version"), + repoSignalSnapshotIdsJson: text("repo_signal_snapshot_ids_json").notNull().default("[]"), + scoringModelId: text("scoring_model_id"), + freshnessWarningsJson: text("freshness_warnings_json").notNull().default("[]"), + payloadJson: text("payload_json").notNull().default("{}"), + createdAt: text("created_at").notNull().default("CURRENT_TIMESTAMP"), + }, + (table) => ({ + runCreated: index("agent_context_snapshots_run_created_idx").on(table.runId, table.createdAt), + }), +); + export const installationHealth = sqliteTable("installation_health", { installationId: integer("installation_id").primaryKey(), accountLogin: text("account_login").notNull(), diff --git a/src/github/backfill.ts b/src/github/backfill.ts index 0e4f809192..bd5bfe46de 100644 --- a/src/github/backfill.ts +++ b/src/github/backfill.ts @@ -585,7 +585,7 @@ export const OPTIONAL_CHECK_RUN_PERMISSION: Record = { checks: "write", }; -export const REQUIRED_INSTALLATION_EVENTS = ["issues", "pull_request", "repository"] as const; +export const REQUIRED_INSTALLATION_EVENTS = ["issues", "issue_comment", "pull_request", "repository"] as const; export const OPTIONAL_VISIBLE_INSTALLATION_EVENTS = ["installation_target"] as const; export function enrichInstallationHealth(health: InstallationHealthRecord) { diff --git a/src/github/commands.ts b/src/github/commands.ts new file mode 100644 index 0000000000..9d5e4c8e2e --- /dev/null +++ b/src/github/commands.ts @@ -0,0 +1,128 @@ +import { AGENT_COMMAND_COMMENT_MARKER } from "./comments"; +import type { AgentRunBundle } from "../services/agent-orchestrator"; +import type { GittensorContributorSnapshot, OfficialGittensorMinerDetection } from "../gittensor/api"; +import type { GitHubIssuePayload, PullRequestRecord, RepositoryRecord } from "../types"; + +export type GittensoryMentionCommandName = "help" | "preflight" | "blockers" | "duplicate-check" | "miner-context" | "next-action"; + +export type GittensoryMentionCommand = { + name: GittensoryMentionCommandName; + raw: string; +}; + +const COMMANDS = new Set(["help", "preflight", "blockers", "duplicate-check", "miner-context", "next-action"]); +const MAINTAINER_ASSOCIATIONS = new Set(["OWNER", "MEMBER", "COLLABORATOR"]); + +export function parseGittensoryMentionCommand(body: string | null | undefined): GittensoryMentionCommand | null { + if (!body) return null; + const match = body.match(/(?:^|\s)@gittensory(?:\s+([a-z-]+))?/i); + if (!match) return null; + const requested = (match[1]?.toLowerCase() || "help") as GittensoryMentionCommandName; + const name = COMMANDS.has(requested) ? requested : "help"; + return { name, raw: match[0].trim() }; +} + +export function isMaintainerAssociation(association: string | null | undefined): boolean { + return Boolean(association && MAINTAINER_ASSOCIATIONS.has(association)); +} + +export function isAuthorizedCommandActor(args: { + commenterLogin?: string | null | undefined; + commenterAssociation?: string | null | undefined; + pullRequestAuthorLogin?: string | null | undefined; + officialAuthorDetection?: OfficialGittensorMinerDetection | undefined; +}): { authorized: boolean; reason: string; actorKind: "maintainer" | "author" | "none" } { + if (isMaintainerAssociation(args.commenterAssociation)) return { authorized: true, reason: "maintainer_invocation", actorKind: "maintainer" }; + if (!args.commenterLogin || !args.pullRequestAuthorLogin || args.commenterLogin.toLowerCase() !== args.pullRequestAuthorLogin.toLowerCase()) { + return { authorized: false, reason: "not_maintainer_or_pr_author", actorKind: "none" }; + } + if (!args.officialAuthorDetection || args.officialAuthorDetection.status === "unavailable") { + return { authorized: false, reason: "miner_detection_unavailable", actorKind: "author" }; + } + if (args.officialAuthorDetection.status !== "confirmed") { + return { authorized: false, reason: "pr_author_not_confirmed_miner", actorKind: "author" }; + } + return { authorized: true, reason: "confirmed_miner_pr_author", actorKind: "author" }; +} + +export function buildPublicAgentCommandComment(args: { + command: GittensoryMentionCommand; + repo: RepositoryRecord | null; + issue: GitHubIssuePayload; + pullRequest: PullRequestRecord | null; + actorKind: "maintainer" | "author"; + officialMiner?: GittensorContributorSnapshot | null | undefined; + bundle?: AgentRunBundle | null | undefined; +}): string { + const repoFullName = args.repo?.fullName ?? args.pullRequest?.repoFullName ?? "this repository"; + const title = "Gittensory agent context"; + const sections = + args.command.name === "help" + ? helpSections() + : args.command.name === "miner-context" + ? minerContextSections(args.officialMiner) + : actionSections(args.bundle); + const body = [ + AGENT_COMMAND_COMMENT_MARKER, + `### ${title}`, + "", + `Command: \`@gittensory ${args.command.name}\``, + `Scope: ${repoFullName}#${args.issue.number}`, + "", + ...sections, + "", + "_Advisory context only. This public comment intentionally excludes private ranking, wallet, payout, and reviewability internals._", + ].join("\n"); + return sanitizePublicComment(body); +} + +function helpSections(): string[] { + return [ + "**Commands**", + "", + "- `@gittensory help` shows this command list.", + "- `@gittensory preflight` summarizes public PR hygiene.", + "- `@gittensory blockers` explains public readiness blockers.", + "- `@gittensory duplicate-check` summarizes duplicate/WIP caution.", + "- `@gittensory miner-context` confirms public Gittensor miner context.", + "- `@gittensory next-action` gives a public-safe next step.", + ]; +} + +function minerContextSections(miner: GittensorContributorSnapshot | null | undefined): string[] { + if (!miner) { + return ["**Miner context**", "", "- Official miner context is unavailable for this public response."]; + } + return [ + "**Miner context**", + "", + `- GitHub user \`${miner.githubUsername}\` is confirmed by the official Gittensor API.`, + `- Registered-repo PRs observed by Gittensor: ${miner.totals.pullRequests}.`, + `- Merged registered-repo PRs observed by Gittensor: ${miner.totals.mergedPullRequests}.`, + "- Use MCP for private branch planning before adding more public review load.", + ]; +} + +function actionSections(bundle: AgentRunBundle | null | undefined): string[] { + const actions = bundle?.actions ?? []; + if (bundle?.run.status === "needs_snapshot_refresh") { + return ["**Next step**", "", "- Gittensory is refreshing the contributor decision snapshot. Try the command again shortly."]; + } + if (actions.length === 0) { + return ["**Next step**", "", "- No public-safe action is available from the current cached context."]; + } + const top = actions[0]!; + return [ + "**Recommended public-safe next step**", + "", + `- ${top.publicSafeSummary}`, + ...(top.blockedBy.length > 0 ? ["", "**Public readiness blockers**", "", ...top.blockedBy.slice(0, 4).map((item) => `- ${sanitizePublicComment(item)}`)] : []), + ...(top.rerunWhen ? ["", "**Rerun when**", "", `- ${sanitizePublicComment(top.rerunWhen)}`] : []), + ]; +} + +export function sanitizePublicComment(value: string): string { + return value + .replace(/\b(raw trust score|trust score|wallet|hotkey|coldkey|seed phrase|mnemonic)\b/gi, "private context") + .replace(/\b(estimated score|score estimate|reward estimate|payout|farming)\b/gi, "private context"); +} diff --git a/src/github/comments.ts b/src/github/comments.ts index 1a9c65ffcd..675ab67eee 100644 --- a/src/github/comments.ts +++ b/src/github/comments.ts @@ -2,6 +2,7 @@ import { Octokit } from "@octokit/core"; import { createInstallationToken } from "./app"; export const PR_INTELLIGENCE_COMMENT_MARKER = ""; +export const AGENT_COMMAND_COMMENT_MARKER = ""; type IssueComment = { id: number; @@ -19,6 +20,27 @@ export async function createOrUpdatePrIntelligenceComment( repoFullName: string, pullNumber: number, body: string, +): Promise<{ id: number; html_url?: string } | null> { + return createOrUpdateIssueCommentWithMarker(env, installationId, repoFullName, pullNumber, body, PR_INTELLIGENCE_COMMENT_MARKER); +} + +export async function createOrUpdateAgentCommandComment( + env: Env, + installationId: number, + repoFullName: string, + issueNumber: number, + body: string, +): Promise<{ id: number; html_url?: string } | null> { + return createOrUpdateIssueCommentWithMarker(env, installationId, repoFullName, issueNumber, body, AGENT_COMMAND_COMMENT_MARKER); +} + +async function createOrUpdateIssueCommentWithMarker( + env: Env, + installationId: number, + repoFullName: string, + issueNumber: number, + body: string, + marker: string, ): Promise<{ id: number; html_url?: string } | null> { const [owner, repo] = repoFullName.split("/"); if (!owner || !repo) throw new Error(`Invalid repository full name: ${repoFullName}`); @@ -28,10 +50,10 @@ export async function createOrUpdatePrIntelligenceComment( const comments = await octokit.request("GET /repos/{owner}/{repo}/issues/{issue_number}/comments", { owner, repo, - issue_number: pullNumber, + issue_number: issueNumber, per_page: 100, }); - const existing = (comments.data as IssueComment[]).find((comment) => comment.body?.includes(PR_INTELLIGENCE_COMMENT_MARKER)); + const existing = (comments.data as IssueComment[]).find((comment) => comment.body?.includes(marker)); if (existing) { const response = await octokit.request("PATCH /repos/{owner}/{repo}/issues/comments/{comment_id}", { owner, @@ -44,7 +66,7 @@ export async function createOrUpdatePrIntelligenceComment( const response = await octokit.request("POST /repos/{owner}/{repo}/issues/{issue_number}/comments", { owner, repo, - issue_number: pullNumber, + issue_number: issueNumber, body, }); return response.data as { id: number; html_url?: string }; diff --git a/src/mcp/server.ts b/src/mcp/server.ts index cfd7b8f758..8a0a7fd140 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -28,6 +28,13 @@ import { fetchPublicContributorProfile } from "../github/public"; import { listLatestRegistrySnapshots } from "../registry/sync"; import { getOrCreateScoringModelSnapshot } from "../scoring/model"; import { buildScorePreview, makeScorePreviewRecord } from "../scoring/preview"; +import { + explainBlockersWithAgent, + getAgentRunBundle, + planNextWork, + preparePrPacketWithAgent, + startAgentRun, +} from "../services/agent-orchestrator"; import { loadFreshContributorDecisionPack, repoDecisionFromPack } from "../services/decision-pack"; import { buildBountyAdvisory, @@ -158,6 +165,24 @@ const localBranchVariantsShape = { variants: z.array(z.object(localBranchAnalysisShape).strict()).min(1).max(10), }; +const agentRunShape = { + objective: z.string().min(1).max(500), + actorLogin: z.string().min(1), + targetRepoFullName: z.string().min(3).optional(), + targetPullNumber: z.number().int().positive().optional(), + targetIssueNumber: z.number().int().positive().optional(), +}; + +const agentRunIdShape = { + runId: z.string().min(1), +}; + +const agentPlanShape = { + login: z.string().min(1), + objective: z.string().min(1).max(500).optional(), + repoFullName: z.string().min(3).optional(), +}; + const scorePreviewShape = { repoFullName: z.string().min(3), targetType: z.enum(["planned_pr", "pull_request", "local_diff", "variant"]).default("local_diff"), @@ -384,6 +409,51 @@ export class GittensoryMcp { async (input) => this.toolResult(await this.compareLocalVariants(input.variants)), ); + server.registerTool( + "gittensory_agent_plan_next_work", + { + description: "Run the deterministic Gittensory base-agent planner and rank the next Gittensor OSS contribution actions.", + inputSchema: agentPlanShape, + }, + async (input) => this.toolResult(await this.agentPlanNextWork(input)), + ); + + server.registerTool( + "gittensory_agent_start_run", + { + description: "Create a queued copilot-only Gittensory agent run. The agent plans and explains; it does not edit code or open PRs.", + inputSchema: agentRunShape, + }, + async (input) => this.toolResult(await this.agentStartRun(input)), + ); + + server.registerTool( + "gittensory_agent_get_run", + { + description: "Fetch a persisted Gittensory agent run with ranked actions and context snapshots.", + inputSchema: agentRunIdShape, + }, + async (input) => this.toolResult(await this.agentGetRun(input.runId)), + ); + + server.registerTool( + "gittensory_agent_explain_next_action", + { + description: "Explain the top deterministic next action and its scoreability/risk/maintainer impact.", + inputSchema: agentPlanShape, + }, + async (input) => this.toolResult(await this.agentExplainNextAction(input)), + ); + + server.registerTool( + "gittensory_agent_prepare_pr_packet", + { + description: "Prepare a public-safe PR packet from local branch metadata. Source contents are not uploaded.", + inputSchema: localBranchAnalysisShape, + }, + async (input) => this.toolResult(await this.agentPreparePrPacket(input)), + ); + return server; } @@ -622,6 +692,59 @@ export class GittensoryMcp { }; } + private async agentPlanNextWork(input: z.infer>): Promise { + const bundle = await planNextWork(this.env, { ...input, surface: "mcp" }); + return { + summary: `Gittensory base-agent plan for ${input.login}.`, + data: bundle as unknown as Record, + }; + } + + private async agentStartRun(input: z.infer>): Promise { + const bundle = await startAgentRun(this.env, { + objective: input.objective, + actorLogin: input.actorLogin, + surface: "mcp", + target: { + repoFullName: input.targetRepoFullName, + pullNumber: input.targetPullNumber, + issueNumber: input.targetIssueNumber, + }, + }); + return { + summary: `Queued Gittensory base-agent run for ${input.actorLogin}.`, + data: bundle as unknown as Record, + }; + } + + private async agentGetRun(runId: string): Promise { + const bundle = await getAgentRunBundle(this.env, runId); + if (!bundle) throw new Error("Agent run not found."); + return { + summary: `Gittensory base-agent run ${runId}.`, + data: bundle as unknown as Record, + }; + } + + private async agentExplainNextAction(input: z.infer>): Promise { + const bundle = await explainBlockersWithAgent(this.env, { ...input, surface: "mcp" }); + return { + summary: `Gittensory base-agent next-action explanation for ${input.login}.`, + data: { + ...bundle, + topAction: bundle.actions[0] ?? null, + } as unknown as Record, + }; + } + + private async agentPreparePrPacket(input: z.infer>): Promise { + const bundle = await preparePrPacketWithAgent(this.env, input, "mcp"); + return { + summary: `Gittensory base-agent public-safe PR packet for ${input.repoFullName}.`, + data: bundle as unknown as Record, + }; + } + private async analyzeLocalBranch(input: z.infer>) { const [context, repo, issues, pullRequests, recentMergedPullRequests, snapshot] = await Promise.all([ this.loadContributorFastContext(input.login), diff --git a/src/openapi/schemas.ts b/src/openapi/schemas.ts index 96277692c6..469e67fd47 100644 --- a/src/openapi/schemas.ts +++ b/src/openapi/schemas.ts @@ -1241,6 +1241,77 @@ export const RegistryChangeReportSchema = z }) .openapi("RegistryChangeReport"); +export const AgentActionSchema = z + .object({ + id: z.string(), + runId: z.string(), + actionType: z.enum([ + "choose_next_work", + "cleanup_existing_prs", + "preflight_branch", + "explain_score_blockers", + "prepare_pr_packet", + "check_duplicate_risk", + "monitor_existing_pr", + "explain_repo_fit", + ]), + targetRepoFullName: z.string().nullable().optional(), + targetPullNumber: z.number().nullable().optional(), + targetIssueNumber: z.number().nullable().optional(), + status: z.enum(["recommended", "ready", "blocked", "watch", "needs_input"]), + recommendation: z.string(), + why: z.array(z.string()), + scoreabilityImpact: z.string().nullable().optional(), + riskImpact: z.string().nullable().optional(), + maintainerImpact: z.string().nullable().optional(), + blockedBy: z.array(z.string()), + rerunWhen: z.string().nullable().optional(), + publicSafeSummary: z.string(), + approvalRequired: z.boolean(), + safetyClass: z.enum(["private", "public_safe", "approval_required"]), + payload: z.record(z.unknown()), + createdAt: z.string().nullable().optional(), + }) + .openapi("AgentAction"); + +export const AgentRunSchema = z + .object({ + id: z.string(), + objective: z.string(), + actorLogin: z.string(), + surface: z.enum(["mcp", "github_comment", "api"]), + mode: z.literal("copilot"), + status: z.enum(["queued", "running", "completed", "failed", "needs_snapshot_refresh"]), + dataQualityStatus: z.enum(["complete", "degraded", "blocked", "unknown"]), + errorSummary: z.string().nullable().optional(), + payload: z.record(z.unknown()), + createdAt: z.string().nullable().optional(), + updatedAt: z.string().nullable().optional(), + }) + .openapi("AgentRun"); + +export const AgentContextSnapshotSchema = z + .object({ + id: z.string(), + runId: z.string(), + decisionPackVersion: z.string().nullable().optional(), + repoSignalSnapshotIds: z.array(z.string()), + scoringModelId: z.string().nullable().optional(), + freshnessWarnings: z.array(z.string()), + payload: z.record(z.unknown()), + createdAt: z.string().nullable().optional(), + }) + .openapi("AgentContextSnapshot"); + +export const AgentRunBundleSchema = z + .object({ + run: AgentRunSchema, + actions: z.array(AgentActionSchema), + contextSnapshots: z.array(AgentContextSnapshotSchema), + summary: z.string(), + }) + .openapi("AgentRunBundle"); + export const HealthSchema = z .object({ status: z.literal("ok"), diff --git a/src/openapi/spec.ts b/src/openapi/spec.ts index 9711d1ec8d..cdd74d7619 100644 --- a/src/openapi/spec.ts +++ b/src/openapi/spec.ts @@ -2,6 +2,10 @@ import { OpenApiGeneratorV3, OpenAPIRegistry } from "@asteasolutions/zod-to-open import { z } from "zod"; import { AdvisorySchema, + AgentActionSchema, + AgentContextSnapshotSchema, + AgentRunBundleSchema, + AgentRunSchema, BountyAdvisorySchema, BountySchema, BurdenForecastSchema, @@ -95,6 +99,10 @@ export function buildOpenApiSpec() { registry.register("Bounty", BountySchema); registry.register("BountyAdvisory", BountyAdvisorySchema); registry.register("RepositorySettings", RepositorySettingsSchema); + registry.register("AgentRun", AgentRunSchema); + registry.register("AgentAction", AgentActionSchema); + registry.register("AgentContextSnapshot", AgentContextSnapshotSchema); + registry.register("AgentRunBundle", AgentRunBundleSchema); registry.register("RepoSyncState", RepoSyncStateSchema); registry.register("RepoSyncSegment", RepoSyncSegmentSchema); registry.register("GitHubRateLimitObservation", GitHubRateLimitObservationSchema); @@ -299,6 +307,35 @@ export function buildOpenApiSpec() { 401: { description: "Unauthorized" }, }, }); + registry.registerPath({ + method: "post", + path: "/v1/agent/runs", + responses: { + 202: { description: "Copilot-only agent run queued", content: { "application/json": { schema: AgentRunBundleSchema } } }, + 400: { description: "Invalid agent run request" }, + 401: { description: "Unauthorized" }, + }, + }); + registry.registerPath({ + method: "get", + path: "/v1/agent/runs/{id}", + responses: { + 200: { description: "Persisted agent run bundle", content: { "application/json": { schema: AgentRunBundleSchema } } }, + 404: { description: "Agent run not found" }, + }, + }); + for (const path of ["/v1/agent/plan-next-work", "/v1/agent/preflight-branch", "/v1/agent/prepare-pr-packet", "/v1/agent/explain-blockers"]) { + registry.registerPath({ + method: "post", + path, + responses: { + 200: { description: "Agent run completed with deterministic ranked actions", content: { "application/json": { schema: AgentRunBundleSchema } } }, + 202: { description: "Agent run needs snapshot refresh", content: { "application/json": { schema: AgentRunBundleSchema } } }, + 400: { description: "Invalid agent request" }, + 401: { description: "Unauthorized" }, + }, + }); + } registry.registerPath({ method: "get", path: "/v1/bounties", diff --git a/src/queue/processors.ts b/src/queue/processors.ts index 3cdb295d21..03d688d0d9 100644 --- a/src/queue/processors.ts +++ b/src/queue/processors.ts @@ -2,6 +2,7 @@ import { countOpenIssues, countOpenPullRequests, getLatestRepoGithubTotalsSnapshot, + getPullRequest, getRepository, getRepositorySettings, listAllIssues, @@ -43,13 +44,19 @@ import { } from "../github/backfill"; import { contributorRepoStatsFromGittensor, fetchGittensorContributorSnapshot, fetchOfficialGittensorMiner, type GittensorContributorSnapshot } from "../gittensor/api"; import { createOrUpdateCheckRun, getInstallationId } from "../github/app"; -import { createOrUpdatePrIntelligenceComment } from "../github/comments"; +import { createOrUpdateAgentCommandComment, createOrUpdatePrIntelligenceComment } from "../github/comments"; +import { + buildPublicAgentCommandComment, + isAuthorizedCommandActor, + parseGittensoryMentionCommand, +} from "../github/commands"; import { ensurePullRequestLabel } from "../github/labels"; import { fetchPublicContributorProfile } from "../github/public"; import { refreshRegistry } from "../registry/sync"; import { buildIssueAdvisory, buildPullRequestAdvisory } from "../rules/advisory"; import { getOrCreateScoringModelSnapshot, refreshScoringModelSnapshot } from "../scoring/model"; import { buildAndPersistContributorDecisionPack } from "../services/decision-pack"; +import { executeAgentRun, explainBlockersWithAgent, planNextWork } from "../services/agent-orchestrator"; import { buildBurdenForecast, buildCollisionEdges, @@ -159,6 +166,9 @@ export async function processJob(env: Env, message: JobMessage): Promise { case "repair-data-fidelity": await repairDataFidelity(env, message.requestedBy); return; + case "run-agent": + await executeAgentRun(env, message.runId); + return; case "github-webhook": await processGitHubWebhook(env, message.deliveryId, message.eventName, message.payload); return; @@ -427,6 +437,19 @@ async function processGitHubWebhook(env: Env, deliveryId: string, eventName: str } if (payload.repository) await upsertRepositoryFromGitHub(env, payload.repository, installationId ?? undefined); + if (eventName === "issue_comment" && (await maybeProcessGittensoryMentionCommand(env, deliveryId, payload))) { + await recordWebhookEvent(env, { + deliveryId, + eventName, + action: payload.action, + installationId: payload.installation?.id, + repositoryFullName: payload.repository?.full_name, + payloadHash: "processed", + status: "processed", + }); + return; + } + if (payload.repository?.full_name && payload.pull_request) { const repoFullName = payload.repository.full_name; const pr = await upsertPullRequestFromGitHub(env, repoFullName, payload.pull_request); @@ -599,6 +622,100 @@ async function maybePublishPrPublicSurface( }); } +async function maybeProcessGittensoryMentionCommand(env: Env, deliveryId: string, payload: GitHubWebhookPayload): Promise { + const command = parseGittensoryMentionCommand(payload.comment?.body); + if (!command) return false; + const repoFullName = payload.repository?.full_name; + const issue = payload.issue; + const installationId = getInstallationId(payload); + const commenter = payload.comment?.user?.login; + if (!repoFullName || !issue || !installationId || !commenter) { + await recordAuditEvent(env, { + eventType: "github_app.agent_command_skipped", + actor: commenter, + targetKey: repoFullName, + outcome: "completed", + detail: "missing_repo_issue_installation_or_actor", + metadata: { deliveryId, command: command.name }, + }); + return true; + } + if (payload.comment?.user?.type === "Bot" || /\[bot\]$/i.test(commenter)) { + await recordAuditEvent(env, { + eventType: "github_app.agent_command_skipped", + actor: commenter, + targetKey: `${repoFullName}#${issue.number}`, + outcome: "completed", + detail: "bot_author", + metadata: { deliveryId, command: command.name }, + }); + return true; + } + if (!issue.pull_request) { + await recordAuditEvent(env, { + eventType: "github_app.agent_command_skipped", + actor: commenter, + targetKey: `${repoFullName}#${issue.number}`, + outcome: "completed", + detail: "not_a_pull_request_thread", + metadata: { deliveryId, command: command.name }, + }); + return true; + } + + const [repo, cachedPullRequest] = await Promise.all([getRepository(env, repoFullName), getPullRequest(env, repoFullName, issue.number)]); + const pullRequestAuthor = cachedPullRequest?.authorLogin ?? issue.user?.login ?? null; + const official = pullRequestAuthor ? await fetchOfficialGittensorMiner(pullRequestAuthor) : undefined; + const authorization = isAuthorizedCommandActor({ + commenterLogin: commenter, + commenterAssociation: payload.comment?.author_association ?? issue.author_association, + pullRequestAuthorLogin: pullRequestAuthor, + officialAuthorDetection: official, + }); + if (!authorization.authorized) { + await recordAuditEvent(env, { + eventType: "github_app.agent_command_skipped", + actor: commenter, + targetKey: `${repoFullName}#${issue.number}`, + outcome: authorization.reason === "miner_detection_unavailable" ? "error" : "completed", + detail: authorization.reason, + metadata: { deliveryId, command: command.name }, + }); + return true; + } + + const login = pullRequestAuthor ?? commenter; + const bundle = + command.name === "help" || command.name === "miner-context" + ? null + : command.name === "blockers" + ? await explainBlockersWithAgent(env, { login, repoFullName, surface: "github_comment" }) + : await planNextWork(env, { + login, + repoFullName, + surface: "github_comment", + objective: `Respond to @gittensory ${command.name} for ${repoFullName}#${issue.number}.`, + }); + const body = buildPublicAgentCommandComment({ + command, + repo, + issue, + pullRequest: cachedPullRequest, + actorKind: authorization.actorKind === "maintainer" ? "maintainer" : "author", + officialMiner: official?.status === "confirmed" ? official.snapshot : null, + bundle, + }); + await createOrUpdateAgentCommandComment(env, installationId, repoFullName, issue.number, body); + await recordAuditEvent(env, { + eventType: "github_app.agent_command_replied", + actor: commenter, + targetKey: `${repoFullName}#${issue.number}`, + outcome: "completed", + metadata: { deliveryId, command: command.name, actorKind: authorization.actorKind, runId: bundle?.run.id ?? null }, + }); + return true; +} + function hasVisiblePrSurface(settings: Awaited>): boolean { return settings.publicSurface !== "off" || settings.checkRunMode === "enabled"; } diff --git a/src/services/agent-orchestrator.ts b/src/services/agent-orchestrator.ts new file mode 100644 index 0000000000..7a9f87723d --- /dev/null +++ b/src/services/agent-orchestrator.ts @@ -0,0 +1,586 @@ +import { + createAgentRun, + getAgentRun, + getRepository, + listAgentActions, + listAgentContextSnapshots, + listContributorIssues, + listContributorPullRequests, + listContributorRepoStats, + listIssues, + listPullRequests, + listRecentMergedPullRequests, + listRepositories, + listRepoSyncStates, + persistAgentContextSnapshot, + recordAuditEvent, + replaceAgentActions, + updateAgentRun, +} from "../db/repositories"; +import { contributorRepoStatsFromGittensor, fetchGittensorContributorSnapshot } from "../gittensor/api"; +import { fetchPublicContributorProfile } from "../github/public"; +import { getOrCreateScoringModelSnapshot } from "../scoring/model"; +import { loadFreshContributorDecisionPack, repoDecisionFromPack, type ContributorDecisionPack, type DecisionAction, type RepoDecision } from "./decision-pack"; +import { buildContributorFit, buildContributorOutcomeHistory, buildContributorProfile, buildContributorScoringProfile } from "../signals/engine"; +import { buildLocalBranchAnalysis, type LocalBranchAnalysis, type LocalBranchAnalysisInput } from "../signals/local-branch"; +import type { + AgentActionRecord, + AgentActionStatus, + AgentActionType, + AgentContextSnapshotRecord, + AgentRunRecord, + AgentRunStatus, + AgentSafetyClass, + AgentSurface, + JsonValue, +} from "../types"; +import { nowIso } from "../utils/json"; + +export type AgentPlanRequest = { + login: string; + objective?: string | undefined; + repoFullName?: string | undefined; + surface?: AgentSurface | undefined; +}; + +export type AgentRunCreateRequest = { + objective: string; + actorLogin: string; + surface?: AgentSurface | undefined; + target?: { + repoFullName?: string | undefined; + pullNumber?: number | undefined; + issueNumber?: number | undefined; + } | undefined; +}; + +export type AgentRunBundle = { + run: AgentRunRecord; + actions: AgentActionRecord[]; + contextSnapshots: AgentContextSnapshotRecord[]; + summary: string; +}; + +export async function startAgentRun(env: Env, input: AgentRunCreateRequest): Promise { + const run = buildRunRecord({ + objective: input.objective, + actorLogin: input.actorLogin, + surface: input.surface ?? "api", + status: "queued", + payload: jsonPayload({ + kind: "plan_next_work", + login: input.actorLogin, + repoFullName: input.target?.repoFullName, + pullNumber: input.target?.pullNumber, + issueNumber: input.target?.issueNumber, + }), + }); + await createAgentRun(env, run); + await env.JOBS.send({ type: "run-agent", requestedBy: run.surface, runId: run.id }); + await recordAuditEvent(env, { + eventType: "agent.run_created", + actor: input.actorLogin, + targetKey: input.target?.repoFullName, + outcome: "queued", + metadata: { runId: run.id, surface: run.surface, objective: input.objective }, + }); + return { run, actions: [], contextSnapshots: [], summary: `Queued Gittensory agent run ${run.id}.` }; +} + +export async function getAgentRunBundle(env: Env, runId: string): Promise { + const run = await getAgentRun(env, runId); + if (!run) return null; + const [actions, contextSnapshots] = await Promise.all([listAgentActions(env, runId), listAgentContextSnapshots(env, runId)]); + return { + run, + actions, + contextSnapshots, + summary: summarizeRun(run, actions), + }; +} + +export async function planNextWork(env: Env, input: AgentPlanRequest): Promise { + const run = buildRunRecord({ + objective: input.objective ?? "Plan the next Gittensor OSS contribution action.", + actorLogin: input.login, + surface: input.surface ?? "api", + status: "running", + payload: jsonPayload({ kind: "plan_next_work", ...input }), + }); + await createAgentRun(env, run); + return executeAgentRun(env, run.id); +} + +export async function preflightBranchWithAgent(env: Env, input: LocalBranchAnalysisInput, surface: AgentSurface = "api"): Promise { + const run = buildRunRecord({ + objective: `Preflight branch for ${input.repoFullName}.`, + actorLogin: input.login, + surface, + status: "running", + payload: { kind: "preflight_branch", input: input as unknown as Record }, + }); + await createAgentRun(env, run); + return executeAgentRun(env, run.id); +} + +export async function preparePrPacketWithAgent(env: Env, input: LocalBranchAnalysisInput, surface: AgentSurface = "api"): Promise { + const run = buildRunRecord({ + objective: `Prepare a public-safe PR packet for ${input.repoFullName}.`, + actorLogin: input.login, + surface, + status: "running", + payload: { kind: "prepare_pr_packet", input: input as unknown as Record }, + }); + await createAgentRun(env, run); + return executeAgentRun(env, run.id); +} + +export async function explainBlockersWithAgent(env: Env, input: AgentPlanRequest | LocalBranchAnalysisInput): Promise { + const login = input.login; + const repoFullName = input.repoFullName; + const isLocalBranch = "changedFiles" in input || "branchName" in input || "headRef" in input; + const run = buildRunRecord({ + objective: `Explain scoreability and review blockers${repoFullName ? ` for ${repoFullName}` : ""}.`, + actorLogin: login, + surface: "api", + status: "running", + payload: isLocalBranch + ? { kind: "explain_branch_blockers", input: input as unknown as Record } + : jsonPayload({ kind: "explain_blockers", ...(input as AgentPlanRequest) }), + }); + await createAgentRun(env, run); + return executeAgentRun(env, run.id); +} + +export async function executeAgentRun(env: Env, runId: string): Promise { + const run = await getAgentRun(env, runId); + if (!run) throw new Error(`Agent run not found: ${runId}`); + await updateAgentRun(env, runId, { status: "running" }); + try { + const kind = String(run.payload.kind ?? "plan_next_work"); + const bundle = + kind === "preflight_branch" || kind === "prepare_pr_packet" || kind === "explain_branch_blockers" + ? await executeLocalBranchRun(env, run, kind) + : await executeDecisionPackRun(env, run, kind); + await recordAuditEvent(env, { + eventType: "agent.run_completed", + actor: run.actorLogin, + targetKey: String(run.payload.repoFullName ?? ""), + outcome: "completed", + metadata: { runId, kind, actionCount: bundle.actions.length }, + }); + return bundle; + } catch (error) { + const message = error instanceof Error ? error.message : "agent_run_failed"; + await updateAgentRun(env, runId, { status: "failed", errorSummary: message }); + await recordAuditEvent(env, { + eventType: "agent.run_failed", + actor: run.actorLogin, + outcome: "error", + detail: message, + metadata: { runId }, + }); + const failed = await getAgentRunBundle(env, runId); + if (!failed) throw error; + return failed; + } +} + +async function executeDecisionPackRun(env: Env, run: AgentRunRecord, kind: string): Promise { + const login = String(run.payload.login ?? run.actorLogin); + const repoFullName = typeof run.payload.repoFullName === "string" ? run.payload.repoFullName : undefined; + const pack = await loadFreshContributorDecisionPack(env, login); + if (!pack) { + await env.JOBS.send({ type: "build-contributor-decision-packs", requestedBy: "api", login }); + await updateAgentRun(env, run.id, { + status: "needs_snapshot_refresh", + dataQualityStatus: "unknown", + payload: { ...run.payload, snapshotRefreshEnqueued: true, refreshReason: "missing_or_stale_decision_pack" }, + }); + return (await getAgentRunBundle(env, run.id))!; + } + const decisions = repoFullName ? pack.repoDecisions.filter((decision) => sameRepo(decision.repoFullName, repoFullName)) : pack.repoDecisions; + const actions = + kind === "explain_blockers" + ? buildBlockerActions(run, pack, decisions) + : buildDecisionActions(run, pack, decisions.length > 0 ? decisions : pack.repoDecisions); + const contexts = [contextSnapshotFromPack(run.id, pack, decisions)]; + await replaceAgentActions(env, run.id, actions); + await persistAgentContextSnapshot(env, contexts[0]!); + await updateAgentRun(env, run.id, { + status: "completed", + dataQualityStatus: pack.dataQuality.signalFidelity.status, + payload: { ...run.payload, generatedAt: pack.generatedAt, actionCount: actions.length }, + }); + return (await getAgentRunBundle(env, run.id))!; +} + +async function executeLocalBranchRun(env: Env, run: AgentRunRecord, kind: string): Promise { + const input = run.payload.input as unknown as LocalBranchAnalysisInput | undefined; + if (!input?.login || !input.repoFullName) throw new Error("agent_local_branch_input_missing"); + const analysis = await analyzeLocalBranch(env, input); + const actions = + kind === "prepare_pr_packet" + ? [localPrPacketAction(run, analysis)] + : kind === "explain_branch_blockers" + ? buildLocalBlockerActions(run, analysis) + : buildLocalBranchActions(run, analysis); + const context: AgentContextSnapshotRecord = { + id: crypto.randomUUID(), + runId: run.id, + decisionPackVersion: analysis.generatedAt, + scoringModelId: analysis.scorePreview.scoringModelSnapshotId, + repoSignalSnapshotIds: [], + freshnessWarnings: [...analysis.baseFreshness.warnings, ...(analysis.dataQuality?.warnings ?? [])], + payload: { + repoFullName: analysis.repoFullName, + baseFreshness: analysis.baseFreshness as unknown as JsonValue, + scoreabilityStatus: analysis.scorePreview.scoreabilityStatus, + dataQuality: (analysis.dataQuality ?? null) as unknown as JsonValue, + }, + }; + await replaceAgentActions(env, run.id, actions); + await persistAgentContextSnapshot(env, context); + await updateAgentRun(env, run.id, { + status: "completed", + dataQualityStatus: analysis.dataQuality?.status ?? "unknown", + payload: { ...run.payload, generatedAt: analysis.generatedAt, actionCount: actions.length }, + }); + return (await getAgentRunBundle(env, run.id))!; +} + +async function analyzeLocalBranch(env: Env, input: LocalBranchAnalysisInput): Promise { + const [github, contributorPullRequests, contributorIssues, repositories, syncStates, cachedRepoStats, gittensorSnapshot, repo, issues, pullRequests, recentMergedPullRequests, scoringSnapshot] = + await Promise.all([ + fetchPublicContributorProfile(input.login), + listContributorPullRequests(env, input.login), + listContributorIssues(env, input.login), + listRepositories(env), + listRepoSyncStates(env), + listContributorRepoStats(env, input.login), + fetchGittensorContributorSnapshot(input.login), + getRepository(env, input.repoFullName), + listIssues(env, input.repoFullName), + listPullRequests(env, input.repoFullName), + listRecentMergedPullRequests(env, input.repoFullName), + getOrCreateScoringModelSnapshot(env), + ]); + const repoStats = contributorRepoStatsFromGittensor(gittensorSnapshot).length > 0 ? contributorRepoStatsFromGittensor(gittensorSnapshot) : cachedRepoStats; + const profile = buildContributorProfile(input.login, github, contributorPullRequests, contributorIssues, repoStats, gittensorSnapshot); + const outcomeHistory = buildContributorOutcomeHistory({ login: input.login, profile, repositories, pullRequests: contributorPullRequests, issues: contributorIssues, repoStats }); + const fit = buildContributorFit(profile, repositories, [], [], syncStates, repoStats); + const scoringProfile = buildContributorScoringProfile({ login: input.login, fit, scoringSnapshot }); + return buildLocalBranchAnalysis({ + input, + repo, + issues, + pullRequests, + recentMergedPullRequests, + profile, + outcomeHistory, + scoringSnapshot, + scoringProfile, + }); +} + +function buildDecisionActions(run: AgentRunRecord, pack: ContributorDecisionPack, decisions: RepoDecision[]): AgentActionRecord[] { + const decisionByRepo = new Map(decisions.map((decision) => [decision.repoFullName, decision])); + const candidateActions = pack.topActions + .filter((action) => decisionByRepo.has(action.repoFullName)) + .slice(0, 8) + .map((action, index) => actionFromDecisionAction(run, action, decisionByRepo.get(action.repoFullName)!, index)); + if (candidateActions.length > 0) return candidateActions; + return decisions.slice(0, 5).map((decision, index) => actionFromRepoDecision(run, decision, index)); +} + +function buildBlockerActions(run: AgentRunRecord, pack: ContributorDecisionPack, decisions: RepoDecision[]): AgentActionRecord[] { + const selected = decisions.length > 0 ? decisions : pack.repoDecisions.filter((decision) => decision.scoreBlockers.length > 0).slice(0, 6); + return selected.slice(0, 8).map((decision, index) => + actionRecord({ + run, + actionType: "explain_score_blockers", + index, + targetRepoFullName: decision.repoFullName, + status: decision.scoreBlockers.length > 0 ? "blocked" : "ready", + recommendation: decision.scoreBlockers.length > 0 ? "Resolve scoreability blockers before adding work." : "No hard scoreability blocker is visible in the decision pack.", + why: decision.scoreBlockers.map((blocker) => blocker.detail).concat(decision.riskReasons).slice(0, 6), + scoreabilityImpact: decision.scoreBlockers.length > 0 ? "Clearing hard blockers can move the action from blocked to scoreable/conditionally scoreable." : "Current signals do not show a hard scoreability gate.", + riskImpact: decision.riskReasons[0] ?? "No major repo-specific risk in current snapshot.", + maintainerImpact: "Reducing blockers before submission keeps maintainer review focused on the actual change.", + blockedBy: decision.scoreBlockers.map((blocker) => blocker.code), + rerunWhen: "Rerun after open PRs merge/close, credibility updates, linked issue context changes, or validation changes.", + publicSafeSummary: `${decision.repoFullName}: blocker context is available privately; public output should stay focused on review hygiene.`, + payload: { decision: decision as unknown as JsonValue }, + }), + ); +} + +function buildLocalBranchActions(run: AgentRunRecord, analysis: LocalBranchAnalysis): AgentActionRecord[] { + const actions: AgentActionRecord[] = [ + actionRecord({ + run, + actionType: "preflight_branch", + index: 0, + targetRepoFullName: analysis.repoFullName, + status: analysis.preflight.status === "ready" ? "ready" : "blocked", + recommendation: analysis.preflight.status === "ready" ? "Branch is ready for a maintainer-friendly PR packet." : "Fix preflight findings before opening or updating the PR.", + why: [ + `Preflight status is ${analysis.preflight.status}.`, + `Lane is ${analysis.lane.lane}.`, + ...analysis.branchQualityBlockers.slice(0, 3), + ], + scoreabilityImpact: analysis.scorePreview.scoreabilityStatus === "blocked" ? "Current scoreability is blocked; scenario projections show what changes after gates clear." : "Current scoreability is not hard-blocked by branch metadata.", + riskImpact: analysis.scoreBlockers[0] ?? analysis.rewardRisk.summary, + maintainerImpact: analysis.maintainerFit.risks[0] ?? "A narrow PR packet reduces review friction.", + blockedBy: [...analysis.branchQualityBlockers, ...analysis.accountStateBlockers].slice(0, 8), + rerunWhen: analysis.recommendedRerunCondition, + publicSafeSummary: sanitizePublicSummary(`${analysis.repoFullName}: preflight found ${analysis.preflight.findings.length} finding(s); use the public-safe PR packet before posting.`), + payload: { analysis: analysis as unknown as JsonValue }, + }), + localPrPacketAction(run, analysis, 1), + ]; + if (analysis.scoreBlockers.length > 0 || analysis.accountStateBlockers.length > 0) actions.push(...buildLocalBlockerActions(run, analysis, 2)); + return actions.slice(0, 8); +} + +function buildLocalBlockerActions(run: AgentRunRecord, analysis: LocalBranchAnalysis, startIndex = 0): AgentActionRecord[] { + return [ + actionRecord({ + run, + actionType: "explain_score_blockers", + index: startIndex, + targetRepoFullName: analysis.repoFullName, + status: analysis.scoreBlockers.length > 0 || analysis.accountStateBlockers.length > 0 ? "blocked" : "ready", + recommendation: analysis.scoreBlockers.length > 0 ? "Treat these as private scoreability blockers, not public PR copy." : "No hard scoreability blocker is visible from local metadata.", + why: [...analysis.scoreBlockers, ...analysis.accountStateBlockers, ...analysis.scenarioScorePreview.blockedBy.map((blocker) => blocker.detail)].slice(0, 8), + scoreabilityImpact: `Current status: ${analysis.scorePreview.scoreabilityStatus}; underlying potential: ${analysis.scorePreview.underlyingPotentialScore}.`, + riskImpact: analysis.rewardRisk.summary, + maintainerImpact: "Separate account/queue blockers from branch quality so maintainers only see actionable PR hygiene.", + blockedBy: [...analysis.scoreBlockers, ...analysis.accountStateBlockers].slice(0, 8), + rerunWhen: analysis.recommendedRerunCondition, + publicSafeSummary: sanitizePublicSummary(`${analysis.repoFullName}: private blockers are separated from public PR guidance.`), + payload: { + scenarioScorePreview: analysis.scenarioScorePreview as unknown as JsonValue, + baseFreshness: analysis.baseFreshness as unknown as JsonValue, + }, + }), + ]; +} + +function localPrPacketAction(run: AgentRunRecord, analysis: LocalBranchAnalysis, index = 0): AgentActionRecord { + return actionRecord({ + run, + actionType: "prepare_pr_packet", + index, + targetRepoFullName: analysis.repoFullName, + status: "ready", + recommendation: "Use this public-safe packet when drafting PR text or a maintainer reply.", + why: ["The packet excludes sensitive private scoring and identity context.", `Validation commands passed: ${analysis.prPacket.validationSummary.passed}.`], + maintainerImpact: "A concise packet gives maintainers linked context, validation evidence, and next steps without noisy scoring language.", + blockedBy: analysis.prPacket.publicSafeWarnings, + rerunWhen: analysis.recommendedRerunCondition, + publicSafeSummary: sanitizePublicSummary(`${analysis.repoFullName}: public-safe PR packet prepared from metadata only.`), + payload: { prPacket: analysis.prPacket as unknown as JsonValue }, + safetyClass: "public_safe", + approvalRequired: false, + }); +} + +function actionFromDecisionAction(run: AgentRunRecord, action: DecisionAction, decision: RepoDecision, index: number): AgentActionRecord { + return actionRecord({ + run, + actionType: mapDecisionAction(action.actionKind), + index, + targetRepoFullName: action.repoFullName, + status: decision.recommendation === "avoid_for_now" ? "watch" : decision.scoreBlockers.some((blocker) => blocker.severity === "critical") ? "blocked" : "recommended", + recommendation: recommendationText(action, decision), + why: [...action.whyThisHelps, ...decision.riskReasons].slice(0, 6), + scoreabilityImpact: decision.scoreBlockers.length > 0 ? `Blocked by ${decision.scoreBlockers.map((blocker) => blocker.code).join(", ")}.` : `Lane fit: ${decision.lane.lane}; direct PR share ${decision.rewardUpside.directPrShare}.`, + riskImpact: decision.riskReasons[0] ?? "No major repo-specific risk is visible in the current decision pack.", + maintainerImpact: maintainerImpactFor(decision), + blockedBy: decision.scoreBlockers.map((blocker) => blocker.code), + rerunWhen: rerunWhenForDecision(decision), + publicSafeSummary: sanitizePublicSummary(`${decision.repoFullName}: ${action.nextActions[0] ?? "Use Gittensory preflight before posting public PR context."}`), + payload: { + action: action as unknown as JsonValue, + decision: decision as unknown as JsonValue, + }, + }); +} + +function actionFromRepoDecision(run: AgentRunRecord, decision: RepoDecision, index: number): AgentActionRecord { + return actionRecord({ + run, + actionType: "explain_repo_fit", + index, + targetRepoFullName: decision.repoFullName, + status: decision.recommendation === "avoid_for_now" ? "watch" : "recommended", + recommendation: decision.nextActions[0] ?? "Use repo fit context before choosing work.", + why: decision.whyThisHelps.concat(decision.riskReasons).slice(0, 6), + scoreabilityImpact: decision.scoreBlockers.length > 0 ? `Blocked by ${decision.scoreBlockers.map((blocker) => blocker.code).join(", ")}.` : `Risk-adjusted priority ${decision.priorityScore}.`, + riskImpact: decision.riskReasons[0] ?? "No major repo-specific risk is visible in the current decision pack.", + maintainerImpact: maintainerImpactFor(decision), + blockedBy: decision.scoreBlockers.map((blocker) => blocker.code), + rerunWhen: rerunWhenForDecision(decision), + publicSafeSummary: sanitizePublicSummary(`${decision.repoFullName}: ${decision.nextActions[0] ?? "Use local branch preflight before posting."}`), + payload: { decision: decision as unknown as JsonValue }, + }); +} + +function actionRecord(args: { + run: AgentRunRecord; + actionType: AgentActionType; + index: number; + targetRepoFullName?: string | undefined; + targetPullNumber?: number | undefined; + targetIssueNumber?: number | undefined; + status: AgentActionStatus; + recommendation: string; + why: string[]; + scoreabilityImpact?: string | undefined; + riskImpact?: string | undefined; + maintainerImpact?: string | undefined; + blockedBy: string[]; + rerunWhen?: string | undefined; + publicSafeSummary: string; + approvalRequired?: boolean | undefined; + safetyClass?: AgentSafetyClass | undefined; + payload: Record; +}): AgentActionRecord { + return { + id: `${args.run.id}:${String(args.index).padStart(2, "0")}:${args.actionType}`, + runId: args.run.id, + actionType: args.actionType, + targetRepoFullName: args.targetRepoFullName, + targetPullNumber: args.targetPullNumber, + targetIssueNumber: args.targetIssueNumber, + status: args.status, + recommendation: args.recommendation, + why: args.why.filter(Boolean).slice(0, 8), + scoreabilityImpact: args.scoreabilityImpact, + riskImpact: args.riskImpact, + maintainerImpact: args.maintainerImpact, + blockedBy: [...new Set(args.blockedBy.filter(Boolean))].slice(0, 10), + rerunWhen: args.rerunWhen, + publicSafeSummary: sanitizePublicSummary(args.publicSafeSummary), + approvalRequired: args.approvalRequired ?? true, + safetyClass: args.safetyClass ?? "private", + payload: args.payload, + createdAt: nowIso(), + }; +} + +function contextSnapshotFromPack(runId: string, pack: ContributorDecisionPack, decisions: RepoDecision[]): AgentContextSnapshotRecord { + const fidelity = pack.dataQuality.signalFidelity; + const warnings = [ + ...fidelity.partialRepos.map((repo) => `${repo}: partial signal coverage`), + ...fidelity.cappedRepos.map((repo) => `${repo}: capped signal coverage`), + ...fidelity.staleRepos.map((repo) => `${repo}: stale signal coverage`), + ...fidelity.rateLimitedRepos.map((repo) => `${repo}: rate limited signal coverage`), + ]; + return { + id: crypto.randomUUID(), + runId, + decisionPackVersion: pack.generatedAt, + repoSignalSnapshotIds: [], + scoringModelId: pack.scoringModelSnapshotId, + freshnessWarnings: warnings, + payload: { + login: pack.login, + source: pack.source, + selectedRepos: decisions.map((decision) => decision.repoFullName), + dataQuality: pack.dataQuality as unknown as JsonValue, + }, + }; +} + +function buildRunRecord(args: { + objective: string; + actorLogin: string; + surface: AgentSurface; + status: AgentRunStatus; + payload: Record; +}): AgentRunRecord { + const now = nowIso(); + return { + id: crypto.randomUUID(), + objective: args.objective, + actorLogin: args.actorLogin, + surface: args.surface, + mode: "copilot", + status: args.status, + dataQualityStatus: "unknown", + payload: args.payload, + createdAt: now, + updatedAt: now, + }; +} + +function mapDecisionAction(kind: DecisionAction["actionKind"]): AgentActionType { + if (kind === "cleanup_existing_prs") return "cleanup_existing_prs"; + if (kind === "land_existing_prs") return "monitor_existing_pr"; + if (kind === "maintainer_lane_improve_repo" || kind === "maintainer_cut_readiness") return "explain_repo_fit"; + return "choose_next_work"; +} + +function recommendationText(action: DecisionAction, decision: RepoDecision): string { + if (action.actionKind === "cleanup_existing_prs") return "Clean up existing PR pressure before opening new work."; + if (action.actionKind === "land_existing_prs") return "Focus on landing or closing already-open PRs."; + if (action.actionKind === "file_issue_discovery") return "Only file an actionable, non-duplicate issue-discovery report."; + if (decision.recommendation === "maintainer_lane") return "Treat this as maintainer-lane repo health work, not outside-contributor work."; + return action.nextActions[0] ?? "Pick narrow work and run branch preflight before opening a PR."; +} + +function maintainerImpactFor(decision: RepoDecision): string { + if (decision.recommendation === "cleanup_first") return "Cleanup lowers active-review pressure before adding more queue load."; + if (decision.recommendation === "maintainer_lane") return "Repo-owner work should improve intake quality and contributor routing."; + return "Narrow, validated work with clear lane fit is easier to review."; +} + +function rerunWhenForDecision(decision: RepoDecision): string { + if (decision.recommendation === "cleanup_first") return "Rerun after open PRs merge, close, or are withdrawn."; + if (decision.scoreBlockers.length > 0) return "Rerun after the listed scoreability blockers change."; + return "Rerun before opening a PR or when repo queue/registry signals change."; +} + +function summarizeRun(run: AgentRunRecord, actions: AgentActionRecord[]): string { + if (run.status === "needs_snapshot_refresh") return `Agent run ${run.id} needs a contributor decision-pack refresh.`; + if (run.status === "failed") return `Agent run ${run.id} failed: ${run.errorSummary ?? "unknown error"}.`; + return `Agent run ${run.id} has ${actions.length} ranked action(s).`; +} + +function sanitizePublicSummary(value: string): string { + return value + .replace(/\b(reward|payout|farming|estimated score|raw trust score|wallet|hotkey|coldkey)\b/gi, "private signal") + .replace(/\s+/g, " ") + .trim(); +} + +function jsonPayload(value: Record): Record { + return Object.fromEntries(Object.entries(value).filter(([, entry]) => entry !== undefined)) as Record; +} + +function sameRepo(left: string, right: string): boolean { + return left.toLowerCase() === right.toLowerCase(); +} + +export const __agentOrchestratorInternals = { + buildDecisionActions, + buildBlockerActions, + buildLocalBranchActions, + buildLocalBlockerActions, + localPrPacketAction, + actionFromDecisionAction, + actionFromRepoDecision, + actionRecord, + contextSnapshotFromPack, + buildRunRecord, + mapDecisionAction, + recommendationText, + maintainerImpactFor, + rerunWhenForDecision, + summarizeRun, + sanitizePublicSummary, + jsonPayload, + sameRepo, +}; diff --git a/src/types.ts b/src/types.ts index 3e223b05bf..161c1430ef 100644 --- a/src/types.ts +++ b/src/types.ts @@ -72,6 +72,11 @@ export type JobMessage = | { type: "repair-data-fidelity"; requestedBy: "schedule" | "api" | "test"; + } + | { + type: "run-agent"; + requestedBy: "api" | "mcp" | "github_comment" | "test"; + runId: string; }; export type GitHubWebhookPayload = { @@ -93,6 +98,7 @@ export type GitHubWebhookPayload = { repositories?: GitHubRepositoryPayload[]; pull_request?: GitHubPullRequestPayload; issue?: GitHubIssuePayload; + comment?: GitHubIssueCommentPayload; label?: { name?: string; }; @@ -150,6 +156,19 @@ export type GitHubIssuePayload = { pull_request?: unknown; }; +export type GitHubIssueCommentPayload = { + id: number; + body?: string | null; + html_url?: string | null; + user?: { + login?: string; + type?: string; + }; + author_association?: string; + created_at?: string | null; + updated_at?: string | null; +}; + export type RegistryRepoConfig = { repo: string; emissionShare: number; @@ -526,6 +545,68 @@ export type SignalSnapshotRecord = { generatedAt?: string | null | undefined; }; +export type AgentSurface = "mcp" | "github_comment" | "api"; +export type AgentMode = "copilot"; +export type AgentRunStatus = "queued" | "running" | "completed" | "failed" | "needs_snapshot_refresh"; +export type AgentActionType = + | "choose_next_work" + | "cleanup_existing_prs" + | "preflight_branch" + | "explain_score_blockers" + | "prepare_pr_packet" + | "check_duplicate_risk" + | "monitor_existing_pr" + | "explain_repo_fit"; +export type AgentActionStatus = "recommended" | "ready" | "blocked" | "watch" | "needs_input"; +export type AgentSafetyClass = "private" | "public_safe" | "approval_required"; + +export type AgentRunRecord = { + id: string; + objective: string; + actorLogin: string; + surface: AgentSurface; + mode: AgentMode; + status: AgentRunStatus; + dataQualityStatus: "complete" | "degraded" | "blocked" | "unknown"; + errorSummary?: string | null | undefined; + payload: Record; + createdAt?: string | null | undefined; + updatedAt?: string | null | undefined; +}; + +export type AgentActionRecord = { + id: string; + runId: string; + actionType: AgentActionType; + targetRepoFullName?: string | null | undefined; + targetPullNumber?: number | null | undefined; + targetIssueNumber?: number | null | undefined; + status: AgentActionStatus; + recommendation: string; + why: string[]; + scoreabilityImpact?: string | null | undefined; + riskImpact?: string | null | undefined; + maintainerImpact?: string | null | undefined; + blockedBy: string[]; + rerunWhen?: string | null | undefined; + publicSafeSummary: string; + approvalRequired: boolean; + safetyClass: AgentSafetyClass; + payload: Record; + createdAt?: string | null | undefined; +}; + +export type AgentContextSnapshotRecord = { + id: string; + runId: string; + decisionPackVersion?: string | null | undefined; + repoSignalSnapshotIds: string[]; + scoringModelId?: string | null | undefined; + freshnessWarnings: string[]; + payload: Record; + createdAt?: string | null | undefined; +}; + export type InstallationRecord = { id: number; accountLogin: string; diff --git a/test/integration/api.test.ts b/test/integration/api.test.ts index 1337709f4d..0857fca3b6 100644 --- a/test/integration/api.test.ts +++ b/test/integration/api.test.ts @@ -346,6 +346,29 @@ describe("api routes", () => { decision: { repoFullName: "entrius/allways-ui", rewardUpside: expect.any(Object), roleContext: { role: "outside_contributor" } }, }); + const agentPlan = await app.request( + "/v1/agent/plan-next-work", + { + method: "POST", + headers: apiHeaders(env), + body: JSON.stringify({ login: "oktofeesh1", repoFullName: "entrius/allways-ui" }), + }, + env, + ); + expect(agentPlan.status).toBe(200); + const agentPlanPayload = (await agentPlan.json()) as { + run: { id: string; status: string; mode: string; surface: string }; + actions: Array<{ actionType: string; publicSafeSummary: string; payload: Record }>; + }; + expect(agentPlanPayload.run).toMatchObject({ status: "completed", mode: "copilot", surface: "api" }); + expect(agentPlanPayload.actions.length).toBeGreaterThan(0); + expect(agentPlanPayload.actions[0]?.publicSafeSummary).not.toMatch(/wallet|hotkey|reward estimate|payout|farming|raw trust score/i); + expect(agentPlanPayload.actions[0]?.payload).toHaveProperty("decision"); + + const fetchedAgentRun = await app.request(`/v1/agent/runs/${agentPlanPayload.run.id}`, { headers: apiHeaders(env) }, env); + expect(fetchedAgentRun.status).toBe(200); + await expect(fetchedAgentRun.json()).resolves.toMatchObject({ run: { id: agentPlanPayload.run.id }, actions: expect.any(Array) }); + const missingRepoDecisionSnapshot = await app.request("/v1/contributors/new-user/repos/entrius/allways-ui/decision", { headers: apiHeaders(env) }, env); expect(missingRepoDecisionSnapshot.status).toBe(202); await expect(missingRepoDecisionSnapshot.json()).resolves.toMatchObject({ status: "needs_snapshot_refresh", repoFullName: "entrius/allways-ui" }); @@ -429,6 +452,34 @@ describe("api routes", () => { rewardRisk: { rewardUpside: { relevantLane: "direct_pr" } }, prPacket: { titleSuggestion: "Fix dashboard cache refresh after reconnect" }, }); + + const agentPacket = await app.request( + "/v1/agent/prepare-pr-packet", + { + method: "POST", + headers: apiHeaders(env), + body: JSON.stringify({ + login: "oktofeesh1", + repoFullName: "entrius/allways-ui", + baseRef: "origin/test", + headRef: "fix-cache", + branchName: "fix-cache-reconnect", + title: "Fix dashboard cache refresh after reconnect", + body: "Fixes #7", + changedFiles: [ + { path: "src/cache.ts", additions: 42, deletions: 4, status: "modified" }, + { path: "test/cache.test.ts", additions: 20, deletions: 0, status: "added" }, + ], + validation: [{ command: "npm test -- cache", status: "passed", summary: "cache regression passed" }], + }), + }, + env, + ); + expect(agentPacket.status).toBe(200); + await expect(agentPacket.json()).resolves.toMatchObject({ + run: { status: "completed" }, + actions: [expect.objectContaining({ actionType: "prepare_pr_packet", safetyClass: "public_safe" })], + }); expect(JSON.stringify(localBranchPayload.prPacket)).not.toMatch(/reward|score|wallet|hotkey|farming|payout|ranking|trust score/i); const localBranchWithMcpToken = await app.request( @@ -750,7 +801,7 @@ describe("api routes", () => { missingPermissions: ["issues"], missingEvents: [], permissions: { metadata: "read", pull_requests: "read" }, - events: ["issues", "pull_request", "repository"], + events: ["issues", "issue_comment", "pull_request", "repository"], checkedAt: "2026-05-23T00:00:00.000Z", }); await upsertRepoSyncSegment(env, { @@ -948,6 +999,11 @@ describe("api routes", () => { expect(toolNames).toContain("gittensory_compare_local_variants"); expect(toolNames).toContain("gittensory_explain_local_blockers"); expect(toolNames).toContain("gittensory_prepare_pr_packet"); + expect(toolNames).toContain("gittensory_agent_plan_next_work"); + expect(toolNames).toContain("gittensory_agent_start_run"); + expect(toolNames).toContain("gittensory_agent_get_run"); + expect(toolNames).toContain("gittensory_agent_explain_next_action"); + expect(toolNames).toContain("gittensory_agent_prepare_pr_packet"); for (const removed of [ "gittensory_get_contributor_fit", "gittensory_find_opportunities", @@ -1049,6 +1105,7 @@ describe("api routes", () => { ["gittensory_get_contributor_profile", { login: "oktofeesh1" }], ["gittensory_get_decision_pack", { login: "oktofeesh1" }], ["gittensory_explain_repo_decision", { login: "oktofeesh1", owner: "entrius", repo: "allways-ui" }], + ["gittensory_agent_plan_next_work", { login: "oktofeesh1", repoFullName: "entrius/allways-ui" }], [ "gittensory_preflight_pr", { @@ -1203,11 +1260,70 @@ describe("api routes", () => { "gittensory_rank_local_next_actions", "gittensory_explain_local_blockers", "gittensory_compare_local_variants", + "gittensory_agent_plan_next_work", + "gittensory_agent_explain_next_action", ]); expect(text).not.toMatch(/farming|wallet|hotkey|guaranteed payout/i); if (!privateRewardTools.has(name)) expect(text).not.toMatch(/reward/i); } + const agentStart = await app.request( + "/mcp", + { + method: "POST", + headers: mcpHeaders(env), + body: JSON.stringify({ + jsonrpc: "2.0", + id: "agent-start", + method: "tools/call", + params: { + name: "gittensory_agent_start_run", + arguments: { + actorLogin: "oktofeesh1", + objective: "Plan next Gittensor action", + repoFullName: "entrius/allways-ui", + }, + }, + }), + }, + env, + ); + expect(agentStart.status).toBe(200); + const agentStartPayload = (await mcpJson(agentStart)) as { result: { structuredContent: { run: { id: string; status: string } } } }; + expect(agentStartPayload.result.structuredContent.run.status).toBe("queued"); + + for (const [name, args] of [ + ["gittensory_agent_get_run", { runId: agentStartPayload.result.structuredContent.run.id }], + ["gittensory_agent_explain_next_action", { login: "oktofeesh1", repoFullName: "entrius/allways-ui" }], + [ + "gittensory_agent_prepare_pr_packet", + { + login: "oktofeesh1", + repoFullName: "entrius/allways-ui", + branchName: "fix-cache", + changedFiles: [ + { path: "src/cache.ts", additions: 8, deletions: 1, status: "modified" }, + { path: "test/cache.test.ts", additions: 5, deletions: 0, status: "added" }, + ], + linkedIssues: [7], + validation: [{ command: "npm test -- cache", status: "passed", summary: "cache tests passed" }], + }, + ], + ] as const) { + const response = await app.request( + "/mcp", + { + method: "POST", + headers: mcpHeaders(env), + body: JSON.stringify({ jsonrpc: "2.0", id: `agent-${name}`, method: "tools/call", params: { name, arguments: args } }), + }, + env, + ); + expect(response.status).toBe(200); + const payload = (await mcpJson(response)) as { result?: { content?: Array<{ text: string }> } }; + expect(payload.result?.content?.[0]?.text ?? "").not.toMatch(/wallet|hotkey|farming|guaranteed payout/i); + } + for (const [args, recommendation] of [ [ { @@ -1920,7 +2036,7 @@ async function seedSignalData(env: Env): Promise { missingPermissions: [], missingEvents: [], permissions: { metadata: "read", pull_requests: "read", issues: "write" }, - events: ["issues", "pull_request", "repository"], + events: ["issues", "issue_comment", "pull_request", "repository"], checkedAt: "2026-05-23T00:00:00.000Z", }); await upsertIssueFromGitHub(env, "entrius/allways-ui", { diff --git a/test/integration/routes-errors.test.ts b/test/integration/routes-errors.test.ts index 4642bcd50f..efe732ea4c 100644 --- a/test/integration/routes-errors.test.ts +++ b/test/integration/routes-errors.test.ts @@ -198,6 +198,57 @@ describe("api route guards and error branches", () => { expect((await app.request("/v1/preflight/pr", { method: "POST", headers: apiHeaders(env), body: "{}" }, env)).status).toBe(400); expect((await app.request("/v1/preflight/local-diff", { method: "POST", headers: apiHeaders(env), body: "{}" }, env)).status).toBe(400); expect((await app.request("/v1/local/branch-analysis", { method: "POST", headers: apiHeaders(env), body: "{}" }, env)).status).toBe(400); + expect((await app.request("/v1/agent/runs/missing-run", { headers: apiHeaders(env) }, env)).status).toBe(404); + expect((await app.request("/v1/agent/runs", { method: "POST", headers: apiHeaders(env), body: "{}" }, env)).status).toBe(400); + expect((await app.request("/v1/agent/plan-next-work", { method: "POST", headers: apiHeaders(env), body: "{}" }, env)).status).toBe(400); + expect((await app.request("/v1/agent/preflight-branch", { method: "POST", headers: apiHeaders(env), body: "{}" }, env)).status).toBe(400); + expect((await app.request("/v1/agent/prepare-pr-packet", { method: "POST", headers: apiHeaders(env), body: "{}" }, env)).status).toBe(400); + expect((await app.request("/v1/agent/explain-blockers", { method: "POST", headers: apiHeaders(env), body: "{}" }, env)).status).toBe(400); + expect((await app.request("/openapi.json", { method: "OPTIONS" }, env)).status).toBe(204); + + const agentRun = await app.request("/v1/agent/runs", { + method: "POST", + headers: apiHeaders(env), + body: JSON.stringify({ objective: "Plan next work", actorLogin: "oktofeesh1", surface: "api" }), + }, env); + expect(agentRun.status).toBe(202); + const agentRunJson = (await agentRun.json()) as { run: { id: string; status: string } }; + expect(agentRunJson.run.status).toBe("queued"); + const loadedAgentRun = await app.request(`/v1/agent/runs/${agentRunJson.run.id}`, { headers: apiHeaders(env) }, env); + expect(loadedAgentRun.status).toBe(200); + + const agentPlan = await app.request("/v1/agent/plan-next-work", { + method: "POST", + headers: apiHeaders(env), + body: JSON.stringify({ login: "oktofeesh1", objective: "Pick next work", surface: "api" }), + }, env); + expect(agentPlan.status).toBe(202); + await expect(agentPlan.json()).resolves.toMatchObject({ run: { status: "needs_snapshot_refresh" } }); + + const agentBlockers = await app.request("/v1/agent/explain-blockers", { + method: "POST", + headers: apiHeaders(env), + body: JSON.stringify({ login: "oktofeesh1", repoFullName: "JSONbored/gittensory" }), + }, env); + expect(agentBlockers.status).toBe(202); + await expect(agentBlockers.json()).resolves.toMatchObject({ run: { status: "needs_snapshot_refresh" } }); + + const localAgentPayload = { + login: "oktofeesh1", + repoFullName: "JSONbored/gittensory", + baseRef: "origin/main", + headRef: "feature/base-agent", + branchName: "feature/base-agent", + changedFiles: [{ path: "src/services/agent-orchestrator.ts", additions: 20, deletions: 2, status: "modified" }], + validation: [{ command: "npm test", status: "passed", summary: "unit tests passed" }], + title: "Add base-agent planning", + body: "No issue: base-agent planning surface.", + localScorer: { mode: "metadata_only", sourceTokenScore: 40, totalTokenScore: 60 }, + }; + expect((await app.request("/v1/agent/preflight-branch", { method: "POST", headers: apiHeaders(env), body: JSON.stringify(localAgentPayload) }, env)).status).toBe(200); + expect((await app.request("/v1/agent/prepare-pr-packet", { method: "POST", headers: apiHeaders(env), body: JSON.stringify(localAgentPayload) }, env)).status).toBe(200); + expect((await app.request("/v1/agent/explain-blockers", { method: "POST", headers: apiHeaders(env), body: JSON.stringify(localAgentPayload) }, env)).status).toBe(200); + expect((await app.request("/v1/scoring/preview", { method: "POST", headers: apiHeaders(env), body: "{}" }, env)).status).toBe(400); expect((await app.request("/v1/scoring/model", { headers: apiHeaders(env) }, env)).status).toBe(200); expect((await app.request("/v1/repos/nope/missing/issue-quality", { headers: apiHeaders(env) }, env)).status).toBe(404); diff --git a/test/unit/agent-orchestrator.test.ts b/test/unit/agent-orchestrator.test.ts new file mode 100644 index 0000000000..683bf0e76f --- /dev/null +++ b/test/unit/agent-orchestrator.test.ts @@ -0,0 +1,762 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createAgentRun, persistScoringModelSnapshot, persistSignalSnapshot, upsertIssueFromGitHub, upsertPullRequestFromGitHub, upsertRecentMergedPullRequest, upsertRepositoryFromGitHub } from "../../src/db/repositories"; +import { + __agentOrchestratorInternals, + executeAgentRun, + explainBlockersWithAgent, + getAgentRunBundle, + planNextWork, + preflightBranchWithAgent, + preparePrPacketWithAgent, + startAgentRun, + type AgentRunBundle, +} from "../../src/services/agent-orchestrator"; +import { CONTRIBUTOR_DECISION_PACK_SIGNAL, type ContributorDecisionPack } from "../../src/services/decision-pack"; +import { normalizeRegistryPayload } from "../../src/registry/normalize"; +import { persistRegistrySnapshot } from "../../src/registry/sync"; +import type { AgentRunRecord, JsonValue } from "../../src/types"; +import { nowIso } from "../../src/utils/json"; +import { createTestEnv } from "../helpers/d1"; + +describe("agent orchestrator", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("queues copilot runs and reports missing decision-pack snapshots without recomputing broad data", async () => { + const sent: unknown[] = []; + const env = createTestEnv({ + JOBS: { + async send(message: unknown) { + sent.push(message); + }, + } as unknown as Queue, + }); + + const queued = await startAgentRun(env, { + actorLogin: "oktofeesh1", + objective: "Find my next Gittensor action", + surface: "mcp", + target: { repoFullName: "we-promise/sure" }, + }); + expect(queued.run).toMatchObject({ actorLogin: "oktofeesh1", mode: "copilot", status: "queued" }); + expect(sent).toContainEqual(expect.objectContaining({ type: "run-agent", requestedBy: "mcp", runId: queued.run.id })); + + const missing = await planNextWork(env, { login: "oktofeesh1", surface: "mcp" }); + expect(missing.run.status).toBe("needs_snapshot_refresh"); + expect(missing.summary).toContain("needs a contributor decision-pack refresh"); + expect(sent).toContainEqual({ type: "build-contributor-decision-packs", requestedBy: "api", login: "oktofeesh1" }); + + await expect(getAgentRunBundle(env, "missing-run")).resolves.toBeNull(); + }); + + it("defaults API runs and fails missing or interrupted runs predictably", async () => { + const sent: unknown[] = []; + const env = createTestEnv({ + JOBS: { + async send(message: unknown) { + sent.push(message); + }, + } as unknown as Queue, + }); + + const queued = await startAgentRun(env, { + actorLogin: "oktofeesh1", + objective: "Find work without explicit surface", + }); + expect(queued.run.surface).toBe("api"); + expect(sent).toContainEqual(expect.objectContaining({ type: "run-agent", requestedBy: "api" })); + await expect(executeAgentRun(env, "missing-run")).rejects.toThrow("Agent run not found"); + + const interruptedEnv = createTestEnv({ + JOBS: { + async send() { + throw "queue offline"; + }, + } as unknown as Queue, + }); + const run: AgentRunRecord = { + id: "run-interrupted", + objective: "interrupt", + actorLogin: "oktofeesh1", + surface: "api", + mode: "copilot", + status: "queued", + dataQualityStatus: "unknown", + payload: {}, + createdAt: nowIso(), + updatedAt: nowIso(), + }; + await createAgentRun(interruptedEnv, run); + const failed = await executeAgentRun(interruptedEnv, run.id); + expect(failed.run).toMatchObject({ status: "failed", errorSummary: "agent_run_failed" }); + }); + + it("ranks decision-pack actions, persists context snapshots, and sanitizes public summaries", async () => { + const env = createTestEnv(); + await persistDecisionPack(env, decisionPackFixture()); + + const bundle = await planNextWork(env, { login: "oktofeesh1", repoFullName: "we-promise/sure", objective: "Pick one action" }); + + expect(bundle.run).toMatchObject({ status: "completed", dataQualityStatus: "degraded" }); + expect(bundle.actions[0]).toMatchObject({ + actionType: "cleanup_existing_prs", + targetRepoFullName: "we-promise/sure", + status: "blocked", + approvalRequired: true, + safetyClass: "private", + }); + expect(bundle.actions[0]?.publicSafeSummary).not.toMatch(/reward|wallet|hotkey|raw trust score|estimated score/i); + expect(bundle.contextSnapshots[0]).toMatchObject({ + scoringModelId: "scoring-1", + freshnessWarnings: expect.arrayContaining(["we-promise/sure: partial signal coverage", "we-promise/sure: stale signal coverage"]), + }); + }); + + it("falls back to repo-fit actions when a snapshot has no top actions", async () => { + const env = createTestEnv(); + await persistDecisionPack(env, decisionPackFixture({ topActions: [] })); + + const bundle = await planNextWork(env, { login: "oktofeesh1" }); + + expect(bundle.actions.map((action) => action.actionType)).toContain("explain_repo_fit"); + expect(bundle.actions.some((action) => action.recommendation.includes("repo health") || action.recommendation.includes("Choose a different repo"))).toBe(true); + }); + + it("explains scoreability blockers from persisted decision packs", async () => { + const env = createTestEnv(); + await persistDecisionPack(env, decisionPackFixture()); + + const bundle = await explainBlockersWithAgent(env, { login: "oktofeesh1", repoFullName: "entrius/gittensor" }); + + expect(bundle.actions).toHaveLength(1); + expect(bundle.actions[0]).toMatchObject({ + actionType: "explain_score_blockers", + status: "blocked", + blockedBy: ["closed_pr_credibility", "low_credibility"], + }); + expect(bundle.actions[0]?.rerunWhen).toContain("Rerun after"); + }); + + it("covers pure action mapping, summaries, and public sanitization branches", () => { + const generatedAt = nowIso(); + const run = __agentOrchestratorInternals.buildRunRecord({ + objective: "exercise pure branches", + actorLogin: "oktofeesh1", + surface: "mcp", + status: "running", + payload: {}, + }); + const avoidDecision = repoDecision({ + repoFullName: "owner/avoid", + recommendation: "avoid_for_now", + priorityScore: 1, + scoreBlockers: [], + nextActions: [], + }); + const maintainerDecision = repoDecision({ + repoFullName: "owner/maintainer", + recommendation: "maintainer_lane", + priorityScore: 44, + nextActions: [], + }); + const criticalDecision = repoDecision({ + repoFullName: "owner/critical", + recommendation: "pursue", + priorityScore: 77, + scoreBlockers: [{ code: "inactive_or_unknown_lane", repoFullName: "owner/critical", severity: "critical", detail: "Critical gate." }], + riskReasons: [], + nextActions: [], + }); + const readyDecision = repoDecision({ + repoFullName: "owner/ready", + recommendation: "pursue", + priorityScore: 80, + scoreBlockers: [], + riskReasons: [], + nextActions: ["Open a narrow, validated PR."], + }); + + expect(__agentOrchestratorInternals.mapDecisionAction("land_existing_prs")).toBe("monitor_existing_pr"); + expect(__agentOrchestratorInternals.mapDecisionAction("maintainer_cut_readiness")).toBe("explain_repo_fit"); + expect(__agentOrchestratorInternals.recommendationText(action("file_issue_discovery", "owner/issues", "watch", 30), readyDecision)).toMatch(/actionable/); + expect(__agentOrchestratorInternals.recommendationText(action("maintainer_lane_improve_repo", "owner/maintainer", "maintainer_lane", 44), maintainerDecision)).toMatch(/maintainer-lane/); + expect(__agentOrchestratorInternals.recommendationText({ ...action("open_new_direct_pr", "owner/ready", "pursue", 80), nextActions: [] }, readyDecision)).toMatch(/Pick narrow work/); + expect(__agentOrchestratorInternals.maintainerImpactFor(maintainerDecision)).toMatch(/Repo-owner/); + expect(__agentOrchestratorInternals.rerunWhenForDecision(criticalDecision)).toMatch(/blockers/); + expect(__agentOrchestratorInternals.sameRepo("Owner/Repo", "owner/repo")).toBe(true); + expect(__agentOrchestratorInternals.jsonPayload({ keep: "yes", drop: undefined })).toEqual({ keep: "yes" }); + expect(__agentOrchestratorInternals.sanitizePublicSummary("reward payout wallet hotkey raw trust score")).not.toMatch(/reward|payout|wallet|hotkey|raw trust score/i); + + const watchAction = __agentOrchestratorInternals.actionFromDecisionAction(run, action("open_new_direct_pr", "owner/avoid", "avoid_for_now", 1), avoidDecision, 0); + const blockedAction = __agentOrchestratorInternals.actionFromDecisionAction(run, action("open_new_direct_pr", "owner/critical", "pursue", 77), criticalDecision, 1); + const readyAction = __agentOrchestratorInternals.actionFromDecisionAction(run, action("open_new_direct_pr", "owner/ready", "pursue", 80), readyDecision, 2); + const emptyNextAction = __agentOrchestratorInternals.actionFromDecisionAction(run, { ...action("open_new_direct_pr", "owner/ready", "pursue", 80), nextActions: [] }, readyDecision, 4); + const repoFit = __agentOrchestratorInternals.actionFromRepoDecision(run, { ...readyDecision, nextActions: [] }, 3); + const noDecisionActions = __agentOrchestratorInternals.buildDecisionActions(run, decisionPackFixture({ generatedAt, topActions: [], repoDecisions: [readyDecision] }), [readyDecision]); + const blockerFallback = __agentOrchestratorInternals.buildBlockerActions( + run, + decisionPackFixture({ generatedAt, repoDecisions: [criticalDecision], topActions: [] }), + [], + ); + + expect([watchAction.status, blockedAction.status, readyAction.status]).toEqual(["watch", "blocked", "recommended"]); + expect(emptyNextAction.publicSafeSummary).toMatch(/Use Gittensory preflight/); + expect(repoFit.recommendation).toMatch(/repo fit/); + expect(noDecisionActions[0]).toMatchObject({ actionType: "explain_repo_fit", status: "recommended" }); + expect(blockerFallback[0]).toMatchObject({ actionType: "explain_score_blockers", status: "blocked" }); + expect(__agentOrchestratorInternals.summarizeRun({ ...run, status: "failed", errorSummary: undefined }, [])).toContain("unknown"); + }); + + it("covers local action ready and blocker-free branches from prepared metadata", () => { + const run = __agentOrchestratorInternals.buildRunRecord({ + objective: "local ready branch", + actorLogin: "oktofeesh1", + surface: "mcp", + status: "running", + payload: {}, + }); + const analysis = { + repoFullName: "owner/ready", + generatedAt: nowIso(), + preflight: { status: "ready", findings: [] }, + lane: { lane: "direct_pr" }, + branchQualityBlockers: [], + accountStateBlockers: [], + scoreBlockers: [], + scorePreview: { + scoreabilityStatus: "scoreable", + underlyingPotentialScore: 20, + scoringModelSnapshotId: "scoring-ready", + }, + scenarioScorePreview: { blockedBy: [] }, + rewardRisk: { summary: "Risk is acceptable." }, + maintainerFit: { risks: [] }, + recommendedRerunCondition: "Rerun before opening a PR.", + prPacket: { + validationSummary: { passed: 1 }, + publicSafeWarnings: [], + }, + baseFreshness: { warnings: [] }, + } as any; + + const actions = __agentOrchestratorInternals.buildLocalBranchActions(run, analysis); + const blockers = __agentOrchestratorInternals.buildLocalBlockerActions(run, analysis); + const blockedActions = __agentOrchestratorInternals.buildLocalBranchActions(run, { + ...analysis, + scoreBlockers: ["Open PR pressure blocks current scoreability."], + accountStateBlockers: ["Credibility is below the current floor."], + scorePreview: { ...analysis.scorePreview, scoreabilityStatus: "blocked" }, + scenarioScorePreview: { blockedBy: [{ detail: "openPrMultiplier is 0." }] }, + }); + + expect(actions.map((entry) => entry.actionType)).toEqual(["preflight_branch", "prepare_pr_packet"]); + expect(actions[0]).toMatchObject({ status: "ready", scoreabilityImpact: "Current scoreability is not hard-blocked by branch metadata." }); + expect(blockers[0]).toMatchObject({ status: "ready", recommendation: "No hard scoreability blocker is visible from local metadata." }); + expect(blockedActions.map((entry) => entry.actionType)).toEqual(["preflight_branch", "prepare_pr_packet", "explain_score_blockers"]); + expect(blockedActions[0]?.scoreabilityImpact).toContain("scenario projections"); + }); + + it("covers watch, pursue, and no-blocker decision branches", async () => { + const env = createTestEnv(); + const generatedAt = nowIso(); + const pursue = repoDecision({ + repoFullName: "touchpilot/touchpilot", + recommendation: "pursue", + priorityScore: 72, + queue: { openIssues: 1, openPullRequests: 1, mergedPullRequests: 5, closedUnmergedPullRequests: 0 }, + nextActions: ["Pick one narrow change, link context clearly, run tests, and use local branch analysis before opening the PR."], + }); + const watch = repoDecision({ + repoFullName: "entrius/allways", + recommendation: "watch", + priorityScore: 41, + lane: { + repoFullName: "entrius/allways", + lane: "issue_discovery", + issueDiscoveryShare: 1, + directPrShare: 0, + summary: "Issue-discovery lane.", + contributorGuidance: "File actionable reports only.", + maintainerGuidance: "Watch issue quality.", + }, + scoreBlockers: [{ code: "issue_discovery_only", repoFullName: "entrius/allways", severity: "warning", detail: "This repo is issue-discovery-only." }], + riskReasons: ["Direct PRs are not the useful lane here; use issue-discovery behavior only."], + nextActions: ["File only high-confidence, actionable, non-duplicate issue-discovery reports."], + }); + await persistDecisionPack( + env, + decisionPackFixture({ + generatedAt, + repoDecisions: [pursue, watch], + topActions: [ + action("open_new_direct_pr", "touchpilot/touchpilot", "pursue", 72), + action("file_issue_discovery", "entrius/allways", "watch", 41), + ], + cleanupFirst: [], + pursueRepos: [pursue], + avoidRepos: [], + maintainerLaneRepos: [], + scoreBlockers: watch.scoreBlockers, + dataQuality: { + signalFidelity: { + status: "degraded", + repoCount: 2, + completeRepos: 1, + degradedRepos: 1, + blockedRepos: 0, + partialRepos: [], + cappedRepos: ["entrius/allways"], + staleRepos: [], + rateLimitedRepos: ["entrius/allways"], + }, + }, + }), + ); + + const plan = await planNextWork(env, { login: "oktofeesh1" }); + const noBlockers = await explainBlockersWithAgent(env, { login: "oktofeesh1", repoFullName: "touchpilot/touchpilot" }); + const noRepoBlockers = await explainBlockersWithAgent(env, { login: "oktofeesh1" }); + const missingRepoPlan = await planNextWork(env, { login: "oktofeesh1", repoFullName: "missing/repo" }); + + expect(plan.actions.map((entry) => entry.recommendation)).toEqual( + expect.arrayContaining([ + "Use Gittensory preflight before posting public PR context.", + "Only file an actionable, non-duplicate issue-discovery report.", + ]), + ); + expect(plan.contextSnapshots[0]?.freshnessWarnings).toEqual(expect.arrayContaining(["entrius/allways: capped signal coverage", "entrius/allways: rate limited signal coverage"])); + expect(noBlockers.actions[0]).toMatchObject({ + status: "ready", + scoreabilityImpact: "Current signals do not show a hard scoreability gate.", + blockedBy: [], + }); + expect(noRepoBlockers.run.objective).toBe("Explain scoreability and review blockers."); + expect(missingRepoPlan.actions.length).toBeGreaterThan(0); + }); + + it("marks failed runs without throwing when local branch input is malformed", async () => { + const env = createTestEnv(); + const run: AgentRunRecord = { + id: "run-malformed", + objective: "bad local branch", + actorLogin: "oktofeesh1", + surface: "api", + mode: "copilot", + status: "queued", + dataQualityStatus: "unknown", + payload: { kind: "preflight_branch" }, + createdAt: nowIso(), + updatedAt: nowIso(), + }; + await createAgentRun(env, run); + + const failed = await executeAgentRun(env, run.id); + + expect(failed.run.status).toBe("failed"); + expect(failed.summary).toContain("agent_local_branch_input_missing"); + }); + + it("runs local branch agent flows from metadata only", async () => { + const env = createTestEnv(); + await seedLocalBranchData(env); + stubContributorFetch(); + const input = { + login: "oktofeesh1", + repoFullName: "entrius/allways-ui", + baseRef: "origin/main", + headRef: "feature/cache-fix", + branchName: "fix-cache-7", + baseSha: "base", + headSha: "head", + mergeBaseSha: "base", + remoteTrackingSha: "base", + changedFiles: [ + { path: "src/cache.ts", additions: 30, deletions: 4, status: "modified" as const }, + { path: "src/cache_test.ts", additions: 18, deletions: 0, status: "added" as const }, + ], + linkedIssues: [7], + labels: ["bug"], + title: "Fix dashboard cache refresh after reconnect", + body: "Fixes #7", + validation: [{ command: "npm test -- cache", status: "passed" as const, summary: "cache tests passed" }], + localScorer: { mode: "metadata_only" as const, sourceTokenScore: 40, totalTokenScore: 58, testTokenScore: 18, nonCodeTokenScore: 0 }, + expectedOpenPrCountAfterMerge: 1, + projectedCredibility: 0.9, + scenarioNotes: ["approved PRs may land first"], + }; + + const preflight = await preflightBranchWithAgent(env, input, "mcp"); + const packet = await preparePrPacketWithAgent(env, input, "mcp"); + const blockers = await explainBlockersWithAgent(env, input); + + expect(preflight.actions.map((action) => action.actionType)).toEqual(expect.arrayContaining(["preflight_branch", "prepare_pr_packet"])); + expect(packet.actions).toHaveLength(1); + expect(packet.actions[0]).toMatchObject({ actionType: "prepare_pr_packet", safetyClass: "public_safe", approvalRequired: false }); + expect(blockers.actions[0]).toMatchObject({ actionType: "explain_score_blockers", targetRepoFullName: "entrius/allways-ui" }); + expect(JSON.stringify(preflight.actions)).toContain("Source upload disabled"); + }); +}); + +async function persistDecisionPack(env: Env, pack: ContributorDecisionPack): Promise { + await persistSignalSnapshot(env, { + id: crypto.randomUUID(), + signalType: CONTRIBUTOR_DECISION_PACK_SIGNAL, + targetKey: pack.login, + payload: pack as unknown as Record, + generatedAt: pack.generatedAt, + }); +} + +function decisionPackFixture(overrides: Partial = {}): ContributorDecisionPack { + const generatedAt = nowIso(); + const repoDecisions = [ + repoDecision({ + repoFullName: "we-promise/sure", + recommendation: "cleanup_first", + priorityScore: 94, + scoreBlockers: [{ code: "open_pr_pressure", repoFullName: "we-promise/sure", severity: "critical", detail: "7 open PR(s) create scoreability and review-pressure risk." }], + riskReasons: ["Contributor has 7 open PR(s) in this repo."], + nextActions: ["Close, update, or land existing open PRs before opening more work."], + }), + repoDecision({ + repoFullName: "JSONbored/awesome-claude", + recommendation: "maintainer_lane", + priorityScore: 62, + roleContext: { + login: "oktofeesh1", + role: "owner", + repoFullName: "JSONbored/awesome-claude", + generatedAt, + maintainerLane: true, + normalContributorEvidenceAllowed: false, + source: "repo_owner_match", + reasons: ["Owner of this repo."], + guidance: "Use maintainer-lane guidance.", + }, + scoreBlockers: [{ code: "maintainer_lane", repoFullName: "JSONbored/awesome-claude", severity: "info", detail: "Maintainer-lane activity is separate from normal outside-contributor reward evidence." }], + nextActions: ["Improve contributor intake health, label clarity, and queue hygiene."], + }), + repoDecision({ + repoFullName: "entrius/gittensor", + recommendation: "avoid_for_now", + priorityScore: 31, + scoreBlockers: [ + { code: "closed_pr_credibility", repoFullName: "entrius/gittensor", severity: "warning", detail: "Closed PR rate is 50%." }, + { code: "low_credibility", repoFullName: "entrius/gittensor", severity: "warning", detail: "Official repo credibility is 0.5." }, + ], + riskReasons: ["Repo-specific closed PR rate is 50%."], + nextActions: ["Choose a different repo or wait for cleaner lane/credibility conditions."], + }), + ] satisfies ContributorDecisionPack["repoDecisions"]; + const pack = { + status: "ready", + source: "computed", + login: "oktofeesh1", + generatedAt, + stale: false, + scoringModelSnapshotId: "scoring-1", + profile: { + login: "oktofeesh1", + github: { login: "oktofeesh1", publicRepos: 8, followers: 2, topLanguages: ["TypeScript"], source: "github" }, + source: "gittensor_api", + officialStats: { + githubId: "123", + githubUsername: "oktofeesh1", + isEligible: true, + credibility: 0.9, + eligibleRepoCount: 2, + issueDiscoveryScore: 0, + issueTokenScore: 0, + issueCredibility: 1, + isIssueEligible: false, + issueEligibleRepoCount: 0, + alphaPerDay: 0, + taoPerDay: 0, + usdPerDay: 0, + totals: { + pullRequests: 9, + mergedPullRequests: 5, + openPullRequests: 7, + closedPullRequests: 2, + openIssues: 0, + closedIssues: 1, + solvedIssues: 0, + validSolvedIssues: 0, + }, + repositories: [], + }, + registeredRepoActivity: { + pullRequests: 9, + mergedPullRequests: 5, + issues: 1, + reposTouched: ["we-promise/sure", "entrius/gittensor"], + dominantLabels: ["bug"], + }, + trustSignals: { + evidenceScore: 72, + level: "established", + unlinkedOpenPullRequests: 1, + maintainerAssociatedPullRequests: 0, + }, + }, + outcomeHistory: { + login: "oktofeesh1", + generatedAt, + source: "gittensor_api", + totals: { + pullRequests: 9, + mergedPullRequests: 5, + openPullRequests: 7, + closedPullRequests: 2, + closedPullRequestRate: 0.22, + issues: 1, + openIssues: 0, + closedIssues: 1, + solvedIssues: 0, + validSolvedIssues: 0, + credibility: 0.9, + issueCredibility: 1, + }, + repoOutcomes: [], + successPatterns: [], + failurePatterns: [], + maintainerLaneRepos: ["JSONbored/awesome-claude"], + }, + roleContexts: [], + repoDecisions, + topActions: [ + action("cleanup_existing_prs", "we-promise/sure", "cleanup_first", 94), + action("land_existing_prs", "we-promise/sure", "cleanup_first", 86), + action("maintainer_lane_improve_repo", "JSONbored/awesome-claude", "maintainer_lane", 62), + action("open_new_direct_pr", "entrius/gittensor", "avoid_for_now", 31), + ], + cleanupFirst: [repoDecisions[0]!], + pursueRepos: [], + avoidRepos: [repoDecisions[2]!], + maintainerLaneRepos: [repoDecisions[1]!], + scoreBlockers: repoDecisions.flatMap((decision) => decision.scoreBlockers), + dataQuality: { + signalFidelity: { + status: "degraded", + repoCount: 3, + completeRepos: 2, + degradedRepos: 1, + blockedRepos: 0, + partialRepos: ["we-promise/sure"], + cappedRepos: [], + staleRepos: ["we-promise/sure"], + rateLimitedRepos: [], + }, + }, + summary: "oktofeesh1 has ranked actions.", + nextActions: ["Close, update, or land existing open PRs before opening more work."], + } as unknown as ContributorDecisionPack; + return { ...pack, ...overrides } as ContributorDecisionPack; +} + +function repoDecision(overrides: Partial): ContributorDecisionPack["repoDecisions"][number] { + return { + repoFullName: "owner/repo", + recommendation: "pursue", + priorityScore: 50, + lane: { + repoFullName: overrides.repoFullName ?? "owner/repo", + lane: "direct_pr", + issueDiscoveryShare: 0, + directPrShare: 0.01, + summary: "Direct PR lane.", + contributorGuidance: "Submit focused PRs.", + maintainerGuidance: "Review focused PRs.", + }, + roleContext: { + login: "oktofeesh1", + role: "outside_contributor", + repoFullName: overrides.repoFullName ?? "owner/repo", + generatedAt: nowIso(), + maintainerLane: false, + normalContributorEvidenceAllowed: true, + source: "gittensor_api", + reasons: [], + guidance: "Use contributor-lane guidance.", + }, + queue: { + openIssues: 3, + openPullRequests: 5, + mergedPullRequests: 2, + closedUnmergedPullRequests: 1, + }, + rewardUpside: { + emissionShare: 0.01, + directPrShare: 0.01, + issueDiscoveryShare: 0, + maintainerCut: 0, + }, + scoreBlockers: [], + riskReasons: [], + whyThisHelps: [`${overrides.repoFullName ?? "owner/repo"}: private reward estimate should stay private.`], + nextActions: ["Pick one narrow change and run branch preflight."], + ...overrides, + } as ContributorDecisionPack["repoDecisions"][number]; +} + +function action( + actionKind: ContributorDecisionPack["topActions"][number]["actionKind"], + repoFullName: string, + recommendation: ContributorDecisionPack["topActions"][number]["recommendation"], + priorityScore: number, +): ContributorDecisionPack["topActions"][number] { + return { + actionKind, + repoFullName, + priorityScore, + recommendation, + whyThisHelps: [`${repoFullName}: action improves private scoreability.`], + nextActions: ["Use Gittensory preflight before posting public PR context."], + }; +} + +async function seedLocalBranchData(env: Env): Promise { + const registry = normalizeRegistryPayload( + { + "entrius/allways-ui": { + emission_share: 0.011, + issue_discovery_share: 0, + label_multipliers: { bug: 1.1 }, + trusted_label_pipeline: true, + maintainer_cut: 0, + }, + }, + { kind: "raw-github", url: "fixture://registry" }, + "2026-05-28T00:00:00.000Z", + ); + await persistRegistrySnapshot(env, registry); + await upsertRepositoryFromGitHub(env, { + name: "allways-ui", + full_name: "entrius/allways-ui", + private: false, + default_branch: "main", + owner: { login: "entrius" }, + }); + await persistScoringModelSnapshot(env, { + id: "scoring-local", + sourceKind: "test", + sourceUrl: "fixture://scoring", + fetchedAt: "2026-05-28T00:00:00.000Z", + activeModel: "current_density_model", + constants: { + OSS_EMISSION_SHARE: 0.9, + MERGED_PR_BASE_SCORE: 25, + MIN_TOKEN_SCORE_FOR_BASE_SCORE: 5, + MAX_CODE_DENSITY_MULTIPLIER: 1.15, + MAX_CONTRIBUTION_BONUS: 25, + CONTRIBUTION_SCORE_FOR_FULL_BONUS: 1500, + STANDARD_ISSUE_MULTIPLIER: 1.33, + MAINTAINER_ISSUE_MULTIPLIER: 1.66, + MIN_CREDIBILITY: 0.8, + REVIEW_PENALTY_RATE: 0.15, + EXCESSIVE_PR_PENALTY_BASE_THRESHOLD: 2, + OPEN_PR_THRESHOLD_TOKEN_SCORE: 300, + MAX_OPEN_PR_THRESHOLD: 30, + OPEN_PR_COLLATERAL_PERCENT: 0.2, + SRC_TOK_SATURATION_SCALE: 58, + }, + programmingLanguages: { TypeScript: 1 }, + registrySnapshotId: registry.id, + warnings: [], + payload: {}, + }); + await upsertIssueFromGitHub(env, "entrius/allways-ui", { + number: 7, + title: "Dashboard cache refresh fails", + state: "open", + user: { login: "reporter" }, + labels: [{ name: "bug" }], + body: "Cache refresh fails after reconnect.", + }); + await upsertPullRequestFromGitHub(env, "entrius/allways-ui", { + number: 12, + title: "Fix dashboard cache refresh", + state: "open", + user: { login: "oktofeesh1" }, + author_association: "NONE", + head: { sha: "abc123", ref: "fix-cache" }, + base: { ref: "main" }, + labels: [{ name: "bug" }], + body: "Fixes #7", + }); + await upsertPullRequestFromGitHub(env, "entrius/allways-ui", { + number: 13, + title: "Alternative cache refresh", + state: "open", + user: { login: "other" }, + author_association: "NONE", + head: { sha: "def456", ref: "alt-cache" }, + base: { ref: "main" }, + labels: [{ name: "bug" }], + body: "Fixes #7", + }); + await upsertRecentMergedPullRequest(env, { + repoFullName: "entrius/allways-ui", + number: 3, + title: "Fix dashboard cache refresh after reconnect", + authorLogin: "oktofeesh1", + mergedAt: "2026-05-01T00:00:00.000Z", + labels: ["bug"], + linkedIssues: [7], + changedFiles: ["src/cache.ts"], + payload: {}, + }); +} + +function stubContributorFetch(): void { + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + const url = input.toString(); + if (url === "https://api.gittensor.io/miners") { + return Response.json([ + { + uid: 7, + githubUsername: "oktofeesh1", + githubId: "123", + totalPrs: 3, + totalMergedPrs: 2, + totalOpenPrs: 1, + totalClosedPrs: 0, + totalOpenIssues: 0, + totalClosedIssues: 0, + totalSolvedIssues: 0, + totalValidSolvedIssues: 0, + isEligible: true, + credibility: 0.9, + eligibleRepoCount: 1, + }, + ]); + } + if (url === "https://api.gittensor.io/miners/123") { + return Response.json({ + repositories: [ + { + repositoryFullName: "entrius/allways-ui", + totalPrs: "3", + totalMergedPrs: "2", + totalOpenPrs: "1", + totalClosedPrs: "0", + totalOpenIssues: "0", + totalClosedIssues: "0", + isEligible: true, + credibility: "0.900000", + }, + ], + }); + } + if (url === "https://api.gittensor.io/miners/123/prs") { + return Response.json([{ repository: "entrius/allways-ui", pullRequestNumber: 12, pullRequestTitle: "Fix dashboard cache refresh", prState: "OPEN", label: "bug" }]); + } + if (url === "https://mirror.gittensor.io/api/v1/miners/123/issues") { + return Response.json({ issues: [] }); + } + if (url.endsWith("/users/oktofeesh1")) return Response.json({ login: "oktofeesh1", public_repos: 12, followers: 3 }); + if (url.includes("/users/oktofeesh1/repos")) return Response.json([{ language: "TypeScript" }]); + return new Response("not found", { status: 404 }); + }); +} diff --git a/test/unit/backfill.test.ts b/test/unit/backfill.test.ts index 2211104849..7641a40ab8 100644 --- a/test/unit/backfill.test.ts +++ b/test/unit/backfill.test.ts @@ -304,7 +304,7 @@ describe("GitHub backfill", () => { account: { login: "JSONbored", id: 1, type: "User" }, repository_selection: "selected", permissions: { checks: "write", metadata: "read", pull_requests: "read", issues: "write" }, - events: ["issues", "pull_request", "repository"], + events: ["issues", "issue_comment", "pull_request", "repository"], }); } return new Response("not found", { status: 404 }); @@ -314,7 +314,7 @@ describe("GitHub backfill", () => { expect(result.installations[0]).toMatchObject({ status: "needs_attention", missingPermissions: ["pull_requests", "issues"], - missingEvents: ["issues", "repository"], + missingEvents: ["issues", "issue_comment", "repository"], repairSteps: expect.arrayContaining(["Update the GitHub App permissions and subscribed events."]), }); @@ -324,7 +324,7 @@ describe("GitHub backfill", () => { account: { login: "JSONbored", id: 1, type: "User" }, repository_selection: "selected", permissions: { checks: "write", metadata: "read", pull_requests: "read", issues: "write" }, - events: ["issues", "pull_request", "repository"], + events: ["issues", "issue_comment", "pull_request", "repository"], }, }); const refreshed = await refreshInstallationHealth(env); @@ -340,7 +340,7 @@ describe("GitHub backfill", () => { account: { login: "JSONbored", id: 1, type: "User" }, repository_selection: "selected", permissions: { metadata: "read", pull_requests: "read", issues: "write" }, - events: ["issues", "pull_request", "repository"], + events: ["issues", "issue_comment", "pull_request", "repository"], }, }); await upsertRepositoryFromGitHub(env, { name: "gittensory", full_name: "JSONbored/gittensory", private: true, owner: { login: "JSONbored" } }, 123); @@ -356,7 +356,7 @@ describe("GitHub backfill", () => { account: { login: "JSONbored", id: 1, type: "User" }, repository_selection: "selected", permissions: { metadata: "read", pull_requests: "read", issues: "write" }, - events: ["issues", "pull_request", "repository"], + events: ["issues", "issue_comment", "pull_request", "repository"], }); } return new Response("not found", { status: 404 }); @@ -397,7 +397,7 @@ describe("GitHub backfill", () => { target_type: "User", repository_selection: "selected", permissions: { checks: "write", metadata: "read", pull_requests: "read", issues: "write" }, - events: ["issues", "pull_request", "repository"], + events: ["issues", "issue_comment", "pull_request", "repository"], }); } return new Response("not found", { status: 404 }); @@ -425,7 +425,7 @@ describe("GitHub backfill", () => { account: { login: "JSONbored", id: 1, type: "User" }, repository_selection: "selected", permissions: { metadata: "read", pull_requests: "read", issues: "write" }, - events: ["issues", "pull_request", "repository"], + events: ["issues", "issue_comment", "pull_request", "repository"], }); } return new Response("not found", { status: 404 }); @@ -452,7 +452,7 @@ describe("GitHub backfill", () => { account: { login: "JSONbored", id: 1, type: "User" }, repository_selection: "selected", permissions: { metadata: "read", pull_requests: "read", issues: "write" }, - events: ["issues", "pull_request", "repository"], + events: ["issues", "issue_comment", "pull_request", "repository"], }, }); vi.stubGlobal("fetch", async () => new Response("installation unavailable", { status: 503 })); @@ -1545,7 +1545,7 @@ describe("GitHub backfill", () => { account: { login: "JSONbored", id: 1, type: "User" }, repository_selection: "selected", permissions: { metadata: "read", pull_requests: "read", issues: "write" }, - events: ["issues", "pull_request", "repository"], + events: ["issues", "issue_comment", "pull_request", "repository"], }, }); await upsertRepositoryFromGitHub( @@ -1593,7 +1593,7 @@ describe("GitHub backfill", () => { permissions: {}, events: [], missingPermissions: ["metadata", "pull_requests", "issues"], - missingEvents: ["issues", "pull_request", "repository"], + missingEvents: ["issues", "issue_comment", "pull_request", "repository"], }), ]), ); diff --git a/test/unit/github-commands.test.ts b/test/unit/github-commands.test.ts new file mode 100644 index 0000000000..20fbb4453c --- /dev/null +++ b/test/unit/github-commands.test.ts @@ -0,0 +1,263 @@ +import { describe, expect, it } from "vitest"; +import { + buildPublicAgentCommandComment, + isAuthorizedCommandActor, + parseGittensoryMentionCommand, + sanitizePublicComment, +} from "../../src/github/commands"; + +describe("GitHub mention commands", () => { + it("parses only explicit @gittensory commands", () => { + expect(parseGittensoryMentionCommand(null)).toBeNull(); + expect(parseGittensoryMentionCommand("@gittensory")?.name).toBe("help"); + expect(parseGittensoryMentionCommand("@gittensory preflight")?.name).toBe("preflight"); + expect(parseGittensoryMentionCommand("please @gittensory duplicate-check now")?.name).toBe("duplicate-check"); + expect(parseGittensoryMentionCommand("@gittensory unknown")?.name).toBe("help"); + expect(parseGittensoryMentionCommand("gittensory preflight")).toBeNull(); + }); + + it("authorizes maintainers and confirmed miner PR authors only", () => { + expect(isAuthorizedCommandActor({ commenterLogin: "reviewer", commenterAssociation: "OWNER" })).toMatchObject({ + authorized: true, + actorKind: "maintainer", + }); + expect( + isAuthorizedCommandActor({ + commenterLogin: "oktofeesh1", + commenterAssociation: "NONE", + pullRequestAuthorLogin: "oktofeesh1", + officialAuthorDetection: { status: "confirmed", snapshot: minerSnapshot() }, + }), + ).toMatchObject({ authorized: true, reason: "confirmed_miner_pr_author" }); + expect( + isAuthorizedCommandActor({ + commenterLogin: "oktofeesh1", + commenterAssociation: "NONE", + pullRequestAuthorLogin: "oktofeesh1", + officialAuthorDetection: { status: "unavailable", error: "api down" }, + }), + ).toMatchObject({ authorized: false, reason: "miner_detection_unavailable" }); + expect( + isAuthorizedCommandActor({ + commenterLogin: "oktofeesh1", + commenterAssociation: "NONE", + pullRequestAuthorLogin: "oktofeesh1", + officialAuthorDetection: { status: "not_found" }, + }), + ).toMatchObject({ authorized: false, reason: "pr_author_not_confirmed_miner" }); + expect( + isAuthorizedCommandActor({ + commenterLogin: "other", + commenterAssociation: "NONE", + pullRequestAuthorLogin: "oktofeesh1", + officialAuthorDetection: { status: "confirmed", snapshot: minerSnapshot() }, + }), + ).toMatchObject({ authorized: false, reason: "not_maintainer_or_pr_author" }); + }); + + it("keeps public comments sanitized", () => { + const command = parseGittensoryMentionCommand("@gittensory next-action")!; + const body = buildPublicAgentCommandComment({ + command, + repo: null, + issue: { number: 12, title: "PR", state: "open", pull_request: {} }, + pullRequest: null, + actorKind: "author", + officialMiner: minerSnapshot(), + bundle: { + run: { + id: "run-1", + objective: "plan", + actorLogin: "oktofeesh1", + surface: "github_comment", + mode: "copilot", + status: "completed", + dataQualityStatus: "complete", + payload: {}, + }, + actions: [ + { + id: "action-1", + runId: "run-1", + actionType: "choose_next_work", + status: "recommended", + recommendation: "private recommendation", + why: [], + blockedBy: ["estimated score and wallet should be hidden"], + publicSafeSummary: "Use a narrow PR packet; reward estimate should not leak.", + approvalRequired: true, + safetyClass: "private", + payload: {}, + }, + ], + contextSnapshots: [], + summary: "done", + }, + }); + expect(body).toContain(""); + expect(body).toContain("Scope: this repository#12"); + expect(body).not.toMatch(/wallet|hotkey|coldkey|estimated score|reward estimate|payout|farming|raw trust score/i); + expect(sanitizePublicComment("wallet hotkey payout")).not.toMatch(/wallet|hotkey|payout/i); + }); + + it("renders help, miner-context fallback, refresh, and empty-action responses", () => { + const help = buildPublicAgentCommandComment({ + command: parseGittensoryMentionCommand("@gittensory help")!, + repo: null, + issue: { number: 1, title: "PR", state: "open", pull_request: {} }, + pullRequest: null, + actorKind: "maintainer", + }); + expect(help).toContain("@gittensory duplicate-check"); + + const minerFallback = buildPublicAgentCommandComment({ + command: parseGittensoryMentionCommand("@gittensory miner-context")!, + repo: null, + issue: { number: 2, title: "PR", state: "open", pull_request: {} }, + pullRequest: null, + actorKind: "author", + officialMiner: null, + }); + expect(minerFallback).toContain("Official miner context is unavailable"); + + const minerContext = buildPublicAgentCommandComment({ + command: parseGittensoryMentionCommand("@gittensory miner-context")!, + repo: { fullName: "owner/repo" } as any, + issue: { number: 22, title: "PR", state: "open", pull_request: {} }, + pullRequest: null, + actorKind: "author", + officialMiner: minerSnapshot(), + }); + expect(minerContext).toContain("confirmed by the official Gittensor API"); + expect(minerContext).toContain("Scope: owner/repo#22"); + + const refresh = buildPublicAgentCommandComment({ + command: parseGittensoryMentionCommand("@gittensory blockers")!, + repo: null, + issue: { number: 3, title: "PR", state: "open", pull_request: {} }, + pullRequest: null, + actorKind: "author", + bundle: { + run: { + id: "run-refresh", + objective: "refresh", + actorLogin: "oktofeesh1", + surface: "github_comment", + mode: "copilot", + status: "needs_snapshot_refresh", + dataQualityStatus: "unknown", + payload: {}, + }, + actions: [], + contextSnapshots: [], + summary: "refresh", + }, + }); + expect(refresh).toContain("refreshing the contributor decision snapshot"); + + const empty = buildPublicAgentCommandComment({ + command: parseGittensoryMentionCommand("@gittensory next-action")!, + repo: null, + issue: { number: 4, title: "PR", state: "open", pull_request: {} }, + pullRequest: null, + actorKind: "author", + bundle: { + run: { + id: "run-empty", + objective: "empty", + actorLogin: "oktofeesh1", + surface: "github_comment", + mode: "copilot", + status: "completed", + dataQualityStatus: "complete", + payload: {}, + }, + actions: [], + contextSnapshots: [], + summary: "empty", + }, + }); + expect(empty).toContain("No public-safe action is available"); + + const noBundle = buildPublicAgentCommandComment({ + command: parseGittensoryMentionCommand("@gittensory preflight")!, + repo: null, + issue: { number: 44, title: "PR", state: "open", pull_request: {} }, + pullRequest: null, + actorKind: "author", + }); + expect(noBundle).toContain("No public-safe action is available"); + + const withPrFallbackScope = buildPublicAgentCommandComment({ + command: parseGittensoryMentionCommand("@gittensory next-action")!, + repo: null, + issue: { number: 5, title: "PR", state: "open", pull_request: {} }, + pullRequest: { repoFullName: "owner/from-pr" } as any, + actorKind: "author", + bundle: { + run: { + id: "run-action", + objective: "action", + actorLogin: "oktofeesh1", + surface: "github_comment", + mode: "copilot", + status: "completed", + dataQualityStatus: "complete", + payload: {}, + }, + actions: [ + { + id: "action", + runId: "run-action", + actionType: "choose_next_work", + status: "recommended", + recommendation: "recommendation", + why: [], + blockedBy: [], + publicSafeSummary: "Run local branch preflight first.", + rerunWhen: "After tests pass.", + approvalRequired: true, + safetyClass: "private", + payload: {}, + }, + ], + contextSnapshots: [], + summary: "done", + }, + }); + expect(withPrFallbackScope).toContain("Scope: owner/from-pr#5"); + expect(withPrFallbackScope).toContain("After tests pass."); + }); +}); + +function minerSnapshot() { + return { + source: "gittensor_api" as const, + githubId: "123", + githubUsername: "oktofeesh1", + isEligible: true, + credibility: 1, + eligibleRepoCount: 1, + issueDiscoveryScore: 0, + issueTokenScore: 0, + issueCredibility: 1, + isIssueEligible: false, + issueEligibleRepoCount: 0, + alphaPerDay: 0, + taoPerDay: 0, + usdPerDay: 0, + totals: { + pullRequests: 3, + mergedPullRequests: 2, + openPullRequests: 1, + closedPullRequests: 0, + openIssues: 0, + closedIssues: 0, + solvedIssues: 0, + validSolvedIssues: 0, + }, + repositories: [], + pullRequests: [], + issueLabels: [], + }; +} diff --git a/test/unit/mcp-cli.test.ts b/test/unit/mcp-cli.test.ts index 9dcc5436d0..cc0d625254 100644 --- a/test/unit/mcp-cli.test.ts +++ b/test/unit/mcp-cli.test.ts @@ -62,15 +62,38 @@ describe("gittensory-mcp CLI", () => { }), ) as { package: { name: string; version: string; latestStatus: string }; api: { status: string }; auth: { login: string } }; - expect(status.package).toMatchObject({ name: "@jsonbored/gittensory-mcp", version: "0.1.4", latestStatus: "skipped" }); + expect(status.package).toMatchObject({ name: "@jsonbored/gittensory-mcp", version: "0.2.0", latestStatus: "skipped" }); expect(status.api.status).toBe("ok"); expect(status.auth.login).toBe("JSONbored"); const changelog = JSON.parse(run(["changelog", "--json"])) as { package: { version: string }; changelog: string }; - expect(changelog.package.version).toBe("0.1.4"); + expect(changelog.package.version).toBe("0.2.0"); expect(changelog.changelog).toContain("# Changelog"); }); + it("runs base-agent CLI commands against API fixtures", async () => { + tempDir = mkdtempSync(join(tmpdir(), "gittensory-cli-")); + const url = await startFixtureServer(); + const env = { + GITTENSORY_API_URL: url, + GITTENSORY_TOKEN: "session-token", + GITTENSORY_CONFIG_DIR: tempDir, + }; + + const plan = JSON.parse(await runAsync(["agent", "plan", "--login", "JSONbored", "--repo", "JSONbored/gittensory", "--json"], env)) as { + run: { id: string; status: string }; + actions: Array<{ actionType: string }>; + }; + expect(plan.run).toMatchObject({ id: "run-1", status: "completed" }); + expect(plan.actions[0]).toMatchObject({ actionType: "choose_next_work" }); + + const statusPayload = JSON.parse(await runAsync(["agent", "status", "run-1", "--json"], env)) as { run: { id: string } }; + expect(statusPayload.run.id).toBe("run-1"); + + const explain = JSON.parse(await runAsync(["agent", "explain", "run-1", "--json"], env)) as { topAction: { actionType: string } }; + expect(explain.topAction.actionType).toBe("choose_next_work"); + }); + it("rejects unsupported client snippets", () => { expect(() => run(["init-client", "--print", "other"])).toThrow(/Unsupported client/); }); @@ -125,6 +148,14 @@ async function startFixtureServer() { response.end(JSON.stringify({ status: "authenticated", login: "JSONbored", expiresAt: "2026-06-02T00:00:00.000Z", scopes: ["read:user"] })); return; } + if (request.url === "/v1/agent/plan-next-work" && request.method === "POST") { + response.end(JSON.stringify(agentFixture())); + return; + } + if (request.url === "/v1/agent/runs/run-1" && request.method === "GET") { + response.end(JSON.stringify(agentFixture())); + return; + } response.statusCode = 404; response.end(JSON.stringify({ error: "not_found" })); }); @@ -133,3 +164,35 @@ async function startFixtureServer() { if (!address || typeof address === "string") throw new Error("fixture server did not bind a TCP port"); return `http://127.0.0.1:${address.port}`; } + +function agentFixture() { + return { + run: { + id: "run-1", + objective: "plan", + actorLogin: "JSONbored", + surface: "mcp", + mode: "copilot", + status: "completed", + dataQualityStatus: "complete", + payload: {}, + }, + actions: [ + { + id: "action-1", + runId: "run-1", + actionType: "choose_next_work", + status: "recommended", + recommendation: "Pick narrow work and run branch preflight.", + why: ["Fixture"], + blockedBy: [], + publicSafeSummary: "Fixture public summary.", + approvalRequired: true, + safetyClass: "private", + payload: {}, + }, + ], + contextSnapshots: [], + summary: "fixture", + }; +} diff --git a/test/unit/openapi.test.ts b/test/unit/openapi.test.ts index 74deeda1d2..3b6177907e 100644 --- a/test/unit/openapi.test.ts +++ b/test/unit/openapi.test.ts @@ -20,6 +20,12 @@ describe("OpenAPI contract", () => { expect(spec.paths["/v1/preflight/pr"]).toBeDefined(); expect(spec.paths["/v1/preflight/local-diff"]).toBeDefined(); expect(spec.paths["/v1/local/branch-analysis"]).toBeDefined(); + expect(spec.paths["/v1/agent/runs"]).toBeDefined(); + expect(spec.paths["/v1/agent/runs/{id}"]).toBeDefined(); + expect(spec.paths["/v1/agent/plan-next-work"]).toBeDefined(); + expect(spec.paths["/v1/agent/preflight-branch"]).toBeDefined(); + expect(spec.paths["/v1/agent/prepare-pr-packet"]).toBeDefined(); + expect(spec.paths["/v1/agent/explain-blockers"]).toBeDefined(); expect(spec.paths["/v1/scoring/model"]).toBeDefined(); expect(spec.paths["/v1/scoring/preview"]).toBeDefined(); expect(spec.paths["/v1/bounties/{id}/advisory"]).toBeDefined(); @@ -62,6 +68,8 @@ describe("OpenAPI contract", () => { expect(spec.components?.schemas?.PullRequestMaintainerPacket).toBeDefined(); expect(spec.components?.schemas?.PullRequestReviewability).toBeDefined(); expect(spec.components?.schemas?.LocalBranchAnalysis).toBeDefined(); + expect(spec.components?.schemas?.AgentRunBundle).toBeDefined(); + expect(spec.components?.schemas?.AgentAction).toBeDefined(); expect(JSON.stringify(spec.components?.schemas?.ScorePreviewResult)).toContain("scenarioPreviews"); expect(JSON.stringify(spec.components?.schemas?.LocalBranchAnalysis)).toContain("baseFreshness"); expect(JSON.stringify(spec.components?.schemas?.LocalBranchAnalysis)).toContain("recommendedRerunCondition"); diff --git a/test/unit/queue.test.ts b/test/unit/queue.test.ts index 8c46b42cb6..2ab7263b2d 100644 --- a/test/unit/queue.test.ts +++ b/test/unit/queue.test.ts @@ -1,7 +1,9 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { listCollisionEdges, + createAgentRun, getContributorEvidence, + getAgentRun, getContributorScoringProfile, listInstallationHealth, listPullRequests, @@ -99,6 +101,34 @@ describe("queue processors", () => { expect(await getContributorScoringProfile(env, "oktofeesh1")).toMatchObject({ login: "oktofeesh1" }); }); + it("runs queued agent jobs through the queue processor", async () => { + const queued: unknown[] = []; + const env = createTestEnv({ + JOBS: { + async send(message: unknown) { + queued.push(message); + }, + } as unknown as Queue, + }); + await createAgentRun(env, { + id: "agent-run-queue", + objective: "Plan next work", + actorLogin: "oktofeesh1", + surface: "api", + mode: "copilot", + status: "queued", + dataQualityStatus: "unknown", + payload: { kind: "plan_next_work", login: "oktofeesh1" }, + createdAt: "2026-05-25T00:00:00.000Z", + updatedAt: "2026-05-25T00:00:00.000Z", + }); + + await processJob(env, { type: "run-agent", requestedBy: "api", runId: "agent-run-queue" }); + + await expect(getAgentRun(env, "agent-run-queue")).resolves.toMatchObject({ status: "needs_snapshot_refresh" }); + expect(queued).toContainEqual({ type: "build-contributor-decision-packs", requestedBy: "api", login: "oktofeesh1" }); + }); + it("fans out all-repo backfill jobs into repo-scoped queue messages", async () => { const sent: import("../../src/types").JobMessage[] = []; const env = createTestEnv({ @@ -385,7 +415,7 @@ describe("queue processors", () => { account: { login: "JSONbored", id: 1, type: "User" }, repository_selection: "selected", permissions: { metadata: "read", pull_requests: "read", issues: "write" }, - events: ["issues", "pull_request", "repository"], + events: ["issues", "issue_comment", "pull_request", "repository"], }, repositories: [{ name: "gittensory", full_name: "JSONbored/gittensory", private: true, owner: { login: "JSONbored" } }], }); @@ -398,7 +428,7 @@ describe("queue processors", () => { account: { login: "JSONbored", id: 1, type: "User" }, repository_selection: "selected", permissions: { metadata: "read", pull_requests: "read", issues: "write" }, - events: ["issues", "pull_request", "repository"], + events: ["issues", "issue_comment", "pull_request", "repository"], }); } return new Response("not found", { status: 404 }); @@ -510,7 +540,7 @@ describe("queue processors", () => { account: { login: "JSONbored", id: 1, type: "User" }, repository_selection: "selected", permissions: { metadata: "read", pull_requests: "read", issues: "write" }, - events: ["issues", "pull_request", "repository"], + events: ["issues", "issue_comment", "pull_request", "repository"], }, repository: { name: "gittensory", full_name: "JSONbored/gittensory", private: true, owner: { login: "JSONbored" } }, }; @@ -908,6 +938,205 @@ describe("queue processors", () => { ]), ); }); + + it("responds to authorized @gittensory mention commands with one public-safe comment", async () => { + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem() }); + await upsertPullRequestFromGitHub(env, "JSONbored/gittensory", { + number: 77, + title: "Miner command context", + state: "open", + user: { login: "oktofeesh1" }, + author_association: "NONE", + labels: [], + body: "Fixes #1", + }); + const calls = { commentsCreated: 0, token: 0, minerList: 0 }; + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + const method = init?.method ?? "GET"; + if (url === "https://api.gittensor.io/miners") { + calls.minerList += 1; + return Response.json([{ githubUsername: "oktofeesh1", githubId: "123", totalPrs: 3, totalMergedPrs: 2, isEligible: true, credibility: 1 }]); + } + if (url === "https://api.gittensor.io/miners/123") return Response.json({ repositories: [] }); + if (url === "https://api.gittensor.io/miners/123/prs") return Response.json([]); + if (url === "https://mirror.gittensor.io/api/v1/miners/123/issues") return Response.json({ issues: [] }); + if (url.includes("/access_tokens")) { + calls.token += 1; + return Response.json({ token: "installation-token" }); + } + if (url.includes("/issues/77/comments") && method === "GET") return Response.json([]); + if (url.includes("/issues/77/comments") && method === "POST") { + calls.commentsCreated += 1; + const body = JSON.parse(String(init?.body ?? "{}")) as { body?: string }; + expect(body.body).toContain(""); + expect(body.body).toContain("@gittensory"); + expect(body.body).not.toMatch(/wallet|hotkey|estimated score|reward estimate|payout|farming|raw trust score/i); + return Response.json({ id: 1001 }, { status: 201 }); + } + return new Response("not found", { status: 404 }); + }); + + await processJob(env, { + type: "github-webhook", + deliveryId: "agent-command-miner-context", + eventName: "issue_comment", + payload: { + action: "created", + installation: { id: 123, account: { login: "JSONbored", id: 1, type: "User" } }, + repository: { name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }, + issue: { number: 77, title: "Miner command context", state: "open", pull_request: {}, user: { login: "oktofeesh1" }, author_association: "NONE" }, + comment: { + id: 1, + body: "@gittensory miner-context", + user: { login: "maintainer", type: "User" }, + author_association: "OWNER", + }, + }, + }); + await processJob(env, { + type: "github-webhook", + deliveryId: "agent-command-blockers", + eventName: "issue_comment", + payload: { + action: "created", + installation: { id: 123, account: { login: "JSONbored", id: 1, type: "User" } }, + repository: { name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }, + issue: { number: 77, title: "Miner command context", state: "open", pull_request: {}, user: { login: "oktofeesh1" }, author_association: "NONE" }, + comment: { + id: 2, + body: "@gittensory blockers", + user: { login: "maintainer", type: "User" }, + author_association: "OWNER", + }, + }, + }); + await processJob(env, { + type: "github-webhook", + deliveryId: "agent-command-help", + eventName: "issue_comment", + payload: { + action: "created", + installation: { id: 123, account: { login: "JSONbored", id: 1, type: "User" } }, + repository: { name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }, + issue: { number: 77, title: "Miner command context", state: "open", pull_request: {}, user: { login: "oktofeesh1" }, author_association: "NONE" }, + comment: { + id: 3, + body: "@gittensory help", + user: { login: "maintainer", type: "User" }, + author_association: "OWNER", + }, + }, + }); + await processJob(env, { + type: "github-webhook", + deliveryId: "agent-command-author-next-action", + eventName: "issue_comment", + payload: { + action: "created", + installation: { id: 123, account: { login: "JSONbored", id: 1, type: "User" } }, + repository: { name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }, + issue: { number: 77, title: "Miner command context", state: "open", pull_request: {}, user: { login: "oktofeesh1" }, author_association: "NONE" }, + comment: { + id: 4, + body: "@gittensory next-action", + user: { login: "oktofeesh1", type: "User" }, + author_association: "NONE", + }, + }, + }); + + expect(calls).toEqual({ commentsCreated: 4, token: 4, minerList: 4 }); + const audit = await env.DB.prepare("select event_type, detail from audit_events where target_key = ? order by created_at") + .bind("JSONbored/gittensory#77") + .all<{ event_type: string; detail: string | null }>(); + expect(audit.results).toEqual(expect.arrayContaining([expect.objectContaining({ event_type: "github_app.agent_command_replied" })])); + }); + + it("skips unauthorized, bot, and non-PR @gittensory mention commands without public output", async () => { + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem() }); + let commentCalls = 0; + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + const url = input.toString(); + if (url === "https://api.gittensor.io/miners") return Response.json([]); + if (url.includes("/issues/")) { + commentCalls += 1; + return Response.json([]); + } + return new Response("not found", { status: 404 }); + }); + const basePayload = { + action: "created", + installation: { id: 123, account: { login: "JSONbored", id: 1, type: "User" } }, + repository: { name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }, + }; + await processJob(env, { + type: "github-webhook", + deliveryId: "agent-command-none", + eventName: "issue_comment", + payload: { + ...basePayload, + issue: { number: 79, title: "No command", state: "open", pull_request: {}, user: { login: "reporter" } }, + comment: { id: 0, body: "plain comment", user: { login: "reporter", type: "User" }, author_association: "NONE" }, + }, + }); + await processJob(env, { + type: "github-webhook", + deliveryId: "agent-command-missing-fields", + eventName: "issue_comment", + payload: { + action: "created", + comment: { id: 9, body: "@gittensory preflight", user: { login: "reporter", type: "User" }, author_association: "NONE" }, + }, + }); + + await processJob(env, { + type: "github-webhook", + deliveryId: "agent-command-non-pr", + eventName: "issue_comment", + payload: { + ...basePayload, + issue: { number: 80, title: "Plain issue", state: "open", user: { login: "reporter" } }, + comment: { id: 1, body: "@gittensory preflight", user: { login: "reporter", type: "User" }, author_association: "NONE" }, + }, + }); + await processJob(env, { + type: "github-webhook", + deliveryId: "agent-command-bot", + eventName: "issue_comment", + payload: { + ...basePayload, + issue: { number: 81, title: "Bot PR", state: "open", pull_request: {}, user: { login: "renovate[bot]" } }, + comment: { id: 2, body: "@gittensory preflight", user: { login: "renovate[bot]", type: "Bot" }, author_association: "NONE" }, + }, + }); + await processJob(env, { + type: "github-webhook", + deliveryId: "agent-command-unauthorized", + eventName: "issue_comment", + payload: { + ...basePayload, + issue: { number: 82, title: "Unauthorized PR", state: "open", pull_request: {}, user: { login: "not-a-miner" }, author_association: "NONE" }, + comment: { id: 3, body: "@gittensory preflight", user: { login: "not-a-miner", type: "User" }, author_association: "NONE" }, + }, + }); + await processJob(env, { + type: "github-webhook", + deliveryId: "agent-command-no-pr-author", + eventName: "issue_comment", + payload: { + ...basePayload, + issue: { number: 83, title: "Unknown author PR", state: "open", pull_request: {}, author_association: "NONE" }, + comment: { id: 4, body: "@gittensory preflight", user: { login: "commenter", type: "User" } }, + }, + }); + + expect(commentCalls).toBe(0); + const skips = await env.DB.prepare("select detail from audit_events where event_type = ? order by detail") + .bind("github_app.agent_command_skipped") + .all<{ detail: string }>(); + expect(skips.results.map((entry) => entry.detail)).toEqual(expect.arrayContaining(["bot_author", "not_a_pull_request_thread", "pr_author_not_confirmed_miner"])); + }); }); function completeSegment(repoFullName: string, segment: "labels" | "open_issues" | "open_pull_requests") { From d9541cdc50a4f4b09224e58110206a63e2aa6b9a Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Thu, 28 May 2026 05:34:42 -0700 Subject: [PATCH 2/2] feat(ai): add optional workers ai summaries --- CHANGELOG.md | 2 + README.md | 8 + migrations/0011_workers_ai_usage.sql | 18 ++ packages/gittensory-mcp/CHANGELOG.md | 2 + packages/gittensory-mcp/bin/gittensory-mcp.js | 2 +- site/guide/github-app-setup.md | 2 +- site/guide/mcp.md | 2 + site/reference/api.md | 2 +- site/security/privacy.md | 4 + site/troubleshooting.md | 2 +- src/db/repositories.ts | 40 +++- src/db/schema.ts | 20 ++ src/env.d.ts | 7 + src/services/agent-orchestrator.ts | 18 +- src/services/ai-summaries.ts | 212 ++++++++++++++++++ test/helpers/d1.ts | 2 +- test/integration/routes-errors.test.ts | 4 +- test/unit/agent-orchestrator.test.ts | 18 ++ test/unit/ai-summaries.test.ts | 212 ++++++++++++++++++ vitest.workers.config.ts | 2 +- worker-configuration.d.ts | 13 +- wrangler.jsonc | 17 +- wrangler.vitest.jsonc | 30 +++ 23 files changed, 624 insertions(+), 15 deletions(-) create mode 100644 migrations/0011_workers_ai_usage.sql create mode 100644 src/services/ai-summaries.ts create mode 100644 test/unit/ai-summaries.test.ts create mode 100644 wrangler.vitest.jsonc diff --git a/CHANGELOG.md b/CHANGELOG.md index 70cb3b413b..d88e5d5ccd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -51,6 +51,8 @@ - Add deterministic base-agent orchestrator +- Add optional workers ai summaries + ### Fixes diff --git a/README.md b/README.md index 9c24549664..d62436a912 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,12 @@ Gittensory is not a Gittensor frontend, not a public leaderboard, and not an aut - GitHub App: quiet PR inspection, public-safe sticky comments, maintainer-configured labels, and explicit `@gittensory` commands on installed repos. - Docs site: VitePress under `site/`, deployed at `https://gittensory.aethereal.dev/`. +## AI Posture + +Gittensory's core decisions are deterministic. MCP users normally bring their own AI harness through Codex, Claude, Cursor, or another MCP client, while Gittensory supplies the structured Gittensor context those agents need. + +Cloudflare Workers AI is optional and disabled by default. When enabled, it can rewrite compact deterministic signal bundles into clearer prose, but it never receives source contents and all public output still passes through Gittensory's sanitizer. + ## MCP Install Public npm: @@ -101,6 +107,8 @@ wrangler secret put INTERNAL_JOB_TOKEN `GITHUB_PUBLIC_TOKEN` is a server-side token used to raise public GitHub API rate limits during registered-repo backfill. It is not a contributor token. +The production API origin is `https://api.gittensory.aethereal.dev`. The `workers.dev` deployment URL is treated as an internal fallback, not the public integration target. + ## Canonical API Protected endpoints use `Authorization: Bearer ` or a Gittensory OAuth session where supported. diff --git a/migrations/0011_workers_ai_usage.sql b/migrations/0011_workers_ai_usage.sql new file mode 100644 index 0000000000..f541a1429a --- /dev/null +++ b/migrations/0011_workers_ai_usage.sql @@ -0,0 +1,18 @@ +CREATE TABLE IF NOT EXISTS ai_usage_events ( + id TEXT PRIMARY KEY, + feature TEXT NOT NULL, + actor TEXT, + route TEXT, + model TEXT NOT NULL, + status TEXT NOT NULL, + estimated_neurons INTEGER NOT NULL DEFAULT 0, + detail TEXT, + metadata_json TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS ai_usage_events_feature_created_idx + ON ai_usage_events(feature, created_at); + +CREATE INDEX IF NOT EXISTS ai_usage_events_actor_created_idx + ON ai_usage_events(actor, created_at); diff --git a/packages/gittensory-mcp/CHANGELOG.md b/packages/gittensory-mcp/CHANGELOG.md index 62806de8ee..4018b719bd 100644 --- a/packages/gittensory-mcp/CHANGELOG.md +++ b/packages/gittensory-mcp/CHANGELOG.md @@ -7,6 +7,8 @@ - Add deterministic base-agent orchestrator +- Add optional workers ai summaries + ### Fixes diff --git a/packages/gittensory-mcp/bin/gittensory-mcp.js b/packages/gittensory-mcp/bin/gittensory-mcp.js index 1f7c4ea559..969ebdc20f 100755 --- a/packages/gittensory-mcp/bin/gittensory-mcp.js +++ b/packages/gittensory-mcp/bin/gittensory-mcp.js @@ -7,7 +7,7 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js" import { z } from "zod"; import { buildBranchAnalysisPayload, collectLocalDiff, collectLocalBranchMetadata, setupGuidanceForLocalScorer } from "../lib/local-branch.js"; -const defaultApiUrl = "https://gittensory-api.zeronode.workers.dev"; +const defaultApiUrl = "https://api.gittensory.aethereal.dev"; const packageName = "@jsonbored/gittensory-mcp"; const packageVersion = "0.2.0"; const changelogPath = new URL("../CHANGELOG.md", import.meta.url); diff --git a/site/guide/github-app-setup.md b/site/guide/github-app-setup.md index 9b967643de..1fa0808ef9 100644 --- a/site/guide/github-app-setup.md +++ b/site/guide/github-app-setup.md @@ -7,7 +7,7 @@ The GitHub App is the maintainer surface. GitHub OAuth is the MCP user-auth surf | Field | Value | | --- | --- | | Homepage URL | `https://gittensory.aethereal.dev` | -| Webhook URL | your Gittensory API origin plus `/v1/github/webhook` | +| Webhook URL | `https://api.gittensory.aethereal.dev/v1/github/webhook` | | Webhook active | Enabled | | SSL verification | Enabled | | Install target | Current account or selected organizations | diff --git a/site/guide/mcp.md b/site/guide/mcp.md index 20fa0e0ab4..2cd2b29101 100644 --- a/site/guide/mcp.md +++ b/site/guide/mcp.md @@ -2,6 +2,8 @@ The MCP package is the contributor-facing surface and the first base-agent interface. Your coding agent can ask Gittensory what the current branch means in Gittensor terms, what to do next, and how to draft a public-safe packet before you open or update a PR. +Your local agent supplies the LLM. Gittensory supplies deterministic Gittensor context: lane fit, score blockers, queue pressure, role context, and public-safe packets. Hosted Workers AI summaries are optional backend polish, not the source of truth. + ## What Agents Can Ask - what repo lane applies: direct PR, issue discovery, split, inactive, or unknown diff --git a/site/reference/api.md b/site/reference/api.md index 4ca8a549e8..6b39d6a014 100644 --- a/site/reference/api.md +++ b/site/reference/api.md @@ -5,7 +5,7 @@ The REST API is bearer-token protected except for `/health`, GitHub webhook deli Use the live OpenAPI document when authenticated: ```sh -export GITTENSORY_API_URL="https://your-gittensory-api-origin.example" +export GITTENSORY_API_URL="https://api.gittensory.aethereal.dev" curl "$GITTENSORY_API_URL/openapi.json" \ -H "Authorization: Bearer $GITTENSORY_API_TOKEN" ``` diff --git a/site/security/privacy.md b/site/security/privacy.md index 149de5ab37..a095acc86a 100644 --- a/site/security/privacy.md +++ b/site/security/privacy.md @@ -25,6 +25,10 @@ Local MCP branch analysis sends metadata only: Source contents are not uploaded in v1. `GITTENSORY_UPLOAD_SOURCE=true` fails closed. +## Optional Workers AI + +Gittensory's scoring and recommendations are deterministic. Cloudflare Workers AI can be enabled later as a quota-gated summarization layer, but it receives only compact signal metadata, not source code. If the AI layer is disabled, unavailable, or over budget, Gittensory falls back to deterministic templates. + ## Public Output Boundaries Public comments and public-safe PR packets must not include: diff --git a/site/troubleshooting.md b/site/troubleshooting.md index fc55d19cd8..70515a30bd 100644 --- a/site/troubleshooting.md +++ b/site/troubleshooting.md @@ -52,7 +52,7 @@ Gittensory rejects source upload mode in v1. Check the installation health endpoint: ```sh -export GITTENSORY_API_URL="https://your-gittensory-api-origin.example" +export GITTENSORY_API_URL="https://api.gittensory.aethereal.dev" curl "$GITTENSORY_API_URL/v1/installations/INSTALLATION_ID/health" \ -H "Authorization: Bearer $GITTENSORY_API_TOKEN" ``` diff --git a/src/db/repositories.ts b/src/db/repositories.ts index dd5195274b..68b27837b2 100644 --- a/src/db/repositories.ts +++ b/src/db/repositories.ts @@ -1,7 +1,8 @@ -import { and, desc, eq, not, sql } from "drizzle-orm"; +import { and, desc, eq, gte, not, sql } from "drizzle-orm"; import { getDb } from "./client"; import { advisories, + aiUsageEvents, agentActions, agentContextSnapshots, agentRuns, @@ -734,6 +735,43 @@ export async function recordAuditEvent(env: Env, event: AuditEventRecord): Promi }); } +export async function recordAiUsageEvent( + env: Env, + event: { + feature: string; + actor?: string | null | undefined; + route?: string | null | undefined; + model: string; + status: string; + estimatedNeurons: number; + detail?: string | null | undefined; + metadata?: Record | undefined; + }, +): Promise { + const db = getDb(env.DB); + await db.insert(aiUsageEvents).values({ + id: crypto.randomUUID(), + feature: event.feature, + actor: event.actor ?? null, + route: event.route ?? null, + model: event.model, + status: event.status, + estimatedNeurons: Math.max(0, Math.round(event.estimatedNeurons)), + detail: event.detail ?? null, + metadataJson: jsonString(event.metadata ?? {}), + createdAt: nowIso(), + }); +} + +export async function sumAiEstimatedNeuronsSince(env: Env, sinceIso: string): Promise { + const db = getDb(env.DB); + const [row] = await db + .select({ total: sql`coalesce(sum(${aiUsageEvents.estimatedNeurons}), 0)` }) + .from(aiUsageEvents) + .where(and(gte(aiUsageEvents.createdAt, sinceIso), eq(aiUsageEvents.status, "ok"))); + return Number(row?.total ?? 0); +} + export async function upsertContributorScoringProfile(env: Env, profile: ContributorScoringProfileRecord): Promise { const db = getDb(env.DB); await db diff --git a/src/db/schema.ts b/src/db/schema.ts index a547907d53..6838023967 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -652,3 +652,23 @@ export const auditEvents = sqliteTable( routeCreated: index("audit_events_route_created_idx").on(table.route, table.createdAt), }), ); + +export const aiUsageEvents = sqliteTable( + "ai_usage_events", + { + id: text("id").primaryKey(), + feature: text("feature").notNull(), + actor: text("actor"), + route: text("route"), + model: text("model").notNull(), + status: text("status").notNull(), + estimatedNeurons: integer("estimated_neurons").notNull().default(0), + detail: text("detail"), + metadataJson: text("metadata_json").notNull().default("{}"), + createdAt: text("created_at").notNull().default("CURRENT_TIMESTAMP"), + }, + (table) => ({ + featureCreated: index("ai_usage_events_feature_created_idx").on(table.feature, table.createdAt), + actorCreated: index("ai_usage_events_actor_created_idx").on(table.actor, table.createdAt), + }), +); diff --git a/src/env.d.ts b/src/env.d.ts index f7804cde42..67f9a8e2d0 100644 --- a/src/env.d.ts +++ b/src/env.d.ts @@ -3,7 +3,14 @@ declare global { DB: D1Database; JOBS: Queue; RATE_LIMITER?: DurableObjectNamespace; + AI?: Ai; PUBLIC_API_ORIGIN?: string; + AI_SUMMARIES_ENABLED?: string; + AI_PUBLIC_COMMENTS_ENABLED?: string; + WORKERS_AI_SUMMARY_MODEL?: string; + AI_DAILY_NEURON_BUDGET?: string; + AI_MAX_OUTPUT_TOKENS?: string; + ADMIN_GITHUB_LOGINS?: string; GITHUB_WEBHOOK_SECRET: string; GITHUB_APP_PRIVATE_KEY: string; GITHUB_APP_ID: string; diff --git a/src/services/agent-orchestrator.ts b/src/services/agent-orchestrator.ts index 7a9f87723d..78f9f7ad31 100644 --- a/src/services/agent-orchestrator.ts +++ b/src/services/agent-orchestrator.ts @@ -21,6 +21,7 @@ import { contributorRepoStatsFromGittensor, fetchGittensorContributorSnapshot } import { fetchPublicContributorProfile } from "../github/public"; import { getOrCreateScoringModelSnapshot } from "../scoring/model"; import { loadFreshContributorDecisionPack, repoDecisionFromPack, type ContributorDecisionPack, type DecisionAction, type RepoDecision } from "./decision-pack"; +import { summarizeAgentBundleWithAi } from "./ai-summaries"; import { buildContributorFit, buildContributorOutcomeHistory, buildContributorProfile, buildContributorScoringProfile } from "../signals/engine"; import { buildLocalBranchAnalysis, type LocalBranchAnalysis, type LocalBranchAnalysisInput } from "../signals/local-branch"; import type { @@ -162,14 +163,15 @@ export async function executeAgentRun(env: Env, runId: string): Promise { + const summary = await summarizeAgentBundleWithAi(env, bundle, "private"); + if (summary.status === "disabled" || summary.status === "unavailable") return bundle; + await updateAgentRun(env, bundle.run.id, { + payload: { + ...bundle.run.payload, + aiSummary: summary as unknown as JsonValue, + }, + }); + return (await getAgentRunBundle(env, bundle.run.id)) ?? bundle; +} + async function executeDecisionPackRun(env: Env, run: AgentRunRecord, kind: string): Promise { const login = String(run.payload.login ?? run.actorLogin); const repoFullName = typeof run.payload.repoFullName === "string" ? run.payload.repoFullName : undefined; diff --git a/src/services/ai-summaries.ts b/src/services/ai-summaries.ts new file mode 100644 index 0000000000..cce2ce06d2 --- /dev/null +++ b/src/services/ai-summaries.ts @@ -0,0 +1,212 @@ +import { recordAiUsageEvent, recordAuditEvent, sumAiEstimatedNeuronsSince } from "../db/repositories"; +import type { JsonValue } from "../types"; +import type { AgentRunBundle } from "./agent-orchestrator"; + +type AiSummaryVisibility = "private" | "public"; + +export type AiSummaryResult = + | { status: "disabled"; reason: string } + | { status: "unavailable"; reason: string } + | { status: "quota_exceeded"; model: string; estimatedNeurons: number; remainingBudget: number } + | { status: "unsafe"; model: string; estimatedNeurons: number; reason: string } + | { status: "error"; model: string; estimatedNeurons: number; reason: string } + | { status: "ok"; model: string; estimatedNeurons: number; text: string }; + +export async function summarizeAgentBundleWithAi(env: Env, bundle: AgentRunBundle, visibility: AiSummaryVisibility): Promise { + const privateEnabled = isEnabled(env.AI_SUMMARIES_ENABLED); + const publicEnabled = isEnabled(env.AI_PUBLIC_COMMENTS_ENABLED); + if (!privateEnabled) return { status: "disabled", reason: "AI summaries are disabled." }; + if (visibility === "public" && !publicEnabled) return { status: "disabled", reason: "Public AI summaries are disabled." }; + if (!env.AI) return { status: "unavailable", reason: "Workers AI binding is not configured." }; + + const model = env.WORKERS_AI_SUMMARY_MODEL || "@cf/meta/llama-3.1-8b-instruct-fp8-fast"; + const maxOutputTokens = clampNumber(Number(env.AI_MAX_OUTPUT_TOKENS || 256), 64, 512); + const signalBundle = compactAgentSignalBundle(bundle, visibility); + const prompt = buildPrompt(signalBundle, visibility); + const estimatedNeurons = estimateNeurons(prompt, maxOutputTokens); + const budget = clampNumber(Number(env.AI_DAILY_NEURON_BUDGET || 10000), 0, 1_000_000); + const used = await sumAiEstimatedNeuronsSince(env, utcDayStartIso()); + const remainingBudget = Math.max(0, budget - used); + + if (estimatedNeurons > remainingBudget) { + await recordAi(env, bundle, { + feature: `agent_${visibility}_summary`, + model, + status: "quota_exceeded", + estimatedNeurons: 0, + detail: `estimated ${estimatedNeurons} neurons exceeds remaining budget ${remainingBudget}`, + }); + return { status: "quota_exceeded", model, estimatedNeurons, remainingBudget }; + } + + try { + const response = await env.AI.run(model, { + messages: [ + { + role: "system", + content: + visibility === "public" + ? "Summarize deterministic Gittensory signals for a public GitHub comment. Do not mention rewards, payouts, wallets, hotkeys, raw trust scores, or private reviewability." + : "Summarize deterministic Gittensory signals for an authenticated MCP/API user. Be concise and preserve scoreability blockers and next actions.", + }, + { role: "user", content: prompt }, + ], + max_tokens: maxOutputTokens, + temperature: 0.1, + }); + const rawText = extractAiText(response); + if (!rawText) throw new Error("empty_ai_summary"); + if (visibility === "public" && containsPublicForbiddenText(rawText)) { + await recordAi(env, bundle, { + feature: `agent_${visibility}_summary`, + model, + status: "unsafe", + estimatedNeurons, + detail: "public summary failed sanitizer", + }); + return { status: "unsafe", model, estimatedNeurons, reason: "public summary failed sanitizer" }; + } + const text = sanitizeAiText(rawText, visibility); + await recordAi(env, bundle, { + feature: `agent_${visibility}_summary`, + model, + status: "ok", + estimatedNeurons, + detail: "summary generated", + metadata: { visibility }, + }); + return { status: "ok", model, estimatedNeurons, text }; + } catch (error) { + const reason = error instanceof Error ? error.message : "workers_ai_failed"; + await recordAi(env, bundle, { + feature: `agent_${visibility}_summary`, + model, + status: "error", + estimatedNeurons: 0, + detail: reason, + }); + return { status: "error", model, estimatedNeurons, reason }; + } +} + +function compactAgentSignalBundle(bundle: AgentRunBundle, visibility: AiSummaryVisibility): Record { + return { + run: { + id: bundle.run.id, + objective: bundle.run.objective, + actorLogin: bundle.run.actorLogin, + surface: bundle.run.surface, + status: bundle.run.status, + dataQualityStatus: bundle.run.dataQualityStatus, + }, + actions: bundle.actions.slice(0, 5).map((action) => ({ + actionType: action.actionType, + status: action.status, + recommendation: visibility === "public" ? action.publicSafeSummary : action.recommendation, + publicSafeSummary: action.publicSafeSummary, + why: action.why.slice(0, 4), + blockedBy: action.blockedBy.slice(0, 4), + scoreabilityImpact: visibility === "public" ? undefined : action.scoreabilityImpact, + riskImpact: visibility === "public" ? undefined : action.riskImpact, + maintainerImpact: action.maintainerImpact, + rerunWhen: action.rerunWhen, + })), + freshnessWarnings: bundle.contextSnapshots.flatMap((snapshot) => snapshot.freshnessWarnings).slice(0, 8), + } as Record; +} + +function buildPrompt(signalBundle: Record, visibility: AiSummaryVisibility): string { + return [ + `Visibility: ${visibility}`, + "Summarize this deterministic Gittensory signal bundle in 4 short bullets.", + "Do not invent facts or claim guaranteed outcomes.", + JSON.stringify(signalBundle), + ].join("\n"); +} + +function estimateNeurons(prompt: string, maxOutputTokens: number): number { + const inputTokens = Math.ceil(prompt.length / 4); + return Math.max(1, Math.ceil((inputTokens + maxOutputTokens) * 0.035)); +} + +function extractAiText(response: unknown): string { + if (typeof response === "string") return response; + if (!response || typeof response !== "object") return ""; + const record = response as Record; + if (typeof record.response === "string") return record.response; + if (typeof record.text === "string") return record.text; + if (typeof record.result === "string") return record.result; + return ""; +} + +function sanitizeAiText(value: string, visibility: AiSummaryVisibility): string { + const sanitized = value + .replace(/\b(wallet|hotkey|coldkey|seed phrase|mnemonic|raw trust score|trust score)\b/gi, "private context") + .replace(/\b(payout|farming)\b/gi, "private outcome"); + if (visibility === "public") { + return sanitized.replace(/\b(estimated score|score estimate|reward estimate|reward optimization)\b/gi, "private context"); + } + return sanitized.trim(); +} + +function containsPublicForbiddenText(value: string): boolean { + return /\b(wallet|hotkey|coldkey|seed phrase|mnemonic|raw trust score|estimated score|score estimate|reward estimate|payout|farming)\b/i.test(value); +} + +function isEnabled(value: string | undefined): boolean { + return /^(1|true|yes|on)$/i.test(value ?? ""); +} + +function clampNumber(value: number, min: number, max: number): number { + if (!Number.isFinite(value)) return min; + return Math.min(max, Math.max(min, Math.floor(value))); +} + +function utcDayStartIso(): string { + const now = new Date(); + return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())).toISOString(); +} + +async function recordAi( + env: Env, + bundle: AgentRunBundle, + event: { + feature: string; + model: string; + status: string; + estimatedNeurons: number; + detail?: string; + metadata?: Record; + }, +): Promise { + await recordAiUsageEvent(env, { + ...event, + actor: bundle.run.actorLogin, + route: bundle.run.surface, + metadata: { runId: bundle.run.id, ...(event.metadata ?? {}) }, + }); + await recordAuditEvent(env, { + eventType: "ai.summary", + actor: bundle.run.actorLogin, + route: bundle.run.surface, + outcome: auditOutcomeForAiStatus(event.status), + detail: event.detail, + metadata: { runId: bundle.run.id, feature: event.feature, model: event.model, estimatedNeurons: event.estimatedNeurons }, + }); +} + +function auditOutcomeForAiStatus(status: string): "success" | "denied" | "error" | "queued" | "completed" { + if (status === "ok") return "success"; + if (status === "quota_exceeded" || status === "unsafe") return "denied"; + if (status === "error") return "error"; + return "completed"; +} + +export const __aiSummaryInternals = { + compactAgentSignalBundle, + estimateNeurons, + extractAiText, + sanitizeAiText, + containsPublicForbiddenText, + auditOutcomeForAiStatus, +}; diff --git a/test/helpers/d1.ts b/test/helpers/d1.ts index 38d4e9aec6..b209ba715a 100644 --- a/test/helpers/d1.ts +++ b/test/helpers/d1.ts @@ -59,7 +59,7 @@ export function createTestEnv(overrides: Partial = {}): Env { GITHUB_APP_ID: "3824093", GITHUB_APP_SLUG: "gittensory", GITTENSOR_REGISTRY_URL: "https://raw.githubusercontent.com/entrius/gittensor/test/gittensor/validator/weights/master_repositories.json", - PUBLIC_API_ORIGIN: "https://gittensory-api.zeronode.workers.dev", + PUBLIC_API_ORIGIN: "https://api.gittensory.aethereal.dev", INTERNAL_JOB_TOKEN: "dev-internal-token", GITTENSORY_API_TOKEN: "test-api-token", GITTENSORY_MCP_TOKEN: "test-mcp-token", diff --git a/test/integration/routes-errors.test.ts b/test/integration/routes-errors.test.ts index efe732ea4c..3266aeec67 100644 --- a/test/integration/routes-errors.test.ts +++ b/test/integration/routes-errors.test.ts @@ -64,11 +64,11 @@ describe("api route guards and error branches", () => { "/v1/repos", { method: "OPTIONS", - headers: { origin: "https://gittensory-api.zeronode.workers.dev", "access-control-request-method": "GET" }, + headers: { origin: "https://api.gittensory.aethereal.dev", "access-control-request-method": "GET" }, }, env, ); - expect(allowedPreflight.headers.get("access-control-allow-origin")).toBe("https://gittensory-api.zeronode.workers.dev"); + expect(allowedPreflight.headers.get("access-control-allow-origin")).toBe("https://api.gittensory.aethereal.dev"); const limitedEnv = createTestEnv({ RATE_LIMITER: denyAllRateLimiter() as unknown as DurableObjectNamespace }); const limited = await app.request("/v1/auth/github/device/start", { method: "POST" }, limitedEnv); diff --git a/test/unit/agent-orchestrator.test.ts b/test/unit/agent-orchestrator.test.ts index 683bf0e76f..48444c712f 100644 --- a/test/unit/agent-orchestrator.test.ts +++ b/test/unit/agent-orchestrator.test.ts @@ -113,6 +113,24 @@ describe("agent orchestrator", () => { }); }); + it("attaches optional Workers AI summaries when enabled", async () => { + const env = createTestEnv({ + AI: { run: vi.fn(async () => ({ response: "Clean up queue pressure before adding more work." })) } as unknown as Ai, + AI_SUMMARIES_ENABLED: "true", + AI_DAILY_NEURON_BUDGET: "10000", + }); + await persistDecisionPack(env, decisionPackFixture()); + + const bundle = await planNextWork(env, { login: "oktofeesh1", repoFullName: "we-promise/sure", objective: "Pick one action" }); + + expect(bundle.run.payload).toMatchObject({ + aiSummary: expect.objectContaining({ + status: "ok", + text: "Clean up queue pressure before adding more work.", + }), + }); + }); + it("falls back to repo-fit actions when a snapshot has no top actions", async () => { const env = createTestEnv(); await persistDecisionPack(env, decisionPackFixture({ topActions: [] })); diff --git a/test/unit/ai-summaries.test.ts b/test/unit/ai-summaries.test.ts new file mode 100644 index 0000000000..8ef18e1c92 --- /dev/null +++ b/test/unit/ai-summaries.test.ts @@ -0,0 +1,212 @@ +import { describe, expect, it, vi } from "vitest"; +import { __aiSummaryInternals, summarizeAgentBundleWithAi } from "../../src/services/ai-summaries"; +import type { AgentRunBundle } from "../../src/services/agent-orchestrator"; +import { createTestEnv } from "../helpers/d1"; + +describe("Workers AI summaries", () => { + it("stays disabled by default and does not call Workers AI", async () => { + const run = vi.fn(); + const env = createTestEnv({ AI: { run } as unknown as Ai }); + + await expect(summarizeAgentBundleWithAi(env, bundleFixture(), "private")).resolves.toEqual({ + status: "disabled", + reason: "AI summaries are disabled.", + }); + expect(run).not.toHaveBeenCalled(); + }); + + it("enforces a daily neuron budget before calling Workers AI", async () => { + const run = vi.fn(); + const env = createTestEnv({ + AI: { run } as unknown as Ai, + AI_SUMMARIES_ENABLED: "true", + AI_DAILY_NEURON_BUDGET: "1", + }); + + const result = await summarizeAgentBundleWithAi(env, bundleFixture(), "private"); + + expect(result).toMatchObject({ status: "quota_exceeded" }); + expect(run).not.toHaveBeenCalled(); + }); + + it("reports unavailable Workers AI bindings when summaries are enabled", async () => { + const env = createTestEnv({ AI_SUMMARIES_ENABLED: "true" }); + + await expect(summarizeAgentBundleWithAi(env, bundleFixture(), "private")).resolves.toEqual({ + status: "unavailable", + reason: "Workers AI binding is not configured.", + }); + }); + + it("generates sanitized private summaries from compact deterministic context", async () => { + const run = vi.fn(async () => ({ response: "Use cleanup-first guidance. Do not mention wallet or payout." })); + const env = createTestEnv({ + AI: { run } as unknown as Ai, + AI_SUMMARIES_ENABLED: "true", + AI_DAILY_NEURON_BUDGET: "10000", + AI_MAX_OUTPUT_TOKENS: "128", + }); + + const result = await summarizeAgentBundleWithAi(env, bundleFixture(), "private"); + + expect(result).toMatchObject({ status: "ok" }); + expect(result.status === "ok" ? result.text : "").not.toMatch(/wallet|payout/i); + expect(run).toHaveBeenCalledWith( + "@cf/meta/llama-3.1-8b-instruct-fp8-fast", + expect.objectContaining({ + messages: expect.arrayContaining([expect.objectContaining({ role: "user", content: expect.not.stringContaining("source code") })]), + }), + ); + }); + + it("honors custom model and clamps output token configuration", async () => { + const run = vi.fn(async () => ({ response: "Custom model summary." })); + const env = createTestEnv({ + AI: { run } as unknown as Ai, + AI_SUMMARIES_ENABLED: "yes", + WORKERS_AI_SUMMARY_MODEL: "@cf/test/model", + AI_DAILY_NEURON_BUDGET: "10000", + AI_MAX_OUTPUT_TOKENS: "99999", + }); + + const result = await summarizeAgentBundleWithAi(env, bundleFixture(), "private"); + + expect(result).toMatchObject({ status: "ok", model: "@cf/test/model" }); + expect(run).toHaveBeenCalledWith("@cf/test/model", expect.objectContaining({ max_tokens: 512 })); + + const lowTokenRun = vi.fn(async () => ({ response: "Low token summary." })); + await summarizeAgentBundleWithAi( + { + ...env, + AI: { run: lowTokenRun } as unknown as Ai, + AI_MAX_OUTPUT_TOKENS: "12", + }, + bundleFixture(), + "private", + ); + expect(lowTokenRun).toHaveBeenCalledWith("@cf/test/model", expect.objectContaining({ max_tokens: 64 })); + }); + + it("treats invalid daily budget as zero budget", async () => { + const run = vi.fn(); + const env = createTestEnv({ + AI: { run } as unknown as Ai, + AI_SUMMARIES_ENABLED: "on", + AI_DAILY_NEURON_BUDGET: "not-a-number", + }); + + const result = await summarizeAgentBundleWithAi(env, bundleFixture(), "private"); + + expect(result).toMatchObject({ status: "quota_exceeded", remainingBudget: 0 }); + expect(run).not.toHaveBeenCalled(); + }); + + it("keeps public summaries disabled unless explicitly enabled and rejects unsafe public text", async () => { + const run = vi.fn(async () => ({ response: "estimated score and wallet detail" })); + const env = createTestEnv({ + AI: { run } as unknown as Ai, + AI_SUMMARIES_ENABLED: "true", + AI_PUBLIC_COMMENTS_ENABLED: "false", + }); + + await expect(summarizeAgentBundleWithAi(env, bundleFixture(), "public")).resolves.toEqual({ + status: "disabled", + reason: "Public AI summaries are disabled.", + }); + + const unsafe = await summarizeAgentBundleWithAi( + { ...env, AI_PUBLIC_COMMENTS_ENABLED: "true", AI_DAILY_NEURON_BUDGET: "10000" }, + bundleFixture(), + "public", + ); + expect(unsafe).toMatchObject({ status: "unsafe", reason: "public summary failed sanitizer" }); + }); + + it("falls back when Workers AI returns malformed output or throws non-Error values", async () => { + const malformed = createTestEnv({ + AI: { run: vi.fn(async () => ({ unknown: "shape" })) } as unknown as Ai, + AI_SUMMARIES_ENABLED: "true", + AI_DAILY_NEURON_BUDGET: "10000", + }); + await expect(summarizeAgentBundleWithAi(malformed, bundleFixture(), "private")).resolves.toMatchObject({ + status: "error", + reason: "empty_ai_summary", + }); + + const thrown = createTestEnv({ + AI: { run: vi.fn(async () => Promise.reject("offline")) } as unknown as Ai, + AI_SUMMARIES_ENABLED: "true", + AI_DAILY_NEURON_BUDGET: "10000", + }); + await expect(summarizeAgentBundleWithAi(thrown, bundleFixture(), "private")).resolves.toMatchObject({ + status: "error", + reason: "workers_ai_failed", + }); + }); + + it("covers pure extraction and sanitizer helpers", () => { + expect(__aiSummaryInternals.extractAiText("plain")).toBe("plain"); + expect(__aiSummaryInternals.extractAiText({ text: "text" })).toBe("text"); + expect(__aiSummaryInternals.extractAiText({ result: "result" })).toBe("result"); + expect(__aiSummaryInternals.extractAiText({ nope: 1 })).toBe(""); + expect(__aiSummaryInternals.extractAiText(null)).toBe(""); + expect(__aiSummaryInternals.estimateNeurons("abcd".repeat(100), 128)).toBeGreaterThan(0); + expect(__aiSummaryInternals.sanitizeAiText("wallet hotkey payout", "public")).not.toMatch(/wallet|hotkey|payout/i); + expect(__aiSummaryInternals.containsPublicForbiddenText("raw trust score")).toBe(true); + expect(__aiSummaryInternals.compactAgentSignalBundle(bundleFixture(), "public").actions).toHaveLength(1); + expect(__aiSummaryInternals.auditOutcomeForAiStatus("ok")).toBe("success"); + expect(__aiSummaryInternals.auditOutcomeForAiStatus("unsafe")).toBe("denied"); + expect(__aiSummaryInternals.auditOutcomeForAiStatus("error")).toBe("error"); + expect(__aiSummaryInternals.auditOutcomeForAiStatus("disabled")).toBe("completed"); + }); +}); + +function bundleFixture(): AgentRunBundle { + return { + run: { + id: "run-ai", + objective: "Plan next work", + actorLogin: "oktofeesh1", + surface: "mcp", + mode: "copilot", + status: "completed", + dataQualityStatus: "complete", + payload: {}, + createdAt: "2026-05-28T00:00:00.000Z", + updatedAt: "2026-05-28T00:00:00.000Z", + }, + actions: [ + { + id: "action-ai", + runId: "run-ai", + actionType: "cleanup_existing_prs", + targetRepoFullName: "we-promise/sure", + status: "recommended", + recommendation: "Clean up open PR pressure before opening new work.", + why: ["Open PR pressure blocks current scoreability."], + blockedBy: ["open_pr_pressure"], + scoreabilityImpact: "Cleanup can restore scoreability.", + riskImpact: "Lower review friction.", + maintainerImpact: "Less queue pressure.", + publicSafeSummary: "Clean up open PR pressure before opening new work.", + approvalRequired: true, + safetyClass: "private", + payload: {}, + createdAt: "2026-05-28T00:00:00.000Z", + }, + ], + contextSnapshots: [ + { + id: "ctx-ai", + runId: "run-ai", + decisionPackVersion: "2026-05-28T00:00:00.000Z", + scoringModelId: "scoring-ai", + repoSignalSnapshotIds: [], + freshnessWarnings: ["fresh enough"], + payload: {}, + createdAt: "2026-05-28T00:00:00.000Z", + }, + ], + summary: "done", + }; +} diff --git a/vitest.workers.config.ts b/vitest.workers.config.ts index 143e4137ab..fee3777e5e 100644 --- a/vitest.workers.config.ts +++ b/vitest.workers.config.ts @@ -4,7 +4,7 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ plugins: [ cloudflareTest({ - wrangler: { configPath: "./wrangler.jsonc" }, + wrangler: { configPath: "./wrangler.vitest.jsonc" }, }), ], test: { diff --git a/worker-configuration.d.ts b/worker-configuration.d.ts index 50e4f6a7c6..dccfa15843 100644 --- a/worker-configuration.d.ts +++ b/worker-configuration.d.ts @@ -1,14 +1,21 @@ /* eslint-disable */ -// Generated by Wrangler by running `wrangler types` (hash: c49fe6e75bac710794f5642ad3209b98) +// Generated by Wrangler by running `wrangler types` (hash: ce73723cb7ca944114cec6baf72fbe3b) // Runtime types generated with workerd@1.20260521.1 2026-05-26 nodejs_compat interface __BaseEnv_Env { DB: D1Database; JOBS: Queue; + AI: Ai; GITHUB_APP_ID: "3824093"; GITHUB_APP_SLUG: "gittensory"; GITHUB_OAUTH_CLIENT_ID: "Iv23li574mpdLo2PnVN4"; GITTENSOR_REGISTRY_URL: "https://raw.githubusercontent.com/entrius/gittensor/test/gittensor/validator/weights/master_repositories.json"; - PUBLIC_API_ORIGIN: "https://gittensory-api.zeronode.workers.dev"; + PUBLIC_API_ORIGIN: "https://api.gittensory.aethereal.dev"; + AI_SUMMARIES_ENABLED: "false"; + AI_PUBLIC_COMMENTS_ENABLED: "false"; + WORKERS_AI_SUMMARY_MODEL: "@cf/meta/llama-3.1-8b-instruct-fp8-fast"; + AI_DAILY_NEURON_BUDGET: "10000"; + AI_MAX_OUTPUT_TOKENS: "256"; + ADMIN_GITHUB_LOGINS: "JSONbored"; GITTENSORY_API_TOKEN: string; GITTENSORY_MCP_TOKEN: string; INTERNAL_JOB_TOKEN: string; @@ -28,7 +35,7 @@ type StringifyValues> = { [Binding in keyof EnvType]: EnvType[Binding] extends string ? EnvType[Binding] : string; }; declare namespace NodeJS { - interface ProcessEnv extends StringifyValues> {} + interface ProcessEnv extends StringifyValues> {} } // Begin runtime types diff --git a/wrangler.jsonc b/wrangler.jsonc index f9ff295c85..ad9563c4ec 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -13,7 +13,22 @@ "GITHUB_APP_SLUG": "gittensory", "GITHUB_OAUTH_CLIENT_ID": "Iv23li574mpdLo2PnVN4", "GITTENSOR_REGISTRY_URL": "https://raw.githubusercontent.com/entrius/gittensor/test/gittensor/validator/weights/master_repositories.json", - "PUBLIC_API_ORIGIN": "https://gittensory-api.zeronode.workers.dev" + "PUBLIC_API_ORIGIN": "https://api.gittensory.aethereal.dev", + "AI_SUMMARIES_ENABLED": "false", + "AI_PUBLIC_COMMENTS_ENABLED": "false", + "WORKERS_AI_SUMMARY_MODEL": "@cf/meta/llama-3.1-8b-instruct-fp8-fast", + "AI_DAILY_NEURON_BUDGET": "10000", + "AI_MAX_OUTPUT_TOKENS": "256", + "ADMIN_GITHUB_LOGINS": "JSONbored" + }, + "routes": [ + { + "pattern": "api.gittensory.aethereal.dev", + "custom_domain": true + } + ], + "ai": { + "binding": "AI" }, "d1_databases": [ { diff --git a/wrangler.vitest.jsonc b/wrangler.vitest.jsonc new file mode 100644 index 0000000000..13d60f1da1 --- /dev/null +++ b/wrangler.vitest.jsonc @@ -0,0 +1,30 @@ +{ + "$schema": "node_modules/wrangler/config-schema.json", + "name": "gittensory-api-vitest", + "main": "src/index.ts", + "compatibility_date": "2026-05-26", + "compatibility_flags": ["nodejs_compat"], + "vars": { + "GITHUB_APP_ID": "3824093", + "GITHUB_APP_SLUG": "gittensory", + "GITHUB_OAUTH_CLIENT_ID": "Iv23li574mpdLo2PnVN4", + "GITTENSOR_REGISTRY_URL": "https://raw.githubusercontent.com/entrius/gittensor/test/gittensor/validator/weights/master_repositories.json", + "PUBLIC_API_ORIGIN": "https://api.gittensory.aethereal.dev", + "AI_SUMMARIES_ENABLED": "false", + "AI_PUBLIC_COMMENTS_ENABLED": "false" + }, + "durable_objects": { + "bindings": [ + { + "name": "RATE_LIMITER", + "class_name": "RateLimiter" + } + ] + }, + "migrations": [ + { + "tag": "v1-rate-limiter", + "new_sqlite_classes": ["RateLimiter"] + } + ] +}