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.
- package/README.md +31 -8
- 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 +4018 -395
- 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 +107 -32
- package/lib/historical-run-lock.js +102 -0
- package/lib/historical-web.js +54 -18
- package/lib/interaction-objects.js +56 -0
- package/lib/model-runtime.js +48 -3
- package/lib/runtime-identity.js +0 -1
- package/lib/service.js +1427 -28
- package/lib/session-diagnostic.js +22 -8
- package/lib/session-materializer.js +42 -2
- package/lib/session-redaction.js +17 -31
- package/lib/session-selection.js +120 -55
- 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/schemas/historical-generation-batch.schema.json +23 -2
- package/skills/evolve-agent-with-harbor/SKILL.md +33 -4
|
@@ -157,10 +157,12 @@ export class SessionDiagnosticService {
|
|
|
157
157
|
}
|
|
158
158
|
|
|
159
159
|
async preview(args = {}, exec) {
|
|
160
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
|
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',
|
package/lib/session-redaction.js
CHANGED
|
@@ -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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
text
|
|
37
|
-
|
|
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
|
-
|
|
185
|
-
|
|
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
|
-
|
|
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) {
|
package/lib/session-selection.js
CHANGED
|
@@ -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 (
|
|
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
|
|
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
|
-
|
|
49
|
-
||
|
|
50
|
-
||
|
|
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 (
|
|
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
|
-
|
|
166
|
+
const seenSessionIds = new Set()
|
|
167
|
+
for (const record of listed) {
|
|
135
168
|
const header = record?.header ?? {}
|
|
136
|
-
if (!
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
180
|
-
|
|
181
|
-
}
|
|
182
|
-
if (index.
|
|
183
|
-
|
|
184
|
-
|
|
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
|
-
|
|
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
|
-
|
|
236
|
-
|
|
237
|
-
: [],
|
|
300
|
+
scan,
|
|
301
|
+
warnings,
|
|
238
302
|
}
|
|
239
303
|
}
|
|
240
304
|
|
|
241
305
|
export function verifySessionSnapshot(expected, snapshot, projectRoot) {
|
|
242
|
-
if (snapshot?.session
|
|
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
|
+
}
|