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,443 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto'
|
|
2
|
+
import { constants } from 'node:fs'
|
|
3
|
+
import { lstat, mkdir, open, readdir, readFile, unlink, writeFile } from 'node:fs/promises'
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
import { localObjectDigest } from './interaction-objects.js'
|
|
6
|
+
|
|
7
|
+
export const WORKBENCH_ACTIONS = Object.freeze({
|
|
8
|
+
'candidate-draft': Object.freeze({ risk: 'R2', execution: 'draft-only', mutationSurface: 'Candidate' }),
|
|
9
|
+
'evaluator-draft': Object.freeze({ risk: 'R2', execution: 'draft-only', mutationSurface: 'Evaluator/Rubric', freshBaseline: true }),
|
|
10
|
+
compare: Object.freeze({ risk: 'R0', execution: 'read-only', mutationSurface: 'None' }),
|
|
11
|
+
'diagnostic-evaluation': Object.freeze({ risk: 'R1', execution: 'requires-registered-runner', mutationSurface: 'None' }),
|
|
12
|
+
'retry-infrastructure': Object.freeze({ risk: 'R1', execution: 'requires-registered-runner', mutationSurface: 'None' }),
|
|
13
|
+
'gate-request': Object.freeze({ risk: 'R2', execution: 'draft-only', mutationSurface: 'None' }),
|
|
14
|
+
'deployment-handoff': Object.freeze({ risk: 'R2', execution: 'draft-only', mutationSurface: 'None' }),
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
const sameOwner = (entry, owner) => entry.sessionId === owner.sessionId && entry.projectRoot === owner.projectRoot
|
|
18
|
+
const error = (code, text) => { throw new Error(`${code}: ${text}`) }
|
|
19
|
+
const terminal = status => ['COMPLETED', 'FAILED', 'CANCELLED', 'INTERRUPTED'].includes(status)
|
|
20
|
+
const offline = draft => draft.execution === 'requires-registered-runner'
|
|
21
|
+
const TRANSITIONS = { SCHEDULED: ['EXECUTING', 'CANCELLING', 'FAILED', 'CANCELLED'], EXECUTING: ['ACTIVE', 'CANCELLING', 'COMPLETED', 'FAILED', 'CANCELLED'], ACTIVE: ['CANCELLING', 'COMPLETED', 'FAILED', 'CANCELLED'], CANCELLING: ['CANCELLED', 'FAILED'] }
|
|
22
|
+
|
|
23
|
+
function executionFailure(cause) {
|
|
24
|
+
const code = String(cause?.code ?? cause?.message?.match(/^([A-Z][A-Z0-9_]+):/)?.[1] ?? '')
|
|
25
|
+
return { code: /^HARBOR_[A-Z0-9_]{1,80}$/.test(code) ? code : 'ACTION_EXECUTION_FAILED', message: 'Operation stopped. Inspect its result and prerequisites before preparing another run; no automatic retry was performed.', ...(cause?.cleanupRequired === true ? { cleanupRequired: true, ...(typeof cause.jobName === 'string' && /^diagnostic-[a-f0-9-]{36}$/.test(cause.jobName) ? { jobName: cause.jobName } : {}), cleanupMessage: 'Docker resource cleanup is not verified. The workspace remains locked against further diagnostics until those resources and the claim are explicitly reconciled.' } : {}) }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// These directories hold ONLY explicit Workbench draft/operation records.
|
|
29
|
+
// Refuse symlinks at every level, including on reconnect reads.
|
|
30
|
+
async function journalDirectory(root, create) {
|
|
31
|
+
let current = path.resolve(root)
|
|
32
|
+
for (const segment of ['.harbor', 'workbench-operations']) {
|
|
33
|
+
current = path.join(current, segment)
|
|
34
|
+
if (create) await mkdir(current, { mode: 0o700 }).catch(e => { if (e.code !== 'EEXIST') throw e })
|
|
35
|
+
const info = await lstat(current)
|
|
36
|
+
if (!info.isDirectory() || info.isSymbolicLink()) error('HARBOR_ACTION_STORAGE_UNSAFE', 'Operation storage must be a real project directory.')
|
|
37
|
+
}
|
|
38
|
+
return current
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function hasDiagnosticClaim(root) {
|
|
42
|
+
try {
|
|
43
|
+
const directory = await journalDirectory(root, false)
|
|
44
|
+
await lstat(path.join(directory, 'diagnostic-active.json'))
|
|
45
|
+
return true
|
|
46
|
+
} catch (cause) { if (cause.code === 'ENOENT') return false; throw cause }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function safeRecord(file, maxBytes = 128 * 1024) {
|
|
50
|
+
const handle = await open(file, constants.O_RDONLY | constants.O_NOFOLLOW)
|
|
51
|
+
try {
|
|
52
|
+
const info = await handle.stat()
|
|
53
|
+
if (!info.isFile() || info.size > maxBytes) error('HARBOR_ACTION_STORAGE_UNSAFE', 'Unsafe operation record.')
|
|
54
|
+
return { value: JSON.parse(await handle.readFile('utf8')), info }
|
|
55
|
+
} catch (cause) {
|
|
56
|
+
if (cause instanceof SyntaxError) error('HARBOR_ACTION_STORAGE_UNSAFE', 'Invalid operation record.')
|
|
57
|
+
throw cause
|
|
58
|
+
} finally { await handle.close() }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const diagnosticOperation = operation => ['diagnostic-evaluation', 'retry-infrastructure'].includes(operation.kind)
|
|
62
|
+
|
|
63
|
+
export class ActionDraftController {
|
|
64
|
+
constructor({ resolve, execute, prepare, observe, inspect, now = Date.now, ttlMs = 10 * 60_000, maxEntries = 256 }) {
|
|
65
|
+
this.resolve = resolve
|
|
66
|
+
this.execute = execute
|
|
67
|
+
this.prepare = prepare
|
|
68
|
+
this.observe = observe
|
|
69
|
+
this.inspectRunner = inspect
|
|
70
|
+
this.now = now
|
|
71
|
+
this.ttlMs = ttlMs
|
|
72
|
+
this.maxEntries = maxEntries
|
|
73
|
+
this.drafts = new Map()
|
|
74
|
+
this.previews = new Map()
|
|
75
|
+
this.operations = new Map()
|
|
76
|
+
this.inFlight = new Map()
|
|
77
|
+
this.draftOperations = new Map()
|
|
78
|
+
this.tasks = new Map()
|
|
79
|
+
this.projectClaims = new Set()
|
|
80
|
+
this.inspections = new Map()
|
|
81
|
+
this.closed = false
|
|
82
|
+
}
|
|
83
|
+
async dispose() {
|
|
84
|
+
this.closed = true
|
|
85
|
+
await Promise.allSettled([...this.inFlight.values()])
|
|
86
|
+
const tasks = [...this.tasks.values()]
|
|
87
|
+
for (const task of tasks) task.abort.abort()
|
|
88
|
+
await Promise.allSettled(tasks.map(task => task.promise))
|
|
89
|
+
}
|
|
90
|
+
prune() {
|
|
91
|
+
for (const map of [this.drafts, this.previews, this.inspections]) for (const [id, value] of map) if (value.expiresAtMs <= this.now()) map.delete(id)
|
|
92
|
+
for (const draftId of this.draftOperations.keys()) if (!this.drafts.has(draftId) && !this.inFlight.has(draftId)) this.draftOperations.delete(draftId)
|
|
93
|
+
// Completed journals remain readable on disk; the live Host must not retain
|
|
94
|
+
// every result for its entire lifetime. Never evict an executing operation.
|
|
95
|
+
for (const [id, value] of this.operations) {
|
|
96
|
+
if (this.operations.size <= this.maxEntries) break
|
|
97
|
+
if (terminal(value.operation.status)) this.operations.delete(id)
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
owned(map, id, owner) {
|
|
101
|
+
this.prune()
|
|
102
|
+
const entry = map.get(id)
|
|
103
|
+
if (!entry) error('HARBOR_ACTION_EXPIRED', 'Draft or preview expired. Prepare a new one.')
|
|
104
|
+
if (!sameOwner(entry, owner)) error('HARBOR_ACTION_DENIED', 'Draft belongs to a different Session/project.')
|
|
105
|
+
return entry
|
|
106
|
+
}
|
|
107
|
+
async propose(args, owner) {
|
|
108
|
+
if (this.closed) error('HARBOR_ACTION_HOST_CLOSED', 'The Host is stopping. Reload after it restarts.')
|
|
109
|
+
this.prune()
|
|
110
|
+
const registration = Object.hasOwn(WORKBENCH_ACTIONS, args.kind) ? WORKBENCH_ACTIONS[args.kind] : undefined
|
|
111
|
+
if (!registration) error('HARBOR_ACTION_UNREGISTERED', 'This action is not registered. Production mutation is disabled.')
|
|
112
|
+
if (this.drafts.size >= this.maxEntries) error('HARBOR_ACTION_CAPACITY', 'Too many pending drafts.')
|
|
113
|
+
const basis = await this.resolve(args.contextSnapshotId, owner)
|
|
114
|
+
if (basis.freshness !== 'FRESH') error('HARBOR_ACTION_REVISION_CONFLICT', 'Rebind the latest object before preparing an action.')
|
|
115
|
+
const actionId = randomUUID()
|
|
116
|
+
const draft = {
|
|
117
|
+
schema: 'harbor-action-draft/v1', draftId: `hdraft_${actionId}`, operationId: `hop_${actionId}`, kind: args.kind,
|
|
118
|
+
risk: registration.risk, execution: registration.execution, mutationSurface: registration.mutationSurface,
|
|
119
|
+
contextSnapshotId: args.contextSnapshotId, baseRevision: basis.basedOn.currentRevision,
|
|
120
|
+
target: basis.refs.object, selection: basis.refs.selection ?? [], identities: basis.context.identities,
|
|
121
|
+
proposal: args.proposal, proposalId: `proposal_${randomUUID()}`, templateVersion: 'harbor-workbench-action/v1',
|
|
122
|
+
freshBaselineRequired: Boolean(registration.freshBaseline), productionImpact: 'none',
|
|
123
|
+
createdAt: new Date(this.now()).toISOString(), expiresAt: new Date(this.now() + this.ttlMs).toISOString(),
|
|
124
|
+
}
|
|
125
|
+
const entry = { ...owner, draft, expiresAtMs: this.now() + this.ttlMs }
|
|
126
|
+
this.drafts.set(draft.draftId, structuredClone(entry))
|
|
127
|
+
return structuredClone(draft)
|
|
128
|
+
}
|
|
129
|
+
async preview(args, owner) {
|
|
130
|
+
const { draft } = this.owned(this.drafts, args.draftId, owner)
|
|
131
|
+
if (this.previews.size >= this.maxEntries) error('HARBOR_ACTION_CAPACITY', 'Too many active previews.')
|
|
132
|
+
const basis = await this.resolve(draft.contextSnapshotId, owner)
|
|
133
|
+
const blocking = []
|
|
134
|
+
if (basis.freshness !== 'FRESH' || basis.basedOn.currentRevision !== draft.baseRevision) blocking.push({ code: 'REVISION_CONFLICT', message: 'The selected evidence changed. Rebind and prepare a new draft.' })
|
|
135
|
+
let prepared
|
|
136
|
+
if (offline(draft)) {
|
|
137
|
+
if (!this.prepare) blocking.push({ code: 'OFFLINE_RUNNER_NOT_REGISTERED', message: 'This workspace has no registered bounded runner. No Job can start.' })
|
|
138
|
+
else if (!blocking.length) {
|
|
139
|
+
prepared = await this.prepare(draft, basis, owner)
|
|
140
|
+
blocking.push(...(prepared.blocking ?? []))
|
|
141
|
+
}
|
|
142
|
+
if (this.projectClaims.has(owner.projectRoot)) blocking.push({ code: 'DIAGNOSTIC_ALREADY_ACTIVE', message: 'A diagnostic is already running in this workspace. Wait for it or cancel it first.' })
|
|
143
|
+
else if (await hasDiagnosticClaim(owner.projectRoot)) blocking.push({ code: 'DIAGNOSTIC_RECOVERY_REQUIRED', message: 'Another Host owns an unfinished diagnostic. Inspect that operation and its processes before preparing another run.' })
|
|
144
|
+
}
|
|
145
|
+
if (draft.kind === 'compare' && basis.refs.object?.kind !== 'harbor.compare/v1') blocking.push({ code: 'COMPARE_PAIR_REQUIRED', message: 'Select an authoritative Baseline/Candidate comparison first.' })
|
|
146
|
+
if (draft.kind === 'evaluator-draft' && !basis.selectedEvidence?.some(item => item.available && item.ref.kind === 'evaluator-source')) blocking.push({ code: 'SAVED_SOURCE_REQUIRED', message: 'Select a saved Evaluator/Rubric fragment first.' })
|
|
147
|
+
const preview = {
|
|
148
|
+
schema: 'harbor-action-preview/v1', previewId: `hpreview_${randomUUID()}`, draftId: draft.draftId,
|
|
149
|
+
contentHash: localObjectDigest(prepared ? { draft, plan: prepared.plan } : draft), baseRevision: draft.baseRevision,
|
|
150
|
+
selectionDigest: localObjectDigest(draft.selection), target: draft.target, identities: draft.identities,
|
|
151
|
+
risk: draft.risk, mutationSurface: draft.mutationSurface, productionImpact: 'none',
|
|
152
|
+
freshBaselineRequired: draft.freshBaselineRequired, estimatedExternalRequests: draft.execution === 'requires-registered-runner' ? null : 0,
|
|
153
|
+
costEstimate: draft.execution === 'requires-registered-runner' ? 'unavailable — blocked' : 'No external model/evaluation requests',
|
|
154
|
+
...(prepared?.public ?? {}),
|
|
155
|
+
blocking, status: blocking.length ? 'BLOCKED' : 'READY_FOR_REVIEW',
|
|
156
|
+
expiresAt: new Date(this.now() + this.ttlMs).toISOString(),
|
|
157
|
+
}
|
|
158
|
+
this.previews.set(preview.previewId, { ...owner, preview, draft, prepared, expiresAtMs: this.now() + this.ttlMs })
|
|
159
|
+
return structuredClone(preview)
|
|
160
|
+
}
|
|
161
|
+
async confirm(args, owner) {
|
|
162
|
+
if (this.closed) error('HARBOR_ACTION_HOST_CLOSED', 'The Host is stopping. Nothing was started.')
|
|
163
|
+
const entry = this.owned(this.previews, args.previewId, owner)
|
|
164
|
+
if (args.confirmed !== true || args.contentHash !== entry.preview.contentHash || args.expectedRevision !== entry.preview.baseRevision) error('HARBOR_ACTION_CONFIRMATION_REQUIRED', 'Explicit review must match the exact preview hash and revision.')
|
|
165
|
+
if (entry.preview.blocking.length) error('HARBOR_ACTION_BLOCKED', 'Blocking preflight checks prevent execution.')
|
|
166
|
+
const key = entry.draft.draftId
|
|
167
|
+
if (this.inFlight.has(key)) return this.inFlight.get(key)
|
|
168
|
+
const existing = this.draftOperations.get(key)
|
|
169
|
+
if (existing) return this.operation({ operationId: existing }, owner)
|
|
170
|
+
const task = this.commit(entry, owner)
|
|
171
|
+
this.inFlight.set(key, task)
|
|
172
|
+
try { return await task } finally { this.inFlight.delete(key) }
|
|
173
|
+
}
|
|
174
|
+
async commit(entry, owner) {
|
|
175
|
+
const basis = await this.resolve(entry.draft.contextSnapshotId, owner)
|
|
176
|
+
if (basis.freshness !== 'FRESH' || basis.basedOn.currentRevision !== entry.preview.baseRevision) error('HARBOR_ACTION_REVISION_CONFLICT', 'Revision changed after review; no writes were made.')
|
|
177
|
+
if (offline(entry.draft)) {
|
|
178
|
+
const fresh = await this.prepare(entry.draft, basis, owner)
|
|
179
|
+
if (fresh.blocking?.length) error('HARBOR_ACTION_BLOCKED', 'The runner prerequisites changed. Preview again; nothing was started.')
|
|
180
|
+
if (localObjectDigest({ draft: entry.draft, plan: fresh.plan }) !== entry.preview.contentHash) error('HARBOR_ACTION_REVISION_CONFLICT', 'The diagnostic scope or identities changed. Preview again; nothing was started.')
|
|
181
|
+
if (this.projectClaims.has(owner.projectRoot)) error('HARBOR_ACTION_CAPACITY', 'Only one diagnostic may run in a workspace at a time.')
|
|
182
|
+
this.projectClaims.add(owner.projectRoot)
|
|
183
|
+
try { return await this.schedule(entry, basis, owner) }
|
|
184
|
+
catch (cause) { this.projectClaims.delete(owner.projectRoot); throw cause }
|
|
185
|
+
}
|
|
186
|
+
const operationId = entry.draft.operationId
|
|
187
|
+
const operation = { schema: 'harbor-operation/v1', operationId, draftId: entry.draft.draftId, causationId: entry.draft.proposalId, sessionId: owner.sessionId, status: 'EXECUTING', risk: entry.draft.risk, kind: entry.draft.kind, baseRevision: entry.draft.baseRevision, contentHash: entry.preview.contentHash, contextSnapshotId: entry.draft.contextSnapshotId, createdAt: new Date(this.now()).toISOString(), events: [] }
|
|
188
|
+
const directory = await journalDirectory(owner.projectRoot, true)
|
|
189
|
+
operation.previewId = entry.preview.previewId
|
|
190
|
+
operation.selectionDigest = entry.preview.selectionDigest
|
|
191
|
+
operation.actor = { sessionId: owner.sessionId, role: 'local-workspace-user', approval: 'explicit-preview-confirmation' }
|
|
192
|
+
operation.target = entry.draft.target
|
|
193
|
+
const record = async (status, result) => {
|
|
194
|
+
const event = { eventId: `${operationId}:${operation.events.length + 1}`, sequence: operation.events.length + 1, status, at: new Date(this.now()).toISOString(), ...(result ? { result } : {}) }
|
|
195
|
+
const next = { ...operation, status, events: [...operation.events, event] }
|
|
196
|
+
await writeFile(path.join(directory, `${operationId}.${event.sequence}.json`), JSON.stringify(next, null, 2), { flag: 'wx', mode: 0o600 })
|
|
197
|
+
Object.assign(operation, next)
|
|
198
|
+
this.operations.set(operationId, { ...owner, operation })
|
|
199
|
+
this.prune()
|
|
200
|
+
}
|
|
201
|
+
await record('EXECUTING')
|
|
202
|
+
entry.operationId = operationId
|
|
203
|
+
this.draftOperations.set(entry.draft.draftId, operationId)
|
|
204
|
+
try {
|
|
205
|
+
const result = await this.execute(entry.draft, basis, owner)
|
|
206
|
+
if (Buffer.byteLength(JSON.stringify(result), 'utf8') > 64 * 1024) error('HARBOR_ACTION_RESULT_TOO_LARGE', 'Operation result exceeds its journal budget.')
|
|
207
|
+
await record('COMPLETED', result)
|
|
208
|
+
} catch {
|
|
209
|
+
await record('FAILED', { code: 'ACTION_EXECUTION_FAILED', message: 'Operation failed. Inspect the operation; no automatic retry was performed.' })
|
|
210
|
+
}
|
|
211
|
+
return structuredClone(operation)
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async schedule(entry, basis, owner) {
|
|
215
|
+
if (this.closed) error('HARBOR_ACTION_HOST_CLOSED', 'The Host is stopping. Nothing was started.')
|
|
216
|
+
const operationId = entry.draft.operationId
|
|
217
|
+
const directory = await journalDirectory(owner.projectRoot, true)
|
|
218
|
+
const operation = { schema: 'harbor-operation/v1', operationId, draftId: entry.draft.draftId, causationId: entry.draft.proposalId, sessionId: owner.sessionId, kind: entry.draft.kind, risk: entry.draft.risk, baseRevision: entry.draft.baseRevision, contentHash: entry.preview.contentHash, contextSnapshotId: entry.draft.contextSnapshotId, selectionDigest: entry.preview.selectionDigest, target: entry.draft.target, previewId: entry.preview.previewId, actor: { sessionId: owner.sessionId, role: 'local-workspace-user', approval: 'explicit-preview-confirmation' }, diagnosticOnly: true, limits: entry.preview.limits, createdAt: new Date(this.now()).toISOString(), events: [] }
|
|
219
|
+
const abort = new AbortController()
|
|
220
|
+
let writes = Promise.resolve()
|
|
221
|
+
const record = (status, result) => {
|
|
222
|
+
const nextWrite = writes.then(async () => {
|
|
223
|
+
if (terminal(operation.status)) return
|
|
224
|
+
// Cancellation may queue before the asynchronous spawn checkpoint.
|
|
225
|
+
// Keep its ownership evidence without moving CANCELLING back to ACTIVE.
|
|
226
|
+
const lateCheckpoint = operation.status === 'CANCELLING' && status === 'ACTIVE' && result?.processStarted === true
|
|
227
|
+
if (lateCheckpoint) status = 'CANCELLING'
|
|
228
|
+
if (operation.status === status && !lateCheckpoint) return
|
|
229
|
+
if (!lateCheckpoint && operation.status && !TRANSITIONS[operation.status]?.includes(status)) error('HARBOR_ACTION_STATE_INVALID', 'Operation transition is not allowed.')
|
|
230
|
+
if (result && Buffer.byteLength(JSON.stringify(result), 'utf8') > 64 * 1024) error('HARBOR_ACTION_RESULT_TOO_LARGE', 'Operation result exceeds its journal budget.')
|
|
231
|
+
const sequence = operation.events.length + 1
|
|
232
|
+
const event = { eventId: `${operationId}:${sequence}`, sequence, status, at: new Date(this.now()).toISOString(), ...(result ? { result } : {}) }
|
|
233
|
+
const next = { ...operation, status, ...(result?.cleanupRequired ? { cleanupRequired: true } : {}), events: [...operation.events, event] }
|
|
234
|
+
// Sequence 1 is the exclusive durable claim. No runner is invoked if
|
|
235
|
+
// it exists already, even in another Host/controller instance.
|
|
236
|
+
await writeFile(path.join(directory, `${operationId}.${sequence}.json`), JSON.stringify(next, null, 2), { flag: 'wx', mode: 0o600 })
|
|
237
|
+
Object.assign(operation, next)
|
|
238
|
+
this.operations.set(operationId, { ...owner, operation })
|
|
239
|
+
})
|
|
240
|
+
writes = nextWrite.catch(() => {})
|
|
241
|
+
return nextWrite
|
|
242
|
+
}
|
|
243
|
+
const claimFile = path.join(directory, 'diagnostic-active.json')
|
|
244
|
+
await writeFile(claimFile, JSON.stringify({ operationId, sessionId: owner.sessionId }), { flag: 'wx', mode: 0o600 }).catch(cause => {
|
|
245
|
+
if (cause.code === 'EEXIST') error('HARBOR_ACTION_RECOVERY_REQUIRED', 'A diagnostic is already claimed in this workspace. Inspect it; no new run was started.')
|
|
246
|
+
throw cause
|
|
247
|
+
})
|
|
248
|
+
try { await record('SCHEDULED') }
|
|
249
|
+
catch (cause) { await unlink(claimFile); throw cause }
|
|
250
|
+
this.draftOperations.set(entry.draft.draftId, operationId)
|
|
251
|
+
const task = { ...owner, abort, record, operation, promise: undefined }
|
|
252
|
+
this.tasks.set(operationId, task)
|
|
253
|
+
task.promise = (async () => {
|
|
254
|
+
try {
|
|
255
|
+
abort.signal.throwIfAborted()
|
|
256
|
+
await record('EXECUTING')
|
|
257
|
+
const result = await this.execute(entry.draft, basis, owner, { plan: entry.prepared.plan, operationId, signal: abort.signal, onUsage: readUsage => { if (typeof readUsage === 'function') task.readUsage = readUsage }, onSpawn: (pid, checkpoint) => record('ACTIVE', { jobName: checkpoint.job, diagnosticOnly: true, processStarted: true, ...(Number.isSafeInteger(pid) && pid > 1 && checkpoint.process?.groupId === pid ? { process: { pid, groupId: pid, platform: checkpoint.process.platform, ...(checkpoint.process.dockerTransport === 'pinned-local-unix/v1' ? { dockerTransport: checkpoint.process.dockerTransport } : {}), ...Object.fromEntries(['hostIdentity', 'dockerIdentity'].filter(key => /^sha256:[a-f0-9]{64}$/.test(checkpoint.process[key] ?? '')).map(key => [key, checkpoint.process[key]])) } } : {}) }) })
|
|
258
|
+
const usage = task.readUsage?.()
|
|
259
|
+
if (usage) operation.modelUsage = usage
|
|
260
|
+
await record(abort.signal.aborted ? 'CANCELLED' : 'COMPLETED', result)
|
|
261
|
+
} catch (cause) {
|
|
262
|
+
const usage = task.readUsage?.()
|
|
263
|
+
if (usage) operation.modelUsage = usage
|
|
264
|
+
await record(abort.signal.aborted ? 'CANCELLED' : 'FAILED', executionFailure(cause))
|
|
265
|
+
}
|
|
266
|
+
})().catch(() => {
|
|
267
|
+
// A journal failure is not success. Terminate the owned work and retain a
|
|
268
|
+
// visible recovery state; never launch a replacement automatically.
|
|
269
|
+
abort.abort()
|
|
270
|
+
operation.status = 'INTERRUPTED'
|
|
271
|
+
operation.recoveryRequired = true
|
|
272
|
+
}).finally(async () => {
|
|
273
|
+
// A crash/interrupted journal retains the claim for explicit recovery.
|
|
274
|
+
// Only this invocation's successfully settled claim can be removed.
|
|
275
|
+
if (terminal(operation.status) && operation.status !== 'INTERRUPTED' && !operation.cleanupRequired) {
|
|
276
|
+
try {
|
|
277
|
+
const info = await lstat(claimFile)
|
|
278
|
+
const claim = !info.isSymbolicLink() && info.isFile() && info.size < 1024 ? JSON.parse(await readFile(claimFile, 'utf8')) : undefined
|
|
279
|
+
if (claim?.operationId === operationId && claim?.sessionId === owner.sessionId) await unlink(claimFile)
|
|
280
|
+
} catch { operation.recoveryRequired = true }
|
|
281
|
+
}
|
|
282
|
+
this.tasks.delete(operationId)
|
|
283
|
+
this.projectClaims.delete(owner.projectRoot)
|
|
284
|
+
this.prune()
|
|
285
|
+
})
|
|
286
|
+
return structuredClone(operation)
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
async cancel(args, owner) {
|
|
290
|
+
const operation = await this.operation(args, owner)
|
|
291
|
+
if (terminal(operation.status)) return operation
|
|
292
|
+
const task = this.tasks.get(args.operationId)
|
|
293
|
+
if (!task || !sameOwner(task, owner)) error('HARBOR_ACTION_RECOVERY_REQUIRED', 'The original runner is not owned by this Host; inspect it before starting another run.')
|
|
294
|
+
// Abort synchronously before queuing the event. The executor must await
|
|
295
|
+
// process-tree termination and close its model lease before settling.
|
|
296
|
+
task.abort.abort()
|
|
297
|
+
if (task.operation.status !== 'CANCELLING') await task.record('CANCELLING')
|
|
298
|
+
return structuredClone(task.operation)
|
|
299
|
+
}
|
|
300
|
+
async operation(args, owner, { observe = true } = {}) {
|
|
301
|
+
if (!/^hop_[a-f0-9-]{36}$/.test(args.operationId ?? '')) error('HARBOR_ACTION_INVALID', 'Invalid operation ID.')
|
|
302
|
+
const cached = this.operations.get(args.operationId)
|
|
303
|
+
if (cached) {
|
|
304
|
+
if (!sameOwner(cached, owner)) error('HARBOR_ACTION_DENIED', 'Operation belongs to another Session.')
|
|
305
|
+
return this.decorate(cached.operation, owner, { observe })
|
|
306
|
+
}
|
|
307
|
+
const directory = await journalDirectory(owner.projectRoot, false)
|
|
308
|
+
let operation
|
|
309
|
+
for (let sequence = 1; sequence <= 32; sequence += 1) {
|
|
310
|
+
const file = path.join(directory, `${args.operationId}.${sequence}.json`)
|
|
311
|
+
try {
|
|
312
|
+
operation = (await safeRecord(file)).value
|
|
313
|
+
if (operation.operationId !== args.operationId || operation.schema !== 'harbor-operation/v1' || !Array.isArray(operation.events) || operation.events.length !== sequence) error('HARBOR_ACTION_STORAGE_UNSAFE', 'Operation journal identity or sequence is invalid.')
|
|
314
|
+
} catch (e) { if (e.code !== 'ENOENT') throw e; break }
|
|
315
|
+
}
|
|
316
|
+
if (!operation || operation.sessionId !== owner.sessionId) error('HARBOR_ACTION_DENIED', 'Operation unavailable in this Session.')
|
|
317
|
+
if (!terminal(operation.status)) operation = { ...operation, status: 'INTERRUPTED', recoveryRequired: true, recoveryMessage: 'Host execution ownership was lost. Inspect the existing Job and containers; this operation will not be resumed or retried automatically.' }
|
|
318
|
+
return this.decorate(operation, owner, { observe })
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
async decorate(operation, owner, { observe = true } = {}) {
|
|
322
|
+
const value = structuredClone(operation)
|
|
323
|
+
if (!diagnosticOperation(value)) return value
|
|
324
|
+
try {
|
|
325
|
+
const directory = await journalDirectory(owner.projectRoot, false)
|
|
326
|
+
const recovery = (await safeRecord(path.join(directory, `${value.operationId}.recovery-released.json`), 16 * 1024)).value
|
|
327
|
+
if (recovery.operationId !== value.operationId || recovery.sessionId !== owner.sessionId || recovery.released !== true) error('HARBOR_ACTION_STORAGE_UNSAFE', 'Invalid recovery receipt.')
|
|
328
|
+
value.recovery = recovery
|
|
329
|
+
value.cleanupRequired = false
|
|
330
|
+
value.recoveryRequired = false
|
|
331
|
+
} catch (cause) { if (cause.code !== 'ENOENT') throw cause }
|
|
332
|
+
if (observe && this.observe) {
|
|
333
|
+
try { Object.assign(value, await this.observe(value, owner)) }
|
|
334
|
+
catch { value.observationWarning = 'HARBOR_DIAGNOSTIC_OBSERVATION_UNAVAILABLE' }
|
|
335
|
+
}
|
|
336
|
+
const usage = this.tasks.get(value.operationId)?.readUsage?.() ?? value.modelUsage
|
|
337
|
+
if (Number.isSafeInteger(usage?.modelRequests) && Number.isSafeInteger(usage?.maxModelRequests)) value.progress = { ...(value.progress ?? { source: 'host-model-broker' }), modelRequests: usage.modelRequests, maxModelRequests: usage.maxModelRequests }
|
|
338
|
+
return value
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
async list(args = {}, owner) {
|
|
342
|
+
if (!owner?.sessionId || !owner.projectRoot) error('HARBOR_ACTION_DENIED', 'A current Session/project is required.')
|
|
343
|
+
const limit = typeof args.limit === 'string' && /^[1-9][0-9]{0,2}$/.test(args.limit) ? Number(args.limit) : args.limit ?? 20
|
|
344
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100 || (args.cursor !== undefined && !/^hop_[a-f0-9-]{36}$/.test(args.cursor))) error('HARBOR_ACTION_INVALID', 'Invalid operation page.')
|
|
345
|
+
let directory
|
|
346
|
+
try { directory = await journalDirectory(owner.projectRoot, false) }
|
|
347
|
+
catch (cause) { if (cause.code === 'ENOENT') return { items: [] }; throw cause }
|
|
348
|
+
const ids = (await readdir(directory)).filter(name => /^hop_[a-f0-9-]{36}\.1\.json$/.test(name)).map(name => name.slice(0, -7))
|
|
349
|
+
const items = []
|
|
350
|
+
for (const operationId of ids) {
|
|
351
|
+
// Read the first record to establish ownership BEFORE observation. A
|
|
352
|
+
// foreign Session is not told an operation ID, source Job or count.
|
|
353
|
+
const first = (await safeRecord(path.join(directory, `${operationId}.1.json`))).value
|
|
354
|
+
if (first.sessionId !== owner.sessionId || !diagnosticOperation(first)) continue
|
|
355
|
+
items.push(await this.operation({ operationId }, owner, { observe: false }))
|
|
356
|
+
}
|
|
357
|
+
items.sort((a, b) => {
|
|
358
|
+
const active = item => !terminal(item.status) || ((item.cleanupRequired || item.recoveryRequired) && !item.recovery?.released)
|
|
359
|
+
return Number(active(b)) - Number(active(a)) || String(b.createdAt).localeCompare(String(a.createdAt)) || a.operationId.localeCompare(b.operationId)
|
|
360
|
+
})
|
|
361
|
+
const start = args.cursor ? items.findIndex(item => item.operationId === args.cursor) + 1 : 0
|
|
362
|
+
if (args.cursor && start === 0) error('HARBOR_ACTION_INVALID', 'Operation page expired. Reload the task list.')
|
|
363
|
+
const page = await Promise.all(items.slice(start, start + limit).map(operation => this.decorate(operation, owner)))
|
|
364
|
+
return { items: page, ...(start + limit < items.length ? { nextCursor: page.at(-1).operationId } : {}) }
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
async inspectionState(operation, owner, { ownedRecoveryLock } = {}) {
|
|
368
|
+
const directory = await journalDirectory(owner.projectRoot, false)
|
|
369
|
+
let claim
|
|
370
|
+
try { claim = (await safeRecord(path.join(directory, 'diagnostic-active.json'), 1024)).value }
|
|
371
|
+
catch (cause) { if (cause.code !== 'ENOENT') throw cause }
|
|
372
|
+
if (claim && (claim.operationId !== operation.operationId || claim.sessionId !== owner.sessionId)) error('HARBOR_ACTION_DENIED', 'The active diagnostic claim belongs to another Operation/Session.')
|
|
373
|
+
let pendingReceipt = false
|
|
374
|
+
if (!claim && !operation.recovery?.released) {
|
|
375
|
+
try {
|
|
376
|
+
const approved = (await safeRecord(path.join(directory, `${operation.operationId}.recovery-approved.json`), 16 * 1024)).value
|
|
377
|
+
if (approved.operationId !== operation.operationId || approved.sessionId !== owner.sessionId || approved.released !== false) error('HARBOR_ACTION_STORAGE_UNSAFE', 'Invalid recovery approval receipt.')
|
|
378
|
+
pendingReceipt = true
|
|
379
|
+
} catch (cause) { if (cause.code !== 'ENOENT') throw cause }
|
|
380
|
+
}
|
|
381
|
+
const runner = this.inspectRunner ? await this.inspectRunner(operation, owner) : { process: { state: 'unknown' }, resources: { state: 'unknown', items: [] }, blockers: [{ code: 'DIAGNOSTIC_INSPECTOR_UNAVAILABLE', message: 'Update the Host to inspect diagnostic resources safely.' }], canRecover: false }
|
|
382
|
+
const blockers = [...(runner.blockers ?? [])]
|
|
383
|
+
try {
|
|
384
|
+
const lock = (await safeRecord(path.join(directory, `${operation.operationId}.recovery-lock.json`), 1024)).value
|
|
385
|
+
if (lock.sessionId !== owner.sessionId) error('HARBOR_ACTION_DENIED', 'Recovery lock belongs to another Session.')
|
|
386
|
+
if (!ownedRecoveryLock || lock.inspectionId !== ownedRecoveryLock) blockers.push({ code: 'DIAGNOSTIC_RECOVERY_LOCK_PRESENT', message: `Another recovery or interrupted Host owns ${operation.operationId}.recovery-lock.json. Wait and refresh. If its Host stopped, ask the workspace administrator to reconcile that exact control lock; it is never removed automatically.` })
|
|
387
|
+
} catch (cause) { if (cause.code !== 'ENOENT') throw cause }
|
|
388
|
+
if (this.tasks.has(operation.operationId)) blockers.push({ code: 'DIAGNOSTIC_HOST_STILL_OWNS_RUN', message: 'This Host still owns the execution. Cancel it or wait for cleanup, then inspect again.' })
|
|
389
|
+
if (!claim && !pendingReceipt) blockers.push({ code: 'DIAGNOSTIC_CLAIM_NOT_PRESENT', message: 'No matching workspace claim remains; there is no lock to release.' })
|
|
390
|
+
const canRecover = Boolean(claim || pendingReceipt) && !operation.recovery?.released && !this.tasks.has(operation.operationId) && terminal(operation.status) && runner.canRecover === true && runner.process?.state === 'stopped' && runner.resources?.state === 'clean' && blockers.length === 0
|
|
391
|
+
return { ...runner, blockers, canRecover, claim: claim ? { operationId: claim.operationId, sessionId: claim.sessionId } : null, pendingReceipt, operationStatus: operation.status, lastSequence: operation.events?.length ?? 0 }
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
async inspect(args, owner) {
|
|
395
|
+
this.prune()
|
|
396
|
+
if (this.inspections.size >= this.maxEntries) error('HARBOR_ACTION_CAPACITY', 'Too many pending recovery inspections.')
|
|
397
|
+
const operation = await this.operation(args, owner)
|
|
398
|
+
if (!diagnosticOperation(operation)) error('HARBOR_ACTION_INVALID', 'Only diagnostic Operations require resource recovery.')
|
|
399
|
+
const state = await this.inspectionState(operation, owner)
|
|
400
|
+
const inspectionId = `hinspect_${randomUUID()}`
|
|
401
|
+
const expiresAtMs = this.now() + 60_000
|
|
402
|
+
const value = { schema: 'harbor-operation-inspection/v1', operationId: operation.operationId, inspectionId, contentHash: localObjectDigest(state), expiresAt: new Date(expiresAtMs).toISOString(), ...state }
|
|
403
|
+
this.inspections.set(inspectionId, { ...owner, value, expiresAtMs })
|
|
404
|
+
return structuredClone(value)
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
async recover(args, owner) {
|
|
408
|
+
const entry = this.owned(this.inspections, args.inspectionId, owner)
|
|
409
|
+
if (args.confirmed !== true || args.operationId !== entry.value.operationId || args.contentHash !== entry.value.contentHash) error('HARBOR_ACTION_CONFIRMATION_REQUIRED', 'Review and explicitly confirm the exact cleanup inspection.')
|
|
410
|
+
if (!entry.value.canRecover) error('HARBOR_ACTION_BLOCKED', 'The process/resources have not been proven stopped and clean.')
|
|
411
|
+
const operation = await this.operation(args, owner)
|
|
412
|
+
if (operation.recovery?.released) return operation
|
|
413
|
+
const directory = await journalDirectory(owner.projectRoot, false)
|
|
414
|
+
const lockFile = path.join(directory, `${operation.operationId}.recovery-lock.json`)
|
|
415
|
+
let locked = false
|
|
416
|
+
try {
|
|
417
|
+
await writeFile(lockFile, JSON.stringify({ sessionId: owner.sessionId, inspectionId: args.inspectionId }), { flag: 'wx', mode: 0o600 })
|
|
418
|
+
locked = true
|
|
419
|
+
const current = await this.inspectionState(operation, owner, { ownedRecoveryLock: args.inspectionId })
|
|
420
|
+
if (!current.canRecover || localObjectDigest(current) !== entry.value.contentHash) error('HARBOR_ACTION_REVISION_CONFLICT', 'Process, resources or Operation changed. Inspect again; the claim was not released.')
|
|
421
|
+
const claimFile = path.join(directory, 'diagnostic-active.json')
|
|
422
|
+
const claim = current.claim ? await safeRecord(claimFile, 1024) : undefined
|
|
423
|
+
if (claim && (claim.value.operationId !== operation.operationId || claim.value.sessionId !== owner.sessionId)) error('HARBOR_ACTION_DENIED', 'The diagnostic claim changed; nothing was released.')
|
|
424
|
+
const receipt = { schema: 'harbor-operation-recovery/v1', operationId: operation.operationId, sessionId: owner.sessionId, inspectionId: args.inspectionId, contentHash: args.contentHash, released: true, releasedAt: new Date(this.now()).toISOString(), actor: 'local-workspace-user', process: current.process, resources: current.resources, productionImpact: 'none', rerun: false }
|
|
425
|
+
// Approval is durable before the only mutation (unlinking our exact
|
|
426
|
+
// claim); resource deletion or process signalling is never performed.
|
|
427
|
+
const approved = path.join(directory, `${operation.operationId}.recovery-approved.json`)
|
|
428
|
+
try { await writeFile(approved, JSON.stringify({ ...receipt, released: false }), { flag: 'wx', mode: 0o600 }) }
|
|
429
|
+
catch (cause) { if (cause.code !== 'EEXIST') throw cause; const previous = (await safeRecord(approved, 16 * 1024)).value; if (previous.operationId !== operation.operationId || previous.sessionId !== owner.sessionId) error('HARBOR_ACTION_STORAGE_UNSAFE', 'Recovery approval ownership changed.') }
|
|
430
|
+
await journalDirectory(owner.projectRoot, false)
|
|
431
|
+
if (claim) {
|
|
432
|
+
const latest = await safeRecord(claimFile, 1024)
|
|
433
|
+
if (latest.info.ino !== claim.info.ino || latest.info.dev !== claim.info.dev || localObjectDigest(latest.value) !== localObjectDigest(claim.value)) error('HARBOR_ACTION_REVISION_CONFLICT', 'Diagnostic claim changed; inspect again.')
|
|
434
|
+
await unlink(claimFile)
|
|
435
|
+
}
|
|
436
|
+
await writeFile(path.join(directory, `${operation.operationId}.recovery-released.json`), JSON.stringify(receipt), { flag: 'wx', mode: 0o600 })
|
|
437
|
+
return this.operation(args, owner)
|
|
438
|
+
} catch (cause) {
|
|
439
|
+
if (!locked && cause.code === 'EEXIST') error('HARBOR_ACTION_RECOVERY_BUSY', 'Another recovery owns this Operation. Wait and refresh; stale recovery locks require administrator inspection.')
|
|
440
|
+
throw cause
|
|
441
|
+
} finally { if (locked) await unlink(lockFile) }
|
|
442
|
+
}
|
|
443
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process'
|
|
2
|
+
import { dockerDesktopAwareEnv } from './process.js'
|
|
3
|
+
|
|
4
|
+
const DEFAULT_TIMEOUT_MS = 30 * 60_000
|
|
5
|
+
const DEFAULT_OUTPUT_BYTES = 1024 * 1024
|
|
6
|
+
const MAX_TIMER_MS = 2_147_483_647
|
|
7
|
+
|
|
8
|
+
function processError(code, message) {
|
|
9
|
+
return Object.assign(new Error(`${code}: ${message}`), { code })
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function integerOption(value, fallback, minimum, maximum, name) {
|
|
13
|
+
const selected = value ?? fallback
|
|
14
|
+
if (!Number.isSafeInteger(selected) || selected < minimum || selected > maximum) {
|
|
15
|
+
throw processError('HARBOR_PROCESS_INVALID_OPTIONS', `${name} is outside the supported bounds.`)
|
|
16
|
+
}
|
|
17
|
+
return selected
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function decodeBounded(chunks) {
|
|
21
|
+
const raw = Buffer.concat(chunks)
|
|
22
|
+
const decoded = raw.toString('utf8')
|
|
23
|
+
const encoded = Buffer.from(decoded, 'utf8')
|
|
24
|
+
if (encoded.length <= raw.length) return decoded
|
|
25
|
+
// Invalid/incomplete UTF-8 expands into replacement characters. Keep even
|
|
26
|
+
// the returned text within the original byte budget, at a code-point edge.
|
|
27
|
+
let end = raw.length
|
|
28
|
+
while (end > 0 && (encoded[end] & 0xc0) === 0x80) end -= 1
|
|
29
|
+
return encoded.subarray(0, end).toString('utf8')
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Run a controller-owned, bounded subprocess. No command/arguments or Abort
|
|
34
|
+
* reasons are copied into public error messages. Output in error.result is
|
|
35
|
+
* bounded but still private diagnostic data: the caller must redact it.
|
|
36
|
+
*
|
|
37
|
+
* POSIX children get their own process group, so cancellation also signals
|
|
38
|
+
* descendants that remain in that group. Windows supports direct-child
|
|
39
|
+
* cancellation only; this is not a Windows process-tree isolation boundary.
|
|
40
|
+
* A descendant that deliberately starts a new process group is outside this
|
|
41
|
+
* primitive's ownership boundary and requires a separate sandbox supervisor.
|
|
42
|
+
*/
|
|
43
|
+
export function runBoundedProcess(command, args, options = {}) {
|
|
44
|
+
return new Promise((resolve, reject) => {
|
|
45
|
+
let timeoutMs, maxOutputBytes, killGraceMs
|
|
46
|
+
try {
|
|
47
|
+
timeoutMs = integerOption(options.timeoutMs, DEFAULT_TIMEOUT_MS, 1, MAX_TIMER_MS, 'timeoutMs')
|
|
48
|
+
maxOutputBytes = integerOption(options.maxOutputBytes, DEFAULT_OUTPUT_BYTES, 0, 64 * 1024 * 1024, 'maxOutputBytes')
|
|
49
|
+
const maxInputBytes = integerOption(options.maxInputBytes, DEFAULT_OUTPUT_BYTES, 0, 64 * 1024 * 1024, 'maxInputBytes')
|
|
50
|
+
killGraceMs = integerOption(options.killGraceMs, 1500, 1, 30_000, 'killGraceMs')
|
|
51
|
+
if (options.input !== undefined && typeof options.input !== 'string' && !Buffer.isBuffer(options.input) && !(options.input instanceof Uint8Array)) {
|
|
52
|
+
throw processError('HARBOR_PROCESS_INVALID_OPTIONS', 'input must be text or bytes.')
|
|
53
|
+
}
|
|
54
|
+
if (options.input !== undefined && Buffer.byteLength(options.input) > maxInputBytes) {
|
|
55
|
+
throw processError('HARBOR_PROCESS_INPUT_LIMIT', 'Process input exceeded its byte budget.')
|
|
56
|
+
}
|
|
57
|
+
if (options.signal && (typeof options.signal.addEventListener !== 'function' || typeof options.signal.removeEventListener !== 'function')) {
|
|
58
|
+
throw processError('HARBOR_PROCESS_INVALID_OPTIONS', 'signal must be an AbortSignal.')
|
|
59
|
+
}
|
|
60
|
+
if (options.onSpawn !== undefined && typeof options.onSpawn !== 'function') {
|
|
61
|
+
throw processError('HARBOR_PROCESS_INVALID_OPTIONS', 'onSpawn must be a function.')
|
|
62
|
+
}
|
|
63
|
+
if (options.allowedExitCodes !== undefined && (!Array.isArray(options.allowedExitCodes) || options.allowedExitCodes.some(code => !Number.isInteger(code)))) {
|
|
64
|
+
throw processError('HARBOR_PROCESS_INVALID_OPTIONS', 'allowedExitCodes must contain integer exit codes.')
|
|
65
|
+
}
|
|
66
|
+
} catch (error) { reject(error); return }
|
|
67
|
+
|
|
68
|
+
if (options.signal?.aborted) {
|
|
69
|
+
reject(processError('HARBOR_PROCESS_ABORTED', 'Execution was cancelled before launch.'))
|
|
70
|
+
return
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const posixGroup = process.platform !== 'win32'
|
|
74
|
+
let child
|
|
75
|
+
try {
|
|
76
|
+
child = spawn(command, args, {
|
|
77
|
+
cwd: options.cwd,
|
|
78
|
+
env: dockerDesktopAwareEnv(options.env ?? process.env),
|
|
79
|
+
shell: false,
|
|
80
|
+
detached: posixGroup,
|
|
81
|
+
windowsHide: true,
|
|
82
|
+
stdio: [options.input === undefined ? 'ignore' : 'pipe', 'pipe', 'pipe'],
|
|
83
|
+
})
|
|
84
|
+
} catch {
|
|
85
|
+
reject(processError('HARBOR_PROCESS_SPAWN_FAILED', 'The evaluation process could not be started.'))
|
|
86
|
+
return
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const stdout = [], stderr = []
|
|
90
|
+
let retainedBytes = 0
|
|
91
|
+
let closed = false, settled = false, terminating = false, terminationFinished = false
|
|
92
|
+
let exitCode = null, exitSignal = null, terminalError, killTimer, deadline
|
|
93
|
+
let spawnHookPending = false, groupRetired = false
|
|
94
|
+
|
|
95
|
+
const groupExists = () => {
|
|
96
|
+
if (!posixGroup || !child.pid || groupRetired) return false
|
|
97
|
+
try { process.kill(-child.pid, 0); return true }
|
|
98
|
+
catch (error) { return error.code !== 'ESRCH' }
|
|
99
|
+
}
|
|
100
|
+
const signalOwnedProcess = signal => {
|
|
101
|
+
// Once the owned group disappeared, never signal a potentially reused
|
|
102
|
+
// pid/group id while an asynchronous checkpoint is still pending.
|
|
103
|
+
if (groupRetired) return
|
|
104
|
+
if (posixGroup && child.pid) {
|
|
105
|
+
try { process.kill(-child.pid, signal); return }
|
|
106
|
+
catch (error) { if (error.code !== 'ESRCH') return }
|
|
107
|
+
}
|
|
108
|
+
// Failed spawns have no pid; Windows uses the direct child only.
|
|
109
|
+
if (!closed && child.pid) {
|
|
110
|
+
try { child.kill(signal) } catch {}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
const finish = () => {
|
|
114
|
+
if (settled || !closed || (terminating && !terminationFinished) || (spawnHookPending && !terminalError)) return
|
|
115
|
+
settled = true
|
|
116
|
+
clearTimeout(deadline)
|
|
117
|
+
clearTimeout(killTimer)
|
|
118
|
+
options.signal?.removeEventListener('abort', abort)
|
|
119
|
+
const result = { code: exitCode, stdout: decodeBounded(stdout), stderr: decodeBounded(stderr) }
|
|
120
|
+
if (!terminalError && !(options.allowedExitCodes ?? [0]).includes(exitCode)) {
|
|
121
|
+
terminalError = processError('HARBOR_PROCESS_EXIT_FAILED', exitSignal ? 'The evaluation process was terminated by a signal.' : 'The evaluation process exited unsuccessfully.')
|
|
122
|
+
}
|
|
123
|
+
if (terminalError) reject(Object.assign(terminalError, { result }))
|
|
124
|
+
else resolve(result)
|
|
125
|
+
}
|
|
126
|
+
const terminate = error => {
|
|
127
|
+
if (settled) return
|
|
128
|
+
terminalError ??= error
|
|
129
|
+
if (terminating) return
|
|
130
|
+
terminating = true
|
|
131
|
+
signalOwnedProcess('SIGTERM')
|
|
132
|
+
killTimer = setTimeout(() => {
|
|
133
|
+
signalOwnedProcess('SIGKILL')
|
|
134
|
+
terminationFinished = true
|
|
135
|
+
// Never resolve/reject until the actual ChildProcess close event.
|
|
136
|
+
finish()
|
|
137
|
+
}, killGraceMs)
|
|
138
|
+
}
|
|
139
|
+
const abort = () => terminate(processError('HARBOR_PROCESS_ABORTED', 'Execution was cancelled.'))
|
|
140
|
+
const capture = target => chunk => {
|
|
141
|
+
const available = Math.max(0, maxOutputBytes - retainedBytes)
|
|
142
|
+
if (available > 0) {
|
|
143
|
+
const retained = chunk.subarray(0, available)
|
|
144
|
+
target.push(retained)
|
|
145
|
+
retainedBytes += retained.length
|
|
146
|
+
}
|
|
147
|
+
if (chunk.length > available) terminate(processError('HARBOR_PROCESS_OUTPUT_LIMIT', 'Combined process output exceeded its byte budget.'))
|
|
148
|
+
}
|
|
149
|
+
child.stdout.on('data', capture(stdout))
|
|
150
|
+
child.stderr.on('data', capture(stderr))
|
|
151
|
+
child.once('spawn', () => {
|
|
152
|
+
if (!options.onSpawn) return
|
|
153
|
+
spawnHookPending = true
|
|
154
|
+
Promise.resolve().then(() => options.onSpawn(child.pid)).then(
|
|
155
|
+
() => { spawnHookPending = false; finish() },
|
|
156
|
+
() => { spawnHookPending = false; terminate(processError('HARBOR_PROCESS_CHECKPOINT_FAILED', 'The process ownership checkpoint could not be recorded.')); finish() },
|
|
157
|
+
)
|
|
158
|
+
})
|
|
159
|
+
child.once('error', () => {
|
|
160
|
+
// Even ENOENT is followed by close; keep a single lifecycle boundary.
|
|
161
|
+
terminalError ??= processError('HARBOR_PROCESS_SPAWN_FAILED', 'The evaluation process could not be started.')
|
|
162
|
+
if (child.pid) terminate(terminalError)
|
|
163
|
+
})
|
|
164
|
+
child.once('close', (code, signal) => {
|
|
165
|
+
closed = true
|
|
166
|
+
exitCode = code
|
|
167
|
+
exitSignal = signal
|
|
168
|
+
// A successful parent must not leave background work in its owned group.
|
|
169
|
+
const hasDescendants = groupExists()
|
|
170
|
+
if (!hasDescendants) groupRetired = true
|
|
171
|
+
if (!terminating && hasDescendants) terminate()
|
|
172
|
+
if (terminating && !hasDescendants) {
|
|
173
|
+
clearTimeout(killTimer)
|
|
174
|
+
terminationFinished = true
|
|
175
|
+
}
|
|
176
|
+
finish()
|
|
177
|
+
})
|
|
178
|
+
options.signal?.addEventListener('abort', abort, { once: true })
|
|
179
|
+
if (options.signal?.aborted) abort()
|
|
180
|
+
deadline = setTimeout(() => terminate(processError('HARBOR_PROCESS_TIMEOUT', 'Execution exceeded its time budget.')), timeoutMs)
|
|
181
|
+
if (child.stdin) {
|
|
182
|
+
child.stdin.on('error', error => {
|
|
183
|
+
// EPIPE means the child has already stopped reading; close/exit still
|
|
184
|
+
// decides the result. Other pipe failures stop the owned process.
|
|
185
|
+
if (error.code !== 'EPIPE') terminate(processError('HARBOR_PROCESS_INPUT_FAILED', 'Process input could not be delivered.'))
|
|
186
|
+
})
|
|
187
|
+
child.stdin.end(options.input)
|
|
188
|
+
}
|
|
189
|
+
})
|
|
190
|
+
}
|