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.
@@ -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
+ }