@opengeni/api-router 0.5.4 → 0.5.6

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.
@@ -2,9 +2,10 @@ import {
2
2
  AcknowledgeStreamRequest,
3
3
  AttachViewerRequest,
4
4
  ClearSessionContextRequest,
5
- CancelSessionQueueItemRequest,
6
5
  ClientSessionEvent,
7
6
  CompactSessionContextRequest,
7
+ DeleteSessionQueueItemRequest,
8
+ EditSessionQueueItemRequest,
8
9
  FsDeleteRequest,
9
10
  FsListRequest,
10
11
  FsMkdirRequest,
@@ -15,11 +16,14 @@ import {
15
16
  GitLogRequest,
16
17
  GitShowRequest,
17
18
  GitStatusRequest,
19
+ MoveSessionQueueItemRequest,
18
20
  PtyCloseRequest,
19
21
  PtyOpenRequest,
20
22
  PtyResizeRequest,
21
23
  PtyWriteRequest,
22
24
  SessionControlRequest,
25
+ SaveComposerDraftRequest,
26
+ SteerSessionQueueItemRequest,
23
27
  SteerSessionMessageRequest,
24
28
  TerminalExecRequest,
25
29
  UpdateSessionPinRequest,
@@ -34,7 +38,6 @@ import {
34
38
  } from "@opengeni/contracts";
35
39
  import { streamTokenDegraded } from "@opengeni/config";
36
40
  import {
37
- cancelQueuedSessionTurnWithVersion,
38
41
  acceptSessionApprovalDecision,
39
42
  clearSessionGoal,
40
43
  clearSessionContext,
@@ -64,8 +67,9 @@ import {
64
67
  revokeViewer,
65
68
  setSessionGoalStatus,
66
69
  updatePtySessionActivity,
67
- requestSessionControl,
68
- SessionQueueConflictError,
70
+ QueueCommandConflictError,
71
+ SessionCommandIdempotencyError,
72
+ SessionControlConflictError,
69
73
  SessionContextBusyError,
70
74
  latestWorkspaceCapture,
71
75
  workspaceCaptureAtRevision,
@@ -97,8 +101,15 @@ import {
97
101
  } from "../sandbox/viewer";
98
102
  import {
99
103
  acceptSessionUserMessage,
104
+ controlHumanSessionWorkstream,
100
105
  createSessionForRequest,
106
+ deleteHumanQueuePrompt,
107
+ editHumanQueuePrompt,
108
+ getHumanComposerDraft,
109
+ moveHumanQueuePrompt,
101
110
  readSessionLineage,
111
+ saveHumanComposerDraft,
112
+ steerHumanQueuePrompt,
102
113
  updateSessionTitle,
103
114
  workflowIdForSession,
104
115
  } from "@opengeni/core";
@@ -508,115 +519,136 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
508
519
  return c.json(snapshot);
509
520
  });
510
521
 
511
- app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/queue/:turnId/cancel", async (c) => {
522
+ app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/queue/:turnId/move", async (c) => {
512
523
  const workspaceId = c.req.param("workspaceId");
513
524
  const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:control");
514
525
  const sessionId = c.req.param("sessionId");
515
526
  await assertSessionExists(db, workspaceId, sessionId);
516
- const payload = CancelSessionQueueItemRequest.parse(await c.req.json());
527
+ const payload = MoveSessionQueueItemRequest.parse(await c.req.json());
517
528
  try {
518
- const result = await cancelQueuedSessionTurnWithVersion(
519
- db,
520
- workspaceId,
521
- sessionId,
522
- c.req.param("turnId"),
523
- payload.expectedQueueVersion,
524
- payload.expectedItemVersion,
525
- grant.subjectId,
526
- payload.reason ?? null,
529
+ return c.json(
530
+ await moveHumanQueuePrompt(
531
+ { db, bus },
532
+ { accountId: grant.accountId, workspaceId, sessionId, subjectId: grant.subjectId },
533
+ c.req.param("turnId"),
534
+ payload,
535
+ ),
527
536
  );
528
- await bus.publish(workspaceId, sessionId, result.events);
529
- if (result.shouldWake) {
530
- if (result.workflowWakeRevision === null) {
531
- throw new Error("Queue continuation has no workflow wake revision");
532
- }
533
- await workflowClient.wakeSessionWorkflow({
534
- accountId: grant.accountId,
535
- workspaceId,
536
- sessionId,
537
- workflowId: workflowIdForSession(sessionId),
538
- wakeRevision: result.workflowWakeRevision,
539
- });
540
- }
541
- return c.json(result);
542
537
  } catch (error) {
543
- throwQueueConflict(error);
538
+ return commandConflictResponse(c, error);
544
539
  }
545
540
  });
546
541
 
547
- app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/control", async (c) => {
542
+ app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/queue/:turnId/edit", async (c) => {
548
543
  const workspaceId = c.req.param("workspaceId");
549
544
  const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:control");
550
545
  const sessionId = c.req.param("sessionId");
551
- const payload = SessionControlRequest.parse(await c.req.json());
552
- let result;
546
+ await assertSessionExists(db, workspaceId, sessionId);
547
+ const payload = EditSessionQueueItemRequest.parse(await c.req.json());
553
548
  try {
554
- result = await requestSessionControl(db, {
555
- accountId: grant.accountId,
556
- workspaceId,
557
- sessionId,
558
- actor: grant.subjectId,
559
- mode: payload.mode,
560
- reason: payload.reason ?? null,
561
- clientEventId: payload.clientEventId ?? null,
562
- ...(payload.expectedControlState !== undefined
563
- ? { expectedControlState: payload.expectedControlState }
564
- : {}),
565
- ...(payload.expectedControlGeneration !== undefined
566
- ? { expectedControlGeneration: payload.expectedControlGeneration }
567
- : {}),
568
- ...(payload.expectedWorkspaceInferenceGeneration !== undefined
569
- ? {
570
- expectedWorkspaceInferenceGeneration: payload.expectedWorkspaceInferenceGeneration,
571
- }
572
- : {}),
573
- });
549
+ return c.json(
550
+ await editHumanQueuePrompt(
551
+ { db, bus },
552
+ { accountId: grant.accountId, workspaceId, sessionId, subjectId: grant.subjectId },
553
+ c.req.param("turnId"),
554
+ payload,
555
+ ),
556
+ );
574
557
  } catch (error) {
575
- throwQueueConflict(error);
558
+ return commandConflictResponse(c, error);
576
559
  }
577
- await bus.publish(workspaceId, sessionId, result.events);
578
- const workflowId = workflowIdForSession(sessionId);
579
- if (result.shouldSignalControl) {
580
- if (result.workflowWakeRevision === null) {
581
- throw new Error("Session control has no workflow wake revision");
582
- }
583
- await workflowClient.signalSessionControl({
584
- accountId: grant.accountId,
585
- workspaceId,
586
- sessionId,
587
- eventId: result.event.id,
588
- workflowId,
589
- workflowWakeRevision: result.workflowWakeRevision,
590
- });
591
- } else if (result.shouldWake) {
592
- if (result.workflowWakeRevision === null) {
593
- throw new Error("Session resume has no workflow wake revision");
594
- }
595
- await workflowClient.wakeSessionWorkflow({
560
+ });
561
+
562
+ app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/queue/:turnId/steer", async (c) => {
563
+ const workspaceId = c.req.param("workspaceId");
564
+ const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:control");
565
+ const sessionId = c.req.param("sessionId");
566
+ await assertSessionExists(db, workspaceId, sessionId);
567
+ const payload = SteerSessionQueueItemRequest.parse(await c.req.json());
568
+ try {
569
+ return c.json(
570
+ await steerHumanQueuePrompt(
571
+ { db, bus },
572
+ { accountId: grant.accountId, workspaceId, sessionId, subjectId: grant.subjectId },
573
+ c.req.param("turnId"),
574
+ payload,
575
+ ),
576
+ );
577
+ } catch (error) {
578
+ return commandConflictResponse(c, error);
579
+ }
580
+ });
581
+
582
+ app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/queue/:turnId/delete", async (c) => {
583
+ const workspaceId = c.req.param("workspaceId");
584
+ const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:control");
585
+ const sessionId = c.req.param("sessionId");
586
+ await assertSessionExists(db, workspaceId, sessionId);
587
+ const payload = DeleteSessionQueueItemRequest.parse(await c.req.json());
588
+ try {
589
+ return c.json(
590
+ await deleteHumanQueuePrompt(
591
+ { db, bus },
592
+ { accountId: grant.accountId, workspaceId, sessionId, subjectId: grant.subjectId },
593
+ c.req.param("turnId"),
594
+ payload,
595
+ ),
596
+ );
597
+ } catch (error) {
598
+ return commandConflictResponse(c, error);
599
+ }
600
+ });
601
+
602
+ app.get("/v1/workspaces/:workspaceId/sessions/:sessionId/composer-draft", async (c) => {
603
+ const workspaceId = c.req.param("workspaceId");
604
+ const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:read");
605
+ const sessionId = c.req.param("sessionId");
606
+ return c.json(
607
+ await getHumanComposerDraft(db, {
596
608
  accountId: grant.accountId,
597
609
  workspaceId,
598
610
  sessionId,
599
- workflowId,
600
- wakeRevision: result.workflowWakeRevision,
601
- });
602
- }
603
- return c.json(
604
- {
605
- operationId: result.operationId,
606
- event: result.event,
607
- controlState: result.controlState,
608
- controlGeneration: result.controlGeneration,
609
- expectedActiveTurnId: result.expectedActiveTurnId,
610
- expectedExecutionGeneration: result.expectedExecutionGeneration,
611
- expectedAttemptId: result.expectedAttemptId,
612
- deliveryEventId: result.deliveryEventId,
613
- shouldSignalControl: result.shouldSignalControl,
614
- shouldWake: result.shouldWake,
615
- },
616
- 202,
611
+ subjectId: grant.subjectId,
612
+ }),
617
613
  );
