@deepblend/dsh-blender-host 0.1.0

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,290 @@
1
+ /**
2
+ * Render Job Store — the durable record of a render that must survive a restart.
3
+ *
4
+ * Plane: HOST composition (SPEC §4.1 "DeepBlend Persistent Job Store", §4.3
5
+ * "渲染任务恢复 → Host / 需要重启后恢复"). Process-level, cross-session, on disk.
6
+ *
7
+ * WHERE IT LIVES, AND WHY NOT IN `jobs/`
8
+ * -------------------------------------
9
+ * ```
10
+ * <project>/
11
+ * jobs/<jobId>.json attempt log, one per Blender action (M1)
12
+ * renders/
13
+ * <jobId>/
14
+ * job.json THE RENDER JOB — this module (M3)
15
+ * plan.json the frame list handed to the renderer
16
+ * process.json the pid the CHILD claimed
17
+ * events.jsonl the child's fsynced per-frame journal
18
+ * result.json the child's bootstrap envelope
19
+ * frames/frame_0001.png … the deliverable's frames
20
+ * encoded/<jobId>.mp4 the encoded video, before publication
21
+ * manifest.json the render manifest for this job
22
+ * ```
23
+ *
24
+ * `jobs/` holds M1's attempt log: written once at the start of an action and once
25
+ * at the end, because every M1/M2 action finished inside the tool call that
26
+ * started it. The durable render job is a different object with a different
27
+ * lifetime (SPEC §10.2), and `allocateJobId` counts `*.json` in `jobs/` to mint
28
+ * its ids — writing render jobs there would both inflate that counter and make
29
+ * "list this project's attempts" return two shapes.
30
+ *
31
+ * WHY THE RECORD IS WRITTEN SO OFTEN
32
+ * ----------------------------------
33
+ * Every mutation is an atomic write of the whole document (`writeJsonAtomic`:
34
+ * temp file in the same directory, then `rename`). At ~29 s per frame, one write
35
+ * per frame costs nothing measurable, and the alternative — batching — means a
36
+ * kill loses the very progress the record exists to preserve. A reader therefore
37
+ * sees either the previous complete document or the new one, never a torn one.
38
+ *
39
+ * Owner: DeepBlend Studio — M3
40
+ */
41
+
42
+ import { existsSync, readdirSync } from 'node:fs'
43
+ import { join } from 'node:path'
44
+
45
+ import {
46
+ BlenderError,
47
+ BlenderErrorCode,
48
+ RENDER_JOB_VERSION,
49
+ RENDER_JOB_STATUSES,
50
+ RENDER_JOB_TERMINAL_STATUSES,
51
+ canTransitionRenderJob,
52
+ checkTransition,
53
+ frameNumbers,
54
+ isTerminalRenderJobStatus,
55
+ } from '@deepblend/dsh-blender-contracts'
56
+
57
+ import { ensureDirectory, readJson, requireSafeSegment, resolveInside, writeJsonAtomic } from './paths.js'
58
+
59
+ /** Directory under the project that holds render jobs. */
60
+ export const RENDER_JOBS_DIRECTORY = 'renders'
61
+
62
+ /**
63
+ * A render job id, safe as a single path segment.
64
+ *
65
+ * Minted from the project and a monotonically increasing counter rather than a
66
+ * timestamp: the id appears in a tool result the model reasons about and in a
67
+ * directory a human opens, and `render-0001` reads as "the first delivery render"
68
+ * where `render-20260913143502-3` reads as noise.
69
+ *
70
+ * @param {number} ordinal
71
+ * @returns {string}
72
+ */
73
+ export function formatRenderJobId(ordinal) {
74
+ return `render-${String(ordinal).padStart(4, '0')}`
75
+ }
76
+
77
+ /** The statuses that mean "this job still owes the project frames". */
78
+ /**
79
+ * Statuses in which a job is still LIVE — derived, not restated.
80
+ *
81
+ * This was a fourth hand-written copy of the vocabulary (`['queued', 'running', 'stopping', 'recovering']`),
82
+ * which is one fact in two places: a status added to `RENDER_JOB_STATUSES` and not to this list would make
83
+ * `_activeRenderJob` answer "no render is running" for a job that is running, and the delivery slot would be
84
+ * handed to a second renderer. The complement of the terminal statuses IS this list, so it is computed.
85
+ */
86
+ export const UNFINISHED_STATUSES = Object.freeze(
87
+ RENDER_JOB_STATUSES.filter(status => !RENDER_JOB_TERMINAL_STATUSES.includes(status)),
88
+ )
89
+
90
+ export class RenderJobStore {
91
+ /**
92
+ * @param {{ projectDirectory: (projectId: string) => string, workspaceRoot: string }} options
93
+ */
94
+ constructor(options) {
95
+ this.projectDirectory = options.projectDirectory
96
+ this.workspaceRoot = options.workspaceRoot
97
+ }
98
+
99
+ /** `<project>/renders` */
100
+ rendersRoot(projectId) {
101
+ return join(this.projectDirectory(projectId), RENDER_JOBS_DIRECTORY)
102
+ }
103
+
104
+ /** `<project>/renders/<jobId>` */
105
+ jobDirectory(projectId, jobId) {
106
+ requireSafeSegment(jobId, 'render job id')
107
+ return resolveInside(this.workspaceRoot, join(this.rendersRoot(projectId), jobId), 'render job directory')
108
+ }
109
+
110
+ /** `<project>/renders/<jobId>/frames` */
111
+ framesDirectory(projectId, jobId) {
112
+ return join(this.jobDirectory(projectId, jobId), 'frames')
113
+ }
114
+
115
+ /** Create the job directory tree. */
116
+ ensureJobDirectory(projectId, jobId) {
117
+ return ensureDirectory(this.workspaceRoot, this.jobDirectory(projectId, jobId), 'render job directory')
118
+ }
119
+
120
+ /** `<project>/renders/<jobId>/job.json` */
121
+ recordPath(projectId, jobId) {
122
+ return join(this.jobDirectory(projectId, jobId), 'job.json')
123
+ }
124
+
125
+ /**
126
+ * Mint the next render job id for a project.
127
+ *
128
+ * The counter is derived from the directory listing rather than stored, so it
129
+ * cannot drift from what exists: a deleted job frees its ordinal, and a
130
+ * restored project keeps the ids it shipped with.
131
+ *
132
+ * @param {string} projectId
133
+ * @returns {string}
134
+ */
135
+ allocateJobId(projectId) {
136
+ const root = this.rendersRoot(projectId)
137
+ const existing = existsSync(root)
138
+ ? readdirSync(root).map(name => /^render-(\d+)$/.exec(name)).filter(Boolean).map(match => Number.parseInt(match[1], 10))
139
+ : []
140
+ const next = existing.length === 0 ? 1 : Math.max(...existing) + 1
141
+ return formatRenderJobId(next)
142
+ }
143
+
144
+ /**
145
+ * Persist a render job record.
146
+ *
147
+ * Validates the status transition first. An illegal transition here is a bug in
148
+ * the caller, and the failure it prevents is a record in a state no reader can
149
+ * act on — for example `completed` with missing frames, which would make the
150
+ * reconciler consider the job finished.
151
+ *
152
+ * @param {object} record
153
+ * @param {{allowIllegalTransition?: boolean}} [options]
154
+ * @returns {object}
155
+ */
156
+ write(record, options = {}) {
157
+ const previous = options.previous ?? null
158
+ if (previous !== null && !canTransitionRenderJob(previous.status, record.status)) {
159
+ const verdict = checkTransition(previous.status, record.status)
160
+ throw new BlenderError(
161
+ BlenderErrorCode.RENDER_JOB_STATE_INVALID,
162
+ `${verdict.reason} (job ${record.jobId})`,
163
+ { detail: { jobId: record.jobId, from: previous.status, to: record.status } },
164
+ )
165
+ }
166
+ this.ensureJobDirectory(record.projectId, record.jobId)
167
+ const next = { ...record, schemaVersion: RENDER_JOB_VERSION, updatedAt: Date.now() }
168
+ writeJsonAtomic(this.recordPath(record.projectId, record.jobId), next)
169
+ return next
170
+ }
171
+
172
+ /**
173
+ * Read a render job record, or throw.
174
+ * @param {string} projectId
175
+ * @param {string} jobId
176
+ * @returns {object}
177
+ */
178
+ read(projectId, jobId) {
179
+ const path = this.recordPath(projectId, jobId)
180
+ const record = readJson(path)
181
+ if (record === null) {
182
+ throw new BlenderError(
183
+ BlenderErrorCode.RENDER_JOB_NOT_FOUND,
184
+ `Project "${projectId}" has no render job "${jobId}".`,
185
+ { detail: { projectId, jobId } },
186
+ )
187
+ }
188
+ return record
189
+ }
190
+
191
+ /** Read a record without throwing, for a scan. */
192
+ readSafe(projectId, jobId) {
193
+ return readJson(this.recordPath(projectId, jobId))
194
+ }
195
+
196
+ /** Every render job id under a project, in ordinal order. */
197
+ listJobIds(projectId) {
198
+ const root = this.rendersRoot(projectId)
199
+ if (!existsSync(root)) return []
200
+ return readdirSync(root)
201
+ .map(name => /^render-(\d+)$/.exec(name))
202
+ .filter(match => match !== null)
203
+ .map(match => ({ id: match[0], ordinal: Number.parseInt(match[1], 10) }))
204
+ .sort((left, right) => left.ordinal - right.ordinal)
205
+ .map(entry => entry.id)
206
+ }
207
+
208
+ /**
209
+ * Every render job record in a project, newest last.
210
+ * @param {string} projectId
211
+ * @returns {object[]}
212
+ */
213
+ list(projectId) {
214
+ const records = []
215
+ for (const jobId of this.listJobIds(projectId)) {
216
+ const record = this.readSafe(projectId, jobId)
217
+ if (record !== null) records.push(record)
218
+ }
219
+ return records
220
+ }
221
+
222
+ /**
223
+ * The jobs that still owe frames — the reconciler's input set.
224
+ *
225
+ * A record that cannot be PARSED is included as a job id with a null record, so
226
+ * a corrupt record is surfaced rather than skipped. A scan that silently drops
227
+ * what it cannot read reports "nothing unfinished" for a directory that has an
228
+ * unfinished job in it, which is the one answer restart recovery must never give.
229
+ *
230
+ * @param {string} projectId
231
+ * @returns {{jobId: string, record: object|null}[]}
232
+ */
233
+ unfinished(projectId) {
234
+ const found = []
235
+ for (const jobId of this.listJobIds(projectId)) {
236
+ const record = this.readSafe(projectId, jobId)
237
+ if (record === null) {
238
+ found.push({ jobId, record: null })
239
+ continue
240
+ }
241
+ if (!isTerminalRenderJobStatus(record.status)) found.push({ jobId, record })
242
+ }
243
+ return found
244
+ }
245
+
246
+ /**
247
+ * The unfinished jobs of every project under a root.
248
+ *
249
+ * SPEC §10.3 says the reconciler runs "at Host startup", and a scan of every
250
+ * project is the honest reading: an unfinished render is exactly the thing a
251
+ * user comes back to the workbench to find, and a project they open first would
252
+ * otherwise be the only one that reports it. The scan touches one small JSON
253
+ * document per job plus a directory listing per project, so its cost is bounded
254
+ * by the number of projects, not by the number of frames.
255
+ *
256
+ * @param {string[]} projectIds
257
+ * @returns {{projectId: string, jobId: string, record: object|null}[]}
258
+ */
259
+ unfinishedAcross(projectIds) {
260
+ const found = []
261
+ for (const projectId of projectIds) {
262
+ for (const entry of this.unfinished(projectId)) {
263
+ found.push({ projectId, jobId: entry.jobId, record: entry.record })
264
+ }
265
+ }
266
+ return found
267
+ }
268
+
269
+ /**
270
+ * The record's own frame range as an explicit list.
271
+ * @param {object} record
272
+ * @returns {number[]}
273
+ */
274
+ expectedFrames(record) {
275
+ const { frames, error } = frameNumbers(record.frameStart, record.frameEnd)
276
+ if (error !== null) {
277
+ throw new BlenderError(
278
+ BlenderErrorCode.RENDER_RANGE_INVALID,
279
+ `render job ${record.jobId} has ${error}`,
280
+ { detail: { jobId: record.jobId, frameStart: record.frameStart, frameEnd: record.frameEnd } },
281
+ )
282
+ }
283
+ return frames
284
+ }
285
+
286
+ /** True when the record is finished and owes nothing. */
287
+ static isTerminal(record) {
288
+ return RENDER_JOB_TERMINAL_STATUSES.includes(record?.status)
289
+ }
290
+ }
@@ -0,0 +1,236 @@
1
+ /**
2
+ * Render journal tail — reading the child's progress without trusting it.
3
+ *
4
+ * WHAT THE JOURNAL IS, AND WHAT IT IS NOT
5
+ * ---------------------------------------
6
+ * `bootstrap.py --events <path>` appends one JSON line per event and fsyncs each
7
+ * one, so the journal survives a `kill -9` of the Host. That makes it the right
8
+ * channel for the two facts disk cannot express on its own:
9
+ *
10
+ * - which pid is rendering (`process.json`, written before any bpy work), and
11
+ * - that frame N has been written AND verified by the renderer.
12
+ *
13
+ * It is **not** the authority for which frames exist. A frame can be present while
14
+ * its journal line is missing (killed between the file landing and the line being
15
+ * flushed), and a file can exist while not being a frame (killed mid-write). So
16
+ * this module only says "the renderer claims frame N landed"; the caller verifies
17
+ * that frame against its bytes before counting it (see `frame-ledger.js`).
18
+ *
19
+ * THE TORN-LINE RULE
20
+ * ------------------
21
+ * The journal is appended to by a process that can be killed at any instant, so
22
+ * the last line may be a partial write. The reader must NOT advance past a line it
23
+ * could not parse: the rest of that line arrives a moment later, and an offset
24
+ * that skipped it would lose an event permanently. So the offset advances only to
25
+ * the end of the last COMPLETE, parsed line.
26
+ *
27
+ * `tornLineSeen` says "the file ended mid-line as of the last read that reached the
28
+ * end of it", and it is recomputed on every such read rather than latched: a tail
29
+ * that completes on the next poll means the journal is whole, and a latched flag
30
+ * would go on accusing the renderer of losing an event that arrived.
31
+ *
32
+ * That flag is what a kill between two writes leaves behind — and it is ALSO what a
33
+ * line still being written looks like, so at read time the two are indistinguishable.
34
+ * The flag is a fact about the file; `tornLineIsEvidence({stopped})` is where the
35
+ * fact becomes reportable, and it takes the one thing only the caller knows: whether
36
+ * the writer has stopped for good.
37
+ *
38
+ * A COMPLETE line that does not parse is a different fact — a writer bug, not a
39
+ * torn kill — so it gets its own `unparseable-line` marker and does NOT raise the
40
+ * torn flag. Reporting one as the other would name the wrong cause.
41
+ *
42
+ * Owner: DeepBlend Studio — M3
43
+ * Plane: Host composition
44
+ */
45
+
46
+ import { closeSync, existsSync, openSync, readSync, statSync } from 'node:fs'
47
+
48
+ import { BlenderWarningCode, warning } from '@deepblend/dsh-blender-contracts'
49
+
50
+ /** Cap on one read, so a pathological journal cannot be read in one allocation. */
51
+ const MAX_READ_BYTES = 4 * 1024 * 1024
52
+
53
+ export class JournalTail {
54
+ /** @param {string} path */
55
+ constructor(path) {
56
+ this.path = path
57
+ this.offset = 0
58
+ /** Events already returned, so a caller can ask for the whole set. */
59
+ this.events = []
60
+ /** The file ended mid-line as of the last read that reached the end of it. */
61
+ this.tornLineSeen = false
62
+ /** The torn tail has already been reported, so it is not reported again. */
63
+ this.tornReported = false
64
+ }
65
+
66
+ /**
67
+ * Is the torn tail EVIDENCE yet — that is, should the caller report it?
68
+ *
69
+ * `tornLineSeen` is a fact about the file; this is where the fact becomes
70
+ * reportable, and it needs one thing only the caller knows: whether the writer has
71
+ * stopped. Mid-render an incomplete last line is the ordinary state of a line being
72
+ * written, and a reader that called every one of those a kill would put a false
73
+ * accusation in the log of every healthy render. Once the process is gone, the same
74
+ * bytes mean the last event never arrived.
75
+ *
76
+ * True at most once per journal: a poll repeats every second, and a diagnostic that
77
+ * repeats is noise. A `false` answer is not final, though — the caller asks again on
78
+ * the next read, which is how a tail that is still being written gets its chance to
79
+ * complete, and how one that never does gets reported once the writer is gone.
80
+ *
81
+ * @param {{stopped: boolean}} input - whether the writer has stopped for good.
82
+ * @returns {boolean}
83
+ */
84
+ tornLineIsEvidence(input) {
85
+ if (this.tornReported === true || this.tornLineSeen !== true) return false
86
+ if (input?.stopped !== true) return false
87
+ this.tornReported = true
88
+ return true
89
+ }
90
+
91
+ /**
92
+ * Read every complete line appended since the last call.
93
+ *
94
+ * @returns {object[]} the newly parsed events, in order.
95
+ */
96
+ drain() {
97
+ if (!existsSync(this.path)) return []
98
+ let size
99
+ try {
100
+ size = statSync(this.path).size
101
+ } catch {
102
+ return []
103
+ }
104
+ if (size <= this.offset) return []
105
+
106
+ const length = Math.min(size - this.offset, MAX_READ_BYTES)
107
+ const buffer = Buffer.alloc(length)
108
+ let descriptor = null
109
+ try {
110
+ descriptor = openSync(this.path, 'r')
111
+ readSync(descriptor, buffer, 0, length, this.offset)
112
+ } catch {
113
+ return []
114
+ } finally {
115
+ if (descriptor !== null) {
116
+ try {
117
+ closeSync(descriptor)
118
+ } catch {
119
+ // The descriptor was opened here and nothing else closes it, so this catch is not for "already
120
+ // closed" — it is because a throw inside `finally` REPLACES the error being propagated, and the
121
+ // caller needs the read failure rather than a close failure.
122
+ }
123
+ }
124
+ }
125
+
126
+ // Did this read reach the end of the FILE, or stop at the cap? Only a read that
127
+ // reached the end can say anything about the tail: with bytes still unread, an
128
+ // incomplete line here is the cap, not a torn write.
129
+ const readToEnd = this.offset + length === size
130
+
131
+ const text = buffer.toString('utf8')
132
+ const lastNewline = text.lastIndexOf('\n')
133
+ if (lastNewline < 0) {
134
+ // Nothing complete yet. The offset stays put so the partial line is re-read
135
+ // once it is finished — skipping it would lose the event for good.
136
+ if (readToEnd) this.tornLineSeen = text.length > 0
137
+ return []
138
+ }
139
+ // The flag describes the file as it stands NOW, so it is recomputed rather than
140
+ // latched — and the ordinary torn case is a NON-EMPTY TAIL after the last newline,
141
+ // which is what a kill between two writes leaves behind. Missing that case was a
142
+ // real defect: the flag was raised only for a buffer with no complete line at all,
143
+ // so a journal cut after frame 30 of 450 looked intact, and the host's "the journal
144
+ // ends mid-line" line could not fire for the case it exists for. Recomputing also
145
+ // keeps the opposite lie out: a tail that completes on the next poll means the
146
+ // journal is whole, and a latched flag would still be accusing the renderer of
147
+ // losing an event that arrived.
148
+ if (readToEnd) this.tornLineSeen = lastNewline < text.length - 1
149
+
150
+ const complete = text.slice(0, lastNewline + 1)
151
+ const consumedBytes = Buffer.byteLength(complete, 'utf8')
152
+ const fresh = []
153
+ for (const line of complete.split('\n')) {
154
+ if (line.trim().length === 0) continue
155
+ try {
156
+ fresh.push(JSON.parse(line))
157
+ } catch {
158
+ // A line that is complete AND unparseable is not a torn write; it is a
159
+ // writer bug. Recorded as a marker rather than dropped, because a journal
160
+ // that silently loses an event is indistinguishable from one that never
161
+ // had it. Deliberately NOT `tornLineSeen`: the two have different causes,
162
+ // and the caller says different things about them.
163
+ fresh.push({ type: 'unparseable-line' })
164
+ }
165
+ }
166
+ this.offset += consumedBytes
167
+ this.events.push(...fresh)
168
+ return fresh
169
+ }
170
+
171
+ /** Per-frame durations the renderer reported, for the remaining-time estimate. */
172
+ frameDurations() {
173
+ const durations = []
174
+ for (const event of this.events) {
175
+ if (event?.type === 'frame' && Number.isFinite(event.ms)) durations.push(event.ms)
176
+ }
177
+ return durations
178
+ }
179
+ }
180
+
181
+ /**
182
+ * Does this event claim that a frame landed?
183
+ *
184
+ * "Claims" is the operative word: the renderer says frame N is done, and the caller
185
+ * verifies that against the frame's BYTES before counting it (see `frame-ledger.js`).
186
+ * The rule lives here, next to the format, because it had two copies in the host and
187
+ * a copy in a test — three places to drift.
188
+ *
189
+ * (This replaces a `claimedFrames()` collector that handed back a list of unverified
190
+ * frame numbers. Nothing called it, which is the only reason it was harmless: the
191
+ * architecture says a claim is never the authority, so the collection of claims is
192
+ * exactly the value no caller may act on. A predicate cannot be mistaken for a
193
+ * ledger.)
194
+ *
195
+ * @param {unknown} event
196
+ * @returns {boolean}
197
+ */
198
+ export function isFrameClaim(event) {
199
+ return event?.type === 'frame' && Number.isSafeInteger(event.frame)
200
+ }
201
+
202
+ /**
203
+ * The warning this attempt earns when its journal was cut off mid-line, or `null`.
204
+ *
205
+ * WHY THE WHOLE DECISION IS HERE AND NOT AT THE CALL SITE
206
+ * ------------------------------------------------------
207
+ * It is four conditions in a row — the file ends mid-line, the writer has stopped, the kill was not
208
+ * one the caller asked for, and it has not been said before — and the only place that can call it is
209
+ * a background absorb inside a running Host. MEASURED, round 25: the end-to-end suite settles it
210
+ * against the FILE, but a `SIGKILL` lands on a line boundary in practice (two runs, both clean), so
211
+ * "torn, therefore recorded" is not reachable on demand there, and the branch that writes the warning
212
+ * would never be exercised. A rule whose only exercise is luck is a rule nobody has checked, so it
213
+ * lives where the contract suite can drive all four conditions by hand.
214
+ *
215
+ * A kill the caller ASKED for is not a finding: `cancelJob` terminates the process group on purpose,
216
+ * and a journal cut in half is the expected consequence of that. That check comes FIRST so a
217
+ * cancelled absorb does not consume the once-only flag of a journal that is genuinely torn.
218
+ *
219
+ * @param {JournalTail} journal
220
+ * @param {{stopped: boolean, cancelled?: boolean, jobId?: string, attemptToken?: string|null}} input
221
+ * @returns {{code: string, message: string, detail?: object}|null}
222
+ */
223
+ export function incompleteJournalWarning(journal, input) {
224
+ if (input?.cancelled === true) return null
225
+ if (journal?.tornLineIsEvidence({ stopped: input?.stopped === true }) !== true) return null
226
+ const detail = {}
227
+ if (input?.jobId !== undefined) detail.jobId = input.jobId
228
+ if (input?.attemptToken !== undefined && input.attemptToken !== null) detail.attemptToken = input.attemptToken
229
+ return warning(
230
+ BlenderWarningCode.JOURNAL_INCOMPLETE,
231
+ 'an attempt at this job was cut off mid-line in its event journal (a kill between the write and the ' +
232
+ 'flush), so the last event the renderer sent never arrived. Frame progress and the resumed frame ' +
233
+ 'set are read from the frame files themselves, so no count here is affected.',
234
+ Object.keys(detail).length > 0 ? detail : undefined,
235
+ )
236
+ }