Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 | 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 23x 23x 1x 1x 132x 132x 7x 125x 132x 1x 39x 39x 9x 39x 5x 5x 39x 1x 4x 39x 39x 1x 1x 1x 1x 1x 1x 1x 1x 1x 4x 39x 39x 39x 39x 39x 39x 39x 39x 39x 39x 1x 1x 1x 2x 2x 1x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 1x 39x 39x 39x 39x 39x 1x 1x 39x 39x 39x 39x 1x 1x 1x 1x 7x 7x 1x 1x 1x 1x 4x 3x 4x 2x 2x 4x 4x 2x 2x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 39x 39x 6x 35x 4x 35x 11x 10x 11x 7x 7x 11x 11x 11x 7x 11x 7x 7x 4x 4x 1x 1x 1x 1x 39x 39x 5x 35x 11x 9x 11x 6x 6x 11x 11x 11x 11x 5x 5x 1x 1x 39x 39x 1x 1x 1x 1x 39x 39x 39x 1x 38x 39x 1x 1x 42x 34x 8x 3x 5x 42x 42x 42x 40x 40x 40x 39x 39x 42x 42x 42x 13x 26x 1x 25x 42x 42x 42x 42x 42x 42x 42x 42x 42x 42x 42x 42x 42x 42x 42x 42x 42x 42x 42x 42x 42x 42x | /**
* Jobs-roster projection for the print panel (gh#162).
*
* The `/api/abacus/print/jobs` proxy passes the service's job list through
* untouched, so this is the ONLY place the wire shape is interpreted. The
* error carries the service's own explanation of a failure — it must survive
* the projection verbatim (dropping it is exactly how the first real print
* failure rendered as a bare `failed`).
*
* Beyond the error, the service also ships each job's `attention` reasons (why
* it parked), its `startPolicy`, and what's already been `acknowledged`. Those
* are what let the panel RESOLVE a parked job in place instead of leaving it a
* dead-end — so they must survive the projection too (dropping them is exactly
* why a held job used to block every later submit with no way out).
*
* `notices` (THH #429) is a THIRD channel and a different kind of fact: not why
* the job stopped, but what the service couldn't CHECK before letting it run.
* We send `startPolicy: 'auto'`, so when the service's bed camera can't reach
* its vision provider our print now starts unattended with nothing having
* looked at the plate — and the notice is the only thing that says so. Dropping
* it here makes that silent.
*/
export type JobRecovery = {
retrySameInput: boolean
recommended: string
options: string[]
message: string
}
export type JobTechnical = {
component?: string
exitCode?: number
detail?: string
[key: string]: unknown
}
export type JobError = {
code: string
message: string | null
cause: string | null
summary: string | null
context: Record<string, unknown> | null
recovery: JobRecovery | null
technical: JobTechnical | null
}
export type JobFailureView = {
summary: string
action: string
retrySameInput: boolean
recommended: string
technical: string | null
}
const FAILURE_FALLBACKS: Record<string, Omit<JobFailureView, 'technical'>> = {
wipe_tower_collision: {
summary: 'The wipe tower overlaps the abacus or its generated supports. No print was sent.',
action:
'Rebuild the plate with more space around the wipe tower, then resubmit. Changing only the feet filament will not fix this geometry collision.',
retrySameInput: false,
recommended: 'relayout_plate',
},
slicer_configuration_error: {
summary:
'The print service could not apply one of the selected print profiles. No print was sent.',
action: 'Review the selected material and print settings before trying again.',
retrySameInput: false,
recommended: 'review_print_profile',
},
filament_temp_incompatible: {
summary: 'The selected filaments require incompatible nozzle temperatures. No print was sent.',
action: 'Choose compatible model and support filaments, then resubmit.',
retrySameInput: false,
recommended: 'change_filament_mapping',
},
// A job-level failure code, not a cause: the two-stage Stage A slice could not
// be cut at the feet seam (Gitea #38 / things-haunt-house#456).
split_failed: {
summary: 'The slice could not be split at the feet seam. No print was sent.',
action:
'The seam has to land on a layer boundary and every layer below it must print in the feet filament alone — check the layer height against the feet stand-off, and that the support interface is not routed to another spool.',
retrySameInput: false,
recommended: 'review_print_profile',
},
}
const UNKNOWN_FAILURE: Omit<JobFailureView, 'technical'> = {
summary: 'The print service could not prepare this job. No print was sent.',
action: 'Open the technical details or return to Abacus Studio before trying again.',
retrySameInput: true,
recommended: 'review_technical_details',
}
/** One reason the service parked a job, from `attention.reasons[]`. `detail`
* is the service's own human sentence; `code` is what {@link isParked} jobs
* must acknowledge to start. `frameRef` (when present) marks the reason that
* carries the bed photo — informational; the photo loads from the job-level
* `attention-frame` endpoint, not this ref. */
export type AttentionReason = {
code: string
detail: string | null
frameRef?: string | null
/** The printer's own facts at a mid-print pause (`print_paused_fault` /
* `print_paused`), verbatim from the service — rendered, never interpreted. */
printer?: PrinterFacts
}
/** One HMS alert the printer reported: both halves of its identity in Bambu's
* own hex form (`07FF-2000-0002-0004`) and, when the service knew both halves,
* the wiki page Bambu files it under. */
export type PrinterHmsRecord = { hex: string; wiki?: string; lvl?: number }
/** What the printer said at a pause — its state, error code and alerts, and
* where it reported being. The `detail` sentence already reads these out; the
* structured copy is for linking each code to the printer's own page. */
export type PrinterFacts = {
gcodeState?: string
printError?: string
hms: PrinterHmsRecord[]
layer?: number
percent?: number
totalLayers?: number
}
/** One thing the service COULDN'T verify, from the job's `notices[]` (THH #429).
*
* The opposite of an {@link AttentionReason} in every way that matters: it
* blocks nothing, is never acknowledged, is never cleared, and is still on the
* job at `completed` — which is the surface where "nothing looked at the build
* plate" is most worth knowing. `detail` is the service's own sentence (prose,
* and prose changes); `cause` is the stable machine class to branch on:
* `insufficient_quota` | `http_<NNN>` | `network` | `no_frame` |
* `capture_failed`.
*
* An EMPTY list is not proof the bed was checked — a service with no vision
* key configured checks nothing and says nothing. Absence means only that
* nothing went wrong that the service considers worth reporting. */
export type JobNotice = { code: string; detail: string | null; cause: string | null }
export type JobRow = {
id: string
name: string
phase: string
progress: number | null
error: JobError | null
/** Reasons the service is holding this job (empty unless it parked). */
attention: AttentionReason[]
/** Checks the service couldn't run (empty when nothing went unchecked). */
notices: JobNotice[]
/** `'auto'` | `'hold'` as the service recorded it, or null. */
startPolicy: string | null
/** Reason codes already confirmed on this job. */
acknowledged: string[]
/** Stage B's chain declaration (Gitea #38 / things-haunt-house#456) as THH
* echoes it, write-once — null on an ordinary job. */
chain: { continuesJobId: string } | null
/** The service's last-touched epoch (seconds); a cache-buster for the frame. */
updatedAt: number | null
/** Source/editor linkage belongs to the failed job, not the page's current design. */
source?: Record<string, unknown> | null
authoring?: { editUrl: string; editTool: string | null; editLabel: string | null } | null
}
/** The two non-terminal phases a job rests in while it waits for a human: a
* clean `hold` slice (`ready`) or a preflight that found something to confirm
* (`needs_attention`). Both count as busy upstream — so both are what the
* panel must be able to start or cancel. */
export const PARKED_PHASES = ['ready', 'needs_attention'] as const
/** Whether a phase is one the panel can start or cancel (see {@link PARKED_PHASES}). */
export function isParked(phase: string): boolean {
return (PARKED_PHASES as readonly string[]).includes(phase)
}
/** The service's `error: {code, message}` — attached only when the job failed. */
function asRecord(value: unknown): Record<string, unknown> | null {
return typeof value === 'object' && value !== null && !Array.isArray(value)
? (value as Record<string, unknown>)
: null
}
function normalizeJobError(value: unknown): JobError | null {
if (typeof value !== 'object' || value === null) return null
const rec = value as Record<string, unknown>
if (typeof rec.code !== 'string' || rec.code.length === 0) return null
const context = asRecord(rec.context)
const recoveryRaw = asRecord(rec.recovery)
const recoveryOptions = Array.isArray(recoveryRaw?.options)
? recoveryRaw.options.filter((v): v is string => typeof v === 'string')
: []
const recovery =
recoveryRaw &&
typeof recoveryRaw.retrySameInput === 'boolean' &&
typeof recoveryRaw.recommended === 'string' &&
typeof recoveryRaw.message === 'string'
? {
retrySameInput: recoveryRaw.retrySameInput,
recommended: recoveryRaw.recommended,
options: recoveryOptions,
message: recoveryRaw.message,
}
: null
return {
code: rec.code,
message: typeof rec.message === 'string' && rec.message.length > 0 ? rec.message : null,
cause: typeof rec.cause === 'string' && rec.cause.length > 0 ? rec.cause : null,
summary: typeof rec.summary === 'string' && rec.summary.length > 0 ? rec.summary : null,
context,
recovery,
technical: asRecord(rec.technical) as JobTechnical | null,
}
}
/** User copy for both the v27 structured contract and older cause-only services. */
export function describeJobError(error: JobError): JobFailureView {
const fallback =
(error.cause && FAILURE_FALLBACKS[error.cause]) ||
FAILURE_FALLBACKS[error.code] ||
UNKNOWN_FAILURE
const detail =
typeof error.technical?.detail === 'string' ? error.technical.detail : error.message
const exit =
typeof error.technical?.exitCode === 'number' ? `exit ${error.technical.exitCode}` : null
return {
summary: error.summary ?? fallback.summary,
action: error.recovery?.message ?? fallback.action,
retrySameInput: error.recovery?.retrySameInput ?? fallback.retrySameInput,
recommended: error.recovery?.recommended ?? fallback.recommended,
technical: [exit, detail].filter(Boolean).join(' · ') || null,
}
}
function normalizeAuthoring(
value: unknown
): { editUrl: string; editTool: string | null; editLabel: string | null } | null {
const rec = asRecord(value)
if (!rec || typeof rec.editUrl !== 'string' || rec.editUrl.length === 0) return null
return {
editUrl: rec.editUrl,
editTool: typeof rec.editTool === 'string' ? rec.editTool : null,
editLabel: typeof rec.editLabel === 'string' ? rec.editLabel : null,
}
}
/** A reason's `printer` block. Only the shapes the card renders are kept; an
* alert without its hex identity is dropped rather than shown as a blank link,
* and a wiki link that is not https is not a link. */
function normalizePrinterFacts(value: unknown): PrinterFacts | undefined {
if (typeof value !== 'object' || value === null) return undefined
const rec = value as Record<string, unknown>
const hms: PrinterHmsRecord[] = []
if (Array.isArray(rec.hms)) {
for (const item of rec.hms) {
if (typeof item !== 'object' || item === null) continue
const alert = item as Record<string, unknown>
if (typeof alert.hex !== 'string' || alert.hex.length === 0) continue
const record: PrinterHmsRecord = { hex: alert.hex }
if (typeof alert.wiki === 'string' && alert.wiki.startsWith('https://'))
record.wiki = alert.wiki
if (typeof alert.lvl === 'number') record.lvl = alert.lvl
hms.push(record)
}
}
const facts: PrinterFacts = { hms }
if (typeof rec.gcodeState === 'string') facts.gcodeState = rec.gcodeState
if (typeof rec.printError === 'string') facts.printError = rec.printError
if (typeof rec.layer === 'number') facts.layer = rec.layer
if (typeof rec.percent === 'number') facts.percent = rec.percent
if (typeof rec.totalLayers === 'number') facts.totalLayers = rec.totalLayers
return facts
}
/** The service's `attention: {reasons: [{code, detail, frameRef?, printer?}]}` — a job is
* only actionable through the reasons it reports, so a reason without a usable
* `code` is dropped rather than guessed at. */
function normalizeAttention(value: unknown): AttentionReason[] {
if (typeof value !== 'object' || value === null) return []
const reasons = (value as { reasons?: unknown }).reasons
if (!Array.isArray(reasons)) return []
const out: AttentionReason[] = []
for (const item of reasons) {
if (typeof item !== 'object' || item === null) continue
const rec = item as Record<string, unknown>
if (typeof rec.code !== 'string' || rec.code.length === 0) continue
const reason: AttentionReason = {
code: rec.code,
detail: typeof rec.detail === 'string' && rec.detail.length > 0 ? rec.detail : null,
}
if (typeof rec.frameRef === 'string' && rec.frameRef.length > 0) reason.frameRef = rec.frameRef
const printer = normalizePrinterFacts(rec.printer)
if (printer) reason.printer = printer
out.push(reason)
}
return out
}
/** The service's `notices: [{code, detail, cause}]` — a BARE list, unlike
* `attention`'s `{reasons: […]}` wrapper. Same rule as the reasons: an entry
* without a usable `code` is dropped rather than guessed at. */
function normalizeNotices(value: unknown): JobNotice[] {
if (!Array.isArray(value)) return []
const out: JobNotice[] = []
for (const item of value) {
if (typeof item !== 'object' || item === null) continue
const rec = item as Record<string, unknown>
if (typeof rec.code !== 'string' || rec.code.length === 0) continue
out.push({
code: rec.code,
detail: typeof rec.detail === 'string' && rec.detail.length > 0 ? rec.detail : null,
cause: typeof rec.cause === 'string' && rec.cause.length > 0 ? rec.cause : null,
})
}
return out
}
/** A list of non-empty strings (e.g. `acknowledged` reason codes), or []. */
function normalizeStringArray(value: unknown): string[] {
if (!Array.isArray(value)) return []
return value.filter((v): v is string => typeof v === 'string' && v.length > 0)
}
/** Defensive projection of the proxy's pass-through jobs read (open wire shape). */
function normalizeChain(value: unknown): { continuesJobId: string } | null {
const rec = asRecord(value)
return rec && typeof rec.continuesJobId === 'string' && rec.continuesJobId.length > 0
? { continuesJobId: rec.continuesJobId }
: null
}
export function normalizeJobs(data: unknown): JobRow[] {
const list = Array.isArray(data)
? data
: typeof data === 'object' && data !== null && Array.isArray((data as { jobs?: unknown }).jobs)
? ((data as { jobs: unknown[] }).jobs as unknown[])
: []
const rows: JobRow[] = []
for (const item of list) {
if (typeof item !== 'object' || item === null) continue
const rec = item as Record<string, unknown>
const id = [rec.jobId, rec.id].find((v): v is string => typeof v === 'string' && v.length > 0)
if (!id) continue
rows.push({
id,
name: typeof rec.name === 'string' ? rec.name : id,
phase:
typeof rec.phase === 'string'
? rec.phase
: typeof rec.status === 'string'
? rec.status
: '—',
progress: typeof rec.progress === 'number' ? rec.progress : null,
// Carried whenever the service attached one, regardless of the phase
// word — if the service says why, the panel must be able to say why.
error: normalizeJobError(rec.error),
// Why the job is parked + how to release it — the data the resolver card
// needs. Absent/healthy jobs project to empty, so the card simply won't
// render for them.
attention: normalizeAttention(rec.attention),
// What the service couldn't check before running it. Never gates
// anything, so it rides every phase — including the finished job, which
// is where an unchecked bed is most worth knowing about.
notices: normalizeNotices(rec.notices),
startPolicy: typeof rec.startPolicy === 'string' ? rec.startPolicy : null,
acknowledged: normalizeStringArray(rec.acknowledged),
chain: normalizeChain(rec.chain),
updatedAt: typeof rec.updatedAt === 'number' ? rec.updatedAt : null,
source: asRecord(rec.source),
authoring: normalizeAuthoring(rec.authoring),
})
}
return rows
}
|