dsh-harbor-evolution 0.8.2 → 0.9.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +28 -6
- package/index.js +120 -19
- package/lib/action-drafts.js +443 -0
- package/lib/bounded-process.js +190 -0
- package/lib/candidate-runtime.js +160 -0
- package/lib/candidate.js +7 -11
- package/lib/client.js +4110 -292
- package/lib/composer-context.js +56 -0
- package/lib/credential-redaction.js +155 -0
- package/lib/dashboard.js +417 -98
- package/lib/diagnostic-observation.js +175 -0
- package/lib/diagnostic-runner.js +206 -0
- package/lib/evaluator-saves.js +129 -0
- package/lib/evolution.js +126 -32
- package/lib/historical-run-lock.js +102 -0
- package/lib/historical-web.js +52 -16
- package/lib/interaction-objects.js +56 -0
- package/lib/model-runtime.js +48 -3
- package/lib/process.js +27 -1
- package/lib/runtime-identity.js +0 -1
- package/lib/service.js +1427 -28
- package/lib/session-diagnostic.js +0 -1
- package/lib/session-redaction.js +17 -31
- package/lib/session-selection.js +5 -3
- package/lib/trial-selection.js +46 -0
- package/lib/ui-context.js +518 -0
- package/lib/web.js +32 -6
- package/lib/workbench-health.js +27 -0
- package/package.json +4 -4
- package/skills/evolve-agent-with-harbor/SKILL.md +33 -4
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { constants } from 'node:fs'
|
|
2
|
+
import { lstat, open, readFile, readlink } from 'node:fs/promises'
|
|
3
|
+
import { createHash } from 'node:crypto'
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
import { resolveWithin } from './evolution.js'
|
|
6
|
+
|
|
7
|
+
const phases = new Set(['queued', 'preparing-environment', 'preparing-agent', 'loading-observation', 'running-agent', 'running-adapter', 'running-integration', 'rendering', 'evaluating', 'completed', 'completed-unscored', 'candidate-quality-failed', 'infrastructure-error', 'evaluation-error', 'cancelled'])
|
|
8
|
+
const fail = () => { throw Object.assign(new Error('HARBOR_DIAGNOSTIC_OBSERVATION_UNSAFE: Diagnostic evidence is missing or unsafe.'), { code: 'HARBOR_DIAGNOSTIC_OBSERVATION_UNSAFE' }) }
|
|
9
|
+
|
|
10
|
+
export async function pinDiagnosticEnvironment(runProcess, environment) {
|
|
11
|
+
const env = { ...environment }
|
|
12
|
+
let endpoint = env.DOCKER_HOST
|
|
13
|
+
if (env.DOCKER_CONTEXT || !endpoint) {
|
|
14
|
+
let context = env.DOCKER_CONTEXT
|
|
15
|
+
if (!context) {
|
|
16
|
+
const selected = await runProcess('docker', ['context', 'show'], { env, timeoutMs: 5000, maxOutputBytes: 1024 })
|
|
17
|
+
if (selected.code !== 0) fail()
|
|
18
|
+
context = selected.stdout.trim()
|
|
19
|
+
}
|
|
20
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9_.-]{0,100}$/.test(context)) fail()
|
|
21
|
+
const resolved = await runProcess('docker', ['context', 'inspect', context, '--format', '{{.Endpoints.docker.Host}}'], { env, timeoutMs: 5000, maxOutputBytes: 4096 })
|
|
22
|
+
if (resolved.code !== 0) fail()
|
|
23
|
+
endpoint = resolved.stdout.trim()
|
|
24
|
+
}
|
|
25
|
+
if (!/^unix:\/\/\/[^\r\n\0]{1,4000}$/.test(endpoint)) throw Object.assign(new Error('HARBOR_DIAGNOSTIC_RUNTIME_UNSUPPORTED: Bounded diagnostics currently require a local Docker Unix socket. Remote/TLS contexts are not enabled because immutable transport and cleanup ownership cannot yet be proven; no run was started.'), { code: 'HARBOR_DIAGNOSTIC_RUNTIME_UNSUPPORTED' })
|
|
26
|
+
// Pin the endpoint, not merely its mutable context name. No TLS credentials
|
|
27
|
+
// are copied and editing Docker's context definition cannot redirect a run.
|
|
28
|
+
env.DOCKER_HOST = endpoint
|
|
29
|
+
delete env.DOCKER_CONTEXT
|
|
30
|
+
delete env.DOCKER_TLS
|
|
31
|
+
delete env.DOCKER_TLS_VERIFY
|
|
32
|
+
delete env.DOCKER_CERT_PATH
|
|
33
|
+
return env
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function readDiagnosticRuntimeIdentity({ runProcess, platform = process.platform, env }) {
|
|
37
|
+
let machine, processDomain = ''
|
|
38
|
+
if (platform === 'darwin') {
|
|
39
|
+
const response = await runProcess('ioreg', ['-rd1', '-c', 'IOPlatformExpertDevice'], { env, timeoutMs: 5000, maxOutputBytes: 64 * 1024 })
|
|
40
|
+
if (response.code !== 0) fail()
|
|
41
|
+
machine = response.stdout.match(/"IOPlatformUUID"\s*=\s*"([a-f0-9-]{36})"/i)?.[1] ?? ''
|
|
42
|
+
} else if (platform === 'linux') {
|
|
43
|
+
const info = await lstat('/etc/machine-id')
|
|
44
|
+
if (!info.isFile() || info.size > 1024) fail()
|
|
45
|
+
machine = (await readFile('/etc/machine-id', 'utf8')).trim()
|
|
46
|
+
const namespace = await readlink('/proc/self/ns/pid')
|
|
47
|
+
const boot = (await readFile('/proc/sys/kernel/random/boot_id', 'utf8')).trim()
|
|
48
|
+
if (!/^pid:\[[0-9]+\]$/.test(namespace) || !/^[a-f0-9-]{36}$/.test(boot)) fail()
|
|
49
|
+
processDomain = `:${namespace}:${boot}`
|
|
50
|
+
} else fail()
|
|
51
|
+
if (!/^[a-f0-9-]{16,64}$/i.test(machine)) fail()
|
|
52
|
+
const docker = await runProcess('docker', ['info', '--format', '{{.ID}}'], { env, timeoutMs: 10_000, maxOutputBytes: 1024 })
|
|
53
|
+
if (docker.code !== 0 || !/^[A-Za-z0-9:_.-]{8,200}$/.test(docker.stdout.trim())) fail()
|
|
54
|
+
const digest = value => `sha256:${createHash('sha256').update(value).digest('hex')}`
|
|
55
|
+
// Do not persist device IDs, daemon endpoints, context names or credentials.
|
|
56
|
+
return { hostIdentity: digest(`${platform}:${machine.toLowerCase()}${processDomain}`), dockerIdentity: digest(docker.stdout.trim()) }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function artifact(root, target) {
|
|
60
|
+
const file = resolveWithin(root, target, 'diagnostic observation')
|
|
61
|
+
let cursor = root
|
|
62
|
+
for (const part of path.relative(root, file).split(path.sep)) {
|
|
63
|
+
cursor = path.join(cursor, part)
|
|
64
|
+
if ((await lstat(cursor)).isSymbolicLink()) fail()
|
|
65
|
+
}
|
|
66
|
+
const handle = await open(file, constants.O_RDONLY | constants.O_NOFOLLOW)
|
|
67
|
+
try {
|
|
68
|
+
const info = await handle.stat()
|
|
69
|
+
if (!info.isFile() || info.size > 512 * 1024) fail()
|
|
70
|
+
return JSON.parse(await handle.readFile('utf8'))
|
|
71
|
+
} finally { await handle.close() }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function evidence(config, operation, root, { tolerateLifecycleError = false } = {}) {
|
|
75
|
+
if (!/^hop_[A-Za-z0-9_-]{1,100}$/.test(operation.operationId ?? '')) fail()
|
|
76
|
+
const jobName = `diagnostic-${operation.operationId.slice(4)}`
|
|
77
|
+
const directory = resolveWithin(root, path.join(config.jobsDir ?? 'jobs', jobName), 'diagnostic Job')
|
|
78
|
+
const provenance = await artifact(root, path.join(directory, 'diagnostic-provenance.json'))
|
|
79
|
+
if (provenance?.protocol !== 'harbor-diagnostic-provenance/v1' || provenance.operationId !== operation.operationId || provenance.promotionEligible !== false || !Array.isArray(provenance.selection) || provenance.selection.length < 1 || provenance.selection.length > 12) fail()
|
|
80
|
+
const resultRef = { verified: true, jobName, ...(operation.target?.workspace ? { workspace: operation.target.workspace } : {}), partial: operation.status !== 'COMPLETED' }
|
|
81
|
+
let lifecycle, observationWarning
|
|
82
|
+
try {
|
|
83
|
+
lifecycle = await artifact(root, path.join(directory, 'trial-lifecycle.json'))
|
|
84
|
+
if (lifecycle.schema_version !== 1 || lifecycle.job !== jobName || lifecycle.dataset_total !== provenance.selection.length || !Array.isArray(lifecycle.trials) || lifecycle.trials.length !== provenance.selection.length || lifecycle.trials.some(trial => !phases.has(trial.phase) || typeof trial.terminal !== 'boolean' || !Number.isSafeInteger(trial.dataset_order) || trial.dataset_order < 0 || trial.dataset_order >= provenance.selection.length) || new Set(lifecycle.trials.map(trial => trial.dataset_order)).size !== provenance.selection.length) fail()
|
|
85
|
+
} catch (cause) {
|
|
86
|
+
if (cause.code !== 'ENOENT' && !tolerateLifecycleError) throw cause
|
|
87
|
+
lifecycle = undefined
|
|
88
|
+
if (cause.code !== 'ENOENT') observationWarning = 'HARBOR_DIAGNOSTIC_OBSERVATION_UNSAFE'
|
|
89
|
+
}
|
|
90
|
+
return { resultRef, lifecycle, total: provenance.selection.length, ...(observationWarning ? { observationWarning } : {}) }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Bounded local artifact reads only. Missing evidence is not manufactured progress. */
|
|
94
|
+
export async function observeDiagnostic(config, operation, { root }) {
|
|
95
|
+
try {
|
|
96
|
+
const { resultRef, lifecycle, total, observationWarning } = await evidence(config, operation, root, { tolerateLifecycleError: true })
|
|
97
|
+
if (!lifecycle) return { resultRef, ...(observationWarning ? { observationWarning } : {}) }
|
|
98
|
+
const counts = {}
|
|
99
|
+
for (const trial of lifecycle.trials) counts[trial.phase] = (counts[trial.phase] ?? 0) + 1
|
|
100
|
+
return { resultRef, progress: { source: 'harbor-lifecycle', total, completed: lifecycle.trials.filter(trial => trial.terminal).length, counts, ...(typeof lifecycle.updated_at === 'string' && Number.isFinite(Date.parse(lifecycle.updated_at)) ? { updatedAt: lifecycle.updated_at } : {}) } }
|
|
101
|
+
} catch (cause) {
|
|
102
|
+
return cause.code === 'ENOENT' ? {} : { observationWarning: 'HARBOR_DIAGNOSTIC_OBSERVATION_UNSAFE' }
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function probeGroup(pid) {
|
|
107
|
+
try { process.kill(-pid, 0); return 'running' }
|
|
108
|
+
catch (cause) { if (cause.code !== 'ESRCH') return 'unknown' }
|
|
109
|
+
// A reused live PID is deliberately a blocker, never a process to signal.
|
|
110
|
+
try { process.kill(pid, 0); return 'running' }
|
|
111
|
+
catch (cause) { return cause.code === 'ESRCH' ? 'stopped' : 'unknown' }
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const composeName = name => {
|
|
115
|
+
const lowered = name.toLowerCase()
|
|
116
|
+
return (/^[a-z0-9]/.test(lowered) ? lowered : `0${lowered}`).replace(/[^a-z0-9_-]/g, '-')
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Read-only reconciliation. It does not kill processes or remove Docker resources. */
|
|
120
|
+
export async function inspectDiagnostic(config, operation, { root, runProcess, processProbe = probeGroup, platform = process.platform }) {
|
|
121
|
+
const blockers = []
|
|
122
|
+
const checkpoint = operation.events?.find(event => event.result?.process)?.result.process
|
|
123
|
+
let processState = 'unknown'
|
|
124
|
+
let runtimeMatches = false
|
|
125
|
+
let environment
|
|
126
|
+
if (/^sha256:[a-f0-9]{64}$/.test(checkpoint?.hostIdentity ?? '') && /^sha256:[a-f0-9]{64}$/.test(checkpoint?.dockerIdentity ?? '') && checkpoint.platform === platform && checkpoint.dockerTransport === 'pinned-local-unix/v1') {
|
|
127
|
+
try {
|
|
128
|
+
environment = await pinDiagnosticEnvironment(runProcess, process.env)
|
|
129
|
+
const current = await readDiagnosticRuntimeIdentity({ runProcess, platform, env: environment })
|
|
130
|
+
runtimeMatches = current.hostIdentity === checkpoint.hostIdentity && current.dockerIdentity === checkpoint.dockerIdentity
|
|
131
|
+
} catch {}
|
|
132
|
+
}
|
|
133
|
+
if (!runtimeMatches) blockers.push({ code: 'DIAGNOSTIC_RUNTIME_IDENTITY_UNVERIFIED', message: 'The recorded Host machine and Docker daemon cannot be matched. Restore the original runtime/context and inspect again; a different empty daemon is not proof of cleanup.' })
|
|
134
|
+
if (runtimeMatches && Number.isSafeInteger(checkpoint?.pid) && checkpoint.pid > 1 && checkpoint.groupId === checkpoint.pid && ['darwin', 'linux'].includes(checkpoint.platform)) {
|
|
135
|
+
try { processState = await processProbe(checkpoint.pid) } catch {}
|
|
136
|
+
if (!['stopped', 'running', 'unknown'].includes(processState)) processState = 'unknown'
|
|
137
|
+
}
|
|
138
|
+
const processInfo = { state: processState, ...(checkpoint?.pid ? { pid: checkpoint.pid, groupId: checkpoint.groupId } : {}) }
|
|
139
|
+
if (processState !== 'stopped') blockers.push({ code: processState === 'running' ? 'DIAGNOSTIC_PROCESS_PRESENT' : 'DIAGNOSTIC_PROCESS_OWNERSHIP_UNKNOWN', message: processState === 'running' ? 'The recorded process or process group still exists. Stop the original Host-owned run and inspect again; no signal was sent.' : 'A trustworthy stopped-process checkpoint is unavailable. An administrator must reconcile the original runner; no resources or lock were changed.' })
|
|
140
|
+
let resources = { state: 'unknown', items: [] }, resultRef
|
|
141
|
+
try {
|
|
142
|
+
const value = await evidence(config, operation, root)
|
|
143
|
+
resultRef = value.resultRef
|
|
144
|
+
if (!runtimeMatches) fail()
|
|
145
|
+
if (!value.lifecycle) fail()
|
|
146
|
+
const names = value.lifecycle.trials.map(trial => trial.trial_name).filter(Boolean)
|
|
147
|
+
// A separate-verifier name is truncated to 63 characters by Harbor. For
|
|
148
|
+
// longer Trial names that can truncate the ownership prefix itself, so
|
|
149
|
+
// refuse to infer cleanup instead of missing those resources.
|
|
150
|
+
if (names.some(name => typeof name !== 'string' || !/^[A-Za-z0-9_.-]{1,50}$/.test(name)) || value.lifecycle.trials.some(trial => trial.phase !== 'queued' && !trial.trial_name)) fail()
|
|
151
|
+
// Harbor 0.21's POSIX Docker adapter binds every environment and separate
|
|
152
|
+
// verifier project to `<trial_name>__…`. Trial-start is journaled before
|
|
153
|
+
// environment launch. We read labels only, never environment variables.
|
|
154
|
+
const prefixes = names.map(name => `${composeName(name)}__`)
|
|
155
|
+
const items = []
|
|
156
|
+
for (const kind of ['container', 'network', 'volume']) {
|
|
157
|
+
const args = [kind, 'ls', ...(kind === 'container' ? ['--all'] : []), '--filter', 'label=com.docker.compose.project', '--format', '{{.ID}}\t{{.Label "com.docker.compose.project"}}']
|
|
158
|
+
if (kind === 'volume') args[args.length - 1] = '{{.Name}}\t{{.Label "com.docker.compose.project"}}'
|
|
159
|
+
const response = await runProcess('docker', args, { cwd: root, env: environment, timeoutMs: 10_000, maxOutputBytes: 128 * 1024 })
|
|
160
|
+
if (response.code !== 0 || typeof response.stdout !== 'string') fail()
|
|
161
|
+
for (const line of response.stdout.split('\n').filter(Boolean)) {
|
|
162
|
+
const [id, project, extra] = line.split('\t')
|
|
163
|
+
if (extra !== undefined || !/^[a-zA-Z0-9_.-]{1,200}$/.test(id ?? '') || !/^[a-z0-9_-]{1,220}$/.test(project ?? '')) fail()
|
|
164
|
+
if (prefixes.some(prefix => project.startsWith(prefix))) items.push({ kind, id, project })
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
const finalIdentity = await readDiagnosticRuntimeIdentity({ runProcess, platform, env: environment })
|
|
168
|
+
if (finalIdentity.hostIdentity !== checkpoint.hostIdentity || finalIdentity.dockerIdentity !== checkpoint.dockerIdentity) fail()
|
|
169
|
+
resources = { state: items.length ? 'remaining' : 'clean', items: items.sort((a, b) => `${a.kind}:${a.id}`.localeCompare(`${b.kind}:${b.id}`)), checkedProjects: prefixes, boundary: 'Owned Compose containers, networks and volumes only; shared image/build caches are retained.' }
|
|
170
|
+
if (items.length) blockers.push({ code: 'DIAGNOSTIC_RESOURCES_REMAIN', message: 'The listed Compose resources remain. Have the workspace administrator inspect these exact resources, then check again. This action never deletes resources.' })
|
|
171
|
+
} catch {
|
|
172
|
+
blockers.push({ code: 'DIAGNOSTIC_RESOURCES_UNKNOWN', message: 'Job provenance/lifecycle or Docker inspection is unavailable. Restore access and inspect again; an empty or unreadable response is not proof of cleanup.' })
|
|
173
|
+
}
|
|
174
|
+
return { process: processInfo, resources, ...(resultRef ? { resultRef } : {}), blockers, canRecover: processState === 'stopped' && resources.state === 'clean' }
|
|
175
|
+
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { lstat, open, realpath } from 'node:fs/promises'
|
|
2
|
+
import { constants } from 'node:fs'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import { runBoundedProcess } from './bounded-process.js'
|
|
5
|
+
import { buildEvaluationRunReceipt, redactDiagnostic, resolveWithin } from './evolution.js'
|
|
6
|
+
import { inspectDiagnostic, observeDiagnostic, pinDiagnosticEnvironment, readDiagnosticRuntimeIdentity } from './diagnostic-observation.js'
|
|
7
|
+
|
|
8
|
+
export const DIAGNOSTIC_LIMITS = Object.freeze({ maxTrials: 12, concurrency: 2, attempts: 1, maxRetries: 0, wallTimeoutMs: 900_000, maxModelRequests: 96, maxResponseBytes: 1_048_576 })
|
|
9
|
+
const AGENT = 'harbor_dsh_evolution.agent:DshCandidateAgent'
|
|
10
|
+
const PLUGIN = 'harbor_dsh_evolution.diagnostic_plugin:BoundedDiagnosticPlugin'
|
|
11
|
+
const PLAN_PROTOCOL = 'harbor-bounded-diagnostic-plan/v1'
|
|
12
|
+
const OPERATION_ID = /^hop_[A-Za-z0-9_-]{1,100}$/
|
|
13
|
+
|
|
14
|
+
function failure(code, message) { return Object.assign(new Error(`${code}: ${message}`), { code }) }
|
|
15
|
+
function aborted(signal) { if (signal?.aborted) throw failure('HARBOR_PROCESS_ABORTED', 'Diagnostic cancelled before launch.') }
|
|
16
|
+
function bindingIdentity(binding) {
|
|
17
|
+
return Object.fromEntries(['provider', 'model', 'transport', 'protocol', 'reasoning_effort'].filter(key => binding?.[key] !== undefined).map(key => [key, binding[key]]))
|
|
18
|
+
}
|
|
19
|
+
function hasLockedRuntime(runtime) {
|
|
20
|
+
const safeSourcePath = value => typeof value === 'string' && value.length <= 1024 && value.trim() === value && !/[\\:\x00-\x1f\x7f]/.test(value) && !value.split('/').some(part => ['', '.', '..', 'node_modules', '.harbor-runtime', '.git'].includes(part))
|
|
21
|
+
return runtime?.kind === 'deepseek-harness' && runtime.policy === 'candidate-locked' && runtime.transport === 'acp'
|
|
22
|
+
&& runtime.descriptor === 'candidate-runtime.json' && runtime.lockfile === 'package-lock.json'
|
|
23
|
+
&& safeSourcePath(runtime.entrypoint) && /\.(?:js|mjs|cjs)$/.test(runtime.entrypoint)
|
|
24
|
+
&& safeSourcePath(runtime.config_path) && typeof runtime.agent_entry_id === 'string' && /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(runtime.agent_entry_id)
|
|
25
|
+
&& typeof runtime.node_version === 'string' && /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(runtime.node_version) && Number(runtime.node_version.split('.')[0]) >= 22
|
|
26
|
+
&& ['descriptor_digest', 'entrypoint_digest', 'lockfile_digest'].every(key => /^sha256:[a-f0-9]{64}$/.test(runtime[key] ?? ''))
|
|
27
|
+
}
|
|
28
|
+
function assertPlan(plan) {
|
|
29
|
+
if (plan?.protocol !== PLAN_PROTOCOL || !/^sha256:[a-f0-9]{64}$/.test(plan.planDigest ?? '') || plan.mode !== 'diagnostic' || plan.promotionEligible !== false || !Array.isArray(plan.selection) || !plan.selection.length || plan.selection.length > DIAGNOSTIC_LIMITS.maxTrials || Object.entries(DIAGNOSTIC_LIMITS).some(([key, value]) => plan.limits?.[key] !== value)) {
|
|
30
|
+
throw failure('HARBOR_DIAGNOSTIC_PLAN_INVALID', 'The installed adapter did not produce the bounded diagnostic contract.')
|
|
31
|
+
}
|
|
32
|
+
if (!hasLockedRuntime(plan.identities?.candidate?.runtime)) {
|
|
33
|
+
throw failure('HARBOR_DIAGNOSTIC_RUNTIME_ADAPTER_UNSUPPORTED', 'Update the Python Adapter and review a new plan; it has not verified a locked Candidate-owned ACP runtime. No Job was started.')
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
function safeProcessError(error) {
|
|
37
|
+
const code = /^HARBOR_[A-Z_]+$/.test(error?.code ?? '') ? error.code : 'HARBOR_DIAGNOSTIC_EXECUTION_FAILED'
|
|
38
|
+
return failure(code, redactDiagnostic(error?.message ?? 'Diagnostic execution failed.').slice(0, 600))
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function safeJsonArtifact(root, file, maxBytes = 4 * 1024 * 1024) {
|
|
42
|
+
const target = resolveWithin(root, file, 'diagnostic artifact')
|
|
43
|
+
const relative = path.relative(root, target)
|
|
44
|
+
let cursor = root
|
|
45
|
+
for (const part of relative.split(path.sep)) {
|
|
46
|
+
cursor = path.join(cursor, part)
|
|
47
|
+
if ((await lstat(cursor)).isSymbolicLink()) throw failure('HARBOR_DIAGNOSTIC_ARTIFACT_INVALID', 'Diagnostic artifacts must not traverse symlinks.')
|
|
48
|
+
}
|
|
49
|
+
const handle = await open(target, constants.O_RDONLY | constants.O_NOFOLLOW)
|
|
50
|
+
try {
|
|
51
|
+
const stats = await handle.stat()
|
|
52
|
+
if (!stats.isFile() || stats.size > maxBytes) throw failure('HARBOR_DIAGNOSTIC_ARTIFACT_INVALID', 'Diagnostic artifacts exceeded the bounded regular-file contract.')
|
|
53
|
+
return JSON.parse(await handle.readFile('utf8'))
|
|
54
|
+
} finally { await handle.close() }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Fixed CLI adapter. Authorization, idempotency and persistent Operation state belong to the Host controller. */
|
|
58
|
+
export class DiagnosticRunner {
|
|
59
|
+
constructor(config, modelRuntime, { runProcess = runBoundedProcess, platform = process.platform, processProbe } = {}) {
|
|
60
|
+
this.config = config
|
|
61
|
+
this.modelRuntime = modelRuntime
|
|
62
|
+
this.runProcess = runProcess
|
|
63
|
+
this.platform = platform
|
|
64
|
+
this.processProbe = processProbe
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async observe(operation, { owner } = {}) {
|
|
68
|
+
return observeDiagnostic(this.config, operation, { root: await this._root(owner) })
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async inspect(operation, { owner } = {}) {
|
|
72
|
+
return inspectDiagnostic(this.config, operation, { root: await this._root(owner), runProcess: this.runProcess, processProbe: this.processProbe, platform: this.platform })
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async _root(owner) {
|
|
76
|
+
if (!owner?.sessionId || !owner.projectRoot) throw failure('HARBOR_DIAGNOSTIC_OWNER_REQUIRED', 'A current Session project is required.')
|
|
77
|
+
const root = await realpath(owner.projectRoot)
|
|
78
|
+
if (root !== await realpath(this.config.projectRoot)) throw failure('HARBOR_DIAGNOSTIC_SOURCE_DENIED', 'The diagnostic runner belongs to a different project.')
|
|
79
|
+
if (this.platform === 'win32') throw failure('HARBOR_DIAGNOSTIC_RUNTIME_UNSUPPORTED', 'This runner requires POSIX process-group cancellation; Windows execution is not enabled.')
|
|
80
|
+
return root
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
_environment() { return { ...process.env, HARBOR_TELEMETRY: '0', ...(this.config.pythonPath ? { PYTHONPATH: this.config.pythonPath } : {}) } }
|
|
84
|
+
|
|
85
|
+
async _json(args, root, { input, signal, timeoutMs = 30_000 } = {}) {
|
|
86
|
+
let result
|
|
87
|
+
try {
|
|
88
|
+
result = await this.runProcess(this.config.harborDshBin, args, { cwd: root, env: this._environment(), timeoutMs, signal, allowedExitCodes: [0, 2], maxOutputBytes: 4 * 1024 * 1024, ...(input ? { input: JSON.stringify(input) } : {}) })
|
|
89
|
+
} catch (error) { throw safeProcessError(error) }
|
|
90
|
+
let value
|
|
91
|
+
try { value = JSON.parse(result.stdout) } catch { throw failure('HARBOR_DIAGNOSTIC_ADAPTER_UNAVAILABLE', 'Update the Python Adapter; its bounded diagnostic response is missing or invalid.') }
|
|
92
|
+
if (value?.error) {
|
|
93
|
+
const message = redactDiagnostic(value.error).slice(0, 600)
|
|
94
|
+
throw failure(message.match(/^(HARBOR_[A-Z_]+):/)?.[1] ?? 'HARBOR_DIAGNOSTIC_SOURCE_INVALID', message)
|
|
95
|
+
}
|
|
96
|
+
return value
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async _binding(plan) {
|
|
100
|
+
if (!this.modelRuntime?.resolve || !this.modelRuntime?.openLease) throw failure('HARBOR_DIAGNOSTIC_MODEL_UNAVAILABLE', 'The Host model broker is not connected.')
|
|
101
|
+
const pinned = plan.candidateModelBinding
|
|
102
|
+
const binding = await this.modelRuntime.resolve({}, pinned, { ignoreConfigured: true })
|
|
103
|
+
if (JSON.stringify(bindingIdentity(binding)) !== JSON.stringify(bindingIdentity(pinned))) throw failure('HARBOR_DIAGNOSTIC_MODEL_CHANGED', 'The recorded Candidate model cannot be resolved exactly.')
|
|
104
|
+
return binding
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async prepare({ owner, sourceJobDir, trialIds, signal }) {
|
|
108
|
+
const root = await this._root(owner)
|
|
109
|
+
const source = resolveWithin(root, sourceJobDir, 'source Job')
|
|
110
|
+
const plan = await this._json(['diagnostic-subset', 'plan'], root, { input: { projectRoot: root, sourceJobDir: source, trialIds }, signal })
|
|
111
|
+
assertPlan(plan)
|
|
112
|
+
const binding = await this._binding(plan)
|
|
113
|
+
if (typeof this.modelRuntime.assertLeaseLimits !== 'function') throw failure('HARBOR_DIAGNOSTIC_MODEL_LIMIT_UNSUPPORTED', 'The Host broker cannot prove the requested model budget; update the runtime before running.')
|
|
114
|
+
const budget = await this.modelRuntime.assertLeaseLimits(binding, { maxRequests: DIAGNOSTIC_LIMITS.maxModelRequests, maxResponseBytes: DIAGNOSTIC_LIMITS.maxResponseBytes })
|
|
115
|
+
if (!Number.isSafeInteger(budget?.maxRequests) || budget.maxRequests < 1 || budget.maxRequests > DIAGNOSTIC_LIMITS.maxModelRequests || !Number.isSafeInteger(budget?.maxResponseBytes) || budget.maxResponseBytes < 1 || budget.maxResponseBytes > DIAGNOSTIC_LIMITS.maxResponseBytes) throw failure('HARBOR_DIAGNOSTIC_MODEL_LIMIT_UNSUPPORTED', 'The Host broker returned an invalid effective budget.')
|
|
116
|
+
const runtime = await this._json(['docker-check'], root, { signal })
|
|
117
|
+
if (runtime?.valid !== true) {
|
|
118
|
+
const codes = (runtime?.findings ?? []).filter(item => item.level === 'error').map(item => String(item.code)).filter(code => /^DOCKER_[A-Z_]+$/.test(code))
|
|
119
|
+
throw failure('HARBOR_DIAGNOSTIC_RUNTIME_BLOCKED', `Docker is not ready (${codes.join(', ') || 'DOCKER_UNAVAILABLE'}). Fix the runtime and check parameters again; no Job was started.`)
|
|
120
|
+
}
|
|
121
|
+
// This is a read-only capability check. Unsupported remote/TLS transports
|
|
122
|
+
// must be visible in preflight, not discovered after user confirmation.
|
|
123
|
+
await pinDiagnosticEnvironment(this.runProcess, this._environment())
|
|
124
|
+
try {
|
|
125
|
+
const version = await this.runProcess(this.config.harborBin, ['--version'], { cwd: root, env: this._environment(), timeoutMs: 10_000, signal, maxOutputBytes: 4096 })
|
|
126
|
+
if (!/\b0\.21\.\d+(?:\b|[-+])/.test(version.stdout)) throw failure('HARBOR_DIAGNOSTIC_RUNTIME_UNSUPPORTED', 'The bounded runner requires the installed Harbor 0.21 adapter contract.')
|
|
127
|
+
} catch (error) { throw safeProcessError(error) }
|
|
128
|
+
return { ...plan, effectiveLimits: { ...plan.limits, maxModelRequests: budget.maxRequests, maxResponseBytes: budget.maxResponseBytes } }
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async execute(plan, { owner, operationId, signal, onSpawn, onUsage } = {}) {
|
|
132
|
+
if (!OPERATION_ID.test(operationId ?? '')) throw failure('HARBOR_DIAGNOSTIC_OPERATION_INVALID', 'A stable Host Operation ID is required.')
|
|
133
|
+
assertPlan(plan)
|
|
134
|
+
aborted(signal)
|
|
135
|
+
const root = await this._root(owner)
|
|
136
|
+
const trialIds = plan.selection.map(item => item.trialId)
|
|
137
|
+
const fresh = await this.prepare({ owner, sourceJobDir: plan.sourceJob, trialIds, signal })
|
|
138
|
+
if (fresh.planDigest !== plan.planDigest) throw failure('HARBOR_DIAGNOSTIC_REVISION_CONFLICT', 'Inputs changed since preflight. Review a new preview; no Job was started.')
|
|
139
|
+
if (plan.effectiveLimits && JSON.stringify(plan.effectiveLimits) !== JSON.stringify(fresh.effectiveLimits)) throw failure('HARBOR_DIAGNOSTIC_REVISION_CONFLICT', 'The effective diagnostic budget changed. Review a new preview; no Job was started.')
|
|
140
|
+
const binding = await this._binding(fresh)
|
|
141
|
+
const jobName = `diagnostic-${operationId.slice(4)}`
|
|
142
|
+
if (jobName.length > 100) throw failure('HARBOR_DIAGNOSTIC_OPERATION_INVALID', 'The Operation ID exceeds the fixed Job-name bound.')
|
|
143
|
+
const jobs = resolveWithin(root, this.config.jobsDir ?? 'jobs', 'jobs directory')
|
|
144
|
+
let cursor = root
|
|
145
|
+
for (const part of path.relative(root, jobs).split(path.sep).filter(Boolean)) {
|
|
146
|
+
cursor = path.join(cursor, part)
|
|
147
|
+
try { if ((await lstat(cursor)).isSymbolicLink()) throw failure('HARBOR_DIAGNOSTIC_SOURCE_DENIED', 'The Job output directory must not traverse symlinks.') }
|
|
148
|
+
catch (error) { if (error.code !== 'ENOENT') throw error }
|
|
149
|
+
}
|
|
150
|
+
const jobDir = path.join(jobs, jobName)
|
|
151
|
+
try { await lstat(jobDir); throw failure('HARBOR_DIAGNOSTIC_ALREADY_STARTED', 'This Operation already has a Job; it cannot be launched again.') }
|
|
152
|
+
catch (error) { if (error.code !== 'ENOENT') throw error }
|
|
153
|
+
aborted(signal)
|
|
154
|
+
const materialized = await this._json(['diagnostic-subset', 'materialize'], root, { input: { projectRoot: root, sourceJobDir: plan.sourceJob, trialIds, expectedPlanDigest: plan.planDigest, operationId }, signal, timeoutMs: 60_000 })
|
|
155
|
+
assertPlan(materialized)
|
|
156
|
+
const candidate = resolveWithin(root, materialized.candidatePath, 'Candidate')
|
|
157
|
+
const dataset = resolveWithin(root, materialized.datasetPath, 'diagnostic Dataset')
|
|
158
|
+
const stack = resolveWithin(root, materialized.stackPath, 'Evaluation Stack')
|
|
159
|
+
const identity = materialized.identities.candidate
|
|
160
|
+
const args = [
|
|
161
|
+
'run', '-p', dataset, '-a', AGENT,
|
|
162
|
+
'--ak', `candidate_path=${candidate}`, '--ak', `candidate_version=${identity.version}`, '--ak', `candidate_digest=${identity.digest}`,
|
|
163
|
+
'--ak', `candidate_model_provider=${binding.provider}`, '--ak', `candidate_model=${binding.model}`,
|
|
164
|
+
'--job-name', jobName, '--jobs-dir', jobs, '-n', String(DIAGNOSTIC_LIMITS.concurrency), '-k', '1', '--max-retries', '0', '-e', 'docker', '--delete',
|
|
165
|
+
'--plugin', PLUGIN, '--plugin-kwarg', `candidate_manifest=${path.join(candidate, 'candidate-manifest.json')}`,
|
|
166
|
+
'--plugin-kwarg', `dataset_path=${dataset}`, '--plugin-kwarg', `stack_path=${stack}`, '--plugin-kwarg', `project_root=${root}`, '--plugin-kwarg', 'mode=diagnostic',
|
|
167
|
+
'--plugin-kwarg', `candidate_model_provider=${binding.provider}`, '--plugin-kwarg', `candidate_model=${binding.model}`,
|
|
168
|
+
'--plugin-kwarg', `candidate_model_transport=${binding.transport}`, '--plugin-kwarg', `candidate_model_protocol=${binding.protocol}`,
|
|
169
|
+
'--plugin-kwarg', `expected_dataset_digest=${materialized.datasetIdentity.source_digest}`, '--plugin-kwarg', `expected_stack_digest=${materialized.identities.stack.digest}`,
|
|
170
|
+
'--plugin-kwarg', `operation_id=${operationId}`, '--plugin-kwarg', `source_plan_digest=${materialized.planDigest}`,
|
|
171
|
+
]
|
|
172
|
+
if (binding.reasoning_effort !== undefined) args.push('--ak', `candidate_reasoning_effort=${binding.reasoning_effort}`, '--plugin-kwarg', `candidate_reasoning_effort=${binding.reasoning_effort}`)
|
|
173
|
+
aborted(signal)
|
|
174
|
+
const environment = await pinDiagnosticEnvironment(this.runProcess, this._environment())
|
|
175
|
+
const runtimeIdentity = await readDiagnosticRuntimeIdentity({ runProcess: this.runProcess, platform: this.platform, env: environment })
|
|
176
|
+
aborted(signal)
|
|
177
|
+
const lease = await this.modelRuntime.openLease(binding, { candidateDigest: identity.digest, jobName, maxRequests: fresh.effectiveLimits.maxModelRequests, maxResponseBytes: fresh.effectiveLimits.maxResponseBytes })
|
|
178
|
+
let processStarted = false
|
|
179
|
+
// A lightweight in-memory counter is sampled by the controller; unlike
|
|
180
|
+
// lifecycle evidence, request usage cannot be reconstructed after restart.
|
|
181
|
+
onUsage?.(() => lease.usage?.())
|
|
182
|
+
const executionError = error => {
|
|
183
|
+
const safe = safeProcessError(error)
|
|
184
|
+
if (processStarted) Object.assign(safe, { cleanupRequired: true, jobName, diagnosticOnly: true })
|
|
185
|
+
return safe
|
|
186
|
+
}
|
|
187
|
+
try {
|
|
188
|
+
const result = await this.runProcess(this.config.harborBin, args, {
|
|
189
|
+
cwd: root, env: { ...environment, HSE_MODEL_GATEWAY_URL: lease.endpoint, HSE_MODEL_GATEWAY_TOKEN: lease.token, HSE_MODEL_GATEWAY_PROVIDER: lease.candidateProvider, HSE_MODEL_GATEWAY_INFO: JSON.stringify(lease.modelInfo), HSE_MODEL_GATEWAY_PROTOCOL: lease.protocol },
|
|
190
|
+
timeoutMs: DIAGNOSTIC_LIMITS.wallTimeoutMs, maxOutputBytes: 2 * 1024 * 1024, killGraceMs: 30_000, signal,
|
|
191
|
+
onSpawn: pid => { processStarted = true; return onSpawn?.(pid, { job: jobName, dataset: materialized.datasetIdentity, operationId, process: { pid, groupId: pid, platform: this.platform, dockerTransport: 'pinned-local-unix/v1', ...runtimeIdentity } }) },
|
|
192
|
+
})
|
|
193
|
+
const summary = await safeJsonArtifact(root, path.join(jobDir, 'evaluation-summary.json'))
|
|
194
|
+
const context = await safeJsonArtifact(root, path.join(jobDir, 'evaluation-context.json'))
|
|
195
|
+
const count = plan.selection.length
|
|
196
|
+
if (summary?.schema_version !== 3 || summary.job !== jobName || summary.mode !== 'diagnostic' || summary.n_trials !== count || summary.n_discovered_trials !== count || summary.artifact_validation?.valid !== true || context.mode !== 'diagnostic' || context.candidate?.digest !== identity.digest || context.dataset?.source_digest !== materialized.datasetIdentity.source_digest || context.evaluation_stack?.digest !== materialized.identities.stack.digest || summary.evaluation_context?.full_digest !== context.full_digest) {
|
|
197
|
+
throw failure('HARBOR_DIAGNOSTIC_ARTIFACT_INVALID', 'The Job process ended without complete, identity-matching diagnostic artifacts. Inspect the Job; it is not a successful evaluation.')
|
|
198
|
+
}
|
|
199
|
+
return { ...buildEvaluationRunReceipt({ jobName, mode: 'diagnostic', summary, processCode: result.code }), schema: 'harbor-diagnostic-operation-result/v1', operationId, jobName, productionImpact: 'none', promotionEligible: false, sourceJob: path.basename(plan.sourceJob), selectionCount: count, freshBaselineRequired: true, limits: fresh.effectiveLimits }
|
|
200
|
+
} catch (error) { throw executionError(error) }
|
|
201
|
+
finally {
|
|
202
|
+
try { await lease.close() }
|
|
203
|
+
catch (error) { throw executionError(error) }
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { constants } from 'node:fs'
|
|
2
|
+
import { access, lstat, mkdir, open, rename, unlink } from 'node:fs/promises'
|
|
3
|
+
import { createHash, randomUUID } from 'node:crypto'
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
|
|
6
|
+
const SCHEMA = 'harbor-evaluator-save/v1'
|
|
7
|
+
const IDENTITY = /^[A-Za-z0-9][A-Za-z0-9._+-]{0,99}$/
|
|
8
|
+
const DIGEST = /^(?:sha256:)?[a-f0-9]{64}$/i
|
|
9
|
+
const MAX_RECORD_BYTES = 16 * 1024
|
|
10
|
+
const fail = () => { throw new Error('HARBOR_EVALUATOR_SAVE_HISTORY_UNAVAILABLE: Saved-version history could not be safely read or recorded.') }
|
|
11
|
+
const hash = value => createHash('sha256').update(JSON.stringify(value)).digest('hex')
|
|
12
|
+
|
|
13
|
+
function relativeFile(value) {
|
|
14
|
+
if (typeof value !== 'string' || !value || value.length > 1024 || /[\u0000-\u001f\u007f\\]/.test(value) || value.startsWith('/') || /^[a-z][a-z\d+.-]*:/i.test(value) || value.split('/').some(part => !part || part === '.' || part === '..')) fail()
|
|
15
|
+
return value
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function identity(value) { if (typeof value !== 'string' || !IDENTITY.test(value)) fail(); return value }
|
|
19
|
+
|
|
20
|
+
function scopeFor(args) {
|
|
21
|
+
const scope = { sessionId: args.sessionId, workspace: args.workspace, job: args.job }
|
|
22
|
+
// Workspace IDs may begin with a dot (the project-root workspace label).
|
|
23
|
+
// These opaque strings are hashed, never interpolated into a file path.
|
|
24
|
+
if (Object.values(scope).some(value => typeof value !== 'string' || !value || value.length > 512 || /[\u0000-\u001f\u007f]/.test(value))) fail()
|
|
25
|
+
return scope
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function evaluatorSourceIdentity(governance) {
|
|
29
|
+
const evaluator = governance?.components?.evaluator
|
|
30
|
+
return { stack: governance?.stackIdentity, evaluator: { id: evaluator?.id, version: evaluator?.version, digest: evaluator?.digest, entry: evaluator?.entry }, contextDigest: governance?.contextDigest }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// The journal stores identities only, never browser-supplied receipts, source
|
|
34
|
+
// text, credentials, or evaluation claims. Live source is re-read by the Host.
|
|
35
|
+
function receiptIdentity(receipt) {
|
|
36
|
+
const evaluator = receipt?.evaluator
|
|
37
|
+
const stack = receipt?.stack
|
|
38
|
+
if (typeof evaluator?.digest !== 'string' || !DIGEST.test(evaluator.digest)) fail()
|
|
39
|
+
return {
|
|
40
|
+
stack: { id: identity(stack?.id), version: identity(stack?.version), path: relativeFile(stack?.path) },
|
|
41
|
+
evaluator: { evaluator_id: identity(evaluator?.evaluator_id), version: identity(evaluator?.version), descriptor_path: relativeFile(evaluator?.descriptor_path), digest: evaluator.digest },
|
|
42
|
+
requires_fresh_baseline: true, automatic_evaluation: false, automatic_gate: false,
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function directory(root, create) {
|
|
47
|
+
let current = path.resolve(root)
|
|
48
|
+
for (const segment of ['.harbor', 'workbench-evaluator-saves']) {
|
|
49
|
+
current = path.join(current, segment)
|
|
50
|
+
if (create) await mkdir(current, { mode: 0o700 }).catch(error => { if (error.code !== 'EEXIST') throw error })
|
|
51
|
+
const details = await lstat(current)
|
|
52
|
+
if (!details.isDirectory() || details.isSymbolicLink()) fail()
|
|
53
|
+
}
|
|
54
|
+
return current
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function stackDigest(config, relative) {
|
|
58
|
+
const segments = relativeFile(relative).split('/')
|
|
59
|
+
let file = path.resolve(config.projectRoot)
|
|
60
|
+
for (const segment of segments) {
|
|
61
|
+
file = path.join(file, segment)
|
|
62
|
+
if ((await lstat(file)).isSymbolicLink()) fail()
|
|
63
|
+
}
|
|
64
|
+
const handle = await open(file, constants.O_RDONLY | constants.O_NOFOLLOW)
|
|
65
|
+
try {
|
|
66
|
+
const details = await handle.stat()
|
|
67
|
+
if (!details.isFile() || details.size > 1024 * 1024) fail()
|
|
68
|
+
return `sha256:${createHash('sha256').update(await handle.readFile()).digest('hex')}`
|
|
69
|
+
} finally { await handle.close() }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Check journal access before mutating the evaluator. A later disk failure must
|
|
73
|
+
// still be reported as a *saved* version with recovery unavailable, not a failed
|
|
74
|
+
// save which invites the user to repeat a successful source mutation.
|
|
75
|
+
export async function prepareEvaluatorSaveHistory(config, args) {
|
|
76
|
+
const scope = scopeFor(args)
|
|
77
|
+
const destination = await directory(config.projectRoot, true)
|
|
78
|
+
await access(destination, constants.W_OK)
|
|
79
|
+
return { scope, destination }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export async function recordEvaluatorSave(config, args, governance, receipt) {
|
|
83
|
+
if (receipt?.requires_fresh_baseline !== true || receipt?.automatic_evaluation !== false || receipt?.automatic_gate !== false) fail()
|
|
84
|
+
const { scope, destination } = await prepareEvaluatorSaveHistory(config, args)
|
|
85
|
+
const value = receiptIdentity(receipt)
|
|
86
|
+
const record = { schema: SCHEMA, scope, sourceDigest: hash(evaluatorSourceIdentity(governance)), stackDigest: await stackDigest(config, value.stack.path), savedAt: new Date().toISOString(), receipt: value }
|
|
87
|
+
const temporary = path.join(destination, `.save-${randomUUID()}.tmp`)
|
|
88
|
+
let handle
|
|
89
|
+
try {
|
|
90
|
+
handle = await open(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600)
|
|
91
|
+
await handle.writeFile(JSON.stringify(record))
|
|
92
|
+
await handle.sync()
|
|
93
|
+
await handle.close(); handle = undefined
|
|
94
|
+
await rename(temporary, path.join(destination, `${hash(scope)}.json`))
|
|
95
|
+
} finally {
|
|
96
|
+
await handle?.close()
|
|
97
|
+
await unlink(temporary).catch(error => { if (error.code !== 'ENOENT') throw error })
|
|
98
|
+
}
|
|
99
|
+
return { ...receipt, continuation: { verification: 'VERIFIED', savedAt: record.savedAt, durable: true } }
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export async function readEvaluatorSave(config, args, governance, current, inspect) {
|
|
103
|
+
const scope = scopeFor(args)
|
|
104
|
+
let handle
|
|
105
|
+
let record
|
|
106
|
+
try {
|
|
107
|
+
const destination = await directory(config.projectRoot, false)
|
|
108
|
+
handle = await open(path.join(destination, `${hash(scope)}.json`), constants.O_RDONLY | constants.O_NOFOLLOW)
|
|
109
|
+
const details = await handle.stat()
|
|
110
|
+
if (!details.isFile() || details.size > MAX_RECORD_BYTES) fail()
|
|
111
|
+
record = JSON.parse(await handle.readFile('utf8'))
|
|
112
|
+
} catch (error) { if (error.code === 'ENOENT') return undefined; throw error }
|
|
113
|
+
finally { await handle?.close() }
|
|
114
|
+
if (record.schema !== SCHEMA || hash(record.scope) !== hash(scope) || record.sourceDigest !== hash(evaluatorSourceIdentity(governance)) || typeof record.stackDigest !== 'string' || !DIGEST.test(record.stackDigest) || typeof record.savedAt !== 'string' || !Number.isFinite(Date.parse(record.savedAt))) fail()
|
|
115
|
+
const receipt = receiptIdentity(record.receipt)
|
|
116
|
+
// A saved explicit Stack path can differ from the Job's default discovery
|
|
117
|
+
// path. Resolve only the validated recorded path through the Host inspector.
|
|
118
|
+
if (inspect && current?.stack?.path !== receipt.stack.path) {
|
|
119
|
+
try { current = await inspect(receipt.stack.path) } catch { current = undefined }
|
|
120
|
+
}
|
|
121
|
+
let verified = false
|
|
122
|
+
let available = Boolean(current)
|
|
123
|
+
try { verified = hash(receiptIdentity(current)) === hash(receipt) && await stackDigest(config, receipt.stack.path) === record.stackDigest } catch { available = false }
|
|
124
|
+
return {
|
|
125
|
+
...receipt,
|
|
126
|
+
...(verified ? { evaluator: { ...receipt.evaluator, editable_files: current.evaluator.editable_files } } : {}),
|
|
127
|
+
continuation: { verification: verified ? 'VERIFIED' : available ? 'DRIFTED' : 'UNAVAILABLE', savedAt: record.savedAt, durable: true, recovered: true },
|
|
128
|
+
}
|
|
129
|
+
}
|