Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
ac5c3b0
fix(desktop): keep cached Library sessions from mkdir empty custom roots
PraiseZhu Sep 19, 2026
dd7f150
fix(desktop): do not mint unavailable Library identity from default root
PraiseZhu Sep 19, 2026
af12a81
feat(desktop): add owner-scoped Library staging adapter
PraiseZhu Sep 19, 2026
5c2a717
fix(desktop): recover staging commit from retained manifest and intent
PraiseZhu Sep 19, 2026
f255e0d
fix(desktop): fail closed when a custom Library parent vanishes
PraiseZhu Sep 20, 2026
f43ab2a
fix(desktop): reject replaced custom parents and revoke extraDir on d…
PraiseZhu Sep 20, 2026
e5df252
fix(desktop): do not recursive-mkdir custom Library parent after inspect
PraiseZhu Sep 20, 2026
08627b3
fix(desktop): bind custom Library mkdir to held parent identity
PraiseZhu Sep 20, 2026
35ced66
fix(desktop): create custom Library trees via held parent fd
PraiseZhu Sep 20, 2026
5e19e9e
fix(desktop): skip custom first-open path usage persist and sweep
PraiseZhu Sep 20, 2026
0f372af
fix(desktop): existing custom open, session teardown, usage ledger
PraiseZhu Sep 20, 2026
0cf02ab
fix(desktop): Windows existing custom open via FILE_OPEN ReadFile
PraiseZhu Sep 20, 2026
11cb431
fix(desktop): parse existing-open helper JSON as complete values
PraiseZhu Sep 20, 2026
8cfa9d6
fix(desktop): do not delete custom Library uuid tmp/stream on open
PraiseZhu Sep 20, 2026
cb55ea8
feat(desktop): Windows custom Library first-create via NtCreateFile
PraiseZhu Sep 20, 2026
132dc98
test(maker-core): time durable Subagent system-deny waitFor
PraiseZhu Sep 20, 2026
94f36c0
fix(desktop): distinguish Windows meta exists from reparse collision
PraiseZhu Sep 20, 2026
c449c9c
Merge origin/main into feat/mivo-library-root-recovery-20260920
PraiseZhu Sep 20, 2026
d7c68cd
Merge origin/main into feat/mivo-library-root-recovery-20260920
PraiseZhu Sep 20, 2026
3c35b22
Merge d7c68cd9 into feat/mivo-library-staging-20260920
PraiseZhu Sep 20, 2026
58efb97
fix(desktop): sweep idle staging uploads and count concurrent disk re…
PraiseZhu Sep 20, 2026
6445e75
fix(desktop): count idle tmp residue and unwritten disk reserve
PraiseZhu Sep 20, 2026
7faa6c9
fix(desktop): recover staging durables across owner generation
PraiseZhu Sep 20, 2026
85c8d73
fix(desktop): fail closed on custom ghost loss and staging.release races
PraiseZhu Sep 21, 2026
22d9b70
fix(desktop): persist custom libraryReady so vanished ghost dirs stay…
PraiseZhu Sep 21, 2026
6576e4d
fix(desktop): serialize Library mutates with staging.release
PraiseZhu Sep 21, 2026
92acffd
Merge remote-tracking branch 'origin/main' into pr-4730-resolve-main
PraiseZhu Sep 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -1704,7 +1704,7 @@ describe('FORGE_GUIDE', () => {

it('documents library capabilities as a sessionless support list with stable failure reasons', () => {
expect(FORGE_GUIDE).toContain("op: 'capabilities'");
expect(FORGE_GUIDE).toContain("operations:['clipboardWrite','saveAs']");
expect(FORGE_GUIDE).toContain("operations:['clipboardWrite','saveAs','staging.begin'");
expect(FORGE_GUIDE).toContain('不等于此刻有窗口 / 已授权 / 库可用');
expect(FORGE_GUIDE).toContain('全部字符串');
expect(FORGE_GUIDE).toContain('数组内混入');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,10 @@ describe('LibraryBindingStore', () => {
expect(set.ok).toBe(true);
if (!set.ok) return;
expect(set.record.generation).toBe(1);
expect(set.record.libraryReady).toBe(false);
await store.markLibraryReady(GHOST_ID);
expect((await store.getBinding(GHOST_ID))?.libraryReady).toBe(true);
expect((await store.getBinding(GHOST_ID))?.generation).toBe(1);

const after = await store.resolveLibraryRoot(GHOST_ID);
expect(after.kind).toBe('custom');
Expand Down
480 changes: 478 additions & 2 deletions apps/desktop/src/main/cindy-brain/__tests__/librarySlot.test.ts

Large diffs are not rendered by default.

1,149 changes: 1,149 additions & 0 deletions apps/desktop/src/main/cindy-brain/__tests__/libraryStaging.test.ts

Large diffs are not rendered by default.

104 changes: 104 additions & 0 deletions apps/desktop/src/main/cindy-brain/__tests__/libraryVault.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,34 @@ describe('LibraryVault', () => {
expect(stat.isDirectory()).toBe(true);
});

it('custom 已建过(allowCustomInit=false)且新 vault: ghost 子目录 MISSING 不得空库重建', async () => {
const parent = path.join(tmpRoot, 'picked-no-init');
const custom = path.join(parent, 'mivo-canvas');
await fs.promises.mkdir(parent, { recursive: true });
const vault = makeVault({
rootDir: () => custom,
locationKind: 'custom',
allowCustomInit: false,
});
const missing = await vault.open();
expect(missing).toMatchObject({ ok: true, state: 'unavailable', reason: 'disk-missing' });
expect(fs.existsSync(path.join(custom, '.cindy-library', 'meta.json'))).toBe(false);
});

it('custom 已 open 后 ghost 子目录消失: 再 open 报 disk-missing 且不重建空库', async () => {
const parent = path.join(tmpRoot, 'picked-ghost-gone');
const custom = path.join(parent, 'mivo-canvas');
await fs.promises.mkdir(custom, { recursive: true });
await fs.promises.writeFile(path.join(custom, 'keep.txt'), 'keep-me');
const vault = makeVault({ rootDir: () => custom, locationKind: 'custom' });
expect(await vault.open()).toMatchObject({ ok: true, state: 'ready' });
await fs.promises.rename(custom, `${custom}.parked`);
const missing = await vault.open();
expect(missing).toMatchObject({ ok: true, state: 'unavailable', reason: 'disk-missing' });
expect(fs.existsSync(custom)).toBe(false);
expect(fs.existsSync(path.join(parent, 'mivo-canvas', '.cindy-library', 'meta.json'))).toBe(false);
expect(fs.existsSync(path.join(`${custom}.parked`, 'keep.txt'))).toBe(true);
});
it('custom 用户父目录消失: open 报 disk-missing 且不重建空库; keep 仍在 rename 走的目录', async () => {
const parent = path.join(tmpRoot, 'picked');
const custom = path.join(parent, 'mivo-canvas');
Expand Down Expand Up @@ -565,6 +593,73 @@ describe('LibraryVault', () => {
// staging 清空。
const tmpEntries = await fs.promises.readdir(path.join(libraryRoot, '.cindy-library', 'tmp'));
expect(tmpEntries).toEqual([]);
const dirSync = await vault.fsyncDir('assets');
expect(dirSync.ok).toBe(true);
if (dirSync.ok) {
if (process.platform === 'win32') expect(dirSync.fsynced).toBe(false);
else expect(dirSync.fsynced).toBe(true);
}
const residue = await vault.tmpResidueBytes();
expect(residue).toEqual({ ok: true, bytes: 0 });
});

it('fsyncCreatedAncestors 同步新建根的父目录项,只 fsync 根不等于根 entry 已耐久', async () => {
const nestedRoot = path.join(tmpRoot, 'owners', 'a', 'library-staging', 'test-ghost');
const vault = makeVault({ rootDir: () => nestedRoot });
const opened = await vault.open();
expect(opened.ok).toBe(true);
const parent = path.dirname(nestedRoot);
expect(fs.existsSync(parent)).toBe(true);
const synced = new Set<string>();
const origOpen = fs.promises.open.bind(fs.promises);
const spy = vi.spyOn(fs.promises, 'open').mockImplementation(async (file, flags, mode) => {
const handle = await origOpen(file, flags, mode);
if (typeof file === 'string' && flags === 'r') {
const origSync = handle.sync.bind(handle);
handle.sync = async () => {
synced.add(path.resolve(file));
return origSync();
};
}
return handle;
});
try {
const ok = await vault.fsyncCreatedAncestors();
expect(ok.ok).toBe(true);
if (process.platform === 'win32') {
if (ok.ok) expect(ok.fsynced).toBe(false);
} else {
if (ok.ok) expect(ok.fsynced).toBe(true);
expect(synced.has(path.resolve(parent))).toBe(true);
}
} finally {
spy.mockRestore();
}
});

it('fsyncCreatedAncestors 父目录 fsync 失败则 INTERNAL,不得当耐久', async () => {
const nestedRoot = path.join(tmpRoot, 'owners', 'b', 'library-staging', 'test-ghost');
const vault = makeVault({ rootDir: () => nestedRoot });
await vault.open();
const parent = path.resolve(path.dirname(nestedRoot));
const origOpen = fs.promises.open.bind(fs.promises);
const spy = vi.spyOn(fs.promises, 'open').mockImplementation(async (file, flags, mode) => {
if (typeof file === 'string' && path.resolve(file) === parent && flags === 'r') {
throw Object.assign(new Error('EIO'), { code: 'EIO' });
}
return origOpen(file, flags, mode);
});
try {
const failed = await vault.fsyncCreatedAncestors();
if (process.platform === 'win32') {
expect(failed).toEqual({ ok: true, fsynced: false });
} else {
expect(failed.ok).toBe(false);
if (!failed.ok) expect(failed.errorCode).toBe('INTERNAL');
}
} finally {
spy.mockRestore();
}
});

it('sha256 声明不符 → STREAM_INVALID 且不留目标文件', async () => {
Expand Down Expand Up @@ -637,6 +732,8 @@ describe('LibraryVault', () => {

const flat = await vault.list({ path: 'canvases/c1' });
if (flat.ok) expect(flat.entries.map((e) => e.path)).toEqual(['canvases/c1/state.json']);
const compatible = await vault.list({ recursive: false });
expect(compatible.ok).toBe(true);
});
});

Expand Down Expand Up @@ -771,6 +868,11 @@ describe('LibraryVault', () => {
expect(r.ok).toBe(false);
const d = await vault.delete({ path: 'escape-door/anything' });
expect(d.ok).toBe(false);
const compatible = await vault.list({ recursive: false });
expect(compatible.ok).toBe(true);
const strict = await vault.list({ recursive: false, strict: true });
expect(strict.ok).toBe(false);
if (!strict.ok) expect(strict.errorCode).toBe('LIBRARY_UNAVAILABLE');
});
});

Expand All @@ -789,6 +891,8 @@ describe('LibraryVault', () => {
expect(r.sha256).toBe(sha256Of(body));
expect(r.bytes).toBe(Buffer.byteLength(body));
}
const hashed = await vault.hashFile(rel);
expect(hashed).toEqual({ ok: true, path: rel, bytes: Buffer.byteLength(body), sha256: sha256Of(body) });
});

it('打开后目标 identity 变化 → INTERNAL 且不得返回字节', async () => {
Expand Down
18 changes: 17 additions & 1 deletion apps/desktop/src/main/cindy-brain/forge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3586,7 +3586,10 @@ const st = await cindy.library({ op: 'status' });
// 只读能力查询:资格审与 op 合法性之后、会话创建之前返回;不打开库、不弹窗
const caps = await cindy.library({ op: 'capabilities' });
// caps = { ok:true, op:'capabilities',
// capabilities:{ version:1, operations:['clipboardWrite','saveAs'] } }
// capabilities:{ version:1,
// operations:['clipboardWrite','saveAs','staging.begin',...],
// staging:{ version:1, maxTaskBytes, maxTotalBytes,
// maxConcurrentWrites, maxChunkBytes, reserveBytes } } }
// operations 只表示宿主实现了这些 op,不等于此刻有窗口 / 已授权 / 库可用

// 文件操作(全 Family;写入原子化,大文件走分块流)
Expand Down Expand Up @@ -3633,6 +3636,18 @@ await cindy.library({ op: 'db.migrate', dbPath: 'canvas.sqlite', targetVersion:
{ toVersion: 2, sql: ['CREATE TABLE v2 (a TEXT)'] }] });
await cindy.library({ op: 'db.backup', dbPath: 'library.sqlite' }); // 宿主命名空间
await cindy.library({ op: 'db.check', dbPath: 'library.sqlite' }); // quick_check

// 后台暂存(staging.*):独立于可迁移 Library 根,不是第二媒体库,不返回 imageRef。
const up = await cindy.library({
op: 'staging.begin', taskId, sourceRevision, totalBytes, sha256, mime, recovery,
});
await cindy.library({ op: 'staging.chunk', stagingId: up.stagingId, seq: 1, content: b64, encoding: 'base64' });
const receipt = await cindy.library({ op: 'staging.commit', stagingId: up.stagingId });
// receipt.durable === true 才可当跨退出原件。release 带当前 Library ACK 的 bytes(不是 begin 的 totalBytes),且画布已保存后才调用。
await cindy.library({
op: 'staging.release', stagingId: up.stagingId, path, sha256, bytes,
libraryIdentity, libraryGeneration,
});
\`\`\`

关键语义(全部由宿主强制):
Expand All @@ -3649,6 +3664,7 @@ await cindy.library({ op: 'db.check', dbPath: 'library.sqlite' }); // quick_ch
open/status 失败),非法请求=\`INVALID_REQUEST\`(含非法/越界 dbPath 与未知 op),
取消=\`CANCELLED\`;成功 open/status 的 \`state:'unavailable'\` 仍用结果体 reason
(如 disk-missing),不是失败 reason 枚举;查询/传输层本地分类 \`TIMEOUT\` / \`TRANSPORT_ERROR\`;
- **staging.***:后台暂存,不是媒体库、不弹新 UI。\`staging.read\` 未传 length 默认 16MiB 分片;负数/NaN offset/length 是 \`PATH_INVALID\`。release 必须带当前 Library ACK 的 \`bytes\`(不是 begin 的 \`totalBytes\`),且只在画布保存后调用。父目录 fsync 失败不得 \`durable:true\`。
- **capabilities**:先查 \`{ op:'capabilities' }\`。仅 \`version===1\` 且
\`operations\` 为**全部字符串**的数组才有效;额外字段忽略,未知 operation 忽略,
已知项保留;有效 v1 清单缺少某项才是 unsupported。缺字段、错类型(含数组内混入
Expand Down
72 changes: 46 additions & 26 deletions apps/desktop/src/main/cindy-brain/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -327,6 +327,7 @@ import { GhostFsSlot } from './fsSlot.js';
import { GhostLibrarySlot } from './librarySlot.js';
import { LibraryBindingStore, validateLibraryCandidateLocation } from './libraryBinding.js';
import { LibraryVault, statfsFreeBytes, DEFAULT_LIBRARY_LIMITS } from './libraryVault.js';
import { LibraryStagingStore } from './libraryStaging.js';
import { LibrarySqlService, defaultLibraryDbWorkerPath } from './librarySqlService.js';
import { trashGhostLibrary } from './libraryTrash.js';
import { migrateGhostLibrary } from './libraryMigrate.js';
Expand Down Expand Up @@ -1031,8 +1032,9 @@ export async function interruptGhostCallsForAccountBoundary(): Promise<void> {
getForgeOidcInstallConfirmBridge()?.cancelAll();
runtimeSingleton?.destroyAll();
resetNodeRuntimeBrokerForAccountBoundary();
// Library 会话一并作废:关 db worker + 作废 handle——在途写入已在串行链上
// 归属原 owner 完成或随 vault.invalidate 作废,新 owner 解析到全新根。
// Drain in-flight staging.release (tombstone/fsync) before tearing Library
// sessions. Owner mutation leases stay held until each call unwinds; waiting
// for idle first would let marker-window teardown race the lease.
await getGhostLibrarySlot().disposeAll();
if (libraryExtraDirSync) {
await libraryExtraDirSync(null).catch((error) => {
Expand Down Expand Up @@ -5403,6 +5405,10 @@ export function getGhostLibrarySlot(): GhostLibrarySlot {
getGhost: findAvailableGhost,
bindingStore,
getDefaultRoot: (ghostId) => ownerScopedUserDataPath('libraries', ghostId),
getStagingRoot: (ghostId) => ownerScopedUserDataPath('library-staging', ghostId),
createStagingStore: (deps) => new LibraryStagingStore(deps),
captureMutationOwner: () => captureGhostMutationOwner(),
beginMutation: (expected) => beginGhostMutation(expected as ActiveAppSession | undefined),
captureOwnerScope: () => activeOwnerScopeKey(),
createVault: (deps) => new LibraryVault(deps),
createSqlService: (deps) => new LibrarySqlService(deps),
Expand Down Expand Up @@ -5620,30 +5626,36 @@ export async function getGhostLibraryOverview(ghostId: string): Promise<GhostLib
*/
export async function deleteGhostLibraryForActiveOwner(ghostId: string): Promise<{ ok: boolean; message?: string }> {
if (!isValidGhostId(ghostId)) return { ok: false, message: '非法插件 id' };
await getGhostLibrarySlot().disposeGhost(ghostId);
const result = await trashGhostLibrary(ghostId, {
// 默认根与自定义根都经 binding store 的解析口径(漂移时返回 null → 上层
// 引导恢复位置,不误删)。
resolveLibraryRoot: async (id) => {
const resolution = await getGhostLibraryBindingStore().resolveLibraryRoot(id);
return resolution.kind === 'custom' ? resolution.root : ownerScopedUserDataPath('libraries', id);
},
trashRoot: () => ownerScopedUserDataPath('libraries-trash'),
removeBinding: async (id) => {
await getGhostLibraryBindingStore().removeBinding(id);
},
log,
});
if (result.ok) {
await refreshMivoLibraryExtraDirGrant().catch((error) => {
log.warn('library extraDirs delete sync failed', {
ghostId,
error: error instanceof Error ? error.message : String(error),
});
const slot = getGhostLibrarySlot();
slot.setRelocating(ghostId, true);
try {
await slot.disposeGhost(ghostId);
const result = await trashGhostLibrary(ghostId, {
// 默认根与自定义根都经 binding store 的解析口径(漂移时返回 null → 上层
// 引导恢复位置,不误删)。
resolveLibraryRoot: async (id) => {
const resolution = await getGhostLibraryBindingStore().resolveLibraryRoot(id);
return resolution.kind === 'custom' ? resolution.root : ownerScopedUserDataPath('libraries', id);
},
trashRoot: () => ownerScopedUserDataPath('libraries-trash'),
removeBinding: async (id) => {
await getGhostLibraryBindingStore().removeBinding(id);
},
log,
});
return { ok: true };
if (result.ok) {
await refreshMivoLibraryExtraDirGrant().catch((error) => {
log.warn('library extraDirs delete sync failed', {
ghostId,
error: error instanceof Error ? error.message : String(error),
});
});
return { ok: true };
}
return { ok: false, message: result.message };
} finally {
slot.setRelocating(ghostId, false);
}
return { ok: false, message: result.message };
}

let libraryBindingStoreSingleton: LibraryBindingStore | null = null;
Expand Down Expand Up @@ -7977,15 +7989,19 @@ export function registerGhostIpc(): void {
throwIpcError('INVALID_PARAMS', '参数非法');
}
const releaseMutation = beginGhostMutation();
const slot = getGhostLibrarySlot();
try {
slot.setRelocating(id, true);
await slot.disposeGhost(id); // drain in-flight staging.release before binding changes
const set = await getGhostLibraryBindingStore().setBinding(id, candidate, (root) =>
statfsFreeBytes(root),
);
if (!set.ok) return { ok: false as const, message: set.message };
await getGhostLibrarySlot().disposeGhost(id); // 作废会话,下一请求用新根
await slot.disposeGhost(id); // 作废会话,下一请求用新根
await refreshMivoLibraryExtraDirGrant();
return { ok: true as const, warnings: set.warnings };
} finally {
slot.setRelocating(id, false);
releaseMutation();
}
});
Expand Down Expand Up @@ -8021,12 +8037,16 @@ export function registerGhostIpc(): void {
assertTrustedAppRendererEvent(event);
if (typeof id !== 'string' || !isValidGhostId(id)) throwIpcError('INVALID_PARAMS', '非法插件 id');
const releaseMutation = beginGhostMutation();
const slot = getGhostLibrarySlot();
try {
slot.setRelocating(id, true);
await slot.disposeGhost(id);
await getGhostLibraryBindingStore().removeBinding(id);
await getGhostLibrarySlot().disposeGhost(id);
await slot.disposeGhost(id);
await refreshMivoLibraryExtraDirGrant();
return { ok: true as const };
} finally {
slot.setRelocating(id, false);
releaseMutation();
}
});
Expand Down
17 changes: 17 additions & 0 deletions apps/desktop/src/main/cindy-brain/libraryBinding.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,11 @@ export interface LibraryBindingRecord {
grantedAt: number;
/** 每次重新绑定递增;迁移切换时原子写入。 */
generation: number;
/**
* false = 授权后尚未成功建出 `<parent>/<ghostId>`。缺省/true = 已经建过,
* ghost 子目录 MISSING 时不得空库重建。旧文件无此字段按已建过处理。
*/
libraryReady?: boolean;
}

export interface LibraryBindingFileData {
Expand Down Expand Up @@ -263,6 +268,7 @@ export class LibraryBindingStore {
identity,
grantedAt: this.now,
generation: (prev?.generation ?? 0) + 1,
libraryReady: false,
};
data.bindings[ghostId] = record;
await this.writeData(data);
Expand All @@ -286,6 +292,17 @@ export class LibraryBindingStore {
return this.readData().then((d) => d.bindings[ghostId] ?? null);
}

/** First successful custom open: persist ready without bumping generation. */
async markLibraryReady(ghostId: string): Promise<void> {
await this.runSerialized(async () => {
const data = await this.readData();
const rec = data.bindings[ghostId];
if (!rec || rec.libraryReady === true) return;
data.bindings[ghostId] = { ...rec, libraryReady: true };
await this.writeData(data);
});
}

/**
* 解析库根:无 binding → 系统默认;有 binding → 漂移检测(realpath 重解 +
* identity 比对)。漂移时返回 root:null,上层必须进入 unavailable 状态并
Expand Down
Loading
Loading