618
614
  });
619
615
 
616
+ app.put("/v1/workspaces/:workspaceId/sessions/:sessionId/composer-draft", async (c) => {
617
+ const workspaceId = c.req.param("workspaceId");
618
+ const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:control");
619
+ const sessionId = c.req.param("sessionId");
620
+ const payload = SaveComposerDraftRequest.parse(await c.req.json());
621
+ try {
622
+ return c.json(
623
+ await saveHumanComposerDraft(
624
+ db,
625
+ { accountId: grant.accountId, workspaceId, sessionId, subjectId: grant.subjectId },
626
+ payload,
627
+ ),
628
+ );
629
+ } catch (error) {
630
+ return commandConflictResponse(c, error);
631
+ }
632
+ });
633
+
634
+ app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/control", async (c) => {
635
+ const workspaceId = c.req.param("workspaceId");
636
+ const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:control");
637
+ const sessionId = c.req.param("sessionId");
638
+ const payload = SessionControlRequest.parse(await c.req.json());
639
+ try {
640
+ return c.json(
641
+ await controlHumanSessionWorkstream(
642
+ { db, bus, workflowClient },
643
+ { accountId: grant.accountId, workspaceId, sessionId, subjectId: grant.subjectId },
644
+ payload,
645
+ ),
646
+ );
647
+ } catch (error) {
648
+ return commandConflictResponse(c, error);
649
+ }
650
+ });
651
+
620
652
  app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/steer", async (c) => {
