dsh-harbor-evolution 0.9.2 → 0.9.4

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.
@@ -0,0 +1,83 @@
1
+ import { constants } from 'node:fs'
2
+ import { lstat, mkdir, open, link, unlink } from 'node:fs/promises'
3
+ import { createHash, randomUUID } from 'node:crypto'
4
+ import path from 'node:path'
5
+
6
+ const MAX_BYTES = 1024 * 1024
7
+ const ID = /^(?:hctx_[A-Za-z0-9_-]{20,80}|hsel_[A-Za-z0-9_-]{24})$/
8
+ const failure = () => new Error('HARBOR_CONTEXT_STORAGE_UNAVAILABLE: Page context could not be safely saved or read. Keep the draft and retry.')
9
+
10
+ async function directory(projectRoot, sessionId, create) {
11
+ if (typeof sessionId !== 'string' || !sessionId || sessionId.length > 240) throw failure()
12
+ let current = path.resolve(projectRoot)
13
+ const sessionKey = createHash('sha256').update(sessionId).digest('hex')
14
+ for (const segment of ['.harbor', 'private', 'page-contexts', sessionKey]) {
15
+ current = path.join(current, segment)
16
+ if (create) await mkdir(current, { mode: 0o700 }).catch(error => { if (error.code !== 'EEXIST') throw error })
17
+ const details = await lstat(current)
18
+ if (!details.isDirectory() || details.isSymbolicLink()) throw failure()
19
+ if (process.platform !== 'win32' && ((typeof process.getuid === 'function' && details.uid !== process.getuid()) || (details.mode & (segment === '.harbor' ? 0o022 : 0o077)))) throw failure()
20
+ }
21
+ return current
22
+ }
23
+
24
+ /** Read one immutable Session-owned record; absence never selects a replacement. */
25
+ export async function readContextSnapshot(projectRoot, sessionId, id) {
26
+ if (!ID.test(id ?? '')) throw failure()
27
+ let handle
28
+ try {
29
+ const root = await directory(projectRoot, sessionId, false)
30
+ handle = await open(path.join(root, `${id}.json`), constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK)
31
+ const details = await handle.stat()
32
+ if (!details.isFile() || details.size > MAX_BYTES) throw failure()
33
+ if (process.platform !== 'win32' && ((typeof process.getuid === 'function' && details.uid !== process.getuid()) || (details.mode & 0o077))) throw failure()
34
+ const record = JSON.parse(await handle.readFile('utf8'))
35
+ if (record.schema !== 'harbor-context-snapshot/v1' || record.sessionId !== sessionId || record.id !== id || record.projectRoot !== path.resolve(projectRoot)) throw failure()
36
+ return record.value
37
+ } catch (error) {
38
+ if (error.code === 'ENOENT') return undefined
39
+ throw failure()
40
+ } finally { await handle?.close() }
41
+ }
42
+
43
+ async function publishImmutable(file, text) {
44
+ const temporary = path.join(path.dirname(file), `.context-${randomUUID()}.tmp`)
45
+ let handle
46
+ try {
47
+ handle = await open(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600)
48
+ await handle.writeFile(text)
49
+ await handle.sync()
50
+ await handle.close(); handle = undefined
51
+ try { await link(temporary, file); return true }
52
+ catch (error) {
53
+ if (error.code !== 'EEXIST') throw error
54
+ return false
55
+ }
56
+ } catch { throw failure() }
57
+ finally {
58
+ await handle?.close()
59
+ await unlink(temporary).catch(error => { if (error.code !== 'ENOENT') throw error })
60
+ }
61
+ }
62
+
63
+ /** Persist only validated identity/revision data, never artifact bodies or credentials. */
64
+ export async function writeContextSnapshot(projectRoot, sessionId, id, value) {
65
+ if (!ID.test(id ?? '')) throw failure()
66
+ const text = JSON.stringify({ schema: 'harbor-context-snapshot/v1', sessionId, projectRoot: path.resolve(projectRoot), id, value })
67
+ if (Buffer.byteLength(text) > MAX_BYTES) throw failure()
68
+ const root = await directory(projectRoot, sessionId, true)
69
+ const ignore = path.join(root, '..', '.gitignore')
70
+ await publishImmutable(ignore, '*\n!.gitignore\n')
71
+ let ignoreHandle
72
+ try {
73
+ ignoreHandle = await open(ignore, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK)
74
+ const details = await ignoreHandle.stat()
75
+ if (!details.isFile() || details.size > 1024 || (process.platform !== 'win32' && ((typeof process.getuid === 'function' && details.uid !== process.getuid()) || (details.mode & 0o077)))) throw failure()
76
+ if (await ignoreHandle.readFile('utf8') !== '*\n!.gitignore\n') throw failure()
77
+ } catch { throw failure() }
78
+ finally { await ignoreHandle?.close() }
79
+ if (!await publishImmutable(path.join(root, `${id}.json`), text)) {
80
+ const existing = await readContextSnapshot(projectRoot, sessionId, id)
81
+ if (JSON.stringify(existing) !== JSON.stringify(value)) throw failure()
82
+ }
83
+ }
@@ -97,10 +97,10 @@ export class HistoricalWebController {
97
97
  ownerSessionId: `web-historical:${ownerSessionId}:${this.randomId()}`,
98
98
  }
