dsh-harbor-evolution 0.7.2 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/service.js CHANGED
@@ -1,7 +1,10 @@
1
- import { access } from 'node:fs/promises'
1
+ import { access, stat } from 'node:fs/promises'
2
2
  import path from 'node:path'
3
3
 
4
+ import { loadModelBinding } from './candidate.js'
5
+
4
6
  import {
7
+ discoverWorkspaceConfigs,
5
8
  readComparison,
6
9
  readDashboardSnapshot,
7
10
  readDatasetPreview,
@@ -16,6 +19,7 @@ import {
16
19
  compareCandidates,
17
20
  initializeGroundTruth,
18
21
  initializeProject,
22
+ initializeQuickDiagnostic,
19
23
  inspectEvaluator,
20
24
  previewContext,
21
25
  readEvaluation,
@@ -25,7 +29,9 @@ import {
25
29
  snapshot,
26
30
  updateEvaluator,
27
31
  validateDataset,
32
+ resolveWithin,
28
33
  } from './evolution.js'
34
+ import { createVersionChecker } from './version.js'
29
35
 
30
36
  export async function resolveEvaluatorStackPath(config, governance, explicitPath) {
31
37
  if (explicitPath) return explicitPath
@@ -49,9 +55,54 @@ export async function resolveEvaluatorStackPath(config, governance, explicitPath
49
55
  /** One Host-side boundary shared by Agent tools and the Web dashboard. */
50
56
  export class EvolutionService {
51
57
  constructor(config, metadata = {}, modelRuntime) {
52
- this.config = config
58
+ this.config = { jobsDir: 'jobs', ...config }
53
59
  this.metadata = metadata
54
60
  this.modelRuntime = modelRuntime
61
+ this.versionChecker = metadata.versionChecker ?? createVersionChecker()
62
+ this.projectRoots = new Map()
63
+ this.workspaceConfigs = new Map()
64
+ this.activeProjectRoot = path.resolve(this.config.projectRoot)
65
+ this._registerProjectRoot(this.activeProjectRoot, metadata.projectRootSource ?? 'configured')
66
+ }
67
+
68
+ _registerProjectRoot(projectRoot, source) {
69
+ const resolved = path.resolve(projectRoot)
70
+ this.projectRoots.set(resolved, { projectRoot: resolved, source, activatedAt: new Date().toISOString() })
71
+ this.activeProjectRoot = resolved
72
+ return this.projectRoots.get(resolved)
73
+ }
74
+
75
+ async _refreshWorkspaces() {
76
+ const discovered = []
77
+ this.workspaceConfigs.clear()
78
+ for (const [identity, root] of this.projectRoots.entries()) {
79
+ try {
80
+ const details = await stat(root.projectRoot)
81
+ if (!details.isDirectory()) throw new Error('not a directory')
82
+ } catch {
83
+ this.projectRoots.delete(identity)
84
+ continue
85
+ }
86
+ const configs = await discoverWorkspaceConfigs({ ...this.config, projectRoot: root.projectRoot })
87
+ for (const config of configs) {
88
+ const value = { ...config, projectRootSource: root.source }
89
+ this.workspaceConfigs.set(config.workspaceId, value)
90
+ discovered.push(value)
91
+ }
92
+ }
93
+ return discovered
94
+ }
95
+
96
+ async _webContext(args = {}) {
97
+ const workspaces = await this._refreshWorkspaces()
98
+ const requested = String(args.workspace ?? '').trim()
99
+ let config = requested ? this.workspaceConfigs.get(requested) : undefined
100
+ if (requested && !config) throw new Error('Workspace is unavailable; reload Harbor and select an active workspace')
101
+ config ??= workspaces.find(item => item.projectRoot === this.activeProjectRoot && item.workspaceRoot === '.')
102
+ ?? workspaces.find(item => item.projectRoot === this.activeProjectRoot)
103
+ ?? workspaces[0]
104
+ if (!config) throw new Error('No Harbor workspace is available')
105
+ return { config, workspaces }
55
106
  }
56
107
 
57
108
  snapshot(args) {
@@ -62,8 +113,22 @@ export class EvolutionService {
62
113
  return initializeProject(this.config, args)
63
114
  }
64
115
 
116
+ quickDiagnostic(args) {
117
+ return initializeQuickDiagnostic(this.config, args)
118
+ }
119
+
120
+ async _resolveCandidateModel(args) {
121
+ const candidatePath = resolveWithin(
122
+ this.config.projectRoot,
123
+ args.candidatePath,
124
+ 'candidatePath',
125
+ )
126
+ const pinnedBinding = await loadModelBinding(candidatePath)
127
+ return this.modelRuntime.resolve(args, pinnedBinding)
128
+ }
129
+
65
130
  async run(args) {
66
- const candidateModelBinding = await this.modelRuntime.resolve(args)
131
+ const candidateModelBinding = await this._resolveCandidateModel(args)
67
132
  return runEvaluation(this.config, { ...args, candidateModelBinding }, this.modelRuntime)
68
133
  }
69
134
 
@@ -85,7 +150,7 @@ export class EvolutionService {
85
150
  }
86
151
 
87
152
  async doctor(args) {
88
- const candidateModelBinding = await this.modelRuntime.resolve(args)
153
+ const candidateModelBinding = await this._resolveCandidateModel(args)
89
154
  const result = await runDoctor(this.config, args)
90
155
  return { ...result, candidate_model_binding: candidateModelBinding }
91
156
  }
@@ -95,54 +160,141 @@ export class EvolutionService {
95
160
  }
96
161
 
97
162
  async previewContext(args) {
98
- const candidateModelBinding = await this.modelRuntime.resolve(args)
163
+ const candidateModelBinding = await this._resolveCandidateModel(args)
99
164
  return previewContext(this.config, { ...args, candidateModelBinding })
100
165
  }
101
166
 
102
- dashboard() {
103
- return readDashboardSnapshot(this.config, this.metadata)
167
+ async dashboard(args = {}) {
168
+ const { config, workspaces } = await this._webContext(args)
169
+ return readDashboardSnapshot(config, {
170
+ ...this.metadata,
171
+ projectRootSource: config.projectRootSource,
172
+ workspaces: workspaces.map(item => ({
173
+ id: item.workspaceId,
174
+ label: item.workspaceLabel,
175
+ root: item.workspaceRoot,
176
+ projectRoot: item.projectRoot,
177
+ jobsDir: item.jobsDir,
178
+ stackPath: item.stackPath,
179
+ source: item.projectRootSource,
180
+ })),
181
+ }, args)
182
+ }
183
+
184
+ async version(args = {}) {
185
+ let config = this.config
186
+ if (args.workspace) ({ config } = await this._webContext(args))
187
+ else {
188
+ try { ({ config } = await this._webContext(args)) } catch {}
189
+ }
190
+ return this.versionChecker({
191
+ currentVersion: this.metadata.pluginVersion ?? 'development',
192
+ projectRoot: config.projectRoot,
193
+ refresh: args.refresh === true || args.refresh === 'true',
194
+ })
195
+ }
196
+
197
+ async modelBinding() {
198
+ const binding = await this.modelRuntime.currentBinding()
199
+ return {
200
+ schema_version: 1,
201
+ scope: 'new-candidate',
202
+ candidate_model_binding: binding,
203
+ transport: 'dsh-host-broker',
204
+ protocol: 'dsh-host-model-gateway/v1',
205
+ credentials: {
206
+ mode: 'host-broker-only',
207
+ note: 'The Candidate receives only a short-lived Job capability. Host OAuth and API credentials never enter the Candidate or Harbor artifacts.',
208
+ },
209
+ note: 'Write candidate_model_binding to model-binding.json before snapshotting. Later chat-model changes do not rewrite this Candidate.',
210
+ }
211
+ }
212
+
213
+ activateProjectRoot(requested, source = 'agent-session') {
214
+ if (!path.isAbsolute(requested)) throw new Error('projectRoot must be an absolute directory path')
215
+ const resolved = path.resolve(requested)
216
+ this._registerProjectRoot(resolved, source)
217
+ return {
218
+ projectRoot: resolved,
219
+ reloaded: true,
220
+ source,
221
+ scope: 'Web Workbench only; Agent tools remain isolated to each calling session working directory.',
222
+ }
223
+ }
224
+
225
+ async setProjectRoot(args) {
226
+ const requested = String(args?.projectRoot ?? '').trim()
227
+ if (!path.isAbsolute(requested)) throw new Error('projectRoot must be an absolute directory path')
228
+ const resolved = path.resolve(requested)
229
+ const details = await stat(resolved)
230
+ if (!details.isDirectory()) throw new Error('projectRoot must point to an existing directory')
231
+ return this.activateProjectRoot(resolved, 'manual')
104
232
  }
105
233
 
106
- job(args) {
107
- return readJobDetail(this.config, args)
234
+ async job(args) {
235
+ const { config } = await this._webContext(args)
236
+ return readJobDetail(config, args)
108
237
  }
109
238
 
110
- trials(args) {
111
- return readTrialsPage(this.config, args)
239
+ async trials(args) {
240
+ const { config } = await this._webContext(args)
241
+ return readTrialsPage(config, args)
112
242
  }
113
243
 
114
- trial(args) {
115
- return readTrialDetail(this.config, args)
244
+ async trial(args) {
245
+ const { config } = await this._webContext(args)
246
+ return readTrialDetail(config, args)
116
247
  }
117
248
 
118
- dataset(args) {
119
- return readDatasetPreview(this.config, args)
249
+ async dataset(args) {
250
+ const { config } = await this._webContext(args)
251
+ return readDatasetPreview(config, args)
120
252
  }
121
253
 
122
- progress(args) {
123
- return readJobProgress(this.config, args)
254
+ async progress(args) {
255
+ const { config } = await this._webContext(args)
256
+ return readJobProgress(config, args)
124
257
  }
125
258
 
126
- comparison(args) {
127
- return readComparison(this.config, args)
259
+ async comparison(args) {
260
+ const { config } = await this._webContext(args)
261
+ return readComparison(config, args)
128
262
  }
129
263
 
130
264
  async governance(args) {
131
- const governance = await readEvaluatorGovernance(this.config, args)
265
+ const { config } = await this._webContext(args)
266
+ const governance = await readEvaluatorGovernance(config, args)
132
267
  try {
133
- const stackPath = await resolveEvaluatorStackPath(this.config, governance, args.stackPath)
134
- governance.evaluatorInterface = await inspectEvaluator(this.config, { ...args, stackPath })
135
- governance.editingPolicy.browserWriteEnabled = true
136
- governance.editingPolicy.stackPath = governance.evaluatorInterface.stack?.path
137
- governance.editingPolicy.saveBehavior = 'Update one descriptor-authorized file with optimistic concurrency and create new Evaluator and Stack identities.'
268
+ const stackPath = await resolveEvaluatorStackPath(config, governance, args.stackPath)
269
+ const current = await inspectEvaluator(config, { ...args, stackPath })
270
+ const historicalEvaluator = governance.components?.evaluator
271
+ const identityMatches = current.stack?.id === governance.stackIdentity.id
272
+ && current.stack?.version === governance.stackIdentity.version
273
+ && current.evaluator?.evaluator_id === historicalEvaluator?.id
274
+ && current.evaluator?.version === historicalEvaluator?.version
275
+ && current.evaluator?.digest === historicalEvaluator?.digest
276
+ if (!identityMatches) {
277
+ governance.evaluatorInterface = {
278
+ error: 'The live Evaluator no longer matches this historical Job. Historical sources remain readable, but editing is disabled until you open a Job with the current Stack identity.',
279
+ }
280
+ governance.editingPolicy.identityMatch = false
281
+ } else {
282
+ governance.evaluatorInterface = current
283
+ governance.editingPolicy.browserWriteEnabled = true
284
+ governance.editingPolicy.identityMatch = true
285
+ governance.editingPolicy.stackPath = current.stack?.path
286
+ governance.editingPolicy.saveBehavior = 'Update one descriptor-authorized file with optimistic concurrency and create new Evaluator and Stack identities.'
287
+ }
138
288
  } catch (error) {
139
289
  governance.evaluatorInterface = { error: error instanceof Error ? error.message : String(error) }
290
+ governance.editingPolicy.identityMatch = false
140
291
  }
141
292
  return governance
142
293
  }
143
294
 
144
- evaluator(args) {
145
- return updateEvaluator(this.config, args)
295
+ async evaluator(args) {
296
+ const config = args.workspace ? (await this._webContext(args)).config : this.config
297
+ return updateEvaluator(config, args)
146
298
  }
147
299
 
148
300
  evaluatorInspect(args) {
@@ -158,13 +310,15 @@ export class EvolutionService {
158
310
  }
159
311
 
160
312
  async meta(args) {
161
- const governance = await readEvaluatorGovernance(this.config, args)
162
- const stackPath = await resolveEvaluatorStackPath(this.config, governance, args.stackPath)
163
- if (!stackPath) return readMetaEvaluation(this.config)
164
- const stackDirectory = path.dirname(path.resolve(this.config.projectRoot, stackPath))
313
+ const { config } = await this._webContext(args)
314
+ const governance = await readEvaluatorGovernance(config, args)
315
+ const stackPath = await resolveEvaluatorStackPath(config, governance, args.stackPath)
316
+ if (!stackPath) return readMetaEvaluation(config, args)
317
+ const stackDirectory = path.dirname(path.resolve(config.projectRoot, stackPath))
165
318
  const evaluationRoot = path.dirname(stackDirectory)
166
- return readMetaEvaluation(this.config, {
167
- evaluationRoot: path.relative(this.config.projectRoot, evaluationRoot),
319
+ return readMetaEvaluation(config, {
320
+ ...args,
321
+ evaluationRoot: path.relative(config.projectRoot, evaluationRoot),
168
322
  })
169
323
  }
170
324
  }
@@ -0,0 +1,320 @@
1
+ import path from 'node:path'
2
+
3
+ import { buildHistoricalGenerationBatch, writePrivateHistoricalBatch } from './session-materializer.js'
4
+ import { buildSessionObservation } from './session-redaction.js'
5
+ import {
6
+ canonicalDigest,
7
+ SessionSelectionTokenStore,
8
+ selectRecentSessions,
9
+ verifySessionSnapshot,
10
+ } from './session-selection.js'
11
+
12
+ function capability(ctx, name) {
13
+ try {
14
+ return typeof ctx?.get === 'function' ? ctx.get(name) ?? ctx[name] : ctx?.[name]
15
+ } catch {
16
+ return ctx?.[name]
17
+ }
18
+ }
19
+
20
+ function executionIdentity(exec) {
21
+ const header = exec?.agent?.session?.header
22
+ if (typeof header?.cwd !== 'string' || !path.isAbsolute(header.cwd)) {
23
+ throw new Error('Harbor Session tools require an Agent Session with an absolute working directory')
24
+ }
25
+ if (typeof header.id !== 'string' || !header.id) {
26
+ throw new Error('Harbor Session tools require the calling Agent Session identity')
27
+ }
28
+ return { projectRoot: path.resolve(header.cwd), ownerSessionId: header.id }
29
+ }
30
+
31
+ function feedbackItems(result) {
32
+ return result?.ok === true && Array.isArray(result.value?.items) ? result.value.items : []
33
+ }
34
+
35
+ async function readFeedback(service, sessionId) {
36
+ if (!service || typeof service.list !== 'function') return { items: [], available: false, failed: false }
37
+ try {
38
+ const result = await service.list({ sessionId })
39
+ return {
40
+ items: feedbackItems(result),
41
+ available: result?.ok === true,
42
+ failed: result?.ok === false,
43
+ }
44
+ } catch {
45
+ return { items: [], available: true, failed: true }
46
+ }
47
+ }
48
+
49
+ function withoutRawEvents(selection) {
50
+ const { events: _events, ...rest } = selection
51
+ return rest
52
+ }
53
+
54
+ function parseCreatedAfter(value) {
55
+ if (value === undefined || value === null || value === '') return undefined
56
+ if (typeof value !== 'string') {
57
+ throw new Error('SESSION_CREATED_AFTER_INVALID: createdAfter must be an ISO-8601 string')
58
+ }
59
+ const parsed = Date.parse(value)
60
+ if (!Number.isSafeInteger(parsed) || parsed < 0) {
61
+ throw new Error('SESSION_CREATED_AFTER_INVALID: createdAfter must be a valid ISO-8601 timestamp')
62
+ }
63
+ return parsed
64
+ }
65
+
66
+ function feedbackDigest(observation) {
67
+ return canonicalDigest(
68
+ {
69
+ available: observation.available,
70
+ failed: observation.failed,
71
+ items: observation.items,
72
+ },
73
+ 'harbor-dsh-session-feedback-snapshot-v1',
74
+ )
75
+ }
76
+
77
+ function requestedJudge(args) {
78
+ if (Boolean(args.evaluatorProvider) !== Boolean(args.evaluatorModel)) {
79
+ throw new Error('EVALUATOR_MODEL_INVALID: evaluatorProvider and evaluatorModel must be supplied together')
80
+ }
81
+ if (args.evaluatorReasoningEffort !== undefined && !args.evaluatorProvider) {
82
+ throw new Error('EVALUATOR_MODEL_INVALID: evaluatorReasoningEffort requires an explicit evaluatorProvider and evaluatorModel')
83
+ }
84
+ return {
85
+ candidateProvider: args.evaluatorProvider,
86
+ candidateModel: args.evaluatorModel,
87
+ candidateReasoningEffort: args.evaluatorReasoningEffort,
88
+ }
89
+ }
90
+
91
+ async function resolveJudge(modelRuntime, args) {
92
+ const requested = requestedJudge(args)
93
+ if (requested.candidateProvider) return modelRuntime.resolve(requested)
94
+ if (typeof modelRuntime.resolveCurrent === 'function') {
95
+ return modelRuntime.resolveCurrent()
96
+ }
97
+ const current = await modelRuntime.currentBinding()
98
+ return modelRuntime.resolve({
99
+ candidateProvider: current.provider,
100
+ candidateModel: current.model,
101
+ candidateReasoningEffort: current.reasoning_effort,
102
+ })
103
+ }
104
+
105
+ function judgeIdentity(binding, selections) {
106
+ if (!binding?.provider || !binding?.model) {
107
+ throw new Error('EVALUATOR_MODEL_INVALID: Judge resolution returned no provider/model identity')
108
+ }
109
+ const route = `${binding.provider}/${binding.model}`
110
+ const generatorRoutes = new Set(
111
+ selections.flatMap(item => item.index.modelRoutes.map(value => `${value.provider}/${value.model}`)),
112
+ )
113
+ return {
114
+ evaluator: { id: 'dsh-session-historical-evaluator', version: '1.0.0' },
115
+ judge: {
116
+ provider: binding.provider,
117
+ model: binding.model,
118
+ ...(binding.reasoning_effort === undefined
119
+ ? {}
120
+ : { reasoning_effort: binding.reasoning_effort }),
121
+ transport: 'dsh-host-broker',
122
+ protocol: 'dsh-host-model-gateway/v1',
123
+ },
124
+ coupling: generatorRoutes.size === 0
125
+ ? 'generator-model-unknown-diagnostic-only'
126
+ : generatorRoutes.has(route)
127
+ ? 'same-host-model-diagnostic-only'
128
+ : 'independent-historical-judge',
129
+ }
130
+ }
131
+
132
+ export class SessionDiagnosticService {
133
+ constructor({
134
+ ctx,
135
+ config,
136
+ modelRuntime,
137
+ runHistoricalEvaluation,
138
+ tokenStore,
139
+ now = () => new Date(),
140
+ }) {
141
+ this.ctx = ctx
142
+ this.config = config
143
+ this.modelRuntime = modelRuntime
144
+ this.runHistoricalEvaluation = runHistoricalEvaluation
145
+ this.tokens = tokenStore ?? new SessionSelectionTokenStore()
146
+ this.now = now
147
+ }
148
+
149
+ async preview(args = {}, exec) {
150
+ const identity = executionIdentity(exec)
151
+ const sessionQuery = capability(this.ctx, 'sessionQuery')
152
+ const limit = args.limit ?? 10
153
+ const createdAfter = parseCreatedAfter(args.createdAfter)
154
+ const result = await selectRecentSessions({
155
+ sessionQuery,
156
+ projectRoot: identity.projectRoot,
157
+ currentSessionId: identity.ownerSessionId,
158
+ limit,
159
+ maxSessionReads: this.config.sessionMaxReads ?? 100,
160
+ concurrency: this.config.sessionReadConcurrency ?? 4,
161
+ createdAfter,
162
+ signal: exec?.signal,
163
+ })
164
+ if (!result.selected.length) {
165
+ throw new Error('NO_ELIGIBLE_SESSIONS: no completed top-level DSH Sessions with direct human input and assistant output were found in this workspace')
166
+ }
167
+ const judgeBinding = await resolveJudge(this.modelRuntime, args)
168
+ const includeFeedback = args.includeFeedback !== false
169
+ const feedback = capability(this.ctx, 'messageFeedback')
170
+ const feedbackObservations = includeFeedback
171
+ ? await Promise.all(result.selected.map(item => readFeedback(feedback, item.rawSessionId)))
172
+ : result.selected.map(() => ({ items: [], available: false, failed: false }))
173
+ const feedbackSnapshots = feedbackObservations.map(observation => ({
174
+ available: observation.available,
175
+ failed: observation.failed,
176
+ digest: feedbackDigest(observation),
177
+ }))
178
+ const selected = result.publicSelected.map((item, index) => ({
179
+ ...item,
180
+ feedback: {
181
+ available: feedbackObservations[index].available,
182
+ positive: feedbackObservations[index].items.filter(value => value.rating === 'positive').length,
183
+ negative: feedbackObservations[index].items.filter(value => value.rating === 'negative').length,
184
+ },
185
+ }))
186
+ const evaluation = judgeIdentity(judgeBinding, result.selected)
187
+ const issued = this.tokens.issue({
188
+ ...identity,
189
+ selection: result.selected.map(withoutRawEvents),
190
+ feedbackSnapshots,
191
+ judgeBinding,
192
+ evaluation,
193
+ parameters: { limit, includeFeedback, createdAfter, scope: 'exact-cwd', order: 'last-activity-desc' },
194
+ })
195
+ const warnings = [...result.warnings]
196
+ if (feedbackObservations.some(item => item.failed)) {
197
+ warnings.push('Some Message Feedback could not be read; the Session sample remains usable without it.')
198
+ }
199
+ warnings.push('Frozen Session observations remain local under .harbor/private and the Harbor jobs directory; review repository ignore and retention policy before committing artifacts.')
200
+ return {
201
+ schema_version: 1,
202
+ capability: 'historical-generation-evaluation',
203
+ jobKind: 'historical-generation-evaluation',
204
+ projectRoot: identity.projectRoot,
205
+ scope: 'exact-cwd',
206
+ order: 'last-activity-desc',
207
+ ...(createdAfter === undefined ? {} : { createdAfter: new Date(createdAfter).toISOString() }),
208
+ executionMode: 'observe-existing',
209
+ promotionEligible: false,
210
+ evaluationLevel: 'trial',
211
+ selectionToken: issued.token,
212
+ expiresAt: new Date(issued.expiresAt).toISOString(),
213
+ selected,
214
+ excludedCounts: result.excludedCounts,
215
+ warnings,
216
+ estimatedJudgeRequests: selected.length,
217
+ estimatedMaxBytes: selected.length * 512 * 1024,
218
+ evaluation,
219
+ retention: {
220
+ privateEvidence: '.harbor/private/session-batches',
221
+ jobEvidence: this.config.jobsDir ?? 'jobs',
222
+ vcsPolicy: 'an ignore-all file is created only when .harbor/private/.gitignore is absent; existing private rules and jobs retention/VCS policy remain project-owned',
223
+ },
224
+ confirmation: `Run 1 historical-generation-evaluation Job with ${selected.length} immutable Trial(s) using ${evaluation.evaluator.id}@${evaluation.evaluator.version} and Judge ${evaluation.judge.provider}/${evaluation.judge.model} (${evaluation.coupling}); no Candidate will be executed or promoted.`,
225
+ }
226
+ }
227
+
228
+ async run(args = {}, exec) {
229
+ const identity = executionIdentity(exec)
230
+ if (args.stackPath !== undefined) {
231
+ throw new Error('HISTORICAL_CUSTOM_STACK_UNSUPPORTED: the first release binds the materialized Broker Evaluator and Stack as one immutable unit')
232
+ }
233
+ if (
234
+ args.evaluatorProvider !== undefined
235
+ || args.evaluatorModel !== undefined
236
+ || args.evaluatorReasoningEffort !== undefined
237
+ ) {
238
+ throw new Error('HISTORICAL_JUDGE_NOT_CONFIRMED: choose the Judge during Preview, then Run with only the confirmed selectionToken')
239
+ }
240
+ const token = String(args.selectionToken ?? '')
241
+ if (!token) throw new Error('selectionToken is required; call harbor_session_diagnostic_preview first')
242
+ const selectedState = this.tokens.consume(token, identity)
243
+ const sessionQuery = capability(this.ctx, 'sessionQuery')
244
+ if (!sessionQuery || typeof sessionQuery.readSession !== 'function') {
245
+ throw new Error('DSH_SESSION_QUERY_UNAVAILABLE: this DSH Profile does not expose the Session Query service')
246
+ }
247
+ const reads = await Promise.allSettled(
248
+ selectedState.selection.map(item => sessionQuery.readSession(item.rawSessionId)),
249
+ )
250
+ if (reads.some(item => item.status === 'rejected')) {
251
+ throw new Error('SESSION_SOURCE_READ_FAILED: at least one selected Session could not be re-read; no Batch was written')
252
+ }
253
+ const snapshots = reads.map(item => item.value)
254
+ if (snapshots.some((snapshot, index) => (
255
+ !verifySessionSnapshot(selectedState.selection[index], snapshot, identity.projectRoot)
256
+ ))) {
257
+ throw new Error('SESSION_SAMPLE_CHANGED: at least one selected Session changed after Preview; no Batch was written, preview again')
258
+ }
259
+ const feedback = capability(this.ctx, 'messageFeedback')
260
+ const feedbackObservations = selectedState.parameters.includeFeedback
261
+ ? await Promise.all(selectedState.selection.map(item => readFeedback(feedback, item.rawSessionId)))
262
+ : selectedState.selection.map(() => ({ items: [] }))
263
+ if (selectedState.parameters.includeFeedback) {
264
+ for (let index = 0; index < feedbackObservations.length; index += 1) {
265
+ const expected = selectedState.feedbackSnapshots[index]
266
+ const observed = feedbackObservations[index]
267
+ if (!expected || feedbackDigest(observed) !== expected.digest) {
268
+ throw new Error('SESSION_FEEDBACK_CHANGED: Message Feedback changed after Preview; no Batch was written, preview again')
269
+ }
270
+ }
271
+ }
272
+ const frozenSelections = selectedState.selection.map((item, index) => ({
273
+ ...item,
274
+ events: snapshots[index].events,
275
+ }))
276
+ const observations = frozenSelections.map((item, index) => (
277
+ buildSessionObservation(item, feedbackObservations[index].items)
278
+ ))
279
+ const judgeBinding = selectedState.judgeBinding
280
+ if (!judgeBinding?.provider || !judgeBinding?.model) {
281
+ throw new Error('HISTORICAL_JUDGE_NOT_CONFIRMED: preview again to freeze a valid Judge identity before writing the Batch')
282
+ }
283
+ const batch = buildHistoricalGenerationBatch({
284
+ projectRoot: identity.projectRoot,
285
+ selections: frozenSelections,
286
+ observations,
287
+ limit: selectedState.parameters.limit,
288
+ createdAfter: selectedState.parameters.createdAfter,
289
+ now: this.now(),
290
+ })
291
+ const written = await writePrivateHistoricalBatch({
292
+ projectRoot: identity.projectRoot,
293
+ batch,
294
+ observations,
295
+ })
296
+ const result = await this.runHistoricalEvaluation(
297
+ { ...this.config, projectRoot: identity.projectRoot },
298
+ {
299
+ batchPath: written.batchPath,
300
+ batchDir: written.batchDir,
301
+ jobName: args.jobName,
302
+ judgeBinding,
303
+ },
304
+ this.modelRuntime,
305
+ )
306
+ return {
307
+ schema_version: 1,
308
+ jobKind: 'historical-generation-evaluation',
309
+ executionMode: 'observe-existing',
310
+ promotionEligible: false,
311
+ batch: {
312
+ id: batch.batch_id,
313
+ digest: batch.digest,
314
+ recordCount: batch.records.length,
315
+ path: path.relative(identity.projectRoot, written.batchPath).split(path.sep).join('/'),
316
+ },
317
+ ...result,
318
+ }
319
+ }
320
+ }