@ai-sdlc/orchestrator 0.6.0 → 0.10.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/dist/__test-helpers/git-env.d.ts +40 -0
- package/dist/__test-helpers/git-env.js +73 -0
- package/dist/admission-composite.d.ts +89 -0
- package/dist/admission-composite.js +241 -0
- package/dist/admission-enrichment.d.ts +142 -0
- package/dist/admission-enrichment.js +341 -0
- package/dist/admission-hc.d.ts +81 -0
- package/dist/admission-hc.js +94 -0
- package/dist/admission-score.d.ts +109 -5
- package/dist/admission-score.js +90 -11
- package/dist/analysis/analyzer.js +3 -2
- package/dist/analysis/convention-detector.d.ts +85 -2
- package/dist/analysis/convention-detector.js +375 -70
- package/dist/analysis/diff-analyzer.d.ts +33 -0
- package/dist/analysis/diff-analyzer.js +122 -0
- package/dist/analysis/hotspot-analyzer.js +3 -1
- package/dist/analysis/index.d.ts +2 -1
- package/dist/analysis/index.js +2 -1
- package/dist/artifacts/index.d.ts +65 -0
- package/dist/artifacts/index.js +142 -0
- package/dist/backlog-adapter.d.ts +121 -0
- package/dist/backlog-adapter.js +438 -0
- package/dist/calibration.d.ts +43 -0
- package/dist/calibration.js +76 -0
- package/dist/cli/commands/agents.d.ts +24 -0
- package/dist/cli/commands/agents.js +66 -1
- package/dist/cli/commands/git-remote.d.ts +74 -0
- package/dist/cli/commands/git-remote.js +170 -0
- package/dist/cli/commands/health.d.ts +4 -0
- package/dist/cli/commands/health.js +13 -1
- package/dist/cli/commands/init-features.d.ts +208 -0
- package/dist/cli/commands/init-features.js +473 -0
- package/dist/cli/commands/init-templates.d.ts +104 -0
- package/dist/cli/commands/init-templates.js +399 -0
- package/dist/cli/commands/init.d.ts +48 -0
- package/dist/cli/commands/init.js +322 -23
- package/dist/cli/commands/mcp-setup.d.ts +31 -0
- package/dist/cli/commands/mcp-setup.js +78 -8
- package/dist/cli/formatters/table.js +15 -2
- package/dist/cli/index.d.ts +14 -1
- package/dist/cli/index.js +81 -20
- package/dist/cli/versions.d.ts +57 -0
- package/dist/cli/versions.js +128 -0
- package/dist/code-area-classifier.d.ts +21 -0
- package/dist/code-area-classifier.js +48 -0
- package/dist/config.d.ts +33 -1
- package/dist/config.js +78 -8
- package/dist/database/adapters/external.d.ts +24 -0
- package/dist/database/adapters/external.js +80 -0
- package/dist/database/adapters/neon.d.ts +41 -0
- package/dist/database/adapters/neon.js +98 -0
- package/dist/database/adapters/pg-snapshot-restore.d.ts +28 -0
- package/dist/database/adapters/pg-snapshot-restore.js +68 -0
- package/dist/database/adapters/sqlite-copy.d.ts +32 -0
- package/dist/database/adapters/sqlite-copy.js +145 -0
- package/dist/database/connection-injection.d.ts +35 -0
- package/dist/database/connection-injection.js +93 -0
- package/dist/database/index.d.ts +12 -0
- package/dist/database/index.js +23 -0
- package/dist/database/registry.d.ts +13 -0
- package/dist/database/registry.js +27 -0
- package/dist/database/topology.d.ts +52 -0
- package/dist/database/topology.js +44 -0
- package/dist/database/types.d.ts +89 -0
- package/dist/database/types.js +26 -0
- package/dist/design-authority.d.ts +53 -0
- package/dist/design-authority.js +84 -0
- package/dist/design-lookahead.d.ts +64 -0
- package/dist/design-lookahead.js +86 -0
- package/dist/design-quality-trend.d.ts +87 -0
- package/dist/design-quality-trend.js +190 -0
- package/dist/design-system-context.d.ts +46 -0
- package/dist/design-system-context.js +80 -0
- package/dist/design-system-correction-loop.d.ts +64 -0
- package/dist/design-system-correction-loop.js +128 -0
- package/dist/design-system-metrics.d.ts +61 -0
- package/dist/design-system-metrics.js +104 -0
- package/dist/design-system-stewardship.d.ts +22 -0
- package/dist/design-system-stewardship.js +85 -0
- package/dist/design-system-validation.d.ts +37 -0
- package/dist/design-system-validation.js +88 -0
- package/dist/dispatch/index.d.ts +4 -0
- package/dist/dispatch/index.js +4 -0
- package/dist/dispatch/merge-gate.d.ts +46 -0
- package/dist/dispatch/merge-gate.js +90 -0
- package/dist/dispatch/requeue.d.ts +57 -0
- package/dist/dispatch/requeue.js +131 -0
- package/dist/dispatch/worker-pool.d.ts +62 -0
- package/dist/dispatch/worker-pool.js +60 -0
- package/dist/execute.d.ts +40 -0
- package/dist/execute.js +188 -15
- package/dist/fix-ci.js +8 -1
- package/dist/fix-review.js +8 -1
- package/dist/harness/adapters/claude-code.d.ts +29 -0
- package/dist/harness/adapters/claude-code.js +191 -0
- package/dist/harness/adapters/codex.d.ts +25 -0
- package/dist/harness/adapters/codex.js +61 -0
- package/dist/harness/independence.d.ts +51 -0
- package/dist/harness/independence.js +67 -0
- package/dist/harness/index.d.ts +14 -0
- package/dist/harness/index.js +20 -0
- package/dist/harness/registry.d.ts +17 -0
- package/dist/harness/registry.js +31 -0
- package/dist/harness/types.d.ts +123 -0
- package/dist/harness/types.js +8 -0
- package/dist/harness/version-probe.d.ts +14 -0
- package/dist/harness/version-probe.js +123 -0
- package/dist/index.d.ts +30 -4
- package/dist/index.js +35 -2
- package/dist/models/classifier.d.ts +78 -0
- package/dist/models/classifier.js +277 -0
- package/dist/models/index.d.ts +3 -0
- package/dist/models/index.js +3 -0
- package/dist/models/registry.d.ts +97 -0
- package/dist/models/registry.js +173 -0
- package/dist/pillar-breakdown.d.ts +85 -0
- package/dist/pillar-breakdown.js +162 -0
- package/dist/priority.d.ts +5 -0
- package/dist/priority.js +18 -5
- package/dist/review-meta.d.ts +65 -0
- package/dist/review-meta.js +149 -0
- package/dist/review.d.ts +4 -0
- package/dist/review.js +12 -2
- package/dist/runners/claude-code-sdk.d.ts +31 -0
- package/dist/runners/claude-code-sdk.js +238 -0
- package/dist/runners/claude-code.d.ts +3 -0
- package/dist/runners/claude-code.js +30 -66
- package/dist/runners/codex.js +4 -1
- package/dist/runners/copilot.js +4 -1
- package/dist/runners/cursor.js +4 -1
- package/dist/runners/git-utils.d.ts +81 -0
- package/dist/runners/git-utils.js +201 -0
- package/dist/runners/index.d.ts +3 -0
- package/dist/runners/index.js +3 -0
- package/dist/runners/review-agent.d.ts +20 -0
- package/dist/runners/review-agent.js +171 -41
- package/dist/runners/runner-registry.js +10 -0
- package/dist/runners/sdk-review-runner.d.ts +65 -0
- package/dist/runners/sdk-review-runner.js +185 -0
- package/dist/runners/security-triage.d.ts +20 -4
- package/dist/runners/security-triage.js +39 -15
- package/dist/runners/types.d.ts +6 -0
- package/dist/runtime/attestations.d.ts +766 -0
- package/dist/runtime/attestations.js +1195 -0
- package/dist/runtime/git-env.d.ts +53 -0
- package/dist/runtime/git-env.js +60 -0
- package/dist/runtime/index.d.ts +7 -0
- package/dist/runtime/index.js +7 -0
- package/dist/runtime/parallelism-flag.d.ts +28 -0
- package/dist/runtime/parallelism-flag.js +39 -0
- package/dist/runtime/port-allocator.d.ts +32 -0
- package/dist/runtime/port-allocator.js +96 -0
- package/dist/runtime/worktree-pool.d.ts +86 -0
- package/dist/runtime/worktree-pool.js +204 -0
- package/dist/runtime/worktree.d.ts +25 -0
- package/dist/runtime/worktree.js +111 -0
- package/dist/sa-scoring/auto-calibrate.d.ts +69 -0
- package/dist/sa-scoring/auto-calibrate.js +107 -0
- package/dist/sa-scoring/c1-sa2-computable.d.ts +42 -0
- package/dist/sa-scoring/c1-sa2-computable.js +59 -0
- package/dist/sa-scoring/composite.d.ts +107 -0
- package/dist/sa-scoring/composite.js +139 -0
- package/dist/sa-scoring/depparse-client.d.ts +79 -0
- package/dist/sa-scoring/depparse-client.js +187 -0
- package/dist/sa-scoring/did-compiler.d.ts +122 -0
- package/dist/sa-scoring/did-compiler.js +286 -0
- package/dist/sa-scoring/drift-monitor.d.ts +84 -0
- package/dist/sa-scoring/drift-monitor.js +186 -0
- package/dist/sa-scoring/exemplar-bank.d.ts +78 -0
- package/dist/sa-scoring/exemplar-bank.js +154 -0
- package/dist/sa-scoring/feedback-store.d.ts +100 -0
- package/dist/sa-scoring/feedback-store.js +156 -0
- package/dist/sa-scoring/index.d.ts +71 -0
- package/dist/sa-scoring/index.js +158 -0
- package/dist/sa-scoring/layer1-deterministic.d.ts +115 -0
- package/dist/sa-scoring/layer1-deterministic.js +298 -0
- package/dist/sa-scoring/layer2-structural.d.ts +71 -0
- package/dist/sa-scoring/layer2-structural.js +151 -0
- package/dist/sa-scoring/layer3-llm.d.ts +86 -0
- package/dist/sa-scoring/layer3-llm.js +282 -0
- package/dist/sa-scoring/rescore-orchestrator.d.ts +52 -0
- package/dist/sa-scoring/rescore-orchestrator.js +47 -0
- package/dist/scheduling/burn-down.d.ts +27 -0
- package/dist/scheduling/burn-down.js +43 -0
- package/dist/scheduling/calibration.d.ts +42 -0
- package/dist/scheduling/calibration.js +150 -0
- package/dist/scheduling/index.d.ts +8 -0
- package/dist/scheduling/index.js +8 -0
- package/dist/scheduling/ledger.d.ts +59 -0
- package/dist/scheduling/ledger.js +216 -0
- package/dist/scheduling/off-peak.d.ts +27 -0
- package/dist/scheduling/off-peak.js +112 -0
- package/dist/scheduling/schedule-decision.d.ts +39 -0
- package/dist/scheduling/schedule-decision.js +89 -0
- package/dist/scheduling/tier-analysis.d.ts +47 -0
- package/dist/scheduling/tier-analysis.js +81 -0
- package/dist/scheduling/types.d.ts +140 -0
- package/dist/scheduling/types.js +11 -0
- package/dist/shared.d.ts +13 -0
- package/dist/shared.js +32 -0
- package/dist/state/schema.d.ts +5 -1
- package/dist/state/schema.js +223 -1
- package/dist/state/store.d.ts +55 -1
- package/dist/state/store.js +342 -7
- package/dist/state/types.d.ts +139 -0
- package/dist/types.d.ts +1 -1
- package/dist/validate-agent-output.js +4 -1
- package/dist/watch.js +6 -0
- package/package.json +3 -2
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SubscriptionLedger per RFC §14.2 / §14.12. Tracks per-(harness, accountId, tenant)
|
|
3
|
+
* window state derived from observed token consumption. Persists per-key state to
|
|
4
|
+
* $ARTIFACTS_DIR/_ledger/<harness>-<accountIdShort>-<tenant>.json so it survives
|
|
5
|
+
* orchestrator restarts within a window.
|
|
6
|
+
*/
|
|
7
|
+
import { type AdmissionDecision, type LedgerKey, type SubscriptionPlan, type TokenEstimate, type WindowState } from './types.js';
|
|
8
|
+
export interface LedgerDeps {
|
|
9
|
+
now?: () => Date;
|
|
10
|
+
/** Override file IO for tests. */
|
|
11
|
+
io?: {
|
|
12
|
+
read: (path: string) => Promise<string | null>;
|
|
13
|
+
write: (path: string, content: string) => Promise<void>;
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
export declare class SubscriptionLedger {
|
|
17
|
+
private readonly state;
|
|
18
|
+
private readonly now;
|
|
19
|
+
private readonly io;
|
|
20
|
+
private readonly artifactsDir;
|
|
21
|
+
constructor(artifactsDir: string, deps?: LedgerDeps);
|
|
22
|
+
static keyToString(key: LedgerKey): string;
|
|
23
|
+
static keyToFilename(key: LedgerKey): string;
|
|
24
|
+
private filePathFor;
|
|
25
|
+
/** Load persisted state for a key (or initialize a fresh window). */
|
|
26
|
+
load(key: LedgerKey, plan: SubscriptionPlan): Promise<void>;
|
|
27
|
+
windowState(key: LedgerKey, plan: SubscriptionPlan): WindowState;
|
|
28
|
+
/**
|
|
29
|
+
* Decide whether a stage with the given estimated token cost may admit. Off-peak
|
|
30
|
+
* multiplier reduces the effective cost (2x off-peak → 0.5x quota consumption).
|
|
31
|
+
*/
|
|
32
|
+
admit(key: LedgerKey, plan: SubscriptionPlan, estimate: TokenEstimate): AdmissionDecision;
|
|
33
|
+
record(key: LedgerKey, plan: SubscriptionPlan, actualTokens: {
|
|
34
|
+
input: number;
|
|
35
|
+
output: number;
|
|
36
|
+
}): Promise<void>;
|
|
37
|
+
isOffPeak(plan: SubscriptionPlan, when?: Date): boolean;
|
|
38
|
+
/** Reset the ledger for a key (used by tests + the LedgerReconciliation flow). */
|
|
39
|
+
reset(key: LedgerKey): void;
|
|
40
|
+
private persist;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Tenant-share validation per RFC §14.12. Returns null if valid; otherwise the failed
|
|
44
|
+
* (harness, accountId) groups with their share sums. The orchestrator emits
|
|
45
|
+
* TenantShareInvalid for each failure and refuses to start.
|
|
46
|
+
*/
|
|
47
|
+
export declare function validateTenantShares(pipelines: ReadonlyArray<{
|
|
48
|
+
name: string;
|
|
49
|
+
harness: string;
|
|
50
|
+
accountId: string;
|
|
51
|
+
tenant?: string;
|
|
52
|
+
tenantQuotaShare?: number;
|
|
53
|
+
}>, tolerance?: number): Array<{
|
|
54
|
+
harness: string;
|
|
55
|
+
accountId: string;
|
|
56
|
+
sumOfShares: number;
|
|
57
|
+
pipelines: string[];
|
|
58
|
+
}>;
|
|
59
|
+
//# sourceMappingURL=ledger.d.ts.map
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SubscriptionLedger per RFC §14.2 / §14.12. Tracks per-(harness, accountId, tenant)
|
|
3
|
+
* window state derived from observed token consumption. Persists per-key state to
|
|
4
|
+
* $ARTIFACTS_DIR/_ledger/<harness>-<accountIdShort>-<tenant>.json so it survives
|
|
5
|
+
* orchestrator restarts within a window.
|
|
6
|
+
*/
|
|
7
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
8
|
+
import { dirname, join } from 'node:path';
|
|
9
|
+
import { isOffPeakAt } from './off-peak.js';
|
|
10
|
+
import { DEFAULT_TENANT, } from './types.js';
|
|
11
|
+
export class SubscriptionLedger {
|
|
12
|
+
state = new Map();
|
|
13
|
+
now;
|
|
14
|
+
io;
|
|
15
|
+
artifactsDir;
|
|
16
|
+
constructor(artifactsDir, deps = {}) {
|
|
17
|
+
this.artifactsDir = artifactsDir;
|
|
18
|
+
this.now = deps.now ?? (() => new Date());
|
|
19
|
+
this.io = deps.io ?? {
|
|
20
|
+
read: async (p) => {
|
|
21
|
+
try {
|
|
22
|
+
return await readFile(p, 'utf8');
|
|
23
|
+
}
|
|
24
|
+
catch (err) {
|
|
25
|
+
if (err.code === 'ENOENT')
|
|
26
|
+
return null;
|
|
27
|
+
throw err;
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
write: async (p, c) => {
|
|
31
|
+
await mkdir(dirname(p), { recursive: true });
|
|
32
|
+
await writeFile(p, c, 'utf8');
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
static keyToString(key) {
|
|
37
|
+
return `${key.harness}|${key.accountId}|${key.tenant ?? DEFAULT_TENANT}`;
|
|
38
|
+
}
|
|
39
|
+
static keyToFilename(key) {
|
|
40
|
+
const accountIdShort = key.accountId.slice(0, 8);
|
|
41
|
+
const tenant = key.tenant ?? DEFAULT_TENANT;
|
|
42
|
+
return `${key.harness}-${accountIdShort}-${tenant}.json`;
|
|
43
|
+
}
|
|
44
|
+
filePathFor(key) {
|
|
45
|
+
return join(this.artifactsDir, '_ledger', SubscriptionLedger.keyToFilename(key));
|
|
46
|
+
}
|
|
47
|
+
/** Load persisted state for a key (or initialize a fresh window). */
|
|
48
|
+
async load(key, plan) {
|
|
49
|
+
const path = this.filePathFor(key);
|
|
50
|
+
const raw = await this.io.read(path);
|
|
51
|
+
if (raw) {
|
|
52
|
+
try {
|
|
53
|
+
const parsed = JSON.parse(raw);
|
|
54
|
+
const now = this.now();
|
|
55
|
+
const windowStart = new Date(parsed.windowStart);
|
|
56
|
+
const windowEnd = computeWindowEnd(windowStart, plan);
|
|
57
|
+
if (now.getTime() < windowEnd.getTime()) {
|
|
58
|
+
this.state.set(SubscriptionLedger.keyToString(key), parsed);
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
// Window has expired; reset.
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
// Malformed; reset.
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
this.state.set(SubscriptionLedger.keyToString(key), {
|
|
68
|
+
windowStart: this.now().toISOString(),
|
|
69
|
+
consumedTokens: 0,
|
|
70
|
+
});
|
|
71
|
+
await this.persist(key);
|
|
72
|
+
}
|
|
73
|
+
windowState(key, plan) {
|
|
74
|
+
const persisted = this.state.get(SubscriptionLedger.keyToString(key));
|
|
75
|
+
if (!persisted)
|
|
76
|
+
throw new Error(`Ledger not loaded for key: ${SubscriptionLedger.keyToString(key)}`);
|
|
77
|
+
const windowStart = new Date(persisted.windowStart);
|
|
78
|
+
const windowEnd = computeWindowEnd(windowStart, plan);
|
|
79
|
+
const multiplier = plan.offPeak && isOffPeakAt(plan.offPeak, this.now()) ? plan.offPeak.multiplier : 1.0;
|
|
80
|
+
const quotaTokens = plan.windowQuotaTokens ?? Number.POSITIVE_INFINITY;
|
|
81
|
+
return {
|
|
82
|
+
windowStart,
|
|
83
|
+
windowEnd,
|
|
84
|
+
consumedTokens: persisted.consumedTokens,
|
|
85
|
+
quotaTokens,
|
|
86
|
+
multiplier,
|
|
87
|
+
utilizationFraction: quotaTokens === Number.POSITIVE_INFINITY ? 0 : persisted.consumedTokens / quotaTokens,
|
|
88
|
+
pacingTarget: plan.pacingTarget,
|
|
89
|
+
hardCap: plan.hardCap,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Decide whether a stage with the given estimated token cost may admit. Off-peak
|
|
94
|
+
* multiplier reduces the effective cost (2x off-peak → 0.5x quota consumption).
|
|
95
|
+
*/
|
|
96
|
+
admit(key, plan, estimate) {
|
|
97
|
+
const ws = this.windowState(key, plan);
|
|
98
|
+
if (plan.billingMode === 'pay-per-token' || ws.quotaTokens === Number.POSITIVE_INFINITY) {
|
|
99
|
+
return { kind: 'yes', reason: 'pay-per-token plan; no quota' };
|
|
100
|
+
}
|
|
101
|
+
const totalEstimate = (estimate.input + estimate.output) / ws.multiplier;
|
|
102
|
+
const wouldConsume = ws.consumedTokens + totalEstimate;
|
|
103
|
+
const projectedFraction = wouldConsume / ws.quotaTokens;
|
|
104
|
+
if (projectedFraction <= plan.hardCap) {
|
|
105
|
+
return {
|
|
106
|
+
kind: 'yes',
|
|
107
|
+
reason: `under hardCap (${projectedFraction.toFixed(3)} ≤ ${plan.hardCap})`,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
return {
|
|
111
|
+
kind: 'no',
|
|
112
|
+
reason: `would exceed hardCap (${projectedFraction.toFixed(3)} > ${plan.hardCap})`,
|
|
113
|
+
blockedBy: 'hardCap',
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
async record(key, plan, actualTokens) {
|
|
117
|
+
const persisted = this.state.get(SubscriptionLedger.keyToString(key));
|
|
118
|
+
if (!persisted)
|
|
119
|
+
throw new Error(`Ledger not loaded for key: ${SubscriptionLedger.keyToString(key)}`);
|
|
120
|
+
const multiplier = plan.offPeak && isOffPeakAt(plan.offPeak, this.now()) ? plan.offPeak.multiplier : 1.0;
|
|
121
|
+
persisted.consumedTokens += (actualTokens.input + actualTokens.output) / multiplier;
|
|
122
|
+
await this.persist(key);
|
|
123
|
+
}
|
|
124
|
+
isOffPeak(plan, when) {
|
|
125
|
+
if (!plan.offPeak)
|
|
126
|
+
return false;
|
|
127
|
+
return isOffPeakAt(plan.offPeak, when ?? this.now());
|
|
128
|
+
}
|
|
129
|
+
/** Reset the ledger for a key (used by tests + the LedgerReconciliation flow). */
|
|
130
|
+
reset(key) {
|
|
131
|
+
this.state.set(SubscriptionLedger.keyToString(key), {
|
|
132
|
+
windowStart: this.now().toISOString(),
|
|
133
|
+
consumedTokens: 0,
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
async persist(key) {
|
|
137
|
+
const persisted = this.state.get(SubscriptionLedger.keyToString(key));
|
|
138
|
+
if (!persisted)
|
|
139
|
+
return;
|
|
140
|
+
await this.io.write(this.filePathFor(key), JSON.stringify(persisted));
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
function computeWindowEnd(windowStart, plan) {
|
|
144
|
+
if (plan.billingMode === 'monthly-cap') {
|
|
145
|
+
// Roll at month boundary.
|
|
146
|
+
const next = new Date(windowStart);
|
|
147
|
+
next.setMonth(next.getMonth() + 1);
|
|
148
|
+
return next;
|
|
149
|
+
}
|
|
150
|
+
if (plan.billingMode === 'session-window' && plan.windowDuration) {
|
|
151
|
+
return new Date(windowStart.getTime() + parseIso8601DurationMs(plan.windowDuration));
|
|
152
|
+
}
|
|
153
|
+
// pay-per-token: no window.
|
|
154
|
+
return new Date(windowStart.getTime() + 365 * 24 * 60 * 60 * 1000);
|
|
155
|
+
}
|
|
156
|
+
/** Minimal ISO 8601 duration parser supporting PT<N>H / PT<N>M / P<N>D combinations. */
|
|
157
|
+
function parseIso8601DurationMs(duration) {
|
|
158
|
+
const m = duration.match(/^P(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?)?$/);
|
|
159
|
+
if (!m)
|
|
160
|
+
throw new Error(`Unsupported ISO 8601 duration: ${duration}`);
|
|
161
|
+
const days = Number.parseInt(m[1] ?? '0', 10);
|
|
162
|
+
const hours = Number.parseInt(m[2] ?? '0', 10);
|
|
163
|
+
const minutes = Number.parseInt(m[3] ?? '0', 10);
|
|
164
|
+
return ((days * 24 + hours) * 60 + minutes) * 60 * 1000;
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Tenant-share validation per RFC §14.12. Returns null if valid; otherwise the failed
|
|
168
|
+
* (harness, accountId) groups with their share sums. The orchestrator emits
|
|
169
|
+
* TenantShareInvalid for each failure and refuses to start.
|
|
170
|
+
*/
|
|
171
|
+
export function validateTenantShares(pipelines, tolerance = 0.001) {
|
|
172
|
+
const groups = new Map();
|
|
173
|
+
for (const p of pipelines) {
|
|
174
|
+
const key = `${p.harness}|${p.accountId}`;
|
|
175
|
+
let group = groups.get(key);
|
|
176
|
+
if (!group) {
|
|
177
|
+
group = {
|
|
178
|
+
harness: p.harness,
|
|
179
|
+
accountId: p.accountId,
|
|
180
|
+
pipelines: [],
|
|
181
|
+
shares: [],
|
|
182
|
+
tenants: new Set(),
|
|
183
|
+
};
|
|
184
|
+
groups.set(key, group);
|
|
185
|
+
}
|
|
186
|
+
group.pipelines.push(p.name);
|
|
187
|
+
group.shares.push(p.tenantQuotaShare);
|
|
188
|
+
if (p.tenant)
|
|
189
|
+
group.tenants.add(p.tenant);
|
|
190
|
+
}
|
|
191
|
+
const failures = [];
|
|
192
|
+
for (const group of groups.values()) {
|
|
193
|
+
if (group.tenants.size === 0)
|
|
194
|
+
continue; // all untenanted; no validation needed
|
|
195
|
+
if (group.shares.some((s) => s === undefined)) {
|
|
196
|
+
failures.push({
|
|
197
|
+
harness: group.harness,
|
|
198
|
+
accountId: group.accountId,
|
|
199
|
+
sumOfShares: NaN,
|
|
200
|
+
pipelines: group.pipelines,
|
|
201
|
+
});
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
const sum = group.shares.reduce((a, b) => a + b, 0);
|
|
205
|
+
if (Math.abs(sum - 1.0) > tolerance) {
|
|
206
|
+
failures.push({
|
|
207
|
+
harness: group.harness,
|
|
208
|
+
accountId: group.accountId,
|
|
209
|
+
sumOfShares: sum,
|
|
210
|
+
pipelines: group.pipelines,
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
return failures;
|
|
215
|
+
}
|
|
216
|
+
//# sourceMappingURL=ledger.js.map
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Off-peak window evaluation per RFC §14.5. Operator-declared schedules with hour ranges
|
|
3
|
+
* (possibly wrapping midnight) and optional day-of-week filters, evaluated against an
|
|
4
|
+
* IANA timezone.
|
|
5
|
+
*/
|
|
6
|
+
import type { OffPeakSchedule } from './types.js';
|
|
7
|
+
/**
|
|
8
|
+
* Returns true when the given instant falls inside any of the schedule's windows.
|
|
9
|
+
* Each window's `hours` is a range like '22-06' (which wraps midnight) or '0-7'.
|
|
10
|
+
*/
|
|
11
|
+
export declare function isOffPeakAt(schedule: OffPeakSchedule, when: Date): boolean;
|
|
12
|
+
/**
|
|
13
|
+
* Returns the next off-peak window start strictly after `from`. Iterates by hour up to
|
|
14
|
+
* a 7-day horizon — bounded scan since schedules are weekly-periodic.
|
|
15
|
+
*/
|
|
16
|
+
export declare function nextOffPeakStart(schedule: OffPeakSchedule, from: Date): Date | null;
|
|
17
|
+
/**
|
|
18
|
+
* Days since `lastVerified`. Returns Infinity if the date is missing/unparseable.
|
|
19
|
+
*/
|
|
20
|
+
export declare function ageInDays(lastVerified: string | undefined, now?: Date): number;
|
|
21
|
+
export type FreshnessLevel = 'fresh' | 'advisory' | 'error';
|
|
22
|
+
/**
|
|
23
|
+
* Returns the freshness severity per RFC §14.5: fresh ≤ 30 days, advisory 30–90 days,
|
|
24
|
+
* error > 90 days (or missing). Operators see this in cli-status --subscriptions.
|
|
25
|
+
*/
|
|
26
|
+
export declare function freshnessLevel(lastVerified: string | undefined, now?: Date): FreshnessLevel;
|
|
27
|
+
//# sourceMappingURL=off-peak.d.ts.map
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Off-peak window evaluation per RFC §14.5. Operator-declared schedules with hour ranges
|
|
3
|
+
* (possibly wrapping midnight) and optional day-of-week filters, evaluated against an
|
|
4
|
+
* IANA timezone.
|
|
5
|
+
*/
|
|
6
|
+
const DAY_NAMES = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
|
7
|
+
/**
|
|
8
|
+
* Returns true when the given instant falls inside any of the schedule's windows.
|
|
9
|
+
* Each window's `hours` is a range like '22-06' (which wraps midnight) or '0-7'.
|
|
10
|
+
*/
|
|
11
|
+
export function isOffPeakAt(schedule, when) {
|
|
12
|
+
if (!schedule.enabled || schedule.schedule.length === 0)
|
|
13
|
+
return false;
|
|
14
|
+
for (const window of schedule.schedule) {
|
|
15
|
+
if (windowMatches(window, when))
|
|
16
|
+
return true;
|
|
17
|
+
}
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
function windowMatches(window, when) {
|
|
21
|
+
const hourMatch = window.hours.match(/^(\d{1,2})-(\d{1,2})$/);
|
|
22
|
+
if (!hourMatch)
|
|
23
|
+
return false;
|
|
24
|
+
const startHour = Number.parseInt(hourMatch[1], 10);
|
|
25
|
+
const endHour = Number.parseInt(hourMatch[2], 10);
|
|
26
|
+
// Get the local hour + day-of-week in the window's timezone.
|
|
27
|
+
const local = getLocalTimeInTimezone(when, window.tz);
|
|
28
|
+
if (!local)
|
|
29
|
+
return false;
|
|
30
|
+
// Day filter (optional).
|
|
31
|
+
if (window.daysOfWeek) {
|
|
32
|
+
const allowedDays = new Set(window.daysOfWeek.split(',').map((d) => d.trim()));
|
|
33
|
+
if (!allowedDays.has(DAY_NAMES[local.dayOfWeek]))
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
// Hour range; wraps midnight when start > end.
|
|
37
|
+
if (startHour <= endHour) {
|
|
38
|
+
return local.hour >= startHour && local.hour < endHour;
|
|
39
|
+
}
|
|
40
|
+
// Wrapping case: e.g., 22-06 means [22, 24) ∪ [0, 6).
|
|
41
|
+
return local.hour >= startHour || local.hour < endHour;
|
|
42
|
+
}
|
|
43
|
+
function getLocalTimeInTimezone(when, tz) {
|
|
44
|
+
try {
|
|
45
|
+
const fmt = new Intl.DateTimeFormat('en-US', {
|
|
46
|
+
timeZone: tz,
|
|
47
|
+
hour: 'numeric',
|
|
48
|
+
hourCycle: 'h23',
|
|
49
|
+
weekday: 'short',
|
|
50
|
+
});
|
|
51
|
+
const parts = fmt.formatToParts(when);
|
|
52
|
+
let hour = null;
|
|
53
|
+
let weekday = null;
|
|
54
|
+
for (const p of parts) {
|
|
55
|
+
if (p.type === 'hour')
|
|
56
|
+
hour = Number.parseInt(p.value, 10);
|
|
57
|
+
else if (p.type === 'weekday')
|
|
58
|
+
weekday = p.value;
|
|
59
|
+
}
|
|
60
|
+
if (hour === null || weekday === null)
|
|
61
|
+
return null;
|
|
62
|
+
const dayIndex = DAY_NAMES.indexOf(weekday);
|
|
63
|
+
if (dayIndex < 0)
|
|
64
|
+
return null;
|
|
65
|
+
return { hour, dayOfWeek: dayIndex };
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Returns the next off-peak window start strictly after `from`. Iterates by hour up to
|
|
73
|
+
* a 7-day horizon — bounded scan since schedules are weekly-periodic.
|
|
74
|
+
*/
|
|
75
|
+
export function nextOffPeakStart(schedule, from) {
|
|
76
|
+
if (!schedule.enabled || schedule.schedule.length === 0)
|
|
77
|
+
return null;
|
|
78
|
+
const HORIZON_HOURS = 24 * 7;
|
|
79
|
+
for (let h = 1; h <= HORIZON_HOURS; h++) {
|
|
80
|
+
const candidate = new Date(from.getTime() + h * 60 * 60 * 1000);
|
|
81
|
+
const candidateMinusHour = new Date(candidate.getTime() - 60 * 60 * 1000);
|
|
82
|
+
// Find a transition from off → on.
|
|
83
|
+
if (isOffPeakAt(schedule, candidate) && !isOffPeakAt(schedule, candidateMinusHour)) {
|
|
84
|
+
return candidate;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Days since `lastVerified`. Returns Infinity if the date is missing/unparseable.
|
|
91
|
+
*/
|
|
92
|
+
export function ageInDays(lastVerified, now = new Date()) {
|
|
93
|
+
if (!lastVerified)
|
|
94
|
+
return Infinity;
|
|
95
|
+
const parsed = new Date(lastVerified);
|
|
96
|
+
if (Number.isNaN(parsed.getTime()))
|
|
97
|
+
return Infinity;
|
|
98
|
+
return Math.floor((now.getTime() - parsed.getTime()) / (24 * 60 * 60 * 1000));
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Returns the freshness severity per RFC §14.5: fresh ≤ 30 days, advisory 30–90 days,
|
|
102
|
+
* error > 90 days (or missing). Operators see this in cli-status --subscriptions.
|
|
103
|
+
*/
|
|
104
|
+
export function freshnessLevel(lastVerified, now = new Date()) {
|
|
105
|
+
const age = ageInDays(lastVerified, now);
|
|
106
|
+
if (age <= 30)
|
|
107
|
+
return 'fresh';
|
|
108
|
+
if (age <= 90)
|
|
109
|
+
return 'advisory';
|
|
110
|
+
return 'error';
|
|
111
|
+
}
|
|
112
|
+
//# sourceMappingURL=off-peak.js.map
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Schedule-aware dispatch decision per RFC §14.3. Implements the four schedule modes
|
|
3
|
+
* (now, off-peak, quota-permitting, defer-if-low-priority) on top of the
|
|
4
|
+
* SubscriptionLedger admission decision.
|
|
5
|
+
*/
|
|
6
|
+
import type { SubscriptionLedger } from './ledger.js';
|
|
7
|
+
import type { LedgerKey, Schedule, SubscriptionPlan, TokenEstimate } from './types.js';
|
|
8
|
+
export type ScheduleDecision = {
|
|
9
|
+
kind: 'dispatch-now';
|
|
10
|
+
reason: string;
|
|
11
|
+
} | {
|
|
12
|
+
kind: 'wait-until';
|
|
13
|
+
until: Date;
|
|
14
|
+
reason: string;
|
|
15
|
+
} | {
|
|
16
|
+
kind: 'requeue';
|
|
17
|
+
reason: string;
|
|
18
|
+
} | {
|
|
19
|
+
kind: 'denied';
|
|
20
|
+
reason: string;
|
|
21
|
+
blockedBy: string;
|
|
22
|
+
};
|
|
23
|
+
export interface ScheduleEvaluationContext {
|
|
24
|
+
ledger: SubscriptionLedger;
|
|
25
|
+
ledgerKey: LedgerKey;
|
|
26
|
+
plan: SubscriptionPlan;
|
|
27
|
+
estimate: TokenEstimate;
|
|
28
|
+
/**
|
|
29
|
+
* PPA score in [0,1]. Used by 'defer-if-low-priority' to decide whether to
|
|
30
|
+
* dispatch immediately (top quartile or > 30% headroom in the window).
|
|
31
|
+
*/
|
|
32
|
+
ppaScore?: number;
|
|
33
|
+
queueScores?: number[];
|
|
34
|
+
/** Maximum time to wait for the next off-peak window. ISO 8601 duration. */
|
|
35
|
+
offPeakMaxWait?: string;
|
|
36
|
+
now?: () => Date;
|
|
37
|
+
}
|
|
38
|
+
export declare function evaluateSchedule(schedule: Schedule, ctx: ScheduleEvaluationContext): ScheduleDecision;
|
|
39
|
+
//# sourceMappingURL=schedule-decision.d.ts.map
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Schedule-aware dispatch decision per RFC §14.3. Implements the four schedule modes
|
|
3
|
+
* (now, off-peak, quota-permitting, defer-if-low-priority) on top of the
|
|
4
|
+
* SubscriptionLedger admission decision.
|
|
5
|
+
*/
|
|
6
|
+
import { isOffPeakAt, nextOffPeakStart } from './off-peak.js';
|
|
7
|
+
const DEFAULT_OFF_PEAK_MAX_WAIT_MS = 8 * 60 * 60 * 1000; // PT8H
|
|
8
|
+
export function evaluateSchedule(schedule, ctx) {
|
|
9
|
+
const now = ctx.now ?? (() => new Date());
|
|
10
|
+
const admission = ctx.ledger.admit(ctx.ledgerKey, ctx.plan, ctx.estimate);
|
|
11
|
+
switch (schedule) {
|
|
12
|
+
case 'now':
|
|
13
|
+
return interpretNow(admission);
|
|
14
|
+
case 'off-peak':
|
|
15
|
+
return interpretOffPeak(admission, ctx, now());
|
|
16
|
+
case 'quota-permitting':
|
|
17
|
+
return interpretQuotaPermitting(admission);
|
|
18
|
+
case 'defer-if-low-priority':
|
|
19
|
+
return interpretDeferIfLowPriority(admission, ctx, now());
|
|
20
|
+
default:
|
|
21
|
+
throw new Error(`Unknown schedule mode: ${String(schedule)}`);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
function interpretNow(admission) {
|
|
25
|
+
if (admission.kind === 'yes')
|
|
26
|
+
return { kind: 'dispatch-now', reason: admission.reason };
|
|
27
|
+
if (admission.kind === 'wait-until')
|
|
28
|
+
return { kind: 'wait-until', until: admission.until, reason: admission.reason };
|
|
29
|
+
return { kind: 'denied', reason: admission.reason, blockedBy: admission.blockedBy };
|
|
30
|
+
}
|
|
31
|
+
function interpretOffPeak(admission, ctx, now) {
|
|
32
|
+
if (!ctx.plan.offPeak || !ctx.plan.offPeak.enabled) {
|
|
33
|
+
// No off-peak configured; behave as 'now'.
|
|
34
|
+
return interpretNow(admission);
|
|
35
|
+
}
|
|
36
|
+
if (isOffPeakAt(ctx.plan.offPeak, now)) {
|
|
37
|
+
return interpretNow(admission);
|
|
38
|
+
}
|
|
39
|
+
const next = nextOffPeakStart(ctx.plan.offPeak, now);
|
|
40
|
+
if (!next)
|
|
41
|
+
return interpretNow(admission);
|
|
42
|
+
const maxWaitMs = parseDurationMs(ctx.offPeakMaxWait) ?? DEFAULT_OFF_PEAK_MAX_WAIT_MS;
|
|
43
|
+
if (next.getTime() - now.getTime() <= maxWaitMs) {
|
|
44
|
+
return {
|
|
45
|
+
kind: 'wait-until',
|
|
46
|
+
until: next,
|
|
47
|
+
reason: 'next off-peak window within offPeakMaxWait',
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
// Off-peak too far in the future; fall through to dispatch-on-peak with a warning.
|
|
51
|
+
return { kind: 'dispatch-now', reason: 'OffPeakDeferralExceeded — dispatching on-peak' };
|
|
52
|
+
}
|
|
53
|
+
function interpretQuotaPermitting(admission) {
|
|
54
|
+
if (admission.kind === 'yes')
|
|
55
|
+
return { kind: 'dispatch-now', reason: admission.reason };
|
|
56
|
+
return { kind: 'requeue', reason: admission.reason };
|
|
57
|
+
}
|
|
58
|
+
function interpretDeferIfLowPriority(admission, ctx, now) {
|
|
59
|
+
// Top quartile of queue → dispatch immediately.
|
|
60
|
+
const isTopQuartile = ctx.ppaScore !== undefined && ctx.queueScores && isInTopQuartile(ctx.ppaScore, ctx.queueScores);
|
|
61
|
+
if (isTopQuartile)
|
|
62
|
+
return interpretNow(admission);
|
|
63
|
+
// >30% window headroom → dispatch immediately.
|
|
64
|
+
const ws = ctx.ledger.windowState(ctx.ledgerKey, ctx.plan);
|
|
65
|
+
const headroomFraction = 1 - ws.utilizationFraction;
|
|
66
|
+
if (headroomFraction > 0.3)
|
|
67
|
+
return interpretNow(admission);
|
|
68
|
+
// Otherwise behave as 'off-peak'.
|
|
69
|
+
return interpretOffPeak(admission, ctx, now);
|
|
70
|
+
}
|
|
71
|
+
function isInTopQuartile(score, queueScores) {
|
|
72
|
+
if (queueScores.length === 0)
|
|
73
|
+
return true;
|
|
74
|
+
const sorted = [...queueScores].sort((a, b) => b - a);
|
|
75
|
+
const cutoff = sorted[Math.floor(sorted.length / 4)] ?? sorted[0];
|
|
76
|
+
return score >= cutoff;
|
|
77
|
+
}
|
|
78
|
+
function parseDurationMs(duration) {
|
|
79
|
+
if (!duration)
|
|
80
|
+
return null;
|
|
81
|
+
const m = duration.match(/^P(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?)?$/);
|
|
82
|
+
if (!m)
|
|
83
|
+
return null;
|
|
84
|
+
const days = Number.parseInt(m[1] ?? '0', 10);
|
|
85
|
+
const hours = Number.parseInt(m[2] ?? '0', 10);
|
|
86
|
+
const minutes = Number.parseInt(m[3] ?? '0', 10);
|
|
87
|
+
return ((days * 24 + hours) * 60 + minutes) * 60 * 1000;
|
|
88
|
+
}
|
|
89
|
+
//# sourceMappingURL=schedule-decision.js.map
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TierAnalysis aggregator per RFC §14.13. Aggregates QuotaContention events per
|
|
3
|
+
* billing window and produces upgrade/downgrade recommendations consumed by the
|
|
4
|
+
* Slack daily digest and cli-tier-recommendation.
|
|
5
|
+
*/
|
|
6
|
+
import type { LedgerKey, SubscriptionPlan } from './types.js';
|
|
7
|
+
export type Confidence = 'low' | 'medium' | 'high';
|
|
8
|
+
export interface ContentionEvent {
|
|
9
|
+
timestamp: string;
|
|
10
|
+
/** Cumulative duration the high-PPA stage waited on hardCap. */
|
|
11
|
+
contentionDurationMs: number;
|
|
12
|
+
}
|
|
13
|
+
export interface TierAnalysisInput {
|
|
14
|
+
billingPeriod: string;
|
|
15
|
+
ledgerKey: LedgerKey;
|
|
16
|
+
currentPlan: SubscriptionPlan;
|
|
17
|
+
contentionEvents: ContentionEvent[];
|
|
18
|
+
issuesDeferredOffPeak: number;
|
|
19
|
+
issuesBlockedOnHardCap: number;
|
|
20
|
+
/** Candidate plans (typically: registered SubscriptionPlan resources). */
|
|
21
|
+
candidates: SubscriptionPlan[];
|
|
22
|
+
}
|
|
23
|
+
export interface TierAnalysisResult {
|
|
24
|
+
billingPeriod: string;
|
|
25
|
+
ledgerKey: LedgerKey;
|
|
26
|
+
currentPlan: string;
|
|
27
|
+
currentPlanCostUsd: number;
|
|
28
|
+
contentionEvents: number;
|
|
29
|
+
cumulativeContentionDurationMs: number;
|
|
30
|
+
issuesDeferredOffPeak: number;
|
|
31
|
+
issuesBlockedOnHardCap: number;
|
|
32
|
+
recommendedPlan: string;
|
|
33
|
+
recommendedPlanCostUsd: number;
|
|
34
|
+
projectedTimeSavedMs: number;
|
|
35
|
+
projectedAdditionalIssuesProcessed: number;
|
|
36
|
+
projectedSpilloverSavingsUsd: number;
|
|
37
|
+
confidence: Confidence;
|
|
38
|
+
reasoning: string;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Reference plan-cost table — the orchestrator typically has these populated from the
|
|
42
|
+
* SubscriptionPlan resources, but ships with sensible defaults so cli-tier-recommendation
|
|
43
|
+
* can produce useful output even on a fresh deployment.
|
|
44
|
+
*/
|
|
45
|
+
export declare const PLAN_COSTS_USD: Record<string, number>;
|
|
46
|
+
export declare function analyzeTier(input: TierAnalysisInput): TierAnalysisResult;
|
|
47
|
+
//# sourceMappingURL=tier-analysis.d.ts.map
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TierAnalysis aggregator per RFC §14.13. Aggregates QuotaContention events per
|
|
3
|
+
* billing window and produces upgrade/downgrade recommendations consumed by the
|
|
4
|
+
* Slack daily digest and cli-tier-recommendation.
|
|
5
|
+
*/
|
|
6
|
+
/**
|
|
7
|
+
* Reference plan-cost table — the orchestrator typically has these populated from the
|
|
8
|
+
* SubscriptionPlan resources, but ships with sensible defaults so cli-tier-recommendation
|
|
9
|
+
* can produce useful output even on a fresh deployment.
|
|
10
|
+
*/
|
|
11
|
+
export const PLAN_COSTS_USD = {
|
|
12
|
+
'claude-code-pro': 20,
|
|
13
|
+
'claude-code-max-5x': 100,
|
|
14
|
+
'claude-code-max-20x': 200,
|
|
15
|
+
'codex-plus': 20,
|
|
16
|
+
'codex-pro': 200,
|
|
17
|
+
'pay-per-token': 0,
|
|
18
|
+
};
|
|
19
|
+
const HIGH_CONTENTION_THRESHOLD = 20;
|
|
20
|
+
const MEDIUM_CONTENTION_THRESHOLD = 5;
|
|
21
|
+
export function analyzeTier(input) {
|
|
22
|
+
const cumulative = input.contentionEvents.reduce((a, e) => a + e.contentionDurationMs, 0);
|
|
23
|
+
const confidence = confidenceFor(input.contentionEvents.length);
|
|
24
|
+
const currentPlanCost = PLAN_COSTS_USD[input.currentPlan.name] ?? 0;
|
|
25
|
+
// Find the candidate that minimizes expected contention, conservative for downgrades.
|
|
26
|
+
let recommended = input.currentPlan;
|
|
27
|
+
let recommendedCost = currentPlanCost;
|
|
28
|
+
if (input.contentionEvents.length > 0) {
|
|
29
|
+
// Upgrade direction: pick the next plan up.
|
|
30
|
+
const upgrade = nextPlanUp(input.currentPlan, input.candidates);
|
|
31
|
+
if (upgrade) {
|
|
32
|
+
recommended = upgrade;
|
|
33
|
+
recommendedCost = PLAN_COSTS_USD[upgrade.name] ?? currentPlanCost;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
else if (confidence === 'high') {
|
|
37
|
+
// Possibly recommend downgrade if utilization is consistently low — we don't have
|
|
38
|
+
// utilization in this aggregation; defer to caller to set issuesBlockedOnHardCap=0
|
|
39
|
+
// and contentionEvents=0 explicitly. For now keep currentPlan as the recommendation.
|
|
40
|
+
}
|
|
41
|
+
const reasoning = buildReasoning(input, cumulative, recommended);
|
|
42
|
+
return {
|
|
43
|
+
billingPeriod: input.billingPeriod,
|
|
44
|
+
ledgerKey: input.ledgerKey,
|
|
45
|
+
currentPlan: input.currentPlan.name,
|
|
46
|
+
currentPlanCostUsd: currentPlanCost,
|
|
47
|
+
contentionEvents: input.contentionEvents.length,
|
|
48
|
+
cumulativeContentionDurationMs: cumulative,
|
|
49
|
+
issuesDeferredOffPeak: input.issuesDeferredOffPeak,
|
|
50
|
+
issuesBlockedOnHardCap: input.issuesBlockedOnHardCap,
|
|
51
|
+
recommendedPlan: recommended.name,
|
|
52
|
+
recommendedPlanCostUsd: recommendedCost,
|
|
53
|
+
projectedTimeSavedMs: cumulative * 0.75, // heuristic: upgrade reclaims ~75% of contention
|
|
54
|
+
projectedAdditionalIssuesProcessed: Math.floor(cumulative / (60 * 60 * 1000)), // 1 issue per hour saved
|
|
55
|
+
projectedSpilloverSavingsUsd: input.issuesBlockedOnHardCap * 1.5,
|
|
56
|
+
confidence,
|
|
57
|
+
reasoning,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
function nextPlanUp(current, candidates) {
|
|
61
|
+
const sameHarness = candidates.filter((c) => c.harness === current.harness);
|
|
62
|
+
const ordered = sameHarness.sort((a, b) => (PLAN_COSTS_USD[a.name] ?? 0) - (PLAN_COSTS_USD[b.name] ?? 0));
|
|
63
|
+
const idx = ordered.findIndex((p) => p.name === current.name);
|
|
64
|
+
if (idx < 0 || idx === ordered.length - 1)
|
|
65
|
+
return null;
|
|
66
|
+
return ordered[idx + 1];
|
|
67
|
+
}
|
|
68
|
+
function confidenceFor(count) {
|
|
69
|
+
if (count > HIGH_CONTENTION_THRESHOLD)
|
|
70
|
+
return 'high';
|
|
71
|
+
if (count >= MEDIUM_CONTENTION_THRESHOLD)
|
|
72
|
+
return 'medium';
|
|
73
|
+
return 'low';
|
|
74
|
+
}
|
|
75
|
+
function buildReasoning(input, cumulativeMs, recommended) {
|
|
76
|
+
if (recommended.name === input.currentPlan.name) {
|
|
77
|
+
return `Current plan ${input.currentPlan.name} appears appropriately sized for observed contention (${input.contentionEvents.length} events, ${(cumulativeMs / 1000 / 60 / 60).toFixed(1)}h cumulative).`;
|
|
78
|
+
}
|
|
79
|
+
return `${input.currentPlan.name} hit hardCap on ${input.issuesBlockedOnHardCap} issues this period, totaling ${(cumulativeMs / 1000 / 60 / 60).toFixed(1)}h of cumulative wait. ${recommended.name} would project to keep utilization below pacingTarget at current dispatch rate.`;
|
|
80
|
+
}
|
|
81
|
+
//# sourceMappingURL=tier-analysis.js.map
|