@aiwg/cli 2026.9.20 → 2026.9.21
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/agentic/code/providers/capability-matrix.yaml +58 -6
- package/agentic/code/providers/model-capabilities.v1.json +34 -0
- package/agentic/code/providers/model-catalog.v1.json +55 -13
- package/bin/aiwg.mjs +2 -0
- package/dist/src/api/index.d.ts +1 -0
- package/dist/src/api/index.js +1 -0
- package/dist/src/artifacts/enrichment/cli.js +1 -1
- package/dist/src/artifacts/views/cli.js +1 -1
- package/dist/src/audit/operator-decision.js +10 -0
- package/dist/src/auth/credential-store.js +127 -27
- package/dist/src/catalog/builtin-models.json +272 -40
- package/dist/src/catalog/cli.js +1 -1
- package/dist/src/catalog/cli.mjs +1 -1
- package/dist/src/cli/handlers/effect.js +872 -0
- package/dist/src/cli/handlers/help.js +2 -0
- package/dist/src/cli/handlers/index.js +4 -2
- package/dist/src/cli/handlers/models.js +41 -10
- package/dist/src/cli/handlers/ralph.js +1 -1
- package/dist/src/cli/handlers/sessions.js +113 -37
- package/dist/src/cli/handlers/subcommands.js +31 -0
- package/dist/src/cli/handlers/use.js +46 -9
- package/dist/src/cli/handlers/utilities.js +10 -0
- package/dist/src/cli/handlers/workspace.js +3 -1
- package/dist/src/cli/scope-resolver.js +112 -9
- package/dist/src/cli/services/deployment-verification.js +5 -0
- package/dist/src/config/aiwg-config.js +3 -0
- package/dist/src/decision/acceptance-rollout.js +93 -0
- package/dist/src/decision/acceptance.js +175 -0
- package/dist/src/decision/adapters/jev.js +234 -13
- package/dist/src/decision/adapters/llm-subagent.js +60 -12
- package/dist/src/decision/admission.js +550 -0
- package/dist/src/decision/batch-benchmark.js +47 -0
- package/dist/src/decision/batch-receipts/accounting.js +75 -0
- package/dist/src/decision/batch-receipts/export.js +40 -0
- package/dist/src/decision/batch-receipts/index.js +9 -0
- package/dist/src/decision/batch-receipts/protection.js +40 -0
- package/dist/src/decision/batch-receipts/receipt.js +44 -0
- package/dist/src/decision/batch-receipts/result-store.js +341 -0
- package/dist/src/decision/batch-receipts/store.js +362 -0
- package/dist/src/decision/batch-receipts/types.js +2 -0
- package/dist/src/decision/batch-receipts/validate.js +127 -0
- package/dist/src/decision/batch.js +68 -0
- package/dist/src/decision/calibration/index.js +4 -0
- package/dist/src/decision/calibration/receipts.js +130 -0
- package/dist/src/decision/calibration/registry.js +258 -0
- package/dist/src/decision/calibration/types.js +2 -0
- package/dist/src/decision/compile-cache/benchmark.js +62 -0
- package/dist/src/decision/compile-cache/compatibility.js +65 -0
- package/dist/src/decision/compile-cache/file-store.js +280 -0
- package/dist/src/decision/compile-cache/identity.js +26 -0
- package/dist/src/decision/compile-cache/index.js +9 -0
- package/dist/src/decision/compile-cache/prefix.js +21 -0
- package/dist/src/decision/compile-cache/records.js +86 -0
- package/dist/src/decision/compile-cache/runtime.js +68 -0
- package/dist/src/decision/compile-cache/store.js +108 -0
- package/dist/src/decision/compile-cache/types.js +2 -0
- package/dist/src/decision/context-plan.js +234 -0
- package/dist/src/decision/context-qualification.js +60 -0
- package/dist/src/decision/ensemble/aggregate.js +215 -0
- package/dist/src/decision/ensemble/contract.js +316 -0
- package/dist/src/decision/ensemble/drift.js +51 -0
- package/dist/src/decision/ensemble/index.js +6 -0
- package/dist/src/decision/ensemble/report.js +71 -0
- package/dist/src/decision/ensemble/types.js +2 -0
- package/dist/src/decision/evaluate.js +1024 -78
- package/dist/src/decision/file-lifecycle-store.js +168 -0
- package/dist/src/decision/graph-beam.js +40 -0
- package/dist/src/decision/graph-budget.js +110 -0
- package/dist/src/decision/graph-decision-bridge.js +93 -0
- package/dist/src/decision/graph-evidence.js +123 -0
- package/dist/src/decision/graph-flow-adapter.js +56 -0
- package/dist/src/decision/graph-flow.js +105 -0
- package/dist/src/decision/graph-parallel.js +79 -0
- package/dist/src/decision/graph-patterns.js +20 -0
- package/dist/src/decision/graph-run-store.js +66 -0
- package/dist/src/decision/graph-run.js +166 -0
- package/dist/src/decision/graph-skill-bridge.js +91 -0
- package/dist/src/decision/graph-templates.js +56 -0
- package/dist/src/decision/graph.js +115 -0
- package/dist/src/decision/index.js +35 -0
- package/dist/src/decision/job-accounting.js +93 -0
- package/dist/src/decision/job-contract.js +186 -0
- package/dist/src/decision/job-effects.js +95 -0
- package/dist/src/decision/job-evaluate.js +80 -0
- package/dist/src/decision/job-gateway.js +121 -0
- package/dist/src/decision/job-http.js +194 -0
- package/dist/src/decision/job-payload-store.js +258 -0
- package/dist/src/decision/job-poll-limiter.js +92 -0
- package/dist/src/decision/job-quota.js +158 -0
- package/dist/src/decision/job-runtime.js +240 -0
- package/dist/src/decision/job-scheduler.js +26 -0
- package/dist/src/decision/job-service.js +117 -0
- package/dist/src/decision/job-store.js +264 -0
- package/dist/src/decision/job-worker.js +141 -0
- package/dist/src/decision/lifecycle.js +148 -0
- package/dist/src/decision/patterns/artifacts.js +173 -0
- package/dist/src/decision/patterns/catalog.js +108 -0
- package/dist/src/decision/patterns/durable-review.js +237 -0
- package/dist/src/decision/patterns/index.js +8 -0
- package/dist/src/decision/patterns/playground.js +401 -0
- package/dist/src/decision/patterns/recorded.js +43 -0
- package/dist/src/decision/patterns/specs.js +174 -0
- package/dist/src/decision/patterns/types.js +2 -0
- package/dist/src/decision/portable-secrets.js +53 -0
- package/dist/src/decision/projection.js +210 -0
- package/dist/src/decision/qualification/capture.js +67 -0
- package/dist/src/decision/qualification/drift.js +68 -0
- package/dist/src/decision/qualification/gate-evidence.js +172 -0
- package/dist/src/decision/qualification/gates.js +83 -0
- package/dist/src/decision/qualification/index.js +12 -0
- package/dist/src/decision/qualification/load.js +275 -0
- package/dist/src/decision/qualification/manifest.js +37 -0
- package/dist/src/decision/qualification/privacy.js +41 -0
- package/dist/src/decision/qualification/quality.js +213 -0
- package/dist/src/decision/qualification/release.js +116 -0
- package/dist/src/decision/qualification/runner.js +408 -0
- package/dist/src/decision/qualification/types.js +2 -0
- package/dist/src/decision/receipts.js +30 -7
- package/dist/src/decision/result-cache/index.js +6 -0
- package/dist/src/decision/result-cache/integrity.js +28 -0
- package/dist/src/decision/result-cache/key.js +28 -0
- package/dist/src/decision/result-cache/service.js +92 -0
- package/dist/src/decision/result-cache/store.js +327 -0
- package/dist/src/decision/result-cache/types.js +3 -0
- package/dist/src/decision/review/authorization.js +110 -0
- package/dist/src/decision/review/effect-ledger-adapter.js +321 -0
- package/dist/src/decision/review/index.js +12 -0
- package/dist/src/decision/review/migrate.js +34 -0
- package/dist/src/decision/review/operator-audit.js +59 -0
- package/dist/src/decision/review/recovery-adapters.js +252 -0
- package/dist/src/decision/review/recovery.js +33 -0
- package/dist/src/decision/review/service.js +498 -0
- package/dist/src/decision/review/store.js +219 -0
- package/dist/src/decision/review/types.js +2 -0
- package/dist/src/decision/review/validate.js +205 -0
- package/dist/src/decision/scheduler.js +115 -0
- package/dist/src/decision/telemetry/context.js +42 -0
- package/dist/src/decision/telemetry/debug-sidecar.js +152 -0
- package/dist/src/decision/telemetry/exporter.js +77 -0
- package/dist/src/decision/telemetry/file-debug-backend.js +112 -0
- package/dist/src/decision/telemetry/index.js +13 -0
- package/dist/src/decision/telemetry/mapping.js +146 -0
- package/dist/src/decision/telemetry/metrics.js +164 -0
- package/dist/src/decision/telemetry/otlp-http.js +183 -0
- package/dist/src/decision/telemetry/redaction.js +93 -0
- package/dist/src/decision/telemetry/retention.js +109 -0
- package/dist/src/decision/telemetry/runtime.js +310 -0
- package/dist/src/decision/telemetry/trace.js +158 -0
- package/dist/src/decision/telemetry/types.js +3 -0
- package/dist/src/decision/validate.js +135 -5
- package/dist/src/effects/checkpoint-sinks.js +94 -0
- package/dist/src/effects/checkpoint.js +76 -0
- package/dist/src/effects/decision-links.js +54 -0
- package/dist/src/effects/errors.js +48 -0
- package/dist/src/effects/identity.js +178 -0
- package/dist/src/effects/index.js +25 -0
- package/dist/src/effects/keyring.js +160 -0
- package/dist/src/effects/keys.js +130 -0
- package/dist/src/effects/ledger.js +435 -0
- package/dist/src/effects/lifecycle.js +110 -0
- package/dist/src/effects/lock-recovery.js +269 -0
- package/dist/src/effects/reader.js +103 -0
- package/dist/src/effects/records.js +114 -0
- package/dist/src/effects/redaction.js +51 -0
- package/dist/src/effects/schema.js +73 -0
- package/dist/src/effects/store.js +239 -0
- package/dist/src/effects/types.js +49 -0
- package/dist/src/effects/verifiers/decision.js +168 -0
- package/dist/src/effects/verifiers/file.js +106 -0
- package/dist/src/effects/verifiers/git.js +206 -0
- package/dist/src/effects/verifiers/index.js +90 -0
- package/dist/src/effects/verifiers/registry.js +148 -0
- package/dist/src/effects/verifiers/review.js +123 -0
- package/dist/src/effects/verifiers/tracker.js +299 -0
- package/dist/src/effects/verifiers/types.js +21 -0
- package/dist/src/effects/verify.js +295 -0
- package/dist/src/evidence/bundle.js +2 -0
- package/dist/src/extensions/commands/definitions.js +28 -0
- package/dist/src/extensions/manifest.js +1 -0
- package/dist/src/marketplace/provenance.js +25 -27
- package/dist/src/mcp/cli.mjs +11 -1
- package/dist/src/mcp/grok-build-config.mjs +189 -0
- package/dist/src/mcp/registry.js +21 -1
- package/dist/src/mcp/registry.mjs +20 -1
- package/dist/src/models/grok-build-models.js +356 -0
- package/dist/src/models/index.js +1 -0
- package/dist/src/models/model-capabilities.v1.json +34 -0
- package/dist/src/models/model-catalog.v1.json +55 -13
- package/dist/src/models/model-discovery.js +41 -5
- package/dist/src/models/provider-policy.js +3 -2
- package/dist/src/plugin/skill-command-translator.js +1 -0
- package/dist/src/providers/capability-matrix.yaml +58 -6
- package/dist/src/providers/grok-build-acp.js +192 -0
- package/dist/src/providers/grok-build-dispatch.js +251 -0
- package/dist/src/providers/grok-build-headless.js +170 -0
- package/dist/src/providers/muse-paths.js +105 -0
- package/dist/src/providers/provider-definitions.js +93 -7
- package/dist/src/providers/provider-definitions.mjs +21 -1
- package/dist/src/providers/provider-inventory.js +3 -0
- package/dist/src/security/artifact-attestation.js +4 -8
- package/dist/src/security/signing.js +119 -0
- package/dist/src/sessions/adapters/grok-build.js +187 -0
- package/dist/src/sessions/adapters/muse.js +412 -0
- package/dist/src/sessions/contracts.js +1 -1
- package/dist/src/sessions/grok-build-cli.js +196 -0
- package/dist/src/sessions/index.js +3 -0
- package/dist/src/sessions/workspace-discovery.js +9 -5
- package/dist/src/skills/deployer.js +33 -5
- package/dist/src/smiths/context-pipeline/claude-hook.js +2 -0
- package/dist/src/smiths/context-pipeline/context-file-safety.js +18 -0
- package/dist/src/smiths/context-pipeline/finalization.js +1 -32
- package/dist/src/smiths/context-pipeline/generator.js +12 -0
- package/dist/src/smiths/context-pipeline/index.js +1 -1
- package/dist/src/smiths/context-pipeline/managed-hook.js +2 -0
- package/dist/src/smiths/context-pipeline/muse-bridge.js +50 -0
- package/dist/src/smiths/context-pipeline/provider-policy.js +5 -0
- package/dist/src/smiths/context-pipeline/workspace-context.js +22 -16
- package/dist/src/smiths/hook-bridge/grok-build-translator.js +64 -0
- package/dist/src/smiths/hook-bridge/index.js +3 -0
- package/dist/src/smiths/hook-bridge/shim.js +2 -0
- package/dist/src/smiths/hook-bridge/types.js +5 -0
- package/dist/src/smiths/skillsmith/collision-detector.js +1 -0
- package/dist/src/storage/protected-files.js +102 -0
- package/dist/src/tracker/forge-http.js +371 -0
- package/dist/src/tracker/project-inputs.js +41 -0
- package/package.json +2 -1
- package/schemas/decision/BatchReceipt.v1.schema.json +50 -0
- package/schemas/decision/CalibrationArtifact.v1.schema.json +76 -0
- package/schemas/decision/CalibrationCompatibility.v1.schema.json +18 -0
- package/schemas/decision/CalibrationGovernanceReceipt.v1.schema.json +44 -0
- package/schemas/decision/DecisionAdmissionEvidence.v1.schema.json +43 -0
- package/schemas/decision/DecisionBinding.schema.json +309 -0
- package/schemas/decision/DecisionBinding.v1alpha2.schema.json +369 -0
- package/schemas/decision/DecisionCacheTelemetry.v1.schema.json +20 -0
- package/schemas/decision/DecisionChampionChallenger.v1.schema.json +100 -0
- package/schemas/decision/DecisionCompileCacheEntry.v1.schema.json +35 -0
- package/schemas/decision/DecisionCompileCacheIdentity.v1.schema.json +26 -0
- package/schemas/decision/DecisionCompileCacheTombstone.v1.schema.json +31 -0
- package/schemas/decision/DecisionContextEvidence.v1.schema.json +136 -0
- package/schemas/decision/DecisionDefinition.schema.json +159 -0
- package/schemas/decision/DecisionDefinition.v1alpha2.schema.json +203 -0
- package/schemas/decision/DecisionDriftResponse.v1.schema.json +55 -0
- package/schemas/decision/DecisionEnsembleAggregate.v1.schema.json +86 -0
- package/schemas/decision/DecisionEnsembleIntegrityReport.v1.schema.json +64 -0
- package/schemas/decision/DecisionEnsemblePolicy.v1.schema.json +130 -0
- package/schemas/decision/DecisionGraph.v1.schema.json +77 -0
- package/schemas/decision/DecisionJob.v1.schema.json +71 -0
- package/schemas/decision/DecisionLifecyclePolicy.v1.schema.json +52 -0
- package/schemas/decision/DecisionProjectionPolicy.v1.schema.json +65 -0
- package/schemas/decision/DecisionProviderPrefixCompatibility.v1.schema.json +45 -0
- package/schemas/decision/DecisionProviderPrefixEvidence.v1.schema.json +17 -0
- package/schemas/decision/DecisionProviderPrefixIdentity.v1.schema.json +36 -0
- package/schemas/decision/DecisionResult.schema.json +524 -0
- package/schemas/decision/DecisionResult.v1alpha2.schema.json +619 -0
- package/schemas/decision/DecisionResultCacheEntry.v1.schema.json +53 -0
- package/schemas/decision/DecisionReview.schema.json +29 -0
- package/schemas/decision/DecisionRuleset.schema.json +313 -0
- package/schemas/decision/DecisionRuleset.v1alpha2.schema.json +313 -0
- package/schemas/decision/DecisionTelemetryTrace.v1.schema.json +95 -0
- package/schemas/decision/RulesetResult.schema.json +721 -0
- package/schemas/decision/RulesetResult.v1alpha2.schema.json +891 -0
- package/schemas/security/pr-evidence-receipt.v1.schema.json +77 -0
- package/schemas/security/threat-assessment-report.v1.schema.json +71 -1
- package/tools/agents/deploy-agents.mjs +21 -6
- package/tools/agents/providers/base.mjs +7 -4
- package/tools/agents/providers/codex.mjs +1 -1
- package/tools/agents/providers/grok-build.d.mts +7 -0
- package/tools/agents/providers/grok-build.mjs +249 -9
- package/tools/agents/providers/model-role.mjs +6 -3
- package/tools/agents/providers/muse-hooks.mjs +785 -0
- package/tools/agents/providers/muse-paths.mjs +112 -0
- package/tools/agents/providers/muse.mjs +336 -0
|
@@ -0,0 +1,550 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { canonicalJson } from '../security/artifact-trust.js';
|
|
3
|
+
export class AdmissionError extends Error {
|
|
4
|
+
evidence;
|
|
5
|
+
retryable;
|
|
6
|
+
constructor(evidence, retryable) {
|
|
7
|
+
super(`decision admission ${evidence.decision}: ${evidence.reason}`);
|
|
8
|
+
this.evidence = evidence;
|
|
9
|
+
this.retryable = retryable;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
const MAX_BREAKER_HISTORY = 64;
|
|
13
|
+
const shareCap = (share, capacity) => Math.max(1, Math.floor(share * capacity));
|
|
14
|
+
/** Shared, in-process controller. Hosts may share one instance across invocations. */
|
|
15
|
+
export class DecisionAdmissionController {
|
|
16
|
+
limits;
|
|
17
|
+
now;
|
|
18
|
+
options;
|
|
19
|
+
active = 0;
|
|
20
|
+
activePrincipal = new Map();
|
|
21
|
+
activeWorkspace = new Map();
|
|
22
|
+
activeProvider = new Map();
|
|
23
|
+
activePrincipalProvider = new Map();
|
|
24
|
+
seenReservations = new Map();
|
|
25
|
+
/** Oldest waiter deferred on each token bucket; later work cannot drain its share. */
|
|
26
|
+
tokenReservations = new Map();
|
|
27
|
+
breakerHistory = [];
|
|
28
|
+
requestBuckets = new Map();
|
|
29
|
+
tokenBuckets = new Map();
|
|
30
|
+
costSpent = new Map();
|
|
31
|
+
tokensReserved = new Map();
|
|
32
|
+
attempts = new Map();
|
|
33
|
+
breakers = new Map();
|
|
34
|
+
providerPausedUntil = new Map();
|
|
35
|
+
queues = new Map();
|
|
36
|
+
laneCursor = 0;
|
|
37
|
+
retryPressure = 0;
|
|
38
|
+
timer;
|
|
39
|
+
constructor(
|
|
40
|
+
/** Returns null when the trusted profile has no limits for the request's provider. */
|
|
41
|
+
limits, now = Date.now, options = {}) {
|
|
42
|
+
this.limits = limits;
|
|
43
|
+
this.now = now;
|
|
44
|
+
this.options = options;
|
|
45
|
+
}
|
|
46
|
+
/** Aggregate counts only; suitable for load harness sampling. */
|
|
47
|
+
snapshot() {
|
|
48
|
+
return { active: this.active, queued: this.queuedCount() };
|
|
49
|
+
}
|
|
50
|
+
/** Bounded, metadata-only breaker transition history, oldest first. */
|
|
51
|
+
breakerTransitions() {
|
|
52
|
+
return structuredClone(this.breakerHistory);
|
|
53
|
+
}
|
|
54
|
+
/** True when no lease is active and no request is queued, optionally for one principal. */
|
|
55
|
+
idle(principalId) {
|
|
56
|
+
if (principalId === undefined)
|
|
57
|
+
return this.active === 0 && this.queuedCount() === 0;
|
|
58
|
+
return !this.activePrincipal.get(principalId) && this.queuedCount(request => request.principalId === principalId) === 0;
|
|
59
|
+
}
|
|
60
|
+
async acquire(request) {
|
|
61
|
+
const checked = this.preflight(request);
|
|
62
|
+
if (checked)
|
|
63
|
+
throw checked;
|
|
64
|
+
const lane = `${request.providerId}\u0000${request.workspaceId}\u0000${request.principalId}`;
|
|
65
|
+
const limits = this.limits(request);
|
|
66
|
+
const queueCounts = [this.queuedCount(candidate => candidate.principalId === request.principalId),
|
|
67
|
+
this.queuedCount(candidate => candidate.workspaceId === request.workspaceId),
|
|
68
|
+
this.queuedCount(candidate => candidate.providerId === request.providerId)];
|
|
69
|
+
const queueLimits = [limits.principal.maxQueueLength, limits.workspace.maxQueueLength, limits.provider.maxQueueLength];
|
|
70
|
+
if (queueLimits.some((limit, index) => limit !== undefined && queueCounts[index] >= limit)) {
|
|
71
|
+
throw this.error('queue-full', 'reject', request, true, this.jitterHint(250));
|
|
72
|
+
}
|
|
73
|
+
// A principal's share of a shared queue is capped so one noisy scope cannot
|
|
74
|
+
// fill it and turn other principals' requests into queue-full rejections.
|
|
75
|
+
const ownQueued = [
|
|
76
|
+
this.queuedCount(candidate => candidate.principalId === request.principalId && candidate.workspaceId === request.workspaceId),
|
|
77
|
+
this.queuedCount(candidate => candidate.principalId === request.principalId && candidate.providerId === request.providerId),
|
|
78
|
+
];
|
|
79
|
+
if ([limits.workspace, limits.provider].some((limit, index) => limit.maxPrincipalShare !== undefined
|
|
80
|
+
&& limit.maxQueueLength !== undefined && ownQueued[index] >= shareCap(limit.maxPrincipalShare, limit.maxQueueLength))) {
|
|
81
|
+
throw this.error('queue-full', 'reject', request, true, this.jitterHint(250));
|
|
82
|
+
}
|
|
83
|
+
if (!this.options.principals && limits.principal.reservedConcurrency)
|
|
84
|
+
this.seenReservations.set(request.principalId, limits.principal);
|
|
85
|
+
return await new Promise((resolve, reject) => {
|
|
86
|
+
const queue = this.queues.get(lane) ?? [];
|
|
87
|
+
const cancelled = () => { this.remove(request, 'cancelled'); };
|
|
88
|
+
request.signal.addEventListener('abort', cancelled, { once: true });
|
|
89
|
+
queue.push({ request, resolve, reject, enqueuedAt: this.now(),
|
|
90
|
+
cleanup: () => {
|
|
91
|
+
request.signal.removeEventListener('abort', cancelled);
|
|
92
|
+
for (const [key, holder] of this.tokenReservations)
|
|
93
|
+
if (holder.request === request)
|
|
94
|
+
this.tokenReservations.delete(key);
|
|
95
|
+
} });
|
|
96
|
+
this.queues.set(lane, queue);
|
|
97
|
+
this.pump();
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
coordinateRetryAfter(providerId, retryAfterMs) {
|
|
101
|
+
this.providerPausedUntil.set(providerId, Math.max(this.providerPausedUntil.get(providerId) ?? 0, this.now() + Math.max(0, retryAfterMs)));
|
|
102
|
+
this.retryPressure += 1;
|
|
103
|
+
this.schedulePump(Math.max(1, retryAfterMs));
|
|
104
|
+
}
|
|
105
|
+
/** Release invocation-scoped counters once every active lease has settled. */
|
|
106
|
+
releaseBudget(budgetId) {
|
|
107
|
+
const prefix = `${budgetId}:`;
|
|
108
|
+
for (const key of this.attempts.keys())
|
|
109
|
+
if (key.startsWith(prefix))
|
|
110
|
+
this.attempts.delete(key);
|
|
111
|
+
for (const key of this.costSpent.keys())
|
|
112
|
+
if (key.startsWith(prefix))
|
|
113
|
+
this.costSpent.delete(key);
|
|
114
|
+
for (const key of this.tokensReserved.keys())
|
|
115
|
+
if (key.startsWith(prefix))
|
|
116
|
+
this.tokensReserved.delete(key);
|
|
117
|
+
}
|
|
118
|
+
preflight(request) {
|
|
119
|
+
const scoped = this.limits(request);
|
|
120
|
+
const estimate = request.estimate;
|
|
121
|
+
if (request.signal.aborted)
|
|
122
|
+
return this.error('cancelled', 'reject', request, false);
|
|
123
|
+
if (!scoped)
|
|
124
|
+
return this.error('unconfigured-provider', 'reject', request, false);
|
|
125
|
+
const all = Object.values(scoped);
|
|
126
|
+
if (this.now() >= request.deadlineEpochMs)
|
|
127
|
+
return this.error('deadline-exceeded', 'reject', request, false);
|
|
128
|
+
for (const count of [estimate.tokens, estimate.requestBytes, estimate.items, estimate.attempts, estimate.batchSize, estimate.retainedWork]) {
|
|
129
|
+
if (count !== undefined && (!Number.isSafeInteger(count) || count < 0)) {
|
|
130
|
+
return this.error('invalid-estimate', 'reject', request, false);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
if (estimate.costUsd !== undefined && estimate.costUsd !== null
|
|
134
|
+
&& (!Number.isFinite(estimate.costUsd) || estimate.costUsd < 0)) {
|
|
135
|
+
return this.error('invalid-estimate', 'reject', request, false);
|
|
136
|
+
}
|
|
137
|
+
for (const limit of all) {
|
|
138
|
+
if (estimate.batchSize !== undefined && limit.maxBatchSize !== undefined && estimate.batchSize > limit.maxBatchSize)
|
|
139
|
+
return this.error('batch-size', 'reject', request, false);
|
|
140
|
+
if (estimate.requestBytes !== undefined && limit.maxRequestBytes !== undefined && estimate.requestBytes > limit.maxRequestBytes)
|
|
141
|
+
return this.error('request-too-large', 'reject', request, false);
|
|
142
|
+
if (estimate.items !== undefined && limit.maxItems !== undefined && estimate.items > limit.maxItems)
|
|
143
|
+
return this.error('too-many-items', 'reject', request, false);
|
|
144
|
+
if (limit.maxRetainedWork !== undefined && estimate.retainedWork === undefined)
|
|
145
|
+
return this.error('unknown-retained-work', 'reject', request, false);
|
|
146
|
+
if (estimate.retainedWork !== undefined && limit.maxRetainedWork !== undefined && estimate.retainedWork > limit.maxRetainedWork)
|
|
147
|
+
return this.error('retained-work', 'reject', request, false);
|
|
148
|
+
if (estimate.costUsd == null && limit.maxCostUsd !== undefined && !limit.allowUnknownCost)
|
|
149
|
+
return this.error('unknown-cost', 'reject', request, false);
|
|
150
|
+
if (estimate.tokens === undefined && limit.maxTokens !== undefined)
|
|
151
|
+
return this.error('unknown-tokens', 'reject', request, false);
|
|
152
|
+
}
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
pump() {
|
|
156
|
+
if (this.timer) {
|
|
157
|
+
clearTimeout(this.timer);
|
|
158
|
+
this.timer = undefined;
|
|
159
|
+
}
|
|
160
|
+
const lanes = [...this.queues.keys()].filter(lane => this.queues.get(lane)?.length);
|
|
161
|
+
if (!lanes.length)
|
|
162
|
+
return;
|
|
163
|
+
let nearest = Number.POSITIVE_INFINITY;
|
|
164
|
+
let progressed = true;
|
|
165
|
+
while (progressed) {
|
|
166
|
+
progressed = false;
|
|
167
|
+
for (let offset = 0; offset < lanes.length; offset += 1) {
|
|
168
|
+
const position = (this.laneCursor + offset) % lanes.length;
|
|
169
|
+
const queue = this.queues.get(lanes[position]);
|
|
170
|
+
const waiter = queue[0];
|
|
171
|
+
if (!waiter)
|
|
172
|
+
continue;
|
|
173
|
+
const waitLimit = Math.min(...Object.values(this.limits(waiter.request) ?? {}).map(limit => limit.maxQueueWaitMs ?? Number.POSITIVE_INFINITY));
|
|
174
|
+
if (waiter.request.signal.aborted || this.now() >= waiter.request.deadlineEpochMs || this.now() - waiter.enqueuedAt >= waitLimit) {
|
|
175
|
+
queue.shift();
|
|
176
|
+
waiter.cleanup();
|
|
177
|
+
waiter.reject(this.error(waiter.request.signal.aborted ? 'cancelled' : this.now() >= waiter.request.deadlineEpochMs ? 'deadline-exceeded' : 'queue-timeout', 'reject', waiter.request, false));
|
|
178
|
+
progressed = true;
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
// A profile may tighten while a client is queued. Recheck non-concurrency
|
|
182
|
+
// limits at dispatch, not only when the waiter first enters the queue.
|
|
183
|
+
const admitted = this.preflight(waiter.request) ?? this.tryAdmit(waiter);
|
|
184
|
+
if (admitted instanceof AdmissionError) {
|
|
185
|
+
if (!admitted.retryable) {
|
|
186
|
+
queue.shift();
|
|
187
|
+
waiter.cleanup();
|
|
188
|
+
waiter.reject(admitted);
|
|
189
|
+
progressed = true;
|
|
190
|
+
}
|
|
191
|
+
else
|
|
192
|
+
nearest = Math.min(nearest, admitted.evidence.retryAfterMs ?? 25, Math.max(1, waiter.request.deadlineEpochMs - this.now()), Math.max(1, waitLimit - (this.now() - waiter.enqueuedAt)));
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
queue.shift();
|
|
196
|
+
waiter.cleanup();
|
|
197
|
+
this.laneCursor = (position + 1) % lanes.length;
|
|
198
|
+
waiter.resolve(admitted);
|
|
199
|
+
progressed = true;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
for (const [lane, queue] of this.queues)
|
|
203
|
+
if (!queue.length)
|
|
204
|
+
this.queues.delete(lane);
|
|
205
|
+
if (this.queuedCount())
|
|
206
|
+
this.schedulePump(Number.isFinite(nearest) ? nearest : 25);
|
|
207
|
+
}
|
|
208
|
+
tryAdmit(waiter) {
|
|
209
|
+
const request = waiter.request;
|
|
210
|
+
const { principal, workspace, provider } = this.limits(request);
|
|
211
|
+
const limits = [principal, workspace, provider];
|
|
212
|
+
const counts = [this.activePrincipal.get(request.principalId) ?? 0, this.activeWorkspace.get(request.workspaceId) ?? 0, this.activeProvider.get(request.providerId) ?? 0];
|
|
213
|
+
if (limits.some((limit, index) => counts[index] >= limit.concurrency))
|
|
214
|
+
return this.error('concurrency', 'defer', request, true, 10);
|
|
215
|
+
const ownProvider = this.activePrincipalProvider.get(`${request.providerId}\u0000${request.principalId}`) ?? 0;
|
|
216
|
+
if ((workspace.maxPrincipalShare !== undefined && counts[0] >= shareCap(workspace.maxPrincipalShare, workspace.concurrency))
|
|
217
|
+
|| (provider.maxPrincipalShare !== undefined && ownProvider >= shareCap(provider.maxPrincipalShare, provider.concurrency))
|
|
218
|
+
|| !this.withinReservations(request, principal, workspace.concurrency, counts[1], counts[0], id => this.activePrincipal.get(id) ?? 0)
|
|
219
|
+
|| !this.withinReservations(request, principal, provider.concurrency, counts[2], ownProvider, id => this.activePrincipalProvider.get(`${request.providerId}\u0000${id}`) ?? 0)) {
|
|
220
|
+
return this.error('concurrency', 'defer', request, true, 10);
|
|
221
|
+
}
|
|
222
|
+
const breaker = this.breaker(request.providerId);
|
|
223
|
+
const breakerConfig = provider.circuitBreaker;
|
|
224
|
+
const transitions = [];
|
|
225
|
+
const pausedUntil = this.providerPausedUntil.get(request.providerId) ?? 0;
|
|
226
|
+
if (this.now() < pausedUntil)
|
|
227
|
+
return this.error('retry-after', 'defer', request, true, pausedUntil - this.now());
|
|
228
|
+
if (breaker.state === 'open') {
|
|
229
|
+
const readyAt = breaker.openedAt + (breakerConfig?.openMs ?? 30_000);
|
|
230
|
+
if (this.now() < readyAt)
|
|
231
|
+
return this.error('circuit-open', 'defer', request, true, readyAt - this.now());
|
|
232
|
+
this.transition(request.providerId, breaker, 'half-open', transitions);
|
|
233
|
+
breaker.halfOpenActive = 0;
|
|
234
|
+
}
|
|
235
|
+
if (breaker.state === 'half-open' && breaker.halfOpenActive >= (breakerConfig?.halfOpenMaxCalls ?? 1))
|
|
236
|
+
return this.error('circuit-open', 'defer', request, true, 25);
|
|
237
|
+
const keys = [[`p:${request.principalId}`, principal], [`w:${request.workspaceId}`, workspace], [`v:${request.providerId}`, provider]];
|
|
238
|
+
for (const [key, limit] of keys) {
|
|
239
|
+
const budgetKey = `${request.budgetId}:${key}`;
|
|
240
|
+
if (limit.maxAttempts !== undefined && (this.attempts.get(budgetKey) ?? 0) + (request.estimate.attempts ?? 1) > limit.maxAttempts)
|
|
241
|
+
return this.error('attempts', 'reject', request, false);
|
|
242
|
+
if (limit.maxCostUsd !== undefined && request.estimate.costUsd != null && (this.costSpent.get(budgetKey) ?? 0) + request.estimate.costUsd > limit.maxCostUsd)
|
|
243
|
+
return this.error('cost', 'reject', request, false);
|
|
244
|
+
if (limit.maxTokens !== undefined && (this.tokensReserved.get(budgetKey) ?? 0) + (request.estimate.tokens ?? 0) > limit.maxTokens)
|
|
245
|
+
return this.error('tokens', 'reject', request, false);
|
|
246
|
+
// A bucket can never refill beyond its capacity. Fail permanently rather
|
|
247
|
+
// than retaining an impossible waiter until its deadline or queue timeout.
|
|
248
|
+
if (limit.requestsPerMinute === 0)
|
|
249
|
+
return this.error('requests-per-minute', 'reject', request, false);
|
|
250
|
+
if (limit.tokensPerSecond !== undefined && (request.estimate.tokens ?? 0) > limit.tokensPerSecond)
|
|
251
|
+
return this.error('tokens-per-second', 'reject', request, false);
|
|
252
|
+
if (limit.requestsPerMinute !== undefined && !this.canConsume(this.requestBuckets, key, limit.requestsPerMinute, limit.requestsPerMinute / 60_000, 1))
|
|
253
|
+
return this.error('requests-per-minute', 'defer', request, true, 100);
|
|
254
|
+
if (limit.tokensPerSecond !== undefined) {
|
|
255
|
+
const holder = this.tokenReservations.get(key);
|
|
256
|
+
const reserved = holder && holder !== waiter ? holder.request.estimate.tokens ?? 0 : 0;
|
|
257
|
+
if (!this.canConsume(this.tokenBuckets, key, limit.tokensPerSecond, limit.tokensPerSecond / 1000, (request.estimate.tokens ?? 0) + reserved)) {
|
|
258
|
+
if (!holder)
|
|
259
|
+
this.tokenReservations.set(key, waiter);
|
|
260
|
+
return this.error('tokens-per-second', 'defer', request, true, 100);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
keys.forEach(([key, limit]) => {
|
|
265
|
+
this.consume(this.requestBuckets, key, limit.requestsPerMinute, limit.requestsPerMinute === undefined ? 0 : limit.requestsPerMinute / 60_000, 1);
|
|
266
|
+
this.consume(this.tokenBuckets, key, limit.tokensPerSecond, limit.tokensPerSecond === undefined ? 0 : limit.tokensPerSecond / 1000, request.estimate.tokens ?? 0);
|
|
267
|
+
const budgetKey = `${request.budgetId}:${key}`;
|
|
268
|
+
this.attempts.set(budgetKey, (this.attempts.get(budgetKey) ?? 0) + (request.estimate.attempts ?? 1));
|
|
269
|
+
if (request.estimate.costUsd != null)
|
|
270
|
+
this.costSpent.set(budgetKey, (this.costSpent.get(budgetKey) ?? 0) + request.estimate.costUsd);
|
|
271
|
+
if (request.estimate.tokens !== undefined)
|
|
272
|
+
this.tokensReserved.set(budgetKey, (this.tokensReserved.get(budgetKey) ?? 0) + request.estimate.tokens);
|
|
273
|
+
});
|
|
274
|
+
this.active += 1;
|
|
275
|
+
this.increment(this.activePrincipal, request.principalId);
|
|
276
|
+
this.increment(this.activeWorkspace, request.workspaceId);
|
|
277
|
+
this.increment(this.activeProvider, request.providerId);
|
|
278
|
+
this.increment(this.activePrincipalProvider, `${request.providerId}\u0000${request.principalId}`);
|
|
279
|
+
for (const [key, holder] of this.tokenReservations)
|
|
280
|
+
if (holder === waiter)
|
|
281
|
+
this.tokenReservations.delete(key);
|
|
282
|
+
if (breaker.state === 'half-open')
|
|
283
|
+
breaker.halfOpenActive += 1;
|
|
284
|
+
let released = false;
|
|
285
|
+
const evidence = this.evidence('admitted', 'admit', request, this.now() - waiter.enqueuedAt);
|
|
286
|
+
if (transitions.length)
|
|
287
|
+
evidence.breakerTransitions = transitions;
|
|
288
|
+
return { evidence, release: outcome => {
|
|
289
|
+
if (released)
|
|
290
|
+
return;
|
|
291
|
+
released = true;
|
|
292
|
+
this.active -= 1;
|
|
293
|
+
this.decrement(this.activePrincipal, request.principalId);
|
|
294
|
+
this.decrement(this.activeWorkspace, request.workspaceId);
|
|
295
|
+
this.decrement(this.activeProvider, request.providerId);
|
|
296
|
+
this.decrement(this.activePrincipalProvider, `${request.providerId}\u0000${request.principalId}`);
|
|
297
|
+
if (breaker.state === 'half-open')
|
|
298
|
+
breaker.halfOpenActive = Math.max(0, breaker.halfOpenActive - 1);
|
|
299
|
+
// Transitions after release are appended to this lease's evidence so the
|
|
300
|
+
// attempt that caused them carries them into receipts and telemetry.
|
|
301
|
+
const after = evidence.breakerTransitions ?? [];
|
|
302
|
+
if (outcome?.retryAfterMs !== undefined)
|
|
303
|
+
this.coordinateRetryAfter(request.providerId, outcome.retryAfterMs);
|
|
304
|
+
else if (outcome?.success) {
|
|
305
|
+
breaker.failures = 0;
|
|
306
|
+
if (breaker.state !== 'closed')
|
|
307
|
+
this.transition(request.providerId, breaker, 'closed', after);
|
|
308
|
+
}
|
|
309
|
+
else if (outcome && breakerConfig && ++breaker.failures >= breakerConfig.failureThreshold && breaker.state !== 'open') {
|
|
310
|
+
breaker.openedAt = this.now();
|
|
311
|
+
this.transition(request.providerId, breaker, 'open', after);
|
|
312
|
+
}
|
|
313
|
+
if (after.length)
|
|
314
|
+
evidence.breakerTransitions = after;
|
|
315
|
+
this.pump();
|
|
316
|
+
} };
|
|
317
|
+
}
|
|
318
|
+
transition(providerId, breaker, to, into) {
|
|
319
|
+
const change = { from: breaker.state, to };
|
|
320
|
+
breaker.state = to;
|
|
321
|
+
into.push(change);
|
|
322
|
+
const record = { providerId, ...change, atEpochMs: this.now(), failures: breaker.failures };
|
|
323
|
+
this.breakerHistory.push(record);
|
|
324
|
+
if (this.breakerHistory.length > MAX_BREAKER_HISTORY)
|
|
325
|
+
this.breakerHistory.shift();
|
|
326
|
+
try {
|
|
327
|
+
this.options.onBreakerTransition?.(structuredClone(record));
|
|
328
|
+
}
|
|
329
|
+
catch { /* observers cannot change admission */ }
|
|
330
|
+
}
|
|
331
|
+
/**
|
|
332
|
+
* Other principals' unused `reservedConcurrency` is held back from a shared
|
|
333
|
+
* pool. A principal still inside its own reservation needs one free permit.
|
|
334
|
+
*/
|
|
335
|
+
withinReservations(request, principal, capacity, active, ownActive, activeOf) {
|
|
336
|
+
const free = capacity - active;
|
|
337
|
+
if (ownActive < Math.min(principal.reservedConcurrency ?? 0, capacity))
|
|
338
|
+
return free >= 1;
|
|
339
|
+
let held = 0;
|
|
340
|
+
for (const [principalId, limits] of this.options.principals?.() ?? this.seenReservations) {
|
|
341
|
+
if (principalId === request.principalId || !limits.reservedConcurrency)
|
|
342
|
+
continue;
|
|
343
|
+
held += Math.max(0, Math.min(limits.reservedConcurrency, capacity) - activeOf(principalId));
|
|
344
|
+
}
|
|
345
|
+
return free - held >= 1;
|
|
346
|
+
}
|
|
347
|
+
breaker(provider) { let value = this.breakers.get(provider); if (!value) {
|
|
348
|
+
value = { state: 'closed', failures: 0, openedAt: 0, halfOpenActive: 0 };
|
|
349
|
+
this.breakers.set(provider, value);
|
|
350
|
+
} return value; }
|
|
351
|
+
queuedCount(predicate = () => true) {
|
|
352
|
+
return [...this.queues.values()].reduce((sum, queue) => sum + queue.filter(waiter => predicate(waiter.request)).length, 0);
|
|
353
|
+
}
|
|
354
|
+
increment(map, key) { map.set(key, (map.get(key) ?? 0) + 1); }
|
|
355
|
+
decrement(map, key) { const next = (map.get(key) ?? 1) - 1; if (next)
|
|
356
|
+
map.set(key, next);
|
|
357
|
+
else
|
|
358
|
+
map.delete(key); }
|
|
359
|
+
canConsume(map, key, capacity, rate, amount) { const bucket = this.refill(map, key, capacity, rate); return bucket.value >= amount; }
|
|
360
|
+
consume(map, key, capacity, rate, amount) { if (capacity === undefined)
|
|
361
|
+
return; const bucket = this.refill(map, key, capacity, rate); bucket.value -= amount; }
|
|
362
|
+
refill(map, key, capacity, rate) { const at = this.now(); let bucket = map.get(key); if (!bucket) {
|
|
363
|
+
bucket = { value: capacity, updatedAt: at };
|
|
364
|
+
map.set(key, bucket);
|
|
365
|
+
}
|
|
366
|
+
else {
|
|
367
|
+
bucket.value = Math.min(capacity, bucket.value + Math.max(0, at - bucket.updatedAt) * rate);
|
|
368
|
+
bucket.updatedAt = at;
|
|
369
|
+
} return bucket; }
|
|
370
|
+
remove(request, reason) { for (const queue of this.queues.values()) {
|
|
371
|
+
const index = queue.findIndex(waiter => waiter.request === request);
|
|
372
|
+
if (index >= 0) {
|
|
373
|
+
const [waiter] = queue.splice(index, 1);
|
|
374
|
+
waiter.cleanup();
|
|
375
|
+
waiter.reject(this.error(reason, 'reject', request, false));
|
|
376
|
+
break;
|
|
377
|
+
}
|
|
378
|
+
} this.pump(); }
|
|
379
|
+
evidence(reason, decision, request, queueDelayMs = 0, retryAfterMs) { const breaker = this.breaker(request.providerId); return { decision, reason, queueDelayMs: Math.max(0, queueDelayMs), active: this.activePrincipal.get(request.principalId) ?? 0, queued: Math.max(0, this.queuedCount(candidate => candidate.principalId === request.principalId) - ([...this.queues.values()].some(queue => queue.some(waiter => waiter.request === request)) ? 1 : 0)), estimatedTokens: Number.isSafeInteger(request.estimate.tokens) && request.estimate.tokens >= 0 ? request.estimate.tokens : null, estimatedCostUsd: request.estimate.costUsd != null && Number.isFinite(request.estimate.costUsd) && request.estimate.costUsd >= 0 ? request.estimate.costUsd : null, retryPressure: this.retryPressure, breakerState: breaker.state, ...(retryAfterMs === undefined ? {} : { retryAfterMs: this.jitterHint(retryAfterMs) }) }; }
|
|
380
|
+
error(reason, decision, request, retryable, retryAfterMs) { return new AdmissionError(this.evidence(reason, decision, request, 0, retryAfterMs), retryable); }
|
|
381
|
+
jitterHint(value) { return Math.max(1, Math.min(30_000, Math.round(value * 0.875))); }
|
|
382
|
+
schedulePump(delay) { if (this.timer)
|
|
383
|
+
return; this.timer = setTimeout(() => { this.timer = undefined; this.pump(); }, Math.max(1, Math.min(delay, 30_000))); }
|
|
384
|
+
}
|
|
385
|
+
/** A scheduler profile the registry refuses; the evaluator reports it as an invalid definition. */
|
|
386
|
+
export class AdmissionProfileError extends Error {
|
|
387
|
+
}
|
|
388
|
+
export class AdmissionProfileConflictError extends AdmissionProfileError {
|
|
389
|
+
constructor() { super('scheduler profile revision conflicts with the registered revision'); }
|
|
390
|
+
}
|
|
391
|
+
const MAX_TRACKED_REVISIONS = 4096;
|
|
392
|
+
const MAX_TRACKED_PRINCIPALS = 4096;
|
|
393
|
+
const MAX_PROFILE_HISTORY = 64;
|
|
394
|
+
/**
|
|
395
|
+
* Trusted-scope controller registry. Admission state is keyed by the host's
|
|
396
|
+
* workspace ID, not by the identity of the policy object, so structurally equal
|
|
397
|
+
* policies built per request share principal, workspace, and provider ceilings.
|
|
398
|
+
*
|
|
399
|
+
* The most recently registered `profileVersion` supplies the current limits for
|
|
400
|
+
* a workspace (queued work is revalidated against it). Registering different
|
|
401
|
+
* limits under a revision already seen for the same scope fails closed: a
|
|
402
|
+
* changed profile must carry a new `profileVersion`. Every change of revision
|
|
403
|
+
* produces an audit record.
|
|
404
|
+
*/
|
|
405
|
+
export class DecisionAdmissionRegistry {
|
|
406
|
+
maxIdleScopes;
|
|
407
|
+
scopes = new Map();
|
|
408
|
+
profileListeners = new Set();
|
|
409
|
+
breakerListeners = new Set();
|
|
410
|
+
constructor(maxIdleScopes = 1024) {
|
|
411
|
+
this.maxIdleScopes = maxIdleScopes;
|
|
412
|
+
}
|
|
413
|
+
/** Record the policy as the workspace's current profile and return its controller. */
|
|
414
|
+
register(policy, now = Date.now) {
|
|
415
|
+
validateShares(policy);
|
|
416
|
+
const scope = this.scopeFor(policy, now);
|
|
417
|
+
const workspaceDigest = canonicalJson({ workspace: policy.workspace.limits, providers: policy.providers });
|
|
418
|
+
const principalDigest = canonicalJson(policy.principal.limits);
|
|
419
|
+
const workspaceKey = `w\u0000${policy.profileVersion}`;
|
|
420
|
+
const principalKey = `p\u0000${policy.principal.id}\u0000${policy.profileVersion}`;
|
|
421
|
+
const knownWorkspace = scope.revisions.get(workspaceKey);
|
|
422
|
+
const knownPrincipal = scope.revisions.get(principalKey);
|
|
423
|
+
if ((knownWorkspace !== undefined && knownWorkspace !== workspaceDigest)
|
|
424
|
+
|| (knownPrincipal !== undefined && knownPrincipal !== principalDigest))
|
|
425
|
+
throw new AdmissionProfileConflictError();
|
|
426
|
+
const reserved = [...scope.principals].reduce((sum, [principalId, limits]) => principalId === policy.principal.id ? sum : sum + (limits.reservedConcurrency ?? 0), policy.principal.limits.reservedConcurrency ?? 0);
|
|
427
|
+
const pools = [policy.workspace.limits, ...Object.values(policy.providers)];
|
|
428
|
+
if (reserved > 0 && pools.some(limit => reserved > limit.concurrency)) {
|
|
429
|
+
throw new AdmissionProfileError('scheduler principal reservations exceed shared workspace or provider concurrency');
|
|
430
|
+
}
|
|
431
|
+
const changed = scope.history.length === 0 || scope.profileVersion !== policy.profileVersion;
|
|
432
|
+
const rollback = changed && knownWorkspace !== undefined;
|
|
433
|
+
this.remember(scope.revisions, workspaceKey, workspaceDigest, MAX_TRACKED_REVISIONS);
|
|
434
|
+
this.remember(scope.revisions, principalKey, principalDigest, MAX_TRACKED_REVISIONS);
|
|
435
|
+
const previousProfileVersion = scope.history.length ? scope.profileVersion : null;
|
|
436
|
+
scope.profileVersion = policy.profileVersion;
|
|
437
|
+
scope.workspace = structuredClone(policy.workspace.limits);
|
|
438
|
+
scope.providers = structuredClone(policy.providers);
|
|
439
|
+
scope.principals.delete(policy.principal.id);
|
|
440
|
+
scope.principals.set(policy.principal.id, structuredClone(policy.principal.limits));
|
|
441
|
+
for (const principalId of scope.principals.keys()) {
|
|
442
|
+
if (scope.principals.size <= MAX_TRACKED_PRINCIPALS)
|
|
443
|
+
break;
|
|
444
|
+
if (principalId !== policy.principal.id && scope.controller.idle(principalId))
|
|
445
|
+
scope.principals.delete(principalId);
|
|
446
|
+
}
|
|
447
|
+
if (changed) {
|
|
448
|
+
const record = {
|
|
449
|
+
schema: 'decision-admission-profile-change/v1', workspaceId: policy.workspace.id,
|
|
450
|
+
kind: previousProfileVersion === null ? 'initial' : rollback ? 'rollback' : 'change',
|
|
451
|
+
previousProfileVersion, profileVersion: policy.profileVersion,
|
|
452
|
+
digest: `sha256:${createHash('sha256').update(workspaceDigest).digest('hex')}`, atEpochMs: scope.now(),
|
|
453
|
+
};
|
|
454
|
+
scope.history.push(record);
|
|
455
|
+
if (scope.history.length > MAX_PROFILE_HISTORY)
|
|
456
|
+
scope.history.shift();
|
|
457
|
+
for (const listener of this.profileListeners) {
|
|
458
|
+
try {
|
|
459
|
+
listener(structuredClone(record));
|
|
460
|
+
}
|
|
461
|
+
catch { /* audit sinks cannot change admission */ }
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
return scope.controller;
|
|
465
|
+
}
|
|
466
|
+
/** The workspace's controller, registering the policy only when the scope is unknown. */
|
|
467
|
+
controllerFor(policy, now = Date.now) {
|
|
468
|
+
const scope = this.scopes.get(policy.workspace.id);
|
|
469
|
+
return scope?.principals.has(policy.principal.id) ? scope.controller : this.register(policy, now);
|
|
470
|
+
}
|
|
471
|
+
controller(workspaceId) {
|
|
472
|
+
return this.scopes.get(workspaceId)?.controller;
|
|
473
|
+
}
|
|
474
|
+
/** Bounded audit history of profile revisions for one workspace, oldest first. */
|
|
475
|
+
profileHistory(workspaceId) {
|
|
476
|
+
return structuredClone(this.scopes.get(workspaceId)?.history ?? []);
|
|
477
|
+
}
|
|
478
|
+
/** Subscribe to profile-change audit records. Returns an unsubscribe function. */
|
|
479
|
+
onProfileChange(listener) {
|
|
480
|
+
this.profileListeners.add(listener);
|
|
481
|
+
return () => { this.profileListeners.delete(listener); };
|
|
482
|
+
}
|
|
483
|
+
/** Subscribe to breaker transitions in every workspace scope. Returns an unsubscribe function. */
|
|
484
|
+
onBreakerTransition(listener) {
|
|
485
|
+
this.breakerListeners.add(listener);
|
|
486
|
+
return () => { this.breakerListeners.delete(listener); };
|
|
487
|
+
}
|
|
488
|
+
scopeFor(policy, now) {
|
|
489
|
+
let scope = this.scopes.get(policy.workspace.id);
|
|
490
|
+
if (!scope) {
|
|
491
|
+
const created = { controller: undefined, now,
|
|
492
|
+
profileVersion: policy.profileVersion, workspace: policy.workspace.limits, providers: policy.providers,
|
|
493
|
+
principals: new Map(), revisions: new Map(), history: [] };
|
|
494
|
+
created.controller = this.controllerOf(created);
|
|
495
|
+
this.scopes.set(policy.workspace.id, created);
|
|
496
|
+
for (const [workspaceId, candidate] of this.scopes) {
|
|
497
|
+
if (this.scopes.size <= this.maxIdleScopes)
|
|
498
|
+
break;
|
|
499
|
+
if (candidate !== created && candidate.controller.idle())
|
|
500
|
+
this.scopes.delete(workspaceId);
|
|
501
|
+
}
|
|
502
|
+
scope = created;
|
|
503
|
+
}
|
|
504
|
+
else if (scope.now !== now && scope.controller.idle()) {
|
|
505
|
+
// An injected clock is a test seam. Switching clocks restarts idle state;
|
|
506
|
+
// a busy scope keeps its clock so live counters are never discarded.
|
|
507
|
+
scope.now = now;
|
|
508
|
+
scope.controller = this.controllerOf(scope);
|
|
509
|
+
}
|
|
510
|
+
return scope;
|
|
511
|
+
}
|
|
512
|
+
controllerOf(scope) {
|
|
513
|
+
return new DecisionAdmissionController(request => {
|
|
514
|
+
const principal = scope.principals.get(request.principalId);
|
|
515
|
+
const provider = scope.providers[request.providerId];
|
|
516
|
+
return principal && provider ? { principal, workspace: scope.workspace, provider } : null;
|
|
517
|
+
}, scope.now, {
|
|
518
|
+
principals: () => scope.principals,
|
|
519
|
+
onBreakerTransition: transition => {
|
|
520
|
+
for (const listener of this.breakerListeners) {
|
|
521
|
+
try {
|
|
522
|
+
listener(structuredClone(transition));
|
|
523
|
+
}
|
|
524
|
+
catch { /* observers cannot change admission */ }
|
|
525
|
+
}
|
|
526
|
+
},
|
|
527
|
+
});
|
|
528
|
+
}
|
|
529
|
+
remember(map, key, value, maximum) {
|
|
530
|
+
map.delete(key);
|
|
531
|
+
map.set(key, value);
|
|
532
|
+
while (map.size > maximum)
|
|
533
|
+
map.delete(map.keys().next().value);
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
function validateShares(policy) {
|
|
537
|
+
const reserved = policy.principal.limits.reservedConcurrency;
|
|
538
|
+
if (reserved !== undefined && (!Number.isSafeInteger(reserved) || reserved < 0 || reserved > policy.principal.limits.concurrency)) {
|
|
539
|
+
throw new AdmissionProfileError('scheduler reserved concurrency must be an integer within the principal concurrency');
|
|
540
|
+
}
|
|
541
|
+
for (const limit of [policy.workspace.limits, policy.principal.limits, ...Object.values(policy.providers)]) {
|
|
542
|
+
const share = limit.maxPrincipalShare;
|
|
543
|
+
if (share !== undefined && (!Number.isFinite(share) || share <= 0 || share > 1)) {
|
|
544
|
+
throw new AdmissionProfileError('scheduler principal share must be greater than 0 and at most 1');
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
/** Process-wide registry used by the evaluator. */
|
|
549
|
+
export const decisionAdmissionRegistry = new DecisionAdmissionRegistry();
|
|
550
|
+
//# sourceMappingURL=admission.js.map
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
function finiteNonNegative(value, label) {
|
|
2
|
+
if (!Number.isFinite(value) || value < 0)
|
|
3
|
+
throw new Error(`${label} must be finite and non-negative`);
|
|
4
|
+
}
|
|
5
|
+
function sumKnown(values) {
|
|
6
|
+
return values.some(value => value === null) ? null : values.reduce((sum, value) => sum + value, 0);
|
|
7
|
+
}
|
|
8
|
+
function summarize(samples) {
|
|
9
|
+
return {
|
|
10
|
+
samples: samples.length,
|
|
11
|
+
providerCalls: samples.reduce((sum, value) => sum + value.providerCalls, 0),
|
|
12
|
+
inputTokens: sumKnown(samples.map(value => value.inputTokens)),
|
|
13
|
+
outputTokens: sumKnown(samples.map(value => value.outputTokens)),
|
|
14
|
+
latencyMs: samples.reduce((sum, value) => sum + value.latencyMs, 0),
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Produces workload-pinned comparative evidence without asserting that a
|
|
19
|
+
* synthetic improvement generalizes to another provider, model, or workload.
|
|
20
|
+
*/
|
|
21
|
+
export function decisionBatchBenchmarkReport(workloadDigest, samples) {
|
|
22
|
+
if (!/^sha256:[a-f0-9]{64}$/.test(workloadDigest))
|
|
23
|
+
throw new Error('invalid workload digest');
|
|
24
|
+
for (const sample of samples) {
|
|
25
|
+
if (!Number.isSafeInteger(sample.providerCalls) || sample.providerCalls < 1) {
|
|
26
|
+
throw new Error('providerCalls must be a positive integer');
|
|
27
|
+
}
|
|
28
|
+
finiteNonNegative(sample.latencyMs, 'latencyMs');
|
|
29
|
+
if (sample.inputTokens !== null)
|
|
30
|
+
finiteNonNegative(sample.inputTokens, 'inputTokens');
|
|
31
|
+
if (sample.outputTokens !== null)
|
|
32
|
+
finiteNonNegative(sample.outputTokens, 'outputTokens');
|
|
33
|
+
}
|
|
34
|
+
const nativeBatch = samples.filter(sample => sample.mode === 'native-batch');
|
|
35
|
+
const singleCall = samples.filter(sample => sample.mode === 'single-call');
|
|
36
|
+
if (nativeBatch.length === 0 || nativeBatch.length !== singleCall.length) {
|
|
37
|
+
throw new Error('paired batch benchmark requires equal non-empty mode samples');
|
|
38
|
+
}
|
|
39
|
+
return {
|
|
40
|
+
schemaVersion: 'decision-batch-benchmark/v1',
|
|
41
|
+
workloadDigest,
|
|
42
|
+
repetitions: nativeBatch.length,
|
|
43
|
+
nativeBatch: summarize(nativeBatch),
|
|
44
|
+
singleCall: summarize(singleCall),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
//# sourceMappingURL=batch-benchmark.js.map
|