dsh-harbor-evolution 0.8.2 → 0.9.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +28 -6
- package/index.js +120 -19
- package/lib/action-drafts.js +443 -0
- package/lib/bounded-process.js +190 -0
- package/lib/candidate-runtime.js +160 -0
- package/lib/candidate.js +7 -11
- package/lib/client.js +4110 -292
- package/lib/composer-context.js +56 -0
- package/lib/credential-redaction.js +155 -0
- package/lib/dashboard.js +417 -98
- package/lib/diagnostic-observation.js +175 -0
- package/lib/diagnostic-runner.js +206 -0
- package/lib/evaluator-saves.js +129 -0
- package/lib/evolution.js +126 -32
- package/lib/historical-run-lock.js +102 -0
- package/lib/historical-web.js +52 -16
- package/lib/interaction-objects.js +56 -0
- package/lib/model-runtime.js +48 -3
- package/lib/process.js +27 -1
- package/lib/runtime-identity.js +0 -1
- package/lib/service.js +1427 -28
- package/lib/session-diagnostic.js +0 -1
- package/lib/session-redaction.js +17 -31
- package/lib/session-selection.js +5 -3
- package/lib/trial-selection.js +46 -0
- package/lib/ui-context.js +518 -0
- package/lib/web.js +32 -6
- package/lib/workbench-health.js +27 -0
- package/package.json +4 -4
- package/skills/evolve-agent-with-harbor/SKILL.md +33 -4
package/lib/evolution.js
CHANGED
|
@@ -2,6 +2,8 @@ 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'
|
|
5
|
+
import { loadCandidateRuntime } from './candidate-runtime.js'
|
|
6
|
+
import { redactCredentialText, redactLocalPaths, redactOpaqueSecretText } from './credential-redaction.js'
|
|
5
7
|
import { runProcess } from './process.js'
|
|
6
8
|
|
|
7
9
|
export function resolveWithin(root, value, label) {
|
|
@@ -145,7 +147,8 @@ async function cliJson(config, args, { allowedExitCodes = [0], input } = {}) {
|
|
|
145
147
|
})
|
|
146
148
|
} catch (error) {
|
|
147
149
|
const detail = error?.result?.stderr?.trim().split('\n').at(-1)?.replace(/^[A-Za-z]+Error:\s*/, '')
|
|
148
|
-
|
|
150
|
+
const sanitized = redactDiagnostic(detail || error.message).slice(0, 512)
|
|
151
|
+
throw new Error(sanitized || 'HARBOR_DSH_COMMAND_FAILED: harbor-dsh did not return a safe error detail')
|
|
149
152
|
}
|
|
150
153
|
try {
|
|
151
154
|
return JSON.parse(result.stdout)
|
|
@@ -154,6 +157,15 @@ async function cliJson(config, args, { allowedExitCodes = [0], input } = {}) {
|
|
|
154
157
|
}
|
|
155
158
|
}
|
|
156
159
|
|
|
160
|
+
export function historicalDockerBlockers(value) {
|
|
161
|
+
const findings = Array.isArray(value?.findings) ? value.findings : []
|
|
162
|
+
return findings.filter(item => (
|
|
163
|
+
item?.level === 'error'
|
|
164
|
+
&& typeof item.code === 'string'
|
|
165
|
+
&& item.code.startsWith('DOCKER_')
|
|
166
|
+
))
|
|
167
|
+
}
|
|
168
|
+
|
|
157
169
|
export async function inspectEvaluator(config, args = {}) {
|
|
158
170
|
const stack = resolveWithin(config.projectRoot, args.stackPath ?? '.harbor/evaluation-stack.yml', 'stackPath')
|
|
159
171
|
return cliJson(config, [
|
|
@@ -239,10 +251,91 @@ function candidateModelCliArgs(binding) {
|
|
|
239
251
|
}
|
|
240
252
|
|
|
241
253
|
export function redactDiagnostic(value) {
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
254
|
+
const credentials = redactCredentialText(String(value ?? ''), '[redacted]')
|
|
255
|
+
const opaque = redactOpaqueSecretText(credentials, kind => `[redacted ${kind}]`)
|
|
256
|
+
return redactLocalPaths(opaque)
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const RUN_RECEIPT_TEXT_LIMIT = 256
|
|
260
|
+
const RUN_SUMMARY_COUNT_FIELDS = [
|
|
261
|
+
'n_trials',
|
|
262
|
+
'n_valid_scores',
|
|
263
|
+
'n_invalid_scores',
|
|
264
|
+
'n_exceptions',
|
|
265
|
+
'n_unscored_trials',
|
|
266
|
+
'n_discovered_trials',
|
|
267
|
+
]
|
|
268
|
+
|
|
269
|
+
function boundedRunReceiptText(value, fallback = 'unknown') {
|
|
270
|
+
const sanitized = redactDiagnostic(value).replace(/[\r\n\t]+/g, ' ').trim()
|
|
271
|
+
return (sanitized || fallback).slice(0, RUN_RECEIPT_TEXT_LIMIT)
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function safeNonNegativeInteger(value) {
|
|
275
|
+
return Number.isSafeInteger(value) && value >= 0 ? value : undefined
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function safeFiniteNumber(value) {
|
|
279
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : undefined
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function narrowRunSummary(summary, { historical = false } = {}) {
|
|
283
|
+
const receipt = {
|
|
284
|
+
artifact_ref: 'evaluation-summary.json',
|
|
285
|
+
artifact_validation: { valid: summary?.artifact_validation?.valid === true },
|
|
286
|
+
}
|
|
287
|
+
const schemaVersion = safeNonNegativeInteger(summary?.schema_version)
|
|
288
|
+
if (schemaVersion !== undefined) receipt.schema_version = schemaVersion
|
|
289
|
+
for (const key of RUN_SUMMARY_COUNT_FIELDS) {
|
|
290
|
+
const value = safeNonNegativeInteger(summary?.[key])
|
|
291
|
+
if (value !== undefined) receipt[key] = value
|
|
292
|
+
}
|
|
293
|
+
if (historical) {
|
|
294
|
+
const coverage = {}
|
|
295
|
+
for (const key of HISTORICAL_COVERAGE_KEYS) {
|
|
296
|
+
const value = safeFiniteNumber(summary?.coverage?.[key])
|
|
297
|
+
if (value !== undefined) coverage[key] = value
|
|
298
|
+
}
|
|
299
|
+
receipt.coverage = coverage
|
|
300
|
+
}
|
|
301
|
+
return receipt
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* A mutating tool receipt is intentionally not an artifact read path. Return
|
|
306
|
+
* only bounded scalar status plus a reference that can be opened through
|
|
307
|
+
* harbor_eval_result, where untrusted artifact content receives its dedicated
|
|
308
|
+
* allowlist/redaction policy.
|
|
309
|
+
*/
|
|
310
|
+
export function buildEvaluationRunReceipt({ jobName, mode, summary, processCode }) {
|
|
311
|
+
return {
|
|
312
|
+
schema_version: 1,
|
|
313
|
+
jobKind: 'candidate-evaluation',
|
|
314
|
+
mode: mode === 'promotion-eligible' ? 'promotion-eligible' : 'diagnostic',
|
|
315
|
+
status: 'completed',
|
|
316
|
+
job: boundedRunReceiptText(jobName),
|
|
317
|
+
summary: narrowRunSummary(summary),
|
|
318
|
+
process: { code: safeNonNegativeInteger(processCode) ?? null },
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
export function buildHistoricalRunReceipt({ jobName, summary, processCode }) {
|
|
323
|
+
return {
|
|
324
|
+
schema_version: 1,
|
|
325
|
+
jobKind: 'historical-generation-evaluation',
|
|
326
|
+
executionMode: 'observe-existing',
|
|
327
|
+
promotionEligible: false,
|
|
328
|
+
status: 'completed',
|
|
329
|
+
job: boundedRunReceiptText(jobName),
|
|
330
|
+
summary: narrowRunSummary(summary, { historical: true }),
|
|
331
|
+
completion: {
|
|
332
|
+
schema_version: 1,
|
|
333
|
+
status: 'completed',
|
|
334
|
+
valid: true,
|
|
335
|
+
artifact_ref: 'historical-evaluation-complete.json',
|
|
336
|
+
},
|
|
337
|
+
process: { code: safeNonNegativeInteger(processCode) ?? null },
|
|
338
|
+
}
|
|
246
339
|
}
|
|
247
340
|
|
|
248
341
|
export function classifyHarborFailure(value) {
|
|
@@ -288,7 +381,7 @@ export async function explainHarborFailure(error, jobDir) {
|
|
|
288
381
|
const suggestions = classifyHarborFailure(detail || error?.message)
|
|
289
382
|
const lines = [
|
|
290
383
|
`HARBOR_JOB_FAILED: Harbor exited with code ${error?.result?.code ?? 'unknown'}.`,
|
|
291
|
-
|
|
384
|
+
'jobPath: [local path]',
|
|
292
385
|
...suggestions.map(item => `nextStep[${item.code}]: ${item.action}`),
|
|
293
386
|
]
|
|
294
387
|
if (detail.trim()) lines.push('diagnosticTail:', detail.trim())
|
|
@@ -350,8 +443,11 @@ export async function previewContext(config, args) {
|
|
|
350
443
|
}
|
|
351
444
|
|
|
352
445
|
export async function runEvaluation(config, args, modelRuntime) {
|
|
353
|
-
const manifest = await snapshot(config, args)
|
|
354
446
|
const inputs = strictInputs(config, args)
|
|
447
|
+
// Do not rely on a possibly older Python Doctor to enforce a contract that
|
|
448
|
+
// its agent may not understand. Legacy snapshots remain readable, not runnable.
|
|
449
|
+
await loadCandidateRuntime(inputs.candidate, { required: true })
|
|
450
|
+
const manifest = await snapshot(config, args)
|
|
355
451
|
const datasetValidation = await validateDataset(config, args)
|
|
356
452
|
if (!datasetValidation.valid) {
|
|
357
453
|
throw new Error(
|
|
@@ -360,10 +456,13 @@ export async function runEvaluation(config, args, modelRuntime) {
|
|
|
360
456
|
)
|
|
361
457
|
}
|
|
362
458
|
const doctor = await runDoctor(config, args)
|
|
363
|
-
const runtimeBlockers = doctor.findings.filter(item => item.level === 'error' && item.code.startsWith('DOCKER_'))
|
|
459
|
+
const runtimeBlockers = doctor.findings.filter(item => item.level === 'error' && (item.code.startsWith('DOCKER_') || item.code.startsWith('CANDIDATE_RUNTIME_')))
|
|
364
460
|
if (runtimeBlockers.length) {
|
|
365
461
|
throw new Error(`Runtime Doctor blocked Harbor Job:\n${runtimeBlockers.map(item => `${item.code}: ${item.message}`).join('\n')}`)
|
|
366
462
|
}
|
|
463
|
+
if (!doctor.findings.some(item => item.level === 'info' && item.code === 'CANDIDATE_RUNTIME_VERIFIED')) {
|
|
464
|
+
throw new Error('CANDIDATE_RUNTIME_ADAPTER_UNSUPPORTED: update the Python Adapter; it has not verified the Candidate-owned local ACP runtime. No model lease or Harbor Job was started.')
|
|
465
|
+
}
|
|
367
466
|
if (inputs.mode === 'promotion-eligible' && !doctor.promotion_ready) {
|
|
368
467
|
throw new Error(`Architecture Doctor blocked promotion-eligible Job: ${doctor.findings.filter(item => item.level === 'error').map(item => item.code).join(', ')}`)
|
|
369
468
|
}
|
|
@@ -427,15 +526,12 @@ export async function runEvaluation(config, args, modelRuntime) {
|
|
|
427
526
|
throw await explainHarborFailure(error, jobDir)
|
|
428
527
|
}
|
|
429
528
|
const summary = JSON.parse(await readFile(path.join(jobDir, 'evaluation-summary.json'), 'utf8'))
|
|
430
|
-
return {
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
job: path.relative(inputs.projectRoot, jobDir),
|
|
529
|
+
return buildEvaluationRunReceipt({
|
|
530
|
+
jobName,
|
|
531
|
+
mode: inputs.mode,
|
|
434
532
|
summary,
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
process: { code: processResult.code },
|
|
438
|
-
}
|
|
533
|
+
processCode: processResult.code,
|
|
534
|
+
})
|
|
439
535
|
} finally {
|
|
440
536
|
await lease.close()
|
|
441
537
|
}
|
|
@@ -467,6 +563,16 @@ export async function runHistoricalEvaluation(config, args, modelRuntime) {
|
|
|
467
563
|
])
|
|
468
564
|
const dataset = resolveWithin(projectRoot, materialized.dataset_path ?? output, 'historicalDataset')
|
|
469
565
|
const stack = resolveWithin(projectRoot, materialized.stack_path, 'historicalStack')
|
|
566
|
+
// Fail fast on Docker runtime blockers (e.g. an unresolvable credential
|
|
567
|
+
// helper) before Harbor creates a Job whose Trials would all fail during
|
|
568
|
+
// environment setup and only report missing downstream artifacts.
|
|
569
|
+
const dockerCheck = await cliJson(config, ['docker-check'], { allowedExitCodes: [0, 2] })
|
|
570
|
+
const dockerBlockers = historicalDockerBlockers(dockerCheck)
|
|
571
|
+
if (dockerBlockers.length) {
|
|
572
|
+
throw new Error(
|
|
573
|
+
`HISTORICAL_DOCKER_PREFLIGHT_FAILED: Docker runtime preflight blocked the Historical Job:\n${dockerBlockers.map(item => `${item.code}: ${item.message}`).join('\n')}`,
|
|
574
|
+
)
|
|
575
|
+
}
|
|
470
576
|
const batch = JSON.parse(await readFile(batchPath, 'utf8'))
|
|
471
577
|
const jobs = resolveWithin(projectRoot, config.jobsDir, 'jobsDir')
|
|
472
578
|
const jobName = args.jobName ?? makeHistoricalJobName(batch)
|
|
@@ -541,23 +647,11 @@ export async function runHistoricalEvaluation(config, args, modelRuntime) {
|
|
|
541
647
|
batchId: batch.batch_id,
|
|
542
648
|
recordCount: Array.isArray(batch.records) ? batch.records.length : undefined,
|
|
543
649
|
})
|
|
544
|
-
return {
|
|
545
|
-
|
|
546
|
-
provider: args.judgeBinding.provider,
|
|
547
|
-
model: args.judgeBinding.model,
|
|
548
|
-
...(args.judgeBinding.reasoning_effort === undefined
|
|
549
|
-
? {}
|
|
550
|
-
: { reasoning_effort: args.judgeBinding.reasoning_effort }),
|
|
551
|
-
},
|
|
552
|
-
job: path.relative(projectRoot, jobDir).split(path.sep).join('/'),
|
|
650
|
+
return buildHistoricalRunReceipt({
|
|
651
|
+
jobName,
|
|
553
652
|
summary,
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
dataset: path.relative(projectRoot, dataset).split(path.sep).join('/'),
|
|
557
|
-
stack: path.relative(projectRoot, stack).split(path.sep).join('/'),
|
|
558
|
-
},
|
|
559
|
-
process: { code: processResult.code },
|
|
560
|
-
}
|
|
653
|
+
processCode: processResult.code,
|
|
654
|
+
})
|
|
561
655
|
} finally {
|
|
562
656
|
await lease.close()
|
|
563
657
|
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { realpathSync } from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
|
|
4
|
+
const LOCKED_MESSAGE = 'HISTORICAL_JOB_ALREADY_RUNNING: wait for the current Historical Session Job to finish'
|
|
5
|
+
|
|
6
|
+
function lockError(code, message) {
|
|
7
|
+
const error = new Error(`${code}: ${message}`)
|
|
8
|
+
error.code = code
|
|
9
|
+
return error
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function realpathIfPresent(value) {
|
|
13
|
+
try {
|
|
14
|
+
return realpathSync.native(value)
|
|
15
|
+
} catch (error) {
|
|
16
|
+
if (error?.code === 'ENOENT') return undefined
|
|
17
|
+
throw lockError('HISTORICAL_LOCK_SCOPE_INVALID', 'the Historical workspace path could not be resolved safely')
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function realpathFromDeepestExistingAncestor(value) {
|
|
22
|
+
let current = path.resolve(value)
|
|
23
|
+
const missingSegments = []
|
|
24
|
+
while (true) {
|
|
25
|
+
const physical = realpathIfPresent(current)
|
|
26
|
+
if (physical) return path.resolve(physical, ...missingSegments)
|
|
27
|
+
const parent = path.dirname(current)
|
|
28
|
+
if (parent === current) {
|
|
29
|
+
throw lockError('HISTORICAL_LOCK_SCOPE_INVALID', 'the Historical workspace path has no resolvable ancestor')
|
|
30
|
+
}
|
|
31
|
+
missingSegments.unshift(path.basename(current))
|
|
32
|
+
current = parent
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Resolve the write target used by Historical evaluation. The absolute Jobs
|
|
38
|
+
* directory, rather than a UI workspace label, is the collision boundary:
|
|
39
|
+
* nested project roots that point at the same directory must share a lock.
|
|
40
|
+
*/
|
|
41
|
+
export function historicalRunScope(config = {}) {
|
|
42
|
+
const projectRoot = String(config.projectRoot ?? '')
|
|
43
|
+
const jobsDir = String(config.jobsDir ?? 'jobs')
|
|
44
|
+
if (!projectRoot || !path.isAbsolute(projectRoot)) {
|
|
45
|
+
throw lockError('HISTORICAL_LOCK_SCOPE_INVALID', 'projectRoot must be an absolute path')
|
|
46
|
+
}
|
|
47
|
+
if (!jobsDir) {
|
|
48
|
+
throw lockError('HISTORICAL_LOCK_SCOPE_INVALID', 'jobsDir must be a non-empty path inside projectRoot')
|
|
49
|
+
}
|
|
50
|
+
const root = path.resolve(projectRoot)
|
|
51
|
+
const target = path.resolve(root, jobsDir)
|
|
52
|
+
const relative = path.relative(root, target)
|
|
53
|
+
if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
|
|
54
|
+
throw lockError('HISTORICAL_LOCK_SCOPE_INVALID', 'jobsDir must stay inside projectRoot')
|
|
55
|
+
}
|
|
56
|
+
return realpathFromDeepestExistingAncestor(target)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Process-local, fail-closed lease registry for Historical Job writers. */
|
|
60
|
+
export class HistoricalRunLock {
|
|
61
|
+
constructor() {
|
|
62
|
+
this.active = new Map()
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
acquire(config, owner = {}) {
|
|
66
|
+
const scope = historicalRunScope(config)
|
|
67
|
+
if (this.active.has(scope)) {
|
|
68
|
+
const error = new Error(LOCKED_MESSAGE)
|
|
69
|
+
error.code = 'HISTORICAL_JOB_ALREADY_RUNNING'
|
|
70
|
+
throw error
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const token = Symbol('historical-run-lease')
|
|
74
|
+
this.active.set(scope, { token, owner: { ...owner } })
|
|
75
|
+
let released = false
|
|
76
|
+
return Object.freeze({
|
|
77
|
+
scope,
|
|
78
|
+
release: () => {
|
|
79
|
+
if (released) return false
|
|
80
|
+
released = true
|
|
81
|
+
const current = this.active.get(scope)
|
|
82
|
+
if (current?.token === token) this.active.delete(scope)
|
|
83
|
+
return true
|
|
84
|
+
},
|
|
85
|
+
})
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async runExclusive(config, task, owner = {}) {
|
|
89
|
+
if (typeof task !== 'function') {
|
|
90
|
+
throw lockError('HISTORICAL_LOCK_TASK_INVALID', 'the protected operation must be a function')
|
|
91
|
+
}
|
|
92
|
+
const lease = this.acquire(config, owner)
|
|
93
|
+
try {
|
|
94
|
+
return await task()
|
|
95
|
+
} finally {
|
|
96
|
+
lease.release()
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Shared across every Harbor plugin/controller instance in this Node process. */
|
|
102
|
+
export const historicalRunLock = new HistoricalRunLock()
|
package/lib/historical-web.js
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto'
|
|
2
2
|
|
|
3
|
+
import { redactDiagnostic } from './evolution.js'
|
|
4
|
+
import { historicalRunLock } from './historical-run-lock.js'
|
|
5
|
+
|
|
3
6
|
const OPERATION_RETENTION_MS = 60 * 60 * 1000
|
|
4
7
|
|
|
5
8
|
function timestamp(now) {
|
|
@@ -9,9 +12,7 @@ function timestamp(now) {
|
|
|
9
12
|
|
|
10
13
|
function publicError(error) {
|
|
11
14
|
const raw = error instanceof Error ? error.message : String(error)
|
|
12
|
-
const message = raw
|
|
13
|
-
.replace(/(?:\/[A-Za-z0-9._ -]+){2,}/g, '[local path]')
|
|
14
|
-
.replace(/[A-Za-z]:\\[^\s]+/g, '[local path]')
|
|
15
|
+
const message = redactDiagnostic(raw).slice(0, 512)
|
|
15
16
|
const match = message.match(/^([A-Z][A-Z0-9_]+):\s*(.*)$/s)
|
|
16
17
|
return {
|
|
17
18
|
code: match?.[1] ?? 'HISTORICAL_JOB_FAILED',
|
|
@@ -44,6 +45,7 @@ export class HistoricalWebController {
|
|
|
44
45
|
randomId = () => randomUUID(),
|
|
45
46
|
schedule = callback => queueMicrotask(callback),
|
|
46
47
|
operationRetentionMs = OPERATION_RETENTION_MS,
|
|
48
|
+
runLock = historicalRunLock,
|
|
47
49
|
}) {
|
|
48
50
|
this.service = service
|
|
49
51
|
this.sessionDiagnostic = sessionDiagnostic
|
|
@@ -51,6 +53,7 @@ export class HistoricalWebController {
|
|
|
51
53
|
this.randomId = randomId
|
|
52
54
|
this.schedule = schedule
|
|
53
55
|
this.operationRetentionMs = operationRetentionMs
|
|
56
|
+
this.runLock = runLock
|
|
54
57
|
this.previews = new Map()
|
|
55
58
|
this.operations = new Map()
|
|
56
59
|
this.consumedPreviews = new Map()
|
|
@@ -71,19 +74,27 @@ export class HistoricalWebController {
|
|
|
71
74
|
}
|
|
72
75
|
}
|
|
73
76
|
|
|
74
|
-
|
|
77
|
+
_activeWorkspaceOperation(workspace) {
|
|
75
78
|
return [...this.operations.values()]
|
|
76
79
|
.filter(item => item.workspace === workspace && ['queued', 'running'].includes(item.status))
|
|
77
80
|
.sort((left, right) => right.createdAt.localeCompare(left.createdAt))[0]
|
|
78
81
|
}
|
|
79
82
|
|
|
83
|
+
_activeOwnedOperation(workspace, ownerSessionId) {
|
|
84
|
+
return [...this.operations.values()]
|
|
85
|
+
.filter(item => item.workspace === workspace && item.ownerSessionId === ownerSessionId && ['queued', 'running'].includes(item.status))
|
|
86
|
+
.sort((left, right) => right.createdAt.localeCompare(left.createdAt))[0]
|
|
87
|
+
}
|
|
88
|
+
|
|
80
89
|
async preview(args = {}) {
|
|
81
90
|
this._cleanup()
|
|
82
|
-
const
|
|
91
|
+
const ownerSessionId = String(args.sessionId ?? '').trim()
|
|
92
|
+
if (!ownerSessionId) throw new Error('HISTORICAL_SESSION_REQUIRED: a live DSH Session is required')
|
|
93
|
+
const resolved = await this.service.historicalWorkspace({ workspace: args.workspace, sessionId: ownerSessionId })
|
|
83
94
|
const previewId = this.randomId()
|
|
84
95
|
const identity = {
|
|
85
96
|
projectRoot: resolved.projectRoot,
|
|
86
|
-
ownerSessionId: `web-historical:${this.randomId()}`,
|
|
97
|
+
ownerSessionId: `web-historical:${ownerSessionId}:${this.randomId()}`,
|
|
87
98
|
}
|
|
88
99
|
const preview = await this.sessionDiagnostic.previewWithIdentity({
|
|
89
100
|
limit: args.limit === undefined ? 10 : args.limit,
|
|
@@ -94,6 +105,7 @@ export class HistoricalWebController {
|
|
|
94
105
|
this.previews.set(previewId, {
|
|
95
106
|
previewId,
|
|
96
107
|
workspace: resolved.workspace,
|
|
108
|
+
ownerSessionId,
|
|
97
109
|
identity,
|
|
98
110
|
config: resolved.config,
|
|
99
111
|
selectionToken,
|
|
@@ -110,11 +122,14 @@ export class HistoricalWebController {
|
|
|
110
122
|
|
|
111
123
|
async run(args = {}) {
|
|
112
124
|
this._cleanup()
|
|
125
|
+
const ownerSessionId = String(args.sessionId ?? '').trim()
|
|
126
|
+
if (!ownerSessionId) throw new Error('HISTORICAL_SESSION_REQUIRED: a live DSH Session is required')
|
|
113
127
|
const previewId = String(args.previewId ?? '')
|
|
114
128
|
if (!previewId) throw new Error('HISTORICAL_PREVIEW_REQUIRED: preview the recent Sessions before confirming the Job')
|
|
115
129
|
const existingOperationId = this.consumedPreviews.get(previewId)
|
|
116
130
|
if (existingOperationId) {
|
|
117
131
|
const existing = this.operations.get(existingOperationId)
|
|
132
|
+
if (existing?.ownerSessionId !== ownerSessionId) throw new Error('HISTORICAL_PREVIEW_SESSION_MISMATCH: the preview belongs to another DSH Session')
|
|
118
133
|
if (args.workspace && existing?.workspace !== args.workspace) {
|
|
119
134
|
throw new Error('HISTORICAL_PREVIEW_WORKSPACE_MISMATCH: the workspace changed; preview again')
|
|
120
135
|
}
|
|
@@ -122,33 +137,47 @@ export class HistoricalWebController {
|
|
|
122
137
|
}
|
|
123
138
|
const preview = this.previews.get(previewId)
|
|
124
139
|
if (!preview) throw new Error('HISTORICAL_PREVIEW_INVALID: this preview expired or was already discarded; preview again')
|
|
140
|
+
if (preview.ownerSessionId !== ownerSessionId) throw new Error('HISTORICAL_PREVIEW_SESSION_MISMATCH: the preview belongs to another DSH Session')
|
|
125
141
|
if (args.workspace && preview.workspace !== args.workspace) {
|
|
126
142
|
throw new Error('HISTORICAL_PREVIEW_WORKSPACE_MISMATCH: the workspace changed; preview again')
|
|
127
143
|
}
|
|
128
|
-
const active = this.
|
|
144
|
+
const active = this._activeWorkspaceOperation(preview.workspace)
|
|
129
145
|
if (active) {
|
|
130
146
|
throw new Error('HISTORICAL_JOB_ALREADY_RUNNING: wait for the current Historical Session Job to finish')
|
|
131
147
|
}
|
|
148
|
+
const lease = this.runLock.acquire(preview.config, {
|
|
149
|
+
channel: 'web',
|
|
150
|
+
workspace: preview.workspace,
|
|
151
|
+
})
|
|
132
152
|
const operationId = this.randomId()
|
|
133
153
|
const createdAt = timestamp(this.now).toISOString()
|
|
134
154
|
const operation = {
|
|
135
155
|
operationId,
|
|
136
156
|
workspace: preview.workspace,
|
|
157
|
+
ownerSessionId,
|
|
137
158
|
status: 'queued',
|
|
138
159
|
selectedCount: preview.selectedCount,
|
|
139
160
|
createdAt,
|
|
140
161
|
}
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
162
|
+
try {
|
|
163
|
+
this.previews.delete(previewId)
|
|
164
|
+
this.operations.set(operationId, operation)
|
|
165
|
+
this.consumedPreviews.set(previewId, operationId)
|
|
166
|
+
this.schedule(() => { void this._execute(operation, preview, lease) })
|
|
167
|
+
} catch (error) {
|
|
168
|
+
this.operations.delete(operationId)
|
|
169
|
+
this.consumedPreviews.delete(previewId)
|
|
170
|
+
this.previews.set(previewId, preview)
|
|
171
|
+
lease.release()
|
|
172
|
+
throw error
|
|
173
|
+
}
|
|
145
174
|
return publicOperation(operation)
|
|
146
175
|
}
|
|
147
176
|
|
|
148
|
-
async _execute(operation, preview) {
|
|
149
|
-
operation.status = 'running'
|
|
150
|
-
operation.startedAt = timestamp(this.now).toISOString()
|
|
177
|
+
async _execute(operation, preview, lease) {
|
|
151
178
|
try {
|
|
179
|
+
operation.status = 'running'
|
|
180
|
+
operation.startedAt = timestamp(this.now).toISOString()
|
|
152
181
|
const result = await this.sessionDiagnostic.runWithIdentity({
|
|
153
182
|
selectionToken: preview.selectionToken,
|
|
154
183
|
}, preview.identity, { config: preview.config })
|
|
@@ -159,22 +188,29 @@ export class HistoricalWebController {
|
|
|
159
188
|
operation.status = 'failed'
|
|
160
189
|
operation.error = publicError(error)
|
|
161
190
|
} finally {
|
|
162
|
-
|
|
191
|
+
try {
|
|
192
|
+
operation.finishedAt = timestamp(this.now).toISOString()
|
|
193
|
+
} finally {
|
|
194
|
+
lease.release()
|
|
195
|
+
}
|
|
163
196
|
}
|
|
164
197
|
}
|
|
165
198
|
|
|
166
199
|
operation(args = {}) {
|
|
167
200
|
this._cleanup()
|
|
201
|
+
const ownerSessionId = String(args.sessionId ?? '').trim()
|
|
202
|
+
if (!ownerSessionId) throw new Error('HISTORICAL_SESSION_REQUIRED: a live DSH Session is required')
|
|
168
203
|
const operationId = String(args.operationId ?? '')
|
|
169
204
|
if (operationId) {
|
|
170
205
|
const operation = this.operations.get(operationId)
|
|
171
206
|
if (!operation) return { status: 'idle' }
|
|
207
|
+
if (operation.ownerSessionId !== ownerSessionId) throw new Error('HISTORICAL_OPERATION_SESSION_MISMATCH: the operation belongs to another DSH Session')
|
|
172
208
|
if (args.workspace && operation.workspace !== args.workspace) {
|
|
173
209
|
throw new Error('HISTORICAL_OPERATION_WORKSPACE_MISMATCH: the operation belongs to another workspace')
|
|
174
210
|
}
|
|
175
211
|
return publicOperation(operation)
|
|
176
212
|
}
|
|
177
213
|
if (!args.workspace) return { status: 'idle' }
|
|
178
|
-
return publicOperation(this.
|
|
214
|
+
return publicOperation(this._activeOwnedOperation(String(args.workspace), ownerSessionId))
|
|
179
215
|
}
|
|
180
216
|
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
|
|
3
|
+
export const LOCAL_OBJECT_KINDS = new Set(['hypothesis', 'gate-reason', 'metric', 'finding', 'attempt', 'exception', 'evaluator-source', 'trial-set'])
|
|
4
|
+
|
|
5
|
+
function canonical(value) {
|
|
6
|
+
if (Array.isArray(value)) return value.map(canonical)
|
|
7
|
+
if (value && typeof value === 'object') return Object.fromEntries(Object.keys(value).sort().map(key => [key, canonical(value[key])]))
|
|
8
|
+
return value
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function localObjectDigest(value) {
|
|
12
|
+
return `sha256:${createHash('sha256').update(JSON.stringify(canonical(value))).digest('hex')}`
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Host-derived selectors. No paths or artifact prose enter message context. */
|
|
16
|
+
export function interactionObjectCatalog(job, jobState, trialState, governance) {
|
|
17
|
+
const artifacts = jobState?.artifacts ?? {}
|
|
18
|
+
const entries = []
|
|
19
|
+
const add = (kind, stage, value, fields = {}) => {
|
|
20
|
+
if (value === undefined || value === null) return
|
|
21
|
+
const sourceDigest = localObjectDigest(value)
|
|
22
|
+
const id = `${kind}-${sourceDigest.slice(7, 31)}`
|
|
23
|
+
entries.push({ ref: { kind, id, job, stage, sourceDigest, ...fields }, value })
|
|
24
|
+
}
|
|
25
|
+
for (const value of (artifacts.optimization?.hypotheses ?? []).slice(0, 100)) add('hypothesis', 'optimizer', value)
|
|
26
|
+
for (const value of (artifacts.promotion?.reasons ?? []).slice(0, 100)) add('gate-reason', 'gate', value)
|
|
27
|
+
for (const [metric, value] of Object.entries(artifacts.summary?.metrics ?? {}).slice(0, 100)) {
|
|
28
|
+
if (typeof value === 'number' && Number.isFinite(value)) add('metric', 'reporter', { metric, value })
|
|
29
|
+
}
|
|
30
|
+
if (trialState) {
|
|
31
|
+
const trial = trialState.lifecycle?.id ?? trialState.trial
|
|
32
|
+
for (const value of (trialState.assessment?.findings ?? []).slice(0, 100)) add('finding', 'judge', value, { trial })
|
|
33
|
+
for (const reason of (trialState.assessment?.score?.invalid_reasons ?? trialState.lifecycle?.score?.invalid_reasons ?? []).slice(0, 100)) add('exception', 'judge', { reason, scoreValid: false }, { trial })
|
|
34
|
+
if (trialState.lifecycle?.exception) add('exception', 'judge', trialState.lifecycle.exception, { trial })
|
|
35
|
+
if (trialState.lifecycle) add('attempt', 'judge', trialState.lifecycle, { trial })
|
|
36
|
+
}
|
|
37
|
+
for (const role of ['evaluator', 'rubric']) {
|
|
38
|
+
const source = governance?.components?.[role]?.source
|
|
39
|
+
if (typeof source?.text === 'string' && !source.error) {
|
|
40
|
+
add('evaluator-source', 'judge', { role, text: source.text }, { sourceRole: role })
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return entries
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function resolveCatalogSelection(ref, catalog) {
|
|
47
|
+
const entry = catalog.find(item => item.ref.job === ref.job && item.ref.kind === ref.kind && item.ref.id === ref.id && item.ref.sourceDigest === ref.sourceDigest && item.ref.trial === ref.trial)
|
|
48
|
+
if (!entry) throw new Error('HARBOR_CONTEXT_STALE_SELECTION: Selected artifact item has changed or is unavailable; select it again.')
|
|
49
|
+
if (ref.kind !== 'evaluator-source') return entry
|
|
50
|
+
if (ref.sourceRole !== entry.ref.sourceRole) throw new Error('HARBOR_CONTEXT_STALE_SELECTION: Source role does not match the selected artifact.')
|
|
51
|
+
const lines = entry.value.text.split('\n')
|
|
52
|
+
const start = ref.startLine ?? 1
|
|
53
|
+
const end = ref.endLine ?? Math.min(lines.length, 200)
|
|
54
|
+
if (start < 1 || end < start || end > lines.length || end - start >= 200) throw new Error('HARBOR_CONTEXT_INVALID: Select between 1 and 200 saved source lines.')
|
|
55
|
+
return { ref: { ...entry.ref, startLine: start, endLine: end }, value: { role: ref.sourceRole, startLine: start, endLine: end, text: lines.slice(start - 1, end).join('\n') } }
|
|
56
|
+
}
|
package/lib/model-runtime.js
CHANGED
|
@@ -9,6 +9,14 @@ function nonBlank(value) {
|
|
|
9
9
|
return typeof value === 'string' && value.trim() ? value.trim() : undefined
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
+
function leaseLimit(value, fallback, label) {
|
|
13
|
+
const selected = value ?? fallback
|
|
14
|
+
if (!Number.isSafeInteger(selected) || selected < 1) {
|
|
15
|
+
throw Object.assign(new Error(`HARBOR_MODEL_LIMIT_INVALID: ${label} must be a positive integer.`), { code: 'HARBOR_MODEL_LIMIT_INVALID' })
|
|
16
|
+
}
|
|
17
|
+
return selected
|
|
18
|
+
}
|
|
19
|
+
|
|
12
20
|
function sameSecret(expected, actual) {
|
|
13
21
|
const left = Buffer.from(expected)
|
|
14
22
|
const right = Buffer.from(actual)
|
|
@@ -165,7 +173,27 @@ export class CandidateModelRuntime {
|
|
|
165
173
|
}
|
|
166
174
|
}
|
|
167
175
|
|
|
168
|
-
|
|
176
|
+
/** Read-only budget validation, shared by Preflight and the execution boundary. */
|
|
177
|
+
async assertLeaseLimits(binding, scope = {}) {
|
|
178
|
+
const globalRequests = leaseLimit(this.config.modelBrokerMaxRequests, 1000, 'modelBrokerMaxRequests')
|
|
179
|
+
const maxRequests = Math.min(globalRequests, leaseLimit(scope.maxRequests, globalRequests, 'maxRequests'))
|
|
180
|
+
let maxResponseBytes
|
|
181
|
+
if (scope.maxResponseBytes !== undefined) {
|
|
182
|
+
const globalBytes = leaseLimit(this.config.modelBrokerMaxResponseBytes, 4 * 1024 * 1024, 'modelBrokerMaxResponseBytes')
|
|
183
|
+
maxResponseBytes = Math.min(globalBytes, leaseLimit(scope.maxResponseBytes, globalBytes, 'maxResponseBytes'))
|
|
184
|
+
}
|
|
185
|
+
if (scope.maxOutputTokens !== undefined) {
|
|
186
|
+
leaseLimit(scope.maxOutputTokens, undefined, 'maxOutputTokens')
|
|
187
|
+
// The current public DSH model metadata does not prove provider-wire
|
|
188
|
+
// enforcement. In particular its Codex adapter can ignore maxTokens.
|
|
189
|
+
// Do not silently treat an API option as an actual billing/token cap.
|
|
190
|
+
throw Object.assign(new Error('HARBOR_MODEL_OUTPUT_LIMIT_UNSUPPORTED: This Host does not expose verified provider output-token enforcement. Use explicit request, time and response-byte budgets; these are not token or billing limits.'), { code: 'HARBOR_MODEL_OUTPUT_LIMIT_UNSUPPORTED' })
|
|
191
|
+
}
|
|
192
|
+
return { maxRequests, ...(maxResponseBytes === undefined ? {} : { maxResponseBytes }) }
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async openLease(binding, scope = {}) {
|
|
196
|
+
const limits = await this.assertLeaseLimits(binding, scope)
|
|
169
197
|
const token = randomBytes(32).toString('base64url')
|
|
170
198
|
const route = `/harbor-model-gateway/v1/${randomBytes(18).toString('base64url')}`
|
|
171
199
|
const controllers = new Set()
|
|
@@ -180,6 +208,7 @@ export class CandidateModelRuntime {
|
|
|
180
208
|
protocol: MODEL_GATEWAY_PROTOCOL,
|
|
181
209
|
candidate_digest: scope.candidateDigest,
|
|
182
210
|
job: scope.jobName,
|
|
211
|
+
limits,
|
|
183
212
|
binding: {
|
|
184
213
|
provider: binding.provider,
|
|
185
214
|
model: binding.model,
|
|
@@ -192,7 +221,7 @@ export class CandidateModelRuntime {
|
|
|
192
221
|
sendJson(response, 405, { error: 'method not allowed' })
|
|
193
222
|
return
|
|
194
223
|
}
|
|
195
|
-
if (requestCount >=
|
|
224
|
+
if (requestCount >= limits.maxRequests) {
|
|
196
225
|
sendJson(response, 429, { error: 'model gateway request budget exhausted' })
|
|
197
226
|
return
|
|
198
227
|
}
|
|
@@ -209,8 +238,12 @@ export class CandidateModelRuntime {
|
|
|
209
238
|
model: _model,
|
|
210
239
|
reasoningEffort: _reasoningEffort,
|
|
211
240
|
signal: _signal,
|
|
241
|
+
maxRequests: _maxRequests,
|
|
242
|
+
maxResponseBytes: _maxResponseBytes,
|
|
243
|
+
maxOutputTokens: _maxOutputTokens,
|
|
212
244
|
...requestOptions
|
|
213
245
|
} = body
|
|
246
|
+
if (controller.signal.aborted) throw new Error('Candidate disconnected before model execution')
|
|
214
247
|
response.writeHead(200, {
|
|
215
248
|
'content-type': 'application/x-ndjson; charset=utf-8',
|
|
216
249
|
'cache-control': 'no-store',
|
|
@@ -222,8 +255,17 @@ export class CandidateModelRuntime {
|
|
|
222
255
|
...(binding.reasoning_effort === undefined ? {} : { reasoningEffort: binding.reasoning_effort }),
|
|
223
256
|
signal: controller.signal,
|
|
224
257
|
})
|
|
258
|
+
let responseBytes = 0
|
|
225
259
|
for await (const chunk of stream) {
|
|
226
|
-
|
|
260
|
+
const line = `${JSON.stringify(chunk)}\n`
|
|
261
|
+
responseBytes += Buffer.byteLength(line, 'utf8')
|
|
262
|
+
if (limits.maxResponseBytes !== undefined && responseBytes > limits.maxResponseBytes) {
|
|
263
|
+
const error = new Error('model gateway response byte budget exhausted; this is not a provider token or billing limit')
|
|
264
|
+
controller.abort(error)
|
|
265
|
+
// Never publish the overflowing chunk or a successful finish.
|
|
266
|
+
throw error
|
|
267
|
+
}
|
|
268
|
+
if (!response.write(line)) await once(response, 'drain', { signal: controller.signal })
|
|
227
269
|
}
|
|
228
270
|
response.end()
|
|
229
271
|
} catch (error) {
|
|
@@ -248,6 +290,9 @@ export class CandidateModelRuntime {
|
|
|
248
290
|
token,
|
|
249
291
|
candidateProvider: CANDIDATE_GATEWAY_PROVIDER,
|
|
250
292
|
modelInfo: binding.model_info,
|
|
293
|
+
limits: { ...limits },
|
|
294
|
+
// Host-only observation: no gateway URL, token or request content escapes.
|
|
295
|
+
usage: () => ({ modelRequests: requestCount, maxModelRequests: limits.maxRequests }),
|
|
251
296
|
async close() {
|
|
252
297
|
if (closed) return
|
|
253
298
|
closed = true
|