P7/P4: harden recompute and FCStd boundaries

This commit is contained in:
2026-08-02 14:33:41 -04:00
parent 022a5dde2f
commit 4cc349a589
12 changed files with 848 additions and 30 deletions

View File

@@ -0,0 +1,208 @@
import { DependencyGraph, type RecomputeState } from './dependencyGraph'
import { cloneSketch, solveSketch } from './sketcher'
import type { DocumentObjectSnapshot, DocumentSnapshot } from './types'
export type RecomputeExecutionStatus = 'completed' | 'failed' | 'cancelled' | 'stale'
export type RecomputeExecutionError = {
objectId: string
code: string
message: string
}
export type RecomputeNodeContext = {
documentId: string
documentVersion: number
generation: number
signal: AbortSignal
}
export type RecomputeNodeResult = {
status: 'success' | 'failed'
errors?: RecomputeExecutionError[]
updatedObject?: DocumentObjectSnapshot
}
export type RecomputeNodeExecutor = (
object: DocumentObjectSnapshot,
document: DocumentSnapshot,
context: RecomputeNodeContext,
) => Promise<RecomputeNodeResult>
export type RecomputeProgress = {
generation: number
documentVersion: number
objectId: string
completed: number
total: number
state: RecomputeState
}
export type RecomputeExecutionOptions = {
dirtyObjectIds?: string[]
onProgress?: (progress: RecomputeProgress) => void
}
export type RecomputeExecutionResult = {
generation: number
documentVersion: number
status: RecomputeExecutionStatus
affected: string[]
order: string[]
completed: string[]
failed: string[]
skipped: string[]
dirtyObjects: string[]
objectStates: Record<string, RecomputeState>
objectUpdates: DocumentObjectSnapshot[]
errors: RecomputeExecutionError[]
}
const isAbortError = (error: unknown) => error instanceof Error && error.name === 'AbortError'
export class RecomputeCoordinator {
private active: { generation: number; controller: AbortController } | null = null
private generation = 0
constructor(
private readonly executeNode: RecomputeNodeExecutor,
private readonly currentDocumentVersion: (documentId: string) => number | null,
) {}
cancel() {
this.active?.controller.abort()
}
async run(document: DocumentSnapshot, options: RecomputeExecutionOptions = {}): Promise<RecomputeExecutionResult> {
this.active?.controller.abort()
const controller = new AbortController()
const generation = Math.max(this.generation, document.recompute?.generation ?? 0) + 1
this.generation = generation
this.active = { generation, controller }
const graph = new DependencyGraph(document.dependencies ?? [], document.objects.map((object) => object.id))
const dirtyObjectIds = options.dirtyObjectIds ?? document.recompute?.dirtyObjects ?? []
const plan = graph.plan(dirtyObjectIds)
const objectStates: Record<string, RecomputeState> = {
...Object.fromEntries(document.objects.map((object) => [object.id, 'up-to-date' as const])),
...(document.recompute?.objectStates ?? {}),
}
for (const objectId of plan.affected) objectStates[objectId] = 'recomputing'
const completed: string[] = []
const failed: string[] = []
const skipped: string[] = []
const errors: RecomputeExecutionError[] = []
const objectUpdates: DocumentObjectSnapshot[] = []
const objectById = new Map(document.objects.map((object) => [object.id, object]))
for (const cycle of plan.cycles) {
const message = `Dependency cycle: ${cycle.join(' -> ')}`
for (const objectId of cycle) {
objectStates[objectId] = 'error'
failed.push(objectId)
errors.push({ objectId, code: 'DEPENDENCY_CYCLE', message })
}
}
const terminalResult = (status: RecomputeExecutionStatus): RecomputeExecutionResult => {
if (status === 'cancelled' || status === 'stale') {
for (const objectId of plan.affected) if (!completed.includes(objectId)) objectStates[objectId] = 'touched'
}
if (this.active?.generation === generation) this.active = null
return {
generation,
documentVersion: document.version,
status,
affected: plan.affected,
order: plan.order,
completed,
failed,
skipped,
dirtyObjects: plan.affected.filter((objectId) => objectStates[objectId] !== 'up-to-date'),
objectStates,
objectUpdates,
errors,
}
}
for (const objectId of plan.order) {
if (controller.signal.aborted || this.active?.generation !== generation) return terminalResult('cancelled')
if (this.currentDocumentVersion(document.id) !== document.version) return terminalResult('stale')
const failedDependency = graph.dependenciesOf(objectId).find((dependencyId) => objectStates[dependencyId] === 'error' || objectStates[dependencyId] === 'upstream-failed')
if (failedDependency) {
objectStates[objectId] = 'upstream-failed'
skipped.push(objectId)
errors.push({ objectId, code: 'UPSTREAM_FAILED', message: `Dependency ${failedDependency} did not recompute successfully.` })
options.onProgress?.({ generation, documentVersion: document.version, objectId, completed: completed.length, total: plan.order.length, state: 'upstream-failed' })
continue
}
const object = objectById.get(objectId)
if (!object) {
objectStates[objectId] = 'error'
failed.push(objectId)
errors.push({ objectId, code: 'OBJECT_NOT_FOUND', message: `Document object does not exist: ${objectId}` })
continue
}
try {
const result = await this.executeNode(object, document, {
documentId: document.id,
documentVersion: document.version,
generation,
signal: controller.signal,
})
if (controller.signal.aborted || this.active?.generation !== generation) return terminalResult('cancelled')
if (this.currentDocumentVersion(document.id) !== document.version) return terminalResult('stale')
if (result.status === 'failed') {
objectStates[objectId] = 'error'
failed.push(objectId)
errors.push(...(result.errors?.length ? result.errors : [{ objectId, code: 'RECOMPUTE_FAILED', message: `${objectId} failed to recompute.` }]))
} else {
objectStates[objectId] = 'up-to-date'
completed.push(objectId)
if (result.updatedObject) objectUpdates.push(result.updatedObject)
}
} catch (error) {
if (controller.signal.aborted || isAbortError(error)) return terminalResult('cancelled')
objectStates[objectId] = 'error'
failed.push(objectId)
errors.push({ objectId, code: 'RECOMPUTE_EXCEPTION', message: error instanceof Error ? error.message : String(error) })
}
options.onProgress?.({ generation, documentVersion: document.version, objectId, completed: completed.length, total: plan.order.length, state: objectStates[objectId] })
}
if (this.active?.generation === generation) this.active = null
return terminalResult(errors.length > 0 ? 'failed' : 'completed')
}
}
export const executeFacadeRecomputeNode: RecomputeNodeExecutor = async (object, _document, context) => {
if (context.signal.aborted) throw new DOMException('Recompute cancelled.', 'AbortError')
const expressionError = object.properties.find((property) => property.expressionError)
if (expressionError) {
return {
status: 'failed',
errors: [{ objectId: object.id, code: 'EXPRESSION_ERROR', message: expressionError.expressionError as string }],
}
}
if (!object.sketch) return { status: 'success' }
const solved = solveSketch(object.sketch)
if (solved.status === 'conflicting' || solved.status === 'invalid') {
return {
status: 'failed',
errors: solved.diagnostics.map((diagnostic) => ({ objectId: object.id, code: diagnostic.code, message: diagnostic.message })),
}
}
const updatedObject: DocumentObjectSnapshot = {
...object,
properties: object.properties.map((property) => property.name === 'ConstraintStatus'
? { ...property, value: solved.status === 'solved' ? 'Fully constrained' : `Under-constrained (${solved.degreesOfFreedom} DOF)` }
: { ...property, options: property.options ? [...property.options] : undefined }),
sketch: cloneSketch(solved.snapshot),
}
return { status: 'success', updatedObject }
}