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/README.md +9 -4
- package/index.js +32 -6
- package/lib/candidate.js +58 -5
- package/lib/client.js +195 -54
- package/lib/dashboard.js +141 -20
- package/lib/evolution.js +139 -20
- package/lib/model-runtime.js +46 -5
- package/lib/runtime-identity.js +7 -0
- package/lib/service.js +187 -33
- package/lib/setup.js +2 -2
- package/lib/version.js +128 -0
- package/lib/web.js +5 -1
- package/package.json +8 -3
- package/skills/evolve-agent-with-harbor/SKILL.md +81 -7
- package/skills/evolve-agent-with-harbor/evals/evals.json +48 -3
- package/skills/evolve-agent-with-harbor/references/evaluator-upgrade.md +31 -0
- package/skills/evolve-agent-with-harbor/references/initialization.md +9 -2
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
|
|
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
|
|
208
|
-
|
|
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
|
|
223
|
-
|
|
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(
|
|
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
|
-
|
|
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:
|
|
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
|
|
578
|
-
const
|
|
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,
|
|
696
|
+
path: path.relative(config.projectRoot, groundTruthPath),
|
|
594
697
|
} : undefined,
|
|
595
|
-
report:
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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,
|
package/lib/model-runtime.js
CHANGED
|
@@ -74,7 +74,25 @@ export class CandidateModelRuntime {
|
|
|
74
74
|
this.config = config
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
-
async
|
|
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 =
|
|
96
|
-
|
|
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
|