create-mercato-app 0.7.1-develop.7150.1.c1941e0c22 → 0.7.1-develop.7152.1.a69e92f9c9

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 (119) hide show
  1. package/agentic/shared/ai/harness/cases.json +4 -2
  2. package/dist/agentic/guides/module-facts.json +828 -149
  3. package/dist/agentic/guides/module-facts.v2.json +828 -149
  4. package/dist/agentic/guides/modules/ai_assistant/index.md +1 -1
  5. package/dist/agentic/guides/modules/api_docs/index.md +1 -1
  6. package/dist/agentic/guides/modules/api_keys/index.md +1 -1
  7. package/dist/agentic/guides/modules/attachments/index.md +1 -1
  8. package/dist/agentic/guides/modules/audit_logs/index.md +1 -1
  9. package/dist/agentic/guides/modules/auth/index.md +1 -1
  10. package/dist/agentic/guides/modules/business_rules/index.md +1 -1
  11. package/dist/agentic/guides/modules/catalog/index.md +1 -1
  12. package/dist/agentic/guides/modules/channel_apns/index.md +1 -1
  13. package/dist/agentic/guides/modules/channel_discord/index.md +1 -1
  14. package/dist/agentic/guides/modules/channel_expo/index.md +1 -1
  15. package/dist/agentic/guides/modules/channel_fcm/index.md +1 -1
  16. package/dist/agentic/guides/modules/channel_gmail/index.md +1 -1
  17. package/dist/agentic/guides/modules/channel_imap/index.md +1 -1
  18. package/dist/agentic/guides/modules/channel_resend/acl-features.md +12 -0
  19. package/dist/agentic/guides/modules/channel_resend/contribution-resolutions.md +11 -0
  20. package/dist/agentic/guides/modules/channel_resend/di-registrations-rich.md +12 -0
  21. package/dist/agentic/guides/modules/channel_resend/exact-override-targets.md +16 -0
  22. package/dist/agentic/guides/modules/channel_resend/index.md +18 -0
  23. package/dist/agentic/guides/modules/channel_resend/owned-contract-module-metadata.md +11 -0
  24. package/dist/agentic/guides/modules/channel_resend/setup.md +11 -0
  25. package/dist/agentic/guides/modules/channel_resend/umes-contributions.md +11 -0
  26. package/dist/agentic/guides/modules/channel_ses/acl-features.md +12 -0
  27. package/dist/agentic/guides/modules/channel_ses/contribution-resolutions.md +11 -0
  28. package/dist/agentic/guides/modules/channel_ses/di-registrations-rich.md +12 -0
  29. package/dist/agentic/guides/modules/channel_ses/exact-override-targets.md +16 -0
  30. package/dist/agentic/guides/modules/channel_ses/index.md +18 -0
  31. package/dist/agentic/guides/modules/channel_ses/owned-contract-module-metadata.md +11 -0
  32. package/dist/agentic/guides/modules/channel_ses/setup.md +11 -0
  33. package/dist/agentic/guides/modules/channel_ses/umes-contributions.md +11 -0
  34. package/dist/agentic/guides/modules/checkout/index.md +1 -1
  35. package/dist/agentic/guides/modules/communication_channels/di-registrations-rich.md +10 -9
  36. package/dist/agentic/guides/modules/communication_channels/exact-override-targets.md +12 -11
  37. package/dist/agentic/guides/modules/communication_channels/index.md +1 -1
  38. package/dist/agentic/guides/modules/communication_channels/setup.md +1 -1
  39. package/dist/agentic/guides/modules/configs/index.md +1 -1
  40. package/dist/agentic/guides/modules/content/index.md +1 -1
  41. package/dist/agentic/guides/modules/currencies/index.md +1 -1
  42. package/dist/agentic/guides/modules/customer_accounts/index.md +1 -1
  43. package/dist/agentic/guides/modules/customers/index.md +1 -1
  44. package/dist/agentic/guides/modules/dashboards/index.md +1 -1
  45. package/dist/agentic/guides/modules/data_sync/index.md +1 -1
  46. package/dist/agentic/guides/modules/design_system/index.md +1 -1
  47. package/dist/agentic/guides/modules/devices/index.md +1 -1
  48. package/dist/agentic/guides/modules/dictionaries/index.md +1 -1
  49. package/dist/agentic/guides/modules/directory/index.md +1 -1
  50. package/dist/agentic/guides/modules/documents/index.md +1 -1
  51. package/dist/agentic/guides/modules/entities/index.md +1 -1
  52. package/dist/agentic/guides/modules/eudr/index.md +1 -1
  53. package/dist/agentic/guides/modules/events/index.md +1 -1
  54. package/dist/agentic/guides/modules/feature_toggles/index.md +1 -1
  55. package/dist/agentic/guides/modules/gateway_stripe/index.md +1 -1
  56. package/dist/agentic/guides/modules/generators/index.md +1 -1
  57. package/dist/agentic/guides/modules/inbox_ops/index.md +1 -1
  58. package/dist/agentic/guides/modules/integrations/index.md +1 -1
  59. package/dist/agentic/guides/modules/messages/index.md +1 -1
  60. package/dist/agentic/guides/modules/notifications/index.md +1 -1
  61. package/dist/agentic/guides/modules/onboarding/index.md +1 -1
  62. package/dist/agentic/guides/modules/payment_gateways/index.md +1 -1
  63. package/dist/agentic/guides/modules/perspectives/index.md +1 -1
  64. package/dist/agentic/guides/modules/planner/index.md +1 -1
  65. package/dist/agentic/guides/modules/portal/index.md +1 -1
  66. package/dist/agentic/guides/modules/progress/index.md +1 -1
  67. package/dist/agentic/guides/modules/push_notifications/index.md +1 -1
  68. package/dist/agentic/guides/modules/query_index/index.md +1 -1
  69. package/dist/agentic/guides/modules/record_locks/index.md +1 -1
  70. package/dist/agentic/guides/modules/resources/index.md +1 -1
  71. package/dist/agentic/guides/modules/sales/index.md +1 -1
  72. package/dist/agentic/guides/modules/scheduler/index.md +1 -1
  73. package/dist/agentic/guides/modules/search/index.md +1 -1
  74. package/dist/agentic/guides/modules/security/index.md +1 -1
  75. package/dist/agentic/guides/modules/shipping_carriers/index.md +1 -1
  76. package/dist/agentic/guides/modules/sso/index.md +1 -1
  77. package/dist/agentic/guides/modules/staff/index.md +1 -1
  78. package/dist/agentic/guides/modules/storage_s3/index.md +1 -1
  79. package/dist/agentic/guides/modules/sync_akeneo/index.md +1 -1
  80. package/dist/agentic/guides/modules/sync_excel/index.md +1 -1
  81. package/dist/agentic/guides/modules/system_status_overlays/index.md +1 -1
  82. package/dist/agentic/guides/modules/translations/index.md +1 -1
  83. package/dist/agentic/guides/modules/warranty_claims/index.md +1 -1
  84. package/dist/agentic/guides/modules/webhooks/index.md +1 -1
  85. package/dist/agentic/guides/modules/wms/index.md +1 -1
  86. package/dist/agentic/guides/modules/workflows/index.md +1 -1
  87. package/dist/agentic/guides/reference-module-facts.json +1 -1
  88. package/dist/agentic/guides/upstream/manifest.json +1 -1
  89. package/dist/agentic/shared/ai/harness/cases.json +4 -2
  90. package/package.json +3 -3
  91. package/template/.env.example +46 -1
  92. package/template/package.json.template +2 -0
  93. package/template/scripts/dev-runtime-actions.mjs +99 -0
  94. package/template/scripts/dev-runtime-config.mjs +89 -0
  95. package/template/scripts/dev-runtime-diagnostics.mjs +292 -0
  96. package/template/scripts/dev-runtime-gateway.mjs +370 -0
  97. package/template/scripts/dev-runtime-probe.mjs +77 -0
  98. package/template/scripts/dev-runtime-state.mjs +571 -0
  99. package/template/scripts/dev-runtime-supervisor.mjs +268 -0
  100. package/template/scripts/dev-runtime.mjs +29 -0
  101. package/template/scripts/dev-splash-state.mjs +14 -0
  102. package/template/scripts/dev-splash.html +357 -2
  103. package/template/scripts/dev.mjs +396 -14
  104. package/template/src/app/__tests__/start-page-bootstrap.test.ts +34 -0
  105. package/template/src/app/api/dev-runtime/actions/[action]/route.ts +5 -0
  106. package/template/src/app/api/dev-runtime/diagnostics/route.ts +5 -0
  107. package/template/src/app/api/dev-runtime/logs/route.ts +5 -0
  108. package/template/src/app/api/dev-runtime/status/route.ts +5 -0
  109. package/template/src/app/api/healthz/__tests__/route.test.ts +3 -1
  110. package/template/src/app/global-error.tsx +7 -0
  111. package/template/src/app/layout.tsx +11 -0
  112. package/template/src/app/start/page.tsx +11 -1
  113. package/template/src/i18n/de.json +27 -0
  114. package/template/src/i18n/en.json +27 -0
  115. package/template/src/i18n/es.json +27 -0
  116. package/template/src/i18n/ko.json +27 -0
  117. package/template/src/i18n/pl.json +27 -0
  118. package/template/src/modules/example/__integration__/TC-START-001-onboarding-gate.spec.ts +33 -0
  119. package/template/src/modules.ts +2 -0
