// 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);
},
);