dsh-harbor-evolution 0.7.2 → 0.8.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/lib/dashboard.js CHANGED
@@ -1,10 +1,13 @@
1
1
  import { access, constants, lstat, readdir, readFile, stat } from 'node:fs/promises'
2
+ import { createHash } from 'node:crypto'
2
3
  import path from 'node:path'
3
4
 
4
5
  import { resolveWithin } from './evolution.js'
5
6
 
6
7
  const SUMMARY_NAME = 'evaluation-summary.json'
7
- const MAX_JOBS = 50
8
+ const HISTORICAL_COMPLETION_NAME = 'historical-evaluation-complete.json'
9
+ const DEFAULT_JOB_PAGE_SIZE = 20
10
+ const MAX_JOB_PAGE_SIZE = 100
8
11
  const MAX_JSON_BYTES = 2 * 1024 * 1024
9
12
  const MAX_SOURCE_BYTES = 128 * 1024
10
13
  const MAX_PREVIEW_BYTES = 512 * 1024
@@ -12,6 +15,11 @@ const MAX_TRIAL_LIMIT = 100
12
15
  const jsonCache = new Map()
13
16
  const SENSITIVE_KEY = /authorization|cookie|token|api[_-]?key|secret|password|request[_-]?headers/i
14
17
  const SENSITIVE_SOURCE_VALUE = /(authorization|cookie|token|api[_-]?key|secret|password)\s*[:=]\s*([^\s,;]+)/gi
18
+ const WORKSPACE_SKIP_DIRECTORIES = new Set([
19
+ '.cache', '.git', '.harbor', '.next', '.venv', '__pycache__',
20
+ 'build', 'candidates', 'coverage', 'datasets', 'dist', 'jobs', 'node_modules', 'public', 'vendor', 'venv',
21
+ ])
22
+ const MAX_WORKSPACE_DEPTH = 5
15
23
 
16
24
  function safeSegment(value, label) {
17
25
  if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,199}$/.test(String(value ?? ''))) throw new Error(`${label} is invalid`)
@@ -100,19 +108,65 @@ function isObject(value) {
100
108
  return value !== null && typeof value === 'object' && !Array.isArray(value)
101
109
  }
102
110
 
111
+ const CANDIDATE_JOB_KIND = 'candidate-evaluation'
112
+ const HISTORICAL_JOB_KIND = 'historical-generation-evaluation'
113
+
114
+ function normalizedJobKind(summary, context) {
115
+ const declared = summary?.job_kind ?? context?.job_kind
116
+ if (typeof declared === 'string' && declared) return declared
117
+ if (context?.protocol === 'historical-generation-evaluation-context/v1') return HISTORICAL_JOB_KIND
118
+ return CANDIDATE_JOB_KIND
119
+ }
120
+
121
+ function coverageView(summary) {
122
+ if (isObject(summary?.coverage)) return summary.coverage
123
+ const total = Number(summary?.n_trials ?? 0)
124
+ const scored = Number(summary?.scored_trial_count ?? summary?.n_valid_scores ?? 0)
125
+ const unscored = Number(
126
+ summary?.unscored_trial_count
127
+ ?? summary?.status_counts?.['completed-unscored']
128
+ ?? 0,
129
+ )
130
+ return {
131
+ scored_trials: scored,
132
+ unscored_trials: unscored,
133
+ total_trials: total,
134
+ trial_rate: total ? scored / total : undefined,
135
+ }
136
+ }
137
+
138
+ function evaluatorMetaEvaluation(summary, context) {
139
+ return summary?.evaluator_meta_evaluation
140
+ ?? context?.downstream_analysis?.evaluator_meta_evaluation
141
+ ?? (normalizedJobKind(summary, context) === HISTORICAL_JOB_KIND
142
+ ? { status: 'not-run', validation_report_ref: null }
143
+ : undefined)
144
+ }
145
+
103
146
  function capabilityMap(summary, context, lifecycle, registry, stack) {
147
+ const jobKind = normalizedJobKind(summary, context)
148
+ const historicalGeneration = jobKind === HISTORICAL_JOB_KIND
104
149
  const contextV2 = context?.schema_version === 2
150
+ const historicalContext = context?.schema_version === 1
151
+ && context?.protocol === 'historical-generation-evaluation-context/v1'
105
152
  const scoreValidity = summary?.schema_version === 3
153
+ || summary?.schema_version === 4
106
154
  return {
155
+ jobKind,
107
156
  contextV2,
157
+ contextSupported: contextV2 || historicalContext,
158
+ historicalGeneration,
159
+ candidateEvaluation: !historicalGeneration,
108
160
  trialLifecycle: lifecycle?.schema_version === 1,
109
161
  scoreValidity,
110
162
  evidenceProvenance: scoreValidity,
111
- artifactRegistry: registry?.schema_version === 1,
112
- compare: contextV2,
163
+ artifactRegistry: [1, 2].includes(registry?.schema_version),
164
+ source: historicalGeneration,
165
+ compare: contextV2 && !historicalGeneration,
113
166
  evaluatorGovernance: stack?.schema_version === 1,
114
- gate: contextV2 && summary?.mode === 'promotion-eligible',
115
- readOnlyLegacy: !contextV2,
167
+ evaluatorMetaEvaluation: evaluatorMetaEvaluation(summary, context),
168
+ gate: contextV2 && !historicalGeneration && summary?.mode === 'promotion-eligible',
169
+ readOnlyLegacy: !contextV2 && !historicalContext,
116
170
  }
117
171
  }
