85 lines
7.8 KiB
TypeScript
85 lines
7.8 KiB
TypeScript
/**
|
||
* 应用级运行宿主,生命周期跨越页面导航;同一账号下复用 workspace runtime 和共享读取器。
|
||
* 账号/退出登录变化先递增 authEpoch 使旧回调失效,再异步关闭退休 runtime;关闭失败仍保留退休实例供重试。
|
||
* registry 是共享定义,工作文档归 project engine,读取预览也不能成为第二份可写文档。
|
||
*/
|
||
import {createModelPreview,type LiveModelPreview} from './model-preview.ts';
|
||
import type { Id, Registry } from '../canvas-core/types.ts';
|
||
import { fault, readMap } from '../canvas-core/values.ts';
|
||
import type { WorkspaceRuntime } from './workspace.ts';
|
||
import { createScopedLoader, type LoadPort, type ScopedLoader } from './loader.ts';
|
||
import type { CanonicalReadUpdate } from './contracts.ts';
|
||
|
||
/** Host lifetime matches the signed-in application, independently of navigation. */
|
||
/** 宿主注入 workspace/loader 工厂;current 闭包绑定精确账号与 epoch,作为异步结果准入围栏。 */
|
||
export function createRuntimeHost(options: { registry: Registry; workspace(accountId: Id, workspaceId: Id, authEpoch: number, current: () => boolean): WorkspaceRuntime; modelLoader?(accountId:Id,workspaceId:Id,authEpoch:number,current:()=>boolean):LoadPort<CanonicalReadUpdate>; onError?: (error: unknown) => void; onObserverError?: (error: unknown) => void }) {
|
||
let accountId: Id | null = null, authEpoch = 0, disposed = false;
|
||
// active 表只含当前身份,retired 持有尚未成功关闭的旧 runtime,不能因换账号就丢掉待保存内容。
|
||
const workspaces = new Map<Id, WorkspaceRuntime>(), retired = new Set<WorkspaceRuntime>();
|
||
const readers = new Map<Id, ScopedLoader<CanonicalReadUpdate>>();
|
||
/** 并行尝试关闭退休实例,成功才移出集合;汇总失败而不提前释放失败实例。 */
|
||
const closeRetired = async () => {
|
||
const results = await Promise.allSettled([...retired].map(async runtime => { await runtime.close(); retired.delete(runtime); }));
|
||
const errors = results.flatMap(result => result.status === 'rejected' ? [result.reason] : []);
|
||
if (errors.length) throw new AggregateError(errors, 'host_close_failed');
|
||
};
|
||
/** 同步撤销当前身份:先换 epoch,再关闭读取句柄/移走 workspace;实际存储收尾另行等待。 */
|
||
function retire() { authEpoch++; accountId = null; for (const reader of readers.values()) reader.close();readers.clear();for (const runtime of workspaces.values()) retired.add(runtime); workspaces.clear(); }
|
||
/** 账号变化才退休旧作用域;后台关闭错误走 onError,报告失败不能改变新账号的身份事实。 */
|
||
function activate(account: Id) {
|
||
if (disposed) throw fault('host_disposed');
|
||
if (accountId !== account) {
|
||
retire(); accountId = account;
|
||
void closeRetired().catch(error => { try { options.onError?.(error); } catch { /* Reporting cannot change scope. */ } });
|
||
}
|
||
}
|
||
return Object.freeze({
|
||
/** 只读共享定义,不含各 workspace 的可变运行状态。 */
|
||
registry: options.registry,
|
||
/** 返回绑定当前 epoch 的校验函数;同账号退出再登录也因 epoch 变化而使旧函数失效。 */
|
||
bindAccount(account: Id) { activate(account); const epoch = authEpoch; return () => !disposed && accountId === account && authEpoch === epoch; },
|
||
/** 捕获当前身份摘要,供导航/宿主检查,不携带认证凭证。 */
|
||
identity: () => Object.freeze({ accountId, authEpoch }),
|
||
/** 返回当前 workspace 表的安全只读快照,外部不能手动增删运行实例。 */
|
||
getWorkspaces: () => readMap(workspaces),
|
||
/** 通过共享 scoped loader 读 canonical 源;增量可产生只读预览,terminal 完整后才返回最终模型。 */
|
||
async loadModel(account:Id,workspace:Id,canvas:Id,settings:{signal?:AbortSignal;navigationGeneration?:number;onPreview?:(preview:LiveModelPreview)=>void}={}) {
|
||
activate(account);
|
||
const readEpoch=authEpoch;
|
||
if(!options.modelLoader)throw fault('model_loader_unavailable');
|
||
let reader=readers.get(workspace);
|
||
// 同 workspace 复用并发读取;maxCacheEntries=0 不把一次已完成读取当成长期授权缓存。
|
||
if(!reader){const epoch=authEpoch,current=()=>!disposed&&accountId===account&&authEpoch===epoch;reader=createScopedLoader({load:options.modelLoader(account,workspace,epoch,current),current:()=>current(),maxCacheEntries:0});readers.set(workspace,reader);}
|
||
// 每个订阅者自己解算预览/seen 游标,观察者异常只终止该预览,不更改共享读取事实。
|
||
const preview=settings.onPreview?createModelPreview(options.registry):undefined;let seen=0,previewFailed=false;let declaredCanvasIds:readonly Id[]=[];
|
||
/** 先检查身份/取消,再消费尚未处理的块;头部范围证据决定 loading 版本与可用覆盖范围。 */
|
||
const onUpdate=(state:import('./loader.ts').LoadSnapshot<CanonicalReadUpdate>)=>{
|
||
if(!preview||previewFailed||disposed||accountId!==account||authEpoch!==readEpoch||settings.signal?.aborted)return;
|
||
try{for(;seen<state.chunks.length;seen++){const chunk=state.chunks[seen],value=preview.update(chunk.value);if(chunk.value.status==='receiving'&&value){if(chunk.sequence===0)declaredCanvasIds=Object.freeze(chunk.value.canvases.map(canvas=>canvas.id));settings.onPreview?.(Object.freeze({...value,versions:readMap(new Map(Object.entries(chunk.serverVersions))),loading:Object.freeze({...(value.canvasElementCounts?value.elementOwners?{version:6 as const,canvasElementCounts:value.canvasElementCounts,elementOwners:value.elementOwners,sourceCanvases:value.sourceCanvases!}:{version:3 as const,canvasElementCounts:value.canvasElementCounts}:{version:1 as const}),sourceToken:chunk.snapshotToken,declaredCanvasIds,receivedElements:value.receivedElements,totalElements:value.totalElements})}));}}}
|
||
catch(error){previewFailed=true;try{options.onObserverError?.(error);}catch{}}
|
||
};
|
||
// key 纳入账号、workspace、格式、查询与 authEpoch;view 导航代次不替代授权代次。
|
||
const handle=reader.acquire({accountId:account,workspaceId:workspace,projectId:null,canvasId:canvas,viewKey:'canonical-project',format:'canonical-model-v1',queryFingerprint:'full-project',source:'foundry',authEpoch,navigationGeneration:settings.navigationGeneration??0},{signal:settings.signal,onUpdate});
|
||
// ready 后再查身份和 terminal/complete;finally 始终释放本订阅,不能泄漏 reader 引用。
|
||
try{const snapshot=await handle.ready;if(disposed||accountId!==account||authEpoch!==readEpoch)throw fault('identity_changed');const last=snapshot.chunks.at(-1);if(!last||!last.complete||last.value.status==='receiving')throw fault('incomplete_load');return last.value.status==='ready'?Object.freeze({...last.value,sourceToken:last.snapshotToken}):last.value;}finally{handle.release();preview?.close();}
|
||
},
|
||
/** 按当前账号激活并懒创建 workspace;导航到同工作区不会重新创建资源池或项目。 */
|
||
workspace(account: Id, workspace: Id) {
|
||
activate(account);
|
||
let runtime = workspaces.get(workspace);
|
||
if (!runtime) {
|
||
const epoch = authEpoch;
|
||
runtime = options.workspace(account, workspace, epoch, () => !disposed && accountId === account && authEpoch === epoch);
|
||
workspaces.set(workspace, runtime);
|
||
}
|
||
return runtime;
|
||
},
|
||
/** 退出身份但保留 host 可再次绑定;返回旧 runtime 收尾 Promise,让宿主处理未保存错误。 */
|
||
retireIdentity() { retire(); return closeRetired(); },
|
||
/** 永久关闭 host 并退休全部作用域;失败仍可再次调用 close 重试退休集合。 */
|
||
close() { disposed = true; retire(); return closeRetired(); },
|
||
});
|
||
}
|
||
/** 公共宿主 API 由工厂返回值推导,避免接口声明与实现两份维护。 */
|
||
export type RuntimeHost = ReturnType<typeof createRuntimeHost>;
|