@bitkyc08/opencodex 2.58.0 → 2.60.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +28 -10
- package/gui/dist/assets/index-BTuCbqQd.css +1 -0
- package/gui/dist/assets/index-DoBVdPHP.js +134 -0
- package/gui/dist/index.html +2 -2
- package/gui/dist/provider-icons/crusoe.svg +1 -0
- package/gui/dist/provider-icons/opper.svg +3 -0
- package/package.json +4 -1
- package/src/adapters/anthropic-image-codec.ts +16 -2
- package/src/adapters/anthropic-image-normalize.ts +49 -2
- package/src/adapters/anthropic.ts +4 -1
- package/src/adapters/base.ts +34 -1
- package/src/adapters/coding-agent/turn.ts +22 -2
- package/src/adapters/command-code.ts +50 -3
- package/src/adapters/cursor/catalog.ts +11 -0
- package/src/adapters/cursor/checkpoint-store.ts +3 -0
- package/src/adapters/cursor/discovery.ts +11 -8
- package/src/adapters/cursor/effort-map.ts +16 -2
- package/src/adapters/cursor/envelope-echo.ts +55 -2
- package/src/adapters/cursor/live-transport.ts +26 -9
- package/src/adapters/cursor/message-mapper.ts +3 -2
- package/src/adapters/cursor/protobuf-request.ts +8 -5
- package/src/adapters/cursor/request-builder.ts +21 -4
- package/src/adapters/cursor/thread-continuity.ts +105 -31
- package/src/adapters/cursor/tool-guidance.ts +5 -4
- package/src/adapters/cursor/transport.ts +19 -0
- package/src/adapters/cursor.ts +45 -2
- package/src/adapters/devin/cloud-direct/chat.ts +14 -3
- package/src/adapters/devin/cloud-direct/index.ts +7 -0
- package/src/adapters/devin/cloud-direct/stated-reset-retry.ts +140 -0
- package/src/adapters/devin.ts +125 -26
- package/src/adapters/google-antigravity-replay.ts +1 -1
- package/src/adapters/google-antigravity-wire.ts +55 -4
- package/src/adapters/google-http.ts +57 -11
- package/src/adapters/google-tool-schema.ts +595 -31
- package/src/adapters/google-wire-compiler.ts +93 -10
- package/src/adapters/google-wire-shape.ts +461 -0
- package/src/adapters/google.ts +60 -10
- package/src/adapters/openai-chat/response-events.ts +61 -0
- package/src/adapters/openai-chat-images.ts +3 -1
- package/src/adapters/openai-chat.ts +10 -11
- package/src/adapters/openai-responses/image-gen.ts +8 -6
- package/src/adapters/openai-responses/passthrough.ts +25 -4
- package/src/adapters/openai-responses/reasoning.ts +7 -0
- package/src/adapters/openai-responses/tool-output-recovery.ts +75 -0
- package/src/adapters/openai-responses/tool-schema.ts +19 -7
- package/src/adapters/opencode-go-additional-tools.ts +12 -2
- package/src/adapters/responses-tool-schema.ts +76 -46
- package/src/adapters/run-turn-queue.ts +17 -4
- package/src/bridge/response-json.ts +1 -1
- package/src/bridge/sse.ts +28 -35
- package/src/claude/context-windows.ts +22 -0
- package/src/claude/outbound.ts +35 -4
- package/src/cli/account-api.ts +4 -3
- package/src/cli/account-extended.ts +26 -6
- package/src/cli/account-orca-import.ts +63 -0
- package/src/cli/account.ts +32 -4
- package/src/cli/capabilities.ts +40 -0
- package/src/cli/claude.ts +29 -1
- package/src/cli/codex-cli-update.ts +97 -2
- package/src/cli/dispatch.ts +57 -3
- package/src/cli/doctor.ts +218 -4
- package/src/cli/help.ts +4 -1
- package/src/cli/hub.ts +3 -2
- package/src/cli/index.ts +95 -22
- package/src/cli/models-runtime.ts +33 -4
- package/src/cli/opencode.ts +2 -2
- package/src/cli/provider.ts +13 -1
- package/src/cli/registry.ts +11 -1
- package/src/cli/runtime-api.ts +44 -0
- package/src/cli/start-args.ts +94 -0
- package/src/cli/system-command.ts +2 -0
- package/src/client/machine-api.ts +6 -5
- package/src/client/machine-listener.ts +16 -3
- package/src/client/runtime.ts +26 -2
- package/src/clients/config-export/constants.ts +2 -3
- package/src/clients/config-export.ts +5 -5
- package/src/codex/account-store.ts +146 -5
- package/src/codex/auth-api/account-list.ts +19 -11
- package/src/codex/auth-api/pool-quota-probe.ts +44 -10
- package/src/codex/auth-api/routes.ts +17 -2
- package/src/codex/auth-context.ts +16 -12
- package/src/codex/catalog/build-entries.ts +25 -4
- package/src/codex/catalog/derive-entry.ts +8 -1
- package/src/codex/catalog/effort.ts +10 -6
- package/src/codex/catalog/gather-capture.ts +22 -2
- package/src/codex/catalog/model-hints.ts +66 -33
- package/src/codex/catalog/parsing.ts +90 -5
- package/src/codex/catalog/provider-models.ts +19 -2
- package/src/codex/catalog/reserve-warn.ts +96 -0
- package/src/codex/catalog/retained-sync.ts +41 -26
- package/src/codex/catalog/routed-gather.ts +61 -3
- package/src/codex/cli-installation-identity.ts +210 -0
- package/src/codex/cli-installation-targets.ts +158 -0
- package/src/codex/context-compat.ts +5 -2
- package/src/codex/convergence.ts +5 -0
- package/src/codex/desired-state.ts +4 -1
- package/src/codex/history-job.ts +6 -6
- package/src/codex/history-provider.ts +24 -167
- package/src/codex/history-rollout-read.ts +174 -0
- package/src/codex/history-state-open.ts +105 -0
- package/src/codex/inject/config-toml.ts +44 -2
- package/src/codex/inject.ts +3 -2
- package/src/codex/internal/catalog-writer.ts +33 -1
- package/src/codex/lineage.ts +83 -32
- package/src/codex/loopback-target.ts +31 -0
- package/src/codex/main-account-hard-lock.ts +2 -1
- package/src/codex/main-account.ts +10 -3
- package/src/codex/main-device-reauth.ts +17 -9
- package/src/codex/model-cache.ts +47 -0
- package/src/codex/model-entitlements.ts +80 -2
- package/src/codex/observed-model-denials.ts +230 -0
- package/src/codex/orca-auth-source.ts +94 -0
- package/src/codex/orca-import.ts +219 -0
- package/src/codex/prompt-text-probe.ts +289 -16
- package/src/codex/quota-401-recovery.ts +12 -0
- package/src/codex/quota-types.ts +65 -0
- package/src/codex/quota.ts +24 -19
- package/src/codex/routing/cooldown-math.ts +8 -47
- package/src/codex/routing/pin-drain.ts +57 -0
- package/src/codex/routing.ts +20 -16
- package/src/codex/shim.ts +1 -1
- package/src/codex/subagent-model-fallback.ts +114 -2
- package/src/codex/windows-installation-files.ts +224 -0
- package/src/combos/failover.ts +125 -5
- package/src/config/admitted-identity.ts +222 -0
- package/src/config/diagnostics.ts +43 -1
- package/src/config/feature-flags.ts +5 -0
- package/src/config/load-degrade.ts +33 -0
- package/src/config/pending-teardown.ts +8 -0
- package/src/config/process-state.ts +36 -3
- package/src/config/provider-relative-send-path.ts +16 -0
- package/src/config/proxy-env.ts +31 -7
- package/src/config/schema/compaction-triggers.ts +11 -0
- package/src/config/schema/config-schema.ts +25 -0
- package/src/config/schema/leaf-validators.ts +76 -17
- package/src/config.ts +2 -2
- package/src/generated/compatibility-version.json +410 -258
- package/src/generated/model-metadata.ts +1 -1
- package/src/grok/reset-coupons.ts +38 -19
- package/src/images/loop.ts +6 -1
- package/src/integrations/aside-profile-context.ts +37 -3
- package/src/integrations/aside-profile-journal.ts +68 -3
- package/src/integrations/aside-profiles.ts +128 -3
- package/src/integrations/mutation-plan.ts +815 -0
- package/src/integrations/writer.ts +85 -99
- package/src/lab/live/transport.ts +4 -0
- package/src/lab/live/types.ts +5 -0
- package/src/lab/subject/behavior-fingerprint.ts +1 -1
- package/src/lib/admin-secrets.ts +9 -1
- package/src/lib/bounded-body.ts +4 -2
- package/src/lib/debug-log-buffer.ts +6 -1
- package/src/lib/debug.ts +23 -0
- package/src/lib/destination-policy.ts +48 -6
- package/src/lib/errors.ts +82 -15
- package/src/lib/http-response-semantics.ts +57 -0
- package/src/lib/lab-live-pinned-sender.ts +26 -12
- package/src/lib/local-destinations.ts +32 -5
- package/src/lib/pinned-http.ts +142 -2
- package/src/lib/plain-data.ts +103 -0
- package/src/lib/process-control.ts +13 -5
- package/src/lib/provider-outbound.ts +53 -5
- package/src/lib/proxy-env.ts +70 -3
- package/src/lib/request-execution-budget.ts +11 -3
- package/src/lib/response-body-inactivity.ts +193 -0
- package/src/lib/retry-delay.ts +69 -0
- package/src/lib/socks5-fetch.ts +741 -0
- package/src/lib/spend-ledger-owner.ts +364 -0
- package/src/lib/spend-reservation-ledger.ts +332 -35
- package/src/lib/windows-system-proxy.ts +16 -11
- package/src/lib/workflow-budget.ts +145 -8
- package/src/oauth/account-quota-rank.ts +72 -15
- package/src/oauth/callback-server.ts +4 -3
- package/src/oauth/generic-account-failover.ts +41 -27
- package/src/oauth/health.ts +12 -1
- package/src/oauth/index.ts +3 -107
- package/src/oauth/login-flow-state.ts +127 -0
- package/src/oauth/orcarouter.ts +15 -2
- package/src/oauth/store.ts +8 -0
- package/src/providers/codex-capacity.ts +9 -0
- package/src/providers/derive.ts +34 -17
- package/src/providers/devin-cli-authmode-migration.ts +14 -10
- package/src/providers/devin-provider-merge-migration.ts +33 -12
- package/src/providers/free-directory.ts +20 -2
- package/src/providers/key-failover.ts +327 -25
- package/src/providers/model-rename-migration.ts +56 -1
- package/src/providers/model-rename-startup.ts +7 -5
- package/src/providers/openai-sidecar.ts +4 -0
- package/src/providers/openai-virtual-models.ts +42 -2
- package/src/providers/opencode-go-transport.ts +14 -5
- package/src/providers/quota/antigravity.ts +22 -2
- package/src/providers/quota/report-cache.ts +3 -0
- package/src/providers/quota/vendor-probes-key.ts +1 -1
- package/src/providers/registry/entries-core.ts +39 -17
- package/src/providers/registry/entries-extended.ts +131 -1
- package/src/providers/registry/model-ids.ts +168 -0
- package/src/providers/registry/model-seeds.ts +87 -21
- package/src/providers/registry/types.ts +2 -0
- package/src/providers/resolved-model-policy-merge.ts +167 -0
- package/src/providers/resolved-model-policy.ts +406 -0
- package/src/providers/stale-vision-classification-migration.ts +137 -0
- package/src/responses/apply-patch-envelope.ts +32 -11
- package/src/responses/bridge-search-replay-cache.ts +152 -0
- package/src/responses/code-mode-helper-compat.ts +26 -16
- package/src/responses/custom-tool-compat.ts +1 -1
- package/src/responses/freeform-wrapper-scan.ts +279 -0
- package/src/responses/hosted-tool-policy.ts +85 -2
- package/src/responses/legacy-dotted-tool-name-repair.ts +134 -0
- package/src/responses/progressive-freeform-input.ts +130 -0
- package/src/responses/reasoning-envelope.ts +30 -0
- package/src/responses/schema.ts +9 -2
- package/src/responses/state.ts +5 -12
- package/src/responses/tool-name-aliases.ts +15 -1
- package/src/router.ts +91 -115
- package/src/routing/compatibility/behavior.ts +9 -0
- package/src/routing/compatibility/subject.ts +16 -1
- package/src/server/adapter-resolve.ts +9 -0
- package/src/server/auth-cors.ts +29 -0
- package/src/server/chat-completions.ts +13 -5
- package/src/server/chat-native-sse.ts +26 -9
- package/src/server/chat-native.ts +10 -4
- package/src/server/claude-messages.ts +30 -5
- package/src/server/effort-row.ts +11 -3
- package/src/server/grok-responses-control-frame.ts +160 -1
- package/src/server/gui-static.ts +36 -2
- package/src/server/inbound-body-admission.ts +187 -0
- package/src/server/index/serve-options.ts +86 -29
- package/src/server/index/spend-ledger-lifecycle.ts +66 -0
- package/src/server/index/websocket-handler.ts +6 -1
- package/src/server/index.ts +24 -25
- package/src/server/management/api-access.ts +3 -4
- package/src/server/management/aside-profile-routes.ts +266 -7
- package/src/server/management/config-routes.ts +48 -7
- package/src/server/management/context.ts +3 -0
- package/src/server/management/integration-routes.ts +287 -5
- package/src/server/management/metrics-routes.ts +20 -0
- package/src/server/management/model-rows.ts +224 -12
- package/src/server/management/provider-capability-config.ts +35 -7
- package/src/server/management/provider-routes.ts +70 -18
- package/src/server/management/route-registry.ts +14 -0
- package/src/server/management/shared.ts +10 -3
- package/src/server/management/system-restart.ts +7 -2
- package/src/server/management/system-routes.ts +2 -0
- package/src/server/management/usage-aggregate-cache.ts +4 -0
- package/src/server/management-api.ts +2 -0
- package/src/server/management-auth.ts +15 -1
- package/src/server/proxy-liveness.ts +97 -2
- package/src/server/readiness.ts +29 -10
- package/src/server/relay-eager.ts +24 -2
- package/src/server/relay.ts +136 -34
- package/src/server/request-log.ts +80 -3
- package/src/server/request-metrics.ts +236 -0
- package/src/server/responses/adapter-continuation.ts +74 -30
- package/src/server/responses/adapter-delivery.ts +39 -8
- package/src/server/responses/adapter-dispatch.ts +63 -30
- package/src/server/responses/compact.ts +89 -18
- package/src/server/responses/compaction-routing.ts +111 -0
- package/src/server/responses/core-codex-account.ts +90 -24
- package/src/server/responses/core-combo.ts +7 -7
- package/src/server/responses/core-normalize.ts +16 -15
- package/src/server/responses/core-opaque-recovery.ts +1 -0
- package/src/server/responses/core-options.ts +4 -0
- package/src/server/responses/encrypted-payload.ts +20 -2
- package/src/server/responses/fetch-helpers.ts +68 -2
- package/src/server/responses/passthrough-delivery.ts +41 -15
- package/src/server/responses/passthrough-dispatch.ts +145 -53
- package/src/server/responses/passthrough-execution.ts +11 -1
- package/src/server/responses/policy-fallback.ts +5 -13
- package/src/server/responses/request-prepare.ts +96 -17
- package/src/server/responses/request-send-budget.ts +89 -8
- package/src/server/responses/request-sidecar-auth.ts +17 -9
- package/src/server/responses/request-spend.ts +38 -9
- package/src/server/responses/request-transport.ts +15 -12
- package/src/server/responses/run-turn-execution.ts +45 -8
- package/src/server/responses/sidecar-execution.ts +19 -2
- package/src/server/responses/ws-upstream.ts +16 -28
- package/src/server/responses-custom-tool-repair.ts +29 -56
- package/src/server/responses-undeclared-tool-guard.ts +31 -1
- package/src/server/sse-frame-buffer.ts +12 -10
- package/src/server/sse-payload-rewrite.ts +37 -10
- package/src/server/system-env-shell.ts +5 -1
- package/src/server/system-env.ts +7 -1
- package/src/server/workflow-refusal.ts +56 -2
- package/src/service/cli.ts +16 -6
- package/src/service/guards.ts +10 -0
- package/src/service/health.ts +43 -0
- package/src/service/state.ts +7 -2
- package/src/tray/windows-tray.ps1 +155 -3
- package/src/types/accounts.ts +4 -0
- package/src/types/config.ts +111 -6
- package/src/types/provider.ts +37 -0
- package/src/types/request.ts +9 -1
- package/src/types/tools.ts +14 -0
- package/src/types/wire.ts +9 -1
- package/src/types.ts +1 -0
- package/src/usage/expected-prices.ts +28 -0
- package/src/usage/log.ts +87 -4
- package/src/vision/eligibility.ts +88 -9
- package/src/vision/plan.ts +34 -10
- package/src/web-search/executor.ts +41 -2
- package/src/web-search/loop.ts +6 -1
- package/src/web-search/passthrough-bridge.ts +39 -5
- package/gui/dist/assets/index-BbrHOIY0.js +0 -128
- package/gui/dist/assets/index-C5-RdDmD.css +0 -1
|
@@ -31,6 +31,7 @@ import {
|
|
|
31
31
|
disableIntegrationCoordinated,
|
|
32
32
|
overwriteIntegrationCoordinated,
|
|
33
33
|
restoreIntegrationCoordinated,
|
|
34
|
+
type CoordinatedIntegrationOptions,
|
|
34
35
|
type IntegrationRestoreInput,
|
|
35
36
|
type IntegrationWriteInput,
|
|
36
37
|
type WriteRefused,
|
|
@@ -45,7 +46,13 @@ import {
|
|
|
45
46
|
import { jsonResponse } from "../auth-cors";
|
|
46
47
|
import { readManagementJsonBody, rethrowManagementBodyTooLarge } from "./body";
|
|
47
48
|
import type { ManagementContext } from "./context";
|
|
48
|
-
import { loadExportModels } from "./model-rows";
|
|
49
|
+
import { exportSnapshotIdentity, loadExportModels, previewExportSnapshot } from "./model-rows";
|
|
50
|
+
import {
|
|
51
|
+
previewIntegration,
|
|
52
|
+
type IntegrationMutationPlan,
|
|
53
|
+
type IntegrationPlanOperation,
|
|
54
|
+
type PreviewRequest,
|
|
55
|
+
} from "../../integrations/mutation-plan";
|
|
49
56
|
|
|
50
57
|
|
|
51
58
|
const INTEGRATION_ROUTE_PREFIX = "/api/client-integrations/";
|
|
@@ -246,6 +253,37 @@ async function buildIntegrationWriteInput(
|
|
|
246
253
|
};
|
|
247
254
|
}
|
|
248
255
|
|
|
256
|
+
/**
|
|
257
|
+
* The same input a mutation would build, from the read-only roster.
|
|
258
|
+
*
|
|
259
|
+
* It differs from the mutation's in exactly one way, and the difference is deliberate: the roster
|
|
260
|
+
* comes from `previewExportModels`, which gathers without running the initial-selection finalizer
|
|
261
|
+
* that persists configuration. Everything else is shared, so a preview and the commit that follows
|
|
262
|
+
* it cannot disagree for any reason except the state genuinely moving.
|
|
263
|
+
*/
|
|
264
|
+
async function buildIntegrationPreviewInput(
|
|
265
|
+
clientId: IntegrationClientId,
|
|
266
|
+
ctx: ManagementContext,
|
|
267
|
+
store: IntegrationStateStore,
|
|
268
|
+
): Promise<{ input: IntegrationWriteInput; identity: string } | null> {
|
|
269
|
+
const snapshot = previewExportSnapshot(ctx.config);
|
|
270
|
+
// No cached roster means no honest snapshot to plan against. Gathering one here would make a
|
|
271
|
+
// read refresh credentials and write the provider cache, which is the thing preview must not do.
|
|
272
|
+
if (snapshot === null) return null;
|
|
273
|
+
return {
|
|
274
|
+
identity: snapshot.identity,
|
|
275
|
+
input: {
|
|
276
|
+
clientId,
|
|
277
|
+
models: snapshot.models,
|
|
278
|
+
config: ctx.config,
|
|
279
|
+
port: Number(ctx.url.port) || ctx.config.port,
|
|
280
|
+
store,
|
|
281
|
+
io: integrationMutationTestHooks?.io,
|
|
282
|
+
...pathOverrides(),
|
|
283
|
+
},
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
|
|
249
287
|
/**
|
|
250
288
|
* The file's current bytes, or `null` when it is missing.
|
|
251
289
|
*
|
|
@@ -271,6 +309,97 @@ function invalidClientResponse(ctx: ManagementContext): Response {
|
|
|
271
309
|
}, 400, ctx.req, ctx.config);
|
|
272
310
|
}
|
|
273
311
|
|
|
312
|
+
/**
|
|
313
|
+
* No cached model roster, so there is nothing honest to plan against.
|
|
314
|
+
*
|
|
315
|
+
* Answered as a bounded refusal rather than by gathering one: discovery refreshes credentials and
|
|
316
|
+
* writes the provider cache, and a preview that did either would be a write wearing a read's name.
|
|
317
|
+
* The caller opens the models view or performs the mutation directly.
|
|
318
|
+
*/
|
|
319
|
+
function previewUnavailableResponse(ctx: ManagementContext): Response {
|
|
320
|
+
return jsonResponse({
|
|
321
|
+
error: "no model roster is cached yet, so this change cannot be planned",
|
|
322
|
+
code: "integration_preview_unavailable",
|
|
323
|
+
}, 409, ctx.req, ctx.config);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* A confirmed plan, or a reason the request cannot carry one.
|
|
328
|
+
*
|
|
329
|
+
* Both fields or neither. A half-bound request is rejected rather than quietly treated as
|
|
330
|
+
* unbound, because dropping one half would answer 200 to a caller who believed their
|
|
331
|
+
* confirmation was being checked.
|
|
332
|
+
*/
|
|
333
|
+
function planBindingOf(
|
|
334
|
+
body: Record<string, unknown>,
|
|
335
|
+
): { operation: IntegrationPlanOperation; fingerprint: string } | "none" | "half" | "unknown-operation" {
|
|
336
|
+
const { operation, planFingerprint } = body;
|
|
337
|
+
if (operation === undefined && planFingerprint === undefined) return "none";
|
|
338
|
+
if (operation === undefined || typeof planFingerprint !== "string" || planFingerprint.length === 0) return "half";
|
|
339
|
+
if (operation !== "apply" && operation !== "overwrite" && operation !== "disable" && operation !== "restore") {
|
|
340
|
+
return "unknown-operation";
|
|
341
|
+
}
|
|
342
|
+
return { operation, fingerprint: planFingerprint };
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function halfBoundResponse(ctx: ManagementContext): Response {
|
|
346
|
+
return jsonResponse({
|
|
347
|
+
error: "operation and planFingerprint must be sent together",
|
|
348
|
+
code: "invalid_preview_binding",
|
|
349
|
+
}, 400, ctx.req, ctx.config);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Re-plan and compare before the mutation runs.
|
|
354
|
+
*
|
|
355
|
+
* The fingerprint is an optimistic token, never authorization: management authentication and
|
|
356
|
+
* every ownership rule still apply. What it adds is that a confirmation stops meaning anything
|
|
357
|
+
* the moment the state it described moved, and the refusal carries a fresh plan so the operator
|
|
358
|
+
* decides again against what is true now.
|
|
359
|
+
*/
|
|
360
|
+
function stalePlanGuard(
|
|
361
|
+
clientId: IntegrationClientId,
|
|
362
|
+
ctx: ManagementContext,
|
|
363
|
+
store: IntegrationStateStore,
|
|
364
|
+
request: PreviewRequest,
|
|
365
|
+
fingerprint: string,
|
|
366
|
+
capturedIdentity: string | null,
|
|
367
|
+
): {
|
|
368
|
+
revalidate: NonNullable<CoordinatedIntegrationOptions["revalidate"]>;
|
|
369
|
+
response: () => Response | null;
|
|
370
|
+
} {
|
|
371
|
+
let stale: IntegrationMutationPlan | "unavailable" | null = null;
|
|
372
|
+
return {
|
|
373
|
+
revalidate: async frozen => {
|
|
374
|
+
/*
|
|
375
|
+
* Plan the coordinator's OWN frozen input, never a freshly built one. Rebuilding here let
|
|
376
|
+
* the check validate against one roster while the mutation wrote from another, because an
|
|
377
|
+
* ordinary load can replace the snapshot at any time and nothing serialises that against
|
|
378
|
+
* this lock. The captured identity is verified separately, so a replacement is detected
|
|
379
|
+
* without ever swapping the roster this mutation is about to use.
|
|
380
|
+
*/
|
|
381
|
+
if (exportSnapshotIdentity(ctx.config) !== capturedIdentity) {
|
|
382
|
+
const refreshed = await buildIntegrationPreviewInput(clientId, ctx, store);
|
|
383
|
+
stale = refreshed === null ? "unavailable" : previewIntegration(refreshed.input, request);
|
|
384
|
+
return { ok: false, reason: "conflict", state: "conflict", clientId, message: "the model roster changed while confirming" };
|
|
385
|
+
}
|
|
386
|
+
const plan = previewIntegration(frozen, request);
|
|
387
|
+
if (plan.canApply && plan.fingerprint === fingerprint) return null;
|
|
388
|
+
stale = plan;
|
|
389
|
+
return { ok: false, reason: "conflict", state: plan.state, clientId, message: "that confirmation no longer describes this file" };
|
|
390
|
+
},
|
|
391
|
+
response: () => {
|
|
392
|
+
if (stale === null) return null;
|
|
393
|
+
if (stale === "unavailable") return previewUnavailableResponse(ctx);
|
|
394
|
+
return jsonResponse({
|
|
395
|
+
error: "integration preview is stale",
|
|
396
|
+
code: "integration_preview_stale",
|
|
397
|
+
plan: stale,
|
|
398
|
+
}, 409, ctx.req, ctx.config);
|
|
399
|
+
},
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
|
|
274
403
|
function internalErrorResponse(error: unknown, ctx: ManagementContext): Response {
|
|
275
404
|
return jsonResponse({
|
|
276
405
|
error: error instanceof Error ? error.message : String(error),
|
|
@@ -565,6 +694,88 @@ export async function handleIntegrationRoutes(ctx: ManagementContext): Promise<R
|
|
|
565
694
|
}
|
|
566
695
|
}
|
|
567
696
|
|
|
697
|
+
if (url.pathname === "/api/client-integrations/preview") {
|
|
698
|
+
if (req.method !== "POST") return null;
|
|
699
|
+
const parsed = await readJsonBody(ctx);
|
|
700
|
+
if (parsed instanceof Response) return parsed;
|
|
701
|
+
if (!isPlainRecord(parsed)) {
|
|
702
|
+
return jsonResponse({ error: "preview body must be an object", code: "invalid_preview_body" }, 400, req, ctx.config);
|
|
703
|
+
}
|
|
704
|
+
const previewClient = parsed.clientId;
|
|
705
|
+
if (typeof previewClient !== "string"
|
|
706
|
+
|| !(INTEGRATION_CLIENT_IDS as readonly string[]).includes(previewClient)) {
|
|
707
|
+
return invalidClientResponse(ctx);
|
|
708
|
+
}
|
|
709
|
+
/*
|
|
710
|
+
* Aside is a set of profiles, not one file, and every mutation it has requires a profile. A
|
|
711
|
+
* plan built here would describe the legacy single-account location and no bound mutation
|
|
712
|
+
* would accept it, so an operator could confirm something nothing can carry out. The canonical
|
|
713
|
+
* per-profile preview answers this question properly, and the mutation routes already refuse
|
|
714
|
+
* the unscoped spelling the same way.
|
|
715
|
+
*/
|
|
716
|
+
if (previewClient === "aside") {
|
|
717
|
+
return jsonResponse({ error: "Use the canonical Aside profile path", code: "invalid_aside_profile_path" }, 400, req, ctx.config);
|
|
718
|
+
}
|
|
719
|
+
const operation = parsed.operation;
|
|
720
|
+
if (operation !== "apply" && operation !== "overwrite" && operation !== "disable") {
|
|
721
|
+
return jsonResponse({
|
|
722
|
+
error: "operation must be apply, overwrite or disable",
|
|
723
|
+
code: "invalid_preview_operation",
|
|
724
|
+
}, 400, req, ctx.config);
|
|
725
|
+
}
|
|
726
|
+
try {
|
|
727
|
+
const captured = await buildIntegrationPreviewInput(previewClient as IntegrationClientId, ctx, integrationStore());
|
|
728
|
+
if (!captured) return previewUnavailableResponse(ctx);
|
|
729
|
+
return jsonResponse(previewIntegration(captured.input, { operation }), 200, req, ctx.config);
|
|
730
|
+
} catch (error) {
|
|
731
|
+
return internalErrorResponse(error, ctx);
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
if (url.pathname === "/api/client-integrations/restore/preview") {
|
|
736
|
+
if (req.method !== "POST") return null;
|
|
737
|
+
const parsed = await readJsonBody(ctx);
|
|
738
|
+
if (parsed instanceof Response) return parsed;
|
|
739
|
+
if (!isPlainRecord(parsed) || typeof parsed.opId !== "string" || parsed.opId.trim().length === 0) {
|
|
740
|
+
return jsonResponse({ error: "opId must be a non-empty string", code: "invalid_op_id" }, 400, req, ctx.config);
|
|
741
|
+
}
|
|
742
|
+
if (parsed.confirmDrift !== undefined && typeof parsed.confirmDrift !== "boolean") {
|
|
743
|
+
return jsonResponse({ error: "confirmDrift must be a boolean", code: "invalid_confirm_drift" }, 400, req, ctx.config);
|
|
744
|
+
}
|
|
745
|
+
const opId = parsed.opId.trim();
|
|
746
|
+
try {
|
|
747
|
+
const store = integrationStore();
|
|
748
|
+
const operation = store.findOperation(opId);
|
|
749
|
+
/*
|
|
750
|
+
* Answered as "not found" rather than by reading the row back to the caller. A preview is
|
|
751
|
+
* reached before any confirmation, so it is the cheapest place to probe journal contents,
|
|
752
|
+
* and it declines to be one.
|
|
753
|
+
*/
|
|
754
|
+
if (!operation) {
|
|
755
|
+
return jsonResponse({
|
|
756
|
+
error: "integration operation not found",
|
|
757
|
+
code: "integration_operation_not_found",
|
|
758
|
+
opId,
|
|
759
|
+
}, 404, req, ctx.config);
|
|
760
|
+
}
|
|
761
|
+
// Same rule, decided after the row is found so an unscoped undo of an Aside operation is
|
|
762
|
+
// refused for what it is rather than answered as a missing operation.
|
|
763
|
+
if (operation.clientId === "aside") {
|
|
764
|
+
return jsonResponse({ error: "Use the canonical Aside profile path", code: "invalid_aside_profile_path" }, 400, req, ctx.config);
|
|
765
|
+
}
|
|
766
|
+
const captured = await buildIntegrationPreviewInput(operation.clientId, ctx, store);
|
|
767
|
+
if (!captured) return previewUnavailableResponse(ctx);
|
|
768
|
+
const plan = previewIntegration(captured.input, {
|
|
769
|
+
operation: "restore",
|
|
770
|
+
opId,
|
|
771
|
+
confirmDrift: parsed.confirmDrift ?? false,
|
|
772
|
+
});
|
|
773
|
+
return jsonResponse(plan, 200, req, ctx.config);
|
|
774
|
+
} catch (error) {
|
|
775
|
+
return internalErrorResponse(error, ctx);
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
|
|
568
779
|
if (url.pathname === "/api/client-integrations/restore") {
|
|
569
780
|
if (req.method !== "POST") return null;
|
|
570
781
|
const parsed = await readJsonBody(ctx);
|
|
@@ -584,7 +795,22 @@ export async function handleIntegrationRoutes(ctx: ManagementContext): Promise<R
|
|
|
584
795
|
|
|
585
796
|
const opId = parsed.opId.trim();
|
|
586
797
|
const confirmDrift = parsed.confirmDrift ?? false;
|
|
587
|
-
const
|
|
798
|
+
const restoreBinding = planBindingOf(parsed);
|
|
799
|
+
if (restoreBinding === "half") return halfBoundResponse(ctx);
|
|
800
|
+
if (restoreBinding === "unknown-operation" || (restoreBinding !== "none" && restoreBinding.operation !== "restore")) {
|
|
801
|
+
return jsonResponse({
|
|
802
|
+
error: "operation does not match the requested change",
|
|
803
|
+
code: "invalid_preview_operation",
|
|
804
|
+
}, 400, req, ctx.config);
|
|
805
|
+
}
|
|
806
|
+
// The binding travels with the request. Dropping it here routed a bound Aside restore into
|
|
807
|
+
// the unbound path, which executed the mutation while its confirmation went unexamined.
|
|
808
|
+
const asideRestore = await asideRestoreResponse(ctx, {
|
|
809
|
+
opId,
|
|
810
|
+
confirmDrift,
|
|
811
|
+
...(parsed.operation === undefined ? {} : { operation: parsed.operation }),
|
|
812
|
+
...(parsed.planFingerprint === undefined ? {} : { planFingerprint: parsed.planFingerprint }),
|
|
813
|
+
}, profileOptions);
|
|
588
814
|
if (asideRestore) return asideRestore;
|
|
589
815
|
let restoreClientId: IntegrationClientId | undefined;
|
|
590
816
|
try {
|
|
@@ -607,7 +833,21 @@ export async function handleIntegrationRoutes(ctx: ManagementContext): Promise<R
|
|
|
607
833
|
}, 410, req, ctx.config);
|
|
608
834
|
}
|
|
609
835
|
|
|
610
|
-
const
|
|
836
|
+
const boundRestore = restoreBinding === "none"
|
|
837
|
+
? null
|
|
838
|
+
: await buildIntegrationPreviewInput(operation.clientId, ctx, store);
|
|
839
|
+
if (restoreBinding !== "none" && boundRestore === null) return previewUnavailableResponse(ctx);
|
|
840
|
+
const writeInput = boundRestore
|
|
841
|
+
? boundRestore.input
|
|
842
|
+
: await buildIntegrationWriteInput(operation.clientId, ctx, store);
|
|
843
|
+
const restoreGuard = restoreBinding === "none" ? null : stalePlanGuard(
|
|
844
|
+
operation.clientId,
|
|
845
|
+
ctx,
|
|
846
|
+
store,
|
|
847
|
+
{ operation: "restore", opId, confirmDrift },
|
|
848
|
+
restoreBinding.fingerprint,
|
|
849
|
+
boundRestore === null ? null : boundRestore.identity,
|
|
850
|
+
);
|
|
611
851
|
const restoreInput: IntegrationRestoreInput = {
|
|
612
852
|
...writeInput,
|
|
613
853
|
opId,
|
|
@@ -619,8 +859,11 @@ export async function handleIntegrationRoutes(ctx: ManagementContext): Promise<R
|
|
|
619
859
|
writeInput.io?.now ?? Date.now,
|
|
620
860
|
() => restoreIntegrationCoordinated(restoreInput, {
|
|
621
861
|
lockSeams: integrationMutationTestHooks?.lockSeams,
|
|
862
|
+
...(restoreGuard ? { revalidate: restoreGuard.revalidate } : {}),
|
|
622
863
|
}),
|
|
623
864
|
);
|
|
865
|
+
const restoreStale = restoreGuard?.response();
|
|
866
|
+
if (restoreStale) return restoreStale;
|
|
624
867
|
if (!result.ok) {
|
|
625
868
|
/*
|
|
626
869
|
* Drift is NOT special-cased here.
|
|
@@ -698,20 +941,59 @@ export async function handleIntegrationRoutes(ctx: ManagementContext): Promise<R
|
|
|
698
941
|
}, 400, req, ctx.config);
|
|
699
942
|
}
|
|
700
943
|
|
|
944
|
+
const requestedOperation: IntegrationPlanOperation = parsed.enabled
|
|
945
|
+
? (parsed.overwriteConflict === true ? "overwrite" : "apply")
|
|
946
|
+
: "disable";
|
|
947
|
+
const binding = planBindingOf(parsed);
|
|
948
|
+
if (binding === "half") return halfBoundResponse(ctx);
|
|
949
|
+
if (binding === "unknown-operation" || (binding !== "none" && binding.operation !== requestedOperation)) {
|
|
950
|
+
// A confirmation that names a different operation than the request performs is not a
|
|
951
|
+
// confirmation of this request.
|
|
952
|
+
return jsonResponse({
|
|
953
|
+
error: "operation does not match the requested change",
|
|
954
|
+
code: "invalid_preview_operation",
|
|
955
|
+
}, 400, req, ctx.config);
|
|
956
|
+
}
|
|
957
|
+
|
|
701
958
|
try {
|
|
702
|
-
|
|
959
|
+
/*
|
|
960
|
+
* A bound request is built from the same passive roster the guard re-plans against, and an
|
|
961
|
+
* unbound one keeps its existing refreshing path. Building the refreshing input first would
|
|
962
|
+
* have had the mutation and its own confirmation check disagree about the roster by
|
|
963
|
+
* construction, which is the disagreement this binding exists to detect.
|
|
964
|
+
*/
|
|
965
|
+
const boundToggle = binding === "none"
|
|
966
|
+
? null
|
|
967
|
+
: await buildIntegrationPreviewInput(requestedClient, ctx, integrationStore());
|
|
968
|
+
if (binding !== "none" && boundToggle === null) return previewUnavailableResponse(ctx);
|
|
969
|
+
const input = boundToggle
|
|
970
|
+
? boundToggle.input
|
|
971
|
+
: await buildIntegrationWriteInput(requestedClient, ctx, integrationStore());
|
|
972
|
+
const guard = binding === "none" ? null : stalePlanGuard(
|
|
973
|
+
requestedClient,
|
|
974
|
+
ctx,
|
|
975
|
+
integrationStore(),
|
|
976
|
+
{ operation: binding.operation },
|
|
977
|
+
binding.fingerprint,
|
|
978
|
+
boundToggle === null ? null : boundToggle.identity,
|
|
979
|
+
);
|
|
703
980
|
const result = await runIntegrationMutationFlight(
|
|
704
981
|
requestedClient,
|
|
705
982
|
parsed.enabled ? (parsed.overwriteConflict === true ? "overwrite" : "apply") : "disable",
|
|
706
983
|
input.io?.now ?? Date.now,
|
|
707
984
|
() => {
|
|
708
|
-
const options = {
|
|
985
|
+
const options = {
|
|
986
|
+
lockSeams: integrationMutationTestHooks?.lockSeams,
|
|
987
|
+
...(guard ? { revalidate: guard.revalidate } : {}),
|
|
988
|
+
};
|
|
709
989
|
if (!parsed.enabled) return disableIntegrationCoordinated(input, options);
|
|
710
990
|
return parsed.overwriteConflict === true
|
|
711
991
|
? overwriteIntegrationCoordinated(input, options)
|
|
712
992
|
: applyIntegrationCoordinated(input, options);
|
|
713
993
|
},
|
|
714
994
|
);
|
|
995
|
+
const stale = guard?.response();
|
|
996
|
+
if (stale) return stale;
|
|
715
997
|
if (!result.ok) return writerFailureResponse(requestedClient, result, ctx);
|
|
716
998
|
return jsonResponse(result satisfies IntegrationToggleEnvelope, 200, req, ctx.config);
|
|
717
999
|
} catch (error) {
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { ManagementContext } from "./context";
|
|
2
|
+
|
|
3
|
+
export function handleMetricsRoutes(ctx: ManagementContext): Response | null {
|
|
4
|
+
if (ctx.url.pathname === "/api/metrics" && ctx.req.method === "GET") {
|
|
5
|
+
if (!ctx.deps.requestMetrics) {
|
|
6
|
+
return Response.json({ error: { code: "not_found", message: "metrics export is disabled" } }, {
|
|
7
|
+
status: 404,
|
|
8
|
+
headers: { "Cache-Control": "no-store" },
|
|
9
|
+
});
|
|
10
|
+
}
|
|
11
|
+
return new Response(ctx.deps.requestMetrics.snapshot(), {
|
|
12
|
+
status: 200,
|
|
13
|
+
headers: {
|
|
14
|
+
"Cache-Control": "no-store",
|
|
15
|
+
"Content-Type": "text/plain;version=0.0.4",
|
|
16
|
+
},
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
@@ -9,6 +9,12 @@
|
|
|
9
9
|
* Bodies are unchanged from their previous home; only `export` was added.
|
|
10
10
|
*/
|
|
11
11
|
import type { CatalogModel } from "../../codex/catalog";
|
|
12
|
+
import { observeModelCacheRevision } from "../../codex/model-cache";
|
|
13
|
+
import {
|
|
14
|
+
captureExportConfigAdmission,
|
|
15
|
+
isExportConfigAdmissionCurrent,
|
|
16
|
+
type ExportConfigAdmission,
|
|
17
|
+
} from "../../config/admitted-identity";
|
|
12
18
|
import {
|
|
13
19
|
catalogModelSlug,
|
|
14
20
|
filterCatalogVisibleModels,
|
|
@@ -28,7 +34,7 @@ import { routedSlug, slugEquals } from "../../providers/slug-codec";
|
|
|
28
34
|
import type { OcxConfig } from "../../types";
|
|
29
35
|
import { ensureCodexEntitlementFreshness } from "../../codex/model-entitlements";
|
|
30
36
|
import { fetchAllModels } from "./shared";
|
|
31
|
-
import { initialModelSelectionPending } from "../../providers/initial-model-selection";
|
|
37
|
+
import { initialModelSelectionPending, pendingModelSelectionProviders } from "../../providers/initial-model-selection";
|
|
32
38
|
import { catalogFastRowEligible, fastRowId } from "../fast-row";
|
|
33
39
|
import { knownEffortRowIds } from "../effort-row";
|
|
34
40
|
|
|
@@ -77,14 +83,29 @@ export function effectiveManagementDisplayName(
|
|
|
77
83
|
*/
|
|
78
84
|
export async function listManagementModelRows(
|
|
79
85
|
config: OcxConfig,
|
|
80
|
-
options: {
|
|
86
|
+
options: {
|
|
87
|
+
entitlementWaitMs?: number;
|
|
88
|
+
models?: readonly CatalogModel[];
|
|
89
|
+
/** Filled with each provider's content revision as of the moment its rows were chosen. */
|
|
90
|
+
providerContentRevisions?: Map<string, string>;
|
|
91
|
+
} = {},
|
|
81
92
|
): Promise<ManagementModelRow[]> {
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
93
|
+
/*
|
|
94
|
+
* A supplied roster skips the gather, and that is the point rather than an optimization.
|
|
95
|
+
* `fetchAllModels` reaches providers and can persist an initial model selection, which a
|
|
96
|
+
* read-only caller must not do. Everything below this line is the projection — the disabled
|
|
97
|
+
* computation, native and account-bound rows, custom rows and the public list — so a caller
|
|
98
|
+
* that brings its own roster still sees exactly what a writer would, and the two cannot
|
|
99
|
+
* disagree about the roster for any reason except the roster itself.
|
|
100
|
+
*/
|
|
101
|
+
const models = options.models === undefined
|
|
102
|
+
? (await Promise.all([
|
|
103
|
+
fetchAllModels(config, options.providerContentRevisions),
|
|
104
|
+
ensureCodexEntitlementFreshness(config, {
|
|
105
|
+
waitMs: options.entitlementWaitMs ?? 3_000,
|
|
106
|
+
}),
|
|
107
|
+
]))[0]
|
|
108
|
+
: [...options.models];
|
|
88
109
|
const disabled = new Set(config.disabledModels ?? []);
|
|
89
110
|
// Native GPT passthrough rows lead (provider "openai", bare-slug namespaced ids): sourced
|
|
90
111
|
// from the static supported set so a disabled model stays listed and re-enableable.
|
|
@@ -236,10 +257,201 @@ export function toExportModel(row: ManagementModelRow): ExportModel {
|
|
|
236
257
|
* tab is absent from `/v1/models` and exporting it would hand the client a
|
|
237
258
|
* selector the proxy refuses to route.
|
|
238
259
|
*/
|
|
239
|
-
export async function loadExportModels(
|
|
240
|
-
|
|
260
|
+
export async function loadExportModels(
|
|
261
|
+
config: OcxConfig,
|
|
262
|
+
models?: readonly CatalogModel[],
|
|
263
|
+
): Promise<ExportModel[]> {
|
|
264
|
+
// Initial selection adopts into the live configuration and persists it, so it has to finish
|
|
265
|
+
// before anything is admitted. Admitting first would bind this roster to bytes the same load is
|
|
266
|
+
// about to rewrite, and finalizing against a detached copy would adopt the choices into the copy
|
|
267
|
+
// while leaving the live configuration pending.
|
|
268
|
+
if (models === undefined && pendingModelSelectionProviders(config).size > 0) {
|
|
269
|
+
const { resolvePendingInitialModelSelection } = await import("../../providers/initial-model-selection-runtime");
|
|
270
|
+
await resolvePendingInitialModelSelection(config);
|
|
271
|
+
}
|
|
272
|
+
// The configuration this pass will use from beginning to end, proved to be the one on disk.
|
|
273
|
+
// Without it there is nothing that may be retained, and the caller still gets its rows: only the
|
|
274
|
+
// preview authority is withheld.
|
|
275
|
+
const admission = captureExportConfigAdmission(config);
|
|
276
|
+
const admitted = admission?.config ?? config;
|
|
277
|
+
// The gather stamps each provider as it chooses its rows, so the roster and the revisions that
|
|
278
|
+
// vouch for it come from the same moment. Sampling afterwards would let a concurrent flight's
|
|
279
|
+
// publication be recorded against rows it never produced.
|
|
280
|
+
const gathered = new Map<string, string>();
|
|
281
|
+
// Gathering here rather than through the shared fetch is what keeps the detached copy out of the
|
|
282
|
+
// initial-selection finalizer: the projection below takes a roster, and that branch performs no
|
|
283
|
+
// discovery and no configuration write. The entitlement refresh keeps the budget it has always
|
|
284
|
+
// had, and runs alongside as it did inside the projection.
|
|
285
|
+
const roster = models === undefined
|
|
286
|
+
? (await Promise.all([
|
|
287
|
+
(await import("../../codex/catalog")).gatherRoutedModels(admitted, { providerContentRevisions: gathered }),
|
|
288
|
+
ensureCodexEntitlementFreshness(admitted, { waitMs: 3_000 }),
|
|
289
|
+
]))[0]
|
|
290
|
+
: models;
|
|
291
|
+
const rows = await listManagementModelRows(admitted, { models: roster });
|
|
241
292
|
// Management deliberately lists the full roster so hidden models can be enabled.
|
|
242
293
|
// A client picker must also honor the provider selection, not just its blocklist.
|
|
243
|
-
const visibleRouted = new Set(filterCatalogVisibleModels(rows.filter(row => !row.native),
|
|
244
|
-
|
|
294
|
+
const visibleRouted = new Set(filterCatalogVisibleModels(rows.filter(row => !row.native), admitted));
|
|
295
|
+
const exported = rows.filter(row => !row.disabled && (row.native || visibleRouted.has(row))).map(toExportModel);
|
|
296
|
+
// Retain the FINAL projection, not an input to it. A preview that rebuilt from raw provider
|
|
297
|
+
// caches would miss static and forward providers, which never populate one, and would skip the
|
|
298
|
+
// retention, metadata, combo and filtering this function applies afterwards.
|
|
299
|
+
// A deep clone, not a frozen view of the caller's array. Freezing the array alone left the model
|
|
300
|
+
// objects shared, so a caller mutating one in place would have silently rewritten the roster a
|
|
301
|
+
// later preview plans against, and the fingerprint would have moved with it.
|
|
302
|
+
// Still the configuration these rows were chosen under, on disk and in hand alike. Revalidating
|
|
303
|
+
// rather than re-reading an identity is what makes this fail closed: a configuration that moved
|
|
304
|
+
// during the load leaves no snapshot rather than one recorded under a state its rows never had.
|
|
305
|
+
if (admission === null || !isExportConfigAdmissionCurrent(admission, config)) {
|
|
306
|
+
lastExportSnapshot = null;
|
|
307
|
+
return exported;
|
|
308
|
+
}
|
|
309
|
+
// Prefer the revisions the gather stamped; fall back to observing only when the roster was
|
|
310
|
+
// supplied and no gather happened, where there is nothing tighter to use.
|
|
311
|
+
const cacheStamp = gathered.size > 0 ? stampFrom(admitted, gathered) : modelCacheStamp(admitted);
|
|
312
|
+
const retained = lastExportSnapshot;
|
|
313
|
+
/*
|
|
314
|
+
* An identical roster keeps the identity it already had.
|
|
315
|
+
*
|
|
316
|
+
* The generation moved on every load, so an ordinary read that rebuilt the same rows, which the
|
|
317
|
+
* Integrations collection does, invalidated a confirmation an operator was in the middle of
|
|
318
|
+
* submitting. Nothing about the roster had changed; only the counter had. The rows themselves
|
|
319
|
+
* are compared rather than assumed equal from the configuration and the cache stamp, because a
|
|
320
|
+
* projection also reads entitlement state neither of those two describes.
|
|
321
|
+
*/
|
|
322
|
+
const projection = Object.freeze(structuredClone(exported));
|
|
323
|
+
if (retained !== null
|
|
324
|
+
&& retained.cacheStamp === cacheStamp
|
|
325
|
+
&& isExportConfigAdmissionCurrent(retained.admission, config)
|
|
326
|
+
&& JSON.stringify(retained.models) === JSON.stringify(projection)) {
|
|
327
|
+
return exported;
|
|
328
|
+
}
|
|
329
|
+
lastExportSnapshot = { admission, cacheStamp, generation: ++exportSnapshotGeneration, models: projection };
|
|
330
|
+
return exported;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* The completed export roster from the last ordinary load, if it still describes this config.
|
|
335
|
+
*
|
|
336
|
+
* A preview may not gather, so it reads only what an authoritative load already finished. The
|
|
337
|
+
* admission it carries proved, when the roster was built, that the configuration in hand was the
|
|
338
|
+
* one on disk; a later read repeats that proof, so a rewritten file, an edited resident object or
|
|
339
|
+
* a mutated working copy each retire the snapshot rather than letting a preview plan against a
|
|
340
|
+
* configuration nobody has.
|
|
341
|
+
*
|
|
342
|
+
* A cold process has no snapshot and the caller answers a bounded refusal, and an ordinary load
|
|
343
|
+
* populates one: the Integrations collection read calls `loadExportModels`, so the page an
|
|
344
|
+
* operator opens before confirming anything is usually the page that fills this in. That is not a
|
|
345
|
+
* repair for every refusal. A configuration that disagrees with its file keeps refusing however
|
|
346
|
+
* many times the page is opened, because nothing here reloads or reconciles anything; once the
|
|
347
|
+
* two agree again the next ordinary read rebuilds the snapshot by itself.
|
|
348
|
+
*/
|
|
349
|
+
let lastExportSnapshot:
|
|
350
|
+
| { admission: ExportConfigAdmission; cacheStamp: string; generation: number; models: readonly ExportModel[] }
|
|
351
|
+
| null = null;
|
|
352
|
+
let exportSnapshotGeneration = 0;
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* A process-local prefix for the roster identity a caller carries between a preview and the
|
|
356
|
+
* mutation that confirms it.
|
|
357
|
+
*
|
|
358
|
+
* The identity used to be the configuration digest with a counter appended, which handed a
|
|
359
|
+
* dashboard an opaque-looking string that was in fact a fingerprint of the operator's
|
|
360
|
+
* configuration file. It only has to be unforgeable within this process and distinct across
|
|
361
|
+
* restarts, so it says nothing about the configuration at all.
|
|
362
|
+
*/
|
|
363
|
+
const rosterIdentityPrefix = `r${Math.trunc(Math.random() * 0xffffffff).toString(36)}`;
|
|
364
|
+
|
|
365
|
+
function rosterIdentity(generation: number): string {
|
|
366
|
+
return `${rosterIdentityPrefix}:${generation}`;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Where the gathered half of the roster stands, observed without changing it.
|
|
371
|
+
*
|
|
372
|
+
* The config key cannot see a provider's models changing underneath an unchanged configuration,
|
|
373
|
+
* which is exactly what discovery does. This reads the cache's own generation for each configured
|
|
374
|
+
* provider through the passive observer, so a completed discovery retires the snapshot and a
|
|
375
|
+
* preview stops planning against a roster that no longer reflects the provider.
|
|
376
|
+
*/
|
|
377
|
+
function modelCacheStamp(config: OcxConfig): string {
|
|
378
|
+
return Object.keys(config.providers ?? {})
|
|
379
|
+
.sort()
|
|
380
|
+
.map(provider => `${provider}=${observeModelCacheRevision(provider)}`)
|
|
381
|
+
.join(",");
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* The same stamp shape, built from revisions the gather recorded rather than from observation.
|
|
386
|
+
*
|
|
387
|
+
* A provider the gather did not report falls back to observation so the stamp stays total; that
|
|
388
|
+
* happens for a provider configured after the rows were chosen, and it retires the snapshot on
|
|
389
|
+
* the next read rather than pretending the roster covered it.
|
|
390
|
+
*/
|
|
391
|
+
function stampFrom(config: OcxConfig, gathered: ReadonlyMap<string, string>): string {
|
|
392
|
+
return Object.keys(config.providers ?? {})
|
|
393
|
+
.sort()
|
|
394
|
+
.map(provider => `${provider}=${gathered.get(provider) ?? observeModelCacheRevision(provider)}`)
|
|
395
|
+
.join(",");
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* Opaque identity of the snapshot a caller is holding, or null when there is none for this config.
|
|
400
|
+
*
|
|
401
|
+
* A fingerprint check that rebuilt its own roster could validate against one snapshot while the
|
|
402
|
+
* mutation wrote from another, because an ordinary load can replace the snapshot at any moment and
|
|
403
|
+
* nothing about that is serialised against the writer lock. Carrying this identity alongside the
|
|
404
|
+
* captured roster lets a revalidation prove the snapshot it captured is still the current one
|
|
405
|
+
* without ever swapping the roster the mutation is about to use.
|
|
406
|
+
*/
|
|
407
|
+
export function exportSnapshotIdentity(config: OcxConfig): string | null {
|
|
408
|
+
const snapshot = lastExportSnapshot;
|
|
409
|
+
if (snapshot === null) return null;
|
|
410
|
+
if (!isExportConfigAdmissionCurrent(snapshot.admission, config)) return null;
|
|
411
|
+
if (snapshot.cacheStamp !== modelCacheStamp(config)) return null;
|
|
412
|
+
return rosterIdentity(snapshot.generation);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/** Test seam: a fresh process has no snapshot, and suites must be able to reproduce that. */
|
|
416
|
+
export function resetExportSnapshotForTests(): void {
|
|
417
|
+
lastExportSnapshot = null;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* The export roster for a read that must change nothing at all, or null when there is not one.
|
|
422
|
+
*
|
|
423
|
+
* Skipping the initial-selection finalizer was not enough. Discovery itself refreshes credentials
|
|
424
|
+
* and writes the provider model cache, so a preview that gathered would still be a write dressed
|
|
425
|
+
* as a read, and "the models list already does this" describes what a GET happens to do rather
|
|
426
|
+
* than what a preview is allowed to do.
|
|
427
|
+
*
|
|
428
|
+
* So this reads already-captured per-provider cache entries and never fetches. When no provider
|
|
429
|
+
* has a cached roster there is no honest snapshot to plan against, and the caller reports a
|
|
430
|
+
* bounded refusal rather than triggering a gather to manufacture one.
|
|
431
|
+
*/
|
|
432
|
+
export function previewExportSnapshot(
|
|
433
|
+
config: OcxConfig,
|
|
434
|
+
): { models: readonly ExportModel[]; identity: string } | null {
|
|
435
|
+
// One synchronous read of one const. Taking the roster and its identity in two steps let a
|
|
436
|
+
// concurrent load publish a new snapshot between them, so a caller could hold one roster while
|
|
437
|
+
// believing it held the identity of another.
|
|
438
|
+
const snapshot = lastExportSnapshot;
|
|
439
|
+
if (snapshot === null) return null;
|
|
440
|
+
// The roster was built from a configuration proved to be the one on disk; this asks whether both
|
|
441
|
+
// are still that same configuration, and reads nothing but the file to answer.
|
|
442
|
+
if (!isExportConfigAdmissionCurrent(snapshot.admission, config)) return null;
|
|
443
|
+
// A completed discovery retires the snapshot: the configuration is unchanged, but the models it
|
|
444
|
+
// resolves to are not the ones this roster was built from.
|
|
445
|
+
if (snapshot.cacheStamp !== modelCacheStamp(config)) return null;
|
|
446
|
+
// Cloned on the way out as well as on the way in. The retained copy is the authority, and a
|
|
447
|
+
// reader holding its objects could edit the roster every later preview plans against without
|
|
448
|
+
// going anywhere near this module.
|
|
449
|
+
return {
|
|
450
|
+
models: structuredClone(snapshot.models) as readonly ExportModel[],
|
|
451
|
+
identity: rosterIdentity(snapshot.generation),
|
|
452
|
+
};
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
export function previewExportModels(config: OcxConfig): readonly ExportModel[] | null {
|
|
456
|
+
return previewExportSnapshot(config)?.models ?? null;
|
|
245
457
|
}
|