@opengeni/api-router 0.30.3 → 2.2.0-canary.0
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.
- package/dist/api-websocket.d.ts +18 -0
- package/dist/app.js +1 -1
- package/dist/auth/managed-auth.d.ts +6 -0
- package/dist/{chunk-WCXHI3FF.js → chunk-3TP54PPX.js} +26692 -15389
- package/dist/chunk-3TP54PPX.js.map +1 -0
- package/dist/codemode.d.ts +8 -1
- package/dist/company-brain-okf.d.ts +24 -0
- package/dist/connected-machine-computer-access.d.ts +11 -0
- package/dist/connection-authority-owner.d.ts +59 -0
- package/dist/connection-ownership.d.ts +47 -0
- package/dist/controller-data-plane.d.ts +16 -0
- package/dist/editable-artifact-websocket.d.ts +5 -15
- package/dist/editable-artifact-workspace-files.d.ts +1 -1
- package/dist/http/api-error.d.ts +10 -0
- package/dist/http/cors.d.ts +5 -0
- package/dist/http/interaction-control-error.d.ts +13 -0
- package/dist/http/sse.d.ts +2 -3
- package/dist/index.d.ts +5 -2
- package/dist/index.js +326 -82
- package/dist/index.js.map +1 -1
- package/dist/integrations/atlassian.d.ts +17 -0
- package/dist/integrations/fiken.d.ts +7 -2
- package/dist/integrations/google-drive.d.ts +18 -0
- package/dist/integrations/oauth-client.d.ts +22 -3
- package/dist/integrations/oauth-profiles.d.ts +195 -0
- package/dist/integrations/personal-github-repositories.d.ts +45 -0
- package/dist/integrations/personal-github.d.ts +40 -0
- package/dist/integrations/pr-review-provider.d.ts +22 -0
- package/dist/integrations/provider-oauth.d.ts +8 -0
- package/dist/integrations/slack-app-home.d.ts +24 -0
- package/dist/integrations/slack-bot.d.ts +157 -9
- package/dist/integrations/slack-interactions.d.ts +39 -5
- package/dist/integrations/slack-routing.d.ts +120 -0
- package/dist/integrations/social-oauth.d.ts +5 -0
- package/dist/interaction-frame-proxy.d.ts +68 -0
- package/dist/mcp/company-brain-governed-writes.d.ts +26 -0
- package/dist/mcp/company-profile-agent-admin.d.ts +44 -0
- package/dist/mcp/documents.d.ts +1 -0
- package/dist/mcp/remember.d.ts +28 -0
- package/dist/mcp/request-abort.d.ts +19 -0
- package/dist/mcp/scheduled-task-view.d.ts +4 -4
- package/dist/mcp/server.d.ts +15 -2
- package/dist/mcp/session-view.d.ts +4 -0
- package/dist/mcp/session-wait.d.ts +137 -0
- package/dist/routes/automations.d.ts +4 -0
- package/dist/routes/billing.d.ts +5 -0
- package/dist/routes/browser-sessions.d.ts +4 -1
- package/dist/routes/company-brain.d.ts +3 -0
- package/dist/routes/company-profile.d.ts +2 -1
- package/dist/routes/connection-authorities.d.ts +3 -0
- package/dist/routes/personal-github-git-broker.d.ts +29 -0
- package/dist/routes/personal-github.d.ts +3 -0
- package/dist/routes/pr-review.d.ts +3 -0
- package/dist/routes/sessions.d.ts +1 -0
- package/dist/routes/user-resource-authorities.d.ts +3 -0
- package/dist/routes/workspace-capture.d.ts +10 -2
- package/dist/routes/workspace-learning.d.ts +3 -0
- package/dist/routes/workspaces.d.ts +19 -0
- package/dist/sandbox/channel-a.d.ts +11 -1
- package/dist/sandbox/connection-authority.d.ts +3 -0
- package/dist/sandbox/enrollment.d.ts +2 -0
- package/dist/sandbox/machines.d.ts +2 -2
- package/dist/sandbox/metrics-ingestion.d.ts +10 -20
- package/dist/sandbox/viewer.d.ts +24 -1
- package/dist/sandbox-file-artifacts.d.ts +11 -0
- package/dist/scheduled-task-deletion.d.ts +14 -0
- package/dist/slack-reaction-files.d.ts +1 -1
- package/dist/temporal-schedule-cleanup.d.ts +1 -0
- package/package.json +20 -18
- package/src/api-websocket.ts +23 -0
- package/src/app.ts +161 -23
- package/src/auth/managed-auth.ts +25 -3
- package/src/codemode.ts +53 -23
- package/src/codex-realtime.ts +8 -2
- package/src/company-brain-okf.ts +340 -0
- package/src/connected-machine-computer-access.ts +33 -0
- package/src/connection-authority-owner.ts +61 -0
- package/src/connection-ownership.ts +180 -0
- package/src/controller-data-plane.ts +47 -0
- package/src/editable-artifact-native-kernel.ts +26 -15
- package/src/editable-artifact-office-import.ts +28 -3
- package/src/editable-artifact-production.ts +13 -7
- package/src/editable-artifact-websocket.ts +11 -18
- package/src/editable-artifact-workspace-files.ts +31 -6
- package/src/http/api-error.ts +28 -0
- package/src/http/auth.ts +4 -0
- package/src/http/cors.ts +35 -0
- package/src/http/interaction-control-error.ts +164 -0
- package/src/http/sse.ts +112 -66
- package/src/index.ts +46 -6
- package/src/integrations/atlassian.ts +146 -35
- package/src/integrations/fiken.ts +102 -22
- package/src/integrations/google-drive.ts +301 -92
- package/src/integrations/oauth-client.ts +272 -143
- package/src/integrations/oauth-profiles.ts +477 -0
- package/src/integrations/personal-github-repositories.ts +445 -0
- package/src/integrations/personal-github.ts +705 -0
- package/src/integrations/pr-review-provider.ts +246 -0
- package/src/integrations/provider-oauth.ts +121 -5
- package/src/integrations/slack-app-home.ts +300 -0
- package/src/integrations/slack-bot.ts +342 -57
- package/src/integrations/slack-interactions.ts +1898 -259
- package/src/integrations/slack-routing.ts +337 -0
- package/src/integrations/social-oauth.ts +25 -0
- package/src/interaction-frame-proxy.ts +409 -0
- package/src/mcp/company-brain-governed-writes.ts +262 -0
- package/src/mcp/company-profile-agent-admin.ts +205 -0
- package/src/mcp/documents.ts +37 -6
- package/src/mcp/files.ts +16 -1
- package/src/mcp/remember.ts +181 -0
- package/src/mcp/request-abort.ts +44 -0
- package/src/mcp/scheduled-task-view.ts +1 -0
- package/src/mcp/server.ts +719 -369
- package/src/mcp/session-view.ts +54 -0
- package/src/mcp/session-wait.ts +554 -0
- package/src/model-catalog.ts +20 -12
- package/src/routes/api-integrations.ts +4 -0
- package/src/routes/api-keys.ts +1 -0
- package/src/routes/automations.ts +534 -0
- package/src/routes/billing.ts +57 -1
- package/src/routes/browser-sessions.ts +329 -79
- package/src/routes/channels.ts +17 -1
- package/src/routes/codex.ts +50 -0
- package/src/routes/company-brain.ts +367 -0
- package/src/routes/company-profile.ts +1 -1
- package/src/routes/computer-sessions.ts +320 -93
- package/src/routes/connection-authorities.ts +139 -0
- package/src/routes/connections.ts +144 -34
- package/src/routes/documents.ts +353 -4
- package/src/routes/editable-artifacts.ts +11 -3
- package/src/routes/enrollments.ts +116 -26
- package/src/routes/environments.ts +127 -55
- package/src/routes/files.ts +72 -11
- package/src/routes/install.ts +82 -28
- package/src/routes/integration-facets.ts +29 -0
- package/src/routes/interaction-resources.ts +20 -2
- package/src/routes/machines.ts +275 -13
- package/src/routes/organization-memberships.ts +717 -28
- package/src/routes/packs.ts +4 -3
- package/src/routes/personal-github-git-broker.ts +785 -0
- package/src/routes/personal-github.ts +319 -0
- package/src/routes/pr-review.ts +531 -0
- package/src/routes/rigs.ts +104 -39
- package/src/routes/scheduled-tasks.ts +19 -21
- package/src/routes/sessions.ts +775 -38
- package/src/routes/social.ts +14 -2
- package/src/routes/supergrok.ts +23 -4
- package/src/routes/transcription-recordings.ts +8 -2
- package/src/routes/user-resource-authorities.ts +138 -0
- package/src/routes/workspace-artifacts.ts +4 -1
- package/src/routes/workspace-capture.ts +21 -0
- package/src/routes/workspace-learning.ts +228 -0
- package/src/routes/workspaces.ts +59 -6
- package/src/sandbox/auth-callout.ts +38 -12
- package/src/sandbox/channel-a.ts +242 -19
- package/src/sandbox/connection-authority.ts +3 -0
- package/src/sandbox/enrollment.ts +15 -3
- package/src/sandbox/machines.ts +186 -64
- package/src/sandbox/metrics-ingestion.ts +220 -58
- package/src/sandbox/viewer.ts +243 -19
- package/src/sandbox-file-artifacts.ts +329 -0
- package/src/scheduled-task-deletion.ts +127 -0
- package/src/slack-reaction-files.ts +16 -5
- package/src/temporal-schedule-cleanup.ts +13 -0
- package/dist/chunk-WCXHI3FF.js.map +0 -1
package/src/mcp/session-view.ts
CHANGED
|
@@ -479,6 +479,55 @@ function projectMonitoringContainer(
|
|
|
479
479
|
};
|
|
480
480
|
}
|
|
481
481
|
|
|
482
|
+
const SESSION_DETAIL_EFFECTIVE_TOOL_POLICY_ID_LIMIT = 12;
|
|
483
|
+
|
|
484
|
+
function projectEffectiveToolPolicy(value: Session["effectiveToolPolicy"]): {
|
|
485
|
+
value: unknown;
|
|
486
|
+
fact: SessionDetailFieldFact;
|
|
487
|
+
details: string[];
|
|
488
|
+
} {
|
|
489
|
+
if (!value) return projectMonitoringContainer(null, 16_000);
|
|
490
|
+
const sample = (ids: string[]) => ids.slice(0, SESSION_DETAIL_EFFECTIVE_TOOL_POLICY_ID_LIMIT);
|
|
491
|
+
const sampled =
|
|
492
|
+
value.selectedIds.length > SESSION_DETAIL_EFFECTIVE_TOOL_POLICY_ID_LIMIT ||
|
|
493
|
+
value.effectiveIds.length > SESSION_DETAIL_EFFECTIVE_TOOL_POLICY_ID_LIMIT ||
|
|
494
|
+
value.mandatoryIds.length > SESSION_DETAIL_EFFECTIVE_TOOL_POLICY_ID_LIMIT ||
|
|
495
|
+
value.lazyRouter.deferredIds.length > SESSION_DETAIL_EFFECTIVE_TOOL_POLICY_ID_LIMIT ||
|
|
496
|
+
value.configuredIds.length > SESSION_DETAIL_EFFECTIVE_TOOL_POLICY_ID_LIMIT ||
|
|
497
|
+
value.droppedIds.length > SESSION_DETAIL_EFFECTIVE_TOOL_POLICY_ID_LIMIT;
|
|
498
|
+
const bounded = {
|
|
499
|
+
...value,
|
|
500
|
+
selectedIds: sample(value.selectedIds),
|
|
501
|
+
effectiveIds: sample(value.effectiveIds),
|
|
502
|
+
mandatoryIds: sample(value.mandatoryIds),
|
|
503
|
+
lazyRouter: { ...value.lazyRouter, deferredIds: sample(value.lazyRouter.deferredIds) },
|
|
504
|
+
configuredIds: sample(value.configuredIds),
|
|
505
|
+
droppedIds: sample(value.droppedIds),
|
|
506
|
+
idsTruncated: value.idsTruncated || sampled,
|
|
507
|
+
};
|
|
508
|
+
const projected = projectMonitoringContainer(bounded, 16_000);
|
|
509
|
+
const sourceMeasurement = measureSessionEventJson(value);
|
|
510
|
+
return {
|
|
511
|
+
...projected,
|
|
512
|
+
fact: {
|
|
513
|
+
...projected.fact,
|
|
514
|
+
truncated:
|
|
515
|
+
sampled ||
|
|
516
|
+
projected.fact.truncated ||
|
|
517
|
+
sourceMeasurement.bytes === null ||
|
|
518
|
+
sourceMeasurement.bytes !== projected.fact.deliveredBytes,
|
|
519
|
+
originalBytes: sourceMeasurement.bytes,
|
|
520
|
+
...(sourceMeasurement.bytes === null ? { measurementBounded: true } : {}),
|
|
521
|
+
},
|
|
522
|
+
details: sampled
|
|
523
|
+
? [
|
|
524
|
+
`effectiveToolPolicy: ids sampled at ${SESSION_DETAIL_EFFECTIVE_TOOL_POLICY_ID_LIMIT} per set; counts remain exact`,
|
|
525
|
+
...projected.details,
|
|
526
|
+
]
|
|
527
|
+
: projected.details,
|
|
528
|
+
};
|
|
529
|
+
}
|
|
530
|
+
|
|
482
531
|
/** Purpose-built, flat, model-facing detail projection for `session_get`. */
|
|
483
532
|
export function boundSessionDetailMcp(
|
|
484
533
|
session: Session,
|
|
@@ -494,6 +543,7 @@ export function boundSessionDetailMcp(
|
|
|
494
543
|
const tools = projectMonitoringContainer(session.tools, 4_000);
|
|
495
544
|
const mcpServers = projectMonitoringContainer(session.mcpServers, 3_000);
|
|
496
545
|
const permissions = projectMonitoringContainer(session.firstPartyMcpPermissions, 1_500);
|
|
546
|
+
const effectiveToolPolicy = projectEffectiveToolPolicy(session.effectiveToolPolicy);
|
|
497
547
|
const control = projectMonitoringContainer(effectiveControl, 2_000);
|
|
498
548
|
const fieldFacts: Record<string, SessionDetailFieldFact> = {
|
|
499
549
|
title: title?.fact ?? {
|
|
@@ -512,6 +562,7 @@ export function boundSessionDetailMcp(
|
|
|
512
562
|
tools: tools.fact,
|
|
513
563
|
mcpServers: mcpServers.fact,
|
|
514
564
|
firstPartyMcpPermissions: permissions.fact,
|
|
565
|
+
effectiveToolPolicy: effectiveToolPolicy.fact,
|
|
515
566
|
effectiveControl: control.fact,
|
|
516
567
|
};
|
|
517
568
|
const details = [
|
|
@@ -520,6 +571,7 @@ export function boundSessionDetailMcp(
|
|
|
520
571
|
...tools.details,
|
|
521
572
|
...mcpServers.details,
|
|
522
573
|
...permissions.details,
|
|
574
|
+
...effectiveToolPolicy.details,
|
|
523
575
|
...control.details,
|
|
524
576
|
].slice(0, 32);
|
|
525
577
|
const result = {
|
|
@@ -545,6 +597,7 @@ export function boundSessionDetailMcp(
|
|
|
545
597
|
rigId: session.rigId,
|
|
546
598
|
rigVersionId: session.rigVersionId,
|
|
547
599
|
firstPartyMcpPermissions: permissions.value,
|
|
600
|
+
effectiveToolPolicy: effectiveToolPolicy.value,
|
|
548
601
|
mcpServers: mcpServers.value,
|
|
549
602
|
parentSessionId: session.parentSessionId,
|
|
550
603
|
createIdempotencyKey:
|
|
@@ -594,6 +647,7 @@ export function boundSessionDetailMcp(
|
|
|
594
647
|
["mcpServers", fieldFacts.mcpServers!],
|
|
595
648
|
["effectiveControl", fieldFacts.effectiveControl!],
|
|
596
649
|
["firstPartyMcpPermissions", fieldFacts.firstPartyMcpPermissions!],
|
|
650
|
+
["effectiveToolPolicy", fieldFacts.effectiveToolPolicy!],
|
|
597
651
|
];
|
|
598
652
|
for (const [field, fact] of fallbackContainers) {
|
|
599
653
|
if (result.projection.bytes <= maxBytes) break;
|
|
@@ -0,0 +1,554 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `session_wait`: one blocking first-party MCP call that replaces the
|
|
3
|
+
* `sleep 55` plus three or four `session_events`/`session_get` probes an agent
|
|
4
|
+
* otherwise runs per loop while it waits for a child or peer session.
|
|
5
|
+
*
|
|
6
|
+
* The durable `session_events` table is the only authority. The NATS event bus
|
|
7
|
+
* is live fanout only, so every wake (bus, deadline, or the initial call) is
|
|
8
|
+
* followed by a database read and the returned rows are always the exact
|
|
9
|
+
* durable events, never the bus payload. Subscriptions are opened before the
|
|
10
|
+
* first read so an event committed between the read and the subscription
|
|
11
|
+
* cannot be missed.
|
|
12
|
+
*
|
|
13
|
+
* Timing facts: the built-in `opengeni` MCP server entry carries no
|
|
14
|
+
* `timeoutMs`, so the worker's MCP client uses the SDK default request timeout
|
|
15
|
+
* of 60 s and a longer call would surface as a `-32001` tool error. The wait is
|
|
16
|
+
* therefore capped at {@link SESSION_WAIT_MAX_SECONDS} both in the tool schema
|
|
17
|
+
* and inside {@link waitForSessionChanges}. The API's `Bun.serve`
|
|
18
|
+
* `idleTimeout` is 255 s, far above that cap.
|
|
19
|
+
*
|
|
20
|
+
* Cancellation: the API serves one transport per POST, so the worker's MCP
|
|
21
|
+
* `notifications/cancelled` never reaches this handler; instead the route binds
|
|
22
|
+
* the HTTP request's own abort to `transport.close()` (`request-abort.ts`),
|
|
23
|
+
* which aborts `extra.signal`. A worker that drops the call on Steer/Pause
|
|
24
|
+
* therefore ends the server-side wait promptly; the deadline bounds it anyway.
|
|
25
|
+
*
|
|
26
|
+
* The live bus is best-effort: a failed subscription degrades the wait to the
|
|
27
|
+
* durable pre-check plus the deadline re-check and is reported as
|
|
28
|
+
* `liveFanout: false` rather than failing the tool.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import type {
|
|
32
|
+
SessionEvent,
|
|
33
|
+
SessionEventSemanticClass,
|
|
34
|
+
SessionEventType,
|
|
35
|
+
} from "@opengeni/contracts";
|
|
36
|
+
import {
|
|
37
|
+
SESSION_EVENT_SEMANTIC_CLASS_TYPES,
|
|
38
|
+
SESSION_SYSTEM_UPDATE_WAKE_CLASS,
|
|
39
|
+
compactSessionEventResult,
|
|
40
|
+
type SessionSystemUpdateKind,
|
|
41
|
+
} from "@opengeni/contracts";
|
|
42
|
+
import { SESSION_EVENT_MCP_MAX_BYTES, capPayloadValue } from "./session-view";
|
|
43
|
+
|
|
44
|
+
export const SESSION_WAIT_MAX_TARGETS = 16;
|
|
45
|
+
export const SESSION_WAIT_MAX_SECONDS = 50;
|
|
46
|
+
export const SESSION_WAIT_DEFAULT_SECONDS = 45;
|
|
47
|
+
/** Newest-first durable rows read per target on every database check. */
|
|
48
|
+
export const SESSION_WAIT_EVENTS_PER_TARGET = 20;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The durable event types that matter to a waiter: turn lifecycle, completed
|
|
52
|
+
* agent messages, blocking failures, goal facts, and session status/control
|
|
53
|
+
* changes. Raw deltas, tool receipts, sandbox/machine diagnostics, and PTY
|
|
54
|
+
* noise never wake a waiter; `session_events` remains the drill-down for them.
|
|
55
|
+
*/
|
|
56
|
+
export const SESSION_WAIT_EVENT_TYPES = [
|
|
57
|
+
"turn.started",
|
|
58
|
+
"turn.completed",
|
|
59
|
+
"turn.failed",
|
|
60
|
+
"turn.cancelled",
|
|
61
|
+
"turn.superseded",
|
|
62
|
+
"turn.capacity_waiting",
|
|
63
|
+
"agent.message.completed",
|
|
64
|
+
"session.status.changed",
|
|
65
|
+
"session.requiresAction",
|
|
66
|
+
"session.humanInput.requested",
|
|
67
|
+
"session.control.paused",
|
|
68
|
+
"session.control.resumed",
|
|
69
|
+
"tool.auth_needed",
|
|
70
|
+
"credential.auth_needed",
|
|
71
|
+
"rig.setup.failed",
|
|
72
|
+
"goal.set",
|
|
73
|
+
"goal.updated",
|
|
74
|
+
"goal.progress",
|
|
75
|
+
"goal.rewrite.proposed",
|
|
76
|
+
"goal.rewrite.rejected",
|
|
77
|
+
"goal.completed",
|
|
78
|
+
"goal.paused",
|
|
79
|
+
"goal.resumed",
|
|
80
|
+
"goal.cleared",
|
|
81
|
+
"goal.continuation",
|
|
82
|
+
] as const satisfies readonly SessionEventType[];
|
|
83
|
+
|
|
84
|
+
const SESSION_WAIT_EVENT_TYPE_SET: ReadonlySet<string> = new Set(SESSION_WAIT_EVENT_TYPES);
|
|
85
|
+
|
|
86
|
+
/** The self-session event that announces a newly pending machine input. */
|
|
87
|
+
export const SESSION_WAIT_OWN_PENDING_EVENT_TYPE =
|
|
88
|
+
"system.update.pending" satisfies SessionEventType;
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Whether a pending own machine input of this kind ends the wait. Every
|
|
92
|
+
* pre-existing kind and `child_requires_action` are `immediate`; deferred
|
|
93
|
+
* child notices are reported but do not end the wait by themselves.
|
|
94
|
+
*/
|
|
95
|
+
export function ownPendingKindWakes(kind: string): boolean {
|
|
96
|
+
const wakeClass = SESSION_SYSTEM_UPDATE_WAKE_CLASS[kind as SessionSystemUpdateKind];
|
|
97
|
+
return wakeClass === undefined || wakeClass === "immediate";
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const SEMANTIC_CLASS_PRIORITY: readonly SessionEventSemanticClass[] = [
|
|
101
|
+
"terminal",
|
|
102
|
+
"failure",
|
|
103
|
+
"control",
|
|
104
|
+
"checkpoint",
|
|
105
|
+
"tool_receipt",
|
|
106
|
+
"provider_account",
|
|
107
|
+
];
|
|
108
|
+
|
|
109
|
+
export function sessionWaitSemanticClass(type: SessionEventType): SessionEventSemanticClass {
|
|
110
|
+
for (const semanticClass of SEMANTIC_CLASS_PRIORITY) {
|
|
111
|
+
const types: readonly SessionEventType[] = SESSION_EVENT_SEMANTIC_CLASS_TYPES[semanticClass];
|
|
112
|
+
if (types.includes(type)) return semanticClass;
|
|
113
|
+
}
|
|
114
|
+
return "control";
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export type SessionWaitTarget = { sessionId: string; afterSequence: number };
|
|
118
|
+
|
|
119
|
+
export type SessionWaitEventSummary = {
|
|
120
|
+
id: string;
|
|
121
|
+
sequence: number;
|
|
122
|
+
type: SessionEventType;
|
|
123
|
+
occurredAt: string;
|
|
124
|
+
turnId: string | null;
|
|
125
|
+
turnGeneration: number | null;
|
|
126
|
+
status: string;
|
|
127
|
+
text: string | null;
|
|
128
|
+
failure: {
|
|
129
|
+
error: string | null;
|
|
130
|
+
code: string | null;
|
|
131
|
+
retryable: boolean | null;
|
|
132
|
+
recovery: string | null;
|
|
133
|
+
} | null;
|
|
134
|
+
result?: unknown;
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
export type SessionWaitTargetResult = {
|
|
138
|
+
sessionId: string;
|
|
139
|
+
afterSequence: number;
|
|
140
|
+
/** Cursor for the next `session_wait`/`session_events after=` call. */
|
|
141
|
+
latestSequence: number;
|
|
142
|
+
hasMore: boolean;
|
|
143
|
+
events: SessionWaitEventSummary[];
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
export type SessionWaitResult = {
|
|
147
|
+
changed: SessionWaitTargetResult[];
|
|
148
|
+
ownPendingUpdates: number;
|
|
149
|
+
ownPendingUpdateKinds: string[];
|
|
150
|
+
/**
|
|
151
|
+
* Pending own inputs whose kind wakes the session by itself (every
|
|
152
|
+
* pre-existing kind plus `child_requires_action`). Only these end the wait;
|
|
153
|
+
* `deferred` child notices (resolution, pause, capacity wait, progress) are
|
|
154
|
+
* reported but keep the wait on the targets.
|
|
155
|
+
*/
|
|
156
|
+
ownPendingImmediateUpdates: number;
|
|
157
|
+
ownPendingDeferredUpdateKinds: string[];
|
|
158
|
+
waitedMs: number;
|
|
159
|
+
timedOut: boolean;
|
|
160
|
+
aborted: boolean;
|
|
161
|
+
/** False when at least one live-fanout subscription failed; the wait then relied on the deadline re-check. */
|
|
162
|
+
liveFanout: boolean;
|
|
163
|
+
truncated: boolean;
|
|
164
|
+
bytes: number;
|
|
165
|
+
maxBytes: number;
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
export type SessionWaitTargetRead = { events: readonly SessionEvent[]; hasMore: boolean };
|
|
169
|
+
|
|
170
|
+
export type SessionWaitSource = {
|
|
171
|
+
/** Durable forward read of matching events strictly after the target cursor. */
|
|
172
|
+
readTargetEvents: (target: SessionWaitTarget) => Promise<SessionWaitTargetRead>;
|
|
173
|
+
/** Kinds of the caller's own pending machine inputs; null disables self tracking. */
|
|
174
|
+
readOwnPendingUpdateKinds: (() => Promise<readonly string[]>) | null;
|
|
175
|
+
/** Live fanout subscription for one session; the returned function unsubscribes. */
|
|
176
|
+
subscribe: (
|
|
177
|
+
sessionId: string,
|
|
178
|
+
onEvents: (events: SessionEvent[]) => void | Promise<void>,
|
|
179
|
+
) => Promise<() => void>;
|
|
180
|
+
/**
|
|
181
|
+
* Re-run target authorization for the sessions about to be returned after a
|
|
182
|
+
* wait (parity with SSE re-authorization). Throws to refuse the result.
|
|
183
|
+
*/
|
|
184
|
+
reauthorizeTargets?: ((sessionIds: readonly string[]) => Promise<void>) | undefined;
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
export type SessionWaitInput = {
|
|
188
|
+
targets: readonly SessionWaitTarget[];
|
|
189
|
+
ownSessionId: string | null;
|
|
190
|
+
maxWaitMs: number;
|
|
191
|
+
source: SessionWaitSource;
|
|
192
|
+
signal?: AbortSignal | undefined;
|
|
193
|
+
now?: (() => number) | undefined;
|
|
194
|
+
maxBytes?: number | undefined;
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
type WakeReason = "bus" | "deadline" | "abort";
|
|
198
|
+
|
|
199
|
+
export async function waitForSessionChanges(input: SessionWaitInput): Promise<SessionWaitResult> {
|
|
200
|
+
const now = input.now ?? Date.now;
|
|
201
|
+
const startedAt = now();
|
|
202
|
+
// The cap is enforced here as well as in the tool schema: no caller can hold
|
|
203
|
+
// the API past the worker's 60 s MCP request timeout.
|
|
204
|
+
const deadlineAt =
|
|
205
|
+
startedAt + Math.min(Math.max(0, input.maxWaitMs), SESSION_WAIT_MAX_SECONDS * 1_000);
|
|
206
|
+
let liveFanout = true;
|
|
207
|
+
let waited = false;
|
|
208
|
+
const ownSessionId = input.source.readOwnPendingUpdateKinds ? input.ownSessionId : null;
|
|
209
|
+
|
|
210
|
+
// One subscription per distinct session; a session may be both a target and
|
|
211
|
+
// the caller's own session, in which case either condition wakes the wait.
|
|
212
|
+
const targetAfter = new Map<string, number>();
|
|
213
|
+
for (const target of input.targets) {
|
|
214
|
+
const existing = targetAfter.get(target.sessionId);
|
|
215
|
+
targetAfter.set(
|
|
216
|
+
target.sessionId,
|
|
217
|
+
existing === undefined ? target.afterSequence : Math.min(existing, target.afterSequence),
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
const subscribedSessionIds = new Set<string>(targetAfter.keys());
|
|
221
|
+
if (ownSessionId !== null) subscribedSessionIds.add(ownSessionId);
|
|
222
|
+
|
|
223
|
+
let wake: (() => void) | null = null;
|
|
224
|
+
let wakePending = false;
|
|
225
|
+
const signalWake = () => {
|
|
226
|
+
wakePending = true;
|
|
227
|
+
wake?.();
|
|
228
|
+
};
|
|
229
|
+
const matchesWake = (sessionId: string, events: SessionEvent[]): boolean => {
|
|
230
|
+
const after = targetAfter.get(sessionId);
|
|
231
|
+
for (const event of events) {
|
|
232
|
+
if (event.sessionId !== sessionId) continue;
|
|
233
|
+
if (
|
|
234
|
+
after !== undefined &&
|
|
235
|
+
event.sequence > after &&
|
|
236
|
+
SESSION_WAIT_EVENT_TYPE_SET.has(event.type)
|
|
237
|
+
) {
|
|
238
|
+
return true;
|
|
239
|
+
}
|
|
240
|
+
if (sessionId === ownSessionId && event.type === SESSION_WAIT_OWN_PENDING_EVENT_TYPE) {
|
|
241
|
+
return true;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
return false;
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
const unsubscribes: Array<() => void> = [];
|
|
248
|
+
const release = () => {
|
|
249
|
+
for (const unsubscribe of unsubscribes.splice(0)) {
|
|
250
|
+
try {
|
|
251
|
+
unsubscribe();
|
|
252
|
+
} catch {
|
|
253
|
+
// Releasing a live-fanout subscription is best-effort; the durable
|
|
254
|
+
// read already decided the result.
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
const readAll = async (): Promise<{
|
|
260
|
+
changed: SessionWaitTargetResult[];
|
|
261
|
+
ownPendingUpdateKinds: string[];
|
|
262
|
+
}> => {
|
|
263
|
+
const [targetReads, ownKinds] = await Promise.all([
|
|
264
|
+
Promise.all(
|
|
265
|
+
input.targets.map(async (target) => ({
|
|
266
|
+
target,
|
|
267
|
+
read: await input.source.readTargetEvents(target),
|
|
268
|
+
})),
|
|
269
|
+
),
|
|
270
|
+
input.source.readOwnPendingUpdateKinds
|
|
271
|
+
? input.source.readOwnPendingUpdateKinds()
|
|
272
|
+
: Promise.resolve([] as readonly string[]),
|
|
273
|
+
]);
|
|
274
|
+
const changed: SessionWaitTargetResult[] = [];
|
|
275
|
+
for (const { target, read } of targetReads) {
|
|
276
|
+
const events = read.events.filter(
|
|
277
|
+
(event) =>
|
|
278
|
+
event.sequence > target.afterSequence && SESSION_WAIT_EVENT_TYPE_SET.has(event.type),
|
|
279
|
+
);
|
|
280
|
+
if (events.length === 0) continue;
|
|
281
|
+
changed.push({
|
|
282
|
+
sessionId: target.sessionId,
|
|
283
|
+
afterSequence: target.afterSequence,
|
|
284
|
+
latestSequence: events[events.length - 1]!.sequence,
|
|
285
|
+
hasMore: read.hasMore,
|
|
286
|
+
events: events.map((event) => summarizeSessionWaitEvent(event)),
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
return { changed, ownPendingUpdateKinds: [...ownKinds] };
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
const finish = async (
|
|
293
|
+
read: { changed: SessionWaitTargetResult[]; ownPendingUpdateKinds: string[] },
|
|
294
|
+
outcome: { timedOut: boolean; aborted: boolean },
|
|
295
|
+
): Promise<SessionWaitResult> => {
|
|
296
|
+
// Authorization ran immediately before the first durable read; a result
|
|
297
|
+
// produced after waiting re-proves every returned target first.
|
|
298
|
+
if (waited && read.changed.length > 0 && input.source.reauthorizeTargets) {
|
|
299
|
+
await input.source.reauthorizeTargets(read.changed.map((target) => target.sessionId));
|
|
300
|
+
}
|
|
301
|
+
const ownKinds = [...new Set(read.ownPendingUpdateKinds)].sort();
|
|
302
|
+
return boundSessionWaitResult(
|
|
303
|
+
{
|
|
304
|
+
changed: read.changed,
|
|
305
|
+
ownPendingUpdates: read.ownPendingUpdateKinds.length,
|
|
306
|
+
ownPendingUpdateKinds: ownKinds,
|
|
307
|
+
ownPendingImmediateUpdates: read.ownPendingUpdateKinds.filter((kind) =>
|
|
308
|
+
ownPendingKindWakes(kind),
|
|
309
|
+
).length,
|
|
310
|
+
ownPendingDeferredUpdateKinds: ownKinds.filter((kind) => !ownPendingKindWakes(kind)),
|
|
311
|
+
waitedMs: Math.max(0, now() - startedAt),
|
|
312
|
+
timedOut: outcome.timedOut,
|
|
313
|
+
aborted: outcome.aborted,
|
|
314
|
+
liveFanout,
|
|
315
|
+
},
|
|
316
|
+
input.maxBytes,
|
|
317
|
+
);
|
|
318
|
+
};
|
|
319
|
+
|
|
320
|
+
try {
|
|
321
|
+
if (input.signal?.aborted) {
|
|
322
|
+
return await finish(
|
|
323
|
+
{ changed: [], ownPendingUpdateKinds: [] },
|
|
324
|
+
{ timedOut: false, aborted: true },
|
|
325
|
+
);
|
|
326
|
+
}
|
|
327
|
+
// Subscribe first, then read: an event committed between the read and the
|
|
328
|
+
// subscription would otherwise be missed until the deadline re-check. A
|
|
329
|
+
// failed subscription degrades to pre-check plus deadline re-check.
|
|
330
|
+
for (const sessionId of subscribedSessionIds) {
|
|
331
|
+
try {
|
|
332
|
+
const unsubscribe = await input.source.subscribe(sessionId, (events) => {
|
|
333
|
+
if (matchesWake(sessionId, events)) signalWake();
|
|
334
|
+
});
|
|
335
|
+
unsubscribes.push(unsubscribe);
|
|
336
|
+
} catch {
|
|
337
|
+
liveFanout = false;
|
|
338
|
+
}
|
|
339
|
+
if (input.signal?.aborted) break;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
for (;;) {
|
|
343
|
+
if (input.signal?.aborted) {
|
|
344
|
+
return await finish(
|
|
345
|
+
{ changed: [], ownPendingUpdateKinds: [] },
|
|
346
|
+
{ timedOut: false, aborted: true },
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
wakePending = false;
|
|
350
|
+
const read = await readAll();
|
|
351
|
+
if (
|
|
352
|
+
read.changed.length > 0 ||
|
|
353
|
+
read.ownPendingUpdateKinds.some((kind) => ownPendingKindWakes(kind))
|
|
354
|
+
) {
|
|
355
|
+
return await finish(read, { timedOut: false, aborted: false });
|
|
356
|
+
}
|
|
357
|
+
const remainingMs = deadlineAt - now();
|
|
358
|
+
if (remainingMs <= 0) {
|
|
359
|
+
return await finish(read, { timedOut: true, aborted: false });
|
|
360
|
+
}
|
|
361
|
+
if (wakePending) continue;
|
|
362
|
+
const reason = await waitForWake(remainingMs, input.signal, (resolve) => {
|
|
363
|
+
wake = resolve;
|
|
364
|
+
});
|
|
365
|
+
wake = null;
|
|
366
|
+
waited = true;
|
|
367
|
+
if (reason === "abort") {
|
|
368
|
+
return await finish(
|
|
369
|
+
{ changed: [], ownPendingUpdateKinds: [] },
|
|
370
|
+
{ timedOut: false, aborted: true },
|
|
371
|
+
);
|
|
372
|
+
}
|
|
373
|
+
// "bus" and "deadline" both re-read durable state; the deadline branch is
|
|
374
|
+
// the final re-check that covers any lost live fanout.
|
|
375
|
+
}
|
|
376
|
+
} finally {
|
|
377
|
+
wake = null;
|
|
378
|
+
release();
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function waitForWake(
|
|
383
|
+
timeoutMs: number,
|
|
384
|
+
signal: AbortSignal | undefined,
|
|
385
|
+
registerWake: (wake: () => void) => void,
|
|
386
|
+
): Promise<WakeReason> {
|
|
387
|
+
return new Promise<WakeReason>((resolve) => {
|
|
388
|
+
let settled = false;
|
|
389
|
+
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
390
|
+
const onAbort = () => settle("abort");
|
|
391
|
+
const settle = (reason: WakeReason) => {
|
|
392
|
+
if (settled) return;
|
|
393
|
+
settled = true;
|
|
394
|
+
if (timer !== null) clearTimeout(timer);
|
|
395
|
+
signal?.removeEventListener("abort", onAbort);
|
|
396
|
+
resolve(reason);
|
|
397
|
+
};
|
|
398
|
+
registerWake(() => settle("bus"));
|
|
399
|
+
timer = setTimeout(() => settle("deadline"), Math.max(1, Math.ceil(timeoutMs)));
|
|
400
|
+
if (signal) {
|
|
401
|
+
if (signal.aborted) {
|
|
402
|
+
settle("abort");
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
406
|
+
}
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
const SUMMARY_TEXT_TIERS = [2_000, 800, 200] as const;
|
|
411
|
+
const SUMMARY_FAILURE_CHARS = 500;
|
|
412
|
+
const SUMMARY_RESULT_CHARS = 1_000;
|
|
413
|
+
|
|
414
|
+
function truncationMarker(droppedChars: number): string {
|
|
415
|
+
return `…[${droppedChars} chars omitted from this session_wait summary; use session_events for the exact event]`;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function clampSummaryString(value: string, maxChars: number): string {
|
|
419
|
+
if (value.length <= maxChars) return value;
|
|
420
|
+
const dropped = value.length - maxChars;
|
|
421
|
+
const headChars = Math.max(0, Math.floor(maxChars * 0.7));
|
|
422
|
+
const tailChars = Math.max(0, maxChars - headChars);
|
|
423
|
+
const tail = tailChars > 0 ? value.slice(value.length - tailChars) : "";
|
|
424
|
+
return `${value.slice(0, headChars)}${truncationMarker(dropped)}${tail}`;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/** One bounded, result-bearing summary of a durable event for a waiter. */
|
|
428
|
+
export function summarizeSessionWaitEvent(
|
|
429
|
+
event: SessionEvent,
|
|
430
|
+
textChars: number = SUMMARY_TEXT_TIERS[0],
|
|
431
|
+
): SessionWaitEventSummary {
|
|
432
|
+
const compact = compactSessionEventResult(event, sessionWaitSemanticClass(event.type));
|
|
433
|
+
const text = compact.text === null ? null : clampSummaryString(compact.text, textChars);
|
|
434
|
+
const failure =
|
|
435
|
+
compact.failure === null
|
|
436
|
+
? null
|
|
437
|
+
: {
|
|
438
|
+
error:
|
|
439
|
+
compact.failure.error === null
|
|
440
|
+
? null
|
|
441
|
+
: clampSummaryString(compact.failure.error, SUMMARY_FAILURE_CHARS),
|
|
442
|
+
code:
|
|
443
|
+
compact.failure.code === null ? null : clampSummaryString(compact.failure.code, 128),
|
|
444
|
+
retryable: compact.failure.retryable,
|
|
445
|
+
recovery:
|
|
446
|
+
compact.failure.recovery === null
|
|
447
|
+
? null
|
|
448
|
+
: clampSummaryString(compact.failure.recovery, SUMMARY_FAILURE_CHARS),
|
|
449
|
+
};
|
|
450
|
+
const summary: SessionWaitEventSummary = {
|
|
451
|
+
id: compact.id,
|
|
452
|
+
sequence: compact.sequence,
|
|
453
|
+
type: compact.type,
|
|
454
|
+
occurredAt: compact.occurredAt,
|
|
455
|
+
turnId: compact.turnId,
|
|
456
|
+
turnGeneration: compact.turnGeneration,
|
|
457
|
+
status: compact.status,
|
|
458
|
+
text,
|
|
459
|
+
failure,
|
|
460
|
+
};
|
|
461
|
+
// `result` repeats `text` for plain message/completion events; only carry a
|
|
462
|
+
// distinct structured result so the summary stays small.
|
|
463
|
+
if (
|
|
464
|
+
compact.result !== null &&
|
|
465
|
+
compact.result !== undefined &&
|
|
466
|
+
compact.result !== compact.text &&
|
|
467
|
+
typeof compact.result !== "string"
|
|
468
|
+
) {
|
|
469
|
+
summary.result = capPayloadValue(compact.result, SUMMARY_RESULT_CHARS);
|
|
470
|
+
} else if (typeof compact.result === "string" && compact.result !== compact.text) {
|
|
471
|
+
summary.result = clampSummaryString(compact.result, textChars);
|
|
472
|
+
}
|
|
473
|
+
return summary;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function prettyJsonBytes(value: unknown): number {
|
|
477
|
+
return Buffer.byteLength(JSON.stringify(value, null, 2), "utf8");
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
function measure(result: SessionWaitResult): SessionWaitResult {
|
|
481
|
+
let measured = result.bytes;
|
|
482
|
+
for (let attempt = 0; attempt < 8; attempt += 1) {
|
|
483
|
+
result.bytes = measured;
|
|
484
|
+
const next = prettyJsonBytes(result);
|
|
485
|
+
if (next === measured) return result;
|
|
486
|
+
measured = next;
|
|
487
|
+
}
|
|
488
|
+
result.bytes = measured;
|
|
489
|
+
return result;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/**
|
|
493
|
+
* Keep the whole model-visible result at or below the `session_events` MCP
|
|
494
|
+
* envelope. Text is tightened first; only then are newest rows dropped from the
|
|
495
|
+
* largest target, which keeps each target's `latestSequence` an exact cursor
|
|
496
|
+
* for the rows actually delivered and marks the remainder as `hasMore`.
|
|
497
|
+
*/
|
|
498
|
+
export function boundSessionWaitResult(
|
|
499
|
+
input: Omit<SessionWaitResult, "truncated" | "bytes" | "maxBytes">,
|
|
500
|
+
maxBytes: number = SESSION_EVENT_MCP_MAX_BYTES,
|
|
501
|
+
): SessionWaitResult {
|
|
502
|
+
const envelopeMaxBytes = Math.max(8 * 1024, maxBytes);
|
|
503
|
+
const build = (changed: SessionWaitTargetResult[], truncated: boolean): SessionWaitResult =>
|
|
504
|
+
measure({
|
|
505
|
+
...input,
|
|
506
|
+
changed,
|
|
507
|
+
truncated,
|
|
508
|
+
bytes: 0,
|
|
509
|
+
maxBytes: envelopeMaxBytes,
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
let candidate = build(input.changed, false);
|
|
513
|
+
if (candidate.bytes <= envelopeMaxBytes) return candidate;
|
|
514
|
+
|
|
515
|
+
// Tier 1: tighten text on every summary. Summaries carry their source id and
|
|
516
|
+
// sequence, so a tighter projection loses no identity.
|
|
517
|
+
const retextured = (textChars: number): SessionWaitTargetResult[] =>
|
|
518
|
+
input.changed.map((target) => ({
|
|
519
|
+
...target,
|
|
520
|
+
events: target.events.map((event) => ({
|
|
521
|
+
...event,
|
|
522
|
+
text: event.text === null ? null : clampSummaryString(event.text, textChars),
|
|
523
|
+
...(typeof event.result === "string"
|
|
524
|
+
? { result: clampSummaryString(event.result, textChars) }
|
|
525
|
+
: {}),
|
|
526
|
+
})),
|
|
527
|
+
}));
|
|
528
|
+
let changed = input.changed;
|
|
529
|
+
for (const textChars of SUMMARY_TEXT_TIERS.slice(1)) {
|
|
530
|
+
changed = retextured(textChars);
|
|
531
|
+
candidate = build(changed, true);
|
|
532
|
+
if (candidate.bytes <= envelopeMaxBytes) return candidate;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
// Tier 2: drop newest rows from the largest target until the envelope fits.
|
|
536
|
+
const working = changed.map((target) => ({ ...target, events: [...target.events] }));
|
|
537
|
+
for (;;) {
|
|
538
|
+
let largest: (typeof working)[number] | null = null;
|
|
539
|
+
for (const target of working) {
|
|
540
|
+
if (target.events.length === 0) continue;
|
|
541
|
+
if (largest === null || target.events.length > largest.events.length) largest = target;
|
|
542
|
+
}
|
|
543
|
+
if (largest === null) break;
|
|
544
|
+
largest.events.pop();
|
|
545
|
+
largest.hasMore = true;
|
|
546
|
+
largest.latestSequence =
|
|
547
|
+
largest.events.length > 0
|
|
548
|
+
? largest.events[largest.events.length - 1]!.sequence
|
|
549
|
+
: largest.afterSequence;
|
|
550
|
+
candidate = build(working, true);
|
|
551
|
+
if (candidate.bytes <= envelopeMaxBytes) return candidate;
|
|
552
|
+
}
|
|
553
|
+
throw new RangeError(`session_wait result exceeds its ${envelopeMaxBytes}-byte envelope`);
|
|
554
|
+
}
|