Vectrum
Sign In
276 lines · 11.1 KB · JavaScript
// 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);
  },
);