@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.
Files changed (138) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/README.md +28 -0
  3. package/TUTORIAL.md +495 -0
  4. package/dist/aggregator.d.ts +43 -0
  5. package/dist/aggregator.js +270 -0
  6. package/dist/analyze/analyzeGate.d.ts +44 -0
  7. package/dist/analyze/analyzeGate.js +114 -0
  8. package/dist/analyze/apiOverconsumptionDetector.d.ts +13 -0
  9. package/dist/analyze/apiOverconsumptionDetector.js +75 -0
  10. package/dist/analyze/astDetectors.d.ts +38 -0
  11. package/dist/analyze/astDetectors.js +566 -0
  12. package/dist/analyze/buildResourceFindings.d.ts +34 -0
  13. package/dist/analyze/buildResourceFindings.js +86 -0
  14. package/dist/analyze/cli.d.ts +20 -0
  15. package/dist/analyze/cli.js +186 -0
  16. package/dist/analyze/cpuResourceDetectors.d.ts +35 -0
  17. package/dist/analyze/cpuResourceDetectors.js +163 -0
  18. package/dist/analyze/heuristicScan.d.ts +61 -0
  19. package/dist/analyze/heuristicScan.js +91 -0
  20. package/dist/analyze/index.d.ts +15 -0
  21. package/dist/analyze/index.js +15 -0
  22. package/dist/analyze/loadTypescript.d.ts +8 -0
  23. package/dist/analyze/loadTypescript.js +23 -0
  24. package/dist/analyze/memoryIoDetectors.d.ts +15 -0
  25. package/dist/analyze/memoryIoDetectors.js +68 -0
  26. package/dist/analyze/preferAstDetectors.d.ts +18 -0
  27. package/dist/analyze/preferAstDetectors.js +91 -0
  28. package/dist/analyze/projectScan.d.ts +19 -0
  29. package/dist/analyze/projectScan.js +115 -0
  30. package/dist/analyze/resourceAdmission.d.ts +33 -0
  31. package/dist/analyze/resourceAdmission.js +91 -0
  32. package/dist/analyze/resourceFindingCandidate.d.ts +61 -0
  33. package/dist/analyze/resourceFindingCandidate.js +75 -0
  34. package/dist/analyze/retryAmplificationDetector.d.ts +11 -0
  35. package/dist/analyze/retryAmplificationDetector.js +55 -0
  36. package/dist/analyze/schemas/resource-finding.schema.json +352 -0
  37. package/dist/analyze/serviceDiscovery.d.ts +66 -0
  38. package/dist/analyze/serviceDiscovery.js +210 -0
  39. package/dist/analyze/unboundedParallelismDetector.d.ts +11 -0
  40. package/dist/analyze/unboundedParallelismDetector.js +57 -0
  41. package/dist/analyze/validateResourceFinding.d.ts +19 -0
  42. package/dist/analyze/validateResourceFinding.js +46 -0
  43. package/dist/bullmq.d.ts +33 -0
  44. package/dist/bullmq.js +106 -0
  45. package/dist/cli/helpText.d.ts +8 -0
  46. package/dist/cli/helpText.js +99 -0
  47. package/dist/cli/netgreener.d.ts +12 -0
  48. package/dist/cli/netgreener.js +65 -0
  49. package/dist/collectorIpc.d.ts +28 -0
  50. package/dist/collectorIpc.js +189 -0
  51. package/dist/collectorMetadata.d.ts +15 -0
  52. package/dist/collectorMetadata.js +33 -0
  53. package/dist/config.d.ts +41 -0
  54. package/dist/config.js +97 -0
  55. package/dist/contract/index.d.ts +1 -0
  56. package/dist/contract/index.js +1 -0
  57. package/dist/contract/observation-envelope.schema.json +331 -0
  58. package/dist/contract/observation-protobuf-view.schema.json +773 -0
  59. package/dist/contract/observation-v1.d.mts +42 -0
  60. package/dist/contract/observation-v1.mjs +555 -0
  61. package/dist/contract/observationIdentity.d.ts +11 -0
  62. package/dist/contract/observationIdentity.js +48 -0
  63. package/dist/contract/observationProjection.d.ts +3 -0
  64. package/dist/contract/observationProjection.js +29 -0
  65. package/dist/contract/validate.d.ts +20 -0
  66. package/dist/contract/validate.js +227 -0
  67. package/dist/dogfood/mp3WorkerGates.d.ts +54 -0
  68. package/dist/dogfood/mp3WorkerGates.js +86 -0
  69. package/dist/dogfood/retainDryRunArtifact.d.ts +45 -0
  70. package/dist/dogfood/retainDryRunArtifact.js +103 -0
  71. package/dist/dogfood/tenantDogfoodGates.d.ts +76 -0
  72. package/dist/dogfood/tenantDogfoodGates.js +206 -0
  73. package/dist/exporter.d.ts +56 -0
  74. package/dist/exporter.js +304 -0
  75. package/dist/express.d.ts +44 -0
  76. package/dist/express.js +118 -0
  77. package/dist/externalApiMeter.d.ts +72 -0
  78. package/dist/externalApiMeter.js +1167 -0
  79. package/dist/fastify.d.ts +53 -0
  80. package/dist/fastify.js +148 -0
  81. package/dist/index.d.ts +32 -0
  82. package/dist/index.js +31 -0
  83. package/dist/n4/deploymentMatrix.d.ts +22 -0
  84. package/dist/n4/deploymentMatrix.js +57 -0
  85. package/dist/n4/missingnessInventory.d.ts +22 -0
  86. package/dist/n4/missingnessInventory.js +72 -0
  87. package/dist/nest.d.ts +42 -0
  88. package/dist/nest.js +87 -0
  89. package/dist/processResources.d.ts +28 -0
  90. package/dist/processResources.js +31 -0
  91. package/dist/processRuntime.d.ts +35 -0
  92. package/dist/processRuntime.js +96 -0
  93. package/dist/requestContext.d.ts +21 -0
  94. package/dist/requestContext.js +33 -0
  95. package/dist/runtime.d.ts +60 -0
  96. package/dist/runtime.js +289 -0
  97. package/dist/runtimeHealth.d.ts +35 -0
  98. package/dist/runtimeHealth.js +121 -0
  99. package/dist/serverlessHints.d.ts +26 -0
  100. package/dist/serverlessHints.js +52 -0
  101. package/dist/tenantContext.d.ts +78 -0
  102. package/dist/tenantContext.js +226 -0
  103. package/dist/tenantDefaults.d.ts +9 -0
  104. package/dist/tenantDefaults.js +9 -0
  105. package/dist/types.d.ts +131 -0
  106. package/dist/types.js +2 -0
  107. package/dist/uploader.d.ts +23 -0
  108. package/dist/uploader.js +52 -0
  109. package/dist/windowId.d.ts +2 -0
  110. package/dist/windowId.js +7 -0
  111. package/examples/analyze-manifest.mjs +30 -0
  112. package/examples/bullmq-live-smoke.mjs +178 -0
  113. package/examples/bullmq-mp3-worker-gate.mjs +237 -0
  114. package/examples/express-dry-run.mjs +72 -0
  115. package/examples/process-mp3-restart-gate.mjs +135 -0
  116. package/examples/retain-dry-run.mjs +110 -0
  117. package/examples/tenant-dogfood.mjs +269 -0
  118. package/fixtures/analyze-sample/architectureNoise.ts +13 -0
  119. package/fixtures/analyze-sample/cpuHotPaths.ts +27 -0
  120. package/fixtures/analyze-sample/fanOutClient.ts +15 -0
  121. package/fixtures/analyze-sample/inferenceHotPaths.ts +22 -0
  122. package/fixtures/analyze-sample/memoryIoHotPaths.ts +21 -0
  123. package/fixtures/analyze-sample/modelLoadHotPaths.ts +11 -0
  124. package/fixtures/analyze-sample/nestedLookup.ts +15 -0
  125. package/fixtures/analyze-sample/retryClient.ts +10 -0
  126. package/fixtures/analyze-sample/securitySmell.ts +8 -0
  127. package/fixtures/analyze-sample/server.ts +11 -0
  128. package/fixtures/analyze-sample/styleOnly.ts +7 -0
  129. package/fixtures/analyze-sample/worker.ts +4 -0
  130. package/fixtures/dual-view-projection.v1.json +53 -0
  131. package/fixtures/identity-preimages.v1.json +42 -0
  132. package/fixtures/mp2_shadow_digest_golden.v1.json +47 -0
  133. package/fixtures/projection-batch-document.v1.json +170 -0
  134. package/fixtures/projection-batch.v1.json +9 -0
  135. package/fixtures/python_reference_shapes.md +24 -0
  136. package/fixtures/session_metadata_runtime_v0.json +120 -0
  137. package/fixtures/session_metadata_with_tenant.json +85 -0
  138. package/package.json +137 -0
