dsh-harbor-evolution 0.7.2 → 0.7.3

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,12 @@
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 DEFAULT_JOB_PAGE_SIZE = 20
9
+ const MAX_JOB_PAGE_SIZE = 100
8
10
  const MAX_JSON_BYTES = 2 * 1024 * 1024
9
11
  const MAX_SOURCE_BYTES = 128 * 1024
10
12
  const MAX_PREVIEW_BYTES = 512 * 1024
@@ -12,6 +14,11 @@ const MAX_TRIAL_LIMIT = 100
12
14
  const jsonCache = new Map()
13
15
  const SENSITIVE_KEY = /authorization|cookie|token|api[_-]?key|secret|password|request[_-]?headers/i
14
16
  const SENSITIVE_SOURCE_VALUE = /(authorization|cookie|token|api[_-]?key|secret|password)\s*[:=]\s*([^\s,;]+)/gi
17
+ const WORKSPACE_SKIP_DIRECTORIES = new Set([
18
+ '.cache', '.git', '.harbor', '.next', '.venv', '__pycache__',
19
+ 'build', 'candidates', 'coverage', 'datasets', 'dist', 'jobs', 'node_modules', 'public', 'vendor', 'venv',
20
+ ])
21
+ const MAX_WORKSPACE_DEPTH = 5
15
22
 
16
23
  function safeSegment(value, label) {
17
24
  if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,199}$/.test(String(value ?? ''))) throw new Error(`${label} is invalid`)
@@ -193,19 +200,96 @@ async function readJob(jobsDir, entry, details) {
193
200
  }
194
201
  }
195
202
 
196
- async function listJobs(jobsDir) {
203
+ async function listJobs(jobsDir, { offset = 0, limit = DEFAULT_JOB_PAGE_SIZE } = {}) {
197
204
  let entries
198
205
  try {
199
206
  entries = await readdir(jobsDir, { withFileTypes: true })
200
207
  } catch (error) {
201
- if (error.code === 'ENOENT') return []
208
+ if (error.code === 'ENOENT') return { items: [], total: 0, offset, limit, hasMore: false }
202
209
  throw error
203
210
  }
204
211
  const directories = entries.filter(entry => entry.isDirectory() && !entry.isSymbolicLink())
205
212
  const recent = await Promise.all(directories.map(async entry => ({ entry, details: await stat(path.join(jobsDir, entry.name)) })))
206
213
  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)
214
+ const page = recent.slice(offset, offset + limit)
215
+ const jobs = await Promise.all(page.map(({ entry, details }) => readJob(jobsDir, entry, details)))
216
+ return { items: jobs.filter(Boolean), total: recent.length, offset, limit, hasMore: offset + limit < recent.length }
217
+ }
218
+
219
+ function relativePath(root, value) {
220
+ return path.relative(root, value).split(path.sep).join('/') || '.'
221
+ }
222
+
223
+ function workspaceIdentity(projectRoot, workspaceRoot, jobsDir, preferred) {
224
+ const digest = createHash('sha256').update(`${projectRoot}\0${workspaceRoot}\0${jobsDir}`).digest('hex').slice(0, 12)
225
+ const label = String(preferred || path.basename(workspaceRoot) || 'root').replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '') || 'root'
226
+ return `${label}-${digest}`
227
+ }
228
+
229
+ async function regularFile(pathname) {
230
+ try {
231
+ const details = await lstat(pathname)
232
+ return details.isFile() && !details.isSymbolicLink()
233
+ } catch (error) {
234
+ if (error.code === 'ENOENT') return false
235
+ throw error
236
+ }
237
+ }
238
+
239
+ /** Discover root and namespaced Harbor workspaces without interpreting YAML. */
240
+ export async function discoverWorkspaceConfigs(config) {
241
+ const projectRoot = path.resolve(config.projectRoot)
242
+ const found = []
243
+ async function visit(directory, depth) {
244
+ const harborDirectory = path.join(directory, '.harbor')
245
+ const descriptorPath = path.join(harborDirectory, 'workspace.json')
246
+ const stackPath = path.join(harborDirectory, 'evaluation-stack.yml')
247
+ const descriptor = await readJson(descriptorPath)
248
+ if (descriptor?.schema_version === 1 && descriptor.jobs && descriptor.stack) {
249
+ const jobs = relativePath(projectRoot, resolveWithin(projectRoot, path.resolve(directory, descriptor.jobs), 'workspace.jobs'))
250
+ const stack = relativePath(projectRoot, resolveWithin(projectRoot, path.resolve(directory, descriptor.stack), 'workspace.stack'))
251
+ const workspaceRoot = relativePath(projectRoot, directory)
252
+ found.push({
253
+ ...config,
254
+ jobsDir: jobs,
255
+ stackPath: stack,
256
+ workspaceRoot,
257
+ workspaceLabel: descriptor.workspace_id ?? workspaceRoot,
258
+ workspaceId: workspaceIdentity(projectRoot, directory, jobs, descriptor.workspace_id),
259
+ })
260
+ } else if (await regularFile(stackPath)) {
261
+ const workspaceRoot = relativePath(projectRoot, directory)
262
+ const jobs = relativePath(projectRoot, path.join(directory, 'jobs'))
263
+ found.push({
264
+ ...config,
265
+ jobsDir: jobs,
266
+ stackPath: relativePath(projectRoot, stackPath),
267
+ workspaceRoot,
268
+ workspaceLabel: workspaceRoot,
269
+ workspaceId: workspaceIdentity(projectRoot, directory, jobs, workspaceRoot),
270
+ })
271
+ }
272
+ if (depth >= MAX_WORKSPACE_DEPTH) return
273
+ let entries
274
+ try { entries = await readdir(directory, { withFileTypes: true }) } catch (error) {
275
+ if (error.code === 'ENOENT' || error.code === 'EACCES') return
276
+ throw error
277
+ }
278
+ await Promise.all(entries
279
+ .filter(entry => entry.isDirectory() && !entry.isSymbolicLink() && !WORKSPACE_SKIP_DIRECTORIES.has(entry.name))
280
+ .map(entry => visit(path.join(directory, entry.name), depth + 1)))
281
+ }
282
+ await visit(projectRoot, 0)
283
+ if (!found.some(item => item.workspaceRoot === '.')) {
284
+ found.unshift({
285
+ ...config,
286
+ stackPath: '.harbor/evaluation-stack.yml',
287
+ workspaceRoot: '.',
288
+ workspaceLabel: path.basename(projectRoot) || 'root',
289
+ workspaceId: workspaceIdentity(projectRoot, projectRoot, config.jobsDir, path.basename(projectRoot)),
290
+ })
291
+ }
292
+ return found.sort((left, right) => left.workspaceRoot.localeCompare(right.workspaceRoot))
209
293
  }
