@opengeni/db 4.3.3-canary.0 → 4.3.3-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.
package/src/index.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { codexSelectionDiagnostics } from "./codex-selection-diagnostics";
2
+ export { codexSelectionDiagnostics } from "./codex-selection-diagnostics";
1
3
  import {
2
4
  latestStartedSessionTurnQuery,
3
5
  withLatestStartedSessionPolicy,
@@ -335,7 +337,6 @@ import {
335
337
  } from "@opengeni/contracts";
336
338
  import {
337
339
  environmentsEncryptionKeyBytes,
338
- SANDBOX_LIFECYCLE_RETRY_HANDOFF_GRACE_MS,
339
340
  SANDBOX_LIFECYCLE_TRANSITION_MAX_WAIT_MS,
340
341
  WORKSPACE_OPENROUTER_CONNECTION_DOMAIN,
341
342
  WORKSPACE_OPENROUTER_CONNECTION_ROLE,
@@ -343,6 +344,7 @@ import {
343
344
  VERCEL_AI_GATEWAY_CONNECTION_ROLE,
344
345
  type Settings,
345
346
  } from "@opengeni/config";
347
+ import { SandboxTransitionWaitBudget } from "./sandbox-transition-wait";
346
348
  import { normalizeWorkspaceMembershipPermissions } from "./workspace-membership-permissions";
347
349
  import {
348
350
  canonicalizePersistedHistoryItem,
@@ -399,6 +401,8 @@ import {
399
401
  export { LOSSLESS_TEXT_PREFIX } from "./lossless-json";
400
402
  import {
401
403
  projectSessionMcpProgressText,
404
+ projectSessionTextPrefix,
405
+ sessionTextStoragePrefixChars,
402
406
  SESSION_MCP_PROGRESS_STORAGE_CHARS,
403
407
  sessionMcpProgressScalarIsEncodedSql,
404
408
  } from "./session-mcp-progress";
@@ -29660,6 +29664,114 @@ export async function setSessionCodexPin(
29660
29664
  }
29661
29665
 
29662
29666
  /** Written by the worker at the turn boundary; drives the in-session indicator. */
29667
+ /** Commit observed assignment and its events together under the exact live attempt.
29668
+ * Allocation still uses its frozen policy snapshot; this is observation only.
29669
+ * Replays return the same receipt and never manufacture a second switch.
29670
+ */
29671
+ export async function recordSessionCodexSelectionForTurnAttempt(
29672
+ db: Database,
29673
+ input: {
29674
+ workspaceId: string;
29675
+ sessionId: string;
29676
+ turnId: string;
29677
+ attemptId: string;
29678
+ executionGeneration: number;
29679
+ credentialId: string;
29680
+ strategy: string;
29681
+ reusedLease: boolean;
29682
+ pinnedCredentialId: string | null;
29683
+ pinSource: "manual" | "policy" | null;
29684
+ eligibleCount: number;
29685
+ connectedCount: number;
29686
+ },
29687
+ ): Promise<{ events: SessionEvent[]; diagnostics: ReturnType<typeof codexSelectionDiagnostics> }> {
29688
+ return await withWorkspaceSessionEventActivityRls(db, input.workspaceId, true, async (tx) => {
29689
+ const fence = await lockTurnAttemptWriteFenceTx(tx, {
29690
+ workspaceId: input.workspaceId,
29691
+ sessionId: input.sessionId,
29692
+ turnId: input.turnId,
29693
+ executionGeneration: input.executionGeneration,
29694
+ attemptId: input.attemptId,
29695
+ sessionLock: "no_key_update",
29696
+ });
29697
+ if (!fence.allowed || !fence.session) throw new CodexCredentialLeaseAttemptFencedError();
29698
+ const key = `opengeni:codex-selection:${input.attemptId}`;
29699
+ const prior = await tx
29700
+ .select()
29701
+ .from(schema.sessionEvents)
29702
+ .where(
29703
+ and(
29704
+ eq(schema.sessionEvents.workspaceId, input.workspaceId),
29705
+ eq(schema.sessionEvents.sessionId, input.sessionId),
29706
+ inArray(schema.sessionEvents.clientEventId, [key, `${key}:switch`]),
29707
+ ),
29708
+ )
29709
+ .orderBy(asc(schema.sessionEvents.sequence));
29710
+ if (prior.length) {
29711
+ const receipt = prior.find((event) => event.type === "codex.credential.selected");
29712
+ if (!receipt) throw new Error("Codex selection receipt missing");
29713
+ const payload = sessionEventPayloadRecord(receipt.payload, receipt.payloadCodecVersion);
29714
+ if (payload.credentialId !== input.credentialId)
29715
+ throw new Error("An attempt cannot record two different Codex selections");
29716
+ return {
29717
+ events: prior.map(mapEvent),
29718
+ diagnostics: {
29719
+ transition: payload.transition,
29720
+ source: payload.source,
29721
+ reason: payload.reason,
29722
+ } as ReturnType<typeof codexSelectionDiagnostics>,
29723
+ };
29724
+ }
29725
+ const previousCredentialId = fence.session.codexLastCredentialId;
29726
+ const diagnostics = codexSelectionDiagnostics({ ...input, previousCredentialId });
29727
+ const events: AppendEventInput[] = [];
29728
+ if (previousCredentialId !== null && previousCredentialId !== input.credentialId) {
29729
+ events.push({
29730
+ type: "codex.account.switched",
29731
+ clientEventId: `${key}:switch`,
29732
+ payload: {
29733
+ fromAccountId: previousCredentialId,
29734
+ toAccountId: input.credentialId,
29735
+ reason: diagnostics.source === "manual_pin" ? "manual" : "rotation",
29736
+ },
29737
+ });
29738
+ }
29739
+ events.push({
29740
+ type: "codex.credential.selected",
29741
+ clientEventId: key,
29742
+ payload: {
29743
+ credentialId: input.credentialId,
29744
+ strategy: input.strategy,
29745
+ ...diagnostics,
29746
+ previousCredentialId,
29747
+ eligibleCount: input.eligibleCount,
29748
+ connectedCount: input.connectedCount,
29749
+ reused: input.reusedLease,
29750
+ },
29751
+ });
29752
+ await tx
29753
+ .update(schema.sessions)
29754
+ .set({ codexLastCredentialId: input.credentialId })
29755
+ .where(
29756
+ and(
29757
+ eq(schema.sessions.workspaceId, input.workspaceId),
29758
+ eq(schema.sessions.id, input.sessionId),
29759
+ ),
29760
+ );
29761
+ const appended = await appendSessionEventsForTurnAttempt(
29762
+ tx,
29763
+ input.workspaceId,
29764
+ input.sessionId,
29765
+ input.turnId,
29766
+ input.executionGeneration,
29767
+ input.attemptId,
29768
+ events,
29769
+ );
29770
+ if (!appended.accepted) throw new CodexCredentialLeaseAttemptFencedError();
29771
+ return { events: appended.events, diagnostics };
29772
+ });
29773
+ }
29774
+
29663
29775
  export async function recordSessionActiveCodexCredential(
29664
29776
  db: Database,
29665
29777
  workspaceId: string,
@@ -35195,6 +35307,120 @@ export async function listSessionsForSubject(
35195
35307
  }
35196
35308
 
35197
35309
  /** Read a session with the caller subject's personal pin projection. */
35310
+ /** Read failure evidence at the already-authorized detail cursor, never a browser page.
35311
+ * Event rows are immutable; the sequence fence keeps these reads coherent even
35312
+ * if another turn is accepted while the detail projection is being assembled.
35313
+ * Keep this off fleet/list/worker reads. Existing type/turn indexes cover it.
35314
+ */
35315
+ async function sessionFailureDiagnostics(
35316
+ db: Database,
35317
+ workspaceId: string,
35318
+ session: { id: string; lastSequence: number; status: string },
35319
+ ): Promise<Session["failureDiagnostics"]> {
35320
+ if (session.status !== "failed") return null;
35321
+ const scope = and(
35322
+ eq(schema.sessionEvents.workspaceId, workspaceId),
35323
+ eq(schema.sessionEvents.sessionId, session.id),
35324
+ lte(schema.sessionEvents.sequence, session.lastSequence),
35325
+ isNull(schema.sessionEvents.duplicateOfEventId),
35326
+ or(
35327
+ isNull(schema.sessionEvents.turnAssociation),
35328
+ eq(schema.sessionEvents.turnAssociation, "current"),
35329
+ ),
35330
+ );
35331
+ // Transfer only codec-aware prefixes. Cutting an encoded storage scalar
35332
+ // directly would show base64 rather than the accepted logical error text.
35333
+ const maxChars = 1024;
35334
+ const storageChars = sessionTextStoragePrefixChars(maxChars);
35335
+ const diagnosticFields = ["error", "message", "detail", "lastRetryableError", "code", "status"];
35336
+ const boundedFields = diagnosticFields.flatMap((field) => {
35337
+ const scalar = sql`case when jsonb_typeof(${schema.sessionEvents.payload}->${field}::text) = 'string'
35338
+ then ${schema.sessionEvents.payload}->>${field}::text else null end`;
35339
+ return [
35340
+ sql`${field}::text`,
35341
+ sql`jsonb_build_object(
35342
+ 'prefix', left(${scalar}, ${storageChars}), 'chars', char_length(${scalar}),
35343
+ 'encoded', ${sessionMcpProgressScalarIsEncodedSql(scalar, schema.sessionEvents.payloadCodecVersion)})`,
35344
+ ];
35345
+ });
35346
+ const projectedPayload = sql<Record<string, unknown>>`jsonb_build_object(
35347
+ ${sql.join(boundedFields, sql`, `)},
35348
+ 'providerRecoveryCount', case when jsonb_typeof(${schema.sessionEvents.payload}->'providerRecoveryCount') = 'number'
35349
+ and length((${schema.sessionEvents.payload}->'providerRecoveryCount')::text) <= 16
35350
+ then ${schema.sessionEvents.payload}->'providerRecoveryCount' else 'null'::jsonb end)`;
35351
+ const latest = async (type: string) => {
35352
+ const [row] = await db
35353
+ .select({
35354
+ id: schema.sessionEvents.id,
35355
+ sequence: schema.sessionEvents.sequence,
35356
+ turnId: schema.sessionEvents.turnId,
35357
+ occurredAt: schema.sessionEvents.occurredAt,
35358
+ codecVersion: schema.sessionEvents.payloadCodecVersion,
35359
+ payload: projectedPayload,
35360
+ })
35361
+ .from(schema.sessionEvents)
35362
+ .where(and(scope, eq(schema.sessionEvents.type, type)))
35363
+ .orderBy(desc(schema.sessionEvents.sequence))
35364
+ .limit(1);
35365
+ if (!row) return null;
35366
+ const payload: Record<string, unknown> = {
35367
+ providerRecoveryCount: row.payload.providerRecoveryCount,
35368
+ };
35369
+ const truncatedFields: string[] = [];
35370
+ for (const field of diagnosticFields) {
35371
+ const stored = row.payload[field] as {
35372
+ prefix: string | null;
35373
+ chars: number | null;
35374
+ encoded: boolean;
35375
+ };
35376
+ const projected = projectSessionTextPrefix(
35377
+ stored.prefix,
35378
+ stored.chars,
35379
+ row.codecVersion,
35380
+ stored.encoded,
35381
+ maxChars,
35382
+ );
35383
+ payload[field] = projected.text;
35384
+ if (projected.textTruncated || (projected.originalChars ?? 0) > maxChars)
35385
+ truncatedFields.push(field);
35386
+ }
35387
+ // Six 1024-character logical strings remain below 40KiB even when JSON
35388
+ // escapes every character as six bytes. Stored values remain untouched.
35389
+ payload.projection = { fieldsOnly: true, fieldLimitChars: maxChars, truncatedFields };
35390
+ return { ...row, payload, occurredAt: row.occurredAt.toISOString() };
35391
+ };
35392
+ const turnFailure = await latest("turn.failed");
35393
+ const latestStatus = await latest("session.status.changed");
35394
+ const statusPayload = latestStatus?.payload as Record<string, unknown> | undefined;
35395
+ // Do not associate an earlier failure with a later recorded revival.
35396
+ if (
35397
+ latestStatus &&
35398
+ (!turnFailure || latestStatus.sequence > turnFailure.sequence) &&
35399
+ typeof statusPayload?.status === "string" &&
35400
+ statusPayload.status !== "failed"
35401
+ )
35402
+ return null;
35403
+ const preClaimFailure =
35404
+ statusPayload?.status === "failed" && statusPayload.code === "pre_claim_failure"
35405
+ ? latestStatus
35406
+ : null;
35407
+ const failure =
35408
+ preClaimFailure &&
35409
+ (!turnFailure ||
35410
+ (preClaimFailure.sequence > turnFailure.sequence &&
35411
+ (!preClaimFailure.turnId || preClaimFailure.turnId !== turnFailure.turnId)))
35412
+ ? preClaimFailure
35413
+ : turnFailure;
35414
+ if (!failure) return null;
35415
+ return {
35416
+ eventId: failure.id,
35417
+ sequence: failure.sequence,
35418
+ turnId: failure.turnId ?? null,
35419
+ occurredAt: failure.occurredAt,
35420
+ payload: failure.payload,
35421
+ };
35422
+ }
35423
+
35198
35424
  export async function getSessionForSubject(
35199
35425
  db: Database,
35200
35426
  workspaceId: string,
@@ -35204,8 +35430,22 @@ export async function getSessionForSubject(
35204
35430
  ): Promise<Session | null> {
35205
35431
  return await withWorkspaceSubjectRls(db, workspaceId, subjectId, async (scopedDb) => {
35206
35432
  const [row] = await scopedDb
35207
- .select({ session: schema.sessions, pin: schema.sessionPins })
35433
+ .select({
35434
+ session: schema.sessions,
35435
+ pin: schema.sessionPins,
35436
+ cursor: schema.sessionEventCursors,
35437
+ })
35208
35438
  .from(schema.sessions)
35439
+ // Status and replay cursor must share one statement snapshot. Otherwise a
35440
+ // concurrent revival can pair an old failed row with a new running cursor,
35441
+ // causing the browser to skip the very event that would refresh its status.
35442
+ .leftJoin(
35443
+ schema.sessionEventCursors,
35444
+ and(
35445
+ eq(schema.sessionEventCursors.workspaceId, schema.sessions.workspaceId),
35446
+ eq(schema.sessionEventCursors.sessionId, schema.sessions.id),
35447
+ ),
35448
+ )
35209
35449
  .leftJoin(
35210
35450
  schema.sessionPins,
35211
35451
  and(
@@ -35230,25 +35470,40 @@ export async function getSessionForSubject(
35230
35470
  )
35231
35471
  .limit(1);
35232
35472
  if (!row) return null;
35233
- const [session] = await canonicalSessionRowsFromEventCursors(scopedDb, workspaceId, [
35234
- row.session,
35235
- ]);
35473
+ if (
35474
+ !row.cursor ||
35475
+ row.cursor.accountId !== row.session.accountId ||
35476
+ row.cursor.lastSequence < row.session.lastSequence
35477
+ ) {
35478
+ throw new Error(`Session event cursor invariant failed for session ${sessionId}`);
35479
+ }
35480
+ const [session] = await withLatestStartedSessionPolicy(
35481
+ scopedDb,
35482
+ workspaceId,
35483
+ await withCurrentSessionInputWait(scopedDb, workspaceId, [
35484
+ { ...row.session, lastSequence: row.cursor.lastSequence },
35485
+ ]),
35486
+ );
35236
35487
  if (!session) throw new Error(`Session event cursor missing for session ${sessionId}`);
35237
35488
  const mcpServers = await sessionMcpServerMetadataForSessions(scopedDb, workspaceId, [
35238
35489
  sessionId,
35239
35490
  ]);
35240
35491
  const tenancyActivated = await sessionTenancyProductActivated(scopedDb, workspaceId);
35492
+ const failureDiagnostics = await sessionFailureDiagnostics(scopedDb, workspaceId, session);
35241
35493
  return projectSessionForRelatedAccess(
35242
- await mapSessionWithControl(
35243
- scopedDb,
35244
- session,
35245
- mcpServers.get(sessionId) ?? [],
35246
- mapSessionPin(row.pin),
35247
- mapSessionAttention(session, row.pin),
35248
- mapSessionArchive(row.pin),
35249
- undefined,
35250
- { subjectId, activated: tenancyActivated },
35251
- ),
35494
+ {
35495
+ ...(await mapSessionWithControl(
35496
+ scopedDb,
35497
+ session,
35498
+ mcpServers.get(sessionId) ?? [],
35499
+ mapSessionPin(row.pin),
35500
+ mapSessionAttention(session, row.pin),
35501
+ mapSessionArchive(row.pin),
35502
+ undefined,
35503
+ { subjectId, activated: tenancyActivated },
35504
+ )),
35505
+ failureDiagnostics,
35506
+ },
35252
35507
  relatedSessionAccess,
35253
35508
  );
35254
35509
  });
@@ -43119,19 +43374,6 @@ function archiveCaptureRemainingMs(
43119
43374
  : null;
43120
43375
  }
43121
43376
 
43122
- function extendSandboxTransitionDeadline(
43123
- currentDeadline: number,
43124
- hardDeadline: number,
43125
- remainingCaptureMs: number | null | undefined,
43126
- now: number,
43127
- ): number {
43128
- if (remainingCaptureMs === null || remainingCaptureMs === undefined) return currentDeadline;
43129
- return Math.min(
43130
- hardDeadline,
43131
- Math.max(currentDeadline, now + remainingCaptureMs + SANDBOX_LIFECYCLE_RETRY_HANDOFF_GRACE_MS),
43132
- );
43133
- }
43134
-
43135
43377
  function providerlessLeaseBackendPredicateSql() {
43136
43378
  const providerlessBackends = Object.entries(SANDBOX_PROVIDER_INSTANCE_ID_FIELDS_BY_BACKEND)
43137
43379
  .filter(([, fields]) => fields.length === 0)
@@ -43976,33 +44218,27 @@ export async function acquireLease(
43976
44218
  // NTP/VM resume may change timestamp interpretation, but must never stretch
43977
44219
  // or prematurely consume the caller's resource budget. PostgreSQL projects
43978
44220
  // an active claim's remaining time on its own authoritative clock; that may
43979
- // extend a positive caller budget after a rolling config change, but never
43980
- // beyond the same one-hour lifecycle ceiling.
44221
+ // extend a positive caller budget once after a rolling config change. Later
44222
+ // capture retries must not keep renewing a waiter's observational budget.
43981
44223
  const startedAt = performance.now();
43982
- const hardDeadline = startedAt + SANDBOX_LIFECYCLE_TRANSITION_MAX_WAIT_MS;
43983
- let deadline = Math.min(hardDeadline, startedAt + captureWaitMs);
44224
+ const waitBudget = new SandboxTransitionWaitBudget(captureWaitMs, startedAt);
43984
44225
  let delayMs = 25;
43985
44226
  for (;;) {
43986
44227
  const result = await acquireLeaseOnce(db, input);
43987
44228
  const now = performance.now();
43988
44229
  if (captureWaitMs > 0 && result.role === "fenced" && result.reason === "capture_in_progress") {
43989
- deadline = extendSandboxTransitionDeadline(
43990
- deadline,
43991
- hardDeadline,
43992
- result.captureRemainingMs,
43993
- now,
43994
- );
44230
+ waitBudget.observeCapture(result.captureRemainingMs, now);
43995
44231
  }
43996
44232
  if (
43997
44233
  result.role !== "fenced" ||
43998
44234
  result.reason === "superseded" ||
43999
44235
  result.reason === "rotation_in_progress" ||
44000
- now >= deadline
44236
+ now >= waitBudget.deadline
44001
44237
  ) {
44002
44238
  return result;
44003
44239
  }
44004
44240
  await waitForSandboxTransition(
44005
- Math.min(delayMs, Math.max(1, deadline - now)),
44241
+ Math.min(delayMs, Math.max(1, waitBudget.deadline - now)),
44006
44242
  input.waitSignal,
44007
44243
  "Sandbox lease transition wait cancelled",
44008
44244
  );
@@ -50128,8 +50364,7 @@ async function advanceWorkspaceGenerationForAuthority(
50128
50364
  throw new Error("Workspace archive capture wait is invalid");
50129
50365
  }
50130
50366
  const startedAt = performance.now();
50131
- const hardDeadline = startedAt + SANDBOX_LIFECYCLE_TRANSITION_MAX_WAIT_MS;
50132
- let deadline = Math.min(hardDeadline, startedAt + captureWaitMs);
50367
+ const waitBudget = new SandboxTransitionWaitBudget(captureWaitMs, startedAt);
50133
50368
  let delayMs = 25;
50134
50369
  let captureWaitStartedAt: number | undefined;
50135
50370
  let captureWaitOutcome: "completed" | "failed" = "failed";
@@ -50150,23 +50385,18 @@ async function advanceWorkspaceGenerationForAuthority(
50150
50385
  error instanceof SandboxWorkspaceMutationFencedError &&
50151
50386
  error.code === "capture_in_progress"
50152
50387
  ) {
50153
- deadline = extendSandboxTransitionDeadline(
50154
- deadline,
50155
- hardDeadline,
50156
- error.captureRemainingMs,
50157
- now,
50158
- );
50388
+ waitBudget.observeCapture(error.captureRemainingMs, now);
50159
50389
  }
50160
50390
  if (
50161
50391
  !(error instanceof SandboxWorkspaceMutationFencedError) ||
50162
50392
  error.code !== "capture_in_progress" ||
50163
- now >= deadline
50393
+ now >= waitBudget.deadline
50164
50394
  ) {
50165
50395
  throw error;
50166
50396
  }
50167
50397
  captureWaitStartedAt ??= performance.now();
50168
50398
  await waitForSandboxTransition(
50169
- Math.min(delayMs, Math.max(1, deadline - now)),
50399
+ Math.min(delayMs, Math.max(1, waitBudget.deadline - now)),
50170
50400
  waitSignal,
50171
50401
  "Sandbox workspace mutation wait cancelled",
50172
50402
  );
@@ -52240,12 +52470,12 @@ export async function claimWorkspaceArchiveCapture(
52240
52470
  throw new Error("Workspace archive capture timeout is invalid");
52241
52471
  }
52242
52472
  const operationId = input.operationId ?? input.captureId;
52243
- // captureId owns one DB callback; providerRequestId owns the external
52244
- // operation. Modal's wire contract explicitly defines snapshotId as an
52245
- // idempotency key, so every Temporal retry of one logical capture must reuse
52246
- // it. Other providers still retain the stable lineage for late-result
52247
- // adoption, but may not replay the physical request before its deadline.
52248
- const providerRequestId = operationId;
52473
+ // A fresh admission-fenced capture gets a fresh provider request identity.
52474
+ // After a released failure, writers may re-arm this same lease epoch before
52475
+ // the drain workflow retries. Reusing its operationId could then retrieve an
52476
+ // older provider snapshot as the newer workspace generation. Replacements of
52477
+ // an uninterrupted claim retain this stored identity for late-result adoption.
52478
+ const providerRequestId = randomUUID();
52249
52479
  const captureAttempt = input.attempt ?? 1;
52250
52480
  const providerReplaySafe = input.providerReplaySafe === true;
52251
52481
  const takeoverSafe = input.takeoverSafe === true;
@@ -48,6 +48,7 @@ export type InstalledPluginPackageSummary = {
48
48
  category: string;
49
49
  tags: string[];
50
50
  sourceUrl: string | null;
51
+ logoUrl: string | null;
51
52
  manifestDigest: string;
52
53
  installationVersion: number;
53
54
  componentCount: number;
@@ -158,6 +159,7 @@ export async function listInstalledPluginPackages(
158
159
  return rows.map((row) => {
159
160
  const manifest = objectValue(row.manifest);
160
161
  const sourceUrl = stringValue(manifest.sourceUrl);
162
+ const logoUrl = stringValue(objectValue(manifest.discovery).logoUrl);
161
163
  const bom = pluginBom(manifest);
162
164
  if (row.status !== "active" && row.status !== "needs_attention") {
163
165
  throw new Error(`Unknown installed Plugin status: ${row.status}`);
@@ -173,6 +175,7 @@ export async function listInstalledPluginPackages(
173
175
  ? stringArray(manifest.tags)
174
176
  : stringArray(row.tags),
175
177
  sourceUrl: sourceUrl && safeHttpUrl(sourceUrl) ? sourceUrl : null,
178
+ logoUrl: logoUrl && safeHttpUrl(logoUrl) ? logoUrl : null,
176
179
  manifestDigest: row.manifestDigest,
177
180
  installationVersion: row.installationVersion,
178
181
  componentCount: bom.length,
@@ -0,0 +1,45 @@
1
+ import {
2
+ SANDBOX_LIFECYCLE_RETRY_HANDOFF_GRACE_MS,
3
+ SANDBOX_LIFECYCLE_TRANSITION_MAX_WAIT_MS,
4
+ } from "@opengeni/config";
5
+
6
+ /** Observational only: expiring a caller's wait never revokes a capture fence. */
7
+ export class SandboxTransitionWaitBudget {
8
+ private observedCapture = false;
9
+ private currentDeadline: number;
10
+ private readonly hardDeadline: number;
11
+
12
+ constructor(
13
+ private readonly waitMs: number,
14
+ startedAt: number,
15
+ ) {
16
+ this.hardDeadline = startedAt + SANDBOX_LIFECYCLE_TRANSITION_MAX_WAIT_MS;
17
+ this.currentDeadline = Math.min(this.hardDeadline, startedAt + waitMs);
18
+ }
19
+
20
+ get deadline(): number {
21
+ return this.currentDeadline;
22
+ }
23
+
24
+ observeCapture(remainingMs: number | null | undefined, now: number): void {
25
+ if (
26
+ this.waitMs === 0 ||
27
+ this.observedCapture ||
28
+ remainingMs === null ||
29
+ remainingMs === undefined ||
30
+ !Number.isSafeInteger(remainingMs) ||
31
+ remainingMs < 0 ||
32
+ remainingMs > SANDBOX_LIFECYCLE_TRANSITION_MAX_WAIT_MS
33
+ )
34
+ return;
35
+
36
+ // Honor the first observed child's frozen timeout across rolling settings
37
+ // changes. Neither an expired claim (remaining=0) nor a replacement capture
38
+ // can replenish the grace/budget on every poll and starve this caller.
39
+ this.observedCapture = true;
40
+ this.currentDeadline = Math.min(
41
+ this.hardDeadline,
42
+ Math.max(this.currentDeadline, now + remainingMs + SANDBOX_LIFECYCLE_RETRY_HANDOFF_GRACE_MS),
43
+ );
44
+ }
45
+ }
@@ -11,8 +11,12 @@ export const SESSION_MCP_PROGRESS_CHARS = 600;
11
11
  // so a prefix cannot split the last delivered surrogate pair. Eight base64
12
12
  // characters encode three complete UTF-16 units, so a cut encoded prefix is
13
13
  // independently decodable by the canonical codec (no partial bytes/units).
14
- export const SESSION_MCP_PROGRESS_STORAGE_CHARS =
15
- LOSSLESS_JSON_STRING_PREFIX.length + Math.ceil(((SESSION_MCP_PROGRESS_CHARS + 1) * 2) / 3) * 8;
14
+ export function sessionTextStoragePrefixChars(maxChars: number): number {
15
+ return LOSSLESS_JSON_STRING_PREFIX.length + Math.ceil(((maxChars + 1) * 2) / 3) * 8;
16
+ }
17
+ export const SESSION_MCP_PROGRESS_STORAGE_CHARS = sessionTextStoragePrefixChars(
18
+ SESSION_MCP_PROGRESS_CHARS,
19
+ );
16
20
 
17
21
  // Match the canonical decoder's nonempty, round-tripping base64 of an even
18
22
  // byte count. Each 8-character block is 6 bytes. The final block contributes
@@ -38,12 +42,13 @@ export function sessionMcpProgressScalarIsEncodedSql(
38
42
  )`;
39
43
  }
40
44
 
41
- /** Project only the selected scalar, never the rest of a goal.progress payload. */
42
- export function projectSessionMcpProgressText(
45
+ /** Project only the selected scalar, never the rest of its event payload. */
46
+ export function projectSessionTextPrefix(
43
47
  storedPrefix: string | null,
44
48
  storedChars: number | null,
45
49
  codecVersion: number | null,
46
50
  scalarIsEncoded: boolean,
51
+ maxChars: number,
47
52
  ): { text: string | null; originalChars: number | null; textTruncated?: true } {
48
53
  if (storedPrefix === null) return { text: null, originalChars: null };
49
54
  const decoded = scalarIsEncoded
@@ -60,8 +65,24 @@ export function projectSessionMcpProgressText(
60
65
  ? storedChars
61
66
  : chars.length;
62
67
  return {
63
- text: chars.slice(0, SESSION_MCP_PROGRESS_CHARS).join(""),
68
+ text: chars.slice(0, maxChars).join(""),
64
69
  originalChars,
65
70
  ...(encodedPrefixWasCut ? { textTruncated: true } : {}),
66
71
  };
67
72
  }
73
+
74
+ /** Existing MCP progress budget, using the shared logical-text prefix projection. */
75
+ export function projectSessionMcpProgressText(
76
+ storedPrefix: string | null,
77
+ storedChars: number | null,
78
+ codecVersion: number | null,
79
+ scalarIsEncoded: boolean,
80
+ ): { text: string | null; originalChars: number | null; textTruncated?: true } {
81
+ return projectSessionTextPrefix(
82
+ storedPrefix,
83
+ storedChars,
84
+ codecVersion,
85
+ scalarIsEncoded,
86
+ SESSION_MCP_PROGRESS_CHARS,
87
+ );
88
+ }