Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/api/src/info.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
152 changes: 112 additions & 40 deletions packages/server/src/app.ts

Large diffs are not rendered by default.

11 changes: 11 additions & 0 deletions packages/server/src/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
16 changes: 13 additions & 3 deletions packages/server/src/github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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){
Expand Down Expand Up @@ -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<ReviewRequest[]> {
const query = new URLSearchParams({ q: REVIEW_REQUESTS_QUERY, sort: 'updated', order: 'desc', per_page: '50' });
reviewRequests(token: string): Promise<ReviewRequest[]> {
return this.searchPulls(token, REVIEW_REQUESTS_QUERY);
}

/** The token's own open pull requests, most recently updated first. */
ownPulls(token: string): Promise<ReviewRequest[]> {
return this.searchPulls(token, OWN_PULLS_QUERY);
}

private async searchPulls(token: string, q: string): Promise<ReviewRequest[]> {
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;
Expand Down
14 changes: 14 additions & 0 deletions packages/server/src/html.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
? `<a href="/">Sessions</a><a href="/settings">Settings</a><span class="spacer"></span>
Expand Down
35 changes: 29 additions & 6 deletions packages/server/src/live.ts
Original file line number Diff line number Diff line change
@@ -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. */
Expand Down Expand Up @@ -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,
};
}

Expand All @@ -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.
Expand Down Expand Up @@ -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<LiveStatus> {
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. */
Expand Down
Loading
Loading