diff --git a/src/components/canvas/headless-graph-canvas.tsx b/src/components/canvas/headless-graph-canvas.tsx index 779935a..da9ce0d 100644 --- a/src/components/canvas/headless-graph-canvas.tsx +++ b/src/components/canvas/headless-graph-canvas.tsx @@ -254,7 +254,7 @@ function CanvasView({ engine, board, viewId, renderers }: { engine: ProjectEngin setShowAI(value => !value)} /> add('plain-text')} /> add('text')} /> imageInput.current?.click()} /> add('frame')} /> - { const files = [...event.target.files ?? []]; event.target.value = ''; try { report(engine.stageFiles(files.map(file => ({ name: file.name, bytes: file })), (refs, tx) => { for (const ref of refs) add('image', ref, tx); })); } catch (e) { setError(errorText(e)); } }} /> + { const files = [...event.target.files ?? []]; event.target.value = ''; try { report(engine.stageFiles(files.map(file => ({ name: file.name, bytes: file, defaultSize: true })), (refs, tx) => { for (const ref of refs) add('image', ref, tx); })); } catch (e) { setError(errorText(e)); } }} /> {showAI && aiElement && setShowAI(false)} />} {showRecovery && setShowRecovery(false)} />} diff --git a/src/lib/canvas-core/creation-size-plan.ts b/src/lib/canvas-core/creation-size-plan.ts index ca83361..bed6a61 100644 --- a/src/lib/canvas-core/creation-size-plan.ts +++ b/src/lib/canvas-core/creation-size-plan.ts @@ -1,7 +1,15 @@ -import type { Change, Document, ElementRecord, Geometry, HistoryState, Id, Registry, Target } from './types.ts'; +import type { Change, CreationSizeEffect, Document, ElementRecord, Geometry, HistoryState, Id, Registry, Target } from './types.ts'; import { applyChanges, invert, validateDocument } from './document.ts'; import { closeNumber, unitScale } from './units.ts'; -import { equal, fault, freeze, readMap } from './values.ts'; +import { equal, fault, freeze, readMap, validJson } from './values.ts'; + +export function validateCreationSizeEffect(value: unknown): asserts value is CreationSizeEffect { + const effect = value as CreationSizeEffect | undefined; + if (!effect || effect.version !== 1 || effect.kind !== 'creation-size') throw fault('history_effect_interpreter_unavailable'); + if (!validJson(effect) || typeof effect.entryId !== 'string' || !effect.entryId || typeof effect.target?.id !== 'string' || !effect.target.id + || !Number.isSafeInteger(effect.target.incarnation) || effect.target.incarnation < 1 + || !effect.size || ![effect.size.width, effect.size.height].every(value => typeof value === 'number' && Number.isFinite(value) && value > 0)) throw fault('invalid_creation_size_effect'); +} /** A pure proposal, not a publication API. The coordinator must still validate a * durable sizing grant and record a recoverable history effect before adopting it. diff --git a/src/lib/canvas-core/editor.ts b/src/lib/canvas-core/editor.ts index 91bac81..68e132f 100644 --- a/src/lib/canvas-core/editor.ts +++ b/src/lib/canvas-core/editor.ts @@ -8,6 +8,8 @@ import { unitScale, withUnitScale } from './units.ts'; import { removalChanges, repairConnectionOwners } from './structure.ts'; import { planHistoryChanges } from './history.ts'; import { describeHistoryTransition } from './history-transition.ts'; +import { planCreationSize, validateCreationSizeEffect } from './creation-size-plan.ts'; +import type { CreationSizeEffect } from './types.ts'; export type EditorOptions = { registry: Registry; document: Document; queryBudget?:QueryBudget; createId?: () => Id; historyLimit?: number; @@ -135,7 +137,7 @@ export function createEditor(options: EditorOptions(); for (const id of history.entries.keys()) used.add(id); // Canonical remote/ACK/replay updates must never feed back into the outbox. @@ -180,9 +182,9 @@ export function createEditor(options: EditorOptions!equal((prepared as unknown as Record)[key],(intent as unknown as Record)[key])))throw fault('invalid_prepared_intent'); + if(prepared.operationType==='run.apply'&&!validJson(prepared.action)||!['document.change','run.apply'].includes(prepared.operationType)||['id','operationSchemaVersion','sequence','origin','canvasIds','changes','dependencies','historyTransition','historyEffect'].some(key=>!equal((prepared as unknown as Record)[key],(intent as unknown as Record)[key])))throw fault('invalid_prepared_intent'); operations.set(intent.id, freeze(clone(prepared))); } const next: CoreSnapshot = Object.freeze({ revision: state.revision + 1, document, history, operations: readMap(operations) }); @@ -268,6 +270,23 @@ export function createEditor(options: EditorOptions state,getQueries:(document=state.document)=>{const value=queriesByDocument.get(document);if(!value)throw fault('query_snapshot_unavailable');return value;}, registry: options.registry, dispatch, transaction, can, + /** Coordinator entry: document, amended history and outbound fact share one Root. */ + resolveCreationSize(effect: CreationSizeEffect, expectedRootRevision: number): Result { + try { assertOpen(); } catch (error) { return reject(error); } + entered = true; + try { + if (expectedRootRevision !== state.rootRevision) throw fault('stale_plan'); + validateCreationSizeEffect(effect); + const plan = planCreationSize(state.document, state.history, options.registry, effect.entryId, effect.target, effect.size); + const after = plan.document.elements.get(effect.target.id)!; + if (after.structure !== 'node') throw fault('creation_size_target_changed'); + const allowed = options.policy?.({ type: 'element.geometry', target: effect.target, patch: { width: after.geometry.width, height: after.geometry.height } }, state.document); + if (allowed && !allowed.enabled) throw fault(allowed.code, allowed.targets); + if (!plan.changes.length) return Object.freeze({ status: 'applied', revision: state.revision, operationIds: Object.freeze([]) }); + return publish(plan.document, plan.changes, 'asset-resolution', plan.history, effect.entryId, freeze(clone(effect))); + } catch (error) { return reject(error); } + finally { entered = false; } + }, subscribe(listener: (commit: Commit, snapshot: Snapshot) => void) { if (disposed) throw fault('editor_disposed'); listeners.add(listener); return () => { listeners.delete(listener); }; }, /** Engine-only metadata commit, sharing the document/history publication boundary. */ updateRuntime(reduce: (runtime: Runtime) => Runtime, expectedRootRevision = state.rootRevision): Result { diff --git a/src/lib/canvas-core/types.ts b/src/lib/canvas-core/types.ts index 6b16c94..3bcec2b 100644 --- a/src/lib/canvas-core/types.ts +++ b/src/lib/canvas-core/types.ts @@ -36,7 +36,8 @@ export type Applied = { readonly status: 'applied'; readonly revision: number; r export type Result = Applied | Rejected; export type HistoryEntry = { readonly id: Id; readonly label: string; readonly changes: readonly Change[]; readonly operationIds: readonly Id[]; readonly groupId?: Id }; export type HistoryState = { readonly undo: readonly Id[]; readonly redo: readonly Id[]; readonly entries: ReadonlyMap }; -type IntentBase = { readonly historyTransition?:HistoryTransition; readonly id: Id; readonly operationSchemaVersion: 1; readonly sequence: number; readonly origin: Origin; readonly canvasIds: readonly Id[]; readonly changes: readonly Change[]; readonly dependencies: readonly Id[] }; +export type CreationSizeEffect = { readonly version: 1; readonly kind: 'creation-size'; readonly entryId: Id; readonly target: Target; readonly size: { readonly width: number; readonly height: number } }; +type IntentBase = { readonly historyEffect?:CreationSizeEffect; readonly historyTransition?:HistoryTransition; readonly id: Id; readonly operationSchemaVersion: 1; readonly sequence: number; readonly origin: Origin; readonly canvasIds: readonly Id[]; readonly changes: readonly Change[]; readonly dependencies: readonly Id[] }; export type RunInput = { readonly target: Target; readonly boardId: Id; readonly modelId: string; readonly prompt: string; readonly parameters: Data; readonly propagate: boolean }; export type RunCancelInput = { readonly runId: Id; readonly boardId: Id }; export type RunApplyInput = RunCancelInput & { readonly targetCanvasId: Id; readonly target: Target; readonly outputId: Id }; diff --git a/src/lib/canvas-runtime/conflicts.ts b/src/lib/canvas-runtime/conflicts.ts index 9cc4edd..2d44a46 100644 --- a/src/lib/canvas-runtime/conflicts.ts +++ b/src/lib/canvas-runtime/conflicts.ts @@ -46,7 +46,7 @@ export function planConflictRecovery(snapshot: EngineSnapshot, canonical: Canoni for(const record of records){ const intent=record.intent,choice=choices.get(intent.id)!; if(intent.operationSchemaVersion!==1)throw fault('operation_interpreter_unavailable',[intent.id]); - if(intent.changes.length&&!['user','undo','redo'].includes(intent.origin))throw fault('recovery_history_traversal_not_supported',[intent.id]); + if(intent.changes.length&&!['user','undo','redo'].includes(intent.origin)&&!(intent.origin==='asset-resolution'&&intent.historyEffect))throw fault('recovery_history_traversal_not_supported',[intent.id]); const before=document,entryId=historyOwner.get(intent.id); if(entryId&&!boundaries.has(entryId))boundaries.set(entryId,{before,after:before}); for(const original of intent.changes)if(original.kind==='field'){ diff --git a/src/lib/canvas-runtime/default-image-size.ts b/src/lib/canvas-runtime/default-image-size.ts new file mode 100644 index 0000000..65d19cb --- /dev/null +++ b/src/lib/canvas-runtime/default-image-size.ts @@ -0,0 +1,84 @@ +import type { Commit, CoreSnapshot, Id, Registry, Target } from '../canvas-core/types.ts'; +import { closeNumber, unitScale } from '../canvas-core/units.ts'; +import { fault, freeze, readMap } from '../canvas-core/values.ts'; +import type { EngineSnapshot, RuntimeState } from './contracts.ts'; +import type { ResourceRecord } from './resources.ts'; + +export type DefaultSizeGrant = { + readonly target: Target; readonly entryId: Id; readonly componentId: string; readonly dataVersion: number; + readonly initialSize: { readonly width: number; readonly height: number }; + readonly status: 'pending' | 'applied' | 'revoked' | 'blocked'; readonly code?: string; +}; +export type DefaultImageSizing = { readonly version: 1; readonly grants: readonly DefaultSizeGrant[] }; + +export function validateDefaultImageSizing(value: unknown): asserts value is DefaultImageSizing { + const state = value as DefaultImageSizing; + if (!state || state.version !== 1) throw fault('default_size_interpreter_unavailable'); + if (!Array.isArray(state.grants)) throw fault('invalid_default_size_grant'); + const ids = new Set(); + for (const grant of state.grants) { + if (!grant || typeof grant.target?.id !== 'string' || !grant.target.id || !Number.isSafeInteger(grant.target.incarnation) || grant.target.incarnation < 1 + || typeof grant.entryId !== 'string' || !grant.entryId || typeof grant.componentId !== 'string' || !grant.componentId || !Number.isSafeInteger(grant.dataVersion) || grant.dataVersion < 1 + || !grant.initialSize || ![grant.initialSize.width, grant.initialSize.height].every(value => typeof value === 'number' && Number.isFinite(value) && value > 0) + || !['pending', 'applied', 'revoked', 'blocked'].includes(grant.status) || grant.code !== undefined && (typeof grant.code !== 'string' || !grant.code)) throw fault('invalid_default_size_grant'); + const key = JSON.stringify([grant.target.id, grant.target.incarnation]); + if (ids.has(key)) throw fault('invalid_default_size_grant'); ids.add(key); + } +} + +export function captureDefaultImageSizing(resource: ResourceRecord, next: CoreSnapshot, commit: Commit, registry: Registry): ResourceRecord { + const grants: DefaultSizeGrant[] = []; + for (const entry of next.history.entries.values()) { + if (!entry.operationIds.some(id => commit.operationIds.includes(id))) continue; + for (const change of commit.changes) { + if (change.kind !== 'element' || change.before || change.after?.structure !== 'node') continue; + const element = next.document.elements.get(change.id); + if (element?.structure !== 'node' || element.incarnation !== change.after.incarnation || element.data.assetId !== resource.id || registry.resolve(element) !== 'known' + || !registry.components.get(element.componentId)?.resourceFields?.includes('assetId') || !registry.supports(element, 'geometry.resize')) continue; + const units = unitScale(element.data.presentation); + grants.push(freeze({ target: { id: element.id, incarnation: element.incarnation }, entryId: entry.id, componentId: element.componentId, dataVersion: element.dataVersion, + initialSize: { width: element.geometry.width / units, height: element.geometry.height / units }, status: 'pending' })); + } + } + return freeze({ ...resource, sizing: { version: 1, grants } }); +} + +/** Runs on every publication path. Revocation is durable and does not revive when + * undo happens to return to the default geometry. Preview-only gestures do not publish. + */ +export function prepareDefaultImageSizing(previous: EngineSnapshot, next: EngineSnapshot): RuntimeState { + let resources = next.runtime.resources; + for (const [id, resource] of resources) { + if (resource.sizing?.version !== 1) continue; + let changed = false; + const grants = resource.sizing.grants.map(grant => { + if (!['pending', 'blocked'].includes(grant.status)) return grant; + const element = next.document.elements.get(grant.target.id); + let code: string | undefined; + if (element?.structure !== 'node' || element.incarnation !== grant.target.incarnation || element.componentId !== grant.componentId || element.dataVersion !== grant.dataVersion || element.data.assetId !== id) code = 'target_changed'; + else if (!next.history.undo.includes(grant.entryId)) code = 'creation_history_unavailable'; + else { + const units = unitScale(element.data.presentation); + if (!closeNumber(element.geometry.width / units, grant.initialSize.width) || !closeNumber(element.geometry.height / units, grant.initialSize.height)) code = 'user_resized'; + // Detect committed resize-away-and-back transactions. Pure coordinate + // conversions may alter geometry sizes and unitScale together. + if (!code && previous.document !== next.document && previous.operations.size !== next.operations.size) { + for (const [operationId, intent] of next.operations) { + if (previous.operations.has(operationId) || intent.origin !== 'user') continue; + const conversion = intent.changes.some(change => change.kind === 'field' && change.target.id === grant.target.id + && (change.field === 'ownerCanvasId' || change.field === 'data.presentation' && !closeNumber(unitScale(change.before), unitScale(change.after)))); + if (!conversion && intent.changes.some(change => change.kind === 'field' && change.target.id === grant.target.id && change.field === 'geometry' + && (!closeNumber((change.before as { width: number }).width, (change.after as { width: number }).width) || !closeNumber((change.before as { height: number }).height, (change.after as { height: number }).height)))) code = 'user_resized'; + } + } + } + if (!code) return grant; + changed = true; return freeze({ ...grant, status: 'revoked' as const, code }); + }); + if (changed) { + if (resources === next.runtime.resources) resources = new Map(resources); + (resources as Map).set(id, freeze({ ...resource, sizing: { version: 1, grants } })); + } + } + return resources === next.runtime.resources ? next.runtime : Object.freeze({ ...next.runtime, resources: readMap(resources) }); +} diff --git a/src/lib/canvas-runtime/engine.ts b/src/lib/canvas-runtime/engine.ts index d639df9..07a5144 100644 --- a/src/lib/canvas-runtime/engine.ts +++ b/src/lib/canvas-runtime/engine.ts @@ -1,5 +1,7 @@ import {validImageDimensions,type ImageDecoderPort} from './image-decoder.ts'; import {unitScale} from '../canvas-core/units.ts'; +import { validateCreationSizeEffect } from '../canvas-core/creation-size-plan.ts'; +import { captureDefaultImageSizing, prepareDefaultImageSizing } from './default-image-size.ts'; import {orphanCoverage,prepareOrphanFiles,abortableFileRead,type OrphanFile} from './orphan-files.ts'; import {importFiles,terminalImport,markImportFiles,prepareImportFileRetention,type FileRetirement} from './import-file-retention.ts'; import {resourceRetentionPins,prepareResourceRetention} from './resource-retention.ts'; @@ -75,8 +77,10 @@ export async function openProjectEngine(options: EngineOptions) { for(const [id,entry]of importFiles(recovered?.runtime.imports))if(entry.file.retirement?.purgedAt!==undefined)durablyPurged.add(id); let collection:Promise|undefined; const imageTasks=new Map>(); + let sizingTask: Promise | undefined; const fileWrites = new Map(), digestTasks = new Map>(); let staged: readonly ResourceRecord[] = []; + const stagedDefaultSizes = new Set(); let applying: RunApplyInput | undefined; let importing: { job: ImportJob; expectedGeneration: number } | undefined; const preparationControllers=new Map(); @@ -91,7 +95,8 @@ export async function openProjectEngine(options: EngineOptions) { traversalPolicy(entry, direction) { return closing || closed || recovery ? { enabled: false, code: closing||closed?'engine_closing':'recovery_in_progress', targets: [] } : options.traversalPolicy?.(entry, direction) ?? { enabled: true }; }, onListenerError: report, prepareRuntime:(previous,next)=>{ - const runtime=prepareResourceRetention(next,options.registry,displayPins,collectionReservations,admissionPins(next)); + const sizing=prepareDefaultImageSizing(previous,next); + const runtime=prepareResourceRetention({...next,runtime:sizing},options.registry,displayPins,collectionReservations,admissionPins(next)); const changed=previous.document!==next.document||previous.history!==next.history||previous.runtime.ledger!==runtime.ledger||previous.runtime.resourcePins!==runtime.resourcePins; const imports=prepareImportFileRetention(previous.runtime,runtime,()=>resourceRetentionPins({...next,runtime},options.registry,displayPins,admissionPins({...next,runtime})),collectionReservations,changed); return prepareOrphanFiles(previous.runtime,imports,()=>orphanCoverage({...next,runtime:imports},options.registry,displayPins,admissionPins({...next,runtime:imports})),collectionReservations,changed); @@ -104,12 +109,20 @@ export async function openProjectEngine(options: EngineOptions) { const firstSequence = next.operations.get(commit.operationIds[0])!.sequence - staged.length; staged.forEach((resource, index) => { if (resources.has(resource.id) || ledger.getSnapshot().operations.has(resource.operationId)) throw fault('resource_identity_conflict', [resource.id]); - resources.set(resource.id, resource); + resources.set(resource.id, stagedDefaultSizes.has(resource.id) ? captureDefaultImageSizing(resource, next, commit, options.registry) : resource); ledger.add(freeze({ id: resource.operationId, operationType: 'asset.upload', operationSchemaVersion: 1, sequence: firstSequence + index, origin: 'user', canvasIds: [], changes: [], dependencies: [], resourceId: resource.id }), {}); ledger.block(resource.operationId, ['waiting_digest']); }); for (const id of commit.operationIds) { const intent = next.operations.get(id)!, footprint = operationFootprint(intent, next.document); + if (intent.historyEffect) { + const effect = intent.historyEffect; + for (const [resourceId, resource] of resources) if (resource.sizing?.version === 1) { + const grants = resource.sizing.grants.map(grant => grant.status === 'pending' && grant.entryId === effect.entryId && grant.target.id === effect.target.id && grant.target.incarnation === effect.target.incarnation + ? freeze({ ...grant, status: 'applied' as const, code: undefined }) : grant); + if (grants.some((grant, index) => grant !== resource.sizing!.grants[index])) resources.set(resourceId, freeze({ ...resource, sizing: { version: 1 as const, grants } })); + } + } const dependencies = new Set(intent.dependencies); for (const record of ledger.getSnapshot().operations.values()) if (record.phase !== 'resolved' && (record.intent.operationType === 'run.start' || ['document.change','run.apply'].includes(record.intent.operationType) && conflictsWith(operationFootprint(record.intent, next.document), footprint))) dependencies.add(record.intent.id); for (const id of referencedResources(intent.changes, options.registry)) { @@ -371,7 +384,7 @@ export async function openProjectEngine(options: EngineOptions) { } const scheduler = createScheduler({ getLedger: () => editor.getSnapshot().runtime.ledger, mutate, persist, createId: options.createId ?? (() => crypto.randomUUID()), - participants: intent => operationCanvases(intent, editor.getSnapshot().document), + participants: intent => { if(intent.historyEffect)validateCreationSizeEffect(intent.historyEffect);return operationCanvases(intent, editor.getSnapshot().document); }, compile(intent, id) { if (intent.operationType === 'document.change') return options.persistence.compile(intent, id, editor.getSnapshot()); if (['run.start','run.cancel','run.apply'].includes(intent.operationType)) { if (!options.runs) throw fault('run_interpreter_unavailable');return options.runs.compile(intent,id,editor.getSnapshot()); } @@ -444,6 +457,7 @@ export async function openProjectEngine(options: EngineOptions) { poll:(run,signal)=>options.runs ? network.run(signal,()=>options.runs!.poll(run,signal)) : Promise.reject(fault('run_interpreter_unavailable')),onError:report}); const trackedRuns=new Set(initial.runs.keys()); const sessions = createSessions(editor); + const unsubscribeSizing = sessions.subscribe(() => { if (!sessions.getSnapshot().gesture) queueDefaultSizes(); }); const unsubscribe = editor.subscribe((commit, snapshot) => { pending.set(snapshot.rootRevision, snapshot); const addedRuns=[...snapshot.runtime.runs.keys()].filter(id=>!trackedRuns.has(id));for(const id of addedRuns)trackedRuns.add(id); @@ -479,6 +493,7 @@ export async function openProjectEngine(options: EngineOptions) { } } function prepareImages(){ + queueDefaultSizes(); if(closing||closed||recovery||!options.images)return; for(const resource of editor.getSnapshot().runtime.resources.values()){ if(imageTasks.size>=2)break; @@ -494,6 +509,52 @@ export async function openProjectEngine(options: EngineOptions) { })().catch(error=>{if(closing||closed||recovery)return;const code=(error as{code?:string}).code??'image_decode_failed';editor.updateRuntime(previous=>{const current=previous.resources.get(resource.id);if(!current||current.operationId!==resource.operationId||current.image!==resource.image)return previous;const resources=new Map(previous.resources);resources.set(resource.id,freeze({...current,image:{version:1 as const,status:'failed' as const,code}}));return Object.freeze({...previous,resources:readMap(resources)});});}).finally(()=>{imageTasks.delete(resource.id);prepareImages();});imageTasks.set(resource.id,task); } } + function queueDefaultSizes() { + if (sizingTask || closing || closed || recovery || ![...editor.getSnapshot().runtime.resources.values()].some(resource => resource.sizing?.version === 1 && resource.sizing.grants.some(grant => grant.status === 'pending'))) return; + // A scheduling yield, not a logical retry deadline: avoid a self-extending + // microtask chain when one resource supplies many newly created elements. + sizingTask = new Promise(resolve => { setTimeout(() => { sizingTask = undefined; try { prepareDefaultSizes(); } catch (error) { report(error); } finally { resolve(); } }, 0); }); + } + function prepareDefaultSizes() { + if (closing || closed || recovery || sessions.getSnapshot().gesture) return; + const root = editor.getSnapshot(); + for (const [id, resource] of root.runtime.resources) { + if (resource.sizing?.version !== 1 || resource.image && resource.image.version !== 1) continue; + const size = resource.image?.version === 1 && resource.image.status === 'ready' ? resource.image : !options.images ? resource.stored : undefined; + if (!size || !validImageDimensions(size)) continue; + for (const grant of resource.sizing.grants) { + if (grant.status !== 'pending') continue; + const element = root.document.elements.get(grant.target.id); + if (element?.structure !== 'node') continue; + const units = unitScale(element.data.presentation), min = units, max = 100000 * units; + const scale = Math.min(element.geometry.width / size.width, max / size.width, max / size.height), grow = Math.max(scale, min / size.width, min / size.height); + const width = size.width * grow, height = size.height * grow; + const result = !Number.isFinite(width) || !Number.isFinite(height) || width > max || height > max ? reject(fault('image_fit_constraint')) + : editor.resolveCreationSize({ version: 1, kind: 'creation-size', entryId: grant.entryId, target: grant.target, size: { width: width / units, height: height / units } }, root.rootRevision); + if (result.status === 'rejected' || !result.operationIds.length) { + editor.updateRuntime(previous => { + const current = previous.resources.get(id); + if (current?.sizing?.version !== 1) return previous; + const grants = current.sizing.grants.map(value => value !== grant ? value : freeze({ ...value, status: result.status === 'rejected' ? 'blocked' as const : 'applied' as const, code: result.status === 'rejected' ? result.code : undefined })); + const resources = new Map(previous.resources); resources.set(id, freeze({ ...current, sizing: { version: 1 as const, grants } })); + return Object.freeze({ ...previous, resources: readMap(resources) }); + }); + if (result.status === 'rejected') report(fault(result.code, result.targets)); + } + return; // One publication per pump; its notification schedules further grants. + } + } + } + function retryDefaultSizes() { + editor.updateRuntime(previous => { + const resources = new Map(previous.resources); let changed = false; + for (const [id, resource] of resources) if (resource.sizing?.version === 1 && resource.sizing.grants.some(grant => grant.status === 'blocked')) { + resources.set(id, freeze({ ...resource, sizing: { version: 1 as const, grants: resource.sizing.grants.map(grant => grant.status === 'blocked' ? freeze({ ...grant, status: 'pending' as const, code: undefined }) : grant) } })); changed = true; + } + return changed ? Object.freeze({ ...previous, resources: readMap(resources) }) : previous; + }); + queueDefaultSizes(); + } function queueRunAction(operationType:'run.cancel'|'run.apply',action:RunCancelInput|RunApplyInput):Result { const root=editor.getSnapshot();let sequence=1;for(const record of root.runtime.ledger.operations.values())sequence=Math.max(sequence,record.intent.sequence+1); const id=createId(),intent={id,operationType,action,operationSchemaVersion:1,sequence,origin:'user',canvasIds:operationType==='run.apply'?[(action as RunApplyInput).targetCanvasId]:[],changes:[],dependencies:operationType==='run.cancel'?[]:[...root.runtime.ledger.operations.values()].filter(record=>record.phase!=='resolved').map(record=>record.intent.id)} as OperationIntent; @@ -757,17 +818,17 @@ export async function openProjectEngine(options: EngineOptions) { if (staged.length) throw fault('reentrant_dispatch'); if (!options.assets) throw fault('asset_interpreter_unavailable'); const resources = files.map(file => { - if (!(file.bytes instanceof Blob) || !file.bytes.size || file.bytes.size > maxResourceBytes || !file.bytes.type.startsWith('image/') || typeof file.name !== 'string' || !file.name) throw fault('invalid_staged_file'); + if (!(file.bytes instanceof Blob) || !file.bytes.size || file.bytes.size > maxResourceBytes || !file.bytes.type.startsWith('image/') || typeof file.name !== 'string' || !file.name || file.defaultSize !== undefined && typeof file.defaultSize !== 'boolean') throw fault('invalid_staged_file'); return freeze({ id: createId(), operationId: createId(), name: file.name, mimeType: file.bytes.type, byteLength: file.bytes.size,...(options.images?{image:{version:1 as const,status:'pending' as const}}:{}) }); }); if (new Set(resources.flatMap(resource => [resource.id, resource.operationId])).size !== resources.length * 2 || resources.some(resource => editor.getSnapshot().runtime.resources.has(resource.id) || editor.getSnapshot().runtime.orphanFiles?.has(resource.id) || fileWrites.has(resource.id) || importFiles(editor.getSnapshot().runtime.imports).has(resource.id))) throw fault('resource_identity_conflict'); - staged = resources; resources.forEach((resource, index) => fileWrites.set(resource.id, files[index].bytes)); + staged = resources; resources.forEach((resource, index) => { fileWrites.set(resource.id, files[index].bytes);if(files[index].defaultSize)stagedDefaultSizes.add(resource.id); }); try { const handles = freeze(resources.map(resource => ({ id: resource.id, src: options.assets!.sourceURL(resource.id) }))); const result = editor.transaction(tx => callback(handles, tx), { label: 'Add images' }); if (result.status === 'rejected' || !result.operationIds.length) for (const resource of resources) fileWrites.delete(resource.id); return result; - } finally { staged = []; for (const resource of resources) if (!editor.getSnapshot().runtime.resources.has(resource.id)) fileWrites.delete(resource.id); } + } finally { staged = []; stagedDefaultSizes.clear();for (const resource of resources) if (!editor.getSnapshot().runtime.resources.has(resource.id)) fileWrites.delete(resource.id); } }, async displayResource(id: Id) { if (closing || closed) throw fault('engine_closing'); @@ -810,12 +871,12 @@ export async function openProjectEngine(options: EngineOptions) { getSnapshot: editor.getSnapshot, persist, cancelRecovery:async()=>{await recovery?.cancel();}, getDurability: () => Object.freeze({ rootRevision: editor.getSnapshot().rootRevision, durableRootRevision }), - retry: async () => { if(recovery)await recovery.cancel();assertWritable();const runtime=editor.getSnapshot().runtime,ids=[...runtime.resources.values()].filter(resource=>!resource.digest&&runtime.ledger.operations.get(resource.operationId)?.blockers.some(code=>digestFailures.has(code))).map(resource=>resource.operationId);if(ids.length){mutate(ledger=>{for(const id of ids)ledger.block(id,['waiting_digest']);});prepareDigests();}if(options.images){editor.updateRuntime(previous=>{const resources=new Map(previous.resources);let changed=false;for(const [id,resource]of resources)if(resource.image?.version===1&&resource.image.status==='failed'){resources.set(id,freeze({...resource,image:{version:1 as const,status:'pending' as const}}));changed=true;}return changed?Object.freeze({...previous,resources:readMap(resources)}):previous;});prepareImages();}await scheduler.resume();await runTracker.retry(); }, - async settle() { await Promise.resolve();await Promise.allSettled([...copyPreparations.values()]);while(importLoads.size)await Promise.all([...importLoads.values()]);await Promise.all([...importRunners.values()].map(runner=>runner.settle())); while (digestTasks.size) await Promise.all([...digestTasks.values()]);while(imageTasks.size)await Promise.all([...imageTasks.values()]); await scheduler.settle(); await runTracker.settle(); await persist(); }, + retry: async () => { if(recovery)await recovery.cancel();assertWritable();retryDefaultSizes();const runtime=editor.getSnapshot().runtime,ids=[...runtime.resources.values()].filter(resource=>!resource.digest&&runtime.ledger.operations.get(resource.operationId)?.blockers.some(code=>digestFailures.has(code))).map(resource=>resource.operationId);if(ids.length){mutate(ledger=>{for(const id of ids)ledger.block(id,['waiting_digest']);});prepareDigests();}if(options.images){editor.updateRuntime(previous=>{const resources=new Map(previous.resources);let changed=false;for(const [id,resource]of resources)if(resource.image?.version===1&&resource.image.status==='failed'){resources.set(id,freeze({...resource,image:{version:1 as const,status:'pending' as const}}));changed=true;}return changed?Object.freeze({...previous,resources:readMap(resources)}):previous;});prepareImages();}await scheduler.resume();await runTracker.retry(); }, + async settle() { await Promise.resolve();await Promise.allSettled([...copyPreparations.values()]);while(importLoads.size)await Promise.all([...importLoads.values()]);await Promise.all([...importRunners.values()].map(runner=>runner.settle())); while (digestTasks.size) await Promise.all([...digestTasks.values()]);while(imageTasks.size)await Promise.all([...imageTasks.values()]);while(sizingTask)await sizingTask; await scheduler.settle(); await runTracker.settle(); await persist(); }, async close() { if (closed) return; - closing = true;orphanController?.abort();await Promise.allSettled([collection,sourceCollection,orphanCollection].filter(Boolean));digestLifecycle.abort();for(const controller of preparationControllers.values())controller.abort();for(const controller of copyPreparations.keys())controller.abort();await Promise.allSettled([...copyPreparations.values()]); sessions.dispose(); scheduler.stop(); runTracker.stop(); - await Promise.all([...importRunners.values()].map(runner=>runner.close()));while(importLoads.size)await Promise.all([...importLoads.values()]);while(digestTasks.size)await Promise.allSettled([...digestTasks.values()]);while(imageTasks.size)await Promise.allSettled([...imageTasks.values()]); + closing = true;unsubscribeSizing();orphanController?.abort();await Promise.allSettled([collection,sourceCollection,orphanCollection].filter(Boolean));digestLifecycle.abort();for(const controller of preparationControllers.values())controller.abort();for(const controller of copyPreparations.keys())controller.abort();await Promise.allSettled([...copyPreparations.values()]); sessions.dispose(); scheduler.stop(); runTracker.stop(); + await Promise.all([...importRunners.values()].map(runner=>runner.close()));while(importLoads.size)await Promise.all([...importLoads.values()]);while(digestTasks.size)await Promise.allSettled([...digestTasks.values()]);while(imageTasks.size)await Promise.allSettled([...imageTasks.values()]);while(sizingTask)await sizingTask; recovery?.controller.abort();recovery?.release(); for (const release of [...displays]) release(); // Failure keeps the leader/recovery material available for an explicit retry of close. diff --git a/src/lib/canvas-runtime/history-event-source.ts b/src/lib/canvas-runtime/history-event-source.ts index 4089dd9..7b45c5a 100644 --- a/src/lib/canvas-runtime/history-event-source.ts +++ b/src/lib/canvas-runtime/history-event-source.ts @@ -1,4 +1,5 @@ import type { OperationIntent } from '../canvas-core/types.ts'; +import { validateCreationSizeEffect } from '../canvas-core/creation-size-plan.ts'; import { validateHistoryTransition, type HistoryTransition } from '../canvas-core/history-transition.ts'; import { fault } from '../canvas-core/values.ts'; import type { EngineSnapshot } from './contracts.ts'; @@ -12,7 +13,7 @@ export function historyEventSource(snapshot:EngineSnapshot,pending:readonly Oper const content=pending.filter(intent=>intent.changes.length).sort((a,b)=>a.sequence-b.sequence),first=content[0];if(!first)return{events:[]}; const records=snapshot.runtime.ledger.operations,audits=[...snapshot.runtime.recoveries.values()],owners=audits.filter(audit=>audit.decisions.some(decision=>decision.replacementId===first.id)); if(owners.length>1)throw fault('recovery_history_adoption_ambiguous',[first.id]); - const interpreter=(record:OperationRecord)=>{if(record.intent.operationSchemaVersion!==1)throw fault('operation_interpreter_unavailable',[record.intent.id]);}; + const interpreter=(record:OperationRecord)=>{if(record.intent.operationSchemaVersion!==1)throw fault('operation_interpreter_unavailable',[record.intent.id]);if(record.intent.historyEffect)validateCreationSizeEffect(record.intent.historyEffect);}; const pendingIds=new Set(content.map(intent=>intent.id)); const order=(events:readonly HistorySourceEvent[])=>{const seen=new Set(),active:string[]=[];for(const event of events)if(event.record){const id=event.record.intent.id;if(seen.has(id))throw fault('recovery_history_event_order',[id]);seen.add(id);if(pendingIds.has(id))active.push(id);}if(active.length!==content.length||active.some((id,index)=>id!==content[index].id))throw fault('recovery_history_event_order');}; const native=(record:OperationRecord):HistorySourceEvent=>{interpreter(record);const transition=record.intent.historyTransition;if(!transition)throw fault('recovery_history_transition_missing',[record.intent.id]);validateHistoryTransition(transition);return{record,transition};}; diff --git a/src/lib/canvas-runtime/history-journal-recovery.ts b/src/lib/canvas-runtime/history-journal-recovery.ts index b236aeb..7b093b0 100644 --- a/src/lib/canvas-runtime/history-journal-recovery.ts +++ b/src/lib/canvas-runtime/history-journal-recovery.ts @@ -7,6 +7,7 @@ import { rebaseIntent, type IntentContext } from './change-rebase.ts'; import { sameHistory, type HistoryReplay, type HistoryReplayEvent } from './history-replay.ts'; import { historyEventSource } from './history-event-source.ts'; import type { EngineSnapshot } from './contracts.ts'; +import { planCreationSize, validateCreationSizeEffect } from '../canvas-core/creation-size-plan.ts'; /** Native history events are reconstructed from evidence; all cursors remain private until the recovery root is accepted. */ export function createHistoryJournalRecovery(snapshot:EngineSnapshot,pending:readonly OperationIntent[],registry:Registry,limit:number){ @@ -21,6 +22,7 @@ export function createHistoryJournalRecovery(snapshot:EngineSnapshot,pending:rea const baseline=history,replayEvents:HistoryReplayEvent[]=[],adoptionRevision=snapshot.rootRevision+1; function recordStep(before:HistoryState,entryId:Id|undefined,operationId?:Id){const transition=describeHistoryTransition(before,history,adoptionRevision,entryId,!!operationId);if(transition)replayEvents.push(operationId?freeze({kind:'operation' as const,operationId,transition}):freeze({kind:'adjustment' as const,transition}));} let next=0;const touched=new Set(),retired=new Set();let remap:((change:Change)=>Change)|undefined; + let sizeHistory: HistoryState | undefined; const state=(undo:readonly Id[],redo:readonly Id[],entries:ReadonlyMap)=>Object.freeze({undo:freeze([...undo]),redo:freeze([...redo]),entries:readMap(entries)}); const eventFor=(intent:OperationIntent)=>{const event=events[next];if(!event||event.record?.intent.id!==intent.id)throw fault('recovery_history_event_order',[intent.id]);return event;}; function reviewPrefix(entry:HistoryEntry,document:Document):readonly Change[]{ @@ -61,7 +63,7 @@ export function createHistoryJournalRecovery(snapshot:EngineSnapshot,pending:rea if(!record)applyAdjustment(event); else{ if(pendingIds.has(record.intent.id))throw fault('recovery_history_event_order',[record.intent.id]);if(!['applied','elided'].includes(record.resolution??''))throw fault('recovery_history_event_unresolved',[record.intent.id]); - if(record.intent.origin==='user')addUser(record.intent,record.intent.changes,record.intent.id,false);else if(['undo','redo'].includes(record.intent.origin))addConfirmedTraversal(record.intent);else throw fault('recovery_history_event_origin_unsupported',[record.intent.id]); + if(record.intent.historyEffect)addConfirmedSize(record.intent);else if(record.intent.origin==='user')addUser(record.intent,record.intent.changes,record.intent.id,false);else if(['undo','redo'].includes(record.intent.origin))addConfirmedTraversal(record.intent);else throw fault('recovery_history_event_origin_unsupported',[record.intent.id]); } recordStep(before,event.transition.entryId,record?.intent.id);next++; } @@ -81,9 +83,27 @@ export function createHistoryJournalRecovery(snapshot:EngineSnapshot,pending:rea const entries=new Map(history.entries),map=incarnationRemap(before,after);for(const[otherId,other]of entries)if(otherId!==id)entries.set(otherId,freeze({...other,changes:other.changes.map(map)})); entries.set(id,freeze({...entry,changes,operationIds:[...entry.operationIds,intent.id]}));source.pop();destination.push(id);history=state(undo,redo,entries); } + function addConfirmedSize(intent: OperationIntent) { + validateCreationSizeEffect(intent.historyEffect); + const event = eventFor(intent), entries = new Map(history.entries); + if (event.transition.entryId !== intent.historyEffect.entryId || !equal(event.before!.undo, event.after!.undo) || !equal(event.before!.redo, event.after!.redo)) throw fault('recovery_history_size_conflict'); + for (const patch of event.transition.entries) { + const current = entries.get(patch.id), before = event.before!.entries.get(patch.id), after = event.after!.entries.get(patch.id); + if (!current || !before || !after || !equal(current.changes, before.changes) || current.label !== before.label || current.groupId !== before.groupId) throw fault('recovery_history_size_conflict', [patch.id]); + entries.set(patch.id, freeze({ ...current, changes: after.changes, operationIds: patch.id === intent.historyEffect.entryId ? [...current.operationIds, intent.id] : current.operationIds })); + } + history = state(history.undo, history.redo, entries); + } return{ project(document:Document,intent:OperationIntent,context:IntentContext,keep:boolean){ - if(!intent.changes.length)return{document,changes:[] as readonly Change[]};advance(intent);const event=eventFor(intent),id=event.transition.entryId;remap=undefined; + if(!intent.changes.length)return{document,changes:[] as readonly Change[]};advance(intent);const event=eventFor(intent),id=event.transition.entryId;remap=undefined;sizeHistory=undefined; + if (intent.historyEffect) { + validateCreationSizeEffect(intent.historyEffect); + if (!keep) return { document, changes: [] as readonly Change[] }; + const effect = intent.historyEffect, plan = planCreationSize(document, history, registry, effect.entryId, effect.target, effect.size); + sizeHistory = plan.history; + return { document: plan.document, changes: plan.changes }; + } if(['undo','redo'].includes(intent.origin)){ if(!id)throw fault('recovery_history_traversal_order');const entry=history.entries.get(id); if(!keep){ @@ -100,7 +120,17 @@ export function createHistoryJournalRecovery(snapshot:EngineSnapshot,pending:rea }, accept(intent:OperationIntent,replacement:OperationIntent|undefined,keep:boolean,before:Document,after:Document){ if(!intent.changes.length)return;const previous=history,event=eventFor(intent),id=event.transition.entryId; - if(intent.origin==='user'){if(replacement)addUser(intent,replacement.changes,replacement.id,true,before);else skipUser(intent,before);} + if (intent.historyEffect) { + if (keep) { + if (!sizeHistory) throw fault('recovery_history_size_conflict'); + const entries = new Map(sizeHistory.entries), owner = entries.get(intent.historyEffect.entryId); + if (!owner) throw fault('recovery_history_size_conflict'); + if (replacement) entries.set(owner.id, freeze({ ...owner, operationIds: [...owner.operationIds, replacement.id] })); + for (const [entryId, entry] of entries) if (!equal(entry, history.entries.get(entryId))) touched.add(entryId); + history = state(sizeHistory.undo, sizeHistory.redo, entries); + } + } + else if(intent.origin==='user'){if(replacement)addUser(intent,replacement.changes,replacement.id,true,before);else skipUser(intent,before);} else if(['undo','redo'].includes(intent.origin)&&id&&keep){ const entries=new Map(history.entries),undo=[...history.undo],redo=[...history.redo],source=intent.origin==='undo'?undo:redo,destination=intent.origin==='undo'?redo:undo,entry=entries.get(id)!;source.pop(); if(replacement){const changes=intent.origin==='undo'?invert(replacement.changes):replacement.changes;try{planHistoryChanges(after,intent.origin==='undo'?changes:invert(changes),registry);}catch{throw fault('recovery_history_traversal_conflict',[id]);}if(remap)for(const[otherId,other]of entries)if(otherId!==id)entries.set(otherId,freeze({...other,changes:other.changes.map(remap)}));entries.set(id,freeze({...entry,changes,operationIds:[...entry.operationIds,replacement.id]}));destination.push(id);}else{entries.delete(id);retired.add(id);} diff --git a/src/lib/canvas-runtime/reconcile.ts b/src/lib/canvas-runtime/reconcile.ts index c0ecc27..cd69144 100644 --- a/src/lib/canvas-runtime/reconcile.ts +++ b/src/lib/canvas-runtime/reconcile.ts @@ -1,4 +1,5 @@ import { withLocalLifecycles } from './elision.ts'; +import { validateCreationSizeEffect } from '../canvas-core/creation-size-plan.ts'; import type { Change, Document, Id, OperationIntent, Registry } from '../canvas-core/types.ts'; import { applyChanges, validateDocument } from '../canvas-core/document.ts'; import { equal, fault } from '../canvas-core/values.ts'; @@ -55,6 +56,7 @@ export function replayPending(base: Document, ledger: LedgerSnapshot, registry: let document = withLocalLifecycles(base, ledger); for (const record of [...ledger.operations.values()].sort((a, b) => a.intent.sequence - b.intent.sequence)) { if (record.phase === 'resolved' || !['document.change','run.apply'].includes(record.intent.operationType)) continue; + if (record.intent.historyEffect) validateCreationSizeEffect(record.intent.historyEffect); for (const change of record.intent.changes) { if(change.kind==='field'){const owner=document.elements.get(change.target.id)?.ownerCanvasId;if(owner&&record.intent.canvasIds?.length&&!record.intent.canvasIds.includes(owner))throw fault('operation_context_changed',[change.target.id]);} if (satisfied(document, change)) continue; diff --git a/src/lib/canvas-runtime/recovery-encoding.ts b/src/lib/canvas-runtime/recovery-encoding.ts index 42c30b1..9f11e76 100644 --- a/src/lib/canvas-runtime/recovery-encoding.ts +++ b/src/lib/canvas-runtime/recovery-encoding.ts @@ -52,8 +52,8 @@ export function encodeRecoveryValue(input:unknown):Json { } /** Only codec-owned, validated immutable images qualify for the storage copy fast path. */ -export function sealRecoveryImage(formatVersion:1|2|3|4|5,rootRevision:number,localRevision:number,payload:unknown):RecoveryImage { - if(![1,2,3,4,5].includes(formatVersion)||!Number.isSafeInteger(rootRevision)||rootRevision<0||!Number.isSafeInteger(localRevision)||localRevision<0||localRevision>rootRevision)throw fault('corrupt_recovery_image'); +export function sealRecoveryImage(formatVersion:1|2|3|4|5|6,rootRevision:number,localRevision:number,payload:unknown):RecoveryImage { + if(![1,2,3,4,5,6].includes(formatVersion)||!Number.isSafeInteger(rootRevision)||rootRevision<0||!Number.isSafeInteger(localRevision)||localRevision<0||localRevision>rootRevision)throw fault('corrupt_recovery_image'); const image=Object.freeze({formatVersion,rootRevision,localRevision,payload:encodeRecoveryValue(payload)});sealed.add(image);return image; } export function isSealedRecoveryImage(image:RecoveryImage):boolean{return sealed.has(image);} diff --git a/src/lib/canvas-runtime/recovery.ts b/src/lib/canvas-runtime/recovery.ts index 6d63017..2b6538b 100644 --- a/src/lib/canvas-runtime/recovery.ts +++ b/src/lib/canvas-runtime/recovery.ts @@ -2,6 +2,8 @@ import {validateOrphanFile,type OrphanFile} from './orphan-files.ts'; import {importFiles} from './import-file-retention.ts'; import {resourceRetentionPins} from './resource-retention.ts'; import { sealRecoveryImage } from './recovery-encoding.ts'; +import { validateCreationSizeEffect } from '../canvas-core/creation-size-plan.ts'; +import { validateDefaultImageSizing } from './default-image-size.ts'; import { validateImportJob, type ImportJob } from './imports.ts'; import { validateElisions, withLocalLifecycles } from './elision.ts'; import type { Change, CoreSnapshot, Document, HistoryEntry, HistoryState, Id, Json, OperationIntent, Registry } from '../canvas-core/types.ts'; @@ -25,7 +27,7 @@ export function encodeRecovery(snapshot: EngineSnapshot): RecoveryImage { operations: [...snapshot.operations], runtime: { ...(snapshot.runtime.orphanFiles?.size?{orphanFiles:[...snapshot.runtime.orphanFiles]}:{}), imports:[...(snapshot.runtime.imports??[])], recoveries:[...(snapshot.runtime.recoveries??[])], runs: [...snapshot.runtime.runs], resources: [...snapshot.runtime.resources], resourcePins: [...snapshot.runtime.resourcePins], acknowledged: documentValue(snapshot.runtime.acknowledged), versions: [...snapshot.runtime.versions], receipts: [...snapshot.runtime.receipts], conflicts: [...snapshot.runtime.conflicts], ledger: { ...snapshot.runtime.ledger, operations: [...snapshot.runtime.ledger.operations], requests: [...snapshot.runtime.ledger.requests] } }, }; - return sealRecoveryImage(snapshot.runtime.orphanFiles?.size?5:[...(snapshot.runtime.imports?.values()??[])].some(job=>job.source?.retirement||job.source?.previousFiles?.length)?4:[...snapshot.runtime.resources.values()].some(resource=>resource.retirement)?3:snapshot.runtime.imports?.size ? 2 : 1,snapshot.rootRevision,snapshot.revision,payload); + return sealRecoveryImage([...snapshot.operations.values()].some(intent=>intent.historyEffect)||[...snapshot.runtime.resources.values()].some(resource=>resource.sizing)?6:snapshot.runtime.orphanFiles?.size?5:[...(snapshot.runtime.imports?.values()??[])].some(job=>job.source?.retirement||job.source?.previousFiles?.length)?4:[...snapshot.runtime.resources.values()].some(resource=>resource.retirement)?3:snapshot.runtime.imports?.size ? 2 : 1,snapshot.rootRevision,snapshot.revision,payload); } type ObjectValue = Record; function object(value: unknown): ObjectValue { if (!value || Array.isArray(value) || typeof value !== 'object') throw fault('corrupt_recovery_image'); return value as ObjectValue; } @@ -77,6 +79,7 @@ function intent(value: unknown, id: Id): OperationIntent { else {const target=object(action.target);string(target.id);integer(target.incarnation,1);string(action.outputId);if(!strings(raw.canvasIds).includes(string(action.targetCanvasId)))throw fault('corrupt_recovery_image');} } if(raw.historyTransition!==undefined)historyTransition(raw.historyTransition); + if(raw.historyEffect!==undefined){const effect=object(raw.historyEffect);integer(effect.version,1);if(raw.origin!=='asset-resolution'||kind!=='document.change')throw fault('corrupt_recovery_image');if(effect.version===1)validateCreationSizeEffect(effect);} integer(raw.operationSchemaVersion, 1); integer(raw.sequence, 1); string(raw.origin); strings(raw.canvasIds); strings(raw.dependencies); changes(raw.changes); return freeze(value as OperationIntent); @@ -88,18 +91,20 @@ function readHistory(history:ObjectValue,operations:ReadonlyMap image.rootRevision || !validJson(image.payload)) throw fault('corrupt_recovery_image'); // Own the parsed records; mutating a caller's RecoveryImage must not mutate live state. const raw = object(JSON.parse(JSON.stringify(image.payload))), document = readDocument(raw.document, registry), history = object(raw.history), runtime = object(raw.runtime), ledger = object(runtime.ledger); const operations = table(raw.operations, intent),restoredHistory=readHistory(history,operations); + if(image.formatVersion<6&&[...operations.values()].some(intent=>intent.historyEffect))throw fault('unsupported_recovery_version'); for(const operation of operations.values())if(operation.historyTransition?.version===1&&(operation.historyTransition.rootRevision<1||operation.historyTransition.rootRevision>image.rootRevision))throw fault('corrupt_recovery_image'); if(image.formatVersion===1&&array(runtime.imports??[]).length)throw fault('unsupported_recovery_version'); const records = table(ledger.operations, (value, id) => { const raw = object(value); intent(raw.intent, id); strings(raw.blockers); object(raw.basePresence); if (((object(raw.intent).operationType === 'document.change'||object(raw.intent).operationType==='run.apply'&&array(object(raw.intent).changes).length>0) && !operations.has(id)) || !['pending', 'frozen', 'resolved', 'blocked'].includes(string(raw.phase))) throw fault('corrupt_recovery_image'); if(operations.has(id)&&!equal(operations.get(id)!.historyTransition,(raw.intent as OperationIntent).historyTransition))throw fault('corrupt_recovery_image'); + if(operations.has(id)&&!equal(operations.get(id)!.historyEffect,(raw.intent as OperationIntent).historyEffect))throw fault('corrupt_recovery_image'); if(raw.resolution!==undefined&&!['applied','not-applied','superseded','discarded','satisfied','elided'].includes(string(raw.resolution)))throw fault('corrupt_recovery_image'); return freeze(value as OperationRecord); }); @@ -165,6 +170,7 @@ export function decodeRecovery(image: RecoveryImage, registry: Registry): CoreSn const raw = object(value); if (string(raw.id) !== id || string(raw.operationId) === id) throw fault('corrupt_recovery_image'); string(raw.name); string(raw.mimeType); integer(raw.byteLength, 1); + if(raw.sizing!==undefined){if(image.formatVersion<6)throw fault('unsupported_recovery_version');const sizing=object(raw.sizing);integer(sizing.version,1);if(sizing.version===1)validateDefaultImageSizing(sizing);} if(raw.retirement!==undefined){if(image.formatVersion<3)throw fault('unsupported_recovery_version');const marker=object(raw.retirement);if(marker.version!==1)throw fault('resource_retirement_interpreter_unavailable');integer(marker.unreferencedAt);if(marker.purgedAt!==undefined&&integer(marker.purgedAt)<(marker.unreferencedAt as number))throw fault('corrupt_recovery_image');} if(raw.image!==undefined){const image=object(raw.image);integer(image.version,1);if(image.version===1){if(!['pending','ready','failed'].includes(String(image.status)))throw fault('corrupt_recovery_image');if(image.status==='ready'){integer(image.width,1);integer(image.height,1);if(!Number.isSafeInteger((image.width as number)*(image.height as number))||!['decode','server'].includes(String(image.source)))throw fault('corrupt_recovery_image');}if(image.status==='failed')string(image.code);}} if (raw.digest !== undefined && !/^[a-f0-9]{64}$/.test(string(raw.digest))) throw fault('corrupt_recovery_image'); @@ -200,7 +206,7 @@ export function decodeRecovery(image: RecoveryImage, registry: Registry): CoreSn const original=readHistory(object(backup.history),operations); const composed=replayHistoryEvidence(original,replay,id=>{ const record=records.get(id); - return!!record?.intent.changes.length&&['user','undo','redo'].includes(record.intent.origin)&&(local.has(id)||['applied','elided'].includes(record.resolution??'')); + return!!record?.intent.changes.length&&(['user','undo','redo'].includes(record.intent.origin)||record.intent.origin==='asset-resolution'&&!!record.intent.historyEffect)&&(local.has(id)||['applied','elided'].includes(record.resolution??'')); }); const expected=applyHistoryTransition(original,raw.historyTransition as HistoryTransition,'forward'); if(!sameHistory(composed,expected))throw fault('corrupt_recovery_image'); @@ -231,7 +237,7 @@ export function decodeRecovery(image: RecoveryImage, registry: Registry): CoreSn const imports=table(runtime.imports??[],(value,id)=>{const job=value as ImportJob;validateImportJob(job);if(job.id!==id||job.projectId!==document.projectId)throw fault('corrupt_recovery_image');return freeze(job);}); for(const [id,entry]of importFiles(imports)){if(resources.has(id))throw fault('import_file_identity_conflict',[id]);if((entry.file.retirement||imports.get(entry.jobId)?.source?.previousFiles?.length)&&image.formatVersion<4)throw fault('unsupported_recovery_version');} const orphanFiles=table(runtime.orphanFiles??[],(file,id)=>{validateOrphanFile(file,id);return freeze(file);}); - if(orphanFiles.size&&image.formatVersion!==5)throw fault('unsupported_recovery_version');for(const id of orphanFiles.keys())if(resources.has(id)||importFiles(imports).has(id))throw fault('local_file_identity_conflict',[id]); + if(orphanFiles.size&&image.formatVersion<5)throw fault('unsupported_recovery_version');for(const id of orphanFiles.keys())if(resources.has(id)||importFiles(imports).has(id))throw fault('local_file_identity_conflict',[id]); const restoredLedger: LedgerSnapshot = Object.freeze({ generation, durableGeneration, operations: records, requests }); const snapshot=Object.freeze({ revision: image.localRevision, rootRevision: image.rootRevision, document, history:restoredHistory, operations, runtime: Object.freeze({ orphanFiles, imports, recoveries, runs, resources, resourcePins, ledger: restoredLedger, acknowledged, versions, receipts, conflicts: table(runtime.conflicts, string) }), diff --git a/src/lib/canvas-runtime/resource-retention.ts b/src/lib/canvas-runtime/resource-retention.ts index b4c3d33..5df6294 100644 --- a/src/lib/canvas-runtime/resource-retention.ts +++ b/src/lib/canvas-runtime/resource-retention.ts @@ -19,7 +19,7 @@ export function resourceRetentionPins(snapshot:EngineSnapshot,registry:Registry, for(const resource of runtime.resources.values())if(resource.image&&(resource.image.version!==1||resource.image.status==='pending'))add(resource.id,resource.image.version!==1?'image-decoder-unavailable':'image-decode'); for(const [owner,ids]of temporary)for(const id of ids)add(id,owner); for(const [id,count]of displays)if(count>0)add(id,'display'); - return Object.freeze({complete:current.complete&&base.complete&&![...runtime.resources.values()].some(resource=>resource.image&&resource.image.version!==1),pins:readMap(new Map([...pins].map(([id,owners])=>[id,readSet(owners)])))}); + return Object.freeze({complete:current.complete&&base.complete&&![...runtime.resources.values()].some(resource=>resource.image&&resource.image.version!==1||resource.sizing&&resource.sizing.version!==1),pins:readMap(new Map([...pins].map(([id,owners])=>[id,readSet(owners)])))}); } /** Runs inside the editor's single publication boundary, including reconcile and metadata commits. */ diff --git a/src/lib/canvas-runtime/resources.ts b/src/lib/canvas-runtime/resources.ts index 1651ccd..84b82e1 100644 --- a/src/lib/canvas-runtime/resources.ts +++ b/src/lib/canvas-runtime/resources.ts @@ -1,4 +1,5 @@ import type {ImageDecodeState} from './image-decoder.ts'; +import type { DefaultImageSizing } from './default-image-size.ts'; import type { Change, CoreSnapshot, Document, ElementRecord, Id, OperationIntent, Registry } from '../canvas-core/types.ts'; import { historyTransitionChanges } from '../canvas-core/history-transition.ts'; import { ancestors } from '../canvas-core/document.ts'; @@ -7,7 +8,7 @@ import type { OperationStatus } from './scheduler.ts'; import { readMap, readSet } from '../canvas-core/values.ts'; export type StoredAsset = { readonly id: Id; readonly url: string; readonly width: number; readonly height: number; readonly bytes: number; readonly mime_type: string }; -export type ResourceRecord = { readonly id: Id; readonly operationId: Id; readonly name: string; readonly mimeType: string; readonly byteLength: number; readonly image?:ImageDecodeState; readonly digest?: string; readonly retirement?: {readonly version:1;readonly unreferencedAt:number;readonly purgedAt?:number}; readonly stored?: StoredAsset }; +export type ResourceRecord = { readonly id: Id; readonly operationId: Id; readonly name: string; readonly mimeType: string; readonly byteLength: number; readonly sizing?: DefaultImageSizing; readonly image?:ImageDecodeState; readonly digest?: string; readonly retirement?: {readonly version:1;readonly unreferencedAt:number;readonly purgedAt?:number}; readonly stored?: StoredAsset }; export type DisplayHandle = { readonly url: string; release(): void }; export type AssetPort = { sourceURL(resourceId: Id): string; @@ -16,7 +17,7 @@ export type AssetPort = { query(request: FrozenRequest, signal: AbortSignal): Promise; accept(request: FrozenRequest, status: OperationStatus, resource: ResourceRecord): StoredAsset; }; -export type StagedFile = { readonly bytes: Blob; readonly name: string }; +export type StagedFile = { readonly bytes: Blob; readonly name: string; readonly defaultSize?: boolean }; export type ResourceHandle = { readonly id: Id; readonly src: string }; export const maxResourceBytes = 20 * 1024 * 1024; @@ -105,6 +106,7 @@ export function resourcePins(snapshot: CoreSnapshot, ledger: LedgerSnapshot, reg else changes(record.intent.changes, `operation:${record.intent.id}`); } const pending=[...ledger.operations.values()].filter(record=>record.phase!=='resolved'),earliest=pending.reduce((value,record)=>Math.min(value,record.intent.sequence),Infinity); + for (const record of ledger.operations.values()) if (record.intent.historyEffect && record.intent.historyEffect.version !== 1) complete = false; for(const record of ledger.operations.values())if(record.intent.sequence>=earliest&&record.intent.historyTransition){ if(record.intent.historyTransition.version!==1){complete=false;continue;} changes(historyTransitionChanges(record.intent.historyTransition),`history-transition:${record.intent.id}`); diff --git a/src/lib/canvas-runtime/storage.ts b/src/lib/canvas-runtime/storage.ts index ac3b531..b04d50c 100644 --- a/src/lib/canvas-runtime/storage.ts +++ b/src/lib/canvas-runtime/storage.ts @@ -4,7 +4,7 @@ import type { Json } from '../canvas-core/types.ts'; import { clone, equal, fault, freeze, validJson } from '../canvas-core/values.ts'; export type StorageScope = { readonly accountId: string; readonly workspaceId: string; readonly projectId: string }; -export type RecoveryImage = { readonly formatVersion: 1 | 2 | 3 | 4 | 5; readonly rootRevision: number; readonly localRevision: number; readonly payload: Json }; +export type RecoveryImage = { readonly formatVersion: 1 | 2 | 3 | 4 | 5 | 6; readonly rootRevision: number; readonly localRevision: number; readonly payload: Json }; export type DurableBatch = { readonly image: RecoveryImage; readonly deleteFiles?:readonly string[]; readonly files?: readonly { readonly id: string; readonly bytes: Blob }[] }; export type StorageLeader = { readonly scope: StorageScope; readonly fence: number; readonly active: boolean; release(): Promise }; export type StoredFileInfo={readonly id:string;readonly byteLength:number;readonly mimeType:string}; @@ -43,7 +43,7 @@ function purgedFileIds(image:RecoveryImage):Set{ return result; } function validateImage(image: RecoveryImage) { - if (![1,2,3,4,5].includes(image.formatVersion)) throw fault('unsupported_recovery_version'); + if (![1,2,3,4,5,6].includes(image.formatVersion)) throw fault('unsupported_recovery_version'); if (!Number.isSafeInteger(image.rootRevision) || image.rootRevision < 0 || !Number.isSafeInteger(image.localRevision) || image.localRevision < 0 || image.localRevision > image.rootRevision || !validJson(image.payload)) throw fault('corrupt_recovery_image'); } const requestValue = (request: IDBRequest) => new Promise((resolve, reject) => { request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error); }); @@ -149,7 +149,7 @@ export function createBrowserStorage(options: { indexedDB?: IDBFactory; locks?: const own=(id:string,file:FileProof,eligible:boolean)=>{if(typeof id!=='string'||!id||owned.has(id))throw fault('invalid_file_deletion');owned.add(id);if(eligible)proofs.set(id,file);}; for(const [id,resource]of payload?.runtime?.resources??[])own(id,resource,true); if(image.formatVersion>=4)for(const [,job]of payload?.runtime?.imports??[]){if(job.source){own(job.source.fileId!,job.source,['completed','partial','cancelled'].includes(job.status));for(const file of job.source.previousFiles??[])own(file.fileId!,file,true);}} - if(image.formatVersion===5)for(const [id,file]of payload?.runtime?.orphanFiles??[]){if(file.version!==1||file.id!==id||!Number.isSafeInteger(file.byteLength)||file.byteLength!<0||typeof file.mimeType!=='string'||typeof file.digest!=='string'||!/^[a-f0-9]{64}$/.test(file.digest))throw fault('invalid_file_deletion');own(id,file,true);} + if(image.formatVersion>=5)for(const [id,file]of payload?.runtime?.orphanFiles??[]){if(file.version!==1||file.id!==id||!Number.isSafeInteger(file.byteLength)||file.byteLength!<0||typeof file.mimeType!=='string'||typeof file.digest!=='string'||!/^[a-f0-9]{64}$/.test(file.digest))throw fault('invalid_file_deletion');own(id,file,true);} if(image.formatVersion<3||deleteFiles.some(id=>{const marker=proofs.get(id)?.retirement;return !marker||marker.version!==1||!Number.isSafeInteger(marker.unreferencedAt)||marker.unreferencedAt<0||!Number.isSafeInteger(marker.purgedAt)||marker.purgedAt! `/asset/${id}`, displayURL: id => `/asset/${id}`, async upload(request, bytes) { uploads.push(request);return { outcome: 'applied', canonical: { id: JSON.parse(request.body).resource_id, bytes: bytes.size, mime_type: bytes.type, width: 4, height: 2, url: '/stored' } }; }, async query() { throw Error('unexpected asset query'); }, accept: (_request, status) => status.canonical }; + const options = { scope, registry, document: original, versions: new Map([['root', 1]]), storage, images, assets, persistence, createId: () => `size-${++next}` }; + try { + engine = await openProjectEngine(options); const canvas = new OffscreenCanvas(4, 2); canvas.getContext('2d').fillRect(0, 0, 4, 2); const bytes = await canvas.convertToBlob({ type: 'image/png' }); + const result = engine.stageFiles([{ name: 'pixel.png', bytes, defaultSize: true }], ([ref], tx) => tx.dispatch({ type: 'element.create', element: { id: 'picture', incarnation: 1, componentId: 'image', dataVersion: 1, structure: 'node', ownerCanvasId: 'root', data: { title: 'Picture', text: '', assetId: ref.id, src: ref.src }, geometry: { x: 0, y: 0, width: 260, height: 200, rotation: 0 } } })); + assert(result.status === 'applied', 'default image admission failed'); await engine.settle(); + assert(engine.getSnapshot().document.elements.get('picture').geometry.height === 130 && engine.getSnapshot().history.undo.length === 1, 'decode did not enrich the original creation'); + const request = writes.find(request => JSON.parse(request.body).intent.historyEffect); assert(!!request, 'size change was never submitted'); const body = request.body; + await engine.close(); const owner = await storage.claim(scope), cut = await storage.recover(owner); assert(cut.formatVersion === 6, 'size effect did not require recovery v6'); decodeRecovery(cut, registry); await owner.release(); + engine = await openProjectEngine(options); await engine.settle(); + assert(queries.some(query => query.id === request.id && query.body === body), 'lost size receipt did not query the frozen original request'); + assert(writes.filter(write => write.id === request.id).length === 1, 'lost size receipt repeated the write'); + assert(engine.editor.history.undo().status === 'applied', 'sized creation could not undo after reopening'); await engine.settle(); assert(!server.elements.has('picture'), 'undo left the server image behind'); + assert(engine.editor.history.redo().status === 'applied', 'sized creation could not redo after reopening'); await engine.settle(); + assert(server.elements.get('picture').incarnation === 2 && server.elements.get('picture').geometry.height === 130, 'redo lost size or reused a deleted incarnation'); assert(uploads.length === 1, 'size recovery repeated the upload'); + } finally { await engine?.close(); images.close(); storage.close(); indexedDB.deleteDatabase(`${name}-default-size`); } +} + async function imageDecodeWorkerCase(assert){ const NativeWorker=globalThis.Worker;let active=0,peak=0,created=0;globalThis.Worker=class extends NativeWorker{constructor(...args){super(...args);active++;created++;peak=Math.max(peak,active);this.live=true;}terminate(){if(this.live){this.live=false;active--;}super.terminate();}}; const pool=createBrowserImageDecoder();try{const canvas=new OffscreenCanvas(4,2);canvas.getContext('2d').fillRect(0,0,4,2);const bytes=await canvas.convertToBlob({type:'image/png'}),results=await Promise.all([pool.decode(bytes),pool.decode(bytes),pool.decode(bytes)]);assert(results.every(size=>size.width===4&&size.height===2),'real bitmap decoder returned incorrect dimensions');assert(peak===2&&active===0&&created===3,'image worker concurrency/cleanup failed');let code;try{await pool.decode(new Blob(['not an image'],{type:'image/png'}));}catch(error){code=error.code;}assert(code==='image_decode_failed'&&active===0,'invalid image did not fail or leaked a worker');const cancel=new AbortController(),pending=pool.decode(bytes,cancel.signal),cancelled=pending.then(()=>{throw Error('cancelled image returned dimensions');},error=>assert(error.code==='image_decode_aborted','image cancellation code changed'));cancel.abort();await cancelled;assert(active===0,'cancelled image worker leaked');}finally{pool.close();globalThis.Worker=NativeWorker;} @@ -629,7 +658,7 @@ export async function run() { await storage.commit(leader, { image: image(1) }); await rejects(() => storage.commit(leader, { image: { ...image(1), payload: null } }), 'storage_revision_conflict'); await rejects(() => storage.commit(leader, { image: image(3) }), 'storage_revision_gap'); - await rejects(() => storage.commit(leader, { image: { ...image(2), formatVersion: 6 } }), 'unsupported_recovery_version'); + await rejects(() => storage.commit(leader, { image: { ...image(2), formatVersion: 7 } }), 'unsupported_recovery_version'); results.push('same-cut retry is idempotent; conflicting, skipped and future-format cuts reject'); // The first put succeeds before a later duplicate resource aborts the whole transaction. @@ -678,6 +707,8 @@ export async function run() { results.push('scoped paged inventory finds legacy orphans without hiding references; orphan v5 deletion rolls back faults, retries, isolates projects and forbids byte identity reuse'); await resourceRecoveryCase(name, assert); results.push('staged file and image recover from real IndexedDB; lost upload ACK queries stable resource identity'); + await defaultImageSizeCase(name, assert); + results.push('real image Worker enriches creation history in recovery v6; IndexedDB reopening queries a lost size ACK with the original body, then undo/redo preserves size and upload identity'); await imageDecodeWorkerCase(assert); results.push('real module image workers decode intrinsic PNG dimensions with bounded concurrency, reject bad bytes, abort safely and release their bitmap workers'); await digestWorkerCase(assert); diff --git a/tests/headless/recovery-encoding.test.mjs b/tests/headless/recovery-encoding.test.mjs index fb6f08e..005ad35 100644 --- a/tests/headless/recovery-encoding.test.mjs +++ b/tests/headless/recovery-encoding.test.mjs @@ -24,5 +24,5 @@ test('cached subtree height cannot bypass the depth bound and omitted object fie const cycle={};cycle.self=cycle;assert.throws(()=>encodeRecoveryValue(cycle),/invalid_recovery_payload/);assert.throws(()=>encodeRecoveryValue({big:1n})); }); test('only factory-owned images are recognized; immutable-looking impostors retain defensive-copy handling',()=>{ - const mutable={title:'before'},image=sealRecoveryImage(2,3,2,{mutable});assert.equal(isSealedRecoveryImage(image),true);assert.equal(isSealedRecoveryImage(structuredClone(image)),false);assert.equal(isSealedRecoveryImage(Object.freeze({...image})),false);mutable.title='after';assert.equal(image.payload.mutable.title,'before');assert.ok(Object.isFrozen(image.payload.mutable));assert.throws(()=>sealRecoveryImage(2,1,2,{}),/corrupt_recovery_image/);assert.equal(isSealedRecoveryImage(sealRecoveryImage(3,1,1,{})),true);assert.equal(isSealedRecoveryImage(sealRecoveryImage(4,1,1,{})),true);assert.equal(isSealedRecoveryImage(sealRecoveryImage(5,1,1,{})),true);assert.throws(()=>sealRecoveryImage(6,1,1,{}),/corrupt_recovery_image/); + const mutable={title:'before'},image=sealRecoveryImage(2,3,2,{mutable});assert.equal(isSealedRecoveryImage(image),true);assert.equal(isSealedRecoveryImage(structuredClone(image)),false);assert.equal(isSealedRecoveryImage(Object.freeze({...image})),false);mutable.title='after';assert.equal(image.payload.mutable.title,'before');assert.ok(Object.isFrozen(image.payload.mutable));assert.throws(()=>sealRecoveryImage(2,1,2,{}),/corrupt_recovery_image/);assert.equal(isSealedRecoveryImage(sealRecoveryImage(3,1,1,{})),true);assert.equal(isSealedRecoveryImage(sealRecoveryImage(4,1,1,{})),true);assert.equal(isSealedRecoveryImage(sealRecoveryImage(5,1,1,{})),true);assert.equal(isSealedRecoveryImage(sealRecoveryImage(6,1,1,{})),true);assert.throws(()=>sealRecoveryImage(7,1,1,{}),/corrupt_recovery_image/); }); diff --git a/tests/headless/renderer.integration.mjs b/tests/headless/renderer.integration.mjs index 70f1a44..23dfc83 100644 --- a/tests/headless/renderer.integration.mjs +++ b/tests/headless/renderer.integration.mjs @@ -244,8 +244,12 @@ try { await evaluate(`(async()=>{const canvas=new OffscreenCanvas(4,2);canvas.getContext('2d').fillRect(0,0,4,2);const bytes=await canvas.convertToBlob({type:'image/png'});const transfer=new DataTransfer();transfer.items.add(new File([bytes],'pixel.png',{type:'image/png'}));const input=document.querySelector('[data-headless-canvas] input[type=file]');input.files=transfer.files;input.dispatchEvent(new Event('change',{bubbles:true}));})()`); await until(`document.querySelectorAll('.react-flow__node').length === 5 && [...document.querySelectorAll('[data-headless-canvas] img')].some(image=>image.getAttribute('src')?.startsWith('blob:'))`, 'pending image did not appear before upload'); const pendingImageId=await evaluate(`document.querySelector('[data-headless-canvas] img[src^="blob:"]').closest('.react-flow__node').getAttribute('data-id')`); - await evaluate(`document.querySelector('.react-flow__node[data-id="${pendingImageId}"]').dispatchEvent(new MouseEvent('click',{bubbles:true}))`);await until(`!document.querySelector('[aria-label="按图片比例调整"]').disabled`,'decoded image dimensions were not available before upload ACK');await click('按图片比例调整');await until(`document.querySelector('.react-flow__node[data-id="${pendingImageId}"]').style.height==='130px'`,'explicit fit did not use locally decoded 4:2 aspect ratio');await click('撤销');await until(`document.querySelector('.react-flow__node[data-id="${pendingImageId}"]').style.height==='200px'`,'fit undo did not restore default dimensions');assert.equal(uploads.length<=1,true); - console.log('PASS decoded image metadata is available before upload ACK; explicit ratio fit and its undo preserve the image identity'); + await until(`document.querySelector('.react-flow__node[data-id="${pendingImageId}"]').style.height==='130px'`,'default image size did not automatically use locally decoded 4:2 aspect ratio before upload ACK'); + await evaluate(`document.querySelector('.react-flow__node[data-id="${pendingImageId}"]').dispatchEvent(new MouseEvent('click',{bubbles:true}))`);await until(`!document.querySelector('[aria-label="按图片比例调整"]').disabled`,'decoded image dimensions were not available before upload ACK');await click('按图片比例调整'); + await click('撤销');await until(`!document.querySelector('.react-flow__node[data-id="${pendingImageId}"]')`,'one undo did not remove the automatically sized creation'); + await click('重做');await until(`document.querySelector('.react-flow__node[data-id="${pendingImageId}"]')?.style.height==='130px'`,'redo lost the enriched creation dimensions');assert.equal(uploads.length<=1,true); + await until(`(()=>{const node=document.querySelector('.react-flow__node[data-id="${pendingImageId}"]');const content=[...node.querySelectorAll('div')].find(element=>element.style.zoom);return content?.style.height==='130px';})()`,'image content retained a stale measured height after automatic sizing and redo'); + console.log('PASS default image sizing precedes upload ACK; creation undo/redo includes decoded dimensions without a second history unit or upload'); const screenshot=await call('Page.captureScreenshot',{format:'png'});await writeFile('/private/tmp/headless-canvas-integration.png',Buffer.from(screenshot.data,'base64')); for (let i = 0; i < 100 && !uploads.length; i++) await pause(20); assert.equal(uploads.length, 1); assert.equal(documents.get(root).elements.size, 5); diff --git a/tests/headless/resources.test.mjs b/tests/headless/resources.test.mjs index aeead3f..9eb2504 100644 --- a/tests/headless/resources.test.mjs +++ b/tests/headless/resources.test.mjs @@ -47,15 +47,131 @@ function fixture() { open: extra => openProjectEngine({ scope, registry, document: original, versions: new Map([['canvas', 1]]), storage, assets, persistence, createId: () => `id-${++counter}`, ...extra }), }; } -function addImage(engine, id = 'picture') { +function addImage(engine, id = 'picture', defaultSize = false) { let resource; - const result = engine.stageFiles([{ name: 'image.png', bytes: new Blob(['image bytes'], { type: 'image/png' }) }], ([ref], tx) => { + const result = engine.stageFiles([{ name: 'image.png', bytes: new Blob(['image bytes'], { type: 'image/png' }), defaultSize }], ([ref], tx) => { resource = ref; tx.dispatch({ type: 'element.create', element: node(id, { assetId: ref.id, src: ref.src }) }); }); assert.equal(result.status, 'applied'); return resource; } +test('automatic image sizing publishes with creation history across intervening edits and survives reopening', async () => { + const f = fixture(), gate = deferred(), entered = deferred(); let calls = 0; + const images = { decode: async () => { calls++; entered.resolve(); await gate.promise; return { width: 4, height: 2 }; } }; + let engine = await f.open({ images }); + try { + const ref = addImage(engine, 'picture', true); await entered.promise; + const observations = []; + const unsubscribe = engine.editor.subscribe((_commit, snapshot) => { if (snapshot.document.elements.get('picture')?.geometry.height === 50) observations.push(snapshot); }); + const target = { id: 'picture', incarnation: 1 }; + engine.editor.dispatch({ type: 'element.geometry', target, patch: { x: 70 } }); + engine.editor.dispatch({ type: 'element.data', target, patch: { title: 'Renamed before decode' } }); + const undo = [...engine.getSnapshot().history.undo]; gate.resolve(); await engine.settle(); + const root = engine.getSnapshot(); assert.equal(root.document.elements.get('picture').geometry.height, 50); + assert.equal(root.document.elements.get('picture').geometry.x, 70); assert.deepEqual(root.history.undo, undo); + assert.equal(root.runtime.resources.get(ref.id).sizing.grants[0].status, 'applied'); + assert.ok(observations.length > 0); + for (const snapshot of observations) { + assert.equal(snapshot.runtime.resources.get(ref.id).sizing.grants[0].status, 'applied'); + assert.equal(snapshot.history.entries.get(undo[0]).changes[0].after.geometry.height, 50); + assert.equal([...snapshot.operations.values()].some(intent => intent.historyEffect), true); + } + unsubscribe(); + const effect = [...root.operations.values()].find(intent => intent.historyEffect); + assert.equal(effect.origin, 'asset-resolution'); assert.equal(effect.historyTransition.entries.length, 2); + assert.equal(f.image().formatVersion, 6); decodeRecovery(f.image(), registry); + for (const version of [1, 2, 3, 4, 5]) { const bad = structuredClone(f.image()); bad.formatVersion = version; assert.throws(() => decodeRecovery(bad, registry), /unsupported_recovery_version/); } + await engine.close(); engine = await f.open({ images }); + assert.equal(engine.getSnapshot().document.elements.get('picture').geometry.height, 50); + assert.equal(engine.editor.history.undo().status, 'applied'); + assert.equal(engine.editor.history.undo().status, 'applied'); + assert.equal(engine.getSnapshot().document.elements.get('picture').geometry.x, 0); + assert.equal(engine.getSnapshot().document.elements.get('picture').geometry.height, 50); + assert.equal(engine.editor.history.undo().status, 'applied'); assert.equal(engine.getSnapshot().document.elements.has('picture'), false); + assert.equal(engine.editor.history.redo().status, 'applied'); + assert.equal(engine.getSnapshot().document.elements.get('picture').geometry.height, 50); + await engine.settle(); assert.equal(calls, 1); assert.equal(f.uploads.length, 1); + } finally { gate.resolve(); await engine.close(); } +}); + +test('a committed user resize permanently revokes default sizing even if undone before decoding', async () => { + const f = fixture(), gate = deferred(), entered = deferred(), engine = await f.open({ images: { decode: async () => { entered.resolve(); await gate.promise; return { width: 4, height: 2 }; } } }); + try { + const ref = addImage(engine, 'picture', true); await entered.promise; + engine.editor.dispatch({ type: 'element.geometry', target: { id: 'picture', incarnation: 1 }, patch: { height: 75 } }); + assert.equal(engine.editor.history.undo().status, 'applied'); gate.resolve(); await engine.settle(); + assert.equal(engine.getSnapshot().document.elements.get('picture').geometry.height, 100); + assert.equal(engine.getSnapshot().runtime.resources.get(ref.id).sizing.grants[0].status, 'revoked'); + assert.equal([...engine.getSnapshot().operations.values()].some(intent => intent.historyEffect), false); + decodeRecovery(f.image(), registry); + } finally { gate.resolve(); await engine.close(); } +}); + +test('a pending sizing grant and source bytes survive closing before decode completion', async () => { + const f = fixture(), gate = deferred(), entered = deferred(); let engine = await f.open({ images: { decode: async () => { entered.resolve(); await gate.promise; return { width: 4, height: 2 }; } } }); + const ref = addImage(engine, 'picture', true); await entered.promise; + const closing = engine.close(); gate.resolve(); await closing; + assert.equal(f.image().payload.runtime.resources.find(([id]) => id === ref.id)[1].sizing.grants[0].status, 'pending'); + engine = await f.open({ images: { decode: async () => ({ width: 4, height: 2 }) } }); + try { await engine.settle(); assert.equal(engine.getSnapshot().document.elements.get('picture').geometry.height, 50); assert.equal(engine.getSnapshot().history.undo.length, 1); decodeRecovery(f.image(), registry); } + finally { await engine.close(); } +}); + +test('default sizing waits for a geometry gesture to finish and retains its committed movement', async () => { + const f = fixture(), gate = deferred(), entered = deferred(), engine = await f.open({ images: { decode: async () => { entered.resolve(); await gate.promise; return { width: 4, height: 2 }; } } }); + try { + addImage(engine, 'picture', true); await entered.promise; + engine.sessions.attach('view'); assert.equal(engine.sessions.begin('view', [{ id: 'picture', incarnation: 1 }]).enabled, true); + engine.sessions.update('view', { id: 'picture', incarnation: 1 }, { x: 180 }); gate.resolve(); await engine.settle(); + assert.equal(engine.getSnapshot().document.elements.get('picture').geometry.height, 100); + assert.equal(engine.sessions.commit('view').status, 'applied'); await engine.settle(); + assert.equal(engine.getSnapshot().document.elements.get('picture').geometry.height, 50); + assert.equal(engine.getSnapshot().document.elements.get('picture').geometry.x, 180); + assert.equal(engine.getSnapshot().history.undo.length, 2); + assert.equal(engine.editor.history.undo().status, 'applied'); await engine.settle(); + assert.equal(engine.getSnapshot().document.elements.get('picture').geometry.height, 50); + } finally { gate.resolve(); await engine.close(); } +}); + +test('replacing an image resource revokes its pending grant and a late decoder cannot change geometry', async () => { + const f = fixture(), gate = deferred(), entered = deferred(), engine = await f.open({ images: { decode: async () => { entered.resolve(); await gate.promise; return { width: 4, height: 2 }; } } }); + try { + const ref = addImage(engine, 'picture', true); await entered.promise; + engine.editor.dispatch({ type: 'element.data', target: { id: 'picture', incarnation: 1 }, patch: { assetId: 'replacement' } }); + gate.resolve(); await engine.settle(); + assert.equal(engine.getSnapshot().document.elements.get('picture').geometry.height, 100); + assert.equal(engine.getSnapshot().runtime.resources.get(ref.id).sizing.grants[0].status, 'revoked'); + } finally { gate.resolve(); await engine.close(); } +}); + +test('a denied automatic resize records a recoverable block and explicit retry consumes the original grant', async () => { + const f = fixture(); let denied = true; + const engine = await f.open({ images: { decode: async () => ({ width: 4, height: 2 }) }, policy(command) { return command.type === 'element.geometry' && denied ? { enabled: false, code: 'forbidden', targets: ['picture'] } : { enabled: true }; } }); + try { + const ref = addImage(engine, 'picture', true); await engine.settle(); + assert.equal(engine.getSnapshot().document.elements.get('picture').geometry.height, 100); + assert.equal(engine.getSnapshot().runtime.resources.get(ref.id).sizing.grants[0].status, 'blocked'); + denied = false; await engine.retry(); await engine.settle(); + assert.equal(engine.getSnapshot().document.elements.get('picture').geometry.height, 50); + assert.equal(engine.getSnapshot().runtime.resources.get(ref.id).sizing.grants[0].status, 'applied'); + assert.equal(engine.getSnapshot().history.undo.length, 1); assert.equal(f.uploads.length, 1); + } finally { await engine.close(); } +}); + +test('future default-sizing metadata stays opaque, blocks GC and permits unrelated editing', async () => { + const f = fixture(); let engine = await f.open(); const ref = addImage(engine); await engine.settle(); await engine.close(); + const future = { version: 2, opaque: { reference: 'keep' } }; f.image().formatVersion = 6; f.image().payload.runtime.resources.find(([id]) => id === ref.id)[1].sizing = future; + engine = await f.open({ images: { decode: async () => ({ width: 4, height: 2 }) } }); + try { + await engine.settle(); assert.equal(engine.getResourcePins().complete, false); + assert.deepEqual(engine.getSnapshot().runtime.resources.get(ref.id).sizing, future); + assert.equal(engine.editor.dispatch({ type: 'element.data', target: { id: 'picture', incarnation: 1 }, patch: { title: 'Edited' } }).status, 'applied'); + await engine.settle(); decodeRecovery(f.image(), registry); + assert.equal(engine.getSnapshot().document.elements.get('picture').geometry.height, 100); + } finally { await engine.close(); } +}); + test('pending images publish immediately, bytes share the durable cut, and independent canvas edits overtake upload', async () => { const f = fixture(), gate = deferred(), upload = f.assets.upload; f.assets.upload = async (...args) => { await gate.promise; return upload(...args); }; diff --git a/tests/headless/structure-recovery.test.mjs b/tests/headless/structure-recovery.test.mjs index 7b6d3a4..522a88f 100644 --- a/tests/headless/structure-recovery.test.mjs +++ b/tests/headless/structure-recovery.test.mjs @@ -21,6 +21,57 @@ function fixture(original=base()){ } const near=(a,b)=>assert.ok(Math.abs(a-b)<1e-7,`${a} != ${b}`); +test('pending default size effects recover with preceding creation and movement, preserving one creation undo unit', async () => { + const f = fixture(), engine = await f.open(); + try { + engine.editor.dispatch({ type: 'element.create', element: node('sized') }); + const entryId = engine.getSnapshot().history.undo.at(-1); + engine.editor.dispatch({ type: 'element.geometry', target: target('sized'), patch: { x: 500 } }); + const root = engine.getSnapshot(); + assert.equal(engine.editor.resolveCreationSize({ version: 1, kind: 'creation-size', entryId, target: target('sized'), size: { width: 100, height: 40 } }, root.rootRevision).status, 'applied'); + await engine.settle(); + const session = await engine.prepareRecovery(), plan = session.plan(choices(engine)); + assert.equal(plan.history.undo.length, 2); assert.equal(plan.document.elements.get('sized').geometry.height, 40); + f.reject = false; await session.resolve(plan); await engine.settle(); decodeRecovery(f.image(), registry); + assert.equal(engine.editor.history.undo().status, 'applied'); await engine.settle(); + assert.equal(f.server().elements.get('sized').geometry.x, 100); assert.equal(f.server().elements.get('sized').geometry.height, 40); + assert.equal(engine.editor.history.undo().status, 'applied'); await engine.settle(); assert.equal(f.server().elements.has('sized'), false); + assert.equal(engine.editor.history.redo().status, 'applied'); await engine.settle(); assert.equal(f.server().elements.get('sized').geometry.height, 40); + } finally { await engine.close(); } +}); + +test('discarding a pending size effect retains the uncorrected creation and movement histories', async () => { + const f = fixture(), engine = await f.open(); + try { + engine.editor.dispatch({ type: 'element.create', element: node('sized') }); const entryId = engine.getSnapshot().history.undo.at(-1); + engine.editor.dispatch({ type: 'element.geometry', target: target('sized'), patch: { x: 500 } }); + const root = engine.getSnapshot(), result = engine.editor.resolveCreationSize({ version: 1, kind: 'creation-size', entryId, target: target('sized'), size: { width: 100, height: 40 } }, root.rootRevision); + await engine.settle(); const selected = choices(engine); selected.set(result.operationIds[0], 'discard-local'); + const session = await engine.prepareRecovery(), plan = session.plan(selected); + assert.equal(plan.document.elements.get('sized').geometry.height, 80); assert.equal(plan.history.undo.length, 2); + f.reject = false; await session.resolve(plan); await engine.settle(); + assert.equal(engine.editor.history.undo().status, 'applied'); await engine.settle(); + assert.equal(f.server().elements.get('sized').geometry.height, 80); + assert.equal(engine.editor.history.undo().status, 'applied'); await engine.settle(); assert.equal(f.server().elements.has('sized'), false); + } finally { await engine.close(); } +}); + +test('a confirmed size effect is retained while an earlier independent pending edit is recovered', async () => { + const f = fixture(); f.reject = false; const engine = await f.open(); + try { + engine.editor.dispatch({ type: 'element.create', element: node('sized', 'child') }); await engine.settle(); + const entryId = engine.getSnapshot().history.undo.at(-1); f.reject = true; + engine.editor.dispatch({ type: 'element.data', target: target('a'), patch: { title: 'Pending root title' } }); await engine.settle(); + f.reject = false; const root = engine.getSnapshot(); + const result = engine.editor.resolveCreationSize({ version: 1, kind: 'creation-size', entryId, target: target('sized'), size: { width: 100, height: 40 } }, root.rootRevision); + await engine.settle(); assert.equal(engine.getSnapshot().runtime.ledger.operations.get(result.operationIds[0]).resolution, 'applied'); + const session = await engine.prepareRecovery(), plan = session.plan(choices(engine)); + await session.resolve(plan); await engine.settle(); decodeRecovery(f.image(), registry); + assert.equal(engine.editor.history.undo().status, 'applied'); await engine.settle(); + assert.equal(engine.editor.history.undo().status, 'applied'); await engine.settle(); assert.equal(f.server().elements.has('sized'), false); + } finally { await engine.close(); } +}); + test('pending create and later edits recover with absent baselines, new keys and lifecycle-safe undo/redo',async()=>{ const f=fixture(),engine=await f.open();engine.editor.dispatch({type:'element.create',element:node('new')});await engine.settle();engine.editor.dispatch({type:'element.data',target:target('new'),patch:{title:'edited'}});await engine.settle(); f.remote([{kind:'field',target:target('a'),field:'data.title',before:'a',after:'remote',beforePresent:true,afterPresent:true}]);const session=await engine.prepareRecovery(),plan=session.plan(choices(engine));