99
99
  const preview = await this.sessionDiagnostic.previewWithIdentity({
100
- limit: args.limit === undefined ? 10 : args.limit,
100
+ limit: args.limit === undefined ? 3 : args.limit,
101
101
  createdAfter: args.createdAfter,
102
102
  includeFeedback: args.includeFeedback !== false,
103
- }, identity, { config: resolved.config })
103
+ }, identity, { config: resolved.config, currentSessionId: ownerSessionId, scope: 'dsh-history' })
104
104
  const { selectionToken, ...visible } = preview
105
105
  this.previews.set(previewId, {
106
106
  previewId,
package/lib/service.js CHANGED
@@ -4,7 +4,8 @@ import path from 'node:path'
4
4
 
5
5
  import { loadModelBinding } from './candidate.js'
6
6
  import { LOCAL_OBJECT_KINDS, interactionObjectCatalog, resolveCatalogSelection } from './interaction-objects.js'
7
- import { TrialSelectionRegistry, MAX_SELECTED_TRIALS } from './trial-selection.js'
7
+ import { TrialSelectionRegistry, MAX_SELECTED_TRIALS, frozenTrialSelection, resolveFrozenTrialSelection } from './trial-selection.js'
8
+ import { readContextSnapshot, writeContextSnapshot } from './context-snapshots.js'
8
9
  import { ActionDraftController } from './action-drafts.js'
9
10
  import { DiagnosticRunner } from './diagnostic-runner.js'
10
11
  import { prepareEvaluatorSaveHistory, readEvaluatorSave, recordEvaluatorSave } from './evaluator-saves.js'
@@ -1202,7 +1203,8 @@ export class EvolutionService {
1202
1203
  if (!job) throw new Error('HARBOR_CONTEXT_INVALID: a Trial context requires a Job')
1203
1204
  trialState = await readTrialDetail(config, { job, trial })
1204
1205
  }
1205
- const objectState = await interactionObjectState(config, normalized, job, jobState, trialState, await this._selectionEntries(normalized, config))
1206
+ const selections = await this._selectionEntries(normalized, config)
1207
+ const objectState = await interactionObjectState(config, normalized, job, jobState, trialState, selections)
1206
1208
  validateInteractionFocus(normalized, trialState)
1207
1209
  validateInteractionObjects(normalized, job, jobState, trialState, objectState)
1208
1210
  // artifactRevision is Host-owned. A browser-supplied value is only an
@@ -1213,11 +1215,14 @@ export class EvolutionService {
1213
1215
  flags: interactionContextSummary(normalized, job, jobState, trialState, objectState).flags,
1214
1216
  artifactRevision: interactionRevision(jobState, trialState, objectState),
1215
1217
  }
1216
- return this.uiContexts.issue({
1218
+ const issued = this.uiContexts.issue({
1217
1219
  sessionId,
1218
1220
  context,
1219
1221
  projectRoot: config.projectRoot,
1220
1222
  })
1223
+ const entry = this.uiContexts.resolve({ contextSnapshotId: issued.contextSnapshotId, sessionId, projectRoot: config.projectRoot })
1224
+ await writeContextSnapshot(config.projectRoot, sessionId, issued.contextSnapshotId, entry)
1225
+ return { ...issued, durable: true, selectedTrials: selections.flatMap(entry => entry.value.members.map(member => member.id)) }
1221
1226
  }
1222
1227
 
1223
1228
  async resolveUiContext(args, owner) {
@@ -1229,7 +1234,7 @@ export class EvolutionService {
1229
1234
  }
1230
1235
 
1231
1236
  async _resolveUiContext(args, owner) {
1232
- const entry = this.uiContexts.resolve({
1237
+ const entry = await this._pageContextEntry({
1233
1238
  contextSnapshotId: args?.contextSnapshotId,
1234
1239
  sessionId: owner.sessionId,
1235
1240
  projectRoot: owner.projectRoot,
@@ -1309,6 +1314,19 @@ export class EvolutionService {
1309
1314
  }
1310
1315
  }
1311
1316
 
1317
+ async _pageContextEntry(args) {
1318
+ try { return this.uiContexts.resolve(args) }
1319
+ catch (error) {
1320
+ if (error.code !== 'HARBOR_CONTEXT_EXPIRED') throw error
1321
+ }
1322
+ const saved = await readContextSnapshot(args.projectRoot, String(args.sessionId), args.contextSnapshotId)
1323
+ if (!saved) throw new Error('HARBOR_CONTEXT_EXPIRED: The original page snapshot is unavailable. Open the intended page and send again.')
1324
+ const context = normalizeHarborUiContext(saved.context, String(args.sessionId))
1325
+ const contextDigest = `sha256:${createHash('sha256').update(JSON.stringify(context)).digest('hex')}`
1326
+ if (saved.token !== args.contextSnapshotId || saved.sessionId !== String(args.sessionId) || saved.projectRoot !== path.resolve(args.projectRoot) || saved.digest !== contextDigest) throw new Error('HARBOR_CONTEXT_INVALID: Saved page context failed identity verification.')
1327
+ return { ...saved, context }
1328
+ }
1329
+
1312
1330
  async resolveBrowserUiContext(args = {}) {
1313
1331
  const sessionId = String(args.sessionId ?? '').trim()
1314
1332
  if (!sessionId) throw new Error('HARBOR_CONTEXT_SESSION_MISMATCH: sessionId is required')
@@ -1537,7 +1555,18 @@ export class EvolutionService {
1537
1555
  if (!Array.isArray(ids) || new Set(ids).size !== ids.length || ids.some(id => typeof id !== 'string')) throw new Error('HARBOR_SELECTION_INVALID: Fixed Trial IDs are required.')
1538
1556
  const selected = trials.filter(trial => ids.includes(trial.id))
1539
1557
  if (selected.length !== ids.length) throw new Error('HARBOR_SELECTION_DENIED: A selected Trial is missing or outside this Job/filter.')
1540
- return this.trialSelections.issue({ sessionId, projectRoot: path.resolve(config.projectRoot), workspace: config.workspaceId, job: args.job, mode: args.mode, filters, trials: selected })
1558
+ const owner = { sessionId, projectRoot: path.resolve(config.projectRoot), workspace: config.workspaceId }
1559
+ const issued = this.trialSelections.issue({ ...owner, job: args.job, mode: args.mode, filters, trials: selected })
1560
+ await writeContextSnapshot(owner.projectRoot, String(sessionId), issued.ref.id, this.trialSelections.owned(issued.ref, owner))
1561
+ return { ...issued, durable: true }
1562
+ }
1563
+
1564
+ async _ownedTrialSelection(ref, owner) {
1565
+ try { return this.trialSelections.owned(ref, owner) }
1566
+ catch (error) { if (!error.message.startsWith('HARBOR_SELECTION_EXPIRED:')) throw error }
1567
+ const saved = await readContextSnapshot(owner.projectRoot, String(owner.sessionId), ref.id)
1568
+ if (!saved) throw new Error('HARBOR_SELECTION_EXPIRED: The original Trial selection is unavailable. Select the intended Trials and send again.')
1569
+ return frozenTrialSelection(saved, ref, owner)
1541
1570
  }
1542
1571
 
1543
1572
  async _selectionEntries(context, config) {
@@ -1545,9 +1574,10 @@ export class EvolutionService {
1545
1574
  if (!refs.length) return []
1546
1575
  const owner = { sessionId: context.sessionId, projectRoot: path.resolve(config.projectRoot), workspace: context.workspace }
1547
1576
  return Promise.all(refs.map(async ref => {
1548
- const trialIds = this.trialSelections.memberIds(ref, owner)
1577
+ const entry = await this._ownedTrialSelection(ref, owner)
1578
+ const trialIds = entry.members.map(member => member.id)
1549
1579
  const trials = await this._allSelectionTrials(config, { job: context.route.params.job, trialIds })
1550
- return this.trialSelections.resolve(ref, owner, trials)
1580
+ return resolveFrozenTrialSelection(entry, ref, owner, trials)
1551
1581
  }))
1552
1582
  }
1553
1583
 
@@ -1557,8 +1587,9 @@ export class EvolutionService {
1557
1587
  if (owner.projectRoot !== path.resolve(config.projectRoot)) throw new Error('HARBOR_SELECTION_DENIED: Session project changed.')
1558
1588
  const ref = { kind: 'trial-set', id: args.id, job: args.job, stage: 'judge', sourceDigest: args.sourceDigest, selectionCount: Number(args.selectionCount) }
1559
1589
  const selectionOwner = { ...owner, workspace: config.workspaceId }
1560
- const trialIds = this.trialSelections.memberIds(ref, selectionOwner)
1561
- const value = this.trialSelections.resolve(ref, selectionOwner, await this._allSelectionTrials(config, { job: args.job, trialIds }))
1590
+ const entry = await this._ownedTrialSelection(ref, selectionOwner)
1591
+ const trialIds = entry.members.map(member => member.id)
1592
+ const value = resolveFrozenTrialSelection(entry, ref, selectionOwner, await this._allSelectionTrials(config, { job: args.job, trialIds }))
1562
1593
  return { ref: value.ref, count: value.value.count, mode: value.value.mode, members: value.value.members }
1563
1594
  }
1564
1595
 
@@ -1606,7 +1637,7 @@ export class EvolutionService {
1606
1637
  try {
1607
1638
  // The bounded model-facing evidence reader is NOT execution authority.
1608
1639
  // Resolve the Host-owned token and every frozen member independently.
1609
- const { context } = this.uiContexts.resolve({ contextSnapshotId: draft.contextSnapshotId, ...owner })
1640
+ const { context } = await this._pageContextEntry({ contextSnapshotId: draft.contextSnapshotId, ...owner })
1610
1641
  const { config } = await this._webContext({ workspace: context.workspace, sessionId: owner.sessionId })
1611
1642
  if (path.resolve(config.projectRoot) !== owner.projectRoot) throw new Error('HARBOR_ACTION_DENIED: Session project changed.')
1612
1643
  const job = context.route.params.job ?? context.object?.job
@@ -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,8 +224,9 @@ export class SessionDiagnosticService {
215
224
  schema_version: 1,
216
225
  capability: 'historical-generation-evaluation',
217
226
  jobKind: 'historical-generation-evaluation',
218
- scope: 'exact-cwd',
227
+ scope,
219
228
  order: 'last-activity-desc',
229
+ ...(result.scan ? { scan: result.scan } : {}),
220
230
  ...(createdAfter === undefined ? {} : { createdAfter: new Date(createdAfter).toISOString() }),
221
231
  executionMode: 'observe-existing',
222
232
  promotionEligible: false,
@@ -269,7 +279,10 @@ export class SessionDiagnosticService {
269
279
  }
270
280
  const snapshots = reads.map(item => item.value)
271
281
  if (snapshots.some((snapshot, index) => (
272
- !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)
273
286
  ))) {
274
287
  throw new Error('SESSION_SAMPLE_CHANGED: at least one selected Session changed after Preview; no Batch was written, preview again')
275
288
  }
@@ -303,6 +316,8 @@ export class SessionDiagnosticService {
303
316
  observations,
304
317
  limit: selectedState.parameters.limit,
305
318
  createdAfter: selectedState.parameters.createdAfter,
319
+ scope: selectedState.parameters.scope,
320
+ scan: selectedState.parameters.scan,
306
321
  now: this.now(),
307
322
  })
308
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',
@@ -34,12 +34,25 @@ function sessionHeaderIdentity(header, effectiveAgentPreset) {
34
34
  }
35
35
 
36
36
  function sameProjectRoot(value, projectRoot) {
37
- if (typeof value !== 'string' || !path.isAbsolute(value)) return false
37
+ if (!validSourceRoot(value) || !validSourceRoot(projectRoot)) return false
38
38
  return path.resolve(value) === path.resolve(projectRoot)
39
39
  }
40
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
+
41
54
  function isoTime(value) {
42
- return Number.isSafeInteger(value) && value > 0 ? new Date(value).toISOString() : null
55
+ return validTimestamp(value) && value > 0 ? new Date(value).toISOString() : null
43
56
  }
44
57
 
45
58
  function safeIdentity(value, rawSessionId) {
@@ -97,6 +110,7 @@ export async function selectRecentSessions({
97
110
  sessionQuery,
98
111
  projectRoot,
99
112
  currentSessionId,
113
+ scope = 'exact-cwd',
100
114
  limit = 10,
101
115
  maxSessionReads = 100,
102
116
  concurrency = 4,
@@ -109,15 +123,29 @@ export async function selectRecentSessions({
109
123
  if (!Number.isInteger(limit) || limit < 1 || limit > 10) {
110
124
  throw new Error('SESSION_LIMIT_INVALID: limit must be an integer from 1 to 10')
111
125
  }
112
- 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)) {
113
136
  throw new Error('SESSION_CREATED_AFTER_INVALID: createdAfter must be a valid timestamp')
114
137
  }
138
+ signal?.throwIfAborted()
115
139
  const listed = typeof sessionQuery.filterSessions === 'function'
116
140
  ? await sessionQuery.filterSessions([
117
- { kind: 'cwd', values: [projectRoot] },
141
+ ...(scope === 'exact-cwd' ? [{ kind: 'cwd', values: [projectRoot] }] : []),
118
142
  ...(createdAfter === undefined ? [] : [{ kind: 'created-at', from: createdAfter }]),
119
143
  ], signal)
120
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
+ }
121
149
  const excludedCounts = {
122
150
  outsideWorkspace: 0,
123
151
  beforeCreatedAfter: 0,
@@ -131,11 +159,23 @@ export async function selectRecentSessions({
131
159
  harborInternal: 0,
132
160
  empty: 0,
133
161
  unreadable: 0,
162
+ invalidHeader: 0,
163
+ duplicate: 0,
134
164
  }
135
165
  const candidates = []
136
- for (const record of Array.isArray(listed) ? listed : []) {
166
+ const seenSessionIds = new Set()
167
+ for (const record of listed) {
137
168
  const header = record?.header ?? {}
138
- 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)) {
139
179
  excludedCounts.outsideWorkspace += 1
140
180
  } else if (header.id === currentSessionId) {
141
181
  excludedCounts.currentSession += 1
@@ -153,57 +193,43 @@ export async function selectRecentSessions({
153
193
  candidates.push(record)
154
194
  }
155
195
  }
156
- if (candidates.length > maxSessionReads) {
196
+ if (scope === 'exact-cwd' && candidates.length > maxSessionReads) {
157
197
  throw new Error(
158
198
  `SESSION_SELECTION_TOO_EXPENSIVE: ${candidates.length} exact Session reads exceed maxSessionReads=${maxSessionReads}; preview again with createdAfter to narrow the scan`,
159
199
  )
160
200
  }
161
201
 
162
- 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()
163
213
  const snapshot = await sessionQuery.readSession(record.header.id)
164
- 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)))) {
165
220
  throw new Error('Session Query returned a mismatched Session header')
166
221
  }
167
- if (!sameProjectRoot(snapshot.session.cwd, projectRoot)) {
222
+ if (scope === 'exact-cwd' && !sameProjectRoot(snapshot.session.cwd, projectRoot)) {
168
223
  throw new Error('Session Query changed the Session workspace boundary')
169
224
  }
170
- return snapshot
171
- })
172
- const eligible = []
173
- for (const outcome of snapshots) {
174
- if (outcome.status === 'rejected') {
175
- excludedCounts.unreadable += 1
176
- continue
177
- }
178
- const snapshot = outcome.value
179
225
  const index = foldSessionDiagnosticIndex(snapshot.events, snapshot.session)
180
- if (index.lastSeq === null) {
181
- excludedCounts.empty += 1
182
- continue
183
- }
184
- if (index.openTurn) {
185
- excludedCounts.openTurn += 1
186
- continue
187
- }
188
- if (index.lastTurnReason === 'aborted') {
189
- excludedCounts.userAborted += 1
190
- continue
191
- }
192
- if (index.humanMessageCount < 1) {
193
- excludedCounts.noDirectHumanInput += 1
194
- continue
195
- }
196
- if (index.assistantMessageCount < 1) {
197
- excludedCounts.noAssistantOutput += 1
198
- continue
199
- }
200
- if (index.hasHarborToolCall) {
201
- excludedCounts.harborInternal += 1
202
- continue
203
- }
204
- if (/harbor/i.test(String(index.effectiveAgentPreset ?? ''))) {
205
- excludedCounts.harborInternal += 1
206
- 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' }
207
233
  }
208
234
  const header = sessionHeaderIdentity(snapshot.session, index.effectiveAgentPreset)
209
235
  const sourceDigest = canonicalDigest(
@@ -214,7 +240,7 @@ export async function selectRecentSessions({
214
240
  { id: snapshot.session.id, header },
215
241
  'harbor-dsh-session-source-ref-v1',
216
242
  )
217
- eligible.push({
243
+ return { selected: {
218
244
  rawSessionId: snapshot.session.id,
219
245
  header,
220
246
  events: snapshot.events,
@@ -223,25 +249,62 @@ export async function selectRecentSessions({
223
249
  sourceRef,
224
250
  capturedThroughSeq: index.lastSeq,
225
251
  trialId: `session-${sourceRef.slice('sha256:'.length, 'sha256:'.length + 12)}`,
226
- })
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',
227
280
  }
228
281
  eligible.sort((left, right) => (
229
282
  right.index.lastActivityAt - left.index.lastActivityAt
230
283
  || left.sourceRef.localeCompare(right.sourceRef)
231
284
  ))
232
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
+ }
233
296
  return {
234
297
  selected,
235
298
  publicSelected: selected.map(publicSelection),
236
299
  excludedCounts,
237
- warnings: excludedCounts.unreadable
238
- ? [`${excludedCounts.unreadable} Session(s) could not be read and were excluded.`]
239
- : [],
300
+ scan,
301
+ warnings,
240
302
  }
241
303
  }
242
304
 
243
305
  export function verifySessionSnapshot(expected, snapshot, projectRoot) {
244
- 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)) {
245
308
  return false
246
309
  }
247
310
  const index = foldSessionDiagnosticIndex(snapshot.events, snapshot.session)