@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/dist/codex-selection-diagnostics.d.ts +12 -0
- package/dist/index.d.ts +23 -1
- package/dist/index.js +260 -49
- package/dist/index.js.map +1 -1
- package/dist/plugin-packages.d.ts +1 -0
- package/dist/sandbox-transition-wait.d.ts +10 -0
- package/dist/session-mcp-progress.d.ts +8 -1
- package/drizzle/0460_host_export_message_attribution.sql +177 -0
- package/package.json +6 -6
- package/src/codex-selection-diagnostics.ts +25 -0
- package/src/computer-sessions.ts +5 -1
- package/src/index.ts +286 -56
- package/src/plugin-packages.ts +3 -0
- package/src/sandbox-transition-wait.ts +45 -0
- package/src/session-mcp-progress.ts +26 -5
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({
|
|
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
|
-
|
|
35234
|
-
row.
|
|
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
|
-
|
|
35243
|
-
|
|
35244
|
-
|
|
35245
|
-
|
|
35246
|
-
|
|
35247
|
-
|
|
35248
|
-
|
|
35249
|
-
|
|
35250
|
-
|
|
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
|
|
43980
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
52244
|
-
//
|
|
52245
|
-
//
|
|
52246
|
-
//
|
|
52247
|
-
//
|
|
52248
|
-
const providerRequestId =
|
|
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;
|
package/src/plugin-packages.ts
CHANGED
|
@@ -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
|
|
15
|
-
LOSSLESS_JSON_STRING_PREFIX.length + Math.ceil(((
|
|
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
|
|
42
|
-
export function
|
|
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,
|
|
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
|
+
}
|