File size: 9,681 Bytes
5710dd0 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 | /**
* REST client for the v2 session list endpoint: `GET {baseUrl}/api/v2/sessions`.
*
* Like the v1 surface, every response is wrapped in the
* `{ code, msg, data, request_id }` envelope — the business outcome lives in
* `code` (`0` success; e.g. `40001` invalid query params, `40922` page_token
* mismatch) while the HTTP status only reports server-/transport-level
* outcomes (401 from the global auth hook, etc.). Pagination is an opaque
* cursor (`page_token`) bound to the first page's query conditions; any
* condition change mid-pagination fails server-side with 40922, so callers
* must restart from the first page instead of reusing a cursor across
* filters/sorts.
*
* The wire shape is validated locally by hand (this app does not depend on
* zod): a malformed page throws, individual items with an unexpected shape
* are dropped rather than failing the whole page.
*/
export type V2ActivityStatus = 'running' | 'approval' | 'question' | 'failed' | 'idle';
export type V2SessionSort =
| 'meta.updated_at_desc'
| 'meta.updated_at_asc'
| 'meta.created_at_desc';
export interface V2Session {
readonly id: string;
readonly workspace: { readonly id: string; readonly cwd: string | null };
readonly meta: {
readonly title: string | null;
readonly lastPrompt: string | null;
readonly createdAt: number;
readonly updatedAt: number;
readonly archived: boolean;
};
readonly activity: { readonly status: V2ActivityStatus };
/** Present only when the request opted in via `include=git`. */
readonly git?: {
readonly branch: string | null;
readonly pullRequest: {
readonly number: number;
readonly state: 'open' | 'closed' | 'merged';
readonly url: string;
} | null;
};
}
export interface V2SessionsQuery {
readonly workspaceIds?: readonly string[];
readonly statuses?: readonly V2ActivityStatus[];
readonly updatedAfter?: number;
readonly archived?: 'true' | 'false' | 'all';
readonly sort?: V2SessionSort;
readonly includeGit?: boolean;
readonly pageSize?: number;
/** Opaque cursor from the previous page; the query conditions must not change. */
readonly pageToken?: string;
}
export interface V2SessionGroup {
readonly workspace: { readonly id: string; readonly cwd: string | null };
readonly sessions: readonly V2Session[];
/** Full matching-session count of this workspace (≥ sessions.length). */
readonly total: number;
}
export interface V2SessionGroupPage {
readonly groups: readonly V2SessionGroup[];
readonly hasMore: boolean;
readonly nextPageToken?: string;
}
export interface V2SessionPage {
readonly items: readonly V2Session[];
readonly hasMore: boolean;
readonly nextPageToken?: string;
}
const STATUSES = new Set<V2ActivityStatus>(['running', 'approval', 'question', 'failed', 'idle']);
const PR_STATES = new Set(['open', 'closed', 'merged']);
function parsePullRequest(value: unknown): NonNullable<V2Session['git']>['pullRequest'] {
if (value === null || value === undefined) return null;
if (typeof value !== 'object' || Array.isArray(value)) return null;
const pr = value as Record<string, unknown>;
if (
typeof pr['number'] !== 'number' ||
typeof pr['state'] !== 'string' ||
!PR_STATES.has(pr['state']) ||
typeof pr['url'] !== 'string'
) {
return null;
}
return { number: pr['number'], state: pr['state'] as 'open' | 'closed' | 'merged', url: pr['url'] };
}
function parseSession(value: unknown): V2Session | undefined {
if (value === null || typeof value !== 'object' || Array.isArray(value)) return undefined;
const s = value as Record<string, unknown>;
const workspace = s['workspace'] as Record<string, unknown> | null;
const meta = s['meta'] as Record<string, unknown> | null;
const activity = s['activity'] as Record<string, unknown> | null;
if (
typeof s['id'] !== 'string' ||
workspace === null ||
typeof workspace !== 'object' ||
typeof workspace['id'] !== 'string' ||
meta === null ||
typeof meta !== 'object' ||
typeof meta['created_at'] !== 'number' ||
typeof meta['updated_at'] !== 'number' ||
typeof meta['archived'] !== 'boolean' ||
activity === null ||
typeof activity !== 'object' ||
typeof activity['status'] !== 'string' ||
!STATUSES.has(activity['status'] as V2ActivityStatus)
) {
return undefined;
}
const cwd = workspace['cwd'];
const title = meta['title'];
const lastPrompt = meta['last_prompt'];
let git: V2Session['git'];
if ('git' in s) {
const g = s['git'] as Record<string, unknown> | null;
if (g !== null && typeof g === 'object' && !Array.isArray(g)) {
git = {
branch: typeof g['branch'] === 'string' ? g['branch'] : null,
pullRequest: parsePullRequest(g['pull_request']),
};
}
}
return {
id: s['id'],
workspace: { id: workspace['id'], cwd: typeof cwd === 'string' ? cwd : null },
meta: {
title: typeof title === 'string' ? title : null,
lastPrompt: typeof lastPrompt === 'string' ? lastPrompt : null,
createdAt: meta['created_at'],
updatedAt: meta['updated_at'],
archived: meta['archived'],
},
activity: { status: activity['status'] as V2ActivityStatus },
git,
};
}
interface FetchOptions {
readonly baseUrl: string;
readonly token?: string;
readonly fetchImpl?: typeof fetch;
}
function buildParams(query: V2SessionsQuery): URLSearchParams {
const params = new URLSearchParams();
for (const id of query.workspaceIds ?? []) params.append('workspace.id', id);
for (const status of query.statuses ?? []) params.append('activity.status', status);
if (query.updatedAfter !== undefined) params.set('meta.updated_after', String(query.updatedAfter));
if (query.archived !== undefined) params.set('meta.archived', query.archived);
if (query.sort !== undefined) params.set('sort', query.sort);
if (query.includeGit === true) params.set('include', 'git');
if (query.pageSize !== undefined) params.set('page_size', String(query.pageSize));
if (query.pageToken !== undefined) params.set('page_token', query.pageToken);
return params;
}
async function requestData(
opts: FetchOptions,
params: URLSearchParams,
): Promise<Record<string, unknown>> {
const headers: Record<string, string> = {};
if (opts.token !== undefined && opts.token !== '') {
headers['authorization'] = `Bearer ${opts.token}`;
}
const doFetch = opts.fetchImpl ?? fetch;
const query = params.toString();
const res = await doFetch(
`${opts.baseUrl}/api/v2/sessions${query === '' ? '' : `?${query}`}`,
{ headers },
);
const body: unknown = await res.json();
const envelope = (
body !== null && typeof body === 'object' && !Array.isArray(body) ? body : {}
) as Record<string, unknown>;
const code = typeof envelope['code'] === 'number' ? envelope['code'] : undefined;
const msg = typeof envelope['msg'] === 'string' ? envelope['msg'] : res.statusText;
if (!res.ok || code !== 0) {
throw new Error(`v2 sessions failed (${code ?? `http_${res.status}`}): ${msg}`);
}
const data = envelope['data'] as Record<string, unknown> | null;
if (data === null || typeof data !== 'object') {
throw new Error('v2 sessions: unexpected response shape');
}
return data;
}
function pageMeta(data: Record<string, unknown>): {
readonly hasMore: boolean;
readonly nextPageToken?: string;
} {
return {
hasMore: data['has_more'] === true,
nextPageToken:
typeof data['next_page_token'] === 'string' ? data['next_page_token'] : undefined,
};
}
export async function fetchV2SessionsPage(
opts: FetchOptions & V2SessionsQuery,
): Promise<V2SessionPage> {
const data = await requestData(opts, buildParams(opts));
if (!Array.isArray(data['items'])) {
throw new Error('v2 sessions: unexpected response shape');
}
const items = (data['items'] as unknown[])
.map(parseSession)
.filter((s): s is V2Session => s !== undefined);
return { items, ...pageMeta(data) };
}
/**
* The workspace-grouped projection (`view=by_workspace`): one request returns
* every workspace with a matching session, each carrying its first
* `groupPageSize` sessions under the requested sort plus the workspace's full
* matching `total`. The opaque cursor pages over groups.
*/
export async function fetchV2SessionGroups(
opts: FetchOptions & V2SessionsQuery & { readonly groupPageSize?: number },
): Promise<V2SessionGroupPage> {
const params = buildParams(opts);
params.set('view', 'by_workspace');
if (opts.groupPageSize !== undefined) params.set('group.page_size', String(opts.groupPageSize));
const data = await requestData(opts, params);
if (!Array.isArray(data['groups'])) {
throw new Error('v2 sessions: unexpected response shape');
}
const groups: V2SessionGroup[] = [];
for (const value of data['groups'] as unknown[]) {
if (value === null || typeof value !== 'object' || Array.isArray(value)) continue;
const g = value as Record<string, unknown>;
const workspace = g['workspace'] as Record<string, unknown> | null;
if (
workspace === null ||
typeof workspace !== 'object' ||
typeof workspace['id'] !== 'string' ||
!Array.isArray(g['sessions']) ||
typeof g['total'] !== 'number'
) {
continue;
}
const cwd = workspace['cwd'];
groups.push({
workspace: { id: workspace['id'], cwd: typeof cwd === 'string' ? cwd : null },
sessions: (g['sessions'] as unknown[])
.map(parseSession)
.filter((s): s is V2Session => s !== undefined),
total: g['total'],
});
}
return { groups, ...pageMeta(data) };
}
|