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
package/lib/dashboard.js
CHANGED
|
@@ -2,7 +2,14 @@ import { access, constants, lstat, readdir, readFile, stat } from 'node:fs/promi
|
|
|
2
2
|
import { createHash } from 'node:crypto'
|
|
3
3
|
import path from 'node:path'
|
|
4
4
|
|
|
5
|
+
import {
|
|
6
|
+
isSensitiveCredentialContainerKey,
|
|
7
|
+
redactCredentialText,
|
|
8
|
+
redactLocalPaths,
|
|
9
|
+
redactOpaqueSecretText,
|
|
10
|
+
} from './credential-redaction.js'
|
|
5
11
|
import { resolveWithin } from './evolution.js'
|
|
12
|
+
import { ATTENTION_FILTERS, attentionCounts, jobAttention, matchesJobFilter } from './workbench-health.js'
|
|
6
13
|
|
|
7
14
|
const SUMMARY_NAME = 'evaluation-summary.json'
|
|
8
15
|
const HISTORICAL_COMPLETION_NAME = 'historical-evaluation-complete.json'
|
|
@@ -13,8 +20,7 @@ const MAX_SOURCE_BYTES = 128 * 1024
|
|
|
13
20
|
const MAX_PREVIEW_BYTES = 512 * 1024
|
|
14
21
|
const MAX_TRIAL_LIMIT = 100
|
|
15
22
|
const jsonCache = new Map()
|
|
16
|
-
const
|
|
17
|
-
const SENSITIVE_SOURCE_VALUE = /(authorization|cookie|token|api[_-]?key|secret|password)\s*[:=]\s*([^\s,;]+)/gi
|
|
23
|
+
const authoritativeRevisions = new WeakMap()
|
|
18
24
|
const WORKSPACE_SKIP_DIRECTORIES = new Set([
|
|
19
25
|
'.cache', '.git', '.harbor', '.next', '.venv', '__pycache__',
|
|
20
26
|
'build', 'candidates', 'coverage', 'datasets', 'dist', 'jobs', 'node_modules', 'public', 'vendor', 'venv',
|
|
@@ -30,26 +36,83 @@ function redact(value, depth = 0, maxText = 8_000) {
|
|
|
30
36
|
if (depth > 10) return '[TRUNCATED depth]'
|
|
31
37
|
if (Array.isArray(value)) return value.slice(0, 10_000).map(item => redact(item, depth + 1, maxText))
|
|
32
38
|
if (value && typeof value === 'object') {
|
|
33
|
-
return Object.fromEntries(Object.entries(value).map(([key, item]) => [key,
|
|
39
|
+
return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, isSensitiveCredentialContainerKey(key) ? '[REDACTED]' : redact(item, depth + 1, maxText)]))
|
|
40
|
+
}
|
|
41
|
+
if (typeof value === 'string') {
|
|
42
|
+
const safe = redactSourceText(value)
|
|
43
|
+
return safe.length > maxText ? `${safe.slice(0, maxText)}\n[TRUNCATED ${safe.length - maxText} chars]` : safe
|
|
34
44
|
}
|
|
35
|
-
if (typeof value === 'string' && value.length > maxText) return `${value.slice(0, maxText)}\n[TRUNCATED ${value.length - maxText} chars]`
|
|
36
45
|
return value
|
|
37
46
|
}
|
|
38
47
|
|
|
39
|
-
|
|
48
|
+
function redactSourceText(value) {
|
|
49
|
+
return redactOpaqueSecretText(redactCredentialText(value))
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function rawContentRevision(value) {
|
|
53
|
+
return `sha256:${createHash('sha256').update(value).digest('hex')}`
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function rememberAuthoritativeRevision(value, ...sources) {
|
|
57
|
+
if (!value || typeof value !== 'object') return value
|
|
58
|
+
const revisions = sources
|
|
59
|
+
.map(source => typeof source === 'string' ? source : authoritativeRevisions.get(source))
|
|
60
|
+
.filter(Boolean)
|
|
61
|
+
if (revisions.length) authoritativeRevisions.set(value, rawContentRevision(JSON.stringify(revisions)))
|
|
62
|
+
return value
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Return a non-serialized digest of the full artifact bytes used to build a bounded dashboard value. */
|
|
66
|
+
export function authoritativeArtifactRevision(value) {
|
|
67
|
+
return value && typeof value === 'object' ? authoritativeRevisions.get(value) : undefined
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function safePathDetails(target, root) {
|
|
71
|
+
const resolvedTarget = path.resolve(target)
|
|
72
|
+
if (!root) return lstat(resolvedTarget)
|
|
73
|
+
const resolvedRoot = path.resolve(root)
|
|
74
|
+
const relative = path.relative(resolvedRoot, resolvedTarget)
|
|
75
|
+
if (relative.startsWith('..') || path.isAbsolute(relative)) {
|
|
76
|
+
const error = new Error('path is outside trusted project root')
|
|
77
|
+
error.code = 'HARBOR_UNSAFE_PATH'
|
|
78
|
+
throw error
|
|
79
|
+
}
|
|
80
|
+
let current = resolvedRoot
|
|
81
|
+
let details = await lstat(current)
|
|
82
|
+
if (details.isSymbolicLink()) {
|
|
83
|
+
const error = new Error('trusted project root may not be a symlink')
|
|
84
|
+
error.code = 'HARBOR_UNSAFE_PATH'
|
|
85
|
+
throw error
|
|
86
|
+
}
|
|
87
|
+
for (const segment of relative.split(path.sep).filter(Boolean)) {
|
|
88
|
+
current = path.join(current, segment)
|
|
89
|
+
details = await lstat(current)
|
|
90
|
+
if (details.isSymbolicLink()) {
|
|
91
|
+
const error = new Error('symlinked path component is not allowed')
|
|
92
|
+
error.code = 'HARBOR_UNSAFE_PATH'
|
|
93
|
+
throw error
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return details
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function readJson(file, { maxBytes = MAX_JSON_BYTES, maxText = 8_000, root } = {}) {
|
|
40
100
|
try {
|
|
41
|
-
const details = await
|
|
101
|
+
const details = await safePathDetails(file, root)
|
|
42
102
|
if (details.isSymbolicLink()) return { __readError: `${path.basename(file)} may not be a symlink` }
|
|
43
103
|
if (!details.isFile()) return { __readError: `${path.basename(file)} is not a file` }
|
|
44
104
|
if (details.size > maxBytes) return { __readError: `${path.basename(file)} exceeds ${maxBytes} bytes` }
|
|
45
105
|
const cached = jsonCache.get(file)
|
|
46
|
-
const identity = `${details.mtimeMs}:${details.size}:${maxText}`
|
|
106
|
+
const identity = `${details.dev}:${details.ino}:${details.ctimeMs}:${details.mtimeMs}:${details.size}:${maxText}`
|
|
47
107
|
if (cached?.identity === identity) return cached.value
|
|
48
|
-
const
|
|
108
|
+
const source = await readFile(file, 'utf8')
|
|
109
|
+
const value = redact(JSON.parse(source), 0, maxText)
|
|
110
|
+
if (value && typeof value === 'object') authoritativeRevisions.set(value, rawContentRevision(source))
|
|
49
111
|
jsonCache.set(file, { identity, value })
|
|
50
112
|
return value
|
|
51
113
|
} catch (error) {
|
|
52
114
|
if (error.code === 'ENOENT') return undefined
|
|
115
|
+
if (error.code === 'HARBOR_UNSAFE_PATH') return { __readError: `${path.basename(file)} is not a safe file` }
|
|
53
116
|
if (error instanceof SyntaxError) return { __readError: `invalid JSON in ${path.basename(file)}` }
|
|
54
117
|
throw error
|
|
55
118
|
}
|
|
@@ -60,19 +123,19 @@ async function readSafeText(file, projectRoot) {
|
|
|
60
123
|
const root = path.resolve(projectRoot)
|
|
61
124
|
if (resolved !== root && !resolved.startsWith(`${root}${path.sep}`)) return { error: 'source is outside projectRoot' }
|
|
62
125
|
try {
|
|
63
|
-
const details = await
|
|
126
|
+
const details = await safePathDetails(resolved, root)
|
|
64
127
|
if (!details.isFile() || details.isSymbolicLink()) return { error: 'source is not a safe file' }
|
|
65
128
|
if (details.size > MAX_SOURCE_BYTES) return { error: `source exceeds ${MAX_SOURCE_BYTES} bytes` }
|
|
66
|
-
const text = (await readFile(resolved, 'utf8'))
|
|
129
|
+
const text = redactLocalPaths(redactSourceText(await readFile(resolved, 'utf8')))
|
|
67
130
|
return { text: redact(text) }
|
|
68
131
|
} catch (error) {
|
|
69
132
|
return { error: error.code === 'ENOENT' ? 'source is unavailable' : 'source is unreadable' }
|
|
70
133
|
}
|
|
71
134
|
}
|
|
72
135
|
|
|
73
|
-
async function directoryCheck(directory, { optional = false } = {}) {
|
|
136
|
+
async function directoryCheck(directory, { optional = false, root } = {}) {
|
|
74
137
|
try {
|
|
75
|
-
const details = await
|
|
138
|
+
const details = await safePathDetails(directory, root)
|
|
76
139
|
if (details.isSymbolicLink() || !details.isDirectory()) return { status: 'error', detail: 'not a safe directory' }
|
|
77
140
|
await access(directory, constants.R_OK)
|
|
78
141
|
return { status: 'ok', detail: 'readable' }
|
|
@@ -82,9 +145,9 @@ async function directoryCheck(directory, { optional = false } = {}) {
|
|
|
82
145
|
}
|
|
83
146
|
}
|
|
84
147
|
|
|
85
|
-
async function fileCheck(file) {
|
|
148
|
+
async function fileCheck(file, { root } = {}) {
|
|
86
149
|
try {
|
|
87
|
-
const details = await
|
|
150
|
+
const details = await safePathDetails(file, root)
|
|
88
151
|
return details.isFile() && !details.isSymbolicLink()
|
|
89
152
|
? { status: 'ok', detail: path.basename(file) }
|
|
90
153
|
: { status: 'error', detail: 'not a safe file' }
|
|
@@ -242,17 +305,17 @@ function jobStatus(summary, lifecycle, progress, jobKind, completion, jobName) {
|
|
|
242
305
|
return 'completed'
|
|
243
306
|
}
|
|
244
307
|
|
|
245
|
-
async function readJob(jobsDir, entry, details) {
|
|
308
|
+
async function readJob(jobsDir, entry, details, projectRoot) {
|
|
246
309
|
const directory = path.join(jobsDir, entry.name)
|
|
247
310
|
const [summary, contextFile, promotion, contract, lifecycle, registry, stack, completion] = await Promise.all([
|
|
248
|
-
readJson(path.join(directory, SUMMARY_NAME)),
|
|
249
|
-
readJson(path.join(directory, 'evaluation-context.json')),
|
|
250
|
-
readJson(path.join(directory, 'promotion-report.json')),
|
|
251
|
-
readJson(path.join(directory, 'evaluation-contract.json')),
|
|
252
|
-
readJson(path.join(directory, 'trial-lifecycle.json')),
|
|
253
|
-
readJson(path.join(directory, 'artifact-registry.json')),
|
|
254
|
-
readJson(path.join(directory, 'evaluation-stack-manifest.json')),
|
|
255
|
-
readJson(path.join(directory, HISTORICAL_COMPLETION_NAME)),
|
|
311
|
+
readJson(path.join(directory, SUMMARY_NAME), { root: projectRoot }),
|
|
312
|
+
readJson(path.join(directory, 'evaluation-context.json'), { root: projectRoot }),
|
|
313
|
+
readJson(path.join(directory, 'promotion-report.json'), { root: projectRoot }),
|
|
314
|
+
readJson(path.join(directory, 'evaluation-contract.json'), { root: projectRoot }),
|
|
315
|
+
readJson(path.join(directory, 'trial-lifecycle.json'), { root: projectRoot }),
|
|
316
|
+
readJson(path.join(directory, 'artifact-registry.json'), { root: projectRoot }),
|
|
317
|
+
readJson(path.join(directory, 'evaluation-stack-manifest.json'), { root: projectRoot }),
|
|
318
|
+
readJson(path.join(directory, HISTORICAL_COMPLETION_NAME), { root: projectRoot }),
|
|
256
319
|
])
|
|
257
320
|
const evaluationContext = summary?.evaluation_context ?? contextFile
|
|
258
321
|
if (!evaluationContext && !summary && !lifecycle) return undefined
|
|
@@ -276,6 +339,8 @@ async function readJob(jobsDir, entry, details) {
|
|
|
276
339
|
nInvalidScores: summary?.n_invalid_scores,
|
|
277
340
|
nUnscoredTrials: Number(coverage.unscored_trials ?? 0),
|
|
278
341
|
nExceptions: Number(summary?.n_exceptions ?? 0),
|
|
342
|
+
nInfrastructureExceptions: Number(summary?.n_infrastructure_exceptions ?? 0),
|
|
343
|
+
nEvaluationExceptions: Number(summary?.n_evaluation_exceptions ?? 0),
|
|
279
344
|
primaryMetric: primaryMetric(summary, contract),
|
|
280
345
|
metrics: summary?.metrics ?? {},
|
|
281
346
|
candidate: summary?.candidate ?? evaluationContext?.candidate,
|
|
@@ -289,12 +354,16 @@ async function readJob(jobsDir, entry, details) {
|
|
|
289
354
|
progress,
|
|
290
355
|
capabilities,
|
|
291
356
|
artifactValidation: summary?.artifact_validation,
|
|
292
|
-
promotion: promotion ? { decision: promotion.decision, reasons: promotion.reasons ?? [], baselineJob: promotion.baseline_job } : undefined,
|
|
357
|
+
promotion: promotion ? { decision: promotion.decision, reasons: promotion.reasons ?? [], baselineJob: promotion.baseline_job, regressions: promotion.regressed_trials?.length ?? 0 } : undefined,
|
|
293
358
|
readError: summary?.__readError,
|
|
294
359
|
}
|
|
295
360
|
}
|
|
296
361
|
|
|
297
|
-
async function listJobs(jobsDir, { offset = 0, limit = DEFAULT_JOB_PAGE_SIZE } = {}) {
|
|
362
|
+
async function listJobs(jobsDir, { offset = 0, limit = DEFAULT_JOB_PAGE_SIZE, root, attention = 'all' } = {}) {
|
|
363
|
+
if (!ATTENTION_FILTERS.includes(attention)) throw new Error('HARBOR_FILTER_INVALID: Unknown attention filter')
|
|
364
|
+
const check = await directoryCheck(jobsDir, { optional: true, root })
|
|
365
|
+
if (check.status === 'warning') return { items: [], total: 0, offset, limit, hasMore: false }
|
|
366
|
+
if (check.status !== 'ok') throw new Error('Jobs directory is not safe')
|
|
298
367
|
let entries
|
|
299
368
|
try {
|
|
300
369
|
entries = await readdir(jobsDir, { withFileTypes: true })
|
|
@@ -305,9 +374,18 @@ async function listJobs(jobsDir, { offset = 0, limit = DEFAULT_JOB_PAGE_SIZE } =
|
|
|
305
374
|
const directories = entries.filter(entry => entry.isDirectory() && !entry.isSymbolicLink())
|
|
306
375
|
const recent = await Promise.all(directories.map(async entry => ({ entry, details: await stat(path.join(jobsDir, entry.name)) })))
|
|
307
376
|
recent.sort((left, right) => right.details.mtimeMs - left.details.mtimeMs)
|
|
308
|
-
const
|
|
309
|
-
|
|
310
|
-
|
|
377
|
+
const allJobs = []
|
|
378
|
+
let cursor = 0
|
|
379
|
+
await Promise.all(Array.from({ length: Math.min(8, recent.length) }, async () => {
|
|
380
|
+
while (cursor < recent.length) {
|
|
381
|
+
const { entry, details } = recent[cursor++]
|
|
382
|
+
const job = await readJob(jobsDir, entry, details, root)
|
|
383
|
+
if (job) allJobs.push(job)
|
|
384
|
+
}
|
|
385
|
+
}))
|
|
386
|
+
allJobs.sort((a, b) => jobAttention(a).rank - jobAttention(b).rank || Date.parse(b.updatedAt) - Date.parse(a.updatedAt) || a.name.localeCompare(b.name))
|
|
387
|
+
const jobs = allJobs.filter(job => matchesJobFilter(job, attention))
|
|
388
|
+
return { items: jobs.slice(offset, offset + limit), allJobs, attentionCounts: attentionCounts(allJobs), total: jobs.length, offset, limit, hasMore: offset + limit < jobs.length }
|
|
311
389
|
}
|
|
312
390
|
|
|
313
391
|
function relativePath(root, value) {
|
|
@@ -320,9 +398,9 @@ function workspaceIdentity(projectRoot, workspaceRoot, jobsDir, preferred) {
|
|
|
320
398
|
return `${label}-${digest}`
|
|
321
399
|
}
|
|
322
400
|
|
|
323
|
-
async function regularFile(pathname) {
|
|
401
|
+
async function regularFile(pathname, root) {
|
|
324
402
|
try {
|
|
325
|
-
const details = await
|
|
403
|
+
const details = await safePathDetails(pathname, root)
|
|
326
404
|
return details.isFile() && !details.isSymbolicLink()
|
|
327
405
|
} catch (error) {
|
|
328
406
|
if (error.code === 'ENOENT') return false
|
|
@@ -338,7 +416,7 @@ export async function discoverWorkspaceConfigs(config) {
|
|
|
338
416
|
const harborDirectory = path.join(directory, '.harbor')
|
|
339
417
|
const descriptorPath = path.join(harborDirectory, 'workspace.json')
|
|
340
418
|
const stackPath = path.join(harborDirectory, 'evaluation-stack.yml')
|
|
341
|
-
const descriptor = await readJson(descriptorPath)
|
|
419
|
+
const descriptor = await readJson(descriptorPath, { root: projectRoot })
|
|
342
420
|
if (descriptor?.schema_version === 1 && descriptor.jobs && descriptor.stack) {
|
|
343
421
|
const jobs = relativePath(projectRoot, resolveWithin(projectRoot, path.resolve(directory, descriptor.jobs), 'workspace.jobs'))
|
|
344
422
|
const stack = relativePath(projectRoot, resolveWithin(projectRoot, path.resolve(directory, descriptor.stack), 'workspace.stack'))
|
|
@@ -351,7 +429,7 @@ export async function discoverWorkspaceConfigs(config) {
|
|
|
351
429
|
workspaceLabel: descriptor.workspace_id ?? workspaceRoot,
|
|
352
430
|
workspaceId: workspaceIdentity(projectRoot, directory, jobs, descriptor.workspace_id),
|
|
353
431
|
})
|
|
354
|
-
} else if (await regularFile(stackPath)) {
|
|
432
|
+
} else if (await regularFile(stackPath, projectRoot)) {
|
|
355
433
|
const workspaceRoot = relativePath(projectRoot, directory)
|
|
356
434
|
const jobs = relativePath(projectRoot, path.join(directory, 'jobs'))
|
|
357
435
|
found.push({
|
|
@@ -394,21 +472,32 @@ function jobDirectory(config, job) {
|
|
|
394
472
|
return path.join(jobsDirectory(config), safeSegment(job, 'job'))
|
|
395
473
|
}
|
|
396
474
|
|
|
475
|
+
/** Read the stable Job Summary through the same bounded, redacting reader used by the Workbench. */
|
|
476
|
+
export async function readEvaluationSummary(config, args) {
|
|
477
|
+
const projectRoot = path.resolve(config.projectRoot)
|
|
478
|
+
const directory = resolveWithin(projectRoot, args.jobPath, 'jobPath')
|
|
479
|
+
const check = await directoryCheck(directory, { root: projectRoot })
|
|
480
|
+
if (check.status !== 'ok') return { __readError: 'Job path is not a safe directory' }
|
|
481
|
+
const summary = await readJson(path.join(directory, SUMMARY_NAME), { root: projectRoot })
|
|
482
|
+
return summary ?? { __readError: `${SUMMARY_NAME} is unavailable` }
|
|
483
|
+
}
|
|
484
|
+
|
|
397
485
|
export async function readDashboardSnapshot(config, metadata = {}, args = {}) {
|
|
398
486
|
const projectRoot = path.resolve(config.projectRoot)
|
|
399
487
|
const jobsDir = jobsDirectory(config)
|
|
400
488
|
const offset = Math.max(0, Number.parseInt(args.offset ?? 0, 10) || 0)
|
|
401
489
|
const limit = Math.min(MAX_JOB_PAGE_SIZE, Math.max(1, Number.parseInt(args.limit ?? DEFAULT_JOB_PAGE_SIZE, 10) || DEFAULT_JOB_PAGE_SIZE))
|
|
402
490
|
const [jobPage, projectRootCheck, jobsDirCheck, harborCheck, harborDshCheck, stackCheck] = await Promise.all([
|
|
403
|
-
listJobs(jobsDir, { offset, limit }),
|
|
491
|
+
listJobs(jobsDir, { offset, limit, root: projectRoot, attention: args.attention ?? 'all' }),
|
|
404
492
|
directoryCheck(projectRoot),
|
|
405
|
-
directoryCheck(jobsDir, { optional: true }),
|
|
493
|
+
directoryCheck(jobsDir, { optional: true, root: projectRoot }),
|
|
406
494
|
executableCheck(config.harborBin),
|
|
407
495
|
executableCheck(config.harborDshBin),
|
|
408
|
-
fileCheck(resolveWithin(projectRoot, config.stackPath ?? '.harbor/evaluation-stack.yml', 'stackPath')),
|
|
496
|
+
fileCheck(resolveWithin(projectRoot, config.stackPath ?? '.harbor/evaluation-stack.yml', 'stackPath'), { root: projectRoot }),
|
|
409
497
|
])
|
|
410
498
|
const jobs = jobPage.items
|
|
411
|
-
const
|
|
499
|
+
const allJobs = jobPage.allJobs ?? jobs
|
|
500
|
+
const counts = allJobs.reduce((result, job) => ({ ...result, [job.status]: (result[job.status] ?? 0) + 1 }), {})
|
|
412
501
|
const latestMetric = jobs.find(job => job.primaryMetric)?.primaryMetric
|
|
413
502
|
return {
|
|
414
503
|
schemaVersion: 3,
|
|
@@ -416,10 +505,11 @@ export async function readDashboardSnapshot(config, metadata = {}, args = {}) {
|
|
|
416
505
|
pluginVersion: metadata.pluginVersion ?? 'development',
|
|
417
506
|
workspace: { id: config.workspaceId, label: config.workspaceLabel, root: config.workspaceRoot ?? '.', stackPath: config.stackPath },
|
|
418
507
|
workspaces: metadata.workspaces ?? [],
|
|
419
|
-
config: { projectRoot, projectRootSource: metadata.projectRootSource ?? 'configured', jobsDir: config.jobsDir, runtimePolicy: config.runtimePolicy ?? '
|
|
508
|
+
config: { projectRoot, projectRootSource: metadata.projectRootSource ?? 'configured', jobsDir: config.jobsDir, runtimePolicy: config.runtimePolicy ?? 'candidate-locked', agentImportPath: config.agentImportPath, pluginImportPath: config.pluginImportPath },
|
|
420
509
|
checks: { projectRoot: projectRootCheck, jobsDir: jobsDirCheck, harbor: harborCheck, harborDsh: harborDshCheck, evaluationStack: stackCheck },
|
|
421
510
|
overview: {
|
|
422
|
-
totalJobs:
|
|
511
|
+
totalJobs: allJobs.length,
|
|
512
|
+
attention: jobPage.attentionCounts ?? attentionCounts(allJobs),
|
|
423
513
|
visibleJobs: jobs.length,
|
|
424
514
|
completedJobs: (counts.completed ?? 0) + (counts.partial ?? 0) + (counts.attention ?? 0),
|
|
425
515
|
activeJobs: (counts.pending ?? 0) + (counts.running ?? 0),
|
|
@@ -476,13 +566,15 @@ function schemaIssue(key, value) {
|
|
|
476
566
|
export async function readJobDetail(config, args) {
|
|
477
567
|
const job = safeSegment(args.job, 'job')
|
|
478
568
|
const directory = jobDirectory(config, job)
|
|
479
|
-
const
|
|
569
|
+
const projectRoot = path.resolve(config.projectRoot)
|
|
570
|
+
const check = await directoryCheck(directory, { root: projectRoot })
|
|
480
571
|
if (check.status !== 'ok') throw new Error('Job not found')
|
|
481
|
-
const values = await Promise.all(Object.values(DETAIL_ARTIFACTS).map(name => readJson(path.join(directory, name))))
|
|
572
|
+
const values = await Promise.all(Object.values(DETAIL_ARTIFACTS).map(name => readJson(path.join(directory, name), { root: projectRoot })))
|
|
482
573
|
const artifacts = Object.fromEntries(Object.keys(DETAIL_ARTIFACTS).map((key, index) => [key, values[index]]))
|
|
483
574
|
if (artifacts.summary && !artifacts.summary.__readError) {
|
|
484
|
-
const
|
|
485
|
-
|
|
575
|
+
const fullSummary = artifacts.summary
|
|
576
|
+
const { trials: _trials, ...lightSummary } = fullSummary
|
|
577
|
+
artifacts.summary = rememberAuthoritativeRevision(lightSummary, fullSummary)
|
|
486
578
|
}
|
|
487
579
|
const validation = Object.fromEntries(Object.entries(artifacts).map(([key, value]) => {
|
|
488
580
|
const issue = schemaIssue(key, value)
|
|
@@ -554,9 +646,12 @@ function normalizeTrial(trial, order) {
|
|
|
554
646
|
|
|
555
647
|
async function jobTrials(config, job) {
|
|
556
648
|
const directory = jobDirectory(config, job)
|
|
649
|
+
const projectRoot = path.resolve(config.projectRoot)
|
|
650
|
+
const check = await directoryCheck(directory, { root: projectRoot })
|
|
651
|
+
if (check.status !== 'ok') throw new Error('Job not found')
|
|
557
652
|
const [summary, lifecycle] = await Promise.all([
|
|
558
|
-
readJson(path.join(directory, SUMMARY_NAME)),
|
|
559
|
-
readJson(path.join(directory, 'trial-lifecycle.json')),
|
|
653
|
+
readJson(path.join(directory, SUMMARY_NAME), { root: projectRoot }),
|
|
654
|
+
readJson(path.join(directory, 'trial-lifecycle.json'), { root: projectRoot }),
|
|
560
655
|
])
|
|
561
656
|
if ((!summary || summary.__readError) && (!lifecycle || lifecycle.__readError)) throw new Error('Job progress is unavailable')
|
|
562
657
|
const summaryTrials = (summary?.trials ?? []).map(normalizeTrial)
|
|
@@ -625,6 +720,11 @@ export async function readTrialsPage(config, args) {
|
|
|
625
720
|
const sort = String(args.sort ?? 'dataset-order')
|
|
626
721
|
const source = await jobTrials(config, job)
|
|
627
722
|
let trials = await enrichTrialsWithDataset(config, job, source.trials)
|
|
723
|
+
// Internal fixed-set reads must not expand to the entire (possibly large) Job.
|
|
724
|
+
if (Array.isArray(args.trialIds)) {
|
|
725
|
+
const selectedIds = new Set(args.trialIds)
|
|
726
|
+
trials = trials.filter(trial => selectedIds.has(trial.id))
|
|
727
|
+
}
|
|
628
728
|
if (query) trials = trials.filter(trial => `${trial.id ?? ''} ${trial.displayName ?? ''} ${trial.name ?? ''} ${trial.datasetTrial ?? ''}`.toLowerCase().includes(query))
|
|
629
729
|
if (status) trials = trials.filter(trial => trial.status === status)
|
|
630
730
|
if (validity) trials = trials.filter(trial => String(Boolean(trial.score?.valid)) === validity)
|
|
@@ -656,13 +756,13 @@ function previewFromOutput(output, evidence = []) {
|
|
|
656
756
|
return { kind: 'structured', format: 'json', title: output.title ?? 'Structured output', content: output, provenance: evidence }
|
|
657
757
|
}
|
|
658
758
|
|
|
659
|
-
async function previewFromTrialFiles(directory, lifecycle) {
|
|
759
|
+
async function previewFromTrialFiles(directory, lifecycle, projectRoot) {
|
|
660
760
|
let trialName
|
|
661
761
|
try { trialName = safeSegment(lifecycle?.name, 'trial directory') } catch { return undefined }
|
|
662
762
|
const trialDirectory = path.join(directory, trialName)
|
|
663
|
-
const check = await directoryCheck(trialDirectory)
|
|
763
|
+
const check = await directoryCheck(trialDirectory, { root: projectRoot })
|
|
664
764
|
if (check.status !== 'ok') return undefined
|
|
665
|
-
const manifest = await readJson(path.join(trialDirectory, 'artifacts', 'manifest.json'), { maxBytes: MAX_PREVIEW_BYTES, maxText: 128_000 })
|
|
765
|
+
const manifest = await readJson(path.join(trialDirectory, 'artifacts', 'manifest.json'), { maxBytes: MAX_PREVIEW_BYTES, maxText: 128_000, root: projectRoot })
|
|
666
766
|
const candidates = []
|
|
667
767
|
for (const entry of Array.isArray(manifest) ? manifest : []) {
|
|
668
768
|
if (!isObject(entry) || !['ok', 'collected', 'mounted'].includes(entry.status) || typeof entry.destination !== 'string' || !entry.destination.startsWith('artifacts/')) continue
|
|
@@ -674,31 +774,40 @@ async function previewFromTrialFiles(directory, lifecycle) {
|
|
|
674
774
|
candidates.sort((a, b) => (priority.get(path.extname(a).toLowerCase()) ?? 99) - (priority.get(path.extname(b).toLowerCase()) ?? 99) || a.localeCompare(b))
|
|
675
775
|
for (const candidate of candidates) {
|
|
676
776
|
try {
|
|
677
|
-
const details = await
|
|
777
|
+
const details = await safePathDetails(candidate, projectRoot)
|
|
678
778
|
if (!details.isFile() || details.isSymbolicLink() || details.size > MAX_PREVIEW_BYTES) continue
|
|
679
779
|
const format = path.extname(candidate).toLowerCase()
|
|
680
|
-
const
|
|
780
|
+
const source = await readFile(candidate, 'utf8')
|
|
781
|
+
const text = redactLocalPaths(redactSourceText(source))
|
|
681
782
|
const content = format === '.json' ? redact(JSON.parse(text), 0, 128_000) : redact(text, 0, 128_000)
|
|
682
783
|
const kind = ['.html', '.htm'].includes(format)
|
|
683
784
|
? 'page'
|
|
684
785
|
: format === '.json' && !(isObject(content) && ['answer', 'content', 'report', 'markdown', 'text'].some(key => typeof content[key] === 'string'))
|
|
685
786
|
? 'structured'
|
|
686
787
|
: 'document'
|
|
687
|
-
return
|
|
788
|
+
return rememberAuthoritativeRevision(
|
|
789
|
+
{ 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) }] },
|
|
790
|
+
rawContentRevision(source),
|
|
791
|
+
)
|
|
688
792
|
} catch { /* try the next declared artifact */ }
|
|
689
793
|
}
|
|
690
|
-
const trajectory = await readJson(path.join(trialDirectory, 'agent', 'trajectory.json'), { maxBytes: 2 * 1024 * 1024, maxText: 128_000 })
|
|
794
|
+
const trajectory = await readJson(path.join(trialDirectory, 'agent', 'trajectory.json'), { maxBytes: 2 * 1024 * 1024, maxText: 128_000, root: projectRoot })
|
|
691
795
|
const messages = (trajectory?.steps ?? []).filter(step => step?.source === 'agent' && typeof step.message === 'string').map(step => step.message)
|
|
692
|
-
return messages.length
|
|
796
|
+
return messages.length
|
|
797
|
+
? rememberAuthoritativeRevision(
|
|
798
|
+
{ 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' }] },
|
|
799
|
+
trajectory,
|
|
800
|
+
)
|
|
801
|
+
: undefined
|
|
693
802
|
}
|
|
694
803
|
|
|
695
|
-
async function evaluatorResultFromTrialFiles(directory, lifecycle) {
|
|
804
|
+
async function evaluatorResultFromTrialFiles(directory, lifecycle, projectRoot) {
|
|
696
805
|
let trialName
|
|
697
806
|
try { trialName = safeSegment(lifecycle?.name, 'trial directory') } catch { return undefined }
|
|
698
807
|
const trialDirectory = path.join(directory, trialName)
|
|
699
|
-
const check = await directoryCheck(trialDirectory)
|
|
808
|
+
const check = await directoryCheck(trialDirectory, { root: projectRoot })
|
|
700
809
|
if (check.status !== 'ok') return undefined
|
|
701
|
-
const result = await readJson(path.join(trialDirectory, 'verifier', 'evaluation-result.json'), { maxBytes: 128_000, maxText: 32_000 })
|
|
810
|
+
const result = await readJson(path.join(trialDirectory, 'verifier', 'evaluation-result.json'), { maxBytes: 128_000, maxText: 32_000, root: projectRoot })
|
|
702
811
|
return result && !result.__readError ? result : undefined
|
|
703
812
|
}
|
|
704
813
|
|
|
@@ -710,19 +819,23 @@ function enrichAssessmentWithEvaluator(assessment, evaluatorResult) {
|
|
|
710
819
|
return evaluator ? { ...item, reason: evaluator.reason ?? item.reason, recommendation: evaluator.recommendation ?? item.recommendation } : item
|
|
711
820
|
})
|
|
712
821
|
const evaluatorRecommendations = (evaluatorResult?.recommendations ?? []).map(item => isObject(item) ? item : { message: String(item) })
|
|
713
|
-
return
|
|
822
|
+
return rememberAuthoritativeRevision(
|
|
823
|
+
{ ...assessment, criteria, recommendations: [...(assessment.recommendations ?? []), ...evaluatorRecommendations] },
|
|
824
|
+
assessment,
|
|
825
|
+
evaluatorResult,
|
|
826
|
+
)
|
|
714
827
|
}
|
|
715
828
|
|
|
716
829
|
async function datasetRoots(directory, projectRoot) {
|
|
717
830
|
const entries = await readdir(directory, { withFileTypes: true })
|
|
718
831
|
const roots = []
|
|
719
832
|
for (const entry of entries.filter(item => item.isDirectory() && !item.isSymbolicLink()).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
720
|
-
const result = await readJson(path.join(directory, entry.name, 'result.json'))
|
|
833
|
+
const result = await readJson(path.join(directory, entry.name, 'result.json'), { root: projectRoot })
|
|
721
834
|
const candidate = result?.task_id?.path
|
|
722
835
|
if (typeof candidate !== 'string') continue
|
|
723
836
|
try {
|
|
724
837
|
const resolved = resolveWithin(projectRoot, path.relative(projectRoot, candidate), 'task path')
|
|
725
|
-
const check = await directoryCheck(resolved)
|
|
838
|
+
const check = await directoryCheck(resolved, { root: projectRoot })
|
|
726
839
|
if (check.status === 'ok') roots.push(resolved)
|
|
727
840
|
} catch { /* ignore historical out-of-root task sources */ }
|
|
728
841
|
}
|
|
@@ -732,17 +845,20 @@ async function datasetRoots(directory, projectRoot) {
|
|
|
732
845
|
export async function readDatasetPreview(config, args) {
|
|
733
846
|
const job = safeSegment(args.job, 'job')
|
|
734
847
|
const directory = jobDirectory(config, job)
|
|
735
|
-
const
|
|
736
|
-
|
|
737
|
-
|
|
848
|
+
const projectRoot = path.resolve(config.projectRoot)
|
|
849
|
+
const check = await directoryCheck(directory, { root: projectRoot })
|
|
850
|
+
if (check.status !== 'ok') throw new Error('Job not found')
|
|
851
|
+
const snapshot = await readJson(path.join(directory, 'dataset-preview.json'), { maxBytes: MAX_JSON_BYTES, maxText: 128_000, root: projectRoot })
|
|
852
|
+
if (snapshot && !snapshot.__readError) return rememberAuthoritativeRevision({ ...snapshot, source: 'job-snapshot' }, snapshot)
|
|
853
|
+
const manifest = await readJson(path.join(directory, 'dataset-manifest.json'), { root: projectRoot })
|
|
738
854
|
if (!manifest || manifest.__readError) throw new Error('Dataset Manifest is unavailable')
|
|
739
|
-
const roots = await datasetRoots(directory,
|
|
855
|
+
const roots = await datasetRoots(directory, projectRoot)
|
|
740
856
|
const tasks = []
|
|
741
857
|
for (const [index, task] of (manifest.tasks ?? []).entries()) {
|
|
742
858
|
const root = roots[Math.min(index, Math.max(0, roots.length - 1))]
|
|
743
859
|
let instruction = { error: 'instruction source is unavailable for this historical Job' }
|
|
744
860
|
if (root && typeof task?.instruction === 'string') {
|
|
745
|
-
try { instruction = await readSafeText(resolveWithin(root, task.instruction, 'task.instruction'),
|
|
861
|
+
try { instruction = await readSafeText(resolveWithin(root, task.instruction, 'task.instruction'), projectRoot) } catch { instruction = { error: 'instruction path is invalid' } }
|
|
746
862
|
}
|
|
747
863
|
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')) })
|
|
748
864
|
}
|
|
@@ -753,18 +869,21 @@ export async function readTrialDetail(config, args) {
|
|
|
753
869
|
const job = safeSegment(args.job, 'job')
|
|
754
870
|
const trial = safeSegment(args.trial, 'trial')
|
|
755
871
|
const directory = jobDirectory(config, job)
|
|
756
|
-
|
|
872
|
+
const projectRoot = path.resolve(config.projectRoot)
|
|
873
|
+
const check = await directoryCheck(directory, { root: projectRoot })
|
|
874
|
+
if (check.status !== 'ok') throw new Error('Job not found')
|
|
875
|
+
let assessment = await readJson(path.join(directory, 'trial-assessments', assessmentName(trial)), { root: projectRoot })
|
|
757
876
|
const source = await jobTrials(config, job)
|
|
758
877
|
const lifecycle = source.trials.find(item => String(item.id) === trial || String(item.datasetTrial) === trial || String(item.name) === trial)
|
|
759
878
|
if ((!assessment || assessment.__readError) && lifecycle?.id && String(lifecycle.id) !== trial) {
|
|
760
|
-
assessment = await readJson(path.join(directory, 'trial-assessments', assessmentName(lifecycle.id)))
|
|
879
|
+
assessment = await readJson(path.join(directory, 'trial-assessments', assessmentName(lifecycle.id)), { root: projectRoot })
|
|
761
880
|
}
|
|
762
881
|
if (assessment?.__readError) throw new Error('Trial assessment is invalid')
|
|
763
882
|
if (!assessment && !lifecycle) throw new Error('Trial not found')
|
|
764
|
-
assessment = enrichAssessmentWithEvaluator(assessment, await evaluatorResultFromTrialFiles(directory, lifecycle))
|
|
883
|
+
assessment = enrichAssessmentWithEvaluator(assessment, await evaluatorResultFromTrialFiles(directory, lifecycle, projectRoot))
|
|
765
884
|
const assessmentPreview = previewFromOutput(assessment?.output, assessment?.evidence_provenance)
|
|
766
885
|
const realAssessmentOutput = assessment?.evidence_provenance?.some(item => item?.kind === 'real-renderer' || item?.kind === 'agent-artifact')
|
|
767
|
-
const filePreview = realAssessmentOutput ? undefined : await previewFromTrialFiles(directory, lifecycle)
|
|
886
|
+
const filePreview = realAssessmentOutput ? undefined : await previewFromTrialFiles(directory, lifecycle, projectRoot)
|
|
768
887
|
const preview = realAssessmentOutput ? assessmentPreview : filePreview ?? assessmentPreview
|
|
769
888
|
return {
|
|
770
889
|
schemaVersion: 2, job, trial, lifecycle,
|
|
@@ -775,6 +894,116 @@ export async function readTrialDetail(config, args) {
|
|
|
775
894
|
}
|
|
776
895
|
}
|
|
777
896
|
|
|
897
|
+
function historicalEvidenceSelection(record, evidenceRef) {
|
|
898
|
+
if (evidenceRef === 'generation_record') return record
|
|
899
|
+
if (!evidenceRef.startsWith('generation_record.')) return undefined
|
|
900
|
+
const selector = evidenceRef.slice('generation_record.'.length)
|
|
901
|
+
const segments = selector.split('/').filter(Boolean)
|
|
902
|
+
if (
|
|
903
|
+
segments.length === 0
|
|
904
|
+
|| segments.length > 20
|
|
905
|
+
|| segments.some(segment => !/^(?:[A-Za-z_][A-Za-z0-9_-]{0,127}|0|[1-9][0-9]{0,5})$/.test(segment))
|
|
906
|
+
) return undefined
|
|
907
|
+
let selected = record
|
|
908
|
+
for (const segment of segments) {
|
|
909
|
+
if (Array.isArray(selected)) {
|
|
910
|
+
const index = Number(segment)
|
|
911
|
+
if (!Number.isSafeInteger(index) || index < 0 || index >= selected.length) return undefined
|
|
912
|
+
selected = selected[index]
|
|
913
|
+
continue
|
|
914
|
+
}
|
|
915
|
+
if (!isObject(selected) || !Object.hasOwn(selected, segment)) return undefined
|
|
916
|
+
selected = selected[segment]
|
|
917
|
+
}
|
|
918
|
+
return selected
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
/**
|
|
922
|
+
* Read the two immutable evidence containers produced by Historical Generation
|
|
923
|
+
* evaluation. Criterion evidence refs are semantic selectors (for example,
|
|
924
|
+
* generation_record.visible_transcript/1), not filesystem paths. Keep the
|
|
925
|
+
* filesystem mapping here so neither the browser nor the Agent can choose an
|
|
926
|
+
* arbitrary artifact path.
|
|
927
|
+
*/
|
|
928
|
+
export async function readHistoricalEvidence(config, args) {
|
|
929
|
+
const job = safeSegment(args.job, 'job')
|
|
930
|
+
const trial = safeSegment(args.trial, 'trial')
|
|
931
|
+
const criterion = String(args.criterion ?? '')
|
|
932
|
+
if (!criterion || criterion.length > 180 || /[\u0000-\u001f\u007f]/.test(criterion)) {
|
|
933
|
+
return { available: false, reason: 'The requested Historical Generation Criterion is invalid.' }
|
|
934
|
+
}
|
|
935
|
+
const evidenceRef = String(args.evidenceRef ?? '')
|
|
936
|
+
if (
|
|
937
|
+
evidenceRef !== 'judge-gateway'
|
|
938
|
+
&& evidenceRef !== 'generation_record'
|
|
939
|
+
&& !/^generation_record\.[A-Za-z_][A-Za-z0-9_-]{0,127}(?:\/(?:[A-Za-z_][A-Za-z0-9_-]{0,127}|0|[1-9][0-9]{0,5})){0,19}$/.test(evidenceRef)
|
|
940
|
+
) return { available: false, reason: 'The requested ref is not a supported Historical Generation evidence selector.' }
|
|
941
|
+
|
|
942
|
+
const projectRoot = path.resolve(config.projectRoot)
|
|
943
|
+
const directory = jobDirectory(config, job)
|
|
944
|
+
const source = await jobTrials(config, job)
|
|
945
|
+
const lifecycle = source.trials.find(item => (
|
|
946
|
+
String(item.id) === trial || String(item.datasetTrial) === trial || String(item.name) === trial
|
|
947
|
+
))
|
|
948
|
+
if (!lifecycle) return { available: false, reason: 'The Historical Generation Trial is unavailable.' }
|
|
949
|
+
let trialName
|
|
950
|
+
try { trialName = safeSegment(lifecycle.name ?? lifecycle.id, 'trial directory') } catch {
|
|
951
|
+
return { available: false, reason: 'The Historical Generation Trial directory is invalid.' }
|
|
952
|
+
}
|
|
953
|
+
const trialDirectory = path.join(directory, trialName)
|
|
954
|
+
const trialCheck = await directoryCheck(trialDirectory, { root: projectRoot })
|
|
955
|
+
if (trialCheck.status !== 'ok') return { available: false, reason: 'The Historical Generation Trial directory is unavailable.' }
|
|
956
|
+
|
|
957
|
+
if (evidenceRef === 'judge-gateway') {
|
|
958
|
+
const result = await readJson(path.join(trialDirectory, 'verifier', 'evaluation-result.json'), {
|
|
959
|
+
maxBytes: 128_000,
|
|
960
|
+
maxText: 32_000,
|
|
961
|
+
root: projectRoot,
|
|
962
|
+
})
|
|
963
|
+
if (!result || result.__readError) return { available: false, reason: 'The frozen evaluator result is unavailable.' }
|
|
964
|
+
const matches = (Array.isArray(result.criteria) ? result.criteria : [])
|
|
965
|
+
.filter(item => isObject(item) && String(item.id) === criterion)
|
|
966
|
+
if (matches.length !== 1) return { available: false, reason: 'The frozen evaluator result has no unique matching Criterion.' }
|
|
967
|
+
return {
|
|
968
|
+
available: true,
|
|
969
|
+
content: matches[0],
|
|
970
|
+
source: {
|
|
971
|
+
id: 'evaluator-result-v2',
|
|
972
|
+
kind: 'evaluator-result',
|
|
973
|
+
artifactRef: 'verifier/evaluation-result.json',
|
|
974
|
+
selector: `criteria[id=${criterion}]`,
|
|
975
|
+
},
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
const observationCandidates = [
|
|
980
|
+
path.join(trialDirectory, 'artifacts', 'logs', 'artifacts', 'session-observation.json'),
|
|
981
|
+
path.join(trialDirectory, 'artifacts', 'session-observation.json'),
|
|
982
|
+
]
|
|
983
|
+
const observations = []
|
|
984
|
+
for (const candidate of observationCandidates) {
|
|
985
|
+
const value = await readJson(candidate, { maxBytes: MAX_PREVIEW_BYTES, maxText: 128_000, root: projectRoot })
|
|
986
|
+
if (value && !value.__readError) {
|
|
987
|
+
observations.push({ value, artifactRef: path.relative(trialDirectory, candidate) })
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
if (observations.length === 0) return { available: false, reason: 'The frozen Session Observation is unavailable.' }
|
|
991
|
+
if (observations.length > 1) return { available: false, reason: 'Multiple Session Observation containers are present; exact provenance is ambiguous.' }
|
|
992
|
+
const [{ value: observation, artifactRef }] = observations
|
|
993
|
+
const content = historicalEvidenceSelection(observation, evidenceRef)
|
|
994
|
+
if (content === undefined) return { available: false, reason: 'The requested field is absent from the frozen Session Observation.' }
|
|
995
|
+
return {
|
|
996
|
+
available: true,
|
|
997
|
+
content,
|
|
998
|
+
source: {
|
|
999
|
+
id: 'frozen-session-observation',
|
|
1000
|
+
kind: 'historical-generation-record',
|
|
1001
|
+
artifactRef,
|
|
1002
|
+
selector: evidenceRef,
|
|
1003
|
+
},
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
|
|
778
1007
|
export async function readJobProgress(config, args) {
|
|
779
1008
|
const job = safeSegment(args.job, 'job')
|
|
780
1009
|
const since = args.since ? Date.parse(args.since) : 0
|
|
@@ -791,13 +1020,16 @@ export async function readJobProgress(config, args) {
|
|
|
791
1020
|
}
|
|
792
1021
|
|
|
793
1022
|
export async function readMetaEvaluation(config, args = {}) {
|
|
794
|
-
const
|
|
795
|
-
const
|
|
1023
|
+
const projectRoot = path.resolve(config.projectRoot)
|
|
1024
|
+
const evaluationRoot = resolveWithin(projectRoot, args.evaluationRoot ?? '.', 'evaluationRoot')
|
|
1025
|
+
const evaluationCheck = await directoryCheck(evaluationRoot, { root: projectRoot })
|
|
1026
|
+
if (evaluationCheck.status !== 'ok') throw new Error('Evaluation root is not a safe directory')
|
|
1027
|
+
const index = await readJson(path.join(evaluationRoot, '.harbor', 'meta-artifacts.json'), { root: projectRoot })
|
|
796
1028
|
const registered = index?.schema_version === 1 ? index.artifacts ?? {} : {}
|
|
797
1029
|
const groundTruthPath = resolveWithin(evaluationRoot, registered.ground_truth ?? '.harbor/ground-truth.json', 'groundTruthPath')
|
|
798
1030
|
const reportPath = resolveWithin(evaluationRoot, registered.meta_evaluation_report ?? '.harbor/meta-evaluation-report.json', 'metaEvaluationReportPath')
|
|
799
|
-
const groundTruth = await readJson(groundTruthPath, { maxText: 64_000 })
|
|
800
|
-
const report = await readJson(reportPath, { maxText: 64_000 })
|
|
1031
|
+
const groundTruth = await readJson(groundTruthPath, { maxText: 64_000, root: projectRoot })
|
|
1032
|
+
const report = await readJson(reportPath, { maxText: 64_000, root: projectRoot })
|
|
801
1033
|
const availableGroundTruth = groundTruth && !groundTruth.__readError
|
|
802
1034
|
const availableReport = report && !report.__readError
|
|
803
1035
|
const cases = availableGroundTruth && Array.isArray(groundTruth.cases) ? groundTruth.cases : []
|
|
@@ -810,7 +1042,7 @@ export async function readMetaEvaluation(config, args = {}) {
|
|
|
810
1042
|
} : undefined
|
|
811
1043
|
return {
|
|
812
1044
|
schemaVersion: 1,
|
|
813
|
-
evaluationRoot: path.relative(
|
|
1045
|
+
evaluationRoot: path.relative(projectRoot, evaluationRoot) || '.',
|
|
814
1046
|
status: availableReport ? 'evaluated' : availableGroundTruth ? (cases.length ? 'ground-truth-ready' : 'ground-truth-draft') : 'ground-truth-required',
|
|
815
1047
|
groundTruth: availableGroundTruth ? {
|
|
816
1048
|
id: groundTruth.ground_truth_id,
|
|
@@ -847,22 +1079,71 @@ export async function readMetaEvaluation(config, args = {}) {
|
|
|
847
1079
|
}
|
|
848
1080
|
}
|
|
849
1081
|
|
|
850
|
-
function
|
|
851
|
-
|
|
1082
|
+
function comparisonTrials(summary, lifecycle) {
|
|
1083
|
+
const summaryTrials = (summary?.trials ?? []).map(normalizeTrial)
|
|
1084
|
+
if (!Array.isArray(lifecycle?.trials)) return summaryTrials
|
|
1085
|
+
const byExecution = new Map(summaryTrials.map(item => [String(item.id), item]))
|
|
1086
|
+
const byDataset = new Map(summaryTrials.map(item => [String(item.datasetTrial ?? item.name), item]))
|
|
1087
|
+
const matched = new Set()
|
|
1088
|
+
const current = selectedLifecycleTrials(lifecycle).map((item, index) => {
|
|
1089
|
+
const evaluated = byExecution.get(String(item.execution_id)) ?? byDataset.get(String(item.dataset_trial))
|
|
1090
|
+
if (evaluated) matched.add(evaluated)
|
|
1091
|
+
const lifecycleStatus = item.status ?? item.phase
|
|
1092
|
+
return normalizeTrial({
|
|
1093
|
+
...evaluated,
|
|
1094
|
+
...item,
|
|
1095
|
+
id: evaluated?.id ?? item.execution_id,
|
|
1096
|
+
name: evaluated?.name ?? item.trial_name ?? item.dataset_trial,
|
|
1097
|
+
datasetTrial: evaluated?.datasetTrial ?? item.dataset_trial,
|
|
1098
|
+
status: lifecycleStatus ?? evaluated?.status,
|
|
1099
|
+
score: item.score ?? evaluated?.score,
|
|
1100
|
+
rewards: evaluated?.rewards ?? {},
|
|
1101
|
+
exception: lifecycleStatus === 'infrastructure-error' ? evaluated?.exception : undefined,
|
|
1102
|
+
terminal: item.terminal,
|
|
1103
|
+
}, index)
|
|
1104
|
+
})
|
|
1105
|
+
return [...current, ...summaryTrials.filter(item => !matched.has(item))]
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
function compareTrialMaps(summary, lifecycle) {
|
|
1109
|
+
return new Map(comparisonTrials(summary, lifecycle).map(item => [String(item.datasetTrial ?? item.name ?? item.id), item]))
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
function isInvalidComparisonTrial(trial) {
|
|
1113
|
+
return trial?.terminal !== false
|
|
1114
|
+
&& trial?.score?.valid === false
|
|
1115
|
+
&& !['completed-unscored', 'cancelled'].includes(trial?.status)
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
function isInfrastructureComparisonTrial(trial) {
|
|
1119
|
+
return trial?.terminal !== false && (
|
|
1120
|
+
trial?.status === 'infrastructure-error'
|
|
1121
|
+
|| trial?.exception?.classification === 'infrastructure'
|
|
1122
|
+
)
|
|
852
1123
|
}
|
|
853
1124
|
|
|
854
1125
|
export async function readComparison(config, args) {
|
|
855
1126
|
const baselineJob = safeSegment(args.baseline, 'baseline')
|
|
856
1127
|
const candidateJob = safeSegment(args.candidate, 'candidate')
|
|
857
|
-
const
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
1128
|
+
const projectRoot = path.resolve(config.projectRoot)
|
|
1129
|
+
const [baselineDirectoryCheck, candidateDirectoryCheck] = await Promise.all([
|
|
1130
|
+
directoryCheck(jobDirectory(config, baselineJob), { root: projectRoot }),
|
|
1131
|
+
directoryCheck(jobDirectory(config, candidateJob), { root: projectRoot }),
|
|
1132
|
+
])
|
|
1133
|
+
if (baselineDirectoryCheck.status !== 'ok' || candidateDirectoryCheck.status !== 'ok') {
|
|
1134
|
+
throw new Error('Both Job directories must be safe')
|
|
1135
|
+
}
|
|
1136
|
+
const [baseline, candidate, baselineContract, candidateContract, baselineLifecycle, candidateLifecycle] = await Promise.all([
|
|
1137
|
+
readJson(path.join(jobDirectory(config, baselineJob), SUMMARY_NAME), { root: projectRoot }),
|
|
1138
|
+
readJson(path.join(jobDirectory(config, candidateJob), SUMMARY_NAME), { root: projectRoot }),
|
|
1139
|
+
readJson(path.join(jobDirectory(config, baselineJob), 'evaluation-contract.json'), { root: projectRoot }),
|
|
1140
|
+
readJson(path.join(jobDirectory(config, candidateJob), 'evaluation-contract.json'), { root: projectRoot }),
|
|
1141
|
+
readJson(path.join(jobDirectory(config, baselineJob), 'trial-lifecycle.json'), { root: projectRoot }),
|
|
1142
|
+
readJson(path.join(jobDirectory(config, candidateJob), 'trial-lifecycle.json'), { root: projectRoot }),
|
|
862
1143
|
])
|
|
863
1144
|
if (!baseline || baseline.__readError || !candidate || candidate.__readError) throw new Error('Both Job summaries are required')
|
|
864
|
-
const baselineContext = baseline.evaluation_context ?? await readJson(path.join(jobDirectory(config, baselineJob), 'evaluation-context.json'))
|
|
865
|
-
const candidateContext = candidate.evaluation_context ?? await readJson(path.join(jobDirectory(config, candidateJob), 'evaluation-context.json'))
|
|
1145
|
+
const baselineContext = baseline.evaluation_context ?? await readJson(path.join(jobDirectory(config, baselineJob), 'evaluation-context.json'), { root: projectRoot })
|
|
1146
|
+
const candidateContext = candidate.evaluation_context ?? await readJson(path.join(jobDirectory(config, candidateJob), 'evaluation-context.json'), { root: projectRoot })
|
|
866
1147
|
const baselineKind = normalizedJobKind(baseline, baselineContext)
|
|
867
1148
|
const candidateKind = normalizedJobKind(candidate, candidateContext)
|
|
868
1149
|
if (baselineKind !== CANDIDATE_JOB_KIND || candidateKind !== CANDIDATE_JOB_KIND) {
|
|
@@ -870,7 +1151,7 @@ export async function readComparison(config, args) {
|
|
|
870
1151
|
code: 'UNSUPPORTED_JOB_KIND_FOR_PROMOTION',
|
|
871
1152
|
message: 'Historical Generation Evaluation Jobs are diagnostic evidence and cannot be used as a Candidate baseline, comparison, or Promotion Gate input.',
|
|
872
1153
|
}
|
|
873
|
-
return {
|
|
1154
|
+
return rememberAuthoritativeRevision({
|
|
874
1155
|
schemaVersion: 1,
|
|
875
1156
|
baselineJob,
|
|
876
1157
|
candidateJob,
|
|
@@ -882,12 +1163,14 @@ export async function readComparison(config, args) {
|
|
|
882
1163
|
population: {},
|
|
883
1164
|
improvedTrials: [],
|
|
884
1165
|
regressedTrials: [],
|
|
1166
|
+
invalidTrials: [],
|
|
1167
|
+
newInfrastructureExceptions: [],
|
|
885
1168
|
newExceptions: [],
|
|
886
1169
|
artifactRegressions: [],
|
|
887
1170
|
gateEligibility: 'not-applicable',
|
|
888
1171
|
error,
|
|
889
1172
|
note: 'Convert reviewed badcases into a fixed regression Dataset before running Candidate comparison or Gate.',
|
|
890
|
-
}
|
|
1173
|
+
}, baseline, candidate, baselineContract, candidateContract, baselineLifecycle, candidateLifecycle, baselineContext, candidateContext)
|
|
891
1174
|
}
|
|
892
1175
|
const reasons = []
|
|
893
1176
|
if (baselineContext?.schema_version !== 2 || candidateContext?.schema_version !== 2) reasons.push('Context v2 is required')
|
|
@@ -902,39 +1185,67 @@ export async function readComparison(config, args) {
|
|
|
902
1185
|
? (directions[key] === 'minimize' ? baseline.metrics[key] - candidate.metrics[key] : candidate.metrics[key] - baseline.metrics[key])
|
|
903
1186
|
: undefined,
|
|
904
1187
|
}]))
|
|
905
|
-
const oldTrials = compareTrialMaps(baseline)
|
|
906
|
-
const nextTrials = compareTrialMaps(candidate)
|
|
1188
|
+
const oldTrials = compareTrialMaps(baseline, baselineLifecycle)
|
|
1189
|
+
const nextTrials = compareTrialMaps(candidate, candidateLifecycle)
|
|
907
1190
|
const improved = []
|
|
908
1191
|
const regressed = []
|
|
909
1192
|
const primaryDirection = directions[candidateContract?.primary_metric] ?? 'maximize'
|
|
910
1193
|
for (const trial of [...oldTrials.keys()].filter(key => nextTrials.has(key)).sort()) {
|
|
911
|
-
const
|
|
912
|
-
const
|
|
913
|
-
if (
|
|
1194
|
+
const oldTrial = oldTrials.get(trial)
|
|
1195
|
+
const newTrial = nextTrials.get(trial)
|
|
1196
|
+
if (oldTrial.score?.valid !== true || newTrial.score?.valid !== true) continue
|
|
1197
|
+
const oldValue = oldTrial.score.value ?? oldTrial.rewards?.reward
|
|
1198
|
+
const newValue = newTrial.score.value ?? newTrial.rewards?.reward
|
|
1199
|
+
if (!Number.isFinite(oldValue) || !Number.isFinite(newValue) || oldValue === newValue) continue
|
|
914
1200
|
const item = { trial, baseline: oldValue, candidate: newValue, delta: newValue - oldValue }
|
|
915
1201
|
const isImproved = primaryDirection === 'minimize' ? newValue < oldValue : newValue > oldValue
|
|
916
1202
|
;(isImproved ? improved : regressed).push(item)
|
|
917
1203
|
}
|
|
1204
|
+
const invalidTrials = [...nextTrials.entries()]
|
|
1205
|
+
.filter(([, trial]) => isInvalidComparisonTrial(trial))
|
|
1206
|
+
.map(([trial, candidateTrial]) => ({
|
|
1207
|
+
trial,
|
|
1208
|
+
status: candidateTrial.status,
|
|
1209
|
+
invalidReasons: Array.isArray(candidateTrial.score?.invalid_reasons)
|
|
1210
|
+
? candidateTrial.score.invalid_reasons.slice(0, 20).map(String)
|
|
1211
|
+
: [],
|
|
1212
|
+
baselineValid: oldTrials.get(trial)?.score?.valid,
|
|
1213
|
+
candidateValid: false,
|
|
1214
|
+
}))
|
|
1215
|
+
const newInfrastructureExceptions = [...nextTrials.entries()]
|
|
1216
|
+
.filter(([trial, candidateTrial]) => (
|
|
1217
|
+
isInfrastructureComparisonTrial(candidateTrial)
|
|
1218
|
+
&& !isInfrastructureComparisonTrial(oldTrials.get(trial))
|
|
1219
|
+
))
|
|
1220
|
+
.map(([trial, candidateTrial]) => ({
|
|
1221
|
+
trial,
|
|
1222
|
+
baselineStatus: oldTrials.get(trial)?.status,
|
|
1223
|
+
candidateStatus: candidateTrial.status,
|
|
1224
|
+
...(candidateTrial.exception ? { exception: candidateTrial.exception } : {}),
|
|
1225
|
+
}))
|
|
918
1226
|
const baselineExceptions = new Set((baseline.exceptions ?? []).map(item => String(item.trial)))
|
|
919
1227
|
const newExceptions = (candidate.exceptions ?? []).filter(item => !baselineExceptions.has(String(item.trial)))
|
|
920
1228
|
const artifactRegressions = (baseline.artifact_validation?.valid && !candidate.artifact_validation?.valid) ? ['artifact-validation'] : []
|
|
921
|
-
return {
|
|
1229
|
+
return rememberAuthoritativeRevision({
|
|
922
1230
|
schemaVersion: 1, baselineJob, candidateJob, comparable: reasons.length === 0, comparabilityReasons: reasons,
|
|
923
1231
|
metrics, population: { baseline: baseline.n_trials, candidate: candidate.n_trials, baselineValid: baseline.n_valid_scores, candidateValid: candidate.n_valid_scores },
|
|
924
|
-
improvedTrials: improved, regressedTrials: regressed, newExceptions, artifactRegressions,
|
|
1232
|
+
improvedTrials: improved, regressedTrials: regressed, invalidTrials, newInfrastructureExceptions, newExceptions, artifactRegressions,
|
|
925
1233
|
gateEligibility: reasons.length ? 'not-comparable' : 'requires-explicit-gate',
|
|
926
1234
|
note: 'This read-only comparison never runs Gate, promotes a Candidate, deploys, or publishes.',
|
|
927
|
-
}
|
|
1235
|
+
}, baseline, candidate, baselineContract, candidateContract, baselineLifecycle, candidateLifecycle, baselineContext, candidateContext)
|
|
928
1236
|
}
|
|
929
1237
|
|
|
930
1238
|
export async function readEvaluatorGovernance(config, args) {
|
|
931
1239
|
const job = safeSegment(args.job, 'job')
|
|
932
1240
|
const directory = jobDirectory(config, job)
|
|
1241
|
+
const projectRoot = path.resolve(config.projectRoot)
|
|
1242
|
+
const check = await directoryCheck(directory, { root: projectRoot })
|
|
1243
|
+
if (check.status !== 'ok') throw new Error('Job not found')
|
|
933
1244
|
const [stack, sources, contract, context] = await Promise.all([
|
|
934
|
-
readJson(path.join(directory, 'evaluation-stack-manifest.json')),
|
|
935
|
-
readJson(path.join(directory, 'evaluation-stack-sources.json'), { maxText: MAX_SOURCE_BYTES }),
|
|
936
|
-
readJson(path.join(directory, 'evaluation-contract.json')),
|
|
937
|
-
readJson(path.join(directory, 'evaluation-context.json')),
|
|
1245
|
+
readJson(path.join(directory, 'evaluation-stack-manifest.json'), { root: projectRoot }),
|
|
1246
|
+
readJson(path.join(directory, 'evaluation-stack-sources.json'), { maxText: MAX_SOURCE_BYTES, root: projectRoot }),
|
|
1247
|
+
readJson(path.join(directory, 'evaluation-contract.json'), { root: projectRoot }),
|
|
1248
|
+
readJson(path.join(directory, 'evaluation-context.json'), { root: projectRoot }),
|
|
938
1249
|
])
|
|
939
1250
|
if (!stack || stack.__readError) throw new Error('Evaluation Stack is unavailable')
|
|
940
1251
|
const historicalSources = sources?.schema_version === 1 && sources.stack_digest === stack.digest
|
|
@@ -944,18 +1255,26 @@ export async function readEvaluatorGovernance(config, args) {
|
|
|
944
1255
|
for (const [role, component] of Object.entries(stack.components ?? {})) {
|
|
945
1256
|
const entry = component?.entry
|
|
946
1257
|
const snapshot = historicalSources?.components?.[role]
|
|
947
|
-
|
|
1258
|
+
// The descriptor is identity/configuration, not the editable prompt. Prefer
|
|
1259
|
+
// an authorized historical prompt/implementation without reading live text.
|
|
1260
|
+
const editable = role === 'evaluator' ? component?.interface?.editable_files ?? [] : []
|
|
1261
|
+
const preferred = editable.find(item => item.role === 'prompt') ?? editable.find(item => item.role === 'implementation')
|
|
1262
|
+
const snapshotFile = snapshot?.files?.find(item => item.path === preferred?.path && item.text)
|
|
1263
|
+
?? snapshot?.files?.find(item => item.path === entry && item.text)
|
|
948
1264
|
?? snapshot?.files?.find(item => item.text)
|
|
949
1265
|
const source = snapshotFile
|
|
950
1266
|
? { ...snapshotFile, source: 'job-snapshot', readOnly: true }
|
|
951
1267
|
: entry
|
|
952
|
-
? { ...(await readSafeText(resolveWithin(
|
|
1268
|
+
? { ...(await readSafeText(resolveWithin(projectRoot, entry, `${role}.entry`), projectRoot)), source: 'historical-live-fallback', readOnly: true }
|
|
953
1269
|
: { error: 'entry unavailable', source: 'unavailable', readOnly: true }
|
|
954
1270
|
components[role] = { ...component, source }
|
|
955
1271
|
}
|
|
956
1272
|
let comparison
|
|
957
1273
|
if (args.compareJob) {
|
|
958
|
-
const
|
|
1274
|
+
const compareDirectory = jobDirectory(config, safeSegment(args.compareJob, 'compareJob'))
|
|
1275
|
+
const compareCheck = await directoryCheck(compareDirectory, { root: projectRoot })
|
|
1276
|
+
if (compareCheck.status !== 'ok') throw new Error('Comparison Job not found')
|
|
1277
|
+
const other = await readJson(path.join(compareDirectory, 'evaluation-stack-manifest.json'), { root: projectRoot })
|
|
959
1278
|
const changes = []
|
|
960
1279
|
for (const role of new Set([...Object.keys(stack.components ?? {}), ...Object.keys(other?.components ?? {})])) {
|
|
961
1280
|
const before = other?.components?.[role]
|