From 18e9da23ac6c30b9091967522d144c0452fc57ad Mon Sep 17 00:00:00 2001 From: "Fredrik Liljegren (Claude Code Fable 5.1)" Date: Tue, 27 Oct 2026 17:21:37 +0100 Subject: [PATCH] feat: a Claude routine attends a session on a token for that session only; the page lists your open pull requests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Diffity fires the user's routine (Settings → Call an agent) when the page asks for a review or a question arrives with nobody listening, with a session token that works on that one session's review tools and its await, never on code reads. The Sessions page gains "Your open pull requests", collapsible sections, and opens sessions in a new tab. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Bwp5QefjsjMFeq6CK6cT6w --- packages/api/src/info.ts | 4 + packages/server/src/app.ts | 152 +++++--- packages/server/src/db.ts | 11 + packages/server/src/github.ts | 16 +- packages/server/src/html.ts | 14 + packages/server/src/live.ts | 35 +- packages/server/src/mcp.ts | 336 +++++++++--------- packages/server/src/routine.ts | 85 +++++ packages/server/src/server.ts | 5 + packages/server/src/service.ts | 44 ++- packages/server/src/ui-api.ts | 21 +- packages/server/src/users.ts | 36 ++ packages/server/src/web.ts | 5 +- packages/server/tests/helpers.ts | 6 +- packages/server/tests/http.test.ts | 120 ++++++- packages/server/tests/live.test.ts | 24 +- packages/server/tests/mcp.test.ts | 62 ++++ packages/server/tests/routine.test.ts | 188 ++++++++++ .../src/components/comments/comment-form.tsx | 13 +- packages/ui/src/components/diff/diff-page.tsx | 10 +- .../src/components/layout/review-banner.tsx | 10 +- packages/ui/src/lib/agent-listening.ts | 6 + packages/ui/tests/ask-claude.test.tsx | 18 +- packages/ui/tests/review-banner.test.tsx | 7 + 24 files changed, 984 insertions(+), 244 deletions(-) create mode 100644 packages/server/src/routine.ts create mode 100644 packages/server/tests/routine.test.ts diff --git a/packages/api/src/info.ts b/packages/api/src/info.ts index 375666fc..baeb27da 100644 --- a/packages/api/src/info.ts +++ b/packages/api/src/info.ts @@ -35,4 +35,8 @@ export interface LiveStatusResponse { lastPollAt: string | null; /** Requests an agent has taken and not answered yet; it counts as listening while it works on them. */ working: number; + /** When diffity last fired the user's routine for this session, while that call is still expected to arrive. */ + calledAt: string | null; + /** Diffity has a routine to call for this user when no agent is listening. */ + routine: boolean; } diff --git a/packages/server/src/app.ts b/packages/server/src/app.ts index 854c7f32..775ba01f 100644 --- a/packages/server/src/app.ts +++ b/packages/server/src/app.ts @@ -11,13 +11,14 @@ import type { Config } from './config.js'; import { WEB_SESSION_MAX_AGE_SECONDS, type Users, type User, type WebSessions } from './users.js'; import { hashPresentedClientSecret, type OAuthProvider } from './oauth.js'; import type { AutomationTokens } from './automation.js'; -import { ServiceError, describeSession, type ReviewRequests, type ReviewService } from './service.js'; +import { ServiceError, describeSession, type ReviewRequestRow, type ReviewService } from './service.js'; import type { DevLoginProvider, IapLogin } from './login.js'; import { GitHubConnectError, type GitHubAppAccess } from './github-app.js'; import { createMcpServer } from './mcp.js'; import { MAX_WAIT_SECONDS, type Live } from './live.js'; +import type { Routine } from './routine.js'; import { uiApiRouter } from './ui-api.js'; -import { escapeHtml, page } from './html.js'; +import { SECTIONS_SCRIPT, escapeHtml, page } from './html.js'; import { randomToken } from './crypto.js'; import { SESSION_COOKIE, @@ -37,6 +38,7 @@ export interface AppDeps { automationTokens: AutomationTokens; service: ReviewService; live: Live; + routine: Routine; devLogin: DevLoginProvider | null; /** When set, identity comes from IAP's header on every request, and there is no login form. */ iap: IapLogin | null; @@ -91,7 +93,7 @@ function formTargetFor(redirectUri: string): string { } export function createApp(deps: AppDeps): express.Express { - const { config, users, webSessions, oauth, service, live } = deps; + const { config, users, webSessions, oauth, service, live, routine } = deps; const publicUrl = config.publicUrl; const mcpUrl = new URL('/mcp', publicUrl); const secureCookies = publicUrl.protocol === 'https:'; @@ -118,9 +120,9 @@ export function createApp(deps: AppDeps): express.Express { return userId ? users.get(userId) : null; }; - const sendPage = (res: Response, status: number, title: string, body: string, user: User | null, formTargets: string[] = []) => { + const sendPage = (res: Response, status: number, title: string, body: string, user: User | null, formTargets: string[] = [], selfScripts = false) => { res.status(status) - .set('Content-Security-Policy', pageContentSecurityPolicy(formTargets)) + .set('Content-Security-Policy', pageContentSecurityPolicy(formTargets, selfScripts)) .set('Cache-Control', 'no-store') .type('html') .send(page({ title, body, user })); @@ -145,6 +147,10 @@ export function createApp(deps: AppDeps): express.Express { res.json({ ok: true, version: deps.version }); }); + app.get('/sections.js', (_req, res) => { + res.type('text/javascript').set('Cache-Control', 'no-cache').send(SECTIONS_SCRIPT); + }); + if (deps.uiDir) { // Build artifacts, the same for everyone; nothing about a review is in them. app.use('/assets', express.static(join(deps.uiDir, 'assets'), { index: false, immutable: true, maxAge: '365d' })); @@ -209,17 +215,27 @@ export function createApp(deps: AppDeps): express.Express { verifier: { verifyAccessToken: async token => { const automation = await deps.automationTokens.verify(token); - if (!automation) { - return oauth.verifyAccessToken(token); + if (automation) { + return { + token, + clientId: `automation:${automation.name}`, + scopes: [], + // The SDK requires an expiry; an automation token lasts until it is revoked. + expiresAt: Number.MAX_SAFE_INTEGER, + extra: { userId: automation.userId, automation: true, automationName: automation.name }, + }; + } + const scoped = await live.verifySessionToken(token); + if (scoped) { + return { + token, + clientId: 'session', + scopes: [], + expiresAt: scoped.expiresAt, + extra: { userId: scoped.userId, sessionScope: scoped.sessionId }, + }; } - return { - token, - clientId: `automation:${automation.name}`, - scopes: [], - // The SDK requires an expiry; an automation token lasts until it is revoked. - expiresAt: Number.MAX_SAFE_INTEGER, - extra: { userId: automation.userId, automation: true, automationName: automation.name }, - }; + return oauth.verifyAccessToken(token); }, }, resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(mcpUrl), @@ -232,14 +248,16 @@ export function createApp(deps: AppDeps): express.Express { return; } const automationName = req.auth!.extra!.automation === true ? String(req.auth!.extra!.automationName) : null; + const sessionScope = typeof req.auth!.extra!.sessionScope === 'string' ? req.auth!.extra!.sessionScope : undefined; const server = createMcpServer({ service, live, automationTokens: deps.automationTokens, userId, automation: automationName !== null, - agentName: automationName ?? (await oauth.clientsStore.clientName(req.auth!.clientId)) ?? 'Claude', + agentName: sessionScope ? 'Attendant' : automationName ?? (await oauth.clientsStore.clientName(req.auth!.clientId)) ?? 'Claude', version: deps.version, + sessionScope, }); // Stateless: every request is complete in itself, so nothing is held between them. const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true }); @@ -435,6 +453,7 @@ export function createApp(deps: AppDeps): express.Express { return; } const settings = await users.settings(user.id); + const routineSettings = await users.routineSettings(user.id); sendPage(res, 200, 'Settings', `

Settings

GitHub access

@@ -448,6 +467,18 @@ export function createApp(deps: AppDeps): express.Express {

+

Call an agent

+

When you ask for a review from this page, or ask a question while no agent is listening, diffity fires this Claude + routine with the session and a token that works on that session only.

+
+

+

+ ${routineSettings.keySet ? 'Key is set' : 'No key yet'}

+ +
+ ${routineSettings.url || routineSettings.keySet + ? '
' + : ''}

Connect an agent

Add this server to Claude Code as an MCP connector; it signs you in through this page the first time. Its review prompt holds the review method.

@@ -483,6 +514,27 @@ export function createApp(deps: AppDeps): express.Express { res.redirect(303, '/settings'); }); + app.post('/settings/routine', sameOrigin, forms, async (req, res) => { + const user = await userFor(req); + if (!user) { + notSignedIn(res, '/settings'); + return; + } + if (req.body?.clear === '1') { + await users.setRoutine(user.id, null, null); + res.redirect(303, '/settings'); + return; + } + const url = typeof req.body?.url === 'string' ? req.body.url.trim() : ''; + if (!url.startsWith('https://')) { + sendPage(res, 400, 'Settings', '

The fire URL must start with https://.

Settings

', user); + return; + } + const key = typeof req.body?.key === 'string' ? req.body.key.trim() : ''; + await users.setRoutine(user.id, url, key || undefined); + res.redirect(303, '/settings'); + }); + app.get('/github/connect', async (req, res) => { const user = await userFor(req); if (!user) { @@ -528,47 +580,54 @@ export function createApp(deps: AppDeps): express.Express { res.redirect(303, '/settings'); }); - const reviewRequestsSection = async (user: User, listening: boolean): Promise => { - let found: ReviewRequests | null; + const section = (heading: string, content: string): string => + `

${heading}

${content}
`; + + const pullsSection = async ( + heading: string, + connectFor: string, + empty: string, + list: () => Promise<{ rows: ReviewRequestRow[]; hidden: number } | null>, + reachable: boolean, + ): Promise => { + let found: { rows: ReviewRequestRow[]; hidden: number } | null; try { - found = await service.reviewRequests(user.id); + found = await list(); } catch (err) { - return `

Awaiting your review

-

GitHub could not be asked: ${escapeHtml(err instanceof Error ? err.message : String(err))}

`; + return section(heading, `

GitHub could not be asked: ${escapeHtml(err instanceof Error ? err.message : String(err))}

`); } if (!found) { - return `

Awaiting your review

-

Connect GitHub on Settings to see pull requests waiting for your review.

`; + return section(heading, `

Connect GitHub on Settings to see ${connectFor}.

`); } const hidden = found.hidden > 0 ? ` (${found.hidden} hidden by your filters)` : ''; - if (found.requests.length === 0) { - return `

Awaiting your review

Nothing is waiting for your review.${hidden}

`; + if (found.rows.length === 0) { + return section(heading, `

${empty}${hidden}

`); } - const rows = found.requests.map(request => { + const rows = found.rows.map(request => { const slug = `${request.owner}/${request.repo}`; const action = request.session - ? `open · ${escapeHtml(request.session.headSha.slice(0, 7))}${listening + ? `open · ${escapeHtml(request.session.headSha.slice(0, 7))}${reachable ? `
` : ''}` - : `
+ : ` - ${listening + ${reachable ? '' : ''}
`; return ` ${escapeHtml(`${slug}#${request.number}`)} - ${escapeHtml(request.title)} + ${escapeHtml(request.title)}${request.draft ? ' draft' : ''} ${escapeHtml(request.author)} ${escapeHtml(request.updatedAt.slice(0, 16).replace('T', ' '))} ${action} `; }).join(''); - return `

Awaiting your review

+ return section(heading, ` ${rows}
Pull requestTitleAuthorUpdated
- ${hidden ? `

${hidden}

` : ''}`; + ${hidden ? `

${hidden}

` : ''}`); }; app.get('/', async (req, res) => { @@ -579,10 +638,21 @@ export function createApp(deps: AppDeps): express.Express { } const status = await live.userStatus(user.id); const listening = status.listening; - const [requests, sessions] = await Promise.all([reviewRequestsSection(user, listening), reviews.listSessions(user.id)]); + const reachable = listening || (await routine.configured(user.id)); + const [requests, own, sessions] = await Promise.all([ + pullsSection('Awaiting your review', 'pull requests waiting for your review', 'Nothing is waiting for your review.', async () => { + const found = await service.reviewRequests(user.id); + return found && { rows: found.requests, hidden: found.hidden }; + }, reachable), + pullsSection('Your open pull requests', 'your open pull requests', 'You have no open pull requests.', async () => { + const rows = await service.ownPulls(user.id); + return rows && { rows, hidden: 0 }; + }, reachable), + reviews.listSessions(user.id), + ]); const rows = sessions.map(session => ` - ${escapeHtml(`${session.owner}/${session.repo}`)} + ${escapeHtml(`${session.owner}/${session.repo}`)} ${escapeHtml(describeSession(session))} ${escapeHtml(session.createdAt.slice(0, 16).replace('T', ' '))} `).join(''); @@ -591,10 +661,10 @@ export function createApp(deps: AppDeps): express.Express { : listening ? '

Agent listening · Create and review hands a pull request to it

' : ''; - sendPage(res, 200, 'Sessions', agent + requests + (sessions.length > 0 - ? `

Your review sessions

${rows}
RepositoryChangeCreated
` - : `

No review sessions yet

Ask your agent to create_session through the diffity MCP connector. - See settings for how to add it.

`), user); + sendPage(res, 200, 'Sessions', agent + requests + own + (sessions.length > 0 + ? section('Your review sessions', `${rows}
RepositoryChangeCreated
`) + : section('No review sessions yet', `

Ask your agent to create_session through the diffity MCP connector. + See settings for how to add it.

`)) + '', user, [], true); }); app.post('/sessions', sameOrigin, forms, async (req, res) => { @@ -608,6 +678,7 @@ export function createApp(deps: AppDeps): express.Express { const { session } = await service.createSession(user.id, { repo, pr: Number(req.body?.pr) }); if (req.body?.review === '1') { await live.queueReview(user.id, session.id); + await routine.fire(user.id, session, 'review'); } res.redirect(303, `/s/${session.id}/`); } catch (err) { @@ -631,6 +702,7 @@ export function createApp(deps: AppDeps): express.Express { return; } await live.queueReview(user.id, session.id); + await routine.fire(user.id, session, 'review'); res.redirect(303, '/'); }); @@ -641,14 +713,14 @@ export function createApp(deps: AppDeps): express.Express { res.status(401).json({ error: 'Sign in first' }); return; } - res.json((await live.userStatus(user.id)) satisfies LiveStatusResponse); + res.json({ ...(await live.userStatus(user.id)), routine: await routine.configured(user.id) } satisfies LiveStatusResponse); }); app.use('/s/:sid/api', requireSameOrigin(publicUrl), (_req, res, next) => { res.set('Content-Security-Policy', UI_CONTENT_SECURITY_POLICY); res.set('Cache-Control', 'no-store'); next(); - }, uiApiRouter({ service, live, userFor })); + }, uiApiRouter({ service, live, routine, userFor })); app.get('/s/:sid{/*rest}', async (req, res) => { const user = await userFor(req); diff --git a/packages/server/src/db.ts b/packages/server/src/db.ts index 80d2a262..4808d678 100644 --- a/packages/server/src/db.ts +++ b/packages/server/src/db.ts @@ -232,6 +232,17 @@ export const MIGRATIONS: Migration[] = [ ); `, }, + { + version: 6, + sql: ` + ALTER TABLE live_tokens ADD COLUMN scope TEXT NOT NULL DEFAULT 'wait'; + ALTER TABLE live_tokens ADD CONSTRAINT live_tokens_scope_check + CHECK (scope = 'wait' OR (scope = 'session' AND session_id IS NOT NULL)); + ALTER TABLE users ADD COLUMN routine_url TEXT; + ALTER TABLE users ADD COLUMN routine_key TEXT; + ALTER TABLE sessions ADD COLUMN routine_fired_at BIGINT; + `, + }, ]; /** Any constant; two instances starting together take turns migrating. */ diff --git a/packages/server/src/github.ts b/packages/server/src/github.ts index cb9e5660..55c4564e 100644 --- a/packages/server/src/github.ts +++ b/packages/server/src/github.ts @@ -97,6 +97,7 @@ const PAGE_SIZE = 100; const MAX_PAGES = 30; const REVIEW_REQUESTS_QUERY = 'is:pr is:open archived:false review-requested:@me'; +const OWN_PULLS_QUERY = 'is:pr is:open archived:false author:@me'; const REVIEW_THREADS_QUERY = `query($owner:String!,$repo:String!,$number:Int!){ repository(owner:$owner,name:$repo){ @@ -290,15 +291,24 @@ export class GitHubApi { } /** Open pull requests waiting for the token's own review, most recently updated first. */ - async reviewRequests(token: string): Promise { - const query = new URLSearchParams({ q: REVIEW_REQUESTS_QUERY, sort: 'updated', order: 'desc', per_page: '50' }); + reviewRequests(token: string): Promise { + return this.searchPulls(token, REVIEW_REQUESTS_QUERY); + } + + /** The token's own open pull requests, most recently updated first. */ + ownPulls(token: string): Promise { + return this.searchPulls(token, OWN_PULLS_QUERY); + } + + private async searchPulls(token: string, q: string): Promise { + const query = new URLSearchParams({ q, sort: 'updated', order: 'desc', per_page: '50' }); let result: { items?: RawSearchIssue[] }; try { result = await this.json(token, `/search/issues?${query.toString()}`); } catch (err) { // A search GitHub refuses (rate limit, an App without access) leaves the list empty, not the page broken. if (err instanceof GitHubApiError && (err.status === 422 || err.status === 403)) { - console.warn(`Listing review requests failed: ${err.message}`); + console.warn(`Searching pull requests failed: ${err.message}`); return []; } throw err; diff --git a/packages/server/src/html.ts b/packages/server/src/html.ts index 702ed9fa..44df093a 100644 --- a/packages/server/src/html.ts +++ b/packages/server/src/html.ts @@ -23,10 +23,24 @@ const STYLE = ` textarea { font: inherit; padding: 6px 8px; max-width: 100%; } button { font: inherit; padding: 6px 12px; cursor: pointer; } form.inline { display: inline; } + summary { cursor: pointer; } + summary > h2 { display: inline-block; } code, pre { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; } pre { padding: 8px; border: 1px solid var(--line); overflow-x: auto; } `; +/** Keeps each collapsible section of a page open or closed as the user last left it. */ +export const SECTIONS_SCRIPT = `for (const details of document.querySelectorAll('details')) { + const key = 'diffity.section:' + details.querySelector('summary').textContent.trim(); + try { + if (localStorage.getItem(key) === 'closed') details.open = false; + } catch {} + details.addEventListener('toggle', () => { + try { localStorage.setItem(key, details.open ? 'open' : 'closed'); } catch {} + }); +} +`; + export function page(input: { title: string; body: string; user?: { email: string } | null }): string { const nav = input.user ? `SessionsSettings diff --git a/packages/server/src/live.ts b/packages/server/src/live.ts index f23a7e0c..3bc6fae0 100644 --- a/packages/server/src/live.ts +++ b/packages/server/src/live.ts @@ -1,8 +1,10 @@ import { randomUUID } from 'node:crypto'; import type { Db } from './db.js'; import { randomToken, sha256 } from './crypto.js'; +import { ROUTINE_COOLDOWN_MS } from './routine.js'; export const LIVE_TOKEN_TTL_MS = 12 * 60 * 60 * 1000; +export const SESSION_TOKEN_TTL_MS = 48 * 60 * 60 * 1000; /** A request whose agent went quiet for this long is handed out again. */ export const CLAIM_TTL_MS = 10 * 60 * 1000; /** Under the load balancer's 30 s backend timeout. */ @@ -79,15 +81,17 @@ export interface LiveStatus { listening: boolean; lastPollAt: string | null; working: number; + calledAt: string | null; } /** An agent that holds a request has stopped polling to work on it, and still counts as listening. */ -function liveStatus(polls: (number | null | undefined)[], working: number, now: number): LiveStatus { +function liveStatus(polls: (number | null | undefined)[], working: number, now: number, calledAt: string | null = null): LiveStatus { const latest = Math.max(...polls.map(poll => poll ?? -Infinity)); return { listening: now - latest < LISTENING_WINDOW_MS || working > 0, lastPollAt: Number.isFinite(latest) ? new Date(latest).toISOString() : null, working, + calledAt, }; } @@ -108,17 +112,34 @@ export class Live { * user's. Only its hash is kept. */ async issueToken(userId: string, sessionId: string | null, now = Date.now()): Promise<{ token: string; expiresAt: number }> { + return this.insertToken(userId, sessionId, 'wait', now + LIVE_TOKEN_TTL_MS); + } + + /** A token for a routine diffity called: the review tools on this one session, and waiting on it. */ + async issueSessionToken(userId: string, sessionId: string, now = Date.now()): Promise<{ token: string; expiresAt: number }> { + return this.insertToken(userId, sessionId, 'session', now + SESSION_TOKEN_TTL_MS); + } + + private async insertToken(userId: string, sessionId: string | null, scope: 'wait' | 'session', expiresAt: number) { const token = randomToken(); - const expiresAt = now + LIVE_TOKEN_TTL_MS; - await this.db.query('INSERT INTO live_tokens (token_hash, user_id, session_id, expires_at) VALUES ($1, $2, $3, $4)', [ + await this.db.query('INSERT INTO live_tokens (token_hash, user_id, session_id, scope, expires_at) VALUES ($1, $2, $3, $4, $5)', [ sha256(token), userId, sessionId, + scope, expiresAt, ]); return { token, expiresAt }; } + async verifySessionToken(token: string, now = Date.now()): Promise<{ userId: string; sessionId: string; expiresAt: number } | null> { + const row = await this.db.one<{ user_id: string; session_id: string; expires_at: number }>( + "SELECT user_id, session_id, expires_at FROM live_tokens WHERE token_hash = $1 AND scope = 'session'", + [sha256(token)], + ); + return row && row.expires_at > now ? { userId: row.user_id, sessionId: row.session_id, expiresAt: row.expires_at } : null; + } + /** * Who may wait where with this token. Without a session only a user token qualifies; with one, * that session's token, or a user token when the session is the user's. @@ -268,12 +289,14 @@ export class Live { /** An agent listens on a session when it waits on that session or on everything of the user's. */ async status(userId: string, sessionId: string, now = Date.now()): Promise { - const row = await this.db.one<{ session_poll: number | null; user_poll: number | null }>( - `SELECT s.live_polled_at AS session_poll, u.live_polled_at AS user_poll + const row = await this.db.one<{ session_poll: number | null; user_poll: number | null; fired: number | null }>( + `SELECT s.live_polled_at AS session_poll, u.live_polled_at AS user_poll, s.routine_fired_at AS fired FROM sessions s JOIN users u ON u.id = s.user_id WHERE s.id = $1 AND s.user_id = $2`, [sessionId, userId], ); - return liveStatus([row?.session_poll, row?.user_poll], row ? await this.working(userId, sessionId, now) : 0, now); + const fired = row?.fired ?? null; + const calledAt = fired !== null && now - fired < ROUTINE_COOLDOWN_MS ? new Date(fired).toISOString() : null; + return liveStatus([row?.session_poll, row?.user_poll], row ? await this.working(userId, sessionId, now) : 0, now, calledAt); } /** Whether an agent waits on the user's whole queue, which is what a new session's review needs. */ diff --git a/packages/server/src/mcp.ts b/packages/server/src/mcp.ts index ceab062b..294312b3 100644 --- a/packages/server/src/mcp.ts +++ b/packages/server/src/mcp.ts @@ -19,6 +19,8 @@ export interface McpContext { /** Who the comments are from, as the connecting client named itself when it registered. */ agentName: string; version: string; + /** The one session a session token works on. */ + sessionScope?: string; } function json(value: unknown): CallToolResult { @@ -94,9 +96,15 @@ export function createMcpServer(ctx: McpContext): McpServer { const agent: CommentAuthor = { name: ctx.agentName, type: 'agent' }; const server = new McpServer({ name: 'diffity', version: ctx.version }); - const session = (id: string): Promise => service.requireSession(userId, id); + const session = async (id: string): Promise => { + const record = await service.requireSession(userId, id); + if (ctx.sessionScope !== undefined && record.id !== ctx.sessionScope) { + throw new ServiceError(`This token is for session ${ctx.sessionScope.slice(0, 8)} only`); + } + return record; + }; - const thread = async (id: string, sessionId?: string): Promise => { + const thread = async (id: string, sessionId = ctx.sessionScope): Promise => { const found = await reviews.getThread(userId, id); if (!found) { throw new ServiceError(`No thread matches ${id}`); @@ -112,6 +120,9 @@ export function createMcpServer(ctx: McpContext): McpServer { if (!found) { throw new ServiceError(`No walkthrough matches ${id}`); } + if (ctx.sessionScope !== undefined) { + await session(found.sessionId); + } return found; }; @@ -128,112 +139,170 @@ export function createMcpServer(ctx: McpContext): McpServer { createdAt: record.createdAt, }); - server.registerTool( - 'create_session', - { - description: - 'Open a review session on pushed code. Give the repository and exactly one of: `pr` (a pull request number); ' - + '`base` and `head` (full commit shas); or `base` and `patch` (a unified diff applied to base, for work that is not pushed). ' - + 'Returns the session id, the URL the user opens to read the review, and the changed files. ' - + 'With `review: true` the session is queued for the agent waiting on the user\'s queue (live_token {}) to review. ' - + 'The `review` prompt of this server holds the method for reviewing through these tools.', - inputSchema: { - repo: z.string().describe('"owner/name" on GitHub'), - pr: z.number().int().positive().optional(), - base: z.string().optional().describe('Full 40-character commit sha'), - head: z.string().optional().describe('Full 40-character commit sha'), - patch: z.string().optional().describe('Unified diff (git diff output) against base'), - review: z.boolean().optional().describe('Queue the session for the agent waiting on your queue to review'), + // A session token's run works on its one session and reads the code from its own checkout. + if (!ctx.sessionScope) { + server.registerTool( + 'create_session', + { + description: + 'Open a review session on pushed code. Give the repository and exactly one of: `pr` (a pull request number); ' + + '`base` and `head` (full commit shas); or `base` and `patch` (a unified diff applied to base, for work that is not pushed). ' + + 'Returns the session id, the URL the user opens to read the review, and the changed files. ' + + 'With `review: true` the session is queued for the agent waiting on the user\'s queue (live_token {}) to review. ' + + 'The `review` prompt of this server holds the method for reviewing through these tools.', + inputSchema: { + repo: z.string().describe('"owner/name" on GitHub'), + pr: z.number().int().positive().optional(), + base: z.string().optional().describe('Full 40-character commit sha'), + head: z.string().optional().describe('Full 40-character commit sha'), + patch: z.string().optional().describe('Unified diff (git diff output) against base'), + review: z.boolean().optional().describe('Queue the session for the agent waiting on your queue to review'), + }, }, - }, - guarded(async ({ review, ...input }) => { - const created = await service.createSession(userId, input); - const queued = review ? await ctx.live.queueReview(userId, created.session.id) : false; - return json({ - ...describe(created.session), - ...(review ? { review: queued ? 'queued' : 'already queued' } : {}), - created: created.created, - carriedThreads: created.carried, - files: created.files.map(file => ({ - status: file.status, - path: file.newPath, - ...(file.oldPath !== file.newPath ? { oldPath: file.oldPath } : {}), - })), - }); - }), - ); - - server.registerTool( - 'list_sessions', - { - description: 'List your review sessions, newest first.', - inputSchema: { repo: z.string().optional().describe('Only sessions of this "owner/name"') }, - annotations: { readOnlyHint: true }, - }, - guarded(async ({ repo }) => { - const [owner, name] = repo?.split('/') ?? []; - return json((await reviews.listSessions(userId, { owner, repo: name })).map(describe)); - }), - ); + guarded(async ({ review, ...input }) => { + const created = await service.createSession(userId, input); + const queued = review ? await ctx.live.queueReview(userId, created.session.id) : false; + return json({ + ...describe(created.session), + ...(review ? { review: queued ? 'queued' : 'already queued' } : {}), + created: created.created, + carriedThreads: created.carried, + files: created.files.map(file => ({ + status: file.status, + path: file.newPath, + ...(file.oldPath !== file.newPath ? { oldPath: file.oldPath } : {}), + })), + }); + }), + ); + + server.registerTool( + 'list_sessions', + { + description: 'List your review sessions, newest first.', + inputSchema: { repo: z.string().optional().describe('Only sessions of this "owner/name"') }, + annotations: { readOnlyHint: true }, + }, + guarded(async ({ repo }) => { + const [owner, name] = repo?.split('/') ?? []; + return json((await reviews.listSessions(userId, { owner, repo: name })).map(describe)); + }), + ); + + server.registerTool( + 'list_review_requests', + { + description: + 'List the open pull requests waiting for your review on GitHub, after your filters on the Settings page, ' + + 'each with your newest session on it, if any. Review one with create_session { repo, pr }.', + inputSchema: {}, + annotations: { readOnlyHint: true }, + }, + guarded(async () => { + const found = await service.reviewRequests(userId); + if (!found) { + throw new ServiceError(`Connect GitHub first, at ${service.settingsUrl()}`); + } + return json({ + requests: found.requests.map(request => ({ + repo: `${request.owner}/${request.repo}`, + pr: request.number, + title: request.title, + author: request.author, + draft: request.draft, + updatedAt: request.updatedAt, + url: request.url, + session: request.session ? { ...request.session, url: service.sessionUrl(request.session.id) } : null, + })), + hidden: found.hidden, + }); + }), + ); + + server.registerTool( + 'get_diff', + { + description: "The session's unified diff; line numbers are in the @@ hunk headers.", + inputSchema: { session: sessionArg, file: z.string().optional().describe('Only this file') }, + annotations: { readOnlyHint: true }, + }, + guarded(async args => { + const raw = await service.diffText(await session(args.session), { path: args.file }); + return text(raw.trim() ? raw : 'No changes.'); + }), + ); + + server.registerTool( + 'get_file', + { + description: 'A file as it is on one side of the diff: `new` is the head, `old` the base.', + inputSchema: { session: sessionArg, path: z.string().min(1), side: z.enum(['old', 'new']).default('new') }, + annotations: { readOnlyHint: true }, + }, + guarded(async args => { + const content = await service.readFile(await session(args.session), args.side, args.path); + if (content === null) { + throw new ServiceError(`${args.path} does not exist on the ${args.side} side`); + } + return text(content); + }), + ); - server.registerTool( - 'list_review_requests', - { - description: - 'List the open pull requests waiting for your review on GitHub, after your filters on the Settings page, ' - + 'each with your newest session on it, if any. Review one with create_session { repo, pr }.', - inputSchema: {}, - annotations: { readOnlyHint: true }, - }, - guarded(async () => { - const found = await service.reviewRequests(userId); - if (!found) { - throw new ServiceError(`Connect GitHub first, at ${service.settingsUrl()}`); + const requireSignedIn = () => { + if (ctx.automation) { + throw new ServiceError('Automation tokens are managed by a signed-in agent, not by an automation token'); } - return json({ - requests: found.requests.map(request => ({ - repo: `${request.owner}/${request.repo}`, - pr: request.number, - title: request.title, - author: request.author, - draft: request.draft, - updatedAt: request.updatedAt, - url: request.url, - session: request.session ? { ...request.session, url: service.sessionUrl(request.session.id) } : null, - })), - hidden: found.hidden, - }); - }), - ); + }; - server.registerTool( - 'get_diff', - { - description: "The session's unified diff; line numbers are in the @@ hunk headers.", - inputSchema: { session: sessionArg, file: z.string().optional().describe('Only this file') }, - annotations: { readOnlyHint: true }, - }, - guarded(async args => { - const raw = await service.diffText(await session(args.session), { path: args.file }); - return text(raw.trim() ? raw : 'No changes.'); - }), - ); + const tokenName = z.string().trim().min(1).max(100).describe('What the token is for, e.g. "pr-review routine"'); - server.registerTool( - 'get_file', - { - description: 'A file as it is on one side of the diff: `new` is the head, `old` the base.', - inputSchema: { session: sessionArg, path: z.string().min(1), side: z.enum(['old', 'new']).default('new') }, - annotations: { readOnlyHint: true }, - }, - guarded(async args => { - const content = await service.readFile(await session(args.session), args.side, args.path); - if (content === null) { - throw new ServiceError(`${args.path} does not exist on the ${args.side} side`); - } - return text(content); - }), - ); + server.registerTool( + 'automation_token', + { + description: + 'Mint a bearer token for an agent that cannot sign in, such as a scheduled routine: it reaches this MCP endpoint ' + + 'as you, with every tool, until revoked. Shown once; store it as a secret.', + inputSchema: { name: tokenName }, + }, + guarded(async args => { + requireSignedIn(); + const token = await ctx.automationTokens.mint(userId, args.name); + return json({ + name: args.name, + token, + use: `Send it as "Authorization: Bearer " to ${new URL('/mcp', service.publicUrl).href}. It is not shown again.`, + }); + }), + ); + + server.registerTool( + 'automation_tokens', + { + description: 'List your automation tokens, without the tokens themselves.', + inputSchema: {}, + annotations: { readOnlyHint: true }, + }, + guarded(async () => { + requireSignedIn(); + return json(await ctx.automationTokens.list(userId)); + }), + ); + + server.registerTool( + 'automation_token_revoke', + { + description: 'Revoke one of your automation tokens by name.', + inputSchema: { name: tokenName }, + }, + guarded(async args => { + requireSignedIn(); + if (!(await ctx.automationTokens.revoke(userId, args.name))) { + throw new ServiceError(`You have no automation token named "${args.name}"`); + } + return text(`Revoked automation token "${args.name}"`); + }), + ); + } server.registerTool( 'get_standards', @@ -371,7 +440,8 @@ export function createMcpServer(ctx: McpContext): McpServer { inputSchema: { id: z.string().min(1), body, session: sessionArg.optional() }, }, guarded(async args => { - const scoped = args.session === undefined ? undefined : (await session(args.session)).id; + const asked = args.session ?? ctx.sessionScope; + const scoped = asked === undefined ? undefined : (await session(asked)).id; const comment = await reviews.findComment(userId, args.id); let commentId: string; let sessionId: string; @@ -516,7 +586,8 @@ export function createMcpServer(ctx: McpContext): McpServer { inputSchema: { session: sessionArg.optional() }, }, guarded(async args => { - const record = args.session === undefined ? null : await session(args.session); + const scoped = ctx.sessionScope ?? args.session; + const record = scoped === undefined ? null : await session(scoped); const { token, expiresAt } = await ctx.live.issueToken(userId, record?.id ?? null); const query = record ? `session=${record.id}&wait=${MAX_WAIT_SECONDS}` : `wait=${MAX_WAIT_SECONDS}`; const pollUrl = new URL(`/live/await?${query}`, service.publicUrl).href; @@ -560,61 +631,6 @@ export function createMcpServer(ctx: McpContext): McpServer { }), ); - const requireSignedIn = () => { - if (ctx.automation) { - throw new ServiceError('Automation tokens are managed by a signed-in agent, not by an automation token'); - } - }; - - const tokenName = z.string().trim().min(1).max(100).describe('What the token is for, e.g. "pr-review routine"'); - - server.registerTool( - 'automation_token', - { - description: - 'Mint a bearer token for an agent that cannot sign in, such as a scheduled routine: it reaches this MCP endpoint ' - + 'as you, with every tool, until revoked. Shown once; store it as a secret.', - inputSchema: { name: tokenName }, - }, - guarded(async args => { - requireSignedIn(); - const token = await ctx.automationTokens.mint(userId, args.name); - return json({ - name: args.name, - token, - use: `Send it as "Authorization: Bearer " to ${new URL('/mcp', service.publicUrl).href}. It is not shown again.`, - }); - }), - ); - - server.registerTool( - 'automation_tokens', - { - description: 'List your automation tokens, without the tokens themselves.', - inputSchema: {}, - annotations: { readOnlyHint: true }, - }, - guarded(async () => { - requireSignedIn(); - return json(await ctx.automationTokens.list(userId)); - }), - ); - - server.registerTool( - 'automation_token_revoke', - { - description: 'Revoke one of your automation tokens by name.', - inputSchema: { name: tokenName }, - }, - guarded(async args => { - requireSignedIn(); - if (!(await ctx.automationTokens.revoke(userId, args.name))) { - throw new ServiceError(`You have no automation token named "${args.name}"`); - } - return text(`Revoked automation token "${args.name}"`); - }), - ); - server.registerPrompt( 'review', { diff --git a/packages/server/src/routine.ts b/packages/server/src/routine.ts new file mode 100644 index 00000000..d2d230b7 --- /dev/null +++ b/packages/server/src/routine.ts @@ -0,0 +1,85 @@ +import type { Db } from './db.js'; +import type { Live } from './live.js'; +import type { ReviewSessionRecord } from './reviews.js'; +import type { Users } from './users.js'; + +export const ROUTINE_COOLDOWN_MS = 10 * 60 * 1000; +const FIRE_TIMEOUT_MS = 10_000; + +export type RoutineFireKind = 'review' | 'ask'; +export type RoutineFireResult = 'fired' | 'cooldown' | 'unconfigured' | 'failed'; + +/** Calls the user's Claude routine (an API trigger) to attend a session; the run itself happens elsewhere. */ +export class Routine { + constructor( + private readonly db: Db, + private readonly users: Users, + private readonly live: Live, + private readonly publicUrl: URL, + private readonly fetchImpl: typeof fetch = fetch, + ) {} + + async configured(userId: string): Promise { + const settings = await this.users.routineSettings(userId); + return !!settings.url && settings.keySet; + } + + /** Fires the user's routine for the session unless none is configured or one fired within the cooldown; never throws. */ + async fire(userId: string, session: ReviewSessionRecord, kind: RoutineFireKind, now = Date.now()): Promise { + try { + const routine = await this.users.routine(userId); + if (!routine) { + return 'unconfigured'; + } + const claimed = await this.db.one( + `UPDATE sessions SET routine_fired_at = $3 WHERE id = $1 AND user_id = $2 + AND (routine_fired_at IS NULL OR routine_fired_at <= $4) RETURNING id`, + [session.id, userId, now, now - ROUTINE_COOLDOWN_MS], + ); + if (!claimed) { + return 'cooldown'; + } + const failure = await this.post(routine, userId, session, kind); + if (failure === null) { + return 'fired'; + } + console.error(`routine fire failed for session ${session.id}: ${failure}`); + await this.db.query('UPDATE sessions SET routine_fired_at = NULL WHERE id = $1 AND routine_fired_at = $2', [session.id, now]); + return 'failed'; + } catch (err) { + console.error(`routine fire failed for session ${session.id}: ${err instanceof Error ? err.message : String(err)}`); + return 'failed'; + } + } + + /** Null when the routine took the call, else why not. */ + private async post(routine: { url: string; key: string }, userId: string, session: ReviewSessionRecord, kind: RoutineFireKind): Promise { + const { token } = await this.live.issueSessionToken(userId, session.id); + const text = [ + 'diffity session', session.id, + 'repo', `${session.owner}/${session.repo}`, + 'pr', session.prNumber ?? '-', + 'base', session.baseSha, + 'head', session.headSha, + 'kind', kind, + 'url', new URL(`/s/${session.id}/`, this.publicUrl).href, + 'token', token, + ].join(' '); + try { + const res = await this.fetchImpl(routine.url, { + method: 'POST', + headers: { + Authorization: `Bearer ${routine.key}`, + 'anthropic-beta': 'experimental-cc-routine-2026-04-01', + 'anthropic-version': '2023-06-01', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ text }), + signal: AbortSignal.timeout(FIRE_TIMEOUT_MS), + }); + return res.ok ? null : String(res.status); + } catch (err) { + return err instanceof Error ? err.message : String(err); + } + } +} diff --git a/packages/server/src/server.ts b/packages/server/src/server.ts index 2b714d40..e18d4185 100644 --- a/packages/server/src/server.ts +++ b/packages/server/src/server.ts @@ -12,6 +12,7 @@ import { AutomationTokens } from './automation.js'; import { ReviewService } from './service.js'; import { DevLoginProvider, IapLogin } from './login.js'; import { Live } from './live.js'; +import { Routine } from './routine.js'; import { createApp } from './app.js'; export interface ServerOverrides { @@ -23,6 +24,8 @@ export interface ServerOverrides { uiDir?: string | null; rateLimit?: boolean; version?: string; + /** What fires the users' routines, instead of the network. */ + routineFetch?: typeof fetch; } export interface RunningServer { @@ -63,6 +66,7 @@ export async function startServer( const access = overrides.gitHubAccess ?? githubApp ?? new StoredTokenAccess(users, config.devGitHubToken); const service = new ReviewService(reviews, mirrors, api, access, config.publicUrl, users, bot); const live = new Live(db); + const routine = new Routine(db, users, live, config.publicUrl, overrides.routineFetch); const app = createApp({ config, @@ -72,6 +76,7 @@ export async function startServer( automationTokens: new AutomationTokens(db), service, live, + routine, devLogin: config.devLogin ? new DevLoginProvider(config) : null, iap: config.iapAudience ? new IapLogin({ ...config, iapAudience: config.iapAudience }, overrides.iapKeys) : null, githubApp, diff --git a/packages/server/src/service.ts b/packages/server/src/service.ts index a6a996ef..7d554536 100644 --- a/packages/server/src/service.ts +++ b/packages/server/src/service.ts @@ -168,6 +168,26 @@ export class ReviewService { /** Null when the user has no GitHub access to ask with. */ async reviewRequests(userId: string, now = Date.now()): Promise { + const all = await this.searchPulls(userId, 'review', token => this.github.reviewRequests(token), now); + if (!all) { + return null; + } + const shown = filterReviewRequests(all, await this.users.settings(userId)); + return { requests: await this.withSessions(userId, shown), hidden: all.length - shown.length }; + } + + /** Null when the user has no GitHub access to ask with. */ + async ownPulls(userId: string, now = Date.now()): Promise { + const all = await this.searchPulls(userId, 'own', token => this.github.ownPulls(token), now); + return all && this.withSessions(userId, all); + } + + private async searchPulls( + userId: string, + kind: 'review' | 'own', + search: (token: string) => Promise, + now: number, + ): Promise { const token = await this.access.tokenFor(userId); if (!token) { return null; @@ -177,24 +197,24 @@ export class ReviewService { this.reviewRequestCache.delete(key); } } - let cached = this.reviewRequestCache.get(userId); + const key = `${kind}:${userId}`; + let cached = this.reviewRequestCache.get(key); if (!cached || cached.token !== token) { - const entry = { token, at: now, requests: this.github.reviewRequests(token) }; - this.reviewRequestCache.set(userId, entry); + const entry = { token, at: now, requests: search(token) }; + this.reviewRequestCache.set(key, entry); entry.requests.catch(() => { - if (this.reviewRequestCache.get(userId) === entry) { - this.reviewRequestCache.delete(userId); + if (this.reviewRequestCache.get(key) === entry) { + this.reviewRequestCache.delete(key); } }); cached = entry; } - const all = await cached.requests; - const shown = filterReviewRequests(all, await this.users.settings(userId)); - const sessions = await this.reviews.latestPrSessions(userId, shown); - return { - requests: shown.map(request => ({ ...request, session: sessions.get(prKey(request.owner, request.repo, request.number)) ?? null })), - hidden: all.length - shown.length, - }; + return cached.requests; + } + + private async withSessions(userId: string, requests: ReviewRequest[]): Promise { + const sessions = await this.reviews.latestPrSessions(userId, requests); + return requests.map(request => ({ ...request, session: sessions.get(prKey(request.owner, request.repo, request.number)) ?? null })); } async requireSession(userId: string, idOrPrefix: string): Promise { diff --git a/packages/server/src/ui-api.ts b/packages/server/src/ui-api.ts index a309f5c9..8a761e44 100644 --- a/packages/server/src/ui-api.ts +++ b/packages/server/src/ui-api.ts @@ -21,10 +21,12 @@ import { describeSession, ServiceError, type ReviewService } from './service.js' import { splitLines } from './anchor.js'; import type { User } from './users.js'; import type { Live } from './live.js'; +import type { Routine } from './routine.js'; export interface UiApiDeps { service: ReviewService; live: Live; + routine: Routine; userFor: (req: Request) => Promise; } @@ -47,7 +49,7 @@ function parsed(res: Response, result: ParseResult): T | null { /** The `/api/*` subset the review UI needs, scoped to one session of the signed-in user. */ export function uiApiRouter(deps: UiApiDeps): Router { - const { service, live } = deps; + const { service, live, routine } = deps; const reviews = service.reviews; const router = express.Router({ mergeParams: true }); router.use(express.json({ limit: '2mb' })); @@ -83,6 +85,13 @@ export function uiApiRouter(deps: UiApiDeps): Router { const author = (user: User): CommentAuthor => ({ name: user.name, type: 'user' }); + const ask = async ({ user, session }: Scoped, threadId: string, commentId: string): Promise => { + await live.ask(user.id, session.id, threadId, commentId); + if (!(await live.status(user.id, session.id)).listening) { + await routine.fire(user.id, session, 'ask'); + } + }; + const sessionMatches = (res: Response, session: ReviewSessionRecord, asked: unknown): boolean => { if (asked !== undefined && asked !== null && asked !== '' && asked !== session.id) { fail(res, 400, 'session does not match this review'); @@ -148,7 +157,8 @@ export function uiApiRouter(deps: UiApiDeps): Router { res.json(await reviews.threadsForSession(user.id, session.id, status)); })); - router.post('/threads', handle(async ({ user, session }, req, res) => { + router.post('/threads', handle(async (scoped, req, res) => { + const { user, session } = scoped; const body = parsed(res, parseCreateThreadRequest(req.body)); if (!body || !sessionMatches(res, session, body.sessionId)) { return; @@ -166,7 +176,7 @@ export function uiApiRouter(deps: UiApiDeps): Router { kind: body.ask ? 'aside' : body.kind ?? 'review', }); if (body.ask) { - await live.ask(user.id, session.id, thread.id, thread.comments[0].id); + await ask(scoped, thread.id, thread.comments[0].id); } res.json(body.ask ? await reviews.getThread(user.id, thread.id) : thread); })); @@ -189,7 +199,7 @@ export function uiApiRouter(deps: UiApiDeps): Router { const kind = body.ask ? 'aside' : body.kind ?? 'review'; const reply = await reviews.addReply(scoped.user.id, thread.id, body.body, author(scoped.user), kind); if (body.ask) { - await live.ask(scoped.user.id, scoped.session.id, thread.id, reply.id); + await ask(scoped, thread.id, reply.id); } res.json(body.ask ? { ...reply, ask: 'pending' } : reply); })); @@ -246,11 +256,12 @@ export function uiApiRouter(deps: UiApiDeps): Router { })); router.get('/live/status', handle(async ({ user, session }, _req, res) => { - res.json((await live.status(user.id, session.id)) satisfies LiveStatusResponse); + res.json({ ...(await live.status(user.id, session.id)), routine: await routine.configured(user.id) } satisfies LiveStatusResponse); })); router.post('/review-request', handle(async ({ user, session }, _req, res) => { await live.queueReview(user.id, session.id); + await routine.fire(user.id, session, 'review'); res.json((await reviews.getSession(user.id, session.id))!.review satisfies ReviewRun); })); diff --git a/packages/server/src/users.ts b/packages/server/src/users.ts index f0153ec1..2b3c7945 100644 --- a/packages/server/src/users.ts +++ b/packages/server/src/users.ts @@ -74,6 +74,42 @@ export class Users { const row = await this.db.one<{ github_token: string | null }>('SELECT github_token FROM users WHERE id = $1', [userId]); return row?.github_token ? decrypt(this.secretKey, row.github_token) : null; } + + /** The Claude routine diffity fires to attend the user's sessions; null as well when its key no longer decrypts. */ + async routine(userId: string): Promise<{ url: string; key: string } | null> { + const row = await this.db.one<{ routine_url: string | null; routine_key: string | null }>( + 'SELECT routine_url, routine_key FROM users WHERE id = $1', + [userId], + ); + const key = row?.routine_key ? decrypt(this.secretKey, row.routine_key) : null; + return row?.routine_url && key ? { url: row.routine_url, key } : null; + } + + /** A null URL clears the routine; an undefined key keeps the stored one. */ + async setRoutine(userId: string, url: string | null, key: string | null | undefined): Promise { + if (url === null) { + await this.db.query('UPDATE users SET routine_url = NULL, routine_key = NULL WHERE id = $1', [userId]); + return; + } + if (key === undefined) { + await this.db.query('UPDATE users SET routine_url = $1 WHERE id = $2', [url, userId]); + return; + } + await this.db.query('UPDATE users SET routine_url = $1, routine_key = $2 WHERE id = $3', [ + url, + key === null ? null : encrypt(this.secretKey, key), + userId, + ]); + } + + /** What the settings page shows of the routine: never the key. */ + async routineSettings(userId: string): Promise<{ url: string | null; keySet: boolean }> { + const row = await this.db.one<{ routine_url: string | null; routine_key: string | null }>( + 'SELECT routine_url, routine_key FROM users WHERE id = $1', + [userId], + ); + return { url: row?.routine_url ?? null, keySet: !!row?.routine_key }; + } } export const WEB_SESSION_MAX_AGE_SECONDS = 30 * 24 * 60 * 60; diff --git a/packages/server/src/web.ts b/packages/server/src/web.ts index 72e7e08b..0b1497ee 100644 --- a/packages/server/src/web.ts +++ b/packages/server/src/web.ts @@ -17,8 +17,8 @@ export const UI_CONTENT_SECURITY_POLICY = [ "connect-src 'self'", ].join('; '); -/** The server's own pages: no script at all, forms only to this origin unless widened for one. */ -export function pageContentSecurityPolicy(extraFormTargets: string[] = []): string { +/** The server's own pages: no script unless the page loads one of this origin's, forms only to this origin unless widened for one. */ +export function pageContentSecurityPolicy(extraFormTargets: string[] = [], selfScripts = false): string { return [ "default-src 'none'", "base-uri 'none'", @@ -26,6 +26,7 @@ export function pageContentSecurityPolicy(extraFormTargets: string[] = []): stri `form-action 'self'${extraFormTargets.map(target => ` ${target}`).join('')}`, "img-src 'self'", "style-src 'unsafe-inline'", + ...selfScripts ? ["script-src 'self'"] : [], ].join('; '); } diff --git a/packages/server/tests/helpers.ts b/packages/server/tests/helpers.ts index 89e72ffc..8df2cdfa 100644 --- a/packages/server/tests/helpers.ts +++ b/packages/server/tests/helpers.ts @@ -166,6 +166,8 @@ export interface FakeGitHub { failReviewWith: number | null; /** What `GET /search/issues` finds waiting for each token's review. */ reviewRequests: Record; + /** What `GET /search/issues` finds authored by each token's own account. */ + ownPulls: Record; /** The `q` of every search. */ searches: string[]; /** Makes `GET /search/issues` answer this status instead. */ @@ -197,6 +199,7 @@ export async function startFakeGitHub(repos: FakeRepo[]): Promise { tokenEndpoint: () => ({ error: 'bad_verification_code' }), failReviewWith: null, reviewRequests: {}, + ownPulls: {}, searches: [], failSearchWith: null, installations: {}, @@ -238,7 +241,8 @@ export async function startFakeGitHub(repos: FakeRepo[]): Promise { send(fake.failSearchWith, { message: 'Validation Failed' }); return; } - const items = (token ? fake.reviewRequests[token] ?? [] : []).map(pr => ({ + const found = (url.searchParams.get('q') ?? '').includes('author:@me') ? fake.ownPulls : fake.reviewRequests; + const items = (token ? found[token] ?? [] : []).map(pr => ({ number: pr.number, title: pr.title, html_url: `https://github.com/${pr.owner}/${pr.repo}/pull/${pr.number}`, diff --git a/packages/server/tests/http.test.ts b/packages/server/tests/http.test.ts index 6f6fb2c4..8a011b83 100644 --- a/packages/server/tests/http.test.ts +++ b/packages/server/tests/http.test.ts @@ -159,6 +159,36 @@ describe('web pages', () => { expect(await server.users.gitHubToken(aliceId)).toBeNull(); }); + it('keeps the routine’s key encrypted and never shows it, keeps it on a save without one, and removes both on request', async () => { + const save = (body: string) => api('/settings/routine', { + method: 'POST', + cookie: aliceCookie, + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body, + }); + expect((await api('/settings', { cookie: aliceCookie })).body).toContain('No key yet'); + expect((await save('url=http%3A%2F%2Finsecure.example%2Ffire&key=sk-routine')).status).toBe(400); + expect(await server.users.routine(aliceId)).toBeNull(); + + const saved = await save('url=+https%3A%2F%2Fapi.example%2Ffire+&key=sk-routine'); + expect(saved.status).toBe(303); + expect(saved.headers.get('location')).toBe('/settings'); + expect(await server.users.routine(aliceId)).toEqual({ url: 'https://api.example/fire', key: 'sk-routine' }); + expect(JSON.stringify(await server.db.query('SELECT routine_key FROM users'))).not.toContain('sk-routine'); + const settings = (await api('/settings', { cookie: aliceCookie })).body as string; + expect(settings).toContain('Key is set'); + expect(settings).toContain('value="https://api.example/fire"'); + expect(settings).not.toContain('sk-routine'); + expect(await server.users.routine(bobId)).toBeNull(); + + await save('url=https%3A%2F%2Fapi.example%2Fother&key='); + expect(await server.users.routine(aliceId)).toEqual({ url: 'https://api.example/other', key: 'sk-routine' }); + + await save('clear=1'); + expect(await server.users.routine(aliceId)).toBeNull(); + expect(await server.users.routineSettings(aliceId)).toEqual({ url: null, keySet: false }); + }); + it('signs out', async () => { const cookie = await login(server.base, 'carol@example.com'); expect((await api('/', { cookie })).status).toBe(200); @@ -194,9 +224,9 @@ describe('pull requests awaiting review', () => { try { const page = (await api('/', { cookie: aliceCookie })).body as string; expect(page).toContain('acme/widgets#1'); - expect(page).toContain(`open · ${fixture.head1.slice(0, 7)}`); + expect(page).toContain(`open · ${fixture.head1.slice(0, 7)}`); expect(page).toContain('2026-10-03 09:15'); - expect(page).toMatch(/action="\/sessions">\s*\s*/); + expect(page).toMatch(/action="\/sessions" target="_blank">\s*\s*/); expect(page).not.toContain('Release 1.2'); expect(page).toContain('(1 hidden by your'); @@ -209,6 +239,92 @@ describe('pull requests awaiting review', () => { } }); + it('offers to hand a pull request to the agent when a routine is configured, with nobody listening', async () => { + github.reviewRequests['alice-token-routine'] = [ + { owner: 'acme', repo: 'widgets', number: 1, title: 'Change line ten', author: 'octocat', updatedAt: '2026-10-03T09:15:00Z' }, + { owner: 'acme', repo: 'widgets', number: 2, title: 'Rename a file', author: 'carol', updatedAt: '2026-10-02T09:00:00Z' }, + ]; + await server.users.setGitHubToken(aliceId, 'alice-token-routine'); + const userStatus = async () => (await api('/api/live/status', { cookie: aliceCookie })).body; + const sessionStatus = async () => (await api(`/s/${session.id}/api/live/status`, { cookie: aliceCookie })).body; + try { + const without = (await api('/', { cookie: aliceCookie })).body as string; + expect(without).not.toContain('Create and review'); + expect(without).not.toContain(''); + expect(without).toContain(''); + expect(await userStatus()).toMatchObject({ listening: false, routine: false }); + expect(await sessionStatus()).toMatchObject({ listening: false, routine: false }); + + await server.users.setRoutine(aliceId, 'https://api.example/fire', 'sk-routine'); + const reachable = (await api('/', { cookie: aliceCookie })).body as string; + expect(reachable).not.toContain('Agent listening'); + expect(reachable).toMatch(/name="pr" value="2">\s*`); + expect(await userStatus()).toMatchObject({ listening: false, routine: true }); + expect(await sessionStatus()).toMatchObject({ listening: false, routine: true }); + expect((await api('/api/live/status', { cookie: bobCookie })).body.routine).toBe(false); + } finally { + await server.users.setRoutine(aliceId, null, null); + await server.users.setGitHubToken(aliceId, null); + } + }); + + it('lists the user’s own open pull requests, drafts marked, apart from the review queue', async () => { + github.reviewRequests['alice-token-own'] = [ + { owner: 'acme', repo: 'widgets', number: 2, title: 'Rename a file', author: 'carol', updatedAt: '2026-10-02T09:00:00Z' }, + ]; + github.ownPulls['alice-token-own'] = [ + { owner: 'acme', repo: 'widgets', number: 1, title: 'Change line ten', author: 'alice', updatedAt: '2026-10-03T09:15:00Z' }, + { owner: 'acme', repo: 'widgets', number: 5, title: 'Work in progress', author: 'alice', draft: true, updatedAt: '2026-10-04T09:15:00Z' }, + ]; + await server.users.setGitHubToken(aliceId, 'alice-token-own'); + try { + const page = (await api('/', { cookie: aliceCookie })).body as string; + const review = page.slice(page.indexOf('Awaiting your review'), page.indexOf('Your open pull requests')); + const own = page.slice(page.indexOf('Your open pull requests'), page.indexOf('Your review sessions')); + expect(review).toContain('acme/widgets#2'); + expect(review).not.toContain('acme/widgets#1'); + expect(review).not.toContain('Work in progress'); + expect(own).toContain('acme/widgets#1'); + expect(own).toContain('Work in progress draft'); + expect(own).toContain(`open · ${fixture.head1.slice(0, 7)}`); + expect(own).not.toContain('acme/widgets#2'); + expect(github.searches).toContain('is:pr is:open archived:false author:@me'); + + github.ownPulls['alice-token-own-none'] = []; + await server.users.setGitHubToken(aliceId, 'alice-token-own-none'); + expect((await api('/', { cookie: aliceCookie })).body).toContain('You have no open pull requests.'); + } finally { + await server.users.setGitHubToken(aliceId, null); + } + expect((await api('/', { cookie: aliceCookie })).body).toContain('Connect GitHub on Settings to see your open pull requests.'); + }); + + it('opens sessions in a new tab, folds each section, and allows only its own script', async () => { + github.reviewRequests['alice-token-tabs'] = [ + { owner: 'acme', repo: 'widgets', number: 2, title: 'Rename a file', author: 'carol', updatedAt: '2026-10-02T09:00:00Z' }, + ]; + await server.users.setGitHubToken(aliceId, 'alice-token-tabs'); + try { + const res = await api('/', { cookie: aliceCookie }); + const page = res.body as string; + expect(page).toContain(``); + expect(page).not.toMatch(//); + expect(page).toContain('
'); + expect(page.match(/

/g)).toHaveLength(3); + expect(page).toContain(''); + const csp = res.headers.get('content-security-policy')!; + expect(csp).toContain("script-src 'self'"); + expect(csp).not.toMatch(/script-src[^;]*unsafe/); + const script = await api('/sections.js'); + expect(script.headers.get('content-type')).toContain('javascript'); + expect(script.body).toContain('localStorage'); + expect((await api('/settings', { cookie: aliceCookie })).headers.get('content-security-policy')).not.toContain('script-src'); + } finally { + await server.users.setGitHubToken(aliceId, null); + } + }); + it('saves the filters from the settings form, and only from this site', async () => { expect(await form('/settings/review-requests', { skipTitles: 'Bump ', includeDrafts: '1' }, aliceCookie, { 'Sec-Fetch-Site': 'cross-site' })) .toMatchObject({ status: 403 }); diff --git a/packages/server/tests/live.test.ts b/packages/server/tests/live.test.ts index 3a5d7237..ecfc79a5 100644 --- a/packages/server/tests/live.test.ts +++ b/packages/server/tests/live.test.ts @@ -3,7 +3,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; import type { CommentThread, RepoInfoResponse } from '@diffity/api'; -import { CLAIM_TTL_MS, LISTENING_WINDOW_MS, LIVE_TOKEN_TTL_MS, Live, REVIEW_STALE_MS } from '../src/live.js'; +import { CLAIM_TTL_MS, LISTENING_WINDOW_MS, LIVE_TOKEN_TTL_MS, Live, REVIEW_STALE_MS, SESSION_TOKEN_TTL_MS } from '../src/live.js'; import { Reviews } from '../src/reviews.js'; import { Users } from '../src/users.js'; import type { Db } from '../src/db.js'; @@ -133,6 +133,24 @@ describe('the request queue', () => { expect(await live.verifyToken(scoped.token, null)).toBeNull(); }); + it('accepts a session token as one for its session, until it expires, and a wait token never as one', async () => { + const { token, expiresAt } = await live.issueSessionToken(alice, sessionId); + expect(expiresAt - Date.now()).toBeGreaterThan(SESSION_TOKEN_TTL_MS - 60_000); + expect(await live.verifySessionToken(token)).toEqual({ userId: alice, sessionId, expiresAt }); + expect(await live.verifySessionToken(token, expiresAt)).toBeNull(); + expect(await live.verifySessionToken('made-up')).toBeNull(); + expect((await db.one<{ scope: string }>('SELECT scope FROM live_tokens WHERE session_id = $1 AND expires_at = $2', [sessionId, expiresAt]))?.scope) + .toBe('session'); + + expect(await live.verifyToken(token, sessionId)).toBe(alice); + expect(await live.verifyToken(token, otherSessionId)).toBeNull(); + expect(await live.verifyToken(token, null)).toBeNull(); + + const wait = await live.issueToken(alice, sessionId); + expect(await live.verifySessionToken(wait.token)).toBeNull(); + expect(await live.verifySessionToken((await live.issueToken(alice, null)).token)).toBeNull(); + }); + it('queues one review per session at a time, and review_done settles it', async () => { expect(await live.queueReview(alice, otherSessionId)).toBe(true); expect(await live.queueReview(alice, otherSessionId)).toBe(false); @@ -192,7 +210,7 @@ describe('the request queue', () => { expect((await live.userStatus(alice, now)).listening).toBe(false); await live.recordPoll({ userId: alice, sessionId: null }, now); - expect(await live.status(alice, otherSessionId, now)).toEqual({ listening: true, lastPollAt: new Date(now).toISOString(), working: 0 }); + expect(await live.status(alice, otherSessionId, now)).toEqual({ listening: true, lastPollAt: new Date(now).toISOString(), working: 0, calledAt: null }); expect((await live.userStatus(alice, now)).listening).toBe(true); expect((await live.userStatus(alice, now + LISTENING_WINDOW_MS)).listening).toBe(false); expect((await live.userStatus(bob, now)).listening).toBe(false); @@ -577,7 +595,7 @@ describe('the attendant over HTTP', () => { try { const status = () => fetch(`${server.base}/api/live/status`, { headers: { cookie: aliceCookie } }).then(res => res.json()); expect((await fetch(`${server.base}/api/live/status`)).status).toBe(401); - expect(await status()).toEqual({ listening: false, lastPollAt: null, working: 0 }); + expect(await status()).toEqual({ listening: false, lastPollAt: null, working: 0, calledAt: null, routine: false }); const before = await (await fetch(`${server.base}/`, { headers: { cookie: aliceCookie } })).text(); expect(before).not.toContain('Agent listening'); expect(before).toContain(''); diff --git a/packages/server/tests/mcp.test.ts b/packages/server/tests/mcp.test.ts index f6a4894f..0b0b5130 100644 --- a/packages/server/tests/mcp.test.ts +++ b/packages/server/tests/mcp.test.ts @@ -346,6 +346,68 @@ describe('automation tokens', () => { }); }); +describe('session tokens', () => { + it('work on their one session through the review tools, and on nothing else', async () => { + const aliceId = (await server.users.findOrCreate('alice@example.com')).id; + const { session } = await ok<{ session: string }>(alice, 'create_session', { repo: 'acme/widgets', base: fixture.base, head: fixture.head1 }); + const other = await ok<{ session: string }>(alice, 'create_session', { repo: 'acme/widgets', base: fixture.base, head: fixture.head2 }); + const otherThread = await ok<{ thread: string }>(alice, 'comment', { session: other.session, file: 'added.ts', line: 1, body: 'Elsewhere' }); + const otherTour = await ok<{ tour: string }>(alice, 'tour_start', { session: other.session, topic: 'Elsewhere' }); + const { token } = await server.live.issueSessionToken(aliceId, session); + const attendant = await mcpClient(token); + try { + const names = (await attendant.listTools()).tools.map(tool => tool.name); + for (const removed of [ + 'create_session', 'list_sessions', 'list_review_requests', 'get_diff', 'get_file', + 'automation_token', 'automation_tokens', 'automation_token_revoke', + ]) { + expect(names).not.toContain(removed); + } + expect(names).toEqual(expect.arrayContaining(['comment', 'list_comments', 'review_start', 'review_done', 'post_review', 'live_token'])); + + await ok(attendant, 'review_start', { session: session.slice(0, 8) }); + const finding = await ok<{ thread: string }>(attendant, 'comment', { session, file: 'src.ts', line: 10, body: 'From the attendant' }); + const threads = await ok<{ id: string; comments: { author: { name: string; type: string } }[] }[]>(attendant, 'list_comments', { session }); + expect(threads.find(t => t.id === finding.thread)?.comments[0].author).toEqual({ name: 'Attendant', type: 'agent' }); + await ok(attendant, 'review_done', { session }); + expect(await failed(attendant, 'post_review', { session, event: 'COMMENT' })).toContain('not configured on this server'); + + const only = `This token is for session ${session.slice(0, 8)} only`; + expect(await failed(attendant, 'comment', { session: other.session, file: 'added.ts', line: 1, body: 'x' })).toBe(only); + expect(await failed(attendant, 'list_comments', { session: other.session })).toBe(only); + expect(await failed(attendant, 'review_start', { session: other.session })).toBe(only); + expect(await failed(attendant, 'review_done', { session: other.session })).toBe(only); + expect(await failed(attendant, 'post_review', { session: other.session, event: 'COMMENT' })).toBe(only); + expect(await failed(attendant, 'reply', { id: otherThread.thread, body: 'x' })).toContain('belongs to another session'); + expect(await failed(attendant, 'resolve', { id: otherThread.thread })).toContain('belongs to another session'); + expect(await failed(attendant, 'amend', { id: otherThread.thread, body: 'x' })).toContain('belongs to another session'); + expect(await failed(attendant, 'tour_done', { tour: otherTour.tour })).toBe(only); + + const waiting = await ok<{ session: string; command: string; pollUrl: string; token: string }>(attendant, 'live_token', { session: other.session }); + expect(waiting.session).toBe(session); + expect(waiting.pollUrl).toBe(`${server.base}/live/await?session=${session}&wait=25`); + expect(waiting.command).toContain(waiting.pollUrl); + } finally { + await attendant.close(); + await ok(alice, 'tour_delete', { tour: otherTour.tour }); + } + }); + + it('wait on their session at /live/await', async () => { + const aliceId = (await server.users.findOrCreate('alice@example.com')).id; + const { session } = await ok<{ session: string }>(alice, 'create_session', { repo: 'acme/widgets', base: fixture.base, head: fixture.head1 }); + const { token } = await server.live.issueSessionToken(aliceId, session); + const thread = await server.service.reviews.createThread({ + userId: aliceId, sessionId: session, filePath: 'src.ts', side: 'new', startLine: 10, endLine: 10, body: 'Why?', author: { name: 'Alice', type: 'user' }, + }); + await server.live.ask(aliceId, session, thread.id, thread.comments[0].id); + const res = await fetch(`${server.base}/live/await?session=${session}&wait=0`, { headers: { Authorization: `Bearer ${token}` } }); + expect(res.status).toBe(200); + expect(await res.json()).toMatchObject({ kind: 'ask', session, thread: thread.id, question: 'Why?' }); + await server.live.answer(aliceId, thread.id); + }); +}); + describe('post_review', () => { it('answers that bot posting is not configured on a server without the App key', async () => { const { session } = await ok<{ session: string }>(alice, 'create_session', { repo: 'acme/widgets', pr: 1 }); diff --git a/packages/server/tests/routine.test.ts b/packages/server/tests/routine.test.ts new file mode 100644 index 00000000..d7bcbfc7 --- /dev/null +++ b/packages/server/tests/routine.test.ts @@ -0,0 +1,188 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { LiveStatusResponse } from '@diffity/api'; +import { + fakeUiDir, + login, + makeFixture, + removeDir, + startFakeGitHub, + startTestServer, + tempDir, + type FakeGitHub, + type Fixture, + type TestServer, +} from './helpers.js'; + +interface Fired { + url: string; + headers: Headers; + text: string; +} + +let fixture: Fixture; +let github: FakeGitHub; +let dataDir: string; +let server: TestServer; +let aliceCookie: string; +let bobCookie: string; +let aliceId: string; +let bobId: string; +let fired: Fired[]; +let answer: number; + +const routineFetch = (async (input: string | URL | Request, init?: RequestInit) => { + fired.push({ url: String(input), headers: new Headers(init?.headers), text: (JSON.parse(String(init?.body)) as { text: string }).text }); + return new Response('{}', { status: answer }); +}) as typeof fetch; + +const sameSite = (cookie: string) => ({ cookie, 'Sec-Fetch-Site': 'same-origin' }); + +const createAndReview = (cookie: string) => fetch(`${server.base}/sessions`, { + method: 'POST', + redirect: 'manual', + headers: { ...sameSite(cookie), 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ repo: 'acme/widgets', pr: '1', review: '1' }).toString(), +}); + +const shasSession = async (userId: string, base: string, head: string) => + (await server.service.createSession(userId, { repo: 'acme/widgets', base, head })).session; + +const ask = (session: string, cookie = aliceCookie) => fetch(`${server.base}/s/${session}/api/threads`, { + method: 'POST', + headers: { ...sameSite(cookie), 'Content-Type': 'application/json' }, + body: JSON.stringify({ sessionId: session, filePath: 'src.ts', side: 'new', startLine: 10, endLine: 10, body: 'Why?', author: { name: 'You', type: 'user' }, ask: true }), +}); + +const firedAt = async (session: string) => + (await server.db.one<{ routine_fired_at: number | null }>('SELECT routine_fired_at FROM sessions WHERE id = $1', [session]))!.routine_fired_at; + +/** `diffity session repo pr base head kind url token ` */ +const fields = (text: string): Record => { + const words = text.split(' '); + expect(words[0]).toBe('diffity'); + const pairs: Record = {}; + for (let i = 1; i < words.length; i += 2) { + pairs[words[i]] = words[i + 1]; + } + return pairs; +}; + +beforeAll(async () => { + fixture = makeFixture(); + github = await startFakeGitHub([ + { + owner: 'acme', + name: 'widgets', + private: false, + tokens: [], + pulls: { 1: { title: 'Change line ten', baseSha: fixture.mainTip, headSha: fixture.head1 } }, + }, + ]); + dataDir = tempDir('routine'); + server = await startTestServer(dataDir, github.url, { remoteUrl: fixture.remoteUrl, uiDir: fakeUiDir(dataDir), routineFetch }); + aliceCookie = await login(server.base, 'alice@example.com'); + bobCookie = await login(server.base, 'bob@example.com'); + aliceId = (await server.users.findOrCreate('alice@example.com')).id; + bobId = (await server.users.findOrCreate('bob@example.com')).id; + await server.users.setRoutine(aliceId, 'https://api.example/fire', 'sk-routine'); +}); + +beforeEach(() => { + fired = []; + answer = 200; +}); + +afterAll(async () => { + await server.close(); + await github.close(); + removeDir(fixture.root); + removeDir(dataDir); +}); + +describe('calling the routine', () => { + it('fires once for a session created and reviewed from the page, with a token for that session only', async () => { + const created = await createAndReview(aliceCookie); + expect(created.status).toBe(303); + const session = /^\/s\/([^/]+)\/$/.exec(created.headers.get('location')!)![1]; + + expect(fired).toHaveLength(1); + const [call] = fired; + expect(call.url).toBe('https://api.example/fire'); + expect(call.headers.get('authorization')).toBe('Bearer sk-routine'); + expect(call.headers.get('anthropic-beta')).toBe('experimental-cc-routine-2026-04-01'); + expect(call.headers.get('anthropic-version')).toBe('2023-06-01'); + expect(call.headers.get('content-type')).toBe('application/json'); + expect(call.text).not.toContain('\n'); + const payload = fields(call.text); + expect(payload).toEqual({ + session, + repo: 'acme/widgets', + pr: '1', + base: fixture.base, + head: fixture.head1, + kind: 'review', + url: `${server.base}/s/${session}/`, + token: expect.any(String), + }); + expect(await server.live.verifySessionToken(payload.token)).toMatchObject({ userId: aliceId, sessionId: session }); + + const again = await fetch(`${server.base}/sessions/${session}/review-request`, { method: 'POST', redirect: 'manual', headers: sameSite(aliceCookie) }); + expect(again.status).toBe(303); + const fromPage = await fetch(`${server.base}/s/${session}/api/review-request`, { method: 'POST', headers: sameSite(aliceCookie) }); + expect(fromPage.status).toBe(200); + expect(fired).toHaveLength(1); + }); + + it('fires for a question nobody is listening for, and the page says the agent was called', async () => { + const session = await shasSession(aliceId, fixture.mainTip, fixture.head1); + const status = async () => + (await fetch(`${server.base}/s/${session.id}/api/live/status`, { headers: { cookie: aliceCookie } })).json() as Promise; + expect((await status()).calledAt).toBeNull(); + + expect((await ask(session.id)).status).toBe(200); + expect(fired).toHaveLength(1); + expect(fields(fired[0].text)).toMatchObject({ session: session.id, pr: '-', base: fixture.mainTip, head: fixture.head1, kind: 'ask' }); + const after = await status(); + expect(after.listening).toBe(false); + expect(Date.now() - Date.parse(after.calledAt!)).toBeLessThan(5000); + + expect((await ask(session.id)).status).toBe(200); + expect(fired).toHaveLength(1); + }); + + it('does not fire for a question an agent is listening for', async () => { + const session = await shasSession(aliceId, fixture.base, fixture.head2); + await server.live.recordPoll({ userId: aliceId, sessionId: session.id }); + expect((await ask(session.id)).status).toBe(200); + expect(fired).toHaveLength(0); + expect(await firedAt(session.id)).toBeNull(); + }); + + it('answers the request as usual when the routine fails, and lets the next one try again', async () => { + const session = await shasSession(aliceId, fixture.base, fixture.mainTip); + const errors = vi.spyOn(console, 'error').mockImplementation(() => {}); + try { + answer = 500; + expect((await ask(session.id)).status).toBe(200); + expect(fired).toHaveLength(1); + expect(await firedAt(session.id)).toBeNull(); + expect(errors).toHaveBeenCalledWith(`routine fire failed for session ${session.id}: 500`); + + answer = 200; + expect((await ask(session.id)).status).toBe(200); + expect(fired).toHaveLength(2); + expect(await firedAt(session.id)).not.toBeNull(); + } finally { + errors.mockRestore(); + } + }); + + it('fires nothing for a user without a routine', async () => { + const created = await createAndReview(bobCookie); + expect(created.status).toBe(303); + const session = await shasSession(bobId, fixture.base, fixture.head1); + expect((await ask(session.id, bobCookie)).status).toBe(200); + expect(fired).toHaveLength(0); + expect(await firedAt(session.id)).toBeNull(); + }); +}); diff --git a/packages/ui/src/components/comments/comment-form.tsx b/packages/ui/src/components/comments/comment-form.tsx index e6af89c1..17654b82 100644 --- a/packages/ui/src/components/comments/comment-form.tsx +++ b/packages/ui/src/components/comments/comment-form.tsx @@ -1,5 +1,5 @@ import { useState, useRef, useEffect, useContext } from 'react'; -import { AgentListeningContext } from '../../lib/agent-listening'; +import { AgentCalledContext, AgentListeningContext, AgentReachableContext } from '../../lib/agent-listening'; interface CommentFormProps { onSubmit: (body: string) => void; @@ -18,6 +18,8 @@ export function CommentForm(props: CommentFormProps) { autoFocus = true, lineLabel, onAsk, } = props; const listening = useContext(AgentListeningContext); + const called = useContext(AgentCalledContext); + const reachable = useContext(AgentReachableContext) || listening; const [body, setBody] = useState(''); const textareaRef = useRef(null); @@ -76,10 +78,15 @@ export function CommentForm(props: CommentFormProps) { Cancel {onAsk && ( - +