dsh-harbor-evolution 0.8.2 → 0.9.2

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.
@@ -0,0 +1,518 @@
1
+ import { createHash, randomBytes } from 'node:crypto'
2
+ import path from 'node:path'
3
+
4
+ export const HARBOR_UI_CONTEXT_SCHEMA = 'harbor-ui-context/v1'
5
+ export const HARBOR_RESOLVED_CONTEXT_SCHEMA = 'harbor-resolved-context/v1'
6
+ export const MAX_UI_CONTEXT_BYTES = 4 * 1024
7
+ export const DEFAULT_UI_CONTEXT_TTL_MS = 15 * 60 * 1000
8
+ export const DEFAULT_UI_CONTEXT_MAX_ENTRIES = 2_048
9
+ export const DEFAULT_UI_CONTEXT_MAX_ENTRIES_PER_SESSION = 128
10
+
11
+ const ROUTES = new Set(['harbor.home', 'harbor.job', 'harbor.trial.detail', 'harbor.evaluator', 'harbor.compare', 'harbor.gate'])
12
+ const OBJECT_KINDS = new Set(['workspace', 'job', 'trial', 'criterion', 'evidence', 'candidate', 'dataset', 'evaluator', 'hypothesis', 'compare', 'gate', 'gate-reason', 'metric', 'finding', 'attempt', 'exception', 'evaluator-source', 'trial-set'])
13
+ const STAGES = new Set(['candidate', 'dataset', 'integration', 'renderer', 'judge', 'meta', 'reporter', 'optimizer', 'gate'])
14
+ const DETAIL_TABS = new Set(['summary', 'output', 'scores', 'evidence', 'attempts', 'audit'])
15
+ const FILTER_KEYS = new Set(['status', 'validity', 'segment'])
16
+ const FILTER_STATUSES = new Set(['pending', 'queued', 'starting', 'running-agent', 'evaluating', 'completed', 'completed-unscored', 'candidate-quality-failed', 'infrastructure-error', 'evaluation-error', 'failed', 'cancelled', 'timed-out'])
17
+ const FILTER_VALIDITIES = new Set(['true', 'false'])
18
+ const SORTS = new Set(['dataset-order', 'latest-completed', 'lowest-score', 'errors'])
19
+ const TOKEN_PATTERN = /^hctx_[A-Za-z0-9_-]{20,80}$/
20
+ const STABLE_ID_PATTERN = /^(?:@?[\p{L}\p{N}][\p{L}\p{N}._:@+-]*|@[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+)$/u
21
+ const VERSION_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:+-]*$/
22
+ const DIGEST_PATTERN = /^sha256:[a-f0-9]{64}$/
23
+ const ISO_8601_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?(?:Z|[+-]\d{2}:\d{2})$/
24
+ const EVIDENCE_REF_PATTERN = /^[\p{L}\p{N}][\p{L}\p{N}._:@+#/+-]{0,319}$/u
25
+ const SECRET_VALUE_PATTERNS = [
26
+ /-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----/i,
27
+ /\bBearer\s+[A-Za-z0-9._~+\/-]{12,}/i,
28
+ /\b(?:sk|rk|pk|ghp|gho|ghu|github_pat|xox[baprs])-[_A-Za-z0-9-]{12,}\b/i,
29
+ /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/,
30
+ /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/,
31
+ /\b(?:api[_-]?key|access[_-]?token|auth(?:orization)?|cookie|password|passwd|secret)\s*[:=]\s*\S{6,}/i,
32
+ ]
33
+ const SECRET_KEY_NAMES = new Set(['apikey', 'authorization', 'authtoken', 'accesstoken', 'refreshtoken', 'bearertoken', 'secretaccesskey', 'cookie', 'cookies', 'header', 'headers', 'password', 'passwd', 'privatekey', 'secret', 'secrets', 'token'])
34
+ const LOCATION_KEY_PARTS = ['absoluteurl', 'filepath', 'pathname', 'projectpath', 'directory', 'workingdirectory', 'cwd', 'href', 'uri', 'url']
35
+
36
+ const ROUTE_RULES = Object.freeze({
37
+ 'harbor.home': { required: [], allowed: [], objectKinds: new Set(['workspace']) },
38
+ 'harbor.job': { required: ['job'], allowed: ['job', 'stage'], objectKinds: new Set(['job', 'candidate', 'dataset', 'hypothesis', 'gate-reason', 'metric', 'trial-set']) },
39
+ 'harbor.trial.detail': { required: ['job', 'trial'], allowed: ['job', 'stage', 'trial', 'detailTab', 'criterion', 'evidenceRef'], objectKinds: new Set(['trial', 'criterion', 'evidence', 'finding', 'attempt', 'exception']) },
40
+ 'harbor.evaluator': { required: ['job'], allowed: ['job', 'stage'], objectKinds: new Set(['job', 'evaluator', 'evaluator-source']) },
41
+ 'harbor.compare': { required: ['job', 'baseline', 'candidate'], allowed: ['job', 'stage', 'baseline', 'candidate'], objectKinds: new Set(['compare']) },
42
+ 'harbor.gate': { required: ['job', 'baseline', 'candidate', 'policy', 'policyVersion', 'policyDigest', 'reportDigest'], allowed: ['job', 'stage', 'baseline', 'candidate', 'policy', 'policyVersion', 'policyDigest', 'reportDigest'], objectKinds: new Set(['gate']) },
43
+ })
44
+
45
+ function fail(code, message) {
46
+ const error = new Error(`${code}: ${message}`)
47
+ error.code = code
48
+ throw error
49
+ }
50
+
51
+ function canonicalKey(key) {
52
+ return String(key).replace(/[^A-Za-z0-9]/g, '').toLowerCase()
53
+ }
54
+
55
+ function isLocationKey(key) {
56
+ const normalized = canonicalKey(key)
57
+ return LOCATION_KEY_PARTS.some(part => normalized === part || normalized.endsWith(part))
58
+ }
59
+
60
+ function looksLikeLocation(value) {
61
+ if (/^(?:[A-Za-z][A-Za-z0-9+.-]*:\/\/|blob:|data:|file:|javascript:|mailto:|www\.)/i.test(value)) return true
62
+ if (/^(?:\/|\\|~[\\/]|\.{1,2}[\\/]|[A-Za-z]:[\\/])/.test(value)) return true
63
+ if (value.includes('\\')) return true
64
+ return value.includes('/') && !/^@[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/.test(value)
65
+ }
66
+
67
+ function isSafeEvidenceRef(value) {
68
+ return typeof value === 'string'
69
+ && EVIDENCE_REF_PATTERN.test(value)
70
+ && !/^(?:[A-Za-z][A-Za-z0-9+.-]*:|[\\/~]|\.{1,2}[\\/])/.test(value)
71
+ && !value.includes('\\')
72
+ && !value.split('/').includes('..')
73
+ && !SECRET_VALUE_PATTERNS.some(pattern => pattern.test(value))
74
+ }
75
+
76
+ function assertSafeJsonTree(value, name = 'context', depth = 0, seen = new WeakSet()) {
77
+ if (depth > 12) fail('HARBOR_CONTEXT_INVALID', `${name} is nested too deeply`)
78
+ if (value === null || value === undefined || typeof value === 'boolean') return
79
+ if (typeof value === 'number') {
80
+ if (!Number.isFinite(value)) fail('HARBOR_CONTEXT_INVALID', `${name} must be finite`)
81
+ return
82
+ }
83
+ if (typeof value === 'string') {
84
+ const isDeclaredSchema = name === 'context.schema' && value === HARBOR_UI_CONTEXT_SCHEMA
85
+ const isEvidenceRef = name.endsWith('.evidenceRef') && isSafeEvidenceRef(value)
86
+ if (!isDeclaredSchema && !isEvidenceRef && looksLikeLocation(value)) fail('HARBOR_CONTEXT_UNSAFE_VALUE', `${name} must not contain a URL or path`)
87
+ if (SECRET_VALUE_PATTERNS.some(pattern => pattern.test(value))) fail('HARBOR_CONTEXT_SECRET_DETECTED', `${name} appears to contain a secret`)
88
+ return
89
+ }
90
+ if (typeof value !== 'object') fail('HARBOR_CONTEXT_INVALID', `${name} must be JSON-compatible`)
91
+ if (seen.has(value)) fail('HARBOR_CONTEXT_INVALID', `${name} must not contain a cycle`)
92
+ seen.add(value)
93
+ if (Array.isArray(value)) {
94
+ for (const [index, item] of value.entries()) assertSafeJsonTree(item, `${name}[${index}]`, depth + 1, seen)
95
+ } else {
96
+ const prototype = Object.getPrototypeOf(value)
97
+ if (prototype !== Object.prototype && prototype !== null) fail('HARBOR_CONTEXT_INVALID', `${name} must be a plain object`)
98
+ for (const [key, item] of Object.entries(value)) {
99
+ const normalizedKey = canonicalKey(key)
100
+ if (SECRET_KEY_NAMES.has(normalizedKey) || [...SECRET_KEY_NAMES].some(secret => normalizedKey.endsWith(secret))) fail('HARBOR_CONTEXT_SECRET_DETECTED', `${name}.${key} is not allowed`)
101
+ if (isLocationKey(key)) fail('HARBOR_CONTEXT_UNSAFE_VALUE', `${name}.${key} must not contain a URL or path`)
102
+ const childName = key === 'id' && value.kind === 'evidence' ? `${name}.evidenceRef` : `${name}.${key}`
103
+ assertSafeJsonTree(item, childName, depth + 1, seen)
104
+ }
105
+ }
106
+ seen.delete(value)
107
+ }
108
+
109
+ function assertRawPayloadSize(value) {
110
+ let serialized
111
+ try { serialized = JSON.stringify(value) } catch { fail('HARBOR_CONTEXT_INVALID', 'context must be JSON-compatible') }
112
+ if (serialized === undefined) fail('HARBOR_CONTEXT_INVALID', 'context must be JSON-compatible')
113
+ if (Buffer.byteLength(serialized, 'utf8') > MAX_UI_CONTEXT_BYTES) fail('HARBOR_CONTEXT_TOO_LARGE', `context payload exceeds ${MAX_UI_CONTEXT_BYTES} bytes`)
114
+ }
115
+
116
+ function record(value, name) {
117
+ if (!value || typeof value !== 'object' || Array.isArray(value)) fail('HARBOR_CONTEXT_INVALID', `${name} must be an object`)
118
+ return value
119
+ }
120
+
121
+ function string(value, name, { required = false, max = 256 } = {}) {
122
+ if (value === undefined || value === null || value === '') {
123
+ if (required) fail('HARBOR_CONTEXT_INVALID', `${name} is required`)
124
+ return undefined
125
+ }
126
+ if (typeof value !== 'string') fail('HARBOR_CONTEXT_INVALID', `${name} must be a string`)
127
+ const normalized = value.trim()
128
+ if (!normalized && required) fail('HARBOR_CONTEXT_INVALID', `${name} is required`)
129
+ if (normalized.length > max || /[\u0000-\u001f\u007f]/.test(normalized)) fail('HARBOR_CONTEXT_INVALID', `${name} is invalid`)
130
+ return normalized || undefined
131
+ }
132
+
133
+ function stableId(value, name, { required = false, max = 240 } = {}) {
134
+ const normalized = string(value, name, { required, max })
135
+ if (normalized !== undefined && (!STABLE_ID_PATTERN.test(normalized) || normalized === '.' || normalized === '..')) fail('HARBOR_CONTEXT_INVALID', `${name} must be a stable ID`)
136
+ return normalized
137
+ }
138
+
139
+ function evidenceRef(value, name, { required = false } = {}) {
140
+ const normalized = string(value, name, { required, max: 320 })
141
+ if (normalized !== undefined && !isSafeEvidenceRef(normalized)) fail('HARBOR_CONTEXT_INVALID', `${name} must be a safe opaque Evidence ref`)
142
+ return normalized
143
+ }
144
+
145
+ function digest(value, name) {
146
+ const normalized = string(value, name, { max: 72 })
147
+ if (normalized !== undefined && !DIGEST_PATTERN.test(normalized)) fail('HARBOR_CONTEXT_INVALID', `${name} must be a sha256 digest`)
148
+ return normalized
149
+ }
150
+
151
+ function boolean(value, name) {
152
+ if (value === undefined) return undefined
153
+ if (typeof value !== 'boolean') fail('HARBOR_CONTEXT_INVALID', `${name} must be boolean`)
154
+ return value
155
+ }
156
+
157
+ function enumValue(value, name, values, { required = false } = {}) {
158
+ const normalized = string(value, name, { required, max: 80 })
159
+ if (normalized !== undefined && !values.has(normalized)) fail('HARBOR_CONTEXT_INVALID', `${name} is not supported`)
160
+ return normalized
161
+ }
162
+
163
+ function compact(value) {
164
+ return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined))
165
+ }
166
+
167
+ function compactNonEmpty(value) {
168
+ const normalized = compact(value)
169
+ return Object.keys(normalized).length ? normalized : undefined
170
+ }
171
+
172
+ function normalizeIdentity(value, name) {
173
+ if (value === undefined) return undefined
174
+ const source = record(value, name)
175
+ const version = string(source.version, `${name}.version`, { max: 120 })
176
+ if (version !== undefined && !VERSION_PATTERN.test(version)) fail('HARBOR_CONTEXT_INVALID', `${name}.version is invalid`)
177
+ return compactNonEmpty({
178
+ id: stableId(source.id, `${name}.id`, { required: true, max: 180 }),
179
+ version,
180
+ digest: digest(source.digest, `${name}.digest`),
181
+ })
182
+ }
183
+
184
+ function normalizeObjectRef(value, name) {
185
+ const source = record(value, name)
186
+ const kind = enumValue(source.kind, `${name}.kind`, OBJECT_KINDS, { required: true })
187
+ const policyVersion = string(source.policyVersion, `${name}.policyVersion`, { max: 120 })
188
+ if (policyVersion !== undefined && !VERSION_PATTERN.test(policyVersion)) fail('HARBOR_CONTEXT_INVALID', `${name}.policyVersion is invalid`)
189
+ const normalized = compact({
190
+ kind,
191
+ id: kind === 'evidence'
192
+ ? evidenceRef(source.id, `${name}.id`, { required: true })
193
+ : stableId(source.id, `${name}.id`, { required: true, max: 240 }),
194
+ job: stableId(source.job, `${name}.job`, { max: 240 }),
195
+ stage: enumValue(source.stage, `${name}.stage`, STAGES),
196
+ trial: stableId(source.trial, `${name}.trial`, { max: 240 }),
197
+ criterion: stableId(source.criterion, `${name}.criterion`, { max: 180 }),
198
+ evidenceRef: evidenceRef(source.evidenceRef, `${name}.evidenceRef`),
199
+ baseline: stableId(source.baseline, `${name}.baseline`, { max: 240 }),
200
+ candidate: stableId(source.candidate, `${name}.candidate`, { max: 240 }),
201
+ comparisonDigest: digest(source.comparisonDigest, `${name}.comparisonDigest`),
202
+ policy: stableId(source.policy, `${name}.policy`, { max: 180 }),
203
+ policyVersion,
204
+ policyDigest: digest(source.policyDigest, `${name}.policyDigest`),
205
+ reportDigest: digest(source.reportDigest, `${name}.reportDigest`),
206
+ sourceDigest: digest(source.sourceDigest, `${name}.sourceDigest`),
207
+ sourceRole: enumValue(source.sourceRole, `${name}.sourceRole`, new Set(['evaluator', 'rubric'])),
208
+ startLine: source.startLine,
209
+ endLine: source.endLine,
210
+ selectionCount: source.selectionCount,
211
+ })
212
+ if (kind === 'trial-set' ? !Number.isInteger(source.selectionCount) || source.selectionCount < 1 || source.selectionCount > 1000 : source.selectionCount !== undefined) fail('HARBOR_CONTEXT_INVALID', `${name}.selectionCount must be a bounded Trial set count`)
213
+ const local = ['hypothesis', 'gate-reason', 'metric', 'finding', 'attempt', 'exception', 'evaluator-source', 'trial-set'].includes(kind)
214
+ if (!local && normalized.sourceDigest) fail('HARBOR_CONTEXT_INVALID', `${name}.sourceDigest is not valid for ${kind}`)
215
+ if (local && kind !== 'hypothesis' && !normalized.sourceDigest) fail('HARBOR_CONTEXT_INVALID', `${name}.sourceDigest is required`)
216
+ for (const field of ['sourceRole', 'startLine', 'endLine']) {
217
+ if (normalized[field] !== undefined && kind !== 'evaluator-source') fail('HARBOR_CONTEXT_INVALID', `${name}.${field} is only valid for evaluator-source`)
218
+ }
219
+ for (const field of ['startLine', 'endLine']) {
220
+ if (normalized[field] !== undefined && (!Number.isSafeInteger(normalized[field]) || normalized[field] < 1 || normalized[field] > 10000)) fail('HARBOR_CONTEXT_INVALID', `${name}.${field} must be a positive bounded line number`)
221
+ }
222
+ if (kind === 'evaluator-source' && (!normalized.sourceRole || (normalized.startLine === undefined) !== (normalized.endLine === undefined))) fail('HARBOR_CONTEXT_INVALID', `${name} requires a source role and a complete optional line range`)
223
+ const require = field => { if (!normalized[field]) fail('HARBOR_CONTEXT_INVALID', `${name}.${field} is required for ${kind}`) }
224
+ if (kind === 'workspace') {
225
+ for (const field of ['job', 'stage', 'trial', 'criterion', 'evidenceRef']) if (normalized[field] !== undefined) fail('HARBOR_CONTEXT_INVALID', `${name}.${field} is not valid for workspace`)
226
+ }
227
+ if (kind !== 'workspace') require('job')
228
+ if (['trial', 'criterion', 'evidence', 'finding', 'attempt', 'exception'].includes(kind)) require('trial')
229
+ if (kind === 'criterion') require('criterion')
230
+ if (kind === 'evidence') require('evidenceRef')
231
+ if (kind === 'compare') {
232
+ for (const field of ['baseline', 'candidate', 'comparisonDigest']) require(field)
233
+ if (normalized.job !== normalized.candidate) fail('HARBOR_CONTEXT_INVALID', `${name}.job must match candidate for compare`)
234
+ for (const field of ['policy', 'policyVersion', 'policyDigest', 'reportDigest']) if (normalized[field] !== undefined) fail('HARBOR_CONTEXT_INVALID', `${name}.${field} is not valid for compare`)
235
+ } else if (kind === 'gate') {
236
+ for (const field of ['baseline', 'candidate', 'policy', 'policyVersion', 'policyDigest', 'reportDigest']) require(field)
237
+ if (normalized.job !== normalized.candidate) fail('HARBOR_CONTEXT_INVALID', `${name}.job must match candidate for gate`)
238
+ if (normalized.comparisonDigest !== undefined) fail('HARBOR_CONTEXT_INVALID', `${name}.comparisonDigest is not valid for gate`)
239
+ } else {
240
+ for (const field of ['baseline', 'candidate', 'comparisonDigest', 'policy', 'policyVersion', 'policyDigest', 'reportDigest']) {
241
+ if (normalized[field] !== undefined) fail('HARBOR_CONTEXT_INVALID', `${name}.${field} is only valid for compare or gate`)
242
+ }
243
+ }
244
+ const canonical = kind === 'workspace' ? normalized.id : kind === 'job' ? normalized.job : kind === 'trial' ? normalized.trial : kind === 'criterion' ? normalized.criterion : kind === 'evidence' ? normalized.evidenceRef : kind === 'compare' ? normalized.comparisonDigest : kind === 'gate' ? normalized.reportDigest : undefined
245
+ if (canonical !== undefined && normalized.id !== canonical) fail('HARBOR_CONTEXT_INVALID', `${name}.id must match its typed reference`)
246
+ return normalized
247
+ }
248
+
249
+ function normalizeRoute(value) {
250
+ const source = record(value, 'context.route')
251
+ const name = enumValue(source.name, 'context.route.name', ROUTES, { required: true })
252
+ const params = source.params === undefined ? {} : record(source.params, 'context.route.params')
253
+ const normalizedParams = compact({
254
+ job: stableId(params.job, 'context.route.params.job', { max: 240 }),
255
+ stage: enumValue(params.stage, 'context.route.params.stage', STAGES),
256
+ trial: stableId(params.trial, 'context.route.params.trial', { max: 240 }),
257
+ detailTab: enumValue(params.detailTab, 'context.route.params.detailTab', DETAIL_TABS),
258
+ criterion: stableId(params.criterion, 'context.route.params.criterion', { max: 180 }),
259
+ evidenceRef: evidenceRef(params.evidenceRef, 'context.route.params.evidenceRef'),
260
+ baseline: stableId(params.baseline, 'context.route.params.baseline', { max: 240 }),
261
+ candidate: stableId(params.candidate, 'context.route.params.candidate', { max: 240 }),
262
+ policy: stableId(params.policy, 'context.route.params.policy', { max: 180 }),
263
+ policyVersion: string(params.policyVersion, 'context.route.params.policyVersion', { max: 120 }),
264
+ policyDigest: digest(params.policyDigest, 'context.route.params.policyDigest'),
265
+ reportDigest: digest(params.reportDigest, 'context.route.params.reportDigest'),
266
+ })
267
+ if (normalizedParams.policyVersion !== undefined && !VERSION_PATTERN.test(normalizedParams.policyVersion)) fail('HARBOR_CONTEXT_INVALID', 'context.route.params.policyVersion is invalid')
268
+ const rule = ROUTE_RULES[name]
269
+ for (const key of rule.required) if (normalizedParams[key] === undefined) fail('HARBOR_CONTEXT_INVALID', `context.route.params.${key} is required for ${name}`)
270
+ for (const key of Object.keys(normalizedParams)) if (!rule.allowed.includes(key)) fail('HARBOR_CONTEXT_INVALID', `context.route.params.${key} is not valid for ${name}`)
271
+ if (normalizedParams.criterion && normalizedParams.evidenceRef) fail('HARBOR_CONTEXT_INVALID', 'a route cannot focus a criterion and evidence at the same time')
272
+ return { name, params: normalizedParams }
273
+ }
274
+
275
+ function normalizeViewState(value) {
276
+ if (value === undefined) return undefined
277
+ const source = record(value, 'context.viewState')
278
+ const filters = source.filters === undefined ? undefined : record(source.filters, 'context.viewState.filters')
279
+ const normalizedFilters = filters === undefined ? undefined : Object.fromEntries(Object.entries(filters)
280
+ .filter(([key]) => FILTER_KEYS.has(key))
281
+ .map(([key, item]) => {
282
+ if (key === 'status') return [key, enumValue(item, 'context.viewState.filters.status', FILTER_STATUSES)]
283
+ if (key === 'validity') return [key, enumValue(item, 'context.viewState.filters.validity', FILTER_VALIDITIES)]
284
+ return [key, stableId(item, 'context.viewState.filters.segment', { max: 120 })]
285
+ })
286
+ .filter(([, item]) => item !== undefined))
287
+ return compactNonEmpty({
288
+ detailTab: enumValue(source.detailTab, 'context.viewState.detailTab', DETAIL_TABS),
289
+ filters: normalizedFilters && Object.keys(normalizedFilters).length ? normalizedFilters : undefined,
290
+ sort: enumValue(source.sort, 'context.viewState.sort', SORTS),
291
+ segment: stableId(source.segment, 'context.viewState.segment', { max: 120 }),
292
+ })
293
+ }
294
+
295
+ function normalizeObservedAt(value) {
296
+ const observedAt = string(value, 'context.observedAt', { required: true, max: 80 })
297
+ if (!ISO_8601_PATTERN.test(observedAt) || Number.isNaN(Date.parse(observedAt))) fail('HARBOR_CONTEXT_INVALID', 'context.observedAt must be ISO-8601')
298
+ return new Date(observedAt).toISOString()
299
+ }
300
+
301
+ function assertRefMatchesRoute(ref, route, name) {
302
+ const params = route.params
303
+ if (ref.job !== undefined && ref.job !== params.job) fail('HARBOR_CONTEXT_INVALID', `${name}.job does not match the route`)
304
+ if (ref.trial !== undefined && ref.trial !== params.trial) fail('HARBOR_CONTEXT_INVALID', `${name}.trial does not match the route`)
305
+ if (ref.stage !== undefined && params.stage !== undefined && ref.stage !== params.stage) fail('HARBOR_CONTEXT_INVALID', `${name}.stage does not match the route`)
306
+ if (ref.criterion !== undefined && params.criterion !== undefined && ref.criterion !== params.criterion) fail('HARBOR_CONTEXT_INVALID', `${name}.criterion does not match the route`)
307
+ if (ref.evidenceRef !== undefined && params.evidenceRef !== undefined && ref.evidenceRef !== params.evidenceRef) fail('HARBOR_CONTEXT_INVALID', `${name}.evidenceRef does not match the route`)
308
+ for (const field of ['baseline', 'candidate', 'policy', 'policyVersion', 'policyDigest', 'reportDigest']) {
309
+ if (ref[field] !== undefined && ref[field] !== params[field]) fail('HARBOR_CONTEXT_INVALID', `${name}.${field} does not match the route`)
310
+ }
311
+ if (['trial', 'criterion', 'evidence'].includes(ref.kind) && route.name !== 'harbor.trial.detail') fail('HARBOR_CONTEXT_INVALID', `${name}.kind is not valid for ${route.name}`)
312
+ }
313
+
314
+ function assertContextConsistency(context) {
315
+ const { route, object, selection, viewState } = context
316
+ const rule = ROUTE_RULES[route.name]
317
+ if (route.name === 'harbor.compare' && object?.kind !== 'compare') fail('HARBOR_CONTEXT_INVALID', 'harbor.compare requires one concrete Compare object')
318
+ if (route.name === 'harbor.gate' && object?.kind !== 'gate') fail('HARBOR_CONTEXT_INVALID', 'harbor.gate requires one concrete Gate object')
319
+ if (object) {
320
+ if (!rule.objectKinds.has(object.kind)) fail('HARBOR_CONTEXT_INVALID', `context.object.kind is not valid for ${route.name}`)
321
+ assertRefMatchesRoute(object, route, 'context.object')
322
+ if (object.kind === 'workspace' && object.id !== context.workspace) fail('HARBOR_CONTEXT_INVALID', 'context.object.id does not match context.workspace')
323
+ if (object.kind === 'criterion' && object.criterion !== route.params.criterion) fail('HARBOR_CONTEXT_INVALID', 'context.object.criterion must match the route focus')
324
+ if (object.kind === 'evidence' && object.evidenceRef !== route.params.evidenceRef) fail('HARBOR_CONTEXT_INVALID', 'context.object.evidenceRef must match the route focus')
325
+ }
326
+ const seen = new Set()
327
+ for (const [index, ref] of (selection ?? []).entries()) {
328
+ const name = `context.selection[${index}]`
329
+ assertRefMatchesRoute(ref, route, name)
330
+ const key = JSON.stringify([ref.kind, ref.id, ref.job, ref.trial])
331
+ if (seen.has(key)) fail('HARBOR_CONTEXT_INVALID', 'context.selection contains a duplicate reference')
332
+ seen.add(key)
333
+ }
334
+ const focused = selection?.at(-1)
335
+ if (route.params.criterion && !(focused?.kind === 'criterion' && focused.criterion === route.params.criterion)) fail('HARBOR_CONTEXT_INVALID', 'context.route.params.criterion must match the final explicit selection')
336
+ if (route.params.evidenceRef && !(focused?.kind === 'evidence' && focused.evidenceRef === route.params.evidenceRef)) fail('HARBOR_CONTEXT_INVALID', 'context.route.params.evidenceRef must match the final explicit selection')
337
+ if (route.params.detailTab && viewState?.detailTab && route.params.detailTab !== viewState.detailTab) fail('HARBOR_CONTEXT_INVALID', 'context.viewState.detailTab does not match the route')
338
+ }
339
+
340
+ export function normalizeHarborUiContext(value, expectedSessionId) {
341
+ assertRawPayloadSize(value)
342
+ const source = record(value, 'context')
343
+ const schema = string(source.schema, 'context.schema', { required: true, max: 80 })
344
+ if (schema !== HARBOR_UI_CONTEXT_SCHEMA) fail('HARBOR_CONTEXT_INVALID', `context.schema must be ${HARBOR_UI_CONTEXT_SCHEMA}`)
345
+ assertSafeJsonTree(value)
346
+ const sessionId = stableId(source.sessionId, 'context.sessionId', { required: true, max: 240 })
347
+ if (expectedSessionId && sessionId !== expectedSessionId) fail('HARBOR_CONTEXT_SESSION_MISMATCH', 'context session does not match the request')
348
+ const pageSessionId = stableId(source.pageSessionId, 'context.pageSessionId', { required: true, max: 240 })
349
+ if (!Number.isSafeInteger(source.generation) || source.generation < 1) fail('HARBOR_CONTEXT_INVALID', 'context.generation must be a positive integer')
350
+ const workspace = stableId(source.workspace, 'context.workspace', { required: true, max: 240 })
351
+ const selection = source.selection === undefined ? undefined : source.selection
352
+ if (selection !== undefined && (!Array.isArray(selection) || selection.length > 25)) fail('HARBOR_CONTEXT_INVALID', 'context.selection must contain at most 25 references')
353
+ const identitiesSource = source.identities === undefined ? undefined : record(source.identities, 'context.identities')
354
+ const flagsSource = source.flags === undefined ? undefined : record(source.flags, 'context.flags')
355
+ const normalized = compact({
356
+ schema: HARBOR_UI_CONTEXT_SCHEMA,
357
+ sessionId,
358
+ pageSessionId,
359
+ generation: source.generation,
360
+ workspace,
361
+ route: normalizeRoute(source.route),
362
+ object: source.object === undefined ? undefined : normalizeObjectRef(source.object, 'context.object'),
363
+ selection: selection?.map((item, index) => normalizeObjectRef(item, `context.selection[${index}]`)),
364
+ viewState: normalizeViewState(source.viewState),
365
+ identities: identitiesSource === undefined ? undefined : compactNonEmpty({
366
+ candidate: normalizeIdentity(identitiesSource.candidate, 'context.identities.candidate'),
367
+ dataset: normalizeIdentity(identitiesSource.dataset, 'context.identities.dataset'),
368
+ context: normalizeIdentity(identitiesSource.context, 'context.identities.context'),
369
+ stack: normalizeIdentity(identitiesSource.stack, 'context.identities.stack'),
370
+ evaluator: normalizeIdentity(identitiesSource.evaluator, 'context.identities.evaluator'),
371
+ }),
372
+ flags: flagsSource === undefined ? undefined : compactNonEmpty({
373
+ legacy: boolean(flagsSource.legacy, 'context.flags.legacy'),
374
+ comparable: boolean(flagsSource.comparable, 'context.flags.comparable'),
375
+ scoreValid: boolean(flagsSource.scoreValid, 'context.flags.scoreValid'),
376
+ }),
377
+ artifactRevision: digest(source.artifactRevision, 'context.artifactRevision'),
378
+ observedAt: normalizeObservedAt(source.observedAt),
379
+ })
380
+ assertContextConsistency(normalized)
381
+ if (Buffer.byteLength(JSON.stringify(normalized), 'utf8') > MAX_UI_CONTEXT_BYTES) fail('HARBOR_CONTEXT_TOO_LARGE', `context payload exceeds ${MAX_UI_CONTEXT_BYTES} bytes`)
382
+ return normalized
383
+ }
384
+
385
+ export function harborContextLabel(context) {
386
+ const selected = context.selection?.at(-1)
387
+ if (selected?.kind === 'criterion') return `Trial ${selected.trial ?? context.object?.trial ?? '—'} · ${selected.criterion ?? selected.id ?? 'Criterion'}`
388
+ if (selected?.kind === 'evidence') return `Trial ${selected.trial ?? context.object?.trial ?? '—'} · Evidence`
389
+ const object = context.object
390
+ if (object?.kind === 'trial') return `Trial ${object.trial ?? object.id ?? '—'}`
391
+ if (object?.kind === 'job') return `Job ${object.job ?? object.id ?? '—'}`
392
+ return `Harbor · ${context.route.params.job ?? context.workspace}`
393
+ }
394
+
395
+ export function harborContextMention(context, token) {
396
+ const label = harborContextLabel(context).replace(/[\[\]()]/g, '')
397
+ return `@harbor[${label}](${token})`
398
+ }
399
+
400
+ export function harborNavigationTarget(context) {
401
+ const selected = context.selection?.at(-1)
402
+ return compact({
403
+ route: context.route.name,
404
+ workspace: context.workspace,
405
+ job: context.route.params.job ?? context.object?.job,
406
+ stage: context.route.params.stage ?? context.object?.stage,
407
+ trial: selected?.trial ?? context.route.params.trial ?? context.object?.trial,
408
+ detailTab: context.route.params.detailTab ?? context.viewState?.detailTab,
409
+ criterion: selected?.criterion ?? context.route.params.criterion ?? context.object?.criterion,
410
+ evidenceRef: selected?.evidenceRef ?? context.route.params.evidenceRef ?? context.object?.evidenceRef,
411
+ baseline: context.route.params.baseline ?? context.object?.baseline,
412
+ candidate: context.route.params.candidate ?? context.object?.candidate,
413
+ policy: context.route.params.policy ?? context.object?.policy,
414
+ policyVersion: context.route.params.policyVersion ?? context.object?.policyVersion,
415
+ policyDigest: context.route.params.policyDigest ?? context.object?.policyDigest,
416
+ reportDigest: context.route.params.reportDigest ?? context.object?.reportDigest,
417
+ filters: context.viewState?.filters,
418
+ sort: context.viewState?.sort,
419
+ })
420
+ }
421
+
422
+ function positiveInteger(value, name) {
423
+ if (!Number.isSafeInteger(value) || value < 1) throw new TypeError(`${name} must be a positive integer`)
424
+ return value
425
+ }
426
+
427
+ function normalizeProjectRoot(value) {
428
+ if (typeof value !== 'string' || !value.trim()) fail('HARBOR_CONTEXT_INVALID', 'projectRoot is required')
429
+ return path.resolve(value)
430
+ }
431
+
432
+ function streamKey(sessionId, pageSessionId) {
433
+ return JSON.stringify([sessionId, pageSessionId])
434
+ }
435
+
436
+ function deepFreeze(value) {
437
+ if (!value || typeof value !== 'object' || Object.isFrozen(value)) return value
438
+ for (const item of Object.values(value)) deepFreeze(item)
439
+ return Object.freeze(value)
440
+ }
441
+
442
+ function publication(entry) {
443
+ return {
444
+ schema: HARBOR_UI_CONTEXT_SCHEMA,
445
+ contextSnapshotId: entry.token,
446
+ context: entry.context,
447
+ generation: entry.context.generation,
448
+ digest: entry.digest,
449
+ expiresAt: new Date(entry.expiresAtMs).toISOString(),
450
+ label: harborContextLabel(entry.context),
451
+ reference: harborContextMention(entry.context, entry.token),
452
+ }
453
+ }
454
+
455
+ export class HarborUiContextRegistry {
456
+ constructor({ ttlMs = DEFAULT_UI_CONTEXT_TTL_MS, maxEntries = DEFAULT_UI_CONTEXT_MAX_ENTRIES, maxEntriesPerSession = Math.min(DEFAULT_UI_CONTEXT_MAX_ENTRIES_PER_SESSION, maxEntries), now = () => Date.now(), random = size => randomBytes(size) } = {}) {
457
+ this.ttlMs = positiveInteger(ttlMs, 'ttlMs')
458
+ this.maxEntries = positiveInteger(maxEntries, 'maxEntries')
459
+ this.maxEntriesPerSession = positiveInteger(maxEntriesPerSession, 'maxEntriesPerSession')
460
+ if (this.maxEntriesPerSession > this.maxEntries) throw new TypeError('maxEntriesPerSession must not exceed maxEntries')
461
+ this.now = now
462
+ this.random = random
463
+ this.entries = new Map()
464
+ this.streams = new Map()
465
+ }
466
+
467
+ issue({ sessionId, context, projectRoot }) {
468
+ const normalized = deepFreeze(normalizeHarborUiContext(context, sessionId))
469
+ const root = normalizeProjectRoot(projectRoot)
470
+ const createdAtMs = this.now()
471
+ this.prune(createdAtMs)
472
+ const digestValue = `sha256:${createHash('sha256').update(JSON.stringify(normalized)).digest('hex')}`
473
+ const key = streamKey(normalized.sessionId, normalized.pageSessionId)
474
+ const stream = this.streams.get(key)
475
+ if (stream) {
476
+ if (stream.projectRoot !== root) fail('HARBOR_CONTEXT_PROJECT_MISMATCH', 'Harbor page context cannot move to a different project')
477
+ if (normalized.generation < stream.generation) fail('HARBOR_CONTEXT_STALE_GENERATION', 'context.generation is older than the current page generation')
478
+ if (normalized.generation === stream.generation) {
479
+ if (stream.digest !== digestValue || stream.projectRoot !== root) fail('HARBOR_CONTEXT_GENERATION_CONFLICT', 'context.generation was already issued with different state')
480
+ const existing = this.entries.get(stream.token)
481
+ if (existing) return publication(existing)
482
+ }
483
+ }
484
+ let sessionEntries = 0
485
+ for (const entry of this.entries.values()) if (entry.sessionId === normalized.sessionId) sessionEntries += 1
486
+ if (sessionEntries >= this.maxEntriesPerSession) fail('HARBOR_CONTEXT_CAPACITY', 'Harbor context capacity for this Session has been reached')
487
+ if (this.entries.size >= this.maxEntries) fail('HARBOR_CONTEXT_CAPACITY', 'Harbor context registry capacity has been reached')
488
+ let token
489
+ for (let attempt = 0; attempt < 8; attempt += 1) {
490
+ const candidate = `hctx_${Buffer.from(this.random(24)).toString('base64url')}`
491
+ if (TOKEN_PATTERN.test(candidate) && !this.entries.has(candidate)) { token = candidate; break }
492
+ }
493
+ if (!token) fail('HARBOR_CONTEXT_CAPACITY', 'unable to allocate a unique context token')
494
+ const expiresAtMs = createdAtMs + this.ttlMs
495
+ const entry = Object.freeze({ token, sessionId: normalized.sessionId, context: normalized, projectRoot: root, digest: digestValue, createdAtMs, expiresAtMs })
496
+ this.entries.set(token, entry)
497
+ this.streams.set(key, Object.freeze({ sessionId: normalized.sessionId, pageSessionId: normalized.pageSessionId, generation: normalized.generation, digest: digestValue, token, projectRoot: root, expiresAtMs }))
498
+ return publication(entry)
499
+ }
500
+
501
+ resolve({ contextSnapshotId, sessionId, projectRoot }) {
502
+ const token = string(contextSnapshotId, 'contextSnapshotId', { required: true, max: 100 })
503
+ if (!TOKEN_PATTERN.test(token)) fail('HARBOR_CONTEXT_INVALID_TOKEN', 'contextSnapshotId is invalid')
504
+ const ownerSessionId = stableId(sessionId, 'sessionId', { required: true, max: 240 })
505
+ const root = normalizeProjectRoot(projectRoot)
506
+ this.prune(this.now())
507
+ const entry = this.entries.get(token)
508
+ if (!entry) fail('HARBOR_CONTEXT_EXPIRED', 'Harbor context is unavailable or expired; bind the current page again')
509
+ if (entry.sessionId !== ownerSessionId) fail('HARBOR_CONTEXT_SESSION_MISMATCH', 'Harbor context belongs to a different DSH Session')
510
+ if (root !== entry.projectRoot) fail('HARBOR_CONTEXT_PROJECT_MISMATCH', 'Harbor context belongs to a different project')
511
+ return entry
512
+ }
513
+
514
+ prune(now = this.now()) {
515
+ for (const [token, entry] of this.entries) if (entry.expiresAtMs <= now) this.entries.delete(token)
516
+ for (const [key, stream] of this.streams) if (stream.expiresAtMs <= now || !this.entries.has(stream.token)) this.streams.delete(key)
517
+ }
518
+ }
package/lib/web.js CHANGED
@@ -1,3 +1,5 @@
1
+ import { redactCredentialText, redactLocalPaths, redactOpaqueSecretText } from './credential-redaction.js'
2
+
1
3
  export const DASHBOARD_ROUTE = '/_dsh/harbor-evolution/dashboard'