210
294
 
211
295
  function jobsDirectory(config) {
@@ -216,32 +300,39 @@ function jobDirectory(config, job) {
216
300
  return path.join(jobsDirectory(config), safeSegment(job, 'job'))
217
301
  }
218
302
 
219
- export async function readDashboardSnapshot(config, metadata = {}) {
303
+ export async function readDashboardSnapshot(config, metadata = {}, args = {}) {
220
304
  const projectRoot = path.resolve(config.projectRoot)
221
305
  const jobsDir = jobsDirectory(config)
222
- const [jobs, projectRootCheck, jobsDirCheck, harborCheck, harborDshCheck, stackCheck] = await Promise.all([
223
- listJobs(jobsDir),
306
+ const offset = Math.max(0, Number.parseInt(args.offset ?? 0, 10) || 0)
307
+ const limit = Math.min(MAX_JOB_PAGE_SIZE, Math.max(1, Number.parseInt(args.limit ?? DEFAULT_JOB_PAGE_SIZE, 10) || DEFAULT_JOB_PAGE_SIZE))
308
+ const [jobPage, projectRootCheck, jobsDirCheck, harborCheck, harborDshCheck, stackCheck] = await Promise.all([
309
+ listJobs(jobsDir, { offset, limit }),
224
310
  directoryCheck(projectRoot),
225
311
  directoryCheck(jobsDir, { optional: true }),
226
312
  executableCheck(config.harborBin),
227
313
  executableCheck(config.harborDshBin),
228
- fileCheck(path.join(projectRoot, '.harbor', 'evaluation-stack.yml')),
314
+ fileCheck(resolveWithin(projectRoot, config.stackPath ?? '.harbor/evaluation-stack.yml', 'stackPath')),
229
315
  ])
316
+ const jobs = jobPage.items
230
317
  const counts = jobs.reduce((result, job) => ({ ...result, [job.status]: (result[job.status] ?? 0) + 1 }), {})
231
318
  const latestMetric = jobs.find(job => job.primaryMetric)?.primaryMetric
232
319
  return {
233
320
  schemaVersion: 3,
234
321
  generatedAt: new Date().toISOString(),
235
322
  pluginVersion: metadata.pluginVersion ?? 'development',
236
- config: { jobsDir: config.jobsDir, dshVersion: config.dshVersion, agentImportPath: config.agentImportPath, pluginImportPath: config.pluginImportPath },
323
+ workspace: { id: config.workspaceId, label: config.workspaceLabel, root: config.workspaceRoot ?? '.', stackPath: config.stackPath },
324
+ workspaces: metadata.workspaces ?? [],
325
+ config: { projectRoot, projectRootSource: metadata.projectRootSource ?? 'configured', jobsDir: config.jobsDir, runtimePolicy: config.runtimePolicy ?? 'follow-latest', agentImportPath: config.agentImportPath, pluginImportPath: config.pluginImportPath },
237
326
  checks: { projectRoot: projectRootCheck, jobsDir: jobsDirCheck, harbor: harborCheck, harborDsh: harborDshCheck, evaluationStack: stackCheck },
238
327
  overview: {
239
- totalJobs: jobs.length,
328
+ totalJobs: jobPage.total,
329
+ visibleJobs: jobs.length,
240
330
  completedJobs: (counts.completed ?? 0) + (counts.partial ?? 0) + (counts.attention ?? 0),
241
331
  activeJobs: (counts.pending ?? 0) + (counts.running ?? 0),
242
332
  failedJobs: counts.failed ?? 0,
243
333
  latestMetric,
244
334
  },
335
+ jobPagination: { offset: jobPage.offset, limit: jobPage.limit, total: jobPage.total, hasMore: jobPage.hasMore },
245
336
  jobs,
246
337
  }
247
338
  }
@@ -252,6 +343,7 @@ const DETAIL_ARTIFACTS = {
252
343
  dataset: 'dataset-manifest.json',
253
344
  datasetPreview: 'dataset-preview.json',
254
345
  stack: 'evaluation-stack-manifest.json',
346
+ stackSources: 'evaluation-stack-sources.json',
255
347
  context: 'evaluation-context.json',
256
348
  contract: 'evaluation-contract.json',
257
349
  doctor: 'architecture-doctor.json',
@@ -268,13 +360,13 @@ function schemaIssue(key, value) {
268
360
  if (value?.__readError) return value.__readError
269
361
  if (!isObject(value)) return 'artifact must be an object'
270
362
  const versions = {
271
- summary: [2, 3], candidate: [1], dataset: [1], datasetPreview: [1], stack: [1], context: [1, 2], contract: [1],
363
+ summary: [2, 3], candidate: [1], dataset: [1], datasetPreview: [1], stack: [1], stackSources: [1], context: [1, 2], contract: [1],
272
364
  doctor: [1], population: [1, 2], lifecycle: [1], registry: [1], diagnosis: [1], optimization: [1, 2], promotion: [2],
273
365
  }[key]
274
366
  if (versions && !versions.includes(value.schema_version)) return `schema_version must be one of ${versions.join(', ')}`
275
367
  const required = {
276
368
  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'],
369
+ stack: ['stack_id', 'version', 'digest', 'components', 'judge'], stackSources: ['stack_digest', 'components'], context: ['digest'], contract: ['contract_id', 'version', 'primary_metric', 'metrics'],
278
370
  doctor: ['promotion_ready', 'findings'], population: ['population_size', 'groups', 'metrics'], lifecycle: ['dataset_total', 'trials'],
279
371
  registry: ['artifacts'], diagnosis: ['diagnoses'], optimization: ['hypotheses'], promotion: ['decision', 'reasons', 'policy_digest'],
280
372
  }[key] ?? []
@@ -574,11 +666,22 @@ export async function readJobProgress(config, args) {
574
666
 
575
667
  export async function readMetaEvaluation(config, args = {}) {
576
668
  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 })
669
+ const index = await readJson(path.join(evaluationRoot, '.harbor', 'meta-artifacts.json'))
670
+ const registered = index?.schema_version === 1 ? index.artifacts ?? {} : {}
671
+ const groundTruthPath = resolveWithin(evaluationRoot, registered.ground_truth ?? '.harbor/ground-truth.json', 'groundTruthPath')
672
+ const reportPath = resolveWithin(evaluationRoot, registered.meta_evaluation_report ?? '.harbor/meta-evaluation-report.json', 'metaEvaluationReportPath')
673
+ const groundTruth = await readJson(groundTruthPath, { maxText: 64_000 })
674
+ const report = await readJson(reportPath, { maxText: 64_000 })
579
675
  const availableGroundTruth = groundTruth && !groundTruth.__readError
580
676
  const availableReport = report && !report.__readError
581
677
  const cases = availableGroundTruth && Array.isArray(groundTruth.cases) ? groundTruth.cases : []
678
+ const disagreementOffset = Math.max(0, Number.parseInt(args.offset ?? 0, 10) || 0)
679
+ const disagreementLimit = Math.min(100, Math.max(1, Number.parseInt(args.limit ?? 20, 10) || 20))
680
+ const disagreements = availableReport && Array.isArray(report.disagreements) ? report.disagreements : []
681
+ const pagedReport = availableReport ? {
682
+ ...report,
683
+ disagreements: disagreements.slice(disagreementOffset, disagreementOffset + disagreementLimit),
684
+ } : undefined
582
685
  return {
583
686
  schemaVersion: 1,
584
687
  evaluationRoot: path.relative(config.projectRoot, evaluationRoot) || '.',
@@ -590,9 +693,16 @@ export async function readMetaEvaluation(config, args = {}) {
590
693
  criteria: groundTruth.criteria ?? [],
591
694
  caseCount: cases.length,
592
695
  badcaseCount: cases.filter(item => item?.badcase).length,
593
- path: path.relative(config.projectRoot, path.join(evaluationRoot, '.harbor', 'ground-truth.json')),
696
+ path: path.relative(config.projectRoot, groundTruthPath),
594
697
  } : undefined,
595
- report: availableReport ? report : undefined,
698
+ report: pagedReport,
699
+ artifactIndex: index?.schema_version === 1 ? path.relative(config.projectRoot, path.join(evaluationRoot, '.harbor', 'meta-artifacts.json')) : undefined,
700
+ disagreementPagination: {
701
+ offset: disagreementOffset,
702
+ limit: disagreementLimit,
703
+ total: disagreements.length,
704
+ hasMore: disagreementOffset + disagreementLimit < disagreements.length,
705
+ },
596
706
  workflow: {
597
707
  candidate: 'Evaluator / Rubric / Judge identity',
598
708
  dataset: 'Fixed artifacts plus independent Ground Truth',
@@ -668,16 +778,27 @@ export async function readComparison(config, args) {
668
778
  export async function readEvaluatorGovernance(config, args) {
669
779
  const job = safeSegment(args.job, 'job')
670
780
  const directory = jobDirectory(config, job)
671
- const [stack, contract, context] = await Promise.all([
781
+ const [stack, sources, contract, context] = await Promise.all([
672
782
  readJson(path.join(directory, 'evaluation-stack-manifest.json')),
783
+ readJson(path.join(directory, 'evaluation-stack-sources.json'), { maxText: MAX_SOURCE_BYTES }),
673
784
  readJson(path.join(directory, 'evaluation-contract.json')),
674
785
  readJson(path.join(directory, 'evaluation-context.json')),
675
786
  ])
676
787
  if (!stack || stack.__readError) throw new Error('Evaluation Stack is unavailable')
788
+ const historicalSources = sources?.schema_version === 1 && sources.stack_digest === stack.digest
789
+ ? sources
790
+ : undefined
677
791
  const components = {}
678
792
  for (const [role, component] of Object.entries(stack.components ?? {})) {
679
793
  const entry = component?.entry
680
- const source = entry ? await readSafeText(resolveWithin(config.projectRoot, entry, `${role}.entry`), config.projectRoot) : { error: 'entry unavailable' }
794
+ const snapshot = historicalSources?.components?.[role]
795
+ const snapshotFile = snapshot?.files?.find(item => item.path === entry && item.text)
796
+ ?? snapshot?.files?.find(item => item.text)
797
+ const source = snapshotFile
798
+ ? { ...snapshotFile, source: 'job-snapshot', readOnly: true }
799
+ : entry
800
+ ? { ...(await readSafeText(resolveWithin(config.projectRoot, entry, `${role}.entry`), config.projectRoot)), source: 'historical-live-fallback', readOnly: true }
801
+ : { error: 'entry unavailable', source: 'unavailable', readOnly: true }
681
802
  components[role] = { ...component, source }
682
803
  }
683
804
  let comparison
@@ -697,7 +818,7 @@ export async function readEvaluatorGovernance(config, args) {
697
818
  }
698
819
  }
699
820
  return {
700
- schemaVersion: 1, job, stackIdentity: { id: stack.stack_id, version: stack.version, digest: stack.digest },
821
+ schemaVersion: 1, job, stackIdentity: { id: stack.stack_id, version: stack.version, digest: stack.digest, comparisonDigest: stack.comparison_digest },
701
822
  judge: stack.judge, contract, contextDigest: context?.digest, components, comparison,
702
823
  editingPolicy: {
703
824
  browserWriteEnabled: false,
package/lib/evolution.js CHANGED
@@ -1,4 +1,4 @@
1
- import { readFile } from 'node:fs/promises'
1
+ import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
2
2
  import path from 'node:path'
3
3
 
4
4
  import { MANIFEST_NAME, snapshotCandidate } from './candidate.js'
@@ -8,17 +8,50 @@ export function resolveWithin(root, value, label) {
8
8
  const base = path.resolve(root)
9
9
  const resolved = path.resolve(base, value)
10
10
  if (resolved !== base && !resolved.startsWith(`${base}${path.sep}`)) {
11
- throw new Error(`${label} must stay under projectRoot`)
11
+ throw new Error(
12
+ `PATH_OUTSIDE_PROJECT_ROOT: ${label} must stay under projectRoot.\n` +
13
+ `projectRoot: ${base}\n` +
14
+ `${label}: ${value}\n` +
15
+ 'Recommended fix: use a path inside the current Agent session directory, or open the intended project as the session working directory. The Web Workbench projectRoot can be switched and reloaded in Harbor settings.',
16
+ )
12
17
  }
13
18
  return resolved
14
19
  }
15
20
 
21
+ const META_ARTIFACT_INDEX = '.harbor/meta-artifacts.json'
22
+
23
+ function inferEvaluationRoot(projectRoot, artifactPath, explicitRoot) {
24
+ if (explicitRoot) return resolveWithin(projectRoot, explicitRoot, 'evaluationRoot')
25
+ const relative = path.relative(projectRoot, artifactPath)
26
+ const parts = relative.split(path.sep)
27
+ const marker = parts.lastIndexOf('.harbor')
28
+ return marker >= 0 ? path.resolve(projectRoot, ...parts.slice(0, marker)) : path.resolve(projectRoot)
29
+ }
30
+
31
+ async function recordMetaArtifact(config, artifactPath, key, explicitRoot) {
32
+ const evaluationRoot = inferEvaluationRoot(config.projectRoot, artifactPath, explicitRoot)
33
+ const registeredArtifact = resolveWithin(evaluationRoot, artifactPath, key)
34
+ const indexPath = resolveWithin(evaluationRoot, META_ARTIFACT_INDEX, 'metaArtifactIndex')
35
+ let current = { schema_version: 1, artifacts: {} }
36
+ try {
37
+ const parsed = JSON.parse(await readFile(indexPath, 'utf8'))
38
+ if (parsed?.schema_version === 1 && parsed.artifacts && typeof parsed.artifacts === 'object') current = parsed
39
+ } catch (error) {
40
+ if (error.code !== 'ENOENT' && !(error instanceof SyntaxError)) throw error
41
+ }
42
+ current.artifacts[key] = path.relative(evaluationRoot, registeredArtifact).split(path.sep).join('/')
43
+ await mkdir(path.dirname(indexPath), { recursive: true })
44
+ const temporary = `${indexPath}.${process.pid}.tmp`
45
+ await writeFile(temporary, `${JSON.stringify(current, null, 2)}\n`, 'utf8')
46
+ await rename(temporary, indexPath)
47
+ return path.relative(config.projectRoot, indexPath).split(path.sep).join('/')
48
+ }
49
+
16
50
  export async function snapshot(config, args) {
17
51
  const candidateDir = resolveWithin(config.projectRoot, args.candidatePath, 'candidatePath')
18
52
  return snapshotCandidate(candidateDir, {
19
53
  candidateId: args.candidateId,
20
54
  version: args.version,
21
- runtimeVersion: config.dshVersion,
22
55
  })
23
56
  }
24
57
 
@@ -80,7 +113,7 @@ export async function updateEvaluator(config, args) {
80
113
 
81
114
  export async function initializeGroundTruth(config, args) {
82
115
  const output = resolveWithin(config.projectRoot, args.outputPath ?? '.harbor/ground-truth.json', 'outputPath')
83
- return cliJson(config, [
116
+ const result = await cliJson(config, [
84
117
  'ground-truth', 'init',
85
118
  '--project-root', config.projectRoot,
86
119
  '--output', output,
@@ -91,19 +124,25 @@ export async function initializeGroundTruth(config, args) {
91
124
  '--provenance', String(args.provenance ?? ''),
92
125
  '--criteria', String(args.criteria ?? ''),
93
126
  ])
127
+ result.artifact_index = await recordMetaArtifact(config, output, 'ground_truth', args.evaluationRoot)
128
+ return result
94
129
  }
95
130
 
96
131
  export async function runMetaEvaluation(config, args) {
97
132
  const groundTruth = resolveWithin(config.projectRoot, args.groundTruthPath ?? '.harbor/ground-truth.json', 'groundTruthPath')
98
133
  const observations = resolveWithin(config.projectRoot, args.observationsPath, 'observationsPath')
99
134
  const output = resolveWithin(config.projectRoot, args.outputPath ?? '.harbor/meta-evaluation-report.json', 'outputPath')
100
- return cliJson(config, [
135
+ const result = await cliJson(config, [
101
136
  'meta-evaluate',
102
137
  '--project-root', config.projectRoot,
103
138
  '--ground-truth', groundTruth,
104
139
  '--observations', observations,
105
140
  '--output', output,
106
141
  ])
142
+ const evaluationRoot = args.evaluationRoot
143
+ ?? inferEvaluationRoot(config.projectRoot, groundTruth)
144
+ result.artifact_index = await recordMetaArtifact(config, output, 'meta_evaluation_report', evaluationRoot)
145
+ return result
107
146
  }
108
147
 
109
148
  function strictInputs(config, args) {
@@ -132,6 +171,63 @@ function candidateModelCliArgs(binding) {
132
171
  ]
133
172
  }
134
173
 
174
+ export function redactDiagnostic(value) {
175
+ return String(value ?? '')
176
+ .replace(/(authorization\s*[:=]\s*(?:bearer\s+)?)[^\s,'"}]+/gi, '$1[redacted]')
177
+ .replace(/(bearer\s+)[A-Za-z0-9._~+\/-]+/gi, '$1[redacted]')
178
+ .replace(/((?:api[_-]?key|token|secret|password)\s*[:=]\s*)[^\s,'"}]+/gi, '$1[redacted]')
179
+ }
180
+
181
+ export function classifyHarborFailure(value) {
182
+ const text = String(value ?? '')
183
+ const suggestions = []
184
+ if (/AgentSetupTimeoutError|agent setup.{0,30}time(?:d out|out)/i.test(text)) {
185
+ suggestions.push({ code: 'AGENT_SETUP_TIMEOUT', action: 'Use a base image with Python, curl, Node.js, npm, and ACP/DSH dependencies already installed; then rerun Doctor.' })
186
+ }
187
+ if (/evaluation-result\.json is missing/i.test(text)) {
188
+ suggestions.push({ code: 'EVALUATOR_RESULT_MISSING', action: 'Update tests/test.sh or its evaluator script to write /logs/verifier/evaluation-result.json using evaluation-result/v1.' })
189
+ }
190
+ if (/Either datasets or tasks must be provided|HARBOR_RUNTIME_NO_TASKS/i.test(text)) {
191
+ suggestions.push({ code: 'DATASET_NOT_RESOLVED', action: 'Make the Dataset root contain immediate Task subdirectories with schema_version = "1.4", [task] name = "org/name", instruction.md, environment/, and tests/test.sh.' })
192
+ }
193
+ if (/docker-credential-|credential helper/i.test(text)) {
194
+ suggestions.push({ code: 'DOCKER_CREDENTIAL_HELPER', action: 'Repair the configured Docker credential helper or use a verified image already present in the local Docker daemon.' })
195
+ }
196
+ if (/Cannot connect to the Docker daemon|DOCKER_DAEMON_UNAVAILABLE/i.test(text)) {
197
+ suggestions.push({ code: 'DOCKER_DAEMON_UNAVAILABLE', action: 'Start Docker and confirm `docker version` can reach the server.' })
198
+ }
199
+ if (!suggestions.length) {
200
+ suggestions.push({ code: 'INSPECT_JOB_LOG', action: 'Review the stderr/job.log excerpt below, fix the first causal error, rerun Doctor, then retry the Job.' })
201
+ }
202
+ return suggestions
203
+ }
204
+
205
+ async function optionalTail(pathname, maxChars = 6000) {
206
+ try {
207
+ const value = await readFile(pathname, 'utf8')
208
+ return value.slice(-maxChars)
209
+ } catch {
210
+ return ''
211
+ }
212
+ }
213
+
214
+ export async function explainHarborFailure(error, jobDir) {
215
+ const stderr = error?.result?.stderr ?? ''
216
+ const stdout = error?.result?.stdout ?? ''
217
+ const logCandidates = ['job.log', 'harbor.log']
218
+ const logParts = (await Promise.all(logCandidates.map(name => optionalTail(path.join(jobDir, name)))))
219
+ .filter(Boolean)
220
+ const detail = redactDiagnostic([stderr.slice(-8000), ...logParts, stdout.slice(-2000)].filter(Boolean).join('\n'))
221
+ const suggestions = classifyHarborFailure(detail || error?.message)
222
+ const lines = [
223
+ `HARBOR_JOB_FAILED: Harbor exited with code ${error?.result?.code ?? 'unknown'}.`,
224
+ `jobPath: ${jobDir}`,
225
+ ...suggestions.map(item => `nextStep[${item.code}]: ${item.action}`),
226
+ ]
227
+ if (detail.trim()) lines.push('diagnosticTail:', detail.trim())
228
+ return new Error(lines.join('\n'))
229
+ }
230
+
135
231
  export async function validateDataset(config, args) {
136
232
  const dataset = resolveWithin(config.projectRoot, args.datasetPath, 'datasetPath')
137
233
  return cliJson(config, ['dataset', 'validate', dataset, '--project-root', config.projectRoot], { allowedExitCodes: [0, 2] })
@@ -148,12 +244,23 @@ export async function initializeProject(config, args) {
148
244
  '--judge-provider', args.judgeProvider, '--judge-model', args.judgeModel, '--judge-version', args.judgeVersion,
149
245
  '--policy-id', args.policyId, '--policy-version', args.policyVersion,
150
246
  '--min-improvement', String(args.minImprovement),
247
+ '--workspace-subdir', String(args.workspaceSubdir ?? '.'),
248
+ ])
249
+ }
250
+
251
+ export async function initializeQuickDiagnostic(config, args) {
252
+ return cliJson(config, [
253
+ 'quick', 'diagnostic',
254
+ '--project-root', config.projectRoot,
255
+ '--query', String(args.query ?? ''),
256
+ '--rubric', String(args.rubric ?? ''),
257
+ '--workspace-subdir', String(args.workspaceSubdir ?? 'harbor-diagnostic'),
151
258
  ])
152
259
  }
153
260
 
154
261
  export async function runDoctor(config, args) {
155
262
  const inputs = strictInputs(config, { ...args, mode: args.mode ?? 'diagnostic' })
156
- const command = ['doctor', '--architecture', '--project-root', inputs.projectRoot, '--stack', inputs.stack, '--dataset', inputs.dataset]
263
+ const command = ['doctor', '--architecture', '--runtime', '--project-root', inputs.projectRoot, '--stack', inputs.stack, '--dataset', inputs.dataset]
157
264
  if (args.candidatePath) command.push('--candidate', inputs.candidate)
158
265
  if (inputs.policy) command.push('--policy', inputs.policy)
159
266
  return cliJson(config, command, { allowedExitCodes: [0, 2] })
@@ -180,9 +287,16 @@ export async function runEvaluation(config, args, modelRuntime) {
180
287
  const inputs = strictInputs(config, args)
181
288
  const datasetValidation = await validateDataset(config, args)
182
289
  if (!datasetValidation.valid) {
183
- throw new Error(`Dataset validation failed: ${datasetValidation.findings.map(item => item.code).join(', ')}`)
290
+ throw new Error(
291
+ `Dataset validation failed under projectRoot ${inputs.projectRoot}:\n` +
292
+ datasetValidation.findings.map(item => `${item.code}: ${item.message}`).join('\n'),
293
+ )
184
294
  }
185
295
  const doctor = await runDoctor(config, args)
296
+ const runtimeBlockers = doctor.findings.filter(item => item.level === 'error' && item.code.startsWith('DOCKER_'))
297
+ if (runtimeBlockers.length) {
298
+ throw new Error(`Runtime Doctor blocked Harbor Job:\n${runtimeBlockers.map(item => `${item.code}: ${item.message}`).join('\n')}`)
299
+ }
186
300
  if (inputs.mode === 'promotion-eligible' && !doctor.promotion_ready) {
187
301
  throw new Error(`Architecture Doctor blocked promotion-eligible Job: ${doctor.findings.filter(item => item.level === 'error').map(item => item.code).join(', ')}`)
188
302
  }
@@ -226,20 +340,25 @@ export async function runEvaluation(config, args, modelRuntime) {
226
340
  jobName,
227
341
  })
228
342
  try {
229
- const processResult = await runProcess(config.harborBin, harborArgs, {
230
- cwd: config.projectRoot,
231
- timeoutMs: config.timeoutMs,
232
- env: {
233
- ...process.env,
234
- ...(config.pythonPath ? { PYTHONPATH: config.pythonPath } : {}),
235
- HSE_MODEL_GATEWAY_URL: lease.endpoint,
236
- HSE_MODEL_GATEWAY_TOKEN: lease.token,
237
- HSE_MODEL_GATEWAY_PROVIDER: lease.candidateProvider,
238
- HSE_MODEL_GATEWAY_INFO: JSON.stringify(lease.modelInfo),
239
- HSE_MODEL_GATEWAY_PROTOCOL: lease.protocol,
240
- },
241
- })
242
343
  const jobDir = path.join(inputs.jobs, jobName)
344
+ let processResult
345
+ try {
346
+ processResult = await runProcess(config.harborBin, harborArgs, {
347
+ cwd: config.projectRoot,
348
+ timeoutMs: config.timeoutMs,
349
+ env: {
350
+ ...process.env,
351
+ ...(config.pythonPath ? { PYTHONPATH: config.pythonPath } : {}),
352
+ HSE_MODEL_GATEWAY_URL: lease.endpoint,
353
+ HSE_MODEL_GATEWAY_TOKEN: lease.token,
354
+ HSE_MODEL_GATEWAY_PROVIDER: lease.candidateProvider,
355
+ HSE_MODEL_GATEWAY_INFO: JSON.stringify(lease.modelInfo),
356
+ HSE_MODEL_GATEWAY_PROTOCOL: lease.protocol,
357
+ },
358
+ })
359
+ } catch (error) {
360
+ throw await explainHarborFailure(error, jobDir)
361
+ }
243
362
  const summary = JSON.parse(await readFile(path.join(jobDir, 'evaluation-summary.json'), 'utf8'))
244
363
  return {
245
364
  manifest,
@@ -74,7 +74,25 @@ export class CandidateModelRuntime {
74
74
  this.config = config
75
75
  }
76
76
 
77
- async resolve(args = {}) {
77
+ async currentBinding() {
78
+ const inherited = this.ctx.agentDefaultModel.currentSelection()
79
+ const binding = await this.resolve({
80
+ candidateProvider: inherited.provider,
81
+ candidateModel: inherited.model,
82
+ candidateReasoningEffort: inherited.reasoningEffort,
83
+ })
84
+ return {
85
+ schema_version: 1,
86
+ source: 'skill-agent-default',
87
+ provider: binding.provider,
88
+ model: binding.model,
89
+ ...(binding.reasoning_effort === undefined
90
+ ? {}
91
+ : { reasoning_effort: binding.reasoning_effort }),
92
+ }
93
+ }
94
+
95
+ async resolve(args = {}, pinnedBinding) {
78
96
  const explicitProvider = nonBlank(args.candidateProvider)
79
97
  const explicitModel = nonBlank(args.candidateModel)
80
98
  if (Boolean(explicitProvider) !== Boolean(explicitModel)) {
@@ -86,14 +104,37 @@ export class CandidateModelRuntime {
86
104
  throw new Error('Harbor candidateProvider and candidateModel configuration must be supplied together')
87
105
  }
88
106
 
107
+ const pinnedProvider = nonBlank(pinnedBinding?.provider)
108
+ const pinnedModel = nonBlank(pinnedBinding?.model)
109
+ if (Boolean(pinnedProvider) !== Boolean(pinnedModel)) {
110
+ throw new Error('Candidate model-binding.json requires provider and model')
111
+ }
112
+ const pinnedReasoning = nonBlank(pinnedBinding?.reasoning_effort)
113
+ if (pinnedProvider && explicitProvider && (
114
+ explicitProvider !== pinnedProvider
115
+ || explicitModel !== pinnedModel
116
+ || (nonBlank(args.candidateReasoningEffort) ?? undefined) !== pinnedReasoning
117
+ )) {
118
+ throw new Error('CANDIDATE_MODEL_BINDING_CONFLICT: explicit Job model arguments do not match model-binding.json; create a new Candidate for a different model identity')
119
+ }
120
+ if (pinnedProvider && configuredProvider && (
121
+ configuredProvider !== pinnedProvider
122
+ || configuredModel !== pinnedModel
123
+ || (nonBlank(this.config.candidateReasoningEffort) ?? undefined) !== pinnedReasoning
124
+ )) {
125
+ throw new Error('CANDIDATE_MODEL_BINDING_CONFLICT: Plugin model configuration does not match model-binding.json; create a new Candidate or remove the global override')
126
+ }
127
+
89
128
  const inherited = this.ctx.agentDefaultModel.currentSelection()
90
- const provider = explicitProvider ?? configuredProvider ?? inherited.provider
91
- const model = explicitModel ?? configuredModel ?? inherited.model
129
+ const provider = pinnedProvider ?? explicitProvider ?? configuredProvider ?? inherited.provider
130
+ const model = pinnedModel ?? explicitModel ?? configuredModel ?? inherited.model
92
131
  const explicitReasoning = nonBlank(args.candidateReasoningEffort)
93
132
  const configuredReasoning = nonBlank(this.config.candidateReasoningEffort)
94
133
  const canInheritReasoning = provider === inherited.provider && model === inherited.model
95
- const reasoningEffort = explicitReasoning ?? configuredReasoning
96
- ?? (canInheritReasoning ? inherited.reasoningEffort : undefined)
134
+ const reasoningEffort = pinnedProvider
135
+ ? pinnedReasoning
136
+ : explicitReasoning ?? configuredReasoning
137
+ ?? (canInheritReasoning ? inherited.reasoningEffort : undefined)
97
138
 
98
139
  if (!this.ctx.llm.listProviders().some(item => item.id === provider)) {
99
140
  throw new Error(`Candidate model provider "${provider}" is not registered in DeepSeek Harness`)
@@ -0,0 +1,7 @@
1
+ import { readFileSync } from 'node:fs'
2
+
3
+ const packageJson = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'))
4
+
5
+ export const DSH_RUNTIME_VERSION = packageJson.harborEvolution.dshRuntimeVersion
6
+ export const CANDIDATE_ACP_PACKAGE = packageJson.harborEvolution.candidateAcpPackage
7
+ export const RUNTIME_POLICY = packageJson.harborEvolution.runtimePolicy