@netgreener/runtime 0.1.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/CHANGELOG.md +16 -0
- package/README.md +28 -0
- package/TUTORIAL.md +495 -0
- package/dist/aggregator.d.ts +43 -0
- package/dist/aggregator.js +270 -0
- package/dist/analyze/analyzeGate.d.ts +44 -0
- package/dist/analyze/analyzeGate.js +114 -0
- package/dist/analyze/apiOverconsumptionDetector.d.ts +13 -0
- package/dist/analyze/apiOverconsumptionDetector.js +75 -0
- package/dist/analyze/astDetectors.d.ts +38 -0
- package/dist/analyze/astDetectors.js +566 -0
- package/dist/analyze/buildResourceFindings.d.ts +34 -0
- package/dist/analyze/buildResourceFindings.js +86 -0
- package/dist/analyze/cli.d.ts +20 -0
- package/dist/analyze/cli.js +186 -0
- package/dist/analyze/cpuResourceDetectors.d.ts +35 -0
- package/dist/analyze/cpuResourceDetectors.js +163 -0
- package/dist/analyze/heuristicScan.d.ts +61 -0
- package/dist/analyze/heuristicScan.js +91 -0
- package/dist/analyze/index.d.ts +15 -0
- package/dist/analyze/index.js +15 -0
- package/dist/analyze/loadTypescript.d.ts +8 -0
- package/dist/analyze/loadTypescript.js +23 -0
- package/dist/analyze/memoryIoDetectors.d.ts +15 -0
- package/dist/analyze/memoryIoDetectors.js +68 -0
- package/dist/analyze/preferAstDetectors.d.ts +18 -0
- package/dist/analyze/preferAstDetectors.js +91 -0
- package/dist/analyze/projectScan.d.ts +19 -0
- package/dist/analyze/projectScan.js +115 -0
- package/dist/analyze/resourceAdmission.d.ts +33 -0
- package/dist/analyze/resourceAdmission.js +91 -0
- package/dist/analyze/resourceFindingCandidate.d.ts +61 -0
- package/dist/analyze/resourceFindingCandidate.js +75 -0
- package/dist/analyze/retryAmplificationDetector.d.ts +11 -0
- package/dist/analyze/retryAmplificationDetector.js +55 -0
- package/dist/analyze/schemas/resource-finding.schema.json +352 -0
- package/dist/analyze/serviceDiscovery.d.ts +66 -0
- package/dist/analyze/serviceDiscovery.js +210 -0
- package/dist/analyze/unboundedParallelismDetector.d.ts +11 -0
- package/dist/analyze/unboundedParallelismDetector.js +57 -0
- package/dist/analyze/validateResourceFinding.d.ts +19 -0
- package/dist/analyze/validateResourceFinding.js +46 -0
- package/dist/bullmq.d.ts +33 -0
- package/dist/bullmq.js +106 -0
- package/dist/cli/helpText.d.ts +8 -0
- package/dist/cli/helpText.js +99 -0
- package/dist/cli/netgreener.d.ts +12 -0
- package/dist/cli/netgreener.js +65 -0
- package/dist/collectorIpc.d.ts +28 -0
- package/dist/collectorIpc.js +189 -0
- package/dist/collectorMetadata.d.ts +15 -0
- package/dist/collectorMetadata.js +33 -0
- package/dist/config.d.ts +41 -0
- package/dist/config.js +97 -0
- package/dist/contract/index.d.ts +1 -0
- package/dist/contract/index.js +1 -0
- package/dist/contract/observation-envelope.schema.json +331 -0
- package/dist/contract/observation-protobuf-view.schema.json +773 -0
- package/dist/contract/observation-v1.d.mts +42 -0
- package/dist/contract/observation-v1.mjs +555 -0
- package/dist/contract/observationIdentity.d.ts +11 -0
- package/dist/contract/observationIdentity.js +48 -0
- package/dist/contract/observationProjection.d.ts +3 -0
- package/dist/contract/observationProjection.js +29 -0
- package/dist/contract/validate.d.ts +20 -0
- package/dist/contract/validate.js +227 -0
- package/dist/dogfood/mp3WorkerGates.d.ts +54 -0
- package/dist/dogfood/mp3WorkerGates.js +86 -0
- package/dist/dogfood/retainDryRunArtifact.d.ts +45 -0
- package/dist/dogfood/retainDryRunArtifact.js +103 -0
- package/dist/dogfood/tenantDogfoodGates.d.ts +76 -0
- package/dist/dogfood/tenantDogfoodGates.js +206 -0
- package/dist/exporter.d.ts +56 -0
- package/dist/exporter.js +304 -0
- package/dist/express.d.ts +44 -0
- package/dist/express.js +118 -0
- package/dist/externalApiMeter.d.ts +72 -0
- package/dist/externalApiMeter.js +1167 -0
- package/dist/fastify.d.ts +53 -0
- package/dist/fastify.js +148 -0
- package/dist/index.d.ts +32 -0
- package/dist/index.js +31 -0
- package/dist/n4/deploymentMatrix.d.ts +22 -0
- package/dist/n4/deploymentMatrix.js +57 -0
- package/dist/n4/missingnessInventory.d.ts +22 -0
- package/dist/n4/missingnessInventory.js +72 -0
- package/dist/nest.d.ts +42 -0
- package/dist/nest.js +87 -0
- package/dist/processResources.d.ts +28 -0
- package/dist/processResources.js +31 -0
- package/dist/processRuntime.d.ts +35 -0
- package/dist/processRuntime.js +96 -0
- package/dist/requestContext.d.ts +21 -0
- package/dist/requestContext.js +33 -0
- package/dist/runtime.d.ts +60 -0
- package/dist/runtime.js +289 -0
- package/dist/runtimeHealth.d.ts +35 -0
- package/dist/runtimeHealth.js +121 -0
- package/dist/serverlessHints.d.ts +26 -0
- package/dist/serverlessHints.js +52 -0
- package/dist/tenantContext.d.ts +78 -0
- package/dist/tenantContext.js +226 -0
- package/dist/tenantDefaults.d.ts +9 -0
- package/dist/tenantDefaults.js +9 -0
- package/dist/types.d.ts +131 -0
- package/dist/types.js +2 -0
- package/dist/uploader.d.ts +23 -0
- package/dist/uploader.js +52 -0
- package/dist/windowId.d.ts +2 -0
- package/dist/windowId.js +7 -0
- package/examples/analyze-manifest.mjs +30 -0
- package/examples/bullmq-live-smoke.mjs +178 -0
- package/examples/bullmq-mp3-worker-gate.mjs +237 -0
- package/examples/express-dry-run.mjs +72 -0
- package/examples/process-mp3-restart-gate.mjs +135 -0
- package/examples/retain-dry-run.mjs +110 -0
- package/examples/tenant-dogfood.mjs +269 -0
- package/fixtures/analyze-sample/architectureNoise.ts +13 -0
- package/fixtures/analyze-sample/cpuHotPaths.ts +27 -0
- package/fixtures/analyze-sample/fanOutClient.ts +15 -0
- package/fixtures/analyze-sample/inferenceHotPaths.ts +22 -0
- package/fixtures/analyze-sample/memoryIoHotPaths.ts +21 -0
- package/fixtures/analyze-sample/modelLoadHotPaths.ts +11 -0
- package/fixtures/analyze-sample/nestedLookup.ts +15 -0
- package/fixtures/analyze-sample/retryClient.ts +10 -0
- package/fixtures/analyze-sample/securitySmell.ts +8 -0
- package/fixtures/analyze-sample/server.ts +11 -0
- package/fixtures/analyze-sample/styleOnly.ts +7 -0
- package/fixtures/analyze-sample/worker.ts +4 -0
- package/fixtures/dual-view-projection.v1.json +53 -0
- package/fixtures/identity-preimages.v1.json +42 -0
- package/fixtures/mp2_shadow_digest_golden.v1.json +47 -0
- package/fixtures/projection-batch-document.v1.json +170 -0
- package/fixtures/projection-batch.v1.json +9 -0
- package/fixtures/python_reference_shapes.md +24 -0
- package/fixtures/session_metadata_runtime_v0.json +120 -0
- package/fixtures/session_metadata_with_tenant.json +85 -0
- package/package.json +137 -0
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Observation semantic-projection helpers (ADR 0008 D2 twin).
|
|
3
|
+
*
|
|
4
|
+
* Additive helper for golden parity. Does not change upload/emit paths.
|
|
5
|
+
*/
|
|
6
|
+
import { createHash } from 'node:crypto';
|
|
7
|
+
export function projectValue(value) {
|
|
8
|
+
if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
|
|
9
|
+
const obj = value;
|
|
10
|
+
const out = {};
|
|
11
|
+
for (const key of Object.keys(obj).sort()) {
|
|
12
|
+
if (obj[key] !== null && obj[key] !== undefined) {
|
|
13
|
+
out[key] = projectValue(obj[key]);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
return out;
|
|
17
|
+
}
|
|
18
|
+
if (Array.isArray(value)) {
|
|
19
|
+
return value.map((item) => projectValue(item));
|
|
20
|
+
}
|
|
21
|
+
return value;
|
|
22
|
+
}
|
|
23
|
+
export function projectionBytes(document) {
|
|
24
|
+
const projected = projectValue(document);
|
|
25
|
+
return Buffer.from(JSON.stringify(projected), 'utf8');
|
|
26
|
+
}
|
|
27
|
+
export function projectionDigest(document) {
|
|
28
|
+
return 'sha256:' + createHash('sha256').update(projectionBytes(document)).digest('hex');
|
|
29
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { ExternalApiV0, MeasurementProvenance, RuntimeSessionMetadata, ServiceRuntimeV0 } from '../types.js';
|
|
2
|
+
export type ContractIssue = {
|
|
3
|
+
path: string;
|
|
4
|
+
message: string;
|
|
5
|
+
};
|
|
6
|
+
export type ContractResult = {
|
|
7
|
+
ok: boolean;
|
|
8
|
+
issues: ContractIssue[];
|
|
9
|
+
};
|
|
10
|
+
/** Validate the legacy Python-compatible v0 scaffold. MP0 remains the authority. */
|
|
11
|
+
export declare function validateServiceRuntimeV0(value: unknown, path?: string): ContractResult;
|
|
12
|
+
/** Validate external_api_v0 including optional by_model buckets. */
|
|
13
|
+
export declare function validateExternalApiV0(value: unknown, path?: string): ContractResult;
|
|
14
|
+
export declare function validateMeasurementProvenance(value: unknown, path?: string): ContractResult;
|
|
15
|
+
/**
|
|
16
|
+
* Validate a session_metadata fragment Node will upload.
|
|
17
|
+
* Requires at least service_runtime_v0 or external_api_v0.
|
|
18
|
+
*/
|
|
19
|
+
export declare function validateRuntimeSessionMetadata(value: unknown): ContractResult;
|
|
20
|
+
export type { ExternalApiV0, MeasurementProvenance, RuntimeSessionMetadata, ServiceRuntimeV0, };
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
function issue(path, message) {
|
|
2
|
+
return { path, message };
|
|
3
|
+
}
|
|
4
|
+
function isObject(value) {
|
|
5
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
6
|
+
}
|
|
7
|
+
function requireNumber(obj, key, path, issues, requirements = {}) {
|
|
8
|
+
const value = obj[key];
|
|
9
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
|
10
|
+
issues.push(issue(`${path}.${key}`, 'expected number'));
|
|
11
|
+
return;
|
|
12
|
+
}
|
|
13
|
+
if (requirements.nonNegative && value < 0) {
|
|
14
|
+
issues.push(issue(`${path}.${key}`, 'expected non-negative number'));
|
|
15
|
+
}
|
|
16
|
+
if (requirements.integer && !Number.isSafeInteger(value)) {
|
|
17
|
+
issues.push(issue(`${path}.${key}`, 'expected safe integer'));
|
|
18
|
+
}
|
|
19
|
+
if (requirements.maximum !== undefined && value > requirements.maximum) {
|
|
20
|
+
issues.push(issue(`${path}.${key}`, `expected number at most ${requirements.maximum}`));
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
function optionalNumber(obj, key, path, issues, requirements = {}) {
|
|
24
|
+
if (obj[key] !== undefined) {
|
|
25
|
+
requireNumber(obj, key, path, issues, requirements);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
function errorsDoNotExceedCalls(obj, path, issues) {
|
|
29
|
+
if (typeof obj.calls === 'number' &&
|
|
30
|
+
Number.isFinite(obj.calls) &&
|
|
31
|
+
typeof obj.errors === 'number' &&
|
|
32
|
+
Number.isFinite(obj.errors) &&
|
|
33
|
+
obj.errors > obj.calls) {
|
|
34
|
+
issues.push(issue(`${path}.errors`, 'must not exceed calls'));
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
function requireString(obj, key, path, issues) {
|
|
38
|
+
if (typeof obj[key] !== 'string' || !obj[key].trim()) {
|
|
39
|
+
issues.push(issue(`${path}.${key}`, 'expected non-empty string'));
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/** Validate the legacy Python-compatible v0 scaffold. MP0 remains the authority. */
|
|
43
|
+
export function validateServiceRuntimeV0(value, path = 'service_runtime_v0') {
|
|
44
|
+
const issues = [];
|
|
45
|
+
if (!isObject(value)) {
|
|
46
|
+
return { ok: false, issues: [issue(path, 'expected object')] };
|
|
47
|
+
}
|
|
48
|
+
requireString(value, 'collector', path, issues);
|
|
49
|
+
requireNumber(value, 'window_seconds', path, issues, { nonNegative: true });
|
|
50
|
+
requireString(value, 'attribution', path, issues);
|
|
51
|
+
requireString(value, 'accuracy', path, issues);
|
|
52
|
+
if (!Array.isArray(value.units)) {
|
|
53
|
+
issues.push(issue(`${path}.units`, 'expected array'));
|
|
54
|
+
}
|
|
55
|
+
else {
|
|
56
|
+
value.units.forEach((unit, index) => {
|
|
57
|
+
const up = `${path}.units[${index}]`;
|
|
58
|
+
if (!isObject(unit)) {
|
|
59
|
+
issues.push(issue(up, 'expected object'));
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
requireString(unit, 'service_unit', up, issues);
|
|
63
|
+
requireString(unit, 'unit_type', up, issues);
|
|
64
|
+
requireNumber(unit, 'calls', up, issues, { nonNegative: true, integer: true });
|
|
65
|
+
requireNumber(unit, 'errors', up, issues, { nonNegative: true, integer: true });
|
|
66
|
+
errorsDoNotExceedCalls(unit, up, issues);
|
|
67
|
+
requireNumber(unit, 'cpu_seconds_total', up, issues, { nonNegative: true });
|
|
68
|
+
requireNumber(unit, 'energy_kwh', up, issues, { nonNegative: true });
|
|
69
|
+
requireNumber(unit, 'carbon_g', up, issues, { nonNegative: true });
|
|
70
|
+
optionalNumber(unit, 'peak_rss_kb_max', up, issues, { nonNegative: true });
|
|
71
|
+
optionalNumber(unit, 'cpu_percent_hours', up, issues, { nonNegative: true });
|
|
72
|
+
optionalNumber(unit, 'ram_gb_hours', up, issues, { nonNegative: true });
|
|
73
|
+
optionalNumber(unit, 'process_gpu_hours', up, issues, { nonNegative: true });
|
|
74
|
+
if (!isObject(unit.duration_ms)) {
|
|
75
|
+
issues.push(issue(`${up}.duration_ms`, 'expected object'));
|
|
76
|
+
}
|
|
77
|
+
else {
|
|
78
|
+
requireNumber(unit.duration_ms, 'avg', `${up}.duration_ms`, issues, {
|
|
79
|
+
nonNegative: true,
|
|
80
|
+
});
|
|
81
|
+
requireNumber(unit.duration_ms, 'max', `${up}.duration_ms`, issues, {
|
|
82
|
+
nonNegative: true,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
if (value.allocation_reconciliation_v0 !== undefined) {
|
|
88
|
+
const reconciliationPath = `${path}.allocation_reconciliation_v0`;
|
|
89
|
+
if (!isObject(value.allocation_reconciliation_v0)) {
|
|
90
|
+
issues.push(issue(reconciliationPath, 'expected object'));
|
|
91
|
+
}
|
|
92
|
+
else {
|
|
93
|
+
const reconciliation = value.allocation_reconciliation_v0;
|
|
94
|
+
requireNumber(reconciliation, 'schema_version', reconciliationPath, issues, {
|
|
95
|
+
nonNegative: true,
|
|
96
|
+
integer: true,
|
|
97
|
+
});
|
|
98
|
+
requireNumber(reconciliation, 'window_energy_kwh', reconciliationPath, issues, {
|
|
99
|
+
nonNegative: true,
|
|
100
|
+
});
|
|
101
|
+
requireNumber(reconciliation, 'allocated_energy_kwh', reconciliationPath, issues, {
|
|
102
|
+
nonNegative: true,
|
|
103
|
+
});
|
|
104
|
+
requireNumber(reconciliation, 'residual_energy_kwh', reconciliationPath, issues);
|
|
105
|
+
requireNumber(reconciliation, 'tolerance_kwh', reconciliationPath, issues, {
|
|
106
|
+
nonNegative: true,
|
|
107
|
+
});
|
|
108
|
+
if (reconciliation.endpoint_capture_coverage !== null &&
|
|
109
|
+
reconciliation.endpoint_capture_coverage !== undefined) {
|
|
110
|
+
requireNumber(reconciliation, 'endpoint_capture_coverage', reconciliationPath, issues, {
|
|
111
|
+
nonNegative: true,
|
|
112
|
+
maximum: 1,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return { ok: issues.length === 0, issues };
|
|
118
|
+
}
|
|
119
|
+
/** Validate external_api_v0 including optional by_model buckets. */
|
|
120
|
+
export function validateExternalApiV0(value, path = 'external_api_v0') {
|
|
121
|
+
const issues = [];
|
|
122
|
+
if (!isObject(value)) {
|
|
123
|
+
return { ok: false, issues: [issue(path, 'expected object')] };
|
|
124
|
+
}
|
|
125
|
+
requireString(value, 'collector', path, issues);
|
|
126
|
+
if (!Array.isArray(value.providers)) {
|
|
127
|
+
issues.push(issue(`${path}.providers`, 'expected array'));
|
|
128
|
+
return { ok: false, issues };
|
|
129
|
+
}
|
|
130
|
+
value.providers.forEach((provider, index) => {
|
|
131
|
+
const pp = `${path}.providers[${index}]`;
|
|
132
|
+
if (!isObject(provider)) {
|
|
133
|
+
issues.push(issue(pp, 'expected object'));
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
requireString(provider, 'provider_key', pp, issues);
|
|
137
|
+
requireNumber(provider, 'calls', pp, issues, { nonNegative: true, integer: true });
|
|
138
|
+
requireNumber(provider, 'errors', pp, issues, { nonNegative: true, integer: true });
|
|
139
|
+
errorsDoNotExceedCalls(provider, pp, issues);
|
|
140
|
+
optionalNumber(provider, 'duration_ms_avg', pp, issues, { nonNegative: true });
|
|
141
|
+
optionalNumber(provider, 'input_tokens', pp, issues, { nonNegative: true, integer: true });
|
|
142
|
+
optionalNumber(provider, 'output_tokens', pp, issues, { nonNegative: true, integer: true });
|
|
143
|
+
optionalNumber(provider, 'total_tokens', pp, issues, { nonNegative: true, integer: true });
|
|
144
|
+
if (provider.by_model !== undefined) {
|
|
145
|
+
if (!Array.isArray(provider.by_model)) {
|
|
146
|
+
issues.push(issue(`${pp}.by_model`, 'expected array'));
|
|
147
|
+
}
|
|
148
|
+
else {
|
|
149
|
+
provider.by_model.forEach((row, mi) => {
|
|
150
|
+
const mp = `${pp}.by_model[${mi}]`;
|
|
151
|
+
if (!isObject(row)) {
|
|
152
|
+
issues.push(issue(mp, 'expected object'));
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
requireString(row, 'model', mp, issues);
|
|
156
|
+
requireNumber(row, 'calls', mp, issues, { nonNegative: true, integer: true });
|
|
157
|
+
requireNumber(row, 'errors', mp, issues, { nonNegative: true, integer: true });
|
|
158
|
+
errorsDoNotExceedCalls(row, mp, issues);
|
|
159
|
+
optionalNumber(row, 'input_tokens', mp, issues, { nonNegative: true, integer: true });
|
|
160
|
+
optionalNumber(row, 'output_tokens', mp, issues, { nonNegative: true, integer: true });
|
|
161
|
+
optionalNumber(row, 'total_tokens', mp, issues, { nonNegative: true, integer: true });
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
return { ok: issues.length === 0, issues };
|
|
167
|
+
}
|
|
168
|
+
export function validateMeasurementProvenance(value, path = 'measurement_provenance') {
|
|
169
|
+
const issues = [];
|
|
170
|
+
if (!isObject(value)) {
|
|
171
|
+
return { ok: false, issues: [issue(path, 'expected object')] };
|
|
172
|
+
}
|
|
173
|
+
const carbonFactor = value.carbon_factor;
|
|
174
|
+
if (carbonFactor !== undefined) {
|
|
175
|
+
if (!isObject(carbonFactor)) {
|
|
176
|
+
issues.push(issue(`${path}.carbon_factor`, 'expected object'));
|
|
177
|
+
}
|
|
178
|
+
else if (carbonFactor.value !== null) {
|
|
179
|
+
requireNumber(carbonFactor, 'value', `${path}.carbon_factor`, issues, {
|
|
180
|
+
nonNegative: true,
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
const grades = value.evidence_grades;
|
|
185
|
+
if (grades !== undefined) {
|
|
186
|
+
if (!isObject(grades)) {
|
|
187
|
+
issues.push(issue(`${path}.evidence_grades`, 'expected object'));
|
|
188
|
+
}
|
|
189
|
+
else {
|
|
190
|
+
requireNumber(grades, 'schema_version', `${path}.evidence_grades`, issues, {
|
|
191
|
+
nonNegative: true,
|
|
192
|
+
integer: true,
|
|
193
|
+
});
|
|
194
|
+
requireString(grades, 'policy_version', `${path}.evidence_grades`, issues);
|
|
195
|
+
requireString(grades, 'combined_floor_grade', `${path}.evidence_grades`, issues);
|
|
196
|
+
if (!isObject(grades.metrics)) {
|
|
197
|
+
issues.push(issue(`${path}.evidence_grades.metrics`, 'expected object'));
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
return { ok: issues.length === 0, issues };
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Validate a session_metadata fragment Node will upload.
|
|
205
|
+
* Requires at least service_runtime_v0 or external_api_v0.
|
|
206
|
+
*/
|
|
207
|
+
export function validateRuntimeSessionMetadata(value) {
|
|
208
|
+
const issues = [];
|
|
209
|
+
if (!isObject(value)) {
|
|
210
|
+
return { ok: false, issues: [issue('session_metadata', 'expected object')] };
|
|
211
|
+
}
|
|
212
|
+
const hasRuntime = value.service_runtime_v0 !== undefined;
|
|
213
|
+
const hasExternal = value.external_api_v0 !== undefined;
|
|
214
|
+
if (!hasRuntime && !hasExternal) {
|
|
215
|
+
issues.push(issue('session_metadata', 'expected service_runtime_v0 and/or external_api_v0'));
|
|
216
|
+
}
|
|
217
|
+
if (hasRuntime) {
|
|
218
|
+
issues.push(...validateServiceRuntimeV0(value.service_runtime_v0).issues);
|
|
219
|
+
}
|
|
220
|
+
if (hasExternal) {
|
|
221
|
+
issues.push(...validateExternalApiV0(value.external_api_v0).issues);
|
|
222
|
+
}
|
|
223
|
+
if (value.measurement_provenance !== undefined) {
|
|
224
|
+
issues.push(...validateMeasurementProvenance(value.measurement_provenance).issues);
|
|
225
|
+
}
|
|
226
|
+
return { ok: issues.length === 0, issues };
|
|
227
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MP3 worker-gate helpers (BullMQ concurrency + restart; process restart).
|
|
3
|
+
*
|
|
4
|
+
* Pure assertions over flushed session_metadata — no Redis required in unit tests.
|
|
5
|
+
* Does not claim production support or change export defaults.
|
|
6
|
+
*/
|
|
7
|
+
export type Mp3WorkerGateFlush = {
|
|
8
|
+
sessionMetadata?: {
|
|
9
|
+
service_runtime_v0?: {
|
|
10
|
+
collector?: string;
|
|
11
|
+
units?: Array<{
|
|
12
|
+
service_unit?: string;
|
|
13
|
+
calls?: number;
|
|
14
|
+
unit_type?: string;
|
|
15
|
+
}>;
|
|
16
|
+
by_tenant?: Record<string, unknown>;
|
|
17
|
+
};
|
|
18
|
+
} | null;
|
|
19
|
+
/** Number of jobs the harness observed as completed. */
|
|
20
|
+
completedJobs: number;
|
|
21
|
+
/** Expected minimum completed jobs for this phase. */
|
|
22
|
+
minCompletedJobs: number;
|
|
23
|
+
/** Optional tenant key that must appear when tenants are configured. */
|
|
24
|
+
expectedTenant?: string;
|
|
25
|
+
/** Optional task unit name (e.g. task:analyze_document). */
|
|
26
|
+
expectedTaskUnit?: string;
|
|
27
|
+
phase: 'concurrency' | 'restart' | 'process_restart';
|
|
28
|
+
/** Expected collector metadata (default bullmq_worker). */
|
|
29
|
+
expectedCollector?: string;
|
|
30
|
+
};
|
|
31
|
+
export type Mp3WorkerGateResult = {
|
|
32
|
+
ok: true;
|
|
33
|
+
phase: string;
|
|
34
|
+
} | {
|
|
35
|
+
ok: false;
|
|
36
|
+
phase: string;
|
|
37
|
+
reason: string;
|
|
38
|
+
};
|
|
39
|
+
/**
|
|
40
|
+
* Concurrency phase: enough jobs completed and expected collector metadata present.
|
|
41
|
+
* When expectedTaskUnit is set, recorded calls must be ≥ 1 (unit must exist).
|
|
42
|
+
*/
|
|
43
|
+
export declare function assertMp3ConcurrencyGate(input: Mp3WorkerGateFlush): Mp3WorkerGateResult;
|
|
44
|
+
/**
|
|
45
|
+
* Restart phase: a second worker generation completed jobs and still emits
|
|
46
|
+
* expected collector metadata (proves wrap survives worker.close → new Worker).
|
|
47
|
+
*/
|
|
48
|
+
export declare function assertMp3RestartGate(input: Mp3WorkerGateFlush): Mp3WorkerGateResult;
|
|
49
|
+
/**
|
|
50
|
+
* Process/cron restart: after `_resetRuntimeForTests` + new `startProcessRuntime`,
|
|
51
|
+
* flush still emits ``node_process`` with tenant/task samples.
|
|
52
|
+
*/
|
|
53
|
+
export declare function assertMp3ProcessRestartGate(input: Mp3WorkerGateFlush): Mp3WorkerGateResult;
|
|
54
|
+
export declare function formatMp3GateFailure(result: Mp3WorkerGateResult): string;
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MP3 worker-gate helpers (BullMQ concurrency + restart; process restart).
|
|
3
|
+
*
|
|
4
|
+
* Pure assertions over flushed session_metadata — no Redis required in unit tests.
|
|
5
|
+
* Does not claim production support or change export defaults.
|
|
6
|
+
*/
|
|
7
|
+
function taskUnitCalls(meta, serviceUnit) {
|
|
8
|
+
const units = meta.service_runtime_v0?.units || [];
|
|
9
|
+
const hit = units.find((u) => u.service_unit === serviceUnit);
|
|
10
|
+
return Number(hit?.calls || 0);
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Concurrency phase: enough jobs completed and expected collector metadata present.
|
|
14
|
+
* When expectedTaskUnit is set, recorded calls must be ≥ 1 (unit must exist).
|
|
15
|
+
*/
|
|
16
|
+
export function assertMp3ConcurrencyGate(input) {
|
|
17
|
+
const phase = input.phase || 'concurrency';
|
|
18
|
+
const expectedCollector = input.expectedCollector || 'bullmq_worker';
|
|
19
|
+
if (input.completedJobs < input.minCompletedJobs) {
|
|
20
|
+
return {
|
|
21
|
+
ok: false,
|
|
22
|
+
phase,
|
|
23
|
+
reason: `completedJobs=${input.completedJobs} < minCompletedJobs=${input.minCompletedJobs}`,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
const meta = input.sessionMetadata;
|
|
27
|
+
if (!meta?.service_runtime_v0) {
|
|
28
|
+
return { ok: false, phase, reason: 'missing service_runtime_v0 after concurrency flush' };
|
|
29
|
+
}
|
|
30
|
+
const collector = meta.service_runtime_v0.collector;
|
|
31
|
+
if (collector !== expectedCollector) {
|
|
32
|
+
return {
|
|
33
|
+
ok: false,
|
|
34
|
+
phase,
|
|
35
|
+
reason: `collector=${collector ?? '(none)'} expected ${expectedCollector}`,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
if (input.expectedTaskUnit) {
|
|
39
|
+
const calls = taskUnitCalls(meta, input.expectedTaskUnit);
|
|
40
|
+
if (calls < 1) {
|
|
41
|
+
return {
|
|
42
|
+
ok: false,
|
|
43
|
+
phase,
|
|
44
|
+
reason: `missing task unit ${input.expectedTaskUnit} (calls=${calls})`,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
if (input.expectedTenant) {
|
|
49
|
+
const tenants = meta.service_runtime_v0.by_tenant || {};
|
|
50
|
+
if (!(input.expectedTenant in tenants)) {
|
|
51
|
+
return {
|
|
52
|
+
ok: false,
|
|
53
|
+
phase,
|
|
54
|
+
reason: `missing by_tenant.${input.expectedTenant}`,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return { ok: true, phase };
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Restart phase: a second worker generation completed jobs and still emits
|
|
62
|
+
* expected collector metadata (proves wrap survives worker.close → new Worker).
|
|
63
|
+
*/
|
|
64
|
+
export function assertMp3RestartGate(input) {
|
|
65
|
+
const phase = input.phase || 'restart';
|
|
66
|
+
const base = assertMp3ConcurrencyGate({ ...input, phase });
|
|
67
|
+
if (!base.ok)
|
|
68
|
+
return base;
|
|
69
|
+
return { ok: true, phase };
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Process/cron restart: after `_resetRuntimeForTests` + new `startProcessRuntime`,
|
|
73
|
+
* flush still emits ``node_process`` with tenant/task samples.
|
|
74
|
+
*/
|
|
75
|
+
export function assertMp3ProcessRestartGate(input) {
|
|
76
|
+
return assertMp3ConcurrencyGate({
|
|
77
|
+
...input,
|
|
78
|
+
phase: input.phase || 'process_restart',
|
|
79
|
+
expectedCollector: input.expectedCollector || 'node_process',
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
export function formatMp3GateFailure(result) {
|
|
83
|
+
if (result.ok)
|
|
84
|
+
return `MP3 ${result.phase} gate PASS`;
|
|
85
|
+
return `MP3 ${result.phase} gate FAIL: ${result.reason}`;
|
|
86
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Revision-bound dry-run artifact helpers (N6 evidence prep).
|
|
3
|
+
* Redacts secrets; does not upload; does not change RunSession flush pipelines.
|
|
4
|
+
*/
|
|
5
|
+
export declare const DRY_RUN_ARTIFACT_SCHEMA = "netgreener_node_dry_run_artifact_v0";
|
|
6
|
+
export type DryRunArtifactV0 = {
|
|
7
|
+
schema: typeof DRY_RUN_ARTIFACT_SCHEMA;
|
|
8
|
+
git_sha: string;
|
|
9
|
+
git_sha_short: string;
|
|
10
|
+
node_version: string;
|
|
11
|
+
package_name: string;
|
|
12
|
+
package_version: string;
|
|
13
|
+
created_at: string;
|
|
14
|
+
dry_run: true;
|
|
15
|
+
upload: 'never';
|
|
16
|
+
sources: string[];
|
|
17
|
+
checks: {
|
|
18
|
+
ok: boolean;
|
|
19
|
+
has_session_metadata: boolean;
|
|
20
|
+
has_service_runtime_v0: boolean;
|
|
21
|
+
collector: string | null;
|
|
22
|
+
};
|
|
23
|
+
payload_sha256_prefix: string;
|
|
24
|
+
payload_redacted: unknown;
|
|
25
|
+
};
|
|
26
|
+
/** Deep-clone JSON-ish values while redacting secret-looking strings/keys. */
|
|
27
|
+
export declare function redactSensitive(value: unknown): unknown;
|
|
28
|
+
export declare function sha256Prefix(value: unknown, chars?: number): string;
|
|
29
|
+
export declare function extractDryRunChecks(payload: unknown): DryRunArtifactV0['checks'];
|
|
30
|
+
export type BuildDryRunArtifactInput = {
|
|
31
|
+
payload: unknown;
|
|
32
|
+
gitSha: string;
|
|
33
|
+
packageName?: string;
|
|
34
|
+
packageVersion?: string;
|
|
35
|
+
sources?: string[];
|
|
36
|
+
createdAt?: string;
|
|
37
|
+
nodeVersion?: string;
|
|
38
|
+
};
|
|
39
|
+
export declare function buildDryRunArtifact(input: BuildDryRunArtifactInput): DryRunArtifactV0;
|
|
40
|
+
export declare function writeDryRunArtifact(artifactsDir: string, artifact: DryRunArtifactV0): {
|
|
41
|
+
summaryPath: string;
|
|
42
|
+
latestPath: string;
|
|
43
|
+
};
|
|
44
|
+
/** Fail closed if redacted text still looks like a live token. */
|
|
45
|
+
export declare function assertArtifactRedacted(artifact: DryRunArtifactV0): void;
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Revision-bound dry-run artifact helpers (N6 evidence prep).
|
|
3
|
+
* Redacts secrets; does not upload; does not change RunSession flush pipelines.
|
|
4
|
+
*/
|
|
5
|
+
import { createHash } from 'node:crypto';
|
|
6
|
+
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
export const DRY_RUN_ARTIFACT_SCHEMA = 'netgreener_node_dry_run_artifact_v0';
|
|
9
|
+
const SECRET_STRING_RE = /\b(?:ngs_[A-Za-z0-9._-]{8,}|Bearer\s+[A-Za-z0-9._~+/=-]{8,}|sk-[A-Za-z0-9]{8,})\b/gi;
|
|
10
|
+
const SECRET_KEY_RE = /^(?:authorization|api[_-]?key|token|access[_-]?token|password|secret|NETGREENER_TOKEN)$/i;
|
|
11
|
+
/** Deep-clone JSON-ish values while redacting secret-looking strings/keys. */
|
|
12
|
+
export function redactSensitive(value) {
|
|
13
|
+
if (value == null)
|
|
14
|
+
return value;
|
|
15
|
+
if (typeof value === 'string') {
|
|
16
|
+
return value.replace(SECRET_STRING_RE, '[REDACTED]');
|
|
17
|
+
}
|
|
18
|
+
if (typeof value === 'number' || typeof value === 'boolean')
|
|
19
|
+
return value;
|
|
20
|
+
if (Array.isArray(value))
|
|
21
|
+
return value.map((v) => redactSensitive(v));
|
|
22
|
+
if (typeof value === 'object') {
|
|
23
|
+
const out = {};
|
|
24
|
+
for (const [k, v] of Object.entries(value)) {
|
|
25
|
+
if (SECRET_KEY_RE.test(k)) {
|
|
26
|
+
out[k] = '[REDACTED]';
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
out[k] = redactSensitive(v);
|
|
30
|
+
}
|
|
31
|
+
return out;
|
|
32
|
+
}
|
|
33
|
+
return String(value);
|
|
34
|
+
}
|
|
35
|
+
export function sha256Prefix(value, chars = 12) {
|
|
36
|
+
const raw = typeof value === 'string' ? value : JSON.stringify(value);
|
|
37
|
+
return createHash('sha256').update(raw).digest('hex').slice(0, chars).toUpperCase();
|
|
38
|
+
}
|
|
39
|
+
export function extractDryRunChecks(payload) {
|
|
40
|
+
const body = payload && typeof payload === 'object' && 'body' in payload
|
|
41
|
+
? payload.body
|
|
42
|
+
: payload;
|
|
43
|
+
const meta = body && typeof body === 'object' && 'session_metadata' in body
|
|
44
|
+
? body.session_metadata
|
|
45
|
+
: body && typeof body === 'object'
|
|
46
|
+
? body
|
|
47
|
+
: null;
|
|
48
|
+
const srv = meta && typeof meta === 'object'
|
|
49
|
+
? meta.service_runtime_v0
|
|
50
|
+
: undefined;
|
|
51
|
+
const collector = srv && typeof srv.collector === 'string' ? String(srv.collector) : null;
|
|
52
|
+
const hasMeta = Boolean(meta && typeof meta === 'object');
|
|
53
|
+
const hasSrv = Boolean(srv && typeof srv === 'object');
|
|
54
|
+
return {
|
|
55
|
+
ok: hasMeta && hasSrv,
|
|
56
|
+
has_session_metadata: hasMeta,
|
|
57
|
+
has_service_runtime_v0: hasSrv,
|
|
58
|
+
collector,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
export function buildDryRunArtifact(input) {
|
|
62
|
+
const redacted = redactSensitive(input.payload);
|
|
63
|
+
const checks = extractDryRunChecks(input.payload);
|
|
64
|
+
const sha = String(input.gitSha || 'unknown').trim() || 'unknown';
|
|
65
|
+
return {
|
|
66
|
+
schema: DRY_RUN_ARTIFACT_SCHEMA,
|
|
67
|
+
git_sha: sha,
|
|
68
|
+
git_sha_short: sha === 'unknown' ? 'unknown' : sha.slice(0, 7),
|
|
69
|
+
node_version: input.nodeVersion || process.version,
|
|
70
|
+
package_name: input.packageName || '@netgreener/runtime',
|
|
71
|
+
package_version: input.packageVersion || '0.0.0',
|
|
72
|
+
created_at: input.createdAt || new Date().toISOString(),
|
|
73
|
+
dry_run: true,
|
|
74
|
+
upload: 'never',
|
|
75
|
+
sources: input.sources?.length ? [...input.sources] : ['express-dry-run'],
|
|
76
|
+
checks,
|
|
77
|
+
payload_sha256_prefix: sha256Prefix(redacted),
|
|
78
|
+
payload_redacted: redacted,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
export function writeDryRunArtifact(artifactsDir, artifact) {
|
|
82
|
+
mkdirSync(artifactsDir, { recursive: true });
|
|
83
|
+
const stamp = artifact.created_at.replace(/[:.]/g, '-');
|
|
84
|
+
const summaryPath = join(artifactsDir, `${artifact.git_sha_short}-${stamp}-summary.json`);
|
|
85
|
+
const latestPath = join(artifactsDir, 'latest-summary.json');
|
|
86
|
+
const json = `${JSON.stringify(artifact, null, 2)}\n`;
|
|
87
|
+
writeFileSync(summaryPath, json, 'utf8');
|
|
88
|
+
writeFileSync(latestPath, json, 'utf8');
|
|
89
|
+
return { summaryPath, latestPath };
|
|
90
|
+
}
|
|
91
|
+
/** Fail closed if redacted text still looks like a live token. */
|
|
92
|
+
export function assertArtifactRedacted(artifact) {
|
|
93
|
+
const blob = JSON.stringify(artifact);
|
|
94
|
+
if (/\bngs_[A-Za-z0-9._-]{8,}\b/.test(blob)) {
|
|
95
|
+
throw new Error('dry-run artifact still contains an ngs_ token (redaction failed)');
|
|
96
|
+
}
|
|
97
|
+
if (/\bBearer\s+[A-Za-z0-9._~+/=-]{8,}\b/i.test(blob)) {
|
|
98
|
+
throw new Error('dry-run artifact still contains a Bearer token (redaction failed)');
|
|
99
|
+
}
|
|
100
|
+
if (artifact.upload !== 'never' || artifact.dry_run !== true) {
|
|
101
|
+
throw new Error('dry-run artifact must declare dry_run=true and upload=never');
|
|
102
|
+
}
|
|
103
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gates for Node tenant dogfood (experimental v0).
|
|
3
|
+
*
|
|
4
|
+
* Keeps vendor stubs narrow and live uploads honest:
|
|
5
|
+
* - Stub decision uses **parsed hostname** boundaries (not path/query substrings).
|
|
6
|
+
* - Configured NetGreener API destination always takes precedence (never stubbed).
|
|
7
|
+
* - Failed live uploads must fail the run.
|
|
8
|
+
*/
|
|
9
|
+
/** Registrable / DNS suffixes for vendor hosts dogfood may stub. */
|
|
10
|
+
export declare const VENDOR_FETCH_HOST_SUFFIXES: readonly ["cognitive.microsoft.com", "cognitiveservices.azure.com", "openai.com", "openai.azure.com"];
|
|
11
|
+
/** @deprecated Use VENDOR_FETCH_HOST_SUFFIXES (hostname suffixes, not URL substrings). */
|
|
12
|
+
export declare const VENDOR_FETCH_HOST_MARKERS: readonly ["cognitive.microsoft.com", "cognitiveservices.azure.com", "openai.com", "openai.azure.com"];
|
|
13
|
+
export type DogfoodUploadResult = {
|
|
14
|
+
ok: true;
|
|
15
|
+
status: number;
|
|
16
|
+
body?: unknown;
|
|
17
|
+
dryRun?: boolean;
|
|
18
|
+
} | {
|
|
19
|
+
ok: false;
|
|
20
|
+
status?: number;
|
|
21
|
+
error: string;
|
|
22
|
+
body?: unknown;
|
|
23
|
+
dryRun?: boolean;
|
|
24
|
+
};
|
|
25
|
+
export type VendorStubOptions = {
|
|
26
|
+
/** Configured NetGreener API base URL — its host is never stubbed. */
|
|
27
|
+
apiBaseUrl?: string | null;
|
|
28
|
+
};
|
|
29
|
+
export declare function resolveFetchUrl(input: RequestInfo | URL): string;
|
|
30
|
+
/**
|
|
31
|
+
* Extract hostname from a fetch destination. Returns null when the input is
|
|
32
|
+
* not an absolute http(s) URL (relative paths are not stubbed).
|
|
33
|
+
*/
|
|
34
|
+
export declare function parseFetchHostname(input: RequestInfo | URL | string): string | null;
|
|
35
|
+
/** True when hostname is exactly a vendor suffix or a subdomain of one. */
|
|
36
|
+
export declare function hostnameMatchesVendor(hostname: string): boolean;
|
|
37
|
+
export declare function parseApiHostname(apiBaseUrl?: string | null): string | null;
|
|
38
|
+
/** Explicit API destination never stubs, even on a lookalike/vendor-named host. */
|
|
39
|
+
export declare function isConfiguredApiDestination(hostname: string, apiBaseUrl?: string | null): boolean;
|
|
40
|
+
/**
|
|
41
|
+
* True only for explicit vendor hostnames that dogfood may stub.
|
|
42
|
+
* Path/query substrings and lookalike hosts do not match.
|
|
43
|
+
* Configured API host always wins (real HTTP).
|
|
44
|
+
*/
|
|
45
|
+
export declare function shouldStubVendorFetch(input: RequestInfo | URL | string, opts?: VendorStubOptions): boolean;
|
|
46
|
+
export declare function stubVendorResponse(input: RequestInfo | URL | string): Response;
|
|
47
|
+
/**
|
|
48
|
+
* Wrap fetch so only known vendor **hostnames** are stubbed.
|
|
49
|
+
* The configured API destination always uses real HTTP.
|
|
50
|
+
*/
|
|
51
|
+
export declare function createVendorAwareFetch(realFetch: typeof fetch, opts?: VendorStubOptions): typeof fetch;
|
|
52
|
+
export declare function assertUploadForMode(label: string, result: DogfoodUploadResult | null | undefined, opts: {
|
|
53
|
+
live: boolean;
|
|
54
|
+
}): {
|
|
55
|
+
ok: boolean;
|
|
56
|
+
reason?: string;
|
|
57
|
+
};
|
|
58
|
+
/**
|
|
59
|
+
* In live mode, never treat a local onFlush payload as proof of persistence.
|
|
60
|
+
* In dry-run, allow the dry-run body or the captured onFlush payload.
|
|
61
|
+
*/
|
|
62
|
+
export declare function sessionMetadataForDogfood(opts: {
|
|
63
|
+
live: boolean;
|
|
64
|
+
flushResult: DogfoodUploadResult | null | undefined;
|
|
65
|
+
flushPayload?: {
|
|
66
|
+
session_metadata?: unknown;
|
|
67
|
+
} | null;
|
|
68
|
+
label?: string;
|
|
69
|
+
}): {
|
|
70
|
+
sessionMetadata: Record<string, unknown> | null;
|
|
71
|
+
uploadGate: {
|
|
72
|
+
ok: boolean;
|
|
73
|
+
reason?: string;
|
|
74
|
+
};
|
|
75
|
+
};
|
|
76
|
+
export declare function extractPersistedRunId(uploadBody: unknown): number | string | null;
|