diff --git a/.codex/config.toml b/.codex/config.toml
new file mode 100644
index 0000000000000000000000000000000000000000..d60a6975b9ce0c7e3ae6056defacc834732b111c
--- /dev/null
+++ b/.codex/config.toml
@@ -0,0 +1,31 @@
+# Coding MCPs: project-local registration (2026-09-07).
+
+[mcp_servers.serena]
+command = "/home/smlflg/.local/share/coding-mcp/serena/.venv/bin/serena"
+args = ["start-mcp-server", "--context=codex", "--project", "/home/smlflg/Projekte/Experimente", "--enable-web-dashboard", "false", "--open-web-dashboard", "false", "--enable-gui-log-window", "false"]
+cwd = "/home/smlflg/Projekte/Experimente"
+enabled = true
+startup_timeout_sec = 120
+tool_timeout_sec = 180
+
+[mcp_servers.serena.env]
+"SERENA_HOME" = "/home/smlflg/.local/share/coding-mcp/serena/state"
+
+[mcp_servers.gitnexus]
+command = "/home/smlflg/.nvm/versions/node/v24.13.0/bin/node"
+args = ["/home/smlflg/.local/share/coding-mcp/gitnexus/node_modules/gitnexus/dist/cli/index.js", "mcp"]
+cwd = "/home/smlflg/Projekte/Experimente"
+enabled = true
+startup_timeout_sec = 120
+tool_timeout_sec = 180
+
+[mcp_servers.gitnexus.env]
+"GITNEXUS_HOME" = "/home/smlflg/.local/share/coding-mcp/gitnexus/runtime"
+
+[mcp_servers.context7]
+command = "/home/smlflg/.nvm/versions/node/v24.13.0/bin/node"
+args = ["/home/smlflg/.local/share/coding-mcp/context7/node_modules/@upstash/context7-mcp/dist/index.js"]
+cwd = "/home/smlflg/Projekte/Experimente"
+enabled = true
+startup_timeout_sec = 120
+tool_timeout_sec = 180
diff --git a/superlog/.codex/config.toml b/superlog/.codex/config.toml
new file mode 100644
index 0000000000000000000000000000000000000000..7ba990e6f57248a370edce160d51ee30046bc74a
--- /dev/null
+++ b/superlog/.codex/config.toml
@@ -0,0 +1,31 @@
+# Coding MCPs: project-local registration (2026-09-07).
+
+[mcp_servers.serena]
+command = "/home/smlflg/.local/share/coding-mcp/serena/.venv/bin/serena"
+args = ["start-mcp-server", "--context=codex", "--project", "/home/smlflg/Projekte/Experimente/superlog", "--enable-web-dashboard", "false", "--open-web-dashboard", "false", "--enable-gui-log-window", "false"]
+cwd = "/home/smlflg/Projekte/Experimente/superlog"
+enabled = true
+startup_timeout_sec = 120
+tool_timeout_sec = 180
+
+[mcp_servers.serena.env]
+"SERENA_HOME" = "/home/smlflg/.local/share/coding-mcp/serena/state"
+
+[mcp_servers.gitnexus]
+command = "/home/smlflg/.nvm/versions/node/v24.13.0/bin/node"
+args = ["/home/smlflg/.local/share/coding-mcp/gitnexus/node_modules/gitnexus/dist/cli/index.js", "mcp"]
+cwd = "/home/smlflg/Projekte/Experimente/superlog"
+enabled = true
+startup_timeout_sec = 120
+tool_timeout_sec = 180
+
+[mcp_servers.gitnexus.env]
+"GITNEXUS_HOME" = "/home/smlflg/.local/share/coding-mcp/gitnexus/runtime"
+
+[mcp_servers.context7]
+command = "/home/smlflg/.nvm/versions/node/v24.13.0/bin/node"
+args = ["/home/smlflg/.local/share/coding-mcp/context7/node_modules/@upstash/context7-mcp/dist/index.js"]
+cwd = "/home/smlflg/Projekte/Experimente/superlog"
+enabled = true
+startup_timeout_sec = 120
+tool_timeout_sec = 180
diff --git a/superlog/.dockerignore b/superlog/.dockerignore
new file mode 100644
index 0000000000000000000000000000000000000000..441ad991e84ccacd12f96235eb1f291afceeae46
--- /dev/null
+++ b/superlog/.dockerignore
@@ -0,0 +1,18 @@
+.git
+.context
+.railway
+.turbo
+.overmind.sock
+tmp
+
+node_modules
+**/node_modules
+
+.env
+.env.*
+**/.env
+**/.env.*
+
+dist
+build
+coverage
diff --git a/superlog/.gitignore b/superlog/.gitignore
new file mode 100644
index 0000000000000000000000000000000000000000..83425a42d1d6ab075576148f60ea8b4508e50551
--- /dev/null
+++ b/superlog/.gitignore
@@ -0,0 +1,16 @@
+node_modules
+dist
+.turbo
+.next
+.env
+.env.local
+.env.superlog
+*.log
+.DS_Store
+*.tsbuildinfo
+.claude/*
+!.claude/skills/
+.playwright-mcp/
+.overmind.sock
+tmp/
+target/
diff --git a/superlog/.nvmrc b/superlog/.nvmrc
new file mode 100644
index 0000000000000000000000000000000000000000..209e3ef4b6247ce746048d5711befda46206d235
--- /dev/null
+++ b/superlog/.nvmrc
@@ -0,0 +1 @@
+20
diff --git a/superlog/LICENSE.md b/superlog/LICENSE.md
new file mode 100644
index 0000000000000000000000000000000000000000..57bc88a15a0ee8266c259b2667e64608d3f7e292
--- /dev/null
+++ b/superlog/LICENSE.md
@@ -0,0 +1,202 @@
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
+
+ APPENDIX: How to apply the Apache License to your work.
+
+ To apply the Apache License to your work, attach the following
+ boilerplate notice, with the fields enclosed by brackets "[]"
+ replaced with your own identifying information. (Don't include
+ the brackets!) The text should be enclosed in the appropriate
+ comment syntax for the file format. We also recommend that a
+ file or class name and description of purpose be included on the
+ same "printed page" as the copyright notice for easier
+ identification within third-party archives.
+
+ Copyright [yyyy] [name of copyright owner]
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+
diff --git a/superlog/Procfile b/superlog/Procfile
new file mode 100644
index 0000000000000000000000000000000000000000..78d199cc3a3c67da3ea45af0f366a47351196763
--- /dev/null
+++ b/superlog/Procfile
@@ -0,0 +1,4 @@
+api: mkdir -p tmp/logs && unset PORT && pnpm --filter @superlog/api dev 2>&1 | tee tmp/logs/api.log
+web: mkdir -p tmp/logs && unset PORT && pnpm --filter @superlog/web dev 2>&1 | tee tmp/logs/web.log
+worker: mkdir -p tmp/logs && unset PORT && pnpm --filter @superlog/worker dev 2>&1 | tee tmp/logs/worker.log
+proxy: mkdir -p tmp/logs && unset PORT && pnpm --filter @superlog/proxy dev 2>&1 | tee tmp/logs/proxy.log
diff --git a/superlog/Procfile.portless b/superlog/Procfile.portless
new file mode 100644
index 0000000000000000000000000000000000000000..35d3ed869193d0f9b0e7e769cc389c8fe03f7384
--- /dev/null
+++ b/superlog/Procfile.portless
@@ -0,0 +1,4 @@
+api: mkdir -p "${SUPERLOG_LOG_DIR:-tmp/logs}" && ./scripts/portless-run.sh api pnpm --filter @superlog/api dev 2>&1 | tee "${SUPERLOG_LOG_DIR:-tmp/logs}/api.log"
+web: mkdir -p "${SUPERLOG_LOG_DIR:-tmp/logs}" && ./scripts/portless-run.sh web pnpm --filter @superlog/web dev 2>&1 | tee "${SUPERLOG_LOG_DIR:-tmp/logs}/web.log"
+worker: mkdir -p "${SUPERLOG_LOG_DIR:-tmp/logs}" && ./scripts/with-stack-env.sh pnpm --filter @superlog/worker dev 2>&1 | tee "${SUPERLOG_LOG_DIR:-tmp/logs}/worker.log"
+proxy: mkdir -p "${SUPERLOG_LOG_DIR:-tmp/logs}" && ./scripts/portless-run.sh proxy pnpm --filter @superlog/proxy dev 2>&1 | tee "${SUPERLOG_LOG_DIR:-tmp/logs}/proxy.log"
diff --git a/superlog/README.md b/superlog/README.md
new file mode 100644
index 0000000000000000000000000000000000000000..0175863c9afa878e60c9dc42a00f0546b6a5596a
--- /dev/null
+++ b/superlog/README.md
@@ -0,0 +1,112 @@
+
+
+
+
+
+
+
+
+
+
+[](https://github.com/superloglabs/superlog/commits/main)
+[](https://github.com/superloglabs/superlog/graphs/commit-activity)
+[](./LICENSE.md)
+
+
+

+[](https://twitter.com/intent/follow?screen_name=superlogYC)
+
+
+
+
+ Website
+ ·
+ Code
+ ·
+ Skills
+ ·
+ Helpers
+ ·
+ Discord
+
+
+## About
+
+[Superlog](https://superlog.sh) is an open-source agentic telemetry system. It
+ingests traces, logs, and metrics, groups noisy signals into incidents, and watches your infra while you sleep.
+
+## Installation
+
+You can install Superlog in your project by using our [skills](https://superlog.sh) in your favourite coding agent:
+
+```
+Run npx skills add superloglabs/skills --all and use the skills to install Superlog in this project
+```
+
+
+
+## What is Superlog?
+
+Superlog is an open-core observability workspace for OpenTelemetry data. It
+ingests traces, logs, and metrics, groups noisy signals into incidents, and gives
+teams a local-first product surface for debugging production systems.
+
+This repository contains the fully open-source, free community edition:
+
+- Web app and API
+- OTLP ingest proxy
+- Worker processes for incident grouping and background jobs
+- Postgres schema and ClickHouse-backed telemetry queries
+- Agent runner interfaces for pluggable investigation runtimes
+- A default `community` agent runner that records a local incident summary
+
+We also provide a hosted Superlog Cloud edition with a free tier, a pay-to-go plan and monthly credit packs.
+
+## Quick Start
+
+Prerequisites:
+
+- Node.js 20+
+- pnpm 9+
+- Docker
+
+Install dependencies:
+
+```bash
+pnpm install
+```
+
+Start the local stack:
+
+```bash
+docker compose up -d
+pnpm --filter @superlog/db db:migrate
+pnpm dev
+```
+
+The default local services are:
+
+- Web: `http://localhost:5173`
+- API: `http://localhost:4100`
+- OTLP intake: `http://localhost:4101`
+
+## Development
+
+Run typechecks:
+
+```bash
+pnpm typecheck
+```
+
+## Repository Layout
+
+- `apps/web` - Vite/React frontend
+- `apps/api` - HTTP API
+- `apps/proxy` - OTLP intake proxy
+- `apps/worker` - background workers and agent orchestration
+- `packages/db` - Drizzle schema and migrations
+- `packages/fingerprint` - telemetry fingerprinting helpers
+
+## License
+
+Superlog is licensed under the [Apache License 2.0](./LICENSE.md).
diff --git a/superlog/SAMUEL_LOCAL_SETUP.md b/superlog/SAMUEL_LOCAL_SETUP.md
new file mode 100644
index 0000000000000000000000000000000000000000..0ac7f81713410d779c2a83b5d33b3424f72f7b91
--- /dev/null
+++ b/superlog/SAMUEL_LOCAL_SETUP.md
@@ -0,0 +1,50 @@
+# Samuel Local Superlog Setup
+
+Status: isolated local sandbox.
+
+## What is running
+
+- Web UI: http://127.0.0.1:5173
+- API: http://127.0.0.1:4100
+- Proxy health: http://127.0.0.1:4000/health
+- OTEL collector HTTP: http://127.0.0.1:4318
+- Postgres container: `superlog-sandbox-postgres` on host port `5434`
+- ClickHouse container: `superlog-sandbox-clickhouse` on host ports `8123`, `9000`
+- Collector container: `superlog-sandbox-collector` on host ports `4317`, `4318`
+
+## Isolation guarantees
+
+- Cloned into `/home/smlflg/Projekte/Experimente/superlog`.
+- No existing project was modified or instrumented.
+- No GitHub/Slack/Linear/Anthropic/OAuth credentials were added.
+- Local `.env` files exist only in this sandbox and are mode `600`.
+- Docker objects use the `superlog-sandbox-*` names.
+
+## Stop
+
+```bash
+# Stop web/API/worker/proxy dev process from Hermes if still running:
+# process session: proc_6d60aaec9f83
+
+# Stop Docker backend:
+docker stop superlog-sandbox-collector superlog-sandbox-clickhouse superlog-sandbox-postgres
+```
+
+## Start again
+
+```bash
+cd /home/smlflg/Projekte/Experimente/superlog
+
+docker start superlog-sandbox-postgres superlog-sandbox-clickhouse superlog-sandbox-collector
+corepack pnpm dev
+```
+
+## Remove sandbox data later
+
+Only if you explicitly want to delete local sandbox data:
+
+```bash
+docker rm -f superlog-sandbox-collector superlog-sandbox-clickhouse superlog-sandbox-postgres
+docker volume rm superlog_sandbox_pg_data superlog_sandbox_ch_data
+rm -rf /home/smlflg/Projekte/Experimente/superlog
+```
diff --git a/superlog/SECURITY.md b/superlog/SECURITY.md
new file mode 100644
index 0000000000000000000000000000000000000000..0b0a7d9fddb9a379074b315070646c26bc662e79
--- /dev/null
+++ b/superlog/SECURITY.md
@@ -0,0 +1,67 @@
+# Security Policy
+
+We take the security of Superlog and its users seriously. Thank you for helping
+keep Superlog and the people who use it safe.
+
+## Supported Versions
+
+Superlog ships from `main`. Security fixes are applied to the latest release and
+the current `main` branch only. Older commits and tags are not patched — please
+upgrade to the latest version before reporting an issue.
+
+| Version | Supported |
+| ------------------ | ------------------ |
+| Latest release / `main` | :white_check_mark: |
+| Anything older | :x: |
+
+## Reporting a Vulnerability
+
+**Please do not report security vulnerabilities through public GitHub issues,
+pull requests, or our Discord.**
+
+Instead, use either of these private channels:
+
+- **GitHub Private Vulnerability Reporting** (preferred): open a report from the
+ [Security tab](https://github.com/superloglabs/superlog/security/advisories/new)
+ of this repository. This keeps the discussion private until a fix is released.
+- **Email**: send details to **security@superlog.sh**.
+
+Please include as much of the following as you can:
+
+- The type of issue (e.g. injection, authn/authz bypass, SSRF, RCE, data exposure).
+- The affected component (`apps/web`, `apps/api`, `apps/proxy`, `apps/worker`,
+ `packages/*`) and file paths or commit if known.
+- Step-by-step instructions to reproduce, including any required configuration.
+- Proof-of-concept or exploit code, if you have it.
+- The impact of the issue and how an attacker might exploit it.
+
+This information helps us triage your report more quickly.
+
+## What to Expect
+
+- **Acknowledgement** within 3 business days of your report.
+- An **initial assessment** and severity triage within 7 business days.
+- We will keep you informed of progress as we work on a fix, and we will let you
+ know when the issue is resolved.
+- We practice **coordinated disclosure**: we ask that you give us a reasonable
+ opportunity to release a fix before any public disclosure. We are happy to
+ credit you in the release notes and advisory unless you prefer to remain
+ anonymous.
+
+## Scope
+
+This policy covers the open-source code in this repository. Issues in the hosted
+**Superlog Cloud** service should also be reported through the channels above.
+
+Out of scope (please do not report these):
+
+- Reports from automated scanners without a demonstrated, exploitable impact.
+- Denial-of-service achievable only through unrealistic traffic volumes.
+- Vulnerabilities in third-party dependencies that are already publicly known —
+ open a regular issue or PR to bump the dependency instead.
+
+## Safe Harbor
+
+We will not pursue legal action against researchers who act in good faith,
+follow this policy, avoid privacy violations and service degradation, and give
+us a reasonable time to remediate before disclosing.
diff --git a/superlog/apps/api/Dockerfile b/superlog/apps/api/Dockerfile
new file mode 100644
index 0000000000000000000000000000000000000000..34affa2891969460a14c03be69db0306de2ccb79
--- /dev/null
+++ b/superlog/apps/api/Dockerfile
@@ -0,0 +1,23 @@
+# Build context: repo root. Build with `docker build -f apps/api/Dockerfile .`
+FROM node:20-slim
+
+WORKDIR /app
+
+RUN corepack enable && corepack prepare pnpm@9.12.0 --activate
+
+COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
+COPY apps/api/package.json apps/api/
+COPY packages/db/package.json packages/db/
+COPY packages/fingerprint/package.json packages/fingerprint/
+
+RUN pnpm install --frozen-lockfile --filter @superlog/api...
+
+COPY tsconfig.base.json ./
+COPY apps/api apps/api
+COPY packages/db packages/db
+COPY packages/fingerprint packages/fingerprint
+
+ENV PORT=4100
+EXPOSE 4100
+
+CMD ["pnpm", "--filter", "@superlog/api", "start"]
diff --git a/superlog/apps/api/node_modules/.bin/nanoid b/superlog/apps/api/node_modules/.bin/nanoid
new file mode 100644
index 0000000000000000000000000000000000000000..d428edeed232d3196a0ddd62cc1460e6619250fa
--- /dev/null
+++ b/superlog/apps/api/node_modules/.bin/nanoid
@@ -0,0 +1,17 @@
+#!/bin/sh
+basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
+
+case `uname` in
+ *CYGWIN*) basedir=`cygpath -w "$basedir"`;;
+esac
+
+if [ -z "$NODE_PATH" ]; then
+ export NODE_PATH="/home/smlflg/Projekte/Experimente/superlog/node_modules/.pnpm/nanoid@5.1.9/node_modules/nanoid/bin/node_modules:/home/smlflg/Projekte/Experimente/superlog/node_modules/.pnpm/nanoid@5.1.9/node_modules/nanoid/node_modules:/home/smlflg/Projekte/Experimente/superlog/node_modules/.pnpm/nanoid@5.1.9/node_modules:/home/smlflg/Projekte/Experimente/superlog/node_modules/.pnpm/node_modules"
+else
+ export NODE_PATH="/home/smlflg/Projekte/Experimente/superlog/node_modules/.pnpm/nanoid@5.1.9/node_modules/nanoid/bin/node_modules:/home/smlflg/Projekte/Experimente/superlog/node_modules/.pnpm/nanoid@5.1.9/node_modules/nanoid/node_modules:/home/smlflg/Projekte/Experimente/superlog/node_modules/.pnpm/nanoid@5.1.9/node_modules:/home/smlflg/Projekte/Experimente/superlog/node_modules/.pnpm/node_modules:$NODE_PATH"
+fi
+if [ -x "$basedir/node" ]; then
+ exec "$basedir/node" "$basedir/../nanoid/bin/nanoid.js" "$@"
+else
+ exec node "$basedir/../nanoid/bin/nanoid.js" "$@"
+fi
diff --git a/superlog/apps/api/node_modules/.bin/pino b/superlog/apps/api/node_modules/.bin/pino
new file mode 100644
index 0000000000000000000000000000000000000000..dc3214724f83daa042ee09d324798cbf76e71918
--- /dev/null
+++ b/superlog/apps/api/node_modules/.bin/pino
@@ -0,0 +1,17 @@
+#!/bin/sh
+basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
+
+case `uname` in
+ *CYGWIN*) basedir=`cygpath -w "$basedir"`;;
+esac
+
+if [ -z "$NODE_PATH" ]; then
+ export NODE_PATH="/home/smlflg/Projekte/Experimente/superlog/node_modules/.pnpm/pino@10.3.1/node_modules/pino/node_modules:/home/smlflg/Projekte/Experimente/superlog/node_modules/.pnpm/pino@10.3.1/node_modules:/home/smlflg/Projekte/Experimente/superlog/node_modules/.pnpm/node_modules"
+else
+ export NODE_PATH="/home/smlflg/Projekte/Experimente/superlog/node_modules/.pnpm/pino@10.3.1/node_modules/pino/node_modules:/home/smlflg/Projekte/Experimente/superlog/node_modules/.pnpm/pino@10.3.1/node_modules:/home/smlflg/Projekte/Experimente/superlog/node_modules/.pnpm/node_modules:$NODE_PATH"
+fi
+if [ -x "$basedir/node" ]; then
+ exec "$basedir/node" "$basedir/../pino/bin.js" "$@"
+else
+ exec node "$basedir/../pino/bin.js" "$@"
+fi
diff --git a/superlog/apps/api/node_modules/.bin/tsc b/superlog/apps/api/node_modules/.bin/tsc
new file mode 100644
index 0000000000000000000000000000000000000000..5a3027f858d7058aef8886d341f0b32c85ab95db
--- /dev/null
+++ b/superlog/apps/api/node_modules/.bin/tsc
@@ -0,0 +1,17 @@
+#!/bin/sh
+basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
+
+case `uname` in
+ *CYGWIN*) basedir=`cygpath -w "$basedir"`;;
+esac
+
+if [ -z "$NODE_PATH" ]; then
+ export NODE_PATH="/home/smlflg/Projekte/Experimente/superlog/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/bin/node_modules:/home/smlflg/Projekte/Experimente/superlog/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/node_modules:/home/smlflg/Projekte/Experimente/superlog/node_modules/.pnpm/typescript@5.9.3/node_modules:/home/smlflg/Projekte/Experimente/superlog/node_modules/.pnpm/node_modules"
+else
+ export NODE_PATH="/home/smlflg/Projekte/Experimente/superlog/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/bin/node_modules:/home/smlflg/Projekte/Experimente/superlog/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/node_modules:/home/smlflg/Projekte/Experimente/superlog/node_modules/.pnpm/typescript@5.9.3/node_modules:/home/smlflg/Projekte/Experimente/superlog/node_modules/.pnpm/node_modules:$NODE_PATH"
+fi
+if [ -x "$basedir/node" ]; then
+ exec "$basedir/node" "$basedir/../typescript/bin/tsc" "$@"
+else
+ exec node "$basedir/../typescript/bin/tsc" "$@"
+fi
diff --git a/superlog/apps/api/node_modules/.bin/tsserver b/superlog/apps/api/node_modules/.bin/tsserver
new file mode 100644
index 0000000000000000000000000000000000000000..95999db035aafe7ad567730b16e83c6114b243ae
--- /dev/null
+++ b/superlog/apps/api/node_modules/.bin/tsserver
@@ -0,0 +1,17 @@
+#!/bin/sh
+basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
+
+case `uname` in
+ *CYGWIN*) basedir=`cygpath -w "$basedir"`;;
+esac
+
+if [ -z "$NODE_PATH" ]; then
+ export NODE_PATH="/home/smlflg/Projekte/Experimente/superlog/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/bin/node_modules:/home/smlflg/Projekte/Experimente/superlog/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/node_modules:/home/smlflg/Projekte/Experimente/superlog/node_modules/.pnpm/typescript@5.9.3/node_modules:/home/smlflg/Projekte/Experimente/superlog/node_modules/.pnpm/node_modules"
+else
+ export NODE_PATH="/home/smlflg/Projekte/Experimente/superlog/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/bin/node_modules:/home/smlflg/Projekte/Experimente/superlog/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/node_modules:/home/smlflg/Projekte/Experimente/superlog/node_modules/.pnpm/typescript@5.9.3/node_modules:/home/smlflg/Projekte/Experimente/superlog/node_modules/.pnpm/node_modules:$NODE_PATH"
+fi
+if [ -x "$basedir/node" ]; then
+ exec "$basedir/node" "$basedir/../typescript/bin/tsserver" "$@"
+else
+ exec node "$basedir/../typescript/bin/tsserver" "$@"
+fi
diff --git a/superlog/apps/api/node_modules/.bin/tsx b/superlog/apps/api/node_modules/.bin/tsx
new file mode 100644
index 0000000000000000000000000000000000000000..82e6578c601cd86d9e2b3768fd274df4f0f6d556
--- /dev/null
+++ b/superlog/apps/api/node_modules/.bin/tsx
@@ -0,0 +1,17 @@
+#!/bin/sh
+basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
+
+case `uname` in
+ *CYGWIN*) basedir=`cygpath -w "$basedir"`;;
+esac
+
+if [ -z "$NODE_PATH" ]; then
+ export NODE_PATH="/home/smlflg/Projekte/Experimente/superlog/node_modules/.pnpm/tsx@4.21.0/node_modules/tsx/dist/node_modules:/home/smlflg/Projekte/Experimente/superlog/node_modules/.pnpm/tsx@4.21.0/node_modules/tsx/node_modules:/home/smlflg/Projekte/Experimente/superlog/node_modules/.pnpm/tsx@4.21.0/node_modules:/home/smlflg/Projekte/Experimente/superlog/node_modules/.pnpm/node_modules"
+else
+ export NODE_PATH="/home/smlflg/Projekte/Experimente/superlog/node_modules/.pnpm/tsx@4.21.0/node_modules/tsx/dist/node_modules:/home/smlflg/Projekte/Experimente/superlog/node_modules/.pnpm/tsx@4.21.0/node_modules/tsx/node_modules:/home/smlflg/Projekte/Experimente/superlog/node_modules/.pnpm/tsx@4.21.0/node_modules:/home/smlflg/Projekte/Experimente/superlog/node_modules/.pnpm/node_modules:$NODE_PATH"
+fi
+if [ -x "$basedir/node" ]; then
+ exec "$basedir/node" "$basedir/../tsx/dist/cli.mjs" "$@"
+else
+ exec node "$basedir/../tsx/dist/cli.mjs" "$@"
+fi
diff --git a/superlog/apps/api/package.json b/superlog/apps/api/package.json
new file mode 100644
index 0000000000000000000000000000000000000000..4b21bfec7b1dc81b1a2784f3ab788806d697979e
--- /dev/null
+++ b/superlog/apps/api/package.json
@@ -0,0 +1,51 @@
+{
+ "name": "@superlog/api",
+ "version": "0.0.0",
+ "private": true,
+ "license": "Apache-2.0",
+ "type": "module",
+ "scripts": {
+ "dev": "tsx watch --import ./tracing.ts src/index.ts",
+ "start": "tsx --import ./tracing.ts src/index.ts",
+ "test": "tsx --test src/**/*.test.ts",
+ "test:mcp-clickhouse-ranges": "tsx scripts/test-mcp-clickhouse-ranges.ts",
+ "typecheck": "tsc --noEmit"
+ },
+ "dependencies": {
+ "@aws-sdk/client-s3": "^3.1053.0",
+ "@clickhouse/client": "^1.9.0",
+ "@hono/node-server": "^1.13.7",
+ "@hono/standard-validator": "^0.2.2",
+ "@jridgewell/trace-mapping": "^0.3.31",
+ "@modelcontextprotocol/sdk": "^1.29.0",
+ "@opentelemetry/api": "^1.9.1",
+ "@opentelemetry/api-logs": "^0.215.0",
+ "@opentelemetry/auto-instrumentations-node": "^0.73.0",
+ "@opentelemetry/exporter-logs-otlp-http": "^0.215.0",
+ "@opentelemetry/exporter-metrics-otlp-http": "^0.215.0",
+ "@opentelemetry/exporter-trace-otlp-http": "^0.215.0",
+ "@opentelemetry/resources": "^2.7.0",
+ "@opentelemetry/sdk-logs": "^0.215.0",
+ "@opentelemetry/sdk-metrics": "^2.7.0",
+ "@opentelemetry/sdk-node": "^0.215.0",
+ "@opentelemetry/semantic-conventions": "^1.40.0",
+ "@scalar/hono-api-reference": "^0.10.19",
+ "@superlog/db": "workspace:*",
+ "@superlog/fingerprint": "workspace:*",
+ "autumn-js": "^1.2.27",
+ "better-auth": "^1.6.11",
+ "dotenv": "^17.4.2",
+ "drizzle-orm": "^0.45.2",
+ "hono": "^4.6.12",
+ "hono-openapi": "^1.3.0",
+ "nanoid": "^5.0.9",
+ "pino": "^10.3.1",
+ "resend": "^6.12.3",
+ "zod": "^4.4.3"
+ },
+ "devDependencies": {
+ "@types/node": "^22.10.1",
+ "tsx": "^4.19.2",
+ "typescript": "^5.7.2"
+ }
+}
diff --git a/superlog/apps/api/scripts/test-agent-tracking-e2e.ts b/superlog/apps/api/scripts/test-agent-tracking-e2e.ts
new file mode 100644
index 0000000000000000000000000000000000000000..902bc99d942fc1f61736811d34fd5f026b18da96
--- /dev/null
+++ b/superlog/apps/api/scripts/test-agent-tracking-e2e.ts
@@ -0,0 +1,634 @@
+/**
+ * End-to-end integration test for agent PR / Linear ticket tracking.
+ *
+ * What this exercises:
+ * 1. Migrations apply cleanly (creates the four new tables + linear webhook columns).
+ * 2. Worker-side row creation: agent_pull_requests + initial pr_opened event.
+ * 3. GitHub webhook handler updates parent state and appends agent_pr_events
+ * for: pull_request closed/merged, push, pull_request_review,
+ * pull_request_review_comment, issue_comment.
+ * A merged PR also resolves the incident, cascades linked issues, and
+ * writes an incident timeline event.
+ * 4. Worker-side Linear ticket creation: agent_linear_tickets + ticket_filed event.
+ * 5. Linear webhook handler updates parent state and appends agent_linear_ticket_events
+ * for: Issue update (state change), Comment create.
+ * 6. Timeline merge: incident_events + agent_pr_events + agent_linear_ticket_events
+ * come back chronologically with actor metadata.
+ *
+ * What this does NOT exercise:
+ * - Real GitHub PR creation (requires installed GitHub App + repo).
+ * - Real Linear ticket creation (requires agent run with OAuth + MCP).
+ * - Linear webhook auto-registration on OAuth (requires public API_BASE_URL).
+ *
+ * Run: pnpm exec tsx scripts/test-agent-tracking-e2e.ts
+ */
+
+import { strict as assert } from "node:assert";
+import crypto from "node:crypto";
+import "dotenv/config";
+import { Hono } from "hono";
+import { db, runMigrations, schema } from "@superlog/db";
+import { and, asc, desc, eq, inArray } from "drizzle-orm";
+import { mountGithubPublic } from "../src/github.js";
+import { mountLinearPublic } from "../src/linear.js";
+
+const TEST_TAG = `e2e-${Date.now()}`;
+const GH_INSTALLATION_ID = Math.floor(Date.now() / 1000) + 9_000_000; // unique-ish
+const REPO_FULL_NAME = `acme/${TEST_TAG}`;
+const PR_NUMBER = 42;
+const LINEAR_WORKSPACE_ID = `ws-${TEST_TAG}`;
+const LINEAR_TICKET_ID = `linear-issue-${TEST_TAG}`;
+const LINEAR_WEBHOOK_ID = `wh-${TEST_TAG}`;
+const LINEAR_WEBHOOK_SECRET = "linear-test-secret";
+
+process.env.GITHUB_APP_WEBHOOK_SECRET ??= "github-test-secret";
+const GH_WEBHOOK_SECRET = process.env.GITHUB_APP_WEBHOOK_SECRET;
+
+function step(label: string) {
+ console.log(`\n— ${label}`);
+}
+
+function ok(label: string) {
+ console.log(` ✓ ${label}`);
+}
+
+async function seed(): Promise<{
+ orgId: string;
+ projectId: string;
+ incidentId: string;
+ issueId: string;
+ agentRunId: string;
+ ghInstallRowId: string;
+ linearInstallRowId: string;
+ agentPrRowId: string;
+ agentTicketRowId: string;
+}> {
+ // Org / user / project
+ const [org] = await db
+ .insert(schema.orgs)
+ .values({ name: `e2e-${TEST_TAG}`, slug: `e2e-${TEST_TAG}` })
+ .returning();
+ if (!org) throw new Error("seed org failed");
+
+ const [project] = await db
+ .insert(schema.projects)
+ .values({ orgId: org.id, name: "test", slug: `test-${TEST_TAG}` })
+ .returning();
+ if (!project) throw new Error("seed project failed");
+
+ // Incident + agentRun
+ const [incident] = await db
+ .insert(schema.incidents)
+ .values({
+ projectId: project.id,
+ title: "Test incident",
+ service: "api",
+ firstSeen: new Date(),
+ lastSeen: new Date(),
+ })
+ .returning();
+ if (!incident) throw new Error("seed incident failed");
+
+ const [issue] = await db
+ .insert(schema.issues)
+ .values({
+ projectId: project.id,
+ fingerprint: `fp-${TEST_TAG}`,
+ kind: "span",
+ service: "api",
+ exceptionType: "Error",
+ title: "Test issue",
+ message: "boom",
+ firstSeen: new Date(),
+ lastSeen: new Date(),
+ })
+ .returning();
+ if (!issue) throw new Error("seed issue failed");
+
+ await db.insert(schema.incidentIssues).values({
+ incidentId: incident.id,
+ issueId: issue.id,
+ });
+
+ const [agentRun] = await db
+ .insert(schema.agentRuns)
+ .values({ incidentId: incident.id, runtime: "anthropic", state: "running" })
+ .returning();
+ if (!agentRun) throw new Error("seed agent run failed");
+
+ await db.insert(schema.incidentEvents).values({
+ agentRunId: agentRun.id,
+ kind: "agent.session_started",
+ summary: "Agent session started",
+ });
+
+ // GitHub installation
+ const [ghInstall] = await db
+ .insert(schema.githubInstallations)
+ .values({
+ orgId: org.id,
+ projectId: project.id,
+ installationId: GH_INSTALLATION_ID,
+ accountLogin: "acme-bot",
+ accountType: "Organization",
+ repos: [{ id: 1, fullName: REPO_FULL_NAME, private: false }],
+ })
+ .returning();
+ if (!ghInstall) throw new Error("seed gh install failed");
+
+ // Linear installation (with webhook id/secret pre-populated)
+ const [linearInstall] = await db
+ .insert(schema.linearInstallations)
+ .values({
+ projectId: project.id,
+ workspaceId: LINEAR_WORKSPACE_ID,
+ workspaceName: "Acme",
+ accessToken: "fake-token",
+ webhookId: LINEAR_WEBHOOK_ID,
+ webhookSecret: LINEAR_WEBHOOK_SECRET,
+ })
+ .returning();
+ if (!linearInstall) throw new Error("seed linear install failed");
+
+ // Worker-created rows: pretend the worker already opened a PR + filed a ticket.
+ const [agentPr] = await db
+ .insert(schema.agentPullRequests)
+ .values({
+ incidentId: incident.id,
+ agentRunId: agentRun.id,
+ installationId: ghInstall.id,
+ repoFullName: REPO_FULL_NAME,
+ prNumber: PR_NUMBER,
+ prNodeId: "PR_node_1",
+ url: `https://github.com/${REPO_FULL_NAME}/pull/${PR_NUMBER}`,
+ branchName: "superlog/fix-1",
+ baseBranch: "main",
+ headSha: "deadbeef",
+ title: "[superlog] Fix bug",
+ state: "open",
+ lastSyncedAt: new Date(),
+ })
+ .returning();
+ if (!agentPr) throw new Error("seed agent_pr failed");
+
+ await db.insert(schema.agentPrEvents).values({
+ agentPrId: agentPr.id,
+ kind: "pr_opened",
+ summary: `Opened PR #${PR_NUMBER}`,
+ actorLogin: "superlog-bot",
+ payload: { url: agentPr.url },
+ providerEventId: `pr_opened:${REPO_FULL_NAME}#${PR_NUMBER}`,
+ occurredAt: new Date(),
+ });
+
+ const [agentTicket] = await db
+ .insert(schema.agentLinearTickets)
+ .values({
+ incidentId: incident.id,
+ agentRunId: agentRun.id,
+ installationId: linearInstall.id,
+ workspaceId: LINEAR_WORKSPACE_ID,
+ ticketId: LINEAR_TICKET_ID,
+ url: `https://linear.app/acme/issue/ENG-1`,
+ lastSyncedAt: new Date(),
+ })
+ .returning();
+ if (!agentTicket) throw new Error("seed agent_ticket failed");
+
+ await db.insert(schema.agentLinearTicketEvents).values({
+ agentLinearTicketId: agentTicket.id,
+ kind: "ticket_filed",
+ summary: `Filed ${LINEAR_TICKET_ID}`,
+ occurredAt: new Date(),
+ providerEventId: `ticket_filed:${LINEAR_WORKSPACE_ID}:${LINEAR_TICKET_ID}`,
+ });
+
+ return {
+ orgId: org.id,
+ projectId: project.id,
+ incidentId: incident.id,
+ issueId: issue.id,
+ agentRunId: agentRun.id,
+ ghInstallRowId: ghInstall.id,
+ linearInstallRowId: linearInstall.id,
+ agentPrRowId: agentPr.id,
+ agentTicketRowId: agentTicket.id,
+ };
+}
+
+async function cleanup(orgId: string): Promise {
+ // FKs cascade from org → projects → incidents → agentRuns → events.
+ // github_installations / linear_installations cascade from org too.
+ await db.delete(schema.orgs).where(eq(schema.orgs.id, orgId));
+}
+
+function ghSign(body: string): string {
+ return `sha256=${crypto.createHmac("sha256", GH_WEBHOOK_SECRET).update(body).digest("hex")}`;
+}
+
+function linearSign(body: string): string {
+ return crypto.createHmac("sha256", LINEAR_WEBHOOK_SECRET).update(body).digest("hex");
+}
+
+async function postGithub(
+ app: Hono,
+ event: string,
+ delivery: string,
+ payload: unknown,
+): Promise {
+ const body = JSON.stringify(payload);
+ return app.request("/github/webhook", {
+ method: "POST",
+ headers: {
+ "content-type": "application/json",
+ "x-github-event": event,
+ "x-github-delivery": delivery,
+ "x-hub-signature-256": ghSign(body),
+ },
+ body,
+ });
+}
+
+async function postLinear(app: Hono, delivery: string, payload: unknown): Promise {
+ const body = JSON.stringify(payload);
+ return app.request("/linear/webhook", {
+ method: "POST",
+ headers: {
+ "content-type": "application/json",
+ "linear-signature": linearSign(body),
+ "linear-delivery": delivery,
+ },
+ body,
+ });
+}
+
+async function main() {
+ step("Apply migrations");
+ await runMigrations();
+ ok("migrations applied");
+
+ step("Seed test fixtures");
+ const seeded = await seed();
+ ok(`org=${seeded.orgId} pr=${seeded.agentPrRowId} ticket=${seeded.agentTicketRowId}`);
+
+ try {
+ // Build a Hono app with just the webhook routes mounted.
+ // biome-ignore lint/suspicious/noExplicitAny: test harness
+ const app = new Hono();
+ mountGithubPublic(app);
+ mountLinearPublic(app);
+
+ step("GitHub webhook: pull_request_review submitted");
+ {
+ const res = await postGithub(app, "pull_request_review", `del-${TEST_TAG}-1`, {
+ action: "submitted",
+ repository: { full_name: REPO_FULL_NAME },
+ pull_request: { number: PR_NUMBER },
+ review: {
+ id: 1001,
+ state: "approved",
+ user: { login: "alice", id: 100, avatar_url: "https://avatars.example/alice" },
+ },
+ sender: { login: "alice", id: 100 },
+ installation: { id: GH_INSTALLATION_ID },
+ });
+ assert.equal(res.status, 200, `expected 200, got ${res.status}: ${await res.text()}`);
+
+ const events = await db.query.agentPrEvents.findMany({
+ where: eq(schema.agentPrEvents.agentPrId, seeded.agentPrRowId),
+ orderBy: [desc(schema.agentPrEvents.createdAt)],
+ limit: 1,
+ });
+ assert.equal(events[0]?.kind, "review_approved");
+ assert.equal(events[0]?.actorLogin, "alice");
+ ok(`event recorded: ${events[0]?.kind} by ${events[0]?.actorLogin}`);
+ }
+
+ step("GitHub webhook: issue_comment on PR");
+ {
+ const res = await postGithub(app, "issue_comment", `del-${TEST_TAG}-2`, {
+ action: "created",
+ repository: { full_name: REPO_FULL_NAME },
+ issue: { number: PR_NUMBER, pull_request: { url: "..." } },
+ comment: {
+ id: 2002,
+ body: "LGTM",
+ user: { login: "bob", id: 200, avatar_url: "https://avatars.example/bob" },
+ },
+ installation: { id: GH_INSTALLATION_ID },
+ });
+ assert.equal(res.status, 200);
+
+ const ev = await db.query.agentPrEvents.findFirst({
+ where: and(
+ eq(schema.agentPrEvents.agentPrId, seeded.agentPrRowId),
+ eq(schema.agentPrEvents.kind, "issue_comment"),
+ ),
+ });
+ assert.ok(ev, "expected issue_comment event");
+ assert.equal(ev.actorLogin, "bob");
+ ok(`event recorded: issue_comment by ${ev.actorLogin}`);
+ }
+
+ step("GitHub webhook: push to head branch updates head_sha");
+ {
+ const res = await postGithub(app, "push", `del-${TEST_TAG}-3`, {
+ ref: "refs/heads/superlog/fix-1",
+ before: "deadbeef",
+ after: "cafebabe",
+ repository: { full_name: REPO_FULL_NAME },
+ pusher: { name: "alice" },
+ sender: { login: "alice", id: 100 },
+ commits: [{ id: "cafebabe", message: "fix" }],
+ installation: { id: GH_INSTALLATION_ID },
+ });
+ assert.equal(res.status, 200);
+
+ const pr = await db.query.agentPullRequests.findFirst({
+ where: eq(schema.agentPullRequests.id, seeded.agentPrRowId),
+ });
+ assert.equal(pr?.headSha, "cafebabe");
+ ok(`head_sha updated: ${pr?.headSha}`);
+ }
+
+ step("GitHub webhook: pull_request closed (merged)");
+ {
+ const mergedAt = new Date().toISOString();
+ const res = await postGithub(app, "pull_request", `del-${TEST_TAG}-4`, {
+ action: "closed",
+ repository: { full_name: REPO_FULL_NAME },
+ pull_request: {
+ number: PR_NUMBER,
+ merged: true,
+ merged_at: mergedAt,
+ closed_at: mergedAt,
+ merged_by: { login: "alice", id: 100 },
+ user: { login: "superlog-bot", id: 999 },
+ head: { sha: "cafebabe", ref: "superlog/fix-1" },
+ },
+ sender: { login: "alice", id: 100 },
+ installation: { id: GH_INSTALLATION_ID },
+ });
+ assert.equal(res.status, 200);
+
+ const pr = await db.query.agentPullRequests.findFirst({
+ where: eq(schema.agentPullRequests.id, seeded.agentPrRowId),
+ });
+ assert.equal(pr?.state, "merged");
+ assert.equal(pr?.mergedByLogin, "alice");
+ assert.ok(pr?.mergedAt, "expected mergedAt");
+
+ const incident = await db.query.incidents.findFirst({
+ where: eq(schema.incidents.id, seeded.incidentId),
+ });
+ assert.equal(incident?.status, "resolved");
+
+ const issue = await db.query.issues.findFirst({
+ where: eq(schema.issues.id, seeded.issueId),
+ });
+ assert.equal(issue?.status, "resolved");
+ assert.ok(issue?.resolvedAt, "expected issue resolvedAt");
+
+ const ev = await db.query.incidentEvents.findFirst({
+ where: and(
+ eq(schema.incidentEvents.agentRunId, seeded.agentRunId),
+ eq(schema.incidentEvents.kind, "incident_resolved"),
+ ),
+ });
+ assert.ok(ev, "expected incident_resolved timeline event");
+ assert.equal(ev.summary, `Incident resolved because PR #${PR_NUMBER} was merged.`);
+ ok(
+ `PR merged: state=${pr?.state}, incident=${incident?.status}, issue=${issue?.status}`,
+ );
+ }
+
+ step("GitHub webhook: dedup on duplicate delivery");
+ {
+ const res = await postGithub(app, "issue_comment", `del-${TEST_TAG}-2`, {
+ action: "created",
+ repository: { full_name: REPO_FULL_NAME },
+ issue: { number: PR_NUMBER, pull_request: { url: "..." } },
+ comment: { id: 2002, body: "LGTM", user: { login: "bob", id: 200 } },
+ installation: { id: GH_INSTALLATION_ID },
+ });
+ assert.equal(res.status, 200);
+ const events = await db.query.agentPrEvents.findMany({
+ where: and(
+ eq(schema.agentPrEvents.agentPrId, seeded.agentPrRowId),
+ eq(schema.agentPrEvents.providerEventId, `del-${TEST_TAG}-2`),
+ ),
+ });
+ assert.equal(events.length, 1, `expected 1 dedup'd event, got ${events.length}`);
+ ok(`duplicate delivery deduped (${events.length} row)`);
+ }
+
+ step("Linear webhook: Issue update with state change");
+ {
+ const res = await postLinear(app, `lin-${TEST_TAG}-1`, {
+ action: "update",
+ type: "Issue",
+ webhookId: LINEAR_WEBHOOK_ID,
+ webhookTimestamp: Date.now(),
+ createdAt: new Date().toISOString(),
+ updatedFrom: { stateId: "old-state" },
+ data: {
+ id: LINEAR_TICKET_ID,
+ identifier: "ENG-1",
+ title: "Bug fix",
+ url: `https://linear.app/acme/issue/ENG-1`,
+ state: { id: "in-progress", name: "In Progress", type: "started" },
+ assignee: { id: "user-99", name: "Charlie" },
+ },
+ actor: { id: "user-99", name: "Charlie" },
+ });
+ assert.equal(res.status, 200, `expected 200, got ${res.status}: ${await res.text()}`);
+
+ const ticket = await db.query.agentLinearTickets.findFirst({
+ where: eq(schema.agentLinearTickets.id, seeded.agentTicketRowId),
+ });
+ assert.equal(ticket?.state, "In Progress");
+ assert.equal(ticket?.stateType, "started");
+ assert.equal(ticket?.assigneeName, "Charlie");
+ ok(`ticket state: ${ticket?.state} (${ticket?.stateType}), assignee=${ticket?.assigneeName}`);
+
+ const ev = await db.query.agentLinearTicketEvents.findFirst({
+ where: and(
+ eq(schema.agentLinearTicketEvents.agentLinearTicketId, seeded.agentTicketRowId),
+ eq(schema.agentLinearTicketEvents.kind, "ticket_state_changed"),
+ ),
+ });
+ assert.ok(ev, "expected ticket_state_changed event");
+ assert.equal(ev.actorName, "Charlie");
+ ok(`event recorded: ${ev.kind} by ${ev.actorName}`);
+ }
+
+ step("Linear webhook: Comment create");
+ {
+ const res = await postLinear(app, `lin-${TEST_TAG}-2`, {
+ action: "create",
+ type: "Comment",
+ webhookId: LINEAR_WEBHOOK_ID,
+ createdAt: new Date().toISOString(),
+ data: {
+ id: "cmt-1",
+ issueId: LINEAR_TICKET_ID,
+ body: "Looks good",
+ user: { id: "user-99", name: "Charlie" },
+ },
+ });
+ assert.equal(res.status, 200);
+
+ const ev = await db.query.agentLinearTicketEvents.findFirst({
+ where: and(
+ eq(schema.agentLinearTicketEvents.agentLinearTicketId, seeded.agentTicketRowId),
+ eq(schema.agentLinearTicketEvents.kind, "ticket_comment"),
+ ),
+ });
+ assert.ok(ev, "expected ticket_comment event");
+ assert.equal(ev.actorName, "Charlie");
+ ok(`event recorded: ${ev.kind} by ${ev.actorName}`);
+ }
+
+ step("Linear webhook: bad signature rejected");
+ {
+ const res = await app.request("/linear/webhook", {
+ method: "POST",
+ headers: {
+ "content-type": "application/json",
+ "linear-signature": "0".repeat(64),
+ },
+ body: JSON.stringify({
+ webhookId: LINEAR_WEBHOOK_ID,
+ type: "Issue",
+ action: "update",
+ data: { id: LINEAR_TICKET_ID },
+ }),
+ });
+ assert.equal(res.status, 401);
+ ok(`bad signature → 401`);
+ }
+
+ step("Timeline merge: agent run + agent_pr + agent_linear, sorted");
+ {
+ const events = await loadTimelineForTest(seeded.incidentId, seeded.agentRunId);
+ const sources = new Set(events.map((e) => e.source));
+ assert.ok(sources.has("agent_run"), "missing agent run events");
+ assert.ok(sources.has("agent_pr"), "missing agent_pr events");
+ assert.ok(sources.has("agent_linear"), "missing agent_linear events");
+
+ // Verify chronological order
+ for (let i = 1; i < events.length; i++) {
+ assert.ok(
+ events[i - 1]!.createdAt <= events[i]!.createdAt,
+ `events out of order at index ${i}`,
+ );
+ }
+ // Verify actor surfaced for at least one PR event
+ const prEventWithActor = events.find((e) => e.source === "agent_pr" && e.actor);
+ assert.ok(prEventWithActor, "expected at least one PR event with actor metadata");
+ const incidentResolvedEvent = events.find(
+ (e) => e.source === "agent_run" && e.kind === "incident_resolved",
+ );
+ assert.ok(incidentResolvedEvent, "missing incident_resolved timeline event");
+ assert.equal(
+ incidentResolvedEvent.summary,
+ `Incident resolved because PR #${PR_NUMBER} was merged.`,
+ );
+ ok(`timeline has ${events.length} events across ${sources.size} sources, chronological`);
+ ok(`sample actor: ${prEventWithActor.actor?.name} (${prEventWithActor.kind})`);
+ }
+
+ console.log("\nAll assertions passed.");
+ } finally {
+ step("Cleanup");
+ await cleanup(seeded.orgId);
+ ok("test rows removed");
+ }
+}
+
+// Inline copy of apps/api/src/index.ts loadIncidentTimeline so the test doesn't
+// have to spin up the whole api. If this drifts, the unit-of-truth is the api file.
+async function loadTimelineForTest(incidentId: string, agentRunId: string) {
+ const [agentRunEvents, prRows, linearTickets] = await Promise.all([
+ db.query.incidentEvents.findMany({
+ where: eq(schema.incidentEvents.agentRunId, agentRunId),
+ orderBy: [asc(schema.incidentEvents.createdAt)],
+ }),
+ db.query.agentPullRequests.findMany({
+ where: eq(schema.agentPullRequests.incidentId, incidentId),
+ }),
+ db.query.agentLinearTickets.findMany({
+ where: eq(schema.agentLinearTickets.incidentId, incidentId),
+ }),
+ ]);
+
+ const [prEventRows, linearEventRows] = await Promise.all([
+ prRows.length === 0
+ ? []
+ : db.query.agentPrEvents.findMany({
+ where: inArray(
+ schema.agentPrEvents.agentPrId,
+ prRows.map((r) => r.id),
+ ),
+ orderBy: [asc(schema.agentPrEvents.occurredAt)],
+ }),
+ linearTickets.length === 0
+ ? []
+ : db.query.agentLinearTicketEvents.findMany({
+ where: inArray(
+ schema.agentLinearTicketEvents.agentLinearTicketId,
+ linearTickets.map((r) => r.id),
+ ),
+ orderBy: [asc(schema.agentLinearTicketEvents.occurredAt)],
+ }),
+ ]);
+
+ type Item = {
+ id: string;
+ kind: string;
+ summary: string | null;
+ createdAt: string;
+ source: "agent_run" | "agent_pr" | "agent_linear";
+ actor: { name: string | null } | null;
+ };
+ const items: Item[] = [];
+ for (const ev of agentRunEvents) {
+ items.push({
+ id: ev.id,
+ kind: ev.kind,
+ summary: ev.summary,
+ createdAt: ev.createdAt.toISOString(),
+ source: "agent_run",
+ actor: null,
+ });
+ }
+ for (const ev of prEventRows) {
+ items.push({
+ id: ev.id,
+ kind: ev.kind,
+ summary: ev.summary,
+ createdAt: ev.occurredAt.toISOString(),
+ source: "agent_pr",
+ actor: ev.actorLogin ? { name: ev.actorLogin } : null,
+ });
+ }
+ for (const ev of linearEventRows) {
+ items.push({
+ id: ev.id,
+ kind: ev.kind,
+ summary: ev.summary,
+ createdAt: ev.occurredAt.toISOString(),
+ source: "agent_linear",
+ actor: ev.actorName ? { name: ev.actorName } : null,
+ });
+ }
+ items.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
+ return items;
+}
+
+main()
+ .then(() => process.exit(0))
+ .catch((e) => {
+ console.error("\nE2E test failed:", e);
+ process.exit(1);
+ });
diff --git a/superlog/apps/api/scripts/test-mcp-clickhouse-ranges.ts b/superlog/apps/api/scripts/test-mcp-clickhouse-ranges.ts
new file mode 100644
index 0000000000000000000000000000000000000000..6a1a3de1fe1c1ac04d28bc83693eb44227aa258b
--- /dev/null
+++ b/superlog/apps/api/scripts/test-mcp-clickhouse-ranges.ts
@@ -0,0 +1,72 @@
+import assert from "node:assert/strict";
+import type { ClickHouseClient } from "@clickhouse/client";
+import { listServices, queryLogs } from "../src/mcp/clickhouse.js";
+
+type QueryCall = {
+ query: string;
+ query_params?: Record;
+ format?: string;
+};
+
+function fakeClickHouse(calls: QueryCall[]): ClickHouseClient {
+ return {
+ query: async (args: QueryCall) => {
+ calls.push(args);
+ return { json: async () => [] };
+ },
+ } as unknown as ClickHouseClient;
+}
+
+function normalizedSql(sql: string): string {
+ return sql.replace(/\s+/g, " ").trim();
+}
+
+async function testDefaultRelativeRangeIsExecutableSql() {
+ const calls: QueryCall[] = [];
+ await queryLogs(fakeClickHouse(calls), "00000000-0000-0000-0000-000000000001", {
+ limit: 10,
+ });
+
+ const sql = normalizedSql(calls[0]?.query ?? "");
+ assert.match(sql, /Timestamp >= now\(\) - INTERVAL 1 HOUR/);
+ assert.match(sql, /Timestamp <= now\(\)/);
+ assert.doesNotMatch(sql, /parseDateTime64BestEffortOrZero\(\{since:String\}\)/);
+ assert.doesNotMatch(sql, /parseDateTime64BestEffortOrZero\(\{until:String\}\)/);
+}
+
+async function testExplicitRelativeRangeIsExecutableSql() {
+ const calls: QueryCall[] = [];
+ await listServices(fakeClickHouse(calls), "00000000-0000-0000-0000-000000000001", {
+ since: "now() - INTERVAL 7 DAY",
+ until: "now()",
+ });
+
+ const sql = normalizedSql(calls[0]?.query ?? "");
+ assert.match(sql, /Timestamp >= now\(\) - INTERVAL 7 DAY/);
+ assert.match(sql, /Timestamp <= now\(\)/);
+ assert.doesNotMatch(sql, /parseDateTime64BestEffortOrZero\(\{since:String\}\)/);
+ assert.doesNotMatch(sql, /parseDateTime64BestEffortOrZero\(\{until:String\}\)/);
+}
+
+async function testIsoRangeStillUsesParameterizedParsing() {
+ const calls: QueryCall[] = [];
+ await queryLogs(fakeClickHouse(calls), "00000000-0000-0000-0000-000000000001", {
+ range: {
+ since: "2026-05-06T13:12:08.771Z",
+ until: "2026-05-06T14:12:08.771Z",
+ },
+ limit: 10,
+ });
+
+ const sql = normalizedSql(calls[0]?.query ?? "");
+ assert.match(sql, /Timestamp >= parseDateTime64BestEffortOrZero\(\{since:String\}\)/);
+ assert.match(sql, /Timestamp <= parseDateTime64BestEffortOrZero\(\{until:String\}\)/);
+ assert.equal(calls[0]?.query_params?.since, "2026-05-06T13:12:08.771Z");
+ assert.equal(calls[0]?.query_params?.until, "2026-05-06T14:12:08.771Z");
+}
+
+await testDefaultRelativeRangeIsExecutableSql();
+await testExplicitRelativeRangeIsExecutableSql();
+await testIsoRangeStillUsesParameterizedParsing();
+
+console.log("mcp clickhouse range tests passed");
diff --git a/superlog/apps/api/src/alerts-service.ts b/superlog/apps/api/src/alerts-service.ts
new file mode 100644
index 0000000000000000000000000000000000000000..925c90a4e8ab4bacbd2dec0f492f9f5bde3fa2e9
--- /dev/null
+++ b/superlog/apps/api/src/alerts-service.ts
@@ -0,0 +1,391 @@
+import type { ClickHouseClient } from "@clickhouse/client";
+import { db, schema } from "@superlog/db";
+import { and, asc, desc, eq } from "drizzle-orm";
+import { HTTPException } from "hono/http-exception";
+import { z } from "zod";
+import { logger } from "./logger.js";
+import { type ResourceAttrFilter, countSeries, metricSeries, pickStep } from "./mcp/clickhouse.js";
+
+const log = logger.child({ scope: "alerts" });
+
+export const alertSourceSchema = z.enum(["logs", "traces", "metric"]);
+export const alertAggregationSchema = z.enum(["count", "sum", "avg"]);
+export const alertComparatorSchema = z.enum(["gt", "lt"]);
+export const alertGroupModeSchema = z.enum(["per_group", "single"]);
+
+const resourceAttrSchema = z.object({
+ key: z.string().min(1).max(200),
+ value: z.string().max(500),
+});
+
+export const alertFilterSchema = z.object({
+ resourceAttrs: z.array(resourceAttrSchema).max(50).optional(),
+ service: z.string().max(200).optional(),
+ severity: z.string().max(50).optional(),
+ spanName: z.string().max(200).optional(),
+ statusCode: z.string().max(50).optional(),
+ minDurationMs: z.number().nonnegative().optional(),
+});
+
+export const alertInputSchema = z.object({
+ name: z.string().min(1).max(200),
+ enabled: z.boolean().optional(),
+ source: alertSourceSchema,
+ metricName: z.string().max(200).nullable().optional(),
+ filter: alertFilterSchema.optional(),
+ groupBy: z.string().max(200).nullable().optional(),
+ groupMode: alertGroupModeSchema.optional(),
+ aggregation: alertAggregationSchema,
+ comparator: alertComparatorSchema,
+ threshold: z.number().finite(),
+ windowMinutes: z.number().int().min(1).max(1440).optional(),
+ evaluationIntervalSeconds: z.number().int().min(15).max(3600).optional(),
+});
+
+export type AlertInput = z.infer;
+
+export function validateAlertInput(input: AlertInput): void {
+ if (input.source === "metric" && !input.metricName) {
+ throw new HTTPException(400, { message: "metricName required when source = metric" });
+ }
+ if (input.source !== "metric" && input.aggregation !== "count") {
+ throw new HTTPException(400, {
+ message: "aggregation must be 'count' for logs/traces sources",
+ });
+ }
+ if (input.source === "metric" && input.aggregation === "count") {
+ throw new HTTPException(400, {
+ message: "aggregation must be 'sum' or 'avg' for metric source",
+ });
+ }
+ if (input.groupMode === "per_group" && !input.groupBy) {
+ throw new HTTPException(400, { message: "groupBy required when groupMode = per_group" });
+ }
+}
+
+export function alertInputToFilter(input: AlertInput): schema.AlertFilter {
+ return {
+ resourceAttrs: input.filter?.resourceAttrs,
+ service: input.filter?.service,
+ severity: input.filter?.severity,
+ spanName: input.filter?.spanName,
+ statusCode: input.filter?.statusCode,
+ minDurationMs: input.filter?.minDurationMs,
+ };
+}
+
+function rangeForWindow(windowMinutes: number): { since: string; until: string } {
+ const now = new Date();
+ const since = new Date(now.getTime() - windowMinutes * 60_000);
+ return { since: since.toISOString(), until: now.toISOString() };
+}
+
+function compare(value: number, comparator: schema.AlertComparator, threshold: number): boolean {
+ if (comparator === "gt") return value > threshold;
+ return value < threshold;
+}
+
+export async function evaluateAlertQuery(
+ ch: ClickHouseClient,
+ alert: {
+ projectId: string;
+ source: schema.AlertSource;
+ metricName: string | null;
+ filter: schema.AlertFilter;
+ groupBy: string | null;
+ groupMode: schema.AlertGroupMode;
+ aggregation: schema.AlertAggregation;
+ windowMinutes: number;
+ },
+): Promise<{ groups: Map; total: number }> {
+ const range = rangeForWindow(alert.windowMinutes);
+ const rangeSeconds = alert.windowMinutes * 60;
+ const step = pickStep(rangeSeconds);
+ const resourceAttrs: ResourceAttrFilter[] | undefined = alert.filter.resourceAttrs;
+ const groupBy = alert.groupBy || undefined;
+
+ const groupSums = new Map();
+ const groupCounts = new Map();
+
+ if (alert.source === "metric") {
+ if (!alert.metricName) return { groups: new Map(), total: 0 };
+ const rows = await metricSeries(
+ ch,
+ alert.projectId,
+ alert.metricName,
+ {
+ range,
+ service: alert.filter.service,
+ resourceAttrs,
+ },
+ groupBy,
+ step,
+ );
+ for (const row of rows) {
+ const key = row.group ?? "";
+ groupSums.set(key, (groupSums.get(key) ?? 0) + row.value);
+ groupCounts.set(key, (groupCounts.get(key) ?? 0) + 1);
+ }
+ } else {
+ const rows = await countSeries(
+ ch,
+ alert.projectId,
+ alert.source,
+ {
+ range,
+ service: alert.filter.service,
+ resourceAttrs,
+ severity: alert.filter.severity,
+ spanName: alert.filter.spanName,
+ statusCode: alert.filter.statusCode,
+ minDurationMs: alert.filter.minDurationMs,
+ },
+ groupBy,
+ step,
+ );
+ for (const row of rows) {
+ const key = row.group ?? "";
+ groupSums.set(key, (groupSums.get(key) ?? 0) + row.count);
+ groupCounts.set(key, (groupCounts.get(key) ?? 0) + 1);
+ }
+ }
+
+ const groups = new Map();
+ for (const [key, sum] of groupSums) {
+ if (alert.aggregation === "avg") {
+ const n = groupCounts.get(key) ?? 1;
+ groups.set(key, n > 0 ? sum / n : 0);
+ } else {
+ groups.set(key, sum);
+ }
+ }
+
+ let total = 0;
+ if (alert.aggregation === "avg") {
+ let sumAll = 0;
+ let nAll = 0;
+ for (const [key, sum] of groupSums) {
+ sumAll += sum;
+ nAll += groupCounts.get(key) ?? 0;
+ }
+ total = nAll > 0 ? sumAll / nAll : 0;
+ } else {
+ for (const value of groupSums.values()) total += value;
+ }
+
+ return { groups, total };
+}
+
+export type AlertEvalResult =
+ | { mode: "single"; value: number; breaches: number }
+ | {
+ mode: "per_group";
+ groups: { key: string; value: number; breaching: boolean }[];
+ breaches: number;
+ };
+
+export function summarizeEvaluation(
+ alert: {
+ comparator: schema.AlertComparator;
+ threshold: number;
+ groupMode: schema.AlertGroupMode;
+ groupBy: string | null;
+ },
+ evaluation: { groups: Map; total: number },
+): AlertEvalResult {
+ if (alert.groupMode === "per_group" && alert.groupBy) {
+ const rows: { key: string; value: number; breaching: boolean }[] = [];
+ for (const [key, value] of evaluation.groups) {
+ rows.push({
+ key,
+ value,
+ breaching: compare(value, alert.comparator, alert.threshold),
+ });
+ }
+ rows.sort((a, b) => b.value - a.value);
+ return {
+ mode: "per_group",
+ groups: rows,
+ breaches: rows.filter((g) => g.breaching).length,
+ };
+ }
+ return {
+ mode: "single",
+ value: evaluation.total,
+ breaches: compare(evaluation.total, alert.comparator, alert.threshold) ? 1 : 0,
+ };
+}
+
+export async function listAlertsForProject(projectId: string): Promise {
+ return db.query.alerts.findMany({
+ where: eq(schema.alerts.projectId, projectId),
+ orderBy: [asc(schema.alerts.name)],
+ });
+}
+
+export async function getAlertWithFirings(
+ projectId: string,
+ id: string,
+): Promise<(schema.Alert & { firings: schema.AlertFiring[] }) | null> {
+ const alert = await db.query.alerts.findFirst({
+ where: and(eq(schema.alerts.id, id), eq(schema.alerts.projectId, projectId)),
+ });
+ if (!alert) return null;
+ const firings = await db.query.alertFirings.findMany({
+ where: eq(schema.alertFirings.alertId, id),
+ orderBy: [desc(schema.alertFirings.evaluatedAt)],
+ limit: 50,
+ });
+ return { ...alert, firings };
+}
+
+export async function createAlertRecord(
+ projectId: string,
+ userId: string,
+ input: AlertInput,
+): Promise {
+ validateAlertInput(input);
+ const inserted = await db
+ .insert(schema.alerts)
+ .values({
+ projectId,
+ name: input.name,
+ enabled: input.enabled ?? true,
+ source: input.source,
+ metricName: input.metricName ?? null,
+ filter: alertInputToFilter(input),
+ groupBy: input.groupBy ?? null,
+ groupMode: input.groupMode ?? "single",
+ aggregation: input.aggregation,
+ comparator: input.comparator,
+ threshold: input.threshold,
+ windowMinutes: input.windowMinutes ?? 5,
+ evaluationIntervalSeconds: input.evaluationIntervalSeconds ?? 60,
+ createdBy: userId,
+ })
+ .returning();
+ log.info(
+ {
+ alert_id: inserted[0]?.id,
+ project_id: projectId,
+ actor_user_id: userId,
+ source: input.source,
+ comparator: input.comparator,
+ threshold: input.threshold,
+ window_minutes: input.windowMinutes ?? 5,
+ },
+ "alert created",
+ );
+ const row = inserted[0];
+ if (!row) throw new Error("alerts insert returned no rows");
+ return row;
+}
+
+export async function updateAlertRecord(
+ projectId: string,
+ id: string,
+ patch: Partial,
+): Promise {
+ const existing = await db.query.alerts.findFirst({
+ where: and(eq(schema.alerts.id, id), eq(schema.alerts.projectId, projectId)),
+ });
+ if (!existing) return null;
+
+ const merged: AlertInput = {
+ name: patch.name ?? existing.name,
+ enabled: patch.enabled ?? existing.enabled,
+ source: patch.source ?? existing.source,
+ metricName: patch.metricName !== undefined ? patch.metricName : existing.metricName,
+ filter: patch.filter ?? existing.filter,
+ groupBy: patch.groupBy !== undefined ? patch.groupBy : existing.groupBy,
+ groupMode: patch.groupMode ?? existing.groupMode,
+ aggregation: patch.aggregation ?? existing.aggregation,
+ comparator: patch.comparator ?? existing.comparator,
+ threshold: patch.threshold ?? existing.threshold,
+ windowMinutes: patch.windowMinutes ?? existing.windowMinutes,
+ evaluationIntervalSeconds:
+ patch.evaluationIntervalSeconds ?? existing.evaluationIntervalSeconds,
+ };
+ validateAlertInput(merged);
+
+ const updated = await db
+ .update(schema.alerts)
+ .set({
+ name: merged.name,
+ enabled: merged.enabled ?? existing.enabled,
+ source: merged.source,
+ metricName: merged.metricName ?? null,
+ filter: alertInputToFilter(merged),
+ groupBy: merged.groupBy ?? null,
+ groupMode: merged.groupMode ?? "single",
+ aggregation: merged.aggregation,
+ comparator: merged.comparator,
+ threshold: merged.threshold,
+ windowMinutes: merged.windowMinutes ?? 5,
+ evaluationIntervalSeconds: merged.evaluationIntervalSeconds ?? 60,
+ updatedAt: new Date(),
+ })
+ .where(and(eq(schema.alerts.id, id), eq(schema.alerts.projectId, projectId)))
+ .returning();
+ return updated[0] ?? null;
+}
+
+export async function deleteAlertRecord(
+ projectId: string,
+ id: string,
+ actorUserId: string,
+): Promise {
+ await db
+ .delete(schema.alerts)
+ .where(and(eq(schema.alerts.id, id), eq(schema.alerts.projectId, projectId)));
+ log.info({ alert_id: id, project_id: projectId, actor_user_id: actorUserId }, "alert deleted");
+}
+
+export async function previewAlert(
+ ch: ClickHouseClient,
+ projectId: string,
+ input: AlertInput,
+): Promise {
+ validateAlertInput(input);
+ const evaluation = await evaluateAlertQuery(ch, {
+ projectId,
+ source: input.source,
+ metricName: input.metricName ?? null,
+ filter: alertInputToFilter(input),
+ groupBy: input.groupBy ?? null,
+ groupMode: input.groupMode ?? "single",
+ aggregation: input.aggregation,
+ windowMinutes: input.windowMinutes ?? 5,
+ });
+ return summarizeEvaluation(
+ {
+ comparator: input.comparator,
+ threshold: input.threshold,
+ groupMode: input.groupMode ?? "single",
+ groupBy: input.groupBy ?? null,
+ },
+ evaluation,
+ );
+}
+
+export async function testAlertById(
+ ch: ClickHouseClient,
+ projectId: string,
+ id: string,
+): Promise {
+ const alert = await db.query.alerts.findFirst({
+ where: and(eq(schema.alerts.id, id), eq(schema.alerts.projectId, projectId)),
+ });
+ if (!alert) return null;
+ const evaluation = await evaluateAlertQuery(ch, {
+ projectId: alert.projectId,
+ source: alert.source,
+ metricName: alert.metricName,
+ filter: alert.filter,
+ groupBy: alert.groupBy,
+ groupMode: alert.groupMode,
+ aggregation: alert.aggregation,
+ windowMinutes: alert.windowMinutes,
+ });
+ return summarizeEvaluation(alert, evaluation);
+}
diff --git a/superlog/apps/api/src/alerts.ts b/superlog/apps/api/src/alerts.ts
new file mode 100644
index 0000000000000000000000000000000000000000..36b337ca25a33fd772b420cd97f4c491349f0631
--- /dev/null
+++ b/superlog/apps/api/src/alerts.ts
@@ -0,0 +1,97 @@
+import type { ClickHouseClient } from "@clickhouse/client";
+import { db, schema } from "@superlog/db";
+import { eq } from "drizzle-orm";
+import type { Context, Hono } from "hono";
+import { HTTPException } from "hono/http-exception";
+import {
+ alertInputSchema,
+ createAlertRecord,
+ deleteAlertRecord,
+ getAlertWithFirings,
+ listAlertsForProject,
+ previewAlert,
+ testAlertById,
+ updateAlertRecord,
+} from "./alerts-service.js";
+import { resolveActiveOrgContext } from "./org-context.js";
+
+type Vars = { userId: string; orgId: string | null };
+
+export { evaluateAlertQuery } from "./alerts-service.js";
+
+export function mountAlerts(app: Hono<{ Variables: Vars }>, opts: { ch: ClickHouseClient }) {
+ const requireAccess = async (c: Context<{ Variables: Vars }>, projectId: string) => {
+ const project = await db.query.projects.findFirst({
+ where: eq(schema.projects.id, projectId),
+ });
+ if (!project) throw new HTTPException(404, { message: "project not found" });
+ const ctx = await resolveActiveOrgContext({
+ userId: c.var.userId,
+ preferredOrgId: c.var.orgId,
+ });
+ if (project.orgId !== ctx.org.id) throw new HTTPException(403, { message: "forbidden" });
+ return { project, user: ctx.user };
+ };
+
+ app.get("/api/projects/:projectId/alerts", async (c) => {
+ const projectId = c.req.param("projectId");
+ await requireAccess(c, projectId);
+ return c.json(await listAlertsForProject(projectId));
+ });
+
+ app.post("/api/projects/:projectId/alerts", async (c) => {
+ const projectId = c.req.param("projectId");
+ const { user } = await requireAccess(c, projectId);
+ const body = await c.req.json().catch(() => ({}));
+ const parsed = alertInputSchema.safeParse(body);
+ if (!parsed.success) throw new HTTPException(400, { message: "invalid body" });
+ return c.json(await createAlertRecord(projectId, user.id, parsed.data));
+ });
+
+ app.get("/api/projects/:projectId/alerts/:id", async (c) => {
+ const projectId = c.req.param("projectId");
+ const id = c.req.param("id");
+ await requireAccess(c, projectId);
+ const alert = await getAlertWithFirings(projectId, id);
+ if (!alert) throw new HTTPException(404, { message: "alert not found" });
+ return c.json(alert);
+ });
+
+ app.patch("/api/projects/:projectId/alerts/:id", async (c) => {
+ const projectId = c.req.param("projectId");
+ const id = c.req.param("id");
+ await requireAccess(c, projectId);
+ const body = await c.req.json().catch(() => ({}));
+ const parsed = alertInputSchema.partial().safeParse(body);
+ if (!parsed.success) throw new HTTPException(400, { message: "invalid body" });
+ const updated = await updateAlertRecord(projectId, id, parsed.data);
+ if (!updated) throw new HTTPException(404, { message: "alert not found" });
+ return c.json(updated);
+ });
+
+ app.delete("/api/projects/:projectId/alerts/:id", async (c) => {
+ const projectId = c.req.param("projectId");
+ const id = c.req.param("id");
+ const { user } = await requireAccess(c, projectId);
+ await deleteAlertRecord(projectId, id, user.id);
+ return c.json({ ok: true });
+ });
+
+ app.post("/api/projects/:projectId/alerts/preview", async (c) => {
+ const projectId = c.req.param("projectId");
+ await requireAccess(c, projectId);
+ const body = await c.req.json().catch(() => ({}));
+ const parsed = alertInputSchema.safeParse(body);
+ if (!parsed.success) throw new HTTPException(400, { message: "invalid body" });
+ return c.json(await previewAlert(opts.ch, projectId, parsed.data));
+ });
+
+ app.post("/api/projects/:projectId/alerts/:id/test", async (c) => {
+ const projectId = c.req.param("projectId");
+ const id = c.req.param("id");
+ await requireAccess(c, projectId);
+ const result = await testAlertById(opts.ch, projectId, id);
+ if (!result) throw new HTTPException(404, { message: "alert not found" });
+ return c.json(result);
+ });
+}
diff --git a/superlog/apps/api/src/auth.ts b/superlog/apps/api/src/auth.ts
new file mode 100644
index 0000000000000000000000000000000000000000..fad65833a4febb45d5d02ceddb91b7dd92a86c45
--- /dev/null
+++ b/superlog/apps/api/src/auth.ts
@@ -0,0 +1,217 @@
+import { db, schema } from "@superlog/db";
+import { betterAuth } from "better-auth";
+import { drizzleAdapter } from "better-auth/adapters/drizzle";
+import { admin, organization } from "better-auth/plugins";
+import { autumn } from "autumn-js/better-auth";
+import { eq } from "drizzle-orm";
+import {
+ orgInvitationEmailBody,
+ passwordResetEmailBody,
+ sendEmail,
+ verificationEmailBody,
+} from "./email.js";
+
+// Better Auth server config. Mounted at /api/auth/* by apps/api/src/index.ts.
+//
+// Schema mapping: we reuse our existing `users`, `orgs`, `org_members` tables
+// instead of letting Better Auth own parallel ones. The drizzle adapter
+// accepts a `schema` map keyed by the model names BA expects. The
+// organization plugin's `member.organizationId` and `invitation.organizationId`
+// columns live on the `orgId` TS property in our schema, so we map those
+// explicitly via the plugin's `schema.X.fields` config.
+//
+// Identity is UUID end-to-end: `advanced.database.generateId` makes BA emit
+// UUIDs for new rows so foreign keys to our existing uuid columns line up.
+
+const WEB_ORIGIN = process.env.WEB_ORIGIN ?? "http://localhost:5173";
+const API_ORIGIN = process.env.BETTER_AUTH_URL ?? "http://localhost:4100";
+
+// Derive the parent domain that web and api share so the OAuth state cookie
+// is scoped to `.` instead of pinned to the api host. For prod that's
+// `.superlog.sh`; for localhost / .superlog.localhost it's `undefined`
+// because browsers won't honour Domain= on a TLD or localhost.
+function sharedCookieDomain(): string | undefined {
+ try {
+ const apiHost = new URL(API_ORIGIN).hostname;
+ const webHost = new URL(WEB_ORIGIN).hostname;
+ if (apiHost === webHost) return undefined; // same host, no cross-sub needed
+ if (apiHost === "localhost" || webHost === "localhost") return undefined;
+ if (apiHost.endsWith(".localhost") || webHost.endsWith(".localhost")) return undefined;
+ // Trim to the longest common dotted suffix shared by both hosts. For
+ // api.superlog.sh / superlog.sh that yields "superlog.sh".
+ const a = apiHost.split(".");
+ const b = webHost.split(".");
+ const parts: string[] = [];
+ while (a.length && b.length && a[a.length - 1] === b[b.length - 1]) {
+ parts.unshift(a.pop() as string);
+ b.pop();
+ }
+ if (parts.length < 2) return undefined; // needs at least eTLD+1
+ return `.${parts.join(".")}`;
+ } catch {
+ return undefined;
+ }
+}
+
+const COOKIE_DOMAIN = sharedCookieDomain();
+
+// Autumn billing plugin. Maps each org (a Better Auth "organization") to an
+// Autumn customer, auto-created on first sign-in, so billing is per-org. Only
+// enabled when AUTUMN_SECRET_KEY is present, so local dev / worktrees without
+// billing config keep working — same opt-in pattern as the social providers.
+const autumnPlugins = process.env.AUTUMN_SECRET_KEY
+ ? [autumn({ customerScope: "organization" })]
+ : [];
+
+// Fail loud and early if the signing secret is missing. Better Auth doesn't
+// throw on undefined `secret` — depending on the build it falls back to a
+// fresh random value per process start, which silently invalidates every
+// session on every deploy. Match the previous CLERK_SECRET_KEY guard so
+// missing config surfaces as a startup error, not a slow-burn outage.
+const BETTER_AUTH_SECRET = process.env.BETTER_AUTH_SECRET;
+if (!BETTER_AUTH_SECRET) throw new Error("BETTER_AUTH_SECRET is required");
+
+// Both providers stay disabled when their client IDs are missing — Better
+// Auth's `socialProviders` map only enables what's present, so dropping the
+// keys here is enough to hide the buttons in dev without breaking sign-up.
+const googleProvider =
+ process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET
+ ? {
+ clientId: process.env.GOOGLE_CLIENT_ID,
+ clientSecret: process.env.GOOGLE_CLIENT_SECRET,
+ }
+ : undefined;
+
+const githubProvider =
+ process.env.GITHUB_CLIENT_ID && process.env.GITHUB_CLIENT_SECRET
+ ? {
+ clientId: process.env.GITHUB_CLIENT_ID,
+ clientSecret: process.env.GITHUB_CLIENT_SECRET,
+ // GitHub returns email: null in /user when the address is set to
+ // "Keep my email addresses private". user:email scope lets Better Auth
+ // fall back to /user/emails for the verified primary. Has no effect on
+ // GitHub Apps (which derive scopes from the App's User permissions) —
+ // for those, enable the "Email addresses" user permission on the App.
+ scope: ["user:email", "read:user"],
+ }
+ : undefined;
+
+// Picks the org to make active for a session. Prefers an explicit override
+// (set via the org plugin's setActive API) then falls back to the user's
+// first membership. Returns null if the user has no memberships — that's an
+// error state callers can decide how to handle.
+async function pickActiveOrgId(userId: string): Promise {
+ const membership = await db.query.orgMembers.findFirst({
+ where: eq(schema.orgMembers.userId, userId),
+ });
+ return membership?.orgId ?? null;
+}
+
+export const auth = betterAuth({
+ baseURL: API_ORIGIN,
+ secret: BETTER_AUTH_SECRET,
+ trustedOrigins: [WEB_ORIGIN],
+ database: drizzleAdapter(db, {
+ provider: "pg",
+ schema: {
+ user: schema.users,
+ session: schema.sessions,
+ account: schema.accounts,
+ verification: schema.verifications,
+ organization: schema.orgs,
+ member: schema.orgMembers,
+ invitation: schema.invitations,
+ },
+ }),
+ advanced: {
+ database: {
+ generateId: () => crypto.randomUUID(),
+ },
+ // Web and API often live on different subdomains of the same parent
+ // domain (e.g. `superlog.sh` + `api.superlog.sh`). The OAuth state
+ // cookie has to round-trip from BA's `/api/auth/sign-in/social` response
+ // through the provider and back to `/api/auth/callback/`; if
+ // the cookie is pinned to the API host instead of the parent domain it
+ // can fail to ride along on some browsers and BA returns
+ // `State mismatch: verification not found`. When the hosts share an
+ // eTLD+1, scope to the parent. localhost / *.localhost are skipped
+ // because browsers reject Domain= on those.
+ ...(COOKIE_DOMAIN
+ ? {
+ crossSubDomainCookies: { enabled: true, domain: COOKIE_DOMAIN },
+ defaultCookieAttributes: { sameSite: "lax" as const, secure: true },
+ }
+ : {}),
+ },
+ emailAndPassword: {
+ enabled: true,
+ // Sign-ups still land logged-in immediately to keep dev/worktree flows
+ // fast — the verification email is still sent (see `emailVerification`
+ // below) so the user can confirm at their leisure. Flip this to true once
+ // we want to gate /api/* access on a verified address.
+ requireEmailVerification: false,
+ sendResetPassword: async ({ user, url }) => {
+ const body = passwordResetEmailBody(url);
+ await sendEmail({ to: user.email, subject: "Reset your Superlog password", ...body });
+ },
+ },
+ emailVerification: {
+ sendOnSignUp: true,
+ autoSignInAfterVerification: true,
+ sendVerificationEmail: async ({ user, url }) => {
+ const body = verificationEmailBody(url);
+ await sendEmail({ to: user.email, subject: "Confirm your email for Superlog", ...body });
+ },
+ },
+ socialProviders: {
+ ...(googleProvider ? { google: googleProvider } : {}),
+ ...(githubProvider ? { github: githubProvider } : {}),
+ },
+ plugins: [
+ organization({
+ schema: {
+ // Our `org_members` table uses `orgId` (TS) / `org_id` (SQL) instead
+ // of Better Auth's expected `organizationId`. Same for `invitations`.
+ member: {
+ fields: { organizationId: "orgId" },
+ },
+ invitation: {
+ fields: { organizationId: "orgId" },
+ },
+ },
+ sendInvitationEmail: async ({ id, email, role, organization, inviter }) => {
+ const url = `${WEB_ORIGIN}/accept-invitation?id=${encodeURIComponent(id)}`;
+ const body = orgInvitationEmailBody({
+ url,
+ orgName: organization.name,
+ inviterEmail: inviter.user.email,
+ inviterName: inviter.user.name,
+ role,
+ });
+ await sendEmail({
+ to: email,
+ subject: `You're invited to ${organization.name} on Superlog`,
+ ...body,
+ });
+ },
+ }),
+ admin(),
+ ...autumnPlugins,
+ ],
+ databaseHooks: {
+ session: {
+ create: {
+ before: async (session) => {
+ // Seed the active org so the first request after sign-in already
+ // has org context. If the user has no memberships yet (e.g. on a
+ // bootstrap race) leave it null; callers handle that.
+ if (session.activeOrganizationId) return { data: session };
+ const activeOrganizationId = await pickActiveOrgId(session.userId);
+ return { data: { ...session, activeOrganizationId } };
+ },
+ },
+ },
+ },
+});
+
+export type Auth = typeof auth;
diff --git a/superlog/apps/api/src/boot-migrations.test.ts b/superlog/apps/api/src/boot-migrations.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..dba0b5c0850fa1f5b29485939e980e8fce5086f2
--- /dev/null
+++ b/superlog/apps/api/src/boot-migrations.test.ts
@@ -0,0 +1,28 @@
+import assert from "node:assert/strict";
+import { test } from "node:test";
+import { shouldRunMigrationsOnBoot } from "./boot-migrations.js";
+
+test("defaults to running migrations on boot when unset", () => {
+ assert.equal(shouldRunMigrationsOnBoot({} as NodeJS.ProcessEnv), true);
+});
+
+test("runs on boot when explicitly enabled", () => {
+ assert.equal(
+ shouldRunMigrationsOnBoot({ RUN_MIGRATIONS_ON_BOOT: "true" } as NodeJS.ProcessEnv),
+ true,
+ );
+});
+
+test("skips boot migrations only when explicitly set to 'false'", () => {
+ assert.equal(
+ shouldRunMigrationsOnBoot({ RUN_MIGRATIONS_ON_BOOT: "false" } as NodeJS.ProcessEnv),
+ false,
+ );
+});
+
+test("any non-'false' value still runs on boot", () => {
+ assert.equal(
+ shouldRunMigrationsOnBoot({ RUN_MIGRATIONS_ON_BOOT: "0" } as NodeJS.ProcessEnv),
+ true,
+ );
+});
diff --git a/superlog/apps/api/src/boot-migrations.ts b/superlog/apps/api/src/boot-migrations.ts
new file mode 100644
index 0000000000000000000000000000000000000000..ec365312f547e7702219b67c422a61b90de83fe9
--- /dev/null
+++ b/superlog/apps/api/src/boot-migrations.ts
@@ -0,0 +1,15 @@
+// Whether the API process should apply database migrations on boot.
+//
+// In a managed deployment, migrations should be applied by a dedicated,
+// gated step that runs as a schema-owner role *before* any service rolls out.
+// The long-running API process should then connect as a DML-only role that
+// can't run DDL on boot — applying DDL from a rolling app deploy can crash-loop
+// the service. Such a deployment sets RUN_MIGRATIONS_ON_BOOT=false.
+//
+// Locally (overmind, plain `pnpm dev`) there's no separate migrate step, so the
+// default is to run on boot — preserving the existing developer experience.
+// Worktree bootstrap already migrates explicitly, so this only affects the
+// main-checkout dev loop.
+export function shouldRunMigrationsOnBoot(env: NodeJS.ProcessEnv): boolean {
+ return env.RUN_MIGRATIONS_ON_BOOT !== "false";
+}
diff --git a/superlog/apps/api/src/dashboards-service.ts b/superlog/apps/api/src/dashboards-service.ts
new file mode 100644
index 0000000000000000000000000000000000000000..4688dcc69681ec20dd1d332c29a41b03e488509c
--- /dev/null
+++ b/superlog/apps/api/src/dashboards-service.ts
@@ -0,0 +1,252 @@
+import { db, schema } from "@superlog/db";
+import { and, asc, eq } from "drizzle-orm";
+import { z } from "zod";
+
+export const dashboardWidgetTypeSchema = z.enum([
+ "timeseries_count",
+ "timeseries_metric",
+ "trace_table",
+ "log_table",
+ "markdown",
+]);
+
+const resourceAttrSchema = z.object({
+ key: z.string().min(1).max(200),
+ value: z.string().max(500),
+ op: z.enum(["eq", "neq", "not_contains"]).optional(),
+});
+
+export const dashboardWidgetConfigSchema = z.object({
+ source: z.enum(["logs", "traces"]).optional(),
+ filter: z.object({
+ resourceAttrs: z.array(resourceAttrSchema).max(50).optional(),
+ }),
+ groupBy: z.string().max(200).optional(),
+ metricName: z.string().max(200).optional(),
+ aggregation: z.enum(["sum", "avg", "min", "max", "p95", "p99"]).optional(),
+ limit: z.number().int().positive().max(500).optional(),
+ chartType: z.enum(["line", "bar"]).optional(),
+ showXAxis: z.boolean().optional(),
+ showYAxis: z.boolean().optional(),
+ showLegend: z.boolean().optional(),
+ legendPosition: z.enum(["side", "bottom"]).optional(),
+ markdown: z.string().max(20_000).optional(),
+});
+
+export const dashboardWidgetLayoutSchema = z.object({
+ x: z.number().int().min(0).max(48),
+ y: z.number().int().min(0).max(100000),
+ w: z.number().int().min(1).max(48),
+ h: z.number().int().min(1).max(100),
+});
+
+export const dashboardCreateSchema = z.object({ name: z.string().min(1).max(120) });
+export const dashboardUpdateSchema = z.object({ name: z.string().min(1).max(120) });
+
+export const dashboardWidgetCreateSchema = z.object({
+ type: dashboardWidgetTypeSchema,
+ title: z.string().min(1).max(200),
+ config: dashboardWidgetConfigSchema,
+ layout: dashboardWidgetLayoutSchema,
+});
+
+export const dashboardWidgetUpdateSchema = z.object({
+ type: dashboardWidgetTypeSchema.optional(),
+ title: z.string().min(1).max(200).optional(),
+ config: dashboardWidgetConfigSchema.optional(),
+ layout: dashboardWidgetLayoutSchema.optional(),
+});
+
+export type DashboardCreateInput = z.infer;
+export type DashboardUpdateInput = z.infer;
+export type DashboardWidgetCreateInput = z.infer;
+export type DashboardWidgetUpdateInput = z.infer;
+
+const slugFromName = (name: string) =>
+ name
+ .toLowerCase()
+ .trim()
+ .replace(/[^a-z0-9]+/g, "-")
+ .replace(/^-+|-+$/g, "")
+ .slice(0, 60) || "dashboard";
+
+export async function listDashboardsForProject(projectId: string): Promise {
+ return db.query.dashboards.findMany({
+ where: eq(schema.dashboards.projectId, projectId),
+ orderBy: [asc(schema.dashboards.name)],
+ });
+}
+
+export async function getDashboardWithWidgets(
+ projectId: string,
+ id: string,
+): Promise<(schema.Dashboard & { widgets: schema.DashboardWidget[] }) | null> {
+ const dashboard = await db.query.dashboards.findFirst({
+ where: and(eq(schema.dashboards.id, id), eq(schema.dashboards.projectId, projectId)),
+ });
+ if (!dashboard) return null;
+ const widgets = await db.query.dashboardWidgets.findMany({
+ where: eq(schema.dashboardWidgets.dashboardId, id),
+ orderBy: [asc(schema.dashboardWidgets.position)],
+ });
+ return { ...dashboard, widgets };
+}
+
+export async function createDashboard(
+ projectId: string,
+ userId: string,
+ input: DashboardCreateInput,
+): Promise {
+ const baseSlug = slugFromName(input.name);
+ let slug = baseSlug;
+ for (let i = 2; i < 100; i++) {
+ const existing = await db.query.dashboards.findFirst({
+ where: and(eq(schema.dashboards.projectId, projectId), eq(schema.dashboards.slug, slug)),
+ });
+ if (!existing) break;
+ slug = `${baseSlug}-${i}`;
+ }
+ const inserted = await db
+ .insert(schema.dashboards)
+ .values({ projectId, name: input.name, slug, createdBy: userId })
+ .returning();
+ const row = inserted[0];
+ if (!row) throw new Error("dashboards insert returned no rows");
+ return row;
+}
+
+export async function updateDashboard(
+ projectId: string,
+ id: string,
+ input: DashboardUpdateInput,
+): Promise {
+ const updated = await db
+ .update(schema.dashboards)
+ .set({ name: input.name, updatedAt: new Date() })
+ .where(and(eq(schema.dashboards.id, id), eq(schema.dashboards.projectId, projectId)))
+ .returning();
+ return updated[0] ?? null;
+}
+
+export async function deleteDashboard(projectId: string, id: string): Promise {
+ await db
+ .delete(schema.dashboards)
+ .where(and(eq(schema.dashboards.id, id), eq(schema.dashboards.projectId, projectId)));
+}
+
+async function ensureDashboardOwned(
+ projectId: string,
+ dashboardId: string,
+): Promise {
+ return (
+ (await db.query.dashboards.findFirst({
+ where: and(eq(schema.dashboards.id, dashboardId), eq(schema.dashboards.projectId, projectId)),
+ })) ?? null
+ );
+}
+
+export async function addDashboardWidget(
+ projectId: string,
+ dashboardId: string,
+ input: DashboardWidgetCreateInput,
+): Promise {
+ const dashboard = await ensureDashboardOwned(projectId, dashboardId);
+ if (!dashboard) return null;
+ const existing = await db.query.dashboardWidgets.findMany({
+ where: eq(schema.dashboardWidgets.dashboardId, dashboardId),
+ });
+ const nextPosition = existing.reduce((m, w) => Math.max(m, w.position), -1) + 1;
+ const inserted = await db
+ .insert(schema.dashboardWidgets)
+ .values({
+ dashboardId,
+ type: input.type,
+ title: input.title,
+ config: input.config,
+ layout: input.layout,
+ position: nextPosition,
+ })
+ .returning();
+ await db
+ .update(schema.dashboards)
+ .set({ updatedAt: new Date() })
+ .where(eq(schema.dashboards.id, dashboardId));
+ return inserted[0] ?? null;
+}
+
+export async function updateDashboardWidget(
+ projectId: string,
+ dashboardId: string,
+ widgetId: string,
+ input: DashboardWidgetUpdateInput,
+): Promise {
+ const dashboard = await ensureDashboardOwned(projectId, dashboardId);
+ if (!dashboard) return null;
+ const updated = await db
+ .update(schema.dashboardWidgets)
+ .set({
+ ...(input.type !== undefined ? { type: input.type } : {}),
+ ...(input.title !== undefined ? { title: input.title } : {}),
+ ...(input.config !== undefined ? { config: input.config } : {}),
+ ...(input.layout !== undefined ? { layout: input.layout } : {}),
+ updatedAt: new Date(),
+ })
+ .where(
+ and(
+ eq(schema.dashboardWidgets.id, widgetId),
+ eq(schema.dashboardWidgets.dashboardId, dashboardId),
+ ),
+ )
+ .returning();
+ if (!updated[0]) return null;
+ await db
+ .update(schema.dashboards)
+ .set({ updatedAt: new Date() })
+ .where(eq(schema.dashboards.id, dashboardId));
+ return updated[0];
+}
+
+export async function deleteDashboardWidget(
+ projectId: string,
+ dashboardId: string,
+ widgetId: string,
+): Promise {
+ const dashboard = await ensureDashboardOwned(projectId, dashboardId);
+ if (!dashboard) return false;
+ await db
+ .delete(schema.dashboardWidgets)
+ .where(
+ and(
+ eq(schema.dashboardWidgets.id, widgetId),
+ eq(schema.dashboardWidgets.dashboardId, dashboardId),
+ ),
+ );
+ return true;
+}
+
+export async function updateDashboardLayout(
+ projectId: string,
+ dashboardId: string,
+ widgets: { id: string; layout: z.infer }[],
+): Promise {
+ const dashboard = await ensureDashboardOwned(projectId, dashboardId);
+ if (!dashboard) return false;
+ await Promise.all(
+ widgets.map((w) =>
+ db
+ .update(schema.dashboardWidgets)
+ .set({ layout: w.layout, updatedAt: new Date() })
+ .where(
+ and(
+ eq(schema.dashboardWidgets.id, w.id),
+ eq(schema.dashboardWidgets.dashboardId, dashboardId),
+ ),
+ ),
+ ),
+ );
+ await db
+ .update(schema.dashboards)
+ .set({ updatedAt: new Date() })
+ .where(eq(schema.dashboards.id, dashboardId));
+ return true;
+}
diff --git a/superlog/apps/api/src/dashboards.ts b/superlog/apps/api/src/dashboards.ts
new file mode 100644
index 0000000000000000000000000000000000000000..00ad518c580b4f13e7961604aef53c453542c5e3
--- /dev/null
+++ b/superlog/apps/api/src/dashboards.ts
@@ -0,0 +1,136 @@
+import { db, schema } from "@superlog/db";
+import { eq } from "drizzle-orm";
+import type { Context, Hono } from "hono";
+import { HTTPException } from "hono/http-exception";
+import { z } from "zod";
+import {
+ addDashboardWidget,
+ createDashboard,
+ dashboardCreateSchema,
+ dashboardUpdateSchema,
+ dashboardWidgetCreateSchema,
+ dashboardWidgetLayoutSchema,
+ dashboardWidgetUpdateSchema,
+ deleteDashboard,
+ deleteDashboardWidget,
+ getDashboardWithWidgets,
+ listDashboardsForProject,
+ updateDashboard,
+ updateDashboardLayout,
+ updateDashboardWidget,
+} from "./dashboards-service.js";
+import { resolveActiveOrgContext } from "./org-context.js";
+
+type Vars = { userId: string; orgId: string | null };
+
+export function mountDashboards(app: Hono<{ Variables: Vars }>) {
+ const requireAccess = async (c: Context<{ Variables: Vars }>, projectId: string) => {
+ const project = await db.query.projects.findFirst({
+ where: eq(schema.projects.id, projectId),
+ });
+ if (!project) throw new HTTPException(404, { message: "project not found" });
+ const ctx = await resolveActiveOrgContext({
+ userId: c.var.userId,
+ preferredOrgId: c.var.orgId,
+ });
+ if (project.orgId !== ctx.org.id) throw new HTTPException(403, { message: "forbidden" });
+ return { project, user: ctx.user };
+ };
+
+ app.get("/api/projects/:projectId/dashboards", async (c) => {
+ const projectId = c.req.param("projectId");
+ await requireAccess(c, projectId);
+ return c.json(await listDashboardsForProject(projectId));
+ });
+
+ app.post("/api/projects/:projectId/dashboards", async (c) => {
+ const projectId = c.req.param("projectId");
+ const { user } = await requireAccess(c, projectId);
+ const body = await c.req.json().catch(() => ({}));
+ const parsed = dashboardCreateSchema.safeParse(body);
+ if (!parsed.success) throw new HTTPException(400, { message: "invalid body" });
+ return c.json(await createDashboard(projectId, user.id, parsed.data));
+ });
+
+ app.get("/api/projects/:projectId/dashboards/:id", async (c) => {
+ const projectId = c.req.param("projectId");
+ const id = c.req.param("id");
+ await requireAccess(c, projectId);
+ const dashboard = await getDashboardWithWidgets(projectId, id);
+ if (!dashboard) throw new HTTPException(404, { message: "dashboard not found" });
+ return c.json(dashboard);
+ });
+
+ app.patch("/api/projects/:projectId/dashboards/:id", async (c) => {
+ const projectId = c.req.param("projectId");
+ const id = c.req.param("id");
+ await requireAccess(c, projectId);
+ const body = await c.req.json().catch(() => ({}));
+ const parsed = dashboardUpdateSchema.safeParse(body);
+ if (!parsed.success) throw new HTTPException(400, { message: "invalid body" });
+ const updated = await updateDashboard(projectId, id, parsed.data);
+ if (!updated) throw new HTTPException(404, { message: "dashboard not found" });
+ return c.json(updated);
+ });
+
+ app.delete("/api/projects/:projectId/dashboards/:id", async (c) => {
+ const projectId = c.req.param("projectId");
+ const id = c.req.param("id");
+ await requireAccess(c, projectId);
+ await deleteDashboard(projectId, id);
+ return c.json({ ok: true });
+ });
+
+ app.post("/api/projects/:projectId/dashboards/:id/widgets", async (c) => {
+ const projectId = c.req.param("projectId");
+ const id = c.req.param("id");
+ await requireAccess(c, projectId);
+ const body = await c.req.json().catch(() => ({}));
+ const parsed = dashboardWidgetCreateSchema.safeParse(body);
+ if (!parsed.success) throw new HTTPException(400, { message: "invalid body" });
+ const widget = await addDashboardWidget(projectId, id, parsed.data);
+ if (!widget) throw new HTTPException(404, { message: "dashboard not found" });
+ return c.json(widget);
+ });
+
+ app.patch("/api/projects/:projectId/dashboards/:id/widgets/:widgetId", async (c) => {
+ const projectId = c.req.param("projectId");
+ const id = c.req.param("id");
+ const widgetId = c.req.param("widgetId");
+ await requireAccess(c, projectId);
+ const body = await c.req.json().catch(() => ({}));
+ const parsed = dashboardWidgetUpdateSchema.safeParse(body);
+ if (!parsed.success) throw new HTTPException(400, { message: "invalid body" });
+ const widget = await updateDashboardWidget(projectId, id, widgetId, parsed.data);
+ if (!widget) throw new HTTPException(404, { message: "widget not found" });
+ return c.json(widget);
+ });
+
+ app.patch("/api/projects/:projectId/dashboards/:id/layout", async (c) => {
+ const projectId = c.req.param("projectId");
+ const id = c.req.param("id");
+ await requireAccess(c, projectId);
+ const body = await c.req.json().catch(() => ({}));
+ const parsed = z
+ .object({
+ widgets: z
+ .array(z.object({ id: z.string().uuid(), layout: dashboardWidgetLayoutSchema }))
+ .max(200),
+ })
+ .safeParse(body);
+ if (!parsed.success) throw new HTTPException(400, { message: "invalid body" });
+ const ok = await updateDashboardLayout(projectId, id, parsed.data.widgets);
+ if (!ok) throw new HTTPException(404, { message: "dashboard not found" });
+ return c.json({ ok: true });
+ });
+
+ app.delete("/api/projects/:projectId/dashboards/:id/widgets/:widgetId", async (c) => {
+ const projectId = c.req.param("projectId");
+ const id = c.req.param("id");
+ const widgetId = c.req.param("widgetId");
+ await requireAccess(c, projectId);
+ const ok = await deleteDashboardWidget(projectId, id, widgetId);
+ if (!ok) throw new HTTPException(404, { message: "dashboard not found" });
+ return c.json({ ok: true });
+ });
+}
diff --git a/superlog/apps/api/src/email.ts b/superlog/apps/api/src/email.ts
new file mode 100644
index 0000000000000000000000000000000000000000..ef6528750ef81ee7d59a5014beaa99e8bcd01da2
--- /dev/null
+++ b/superlog/apps/api/src/email.ts
@@ -0,0 +1,99 @@
+import { Resend } from "resend";
+import { logger } from "./logger.js";
+
+// Thin Resend wrapper used by Better Auth for verification + password reset
+// emails. Dev fallback: when RESEND_API_KEY is unset, log the body to stderr
+// instead of throwing — that lets worktree sign-ups continue to work without
+// production-level email credentials.
+
+const log = logger.child({ scope: "email" });
+
+const FROM = process.env.SUPERLOG_FROM_EMAIL ?? "Superlog ";
+const REPLY_TO = process.env.SUPERLOG_REPLY_TO_EMAIL;
+
+let client: Resend | null = null;
+function getClient(): Resend | null {
+ if (client) return client;
+ const key = process.env.RESEND_API_KEY;
+ if (!key) return null;
+ client = new Resend(key);
+ return client;
+}
+
+export type EmailMessage = {
+ to: string;
+ subject: string;
+ html: string;
+ text: string;
+};
+
+export async function sendEmail(message: EmailMessage): Promise {
+ const c = getClient();
+ if (!c) {
+ log.warn(
+ { to: message.to, subject: message.subject },
+ "RESEND_API_KEY not set — printing email body instead of sending",
+ );
+ log.info({ body: message.text }, "email body (dev fallback)");
+ return;
+ }
+ const res = await c.emails.send({
+ from: FROM,
+ to: [message.to],
+ replyTo: REPLY_TO,
+ subject: message.subject,
+ html: message.html,
+ text: message.text,
+ });
+ if (res.error) {
+ log.error({ err: res.error, to: message.to }, "resend send failed");
+ throw new Error(`resend: ${res.error.message ?? "unknown"}`);
+ }
+}
+
+function escape(value: string): string {
+ return value
+ .replace(/&/g, "&")
+ .replace(//g, ">")
+ .replace(/"/g, """);
+}
+
+export function verificationEmailBody(url: string): { html: string; text: string } {
+ const safe = escape(url);
+ return {
+ text: `Confirm your email to start using Superlog.\n\n${url}\n\nIf you didn't sign up, you can ignore this email.`,
+ html: `Confirm your email to start using Superlog.
+${safe}
+If you didn't sign up, you can ignore this email.
`,
+ };
+}
+
+export function passwordResetEmailBody(url: string): { html: string; text: string } {
+ const safe = escape(url);
+ return {
+ text: `Reset your Superlog password.\n\n${url}\n\nThis link expires in 1 hour. If you didn't request a reset, ignore this email.`,
+ html: `Reset your Superlog password.
+${safe}
+This link expires in 1 hour. If you didn't request a reset, ignore this email.
`,
+ };
+}
+
+export function orgInvitationEmailBody(args: {
+ url: string;
+ orgName: string;
+ inviterEmail: string;
+ inviterName?: string | null;
+ role: string;
+}): { html: string; text: string } {
+ const safe = escape(args.url);
+ const org = escape(args.orgName);
+ const inviter = escape(args.inviterName?.trim() ? args.inviterName : args.inviterEmail);
+ const role = escape(args.role);
+ return {
+ text: `${args.inviterName?.trim() || args.inviterEmail} invited you to join ${args.orgName} on Superlog as ${args.role}.\n\nAccept: ${args.url}\n\nIf you weren't expecting this invite, you can ignore it.`,
+ html: `${inviter} invited you to join ${org} on Superlog as ${role}.
+Accept invitation
+If you weren't expecting this invite, you can ignore it.
`,
+ };
+}
diff --git a/superlog/apps/api/src/env.ts b/superlog/apps/api/src/env.ts
new file mode 100644
index 0000000000000000000000000000000000000000..d5e10e9f55499f3f76ae6db5a1081fac9f6d7f77
--- /dev/null
+++ b/superlog/apps/api/src/env.ts
@@ -0,0 +1,8 @@
+import { config as loadDotenv } from "dotenv";
+const portlessPort = process.env.PORTLESS_URL ? process.env.PORT : undefined;
+loadDotenv();
+loadDotenv({ path: ".env.local", override: true });
+if (process.env.SUPERLOG_ENV_FILE) {
+ loadDotenv({ path: process.env.SUPERLOG_ENV_FILE, override: true });
+}
+if (portlessPort) process.env.PORT = portlessPort;
diff --git a/superlog/apps/api/src/feedback.ts b/superlog/apps/api/src/feedback.ts
new file mode 100644
index 0000000000000000000000000000000000000000..4bc4f489f92d511945f65fa356739096c387693c
--- /dev/null
+++ b/superlog/apps/api/src/feedback.ts
@@ -0,0 +1,299 @@
+import { db, schema } from "@superlog/db";
+import { and, eq } from "drizzle-orm";
+import type { Hono } from "hono";
+import { HTTPException } from "hono/http-exception";
+import { auth } from "./auth.js";
+import { logger } from "./logger.js";
+
+const log = logger.child({ scope: "feedback" });
+
+const MAX_BODY_LEN = 8_000;
+const FEEDBACK_KINDS = new Set(["incident", "issue", "pr"]);
+
+// Public + authed routes share one mount because the PR-link page must be
+// reachable without a session (external GitHub PR participants).
+//
+// /api/feedback (authed) is for the in-product dialog on incident/issue
+// drawers — the session middleware in index.ts wraps it. /feedback/pr/...
+// is mounted before the session middleware so it's open to anonymous
+// submitters. The github.ts webhook handler inserts feedback rows directly
+// via the helper below; same for the Slack interactivity handler.
+//
+// Every insert path funnels through `recordFeedback` so the Slack notifier
+// and admin-inbox bookkeeping stay in one place.
+
+// biome-ignore lint/suspicious/noExplicitAny: Hono Variables invariance.
+export function mountFeedbackPublic(app: Hono): void {
+ // Anonymous PR-link submissions. We accept these without a session because
+ // the link is included in PR descriptions on customer repos, and many
+ // commenters won't have Superlog accounts. Rate-limiting is intentionally
+ // out-of-scope for v1 — the form is one click deep, the admin inbox is
+ // staff-only, and we can lock it down if it gets abused.
+ app.post("/feedback/pr/:owner/:repo/:number", async (c) => {
+ const owner = c.req.param("owner");
+ const repo = c.req.param("repo");
+ const number = Number(c.req.param("number"));
+ if (!owner || !repo || !Number.isFinite(number) || number <= 0) {
+ throw new HTTPException(400, { message: "invalid pr ref" });
+ }
+ const body = (await c.req.json().catch(() => ({}))) as {
+ body?: unknown;
+ githubLogin?: unknown;
+ };
+ const text = typeof body.body === "string" ? body.body.trim() : "";
+ if (!text) throw new HTTPException(400, { message: "body required" });
+ if (text.length > MAX_BODY_LEN) {
+ throw new HTTPException(400, { message: "body too long" });
+ }
+ const githubLogin = typeof body.githubLogin === "string" ? body.githubLogin.trim() : "";
+
+ // Look up the agent PR row if we know about it — best-effort, the
+ // feedback is recorded either way. ref_id is the agent_pull_requests.id
+ // when we have it, otherwise we fall back to "owner/repo#number" so the
+ // admin still has something to click.
+ const repoFullName = `${owner}/${repo}`;
+ const agentPr = await db.query.agentPullRequests.findFirst({
+ where: and(
+ eq(schema.agentPullRequests.repoFullName, repoFullName),
+ eq(schema.agentPullRequests.prNumber, number),
+ ),
+ });
+
+ // Try to grab the session cookie if the user happens to be signed in —
+ // that lets us attribute the feedback to a known Superlog user even
+ // though the route is public. Best-effort.
+ let authorUserId: string | null = null;
+ try {
+ const session = await auth.api.getSession({ headers: c.req.raw.headers });
+ authorUserId = session?.user.id ?? null;
+ } catch {
+ authorUserId = null;
+ }
+
+ await recordFeedback({
+ kind: "pr",
+ refId: agentPr?.id ?? `${repoFullName}#${number}`,
+ refRepo: repoFullName,
+ source: "pr_link",
+ body: text,
+ authorUserId,
+ authorExternal: githubLogin ? { githubLogin } : null,
+ orgId: null,
+ projectId: null,
+ });
+
+ return c.json({ ok: true });
+ });
+}
+
+// biome-ignore lint/suspicious/noExplicitAny: Hono Variables invariance.
+export function mountFeedbackAuthed(app: Hono): void {
+ // In-product dialog. The dialog is rendered on incident and issue
+ // drawers, so kind is always 'incident' or 'issue'. We trust the caller
+ // to pass the correct ref_id (incident or issue UUID); we don't gate on
+ // whether the user can actually see that incident — feedback is an
+ // unauth'd-by-design signal and the admin inbox is the only thing that
+ // reads it.
+ app.post("/api/feedback", async (c) => {
+ const userId = c.var.userId as string;
+ const body = (await c.req.json().catch(() => ({}))) as {
+ kind?: unknown;
+ refId?: unknown;
+ body?: unknown;
+ projectId?: unknown;
+ };
+ const kind = typeof body.kind === "string" ? body.kind : "";
+ if (!FEEDBACK_KINDS.has(kind as schema.FeedbackKind) || kind === "pr") {
+ // PR feedback comes in via /feedback/pr/* (anonymous) or the github
+ // webhook; the in-product dialog only fires on incident/issue pages.
+ throw new HTTPException(400, { message: "kind must be 'incident' or 'issue'" });
+ }
+ const refId = typeof body.refId === "string" ? body.refId : "";
+ if (!refId) throw new HTTPException(400, { message: "refId required" });
+ const text = typeof body.body === "string" ? body.body.trim() : "";
+ if (!text) throw new HTTPException(400, { message: "body required" });
+ if (text.length > MAX_BODY_LEN) {
+ throw new HTTPException(400, { message: "body too long" });
+ }
+ // Resolve projectId via lookup before we trust it. A garbage string
+ // (non-UUID syntax, or a stale ID whose project was deleted) would
+ // otherwise hit the feedback.project_id FK and 500 on insert. We
+ // wrap the lookup in try/catch because postgres throws a `22P02`
+ // syntax error rather than returning empty for malformed UUIDs.
+ const requestedProjectId = typeof body.projectId === "string" ? body.projectId : null;
+ let projectId: string | null = null;
+ let orgId: string | null = null;
+ if (requestedProjectId) {
+ const project = await db.query.projects
+ .findFirst({ where: eq(schema.projects.id, requestedProjectId) })
+ .catch(() => null);
+ if (project) {
+ projectId = project.id;
+ orgId = project.orgId;
+ }
+ }
+
+ await recordFeedback({
+ kind: kind as "incident" | "issue",
+ refId,
+ refRepo: null,
+ source: "dialog",
+ body: text,
+ authorUserId: userId,
+ authorExternal: null,
+ orgId,
+ projectId,
+ });
+
+ return c.json({ ok: true });
+ });
+
+ // Staff triage reads the same `feedback` table directly.
+}
+
+// Shared insert + notify. Exported so the github webhook handler and Slack
+// view_submission handler can call it directly without re-implementing the
+// shape contract.
+export async function recordFeedback(input: {
+ kind: schema.FeedbackKind;
+ refId: string;
+ refRepo: string | null;
+ source: schema.FeedbackSource;
+ body: string;
+ authorUserId: string | null;
+ authorExternal: schema.FeedbackAuthorExternal | null;
+ orgId: string | null;
+ projectId: string | null;
+}): Promise {
+ const [row] = await db
+ .insert(schema.feedback)
+ .values({
+ kind: input.kind,
+ refId: input.refId,
+ refRepo: input.refRepo,
+ source: input.source,
+ body: input.body,
+ authorUserId: input.authorUserId,
+ authorExternal: input.authorExternal,
+ orgId: input.orgId,
+ projectId: input.projectId,
+ })
+ .returning();
+ log.info(
+ {
+ feedback_id: row?.id,
+ kind: input.kind,
+ source: input.source,
+ ref_id: input.refId,
+ author_user_id: input.authorUserId,
+ },
+ "feedback recorded",
+ );
+ if (row) {
+ void notifyFeedbackSlack(row).catch((err) => {
+ log.warn({ err, feedback_id: row.id }, "feedback slack notify failed");
+ });
+ }
+}
+
+// Posts a one-liner to FEEDBACK_SLACK_WEBHOOK so the team hears about new
+// feedback in real time. Best-effort — failures are logged and swallowed
+// so they never block the insert path. Configure FEEDBACK_SLACK_WEBHOOK to
+// a Slack incoming webhook URL (Slack app → Incoming Webhooks → pick a
+// channel). Leave unset to disable.
+async function notifyFeedbackSlack(row: schema.Feedback): Promise {
+ const webhookUrl = process.env.FEEDBACK_SLACK_WEBHOOK;
+ if (!webhookUrl) return;
+ // Optional staff tooling deep link. Unset -> no deep link.
+ const adminOrigin = process.env.ADMIN_WEB_ORIGIN;
+ const link = adminOrigin ? `${adminOrigin}/feedback?id=${row.id}` : null;
+ const preview = row.body.length > 280 ? `${row.body.slice(0, 277)}…` : row.body;
+ let authorEmail: string | null = null;
+ if (row.authorUserId) {
+ const user = await db.query.users.findFirst({
+ where: eq(schema.users.id, row.authorUserId),
+ });
+ authorEmail = user?.email ?? null;
+ }
+ const author = authorEmail
+ ? authorEmail
+ : row.authorExternal?.githubLogin
+ ? `@${row.authorExternal.githubLogin} (github)`
+ : row.authorExternal?.slackUserId
+ ? `<@${row.authorExternal.slackUserId}> (slack)`
+ : "anonymous";
+ // For PR feedback the refId is either an agent_pull_requests UUID (when
+ // we know the PR — happens for PR-comment webhooks and for /feedback/pr/*
+ // POSTs that match an agent PR row) or the fallback "owner/repo#number"
+ // string from feedback.ts:79 (when the link was hit for a PR we don't
+ // track). The UUID case needs the repo name prepended; the fallback case
+ // already includes it, so re-prepending makes "owner/repo#…" appear twice.
+ // Detect UUIDs by length (36) + presence of dashes.
+ const refDesc =
+ row.kind === "pr"
+ ? row.refId.includes("#")
+ ? `PR ${row.refId}`
+ : `PR ${row.refRepo ?? ""} (${row.refId.slice(0, 8)}…)`
+ : `${row.kind} ${row.refId.slice(0, 8)}…`;
+ const text = `:speech_balloon: *New feedback* on ${refDesc} via _${row.source}_ from ${author}\n>${preview}${
+ link ? `\n<${link}|Open in admin>` : ""
+ }`;
+ try {
+ const res = await fetch(webhookUrl, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ text }),
+ });
+ if (!res.ok) {
+ log.warn({ status: res.status }, "feedback slack webhook returned non-2xx");
+ }
+ } catch (err) {
+ log.warn({ err }, "feedback slack webhook fetch failed");
+ }
+}
+
+// Used by github.ts to decide whether a PR comment counts as feedback.
+// Greptile and other bot accounts have type='Bot'; their reviews are
+// review-bot output, not user feedback. The author_association check
+// keeps us from accepting drive-by comments from random GitHub users
+// who happened to wander in — feedback should come from people who
+// have a real relationship with the PR.
+//
+// Possible author_associations: OWNER, MEMBER, COLLABORATOR, CONTRIBUTOR,
+// FIRST_TIME_CONTRIBUTOR, FIRST_TIMER, MANNEQUIN, NONE.
+const FEEDBACK_ASSOCIATIONS = new Set([
+ "OWNER",
+ "MEMBER",
+ "COLLABORATOR",
+ "CONTRIBUTOR",
+ "FIRST_TIME_CONTRIBUTOR",
+]);
+
+export function isFeedbackEligibleCommenter(opts: {
+ userType?: string | null;
+ authorAssociation?: string | null;
+}): boolean {
+ if (opts.userType === "Bot") return false;
+ if (!opts.authorAssociation) return false;
+ return FEEDBACK_ASSOCIATIONS.has(opts.authorAssociation);
+}
+
+// Used by the dropped FEEDBACK_SLACK_WEBHOOK notifier — exported so tests
+// can confirm the env var is honored without invoking fetch.
+export function feedbackSlackWebhookConfigured(): boolean {
+ return !!process.env.FEEDBACK_SLACK_WEBHOOK;
+}
+
+// Marker used by the github webhook to filter out our own footer so we
+// don't surface our own link as feedback when GitHub echoes it back.
+// The footer string itself is rendered by `renderFeedbackFooter` in
+// apps/worker/src/github-app.ts (worker can't import from the api app),
+// so any change to the URL path there needs to be mirrored here. The
+// path `/feedback/pr/` is wired to a router pattern and is unlikely to
+// move; matching on it (rather than the whole footer text) keeps this
+// resilient to copy edits in the footer.
+export const FEEDBACK_PR_FOOTER_MARKER = "/feedback/pr/";
+
+// Used by the admin inbox query — also exported to keep the
+// authorAssociation list in one place. Tests import this set directly.
+export { FEEDBACK_ASSOCIATIONS };
diff --git a/superlog/apps/api/src/gateway.ts b/superlog/apps/api/src/gateway.ts
new file mode 100644
index 0000000000000000000000000000000000000000..04e5b642f788b1daff3412d588f5228e7cc7bad3
--- /dev/null
+++ b/superlog/apps/api/src/gateway.ts
@@ -0,0 +1,612 @@
+import type { ClickHouseClient } from "@clickhouse/client";
+import {
+ db,
+ generateApiKey,
+ generateCliSession,
+ hashCliSession,
+ listAccessibleGithubInstallsForProject,
+ schema,
+ syncLoopsContactForUserProject,
+} from "@superlog/db";
+import { and, eq, isNull } from "drizzle-orm";
+import type { Hono } from "hono";
+import { nanoid } from "nanoid";
+
+import { auth } from "./auth.js";
+import { logger } from "./logger.js";
+import { resolveActiveOrgContext } from "./org-context.js";
+
+const log = logger.child({ scope: "gateway" });
+
+const UPSTREAM = "https://api.anthropic.com";
+const HARD_CAP_PER_ORG = Number(process.env.SUPERLOG_HARD_CAP ?? 500);
+const DEVICE_TTL_MS = 10 * 60 * 1000;
+const SESSION_TTL_DAYS = 60;
+
+export type Principal = {
+ sessionId: string;
+ userId: string;
+ orgId: string;
+ userEmail: string;
+ orgName: string;
+};
+
+// "cli" — MCP / `superlog init` style pairing. Goes through a GitHub-install
+// gate in the activate page before releasing the CLI token to the poller.
+// "skill" — agent skill (e.g. superlog-onboard) running in the user's terminal
+// pairing for OTel ingest only. Skips the GitHub gate (the post-signup
+// onboarding wizard handles GH + Slack). The poller only receives an ingest
+// key, never a CLI session token, since the skill never calls /v1/* gateway
+// routes.
+type DeviceFlow = "cli" | "skill";
+
+type Device = {
+ deviceCode: string;
+ userCode: string;
+ flow: DeviceFlow;
+ createdAt: number;
+ // user_linked = sign-in done and account/key rows created. For "cli"
+ // flow we hold here until the GitHub install step finishes; for "skill" we
+ // transition straight to approved.
+ status: "pending" | "user_linked" | "approved" | "expired";
+ session?: {
+ cliToken: string;
+ ingestKey: string;
+ orgId: string;
+ projectId: string;
+ userEmail: string;
+ orgName: string;
+ };
+};
+
+const usageByOrg = new Map();
+const devicesByDeviceCode = new Map();
+const devicesByUserCode = new Map();
+
+export function getLinkedDevice(userCode: string): { orgId: string; projectId: string } | null {
+ const device = devicesByUserCode.get(userCode.toUpperCase());
+ if (!device || !device.session) return null;
+ if (isExpired(device)) return null;
+ if (device.status !== "user_linked") return null;
+ return { orgId: device.session.orgId, projectId: device.session.projectId };
+}
+
+// Used by the post-pair integration redirects (GitHub install, Slack install)
+// initiated from the skill. The skill device is already in `approved` state
+// by the time it tries to drive these — getLinkedDevice rejects that.
+// Accepts either user_linked or approved, but only for skill devices, since
+// the cli/MCP flow has its own approval semantics that shouldn't be reused
+// here.
+export function getSkillDeviceForIntegration(
+ userCode: string,
+): { orgId: string; projectId: string } | null {
+ const device = devicesByUserCode.get(userCode.toUpperCase());
+ if (!device || !device.session) return null;
+ if (device.flow !== "skill") return null;
+ if (isExpired(device)) return null;
+ if (device.status !== "user_linked" && device.status !== "approved") return null;
+ return { orgId: device.session.orgId, projectId: device.session.projectId };
+}
+
+export function getDeviceFlow(userCode: string): DeviceFlow | null {
+ const device = devicesByUserCode.get(userCode.toUpperCase());
+ if (!device) return null;
+ return device.flow;
+}
+
+export type GatewayVars = { principal: Principal };
+
+// The host app's Variables type is invariant in Hono. We don't enforce it at
+// the call site — callers should declare `principal?: Principal` in their own
+// Vars if they want typed access downstream.
+// biome-ignore lint/suspicious/noExplicitAny: Hono Variables invariance.
+export function mountGateway(app: Hono, ch: ClickHouseClient): void {
+ const upstreamKey = process.env.ANTHROPIC_API_KEY;
+ if (!upstreamKey) {
+ log.warn("ANTHROPIC_API_KEY not set — /v1/messages proxy disabled");
+ }
+ const publicUrl =
+ process.env.GATEWAY_PUBLIC_URL ?? `http://localhost:${process.env.PORT ?? 4100}`;
+ const webOrigin = process.env.WEB_ORIGIN ?? "http://localhost:5173";
+
+ app.post("/oauth/device", async (c) => {
+ // Optional `flow` field — accept JSON body but tolerate empty/no body for
+ // older callers (the MCP CLI flow doesn't send one).
+ let flow: DeviceFlow = "cli";
+ const ct = c.req.header("content-type") ?? "";
+ if (ct.toLowerCase().includes("application/json")) {
+ const body = (await c.req.json().catch(() => ({}))) as { flow?: unknown };
+ if (body.flow === "skill") flow = "skill";
+ } else {
+ const form = await c.req.parseBody().catch(() => ({}) as Record);
+ if (form.flow === "skill") flow = "skill";
+ }
+
+ const deviceCode = `superlog_dev_${nanoid(24)}`;
+ const userCode = humanCode();
+ const device: Device = {
+ deviceCode,
+ userCode,
+ flow,
+ createdAt: Date.now(),
+ status: "pending",
+ };
+ devicesByDeviceCode.set(deviceCode, device);
+ devicesByUserCode.set(userCode, device);
+ const verificationUri = new URL(`${webOrigin}/activate`);
+ if (flow === "skill") verificationUri.searchParams.set("flow", "skill");
+ const verificationUriComplete = new URL(verificationUri.toString());
+ verificationUriComplete.searchParams.set("code", userCode);
+ return c.json({
+ device_code: deviceCode,
+ user_code: userCode,
+ verification_uri: verificationUri.toString(),
+ verification_uri_complete: verificationUriComplete.toString(),
+ expires_in: Math.floor(DEVICE_TTL_MS / 1000),
+ interval: 2,
+ });
+ });
+
+ app.post("/oauth/token", async (c, next) => {
+ // The MCP OAuth flow registers its own /oauth/token at this path with
+ // RFC 6749 form-encoded grants (authorization_code, refresh_token).
+ // CLI device-flow requests come in as JSON with a device_code field —
+ // anything else falls through to the MCP handler.
+ const ct = c.req.header("content-type") ?? "";
+ if (!ct.toLowerCase().includes("application/json")) {
+ return next();
+ }
+ const body = (await c.req.json().catch(() => ({}))) as { device_code?: string };
+ if (!body.device_code) return next();
+ const device = devicesByDeviceCode.get(body.device_code);
+ if (!device) return c.json({ error: "invalid_grant" }, 400);
+ if (isExpired(device)) {
+ device.status = "expired";
+ log.warn(
+ {
+ user_code: device.userCode,
+ flow: device.flow,
+ age_ms: Date.now() - device.createdAt,
+ },
+ "device flow token expired (oauth/token)",
+ );
+ return c.json({ error: "expired_token" }, 410);
+ }
+ if (device.status !== "approved" || !device.session) {
+ return c.json({ error: "authorization_pending" }, 428);
+ }
+ if (device.flow === "skill") {
+ // Skill pollers only need the ingest key for OTel exporters; never
+ // expose the gateway-scope CLI session token.
+ // Refresh the device's createdAt so the post-pair integration URLs
+ // (`/github/install?user_code=…`, `/slack/install?user_code=…`) the
+ // skill is about to drive don't tip over the 10-min device TTL while
+ // the user clicks through GitHub + Slack OAuth.
+ device.createdAt = Date.now();
+ return c.json({
+ ingest_key: device.session.ingestKey,
+ project_id: device.session.projectId,
+ user: device.session.userEmail,
+ org: device.session.orgName,
+ user_code: device.userCode,
+ flow: "skill",
+ });
+ }
+ return c.json({
+ access_token: device.session.cliToken,
+ token_type: "Bearer",
+ ingest_key: device.session.ingestKey,
+ project_id: device.session.projectId,
+ user: device.session.userEmail,
+ org: device.session.orgName,
+ gateway_url: publicUrl,
+ });
+ });
+
+ app.get("/activate", (c) => {
+ const code = c.req.query("code") ?? "";
+ const url = new URL(`${webOrigin}/activate`);
+ if (code) url.searchParams.set("code", code);
+ return c.redirect(url.toString(), 302);
+ });
+
+ app.post("/activate/approve", async (c) => {
+ const userId = await requireUserFromSession(c);
+ if (userId instanceof Response) return userId;
+
+ const body = (await c.req.json().catch(() => ({}))) as {
+ user_code?: string;
+ org_id?: string;
+ project_id?: string;
+ };
+ const userCode = body.user_code?.toUpperCase() ?? "";
+ const device = devicesByUserCode.get(userCode);
+ if (!device) return c.json({ error: "unknown device code" }, 404);
+ if (isExpired(device)) {
+ device.status = "expired";
+ log.warn(
+ {
+ user_code: userCode,
+ user_id: userId,
+ flow: device.flow,
+ age_ms: Date.now() - device.createdAt,
+ },
+ "device code expired (activate/approve)",
+ );
+ return c.json({ error: "device code expired" }, 410);
+ }
+ if (device.status === "approved" || device.status === "user_linked") {
+ return c.json({ error: "already approved" }, 409);
+ }
+
+ const { user, org, project } = await ensureAccount(userId, {
+ orgId: body.org_id ?? null,
+ projectId: body.project_id ?? null,
+ });
+
+ const cli = generateCliSession();
+ const ingest = generateApiKey();
+ const expiresAt = new Date(Date.now() + SESSION_TTL_DAYS * 24 * 60 * 60 * 1000);
+
+ await db.transaction(async (tx) => {
+ await tx.insert(schema.cliSessions).values({
+ userId: user.id,
+ orgId: org.id,
+ tokenPrefix: cli.prefix,
+ tokenHash: cli.hash,
+ expiresAt,
+ });
+ await tx.insert(schema.apiKeys).values({
+ projectId: project.id,
+ name: "CLI-issued ingest key",
+ keyPrefix: ingest.prefix,
+ keyHash: ingest.hash,
+ });
+ });
+
+ device.status = "user_linked";
+ device.session = {
+ cliToken: cli.plaintext,
+ ingestKey: ingest.plaintext,
+ orgId: org.id,
+ projectId: project.id,
+ userEmail: user.email,
+ orgName: org.name,
+ };
+ void syncLoopsContactForUserProject({
+ userId: user.id,
+ projectId: project.id,
+ appUrl: webOrigin,
+ }).catch((err) =>
+ log.warn({ err, user_id: user.id }, "loops contact sync failed after activation"),
+ );
+
+ // Skill flow has no GitHub gate — the post-signup OnboardingWizard prompts
+ // for GitHub + Slack once the user lands on the dashboard. Approve in one
+ // hop so the skill's poller can pick up the ingest key immediately.
+ if (device.flow === "skill") {
+ device.status = "approved";
+ return c.json({
+ ok: true,
+ orgId: org.id,
+ projectId: project.id,
+ flow: "skill",
+ ingestKey: ingest.plaintext,
+ githubSetupNeeded: false,
+ });
+ }
+
+ const accessibleInstalls = await listAccessibleGithubInstallsForProject(project.id);
+ const githubSetupNeeded = accessibleInstalls.length === 0 && !org.githubSetupSkippedAt;
+
+ return c.json({ ok: true, orgId: org.id, flow: "cli", githubSetupNeeded });
+ });
+
+ app.post("/activate/finalize", async (c) => {
+ const userId = await requireUserFromSession(c);
+ if (userId instanceof Response) return userId;
+ void userId;
+
+ const body = (await c.req.json().catch(() => ({}))) as { user_code?: string };
+ const userCode = body.user_code?.toUpperCase() ?? "";
+ const device = devicesByUserCode.get(userCode);
+ if (!device) return c.json({ error: "unknown device code" }, 404);
+ if (isExpired(device)) {
+ device.status = "expired";
+ log.warn(
+ {
+ user_code: userCode,
+ flow: device.flow,
+ age_ms: Date.now() - device.createdAt,
+ },
+ "device code expired (activate/finalize)",
+ );
+ return c.json({ error: "device code expired" }, 410);
+ }
+ if (device.status === "approved") return c.json({ ok: true });
+ if (device.status !== "user_linked" || !device.session) {
+ return c.json({ error: "not ready to finalize" }, 409);
+ }
+
+ device.status = "approved";
+ return c.json({ ok: true });
+ });
+
+ app.use("/v1/*", async (c, next) => {
+ const header = c.req.header("authorization");
+ if (!header?.toLowerCase().startsWith("bearer ")) {
+ return c.json({ error: "unauthenticated" }, 401);
+ }
+ const token = header.slice(7).trim();
+ if (!token.startsWith("superlog_cli_")) {
+ return c.json(
+ {
+ error:
+ "wrong credential type: /v1/* requires a CLI session token (superlog_cli_*); did you mean to call intake.superlog.sh with your ingest key?",
+ },
+ 401,
+ );
+ }
+ const hash = hashCliSession(token);
+ const session = await db.query.cliSessions.findFirst({
+ where: eq(schema.cliSessions.tokenHash, hash),
+ });
+ if (!session || session.revokedAt) return c.json({ error: "invalid token" }, 401);
+ if (session.expiresAt && session.expiresAt < new Date()) {
+ return c.json({ error: "session expired" }, 401);
+ }
+
+ const user = await db.query.users.findFirst({
+ where: eq(schema.users.id, session.userId),
+ });
+ const org = await db.query.orgs.findFirst({
+ where: eq(schema.orgs.id, session.orgId),
+ });
+ if (!user || !org) return c.json({ error: "session references missing account" }, 401);
+
+ c.set("principal", {
+ sessionId: session.id,
+ userId: session.userId,
+ orgId: session.orgId,
+ userEmail: user.email,
+ orgName: org.name,
+ });
+
+ void db
+ .update(schema.cliSessions)
+ .set({ lastUsedAt: new Date() })
+ .where(eq(schema.cliSessions.id, session.id))
+ .catch((err: unknown) => log.error({ err }, "failed to bump last_used_at"));
+
+ await next();
+ });
+
+ app.get("/v1/me", (c) => {
+ const p = c.var.principal as Principal;
+ return c.json({
+ user: p.userEmail,
+ org: p.orgName,
+ usage: usageByOrg.get(p.orgId) ?? 0,
+ cap: HARD_CAP_PER_ORG,
+ });
+ });
+
+ app.all("/v1/messages", async (c) => {
+ if (!upstreamKey) {
+ return c.json({ error: "gateway upstream not configured" }, 503);
+ }
+ const p = c.var.principal as Principal;
+ const used = usageByOrg.get(p.orgId) ?? 0;
+ if (used >= HARD_CAP_PER_ORG) {
+ return c.json(
+ {
+ error: {
+ type: "rate_limit_error",
+ message: `org cap reached (${HARD_CAP_PER_ORG})`,
+ },
+ },
+ 429,
+ );
+ }
+ usageByOrg.set(p.orgId, used + 1);
+ log.info(
+ { user: p.userEmail, org: p.orgName, used: used + 1, cap: HARD_CAP_PER_ORG },
+ "/v1/messages",
+ );
+ return proxyToAnthropic(c.req.raw, "/v1/messages", upstreamKey);
+ });
+
+ app.get("/v1/telemetry/recent", async (c) => {
+ const p = c.var.principal as Principal;
+ const service = c.req.query("service");
+ const since = c.req.query("since");
+ if (!service || !since) {
+ return c.json({ error: "service and since query params required" }, 400);
+ }
+
+ // The install-verify step only cares about the signed-in user's default
+ // project. Multi-project users would query by project_id explicitly; that
+ // case can wait until we actually have it.
+ const project = await db.query.projects.findFirst({
+ where: eq(schema.projects.orgId, p.orgId),
+ });
+ if (!project) return c.json({ error: "no project for this org" }, 404);
+
+ const [traces, logs, metrics] = await Promise.all([
+ chCountTraces(ch, project.id, service, since),
+ chCountLogs(ch, project.id, service, since),
+ chMetricsCountAcrossTables(ch, project.id, service, since),
+ ]);
+
+ return c.json({
+ service,
+ since,
+ projectId: project.id,
+ traces,
+ logs,
+ metrics,
+ });
+ });
+
+ app.all("/v1/*", (c) => {
+ if (!upstreamKey) {
+ return c.json({ error: "gateway upstream not configured" }, 503);
+ }
+ return proxyToAnthropic(c.req.raw, new URL(c.req.url).pathname, upstreamKey);
+ });
+}
+
+async function chCountTraces(
+ ch: ClickHouseClient,
+ projectId: string,
+ service: string,
+ since: string,
+): Promise<{ count: number; firstAt?: string; firstSpanName?: string }> {
+ const r = await ch.query({
+ query: `
+ SELECT count() AS c,
+ toString(min(Timestamp)) AS first_at,
+ argMin(SpanName, Timestamp) AS first_span
+ FROM otel_traces
+ WHERE ResourceAttributes['superlog.project_id'] = {projectId:String}
+ AND ServiceName = {service:String}
+ AND Timestamp >= parseDateTime64BestEffortOrZero({since:String})
+ `,
+ query_params: { projectId, service, since },
+ format: "JSONEachRow",
+ });
+ const rows = (await r.json()) as { c: string | number; first_at?: string; first_span?: string }[];
+ const row = rows[0];
+ const count = Number(row?.c ?? 0);
+ return {
+ count,
+ firstAt: count > 0 ? row?.first_at : undefined,
+ firstSpanName: count > 0 ? row?.first_span : undefined,
+ };
+}
+
+async function chCountLogs(
+ ch: ClickHouseClient,
+ projectId: string,
+ service: string,
+ since: string,
+): Promise<{ count: number }> {
+ const r = await ch.query({
+ query: `
+ SELECT count() AS c
+ FROM otel_logs
+ WHERE ResourceAttributes['superlog.project_id'] = {projectId:String}
+ AND ServiceName = {service:String}
+ AND Timestamp >= parseDateTime64BestEffortOrZero({since:String})
+ `,
+ query_params: { projectId, service, since },
+ format: "JSONEachRow",
+ });
+ const rows = (await r.json()) as { c: string | number }[];
+ return { count: Number(rows[0]?.c ?? 0) };
+}
+
+async function chMetricsCountAcrossTables(
+ ch: ClickHouseClient,
+ projectId: string,
+ service: string,
+ since: string,
+): Promise<{ count: number }> {
+ const tables = [
+ "otel_metrics_gauge",
+ "otel_metrics_sum",
+ "otel_metrics_histogram",
+ "otel_metrics_summary",
+ "otel_metrics_exp_histogram",
+ ];
+ const counts = await Promise.all(
+ tables.map(async (t) => {
+ const r = await ch.query({
+ query: `
+ SELECT count() AS c
+ FROM ${t}
+ WHERE ResourceAttributes['superlog.project_id'] = {projectId:String}
+ AND ServiceName = {service:String}
+ AND TimeUnix >= parseDateTime64BestEffortOrZero({since:String})
+ `,
+ query_params: { projectId, service, since },
+ format: "JSONEachRow",
+ });
+ const rows = (await r.json()) as { c: string | number }[];
+ return Number(rows[0]?.c ?? 0);
+ }),
+ );
+ return { count: counts.reduce((a, b) => a + b, 0) };
+}
+
+async function ensureAccount(
+ userId: string,
+ scope: { orgId: string | null; projectId: string | null } = {
+ orgId: null,
+ projectId: null,
+ },
+): Promise<{ user: schema.User; org: schema.Org; project: schema.Project }> {
+ const { user, org, project } = await resolveActiveOrgContext({
+ userId,
+ preferredOrgId: scope.orgId,
+ preferredProjectId: scope.projectId,
+ });
+ return { user, org, project };
+}
+
+export async function proxyToAnthropic(
+ req: Request,
+ path: string,
+ upstreamKey: string,
+): Promise {
+ const url = new URL(path, UPSTREAM);
+ // Allowlist only headers Anthropic needs. Forwarding cf-* / x-forwarded-*
+ // from our inbound (api.superlog.sh is orange-clouded) makes Anthropic's
+ // own Cloudflare edge think the request has already traversed CF and
+ // reject it with Error 1000 ("DNS points to prohibited IP").
+ const FORWARD = new Set(["content-type", "accept", "accept-encoding"]);
+ const headers = new Headers();
+ for (const [name, value] of req.headers) {
+ const lower = name.toLowerCase();
+ if (FORWARD.has(lower) || lower.startsWith("anthropic-") || lower.startsWith("x-stainless-")) {
+ headers.set(name, value);
+ }
+ }
+ headers.set("x-api-key", upstreamKey);
+ if (!headers.has("anthropic-version")) headers.set("anthropic-version", "2023-06-01");
+
+ const upstream = await fetch(url, {
+ method: req.method,
+ headers,
+ body:
+ req.method === "GET" || req.method === "HEAD"
+ ? undefined
+ : (req.body as ReadableStream | null),
+ duplex: "half",
+ redirect: "manual",
+ } as RequestInit & { duplex: "half" });
+
+ const respHeaders = new Headers(upstream.headers);
+ respHeaders.delete("content-encoding");
+ respHeaders.delete("content-length");
+ return new Response(upstream.body, { status: upstream.status, headers: respHeaders });
+}
+
+async function requireUserFromSession(
+ // biome-ignore lint/suspicious/noExplicitAny: Hono Context generics vary across mount points.
+ c: any,
+): Promise {
+ const session = await auth.api.getSession({ headers: c.req.raw.headers });
+ if (!session) return c.json({ error: "unauthenticated" }, 401);
+ return session.user.id;
+}
+
+function humanCode(): string {
+ const alphabet = "ABCDEFGHJKMNPQRSTVWXYZ23456789";
+ const pick = () => alphabet[Math.floor(Math.random() * alphabet.length)];
+ return `${pick()}${pick()}${pick()}${pick()}-${pick()}${pick()}${pick()}${pick()}`;
+}
+
+function isExpired(d: Device): boolean {
+ return Date.now() - d.createdAt > DEVICE_TTL_MS;
+}
diff --git a/superlog/apps/api/src/github-branches.test.ts b/superlog/apps/api/src/github-branches.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..0cc191fc2ac06e410d2139bab5abff133ac3ddf1
--- /dev/null
+++ b/superlog/apps/api/src/github-branches.test.ts
@@ -0,0 +1,51 @@
+import { strict as assert } from "node:assert";
+import { test } from "node:test";
+import { mergeRepoBranches, prBaseBranchExists } from "./github-branches.js";
+
+test("mergeRepoBranches dedupes across repos, marks defaults, and sorts defaults first", () => {
+ const merged = mergeRepoBranches([
+ { defaultBranch: "main", branches: ["main", "dev", "feature/x"] },
+ { defaultBranch: "master", branches: ["master", "dev"] },
+ ]);
+ assert.deepEqual(merged, [
+ { name: "main", isDefault: true },
+ { name: "master", isDefault: true },
+ { name: "dev", isDefault: false },
+ { name: "feature/x", isDefault: false },
+ ]);
+});
+
+test("mergeRepoBranches treats a branch as default if it is the default in any repo", () => {
+ const merged = mergeRepoBranches([
+ { defaultBranch: null, branches: ["dev"] },
+ { defaultBranch: "dev", branches: ["dev"] },
+ ]);
+ assert.deepEqual(merged, [{ name: "dev", isDefault: true }]);
+});
+
+test("mergeRepoBranches includes the default branch even if absent from the branch list", () => {
+ const merged = mergeRepoBranches([{ defaultBranch: "main", branches: [] }]);
+ assert.deepEqual(merged, [{ name: "main", isDefault: true }]);
+});
+
+test("mergeRepoBranches ignores blank names and trims", () => {
+ const merged = mergeRepoBranches([{ defaultBranch: " ", branches: [" ", " dev "] }]);
+ assert.deepEqual(merged, [{ name: "dev", isDefault: false }]);
+});
+
+test("prBaseBranchExists allows blank (uses repository default)", () => {
+ const branches = [{ name: "main", isDefault: true }];
+ assert.equal(prBaseBranchExists(null, branches), true);
+ assert.equal(prBaseBranchExists("", branches), true);
+ assert.equal(prBaseBranchExists(" ", branches), true);
+});
+
+test("prBaseBranchExists requires a non-blank branch to be present", () => {
+ const branches = [
+ { name: "main", isDefault: true },
+ { name: "dev", isDefault: false },
+ ];
+ assert.equal(prBaseBranchExists("dev", branches), true);
+ assert.equal(prBaseBranchExists(" dev ", branches), true);
+ assert.equal(prBaseBranchExists("nope", branches), false);
+});
diff --git a/superlog/apps/api/src/github-branches.ts b/superlog/apps/api/src/github-branches.ts
new file mode 100644
index 0000000000000000000000000000000000000000..e10b51c083bc0502e3bd9f89500d73bd561c0dc5
--- /dev/null
+++ b/superlog/apps/api/src/github-branches.ts
@@ -0,0 +1,44 @@
+import { normalizePrBaseBranch } from "@superlog/db/schema";
+
+export type RepoBranch = { name: string; isDefault: boolean };
+
+export type RepoBranchInfo = { defaultBranch: string | null; branches: string[] };
+
+// Pure: collapse the per-repo branch lists of a project into one deduped,
+// sorted set for the config dropdown. A name is `isDefault` if it's the
+// default branch of ANY repo. The default branch is always included even if
+// the paginated branch list didn't surface it. Defaults sort first, then
+// alphabetical — so the most likely target is at the top of the picker.
+export function mergeRepoBranches(perRepo: RepoBranchInfo[]): RepoBranch[] {
+ const names = new Set();
+ const defaults = new Set();
+ for (const repo of perRepo) {
+ for (const raw of repo.branches) {
+ const name = raw.trim();
+ if (name) names.add(name);
+ }
+ const def = repo.defaultBranch?.trim();
+ if (def) {
+ names.add(def);
+ defaults.add(def);
+ }
+ }
+ return [...names]
+ .map((name) => ({ name, isDefault: defaults.has(name) }))
+ .sort((a, b) => {
+ if (a.isDefault !== b.isDefault) return a.isDefault ? -1 : 1;
+ return a.name.localeCompare(b.name);
+ });
+}
+
+// Pure: a configured PR base branch is valid if it's blank (meaning "use the
+// repository default") or it matches a branch that actually exists in one of
+// the project's repos.
+export function prBaseBranchExists(
+ candidate: string | null | undefined,
+ branches: RepoBranch[],
+): boolean {
+ const normalized = normalizePrBaseBranch(candidate);
+ if (!normalized) return true;
+ return branches.some((branch) => branch.name === normalized);
+}
diff --git a/superlog/apps/api/src/github.test.ts b/superlog/apps/api/src/github.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..aee7cfb35fc53ca3481fdae45f407a8d4cbd1f39
--- /dev/null
+++ b/superlog/apps/api/src/github.test.ts
@@ -0,0 +1,297 @@
+import "dotenv/config";
+import { strict as assert } from "node:assert";
+import crypto from "node:crypto";
+import { after, before, test } from "node:test";
+import { closeDb, db, runMigrations, schema } from "@superlog/db";
+import { and, eq } from "drizzle-orm";
+import { Hono } from "hono";
+import { mountGithubPublic } from "./github.js";
+
+const GH_WEBHOOK_SECRET = "github-test-secret";
+process.env.GITHUB_APP_WEBHOOK_SECRET = GH_WEBHOOK_SECRET;
+
+const orgIds: string[] = [];
+
+before(async () => {
+ await runMigrations();
+});
+
+after(async () => {
+ try {
+ for (const orgId of orgIds.reverse()) {
+ await db.delete(schema.orgs).where(eq(schema.orgs.id, orgId));
+ }
+ } finally {
+ await closeDb();
+ }
+});
+
+test("merged agent PR resolves incident, cascades linked issues, and writes timeline events", async () => {
+ const fixture = await seedAgentPrFixture("merged");
+ const app = new Hono();
+ mountGithubPublic(app);
+
+ const mergedAt = new Date().toISOString();
+ const res = await postGithub(app, "pull_request", `gh-${fixture.tag}-merged`, {
+ action: "closed",
+ repository: { full_name: fixture.repoFullName },
+ pull_request: {
+ number: fixture.prNumber,
+ merged: true,
+ merged_at: mergedAt,
+ closed_at: mergedAt,
+ merged_by: { login: "alice", id: 100 },
+ user: { login: "superlog-bot", id: 999 },
+ head: { sha: "cafebabe", ref: fixture.branchName },
+ },
+ sender: { login: "alice", id: 100 },
+ installation: { id: fixture.installationId },
+ });
+
+ assert.equal(res.status, 200);
+
+ const pr = await db.query.agentPullRequests.findFirst({
+ where: eq(schema.agentPullRequests.id, fixture.agentPrId),
+ });
+ assert.equal(pr?.state, "merged");
+ assert.equal(pr?.mergedByLogin, "alice");
+ assert.ok(pr?.mergedAt);
+
+ const incident = await db.query.incidents.findFirst({
+ where: eq(schema.incidents.id, fixture.incidentId),
+ });
+ assert.equal(incident?.status, "resolved");
+
+ const issue = await db.query.issues.findFirst({
+ where: eq(schema.issues.id, fixture.issueId),
+ });
+ assert.ok(issue);
+
+ const resolvedEvents = await db.query.incidentEvents.findMany({
+ where: and(
+ eq(schema.incidentEvents.agentRunId, fixture.agentRunId),
+ eq(schema.incidentEvents.kind, "incident_resolved"),
+ ),
+ });
+ assert.equal(resolvedEvents.length, 1);
+ assert.equal(
+ resolvedEvents[0]?.summary,
+ `Incident resolved because PR #${fixture.prNumber} was merged.`,
+ );
+ assert.equal(resolvedEvents[0]?.detail?.reason, "agent_pr_merged");
+
+ const prMergedEvent = await db.query.agentPrEvents.findFirst({
+ where: and(
+ eq(schema.agentPrEvents.agentPrId, fixture.agentPrId),
+ eq(schema.agentPrEvents.kind, "pr_merged"),
+ ),
+ });
+ assert.ok(prMergedEvent);
+
+ const duplicate = await postGithub(app, "pull_request", `gh-${fixture.tag}-merged`, {
+ action: "closed",
+ repository: { full_name: fixture.repoFullName },
+ pull_request: {
+ number: fixture.prNumber,
+ merged: true,
+ merged_at: mergedAt,
+ closed_at: mergedAt,
+ merged_by: { login: "alice", id: 100 },
+ },
+ sender: { login: "alice", id: 100 },
+ installation: { id: fixture.installationId },
+ });
+ assert.equal(duplicate.status, 200);
+
+ const resolvedEventsAfterDuplicate = await db.query.incidentEvents.findMany({
+ where: and(
+ eq(schema.incidentEvents.agentRunId, fixture.agentRunId),
+ eq(schema.incidentEvents.kind, "incident_resolved"),
+ ),
+ });
+ assert.equal(resolvedEventsAfterDuplicate.length, 1);
+});
+
+test("closed unmerged agent PR does not resolve incident or linked issue", async () => {
+ const fixture = await seedAgentPrFixture("closed");
+ const app = new Hono();
+ mountGithubPublic(app);
+
+ const closedAt = new Date().toISOString();
+ const res = await postGithub(app, "pull_request", `gh-${fixture.tag}-closed`, {
+ action: "closed",
+ repository: { full_name: fixture.repoFullName },
+ pull_request: {
+ number: fixture.prNumber,
+ merged: false,
+ closed_at: closedAt,
+ user: { login: "superlog-bot", id: 999 },
+ },
+ sender: { login: "alice", id: 100 },
+ installation: { id: fixture.installationId },
+ });
+
+ assert.equal(res.status, 200);
+
+ const pr = await db.query.agentPullRequests.findFirst({
+ where: eq(schema.agentPullRequests.id, fixture.agentPrId),
+ });
+ assert.equal(pr?.state, "closed");
+
+ const incident = await db.query.incidents.findFirst({
+ where: eq(schema.incidents.id, fixture.incidentId),
+ });
+ assert.equal(incident?.status, "open");
+
+ const issue = await db.query.issues.findFirst({
+ where: eq(schema.issues.id, fixture.issueId),
+ });
+ assert.ok(issue);
+
+ const resolvedEvent = await db.query.incidentEvents.findFirst({
+ where: and(
+ eq(schema.incidentEvents.agentRunId, fixture.agentRunId),
+ eq(schema.incidentEvents.kind, "incident_resolved"),
+ ),
+ });
+ assert.equal(resolvedEvent, undefined);
+});
+
+async function seedAgentPrFixture(label: string): Promise<{
+ tag: string;
+ repoFullName: string;
+ branchName: string;
+ installationId: number;
+ incidentId: string;
+ issueId: string;
+ agentRunId: string;
+ agentPrId: string;
+ prNumber: number;
+}> {
+ const tag = `test-${label}-${Date.now()}-${Math.floor(Math.random() * 1_000_000)}`;
+ const repoFullName = `acme/${tag}`;
+ const branchName = `superlog/${tag}`;
+ const installationId = Math.floor(Date.now() / 1000) + Math.floor(Math.random() * 1_000_000);
+ const prNumber = Math.floor(Math.random() * 10_000) + 1;
+
+ const [org] = await db
+ .insert(schema.orgs)
+ .values({ name: tag, slug: tag })
+ .returning();
+ if (!org) throw new Error("failed to seed org");
+ orgIds.push(org.id);
+
+ const [project] = await db
+ .insert(schema.projects)
+ .values({ orgId: org.id, name: "test", slug: tag })
+ .returning();
+ if (!project) throw new Error("failed to seed project");
+
+ const now = new Date();
+ const [incident] = await db
+ .insert(schema.incidents)
+ .values({
+ projectId: project.id,
+ title: "Test incident",
+ service: "api",
+ firstSeen: now,
+ lastSeen: now,
+ })
+ .returning();
+ if (!incident) throw new Error("failed to seed incident");
+
+ const [issue] = await db
+ .insert(schema.issues)
+ .values({
+ projectId: project.id,
+ fingerprint: `fp-${tag}`,
+ kind: "span",
+ service: "api",
+ exceptionType: "Error",
+ title: "Test issue",
+ message: "boom",
+ firstSeen: now,
+ lastSeen: now,
+ })
+ .returning();
+ if (!issue) throw new Error("failed to seed issue");
+
+ await db.insert(schema.incidentIssues).values({
+ incidentId: incident.id,
+ issueId: issue.id,
+ });
+
+ const [agentRun] = await db
+ .insert(schema.agentRuns)
+ .values({ incidentId: incident.id, runtime: "anthropic", state: "running" })
+ .returning();
+ if (!agentRun) throw new Error("failed to seed agent run");
+
+ const [installation] = await db
+ .insert(schema.githubInstallations)
+ .values({
+ orgId: org.id,
+ projectId: project.id,
+ installationId,
+ accountLogin: "acme-bot",
+ accountType: "Organization",
+ repos: [{ id: 1, fullName: repoFullName, private: false }],
+ })
+ .returning();
+ if (!installation) throw new Error("failed to seed GitHub installation");
+
+ const [agentPr] = await db
+ .insert(schema.agentPullRequests)
+ .values({
+ incidentId: incident.id,
+ agentRunId: agentRun.id,
+ installationId: installation.id,
+ repoFullName,
+ prNumber,
+ prNodeId: `PR_${tag}`,
+ url: `https://github.com/${repoFullName}/pull/${prNumber}`,
+ branchName,
+ baseBranch: "main",
+ headSha: "deadbeef",
+ title: "[superlog] Fix bug",
+ state: "open",
+ lastSyncedAt: now,
+ })
+ .returning();
+ if (!agentPr) throw new Error("failed to seed agent PR");
+
+ return {
+ tag,
+ repoFullName,
+ branchName,
+ installationId,
+ incidentId: incident.id,
+ issueId: issue.id,
+ agentRunId: agentRun.id,
+ agentPrId: agentPr.id,
+ prNumber,
+ };
+}
+
+async function postGithub(
+ app: Hono,
+ event: string,
+ delivery: string,
+ payload: unknown,
+): Promise {
+ const body = JSON.stringify(payload);
+ return app.request("/github/webhook", {
+ method: "POST",
+ headers: {
+ "content-type": "application/json",
+ "x-github-event": event,
+ "x-github-delivery": delivery,
+ "x-hub-signature-256": ghSign(body),
+ },
+ body,
+ });
+}
+
+function ghSign(body: string): string {
+ return `sha256=${crypto.createHmac("sha256", GH_WEBHOOK_SECRET).update(body).digest("hex")}`;
+}
diff --git a/superlog/apps/api/src/github.ts b/superlog/apps/api/src/github.ts
new file mode 100644
index 0000000000000000000000000000000000000000..2c48b0f4166ae330737b176132016da69f93418e
--- /dev/null
+++ b/superlog/apps/api/src/github.ts
@@ -0,0 +1,2037 @@
+import crypto from "node:crypto";
+import {
+ db,
+ listAccessibleGithubInstallsForProject,
+ resolveIncident,
+ schema,
+ syncLoopsContactsForOrg,
+} from "@superlog/db";
+import { and, eq, inArray, isNull, sql } from "drizzle-orm";
+import type { Hono } from "hono";
+import type { Context } from "hono";
+import {
+ FEEDBACK_PR_FOOTER_MARKER,
+ isFeedbackEligibleCommenter,
+ recordFeedback,
+} from "./feedback.js";
+import { getDeviceFlow, getLinkedDevice, getSkillDeviceForIntegration } from "./gateway.js";
+import { type RepoBranch, type RepoBranchInfo, mergeRepoBranches } from "./github-branches.js";
+import { runResolvedIncidentSideEffectsForIncident } from "./incidents/resolution-side-effects.js";
+import { logger } from "./logger.js";
+import { resolveActiveOrgContext } from "./org-context.js";
+
+const log = logger.child({ scope: "github" });
+type Vars = { userId: string; orgId: string | null };
+const DEFAULT_COMMIT_AUTHOR = {
+ name: "Superlog app",
+ email: "bot@superlog.sh",
+};
+
+// biome-ignore lint/suspicious/noExplicitAny: Hono Variables invariance.
+export function mountGithubPublic(app: Hono): void {
+ const appSlug = process.env.GITHUB_APP_SLUG;
+ const stateSecret = process.env.STATE_SIGNING_SECRET;
+ const webhookSecret = process.env.GITHUB_APP_WEBHOOK_SECRET;
+ const webOrigin = process.env.WEB_ORIGIN ?? "http://localhost:5173";
+ const oauthClientId =
+ process.env.GITHUB_OAUTH_CLIENT_ID ??
+ process.env.GITHUB_APP_CLIENT_ID ??
+ process.env.GITHUB_CLIENT_ID;
+ const oauthClientSecret =
+ process.env.GITHUB_OAUTH_CLIENT_SECRET ??
+ process.env.GITHUB_APP_CLIENT_SECRET ??
+ process.env.GITHUB_CLIENT_SECRET;
+ const installRedirectUrl =
+ process.env.GITHUB_INSTALL_OAUTH_REDIRECT_URL ??
+ "http://localhost:4100/github/install/callback";
+
+ if (!appSlug) {
+ log.warn("GITHUB_APP_SLUG not set — /github/install disabled");
+ }
+ if (!stateSecret) {
+ log.warn("STATE_SIGNING_SECRET not set — /github/install disabled");
+ }
+ if (!webhookSecret) {
+ log.warn("GITHUB_APP_WEBHOOK_SECRET not set — /github/webhook disabled");
+ }
+
+ app.get("/github/install", (c) => {
+ if (!appSlug || !stateSecret) {
+ return c.json({ error: "github app not configured" }, 503);
+ }
+ const userCode = (c.req.query("user_code") ?? "").toUpperCase();
+ // CLI/MCP flow: device must be in user_linked state. Skill flow drives
+ // GitHub install AFTER pairing (device already approved), so fall back
+ // to the skill-device helper when getLinkedDevice rejects it.
+ const device = getLinkedDevice(userCode) ?? getSkillDeviceForIntegration(userCode);
+ if (!device) return c.json({ error: "unknown or not-ready device code" }, 404);
+
+ const state = signState("cli", userCode, stateSecret);
+ const url = new URL(`https://github.com/apps/${appSlug}/installations/new`);
+ url.searchParams.set("state", state);
+ return c.redirect(url.toString(), 302);
+ });
+
+ app.get("/github/install/callback", async (c) => {
+ if (!stateSecret) return c.json({ error: "github app not configured" }, 503);
+ const installationIdRaw = c.req.query("installation_id");
+ const state = c.req.query("state") ?? "";
+ const setupAction = c.req.query("setup_action") ?? "install";
+ const oauthCode = c.req.query("code") ?? null;
+ log.info(
+ {
+ setup_action: setupAction,
+ installation_id: installationIdRaw ?? null,
+ has_oauth_code: !!oauthCode,
+ },
+ "github install callback received",
+ );
+
+ const installationId = Number(installationIdRaw);
+ const marker = setupAction === "update" ? "updated" : "done";
+
+ // First try the management-API state shape for platform provisioning.
+ // Falls through to the existing cli/web state shape if not a mgmt state.
+ const mgmt = verifyMgmtState(state, stateSecret);
+ if (mgmt) {
+ const callbackWebOrigin = resolveCallbackWebOrigin(c, webOrigin);
+ const failTarget = mgmt.returnUrl ?? `${callbackWebOrigin}/`;
+ if (!Number.isFinite(installationId) || installationId <= 0) {
+ return c.redirect(`${failTarget}${failTarget.includes("?") ? "&" : "?"}gh=error`, 302);
+ }
+ let resolvedOrgId: string;
+ let resolvedProjectId: string | null;
+ if (mgmt.scope === "org") {
+ resolvedOrgId = mgmt.orgId;
+ resolvedProjectId = null;
+ } else {
+ const project = await db.query.projects.findFirst({
+ where: eq(schema.projects.id, mgmt.projectId),
+ });
+ if (!project) {
+ return c.redirect(`${failTarget}${failTarget.includes("?") ? "&" : "?"}gh=error`, 302);
+ }
+ resolvedOrgId = project.orgId;
+ resolvedProjectId = project.id;
+ }
+ const rowId = await upsertInstallation({
+ orgId: resolvedOrgId,
+ projectId: resolvedProjectId,
+ installationId,
+ });
+ await resumeBlockedAgentRunsForOrg(resolvedOrgId, "github_install", {
+ projectIdHint: resolvedProjectId,
+ });
+ await maybeCaptureInstallerIdentity({
+ installationRowId: rowId,
+ installationId,
+ oauthCode,
+ clientId: oauthClientId,
+ clientSecret: oauthClientSecret,
+ redirectUrl: installRedirectUrl,
+ });
+ const successTarget = mgmt.returnUrl ?? `${callbackWebOrigin}/`;
+ return c.redirect(
+ `${successTarget}${successTarget.includes("?") ? "&" : "?"}gh=${marker}`,
+ 302,
+ );
+ }
+
+ const decoded = verifyState(state, stateSecret);
+ if (!decoded) return c.json({ error: "invalid state" }, 400);
+
+ // "cli" kind: value is userCode → resolve org+project from device.
+ // "web" kind: value is projectId → resolve org from the project row.
+ let orgId: string | null;
+ let projectId: string | null;
+ if (decoded.kind === "cli") {
+ const device = getLinkedDevice(decoded.value) ?? getSkillDeviceForIntegration(decoded.value);
+ orgId = device?.orgId ?? null;
+ projectId = device?.projectId ?? null;
+ } else {
+ const project = await db.query.projects.findFirst({
+ where: eq(schema.projects.id, decoded.value),
+ });
+ orgId = project?.orgId ?? null;
+ projectId = project?.id ?? null;
+ }
+
+ if (decoded.kind === "cli") {
+ const userCode = decoded.value;
+ const flow = getDeviceFlow(userCode);
+ const flowQuery = flow === "skill" ? "&flow=skill" : "";
+ const callbackWebOrigin = resolveCallbackWebOrigin(c, webOrigin);
+ if (!orgId || !projectId) {
+ return c.redirect(
+ `${callbackWebOrigin}/activate?code=${userCode}${flowQuery}&gh=expired`,
+ 302,
+ );
+ }
+ if (!Number.isFinite(installationId) || installationId <= 0) {
+ return c.redirect(
+ `${callbackWebOrigin}/activate?code=${userCode}${flowQuery}&gh=error`,
+ 302,
+ );
+ }
+ const rowId = await upsertInstallation({ orgId, projectId, installationId });
+ await resumeBlockedAgentRunsForProjects([projectId], "github_install");
+ await maybeCaptureInstallerIdentity({
+ installationRowId: rowId,
+ installationId,
+ oauthCode,
+ clientId: oauthClientId,
+ clientSecret: oauthClientSecret,
+ redirectUrl: installRedirectUrl,
+ });
+ return c.redirect(
+ `${callbackWebOrigin}/activate?code=${userCode}${flowQuery}&gh=${marker}`,
+ 302,
+ );
+ }
+
+ // decoded.kind === "web" — value resolved to project above.
+ const callbackWebOrigin = resolveCallbackWebOrigin(c, webOrigin);
+ if (!Number.isFinite(installationId) || installationId <= 0 || !orgId || !projectId) {
+ return c.redirect(`${callbackWebOrigin}/?gh=error`, 302);
+ }
+ const rowId = await upsertInstallation({ orgId, projectId, installationId });
+ await resumeBlockedAgentRunsForProjects([projectId], "github_install");
+ await maybeCaptureInstallerIdentity({
+ installationRowId: rowId,
+ installationId,
+ oauthCode,
+ clientId: oauthClientId,
+ clientSecret: oauthClientSecret,
+ redirectUrl: installRedirectUrl,
+ });
+ return c.redirect(`${callbackWebOrigin}/?gh=${marker}`, 302);
+ });
+
+ app.post("/github/webhook", async (c) => {
+ if (!webhookSecret) return c.json({ error: "webhook not configured" }, 503);
+
+ const rawBody = await c.req.text();
+ const sigHeader = c.req.header("x-hub-signature-256") ?? "";
+ if (!verifyWebhookSignature(rawBody, sigHeader, webhookSecret)) {
+ log.warn({ delivery: c.req.header("x-github-delivery") }, "github webhook signature failed");
+ return c.json({ error: "bad signature" }, 401);
+ }
+
+ const event = c.req.header("x-github-event") ?? "";
+ let payload: WebhookPayload;
+ try {
+ payload = JSON.parse(rawBody) as WebhookPayload;
+ } catch {
+ return c.json({ error: "invalid json" }, 400);
+ }
+
+ const delivery = c.req.header("x-github-delivery") ?? null;
+ try {
+ await handleWebhook(event, payload, delivery);
+ log.info(
+ {
+ event,
+ action: payload.action,
+ installation_id: payload.installation?.id,
+ delivery,
+ },
+ "github webhook handled",
+ );
+ } catch (err) {
+ log.error(
+ {
+ err,
+ event,
+ action: payload.action,
+ installation_id: payload.installation?.id,
+ },
+ "webhook handler failed",
+ );
+ // Let GitHub retry on transient failures.
+ return c.json({ error: "handler failed" }, 500);
+ }
+ return c.json({ ok: true });
+ });
+}
+
+function resolveCallbackWebOrigin(c: Context, configuredWebOrigin: string): string {
+ const host = c.req.header("host") ?? "";
+ if (
+ host === "localhost:4100" ||
+ host === "127.0.0.1:4100" ||
+ configuredWebOrigin.endsWith(".superlog.localhost:1355")
+ ) {
+ return "http://localhost:5173";
+ }
+ return configuredWebOrigin;
+}
+
+type GhRepo = { id: number; full_name: string; private: boolean };
+export type StoredRepo = { id: number; fullName: string; private: boolean };
+type RepoAccess = { disabledRepoIds?: number[] };
+type GithubRepoSummary = StoredRepo & { enabled: boolean };
+type GithubInstallationReposResponse = { repositories: GhRepo[] };
+type GithubOAuthTokenResponse = {
+ access_token?: string;
+ token_type?: string;
+ scope?: string;
+ error?: string;
+ error_description?: string;
+};
+type GithubViewerResponse = {
+ id: number;
+ login: string;
+ name: string | null;
+ email: string | null;
+ avatar_url: string | null;
+};
+type GithubEmailResponse = {
+ email: string;
+ primary: boolean;
+ verified: boolean;
+ visibility: string | null;
+};
+type GithubUserInstallation = {
+ id: number;
+ account: { login?: string; type?: string } | null;
+ html_url: string;
+ repository_selection: "all" | "selected";
+ suspended_at: string | null;
+};
+type GithubUserInstallationsResponse = {
+ installations: GithubUserInstallation[];
+};
+type GithubUserInstallationReposResponse = {
+ repositories: Array<{ id: number; full_name: string; private: boolean }>;
+};
+type GithubInstallationSummary = {
+ installationId: number;
+ accountLogin: string | null;
+ accountType: string | null;
+ enabled: boolean;
+ manageUrl: string;
+ repos: GithubRepoSummary[];
+};
+type CommitAuthor = {
+ name: string;
+ email: string;
+ githubLogin: string;
+ githubId: number;
+ avatarUrl: string | null;
+};
+type GhActor = {
+ login?: string;
+ id?: number;
+ avatar_url?: string | null;
+ type?: string;
+} | null;
+type WebhookPayload = {
+ action?: string;
+ installation?: {
+ id: number;
+ account?: { login?: string; type?: string };
+ };
+ repositories?: GhRepo[];
+ repositories_added?: GhRepo[];
+ repositories_removed?: GhRepo[];
+ // pull_request events / pull_request_review / pull_request_review_comment
+ repository?: { full_name?: string };
+ pull_request?: {
+ number?: number;
+ node_id?: string;
+ state?: string;
+ title?: string;
+ merged?: boolean;
+ merged_at?: string | null;
+ closed_at?: string | null;
+ merged_by?: GhActor;
+ user?: GhActor;
+ head?: { sha?: string; ref?: string };
+ base?: { ref?: string };
+ html_url?: string;
+ };
+ // issue_comment (only PR if issue.pull_request present)
+ issue?: {
+ number?: number;
+ pull_request?: { url?: string };
+ };
+ comment?: {
+ id?: number;
+ body?: string;
+ user?: GhActor;
+ html_url?: string;
+ author_association?: string;
+ };
+ review?: {
+ id?: number;
+ state?: string;
+ body?: string | null;
+ user?: GhActor;
+ html_url?: string;
+ author_association?: string;
+ };
+ // push events
+ ref?: string;
+ after?: string;
+ before?: string;
+ pusher?: { name?: string; email?: string };
+ sender?: GhActor;
+ commits?: Array<{ id?: string; message?: string; author?: { name?: string; email?: string } }>;
+};
+
+async function handleWebhook(
+ event: string,
+ payload: WebhookPayload,
+ delivery: string | null,
+): Promise {
+ if (
+ event === "pull_request" ||
+ event === "pull_request_review" ||
+ event === "pull_request_review_comment" ||
+ event === "issue_comment" ||
+ event === "push"
+ ) {
+ await handleAgentPrWebhook(event, payload, delivery);
+ return;
+ }
+
+ const installationId = payload.installation?.id;
+ if (!installationId) return;
+
+ if (event === "installation") {
+ const action = payload.action;
+ if (action === "created" || action === "new_permissions_accepted") {
+ await db
+ .update(schema.githubInstallations)
+ .set({
+ accountLogin: payload.installation?.account?.login ?? null,
+ accountType: payload.installation?.account?.type ?? null,
+ repos: [],
+ revokedAt: null,
+ })
+ .where(eq(schema.githubInstallations.installationId, installationId));
+ await resumeBlockedAgentRunsForInstallation(installationId, "github_install");
+ return;
+ }
+ if (action === "deleted" || action === "suspend") {
+ await db
+ .update(schema.githubInstallations)
+ .set({ revokedAt: new Date() })
+ .where(eq(schema.githubInstallations.installationId, installationId));
+ return;
+ }
+ if (action === "unsuspend") {
+ await db
+ .update(schema.githubInstallations)
+ .set({ revokedAt: null })
+ .where(eq(schema.githubInstallations.installationId, installationId));
+ await resumeBlockedAgentRunsForInstallation(installationId, "github_install");
+ return;
+ }
+ return;
+ }
+
+ if (event === "installation_repositories") {
+ const row = await db.query.githubInstallations.findFirst({
+ where: eq(schema.githubInstallations.installationId, installationId),
+ });
+ if (!row) return;
+ await db
+ .update(schema.githubInstallations)
+ .set({
+ repos: [],
+ accountLogin: payload.installation?.account?.login ?? row.accountLogin,
+ accountType: payload.installation?.account?.type ?? row.accountType,
+ })
+ .where(eq(schema.githubInstallations.installationId, installationId));
+ if (payload.action === "added") {
+ await resumeBlockedAgentRunsForInstallation(installationId, "github_repos_added");
+ }
+ }
+}
+
+// Requeue any agentRuns sitting in `blocked_no_github` under the given
+// project(s). Cheap and idempotent — single bulk UPDATE + batched event
+// inserts. Callers: install webhook (resolves project IDs from
+// installationId) and the OAuth install callback (knows projectId directly,
+// avoids a webhook/callback race on first-time installs where the github
+// installations row may not exist yet when the webhook arrives).
+export async function resumeBlockedAgentRunsForProjects(
+ projectIds: string[],
+ trigger: "github_install" | "github_repos_added",
+): Promise {
+ if (projectIds.length === 0) return;
+ const blocked = await db
+ .select({ id: schema.agentRuns.id })
+ .from(schema.agentRuns)
+ .innerJoin(schema.incidents, eq(schema.incidents.id, schema.agentRuns.incidentId))
+ .where(
+ and(
+ eq(schema.agentRuns.state, "blocked_no_github"),
+ inArray(schema.incidents.projectId, projectIds),
+ ),
+ );
+ if (blocked.length === 0) return;
+
+ const ids = blocked.map((row) => row.id);
+ const now = new Date();
+ await db
+ .update(schema.agentRuns)
+ .set({ state: "queued", updatedAt: now })
+ .where(inArray(schema.agentRuns.id, ids));
+ await db
+ .insert(schema.incidentEvents)
+ .values(
+ ids.map((id) => ({
+ agentRunId: id,
+ kind: "unblocked",
+ summary: "Investigation requeued.",
+ detail: { trigger },
+ dedupeKey: `unblocked:${id}:${now.getTime()}`,
+ processedAt: now,
+ })),
+ )
+ .onConflictDoNothing();
+ log.info({ projectIds, trigger, count: ids.length }, "resumed blocked agent_runs");
+}
+
+// Org-scoped resume — fans out across every project in the org. Used for
+// org-scoped installs where the install grants access to repos that any
+// project can be wired up to. The optional `projectIdHint` is purely an
+// optimisation: if the caller already knows one project should be revived,
+// it's bundled into the same single query so we still issue one bulk update.
+async function resumeBlockedAgentRunsForOrg(
+ orgId: string,
+ trigger: "github_install" | "github_repos_added",
+ opts: { projectIdHint?: string | null } = {},
+): Promise {
+ const orgProjects = await db.query.projects.findMany({
+ where: eq(schema.projects.orgId, orgId),
+ columns: { id: true },
+ });
+ const projectIds = new Set(orgProjects.map((row) => row.id));
+ if (opts.projectIdHint) projectIds.add(opts.projectIdHint);
+ if (projectIds.size === 0) return;
+ await resumeBlockedAgentRunsForProjects([...projectIds], trigger);
+}
+
+async function resumeBlockedAgentRunsForInstallation(
+ installationId: number,
+ trigger: "github_install" | "github_repos_added",
+): Promise {
+ const installs = await db.query.githubInstallations.findMany({
+ where: and(
+ eq(schema.githubInstallations.installationId, installationId),
+ isNull(schema.githubInstallations.revokedAt),
+ ),
+ columns: { orgId: true, projectId: true },
+ });
+ if (installs.length === 0) return;
+ // Project-scoped installs grant access narrowly — fan out to that project
+ // only. Org-scoped installs (projectId null) can grant repos to any project
+ // in the org, so widen to every project under the org.
+ const directProjectIds = installs
+ .map((row) => row.projectId)
+ .filter((id): id is string => id !== null);
+ if (directProjectIds.length > 0) {
+ await resumeBlockedAgentRunsForProjects(directProjectIds, trigger);
+ }
+ const orgIdsForOrgScoped = Array.from(
+ new Set(installs.filter((row) => row.projectId === null).map((row) => row.orgId)),
+ );
+ for (const orgId of orgIdsForOrgScoped) {
+ await resumeBlockedAgentRunsForOrg(orgId, trigger);
+ }
+}
+
+async function handleAgentPrWebhook(
+ event: string,
+ payload: WebhookPayload,
+ delivery: string | null,
+): Promise {
+ const repoFullName = payload.repository?.full_name;
+ if (!repoFullName) return;
+
+ // Resolve which agent_pull_request row this event is about.
+ let prNumber: number | undefined;
+ if (
+ event === "pull_request" ||
+ event === "pull_request_review" ||
+ event === "pull_request_review_comment"
+ ) {
+ prNumber = payload.pull_request?.number;
+ } else if (event === "issue_comment") {
+ if (!payload.issue?.pull_request) return;
+ prNumber = payload.issue.number;
+ }
+ // push: resolve by branch
+ let agentPrRow: schema.AgentPullRequest | undefined;
+ if (event === "push") {
+ const ref = payload.ref;
+ if (!ref?.startsWith("refs/heads/")) return;
+ const branch = ref.slice("refs/heads/".length);
+ agentPrRow = await db.query.agentPullRequests.findFirst({
+ where: and(
+ eq(schema.agentPullRequests.repoFullName, repoFullName),
+ eq(schema.agentPullRequests.branchName, branch),
+ ),
+ });
+ } else if (typeof prNumber === "number") {
+ agentPrRow = await db.query.agentPullRequests.findFirst({
+ where: and(
+ eq(schema.agentPullRequests.repoFullName, repoFullName),
+ eq(schema.agentPullRequests.prNumber, prNumber),
+ ),
+ });
+ }
+ if (!agentPrRow) return;
+
+ // Capture human review comments as feedback. We surface this in
+ // /admin/feedback so we can hear about how our agent-opened PRs are
+ // landing with the people reviewing them. Bot comments (greptile and
+ // friends) and the GitHub "review submitted" envelope (which has its
+ // own dedicated state machine in describeAgentPrEvent) are skipped.
+ //
+ // Best-effort: don't let a feedback DB error cascade into a 500 that
+ // makes GitHub retry the whole delivery. A retry would re-run the
+ // agent_pr_events insert (idempotent via onConflictDoNothing) and the
+ // PR state update, but a partial state where the feedback row landed
+ // before the retry-triggering throw is exactly the kind of "I see the
+ // feedback but not the merge-resolved incident" inconsistency we want
+ // to avoid. Matches the wrapping pattern used for Slack feedback in
+ // apps/api/src/slack.ts.
+ await maybeRecordPrCommentFeedback({ event, payload, agentPrRow }).catch((err) => {
+ log.warn({ err, event, agent_pr_id: agentPrRow.id }, "pr comment feedback capture failed");
+ });
+
+ const now = new Date();
+
+ // Update the parent row when this is a state-changing event.
+ if (event === "pull_request") {
+ const action = payload.action ?? "";
+ const pr = payload.pull_request ?? {};
+ let mergedResolution: { mergedAt: Date; mergedByLogin: string | null } | null = null;
+ const updates: Partial = {
+ lastSyncedAt: now,
+ updatedAt: now,
+ };
+ if (pr.head?.sha) updates.headSha = pr.head.sha;
+ if (typeof pr.title === "string") updates.title = pr.title;
+ if (action === "closed") {
+ if (pr.merged) {
+ updates.state = "merged";
+ if (pr.merged_at) updates.mergedAt = new Date(pr.merged_at);
+ updates.closedAt = pr.closed_at ? new Date(pr.closed_at) : now;
+ updates.mergedByLogin = pr.merged_by?.login ?? null;
+ updates.mergedByGithubId = pr.merged_by?.id ?? null;
+ mergedResolution = {
+ mergedAt: updates.mergedAt ?? updates.closedAt ?? now,
+ mergedByLogin: updates.mergedByLogin ?? null,
+ };
+ } else {
+ updates.state = "closed";
+ updates.closedAt = pr.closed_at ? new Date(pr.closed_at) : now;
+ }
+ } else if (action === "reopened") {
+ updates.state = "open";
+ updates.closedAt = null;
+ }
+ await db
+ .update(schema.agentPullRequests)
+ .set(updates)
+ .where(eq(schema.agentPullRequests.id, agentPrRow.id));
+ if (mergedResolution) {
+ await resolveIncidentForMergedAgentPr({
+ agentPr: agentPrRow,
+ mergedAt: mergedResolution.mergedAt,
+ mergedByLogin: mergedResolution.mergedByLogin,
+ });
+ }
+ } else if (event === "push") {
+ if (payload.after) {
+ await db
+ .update(schema.agentPullRequests)
+ .set({ headSha: payload.after, lastSyncedAt: now, updatedAt: now })
+ .where(eq(schema.agentPullRequests.id, agentPrRow.id));
+ }
+ }
+
+ const { kind, summary, actor } = describeAgentPrEvent(event, payload);
+ if (!kind) return;
+
+ await db
+ .insert(schema.agentPrEvents)
+ .values({
+ agentPrId: agentPrRow.id,
+ kind,
+ summary,
+ actorLogin: actor?.login ?? null,
+ actorGithubId: actor?.id ?? null,
+ actorAvatarUrl: actor?.avatar_url ?? null,
+ payload: payload as unknown as Record,
+ providerEventId: delivery,
+ occurredAt: now,
+ })
+ .onConflictDoNothing();
+}
+
+async function resolveIncidentForMergedAgentPr(opts: {
+ agentPr: schema.AgentPullRequest;
+ mergedAt: Date;
+ mergedByLogin: string | null;
+}): Promise {
+ const { agentPr, mergedAt, mergedByLogin } = opts;
+ const { resolved } = await resolveIncident({
+ incidentId: agentPr.incidentId,
+ kind: "agent_pr_merged",
+ reasonCode: "agent_pr_merged",
+ reasonText: `Resolved because agent PR #${agentPr.prNumber} (${agentPr.repoFullName}) was merged${
+ mergedByLogin ? ` by @${mergedByLogin}` : ""
+ }.`,
+ agentRunId: agentPr.agentRunId,
+ eventSummary: `Incident resolved because PR #${agentPr.prNumber} was merged.`,
+ eventDetail: {
+ agentPrId: agentPr.id,
+ repoFullName: agentPr.repoFullName,
+ prNumber: agentPr.prNumber,
+ prUrl: agentPr.url,
+ mergedByLogin,
+ },
+ eventDedupeKey: `incident_resolved:agent_pr:${agentPr.id}`,
+ resolvedAt: mergedAt,
+ });
+ if (resolved) {
+ await runResolvedIncidentSideEffectsForIncident({
+ incidentId: agentPr.incidentId,
+ closePullRequest: (pr) =>
+ closeAgentPullRequestOnGithub({
+ installationId: pr.githubInstallationId,
+ repoFullName: pr.repoFullName,
+ prNumber: pr.prNumber,
+ }),
+ });
+ }
+}
+
+async function maybeRecordPrCommentFeedback(opts: {
+ event: string;
+ payload: WebhookPayload;
+ agentPrRow: schema.AgentPullRequest;
+}): Promise {
+ const { event, payload, agentPrRow } = opts;
+ // Only the comment-bearing events count; PR open/close/merge has its
+ // own first-class handling and isn't user feedback.
+ let body: string | null = null;
+ let actor: GhActor = null;
+ let authorAssociation: string | null = null;
+ let commentUrl: string | null = null;
+ if (event === "issue_comment" && payload.action === "created") {
+ body = payload.comment?.body ?? null;
+ actor = payload.comment?.user ?? null;
+ authorAssociation = payload.comment?.author_association ?? null;
+ commentUrl = payload.comment?.html_url ?? null;
+ } else if (event === "pull_request_review_comment" && payload.action === "created") {
+ body = payload.comment?.body ?? null;
+ actor = payload.comment?.user ?? null;
+ authorAssociation = payload.comment?.author_association ?? null;
+ commentUrl = payload.comment?.html_url ?? null;
+ } else if (event === "pull_request_review" && payload.action === "submitted") {
+ body = payload.review?.body ?? null;
+ actor = payload.review?.user ?? null;
+ authorAssociation = payload.review?.author_association ?? null;
+ commentUrl = payload.review?.html_url ?? null;
+ } else {
+ return;
+ }
+ if (!body || body.trim().length === 0) return;
+
+ // Skip our own footer echoed back by the GitHub UI — the PR description
+ // itself is rendered into comment-like contexts occasionally (e.g. the
+ // squashed merge commit message), and we don't want our own "leave
+ // feedback" link surfaced as feedback.
+ if (body.includes(FEEDBACK_PR_FOOTER_MARKER)) return;
+
+ if (
+ !isFeedbackEligibleCommenter({
+ userType: actor?.type ?? null,
+ authorAssociation,
+ })
+ ) {
+ return;
+ }
+
+ // Resolve org + project via the agent PR's installation so the admin
+ // inbox can show which org's PR this feedback is on.
+ const install = await db.query.githubInstallations.findFirst({
+ where: eq(schema.githubInstallations.id, agentPrRow.installationId),
+ });
+
+ // Best-effort idempotency: GitHub redelivers webhooks on failure (the
+ // outer handler returns 500 on its own catch path), so a single PR
+ // comment can hit us multiple times. The comment URL is a stable
+ // natural key — skip if we've already recorded feedback for it. Not a
+ // strict unique index because the dedupe window between delivery
+ // retries is minutes, not concurrent; a hard guarantee would need a
+ // unique partial index, which we may add later if duplicates show up.
+ if (commentUrl) {
+ const existing = await db.query.feedback.findFirst({
+ where: and(
+ eq(schema.feedback.source, "pr_comment"),
+ sql`${schema.feedback.authorExternal}->>'githubCommentUrl' = ${commentUrl}`,
+ ),
+ columns: { id: true },
+ });
+ if (existing) return;
+ }
+
+ await recordFeedback({
+ kind: "pr",
+ refId: agentPrRow.id,
+ refRepo: agentPrRow.repoFullName,
+ source: "pr_comment",
+ body,
+ authorUserId: null,
+ authorExternal: {
+ githubLogin: actor?.login,
+ githubCommentUrl: commentUrl ?? undefined,
+ },
+ orgId: install?.orgId ?? null,
+ projectId: install?.projectId ?? null,
+ });
+}
+
+function describeAgentPrEvent(
+ event: string,
+ payload: WebhookPayload,
+): { kind: string | null; summary: string | null; actor: GhActor } {
+ if (event === "pull_request") {
+ const action = payload.action ?? "";
+ const actor = payload.sender ?? payload.pull_request?.user ?? null;
+ if (action === "closed") {
+ const merged = payload.pull_request?.merged === true;
+ return {
+ kind: merged ? "pr_merged" : "pr_closed",
+ summary: merged ? "PR merged" : "PR closed",
+ actor: merged ? (payload.pull_request?.merged_by ?? actor) : actor,
+ };
+ }
+ if (action === "reopened") return { kind: "pr_reopened", summary: "PR reopened", actor };
+ if (action === "edited") return { kind: "pr_edited", summary: "PR edited", actor };
+ if (action === "ready_for_review") {
+ return { kind: "pr_ready_for_review", summary: "Marked ready for review", actor };
+ }
+ if (action === "converted_to_draft") {
+ return { kind: "pr_converted_to_draft", summary: "Converted to draft", actor };
+ }
+ return { kind: null, summary: null, actor: null };
+ }
+ if (event === "pull_request_review") {
+ if (payload.action !== "submitted") return { kind: null, summary: null, actor: null };
+ const state = payload.review?.state ?? "commented";
+ const summary = `Review submitted (${state})`;
+ return {
+ kind: `review_${state}`,
+ summary,
+ actor: payload.review?.user ?? payload.sender ?? null,
+ };
+ }
+ if (event === "pull_request_review_comment") {
+ if (payload.action !== "created") return { kind: null, summary: null, actor: null };
+ return {
+ kind: "review_comment",
+ summary: "Inline review comment",
+ actor: payload.comment?.user ?? payload.sender ?? null,
+ };
+ }
+ if (event === "issue_comment") {
+ if (payload.action !== "created") return { kind: null, summary: null, actor: null };
+ return {
+ kind: "issue_comment",
+ summary: "Comment on PR",
+ actor: payload.comment?.user ?? payload.sender ?? null,
+ };
+ }
+ if (event === "push") {
+ const count = payload.commits?.length ?? 0;
+ return {
+ kind: "commit_pushed",
+ summary: `${count} commit${count === 1 ? "" : "s"} pushed`,
+ actor: payload.sender ?? null,
+ };
+ }
+ return { kind: null, summary: null, actor: null };
+}
+
+function toStoredRepo(r: GhRepo): StoredRepo {
+ return { id: r.id, fullName: r.full_name, private: r.private };
+}
+
+// Upserts an installation row. Returns the row's PK id so callers can patch
+// it (e.g. capture installer commit author).
+//
+// Both project- and org-scoped shapes use a partial unique index on the
+// active rows for their respective key tuple. onConflict targets need the
+// same WHERE clause as the index so postgres can match.
+//
+// The `set` payloads are effectively no-ops (any conflicting row already
+// has revoked_at IS NULL by virtue of the partial-index WHERE clause; org_id
+// is determined by project_id for the project-scoped case). They exist only
+// because `DO UPDATE` makes RETURNING fire for the existing row, where
+// `DO NOTHING` would return no row and force a second SELECT to get the id.
+async function upsertInstallation(args: {
+ orgId: string;
+ projectId: string | null;
+ installationId: number;
+}): Promise {
+ if (args.projectId) {
+ const [row] = await db
+ .insert(schema.githubInstallations)
+ .values({
+ orgId: args.orgId,
+ projectId: args.projectId,
+ installationId: args.installationId,
+ })
+ .onConflictDoUpdate({
+ target: [schema.githubInstallations.projectId, schema.githubInstallations.installationId],
+ targetWhere: sql`project_id IS NOT NULL AND revoked_at IS NULL`,
+ set: { orgId: args.orgId, revokedAt: null },
+ })
+ .returning({ id: schema.githubInstallations.id });
+ if (!row) throw new Error("upsert returned no row");
+ return row.id;
+ }
+ const [row] = await db
+ .insert(schema.githubInstallations)
+ .values({
+ orgId: args.orgId,
+ projectId: null,
+ installationId: args.installationId,
+ })
+ .onConflictDoUpdate({
+ target: [schema.githubInstallations.orgId, schema.githubInstallations.installationId],
+ targetWhere: sql`project_id IS NULL AND revoked_at IS NULL`,
+ set: { revokedAt: null },
+ })
+ .returning({ id: schema.githubInstallations.id });
+ if (!row) throw new Error("insert returned no row");
+ return row.id;
+}
+
+async function maybeCaptureInstallerIdentity(opts: {
+ installationRowId: string;
+ installationId: number;
+ oauthCode: string | null;
+ clientId: string | undefined;
+ clientSecret: string | undefined;
+ redirectUrl: string;
+}): Promise {
+ if (!opts.oauthCode || !opts.clientId || !opts.clientSecret) return;
+ try {
+ const token = await exchangeGithubOAuthCode({
+ clientId: opts.clientId,
+ clientSecret: opts.clientSecret,
+ code: opts.oauthCode,
+ redirectUrl: opts.redirectUrl,
+ });
+ const author = await fetchCommitAuthor(token);
+ await db
+ .update(schema.githubInstallations)
+ .set({
+ commitAuthorName: author.name,
+ commitAuthorEmail: author.email,
+ commitAuthorGithubLogin: author.githubLogin,
+ commitAuthorGithubId: author.githubId,
+ commitAuthorAvatarUrl: author.avatarUrl,
+ commitAuthorSetAt: new Date(),
+ })
+ .where(eq(schema.githubInstallations.id, opts.installationRowId));
+ log.info(
+ {
+ installation_row_id: opts.installationRowId,
+ installation_id: opts.installationId,
+ github_login: author.githubLogin,
+ },
+ "captured installer commit author from install OAuth",
+ );
+ } catch (err) {
+ log.warn(
+ { err, installation_row_id: opts.installationRowId, installation_id: opts.installationId },
+ "failed to capture installer commit author; falling back to default",
+ );
+ }
+}
+
+function verifyWebhookSignature(body: string, header: string, secret: string): boolean {
+ if (!header.startsWith("sha256=")) return false;
+ const provided = Buffer.from(header.slice("sha256=".length), "hex");
+ const expected = crypto.createHmac("sha256", secret).update(body).digest();
+ if (provided.length !== expected.length) return false;
+ return crypto.timingSafeEqual(provided, expected);
+}
+
+function getGithubAppConfig(): { appId: string; privateKey: string } | null {
+ const appId = process.env.GITHUB_APP_ID?.trim();
+ const privateKey =
+ process.env.GITHUB_APP_PRIVATE_KEY?.replace(/\\n/g, "\n") ??
+ (process.env.GITHUB_APP_PRIVATE_KEY_BASE64
+ ? Buffer.from(process.env.GITHUB_APP_PRIVATE_KEY_BASE64, "base64").toString("utf8")
+ : undefined);
+ if (!appId || !privateKey) return null;
+ return { appId, privateKey };
+}
+
+function signGithubAppJwt(appId: string, privateKey: string): string {
+ const now = Math.floor(Date.now() / 1000);
+ const header = Buffer.from(JSON.stringify({ alg: "RS256", typ: "JWT" })).toString("base64url");
+ const payload = Buffer.from(
+ JSON.stringify({
+ iat: now - 60,
+ exp: now + 9 * 60,
+ iss: appId,
+ }),
+ ).toString("base64url");
+ const signingInput = `${header}.${payload}`;
+ const signature = crypto.sign("RSA-SHA256", Buffer.from(signingInput), privateKey);
+ return `${signingInput}.${signature.toString("base64url")}`;
+}
+
+async function githubRequest(pathname: string, bearerToken: string): Promise {
+ const res = await fetch(`https://api.github.com${pathname}`, {
+ headers: {
+ accept: "application/vnd.github+json",
+ authorization: `Bearer ${bearerToken}`,
+ "x-github-api-version": "2022-11-28",
+ "user-agent": "superlog-api",
+ },
+ });
+ if (!res.ok) {
+ const text = await res.text().catch(() => "");
+ throw new Error(`github GET ${pathname} failed: ${res.status} ${text}`);
+ }
+ return (await res.json()) as T;
+}
+
+type GithubPermission = "read" | "write";
+
+async function createInstallationToken(opts: {
+ installationId: number;
+ permissions: Record;
+}): Promise {
+ const cfg = getGithubAppConfig();
+ if (!cfg) throw new Error("GITHUB_APP_ID and GITHUB_APP_PRIVATE_KEY are required");
+ const appJwt = signGithubAppJwt(cfg.appId, cfg.privateKey);
+ const res = await fetch(
+ `https://api.github.com/app/installations/${opts.installationId}/access_tokens`,
+ {
+ method: "POST",
+ headers: {
+ accept: "application/vnd.github+json",
+ authorization: `Bearer ${appJwt}`,
+ "content-type": "application/json; charset=utf-8",
+ "x-github-api-version": "2022-11-28",
+ "user-agent": "superlog-api",
+ },
+ body: JSON.stringify({ permissions: opts.permissions }),
+ },
+ );
+ if (!res.ok) {
+ const text = await res.text().catch(() => "");
+ throw new Error(
+ `github POST /app/installations/${opts.installationId}/access_tokens failed: ${res.status} ${text}`,
+ );
+ }
+ const data = (await res.json()) as { token: string };
+ return data.token;
+}
+
+async function createInstallationReadToken(installationId: number): Promise {
+ return createInstallationToken({ installationId, permissions: { contents: "read" } });
+}
+
+async function createInstallationWriteToken(installationId: number): Promise {
+ return createInstallationToken({
+ installationId,
+ permissions: { contents: "write", pull_requests: "write" },
+ });
+}
+
+export async function mergeGithubPullRequest(opts: {
+ installationId: number;
+ repoFullName: string;
+ prNumber: number;
+ method: "squash" | "merge" | "rebase";
+}): Promise<{ sha: string | null }> {
+ const token = await createInstallationWriteToken(opts.installationId);
+ const res = await fetch(
+ `https://api.github.com/repos/${opts.repoFullName}/pulls/${opts.prNumber}/merge`,
+ {
+ method: "PUT",
+ headers: {
+ accept: "application/vnd.github+json",
+ authorization: `Bearer ${token}`,
+ "content-type": "application/json; charset=utf-8",
+ "x-github-api-version": "2022-11-28",
+ "user-agent": "superlog-api",
+ },
+ body: JSON.stringify({ merge_method: opts.method }),
+ },
+ );
+ if (!res.ok) {
+ const text = await res.text().catch(() => "");
+ throw new Error(`github PUT /pulls/${opts.prNumber}/merge failed: ${res.status} ${text}`);
+ }
+ const body = (await res.json().catch(() => ({}))) as { sha?: string };
+ return { sha: body.sha ?? null };
+}
+
+export async function closeAgentPullRequestOnGithub(opts: {
+ installationId: number;
+ repoFullName: string;
+ prNumber: number;
+}): Promise<{ ok: true } | { ok: false; error: string }> {
+ try {
+ const token = await createInstallationWriteToken(opts.installationId);
+ const res = await fetch(
+ `https://api.github.com/repos/${opts.repoFullName}/pulls/${opts.prNumber}`,
+ {
+ method: "PATCH",
+ headers: {
+ accept: "application/vnd.github+json",
+ authorization: `Bearer ${token}`,
+ "content-type": "application/json; charset=utf-8",
+ "x-github-api-version": "2022-11-28",
+ "user-agent": "superlog-api",
+ },
+ body: JSON.stringify({ state: "closed" }),
+ },
+ );
+ if (res.ok) return { ok: true };
+ const text = await res.text().catch(() => "");
+ return { ok: false, error: `github PATCH /pulls/${opts.prNumber} ${res.status} ${text}` };
+ } catch (err) {
+ return { ok: false, error: err instanceof Error ? err.message : String(err) };
+ }
+}
+
+// Safety ceiling on paginated GitHub list calls: 100 pages × 100 per page =
+// 10k items. High enough to cover every realistic repo/branch count while
+// still bounding the loop so a pathological account can't spin it forever.
+const GITHUB_LIST_MAX_PAGES = 100;
+
+export async function listCurrentInstallationRepos(installationId: number): Promise {
+ const token = await createInstallationReadToken(installationId);
+ const repos: StoredRepo[] = [];
+ let page = 1;
+ for (; page <= GITHUB_LIST_MAX_PAGES; page += 1) {
+ const data = await githubRequest(
+ `/installation/repositories?per_page=100&page=${page}`,
+ token,
+ );
+ repos.push(...data.repositories.map(toStoredRepo));
+ if (data.repositories.length < 100) break;
+ }
+ if (page > GITHUB_LIST_MAX_PAGES) {
+ log.warn(
+ { installationId, cap: GITHUB_LIST_MAX_PAGES * 100 },
+ "installation repo list hit the pagination cap; results may be truncated",
+ );
+ }
+ return repos;
+}
+
+// Direct, O(1) repo lookup that doesn't depend on paginating the install's
+// full repo list. Used by the management-API grant flow: a 1000+ repo install
+// would otherwise silently 404 anything past page 10 of the list. Returns
+// null if the install's token can't see this repo (i.e. not covered).
+export async function fetchInstallationRepoById(
+ installationId: number,
+ repoId: number,
+): Promise {
+ const token = await createInstallationReadToken(installationId);
+ try {
+ const data = await githubRequest<{ id: number; full_name: string; private: boolean }>(
+ `/repositories/${repoId}`,
+ token,
+ );
+ return { id: data.id, fullName: data.full_name, private: data.private };
+ } catch (err) {
+ if (
+ err instanceof Error &&
+ (err.message.includes("failed: 404") || err.message.includes("failed: 403"))
+ ) {
+ return null;
+ }
+ throw err;
+ }
+}
+
+async function fetchRepoBranchInfo(
+ installationId: number,
+ repoFullName: string,
+): Promise {
+ const token = await createInstallationReadToken(installationId);
+ const repo = await githubRequest<{ default_branch?: string }>(`/repos/${repoFullName}`, token);
+ const branches: string[] = [];
+ let page = 1;
+ for (; page <= GITHUB_LIST_MAX_PAGES; page += 1) {
+ const data = await githubRequest<{ name: string }[]>(
+ `/repos/${repoFullName}/branches?per_page=100&page=${page}`,
+ token,
+ );
+ branches.push(...data.map((b) => b.name));
+ if (data.length < 100) break;
+ }
+ if (page > GITHUB_LIST_MAX_PAGES) {
+ log.warn(
+ { repo: repoFullName, cap: GITHUB_LIST_MAX_PAGES * 100 },
+ "repo branch list hit the pagination cap; results may be truncated",
+ );
+ }
+ return { defaultBranch: repo.default_branch ?? null, branches };
+}
+
+// Lists the branch set the agent could target for a project: the union of
+// branches across every enabled repo the project's GitHub installation(s) can
+// reach. `errored` is true when there were repos to inspect but every lookup
+// failed (token/network), so callers can disable the picker instead of
+// pretending the repo simply has no branches.
+export async function listProjectRepoBranches(
+ projectId: string,
+): Promise<{ branches: RepoBranch[]; errored: boolean }> {
+ const accessible = await listAccessibleGithubInstallsForProject(projectId);
+ const perRepo: RepoBranchInfo[] = [];
+ const seenRepos = new Set();
+ let sawError = false;
+ for (const { installation: row, allowedRepoIds } of accessible) {
+ if (!row.agentEnabled) continue;
+ let repos: StoredRepo[];
+ try {
+ repos = await listCurrentInstallationRepos(row.installationId);
+ } catch (err) {
+ sawError = true;
+ log.warn(
+ { err, projectId, installationId: row.installationId },
+ "github repository listing failed while loading branches",
+ );
+ continue;
+ }
+ const grantSet = allowedRepoIds === null ? null : new Set(allowedRepoIds);
+ const repoAccess = normalizeRepoAccess(row.repoAccess);
+ for (const repo of repos) {
+ if (grantSet && !grantSet.has(repo.id)) continue;
+ if (!isRepoEnabled(repoAccess, repo.id)) continue;
+ if (seenRepos.has(repo.fullName)) continue;
+ seenRepos.add(repo.fullName);
+ try {
+ perRepo.push(await fetchRepoBranchInfo(row.installationId, repo.fullName));
+ } catch (err) {
+ sawError = true;
+ log.warn({ err, projectId, repo: repo.fullName }, "github branch listing failed for repo");
+ }
+ }
+ }
+ // Distinguish "GitHub call failed" from "project genuinely has no branches":
+ // only flag errored when something broke AND we produced nothing, so a
+ // partial failure still returns the branches we did manage to load.
+ return { branches: mergeRepoBranches(perRepo), errored: sawError && perRepo.length === 0 };
+}
+
+// biome-ignore lint/suspicious/noExplicitAny: Hono Variables invariance.
+export function mountGithubAuthed(app: Hono): void {
+ const appSlug = process.env.GITHUB_APP_SLUG;
+ const oauthClientId =
+ process.env.GITHUB_OAUTH_CLIENT_ID ??
+ process.env.GITHUB_APP_CLIENT_ID ??
+ process.env.GITHUB_CLIENT_ID;
+ const oauthClientSecret =
+ process.env.GITHUB_OAUTH_CLIENT_SECRET ??
+ process.env.GITHUB_APP_CLIENT_SECRET ??
+ process.env.GITHUB_CLIENT_SECRET;
+ const stateSecret = process.env.STATE_SIGNING_SECRET;
+ const authorRedirectUrl =
+ process.env.GITHUB_AUTHOR_OAUTH_REDIRECT_URL ??
+ `${process.env.BETTER_AUTH_URL ?? "http://localhost:4100"}/github/author/callback`;
+
+ async function resolveActiveContext(
+ userId: string | undefined,
+ preferredOrgId?: string | null,
+ ): Promise<{ orgId: string; projectId: string } | null> {
+ if (!userId) return null;
+ const ctx = await resolveActiveOrgContext({
+ userId,
+ preferredOrgId: preferredOrgId ?? null,
+ }).catch(() => null);
+ if (!ctx) return null;
+ return { orgId: ctx.org.id, projectId: ctx.project.id };
+ }
+
+ app.get("/api/github/installation", async (c) => {
+ const active = await resolveActiveContext(
+ c.var.userId as string | undefined,
+ c.var.orgId as string | null | undefined,
+ );
+ if (!active) return c.json({ installed: false });
+
+ const accessible = await listAccessibleGithubInstallsForProject(active.projectId);
+ if (accessible.length === 0) return c.json({ installed: false });
+
+ const installations: GithubInstallationSummary[] = [];
+ let repoVerificationUnavailable = false;
+ for (const { installation: row, allowedRepoIds } of accessible) {
+ let repos = Array.isArray(row.repos) ? (row.repos as StoredRepo[]) : [];
+ try {
+ repos = await listCurrentInstallationRepos(row.installationId);
+ } catch (err) {
+ log.warn(
+ { err, projectId: active.projectId, installationId: row.installationId },
+ "github repository listing failed",
+ );
+ if (
+ err instanceof Error &&
+ err.message.includes("GITHUB_APP_ID and GITHUB_APP_PRIVATE_KEY")
+ ) {
+ repoVerificationUnavailable = true;
+ }
+ if (isMissingGithubInstallation(err)) {
+ await db
+ .update(schema.githubInstallations)
+ .set({ revokedAt: new Date() })
+ .where(eq(schema.githubInstallations.id, row.id));
+ continue;
+ }
+ }
+ const repoAccess = normalizeRepoAccess(row.repoAccess);
+ // Granted org-scoped installs are restricted to the project's allowed
+ // repo set; project-owned installs see everything in the install.
+ const grantSet = allowedRepoIds === null ? null : new Set(allowedRepoIds);
+ const scopedRepos = grantSet === null ? repos : repos.filter((repo) => grantSet.has(repo.id));
+ installations.push({
+ installationId: row.installationId,
+ accountLogin: row.accountLogin,
+ accountType: row.accountType,
+ enabled: row.agentEnabled,
+ manageUrl: githubInstallationManageUrl(row),
+ repos: scopedRepos.map((repo) => ({
+ ...repo,
+ enabled: isRepoEnabled(repoAccess, repo.id),
+ })),
+ });
+ }
+ if (installations.length === 0) return c.json({ installed: false });
+
+ const authorRow = accessible
+ .map(({ installation }) => installation)
+ .find((row) => row.commitAuthorName && row.commitAuthorEmail);
+ const repos = dedupeRepoSummaries(
+ installations.flatMap((installation) =>
+ installation.repos.map((repo) => ({
+ ...repo,
+ enabled: installation.enabled && repo.enabled,
+ })),
+ ),
+ );
+ const firstInstallation = installations[0];
+ if (!firstInstallation) return c.json({ installed: false });
+ return c.json({
+ installed: true,
+ installationId: firstInstallation.installationId,
+ accountLogin: firstInstallation.accountLogin,
+ manageUrl: firstInstallation.manageUrl,
+ repoVerificationUnavailable,
+ installations,
+ repos,
+ commitAuthor:
+ authorRow?.commitAuthorName && authorRow.commitAuthorEmail
+ ? {
+ source: "github_user",
+ name: authorRow.commitAuthorName,
+ email: authorRow.commitAuthorEmail,
+ githubLogin: authorRow.commitAuthorGithubLogin,
+ githubId: authorRow.commitAuthorGithubId,
+ avatarUrl: authorRow.commitAuthorAvatarUrl,
+ setAt: authorRow.commitAuthorSetAt?.toISOString() ?? null,
+ }
+ : {
+ source: "app",
+ name: DEFAULT_COMMIT_AUTHOR.name,
+ email: DEFAULT_COMMIT_AUTHOR.email,
+ githubLogin: null,
+ githubId: null,
+ avatarUrl: null,
+ setAt: null,
+ },
+ });
+ });
+
+ app.post("/api/github/skip", async (c) => {
+ const ctx = await resolveUserOrg(c);
+ if (!ctx) return c.json({ error: "no org for user" }, 404);
+ await db
+ .update(schema.orgs)
+ .set({ githubSetupSkippedAt: new Date() })
+ .where(eq(schema.orgs.id, ctx.orgId));
+ return c.json({ ok: true, orgId: ctx.orgId });
+ });
+
+ // Lands here after Better Auth completes GitHub OAuth. If the user's org
+ // already has a GitHub App install we redirect back to the web app; if not,
+ // we kick off the App install flow inline so signing in with GitHub
+ // doubles as installing the App. The callbackURL passed from the web
+ // client points at this endpoint; the cookie session is set by Better Auth
+ // before the redirect lands here so `c.var.userId` is populated by the
+ // /api/* middleware.
+ app.get("/api/github/post-signin", async (c) => {
+ const webOrigin = process.env.WEB_ORIGIN ?? "http://localhost:5173";
+ const active = await resolveActiveContext(
+ c.var.userId as string | undefined,
+ c.var.orgId as string | null | undefined,
+ );
+ if (!active) return c.redirect(`${webOrigin}/`, 302);
+
+ const installs = await listAccessibleGithubInstallsForProject(active.projectId);
+ if (installs.length > 0) return c.redirect(`${webOrigin}/`, 302);
+
+ if (!appSlug || !stateSecret) {
+ // GitHub app not configured in this env — just send the user home so
+ // they aren't stuck on a blank page.
+ return c.redirect(`${webOrigin}/`, 302);
+ }
+ const state = signState("web", active.projectId, stateSecret);
+ const url = new URL(`https://github.com/apps/${appSlug}/installations/new`);
+ url.searchParams.set("state", state);
+ return c.redirect(url.toString(), 302);
+ });
+
+ app.post("/api/github/install-url", async (c) => {
+ if (!appSlug || !stateSecret) {
+ return c.json({ error: "github app not configured" }, 503);
+ }
+ const active = await resolveActiveContext(
+ c.var.userId as string | undefined,
+ c.var.orgId as string | null | undefined,
+ );
+ if (!active) return c.json({ error: "no org for user" }, 404);
+
+ // "web" state value is now the projectId; the callback resolves the org
+ // from the project row.
+ const state = signState("web", active.projectId, stateSecret);
+ const url = new URL(`https://github.com/apps/${appSlug}/installations/new`);
+ url.searchParams.set("state", state);
+ log.info(
+ { org_id: active.orgId, project_id: active.projectId, app_slug: appSlug },
+ "github install url created",
+ );
+ return c.json({ url: url.toString() });
+ });
+
+ app.post("/api/github/repo-access", async (c) => {
+ const ctx = await resolveUserOrg(c);
+ if (!ctx) return c.json({ error: "no org for user" }, 404);
+
+ const body = (await c.req.json().catch(() => null)) as {
+ installationId?: unknown;
+ enabled?: unknown;
+ repoId?: unknown;
+ repoEnabled?: unknown;
+ } | null;
+ const installationId = Number(body?.installationId);
+ if (!Number.isFinite(installationId) || installationId <= 0) {
+ return c.json({ error: "invalid installation id" }, 400);
+ }
+
+ const row = await db.query.githubInstallations.findFirst({
+ where: and(
+ eq(schema.githubInstallations.projectId, ctx.projectId),
+ eq(schema.githubInstallations.installationId, installationId),
+ isNull(schema.githubInstallations.revokedAt),
+ ),
+ });
+ if (!row) return c.json({ error: "github installation not found" }, 404);
+
+ const patch: {
+ agentEnabled?: boolean;
+ repoAccess?: RepoAccess;
+ } = {};
+ if (typeof body?.enabled === "boolean") {
+ patch.agentEnabled = body.enabled;
+ }
+ if (body?.repoId !== undefined || body?.repoEnabled !== undefined) {
+ const repoId = Number(body?.repoId);
+ if (!Number.isFinite(repoId) || repoId <= 0 || typeof body?.repoEnabled !== "boolean") {
+ return c.json({ error: "invalid repo access update" }, 400);
+ }
+ patch.repoAccess = setRepoEnabled(row.repoAccess, repoId, body.repoEnabled);
+ }
+ if (Object.keys(patch).length === 0) {
+ return c.json({ error: "no repo access change provided" }, 400);
+ }
+
+ await db
+ .update(schema.githubInstallations)
+ .set(patch)
+ .where(eq(schema.githubInstallations.id, row.id));
+ return c.json({ ok: true });
+ });
+
+ app.post("/api/github/author-login-url", async (c) => {
+ const ctx = await resolveUserOrg(c);
+ if (!ctx) return c.json({ error: "no org for user" }, 404);
+
+ if (!oauthClientId || !oauthClientSecret || !stateSecret) {
+ if (!appSlug || !stateSecret) {
+ return c.json({ error: "github oauth not configured" }, 503);
+ }
+ log.warn(
+ { org_id: ctx.orgId, project_id: ctx.projectId },
+ "github oauth not configured; falling back to app installation flow",
+ );
+ const state = signState("web", ctx.projectId, stateSecret);
+ const url = new URL(`https://github.com/apps/${appSlug}/installations/new`);
+ url.searchParams.set("state", state);
+ return c.json({ url: url.toString() });
+ }
+
+ const state = signAuthorState(
+ { orgId: ctx.orgId, projectId: ctx.projectId, userId: ctx.userId, purpose: "author" },
+ stateSecret,
+ );
+ const url = new URL("https://github.com/login/oauth/authorize");
+ url.searchParams.set("client_id", oauthClientId);
+ url.searchParams.set("redirect_uri", authorRedirectUrl);
+ url.searchParams.set("scope", "read:user user:email");
+ url.searchParams.set("state", state);
+ return c.json({ url: url.toString() });
+ });
+
+ app.post("/api/github/access-login-url", async (c) => {
+ const ctx = await resolveUserOrg(c);
+ if (!ctx) return c.json({ error: "no org for user" }, 404);
+
+ if (!oauthClientId || !oauthClientSecret || !stateSecret) {
+ if (!appSlug || !stateSecret) {
+ return c.json({ error: "github oauth not configured" }, 503);
+ }
+ log.warn(
+ { org_id: ctx.orgId, project_id: ctx.projectId },
+ "github oauth not configured; falling back to app installation flow",
+ );
+ const state = signState("web", ctx.projectId, stateSecret);
+ const url = new URL(`https://github.com/apps/${appSlug}/installations/new`);
+ url.searchParams.set("state", state);
+ return c.json({ url: url.toString() });
+ }
+
+ const state = signAuthorState(
+ { orgId: ctx.orgId, projectId: ctx.projectId, userId: ctx.userId, purpose: "access" },
+ stateSecret,
+ );
+ const url = new URL("https://github.com/login/oauth/authorize");
+ url.searchParams.set("client_id", oauthClientId);
+ url.searchParams.set("redirect_uri", authorRedirectUrl);
+ url.searchParams.set("scope", "read:user user:email");
+ url.searchParams.set("state", state);
+ return c.json({ url: url.toString() });
+ });
+
+ app.post("/api/github/commit-author/reset", async (c) => {
+ const ctx = await resolveUserOrg(c);
+ if (!ctx) return c.json({ error: "no org for user" }, 404);
+
+ await db
+ .update(schema.githubInstallations)
+ .set({
+ commitAuthorName: null,
+ commitAuthorEmail: null,
+ commitAuthorGithubLogin: null,
+ commitAuthorGithubId: null,
+ commitAuthorAvatarUrl: null,
+ commitAuthorSetByUserId: null,
+ commitAuthorSetAt: null,
+ })
+ .where(
+ and(
+ eq(schema.githubInstallations.projectId, ctx.projectId),
+ isNull(schema.githubInstallations.revokedAt),
+ ),
+ );
+ return c.json({ ok: true });
+ });
+}
+
+// biome-ignore lint/suspicious/noExplicitAny: Hono Variables invariance.
+export function mountGithubAuthorOAuth(app: Hono): void {
+ const clientId =
+ process.env.GITHUB_OAUTH_CLIENT_ID ??
+ process.env.GITHUB_APP_CLIENT_ID ??
+ process.env.GITHUB_CLIENT_ID;
+ const clientSecret =
+ process.env.GITHUB_OAUTH_CLIENT_SECRET ??
+ process.env.GITHUB_APP_CLIENT_SECRET ??
+ process.env.GITHUB_CLIENT_SECRET;
+ const redirectUrl =
+ process.env.GITHUB_AUTHOR_OAUTH_REDIRECT_URL ??
+ `${process.env.BETTER_AUTH_URL ?? "http://localhost:4100"}/github/author/callback`;
+ const stateSecret = process.env.STATE_SIGNING_SECRET;
+ const webOrigin = process.env.WEB_ORIGIN ?? "http://localhost:5173";
+
+ if (!clientId || !clientSecret) {
+ log.warn("GITHUB_OAUTH_CLIENT_ID/SECRET not set — /github/author/callback disabled");
+ }
+
+ app.get("/github/author/callback", async (c) => {
+ if (!clientId || !clientSecret || !stateSecret) {
+ return c.json({ error: "github oauth not configured" }, 503);
+ }
+ const err = c.req.query("error");
+ if (err) return c.redirect(`${webOrigin}/settings?github_author=denied`, 302);
+
+ const code = c.req.query("code");
+ const state = c.req.query("state") ?? "";
+ if (!code) return c.redirect(`${webOrigin}/settings?github_author=error`, 302);
+
+ const decoded = verifyAuthorState(state, stateSecret);
+ if (!decoded) return c.json({ error: "invalid state" }, 400);
+
+ let token: string;
+ try {
+ token = await exchangeGithubOAuthCode({ clientId, clientSecret, code, redirectUrl });
+ } catch (e) {
+ log.error({ err: e, org_id: decoded.orgId }, "github author oauth exchange failed");
+ return c.redirect(`${webOrigin}/settings?github_author=error`, 302);
+ }
+
+ let author: CommitAuthor | null = null;
+ let installations: GithubUserInstallation[];
+ let reposByInstallation: Map;
+ try {
+ [author, installations] =
+ decoded.purpose === "author"
+ ? await Promise.all([fetchCommitAuthor(token), listUserAccessibleInstallations(token)])
+ : [null, await listUserAccessibleInstallations(token)];
+ reposByInstallation = new Map(
+ await Promise.all(
+ installations.map(
+ async (installation) =>
+ [installation.id, await listUserAccessibleRepos(token, installation.id)] as const,
+ ),
+ ),
+ );
+ } catch (e) {
+ log.error({ err: e, org_id: decoded.orgId }, "github author fetch failed");
+ return c.redirect(`${webOrigin}/settings?github_author=error`, 302);
+ }
+
+ if (installations.length === 0) {
+ log.info(
+ { org_id: decoded.orgId, user_id: decoded.userId, github_login: author?.githubLogin },
+ "github oauth connected but no accessible app installation found",
+ );
+ const param = decoded.purpose === "author" ? "github_author" : "github";
+ return c.redirect(`${webOrigin}/settings?${param}=no_install`, 302);
+ }
+
+ const upserted = await db.transaction(async (tx) => {
+ const rows: Array<{ id: string }> = [];
+ for (const installation of installations) {
+ const repos = reposByInstallation.get(installation.id) ?? [];
+ const inserted = await tx
+ .insert(schema.githubInstallations)
+ .values({
+ orgId: decoded.orgId,
+ projectId: decoded.projectId,
+ installationId: installation.id,
+ accountLogin: installation.account?.login ?? null,
+ accountType: installation.account?.type ?? null,
+ repos,
+ ...(author
+ ? {
+ commitAuthorName: author.name,
+ commitAuthorEmail: author.email,
+ commitAuthorGithubLogin: author.githubLogin,
+ commitAuthorGithubId: author.githubId,
+ commitAuthorAvatarUrl: author.avatarUrl,
+ commitAuthorSetByUserId: decoded.userId,
+ commitAuthorSetAt: new Date(),
+ }
+ : {}),
+ revokedAt: null,
+ })
+ .onConflictDoUpdate({
+ target: [
+ schema.githubInstallations.projectId,
+ schema.githubInstallations.installationId,
+ ],
+ targetWhere: sql`project_id IS NOT NULL AND revoked_at IS NULL`,
+ set: {
+ orgId: decoded.orgId,
+ accountLogin: installation.account?.login ?? null,
+ accountType: installation.account?.type ?? null,
+ repos,
+ ...(author
+ ? {
+ commitAuthorName: author.name,
+ commitAuthorEmail: author.email,
+ commitAuthorGithubLogin: author.githubLogin,
+ commitAuthorGithubId: author.githubId,
+ commitAuthorAvatarUrl: author.avatarUrl,
+ commitAuthorSetByUserId: decoded.userId,
+ commitAuthorSetAt: new Date(),
+ }
+ : {}),
+ revokedAt: null,
+ },
+ })
+ .returning({ id: schema.githubInstallations.id });
+ rows.push(...inserted);
+ }
+ if (author) {
+ await tx
+ .update(schema.githubInstallations)
+ .set({
+ commitAuthorName: author.name,
+ commitAuthorEmail: author.email,
+ commitAuthorGithubLogin: author.githubLogin,
+ commitAuthorGithubId: author.githubId,
+ commitAuthorAvatarUrl: author.avatarUrl,
+ commitAuthorSetByUserId: decoded.userId,
+ commitAuthorSetAt: new Date(),
+ })
+ .where(
+ and(
+ eq(schema.githubInstallations.projectId, decoded.projectId),
+ isNull(schema.githubInstallations.revokedAt),
+ ),
+ );
+ }
+ return rows;
+ });
+
+ log.info(
+ {
+ org_id: decoded.orgId,
+ user_id: decoded.userId,
+ github_login: author?.githubLogin,
+ purpose: decoded.purpose,
+ installation_ids: installations.map((installation) => installation.id),
+ installation_row_ids: upserted.map((row) => row.id),
+ },
+ "github installations reconciled from user oauth",
+ );
+ void syncLoopsContactsForOrg({ orgId: decoded.orgId, appUrl: webOrigin }).catch((err) => {
+ log.warn({ err, org_id: decoded.orgId }, "loops contact sync failed after github connect");
+ });
+ if (decoded.purpose === "author") {
+ return c.redirect(`${webOrigin}/settings?github_author=connected`, 302);
+ }
+ // access flow: send back to root so the gate can pick up and show onboarding
+ return c.redirect(`${webOrigin}/`, 302);
+ });
+}
+
+async function exchangeGithubOAuthCode(opts: {
+ clientId: string;
+ clientSecret: string;
+ code: string;
+ redirectUrl: string;
+}): Promise {
+ const res = await fetch("https://github.com/login/oauth/access_token", {
+ method: "POST",
+ headers: {
+ accept: "application/json",
+ "content-type": "application/json",
+ "user-agent": "superlog-api",
+ },
+ body: JSON.stringify({
+ client_id: opts.clientId,
+ client_secret: opts.clientSecret,
+ code: opts.code,
+ redirect_uri: opts.redirectUrl,
+ }),
+ });
+ const data = (await res.json()) as GithubOAuthTokenResponse;
+ if (!res.ok || !data.access_token) {
+ throw new Error(data.error_description ?? data.error ?? `github oauth failed: ${res.status}`);
+ }
+ return data.access_token;
+}
+
+async function githubUserRequest(pathname: string, token: string): Promise {
+ const res = await fetch(`https://api.github.com${pathname}`, {
+ headers: {
+ accept: "application/vnd.github+json",
+ authorization: `Bearer ${token}`,
+ "x-github-api-version": "2022-11-28",
+ "user-agent": "superlog-api",
+ },
+ });
+ if (!res.ok) {
+ const text = await res.text().catch(() => "");
+ throw new Error(`github GET ${pathname} failed: ${res.status} ${text}`);
+ }
+ return (await res.json()) as T;
+}
+
+async function fetchCommitAuthor(token: string): Promise {
+ const viewer = await githubUserRequest("/user", token);
+ let emails: GithubEmailResponse[] = [];
+ try {
+ emails = await githubUserRequest("/user/emails", token);
+ } catch (err) {
+ log.warn(
+ { err, github_login: viewer.login },
+ "github user emails unavailable; falling back to profile or noreply email",
+ );
+ }
+ const selectedEmail =
+ emails.find((email) => email.primary && email.verified)?.email ??
+ emails.find((email) => email.verified)?.email ??
+ viewer.email ??
+ `${viewer.id}+${viewer.login}@users.noreply.github.com`;
+ const name = sanitizeGitName(viewer.name ?? viewer.login);
+ const email = sanitizeGitEmail(selectedEmail);
+ if (!name || !email) throw new Error("github profile did not provide a usable git identity");
+ return {
+ name,
+ email,
+ githubLogin: viewer.login,
+ githubId: viewer.id,
+ avatarUrl: viewer.avatar_url,
+ };
+}
+
+async function listUserAccessibleInstallations(token: string): Promise {
+ const installs: GithubUserInstallation[] = [];
+ for (let page = 1; page <= 10; page += 1) {
+ const data = await githubUserRequest(
+ `/user/installations?per_page=100&page=${page}`,
+ token,
+ );
+ installs.push(...data.installations);
+ if (data.installations.length < 100) break;
+ }
+
+ return installs.filter((installation) => !installation.suspended_at);
+}
+
+function githubInstallationManageUrl(installation: {
+ installationId: number;
+ accountLogin: string | null;
+ accountType: string | null;
+}): string {
+ if (installation.accountType === "Organization" && installation.accountLogin) {
+ return `https://github.com/organizations/${installation.accountLogin}/settings/installations/${installation.installationId}`;
+ }
+ return `https://github.com/settings/installations/${installation.installationId}`;
+}
+
+function isMissingGithubInstallation(err: unknown): boolean {
+ return err instanceof Error && err.message.includes("failed: 404");
+}
+
+function normalizeRepoAccess(value: unknown): RepoAccess {
+ if (!value || typeof value !== "object") return {};
+ const disabledRepoIds = (value as RepoAccess).disabledRepoIds;
+ if (!Array.isArray(disabledRepoIds)) return {};
+ return {
+ disabledRepoIds: [
+ ...new Set(disabledRepoIds.filter((id): id is number => Number.isFinite(id) && id > 0)),
+ ],
+ };
+}
+
+function isRepoEnabled(repoAccess: RepoAccess, repoId: number): boolean {
+ return !(repoAccess.disabledRepoIds ?? []).includes(repoId);
+}
+
+function setRepoEnabled(value: unknown, repoId: number, enabled: boolean): RepoAccess {
+ const repoAccess = normalizeRepoAccess(value);
+ const disabled = new Set(repoAccess.disabledRepoIds ?? []);
+ if (enabled) {
+ disabled.delete(repoId);
+ } else {
+ disabled.add(repoId);
+ }
+ return { disabledRepoIds: [...disabled].sort((a, b) => a - b) };
+}
+
+function dedupeRepoSummaries(repos: GithubRepoSummary[]): GithubRepoSummary[] {
+ const seen = new Set();
+ const deduped: GithubRepoSummary[] = [];
+ for (const repo of repos) {
+ const existing = deduped.find((item) => item.fullName === repo.fullName);
+ if (existing) {
+ existing.enabled = existing.enabled || repo.enabled;
+ continue;
+ }
+ if (!seen.has(repo.fullName)) {
+ seen.add(repo.fullName);
+ deduped.push({ ...repo });
+ }
+ }
+ return deduped;
+}
+
+async function listUserAccessibleRepos(
+ token: string,
+ installationId: number,
+): Promise {
+ const repos: StoredRepo[] = [];
+ for (let page = 1; page <= 10; page += 1) {
+ const data = await githubUserRequest(
+ `/user/installations/${installationId}/repositories?per_page=100&page=${page}`,
+ token,
+ );
+ repos.push(...data.repositories.map(toStoredRepo));
+ if (data.repositories.length < 100) break;
+ }
+ return repos;
+}
+
+function sanitizeGitName(value: string): string | null {
+ const cleaned = value
+ .replace(/[\r\n<>]/g, " ")
+ .replace(/\s+/g, " ")
+ .trim();
+ return cleaned || null;
+}
+
+function sanitizeGitEmail(value: string): string | null {
+ const cleaned = value.trim();
+ if (!cleaned || /[\r\n<>]/.test(cleaned) || !cleaned.includes("@")) return null;
+ return cleaned;
+}
+
+async function resolveUserOrg(
+ c: Context<{ Variables: Vars }>,
+): Promise<{ userId: string; orgId: string; projectId: string } | null> {
+ const userId = c.var.userId as string | undefined;
+ if (!userId) return null;
+ const ctx = await resolveActiveOrgContext({
+ userId,
+ preferredOrgId: c.var.orgId as string | null | undefined,
+ }).catch(() => null);
+ if (!ctx) return null;
+ return { userId: ctx.user.id, orgId: ctx.org.id, projectId: ctx.project.id };
+}
+
+type AuthorStatePayload = {
+ orgId: string;
+ projectId: string;
+ userId: string;
+ purpose: "access" | "author";
+};
+
+function signAuthorState(p: AuthorStatePayload, secret: string): string {
+ const body = `${p.orgId}.${p.projectId}.${p.userId}.${p.purpose}.${Date.now()}`;
+ const sig = crypto.createHmac("sha256", secret).update(body).digest("base64url");
+ return `${Buffer.from(body, "utf8").toString("base64url")}.${sig}`;
+}
+
+function verifyAuthorState(state: string, secret: string): AuthorStatePayload | null {
+ const [payloadB64, sig] = state.split(".");
+ if (!payloadB64 || !sig) return null;
+ const body = Buffer.from(payloadB64, "base64url").toString("utf8");
+ const expected = crypto.createHmac("sha256", secret).update(body).digest("base64url");
+ const provided = Buffer.from(sig);
+ const expectedBuf = Buffer.from(expected);
+ if (provided.length !== expectedBuf.length) return null;
+ if (!crypto.timingSafeEqual(provided, expectedBuf)) return null;
+ // Current format is `${orgId}.${projectId}.${userId}.${purpose}.${ts}` (5
+ // parts). Old 3- or 4-part states (no projectId) are rejected — 10-min TTL
+ // means worst case is users mid-flow during deploy clicking again.
+ const parts = body.split(".");
+ if (parts.length !== 5) return null;
+ const [orgId, projectId, userId, purpose, tsRaw] = parts as [
+ string,
+ string,
+ string,
+ string,
+ string,
+ ];
+ if (!orgId || !projectId || !userId || !tsRaw) return null;
+ if (purpose !== "access" && purpose !== "author") return null;
+ const ts = Number(tsRaw);
+ if (!Number.isFinite(ts) || Date.now() - ts > 10 * 60 * 1000) return null;
+ return { orgId, projectId, userId, purpose };
+}
+
+type StateKind = "cli" | "web";
+
+function signState(kind: StateKind, value: string, secret: string): string {
+ const payload = `${kind}.${value}.${Date.now()}`;
+ const sig = crypto.createHmac("sha256", secret).update(payload).digest("base64url");
+ return `${base64url(payload)}.${sig}`;
+}
+
+function verifyState(state: string, secret: string): { kind: StateKind; value: string } | null {
+ const [payloadB64, sig] = state.split(".");
+ if (!payloadB64 || !sig) return null;
+ const payload = Buffer.from(payloadB64, "base64url").toString("utf8");
+ const expected = crypto.createHmac("sha256", secret).update(payload).digest("base64url");
+ if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) return null;
+ const [kind, value, tsRaw] = payload.split(".");
+ if ((kind !== "cli" && kind !== "web") || !value || !tsRaw) return null;
+ const ts = Number(tsRaw);
+ // Match device code TTL: 10 min window.
+ if (!Number.isFinite(ts) || Date.now() - ts > 10 * 60 * 1000) return null;
+ return { kind, value };
+}
+
+// Management-API install state. Used when a platform backend mints an install
+// URL via /api/v1/...; the state encodes scope (org vs project) and an
+// optional return URL the user is bounced to after install. 30-min TTL since
+// the customer's operator might not click immediately.
+const MGMT_STATE_PREFIX = "mgmt-v1";
+export type GithubMgmtState =
+ | { scope: "org"; orgId: string; returnUrl: string | null }
+ | { scope: "project"; projectId: string; returnUrl: string | null };
+
+function signMgmtState(payload: GithubMgmtState, secret: string): string {
+ const body = `${MGMT_STATE_PREFIX}.${Buffer.from(JSON.stringify(payload), "utf8").toString("base64url")}.${Date.now()}`;
+ const sig = crypto.createHmac("sha256", secret).update(body).digest("base64url");
+ return `${Buffer.from(body, "utf8").toString("base64url")}.${sig}`;
+}
+
+function verifyMgmtState(state: string, secret: string): GithubMgmtState | null {
+ const [payloadB64, sig] = state.split(".");
+ if (!payloadB64 || !sig) return null;
+ const body = Buffer.from(payloadB64, "base64url").toString("utf8");
+ const expected = crypto.createHmac("sha256", secret).update(body).digest("base64url");
+ const provided = Buffer.from(sig);
+ const expectedBuf = Buffer.from(expected);
+ if (provided.length !== expectedBuf.length) return null;
+ if (!crypto.timingSafeEqual(provided, expectedBuf)) return null;
+ const parts = body.split(".");
+ if (parts.length !== 3 || parts[0] !== MGMT_STATE_PREFIX) return null;
+ const ts = Number(parts[2]);
+ if (!Number.isFinite(ts) || Date.now() - ts > 30 * 60 * 1000) return null;
+ try {
+ const json = Buffer.from(parts[1] as string, "base64url").toString("utf8");
+ const parsed = JSON.parse(json) as Partial;
+ if (parsed.scope === "org" && typeof parsed.orgId === "string") {
+ return { scope: "org", orgId: parsed.orgId, returnUrl: parsed.returnUrl ?? null };
+ }
+ if (parsed.scope === "project" && typeof parsed.projectId === "string") {
+ return { scope: "project", projectId: parsed.projectId, returnUrl: parsed.returnUrl ?? null };
+ }
+ return null;
+ } catch {
+ return null;
+ }
+}
+
+// Public helper used by the management API to mint install URLs without
+// touching state internals.
+export function buildGithubMgmtInstallUrl(args: GithubMgmtState): { url: string } | null {
+ const appSlug = process.env.GITHUB_APP_SLUG;
+ const stateSecret = process.env.STATE_SIGNING_SECRET;
+ if (!appSlug || !stateSecret) return null;
+ const state = signMgmtState(args, stateSecret);
+ const url = new URL(`https://github.com/apps/${appSlug}/installations/new`);
+ url.searchParams.set("state", state);
+ return { url: url.toString() };
+}
+
+function base64url(s: string): string {
+ return Buffer.from(s, "utf8").toString("base64url");
+}
diff --git a/superlog/apps/api/src/http-observability.test.ts b/superlog/apps/api/src/http-observability.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..b91d4e6ef21423e52de43c006060a2662779f5ef
--- /dev/null
+++ b/superlog/apps/api/src/http-observability.test.ts
@@ -0,0 +1,55 @@
+import { strict as assert } from "node:assert";
+import { test } from "node:test";
+import { buildApiSpanAttributes, normalizeHttpRoute } from "./http-observability.js";
+
+test("normalizeHttpRoute collapses dynamic ids while keeping useful endpoint names", () => {
+ assert.equal(
+ normalizeHttpRoute("/api/projects/018f34d2-9f91-76cc-9e13-34f3287ff03d/explore/traces"),
+ "/api/projects/:id/explore/traces",
+ );
+ assert.equal(
+ normalizeHttpRoute("/api/incidents/inc_01jwj3c9j7q93w2p50ab4t72w3/events"),
+ "/api/incidents/:id/events",
+ );
+ assert.equal(normalizeHttpRoute("/api/me?tab=settings"), "/api/me");
+});
+
+test("buildApiSpanAttributes adds tenant and normalized endpoint attributes for server spans", () => {
+ assert.deepEqual(
+ buildApiSpanAttributes({
+ method: "post",
+ path: "/api/projects/project_123/explore/logs?limit=50",
+ routePath: "/api/*",
+ statusCode: 201,
+ orgId: "org_123",
+ userId: "user_123",
+ }),
+ {
+ "http.request.method": "POST",
+ "http.route": "/api/projects/:id/explore/logs",
+ "superlog.endpoint": "/api/projects/:id/explore/logs",
+ "http.response.status_code": 201,
+ "http.response.status_class": "2xx",
+ "tenant.org.id": "org_123",
+ "enduser.id": "user_123",
+ "tenant.project.id": "project_123",
+ },
+ );
+});
+
+test("buildApiSpanAttributes extracts project ids from all project-scoped API route families", () => {
+ for (const path of [
+ "/api/projects/project_123/explore/logs",
+ "/api/org/projects/project_123/keys",
+ "/api/v1/projects/project_123/logs",
+ ]) {
+ assert.equal(
+ buildApiSpanAttributes({
+ method: "get",
+ path,
+ statusCode: 200,
+ })["tenant.project.id"],
+ "project_123",
+ );
+ }
+});
diff --git a/superlog/apps/api/src/http-observability.ts b/superlog/apps/api/src/http-observability.ts
new file mode 100644
index 0000000000000000000000000000000000000000..dec30be86b373e18a7f2d006a00e0b914bbb0fcb
--- /dev/null
+++ b/superlog/apps/api/src/http-observability.ts
@@ -0,0 +1,100 @@
+import { trace } from "@opentelemetry/api";
+import type { Context, Next } from "hono";
+import { HTTPException } from "hono/http-exception";
+
+type MetricAttributes = Record;
+
+export type ApiSpanAttributeInput = {
+ method: string;
+ path: string;
+ routePath?: string;
+ statusCode: number;
+ orgId?: string | null;
+ userId?: string | null;
+};
+
+function statusClass(statusCode: number): string {
+ return `${Math.floor(statusCode / 100)}xx`;
+}
+
+function isDynamicSegment(segment: string): boolean {
+ if (/^\d+$/.test(segment)) return true;
+ if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(segment)) {
+ return true;
+ }
+ if (/^(project|org|user|inc|sui|key)_[a-z0-9]+$/i.test(segment)) return true;
+ if (/^[a-z][a-z0-9]*_[a-z0-9]{8,}$/i.test(segment)) return true;
+ return segment.length >= 20 && /[0-9]/.test(segment) && /^[a-z0-9_-]+$/i.test(segment);
+}
+
+export function normalizeHttpRoute(rawPath: string): string {
+ const path = rawPath.split("?")[0] || "/";
+ return path
+ .split("/")
+ .map((segment) => {
+ if (!segment) return segment;
+ if (segment.startsWith(":")) return segment;
+ return isDynamicSegment(segment) ? ":id" : segment;
+ })
+ .join("/");
+}
+
+function routeName(input: Pick): string {
+ return input.routePath && input.routePath !== "*" && input.routePath !== "/api/*"
+ ? input.routePath
+ : normalizeHttpRoute(input.path);
+}
+
+function extractProjectId(path: string): string | null {
+ const match = path.match(/^\/api\/(?:org\/)?(?:v1\/)?projects\/([^/?#]+)/);
+ return match?.[1] ?? null;
+}
+
+export function buildApiSpanAttributes(input: ApiSpanAttributeInput): MetricAttributes {
+ const endpoint = routeName(input);
+ const attrs: MetricAttributes = {
+ "http.request.method": input.method.toUpperCase(),
+ "http.route": endpoint,
+ "superlog.endpoint": endpoint,
+ "http.response.status_code": input.statusCode,
+ "http.response.status_class": statusClass(input.statusCode),
+ };
+ if (input.orgId) attrs["tenant.org.id"] = input.orgId;
+ if (input.userId) attrs["enduser.id"] = input.userId;
+ const projectId = extractProjectId(input.path);
+ if (projectId) attrs["tenant.project.id"] = projectId;
+ return attrs;
+}
+
+type ApiObservabilityVars = {
+ userId?: string;
+ orgId?: string | null;
+};
+
+export function createApiHttpObservabilityMiddleware(): (
+ c: Context<{ Variables: ApiObservabilityVars }>,
+ next: Next,
+) => Promise {
+ return async (c, next) => {
+ let statusCode = 500;
+
+ try {
+ await next();
+ statusCode = c.res.status;
+ } catch (err) {
+ statusCode = err instanceof HTTPException ? err.status : 500;
+ throw err;
+ } finally {
+ trace.getActiveSpan()?.setAttributes(
+ buildApiSpanAttributes({
+ method: c.req.method,
+ path: c.req.path,
+ routePath: c.req.routePath,
+ statusCode,
+ orgId: c.var.orgId,
+ userId: c.var.userId,
+ }),
+ );
+ }
+ };
+}
diff --git a/superlog/apps/api/src/impersonation.ts b/superlog/apps/api/src/impersonation.ts
new file mode 100644
index 0000000000000000000000000000000000000000..12fcf083b80960e7c8da802b491b904a85b8ec70
--- /dev/null
+++ b/superlog/apps/api/src/impersonation.ts
@@ -0,0 +1,68 @@
+import { db, schema } from "@superlog/db";
+import { asc, eq, ilike, or } from "drizzle-orm";
+import type { Hono } from "hono";
+import { requireStaff } from "./staff.js";
+
+type Vars = { userId: string; orgId: string | null };
+const MAX_TARGET_ROWS = 100;
+
+function normalizeSearch(raw: string | null): string {
+ return (raw ?? "").trim().replace(/[%_]/g, "").slice(0, 80);
+}
+
+// Minimal staff-only user picker for the impersonation command-palette flow.
+// The actual impersonation is performed by Better Auth's admin plugin
+// (authClient.admin.impersonateUser); this endpoint only supplies the list of
+// candidate users + the orgs they belong to so the palette can search them.
+// It deliberately exposes nothing beyond what a user picker needs — no usage
+// metrics or per-org analytics.
+export function mountImpersonation(app: Hono<{ Variables: Vars }>): void {
+ app.get("/api/admin/impersonation-targets", async (c) => {
+ await requireStaff(c.var.userId);
+ const q = normalizeSearch(c.req.query("q") ?? null);
+ const rowsQuery = db
+ .select({
+ userId: schema.users.id,
+ email: schema.users.email,
+ name: schema.users.name,
+ orgName: schema.orgs.name,
+ orgSlug: schema.orgs.slug,
+ })
+ .from(schema.orgMembers)
+ .innerJoin(schema.users, eq(schema.users.id, schema.orgMembers.userId))
+ .innerJoin(schema.orgs, eq(schema.orgs.id, schema.orgMembers.orgId))
+ .orderBy(asc(schema.users.email), asc(schema.orgs.name))
+ .limit(MAX_TARGET_ROWS);
+ const rows = q
+ ? await rowsQuery.where(
+ or(
+ ilike(schema.users.email, `%${q}%`),
+ ilike(schema.users.name, `%${q}%`),
+ ilike(schema.orgs.name, `%${q}%`),
+ ilike(schema.orgs.slug, `%${q}%`),
+ ),
+ )
+ : await rowsQuery;
+
+ // Collapse to one row per user; carry the orgs they belong to so the
+ // palette can match on either a person or a company.
+ const byUser = new Map<
+ string,
+ { userId: string; email: string; name: string | null; orgs: { name: string; slug: string }[] }
+ >();
+ for (const r of rows) {
+ const existing = byUser.get(r.userId);
+ if (existing) {
+ existing.orgs.push({ name: r.orgName, slug: r.orgSlug });
+ } else {
+ byUser.set(r.userId, {
+ userId: r.userId,
+ email: r.email,
+ name: r.name,
+ orgs: [{ name: r.orgName, slug: r.orgSlug }],
+ });
+ }
+ }
+ return c.json({ users: Array.from(byUser.values()), limit: MAX_TARGET_ROWS });
+ });
+}
diff --git a/superlog/apps/api/src/incidents/detail.test.ts b/superlog/apps/api/src/incidents/detail.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..0f80e2528a1391103e074ca921d87ae2c9185b6b
--- /dev/null
+++ b/superlog/apps/api/src/incidents/detail.test.ts
@@ -0,0 +1,74 @@
+import { strict as assert } from "node:assert";
+import { test } from "node:test";
+import type { Issue, IssueSample } from "@superlog/db";
+
+// detail.ts transitively imports the db client, which throws at import time
+// without a connection string. Set a dummy URL before the dynamic import (the
+// porsager client connects lazily, so these pure-function tests never open a
+// socket). Same dynamic-import pattern as linear.test.ts / loops.test.ts.
+process.env.DATABASE_URL ??= "postgres://localhost:5434/superlog";
+const { toIssueContext } = await import("./detail.js");
+
+test("issue context carries the stored telemetry sample as-is", () => {
+ const sample: IssueSample = {
+ kind: "span",
+ service: "api",
+ severity: "ERROR",
+ message: "boom",
+ body: null,
+ exceptionType: "TypeError",
+ topFrame: "checkout.ts:42",
+ normalizedFrames: ["checkout.ts:42", "handler.ts:10"],
+ stacktrace: "TypeError: boom\n at checkout.ts:42",
+ seenAt: "2026-05-24T01:00:00.000Z",
+ traceId: "abc123",
+ spanId: "def456",
+ spanName: "POST /checkout",
+ spanAttrs: { "http.route": "/checkout" },
+ };
+ const ctx = toIssueContext({ ...baseIssue(), lastSample: sample });
+
+ assert.equal(ctx.id, "issue-1");
+ assert.equal(ctx.kind, "span");
+ assert.equal(ctx.exceptionType, "TypeError");
+ assert.equal(ctx.eventCount, 12);
+ assert.equal(ctx.firstSeen, "2026-05-24T00:00:00.000Z");
+ assert.equal(ctx.lastSeen, "2026-05-24T01:00:00.000Z");
+ // The full log/trace shape is passed through untouched so the agent can walk
+ // to live telemetry via the trace_id / span_id.
+ assert.deepEqual(ctx.sample, sample);
+ assert.equal(ctx.sample?.traceId, "abc123");
+});
+
+test("issue context tolerates issues that never captured a sample", () => {
+ const ctx = toIssueContext({ ...baseIssue(), lastSample: null });
+ assert.equal(ctx.sample, null);
+});
+
+function baseIssue(): Issue {
+ return {
+ id: "issue-1",
+ projectId: "project-1",
+ fingerprint: "fp-1",
+ kind: "span",
+ service: "api",
+ exceptionType: "TypeError",
+ title: "TypeError in checkout",
+ message: "boom",
+ topFrame: "checkout.ts:42",
+ normalizedFrames: ["checkout.ts:42"],
+ lastSample: null,
+ firstSeen: new Date("2026-05-24T00:00:00.000Z"),
+ lastSeen: new Date("2026-05-24T01:00:00.000Z"),
+ silencedAt: null,
+ lastAlertedAt: null,
+ slackMessageTs: null,
+ eventCount: 12,
+ groupingState: "grouped",
+ groupingSource: null,
+ groupingReason: null,
+ groupingAttemptedAt: null,
+ groupingAttemptCount: 0,
+ createdAt: new Date("2026-05-24T00:00:00.000Z"),
+ };
+}
diff --git a/superlog/apps/api/src/incidents/detail.ts b/superlog/apps/api/src/incidents/detail.ts
new file mode 100644
index 0000000000000000000000000000000000000000..6b8910d0d4a6dd6cf86d90e93bf541c24958647c
--- /dev/null
+++ b/superlog/apps/api/src/incidents/detail.ts
@@ -0,0 +1,115 @@
+import { type AgentRun, type Issue, type IssueSample, db, schema } from "@superlog/db";
+import { desc, eq, inArray } from "drizzle-orm";
+import { type IncidentSummary, toIncidentSummary } from "./search.js";
+
+export type IssueContext = {
+ id: string;
+ kind: string;
+ service: string | null;
+ exceptionType: string;
+ title: string;
+ message: string | null;
+ topFrame: string | null;
+ fingerprint: string;
+ eventCount: number;
+ firstSeen: string;
+ lastSeen: string;
+ /**
+ * The representative log/trace shape captured for this issue: trace_id /
+ * span_id, stacktrace, and span/log/resource attributes. This is the inline
+ * "issue context" — the agent can read it directly, then walk to live
+ * telemetry with query_traces / query_logs using the trace_id.
+ */
+ sample: IssueSample | null;
+};
+
+/** Compact, agent-friendly projection of an issue plus its stored telemetry sample. */
+export function toIssueContext(issue: Issue): IssueContext {
+ return {
+ id: issue.id,
+ kind: issue.kind,
+ service: issue.service,
+ exceptionType: issue.exceptionType,
+ title: issue.title,
+ message: issue.message,
+ topFrame: issue.topFrame,
+ fingerprint: issue.fingerprint,
+ eventCount: issue.eventCount,
+ firstSeen: issue.firstSeen.toISOString(),
+ lastSeen: issue.lastSeen.toISOString(),
+ sample: issue.lastSample ?? null,
+ };
+}
+
+export type AgentRunPointer = {
+ id: string;
+ state: string;
+ runtime: string;
+ selectedRepoFullName: string | null;
+ failureReason: string | null;
+ startedAt: string | null;
+ completedAt: string | null;
+};
+
+function toAgentRunPointer(run: AgentRun): AgentRunPointer {
+ return {
+ id: run.id,
+ state: run.state,
+ runtime: run.runtime,
+ selectedRepoFullName: run.selectedRepoFullName,
+ failureReason: run.failureReason,
+ startedAt: run.startedAt ? run.startedAt.toISOString() : null,
+ completedAt: run.completedAt ? run.completedAt.toISOString() : null,
+ };
+}
+
+export type IncidentDetail = {
+ incident: IncidentSummary;
+ issues: IssueContext[];
+ latestAgentRun: AgentRunPointer | null;
+};
+
+/**
+ * Load an incident by its (global) id and assemble the full detail an agent
+ * needs to answer "what's going on in this incident": the incident summary
+ * (with project_id for follow-up telemetry queries), every linked issue with
+ * its stored log/trace sample, and a pointer to the latest investigation.
+ *
+ * Returns null when the incident does not exist. Access control is the
+ * caller's responsibility — resolve `incident.projectId` against the session.
+ */
+export async function getIncidentDetail(incidentId: string): Promise {
+ const incident = await db.query.incidents.findFirst({
+ where: eq(schema.incidents.id, incidentId),
+ });
+ if (!incident) return null;
+
+ const [links, agentRuns] = await Promise.all([
+ db.query.incidentIssues.findMany({
+ where: eq(schema.incidentIssues.incidentId, incident.id),
+ orderBy: [desc(schema.incidentIssues.createdAt)],
+ }),
+ db.query.agentRuns.findMany({
+ where: eq(schema.agentRuns.incidentId, incident.id),
+ orderBy: [desc(schema.agentRuns.createdAt)],
+ limit: 1,
+ }),
+ ]);
+
+ const issues =
+ links.length > 0
+ ? await db.query.issues.findMany({
+ where: inArray(
+ schema.issues.id,
+ links.map((link) => link.issueId),
+ ),
+ orderBy: [desc(schema.issues.lastSeen)],
+ })
+ : [];
+
+ return {
+ incident: toIncidentSummary(incident),
+ issues: issues.map(toIssueContext),
+ latestAgentRun: agentRuns[0] ? toAgentRunPointer(agentRuns[0]) : null,
+ };
+}
diff --git a/superlog/apps/api/src/incidents/list.test.ts b/superlog/apps/api/src/incidents/list.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..4d666b1ee463d6be278b25b58105b2cd78caa644
--- /dev/null
+++ b/superlog/apps/api/src/incidents/list.test.ts
@@ -0,0 +1,91 @@
+import { strict as assert } from "node:assert";
+import { test } from "node:test";
+import {
+ type IncidentListItemInput,
+ buildIncidentListItem,
+ shouldInlineIncidentListStats,
+} from "./list.js";
+
+test("incident list omits graph stats by default so slow telemetry cannot block the page", () => {
+ const row = buildIncidentListItem(baseInput());
+
+ assert.equal("buckets" in row, false);
+ assert.equal("impactedUsers" in row, false);
+ assert.equal("impactedUsersAvailable" in row, false);
+ assert.equal("impactedUsersCapped" in row, false);
+ assert.equal("windowDays" in row, false);
+});
+
+test("incident list can still inline graph stats when explicitly requested", () => {
+ const row = buildIncidentListItem({
+ ...baseInput(),
+ stats: {
+ windowDays: 14,
+ buckets: [{ day: "2026-05-24", count: 4 }],
+ impactedUsers: 3,
+ impactedUsersAvailable: true,
+ impactedUsersCapped: false,
+ },
+ });
+
+ assert.deepEqual(row.buckets, [{ day: "2026-05-24", count: 4 }]);
+ assert.equal(row.windowDays, 14);
+ assert.equal(row.impactedUsers, 3);
+ assert.equal(row.impactedUsersAvailable, true);
+ assert.equal(row.impactedUsersCapped, false);
+});
+
+test("incident list only inlines graph stats behind an explicit query flag", () => {
+ assert.equal(shouldInlineIncidentListStats(undefined), false);
+ assert.equal(shouldInlineIncidentListStats(""), false);
+ assert.equal(shouldInlineIncidentListStats("0"), false);
+ assert.equal(shouldInlineIncidentListStats("true"), true);
+ assert.equal(shouldInlineIncidentListStats("1"), true);
+});
+
+function baseInput(): IncidentListItemInput {
+ return {
+ incident: {
+ id: "incident-1",
+ projectId: "project-1",
+ service: "api",
+ environment: "production",
+ title: "Checkout failures",
+ codename: "steady-amber",
+ severity: "SEV-2",
+ status: "open",
+ noiseReason: null,
+ noiseResolvedAt: null,
+ mergedAt: null,
+ slackInstallationId: null,
+ lastSlackPostedAt: null,
+ firstSeen: new Date("2026-05-24T00:00:00.000Z"),
+ lastSeen: new Date("2026-05-24T01:00:00.000Z"),
+ issueCount: 1,
+ slackChannelId: null,
+ slackThreadTs: null,
+ autoInvestigateSuppressedUntil: null,
+ autorecoveryLastEvaluatedAt: null,
+ agentSummary: null,
+ rootCauseText: null,
+ rootCauseConfidence: null,
+ estimatedImpactText: null,
+ estimatedImpactConfidence: null,
+ suggestedSeverity: null,
+ noiseClassification: null,
+ resolutionClassification: null,
+ findingsAgentRunId: null,
+ resolvedByKind: null,
+ resolvedByUserId: null,
+ resolvedBySlackUserId: null,
+ resolvedReasonCode: null,
+ resolvedReasonText: null,
+ resolvedAt: null,
+ mergedIntoId: null,
+ createdAt: new Date("2026-05-24T00:00:00.000Z"),
+ updatedAt: new Date("2026-05-24T00:00:00.000Z"),
+ },
+ agentRun: null,
+ pendingResolutionProposal: null,
+ };
+}
diff --git a/superlog/apps/api/src/incidents/list.ts b/superlog/apps/api/src/incidents/list.ts
new file mode 100644
index 0000000000000000000000000000000000000000..2a3f0558c6db56270b7a6e1d391b02a5cc548219
--- /dev/null
+++ b/superlog/apps/api/src/incidents/list.ts
@@ -0,0 +1,59 @@
+import type { AgentRun, Incident, IncidentResolutionProposalConfidence } from "@superlog/db";
+
+export type IncidentListStats = {
+ windowDays: number;
+ buckets: { day: string; count: number }[];
+ impactedUsers: number;
+ impactedUsersAvailable: boolean;
+ impactedUsersCapped: boolean;
+};
+
+export type IncidentListPendingResolutionProposal = {
+ id: string;
+ sourceKind: string;
+ confidence: IncidentResolutionProposalConfidence;
+ proposedReasonCode: string;
+ proposedReasonText: string;
+ proposedAt: string;
+};
+
+export type IncidentListItemInput = {
+ incident: Incident;
+ agentRun: AgentRun | null;
+ pendingResolutionProposal: IncidentListPendingResolutionProposal | null;
+ stats?: IncidentListStats;
+};
+
+export type IncidentListItem = {
+ incident: Incident;
+ agentRun: AgentRun | null;
+ pendingResolutionProposal: IncidentListPendingResolutionProposal | null;
+ windowDays?: number;
+ buckets?: { day: string; count: number }[];
+ impactedUsers?: number;
+ impactedUsersAvailable?: boolean;
+ impactedUsersCapped?: boolean;
+};
+
+export function shouldInlineIncidentListStats(value: string | undefined): boolean {
+ return value === "1" || value === "true";
+}
+
+export function buildIncidentListItem(input: IncidentListItemInput): IncidentListItem {
+ const base = {
+ incident: input.incident,
+ agentRun: input.agentRun,
+ pendingResolutionProposal: input.pendingResolutionProposal,
+ };
+
+ if (!input.stats) return base;
+
+ return {
+ ...base,
+ windowDays: input.stats.windowDays,
+ buckets: input.stats.buckets,
+ impactedUsers: input.stats.impactedUsers,
+ impactedUsersAvailable: input.stats.impactedUsersAvailable,
+ impactedUsersCapped: input.stats.impactedUsersCapped,
+ };
+}
diff --git a/superlog/apps/api/src/incidents/pr-retry.test.ts b/superlog/apps/api/src/incidents/pr-retry.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..d5c19512f1f5af889ce0c52215686a319d5f3819
--- /dev/null
+++ b/superlog/apps/api/src/incidents/pr-retry.test.ts
@@ -0,0 +1,64 @@
+import { strict as assert } from "node:assert";
+import { test } from "node:test";
+import type { AgentRunResult } from "@superlog/db";
+import { getPrDeliveryRetryEligibility } from "./pr-retry.js";
+
+test("pr delivery retry is allowed for failed pr_open_failed runs with a pending reusable patch", () => {
+ const result: AgentRunResult = {
+ state: "failed",
+ summary: "Failed to validate or open the PR.",
+ failureReason: "pr_open_failed",
+ pr: {
+ selectedRepoFullName: "acme/api",
+ branchName: "superlog/fix-api",
+ baseBranch: "main",
+ patch: "diff --git a/a b/a\n",
+ validationPassed: true,
+ openStatus: "pending",
+ },
+ };
+
+ assert.deepEqual(
+ getPrDeliveryRetryEligibility({
+ state: "failed",
+ failureReason: "pr_open_failed",
+ result,
+ }),
+ { canRetry: true },
+ );
+});
+
+test("pr delivery retry is denied when no patch can be reused", () => {
+ const result: AgentRunResult = {
+ state: "failed",
+ summary: "Failed to validate or open the PR.",
+ failureReason: "pr_open_failed",
+ pr: {
+ selectedRepoFullName: "acme/api",
+ branchName: "superlog/fix-api",
+ baseBranch: "main",
+ validationPassed: true,
+ openStatus: "pending",
+ },
+ };
+
+ assert.deepEqual(
+ getPrDeliveryRetryEligibility({
+ state: "failed",
+ failureReason: "pr_open_failed",
+ result,
+ }),
+ { canRetry: false, reason: "agent run has no reusable patch" },
+ );
+});
+
+test("pr delivery retry is denied for non PR-open failures", () => {
+ assert.deepEqual(
+ getPrDeliveryRetryEligibility({
+ state: "failed",
+ failureReason: "runtime_budget_exhausted",
+ result: null,
+ }),
+ { canRetry: false, reason: "agent run did not fail while opening a PR" },
+ );
+});
diff --git a/superlog/apps/api/src/incidents/pr-retry.ts b/superlog/apps/api/src/incidents/pr-retry.ts
new file mode 100644
index 0000000000000000000000000000000000000000..beb6273cf67e924f6247da58346b1d502d6d4481
--- /dev/null
+++ b/superlog/apps/api/src/incidents/pr-retry.ts
@@ -0,0 +1,46 @@
+import type { AgentRunResult } from "@superlog/db";
+
+type RetryableAgentRun = {
+ state: string;
+ failureReason: string | null;
+ result: AgentRunResult | null;
+};
+
+export type PrDeliveryRetryEligibility = { canRetry: true } | { canRetry: false; reason: string };
+
+export function getPrDeliveryRetryEligibility(
+ agentRun: RetryableAgentRun | null,
+): PrDeliveryRetryEligibility {
+ if (!agentRun) return { canRetry: false, reason: "agent run not found" };
+ if (agentRun.state !== "failed") {
+ return { canRetry: false, reason: "agent run is not failed" };
+ }
+ if (agentRun.failureReason !== "pr_open_failed") {
+ return { canRetry: false, reason: "agent run did not fail while opening a PR" };
+ }
+
+ const pr = agentRun.result?.pr ?? null;
+ if (!pr) return { canRetry: false, reason: "agent run has no PR result" };
+ if (pr.openStatus !== "pending") {
+ return { canRetry: false, reason: "agent run PR is not pending" };
+ }
+ if (!pr.selectedRepoFullName) {
+ return { canRetry: false, reason: "agent run has no selected repository" };
+ }
+ if (!pr.baseBranch) {
+ return { canRetry: false, reason: "agent run has no base branch" };
+ }
+ if (!hasReusablePatch(pr)) {
+ return { canRetry: false, reason: "agent run has no reusable patch" };
+ }
+
+ return { canRetry: true };
+}
+
+function hasReusablePatch(pr: AgentRunResult["pr"]): boolean {
+ if (!pr) return false;
+ if (typeof pr.patch === "string" && pr.patch.trim().length > 0) return true;
+ if (typeof pr.patchFileId === "string" && pr.patchFileId.trim().length > 0) return true;
+ if (typeof pr.patchFilePath === "string" && pr.patchFilePath.trim().length > 0) return true;
+ return false;
+}
diff --git a/superlog/apps/api/src/incidents/pr-view.test.ts b/superlog/apps/api/src/incidents/pr-view.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..156d41f79cc1591c799aeaa0a65dcd309ec5f1df
--- /dev/null
+++ b/superlog/apps/api/src/incidents/pr-view.test.ts
@@ -0,0 +1,76 @@
+import { strict as assert } from "node:assert";
+import { test } from "node:test";
+import { buildIncidentPullRequestViews } from "./pr-view.js";
+
+test("incident PR view includes the recorded PR patch from its agent run", () => {
+ const now = new Date("2026-06-06T12:00:00.000Z");
+ const views = buildIncidentPullRequestViews(
+ [
+ {
+ id: "pr-1",
+ agentRunId: "run-1",
+ repoFullName: "acme/app",
+ prNumber: 42,
+ url: "https://github.com/acme/app/pull/42",
+ branchName: "ash/fix-checkout",
+ baseBranch: "main",
+ headSha: "abc123",
+ state: "open",
+ title: "Fix checkout",
+ createdAt: now,
+ updatedAt: now,
+ mergedAt: null,
+ closedAt: null,
+ },
+ ],
+ [
+ {
+ id: "run-1",
+ result: {
+ state: "complete",
+ summary: "Fixed it",
+ pr: {
+ selectedRepoFullName: "acme/app",
+ branchName: "ash/fix-checkout",
+ baseBranch: "main",
+ validationPassed: true,
+ openStatus: "opened",
+ patch: "diff --git a/app.ts b/app.ts\n+ok\n",
+ },
+ },
+ },
+ ],
+ );
+
+ assert.equal(views.length, 1);
+ assert.equal(views[0]?.repoFullName, "acme/app");
+ assert.equal(views[0]?.patch, "diff --git a/app.ts b/app.ts\n+ok\n");
+ assert.equal(views[0]?.createdAt, "2026-06-06T12:00:00.000Z");
+});
+
+test("incident PR view returns null patch when the agent run has no patch body", () => {
+ const now = new Date("2026-06-06T12:00:00.000Z");
+ const views = buildIncidentPullRequestViews(
+ [
+ {
+ id: "pr-1",
+ agentRunId: "run-1",
+ repoFullName: "acme/app",
+ prNumber: 42,
+ url: "https://github.com/acme/app/pull/42",
+ branchName: "ash/fix-checkout",
+ baseBranch: "main",
+ headSha: null,
+ state: "open",
+ title: null,
+ createdAt: now,
+ updatedAt: now,
+ mergedAt: null,
+ closedAt: null,
+ },
+ ],
+ [{ id: "run-1", result: { state: "complete", summary: "Fixed it", pr: null } }],
+ );
+
+ assert.equal(views[0]?.patch, null);
+});
diff --git a/superlog/apps/api/src/incidents/pr-view.ts b/superlog/apps/api/src/incidents/pr-view.ts
new file mode 100644
index 0000000000000000000000000000000000000000..2c44551596bb0b8e3ca613011fb24b6c8c6d7369
--- /dev/null
+++ b/superlog/apps/api/src/incidents/pr-view.ts
@@ -0,0 +1,67 @@
+import type { schema } from "@superlog/db";
+
+export type IncidentPullRequestView = {
+ id: string;
+ agentRunId: string;
+ repoFullName: string;
+ prNumber: number;
+ url: string;
+ branchName: string;
+ baseBranch: string;
+ headSha: string | null;
+ state: schema.AgentPrState;
+ title: string | null;
+ patch: string | null;
+ createdAt: string;
+ updatedAt: string;
+ mergedAt: string | null;
+ closedAt: string | null;
+};
+
+type AgentRunWithResult = Pick;
+type AgentPullRequestRow = Pick<
+ schema.AgentPullRequest,
+ | "id"
+ | "agentRunId"
+ | "repoFullName"
+ | "prNumber"
+ | "url"
+ | "branchName"
+ | "baseBranch"
+ | "headSha"
+ | "state"
+ | "title"
+ | "createdAt"
+ | "updatedAt"
+ | "mergedAt"
+ | "closedAt"
+>;
+
+export function buildIncidentPullRequestViews(
+ prs: AgentPullRequestRow[],
+ agentRuns: AgentRunWithResult[],
+): IncidentPullRequestView[] {
+ const runsById = new Map(agentRuns.map((run) => [run.id, run] as const));
+ return prs.map((pr) => {
+ const run = runsById.get(pr.agentRunId) ?? null;
+ const runPr = run?.result?.pr ?? null;
+ const patch = typeof runPr?.patch === "string" && runPr.patch.trim() ? runPr.patch : null;
+ return {
+ id: pr.id,
+ agentRunId: pr.agentRunId,
+ repoFullName: pr.repoFullName,
+ prNumber: pr.prNumber,
+ url: pr.url,
+ branchName: pr.branchName,
+ baseBranch: pr.baseBranch,
+ headSha: pr.headSha,
+ state: pr.state,
+ title: pr.title,
+ patch,
+ createdAt: pr.createdAt.toISOString(),
+ updatedAt: pr.updatedAt.toISOString(),
+ mergedAt: pr.mergedAt?.toISOString() ?? null,
+ closedAt: pr.closedAt?.toISOString() ?? null,
+ };
+ });
+}
diff --git a/superlog/apps/api/src/incidents/resolution-side-effects.test.ts b/superlog/apps/api/src/incidents/resolution-side-effects.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..db53eca5bc8bf126e85ba561277a822856dda7cf
--- /dev/null
+++ b/superlog/apps/api/src/incidents/resolution-side-effects.test.ts
@@ -0,0 +1,107 @@
+import assert from "node:assert/strict";
+import { test } from "node:test";
+import {
+ buildResolvedIncidentSlackRoot,
+ runResolvedIncidentSideEffects,
+} from "./resolution-side-effects.js";
+
+test("runResolvedIncidentSideEffects closes incident PRs through the shared helper and refreshes Slack root", async () => {
+ const calls: string[] = [];
+
+ const result = await runResolvedIncidentSideEffects({
+ incident: {
+ id: "inc-1",
+ title: "Checkout API timeout",
+ service: "checkout-api",
+ },
+ projectName: "Acme",
+ deps: {
+ closeIncidentPullRequests: async (incidentId) => {
+ calls.push(`close-prs:${incidentId}`);
+ return { closedPullRequestCount: 2, failedPullRequestCount: 0 };
+ },
+ updateSlackRootMessage: async (input) => {
+ calls.push(`slack:${input.incident.id}:${input.text}`);
+ },
+ },
+ });
+
+ assert.deepEqual(result, { closedPullRequestCount: 2, failedPullRequestCount: 0 });
+ assert.deepEqual(calls, [
+ "close-prs:inc-1",
+ "slack:inc-1::white_check_mark: Checkout API timeout - Incident resolved",
+ ]);
+});
+
+test("runResolvedIncidentSideEffects still refreshes Slack when PR closure reports failures", async () => {
+ const calls: string[] = [];
+ const result = await runResolvedIncidentSideEffects({
+ incident: { id: "inc-1", title: "Checkout API timeout", service: null },
+ projectName: "Acme",
+ deps: {
+ closeIncidentPullRequests: async () => {
+ calls.push("close-prs");
+ return { closedPullRequestCount: 0, failedPullRequestCount: 1 };
+ },
+ updateSlackRootMessage: async () => {
+ calls.push("slack");
+ },
+ },
+ });
+
+ assert.deepEqual(result, { closedPullRequestCount: 0, failedPullRequestCount: 1 });
+ assert.deepEqual(calls, ["close-prs", "slack"]);
+});
+
+test("runResolvedIncidentSideEffects still refreshes Slack when PR closure throws", async () => {
+ const calls: string[] = [];
+ const result = await runResolvedIncidentSideEffects({
+ incident: { id: "inc-1", title: "Checkout API timeout", service: null },
+ projectName: "Acme",
+ deps: {
+ closeIncidentPullRequests: async () => {
+ calls.push("close-prs");
+ throw new Error("github unavailable");
+ },
+ updateSlackRootMessage: async () => {
+ calls.push("slack");
+ },
+ },
+ });
+
+ assert.deepEqual(result, { closedPullRequestCount: 0, failedPullRequestCount: 1 });
+ assert.deepEqual(calls, ["close-prs", "slack"]);
+});
+
+test("runResolvedIncidentSideEffects does not fail when Slack refresh throws", async () => {
+ const result = await runResolvedIncidentSideEffects({
+ incident: { id: "inc-1", title: "Checkout API timeout", service: null },
+ projectName: "Acme",
+ deps: {
+ closeIncidentPullRequests: async () => ({
+ closedPullRequestCount: 1,
+ failedPullRequestCount: 0,
+ }),
+ updateSlackRootMessage: async () => {
+ throw new Error("slack unavailable");
+ },
+ },
+ });
+
+ assert.deepEqual(result, { closedPullRequestCount: 1, failedPullRequestCount: 0 });
+});
+
+test("buildResolvedIncidentSlackRoot removes resolve action and keeps feedback action", () => {
+ const update = buildResolvedIncidentSlackRoot({
+ incident: {
+ id: "inc-1",
+ title: "Checkout API timeout",
+ service: "checkout-api",
+ },
+ projectName: "Acme",
+ });
+
+ assert.equal(update.text, ":white_check_mark: Checkout API timeout - Incident resolved");
+ assert.equal(JSON.stringify(update.blocks).includes("resolve_incident:"), false);
+ assert.equal(JSON.stringify(update.blocks).includes("give_feedback:inc-1"), true);
+});
diff --git a/superlog/apps/api/src/incidents/resolution-side-effects.ts b/superlog/apps/api/src/incidents/resolution-side-effects.ts
new file mode 100644
index 0000000000000000000000000000000000000000..d38a71f87b4c91794c4b47116d5c190f6421a9db
--- /dev/null
+++ b/superlog/apps/api/src/incidents/resolution-side-effects.ts
@@ -0,0 +1,198 @@
+import type { CloseIncidentOpenPullRequestsResult, CloseIncidentPullRequest } from "@superlog/db";
+import { and, eq } from "drizzle-orm";
+import { logger } from "../logger.js";
+
+const WEB_ORIGIN = process.env.WEB_ORIGIN ?? "http://localhost:5173";
+
+const log = logger.child({ scope: "incident-resolution-side-effects" });
+
+export type ResolvedIncidentSideEffectIncident = {
+ id: string;
+ title: string;
+ service: string | null;
+};
+
+export type ResolvedIncidentSlackRoot = {
+ text: string;
+ blocks: unknown[];
+};
+
+export type ResolvedIncidentSideEffectDeps = {
+ closeIncidentPullRequests(incidentId: string): Promise;
+ updateSlackRootMessage(input: {
+ incident: ResolvedIncidentSideEffectIncident;
+ text: string;
+ blocks: unknown[];
+ }): Promise;
+};
+
+export async function runResolvedIncidentSideEffects(opts: {
+ incident: ResolvedIncidentSideEffectIncident;
+ projectName: string;
+ deps: ResolvedIncidentSideEffectDeps;
+}): Promise {
+ let closed: CloseIncidentOpenPullRequestsResult;
+ try {
+ closed = await opts.deps.closeIncidentPullRequests(opts.incident.id);
+ } catch (err) {
+ log.warn({ err, incident_id: opts.incident.id }, "failed to close incident PRs after resolve");
+ closed = { closedPullRequestCount: 0, failedPullRequestCount: 1 };
+ }
+
+ const slackRoot = buildResolvedIncidentSlackRoot({
+ incident: opts.incident,
+ projectName: opts.projectName,
+ });
+ try {
+ await opts.deps.updateSlackRootMessage({
+ incident: opts.incident,
+ text: slackRoot.text,
+ blocks: slackRoot.blocks,
+ });
+ } catch (err) {
+ log.warn(
+ { err, incident_id: opts.incident.id },
+ "failed to update resolved incident Slack root",
+ );
+ }
+
+ return closed;
+}
+
+export async function runResolvedIncidentSideEffectsForIncident(opts: {
+ incidentId: string;
+ closePullRequest: CloseIncidentPullRequest;
+}): Promise {
+ const { db, schema } = await import("@superlog/db");
+ const incident = await db.query.incidents.findFirst({
+ where: eq(schema.incidents.id, opts.incidentId),
+ });
+ if (!incident) return null;
+ const project = await db.query.projects.findFirst({
+ where: eq(schema.projects.id, incident.projectId),
+ });
+ return runResolvedIncidentSideEffects({
+ incident: {
+ id: incident.id,
+ title: incident.title,
+ service: incident.service,
+ },
+ projectName: project?.name ?? incident.projectId,
+ deps: createResolvedIncidentSideEffectDeps({
+ closePullRequest: opts.closePullRequest,
+ }),
+ });
+}
+
+export function buildResolvedIncidentSlackRoot(opts: {
+ incident: ResolvedIncidentSideEffectIncident;
+ projectName: string;
+}): ResolvedIncidentSlackRoot {
+ const incidentUrl = `${WEB_ORIGIN}/incidents/${opts.incident.id}`;
+ const lines = [
+ ":white_check_mark: *Incident resolved*",
+ `*${opts.incident.title}*`,
+ opts.incident.service
+ ? `\`${opts.projectName}\` · \`${opts.incident.service}\``
+ : `\`${opts.projectName}\``,
+ ];
+ const blocks: unknown[] = [
+ { type: "section", text: { type: "mrkdwn", text: lines.join("\n") } },
+ {
+ type: "actions",
+ elements: [
+ {
+ type: "button",
+ text: { type: "plain_text", text: "Open in Superlog", emoji: true },
+ url: incidentUrl,
+ action_id: "open_superlog",
+ },
+ {
+ type: "button",
+ text: { type: "plain_text", text: "💬 Give feedback", emoji: true },
+ action_id: `give_feedback:${opts.incident.id}`,
+ },
+ ],
+ },
+ ];
+ return {
+ text: `:white_check_mark: ${opts.incident.title} - Incident resolved`,
+ blocks,
+ };
+}
+
+export function createResolvedIncidentSideEffectDeps(opts: {
+ closePullRequest: CloseIncidentPullRequest;
+ updateSlackRootMessage?: ResolvedIncidentSideEffectDeps["updateSlackRootMessage"];
+}): ResolvedIncidentSideEffectDeps {
+ return {
+ closeIncidentPullRequests: async (incidentId) => {
+ const { closeIncidentOpenPullRequestsAfterResolution } = await import("@superlog/db");
+ return closeIncidentOpenPullRequestsAfterResolution({
+ incidentId,
+ closePullRequest: opts.closePullRequest,
+ onCloseFailure: ({ pr, error }) =>
+ log.warn(
+ {
+ incident_id: incidentId,
+ agent_pr_id: pr.id,
+ repo: pr.repoFullName,
+ pr_number: pr.prNumber,
+ error,
+ },
+ "failed to close incident PR after resolve",
+ ),
+ });
+ },
+ updateSlackRootMessage: opts.updateSlackRootMessage ?? updateResolvedIncidentSlackRootMessage,
+ };
+}
+
+export async function updateResolvedIncidentSlackRootMessage(input: {
+ incident: ResolvedIncidentSideEffectIncident;
+ text: string;
+ blocks: unknown[];
+}): Promise {
+ const { db, schema } = await import("@superlog/db");
+ const row = await db.query.incidents.findFirst({
+ where: eq(schema.incidents.id, input.incident.id),
+ });
+ if (!row?.slackChannelId || !row.slackThreadTs || !row.slackInstallationId) return;
+
+ const installation = await db.query.slackInstallations.findFirst({
+ where: and(
+ eq(schema.slackInstallations.id, row.slackInstallationId),
+ eq(schema.slackInstallations.projectId, row.projectId),
+ ),
+ });
+ if (!installation?.botAccessToken || installation.revokedAt) return;
+
+ try {
+ const res = await fetch("https://slack.com/api/chat.update", {
+ method: "POST",
+ headers: {
+ "content-type": "application/json; charset=utf-8",
+ authorization: `Bearer ${installation.botAccessToken}`,
+ },
+ body: JSON.stringify({
+ channel: row.slackChannelId,
+ ts: row.slackThreadTs,
+ text: input.text,
+ blocks: input.blocks,
+ }),
+ });
+ const data = (await res.json().catch(() => null)) as { ok?: boolean; error?: string } | null;
+ if (!data?.ok) {
+ log.warn(
+ {
+ incident_id: input.incident.id,
+ channel: row.slackChannelId,
+ error: data?.error ?? `status_${res.status}`,
+ },
+ "failed to update resolved incident Slack root",
+ );
+ }
+ } catch (err) {
+ log.warn({ err, incident_id: input.incident.id }, "resolved incident Slack root update threw");
+ }
+}
diff --git a/superlog/apps/api/src/incidents/search.test.ts b/superlog/apps/api/src/incidents/search.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..82d6ff2ee8a2f31367660e2e68e7de3b9742b9ff
--- /dev/null
+++ b/superlog/apps/api/src/incidents/search.test.ts
@@ -0,0 +1,111 @@
+import { strict as assert } from "node:assert";
+import { test } from "node:test";
+import type { Incident } from "@superlog/db";
+
+// search.ts transitively imports the db client, which throws at import time
+// without a connection string. Set a dummy URL before the dynamic import (the
+// porsager client connects lazily, so these pure-function tests never open a
+// socket). Same dynamic-import pattern as linear.test.ts / loops.test.ts.
+process.env.DATABASE_URL ??= "postgres://localhost:5434/superlog";
+const { resolveIncidentSearchStatus, toIncidentSummary } = await import("./search.js");
+
+test("status defaults to hiding agent-classified noise", () => {
+ assert.deepEqual(resolveIncidentSearchStatus(undefined), { kind: "exclude_noise" });
+});
+
+test("status='all' includes every status, noise included", () => {
+ assert.deepEqual(resolveIncidentSearchStatus("all"), { kind: "all" });
+});
+
+test("a concrete status narrows to exactly that status", () => {
+ assert.deepEqual(resolveIncidentSearchStatus("open"), { kind: "only", status: "open" });
+ assert.deepEqual(resolveIncidentSearchStatus("autoresolved_noise"), {
+ kind: "only",
+ status: "autoresolved_noise",
+ });
+});
+
+test("an unknown status is rejected rather than silently ignored", () => {
+ assert.throws(() => resolveIncidentSearchStatus("bogus"), /invalid incident status/i);
+});
+
+test("summary is a compact, agent-friendly projection with ISO timestamps", () => {
+ const summary = toIncidentSummary(baseIncident());
+
+ assert.equal(summary.id, "incident-1");
+ assert.equal(summary.projectId, "project-1");
+ assert.equal(summary.codename, "steady-amber");
+ assert.equal(summary.title, "Checkout failures");
+ assert.equal(summary.service, "api");
+ assert.equal(summary.environment, "production");
+ assert.equal(summary.severity, "SEV-2");
+ assert.equal(summary.status, "open");
+ assert.equal(summary.issueCount, 1);
+ assert.equal(summary.firstSeen, "2026-05-24T00:00:00.000Z");
+ assert.equal(summary.lastSeen, "2026-05-24T01:00:00.000Z");
+ assert.equal(summary.resolvedAt, null);
+ // Heavy/internal columns must not leak into the MCP payload.
+ assert.equal("noiseClassification" in summary, false);
+ assert.equal("slackInstallationId" in summary, false);
+});
+
+test("summary surfaces agent findings and resolution metadata when present", () => {
+ const summary = toIncidentSummary({
+ ...baseIncident(),
+ status: "resolved",
+ agentSummary: "DB pool exhausted",
+ rootCauseText: "connection leak in checkout handler",
+ rootCauseConfidence: 80,
+ resolvedAt: new Date("2026-05-25T00:00:00.000Z"),
+ resolvedReasonCode: "agent_pr_merged",
+ });
+
+ assert.equal(summary.agentSummary, "DB pool exhausted");
+ assert.equal(summary.rootCauseText, "connection leak in checkout handler");
+ assert.equal(summary.rootCauseConfidence, 80);
+ assert.equal(summary.resolvedAt, "2026-05-25T00:00:00.000Z");
+ assert.equal(summary.resolvedReasonCode, "agent_pr_merged");
+});
+
+function baseIncident(): Incident {
+ return {
+ id: "incident-1",
+ projectId: "project-1",
+ service: "api",
+ environment: "production",
+ title: "Checkout failures",
+ codename: "steady-amber",
+ severity: "SEV-2",
+ status: "open",
+ noiseReason: null,
+ noiseResolvedAt: null,
+ mergedAt: null,
+ slackInstallationId: null,
+ lastSlackPostedAt: null,
+ firstSeen: new Date("2026-05-24T00:00:00.000Z"),
+ lastSeen: new Date("2026-05-24T01:00:00.000Z"),
+ issueCount: 1,
+ slackChannelId: null,
+ slackThreadTs: null,
+ autoInvestigateSuppressedUntil: null,
+ autorecoveryLastEvaluatedAt: null,
+ agentSummary: null,
+ rootCauseText: null,
+ rootCauseConfidence: null,
+ estimatedImpactText: null,
+ estimatedImpactConfidence: null,
+ suggestedSeverity: null,
+ noiseClassification: null,
+ resolutionClassification: null,
+ findingsAgentRunId: null,
+ resolvedByKind: null,
+ resolvedByUserId: null,
+ resolvedBySlackUserId: null,
+ resolvedReasonCode: null,
+ resolvedReasonText: null,
+ resolvedAt: null,
+ mergedIntoId: null,
+ createdAt: new Date("2026-05-24T00:00:00.000Z"),
+ updatedAt: new Date("2026-05-24T00:00:00.000Z"),
+ };
+}
diff --git a/superlog/apps/api/src/incidents/search.ts b/superlog/apps/api/src/incidents/search.ts
new file mode 100644
index 0000000000000000000000000000000000000000..864161ebcddb32e8ad7e5542d571e14e47930765
--- /dev/null
+++ b/superlog/apps/api/src/incidents/search.ts
@@ -0,0 +1,159 @@
+import {
+ type Incident,
+ type IncidentSeverity,
+ type IncidentStatus,
+ db,
+ schema,
+} from "@superlog/db";
+import { and, desc, eq, gte, ilike, lte, ne, or } from "drizzle-orm";
+
+const INCIDENT_STATUSES: readonly IncidentStatus[] = [
+ "open",
+ "resolved",
+ "autoresolved_noise",
+ "merged",
+];
+
+function isIncidentStatus(value: string): value is IncidentStatus {
+ return (INCIDENT_STATUSES as readonly string[]).includes(value);
+}
+
+export type IncidentSearchStatusFilter =
+ | { kind: "exclude_noise" }
+ | { kind: "all" }
+ | { kind: "only"; status: IncidentStatus };
+
+/**
+ * Mirror the web list endpoint's default: with no status given, hide
+ * agent-classified noise. `all` opts into every status; any concrete status
+ * narrows to exactly that one. An unrecognised value is rejected so a typo
+ * doesn't silently return the whole backlog.
+ */
+export function resolveIncidentSearchStatus(
+ status: string | undefined,
+): IncidentSearchStatusFilter {
+ if (!status) return { kind: "exclude_noise" };
+ if (status === "all") return { kind: "all" };
+ if (isIncidentStatus(status)) return { kind: "only", status };
+ throw new Error(`invalid incident status: ${status}`);
+}
+
+export type IncidentSummary = {
+ id: string;
+ projectId: string;
+ codename: string;
+ title: string;
+ service: string | null;
+ environment: string | null;
+ severity: IncidentSeverity | null;
+ status: IncidentStatus;
+ firstSeen: string;
+ lastSeen: string;
+ issueCount: number;
+ agentSummary: string | null;
+ rootCauseText: string | null;
+ rootCauseConfidence: number | null;
+ estimatedImpactText: string | null;
+ resolvedAt: string | null;
+ resolvedReasonCode: string | null;
+ mergedIntoId: string | null;
+ slackChannelId: string | null;
+ slackThreadTs: string | null;
+};
+
+/**
+ * Compact, agent-friendly projection of an incident. Drops the heavy/internal
+ * columns (noise & resolution classification blobs, slack install pointers,
+ * autorecovery bookkeeping) and renders timestamps as ISO strings.
+ */
+export function toIncidentSummary(incident: Incident): IncidentSummary {
+ return {
+ id: incident.id,
+ projectId: incident.projectId,
+ codename: incident.codename,
+ title: incident.title,
+ service: incident.service,
+ environment: incident.environment,
+ severity: incident.severity,
+ status: incident.status,
+ firstSeen: incident.firstSeen.toISOString(),
+ lastSeen: incident.lastSeen.toISOString(),
+ issueCount: incident.issueCount,
+ agentSummary: incident.agentSummary,
+ rootCauseText: incident.rootCauseText,
+ rootCauseConfidence: incident.rootCauseConfidence,
+ estimatedImpactText: incident.estimatedImpactText,
+ resolvedAt: incident.resolvedAt ? incident.resolvedAt.toISOString() : null,
+ resolvedReasonCode: incident.resolvedReasonCode,
+ mergedIntoId: incident.mergedIntoId,
+ slackChannelId: incident.slackChannelId,
+ slackThreadTs: incident.slackThreadTs,
+ };
+}
+
+export type IncidentSearchInput = {
+ status?: string;
+ severity?: IncidentSeverity;
+ service?: string;
+ query?: string;
+ since?: string;
+ until?: string;
+ limit?: number;
+};
+
+function parseTimestamp(value: string | undefined, label: string): Date | undefined {
+ if (!value) return undefined;
+ const date = new Date(value);
+ if (Number.isNaN(date.getTime())) {
+ throw new Error(`invalid ${label} timestamp: ${value}`);
+ }
+ return date;
+}
+
+function clampLimit(limit: number | undefined): number {
+ if (!limit || !Number.isFinite(limit)) return 50;
+ return Math.min(Math.max(Math.trunc(limit), 1), 200);
+}
+
+/**
+ * Search a project's incidents by status, severity, service, free-text
+ * (title/codename substring), and `last_seen` window. Newest activity first.
+ */
+export async function searchIncidents(
+ projectId: string,
+ input: IncidentSearchInput,
+): Promise {
+ const conditions = [eq(schema.incidents.projectId, projectId)];
+
+ const status = resolveIncidentSearchStatus(input.status);
+ if (status.kind === "exclude_noise") {
+ conditions.push(ne(schema.incidents.status, "autoresolved_noise"));
+ } else if (status.kind === "only") {
+ conditions.push(eq(schema.incidents.status, status.status));
+ }
+
+ if (input.severity) conditions.push(eq(schema.incidents.severity, input.severity));
+ if (input.service) conditions.push(eq(schema.incidents.service, input.service));
+
+ if (input.query) {
+ const like = `%${input.query}%`;
+ const textMatch = or(
+ ilike(schema.incidents.title, like),
+ ilike(schema.incidents.codename, like),
+ );
+ if (textMatch) conditions.push(textMatch);
+ }
+
+ const since = parseTimestamp(input.since, "since");
+ if (since) conditions.push(gte(schema.incidents.lastSeen, since));
+ const until = parseTimestamp(input.until, "until");
+ if (until) conditions.push(lte(schema.incidents.lastSeen, until));
+
+ const rows = await db.query.incidents.findMany({
+ where: and(...conditions),
+ orderBy: [desc(schema.incidents.lastSeen)],
+ limit: clampLimit(input.limit),
+ });
+
+ return rows.map(toIncidentSummary);
+}
diff --git a/superlog/apps/api/src/incidents/stats.test.ts b/superlog/apps/api/src/incidents/stats.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..23655a8f4d167911dd1612a4dd86cf37d129b941
--- /dev/null
+++ b/superlog/apps/api/src/incidents/stats.test.ts
@@ -0,0 +1,156 @@
+import { strict as assert } from "node:assert";
+import { test } from "node:test";
+import {
+ buildIncidentStatsFromActivityRows,
+ buildIncidentStatsFromIssues,
+ buildIncidentStatsPairs,
+ buildIncidentStatsWithFallback,
+ emptyIncidentStats,
+} from "./stats.js";
+
+test("incident stats fallback buckets issue event counts by last seen day", () => {
+ const stats = buildIncidentStatsFromIssues(
+ [
+ { eventCount: 395, lastSeen: new Date("2026-05-25T18:23:04.391Z") },
+ { eventCount: 100, lastSeen: new Date("2026-05-27T18:52:35.544Z") },
+ { eventCount: 10, lastSeen: new Date("2026-05-01T00:00:00.000Z") },
+ ],
+ { now: new Date("2026-05-28T19:00:00.000Z"), windowDays: 14 },
+ );
+
+ assert.equal(stats.windowDays, 14);
+ assert.equal(stats.totalEvents, 495);
+ assert.equal(stats.impactedUsers, 0);
+ assert.equal(stats.impactedUsersAvailable, false);
+ assert.equal(stats.buckets.length, 14);
+ assert.equal(stats.buckets.find((b) => b.day === "2026-05-25")?.count, 395);
+ assert.equal(stats.buckets.find((b) => b.day === "2026-05-27")?.count, 100);
+});
+
+test("incident stats fallback returns empty activity when there are no recent issues", () => {
+ const stats = buildIncidentStatsFromIssues(
+ [{ eventCount: 10, lastSeen: new Date("2026-05-01T00:00:00.000Z") }],
+ { now: new Date("2026-05-28T19:00:00.000Z"), windowDays: 14 },
+ );
+
+ assert.deepEqual(stats, emptyIncidentStats(14, new Date("2026-05-28T19:00:00.000Z")));
+});
+
+test("incident stats buckets pre-aggregated fingerprint activity rows", () => {
+ const stats = buildIncidentStatsFromActivityRows(
+ [
+ { day: "2026-05-25", count: 395 },
+ { day: "2026-05-25", count: "100" },
+ { day: "2026-05-27", count: 7 },
+ ],
+ { now: new Date("2026-05-28T19:00:00.000Z"), windowDays: 14 },
+ );
+
+ assert.equal(stats.windowDays, 14);
+ assert.equal(stats.totalEvents, 502);
+ assert.equal(stats.impactedUsers, 0);
+ assert.equal(stats.impactedUsersAvailable, false);
+ assert.equal(stats.buckets.find((b) => b.day === "2026-05-25")?.count, 495);
+ assert.equal(stats.buckets.find((b) => b.day === "2026-05-27")?.count, 7);
+});
+
+test("incident stats returns issue fallback when telemetry load times out", async () => {
+ const reasons: string[] = [];
+ let aborted = false;
+
+ const stats = await buildIncidentStatsWithFallback({
+ fallback: buildIncidentStatsFromIssues(
+ [{ eventCount: 7, lastSeen: new Date("2026-05-27T00:00:00.000Z") }],
+ { now: new Date("2026-05-28T19:00:00.000Z"), windowDays: 14 },
+ ),
+ timeoutMs: 5,
+ onTelemetryUnavailable: (reason) => reasons.push(reason),
+ loadTelemetry: (signal) => {
+ signal.addEventListener("abort", () => {
+ aborted = true;
+ });
+ return new Promise(() => undefined);
+ },
+ });
+
+ assert.equal(aborted, true);
+ assert.deepEqual(reasons, ["timeout"]);
+ assert.equal(stats.totalEvents, 7);
+ assert.equal(stats.buckets.find((b) => b.day === "2026-05-27")?.count, 7);
+});
+
+test("incident stats prefers telemetry when it loads before the timeout", async () => {
+ const telemetry = {
+ windowDays: 14,
+ buckets: [{ day: "2026-05-27", count: 42 }],
+ totalEvents: 42,
+ impactedUsers: 3,
+ impactedUsersAvailable: true,
+ };
+
+ const stats = await buildIncidentStatsWithFallback({
+ fallback: emptyIncidentStats(14, new Date("2026-05-28T19:00:00.000Z")),
+ timeoutMs: 50,
+ loadTelemetry: async () => telemetry,
+ });
+
+ assert.deepEqual(stats, telemetry);
+});
+
+test("incident stats span pairs include resolved span names when available", () => {
+ const pairs = buildIncidentStatsPairs(
+ [
+ {
+ id: "issue-1",
+ kind: "span",
+ service: "superlog-api",
+ exceptionType: "Error",
+ lastSample: { traceId: "trace-1", spanId: "span-1" },
+ },
+ {
+ id: "issue-2",
+ kind: "span",
+ service: "superlog-api",
+ exceptionType: "Error",
+ lastSample: { traceId: "trace-2", spanId: "span-2", spanName: "cached.authorize" },
+ },
+ {
+ id: "issue-3",
+ kind: "log",
+ service: "superlog-api",
+ exceptionType: "Error",
+ lastSample: null,
+ },
+ ],
+ new Map([["trace-1:span-1", "project.authorize"]]),
+ );
+
+ assert.deepEqual(pairs.namedSpanServices, ["superlog-api", "superlog-api"]);
+ assert.deepEqual(pairs.namedSpanNames, ["project.authorize", "cached.authorize"]);
+ assert.deepEqual(pairs.namedSpanExcTypes, ["Error", "Error"]);
+ assert.deepEqual(pairs.unnamedSpanServices, []);
+ assert.deepEqual(pairs.unnamedSpanExcTypes, []);
+ assert.deepEqual(pairs.logServices, ["superlog-api"]);
+ assert.deepEqual(pairs.logExcTypes, ["Error"]);
+});
+
+test("incident stats keeps span pairs queryable when span name is unknown", () => {
+ const pairs = buildIncidentStatsPairs(
+ [
+ {
+ id: "issue-1",
+ kind: "span",
+ service: "api",
+ exceptionType: "TypeError",
+ lastSample: { traceId: "trace-1", spanId: "span-1" },
+ },
+ ],
+ new Map(),
+ );
+
+ assert.deepEqual(pairs.namedSpanServices, []);
+ assert.deepEqual(pairs.namedSpanNames, []);
+ assert.deepEqual(pairs.namedSpanExcTypes, []);
+ assert.deepEqual(pairs.unnamedSpanServices, ["api"]);
+ assert.deepEqual(pairs.unnamedSpanExcTypes, ["TypeError"]);
+});
diff --git a/superlog/apps/api/src/incidents/stats.ts b/superlog/apps/api/src/incidents/stats.ts
new file mode 100644
index 0000000000000000000000000000000000000000..4b066cf09e860280bff58fc3c6d3c79cec1b1025
--- /dev/null
+++ b/superlog/apps/api/src/incidents/stats.ts
@@ -0,0 +1,223 @@
+export type IncidentStats = {
+ windowDays: number;
+ buckets: { day: string; count: number }[];
+ totalEvents: number;
+ impactedUsers: number;
+ impactedUsersAvailable: boolean;
+};
+
+export type IncidentStatsIssue = {
+ eventCount: number;
+ lastSeen: Date | string;
+};
+
+export type IncidentStatsActivityRow = {
+ day: string;
+ count: number | string;
+};
+
+export type IncidentStatsFingerprintIssue = {
+ id: string;
+ kind: string;
+ service: string | null;
+ exceptionType: string;
+ lastSample?: {
+ traceId?: string | null;
+ spanId?: string | null;
+ spanName?: string | null;
+ } | null;
+};
+
+export type IncidentStatsPairs = {
+ namedSpanServices: string[];
+ namedSpanNames: string[];
+ namedSpanExcTypes: string[];
+ unnamedSpanServices: string[];
+ unnamedSpanExcTypes: string[];
+ logServices: string[];
+ logExcTypes: string[];
+};
+
+export type BuildIncidentStatsFromIssuesOptions = {
+ now?: Date;
+ windowDays: number;
+};
+
+export type BuildIncidentStatsWithFallbackOptions = {
+ fallback: IncidentStats;
+ timeoutMs: number;
+ loadTelemetry(signal: AbortSignal): Promise;
+ onTelemetryUnavailable?: (reason: "timeout" | "error", error?: unknown) => void;
+};
+
+export function emptyIncidentStats(windowDays: number, now = new Date()): IncidentStats {
+ return {
+ windowDays,
+ buckets: buildBucketsFromMap(new Map(), windowDays, now),
+ totalEvents: 0,
+ impactedUsers: 0,
+ impactedUsersAvailable: false,
+ };
+}
+
+export function buildIncidentStatsFromIssues(
+ issues: IncidentStatsIssue[],
+ opts: BuildIncidentStatsFromIssuesOptions,
+): IncidentStats {
+ const countsByDay = new Map();
+ const windowStart = startOfUtcDay(opts.now ?? new Date());
+ windowStart.setUTCDate(windowStart.getUTCDate() - opts.windowDays + 1);
+
+ for (const issue of issues) {
+ const seenAt = issue.lastSeen instanceof Date ? issue.lastSeen : new Date(issue.lastSeen);
+ if (!Number.isFinite(seenAt.getTime()) || seenAt < windowStart) continue;
+ const day = toUtcDay(seenAt);
+ countsByDay.set(day, (countsByDay.get(day) ?? 0) + Number(issue.eventCount));
+ }
+
+ const buckets = buildBucketsFromMap(countsByDay, opts.windowDays, opts.now);
+ return {
+ windowDays: opts.windowDays,
+ buckets,
+ totalEvents: buckets.reduce((sum, bucket) => sum + bucket.count, 0),
+ impactedUsers: 0,
+ impactedUsersAvailable: false,
+ };
+}
+
+export function buildIncidentStatsFromActivityRows(
+ rows: IncidentStatsActivityRow[],
+ opts: BuildIncidentStatsFromIssuesOptions,
+): IncidentStats {
+ const countsByDay = new Map();
+ for (const row of rows) {
+ countsByDay.set(row.day, (countsByDay.get(row.day) ?? 0) + Number(row.count));
+ }
+
+ const buckets = buildBucketsFromMap(countsByDay, opts.windowDays, opts.now);
+ return {
+ windowDays: opts.windowDays,
+ buckets,
+ totalEvents: buckets.reduce((sum, bucket) => sum + bucket.count, 0),
+ impactedUsers: 0,
+ impactedUsersAvailable: false,
+ };
+}
+
+export function buildIncidentStatsPairs(
+ issues: IncidentStatsFingerprintIssue[],
+ spanNamesBySample: Map,
+): IncidentStatsPairs {
+ const pairs: IncidentStatsPairs = {
+ namedSpanServices: [],
+ namedSpanNames: [],
+ namedSpanExcTypes: [],
+ unnamedSpanServices: [],
+ unnamedSpanExcTypes: [],
+ logServices: [],
+ logExcTypes: [],
+ };
+ const seenNamedSpan = new Set();
+ const seenUnnamedSpan = new Set();
+ const seenLog = new Set();
+
+ for (const issue of issues) {
+ const svc = issue.service ?? "";
+ const et = issue.exceptionType ?? "";
+ if (issue.kind === "log") {
+ const key = pairKey(svc, et);
+ if (seenLog.has(key)) continue;
+ seenLog.add(key);
+ pairs.logServices.push(svc);
+ pairs.logExcTypes.push(et);
+ continue;
+ }
+
+ const spanName = spanNameForIssue(issue, spanNamesBySample);
+ if (spanName) {
+ const key = pairKey(svc, spanName, et);
+ if (seenNamedSpan.has(key)) continue;
+ seenNamedSpan.add(key);
+ pairs.namedSpanServices.push(svc);
+ pairs.namedSpanNames.push(spanName);
+ pairs.namedSpanExcTypes.push(et);
+ continue;
+ }
+
+ const key = pairKey(svc, et);
+ if (seenUnnamedSpan.has(key)) continue;
+ seenUnnamedSpan.add(key);
+ pairs.unnamedSpanServices.push(svc);
+ pairs.unnamedSpanExcTypes.push(et);
+ }
+
+ return pairs;
+}
+
+export async function buildIncidentStatsWithFallback(
+ opts: BuildIncidentStatsWithFallbackOptions,
+): Promise {
+ const controller = new AbortController();
+ let timedOut = false;
+ let timer: NodeJS.Timeout | null = null;
+ const timeout = new Promise((_, reject) => {
+ timer = setTimeout(() => {
+ timedOut = true;
+ controller.abort();
+ reject(new Error("incident stats telemetry timed out"));
+ }, opts.timeoutMs);
+ });
+
+ try {
+ return await Promise.race([opts.loadTelemetry(controller.signal), timeout]);
+ } catch (err) {
+ opts.onTelemetryUnavailable?.(timedOut ? "timeout" : "error", err);
+ return opts.fallback;
+ } finally {
+ if (timer) clearTimeout(timer);
+ }
+}
+
+export function buildBucketsFromMap(
+ countsByDay: Map,
+ windowDays: number,
+ now = new Date(),
+): { day: string; count: number }[] {
+ const buckets: { day: string; count: number }[] = [];
+ const today = startOfUtcDay(now);
+ for (let i = windowDays - 1; i >= 0; i--) {
+ const d = new Date(today.getTime() - i * 24 * 60 * 60 * 1000);
+ const key = toUtcDay(d);
+ buckets.push({ day: key, count: countsByDay.get(key) ?? 0 });
+ }
+ return buckets;
+}
+
+function startOfUtcDay(value: Date): Date {
+ const out = new Date(value);
+ out.setUTCHours(0, 0, 0, 0);
+ return out;
+}
+
+function toUtcDay(value: Date): string {
+ return value.toISOString().slice(0, 10);
+}
+
+function spanNameForIssue(
+ issue: IncidentStatsFingerprintIssue,
+ spanNamesBySample: Map,
+): string | null {
+ if (issue.lastSample?.spanName) return issue.lastSample.spanName;
+ const traceId = issue.lastSample?.traceId;
+ const spanId = issue.lastSample?.spanId;
+ if (!traceId || !spanId) return null;
+ return spanNamesBySample.get(spanSampleKey(traceId, spanId)) ?? null;
+}
+
+export function spanSampleKey(traceId: string, spanId: string): string {
+ return `${traceId}:${spanId}`;
+}
+
+function pairKey(...parts: string[]): string {
+ return parts.join("\u0001");
+}
diff --git a/superlog/apps/api/src/index.ts b/superlog/apps/api/src/index.ts
new file mode 100644
index 0000000000000000000000000000000000000000..d197a800a9ba1942c1b4de662cdffb7073a24920
--- /dev/null
+++ b/superlog/apps/api/src/index.ts
@@ -0,0 +1,2956 @@
+import "./env.js";
+import "./net.js";
+import { createClient } from "@clickhouse/client";
+import { serve } from "@hono/node-server";
+import { SpanStatusCode, trace } from "@opentelemetry/api";
+import {
+ DEFAULT_AGENT_RUN_PROVIDER,
+ type Issue,
+ confirmResolutionProposal,
+ createIncidentLifecycle,
+ db,
+ dismissResolutionProposal,
+ isAgentRunProvider,
+ listAccessibleGithubInstallsForProject,
+ mintApiKey,
+ resolveIncident,
+ runMigrations,
+ schema,
+ sendLoopsWelcomeFlow,
+ upsertLoopsContact,
+} from "@superlog/db";
+import { fingerprint, fingerprintLog } from "@superlog/fingerprint";
+import { Autumn, AutumnError } from "autumn-js";
+import { and, asc, count, desc, eq, inArray, isNotNull, isNull, ne, or } from "drizzle-orm";
+import { Hono } from "hono";
+import type { Context } from "hono";
+import { cors } from "hono/cors";
+import { HTTPException } from "hono/http-exception";
+import { nanoid } from "nanoid";
+import { mountAlerts } from "./alerts.js";
+import { auth } from "./auth.js";
+import { shouldRunMigrationsOnBoot } from "./boot-migrations.js";
+import { mountDashboards } from "./dashboards.js";
+import { mountFeedbackAuthed, mountFeedbackPublic } from "./feedback.js";
+import { type GatewayVars, mountGateway } from "./gateway.js";
+import { prBaseBranchExists } from "./github-branches.js";
+import {
+ closeAgentPullRequestOnGithub,
+ listProjectRepoBranches,
+ mergeGithubPullRequest,
+ mountGithubAuthed,
+ mountGithubAuthorOAuth,
+ mountGithubPublic,
+} from "./github.js";
+import { createApiHttpObservabilityMiddleware } from "./http-observability.js";
+import { mountImpersonation } from "./impersonation.js";
+import { buildIncidentListItem, shouldInlineIncidentListStats } from "./incidents/list.js";
+import { getPrDeliveryRetryEligibility } from "./incidents/pr-retry.js";
+import { buildIncidentPullRequestViews } from "./incidents/pr-view.js";
+import { runResolvedIncidentSideEffectsForIncident } from "./incidents/resolution-side-effects.js";
+import {
+ buildIncidentStatsFromActivityRows,
+ buildIncidentStatsFromIssues,
+ buildIncidentStatsPairs,
+ buildIncidentStatsWithFallback,
+ spanSampleKey,
+} from "./incidents/stats.js";
+import { mountLinearAuthed, mountLinearPublic } from "./linear.js";
+import { logger } from "./logger.js";
+import { mountManagementApi, mountOrgKeyManagementAuthed } from "./management.js";
+import {
+ METRIC_AGGREGATIONS,
+ type MetricAggregation,
+ type ResourceAttrFilter,
+ type SeriesSource,
+ countSeries,
+ getTraceDetail,
+ listAttributeKeys,
+ listAttributeValues,
+ listIssueFilterAttributeKeys,
+ listIssueFilterAttributeValues,
+ listMetricNames,
+ listServices,
+ metricSeries,
+ pickStep,
+ previewIssueFilterMatches,
+ queryLogs,
+ queryMetrics,
+ queryTraces,
+ queryTracesAggregated,
+} from "./mcp/clickhouse.js";
+import { mountMcpAuthed, mountMcpPublic } from "./mcp/index.js";
+import { resolveActiveOrgContext, resolveMaybeActiveOrgContext } from "./org-context.js";
+import { mountSettingsAuthed } from "./settings.js";
+import { normalizeSignupIntentKeyHash, normalizeSignupIntentKeyPrefix } from "./signup-intents.js";
+import { mountSlackAuthed, mountSlackPublic } from "./slack.js";
+import { sourceMapObjectStoreFromEnv } from "./sourcemaps.js";
+import { userIsStaff } from "./staff.js";
+import { symbolicateIssueSample, symbolicateTelemetrySample } from "./symbolication.js";
+import { buildSystemCapabilities } from "./system-capabilities.js";
+import { mountWebhooks } from "./webhooks.js";
+
+const PORT = Number(process.env.PORT ?? 4100);
+const WEB_ORIGIN = process.env.WEB_ORIGIN ?? "http://localhost:5173";
+const WEB_CORS_ORIGINS = Array.from(
+ new Set([WEB_ORIGIN, "http://localhost:5173", "http://127.0.0.1:5173"]),
+);
+
+const ch = createClient({
+ url: process.env.CLICKHOUSE_URL ?? "http://localhost:8123",
+ database: process.env.CLICKHOUSE_DB ?? "superlog",
+ username: process.env.CLICKHOUSE_USER ?? "default",
+ password: process.env.CLICKHOUSE_PASSWORD ?? "",
+ // idle_socket_ttl must stay below CH server's keep_alive_timeout (3s default)
+ // so we recycle before the server closes; request_timeout short-circuits
+ // stale sockets that slipped through instead of hanging 30s.
+ request_timeout: 10_000,
+ keep_alive: { enabled: true, idle_socket_ttl: 2_500 },
+});
+
+const tracer = trace.getTracer("@superlog/api");
+const SIGNUP_INTENT_TTL_MS = 30 * 60 * 1000;
+
+type Vars = {
+ userId: string;
+ orgId: string | null;
+ impersonating?: boolean;
+} & Partial;
+const app = new Hono<{ Variables: Vars }>();
+const incidentLifecycle = createIncidentLifecycle(db);
+const sourceMapObjectStore = sourceMapObjectStoreFromEnv(process.env);
+
+app.use(
+ "/api/*",
+ cors({
+ origin: WEB_CORS_ORIGINS,
+ credentials: true,
+ allowHeaders: [
+ "authorization",
+ "content-type",
+ "traceparent",
+ "tracestate",
+ "x-superlog-signup-source",
+ ],
+ allowMethods: ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"],
+ }),
+);
+
+app.use("/api/*", createApiHttpObservabilityMiddleware());
+
+app.use(
+ "/mcp",
+ cors({
+ origin: "*",
+ allowHeaders: ["authorization", "content-type", "mcp-session-id", "mcp-protocol-version"],
+ allowMethods: ["GET", "POST", "DELETE", "OPTIONS"],
+ exposeHeaders: ["mcp-session-id", "www-authenticate"],
+ }),
+);
+
+app.use(
+ "/oauth/*",
+ cors({
+ origin: "*",
+ allowHeaders: ["content-type"],
+ allowMethods: ["GET", "POST", "OPTIONS"],
+ }),
+);
+
+app.use(
+ "/.well-known/*",
+ cors({
+ origin: "*",
+ allowMethods: ["GET", "OPTIONS"],
+ }),
+);
+
+app.use(
+ "/activate/*",
+ cors({
+ origin: WEB_CORS_ORIGINS,
+ credentials: true,
+ allowHeaders: ["authorization", "content-type", "traceparent", "tracestate"],
+ allowMethods: ["POST", "OPTIONS"],
+ }),
+);
+
+// /feedback/pr/* is the anonymous PR-link surface — the web app posts to
+// it cross-origin from /feedback/pr/:owner/:repo/:num. Credentials are
+// included so a signed-in user is still attributed to their account.
+app.use(
+ "/feedback/*",
+ cors({
+ origin: WEB_CORS_ORIGINS,
+ credentials: true,
+ allowHeaders: ["content-type", "traceparent", "tracestate"],
+ allowMethods: ["POST", "OPTIONS"],
+ }),
+);
+
+app.get("/health", (c) => c.json({ ok: true }));
+
+// Tells the web client which social providers have credentials configured
+// in this environment so it can hide the buttons that would 503 on click.
+app.get("/api/auth-providers", (c) =>
+ c.json({
+ google: !!(process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET),
+ github: !!(process.env.GITHUB_CLIENT_ID && process.env.GITHUB_CLIENT_SECRET),
+ }),
+);
+
+app.get("/api/system/capabilities", (c) => c.json(buildSystemCapabilities()));
+
+app.post("/api/signup-intents", async (c) => {
+ const body = (await c.req.json().catch(() => ({}))) as {
+ keyHash?: unknown;
+ keyPrefix?: unknown;
+ returnTo?: unknown;
+ };
+ const keyHash = normalizeSignupIntentKeyHash(body.keyHash);
+ const keyPrefix = normalizeSignupIntentKeyPrefix(body.keyPrefix);
+ if (!keyHash || !keyPrefix) {
+ throw new HTTPException(400, { message: "valid keyHash and keyPrefix required" });
+ }
+ const returnTo =
+ typeof body.returnTo === "string" && body.returnTo.length <= 1024 ? body.returnTo : null;
+
+ const intentId = `sui_${nanoid(24)}`;
+ const expiresAt = new Date(Date.now() + SIGNUP_INTENT_TTL_MS);
+ await db.insert(schema.signupIntents).values({
+ id: intentId,
+ keyHash,
+ keyPrefix,
+ returnTo,
+ expiresAt,
+ });
+
+ const signupUrl = new URL(`${WEB_ORIGIN}/signup`);
+ signupUrl.searchParams.set("from", "skill");
+ signupUrl.searchParams.set("intent", intentId);
+
+ return c.json({
+ id: intentId,
+ signupUrl: signupUrl.toString(),
+ expiresAt: expiresAt.toISOString(),
+ });
+});
+
+// Better Auth handles its own routes under /api/auth/*. Mount before the
+// session middleware so sign-in/sign-up/oauth-callback endpoints don't trip
+// the unauthenticated guard.
+app.on(["POST", "GET"], "/api/auth/*", (c) => auth.handler(c.req.raw));
+
+mountGateway(app, ch);
+mountGithubPublic(app);
+mountGithubAuthorOAuth(app);
+mountLinearPublic(app);
+mountMcpPublic(app, ch);
+mountSlackPublic(app);
+mountFeedbackPublic(app);
+// Management API (org-key auth, /api/v1/*) registers its own middleware
+// before the session middleware below — the session middleware skips
+// /api/v1/* and /api/auth/*.
+mountManagementApi(app, { ch });
+
+app.use("/api/*", async (c, next) => {
+ if (c.req.path.startsWith("/api/v1/")) return next();
+ if (c.req.path.startsWith("/api/auth/")) return next();
+ if (c.req.path === "/api/auth-providers") return next();
+ const session = await auth.api.getSession({ headers: c.req.raw.headers });
+ if (!session) return c.json({ error: "unauthenticated" }, 401);
+ c.set("userId", session.user.id);
+ c.set("orgId", session.session.activeOrganizationId ?? null);
+ // Better Auth's admin plugin sets impersonatedBy on the session row when an
+ // admin is acting as another user; surface it on `c.var` so /api/me can
+ // expose a boolean without the web client having to inspect raw session.
+ c.set("impersonating", typeof session.session.impersonatedBy === "string");
+ await next();
+});
+
+mountMcpAuthed(app);
+mountGithubAuthed(app);
+mountLinearAuthed(app);
+mountSlackAuthed(app);
+mountSettingsAuthed(app);
+mountOrgKeyManagementAuthed(app);
+mountDashboards(app);
+mountAlerts(app, { ch });
+mountWebhooks(app);
+mountFeedbackAuthed(app);
+mountImpersonation(app);
+
+const ALLOWED_SIGNUP_SOURCES = new Set(["skill", "web", "mcp", "github", "cli"]);
+
+app.get("/api/me", async (c) => {
+ const ctx = await resolveMaybeActiveOrgContext({
+ userId: c.var.userId,
+ preferredOrgId: c.var.orgId,
+ });
+ const { user } = ctx;
+ // Whether billing hard-blocks (ingest 402 / investigation cap) are enforced.
+ // Metering runs regardless; this only gates blocking, so the web can avoid
+ // showing an "Ingest paused" bar when nothing is actually being blocked.
+ const billingEnforcement = process.env.BILLING_ENFORCEMENT_ENABLED === "true";
+
+ // Pre-org users (just signed up, haven't created their first org yet) get a
+ // null org/project so the web client can route them to the create-org step
+ // in the onboarding wizard.
+ if (!ctx.org) {
+ return c.json({
+ user: {
+ id: user.id,
+ email: user.email,
+ name: user.name,
+ isStaff: userIsStaff(user.role),
+ impersonating: c.var.impersonating === true,
+ },
+ org: null,
+ project: null,
+ billingEnforcement,
+ });
+ }
+
+ const { org, project } = ctx;
+
+ if (!org.signupSource) {
+ const headerSource = c.req.header("x-superlog-signup-source")?.trim().toLowerCase();
+ if (headerSource && ALLOWED_SIGNUP_SOURCES.has(headerSource)) {
+ const updated = await db
+ .update(schema.orgs)
+ .set({ signupSource: headerSource })
+ .where(and(eq(schema.orgs.id, org.id), isNull(schema.orgs.signupSource)))
+ .returning({ signupSource: schema.orgs.signupSource });
+ if (updated[0]?.signupSource) org.signupSource = updated[0].signupSource;
+ }
+ }
+
+ void upsertLoopsContact({
+ user,
+ org,
+ project,
+ signupSource: org.signupSource,
+ appUrl: WEB_ORIGIN,
+ }).catch((err) => {
+ logger.warn({ err, userId: user.id, orgId: org.id }, "loops contact sync failed");
+ });
+
+ const accessibleInstalls = await listAccessibleGithubInstallsForProject(project.id);
+ const githubSetupNeeded = accessibleInstalls.length === 0 && !org.githubSetupSkippedAt;
+
+ // `hasIngested` decides whether OnboardingGate shows the install wizard. Reading
+ // it from api_keys.last_used_at (set by proxy/src/index.ts on every successful
+ // auth) avoids 6 ClickHouse count() queries per page load — the previous gate
+ // derived this from /stats and was firing on every navigation forever.
+ const ingestedKey = await db.query.apiKeys.findFirst({
+ columns: { id: true },
+ where: and(eq(schema.apiKeys.projectId, project.id), isNotNull(schema.apiKeys.lastUsedAt)),
+ });
+ const hasIngested = ingestedKey !== undefined;
+
+ return c.json({
+ user: {
+ id: user.id,
+ email: user.email,
+ name: user.name,
+ isStaff: userIsStaff(user.role),
+ impersonating: c.var.impersonating === true,
+ },
+ org: { id: org.id, name: org.name, slug: org.slug, githubSetupNeeded },
+ project: { id: project.id, name: project.name, slug: project.slug, hasIngested },
+ billingEnforcement,
+ });
+});
+
+app.put("/api/me/active-project", async (c) => {
+ const { user, org } = await resolveActiveOrgContext({
+ userId: c.var.userId,
+ preferredOrgId: c.var.orgId,
+ });
+ const body = (await c.req.json().catch(() => ({}))) as { projectId?: unknown };
+ const projectId = typeof body.projectId === "string" ? body.projectId : null;
+ if (!projectId) throw new HTTPException(400, { message: "projectId required" });
+
+ const project = await db.query.projects.findFirst({
+ where: and(eq(schema.projects.id, projectId), eq(schema.projects.orgId, org.id)),
+ });
+ if (!project) throw new HTTPException(404, { message: "project not found in active org" });
+
+ await db
+ .update(schema.users)
+ .set({ activeProjectId: project.id })
+ .where(eq(schema.users.id, user.id));
+
+ return c.json({ project: { id: project.id, name: project.name, slug: project.slug } });
+});
+
+const ORG_NAME_MAX = 80;
+
+function slugifyOrgName(value: string): string {
+ return value
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, "-")
+ .replace(/^-+|-+$/g, "")
+ .slice(0, 40);
+}
+
+async function uniqueOrgSlug(client: Pick, base: string): Promise {
+ const seed = base || "org";
+ let candidate = seed;
+ for (let attempt = 0; attempt < 20; attempt += 1) {
+ const existing = await client.query.orgs.findFirst({ where: eq(schema.orgs.slug, candidate) });
+ if (!existing) return candidate;
+ candidate = `${seed.slice(0, 32)}-${nanoid(6).toLowerCase()}`;
+ }
+ return `${seed.slice(0, 20)}-${nanoid(12).toLowerCase()}`;
+}
+
+// First-org creation. Called by the onboarding wizard's create-org step for
+// users that signed up but don't have a membership yet. Idempotent on retry:
+// if the user already has an org, returns it instead of creating a duplicate.
+app.post("/api/me/orgs", async (c) => {
+ const body = (await c.req.json().catch(() => ({}))) as { name?: unknown };
+ const rawName = typeof body.name === "string" ? body.name.trim() : "";
+ if (!rawName) throw new HTTPException(400, { message: "name required" });
+ if (rawName.length > ORG_NAME_MAX) {
+ throw new HTTPException(400, { message: `name must be ${ORG_NAME_MAX} chars or fewer` });
+ }
+
+ const userId = c.var.userId;
+ type WelcomePayload = {
+ user: { id: string; email: string };
+ org: typeof schema.orgs.$inferSelect;
+ project: typeof schema.projects.$inferSelect;
+ };
+
+ const result = await db.transaction(
+ async (
+ tx,
+ ): Promise<{
+ payload: {
+ org: { id: string; name: string; slug: string };
+ project: { id: string; name: string; slug: string };
+ };
+ welcome: WelcomePayload | null;
+ }> => {
+ // Serializes first-org creation for a user. Without this lock, two
+ // concurrent create-org requests can both see no membership and create two
+ // organizations before either membership row exists.
+ const [user] = await tx
+ .select({ id: schema.users.id, email: schema.users.email })
+ .from(schema.users)
+ .where(eq(schema.users.id, userId))
+ .for("update");
+ if (!user) throw new HTTPException(404, { message: "user not found" });
+
+ const existingMembership = await tx.query.orgMembers.findFirst({
+ where: eq(schema.orgMembers.userId, userId),
+ });
+ if (existingMembership) {
+ const org = await tx.query.orgs.findFirst({
+ where: eq(schema.orgs.id, existingMembership.orgId),
+ });
+ if (!org)
+ throw new HTTPException(500, { message: "org membership references missing org" });
+ let project = await tx.query.projects.findFirst({
+ where: eq(schema.projects.orgId, org.id),
+ });
+ if (!project) {
+ const [createdProject] = await tx
+ .insert(schema.projects)
+ .values({ orgId: org.id, name: "Default", slug: "default" })
+ .returning();
+ if (!createdProject) {
+ throw new HTTPException(500, { message: "failed to create default project" });
+ }
+ project = createdProject;
+ await tx
+ .insert(schema.projectAutomationSettings)
+ .values({ projectId: project.id })
+ .onConflictDoNothing({ target: schema.projectAutomationSettings.projectId });
+ }
+ return {
+ payload: {
+ org: { id: org.id, name: org.name, slug: org.slug },
+ project: { id: project.id, name: project.name, slug: project.slug },
+ },
+ welcome: null,
+ };
+ }
+
+ const slug = await uniqueOrgSlug(tx, slugifyOrgName(rawName));
+ const [org] = await tx.insert(schema.orgs).values({ name: rawName, slug }).returning();
+ if (!org) throw new HTTPException(500, { message: "failed to create org" });
+
+ await tx
+ .insert(schema.orgMembers)
+ .values({ orgId: org.id, userId, role: "owner" })
+ .onConflictDoNothing({ target: [schema.orgMembers.orgId, schema.orgMembers.userId] });
+
+ const [project] = await tx
+ .insert(schema.projects)
+ .values({ orgId: org.id, name: "Default", slug: "default" })
+ .returning();
+ if (!project) throw new HTTPException(500, { message: "failed to create default project" });
+
+ await tx
+ .insert(schema.projectAutomationSettings)
+ .values({ projectId: project.id })
+ .onConflictDoNothing({ target: schema.projectAutomationSettings.projectId });
+
+ // Promote the new org to active on every session this user has open, so the
+ // next /api/me call returns it without requiring a sign-out/sign-in round trip.
+ await tx
+ .update(schema.sessions)
+ .set({ activeOrganizationId: org.id })
+ .where(
+ and(eq(schema.sessions.userId, userId), isNull(schema.sessions.activeOrganizationId)),
+ );
+
+ return {
+ payload: {
+ org: { id: org.id, name: org.name, slug: org.slug },
+ project: { id: project.id, name: project.name, slug: project.slug },
+ },
+ welcome: { user, org, project },
+ };
+ },
+ );
+
+ if (result.welcome) {
+ void sendLoopsWelcomeFlow({
+ user: result.welcome.user,
+ org: result.welcome.org,
+ project: result.welcome.project,
+ signupSource: result.welcome.org.signupSource,
+ appUrl: WEB_ORIGIN,
+ }).catch((err) => {
+ logger.warn({ err, userId, orgId: result.welcome?.org.id }, "loops welcome flow failed");
+ });
+ }
+
+ return c.json(result.payload);
+});
+
+app.post("/api/signup-intents/:intentId/claim", async (c) => {
+ const intentId = c.req.param("intentId");
+ const body = (await c.req.json().catch(() => ({}))) as { projectId?: unknown };
+ const projectId = typeof body.projectId === "string" ? body.projectId : null;
+ if (!projectId) throw new HTTPException(400, { message: "projectId required" });
+
+ const { user, org } = await resolveActiveOrgContext({
+ userId: c.var.userId,
+ preferredOrgId: c.var.orgId,
+ });
+ const project = await db.query.projects.findFirst({
+ where: and(eq(schema.projects.id, projectId), eq(schema.projects.orgId, org.id)),
+ });
+ if (!project) throw new HTTPException(404, { message: "project not found in active org" });
+
+ const claimed = await db.transaction(async (tx) => {
+ const intent = await tx.query.signupIntents.findFirst({
+ where: eq(schema.signupIntents.id, intentId),
+ });
+ if (!intent) throw new HTTPException(404, { message: "signup intent not found" });
+ if (intent.expiresAt < new Date()) {
+ logger.warn(
+ {
+ intent_id: intentId,
+ project_id: projectId,
+ user_id: c.var.userId,
+ expires_at: intent.expiresAt.toISOString(),
+ age_ms: Date.now() - intent.expiresAt.getTime(),
+ },
+ "signup intent expired",
+ );
+ throw new HTTPException(410, { message: "signup intent expired" });
+ }
+ if (intent.consumedAt) {
+ if (intent.claimedProjectId === project.id) {
+ return {
+ id: null,
+ keyPrefix: intent.keyPrefix,
+ returnTo: intent.returnTo,
+ alreadyClaimed: true,
+ };
+ }
+ throw new HTTPException(409, { message: "signup intent already claimed" });
+ }
+
+ const existingKey = await tx.query.apiKeys.findFirst({
+ where: eq(schema.apiKeys.keyHash, intent.keyHash),
+ });
+ if (existingKey && existingKey.projectId !== project.id) {
+ throw new HTTPException(409, {
+ message: "signup intent key already belongs to another project",
+ });
+ }
+
+ const [key] = existingKey
+ ? [existingKey]
+ : await tx
+ .insert(schema.apiKeys)
+ .values({
+ projectId: project.id,
+ name: "Skill onboarding",
+ keyHash: intent.keyHash,
+ keyPrefix: intent.keyPrefix,
+ })
+ .returning();
+ if (!key) throw new Error("failed to claim signup intent key");
+
+ await tx
+ .update(schema.signupIntents)
+ .set({
+ consumedAt: new Date(),
+ claimedProjectId: project.id,
+ claimedByUserId: user.id,
+ })
+ .where(eq(schema.signupIntents.id, intent.id));
+
+ return {
+ id: key.id,
+ keyPrefix: key.keyPrefix,
+ returnTo: intent.returnTo,
+ alreadyClaimed: false,
+ };
+ });
+
+ return c.json(claimed);
+});
+
+app.post("/api/projects/:projectId/keys", async (c) => {
+ const projectId = c.req.param("projectId");
+ await requireProjectAccess(c, projectId);
+ return tracer.startActiveSpan("apikey.create", async (span) => {
+ span.setAttribute("tenant.project_id", projectId);
+ try {
+ const body = (await c.req.json().catch(() => ({}))) as { name?: string };
+ const name = body.name?.trim() || "new key";
+ span.setAttribute("apikey.name", name);
+ const minted = await mintApiKey({ projectId, name });
+ span.setAttribute("apikey.prefix", minted.keyPrefix);
+ span.setAttribute("apikey.id", minted.id);
+ return c.json({
+ id: minted.id,
+ name: minted.name,
+ keyPrefix: minted.keyPrefix,
+ createdAt: minted.createdAt,
+ plaintext: minted.plaintext,
+ });
+ } catch (err) {
+ span.recordException(err as Error);
+ span.setStatus({ code: SpanStatusCode.ERROR, message: (err as Error).message });
+ throw err;
+ } finally {
+ span.end();
+ }
+ });
+});
+
+app.get("/api/projects/:projectId/keys", async (c) => {
+ const projectId = c.req.param("projectId");
+ await requireProjectAccess(c, projectId);
+ const rows = await db.query.apiKeys.findMany({
+ where: eq(schema.apiKeys.projectId, projectId),
+ });
+ return c.json(
+ rows.map((k) => ({
+ id: k.id,
+ name: k.name,
+ keyPrefix: k.keyPrefix,
+ lastUsedAt: k.lastUsedAt,
+ revokedAt: k.revokedAt,
+ createdAt: k.createdAt,
+ })),
+ );
+});
+
+app.delete("/api/projects/:projectId/keys/:keyId", async (c) => {
+ const projectId = c.req.param("projectId");
+ const keyId = c.req.param("keyId");
+ await requireProjectAccess(c, projectId);
+ await db
+ .update(schema.apiKeys)
+ .set({ revokedAt: new Date() })
+ .where(and(eq(schema.apiKeys.id, keyId), eq(schema.apiKeys.projectId, projectId)));
+ return c.json({ ok: true });
+});
+
+// Reports whether the user has connected at least one MCP client to this
+// project. Used by the dashboard's setup-todos to hide the "Install the MCP
+// server" card once a token exists. Counts non-revoked tokens whose refresh
+// is still valid (or never expires) — clients with only stale access tokens
+// will still re-auth, so we don't gate on `accessExpiresAt`.
+app.get("/api/projects/:projectId/mcp-status", async (c) => {
+ const projectId = c.req.param("projectId");
+ await requireProjectAccess(c, projectId);
+ const rows = await db
+ .select({ id: schema.mcpOauthTokens.id })
+ .from(schema.mcpOauthTokens)
+ .where(
+ and(eq(schema.mcpOauthTokens.projectId, projectId), isNull(schema.mcpOauthTokens.revokedAt)),
+ )
+ .limit(1);
+ return c.json({ connected: rows.length > 0 });
+});
+
+app.get("/api/projects/:projectId/stats", async (c) => {
+ const projectId = c.req.param("projectId");
+ await requireProjectAccess(c, projectId);
+ const [traces, logs, metrics, issueRows] = await Promise.all([
+ chCount("otel_traces", "Timestamp", projectId),
+ chCount("otel_logs", "Timestamp", projectId),
+ chMetricsCount(projectId),
+ db.select({ c: count() }).from(schema.issues).where(eq(schema.issues.projectId, projectId)),
+ ]);
+ return c.json({
+ window: "1h",
+ traces,
+ logs,
+ metrics,
+ issues: Number(issueRows[0]?.c ?? 0),
+ });
+});
+
+// The Autumn SDK throws a typed AutumnError carrying the HTTP `statusCode` and
+// raw `body` — use it directly (no casts) and match the specific 409
+// "plan_already_attached" conflict code in the body.
+function isPlanAlreadyAttached(err: unknown): boolean {
+ return (
+ err instanceof AutumnError &&
+ err.statusCode === 409 &&
+ err.body.includes("plan_already_attached")
+ );
+}
+
+// Immediate "switch to Free" — per Autumn's guidance, attach the Free plan now
+// (planSchedule "immediate") and carry usage over, rather than cancelling. Free
+// is the auto_enable default, so attaching it when already on Free returns a
+// "plan_already_attached" conflict, which we treat as a no-op success. Per-org
+// (org = Autumn customer); the carry-over closes the toggle-to-reset loophole.
+app.post("/api/me/billing/cancel", async (c) => {
+ const ctx = await resolveMaybeActiveOrgContext({
+ userId: c.var.userId,
+ preferredOrgId: c.var.orgId,
+ });
+ if (!ctx.org) throw new HTTPException(400, { message: "no active org" });
+ const key = process.env.AUTUMN_SECRET_KEY;
+ if (!key) throw new HTTPException(400, { message: "billing is not configured" });
+ const orgId = ctx.org.id;
+ // "Switch to Free" — per Autumn's guidance, attach the Free plan immediately
+ // and carry usage over, rather than cancelling the paid plan. When a paid
+ // plan is active this is a real downgrade: planSchedule:"immediate" applies it
+ // now and carryOverUsages preserves the org's metered usage (spans / logs /
+ // metric points / investigation credits) so a maxed cap can't be reset by
+ // toggling paid↔Free. (Cancelling instead spins up a fresh Free entitlement
+ // with usage 0 — the reset we were fighting; and billing.update has no
+ // carry-over option.) If the org is ALREADY on Free (Free is the auto_enable
+ // default), the attach 409s "plan_already_attached" — that's a no-op success.
+ const autumn = new Autumn({ secretKey: key });
+ try {
+ await autumn.billing.attach({
+ customerId: orgId,
+ planId: "free",
+ planSchedule: "immediate",
+ carryOverUsages: { enabled: true },
+ });
+ } catch (err) {
+ // Only the "already on Free" conflict is a no-op success; surface any other
+ // error so a genuine failure isn't reported as a successful switch.
+ if (isPlanAlreadyAttached(err)) {
+ return c.json({ ok: true });
+ }
+ throw err;
+ }
+ return c.json({ ok: true });
+});
+
+app.get("/api/projects/:projectId/explore/attribute-keys", async (c) => {
+ const projectId = c.req.param("projectId");
+ await requireProjectAccess(c, projectId);
+ const { since, until } = parseRangeQuery(c);
+ const rows = await listAttributeKeys(
+ ch,
+ projectId,
+ { since, until },
+ parseExploreAttributeSource(c),
+ );
+ return c.json(rows);
+});
+
+app.get("/api/projects/:projectId/explore/attribute-values", async (c) => {
+ const projectId = c.req.param("projectId");
+ await requireProjectAccess(c, projectId);
+ const key = c.req.query("key");
+ if (!key) throw new HTTPException(400, { message: "key is required" });
+ const { since, until } = parseRangeQuery(c);
+ const rows = await listAttributeValues(
+ ch,
+ projectId,
+ key,
+ { since, until },
+ 200,
+ parseExploreAttributeSource(c),
+ );
+ return c.json(rows);
+});
+
+app.get("/api/projects/:projectId/explore/services", async (c) => {
+ const projectId = c.req.param("projectId");
+ await requireProjectAccess(c, projectId);
+ const { since, until } = parseRangeQuery(c);
+ const services = await listServices(ch, projectId, { since, until });
+ return c.json(services);
+});
+
+app.post("/api/projects/:projectId/explore/logs", async (c) => {
+ const projectId = c.req.param("projectId");
+ await requireProjectAccess(c, projectId);
+ return tracer.startActiveSpan("explore.query_logs", async (span) => {
+ span.setAttribute("tenant.project_id", projectId);
+ try {
+ const body = (await c.req.json().catch(() => ({}))) as ExploreListBody;
+ const limit = clampLimit(body.limit, 100, 500);
+ span.setAttribute("explore.limit", limit);
+ if (body.service) span.setAttribute("explore.service", body.service);
+ if (body.severity) span.setAttribute("explore.severity", body.severity);
+ if (body.search) span.setAttribute("explore.has_search", true);
+ const rows = await queryLogs(ch, projectId, {
+ range: body.range,
+ service: body.service,
+ severity: body.severity,
+ search: body.search,
+ resourceAttrs: normalizeAttrs(body.resourceAttrs),
+ limit,
+ });
+ span.setAttribute("explore.row_count", rows.length);
+ return c.json(rows);
+ } catch (err) {
+ span.recordException(err as Error);
+ span.setStatus({ code: SpanStatusCode.ERROR, message: (err as Error).message });
+ throw err;
+ } finally {
+ span.end();
+ }
+ });
+});
+
+app.post("/api/projects/:projectId/explore/traces", async (c) => {
+ const projectId = c.req.param("projectId");
+ await requireProjectAccess(c, projectId);
+ const body = (await c.req.json().catch(() => ({}))) as ExploreListBody;
+ const limit = clampLimit(body.limit, 100, 500);
+ const rows = await queryTraces(ch, projectId, {
+ range: body.range,
+ service: body.service,
+ spanName: body.spanName,
+ statusCode: body.statusCode,
+ minDurationMs: body.minDurationMs,
+ resourceAttrs: normalizeAttrs(body.resourceAttrs),
+ limit,
+ });
+ return c.json(rows);
+});
+
+app.post("/api/projects/:projectId/explore/traces-aggregated", async (c) => {
+ const projectId = c.req.param("projectId");
+ await requireProjectAccess(c, projectId);
+ const body = (await c.req.json().catch(() => ({}))) as ExploreListBody;
+ const limit = clampLimit(body.limit, 100, 500);
+ const rows = await queryTracesAggregated(ch, projectId, {
+ range: body.range,
+ service: body.service,
+ spanName: body.spanName,
+ statusCode: body.statusCode,
+ minDurationMs: body.minDurationMs,
+ resourceAttrs: normalizeAttrs(body.resourceAttrs),
+ limit,
+ });
+ return c.json(rows);
+});
+
+app.get("/api/projects/:projectId/explore/traces/:traceId", async (c) => {
+ const projectId = c.req.param("projectId");
+ const traceId = c.req.param("traceId");
+ await requireProjectAccess(c, projectId);
+ if (!/^[a-fA-F0-9]{1,64}$/.test(traceId)) {
+ throw new HTTPException(400, { message: "invalid trace id" });
+ }
+ const detail = await getTraceDetail(ch, projectId, traceId);
+ return c.json(detail);
+});
+
+app.post("/api/projects/:projectId/explore/series", async (c) => {
+ const projectId = c.req.param("projectId");
+ await requireProjectAccess(c, projectId);
+ const body = (await c.req.json().catch(() => ({}))) as ExploreSeriesBody;
+ const source: SeriesSource = body.source === "traces" ? "traces" : "logs";
+ const filter = body.filter ?? {};
+ const rangeSeconds = estimateRangeSeconds(filter.range);
+ const step = pickStep(rangeSeconds);
+ const rows = await countSeries(
+ ch,
+ projectId,
+ source,
+ {
+ range: filter.range,
+ service: filter.service,
+ resourceAttrs: normalizeAttrs(filter.resourceAttrs),
+ search: filter.search,
+ severity: filter.severity,
+ spanName: filter.spanName,
+ statusCode: filter.statusCode,
+ minDurationMs: filter.minDurationMs,
+ },
+ body.groupBy || undefined,
+ step,
+ );
+ return c.json({ step: `${step.n} ${step.unit}`, rows });
+});
+
+app.get("/api/projects/:projectId/explore/metric-names", async (c) => {
+ const projectId = c.req.param("projectId");
+ await requireProjectAccess(c, projectId);
+ const { since, until } = parseRangeQuery(c);
+ const names = await listMetricNames(ch, projectId, { since, until });
+ return c.json(names);
+});
+
+app.post("/api/projects/:projectId/explore/metric-series", async (c) => {
+ const projectId = c.req.param("projectId");
+ await requireProjectAccess(c, projectId);
+ const body = (await c.req.json().catch(() => ({}))) as MetricSeriesBody;
+ const metricName = typeof body.metricName === "string" ? body.metricName : "";
+ const filter = body.filter ?? {};
+ const rangeSeconds = estimateRangeSeconds(filter.range);
+ const step = pickStep(rangeSeconds);
+ const aggregation =
+ typeof body.aggregation === "string" &&
+ (METRIC_AGGREGATIONS as readonly string[]).includes(body.aggregation)
+ ? (body.aggregation as MetricAggregation)
+ : undefined;
+ const rows = await metricSeries(
+ ch,
+ projectId,
+ metricName,
+ {
+ range: filter.range,
+ service: filter.service,
+ resourceAttrs: normalizeAttrs(filter.resourceAttrs),
+ },
+ body.groupBy || undefined,
+ step,
+ aggregation,
+ );
+ return c.json({ step: `${step.n} ${step.unit}`, rows });
+});
+
+app.post("/api/projects/:projectId/explore/metrics", async (c) => {
+ const projectId = c.req.param("projectId");
+ await requireProjectAccess(c, projectId);
+ const body = (await c.req.json().catch(() => ({}))) as ExploreListBody & { metricName?: string };
+ const limit = clampLimit(body.limit, 100, 500);
+ const rows = await queryMetrics(ch, projectId, {
+ metricName: typeof body.metricName === "string" ? body.metricName : undefined,
+ range: body.range,
+ service: body.service,
+ resourceAttrs: normalizeAttrs(body.resourceAttrs),
+ limit,
+ });
+ return c.json(rows);
+});
+
+type MetricSeriesBody = {
+ metricName?: string;
+ groupBy?: string;
+ aggregation?: string;
+ filter?: Omit;
+};
+
+type ExploreListBody = {
+ range?: { since?: string; until?: string };
+ service?: string;
+ resourceAttrs?: ResourceAttrFilter[];
+ search?: string;
+ severity?: string;
+ spanName?: string;
+ statusCode?: string;
+ minDurationMs?: number;
+ limit?: number;
+};
+
+type ExploreSeriesBody = {
+ source?: SeriesSource;
+ groupBy?: string;
+ filter?: Omit;
+};
+
+function parseRangeQuery(c: Context<{ Variables: Vars }>): { since?: string; until?: string } {
+ const since = c.req.query("since");
+ const until = c.req.query("until");
+ return { since, until };
+}
+
+function parseExploreAttributeSource(
+ c: Context<{ Variables: Vars }>,
+): SeriesSource | "metrics" | undefined {
+ const source = c.req.query("source");
+ if (source === "logs" || source === "traces" || source === "metrics") return source;
+ return undefined;
+}
+
+function clampLimit(n: number | undefined, fallback: number, max: number): number {
+ const v = Math.floor(Number(n ?? fallback));
+ if (!Number.isFinite(v) || v <= 0) return fallback;
+ return Math.min(v, max);
+}
+
+function isIncidentStatus(value: string): value is schema.IncidentStatus {
+ return (
+ value === "open" || value === "resolved" || value === "autoresolved_noise" || value === "merged"
+ );
+}
+
+function normalizeAttrs(attrs: ResourceAttrFilter[] | undefined): ResourceAttrFilter[] | undefined {
+ if (!Array.isArray(attrs)) return undefined;
+ const out = attrs.filter(
+ (a): a is ResourceAttrFilter =>
+ !!a &&
+ typeof a.key === "string" &&
+ a.key.length > 0 &&
+ typeof a.value === "string" &&
+ (a.op === undefined || a.op === "eq" || a.op === "neq" || a.op === "not_contains"),
+ );
+ return out.length > 0 ? out : undefined;
+}
+
+function estimateRangeSeconds(range: { since?: string; until?: string } | undefined): number {
+ if (!range?.since || !range?.until) return 3600;
+ const s = Date.parse(range.since);
+ const u = Date.parse(range.until);
+ if (!Number.isFinite(s) || !Number.isFinite(u) || u <= s) return 3600;
+ return (u - s) / 1000;
+}
+
+async function chCount(table: string, tsColumn: string, projectId: string): Promise {
+ const r = await ch.query({
+ query: `
+ SELECT count() AS c
+ FROM ${table}
+ WHERE ResourceAttributes['superlog.project_id'] = {projectId:String}
+ AND ${tsColumn} > now() - INTERVAL 1 HOUR
+ `,
+ query_params: { projectId },
+ format: "JSONEachRow",
+ });
+ const rows = (await r.json()) as { c: string | number }[];
+ return Number(rows[0]?.c ?? 0);
+}
+
+async function chMetricsCount(projectId: string): Promise {
+ return tracer.startActiveSpan("metrics.aggregate_counts", async (span) => {
+ span.setAttribute("tenant.project_id", projectId);
+ try {
+ const tables = [
+ "otel_metrics_gauge",
+ "otel_metrics_sum",
+ "otel_metrics_histogram",
+ "otel_metrics_summary",
+ "otel_metrics_exp_histogram",
+ ];
+ span.setAttribute("metrics.table_count", tables.length);
+ const counts = await Promise.all(tables.map((t) => chCount(t, "TimeUnix", projectId)));
+ const total = counts.reduce((a, b) => a + b, 0);
+ span.setAttribute("metrics.total", total);
+ return total;
+ } catch (err) {
+ span.recordException(err as Error);
+ span.setStatus({ code: SpanStatusCode.ERROR, message: (err as Error).message });
+ throw err;
+ } finally {
+ span.end();
+ }
+ });
+}
+
+async function requireProjectAccess(
+ c: Context<{ Variables: Vars }>,
+ projectId: string,
+): Promise {
+ return tracer.startActiveSpan("project.authorize", async (span) => {
+ span.setAttribute("tenant.project_id", projectId);
+ span.setAttribute("superlog.user_id", c.var.userId);
+ if (c.var.orgId) span.setAttribute("superlog.org_id_hint", c.var.orgId);
+ try {
+ const project = await db.query.projects.findFirst({
+ where: eq(schema.projects.id, projectId),
+ });
+ if (!project) throw new HTTPException(404, { message: "project not found" });
+ const ctx = await resolveActiveOrgContext({
+ userId: c.var.userId,
+ preferredOrgId: c.var.orgId,
+ });
+ if (project.orgId !== ctx.org.id) throw new HTTPException(403, { message: "forbidden" });
+ span.setAttribute("superlog.org_id", ctx.org.id);
+ } catch (err) {
+ span.recordException(err as Error);
+ span.setStatus({ code: SpanStatusCode.ERROR, message: (err as Error).message });
+ throw err;
+ } finally {
+ span.end();
+ }
+ });
+}
+
+async function getProjectAutomation(projectId: string): Promise<{
+ autoInvestigateIssuesEnabled: boolean;
+ agentRunProvider: string;
+ maxRuntimeMinutes: number;
+ maxHumanResumeCount: number;
+ customInstructions: string;
+ agentRunEnabled: boolean;
+ linearTicketPolicy: schema.LinearTicketPolicy;
+ linearTicketInstructions: schema.LinearTicketInstruction[];
+ prPolicy: schema.PrPolicy;
+ prBaseBranch: string | null;
+ autoMergeFixPrs: schema.AutoMergePolicy;
+ autoMergeMethod: schema.AutoMergeMethod;
+ issueFilterConfig: schema.IssueFilterConfig;
+}> {
+ const row = await db.query.projectAutomationSettings.findFirst({
+ where: eq(schema.projectAutomationSettings.projectId, projectId),
+ });
+ return {
+ autoInvestigateIssuesEnabled: row?.autoInvestigateIssuesEnabled ?? true,
+ agentRunProvider: row?.agentRunProvider ?? DEFAULT_AGENT_RUN_PROVIDER,
+ maxRuntimeMinutes: row?.maxRuntimeMinutes ?? 90,
+ maxHumanResumeCount: row?.maxHumanResumeCount ?? 3,
+ customInstructions: row?.customInstructions ?? "",
+ agentRunEnabled: row?.agentRunEnabled ?? true,
+ linearTicketPolicy: row?.linearTicketPolicy ?? "on_ready_to_pr",
+ linearTicketInstructions: row?.linearTicketInstructions ?? [],
+ prPolicy: row?.prPolicy ?? "on_ready_to_pr",
+ prBaseBranch: schema.normalizePrBaseBranch(row?.prBaseBranch),
+ autoMergeFixPrs: row?.autoMergeFixPrs ?? "never",
+ autoMergeMethod: row?.autoMergeMethod ?? "squash",
+ issueFilterConfig: row?.issueFilterConfig ?? schema.EMPTY_ISSUE_FILTER_CONFIG,
+ };
+}
+
+function sanitizeClauseList(input: unknown): schema.IssueFilterClause[] {
+ if (!Array.isArray(input)) return [];
+ const out: schema.IssueFilterClause[] = [];
+ const seen = new Set();
+ for (const item of input) {
+ if (!item || typeof item !== "object") continue;
+ const key =
+ typeof (item as { key?: unknown }).key === "string"
+ ? (item as { key: string }).key.trim()
+ : "";
+ const value =
+ typeof (item as { value?: unknown }).value === "string"
+ ? (item as { value: string }).value.trim()
+ : "";
+ if (!key || !value) continue;
+ const dedupe = `${key.toLowerCase()}=${value}`;
+ if (seen.has(dedupe)) continue;
+ seen.add(dedupe);
+ out.push({ key: key.slice(0, 200), value: value.slice(0, 400) });
+ if (out.length >= 20) break;
+ }
+ return out;
+}
+
+function sanitizeIssueFilterConfig(
+ input: unknown,
+ fallback: schema.IssueFilterConfig,
+): schema.IssueFilterConfig {
+ if (!input || typeof input !== "object") return fallback;
+ const o = input as Partial>;
+ return {
+ includeLogs: sanitizeClauseList(o.includeLogs),
+ includeSpans: sanitizeClauseList(o.includeSpans),
+ excludeLogs: sanitizeClauseList(o.excludeLogs),
+ excludeSpans: sanitizeClauseList(o.excludeSpans),
+ };
+}
+
+async function getProjectIncident(projectId: string, incidentId: string) {
+ return db.query.incidents.findFirst({
+ where: and(eq(schema.incidents.id, incidentId), eq(schema.incidents.projectId, projectId)),
+ });
+}
+
+async function getLatestAgentRun(incidentId: string) {
+ return db.query.agentRuns.findFirst({
+ where: eq(schema.agentRuns.incidentId, incidentId),
+ orderBy: [desc(schema.agentRuns.createdAt)],
+ });
+}
+
+app.get("/api/projects/:projectId/issues", async (c) => {
+ const projectId = c.req.param("projectId");
+ await requireProjectAccess(c, projectId);
+ const silencedParam = c.req.query("silenced") ?? "active";
+ const limit = clampLimit(Number(c.req.query("limit") ?? 50), 50, 200);
+ const projectFilter = eq(schema.issues.projectId, projectId);
+ const where =
+ silencedParam === "all"
+ ? projectFilter
+ : silencedParam === "silenced"
+ ? and(projectFilter, isNotNull(schema.issues.silencedAt))
+ : and(projectFilter, isNull(schema.issues.silencedAt));
+ const rows = await db.query.issues.findMany({
+ where,
+ orderBy: [desc(schema.issues.lastSeen)],
+ limit,
+ });
+ return c.json(rows);
+});
+
+app.get("/api/projects/:projectId/issues/:issueId", async (c) => {
+ const projectId = c.req.param("projectId");
+ const issueId = c.req.param("issueId");
+ await requireProjectAccess(c, projectId);
+ const issue = await db.query.issues.findFirst({
+ where: and(eq(schema.issues.id, issueId), eq(schema.issues.projectId, projectId)),
+ });
+ if (!issue) throw new HTTPException(404, { message: "issue not found" });
+ const symbolication = await symbolicateIssueSample({
+ database: db,
+ objectReader: sourceMapObjectStore,
+ projectId,
+ sample: issue.lastSample,
+ }).catch((err) => {
+ logger.warn({ err, projectId, issueId }, "failed to symbolicate issue sample");
+ return null;
+ });
+ return c.json({ ...issue, symbolication });
+});
+
+app.post("/api/projects/:projectId/issues/lookup", async (c) => {
+ const projectId = c.req.param("projectId");
+ await requireProjectAccess(c, projectId);
+ const body = (await c.req.json().catch(() => ({}))) as {
+ kind?: "log" | "span";
+ service?: string | null;
+ severity?: string | null;
+ body?: string | null;
+ exceptionType?: string | null;
+ exceptionMessage?: string | null;
+ stacktrace?: string | null;
+ };
+ const kind = body.kind === "span" ? "span" : "log";
+ const fp =
+ kind === "span"
+ ? fingerprint({
+ type: body.exceptionType || body.severity || "Error",
+ stacktrace: body.stacktrace ?? null,
+ message: body.exceptionMessage ?? body.body ?? null,
+ })
+ : fingerprintLog({
+ service: body.service ?? "",
+ severity: body.severity ?? "",
+ body: body.body ?? "",
+ exceptionType: body.exceptionType ?? null,
+ stacktrace: body.stacktrace ?? null,
+ });
+ const issue = await db.query.issues.findFirst({
+ where: and(eq(schema.issues.projectId, projectId), eq(schema.issues.fingerprint, fp.hash)),
+ });
+ return c.json({ issue: issue ?? null });
+});
+
+app.post("/api/projects/:projectId/symbolication/log", async (c) => {
+ const projectId = c.req.param("projectId");
+ await requireProjectAccess(c, projectId);
+ const body = (await c.req.json().catch(() => ({}))) as {
+ stacktrace?: string | null;
+ logAttrs?: Record | null;
+ resourceAttrs?: Record | null;
+ };
+ const symbolication = await symbolicateTelemetrySample({
+ database: db,
+ objectReader: sourceMapObjectStore,
+ projectId,
+ sample: {
+ stacktrace: body.stacktrace ?? null,
+ logAttrs: body.logAttrs ?? null,
+ resourceAttrs: body.resourceAttrs ?? null,
+ },
+ }).catch((err) => {
+ logger.warn({ err, projectId }, "failed to symbolicate log sample");
+ return null;
+ });
+ return c.json({ symbolication });
+});
+
+app.get("/api/projects/:projectId/issues/:issueId/agent-run", async (c) => {
+ const projectId = c.req.param("projectId");
+ const issueId = c.req.param("issueId");
+ await requireProjectAccess(c, projectId);
+
+ const issue = await db.query.issues.findFirst({
+ where: and(eq(schema.issues.id, issueId), eq(schema.issues.projectId, projectId)),
+ });
+ if (!issue) throw new HTTPException(404, { message: "issue not found" });
+
+ const link = await db.query.incidentIssues.findFirst({
+ where: eq(schema.incidentIssues.issueId, issueId),
+ });
+ if (!link) return c.json({ incident: null, agentRun: null, events: [] });
+
+ const incident = await getProjectIncident(projectId, link.incidentId);
+ if (!incident) return c.json({ incident: null, agentRun: null, events: [] });
+
+ const agentRun = await getLatestAgentRun(incident.id);
+ if (!agentRun) return c.json({ incident, agentRun: null, events: [] });
+
+ const events = await loadIncidentTimeline(incident.id, agentRun.id);
+
+ return c.json({ incident, agentRun, events });
+});
+
+app.patch("/api/projects/:projectId/incidents/:incidentId", async (c) => {
+ const projectId = c.req.param("projectId");
+ const incidentId = c.req.param("incidentId");
+ await requireProjectAccess(c, projectId);
+ const body = (await c.req.json().catch(() => ({}))) as { status?: string };
+ const status = body.status;
+ if (status !== "open" && status !== "resolved") {
+ throw new HTTPException(400, { message: "status must be 'open' or 'resolved'" });
+ }
+ const existing = await db.query.incidents.findFirst({
+ where: and(eq(schema.incidents.id, incidentId), eq(schema.incidents.projectId, projectId)),
+ });
+ if (!existing) throw new HTTPException(404, { message: "incident not found" });
+
+ if (status === "resolved") {
+ // Route the dashboard's mark-resolved through the shared helper so the
+ // resolved_* columns are populated exactly like every other resolve path.
+ const { resolved } = await resolveIncident({
+ incidentId,
+ kind: "dashboard_manual",
+ reasonCode: "dashboard_manual",
+ reasonText: `Resolved from the dashboard by user ${c.var.userId}.`,
+ resolvedByUserId: c.var.userId,
+ });
+ if (resolved) {
+ await runResolvedIncidentSideEffectsForIncident({
+ incidentId,
+ closePullRequest: (pr) =>
+ closeAgentPullRequestOnGithub({
+ installationId: pr.githubInstallationId,
+ repoFullName: pr.repoFullName,
+ prNumber: pr.prNumber,
+ }),
+ });
+ }
+ } else {
+ await incidentLifecycle.reopenManually({
+ incident: existing,
+ actor: { userId: c.var.userId },
+ summary: "Incident reopened from the dashboard.",
+ detail: { reason: "dashboard_manual" },
+ });
+ }
+
+ const updated = await db.query.incidents.findFirst({
+ where: and(eq(schema.incidents.id, incidentId), eq(schema.incidents.projectId, projectId)),
+ });
+ return c.json(updated);
+});
+
+// Confirm/dismiss a pending resolution proposal from the dashboard. The
+// proposalId in the path is the source of truth — projectId/incidentId
+// are validated for access control and to make the URL self-documenting.
+// Both routes route through the same shared helper the Slack handler
+// uses, so confirmed/dismissed proposals from either surface look
+// identical in the audit log.
+async function decideResolutionProposal(
+ c: Context<{ Variables: Vars }>,
+ decision: "confirm" | "dismiss",
+): Promise {
+ const projectId = c.req.param("projectId");
+ const incidentId = c.req.param("incidentId");
+ const proposalId = c.req.param("proposalId");
+ if (!projectId || !incidentId || !proposalId) {
+ throw new HTTPException(400, { message: "missing path parameter" });
+ }
+ await requireProjectAccess(c, projectId);
+
+ const proposal = await db.query.incidentResolutionProposals.findFirst({
+ where: eq(schema.incidentResolutionProposals.id, proposalId),
+ });
+ if (!proposal || proposal.incidentId !== incidentId) {
+ throw new HTTPException(404, { message: "proposal not found" });
+ }
+ // Make sure the proposal belongs to this project — defence in depth on
+ // the project-access check above (the incident's projectId is the
+ // authoritative one).
+ const incident = await db.query.incidents.findFirst({
+ where: and(eq(schema.incidents.id, incidentId), eq(schema.incidents.projectId, projectId)),
+ });
+ if (!incident) throw new HTTPException(404, { message: "incident not found" });
+
+ // Look up the user's name for the attribution phrase. Skip if the user
+ // row is gone (e.g. just-deleted account); the helper falls back to the
+ // user id.
+ const user = await db.query.users.findFirst({
+ where: eq(schema.users.id, c.var.userId),
+ columns: { id: true, name: true, email: true },
+ });
+ const displayName = user?.name?.trim() || user?.email || null;
+
+ const actor = { userId: c.var.userId, displayName };
+ const result =
+ decision === "confirm"
+ ? await confirmResolutionProposal({ proposalId, actor })
+ : await dismissResolutionProposal({ proposalId, actor });
+ if (!result.ok) {
+ // already_confirmed / already_dismissed land as 409 so the dashboard
+ // can refresh and show the up-to-date state without treating it as a
+ // hard error.
+ if (result.reason?.startsWith("already_")) {
+ throw new HTTPException(409, { message: result.reason });
+ }
+ throw new HTTPException(400, { message: result.reason ?? "decision failed" });
+ }
+ if (decision === "confirm" && result.incidentId) {
+ await runResolvedIncidentSideEffectsForIncident({
+ incidentId: result.incidentId,
+ closePullRequest: (pr) =>
+ closeAgentPullRequestOnGithub({
+ installationId: pr.githubInstallationId,
+ repoFullName: pr.repoFullName,
+ prNumber: pr.prNumber,
+ }),
+ });
+ }
+ return c.json({ ok: true, incidentId, proposalId, decision });
+}
+
+app.post(
+ "/api/projects/:projectId/incidents/:incidentId/resolution-proposals/:proposalId/confirm",
+ (c) => decideResolutionProposal(c, "confirm"),
+);
+app.post(
+ "/api/projects/:projectId/incidents/:incidentId/resolution-proposals/:proposalId/dismiss",
+ (c) => decideResolutionProposal(c, "dismiss"),
+);
+
+app.post("/api/projects/:projectId/issues/:issueId/silence", async (c) => {
+ const projectId = c.req.param("projectId");
+ const issueId = c.req.param("issueId");
+ await requireProjectAccess(c, projectId);
+ return tracer.startActiveSpan("issue.silence", async (span) => {
+ span.setAttribute("tenant.project_id", projectId);
+ span.setAttribute("issue.id", issueId);
+ try {
+ const updated = await db
+ .update(schema.issues)
+ .set({ silencedAt: new Date() })
+ .where(
+ and(
+ eq(schema.issues.id, issueId),
+ eq(schema.issues.projectId, projectId),
+ isNull(schema.issues.silencedAt),
+ ),
+ )
+ .returning();
+ if (!updated[0]) {
+ span.setAttribute("issue.silence.applied", false);
+ const existing = await db.query.issues.findFirst({
+ where: and(eq(schema.issues.id, issueId), eq(schema.issues.projectId, projectId)),
+ });
+ if (!existing) throw new HTTPException(404, { message: "issue not found" });
+ return c.json(existing);
+ }
+ span.setAttribute("issue.silence.applied", true);
+ return c.json(updated[0]);
+ } catch (err) {
+ span.recordException(err as Error);
+ span.setStatus({ code: SpanStatusCode.ERROR, message: (err as Error).message });
+ throw err;
+ } finally {
+ span.end();
+ }
+ });
+});
+
+app.post("/api/projects/:projectId/issues/:issueId/unsilence", async (c) => {
+ const projectId = c.req.param("projectId");
+ const issueId = c.req.param("issueId");
+ await requireProjectAccess(c, projectId);
+ const existing = await db.query.issues.findFirst({
+ where: and(eq(schema.issues.id, issueId), eq(schema.issues.projectId, projectId)),
+ });
+ if (!existing) throw new HTTPException(404, { message: "issue not found" });
+ if (!existing.silencedAt) return c.json(existing);
+ const conflict = await db.query.issues.findFirst({
+ where: and(
+ eq(schema.issues.projectId, projectId),
+ eq(schema.issues.fingerprint, existing.fingerprint),
+ isNull(schema.issues.silencedAt),
+ ),
+ });
+ if (conflict) {
+ throw new HTTPException(409, {
+ message: "another active issue already exists for this fingerprint",
+ });
+ }
+ const updated = await db
+ .update(schema.issues)
+ .set({ silencedAt: null })
+ .where(and(eq(schema.issues.id, issueId), eq(schema.issues.projectId, projectId)))
+ .returning();
+ return c.json(updated[0] ?? existing);
+});
+
+app.get("/api/projects/:projectId/automation", async (c) => {
+ const projectId = c.req.param("projectId");
+ await requireProjectAccess(c, projectId);
+ const current = await getProjectAutomation(projectId);
+ return c.json({ projectId, ...current });
+});
+
+// Picker support for the Issue filter editor — keys/values/preview are drawn
+// from ERROR events only (logs SeverityNumber>=17, traces with exception
+// event) so the UI matches the population the worker filter applies to.
+const ISSUE_FILTER_RANGE = "now() - INTERVAL 24 HOUR";
+
+app.get("/api/projects/:projectId/issue-filter/attribute-keys", async (c) => {
+ const projectId = c.req.param("projectId");
+ await requireProjectAccess(c, projectId);
+ const rows = await listIssueFilterAttributeKeys(ch, projectId, {
+ since: ISSUE_FILTER_RANGE,
+ });
+ return c.json(rows);
+});
+
+app.get("/api/projects/:projectId/issue-filter/attribute-values", async (c) => {
+ const projectId = c.req.param("projectId");
+ await requireProjectAccess(c, projectId);
+ const key = c.req.query("key");
+ if (!key) throw new HTTPException(400, { message: "key is required" });
+ const rows = await listIssueFilterAttributeValues(ch, projectId, key, {
+ since: ISSUE_FILTER_RANGE,
+ });
+ return c.json(rows);
+});
+
+// POST because the filter is now a structured object with 4 lists, which
+// doesn't fit cleanly in a query string.
+app.post("/api/projects/:projectId/issue-filter/preview", async (c) => {
+ const projectId = c.req.param("projectId");
+ await requireProjectAccess(c, projectId);
+ const body = (await c.req.json().catch(() => ({}))) as {
+ config?: unknown;
+ };
+ const config = sanitizeIssueFilterConfig(body.config, schema.EMPTY_ISSUE_FILTER_CONFIG);
+ const events = await previewIssueFilterMatches(
+ ch,
+ projectId,
+ config,
+ { since: ISSUE_FILTER_RANGE },
+ 10,
+ );
+ return c.json({ events });
+});
+
+const VALID_AGENT_POLICIES: ReadonlySet = new Set(["never", "on_ready_to_pr", "always"]);
+const VALID_AUTO_MERGE_POLICIES: ReadonlySet = new Set([
+ "never",
+ "when_checks_pass",
+ "immediately",
+]);
+const VALID_AUTO_MERGE_METHODS: ReadonlySet = new Set(["squash", "merge", "rebase"]);
+const MAX_INSTRUCTIONS_LEN = 8000;
+
+function parsePrBaseBranch(input: unknown, current: string | null): string | null {
+ if (input === undefined) return current;
+ if (input === null) return null;
+ if (typeof input !== "string") return current;
+ const branch = schema.normalizePrBaseBranch(input);
+ if (branch && !schema.isValidPrBaseBranch(branch)) {
+ throw new HTTPException(400, {
+ message:
+ "prBaseBranch must be a valid Git branch name, or blank to use the repository default",
+ });
+ }
+ return branch;
+}
+
+// Branches the agent could target for PRs: the union across the project's
+// enabled GitHub repos. Powers the strict PR-target-branch picker in Settings.
+app.get("/api/projects/:projectId/github/branches", async (c) => {
+ const projectId = c.req.param("projectId");
+ await requireProjectAccess(c, projectId);
+
+ const result = await listProjectRepoBranches(projectId);
+ if (result.errored) {
+ return c.json({ error: "failed to load branches from GitHub" }, 502);
+ }
+ return c.json({ branches: result.branches });
+});
+
+app.patch("/api/projects/:projectId/automation", async (c) => {
+ const projectId = c.req.param("projectId");
+ await requireProjectAccess(c, projectId);
+
+ const current = await getProjectAutomation(projectId);
+ const body = (await c.req.json().catch(() => ({}))) as {
+ autoInvestigateIssuesEnabled?: unknown;
+ agentRunProvider?: unknown;
+ maxRuntimeMinutes?: unknown;
+ maxHumanResumeCount?: unknown;
+ customInstructions?: unknown;
+ agentRunEnabled?: unknown;
+ linearTicketPolicy?: unknown;
+ linearTicketInstructions?: unknown;
+ prPolicy?: unknown;
+ prBaseBranch?: unknown;
+ autoMergeFixPrs?: unknown;
+ autoMergeMethod?: unknown;
+ issueFilterConfig?: unknown;
+ };
+
+ const autoInvestigateIssuesEnabled =
+ typeof body.autoInvestigateIssuesEnabled === "boolean"
+ ? body.autoInvestigateIssuesEnabled
+ : current.autoInvestigateIssuesEnabled;
+ const agentRunProvider =
+ typeof body.agentRunProvider === "string" ? body.agentRunProvider : current.agentRunProvider;
+ const maxRuntimeMinutes =
+ typeof body.maxRuntimeMinutes === "number"
+ ? Math.floor(body.maxRuntimeMinutes)
+ : current.maxRuntimeMinutes;
+ const maxHumanResumeCount =
+ typeof body.maxHumanResumeCount === "number"
+ ? Math.floor(body.maxHumanResumeCount)
+ : current.maxHumanResumeCount;
+ const customInstructions =
+ typeof body.customInstructions === "string"
+ ? body.customInstructions.slice(0, MAX_INSTRUCTIONS_LEN)
+ : current.customInstructions;
+ const agentRunEnabled =
+ typeof body.agentRunEnabled === "boolean" ? body.agentRunEnabled : current.agentRunEnabled;
+ const linearTicketPolicy: schema.LinearTicketPolicy =
+ typeof body.linearTicketPolicy === "string" && VALID_AGENT_POLICIES.has(body.linearTicketPolicy)
+ ? (body.linearTicketPolicy as schema.LinearTicketPolicy)
+ : current.linearTicketPolicy;
+ const linearTicketInstructions: schema.LinearTicketInstruction[] = (() => {
+ if (!Array.isArray(body.linearTicketInstructions)) return current.linearTicketInstructions;
+ const validated = body.linearTicketInstructions
+ .filter(
+ (item): item is { id: string; title: string; text: string } =>
+ item !== null &&
+ typeof item === "object" &&
+ typeof item.id === "string" &&
+ typeof item.title === "string" &&
+ typeof item.text === "string",
+ )
+ .slice(0, 20)
+ .map((item) => ({
+ id: item.id,
+ title: item.title.slice(0, 200),
+ text: item.text.slice(0, 2000),
+ }));
+ return validated;
+ })();
+ const prPolicy: schema.PrPolicy =
+ typeof body.prPolicy === "string" && VALID_AGENT_POLICIES.has(body.prPolicy)
+ ? (body.prPolicy as schema.PrPolicy)
+ : current.prPolicy;
+ const prBaseBranch = parsePrBaseBranch(body.prBaseBranch, current.prBaseBranch);
+ const autoMergeFixPrs: schema.AutoMergePolicy =
+ typeof body.autoMergeFixPrs === "string" && VALID_AUTO_MERGE_POLICIES.has(body.autoMergeFixPrs)
+ ? (body.autoMergeFixPrs as schema.AutoMergePolicy)
+ : current.autoMergeFixPrs;
+ const autoMergeMethod: schema.AutoMergeMethod =
+ typeof body.autoMergeMethod === "string" && VALID_AUTO_MERGE_METHODS.has(body.autoMergeMethod)
+ ? (body.autoMergeMethod as schema.AutoMergeMethod)
+ : current.autoMergeMethod;
+ const issueFilterConfig =
+ body.issueFilterConfig !== undefined
+ ? sanitizeIssueFilterConfig(body.issueFilterConfig, current.issueFilterConfig)
+ : current.issueFilterConfig;
+
+ if (!isAgentRunProvider(agentRunProvider)) {
+ throw new HTTPException(400, {
+ message: "agentRunProvider must be one of: community, anthropic, disabled",
+ });
+ }
+ if (!Number.isFinite(maxRuntimeMinutes) || maxRuntimeMinutes < 1 || maxRuntimeMinutes > 720) {
+ throw new HTTPException(400, { message: "maxRuntimeMinutes must be between 1 and 720" });
+ }
+ if (
+ !Number.isFinite(maxHumanResumeCount) ||
+ maxHumanResumeCount < 0 ||
+ maxHumanResumeCount > 10
+ ) {
+ throw new HTTPException(400, { message: "maxHumanResumeCount must be between 0 and 10" });
+ }
+
+ // When the target branch changed to a non-blank value, confirm it actually
+ // exists in one of the project's repos. Skipped when GitHub can't be reached
+ // (errored) so a transient API failure can't lock the user out of saving
+ // unrelated settings — the worker still falls back to the repo default at PR
+ // time if the branch later disappears.
+ if (prBaseBranch && prBaseBranch !== current.prBaseBranch) {
+ const { branches, errored } = await listProjectRepoBranches(projectId);
+ if (!errored && !prBaseBranchExists(prBaseBranch, branches)) {
+ throw new HTTPException(400, {
+ message: `Branch "${prBaseBranch}" was not found in this project's connected repositories.`,
+ });
+ }
+ }
+
+ const values = {
+ projectId,
+ autoInvestigateIssuesEnabled,
+ agentRunProvider,
+ maxRuntimeMinutes,
+ maxHumanResumeCount,
+ customInstructions,
+ agentRunEnabled,
+ linearTicketPolicy,
+ linearTicketInstructions,
+ prPolicy,
+ prBaseBranch,
+ autoMergeFixPrs,
+ autoMergeMethod,
+ issueFilterConfig,
+ updatedAt: new Date(),
+ };
+
+ const updated = await db
+ .insert(schema.projectAutomationSettings)
+ .values(values)
+ .onConflictDoUpdate({
+ target: schema.projectAutomationSettings.projectId,
+ set: {
+ autoInvestigateIssuesEnabled,
+ agentRunProvider,
+ maxRuntimeMinutes,
+ maxHumanResumeCount,
+ customInstructions,
+ agentRunEnabled,
+ linearTicketPolicy,
+ linearTicketInstructions,
+ prPolicy,
+ prBaseBranch,
+ autoMergeFixPrs,
+ autoMergeMethod,
+ issueFilterConfig,
+ updatedAt: new Date(),
+ },
+ })
+ .returning();
+
+ return c.json(updated[0] ?? values);
+});
+
+app.get("/api/projects/:projectId/incidents", async (c) => {
+ const projectId = c.req.param("projectId");
+ await requireProjectAccess(c, projectId);
+ const limit = clampLimit(Number(c.req.query("limit") ?? 50), 50, 200);
+ const status = c.req.query("status");
+ const incidentStatus = status && status !== "all" && isIncidentStatus(status) ? status : null;
+ if (status && status !== "all" && !incidentStatus) {
+ throw new HTTPException(400, { message: "invalid incident status" });
+ }
+
+ // Default view hides agent-classified noise. Pass ?status=all to include it,
+ // or ?status=autoresolved_noise to inspect just the noise pile.
+ const baseFilter = eq(schema.incidents.projectId, projectId);
+ let where = !status
+ ? and(baseFilter, ne(schema.incidents.status, "autoresolved_noise"))
+ : baseFilter;
+ if (incidentStatus) {
+ where = and(baseFilter, eq(schema.incidents.status, incidentStatus));
+ }
+ const incidents = await db.query.incidents.findMany({
+ where,
+ orderBy: [desc(schema.incidents.lastSeen)],
+ limit,
+ });
+ const [agentRuns, pendingProposals] = await Promise.all([
+ Promise.all(incidents.map((incident) => getLatestAgentRun(incident.id))),
+ loadPendingResolutionProposals(incidents.map((i) => i.id)),
+ ]);
+
+ // Graph stats hit ClickHouse and can be slow on large projects. Keep the list
+ // itself Postgres-only by default; callers that still need the legacy inline
+ // shape can opt in with ?includeStats=1 while the web UI lazy-loads rows.
+ const inlineStats = shouldInlineIncidentListStats(c.req.query("includeStats"));
+ const statsByIncidentId = inlineStats
+ ? await loadIncidentsBucketStats(
+ projectId,
+ incidents.map((i) => i.id),
+ )
+ : new Map();
+ const emptyStats: IncidentBucketStats = {
+ buckets: buildBucketsFromMap(new Map(), INCIDENT_STATS_WINDOW_DAYS),
+ impactedUsers: 0,
+ impactedUsersAvailable: false,
+ impactedUsersCapped: false,
+ };
+
+ return c.json(
+ incidents.map((incident, index) =>
+ buildIncidentListItem({
+ incident,
+ agentRun: agentRuns[index] ?? null,
+ pendingResolutionProposal: pendingProposals.get(incident.id) ?? null,
+ stats: inlineStats
+ ? {
+ windowDays: INCIDENT_STATS_WINDOW_DAYS,
+ ...(statsByIncidentId.get(incident.id) ?? emptyStats),
+ }
+ : undefined,
+ }),
+ ),
+ );
+});
+
+type IncidentBucketStats = {
+ buckets: { day: string; count: number }[];
+ impactedUsers: number;
+ impactedUsersAvailable: boolean;
+ impactedUsersCapped: boolean;
+};
+
+// Bulk version of the per-incident stats query, used by the incidents list.
+// Two ClickHouse round-trips: one returns (svc, et, day, count) tuples for
+// the sparkline, the other returns (svc, et) → distinct user.id arrays.
+// We then map each tuple back to whichever incident's issues had that
+// (svc, et) pair, summing counts and Set-unioning users per-incident.
+// Same approximation as the single-incident endpoint — see
+// `fetchIncidentTimeseriesPairs`.
+async function loadIncidentsBucketStats(
+ projectId: string,
+ incidentIds: string[],
+): Promise