Repository navigation
feat(api,control,docs): 新增 project.export——把分支快照流式导出到本地目录并实时报告进度(layerfs-project#53) - #600
Merged
yifanxuaaa merged 6 commits intoNov 7, 2026
Conversation
…gress (Ephemeral-AI-Lab#53) Implement project.export(project, destination, { branch = 'main', on_progress }) across the call table, layerfs-control, worker protocol, CLI/Node/Python front-end tests, and 2.0 architecture/SDK documentation: - Pin the selected Branch snapshot once (main published Layer or Branch head Commit/base Layer) and stream tree::Record parts through a shared local StagingGuard (.layerfs-export-<id>) before atomic rename publication. - Stream worker-backed SQLite exports over 'project.export'; immediately abort and close the worker stream on local write errors (Capacity, Conflict, etc.) and propagate event write failures on the worker side so snapshot traversal stops immediately. - Enforce release-level verification between LocalWriter counts (files, directories, symlinks, bytes) and snapshot/worker counts before atomic publication on both in-process and worker paths. - Emit files, directories, symlinks, bytes, done and total across prepare, export and finalize progress events, and use neutral 'the Store home' wording in destination refusal messages. - Cover shared Store lease concurrency (file.read, concurrent project.export), exclusive Store operation refusals (store.remove, store.push -> InUse), Busy/Unresolved Store admission, and mid-export project.remove snapshot isolation in tests/project_export.rs. Production LOC: 137,010 -> 137,978 (delta +968) - Scope: first-party product source counted by python3 -B tools/production_loc.py --detail comparing parent d430474 to committed tree. - Subtotals: - core: 38,400 -> 38,400 (delta 0) - engine: 62,799 -> 62,799 (delta 0) - connect: 15,947 -> 15,947 (delta 0) - api: 19,864 -> 20,832 (delta +968: layerfs-api 1,434 -> 1,450 [+16], layerfs-control 17,521 -> 18,473 [+952]) - reference: reference absent (retired R9c; last counted 65,417 at tree 498dd19)
- 执行 api/scripts/gen-node-types.mjs 与 api/python/tools/gen_stubs.py,从包含 62 个调用(新增 project.export)的调用表重新生成 api/node/src/index.d.ts、api/python/src/layerfs/_client.pyi 与 aio.pyi - 同步更新 docs/architecture/api/front-ends/python.md 中 test_stubs.py 的 62 sync/async methods 计数 - 补齐 docs/architecture/api/sdk/project/ 下 get.md 与 remove.md 的底部翻页链,将 project.export 纳入单向遍历链 - 在 families.md、routing-table.md、workers.md 三张核心表中补列 project.export - 同步更新 docs/architecture/README.md、glossary.md、flows/call.md、api/README.md 以及 layerfs-js-sdk skill 文档中的 62 calls 与 project 调用列表 Production LOC: 140373 -> 140373 (delta 0) Scope: core 38811, engine 63682, connect 16068, api 21812; reference absent (retired R9c; last counted 65,417 at tree 498dd19). Method: python3 -B tools/production_loc.py --json on parent 89b6213 and committed tree (generated stubs and documentation excluded).
docs/versioned/2.0.0/README.md 的第 27、44、59 行仍写 61 calls,而同目录的 calls.md 与 sdk.md 在 Ephemeral-AI-Lab#53 之后已是 62,手册首页与正文对不上。按 LP53-STUBS-REVIEW 的 R1 一并改为 62。 全仓 grep 后仍有两处 61 calls:core/docs/issues/536/README.md 与 core/docs/issues/536/DECISIONS.md,均为历史决策记录,按报告要求保持原样。 生产 LOC: 140373 -> 140373 (delta 0) 统计范围:core、engine、connect、api 四个分层的产品源码(含运行期 SQL 与 JavaScript),不含测试、文档、工具与第三方代码。计数方法: `python3.11 -B tools/production_loc.py`(与父提交同一计数器与范围)。 本次只改文档,产品代码未变。 验证:`git diff --check` 退出码 0;全仓 grep 已无残留的 61 calls(仅上面 两处历史记录);`python3.11 -B tools/check_product_boundary.py` 输出 PASS。
按 LP53-R3-REVIEW 的 S1、S2 执行,只改测试和文档,不改产品代码;S3 是上游本身 的文案问题,未做。 - S1:新增 symlink 导出行为覆盖。同一个 symlink inode 的两个名字 (linkat 不传 AT_SYMLINK_FOLLOW,Linux 与 macOS 都链到 symlink 本身)、两个名字 只共享同一 target、以及指向导出树外的绝对 target。断言每个名字各自成为一个 destination 条目并保留原 target、分别计入 symlinks、共享 inode 的 mtime 在两个 导出名字上都在、绝对 target 不被跟随(树外文件未被改写)。按 docs/architecture/api/sdk/project/export.md 现有承诺,不断言 hard-link identity: source 侧先断言夹具确为一个 inode 两个名字,destination 侧不做 identity 断言。 - S2:新增 v1 Store 负向测试。把已初始化的 Store 的 SQLite 头部 user_version 改写 为 22(schema version 1)后调用 project.export,断言:以 Failed 拒绝、消息点名 schema version 1 与离线转换 tools/store_blocks.py migrate、没有任何 prepare 事件、 原本存在的空 destination 仍为空、父目录下没有 .layerfs-export-* 残留、源 Store 未改动(版本与长度不变)。仓库没有 rusqlite dev-dependency(加它需要改 Cargo.lock),故测试直接改写头部字段并在测试内回读校验。 - S2 文档:export.md 新增 "Store format prerequisite" 小节并交叉链接 SQLite、 PostgreSQL 与 Store lifecycle 的格式说明,注明旧 SQLite v1 会给出离线转换指引、 PG v1 同样在 open 被拒(Integrity),且该 SQLite 工具不是 PG 迁移工具;Errors 表 与说明补充该拒绝发生在 prepare/staging 之前。 - 顺带把 populate_sample 里的 set_mtime 闭包提成模块级 helper 供新测试复用,行为不变。 验证:cargo +1.85.1 test --locked -j 2 -p layerfs-api --test project_export (本 worktree 自己的 CARGO_TARGET_DIR)8 passed / 0 failed;git diff --check、 tools/check_product_boundary.py 通过;rustfmt --check 干净。PostgreSQL、真机/VM/ 容器 Worker 导出与 Durable 未运行(Durable NOT_RUN — disabled by owner until explicit reauthorization)。既有 suite 在同一进程内并行运行时存在极低概率的 "Store open: Store busy before effect" 抖动:未改动的 HEAD 版本 80 次里同样复现 1 次,不是本次引入,也未在本任务里改产品代码。 Production LOC: 142081 -> 142081 (delta 0) Scope: 首个 API 层产品源码范围 core/crates、engine/crates、connect/crates、api 及随包 运行时 SQL;本次只改 api/crates/layerfs-api/tests/ 与 docs/,产品代码零改动。 Method: tools/production_loc.py --json(本 worktree 树;reference absent,retired R9c, last counted 65,417 at tree 498dd19)。
There was a problem hiding this comment.
Copilot was unable to review this pull request because the user who requested the review has insufficient remaining quota. Try requesting a review at a lower effort level.
An organization owner can choose the repository organization as the billing source for reviews requested by members with a paid Copilot license. Learn about Copilot code review billing.
yifanxuaaa
merged commit Nov 7, 2026
abc4606
into
Ephemeral-AI-Lab:layerfs_v2_0_0
2 of 4 checks passed
yifanxuaaa
added a commit
that referenced
this pull request
Nov 7, 2026
feat(project): project.export, ported from #600, with review fixes (layerfs-project#53)
NelsonLongxiang
pushed a commit
to NelsonLongxiang/layerfs
that referenced
this pull request
Nov 8, 2026
…rt and fixes (layerfs-project#53) core/docs/issues/project-53/PR600-PORT.md records the author's commits and production LOC (+968), the port onto layerfs_v2_0_0 (+1,069 against the base), the three fixes, the review findings withdrawn (setuid/setgid cannot reach a Store; c71f892's LOC line is correct), checks run and not run, and what stays open (PR Ephemeral-AI-Lab#599 first; Apple Container truncation). Production LOC (tools/production_loc.py d3148db88f91, whole tree, tests excluded): 144,952 -> 144,952 (delta 0), docs only. Reference absent (retired R9c; last counted 65,417 at tree 498dd19). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
本 PR 单独在 Python 3.15 上会红一项:
test_project_export.py::AsyncBinding.test_callbacks_run_on_loop_in_order_before_return。原因是基线aio.py的回调排空有竞态,跟本 PR 无关,修复在 #599。实测:本分支单独(3b3cb179c)在 3.15 上 7/8,失败的就是这一项;和 #599 的分支合在一起(26fc1ae73、23fa36892)3.15 上 8/8。合并顺序:先 #599,再本 PR。
任务:Ephemeral-AI-Lab/layerfs-project#53。提交
608b9aba1标题里的(#53)指的也是 layerfs-project#53,不是本仓库的 #53。新增
project.export(project, destination, { branch = "main", on_progress }),CLI 是layerfs project export <store>/<project> <destination> [--branch <name>] [--progress]。它把一个 Branch 已提交的快照导出成调用方机器上的普通目录。来源可以是本地 SQLite、Docker / Lima / Apple Container 机器上的 SQLite(走共享 worker 流式路径),也可以是 PostgreSQL(同机或异机)。导出过程有分阶段、单调递增的实时进度;失败或断连不会报成完成,也不留半成品。Base:
layerfs_v2_0_0@b1802332b(本分支已包含,落后 0 个提交,git merge-tree预演无冲突)。改了什么
相对 base
b1802332b:45 个文件,+3619/−75。生产 LOC(tools/production_loc.py,e1b86ae6…,两份快照都在干净 worktree 上按整树统计):b1802332b140,847 →c71f89215141,815(delta +968);增量全在 api 层:layerfs-api1,427 → 1,450(+23)、layerfs-control18,252 → 19,197(+945),core 39,399 / engine 64,750 / connect 16,070 不变。实现提交608b9aba1当时相对基线d43047413记的是137,010 → 137,978(同为 +968;绝对值差 3,837 是上游在基线之后自己加的行)。之后的3b3cb179c、c71f89215都是 delta 0(实测15e3258e5与c71f89215都数出 141,815,产品代码 0 改动;c71f89215提交信息里的绝对值写成 142,081,delta 0 与实测一致)。1)新调用进入共享 call table,三端同一份契约
layerfs-api/src/project/{export.rs(新),mod.rs}:project、destination必填,branch默认main,on_progress可选;不提供commit参数(CLI 传--commit时 rc=2,提前拒绝)。layerfs-control/src/calls/{mod.rs,dispatch.rs}:表从 61 条变成 62 条,project.export的进度并入同一个事件流。api/node/src/index.d.ts、api/python/src/layerfs/{_client,aio}.pyi,package.mjs、test_stubs.py(含 mypy)都对得上。docs/architecture/api/sdk/project/export.md(新)、call table、front-ends、routing、versioned/2.0.0/{README,calls,sdk}.md(61→62)、.agents/skills/layerfs-js-sdk。2)只固定一次快照,只用一个本地写入器(
layerfs-control/src/project/{export.rs(新),staged.rs,tree.rs})snapshot、node、root、branch。.layerfs-export-<id>暂存目录(0700,与目标在同一文件系统),全部完成后才原子rename发布。目标已存在且非空,或者是普通文件、符号链接,一律拒绝,原目标不动。..、相对路径、Store home 内的路径;符号链接原样保留 target,从不跟随。Failed,消息里写出离线转换工具tools/store_blocks.py migrate;此时不产生prepare事件,也不建暂存目录。3)Worker 流式路径(Docker / Lima / Apple Container)
machine/worker/{client,server}.rs新增project.export:worker 在 Store 旁遍历快照、按 2 MiB 分块回传,宿主用同一个本地写入器落盘。4)进度契约
prepare→export→finalize;字段files, directories, symlinks, bytes, done, total,计数单调;不为算百分比做全量预扫描,total恒为null。export事件);finalize的计数与返回值一致;失败或中断时不产生finalize,也不报完成。--progress(stderr JSONL,返回值走 stdout)、NodeonProgress、Python 同步回调与aio异步回调。5)准入与并发
file.read和另一个project.export;这期间store.remove、store.push返回InUse;处于Busy/Unresolved的 Store 按现有准入拒绝。project.remove,不影响正在导出的固定快照。6)测试
layerfs-api/tests/project_export.rs(新,8 个测试),postgres_connect.rs+2 个用例,calls_table.rs/project_family.rs更新;api/node/test/project_export.mjs(9 个),api/python/test/test_project_export.py(8 个)。main与指定 Branch、拒绝commit、.git和被忽略的文件、权限位、空目录、相对/绝对/悬空符号链接、同一 symlink inode 的多个名字、非空目标、符号链接逃逸、写失败中断、计数不一致、并发写下的固定快照、SQLite schema v1 Store 被拒。怎么验证
下面的数字都来自各轮结果文件,每行注明被测提交。被测提交之间的关系:
c71f89215(本 PR head)=15e3258e5+ 测试和文档(产品代码 0 改动)。23fa36892=15e3258e5+ fix(python): 修掉 aio 进度回调的跨线程计数与排空竞态 #599 的分支(bec200e62),含上游b1802332b。26fc1ae73=3b3cb179c+bec200e62,含上游8b701f42f。d60dc8026=608b9aba1+ fix(python): 修掉 aio 进度回调的跨线程计数与排空竞态 #599 早期版本(f8736949f)。也就是说,head 上新增的 2 个测试只在 head 上跑过;其余结果来自产品代码与 head 相同(
23fa36892)或更早的提交,表里如实标出。通过
c71f89215,Mac(R3-FOLLOW)cargo +1.85.1 test --locked -j 2 -p layerfs-api --test project_export:8 passed / 0 failed(含新增的 symlink 与 schema v1 两项);git diff --check、tools/check_product_boundary.py、rustfmt --check通过c71f89215,Linuxlayerfs-test(PREPR,同一 target 连跑两遍)cargo test --locked -j 4 -p layerfs-api --test project_export:8 passed / 0 failed(0.47 s);同一条加--features postgresql再跑一遍:8 passed / 0 failed(0.44 s)。两遍都含新增的symlink_aliases_keep_targets_and_never_follow_outside_staging与schema_version_1_store_is_refused_before_prepare_and_keeps_destination;两遍Store busy命中 0 次,没有重跑23fa36892,Linuxlayerfs-test(CLOUD-R3)project_export6/6、calls_table15/15(62 calls);Nodeproject_export.mjs9/9、package.mjs4/4;Python 3.11.17 与 3.15.0rc3 的test_project_export.py各 8/8、test_stubs.py各 8/8(mypyChecked2/2 真跑,0 跳过);postgres_connect2/223fa36892,Linux,SQLite schema v2 与 PostgreSQL 17 的 CLI 导出对拍(CLOUD-R3)files=5, directories=2, symlinks=1, bytes=2097194,进度 4 行prepare → export → export → finalize)。src↔out-sq、out-sq↔out-pg、src↔out-pg三组都IDENTICAL(类型、权限、纳秒 mtime、软链目标、字节);成功后暂存目录残留 0;Store 记录里只有口令引用,home 里搜口令明文 0 命中23fa36892,Linux(CLOUD-R3,缩范围前跑过)cargo fmt --check、clippy --no-deps -p layerfs-control -p layerfs-api -D warnings、Rustcalls_scope/project_family/workspace_names、root 下 control 套件(kernel_application1/1、observed_application8/8、r5_1_actual_daemon_commit1/1、mount_control9/9、kernel_mount_routes、unmount_dirty)、Python 3.11/3.13/3.15/3.14t 的test_calls/test_project_export/test_stubs全部 rc=0;基线b1802332b上的同一批 control 套件也通过,无差异23fa36892,Mac,Docker(Colima 0.10.3 / Engine 29.5.2),bundlelinux-aarch64重打,source.head=23fa36892,hash1b0a5401…(MAC-R3)main/agent与 native 基准对拍一致、导出到已有空目录、--progress结构校验、worker 内project init后再导出、11 个负例(NotEmpty/Exists×2/NoSuchStore/NoSuchProject/NoSuchBranch/相对路径/home 内路径/NotFound/66 位 hex 分支名InvalidInput/--commitrc=2)、停机后Unavailable且不建目录、恢复后导出一致、暂存残留 023fa36892,Mac(MAC-EXEC)test_calls.py(带LAYERFS_DAEMON,含 exec 用例)各 14/14,0 跳过;Nodeevents.mjs2/2(含 exec output);macOS 专属sandbox_direct_live(--features machine)2/226fc1ae73,Linux(CLOUD-R2)calls_scope4/4、calls_table15/15、project_family2/2、workspace_names2/2、project_export6/6;root 下sandbox_family1/1、CLIstream1/1、control 套件 29/29;Nodecalls.mjs3/3、project_export.mjs9/9、events.mjs2/2、package.mjs4/4;Python 3.11.17 / 3.13.5 / 3.15.0rc3 / free-threaded 3.14.8t 各test_calls14/14、test_project_export8/8、test_stubs8/8(mypyChecked2/2);free-threaded 3.13.16t 纯 PythonAsyncCallbacks7/7、exportBinding+AsyncBinding7/7、mypy 2/2;boundary(1246 个生产文件)、guard 37/37、fmt、clippy --no-deps -D warnings通过26fc1ae73,Mac,bundle hashbeacfe62…(MAC-R2)main(19 项)与agent(20 项)都与 native 基准逐字节、纳秒 mtime、权限、软链一致;空目录导出、--progress(prepare→ 单调export→finalize)、11 个负例、停机Unavailable、恢复后导出一致、暂存残留 0(Apple Container 和 Lima 各有一次失败,见下表);macOSsandbox_direct_live2/2、Python 3.11/3.15test_calls各 14/14、Nodeevents.mjs+package.mjs6/6、test_stubsStubs6/6d60dc8026,Mac(WORKER-MATRIX)main清单83b3d31c…,agent分别为3163388d…/ce182f9f…/81f61335…),除 Lima 冷缓存那一次外全部通过608b9aba1,Linux PG 17.11(PG-TLS-NETLOSS)sslmode=require连接、导出、清单对拍一致(TLSv1.3);默认prefer同上;disable对照通过(pg_stat_ssl显示未加密)。导出 632,217,600 B 的夹具到约 25% 时,分别注入pg_terminate_backend、iptables+ip6tables REJECT 5432、停 PG:三次都 rc=125、stdout 为空、错误类别Unknown、没有finalize、目标目录不存在、暂存残留 0、无关文件完好;PG 恢复后重新导出,与基准清单完全一致608b9aba1,Mac 连layerfs-test上的 PG(异机,见53-STATE.md)postgres_connect2/2,导出到 Mac 本地目录通过失败(都不是本 PR 引入的,各自有证据)
3b3cb179c(本分支单独,不含 #599)test_project_export.py7/8,AsyncBinding.test_callbacks_run_on_loop_in_order_before_return失败(events == [])aio.py的竞态,#599 修复;合并树上 8/826fc1ae73(AC-FLAKE 专项)Unknown: … the worker's answer was lost (worker connection: failed to fill whole buffer) … was not resent。同一份代码在 Docker 上 130/130 成功store.pull也被同样截断)。产品行为符合契约:不重试、报Unknown、目标为空、无残留26fc1ae73/d60dc8026machine create报limactl create did not end within 30 s;镜像缓存预热后通过layerfs-machine的 30 s 调用超时,与导出无关,另案处理26fc1ae73/23fa36892--no-deps的 clippy rc=101:layerfs-persistence/src/backend/mod.rs:10unused import: ORDINAL_END--no-deps后 0 warningc71f89215测试组(R3-FOLLOW)Store open: Store busy before effect608b9aba1sslmode=verify-full/verify-ca报Failed: making the PostgreSQL Store: Malformed608b9aba1require和默认模式都报Malformed26fc1ae73/23fa36892sandbox_family/kernel_applicationrc=101/dev/fuse),root 下通过,与基线一致未运行(原因)
608b9aba1;23fa36892上只跑了同机 PG 的 CLI 导出对拍与postgres_connect23fa36892上的矩阵26fc1ae7323fa36892)sandbox-image.tar;Mac 上23fa36892已跑通(MAC-EXEC),云机26fc1ae73也已跑通test_stubs的Checked2 项(26fc1ae73)project_export.rs覆盖,PG 中途断连三种已覆盖cargo testNOT_RUN — disabled by owner until explicit reauthorization已知限制(本 PR 不处理)
sslmode=verify-full/verify-ca用不了:Cargo.lock锁定的tokio-postgres 0.7.18只认disable/prefer/require,连接串在解析阶段就被拒,然后被映射成容易误导的Malformed。这是驱动限制。require也校验证书链(native-tls默认策略,和 libpq 不同),自签名证书要先加进系统信任库,否则报Malformed(归类问题见 layerfs-project#100,不阻塞本 PR)。三种断连都报成同一句Unknown: provider failure: Store object demand: storage failure,分类正确,但消息里看不出原因。store.pull也会被截断)。在 Apple Container 上,大流量导出建议改用 Docker 或 Lima 机器。pyo3 0.30.1不支持 3.14 以下的 free-threaded CPython,上游已有;纯 Python 部分和 mypy 在 3.13t 上通过,3.14t 上原生扩展通过。layerfs-persistence的unused import: ORDINAL_END。project_export测试偶发Store busy before effect:并行跑时低概率出现,没改过的 HEAD 上同样复现(80 次 1 次),上游已有。machine create会超过 30 s 超时,属基线行为,另案。风险
hash做worker.hello校验。只验证了宿主和 bundle 同源的情况;旧 bundle 配新宿主没有测,按现有协议推断会被拒(stale_bundle),不会静默降级。total恒为null,调用方不能假设有百分比。store.remove/store.push会返回InUse,行为变化只出现在这几个调用并发的时候。versioned/2.0.0),以后和别的 PR 可能改到同一段文字,属合并顺序问题。