@opengeni/api-router 2.10.0-canary.0 → 2.10.0-canary.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,336 @@
1
+ import { z } from "zod";
2
+ import type { SessionEvent, SessionEventType } from "@opengeni/contracts";
3
+ import type {
4
+ SessionEventSliceOptions,
5
+ SessionEventSlicePage,
6
+ } from "@opengeni/db/session-event-slices";
7
+
8
+ export const SESSION_EVENT_VIEW_MAX_BYTES = 16 * 1024;
9
+ const selectionSchema = z.object({
10
+ sessionId: z.string().uuid(),
11
+ view: z.enum(["conversation", "results", "tools"]),
12
+ includeArguments: z.boolean(),
13
+ includeOutput: z.boolean(),
14
+ callId: z
15
+ .string()
16
+ .max(512)
17
+ .refine(
18
+ (value) => Buffer.byteLength(JSON.stringify(value), "utf8") <= 2048,
19
+ "callId exceeds the encoded cursor budget",
20
+ )
21
+ .nullable(),
22
+ direction: z.enum(["before", "after"]),
23
+ after: z.number().int().nonnegative(),
24
+ before: z.number().int().positive().nullable(),
25
+ });
26
+ const cursorSchema = z.object({
27
+ v: z.union([z.literal(1), z.literal(2)]),
28
+ selection: selectionSchema,
29
+ sequence: z.number().int().positive().nullable(),
30
+ offset: z.number().int().nonnegative().max(2_147_483_647),
31
+ });
32
+ type Selection = z.infer<typeof selectionSchema>;
33
+ export type SessionEventViewInput = {
34
+ sessionId: string;
35
+ view?: Selection["view"] | undefined;
36
+ after?: number | undefined;
37
+ before?: number | undefined;
38
+ direction?: Selection["direction"] | undefined;
39
+ limit?: number | undefined;
40
+ cursor?: string | undefined;
41
+ callId?: string | undefined;
42
+ includeArguments?: boolean | undefined;
43
+ includeOutput?: boolean | undefined;
44
+ };
45
+ type Item = { sequence: number; turnId?: string; role?: string; text?: string } & Record<
46
+ string,
47
+ unknown
48
+ >;
49
+ type ReadPage = (options: SessionEventSliceOptions) => Promise<SessionEventSlicePage>;
50
+ const types: Record<Selection["view"], SessionEventType[]> = {
51
+ conversation: ["user.message", "agent.message.completed"],
52
+ // Final turn output is authoritative: do not repeat message.completed text.
53
+ results: ["turn.completed", "turn.failed", "goal.completed", "goal.paused", "tool.auth_needed"],
54
+ tools: ["agent.toolCall.created", "agent.toolCall.output"],
55
+ };
56
+ const record = (value: unknown): Record<string, unknown> =>
57
+ value !== null && typeof value === "object" && !Array.isArray(value)
58
+ ? (value as Record<string, unknown>)
59
+ : {};
60
+ const bytes = (value: unknown) => Buffer.byteLength(JSON.stringify(value, null, 2), "utf8");
61
+ const encode = (selection: Selection, sequence: number | null = null, offset = 0, v = 2) =>
62
+ Buffer.from(JSON.stringify({ v, selection, sequence, offset })).toString("base64url");
63
+
64
+ /** A cursor is a bounded selector, never authority. The caller reauthorizes every read. */
65
+ export function resolveSessionEventView(input: SessionEventViewInput) {
66
+ let continuation: z.infer<typeof cursorSchema> | null = null;
67
+ if (input.cursor !== undefined) {
68
+ if (input.cursor.length > 4096)
69
+ throw new Error("session_events cursor exceeds 4096 characters");
70
+ try {
71
+ continuation = cursorSchema.parse(
72
+ JSON.parse(Buffer.from(input.cursor, "base64url").toString()),
73
+ );
74
+ } catch {
75
+ throw new Error("Invalid session_events cursor");
76
+ }
77
+ const previous = continuation.selection;
78
+ for (const key of [
79
+ "sessionId",
80
+ "view",
81
+ "includeArguments",
82
+ "includeOutput",
83
+ "callId",
84
+ "direction",
85
+ "after",
86
+ "before",
87
+ ] as const) {
88
+ if (input[key] !== undefined && input[key] !== previous[key]) {
89
+ throw new Error(`session_events cursor cannot change ${key}`);
90
+ }
91
+ }
92
+ }
93
+ const selection =
94
+ continuation?.selection ??
95
+ selectionSchema.parse({
96
+ sessionId: input.sessionId,
97
+ view: input.view ?? "conversation",
98
+ includeArguments: input.includeArguments ?? false,
99
+ includeOutput: input.includeOutput ?? false,
100
+ callId: input.callId ?? null,
101
+ direction:
102
+ input.direction ??
103
+ (input.before !== undefined || input.after === undefined ? "before" : "after"),
104
+ after: input.after ?? 0,
105
+ before: input.before ?? null,
106
+ });
107
+ if (
108
+ selection.view !== "tools" &&
109
+ (selection.callId || selection.includeArguments || selection.includeOutput)
110
+ ) {
111
+ throw new Error("callId/includeArguments/includeOutput require view=tools");
112
+ }
113
+ if (
114
+ continuation &&
115
+ ((continuation.sequence === null && continuation.offset !== 0) ||
116
+ (continuation.sequence !== null &&
117
+ (continuation.sequence <= selection.after ||
118
+ (selection.before !== null && continuation.sequence >= selection.before))))
119
+ )
120
+ throw new Error("Invalid session_events cursor position");
121
+ return { selection, continuation };
122
+ }
123
+
124
+ function project(event: SessionEvent, selection: Selection): Item | null {
125
+ if (event.duplicateOfEventId || (event.turnAssociation && event.turnAssociation !== "current"))
126
+ return null;
127
+ const p = record(event.payload);
128
+ const base = { sequence: event.sequence, ...(event.turnId ? { turnId: event.turnId } : {}) };
129
+ if (selection.view === "conversation") {
130
+ return typeof p.text === "string" && p.text.length > 0
131
+ ? { ...base, role: event.type === "user.message" ? "user" : "assistant", text: p.text }
132
+ : null;
133
+ }
134
+ if (selection.view === "results") {
135
+ if (event.type === "turn.completed" && ("maintenance" in p || "segmentLimit" in p)) return null;
136
+ const value = event.type === "turn.completed" ? (p.output ?? p.result) : p;
137
+ if (value === undefined || value === "") return null;
138
+ return {
139
+ ...base,
140
+ type: event.type,
141
+ text: typeof value === "string" ? value : JSON.stringify(value),
142
+ };
143
+ }
144
+ const callId = p.callId ?? p.call_id ?? p.id;
145
+ if (selection.callId !== null && selection.callId !== callId) return null;
146
+ const output = event.type === "agent.toolCall.output";
147
+ // Choose one canonical slot. Never ship the raw receipt beside its parsed copy.
148
+ const value = output ? p.output : (p.arguments ?? p.args);
149
+ return {
150
+ ...base,
151
+ callId,
152
+ kind: output ? "result" : "call",
153
+ ...(typeof p.name === "string" ? { name: p.name } : {}),
154
+ ...(p.identityOmitted === true ? { identityOmitted: true } : {}),
155
+ ...(p.isError === true || record(value).isError === true ? { isError: true } : {}),
156
+ ...((output ? selection.includeOutput : selection.includeArguments) && value !== undefined
157
+ ? {
158
+ text: typeof value === "string" ? value : JSON.stringify(value),
159
+ encoding: typeof value === "string" ? "text" : "json",
160
+ }
161
+ : {}),
162
+ };
163
+ }
164
+
165
+ /** Read-only projection over the existing RLS/audit query. No command observations. */
166
+ export async function readSessionEventView(input: SessionEventViewInput, read: ReadPage) {
167
+ const { selection, continuation } = resolveSessionEventView(input);
168
+ const limit = Math.max(1, Math.min(50, input.limit ?? 10));
169
+ let after =
170
+ continuation?.sequence && selection.direction === "after"
171
+ ? continuation.sequence - 1
172
+ : selection.after;
173
+ let before =
174
+ continuation?.sequence && selection.direction === "before"
175
+ ? continuation.sequence + 1
176
+ : selection.before;
177
+ const events: Item[] = [];
178
+ let hasMore = false;
179
+ let nextCursor: string | null = null;
180
+ let sourceExact = true;
181
+ let edge: number | null = null;
182
+ let legacyActive = continuation?.v === 1 && continuation.sequence !== null;
183
+ const page = () => ({
184
+ view: selection.view,
185
+ direction: selection.direction,
186
+ events,
187
+ nextAfter: selection.direction === "after" ? (edge ?? selection.after) : null,
188
+ nextBefore: selection.direction === "before" ? (edge ?? selection.before) : null,
189
+ hasMore,
190
+ nextCursor,
191
+ sourceExact,
192
+ ...(!sourceExact
193
+ ? {
194
+ sourceLoss: {
195
+ reason: "structured_value_exceeds_budget" as const,
196
+ completeTextAvailable: false as const,
197
+ message:
198
+ "An oversized structured value was omitted. Scalar text remains retrievable through continuation; omitted structured values are not complete JSON results.",
199
+ },
200
+ }
201
+ : {}),
202
+ maxBytes: SESSION_EVENT_VIEW_MAX_BYTES,
203
+ });
204
+ const resume = (sequence: number, offset = 0) => {
205
+ hasMore = true;
206
+ nextCursor = encode(
207
+ { ...selection, after, before },
208
+ sequence,
209
+ offset,
210
+ legacyActive && sequence === continuation?.sequence ? 1 : 2,
211
+ );
212
+ };
213
+ // Bound work even when a sparse exact callId lookup matches nothing. The
214
+ // returned cursor advances the scan without claiming the lookup is exhausted.
215
+ for (let scan = 0; scan < 64; scan += 1) {
216
+ const source = await read({
217
+ after,
218
+ ...(before === null ? {} : { before }),
219
+ direction: selection.direction,
220
+ limit: 8,
221
+ includeTypes: types[selection.view],
222
+ payloadMode: "full",
223
+ excludeUnclaimedHumanPrompts: true,
224
+ maxBytes: 1024 * 1024,
225
+ legacyUtf16: legacyActive,
226
+ view: selection.view,
227
+ includeArguments: selection.includeArguments,
228
+ includeOutput: selection.includeOutput,
229
+ ...(continuation?.sequence
230
+ ? { sourceSequence: continuation.sequence, sourceOffset: continuation.offset }
231
+ : {}),
232
+ });
233
+ sourceExact &&= source.fullPayloadsExact;
234
+ const ordered = selection.direction === "before" ? [...source.events].reverse() : source.events;
235
+ for (const event of ordered) {
236
+ const slice = source.slices?.[event.sequence];
237
+ const item = project(event, selection);
238
+ if (item) {
239
+ const offset = continuation?.sequence === event.sequence ? continuation.offset : 0;
240
+ if (slice?.omitted) {
241
+ delete item.text;
242
+ item.sourceOmitted = { reason: "structured_value_exceeds_budget", complete: false };
243
+ }
244
+ const advance = (text: string) =>
245
+ slice?.unit === "codepoint" ? Array.from(text).length : text.length;
246
+ if (!slice && offset > (item.text?.length ?? 0))
247
+ throw new Error("Invalid message continuation offset");
248
+ if (
249
+ !slice &&
250
+ offset > 0 &&
251
+ item.text &&
252
+ /[\uD800-\uDBFF]/.test(item.text[offset - 1]!) &&
253
+ /[\uDC00-\uDFFF]/.test(item.text[offset] ?? "")
254
+ ) {
255
+ throw new Error("Invalid message continuation offset: splits a surrogate pair");
256
+ }
257
+ if (!slice && offset > 0 && item.text) item.text = item.text.slice(offset);
258
+ events.push(item);
259
+ // Reserve enough for the bounded cursor and fragment facts.
260
+ if (bytes(page()) > SESSION_EVENT_VIEW_MAX_BYTES - 4096) {
261
+ events.pop();
262
+ if (events.length > 0) {
263
+ resume(event.sequence, offset);
264
+ if (selection.direction === "before") events.reverse();
265
+ return page();
266
+ }
267
+ if (!item.text) throw new Error("Session event identity exceeds page budget");
268
+ const text = item.text;
269
+ let low = 0;
270
+ let high = text.length;
271
+ while (low < high) {
272
+ const mid = Math.ceil((low + high) / 2);
273
+ if (bytes({ ...item, text: text.slice(0, mid) }) <= SESSION_EVENT_VIEW_MAX_BYTES - 6000)
274
+ low = mid;
275
+ else high = mid - 1;
276
+ }
277
+ // Do not split a UTF-16 surrogate pair (UTF-8 remains lossless).
278
+ if (low > 0 && /[\uD800-\uDBFF]/.test(text[low - 1]!)) low -= 1;
279
+ if (low === 0) throw new Error("Session event identity exceeds page budget");
280
+ events.push({
281
+ ...item,
282
+ text: text.slice(0, low),
283
+ fragment: {
284
+ offset,
285
+ nextOffset: offset + advance(text.slice(0, low)),
286
+ complete: false,
287
+ unit: slice?.unit ?? "utf16",
288
+ },
289
+ });
290
+ resume(event.sequence, offset + advance(text.slice(0, low)));
291
+ return page();
292
+ }
293
+ const nextOffset = offset + advance(item.text ?? "");
294
+ if (slice && !slice.omitted && nextOffset < slice.total) {
295
+ item.fragment = { offset, nextOffset, complete: false, unit: slice.unit };
296
+ resume(event.sequence, nextOffset);
297
+ if (selection.direction === "before") events.reverse();
298
+ return page();
299
+ }
300
+ if (offset > 0)
301
+ item.fragment = {
302
+ offset,
303
+ nextOffset,
304
+ complete: true,
305
+ unit: slice?.unit ?? "utf16",
306
+ };
307
+ }
308
+ edge = event.sequence;
309
+ legacyActive = false;
310
+ if (selection.direction === "after") after = event.sequence;
311
+ else before = event.sequence;
312
+ if (events.length >= limit) {
313
+ hasMore = source.hasMore || event !== ordered.at(-1);
314
+ nextCursor = hasMore ? encode({ ...selection, after, before }) : null;
315
+ if (selection.direction === "before") events.reverse();
316
+ return page();
317
+ }
318
+ }
319
+ // Metadata selection can advance over stale/duplicate rows without a
320
+ // projected payload. Use its covered edge rather than repeating the page.
321
+ if (source.coveredSequence) {
322
+ if (selection.direction === "after") after = Math.max(after, source.coveredSequence.last);
323
+ else before = Math.min(before ?? Infinity, source.coveredSequence.first);
324
+ }
325
+ if (!source.hasMore) {
326
+ hasMore = false;
327
+ nextCursor = null;
328
+ if (selection.direction === "before") events.reverse();
329
+ return page();
330
+ }
331
+ hasMore = true;
332
+ nextCursor = encode({ ...selection, after, before });
333
+ }
334
+ if (selection.direction === "before") events.reverse();
335
+ return page();
336
+ }
@@ -463,7 +463,7 @@ const SUMMARY_FAILURE_CHARS = 500;
463
463
  const SUMMARY_RESULT_CHARS = 1_000;
