@k2b/cloud 0.42.0 → 0.44.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/package.json +13 -5
- package/scripts/app-favicon.ts +7 -6
- package/scripts/build.ts +3 -0
- package/scripts/installable-assets.ts +61 -0
- package/scripts/preload.ts +3 -0
- package/src/_internal/app-access-path.ts +12 -0
- package/src/_internal/app-access-request.ts +13 -0
- package/src/_internal/app-access-resources.ts +141 -0
- package/src/_internal/app-event-catalog.ts +40 -0
- package/src/_internal/app-event-schemas.ts +127 -0
- package/src/_internal/application-package.ts +40 -0
- package/src/_internal/built-in-app-ids.json +27 -0
- package/src/_internal/capabilities.ts +185 -12
- package/src/_internal/capability-origin.ts +4 -0
- package/src/_internal/capability-streams.ts +10 -5
- package/src/_internal/define-app.ts +159 -45
- package/src/_internal/installable-assets.ts +90 -0
- package/src/_internal/installable-head.ts +22 -0
- package/src/_internal/object-store-area.ts +9 -0
- package/src/_internal/object-store-config.ts +101 -0
- package/src/_internal/object-store-key.ts +14 -0
- package/src/_internal/page-responses.ts +34 -3
- package/src/_internal/process-identity.ts +7 -2
- package/src/_internal/registry-validation.ts +15 -1
- package/src/_internal/registry.ts +44 -1
- package/src/_internal/runtime-context.ts +9 -1
- package/src/_internal/runtime-watcher.ts +8 -0
- package/src/_internal/status-preserving-ssr.ts +12 -1
- package/src/access/AppAccessEditor.browser-fixture.ts +429 -0
- package/src/access/AppAccessEditor.browser-frame.tsx +68 -0
- package/src/access/AppAccessEditor.browser-harness.tsx +65 -0
- package/src/access/AppAccessEditor.tsx +380 -0
- package/src/access/PermissionEditor.browser-harness.tsx +22 -2
- package/src/access/PermissionEditor.tsx +386 -77
- package/src/access/PrincipalAvatar.tsx +63 -0
- package/src/access/PrincipalPicker.tsx +29 -8
- package/src/access/ResourceApiKeys.tsx +3 -4
- package/src/access/app-access-adapter.ts +51 -0
- package/src/access/app-access-messages.ts +172 -0
- package/src/access/messages.ts +14 -0
- package/src/access/ui.ts +4 -1
- package/src/account/Avatar.tsx +21 -3
- package/src/account/ProfilePictureDialog.tsx +699 -0
- package/src/account/profile-picture-messages.ts +269 -0
- package/src/account/ui.ts +8 -1
- package/src/ai/admin.ts +3 -0
- package/src/ai/app-frame.ts +12 -0
- package/src/ai/application-tasks.ts +101 -0
- package/src/ai/assistant-models.ts +126 -64
- package/src/ai/capabilities.ts +19 -4
- package/src/ai/capability-execution.ts +5 -2
- package/src/ai/chart-block.ts +63 -30
- package/src/ai/chat/chart-block.tsx +12 -3
- package/src/ai/chat/composer-adapter.ts +8 -7
- package/src/ai/chat/message-actions.tsx +1 -1
- package/src/ai/chat/message-utils.ts +2 -2
- package/src/ai/chat/presentation.tsx +22 -1
- package/src/ai/chat-quotas.ts +5 -4
- package/src/ai/chat-tasks.ts +2 -2
- package/src/ai/client/controller.ts +11 -10
- package/src/ai/client/projection.ts +3 -3
- package/src/ai/code-capability-routes.ts +1 -0
- package/src/ai/compaction.ts +3 -3
- package/src/ai/credentials.ts +13 -114
- package/src/ai/default-tool-contracts.ts +4 -3
- package/src/ai/dictation-runtime.ts +4 -4
- package/src/ai/enrich.ts +2 -2
- package/src/ai/executor.ts +38 -27
- package/src/ai/http.ts +17 -29
- package/src/ai/index.ts +11 -5
- package/src/ai/inference-calls.ts +6 -4
- package/src/ai/maintenance.ts +1 -1
- package/src/ai/memory-learning-runs.ts +5 -5
- package/src/ai/memory-learning.ts +3 -3
- package/src/ai/message-queue.ts +1 -2
- package/src/ai/migrate.ts +75 -42
- package/src/ai/model-access-migrate.ts +3 -17
- package/src/ai/model-access.ts +44 -85
- package/src/ai/model-card.tsx +134 -0
- package/src/ai/model-catalog.ts +359 -0
- package/src/ai/model-chooser-messages.ts +88 -0
- package/src/ai/model-chooser.tsx +577 -0
- package/src/ai/model-delete.ts +234 -0
- package/src/ai/model-fixture.ts +39 -0
- package/src/ai/model-icon.tsx +36 -0
- package/src/ai/model-overview.ts +53 -0
- package/src/ai/model-usage.ts +45 -0
- package/src/ai/openai-api-messages.ts +0 -4
- package/src/ai/openai-api-slots.ts +10 -2
- package/src/ai/openai-api.ts +83 -16
- package/src/ai/personal-agent.ts +1 -1
- package/src/ai/prefs.ts +12 -10
- package/src/ai/projects-routes.ts +2 -1
- package/src/ai/projects.ts +13 -12
- package/src/ai/protocol.ts +2 -2
- package/src/ai/provider-connections.ts +161 -0
- package/src/ai/provider-errors.ts +138 -0
- package/src/ai/provider-fetch.ts +4 -0
- package/src/ai/provider-retry.ts +1 -1
- package/src/ai/provider.ts +2 -1
- package/src/ai/quota-limits.ts +36 -0
- package/src/ai/quota-provider.ts +11 -2
- package/src/ai/quota-report.ts +7 -4
- package/src/ai/quotas-migrate.ts +1 -0
- package/src/ai/quotas.ts +19 -8
- package/src/ai/recent-resources.ts +6 -0
- package/src/ai/request-headers.ts +14 -87
- package/src/ai/resource-refs.ts +6 -2
- package/src/ai/routes.ts +17 -18
- package/src/ai/runtime.ts +50 -22
- package/src/ai/settings.ts +93 -580
- package/src/ai/store.ts +45 -23
- package/src/ai/stream.ts +1 -1
- package/src/ai/structured-runs.ts +2 -2
- package/src/ai/structured.ts +29 -46
- package/src/ai/task-contracts.ts +25 -5
- package/src/ai/task-execution.ts +15 -6
- package/src/ai/task-model.ts +9 -0
- package/src/ai/transcription.ts +37 -21
- package/src/ai/turn-failure.ts +24 -2
- package/src/ai/types.ts +50 -62
- package/src/ai/ui.tsx +13 -0
- package/src/ai/usage.ts +8 -8
- package/src/ai/validate.ts +8 -7
- package/src/ai/view-block.ts +2 -2
- package/src/api/accounts-entities.ts +40 -16
- package/src/api/admin-ai-quotas.ts +34 -56
- package/src/api/admin-ai-usage.ts +1 -1
- package/src/api/admin-ai.ts +173 -0
- package/src/api/admin-core-settings.ts +68 -244
- package/src/api/admin-directory-shares.ts +55 -0
- package/src/api/admin-identity.ts +81 -2
- package/src/api/app-access.ts +85 -0
- package/src/api/app-approval.ts +3 -1
- package/src/api/app-event-delivery.ts +51 -0
- package/src/api/app-events.ts +123 -0
- package/src/api/app-resource-access.ts +48 -0
- package/src/api/auth/account-mismatch.ts +24 -0
- package/src/api/auth.ts +26 -3
- package/src/api/capabilities.ts +127 -15
- package/src/api/capability-streams.ts +2 -5
- package/src/api/identity-child-invocation.ts +145 -0
- package/src/api/index.ts +11 -2
- package/src/api/mcp.ts +180 -5
- package/src/api/me.ts +38 -17
- package/src/api/pwa-phone.ts +87 -48
- package/src/api/pwa.ts +34 -8
- package/src/api/resource-links.ts +83 -6
- package/src/api/resource-resolve.ts +50 -0
- package/src/api/search/schemas.ts +3 -1
- package/src/api/search.ts +9 -7
- package/src/api/widgets.ts +1 -0
- package/src/app-pwa/authority.ts +75 -0
- package/src/app-pwa/contracts.ts +177 -0
- package/src/app-pwa/core-query-error.ts +20 -0
- package/src/app-pwa/index.ts +23 -0
- package/src/app-pwa/protocol.ts +27 -0
- package/src/app-pwa/schema.ts +59 -0
- package/src/app-pwa/service.ts +861 -0
- package/src/app-pwa/transaction.ts +338 -0
- package/src/app-pwa/transport-input.ts +57 -0
- package/src/app-pwa/transport.ts +235 -0
- package/src/approvals/messages.ts +2 -2
- package/src/browser/app-access-recovery.ts +44 -0
- package/src/browser/app-session.ts +14 -3
- package/src/browser/cloud-id.ts +86 -0
- package/src/browser/live-websocket.ts +5 -0
- package/src/browser/pwa-drafts.ts +57 -0
- package/src/browser/resource-clipboard.ts +4 -0
- package/src/browser/resource-links.ts +52 -0
- package/src/browser/resource-picker.tsx +3 -2
- package/src/capabilities/calendar-sources.ts +302 -0
- package/src/capabilities/executions.ts +10 -3
- package/src/capabilities/migrate.ts +4 -0
- package/src/capabilities/nested.ts +43 -0
- package/src/capabilities/server-invoke.ts +202 -0
- package/src/capabilities/server.ts +26 -187
- package/src/capabilities/testing.ts +5 -2
- package/src/cli/access.ts +6 -5
- package/src/cli/account.ts +57 -0
- package/src/cli/admin/agents.ts +66 -0
- package/src/cli/admin/ai-models.ts +199 -0
- package/src/cli/admin/ai-quotas.ts +6 -102
- package/src/cli/admin/ai-usage.ts +4 -4
- package/src/cli/admin/app-credentials.ts +5 -3
- package/src/cli/admin/index.ts +7 -3
- package/src/cli/app-access.ts +60 -0
- package/src/cli/apps.ts +89 -26
- package/src/cli/avatar.ts +64 -0
- package/src/cli/capabilities.ts +22 -1
- package/src/cli/events.ts +320 -0
- package/src/cli/index.ts +1 -1
- package/src/config/env.ts +40 -0
- package/src/contracts/app-access.ts +52 -5
- package/src/contracts/app-approval.ts +8 -1
- package/src/contracts/app-details.ts +89 -0
- package/src/contracts/app-events.ts +51 -0
- package/src/contracts/app.ts +24 -5
- package/src/contracts/calendar-source.ts +274 -0
- package/src/contracts/calendar-view.ts +35 -0
- package/src/contracts/capabilities.ts +164 -3
- package/src/contracts/geo-admin.ts +2 -0
- package/src/contracts/geo-route.ts +0 -11
- package/src/contracts/geo.ts +14 -2
- package/src/contracts/index.ts +6 -0
- package/src/contracts/installable.ts +40 -0
- package/src/contracts/notification-types.ts +36 -9
- package/src/contracts/profile.ts +32 -0
- package/src/contracts/pwa-builds.ts +14 -0
- package/src/contracts/pwa-location.ts +38 -0
- package/src/contracts/pwa.ts +24 -3
- package/src/contracts/registry.ts +8 -0
- package/src/contracts/resource-clipboard.ts +35 -0
- package/src/contracts/resource-pages.ts +140 -0
- package/src/contracts/resource-resolution.ts +13 -0
- package/src/contracts/shared.ts +4 -0
- package/src/contracts/user-notifications.ts +2 -0
- package/src/events/app-event-delivery.ts +100 -0
- package/src/events/app-events.testing.ts +65 -0
- package/src/events/app-events.ts +134 -0
- package/src/events/core-user-created.ts +24 -0
- package/src/events/index.ts +9 -0
- package/src/events/testing.ts +2 -0
- package/src/index.ts +2 -1
- package/src/server/api/respond.ts +8 -3
- package/src/server/index.ts +1 -0
- package/src/server/middleware/app-access.ts +7 -6
- package/src/server/middleware/auth.ts +168 -66
- package/src/server/middleware/index.ts +1 -0
- package/src/server/middleware/invocation.ts +4 -2
- package/src/server/middleware/workload.ts +3 -3
- package/src/server/oauth-scope.ts +10 -0
- package/src/server/public-storage.ts +106 -0
- package/src/server/services/access.ts +8 -0
- package/src/services/accounts/app.ts +35 -3
- package/src/services/accounts/avatar.ts +342 -35
- package/src/services/accounts/base-group.ts +4 -5
- package/src/services/accounts/directory-shares.ts +157 -0
- package/src/services/accounts/entities.ts +50 -6
- package/src/services/accounts/groups.ts +3 -1
- package/src/services/accounts/identities.ts +64 -12
- package/src/services/accounts/local-groups.ts +1 -1
- package/src/services/accounts/users.ts +23 -15
- package/src/services/app-access-management.ts +519 -0
- package/src/services/app-access.ts +25 -7
- package/src/services/app-approval.ts +15 -4
- package/src/services/app-details.ts +224 -0
- package/src/services/app-discovery.ts +2 -1
- package/src/services/app-event-shapes.ts +139 -0
- package/src/services/app-events/accept.ts +116 -0
- package/src/services/app-events/admin.ts +250 -0
- package/src/services/app-events/declarations.ts +187 -0
- package/src/services/app-events/deliver.ts +218 -0
- package/src/services/app-events.ts +92 -0
- package/src/services/config-conflict.ts +11 -0
- package/src/services/core-user-events.ts +1 -0
- package/src/services/freeipa-config.ts +9 -0
- package/src/services/geo/admin.ts +4 -1
- package/src/services/geo/ledger.ts +47 -14
- package/src/services/geo/route-chain.ts +40 -18
- package/src/services/geo/route-estimate.ts +19 -0
- package/src/services/geo/route-plan.ts +0 -2
- package/src/services/geo/route-service.ts +57 -39
- package/src/services/geo/service.ts +51 -25
- package/src/services/identity/core-origin.ts +12 -0
- package/src/services/identity/index.ts +2 -1
- package/src/services/identity/invocation-actor.ts +50 -2
- package/src/services/identity/invocation-authority.ts +38 -1
- package/src/services/identity/invocation-token.ts +255 -50
- package/src/services/identity/issue-invocation.ts +9 -1
- package/src/services/identity/models-only-auth.ts +13 -4
- package/src/services/identity/nested-invocation.ts +128 -0
- package/src/services/image-metadata.ts +65 -1
- package/src/services/index.ts +24 -1
- package/src/services/ipa/sync.ts +6 -2
- package/src/services/ipa/users.ts +4 -1
- package/src/services/mandates/policy.ts +19 -12
- package/src/services/notifications/browser.ts +4 -0
- package/src/services/notifications/catalog.ts +5 -2
- package/src/services/notifications/platform.ts +7 -2
- package/src/services/notifications/user.ts +18 -7
- package/src/services/oauth-tokens.ts +28 -2
- package/src/services/object-store.ts +217 -0
- package/src/services/providers/local/users.ts +5 -0
- package/src/services/pwa-devices.ts +147 -37
- package/src/services/service-account-credentials.ts +5 -2
- package/src/services/session/index.ts +14 -0
- package/src/services/settings/core-settings.ts +91 -47
- package/src/services/settings/index.ts +8 -0
- package/src/services/settings/versions.ts +26 -0
- package/src/shared/ai-models.ts +274 -0
- package/src/shared/ai-quotas.ts +2 -2
- package/src/shared/ai-usage.ts +2 -2
- package/src/shared/index.ts +2 -0
- package/src/shared-text/documents.ts +25 -8
- package/src/shared-text/log.ts +1 -1
- package/src/shared-text/text-diff.ts +75 -0
- package/src/ssr/AdminArea.tsx +131 -0
- package/src/ssr/AdminLayout.tsx +11 -3
- package/src/ssr/AdminSidebar.tsx +7 -7
- package/src/ssr/AppAccessDenied.tsx +114 -0
- package/src/ssr/AppBadge.tsx +14 -0
- package/src/ssr/AppLaunchpadPanel.tsx +1 -1
- package/src/ssr/AppPwaRuntime.island.tsx +82 -0
- package/src/ssr/CloudShell.island.tsx +25 -4
- package/src/ssr/Layout.tsx +15 -7
- package/src/ssr/PwaLayout.tsx +73 -8
- package/src/ssr/PwaPartCount.island.tsx +23 -0
- package/src/ssr/PwaRuntime.island.tsx +85 -6
- package/src/ssr/SettingsSaveOutcome.tsx +58 -0
- package/src/ssr/SettingsSaveStatus.island.tsx +16 -0
- package/src/ssr/ShellRuntime.island.tsx +5 -0
- package/src/ssr/__snapshots__/Layout.unchanged.render.test.tsx.snap +3 -3
- package/src/ssr/__snapshots__/LayoutHelp.ssr.render.test.tsx.snap +1 -1
- package/src/ssr/admin-active-link.ts +16 -0
- package/src/ssr/admin-navigation.ts +26 -20
- package/src/ssr/app-badges.ts +31 -7
- package/src/ssr/app-pwa-document.tsx +16 -0
- package/src/ssr/app-pwa-messages.ts +17 -0
- package/src/ssr/index.ts +8 -0
- package/src/ssr/layout-context.ts +1 -1
- package/src/ssr/page-trail-runtime.ts +3 -1
- package/src/ssr/platform-messages.ts +9 -1
- package/src/ssr/pwa-messages.ts +10 -2
- package/src/ssr/rail-navigation.ts +5 -1
- package/src/ssr/settings-draft.ts +153 -0
- package/src/ssr/shell-data.tsx +2 -0
- package/src/styles/global.css +1 -0
- package/src/styles/profile-picture.css +270 -0
- package/src/styles/utilities-navigation.css +6 -0
- package/src/workflows/ai/policy.ts +49 -0
- package/src/workflows/ai/runtime.ts +131 -21
- package/src/workflows/ai/store.ts +283 -35
- package/src/workflows/ai/types.ts +8 -2
- package/src/workflows/ai-actions.ts +148 -120
- package/src/workflows/authoring.ts +31 -0
- package/src/workflows/contracts.ts +1 -0
- package/src/workflows/definition.ts +2 -0
- package/src/workflows/editor.ts +15 -0
- package/src/workflows/index.ts +2 -1
- package/src/workflows/language/compiler.ts +34 -3
- package/src/workflows/runtime/executor.ts +132 -33
- package/src/workflows/runtime/index.ts +2 -0
- package/src/workflows/runtime/ports.ts +5 -1
- package/src/workflows/store/actions.ts +1 -1
- package/src/workflows/store/definitions.ts +5 -0
- package/src/workflows/store/runs.ts +5 -3
- package/src/workflows/store/worker.ts +7 -2
- package/src/ai/model-pricing.ts +0 -43
- package/src/ai/model-request-settings.ts +0 -98
- package/src/ai/settings-messages.ts +0 -129
- package/src/shared/ai-model-request-settings.ts +0 -21
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import type { TopicDeadLetter, TopicInventoryEntry } from "@k2b/sync";
|
|
2
|
+
import { type SQL, sql } from "bun";
|
|
3
|
+
import { lazySync } from "../_internal/process-sync";
|
|
4
|
+
import { getCapability, listCapabilities } from "../_internal/registry";
|
|
5
|
+
import type { AppEventEnvelope } from "../contracts/app-events";
|
|
6
|
+
import type { CapabilityRegistryEntry } from "../contracts/registry";
|
|
7
|
+
import { appEventTopic } from "../events/app-event-delivery";
|
|
8
|
+
import { createEventAcceptance } from "./app-events/accept";
|
|
9
|
+
import { createEventAdministration } from "./app-events/admin";
|
|
10
|
+
import { createDeclarationSync } from "./app-events/declarations";
|
|
11
|
+
import { createEventDelivery } from "./app-events/deliver";
|
|
12
|
+
import type { signAppEventInvocation } from "./identity/invocation-token";
|
|
13
|
+
import { logger } from "./logging";
|
|
14
|
+
|
|
15
|
+
export { APP_EVENT_DELIVERY_ATTEMPTS, APP_EVENT_DELIVERY_BACKOFF_MS } from "./app-events/deliver";
|
|
16
|
+
|
|
17
|
+
const log = logger("app-events:broker");
|
|
18
|
+
const topicInventory = lazySync((sync) => () => sync.listTopics({ idPrefix: "cloud:app-events" }));
|
|
19
|
+
type BrokerDependencies = {
|
|
20
|
+
db?: SQL;
|
|
21
|
+
registry?: () => Promise<CapabilityRegistryEntry[]>;
|
|
22
|
+
getApp?: typeof getCapability;
|
|
23
|
+
fetch?: typeof fetch;
|
|
24
|
+
sign?: typeof signAppEventInvocation;
|
|
25
|
+
deadLetters?: (after?: string) => Promise<TopicDeadLetter<AppEventEnvelope>[]>;
|
|
26
|
+
streams?: () => AsyncIterable<Pick<TopicInventoryEntry, "id" | "firstSequence">>;
|
|
27
|
+
};
|
|
28
|
+
const message = (error: unknown): string => (error instanceof Error ? error.message : String(error)).slice(0, 1000);
|
|
29
|
+
|
|
30
|
+
export const createAppEventBroker = (dependencies: BrokerDependencies = {}) => {
|
|
31
|
+
const db = dependencies.db ?? sql;
|
|
32
|
+
const registry = dependencies.registry ?? listCapabilities;
|
|
33
|
+
const declarations = createDeclarationSync(db, registry);
|
|
34
|
+
const admin = createEventAdministration(db, {
|
|
35
|
+
registry,
|
|
36
|
+
deadLetters: dependencies.deadLetters ?? ((after) => appEventTopic().deadLetters.list({ limit: 100, after })),
|
|
37
|
+
streams: dependencies.streams ?? (() => topicInventory()()),
|
|
38
|
+
});
|
|
39
|
+
const acceptance = createEventAcceptance(db, declarations.reconcileSubscriptions);
|
|
40
|
+
const delivery = createEventDelivery(db, dependencies.getApp ?? getCapability, declarations.reconcileSubscriptions, dependencies);
|
|
41
|
+
return { ...declarations, ...acceptance, ...delivery, ...admin };
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export const appEventBroker = createAppEventBroker();
|
|
45
|
+
export const startAppEventBroker = async (): Promise<() => Promise<void>> => {
|
|
46
|
+
const worker = await appEventTopic().process(
|
|
47
|
+
{
|
|
48
|
+
consumer: "core-app-events-v1",
|
|
49
|
+
start: "earliest",
|
|
50
|
+
concurrency: 1,
|
|
51
|
+
recoverDeadLetters: true,
|
|
52
|
+
delivery: { maxAttempts: 8, backoffMs: [1000, 5000, 30000, 120000, 600000, 1800000, 3600000], maxInFlight: 1 },
|
|
53
|
+
},
|
|
54
|
+
async (event) => {
|
|
55
|
+
try {
|
|
56
|
+
await appEventBroker.accept(event.data, event.sequence, event.attempt);
|
|
57
|
+
} catch (error) {
|
|
58
|
+
if (event.attempt >= 8) {
|
|
59
|
+
await appEventBroker.markSeen(event.sequence);
|
|
60
|
+
}
|
|
61
|
+
throw error;
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
);
|
|
65
|
+
let stopping = false;
|
|
66
|
+
let active: Promise<void> | null = null;
|
|
67
|
+
let tickCount = 0;
|
|
68
|
+
const run = () => {
|
|
69
|
+
if (stopping || active) return;
|
|
70
|
+
active = (async () => {
|
|
71
|
+
await appEventBroker.work();
|
|
72
|
+
if (++tickCount % 60 === 0) {
|
|
73
|
+
await appEventBroker.checkGap();
|
|
74
|
+
await appEventBroker.retain();
|
|
75
|
+
}
|
|
76
|
+
})()
|
|
77
|
+
.catch((error) => log.error("App event broker pass failed", { error: message(error) }))
|
|
78
|
+
.finally(() => {
|
|
79
|
+
active = null;
|
|
80
|
+
});
|
|
81
|
+
};
|
|
82
|
+
const timer = setInterval(run, 1000);
|
|
83
|
+
timer.unref();
|
|
84
|
+
run();
|
|
85
|
+
return async () => {
|
|
86
|
+
stopping = true;
|
|
87
|
+
clearInterval(timer);
|
|
88
|
+
worker.stop();
|
|
89
|
+
await worker.drain();
|
|
90
|
+
await active;
|
|
91
|
+
};
|
|
92
|
+
};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export class ConfigConflict extends Error {
|
|
2
|
+
readonly code = "conflict";
|
|
3
|
+
constructor(
|
|
4
|
+
readonly current: unknown,
|
|
5
|
+
readonly changedBy: { id: string; name: string } | null,
|
|
6
|
+
readonly changedAt: string,
|
|
7
|
+
readonly key?: string,
|
|
8
|
+
) {
|
|
9
|
+
super("This configuration changed. Read the current version before saving.");
|
|
10
|
+
}
|
|
11
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { coreUserEvents, coreUserEventTypes } from "../events/core-user-created";
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { X509Certificate } from "node:crypto";
|
|
2
|
+
import type { SQL } from "bun";
|
|
2
3
|
import { setFreeIpaTlsResolver } from "../server/services/freeipa/tls";
|
|
3
4
|
import { coreSettings } from "./settings/api";
|
|
5
|
+
import { readKeyState, SettingUnreadableError } from "./settings/store";
|
|
4
6
|
|
|
5
7
|
const MAX_CA_CERT_BYTES = 256 * 1024;
|
|
6
8
|
const PEM_CERTIFICATE_PATTERN = /-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----/g;
|
|
@@ -82,6 +84,13 @@ const normalizeStringList = (value: unknown, fallback: string[] = []): string[]
|
|
|
82
84
|
return normalized.length > 0 ? normalized : fallback;
|
|
83
85
|
};
|
|
84
86
|
|
|
87
|
+
/** Read the role mapping on the caller's connection during authorization. */
|
|
88
|
+
export const getFreeIpaAdminGroups = async (db: SQL): Promise<string[]> => {
|
|
89
|
+
const state = await readKeyState("freeipa.groups.admin", db);
|
|
90
|
+
if (state.stored === "unreadable") throw new SettingUnreadableError("freeipa.groups.admin");
|
|
91
|
+
return normalizeStringList(state.value, ["admins"]);
|
|
92
|
+
};
|
|
93
|
+
|
|
85
94
|
/**
|
|
86
95
|
* Read the full FreeIPA config snapshot from settings (Redis cache-aside +
|
|
87
96
|
* Postgres fallback). Always returns within Redis-TTL fresh data — no hidden
|
|
@@ -146,7 +146,7 @@ export const createGeoAdmin = (options: { db?: SQL; verify?: Verify; now?: () =>
|
|
|
146
146
|
const config = await readGeoConfig();
|
|
147
147
|
const periods = geoPeriods(now(), config.timezone);
|
|
148
148
|
const plan = config.plan === "custom" ? { dailyCredits: config.customDailyCredits } : GEO_PLANS[config.plan];
|
|
149
|
-
const [[month], [today], shares] = await Promise.all([
|
|
149
|
+
const [[month], [today], shares, [overruns]] = await Promise.all([
|
|
150
150
|
db<
|
|
151
151
|
{ used: number }[]
|
|
152
152
|
>`SELECT used::float8 AS used FROM geo.usage WHERE scope = 'cloud' AND subject = '*' AND period = ${periods.cloud.period}`,
|
|
@@ -155,6 +155,8 @@ export const createGeoAdmin = (options: { db?: SQL; verify?: Verify; now?: () =>
|
|
|
155
155
|
FROM geo.usage WHERE scope = 'person' AND period = ${periods.person.period}`,
|
|
156
156
|
db<{ function_group: string; used: number }[]>`
|
|
157
157
|
SELECT function_group, used::float8 AS used FROM geo.shares WHERE scope = 'cloud' AND subject = '*' AND period = ${periods.cloud.period}`,
|
|
158
|
+
db<{ count: number }[]>`SELECT count(*)::int AS count FROM audit.events
|
|
159
|
+
WHERE action = 'core.geo.route_cost_overrun' AND metadata->>'period' = ${periods.cloud.period}`,
|
|
158
160
|
]);
|
|
159
161
|
return {
|
|
160
162
|
month: {
|
|
@@ -174,6 +176,7 @@ export const createGeoAdmin = (options: { db?: SQL; verify?: Verify; now?: () =>
|
|
|
174
176
|
credits: (shares.find((row) => row.function_group === group)?.used ?? 0) / 1000,
|
|
175
177
|
})),
|
|
176
178
|
peopleAtLimit: today?.at_limit ?? 0,
|
|
179
|
+
routeCostOverruns: overruns?.count ?? 0,
|
|
177
180
|
timezone: config.timezone,
|
|
178
181
|
};
|
|
179
182
|
};
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type SQL, sql } from "bun";
|
|
2
|
+
import type { AppPwaActor } from "../../app-pwa/contracts";
|
|
2
3
|
import type { AccessSubject, CapabilityExecutionContext } from "../../contracts";
|
|
3
4
|
import { audit } from "../audit";
|
|
4
5
|
import { GeoError } from "./errors";
|
|
@@ -52,9 +53,21 @@ export const createGeoLedger = (db: SQL = sql) => ({
|
|
|
52
53
|
for (const row of rows)
|
|
53
54
|
await tx`INSERT INTO geo.usage(scope,subject,period) VALUES(${row.scope},${row.subject},${row.period}) ON CONFLICT DO NOTHING`;
|
|
54
55
|
for (const row of rows) {
|
|
56
|
+
// Keep the refusal's available/shortfall numbers in the same row-lock snapshot as admission.
|
|
57
|
+
const [balance] = await tx<{ used: number }[]>`SELECT used::float8 AS used FROM geo.usage
|
|
58
|
+
WHERE scope=${row.scope} AND subject=${row.subject} AND period=${row.period} FOR UPDATE`;
|
|
55
59
|
const changed = await tx`UPDATE geo.usage SET used=used+${cost} WHERE scope=${row.scope} AND subject=${row.subject}
|
|
56
60
|
AND period=${row.period} AND used+${cost}<=${row.limit} RETURNING used`;
|
|
57
|
-
if (!changed.length)
|
|
61
|
+
if (!changed.length) {
|
|
62
|
+
const available = row.limit - (balance?.used ?? 0);
|
|
63
|
+
throw new GeoError("GEO_BUDGET_EXHAUSTED", {
|
|
64
|
+
scope: row.scope,
|
|
65
|
+
resetsAt: row.resetsAt,
|
|
66
|
+
requiredCredits: cost / 1000,
|
|
67
|
+
availableCredits: available / 1000,
|
|
68
|
+
shortfallCredits: (cost - available) / 1000,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
58
71
|
}
|
|
59
72
|
for (const row of rows)
|
|
60
73
|
await tx`INSERT INTO geo.shares(scope,subject,period,function_group,used)
|
|
@@ -88,32 +101,52 @@ export const createGeoLedger = (db: SQL = sql) => ({
|
|
|
88
101
|
return { cloud: balances[0]!, person: balances[1]! };
|
|
89
102
|
},
|
|
90
103
|
async recordTransition(
|
|
91
|
-
event: "provider_key_rejected" | "monthly_budget_exhausted",
|
|
104
|
+
event: "provider_key_rejected" | "monthly_budget_exhausted" | "route_cost_overrun",
|
|
92
105
|
timezone: string,
|
|
93
|
-
context:
|
|
106
|
+
context: { actor: CapabilityExecutionContext["actor"] | AppPwaActor; requestId?: string },
|
|
94
107
|
now: Date,
|
|
108
|
+
metadata: Record<string, number> = {},
|
|
95
109
|
) {
|
|
96
110
|
const period = geoPeriods(now, timezone).cloud.period;
|
|
97
111
|
await db.begin(async (tx) => {
|
|
98
112
|
const inserted =
|
|
99
|
-
|
|
113
|
+
event === "route_cost_overrun"
|
|
114
|
+
? [true]
|
|
115
|
+
: await tx`INSERT INTO geo.audit_transitions(event,period) VALUES(${event},${period}) ON CONFLICT DO NOTHING RETURNING event`;
|
|
100
116
|
if (!inserted.length) return;
|
|
101
117
|
const actor =
|
|
102
|
-
context.actor.kind === "
|
|
103
|
-
?
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
118
|
+
context.actor.kind === "device" || context.actor.kind === "visit"
|
|
119
|
+
? null
|
|
120
|
+
: context.actor.kind === "user"
|
|
121
|
+
? {
|
|
122
|
+
userId: context.actor.user.id,
|
|
123
|
+
uid: context.actor.user.uid,
|
|
124
|
+
provider: context.actor.user.provider,
|
|
125
|
+
roles: context.actor.user.roles,
|
|
126
|
+
}
|
|
127
|
+
: { uid: `service-account:${context.actor.serviceAccount.id}`, provider: "service_account", roles: context.actor.scopes };
|
|
110
128
|
await audit.record(
|
|
111
129
|
{
|
|
112
130
|
action: `core.geo.${event}`,
|
|
113
|
-
outcome: event === "
|
|
131
|
+
outcome: event === "monthly_budget_exhausted" ? "denied" : "failed",
|
|
114
132
|
actor,
|
|
115
133
|
requestId: context.requestId,
|
|
116
|
-
metadata: {
|
|
134
|
+
metadata: {
|
|
135
|
+
period,
|
|
136
|
+
...metadata,
|
|
137
|
+
...(context.actor.kind === "device" || context.actor.kind === "visit"
|
|
138
|
+
? {
|
|
139
|
+
appId: context.actor.appId,
|
|
140
|
+
subject: { type: "user", userId: context.actor.ownerUserId },
|
|
141
|
+
via: {
|
|
142
|
+
kind: context.actor.kind,
|
|
143
|
+
appId: context.actor.appId,
|
|
144
|
+
deviceId: context.actor.deviceId,
|
|
145
|
+
...(context.actor.kind === "visit" ? { visitId: context.actor.visitId } : {}),
|
|
146
|
+
},
|
|
147
|
+
}
|
|
148
|
+
: {}),
|
|
149
|
+
},
|
|
117
150
|
},
|
|
118
151
|
tx,
|
|
119
152
|
);
|
|
@@ -16,11 +16,16 @@ export type GeoRouteChainDependencies = {
|
|
|
16
16
|
bucket: ReturnType<typeof createGeoBucket>;
|
|
17
17
|
ledger: ReturnType<typeof createGeoLedger>;
|
|
18
18
|
now: () => Date;
|
|
19
|
-
recordTransition: (
|
|
19
|
+
recordTransition: (
|
|
20
|
+
event: "monthly_budget_exhausted" | "provider_key_rejected" | "route_cost_overrun",
|
|
21
|
+
timezone: string,
|
|
22
|
+
context: GeoContext,
|
|
23
|
+
metadata?: Record<string, number>,
|
|
24
|
+
) => Promise<boolean | void>;
|
|
20
25
|
};
|
|
21
|
-
export type GeoRouteCallPricing = { pairs: number; avoids: number; optimize: boolean;
|
|
26
|
+
export type GeoRouteCallPricing = { pairs: number; avoids: number; optimize: boolean; estimatedDistanceMeters: number };
|
|
22
27
|
export const reserveRouteCall = (pricing: GeoRouteCallPricing) =>
|
|
23
|
-
geoRouteReferenceCost({ ...pricing, distanceMeters: pricing.
|
|
28
|
+
geoRouteReferenceCost({ ...pricing, distanceMeters: pricing.estimatedDistanceMeters });
|
|
24
29
|
export type GeoRouteChainCall = <V>(
|
|
25
30
|
fn: GeoFunction,
|
|
26
31
|
input: unknown,
|
|
@@ -53,26 +58,28 @@ export const runGeoRouteChain = async <T>(
|
|
|
53
58
|
let known = 0,
|
|
54
59
|
unknown = 0;
|
|
55
60
|
let result: T | undefined, failure: unknown;
|
|
61
|
+
let overrun:
|
|
62
|
+
| { estimatedDistanceMeters: number; actualDistanceMeters: number; reservedCredits: number; actualCredits: number }
|
|
63
|
+
| undefined;
|
|
56
64
|
try {
|
|
57
65
|
result = await work(async (fn, input, schema, request, pricing, distance) => {
|
|
58
66
|
context.signal?.throwIfAborted();
|
|
59
67
|
const key = geoCacheKey(fn, input, context.locale ?? "en");
|
|
60
68
|
const hit = await cache.get(key, schema);
|
|
61
|
-
if (hit !== null)
|
|
62
|
-
// A cached response still must respect this call's reference allowance.
|
|
63
|
-
if (pricing && distance && distance(hit) > pricing.pairs * pricing.distanceAllowanceMeters)
|
|
64
|
-
throw new GeoError("GEO_COST_OVERRUN", {
|
|
65
|
-
distanceAllowanceMeters: pricing.distanceAllowanceMeters,
|
|
66
|
-
actualDistanceMeters: distance(hit),
|
|
67
|
-
});
|
|
68
|
-
return hit;
|
|
69
|
-
}
|
|
69
|
+
if (hit !== null) return hit; // Cached provider results incur no new cost.
|
|
70
70
|
const allocated = pricing ? reserveRouteCall(pricing) : geoReferenceCost({ fn });
|
|
71
71
|
const debitKnown = (meters: number) => {
|
|
72
72
|
try {
|
|
73
73
|
const cost = pricing ? geoRouteReferenceCost({ ...pricing, distanceMeters: meters }) : allocated;
|
|
74
74
|
if (!Number.isSafeInteger(known + unknown + cost)) throw new Error("Unrepresentable reference cost");
|
|
75
75
|
known += cost;
|
|
76
|
+
if (pricing && cost > allocated)
|
|
77
|
+
overrun = {
|
|
78
|
+
estimatedDistanceMeters: pricing.estimatedDistanceMeters,
|
|
79
|
+
actualDistanceMeters: meters,
|
|
80
|
+
reservedCredits: allocated / 1000,
|
|
81
|
+
actualCredits: cost / 1000,
|
|
82
|
+
};
|
|
76
83
|
} catch {
|
|
77
84
|
// A sent request never becomes free because the provider's distance cannot be priced safely.
|
|
78
85
|
unknown += allocated;
|
|
@@ -106,13 +113,9 @@ export const runGeoRouteChain = async <T>(
|
|
|
106
113
|
const actualDistance = pricing && distance ? distance(parsed.data) : 0;
|
|
107
114
|
debitKnown(actualDistance);
|
|
108
115
|
if (signal.aborted) throw new GeoError("GEO_PROVIDER_FAILED");
|
|
109
|
-
// Retain
|
|
116
|
+
// Retain paid results even on overrun so an explicit retry can reuse them without paying again.
|
|
110
117
|
await cache.put(key, fn, parsed.data, false);
|
|
111
|
-
if (
|
|
112
|
-
throw new GeoError("GEO_COST_OVERRUN", {
|
|
113
|
-
distanceAllowanceMeters: pricing.distanceAllowanceMeters,
|
|
114
|
-
actualDistanceMeters: actualDistance,
|
|
115
|
-
});
|
|
118
|
+
if (overrun) throw new GeoError("GEO_COST_OVERRUN", overrun);
|
|
116
119
|
return parsed.data;
|
|
117
120
|
} finally {
|
|
118
121
|
await release();
|
|
@@ -121,9 +124,13 @@ export const runGeoRouteChain = async <T>(
|
|
|
121
124
|
} catch (error) {
|
|
122
125
|
failure = error;
|
|
123
126
|
}
|
|
127
|
+
if (overrun) failure = new GeoError("GEO_COST_OVERRUN", overrun);
|
|
128
|
+
const knownReferenceCredits = known / 1000;
|
|
129
|
+
let settlementConfirmed = true;
|
|
124
130
|
try {
|
|
125
131
|
await ledger.settle(reservation, known + unknown);
|
|
126
132
|
} catch {
|
|
133
|
+
settlementConfirmed = false;
|
|
127
134
|
// Keep the original admission debit, and make failed reconciliation visible without retaining a provider cause.
|
|
128
135
|
failure = new GeoError("GEO_PROVIDER_FAILED", {
|
|
129
136
|
adminHint:
|
|
@@ -139,6 +146,21 @@ export const runGeoRouteChain = async <T>(
|
|
|
139
146
|
}
|
|
140
147
|
if (failure) {
|
|
141
148
|
const error = failure instanceof GeoError ? failure : new GeoError("GEO_PROVIDER_FAILED");
|
|
149
|
+
if (overrun) {
|
|
150
|
+
const recorded = await dependencies.recordTransition("route_cost_overrun", config.timezone, context, {
|
|
151
|
+
reservedCredits: reservation.cost / 1000,
|
|
152
|
+
settledCredits: known / 1000,
|
|
153
|
+
unknownCredits: unknown / 1000,
|
|
154
|
+
knownReferenceCredits,
|
|
155
|
+
settlementConfirmed: settlementConfirmed ? 1 : 0,
|
|
156
|
+
estimatedCallCredits: overrun.reservedCredits,
|
|
157
|
+
actualCallCredits: overrun.actualCredits,
|
|
158
|
+
});
|
|
159
|
+
if (recorded === false)
|
|
160
|
+
error.details.adminHint =
|
|
161
|
+
"The routing cost overrun could not be recorded in the audit log. Reconcile the reported costs before retrying. " +
|
|
162
|
+
(typeof error.details.adminHint === "string" ? error.details.adminHint : "");
|
|
163
|
+
}
|
|
142
164
|
throw new GeoError(error.code, { ...error.details, costs: { settledCredits: known / 1000, unknownCredits: unknown / 1000 } });
|
|
143
165
|
}
|
|
144
166
|
return result!;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { GeoRouteResolvedStop } from "../../contracts/geo-route";
|
|
2
|
+
import { geoDistance } from "./polyline";
|
|
3
|
+
|
|
4
|
+
// Cloud technical budget policy, 2026-10-22: straight-line distance × 2.0.
|
|
5
|
+
// Source: places-and-maps.md#reserve-estimated-routing-costs (coordinator decision).
|
|
6
|
+
// This is an admission estimate, not a Geoapify guarantee or a measured detour bound.
|
|
7
|
+
// All checked routing fixtures are synthetic; no measured fixture supersedes this policy.
|
|
8
|
+
export const GEO_ROUTE_DETOUR_FACTOR = 2.0;
|
|
9
|
+
// Maximum great-circle distance on the same sphere used by geoDistance. Unknown addresses
|
|
10
|
+
// must not become zero-distance legs before geocoding; known coordinates avoid this reserve.
|
|
11
|
+
const MAX_STRAIGHT_LINE_METERS = geoDistance({ lat: 0, lon: 0 }, { lat: 0, lon: 180 });
|
|
12
|
+
type Stop = GeoRouteResolvedStop | undefined;
|
|
13
|
+
export const estimateRouteDistance = (stops: readonly Stop[], reorder = false) => {
|
|
14
|
+
const distance = (a: Stop, b: Stop) => (a && b ? geoDistance(a.point, b.point) : MAX_STRAIGHT_LINE_METERS);
|
|
15
|
+
const diameter = reorder ? Math.max(0, ...stops.flatMap((a, i) => stops.slice(i + 1).map((b) => distance(a, b)))) : 0;
|
|
16
|
+
return Math.ceil(
|
|
17
|
+
stops.slice(1).reduce((sum, stop, i) => sum + (reorder ? diameter : distance(stops[i], stop)), 0) * GEO_ROUTE_DETOUR_FACTOR,
|
|
18
|
+
);
|
|
19
|
+
};
|
|
@@ -84,14 +84,12 @@ export const assembleRoute = (
|
|
|
84
84
|
defaults: GeoRouteOptions,
|
|
85
85
|
originalLegs: GeoRouteLeg[],
|
|
86
86
|
now: Date,
|
|
87
|
-
distanceAllowanceMeters: number,
|
|
88
87
|
): GeoRoute => {
|
|
89
88
|
let legs = originalLegs;
|
|
90
89
|
const build = (): GeoRoute => ({
|
|
91
90
|
stops,
|
|
92
91
|
options: defaults,
|
|
93
92
|
legs,
|
|
94
|
-
distanceAllowanceMeters,
|
|
95
93
|
distanceMeters: legs.reduce((sum, leg) => sum + leg.distanceMeters, 0),
|
|
96
94
|
durationSeconds: legs.reduce((sum, leg) => sum + leg.durationSeconds, 0),
|
|
97
95
|
polyline6: joinRouteLines(legs.map((leg) => leg.polyline6)),
|
|
@@ -16,7 +16,7 @@ import {
|
|
|
16
16
|
GeoRouteUpdateInputSchema,
|
|
17
17
|
} from "../../contracts/geo-route";
|
|
18
18
|
import { geoCacheKey, normalizeGeoInput } from "./cache";
|
|
19
|
-
import { GeoError, limitExceeded } from "./errors";
|
|
19
|
+
import { GeoError, GeoProviderError, limitExceeded } from "./errors";
|
|
20
20
|
import type { GeoapifyAdapter } from "./geoapify";
|
|
21
21
|
import { geoDistance } from "./polyline";
|
|
22
22
|
import {
|
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
reserveRouteCall,
|
|
27
27
|
runGeoRouteChain,
|
|
28
28
|
} from "./route-chain";
|
|
29
|
+
import { estimateRouteDistance } from "./route-estimate";
|
|
29
30
|
import {
|
|
30
31
|
assembleRoute,
|
|
31
32
|
insertionGaps,
|
|
@@ -120,18 +121,18 @@ export const createGeoRouteService = (
|
|
|
120
121
|
},
|
|
121
122
|
};
|
|
122
123
|
};
|
|
123
|
-
const pricing = (step: PlannedCall,
|
|
124
|
+
const pricing = (step: PlannedCall, stops: readonly (GeoRouteResolvedStop | undefined)[], reorder = false): GeoRouteCallPricing => ({
|
|
124
125
|
pairs: step.ids.length - 1,
|
|
125
126
|
avoids: step.options.avoid?.length ?? 0,
|
|
126
127
|
optimize: step.optimize,
|
|
127
|
-
|
|
128
|
+
estimatedDistanceMeters: reorder
|
|
129
|
+
? (estimateRouteDistance(stops, true) / (stops.length - 1)) * (step.ids.length - 1)
|
|
130
|
+
: estimateRouteDistance(
|
|
131
|
+
step.ids.map((id) => stops.find((stop) => stop?.id === id)),
|
|
132
|
+
step.optimize,
|
|
133
|
+
),
|
|
128
134
|
});
|
|
129
|
-
const preflight = (
|
|
130
|
-
stops: readonly (GeoRouteResolvedStop | undefined)[],
|
|
131
|
-
options: GeoRouteOptions,
|
|
132
|
-
allowance: number,
|
|
133
|
-
optimize = false,
|
|
134
|
-
) => {
|
|
135
|
+
const preflight = (stops: readonly (GeoRouteResolvedStop | undefined)[], options: GeoRouteOptions, optimize = false) => {
|
|
135
136
|
if (optimize) {
|
|
136
137
|
// Every route connects all known stops. Their minimum spanning tree is an order-independent lower bound.
|
|
137
138
|
const known = stops.filter((stop): stop is GeoRouteResolvedStop => !!stop);
|
|
@@ -153,7 +154,6 @@ export const createGeoRouteService = (
|
|
|
153
154
|
lowerBound += shortest;
|
|
154
155
|
}
|
|
155
156
|
const pairs = stops.length - 1;
|
|
156
|
-
if (lowerBound > pairs * allowance) limitExceeded("distanceAllowanceMeters", pairs * allowance, lowerBound);
|
|
157
157
|
if (options.mode === "walk" && lowerBound > pairs * 100_000) limitExceeded("walk.legMeters", pairs * 100_000, lowerBound);
|
|
158
158
|
return;
|
|
159
159
|
}
|
|
@@ -161,27 +161,41 @@ export const createGeoRouteService = (
|
|
|
161
161
|
const a = stops[i - 1],
|
|
162
162
|
b = stops[i];
|
|
163
163
|
if (!a || !b) continue;
|
|
164
|
-
const distance = geoDistance(a.point, b.point);
|
|
165
|
-
if (distance > allowance) limitExceeded("distanceAllowanceMeters", allowance, distance);
|
|
166
164
|
walkDistance([a, b], options);
|
|
167
165
|
}
|
|
168
166
|
};
|
|
169
167
|
const execute = async (
|
|
170
168
|
step: PlannedCall,
|
|
171
169
|
stops: readonly GeoRouteResolvedStop[],
|
|
172
|
-
allowance: number,
|
|
173
170
|
call: RouteCall,
|
|
174
171
|
config: GeoConfig,
|
|
175
172
|
context: GeoContext,
|
|
176
173
|
) => {
|
|
177
174
|
const selected = step.ids.map((id) => stops.find((stop) => stop.id === id)!);
|
|
178
|
-
preflight(selected, step.options,
|
|
175
|
+
preflight(selected, step.options, step.optimize);
|
|
179
176
|
return call(
|
|
180
177
|
"route",
|
|
181
178
|
{ stops: selected, options: step.options, optimize: step.optimize },
|
|
182
179
|
providerData,
|
|
183
|
-
(signal) =>
|
|
184
|
-
|
|
180
|
+
async (signal) => {
|
|
181
|
+
const result = await dependencies.adapter.route(
|
|
182
|
+
selected,
|
|
183
|
+
step.options,
|
|
184
|
+
step.optimize,
|
|
185
|
+
config.apiKey,
|
|
186
|
+
context.locale ?? "en",
|
|
187
|
+
signal,
|
|
188
|
+
);
|
|
189
|
+
try {
|
|
190
|
+
// Intermediate paid responses (including overruns) must fit the same cache bound
|
|
191
|
+
// as final routes. Keep distance totals exact while compacting only geometry.
|
|
192
|
+
const compact = assembleRoute(result.stops, step.options, result.legs, dependencies.now());
|
|
193
|
+
return { stops: compact.stops, legs: compact.legs, distanceMeters: result.distanceMeters };
|
|
194
|
+
} catch {
|
|
195
|
+
throw new GeoProviderError("GEO_PROVIDER_FAILED", "known", false, result.distanceMeters);
|
|
196
|
+
}
|
|
197
|
+
},
|
|
198
|
+
pricing(step, stops),
|
|
185
199
|
(data) => data.distanceMeters,
|
|
186
200
|
);
|
|
187
201
|
};
|
|
@@ -189,14 +203,12 @@ export const createGeoRouteService = (
|
|
|
189
203
|
const config = await dependencies.configured(context);
|
|
190
204
|
const { plan } = parse(GeoRouteCalculateInputSchema, input);
|
|
191
205
|
const { defaults, byDeparture } = validateRoutePlan(plan);
|
|
192
|
-
const allowance = plan.distanceAllowanceMeters ?? 500_000;
|
|
193
206
|
const key = geoCacheKey(
|
|
194
207
|
"route",
|
|
195
208
|
{
|
|
196
209
|
calculate: {
|
|
197
210
|
stops: plan.stops.map((stop) => (stop.address ? { ...stop, address: normalizeGeoInput(stop.address, "geocode") } : stop)),
|
|
198
211
|
options: defaults,
|
|
199
|
-
distanceAllowanceMeters: allowance,
|
|
200
212
|
optimize: plan.optimize ?? false,
|
|
201
213
|
legOptions: plan.stops.flatMap((stop) => {
|
|
202
214
|
const options = byDeparture.get(stop.id);
|
|
@@ -229,27 +241,26 @@ export const createGeoRouteService = (
|
|
|
229
241
|
preflight(
|
|
230
242
|
step.ids.map((id) => prepared.known[plan.stops.findIndex((stop) => stop.id === id)]),
|
|
231
243
|
step.options,
|
|
232
|
-
allowance,
|
|
233
244
|
step.optimize,
|
|
234
245
|
);
|
|
235
246
|
const result = await runGeoRouteChain(
|
|
236
247
|
dependencies,
|
|
237
248
|
context,
|
|
238
249
|
config,
|
|
239
|
-
prepared.cost + steps.reduce((sum, step) => sum + reserveRouteCall(pricing(step,
|
|
250
|
+
prepared.cost + steps.reduce((sum, step) => sum + reserveRouteCall(pricing(step, prepared.known, plan.optimize)), 0),
|
|
240
251
|
async (call) => {
|
|
241
252
|
let stops = await prepared.resolve(call, config);
|
|
242
253
|
let legs: GeoRouteLeg[] = [];
|
|
243
254
|
const first = steps[0]!;
|
|
244
255
|
if (plan.optimize) {
|
|
245
|
-
const optimized = await execute(first, stops,
|
|
256
|
+
const optimized = await execute(first, stops, call, config, context);
|
|
246
257
|
stops = optimized.stops;
|
|
247
258
|
legs = optimized.legs;
|
|
248
259
|
for (let i = 0; i < legs.length; i++) {
|
|
249
260
|
const options = byDeparture.get(stops[i]!.id);
|
|
250
261
|
if (options && !sameRouteOptions(options, defaults)) {
|
|
251
262
|
const step = { ids: [stops[i]!.id, stops[i + 1]!.id], options, optimize: false };
|
|
252
|
-
legs[i] = (await execute(step, stops,
|
|
263
|
+
legs[i] = (await execute(step, stops, call, config, context)).legs[0]!;
|
|
253
264
|
}
|
|
254
265
|
}
|
|
255
266
|
} else {
|
|
@@ -258,11 +269,10 @@ export const createGeoRouteService = (
|
|
|
258
269
|
preflight(
|
|
259
270
|
step.ids.map((id) => stops.find((stop) => stop.id === id)!),
|
|
260
271
|
step.options,
|
|
261
|
-
allowance,
|
|
262
272
|
);
|
|
263
|
-
for (const step of steps) legs.push(...(await execute(step, stops,
|
|
273
|
+
for (const step of steps) legs.push(...(await execute(step, stops, call, config, context)).legs);
|
|
264
274
|
}
|
|
265
|
-
return { route: assembleRoute(stops, defaults, legs, dependencies.now()
|
|
275
|
+
return { route: assembleRoute(stops, defaults, legs, dependencies.now()) };
|
|
266
276
|
},
|
|
267
277
|
);
|
|
268
278
|
await dependencies.cache.put(key, "route", result, false);
|
|
@@ -270,9 +280,8 @@ export const createGeoRouteService = (
|
|
|
270
280
|
};
|
|
271
281
|
const update = async (input: GeoRouteUpdateInput, context: GeoContext): Promise<GeoRouteUpdateData> => {
|
|
272
282
|
const config = await dependencies.configured(context);
|
|
273
|
-
const { route, changes
|
|
283
|
+
const { route, changes } = parse(GeoRouteUpdateInputSchema, input);
|
|
274
284
|
validateSavedRoute(route);
|
|
275
|
-
const allowance = distanceAllowanceMeters ?? route.distanceAllowanceMeters;
|
|
276
285
|
const key = geoCacheKey("route", { update: input }, context.locale ?? "en");
|
|
277
286
|
const cached = await dependencies.cache.get(key, GeoRouteUpdateDataSchema);
|
|
278
287
|
if (cached) return cached;
|
|
@@ -298,12 +307,15 @@ export const createGeoRouteService = (
|
|
|
298
307
|
options: route.legs[index]!.options,
|
|
299
308
|
optimize: false,
|
|
300
309
|
});
|
|
301
|
-
steps.sort(
|
|
310
|
+
steps.sort(
|
|
311
|
+
(a, b) =>
|
|
312
|
+
reserveRouteCall(pricing(b, [...stops, inserted!.known[0]])) - reserveRouteCall(pricing(a, [...stops, inserted!.known[0]])),
|
|
313
|
+
);
|
|
302
314
|
steps.splice(3);
|
|
303
315
|
const point = inserted.known[0];
|
|
304
316
|
if (point)
|
|
305
317
|
for (const index of fixedGap !== undefined ? [fixedGap] : insertionGaps(route, point.point))
|
|
306
|
-
preflight([stops[index], point, stops[index + 1]], route.legs[index]!.options
|
|
318
|
+
preflight([stops[index], point, stops[index + 1]], route.legs[index]!.options);
|
|
307
319
|
} else if (changes.type === "move") {
|
|
308
320
|
const index = stops.findIndex((stop) => stop.id === changes.stopId);
|
|
309
321
|
if (index < 0 || changes.toIndex >= stops.length) throw new GeoError("GEO_UNSUPPORTED_OPTION");
|
|
@@ -344,7 +356,6 @@ export const createGeoRouteService = (
|
|
|
344
356
|
preflight(
|
|
345
357
|
step.ids.map((id) => stops.find((stop) => stop.id === id)!),
|
|
346
358
|
step.options,
|
|
347
|
-
allowance,
|
|
348
359
|
step.optimize,
|
|
349
360
|
);
|
|
350
361
|
const work = async (call: RouteCall) => {
|
|
@@ -353,7 +364,7 @@ export const createGeoRouteService = (
|
|
|
353
364
|
const [point] = await inserted!.resolve(call, config);
|
|
354
365
|
const gaps = fixedGap !== undefined ? [fixedGap] : insertionGaps(route, point!.point);
|
|
355
366
|
// Resolve and validate every candidate before the first paid route request.
|
|
356
|
-
for (const index of gaps) preflight([stops[index], point, stops[index + 1]], route.legs[index]!.options
|
|
367
|
+
for (const index of gaps) preflight([stops[index], point, stops[index + 1]], route.legs[index]!.options);
|
|
357
368
|
let winner: { index: number; legs: GeoRouteLeg[]; detour: number; meters: number } | undefined;
|
|
358
369
|
for (const index of gaps) {
|
|
359
370
|
let result: z.infer<typeof providerData>;
|
|
@@ -361,7 +372,6 @@ export const createGeoRouteService = (
|
|
|
361
372
|
result = await execute(
|
|
362
373
|
{ ids: [stops[index]!.id, point!.id, stops[index + 1]!.id], options: route.legs[index]!.options, optimize: false },
|
|
363
374
|
[...stops, point!],
|
|
364
|
-
allowance,
|
|
365
375
|
call,
|
|
366
376
|
config,
|
|
367
377
|
context,
|
|
@@ -385,20 +395,19 @@ export const createGeoRouteService = (
|
|
|
385
395
|
};
|
|
386
396
|
stops.splice(winner!.index + 1, 0, point!);
|
|
387
397
|
} else if (changes.type === "optimize") {
|
|
388
|
-
const result = await execute(steps[0]!, stops,
|
|
398
|
+
const result = await execute(steps[0]!, stops, call, config, context);
|
|
389
399
|
stops = result.stops;
|
|
390
400
|
legs = result.legs;
|
|
391
401
|
for (let i = 0; i < legs.length; i++) {
|
|
392
402
|
const options = byDeparture.get(stops[i]!.id) ?? defaults;
|
|
393
403
|
if (!sameRouteOptions(options, defaults))
|
|
394
|
-
legs[i] = (
|
|
395
|
-
|
|
396
|
-
).legs[0]!;
|
|
404
|
+
legs[i] = (await execute({ ids: [stops[i]!.id, stops[i + 1]!.id], options, optimize: false }, stops, call, config, context))
|
|
405
|
+
.legs[0]!;
|
|
397
406
|
}
|
|
398
407
|
} else {
|
|
399
408
|
const replacements = new Map<string, GeoRouteLeg>();
|
|
400
409
|
for (const step of steps)
|
|
401
|
-
for (const leg of (await execute(step, stops,
|
|
410
|
+
for (const leg of (await execute(step, stops, call, config, context)).legs) replacements.set(leg.fromStopId, leg);
|
|
402
411
|
legs = stops
|
|
403
412
|
.slice(0, -1)
|
|
404
413
|
.map(
|
|
@@ -406,7 +415,11 @@ export const createGeoRouteService = (
|
|
|
406
415
|
replacements.get(stop.id) ?? route.legs.find((leg) => leg.fromStopId === stop.id && leg.toStopId === stops[i + 1]!.id)!,
|
|
407
416
|
);
|
|
408
417
|
}
|
|
409
|
-
const
|
|
418
|
+
const unchanged =
|
|
419
|
+
sameRouteOptions(defaults, route.options) &&
|
|
420
|
+
stops.every((stop, i) => stop === route.stops[i]) &&
|
|
421
|
+
legs.every((leg, i) => leg === route.legs[i]);
|
|
422
|
+
const next = unchanged ? route : assembleRoute(stops, defaults, legs, dependencies.now());
|
|
410
423
|
const before = routeSummary(route),
|
|
411
424
|
after = routeSummary(next);
|
|
412
425
|
return GeoRouteUpdateDataSchema.parse({
|
|
@@ -420,7 +433,12 @@ export const createGeoRouteService = (
|
|
|
420
433
|
...(insertion ? { insertion } : {}),
|
|
421
434
|
});
|
|
422
435
|
};
|
|
423
|
-
const cost =
|
|
436
|
+
const cost =
|
|
437
|
+
(inserted?.cost ?? 0) +
|
|
438
|
+
steps.reduce(
|
|
439
|
+
(sum, step) => sum + reserveRouteCall(pricing(step, inserted ? [...stops, inserted.known[0]] : stops, changes.type === "optimize")),
|
|
440
|
+
0,
|
|
441
|
+
);
|
|
424
442
|
const result = cost
|
|
425
443
|
? await runGeoRouteChain(dependencies, context, config, cost, work)
|
|
426
444
|
: await work(async () => {
|