2
4
  export const JOB_ROUTE = '/_dsh/harbor-evolution/job'
3
5
  export const TRIALS_ROUTE = '/_dsh/harbor-evolution/trials'
@@ -13,7 +15,10 @@ export const VERSION_ROUTE = '/_dsh/harbor-evolution/version'
13
15
  export const HISTORICAL_PREVIEW_ROUTE = '/_dsh/harbor-evolution/historical-preview'
14
16
  export const HISTORICAL_RUN_ROUTE = '/_dsh/harbor-evolution/historical-run'
15
17
  export const HISTORICAL_OPERATION_ROUTE = '/_dsh/harbor-evolution/historical-operation'
18
+ export const SESSION_CONTEXT_ROUTE = '/_dsh/harbor-evolution/session-context'
19
+ export const SESSION_CONTEXT_RESOLVE_ROUTE = '/_dsh/harbor-evolution/session-context-resolve'
16
20
  const MAX_MUTATION_BYTES = 256 * 1024
21
+ const SAFE_ERROR_CODE = /^[A-Z][A-Z0-9_]{2,127}$/
17
22
 
18
23
  function sendJson(response, status, body) {
19
24
  response.writeHead(status, {
@@ -29,7 +34,7 @@ export function isSameOriginRequest(request) {
29
34
  if (fetchSite && fetchSite !== 'same-origin' && fetchSite !== 'none') return false
30
35
  const origin = request.headers.origin
31
36
  if (!origin) {
32
- if (fetchSite === 'same-origin' || fetchSite === 'none') return true
37
+ if (fetchSite === 'same-origin') return true
33
38
  const address = request.socket?.remoteAddress ?? ''
34
39
  return address === '::1' || address === '127.0.0.1' || address.startsWith('127.') || address.startsWith('::ffff:127.')
35
40
  }
@@ -45,7 +50,16 @@ export function isSameOriginRequest(request) {
45
50
 
46
51
  function safeError(error) {
47
52
  const message = error instanceof Error ? error.message : String(error)
48
- return message.replace(/(?:\/[A-Za-z0-9._ -]+){2,}/g, '[local path]').replace(/[A-Za-z]:\\[^\s]+/g, '[local path]')
53
+ return redactLocalPaths(redactOpaqueSecretText(redactCredentialText(message, '[redacted]'), '[redacted]'))
54
+ }
55
+
56
+ function safeErrorPayload(error, fallbackCode) {
57
+ const message = safeError(error)
58
+ const directCode = error && typeof error === 'object' && typeof error.code === 'string' && SAFE_ERROR_CODE.test(error.code)
59
+ ? error.code
60
+ : undefined
61
+ const embeddedCode = message.match(/^(?:[A-Za-z]+Error:\s*)?([A-Z][A-Z0-9_]{2,127})(?::|\b)/)?.[1]
62
+ return { code: directCode ?? embeddedCode ?? fallbackCode, message }
49
63
  }
50
64
 
51
65
  export function createApiHandler(load, code = 'request-failed') {
@@ -61,9 +75,9 @@ export function createApiHandler(load, code = 'request-failed') {
61
75
  }
62
76
  const url = new URL(request.url ?? '/', 'http://localhost')
63
77
  const args = Object.fromEntries(url.searchParams)
64
- Promise.resolve(load(args)).then(
78
+ Promise.resolve().then(() => load(args)).then(
65
79
  value => sendJson(response, 200, { ok: true, value }),
66
- error => sendJson(response, 500, { ok: false, error: { code, message: safeError(error) } }),
80
+ error => sendJson(response, 500, { ok: false, error: safeErrorPayload(error, code) }),
67
81
  )
68
82
  }
69
83
  }
@@ -102,7 +116,7 @@ export function createMutationHandler(update, code = 'update-failed') {
102
116
  const value = await update(body)
103
117
  sendJson(response, 200, { ok: true, value })
104
118
  } catch (error) {
105
- sendJson(response, 400, { ok: false, error: { code, message: safeError(error) } })
119
+ sendJson(response, 400, { ok: false, error: safeErrorPayload(error, code) })
106
120
  }
107
121
  }
108
122
  }
@@ -119,13 +133,25 @@ export function installDashboardWeb(ctx, service, historicalController) {
119
133
  [PROGRESS_ROUTE, createApiHandler(args => service.progress(args), 'progress-unavailable')],
120
134
  [COMPARE_ROUTE, createApiHandler(args => service.comparison(args), 'comparison-unavailable')],
121
135
  [GOVERNANCE_ROUTE, createApiHandler(args => service.governance(args), 'governance-unavailable')],
122
- [EVALUATOR_ROUTE, createMutationHandler(args => service.evaluator(args), 'evaluator-update-failed')],
136
+ [EVALUATOR_ROUTE, createMutationHandler(args => service.evaluator(args, { browser: true }), 'evaluator-update-failed')],
123
137
  [META_ROUTE, createApiHandler(args => service.meta(args), 'meta-evaluation-unavailable')],
124
138
  ...(historicalController ? [
125
139
  [HISTORICAL_PREVIEW_ROUTE, createMutationHandler(args => historicalController.preview(args), 'historical-preview-failed')],
126
140
  [HISTORICAL_RUN_ROUTE, createMutationHandler(args => historicalController.run(args), 'historical-run-failed')],
127
141
  [HISTORICAL_OPERATION_ROUTE, createApiHandler(args => historicalController.operation(args), 'historical-operation-unavailable')],
128
142
  ] : []),
143
+ [SESSION_CONTEXT_ROUTE, createMutationHandler(args => service.bindUiContext(args), 'session-context-bind-failed')],
144
+ ['/_dsh/harbor-evolution/trial-selection', createMutationHandler(args => service.createTrialSelection(args), 'trial-selection-failed')],
145
+ ['/_dsh/harbor-evolution/selection-detail', createApiHandler(args => service.trialSelection(args), 'trial-selection-failed')],
146
+ ['/_dsh/harbor-evolution/action-draft', createMutationHandler(args => service.proposeAction(args), 'action-draft-failed')],
147
+ ['/_dsh/harbor-evolution/action-preview', createMutationHandler(args => service.previewAction(args), 'action-preview-failed')],
148
+ ['/_dsh/harbor-evolution/action-confirm', createMutationHandler(args => service.confirmAction(args), 'action-confirm-failed')],
149
+ ['/_dsh/harbor-evolution/action-operation', createApiHandler(args => service.actionOperation(args), 'action-operation-failed')],
150
+ ['/_dsh/harbor-evolution/action-operations', createApiHandler(args => service.listActionOperations(args), 'action-operations-failed')],
151
+ ['/_dsh/harbor-evolution/action-inspect', createApiHandler(args => service.inspectActionOperation(args), 'action-inspect-failed')],
152
+ ['/_dsh/harbor-evolution/action-recover', createMutationHandler(args => service.recoverActionOperation(args), 'action-recover-failed')],
153
+ ['/_dsh/harbor-evolution/action-cancel', createMutationHandler(args => service.cancelAction(args), 'action-cancel-failed')],
154
+ [SESSION_CONTEXT_RESOLVE_ROUTE, createMutationHandler(args => service.resolveBrowserUiContext(args), 'session-context-resolve-failed')],
129
155
  [VERSION_ROUTE, createApiHandler(args => service.version(args), 'version-check-unavailable')],
130
156
  [PROJECT_ROOT_ROUTE, createMutationHandler(args => service.setProjectRoot(args), 'project-root-update-failed')],
131
157
  ]
@@ -0,0 +1,27 @@
1
+ export const ATTENTION_FILTERS = ['all', 'running', 'blocked', 'stalled', 'infrastructure', 'invalid', 'regressed', 'gate', 'fresh-baseline']
2
+
3
+ export function jobAttention(job) {
4
+ const total = Number(job.nTrials ?? job.progress?.total ?? 0)
5
+ const infrastructure = Number(job.nInfrastructureExceptions ?? 0)
6
+ const invalid = Number(job.nInvalidScores ?? 0)
7
+ const reasons = job.promotion?.reasons ?? []
8
+ if (total > 0 && infrastructure >= total) return { kind: 'blocked', rank: 0, count: infrastructure }
9
+ if (job.progress?.health === 'stalled') return { kind: 'stalled', rank: 1, count: Math.max(1, total - Number(job.progress?.completed ?? 0)) }
10
+ if (infrastructure > 0) return { kind: 'infrastructure', rank: 2, count: infrastructure }
11
+ if (invalid > 0 || job.nEvaluationExceptions > 0 || job.status === 'failed') return { kind: 'invalid', rank: 3, count: invalid || job.nEvaluationExceptions || 1 }
12
+ if (job.promotion?.regressions > 0) return { kind: 'regressed', rank: 4, count: job.promotion.regressions }
13
+ if (reasons.some(reason => /fresh.?baseline|context.*mismatch|not.comparable/i.test(typeof reason === 'string' ? reason : reason?.code ?? ''))) return { kind: 'fresh-baseline', rank: 6, count: 1 }
14
+ if (job.promotion && job.promotion.decision !== 'PROMOTE') return { kind: 'gate', rank: 5, count: reasons.length || 1 }
15
+ return { kind: job.progress?.active ? 'running' : 'healthy', rank: 9, count: 0 }
16
+ }
17
+
18
+ export function matchesJobFilter(job, filter) {
19
+ if (!filter || filter === 'all') return true
20
+ if (filter === 'running') return Boolean(job.progress?.active)
21
+ const kind = jobAttention(job).kind
22
+ return filter === 'infrastructure' ? kind === 'infrastructure' || kind === 'blocked' : kind === filter
23
+ }
24
+
25
+ export function attentionCounts(jobs) {
26
+ return Object.fromEntries(ATTENTION_FILTERS.map(filter => [filter, jobs.filter(job => matchesJobFilter(job, filter)).length]))
27
+ }