464
464
 
465
465
  function truncationMarker(droppedChars: number): string {
466
- return `…[${droppedChars} chars omitted from this session_wait summary; use session_events for the exact event]`;
466
+ return `…[${droppedChars} chars omitted from this session_wait summary; use session_events view=debug payloadMode=full for the exact event]`;
467
467
  }
468
468
 
469
469
  function clampSummaryString(value: string, maxChars: number): string {
@@ -1,9 +1,13 @@
1
1
  import {
2
+ GitHubActionPoliciesResponse,
3
+ GitHubActionPolicyActorState,
2
4
  GitHubAppManifestCreate,
5
+ UpdateGitHubActionPolicyRequest,
3
6
  type AccessGrant,
4
7
  type GitHubInstallationBindingCandidate,
5
8
  type GitHubInstallationBindingProof,
6
9
  } from "@opengeni/contracts";
10
+ import { PersonalGitHubConnectionMetadata } from "@opengeni/contracts/personal-github";
7
11
  import {
8
12
  ListGitHubRepositoryBranchesQuery,
9
13
  VerifyPublicGitHubRepositoryRefRequest,
@@ -13,6 +17,7 @@ import {
13
17
  bindAuthorizedGitHubInstallationRepositories,
14
18
  deleteGitHubInstallationBinding,
15
19
  GitHubInstallationAuthorityCommitError,
20
+ listGitHubInstallationAccessForWorkspace,
16
21
  } from "@opengeni/db";
17
22
  import {
18
23
  authorizeGitHubInstallationBinding,
@@ -38,7 +43,15 @@ import {
38
43
  import type { Context, Hono } from "hono";
39
44
  import { deleteCookie, setCookie } from "hono/cookie";
40
45
  import { HTTPException } from "hono/http-exception";
41
- import { hasPermission, requireAccessGrant } from "@opengeni/core";
46
+ import {
47
+ githubAppActionPolicyActor,
48
+ hasPermission,
49
+ listGitHubActionPolicyActors,
50
+ personalGitHubActionPolicyActor,
51
+ requireAccessGrant,
52
+ requireAccessGrantAuthorization,
53
+ updateGitHubActionPolicyGroup,
54
+ } from "@opengeni/core";
42
55
  import type { ApiRouteDeps } from "@opengeni/core";
43
56
  import {
44
57
  continuedGitHubBrowserGrantClaims,
@@ -54,6 +67,8 @@ import {
54
67
  listWorkspaceGitHubRepositoryBranches,
55
68
  listWorkspaceGitHubRepositories,
56
69
  } from "../github-access";
70
+ import { assertPersonalConnectionOwnerPrincipal } from "../connection-ownership";
71
+ import { listPersonalGitHubConnections } from "../integrations/personal-github";
57
72
 
58
73
  const githubStateCookie = "opengeni_github_state";
59
74
  const githubBindingStateMaxAgeSeconds = 10 * 60;
@@ -108,6 +123,95 @@ export function registerGitHubRoutes(app: Hono, deps: ApiRouteDeps): void {
108
123
  });
109
124
  });
110
125
 
126
+ app.get("/v1/workspaces/:workspaceId/github/action-policies", async (c) => {
127
+ const workspaceId = c.req.param("workspaceId");
128
+ const grant = await requireAccessGrant(c, deps, workspaceId, "github:use");
129
+ const [installations, personalConnections] = await Promise.all([
130
+ listGitHubInstallationAccessForWorkspace(db, grant.workspaceId),
131
+ listPersonalGitHubConnections(deps, {
132
+ workspaceId: grant.workspaceId,
133
+ subjectId: grant.subjectId,
134
+ }),
135
+ ]);
136
+ const personalConnection = canonicalPersonalGitHubPolicyConnection(personalConnections);
137
+ const actors = [
138
+ ...installations.map(githubAppActionPolicyActor),
139
+ ...(personalConnection
140
+ ? [
141
+ personalGitHubActionPolicyActor({
142
+ connectionId: personalConnection.id,
143
+ githubLogin: PersonalGitHubConnectionMetadata.parse(personalConnection.metadata)
144
+ .githubLogin,
145
+ }),
146
+ ]
147
+ : []),
148
+ ];
149
+ return c.json(
150
+ GitHubActionPoliciesResponse.parse({
151
+ enabled: settings.githubRestMcpEnabled,
152
+ actors: await listGitHubActionPolicyActors(db, {
153
+ accountId: grant.accountId,
154
+ workspaceId: grant.workspaceId,
155
+ actors,
156
+ }),
157
+ }),
158
+ );
159
+ });
160
+
161
+ app.patch("/v1/workspaces/:workspaceId/github/action-policies", async (c) => {
162
+ const workspaceId = c.req.param("workspaceId");
163
+ const payload = UpdateGitHubActionPolicyRequest.parse(await c.req.json());
164
+ const actor = payload.actor;
165
+ if (actor.kind === "workspace_app") {
166
+ const grant = await requireAccessGrant(c, deps, workspaceId, "github:manage");
167
+ const installations = await listGitHubInstallationAccessForWorkspace(db, grant.workspaceId);
168
+ const installation = installations.find(
169
+ (candidate) => candidate.installationId === actor.installationId,
170
+ );
171
+ if (!installation || installation.accountId !== grant.accountId) {
172
+ throw new HTTPException(404, { message: "GitHub installation not found" });
173
+ }
174
+ return c.json(
175
+ GitHubActionPolicyActorState.parse(
176
+ await updateGitHubActionPolicyGroup(db, {
177
+ accountId: grant.accountId,
178
+ workspaceId: grant.workspaceId,
179
+ subjectId: grant.subjectId,
180
+ actor: githubAppActionPolicyActor(installation),
181
+ group: payload.group,
182
+ decision: payload.decision,
183
+ }),
184
+ ),
185
+ );
186
+ }
187
+
188
+ const access = await requireAccessGrantAuthorization(c, deps, workspaceId, "connections:write");
189
+ assertPersonalConnectionOwnerPrincipal(access, "My GitHub action policy");
190
+ const connections = await listPersonalGitHubConnections(deps, {
191
+ workspaceId: access.grant.workspaceId,
192
+ subjectId: access.grant.subjectId,
193
+ });
194
+ const connection = connections.find((candidate) => candidate.id === actor.connectionId);
195
+ if (!connection) {
196
+ throw new HTTPException(404, { message: "personal GitHub connection not found" });
197
+ }
198
+ return c.json(
199
+ GitHubActionPolicyActorState.parse(
200
+ await updateGitHubActionPolicyGroup(db, {
201
+ accountId: access.grant.accountId,
202
+ workspaceId: access.grant.workspaceId,
203
+ subjectId: access.grant.subjectId,
204
+ actor: personalGitHubActionPolicyActor({
205
+ connectionId: connection.id,
206
+ githubLogin: PersonalGitHubConnectionMetadata.parse(connection.metadata).githubLogin,
207
+ }),
208
+ group: payload.group,
209
+ decision: payload.decision,
210
+ }),
211
+ ),
212
+ );
213
+ });
214
+
111
215
  // Start with user authorization, not GitHub's install/configure selector.
112
216
  // This lets an owner link an already-installed App without relying on
113
217
  // GitHub's Configure page to preserve or return OpenGeni state.
@@ -703,6 +807,25 @@ function githubInstallationSettingsUrl(installation: {
703
807
  return new URL(`https://github.com/settings/installations/${installation.installationId}`);
704
808
  }
705
809
 
810
+ function canonicalPersonalGitHubPolicyConnection<
811
+ T extends { id: string; status: string; updatedAt: string },
812
+ >(connections: T[]): T | null {
813
+ const statusRank: Record<string, number> = {
814
+ active: 0,
815
+ needs_reauth: 1,
816
+ error: 2,
817
+ revoked: 3,
818
+ };
819
+ return (
820
+ [...connections].sort((left, right) => {
821
+ const rank = (statusRank[left.status] ?? 4) - (statusRank[right.status] ?? 4);
822
+ if (rank !== 0) return rank;
823
+ const updated = right.updatedAt.localeCompare(left.updatedAt);
824
+ return updated !== 0 ? updated : right.id.localeCompare(left.id);
825
+ })[0] ?? null
826
+ );
827
+ }
828
+
706
829
  function redirectToExactGitHubAuthorization(
707
830
  c: Context,
708
831
  deps: ApiRouteDeps,
@@ -1,3 +1,4 @@
1
+ import { z } from "zod";
1
2
  import {
2
3
  CreateScheduledTaskRequest,
3
4
  TriggerScheduledTaskRequest,
@@ -72,7 +73,24 @@ export function registerScheduledTaskRoutes(app: Hono, deps: ApiRouteDeps): void
72
73
  app.get("/v1/workspaces/:workspaceId/scheduled-tasks", async (c) => {
73
74
  const workspaceId = c.req.param("workspaceId");
74
75
  const grant = await requireAccessGrant(c, deps, workspaceId, "scheduled_tasks:run");
75
- const tasks = await listScheduledTasks(db, workspaceId, boundedLimit(c.req.query("limit")));
76
+ const sessionId = c.req.query("sessionId");
77
+ const offset = Number(c.req.query("offset") ?? 0);
78
+ if (!Number.isSafeInteger(offset) || offset < 0) {
79
+ throw new HTTPException(400, { message: "invalid offset" });
80
+ }
81
+ if (sessionId !== undefined) {
82
+ if (!z.string().uuid().safeParse(sessionId).success) {
83
+ throw new HTTPException(400, { message: "invalid sessionId" });
84
+ }
85
+ await requireAccessGrant(c, deps, workspaceId, "sessions:control");
86
+ }
87
+ const tasks = await listScheduledTasks(
88
+ db,
89
+ workspaceId,
90
+ boundedLimit(c.req.query("limit")),
91
+ offset,
92
+ sessionId,
93
+ );
76
94
  return c.json(tasks.map((task) => scheduledTaskForGrant(task, grant)));
77
95
  });
78
96