dsh-harbor-evolution 0.4.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -7
- package/index.js +109 -2
- package/lib/candidate.js +29 -2
- package/lib/client.js +767 -260
- package/lib/dashboard.js +644 -67
- package/lib/evolution.js +164 -26
- package/lib/process.js +2 -1
- package/lib/service.js +137 -2
- package/lib/web.js +76 -15
- package/package.json +7 -1
- package/schemas/evaluation-result.schema.json +27 -0
- package/schemas/evaluator-observations.schema.json +51 -0
- package/schemas/ground-truth.schema.json +61 -0
- package/schemas/meta-evaluation-report.schema.json +23 -0
- package/skills/evolve-agent-with-harbor/SKILL.md +149 -78
- package/skills/evolve-agent-with-harbor/references/evaluator-upgrade.md +61 -0
- package/skills/evolve-agent-with-harbor/references/initialization.md +100 -81
package/lib/dashboard.js
CHANGED
|
@@ -1,15 +1,45 @@
|
|
|
1
|
-
import { access, constants, readdir, readFile, stat } from 'node:fs/promises'
|
|
1
|
+
import { access, constants, lstat, readdir, readFile, stat } from 'node:fs/promises'
|
|
2
2
|
import path from 'node:path'
|
|
3
3
|
|
|
4
4
|
import { resolveWithin } from './evolution.js'
|
|
5
5
|
|
|
6
6
|
const SUMMARY_NAME = 'evaluation-summary.json'
|
|
7
|
-
const PROMOTION_NAME = 'promotion-report.json'
|
|
8
7
|
const MAX_JOBS = 50
|
|
8
|
+
const MAX_JSON_BYTES = 2 * 1024 * 1024
|
|
9
|
+
const MAX_SOURCE_BYTES = 128 * 1024
|
|
10
|
+
const MAX_PREVIEW_BYTES = 512 * 1024
|
|
11
|
+
const MAX_TRIAL_LIMIT = 100
|
|
12
|
+
const jsonCache = new Map()
|
|
13
|
+
const SENSITIVE_KEY = /authorization|cookie|token|api[_-]?key|secret|password|request[_-]?headers/i
|
|
14
|
+
const SENSITIVE_SOURCE_VALUE = /(authorization|cookie|token|api[_-]?key|secret|password)\s*[:=]\s*([^\s,;]+)/gi
|
|
9
15
|
|
|
10
|
-
|
|
16
|
+
function safeSegment(value, label) {
|
|
17
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,199}$/.test(String(value ?? ''))) throw new Error(`${label} is invalid`)
|
|
18
|
+
return String(value)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function redact(value, depth = 0, maxText = 8_000) {
|
|
22
|
+
if (depth > 10) return '[TRUNCATED depth]'
|
|
23
|
+
if (Array.isArray(value)) return value.slice(0, 10_000).map(item => redact(item, depth + 1, maxText))
|
|
24
|
+
if (value && typeof value === 'object') {
|
|
25
|
+
return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, SENSITIVE_KEY.test(key) ? '[REDACTED]' : redact(item, depth + 1, maxText)]))
|
|
26
|
+
}
|
|
27
|
+
if (typeof value === 'string' && value.length > maxText) return `${value.slice(0, maxText)}\n[TRUNCATED ${value.length - maxText} chars]`
|
|
28
|
+
return value
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function readJson(file, { maxBytes = MAX_JSON_BYTES, maxText = 8_000 } = {}) {
|
|
11
32
|
try {
|
|
12
|
-
|
|
33
|
+
const details = await lstat(file)
|
|
34
|
+
if (details.isSymbolicLink()) return { __readError: `${path.basename(file)} may not be a symlink` }
|
|
35
|
+
if (!details.isFile()) return { __readError: `${path.basename(file)} is not a file` }
|
|
36
|
+
if (details.size > maxBytes) return { __readError: `${path.basename(file)} exceeds ${maxBytes} bytes` }
|
|
37
|
+
const cached = jsonCache.get(file)
|
|
38
|
+
const identity = `${details.mtimeMs}:${details.size}:${maxText}`
|
|
39
|
+
if (cached?.identity === identity) return cached.value
|
|
40
|
+
const value = redact(JSON.parse(await readFile(file, 'utf8')), 0, maxText)
|
|
41
|
+
jsonCache.set(file, { identity, value })
|
|
42
|
+
return value
|
|
13
43
|
} catch (error) {
|
|
14
44
|
if (error.code === 'ENOENT') return undefined
|
|
15
45
|
if (error instanceof SyntaxError) return { __readError: `invalid JSON in ${path.basename(file)}` }
|
|
@@ -17,59 +47,148 @@ async function readJson(file) {
|
|
|
17
47
|
}
|
|
18
48
|
}
|
|
19
49
|
|
|
50
|
+
async function readSafeText(file, projectRoot) {
|
|
51
|
+
const resolved = path.resolve(file)
|
|
52
|
+
const root = path.resolve(projectRoot)
|
|
53
|
+
if (resolved !== root && !resolved.startsWith(`${root}${path.sep}`)) return { error: 'source is outside projectRoot' }
|
|
54
|
+
try {
|
|
55
|
+
const details = await lstat(resolved)
|
|
56
|
+
if (!details.isFile() || details.isSymbolicLink()) return { error: 'source is not a safe file' }
|
|
57
|
+
if (details.size > MAX_SOURCE_BYTES) return { error: `source exceeds ${MAX_SOURCE_BYTES} bytes` }
|
|
58
|
+
const text = (await readFile(resolved, 'utf8')).replace(SENSITIVE_SOURCE_VALUE, '$1=[REDACTED]')
|
|
59
|
+
return { text: redact(text) }
|
|
60
|
+
} catch (error) {
|
|
61
|
+
return { error: error.code === 'ENOENT' ? 'source is unavailable' : 'source is unreadable' }
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
20
65
|
async function directoryCheck(directory, { optional = false } = {}) {
|
|
21
66
|
try {
|
|
22
|
-
const details = await
|
|
23
|
-
if (!details.isDirectory()) return { status: 'error', detail: 'not a directory' }
|
|
67
|
+
const details = await lstat(directory)
|
|
68
|
+
if (details.isSymbolicLink() || !details.isDirectory()) return { status: 'error', detail: 'not a safe directory' }
|
|
24
69
|
await access(directory, constants.R_OK)
|
|
25
70
|
return { status: 'ok', detail: 'readable' }
|
|
26
71
|
} catch (error) {
|
|
27
72
|
if (optional && error.code === 'ENOENT') return { status: 'warning', detail: 'not created yet' }
|
|
28
|
-
return { status: 'error', detail: error.code === 'ENOENT' ? 'not found' :
|
|
73
|
+
return { status: 'error', detail: error.code === 'ENOENT' ? 'not found' : 'not readable' }
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function fileCheck(file) {
|
|
78
|
+
try {
|
|
79
|
+
const details = await lstat(file)
|
|
80
|
+
return details.isFile() && !details.isSymbolicLink()
|
|
81
|
+
? { status: 'ok', detail: path.basename(file) }
|
|
82
|
+
: { status: 'error', detail: 'not a safe file' }
|
|
83
|
+
} catch (error) {
|
|
84
|
+
return { status: 'error', detail: error.code === 'ENOENT' ? 'not found' : 'not readable' }
|
|
29
85
|
}
|
|
30
86
|
}
|
|
31
87
|
|
|
32
88
|
async function executableCheck(command) {
|
|
33
89
|
if (!command) return { status: 'error', detail: 'not configured' }
|
|
34
|
-
if (!path.isAbsolute(command)) return { status: 'ok', detail: `${command} (
|
|
90
|
+
if (!path.isAbsolute(command)) return { status: 'ok', detail: `${command} (PATH)` }
|
|
35
91
|
try {
|
|
36
92
|
await access(command, constants.X_OK)
|
|
37
|
-
return { status: 'ok', detail: command }
|
|
93
|
+
return { status: 'ok', detail: path.basename(command) }
|
|
38
94
|
} catch (error) {
|
|
39
|
-
return { status: 'error', detail: error.code === 'ENOENT' ?
|
|
95
|
+
return { status: 'error', detail: error.code === 'ENOENT' ? 'not found' : 'not executable' }
|
|
40
96
|
}
|
|
41
97
|
}
|
|
42
98
|
|
|
43
|
-
function
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
99
|
+
function isObject(value) {
|
|
100
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function capabilityMap(summary, context, lifecycle, registry, stack) {
|
|
104
|
+
const contextV2 = context?.schema_version === 2
|
|
105
|
+
const scoreValidity = summary?.schema_version === 3
|
|
106
|
+
return {
|
|
107
|
+
contextV2,
|
|
108
|
+
trialLifecycle: lifecycle?.schema_version === 1,
|
|
109
|
+
scoreValidity,
|
|
110
|
+
evidenceProvenance: scoreValidity,
|
|
111
|
+
artifactRegistry: registry?.schema_version === 1,
|
|
112
|
+
compare: contextV2,
|
|
113
|
+
evaluatorGovernance: stack?.schema_version === 1,
|
|
114
|
+
gate: contextV2 && summary?.mode === 'promotion-eligible',
|
|
115
|
+
readOnlyLegacy: !contextV2,
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function primaryMetric(summary, contract) {
|
|
120
|
+
const name = contract?.primary_metric
|
|
121
|
+
if (name && typeof summary?.metrics?.[name] === 'number') return { name, value: summary.metrics[name] }
|
|
122
|
+
const entry = Object.entries(summary?.metrics ?? {}).find(([, value]) => typeof value === 'number')
|
|
123
|
+
return entry ? { name: entry[0], value: entry[1] } : undefined
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function progressView(summary, lifecycle, updatedAt) {
|
|
127
|
+
const total = Number(lifecycle?.dataset_total ?? summary?.n_trials ?? 0)
|
|
128
|
+
const lifecycleTrials = selectedLifecycleTrials(lifecycle)
|
|
129
|
+
const completed = lifecycle
|
|
130
|
+
? lifecycleTrials.filter(item => item.terminal).length
|
|
131
|
+
: Number(summary?.n_completed_trials ?? summary?.n_discovered_trials ?? summary?.n_trials ?? 0)
|
|
132
|
+
const active = lifecycle ? lifecycleTrials.some(item => !item.terminal) : !summary
|
|
133
|
+
const lastProgressAt = lifecycle?.updated_at ?? updatedAt
|
|
134
|
+
const ageMs = Math.max(0, Date.now() - Date.parse(lastProgressAt || updatedAt))
|
|
135
|
+
const errorCount = Number(summary?.n_infrastructure_exceptions ?? 0) + Number(summary?.n_evaluation_exceptions ?? 0)
|
|
136
|
+
const health = errorCount > 0 ? 'attention' : active && ageMs > 60_000 ? 'stalled' : active ? 'healthy' : 'completed'
|
|
137
|
+
return {
|
|
138
|
+
total,
|
|
139
|
+
completed: Math.min(completed, total || completed),
|
|
140
|
+
active,
|
|
141
|
+
percent: total ? Math.min(100, Math.round((completed / total) * 100)) : 0,
|
|
142
|
+
lastProgressAt,
|
|
143
|
+
health,
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function jobStatus(summary, lifecycle, progress) {
|
|
148
|
+
if (summary?.__readError) return 'failed'
|
|
149
|
+
if (!summary && !lifecycle) return 'pending'
|
|
150
|
+
if (progress.active) return 'running'
|
|
151
|
+
if (!summary && lifecycle) return 'running'
|
|
152
|
+
if (Number(summary.n_infrastructure_exceptions ?? summary.n_exceptions ?? 0) > 0 || Number(summary.n_evaluation_exceptions ?? 0) > 0) return 'partial'
|
|
153
|
+
if (Number(summary.n_invalid_scores ?? 0) > 0) return 'attention'
|
|
50
154
|
return 'completed'
|
|
51
155
|
}
|
|
52
156
|
|
|
53
157
|
async function readJob(jobsDir, entry, details) {
|
|
54
158
|
const directory = path.join(jobsDir, entry.name)
|
|
55
|
-
const summary = await
|
|
56
|
-
|
|
159
|
+
const [summary, contextFile, promotion, contract, lifecycle, registry, stack] = await Promise.all([
|
|
160
|
+
readJson(path.join(directory, SUMMARY_NAME)),
|
|
161
|
+
readJson(path.join(directory, 'evaluation-context.json')),
|
|
162
|
+
readJson(path.join(directory, 'promotion-report.json')),
|
|
163
|
+
readJson(path.join(directory, 'evaluation-contract.json')),
|
|
164
|
+
readJson(path.join(directory, 'trial-lifecycle.json')),
|
|
165
|
+
readJson(path.join(directory, 'artifact-registry.json')),
|
|
166
|
+
readJson(path.join(directory, 'evaluation-stack-manifest.json')),
|
|
167
|
+
])
|
|
168
|
+
const evaluationContext = summary?.evaluation_context ?? contextFile
|
|
169
|
+
if (!evaluationContext && !summary && !lifecycle) return undefined
|
|
170
|
+
const updatedAt = details.mtime.toISOString()
|
|
171
|
+
const progress = progressView(summary, lifecycle, updatedAt)
|
|
172
|
+
const capabilities = capabilityMap(summary, evaluationContext, lifecycle, registry, stack)
|
|
57
173
|
return {
|
|
58
174
|
name: entry.name,
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
nTrials:
|
|
175
|
+
updatedAt,
|
|
176
|
+
status: jobStatus(summary, lifecycle, progress),
|
|
177
|
+
mode: summary?.mode ?? evaluationContext?.mode,
|
|
178
|
+
nTrials: progress.total,
|
|
179
|
+
nDiscoveredTrials: Number(summary?.n_discovered_trials ?? lifecycle?.attempt_count ?? 0),
|
|
180
|
+
nValidScores: summary?.n_valid_scores,
|
|
181
|
+
nInvalidScores: summary?.n_invalid_scores,
|
|
63
182
|
nExceptions: Number(summary?.n_exceptions ?? 0),
|
|
183
|
+
primaryMetric: primaryMetric(summary, contract),
|
|
64
184
|
metrics: summary?.metrics ?? {},
|
|
65
|
-
candidate: summary?.candidate ??
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
} : undefined,
|
|
185
|
+
candidate: summary?.candidate ?? evaluationContext?.candidate,
|
|
186
|
+
dataset: evaluationContext?.dataset,
|
|
187
|
+
evaluationContext,
|
|
188
|
+
progress,
|
|
189
|
+
capabilities,
|
|
190
|
+
artifactValidation: summary?.artifact_validation,
|
|
191
|
+
promotion: promotion ? { decision: promotion.decision, reasons: promotion.reasons ?? [], baselineJob: promotion.baseline_job } : undefined,
|
|
73
192
|
readError: summary?.__readError,
|
|
74
193
|
}
|
|
75
194
|
}
|
|
@@ -82,62 +201,520 @@ async function listJobs(jobsDir) {
|
|
|
82
201
|
if (error.code === 'ENOENT') return []
|
|
83
202
|
throw error
|
|
84
203
|
}
|
|
85
|
-
const
|
|
86
|
-
|
|
87
|
-
.map(async entry => ({ entry, details: await stat(path.join(jobsDir, entry.name)) })))
|
|
204
|
+
const directories = entries.filter(entry => entry.isDirectory() && !entry.isSymbolicLink())
|
|
205
|
+
const recent = await Promise.all(directories.map(async entry => ({ entry, details: await stat(path.join(jobsDir, entry.name)) })))
|
|
88
206
|
recent.sort((left, right) => right.details.mtimeMs - left.details.mtimeMs)
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
.map(({ entry, details }) => readJob(jobsDir, entry, details)))
|
|
207
|
+
const jobs = await Promise.all(recent.map(({ entry, details }) => readJob(jobsDir, entry, details)))
|
|
208
|
+
return jobs.filter(Boolean).slice(0, MAX_JOBS)
|
|
92
209
|
}
|
|
93
210
|
|
|
94
|
-
function
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
211
|
+
function jobsDirectory(config) {
|
|
212
|
+
return resolveWithin(path.resolve(config.projectRoot), config.jobsDir, 'jobsDir')
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function jobDirectory(config, job) {
|
|
216
|
+
return path.join(jobsDirectory(config), safeSegment(job, 'job'))
|
|
99
217
|
}
|
|
100
218
|
|
|
101
219
|
export async function readDashboardSnapshot(config, metadata = {}) {
|
|
102
220
|
const projectRoot = path.resolve(config.projectRoot)
|
|
103
|
-
const jobsDir =
|
|
104
|
-
const [jobs, projectRootCheck, jobsDirCheck, harborCheck, harborDshCheck] = await Promise.all([
|
|
221
|
+
const jobsDir = jobsDirectory(config)
|
|
222
|
+
const [jobs, projectRootCheck, jobsDirCheck, harborCheck, harborDshCheck, stackCheck] = await Promise.all([
|
|
105
223
|
listJobs(jobsDir),
|
|
106
224
|
directoryCheck(projectRoot),
|
|
107
225
|
directoryCheck(jobsDir, { optional: true }),
|
|
108
226
|
executableCheck(config.harborBin),
|
|
109
227
|
executableCheck(config.harborDshBin),
|
|
228
|
+
fileCheck(path.join(projectRoot, '.harbor', 'evaluation-stack.yml')),
|
|
110
229
|
])
|
|
111
|
-
|
|
112
|
-
const
|
|
113
|
-
result[job.status] = (result[job.status] ?? 0) + 1
|
|
114
|
-
return result
|
|
115
|
-
}, {})
|
|
116
|
-
|
|
230
|
+
const counts = jobs.reduce((result, job) => ({ ...result, [job.status]: (result[job.status] ?? 0) + 1 }), {})
|
|
231
|
+
const latestMetric = jobs.find(job => job.primaryMetric)?.primaryMetric
|
|
117
232
|
return {
|
|
118
|
-
schemaVersion:
|
|
233
|
+
schemaVersion: 3,
|
|
119
234
|
generatedAt: new Date().toISOString(),
|
|
120
235
|
pluginVersion: metadata.pluginVersion ?? 'development',
|
|
121
|
-
config: {
|
|
122
|
-
|
|
123
|
-
jobsDir,
|
|
124
|
-
dshVersion: config.dshVersion,
|
|
125
|
-
agentImportPath: config.agentImportPath,
|
|
126
|
-
pluginImportPath: config.pluginImportPath,
|
|
127
|
-
},
|
|
128
|
-
checks: {
|
|
129
|
-
projectRoot: projectRootCheck,
|
|
130
|
-
jobsDir: jobsDirCheck,
|
|
131
|
-
harbor: harborCheck,
|
|
132
|
-
harborDsh: harborDshCheck,
|
|
133
|
-
},
|
|
236
|
+
config: { jobsDir: config.jobsDir, dshVersion: config.dshVersion, agentImportPath: config.agentImportPath, pluginImportPath: config.pluginImportPath },
|
|
237
|
+
checks: { projectRoot: projectRootCheck, jobsDir: jobsDirCheck, harbor: harborCheck, harborDsh: harborDshCheck, evaluationStack: stackCheck },
|
|
134
238
|
overview: {
|
|
135
239
|
totalJobs: jobs.length,
|
|
136
|
-
completedJobs: (counts.completed ?? 0) + (counts.partial ?? 0),
|
|
137
|
-
activeJobs: counts.pending ?? 0,
|
|
240
|
+
completedJobs: (counts.completed ?? 0) + (counts.partial ?? 0) + (counts.attention ?? 0),
|
|
241
|
+
activeJobs: (counts.pending ?? 0) + (counts.running ?? 0),
|
|
138
242
|
failedJobs: counts.failed ?? 0,
|
|
139
|
-
latestMetric
|
|
243
|
+
latestMetric,
|
|
140
244
|
},
|
|
141
245
|
jobs,
|
|
142
246
|
}
|
|
143
247
|
}
|
|
248
|
+
|
|
249
|
+
const DETAIL_ARTIFACTS = {
|
|
250
|
+
summary: 'evaluation-summary.json',
|
|
251
|
+
candidate: 'candidate-manifest.json',
|
|
252
|
+
dataset: 'dataset-manifest.json',
|
|
253
|
+
datasetPreview: 'dataset-preview.json',
|
|
254
|
+
stack: 'evaluation-stack-manifest.json',
|
|
255
|
+
context: 'evaluation-context.json',
|
|
256
|
+
contract: 'evaluation-contract.json',
|
|
257
|
+
doctor: 'architecture-doctor.json',
|
|
258
|
+
population: 'population-report.json',
|
|
259
|
+
lifecycle: 'trial-lifecycle.json',
|
|
260
|
+
registry: 'artifact-registry.json',
|
|
261
|
+
diagnosis: 'diagnosis-report.json',
|
|
262
|
+
optimization: 'optimization-report.json',
|
|
263
|
+
promotion: 'promotion-report.json',
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function schemaIssue(key, value) {
|
|
267
|
+
if (value === undefined) return undefined
|
|
268
|
+
if (value?.__readError) return value.__readError
|
|
269
|
+
if (!isObject(value)) return 'artifact must be an object'
|
|
270
|
+
const versions = {
|
|
271
|
+
summary: [2, 3], candidate: [1], dataset: [1], datasetPreview: [1], stack: [1], context: [1, 2], contract: [1],
|
|
272
|
+
doctor: [1], population: [1, 2], lifecycle: [1], registry: [1], diagnosis: [1], optimization: [1, 2], promotion: [2],
|
|
273
|
+
}[key]
|
|
274
|
+
if (versions && !versions.includes(value.schema_version)) return `schema_version must be one of ${versions.join(', ')}`
|
|
275
|
+
const required = {
|
|
276
|
+
summary: ['job', 'metrics'], candidate: ['candidate_id', 'version', 'digest'], dataset: ['dataset_id', 'version', 'source_digest', 'tasks'], datasetPreview: ['dataset_id', 'version', 'source_digest', 'tasks'],
|
|
277
|
+
stack: ['stack_id', 'version', 'digest', 'components', 'judge'], context: ['digest'], contract: ['contract_id', 'version', 'primary_metric', 'metrics'],
|
|
278
|
+
doctor: ['promotion_ready', 'findings'], population: ['population_size', 'groups', 'metrics'], lifecycle: ['dataset_total', 'trials'],
|
|
279
|
+
registry: ['artifacts'], diagnosis: ['diagnoses'], optimization: ['hypotheses'], promotion: ['decision', 'reasons', 'policy_digest'],
|
|
280
|
+
}[key] ?? []
|
|
281
|
+
const missing = required.filter(field => value[field] === undefined)
|
|
282
|
+
return missing.length ? `missing fields: ${missing.join(', ')}` : undefined
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
export async function readJobDetail(config, args) {
|
|
286
|
+
const job = safeSegment(args.job, 'job')
|
|
287
|
+
const directory = jobDirectory(config, job)
|
|
288
|
+
const check = await directoryCheck(directory)
|
|
289
|
+
if (check.status !== 'ok') throw new Error('Job not found')
|
|
290
|
+
const values = await Promise.all(Object.values(DETAIL_ARTIFACTS).map(name => readJson(path.join(directory, name))))
|
|
291
|
+
const artifacts = Object.fromEntries(Object.keys(DETAIL_ARTIFACTS).map((key, index) => [key, values[index]]))
|
|
292
|
+
if (artifacts.summary && !artifacts.summary.__readError) {
|
|
293
|
+
const { trials: _trials, ...lightSummary } = artifacts.summary
|
|
294
|
+
artifacts.summary = lightSummary
|
|
295
|
+
}
|
|
296
|
+
const validation = Object.fromEntries(Object.entries(artifacts).map(([key, value]) => {
|
|
297
|
+
const issue = schemaIssue(key, value)
|
|
298
|
+
return [key, value === undefined ? { status: 'unavailable', reason: 'capability-not-produced' } : issue ? { status: 'invalid', error: issue } : { status: 'valid' }]
|
|
299
|
+
}))
|
|
300
|
+
const context = artifacts.context ?? values[Object.keys(DETAIL_ARTIFACTS).indexOf('context')]
|
|
301
|
+
const capabilities = capabilityMap(
|
|
302
|
+
values[Object.keys(DETAIL_ARTIFACTS).indexOf('summary')], context, artifacts.lifecycle, artifacts.registry, artifacts.stack,
|
|
303
|
+
)
|
|
304
|
+
return { schemaVersion: 2, job, capabilities, artifacts, validation }
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function selectedLifecycleTrials(lifecycle) {
|
|
308
|
+
const selected = new Map()
|
|
309
|
+
for (const trial of lifecycle?.trials ?? []) {
|
|
310
|
+
const key = Number(trial.dataset_order ?? selected.size)
|
|
311
|
+
if (!selected.has(key) || Number(trial.attempt ?? 1) > Number(selected.get(key).attempt ?? 1)) selected.set(key, trial)
|
|
312
|
+
}
|
|
313
|
+
return [...selected.values()].sort((left, right) => Number(left.dataset_order ?? 0) - Number(right.dataset_order ?? 0))
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function normalizeTrial(trial, order) {
|
|
317
|
+
const score = trial.score ?? { value: undefined, valid: trial.exception ? false : true, invalid_reasons: trial.exception ? ['infrastructure-error'] : [] }
|
|
318
|
+
const datasetOrder = Number(trial.datasetOrder ?? trial.dataset_order ?? order)
|
|
319
|
+
return {
|
|
320
|
+
id: trial.id ?? trial.execution_id ?? `dataset-${datasetOrder}`,
|
|
321
|
+
name: trial.name ?? trial.trial_name ?? trial.dataset_trial ?? trial.trial,
|
|
322
|
+
datasetTrial: trial.datasetTrial ?? trial.dataset_trial ?? trial.trial,
|
|
323
|
+
datasetOrder,
|
|
324
|
+
attempt: Number(trial.attempt ?? 1),
|
|
325
|
+
status: trial.status ?? trial.phase ?? (trial.exception ? 'infrastructure-error' : 'completed'),
|
|
326
|
+
terminal: trial.terminal ?? true,
|
|
327
|
+
updatedAt: trial.updatedAt ?? trial.updated_at,
|
|
328
|
+
score,
|
|
329
|
+
rewards: trial.rewards ?? {},
|
|
330
|
+
requirements: trial.requirements,
|
|
331
|
+
population: trial.population ?? {},
|
|
332
|
+
evidenceAvailable: Boolean(trial.evidenceAvailable ?? trial.terminal),
|
|
333
|
+
exception: trial.exception ? { type: trial.exception.type, classification: trial.exception.classification } : undefined,
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
async function jobTrials(config, job) {
|
|
338
|
+
const directory = jobDirectory(config, job)
|
|
339
|
+
const [summary, lifecycle] = await Promise.all([
|
|
340
|
+
readJson(path.join(directory, SUMMARY_NAME)),
|
|
341
|
+
readJson(path.join(directory, 'trial-lifecycle.json')),
|
|
342
|
+
])
|
|
343
|
+
if ((!summary || summary.__readError) && (!lifecycle || lifecycle.__readError)) throw new Error('Job progress is unavailable')
|
|
344
|
+
const summaryTrials = (summary?.trials ?? []).map(normalizeTrial)
|
|
345
|
+
if (!lifecycle?.trials) return { trials: summaryTrials, total: Number(summary?.n_trials ?? summaryTrials.length), lifecycle }
|
|
346
|
+
const byExecution = new Map(summaryTrials.map(item => [String(item.id), item]))
|
|
347
|
+
const byDataset = new Map(summaryTrials.map(item => [String(item.datasetTrial), item]))
|
|
348
|
+
const trials = selectedLifecycleTrials(lifecycle).map((item, index) => {
|
|
349
|
+
const evaluated = byExecution.get(String(item.execution_id)) ?? byDataset.get(String(item.dataset_trial))
|
|
350
|
+
return normalizeTrial({ ...item, ...evaluated, dataset_order: item.dataset_order, attempt: item.attempt }, index)
|
|
351
|
+
})
|
|
352
|
+
return { trials, total: Number(lifecycle.dataset_total ?? summary?.n_trials ?? trials.length), lifecycle }
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function datasetTaskAliases(task) {
|
|
356
|
+
const values = [task?.id, task?.path, task?.metadata?.task_name]
|
|
357
|
+
const aliases = new Set()
|
|
358
|
+
for (const value of values) {
|
|
359
|
+
const normalized = String(value ?? '').trim().replace(/^\/+|\/+$/g, '')
|
|
360
|
+
if (!normalized || normalized === '.') continue
|
|
361
|
+
aliases.add(normalized)
|
|
362
|
+
aliases.add(normalized.split('/').at(-1))
|
|
363
|
+
}
|
|
364
|
+
return aliases
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function taskDisplayName(task) {
|
|
368
|
+
const direct = task?.query ?? task?.metadata?.query
|
|
369
|
+
if (typeof direct === 'string' && direct.trim()) return direct.trim()
|
|
370
|
+
if (typeof task?.instruction === 'string') {
|
|
371
|
+
const lines = task.instruction.split(/\r?\n/).map(line => line.trim()).filter(line => line && !line.startsWith('#'))
|
|
372
|
+
const labeled = lines.find(line => /^(?:query|question|问题|任务)\s*[::]/i.test(line))
|
|
373
|
+
const text = (labeled ?? lines[0] ?? '').replace(/^(?:query|question|问题|任务)\s*[::]\s*/i, '')
|
|
374
|
+
if (text) return text.length > 120 ? `${text.slice(0, 117)}…` : text
|
|
375
|
+
}
|
|
376
|
+
return String(task?.id ?? task?.path ?? '')
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
async function enrichTrialsWithDataset(config, job, trials) {
|
|
380
|
+
let preview
|
|
381
|
+
try { preview = await readDatasetPreview(config, { job }) } catch { return trials }
|
|
382
|
+
const byAlias = new Map()
|
|
383
|
+
for (const [datasetOrder, task] of (preview?.tasks ?? []).entries()) {
|
|
384
|
+
for (const alias of datasetTaskAliases(task)) byAlias.set(alias, { task, datasetOrder })
|
|
385
|
+
}
|
|
386
|
+
return trials.map(trial => {
|
|
387
|
+
const normalized = String(trial.datasetTrial ?? '').replace(/^\/+|\/+$/g, '')
|
|
388
|
+
const matched = byAlias.get(normalized) ?? byAlias.get(normalized.split('/').at(-1))
|
|
389
|
+
if (!matched) return { ...trial, displayName: trial.datasetTrial ?? trial.name }
|
|
390
|
+
return {
|
|
391
|
+
...trial,
|
|
392
|
+
displayName: taskDisplayName(matched.task) || trial.datasetTrial || trial.name,
|
|
393
|
+
taskId: matched.task.id,
|
|
394
|
+
datasetOrder: matched.datasetOrder,
|
|
395
|
+
}
|
|
396
|
+
})
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
export async function readTrialsPage(config, args) {
|
|
400
|
+
const job = safeSegment(args.job, 'job')
|
|
401
|
+
const offset = Math.max(0, Number.parseInt(args.offset ?? 0, 10) || 0)
|
|
402
|
+
const limit = Math.min(MAX_TRIAL_LIMIT, Math.max(1, Number.parseInt(args.limit ?? 50, 10) || 50))
|
|
403
|
+
const query = String(args.query ?? '').trim().toLowerCase()
|
|
404
|
+
const status = String(args.status ?? '')
|
|
405
|
+
const validity = String(args.validity ?? '')
|
|
406
|
+
const evidence = String(args.evidence ?? '')
|
|
407
|
+
const sort = String(args.sort ?? 'dataset-order')
|
|
408
|
+
const source = await jobTrials(config, job)
|
|
409
|
+
let trials = await enrichTrialsWithDataset(config, job, source.trials)
|
|
410
|
+
if (query) trials = trials.filter(trial => `${trial.id ?? ''} ${trial.displayName ?? ''} ${trial.name ?? ''} ${trial.datasetTrial ?? ''}`.toLowerCase().includes(query))
|
|
411
|
+
if (status) trials = trials.filter(trial => trial.status === status)
|
|
412
|
+
if (validity) trials = trials.filter(trial => String(Boolean(trial.score?.valid)) === validity)
|
|
413
|
+
if (evidence) trials = trials.filter(trial => String(Boolean(trial.evidenceAvailable)) === evidence)
|
|
414
|
+
if (sort === 'dataset-order') trials = [...trials].sort((a, b) => a.datasetOrder - b.datasetOrder || a.attempt - b.attempt)
|
|
415
|
+
if (sort === 'latest-completed') trials = [...trials].sort((a, b) => Date.parse(b.updatedAt ?? 0) - Date.parse(a.updatedAt ?? 0))
|
|
416
|
+
if (sort === 'lowest-score') trials = [...trials].sort((a, b) => (a.score?.value ?? Number.POSITIVE_INFINITY) - (b.score?.value ?? Number.POSITIVE_INFINITY))
|
|
417
|
+
if (sort === 'errors') trials = [...trials].sort((a, b) => Number(!b.exception && b.score?.valid !== false) - Number(!a.exception && a.score?.valid !== false))
|
|
418
|
+
const items = trials.slice(offset, offset + limit)
|
|
419
|
+
return {
|
|
420
|
+
schemaVersion: 2, job, offset, limit, total: trials.length, datasetTotal: source.total,
|
|
421
|
+
sort, items, hasMore: offset + items.length < trials.length, updatedAt: source.lifecycle?.updated_at,
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function assessmentName(id) {
|
|
426
|
+
return `${String(id).replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^[.-]+|[.-]+$/g, '') || 'trial'}.json`
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function previewFromOutput(output, evidence = []) {
|
|
430
|
+
if (typeof output === 'string' && output.trim()) return { kind: 'document', format: 'text', title: 'Agent output', content: output, provenance: evidence }
|
|
431
|
+
if (!isObject(output)) return undefined
|
|
432
|
+
if (typeof output.kind === 'string' && 'content' in output) return { ...output, provenance: evidence }
|
|
433
|
+
const url = output.page_url ?? output.preview_url ?? output.url
|
|
434
|
+
if (typeof url === 'string' && /^(https?:\/\/|\/)/.test(url)) return { kind: 'page', format: 'url', title: output.title ?? 'Generated page', url, content: output, provenance: evidence }
|
|
435
|
+
if (typeof output.html === 'string') return { kind: 'page', format: 'html', title: output.title ?? 'Generated page', content: output.html, provenance: evidence }
|
|
436
|
+
if (['answer', 'content', 'report', 'markdown', 'text'].some(key => typeof output[key] === 'string')) return { kind: 'document', format: 'json', title: output.title ?? 'Generated document', content: output, provenance: evidence }
|
|
437
|
+
if ('metadata' in output && !['answer', 'content', 'report', 'markdown', 'text'].some(key => key in output)) return undefined
|
|
438
|
+
return { kind: 'structured', format: 'json', title: output.title ?? 'Structured output', content: output, provenance: evidence }
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
async function previewFromTrialFiles(directory, lifecycle) {
|
|
442
|
+
let trialName
|
|
443
|
+
try { trialName = safeSegment(lifecycle?.name, 'trial directory') } catch { return undefined }
|
|
444
|
+
const trialDirectory = path.join(directory, trialName)
|
|
445
|
+
const check = await directoryCheck(trialDirectory)
|
|
446
|
+
if (check.status !== 'ok') return undefined
|
|
447
|
+
const manifest = await readJson(path.join(trialDirectory, 'artifacts', 'manifest.json'), { maxBytes: MAX_PREVIEW_BYTES, maxText: 128_000 })
|
|
448
|
+
const candidates = []
|
|
449
|
+
for (const entry of Array.isArray(manifest) ? manifest : []) {
|
|
450
|
+
if (!isObject(entry) || !['ok', 'collected', 'mounted'].includes(entry.status) || typeof entry.destination !== 'string' || !entry.destination.startsWith('artifacts/')) continue
|
|
451
|
+
const candidate = path.resolve(trialDirectory, entry.destination)
|
|
452
|
+
const artifactRoot = path.resolve(trialDirectory, 'artifacts')
|
|
453
|
+
if (candidate.startsWith(`${artifactRoot}${path.sep}`)) candidates.push(candidate)
|
|
454
|
+
}
|
|
455
|
+
const priority = new Map([['.html', 0], ['.htm', 0], ['.md', 1], ['.markdown', 1], ['.txt', 2], ['.json', 3]])
|
|
456
|
+
candidates.sort((a, b) => (priority.get(path.extname(a).toLowerCase()) ?? 99) - (priority.get(path.extname(b).toLowerCase()) ?? 99) || a.localeCompare(b))
|
|
457
|
+
for (const candidate of candidates) {
|
|
458
|
+
try {
|
|
459
|
+
const details = await lstat(candidate)
|
|
460
|
+
if (!details.isFile() || details.isSymbolicLink() || details.size > MAX_PREVIEW_BYTES) continue
|
|
461
|
+
const format = path.extname(candidate).toLowerCase()
|
|
462
|
+
const text = (await readFile(candidate, 'utf8')).replace(SENSITIVE_SOURCE_VALUE, '$1=[REDACTED]')
|
|
463
|
+
const content = format === '.json' ? redact(JSON.parse(text), 0, 128_000) : redact(text, 0, 128_000)
|
|
464
|
+
const kind = ['.html', '.htm'].includes(format)
|
|
465
|
+
? 'page'
|
|
466
|
+
: format === '.json' && !(isObject(content) && ['answer', 'content', 'report', 'markdown', 'text'].some(key => typeof content[key] === 'string'))
|
|
467
|
+
? 'structured'
|
|
468
|
+
: 'document'
|
|
469
|
+
return { kind, format: format.replace('.', '') || 'text', title: path.basename(candidate), content, artifact_ref: path.relative(trialDirectory, candidate), provenance: [{ label: 'Agent Artifact', kind: 'agent-artifact', artifact_ref: path.relative(trialDirectory, candidate) }] }
|
|
470
|
+
} catch { /* try the next declared artifact */ }
|
|
471
|
+
}
|
|
472
|
+
const trajectory = await readJson(path.join(trialDirectory, 'agent', 'trajectory.json'), { maxBytes: 2 * 1024 * 1024, maxText: 128_000 })
|
|
473
|
+
const messages = (trajectory?.steps ?? []).filter(step => step?.source === 'agent' && typeof step.message === 'string').map(step => step.message)
|
|
474
|
+
return messages.length ? { kind: 'document', format: 'text', title: 'Agent final response', content: messages.at(-1), artifact_ref: 'agent/trajectory.json', provenance: [{ label: 'ACP Final Response', kind: 'acp-final-response', artifact_ref: 'agent/trajectory.json' }] } : undefined
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
async function evaluatorResultFromTrialFiles(directory, lifecycle) {
|
|
478
|
+
let trialName
|
|
479
|
+
try { trialName = safeSegment(lifecycle?.name, 'trial directory') } catch { return undefined }
|
|
480
|
+
const trialDirectory = path.join(directory, trialName)
|
|
481
|
+
const check = await directoryCheck(trialDirectory)
|
|
482
|
+
if (check.status !== 'ok') return undefined
|
|
483
|
+
const result = await readJson(path.join(trialDirectory, 'verifier', 'evaluation-result.json'), { maxBytes: 128_000, maxText: 32_000 })
|
|
484
|
+
return result && !result.__readError ? result : undefined
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
function enrichAssessmentWithEvaluator(assessment, evaluatorResult) {
|
|
488
|
+
if (!assessment) return assessment
|
|
489
|
+
const byCriterion = new Map((evaluatorResult?.criteria ?? []).filter(isObject).map(item => [String(item.id), item]))
|
|
490
|
+
const criteria = (assessment.criteria ?? []).map(item => {
|
|
491
|
+
const evaluator = byCriterion.get(String(item.id))
|
|
492
|
+
return evaluator ? { ...item, reason: evaluator.reason ?? item.reason, recommendation: evaluator.recommendation ?? item.recommendation } : item
|
|
493
|
+
})
|
|
494
|
+
const evaluatorRecommendations = (evaluatorResult?.recommendations ?? []).map(item => isObject(item) ? item : { message: String(item) })
|
|
495
|
+
return { ...assessment, criteria, recommendations: [...(assessment.recommendations ?? []), ...evaluatorRecommendations] }
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
async function datasetRoots(directory, projectRoot) {
|
|
499
|
+
const entries = await readdir(directory, { withFileTypes: true })
|
|
500
|
+
const roots = []
|
|
501
|
+
for (const entry of entries.filter(item => item.isDirectory() && !item.isSymbolicLink()).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
502
|
+
const result = await readJson(path.join(directory, entry.name, 'result.json'))
|
|
503
|
+
const candidate = result?.task_id?.path
|
|
504
|
+
if (typeof candidate !== 'string') continue
|
|
505
|
+
try {
|
|
506
|
+
const resolved = resolveWithin(projectRoot, path.relative(projectRoot, candidate), 'task path')
|
|
507
|
+
const check = await directoryCheck(resolved)
|
|
508
|
+
if (check.status === 'ok') roots.push(resolved)
|
|
509
|
+
} catch { /* ignore historical out-of-root task sources */ }
|
|
510
|
+
}
|
|
511
|
+
return [...new Set(roots)]
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
export async function readDatasetPreview(config, args) {
|
|
515
|
+
const job = safeSegment(args.job, 'job')
|
|
516
|
+
const directory = jobDirectory(config, job)
|
|
517
|
+
const snapshot = await readJson(path.join(directory, 'dataset-preview.json'), { maxBytes: MAX_JSON_BYTES, maxText: 128_000 })
|
|
518
|
+
if (snapshot && !snapshot.__readError) return { ...snapshot, source: 'job-snapshot' }
|
|
519
|
+
const manifest = await readJson(path.join(directory, 'dataset-manifest.json'))
|
|
520
|
+
if (!manifest || manifest.__readError) throw new Error('Dataset Manifest is unavailable')
|
|
521
|
+
const roots = await datasetRoots(directory, config.projectRoot)
|
|
522
|
+
const tasks = []
|
|
523
|
+
for (const [index, task] of (manifest.tasks ?? []).entries()) {
|
|
524
|
+
const root = roots[Math.min(index, Math.max(0, roots.length - 1))]
|
|
525
|
+
let instruction = { error: 'instruction source is unavailable for this historical Job' }
|
|
526
|
+
if (root && typeof task?.instruction === 'string') {
|
|
527
|
+
try { instruction = await readSafeText(resolveWithin(root, task.instruction, 'task.instruction'), config.projectRoot) } catch { instruction = { error: 'instruction path is invalid' } }
|
|
528
|
+
}
|
|
529
|
+
tasks.push({ id: task?.id ?? `task-${index + 1}`, path: task?.path ?? '.', instruction_file: task?.instruction, instruction: instruction.text, instruction_error: instruction.error, instruction_truncated: Boolean(instruction.text?.includes('[TRUNCATED')) })
|
|
530
|
+
}
|
|
531
|
+
return { schema_version: 1, dataset_id: manifest.dataset_id, version: manifest.version, source_digest: manifest.source_digest, task_count: tasks.length, tasks, source: 'historical-source-fallback' }
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
export async function readTrialDetail(config, args) {
|
|
535
|
+
const job = safeSegment(args.job, 'job')
|
|
536
|
+
const trial = safeSegment(args.trial, 'trial')
|
|
537
|
+
const directory = jobDirectory(config, job)
|
|
538
|
+
let assessment = await readJson(path.join(directory, 'trial-assessments', assessmentName(trial)))
|
|
539
|
+
const source = await jobTrials(config, job)
|
|
540
|
+
const lifecycle = source.trials.find(item => String(item.id) === trial || String(item.datasetTrial) === trial || String(item.name) === trial)
|
|
541
|
+
if ((!assessment || assessment.__readError) && lifecycle?.id && String(lifecycle.id) !== trial) {
|
|
542
|
+
assessment = await readJson(path.join(directory, 'trial-assessments', assessmentName(lifecycle.id)))
|
|
543
|
+
}
|
|
544
|
+
if (assessment?.__readError) throw new Error('Trial assessment is invalid')
|
|
545
|
+
if (!assessment && !lifecycle) throw new Error('Trial not found')
|
|
546
|
+
assessment = enrichAssessmentWithEvaluator(assessment, await evaluatorResultFromTrialFiles(directory, lifecycle))
|
|
547
|
+
const assessmentPreview = previewFromOutput(assessment?.output, assessment?.evidence_provenance)
|
|
548
|
+
const realAssessmentOutput = assessment?.evidence_provenance?.some(item => item?.kind === 'real-renderer' || item?.kind === 'agent-artifact')
|
|
549
|
+
const filePreview = realAssessmentOutput ? undefined : await previewFromTrialFiles(directory, lifecycle)
|
|
550
|
+
const preview = realAssessmentOutput ? assessmentPreview : filePreview ?? assessmentPreview
|
|
551
|
+
return {
|
|
552
|
+
schemaVersion: 2, job, trial, lifecycle,
|
|
553
|
+
status: lifecycle?.status ?? assessment?.status,
|
|
554
|
+
assessment,
|
|
555
|
+
preview,
|
|
556
|
+
capability: assessment ? 'assessment-available' : 'running-evidence-not-yet-available',
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
export async function readJobProgress(config, args) {
|
|
561
|
+
const job = safeSegment(args.job, 'job')
|
|
562
|
+
const since = args.since ? Date.parse(args.since) : 0
|
|
563
|
+
const source = await jobTrials(config, job)
|
|
564
|
+
const changed = source.trials.filter(item => !since || Date.parse(item.updatedAt ?? 0) > since)
|
|
565
|
+
return {
|
|
566
|
+
schemaVersion: 1,
|
|
567
|
+
job,
|
|
568
|
+
updatedAt: source.lifecycle?.updated_at ?? new Date().toISOString(),
|
|
569
|
+
datasetTotal: source.total,
|
|
570
|
+
counts: source.lifecycle?.counts ?? {},
|
|
571
|
+
changed,
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
export async function readMetaEvaluation(config, args = {}) {
|
|
576
|
+
const evaluationRoot = resolveWithin(config.projectRoot, args.evaluationRoot ?? '.', 'evaluationRoot')
|
|
577
|
+
const groundTruth = await readJson(path.join(evaluationRoot, '.harbor', 'ground-truth.json'), { maxText: 64_000 })
|
|
578
|
+
const report = await readJson(path.join(evaluationRoot, '.harbor', 'meta-evaluation-report.json'), { maxText: 64_000 })
|
|
579
|
+
const availableGroundTruth = groundTruth && !groundTruth.__readError
|
|
580
|
+
const availableReport = report && !report.__readError
|
|
581
|
+
const cases = availableGroundTruth && Array.isArray(groundTruth.cases) ? groundTruth.cases : []
|
|
582
|
+
return {
|
|
583
|
+
schemaVersion: 1,
|
|
584
|
+
evaluationRoot: path.relative(config.projectRoot, evaluationRoot) || '.',
|
|
585
|
+
status: availableReport ? 'evaluated' : availableGroundTruth ? (cases.length ? 'ground-truth-ready' : 'ground-truth-draft') : 'ground-truth-required',
|
|
586
|
+
groundTruth: availableGroundTruth ? {
|
|
587
|
+
id: groundTruth.ground_truth_id,
|
|
588
|
+
version: groundTruth.version,
|
|
589
|
+
source: groundTruth.source,
|
|
590
|
+
criteria: groundTruth.criteria ?? [],
|
|
591
|
+
caseCount: cases.length,
|
|
592
|
+
badcaseCount: cases.filter(item => item?.badcase).length,
|
|
593
|
+
path: path.relative(config.projectRoot, path.join(evaluationRoot, '.harbor', 'ground-truth.json')),
|
|
594
|
+
} : undefined,
|
|
595
|
+
report: availableReport ? report : undefined,
|
|
596
|
+
workflow: {
|
|
597
|
+
candidate: 'Evaluator / Rubric / Judge identity',
|
|
598
|
+
dataset: 'Fixed artifacts plus independent Ground Truth',
|
|
599
|
+
output: 'Repeated evaluator-observations/v1',
|
|
600
|
+
verifier: 'ESF / SCE / RCR reducer',
|
|
601
|
+
automaticAgentBaseline: false,
|
|
602
|
+
sourceKinds: ['human', 'programmatic', 'consensus', 'model', 'external'],
|
|
603
|
+
nextAction: !availableGroundTruth
|
|
604
|
+
? 'Initialize Ground Truth with harbor_ground_truth_init, then add versioned cases.'
|
|
605
|
+
: !cases.length
|
|
606
|
+
? 'Add cases with artifact_ref and ternary criterion labels before collecting observations.'
|
|
607
|
+
: !availableReport
|
|
608
|
+
? 'Collect repeated evaluator observations and run harbor_evaluator_meta_evaluate.'
|
|
609
|
+
: 'Review disagreements before adopting the evaluator and establishing a fresh Agent baseline.',
|
|
610
|
+
},
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
function compareTrialMaps(summary) {
|
|
615
|
+
return new Map((summary?.trials ?? []).map(item => [String(item.datasetTrial ?? item.name ?? item.id), normalizeTrial(item, 0)]))
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
export async function readComparison(config, args) {
|
|
619
|
+
const baselineJob = safeSegment(args.baseline, 'baseline')
|
|
620
|
+
const candidateJob = safeSegment(args.candidate, 'candidate')
|
|
621
|
+
const [baseline, candidate, baselineContract, candidateContract] = await Promise.all([
|
|
622
|
+
readJson(path.join(jobDirectory(config, baselineJob), SUMMARY_NAME)),
|
|
623
|
+
readJson(path.join(jobDirectory(config, candidateJob), SUMMARY_NAME)),
|
|
624
|
+
readJson(path.join(jobDirectory(config, baselineJob), 'evaluation-contract.json')),
|
|
625
|
+
readJson(path.join(jobDirectory(config, candidateJob), 'evaluation-contract.json')),
|
|
626
|
+
])
|
|
627
|
+
if (!baseline || baseline.__readError || !candidate || candidate.__readError) throw new Error('Both Job summaries are required')
|
|
628
|
+
const baselineContext = baseline.evaluation_context ?? await readJson(path.join(jobDirectory(config, baselineJob), 'evaluation-context.json'))
|
|
629
|
+
const candidateContext = candidate.evaluation_context ?? await readJson(path.join(jobDirectory(config, candidateJob), 'evaluation-context.json'))
|
|
630
|
+
const reasons = []
|
|
631
|
+
if (baselineContext?.schema_version !== 2 || candidateContext?.schema_version !== 2) reasons.push('Context v2 is required')
|
|
632
|
+
if (!baselineContext?.digest || baselineContext.digest !== candidateContext?.digest) reasons.push('Evaluation Context differs; establish a fresh baseline')
|
|
633
|
+
if (baselineContract?.contract_id !== candidateContract?.contract_id || baselineContract?.version !== candidateContract?.version) reasons.push('Evaluation Contract identity differs')
|
|
634
|
+
const directions = Object.fromEntries((candidateContract?.metrics ?? []).map(item => [item.id, item.direction ?? 'maximize']))
|
|
635
|
+
const metrics = Object.fromEntries([...new Set([...Object.keys(baseline.metrics ?? {}), ...Object.keys(candidate.metrics ?? {})])].map(key => [key, {
|
|
636
|
+
baseline: baseline.metrics?.[key], candidate: candidate.metrics?.[key],
|
|
637
|
+
delta: typeof baseline.metrics?.[key] === 'number' && typeof candidate.metrics?.[key] === 'number' ? candidate.metrics[key] - baseline.metrics[key] : undefined,
|
|
638
|
+
direction: directions[key] ?? 'maximize',
|
|
639
|
+
improvement: typeof baseline.metrics?.[key] === 'number' && typeof candidate.metrics?.[key] === 'number'
|
|
640
|
+
? (directions[key] === 'minimize' ? baseline.metrics[key] - candidate.metrics[key] : candidate.metrics[key] - baseline.metrics[key])
|
|
641
|
+
: undefined,
|
|
642
|
+
}]))
|
|
643
|
+
const oldTrials = compareTrialMaps(baseline)
|
|
644
|
+
const nextTrials = compareTrialMaps(candidate)
|
|
645
|
+
const improved = []
|
|
646
|
+
const regressed = []
|
|
647
|
+
const primaryDirection = directions[candidateContract?.primary_metric] ?? 'maximize'
|
|
648
|
+
for (const trial of [...oldTrials.keys()].filter(key => nextTrials.has(key)).sort()) {
|
|
649
|
+
const oldValue = oldTrials.get(trial).score?.value ?? oldTrials.get(trial).rewards?.reward
|
|
650
|
+
const newValue = nextTrials.get(trial).score?.value ?? nextTrials.get(trial).rewards?.reward
|
|
651
|
+
if (typeof oldValue !== 'number' || typeof newValue !== 'number' || oldValue === newValue) continue
|
|
652
|
+
const item = { trial, baseline: oldValue, candidate: newValue, delta: newValue - oldValue }
|
|
653
|
+
const isImproved = primaryDirection === 'minimize' ? newValue < oldValue : newValue > oldValue
|
|
654
|
+
;(isImproved ? improved : regressed).push(item)
|
|
655
|
+
}
|
|
656
|
+
const baselineExceptions = new Set((baseline.exceptions ?? []).map(item => String(item.trial)))
|
|
657
|
+
const newExceptions = (candidate.exceptions ?? []).filter(item => !baselineExceptions.has(String(item.trial)))
|
|
658
|
+
const artifactRegressions = (baseline.artifact_validation?.valid && !candidate.artifact_validation?.valid) ? ['artifact-validation'] : []
|
|
659
|
+
return {
|
|
660
|
+
schemaVersion: 1, baselineJob, candidateJob, comparable: reasons.length === 0, comparabilityReasons: reasons,
|
|
661
|
+
metrics, population: { baseline: baseline.n_trials, candidate: candidate.n_trials, baselineValid: baseline.n_valid_scores, candidateValid: candidate.n_valid_scores },
|
|
662
|
+
improvedTrials: improved, regressedTrials: regressed, newExceptions, artifactRegressions,
|
|
663
|
+
gateEligibility: reasons.length ? 'not-comparable' : 'requires-explicit-gate',
|
|
664
|
+
note: 'This read-only comparison never runs Gate, promotes a Candidate, deploys, or publishes.',
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
export async function readEvaluatorGovernance(config, args) {
|
|
669
|
+
const job = safeSegment(args.job, 'job')
|
|
670
|
+
const directory = jobDirectory(config, job)
|
|
671
|
+
const [stack, contract, context] = await Promise.all([
|
|
672
|
+
readJson(path.join(directory, 'evaluation-stack-manifest.json')),
|
|
673
|
+
readJson(path.join(directory, 'evaluation-contract.json')),
|
|
674
|
+
readJson(path.join(directory, 'evaluation-context.json')),
|
|
675
|
+
])
|
|
676
|
+
if (!stack || stack.__readError) throw new Error('Evaluation Stack is unavailable')
|
|
677
|
+
const components = {}
|
|
678
|
+
for (const [role, component] of Object.entries(stack.components ?? {})) {
|
|
679
|
+
const entry = component?.entry
|
|
680
|
+
const source = entry ? await readSafeText(resolveWithin(config.projectRoot, entry, `${role}.entry`), config.projectRoot) : { error: 'entry unavailable' }
|
|
681
|
+
components[role] = { ...component, source }
|
|
682
|
+
}
|
|
683
|
+
let comparison
|
|
684
|
+
if (args.compareJob) {
|
|
685
|
+
const other = await readJson(path.join(jobDirectory(config, safeSegment(args.compareJob, 'compareJob')), 'evaluation-stack-manifest.json'))
|
|
686
|
+
const changes = []
|
|
687
|
+
for (const role of new Set([...Object.keys(stack.components ?? {}), ...Object.keys(other?.components ?? {})])) {
|
|
688
|
+
const before = other?.components?.[role]
|
|
689
|
+
const after = stack.components?.[role]
|
|
690
|
+
if (before?.digest !== after?.digest || before?.version !== after?.version) changes.push({ role, before, after, rewardAffecting: Boolean(before?.reward_affecting || after?.reward_affecting) })
|
|
691
|
+
}
|
|
692
|
+
comparison = {
|
|
693
|
+
changes,
|
|
694
|
+
freshBaselineRequired: changes.some(item => item.rewardAffecting) || JSON.stringify(other?.judge) !== JSON.stringify(stack.judge),
|
|
695
|
+
createsNewIdentity: true,
|
|
696
|
+
overwritesHistoricalIdentity: false,
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
return {
|
|
700
|
+
schemaVersion: 1, job, stackIdentity: { id: stack.stack_id, version: stack.version, digest: stack.digest },
|
|
701
|
+
judge: stack.judge, contract, contextDigest: context?.digest, components, comparison,
|
|
702
|
+
editingPolicy: {
|
|
703
|
+
browserWriteEnabled: false,
|
|
704
|
+
saveBehavior: 'Create a new Stack/component identity in source control, then run a fresh baseline when reward-affecting semantics change.',
|
|
705
|
+
automaticEvaluation: false, automaticGate: false,
|
|
706
|
+
},
|
|
707
|
+
upgradeWorkflow: {
|
|
708
|
+
steps: [
|
|
709
|
+
'Inspect the current Evaluator, Rubric, Judge, Contract, and representative false-positive/false-negative Trials.',
|
|
710
|
+
'Create a new Evaluator/Rubric/Judge identity and source file; never overwrite the historical identity.',
|
|
711
|
+
'Run meta-evaluation against independently maintained, provenance-bearing GT and report ESF, SCE, RCR, latency, and cost as applicable.',
|
|
712
|
+
'Update Evaluation Stack identity and preview Context v2 impact.',
|
|
713
|
+
'Establish a fresh Agent baseline before comparing Agent Candidates under the new reward semantics.',
|
|
714
|
+
],
|
|
715
|
+
freshBaselineRequiredWhen: ['evaluator digest changes', 'rubric digest changes', 'judge identity or parameters change'],
|
|
716
|
+
automaticActions: [],
|
|
717
|
+
skillPrompt: 'Use evolve-agent-with-harbor to upgrade this evaluator. First inspect governance evidence, clarify GT source type, provenance, ownership, and target meta-metrics, then propose a new immutable evaluator identity and fresh-baseline plan. Do not edit or run anything until I approve the controlled change.',
|
|
718
|
+
},
|
|
719
|
+
}
|
|
720
|
+
}
|