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.
- package/README.md +28 -6
- package/index.js +120 -19
- package/lib/action-drafts.js +443 -0
- package/lib/bounded-process.js +190 -0
- package/lib/candidate-runtime.js +160 -0
- package/lib/candidate.js +7 -11
- package/lib/client.js +4110 -292
- package/lib/composer-context.js +56 -0
- package/lib/credential-redaction.js +155 -0
- package/lib/dashboard.js +417 -98
- package/lib/diagnostic-observation.js +175 -0
- package/lib/diagnostic-runner.js +206 -0
- package/lib/evaluator-saves.js +129 -0
- package/lib/evolution.js +126 -32
- package/lib/historical-run-lock.js +102 -0
- package/lib/historical-web.js +52 -16
- package/lib/interaction-objects.js +56 -0
- package/lib/model-runtime.js +48 -3
- package/lib/process.js +27 -1
- package/lib/runtime-identity.js +0 -1
- package/lib/service.js +1427 -28
- package/lib/session-diagnostic.js +0 -1
- package/lib/session-redaction.js +17 -31
- package/lib/session-selection.js +5 -3
- package/lib/trial-selection.js +46 -0
- package/lib/ui-context.js +518 -0
- package/lib/web.js +32 -6
- package/lib/workbench-health.js +27 -0
- package/package.json +4 -4
- package/skills/evolve-agent-with-harbor/SKILL.md +33 -4
|
@@ -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'
|
|
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
|
|
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:
|
|
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:
|
|
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
|
+
}
|