diff --git a/src/lib/canvas-runtime/depot-cut.ts b/src/lib/canvas-runtime/depot-cut.ts index 7f9d0b0..c4ead54 100644 --- a/src/lib/canvas-runtime/depot-cut.ts +++ b/src/lib/canvas-runtime/depot-cut.ts @@ -12,11 +12,15 @@ import type {StorageLeader,StoragePort} from './storage-port.ts'; import {resourceRetentionPins} from './resource-retention.ts'; /** Derive one project's shared-byte manifest from the exact Root being persisted. */ -/** 生成已确认上传/跨项目 binding 的共享身份、所需字节与保留清单;无 depot 能力明确拒绝。 */ +/** 从指定不可变 snapshot 派生共享身份/字节写入项与 pins,缺少 assetDepot 端口则拒绝。 + * 会异步读取 depot/本地文件;不提交存储事务、不重算摘要,也不在 await 后自行校验 leader/宿主身份。 + * 返回可变描述数组而非冻结 Root;调用方须把清单与该 snapshot 同次提交,rootRevision 不另存于返回值。 + * snapshot 和各输入缓存需遵循宿主捕获约定,尤其 adoptedFiles 用 revision 防止混入较晚的采纳。 */ export async function prepareDepotCut(input:{snapshot:EngineSnapshot;registry:Registry;storage:StoragePort;leader:StorageLeader;authority:string;files:ReadonlyMap;displays:ReadonlyMap;temporary:ReadonlyMap;adoptedFiles?:ReadonlyMap}):Promise{ const {snapshot,storage,leader,authority}=input,pins=resourceRetentionPins(snapshot,input.registry,input.displays,input.temporary),writes:DepotWrite[]=[]; if(!storage.assetDepot)throw fault('asset_depot_unavailable'); - // 仅 stored+digest 的原图可进入共享身份;未上传文件仍留在项目本地文件仓库。 + // 每个 stored+digest 资源都登记身份(包括已 purge 的项目记录),但只有仍保留的才尝试补字节。 + // sharedAssetRef 校验并捕获元数据,随后要求资产 ID/长度对应本地记录;未上传资源不进入共享写入清单。 for(const resource of snapshot.runtime.resources.values())if(resource.stored&&resource.digest){ const ref=sharedAssetRef({version:1,authority,digest:resource.digest,asset:resource.stored}); if(ref.asset.id!==resource.id||ref.asset.bytes!==resource.byteLength)throw fault('shared_asset_identity_conflict',[resource.id]); @@ -25,6 +29,8 @@ export async function prepareDepotCut(input:{snapshot:EngineSnapshot;registry:Re if(resource.retirement?.purgedAt===undefined&&pins.pins.has(resource.id)){ const cached=await storage.assetDepot.read(leader,authority,resource.id); // The store enforces immutable identity even when bytes are already present. + // 缓存已有字节则只提交身份;否则优先项目内存文件,再读项目存储。 + // 两处均无字节时仍可形成仅身份写入项;是否满足事务要求、摘要与不可变身份由 adapter 检查。 if(!cached?.bytes)bytes=input.files.get(resource.id)??await storage.readFile(leader,resource.id); } writes.push({ref,...(bytes?{bytes}:{})}); @@ -34,9 +40,11 @@ export async function prepareDepotCut(input:{snapshot:EngineSnapshot;registry:Re if(binding.authority!==authority||binding.asset.id!==id||snapshot.runtime.resources.has(id))throw fault('shared_asset_identity_conflict',[id]); const pending=input.adoptedFiles?.get(id),bytes=pending&&pending.rootRevision<=snapshot.rootRevision?pending.bytes:undefined;writes.push({ref:sharedAssetRef(binding),...(bytes?{bytes}:{})}); } + // binding 不以 pins 决定是否写入,只有其暂存 bytes 的采纳 revision 不晚于本 Root 才携带字节。 // A source/run envelope may refer to resources outside this project's upload table. // Its complete resource interpreter must be installed before global deletion is allowed. // 引用、加载与任务解释都完整才允许全局回收;opaque run/未完导入不能假定无共享资源引用。 const complete=pins.complete&&!snapshot.runtime.loading&&![...(snapshot.runtime.imports?.values()??[])].some(job=>!['completed','partial','cancelled'].includes(job.status))&&snapshot.runtime.runs.size===0; + // complete 是共享仓库全局删除所需的覆盖声明,不是字节写入完成回执;真正删除由仓库协议决定。 return{version:1,authority,complete,pins:[...pins.pins].map(([id,owners])=>[id,[...owners]] as const),writes}; } diff --git a/src/lib/canvas-runtime/import-file-retention.ts b/src/lib/canvas-runtime/import-file-retention.ts index ab8854f..1165042 100644 --- a/src/lib/canvas-runtime/import-file-retention.ts +++ b/src/lib/canvas-runtime/import-file-retention.ts @@ -15,12 +15,15 @@ export type PreviousImportFile={readonly fileId:Id;readonly byteLength:number;re export type ImportFile={readonly jobId:Id;readonly current:boolean;readonly file:PreviousImportFile}; /** completed/partial/cancelled是本任务终态;paused/preparing不代表源已经不再需要。 */ export const terminalImport=(status:ImportJob['status'])=>['completed','partial','cancelled'].includes(status); -/** 检查退休协议与安全时间值;只是结构合法,不证明引用扫描已完整。 */ +/** 检查版本及非负安全整数时间,purgedAt 不得早于 unreferencedAt。 + * 不与当前时钟比较,也不证明保留期已满、引用已清空或删除已提交;这些由收集器/存储边界保证。 */ export function validFileRetirement(value:unknown):value is FileRetirement { const marker=value as FileRetirement; return !!marker&&marker.version===1&&Number.isSafeInteger(marker.unreferencedAt)&&marker.unreferencedAt>=0&&(marker.purgedAt===undefined||Number.isSafeInteger(marker.purgedAt)&&marker.purgedAt>=marker.unreferencedAt); } -/** 汇总全部当前/旧源,验证job键与ID一致且同fileId不跨job重复,供原子回收检查使用。 */ +/** 从有 source 的 job 汇总当前/previous 文件,检查 job 键与 ID 及全表 fileId 唯一性。 + * 重复包括同 job 的 current/previous 重复;无 source 的 job 不贡献文件。 + * 只派生索引,不完整校验文件元数据;返回实际可变 Map(类型只读),值中的 file 沿用输入引用。 */ export function importFiles(jobs:ReadonlyMap|undefined):ReadonlyMap { const files=new Map(); for(const [jobId,job]of jobs??[]){ @@ -32,18 +35,22 @@ export function importFiles(jobs:ReadonlyMap|undefined):ReadonlyMa } return files; } -/** 当前源仅终态可退休;旧源可独立退休,且同job文件ID互异、元数据和摘要合法。 */ +/** 当前源带任何退休标记时都要求终态;previous 文件可独立退休,逐项检查 ID/长度/可选摘要/标记。 + * 最后检查 current+previous ID 互异;当前源自身的 fileId/长度/摘要等仍由导入源完整校验负责。 */ export function validateImportFileRetention(source:NativeImportSource,status:ImportJob['status']) { if(source.retirement!==undefined&&(!validFileRetirement(source.retirement)||!terminalImport(status)))throw fault('invalid_import_file_retirement'); if(source.previousFiles!==undefined&&(!Array.isArray(source.previousFiles)||source.previousFiles.some(file=>!file||typeof file.fileId!=='string'||!file.fileId||!Number.isSafeInteger(file.byteLength)||file.byteLength<0||file.sha256!==undefined&&!/^[a-f0-9]{64}$/.test(file.sha256)||file.retirement!==undefined&&!validFileRetirement(file.retirement))))throw fault('invalid_import_file_retirement'); const ids=[source.fileId,...(source.previousFiles??[]).map(file=>file.fileId)];if(new Set(ids).size!==ids.length)throw fault('import_file_identity_conflict'); } -/** 只改退休标记并保留文件身份;相同标记复用原对象,清标记不删除文件记录。 */ +/** 仅替换/移除 retirement;相等时沿用原对象,否则返回冻结记录。 + * 不验证新标记或任务终态,也不删除文件;调用方仍需经过发布/源结构校验。 */ function mark(file:T,retirement:FileRetirement|undefined):T { if(equal(file.retirement,retirement))return file; const {retirement:_,...rest}=file;return freeze(retirement?{...rest,retirement}:rest) as T; } -/** 按fileId批量更新当前/旧源标记,必要时copy-on-write并推进job.generation;不直接删字节。 */ +/** 按 fileId 更新 current/previous 标记,首次修改才复制 imports,每个变化 job 的 generation 只加一次。 + * 未命中任何文件的 marker 忽略,重复同值无变化时复用 runtime;这里只改元数据,不校验退休资格或删字节。 + * 新 generation 溢出则拒绝,不能生成无法可靠恢复的任务版本。 */ export function markImportFiles(runtime:RuntimeState,markers:ReadonlyMap):RuntimeState { let imports=runtime.imports; for(const [id,job]of runtime.imports??[]){ @@ -56,7 +63,10 @@ export function markImportFiles(runtime:RuntimeState,markers:ReadonlyMap{complete:boolean;pins:ReadonlyMap},reserved:ReadonlySet,referencesChanged=true):RuntimeState { const hasSoft=[...(next.imports?.values()??[])].some(job=>job.source&&[job.source,...(job.source.previousFiles??[])].some(file=>file.retirement&&file.retirement.purgedAt===undefined)); if(previous.imports===next.imports&&previous.resources===next.resources&&previous.resourcePins===next.resourcePins&&!reserved.size&&!hasSoft&&!referencesChanged)return next; @@ -69,8 +79,10 @@ export function prepareImportFileRetention(previous:RuntimeState,next:RuntimeSta if(!entry||entry.jobId!==old.jobId||entry.file.byteLength!==old.file.byteLength||old.file.sha256!==undefined&&entry.file.sha256!==old.file.sha256||!old.current&&entry.current)throw fault('import_file_identity_conflict',[id]); if(old.file.retirement?.purgedAt!==undefined&&!equal(old.file.retirement,entry.file.retirement))throw fault('import_source_retired',[id]); } + // 无软标记、reservation 或 purge 记录时不调用 pins;身份校验已完成,不等于执行了完整引用扫描。 if(!hasSoft&&!reserved.size&&![...after.values()].some(entry=>entry.file.retirement?.purgedAt!==undefined))return next; // 覆盖未知或重新引用都清软标记,下一次无引用须重新计保留期,不沿用旧时钟直接删除。 + // 新摘要可在旧值缺失时补齐,但已有摘要不能替换;清标记通过 markImportFiles 同步推进 job generation。 const coverage=pins(),clear=new Map(); for(const [id,entry]of after){ if(entry.file.retirement?.purgedAt!==undefined){if(coverage.pins.has(id))throw fault('import_source_retired',[id]);continue;} diff --git a/src/lib/canvas-runtime/orphan-files.ts b/src/lib/canvas-runtime/orphan-files.ts index 0e0ef05..17199eb 100644 --- a/src/lib/canvas-runtime/orphan-files.ts +++ b/src/lib/canvas-runtime/orphan-files.ts @@ -10,12 +10,15 @@ import {resourceRetentionPins} from './resource-retention.ts'; /** 盘点确认的文件身份/摘要与退休状态,不是上传资源元数据,也不是 DOM 图片缓存。 */ export type OrphanFile={readonly version:1;readonly id:Id;readonly byteLength:number;readonly mimeType:string;readonly digest:string;readonly retirement?:FileRetirement}; -/** 检查 ID、字节数、MIME、SHA256 和退休解释器;失败不默默忽略损坏文件记录。 */ +/** 检查记录版本、键与 ID 相同、安全字节数、MIME 字符串、64 位小写十六进制摘要和退休标记。 + * 不读取 Blob 或重算 SHA;MIME 只要求字符串,不在此证明真实内容类型或元数据来自可信盘点。 */ export function validateOrphanFile(value:unknown,id:Id):asserts value is OrphanFile { const file=value as OrphanFile; if(!file||file.version!==1||file.id!==id||!id||!Number.isSafeInteger(file.byteLength)||file.byteLength<0||typeof file.mimeType!=='string'||typeof file.digest!=='string'||!/^[a-f0-9]{64}$/.test(file.digest)||file.retirement!==undefined&&!validFileRetirement(file.retirement))throw fault('invalid_orphan_file'); } -/** 结合 pin 与所有未解释任务/审计证据给出回收阻碍;blockers 非空即 complete=false。 */ +/** 在 resourceRetentionPins 上叠加孤立文件专用阻碍,返回已知 pins 与 blockers。 + * 任意 run/recovery、非终态导入、copy-admission 或未解决的其它动作都阻止孤儿回收。 + * complete 只表示本函数列出的引用/生产者条件通过;不代表盘点到末页、文件散列完成或删除已持久化。 */ export function orphanCoverage(snapshot:EngineSnapshot,registry:Registry,displays:ReadonlyMap,temporary:ReadonlyMap) { const pins=resourceRetentionPins(snapshot,registry,displays,temporary),blockers:string[]=[]; if(!pins.complete)blockers.push('opaque_references'); @@ -27,7 +30,10 @@ export function orphanCoverage(snapshot:EngineSnapshot,registry:Registry,display return Object.freeze({...pins,complete:blockers.length===0,blockers:Object.freeze(blockers)}); } /** Registered orphan identities survive repinning. Purged identities cannot be forgotten or reassigned. */ -/** 在发布前维护孤儿事实与回收 reservations:身份不得与资源/导入源冲突,已 purge 不得改写。 */ +/** 在发布前维护孤儿记录:禁止与资源/源文件重名,旧记录不得消失,已 purge 记录必须完整相等。 + * 未 purge 记录允许盘点方更新摘要/长度等事实,本函数不读取字节来证明这种更新。 + * referencesChanged 和 Map 引用用于快路判断;调用方须准确报告文档/历史/账本等引用变化。 + * reservation 仅检查 next.orphanFiles 中对应 ID;跨文件类别的统一阻拦由外围发布钩子负责。 */ export function prepareOrphanFiles(previous:RuntimeState,next:RuntimeState,coverage:()=>{complete:boolean;pins:ReadonlyMap},reserved:ReadonlySet,referencesChanged:boolean):RuntimeState { if(!previous.orphanFiles?.size&&!next.orphanFiles?.size)return next; const hasSoft=[...(next.orphanFiles?.values()??[])].some(file=>file.retirement&&file.retirement.purgedAt===undefined); @@ -37,6 +43,7 @@ export function prepareOrphanFiles(previous:RuntimeState,next:RuntimeState,cover for(const [id,file]of next.orphanFiles??[]){validateOrphanFile(file,id);if(next.resources.has(id)||sources.has(id))throw fault('local_file_identity_conflict',[id]);} for(const [id,file]of previous.orphanFiles??[]){const after=next.orphanFiles?.get(id);if(!after||file.retirement?.purgedAt!==undefined&&!equal(file,after))throw fault('local_file_retired',[id]);} // 覆盖不足/重 pin 取消软退休;永久删除后重 pin 明确拒绝,不能让模型引用失去字节的 ID。 + // 需要慢路才调用覆盖回调;即使引用覆盖未知,已 purge 项也仅在有明确 pin 时拒绝。 const pins=coverage(),clear:Id[]=[]; for(const [id,file]of next.orphanFiles??[]){ if(reserved.has(id)&&(!pins.complete||pins.pins.has(id)))throw fault('resource_collection_in_progress',[id]); @@ -48,7 +55,9 @@ export function prepareOrphanFiles(previous:RuntimeState,next:RuntimeState,cover } /** Read-only I/O may finish late; cancellation detaches its result without publishing it. */ -/** 取消只让调用者停止采纳读取结果,不保证底层只读 I/O 被终止;迟到成功/失败均已接住。 */ +/** 包装已启动的 Promise:取消以 file_inventory_aborted 拒绝,不沿用 signal.reason。 + * 不终止底层只读 I/O;仍安装成功/失败处理器接住迟到结果,并在完成/取消时移除监听。 + * 不持有 Root,也不校验结果是否过期;盘点调用方还需在 await 后核对当前快照身份。 */ export function abortableFileRead(task:Promise,signal:AbortSignal):Promise{ return new Promise((resolve,reject)=>{const abort=()=>{signal.removeEventListener('abort',abort);reject(fault('file_inventory_aborted'));};signal.addEventListener('abort',abort,{once:true});if(signal.aborted)abort();task.then(value=>{signal.removeEventListener('abort',abort);if(!signal.aborted)resolve(value);},error=>{signal.removeEventListener('abort',abort);if(!signal.aborted)reject(error);});}); } diff --git a/src/lib/canvas-runtime/resource-retention.ts b/src/lib/canvas-runtime/resource-retention.ts index 4f63656..bd7f810 100644 --- a/src/lib/canvas-runtime/resource-retention.ts +++ b/src/lib/canvas-runtime/resource-retention.ts @@ -10,7 +10,10 @@ import type {EngineSnapshot,RuntimeState} from './contracts.ts'; import {createResourcePinScan} from './resources.ts'; /** Complete local-byte protection for one project scope; no DOM/viewport filtering. */ -/** 同时扫描 working 与 acknowledged;ACK 只额外取元素引用,避免把历史重复当另一套业务事实。 */ +/** 派生 resource ID → 保留理由 Set,同时扫描 working 与 acknowledged。 + * 复用同一 history/ledger 解释器,但 ACK 只额外合入 element: 理由;两次扫描完整性仍都参与结果。 + * displays 是资源 ID→租用数,temporary/resourcePins 是 owner→资源 ID 列表,不是同方向的表。 + * 返回捕获的只读表/集合;不写 runtime、不增减持久引用计数,也不盘点文件是否实际存在。 */ export function resourceRetentionPins(snapshot:EngineSnapshot,registry:Registry,displays:ReadonlyMap=new Map(),temporary:ReadonlyMap=new Map()) { const {runtime}=snapshot; const scan=createResourcePinScan(snapshot.history,runtime.ledger,registry); @@ -26,20 +29,26 @@ export function resourceRetentionPins(snapshot:EngineSnapshot,registry:Registry, // 未结束/未封存导入和 opaque run 保留已知资源仓库,不假设尚未解释的 envelope 没有引用。 for(const job of runtime.imports?.values()??[])if(!['completed','partial','cancelled'].includes(job.status)||importNeedsSeal(job)){if(job.source)add(job.source.fileId,`import-source:${job.id}`);for(const [id,resource]of runtime.resources)if(resource.retirement?.purgedAt===undefined)add(id,`import:${job.id}`);} for(const run of runtime.runs.values())for(const [id,resource]of runtime.resources)if(resource.retirement?.purgedAt===undefined)add(id,`run:${run.id}`); + // imports 只在非终态或仍需 seal 时 pin,runs 则不按 status 过滤;它们保留已知且未 purge 的资源。 + // 这种保守 pin 不会自行把 complete 改为 false,也无法列出未知仓库 ID;全局/孤儿回收还需更强覆盖门槛。 // 等待解码或没有解释器的状态也 pin;显示 URL 租用数与临时任务另外合并。 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'); + // sizing 未知版本也降低覆盖完整性,即使本轮没有形成额外 pin;complete 不是文件可读/可删证明。 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. */ -/** 所有 Root 发布路径共同检查回收:reserved 正在原子删除时,不允许新 pin 或不完整覆盖闯入。 */ +/** 由 engine 的 Root 发布钩子调用:检查全局 reserved 集合,清除失效的软退休标记。 + * reserved 非空时,任一保留 ID 相交或扫描不完整都会拒绝发布;它本身不获取/释放 reservation。 + * 只按 next 检查保留状态,不比较 previous 来证明退休时钟单调,也不执行磁盘删除或等待保留期。 */ export function prepareResourceRetention(next:EngineSnapshot,registry:Registry,displays:ReadonlyMap,reserved:ReadonlySet,temporary:ReadonlyMap=new Map()):RuntimeState { // 没有待回收工作/软标记时快速复用 runtime,避免每次普通编辑重扫整个资源集合。 if(!reserved.size&&![...next.runtime.resources.values()].some(resource=>resource.retirement&&(resource.retirement.purgedAt===undefined||!resource.stored)))return next.runtime; const pins=resourceRetentionPins(next,registry,displays,temporary); if(reserved.size&&(!pins.complete||[...reserved].some(id=>pins.pins.has(id))))throw fault('resource_collection_in_progress',[...reserved]); + // 没有标记需清除时沿用 runtime;首次真正修改才复制 resources 表,其余资源记录继续共享。 let resources=next.runtime.resources; for(const [id,resource]of next.runtime.resources){ if(!resource.retirement)continue;