@opengeni/db 0.9.3 → 0.10.7
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/{chunk-4LG5NBTC.js → chunk-P6PKXY5W.js} +93 -1
- package/dist/chunk-P6PKXY5W.js.map +1 -0
- package/dist/index.d.ts +3 -2
- package/dist/index.js +1332 -178
- package/dist/index.js.map +1 -1
- package/dist/provision-roles.d.ts +406 -32
- package/dist/{schema-CdPGTHlD.d.ts → schema-CqkzrBRS.d.ts} +513 -2
- package/dist/schema.d.ts +1 -1
- package/dist/schema.js +3 -1
- package/drizzle/0053_codex_credential_leases.sql +2 -2
- package/drizzle/0057_durable_queue_control.sql +1 -1
- package/drizzle/0061_session_workflow_wake_outbox.sql +1 -1
- package/drizzle/0062_session_list_snapshot_reaper.sql +1 -1
- package/drizzle/0063_session_control_mega_foundation.sql +1 -1
- package/drizzle/0064_rotation_strategy_sharded_backfill.sql +1 -1
- package/drizzle/0065_codex_subscription_overview.sql +168 -0
- package/drizzle/0065_session_tool_policy.sql +38 -0
- package/drizzle/0067_session_event_payload_bounds.sql +2 -2
- package/drizzle/0068_workspace_control_event_bounds.sql +2 -2
- package/drizzle/0069_session_event_history_backfill.sql +2 -2
- package/drizzle/0074_session_activity_revisions.sql +2 -2
- package/drizzle/0106_session_attempt_mcp_approval_policies.sql +29 -0
- package/drizzle/0107_host_export_lineage_contract.sql +381 -0
- package/drizzle/0108_fence_invalidated_warming_epochs.sql +76 -0
- package/package.json +5 -4
- package/src/codex-token-resolver.ts +175 -14
- package/src/connection-token-resolver.ts +143 -120
- package/src/event-payload-sanitizer.ts +32 -2
- package/src/index.ts +1888 -205
- package/src/schema.ts +107 -1
- package/src/session-control.ts +2 -0
- package/src/session-queue-commands.ts +94 -21
- package/dist/chunk-4LG5NBTC.js.map +0 -1
package/src/index.ts
CHANGED
|
@@ -57,8 +57,10 @@ import type {
|
|
|
57
57
|
SessionGoalStatus,
|
|
58
58
|
SessionHumanInputRequest,
|
|
59
59
|
LineageNode,
|
|
60
|
+
SessionMcpApprovalPolicy,
|
|
60
61
|
SessionMcpServerMetadata,
|
|
61
62
|
SessionStatus,
|
|
63
|
+
SessionToolPolicy,
|
|
62
64
|
SessionTurn,
|
|
63
65
|
SessionQueueSnapshot,
|
|
64
66
|
SessionSystemUpdate,
|
|
@@ -101,6 +103,9 @@ import {
|
|
|
101
103
|
SESSION_EVENT_ENVELOPE_MAX_BYTES,
|
|
102
104
|
SESSION_EVENT_TYPE_MAX_BYTES,
|
|
103
105
|
resolveSessionEventTypeFilters,
|
|
106
|
+
capabilityCatalogItemIsTrustedForExposure,
|
|
107
|
+
metadataWithTurnExecutionPolicyV1,
|
|
108
|
+
readTurnExecutionPolicyV1,
|
|
104
109
|
reasoningEffortForMetadata,
|
|
105
110
|
resolveWorkspaceMemoryEnabled,
|
|
106
111
|
RigChange as RigChangeContract,
|
|
@@ -113,6 +118,7 @@ import {
|
|
|
113
118
|
OPENGENI_HOST_EXPORT_SCHEMA_REVISION,
|
|
114
119
|
HumanInputQuestion as HumanInputQuestionContract,
|
|
115
120
|
SubmitHumanInputResponseRequest,
|
|
121
|
+
TurnExecutionPolicyV1,
|
|
116
122
|
} from "@opengeni/contracts";
|
|
117
123
|
import { environmentsEncryptionKeyBytes, type Settings } from "@opengeni/config";
|
|
118
124
|
import { boundModelToolOutputItem, isCodexBilledModel } from "@opengeni/codex";
|
|
@@ -2589,6 +2595,11 @@ export async function countScheduledTasksForWorkspace(
|
|
|
2589
2595
|
export type AppendEventInput = {
|
|
2590
2596
|
type: SessionEventType;
|
|
2591
2597
|
payload?: unknown;
|
|
2598
|
+
/**
|
|
2599
|
+
* Server-created retained-output evidence for canonical audit truncation.
|
|
2600
|
+
* Producer payload fields are never trusted as evidence.
|
|
2601
|
+
*/
|
|
2602
|
+
retainedOutputEvidence?: unknown;
|
|
2592
2603
|
clientEventId?: string;
|
|
2593
2604
|
turnId?: string | null;
|
|
2594
2605
|
turnGeneration?: number | null;
|
|
@@ -2909,7 +2920,7 @@ export type CreateSessionMcpServerInput = {
|
|
|
2909
2920
|
allowedTools?: string[] | null;
|
|
2910
2921
|
timeoutMs?: number | null;
|
|
2911
2922
|
cacheToolsList?: boolean | null;
|
|
2912
|
-
requireApproval?:
|
|
2923
|
+
requireApproval?: SessionMcpApprovalPolicy | null;
|
|
2913
2924
|
connectionRef?: McpServerConnectionRef | null;
|
|
2914
2925
|
headersEncrypted?: Record<string, string>;
|
|
2915
2926
|
};
|
|
@@ -2924,11 +2935,16 @@ export type UpdateSessionMcpServerCredentialsResult = {
|
|
|
2924
2935
|
missingIds: string[];
|
|
2925
2936
|
};
|
|
2926
2937
|
|
|
2938
|
+
export type UpdateSessionMcpApprovalPolicyResult = {
|
|
2939
|
+
server: SessionMcpServerMetadata | null;
|
|
2940
|
+
changed: boolean;
|
|
2941
|
+
};
|
|
2942
|
+
|
|
2927
2943
|
export type SessionMcpServerForRun = SessionMcpServerMetadata & {
|
|
2928
2944
|
allowedTools?: string[];
|
|
2929
2945
|
timeoutMs?: number;
|
|
2930
2946
|
cacheToolsList?: boolean;
|
|
2931
|
-
requireApproval
|
|
2947
|
+
requireApproval: SessionMcpApprovalPolicy;
|
|
2932
2948
|
headers: Record<string, string>;
|
|
2933
2949
|
};
|
|
2934
2950
|
|
|
@@ -2943,6 +2959,7 @@ export type EnqueueSessionTurnInput = {
|
|
|
2943
2959
|
turnInstructions?: string | null;
|
|
2944
2960
|
resources: ResourceRef[];
|
|
2945
2961
|
tools: ToolRef[];
|
|
2962
|
+
toolsProvided?: boolean;
|
|
2946
2963
|
model: string;
|
|
2947
2964
|
reasoningEffort: ReasoningEffort;
|
|
2948
2965
|
sandboxBackend: SandboxBackend;
|
|
@@ -3055,6 +3072,49 @@ export async function requireFile(
|
|
|
3055
3072
|
return file;
|
|
3056
3073
|
}
|
|
3057
3074
|
|
|
3075
|
+
export type RetainedFileArtifact = {
|
|
3076
|
+
file: FileAsset;
|
|
3077
|
+
uploadStatus: FileUploadStatus | null;
|
|
3078
|
+
uploadExpiresAt: Date | null;
|
|
3079
|
+
};
|
|
3080
|
+
|
|
3081
|
+
/**
|
|
3082
|
+
* RLS-scoped file lookup with just enough upload lifecycle truth to distinguish
|
|
3083
|
+
* pending, failed, and expired artifact references. The bytes and provider
|
|
3084
|
+
* location remain in the existing FileAsset/object-storage seam.
|
|
3085
|
+
*/
|
|
3086
|
+
export async function getRetainedFileArtifact(
|
|
3087
|
+
db: Database,
|
|
3088
|
+
workspaceId: string,
|
|
3089
|
+
fileId: string,
|
|
3090
|
+
): Promise<RetainedFileArtifact | null> {
|
|
3091
|
+
return await withWorkspaceRls(db, workspaceId, async (scopedDb) => {
|
|
3092
|
+
const [row] = await scopedDb
|
|
3093
|
+
.select({
|
|
3094
|
+
file: schema.files,
|
|
3095
|
+
uploadStatus: schema.fileUploads.status,
|
|
3096
|
+
uploadExpiresAt: schema.fileUploads.expiresAt,
|
|
3097
|
+
})
|
|
3098
|
+
.from(schema.files)
|
|
3099
|
+
.leftJoin(
|
|
3100
|
+
schema.fileUploads,
|
|
3101
|
+
and(
|
|
3102
|
+
eq(schema.fileUploads.workspaceId, schema.files.workspaceId),
|
|
3103
|
+
eq(schema.fileUploads.fileId, schema.files.id),
|
|
3104
|
+
),
|
|
3105
|
+
)
|
|
3106
|
+
.where(and(eq(schema.files.workspaceId, workspaceId), eq(schema.files.id, fileId)))
|
|
3107
|
+
.orderBy(desc(schema.fileUploads.createdAt))
|
|
3108
|
+
.limit(1);
|
|
3109
|
+
if (!row) return null;
|
|
3110
|
+
return {
|
|
3111
|
+
file: mapFile(row.file),
|
|
3112
|
+
uploadStatus: row.uploadStatus as FileUploadStatus | null,
|
|
3113
|
+
uploadExpiresAt: row.uploadExpiresAt,
|
|
3114
|
+
};
|
|
3115
|
+
});
|
|
3116
|
+
}
|
|
3117
|
+
|
|
3058
3118
|
export async function getFileUpload(
|
|
3059
3119
|
db: Database,
|
|
3060
3120
|
workspaceId: string,
|
|
@@ -3895,7 +3955,17 @@ export async function listCapabilityCatalogItems(
|
|
|
3895
3955
|
),
|
|
3896
3956
|
)
|
|
3897
3957
|
.orderBy(asc(schema.capabilityCatalogItems.kind), asc(schema.capabilityCatalogItems.name));
|
|
3898
|
-
|
|
3958
|
+
const installations = await scopedDb
|
|
3959
|
+
.select()
|
|
3960
|
+
.from(schema.capabilityInstallations)
|
|
3961
|
+
.where(eq(schema.capabilityInstallations.workspaceId, workspaceId));
|
|
3962
|
+
const installationByCapabilityId = new Map(
|
|
3963
|
+
installations.map((installation) => [installation.capabilityId, installation]),
|
|
3964
|
+
);
|
|
3965
|
+
return rows.flatMap((row) => {
|
|
3966
|
+
const exposure = catalogExposureState(row, installationByCapabilityId.get(row.id) ?? null);
|
|
3967
|
+
return exposure === "blocked" ? [] : [mapCapabilityCatalogItem(row, exposure)];
|
|
3968
|
+
});
|
|
3899
3969
|
});
|
|
3900
3970
|
}
|
|
3901
3971
|
|
|
@@ -3919,7 +3989,21 @@ export async function getCapabilityCatalogItem(
|
|
|
3919
3989
|
)
|
|
3920
3990
|
.orderBy(asc(sql`(${schema.capabilityCatalogItems.workspaceId} is null)`))
|
|
3921
3991
|
.limit(1);
|
|
3922
|
-
|
|
3992
|
+
if (!row) {
|
|
3993
|
+
return null;
|
|
3994
|
+
}
|
|
3995
|
+
const [installation] = await scopedDb
|
|
3996
|
+
.select()
|
|
3997
|
+
.from(schema.capabilityInstallations)
|
|
3998
|
+
.where(
|
|
3999
|
+
and(
|
|
4000
|
+
eq(schema.capabilityInstallations.workspaceId, workspaceId),
|
|
4001
|
+
eq(schema.capabilityInstallations.capabilityId, capabilityId),
|
|
4002
|
+
),
|
|
4003
|
+
)
|
|
4004
|
+
.limit(1);
|
|
4005
|
+
const exposure = catalogExposureState(row, installation ?? null);
|
|
4006
|
+
return exposure === "blocked" ? null : mapCapabilityCatalogItem(row, exposure);
|
|
3923
4007
|
});
|
|
3924
4008
|
}
|
|
3925
4009
|
|
|
@@ -4098,7 +4182,11 @@ export async function listEnabledMcpCapabilityServers(
|
|
|
4098
4182
|
}
|
|
4099
4183
|
|
|
4100
4184
|
return [...preferredByInstallation.values()].flatMap(({ item, installation }) => {
|
|
4101
|
-
if (
|
|
4185
|
+
if (
|
|
4186
|
+
catalogExposureState(item, installation) === "blocked" ||
|
|
4187
|
+
!item.endpointUrl ||
|
|
4188
|
+
!mcpConnectivityOk(installation.metadata)
|
|
4189
|
+
) {
|
|
4102
4190
|
return [];
|
|
4103
4191
|
}
|
|
4104
4192
|
const headersEncrypted = encryptedHeadersConfig(installation.config.headersEncrypted);
|
|
@@ -8083,6 +8171,10 @@ export type CodexCredentialForRun = {
|
|
|
8083
8171
|
* accessor — auto-activates a brand-new first account and ensures the
|
|
8084
8172
|
* rotation-settings row exists.
|
|
8085
8173
|
*/
|
|
8174
|
+
export type UpsertCodexSubscriptionCredentialResult =
|
|
8175
|
+
| { kind: "upserted"; id: string; isNew: boolean }
|
|
8176
|
+
| { kind: "unresolved_redemption"; id: string; isNew: false };
|
|
8177
|
+
|
|
8086
8178
|
export async function upsertCodexSubscriptionCredential(
|
|
8087
8179
|
db: Database,
|
|
8088
8180
|
input: {
|
|
@@ -8097,12 +8189,54 @@ export async function upsertCodexSubscriptionCredential(
|
|
|
8097
8189
|
lastRefreshAt: Date | null;
|
|
8098
8190
|
accountEmail?: string | null;
|
|
8099
8191
|
label?: string | null;
|
|
8192
|
+
/** Direct managed-cookie human who most recently connected this row. */
|
|
8193
|
+
connectedBySubjectId?: string | null;
|
|
8100
8194
|
},
|
|
8101
|
-
): Promise<
|
|
8195
|
+
): Promise<UpsertCodexSubscriptionCredentialResult> {
|
|
8102
8196
|
return await withRlsContext(
|
|
8103
8197
|
db,
|
|
8104
8198
|
{ accountId: input.accountId, workspaceId: input.workspaceId },
|
|
8105
8199
|
async (scopedDb) => {
|
|
8200
|
+
// Serialize both the initial partial-index insert and ownership-changing
|
|
8201
|
+
// reconnects for this exact provider account. The row lock is shared with
|
|
8202
|
+
// the final redemption-send fence: either reconnect wins before any send,
|
|
8203
|
+
// or it observes durable provider_started truth and cannot replace its
|
|
8204
|
+
// owning human while the upstream outcome is unresolved.
|
|
8205
|
+
await scopedDb.execute(
|
|
8206
|
+
sql`select pg_advisory_xact_lock(hashtextextended(${`codex-credential-upsert:${input.workspaceId}:${input.chatgptAccountId ?? "null"}`}, 0))`,
|
|
8207
|
+
);
|
|
8208
|
+
const [existing] = input.chatgptAccountId
|
|
8209
|
+
? await scopedDb
|
|
8210
|
+
.select({
|
|
8211
|
+
id: schema.codexSubscriptionCredentials.id,
|
|
8212
|
+
connectedBySubjectId: schema.codexSubscriptionCredentials.connectedBySubjectId,
|
|
8213
|
+
})
|
|
8214
|
+
.from(schema.codexSubscriptionCredentials)
|
|
8215
|
+
.where(
|
|
8216
|
+
and(
|
|
8217
|
+
eq(schema.codexSubscriptionCredentials.workspaceId, input.workspaceId),
|
|
8218
|
+
eq(schema.codexSubscriptionCredentials.chatgptAccountId, input.chatgptAccountId),
|
|
8219
|
+
),
|
|
8220
|
+
)
|
|
8221
|
+
.for("update")
|
|
8222
|
+
.limit(1)
|
|
8223
|
+
: [];
|
|
8224
|
+
if (existing && existing.connectedBySubjectId !== (input.connectedBySubjectId ?? null)) {
|
|
8225
|
+
const [unresolved] = await scopedDb
|
|
8226
|
+
.select({ id: schema.codexResetRedemptionAttempts.id })
|
|
8227
|
+
.from(schema.codexResetRedemptionAttempts)
|
|
8228
|
+
.where(
|
|
8229
|
+
and(
|
|
8230
|
+
eq(schema.codexResetRedemptionAttempts.workspaceId, input.workspaceId),
|
|
8231
|
+
eq(schema.codexResetRedemptionAttempts.credentialId, existing.id),
|
|
8232
|
+
eq(schema.codexResetRedemptionAttempts.status, "provider_started"),
|
|
8233
|
+
),
|
|
8234
|
+
)
|
|
8235
|
+
.limit(1);
|
|
8236
|
+
if (unresolved) {
|
|
8237
|
+
return { kind: "unresolved_redemption", id: existing.id, isNew: false };
|
|
8238
|
+
}
|
|
8239
|
+
}
|
|
8106
8240
|
const now = new Date();
|
|
8107
8241
|
const [row] = await scopedDb
|
|
8108
8242
|
.insert(schema.codexSubscriptionCredentials)
|
|
@@ -8118,6 +8252,7 @@ export async function upsertCodexSubscriptionCredential(
|
|
|
8118
8252
|
lastRefreshAt: input.lastRefreshAt,
|
|
8119
8253
|
accountEmail: input.accountEmail ?? null,
|
|
8120
8254
|
label: input.label ?? null,
|
|
8255
|
+
connectedBySubjectId: input.connectedBySubjectId ?? null,
|
|
8121
8256
|
status: "active",
|
|
8122
8257
|
lastError: null,
|
|
8123
8258
|
})
|
|
@@ -8146,6 +8281,11 @@ export async function upsertCodexSubscriptionCredential(
|
|
|
8146
8281
|
// it when still null) so a re-connect never clobbers a rename.
|
|
8147
8282
|
accountEmail: input.accountEmail ?? null,
|
|
8148
8283
|
label: sql`coalesce(${schema.codexSubscriptionCredentials.label}, ${input.label ?? null})`,
|
|
8284
|
+
// Ownership follows the most recent connection exactly. A
|
|
8285
|
+
// configured/delegated/API-key reconnect is intentionally
|
|
8286
|
+
// nonhuman and clears the prior human owner, making the row
|
|
8287
|
+
// view-only until a direct managed-cookie human reconnects it.
|
|
8288
|
+
connectedBySubjectId: input.connectedBySubjectId ?? null,
|
|
8149
8289
|
status: "active",
|
|
8150
8290
|
lastError: null,
|
|
8151
8291
|
version: sql`${schema.codexSubscriptionCredentials.version} + 1`,
|
|
@@ -8166,7 +8306,7 @@ export async function upsertCodexSubscriptionCredential(
|
|
|
8166
8306
|
// keeps the original (older) value, so the two diverge. This distinguishes
|
|
8167
8307
|
// insert from update without a second read.
|
|
8168
8308
|
const isNew = row.createdAt.getTime() === row.updatedAt.getTime();
|
|
8169
|
-
return { id: row.id, isNew };
|
|
8309
|
+
return { kind: "upserted", id: row.id, isNew };
|
|
8170
8310
|
},
|
|
8171
8311
|
);
|
|
8172
8312
|
}
|
|
@@ -8637,6 +8777,12 @@ export type CodexAccountStatus = {
|
|
|
8637
8777
|
status: string; // active | needs_relogin | error
|
|
8638
8778
|
/** New automatic allocations only; health/refresh and existing turns remain independent. */
|
|
8639
8779
|
allocatorEnabled: boolean;
|
|
8780
|
+
allocatorVersion: number;
|
|
8781
|
+
allocatorUpdatedBySubjectId: string | null;
|
|
8782
|
+
allocatorUpdatedAt: Date | null;
|
|
8783
|
+
resetCreditAvailableCount: number | null;
|
|
8784
|
+
resetCreditsCheckedAt: Date | null;
|
|
8785
|
+
connectedBySubjectId: string | null;
|
|
8640
8786
|
isActive: boolean;
|
|
8641
8787
|
expiresAt: Date | null;
|
|
8642
8788
|
lastRefreshAt: Date | null;
|
|
@@ -8664,7 +8810,15 @@ export type CodexAccountStatus = {
|
|
|
8664
8810
|
* the selection cursor gives deterministic fairness after those holders drain.
|
|
8665
8811
|
* None of these fields contains credential material.
|
|
8666
8812
|
*/
|
|
8667
|
-
export type CodexLeaseAccountStatus =
|
|
8813
|
+
export type CodexLeaseAccountStatus = Omit<
|
|
8814
|
+
CodexAccountStatus,
|
|
8815
|
+
| "allocatorVersion"
|
|
8816
|
+
| "allocatorUpdatedBySubjectId"
|
|
8817
|
+
| "allocatorUpdatedAt"
|
|
8818
|
+
| "resetCreditAvailableCount"
|
|
8819
|
+
| "resetCreditsCheckedAt"
|
|
8820
|
+
| "connectedBySubjectId"
|
|
8821
|
+
> & {
|
|
8668
8822
|
activeLeaseCount: number;
|
|
8669
8823
|
selectionCount: number;
|
|
8670
8824
|
lastSelectedAt: Date | null;
|
|
@@ -10467,6 +10621,13 @@ export async function listCodexAccountStatuses(
|
|
|
10467
10621
|
planType: schema.codexSubscriptionCredentials.planType,
|
|
10468
10622
|
status: schema.codexSubscriptionCredentials.status,
|
|
10469
10623
|
allocatorEnabled: schema.codexSubscriptionCredentials.allocatorEnabled,
|
|
10624
|
+
allocatorVersion: schema.codexSubscriptionCredentials.allocatorVersion,
|
|
10625
|
+
allocatorUpdatedBySubjectId:
|
|
10626
|
+
schema.codexSubscriptionCredentials.allocatorUpdatedBySubjectId,
|
|
10627
|
+
allocatorUpdatedAt: schema.codexSubscriptionCredentials.allocatorUpdatedAt,
|
|
10628
|
+
resetCreditAvailableCount: schema.codexSubscriptionCredentials.resetCreditAvailableCount,
|
|
10629
|
+
resetCreditsCheckedAt: schema.codexSubscriptionCredentials.resetCreditsCheckedAt,
|
|
10630
|
+
connectedBySubjectId: schema.codexSubscriptionCredentials.connectedBySubjectId,
|
|
10470
10631
|
expiresAt: schema.codexSubscriptionCredentials.expiresAt,
|
|
10471
10632
|
lastRefreshAt: schema.codexSubscriptionCredentials.lastRefreshAt,
|
|
10472
10633
|
lastError: schema.codexSubscriptionCredentials.lastError,
|
|
@@ -10491,6 +10652,8 @@ export async function listCodexAccountStatuses(
|
|
|
10491
10652
|
...row,
|
|
10492
10653
|
expiresAt: codexMetadataDate(row.expiresAt),
|
|
10493
10654
|
lastRefreshAt: codexMetadataDate(row.lastRefreshAt),
|
|
10655
|
+
allocatorUpdatedAt: codexMetadataDate(row.allocatorUpdatedAt),
|
|
10656
|
+
resetCreditsCheckedAt: codexMetadataDate(row.resetCreditsCheckedAt),
|
|
10494
10657
|
primaryResetAt: codexMetadataDate(row.primaryResetAt),
|
|
10495
10658
|
secondaryResetAt: codexMetadataDate(row.secondaryResetAt),
|
|
10496
10659
|
usageCheckedAt: codexMetadataDate(row.usageCheckedAt),
|
|
@@ -10501,165 +10664,1051 @@ export async function listCodexAccountStatuses(
|
|
|
10501
10664
|
});
|
|
10502
10665
|
}
|
|
10503
10666
|
|
|
10504
|
-
|
|
10505
|
-
|
|
10506
|
-
|
|
10507
|
-
|
|
10508
|
-
|
|
10509
|
-
|
|
10510
|
-
|
|
10511
|
-
}
|
|
10667
|
+
export type CodexAllocatorUpdateResult =
|
|
10668
|
+
| {
|
|
10669
|
+
kind: "updated" | "unchanged";
|
|
10670
|
+
allocatorEnabled: boolean;
|
|
10671
|
+
allocatorVersion: number;
|
|
10672
|
+
allocatorUpdatedBySubjectId: string | null;
|
|
10673
|
+
allocatorUpdatedAt: Date | null;
|
|
10674
|
+
}
|
|
10675
|
+
| {
|
|
10676
|
+
kind: "conflict";
|
|
10677
|
+
allocatorEnabled: boolean;
|
|
10678
|
+
allocatorVersion: number;
|
|
10679
|
+
allocatorUpdatedBySubjectId: string | null;
|
|
10680
|
+
allocatorUpdatedAt: Date | null;
|
|
10681
|
+
}
|
|
10682
|
+
| { kind: "not_found" };
|
|
10512
10683
|
|
|
10513
10684
|
/**
|
|
10514
|
-
*
|
|
10515
|
-
*
|
|
10516
|
-
*
|
|
10517
|
-
*
|
|
10518
|
-
*
|
|
10519
|
-
*
|
|
10685
|
+
* Toggle only NEW allocator eligibility under credential-row OCC.
|
|
10686
|
+
*
|
|
10687
|
+
* Same-state writes are idempotent even when `expectedVersion` is stale. A real
|
|
10688
|
+
* state transition requires the exact current version, increments only the
|
|
10689
|
+
* allocator version, and writes one audit row in the same transaction. It never
|
|
10690
|
+
* touches credential `version`, health, encrypted material, cooldown, or quota.
|
|
10520
10691
|
*/
|
|
10521
|
-
export async function
|
|
10522
|
-
db: Database,
|
|
10523
|
-
workspaceId: string,
|
|
10524
|
-
credentialId: string,
|
|
10525
|
-
snapshot: CodexAccountUsageSnapshot,
|
|
10526
|
-
): Promise<boolean> {
|
|
10527
|
-
return (await recordCodexAccountUsageWithWakeTargets(db, workspaceId, credentialId, snapshot))
|
|
10528
|
-
.result;
|
|
10529
|
-
}
|
|
10530
|
-
|
|
10531
|
-
/** Usage-cache mutation plus its committed durable capacity-wake outbox. */
|
|
10532
|
-
export async function recordCodexAccountUsageWithWakeTargets(
|
|
10692
|
+
export async function updateCodexAllocatorEligibility(
|
|
10533
10693
|
db: Database,
|
|
10534
|
-
|
|
10535
|
-
|
|
10536
|
-
|
|
10537
|
-
|
|
10538
|
-
|
|
10694
|
+
input: {
|
|
10695
|
+
accountId: string;
|
|
10696
|
+
workspaceId: string;
|
|
10697
|
+
credentialId: string;
|
|
10698
|
+
subjectId: string;
|
|
10699
|
+
enabled: boolean;
|
|
10700
|
+
expectedVersion: number;
|
|
10701
|
+
},
|
|
10702
|
+
): Promise<CodexCapacityMutationResult<CodexAllocatorUpdateResult>> {
|
|
10703
|
+
return await withCodexCapacityMutation<CodexAllocatorUpdateResult>(
|
|
10539
10704
|
db,
|
|
10540
|
-
{ workspaceId, reason: "
|
|
10705
|
+
{ workspaceId: input.workspaceId, reason: "codex_allocator_eligibility_changed" },
|
|
10541
10706
|
async (tx) => {
|
|
10542
|
-
const
|
|
10707
|
+
const [row] = await tx
|
|
10708
|
+
.select({
|
|
10709
|
+
allocatorEnabled: schema.codexSubscriptionCredentials.allocatorEnabled,
|
|
10710
|
+
allocatorVersion: schema.codexSubscriptionCredentials.allocatorVersion,
|
|
10711
|
+
allocatorUpdatedBySubjectId:
|
|
10712
|
+
schema.codexSubscriptionCredentials.allocatorUpdatedBySubjectId,
|
|
10713
|
+
allocatorUpdatedAt: schema.codexSubscriptionCredentials.allocatorUpdatedAt,
|
|
10714
|
+
})
|
|
10715
|
+
.from(schema.codexSubscriptionCredentials)
|
|
10716
|
+
.where(
|
|
10717
|
+
and(
|
|
10718
|
+
eq(schema.codexSubscriptionCredentials.accountId, input.accountId),
|
|
10719
|
+
eq(schema.codexSubscriptionCredentials.workspaceId, input.workspaceId),
|
|
10720
|
+
eq(schema.codexSubscriptionCredentials.id, input.credentialId),
|
|
10721
|
+
),
|
|
10722
|
+
)
|
|
10723
|
+
.for("update")
|
|
10724
|
+
.limit(1);
|
|
10725
|
+
if (!row) return { result: { kind: "not_found" } as const, changed: false };
|
|
10726
|
+
const current = {
|
|
10727
|
+
allocatorEnabled: row.allocatorEnabled,
|
|
10728
|
+
allocatorVersion: row.allocatorVersion,
|
|
10729
|
+
allocatorUpdatedBySubjectId: row.allocatorUpdatedBySubjectId,
|
|
10730
|
+
allocatorUpdatedAt: codexMetadataDate(row.allocatorUpdatedAt),
|
|
10731
|
+
};
|
|
10732
|
+
if (row.allocatorEnabled === input.enabled) {
|
|
10733
|
+
return { result: { kind: "unchanged", ...current } as const, changed: false };
|
|
10734
|
+
}
|
|
10735
|
+
if (row.allocatorVersion !== input.expectedVersion) {
|
|
10736
|
+
return { result: { kind: "conflict", ...current } as const, changed: false };
|
|
10737
|
+
}
|
|
10738
|
+
|
|
10739
|
+
const changedAt = new Date();
|
|
10740
|
+
const [updated] = await tx
|
|
10543
10741
|
.update(schema.codexSubscriptionCredentials)
|
|
10544
10742
|
.set({
|
|
10545
|
-
|
|
10546
|
-
|
|
10547
|
-
|
|
10548
|
-
|
|
10549
|
-
|
|
10550
|
-
// NB: no `version` bump and no `updatedAt` touch — usage is non-credential
|
|
10551
|
-
// metadata and must NOT race the (id, version) refresh CAS in
|
|
10552
|
-
// recordCodexTokenRefresh / setCodexCredentialStatus.
|
|
10743
|
+
allocatorEnabled: input.enabled,
|
|
10744
|
+
allocatorVersion: sql`${schema.codexSubscriptionCredentials.allocatorVersion} + 1`,
|
|
10745
|
+
allocatorUpdatedBySubjectId: input.subjectId,
|
|
10746
|
+
allocatorUpdatedAt: changedAt,
|
|
10747
|
+
// Deliberately no credential version/updatedAt write.
|
|
10553
10748
|
})
|
|
10554
10749
|
.where(
|
|
10555
10750
|
and(
|
|
10556
|
-
eq(schema.codexSubscriptionCredentials.
|
|
10557
|
-
eq(schema.codexSubscriptionCredentials.workspaceId, workspaceId),
|
|
10751
|
+
eq(schema.codexSubscriptionCredentials.accountId, input.accountId),
|
|
10752
|
+
eq(schema.codexSubscriptionCredentials.workspaceId, input.workspaceId),
|
|
10753
|
+
eq(schema.codexSubscriptionCredentials.id, input.credentialId),
|
|
10754
|
+
eq(schema.codexSubscriptionCredentials.allocatorVersion, input.expectedVersion),
|
|
10558
10755
|
),
|
|
10559
10756
|
)
|
|
10560
|
-
.returning({
|
|
10561
|
-
|
|
10562
|
-
|
|
10757
|
+
.returning({
|
|
10758
|
+
allocatorEnabled: schema.codexSubscriptionCredentials.allocatorEnabled,
|
|
10759
|
+
allocatorVersion: schema.codexSubscriptionCredentials.allocatorVersion,
|
|
10760
|
+
allocatorUpdatedBySubjectId:
|
|
10761
|
+
schema.codexSubscriptionCredentials.allocatorUpdatedBySubjectId,
|
|
10762
|
+
allocatorUpdatedAt: schema.codexSubscriptionCredentials.allocatorUpdatedAt,
|
|
10763
|
+
});
|
|
10764
|
+
if (!updated) {
|
|
10765
|
+
throw new Error("Codex allocator row changed while locked");
|
|
10766
|
+
}
|
|
10767
|
+
await tx.insert(schema.auditEvents).values({
|
|
10768
|
+
accountId: input.accountId,
|
|
10769
|
+
workspaceId: input.workspaceId,
|
|
10770
|
+
subjectId: input.subjectId,
|
|
10771
|
+
action: "codex.allocator.updated",
|
|
10772
|
+
targetType: "codex_subscription_credential",
|
|
10773
|
+
targetId: input.credentialId,
|
|
10774
|
+
metadata: {
|
|
10775
|
+
allocatorEnabled: updated.allocatorEnabled,
|
|
10776
|
+
allocatorVersion: updated.allocatorVersion,
|
|
10777
|
+
},
|
|
10778
|
+
});
|
|
10779
|
+
return {
|
|
10780
|
+
result: {
|
|
10781
|
+
kind: "updated",
|
|
10782
|
+
allocatorEnabled: updated.allocatorEnabled,
|
|
10783
|
+
allocatorVersion: updated.allocatorVersion,
|
|
10784
|
+
allocatorUpdatedBySubjectId: updated.allocatorUpdatedBySubjectId,
|
|
10785
|
+
allocatorUpdatedAt: codexMetadataDate(updated.allocatorUpdatedAt),
|
|
10786
|
+
} as const,
|
|
10787
|
+
changed: true,
|
|
10788
|
+
};
|
|
10563
10789
|
},
|
|
10564
10790
|
);
|
|
10565
10791
|
}
|
|
10566
10792
|
|
|
10567
|
-
export
|
|
10568
|
-
|
|
10569
|
-
|
|
10570
|
-
|
|
10571
|
-
|
|
10572
|
-
|
|
10573
|
-
|
|
10574
|
-
|
|
10793
|
+
export const CODEX_RESET_REDEMPTION_OUTCOMES = [
|
|
10794
|
+
"reset",
|
|
10795
|
+
"nothingToReset",
|
|
10796
|
+
"noCredit",
|
|
10797
|
+
"alreadyRedeemed",
|
|
10798
|
+
] as const;
|
|
10799
|
+
export type CodexResetRedemptionOutcome = (typeof CODEX_RESET_REDEMPTION_OUTCOMES)[number];
|
|
10800
|
+
export type CodexResetRedemptionStatus = "processing" | "provider_started" | "completed";
|
|
10575
10801
|
|
|
10576
|
-
|
|
10577
|
-
|
|
10578
|
-
|
|
10579
|
-
|
|
10580
|
-
|
|
10581
|
-
|
|
10582
|
-
|
|
10583
|
-
|
|
10584
|
-
|
|
10585
|
-
|
|
10586
|
-
|
|
10802
|
+
export type CodexResetRedemptionAttempt = {
|
|
10803
|
+
id: string;
|
|
10804
|
+
accountId: string;
|
|
10805
|
+
workspaceId: string;
|
|
10806
|
+
credentialId: string;
|
|
10807
|
+
subjectId: string;
|
|
10808
|
+
browserSessionHash: string;
|
|
10809
|
+
creditId: string;
|
|
10810
|
+
upstreamIdempotencyKey: string;
|
|
10811
|
+
status: CodexResetRedemptionStatus;
|
|
10812
|
+
outcome: CodexResetRedemptionOutcome | null;
|
|
10813
|
+
claimHolderId: string | null;
|
|
10814
|
+
claimExpiresAt: Date | null;
|
|
10815
|
+
confirmationExpiresAt: Date;
|
|
10816
|
+
providerStartedAt: Date | null;
|
|
10817
|
+
completedAt: Date | null;
|
|
10818
|
+
lastFailureKind: string | null;
|
|
10819
|
+
retryCount: number;
|
|
10820
|
+
createdAt: Date;
|
|
10821
|
+
updatedAt: Date;
|
|
10587
10822
|
};
|
|
10588
10823
|
|
|
10589
|
-
|
|
10590
|
-
|
|
10824
|
+
function mapCodexResetRedemptionAttempt(
|
|
10825
|
+
row: typeof schema.codexResetRedemptionAttempts.$inferSelect,
|
|
10826
|
+
): CodexResetRedemptionAttempt {
|
|
10827
|
+
return {
|
|
10828
|
+
id: row.id,
|
|
10829
|
+
accountId: row.accountId,
|
|
10830
|
+
workspaceId: row.workspaceId,
|
|
10831
|
+
credentialId: row.credentialId,
|
|
10832
|
+
subjectId: row.subjectId,
|
|
10833
|
+
browserSessionHash: row.browserSessionHash,
|
|
10834
|
+
creditId: row.creditId,
|
|
10835
|
+
upstreamIdempotencyKey: row.upstreamIdempotencyKey,
|
|
10836
|
+
status: row.status as CodexResetRedemptionStatus,
|
|
10837
|
+
outcome: row.outcome as CodexResetRedemptionOutcome | null,
|
|
10838
|
+
claimHolderId: row.claimHolderId,
|
|
10839
|
+
claimExpiresAt: codexMetadataDate(row.claimExpiresAt),
|
|
10840
|
+
confirmationExpiresAt: codexMetadataDate(row.confirmationExpiresAt)!,
|
|
10841
|
+
providerStartedAt: codexMetadataDate(row.providerStartedAt),
|
|
10842
|
+
completedAt: codexMetadataDate(row.completedAt),
|
|
10843
|
+
lastFailureKind: row.lastFailureKind,
|
|
10844
|
+
retryCount: row.retryCount,
|
|
10845
|
+
createdAt: codexMetadataDate(row.createdAt)!,
|
|
10846
|
+
updatedAt: codexMetadataDate(row.updatedAt)!,
|
|
10847
|
+
};
|
|
10848
|
+
}
|
|
10849
|
+
|
|
10850
|
+
/** Metadata-only attempt read used to resume a browser-session-bound ambiguity. */
|
|
10851
|
+
export async function getCodexResetRedemptionAttempt(
|
|
10591
10852
|
db: Database,
|
|
10592
10853
|
workspaceId: string,
|
|
10593
|
-
|
|
10854
|
+
attemptId: string,
|
|
10855
|
+
): Promise<CodexResetRedemptionAttempt | null> {
|
|
10594
10856
|
return await withWorkspaceRls(db, workspaceId, async (scopedDb) => {
|
|
10595
10857
|
const [row] = await scopedDb
|
|
10596
|
-
.select(
|
|
10597
|
-
|
|
10598
|
-
|
|
10599
|
-
|
|
10600
|
-
|
|
10601
|
-
|
|
10858
|
+
.select()
|
|
10859
|
+
.from(schema.codexResetRedemptionAttempts)
|
|
10860
|
+
.where(
|
|
10861
|
+
and(
|
|
10862
|
+
eq(schema.codexResetRedemptionAttempts.workspaceId, workspaceId),
|
|
10863
|
+
eq(schema.codexResetRedemptionAttempts.id, attemptId),
|
|
10864
|
+
),
|
|
10865
|
+
)
|
|
10602
10866
|
.limit(1);
|
|
10603
|
-
return row
|
|
10867
|
+
return row ? mapCodexResetRedemptionAttempt(row) : null;
|
|
10604
10868
|
});
|
|
10605
10869
|
}
|
|
10606
10870
|
|
|
10871
|
+
export type CodexResetRedemptionRecovery = {
|
|
10872
|
+
attemptId: string;
|
|
10873
|
+
credentialId: string;
|
|
10874
|
+
creditId: string;
|
|
10875
|
+
status: "provider_started" | "completed";
|
|
10876
|
+
outcome: CodexResetRedemptionOutcome | null;
|
|
10877
|
+
providerStartedAt: Date | null;
|
|
10878
|
+
completedAt: Date | null;
|
|
10879
|
+
createdAt: Date;
|
|
10880
|
+
updatedAt: Date;
|
|
10881
|
+
};
|
|
10882
|
+
|
|
10607
10883
|
/**
|
|
10608
|
-
*
|
|
10609
|
-
*
|
|
10610
|
-
* "unrestricted" row (delete is not needed for correctness — it reads the same
|
|
10611
|
-
* as no row).
|
|
10884
|
+
* Cookie-owner discovery source for durable ambiguous/completed attempts.
|
|
10885
|
+
* Browser storage is never required to recover these non-secret identifiers.
|
|
10612
10886
|
*/
|
|
10613
|
-
export async function
|
|
10887
|
+
export async function listCodexResetRedemptionRecoveries(
|
|
10614
10888
|
db: Database,
|
|
10615
|
-
input: {
|
|
10616
|
-
|
|
10617
|
-
workspaceId: string;
|
|
10618
|
-
allowedProviders: string[] | null;
|
|
10619
|
-
allowedModels: string[] | null;
|
|
10620
|
-
},
|
|
10621
|
-
): Promise<WorkspaceModelPolicy> {
|
|
10889
|
+
input: { accountId: string; workspaceId: string; subjectId: string },
|
|
10890
|
+
): Promise<CodexResetRedemptionRecovery[]> {
|
|
10622
10891
|
return await withRlsContext(
|
|
10623
10892
|
db,
|
|
10624
10893
|
{ accountId: input.accountId, workspaceId: input.workspaceId },
|
|
10625
10894
|
async (scopedDb) => {
|
|
10626
|
-
const
|
|
10627
|
-
.
|
|
10628
|
-
|
|
10629
|
-
|
|
10630
|
-
|
|
10631
|
-
|
|
10632
|
-
|
|
10633
|
-
|
|
10634
|
-
|
|
10635
|
-
|
|
10636
|
-
|
|
10637
|
-
allowedProviders: input.allowedProviders,
|
|
10638
|
-
allowedModels: input.allowedModels,
|
|
10639
|
-
updatedAt: new Date(),
|
|
10640
|
-
},
|
|
10895
|
+
const rows = await scopedDb
|
|
10896
|
+
.select({
|
|
10897
|
+
attemptId: schema.codexResetRedemptionAttempts.id,
|
|
10898
|
+
credentialId: schema.codexResetRedemptionAttempts.credentialId,
|
|
10899
|
+
creditId: schema.codexResetRedemptionAttempts.creditId,
|
|
10900
|
+
status: schema.codexResetRedemptionAttempts.status,
|
|
10901
|
+
outcome: schema.codexResetRedemptionAttempts.outcome,
|
|
10902
|
+
providerStartedAt: schema.codexResetRedemptionAttempts.providerStartedAt,
|
|
10903
|
+
completedAt: schema.codexResetRedemptionAttempts.completedAt,
|
|
10904
|
+
createdAt: schema.codexResetRedemptionAttempts.createdAt,
|
|
10905
|
+
updatedAt: schema.codexResetRedemptionAttempts.updatedAt,
|
|
10641
10906
|
})
|
|
10642
|
-
.
|
|
10643
|
-
|
|
10644
|
-
|
|
10645
|
-
|
|
10646
|
-
|
|
10907
|
+
.from(schema.codexResetRedemptionAttempts)
|
|
10908
|
+
.innerJoin(
|
|
10909
|
+
schema.codexSubscriptionCredentials,
|
|
10910
|
+
and(
|
|
10911
|
+
eq(
|
|
10912
|
+
schema.codexSubscriptionCredentials.id,
|
|
10913
|
+
schema.codexResetRedemptionAttempts.credentialId,
|
|
10914
|
+
),
|
|
10915
|
+
eq(
|
|
10916
|
+
schema.codexSubscriptionCredentials.workspaceId,
|
|
10917
|
+
schema.codexResetRedemptionAttempts.workspaceId,
|
|
10918
|
+
),
|
|
10919
|
+
),
|
|
10920
|
+
)
|
|
10921
|
+
.where(
|
|
10922
|
+
and(
|
|
10923
|
+
eq(schema.codexResetRedemptionAttempts.workspaceId, input.workspaceId),
|
|
10924
|
+
eq(schema.codexResetRedemptionAttempts.subjectId, input.subjectId),
|
|
10925
|
+
eq(schema.codexSubscriptionCredentials.connectedBySubjectId, input.subjectId),
|
|
10926
|
+
inArray(schema.codexResetRedemptionAttempts.status, ["provider_started", "completed"]),
|
|
10927
|
+
),
|
|
10928
|
+
)
|
|
10929
|
+
.orderBy(desc(schema.codexResetRedemptionAttempts.createdAt));
|
|
10930
|
+
return rows.map((row) => ({
|
|
10931
|
+
attemptId: row.attemptId,
|
|
10932
|
+
credentialId: row.credentialId,
|
|
10933
|
+
creditId: row.creditId,
|
|
10934
|
+
status: row.status as "provider_started" | "completed",
|
|
10935
|
+
outcome: row.outcome as CodexResetRedemptionOutcome | null,
|
|
10936
|
+
providerStartedAt: codexMetadataDate(row.providerStartedAt),
|
|
10937
|
+
completedAt: codexMetadataDate(row.completedAt),
|
|
10938
|
+
createdAt: codexMetadataDate(row.createdAt)!,
|
|
10939
|
+
updatedAt: codexMetadataDate(row.updatedAt)!,
|
|
10940
|
+
}));
|
|
10647
10941
|
},
|
|
10648
10942
|
);
|
|
10649
10943
|
}
|
|
10650
10944
|
|
|
10651
|
-
|
|
10652
|
-
|
|
10945
|
+
export type AdoptCodexResetRedemptionResult =
|
|
10946
|
+
| { kind: "current" | "adopted"; attempt: CodexResetRedemptionAttempt }
|
|
10947
|
+
| { kind: "in_progress" }
|
|
10948
|
+
| { kind: "not_found" }
|
|
10949
|
+
| { kind: "forbidden" }
|
|
10950
|
+
| { kind: "conflict" };
|
|
10951
|
+
|
|
10952
|
+
/** Adopt a released durable ambiguity/completion into the owner's current browser session. */
|
|
10953
|
+
export async function adoptCodexResetRedemptionAttempt(
|
|
10653
10954
|
db: Database,
|
|
10654
|
-
|
|
10655
|
-
|
|
10656
|
-
|
|
10657
|
-
|
|
10658
|
-
|
|
10659
|
-
|
|
10660
|
-
|
|
10661
|
-
|
|
10662
|
-
|
|
10955
|
+
input: {
|
|
10956
|
+
accountId: string;
|
|
10957
|
+
workspaceId: string;
|
|
10958
|
+
attemptId: string;
|
|
10959
|
+
credentialId: string;
|
|
10960
|
+
creditId: string;
|
|
10961
|
+
subjectId: string;
|
|
10962
|
+
browserSessionHash: string;
|
|
10963
|
+
},
|
|
10964
|
+
): Promise<AdoptCodexResetRedemptionResult> {
|
|
10965
|
+
return await withRlsContext(
|
|
10966
|
+
db,
|
|
10967
|
+
{ accountId: input.accountId, workspaceId: input.workspaceId },
|
|
10968
|
+
async (scopedDb) =>
|
|
10969
|
+
await scopedDb.transaction(async (tx) => {
|
|
10970
|
+
const [credential] = await tx
|
|
10971
|
+
.select({
|
|
10972
|
+
connectedBySubjectId: schema.codexSubscriptionCredentials.connectedBySubjectId,
|
|
10973
|
+
})
|
|
10974
|
+
.from(schema.codexSubscriptionCredentials)
|
|
10975
|
+
.where(
|
|
10976
|
+
and(
|
|
10977
|
+
eq(schema.codexSubscriptionCredentials.id, input.credentialId),
|
|
10978
|
+
eq(schema.codexSubscriptionCredentials.workspaceId, input.workspaceId),
|
|
10979
|
+
),
|
|
10980
|
+
)
|
|
10981
|
+
.for("share")
|
|
10982
|
+
.limit(1);
|
|
10983
|
+
if (!credential) return { kind: "not_found" } as const;
|
|
10984
|
+
if (credential.connectedBySubjectId !== input.subjectId) {
|
|
10985
|
+
return { kind: "forbidden" } as const;
|
|
10986
|
+
}
|
|
10987
|
+
const [attempt] = await tx
|
|
10988
|
+
.select()
|
|
10989
|
+
.from(schema.codexResetRedemptionAttempts)
|
|
10990
|
+
.where(
|
|
10991
|
+
and(
|
|
10992
|
+
eq(schema.codexResetRedemptionAttempts.workspaceId, input.workspaceId),
|
|
10993
|
+
eq(schema.codexResetRedemptionAttempts.id, input.attemptId),
|
|
10994
|
+
),
|
|
10995
|
+
)
|
|
10996
|
+
.for("update")
|
|
10997
|
+
.limit(1);
|
|
10998
|
+
if (!attempt) return { kind: "not_found" } as const;
|
|
10999
|
+
if (
|
|
11000
|
+
attempt.accountId !== input.accountId ||
|
|
11001
|
+
attempt.credentialId !== input.credentialId ||
|
|
11002
|
+
attempt.creditId !== input.creditId ||
|
|
11003
|
+
attempt.subjectId !== input.subjectId
|
|
11004
|
+
) {
|
|
11005
|
+
return { kind: "conflict" } as const;
|
|
11006
|
+
}
|
|
11007
|
+
if (attempt.browserSessionHash === input.browserSessionHash) {
|
|
11008
|
+
return { kind: "current", attempt: mapCodexResetRedemptionAttempt(attempt) } as const;
|
|
11009
|
+
}
|
|
11010
|
+
if (attempt.status !== "provider_started" && attempt.status !== "completed") {
|
|
11011
|
+
return { kind: "conflict" } as const;
|
|
11012
|
+
}
|
|
11013
|
+
const claim = await tx.execute<{ claim_live: boolean }>(sql`
|
|
11014
|
+
select claim_expires_at > now() as claim_live
|
|
11015
|
+
from codex_reset_redemption_attempts
|
|
11016
|
+
where workspace_id = ${input.workspaceId} and id = ${input.attemptId}
|
|
11017
|
+
`);
|
|
11018
|
+
if (claim[0]?.claim_live) return { kind: "in_progress" } as const;
|
|
11019
|
+
const [adopted] = await tx
|
|
11020
|
+
.update(schema.codexResetRedemptionAttempts)
|
|
11021
|
+
.set({
|
|
11022
|
+
browserSessionHash: input.browserSessionHash,
|
|
11023
|
+
claimHolderId: null,
|
|
11024
|
+
claimExpiresAt: null,
|
|
11025
|
+
updatedAt: sql`now()`,
|
|
11026
|
+
})
|
|
11027
|
+
.where(eq(schema.codexResetRedemptionAttempts.id, input.attemptId))
|
|
11028
|
+
.returning();
|
|
11029
|
+
if (!adopted) throw new Error("Codex redemption adoption returned no row");
|
|
11030
|
+
return { kind: "adopted", attempt: mapCodexResetRedemptionAttempt(adopted) } as const;
|
|
11031
|
+
}),
|
|
11032
|
+
);
|
|
11033
|
+
}
|
|
11034
|
+
|
|
11035
|
+
export type ClaimCodexResetRedemptionResult =
|
|
11036
|
+
| { kind: "claimed"; attempt: CodexResetRedemptionAttempt }
|
|
11037
|
+
| { kind: "in_progress"; attempt: CodexResetRedemptionAttempt }
|
|
11038
|
+
| { kind: "completed"; attempt: CodexResetRedemptionAttempt }
|
|
11039
|
+
| { kind: "not_found" }
|
|
11040
|
+
| { kind: "forbidden" }
|
|
11041
|
+
| { kind: "conflict" };
|
|
11042
|
+
|
|
11043
|
+
/**
|
|
11044
|
+
* Claim or reclaim one logical redemption across API replicas/crashes.
|
|
11045
|
+
*
|
|
11046
|
+
* A transaction advisory lock closes the initial INSERT race. A live claim
|
|
11047
|
+
* yields `in_progress`; an expired/released claim is reclaimed without changing
|
|
11048
|
+
* the status or upstream key. Existing attempt identity is immutable.
|
|
11049
|
+
*/
|
|
11050
|
+
export async function claimCodexResetRedemption(
|
|
11051
|
+
db: Database,
|
|
11052
|
+
input: {
|
|
11053
|
+
id: string;
|
|
11054
|
+
accountId: string;
|
|
11055
|
+
workspaceId: string;
|
|
11056
|
+
credentialId: string;
|
|
11057
|
+
subjectId: string;
|
|
11058
|
+
browserSessionHash: string;
|
|
11059
|
+
creditId: string;
|
|
11060
|
+
confirmationExpiresAt: Date;
|
|
11061
|
+
claimHolderId: string;
|
|
11062
|
+
claimTtlMs?: number;
|
|
11063
|
+
},
|
|
11064
|
+
): Promise<ClaimCodexResetRedemptionResult> {
|
|
11065
|
+
const claimTtlMs = input.claimTtlMs ?? 60_000;
|
|
11066
|
+
if (!Number.isFinite(claimTtlMs) || claimTtlMs <= 0) {
|
|
11067
|
+
throw new Error("Codex redemption claim TTL must be positive");
|
|
11068
|
+
}
|
|
11069
|
+
if (
|
|
11070
|
+
!Number.isFinite(input.confirmationExpiresAt.getTime()) ||
|
|
11071
|
+
input.confirmationExpiresAt.getTime() <= Date.now()
|
|
11072
|
+
) {
|
|
11073
|
+
return { kind: "forbidden" };
|
|
11074
|
+
}
|
|
11075
|
+
return await withRlsContext(
|
|
11076
|
+
db,
|
|
11077
|
+
{ accountId: input.accountId, workspaceId: input.workspaceId },
|
|
11078
|
+
async (scopedDb) =>
|
|
11079
|
+
await scopedDb.transaction(async (tx) => {
|
|
11080
|
+
await tx.execute(
|
|
11081
|
+
sql`select pg_advisory_xact_lock(hashtextextended(${`codex-reset-attempt:${input.id}`}, 0))`,
|
|
11082
|
+
);
|
|
11083
|
+
// A second browser tab has a different attempt UUID. Serialize on the
|
|
11084
|
+
// irreversible provider credit too, then let the unique index provide
|
|
11085
|
+
// the durable fail-closed backstop.
|
|
11086
|
+
await tx.execute(
|
|
11087
|
+
sql`select pg_advisory_xact_lock(hashtextextended(${`codex-reset-credit:${input.workspaceId}:${input.credentialId}:${input.creditId}`}, 0))`,
|
|
11088
|
+
);
|
|
11089
|
+
const [credential] = await tx
|
|
11090
|
+
.select({
|
|
11091
|
+
connectedBySubjectId: schema.codexSubscriptionCredentials.connectedBySubjectId,
|
|
11092
|
+
status: schema.codexSubscriptionCredentials.status,
|
|
11093
|
+
})
|
|
11094
|
+
.from(schema.codexSubscriptionCredentials)
|
|
11095
|
+
.where(
|
|
11096
|
+
and(
|
|
11097
|
+
eq(schema.codexSubscriptionCredentials.accountId, input.accountId),
|
|
11098
|
+
eq(schema.codexSubscriptionCredentials.workspaceId, input.workspaceId),
|
|
11099
|
+
eq(schema.codexSubscriptionCredentials.id, input.credentialId),
|
|
11100
|
+
),
|
|
11101
|
+
)
|
|
11102
|
+
.for("share")
|
|
11103
|
+
.limit(1);
|
|
11104
|
+
if (!credential) return { kind: "not_found" } as const;
|
|
11105
|
+
const [existing] = await tx
|
|
11106
|
+
.select()
|
|
11107
|
+
.from(schema.codexResetRedemptionAttempts)
|
|
11108
|
+
.where(
|
|
11109
|
+
and(
|
|
11110
|
+
eq(schema.codexResetRedemptionAttempts.workspaceId, input.workspaceId),
|
|
11111
|
+
eq(schema.codexResetRedemptionAttempts.id, input.id),
|
|
11112
|
+
),
|
|
11113
|
+
)
|
|
11114
|
+
.for("update")
|
|
11115
|
+
.limit(1);
|
|
11116
|
+
const now = new Date();
|
|
11117
|
+
if (existing) {
|
|
11118
|
+
if (
|
|
11119
|
+
existing.accountId !== input.accountId ||
|
|
11120
|
+
existing.credentialId !== input.credentialId ||
|
|
11121
|
+
existing.subjectId !== input.subjectId ||
|
|
11122
|
+
existing.browserSessionHash !== input.browserSessionHash ||
|
|
11123
|
+
existing.creditId !== input.creditId
|
|
11124
|
+
) {
|
|
11125
|
+
return { kind: "conflict" } as const;
|
|
11126
|
+
}
|
|
11127
|
+
if (credential.connectedBySubjectId !== input.subjectId) {
|
|
11128
|
+
return { kind: "forbidden" } as const;
|
|
11129
|
+
}
|
|
11130
|
+
const mapped = mapCodexResetRedemptionAttempt(existing);
|
|
11131
|
+
// Completion is durable truth. A token-health transition after the
|
|
11132
|
+
// provider outcome committed must not turn an HTTP-response-loss
|
|
11133
|
+
// replay into a false 403 or trigger a second consume. Current human
|
|
11134
|
+
// ownership remains mandatory, but active health is needed only when
|
|
11135
|
+
// work may still have to reach the provider.
|
|
11136
|
+
if (mapped.status === "completed") return { kind: "completed", attempt: mapped };
|
|
11137
|
+
if (credential.status !== "active") {
|
|
11138
|
+
return { kind: "forbidden" } as const;
|
|
11139
|
+
}
|
|
11140
|
+
const claimState = await tx.execute(sql<{ claim_live: boolean }>`
|
|
11141
|
+
select claim_expires_at > now() as claim_live
|
|
11142
|
+
from codex_reset_redemption_attempts
|
|
11143
|
+
where workspace_id = ${input.workspaceId} and id = ${input.id}
|
|
11144
|
+
`);
|
|
11145
|
+
if (claimState[0]?.claim_live) {
|
|
11146
|
+
return { kind: "in_progress", attempt: mapped };
|
|
11147
|
+
}
|
|
11148
|
+
const [reclaimed] = await tx
|
|
11149
|
+
.update(schema.codexResetRedemptionAttempts)
|
|
11150
|
+
.set({
|
|
11151
|
+
claimHolderId: input.claimHolderId,
|
|
11152
|
+
claimExpiresAt: sql`now() + (${claimTtlMs} * interval '1 millisecond')`,
|
|
11153
|
+
confirmationExpiresAt: input.confirmationExpiresAt,
|
|
11154
|
+
lastFailureKind: null,
|
|
11155
|
+
retryCount: sql`${schema.codexResetRedemptionAttempts.retryCount} + 1`,
|
|
11156
|
+
updatedAt: now,
|
|
11157
|
+
})
|
|
11158
|
+
.where(eq(schema.codexResetRedemptionAttempts.id, input.id))
|
|
11159
|
+
.returning();
|
|
11160
|
+
if (!reclaimed) throw new Error("Codex redemption reclaim returned no row");
|
|
11161
|
+
return {
|
|
11162
|
+
kind: "claimed",
|
|
11163
|
+
attempt: mapCodexResetRedemptionAttempt(reclaimed),
|
|
11164
|
+
};
|
|
11165
|
+
}
|
|
11166
|
+
|
|
11167
|
+
// Authorize before looking up credit-attempt state so a non-owner cannot
|
|
11168
|
+
// distinguish an unused provider credit from one with an existing attempt.
|
|
11169
|
+
if (credential.status !== "active" || credential.connectedBySubjectId !== input.subjectId) {
|
|
11170
|
+
return { kind: "forbidden" } as const;
|
|
11171
|
+
}
|
|
11172
|
+
|
|
11173
|
+
const [creditAttempt] = await tx.execute<{
|
|
11174
|
+
id: string;
|
|
11175
|
+
status: CodexResetRedemptionStatus;
|
|
11176
|
+
claim_live: boolean | null;
|
|
11177
|
+
}>(sql`
|
|
11178
|
+
select id, status, claim_expires_at > now() as claim_live
|
|
11179
|
+
from codex_reset_redemption_attempts
|
|
11180
|
+
where workspace_id = ${input.workspaceId}
|
|
11181
|
+
and credential_id = ${input.credentialId}
|
|
11182
|
+
and credit_id = ${input.creditId}
|
|
11183
|
+
and (status <> 'completed' or outcome in ('reset', 'alreadyRedeemed'))
|
|
11184
|
+
limit 1
|
|
11185
|
+
for update
|
|
11186
|
+
`);
|
|
11187
|
+
if (creditAttempt) {
|
|
11188
|
+
// A different browser UUID may replace only definite pre-provider
|
|
11189
|
+
// work whose claim is released/expired. The per-credit advisory lock
|
|
11190
|
+
// serializes replacement with a late original claimant, and the
|
|
11191
|
+
// DB-time predicate prevents an application-clock race. Once
|
|
11192
|
+
// provider_started (or successfully completed), preserve the one
|
|
11193
|
+
// upstream idempotency key and fail closed.
|
|
11194
|
+
if (creditAttempt.status !== "processing" || creditAttempt.claim_live) {
|
|
11195
|
+
return { kind: "conflict" } as const;
|
|
11196
|
+
}
|
|
11197
|
+
const removed = await tx
|
|
11198
|
+
.delete(schema.codexResetRedemptionAttempts)
|
|
11199
|
+
.where(
|
|
11200
|
+
and(
|
|
11201
|
+
eq(schema.codexResetRedemptionAttempts.workspaceId, input.workspaceId),
|
|
11202
|
+
eq(schema.codexResetRedemptionAttempts.id, creditAttempt.id),
|
|
11203
|
+
eq(schema.codexResetRedemptionAttempts.status, "processing"),
|
|
11204
|
+
sql`(${schema.codexResetRedemptionAttempts.claimExpiresAt} is null or ${schema.codexResetRedemptionAttempts.claimExpiresAt} <= now())`,
|
|
11205
|
+
),
|
|
11206
|
+
)
|
|
11207
|
+
.returning({ id: schema.codexResetRedemptionAttempts.id });
|
|
11208
|
+
if (removed.length !== 1) return { kind: "conflict" } as const;
|
|
11209
|
+
}
|
|
11210
|
+
|
|
11211
|
+
const [created] = await tx
|
|
11212
|
+
.insert(schema.codexResetRedemptionAttempts)
|
|
11213
|
+
.values({
|
|
11214
|
+
id: input.id,
|
|
11215
|
+
accountId: input.accountId,
|
|
11216
|
+
workspaceId: input.workspaceId,
|
|
11217
|
+
credentialId: input.credentialId,
|
|
11218
|
+
subjectId: input.subjectId,
|
|
11219
|
+
browserSessionHash: input.browserSessionHash,
|
|
11220
|
+
creditId: input.creditId,
|
|
11221
|
+
status: "processing",
|
|
11222
|
+
claimHolderId: input.claimHolderId,
|
|
11223
|
+
claimExpiresAt: sql`now() + (${claimTtlMs} * interval '1 millisecond')`,
|
|
11224
|
+
confirmationExpiresAt: input.confirmationExpiresAt,
|
|
11225
|
+
})
|
|
11226
|
+
.returning();
|
|
11227
|
+
if (!created) throw new Error("Codex redemption claim returned no row");
|
|
11228
|
+
return {
|
|
11229
|
+
kind: "claimed",
|
|
11230
|
+
attempt: mapCodexResetRedemptionAttempt(created),
|
|
11231
|
+
};
|
|
11232
|
+
}),
|
|
11233
|
+
);
|
|
11234
|
+
}
|
|
11235
|
+
|
|
11236
|
+
export type CodexResetRedemptionSendNotReadyReason =
|
|
11237
|
+
| "not_found"
|
|
11238
|
+
| "identity_mismatch"
|
|
11239
|
+
| "claim_expired"
|
|
11240
|
+
| "confirmation_expired"
|
|
11241
|
+
| "credential_unavailable"
|
|
11242
|
+
| "already_completed";
|
|
11243
|
+
|
|
11244
|
+
export type FenceCodexResetRedemptionSendResult =
|
|
11245
|
+
| { kind: "ready"; attempt: CodexResetRedemptionAttempt }
|
|
11246
|
+
| {
|
|
11247
|
+
kind: "not_ready";
|
|
11248
|
+
reason: CodexResetRedemptionSendNotReadyReason;
|
|
11249
|
+
};
|
|
11250
|
+
|
|
11251
|
+
/**
|
|
11252
|
+
* Final DB-time irreversible-send fence, called only after preflight and token
|
|
11253
|
+
* resolution and immediately before every provider POST (including ambiguity
|
|
11254
|
+
* retries). The credential SHARE lock serializes against disconnect and
|
|
11255
|
+
* ownership-changing reconnect. The claim is extended beyond the bounded
|
|
11256
|
+
* consume operation in the same transaction that persists provider_started.
|
|
11257
|
+
*/
|
|
11258
|
+
export async function fenceCodexResetRedemptionSend(
|
|
11259
|
+
db: Database,
|
|
11260
|
+
input: {
|
|
11261
|
+
accountId: string;
|
|
11262
|
+
workspaceId: string;
|
|
11263
|
+
attemptId: string;
|
|
11264
|
+
claimHolderId: string;
|
|
11265
|
+
credentialId: string;
|
|
11266
|
+
subjectId: string;
|
|
11267
|
+
browserSessionHash: string;
|
|
11268
|
+
sendLeaseMs?: number;
|
|
11269
|
+
},
|
|
11270
|
+
): Promise<FenceCodexResetRedemptionSendResult> {
|
|
11271
|
+
const sendLeaseMs = input.sendLeaseMs ?? 30_000;
|
|
11272
|
+
if (!Number.isFinite(sendLeaseMs) || sendLeaseMs <= 10_000) {
|
|
11273
|
+
throw new Error("Codex redemption send lease must exceed the bounded provider call");
|
|
11274
|
+
}
|
|
11275
|
+
return await withRlsContext(
|
|
11276
|
+
db,
|
|
11277
|
+
{ accountId: input.accountId, workspaceId: input.workspaceId },
|
|
11278
|
+
async (scopedDb) =>
|
|
11279
|
+
await scopedDb.transaction(async (tx) => {
|
|
11280
|
+
const [credential] = await tx
|
|
11281
|
+
.select({
|
|
11282
|
+
connectedBySubjectId: schema.codexSubscriptionCredentials.connectedBySubjectId,
|
|
11283
|
+
status: schema.codexSubscriptionCredentials.status,
|
|
11284
|
+
})
|
|
11285
|
+
.from(schema.codexSubscriptionCredentials)
|
|
11286
|
+
.where(
|
|
11287
|
+
and(
|
|
11288
|
+
eq(schema.codexSubscriptionCredentials.accountId, input.accountId),
|
|
11289
|
+
eq(schema.codexSubscriptionCredentials.workspaceId, input.workspaceId),
|
|
11290
|
+
eq(schema.codexSubscriptionCredentials.id, input.credentialId),
|
|
11291
|
+
),
|
|
11292
|
+
)
|
|
11293
|
+
.for("share")
|
|
11294
|
+
.limit(1);
|
|
11295
|
+
const [attempt] = await tx
|
|
11296
|
+
.select()
|
|
11297
|
+
.from(schema.codexResetRedemptionAttempts)
|
|
11298
|
+
.where(
|
|
11299
|
+
and(
|
|
11300
|
+
eq(schema.codexResetRedemptionAttempts.accountId, input.accountId),
|
|
11301
|
+
eq(schema.codexResetRedemptionAttempts.workspaceId, input.workspaceId),
|
|
11302
|
+
eq(schema.codexResetRedemptionAttempts.id, input.attemptId),
|
|
11303
|
+
),
|
|
11304
|
+
)
|
|
11305
|
+
.for("update")
|
|
11306
|
+
.limit(1);
|
|
11307
|
+
if (!attempt) return { kind: "not_ready", reason: "not_found" } as const;
|
|
11308
|
+
if (
|
|
11309
|
+
attempt.credentialId !== input.credentialId ||
|
|
11310
|
+
attempt.subjectId !== input.subjectId ||
|
|
11311
|
+
attempt.browserSessionHash !== input.browserSessionHash ||
|
|
11312
|
+
attempt.claimHolderId !== input.claimHolderId
|
|
11313
|
+
) {
|
|
11314
|
+
return { kind: "not_ready", reason: "identity_mismatch" } as const;
|
|
11315
|
+
}
|
|
11316
|
+
if (attempt.status === "completed") {
|
|
11317
|
+
return { kind: "not_ready", reason: "already_completed" } as const;
|
|
11318
|
+
}
|
|
11319
|
+
const [liveness] = await tx.execute<{
|
|
11320
|
+
claim_live: boolean;
|
|
11321
|
+
confirmation_live: boolean;
|
|
11322
|
+
}>(sql`
|
|
11323
|
+
select claim_expires_at > now() as claim_live,
|
|
11324
|
+
confirmation_expires_at > now() as confirmation_live
|
|
11325
|
+
from codex_reset_redemption_attempts
|
|
11326
|
+
where workspace_id = ${input.workspaceId} and id = ${input.attemptId}
|
|
11327
|
+
`);
|
|
11328
|
+
let reason: CodexResetRedemptionSendNotReadyReason;
|
|
11329
|
+
if (!liveness?.claim_live) reason = "claim_expired";
|
|
11330
|
+
else if (!liveness.confirmation_live) reason = "confirmation_expired";
|
|
11331
|
+
else if (
|
|
11332
|
+
!credential ||
|
|
11333
|
+
credential.status !== "active" ||
|
|
11334
|
+
credential.connectedBySubjectId !== input.subjectId
|
|
11335
|
+
) {
|
|
11336
|
+
reason = "credential_unavailable";
|
|
11337
|
+
} else {
|
|
11338
|
+
const [ready] = await tx
|
|
11339
|
+
.update(schema.codexResetRedemptionAttempts)
|
|
11340
|
+
.set({
|
|
11341
|
+
status: "provider_started",
|
|
11342
|
+
providerStartedAt: sql`coalesce(${schema.codexResetRedemptionAttempts.providerStartedAt}, now())`,
|
|
11343
|
+
claimExpiresAt: sql`now() + (${sendLeaseMs} * interval '1 millisecond')`,
|
|
11344
|
+
lastFailureKind: null,
|
|
11345
|
+
updatedAt: sql`now()`,
|
|
11346
|
+
})
|
|
11347
|
+
.where(eq(schema.codexResetRedemptionAttempts.id, input.attemptId))
|
|
11348
|
+
.returning();
|
|
11349
|
+
if (!ready) throw new Error("Codex redemption send fence returned no row");
|
|
11350
|
+
return { kind: "ready", attempt: mapCodexResetRedemptionAttempt(ready) } as const;
|
|
11351
|
+
}
|
|
11352
|
+
|
|
11353
|
+
// Before provider_started it is safe to remove the false logical
|
|
11354
|
+
// attempt. Once provider work may have begun, preserve the upstream key
|
|
11355
|
+
// and merely release this request's stale claim for owner recovery.
|
|
11356
|
+
if (attempt.status === "processing") {
|
|
11357
|
+
await tx
|
|
11358
|
+
.delete(schema.codexResetRedemptionAttempts)
|
|
11359
|
+
.where(eq(schema.codexResetRedemptionAttempts.id, input.attemptId));
|
|
11360
|
+
} else {
|
|
11361
|
+
await tx
|
|
11362
|
+
.update(schema.codexResetRedemptionAttempts)
|
|
11363
|
+
.set({
|
|
11364
|
+
claimHolderId: null,
|
|
11365
|
+
claimExpiresAt: null,
|
|
11366
|
+
lastFailureKind: `send_fence_${reason}`,
|
|
11367
|
+
updatedAt: sql`now()`,
|
|
11368
|
+
})
|
|
11369
|
+
.where(eq(schema.codexResetRedemptionAttempts.id, input.attemptId));
|
|
11370
|
+
}
|
|
11371
|
+
return { kind: "not_ready", reason } as const;
|
|
11372
|
+
}),
|
|
11373
|
+
);
|
|
11374
|
+
}
|
|
11375
|
+
|
|
11376
|
+
/** Delete only definite pre-provider work still owned by this request. */
|
|
11377
|
+
export async function abandonCodexResetRedemptionBeforeProvider(
|
|
11378
|
+
db: Database,
|
|
11379
|
+
input: {
|
|
11380
|
+
accountId: string;
|
|
11381
|
+
workspaceId: string;
|
|
11382
|
+
attemptId: string;
|
|
11383
|
+
claimHolderId: string;
|
|
11384
|
+
},
|
|
11385
|
+
): Promise<boolean> {
|
|
11386
|
+
return await withRlsContext(
|
|
11387
|
+
db,
|
|
11388
|
+
{ accountId: input.accountId, workspaceId: input.workspaceId },
|
|
11389
|
+
async (scopedDb) => {
|
|
11390
|
+
const deleted = await scopedDb
|
|
11391
|
+
.delete(schema.codexResetRedemptionAttempts)
|
|
11392
|
+
.where(
|
|
11393
|
+
and(
|
|
11394
|
+
eq(schema.codexResetRedemptionAttempts.workspaceId, input.workspaceId),
|
|
11395
|
+
eq(schema.codexResetRedemptionAttempts.id, input.attemptId),
|
|
11396
|
+
eq(schema.codexResetRedemptionAttempts.status, "processing"),
|
|
11397
|
+
eq(schema.codexResetRedemptionAttempts.claimHolderId, input.claimHolderId),
|
|
11398
|
+
),
|
|
11399
|
+
)
|
|
11400
|
+
.returning({ id: schema.codexResetRedemptionAttempts.id });
|
|
11401
|
+
return deleted.length === 1;
|
|
11402
|
+
},
|
|
11403
|
+
);
|
|
11404
|
+
}
|
|
11405
|
+
|
|
11406
|
+
/** Release a failed claim while preserving processing/provider_started truth. */
|
|
11407
|
+
export async function releaseCodexResetRedemptionClaim(
|
|
11408
|
+
db: Database,
|
|
11409
|
+
input: {
|
|
11410
|
+
accountId: string;
|
|
11411
|
+
workspaceId: string;
|
|
11412
|
+
attemptId: string;
|
|
11413
|
+
claimHolderId: string;
|
|
11414
|
+
failureKind: string;
|
|
11415
|
+
},
|
|
11416
|
+
): Promise<boolean> {
|
|
11417
|
+
return await withRlsContext(
|
|
11418
|
+
db,
|
|
11419
|
+
{ accountId: input.accountId, workspaceId: input.workspaceId },
|
|
11420
|
+
async (scopedDb) => {
|
|
11421
|
+
const rows = await scopedDb
|
|
11422
|
+
.update(schema.codexResetRedemptionAttempts)
|
|
11423
|
+
.set({
|
|
11424
|
+
claimHolderId: null,
|
|
11425
|
+
claimExpiresAt: null,
|
|
11426
|
+
lastFailureKind: input.failureKind.slice(0, 100),
|
|
11427
|
+
updatedAt: new Date(),
|
|
11428
|
+
})
|
|
11429
|
+
.where(
|
|
11430
|
+
and(
|
|
11431
|
+
eq(schema.codexResetRedemptionAttempts.workspaceId, input.workspaceId),
|
|
11432
|
+
eq(schema.codexResetRedemptionAttempts.id, input.attemptId),
|
|
11433
|
+
eq(schema.codexResetRedemptionAttempts.claimHolderId, input.claimHolderId),
|
|
11434
|
+
sql`${schema.codexResetRedemptionAttempts.status} <> 'completed'`,
|
|
11435
|
+
),
|
|
11436
|
+
)
|
|
11437
|
+
.returning({ id: schema.codexResetRedemptionAttempts.id });
|
|
11438
|
+
return rows.length === 1;
|
|
11439
|
+
},
|
|
11440
|
+
);
|
|
11441
|
+
}
|
|
11442
|
+
|
|
11443
|
+
/** Persist the exact provider outcome, cooldown repair, and audit atomically. */
|
|
11444
|
+
export async function completeCodexResetRedemption(
|
|
11445
|
+
db: Database,
|
|
11446
|
+
input: {
|
|
11447
|
+
accountId: string;
|
|
11448
|
+
workspaceId: string;
|
|
11449
|
+
attemptId: string;
|
|
11450
|
+
claimHolderId: string;
|
|
11451
|
+
outcome: CodexResetRedemptionOutcome;
|
|
11452
|
+
},
|
|
11453
|
+
): Promise<CodexCapacityMutationResult<CodexResetRedemptionAttempt | null>> {
|
|
11454
|
+
if (!CODEX_RESET_REDEMPTION_OUTCOMES.includes(input.outcome)) {
|
|
11455
|
+
throw new Error("Unknown Codex redemption outcome");
|
|
11456
|
+
}
|
|
11457
|
+
return await withCodexCapacityMutation(
|
|
11458
|
+
db,
|
|
11459
|
+
{ workspaceId: input.workspaceId, reason: "codex_reset_credit_redeemed" },
|
|
11460
|
+
async (tx) => {
|
|
11461
|
+
// Claims take this lock before touching the credential and attempt rows.
|
|
11462
|
+
// Completion takes the same fence after the canonical capacity lock, so
|
|
11463
|
+
// a claim cannot hold a credential SHARE lock while waiting for an
|
|
11464
|
+
// attempt row that this transaction already owns.
|
|
11465
|
+
await tx.execute(
|
|
11466
|
+
sql`select pg_advisory_xact_lock(hashtextextended(${`codex-reset-attempt:${input.attemptId}`}, 0))`,
|
|
11467
|
+
);
|
|
11468
|
+
const [current] = await tx
|
|
11469
|
+
.select()
|
|
11470
|
+
.from(schema.codexResetRedemptionAttempts)
|
|
11471
|
+
.where(
|
|
11472
|
+
and(
|
|
11473
|
+
eq(schema.codexResetRedemptionAttempts.accountId, input.accountId),
|
|
11474
|
+
eq(schema.codexResetRedemptionAttempts.workspaceId, input.workspaceId),
|
|
11475
|
+
eq(schema.codexResetRedemptionAttempts.id, input.attemptId),
|
|
11476
|
+
),
|
|
11477
|
+
)
|
|
11478
|
+
.for("update")
|
|
11479
|
+
.limit(1);
|
|
11480
|
+
if (!current) return { result: null, changed: false };
|
|
11481
|
+
if (current.status === "completed") {
|
|
11482
|
+
return { result: mapCodexResetRedemptionAttempt(current), changed: false };
|
|
11483
|
+
}
|
|
11484
|
+
if (current.status !== "provider_started" || current.claimHolderId !== input.claimHolderId) {
|
|
11485
|
+
return { result: null, changed: false };
|
|
11486
|
+
}
|
|
11487
|
+
const completedAt = new Date();
|
|
11488
|
+
const [completed] = await tx
|
|
11489
|
+
.update(schema.codexResetRedemptionAttempts)
|
|
11490
|
+
.set({
|
|
11491
|
+
status: "completed",
|
|
11492
|
+
outcome: input.outcome,
|
|
11493
|
+
completedAt,
|
|
11494
|
+
claimHolderId: null,
|
|
11495
|
+
claimExpiresAt: null,
|
|
11496
|
+
lastFailureKind: null,
|
|
11497
|
+
updatedAt: completedAt,
|
|
11498
|
+
})
|
|
11499
|
+
.where(
|
|
11500
|
+
and(
|
|
11501
|
+
eq(schema.codexResetRedemptionAttempts.accountId, input.accountId),
|
|
11502
|
+
eq(schema.codexResetRedemptionAttempts.workspaceId, input.workspaceId),
|
|
11503
|
+
eq(schema.codexResetRedemptionAttempts.id, input.attemptId),
|
|
11504
|
+
),
|
|
11505
|
+
)
|
|
11506
|
+
.returning();
|
|
11507
|
+
if (!completed) throw new Error("Codex redemption completion returned no row");
|
|
11508
|
+
const restoresCapacity = input.outcome === "reset" || input.outcome === "alreadyRedeemed";
|
|
11509
|
+
if (restoresCapacity) {
|
|
11510
|
+
await tx
|
|
11511
|
+
.update(schema.codexSubscriptionCredentials)
|
|
11512
|
+
.set({ exhaustedUntil: null })
|
|
11513
|
+
.where(
|
|
11514
|
+
and(
|
|
11515
|
+
eq(schema.codexSubscriptionCredentials.accountId, input.accountId),
|
|
11516
|
+
eq(schema.codexSubscriptionCredentials.workspaceId, input.workspaceId),
|
|
11517
|
+
eq(schema.codexSubscriptionCredentials.id, current.credentialId),
|
|
11518
|
+
),
|
|
11519
|
+
);
|
|
11520
|
+
}
|
|
11521
|
+
await tx.insert(schema.auditEvents).values({
|
|
11522
|
+
accountId: input.accountId,
|
|
11523
|
+
workspaceId: input.workspaceId,
|
|
11524
|
+
subjectId: current.subjectId,
|
|
11525
|
+
action: "codex.reset_credit.redemption.completed",
|
|
11526
|
+
targetType: "codex_reset_redemption_attempt",
|
|
11527
|
+
targetId: input.attemptId,
|
|
11528
|
+
metadata: { outcome: input.outcome },
|
|
11529
|
+
});
|
|
11530
|
+
return {
|
|
11531
|
+
result: mapCodexResetRedemptionAttempt(completed),
|
|
11532
|
+
// A successful/already-applied upstream reset can make durable waiters
|
|
11533
|
+
// eligible immediately even when the local cooldown was already null.
|
|
11534
|
+
changed: restoresCapacity,
|
|
11535
|
+
};
|
|
11536
|
+
},
|
|
11537
|
+
);
|
|
11538
|
+
}
|
|
11539
|
+
|
|
11540
|
+
/** The P2 usage-cache snapshot written by the refreshing usage wrapper. */
|
|
11541
|
+
export type CodexAccountUsageSnapshot = {
|
|
11542
|
+
primaryUsedPercent?: number | null;
|
|
11543
|
+
primaryResetAt?: Date | null;
|
|
11544
|
+
secondaryUsedPercent?: number | null;
|
|
11545
|
+
secondaryResetAt?: Date | null;
|
|
11546
|
+
/** Present only when the quota body parsed successfully. */
|
|
11547
|
+
checkedAt?: Date;
|
|
11548
|
+
resetCreditAvailableCount?: number | null;
|
|
11549
|
+
resetCreditsCheckedAt?: Date | null;
|
|
11550
|
+
};
|
|
11551
|
+
|
|
11552
|
+
/**
|
|
11553
|
+
* Cache-write for P2 quota bars: persist the five plaintext usage columns on a
|
|
11554
|
+
* SPECIFIC credential row. NEVER touches credential_encrypted. RLS-scoped, guarded
|
|
11555
|
+
* by (id, workspace_id) so it can only write a row the workspace owns. Returns true
|
|
11556
|
+
* iff a row was updated (false ⇒ the credential was disconnected under us — the
|
|
11557
|
+
* snapshot is moot, drop it). This is the only writer of the usage_checked_at TTL
|
|
11558
|
+
* clock that `listCodexAccountStatuses` reads back.
|
|
11559
|
+
*/
|
|
11560
|
+
export async function recordCodexAccountUsage(
|
|
11561
|
+
db: Database,
|
|
11562
|
+
workspaceId: string,
|
|
11563
|
+
credentialId: string,
|
|
11564
|
+
snapshot: CodexAccountUsageSnapshot,
|
|
11565
|
+
): Promise<boolean> {
|
|
11566
|
+
return (await recordCodexAccountUsageWithWakeTargets(db, workspaceId, credentialId, snapshot))
|
|
11567
|
+
.result;
|
|
11568
|
+
}
|
|
11569
|
+
|
|
11570
|
+
/** Usage-cache mutation plus its committed durable capacity-wake outbox. */
|
|
11571
|
+
export async function recordCodexAccountUsageWithWakeTargets(
|
|
11572
|
+
db: Database,
|
|
11573
|
+
workspaceId: string,
|
|
11574
|
+
credentialId: string,
|
|
11575
|
+
snapshot: CodexAccountUsageSnapshot,
|
|
11576
|
+
): Promise<CodexCapacityMutationResult<boolean>> {
|
|
11577
|
+
return await withCodexCapacityMutation(
|
|
11578
|
+
db,
|
|
11579
|
+
{ workspaceId, reason: "codex_usage_refreshed" },
|
|
11580
|
+
async (tx) => {
|
|
11581
|
+
const updated = await tx
|
|
11582
|
+
.update(schema.codexSubscriptionCredentials)
|
|
11583
|
+
.set({
|
|
11584
|
+
...(snapshot.checkedAt !== undefined
|
|
11585
|
+
? {
|
|
11586
|
+
primaryUsedPercent: snapshot.primaryUsedPercent ?? null,
|
|
11587
|
+
primaryResetAt: snapshot.primaryResetAt ?? null,
|
|
11588
|
+
secondaryUsedPercent: snapshot.secondaryUsedPercent ?? null,
|
|
11589
|
+
secondaryResetAt: snapshot.secondaryResetAt ?? null,
|
|
11590
|
+
usageCheckedAt: snapshot.checkedAt,
|
|
11591
|
+
}
|
|
11592
|
+
: {}),
|
|
11593
|
+
...(snapshot.resetCreditAvailableCount !== undefined
|
|
11594
|
+
? {
|
|
11595
|
+
resetCreditAvailableCount: snapshot.resetCreditAvailableCount,
|
|
11596
|
+
resetCreditsCheckedAt: snapshot.resetCreditsCheckedAt ?? null,
|
|
11597
|
+
}
|
|
11598
|
+
: {}),
|
|
11599
|
+
// NB: no `version` bump and no `updatedAt` touch — usage is non-credential
|
|
11600
|
+
// metadata and must NOT race the (id, version) refresh CAS in
|
|
11601
|
+
// recordCodexTokenRefresh / setCodexCredentialStatus.
|
|
11602
|
+
})
|
|
11603
|
+
.where(
|
|
11604
|
+
and(
|
|
11605
|
+
eq(schema.codexSubscriptionCredentials.id, credentialId),
|
|
11606
|
+
eq(schema.codexSubscriptionCredentials.workspaceId, workspaceId),
|
|
11607
|
+
),
|
|
11608
|
+
)
|
|
11609
|
+
.returning({ id: schema.codexSubscriptionCredentials.id });
|
|
11610
|
+
const changed = updated.length > 0;
|
|
11611
|
+
return { result: changed, changed };
|
|
11612
|
+
},
|
|
11613
|
+
);
|
|
11614
|
+
}
|
|
11615
|
+
|
|
11616
|
+
export type CodexRotationSettings = {
|
|
11617
|
+
activeCredentialId: string | null;
|
|
11618
|
+
/** Legacy selector bit; old binaries only understand this field. */
|
|
11619
|
+
rotationEnabled: boolean;
|
|
11620
|
+
/** New allocator cutover bit; ignored safely by old binaries. */
|
|
11621
|
+
leaseRotationEnabled: boolean;
|
|
11622
|
+
rotationStrategy: string; // P1: 'most_remaining' (unused)
|
|
11623
|
+
};
|
|
11624
|
+
|
|
11625
|
+
/**
|
|
11626
|
+
* Per-workspace model/provider availability policy. NULL fields = unrestricted
|
|
11627
|
+
* (identical to no row — the default for every workspace). Non-null
|
|
11628
|
+
* allowedProviders is a strict allowlist over resolved provider identities;
|
|
11629
|
+
* non-null allowedModels an additional exact model-id allowlist. Consumers:
|
|
11630
|
+
* the API model choke points (fail 422) and the worker's post-resolution gate
|
|
11631
|
+
* (a blocked provider never reaches a model call and never silently remaps).
|
|
11632
|
+
*/
|
|
11633
|
+
export type WorkspaceModelPolicy = {
|
|
11634
|
+
allowedProviders: string[] | null;
|
|
11635
|
+
allowedModels: string[] | null;
|
|
11636
|
+
};
|
|
11637
|
+
|
|
11638
|
+
/** The per-workspace model policy row (null when none exists = unrestricted). */
|
|
11639
|
+
export async function getWorkspaceModelPolicy(
|
|
11640
|
+
db: Database,
|
|
11641
|
+
workspaceId: string,
|
|
11642
|
+
): Promise<WorkspaceModelPolicy | null> {
|
|
11643
|
+
return await withWorkspaceRls(db, workspaceId, async (scopedDb) => {
|
|
11644
|
+
const [row] = await scopedDb
|
|
11645
|
+
.select({
|
|
11646
|
+
allowedProviders: schema.workspaceModelPolicies.allowedProviders,
|
|
11647
|
+
allowedModels: schema.workspaceModelPolicies.allowedModels,
|
|
11648
|
+
})
|
|
11649
|
+
.from(schema.workspaceModelPolicies)
|
|
11650
|
+
.where(eq(schema.workspaceModelPolicies.workspaceId, workspaceId))
|
|
11651
|
+
.limit(1);
|
|
11652
|
+
return row ?? null;
|
|
11653
|
+
});
|
|
11654
|
+
}
|
|
11655
|
+
|
|
11656
|
+
/**
|
|
11657
|
+
* Create or replace the workspace's model policy. Passing null for a field
|
|
11658
|
+
* clears that restriction; a policy of {null, null} is kept as an explicit
|
|
11659
|
+
* "unrestricted" row (delete is not needed for correctness — it reads the same
|
|
11660
|
+
* as no row).
|
|
11661
|
+
*/
|
|
11662
|
+
export async function upsertWorkspaceModelPolicy(
|
|
11663
|
+
db: Database,
|
|
11664
|
+
input: {
|
|
11665
|
+
accountId: string;
|
|
11666
|
+
workspaceId: string;
|
|
11667
|
+
allowedProviders: string[] | null;
|
|
11668
|
+
allowedModels: string[] | null;
|
|
11669
|
+
},
|
|
11670
|
+
): Promise<WorkspaceModelPolicy> {
|
|
11671
|
+
return await withRlsContext(
|
|
11672
|
+
db,
|
|
11673
|
+
{ accountId: input.accountId, workspaceId: input.workspaceId },
|
|
11674
|
+
async (scopedDb) => {
|
|
11675
|
+
const [row] = await scopedDb
|
|
11676
|
+
.insert(schema.workspaceModelPolicies)
|
|
11677
|
+
.values({
|
|
11678
|
+
accountId: input.accountId,
|
|
11679
|
+
workspaceId: input.workspaceId,
|
|
11680
|
+
allowedProviders: input.allowedProviders,
|
|
11681
|
+
allowedModels: input.allowedModels,
|
|
11682
|
+
})
|
|
11683
|
+
.onConflictDoUpdate({
|
|
11684
|
+
target: [schema.workspaceModelPolicies.workspaceId],
|
|
11685
|
+
set: {
|
|
11686
|
+
allowedProviders: input.allowedProviders,
|
|
11687
|
+
allowedModels: input.allowedModels,
|
|
11688
|
+
updatedAt: new Date(),
|
|
11689
|
+
},
|
|
11690
|
+
})
|
|
11691
|
+
.returning({
|
|
11692
|
+
allowedProviders: schema.workspaceModelPolicies.allowedProviders,
|
|
11693
|
+
allowedModels: schema.workspaceModelPolicies.allowedModels,
|
|
11694
|
+
});
|
|
11695
|
+
return row!;
|
|
11696
|
+
},
|
|
11697
|
+
);
|
|
11698
|
+
}
|
|
11699
|
+
|
|
11700
|
+
/** The per-workspace rotation/active-pointer row (null when none exists yet). */
|
|
11701
|
+
export async function getCodexRotationSettings(
|
|
11702
|
+
db: Database,
|
|
11703
|
+
workspaceId: string,
|
|
11704
|
+
): Promise<CodexRotationSettings | null> {
|
|
11705
|
+
return await withWorkspaceRls(db, workspaceId, async (scopedDb) => {
|
|
11706
|
+
const [row] = await scopedDb
|
|
11707
|
+
.select({
|
|
11708
|
+
activeCredentialId: schema.codexRotationSettings.activeCredentialId,
|
|
11709
|
+
rotationEnabled: schema.codexRotationSettings.rotationEnabled,
|
|
11710
|
+
leaseRotationEnabled: schema.codexRotationSettings.leaseRotationEnabled,
|
|
11711
|
+
rotationStrategy: schema.codexRotationSettings.rotationStrategy,
|
|
10663
11712
|
})
|
|
10664
11713
|
.from(schema.codexRotationSettings)
|
|
10665
11714
|
.where(eq(schema.codexRotationSettings.workspaceId, workspaceId))
|
|
@@ -11111,13 +12160,58 @@ export async function disconnectCodexAccount(
|
|
|
11111
12160
|
db: Database,
|
|
11112
12161
|
workspaceId: string,
|
|
11113
12162
|
credentialId: string,
|
|
11114
|
-
): Promise<{
|
|
12163
|
+
): Promise<{
|
|
12164
|
+
removed: boolean;
|
|
12165
|
+
newActiveCredentialId: string | null;
|
|
12166
|
+
blockedByUnresolvedRedemption: boolean;
|
|
12167
|
+
}> {
|
|
11115
12168
|
return await withWorkspaceRls(db, workspaceId, async (scopedDb) => {
|
|
11116
12169
|
await scopedDb.execute(sql`
|
|
11117
12170
|
select id from codex_rotation_settings
|
|
11118
12171
|
where workspace_id = ${workspaceId}
|
|
11119
12172
|
for update
|
|
11120
12173
|
`);
|
|
12174
|
+
const [credential] = await scopedDb
|
|
12175
|
+
.select({ id: schema.codexSubscriptionCredentials.id })
|
|
12176
|
+
.from(schema.codexSubscriptionCredentials)
|
|
12177
|
+
.where(
|
|
12178
|
+
and(
|
|
12179
|
+
eq(schema.codexSubscriptionCredentials.id, credentialId),
|
|
12180
|
+
eq(schema.codexSubscriptionCredentials.workspaceId, workspaceId),
|
|
12181
|
+
),
|
|
12182
|
+
)
|
|
12183
|
+
.for("update")
|
|
12184
|
+
.limit(1);
|
|
12185
|
+
const [settingsBefore] = await scopedDb
|
|
12186
|
+
.select({ activeCredentialId: schema.codexRotationSettings.activeCredentialId })
|
|
12187
|
+
.from(schema.codexRotationSettings)
|
|
12188
|
+
.where(eq(schema.codexRotationSettings.workspaceId, workspaceId))
|
|
12189
|
+
.limit(1);
|
|
12190
|
+
if (!credential) {
|
|
12191
|
+
return {
|
|
12192
|
+
removed: false,
|
|
12193
|
+
newActiveCredentialId: settingsBefore?.activeCredentialId ?? null,
|
|
12194
|
+
blockedByUnresolvedRedemption: false,
|
|
12195
|
+
};
|
|
12196
|
+
}
|
|
12197
|
+
const [unresolved] = await scopedDb
|
|
12198
|
+
.select({ id: schema.codexResetRedemptionAttempts.id })
|
|
12199
|
+
.from(schema.codexResetRedemptionAttempts)
|
|
12200
|
+
.where(
|
|
12201
|
+
and(
|
|
12202
|
+
eq(schema.codexResetRedemptionAttempts.workspaceId, workspaceId),
|
|
12203
|
+
eq(schema.codexResetRedemptionAttempts.credentialId, credentialId),
|
|
12204
|
+
eq(schema.codexResetRedemptionAttempts.status, "provider_started"),
|
|
12205
|
+
),
|
|
12206
|
+
)
|
|
12207
|
+
.limit(1);
|
|
12208
|
+
if (unresolved) {
|
|
12209
|
+
return {
|
|
12210
|
+
removed: false,
|
|
12211
|
+
newActiveCredentialId: settingsBefore?.activeCredentialId ?? null,
|
|
12212
|
+
blockedByUnresolvedRedemption: true,
|
|
12213
|
+
};
|
|
12214
|
+
}
|
|
11121
12215
|
const removedRows = await scopedDb
|
|
11122
12216
|
.delete(schema.codexSubscriptionCredentials)
|
|
11123
12217
|
.where(
|
|
@@ -11139,6 +12233,7 @@ export async function disconnectCodexAccount(
|
|
|
11139
12233
|
return {
|
|
11140
12234
|
removed: false,
|
|
11141
12235
|
newActiveCredentialId: settingsRow?.activeCredentialId ?? null,
|
|
12236
|
+
blockedByUnresolvedRedemption: false,
|
|
11142
12237
|
};
|
|
11143
12238
|
}
|
|
11144
12239
|
let newActive = settingsRow?.activeCredentialId ?? null;
|
|
@@ -11157,21 +12252,47 @@ export async function disconnectCodexAccount(
|
|
|
11157
12252
|
.where(eq(schema.codexRotationSettings.workspaceId, workspaceId));
|
|
11158
12253
|
}
|
|
11159
12254
|
}
|
|
11160
|
-
return {
|
|
12255
|
+
return {
|
|
12256
|
+
removed: true,
|
|
12257
|
+
newActiveCredentialId: newActive,
|
|
12258
|
+
blockedByUnresolvedRedemption: false,
|
|
12259
|
+
};
|
|
11161
12260
|
});
|
|
11162
12261
|
}
|
|
11163
12262
|
|
|
11164
|
-
/** Legacy "disconnect all"
|
|
12263
|
+
/** Legacy "disconnect all"; atomically rejects when any provider work is unresolved. */
|
|
11165
12264
|
export async function disconnectAllCodexAccounts(
|
|
11166
12265
|
db: Database,
|
|
11167
12266
|
workspaceId: string,
|
|
11168
|
-
): Promise<number> {
|
|
12267
|
+
): Promise<{ removed: number; blockedCredentialIds: string[] }> {
|
|
11169
12268
|
return await withWorkspaceRls(db, workspaceId, async (scopedDb) => {
|
|
12269
|
+
const credentials = await scopedDb
|
|
12270
|
+
.select({ id: schema.codexSubscriptionCredentials.id })
|
|
12271
|
+
.from(schema.codexSubscriptionCredentials)
|
|
12272
|
+
.where(eq(schema.codexSubscriptionCredentials.workspaceId, workspaceId))
|
|
12273
|
+
.orderBy(asc(schema.codexSubscriptionCredentials.id))
|
|
12274
|
+
.for("update");
|
|
12275
|
+
if (credentials.length === 0) return { removed: 0, blockedCredentialIds: [] };
|
|
12276
|
+
const blocked = await scopedDb
|
|
12277
|
+
.selectDistinct({ credentialId: schema.codexResetRedemptionAttempts.credentialId })
|
|
12278
|
+
.from(schema.codexResetRedemptionAttempts)
|
|
12279
|
+
.where(
|
|
12280
|
+
and(
|
|
12281
|
+
eq(schema.codexResetRedemptionAttempts.workspaceId, workspaceId),
|
|
12282
|
+
eq(schema.codexResetRedemptionAttempts.status, "provider_started"),
|
|
12283
|
+
),
|
|
12284
|
+
);
|
|
12285
|
+
if (blocked.length > 0) {
|
|
12286
|
+
return {
|
|
12287
|
+
removed: 0,
|
|
12288
|
+
blockedCredentialIds: blocked.map((row) => row.credentialId).sort(),
|
|
12289
|
+
};
|
|
12290
|
+
}
|
|
11170
12291
|
const rows = await scopedDb
|
|
11171
12292
|
.delete(schema.codexSubscriptionCredentials)
|
|
11172
12293
|
.where(eq(schema.codexSubscriptionCredentials.workspaceId, workspaceId))
|
|
11173
12294
|
.returning({ id: schema.codexSubscriptionCredentials.id });
|
|
11174
|
-
return rows.length;
|
|
12295
|
+
return { removed: rows.length, blockedCredentialIds: [] };
|
|
11175
12296
|
});
|
|
11176
12297
|
}
|
|
11177
12298
|
|
|
@@ -11268,6 +12389,7 @@ function mapSessionMcpServerMetadata(
|
|
|
11268
12389
|
url: row.url,
|
|
11269
12390
|
headerNames: Object.keys(row.headersEncrypted ?? {}).sort(),
|
|
11270
12391
|
credentialVersion: Number(row.credentialVersion),
|
|
12392
|
+
requireApproval: row.requireApproval ?? false,
|
|
11271
12393
|
connectionRef: row.connectionRef ?? null,
|
|
11272
12394
|
};
|
|
11273
12395
|
}
|
|
@@ -11453,13 +12575,98 @@ async function updateSessionMcpServerCredentialsInTransaction(
|
|
|
11453
12575
|
return { servers, missingIds };
|
|
11454
12576
|
}
|
|
11455
12577
|
|
|
12578
|
+
export async function updateSessionMcpApprovalPolicy(
|
|
12579
|
+
db: Database,
|
|
12580
|
+
input: {
|
|
12581
|
+
workspaceId: string;
|
|
12582
|
+
sessionId: string;
|
|
12583
|
+
serverId: string;
|
|
12584
|
+
requireApproval: SessionMcpApprovalPolicy;
|
|
12585
|
+
},
|
|
12586
|
+
): Promise<UpdateSessionMcpApprovalPolicyResult> {
|
|
12587
|
+
return await withWorkspaceRls(
|
|
12588
|
+
db,
|
|
12589
|
+
input.workspaceId,
|
|
12590
|
+
async (scopedDb) =>
|
|
12591
|
+
await scopedDb.transaction(
|
|
12592
|
+
async (tx) => await updateSessionMcpApprovalPolicyInTransaction(tx, input),
|
|
12593
|
+
),
|
|
12594
|
+
);
|
|
12595
|
+
}
|
|
12596
|
+
|
|
12597
|
+
async function updateSessionMcpApprovalPolicyInTransaction(
|
|
12598
|
+
tx: Pick<Database, "select" | "update">,
|
|
12599
|
+
input: {
|
|
12600
|
+
workspaceId: string;
|
|
12601
|
+
sessionId: string;
|
|
12602
|
+
serverId: string;
|
|
12603
|
+
requireApproval: SessionMcpApprovalPolicy;
|
|
12604
|
+
},
|
|
12605
|
+
): Promise<UpdateSessionMcpApprovalPolicyResult> {
|
|
12606
|
+
const [existing] = await tx
|
|
12607
|
+
.select()
|
|
12608
|
+
.from(schema.sessionMcpServers)
|
|
12609
|
+
.where(
|
|
12610
|
+
and(
|
|
12611
|
+
eq(schema.sessionMcpServers.workspaceId, input.workspaceId),
|
|
12612
|
+
eq(schema.sessionMcpServers.sessionId, input.sessionId),
|
|
12613
|
+
eq(schema.sessionMcpServers.serverId, input.serverId),
|
|
12614
|
+
),
|
|
12615
|
+
)
|
|
12616
|
+
.for("update")
|
|
12617
|
+
.limit(1);
|
|
12618
|
+
if (!existing) {
|
|
12619
|
+
return { server: null, changed: false };
|
|
12620
|
+
}
|
|
12621
|
+
const current = existing.requireApproval ?? false;
|
|
12622
|
+
if (JSON.stringify(current) === JSON.stringify(input.requireApproval)) {
|
|
12623
|
+
return { server: mapSessionMcpServerMetadata(existing), changed: false };
|
|
12624
|
+
}
|
|
12625
|
+
const [updated] = await tx
|
|
12626
|
+
.update(schema.sessionMcpServers)
|
|
12627
|
+
.set({
|
|
12628
|
+
requireApproval: input.requireApproval,
|
|
12629
|
+
updatedAt: new Date(),
|
|
12630
|
+
})
|
|
12631
|
+
.where(
|
|
12632
|
+
and(
|
|
12633
|
+
eq(schema.sessionMcpServers.workspaceId, input.workspaceId),
|
|
12634
|
+
eq(schema.sessionMcpServers.sessionId, input.sessionId),
|
|
12635
|
+
eq(schema.sessionMcpServers.serverId, input.serverId),
|
|
12636
|
+
),
|
|
12637
|
+
)
|
|
12638
|
+
.returning();
|
|
12639
|
+
if (!updated) {
|
|
12640
|
+
throw new Error(`Session MCP server disappeared during policy update: ${input.serverId}`);
|
|
12641
|
+
}
|
|
12642
|
+
return { server: mapSessionMcpServerMetadata(updated), changed: true };
|
|
12643
|
+
}
|
|
12644
|
+
|
|
11456
12645
|
export async function listSessionMcpServersForRun(
|
|
11457
12646
|
db: Database,
|
|
11458
12647
|
workspaceId: string,
|
|
11459
12648
|
sessionId: string,
|
|
12649
|
+
attemptId: string,
|
|
11460
12650
|
encryptionKey: Uint8Array | null,
|
|
11461
12651
|
): Promise<SessionMcpServerForRun[]> {
|
|
11462
12652
|
return await withWorkspaceRls(db, workspaceId, async (scopedDb) => {
|
|
12653
|
+
const [attempt] = await scopedDb
|
|
12654
|
+
.select({
|
|
12655
|
+
sessionId: schema.sessionTurnAttempts.sessionId,
|
|
12656
|
+
mcpApprovalPolicies: schema.sessionTurnAttempts.mcpApprovalPolicies,
|
|
12657
|
+
})
|
|
12658
|
+
.from(schema.sessionTurnAttempts)
|
|
12659
|
+
.where(
|
|
12660
|
+
and(
|
|
12661
|
+
eq(schema.sessionTurnAttempts.workspaceId, workspaceId),
|
|
12662
|
+
eq(schema.sessionTurnAttempts.id, attemptId),
|
|
12663
|
+
inArray(schema.sessionTurnAttempts.state, ["claimed", "running"]),
|
|
12664
|
+
),
|
|
12665
|
+
)
|
|
12666
|
+
.limit(1);
|
|
12667
|
+
if (!attempt || attempt.sessionId !== sessionId) {
|
|
12668
|
+
throw new Error(`session MCP policy snapshot is unavailable for attempt ${attemptId}`);
|
|
12669
|
+
}
|
|
11463
12670
|
const rows = await scopedDb
|
|
11464
12671
|
.select()
|
|
11465
12672
|
.from(schema.sessionMcpServers)
|
|
@@ -11471,6 +12678,11 @@ export async function listSessionMcpServersForRun(
|
|
|
11471
12678
|
)
|
|
11472
12679
|
.orderBy(asc(schema.sessionMcpServers.createdAt), asc(schema.sessionMcpServers.serverId));
|
|
11473
12680
|
return rows.map((row) => {
|
|
12681
|
+
if (!Object.hasOwn(attempt.mcpApprovalPolicies, row.serverId)) {
|
|
12682
|
+
throw new Error(
|
|
12683
|
+
`session MCP policy snapshot is missing server ${row.serverId} for attempt ${attemptId}`,
|
|
12684
|
+
);
|
|
12685
|
+
}
|
|
11474
12686
|
let headers: Record<string, string>;
|
|
11475
12687
|
try {
|
|
11476
12688
|
if (!encryptionKey && Object.keys(row.headersEncrypted ?? {}).length > 0) {
|
|
@@ -11490,7 +12702,7 @@ export async function listSessionMcpServersForRun(
|
|
|
11490
12702
|
...(row.allowedTools ? { allowedTools: row.allowedTools } : {}),
|
|
11491
12703
|
...(row.timeoutMs ? { timeoutMs: row.timeoutMs } : {}),
|
|
11492
12704
|
...(row.cacheToolsList ? { cacheToolsList: row.cacheToolsList } : {}),
|
|
11493
|
-
|
|
12705
|
+
requireApproval: attempt.mcpApprovalPolicies[row.serverId]!,
|
|
11494
12706
|
headers,
|
|
11495
12707
|
};
|
|
11496
12708
|
});
|
|
@@ -11558,6 +12770,7 @@ export async function createSession(
|
|
|
11558
12770
|
initialTurnInstructions?: string | null;
|
|
11559
12771
|
resources: ResourceRef[];
|
|
11560
12772
|
tools?: ToolRef[];
|
|
12773
|
+
toolPolicy?: SessionToolPolicy | null;
|
|
11561
12774
|
metadata: Record<string, unknown>;
|
|
11562
12775
|
/**
|
|
11563
12776
|
* Frozen creator authority. Legacy/test-only callers may omit this and get
|
|
@@ -11608,6 +12821,7 @@ export async function createSession(
|
|
|
11608
12821
|
initialTurnInstructions: input.initialTurnInstructions ?? null,
|
|
11609
12822
|
resources: input.resources,
|
|
11610
12823
|
tools: input.tools ?? [],
|
|
12824
|
+
toolPolicy: input.toolPolicy ?? null,
|
|
11611
12825
|
metadata: input.metadata,
|
|
11612
12826
|
...creatorColumns(frozenCreator),
|
|
11613
12827
|
model: input.model,
|
|
@@ -11662,6 +12876,7 @@ export async function createSessionWithIdempotencyKey(
|
|
|
11662
12876
|
initialTurnInstructions?: string | null;
|
|
11663
12877
|
resources: ResourceRef[];
|
|
11664
12878
|
tools?: ToolRef[];
|
|
12879
|
+
toolPolicy?: SessionToolPolicy | null;
|
|
11665
12880
|
metadata: Record<string, unknown>;
|
|
11666
12881
|
createdBy?: TurnInitiator;
|
|
11667
12882
|
createdByContext?: TurnInitiatorContext;
|
|
@@ -11705,6 +12920,7 @@ export async function createSessionWithIdempotencyKey(
|
|
|
11705
12920
|
initialTurnInstructions: input.initialTurnInstructions ?? null,
|
|
11706
12921
|
resources: input.resources,
|
|
11707
12922
|
tools: input.tools ?? [],
|
|
12923
|
+
toolPolicy: input.toolPolicy ?? null,
|
|
11708
12924
|
metadata: input.metadata,
|
|
11709
12925
|
...creatorColumns(frozenCreator),
|
|
11710
12926
|
model: input.model,
|
|
@@ -13595,6 +14811,14 @@ export type ListSessionEventsOptions = {
|
|
|
13595
14811
|
excludeClasses?: readonly SessionEventSemanticClass[];
|
|
13596
14812
|
defaultExcludeTypes?: readonly SessionEventType[];
|
|
13597
14813
|
payloadMode?: SessionEventPayloadMode;
|
|
14814
|
+
/**
|
|
14815
|
+
* Internal exclusive-latest selector. Eligible legacy rows with a null
|
|
14816
|
+
* association remain visible, while late-rejected and duplicate callbacks do
|
|
14817
|
+
* not compete with current truth. Durable session sequence is authoritative
|
|
14818
|
+
* across distinct turns; turn generation is metadata only here. Callers must
|
|
14819
|
+
* pair this with a single semantic class and limit 1.
|
|
14820
|
+
*/
|
|
14821
|
+
authoritativeLatest?: boolean;
|
|
13598
14822
|
};
|
|
13599
14823
|
|
|
13600
14824
|
export type ListSessionEventPageOptions = ListSessionEventsOptions & {
|
|
@@ -13691,6 +14915,20 @@ export async function listSessionEventPage(
|
|
|
13691
14915
|
eq(schema.sessionEvents.sessionId, sessionId),
|
|
13692
14916
|
gt(schema.sessionEvents.sequence, after),
|
|
13693
14917
|
];
|
|
14918
|
+
if (options.authoritativeLatest) {
|
|
14919
|
+
// Historical rows predate association stamping and intentionally carry
|
|
14920
|
+
// null. They remain eligible; explicitly stale/duplicate rows and rows
|
|
14921
|
+
// carrying a duplicate reference cannot compete with current truth.
|
|
14922
|
+
filters.push(
|
|
14923
|
+
and(
|
|
14924
|
+
or(
|
|
14925
|
+
isNull(schema.sessionEvents.turnAssociation),
|
|
14926
|
+
eq(schema.sessionEvents.turnAssociation, "current"),
|
|
14927
|
+
)!,
|
|
14928
|
+
isNull(schema.sessionEvents.duplicateOfEventId),
|
|
14929
|
+
)!,
|
|
14930
|
+
);
|
|
14931
|
+
}
|
|
13694
14932
|
if (typeFilters.includeTypes.length > 0) {
|
|
13695
14933
|
filters.push(inArray(schema.sessionEvents.type, typeFilters.includeTypes));
|
|
13696
14934
|
}
|
|
@@ -13713,9 +14951,11 @@ export async function listSessionEventPage(
|
|
|
13713
14951
|
.from(schema.sessionEvents)
|
|
13714
14952
|
.where(and(...filters))
|
|
13715
14953
|
.orderBy(
|
|
13716
|
-
|
|
14954
|
+
options.authoritativeLatest
|
|
13717
14955
|
? desc(schema.sessionEvents.sequence)
|
|
13718
|
-
:
|
|
14956
|
+
: direction === "before"
|
|
14957
|
+
? desc(schema.sessionEvents.sequence)
|
|
14958
|
+
: asc(schema.sessionEvents.sequence),
|
|
13719
14959
|
)
|
|
13720
14960
|
.limit(queryLimit);
|
|
13721
14961
|
if (rows.length === 0) break;
|
|
@@ -13956,7 +15196,42 @@ export async function listSessionEvents(
|
|
|
13956
15196
|
});
|
|
13957
15197
|
}
|
|
13958
15198
|
|
|
13959
|
-
export type ToolspaceCallReservation =
|
|
15199
|
+
export type ToolspaceCallReservation =
|
|
15200
|
+
| { reserved: true; count: number; turn: SessionTurnForExecution }
|
|
15201
|
+
| { reserved: false; reason: TurnAttemptFenceRejectReason | "budget_exhausted" };
|
|
15202
|
+
|
|
15203
|
+
export type ToolspaceTurnAttemptClaims = {
|
|
15204
|
+
sessionId: string;
|
|
15205
|
+
turnId: string;
|
|
15206
|
+
attemptId: string;
|
|
15207
|
+
executionGeneration: number;
|
|
15208
|
+
};
|
|
15209
|
+
|
|
15210
|
+
/**
|
|
15211
|
+
* Admit one exact Toolspace bearer before any session credential is decrypted or
|
|
15212
|
+
* any upstream schema is enumerated. This intentionally reuses the canonical
|
|
15213
|
+
* activity write fence so Pause/Steer, attempt replacement, generation changes,
|
|
15214
|
+
* and terminal settlement revoke a copied token at the same linearization point
|
|
15215
|
+
* as other attempt-owned writes.
|
|
15216
|
+
*/
|
|
15217
|
+
export async function admitToolspaceTurnAttempt(
|
|
15218
|
+
db: Database,
|
|
15219
|
+
workspaceId: string,
|
|
15220
|
+
claims: ToolspaceTurnAttemptClaims,
|
|
15221
|
+
): Promise<boolean> {
|
|
15222
|
+
return await withWorkspaceRls(db, workspaceId, async (scopedDb) => {
|
|
15223
|
+
return await scopedDb.transaction(async (tx) => {
|
|
15224
|
+
const fence = await lockTurnAttemptWriteFenceTx(tx, {
|
|
15225
|
+
workspaceId,
|
|
15226
|
+
sessionId: claims.sessionId,
|
|
15227
|
+
turnId: claims.turnId,
|
|
15228
|
+
attemptId: claims.attemptId,
|
|
15229
|
+
executionGeneration: claims.executionGeneration,
|
|
15230
|
+
});
|
|
15231
|
+
return fence.allowed && fence.turn.status === "running";
|
|
15232
|
+
});
|
|
15233
|
+
});
|
|
15234
|
+
}
|
|
13960
15235
|
|
|
13961
15236
|
/**
|
|
13962
15237
|
* Atomically reserve one toolspace call against a turn's per-turn budget.
|
|
@@ -13965,33 +15240,73 @@ export type ToolspaceCallReservation = { reserved: true; count: number } | { res
|
|
|
13965
15240
|
* below `limit` and returns the post-increment value. Concurrent reservations
|
|
13966
15241
|
* for the same turn serialize on the row lock, so exactly `limit` of N
|
|
13967
15242
|
* simultaneous callers observe `reserved: true` — closing the read-then-append
|
|
13968
|
-
* TOCTOU the event-count approach had.
|
|
13969
|
-
*
|
|
15243
|
+
* TOCTOU the event-count approach had. The returned attempt id is captured by
|
|
15244
|
+
* that same UPDATE, so callers cannot accidentally execute under a successor
|
|
15245
|
+
* attempt. `reserved: false` means the turn is not executable, at/over budget,
|
|
15246
|
+
* or no longer exists.
|
|
13970
15247
|
*/
|
|
13971
|
-
export async function
|
|
15248
|
+
export async function reserveToolspaceCallForAttempt(
|
|
13972
15249
|
db: Database,
|
|
13973
|
-
|
|
13974
|
-
|
|
13975
|
-
|
|
13976
|
-
|
|
15250
|
+
input: {
|
|
15251
|
+
accountId: string;
|
|
15252
|
+
workspaceId: string;
|
|
15253
|
+
sessionId: string;
|
|
15254
|
+
turnId: string;
|
|
15255
|
+
executionGeneration: number;
|
|
15256
|
+
attemptId: string;
|
|
15257
|
+
limit: number;
|
|
15258
|
+
},
|
|
13977
15259
|
): Promise<ToolspaceCallReservation> {
|
|
13978
|
-
return await
|
|
13979
|
-
|
|
13980
|
-
|
|
13981
|
-
|
|
13982
|
-
|
|
13983
|
-
|
|
13984
|
-
|
|
13985
|
-
|
|
13986
|
-
|
|
13987
|
-
|
|
13988
|
-
|
|
13989
|
-
|
|
13990
|
-
)
|
|
13991
|
-
|
|
13992
|
-
|
|
13993
|
-
|
|
13994
|
-
|
|
15260
|
+
return await withRlsContext(
|
|
15261
|
+
db,
|
|
15262
|
+
{ accountId: input.accountId, workspaceId: input.workspaceId },
|
|
15263
|
+
async (scopedDb) =>
|
|
15264
|
+
await scopedDb.transaction(async (tx) => {
|
|
15265
|
+
const fence = await lockTurnAttemptWriteFenceTx(tx as unknown as Database, {
|
|
15266
|
+
workspaceId: input.workspaceId,
|
|
15267
|
+
sessionId: input.sessionId,
|
|
15268
|
+
turnId: input.turnId,
|
|
15269
|
+
executionGeneration: input.executionGeneration,
|
|
15270
|
+
attemptId: input.attemptId,
|
|
15271
|
+
});
|
|
15272
|
+
if (!fence.allowed) {
|
|
15273
|
+
return { reserved: false, reason: fence.reason };
|
|
15274
|
+
}
|
|
15275
|
+
if (fence.turn.status !== "running") {
|
|
15276
|
+
return { reserved: false, reason: "turn_terminal" };
|
|
15277
|
+
}
|
|
15278
|
+
if (Number(fence.turn.toolspaceCallCount) >= input.limit) {
|
|
15279
|
+
return { reserved: false, reason: "budget_exhausted" };
|
|
15280
|
+
}
|
|
15281
|
+
const [row] = await tx
|
|
15282
|
+
.update(schema.sessionTurns)
|
|
15283
|
+
.set({
|
|
15284
|
+
toolspaceCallCount: sql`${schema.sessionTurns.toolspaceCallCount} + 1`,
|
|
15285
|
+
})
|
|
15286
|
+
.where(
|
|
15287
|
+
and(
|
|
15288
|
+
eq(schema.sessionTurns.workspaceId, input.workspaceId),
|
|
15289
|
+
eq(schema.sessionTurns.sessionId, input.sessionId),
|
|
15290
|
+
eq(schema.sessionTurns.id, input.turnId),
|
|
15291
|
+
eq(schema.sessionTurns.executionGeneration, input.executionGeneration),
|
|
15292
|
+
eq(schema.sessionTurns.activeAttemptId, input.attemptId),
|
|
15293
|
+
sql`${schema.sessionTurns.toolspaceCallCount} < ${input.limit}`,
|
|
15294
|
+
),
|
|
15295
|
+
)
|
|
15296
|
+
.returning({ count: schema.sessionTurns.toolspaceCallCount });
|
|
15297
|
+
if (!row) {
|
|
15298
|
+
throw new Error("Toolspace call reservation lost its locked turn");
|
|
15299
|
+
}
|
|
15300
|
+
return {
|
|
15301
|
+
reserved: true,
|
|
15302
|
+
count: Number(row.count),
|
|
15303
|
+
turn: mapSessionTurnForExecution({
|
|
15304
|
+
...fence.turn,
|
|
15305
|
+
toolspaceCallCount: Number(row.count),
|
|
15306
|
+
}),
|
|
15307
|
+
};
|
|
15308
|
+
}),
|
|
15309
|
+
);
|
|
13995
15310
|
}
|
|
13996
15311
|
|
|
13997
15312
|
function normalizeEventSequence(value: number | undefined, fallback: number): number {
|
|
@@ -14809,6 +16124,93 @@ async function lockTurnAttemptWriteFenceTx(
|
|
|
14809
16124
|
return { allowed: true, workspace, session, turn, attempt };
|
|
14810
16125
|
}
|
|
14811
16126
|
|
|
16127
|
+
export type InstallOrReadTurnExecutionPolicyForAttemptResult =
|
|
16128
|
+
| {
|
|
16129
|
+
accepted: true;
|
|
16130
|
+
installed: boolean;
|
|
16131
|
+
policy: TurnExecutionPolicyV1;
|
|
16132
|
+
turn: SessionTurn;
|
|
16133
|
+
}
|
|
16134
|
+
| {
|
|
16135
|
+
accepted: false;
|
|
16136
|
+
reason: TurnAttemptFenceRejectReason;
|
|
16137
|
+
};
|
|
16138
|
+
|
|
16139
|
+
/**
|
|
16140
|
+
* Freeze a legacy turn's execution identity at its first admitted attempt, or
|
|
16141
|
+
* return the policy already accepted for this logical turn. The exact active
|
|
16142
|
+
* attempt owns this mutation; malformed present metadata fails closed and a
|
|
16143
|
+
* replay never replaces an existing valid policy.
|
|
16144
|
+
*/
|
|
16145
|
+
export async function installOrReadTurnExecutionPolicyForAttempt(
|
|
16146
|
+
db: Database,
|
|
16147
|
+
input: {
|
|
16148
|
+
accountId: string;
|
|
16149
|
+
workspaceId: string;
|
|
16150
|
+
sessionId: string;
|
|
16151
|
+
turnId: string;
|
|
16152
|
+
executionGeneration: number;
|
|
16153
|
+
attemptId: string;
|
|
16154
|
+
policyForAbsent: TurnExecutionPolicyV1;
|
|
16155
|
+
},
|
|
16156
|
+
): Promise<InstallOrReadTurnExecutionPolicyForAttemptResult> {
|
|
16157
|
+
return await withRlsContext(
|
|
16158
|
+
db,
|
|
16159
|
+
{ accountId: input.accountId, workspaceId: input.workspaceId },
|
|
16160
|
+
async (scopedDb) =>
|
|
16161
|
+
await scopedDb.transaction(async (tx) => {
|
|
16162
|
+
const fence = await lockTurnAttemptWriteFenceTx(tx, {
|
|
16163
|
+
workspaceId: input.workspaceId,
|
|
16164
|
+
sessionId: input.sessionId,
|
|
16165
|
+
turnId: input.turnId,
|
|
16166
|
+
executionGeneration: input.executionGeneration,
|
|
16167
|
+
attemptId: input.attemptId,
|
|
16168
|
+
});
|
|
16169
|
+
if (!fence.allowed) {
|
|
16170
|
+
return { accepted: false as const, reason: fence.reason };
|
|
16171
|
+
}
|
|
16172
|
+
|
|
16173
|
+
const existing = readTurnExecutionPolicyV1(fence.turn.metadata);
|
|
16174
|
+
if (existing.kind === "valid") {
|
|
16175
|
+
return {
|
|
16176
|
+
accepted: true as const,
|
|
16177
|
+
installed: false,
|
|
16178
|
+
policy: existing.policy,
|
|
16179
|
+
turn: mapSessionTurn(fence.turn),
|
|
16180
|
+
};
|
|
16181
|
+
}
|
|
16182
|
+
|
|
16183
|
+
const policy = TurnExecutionPolicyV1.parse(input.policyForAbsent);
|
|
16184
|
+
const [updated] = await tx
|
|
16185
|
+
.update(schema.sessionTurns)
|
|
16186
|
+
.set({
|
|
16187
|
+
metadata: metadataWithTurnExecutionPolicyV1(fence.turn.metadata, policy),
|
|
16188
|
+
updatedAt: new Date(),
|
|
16189
|
+
})
|
|
16190
|
+
.where(
|
|
16191
|
+
and(
|
|
16192
|
+
eq(schema.sessionTurns.workspaceId, input.workspaceId),
|
|
16193
|
+
eq(schema.sessionTurns.sessionId, input.sessionId),
|
|
16194
|
+
eq(schema.sessionTurns.id, input.turnId),
|
|
16195
|
+
eq(schema.sessionTurns.status, "running"),
|
|
16196
|
+
eq(schema.sessionTurns.executionGeneration, input.executionGeneration),
|
|
16197
|
+
eq(schema.sessionTurns.activeAttemptId, input.attemptId),
|
|
16198
|
+
),
|
|
16199
|
+
)
|
|
16200
|
+
.returning();
|
|
16201
|
+
if (!updated) {
|
|
16202
|
+
throw new Error("Turn execution policy owner changed while its row was locked");
|
|
16203
|
+
}
|
|
16204
|
+
return {
|
|
16205
|
+
accepted: true as const,
|
|
16206
|
+
installed: true,
|
|
16207
|
+
policy,
|
|
16208
|
+
turn: mapSessionTurn(updated),
|
|
16209
|
+
};
|
|
16210
|
+
}),
|
|
16211
|
+
);
|
|
16212
|
+
}
|
|
16213
|
+
|
|
14812
16214
|
/**
|
|
14813
16215
|
* Append conversation items (verbatim SDK AgentInputItems) to the session's
|
|
14814
16216
|
* history. Idempotent on (workspace, session, position): concurrent or
|
|
@@ -14941,6 +16343,47 @@ export async function registerPendingSessionToolCall(
|
|
|
14941
16343
|
);
|
|
14942
16344
|
}
|
|
14943
16345
|
|
|
16346
|
+
/**
|
|
16347
|
+
* Clear one completed Toolspace receipt after its attempt-fenced output event is
|
|
16348
|
+
* durable. If control already replaced the attempt, its settlement transaction
|
|
16349
|
+
* owns the receipt and will retain an explicit outcome-unknown or the durable
|
|
16350
|
+
* output event instead.
|
|
16351
|
+
*/
|
|
16352
|
+
export async function clearPendingSessionToolspaceCall(
|
|
16353
|
+
db: Database,
|
|
16354
|
+
input: Omit<PendingSessionToolCallInput, "callType" | "callItem">,
|
|
16355
|
+
): Promise<{ accepted: boolean; cleared: boolean }> {
|
|
16356
|
+
return await withRlsContext(
|
|
16357
|
+
db,
|
|
16358
|
+
{ accountId: input.accountId, workspaceId: input.workspaceId },
|
|
16359
|
+
async (scopedDb) =>
|
|
16360
|
+
await scopedDb.transaction(async (tx) => {
|
|
16361
|
+
const fence = await lockTurnAttemptWriteFenceTx(tx as unknown as Database, {
|
|
16362
|
+
workspaceId: input.workspaceId,
|
|
16363
|
+
sessionId: input.sessionId,
|
|
16364
|
+
turnId: input.turnId,
|
|
16365
|
+
executionGeneration: input.executionGeneration,
|
|
16366
|
+
attemptId: input.attemptId,
|
|
16367
|
+
});
|
|
16368
|
+
if (!fence.allowed) return { accepted: false, cleared: false };
|
|
16369
|
+
const deleted = await tx
|
|
16370
|
+
.delete(schema.sessionPendingToolCalls)
|
|
16371
|
+
.where(
|
|
16372
|
+
and(
|
|
16373
|
+
eq(schema.sessionPendingToolCalls.workspaceId, input.workspaceId),
|
|
16374
|
+
eq(schema.sessionPendingToolCalls.sessionId, input.sessionId),
|
|
16375
|
+
eq(schema.sessionPendingToolCalls.turnId, input.turnId),
|
|
16376
|
+
eq(schema.sessionPendingToolCalls.attemptId, input.attemptId),
|
|
16377
|
+
eq(schema.sessionPendingToolCalls.callId, input.callId),
|
|
16378
|
+
eq(schema.sessionPendingToolCalls.callType, "toolspace_call"),
|
|
16379
|
+
),
|
|
16380
|
+
)
|
|
16381
|
+
.returning({ id: schema.sessionPendingToolCalls.id });
|
|
16382
|
+
return { accepted: true, cleared: deleted.length === 1 };
|
|
16383
|
+
}),
|
|
16384
|
+
);
|
|
16385
|
+
}
|
|
16386
|
+
|
|
14944
16387
|
/** Record the raw SDK result without dropping the call receipt. */
|
|
14945
16388
|
export async function recordPendingSessionToolCallResult(
|
|
14946
16389
|
db: Database,
|
|
@@ -16618,8 +18061,9 @@ export async function acquireLease(
|
|
|
16618
18061
|
// (2) Serialize ALL concurrent arrivals on this group's row. Plain FOR
|
|
16619
18062
|
// UPDATE (block, do NOT skip) — unlike concurrent enrollment scans,
|
|
16620
18063
|
// because we WANT the loser to block then attach, not skip and lose.
|
|
16621
|
-
const rows = await tx.execute<LeaseRow>(sql`
|
|
16622
|
-
select
|
|
18064
|
+
const rows = await tx.execute<LeaseRow & { draining_expired: boolean }>(sql`
|
|
18065
|
+
select *, (liveness = 'draining' and expires_at <= now()) as draining_expired
|
|
18066
|
+
from sandbox_leases
|
|
16623
18067
|
where workspace_id = ${workspaceId} and sandbox_group_id = ${sandboxGroupId}
|
|
16624
18068
|
for update
|
|
16625
18069
|
`);
|
|
@@ -16628,6 +18072,16 @@ export async function acquireLease(
|
|
|
16628
18072
|
|
|
16629
18073
|
let liveness = row.liveness;
|
|
16630
18074
|
|
|
18075
|
+
// An expired DRAINING row is owned by the reaper's provider teardown,
|
|
18076
|
+
// not by a new arrival. In particular, do this before image/rig conflict
|
|
18077
|
+
// handling: a conflicting successor must not take the SOLO-recreate path
|
|
18078
|
+
// and clear the attributed instance while the reaper/old creator still
|
|
18079
|
+
// owns its termination. The row remains drainable until confirmDrainCold
|
|
18080
|
+
// settles it, so callers fail closed and retry after the box is cold.
|
|
18081
|
+
if (liveness === "draining" && row.draining_expired) {
|
|
18082
|
+
return { role: "fenced" as const, lease: mapLeaseRow(row) };
|
|
18083
|
+
}
|
|
18084
|
+
|
|
16631
18085
|
// -- SHARED STATE CONFLICT (B3 image + M3 rig): a LIVE box (warm/draining/warming)
|
|
16632
18086
|
// was created under a specific image AND rig version. If this run resolves a
|
|
16633
18087
|
// DIFFERENT image OR a DIFFERENT rig version (each checked only when both sides are
|
|
@@ -16690,7 +18144,9 @@ export async function acquireLease(
|
|
|
16690
18144
|
liveness = "cold";
|
|
16691
18145
|
}
|
|
16692
18146
|
|
|
16693
|
-
// -- draining: late arrival re-arms (D1)
|
|
18147
|
+
// -- draining: late arrival re-arms (D1) only while the grace is open.
|
|
18148
|
+
// An expired row was fenced above because the reaper owns its provider
|
|
18149
|
+
// teardown and must not lose the attributed instance race.
|
|
16694
18150
|
if (liveness === "draining") {
|
|
16695
18151
|
await upsertLeaseHolder(tx, row.id, accountId, workspaceId, kind, holderId, subjectId);
|
|
16696
18152
|
const updated = await recomputeAndStampLease(tx, row.id, input.leaseTtlMs, "warm");
|
|
@@ -16747,8 +18203,11 @@ export async function acquireLease(
|
|
|
16747
18203
|
);
|
|
16748
18204
|
}
|
|
16749
18205
|
|
|
16750
|
-
// §4.2 —
|
|
16751
|
-
//
|
|
18206
|
+
// §4.2 — warm commit and every warming invalidation are epoch-fenced transitions.
|
|
18207
|
+
// CAS on (warming AND lease_epoch=expected) folds the group box-envelope
|
|
18208
|
+
// (resume_backend_id/resume_state) onto the lease. A rollback/reset also bumps
|
|
18209
|
+
// the epoch before exposing cold/draining, permanently fencing any provider
|
|
18210
|
+
// create callback that is still unresolved from the invalidated acquisition.
|
|
16752
18211
|
export async function commitWarmingToWarm(
|
|
16753
18212
|
db: Database,
|
|
16754
18213
|
input: {
|
|
@@ -16935,7 +18394,9 @@ export async function markWarmLeaseInstanceLost(
|
|
|
16935
18394
|
|
|
16936
18395
|
// §4.3 — caught spawn failure: warming -> cold (W3). Holders are intentionally
|
|
16937
18396
|
// left intact — the arrival that triggered the spawn still wants a box, so the
|
|
16938
|
-
// next acquireLease re-CAS cold->warming.
|
|
18397
|
+
// next acquireLease re-CAS cold->warming. The rollback increments the epoch in
|
|
18398
|
+
// the same CAS, so a non-abortable provider create from this invalidated
|
|
18399
|
+
// acquisition can never mutate a successor that reuses the lease row.
|
|
16939
18400
|
//
|
|
16940
18401
|
// ARCHIVE PRESERVATION (sandbox-file-persistence): when the cold lease that was
|
|
16941
18402
|
// selected for re-warm carried a persisted /workspace archive on its resume_state
|
|
@@ -16965,6 +18426,7 @@ export async function failWarmingToCold(
|
|
|
16965
18426
|
update sandbox_leases set
|
|
16966
18427
|
liveness = 'cold', instance_id = null,
|
|
16967
18428
|
data_plane_url = null, terminal_data_plane_url = null, updated_at = now(),
|
|
18429
|
+
lease_epoch = lease_epoch + 1,
|
|
16968
18430
|
resume_state = case
|
|
16969
18431
|
when (resume_state #>> '{sessionState,workspaceArchive}') is not null
|
|
16970
18432
|
then jsonb_build_object(
|
|
@@ -17234,21 +18696,24 @@ export async function reapStaleLeaseHolders(
|
|
|
17234
18696
|
`);
|
|
17235
18697
|
|
|
17236
18698
|
// (c1) WARMING-death before provider create returned: no instance_id was
|
|
17237
|
-
// ever persisted, so there is no provider box to stop. Reset to cold
|
|
17238
|
-
//
|
|
18699
|
+
// ever persisted, so there is no provider box to stop. Reset to cold and
|
|
18700
|
+
// bump the epoch so any late provider callback is fenced before a queued
|
|
18701
|
+
// turn can re-acquire and re-spawn.
|
|
17239
18702
|
const warmingReset = await tx.execute<{ id: string }>(sql`
|
|
17240
18703
|
update sandbox_leases set
|
|
17241
18704
|
liveness = 'cold', instance_id = null,
|
|
17242
18705
|
resume_backend_id = null, resume_state = null,
|
|
17243
|
-
data_plane_url = null, terminal_data_plane_url = null,
|
|
18706
|
+
data_plane_url = null, terminal_data_plane_url = null,
|
|
18707
|
+
lease_epoch = lease_epoch + 1, updated_at = now()
|
|
17244
18708
|
where workspace_id = ${input.workspaceId}
|
|
17245
18709
|
and liveness = 'warming' and expires_at < now() and instance_id is null
|
|
17246
18710
|
returning id
|
|
17247
18711
|
`);
|
|
17248
18712
|
|
|
17249
18713
|
// (c2) WARMING-death after provider create returned: instance_id is known,
|
|
17250
|
-
// so do NOT drop it.
|
|
17251
|
-
// provider terminate path stops the box before the lease
|
|
18714
|
+
// so do NOT drop it. Bump the epoch and convert to immediately-drainable
|
|
18715
|
+
// so the caller's provider terminate path stops the box before the lease
|
|
18716
|
+
// goes cold, while late creator callbacks are fenced.
|
|
17252
18717
|
const warmingDrain = await tx.execute<{ id: string }>(sql`
|
|
17253
18718
|
update sandbox_leases set
|
|
17254
18719
|
liveness = 'draining',
|
|
@@ -17257,6 +18722,7 @@ export async function reapStaleLeaseHolders(
|
|
|
17257
18722
|
viewer_holders = 0,
|
|
17258
18723
|
data_plane_url = null,
|
|
17259
18724
|
terminal_data_plane_url = null,
|
|
18725
|
+
lease_epoch = lease_epoch + 1,
|
|
17260
18726
|
expires_at = now() - interval '1 millisecond',
|
|
17261
18727
|
updated_at = now()
|
|
17262
18728
|
where workspace_id = ${input.workspaceId}
|
|
@@ -17510,6 +18976,7 @@ export async function reArmDrainingLease(
|
|
|
17510
18976
|
updated_at = now()
|
|
17511
18977
|
where workspace_id = ${input.workspaceId} and sandbox_group_id = ${input.sandboxGroupId}
|
|
17512
18978
|
and liveness = 'draining'
|
|
18979
|
+
and expires_at > now()
|
|
17513
18980
|
returning id
|
|
17514
18981
|
`);
|
|
17515
18982
|
return { rearmed: rows.length > 0 };
|
|
@@ -21300,6 +22767,8 @@ export type InitializeSessionStartInput = {
|
|
|
21300
22767
|
sessionId: string;
|
|
21301
22768
|
clientEventId?: string;
|
|
21302
22769
|
reasoningEffortFallback: ReasoningEffort;
|
|
22770
|
+
/** Trusted create-session policy. Omitted only by legacy low-level callers. */
|
|
22771
|
+
turnExecutionPolicy?: TurnExecutionPolicyV1;
|
|
21303
22772
|
createdEventPayload: Record<string, unknown>;
|
|
21304
22773
|
goal?: {
|
|
21305
22774
|
text: string;
|
|
@@ -21506,6 +22975,7 @@ export async function initializeSessionStartAtomically(
|
|
|
21506
22975
|
turnInstructions: session.initialTurnInstructions ?? null,
|
|
21507
22976
|
resources: session.resources,
|
|
21508
22977
|
tools: session.tools,
|
|
22978
|
+
toolsProvided: session.toolPolicy?.mode === "explicit",
|
|
21509
22979
|
model: session.model,
|
|
21510
22980
|
reasoningEffort: reasoningEffortForMetadata(
|
|
21511
22981
|
session.metadata,
|
|
@@ -21513,7 +22983,9 @@ export async function initializeSessionStartAtomically(
|
|
|
21513
22983
|
),
|
|
21514
22984
|
sandboxBackend: session.sandboxBackend,
|
|
21515
22985
|
sandboxOs: session.sandboxOs,
|
|
21516
|
-
metadata:
|
|
22986
|
+
metadata: input.turnExecutionPolicy
|
|
22987
|
+
? metadataWithTurnExecutionPolicyV1({}, input.turnExecutionPolicy)
|
|
22988
|
+
: {},
|
|
21517
22989
|
lineage: {},
|
|
21518
22990
|
...initiatorColumns(creator),
|
|
21519
22991
|
})
|
|
@@ -21653,6 +23125,7 @@ export async function enqueueSessionTurn(
|
|
|
21653
23125
|
turnInstructions: input.turnInstructions ?? null,
|
|
21654
23126
|
resources: input.resources,
|
|
21655
23127
|
tools: input.tools,
|
|
23128
|
+
toolsProvided: input.toolsProvided ?? false,
|
|
21656
23129
|
model: input.model,
|
|
21657
23130
|
reasoningEffort: input.reasoningEffort,
|
|
21658
23131
|
sandboxBackend: input.sandboxBackend,
|
|
@@ -21963,8 +23436,24 @@ export async function claimSessionWorkForAttempt(
|
|
|
21963
23436
|
if (unquiescedInterruption) {
|
|
21964
23437
|
return { action: "unclaimed", reason: "control-pending" };
|
|
21965
23438
|
}
|
|
21966
|
-
const registerAttempt = async (turn: typeof schema.sessionTurns.$inferSelect) =>
|
|
21967
|
-
|
|
23439
|
+
const registerAttempt = async (turn: typeof schema.sessionTurns.$inferSelect) => {
|
|
23440
|
+
const policyRows = await tx
|
|
23441
|
+
.select({
|
|
23442
|
+
serverId: schema.sessionMcpServers.serverId,
|
|
23443
|
+
requireApproval: schema.sessionMcpServers.requireApproval,
|
|
23444
|
+
})
|
|
23445
|
+
.from(schema.sessionMcpServers)
|
|
23446
|
+
.where(
|
|
23447
|
+
and(
|
|
23448
|
+
eq(schema.sessionMcpServers.workspaceId, workspaceId),
|
|
23449
|
+
eq(schema.sessionMcpServers.sessionId, sessionId),
|
|
23450
|
+
),
|
|
23451
|
+
)
|
|
23452
|
+
.orderBy(asc(schema.sessionMcpServers.serverId));
|
|
23453
|
+
const mcpApprovalPolicies: Record<string, SessionMcpApprovalPolicy> = Object.fromEntries(
|
|
23454
|
+
policyRows.map((row) => [row.serverId, row.requireApproval ?? false]),
|
|
23455
|
+
);
|
|
23456
|
+
return await registerSessionTurnAttemptClaim(tx as unknown as Database, {
|
|
21968
23457
|
id: input.attemptId,
|
|
21969
23458
|
accountId: session.accountId,
|
|
21970
23459
|
workspaceId,
|
|
@@ -21975,7 +23464,9 @@ export async function claimSessionWorkForAttempt(
|
|
|
21975
23464
|
temporalWorkflowRunId: input.workflowRunId,
|
|
21976
23465
|
temporalActivityId: input.dispatchId,
|
|
21977
23466
|
verifiedControlRevision: Number(workspaceControl.revision),
|
|
23467
|
+
mcpApprovalPolicies,
|
|
21978
23468
|
});
|
|
23469
|
+
};
|
|
21979
23470
|
if (session.activeTurnId !== null) {
|
|
21980
23471
|
const [activeTurnPreview] = await tx
|
|
21981
23472
|
.select()
|
|
@@ -24483,6 +25974,7 @@ export async function applySessionTurnSettlement(
|
|
|
24483
25974
|
effectiveSessionStatus !== input.sessionStatus
|
|
24484
25975
|
? { ...payload, status: effectiveSessionStatus }
|
|
24485
25976
|
: payload,
|
|
25977
|
+
{ fullEvidence: event.retainedOutputEvidence },
|
|
24486
25978
|
),
|
|
24487
25979
|
clientEventId: event.clientEventId ?? null,
|
|
24488
25980
|
turnId: input.turnId,
|
|
@@ -25570,6 +27062,57 @@ export async function getSessionTurn(
|
|
|
25570
27062
|
});
|
|
25571
27063
|
}
|
|
25572
27064
|
|
|
27065
|
+
/**
|
|
27066
|
+
* Resolve the exact currently executable turn/attempt from authoritative
|
|
27067
|
+
* pointers in one query. This avoids treating a stale session preview as proof
|
|
27068
|
+
* that no attempt exists during an atomic claim transition.
|
|
27069
|
+
*/
|
|
27070
|
+
export async function getActiveSessionTurnForExecution(
|
|
27071
|
+
db: Database,
|
|
27072
|
+
workspaceId: string,
|
|
27073
|
+
sessionId: string,
|
|
27074
|
+
): Promise<SessionTurnForExecution | null> {
|
|
27075
|
+
return await withWorkspaceRls(db, workspaceId, async (scopedDb) => {
|
|
27076
|
+
const [row] = await scopedDb
|
|
27077
|
+
.select({ turn: schema.sessionTurns })
|
|
27078
|
+
.from(schema.sessions)
|
|
27079
|
+
.innerJoin(
|
|
27080
|
+
schema.sessionTurns,
|
|
27081
|
+
and(
|
|
27082
|
+
eq(schema.sessionTurns.workspaceId, schema.sessions.workspaceId),
|
|
27083
|
+
eq(schema.sessionTurns.id, schema.sessions.activeTurnId),
|
|
27084
|
+
),
|
|
27085
|
+
)
|
|
27086
|
+
.innerJoin(
|
|
27087
|
+
schema.sessionTurnAttempts,
|
|
27088
|
+
and(
|
|
27089
|
+
eq(schema.sessionTurnAttempts.workspaceId, schema.sessionTurns.workspaceId),
|
|
27090
|
+
eq(schema.sessionTurnAttempts.id, schema.sessionTurns.activeAttemptId),
|
|
27091
|
+
),
|
|
27092
|
+
)
|
|
27093
|
+
.where(
|
|
27094
|
+
and(
|
|
27095
|
+
eq(schema.sessions.workspaceId, workspaceId),
|
|
27096
|
+
eq(schema.sessions.id, sessionId),
|
|
27097
|
+
eq(schema.sessionTurns.sessionId, sessionId),
|
|
27098
|
+
eq(schema.sessionTurnAttempts.sessionId, sessionId),
|
|
27099
|
+
eq(schema.sessionTurnAttempts.turnId, schema.sessionTurns.id),
|
|
27100
|
+
inArray(schema.sessionTurnAttempts.state, ["claimed", "running"]),
|
|
27101
|
+
inArray(schema.sessionTurns.status, ["running", "recovering", "waiting_capacity"]),
|
|
27102
|
+
sql`not exists (
|
|
27103
|
+
select 1
|
|
27104
|
+
from ${schema.sessionAttemptInterruptions} interruption
|
|
27105
|
+
where interruption.workspace_id = ${workspaceId}
|
|
27106
|
+
and interruption.attempt_id = ${schema.sessionTurnAttempts.id}
|
|
27107
|
+
and interruption.state in ('pending', 'delivered', 'acknowledged')
|
|
27108
|
+
)`,
|
|
27109
|
+
),
|
|
27110
|
+
)
|
|
27111
|
+
.limit(1);
|
|
27112
|
+
return row ? mapSessionTurnForExecution(row.turn) : null;
|
|
27113
|
+
});
|
|
27114
|
+
}
|
|
27115
|
+
|
|
25573
27116
|
export async function getSessionTurnForAttempt(
|
|
25574
27117
|
db: Database,
|
|
25575
27118
|
workspaceId: string,
|
|
@@ -26070,40 +27613,85 @@ export async function claimPendingSessionWorkflowWakes(
|
|
|
26070
27613
|
}
|
|
26071
27614
|
|
|
26072
27615
|
/**
|
|
26073
|
-
* Acknowledge an immediate post-commit signal
|
|
26074
|
-
*
|
|
26075
|
-
*
|
|
27616
|
+
* Acknowledge an immediate post-commit signal only after it cannot strand an
|
|
27617
|
+
* accepted Agent Steer. Temporal accepting a signal is transport evidence, not
|
|
27618
|
+
* proof that a closing workflow observed Postgres or admitted its pending
|
|
27619
|
+
* direction. While active control still has an actionable Agent Steer, retain
|
|
27620
|
+
* the revision so the bounded outbox dispatcher retries signalWithStart. The
|
|
27621
|
+
* attempt-fenced claim consumes the update once; a real Pause is the typed
|
|
27622
|
+
* blocker and may acknowledge this revision because Resume commits a new one.
|
|
27623
|
+
*
|
|
27624
|
+
* An older sender may advance only its own revision; it cannot clear a claim or
|
|
27625
|
+
* failure state belonging to a newer revision. The control -> workspace ->
|
|
27626
|
+
* session lock prefix serializes this decision with Steer, Pause/Resume, and
|
|
27627
|
+
* claim without weakening physical-quiescence admission.
|
|
26076
27628
|
*/
|
|
27629
|
+
export type SessionWorkflowWakeDeliveryResult =
|
|
27630
|
+
| { action: "acknowledged" }
|
|
27631
|
+
| { action: "pending_admission"; blocker: "pending_agent_steer" };
|
|
27632
|
+
|
|
26077
27633
|
export async function markSessionWorkflowWakeDelivered(
|
|
26078
27634
|
db: Database,
|
|
26079
27635
|
input: Omit<SessionWorkflowWake, "interruptionRequested">,
|
|
26080
|
-
): Promise<
|
|
26081
|
-
await withRlsContext(
|
|
27636
|
+
): Promise<SessionWorkflowWakeDeliveryResult> {
|
|
27637
|
+
return await withRlsContext(
|
|
26082
27638
|
db,
|
|
26083
27639
|
{ accountId: input.accountId, workspaceId: input.workspaceId },
|
|
26084
|
-
async (scopedDb) =>
|
|
26085
|
-
|
|
26086
|
-
|
|
26087
|
-
|
|
26088
|
-
|
|
26089
|
-
|
|
26090
|
-
|
|
26091
|
-
|
|
26092
|
-
})
|
|
26093
|
-
|
|
26094
|
-
|
|
26095
|
-
|
|
26096
|
-
|
|
26097
|
-
|
|
26098
|
-
),
|
|
26099
|
-
)
|
|
26100
|
-
.returning({ sessionId: schema.sessionWorkflowWakeOutbox.sessionId });
|
|
26101
|
-
if (!row) {
|
|
26102
|
-
throw new Error(
|
|
26103
|
-
`Workflow wake revision ${input.wakeRevision} is not current for session ${input.sessionId}`,
|
|
27640
|
+
async (scopedDb) =>
|
|
27641
|
+
await scopedDb.transaction(async (tx) => {
|
|
27642
|
+
const locks = await lockSessionEventWriteRows(tx as unknown as Database, {
|
|
27643
|
+
workspaceId: input.workspaceId,
|
|
27644
|
+
controlLock: "share",
|
|
27645
|
+
sessionIds: [input.sessionId],
|
|
27646
|
+
});
|
|
27647
|
+
const session = locks.sessions[0];
|
|
27648
|
+
if (!session) throw new Error(`Session not found: ${input.sessionId}`);
|
|
27649
|
+
const effectiveControl = await evaluateSessionControl(
|
|
27650
|
+
tx as unknown as Database,
|
|
27651
|
+
input.workspaceId,
|
|
27652
|
+
input.sessionId,
|
|
27653
|
+
{ workspaceControl: locks.control ?? undefined },
|
|
26104
27654
|
);
|
|
26105
|
-
|
|
26106
|
-
|
|
27655
|
+
if (effectiveControl.state === "active") {
|
|
27656
|
+
const [pendingAgentSteer] = await tx
|
|
27657
|
+
.select({ id: schema.sessionSystemUpdates.id })
|
|
27658
|
+
.from(schema.sessionSystemUpdates)
|
|
27659
|
+
.where(
|
|
27660
|
+
and(
|
|
27661
|
+
eq(schema.sessionSystemUpdates.workspaceId, input.workspaceId),
|
|
27662
|
+
eq(schema.sessionSystemUpdates.sessionId, input.sessionId),
|
|
27663
|
+
eq(schema.sessionSystemUpdates.kind, "agent_steer_instruction"),
|
|
27664
|
+
eq(schema.sessionSystemUpdates.state, "pending"),
|
|
27665
|
+
),
|
|
27666
|
+
)
|
|
27667
|
+
.limit(1);
|
|
27668
|
+
if (pendingAgentSteer) {
|
|
27669
|
+
return { action: "pending_admission", blocker: "pending_agent_steer" } as const;
|
|
27670
|
+
}
|
|
27671
|
+
}
|
|
27672
|
+
const [row] = await tx
|
|
27673
|
+
.update(schema.sessionWorkflowWakeOutbox)
|
|
27674
|
+
.set({
|
|
27675
|
+
deliveredRevision: sql`greatest(${schema.sessionWorkflowWakeOutbox.deliveredRevision}, ${input.wakeRevision})`,
|
|
27676
|
+
attempts: sql`case when ${schema.sessionWorkflowWakeOutbox.wakeRevision} = ${input.wakeRevision} then 0 else ${schema.sessionWorkflowWakeOutbox.attempts} end`,
|
|
27677
|
+
lastError: sql`case when ${schema.sessionWorkflowWakeOutbox.wakeRevision} = ${input.wakeRevision} then null else ${schema.sessionWorkflowWakeOutbox.lastError} end`,
|
|
27678
|
+
updatedAt: new Date(),
|
|
27679
|
+
})
|
|
27680
|
+
.where(
|
|
27681
|
+
and(
|
|
27682
|
+
eq(schema.sessionWorkflowWakeOutbox.workspaceId, input.workspaceId),
|
|
27683
|
+
eq(schema.sessionWorkflowWakeOutbox.sessionId, input.sessionId),
|
|
27684
|
+
gte(schema.sessionWorkflowWakeOutbox.wakeRevision, input.wakeRevision),
|
|
27685
|
+
),
|
|
27686
|
+
)
|
|
27687
|
+
.returning({ sessionId: schema.sessionWorkflowWakeOutbox.sessionId });
|
|
27688
|
+
if (!row) {
|
|
27689
|
+
throw new Error(
|
|
27690
|
+
`Workflow wake revision ${input.wakeRevision} is not current for session ${input.sessionId}`,
|
|
27691
|
+
);
|
|
27692
|
+
}
|
|
27693
|
+
return { action: "acknowledged" } as const;
|
|
27694
|
+
}),
|
|
26107
27695
|
);
|
|
26108
27696
|
}
|
|
26109
27697
|
|
|
@@ -26639,7 +28227,9 @@ export async function appendSessionEvents(
|
|
|
26639
28227
|
sessionId,
|
|
26640
28228
|
sequence: ++sequence,
|
|
26641
28229
|
type: input.type,
|
|
26642
|
-
payload: sanitizeEventPayload(input.payload ?? {}
|
|
28230
|
+
payload: sanitizeEventPayload(input.payload ?? {}, {
|
|
28231
|
+
fullEvidence: input.retainedOutputEvidence,
|
|
28232
|
+
}),
|
|
26643
28233
|
clientEventId: input.clientEventId ?? null,
|
|
26644
28234
|
turnId: input.turnId ?? null,
|
|
26645
28235
|
turnGeneration: input.turnGeneration ?? null,
|
|
@@ -26979,7 +28569,9 @@ export async function appendSessionEventsForTurnAttempt(
|
|
|
26979
28569
|
sessionId,
|
|
26980
28570
|
sequence: ++sequence,
|
|
26981
28571
|
type: input.type,
|
|
26982
|
-
payload: sanitizeEventPayload(input.payload ?? {}
|
|
28572
|
+
payload: sanitizeEventPayload(input.payload ?? {}, {
|
|
28573
|
+
fullEvidence: input.retainedOutputEvidence,
|
|
28574
|
+
}),
|
|
26983
28575
|
clientEventId: input.clientEventId ?? null,
|
|
26984
28576
|
turnId,
|
|
26985
28577
|
turnGeneration: executionGeneration,
|
|
@@ -27053,7 +28645,9 @@ export async function appendSessionEventToSandboxGroup(
|
|
|
27053
28645
|
sessionId: row.id,
|
|
27054
28646
|
sequence: row.lastSequence + 1,
|
|
27055
28647
|
type: input.type,
|
|
27056
|
-
payload: sanitizeEventPayload(input.payload ?? {}
|
|
28648
|
+
payload: sanitizeEventPayload(input.payload ?? {}, {
|
|
28649
|
+
fullEvidence: input.retainedOutputEvidence,
|
|
28650
|
+
}),
|
|
27057
28651
|
clientEventId: input.clientEventId ?? null,
|
|
27058
28652
|
turnId: input.turnId ?? null,
|
|
27059
28653
|
turnGeneration: input.turnGeneration ?? null,
|
|
@@ -27131,7 +28725,9 @@ export async function appendSessionEventsAndUpdateSession(
|
|
|
27131
28725
|
sessionId,
|
|
27132
28726
|
sequence: ++sequence,
|
|
27133
28727
|
type: input.type,
|
|
27134
|
-
payload: sanitizeEventPayload(input.payload ?? {}
|
|
28728
|
+
payload: sanitizeEventPayload(input.payload ?? {}, {
|
|
28729
|
+
fullEvidence: input.retainedOutputEvidence,
|
|
28730
|
+
}),
|
|
27135
28731
|
clientEventId: input.clientEventId ?? null,
|
|
27136
28732
|
turnId: input.turnId ?? null,
|
|
27137
28733
|
turnGeneration: input.turnGeneration ?? null,
|
|
@@ -27166,6 +28762,10 @@ type LockedSessionUpdateContext = {
|
|
|
27166
28762
|
updateSessionMcpServerCredentials: (
|
|
27167
28763
|
updates: UpdateSessionMcpServerCredentialsInput[],
|
|
27168
28764
|
) => Promise<UpdateSessionMcpServerCredentialsResult>;
|
|
28765
|
+
updateSessionMcpApprovalPolicy: (
|
|
28766
|
+
serverId: string,
|
|
28767
|
+
requireApproval: SessionMcpApprovalPolicy,
|
|
28768
|
+
) => Promise<UpdateSessionMcpApprovalPolicyResult>;
|
|
27169
28769
|
listPendingSessionTurns: () => Promise<SessionTurn[]>;
|
|
27170
28770
|
};
|
|
27171
28771
|
|
|
@@ -27218,6 +28818,13 @@ export async function appendSessionEventsWithLockedSessionUpdate(
|
|
|
27218
28818
|
sessionId,
|
|
27219
28819
|
updates,
|
|
27220
28820
|
}),
|
|
28821
|
+
updateSessionMcpApprovalPolicy: async (serverId, requireApproval) =>
|
|
28822
|
+
await updateSessionMcpApprovalPolicyInTransaction(tx, {
|
|
28823
|
+
workspaceId,
|
|
28824
|
+
sessionId,
|
|
28825
|
+
serverId,
|
|
28826
|
+
requireApproval,
|
|
28827
|
+
}),
|
|
27221
28828
|
listPendingSessionTurns: async () => {
|
|
27222
28829
|
const rows = await tx
|
|
27223
28830
|
.select()
|
|
@@ -27255,7 +28862,9 @@ export async function appendSessionEventsWithLockedSessionUpdate(
|
|
|
27255
28862
|
sessionId,
|
|
27256
28863
|
sequence: ++sequence,
|
|
27257
28864
|
type: input.type,
|
|
27258
|
-
payload: sanitizeEventPayload(input.payload ?? {}
|
|
28865
|
+
payload: sanitizeEventPayload(input.payload ?? {}, {
|
|
28866
|
+
fullEvidence: input.retainedOutputEvidence,
|
|
28867
|
+
}),
|
|
27259
28868
|
clientEventId: input.clientEventId ?? null,
|
|
27260
28869
|
turnId: input.turnId ?? null,
|
|
27261
28870
|
turnGeneration: input.turnGeneration ?? null,
|
|
@@ -27338,6 +28947,10 @@ function mapSession(
|
|
|
27338
28947
|
instructions: row.instructions ?? null,
|
|
27339
28948
|
resources: row.resources as ResourceRef[],
|
|
27340
28949
|
tools: row.tools as ToolRef[],
|
|
28950
|
+
toolPolicy: (row.toolPolicy as SessionToolPolicy | null) ?? {
|
|
28951
|
+
mode: "legacy",
|
|
28952
|
+
inheritedFromSessionId: null,
|
|
28953
|
+
},
|
|
27341
28954
|
metadata: row.metadata,
|
|
27342
28955
|
createdBy: initiatorFromStorage(
|
|
27343
28956
|
row.createdByKind,
|
|
@@ -27438,6 +29051,7 @@ function mapSessionTurn(row: typeof schema.sessionTurns.$inferSelect): SessionTu
|
|
|
27438
29051
|
prompt: row.prompt,
|
|
27439
29052
|
resources: row.resources as ResourceRef[],
|
|
27440
29053
|
tools: row.tools as ToolRef[],
|
|
29054
|
+
toolsProvided: row.toolsProvided,
|
|
27441
29055
|
model: row.model,
|
|
27442
29056
|
reasoningEffort: row.reasoningEffort as ReasoningEffort,
|
|
27443
29057
|
sandboxBackend: row.sandboxBackend as SandboxBackend,
|
|
@@ -27613,9 +29227,76 @@ function mapImportBatch(row: typeof schema.importBatches.$inferSelect): ImportBa
|
|
|
27613
29227
|
};
|
|
27614
29228
|
}
|
|
27615
29229
|
|
|
29230
|
+
type CatalogExposureState = "trusted" | "legacy_active" | "blocked";
|
|
29231
|
+
|
|
29232
|
+
function catalogExposureState(
|
|
29233
|
+
item: typeof schema.capabilityCatalogItems.$inferSelect,
|
|
29234
|
+
installation: typeof schema.capabilityInstallations.$inferSelect | null,
|
|
29235
|
+
): CatalogExposureState {
|
|
29236
|
+
if (
|
|
29237
|
+
capabilityCatalogItemIsTrustedForExposure({
|
|
29238
|
+
source: item.source as CapabilitySource,
|
|
29239
|
+
stale: item.stale,
|
|
29240
|
+
authKind: item.authKind as CapabilityCatalogItem["authKind"],
|
|
29241
|
+
metadata: item.metadata,
|
|
29242
|
+
})
|
|
29243
|
+
) {
|
|
29244
|
+
return "trusted";
|
|
29245
|
+
}
|
|
29246
|
+
// Rolling compatibility for installations enabled before registry probe
|
|
29247
|
+
// provenance existed. This is deliberately narrower than the normal trust
|
|
29248
|
+
// gate: only an already-active, non-stale, known-auth row with enable-time
|
|
29249
|
+
// connectivity evidence can continue. A present-but-non-real probe is an
|
|
29250
|
+
// explicit negative verdict and can never be grandfathered.
|
|
29251
|
+
if (
|
|
29252
|
+
item.source !== registryCapabilitySource ||
|
|
29253
|
+
item.stale ||
|
|
29254
|
+
Object.prototype.hasOwnProperty.call(item.metadata, "mcpProbe") ||
|
|
29255
|
+
!item.authKind ||
|
|
29256
|
+
item.authKind === "unknown" ||
|
|
29257
|
+
installation?.status !== "active" ||
|
|
29258
|
+
!mcpConnectivityOk(installation.metadata)
|
|
29259
|
+
) {
|
|
29260
|
+
return "blocked";
|
|
29261
|
+
}
|
|
29262
|
+
const hasCredentialBinding =
|
|
29263
|
+
!!encryptedHeadersConfig(installation.config.headersEncrypted) ||
|
|
29264
|
+
!!connectionRefConfig(installation.config.connectionRef);
|
|
29265
|
+
if (item.authModel && !hasCredentialBinding) {
|
|
29266
|
+
return "blocked";
|
|
29267
|
+
}
|
|
29268
|
+
return "legacy_active";
|
|
29269
|
+
}
|
|
29270
|
+
|
|
27616
29271
|
function mapCapabilityCatalogItem(
|
|
27617
29272
|
row: typeof schema.capabilityCatalogItems.$inferSelect,
|
|
29273
|
+
exposure: "trusted" | "legacy_active" | "unverified" = capabilityCatalogItemIsTrustedForExposure({
|
|
29274
|
+
source: row.source as CapabilitySource,
|
|
29275
|
+
stale: row.stale,
|
|
29276
|
+
authKind: row.authKind as CapabilityCatalogItem["authKind"],
|
|
29277
|
+
metadata: row.metadata,
|
|
29278
|
+
})
|
|
29279
|
+
? "trusted"
|
|
29280
|
+
: "unverified",
|
|
27618
29281
|
): CapabilityCatalogItem {
|
|
29282
|
+
const catalogTrust =
|
|
29283
|
+
exposure === "legacy_active"
|
|
29284
|
+
? {
|
|
29285
|
+
state: "legacy_active" as const,
|
|
29286
|
+
reason: "active_installation_compatibility" as const,
|
|
29287
|
+
}
|
|
29288
|
+
: exposure === "trusted"
|
|
29289
|
+
? {
|
|
29290
|
+
state: "trusted" as const,
|
|
29291
|
+
reason:
|
|
29292
|
+
row.source === registryCapabilitySource
|
|
29293
|
+
? ("verified_probe" as const)
|
|
29294
|
+
: ("trusted_source" as const),
|
|
29295
|
+
}
|
|
29296
|
+
: {
|
|
29297
|
+
state: "unverified" as const,
|
|
29298
|
+
reason: "missing_verification" as const,
|
|
29299
|
+
};
|
|
27619
29300
|
const runtime =
|
|
27620
29301
|
row.kind === "mcp" && row.endpointUrl
|
|
27621
29302
|
? {
|
|
@@ -27625,6 +29306,7 @@ function mapCapabilityCatalogItem(
|
|
|
27625
29306
|
notes: row.authModel
|
|
27626
29307
|
? "Requires credential headers supplied in the enable request."
|
|
27627
29308
|
: null,
|
|
29309
|
+
catalogTrust,
|
|
27628
29310
|
}
|
|
27629
29311
|
: {
|
|
27630
29312
|
available: false,
|
|
@@ -27632,6 +29314,7 @@ function mapCapabilityCatalogItem(
|
|
|
27632
29314
|
row.kind === "mcp"
|
|
27633
29315
|
? "Remote streamable HTTP endpoint is required for runtime use."
|
|
27634
29316
|
: null,
|
|
29317
|
+
catalogTrust,
|
|
27635
29318
|
};
|
|
27636
29319
|
return {
|
|
27637
29320
|
id: row.id,
|