@@ -0,0 +1,77 @@
1
+ import { createProbePolicy } from './dev-runtime-state.mjs'
2
+
3
+ export const RUNTIME_HEALTH_PATH = '/api/healthz'
4
+
5
+ async function defaultProbeRequest(url, timeoutMs) {
6
+ const controller = new AbortController()
7
+ const timer = setTimeout(() => controller.abort(), timeoutMs)
8
+ timer.unref?.()
9
+ try {
10
+ const response = await fetch(url, {
11
+ method: 'GET',
12
+ cache: 'no-store',
13
+ headers: { accept: 'application/json' },
14
+ signal: controller.signal,
15
+ })
16
+ return { healthy: response.ok, status: response.status }
17
+ } catch (error) {
18
+ return { healthy: false, error: error instanceof Error ? error.message : String(error) }
19
+ } finally {
20
+ clearTimeout(timer)
21
+ }
22
+ }
23
+
24
+ // Lightweight post-READY liveness check. It never re-runs the login warmup, it
25
+ // never logs a success, and it never exposes dependency details — the app's
26
+ // /api/healthz contract stays a plain 200/503.
27
+ export function createRuntimeProbe(options = {}) {
28
+ const config = options.config ?? {}
29
+ const intervalMs = Number.isInteger(config.intervalMs) ? config.intervalMs : 5000
30
+ const timeoutMs = Number.isInteger(config.timeoutMs) ? config.timeoutMs : 1500
31
+ const resolveBaseUrl = typeof options.resolveBaseUrl === 'function' ? options.resolveBaseUrl : () => null
32
+ const request = typeof options.request === 'function' ? options.request : defaultProbeRequest
33
+ const onDegraded = typeof options.onDegraded === 'function' ? options.onDegraded : () => {}
34
+ const onRecovered = typeof options.onRecovered === 'function' ? options.onRecovered : () => {}
35
+ const shouldRun = typeof options.shouldRun === 'function' ? options.shouldRun : () => true
36
+
37
+ const policy = createProbePolicy(config)
38
+ let timer = null
39
+ let inFlight = false
40
+
41
+ async function tick() {
42
+ if (inFlight) return
43
+ if (!shouldRun()) {
44
+ policy.reset()
45
+ return
46
+ }
47
+ const baseUrl = resolveBaseUrl()
48
+ if (!baseUrl) return
49
+
50
+ inFlight = true
51
+ try {
52
+ const result = await request(`${baseUrl.replace(/\/$/, '')}${RUNTIME_HEALTH_PATH}`, timeoutMs)
53
+ const transition = policy.record(result.healthy === true)
54
+ if (transition === 'degraded') onDegraded(result)
55
+ if (transition === 'recovered') onRecovered(result)
56
+ } finally {
57
+ inFlight = false
58
+ }
59
+ }
60
+
61
+ return {
62
+ tick,
63
+ getCounters: policy.getCounters,
64
+ reset: policy.reset,
65
+ start() {
66
+ if (timer) return
67
+ timer = setInterval(() => { void tick() }, intervalMs)
68
+ timer.unref?.()
69
+ },
70
+ stop() {
71
+ if (!timer) return
72
+ clearInterval(timer)
73
+ timer = null
74
+ policy.reset()
75
+ },
76
+ }
77
+ }
@@ -0,0 +1,571 @@
1
+ import crypto from 'node:crypto'
2
+
3
+ export const RUNTIME_STATUS_SCHEMA_VERSION = 1
4
+ export const MAX_INCIDENTS = 20
5
+ export const MAX_DIAGNOSTIC_LINES = 200
6
+ export const MAX_BROWSER_REPORT_BYTES = 8192
7
+ export const MAX_TITLE_LENGTH = 120
8
+ export const MAX_DETAIL_LENGTH = 400
9
+ export const MAX_STACK_LENGTH = 2000
10
+ export const MAX_LEGACY_FAILURE_LINES = 20
11
+
12
+ export const RUNTIME_SOURCES = ['process', 'log', 'warmup', 'probe', 'browser']
13
+ export const RUNTIME_RECOVERY_ACTIONS = ['generate', 'migrate', 'restart']
14
+
15
+ const REDACTION_RULES = [
16
+ [/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g, '***'],
17
+ [/\b(postgres(?:ql)?|mysql|mariadb|mongodb(?:\+srv)?|rediss?|amqps?)::?\/\/[^\s'"`<>)]+/gi, '$1://***'],
18
+ [/\bBearer\s+[A-Za-z0-9._~+/=-]{8,}/gi, 'Bearer ***'],
19
+ [/\b(authorization|proxy-authorization|x-api-key|x-auth-token)(\s*[:=]\s*)(?:\w+\s+)?\S+/gi, '$1$2***'],
20
+ [/\b(set-cookie|cookie)(\s*[:=]\s*)[^\n]+/gi, '$1$2***'],
21
+ [/\beyJ[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]*/g, '***'],
22
+ [/\b(sk|pk|rk)_(live|test)_[A-Za-z0-9]{8,}/g, '***'],
23
+ [/\bgh[pousr]_[A-Za-z0-9]{16,}/g, '***'],
24
+ [/\b(password|passwd|pwd|secret|token|api[_-]?key|access[_-]?key|private[_-]?key|client[_-]?secret|session[_-]?id)("?\s*[:=]\s*"?)([^\s"',;)}]+)/gi, '$1$2***'],
25
+ ]
26
+
27
+ // Redacts credentials before a signal reaches runtime state, the diagnostic
28
+ // sink, or any status/log response. Applied to every free-text field.
29
+ export function redactSensitiveText(value, maxLength = MAX_DETAIL_LENGTH) {
30
+ if (value == null) return undefined
31
+ let text = String(value)
32
+ for (const [pattern, replacement] of REDACTION_RULES) {
33
+ text = text.replace(pattern, replacement)
34
+ }
35
+ text = text.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, '').replace(/\s+$/g, '')
36
+ if (!text) return undefined
37
+ return text.length > maxLength ? `${text.slice(0, maxLength - 1)}…` : text
38
+ }
39
+
40
+ function quoteIdentifier(value) {
41
+ return `\`${value}\``
42
+ }
43
+
44
+ const RUNTIME_CLASSIFIERS = [
45
+ {
46
+ code: 'db_column_missing',
47
+ pattern: /column "?([\w.]+)"? (?:of relation "([^"]+)" )?does not exist/i,
48
+ title: 'Database schema mismatch',
49
+ detail: (match) => (match[2]
50
+ ? `Column ${quoteIdentifier(match[1])} is missing on relation ${quoteIdentifier(match[2])}`
51
+ : `Column ${quoteIdentifier(match[1])} is missing`),
52
+ recovery: 'migrate',
53
+ },
54
+ {
55
+ code: 'db_relation_missing',
56
+ pattern: /relation "([^"]+)" does not exist/i,
57
+ title: 'Database schema mismatch',
58
+ detail: (match) => `Relation ${quoteIdentifier(match[1])} is missing`,
59
+ recovery: 'migrate',
60
+ },
61
+ {
62
+ code: 'db_type_missing',
63
+ pattern: /type "([^"]+)" does not exist/i,
64
+ title: 'Database schema mismatch',
65
+ detail: (match) => `Type ${quoteIdentifier(match[1])} is missing`,
66
+ recovery: 'migrate',
67
+ },
68
+ {
69
+ code: 'db_pending_migrations',
70
+ pattern: /pending migrations|migrations? (?:are )?out of sync|run .*db:migrate/i,
71
+ title: 'Pending database migrations',
72
+ detail: () => 'The database schema is behind the current entity definitions',
73
+ recovery: 'migrate',
74
+ },
75
+ {
76
+ code: 'db_unreachable',
77
+ pattern: /(ECONNREFUSED|ENOTFOUND|ETIMEDOUT)[^\n]*(5432|postgres)|could not connect to server|Connection terminated unexpectedly/i,
78
+ title: 'Database is unreachable',
79
+ detail: () => 'The development database did not accept a connection',
80
+ },
81
+ {
82
+ code: 'db_auth_failed',
83
+ pattern: /password authentication failed|role "[^"]+" does not exist/i,
84
+ title: 'Database credentials rejected',
85
+ detail: () => 'The configured database user could not authenticate',
86
+ },
87
+ {
88
+ code: 'generated_registry_missing',
89
+ pattern: /Cannot find module '[^']*generated[^']*'|Module not found:[^\n]*generated/i,
90
+ title: 'Generated registry is missing',
91
+ detail: () => 'A generated module registry is missing or stale',
92
+ recovery: 'generate',
93
+ },
94
+ {
95
+ code: 'module_not_found',
96
+ pattern: /Module not found: Can't resolve '([^']+)'|Cannot find module '([^']+)'/i,
97
+ title: 'Module cannot be resolved',
98
+ detail: (match) => `Cannot resolve ${quoteIdentifier(match[1] ?? match[2])}`,
99
+ recovery: 'generate',
100
+ },
101
+ {
102
+ code: 'chunk_load_failed',
103
+ pattern: /ChunkLoadError|Loading chunk \S+ failed|Loading CSS chunk/i,
104
+ title: 'Stale build assets',
105
+ detail: () => 'The browser requested a chunk the current build no longer serves',
106
+ recovery: 'restart',
107
+ },
108
+ {
109
+ code: 'bundler_crashed',
110
+ pattern: /TurbopackInternalError|\bpanicked at\b|FATAL ERROR: .*heap out of memory/i,
111
+ title: 'Bundler crashed',
112
+ detail: () => 'The development bundler terminated unexpectedly',
113
+ recovery: 'restart',
114
+ },
115
+ {
116
+ code: 'dev_server_conflict',
117
+ pattern: /Another next dev server is already running|Unable to acquire lock/i,
118
+ title: 'Another dev server is running',
119
+ detail: () => 'A second development server holds the runtime lock',
120
+ },
121
+ {
122
+ code: 'port_in_use',
123
+ pattern: /EADDRINUSE/i,
124
+ title: 'Port is already in use',
125
+ detail: () => 'The configured development port is occupied by another process',
126
+ },
127
+ {
128
+ code: 'type_error',
129
+ pattern: /^\s*(?:Type error|TS\d+):/im,
130
+ title: 'TypeScript error',
131
+ detail: (match, message) => message,
132
+ },
133
+ {
134
+ code: 'syntax_error',
135
+ pattern: /SyntaxError|Parsing ecmascript source code failed|Unexpected token/i,
136
+ title: 'Syntax error',
137
+ detail: (match, message) => message,
138
+ },
139
+ ]
140
+
141
+ const SOURCE_FALLBACKS = {
142
+ process: { code: 'process_failed', title: 'Runtime process failed' },
143
+ log: { code: 'runtime_error', title: 'Runtime error detected' },
144
+ warmup: { code: 'warmup_failed', title: 'Startup warmup failed' },
145
+ probe: { code: 'probe_failed', title: 'Runtime health check failed' },
146
+ browser: { code: 'browser_error', title: 'Browser rendering error' },
147
+ }
148
+
149
+ // Maps a raw failure message onto a stable code + user-readable title/detail.
150
+ // Never executes anything; classification is pure text analysis.
151
+ export function classifyRuntimeMessage(message, source = 'log') {
152
+ const text = typeof message === 'string' ? message : ''
153
+ for (const classifier of RUNTIME_CLASSIFIERS) {
154
+ const match = classifier.pattern.exec(text)
155
+ if (!match) continue
156
+ return {
157
+ code: classifier.code,
158
+ title: classifier.title,
159
+ detail: classifier.detail(match, text),
160
+ recovery: classifier.recovery,
161
+ }
162
+ }
163
+ const fallback = SOURCE_FALLBACKS[source] ?? SOURCE_FALLBACKS.log
164
+ return { code: fallback.code, title: fallback.title, detail: text || undefined, recovery: undefined }
165
+ }
166
+
167
+ function normalizeForFingerprint(message, cwd) {
168
+ let text = String(message ?? '')
169
+ if (cwd) text = text.split(cwd).join('.')
170
+ return text
171
+ .replace(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, '<uuid>')
172
+ .replace(/\b0x[0-9a-f]+\b/gi, '<addr>')
173
+ .replace(/:\d+:\d+\b/g, ':<pos>')
174
+ .replace(/\b\d{4,}\b/g, '<n>')
175
+ .replace(/\s+/g, ' ')
176
+ .trim()
177
+ .slice(0, 300)
178
+ }
179
+
180
+ // Deterministic identity for an incident. Derived from the classified code and
181
+ // sanitized locators rather than raw stack text so retries of the same failure
182
+ // collapse into one entry.
183
+ export function fingerprintRuntimeIssue({ source, code, path: issuePath, digest, message, cwd } = {}) {
184
+ const parts = [
185
+ String(source ?? 'log'),
186
+ String(code ?? 'runtime_error'),
187
+ String(issuePath ?? ''),
188
+ String(digest ?? ''),
189
+ digest ? '' : normalizeForFingerprint(message, cwd),
190
+ ]
191
+ return crypto.createHash('sha256').update(parts.join('|')).digest('hex').slice(0, 16)
192
+ }
193
+
194
+ function sanitizePath(value) {
195
+ if (typeof value !== 'string') return undefined
196
+ const trimmed = value.trim()
197
+ if (!trimmed) return undefined
198
+ try {
199
+ const parsed = new URL(trimmed, 'http://localhost')
200
+ return `${parsed.pathname}`.slice(0, 200)
201
+ } catch {
202
+ return trimmed.slice(0, 200)
203
+ }
204
+ }
205
+
206
+ function sanitizeDigest(value) {
207
+ if (typeof value !== 'string') return undefined
208
+ const trimmed = value.trim()
209
+ if (!/^[A-Za-z0-9_-]{1,64}$/.test(trimmed)) return undefined
210
+ return trimmed
211
+ }
212
+
213
+ function normalizeFailureLines(lines) {
214
+ if (!Array.isArray(lines)) return []
215
+ return lines
216
+ .map((line) => redactSensitiveText(line, 500))
217
+ .filter((line) => typeof line === 'string' && line.length > 0)
218
+ .slice(-MAX_LEGACY_FAILURE_LINES)
219
+ }
220
+
221
+ // A blocking incident prevents the runtime from serving at all; a non-blocking
222
+ // one degrades an otherwise usable runtime.
223
+ function resolveBlocking(signal, readySeen) {
224
+ if (typeof signal.blocking === 'boolean') return signal.blocking
225
+ if (signal.source === 'process' || signal.source === 'warmup') return true
226
+ if (signal.source === 'log') return !readySeen
227
+ return false
228
+ }
229
+
230
+ export function projectLegacyReadyFailed(health) {
231
+ switch (health) {
232
+ case 'ready':
233
+ return { ready: true, failed: false }
234
+ case 'degraded':
235
+ return { ready: true, failed: false }
236
+ case 'unavailable':
237
+ return { ready: false, failed: true }
238
+ default:
239
+ return { ready: false, failed: false }
240
+ }
241
+ }
242
+
243
+ function severityRank(severity) {
244
+ return severity === 'error' ? 2 : 1
245
+ }
246
+
247
+ export function createRuntimeStateStore(options = {}) {
248
+ const now = typeof options.now === 'function' ? options.now : () => new Date()
249
+ const cwd = typeof options.cwd === 'string' ? options.cwd : undefined
250
+ const maxIncidents = Number.isInteger(options.maxIncidents) ? options.maxIncidents : MAX_INCIDENTS
251
+ const maxLines = Number.isInteger(options.maxDiagnosticLines) ? options.maxDiagnosticLines : MAX_DIAGNOSTIC_LINES
252
+
253
+ const state = {
254
+ generation: 0,
255
+ readySeen: false,
256
+ health: 'starting',
257
+ updatedAt: now().toISOString(),
258
+ incidents: new Map(),
259
+ recovery: null,
260
+ upstream: {
261
+ configuredPort: Number.isInteger(options.configuredPort) ? options.configuredPort : 0,
262
+ actualPort: undefined,
263
+ publicUrl: typeof options.publicUrl === 'string' ? options.publicUrl : '',
264
+ },
265
+ legacy: { failureLines: [], failureCommand: undefined, failureStage: undefined },
266
+ }
267
+
268
+ const diagnosticLines = []
269
+ let diagnosticSequence = 0
270
+ let issueSequence = 0
271
+
272
+ function touch() {
273
+ state.updatedAt = now().toISOString()
274
+ }
275
+
276
+ function activeIncidents() {
277
+ return Array.from(state.incidents.values()).filter((issue) => issue.generation === state.generation)
278
+ }
279
+
280
+ function deriveHealth() {
281
+ if (state.recovery?.busy) return 'recovering'
282
+ const active = activeIncidents()
283
+ if (active.some((issue) => issue.blocking)) return 'unavailable'
284
+ if (!state.readySeen) return 'starting'
285
+ if (active.some((issue) => issue.severity === 'error')) return 'degraded'
286
+ return 'ready'
287
+ }
288
+
289
+ function refreshHealth() {
290
+ state.health = deriveHealth()
291
+ touch()
292
+ return state.health
293
+ }
294
+
295
+ function pruneIncidents() {
296
+ if (state.incidents.size <= maxIncidents) return
297
+ const ordered = Array.from(state.incidents.entries())
298
+ .sort((a, b) => Date.parse(a[1].lastSeenAt) - Date.parse(b[1].lastSeenAt))
299
+ while (state.incidents.size > maxIncidents && ordered.length > 0) {
300
+ state.incidents.delete(ordered.shift()[0])
301
+ }
302
+ }
303
+
304
+ function toPublicIssue(issue) {
305
+ const { blocking, ...rest } = issue
306
+ return rest
307
+ }
308
+
309
+ function resolveSummary() {
310
+ const active = activeIncidents()
311
+ if (active.length === 0) return undefined
312
+ const sorted = [...active].sort((a, b) => {
313
+ if (a.blocking !== b.blocking) return a.blocking ? -1 : 1
314
+ const bySeverity = severityRank(b.severity) - severityRank(a.severity)
315
+ if (bySeverity !== 0) return bySeverity
316
+ return Date.parse(b.lastSeenAt) - Date.parse(a.lastSeenAt)
317
+ })
318
+ return toPublicIssue(sorted[0])
319
+ }
320
+
321
+ function getStatus() {
322
+ const legacyFlags = projectLegacyReadyFailed(state.health)
323
+ const incidents = activeIncidents()
324
+ .sort((a, b) => Date.parse(b.lastSeenAt) - Date.parse(a.lastSeenAt))
325
+ .map(toPublicIssue)
326
+ return {
327
+ schemaVersion: RUNTIME_STATUS_SCHEMA_VERSION,
328
+ generation: state.generation,
329
+ health: state.health,
330
+ ready: legacyFlags.ready,
331
+ failed: legacyFlags.failed,
332
+ updatedAt: state.updatedAt,
333
+ upstream: { ...state.upstream },
334
+ issueSummary: resolveSummary(),
335
+ incidents,
336
+ recovery: state.recovery ? { ...state.recovery } : undefined,
337
+ legacy: {
338
+ failureLines: [...state.legacy.failureLines],
339
+ failureCommand: state.legacy.failureCommand,
340
+ failureStage: state.legacy.failureStage,
341
+ },
342
+ }
343
+ }
344
+
345
+ function appendDiagnosticLine(line, meta = {}) {
346
+ const text = redactSensitiveText(line, 500)
347
+ if (!text) return null
348
+ diagnosticSequence += 1
349
+ const entry = {
350
+ seq: diagnosticSequence,
351
+ at: now().toISOString(),
352
+ generation: state.generation,
353
+ source: typeof meta.source === 'string' ? meta.source : 'log',
354
+ text,
355
+ }
356
+ diagnosticLines.push(entry)
357
+ while (diagnosticLines.length > maxLines) diagnosticLines.shift()
358
+ return entry
359
+ }
360
+
361
+ function recordSignal(signal = {}) {
362
+ if (!signal || typeof signal !== 'object') return getStatus()
363
+ if (Number.isInteger(signal.generation) && signal.generation !== state.generation) {
364
+ return getStatus()
365
+ }
366
+
367
+ const source = RUNTIME_SOURCES.includes(signal.source) ? signal.source : 'log'
368
+ const rawMessage = typeof signal.message === 'string' ? signal.message : ''
369
+ const classified = classifyRuntimeMessage(rawMessage, source)
370
+ const code = typeof signal.code === 'string' && signal.code.trim() ? signal.code.trim() : classified.code
371
+ const severity = signal.severity === 'warning' ? 'warning' : 'error'
372
+ const issuePath = sanitizePath(signal.path)
373
+ const digest = sanitizeDigest(signal.digest)
374
+ const title = redactSensitiveText(signal.title ?? classified.title, MAX_TITLE_LENGTH) ?? 'Runtime error detected'
375
+ const detail = redactSensitiveText(signal.detail ?? classified.detail ?? rawMessage, MAX_DETAIL_LENGTH)
376
+ const recovery = RUNTIME_RECOVERY_ACTIONS.includes(signal.recovery)
377
+ ? signal.recovery
378
+ : classified.recovery
379
+ const blocking = resolveBlocking({ ...signal, source }, state.readySeen)
380
+ const fingerprint = fingerprintRuntimeIssue({
381
+ source,
382
+ code,
383
+ path: issuePath,
384
+ digest,
385
+ message: rawMessage || detail,
386
+ cwd,
387
+ })
388
+ const timestamp = now().toISOString()
389
+
390
+ const existing = state.incidents.get(fingerprint)
391
+ if (existing && existing.generation === state.generation) {
392
+ existing.lastSeenAt = timestamp
393
+ existing.occurrences += 1
394
+ existing.severity = severityRank(severity) > severityRank(existing.severity) ? severity : existing.severity
395
+ existing.blocking = existing.blocking || blocking
396
+ if (detail) existing.detail = detail
397
+ } else {
398
+ issueSequence += 1
399
+ state.incidents.set(fingerprint, {
400
+ id: `${state.generation}-${issueSequence}`,
401
+ fingerprint,
402
+ code,
403
+ source,
404
+ severity,
405
+ title,
406
+ detail,
407
+ firstSeenAt: timestamp,
408
+ lastSeenAt: timestamp,
409
+ occurrences: 1,
410
+ generation: state.generation,
411
+ path: issuePath,
412
+ digest,
413
+ recovery,
414
+ blocking,
415
+ })
416
+ pruneIncidents()
417
+ }
418
+
419
+ if (Array.isArray(signal.failureLines) && signal.failureLines.length > 0) {
420
+ state.legacy.failureLines = normalizeFailureLines(signal.failureLines)
421
+ }
422
+ if (typeof signal.failureCommand === 'string') {
423
+ state.legacy.failureCommand = redactSensitiveText(signal.failureCommand, 200)
424
+ }
425
+ if (typeof signal.failureStage === 'string') {
426
+ state.legacy.failureStage = redactSensitiveText(signal.failureStage, 120)
427
+ }
428
+
429
+ appendDiagnosticLine(detail ?? rawMessage ?? title, { source })
430
+ refreshHealth()
431
+ return getStatus()
432
+ }
433
+
434
+ return {
435
+ getStatus,
436
+ recordSignal,
437
+ appendDiagnosticLine,
438
+
439
+ getGeneration() {
440
+ return state.generation
441
+ },
442
+
443
+ // Each managed process launch owns a generation. Signals, probes, and
444
+ // action completions from an older generation are ignored.
445
+ beginGeneration(reason) {
446
+ state.generation += 1
447
+ state.readySeen = false
448
+ state.incidents.clear()
449
+ state.legacy = { failureLines: [], failureCommand: undefined, failureStage: undefined }
450
+ state.recovery = null
451
+ if (reason) appendDiagnosticLine(`Runtime generation ${state.generation} started (${reason})`, { source: 'process' })
452
+ refreshHealth()
453
+ return state.generation
454
+ },
455
+
456
+ setUpstream(patch = {}) {
457
+ if (Number.isInteger(patch.configuredPort)) state.upstream.configuredPort = patch.configuredPort
458
+ if (Number.isInteger(patch.actualPort)) state.upstream.actualPort = patch.actualPort
459
+ if (typeof patch.publicUrl === 'string') state.upstream.publicUrl = patch.publicUrl
460
+ touch()
461
+ return getStatus()
462
+ },
463
+
464
+ // Only the full startup warmup may open the READY gate.
465
+ markReady() {
466
+ state.readySeen = true
467
+ for (const [fingerprint, issue] of state.incidents) {
468
+ if (issue.generation === state.generation && issue.blocking) {
469
+ state.incidents.delete(fingerprint)
470
+ }
471
+ }
472
+ state.legacy = { failureLines: [], failureCommand: undefined, failureStage: undefined }
473
+ refreshHealth()
474
+ return getStatus()
475
+ },
476
+
477
+ isReadySeen() {
478
+ return state.readySeen
479
+ },
480
+
481
+ clearIncident(fingerprint) {
482
+ const removed = state.incidents.delete(fingerprint)
483
+ if (removed) refreshHealth()
484
+ return removed
485
+ },
486
+
487
+ clearIncidentsBySource(source) {
488
+ let removed = 0
489
+ for (const [fingerprint, issue] of state.incidents) {
490
+ if (issue.source !== source) continue
491
+ state.incidents.delete(fingerprint)
492
+ removed += 1
493
+ }
494
+ if (removed > 0) refreshHealth()
495
+ return removed
496
+ },
497
+
498
+ beginRecovery(action) {
499
+ if (!RUNTIME_RECOVERY_ACTIONS.includes(action)) return null
500
+ state.recovery = { action, startedAt: now().toISOString(), busy: true }
501
+ refreshHealth()
502
+ return getStatus()
503
+ },
504
+
505
+ isRecoveryBusy() {
506
+ return Boolean(state.recovery?.busy)
507
+ },
508
+
509
+ completeRecovery(exitCode, options = {}) {
510
+ if (!state.recovery) return getStatus()
511
+ if (Number.isInteger(options.generation) && options.generation !== state.generation) {
512
+ return getStatus()
513
+ }
514
+ state.recovery = {
515
+ ...state.recovery,
516
+ busy: false,
517
+ lastExitCode: Number.isInteger(exitCode) ? exitCode : undefined,
518
+ }
519
+ refreshHealth()
520
+ return getStatus()
521
+ },
522
+
523
+ getDiagnosticLines(cursor) {
524
+ const from = Number.isInteger(cursor) && cursor >= 0 ? cursor : 0
525
+ const lines = diagnosticLines.filter((entry) => entry.seq > from)
526
+ return {
527
+ lines,
528
+ nextCursor: lines.length > 0 ? lines[lines.length - 1].seq : from,
529
+ generation: state.generation,
530
+ }
531
+ },
532
+ }
533
+ }
534
+
535
+ // Threshold policy shared by the supervisor probe and its tests. Kept separate
536
+ // from the transport so timings can be exercised without real HTTP.
537
+ export function createProbePolicy(options = {}) {
538
+ const failureThreshold = Number.isInteger(options.failureThreshold) && options.failureThreshold > 0
539
+ ? options.failureThreshold
540
+ : 3
541
+ const recoveryThreshold = Number.isInteger(options.recoveryThreshold) && options.recoveryThreshold > 0
542
+ ? options.recoveryThreshold
543
+ : 2
544
+
545
+ let consecutiveFailures = 0
546
+ let consecutiveSuccesses = 0
547
+
548
+ return {
549
+ reset() {
550
+ consecutiveFailures = 0
551
+ consecutiveSuccesses = 0
552
+ },
553
+ getCounters() {
554
+ return { consecutiveFailures, consecutiveSuccesses }
555
+ },
556
+ // Returns 'degraded' / 'recovered' only when a threshold is newly crossed,
557
+ // so the supervisor does not rewrite state on every tick.
558
+ record(healthy) {
559
+ if (healthy) {
560
+ consecutiveFailures = 0
561
+ consecutiveSuccesses += 1
562
+ if (consecutiveSuccesses === recoveryThreshold) return 'recovered'
563
+ return null
564
+ }
565
+ consecutiveSuccesses = 0
566
+ consecutiveFailures += 1
567
+ if (consecutiveFailures === failureThreshold) return 'degraded'
568
+ return null
569
+ },
570
+ }
571
+ }