chore: publish from main

This commit is contained in:
github-actions[bot]
2026-09-07 04:11:36 +00:00
parent 882c4b286a
commit 2c1ad5b640
18 changed files with 770 additions and 166 deletions
+1 -1
View File
@@ -1515,7 +1515,7 @@
"name": "sentry-triage",
"source": "plugins/sentry-triage",
"description": "Scan live Sentry issues in a Copilot canvas, group them by urgency, and hand issues off for tracking or a fix PR.",
"version": "1.1.0"
"version": "1.2.0"
},
{
"name": "signals-dashboard",
+3 -1
View File
@@ -2,7 +2,9 @@ import { escapeHtml, safeHref } from '../escape.mjs'
function statusLabel(workStatus) {
if (!workStatus || typeof workStatus !== 'object') return ''
if (workStatus.phase === 'working' || workStatus.phase === 'queued') return '⏳ working…'
if (workStatus.phase === 'working' || workStatus.phase === 'queued') {
return workStatus.copilotFix ? '⏳ starting Copilot fix session…' : '⏳ filing tracking issue…'
}
if (workStatus.phase === 'done') {
// Numbers move into clickable links (statusLinks); keep the label a plain badge.
return 'created ✓'
+97 -15
View File
@@ -48,6 +48,7 @@ export function Page({
prTargets,
prSettingsOpen,
plainEnglishView = false,
plainEnglishEnriching = false,
projects = [],
availableModels = [],
issueTrackers,
@@ -313,14 +314,14 @@ export function Page({
</div>
<div id="title-mode-bar" class="title-mode-bar" style="${hasOrg && totalIssues > 0 ? '' : 'display:none;'}">
<label class="switch-label" for="title-mode-switch" title="Switch every card title between the raw Sentry error and a plain-English summary">
<label class="switch-label" for="title-mode-switch" title="Switch every card message between the raw Sentry error and a plain-English summary">
<span class="switch">
<input type="checkbox" id="title-mode-switch" class="switch-input"${plainEnglishView ? ' checked' : ''} />
<input type="checkbox" id="title-mode-switch" class="switch-input"${plainEnglishView ? ' checked' : ''}${plainEnglishEnriching ? ' disabled' : ''} />
<span class="switch-slider" aria-hidden="true"></span>
</span>
<span class="switch-text">Plain-English titles</span>
<span class="switch-text">Plain-English messages</span>
</label>
<span class="title-mode-hint">Showing <strong id="title-mode-state">${plainEnglishView ? 'plain-English summaries' : 'raw errors'}</strong></span>
<span class="title-mode-hint">${plainEnglishEnriching ? '<strong id="title-mode-state">Preparing plain-English summaries — toggle available shortly…</strong>' : `Showing <strong id="title-mode-state">${plainEnglishView ? 'plain-English summaries' : 'raw errors'}</strong>`}</span>
</div>
<main id="categories">
@@ -406,6 +407,10 @@ export function Page({
// is already running for a slug, later callers (e.g. the Scan/Fetch gate)
// queue here and are flushed when it settles, instead of being dropped.
const projectResolveWaiters = {};
// Ceiling for a single exact-slug lookup before we give up and report a
// transient failure. Generous enough to absorb a normal queue wait behind
// an in-progress scan, short enough that the user is never stranded.
const RESOLVE_TIMEOUT_MS = 20000;
function projectResolveKey(org, slug) {
return String(org || "").trim().toLowerCase() + "/" + String(slug || "").trim().toLowerCase();
}
@@ -431,6 +436,10 @@ export function Page({
// An empty project scans all projects and needs no lookup.
verifyProjectForScan(org, project).then((v) => {
if (!v.ok) {
// "stale" means the user changed the org/project while the lookup was
// in flight — they've already moved on, so don't scan the old scope
// and don't nag them about a slug they abandoned.
if (v.reason === "stale") return;
showToast(v.reason === "missing"
? "No project \u201C" + project + "\u201D in " + org + " — check the slug."
: "Couldn't verify that project with Sentry — try again.");
@@ -699,6 +708,7 @@ export function Page({
let lastAutoFetchedOrgDefault = "";
let currentAvailableModels = ${jsonForScript(Array.isArray(availableModels) ? availableModels : [])};
let currentPlainEnglishView = ${jsonForScript(plainEnglishView)};
let currentPlainEnglishEnriching = ${jsonForScript(plainEnglishEnriching)};
let currentScanError = ${jsonForScript(scanError || '')};
let currentScannedTotal = ${jsonForScript(scannedTotal)};
let currentScannedCapped = ${jsonForScript(scannedCapped)};
@@ -741,7 +751,7 @@ export function Page({
function statusText(status) {
if (!status || typeof status !== "object") return "";
if (status.phase === "queued" || status.phase === "working") return "⏳ working…";
if (status.phase === "queued" || status.phase === "working") return status.copilotFix ? "⏳ starting Copilot fix session…" : "⏳ filing tracking issue…";
if (status.phase === "skipped") return "🔒 already being worked on";
if (status.phase === "tracked") return "👀 Tracked";
if (status.phase === "done") {
@@ -1089,17 +1099,34 @@ export function Page({
if (psel && psel.value !== msg.period) psel.value = msg.period;
}
if (Array.isArray(msg.periods)) currentPeriods = msg.periods;
if (typeof msg.plainEnglishEnriching === "boolean" && msg.plainEnglishEnriching !== currentPlainEnglishEnriching) {
currentPlainEnglishEnriching = msg.plainEnglishEnriching;
syncTitleSwitch();
}
if (Array.isArray(msg.projects)) {
const forOrg = typeof msg.projectsOrg === "string" ? msg.projectsOrg.trim().toLowerCase() : "";
const projectsComplete = msg.projectsComplete === true;
// Keep-longest ONLY for streamed/incomplete snapshots. The server
// streams partial pages (each broadcast is a growing snapshot), and a
// discovery run that fails or is cut short mid-traversal publishes a
// truncated snapshot for an org we already have fully loaded — the
// dropdown "sometimes comes back much shorter" symptom. But a COMPLETE
// traversal is authoritative and may legitimately be shorter (projects
// deleted upstream), so it must be allowed to replace the cache —
// otherwise deleted slugs would linger in autocomplete for the panel's
// whole life. Growth within a run still lands normally.
const known = forOrg ? projectsByOrg[forOrg] : null;
const regression = !projectsComplete && Array.isArray(known) && known.length > msg.projects.length;
const projects = regression ? known : msg.projects;
// Always cache under the org this list belongs to so re-selecting it is
// instant next time.
if (forOrg) projectsByOrg[forOrg] = msg.projects;
if (forOrg && !regression) projectsByOrg[forOrg] = msg.projects;
// Only paint the on-screen field if this broadcast matches the org the
// user currently has selected — a background refresh for a previous org
// must not clobber the current list.
const sel = selectedOrgSlug();
if (!forOrg || !sel || forOrg === sel) {
currentSentryProjects = msg.projects;
currentSentryProjects = projects;
setProjectLoading(false);
// Repaint whichever project field is on screen with the new options.
if (currentOrg) renderProjectSwitcher();
@@ -1129,7 +1156,20 @@ export function Page({
currentSavedDefaultOrg = msg.savedDefaultOrg;
refreshDefaultBtn();
}
if (Array.isArray(msg.availableModels)) currentAvailableModels = msg.availableModels;
if (Array.isArray(msg.availableModels)) {
currentAvailableModels = msg.availableModels;
// The host's model catalog can change under us (models ship or retire).
// Drop any per-card override whose id is no longer offered: otherwise the
// <select> falls back to rendering "Default" while modelByKey still holds
// the dead id, counts it as an override, and POSTs it — where the server
// silently discards it. Prune, then re-sync the card selects.
const validModelIds = new Set(currentAvailableModels.map((m) => m && m.id).filter(Boolean));
let prunedModelOverride = false;
for (const k of Object.keys(modelByKey)) {
if (!validModelIds.has(modelByKey[k])) { delete modelByKey[k]; prunedModelOverride = true; }
}
if (prunedModelOverride) syncCardModels();
}
if (typeof msg.plainEnglishView === "boolean" && msg.plainEnglishView !== currentPlainEnglishView) {
currentPlainEnglishView = msg.plainEnglishView;
syncTitleSwitch();
@@ -1397,8 +1437,15 @@ export function Page({
}
projectResolveCache[key] = { status: "checking", slug: "" };
// Record the settled entry, notify this caller, then flush anyone who
// queued while the request was in flight.
// queued while the request was in flight. Idempotent: whichever of the
// response and the timeout below fires first wins, and the loser is a
// no-op (so a late response can't overwrite the timeout's "error" and
// silently re-arm a lookup the user was already told had failed).
let settled = false;
const settle = (entry) => {
if (settled) return;
settled = true;
clearTimeout(timer);
projectResolveCache[key] = entry;
if (onDone) onDone(entry);
const waiters = projectResolveWaiters[key];
@@ -1407,6 +1454,13 @@ export function Page({
for (const fn of waiters) { try { fn(entry); } catch (_) {} }
}
};
// Hard ceiling on a single lookup. The server side runs on a shared,
// strictly-serial Sentry SDK queue, so this request can end up waiting
// behind other work; without a bound the UI would sit on "Checking
// Sentry…" indefinitely with no way out. Settling as a transient "error"
// (not "missing") keeps the honest distinction — the user gets a
// "couldn't check — press Enter to retry" they can act on.
const timer = setTimeout(() => settle({ status: "error", slug: "" }), RESOLVE_TIMEOUT_MS);
fetch("/api/resolve-project", {
method: "POST",
headers: { "Content-Type": "application/json" },
@@ -1430,6 +1484,14 @@ export function Page({
// project is an all-projects scan (always allowed); a locally-known project
// needs no lookup; anything else is verified via the shared resolver, reusing
// its cache and in-flight de-duplication.
//
// Resolves { ok: false, reason: "stale" } if the user changed the org or
// project while the lookup was in flight. A slow lookup (it queues behind
// whatever else is using the shared Sentry SDK chain) can settle long after
// the user has moved on; without this guard its callback would go on to
// overwrite the project input and scan the OLD slug — the "I selected ace
// but it scanned github-app" bug. The autocomplete's Enter path has always
// had this guard; this brings the Scan/Fetch path in line.
function verifyProjectForScan(org, project) {
return new Promise((resolve) => {
const o = String(org || "").trim().toLowerCase();
@@ -1446,7 +1508,15 @@ export function Page({
if (rc && rc.status === "found") { resolve({ ok: true, slug: rc.slug || raw }); return; }
if (rc && rc.status === "missing") { resolve({ ok: false, reason: "missing", slug: raw }); return; }
if (rc && rc.status === "error") delete projectResolveCache[key];
// Snapshot the scope this lookup was started for, so a late completion
// can be discarded rather than applied to a scope the user has left.
const scopeStillCurrent = () => {
const inp = activeProjectInput();
const nowProject = inp ? inp.value.trim().toLowerCase() : p;
return selectedOrgSlug() === o && nowProject === p;
};
resolveProjectSlug(o, p, (res) => {
if (!scopeStillCurrent()) { resolve({ ok: false, reason: "stale", slug: raw }); return; }
if (res && res.status === "found") resolve({ ok: true, slug: res.slug || raw });
else resolve({ ok: false, reason: (res && res.status) || "error", slug: raw });
});
@@ -1843,9 +1913,16 @@ export function Page({
// issue list; this keeps the toggle in sync with state (checkbox + hint).
function syncTitleSwitch() {
const box = document.getElementById("title-mode-switch");
if (box) box.checked = currentPlainEnglishView;
const state = document.getElementById("title-mode-state");
if (state) state.textContent = currentPlainEnglishView ? "plain-English summaries" : "raw errors";
if (box) {
box.checked = currentPlainEnglishView;
box.disabled = currentPlainEnglishEnriching;
}
const hint = document.querySelector(".title-mode-hint");
if (hint) {
hint.innerHTML = currentPlainEnglishEnriching
? '<strong id="title-mode-state">Preparing plain-English summaries — toggle available shortly…</strong>'
: 'Showing <strong id="title-mode-state">' + (currentPlainEnglishView ? "plain-English summaries" : "raw errors") + '</strong>';
}
}
// Flip the whole canvas between raw error and plain-English titles. Optimistic:
@@ -1854,6 +1931,7 @@ export function Page({
document.addEventListener("change", (e) => {
const box = e.target.closest("#title-mode-switch");
if (!box) return;
if (currentPlainEnglishEnriching) return;
currentPlainEnglishView = box.checked;
syncTitleSwitch();
renderCategories(currentCategories);
@@ -1878,6 +1956,10 @@ export function Page({
// empty project scans all projects and needs no lookup.
verifyProjectForScan(org, project).then((v) => {
if (!v.ok) {
// "stale" means the user changed the org/project while the lookup was
// in flight — they've already moved on, so don't scan the old scope
// and don't nag them about a slug they abandoned.
if (v.reason === "stale") return;
showToast(v.reason === "missing"
? "No project \u201C" + project + "\u201D in " + org + " — check the slug."
: "Couldn't verify that project with Sentry — try again.");
@@ -2167,7 +2249,7 @@ export function Page({
const keys = startableSelectedKeys();
if (keys.length === 0) return;
keys.forEach((key) => {
workByIssue[key] = { phase: "queued" };
workByIssue[key] = { phase: "queued", copilotFix: false };
selectedKeys.delete(key);
});
applySelections();
@@ -2184,12 +2266,12 @@ export function Page({
const models = {};
keys.forEach((key) => {
if (modelByKey[key]) models[key] = modelByKey[key];
workByIssue[key] = { phase: "queued" };
workByIssue[key] = { phase: "queued", copilotFix: true };
selectedKeys.delete(key);
});
applySelections();
applyWorkStates();
showToast("🔧 Creating/reusing issues and starting Copilot for " + keys.length + " issues…");
showToast("🔧 Starting a background Copilot session for " + keys.length + " issue" + (keys.length === 1 ? "" : "s") + " — you can keep triaging while it runs…");
postWorkSelected(keys, { keys, modelByKey: models, assignCopilot: true });
});
+135 -11
View File
@@ -205,6 +205,35 @@ function safeIssueKey(value) {
const servers = new Map()
// Live model catalog fetched from the host (session.rpc.model.list()), cached
// here so a newly opened panel gets it immediately instead of waiting on
// another RPC round trip. Stays null until the first successful fetch; every
// server keeps its state.mjs static fallback list until then.
let liveModels = null
// Fetches the host's current model catalog and pushes it into every open
// panel's state (plus caches it for panels opened afterward), so the picker
// tracks whatever models the host makes available without editing this
// extension's static list every time a model ships or is retired. Best-effort:
// on any failure (older host, RPC error) every panel just keeps using the
// static fallback list already seeded in state.mjs.
async function refreshAvailableModels() {
try {
const result = await session.rpc.model.list()
const models = (result?.list || [])
.map((m) => ({ id: String(m?.id ?? ''), label: String(m?.name ?? m?.id ?? '') }))
.filter((m) => m.id)
if (models.length === 0) return
liveModels = models
for (const entry of servers.values()) {
entry.state.setAvailableModels(models)
entry.notifyClients()
}
} catch (err) {
console.error('[sentry-triage] could not fetch live model list, using static fallback:', err?.message || err)
}
}
// Maps an opaque per-work token -> { entry, key } for a "Work on selected"
// hand-off. The spawned fix session echoes this token back via `submit_work_pr`
// so the PR update lands on the EXACT canvas instance and issue that started the
@@ -739,6 +768,20 @@ OR
{"status":"error","error":"plain explanation","issue":{"number":123,"url":"https://..."},"session":{"id":null,"name":""}}`
}
// Reset the plain-English enriching flag when an in-flight enrichment is
// invalidated (scan generation bumped) by a path that will NOT start a
// replacement scan to clear it. triageSentry's isCurrent() guard deliberately
// stops a STALE scan from clearing a NEWER scan's flag; the side effect is that
// an invalidation with no successor (a refresh that finds Sentry unreachable, or
// a repo switch via onInvalidateEnrichment) would otherwise strand the flag
// `true` forever and leave the plain-English toggle disabled. Clearing it here
// is safe: a reachable scan's triageSentry re-sets it true before it drops the
// scanning overlay, so the toggle is never interactive with a false flag.
function resetEnrichingFlag(entry) {
entry.state.setPlainEnglishEnriching(false)
if (entry.notifyPlainEnglishEnriching) entry.notifyPlainEnglishEnriching(false)
}
async function triageSentry(entry) {
if (entry.closed) return
const org = entry.state.getOrg()
@@ -788,8 +831,16 @@ async function triageSentry(entry) {
// 240s; blocking the whole board on it would leave the user staring at a
// spinner. The card renderer already falls back to the raw title when
// `plainEnglish` is absent, so an early render is fully usable.
// Set the enriching flag BEFORE publishing the categories snapshot so that
// snapshot already reports enrichment as in-progress. Publishing categories
// first would emit a snapshot saying enrichment is idle (re-enabling the
// toggle), and a user action slipping in before the separate
// plainEnglishEnriching:true event preserves a narrow version of the race
// this flag exists to remove.
entry.state.setPlainEnglishEnriching(true)
entry.state.setCategories(categories)
entry.notifyClients()
if (entry.notifyPlainEnglishEnriching) entry.notifyPlainEnglishEnriching(true)
// End the scanning overlay NOW that the board is published — enrichment is
// optional polish (plain-English titles + tracked badges) that streams in via
// a later publish. Leaving the overlay up for the whole ≤240s enrich turn
@@ -805,7 +856,14 @@ async function triageSentry(entry) {
// so a stale turn (a newer scan started meanwhile) can't apply its inbox data
// over the newer scan's state.
await enrichPlainEnglish(entry, categories, isCurrent)
// A stale turn (a newer scan started during our ≤240s enrich) must NOT clear
// or broadcast the shared enriching flag: doing so would re-enable the toggle
// while the newer scan is still enriching, recreating the exact race this
// flag prevents. Bail before touching it — the current scan owns the flag and
// clears it when IT finishes (and the guarded `finally` is the fallback).
if (!isCurrent()) return
entry.state.setPlainEnglishEnriching(false)
if (entry.notifyPlainEnglishEnriching) entry.notifyPlainEnglishEnriching(false)
entry.state.setCategories(categories)
entry.notifyClients()
console.error('[sentry-triage] Scan complete, categories:', categories.length, error ? `error: ${error}` : '')
@@ -821,6 +879,10 @@ async function triageSentry(entry) {
entry.notifyClients()
}
} finally {
if (isCurrent()) {
entry.state.setPlainEnglishEnriching(false)
if (entry.notifyPlainEnglishEnriching) entry.notifyPlainEnglishEnriching(false)
}
if (isCurrent() && entry.notifyScanning) entry.notifyScanning(false)
}
}
@@ -915,7 +977,7 @@ After the tool call(s), reply to the user with a confirmation sentence, then a b
Triaged ${total} Sentry ${noun}.
**📋 Next steps in the canvas:** Card titles show the raw Sentry error by default flip the **Plain-English titles** switch above the issue list for readable summaries. Open the Settings panel to confirm the open-issue and draft-PR targets point at the right repo, then select issues to work.
**📋 Next steps in the canvas:** Card titles show the raw Sentry error by default flip the **Plain-English messages** switch above the issue list for readable summaries. Open the Settings panel to confirm the open-issue and draft-PR targets point at the right repo, then select issues to work.
Keep it friendly and brief; do not include the summaries or JSON.
@@ -1134,19 +1196,62 @@ async function discoverProjects(entry, org, { force = false } = {}) {
const conn = entry.state.getConnections()
if (!conn || !conn.sentry || !conn.sentry.reachable) return
if (!force && entry._projectsFetchedFor === slug) return
// Each page now releases the shared SDK queue, so discoveries for different
// orgs (A then B) can interleave. Stamp this discovery with a generation
// token; a newer discoverProjects call bumps it, and every publish below
// (streamed partial, final store, catch path) bails once superseded — so a
// slow org-A traversal can't write A's slugs into the snapshot after org B
// has taken over (which would leave org === B but projectsOrg === A).
const myGen = (entry._projectsGen = (entry._projectsGen || 0) + 1)
const isCurrentGen = () => entry._projectsGen === myGen && !entry.closed
try {
// Seed the keep-longest guard from any list a PRIOR run already published for
// this org. The streamed partials below feed newly connected clients (they
// read this server snapshot), so an early page of a retry must not overwrite
// a longer prior result — the final-store guard alone can't protect a client
// that connects mid-stream.
const priorKnown = entry.state.getProjectsOrg() === slug ? entry.state.getProjects() : []
let storedLen = Array.isArray(priorKnown) ? priorKnown.length : 0
// Stream results: push each page to the panel as it arrives so the first
// ~100 projects light up the autocomplete in ~1s while the rest fill in.
const projects = await listProjects(slug, (partial) => {
entry.state.setProjects(partial, slug)
const { projects, complete } = await listProjects(slug, (partial) => {
// A newer discovery has superseded this one — drop the partial rather than
// publishing a stale org's slugs into the shared snapshot.
if (!isCurrentGen()) return
// Only store a partial that GROWS the snapshot beyond what's already
// published for this org. Within one traversal partials grow monotonically;
// this guard is what stops a retry's small first page from shrinking a
// longer prior list for clients that connect between pages.
if (!Array.isArray(partial) || partial.length <= storedLen) return
storedLen = partial.length
entry.state.setProjects(partial, slug, false)
entry.notifyClients()
})
entry._projectsFetchedFor = slug
entry.state.setProjects(projects, slug)
// Superseded while we were traversing: skip the final store (and the cache
// marker) so the winning discovery owns the snapshot.
if (!isCurrentGen()) return
// A COMPLETE traversal is authoritative: publish it as-is even if it's
// shorter than a prior run, so projects deleted upstream actually leave the
// dropdown. Only an INCOMPLETE traversal (a mid-mega-org page stall/failure)
// must be prevented from shrinking a longer list we already have — the
// "sometimes the list is much shorter" symptom. The finality flag rides
// along in the snapshot so the client applies the same rule.
const known = entry.state.getProjectsOrg() === slug ? entry.state.getProjects() : []
const best = complete
? projects
: (Array.isArray(known) && known.length > projects.length ? known : projects)
// Only treat the list as definitively cached when the traversal actually
// ran to completion. A truncated run must stay retryable, otherwise the
// first unlucky attempt pins a partial list for the life of the panel.
if (complete) entry._projectsFetchedFor = slug
entry.state.setProjects(best, slug, complete)
entry.notifyClients()
console.error('[sentry-triage] discovered projects for', slug, '->', projects.length)
console.error('[sentry-triage] discovered projects for', slug, '->', best.length)
} catch (err) {
console.error('[sentry-triage] project discovery failed:', err instanceof Error ? err.message : err)
// Superseded by a newer discovery — its own publish owns the snapshot; don't
// clobber it with this stale org's partial/empty list.
if (!isCurrentGen()) return
// The client already received a 200 from POST /api/list-projects (that
// request just kicks off this async fetch), so its own catch never runs and
// the autocomplete's loading indicator would pulse forever. Push a project
@@ -1155,7 +1260,7 @@ async function discoverProjects(entry, org, { force = false } = {}) {
// set `_projectsFetchedFor` here, so a later navigation or force refresh can
// retry a transient failure instead of caching the empty result.
const partial = entry.state.getProjectsOrg() === slug ? entry.state.getProjects() : []
entry.state.setProjects(partial, slug)
entry.state.setProjects(partial, slug, false)
entry.notifyClients()
}
}
@@ -1236,6 +1341,11 @@ async function refreshAll(entry) {
// generation up front fails their isCurrent() guard immediately. triageSentry()
// bumps it again when it runs, which is fine.
entry.scanGen = (entry.scanGen || 0) + 1
// The bump above invalidated any enrichment still in flight. Clear its flag now
// so a path that never runs a replacement triageSentry (the Sentry-unreachable
// return below) can't leave the plain-English toggle disabled forever. A
// reachable scan re-sets it true before dropping the overlay.
resetEnrichingFlag(entry)
// Claim an ordering token for THIS refresh. The refresh endpoint is fire-and-
// forget, so two rapid refresh/period/project actions can resolve out of order:
// a slower, older refresh could publish stale connection state, clear the newer
@@ -1460,7 +1570,7 @@ async function onWorkSelected(entry, issueKeys, modelByKey, assignCopilot) {
// error) before re-queuing, so stale issue/PR/session links don't survive the
// shallow-merge into the new run's status.
entry.state.startWorkAttempt(key)
entry.notifyWork(key, { phase: 'queued' })
entry.notifyWork(key, { phase: 'queued', copilotFix: wantsCopilot })
}
// On a timeout we must `return` (can't pile a new turn onto the shared session
@@ -1500,7 +1610,7 @@ async function onWorkSelected(entry, issueKeys, modelByKey, assignCopilot) {
const workToken = randomUUID()
workRegistry.set(workToken, { entry, key, scopeGen, authorizedRepo: prExpectedRepo, authorizedHost: allowedHost })
entry.notifyWork(key, { phase: 'working', error: '' })
entry.notifyWork(key, { phase: 'working', error: '', copilotFix: wantsCopilot })
try {
const response = await runSessionTurn(() => {
// This closure runs only when the shared-session chain drains to it,
@@ -1665,7 +1775,7 @@ async function onWorkSelected(entry, issueKeys, modelByKey, assignCopilot) {
// retry can't race the live session into a duplicate. Otherwise there's no
// session to wait on — release the token and surface a retryable error.
if (handedOff) {
entry.notifyWork(key, { phase: 'working' })
entry.notifyWork(key, { phase: 'working', copilotFix: wantsCopilot })
scheduleWorkReconcile(entry, key, workToken, scopeCurrent)
continue
}
@@ -1776,7 +1886,7 @@ async function onWorkSelected(entry, issueKeys, modelByKey, assignCopilot) {
// session and then dies without spawning, no callback ever arrives; the
// bounded scheduleWorkReconcile below is the safety net that eventually
// releases the token and returns the card to a retryable error.
entry.notifyWork(key, { phase: 'working' })
entry.notifyWork(key, { phase: 'working', copilotFix: wantsCopilot })
scheduleWorkReconcile(entry, key, workToken, scopeCurrent)
strandRemaining(i + 1)
return
@@ -2040,10 +2150,19 @@ const session = await joinSession({
// from the previous repo fails its isCurrent() guard and applies
// none of its (now stale) tracking / related-issue data.
entry.scanGen = (entry.scanGen || 0) + 1
// No replacement scan runs on a repo switch, so clear the enriching
// flag here too — otherwise the invalidated enrichment (barred from
// clearing it by its isCurrent() guard) would leave the toggle
// disabled forever.
resetEnrichingFlag(entry)
},
defaults: runtimeDefaults,
})
servers.set(ctx.instanceId, entry)
// Apply whatever live catalog we already fetched (best-effort; the
// static fallback in state.mjs stands until refreshAvailableModels
// resolves for the first time).
if (liveModels) entry.state.setAvailableModels(liveModels)
} else if (runtimeDefaults.repo) {
// Panel already existed (e.g. opened before the repo resolved) — top
// up its targets now and push the update to any connected clients.
@@ -2094,3 +2213,8 @@ const session = await joinSession({
// Now that we're joined to the host, resolve the driving session's real repo so
// the PR/Issue targets default correctly (must run before any canvas opens).
await seedDefaultsFromSession()
// Kick off the live model catalog fetch in the background; the static list in
// state.mjs stands until it resolves, so panels are usable immediately either
// way. Not awaited: model.list() is best-effort and shouldn't delay startup.
refreshAvailableModels()
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "sentry-triage",
"version": "1.1.0",
"version": "1.2.0",
"main": "extension.mjs",
"author": "Liz Tom",
"license": "MIT",
+41 -34
View File
@@ -16,8 +16,7 @@ import {
orgList,
projectView,
issueList,
projectListRaw,
runSerial,
projectListPage,
SentryError,
} from './sentryClient.mjs'
@@ -221,52 +220,45 @@ export function categorize({ issues = [], regressed = new Set(), escalating = ne
}
// All project slugs in an org. Pages through the list (the API caps each page at
// 100) so orgs with many projects are fully represented in the dropdown. The
// loop is bounded and stops as soon as a page adds no new slugs, so it stays
// safe even if the underlying cursor doesn't advance. Throws SentryError on an
// auth/permission failure.
// 100) so orgs with many projects are fully represented in the dropdown. Throws
// SentryError on an auth/permission failure.
//
// `onPage(slugsSoFar)` — if provided, called after each page with a snapshot of
// everything collected so far. This lets callers stream results to the UI: the
// first ~100 projects land in ~1s and the rest fill in over the following
// seconds, instead of the caller waiting for the whole (potentially large) list.
export async function listProjects(org, onPage) {
// Run the ENTIRE paged traversal as one atomic SDK operation. The CLI resolves
// the symbolic "next" cursor through global per-command state, so pages must not
// interleave with each other or with any other SDK call (a concurrent issue
// scan, another instance's discovery, a second traversal of this same org).
// runSerial holds the module-wide queue for the whole loop, which guarantees
// that. Inside the task we use projectListRaw (un-queued) to avoid re-entering
// the queue we already hold.
return runSerial(() => listProjectsPaged(org, onPage))
}
async function listProjectsPaged(org, onPage) {
// Each page is its own runSerial task (see projectListPage), so a mega-org's
// traversal no longer holds the shared SDK queue end-to-end. That is what lets
// an interactive project.view lookup interleave and answer in ~1s instead of
// waiting out the whole list — the "Checking Sentry…" hang.
const seen = new Set()
const out = []
const PAGE = 100
const MAX_PAGES = 20
// Wall-clock budget: mega-orgs (e.g. "github" has thousands of projects) can
// take minutes to fully page through, leaving the autocomplete spinning. Stop
// once we've spent this long and return what we have — the client filters the
// collected slugs, and a few hundred is plenty to type against. With streaming
// (onPage) the first page is usable almost immediately regardless.
const BUDGET_MS = 8000
const started = Date.now()
// Bound the traversal by PAGES, not wall-clock. A time budget made the result
// depend on how fast the network happened to be, so the same org yielded a
// different-length list run to run (and a slow run could cache a truncated list
// over a previously complete one). A page cap is deterministic: the same org
// always yields the same list.
const MAX_PAGES = 60
let cursor
let complete = false
for (let page = 0; page < MAX_PAGES; page++) {
let raw
let result
try {
raw = await projectListRaw(org, PAGE, cursor)
result = await projectListPage(org, PAGE, cursor)
} catch (err) {
// A transient page failure (network blip / rate limit) mid-pagination must
// not discard the projects we already collected. Surface the error only
// when we have nothing at all (e.g. page 0 failed => likely auth/bad org).
// not discard the projects we already collected. Observed against a
// mega-org: the first pages return in ~300ms each and then a later page
// stalls before failing, so this path is routine, not exceptional. Surface
// the error only when we have nothing at all (e.g. page 0 failed => likely
// auth/bad org).
if (out.length) break
throw err
}
let added = 0
for (const slug of mapProjects(raw)) {
for (const slug of mapProjects(result.projects)) {
if (seen.has(slug)) continue
seen.add(slug)
out.push(slug)
@@ -275,11 +267,26 @@ async function listProjectsPaged(org, onPage) {
if (added && typeof onPage === 'function') {
try { onPage(out.slice()) } catch { /* streaming is best-effort */ }
}
if (raw.length < PAGE || added === 0) break
if (Date.now() - started > BUDGET_MS) break
cursor = 'next'
// Terminal condition: the SDK's own envelope says there are no further
// pages. A short page is NOT authoritative on its own — the SDK can return
// fewer than requested and still report hasMore:true — so `hasMore` is the
// only signal that marks the traversal complete and cacheable. (When the
// envelope omits hasMore, projectListPage defaults it to false, which
// conservatively ends the traversal rather than looping forever.)
if (!result.hasMore) { complete = true; break }
// The SDK says more pages exist but handed back no usable cursor to fetch
// them, or the cursor did not advance (it returned the same token again).
// Either way we can't safely continue, and the traversal is NOT complete —
// leave `complete` false so the caller keeps it retryable rather than
// pinning a truncated list. Detect this by CURSOR progress, not record
// content: a page can legitimately add zero new slugs (all duplicates or
// filtered records) while still advancing its cursor, and stopping on
// `added === 0` would pin that boundary so every retry halts there and later
// projects never reach the dropdown.
if (!result.nextCursor || result.nextCursor === cursor) break
cursor = result.nextCursor
}
return out
return { projects: out, complete }
}
// Verify a specific project slug exists / is accessible. The org's project list
+37 -13
View File
@@ -143,9 +143,11 @@ async function getSdk() {
//
// `runSerial(task)` runs `task` only once all previously enqueued work has
// settled, so at most one SDK operation is ever in flight process-wide. It is the
// single choke point; the public functions below are thin queued wrappers, and
// multi-call traversals (see projectListRaw) run as ONE task so their paging
// can't interleave with anything.
// single choke point; the public functions below are thin queued wrappers. Each
// task should be a SINGLE SDK call: a multi-call traversal that holds the queue
// for its whole run starves every interactive lookup behind it (see
// projectListPage, which pages via an explicit cursor so each page queues
// independently).
let sdkQueue = Promise.resolve()
export function runSerial(task) {
@@ -279,17 +281,39 @@ export async function orgList(limit = 100) {
return runSerial(async () => asArray(await (await getSdk()).org.list({ limit })))
}
// Single-page project fetch within an org. Deliberately NOT wrapped in
// runSerial on its own: the CLI's positional org/project value treats a BARE
// slug as a *project*, so listing every project in an org requires the trailing
// `<org>/` form — we normalize to exactly one trailing slash here. Raw project
// objects (each has a `slug`). `cursor` navigates pages ("next"/"prev"/raw
// cursor). Callers that page through the full list must instead run
// projectListRaw inside a single runSerial task so the whole traversal is atomic
// (see listProjects in sentry.mjs).
export async function projectListRaw(org, limit = 100, cursor) {
// Single page of an org's project list, fetched as its OWN queued task and
// returning an explicit `nextCursor` so the caller can resume without holding
// the queue between pages.
//
// This is what keeps a mega-org's multi-page traversal from starving the rest of
// the UI. The symbolic "next" cursor resolves through the SDK's module-global
// per-command state, so a traversal that used it had to hold the whole sdkQueue
// for every page (otherwise an interleaved call would clobber that state) —
// which meant a slow `github`-sized list blocked the O(1) project.view lookup
// behind it for minutes and left "Checking Sentry…" spinning. The envelope's
// `nextCursor` is a self-contained opaque token, so each page can be an
// independent runSerial task and interactive lookups can slot in between them.
//
// The CLI's positional org/project value treats a BARE slug as a *project*, so
// listing every project in an org requires the trailing `<org>/` form — we
// normalize to exactly one trailing slash here.
//
// Returns { projects, nextCursor, hasMore }. `hasMore` is the SDK envelope's own
// "there are further pages" signal (defaults to false when the envelope omits
// it); `nextCursor` is '' unless the SDK handed back a usable opaque token. The
// two are reported separately so the caller can tell "genuinely done" (hasMore
// false) apart from "more pages exist but we have no cursor to fetch them" — the
// latter is an incomplete traversal, not a terminal one, and must stay retryable.
export async function projectListPage(org, limit = 100, cursor) {
const orgProject = `${String(org || '').replace(/\/+$/, '')}/`
return asArray(await (await getSdk()).project.list({ orgProject, limit, ...(cursor ? { cursor } : {}) }))
const res = await runSerial(async () => (await getSdk()).project.list({ orgProject, limit, ...(cursor ? { cursor } : {}) }))
const projects = asArray(res)
// Only trust an explicit opaque cursor. A truthy `hasMore` without a usable
// `nextCursor` would otherwise tempt us back onto the symbolic "next" token
// and reintroduce the global-state coupling this function exists to avoid.
const next = res && typeof res === 'object' && typeof res.nextCursor === 'string' ? res.nextCursor.trim() : ''
const hasMore = res && typeof res === 'object' ? res.hasMore === true : false
return { projects, nextCursor: hasMore ? next : '', hasMore }
}
// Verify a specific project exists / is accessible via the SDK's O(1)
+12
View File
@@ -108,11 +108,13 @@ export function startServer({ port = 0, onRefresh, onAction, onWorkSelected, onR
periods: PERIODS,
projects: state.getProjects(),
projectsOrg: state.getProjectsOrg(),
projectsComplete: state.getProjectsComplete(),
connections: state.getConnections(),
prTargets: state.getPrTargets(),
availableModels: state.getAvailableModels(),
prSettingsOpen: state.getPrSettingsOpen(),
plainEnglishView: state.getPlainEnglishView(),
plainEnglishEnriching: state.getPlainEnglishEnriching(),
issueTrackers: state.getIssueTrackers(),
selectedTracker: state.getSelectedTracker(),
workByIssueKey: state.getWorkByIssueKey(),
@@ -153,6 +155,15 @@ export function startServer({ port = 0, onRefresh, onAction, onWorkSelected, onR
}
}
// Broadcast plain-English enrichment start/stop so the client can disable the
// toggle (and show a "preparing…" hint) until summaries are ready.
function notifyPlainEnglishEnriching(isEnriching) {
const data = JSON.stringify({ plainEnglishEnriching: Boolean(isEnriching) })
for (const res of sseClients) {
res.write(`data: ${data}\n\n`)
}
}
function handleRequest(req, res) {
// Reject DNS-rebinding hosts on EVERY request (see the trust-model comment):
// a rebinding page still carries its own hostname in `Host`, so anything but
@@ -546,6 +557,7 @@ export function startServer({ port = 0, onRefresh, onAction, onWorkSelected, onR
notifyWork,
notifyFlash,
notifyScanning,
notifyPlainEnglishEnriching,
close,
})
})
+58 -7
View File
@@ -52,6 +52,12 @@ export function createState() {
// SSE project broadcast to the right org (setup screen switches org before any
// scan, so the panel's own org isn't a reliable signal).
let projectsOrg = ''
// Finality of the current `projects` list: true only when the traversal that
// produced it ran to completion (SDK reported no further pages). A COMPLETE
// list is authoritative and may legitimately be shorter than a prior one
// (projects deleted upstream); an INCOMPLETE/streamed one must not shrink a
// longer list. Consumed by the client's keep-longest guard via the snapshot.
let projectsComplete = false
// Sentry search window. Defaults to the last day; the user can widen it from
// the issues list to look further back. Only Sentry's supported periods are
// accepted (see PERIODS below).
@@ -68,6 +74,7 @@ export function createState() {
// Sentry error. Canvas-wide, defaults to the raw error so on-call sees exactly
// what Sentry reported first; the user can flip to plain English from the header.
let plainEnglishView = false
let plainEnglishEnriching = false
let selectedTracker = 'github'
const workByIssueKey = {}
// The issue trackers the "Work on selected" hand-off can file into. The agent
@@ -79,18 +86,27 @@ export function createState() {
{ id: 'linear', label: 'Linear', connected: true },
{ id: 'atlassian', label: 'Jira (Atlassian)', connected: true },
]
// Models the spawned remediation session can run under. The empty id means
// "let the session pick its default model"; every other id must match a value
// the host create_session tool accepts, or the hand-off would fail.
const availableModels = [
{ id: '', label: 'Auto (session default)' },
// Models the spawned remediation session can run under. The empty id always
// means "let the session pick its default model". The rest of the list is
// seeded with a static fallback (used until the live catalog loads, or if
// that fetch ever fails) and is normally replaced at startup by
// setAvailableModels() with the host's live model list (see extension.mjs,
// which calls session.rpc.model.list()) so this never needs to be hand-edited
// again when new models ship.
const AUTO_MODEL = { id: '', label: 'Auto (session default)' }
const FALLBACK_MODELS = [
{ id: 'claude-sonnet-5', label: 'Claude Sonnet 5' },
{ id: 'claude-opus-5', label: 'Claude Opus 5' },
{ id: 'claude-opus-4.8', label: 'Claude Opus 4.8' },
{ id: 'gpt-5.6-sol', label: 'GPT-5.6 Sol' },
{ id: 'gpt-5.5', label: 'GPT-5.5' },
{ id: 'gpt-5.4', label: 'GPT-5.4' },
{ id: 'gpt-5.3-codex', label: 'GPT-5.3-Codex' },
{ id: 'gemini-3.7-flash', label: 'Gemini 3.7 Flash' },
{ id: 'grok-4.5', label: 'Grok 4.5' },
]
const MODEL_IDS = new Set(availableModels.map((model) => model.id))
let availableModels = [AUTO_MODEL, ...FALLBACK_MODELS]
let MODEL_IDS = new Set(availableModels.map((model) => model.id))
const prTargets = {
mode: 'local',
model: '',
@@ -245,6 +261,10 @@ export function createState() {
return projectsOrg
},
getProjectsComplete() {
return projectsComplete
},
getConnections() {
return connections
},
@@ -261,6 +281,26 @@ export function createState() {
return availableModels
},
// Replaces the model list with the host's live catalog (see
// session.rpc.model.list() in extension.mjs). Keeps the current selection
// if it's still valid in the new list; otherwise falls back to Auto so a
// model retired upstream can't leave prTargets pointing at a dead id.
setAvailableModels(models) {
const seen = new Set([''])
const deduped = []
for (const m of Array.isArray(models) ? models : []) {
const id = typeof m?.id === 'string' ? m.id.trim() : ''
const label = typeof m?.label === 'string' && m.label.trim() ? m.label.trim() : id
if (!id || seen.has(id)) continue
seen.add(id)
deduped.push({ id, label })
}
if (deduped.length === 0) return
availableModels = [AUTO_MODEL, ...deduped]
MODEL_IDS = new Set(availableModels.map((model) => model.id))
if (prTargets.model && !MODEL_IDS.has(prTargets.model)) prTargets.model = ''
},
getIssueTrackers() {
return issueTrackers
},
@@ -289,7 +329,7 @@ export function createState() {
project = typeof slug === 'string' ? slug.trim() : ''
},
setProjects(list, org) {
setProjects(list, org, complete = false) {
const seen = new Set()
const out = []
for (const item of Array.isArray(list) ? list : []) {
@@ -300,6 +340,7 @@ export function createState() {
}
projects = out
if (typeof org === 'string') projectsOrg = org.trim().toLowerCase()
projectsComplete = complete === true
return projects
},
@@ -346,7 +387,17 @@ export function createState() {
return plainEnglishView
},
getPlainEnglishEnriching() {
return plainEnglishEnriching
},
setPlainEnglishEnriching(on) {
plainEnglishEnriching = Boolean(on)
return plainEnglishEnriching
},
togglePlainEnglishView() {
if (plainEnglishEnriching) return plainEnglishView
plainEnglishView = !plainEnglishView
return plainEnglishView
},
@@ -2,7 +2,9 @@ import { escapeHtml, safeHref } from '../escape.mjs'
function statusLabel(workStatus) {
if (!workStatus || typeof workStatus !== 'object') return ''
if (workStatus.phase === 'working' || workStatus.phase === 'queued') return '⏳ working…'
if (workStatus.phase === 'working' || workStatus.phase === 'queued') {
return workStatus.copilotFix ? '⏳ starting Copilot fix session…' : '⏳ filing tracking issue…'
}
if (workStatus.phase === 'done') {
// Numbers move into clickable links (statusLinks); keep the label a plain badge.
return 'created ✓'
@@ -48,6 +48,7 @@ export function Page({
prTargets,
prSettingsOpen,
plainEnglishView = false,
plainEnglishEnriching = false,
projects = [],
availableModels = [],
issueTrackers,
@@ -313,14 +314,14 @@ export function Page({
</div>
<div id="title-mode-bar" class="title-mode-bar" style="${hasOrg && totalIssues > 0 ? '' : 'display:none;'}">
<label class="switch-label" for="title-mode-switch" title="Switch every card title between the raw Sentry error and a plain-English summary">
<label class="switch-label" for="title-mode-switch" title="Switch every card message between the raw Sentry error and a plain-English summary">
<span class="switch">
<input type="checkbox" id="title-mode-switch" class="switch-input"${plainEnglishView ? ' checked' : ''} />
<input type="checkbox" id="title-mode-switch" class="switch-input"${plainEnglishView ? ' checked' : ''}${plainEnglishEnriching ? ' disabled' : ''} />
<span class="switch-slider" aria-hidden="true"></span>
</span>
<span class="switch-text">Plain-English titles</span>
<span class="switch-text">Plain-English messages</span>
</label>
<span class="title-mode-hint">Showing <strong id="title-mode-state">${plainEnglishView ? 'plain-English summaries' : 'raw errors'}</strong></span>
<span class="title-mode-hint">${plainEnglishEnriching ? '<strong id="title-mode-state">Preparing plain-English summaries — toggle available shortly…</strong>' : `Showing <strong id="title-mode-state">${plainEnglishView ? 'plain-English summaries' : 'raw errors'}</strong>`}</span>
</div>
<main id="categories">
@@ -406,6 +407,10 @@ export function Page({
// is already running for a slug, later callers (e.g. the Scan/Fetch gate)
// queue here and are flushed when it settles, instead of being dropped.
const projectResolveWaiters = {};
// Ceiling for a single exact-slug lookup before we give up and report a
// transient failure. Generous enough to absorb a normal queue wait behind
// an in-progress scan, short enough that the user is never stranded.
const RESOLVE_TIMEOUT_MS = 20000;
function projectResolveKey(org, slug) {
return String(org || "").trim().toLowerCase() + "/" + String(slug || "").trim().toLowerCase();
}
@@ -431,6 +436,10 @@ export function Page({
// An empty project scans all projects and needs no lookup.
verifyProjectForScan(org, project).then((v) => {
if (!v.ok) {
// "stale" means the user changed the org/project while the lookup was
// in flight — they've already moved on, so don't scan the old scope
// and don't nag them about a slug they abandoned.
if (v.reason === "stale") return;
showToast(v.reason === "missing"
? "No project \u201C" + project + "\u201D in " + org + " — check the slug."
: "Couldn't verify that project with Sentry — try again.");
@@ -699,6 +708,7 @@ export function Page({
let lastAutoFetchedOrgDefault = "";
let currentAvailableModels = ${jsonForScript(Array.isArray(availableModels) ? availableModels : [])};
let currentPlainEnglishView = ${jsonForScript(plainEnglishView)};
let currentPlainEnglishEnriching = ${jsonForScript(plainEnglishEnriching)};
let currentScanError = ${jsonForScript(scanError || '')};
let currentScannedTotal = ${jsonForScript(scannedTotal)};
let currentScannedCapped = ${jsonForScript(scannedCapped)};
@@ -741,7 +751,7 @@ export function Page({
function statusText(status) {
if (!status || typeof status !== "object") return "";
if (status.phase === "queued" || status.phase === "working") return "⏳ working…";
if (status.phase === "queued" || status.phase === "working") return status.copilotFix ? "⏳ starting Copilot fix session…" : "⏳ filing tracking issue…";
if (status.phase === "skipped") return "🔒 already being worked on";
if (status.phase === "tracked") return "👀 Tracked";
if (status.phase === "done") {
@@ -1089,17 +1099,34 @@ export function Page({
if (psel && psel.value !== msg.period) psel.value = msg.period;
}
if (Array.isArray(msg.periods)) currentPeriods = msg.periods;
if (typeof msg.plainEnglishEnriching === "boolean" && msg.plainEnglishEnriching !== currentPlainEnglishEnriching) {
currentPlainEnglishEnriching = msg.plainEnglishEnriching;
syncTitleSwitch();
}
if (Array.isArray(msg.projects)) {
const forOrg = typeof msg.projectsOrg === "string" ? msg.projectsOrg.trim().toLowerCase() : "";
const projectsComplete = msg.projectsComplete === true;
// Keep-longest ONLY for streamed/incomplete snapshots. The server
// streams partial pages (each broadcast is a growing snapshot), and a
// discovery run that fails or is cut short mid-traversal publishes a
// truncated snapshot for an org we already have fully loaded — the
// dropdown "sometimes comes back much shorter" symptom. But a COMPLETE
// traversal is authoritative and may legitimately be shorter (projects
// deleted upstream), so it must be allowed to replace the cache —
// otherwise deleted slugs would linger in autocomplete for the panel's
// whole life. Growth within a run still lands normally.
const known = forOrg ? projectsByOrg[forOrg] : null;
const regression = !projectsComplete && Array.isArray(known) && known.length > msg.projects.length;
const projects = regression ? known : msg.projects;
// Always cache under the org this list belongs to so re-selecting it is
// instant next time.
if (forOrg) projectsByOrg[forOrg] = msg.projects;
if (forOrg && !regression) projectsByOrg[forOrg] = msg.projects;
// Only paint the on-screen field if this broadcast matches the org the
// user currently has selected — a background refresh for a previous org
// must not clobber the current list.
const sel = selectedOrgSlug();
if (!forOrg || !sel || forOrg === sel) {
currentSentryProjects = msg.projects;
currentSentryProjects = projects;
setProjectLoading(false);
// Repaint whichever project field is on screen with the new options.
if (currentOrg) renderProjectSwitcher();
@@ -1129,7 +1156,20 @@ export function Page({
currentSavedDefaultOrg = msg.savedDefaultOrg;
refreshDefaultBtn();
}
if (Array.isArray(msg.availableModels)) currentAvailableModels = msg.availableModels;
if (Array.isArray(msg.availableModels)) {
currentAvailableModels = msg.availableModels;
// The host's model catalog can change under us (models ship or retire).
// Drop any per-card override whose id is no longer offered: otherwise the
// <select> falls back to rendering "Default" while modelByKey still holds
// the dead id, counts it as an override, and POSTs it — where the server
// silently discards it. Prune, then re-sync the card selects.
const validModelIds = new Set(currentAvailableModels.map((m) => m && m.id).filter(Boolean));
let prunedModelOverride = false;
for (const k of Object.keys(modelByKey)) {
if (!validModelIds.has(modelByKey[k])) { delete modelByKey[k]; prunedModelOverride = true; }
}
if (prunedModelOverride) syncCardModels();
}
if (typeof msg.plainEnglishView === "boolean" && msg.plainEnglishView !== currentPlainEnglishView) {
currentPlainEnglishView = msg.plainEnglishView;
syncTitleSwitch();
@@ -1397,8 +1437,15 @@ export function Page({
}
projectResolveCache[key] = { status: "checking", slug: "" };
// Record the settled entry, notify this caller, then flush anyone who
// queued while the request was in flight.
// queued while the request was in flight. Idempotent: whichever of the
// response and the timeout below fires first wins, and the loser is a
// no-op (so a late response can't overwrite the timeout's "error" and
// silently re-arm a lookup the user was already told had failed).
let settled = false;
const settle = (entry) => {
if (settled) return;
settled = true;
clearTimeout(timer);
projectResolveCache[key] = entry;
if (onDone) onDone(entry);
const waiters = projectResolveWaiters[key];
@@ -1407,6 +1454,13 @@ export function Page({
for (const fn of waiters) { try { fn(entry); } catch (_) {} }
}
};
// Hard ceiling on a single lookup. The server side runs on a shared,
// strictly-serial Sentry SDK queue, so this request can end up waiting
// behind other work; without a bound the UI would sit on "Checking
// Sentry…" indefinitely with no way out. Settling as a transient "error"
// (not "missing") keeps the honest distinction — the user gets a
// "couldn't check — press Enter to retry" they can act on.
const timer = setTimeout(() => settle({ status: "error", slug: "" }), RESOLVE_TIMEOUT_MS);
fetch("/api/resolve-project", {
method: "POST",
headers: { "Content-Type": "application/json" },
@@ -1430,6 +1484,14 @@ export function Page({
// project is an all-projects scan (always allowed); a locally-known project
// needs no lookup; anything else is verified via the shared resolver, reusing
// its cache and in-flight de-duplication.
//
// Resolves { ok: false, reason: "stale" } if the user changed the org or
// project while the lookup was in flight. A slow lookup (it queues behind
// whatever else is using the shared Sentry SDK chain) can settle long after
// the user has moved on; without this guard its callback would go on to
// overwrite the project input and scan the OLD slug — the "I selected ace
// but it scanned github-app" bug. The autocomplete's Enter path has always
// had this guard; this brings the Scan/Fetch path in line.
function verifyProjectForScan(org, project) {
return new Promise((resolve) => {
const o = String(org || "").trim().toLowerCase();
@@ -1446,7 +1508,15 @@ export function Page({
if (rc && rc.status === "found") { resolve({ ok: true, slug: rc.slug || raw }); return; }
if (rc && rc.status === "missing") { resolve({ ok: false, reason: "missing", slug: raw }); return; }
if (rc && rc.status === "error") delete projectResolveCache[key];
// Snapshot the scope this lookup was started for, so a late completion
// can be discarded rather than applied to a scope the user has left.
const scopeStillCurrent = () => {
const inp = activeProjectInput();
const nowProject = inp ? inp.value.trim().toLowerCase() : p;
return selectedOrgSlug() === o && nowProject === p;
};
resolveProjectSlug(o, p, (res) => {
if (!scopeStillCurrent()) { resolve({ ok: false, reason: "stale", slug: raw }); return; }
if (res && res.status === "found") resolve({ ok: true, slug: res.slug || raw });
else resolve({ ok: false, reason: (res && res.status) || "error", slug: raw });
});
@@ -1843,9 +1913,16 @@ export function Page({
// issue list; this keeps the toggle in sync with state (checkbox + hint).
function syncTitleSwitch() {
const box = document.getElementById("title-mode-switch");
if (box) box.checked = currentPlainEnglishView;
const state = document.getElementById("title-mode-state");
if (state) state.textContent = currentPlainEnglishView ? "plain-English summaries" : "raw errors";
if (box) {
box.checked = currentPlainEnglishView;
box.disabled = currentPlainEnglishEnriching;
}
const hint = document.querySelector(".title-mode-hint");
if (hint) {
hint.innerHTML = currentPlainEnglishEnriching
? '<strong id="title-mode-state">Preparing plain-English summaries — toggle available shortly…</strong>'
: 'Showing <strong id="title-mode-state">' + (currentPlainEnglishView ? "plain-English summaries" : "raw errors") + '</strong>';
}
}
// Flip the whole canvas between raw error and plain-English titles. Optimistic:
@@ -1854,6 +1931,7 @@ export function Page({
document.addEventListener("change", (e) => {
const box = e.target.closest("#title-mode-switch");
if (!box) return;
if (currentPlainEnglishEnriching) return;
currentPlainEnglishView = box.checked;
syncTitleSwitch();
renderCategories(currentCategories);
@@ -1878,6 +1956,10 @@ export function Page({
// empty project scans all projects and needs no lookup.
verifyProjectForScan(org, project).then((v) => {
if (!v.ok) {
// "stale" means the user changed the org/project while the lookup was
// in flight — they've already moved on, so don't scan the old scope
// and don't nag them about a slug they abandoned.
if (v.reason === "stale") return;
showToast(v.reason === "missing"
? "No project \u201C" + project + "\u201D in " + org + " — check the slug."
: "Couldn't verify that project with Sentry — try again.");
@@ -2167,7 +2249,7 @@ export function Page({
const keys = startableSelectedKeys();
if (keys.length === 0) return;
keys.forEach((key) => {
workByIssue[key] = { phase: "queued" };
workByIssue[key] = { phase: "queued", copilotFix: false };
selectedKeys.delete(key);
});
applySelections();
@@ -2184,12 +2266,12 @@ export function Page({
const models = {};
keys.forEach((key) => {
if (modelByKey[key]) models[key] = modelByKey[key];
workByIssue[key] = { phase: "queued" };
workByIssue[key] = { phase: "queued", copilotFix: true };
selectedKeys.delete(key);
});
applySelections();
applyWorkStates();
showToast("🔧 Creating/reusing issues and starting Copilot for " + keys.length + " issues…");
showToast("🔧 Starting a background Copilot session for " + keys.length + " issue" + (keys.length === 1 ? "" : "s") + " — you can keep triaging while it runs…");
postWorkSelected(keys, { keys, modelByKey: models, assignCopilot: true });
});
@@ -205,6 +205,35 @@ function safeIssueKey(value) {
const servers = new Map()
// Live model catalog fetched from the host (session.rpc.model.list()), cached
// here so a newly opened panel gets it immediately instead of waiting on
// another RPC round trip. Stays null until the first successful fetch; every
// server keeps its state.mjs static fallback list until then.
let liveModels = null
// Fetches the host's current model catalog and pushes it into every open
// panel's state (plus caches it for panels opened afterward), so the picker
// tracks whatever models the host makes available without editing this
// extension's static list every time a model ships or is retired. Best-effort:
// on any failure (older host, RPC error) every panel just keeps using the
// static fallback list already seeded in state.mjs.
async function refreshAvailableModels() {
try {
const result = await session.rpc.model.list()
const models = (result?.list || [])
.map((m) => ({ id: String(m?.id ?? ''), label: String(m?.name ?? m?.id ?? '') }))
.filter((m) => m.id)
if (models.length === 0) return
liveModels = models
for (const entry of servers.values()) {
entry.state.setAvailableModels(models)
entry.notifyClients()
}
} catch (err) {
console.error('[sentry-triage] could not fetch live model list, using static fallback:', err?.message || err)
}
}
// Maps an opaque per-work token -> { entry, key } for a "Work on selected"
// hand-off. The spawned fix session echoes this token back via `submit_work_pr`
// so the PR update lands on the EXACT canvas instance and issue that started the
@@ -739,6 +768,20 @@ OR
{"status":"error","error":"plain explanation","issue":{"number":123,"url":"https://..."},"session":{"id":null,"name":""}}`
}
// Reset the plain-English enriching flag when an in-flight enrichment is
// invalidated (scan generation bumped) by a path that will NOT start a
// replacement scan to clear it. triageSentry's isCurrent() guard deliberately
// stops a STALE scan from clearing a NEWER scan's flag; the side effect is that
// an invalidation with no successor (a refresh that finds Sentry unreachable, or
// a repo switch via onInvalidateEnrichment) would otherwise strand the flag
// `true` forever and leave the plain-English toggle disabled. Clearing it here
// is safe: a reachable scan's triageSentry re-sets it true before it drops the
// scanning overlay, so the toggle is never interactive with a false flag.
function resetEnrichingFlag(entry) {
entry.state.setPlainEnglishEnriching(false)
if (entry.notifyPlainEnglishEnriching) entry.notifyPlainEnglishEnriching(false)
}
async function triageSentry(entry) {
if (entry.closed) return
const org = entry.state.getOrg()
@@ -788,8 +831,16 @@ async function triageSentry(entry) {
// 240s; blocking the whole board on it would leave the user staring at a
// spinner. The card renderer already falls back to the raw title when
// `plainEnglish` is absent, so an early render is fully usable.
// Set the enriching flag BEFORE publishing the categories snapshot so that
// snapshot already reports enrichment as in-progress. Publishing categories
// first would emit a snapshot saying enrichment is idle (re-enabling the
// toggle), and a user action slipping in before the separate
// plainEnglishEnriching:true event preserves a narrow version of the race
// this flag exists to remove.
entry.state.setPlainEnglishEnriching(true)
entry.state.setCategories(categories)
entry.notifyClients()
if (entry.notifyPlainEnglishEnriching) entry.notifyPlainEnglishEnriching(true)
// End the scanning overlay NOW that the board is published — enrichment is
// optional polish (plain-English titles + tracked badges) that streams in via
// a later publish. Leaving the overlay up for the whole ≤240s enrich turn
@@ -805,7 +856,14 @@ async function triageSentry(entry) {
// so a stale turn (a newer scan started meanwhile) can't apply its inbox data
// over the newer scan's state.
await enrichPlainEnglish(entry, categories, isCurrent)
// A stale turn (a newer scan started during our ≤240s enrich) must NOT clear
// or broadcast the shared enriching flag: doing so would re-enable the toggle
// while the newer scan is still enriching, recreating the exact race this
// flag prevents. Bail before touching it — the current scan owns the flag and
// clears it when IT finishes (and the guarded `finally` is the fallback).
if (!isCurrent()) return
entry.state.setPlainEnglishEnriching(false)
if (entry.notifyPlainEnglishEnriching) entry.notifyPlainEnglishEnriching(false)
entry.state.setCategories(categories)
entry.notifyClients()
console.error('[sentry-triage] Scan complete, categories:', categories.length, error ? `error: ${error}` : '')
@@ -821,6 +879,10 @@ async function triageSentry(entry) {
entry.notifyClients()
}
} finally {
if (isCurrent()) {
entry.state.setPlainEnglishEnriching(false)
if (entry.notifyPlainEnglishEnriching) entry.notifyPlainEnglishEnriching(false)
}
if (isCurrent() && entry.notifyScanning) entry.notifyScanning(false)
}
}
@@ -915,7 +977,7 @@ After the tool call(s), reply to the user with a confirmation sentence, then a b
Triaged ${total} Sentry ${noun}.
**📋 Next steps in the canvas:** Card titles show the raw Sentry error by default flip the **Plain-English titles** switch above the issue list for readable summaries. Open the Settings panel to confirm the open-issue and draft-PR targets point at the right repo, then select issues to work.
**📋 Next steps in the canvas:** Card titles show the raw Sentry error by default flip the **Plain-English messages** switch above the issue list for readable summaries. Open the Settings panel to confirm the open-issue and draft-PR targets point at the right repo, then select issues to work.
Keep it friendly and brief; do not include the summaries or JSON.
@@ -1134,19 +1196,62 @@ async function discoverProjects(entry, org, { force = false } = {}) {
const conn = entry.state.getConnections()
if (!conn || !conn.sentry || !conn.sentry.reachable) return
if (!force && entry._projectsFetchedFor === slug) return
// Each page now releases the shared SDK queue, so discoveries for different
// orgs (A then B) can interleave. Stamp this discovery with a generation
// token; a newer discoverProjects call bumps it, and every publish below
// (streamed partial, final store, catch path) bails once superseded — so a
// slow org-A traversal can't write A's slugs into the snapshot after org B
// has taken over (which would leave org === B but projectsOrg === A).
const myGen = (entry._projectsGen = (entry._projectsGen || 0) + 1)
const isCurrentGen = () => entry._projectsGen === myGen && !entry.closed
try {
// Seed the keep-longest guard from any list a PRIOR run already published for
// this org. The streamed partials below feed newly connected clients (they
// read this server snapshot), so an early page of a retry must not overwrite
// a longer prior result — the final-store guard alone can't protect a client
// that connects mid-stream.
const priorKnown = entry.state.getProjectsOrg() === slug ? entry.state.getProjects() : []
let storedLen = Array.isArray(priorKnown) ? priorKnown.length : 0
// Stream results: push each page to the panel as it arrives so the first
// ~100 projects light up the autocomplete in ~1s while the rest fill in.
const projects = await listProjects(slug, (partial) => {
entry.state.setProjects(partial, slug)
const { projects, complete } = await listProjects(slug, (partial) => {
// A newer discovery has superseded this one — drop the partial rather than
// publishing a stale org's slugs into the shared snapshot.
if (!isCurrentGen()) return
// Only store a partial that GROWS the snapshot beyond what's already
// published for this org. Within one traversal partials grow monotonically;
// this guard is what stops a retry's small first page from shrinking a
// longer prior list for clients that connect between pages.
if (!Array.isArray(partial) || partial.length <= storedLen) return
storedLen = partial.length
entry.state.setProjects(partial, slug, false)
entry.notifyClients()
})
entry._projectsFetchedFor = slug
entry.state.setProjects(projects, slug)
// Superseded while we were traversing: skip the final store (and the cache
// marker) so the winning discovery owns the snapshot.
if (!isCurrentGen()) return
// A COMPLETE traversal is authoritative: publish it as-is even if it's
// shorter than a prior run, so projects deleted upstream actually leave the
// dropdown. Only an INCOMPLETE traversal (a mid-mega-org page stall/failure)
// must be prevented from shrinking a longer list we already have — the
// "sometimes the list is much shorter" symptom. The finality flag rides
// along in the snapshot so the client applies the same rule.
const known = entry.state.getProjectsOrg() === slug ? entry.state.getProjects() : []
const best = complete
? projects
: (Array.isArray(known) && known.length > projects.length ? known : projects)
// Only treat the list as definitively cached when the traversal actually
// ran to completion. A truncated run must stay retryable, otherwise the
// first unlucky attempt pins a partial list for the life of the panel.
if (complete) entry._projectsFetchedFor = slug
entry.state.setProjects(best, slug, complete)
entry.notifyClients()
console.error('[sentry-triage] discovered projects for', slug, '->', projects.length)
console.error('[sentry-triage] discovered projects for', slug, '->', best.length)
} catch (err) {
console.error('[sentry-triage] project discovery failed:', err instanceof Error ? err.message : err)
// Superseded by a newer discovery — its own publish owns the snapshot; don't
// clobber it with this stale org's partial/empty list.
if (!isCurrentGen()) return
// The client already received a 200 from POST /api/list-projects (that
// request just kicks off this async fetch), so its own catch never runs and
// the autocomplete's loading indicator would pulse forever. Push a project
@@ -1155,7 +1260,7 @@ async function discoverProjects(entry, org, { force = false } = {}) {
// set `_projectsFetchedFor` here, so a later navigation or force refresh can
// retry a transient failure instead of caching the empty result.
const partial = entry.state.getProjectsOrg() === slug ? entry.state.getProjects() : []
entry.state.setProjects(partial, slug)
entry.state.setProjects(partial, slug, false)
entry.notifyClients()
}
}
@@ -1236,6 +1341,11 @@ async function refreshAll(entry) {
// generation up front fails their isCurrent() guard immediately. triageSentry()
// bumps it again when it runs, which is fine.
entry.scanGen = (entry.scanGen || 0) + 1
// The bump above invalidated any enrichment still in flight. Clear its flag now
// so a path that never runs a replacement triageSentry (the Sentry-unreachable
// return below) can't leave the plain-English toggle disabled forever. A
// reachable scan re-sets it true before dropping the overlay.
resetEnrichingFlag(entry)
// Claim an ordering token for THIS refresh. The refresh endpoint is fire-and-
// forget, so two rapid refresh/period/project actions can resolve out of order:
// a slower, older refresh could publish stale connection state, clear the newer
@@ -1460,7 +1570,7 @@ async function onWorkSelected(entry, issueKeys, modelByKey, assignCopilot) {
// error) before re-queuing, so stale issue/PR/session links don't survive the
// shallow-merge into the new run's status.
entry.state.startWorkAttempt(key)
entry.notifyWork(key, { phase: 'queued' })
entry.notifyWork(key, { phase: 'queued', copilotFix: wantsCopilot })
}
// On a timeout we must `return` (can't pile a new turn onto the shared session
@@ -1500,7 +1610,7 @@ async function onWorkSelected(entry, issueKeys, modelByKey, assignCopilot) {
const workToken = randomUUID()
workRegistry.set(workToken, { entry, key, scopeGen, authorizedRepo: prExpectedRepo, authorizedHost: allowedHost })
entry.notifyWork(key, { phase: 'working', error: '' })
entry.notifyWork(key, { phase: 'working', error: '', copilotFix: wantsCopilot })
try {
const response = await runSessionTurn(() => {
// This closure runs only when the shared-session chain drains to it,
@@ -1665,7 +1775,7 @@ async function onWorkSelected(entry, issueKeys, modelByKey, assignCopilot) {
// retry can't race the live session into a duplicate. Otherwise there's no
// session to wait on — release the token and surface a retryable error.
if (handedOff) {
entry.notifyWork(key, { phase: 'working' })
entry.notifyWork(key, { phase: 'working', copilotFix: wantsCopilot })
scheduleWorkReconcile(entry, key, workToken, scopeCurrent)
continue
}
@@ -1776,7 +1886,7 @@ async function onWorkSelected(entry, issueKeys, modelByKey, assignCopilot) {
// session and then dies without spawning, no callback ever arrives; the
// bounded scheduleWorkReconcile below is the safety net that eventually
// releases the token and returns the card to a retryable error.
entry.notifyWork(key, { phase: 'working' })
entry.notifyWork(key, { phase: 'working', copilotFix: wantsCopilot })
scheduleWorkReconcile(entry, key, workToken, scopeCurrent)
strandRemaining(i + 1)
return
@@ -2040,10 +2150,19 @@ const session = await joinSession({
// from the previous repo fails its isCurrent() guard and applies
// none of its (now stale) tracking / related-issue data.
entry.scanGen = (entry.scanGen || 0) + 1
// No replacement scan runs on a repo switch, so clear the enriching
// flag here too — otherwise the invalidated enrichment (barred from
// clearing it by its isCurrent() guard) would leave the toggle
// disabled forever.
resetEnrichingFlag(entry)
},
defaults: runtimeDefaults,
})
servers.set(ctx.instanceId, entry)
// Apply whatever live catalog we already fetched (best-effort; the
// static fallback in state.mjs stands until refreshAvailableModels
// resolves for the first time).
if (liveModels) entry.state.setAvailableModels(liveModels)
} else if (runtimeDefaults.repo) {
// Panel already existed (e.g. opened before the repo resolved) — top
// up its targets now and push the update to any connected clients.
@@ -2094,3 +2213,8 @@ const session = await joinSession({
// Now that we're joined to the host, resolve the driving session's real repo so
// the PR/Issue targets default correctly (must run before any canvas opens).
await seedDefaultsFromSession()
// Kick off the live model catalog fetch in the background; the static list in
// state.mjs stands until it resolves, so panels are usable immediately either
// way. Not awaited: model.list() is best-effort and shouldn't delay startup.
refreshAvailableModels()
@@ -1,6 +1,6 @@
{
"name": "sentry-triage",
"version": "1.1.0",
"version": "1.2.0",
"main": "extension.mjs",
"author": "Liz Tom",
"license": "MIT",
@@ -16,8 +16,7 @@ import {
orgList,
projectView,
issueList,
projectListRaw,
runSerial,
projectListPage,
SentryError,
} from './sentryClient.mjs'
@@ -221,52 +220,45 @@ export function categorize({ issues = [], regressed = new Set(), escalating = ne
}
// All project slugs in an org. Pages through the list (the API caps each page at
// 100) so orgs with many projects are fully represented in the dropdown. The
// loop is bounded and stops as soon as a page adds no new slugs, so it stays
// safe even if the underlying cursor doesn't advance. Throws SentryError on an
// auth/permission failure.
// 100) so orgs with many projects are fully represented in the dropdown. Throws
// SentryError on an auth/permission failure.
//
// `onPage(slugsSoFar)` — if provided, called after each page with a snapshot of
// everything collected so far. This lets callers stream results to the UI: the
// first ~100 projects land in ~1s and the rest fill in over the following
// seconds, instead of the caller waiting for the whole (potentially large) list.
export async function listProjects(org, onPage) {
// Run the ENTIRE paged traversal as one atomic SDK operation. The CLI resolves
// the symbolic "next" cursor through global per-command state, so pages must not
// interleave with each other or with any other SDK call (a concurrent issue
// scan, another instance's discovery, a second traversal of this same org).
// runSerial holds the module-wide queue for the whole loop, which guarantees
// that. Inside the task we use projectListRaw (un-queued) to avoid re-entering
// the queue we already hold.
return runSerial(() => listProjectsPaged(org, onPage))
}
async function listProjectsPaged(org, onPage) {
// Each page is its own runSerial task (see projectListPage), so a mega-org's
// traversal no longer holds the shared SDK queue end-to-end. That is what lets
// an interactive project.view lookup interleave and answer in ~1s instead of
// waiting out the whole list — the "Checking Sentry…" hang.
const seen = new Set()
const out = []
const PAGE = 100
const MAX_PAGES = 20
// Wall-clock budget: mega-orgs (e.g. "github" has thousands of projects) can
// take minutes to fully page through, leaving the autocomplete spinning. Stop
// once we've spent this long and return what we have — the client filters the
// collected slugs, and a few hundred is plenty to type against. With streaming
// (onPage) the first page is usable almost immediately regardless.
const BUDGET_MS = 8000
const started = Date.now()
// Bound the traversal by PAGES, not wall-clock. A time budget made the result
// depend on how fast the network happened to be, so the same org yielded a
// different-length list run to run (and a slow run could cache a truncated list
// over a previously complete one). A page cap is deterministic: the same org
// always yields the same list.
const MAX_PAGES = 60
let cursor
let complete = false
for (let page = 0; page < MAX_PAGES; page++) {
let raw
let result
try {
raw = await projectListRaw(org, PAGE, cursor)
result = await projectListPage(org, PAGE, cursor)
} catch (err) {
// A transient page failure (network blip / rate limit) mid-pagination must
// not discard the projects we already collected. Surface the error only
// when we have nothing at all (e.g. page 0 failed => likely auth/bad org).
// not discard the projects we already collected. Observed against a
// mega-org: the first pages return in ~300ms each and then a later page
// stalls before failing, so this path is routine, not exceptional. Surface
// the error only when we have nothing at all (e.g. page 0 failed => likely
// auth/bad org).
if (out.length) break
throw err
}
let added = 0
for (const slug of mapProjects(raw)) {
for (const slug of mapProjects(result.projects)) {
if (seen.has(slug)) continue
seen.add(slug)
out.push(slug)
@@ -275,11 +267,26 @@ async function listProjectsPaged(org, onPage) {
if (added && typeof onPage === 'function') {
try { onPage(out.slice()) } catch { /* streaming is best-effort */ }
}
if (raw.length < PAGE || added === 0) break
if (Date.now() - started > BUDGET_MS) break
cursor = 'next'
// Terminal condition: the SDK's own envelope says there are no further
// pages. A short page is NOT authoritative on its own — the SDK can return
// fewer than requested and still report hasMore:true — so `hasMore` is the
// only signal that marks the traversal complete and cacheable. (When the
// envelope omits hasMore, projectListPage defaults it to false, which
// conservatively ends the traversal rather than looping forever.)
if (!result.hasMore) { complete = true; break }
// The SDK says more pages exist but handed back no usable cursor to fetch
// them, or the cursor did not advance (it returned the same token again).
// Either way we can't safely continue, and the traversal is NOT complete —
// leave `complete` false so the caller keeps it retryable rather than
// pinning a truncated list. Detect this by CURSOR progress, not record
// content: a page can legitimately add zero new slugs (all duplicates or
// filtered records) while still advancing its cursor, and stopping on
// `added === 0` would pin that boundary so every retry halts there and later
// projects never reach the dropdown.
if (!result.nextCursor || result.nextCursor === cursor) break
cursor = result.nextCursor
}
return out
return { projects: out, complete }
}
// Verify a specific project slug exists / is accessible. The org's project list
@@ -143,9 +143,11 @@ async function getSdk() {
//
// `runSerial(task)` runs `task` only once all previously enqueued work has
// settled, so at most one SDK operation is ever in flight process-wide. It is the
// single choke point; the public functions below are thin queued wrappers, and
// multi-call traversals (see projectListRaw) run as ONE task so their paging
// can't interleave with anything.
// single choke point; the public functions below are thin queued wrappers. Each
// task should be a SINGLE SDK call: a multi-call traversal that holds the queue
// for its whole run starves every interactive lookup behind it (see
// projectListPage, which pages via an explicit cursor so each page queues
// independently).
let sdkQueue = Promise.resolve()
export function runSerial(task) {
@@ -279,17 +281,39 @@ export async function orgList(limit = 100) {
return runSerial(async () => asArray(await (await getSdk()).org.list({ limit })))
}
// Single-page project fetch within an org. Deliberately NOT wrapped in
// runSerial on its own: the CLI's positional org/project value treats a BARE
// slug as a *project*, so listing every project in an org requires the trailing
// `<org>/` form — we normalize to exactly one trailing slash here. Raw project
// objects (each has a `slug`). `cursor` navigates pages ("next"/"prev"/raw
// cursor). Callers that page through the full list must instead run
// projectListRaw inside a single runSerial task so the whole traversal is atomic
// (see listProjects in sentry.mjs).
export async function projectListRaw(org, limit = 100, cursor) {
// Single page of an org's project list, fetched as its OWN queued task and
// returning an explicit `nextCursor` so the caller can resume without holding
// the queue between pages.
//
// This is what keeps a mega-org's multi-page traversal from starving the rest of
// the UI. The symbolic "next" cursor resolves through the SDK's module-global
// per-command state, so a traversal that used it had to hold the whole sdkQueue
// for every page (otherwise an interleaved call would clobber that state) —
// which meant a slow `github`-sized list blocked the O(1) project.view lookup
// behind it for minutes and left "Checking Sentry…" spinning. The envelope's
// `nextCursor` is a self-contained opaque token, so each page can be an
// independent runSerial task and interactive lookups can slot in between them.
//
// The CLI's positional org/project value treats a BARE slug as a *project*, so
// listing every project in an org requires the trailing `<org>/` form — we
// normalize to exactly one trailing slash here.
//
// Returns { projects, nextCursor, hasMore }. `hasMore` is the SDK envelope's own
// "there are further pages" signal (defaults to false when the envelope omits
// it); `nextCursor` is '' unless the SDK handed back a usable opaque token. The
// two are reported separately so the caller can tell "genuinely done" (hasMore
// false) apart from "more pages exist but we have no cursor to fetch them" — the
// latter is an incomplete traversal, not a terminal one, and must stay retryable.
export async function projectListPage(org, limit = 100, cursor) {
const orgProject = `${String(org || '').replace(/\/+$/, '')}/`
return asArray(await (await getSdk()).project.list({ orgProject, limit, ...(cursor ? { cursor } : {}) }))
const res = await runSerial(async () => (await getSdk()).project.list({ orgProject, limit, ...(cursor ? { cursor } : {}) }))
const projects = asArray(res)
// Only trust an explicit opaque cursor. A truthy `hasMore` without a usable
// `nextCursor` would otherwise tempt us back onto the symbolic "next" token
// and reintroduce the global-state coupling this function exists to avoid.
const next = res && typeof res === 'object' && typeof res.nextCursor === 'string' ? res.nextCursor.trim() : ''
const hasMore = res && typeof res === 'object' ? res.hasMore === true : false
return { projects, nextCursor: hasMore ? next : '', hasMore }
}
// Verify a specific project exists / is accessible via the SDK's O(1)
@@ -108,11 +108,13 @@ export function startServer({ port = 0, onRefresh, onAction, onWorkSelected, onR
periods: PERIODS,
projects: state.getProjects(),
projectsOrg: state.getProjectsOrg(),
projectsComplete: state.getProjectsComplete(),
connections: state.getConnections(),
prTargets: state.getPrTargets(),
availableModels: state.getAvailableModels(),
prSettingsOpen: state.getPrSettingsOpen(),
plainEnglishView: state.getPlainEnglishView(),
plainEnglishEnriching: state.getPlainEnglishEnriching(),
issueTrackers: state.getIssueTrackers(),
selectedTracker: state.getSelectedTracker(),
workByIssueKey: state.getWorkByIssueKey(),
@@ -153,6 +155,15 @@ export function startServer({ port = 0, onRefresh, onAction, onWorkSelected, onR
}
}
// Broadcast plain-English enrichment start/stop so the client can disable the
// toggle (and show a "preparing…" hint) until summaries are ready.
function notifyPlainEnglishEnriching(isEnriching) {
const data = JSON.stringify({ plainEnglishEnriching: Boolean(isEnriching) })
for (const res of sseClients) {
res.write(`data: ${data}\n\n`)
}
}
function handleRequest(req, res) {
// Reject DNS-rebinding hosts on EVERY request (see the trust-model comment):
// a rebinding page still carries its own hostname in `Host`, so anything but
@@ -546,6 +557,7 @@ export function startServer({ port = 0, onRefresh, onAction, onWorkSelected, onR
notifyWork,
notifyFlash,
notifyScanning,
notifyPlainEnglishEnriching,
close,
})
})
@@ -52,6 +52,12 @@ export function createState() {
// SSE project broadcast to the right org (setup screen switches org before any
// scan, so the panel's own org isn't a reliable signal).
let projectsOrg = ''
// Finality of the current `projects` list: true only when the traversal that
// produced it ran to completion (SDK reported no further pages). A COMPLETE
// list is authoritative and may legitimately be shorter than a prior one
// (projects deleted upstream); an INCOMPLETE/streamed one must not shrink a
// longer list. Consumed by the client's keep-longest guard via the snapshot.
let projectsComplete = false
// Sentry search window. Defaults to the last day; the user can widen it from
// the issues list to look further back. Only Sentry's supported periods are
// accepted (see PERIODS below).
@@ -68,6 +74,7 @@ export function createState() {
// Sentry error. Canvas-wide, defaults to the raw error so on-call sees exactly
// what Sentry reported first; the user can flip to plain English from the header.
let plainEnglishView = false
let plainEnglishEnriching = false
let selectedTracker = 'github'
const workByIssueKey = {}
// The issue trackers the "Work on selected" hand-off can file into. The agent
@@ -79,18 +86,27 @@ export function createState() {
{ id: 'linear', label: 'Linear', connected: true },
{ id: 'atlassian', label: 'Jira (Atlassian)', connected: true },
]
// Models the spawned remediation session can run under. The empty id means
// "let the session pick its default model"; every other id must match a value
// the host create_session tool accepts, or the hand-off would fail.
const availableModels = [
{ id: '', label: 'Auto (session default)' },
// Models the spawned remediation session can run under. The empty id always
// means "let the session pick its default model". The rest of the list is
// seeded with a static fallback (used until the live catalog loads, or if
// that fetch ever fails) and is normally replaced at startup by
// setAvailableModels() with the host's live model list (see extension.mjs,
// which calls session.rpc.model.list()) so this never needs to be hand-edited
// again when new models ship.
const AUTO_MODEL = { id: '', label: 'Auto (session default)' }
const FALLBACK_MODELS = [
{ id: 'claude-sonnet-5', label: 'Claude Sonnet 5' },
{ id: 'claude-opus-5', label: 'Claude Opus 5' },
{ id: 'claude-opus-4.8', label: 'Claude Opus 4.8' },
{ id: 'gpt-5.6-sol', label: 'GPT-5.6 Sol' },
{ id: 'gpt-5.5', label: 'GPT-5.5' },
{ id: 'gpt-5.4', label: 'GPT-5.4' },
{ id: 'gpt-5.3-codex', label: 'GPT-5.3-Codex' },
{ id: 'gemini-3.7-flash', label: 'Gemini 3.7 Flash' },
{ id: 'grok-4.5', label: 'Grok 4.5' },
]
const MODEL_IDS = new Set(availableModels.map((model) => model.id))
let availableModels = [AUTO_MODEL, ...FALLBACK_MODELS]
let MODEL_IDS = new Set(availableModels.map((model) => model.id))
const prTargets = {
mode: 'local',
model: '',
@@ -245,6 +261,10 @@ export function createState() {
return projectsOrg
},
getProjectsComplete() {
return projectsComplete
},
getConnections() {
return connections
},
@@ -261,6 +281,26 @@ export function createState() {
return availableModels
},
// Replaces the model list with the host's live catalog (see
// session.rpc.model.list() in extension.mjs). Keeps the current selection
// if it's still valid in the new list; otherwise falls back to Auto so a
// model retired upstream can't leave prTargets pointing at a dead id.
setAvailableModels(models) {
const seen = new Set([''])
const deduped = []
for (const m of Array.isArray(models) ? models : []) {
const id = typeof m?.id === 'string' ? m.id.trim() : ''
const label = typeof m?.label === 'string' && m.label.trim() ? m.label.trim() : id
if (!id || seen.has(id)) continue
seen.add(id)
deduped.push({ id, label })
}
if (deduped.length === 0) return
availableModels = [AUTO_MODEL, ...deduped]
MODEL_IDS = new Set(availableModels.map((model) => model.id))
if (prTargets.model && !MODEL_IDS.has(prTargets.model)) prTargets.model = ''
},
getIssueTrackers() {
return issueTrackers
},
@@ -289,7 +329,7 @@ export function createState() {
project = typeof slug === 'string' ? slug.trim() : ''
},
setProjects(list, org) {
setProjects(list, org, complete = false) {
const seen = new Set()
const out = []
for (const item of Array.isArray(list) ? list : []) {
@@ -300,6 +340,7 @@ export function createState() {
}
projects = out
if (typeof org === 'string') projectsOrg = org.trim().toLowerCase()
projectsComplete = complete === true
return projects
},
@@ -346,7 +387,17 @@ export function createState() {
return plainEnglishView
},
getPlainEnglishEnriching() {
return plainEnglishEnriching
},
setPlainEnglishEnriching(on) {
plainEnglishEnriching = Boolean(on)
return plainEnglishEnriching
},
togglePlainEnglishView() {
if (plainEnglishEnriching) return plainEnglishView
plainEnglishView = !plainEnglishView
return plainEnglishView
},
+1 -1
View File
@@ -2,7 +2,7 @@
"$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
"name": "sentry-triage",
"description": "Scan live Sentry issues in a Copilot canvas, group them by urgency, and hand issues off for tracking or a fix PR.",
"version": "1.1.0",
"version": "1.2.0",
"author": {
"name": "Liz Tom",
"url": "https://github.com/liztom"