dsh-harbor-evolution 0.8.3 → 0.9.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.
@@ -157,10 +157,12 @@ export class SessionDiagnosticService {
157
157
  }
158
158
 
159
159
  async preview(args = {}, exec) {
160
- return this.previewWithIdentity(args, executionIdentity(exec), { signal: exec?.signal })
160
+ // Tool argument schemas may allow undeclared fields. Never let model-supplied
161
+ // arguments widen the public tool's exact-workspace history boundary.
162
+ return this.previewWithIdentity(args, executionIdentity(exec), { signal: exec?.signal, scope: 'exact-cwd' })
161
163
  }
162
164
 
163
- async previewWithIdentity(args = {}, requestedIdentity, { signal, config = this.config } = {}) {
165
+ async previewWithIdentity(args = {}, requestedIdentity, { signal, config = this.config, currentSessionId, scope = 'exact-cwd' } = {}) {
164
166
  const identity = normalizedIdentity(requestedIdentity)
165
167
  const sessionQuery = capability(this.ctx, 'sessionQuery')
166
168
  const limit = args.limit ?? 10
@@ -168,7 +170,8 @@ export class SessionDiagnosticService {
168
170
  const result = await selectRecentSessions({
169
171
  sessionQuery,
170
172
  projectRoot: identity.projectRoot,
171
- currentSessionId: identity.ownerSessionId,
173
+ currentSessionId: currentSessionId ?? identity.ownerSessionId,
174
+ scope,
172
175
  limit,
173
176
  maxSessionReads: config.sessionMaxReads ?? 100,
174
177
  concurrency: config.sessionReadConcurrency ?? 4,
@@ -176,7 +179,13 @@ export class SessionDiagnosticService {
176
179
  signal,
177
180
  })
178
181
  if (!result.selected.length) {
179
- throw new Error('NO_ELIGIBLE_SESSIONS: no completed top-level DSH Sessions with direct human input and assistant output were found in this workspace')
182
+ if (result.excludedCounts.unreadable > 0 || result.excludedCounts.invalidHeader > 0) {
183
+ throw new Error('SESSION_HISTORY_READ_FAILED: historical conversations could not be read; retry without changing the working directory')
184
+ }
185
+ if (result.scan?.partial) {
186
+ throw new Error('SESSION_HISTORY_WINDOW_EXHAUSTED: no completed conversations were available in the recent sample; older history was not evaluated')
187
+ }
188
+ throw new Error('NO_ELIGIBLE_SESSIONS: no completed conversations with direct human input and assistant output were available in the selected history')
180
189
  }
181
190
  const judgeBinding = await resolveJudge(this.modelRuntime, args)
182
191
  const includeFeedback = args.includeFeedback !== false
@@ -204,7 +213,7 @@ export class SessionDiagnosticService {
204
213
  feedbackSnapshots,
205
214
  judgeBinding,
206
215
  evaluation,
207
- parameters: { limit, includeFeedback, createdAfter, scope: 'exact-cwd', order: 'last-activity-desc' },
216
+ parameters: { limit, includeFeedback, createdAfter, scope, order: 'last-activity-desc', ...(result.scan ? { scan: result.scan } : {}) },
208
217
  })
209
218
  const warnings = [...result.warnings]
210
219
  if (feedbackObservations.some(item => item.failed)) {
@@ -215,9 +224,9 @@ export class SessionDiagnosticService {
215
224
  schema_version: 1,
216
225
  capability: 'historical-generation-evaluation',
217
226
  jobKind: 'historical-generation-evaluation',
218
- projectRoot: identity.projectRoot,
219
- scope: 'exact-cwd',
227
+ scope,
220
228
  order: 'last-activity-desc',
229
+ ...(result.scan ? { scan: result.scan } : {}),
221
230
  ...(createdAfter === undefined ? {} : { createdAfter: new Date(createdAfter).toISOString() }),
222
231
  executionMode: 'observe-existing',
223
232
  promotionEligible: false,
@@ -270,7 +279,10 @@ export class SessionDiagnosticService {
270
279
  }
271
280
  const snapshots = reads.map(item => item.value)
272
281
  if (snapshots.some((snapshot, index) => (
273
- !verifySessionSnapshot(selectedState.selection[index], snapshot, identity.projectRoot)
282
+ !verifySessionSnapshot(selectedState.selection[index], snapshot,
283
+ selectedState.parameters.scope === 'dsh-history'
284
+ ? selectedState.selection[index].header.cwd
285
+ : identity.projectRoot)
274
286
  ))) {
275
287
  throw new Error('SESSION_SAMPLE_CHANGED: at least one selected Session changed after Preview; no Batch was written, preview again')
276
288
  }
@@ -304,6 +316,8 @@ export class SessionDiagnosticService {
304
316
  observations,
305
317
  limit: selectedState.parameters.limit,
306
318
  createdAfter: selectedState.parameters.createdAfter,
319
+ scope: selectedState.parameters.scope,
320
+ scan: selectedState.parameters.scan,
307
321
  now: this.now(),
308
322
  })
309
323
  const written = await writePrivateHistoricalBatch({
@@ -12,7 +12,40 @@ function routeName(route) {
12
12
  return `${route.provider}/${route.model}`
13
13
  }
14
14
 
15
- export function buildHistoricalGenerationBatch({ projectRoot, selections, observations, limit = 10, createdAfter, now = new Date() }) {
15
+ function materializeScan(scan, scope) {
16
+ if (scan === undefined) return undefined
17
+ if (
18
+ !scan || scan.scope !== scope
19
+ || ![scan.listedCount, scan.candidateCount, scan.readCount, scan.unscannedCount]
20
+ .every(value => Number.isSafeInteger(value) && value >= 0)
21
+ || scan.candidateCount > scan.listedCount
22
+ || scan.readCount + scan.unscannedCount !== scan.candidateCount
23
+ || scan.partial !== (scan.unscannedCount > 0)
24
+ || scan.windowOrder !== (scope === 'exact-cwd' ? 'all-candidates' : 'created-at-desc')
25
+ || scan.selectionOrder !== 'last-activity-desc'
26
+ ) {
27
+ throw new Error('HISTORICAL_BATCH_SCAN_INVALID')
28
+ }
29
+ return {
30
+ scope,
31
+ listed_count: scan.listedCount,
32
+ candidate_count: scan.candidateCount,
33
+ read_count: scan.readCount,
34
+ unscanned_count: scan.unscannedCount,
35
+ partial: scan.partial,
36
+ window_order: scan.windowOrder,
37
+ selection_order: scan.selectionOrder,
38
+ }
39
+ }
40
+
41
+ export function buildHistoricalGenerationBatch({
42
+ projectRoot, selections, observations, limit = 10, createdAfter,
43
+ scope = 'exact-cwd', scan, now = new Date(),
44
+ }) {
45
+ if (!['exact-cwd', 'dsh-history'].includes(scope)) {
46
+ throw new Error('HISTORICAL_BATCH_SCOPE_INVALID')
47
+ }
48
+ const selectionScan = materializeScan(scan, scope)
16
49
  if (!Array.isArray(selections) || !selections.length || selections.length > 10) {
17
50
  throw new Error('HISTORICAL_BATCH_SIZE_INVALID: a batch requires 1 to 10 Session observations')
18
51
  }
@@ -35,6 +68,11 @@ export function buildHistoricalGenerationBatch({ projectRoot, selections, observ
35
68
  source_ref: selection.sourceRef,
36
69
  captured_through_seq: selection.capturedThroughSeq,
37
70
  source_digest: selection.sourceDigest,
71
+ ...(typeof selection.header?.cwd === 'string' && path.isAbsolute(selection.header.cwd)
72
+ ? { source_project_digest: canonicalDigest(
73
+ { cwd: path.resolve(selection.header.cwd) }, 'harbor-dsh-project-cwd-v1',
74
+ ) }
75
+ : {}),
38
76
  observation_digest: observation.digest,
39
77
  last_activity_at: observation.source.last_activity_at,
40
78
  generator: {
@@ -60,15 +98,17 @@ export function buildHistoricalGenerationBatch({ projectRoot, selections, observ
60
98
  batch_id: batchId,
61
99
  created_at: now.toISOString(),
62
100
  project: {
101
+ // This identifies the output workspace, not the projects the sessions came from.
63
102
  cwd_digest: canonicalDigest({ cwd: path.resolve(projectRoot) }, 'harbor-dsh-project-cwd-v1'),
64
103
  },
65
104
  selection: {
66
- scope: 'exact-cwd',
105
+ scope,
67
106
  order: 'last-activity-desc',
68
107
  requested_limit: limit,
69
108
  selected_count: records.length,
70
109
  current_session_excluded: true,
71
110
  ...(createdAfter === undefined ? {} : { created_after: new Date(createdAfter).toISOString() }),
111
+ ...(selectionScan === undefined ? {} : { scan: selectionScan }),
72
112
  },
73
113
  source: {
74
114
  kind: 'dsh-session',
@@ -1,20 +1,17 @@
1
1
  import { canonicalDigest } from './session-selection.js'
2
+ import {
3
+ containsCredentialText,
4
+ containsLocalPath,
5
+ containsOpaqueSecretText,
6
+ redactCredentialTextWithCount,
7
+ redactLocalPathsWithCount,
8
+ redactOpaqueSecretTextWithCount,
9
+ } from './credential-redaction.js'
2
10
 
3
11
  const MAX_MESSAGE_CHARS = 4_000
4
12
  const MAX_TRANSCRIPT_MESSAGES = 80
5
13
  const MAX_OBSERVATION_BYTES = 512 * 1024
6
14
 
7
- const SECRET_PATTERNS = [
8
- /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/gi,
9
- /\b(?:authorization\s*[:=]\s*(?:bearer\s+)?|bearer\s+)[A-Za-z0-9._~+/=-]{8,}/gi,
10
- /\b(?:api[_-]?key|access[_-]?token|refresh[_-]?token|secret|password|passwd)\s*[:=]\s*["']?[^\s,"'}\]]{4,}/gi,
11
- /\b(?:sk|rk|pk)-[A-Za-z0-9_-]{12,}\b/g,
12
- /\bgh[opusr]_[A-Za-z0-9]{20,}\b/g,
13
- /\bAKIA[0-9A-Z]{16}\b/g,
14
- ]
15
-
16
- const ABSOLUTE_PATH = /(?:[A-Za-z]:\\(?:[^\s<>:"|?*]+\\)+[^\s<>:"|?*]*|\/(?:Users|home|private|tmp|var|etc|opt|Volumes|workspace)(?:\/[A-Za-z0-9._ @+-]+)+)/g
17
-
18
15
  function replaceCanaries(value, canaries) {
19
16
  let text = value
20
17
  let replacements = 0
@@ -29,20 +26,13 @@ function replaceCanaries(value, canaries) {
29
26
 
30
27
  function replaceSecrets(value, canaries = []) {
31
28
  const canaryResult = replaceCanaries(value, canaries)
32
- value = canaryResult.text
33
- let text = value
34
- let replacements = canaryResult.replacements
35
- for (const pattern of SECRET_PATTERNS) {
36
- text = text.replace(pattern, () => {
37
- replacements += 1
38
- return '[REDACTED_SECRET]'
39
- })
29
+ const credentials = redactCredentialTextWithCount(canaryResult.text, '[REDACTED_SECRET]', false)
30
+ const opaque = redactOpaqueSecretTextWithCount(credentials.text, '[REDACTED_SECRET]')
31
+ const paths = redactLocalPathsWithCount(opaque.text, '[REDACTED_PATH]')
32
+ return {
33
+ text: paths.text,
34
+ replacements: canaryResult.replacements + credentials.replacements + opaque.replacements + paths.replacements,
40
35
  }
41
- text = text.replace(ABSOLUTE_PATH, () => {
42
- replacements += 1
43
- return '[REDACTED_PATH]'
44
- })
45
- return { text, replacements }
46
36
  }
47
37
 
48
38
  function sanitizeText(value, maxChars = MAX_MESSAGE_CHARS, canaries = []) {
@@ -181,14 +171,10 @@ function assertNoSecret(value, canaries = []) {
181
171
  throw new Error('SESSION_REDACTION_FAILED: a raw Session id survived the redaction pipeline')
182
172
  }
183
173
  }
184
- for (const pattern of SECRET_PATTERNS) {
185
- pattern.lastIndex = 0
186
- if (pattern.test(serialized)) {
187
- throw new Error('SESSION_REDACTION_FAILED: a credential-shaped value survived the redaction pipeline')
188
- }
174
+ if (containsCredentialText(serialized) || containsOpaqueSecretText(serialized)) {
175
+ throw new Error('SESSION_REDACTION_FAILED: a credential-shaped value survived the redaction pipeline')
189
176
  }
190
- ABSOLUTE_PATH.lastIndex = 0
191
- if (ABSOLUTE_PATH.test(serialized)) {
177
+ if (containsLocalPath(serialized)) {
192
178
  throw new Error('SESSION_REDACTION_FAILED: an absolute local path survived the redaction pipeline')
193
179
  }
194
180
  if (Buffer.byteLength(serialized) > MAX_OBSERVATION_BYTES) {
@@ -1,6 +1,7 @@
1
1
  import { createHash, randomBytes } from 'node:crypto'
2
2
  import path from 'node:path'
3
3
 
4
+ import { containsCredentialText, containsLocalPath, containsOpaqueSecretText } from './credential-redaction.js'
4
5
  import { foldSessionDiagnosticIndex } from './session-projection.js'
5
6
 
6
7
  function canonicalize(value) {
@@ -33,21 +34,35 @@ function sessionHeaderIdentity(header, effectiveAgentPreset) {
33
34
  }
34
35
 
35
36
  function sameProjectRoot(value, projectRoot) {
36
- if (typeof value !== 'string' || !path.isAbsolute(value)) return false
37
+ if (!validSourceRoot(value) || !validSourceRoot(projectRoot)) return false
37
38
  return path.resolve(value) === path.resolve(projectRoot)
38
39
  }
39
40
 
41
+ function validSourceRoot(value) {
42
+ return typeof value === 'string' && !value.includes('\0') && path.isAbsolute(value)
43
+ }
44
+
45
+ function validTimestamp(value) {
46
+ return Number.isSafeInteger(value) && value >= 0 && value <= 8_640_000_000_000_000
47
+ }
48
+
49
+ function validSourceHeader(header) {
50
+ return typeof header?.id === 'string' && header.id.trim().length > 0
51
+ && validSourceRoot(header.cwd) && validTimestamp(header.createdAt)
52
+ }
53
+
40
54
  function isoTime(value) {
41
- return Number.isSafeInteger(value) && value > 0 ? new Date(value).toISOString() : null
55
+ return validTimestamp(value) && value > 0 ? new Date(value).toISOString() : null
42
56
  }
43
57
 
44
58
  function safeIdentity(value, rawSessionId) {
45
59
  const text = typeof value === 'string' ? value : ''
46
60
  if (rawSessionId && text.includes(rawSessionId)) return '[redacted-identity]'
47
61
  if (
48
- /(?:api[_-]?key|token|secret|password|authorization|bearer\s+)/i.test(text)
49
- || /(?:^|[\\/])(?:Users|home|private|tmp|var|etc|opt|Volumes)(?:[\\/]|$)/.test(text)
50
- || /\b(?:sk|rk|pk)-[A-Za-z0-9_-]{12,}\b/.test(text)
62
+ containsCredentialText(text)
63
+ || containsOpaqueSecretText(text)
64
+ || containsLocalPath(text)
65
+ || /(?:api[_-]?key|token|secret|password|authorization)/i.test(text)
51
66
  ) return '[redacted-identity]'
52
67
  return text.slice(0, 160)
53
68
  }
@@ -95,6 +110,7 @@ export async function selectRecentSessions({
95
110
  sessionQuery,
96
111
  projectRoot,
97
112
  currentSessionId,
113
+ scope = 'exact-cwd',
98
114
  limit = 10,
99
115
  maxSessionReads = 100,
100
116
  concurrency = 4,
@@ -107,15 +123,29 @@ export async function selectRecentSessions({
107
123
  if (!Number.isInteger(limit) || limit < 1 || limit > 10) {
108
124
  throw new Error('SESSION_LIMIT_INVALID: limit must be an integer from 1 to 10')
109
125
  }
110
- if (createdAfter !== undefined && (!Number.isSafeInteger(createdAfter) || createdAfter < 0)) {
126
+ if (scope !== 'exact-cwd' && scope !== 'dsh-history') {
127
+ throw new Error('SESSION_SELECTION_SCOPE_INVALID: scope must be exact-cwd or dsh-history')
128
+ }
129
+ if (!Number.isSafeInteger(maxSessionReads) || maxSessionReads < 1) {
130
+ throw new Error('SESSION_READ_BUDGET_INVALID: maxSessionReads must be a positive integer')
131
+ }
132
+ if (!Number.isSafeInteger(concurrency) || concurrency < 1) {
133
+ throw new Error('SESSION_READ_CONCURRENCY_INVALID: concurrency must be a positive integer')
134
+ }
135
+ if (createdAfter !== undefined && !validTimestamp(createdAfter)) {
111
136
  throw new Error('SESSION_CREATED_AFTER_INVALID: createdAfter must be a valid timestamp')
112
137
  }
138
+ signal?.throwIfAborted()
113
139
  const listed = typeof sessionQuery.filterSessions === 'function'
114
140
  ? await sessionQuery.filterSessions([
115
- { kind: 'cwd', values: [projectRoot] },
141
+ ...(scope === 'exact-cwd' ? [{ kind: 'cwd', values: [projectRoot] }] : []),
116
142
  ...(createdAfter === undefined ? [] : [{ kind: 'created-at', from: createdAfter }]),
117
143
  ], signal)
118
144
  : await sessionQuery.listSessions(signal)
145
+ signal?.throwIfAborted()
146
+ if (!Array.isArray(listed)) {
147
+ throw new Error('DSH_SESSION_QUERY_INVALID: Session Query returned an invalid Session list')
148
+ }
119
149
  const excludedCounts = {
120
150
  outsideWorkspace: 0,
121
151
  beforeCreatedAfter: 0,
@@ -129,11 +159,23 @@ export async function selectRecentSessions({
129
159
  harborInternal: 0,
130
160
  empty: 0,
131
161
  unreadable: 0,
162
+ invalidHeader: 0,
163
+ duplicate: 0,
132
164
  }
133
165
  const candidates = []
134
- for (const record of Array.isArray(listed) ? listed : []) {
166
+ const seenSessionIds = new Set()
167
+ for (const record of listed) {
135
168
  const header = record?.header ?? {}
136
- if (!sameProjectRoot(header.cwd, projectRoot)) {
169
+ if (!validSourceHeader(header)) {
170
+ excludedCounts.invalidHeader += 1
171
+ continue
172
+ }
173
+ if (seenSessionIds.has(header.id)) {
174
+ excludedCounts.duplicate += 1
175
+ continue
176
+ }
177
+ seenSessionIds.add(header.id)
178
+ if (scope === 'exact-cwd' && !sameProjectRoot(header.cwd, projectRoot)) {
137
179
  excludedCounts.outsideWorkspace += 1
138
180
  } else if (header.id === currentSessionId) {
139
181
  excludedCounts.currentSession += 1
@@ -151,57 +193,43 @@ export async function selectRecentSessions({
151
193
  candidates.push(record)
152
194
  }
153
195
  }
154
- if (candidates.length > maxSessionReads) {
196
+ if (scope === 'exact-cwd' && candidates.length > maxSessionReads) {
155
197
  throw new Error(
156
198
  `SESSION_SELECTION_TOO_EXPENSIVE: ${candidates.length} exact Session reads exceed maxSessionReads=${maxSessionReads}; preview again with createdAfter to narrow the scan`,
157
199
  )
158
200
  }
159
201
 
160
- const snapshots = await mapConcurrent(candidates, concurrency, async record => {
202
+ // SessionRecord exposes creation time, not last activity. Bound quick-start
203
+ // reads by the newest-created candidate window, then rank the exact snapshots
204
+ // by activity. Explicit scan metadata prevents claiming a global activity rank.
205
+ if (scope === 'dsh-history') {
206
+ candidates.sort((left, right) => (
207
+ right.header.createdAt - left.header.createdAt
208
+ || left.header.id.localeCompare(right.header.id)
209
+ ))
210
+ }
211
+ const inspectRecord = async record => {
212
+ signal?.throwIfAborted()
161
213
  const snapshot = await sessionQuery.readSession(record.header.id)
162
- if (snapshot?.session?.id !== record.header.id) {
214
+ signal?.throwIfAborted()
215
+ if (!validSourceHeader(snapshot?.session) || !Array.isArray(snapshot?.events)) {
216
+ throw new Error('Session Query returned an invalid Session snapshot')
217
+ }
218
+ if (JSON.stringify(canonicalize(sessionHeaderIdentity(snapshot.session)))
219
+ !== JSON.stringify(canonicalize(sessionHeaderIdentity(record.header)))) {
163
220
  throw new Error('Session Query returned a mismatched Session header')
164
221
  }
165
- if (!sameProjectRoot(snapshot.session.cwd, projectRoot)) {
222
+ if (scope === 'exact-cwd' && !sameProjectRoot(snapshot.session.cwd, projectRoot)) {
166
223
  throw new Error('Session Query changed the Session workspace boundary')
167
224
  }
168
- return snapshot
169
- })
170
- const eligible = []
171
- for (const outcome of snapshots) {
172
- if (outcome.status === 'rejected') {
173
- excludedCounts.unreadable += 1
174
- continue
175
- }
176
- const snapshot = outcome.value
177
225
  const index = foldSessionDiagnosticIndex(snapshot.events, snapshot.session)
178
- if (index.lastSeq === null) {
179
- excludedCounts.empty += 1
180
- continue
181
- }
182
- if (index.openTurn) {
183
- excludedCounts.openTurn += 1
184
- continue
185
- }
186
- if (index.lastTurnReason === 'aborted') {
187
- excludedCounts.userAborted += 1
188
- continue
189
- }
190
- if (index.humanMessageCount < 1) {
191
- excludedCounts.noDirectHumanInput += 1
192
- continue
193
- }
194
- if (index.assistantMessageCount < 1) {
195
- excludedCounts.noAssistantOutput += 1
196
- continue
197
- }
198
- if (index.hasHarborToolCall) {
199
- excludedCounts.harborInternal += 1
200
- continue
201
- }
202
- if (/harbor/i.test(String(index.effectiveAgentPreset ?? ''))) {
203
- excludedCounts.harborInternal += 1
204
- continue
226
+ if (index.lastSeq === null) return { excluded: 'empty' }
227
+ if (index.openTurn) return { excluded: 'openTurn' }
228
+ if (index.lastTurnReason === 'aborted') return { excluded: 'userAborted' }
229
+ if (index.humanMessageCount < 1) return { excluded: 'noDirectHumanInput' }
230
+ if (index.assistantMessageCount < 1) return { excluded: 'noAssistantOutput' }
231
+ if (index.hasHarborToolCall || /harbor/i.test(String(index.effectiveAgentPreset ?? ''))) {
232
+ return { excluded: 'harborInternal' }
205
233
  }
206
234
  const header = sessionHeaderIdentity(snapshot.session, index.effectiveAgentPreset)
207
235
  const sourceDigest = canonicalDigest(
@@ -212,7 +240,7 @@ export async function selectRecentSessions({
212
240
  { id: snapshot.session.id, header },
213
241
  'harbor-dsh-session-source-ref-v1',
214
242
  )
215
- eligible.push({
243
+ return { selected: {
216
244
  rawSessionId: snapshot.session.id,
217
245
  header,
218
246
  events: snapshot.events,
@@ -221,25 +249,62 @@ export async function selectRecentSessions({
221
249
  sourceRef,
222
250
  capturedThroughSeq: index.lastSeq,
223
251
  trialId: `session-${sourceRef.slice('sha256:'.length, 'sha256:'.length + 12)}`,
224
- })
252
+ } }
253
+ }
254
+ const eligible = []
255
+ const readBudget = Math.min(candidates.length, maxSessionReads)
256
+ let readCount = 0
257
+ // Quick experience needs a few useful examples, not every transcript. Stop
258
+ // after a small concurrent batch supplies enough, without losing the cap.
259
+ while (readCount < readBudget) {
260
+ const batch = candidates.slice(readCount, Math.min(readCount + concurrency, readBudget))
261
+ const snapshots = await mapConcurrent(batch, concurrency, inspectRecord)
262
+ readCount += batch.length
263
+ signal?.throwIfAborted()
264
+ for (const outcome of snapshots) {
265
+ if (outcome.status === 'rejected') excludedCounts.unreadable += 1
266
+ else if (outcome.value.excluded) excludedCounts[outcome.value.excluded] += 1
267
+ else eligible.push(outcome.value.selected)
268
+ }
269
+ if (scope === 'dsh-history' && eligible.length >= limit) break
270
+ }
271
+ const scan = {
272
+ scope,
273
+ listedCount: listed.length,
274
+ candidateCount: candidates.length,
275
+ readCount,
276
+ unscannedCount: candidates.length - readCount,
277
+ partial: candidates.length > readCount,
278
+ windowOrder: scope === 'dsh-history' ? 'created-at-desc' : 'all-candidates',
279
+ selectionOrder: 'last-activity-desc',
225
280
  }
226
281
  eligible.sort((left, right) => (
227
282
  right.index.lastActivityAt - left.index.lastActivityAt
228
283
  || left.sourceRef.localeCompare(right.sourceRef)
229
284
  ))
230
285
  const selected = eligible.slice(0, limit)
286
+ const warnings = []
287
+ if (excludedCounts.unreadable) {
288
+ warnings.push(`${excludedCounts.unreadable} Session(s) could not be read and were excluded.`)
289
+ }
290
+ if (excludedCounts.invalidHeader) {
291
+ warnings.push(`${excludedCounts.invalidHeader} Session record(s) had invalid metadata and were excluded.`)
292
+ }
293
+ if (scan.partial) {
294
+ warnings.push(`Only the ${scan.readCount} most recently created eligible Session candidates were checked; ${scan.unscannedCount} older candidate(s) were not read. Results are ranked by activity within this window, not across all DSH history.`)
295
+ }
231
296
  return {
232
297
  selected,
233
298
  publicSelected: selected.map(publicSelection),
234
299
  excludedCounts,
235
- warnings: excludedCounts.unreadable
236
- ? [`${excludedCounts.unreadable} Session(s) could not be read and were excluded.`]
237
- : [],
300
+ scan,
301
+ warnings,
238
302
  }
239
303
  }
240
304
 
241
305
  export function verifySessionSnapshot(expected, snapshot, projectRoot) {
242
- if (snapshot?.session?.id !== expected.rawSessionId || !sameProjectRoot(snapshot?.session?.cwd, projectRoot)) {
306
+ if (!validSourceHeader(snapshot?.session) || !Array.isArray(snapshot?.events)
307
+ || snapshot.session.id !== expected.rawSessionId || !sameProjectRoot(snapshot.session.cwd, projectRoot)) {
243
308
  return false
244
309
  }
245
310
  const index = foldSessionDiagnosticIndex(snapshot.events, snapshot.session)
@@ -0,0 +1,46 @@
1
+ import { randomBytes } from 'node:crypto'
2
+ import { localObjectDigest } from './interaction-objects.js'
3
+
4
+ export const MAX_SELECTED_TRIALS = 1000
5
+
6
+ /** Server-owned, frozen IDs/revisions; a query can never silently expand later. */
7
+ export class TrialSelectionRegistry {
8
+ constructor({ now = Date.now, ttlMs = 15 * 60_000, maxEntries = 256 } = {}) {
9
+ this.now = now
10
+ this.ttlMs = ttlMs
11
+ this.maxEntries = maxEntries
12
+ this.entries = new Map()
13
+ }
14
+ issue({ sessionId, projectRoot, workspace, job, mode, filters, trials }) {
15
+ for (const [id, entry] of this.entries) if (entry.expiresAtMs <= this.now()) this.entries.delete(id)
16
+ if (!['explicit', 'query-snapshot'].includes(mode) || !trials?.length || trials.length > MAX_SELECTED_TRIALS) throw new Error('HARBOR_SELECTION_INVALID: Select 1–1000 Trials.')
17
+ if (this.entries.size >= this.maxEntries) throw new Error('HARBOR_SELECTION_LIMIT: Too many selections; wait for old selections to expire.')
18
+ const members = trials.map(trial => ({ id: trial.id, revision: localObjectDigest(trial) }))
19
+ if (new Set(members.map(item => item.id)).size !== members.length || members.some(item => !item.id)) throw new Error('HARBOR_SELECTION_INVALID: Trial identities must be unique.')
20
+ const filterDigest = localObjectDigest(filters ?? {})
21
+ const sourceDigest = localObjectDigest({ mode, filterDigest, members })
22
+ const id = `hsel_${randomBytes(18).toString('base64url')}`
23
+ const ref = { kind: 'trial-set', id, job, stage: 'judge', sourceDigest, selectionCount: members.length }
24
+ const entry = { ref, sessionId: String(sessionId), projectRoot, workspace, job, mode, filterDigest, members, expiresAtMs: this.now() + this.ttlMs }
25
+ this.entries.set(id, structuredClone(entry))
26
+ return { ref, count: members.length, mode, filterDigest, expiresAt: new Date(entry.expiresAtMs).toISOString() }
27
+ }
28
+ owned(ref, owner) {
29
+ const entry = this.entries.get(ref.id)
30
+ if (!entry || entry.expiresAtMs <= this.now()) throw new Error('HARBOR_SELECTION_EXPIRED: Select the Trial set again.')
31
+ if (entry.sessionId !== String(owner.sessionId) || entry.projectRoot !== owner.projectRoot || entry.workspace !== owner.workspace || entry.job !== ref.job || entry.ref.sourceDigest !== ref.sourceDigest || entry.ref.selectionCount !== ref.selectionCount) throw new Error('HARBOR_SELECTION_DENIED: Selection does not belong to this Session and Job.')
32
+ return entry
33
+ }
34
+ memberIds(ref, owner) {
35
+ return this.owned(ref, owner).members.map(member => member.id)
36
+ }
37
+ resolve(ref, owner, currentTrials) {
38
+ const entry = this.owned(ref, owner)
39
+ const byId = new Map(currentTrials.map(trial => [trial.id, trial]))
40
+ for (const member of entry.members) {
41
+ const current = byId.get(member.id)
42
+ if (!current || localObjectDigest(current) !== member.revision) throw new Error('HARBOR_CONTEXT_STALE_SELECTION: Selected Trials changed. Reselect before continuing.')
43
+ }
44
+ return { ref: { ...entry.ref }, value: { mode: entry.mode, filterDigest: entry.filterDigest, count: entry.members.length, members: entry.members, trials: entry.members.map(member => byId.get(member.id)) } }
45
+ }
46
+ }