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.
- package/agentic/shared/ai/harness/cases.json +4 -2
- package/dist/agentic/guides/module-facts.json +828 -149
- package/dist/agentic/guides/module-facts.v2.json +828 -149
- package/dist/agentic/guides/modules/ai_assistant/index.md +1 -1
- package/dist/agentic/guides/modules/api_docs/index.md +1 -1
- package/dist/agentic/guides/modules/api_keys/index.md +1 -1
- package/dist/agentic/guides/modules/attachments/index.md +1 -1
- package/dist/agentic/guides/modules/audit_logs/index.md +1 -1
- package/dist/agentic/guides/modules/auth/index.md +1 -1
- package/dist/agentic/guides/modules/business_rules/index.md +1 -1
- package/dist/agentic/guides/modules/catalog/index.md +1 -1
- package/dist/agentic/guides/modules/channel_apns/index.md +1 -1
- package/dist/agentic/guides/modules/channel_discord/index.md +1 -1
- package/dist/agentic/guides/modules/channel_expo/index.md +1 -1
- package/dist/agentic/guides/modules/channel_fcm/index.md +1 -1
- package/dist/agentic/guides/modules/channel_gmail/index.md +1 -1
- package/dist/agentic/guides/modules/channel_imap/index.md +1 -1
- package/dist/agentic/guides/modules/channel_resend/acl-features.md +12 -0
- package/dist/agentic/guides/modules/channel_resend/contribution-resolutions.md +11 -0
- package/dist/agentic/guides/modules/channel_resend/di-registrations-rich.md +12 -0
- package/dist/agentic/guides/modules/channel_resend/exact-override-targets.md +16 -0
- package/dist/agentic/guides/modules/channel_resend/index.md +18 -0
- package/dist/agentic/guides/modules/channel_resend/owned-contract-module-metadata.md +11 -0
- package/dist/agentic/guides/modules/channel_resend/setup.md +11 -0
- package/dist/agentic/guides/modules/channel_resend/umes-contributions.md +11 -0
- package/dist/agentic/guides/modules/channel_ses/acl-features.md +12 -0
- package/dist/agentic/guides/modules/channel_ses/contribution-resolutions.md +11 -0
- package/dist/agentic/guides/modules/channel_ses/di-registrations-rich.md +12 -0
- package/dist/agentic/guides/modules/channel_ses/exact-override-targets.md +16 -0
- package/dist/agentic/guides/modules/channel_ses/index.md +18 -0
- package/dist/agentic/guides/modules/channel_ses/owned-contract-module-metadata.md +11 -0
- package/dist/agentic/guides/modules/channel_ses/setup.md +11 -0
- package/dist/agentic/guides/modules/channel_ses/umes-contributions.md +11 -0
- package/dist/agentic/guides/modules/checkout/index.md +1 -1
- package/dist/agentic/guides/modules/communication_channels/di-registrations-rich.md +10 -9
- package/dist/agentic/guides/modules/communication_channels/exact-override-targets.md +12 -11
- package/dist/agentic/guides/modules/communication_channels/index.md +1 -1
- package/dist/agentic/guides/modules/communication_channels/setup.md +1 -1
- package/dist/agentic/guides/modules/configs/index.md +1 -1
- package/dist/agentic/guides/modules/content/index.md +1 -1
- package/dist/agentic/guides/modules/currencies/index.md +1 -1
- package/dist/agentic/guides/modules/customer_accounts/index.md +1 -1
- package/dist/agentic/guides/modules/customers/index.md +1 -1
- package/dist/agentic/guides/modules/dashboards/index.md +1 -1
- package/dist/agentic/guides/modules/data_sync/index.md +1 -1
- package/dist/agentic/guides/modules/design_system/index.md +1 -1
- package/dist/agentic/guides/modules/devices/index.md +1 -1
- package/dist/agentic/guides/modules/dictionaries/index.md +1 -1
- package/dist/agentic/guides/modules/directory/index.md +1 -1
- package/dist/agentic/guides/modules/documents/index.md +1 -1
- package/dist/agentic/guides/modules/entities/index.md +1 -1
- package/dist/agentic/guides/modules/eudr/index.md +1 -1
- package/dist/agentic/guides/modules/events/index.md +1 -1
- package/dist/agentic/guides/modules/feature_toggles/index.md +1 -1
- package/dist/agentic/guides/modules/gateway_stripe/index.md +1 -1
- package/dist/agentic/guides/modules/generators/index.md +1 -1
- package/dist/agentic/guides/modules/inbox_ops/index.md +1 -1
- package/dist/agentic/guides/modules/integrations/index.md +1 -1
- package/dist/agentic/guides/modules/messages/index.md +1 -1
- package/dist/agentic/guides/modules/notifications/index.md +1 -1
- package/dist/agentic/guides/modules/onboarding/index.md +1 -1
- package/dist/agentic/guides/modules/payment_gateways/index.md +1 -1
- package/dist/agentic/guides/modules/perspectives/index.md +1 -1
- package/dist/agentic/guides/modules/planner/index.md +1 -1
- package/dist/agentic/guides/modules/portal/index.md +1 -1
- package/dist/agentic/guides/modules/progress/index.md +1 -1
- package/dist/agentic/guides/modules/push_notifications/index.md +1 -1
- package/dist/agentic/guides/modules/query_index/index.md +1 -1
- package/dist/agentic/guides/modules/record_locks/index.md +1 -1
- package/dist/agentic/guides/modules/resources/index.md +1 -1
- package/dist/agentic/guides/modules/sales/index.md +1 -1
- package/dist/agentic/guides/modules/scheduler/index.md +1 -1
- package/dist/agentic/guides/modules/search/index.md +1 -1
- package/dist/agentic/guides/modules/security/index.md +1 -1
- package/dist/agentic/guides/modules/shipping_carriers/index.md +1 -1
- package/dist/agentic/guides/modules/sso/index.md +1 -1
- package/dist/agentic/guides/modules/staff/index.md +1 -1
- package/dist/agentic/guides/modules/storage_s3/index.md +1 -1
- package/dist/agentic/guides/modules/sync_akeneo/index.md +1 -1
- package/dist/agentic/guides/modules/sync_excel/index.md +1 -1
- package/dist/agentic/guides/modules/system_status_overlays/index.md +1 -1
- package/dist/agentic/guides/modules/translations/index.md +1 -1
- package/dist/agentic/guides/modules/warranty_claims/index.md +1 -1
- package/dist/agentic/guides/modules/webhooks/index.md +1 -1
- package/dist/agentic/guides/modules/wms/index.md +1 -1
- package/dist/agentic/guides/modules/workflows/index.md +1 -1
- package/dist/agentic/guides/reference-module-facts.json +1 -1
- package/dist/agentic/guides/upstream/manifest.json +1 -1
- package/dist/agentic/shared/ai/harness/cases.json +4 -2
- package/package.json +3 -3
- package/template/.env.example +46 -1
- package/template/package.json.template +2 -0
- package/template/scripts/dev-runtime-actions.mjs +99 -0
- package/template/scripts/dev-runtime-config.mjs +89 -0
- package/template/scripts/dev-runtime-diagnostics.mjs +292 -0
- package/template/scripts/dev-runtime-gateway.mjs +370 -0
- package/template/scripts/dev-runtime-probe.mjs +77 -0
- package/template/scripts/dev-runtime-state.mjs +571 -0
- package/template/scripts/dev-runtime-supervisor.mjs +268 -0
- package/template/scripts/dev-runtime.mjs +29 -0
- package/template/scripts/dev-splash-state.mjs +14 -0
- package/template/scripts/dev-splash.html +357 -2
- package/template/scripts/dev.mjs +396 -14
- package/template/src/app/__tests__/start-page-bootstrap.test.ts +34 -0
- package/template/src/app/api/dev-runtime/actions/[action]/route.ts +5 -0
- package/template/src/app/api/dev-runtime/diagnostics/route.ts +5 -0
- package/template/src/app/api/dev-runtime/logs/route.ts +5 -0
- package/template/src/app/api/dev-runtime/status/route.ts +5 -0
- package/template/src/app/api/healthz/__tests__/route.test.ts +3 -1
- package/template/src/app/global-error.tsx +7 -0
- package/template/src/app/layout.tsx +11 -0
- package/template/src/app/start/page.tsx +11 -1
- package/template/src/i18n/de.json +27 -0
- package/template/src/i18n/en.json +27 -0
- package/template/src/i18n/es.json +27 -0
- package/template/src/i18n/ko.json +27 -0
- package/template/src/i18n/pl.json +27 -0
- package/template/src/modules/example/__integration__/TC-START-001-onboarding-gate.spec.ts +33 -0
- 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
|
+
}
|