// Agents as first-class actors, pull requests with branch protection, and events. // // Agents are the MCP connections a person approved (an OAuth grant) or an API token used on /mcp. // What they do is recorded as theirs: `agent` holds a key that names the connection (see // lib/actor.ts) and `agent_name` what it was called at the time, on top of the person it acts for. // // Pull requests share their numbers with the repository's issues, like on GitHub. Branch rules // protect branches by name pattern. Events are the feed of everything that happens in a // repository; agents read it (list_events), webhooks deliver it, and workflows start on it. const text = (name, opts = {}) => ({ type: "text", name, max: 1000, ...opts }); const number = (name, opts = {}) => ({ type: "number", name, onlyInt: true, ...opts }); const time = (name, opts = {}) => number(name, { min: 0, ...opts }); const bool = (name) => ({ type: "bool", name }); const select = (name, values, opts = {}) => ({ type: "select", name, values, maxSelect: 1, required: true, ...opts }); const json = (name, opts = {}) => ({ type: "json", name, maxSize: 1024 * 1024, ...opts }); const relation = (name, collection, opts = {}) => ({ type: "relation", name, collectionId: collection.id, maxSelect: 1, cascadeDelete: false, ...opts, }); const parent = (name, collection) => relation(name, collection, { required: true, cascadeDelete: true }); // Who did something on behalf of the person: the connection's key and its name back then. const agentFields = () => [text("agent", { max: 100 }), text("agent_name", { max: 100 })]; function create(app, name, fields, indexes = []) { const collection = new Collection({ type: "base", name, listRule: null, viewRule: null, createRule: null, updateRule: null, deleteRule: null, fields, indexes, }); app.save(collection); return collection; } function addFields(app, name, fields) { const collection = app.findCollectionByNameOrId(name); for (const f of fields) collection.fields.add(f); app.save(collection); return collection; } function removeFields(app, name, names) { const collection = app.findCollectionByNameOrId(name); for (const n of names) collection.fields.removeByName(n); app.save(collection); } migrate( (app) => { const users = app.findCollectionByNameOrId("users"); const repos = app.findCollectionByNameOrId("repos"); // Where agents may push: "" (wherever the person they act for may: their pushes are labeled and // can be reverted), "unprotected" (any branch no rule protects) or "agent_branches" (only // branches under agent/, so their work arrives through pull requests). addFields(app, "repos", [new SelectField({ name: "agent_push", values: ["unprotected", "agent_branches"], maxSelect: 1 })]); addFields(app, "issues", [ new TextField({ name: "agent", max: 100 }), new TextField({ name: "agent_name", max: 100 }), // Set when the issue is assigned to an agent; `assignee` is then the person it acts for. new TextField({ name: "assignee_agent", max: 100 }), new TextField({ name: "assignee_agent_name", max: 100 }), ]); addFields(app, "issue_comments", [new TextField({ name: "agent", max: 100 }), new TextField({ name: "agent_name", max: 100 })]); addFields(app, "issue_events", [new TextField({ name: "agent", max: 100 }), new TextField({ name: "agent_name", max: 100 })]); const runs = app.findCollectionByNameOrId("workflow_runs"); runs.fields.getByName("event").values = ["push", "workflow_dispatch", "issues", "issue_comment", "pull_request", "pull_request_review"]; runs.fields.add(new TextField({ name: "agent_name", max: 100 })); // What started the run (an issue, a comment, a pull request), handed to its jobs. runs.fields.add(new JSONField({ name: "payload", maxSize: 512 * 1024 })); app.save(runs); create( app, "branch_rules", [ parent("repo", repos), // A branch name or a glob: * within a path segment, ** across them. text("pattern", { required: true, max: 255 }), // Changes only arrive by merging a pull request; nobody pushes to it directly. bool("require_pr"), // Approving reviews by people with push access, on the pull request's latest commit. number("required_approvals", { min: 0, max: 10 }), // The latest commit's workflow runs all succeeded. bool("require_ci"), bool("block_force_push"), bool("block_deletion"), // Agents may merge pull requests into it (once its other rules are met). bool("agents_may_merge"), time("created_at", { required: true }), ], ["CREATE UNIQUE INDEX idx_branch_rules_pattern ON branch_rules (repo, pattern)"], ); const pulls = create( app, "pull_requests", [ parent("repo", repos), number("number", { required: true, min: 1 }), text("title", { required: true, max: 256 }), text("body", { max: 65536 }), parent("author", users), ...agentFields(), // Branch names in the repository. text("head", { required: true, max: 255 }), text("base", { required: true, max: 255 }), // The head branch's commit when last seen; kept after the branch is deleted. text("head_sha", { required: true, max: 64 }), select("state", ["open", "closed", "merged"]), bool("draft"), text("merge_sha", { max: 64 }), select("merge_method", ["merge", "squash"], { required: false }), relation("merged_by", users), text("merged_by_agent", { max: 100 }), // Issue numbers it closes when merged ("Fixes #12" in its title or description). json("linked_issues", { maxSize: 10000 }), number("comment_count", { min: 0 }), time("created_at", { required: true }), time("updated_at", { required: true }), time("closed_at"), time("merged_at"), ], [ "CREATE UNIQUE INDEX idx_pull_requests_number ON pull_requests (repo, number)", "CREATE INDEX idx_pull_requests_head ON pull_requests (repo, head, state)", "CREATE INDEX idx_pull_requests_base ON pull_requests (repo, base, state)", ], ); const reviews = create( app, "pr_reviews", [ parent("pr", pulls), parent("author", users), ...agentFields(), select("state", ["approved", "changes_requested", "commented"]), text("body", { max: 65536 }), // The pull request's head when it was reviewed: approvals count for that commit only. text("commit_sha", { required: true, max: 64 }), time("created_at", { required: true }), ], ["CREATE INDEX idx_pr_reviews_pr ON pr_reviews (pr)"], ); const prComments = create( app, "pr_comments", [ parent("pr", pulls), parent("author", users), ...agentFields(), text("body", { required: true, max: 65536 }), relation("review", reviews, { cascadeDelete: true }), // Comments on a line of the diff: the file, the line's number on its side (new: after the // change, old: before it) and the commit it was made on, with a few lines around it. text("path", { max: 1024 }), number("line", { min: 0 }), select("side", ["new", "old"], { required: false }), text("commit_sha", { max: 64 }), text("snippet", { max: 4000 }), bool("resolved"), time("created_at", { required: true }), ], ["CREATE INDEX idx_pr_comments_pr ON pr_comments (pr)"], ); // Replies point to the first comment of their thread. prComments.fields.add(new RelationField({ name: "reply_to", collectionId: prComments.id, maxSelect: 1, cascadeDelete: true })); app.save(prComments); // What happened besides comments and reviews: pushes, merges, closing and reopening, renames. create( app, "pr_events", [ parent("pr", pulls), relation("actor", users, { cascadeDelete: true }), ...agentFields(), text("kind", { required: true, max: 50 }), json("data", { maxSize: 100000 }), time("created_at", { required: true }), ], ["CREATE INDEX idx_pr_events_pr ON pr_events (pr)"], ); // Everything that happens in a repository, in order. `seq` only grows, so it's the cursor. create( app, "events", [ number("seq", { required: true, min: 1 }), parent("repo", repos), // push, create, delete, issues, issue_comment, pull_request, pull_request_review, … text("kind", { required: true, max: 50 }), text("action", { max: 50 }), relation("actor", users, { cascadeDelete: true }), ...agentFields(), // What it's about, for display: "#12", "main", "run #4". text("subject", { max: 300 }), json("payload", { maxSize: 512 * 1024 }), time("created_at", { required: true }), ], [ "CREATE UNIQUE INDEX idx_events_seq ON events (seq)", "CREATE INDEX idx_events_repo ON events (repo, seq)", "CREATE INDEX idx_events_actor ON events (actor, agent, seq)", "CREATE INDEX idx_events_created ON events (created_at)", ], ); const hooks = create( app, "webhooks", [ parent("repo", repos), text("url", { required: true, max: 2000 }), // Signs every delivery (X-Vectrum-Signature-256); kept as is, since signing needs it. text("secret", { max: 200 }), // Event kinds, or ["*"] for all of them. json("events", { maxSize: 5000 }), bool("active"), time("created_at", { required: true }), time("last_delivery_at"), number("last_status", { min: 0 }), ], ["CREATE INDEX idx_webhooks_repo ON webhooks (repo)"], ); create( app, "webhook_deliveries", [ parent("webhook", hooks), text("event", { required: true, max: 50 }), text("delivery", { required: true, max: 64 }), // The response's status, or 0 if there was none. number("status", { min: 0 }), text("error", { max: 2000 }), number("duration_ms", { min: 0 }), number("attempt", { min: 1 }), text("request", { max: 100000 }), text("response", { max: 10000 }), time("created_at", { required: true }), ], ["CREATE INDEX idx_webhook_deliveries ON webhook_deliveries (webhook, created_at)"], ); }, (app) => { for (const name of ["webhook_deliveries", "webhooks", "events", "pr_events", "pr_comments", "pr_reviews", "pull_requests", "branch_rules"]) { app.delete(app.findCollectionByNameOrId(name)); } removeFields(app, "repos", ["agent_push"]); removeFields(app, "issues", ["agent", "agent_name", "assignee_agent", "assignee_agent_name"]); removeFields(app, "issue_comments", ["agent", "agent_name"]); removeFields(app, "issue_events", ["agent", "agent_name"]); const runs = app.findCollectionByNameOrId("workflow_runs"); runs.fields.getByName("event").values = ["push", "workflow_dispatch"]; runs.fields.removeByName("agent_name"); runs.fields.removeByName("payload"); app.save(runs); }, );