P7/P4: harden recompute and FCStd boundaries
This commit is contained in:
208
src/facade/recomputeEngine.ts
Normal file
208
src/facade/recomputeEngine.ts
Normal 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 }
|
||||
}
|
||||
Reference in New Issue
Block a user