621
653
  const workspaceId = c.req.param("workspaceId");
622
654
  const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:control");
@@ -634,13 +666,9 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
634
666
  mcpCredentialUpdates: payload.mcpCredentialUpdates ?? [],
635
667
  delivery: "steer",
636
668
  origin: "human",
637
- ...(payload.expectedControlGeneration !== undefined
638
- ? { expectedControlGeneration: payload.expectedControlGeneration }
639
- : {}),
640
- ...(payload.expectedWorkspaceInferenceGeneration !== undefined
641
- ? {
642
- expectedWorkspaceInferenceGeneration: payload.expectedWorkspaceInferenceGeneration,
643
- }
669
+ ...(payload.controlEtag !== undefined ? { controlEtag: payload.controlEtag } : {}),
670
+ ...(payload.expectedDraftRevision !== undefined
671
+ ? { expectedDraftRevision: payload.expectedDraftRevision }
644
672
  : {}),
645
673
  ...(payload.clientEventId ? { clientEventId: payload.clientEventId } : {}),
646
674
  });
@@ -662,6 +690,12 @@ export function registerSessionRoutes(app: Hono, deps: ApiRouteDeps): void {
662
690
  model: event.payload.model ?? null,
663
691
  reasoningEffort: event.payload.reasoningEffort ?? null,
664
692
  mcpCredentialUpdates: event.payload.mcpCredentialUpdates ?? [],
693
+ ...(event.payload.controlEtag !== undefined
694
+ ? { controlEtag: event.payload.controlEtag }
695
+ : {}),
696
+ ...(event.payload.expectedDraftRevision !== undefined
697
+ ? { expectedDraftRevision: event.payload.expectedDraftRevision }
698
+ : {}),
665
699
  ...(event.clientEventId ? { clientEventId: event.clientEventId } : {}),
666
700
  });
