DAG: execute recompute levels concurrently

This commit is contained in:
2026-08-02 17:52:26 -04:00
parent 0c8efc8b69
commit cb6877be36
5 changed files with 108 additions and 42 deletions

View File

@@ -133,6 +133,7 @@ const createDocument = (label = 'Pump Housing'): DocumentSnapshot => {
const selectionRequired = new Set(['pad', 'pocket', 'revolution', 'fillet', 'chamfer', 'hole', 'linear-pattern', 'polar-pattern', 'measure-distance', 'measure-angle', 'measure-area', 'solve-sketch'])
const systemCommands = new Set(['new-document', 'save', 'select-object'])
const implementedCommandIds = new Set(['new-document', 'save', 'select-object', 'create-body', 'create-sketch', 'new-sketch', 'pad', 'pocket', 'fillet', 'chamfer', 'solve-sketch'])
const partDesignCommands = new Set(['create-body', 'create-sketch', 'pad', 'pocket', 'fillet', 'chamfer'])
const featureCommands: Record<string, { label: string; detail: string }> = {
'create-body': { label: 'Body', detail: 'Part Design body' },
'create-sketch': { label: 'Sketch', detail: 'Fully constrained' },
@@ -146,7 +147,8 @@ const commandState = (commandId: string, activeWorkbench: WorkbenchId, selectedO
const known = systemCommands.has(commandId) || Object.values(workbenchDefinitions).some((definition) => definition.groups.some((group) => group.commands.some((command) => command.id === commandId)))
if (!known) return { id: commandId, status: 'disabled', reason: 'Command is not registered in the active manifest.' }
if (!implementedCommandIds.has(commandId)) return { id: commandId, status: 'disabled', reason: 'Command is visible in the FreeCAD-compatible manifest but its BitBybit business executor is not implemented yet.' }
if (commandId === 'pad' && activeWorkbench !== 'Part Design') return { id: commandId, status: 'disabled', reason: 'Switch to Part Design to use Pad.' }
if (partDesignCommands.has(commandId) && activeWorkbench !== 'Part Design') return { id: commandId, status: 'disabled', reason: `Switch to Part Design to use ${commandId}.` }
if ((commandId === 'new-sketch' || commandId === 'solve-sketch') && activeWorkbench !== 'Sketcher') return { id: commandId, status: 'disabled', reason: `Switch to Sketcher to use ${commandId}.` }
if (selectionRequired.has(commandId) && !selectedObjectId) return { id: commandId, status: 'disabled', reason: 'Select a compatible object or sub-shape first.' }
return { id: commandId, status: 'enabled' }
}

View File

@@ -128,52 +128,61 @@ export class RecomputeCoordinator {
}
}
for (const objectId of plan.order) {
let processed = 0
for (const level of plan.levels) {
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
}
// Nodes in a level have no dependencies on each other. Execute them together,
// then merge outcomes in plan order so persistence and UI events stay deterministic.
const outcomes = await Promise.all(level.map(async (objectId) => {
const failedDependency = graph.dependenciesOf(objectId).find((dependencyId) => objectStates[dependencyId] === 'error' || objectStates[dependencyId] === 'upstream-failed')
if (failedDependency) return { objectId, state: 'upstream-failed' as const, error: { objectId, code: 'UPSTREAM_FAILED', message: `Dependency ${failedDependency} did not recompute successfully.` } }
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
}
const object = objectById.get(objectId)
if (!object) return { objectId, state: 'error' as const, error: { objectId, code: 'OBJECT_NOT_FOUND', message: `Document object does not exist: ${objectId}` } }
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)
try {
const result = await this.executeNode(object, document, {
documentId: document.id,
documentVersion: document.version,
generation,
signal: controller.signal,
})
if (result.status === 'failed') {
return {
objectId,
state: 'error' as const,
errors: result.errors?.length ? result.errors : [{ objectId, code: 'RECOMPUTE_FAILED', message: `${objectId} failed to recompute.` }],
}
}
return { objectId, state: 'up-to-date' as const, updatedObject: result.updatedObject }
} catch (error) {
if (controller.signal.aborted || isAbortError(error)) return { objectId, state: 'cancelled' as const }
return { objectId, state: 'error' as const, error: { objectId, code: 'RECOMPUTE_EXCEPTION', message: error instanceof Error ? error.message : String(error) } }
}
} 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) })
}))
if (controller.signal.aborted || this.active?.generation !== generation) return terminalResult('cancelled')
if (this.currentDocumentVersion(document.id) !== document.version) return terminalResult('stale')
for (const outcome of outcomes) {
if (outcome.state === 'cancelled') return terminalResult('cancelled')
objectStates[outcome.objectId] = outcome.state
processed += 1
if (outcome.state === 'up-to-date') {
completed.push(outcome.objectId)
if (outcome.updatedObject) objectUpdates.push(outcome.updatedObject)
} else if (outcome.state === 'upstream-failed') {
skipped.push(outcome.objectId)
if (outcome.error) errors.push(outcome.error)
} else {
failed.push(outcome.objectId)
if ('errors' in outcome && outcome.errors) errors.push(...outcome.errors)
else if (outcome.error) errors.push(outcome.error)
}
options.onProgress?.({ generation, documentVersion: document.version, objectId: outcome.objectId, completed: processed, total: plan.order.length, state: outcome.state })
}
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