@@ -0,0 +1,52 @@
1
+ import { validateRuntimeSessionMetadata } from './contract/validate.js';
2
+ export async function uploadRunSession(config, payload, fetchImpl = fetch) {
3
+ const validation = validateRuntimeSessionMetadata(payload.session_metadata);
4
+ if (!validation.ok) {
5
+ return {
6
+ ok: false,
7
+ error: `session_metadata contract failed: ${JSON.stringify(validation.issues)}`,
8
+ };
9
+ }
10
+ if (config.dryRun) {
11
+ return { ok: true, status: 0, body: payload, dryRun: true };
12
+ }
13
+ const url = `${config.apiUrl}/api/v1/runsessions/`;
14
+ const headers = {
15
+ Authorization: `Bearer ${config.token}`,
16
+ 'Content-Type': 'application/json',
17
+ Accept: 'application/json',
18
+ };
19
+ if (config.organizationId != null) {
20
+ headers['X-Organization-ID'] = String(config.organizationId);
21
+ }
22
+ try {
23
+ const res = await fetchImpl(url, {
24
+ method: 'POST',
25
+ headers,
26
+ body: JSON.stringify(payload),
27
+ });
28
+ const text = await res.text();
29
+ let body = text;
30
+ try {
31
+ body = text ? JSON.parse(text) : null;
32
+ }
33
+ catch {
34
+ /* keep text */
35
+ }
36
+ if (!res.ok) {
37
+ return {
38
+ ok: false,
39
+ status: res.status,
40
+ error: `upload failed HTTP ${res.status}`,
41
+ body,
42
+ };
43
+ }
44
+ return { ok: true, status: res.status, body };
45
+ }
46
+ catch (err) {
47
+ return {
48
+ ok: false,
49
+ error: err instanceof Error ? err.message : String(err),
50
+ };
51
+ }
52
+ }
@@ -0,0 +1,2 @@
1
+ /** api_server pattern: rtw_ + 64 lowercase hex (68 chars total). */
2
+ export declare function newRuntimeWindowId(): string;
@@ -0,0 +1,7 @@
1
+ import { createHash, randomBytes } from 'node:crypto';
2
+ /** api_server pattern: rtw_ + 64 lowercase hex (68 chars total). */
3
+ export function newRuntimeWindowId() {
4
+ const material = `${Date.now()}-${randomBytes(32).toString('hex')}`;
5
+ const digest = createHash('sha256').update(material).digest('hex');
6
+ return `rtw_${digest}`;
7
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * MP4 Analyze deepen demo — disk scan + resource_finding_v1 candidates.
3
+ *
4
+ * npm run example:analyze-manifest
5
+ *
6
+ * Still not CLI/VSCE wired; candidates only (no Optimize / verified savings).
7
+ */
8
+
9
+ import { dirname, join } from 'node:path'
10
+ import { fileURLToPath } from 'node:url'
11
+
12
+ import {
13
+ buildResourceAnalyzeResultFromPaths,
14
+ isServiceProject,
15
+ } from '../dist/analyze/index.js'
16
+
17
+ const root = join(dirname(fileURLToPath(import.meta.url)), '../fixtures/analyze-sample')
18
+ const result = buildResourceAnalyzeResultFromPaths(root, null, { projectId: '42' })
19
+
20
+ console.log(JSON.stringify(result, null, 2))
21
+ console.log(
22
+ isServiceProject(result.manifest)
23
+ ? 'ANALYZE MANIFEST PASS (service project)'
24
+ : 'ANALYZE MANIFEST: no service units detected',
25
+ )
26
+ console.log(
27
+ result.findings.length > 0
28
+ ? `ANALYZE FINDINGS PASS (${result.findings.length} resource_finding_v1 candidate(s))`
29
+ : 'ANALYZE FINDINGS: no candidates (unexpected for fixtures/analyze-sample)',
30
+ )
@@ -0,0 +1,178 @@
1
+ /**
2
+ * Real BullMQ + Redis live smoke (experimental N3).
3
+ *
4
+ * Requires:
5
+ * - Redis (e.g. docker run -d --name netgreener-redis -p 6379:6379 redis:7-alpine)
6
+ * - Local peers: `npm install bullmq ioredis --no-save`
7
+ * - Live env (token / project / API); dry-run by default
8
+ *
9
+ * $env:NETGREENER_RUNTIME_DRY_RUN="0"
10
+ * $env:NETGREENER_API_URL="https://core-api.netgreener.com"
11
+ * $env:NETGREENER_TOKEN="ngs_..."
12
+ * $env:NETGREENER_PROJECT_ID="97"
13
+ * node examples/bullmq-live-smoke.mjs
14
+ *
15
+ * Default export mode remains direct. Does not claim MP3 / managed sidecar.
16
+ */
17
+
18
+ import { Queue, Worker } from 'bullmq'
19
+ import IORedis from 'ioredis'
20
+
21
+ import {
22
+ _resetOutboundInstrumentationForTests,
23
+ _resetRuntimeForTests,
24
+ getBullMqRuntime,
25
+ netgreenerBullMqProcessor,
26
+ validateExternalApiV0,
27
+ validateRuntimeSessionMetadata,
28
+ } from '../dist/index.js'
29
+ import {
30
+ extractPersistedRunId,
31
+ sessionMetadataForDogfood,
32
+ } from '../dist/dogfood/tenantDogfoodGates.js'
33
+
34
+ _resetRuntimeForTests()
35
+ _resetOutboundInstrumentationForTests()
36
+
37
+ const dryRun =
38
+ (process.env.NETGREENER_RUNTIME_DRY_RUN || '1').trim() !== '0' &&
39
+ (process.env.NETGREENER_RUNTIME_DRY_RUN || '1').trim().toLowerCase() !== 'false'
40
+ const live = !dryRun
41
+
42
+ process.env.NETGREENER_SERVICE_RUNTIME = '1'
43
+ process.env.NETGREENER_TOKEN = process.env.NETGREENER_TOKEN || 'ngs_local_bullmq_smoke'
44
+ process.env.NETGREENER_PROJECT_ID = process.env.NETGREENER_PROJECT_ID || '97'
45
+ process.env.NETGREENER_RUNTIME_DRY_RUN = dryRun ? '1' : '0'
46
+ process.env.NETGREENER_API_URL =
47
+ process.env.NETGREENER_API_URL || 'https://core-api.netgreener.com'
48
+ process.env.NETGREENER_TENANT_SOURCE = process.env.NETGREENER_TENANT_SOURCE || 'task_kwarg'
49
+ process.env.NETGREENER_TENANT_TASK_KWARG =
50
+ process.env.NETGREENER_TENANT_TASK_KWARG || 'organization_id'
51
+ // Leave EXPORT_MODE unset → default direct
52
+ delete process.env.NETGREENER_EXPORT_MODE
53
+
54
+ const connection = new IORedis({
55
+ host: process.env.NETGREENER_REDIS_HOST || '127.0.0.1',
56
+ port: Number(process.env.NETGREENER_REDIS_PORT || '6379'),
57
+ maxRetriesPerRequest: null,
58
+ })
59
+ const queueName = process.env.NETGREENER_BULLMQ_QUEUE || 'netgreener-bullmq-smoke'
60
+
61
+ console.log(
62
+ `mode: ${live ? 'LIVE upload' : 'DRY-RUN'} redis=${connection.options.host}:${connection.options.port} queue=${queueName} project=${process.env.NETGREENER_PROJECT_ID}`,
63
+ )
64
+
65
+ const flushed = []
66
+ const processor = netgreenerBullMqProcessor(
67
+ async (job) => {
68
+ // Light outbound so external_api_v0 can appear when metering is on.
69
+ try {
70
+ await fetch('https://api.openai.com/v1/models', {
71
+ method: 'GET',
72
+ headers: { Authorization: 'Bearer unused' },
73
+ })
74
+ } catch {
75
+ /* ignore network / auth noise — metering still records attempt if wrapped */
76
+ }
77
+ return { ok: true, organization_id: job.data?.organization_id }
78
+ },
79
+ {
80
+ onFlush: (result, payload) => {
81
+ flushed.push({ result, payload })
82
+ const mode = result.ok
83
+ ? result.dryRun
84
+ ? 'dry-run ok'
85
+ : `HTTP ${result.status}`
86
+ : `FAIL ${result.error}`
87
+ console.log(`[flush bullmq-real] ${mode}`)
88
+ },
89
+ },
90
+ )
91
+
92
+ const worker = new Worker(queueName, processor, { connection, concurrency: 1 })
93
+ const queue = new Queue(queueName, { connection })
94
+
95
+ const jobDone = new Promise((resolve, reject) => {
96
+ worker.on('completed', (job, result) => resolve({ job, result }))
97
+ worker.on('failed', (job, err) => reject(err))
98
+ })
99
+
100
+ try {
101
+ await queue.add(
102
+ 'analyze_document',
103
+ { organization_id: 'org_bullmq_live', document_id: 'smoke-doc-1' },
104
+ { removeOnComplete: 10, removeOnFail: 10 },
105
+ )
106
+ const { job, result } = await jobDone
107
+ console.log(`[job] completed id=${job?.id} name=${job?.name} result=${JSON.stringify(result)}`)
108
+
109
+ const flushResult = await getBullMqRuntime().flush('manual')
110
+ if (!flushResult) {
111
+ throw new Error('flush returned null — no runtime/external samples recorded')
112
+ }
113
+
114
+ const flushPayload = flushed.at(-1)?.payload
115
+ const { sessionMetadata, uploadGate } = sessionMetadataForDogfood({
116
+ live,
117
+ flushResult,
118
+ flushPayload,
119
+ label: 'bullmq-real',
120
+ })
121
+ if (!uploadGate.ok) {
122
+ console.error(`[bullmq-real] ${uploadGate.reason}`)
123
+ process.exitCode = 1
124
+ } else {
125
+ const contract = validateRuntimeSessionMetadata(sessionMetadata)
126
+ if (!contract.ok) {
127
+ console.error('[bullmq-real] contract failed', contract.issues)
128
+ process.exitCode = 1
129
+ } else {
130
+ const ext = sessionMetadata.external_api_v0
131
+ if (ext) {
132
+ const extOk = validateExternalApiV0(ext)
133
+ if (!extOk.ok) console.warn('[bullmq-real] external_api_v0 issues', extOk.issues)
134
+ }
135
+ const runtime = sessionMetadata.service_runtime_v0
136
+ console.log(
137
+ `[bullmq-real] collector=${runtime?.collector} units=${(runtime?.units || [])
138
+ .map((u) => u.service_unit)
139
+ .join(',')}`,
140
+ )
141
+ console.log(
142
+ `[bullmq-real] by_tenant=${Object.keys(runtime?.by_tenant || {}).join(',') || '(none)'}`,
143
+ )
144
+ if (live) {
145
+ const runId = extractPersistedRunId(flushResult.body)
146
+ console.log(
147
+ `[bullmq-real] live upload ok status=${flushResult.status} run_id=${runId ?? 'n/a'} runtime_window_id=${flushPayload?.runtime_window_id ?? 'n/a'}`,
148
+ )
149
+ }
150
+ console.log(
151
+ live
152
+ ? 'BULLMQ LIVE SMOKE PASS (real Worker + Redis + upload)'
153
+ : 'BULLMQ DRY-RUN SMOKE PASS (real Worker + Redis)',
154
+ )
155
+ }
156
+ }
157
+ } catch (err) {
158
+ console.error('BULLMQ SMOKE FAIL', err)
159
+ process.exitCode = 1
160
+ } finally {
161
+ try {
162
+ await worker.close()
163
+ } catch {
164
+ /* ignore */
165
+ }
166
+ try {
167
+ await queue.close()
168
+ } catch {
169
+ /* ignore */
170
+ }
171
+ try {
172
+ await connection.quit()
173
+ } catch {
174
+ /* ignore */
175
+ }
176
+ _resetRuntimeForTests()
177
+ _resetOutboundInstrumentationForTests()
178
+ }
@@ -0,0 +1,237 @@
1
+ /**
2
+ * MP3 BullMQ worker gate — concurrency + restart (experimental).
3
+ *
4
+ * Requires Redis + optional peers:
5
+ * docker run -d --name netgreener-redis -p 6379:6379 redis:7-alpine
6
+ * npm install bullmq ioredis --no-save
7
+ *
8
+ * Dry-run (default — no api_server upload):
9
+ * npm run example:bullmq-mp3-gate
10
+ *
11
+ * Live upload (authorized only):
12
+ * $env:NETGREENER_RUNTIME_DRY_RUN="0"
13
+ * $env:NETGREENER_API_URL="http://127.0.0.1:8000"
14
+ * $env:NETGREENER_TOKEN="ngs_..."
15
+ * $env:NETGREENER_PROJECT_ID="97"
16
+ * npm run example:bullmq-mp3-gate
17
+ *
18
+ * Does NOT claim MP3 production support, managed sidecar, or npm publish.
19
+ * Default export mode remains direct.
20
+ */
21
+
22
+ import { Queue, Worker } from 'bullmq'
23
+ import IORedis from 'ioredis'
24
+
25
+ import {
26
+ _resetOutboundInstrumentationForTests,
27
+ _resetRuntimeForTests,
28
+ getBullMqRuntime,
29
+ netgreenerBullMqProcessor,
30
+ } from '../dist/index.js'
31
+ import {
32
+ extractPersistedRunId,
33
+ sessionMetadataForDogfood,
34
+ } from '../dist/dogfood/tenantDogfoodGates.js'
35
+ import {
36
+ assertMp3ConcurrencyGate,
37
+ assertMp3RestartGate,
38
+ formatMp3GateFailure,
39
+ } from '../dist/dogfood/mp3WorkerGates.js'
40
+
41
+ _resetRuntimeForTests()
42
+ _resetOutboundInstrumentationForTests()
43
+
44
+ const dryRun =
45
+ (process.env.NETGREENER_RUNTIME_DRY_RUN || '1').trim() !== '0' &&
46
+ (process.env.NETGREENER_RUNTIME_DRY_RUN || '1').trim().toLowerCase() !== 'false'
47
+ const live = !dryRun
48
+
49
+ process.env.NETGREENER_SERVICE_RUNTIME = '1'
50
+ process.env.NETGREENER_TOKEN = process.env.NETGREENER_TOKEN || 'ngs_local_bullmq_mp3'
51
+ process.env.NETGREENER_PROJECT_ID = process.env.NETGREENER_PROJECT_ID || '97'
52
+ process.env.NETGREENER_RUNTIME_DRY_RUN = dryRun ? '1' : '0'
53
+ process.env.NETGREENER_API_URL =
54
+ process.env.NETGREENER_API_URL || 'http://127.0.0.1:8000'
55
+ process.env.NETGREENER_TENANT_SOURCE = process.env.NETGREENER_TENANT_SOURCE || 'task_kwarg'
56
+ process.env.NETGREENER_TENANT_TASK_KWARG =
57
+ process.env.NETGREENER_TENANT_TASK_KWARG || 'organization_id'
58
+ delete process.env.NETGREENER_EXPORT_MODE
59
+
60
+ const host = process.env.NETGREENER_REDIS_HOST || '127.0.0.1'
61
+ const port = Number(process.env.NETGREENER_REDIS_PORT || '6379')
62
+ const queueName = process.env.NETGREENER_BULLMQ_MP3_QUEUE || 'netgreener-bullmq-mp3-gate'
63
+ const concurrency = Math.max(2, Number(process.env.NETGREENER_BULLMQ_CONCURRENCY || '2'))
64
+ const jobCount = Math.max(concurrency * 2, Number(process.env.NETGREENER_BULLMQ_JOBS || '4'))
65
+ const tenant = 'org_bullmq_mp3'
66
+ const taskName = 'analyze_document'
67
+ const taskUnit = `task:${taskName}`
68
+
69
+ function makeProcessor(label) {
70
+ const flushed = []
71
+ const processor = netgreenerBullMqProcessor(
72
+ async (job) => {
73
+ await new Promise((r) => setTimeout(r, 30))
74
+ return { ok: true, organization_id: job.data?.organization_id, phase: label }
75
+ },
76
+ {
77
+ onFlush: (result, payload) => {
78
+ flushed.push({ result, payload })
79
+ },
80
+ },
81
+ )
82
+ return { processor, flushed }
83
+ }
84
+
85
+ async function waitJobs(worker, n, timeoutMs = 30000) {
86
+ let done = 0
87
+ return new Promise((resolve, reject) => {
88
+ const timer = setTimeout(() => {
89
+ reject(new Error(`timeout waiting for ${n} jobs (got ${done})`))
90
+ }, timeoutMs)
91
+ const onCompleted = () => {
92
+ done += 1
93
+ if (done >= n) {
94
+ clearTimeout(timer)
95
+ worker.off('completed', onCompleted)
96
+ worker.off('failed', onFailed)
97
+ resolve(done)
98
+ }
99
+ }
100
+ const onFailed = (_job, err) => {
101
+ clearTimeout(timer)
102
+ worker.off('completed', onCompleted)
103
+ worker.off('failed', onFailed)
104
+ reject(err)
105
+ }
106
+ worker.on('completed', onCompleted)
107
+ worker.on('failed', onFailed)
108
+ })
109
+ }
110
+
111
+ async function flushAndMeta(flushed, label) {
112
+ const flushResult = await getBullMqRuntime().flush('manual')
113
+ if (!flushResult) {
114
+ throw new Error(`${label}: flush returned null`)
115
+ }
116
+ const flushPayload = flushed.at(-1)?.payload
117
+ const { sessionMetadata, uploadGate } = sessionMetadataForDogfood({
118
+ live,
119
+ flushResult,
120
+ flushPayload,
121
+ label,
122
+ })
123
+ if (!uploadGate.ok) {
124
+ throw new Error(`${label}: ${uploadGate.reason}`)
125
+ }
126
+ const runId = live ? extractPersistedRunId(flushResult.body) : null
127
+ return { sessionMetadata, flushResult, runId, windowId: flushPayload?.runtime_window_id }
128
+ }
129
+
130
+ let connection
131
+ let queue
132
+ let exitCode = 0
133
+
134
+ try {
135
+ connection = new IORedis({ host, port, maxRetriesPerRequest: null, lazyConnect: true })
136
+ await connection.connect()
137
+ await connection.ping()
138
+
139
+ console.log(
140
+ `MP3 worker gate mode=${live ? 'LIVE' : 'DRY-RUN'} redis=${host}:${port} queue=${queueName} concurrency=${concurrency} jobs=${jobCount}`,
141
+ )
142
+
143
+ queue = new Queue(queueName, { connection })
144
+ await queue.obliterate({ force: true }).catch(() => {})
145
+
146
+ // --- Phase A: concurrency ---
147
+ _resetRuntimeForTests()
148
+ _resetOutboundInstrumentationForTests()
149
+ const a = makeProcessor('concurrency')
150
+ const workerA = new Worker(queueName, a.processor, { connection, concurrency })
151
+ for (let i = 0; i < jobCount; i += 1) {
152
+ await queue.add(
153
+ taskName,
154
+ { organization_id: tenant, document_id: `c-${i}` },
155
+ { removeOnComplete: 50, removeOnFail: 50 },
156
+ )
157
+ }
158
+ const completedA = await waitJobs(workerA, jobCount)
159
+ const metaA = await flushAndMeta(a.flushed, 'mp3-concurrency')
160
+ const gateA = assertMp3ConcurrencyGate({
161
+ phase: 'concurrency',
162
+ completedJobs: completedA,
163
+ minCompletedJobs: jobCount,
164
+ sessionMetadata: metaA.sessionMetadata,
165
+ expectedTenant: tenant,
166
+ expectedTaskUnit: taskUnit,
167
+ })
168
+ console.log(formatMp3GateFailure(gateA))
169
+ if (!gateA.ok) exitCode = 1
170
+ if (live && metaA.runId != null) {
171
+ console.log(`[concurrency] run_id=${metaA.runId} window=${metaA.windowId ?? 'n/a'}`)
172
+ }
173
+ await workerA.close()
174
+
175
+ // --- Phase B: restart (new Worker after close) ---
176
+ _resetRuntimeForTests()
177
+ _resetOutboundInstrumentationForTests()
178
+ const restartJobs = Math.max(2, concurrency)
179
+ const b = makeProcessor('restart')
180
+ const workerB = new Worker(queueName, b.processor, { connection, concurrency })
181
+ for (let i = 0; i < restartJobs; i += 1) {
182
+ await queue.add(
183
+ taskName,
184
+ { organization_id: tenant, document_id: `r-${i}` },
185
+ { removeOnComplete: 50, removeOnFail: 50 },
186
+ )
187
+ }
188
+ const completedB = await waitJobs(workerB, restartJobs)
189
+ const metaB = await flushAndMeta(b.flushed, 'mp3-restart')
190
+ const gateB = assertMp3RestartGate({
191
+ phase: 'restart',
192
+ completedJobs: completedB,
193
+ minCompletedJobs: restartJobs,
194
+ sessionMetadata: metaB.sessionMetadata,
195
+ expectedTenant: tenant,
196
+ expectedTaskUnit: taskUnit,
197
+ })
198
+ console.log(formatMp3GateFailure(gateB))
199
+ if (!gateB.ok) exitCode = 1
200
+ if (live && metaB.runId != null) {
201
+ console.log(`[restart] run_id=${metaB.runId} window=${metaB.windowId ?? 'n/a'}`)
202
+ }
203
+ await workerB.close()
204
+
205
+ if (exitCode === 0) {
206
+ console.log(
207
+ live
208
+ ? 'MP3 BULLMQ WORKER GATE PASS (concurrency + restart + live upload)'
209
+ : 'MP3 BULLMQ WORKER GATE PASS (concurrency + restart; dry-run)',
210
+ )
211
+ console.log('Not claimed: MP3 production support label, managed sidecar, npm publish')
212
+ }
213
+ } catch (err) {
214
+ const msg = err && typeof err === 'object' && 'message' in err ? err.message : String(err)
215
+ if (/ECONNREFUSED|ENOTFOUND|connect/i.test(msg)) {
216
+ console.error(
217
+ 'MP3 BULLMQ WORKER GATE BLOCKED: Redis not reachable. Start Redis (e.g. docker redis:7-alpine on :6379) and re-run. Unit tests for gate helpers still apply without Redis.',
218
+ )
219
+ } else {
220
+ console.error('MP3 BULLMQ WORKER GATE FAIL', err)
221
+ }
222
+ exitCode = 1
223
+ } finally {
224
+ try {
225
+ if (queue) await queue.close()
226
+ } catch {
227
+ /* ignore */
228
+ }
229
+ try {
230
+ if (connection) await connection.quit()
231
+ } catch {
232
+ /* ignore */
233
+ }
234
+ _resetRuntimeForTests()
235
+ _resetOutboundInstrumentationForTests()
236
+ process.exitCode = exitCode
237
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Local smoke without deploying api_server:
3
+ *
4
+ * cd netgreener_node
5
+ * npm run build
6
+ * node examples/express-dry-run.mjs
7
+ *
8
+ * Prints one dry-run RunSession payload. This script always disables cloud upload,
9
+ * even when the caller's environment contains live credentials or DRY_RUN=false.
10
+ */
11
+
12
+ import { createServer } from 'node:http'
13
+
14
+ import {
15
+ getExpressRuntime,
16
+ loadRuntimeConfig,
17
+ netgreenerExpressMiddleware,
18
+ } from '../dist/index.js'
19
+
20
+ const config = loadRuntimeConfig({
21
+ ...process.env,
22
+ NETGREENER_SERVICE_RUNTIME: '1',
23
+ NETGREENER_TOKEN: process.env.NETGREENER_TOKEN || 'ngs_local_dry_run',
24
+ NETGREENER_PROJECT_ID: process.env.NETGREENER_PROJECT_ID || '1',
25
+ NETGREENER_RUNTIME_DRY_RUN: '1',
26
+ })
27
+ const mw = netgreenerExpressMiddleware({
28
+ config,
29
+ fetchImpl: async () => {
30
+ throw new Error('dry-run smoke attempted an outbound API request')
31
+ },
32
+ onFlush: (result) => {
33
+ console.log('flush:', result.ok ? (result.dryRun ? 'dry-run ok' : `HTTP ${result.status}`) : result.error)
34
+ },
35
+ })
36
+
37
+ const server = createServer((req, res) => {
38
+ const fakeReq = {
39
+ method: req.method,
40
+ path: (req.url || '/').split('?')[0],
41
+ url: req.url,
42
+ }
43
+ const fakeRes = {
44
+ statusCode: 200,
45
+ on(event, fn) {
46
+ if (event === 'finish' || event === 'close') {
47
+ res.on(event, fn)
48
+ }
49
+ return this
50
+ },
51
+ }
52
+ mw(fakeReq, fakeRes, () => {
53
+ res.statusCode = fakeReq.path === '/fail' ? 500 : 200
54
+ fakeRes.statusCode = res.statusCode
55
+ res.end(JSON.stringify({ path: fakeReq.path, status: res.statusCode }))
56
+ })
57
+ })
58
+
59
+ server.listen(0, async () => {
60
+ const { port } = server.address()
61
+ console.log(`dry-run server on http://127.0.0.1:${port}`)
62
+ await fetch(`http://127.0.0.1:${port}/health`)
63
+ await fetch(`http://127.0.0.1:${port}/v1/predict`, { method: 'POST' })
64
+ await fetch(`http://127.0.0.1:${port}/fail`)
65
+ const result = await getExpressRuntime().flush('manual')
66
+ if (!result?.ok || !result.dryRun) {
67
+ throw new Error('dry-run smoke did not return a dry-run result')
68
+ }
69
+ console.log(JSON.stringify(result?.ok ? result.body : result, null, 2))
70
+ server.close()
71
+ process.exit(0)
72
+ })