667
701
  return c.json(accepted, 202);
@@ -1629,17 +1663,15 @@ function userMessagePayloadHasOwnProperty(value: unknown, key: string): boolean
1629
1663
  return hasOwnProperty(payload, key);
1630
1664
  }
1631
1665
 
1632
- function throwQueueConflict(error: unknown): never {
1633
- if (error instanceof SessionQueueConflictError) {
1634
- throw new HTTPException(409, {
1635
- message: JSON.stringify({
1636
- message: error.message,
1637
- currentQueueVersion: error.currentQueueVersion,
1638
- ...(error.currentItemVersion !== undefined
1639
- ? { currentItemVersion: error.currentItemVersion }
1640
- : {}),
1641
- }),
1642
- });
1666
+ function commandConflictResponse(c: Context, error: unknown): Response {
1667
+ if (error instanceof QueueCommandConflictError) {
1668
+ return c.json({ code: error.code, message: error.message, current: error.current }, 409);
1669
+ }
1670
+ if (error instanceof SessionControlConflictError) {
1671
+ return c.json({ code: error.code, message: error.message }, 409);
1672
+ }
1673
+ if (error instanceof SessionCommandIdempotencyError) {
1674
+ return c.json({ code: error.code, message: error.message }, 409);
1643
1675
  }
1644
1676
  throw error;
1645
1677
  }
@@ -24,6 +24,7 @@ import {
24
24
  grantWorkspaceAccess,
25
25
  listScheduledTasks,
26
26
  listWorkspaceMembers,
27
+ listWorkspaceControlEvents,
27
28
  listWorkspacesForSubject,
28
29
  removeWorkspaceMember,
29
30
  requireWorkspace,
@@ -32,7 +33,6 @@ import {
32
33
  updateWorkspace,
33
34
  updateWorkspaceSettings,
34
35
  upsertWorkspaceModelPolicy,
35
- setWorkspaceInferenceControl,
36
36
  } from "@opengeni/db";
37
37
  import type { Hono } from "hono";
38
38
  import { HTTPException } from "hono/http-exception";
@@ -42,8 +42,11 @@ import type { ApiRouteDeps } from "@opengeni/core";
42
42
  import {
43
43
  assertWorkspaceDeletable,
44
44
  assertWorkspaceMemberRemovable,
45
+ controlHumanWorkspace,
45
46
  resolveMemberSubjectId,
46
47
  } from "@opengeni/core";
48
+ import { boundedLimit } from "../http/common";
49
+ import { sseWorkspaceControlStream } from "../http/sse";
47
50
 
48
51
  export function registerWorkspaceRoutes(app: Hono, deps: ApiRouteDeps): void {
49
52
  app.get("/v1/access/me", async (c) => {
@@ -169,52 +172,36 @@ export function registerWorkspaceRoutes(app: Hono, deps: ApiRouteDeps): void {
169
172
  const workspaceId = c.req.param("workspaceId");
170
173
  const grant = await requireAccessGrant(c, deps, workspaceId, "workspace:admin");
171
174
  const payload = WorkspaceInferenceControlRequest.parse(await c.req.json());
172
- const result = await setWorkspaceInferenceControl(deps.db, {
173
- accountId: grant.accountId,
174
- workspaceId,
175
- actor: grant.subjectId,
176
- state: payload.state,
177
- reason: payload.reason,
178
- clientEventId: payload.clientEventId,
179
- expectedState: payload.expectedState,
180
- expectedGeneration: payload.expectedGeneration,
181
- exceptSessionIds: payload.exceptSessionIds,
182
- });
183
- for (const broadcast of result.broadcasts) {
184
- await deps.bus.publish(workspaceId, broadcast.sessionId, broadcast.events);
185
- }
186
- for (const control of result.controls) {
187
- await deps.workflowClient.signalSessionControl({
188
- accountId: control.accountId,
189
- workspaceId,
190
- sessionId: control.sessionId,
191
- eventId: control.eventId,
192
- workflowId: control.workflowId,
193
- workflowWakeRevision: control.workflowWakeRevision,
194
- });
195
- }
196
- for (const wake of result.wakeSessions) {
197
- await deps.workflowClient.wakeSessionWorkflow({
198
- accountId: wake.accountId,
199
- workspaceId,
200
- sessionId: wake.sessionId,
201
- workflowId: wake.workflowId,
202
- wakeRevision: wake.workflowWakeRevision,
203
- });
204
- }
205
175
  return c.json(
206
- {
207
- operationId: result.operationId,
208
- state: result.state,
209
- generation: result.generation,
210
- affectedSessionIds: result.affectedSessionIds,
211
- controlSessionIds: result.controls.map((entry) => entry.sessionId),
212
- exceptionSessionIds: result.exceptionSessionIds,
213
- },
214
- 202,
176
+ await controlHumanWorkspace(
177
+ { db: deps.db, bus: deps.bus, workflowClient: deps.workflowClient },
178
+ { accountId: grant.accountId, workspaceId, subjectId: grant.subjectId },
179
+ payload,
180
+ ),
215
181
  );
216
182
  });
217
183
 
184
+ app.get("/v1/workspaces/:workspaceId/control-events", async (c) => {
185
+ const workspaceId = c.req.param("workspaceId");
186
+ await requireAccessGrant(c, deps, workspaceId, "workspace:read");
187
+ const after = Math.max(0, Number.parseInt(c.req.query("after") ?? "0", 10) || 0);
188
+ return c.json(
189
+ await listWorkspaceControlEvents(
190
+ deps.db,
191
+ workspaceId,
192
+ after,
193
+ boundedLimit(c.req.query("limit")),
194
+ ),
195
+ );
196
+ });
197
+
198
+ app.get("/v1/workspaces/:workspaceId/control-events/stream", async (c) => {
199
+ const workspaceId = c.req.param("workspaceId");
200
+ await requireAccessGrant(c, deps, workspaceId, "workspace:read");
201
+ const after = Math.max(0, Number.parseInt(c.req.query("after") ?? "0", 10) || 0);
202
+ return await sseWorkspaceControlStream(deps.db, deps.bus, workspaceId, after, c.req.raw.signal);
203
+ });
204
+
218
205
  app.put("/v1/workspaces/:workspaceId/default-rig", async (c) => {
219
206
  const workspaceId = c.req.param("workspaceId");
220
207
  await requireAccessGrant(c, deps, workspaceId, "rigs:manage");