118
172
 
@@ -124,7 +178,7 @@ function primaryMetric(summary, contract) {
124
178
  }
125
179
 
126
180
  function progressView(summary, lifecycle, updatedAt) {
127
- const total = Number(lifecycle?.dataset_total ?? summary?.n_trials ?? 0)
181
+ const total = Number(lifecycle?.dataset_total ?? summary?.n_trials ?? summary?.coverage?.total_trials ?? 0)
128
182
  const lifecycleTrials = selectedLifecycleTrials(lifecycle)
129
183
  const completed = lifecycle
130
184
  ? lifecycleTrials.filter(item => item.terminal).length
@@ -144,19 +198,53 @@ function progressView(summary, lifecycle, updatedAt) {
144
198
  }
145
199
  }
146
200
 
147
- function jobStatus(summary, lifecycle, progress) {
201
+ const HISTORICAL_COVERAGE_KEYS = [
202
+ 'scored_trials', 'unscored_trials', 'total_trials', 'trial_rate',
203
+ 'criterion_scored', 'criterion_total', 'criterion_rate',
204
+ ]
205
+
206
+ function historicalCompletionValid(summary, completion, jobName) {
207
+ return (
208
+ summary?.schema_version === 4
209
+ && summary?.job === jobName
210
+ && summary?.job_kind === HISTORICAL_JOB_KIND
211
+ && summary?.mode === 'diagnostic'
212
+ && summary?.execution_mode === 'observe-existing'
213
+ && summary?.artifact_validation?.valid === true
214
+ && summary?.candidate === undefined
215
+ && completion?.schema_version === 1
216
+ && completion?.job_kind === HISTORICAL_JOB_KIND
217
+ && completion?.status === 'completed'
218
+ && completion?.valid === true
219
+ && completion?.job === jobName
220
+ && completion?.summary_path === SUMMARY_NAME
221
+ && completion?.artifact_registry_path === 'artifact-registry.json'
222
+ && HISTORICAL_COVERAGE_KEYS.every(key => (
223
+ typeof summary?.coverage?.[key] === 'number'
224
+ && summary.coverage[key] === completion?.coverage?.[key]
225
+ ))
226
+ )
227
+ }
228
+
229
+ function jobStatus(summary, lifecycle, progress, jobKind, completion, jobName) {
148
230
  if (summary?.__readError) return 'failed'
149
231
  if (!summary && !lifecycle) return 'pending'
150
232
  if (progress.active) return 'running'
151
233
  if (!summary && lifecycle) return 'running'
234
+ if (summary?.artifact_validation?.valid === false) return 'failed'
235
+ if (
236
+ jobKind === HISTORICAL_JOB_KIND
237
+ && !historicalCompletionValid(summary, completion, jobName)
238
+ ) return 'failed'
152
239
  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'
240
+ const invalidScores = Number(summary.n_invalid_scores ?? 0)
241
+ if (invalidScores > 0) return 'attention'
154
242
  return 'completed'
155
243
  }
156
244
 
157
245
  async function readJob(jobsDir, entry, details) {
158
246
  const directory = path.join(jobsDir, entry.name)
159
- const [summary, contextFile, promotion, contract, lifecycle, registry, stack] = await Promise.all([
247
+ const [summary, contextFile, promotion, contract, lifecycle, registry, stack, completion] = await Promise.all([
160
248
  readJson(path.join(directory, SUMMARY_NAME)),
161
249
  readJson(path.join(directory, 'evaluation-context.json')),
162
250
  readJson(path.join(directory, 'promotion-report.json')),
@@ -164,25 +252,38 @@ async function readJob(jobsDir, entry, details) {
164
252
  readJson(path.join(directory, 'trial-lifecycle.json')),
165
253
  readJson(path.join(directory, 'artifact-registry.json')),
166
254
  readJson(path.join(directory, 'evaluation-stack-manifest.json')),
255
+ readJson(path.join(directory, HISTORICAL_COMPLETION_NAME)),
167
256
  ])
168
257
  const evaluationContext = summary?.evaluation_context ?? contextFile
169
258
  if (!evaluationContext && !summary && !lifecycle) return undefined
170
259
  const updatedAt = details.mtime.toISOString()
171
260
  const progress = progressView(summary, lifecycle, updatedAt)
261
+ const jobKind = normalizedJobKind(summary, evaluationContext)
172
262
  const capabilities = capabilityMap(summary, evaluationContext, lifecycle, registry, stack)
263
+ const evaluationTarget = summary?.evaluation_target ?? evaluationContext?.evaluation_target
264
+ const generationSource = summary?.generation_source ?? evaluationContext?.generation_source
265
+ const coverage = coverageView(summary)
173
266
  return {
174
267
  name: entry.name,
175
268
  updatedAt,
176
- status: jobStatus(summary, lifecycle, progress),
269
+ status: jobStatus(summary, lifecycle, progress, jobKind, completion, entry.name),
270
+ jobKind,
177
271
  mode: summary?.mode ?? evaluationContext?.mode,
272
+ executionMode: summary?.execution_mode ?? evaluationContext?.execution_mode,
178
273
  nTrials: progress.total,
179
274
  nDiscoveredTrials: Number(summary?.n_discovered_trials ?? lifecycle?.attempt_count ?? 0),
180
275
  nValidScores: summary?.n_valid_scores,
181
276
  nInvalidScores: summary?.n_invalid_scores,
277
+ nUnscoredTrials: Number(coverage.unscored_trials ?? 0),
182
278
  nExceptions: Number(summary?.n_exceptions ?? 0),
183
279
  primaryMetric: primaryMetric(summary, contract),
184
280
  metrics: summary?.metrics ?? {},
185
281
  candidate: summary?.candidate ?? evaluationContext?.candidate,
282
+ evaluationTarget,
283
+ generationSource,
284
+ generatorPopulation: evaluationTarget?.generator_population,
285
+ coverage,
286
+ evaluatorMetaEvaluation: evaluatorMetaEvaluation(summary, evaluationContext),
186
287
  dataset: evaluationContext?.dataset,
187
288
  evaluationContext,
188
289
  progress,
@@ -193,19 +294,96 @@ async function readJob(jobsDir, entry, details) {
193
294
  }
194
295
  }
195
296
 
196
- async function listJobs(jobsDir) {
297
+ async function listJobs(jobsDir, { offset = 0, limit = DEFAULT_JOB_PAGE_SIZE } = {}) {
197
298
  let entries
198
299
  try {
199
300
  entries = await readdir(jobsDir, { withFileTypes: true })
200
301
  } catch (error) {
201
- if (error.code === 'ENOENT') return []
302
+ if (error.code === 'ENOENT') return { items: [], total: 0, offset, limit, hasMore: false }
202
303
  throw error
203
304
  }
204
305
  const directories = entries.filter(entry => entry.isDirectory() && !entry.isSymbolicLink())
205
306
  const recent = await Promise.all(directories.map(async entry => ({ entry, details: await stat(path.join(jobsDir, entry.name)) })))
206
307
  recent.sort((left, right) => right.details.mtimeMs - left.details.mtimeMs)
207
- const jobs = await Promise.all(recent.map(({ entry, details }) => readJob(jobsDir, entry, details)))
208
- return jobs.filter(Boolean).slice(0, MAX_JOBS)
308
+ const page = recent.slice(offset, offset + limit)
309
+ const jobs = await Promise.all(page.map(({ entry, details }) => readJob(jobsDir, entry, details)))
310
+ return { items: jobs.filter(Boolean), total: recent.length, offset, limit, hasMore: offset + limit < recent.length }
311
+ }
312
+
313
+ function relativePath(root, value) {
314
+ return path.relative(root, value).split(path.sep).join('/') || '.'
315
+ }
316
+
317
+ function workspaceIdentity(projectRoot, workspaceRoot, jobsDir, preferred) {
318
+ const digest = createHash('sha256').update(`${projectRoot}\0${workspaceRoot}\0${jobsDir}`).digest('hex').slice(0, 12)
319
+ const label = String(preferred || path.basename(workspaceRoot) || 'root').replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '') || 'root'
320
+ return `${label}-${digest}`
321
+ }
322
+
323
+ async function regularFile(pathname) {
324
+ try {
325
+ const details = await lstat(pathname)
326
+ return details.isFile() && !details.isSymbolicLink()
327
+ } catch (error) {
328
+ if (error.code === 'ENOENT') return false
329
+ throw error
330
+ }
331
+ }
332
+
333
+ /** Discover root and namespaced Harbor workspaces without interpreting YAML. */
334
+ export async function discoverWorkspaceConfigs(config) {
335
+ const projectRoot = path.resolve(config.projectRoot)
336
+ const found = []
337
+ async function visit(directory, depth) {
338
+ const harborDirectory = path.join(directory, '.harbor')
339
+ const descriptorPath = path.join(harborDirectory, 'workspace.json')
340
+ const stackPath = path.join(harborDirectory, 'evaluation-stack.yml')
341
+ const descriptor = await readJson(descriptorPath)
342
+ if (descriptor?.schema_version === 1 && descriptor.jobs && descriptor.stack) {
343
+ const jobs = relativePath(projectRoot, resolveWithin(projectRoot, path.resolve(directory, descriptor.jobs), 'workspace.jobs'))
344
+ const stack = relativePath(projectRoot, resolveWithin(projectRoot, path.resolve(directory, descriptor.stack), 'workspace.stack'))
345
+ const workspaceRoot = relativePath(projectRoot, directory)
346
+ found.push({
347
+ ...config,
348
+ jobsDir: jobs,
349
+ stackPath: stack,
350
+ workspaceRoot,
351
+ workspaceLabel: descriptor.workspace_id ?? workspaceRoot,
352
+ workspaceId: workspaceIdentity(projectRoot, directory, jobs, descriptor.workspace_id),
353
+ })
354
+ } else if (await regularFile(stackPath)) {
355
+ const workspaceRoot = relativePath(projectRoot, directory)
356
+ const jobs = relativePath(projectRoot, path.join(directory, 'jobs'))
357
+ found.push({
358
+ ...config,
359
+ jobsDir: jobs,
360
+ stackPath: relativePath(projectRoot, stackPath),
361
+ workspaceRoot,
362
+ workspaceLabel: workspaceRoot,
363
+ workspaceId: workspaceIdentity(projectRoot, directory, jobs, workspaceRoot),
364
+ })
365
+ }
366
+ if (depth >= MAX_WORKSPACE_DEPTH) return
367
+ let entries
368
+ try { entries = await readdir(directory, { withFileTypes: true }) } catch (error) {
369
+ if (error.code === 'ENOENT' || error.code === 'EACCES') return
370
+ throw error
371
+ }
372
+ await Promise.all(entries
373
+ .filter(entry => entry.isDirectory() && !entry.isSymbolicLink() && !WORKSPACE_SKIP_DIRECTORIES.has(entry.name))
374
+ .map(entry => visit(path.join(directory, entry.name), depth + 1)))
375
+ }
376
+ await visit(projectRoot, 0)
377
+ if (!found.some(item => item.workspaceRoot === '.')) {
378
+ found.unshift({
379
+ ...config,
380
+ stackPath: '.harbor/evaluation-stack.yml',
381
+ workspaceRoot: '.',
382
+ workspaceLabel: path.basename(projectRoot) || 'root',
383
+ workspaceId: workspaceIdentity(projectRoot, projectRoot, config.jobsDir, path.basename(projectRoot)),
384
+ })
385
+ }
386
+ return found.sort((left, right) => left.workspaceRoot.localeCompare(right.workspaceRoot))
209
387
  }
210
388
 
211
389
  function jobsDirectory(config) {
@@ -216,32 +394,39 @@ function jobDirectory(config, job) {
216
394
  return path.join(jobsDirectory(config), safeSegment(job, 'job'))
217
395
  }
218
396
 
219
- export async function readDashboardSnapshot(config, metadata = {}) {
397
+ export async function readDashboardSnapshot(config, metadata = {}, args = {}) {
220
398
  const projectRoot = path.resolve(config.projectRoot)
221
399
  const jobsDir = jobsDirectory(config)
222
- const [jobs, projectRootCheck, jobsDirCheck, harborCheck, harborDshCheck, stackCheck] = await Promise.all([
223
- listJobs(jobsDir),
400
+ const offset = Math.max(0, Number.parseInt(args.offset ?? 0, 10) || 0)
401
+ 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
+ const [jobPage, projectRootCheck, jobsDirCheck, harborCheck, harborDshCheck, stackCheck] = await Promise.all([
403
+ listJobs(jobsDir, { offset, limit }),
224
404
  directoryCheck(projectRoot),
225
405
  directoryCheck(jobsDir, { optional: true }),
226
406
  executableCheck(config.harborBin),
227
407
  executableCheck(config.harborDshBin),
228
- fileCheck(path.join(projectRoot, '.harbor', 'evaluation-stack.yml')),
408
+ fileCheck(resolveWithin(projectRoot, config.stackPath ?? '.harbor/evaluation-stack.yml', 'stackPath')),
229
409
  ])
410
+ const jobs = jobPage.items
230
411
  const counts = jobs.reduce((result, job) => ({ ...result, [job.status]: (result[job.status] ?? 0) + 1 }), {})
231
412
  const latestMetric = jobs.find(job => job.primaryMetric)?.primaryMetric
232
413
  return {
233
414
  schemaVersion: 3,
234
415
  generatedAt: new Date().toISOString(),
235
416
  pluginVersion: metadata.pluginVersion ?? 'development',
236
- config: { jobsDir: config.jobsDir, dshVersion: config.dshVersion, agentImportPath: config.agentImportPath, pluginImportPath: config.pluginImportPath },
417
+ workspace: { id: config.workspaceId, label: config.workspaceLabel, root: config.workspaceRoot ?? '.', stackPath: config.stackPath },
418
+ workspaces: metadata.workspaces ?? [],
419
+ config: { projectRoot, projectRootSource: metadata.projectRootSource ?? 'configured', jobsDir: config.jobsDir, runtimePolicy: config.runtimePolicy ?? 'follow-latest', agentImportPath: config.agentImportPath, pluginImportPath: config.pluginImportPath },
237
420
  checks: { projectRoot: projectRootCheck, jobsDir: jobsDirCheck, harbor: harborCheck, harborDsh: harborDshCheck, evaluationStack: stackCheck },
238
421
  overview: {
239
- totalJobs: jobs.length,
422
+ totalJobs: jobPage.total,
423
+ visibleJobs: jobs.length,
240
424
  completedJobs: (counts.completed ?? 0) + (counts.partial ?? 0) + (counts.attention ?? 0),
241
425
  activeJobs: (counts.pending ?? 0) + (counts.running ?? 0),
242
426
  failedJobs: counts.failed ?? 0,
243
427
  latestMetric,
244
428
  },
429
+ jobPagination: { offset: jobPage.offset, limit: jobPage.limit, total: jobPage.total, hasMore: jobPage.hasMore },
245
430
  jobs,
246
431
  }
247
432
  }
@@ -252,6 +437,7 @@ const DETAIL_ARTIFACTS = {
252
437
  dataset: 'dataset-manifest.json',
253
438
  datasetPreview: 'dataset-preview.json',
254
439
  stack: 'evaluation-stack-manifest.json',
440
+ stackSources: 'evaluation-stack-sources.json',
255
441
  context: 'evaluation-context.json',
256
442
  contract: 'evaluation-contract.json',
257
443
  doctor: 'architecture-doctor.json',
@@ -261,6 +447,7 @@ const DETAIL_ARTIFACTS = {
261
447
  diagnosis: 'diagnosis-report.json',
262
448
  optimization: 'optimization-report.json',
263
449
  promotion: 'promotion-report.json',
450
+ completion: HISTORICAL_COMPLETION_NAME,
264
451
  }
265
452
 
266
453
  function schemaIssue(key, value) {
@@ -268,17 +455,21 @@ function schemaIssue(key, value) {
268
455
  if (value?.__readError) return value.__readError
269
456
  if (!isObject(value)) return 'artifact must be an object'
270
457
  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],
458
+ summary: [2, 3, 4], candidate: [1], dataset: [1], datasetPreview: [1], stack: [1], stackSources: [1], context: [1, 2], contract: [1],
459
+ doctor: [1], population: [1, 2, 3], lifecycle: [1], registry: [1, 2], diagnosis: [1, 2], optimization: [1, 2, 3], promotion: [2], completion: [1],
273
460
  }[key]
274
461
  if (versions && !versions.includes(value.schema_version)) return `schema_version must be one of ${versions.join(', ')}`
275
462
  const required = {
276
463
  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'],
464
+ stack: ['stack_id', 'version', 'digest', 'components', 'judge'], stackSources: ['stack_digest', 'components'], context: ['digest'], contract: ['contract_id', 'version', 'primary_metric', 'metrics'],
278
465
  doctor: ['promotion_ready', 'findings'], population: ['population_size', 'groups', 'metrics'], lifecycle: ['dataset_total', 'trials'],
279
466
  registry: ['artifacts'], diagnosis: ['diagnoses'], optimization: ['hypotheses'], promotion: ['decision', 'reasons', 'policy_digest'],
467
+ completion: ['job_kind', 'status', 'valid', 'job', 'summary_path', 'artifact_registry_path', 'coverage'],
280
468
  }[key] ?? []
281
- const missing = required.filter(field => value[field] === undefined)
469
+ const requiredFields = key === 'population' && value.schema_version === 3
470
+ ? ['population_size', 'coverage', 'metrics']
471
+ : required
472
+ const missing = requiredFields.filter(field => value[field] === undefined)
282
473
  return missing.length ? `missing fields: ${missing.join(', ')}` : undefined
283
474
  }
284
475
 
@@ -298,10 +489,35 @@ export async function readJobDetail(config, args) {
298
489
  return [key, value === undefined ? { status: 'unavailable', reason: 'capability-not-produced' } : issue ? { status: 'invalid', error: issue } : { status: 'valid' }]
299
490
  }))
300
491
  const context = artifacts.context ?? values[Object.keys(DETAIL_ARTIFACTS).indexOf('context')]
492
+ const summary = values[Object.keys(DETAIL_ARTIFACTS).indexOf('summary')]
493
+ const jobKind = normalizedJobKind(summary, context)
494
+ if (
495
+ jobKind === HISTORICAL_JOB_KIND
496
+ && !historicalCompletionValid(summary, artifacts.completion, job)
497
+ ) {
498
+ validation.completion = {
499
+ status: 'invalid',
500
+ error: 'Historical completion sentinel is missing, stale, or inconsistent with the Summary',
501
+ }
502
+ }
301
503
  const capabilities = capabilityMap(
302
- values[Object.keys(DETAIL_ARTIFACTS).indexOf('summary')], context, artifacts.lifecycle, artifacts.registry, artifacts.stack,
504
+ summary, context, artifacts.lifecycle, artifacts.registry, artifacts.stack,
303
505
  )
304
- return { schemaVersion: 2, job, capabilities, artifacts, validation }
506
+ const evaluationTarget = summary?.evaluation_target ?? context?.evaluation_target
507
+ return {
508
+ schemaVersion: 3,
509
+ job,
510
+ jobKind,
511
+ evaluationTarget,
512
+ generationSource: summary?.generation_source ?? context?.generation_source,
513
+ generatorPopulation: evaluationTarget?.generator_population,
514
+ executionMode: summary?.execution_mode ?? context?.execution_mode,
515
+ coverage: coverageView(summary),
516
+ evaluatorMetaEvaluation: evaluatorMetaEvaluation(summary, context),
517
+ capabilities,
518
+ artifacts,
519
+ validation,
520
+ }
305
521
  }
306
522
 
307
523
  function selectedLifecycleTrials(lifecycle) {
@@ -316,13 +532,15 @@ function selectedLifecycleTrials(lifecycle) {
316
532
  function normalizeTrial(trial, order) {
317
533
  const score = trial.score ?? { value: undefined, valid: trial.exception ? false : true, invalid_reasons: trial.exception ? ['infrastructure-error'] : [] }
318
534
  const datasetOrder = Number(trial.datasetOrder ?? trial.dataset_order ?? order)
535
+ const status = trial.status ?? trial.phase ?? (trial.exception ? 'infrastructure-error' : 'completed')
319
536
  return {
320
537
  id: trial.id ?? trial.execution_id ?? `dataset-${datasetOrder}`,
321
538
  name: trial.name ?? trial.trial_name ?? trial.dataset_trial ?? trial.trial,
322
539
  datasetTrial: trial.datasetTrial ?? trial.dataset_trial ?? trial.trial,
323
540
  datasetOrder,
324
541
  attempt: Number(trial.attempt ?? 1),
325
- status: trial.status ?? trial.phase ?? (trial.exception ? 'infrastructure-error' : 'completed'),
542
+ status,
543
+ scoringStatus: status === 'completed-unscored' ? 'unscored' : score.valid ? 'scored' : 'invalid',
326
544
  terminal: trial.terminal ?? true,
327
545
  updatedAt: trial.updatedAt ?? trial.updated_at,
328
546
  score,
@@ -574,11 +792,22 @@ export async function readJobProgress(config, args) {
574
792
 
575
793
  export async function readMetaEvaluation(config, args = {}) {
576
794
  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 })
795
+ const index = await readJson(path.join(evaluationRoot, '.harbor', 'meta-artifacts.json'))
796
+ const registered = index?.schema_version === 1 ? index.artifacts ?? {} : {}
797
+ const groundTruthPath = resolveWithin(evaluationRoot, registered.ground_truth ?? '.harbor/ground-truth.json', 'groundTruthPath')
798
+ 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 })
579
801
  const availableGroundTruth = groundTruth && !groundTruth.__readError
580
802
  const availableReport = report && !report.__readError
581
803
  const cases = availableGroundTruth && Array.isArray(groundTruth.cases) ? groundTruth.cases : []
804
+ const disagreementOffset = Math.max(0, Number.parseInt(args.offset ?? 0, 10) || 0)
805
+ const disagreementLimit = Math.min(100, Math.max(1, Number.parseInt(args.limit ?? 20, 10) || 20))
806
+ const disagreements = availableReport && Array.isArray(report.disagreements) ? report.disagreements : []
807
+ const pagedReport = availableReport ? {
808
+ ...report,
809
+ disagreements: disagreements.slice(disagreementOffset, disagreementOffset + disagreementLimit),
810
+ } : undefined
582
811
  return {
583
812
  schemaVersion: 1,
584
813
  evaluationRoot: path.relative(config.projectRoot, evaluationRoot) || '.',
@@ -590,9 +819,16 @@ export async function readMetaEvaluation(config, args = {}) {
590
819
  criteria: groundTruth.criteria ?? [],
591
820
  caseCount: cases.length,
592
821
  badcaseCount: cases.filter(item => item?.badcase).length,
593
- path: path.relative(config.projectRoot, path.join(evaluationRoot, '.harbor', 'ground-truth.json')),
822
+ path: path.relative(config.projectRoot, groundTruthPath),
594
823
  } : undefined,
595
- report: availableReport ? report : undefined,
824
+ report: pagedReport,
825
+ artifactIndex: index?.schema_version === 1 ? path.relative(config.projectRoot, path.join(evaluationRoot, '.harbor', 'meta-artifacts.json')) : undefined,
826
+ disagreementPagination: {
827
+ offset: disagreementOffset,
828
+ limit: disagreementLimit,
829
+ total: disagreements.length,
830
+ hasMore: disagreementOffset + disagreementLimit < disagreements.length,
831
+ },
596
832
  workflow: {
597
833
  candidate: 'Evaluator / Rubric / Judge identity',
598
834
  dataset: 'Fixed artifacts plus independent Ground Truth',
@@ -627,6 +863,32 @@ export async function readComparison(config, args) {
627
863
  if (!baseline || baseline.__readError || !candidate || candidate.__readError) throw new Error('Both Job summaries are required')
628
864
  const baselineContext = baseline.evaluation_context ?? await readJson(path.join(jobDirectory(config, baselineJob), 'evaluation-context.json'))
629
865
  const candidateContext = candidate.evaluation_context ?? await readJson(path.join(jobDirectory(config, candidateJob), 'evaluation-context.json'))
866
+ const baselineKind = normalizedJobKind(baseline, baselineContext)
867
+ const candidateKind = normalizedJobKind(candidate, candidateContext)
868
+ if (baselineKind !== CANDIDATE_JOB_KIND || candidateKind !== CANDIDATE_JOB_KIND) {
869
+ const error = {
870
+ code: 'UNSUPPORTED_JOB_KIND_FOR_PROMOTION',
871
+ message: 'Historical Generation Evaluation Jobs are diagnostic evidence and cannot be used as a Candidate baseline, comparison, or Promotion Gate input.',
872
+ }
873
+ return {
874
+ schemaVersion: 1,
875
+ baselineJob,
876
+ candidateJob,
877
+ baselineJobKind: baselineKind,
878
+ candidateJobKind: candidateKind,
879
+ comparable: false,
880
+ comparabilityReasons: [error],
881
+ metrics: {},
882
+ population: {},
883
+ improvedTrials: [],
884
+ regressedTrials: [],
885
+ newExceptions: [],
886
+ artifactRegressions: [],
887
+ gateEligibility: 'not-applicable',
888
+ error,
889
+ note: 'Convert reviewed badcases into a fixed regression Dataset before running Candidate comparison or Gate.',
890
+ }
891
+ }
630
892
  const reasons = []
631
893
  if (baselineContext?.schema_version !== 2 || candidateContext?.schema_version !== 2) reasons.push('Context v2 is required')
632
894
  if (!baselineContext?.digest || baselineContext.digest !== candidateContext?.digest) reasons.push('Evaluation Context differs; establish a fresh baseline')
@@ -668,16 +930,27 @@ export async function readComparison(config, args) {
668
930
  export async function readEvaluatorGovernance(config, args) {
669
931
  const job = safeSegment(args.job, 'job')
670
932
  const directory = jobDirectory(config, job)
671
- const [stack, contract, context] = await Promise.all([
933
+ const [stack, sources, contract, context] = await Promise.all([
672
934
  readJson(path.join(directory, 'evaluation-stack-manifest.json')),
935
+ readJson(path.join(directory, 'evaluation-stack-sources.json'), { maxText: MAX_SOURCE_BYTES }),
673
936
  readJson(path.join(directory, 'evaluation-contract.json')),
674
937
  readJson(path.join(directory, 'evaluation-context.json')),
675
938
  ])
676
939
  if (!stack || stack.__readError) throw new Error('Evaluation Stack is unavailable')
940
+ const historicalSources = sources?.schema_version === 1 && sources.stack_digest === stack.digest
941
+ ? sources
942
+ : undefined
677
943
  const components = {}
678
944
  for (const [role, component] of Object.entries(stack.components ?? {})) {
679
945
  const entry = component?.entry
680
- const source = entry ? await readSafeText(resolveWithin(config.projectRoot, entry, `${role}.entry`), config.projectRoot) : { error: 'entry unavailable' }
946
+ const snapshot = historicalSources?.components?.[role]
947
+ const snapshotFile = snapshot?.files?.find(item => item.path === entry && item.text)
948
+ ?? snapshot?.files?.find(item => item.text)
949
+ const source = snapshotFile
950
+ ? { ...snapshotFile, source: 'job-snapshot', readOnly: true }
951
+ : entry
952
+ ? { ...(await readSafeText(resolveWithin(config.projectRoot, entry, `${role}.entry`), config.projectRoot)), source: 'historical-live-fallback', readOnly: true }
953
+ : { error: 'entry unavailable', source: 'unavailable', readOnly: true }
681
954
  components[role] = { ...component, source }
682
955
  }
683
956
  let comparison
@@ -697,7 +970,7 @@ export async function readEvaluatorGovernance(config, args) {
697
970
  }
698
971
  }
699
972
  return {
700
- schemaVersion: 1, job, stackIdentity: { id: stack.stack_id, version: stack.version, digest: stack.digest },
973
+ schemaVersion: 1, job, stackIdentity: { id: stack.stack_id, version: stack.version, digest: stack.digest, comparisonDigest: stack.comparison_digest },
701
974
  judge: stack.judge, contract, contextDigest: context?.digest, components, comparison,
702
975
  editingPolicy: {
703
976
  browserWriteEnabled: false,