@centerforagenticai/pi-multi-account 0.1.1
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/LICENSE +21 -0
- package/NOTICE +29 -0
- package/README.md +999 -0
- package/config/models/pi-multi-account.v1.json +32 -0
- package/config/subscription-plans.v1.json +122 -0
- package/package.json +76 -0
- package/packages/pi-anthropic-oauth/LICENSE +21 -0
- package/packages/pi-anthropic-oauth/package.json +54 -0
- package/packages/pi-anthropic-oauth/src/auth.ts +396 -0
- package/packages/pi-anthropic-oauth/src/context.ts +116 -0
- package/packages/pi-anthropic-oauth/src/convert.ts +303 -0
- package/packages/pi-anthropic-oauth/src/index.ts +37 -0
- package/packages/pi-anthropic-oauth/src/prompt.ts +137 -0
- package/packages/pi-anthropic-oauth/src/stream.ts +476 -0
- package/packages/pi-antigravity/LICENSE +21 -0
- package/packages/pi-antigravity/package.json +77 -0
- package/packages/pi-antigravity/src/auth/index.ts +14 -0
- package/packages/pi-antigravity/src/auth/oauth.ts +442 -0
- package/packages/pi-antigravity/src/client/client.ts +561 -0
- package/packages/pi-antigravity/src/client/index.ts +1 -0
- package/packages/pi-antigravity/src/context.ts +110 -0
- package/packages/pi-antigravity/src/diagnostics/diagnostics.ts +96 -0
- package/packages/pi-antigravity/src/diagnostics/index.ts +1 -0
- package/packages/pi-antigravity/src/image/image.ts +336 -0
- package/packages/pi-antigravity/src/image/index.ts +1 -0
- package/packages/pi-antigravity/src/index.ts +280 -0
- package/packages/pi-antigravity/src/models/discovery.ts +154 -0
- package/packages/pi-antigravity/src/models/grouping.ts +424 -0
- package/packages/pi-antigravity/src/models/index.ts +3 -0
- package/packages/pi-antigravity/src/models/models.ts +500 -0
- package/packages/pi-antigravity/src/stream/index.ts +1 -0
- package/packages/pi-antigravity/src/stream/stream.ts +1478 -0
- package/packages/pi-antigravity/src/types/enums.ts +42 -0
- package/packages/pi-antigravity/src/types/index.ts +2 -0
- package/packages/pi-antigravity/src/types/types.ts +292 -0
- package/packages/pi-antigravity/src/usage/index.ts +1 -0
- package/packages/pi-antigravity/src/usage/usage.ts +416 -0
- package/packages/pi-antigravity/src/utils/http.ts +91 -0
- package/packages/pi-antigravity/src/utils/index.ts +3 -0
- package/packages/pi-antigravity/src/utils/security.ts +73 -0
- package/packages/pi-antigravity/src/utils/util.ts +132 -0
- package/scripts/multi-account.mjs +44 -0
- package/src/account-labels.ts +223 -0
- package/src/account-plan-assignment.ts +340 -0
- package/src/account-rate-history.ts +372 -0
- package/src/anthropic-adaptive-stream.ts +531 -0
- package/src/anthropic-alias-stream.ts +140 -0
- package/src/anthropic-context-compat.ts +80 -0
- package/src/api-pricing.ts +579 -0
- package/src/bounded-file-lines.ts +97 -0
- package/src/catalog-rebinding.ts +177 -0
- package/src/catalog-registration-probe.ts +111 -0
- package/src/codex-adapter.ts +345 -0
- package/src/codex-model-defaults.ts +785 -0
- package/src/command-completions.ts +404 -0
- package/src/commands.ts +2000 -0
- package/src/compaction.ts +14 -0
- package/src/config.ts +1317 -0
- package/src/continuation.ts +569 -0
- package/src/cooldowns.ts +110 -0
- package/src/cost-digest-store.ts +332 -0
- package/src/cost-digest.ts +1044 -0
- package/src/cost-history.ts +251 -0
- package/src/cost-period-closer.ts +160 -0
- package/src/cost-report-json.ts +318 -0
- package/src/cost-report-reader.ts +368 -0
- package/src/cost-report-render.ts +207 -0
- package/src/cost-report.ts +1104 -0
- package/src/coverage-attestation.ts +397 -0
- package/src/credential-lifecycle.ts +169 -0
- package/src/credential-refresh.ts +248 -0
- package/src/declaration-notice-marker.ts +238 -0
- package/src/diagnostic-store.ts +276 -0
- package/src/diagnostics.ts +309 -0
- package/src/discovery.ts +471 -0
- package/src/duration.ts +13 -0
- package/src/error-classification.ts +256 -0
- package/src/fuzzy.ts +15 -0
- package/src/group-policy.ts +81 -0
- package/src/history-store.ts +897 -0
- package/src/index.ts +5572 -0
- package/src/lifecycle.ts +378 -0
- package/src/logical-dispatch.ts +279 -0
- package/src/logical-model-selector.ts +254 -0
- package/src/logical-model-switcher.ts +430 -0
- package/src/logical-provider-attribution.ts +544 -0
- package/src/logical-provider.ts +1237 -0
- package/src/logical-route-indicator.ts +215 -0
- package/src/machine-lease.ts +445 -0
- package/src/model-support.ts +66 -0
- package/src/models-declaration.ts +1091 -0
- package/src/openai-adapter.ts +117 -0
- package/src/openrouter-budget.ts +304 -0
- package/src/openrouter-fallback.ts +146 -0
- package/src/period-boundaries.ts +376 -0
- package/src/pi-anthropic-oauth.d.ts +6 -0
- package/src/preflight.ts +253 -0
- package/src/pricing-cache.ts +235 -0
- package/src/project-identity.ts +100 -0
- package/src/provider-registration.ts +942 -0
- package/src/rate-formula.ts +163 -0
- package/src/recovery-engine.ts +853 -0
- package/src/recovery-output.ts +837 -0
- package/src/recovery-plan.ts +239 -0
- package/src/report-range.ts +203 -0
- package/src/route-resolver.ts +789 -0
- package/src/routing-config-transaction.ts +232 -0
- package/src/routing.ts +1163 -0
- package/src/runtime-state.ts +630 -0
- package/src/session-account-groups.ts +284 -0
- package/src/session-restore.ts +287 -0
- package/src/shared-usage.ts +1392 -0
- package/src/standalone-cli.ts +720 -0
- package/src/status-view.ts +578 -0
- package/src/subscription-plan-catalog.ts +346 -0
- package/src/tier-model-resolver.ts +46 -0
- package/src/upstream-anthropic.ts +315 -0
- package/src/upstream-antigravity.ts +327 -0
- package/src/usage-fetch.ts +1634 -0
- package/src/usage.ts +1026 -0
- package/src/vendor.ts +87 -0
- package/src/warmer.ts +231 -0
- package/src/watchdog.ts +219 -0
- package/src/window-history.ts +270 -0
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import type { AllowedFamily, ManagedFamily, MultiAccountConfig } from "./config.js";
|
|
2
|
+
import { isAllowedFamily } from "./config.js";
|
|
3
|
+
import { resolveTierModel } from "./tier-model-resolver.js";
|
|
4
|
+
import type { ProviderType } from "./vendor.js";
|
|
5
|
+
import { sameVendor, vendorForFamily } from "./vendor.js";
|
|
6
|
+
import { isCanonicalManagedProviderId } from "./runtime-state.js";
|
|
7
|
+
|
|
8
|
+
/** Host-observable input modalities used to reject incompatible destinations. */
|
|
9
|
+
export type RecoveryInputModality = "text" | "image";
|
|
10
|
+
|
|
11
|
+
/** Capability facts projected from one physical catalog row. */
|
|
12
|
+
export interface RecoveryModelCapability {
|
|
13
|
+
readonly modelId: string;
|
|
14
|
+
readonly input: readonly RecoveryInputModality[];
|
|
15
|
+
readonly supportsTools: boolean;
|
|
16
|
+
readonly contextWindow: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** One validated physical account visible when a request-local plan is built. */
|
|
20
|
+
export interface RecoveryPlanAccount {
|
|
21
|
+
readonly providerId: string;
|
|
22
|
+
readonly family: ManagedFamily;
|
|
23
|
+
readonly providerType: Exclude<ProviderType, "openrouter">;
|
|
24
|
+
readonly eligible: boolean;
|
|
25
|
+
readonly models: readonly RecoveryModelCapability[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type RecoveryCandidateTier =
|
|
29
|
+
| "same-family-subscription"
|
|
30
|
+
| "owning-vendor-api"
|
|
31
|
+
| "cross-family-subscription";
|
|
32
|
+
|
|
33
|
+
/** One finite provider/model pair. Logical and physical model identity stay distinct. */
|
|
34
|
+
export interface RecoveryCandidate {
|
|
35
|
+
readonly providerId: string;
|
|
36
|
+
readonly family: ManagedFamily;
|
|
37
|
+
readonly providerType: Exclude<ProviderType, "openrouter">;
|
|
38
|
+
readonly modelId: string;
|
|
39
|
+
readonly selectedModelId: string;
|
|
40
|
+
readonly tier: RecoveryCandidateTier;
|
|
41
|
+
readonly substitution: "exact" | "configured";
|
|
42
|
+
readonly capability: RecoveryModelCapability;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export type RecoveryPlanConfig = Pick<
|
|
46
|
+
MultiAccountConfig,
|
|
47
|
+
| "sameFamilyFailover"
|
|
48
|
+
| "crossFamilyChainEnabled"
|
|
49
|
+
| "crossFamilyChains"
|
|
50
|
+
| "preferredModels"
|
|
51
|
+
| "tierModelMap"
|
|
52
|
+
>;
|
|
53
|
+
|
|
54
|
+
export interface RecoveryCandidatePlanRequest {
|
|
55
|
+
readonly selectedModelId: string;
|
|
56
|
+
readonly originFamily: AllowedFamily;
|
|
57
|
+
readonly accounts: readonly RecoveryPlanAccount[];
|
|
58
|
+
readonly config: RecoveryPlanConfig;
|
|
59
|
+
readonly requiredInput: readonly RecoveryInputModality[];
|
|
60
|
+
readonly requiresTools: boolean;
|
|
61
|
+
readonly failedPair?: Readonly<{ providerId: string; modelId: string }>;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function providerTypeMatchesFamily(account: RecoveryPlanAccount): boolean {
|
|
65
|
+
if (account.family === "openai-codex") return account.providerType === "subscription";
|
|
66
|
+
if (account.family === "openai") return account.providerType === "owning-vendor-api";
|
|
67
|
+
return account.providerType === "subscription" || account.providerType === "owning-vendor-api";
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function usableCapability(
|
|
71
|
+
model: RecoveryModelCapability,
|
|
72
|
+
requiredInput: readonly RecoveryInputModality[],
|
|
73
|
+
requiresTools: boolean,
|
|
74
|
+
): boolean {
|
|
75
|
+
return (
|
|
76
|
+
typeof model.modelId === "string" &&
|
|
77
|
+
model.modelId.length > 0 &&
|
|
78
|
+
Number.isFinite(model.contextWindow) &&
|
|
79
|
+
model.contextWindow > 0 &&
|
|
80
|
+
requiredInput.every((input) => model.input.includes(input)) &&
|
|
81
|
+
(!requiresTools || model.supportsTools)
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function immutableCapability(model: RecoveryModelCapability): RecoveryModelCapability {
|
|
86
|
+
return Object.freeze({
|
|
87
|
+
modelId: model.modelId,
|
|
88
|
+
input: Object.freeze([...model.input]),
|
|
89
|
+
supportsTools: model.supportsTools,
|
|
90
|
+
contextWindow: model.contextWindow,
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Build the immutable, deterministic candidate sweep for one logical call. */
|
|
95
|
+
export function buildRecoveryCandidatePlan(
|
|
96
|
+
request: RecoveryCandidatePlanRequest,
|
|
97
|
+
): readonly RecoveryCandidate[] {
|
|
98
|
+
if (typeof request.selectedModelId !== "string" || request.selectedModelId.length === 0) {
|
|
99
|
+
return Object.freeze([]);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const accounts: RecoveryPlanAccount[] = [];
|
|
103
|
+
const seenAccounts = new Set<string>();
|
|
104
|
+
for (const account of request.accounts) {
|
|
105
|
+
if (
|
|
106
|
+
!account.eligible ||
|
|
107
|
+
!isCanonicalManagedProviderId(account.providerId, account.family) ||
|
|
108
|
+
!providerTypeMatchesFamily(account) ||
|
|
109
|
+
seenAccounts.has(account.providerId)
|
|
110
|
+
) {
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
seenAccounts.add(account.providerId);
|
|
114
|
+
accounts.push(account);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const candidates: RecoveryCandidate[] = [];
|
|
118
|
+
const seenPairs = new Set<string>();
|
|
119
|
+
const add = (
|
|
120
|
+
account: RecoveryPlanAccount,
|
|
121
|
+
model: RecoveryModelCapability | undefined,
|
|
122
|
+
tier: RecoveryCandidateTier,
|
|
123
|
+
substitution: RecoveryCandidate["substitution"],
|
|
124
|
+
): void => {
|
|
125
|
+
if (
|
|
126
|
+
model === undefined ||
|
|
127
|
+
!usableCapability(model, request.requiredInput, request.requiresTools)
|
|
128
|
+
) {
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
if (
|
|
132
|
+
request.failedPair?.providerId === account.providerId &&
|
|
133
|
+
request.failedPair.modelId === model.modelId
|
|
134
|
+
) {
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
const key = `${account.providerId}\u0000${model.modelId}`;
|
|
138
|
+
if (seenPairs.has(key)) return;
|
|
139
|
+
seenPairs.add(key);
|
|
140
|
+
candidates.push(
|
|
141
|
+
Object.freeze({
|
|
142
|
+
providerId: account.providerId,
|
|
143
|
+
family: account.family,
|
|
144
|
+
providerType: account.providerType,
|
|
145
|
+
modelId: model.modelId,
|
|
146
|
+
selectedModelId: request.selectedModelId,
|
|
147
|
+
tier,
|
|
148
|
+
substitution,
|
|
149
|
+
capability: immutableCapability(model),
|
|
150
|
+
}),
|
|
151
|
+
);
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
if (request.config.sameFamilyFailover) {
|
|
155
|
+
const sameFamilySubscriptions = accounts.filter(
|
|
156
|
+
(account) =>
|
|
157
|
+
account.providerType === "subscription" &&
|
|
158
|
+
account.family === request.originFamily,
|
|
159
|
+
);
|
|
160
|
+
for (const account of sameFamilySubscriptions) {
|
|
161
|
+
add(
|
|
162
|
+
account,
|
|
163
|
+
account.models.find(({ modelId }) => modelId === request.selectedModelId),
|
|
164
|
+
"same-family-subscription",
|
|
165
|
+
"exact",
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Google has no supported owning-vendor-API destination. Exclude it before
|
|
171
|
+
// this tier is built at all, so a malformed or hostile account shape can
|
|
172
|
+
// neither expand via an exact model match nor reach resolveTierModel below.
|
|
173
|
+
const owningVendorApiAccounts = accounts.filter(
|
|
174
|
+
(account) =>
|
|
175
|
+
account.providerType === "owning-vendor-api" &&
|
|
176
|
+
account.family !== "google-antigravity" &&
|
|
177
|
+
sameVendor(account.family, request.originFamily),
|
|
178
|
+
);
|
|
179
|
+
for (const account of owningVendorApiAccounts) {
|
|
180
|
+
add(
|
|
181
|
+
account,
|
|
182
|
+
account.models.find(({ modelId }) => modelId === request.selectedModelId),
|
|
183
|
+
"owning-vendor-api",
|
|
184
|
+
"exact",
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
for (const account of owningVendorApiAccounts) {
|
|
188
|
+
// Google has no supported metered destination; the filter above already
|
|
189
|
+
// excludes it, and this narrows the type for resolveTierModel below.
|
|
190
|
+
if (account.family === "google-antigravity") continue;
|
|
191
|
+
const resolvedModelId = resolveTierModel(
|
|
192
|
+
request.selectedModelId,
|
|
193
|
+
vendorForFamily(account.family),
|
|
194
|
+
account.models.map(({ modelId }) => modelId),
|
|
195
|
+
request.config.tierModelMap,
|
|
196
|
+
);
|
|
197
|
+
if (resolvedModelId === request.selectedModelId) continue;
|
|
198
|
+
add(
|
|
199
|
+
account,
|
|
200
|
+
account.models.find(({ modelId }) => modelId === resolvedModelId),
|
|
201
|
+
"owning-vendor-api",
|
|
202
|
+
"configured",
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (request.config.crossFamilyChainEnabled) {
|
|
207
|
+
const destinationFamilies = request.config.crossFamilyChains
|
|
208
|
+
.filter(({ from, to }) => from === request.originFamily && to !== from)
|
|
209
|
+
.map(({ to }) => to);
|
|
210
|
+
for (const destinationFamily of destinationFamilies) {
|
|
211
|
+
if (!isAllowedFamily(destinationFamily)) continue;
|
|
212
|
+
const destinations = accounts.filter(
|
|
213
|
+
(account) =>
|
|
214
|
+
account.providerType === "subscription" &&
|
|
215
|
+
account.family === destinationFamily,
|
|
216
|
+
);
|
|
217
|
+
for (const account of destinations) {
|
|
218
|
+
add(
|
|
219
|
+
account,
|
|
220
|
+
account.models.find(({ modelId }) => modelId === request.selectedModelId),
|
|
221
|
+
"cross-family-subscription",
|
|
222
|
+
"exact",
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
for (const modelId of request.config.preferredModels[destinationFamily] ?? []) {
|
|
226
|
+
for (const account of destinations) {
|
|
227
|
+
add(
|
|
228
|
+
account,
|
|
229
|
+
account.models.find((model) => model.modelId === modelId),
|
|
230
|
+
"cross-family-subscription",
|
|
231
|
+
"configured",
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
return Object.freeze(candidates);
|
|
239
|
+
}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure custom-range parsing and granularity-satisfiability checks for the
|
|
3
|
+
* subscription-value report projection.
|
|
4
|
+
*
|
|
5
|
+
* Every export here is read-only and side-effect free: no file I/O, no clock
|
|
6
|
+
* reads (`nowMs` and retained-granularity facts are always caller-supplied),
|
|
7
|
+
* and no `process.exit`. The eventual standalone CLI maps
|
|
8
|
+
* {@link ReportRangeResult.exitCode} onto its own process exit; this module
|
|
9
|
+
* only ever returns that code as data.
|
|
10
|
+
*
|
|
11
|
+
* A custom bound is either an ISO date `YYYY-MM-DD` (resolved to local
|
|
12
|
+
* midnight in the supplied report timezone) or a full RFC 3339 timestamp with
|
|
13
|
+
* an explicit `Z` or numeric offset. An offsetless date-time, a bare
|
|
14
|
+
* year-month, or any other shape is rejected as invalid syntax. The
|
|
15
|
+
* resolved range is start-inclusive and end-exclusive.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { getPeriodBounds, resolveZonedInstant, type PeriodType } from "./period-boundaries.js";
|
|
19
|
+
|
|
20
|
+
const DATE_ONLY_PATTERN = /^\d{4}-\d{2}-\d{2}$/u;
|
|
21
|
+
const OFFSET_DATETIME_PATTERN =
|
|
22
|
+
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/u;
|
|
23
|
+
const OFFSETLESS_DATETIME_PATTERN =
|
|
24
|
+
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?$/u;
|
|
25
|
+
|
|
26
|
+
type BoundParseResult =
|
|
27
|
+
| { readonly ok: true; readonly ms: number }
|
|
28
|
+
| { readonly ok: false; readonly reason: string };
|
|
29
|
+
|
|
30
|
+
function parseBound(raw: string, timeZone: string): BoundParseResult {
|
|
31
|
+
if (raw.length === 0) {
|
|
32
|
+
return { ok: false, reason: "a custom range bound must not be empty." };
|
|
33
|
+
}
|
|
34
|
+
if (DATE_ONLY_PATTERN.test(raw)) {
|
|
35
|
+
const [yearRaw, monthRaw, dayRaw] = raw.split("-");
|
|
36
|
+
const year = Number(yearRaw);
|
|
37
|
+
const month = Number(monthRaw);
|
|
38
|
+
const day = Number(dayRaw);
|
|
39
|
+
if (month < 1 || month > 12 || day < 1 || day > 31) {
|
|
40
|
+
return { ok: false, reason: `"${raw}" is not a valid ISO date.` };
|
|
41
|
+
}
|
|
42
|
+
try {
|
|
43
|
+
return {
|
|
44
|
+
ok: true,
|
|
45
|
+
ms: resolveZonedInstant({ year, month: month - 1, day }, timeZone),
|
|
46
|
+
};
|
|
47
|
+
} catch (error) {
|
|
48
|
+
return { ok: false, reason: (error as Error).message };
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
if (OFFSET_DATETIME_PATTERN.test(raw)) {
|
|
52
|
+
const ms = Date.parse(raw);
|
|
53
|
+
if (!Number.isFinite(ms)) {
|
|
54
|
+
return { ok: false, reason: `"${raw}" is not a valid RFC 3339 timestamp.` };
|
|
55
|
+
}
|
|
56
|
+
return { ok: true, ms };
|
|
57
|
+
}
|
|
58
|
+
if (OFFSETLESS_DATETIME_PATTERN.test(raw)) {
|
|
59
|
+
return {
|
|
60
|
+
ok: false,
|
|
61
|
+
reason: `"${raw}" is an offsetless date-time; supply "Z" or a numeric UTC offset.`,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
return {
|
|
65
|
+
ok: false,
|
|
66
|
+
reason: `"${raw}" is not a supported ISO date (YYYY-MM-DD) or RFC 3339 timestamp.`,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The finest granularity actually retained over one sub-interval of history.
|
|
72
|
+
* `"raw"` means per-observation precision (any instant is a valid boundary);
|
|
73
|
+
* every other value is a {@link PeriodType} whose own period boundaries are
|
|
74
|
+
* the finest safely-sliceable instants in that sub-interval.
|
|
75
|
+
*/
|
|
76
|
+
export type RetainedGranularity = "raw" | PeriodType;
|
|
77
|
+
|
|
78
|
+
export interface GranularitySegment {
|
|
79
|
+
readonly startMs: number;
|
|
80
|
+
readonly endMs: number;
|
|
81
|
+
readonly granularity: RetainedGranularity;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export type RangeSatisfiability =
|
|
85
|
+
| { readonly status: "satisfiable" }
|
|
86
|
+
| { readonly status: "unsatisfiable"; readonly reason: string };
|
|
87
|
+
|
|
88
|
+
function alignsToGranularity(instantMs: number, granularity: RetainedGranularity): boolean {
|
|
89
|
+
if (granularity === "raw") return true;
|
|
90
|
+
return getPeriodBounds(instantMs, granularity).startMs === instantMs;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Checks whether `range` can be reported without slicing a coarser retained
|
|
95
|
+
* rollup to fabricate detail it never recorded. `segments` describes the
|
|
96
|
+
* caller's own retained-state facts: which sub-intervals of history are only
|
|
97
|
+
* available at which granularity. A range whose start or end instant falls
|
|
98
|
+
* inside a segment but does not land on that segment's own granularity
|
|
99
|
+
* boundary, or that reaches outside every supplied segment, is unsatisfiable.
|
|
100
|
+
* An empty range (`endMs <= startMs`) is trivially satisfiable.
|
|
101
|
+
*/
|
|
102
|
+
export function assertRangeSatisfiable(
|
|
103
|
+
range: { readonly startMs: number; readonly endMs: number },
|
|
104
|
+
segments: readonly GranularitySegment[],
|
|
105
|
+
): RangeSatisfiability {
|
|
106
|
+
if (range.endMs <= range.startMs) return { status: "satisfiable" };
|
|
107
|
+
const startSegment = segments.find(
|
|
108
|
+
(segment) => range.startMs >= segment.startMs && range.startMs < segment.endMs,
|
|
109
|
+
);
|
|
110
|
+
if (startSegment === undefined) {
|
|
111
|
+
return {
|
|
112
|
+
status: "unsatisfiable",
|
|
113
|
+
reason: "the range start falls outside every retained interval.",
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
if (!alignsToGranularity(range.startMs, startSegment.granularity)) {
|
|
117
|
+
return {
|
|
118
|
+
status: "unsatisfiable",
|
|
119
|
+
reason: `the range start is not aligned to the retained "${startSegment.granularity}" granularity.`,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
const endSegment = segments.find(
|
|
123
|
+
(segment) => range.endMs > segment.startMs && range.endMs <= segment.endMs,
|
|
124
|
+
);
|
|
125
|
+
if (endSegment === undefined) {
|
|
126
|
+
return {
|
|
127
|
+
status: "unsatisfiable",
|
|
128
|
+
reason: "the range end falls outside every retained interval.",
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
if (!alignsToGranularity(range.endMs, endSegment.granularity)) {
|
|
132
|
+
return {
|
|
133
|
+
status: "unsatisfiable",
|
|
134
|
+
reason: `the range end is not aligned to the retained "${endSegment.granularity}" granularity.`,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
return { status: "satisfiable" };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export interface ReportRangeInvalid {
|
|
141
|
+
readonly status: "invalid";
|
|
142
|
+
readonly exitCode: 2;
|
|
143
|
+
readonly reason: string;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export interface ReportRangeUnsatisfiable {
|
|
147
|
+
readonly status: "unsatisfiable";
|
|
148
|
+
readonly exitCode: 3;
|
|
149
|
+
readonly reason: string;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export interface ReportRangeResolved {
|
|
153
|
+
readonly status: "resolved";
|
|
154
|
+
readonly exitCode: 0;
|
|
155
|
+
readonly startMs: number;
|
|
156
|
+
readonly endMs: number;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* A discriminated projection result, never a process exit. The standalone
|
|
161
|
+
* CLI node maps {@link ReportRangeResult.exitCode} onto its own process exit
|
|
162
|
+
* (2 for invalid syntax, 3 for unsatisfiable precision, 0 for a resolved,
|
|
163
|
+
* reportable range -- which may still describe a truthful empty window).
|
|
164
|
+
*/
|
|
165
|
+
export type ReportRangeResult =
|
|
166
|
+
| ReportRangeInvalid
|
|
167
|
+
| ReportRangeUnsatisfiable
|
|
168
|
+
| ReportRangeResolved;
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Resolves one custom `--from`/`--to` request. `retainedGranularity`, when
|
|
172
|
+
* supplied, is checked with {@link assertRangeSatisfiable}; omitting it skips
|
|
173
|
+
* the satisfiability check (useful when a caller only wants syntax
|
|
174
|
+
* validation). The range is start-inclusive and end-exclusive; `from` equal
|
|
175
|
+
* to `to` resolves to a valid, explicitly empty range rather than an error.
|
|
176
|
+
*/
|
|
177
|
+
export function resolveCustomRange(input: {
|
|
178
|
+
readonly fromRaw: string;
|
|
179
|
+
readonly toRaw: string;
|
|
180
|
+
readonly timeZone?: string;
|
|
181
|
+
readonly retainedGranularity?: readonly GranularitySegment[];
|
|
182
|
+
}): ReportRangeResult {
|
|
183
|
+
const timeZone = input.timeZone ?? "UTC";
|
|
184
|
+
const from = parseBound(input.fromRaw, timeZone);
|
|
185
|
+
if (!from.ok) return { status: "invalid", exitCode: 2, reason: from.reason };
|
|
186
|
+
const to = parseBound(input.toRaw, timeZone);
|
|
187
|
+
if (!to.ok) return { status: "invalid", exitCode: 2, reason: to.reason };
|
|
188
|
+
if (to.ms < from.ms) {
|
|
189
|
+
return {
|
|
190
|
+
status: "invalid",
|
|
191
|
+
exitCode: 2,
|
|
192
|
+
reason: "the range end must not precede its start.",
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
const range = { startMs: from.ms, endMs: to.ms };
|
|
196
|
+
if (input.retainedGranularity !== undefined) {
|
|
197
|
+
const satisfiability = assertRangeSatisfiable(range, input.retainedGranularity);
|
|
198
|
+
if (satisfiability.status === "unsatisfiable") {
|
|
199
|
+
return { status: "unsatisfiable", exitCode: 3, reason: satisfiability.reason };
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return { status: "resolved", exitCode: 0, startMs: range.startMs, endMs: range.endMs };
|
|
203
|
+
}
|