From bfb6bc1d32e5ff6c1ed892317b1ad4adfc088a2b Mon Sep 17 00:00:00 2001 From: Mola-maker <2249464964@qq.com> Date: Wed, 28 Oct 2026 08:22:19 -0700 Subject: [PATCH 01/39] chore: archive Area P plans and build infrastructure Archive the approved design, execution ledgers, ultragoal plan, Docker inputs and current build MCP work in progress. Command adapter tests pass; stdio initialization remains unresolved and Task 2 is not complete. Production LOC: 102763 -> 102763 (delta +0); core+engine+api via python -I tools/production_loc.py --json; reference absent (retired R9c; last counted 65,417 at tree 498dd1917812). Co-Authored-By: Claude Code --- .gitignore | 4 + .omc/project-memory.json | 174 ++++++++++++++ .omc/ultragoal/plans/area-p-20261028/brief.md | 49 ++++ .../area-p-20261028/claude-goal-snapshot.json | 6 + .../plans/area-p-20261028/goals.json | 180 ++++++++++++++ .../plans/area-p-20261028/ledger.jsonl | 4 + .../2026-10-28-area-p-foundation/plan-path | 1 + .../2026-10-28-area-p-foundation/progress.md | 10 + .../task-1-brief.md | 66 ++++++ .../task-2-brief.md | 44 ++++ .../tracking-issue.md | 29 +++ .../plans/2026-10-28-area-p-foundation.md | 220 ++++++++++++++++++ .../specs/2026-10-28-area-p-design.md | 208 +++++++++++++++++ tools/build-mcp/package-lock.json | 53 +++++ tools/build-mcp/package.json | 16 ++ tools/build-mcp/src/commands.js | 120 ++++++++++ tools/build-mcp/src/server.js | 34 +++ tools/build-mcp/test/commands.test.js | 94 ++++++++ tools/build-mcp/test/stdio.test.js | 70 ++++++ tools/docker/README.md | 18 ++ tools/docker/area-p-runtime.Dockerfile | 11 + 21 files changed, 1411 insertions(+) create mode 100644 .omc/project-memory.json create mode 100644 .omc/ultragoal/plans/area-p-20261028/brief.md create mode 100644 .omc/ultragoal/plans/area-p-20261028/claude-goal-snapshot.json create mode 100644 .omc/ultragoal/plans/area-p-20261028/goals.json create mode 100644 .omc/ultragoal/plans/area-p-20261028/ledger.jsonl create mode 100644 .superpowers/sdd/2026-10-28-area-p-foundation/plan-path create mode 100644 .superpowers/sdd/2026-10-28-area-p-foundation/progress.md create mode 100644 .superpowers/sdd/2026-10-28-area-p-foundation/task-1-brief.md create mode 100644 .superpowers/sdd/2026-10-28-area-p-foundation/task-2-brief.md create mode 100644 .superpowers/sdd/2026-10-28-area-p-foundation/tracking-issue.md create mode 100644 docs/superpowers/plans/2026-10-28-area-p-foundation.md create mode 100644 docs/superpowers/specs/2026-10-28-area-p-design.md create mode 100644 tools/build-mcp/package-lock.json create mode 100644 tools/build-mcp/package.json create mode 100644 tools/build-mcp/src/commands.js create mode 100644 tools/build-mcp/src/server.js create mode 100644 tools/build-mcp/test/commands.test.js create mode 100644 tools/build-mcp/test/stdio.test.js create mode 100644 tools/docker/README.md create mode 100644 tools/docker/area-p-runtime.Dockerfile diff --git a/.gitignore b/.gitignore index 70c937bd2..0ef305084 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,7 @@ benchmark-results/* !benchmark-results/fs-bench-pro/ benchmark-results/fs-bench-pro/* !benchmark-results/fs-bench-pro/optimization-history.md + +# Area P build MCP dependencies and transient local agent state. +tools/build-mcp/node_modules/ +.omc/state/ diff --git a/.omc/project-memory.json b/.omc/project-memory.json new file mode 100644 index 000000000..8f9f75f29 --- /dev/null +++ b/.omc/project-memory.json @@ -0,0 +1,174 @@ +{ + "version": "1.0.0", + "lastScanned": 1793199244010, + "projectRoot": "E:\\layerfs-connect\\layerfs", + "techStack": { + "languages": [ + { + "name": "Rust", + "version": "1.85", + "confidence": "high", + "markers": [ + "Cargo.toml" + ] + } + ], + "frameworks": [], + "packageManager": "cargo", + "runtime": null + }, + "build": { + "buildCommand": "cargo build", + "testCommand": "cargo test", + "lintCommand": "cargo clippy", + "devCommand": "cargo run", + "scripts": {} + }, + "conventions": { + "namingStyle": null, + "importStyle": null, + "testPattern": null, + "fileOrganization": null + }, + "structure": { + "isMonorepo": false, + "workspaces": [], + "mainDirectories": [ + "docs" + ], + "gitBranches": null + }, + "customNotes": [], + "directoryMap": { + "api": { + "path": "api", + "purpose": "API routes", + "fileCount": 5, + "lastAccessed": 1793199243987, + "keyFiles": [ + "AGENTS.md", + "package.json", + "README.md" + ] + }, + "core": { + "path": "core", + "purpose": null, + "fileCount": 2, + "lastAccessed": 1793199243987, + "keyFiles": [ + "AGENTS.md", + "README.md" + ] + }, + "docs": { + "path": "docs", + "purpose": "Documentation", + "fileCount": 1, + "lastAccessed": 1793199243988, + "keyFiles": [ + "README.md" + ] + }, + "engine": { + "path": "engine", + "purpose": null, + "fileCount": 1, + "lastAccessed": 1793199243988, + "keyFiles": [ + "AGENTS.md" + ] + }, + "plugins": { + "path": "plugins", + "purpose": null, + "fileCount": 0, + "lastAccessed": 1793199243988, + "keyFiles": [] + }, + "tools": { + "path": "tools", + "purpose": null, + "fileCount": 32, + "lastAccessed": 1793199243988, + "keyFiles": [ + "check_fuser_integrity.py", + "check_product_boundary.py", + "ci_tests.py", + "cleanup-scope.example.json", + "cleanup.py" + ] + }, + "api\\docs": { + "path": "api\\docs", + "purpose": "Documentation", + "fileCount": 3, + "lastAccessed": 1793199243989, + "keyFiles": [ + "agent.md", + "backend.md", + "runtime.md" + ] + }, + "core\\docs": { + "path": "core\\docs", + "purpose": "Documentation", + "fileCount": 0, + "lastAccessed": 1793199243989, + "keyFiles": [] + }, + "docs\\assets": { + "path": "docs\\assets", + "purpose": "Static assets", + "fileCount": 0, + "lastAccessed": 1793199243989, + "keyFiles": [] + }, + "engine\\vendor": { + "path": "engine\\vendor", + "purpose": "Third-party code", + "fileCount": 0, + "lastAccessed": 1793199243989, + "keyFiles": [] + } + }, + "hotPaths": [ + { + "path": "tools/worktree.py", + "accessCount": 2, + "lastAccessed": 1793199282040, + "type": "directory" + }, + { + "path": "", + "accessCount": 2, + "lastAccessed": 1793200733326, + "type": "directory" + }, + { + "path": "tools/worktree_run.py", + "accessCount": 1, + "lastAccessed": 1793199269103, + "type": "file" + }, + { + "path": "tools/production_loc.py", + "accessCount": 1, + "lastAccessed": 1793199269157, + "type": "file" + }, + { + "path": "docs/superpowers/specs/2026-10-28-area-p-design.md", + "accessCount": 1, + "lastAccessed": 1793199296007, + "type": "directory" + }, + { + "path": ".superpowers/sdd/2026-10-28-area-p-foundation/progress.md", + "accessCount": 1, + "lastAccessed": 1793200843472, + "type": "file" + } + ], + "userDirectives": [] +} \ No newline at end of file diff --git a/.omc/ultragoal/plans/area-p-20261028/brief.md b/.omc/ultragoal/plans/area-p-20261028/brief.md new file mode 100644 index 000000000..8f653ce26 --- /dev/null +++ b/.omc/ultragoal/plans/area-p-20261028/brief.md @@ -0,0 +1,49 @@ +# LayerFS Area P (P1–P15) implementation brief + +Date: 2026-10-28 +Source: Ephemeral-AI-Lab/layerfs-connect#14 and its sub-issues; P2 is Ephemeral-AI-Lab/layerfs#484. +Code repository: E:/layerfs-connect/layerfs +Baseline: main at 582bc786f9e17a6993a147cc165e3e4a75366a9e (shallow clone after full-clone index-pack OOM). + +## Objective + +Build Area P without weakening LayerFS's core contracts. Before product code, establish a reproducible Linux-first build/test environment on the current Windows + Docker Desktop host, dedicated project-local build MCP/skills where they add value beyond the existing tools/worktree.py launcher, and a clean Git rollback/checkpoint procedure. Verify changed scope directly; CI is informational, not a completion gate. + +## Required outcome + +Each of Node, Python, CLI and MCP can mount, run a command, commit, read history, and push or pull, subject to explicitly resolved upstream dependencies. All work uses Disposable/WAL/synchronous=OFF only; Durable execution remains disabled. Linux owns real Store/FUSE E2E; Windows uses the existing worker route. No retired server or host-mediated Store route. + +## Task inventory + +- P1 (#53): Node SDK history calls — commit graph with Layers, merge parents, fork points, publish state, branch commits to fork point, changed paths per commit. +- P2 (layerfs#484): explicit JS constructor/open/init configuration with environment fallback and lifetime-stable resolution. +- P3 (#54): CLI history, push and pull; blocked by P1 and R7 (#42), which is open and itself blocked by R2/R3. +- P4 (#55): Python SDK; blocked by D10 (#20), native Rust binding versus worker-only client. +- P5 (#56): MCP server; blocked by D11 (#21), Node versus Python and stdio versus HTTP. +- P6 (#57): contract tests shared by Node, Python, CLI and MCP. +- P7 (#58): commit author/agent and labels. +- P8 (#59): list/stat/read at any Commit or Layer, including binary flag. +- P9 (#60): event stream for commits, branches, workspaces, sandboxes and init progress. +- P10 (#61): per-call results API with method, duration, error code and write outcomes. +- P11 (#62): Store statistics building on get_storage. +- P12 (#63): Store summary for project lists. +- P13 (#64): folder-scoped client with isolated folder and Branch namespace. +- P14 (#65): fix list_branches short pages and list_workspaces Branch names beyond 64 (layerfs#480). +- P15 (#66): publish @layerfs/* packages to npm; outward-facing release requires separate explicit approval before publishing. + +## Execution order and gates + +1. Complete read-only recon and obtain approval for the full design, including D10/D11 choices or explicitly defer blocked implementation. +2. Pin the clean baseline and create the reproducible build/test/rollback tooling. Prove stand-in tests and the smallest real Linux Store/Workspace smoke path before feature work. +3. Implement shared Rust/API primitives and contract fixtures before duplicating behavior across SDKs. +4. Implement Node, Python, CLI and MCP in dependency order. Do not claim P3 push/pull complete while R7 is unavailable. +5. Run scoped tests with explicit <=120-second test ceilings, build before test, preserve failure evidence, and run real Linux E2E through isolated resources. +6. Finish with independent review, ai-slop-cleaner, full changed-scope verification, and the cross-client mount/exec/commit/history/push-or-pull evidence matrix. Publishing is not implied by local completion. + +## Constraints + +- Follow AGENTS.md plus core/engine/api guides and preserve core < engine < api layering. +- No new dependency where an existing crate/package provides the capability. +- One attempted operation; no automatic retry/replay. +- No automatic commits, pushes, issue edits, or npm publication without explicit user request. +- Preserve unrelated files and existing Docker/WSL state; use per-worktree caches and per-run Store/container ownership. diff --git a/.omc/ultragoal/plans/area-p-20261028/claude-goal-snapshot.json b/.omc/ultragoal/plans/area-p-20261028/claude-goal-snapshot.json new file mode 100644 index 000000000..412b7edb8 --- /dev/null +++ b/.omc/ultragoal/plans/area-p-20261028/claude-goal-snapshot.json @@ -0,0 +1,6 @@ +{ + "goal": { + "objective": "Complete all ultragoal stories in .omc/ultragoal/plans/area-p-20261028/goals.json: G001-recon-and-design Recon and design; G002-build-test-rollback-environment Build test rollback environment; G003-p1-node-history P1 Node history; G004-p2-constructor-configuration P2 Constructor configuration; G005-p6-shared-contract-tests P6 Shared contract tests; G006-p7-commit-metadata P7 Commit metadata; G007-p8-historical-file-reads P8 Historical file reads; G008-p9-event-stream P9 Event stream; G009-p10-per-call-results P10 Per-call results; G010-p11-store-statistics P11 Store statistics; G011-p12-store-summary P12 Store summary; G012-p13-folder-scoped-client P13 Folder-scoped client; G013-p14-pagination-fixes P14 Pagination fixes; G014-p3-cli-history-push-pull P3 CLI history push pull; G015-p4-python-sdk P4 Python SDK; G016-p5-mcp-server P5 MCP server; G017-p15-package-publishing P15 Package publishing; G018-final-cross-client-verification Final cross-client verification", + "status": "active" + } +} diff --git a/.omc/ultragoal/plans/area-p-20261028/goals.json b/.omc/ultragoal/plans/area-p-20261028/goals.json new file mode 100644 index 000000000..2a6db3c39 --- /dev/null +++ b/.omc/ultragoal/plans/area-p-20261028/goals.json @@ -0,0 +1,180 @@ +{ + "version": 1, + "planId": "area-p-20261028", + "createdAt": "2026-10-28T14:01:09.199Z", + "updatedAt": "2026-10-28T14:35:19.243Z", + "briefPath": ".omc/ultragoal/plans/area-p-20261028/brief.md", + "goalsPath": ".omc/ultragoal/plans/area-p-20261028/goals.json", + "ledgerPath": ".omc/ultragoal/plans/area-p-20261028/ledger.jsonl", + "claudeGoalMode": "aggregate", + "goals": [ + { + "id": "G001-recon-and-design", + "title": "Recon and design", + "objective": "Understand core and Area P, choose Linux-first environment and resolve or surface D10/D11 and R7 gates; obtain design approval", + "status": "complete", + "attempt": 1, + "createdAt": "2026-10-28T14:01:09.199Z", + "updatedAt": "2026-10-28T14:34:59.680Z", + "startedAt": "2026-10-28T14:01:23.578Z", + "completedAt": "2026-10-28T14:34:59.680Z", + "evidence": "Core/source recon complete; all Area P issues verified; builder-check findings resolved in docs/superpowers/specs/2026-10-28-area-p-design.md; foundation plan saved; private repository created; local branch and verified rollback bundle ready. R7 remains external blocker." + }, + { + "id": "G002-build-test-rollback-environment", + "title": "Build test rollback environment", + "objective": "Establish and verify isolated Linux build/test resources, project-local build helpers, and clean Git rollback checkpoints before product code", + "status": "in_progress", + "attempt": 1, + "createdAt": "2026-10-28T14:01:09.199Z", + "updatedAt": "2026-10-28T14:35:19.243Z", + "startedAt": "2026-10-28T14:35:19.243Z" + }, + { + "id": "G003-p1-node-history", + "title": "P1 Node history", + "objective": "Implement and verify layerfs-connect#53 Node SDK history calls", + "status": "pending", + "attempt": 0, + "createdAt": "2026-10-28T14:01:09.199Z", + "updatedAt": "2026-10-28T14:01:09.199Z" + }, + { + "id": "G004-p2-constructor-configuration", + "title": "P2 Constructor configuration", + "objective": "Implement and verify layerfs#484 explicit JS client configuration with environment fallback", + "status": "pending", + "attempt": 0, + "createdAt": "2026-10-28T14:01:09.199Z", + "updatedAt": "2026-10-28T14:01:09.199Z" + }, + { + "id": "G005-p6-shared-contract-tests", + "title": "P6 Shared contract tests", + "objective": "Create reusable cross-client behavioral contracts and fixtures for Node Python CLI and MCP", + "status": "pending", + "attempt": 0, + "createdAt": "2026-10-28T14:01:09.199Z", + "updatedAt": "2026-10-28T14:01:09.199Z" + }, + { + "id": "G006-p7-commit-metadata", + "title": "P7 Commit metadata", + "objective": "Implement and verify commit author agent and labels through history", + "status": "pending", + "attempt": 0, + "createdAt": "2026-10-28T14:01:09.199Z", + "updatedAt": "2026-10-28T14:01:09.199Z" + }, + { + "id": "G007-p8-historical-file-reads", + "title": "P8 Historical file reads", + "objective": "Implement and verify list stat read at Commit or Layer including binary handling", + "status": "pending", + "attempt": 0, + "createdAt": "2026-10-28T14:01:09.199Z", + "updatedAt": "2026-10-28T14:01:09.199Z" + }, + { + "id": "G008-p9-event-stream", + "title": "P9 Event stream", + "objective": "Implement and verify typed event stream for required project and runtime changes", + "status": "pending", + "attempt": 0, + "createdAt": "2026-10-28T14:01:09.199Z", + "updatedAt": "2026-10-28T14:01:09.199Z" + }, + { + "id": "G009-p10-per-call-results", + "title": "P10 Per-call results", + "objective": "Implement and verify method duration error and write outcome results API", + "status": "pending", + "attempt": 0, + "createdAt": "2026-10-28T14:01:09.199Z", + "updatedAt": "2026-10-28T14:01:09.199Z" + }, + { + "id": "G010-p11-store-statistics", + "title": "P11 Store statistics", + "objective": "Implement and verify detailed Store statistics building on get_storage", + "status": "pending", + "attempt": 0, + "createdAt": "2026-10-28T14:01:09.199Z", + "updatedAt": "2026-10-28T14:01:09.199Z" + }, + { + "id": "G011-p12-store-summary", + "title": "P12 Store summary", + "objective": "Implement and verify project-list Store summary", + "status": "pending", + "attempt": 0, + "createdAt": "2026-10-28T14:01:09.199Z", + "updatedAt": "2026-10-28T14:01:09.199Z" + }, + { + "id": "G012-p13-folder-scoped-client", + "title": "P13 Folder-scoped client", + "objective": "Implement and verify folder and Branch namespace scoping", + "status": "pending", + "attempt": 0, + "createdAt": "2026-10-28T14:01:09.199Z", + "updatedAt": "2026-10-28T14:01:09.199Z" + }, + { + "id": "G013-p14-pagination-fixes", + "title": "P14 Pagination fixes", + "objective": "Reproduce and fix list_branches short pages and list_workspaces Branch names past 64", + "status": "pending", + "attempt": 0, + "createdAt": "2026-10-28T14:01:09.199Z", + "updatedAt": "2026-10-28T14:01:09.199Z" + }, + { + "id": "G014-p3-cli-history-push-pull", + "title": "P3 CLI history push pull", + "objective": "Implement and verify CLI calls after P1 and R7 are available; record blocker otherwise", + "status": "pending", + "attempt": 0, + "createdAt": "2026-10-28T14:01:09.199Z", + "updatedAt": "2026-10-28T14:01:09.199Z" + }, + { + "id": "G015-p4-python-sdk", + "title": "P4 Python SDK", + "objective": "After D10 decision implement and verify Python SDK and required wheel worker paths", + "status": "pending", + "attempt": 0, + "createdAt": "2026-10-28T14:01:09.199Z", + "updatedAt": "2026-10-28T14:01:09.199Z" + }, + { + "id": "G016-p5-mcp-server", + "title": "P5 MCP server", + "objective": "After D11 decision implement and verify MCP file Bash history push pull calls", + "status": "pending", + "attempt": 0, + "createdAt": "2026-10-28T14:01:09.199Z", + "updatedAt": "2026-10-28T14:01:09.199Z" + }, + { + "id": "G017-p15-package-publishing", + "title": "P15 Package publishing", + "objective": "Prepare and verify npm packages; publish only with separate explicit approval", + "status": "pending", + "attempt": 0, + "createdAt": "2026-10-28T14:01:09.199Z", + "updatedAt": "2026-10-28T14:01:09.199Z" + }, + { + "id": "G018-final-cross-client-verification", + "title": "Final cross-client verification", + "objective": "Run independent review ai-slop-cleaner and changed-scope verification; produce Node Python CLI MCP evidence matrix and resolve all review blockers", + "status": "pending", + "attempt": 0, + "createdAt": "2026-10-28T14:01:09.199Z", + "updatedAt": "2026-10-28T14:01:09.199Z" + } + ], + "claudeObjective": "Complete all ultragoal stories in .omc/ultragoal/plans/area-p-20261028/goals.json: G001-recon-and-design Recon and design; G002-build-test-rollback-environment Build test rollback environment; G003-p1-node-history P1 Node history; G004-p2-constructor-configuration P2 Constructor configuration; G005-p6-shared-contract-tests P6 Shared contract tests; G006-p7-commit-metadata P7 Commit metadata; G007-p8-historical-file-reads P8 Historical file reads; G008-p9-event-stream P9 Event stream; G009-p10-per-call-results P10 Per-call results; G010-p11-store-statistics P11 Store statistics; G011-p12-store-summary P12 Store summary; G012-p13-folder-scoped-client P13 Folder-scoped client; G013-p14-pagination-fixes P14 Pagination fixes; G014-p3-cli-history-push-pull P3 CLI history push pull; G015-p4-python-sdk P4 Python SDK; G016-p5-mcp-server P5 MCP server; G017-p15-package-publishing P15 Package publishing; G018-final-cross-client-verification Final cross-client verification", + "activeGoalId": "G002-build-test-rollback-environment" +} diff --git a/.omc/ultragoal/plans/area-p-20261028/ledger.jsonl b/.omc/ultragoal/plans/area-p-20261028/ledger.jsonl new file mode 100644 index 000000000..c5ec5cad6 --- /dev/null +++ b/.omc/ultragoal/plans/area-p-20261028/ledger.jsonl @@ -0,0 +1,4 @@ +{"ts":"2026-10-28T14:01:09.199Z","event":"plan_created","message":"18 goal(s) created"} +{"ts":"2026-10-28T14:01:23.578Z","event":"goal_started","goalId":"G001-recon-and-design","status":"in_progress","message":"Attempt 1"} +{"ts":"2026-10-28T14:34:59.680Z","event":"goal_completed","goalId":"G001-recon-and-design","status":"complete","evidence":"Core/source recon complete; all Area P issues verified; builder-check findings resolved in docs/superpowers/specs/2026-10-28-area-p-design.md; foundation plan saved; private repository created; local branch and verified rollback bundle ready. R7 remains external blocker.","claudeGoal":{"available":true,"objective":"Complete all ultragoal stories in .omc/ultragoal/plans/area-p-20261028/goals.json: G001-recon-and-design Recon and design; G002-build-test-rollback-environment Build test rollback environment; G003-p1-node-history P1 Node history; G004-p2-constructor-configuration P2 Constructor configuration; G005-p6-shared-contract-tests P6 Shared contract tests; G006-p7-commit-metadata P7 Commit metadata; G007-p8-historical-file-reads P8 Historical file reads; G008-p9-event-stream P9 Event stream; G009-p10-per-call-results P10 Per-call results; G010-p11-store-statistics P11 Store statistics; G011-p12-store-summary P12 Store summary; G012-p13-folder-scoped-client P13 Folder-scoped client; G013-p14-pagination-fixes P14 Pagination fixes; G014-p3-cli-history-push-pull P3 CLI history push pull; G015-p4-python-sdk P4 Python SDK; G016-p5-mcp-server P5 MCP server; G017-p15-package-publishing P15 Package publishing; G018-final-cross-client-verification Final cross-client verification","status":"active","remainingTokens":null,"raw":{"goal":{"objective":"Complete all ultragoal stories in .omc/ultragoal/plans/area-p-20261028/goals.json: G001-recon-and-design Recon and design; G002-build-test-rollback-environment Build test rollback environment; G003-p1-node-history P1 Node history; G004-p2-constructor-configuration P2 Constructor configuration; G005-p6-shared-contract-tests P6 Shared contract tests; G006-p7-commit-metadata P7 Commit metadata; G007-p8-historical-file-reads P8 Historical file reads; G008-p9-event-stream P9 Event stream; G009-p10-per-call-results P10 Per-call results; G010-p11-store-statistics P11 Store statistics; G011-p12-store-summary P12 Store summary; G012-p13-folder-scoped-client P13 Folder-scoped client; G013-p14-pagination-fixes P14 Pagination fixes; G014-p3-cli-history-push-pull P3 CLI history push pull; G015-p4-python-sdk P4 Python SDK; G016-p5-mcp-server P5 MCP server; G017-p15-package-publishing P15 Package publishing; G018-final-cross-client-verification Final cross-client verification","status":"active"}}}} +{"ts":"2026-10-28T14:35:19.243Z","event":"goal_started","goalId":"G002-build-test-rollback-environment","status":"in_progress","message":"Attempt 1"} diff --git a/.superpowers/sdd/2026-10-28-area-p-foundation/plan-path b/.superpowers/sdd/2026-10-28-area-p-foundation/plan-path new file mode 100644 index 000000000..3c2a0eb89 --- /dev/null +++ b/.superpowers/sdd/2026-10-28-area-p-foundation/plan-path @@ -0,0 +1 @@ +docs/superpowers/plans/2026-10-28-area-p-foundation.md diff --git a/.superpowers/sdd/2026-10-28-area-p-foundation/progress.md b/.superpowers/sdd/2026-10-28-area-p-foundation/progress.md new file mode 100644 index 000000000..32b5ecd79 --- /dev/null +++ b/.superpowers/sdd/2026-10-28-area-p-foundation/progress.md @@ -0,0 +1,10 @@ +# SDD ledger — plan: docs/superpowers/plans/2026-10-28-area-p-foundation.md + +Pre-flight: Task 1 produces registered resources/images consumed by Tasks 2 and 4; no interface conflict found. +Pre-flight: Task 2 produces allowlisted build operations used optionally by Task 4; CLI remains the authoritative fallback. +Pre-flight: Task 3 preserves sealed bundle interfaces consumed by Task 4; Node behavior must remain compatible. +Ruling: use the fresh dedicated clone on area-p/p1-p15 instead of creating a nested linked worktree — the checkout has no other work and the user approved this branch and immediate execution — cost if wrong: another agent editing this exact clone could collide, so no concurrent editor is allowed. +Ruling: Ubuntu WSL2 is required for the existing launcher because worktree_run.py uses os.uname() — use a dedicated distribution rather than porting the launcher to Windows — cost if wrong: one additional local Linux distribution to maintain. +Task 1: Docker Desktop WSL integration failed; an isolated Ubuntu docker.io install then hit host C: at 0 MB and VHD I/O errors. Moving Ubuntu to E: repeatedly failed ERROR_SHARING_VIOLATION. Do not shut down shared Docker/WSL state without user approval. +Task 1: Ruling: proceed with Task 2's independent MCP unit/stdio tests while the host storage blocker awaits user action — this is still build infrastructure, not Area P product code — cost if wrong: MCP integration must be revalidated after Task 1 resources exist, which is already required by Task 4. +Task 2: command adapter RED→GREEN (6/6); stdio test failed first on the missing server, then exposed unresolved SDK v2 initialization. Task 2 is not complete. User requested an immediate archival push of all plans/history before further debugging. diff --git a/.superpowers/sdd/2026-10-28-area-p-foundation/task-1-brief.md b/.superpowers/sdd/2026-10-28-area-p-foundation/task-1-brief.md new file mode 100644 index 000000000..f307de146 --- /dev/null +++ b/.superpowers/sdd/2026-10-28-area-p-foundation/task-1-brief.md @@ -0,0 +1,66 @@ +### Task 1: Linux Environment and Recovery Baseline + +**Files:** +- Create: `tools/docker/area-p-runtime.Dockerfile` +- Create: `tools/docker/README.md` +- Modify: `.gitignore` only if the runtime recipe generates a local file not already ignored + +**Interfaces:** +- Produces: local image `layerfs-area-p-runtime:node24-python3`, a printed `resources.json` path, and verified `origin`/`upstream` remotes consumed by Tasks 2–4. + +- [ ] **Step 1: Prove the current recovery state** + +Run: +```bash +git -C E:/layerfs-connect/layerfs status --short --branch +git -C E:/layerfs-connect/layerfs remote -v +git -C E:/layerfs-connect/layerfs bundle verify E:/layerfs-connect/backups/layerfs-area-p-baseline-582bc786.bundle +``` +Expected: branch `area-p/p1-p15`, bundle contains baseline tag, `origin` is the private Area P repository, `upstream` is public LayerFS. Then run `git -C E:/layerfs-connect/layerfs remote set-url --push upstream DISABLED_NO_UPSTREAM_PUSH` and confirm no upstream push URL remains usable. + +- [ ] **Step 2: Install and qualify the dedicated WSL control plane** + +Run `wsl.exe --install -d Ubuntu --no-launch`. If Windows requires restart or interactive setup, stop this task and report that exact blocker; do not alter Podman/Docker utility distributions. After installation: +```bash +wsl.exe -d Ubuntu -u root -- sh -lc 'apt-get update && apt-get install -y --no-install-recommends python3 lsof git ca-certificates && python3 --version && docker version && test -d /mnt/e/layerfs-connect/layerfs' +``` +Expected: Python 3, Docker Engine API >=1.48, and the checkout visible at the pinned path. + +- [ ] **Step 3: Write the runtime recipe and its documentation** + +`area-p-runtime.Dockerfile` starts `FROM node:24.21.0-bookworm` and installs only `git`, `procps`, `lsof`, `python3`, `python3-venv`, and `python3-pip` with apt lists removed. `tools/docker/README.md` records exact build/pull commands, the no-Durable rule, and that FUSE capability comes from the product's Docker configuration rather than an unqualified image claim. + +- [ ] **Step 4: Pull/build and pin all image identities** + +Run in Ubuntu: +```bash +docker pull rust:1.85.1-bookworm +docker pull node:24.21.0-bookworm +docker build --file /mnt/e/layerfs-connect/layerfs/tools/docker/area-p-runtime.Dockerfile --tag layerfs-area-p-runtime:node24-python3 /mnt/e/layerfs-connect/layerfs/tools/docker +docker image inspect rust:1.85.1-bookworm node:24.21.0-bookworm layerfs-area-p-runtime:node24-python3 --format '{{.RepoTags}} {{.Id}}' +``` +Expected: all images local; registration subsequently uses these exact resolved IDs. + +- [ ] **Step 5: Register and prepare private build resources** + +Run: +```bash +python3 /mnt/e/layerfs-connect/layerfs/tools/worktree.py register --worktree /mnt/e/layerfs-connect/layerfs --root /root/.cache/layerfs-worktrees --owner area-p --build-image rust:1.85.1-bookworm --runtime-image layerfs-area-p-runtime:node24-python3 --sandbox-image layerfs-area-p-runtime:node24-python3 +python3 /mnt/e/layerfs-connect/layerfs/tools/worktree.py prepare --resources --platform linux +``` +Expected: a registration whose source is the pinned checkout, Linux Cargo/target volumes are private, dependencies are fetched only in `prepare`, and the printed resource path is recorded in `.omc/ultragoal/plans/area-p-20261028/ledger.jsonl` evidence. + +- [ ] **Step 6: Run existing cheap baselines under explicit timeouts** + +Run in Ubuntu with `timeout 100` for each test command: +```bash +cd /mnt/e/layerfs-connect/layerfs +timeout 100 python3 -B -m unittest tools.test_worktree +timeout 100 docker run --rm --pull never -v /mnt/e/layerfs-connect/layerfs:/work:ro -w /work layerfs-area-p-runtime:node24-python3 node api/agent/test/calls.mjs +timeout 100 docker run --rm --pull never -v /mnt/e/layerfs-connect/layerfs:/work:ro -w /work layerfs-area-p-runtime:node24-python3 node --test api/cli/test/cli.test.mjs +``` +Expected: all exit 0 within 100 seconds; otherwise retain output and diagnose before product work. + +- [ ] **Step 7: Commit the environment recipe** + +Run the production LOC counter before staging and against the staged tree, then commit only Task 1 files with `build: add Area P Linux runtime recipe`, the LOC comparison and `Co-Authored-By: Claude Code `. Push is a separate user-approved operation subject to the OMC Git guardrail. diff --git a/.superpowers/sdd/2026-10-28-area-p-foundation/task-2-brief.md b/.superpowers/sdd/2026-10-28-area-p-foundation/task-2-brief.md new file mode 100644 index 000000000..af17cceb1 --- /dev/null +++ b/.superpowers/sdd/2026-10-28-area-p-foundation/task-2-brief.md @@ -0,0 +1,44 @@ +### Task 2: Project Build MCP and Build/Rollback Skills + +**Files:** +- Create: `tools/build-mcp/package.json` +- Create: `tools/build-mcp/package-lock.json` +- Create: `tools/build-mcp/src/commands.js` +- Create: `tools/build-mcp/src/server.js` +- Create: `tools/build-mcp/test/commands.test.js` +- Create: `tools/build-mcp/test/stdio.test.js` +- Create: `.mcp.json` +- Create: `.claude/skills/layerfs-build/SKILL.md` +- Create: `.claude/skills/layerfs-rollback/SKILL.md` + +**Interfaces:** +- Consumes: Task 1 `resources.json` and pinned Ubuntu/checkout. +- Produces: MCP tools `layerfs_status`, `layerfs_prepare`, `layerfs_build`, `layerfs_cargo_test`, and `layerfs_checkpoint`; `createOperations({platform, resources, runner})` is the injectable command boundary. + +- [ ] **Step 1: Write failing command-validation tests** + +Test `createOperations` with an injected runner. Assert exact argv for status/prepare/build/cargo test; assert build defaults to Linux debug with jobs 2; assert cargo tests require `-p` and a named `--test` target and use `--timeout 90`; assert `--workspace`, `--manifest-path`, `--target-dir`, `fs-bench-pro`, shell metacharacters, alternate resources paths and timeout >100 are refused before the runner is called. Assert Windows commands use `wsl.exe -d Ubuntu -u root --cd /mnt/e/layerfs-connect/layerfs` and never a shell command string. + +- [ ] **Step 2: Run the command tests and confirm failure** + +Run `node --test tools/build-mcp/test/commands.test.js` under a 100-second parent timeout. Expected: FAIL because `src/commands.js` does not exist. + +- [ ] **Step 3: Implement the typed command adapter** + +`commands.js` exports `createOperations({platform = process.platform, resources, runner})`. Each operation returns `{status, stdout, stderr, receipt?}` from an injected spawn runner; no `exec`, shell string, arbitrary cwd, retry, Docker cleanup or Git mutation. `layerfs_checkpoint` runs only read-only Git commands and writes a timestamped binary diff plus status/revision manifest beneath `E:/layerfs-connect/backups` (or its Ubuntu-mounted equivalent), reporting untracked files separately rather than pretending they are in the diff. + +- [ ] **Step 4: Implement and test the stdio MCP surface** + +Use official `@modelcontextprotocol/server` 2.x and `zod` 4. `server.js` registers the five tools with strict schemas and uses `serveStdio`; stdout is protocol only and logs use stderr. `stdio.test.js` spawns the server, performs `initialize` and `tools/list`, asserts exactly the five tools, invokes invalid input and expects a tool error without a spawned build. Pin dependencies in the lockfile. + +- [ ] **Step 5: Configure the MCP and write both skills** + +`.mcp.json` starts the server with Windows Node and supplies `LAYERFS_BUILD_RESOURCES=/root/.cache/layerfs-worktrees//resources.json` using the actual Task 1 path. The build skill documents Ubuntu execution, build-before-test, Disposable-only, timeout and receipt rules. The rollback skill documents baseline bundle verification, non-destructive checkpoints, exact-target inspection, and that restore/reset/clean/teardown require explicit approval. + +- [ ] **Step 6: Run MCP and documentation checks** + +Run both Node test files with `node --test` under a 100-second parent timeout. Run `git diff --check` and validate `.mcp.json`/package JSON parsing. Expected: PASS, five tools listed, no protocol logs on stdout. + +- [ ] **Step 7: Commit the MCP and skills** + +Record production LOC before/after and commit Task 2 files as `tools: add Area P build MCP and recovery skills` with the required attribution. diff --git a/.superpowers/sdd/2026-10-28-area-p-foundation/tracking-issue.md b/.superpowers/sdd/2026-10-28-area-p-foundation/tracking-issue.md new file mode 100644 index 000000000..efe2d294f --- /dev/null +++ b/.superpowers/sdd/2026-10-28-area-p-foundation/tracking-issue.md @@ -0,0 +1,29 @@ +Private organization implementation workspace for LayerFS Area P. Upstream `Ephemeral-AI-Lab/layerfs` remains read-only; development lands on `area-p/p1-p15` in this repository. + +Parent: https://github.com/Ephemeral-AI-Lab/layerfs-connect/issues/14 + +## Original Area P issues + +- [ ] P1 — Node SDK history calls: https://github.com/Ephemeral-AI-Lab/layerfs-connect/issues/53 +- [ ] P2 — JS constructor configuration: https://github.com/Ephemeral-AI-Lab/layerfs/issues/484 +- [ ] P3 — CLI history, push and pull: https://github.com/Ephemeral-AI-Lab/layerfs-connect/issues/54 (push/pull blocked by R7 #42) +- [ ] P4 — Python SDK: https://github.com/Ephemeral-AI-Lab/layerfs-connect/issues/55 +- [ ] P5 — MCP server: https://github.com/Ephemeral-AI-Lab/layerfs-connect/issues/56 +- [ ] P6 — Shared contract tests: https://github.com/Ephemeral-AI-Lab/layerfs-connect/issues/57 +- [ ] P7 — Commit author and labels: https://github.com/Ephemeral-AI-Lab/layerfs-connect/issues/58 +- [ ] P8 — Read files at Commit or Layer: https://github.com/Ephemeral-AI-Lab/layerfs-connect/issues/59 +- [ ] P9 — Event stream: https://github.com/Ephemeral-AI-Lab/layerfs-connect/issues/60 +- [ ] P10 — Per-call results API: https://github.com/Ephemeral-AI-Lab/layerfs-connect/issues/61 +- [ ] P11 — Store statistics: https://github.com/Ephemeral-AI-Lab/layerfs-connect/issues/62 +- [ ] P12 — Store summary: https://github.com/Ephemeral-AI-Lab/layerfs-connect/issues/63 +- [ ] P13 — Folder-scoped client: https://github.com/Ephemeral-AI-Lab/layerfs-connect/issues/64 +- [ ] P14 — Pagination fixes: https://github.com/Ephemeral-AI-Lab/layerfs-connect/issues/65 +- [ ] P15 — Publish `@layerfs/*` to npm: https://github.com/Ephemeral-AI-Lab/layerfs-connect/issues/66 + +## Execution records + +- Design: `docs/superpowers/specs/2026-10-28-area-p-design.md` +- Foundation plan: `docs/superpowers/plans/2026-10-28-area-p-foundation.md` +- Local ultragoal plan: `area-p-20261028` + +Real Store/FUSE verification runs on Linux with Disposable/WAL/synchronous=OFF only. R7 push/pull remains an external dependency and will not be simulated. diff --git a/docs/superpowers/plans/2026-10-28-area-p-foundation.md b/docs/superpowers/plans/2026-10-28-area-p-foundation.md new file mode 100644 index 000000000..22e7169bf --- /dev/null +++ b/docs/superpowers/plans/2026-10-28-area-p-foundation.md @@ -0,0 +1,220 @@ +# LayerFS Area P Foundation Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Establish a reproducible Linux build/test environment, project-local build MCP and skills, source recovery points, and a sealed E2E carrier for Node, Python, CLI and MCP before Area P product code. + +**Architecture:** Windows hosts editing and the build MCP; a dedicated Ubuntu WSL2 distribution runs the repository's existing `tools/worktree.py`; Docker Desktop supplies pinned Linux build/runtime/sandbox images. The build MCP is a typed, allowlisted adapter over that launcher, while the launcher is extended in place to seal Python/MCP payloads and execute allowlisted Python clients. + +**Tech Stack:** Ubuntu WSL2, Docker Engine 29/API 1.52, Rust 1.85.1 in Docker, Node 24, Python 3, official `@modelcontextprotocol/server` 2.x + Zod 4, existing Python worktree tooling, Node test runner. + +**Spec:** `docs/superpowers/specs/2026-10-28-area-p-design.md` + +## Global Constraints + +- Commit and push only to private `Ephemeral-AI-Lab/layerfs-area-p` on `area-p/p1-p15`; never commit or push to upstream LayerFS. +- Baseline is `582bc786f9e17a6993a147cc165e3e4a75366a9e`; local tag `area-p/baseline-582bc786` and `E:/layerfs-connect/backups/layerfs-area-p-baseline-582bc786.bundle` are the recovery points. +- Use Ubuntu WSL2 distribution name `Ubuntu`, launcher user `root`, checkout `/mnt/e/layerfs-connect/layerfs`, and resource state under `/root/.cache/layerfs-worktrees`. +- Exact base tags are `rust:1.85.1-bookworm` and `node:24.21.0-bookworm`; registration uses resolved immutable IDs and never pulls implicitly. +- Builds are `--locked --offline` after explicit `prepare`; no third-party source patches or global Docker prune. +- Real Stores use Disposable/WAL/synchronous=OFF only and live in per-run Docker volumes on Linux; Durable is NOT_RUN. +- Every test invocation has an explicit timeout <=120 seconds; build tests first and treat timeout as failure. +- Do not create a pre-push gate, arbitrary-shell MCP tool, automatic retry, automatic rollback, or another build framework. +- Each commit records production LOC before/after/delta using `python3 -B tools/production_loc.py --json`; tools/docs/tests normally leave product LOC unchanged. + +## Review Focus + +- **Wrong repository:** a routine push must not target upstream LayerFS — Task 1 asserts remote names/URLs and disabled upstream push URL. +- **Windows Store backing:** a run must not put SQLite on `/mnt/e` — Tasks 1 and 4 inspect launcher resource/run receipts and Store volume mounts. +- **MCP command injection:** arguments such as `--workspace`, shell metacharacters or another worktree must be refused — Task 2 tests exact argv arrays without a shell. +- **Cross-client artifact drift:** a Python/MCP payload absent from or altered within a sealed bundle must fail closed — Task 3 tests namespace/hash verification. +- **False E2E success:** a timed-out, unreleased or wrong-image run must not report PASS — Task 4 checks exit code, status and `released` in each receipt. + +--- + +### Task 1: Linux Environment and Recovery Baseline + +**Files:** +- Create: `tools/docker/area-p-runtime.Dockerfile` +- Create: `tools/docker/README.md` +- Modify: `.gitignore` only if the runtime recipe generates a local file not already ignored + +**Interfaces:** +- Produces: local image `layerfs-area-p-runtime:node24-python3`, a printed `resources.json` path, and verified `origin`/`upstream` remotes consumed by Tasks 2–4. + +- [ ] **Step 1: Prove the current recovery state** + +Run: +```bash +git -C E:/layerfs-connect/layerfs status --short --branch +git -C E:/layerfs-connect/layerfs remote -v +git -C E:/layerfs-connect/layerfs bundle verify E:/layerfs-connect/backups/layerfs-area-p-baseline-582bc786.bundle +``` +Expected: branch `area-p/p1-p15`, bundle contains baseline tag, `origin` is the private Area P repository, `upstream` is public LayerFS. Then run `git -C E:/layerfs-connect/layerfs remote set-url --push upstream DISABLED_NO_UPSTREAM_PUSH` and confirm no upstream push URL remains usable. + +- [ ] **Step 2: Install and qualify the dedicated WSL control plane** + +Run `wsl.exe --install -d Ubuntu --no-launch`. If Windows requires restart or interactive setup, stop this task and report that exact blocker; do not alter Podman/Docker utility distributions. After installation: +```bash +wsl.exe -d Ubuntu -u root -- sh -lc 'apt-get update && apt-get install -y --no-install-recommends python3 lsof git ca-certificates && python3 --version && docker version && test -d /mnt/e/layerfs-connect/layerfs' +``` +Expected: Python 3, Docker Engine API >=1.48, and the checkout visible at the pinned path. + +- [ ] **Step 3: Write the runtime recipe and its documentation** + +`area-p-runtime.Dockerfile` starts `FROM node:24.21.0-bookworm` and installs only `git`, `procps`, `lsof`, `python3`, `python3-venv`, and `python3-pip` with apt lists removed. `tools/docker/README.md` records exact build/pull commands, the no-Durable rule, and that FUSE capability comes from the product's Docker configuration rather than an unqualified image claim. + +- [ ] **Step 4: Pull/build and pin all image identities** + +Run in Ubuntu: +```bash +docker pull rust:1.85.1-bookworm +docker pull node:24.21.0-bookworm +docker build --file /mnt/e/layerfs-connect/layerfs/tools/docker/area-p-runtime.Dockerfile --tag layerfs-area-p-runtime:node24-python3 /mnt/e/layerfs-connect/layerfs/tools/docker +docker image inspect rust:1.85.1-bookworm node:24.21.0-bookworm layerfs-area-p-runtime:node24-python3 --format '{{.RepoTags}} {{.Id}}' +``` +Expected: all images local; registration subsequently uses these exact resolved IDs. + +- [ ] **Step 5: Register and prepare private build resources** + +Run: +```bash +python3 /mnt/e/layerfs-connect/layerfs/tools/worktree.py register --worktree /mnt/e/layerfs-connect/layerfs --root /root/.cache/layerfs-worktrees --owner area-p --build-image rust:1.85.1-bookworm --runtime-image layerfs-area-p-runtime:node24-python3 --sandbox-image layerfs-area-p-runtime:node24-python3 +python3 /mnt/e/layerfs-connect/layerfs/tools/worktree.py prepare --resources --platform linux +``` +Expected: a registration whose source is the pinned checkout, Linux Cargo/target volumes are private, dependencies are fetched only in `prepare`, and the printed resource path is recorded in `.omc/ultragoal/plans/area-p-20261028/ledger.jsonl` evidence. + +- [ ] **Step 6: Run existing cheap baselines under explicit timeouts** + +Run in Ubuntu with `timeout 100` for each test command: +```bash +cd /mnt/e/layerfs-connect/layerfs +timeout 100 python3 -B -m unittest tools.test_worktree +timeout 100 docker run --rm --pull never -v /mnt/e/layerfs-connect/layerfs:/work:ro -w /work layerfs-area-p-runtime:node24-python3 node api/agent/test/calls.mjs +timeout 100 docker run --rm --pull never -v /mnt/e/layerfs-connect/layerfs:/work:ro -w /work layerfs-area-p-runtime:node24-python3 node --test api/cli/test/cli.test.mjs +``` +Expected: all exit 0 within 100 seconds; otherwise retain output and diagnose before product work. + +- [ ] **Step 7: Commit the environment recipe** + +Run the production LOC counter before staging and against the staged tree, then commit only Task 1 files with `build: add Area P Linux runtime recipe`, the LOC comparison and `Co-Authored-By: Claude Code `. Push is a separate user-approved operation subject to the OMC Git guardrail. + +### Task 2: Project Build MCP and Build/Rollback Skills + +**Files:** +- Create: `tools/build-mcp/package.json` +- Create: `tools/build-mcp/package-lock.json` +- Create: `tools/build-mcp/src/commands.js` +- Create: `tools/build-mcp/src/server.js` +- Create: `tools/build-mcp/test/commands.test.js` +- Create: `tools/build-mcp/test/stdio.test.js` +- Create: `.mcp.json` +- Create: `.claude/skills/layerfs-build/SKILL.md` +- Create: `.claude/skills/layerfs-rollback/SKILL.md` + +**Interfaces:** +- Consumes: Task 1 `resources.json` and pinned Ubuntu/checkout. +- Produces: MCP tools `layerfs_status`, `layerfs_prepare`, `layerfs_build`, `layerfs_cargo_test`, and `layerfs_checkpoint`; `createOperations({platform, resources, runner})` is the injectable command boundary. + +- [ ] **Step 1: Write failing command-validation tests** + +Test `createOperations` with an injected runner. Assert exact argv for status/prepare/build/cargo test; assert build defaults to Linux debug with jobs 2; assert cargo tests require `-p` and a named `--test` target and use `--timeout 90`; assert `--workspace`, `--manifest-path`, `--target-dir`, `fs-bench-pro`, shell metacharacters, alternate resources paths and timeout >100 are refused before the runner is called. Assert Windows commands use `wsl.exe -d Ubuntu -u root --cd /mnt/e/layerfs-connect/layerfs` and never a shell command string. + +- [ ] **Step 2: Run the command tests and confirm failure** + +Run `node --test tools/build-mcp/test/commands.test.js` under a 100-second parent timeout. Expected: FAIL because `src/commands.js` does not exist. + +- [ ] **Step 3: Implement the typed command adapter** + +`commands.js` exports `createOperations({platform = process.platform, resources, runner})`. Each operation returns `{status, stdout, stderr, receipt?}` from an injected spawn runner; no `exec`, shell string, arbitrary cwd, retry, Docker cleanup or Git mutation. `layerfs_checkpoint` runs only read-only Git commands and writes a timestamped binary diff plus status/revision manifest beneath `E:/layerfs-connect/backups` (or its Ubuntu-mounted equivalent), reporting untracked files separately rather than pretending they are in the diff. + +- [ ] **Step 4: Implement and test the stdio MCP surface** + +Use official `@modelcontextprotocol/server` 2.x and `zod` 4. `server.js` registers the five tools with strict schemas and uses `serveStdio`; stdout is protocol only and logs use stderr. `stdio.test.js` spawns the server, performs `initialize` and `tools/list`, asserts exactly the five tools, invokes invalid input and expects a tool error without a spawned build. Pin dependencies in the lockfile. + +- [ ] **Step 5: Configure the MCP and write both skills** + +`.mcp.json` starts the server with Windows Node and supplies `LAYERFS_BUILD_RESOURCES=/root/.cache/layerfs-worktrees//resources.json` using the actual Task 1 path. The build skill documents Ubuntu execution, build-before-test, Disposable-only, timeout and receipt rules. The rollback skill documents baseline bundle verification, non-destructive checkpoints, exact-target inspection, and that restore/reset/clean/teardown require explicit approval. + +- [ ] **Step 6: Run MCP and documentation checks** + +Run both Node test files with `node --test` under a 100-second parent timeout. Run `git diff --check` and validate `.mcp.json`/package JSON parsing. Expected: PASS, five tools listed, no protocol logs on stdout. + +- [ ] **Step 7: Commit the MCP and skills** + +Record production LOC before/after and commit Task 2 files as `tools: add Area P build MCP and recovery skills` with the required attribution. + +### Task 3: Seal Python and MCP E2E Payloads + +**Files:** +- Modify: `tools/worktree.py:15-52` +- Modify: `tools/worktree_build.py:136-196` +- Modify: `tools/worktree_run.py:78-183` +- Modify: `tools/test_worktree.py` in the existing copy/verify/run test groups + +**Interfaces:** +- Consumes: existing `copy_sdk(root, bundle)`, `verify_bundle(path)`, and launcher `arguments`. +- Produces: optional `api/python` and `api/mcp` payload copies when those directories exist, and `run` accepting only `node` or `python3` with a relative script contained in the sealed bundle. + +- [ ] **Step 1: Add failing bundle and command tests** + +Extend existing tests to create temporary `api/python` and `api/mcp` source trees. Assert their files are copied, included in the hash namespace, reject symlinks/path escape and fail verification when altered. Parameterize command validation: `node api/agent/test/...` and `python3 api/python/test/...` are accepted; absolute paths, `..`, `/bin/sh`, `python`, environment assignments and arbitrary host scripts are refused. + +- [ ] **Step 2: Run the selected tests and confirm failure** + +Run `timeout 100 python3 -B -m unittest tools.test_worktree` in Ubuntu. Expected: only the new payload/command cases FAIL; record the exact failures. + +- [ ] **Step 3: Implement minimal payload and command validation changes** + +Keep the existing bundle version and hash/ownership model. Add the two optional source directories without shipping caches/targets/symlinks. Validate interpreter (`node` or `python3`), exactly one relative bundle-contained script plus arguments, and retain all existing image, resource, timeout and per-run ownership checks. Do not add a generic executable selector. + +- [ ] **Step 4: Run launcher self-tests and guards** + +Run: +```bash +timeout 100 python3 -B -m unittest tools.test_worktree +timeout 100 python3 -B -m unittest discover -s tools -p 'test_*.py' +``` +Expected: PASS within each 100-second ceiling. Diagnose any unrelated pre-existing failure separately rather than weakening its assertion. + +- [ ] **Step 5: Commit the launcher extension** + +Record production LOC before/after and commit Task 3 files as `test: seal Python and MCP E2E payloads` with the required attribution. + +### Task 4: Prove the Sealed Linux Build and Real E2E + +**Files:** +- Create: `docs/superpowers/evidence/2026-10-28-area-p-foundation.md` +- Modify: `docs/general/parallel-worktrees.md` only to document the new Python/MCP payload and `python3` command support + +**Interfaces:** +- Consumes: Tasks 1–3 resources, MCP operations and sealed bundle behavior. +- Produces: one sealed Linux daemon/addon/SDK bundle and passing real Store/Workspace receipts used as the Area P starting gate. + +- [ ] **Step 1: Build the sealed Linux bundle** + +Run `python3 tools/worktree.py build --resources --platform linux --profile debug --jobs 2` in Ubuntu. Expected: a sealed bundle with matching daemon, addon and SDK source identity; no test timeout is applied to compilation, but the build is not left unattended. + +- [ ] **Step 2: Run the existing real Store scenario** + +Run `python3 tools/worktree.py run --resources --bundle --timeout 90 -- node api/agent/test/verify/calls_real.mjs inner store`. Expected: process exit 0, receipt `status=passed`, `released=true`, and output reports all Store calls passed. + +- [ ] **Step 3: Run the existing real Workspace scenario** + +Run the same launcher with `inner workspace`. Expected: exit 0, `status=passed`, `released=true`, and actual mount/Exec/Commit/unmount output. On failure preserve the exact stopped containers, volume and receipt for diagnosis; no automatic retry or teardown. + +- [ ] **Step 4: Run CLI agreement against real artifacts** + +Run `node api/cli/test/verify/cli_real.mjs inner store` and then `inner workspace`, each as a separate <=90-second launcher call. Expected: SDK and CLI answers agree and both receipts pass/release cleanly. + +- [ ] **Step 5: Record the evidence and starting gate** + +The evidence document records exact source/image/bundle IDs, commands, output summaries, receipt paths, Store profile and any gaps. Explicitly state Python/MCP real scenario rows are carrier-qualified only until their product packages exist; do not claim P4/P5 complete. Run `git diff --check`. + +- [ ] **Step 6: Commit the foundation evidence** + +Record production LOC before/after and commit Task 4 files as `docs: record Area P foundation verification` with the required attribution. Checkpoint ultragoal G002 only after all applicable steps pass or record the exact blocker. + +## Execution Handoff + +After this foundation, write separate implementation plans for (1) P1/P2/P6/P14 and the shared history contract, (2) P7–P13 engine/API features, (3) P3 CLI, (4) P4 Python, (5) P5 MCP, and (6) P15/final cross-client release. R7 remains an external blocker for push/pull and must be shown explicitly in the final matrix. diff --git a/docs/superpowers/specs/2026-10-28-area-p-design.md b/docs/superpowers/specs/2026-10-28-area-p-design.md new file mode 100644 index 000000000..aeca410d4 --- /dev/null +++ b/docs/superpowers/specs/2026-10-28-area-p-design.md @@ -0,0 +1,208 @@ +# LayerFS Area P implementation design + +Date: 2026-10-28 +Status: Approved for implementation with the repository and publishing constraints below +Baseline: `582bc786f9e17a6993a147cc165e3e4a75366a9e` from upstream `main` +Code repository: private `Ephemeral-AI-Lab/layerfs-area-p`; upstream `Ephemeral-AI-Lab/layerfs` is read-only +Tracking: `layerfs-connect#14`, P1/P3–P15 sub-issues, and `layerfs#484` for P2 + +## 1. Intent + +Make the LayerFS API usable consistently from Node, Python, the CLI and MCP without weakening the filesystem, history or execution contracts. The immediate foundation is a reproducible development environment on the current Windows host, with Linux builds and real Store/FUSE tests running through the existing Docker Desktop engine. Product work starts only after that foundation and this design are approved. + +The intended final demonstration is a shared behavioral matrix: each client mounts a Workspace, executes a command, explicitly commits, reads history, and pushes or pulls. P4 requires Python parity with the Node SDK; CLI and MCP expose the operations named by P3/P5 plus any explicitly shared contracts. P6 tests the overlapping surface rather than inventing CLI/MCP operations that their issues do not request. A client that cannot demonstrate a row because an upstream dependency is unfinished is reported as blocked, never simulated or marked complete. + +## 2. Core understanding that constrains the design + +The dependency direction is `core < engine < api`: + +- **Content** owns canonical object identity, complete-file and filesystem construction, bounded logical reads and merge algorithms. It opens no database and delegates object consumption/readback through ports (`core/crates/layerfs-content/src/lib.rs`). +- **Storage** owns authenticated encoding, packs, deduplicated Save/Read flows and physical locators. It accepts finalized Content objects; it does not construct filesystem history (`core/crates/layerfs-storage/src/lib.rs`). +- **History** owns checked Branch/Commit/Layer/LayerStack identities and records, expected-head publication, fork/merge metadata and bounded queries (`core/crates/layerfs-history/src/lib.rs`). +- **Telemetry** owns bounded parent/child operation timing, not a public per-call API by itself (`core/crates/layerfs-telemetry/src/lib.rs`). +- **Engine** owns SQLite Persistence, Project/Store composition, Workspace/Overlay, FUSE, daemon, Sandbox, the public Rust `layerfs-api` surface and the Node addon. +- **API** contains the JavaScript packages only. The Node addon loads engine code; Python, CLI and MCP must reuse public behavior rather than create another Store implementation. + +A Store is one global SQLite database holding immutable content-addressed objects and mutable Branch history. A Branch is a writable sequence of Commits; publishing to main adds a Layer. A Workspace is a complete writable view over an immutable committed root plus its own mutable overlay. Mount does not copy the whole tree; Commit captures the current Workspace-wide frontier, not merely files written by one registered Exec. Exec never implicitly commits or unmounts. + +The current contract gives each mounted Workspace its own overlay SQLite file, copied from a prepared template at mount and deleted at terminal unmount. Older documents that describe one overlay per daemon or no Workspace database are historical where they conflict with current `AGENTS.md` and source. + +Store calls run directly in the caller's process on Linux. Windows and macOS use the authenticated loopback worker route. Workspace calls go to the daemon; Store calls do not. No retired `layerfs-server`, second database, or host-mediated Linux Store route will be introduced. + +All execution uses Disposable/WAL/synchronous=OFF only. Durable execution remains `NOT_RUN`. Operations are attempted once: no automatic retry, busy replay or guessed recovery after an uncertain result. + +## 3. Goals + +1. Establish a clean, reproducible Linux-first build and test environment before product code. +2. Add project-local build/rollback MCP and skill entrypoints that wrap existing ownership and timeout mechanisms rather than replacing them. +3. Implement P1–P15 through existing core/engine/API boundaries, sharing behavior and contract fixtures across clients. +4. Verify both cheap stand-in/contract behavior and real Linux Store/Docker/FUSE behavior. +5. Preserve exact evidence for unsupported, blocked and failed rows. +6. Prepare npm packages for P15, with actual publication separately approved. + +## 4. Non-goals and anti-goals + +- No PostgreSQL Store, remote object storage, cloud sandbox or implementation of Area R beyond what P3 explicitly consumes. +- No native Windows Store or FUSE implementation and no new general WSL distribution requirement for the initial environment. +- No standalone UI or demo bundle. No commit or push to upstream `Ephemeral-AI-Lab/layerfs`; all code commits and pushes target the private Area P repository. npm/PyPI publication occurs only after the relevant quality gates. +- No new abstraction that merely duplicates `tools/worktree.py`; no unrestricted shell exposed as a build MCP tool. +- No production fake clocks, test hooks in `src/`, inline tests, weakened assertions or automatic retries. +- No claim that CI green alone verifies a change; CI reports and does not gate. +- No silent implementation of unresolved D10/D11 or open R7 behavior under another name. + +## 5. Development environment decision + +### Recommended topology + +Use **Windows as the editor, a dedicated Ubuntu WSL2 distribution as the orchestration host, and Docker Desktop Linux as the authoritative build/E2E host**. The existing launcher calls `os.uname()` and therefore cannot run under Windows Python; a dedicated WSL control plane also preserves the same Linux paths for sibling Docker containers. + +- Windows supplies Git editing and the Claude/MCP host. Ubuntu WSL2 supplies Python 3, `lsof`, Docker CLI integration and a Linux home for launcher state. +- The exact `rust:1.85.1-bookworm` build image supplies the repository's required Rust toolchain. Neither Windows nor WSL needs a host Rust installation. Pulling this image is authorized as part of the requested environment setup. +- The exact `node:24.21.0-bookworm` image is the initial runtime and Sandbox base. A small Area P runtime derivative adds Python and test utilities for cross-client evidence; all three images are registered by immutable local image ID. The host's Node 25 is adequate for simple tooling but is not the compatibility baseline. +- The initial Sandbox uses the same Node 24 userland with the product test's existing Docker/FUSE capabilities. If the real scenario proves a missing userland requirement, the Area P image recipe records that requirement; no unqualified substitute image is selected silently. +- `tools/worktree.py` runs inside Ubuntu WSL2 and owns per-checkout Cargo homes/targets, sealed daemon+addon bundles, per-run Store volumes, container prefixes and retained receipts. Linux builds remain offline and locked after the explicit prepare step. Writable Store/build state stays on Linux ext4; the `/mnt/e` checkout is source input, not Store backing. +- Linux is the required platform for real Store and FUSE E2E. Windows worker compatibility is a later scoped check, not a substitute for Linux evidence. + +This fits the repository better than modifying the existing Podman/Docker utility WSL distributions or adding an unsupported Windows launcher path. Docker Engine API 1.52 already satisfies the SDK's minimum. It also avoids a second native Windows build path that the product does not support. + +### Bootstrap sequence + +1. Verify the clean baseline and required Docker resources. +2. Pull/build the exact approved images and record immutable IDs. +3. Register this checkout with `tools/worktree.py` using an Area P owner and private state root outside the checkout. +4. Run the explicit network-enabled `prepare`, then locked/offline builds. +5. Run existing stand-in Node/CLI tests and tool self-tests. +6. Build the daemon/addon bundle and run the smallest real Store scenario, then the Workspace scenario. +7. Extend the sealed bundle/launcher, with owning tests, to carry the Python package and MCP server and to run allowlisted Python as well as Node client entrypoints. The cross-client runtime image includes both runtimes; this is infrastructure work, not a second E2E framework. +8. Only then start Area P product changes. + +Every test invocation has an explicit wall timeout no greater than 120 seconds and builds first where applicable. A timeout is a failed/hung test, not a reason to wait indefinitely or rerun unchanged. + +## 6. Build MCP and skills + +Add a **project-local, stdio build MCP server** for this checkout, configured locally rather than globally. It will expose a small typed command surface around the existing launcher: + +- inspect environment and current resource registration; +- prepare/build through the pinned resources; +- run an allowlisted package test or sealed E2E entrypoint with a bounded timeout; +- report sealed bundle/run receipts and retained failures; +- create a non-destructive source checkpoint and show rollback instructions. + +It will not expose arbitrary shell execution, global Docker prune, another worktree's resources, dependency cache deletion, automatic retry, Git reset/clean, push or npm publish. Before implementation, consult the official MCP SDK documentation and use the maintained SDK rather than hand-rolling JSON-RPC. + +Add project-local skills for: + +- **LayerFS build/test:** platform routing, image/resource setup, build-before-test, timeout and evidence rules; +- **LayerFS rollback/recovery:** baseline verification, patch/checkpoint creation, retained Docker failure custody and explicit rollback commands. + +The skills call the same existing launcher/MCP operations; they do not maintain a second build algorithm. If the MCP wrapper cannot preserve a launcher guarantee, that operation remains CLI-only rather than weakening the guarantee. + +## 7. Rollback and source custody + +The initial Git baseline is `582bc786f9e17a6993a147cc165e3e4a75366a9e`. The clone is shallow because the initial full clone failed in `index-pack` with an allocation error; the shallow clone completed successfully. This does not affect source verification, but no claim about full local history is made. + +Before product edits: + +- create an Area P feature branch without changing the baseline `main`; +- create a local baseline tag and an external Git bundle for recovery; +- push baseline and feature branches only to the private Area P `origin`; the public LayerFS remote is named `upstream` and has pushing disabled locally; +- keep generated Cargo/Docker state outside the source tree or in the repository's designated target path; +- save rollback instructions and the exact baseline outside the repository as well as in the ultragoal ledger. + +No destructive rollback runs automatically. Any `git restore`, `git reset`, `git clean`, retained volume removal or failed-run teardown requires inspecting the exact target and explicit approval. The user has authorized commits and pushes to the private Area P repository and final package publication after verification. Each commit message must include the repository's reproducible production LOC before/after/delta calculation and the required Claude Code attribution. No commit or push is made to upstream LayerFS. + +## 8. D10 and D11 recommendations + +These are selected implementation routes under the user's approved plan; recording them as global owner rulings in D7 remains outside this assignment. + +### D10 — Python SDK + +Choose a **native Python binding over the public Rust API on Linux**, with the existing worker transport on macOS and Windows. The initial packaging target is a manylinux x86_64 wheel for Python 3.10–3.13, using an ABI3 subset if the binding can remain within it; local source/wheel tests come before any additional architecture. P4 includes the PyPI wheel requirement, while P15 remains the npm publication task. + +A worker-only Linux SDK would move Store calls out of the caller's process and contradict the current Linux-only direct Store route. PyO3 is the likely implementation choice and is authorized as a new locked dependency if no existing crate supplies the binding; maturin may be used as build tooling after its official packaging documentation is checked. The macOS/Windows client implements the existing authenticated length-prefixed JSON worker frame protocol rather than inventing HTTP. Linux wheels and worker-client behavior share one contract suite, not two semantic implementations. + +### D11 — MCP server + +Choose **Node SDK + stdio first**. + +Node already implements the public client and worker transports. Stdio fits local agent integration, avoids a second HTTP authentication/authorization surface, and allows P5 to reuse P1/P2 directly. HTTP is outside the initial scope unless a concrete remote-MCP requirement is approved later. + +## 9. Implementation grouping and dependency order + +### Foundation and shared contracts + +Before each P1/P7–P14 coding slice, write its exact public method/argument/result/error/paging contract beside the P6 cases and have it independently reviewed. The existing one-line issues do not provide those details, so the builder may choose names that follow current SDK conventions; the reviewed contract, not an improvised implementation, freezes the fixture. This does not require a separate user approval round for routine naming. + +- **P6** defines language-neutral cases, normalization and refusal assertions. Start with existing `api/agent/test/fixture/cases.mjs` and the SDK/CLI agreement tests; extend rather than creating an unrelated fixture model. Python receives the corresponding Node SDK calls; CLI/MCP contract coverage is limited to their documented operation surface plus the final five-row matrix. +- **P2** resolves all constructor/open/init options once, with environment fallback and no later environment rereads. It is independent of the history/storage work and can proceed in parallel after the foundation. +- **P14** first reproduces short `list_branches` pages and the Branch-name loss past 64, then fixes the owning Store/query path without returning misleading partial names. + +### History and read surface + +- **P1** adds the Node history surface from existing History/engine primitives: graph, Layers, merge parents, fork points, publish state, Branch-only commits and changed paths. +- **P7** threads author/agent and free labels through Commit writes and history reads, preserving Store format compatibility rules and exact unchanged-history behavior. +- **P8** exposes list/stat/read at a Commit or Layer with explicit binary handling; it must not mount a Workspace or mutate the Branch head. +- **P3** exposes P1 history and R7 push/pull through the CLI. History can proceed after P1; push/pull completion remains blocked while R7 (#42) and its R2/R3 prerequisites are open. + +### Observation and scoping + +- **P9** starts as a live typed stream with explicit connection-local ordering, cursor and disconnect semantics; it adds no persisted event log or Store schema change. A reconnect receives current state according to the reviewed contract and is not promised historical replay. Store-originated and daemon-originated events retain their source identity. If cross-reconnect durable replay is later required, it becomes a separately reviewed Store format change. +- **P10** maps existing invocation outcomes and telemetry into method, duration, error code and write results without adding fake clocks or changing operation semantics. +- **P11** extends `get_storage` with disk/WAL, raw/unique/compressed and per-Commit-kind byte accounting. +- **P12** provides cheap project-list summary data: size, Branch/Commit counts, main Layer and last Commit. +- **P13** is an option/wrapper on the existing client, mirrored by Python where applicable. It maps direct file paths beneath one project folder and Branch names beneath one namespace, and selects the scoped Branch for Workspace calls. A mounted Workspace still exposes the complete tree; scoped Exec starts in the configured folder, but LayerFS does not pretend that the mount is a filtered filesystem. Unscoped clients retain their existing behavior. + +### Additional clients and release + +- **P4** follows the approved D10 route and reuses P6. +- **P5** follows the approved D11 route and exposes files, Bash, history and available push/pull operations with explicit lifecycle cleanup. +- **P15** removes private/file-link publication blockers as required, validates package contents and versions, and performs npm publication only after separate approval. + +Each implementation step updates the owning API/architecture documentation and adds cheap external tests before broad integration. New production files obey the 999-line ceiling; `lib.rs`/`mod.rs` obey the 200-line delegation ceiling. + +## 10. Verification strategy + +### Per-change checks + +Select only the owning scope, but include all affected layers: + +- Rust: format, locked build, warning-denying Clippy, external tests and boundary/fuser guards where applicable. +- Node/CLI: existing stand-in case tests, CLI/SDK agreement, packaging/trace/exec-output and new contract cases. +- Python: native unit/contract tests, wheel contents/import and worker transport tests according to platform. +- MCP: protocol initialize/list/call tests, argument/error mapping, stdio lifecycle and real client contract tests. +- Tools/MCP/skills: owning tool self-tests, allowlist/refusal tests, timeout/cancellation and receipt validation. + +### Real Linux evidence + +Use fresh isolated resources for each run. Run the existing real Store and Workspace scenarios before feature work and the affected scenarios after changes. The final matrix covers: + +| Capability | Node | Python | CLI | MCP | +| --- | --- | --- | --- | --- | +| Mount | real Linux Workspace | real Linux Workspace | real Linux Workspace | real Linux Workspace through MCP | +| Run command | exit/stdout contract | same contract | same contract | same contract | +| Explicit commit | resulting Commit/history identity | same | same | same | +| Read history | graph/metadata contract | same | same | same | +| Push or pull | actual R7-backed result or explicitly blocked | same | same | same | + +Unmount and client/server cleanup occur in `finally`/equivalent paths. Failed runs retain their exact containers, volumes, traces and receipts until diagnosed and explicitly released. + +### Final quality gate + +No story is complete on implementation alone. Final completion requires: + +1. all applicable changed-scope checks passing; +2. independent code review with findings resolved; +3. ai-slop-cleaner and simplification pass without weakening behavior; +4. the cross-client evidence matrix and explicit blockers/gaps; +5. a clean final verifier report. + +## 11. Resolved decisions and remaining external blocker + +- Code lands only in private `Ephemeral-AI-Lab/layerfs-area-p`; upstream LayerFS receives no commits or pushes. +- Windows editing with a dedicated Ubuntu WSL2 control plane and Docker-Linux builds/real E2E; the exact base tags are `rust:1.85.1-bookworm` and `node:24.21.0-bookworm`, with immutable IDs recorded after pulling/building. +- D10 implementation route: native Rust-backed Python on Linux plus the existing framed worker protocol on macOS/Windows. Initial wheel target is manylinux x86_64, Python 3.10–3.13; P4 includes PyPI, P15 covers npm. +- D11 implementation route: Node SDK with stdio MCP first, using the official MCP TypeScript SDK. +- The launcher/bundle may be extended with tested Python/MCP payload and allowlisted Python execution so all client rows produce real Linux evidence. +- P9 is live-only without Store schema changes; P13 scopes direct paths, Branch names and Exec working directory without filtering the mounted tree. +- R7 (#42) and its prerequisites remain outside Area P. Implement P3/P5 history and other unblocked calls, but keep push/pull and the corresponding final matrix cells explicitly blocked unless the user later expands the assignment. No fake transport or partial success claim is allowed. +- The user authorized commits/pushes to the private Area P repository and final publication after quality gates. The OMC unattended Git guardrail may still require the user to execute push commands interactively; it will not be bypassed. diff --git a/tools/build-mcp/package-lock.json b/tools/build-mcp/package-lock.json new file mode 100644 index 000000000..1be14ca97 --- /dev/null +++ b/tools/build-mcp/package-lock.json @@ -0,0 +1,53 @@ +{ + "name": "layerfs-area-p-build-mcp", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "layerfs-area-p-build-mcp", + "version": "1.0.0", + "dependencies": { + "@modelcontextprotocol/server": "2.4.0", + "zod": "4.6.7" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@modelcontextprotocol/core": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/core/-/core-2.4.0.tgz", + "integrity": "sha512-z8QBKqdfXLPzkLQuG4xp8hrVEMm1PZ1IgxMN2sMbJiFiAKOWXnAzogXn2W4aqIBlP1ea6U5yiqVquvUNM1Cj6Q==", + "license": "Apache-2.0", + "dependencies": { + "zod": "^4.2.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@modelcontextprotocol/server": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/server/-/server-2.4.0.tgz", + "integrity": "sha512-rgR9yxSaAJ+bcsi/XroT/S2AfPiIWloXAnrdfYSCms9ivVju0ishu/wW0J21Ho6+ZOR1r7RyeRbjiRuF87ix5Q==", + "license": "Apache-2.0", + "dependencies": { + "@modelcontextprotocol/core": "2.4.0", + "zod": "^4.2.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/zod": { + "version": "4.6.7", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.7.tgz", + "integrity": "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/tools/build-mcp/package.json b/tools/build-mcp/package.json new file mode 100644 index 000000000..113845cda --- /dev/null +++ b/tools/build-mcp/package.json @@ -0,0 +1,16 @@ +{ + "name": "layerfs-area-p-build-mcp", + "version": "1.0.0", + "private": true, + "type": "module", + "engines": { + "node": ">=20" + }, + "scripts": { + "test": "node --test test/*.test.js" + }, + "dependencies": { + "@modelcontextprotocol/server": "2.4.0", + "zod": "4.6.7" + } +} diff --git a/tools/build-mcp/src/commands.js b/tools/build-mcp/src/commands.js new file mode 100644 index 000000000..3105ad3bd --- /dev/null +++ b/tools/build-mcp/src/commands.js @@ -0,0 +1,120 @@ +import { spawn } from 'node:child_process'; +import { mkdir, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const repository = fileURLToPath(new URL('../../', import.meta.url)); +const windowsPrefix = ['-d', 'Ubuntu', '-u', 'root', '--cd', '/mnt/e/layerfs-connect/layerfs']; +const namePattern = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/; + +async function spawnRunner(command, args, { timeout }) { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { + cwd: repository, + shell: false, + stdio: ['ignore', 'pipe', 'pipe'], + }); + const stdout = []; + const stderr = []; + child.stdout.on('data', (chunk) => stdout.push(chunk)); + child.stderr.on('data', (chunk) => stderr.push(chunk)); + child.once('error', reject); + const timer = setTimeout(() => child.kill(), timeout); + child.once('close', (status) => { + clearTimeout(timer); + resolve({ + status, + stdout: Buffer.concat(stdout).toString('utf8'), + stderr: Buffer.concat(stderr).toString('utf8'), + }); + }); + }); +} + +function onlyKeys(input, allowed) { + if (!input || Object.getPrototypeOf(input) !== Object.prototype) throw new Error('Expected an options object'); + for (const key of Object.keys(input)) { + if (!allowed.includes(key)) throw new Error(`Unsupported option: ${key}`); + } +} + +function checkedName(value, label) { + if (typeof value !== 'string' || !namePattern.test(value)) throw new Error(`Invalid ${label}`); + if (value.includes('fs-bench-pro')) throw new Error('fs-bench-pro is frozen'); + return value; +} + +export function createOperations({ platform = process.platform, resources, runner = spawnRunner }) { + if (typeof resources !== 'string' || !path.posix.isAbsolute(resources) || !resources.endsWith('/resources.json')) { + throw new Error('A pinned absolute resources.json path is required'); + } + + function execute(command, args, timeout = 100000) { + return platform === 'win32' + ? runner('wsl.exe', [...windowsPrefix, command, ...args], { timeout }) + : runner(command, args, { timeout }); + } + + function launcher(action, args, timeout) { + return execute('python3', ['tools/worktree.py', action, '--resources', resources, ...args], timeout); + } + + return { + async status() { + const status = await execute('git', ['status', '--short', '--branch'], 10000); + const revision = await execute('git', ['rev-parse', 'HEAD'], 10000); + return { ...revision, stdout: status.stdout + revision.stdout, resources }; + }, + + async prepare() { + return launcher('prepare', ['--platform', 'linux']); + }, + + async build(input) { + onlyKeys(input, ['profile', 'jobs']); + const profile = input.profile ?? 'debug'; + const jobs = input.jobs ?? 2; + if (!['debug', 'release'].includes(profile)) throw new Error('Profile must be debug or release'); + if (!Number.isInteger(jobs) || jobs < 1 || jobs > 32) throw new Error('Jobs must be in [1, 32]'); + return launcher('build', ['--platform', 'linux', '--profile', profile, '--jobs', String(jobs)]); + }, + + async cargoTest(input) { + onlyKeys(input, ['package', 'testTarget', 'timeout']); + const packageName = checkedName(input.package, 'package'); + const testTarget = checkedName(input.testTarget, 'test target'); + const timeout = input.timeout ?? 90; + if (typeof timeout !== 'number' || !Number.isFinite(timeout) || timeout <= 0 || timeout > 100) { + throw new Error('Test timeout must be in (0, 100] seconds'); + } + return launcher('cargo', [ + '--platform', 'linux', '--timeout', String(timeout), '--', 'test', '-p', packageName, + '--test', testTarget, + ]); + }, + + async checkpoint() { + const status = await execute('git', ['status', '--porcelain=v1', '-z'], 10000); + const revision = await execute('git', ['rev-parse', 'HEAD'], 10000); + const diff = await execute('git', ['diff', '--binary', 'HEAD'], 10000); + for (const result of [status, revision, diff]) { + if (result.status !== 0) throw new Error(`Checkpoint Git command failed: ${result.stderr}`); + } + const root = platform === 'win32' ? 'E:/layerfs-connect/backups' : '/mnt/e/layerfs-connect/backups'; + const stamp = new Date().toISOString().replaceAll(':', '-').replaceAll('.', '-'); + const directory = path.join(root, `checkpoint-${stamp}`); + await mkdir(directory, { recursive: false }); + const patch = path.join(directory, 'changes.patch'); + await writeFile(patch, diff.stdout, { flag: 'wx' }); + const untracked = status.stdout.split('\0').filter((entry) => entry.startsWith('?? ')).map((entry) => entry.slice(3)); + const manifest = { + revision: revision.stdout.trim(), + patch: 'changes.patch', + untracked, + note: 'The binary diff excludes untracked files; review the exact target before restoring.', + }; + await writeFile(path.join(directory, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n', { flag: 'wx' }); + return { status: 0, stdout: directory + '\n', stderr: '', receipt: directory }; + }, + }; +} diff --git a/tools/build-mcp/src/server.js b/tools/build-mcp/src/server.js new file mode 100644 index 000000000..6e5cdba1f --- /dev/null +++ b/tools/build-mcp/src/server.js @@ -0,0 +1,34 @@ +import { McpServer } from '@modelcontextprotocol/server'; +import { serveStdio } from '@modelcontextprotocol/server/stdio'; +import { z } from 'zod'; + +import { createOperations } from './commands.js'; + +const server = new McpServer({ name: 'layerfs-area-p-build', version: '1.0.0' }); +const operations = createOperations({ resources: process.env.LAYERFS_BUILD_RESOURCES }); + +function register(name, description, inputSchema, operation) { + server.registerTool(name, { description, inputSchema }, async (input) => { + try { + const result = await operation(input); + return { content: [{ type: 'text', text: JSON.stringify(result) }] }; + } catch (error) { + return { content: [{ type: 'text', text: error.message }], isError: true }; + } + }); +} + +register('layerfs_status', 'Inspect registered resources, checkout status and revision', {}, () => operations.status()); +register('layerfs_prepare', 'Fetch dependencies into this checkout’s pinned Linux caches', {}, () => operations.prepare()); +register('layerfs_build', 'Build and seal the pinned Linux SDK bundle', { + profile: z.enum(['debug', 'release']).optional(), + jobs: z.number().int().min(1).max(32).optional(), +}, (input) => operations.build(input)); +register('layerfs_cargo_test', 'Build then run one named Cargo integration test on Linux', { + package: z.string(), + testTarget: z.string(), + timeout: z.number().positive().max(100).optional(), +}, (input) => operations.cargoTest(input)); +register('layerfs_checkpoint', 'Write a non-destructive binary diff and revision manifest', {}, () => operations.checkpoint()); + +await serveStdio(server); diff --git a/tools/build-mcp/test/commands.test.js b/tools/build-mcp/test/commands.test.js new file mode 100644 index 000000000..6aa6cf97f --- /dev/null +++ b/tools/build-mcp/test/commands.test.js @@ -0,0 +1,94 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; + +import { createOperations } from '../src/commands.js'; + +const resources = '/root/.cache/layerfs-worktrees/area-p/resources.json'; +const windowsPrefix = [ + 'wsl.exe', '-d', 'Ubuntu', '-u', 'root', '--cd', '/mnt/e/layerfs-connect/layerfs', +]; + +function fixture(platform = 'linux') { + const calls = []; + const operations = createOperations({ + platform, + resources, + runner: async (command, args, options) => { + calls.push({ command, args, options }); + return { status: 0, stdout: 'receipt\n', stderr: '' }; + }, + }); + return { calls, operations }; +} + +test('status reads the registered checkout without a shell or mutable Git command', async () => { + const { calls, operations } = fixture('win32'); + const result = await operations.status(); + assert.deepEqual(calls[0], { + command: 'wsl.exe', + args: [...windowsPrefix.slice(1), 'git', 'status', '--short', '--branch'], + options: { timeout: 10000 }, + }); + assert.deepEqual(calls[1].args, [...windowsPrefix.slice(1), 'git', 'rev-parse', 'HEAD']); + assert.equal(result.status, 0); + assert.equal(result.resources, resources); +}); + +test('prepare selects the pinned Linux resources', async () => { + const { calls, operations } = fixture(); + await operations.prepare(); + assert.deepEqual(calls, [{ + command: 'python3', + args: ['tools/worktree.py', 'prepare', '--resources', resources, '--platform', 'linux'], + options: { timeout: 100000 }, + }]); +}); + +test('build defaults to Linux debug with two jobs', async () => { + const { calls, operations } = fixture(); + await operations.build({}); + assert.deepEqual(calls[0].args, [ + 'tools/worktree.py', 'build', '--resources', resources, '--platform', 'linux', + '--profile', 'debug', '--jobs', '2', + ]); +}); + +test('build accepts only release or debug and positive bounded jobs', async () => { + const { calls, operations } = fixture(); + await assert.rejects(operations.build({ profile: 'durable' })); + await assert.rejects(operations.build({ jobs: 0 })); + await assert.rejects(operations.build({ jobs: 33 })); + await assert.rejects(operations.build({ platform: 'macos' })); + assert.equal(calls.length, 0); +}); + +test('cargo test compiles and runs one named package test with a 90 second timeout', async () => { + const { calls, operations } = fixture('win32'); + await operations.cargoTest({ package: 'layerfs-api', testTarget: 'api_contract', timeout: 90 }); + assert.deepEqual(calls[0].args, [ + ...windowsPrefix.slice(1), 'python3', 'tools/worktree.py', 'cargo', '--resources', resources, + '--platform', 'linux', '--timeout', '90', '--', 'test', '-p', 'layerfs-api', '--test', + 'api_contract', + ]); + assert.deepEqual(calls[0].options, { timeout: 100000 }); +}); + +test('cargo test refuses workspace, path and frozen benchmark overrides before spawning', async () => { + const { calls, operations } = fixture(); + const base = { package: 'layerfs-api', testTarget: 'api_contract', timeout: 90 }; + for (const extra of [ + { workspace: true }, + { manifestPath: 'Cargo.toml' }, + { targetDir: 'target-other' }, + { package: 'fs-bench-pro' }, + { testTarget: 'api;whoami' }, + { package: 'layerfs-api;whoami' }, + { resources: '/tmp/other/resources.json' }, + { timeout: 101 }, + { timeout: 0 }, + { args: ['--workspace'] }, + ]) { + await assert.rejects(operations.cargoTest({ ...base, ...extra })); + } + assert.equal(calls.length, 0); +}); diff --git a/tools/build-mcp/test/stdio.test.js b/tools/build-mcp/test/stdio.test.js new file mode 100644 index 000000000..2cac96cc5 --- /dev/null +++ b/tools/build-mcp/test/stdio.test.js @@ -0,0 +1,70 @@ +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import { test } from 'node:test'; + +function request(child, pending, message) { + return new Promise((resolve, reject) => { + pending.set(message.id, { resolve, reject }); + child.stdin.write(JSON.stringify(message) + '\n'); + }); +} + +test('stdio exposes exactly five tools and rejects unsafe input without a build', async (t) => { + const child = spawn(process.execPath, ['src/server.js'], { + cwd: new URL('..', import.meta.url), + env: { ...process.env, LAYERFS_BUILD_RESOURCES: '/root/.cache/layerfs-worktrees/area-p/resources.json' }, + stdio: ['pipe', 'pipe', 'pipe'], + }); + t.after(() => child.kill()); + const pending = new Map(); + child.once('exit', (code) => { + for (const waiter of pending.values()) waiter.reject(new Error(`MCP server exited with code ${code}: ${stderr}`)); + pending.clear(); + }); + const protocolLines = []; + let stderr = ''; + let buffer = ''; + child.stdout.setEncoding('utf8'); + child.stdout.on('data', (chunk) => { + buffer += chunk; + while (buffer.includes('\n')) { + const index = buffer.indexOf('\n'); + const line = buffer.slice(0, index); + buffer = buffer.slice(index + 1); + if (!line.trim()) continue; + const message = JSON.parse(line); + protocolLines.push(message); + const waiter = pending.get(message.id); + if (waiter) { + pending.delete(message.id); + waiter.resolve(message); + } + } + }); + child.stderr.setEncoding('utf8'); + child.stderr.on('data', (chunk) => { stderr += chunk; }); + + const initialized = await request(child, pending, { + jsonrpc: '2.0', id: 1, method: 'initialize', params: { + protocolVersion: '2026-07-28', capabilities: {}, clientInfo: { name: 'build-mcp-test', version: '1.0.0' }, + }, + }); + assert.equal(initialized.result.serverInfo.name, 'layerfs-area-p-build'); + child.stdin.write(JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' }) + '\n'); + + const listed = await request(child, pending, { jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} }); + assert.deepEqual(listed.result.tools.map((tool) => tool.name).sort(), [ + 'layerfs_build', 'layerfs_cargo_test', 'layerfs_checkpoint', 'layerfs_prepare', 'layerfs_status', + ]); + + const invalid = await request(child, pending, { + jsonrpc: '2.0', id: 3, method: 'tools/call', params: { + name: 'layerfs_cargo_test', + arguments: { package: 'layerfs-api', testTarget: 'api_contract', timeout: 90, workspace: true }, + }, + }); + assert.equal(invalid.result.isError, true); + assert.match(invalid.result.content[0].text, /Unsupported option/); + assert.equal(protocolLines.some((message) => message.method?.startsWith('notifications/message')), false); + assert.doesNotMatch(stderr, /tools\/worktree\.py/); +}); diff --git a/tools/docker/README.md b/tools/docker/README.md new file mode 100644 index 000000000..955f71984 --- /dev/null +++ b/tools/docker/README.md @@ -0,0 +1,18 @@ +# Area P Linux images + +The Area P development environment uses a dedicated Ubuntu WSL2 control plane and Docker Desktop's Linux engine. The checkout remains at `/mnt/e/layerfs-connect/layerfs`; writable Stores and launcher state stay on Linux filesystems. + +Build the images before registering `tools/worktree.py` resources: + +```bash +docker pull rust:1.85.1-bookworm +docker pull node:24.21.0-bookworm +docker build \ + --file /mnt/e/layerfs-connect/layerfs/tools/docker/area-p-runtime.Dockerfile \ + --tag layerfs-area-p-runtime:node24-python3 \ + /mnt/e/layerfs-connect/layerfs/tools/docker +``` + +The runtime image supplies Node 24 and Python 3 for sealed cross-client tests. Registration resolves installed images to immutable IDs and never pulls them implicitly. FUSE capability comes from the product's Docker configuration and the real mount tests; this recipe alone is not FUSE qualification. + +All Stores use Disposable/WAL/synchronous=OFF only. Durable execution is NOT_RUN. Builds fetch dependencies only during explicit `prepare`; ordinary builds are locked and offline. diff --git a/tools/docker/area-p-runtime.Dockerfile b/tools/docker/area-p-runtime.Dockerfile new file mode 100644 index 000000000..039deea97 --- /dev/null +++ b/tools/docker/area-p-runtime.Dockerfile @@ -0,0 +1,11 @@ +FROM node:24.21.0-bookworm + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + git \ + lsof \ + procps \ + python3 \ + python3-pip \ + python3-venv \ + && rm -rf /var/lib/apt/lists/* From be5763595cef58d3eb1838df4efbf6cedbfc1c1c Mon Sep 17 00:00:00 2001 From: Mola-maker <2249464964@qq.com> Date: Thu, 29 Oct 2026 11:25:16 +0800 Subject: [PATCH 02/39] P2: JS SDK constructor options (layerfs#484) LayerFS.open(name, options?) and LayerFS.init(name, folder, timeout?, options?) accept one trailing options object. The constructor stays factory-gated. Settings resolve once at construction: explicit option wins, else LAYERFS_* env, else the built-in default; the result is a deep snapshot, so mutating the caller's object or the environment after construction changes nothing. deployment() no longer re-reads the environment. LAYERFS_TRACE_STEP and LAYERFS_TRACE_START stay per-call. An object in the timeout position stays refused, as do extra positional arguments; malformed options are refused InvalidInput before any binding call. Production LOC: 102763 -> 102817 (delta +54); core+engine+api via python -I tools/production_loc.py --json; reference absent (retired R9c; last counted 65,417 at tree 498dd1917812). Co-Authored-By: Claude Code --- api/agent/src/client.js | 18 ++--- api/agent/src/index.js | 124 +++++++++++++++++++++++++++-------- api/agent/test/packaging.mjs | 70 ++++++++++++++++++-- 3 files changed, 168 insertions(+), 44 deletions(-) diff --git a/api/agent/src/client.js b/api/agent/src/client.js index 7e1ae74a9..a212d146b 100644 --- a/api/agent/src/client.js +++ b/api/agent/src/client.js @@ -58,18 +58,18 @@ export function sandboxArg(sandbox) { return sandbox; } -/** Deployment settings from the client options, else the environment - * (LAYERFS_). A Workspace's Sandbox needs +/** Deployment settings from the client's resolved options (the constructor + * already applied the environment fallback and defaults, so nothing here + * re-reads the environment). A Workspace's Sandbox needs * every one but the container prefix and daemon host. */ export function deployment(options = {}) { - const env = (name) => process.env[`LAYERFS_${name}`] || undefined; const deploy = { - docker: options.docker ?? env("DOCKER") ?? "/var/run/docker.sock", - image: options.image ?? env("IMAGE"), - volume: options.volume ?? env("VOLUME"), - daemon: options.daemon ?? env("DAEMON"), - containerPrefix: options.containerPrefix ?? env("CONTAINER_PREFIX"), - daemonHost: options.daemonHost ?? env("DAEMON_HOST"), + docker: options.docker ?? "/var/run/docker.sock", + image: options.image, + volume: options.volume, + daemon: options.daemon, + containerPrefix: options.containerPrefix, + daemonHost: options.daemonHost, }; for (const [name, value] of Object.entries(deploy)) { if (name === "containerPrefix" || name === "daemonHost") continue; diff --git a/api/agent/src/index.js b/api/agent/src/index.js index 446b59a53..745e8ad14 100644 --- a/api/agent/src/index.js +++ b/api/agent/src/index.js @@ -1,7 +1,8 @@ // @layerfs/agent (#428): what an agent's script calls. A client is opened by -// `LayerFS.open(name)` (an existing project) or `LayerFS.init(name, folder, -// timeout?)` (a new one) and released by `close()`; there is no public -// constructor and no options argument: settings come from the environment. +// `LayerFS.open(name, options?)` (an existing project) or `LayerFS.init(name, +// folder, timeout?, options?)` (a new one) and released by `close()`; there +// is no public constructor. Settings come from the options object, else the +// environment, resolved once at construction. // Calls name their Branch. `sandbox.create()` starts a Sandbox (the only // call that does) and `sandbox.close` stops it; `workspace.mount(sandbox, // branch)` returns a Workspace, `{ sandbox, path }`, in the Sandbox it names @@ -62,18 +63,90 @@ async function opened(client) { client[STATE].closed = false; } -/** The removed options argument (#431 follow-up) is refused, never ignored. */ -function noOptions(method, given) { - if (given) { - throw new LayerFSError("InvalidInput", `${method} takes no options: settings come from the environment (LAYERFS_STORE_PATH, LAYERFS_DAEMON_HOST, LAYERFS_TRACE, LAYERFS_TRACE_STEP, LAYERFS_STORE_POLICY)`, null); +/** True for a plain object (not null, not an array). */ +const isPlainObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value); + +/** One string option field: explicit value wins, else the environment. */ +function optionField(given, field, envName, env) { + const value = given?.[field]; + if (value === undefined) return env(envName); + if (typeof value !== "string" || !value) { + throw new LayerFSError("InvalidInput", `LayerFS option "${field}" must be a non-empty string`, null); } + return value; +} + +/** The client's settings, resolved once from options over environment. + * The result is a fresh snapshot: mutating the caller's object, or the + * environment, after construction changes nothing. LAYERFS_TRACE_STEP and + * LAYERFS_TRACE_START are not settings and are never snapshotted: trace.js + * reads them at each call. */ +function resolveOptions(given) { + if (given !== undefined && !isPlainObject(given)) { + throw new LayerFSError("InvalidInput", "LayerFS options must be a plain object", null); + } + for (const key of Object.keys(given ?? {})) { + if (!["home", "trace", "deploy", "storePolicy", "storePath"].includes(key)) { + throw new LayerFSError("InvalidInput", `unknown LayerFS option "${key}"`, null); + } + } + const env = (name) => process.env[`LAYERFS_${name}`] || undefined; + const deployGiven = given?.deploy; + if (deployGiven !== undefined && !isPlainObject(deployGiven)) { + throw new LayerFSError("InvalidInput", `LayerFS option "deploy" must be a plain object`, null); + } + for (const key of Object.keys(deployGiven ?? {})) { + if (!["docker", "image", "volume", "daemon", "containerPrefix", "daemonHost"].includes(key)) { + throw new LayerFSError("InvalidInput", `unknown LayerFS deploy option "${key}"`, null); + } + } + const deployField = (field, envName) => { + const value = deployGiven?.[field]; + if (value === undefined) return env(envName); + if (typeof value !== "string" || !value) { + throw new LayerFSError("InvalidInput", `LayerFS option "deploy.${field}" must be a non-empty string`, null); + } + return value; + }; + const containerPrefix = deployField("containerPrefix", "CONTAINER_PREFIX"); + const daemonHost = deployField("daemonHost", "DAEMON_HOST"); + let storePolicy = given?.storePolicy; + if (storePolicy === undefined) { + storePolicy = env("STORE_POLICY"); + } else if (typeof storePolicy === "string") { + if (!storePolicy) throw new LayerFSError("InvalidInput", `LayerFS option "storePolicy" must be JSON text or a plain object`, null); + } else if (isPlainObject(storePolicy)) { + try { + storePolicy = JSON.stringify(storePolicy); + } catch { + throw new LayerFSError("InvalidInput", `LayerFS option "storePolicy" must be JSON text or a plain object`, null); + } + } else { + throw new LayerFSError("InvalidInput", `LayerFS option "storePolicy" must be JSON text or a plain object`, null); + } + return { + home: optionField(given, "home", "HOME", env), + trace: optionField(given, "trace", "TRACE", env), + deploy: { + docker: deployField("docker", "DOCKER") ?? "/var/run/docker.sock", + image: deployField("image", "IMAGE"), + volume: deployField("volume", "VOLUME"), + daemon: deployField("daemon", "DAEMON"), + ...(containerPrefix ? { containerPrefix } : {}), + ...(daemonHost ? { daemonHost } : {}), + }, + storePolicy, + storePath: optionField(given, "storePath", "STORE_PATH", env), + }; } export class LayerFS { /** A ready client of the existing project `projectName`; the constructor lists its settings. */ static async open(projectName, ...rest) { - noOptions("LayerFS.open(name)", rest.some((value) => value !== undefined)); - const client = new LayerFS(projectName, FACTORY); + if (rest.length > 1 || (rest.length === 1 && rest[0] !== undefined && !isPlainObject(rest[0]))) { + throw new LayerFSError("InvalidInput", "LayerFS.open(name, options?) takes one options object at most", null); + } + const client = new LayerFS(projectName, FACTORY, rest[0]); await opened(client); return client; } @@ -84,8 +157,13 @@ export class LayerFS { * number; default INIT_TIMEOUT_MS, 300 s; no unlimited option) bounds * Init, which then fails `Failed` (Deadline) when it runs longer. */ static async init(projectName, folder, timeout = undefined, ...rest) { - noOptions("LayerFS.init(name, folder, timeout?)", (timeout !== null && typeof timeout === "object") || rest.some((value) => value !== undefined)); - const client = new LayerFS(projectName, FACTORY); + if (timeout !== null && typeof timeout === "object") { + throw new LayerFSError("InvalidInput", "LayerFS.init(name, folder, timeout?, options?) takes options as the fourth argument, not in the timeout position", null); + } + if (rest.length > 1 || (rest.length === 1 && rest[0] !== undefined && !isPlainObject(rest[0]))) { + throw new LayerFSError("InvalidInput", "LayerFS.init(name, folder, timeout?, options?) takes one options object at most", null); + } + const client = new LayerFS(projectName, FACTORY, rest[0]); const created = await init(client, folder, timeout); await opened(client); client.created = created; @@ -93,8 +171,10 @@ export class LayerFS { } /** Not public: `LayerFS.open` and `LayerFS.init` make clients. Every - * setting is read from the environment once, here, into `options`, by its - * LAYERFS_ name. LAYERFS_TRACE names a + * setting is resolved once, here, into `options`: an explicit option + * wins, else its LAYERFS_ name, else the built-in default. + * LAYERFS_TRACE_STEP and LAYERFS_TRACE_START are not settings: trace.js + * reads them at each call. LAYERFS_TRACE names a * per-call trace file; LAYERFS_TRACE_STEP, read at each call, labels its * lines. Deployment * settings read LAYERFS_HOME, _DOCKER, _IMAGE, _VOLUME and _DAEMON; a @@ -111,7 +191,7 @@ export class LayerFS { * LAYERFS_STORE_POLICY, a JSON object { smallFileThreshold, * wholeFileDepth, chunkDepth, metadataDepth }, is the Store policy * `LayerFS.init` records instead of the default. */ - constructor(projectName, token = undefined) { + constructor(projectName, token = undefined, givenOptions = undefined) { if (token !== FACTORY) { throw new LayerFSError("InvalidInput", "use `await LayerFS.open(name)` or `await LayerFS.init(name, folder)`; there is no public constructor", null); } @@ -119,21 +199,7 @@ export class LayerFS { throw new LayerFSError("InvalidInput", "a project name is required", null); } this.projectName = projectName; - const env = (name) => process.env[`LAYERFS_${name}`] || undefined; - this.options = { - home: env("HOME"), - trace: env("TRACE"), - deploy: { - docker: env("DOCKER"), - image: env("IMAGE"), - volume: env("VOLUME"), - daemon: env("DAEMON"), - ...(env("CONTAINER_PREFIX") ? { containerPrefix: env("CONTAINER_PREFIX") } : {}), - ...(env("DAEMON_HOST") ? { daemonHost: env("DAEMON_HOST") } : {}), - }, - storePolicy: env("STORE_POLICY"), - storePath: env("STORE_PATH"), - }; + this.options = resolveOptions(givenOptions); // not open until a factory has opened its Store; the binding is chosen now, once this[STATE] = { closed: null, transport: binding() }; this.fs = bind(this, fsMethods); diff --git a/api/agent/test/packaging.mjs b/api/agent/test/packaging.mjs index 496a616b9..74ab8842e 100644 --- a/api/agent/test/packaging.mjs +++ b/api/agent/test/packaging.mjs @@ -1,7 +1,7 @@ // @layerfs/agent's surface and client lifecycle (#428). Each case is one // command; every case but `dispatch` uses the stand-in binding and creates no // Store: -// node api/agent/test/packaging.mjs surface|lifecycle|copy|sandbox|worker-route|transport-order|dispatch +// node api/agent/test/packaging.mjs surface|lifecycle|options|copy|sandbox|worker-route|transport-order|dispatch // `dispatch` loads the real addon (LAYERFS_NATIVE or engine/crates/layerfs-node/layerfs.node). import assert from "node:assert/strict"; import { cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; @@ -79,12 +79,17 @@ if (selected === "surface") { await assert.rejects(LayerFS.open("website"), { code: "NoSuchProject" }); process.env.LAYERFS_STORE_PATH = "/fake/store.sqlite"; assert.deepEqual(native.stores.slice(before), [["open", "/missing"], ["close", "/missing"]]); - // the options argument is gone: an object where it went is refused, never ignored + // options are accepted (P2), but an object in the timeout position and any + // further positional argument are refused, never ignored const asked = native.calls.length; - for (const call of [() => LayerFS.open("website", {}), () => LayerFS.open("website", { storePath: "/missing" }), () => LayerFS.init("fresh", "/src/fresh", {}), () => LayerFS.init("fresh", "/src/fresh", { storePath: "/x" }, 5000), () => LayerFS.init("fresh", "/src/fresh", 5000, {})]) { - await assert.rejects(call(), { code: "InvalidInput", message: /takes no options: settings come from the environment/ }); + for (const call of [() => LayerFS.init("fresh", "/src/fresh", {}), () => LayerFS.init("fresh", "/src/fresh", { storePath: "/x" }, 5000), () => LayerFS.open("website", {}, "extra"), () => LayerFS.init("fresh", "/src/fresh", 5000, {}, "extra")]) { + await assert.rejects(call(), { code: "InvalidInput" }); } - assert.equal(native.calls.length, asked, "a refused options argument sends nothing"); + assert.equal(native.calls.length, asked, "a refused call sends nothing"); + // and an accepted options object opens fine, resolved over the environment + const accepted = await LayerFS.open("website", {}); + assert.equal(accepted.options.storePath, "/fake/store.sqlite"); + await accepted.close(); // LayerFS.init runs project.init, then the same open const made = await LayerFS.init("fresh", "/src/fresh"); assert.deepEqual(native.calls.slice(-2), ["project.init", "project.list_branches"]); @@ -106,6 +111,59 @@ if (selected === "surface") { await scoped.project.list_branches(); } assert.deepEqual(native.stores.at(-1), ["close", "/fake/store.sqlite"]); +} else if (selected === "options") { + // explicit beats env; env is the fallback for unset fields + const explicit = await LayerFS.open("website", { storePath: "/fake/store.sqlite", deploy: { image: "explicit-image" } }); + assert.deepEqual(explicit.options.deploy, { docker: "/configured/docker.sock", image: "explicit-image", volume: "configured-volume", daemon: "/configured/daemon" }); + assert.equal(explicit.options.home, "/private/configured-home"); + await explicit.close(); + // snapshot: mutating the input and the env after open changes nothing + const input = { deploy: { image: "snap-image" } }; + const snap = await LayerFS.open("website", input); + input.deploy.image = "mutated-image"; + process.env.LAYERFS_IMAGE = "env-changed-image"; + assert.equal(snap.options.deploy.image, "snap-image"); + // a second client still reads the (changed) env at its own construction + const later = await LayerFS.open("website", {}); + assert.equal(later.options.deploy.image, "env-changed-image"); + process.env.LAYERFS_IMAGE = "configured-image"; + await snap.close(); + await later.close(); + // schema refusals send nothing + const asked = native.calls.length; + for (const bad of [{ unknown: 1 }, { deploy: { unknown: "x" } }, { deploy: [] }, { deploy: "x" }, { storePath: null }, { storePath: "" }, { storePath: 7 }, { home: "" }, { trace: 5 }, { storePolicy: 7 }]) { + await assert.rejects(LayerFS.open("website", bad), { code: "InvalidInput" }); + } + assert.equal(native.calls.length, asked, "a refused options argument sends nothing"); + // storePolicy accepts an object (normalized to text) or text + const pol = await LayerFS.open("website", { storePolicy: { smallFileThreshold: 1, wholeFileDepth: 2, chunkDepth: 3, metadataDepth: 4 } }); + assert.equal(typeof pol.options.storePolicy, "string"); + await pol.close(); + // init forms: options as fourth argument accepted; object in timeout position refused + const made = await LayerFS.init("fresh", "/src/fresh", undefined, { storePath: "/fake/store.sqlite" }); + assert.equal(made.options.storePath, "/fake/store.sqlite"); + await made.close(); + const timed = await LayerFS.init("fresh", "/src/fresh", 5000, {}); + assert.equal(timed.options.storePath, "/fake/store.sqlite"); + await timed.close(); + await assert.rejects(LayerFS.init("fresh", "/src/fresh", {}), { code: "InvalidInput" }); + // TRACE_STEP and TRACE_START stay per-call: set after open, they label the next call + const dir = mkdtempSync(join(tmpdir(), "layerfs-options-trace-")); + try { + const traceFile = join(dir, "trace.jsonl"); + const traced = await LayerFS.open("website", { trace: traceFile }); + process.env.LAYERFS_TRACE_STEP = "after-open-step"; + process.env.LAYERFS_TRACE_START = "1"; + await traced.project.list_branches(); + const lines = readFileSync(traceFile, "utf8").trim().split("\n").map((l) => JSON.parse(l)); + assert.ok(lines.some((l) => l.phase === "start"), "TRACE_START read at the call"); + assert.ok(lines.some((l) => l.step === "after-open-step"), "TRACE_STEP read at the call"); + await traced.close(); + } finally { + delete process.env.LAYERFS_TRACE_STEP; + delete process.env.LAYERFS_TRACE_START; + rmSync(dir, { recursive: true, force: true }); + } } else if (selected === "copy") { // a second copy of the package in one process refuses to load const dir = mkdtempSync(join(tmpdir(), "layerfs-agent-copy-")); @@ -233,6 +291,6 @@ if (selected === "surface") { assert.equal((await binding.backendCall("project.init", {})).refused.code, "UnknownMethod"); assert.equal((await binding.backendCall("backend.missing", {})).refused.code, "UnknownMethod"); } else { - throw new Error("select surface, lifecycle, copy, sandbox, worker-route, transport-order or dispatch"); + throw new Error("select surface, lifecycle, options, copy, sandbox, worker-route, transport-order or dispatch"); } console.log(`PASS packaging/${selected}`); From a4cd2153e19ac1f60d9ddae8155f68b3e5b3bf8b Mon Sep 17 00:00:00 2001 From: Mola-maker <2249464964@qq.com> Date: Thu, 29 Oct 2026 11:26:00 +0800 Subject: [PATCH 03/39] P14: resolve list_workspaces Branch past 64 (branch paging verified correct) list_workspaces lost the Branch past 64 because the branch-name map in operator/inspect.rs was built from a single history page; the fix follows history continuations to build the complete map, including main, preserving the first-page output and moreBranches behavior. list_branches short pages were investigated and did not reproduce: the existing next_page (pos + items returned) was already correct, with a 1000-item request returning 85 items, hasMore true and nextPagePos 85, and continuation yielding all 130 branches exactly once, so no branch production code changed. Production LOC: 102763 -> 102779 (delta +16); core+engine+api via python -I tools/production_loc.py --json; reference absent (retired R9c; last counted 65,417 at tree 498dd1917812). Co-Authored-By: Claude Code --- api/agent/test/area-p.mjs | 77 +++++++ api/agent/test/fixture/cases-area-p.mjs | 118 +++++++++++ api/agent/test/fixture/fake-native.cjs | 35 +++- .../layerfs-api/tests/area_p14_paging.rs | 197 ++++++++++++++++++ .../src/operator/inspect.rs | 30 ++- 5 files changed, 442 insertions(+), 15 deletions(-) create mode 100644 api/agent/test/area-p.mjs create mode 100644 api/agent/test/fixture/cases-area-p.mjs create mode 100644 engine/crates/layerfs-api/tests/area_p14_paging.rs diff --git a/api/agent/test/area-p.mjs b/api/agent/test/area-p.mjs new file mode 100644 index 000000000..5f8455c8f --- /dev/null +++ b/api/agent/test/area-p.mjs @@ -0,0 +1,77 @@ +// P14 SDK paging over the stand-in binding. The real-Store coverage is +// engine/crates/layerfs-api/tests/area_p14_paging.rs. +// /usr/bin/node api/agent/test/area-p.mjs +import assert from "node:assert/strict"; +import { createRequire } from "node:module"; +import { fileURLToPath } from "node:url"; +import { ENV, contains, plain } from "./fixture/cases.mjs"; +import { BUDGET_BRANCH_NAMES, CASES } from "./fixture/cases-area-p.mjs"; + +const FAKE = fileURLToPath(new URL("./fixture/fake-native.cjs", import.meta.url)); +Object.assign(process.env, ENV, { LAYERFS_NATIVE: FAKE }); +delete process.env.CLI_FAKE_LOG; +const native = createRequire(import.meta.url)(FAKE); +const { LayerFS } = await import("../src/index.js"); +const lf = await LayerFS.open("site"); +const failures = []; + +for (const c of CASES) { + native.branchList = null; + native.branchReplyBudget = null; + native.workspaceList = []; + c.prepare(native); + const before = native.calls.length; + let result; + let error; + try { + result = await c.sdk(lf); + } catch (thrown) { + error = thrown; + } + try { + assert.equal(error, undefined, `threw ${error?.message}`); + assert.deepEqual(native.calls.slice(before), [c.op], "binding calls"); + assert.ok(contains(plain(native.args[before]), c.sent), `sent ${JSON.stringify(plain(native.args[before]))}`); + assert.deepEqual(plain(result), c.result); + } catch (failure) { + failures.push(`${c.name}: ${failure.message}`); + } +} + +native.branchList = BUDGET_BRANCH_NAMES; +native.branchReplyBudget = 48 * 1024; +const before = native.calls.length; +const found = []; +let first; +let pagePos = 0; +let pages = 0; +let terminated = false; +while (pages <= BUDGET_BRANCH_NAMES.length && !terminated) { + const page = await lf.project.list_branches(1000, pagePos); + first ??= page; + pages += 1; + found.push(...page.items.map((item) => item.name)); + if (page.nextPagePos === null) { + terminated = true; + } else { + pagePos = page.nextPagePos; + } +} +try { + assert.ok(first.items.length < 1000, "the reply budget makes a short page"); + assert.equal(first.hasMore, true); + assert.equal(first.nextPagePos, first.items.length); + assert.equal(terminated, true); + assert.deepEqual(found, BUDGET_BRANCH_NAMES); + assert.equal(native.calls.length - before, pages, "one binding call per page"); + assert.equal(pages, 2); +} catch (failure) { + failures.push(`P14 list_branches budget traversal: ${failure.message}`); +} + +await lf.close(); +if (failures.length) { + console.error(`FAIL area-p: ${failures.length} of ${CASES.length + 1}\n${failures.join("\n")}`); + process.exit(1); +} +console.log(`PASS area-p (${CASES.length} cases + 1 budget traversal)`); diff --git a/api/agent/test/fixture/cases-area-p.mjs b/api/agent/test/fixture/cases-area-p.mjs new file mode 100644 index 000000000..5e1362153 --- /dev/null +++ b/api/agent/test/fixture/cases-area-p.mjs @@ -0,0 +1,118 @@ +// P14 cases in the shared op/sent/result triple shape. `prepare` selects the +// stand-in's data; `sdk` makes the one binding call described by the triple. + +const branchNames = (count) => [ + ...Array.from({ length: count - 1 }, (_, n) => `branch-${String(n).padStart(3, "0")}`), + "main", +]; + +export const BUDGET_BRANCH_NAMES = [ + ...Array.from({ length: 129 }, (_, n) => `b${String(n).padStart(3, "0")}-${"x".repeat(58)}`), + "main", +]; + +const branchValue = (name) => ({ name, base: "base1234567890ab", head: null }); + +function branchPage(names, pageSize, pagePos, replyBudget = null) { + const items = []; + let spent = 0; + let hasMore = false; + for (const name of names.slice(pagePos)) { + const cost = Buffer.byteLength(name) + 512; + if (items.length === pageSize || (replyBudget !== null && spent + cost > replyBudget)) { + hasMore = true; + break; + } + spent += cost; + items.push(branchValue(name)); + } + return { items, hasMore, nextPagePos: hasMore ? pagePos + items.length : null }; +} + +function branchCase(names, pageSize, pagePos, replyBudget = null) { + const label = replyBudget === null ? `count=${names.length}` : "reply-budget"; + return { + name: `P14 list_branches ${label} pageSize=${pageSize} pagePos=${pagePos}`, + prepare(native) { + native.branchList = names; + native.branchReplyBudget = replyBudget; + }, + sdk: (lf) => lf.project.list_branches(pageSize, pagePos), + op: "project.list_branches", + sent: { pageSize, pagePos }, + result: branchPage(names, pageSize, pagePos, replyBudget), + }; +} + +const workspaceRecords = (count) => Array.from({ length: count }, (_, n) => ({ + workspace: `w${n}`, + branch: `branch-${n}`, + base: `base-${n}`, + activity: "idle", + mounted: true, + directory: `/workspaces/w${n}`, + dirty: { inodes: 0, entries: 0 }, + headMovedSinceMount: false, + pendingMerge: false, +})); + +function workspacePage(records, pageSize, pagePos) { + const items = records.map((w) => ({ + sandbox: "1", + path: w.directory, + branch: w.branch, + base: w.base, + activity: "Idle", + changed: false, + workspace: w.workspace, + state: w.activity, + dirty: w.dirty, + headMovedSinceMount: w.headMovedSinceMount, + pendingMerge: w.pendingMerge, + })); + const end = pagePos + pageSize; + return { + items: items.slice(pagePos, end), + hasMore: end < items.length, + nextPagePos: end < items.length ? end : null, + version: "1", + }; +} + +function workspaceCase(records, pageSize, pagePos) { + return { + name: `P14 list_workspaces count=${records.length} pageSize=${pageSize} pagePos=${pagePos}`, + prepare(native) { + native.workspaceList = records; + }, + sdk: (lf) => lf.sandbox.list_workspaces("1", pageSize, pagePos), + op: "sandbox.list_workspaces", + sent: { sandbox: "1" }, + result: workspacePage(records, pageSize, pagePos), + }; +} + +const branches = []; +for (const count of [1, 2, 3, 64, 65, 66, 130]) { + const names = branchNames(count); + for (const pageSize of [1, 2, 5, 50]) { + for (const pagePos of [0, 1, count - 1, count, count + 10]) { + branches.push(branchCase(names, pageSize, pagePos)); + } + } +} +const firstBudgetPage = branchPage(BUDGET_BRANCH_NAMES, 1000, 0, 48 * 1024); +branches.push(branchCase(BUDGET_BRANCH_NAMES, 1000, 0, 48 * 1024)); +branches.push(branchCase(BUDGET_BRANCH_NAMES, 1000, firstBudgetPage.items.length, 48 * 1024)); + +const workspaces = []; +for (const count of [0, 64, 65, 129]) { + const records = workspaceRecords(count); + for (const pageSize of [1, 2, 50]) { + for (const pagePos of [0, 63, 64, 65, count, count + 5]) { + workspaces.push(workspaceCase(records, pageSize, pagePos)); + } + } +} + +export const CASES = [...branches, ...workspaces]; diff --git a/api/agent/test/fixture/fake-native.cjs b/api/agent/test/fixture/fake-native.cjs index 19dbe47be..be51d3de1 100644 --- a/api/agent/test/fixture/fake-native.cjs +++ b/api/agent/test/fixture/fake-native.cjs @@ -11,10 +11,11 @@ // (comma-separated) is refused with code CLI_FAKE_CODE (default Conflict); // `initRefusal`, when set, is what project.init refuses; a policy depth over // 255 is refused as the addon refuses it. -// - Answers: `sandboxList` is the project's running Sandboxes, -// `workspaceList` a Sandbox's daemon records. An exec command "exit " -// ends with exit code n, stdout "out:" and stderr "err"; any other -// command ends with code 3 and 5000 bytes of stdout. +// - Answers: `branchList` enables Branch paging, with `branchReplyBudget` +// optionally limiting each reply; `sandboxList` is the project's running +// Sandboxes, `workspaceList` a Sandbox's daemon records. An exec command +// "exit " ends with exit code n, stdout "out:" and stderr "err"; +// any other command ends with code 3 and 5000 bytes of stdout. const { appendFileSync } = require("node:fs"); const plain = (value) => { @@ -41,10 +42,28 @@ function answer(op, args) { if (exports.initRefusal) return { refused: exports.initRefusal }; if (args.policy?.chunkDepth > 255) return refusal("InvalidInput", "policy.chunkDepth is out of range"); return { value: { kind: "Initialized", project: args.name, layer: "lay0", root: "r00t", store: args.storePath } }; - case "project.list_branches": return { value: { items: [ - { name: "main", base: "layer0000000000aa", head: null }, - { name: "cart", base: "base1234567890ab", head: "head1234567890ab" }, - ], hasMore: false, nextPagePos: null } }; + case "project.list_branches": { + if (!exports.branchList) return { value: { items: [ + { name: "main", base: "layer0000000000aa", head: null }, + { name: "cart", base: "base1234567890ab", head: "head1234567890ab" }, + ], hasMore: false, nextPagePos: null } }; + const items = []; + const names = exports.branchList.slice(args.pagePos ?? 0); + const limit = args.pageSize ?? 50; + let spent = 0; + let hasMore = false; + for (const name of names) { + const cost = Buffer.byteLength(name) + 512; + if (items.length === limit || (exports.branchReplyBudget != null && spent + cost > exports.branchReplyBudget)) { + hasMore = true; + break; + } + spent += cost; + items.push({ name, base: "base1234567890ab", head: null }); + } + const pagePos = args.pagePos ?? 0; + return { value: { items, hasMore, nextPagePos: hasMore ? pagePos + items.length : null } }; + } case "project.list_sandboxes": return { value: { kind: "ok", items: exports.sandboxList } }; case "branch.fork": return { value: { kind: "Forked", branch: args.name, base: "lay0", head: "not-a-publication" } }; case "branch.merge": return { value: { kind: "Merged", branch: args.name, head: "m3rge", root: "r00t" } }; diff --git a/engine/crates/layerfs-api/tests/area_p14_paging.rs b/engine/crates/layerfs-api/tests/area_p14_paging.rs new file mode 100644 index 000000000..e496d4113 --- /dev/null +++ b/engine/crates/layerfs-api/tests/area_p14_paging.rs @@ -0,0 +1,197 @@ +//! P14: Branch reply-budget pages resume after returned items, and every +//! Workspace resolves its Branch even when the project has more than 64. +#[path = "support/installed_store.rs"] +#[allow(dead_code)] +mod installed; +#[path = "support/sdk.rs"] +#[allow(dead_code)] +mod support; + +use layerfs_bridge::control::SdkValue; +use layerfs_history::{BranchId, HistoryName, LayerStackId}; +use layerfs_store_service::operator::{self, WorkspaceView}; +use support::Fixture; + +const REPLY_BUDGET: usize = 48 * 1024; + +fn branch_name(n: usize) -> String { + format!("b{n:03}-{}", "x".repeat(58)) +} + +fn list_branches(fixture: &Fixture, page_size: i64, page_pos: i64) -> SdkValue { + fixture.call( + "project.list_branches", + vec![ + ("pageSize", SdkValue::Int(page_size)), + ("pagePos", SdkValue::Int(page_pos)), + ], + ) +} + +fn branch_names(value: &SdkValue) -> Vec { + match value.get("items") { + Some(SdkValue::List(items)) => items + .iter() + .map(|item| item.get_text("name").unwrap().to_owned()) + .collect(), + other => panic!("items: {other:?}"), + } +} + +fn expected_page(names: &[String], page_size: usize, page_pos: usize) -> (Vec, bool) { + let mut items = Vec::new(); + let mut spent = 0; + for name in names.iter().skip(page_pos) { + if items.len() == page_size || spent + name.len() + 512 > REPLY_BUDGET { + return (items, true); + } + spent += name.len() + 512; + items.push(name.clone()); + } + (items, false) +} + +fn assert_page(page: &SdkValue, names: &[String], page_size: usize, page_pos: usize) { + let (items, more) = expected_page(names, page_size, page_pos); + assert_eq!(branch_names(page), items, "pagePos={page_pos}"); + assert_eq!(page.get_bool("hasMore"), more, "pagePos={page_pos}"); + let next = if more { + Some((page_pos + items.len()) as i64) + } else { + None + }; + assert_eq!(page.get_int("nextPagePos"), next, "pagePos={page_pos}"); +} + +#[test] +fn branch_pages_include_main_and_resume_after_returned_items() { + let fixture = Fixture::new("area-p14-branches", |_source| 0); + let mut made = 0; + for count in [1usize, 2, 3, 64, 65, 66, 130] { + while made < count - 1 { + fixture.fork(&branch_name(made)); + made += 1; + } + let expected = (0..count - 1) + .map(branch_name) + .chain(["main".to_owned()]) + .collect::>(); + for page_size in [1usize, 2, 5, 50] { + for page_pos in [0, 1, count - 1, count, count + 10] { + let page = list_branches(&fixture, page_size as i64, page_pos as i64); + assert_page(&page, &expected, page_size, page_pos); + } + let mut found = Vec::new(); + let mut page_pos = 0; + let mut calls = 0; + loop { + calls += 1; + assert!(calls <= count + 1, "pagination did not terminate"); + let page = list_branches(&fixture, page_size as i64, page_pos); + found.extend(branch_names(&page)); + match page.get_int("nextPagePos") { + Some(next) => page_pos = next, + None => break, + } + } + assert_eq!(found, expected, "pageSize={page_size}"); + } + } + + let expected = (0..129) + .map(branch_name) + .chain(["main".to_owned()]) + .collect::>(); + let first = list_branches(&fixture, 1000, 0); + assert!(branch_names(&first).len() < 1000); + assert_eq!(first.get_bool("hasMore"), true); + assert_eq!( + first.get_int("nextPagePos"), + Some(branch_names(&first).len() as i64) + ); + let second = list_branches(&fixture, 1000, first.get_int("nextPagePos").unwrap()); + assert_eq!(second.get_bool("hasMore"), false); + assert_eq!(second.get("nextPagePos"), Some(&SdkValue::Null)); + assert_eq!( + [branch_names(&first), branch_names(&second)].concat(), + expected + ); + println!( + "P14_BRANCHES first_page_items={} requested=1000 hasMore=true nextPagePos={} all_items=130 exactly_once=true", + branch_names(&first).len(), + first.get_int("nextPagePos").unwrap() + ); + fixture.cleanup(); +} + +fn workspace_view(fixture: &Fixture, name: &str, n: usize) -> WorkspaceView { + let branch = if name == "main" { + fixture.host.branch + } else { + BranchId::named( + LayerStackId::from_authority([23; 16]), + &HistoryName::new(name).unwrap(), + ) + }; + let binding = fixture + .service + .store + .history() + .branch_snapshot(branch) + .unwrap() + .unwrap(); + WorkspaceView { + workspace: format!("workspace-{n:03}"), + namespace: n as i64, + binding, + activity: "idle", + mounted: true, + directory: Some(format!("/workspaces/{n:03}")), + dirty: None, + pending_merge: false, + revision: None, + } +} + +fn snapshot(fixture: &Fixture, views: &[WorkspaceView]) -> SdkValue { + let args = SdkValue::map([("project", SdkValue::text("installed"))]); + operator::snapshot(&fixture.service.store, &args, views).unwrap() +} + +fn workspace_branches(value: &SdkValue) -> Vec> { + match value.get("workspaces") { + Some(SdkValue::List(items)) => items + .iter() + .map(|item| item.get_text("branch").map(str::to_owned)) + .collect(), + other => panic!("workspaces: {other:?}"), + } +} + +#[test] +fn workspace_branches_resolve_beyond_the_first_history_page() { + let fixture = Fixture::new("area-p14-workspaces", |_source| 0); + let names = (0..129).map(branch_name).collect::>(); + for name in &names { + fixture.fork(name); + } + let views = names + .iter() + .enumerate() + .map(|(n, name)| workspace_view(&fixture, name, n)) + .collect::>(); + for count in [0usize, 64, 65, 129] { + let value = snapshot(&fixture, &views[..count]); + let expected = names[..count] + .iter() + .map(|name| Some(name.clone())) + .collect::>(); + let actual = workspace_branches(&value); + let missing = actual.iter().filter(|name| name.is_none()).count(); + println!("P14_WORKSPACES count={count} missing_branch={missing}"); + assert_eq!(actual, expected, "count={count}"); + } + let main = snapshot(&fixture, &[workspace_view(&fixture, "main", 129)]); + assert_eq!(workspace_branches(&main), [Some("main".to_owned())]); + fixture.cleanup(); +} diff --git a/engine/crates/layerfs-store-service/src/operator/inspect.rs b/engine/crates/layerfs-store-service/src/operator/inspect.rs index d04019fcb..e687b0fe9 100644 --- a/engine/crates/layerfs-store-service/src/operator/inspect.rs +++ b/engine/crates/layerfs-store-service/src/operator/inspect.rs @@ -113,7 +113,7 @@ fn workspace_value( /// The snapshot answer, or `unchanged` when `since` names its version. pub fn snapshot(store: &Arc, args: &SdkValue, views: &[WorkspaceView]) -> Answer { let stack = super::args::project(store, args)?; - let page = store + let mut page = store .history() .branches( stack.id, @@ -126,18 +126,34 @@ pub fn snapshot(store: &Arc, args: &SdkValue, views: &[WorkspaceView]) -> let more_branches = page.continuation.is_some(); let branches = page .records - .into_iter() + .iter() .filter(|branch| branch.name.as_str() != MAIN) + .cloned() .collect::>(); - let names = branches - .iter() - .map(|branch| { + let mut names = BTreeMap::new(); + loop { + names.extend(page.records.iter().map(|branch| { ( branch.id, (branch.name.as_str().to_owned(), branch.head_commit), ) - }) - .collect::>(); + })); + match page.continuation.take() { + Some(cursor) => { + page = store + .history() + .branches( + stack.id, + &layerfs_history::Page { + cursor: Some(cursor), + limit: MAXIMUM_PAGE_RECORDS, + }, + ) + .map_err(args::history)?; + } + None => break, + } + } let mut spent = 0usize; let mut take = |value: SdkValue| { spent += cost(&value); From 8f4b4177052afc1ad19b3adc6790082ffcfef26d Mon Sep 17 00:00:00 2001 From: Mola-maker <2249464964@qq.com> Date: Thu, 29 Oct 2026 12:32:12 +0800 Subject: [PATCH 04/39] P1: Node SDK history calls (layerfs#53) Add branch.graph and branch.changed_paths across the JS SDK, CLI, and Rust service. graph returns the branch item, its forkPoint verbatim, and the bounded own-commit walk (stopping before a Commit forkPoint; a Layer or null forkPoint runs to the root), with per-commit published derived from main's current Layer stack, never stored. changed_paths returns one commit's path changes against its first parent, its base Layer for a parentless merge, or an empty tree for a true root commit. Unknown commits refuse NoSuchCommit; malformed or Layer identities refuse InvalidInput. No changes to list_commits, diff_commits, or diff_branches. Production LOC: 102763 -> 103075 (delta +312); core+engine+api via python -I tools/production_loc.py --json; reference absent (retired R9c; last counted 65,417 at tree 498dd1917812). Co-Authored-By: Claude Code --- api/agent/src/branch/changed_paths.js | 5 + api/agent/src/branch/graph.js | 5 + api/agent/src/index.js | 4 +- api/agent/test/area-p.mjs | 47 +++ api/agent/test/calls.mjs | 11 +- api/agent/test/fixture/cases-area-p.mjs | 40 ++ api/agent/test/fixture/fake-native.cjs | 14 + api/agent/test/packaging.mjs | 4 +- api/cli/src/branch/changed_paths.js | 9 + api/cli/src/branch/graph.js | 9 + api/cli/src/branch/index.js | 4 +- api/cli/test/cli.test.mjs | 8 +- api/docs/agent.md | 17 +- .../layerfs-content/src/compare/changes.rs | 34 +- docs/general/sdk.md | 9 + .../crates/layerfs-api/src/base/dispatch.rs | 2 + .../layerfs-api/src/branch/changed_paths.rs | 27 ++ engine/crates/layerfs-api/src/branch/graph.rs | 12 + engine/crates/layerfs-api/src/branch/mod.rs | 4 + .../layerfs-api/tests/area_p1_history.rs | 345 ++++++++++++++++++ .../src/history/branch.rs | 4 +- .../src/history/graph.rs | 180 +++++++++ .../layerfs-store-service/src/history/mod.rs | 1 + .../layerfs-store-service/src/read/compare.rs | 97 ++++- 24 files changed, 855 insertions(+), 37 deletions(-) create mode 100644 api/agent/src/branch/changed_paths.js create mode 100644 api/agent/src/branch/graph.js create mode 100644 api/agent/test/area-p.mjs create mode 100644 api/agent/test/fixture/cases-area-p.mjs create mode 100644 api/cli/src/branch/changed_paths.js create mode 100644 api/cli/src/branch/graph.js create mode 100644 engine/crates/layerfs-api/src/branch/changed_paths.rs create mode 100644 engine/crates/layerfs-api/src/branch/graph.rs create mode 100644 engine/crates/layerfs-api/tests/area_p1_history.rs create mode 100644 engine/crates/layerfs-store-service/src/history/graph.rs diff --git a/api/agent/src/branch/changed_paths.js b/api/agent/src/branch/changed_paths.js new file mode 100644 index 000000000..144ef0393 --- /dev/null +++ b/api/agent/src/branch/changed_paths.js @@ -0,0 +1,5 @@ +// branch.changed_paths: one positional SDK operation. +import { page } from "../client.js"; +export function changed_paths(client, commit, pageSize = 50, pagePos = 0) { + return client.call("branch.changed_paths", { commit, ...page(pageSize, pagePos) }); +} diff --git a/api/agent/src/branch/graph.js b/api/agent/src/branch/graph.js new file mode 100644 index 000000000..0acb16f22 --- /dev/null +++ b/api/agent/src/branch/graph.js @@ -0,0 +1,5 @@ +// branch.graph: one positional SDK operation. +import { page } from "../client.js"; +export function graph(client, branch, pageSize = 50, pagePos = 0) { + return client.call("branch.graph", { branch, ...page(pageSize, pagePos) }); +} diff --git a/api/agent/src/index.js b/api/agent/src/index.js index 446b59a53..0d1981243 100644 --- a/api/agent/src/index.js +++ b/api/agent/src/index.js @@ -16,9 +16,11 @@ import * as fsMethods from "./fs/index.js"; import { INIT_TIMEOUT_MS, init } from "./project/init.js"; import { list_branches } from "./project/list_branches.js"; import { list_sandboxes } from "./project/list_sandboxes.js"; +import { changed_paths } from "./branch/changed_paths.js"; import { diff_branches } from "./branch/diff_branches.js"; import { diff_commits } from "./branch/diff_commits.js"; import { fork } from "./branch/fork.js"; +import { graph } from "./branch/graph.js"; import { list_commits } from "./branch/list_commits.js"; import { merge } from "./branch/merge.js"; import { publish } from "./branch/publish.js"; @@ -138,7 +140,7 @@ export class LayerFS { this[STATE] = { closed: null, transport: binding() }; this.fs = bind(this, fsMethods); this.project = bind(this, { list_branches, list_sandboxes }); - this.branch = bind(this, { list_commits, diff_commits, diff_branches, fork, merge, publish }); + this.branch = bind(this, { list_commits, graph, changed_paths, diff_commits, diff_branches, fork, merge, publish }); this.sandbox = bind(this, { create, list_workspaces, close: closeSandbox }); this.workspace = bind(this, { mount, exec, commit, unmount }); } diff --git a/api/agent/test/area-p.mjs b/api/agent/test/area-p.mjs new file mode 100644 index 000000000..c4f4243d6 --- /dev/null +++ b/api/agent/test/area-p.mjs @@ -0,0 +1,47 @@ +// P1 SDK/CLI agreement over the stand-in binding. Real-Store coverage: +// layerfs-api/tests/area_p1_history.rs. P14's budget traversal joins this +// runner when the P14 slice merges. +// /usr/bin/node api/agent/test/area-p.mjs +import assert from "node:assert/strict"; +import { createRequire } from "node:module"; +import { fileURLToPath } from "node:url"; +import { ENV, contains, plain } from "./fixture/cases.mjs"; +import { CASES } from "./fixture/cases-area-p.mjs"; + +const FAKE = fileURLToPath(new URL("./fixture/fake-native.cjs", import.meta.url)); +Object.assign(process.env, ENV, { LAYERFS_NATIVE: FAKE }); +delete process.env.CLI_FAKE_LOG; +const native = createRequire(import.meta.url)(FAKE); +const { LayerFS } = await import("../src/index.js"); +const lf = await LayerFS.open("site"); +const failures = []; + +for (const c of CASES) { + native.branchList = null; + native.branchReplyBudget = null; + native.workspaceList = []; + c.prepare?.(native); + const before = native.calls.length; + let result; + let error; + try { + result = await c.sdk(lf); + } catch (thrown) { + error = thrown; + } + try { + assert.equal(error, undefined, `threw ${error?.message}`); + assert.deepEqual(native.calls.slice(before), [c.op], "binding calls"); + assert.ok(contains(plain(native.args[before]), c.sent), `sent ${JSON.stringify(plain(native.args[before]))}`); + assert.deepEqual(plain(result), c.result); + } catch (failure) { + failures.push(`${c.name}: ${failure.message}`); + } +} + +await lf.close(); +if (failures.length) { + console.error(`FAIL area-p: ${failures.length} of ${CASES.length}\n${failures.join("\n")}`); + process.exit(1); +} +console.log(`PASS area-p (${CASES.length} cases)`); diff --git a/api/agent/test/calls.mjs b/api/agent/test/calls.mjs index 838357fa7..9bebd3f14 100644 --- a/api/agent/test/calls.mjs +++ b/api/agent/test/calls.mjs @@ -8,7 +8,9 @@ import { readdirSync } from "node:fs"; import { createRequire } from "node:module"; import { fileURLToPath } from "node:url"; import { CASES, ENV, contains, made, plain } from "./fixture/cases.mjs"; +import { P1_CASES } from "./fixture/cases-area-p.mjs"; +const ALL_CASES = [...CASES, ...P1_CASES]; const FAKE = fileURLToPath(new URL("./fixture/fake-native.cjs", import.meta.url)); Object.assign(process.env, ENV, { LAYERFS_NATIVE: FAKE }); delete process.env.CLI_FAKE_LOG; @@ -19,11 +21,12 @@ const { LayerFS } = await import("../src/index.js"); const SRC = fileURLToPath(new URL("../src/", import.meta.url)); const files = ["project", "branch", "fs", "workspace", "sandbox"].flatMap((family) => readdirSync(`${SRC}${family}`).filter((f) => !["index.js", "body.js"].includes(f)).map((f) => `${family}.${f.slice(0, -3)}`)); -assert.deepEqual([...new Set(CASES.map((c) => c.op).filter(Boolean))].sort(), files.sort(), "a case for every SDK call"); +assert.deepEqual([...new Set(ALL_CASES.map((c) => c.op).filter(Boolean))].sort(), files.sort(), "a case for every SDK call"); const lf = await LayerFS.open("site"); const failures = []; -for (const c of CASES) { +for (const c of ALL_CASES) { + c.prepare?.(native); const before = native.args.length; process.env.CLI_FAKE_REFUSE = (c.refuse ?? []).join(","); let result; @@ -53,7 +56,7 @@ for (const c of CASES) { } await lf.close(); if (failures.length) { - console.error(`FAIL calls: ${failures.length} of ${CASES.length}\n${failures.join("\n")}`); + console.error(`FAIL calls: ${failures.length} of ${ALL_CASES.length}\n${failures.join("\n")}`); process.exit(1); } -console.log(`PASS calls (${CASES.length} cases)`); +console.log(`PASS calls (${ALL_CASES.length} cases)`); diff --git a/api/agent/test/fixture/cases-area-p.mjs b/api/agent/test/fixture/cases-area-p.mjs new file mode 100644 index 000000000..85e5b3bdd --- /dev/null +++ b/api/agent/test/fixture/cases-area-p.mjs @@ -0,0 +1,40 @@ +// P1 cases only (branch.graph + branch.changed_paths). P14's branch/workspace +// matrices arrive with the P14 slice and merge into this file then. + +const graphResult = (branch, pagePos) => ({ + branch: { name: branch, id: "branch1234567890ab", base: "base1234567890ab", head: "head1234567890ab", forkedFrom: "c0ffee" }, + forkPoint: { kind: "commit", id: "c0ffee" }, + commits: [{ id: "decaf", branch, parent: "parent1234567890", logical: 120, newCanonical: 40, newStored: 30, newMetadata: 8, published: true }], + hasMore: true, + nextPagePos: pagePos + 1, +}); + +const changedResult = (commit) => ({ + commit, + parent: "parent1234567890", + items: [{ path: "/changed.txt", kind: "Modified", type: "file", before: { type: "file", inode: 7 }, after: { type: "file", inode: 7 }, content: true, identity: false, metadata: false, hunks: null, hunksOmitted: false }], + hasMore: false, + nextPagePos: null, +}); + +export const P1_CASES = [ + { + name: "P1 branch graph", + argv: ["branch", "graph", "cart", "--page-size", "2", "--page-pos", "1"], + sdk: (lf) => lf.branch.graph("cart", 2, 1), + op: "branch.graph", + sent: { branch: "cart", pageSize: 2, pagePos: 1 }, + result: graphResult("cart", 1), + }, + { + name: "P1 branch changed_paths", + argv: ["branch", "changed_paths", "c0ffee", "--page-size", "5"], + sdk: (lf) => lf.branch.changed_paths("c0ffee", 5), + op: "branch.changed_paths", + sent: { commit: "c0ffee", pageSize: 5, pagePos: 0 }, + result: changedResult("c0ffee"), + }, +]; + +export const CASES = [...P1_CASES]; + diff --git a/api/agent/test/fixture/fake-native.cjs b/api/agent/test/fixture/fake-native.cjs index 19dbe47be..7d86e4e14 100644 --- a/api/agent/test/fixture/fake-native.cjs +++ b/api/agent/test/fixture/fake-native.cjs @@ -47,6 +47,20 @@ function answer(op, args) { ], hasMore: false, nextPagePos: null } }; case "project.list_sandboxes": return { value: { kind: "ok", items: exports.sandboxList } }; case "branch.fork": return { value: { kind: "Forked", branch: args.name, base: "lay0", head: "not-a-publication" } }; + case "branch.graph": return { value: { + branch: { name: args.branch, id: "branch1234567890ab", base: "base1234567890ab", head: "head1234567890ab", forkedFrom: "c0ffee" }, + forkPoint: { kind: "commit", id: "c0ffee" }, + commits: [{ ...commit, id: "decaf", branch: args.branch, parent: "parent1234567890", published: true }], + hasMore: true, + nextPagePos: (args.pagePos ?? 0) + 1, + } }; + case "branch.changed_paths": return { value: { + commit: args.commit, + parent: "parent1234567890", + items: [{ path: "/changed.txt", kind: "Modified", type: "file", before: { type: "file", inode: 7 }, after: { type: "file", inode: 7 }, content: true, identity: false, metadata: false, hunks: null, hunksOmitted: false }], + hasMore: false, + nextPagePos: null, + } }; case "branch.merge": return { value: { kind: "Merged", branch: args.name, head: "m3rge", root: "r00t" } }; case "branch.publish": return { value: { kind: "Added", source: { branch: "feature", commit: "pub1" }, publishedLayer: { id: "lay1" } } }; case "fs.read": return { value: args.startLine === 1 ? { text: "a\n", nextLine: 2 } : { text: "b\n", nextLine: null } }; diff --git a/api/agent/test/packaging.mjs b/api/agent/test/packaging.mjs index 496a616b9..8ce9c3a3f 100644 --- a/api/agent/test/packaging.mjs +++ b/api/agent/test/packaging.mjs @@ -31,10 +31,10 @@ if (selected === "surface") { assert.equal(shop.projectName, "website"); assert.equal(shop.options.home, "/private/configured-home"); assert.deepEqual(shop.options.deploy, { docker: "/configured/docker.sock", image: "configured-image", volume: "configured-volume", daemon: "/configured/daemon" }); - // five groups: project 2, branch 6, fs 14, sandbox 3, workspace 4 + // five groups: project 2, branch 8, fs 14, sandbox 3, workspace 4 const methods = (group) => Object.keys(shop[group]).sort(); assert.deepEqual(methods("project"), ["list_branches", "list_sandboxes"]); - assert.deepEqual(methods("branch"), ["diff_branches", "diff_commits", "fork", "list_commits", "merge", "publish"]); + assert.deepEqual(methods("branch"), ["changed_paths", "diff_branches", "diff_commits", "fork", "graph", "list_commits", "merge", "publish"]); assert.equal(methods("fs").length, 14); assert.deepEqual(methods("sandbox"), ["close", "create", "list_workspaces"]); assert.deepEqual(methods("workspace"), ["commit", "exec", "mount", "unmount"]); diff --git a/api/cli/src/branch/changed_paths.js b/api/cli/src/branch/changed_paths.js new file mode 100644 index 000000000..ae70e41dc --- /dev/null +++ b/api/cli/src/branch/changed_paths.js @@ -0,0 +1,9 @@ +// layerfs branch changed_paths: branch.changed_paths(commit, pageSize?, pagePos?). +import { PAGE } from "../common.js"; + +export default { + args: ["commit"], + options: PAGE, + summary: "paths a Commit changed, without line hunks", + run: (lf, a, o) => lf.branch.changed_paths(a.commit, o.pageSize, o.pagePos), +}; diff --git a/api/cli/src/branch/graph.js b/api/cli/src/branch/graph.js new file mode 100644 index 000000000..e5f0d42e7 --- /dev/null +++ b/api/cli/src/branch/graph.js @@ -0,0 +1,9 @@ +// layerfs branch graph: branch.graph(branch, pageSize?, pagePos?). +import { PAGE } from "../common.js"; + +export default { + args: ["branch"], + options: PAGE, + summary: "a Branch's own Commits, fork point and publish state", + run: (lf, a, o) => lf.branch.graph(a.branch, o.pageSize, o.pagePos), +}; diff --git a/api/cli/src/branch/index.js b/api/cli/src/branch/index.js index 7c093430c..4308e66e2 100644 --- a/api/cli/src/branch/index.js +++ b/api/cli/src/branch/index.js @@ -1,9 +1,11 @@ // The `branch` commands, in help order: Branches: fork, history, diffs, merge and publish. import fork from "./fork.js"; +import graph from "./graph.js"; +import changed_paths from "./changed_paths.js"; import list_commits from "./list_commits.js"; import diff_commits from "./diff_commits.js"; import diff_branches from "./diff_branches.js"; import merge from "./merge.js"; import publish from "./publish.js"; -export default { fork, list_commits, diff_commits, diff_branches, merge, publish }; +export default { fork, graph, changed_paths, list_commits, diff_commits, diff_branches, merge, publish }; diff --git a/api/cli/test/cli.test.mjs b/api/cli/test/cli.test.mjs index c91eb9f44..a5fea3a6d 100644 --- a/api/cli/test/cli.test.mjs +++ b/api/cli/test/cli.test.mjs @@ -16,6 +16,9 @@ import { CALLS, INIT } from "../src/calls.js"; import { usage } from "../src/help.js"; import { tables, written } from "./doc-tables.mjs"; import { CASES, ENV, contains, made, plain } from "../../agent/test/fixture/cases.mjs"; +import { P1_CASES } from "../../agent/test/fixture/cases-area-p.mjs"; + +const AGREEMENT_CASES = [...CASES, ...P1_CASES]; const CLI = fileURLToPath(new URL("../src/layerfs.js", import.meta.url)); const FAKE = fileURLToPath(new URL("../../agent/test/fixture/fake-native.cjs", import.meta.url)); @@ -62,6 +65,7 @@ after(() => sdk?.lf.close()); /** One case through the SDK in this process: the binding calls it made, and its result or refusal. */ async function throughSdk(c) { + c.prepare?.(native); const first = native.args.length; process.env.CLI_FAKE_REFUSE = (c.refuse ?? []).join(","); try { @@ -76,11 +80,11 @@ async function throughSdk(c) { } test("every table entry has a case", () => { - const covered = new Set(CASES.map((c) => c.argv.slice(0, c.argv[0] === "init" ? 1 : 2).join(" "))); + const covered = new Set(AGREEMENT_CASES.map((c) => c.argv.slice(0, c.argv[0] === "init" ? 1 : 2).join(" "))); for (const entry of [INIT, ...CALLS]) assert.ok(covered.has(command(entry).join(" ")), key(entry)); }); -for (const c of CASES) { +for (const c of AGREEMENT_CASES) { test(`${c.name}: the command matches the SDK`, async () => { const want = await throughSdk(c); const r = cli(c.argv, { input: c.input, env: { CLI_FAKE_REFUSE: (c.refuse ?? []).join(",") } }); diff --git a/api/docs/agent.md b/api/docs/agent.md index 330fcab91..21e753b1b 100644 --- a/api/docs/agent.md +++ b/api/docs/agent.md @@ -271,7 +271,7 @@ trace line covers the command's run. | project (2) | `list_branches(pageSize?, pagePos?)`, `list_sandboxes(pageSize?, pagePos?)` | | fs reads (7) | `read(ref, path, startLine?, endLine?)`, `list(ref, path?, pageSize?, pagePos?)`, `stat(ref, path)`, `grep(ref, pattern, path?, pageSize?, pagePos?)`, `glob(ref, pattern, path?, pageSize?, pagePos?)`, `diff_files(leftRef, leftPath, rightRef, rightPath)`, `blame(ref, path, fromLine?)` | | fs writes (7) | `write(branch, path, content, message? \| { message?, mode? })`, `edit(branch, path, oldText, newText, message?)`, `apply(branch, changes, message?)`, `mkdir(branch, path, message? \| { message?, mode? })`, `rm(branch, path, message?)`, `cp(branch, from, to, message?)`, `mv(branch, from, to, message?)` | -| branch (6) | `fork(name, fromRef?)`, `list_commits(branch, pageSize?, pagePos?)`, `diff_commits(from, to, pageSize?, pagePos?, { cursor?, hunks? }?)`, `diff_branches(from, to, pageSize?, pagePos?, { cursor?, hunks? }?)`, `merge(a, b, name, message?, units?)`, `publish(branch, commit?)` | +| branch (8) | `fork(name, fromRef?)`, `graph(branch, pageSize?, pagePos?)`, `changed_paths(commit, pageSize?, pagePos?)`, `list_commits(branch, pageSize?, pagePos?)`, `diff_commits(from, to, pageSize?, pagePos?, { cursor?, hunks? }?)`, `diff_branches(from, to, pageSize?, pagePos?, { cursor?, hunks? }?)`, `merge(a, b, name, message?, units?)`, `publish(branch, commit?)` | | sandbox (3) | `create({ image }?)` → `{ sandbox, image }`, `list_workspaces(sandbox, pageSize?, pagePos?)`, `close(sandbox)` | | workspace (4) | `mount(sandbox, branch)` → `{ sandbox, path, image }`, `exec(ws, command, message?, { stdout?, stderr? }?)`, `commit(ws, message?, force?)`, `unmount(ws, discard?, force?)` | @@ -360,6 +360,19 @@ component-by-component byte order, then line number for grep. Commit history is newest first along first parents. Every call resolves each live ref once; separate calls against live heads can shift numeric positions. +`branch.graph` returns the Branch record, its resolved `forkPoint`, and `commits` +in the `list_commits` shape plus `published`. The walk stops before a Commit fork +point; a Layer or absent fork point runs to the root. A fresh Commit fork therefore +has no own Commits. For `main`, it returns the source Commits of the current Layer +stack, skipping genesis, all published. Publication is derived from that stack on +every call, not stored on a Commit. `pagePos` counts the bounded walk. + +`branch.changed_paths` returns one Commit's path-level changes without line hunks. +It compares against the first parent when present, against the base Layer for a +parentless merge, and against an empty tree for a true root Commit. It accepts only +a Commit identity: malformed text and Layer identities are `InvalidInput`, and an +absent Commit is `NoSuchCommit`. + Read/blame use 1-based inclusive line positions, no byte offsets or byte-read method. Responses fit the authenticated control frame: at most 2000 lines and 48 KiB of text/value payload, below the selected 256 KiB ceiling. LF separates @@ -496,6 +509,8 @@ table differs, and when a family's files stop matching the SDK's. | `project.list_branches` | `layerfs project list_branches [--page-size ] [--page-pos ]` | | `project.list_sandboxes` | `layerfs project list_sandboxes [--page-size ] [--page-pos ]` | | `branch.fork` | `layerfs branch fork []` | +| `branch.graph` | `layerfs branch graph [--page-size ] [--page-pos ]` | +| `branch.changed_paths` | `layerfs branch changed_paths [--page-size ] [--page-pos ]` | | `branch.list_commits` | `layerfs branch list_commits [--page-size ] [--page-pos ]` | | `branch.diff_commits` | `layerfs branch diff_commits [--page-size ] [--page-pos ] [--cursor ] [--no-hunks]` | | `branch.diff_branches` | `layerfs branch diff_branches [--page-size ] [--page-pos ] [--cursor ] [--no-hunks]` | diff --git a/core/crates/layerfs-content/src/compare/changes.rs b/core/crates/layerfs-content/src/compare/changes.rs index 5844f4ec2..0b470d948 100644 --- a/core/crates/layerfs-content/src/compare/changes.rs +++ b/core/crates/layerfs-content/src/compare/changes.rs @@ -5,8 +5,9 @@ //! and descends below a path only through [`ChangeWalk::enter`], so a caller //! filters and pages the pairs it is handed. //! -//! [`ChangeWalk::full`] lists both trees completely. [`Plan::make`] reads -//! only what changed: the inodes whose stored values differ come from the +//! [`ChangeWalk::full`] lists both trees completely, and +//! [`ChangeWalk::full_from_empty`] lists the right tree against an empty left. +//! [`Plan::make`] reads only what changed: the inodes whose stored values differ come from the //! page-skipping difference of the two inode tables, and each one's paths from //! the caller's [`BindingHints`], every hint checked against the tree it is used //! for, up to the root. The walk then enters a directory only when its listing @@ -87,7 +88,7 @@ pub struct Plan { /// A path-ordered walk over two roots. pub struct ChangeWalk<'a> { reader: &'a dyn AuthenticatedObjects, - sides: [&'a TreeTable; 2], + sides: [Option<&'a TreeTable>; 2], /// The changed entries; `None` lists every directory instead. plan: Option<&'a Plan>, stack: Vec, @@ -204,6 +205,23 @@ impl<'a> ChangeWalk<'a> { Ok(walk) } + /// The walk of an empty left tree against `right`. A resumed walk names + /// the first component of its resume path as `from`. + pub fn full_from_empty( + reader: &'a dyn AuthenticatedObjects, + right: &'a TreeTable, + from: Option<&PathName>, + ) -> ContentResult { + let mut walk = Self { + reader, + sides: [None, Some(right)], + plan: None, + stack: Vec::new(), + }; + walk.start(from)?; + Ok(walk) + } + /// The walk of the changed entries `plan` holds. A resumed walk names the /// first component of its resume path as `from`, and the root visits only /// the names at or after it. @@ -227,7 +245,7 @@ impl<'a> ChangeWalk<'a> { ) -> Self { Self { reader, - sides: [left, right], + sides: [Some(left), Some(right)], plan, stack: Vec::new(), } @@ -235,7 +253,8 @@ impl<'a> ChangeWalk<'a> { /// Enters the two root directories; the roots themselves are not yielded. fn start(&mut self, from: Option<&PathName>) -> ContentResult<()> { - let root = |tree: &TreeTable| -> ContentResult> { + let root = |tree: Option<&TreeTable>| -> ContentResult> { + let Some(tree) = tree else { return Ok(None) }; let serial = tree.root_serial(); Ok(tree .value(self.reader, serial)? @@ -249,11 +268,14 @@ impl<'a> ChangeWalk<'a> { fn bound(&self, names: Vec) -> ContentResult> { let mut values: [Vec>; 2] = [Vec::new(), Vec::new()]; for (side, values) in values.iter_mut().enumerate() { + let Some(tree) = self.sides[side] else { + continue; + }; let serials = names .iter() .filter_map(|(_, serials)| serials[side]) .collect::>(); - *values = self.sides[side].values(self.reader, &serials)?; + *values = tree.values(self.reader, &serials)?; } let mut cursor = [values[0].iter(), values[1].iter()]; let mut out = VecDeque::with_capacity(names.len()); diff --git a/docs/general/sdk.md b/docs/general/sdk.md index c299eea7d..d0bf11f12 100644 --- a/docs/general/sdk.md +++ b/docs/general/sdk.md @@ -85,6 +85,15 @@ history method. It sends the unchanged History request and returns one anchored, bounded ancestry page, newest first. Cursor authentication, captured-head scope, publication semantics and original control bytes are unchanged. +[Graph](../../engine/crates/layerfs-api/src/branch/graph.rs) returns a Branch's own +first-parent Commits up to, but excluding, a Commit fork point, together with the +fork point and publication state derived from main's current Layer stack. Main's +graph instead walks Layer-source Commits and skips genesis. +[Changed paths](../../engine/crates/layerfs-api/src/branch/changed_paths.rs) uses the +existing comparison encoder against a Commit's first parent, its base Layer for a +parentless merge, or an empty tree for a root Commit. Neither operation changes the +Store schema. + ## Sandbox [Create](../../engine/crates/layerfs-api/src/sandbox/create.rs) uses the original diff --git a/engine/crates/layerfs-api/src/base/dispatch.rs b/engine/crates/layerfs-api/src/base/dispatch.rs index 0db081d12..3379ecdf0 100644 --- a/engine/crates/layerfs-api/src/base/dispatch.rs +++ b/engine/crates/layerfs-api/src/base/dispatch.rs @@ -21,6 +21,8 @@ pub fn call(service: &StoreService, call: &SdkCall) -> Answer { match call.method.as_str() { "project.list_branches" => project::list_branches(service, args), "branch.fork" => branch::fork(service, args), + "branch.graph" => branch::graph(service, args), + "branch.changed_paths" => branch::changed_paths(service, args), "branch.list_commits" => branch::list_commits(service, args), "branch.merge" => branch::merge(service, args), "branch.publish" => branch::publish(service, args), diff --git a/engine/crates/layerfs-api/src/branch/changed_paths.rs b/engine/crates/layerfs-api/src/branch/changed_paths.rs new file mode 100644 index 000000000..7a3a32f03 --- /dev/null +++ b/engine/crates/layerfs-api/src/branch/changed_paths.rs @@ -0,0 +1,27 @@ +//! `branch.changed_paths`. +use crate::base::args::{paging, project, text, Answer}; +use layerfs_bridge::control::SdkValue; +use layerfs_store_service::{ + read::{compare, snapshot}, + StoreService, +}; + +/// `changed_paths({ commit, pageSize?, pagePos? })`: one Commit's path changes. +pub fn changed_paths(service: &StoreService, args: &SdkValue) -> Answer { + let stack = project(service, args)?; + let reader = snapshot::reader(&service.store, &stack); + let commit = text(args, "commit")?; + let (limit, pos) = paging(args)?; + compare::changed_paths( + &service.store, + &stack, + &reader, + commit, + Ok(compare::Request { + limit, + pos, + cursor: None, + hunks: false, + }), + ) +} diff --git a/engine/crates/layerfs-api/src/branch/graph.rs b/engine/crates/layerfs-api/src/branch/graph.rs new file mode 100644 index 000000000..9da8fa8ac --- /dev/null +++ b/engine/crates/layerfs-api/src/branch/graph.rs @@ -0,0 +1,12 @@ +//! `branch.graph`. +use crate::base::args::{paging, project, text, Answer}; +use layerfs_bridge::control::SdkValue; +use layerfs_store_service::{history::graph, StoreService}; + +/// `graph({ branch, pageSize?, pagePos? })`: own Commits, fork and publication. +pub fn graph(service: &StoreService, args: &SdkValue) -> Answer { + let stack = project(service, args)?; + let name = text(args, "branch")?; + let (limit, pos) = paging(args)?; + graph::graph(&service.store, &stack, name, limit, pos) +} diff --git a/engine/crates/layerfs-api/src/branch/mod.rs b/engine/crates/layerfs-api/src/branch/mod.rs index 989ef9aa7..0966bc65b 100644 --- a/engine/crates/layerfs-api/src/branch/mod.rs +++ b/engine/crates/layerfs-api/src/branch/mod.rs @@ -1,13 +1,17 @@ //! Branch methods: history, differences, forks, merges and publication. +mod changed_paths; mod diff_branches; mod diff_commits; mod fork; +mod graph; mod list_commits; mod merge; mod publish; +pub use changed_paths::changed_paths; pub use diff_branches::diff_branches; pub use diff_commits::diff_commits; pub use fork::fork; +pub use graph::graph; pub use list_commits::list_commits; pub use merge::merge; pub use publish::publish; diff --git a/engine/crates/layerfs-api/tests/area_p1_history.rs b/engine/crates/layerfs-api/tests/area_p1_history.rs new file mode 100644 index 000000000..7ad9d78c5 --- /dev/null +++ b/engine/crates/layerfs-api/tests/area_p1_history.rs @@ -0,0 +1,345 @@ +//! P1 history graph and changed-path coverage over a real disposable Store. +#[path = "support/installed_store.rs"] +#[allow(dead_code)] +mod installed; +#[path = "support/sdk.rs"] +#[allow(dead_code)] +mod support; + +use layerfs_bridge::control::SdkValue; +use std::fs; +use support::{write, Fixture}; + +fn text(value: &str) -> SdkValue { + SdkValue::text(value) +} + +fn graph(fixture: &Fixture, branch: &str, limit: i64, pos: i64) -> SdkValue { + fixture.call( + "branch.graph", + vec![ + ("branch", text(branch)), + ("pageSize", SdkValue::Int(limit)), + ("pagePos", SdkValue::Int(pos)), + ], + ) +} + +fn changed(fixture: &Fixture, commit: &str, limit: i64, pos: i64) -> SdkValue { + fixture.call( + "branch.changed_paths", + vec![ + ("commit", text(commit)), + ("pageSize", SdkValue::Int(limit)), + ("pagePos", SdkValue::Int(pos)), + ], + ) +} + +fn graph_ids(value: &SdkValue) -> Vec<&str> { + match value.get("commits") { + Some(SdkValue::List(commits)) => commits + .iter() + .map(|commit| commit.get_text("id").unwrap()) + .collect(), + other => panic!("commits: {other:?}"), + } +} + +fn published(value: &SdkValue) -> Vec { + match value.get("commits") { + Some(SdkValue::List(commits)) => commits + .iter() + .map(|commit| commit.get_bool("published")) + .collect(), + other => panic!("commits: {other:?}"), + } +} + +fn apply_message(fixture: &Fixture, branch: &str, path: &str, message: &str) -> String { + fixture + .call( + "fs.apply", + vec![ + ("branch", text(branch)), + ("changes", SdkValue::List(vec![write(path, path)])), + ("message", text(message)), + ], + ) + .get_text("head") + .unwrap() + .to_owned() +} + +#[test] +fn graph_bounds_forks_merges_main_and_publication() { + let fixture = Fixture::new("area-p1-graph", |source| { + fs::write(source.join("base.txt"), b"base\n").unwrap(); + 1 + }); + fixture.fork("layer"); + let empty = graph(&fixture, "layer", 50, 0); + assert!(graph_ids(&empty).is_empty()); + let layer = empty + .get("branch") + .unwrap() + .get_text("base") + .unwrap() + .to_owned(); + assert_eq!( + empty.get("forkPoint").unwrap().get_text("kind"), + Some("layer") + ); + assert_eq!( + empty.get("forkPoint").unwrap().get_text("id"), + Some(layer.as_str()) + ); + + fixture.apply("layer", vec![write("/first.txt", "first\n")]); + fixture.apply("layer", vec![write("/second.txt", "second\n")]); + let before = graph(&fixture, "layer", 50, 0); + let ids = graph_ids(&before); + assert_eq!(ids.len(), 2); + assert_eq!(published(&before), [false, false]); + let newest = ids[0].to_owned(); + fixture.call("branch.publish", vec![("branch", text("layer"))]); + let after = graph(&fixture, "layer", 50, 0); + assert_eq!(graph_ids(&after), ids); + assert_eq!( + published(&after), + [true, false], + "publish is derived per call" + ); + + fixture.call( + "branch.fork", + vec![("name", text("commit-fork")), ("from", text(&newest))], + ); + let fresh = graph(&fixture, "commit-fork", 50, 0); + assert!(graph_ids(&fresh).is_empty(), "the Commit fork is exclusive"); + assert_eq!( + fresh.get("forkPoint").unwrap().get_text("kind"), + Some("commit") + ); + assert_eq!( + fresh.get("forkPoint").unwrap().get_text("id"), + Some(newest.as_str()) + ); + fixture.apply("commit-fork", vec![write("/own.txt", "own\n")]); + let own = graph(&fixture, "commit-fork", 50, 0); + assert_eq!(graph_ids(&own).len(), 1); + assert_ne!(graph_ids(&own)[0], newest); + assert!(graph_ids(&graph(&fixture, "commit-fork", 50, 1)).is_empty()); + + fixture.fork("left"); + fixture.fork("right"); + fixture.apply("left", vec![write("/left.txt", "left\n")]); + fixture.apply("right", vec![write("/right.txt", "right\n")]); + let merged = fixture.call( + "branch.merge", + vec![ + ("branch", text("left")), + ("with", text("right")), + ("name", text("merged")), + ], + ); + let merge_id = merged.get_text("head").unwrap(); + let merge_graph = graph(&fixture, "merged", 50, 0); + assert_eq!( + graph_ids(&merge_graph), + [merge_id], + "stop before the left node" + ); + assert_eq!( + merge_graph.get("forkPoint").unwrap().get_text("kind"), + Some("commit") + ); + + let main = graph(&fixture, "main", 50, 0); + assert_eq!(graph_ids(&main), [newest]); + assert_eq!(published(&main), [true]); + assert_eq!(main.get("forkPoint"), Some(&SdkValue::Null)); + assert_eq!(main.get("branch").unwrap().get("forkedFrom"), None); + assert_eq!(main.get("commits").unwrap().get("branch"), None); + match main.get("commits") { + Some(SdkValue::List(commits)) => { + assert_eq!(commits[0].get_text("branch"), Some("main")); + } + other => panic!("commits: {other:?}"), + } + + let listed = fixture + .try_call("branch.list_commits", vec![("branch", text("ghost"))]) + .unwrap_err(); + let graphed = fixture + .try_call("branch.graph", vec![("branch", text("ghost"))]) + .unwrap_err(); + assert_eq!(graphed.code, listed.code); + assert_eq!(graphed.message, listed.message); + assert_eq!(graphed.detail, listed.detail); + fixture.cleanup(); +} + +#[test] +fn graph_budget_short_pages_traverse_the_bounded_walk() { + let fixture = Fixture::new("area-p1-graph-budget", |_source| 0); + fixture.fork("budget"); + let message = "x".repeat(4096); + let mut expected = Vec::new(); + for n in 0..12 { + expected.push(apply_message( + &fixture, + "budget", + &format!("/f{n:02}.txt"), + &message, + )); + } + expected.reverse(); + let first = graph(&fixture, "budget", 1000, 0); + assert!(graph_ids(&first).len() < 1000); + assert_eq!(first.get_bool("hasMore"), true); + assert_eq!( + first.get_int("nextPagePos"), + Some(graph_ids(&first).len() as i64) + ); + let second = graph( + &fixture, + "budget", + 1000, + first.get_int("nextPagePos").unwrap(), + ); + assert_eq!(second.get_bool("hasMore"), false); + assert_eq!(second.get("nextPagePos"), Some(&SdkValue::Null)); + assert_eq!([graph_ids(&first), graph_ids(&second)].concat(), expected); + fixture.cleanup(); +} + +#[test] +fn changed_paths_uses_parent_base_layer_or_empty_tree() { + let fixture = Fixture::new("area-p1-changed", |source| { + fs::write(source.join("base.txt"), b"base\n").unwrap(); + 1 + }); + fixture.fork("feature"); + fixture.apply( + "feature", + vec![ + write("/root.txt", "root\n"), + write("/base.txt", "changed\n"), + ], + ); + let history = fixture.call("branch.list_commits", vec![("branch", text("feature"))]); + let root = support::items(&history)[0] + .get_text("id") + .unwrap() + .to_owned(); + let root_changed = changed(&fixture, &root, 50, 0); + assert_eq!(root_changed.get("parent"), Some(&SdkValue::Null)); + assert_eq!(support::paths(&root_changed), ["/base.txt", "/root.txt"]); + assert!(support::items(&root_changed) + .iter() + .all(|item| item.get_text("kind") == Some("Added"))); + + fixture.apply("feature", vec![write("/second.txt", "second\n")]); + let history = fixture.call("branch.list_commits", vec![("branch", text("feature"))]); + let newest = support::items(&history)[0] + .get_text("id") + .unwrap() + .to_owned(); + let parent_changed = changed(&fixture, &newest, 50, 0); + assert_eq!(parent_changed.get_text("parent"), Some(root.as_str())); + let diff = fixture.call( + "branch.diff_commits", + vec![ + ("from", text(&root)), + ("to", text(&newest)), + ("hunks", SdkValue::Bool(false)), + ], + ); + assert_eq!(parent_changed.get("items"), diff.get("items")); + assert_eq!(support::paths(&parent_changed), ["/second.txt"]); + + fixture.fork("main-right"); + fixture.apply("main-right", vec![write("/right.txt", "right\n")]); + fixture.fork("publisher"); + fixture.apply("publisher", vec![write("/main.txt", "main\n")]); + fixture.call("branch.publish", vec![("branch", text("publisher"))]); + let merged = fixture.call( + "branch.merge", + vec![ + ("branch", text("main")), + ("with", text("main-right")), + ("name", text("parentless")), + ], + ); + assert_eq!(merged.get_bool("fastForward"), false); + let merge_id = merged.get_text("head").unwrap(); + let merge_changed = changed(&fixture, merge_id, 50, 0); + assert_eq!(merge_changed.get("parent"), Some(&SdkValue::Null)); + assert_eq!( + support::paths(&merge_changed), + ["/right.txt"], + "a parentless merge diffs its base Layer, not an empty tree" + ); + + let unknown = format!( + "{}{}", + &newest[..newest.len() - 1], + if newest.ends_with('0') { '1' } else { '0' } + ); + for (input, code) in [("not-a-commit", "InvalidInput"), (&unknown, "NoSuchCommit")] { + let refusal = fixture + .try_call("branch.changed_paths", vec![("commit", text(input))]) + .unwrap_err(); + assert_eq!(refusal.code, code, "{refusal:?}"); + } + let layer = graph(&fixture, "feature", 1, 0) + .get("branch") + .unwrap() + .get_text("base") + .unwrap() + .to_owned(); + let refusal = fixture + .try_call("branch.changed_paths", vec![("commit", text(&layer))]) + .unwrap_err(); + assert_eq!(refusal.code, "InvalidInput", "{refusal:?}"); + fixture.cleanup(); +} + +#[test] +fn changed_paths_budget_short_pages_return_every_root_path() { + let fixture = Fixture::new("area-p1-changed-budget", |_source| 0); + fixture.fork("root"); + let expected = (0..220) + .map(|n| format!("/f{n:03}-{}", "x".repeat(220))) + .collect::>(); + fixture.apply( + "root", + expected.iter().map(|path| write(path, "x\n")).collect(), + ); + let history = fixture.call("branch.list_commits", vec![("branch", text("root"))]); + let commit = support::items(&history)[0] + .get_text("id") + .unwrap() + .to_owned(); + let first = changed(&fixture, &commit, 1000, 0); + assert!(support::items(&first).len() < 1000); + assert_eq!(first.get_bool("hasMore"), true); + assert_eq!( + first.get_int("nextPagePos"), + Some(support::items(&first).len() as i64) + ); + let second = changed( + &fixture, + &commit, + 1000, + first.get_int("nextPagePos").unwrap(), + ); + assert_eq!(second.get_bool("hasMore"), false); + assert_eq!(second.get("nextPagePos"), Some(&SdkValue::Null)); + assert_eq!( + [support::paths(&first), support::paths(&second)].concat(), + expected + ); + fixture.cleanup(); +} diff --git a/engine/crates/layerfs-store-service/src/history/branch.rs b/engine/crates/layerfs-store-service/src/history/branch.rs index 6519bf5ed..0063de8be 100644 --- a/engine/crates/layerfs-store-service/src/history/branch.rs +++ b/engine/crates/layerfs-store-service/src/history/branch.rs @@ -136,7 +136,7 @@ pub fn list( } /// The listed form of the project's main line. -fn main_item(stack: &layerfs_history::LayerStackRecord) -> SdkValue { +pub(super) fn main_item(stack: &layerfs_history::LayerStackRecord) -> SdkValue { SdkValue::map([ ("name", SdkValue::text(MAIN)), ("id", SdkValue::Null), @@ -146,7 +146,7 @@ fn main_item(stack: &layerfs_history::LayerStackRecord) -> SdkValue { } /// The listed form of one Branch. -fn item(branch: &layerfs_history::BranchRecord) -> SdkValue { +pub(super) fn item(branch: &layerfs_history::BranchRecord) -> SdkValue { SdkValue::map([ ("name", SdkValue::text(branch.name.as_str())), ("id", SdkValue::text(hex(branch.id.as_slice()))), diff --git a/engine/crates/layerfs-store-service/src/history/graph.rs b/engine/crates/layerfs-store-service/src/history/graph.rs new file mode 100644 index 000000000..9fd8b37fb --- /dev/null +++ b/engine/crates/layerfs-store-service/src/history/graph.rs @@ -0,0 +1,180 @@ +//! Branch-owning history, its fork point and current main publication state. +use super::branch::{item, main_item}; +use super::lookup::{self as args, commit_value, hex, history, Answer}; +use crate::store::Store; +use layerfs_bridge::control::SdkValue; +use layerfs_history::{ + CommitHistoryRequest, CommitId, CommitRecord, LayerHistoryRequest, LayerStackRecord, NodeId, + MAXIMUM_PAGE_RECORDS, +}; +use std::collections::HashSet; +use std::sync::Arc; + +struct Page<'a> { + branch: &'a str, + limit: usize, + pos: u64, + published: &'a HashSet, + all_published: bool, + seen: u64, + spent: usize, + items: Vec, + more: bool, +} + +impl<'a> Page<'a> { + fn new( + branch: &'a str, + limit: usize, + pos: u64, + published: &'a HashSet, + all_published: bool, + ) -> Self { + Self { + branch, + limit, + pos, + published, + all_published, + seen: 0, + spent: 0, + items: Vec::new(), + more: false, + } + } + + fn push(&mut self, record: &CommitRecord) -> bool { + if self.seen < self.pos { + self.seen += 1; + return true; + } + let cost = record.meta.message.as_ref().map_or(0, String::len) + 1024; + if self.items.len() == self.limit || self.spent + cost > crate::read::reads::REPLY_BUDGET { + self.more = true; + return false; + } + self.spent += cost; + let published = self.all_published || self.published.contains(&record.id); + let mut value = commit_value(record, Some(self.branch)); + if let SdkValue::Map(fields) = &mut value { + fields.push(("published".into(), SdkValue::Bool(published))); + } + self.items.push(value); + true + } + + fn finish(self) -> (Vec, bool) { + (self.items, self.more) + } +} + +fn published( + store: &Arc, + stack: &LayerStackRecord, + include_commits: bool, +) -> Result<(HashSet, Vec), layerfs_bridge::control::SdkRefusal> { + let mut cursor = None; + let mut published = HashSet::new(); + let mut commits = Vec::new(); + loop { + let page = store + .history() + .layer_history(&LayerHistoryRequest { + stack: stack.id, + start: Some(stack.head_layer), + cursor, + limit: MAXIMUM_PAGE_RECORDS, + }) + .map_err(history)?; + for layer in page.records { + if let Some(commit) = layer.source_commit { + published.insert(commit); + if include_commits { + commits.push(args::commit(store, commit)?); + } + } + } + match page.continuation { + Some(next) => cursor = Some(next), + None => return Ok((published, commits)), + } + } +} + +fn fork_point(node: Option) -> SdkValue { + node.map_or(SdkValue::Null, |node| { + let (kind, id) = match node { + NodeId::Layer(id) => ("layer", hex(id.as_slice())), + NodeId::Commit(id) => ("commit", hex(id.as_slice())), + }; + SdkValue::map([("kind", SdkValue::text(kind)), ("id", SdkValue::text(id))]) + }) +} + +/// A Branch's own Commits, stopping before its Commit fork point, or main's +/// Layer-source Commits. Publication is read from main's current Layer stack. +pub fn graph( + store: &Arc, + stack: &LayerStackRecord, + name: &str, + limit: usize, + pos: u64, +) -> Answer { + let main = name == super::MAIN; + let branch = if main { + None + } else { + Some(args::branch(store, stack, name)?) + }; + let (published, main_commits) = published(store, stack, main)?; + let mut page = Page::new(name, limit, pos, &published, main); + if main { + for record in &main_commits { + if !page.push(record) { + break; + } + } + } else if let Some(branch) = &branch { + let fork = match branch.forked_from { + Some(NodeId::Commit(id)) => Some(id), + _ => None, + }; + let mut cursor = None; + 'pages: loop { + let found = store + .history() + .commit_history(&CommitHistoryRequest { + branch: branch.id, + start: branch.head_commit, + cursor, + limit: MAXIMUM_PAGE_RECORDS, + }) + .map_err(history)?; + for record in found.records { + if Some(record.id) == fork { + break 'pages; + } + if !page.push(&record) { + break 'pages; + } + } + match found.continuation { + Some(next) => cursor = Some(next), + None => break, + } + } + } + let (commits, more) = page.finish(); + let next = args::next_page(pos, commits.len(), more); + let branch_value = branch.as_ref().map_or_else(|| main_item(stack), item); + Ok(SdkValue::map([ + ("branch", branch_value), + ( + "forkPoint", + fork_point(branch.and_then(|branch| branch.forked_from)), + ), + ("commits", SdkValue::List(commits)), + ("hasMore", SdkValue::Bool(!matches!(next, SdkValue::Null))), + ("nextPagePos", next), + ])) +} diff --git a/engine/crates/layerfs-store-service/src/history/mod.rs b/engine/crates/layerfs-store-service/src/history/mod.rs index 85e5f719e..e72fd18dd 100644 --- a/engine/crates/layerfs-store-service/src/history/mod.rs +++ b/engine/crates/layerfs-store-service/src/history/mod.rs @@ -1,6 +1,7 @@ //! Branch history over the global Store: typed fork, merge, publish and //! listings, and the lookups and refusals the Store calls share. pub mod branch; +pub mod graph; pub mod lookup; mod units; diff --git a/engine/crates/layerfs-store-service/src/read/compare.rs b/engine/crates/layerfs-store-service/src/read/compare.rs index e23866c8d..d98c289fb 100644 --- a/engine/crates/layerfs-store-service/src/read/compare.rs +++ b/engine/crates/layerfs-store-service/src/read/compare.rs @@ -244,12 +244,12 @@ fn order(a: &str, b: &str) -> std::cmp::Ordering { fn changes( store: &Arc, reader: &StorePorts, - left: &Snap, + left: Option<&Snap>, right: &Snap, request: Arg, ) -> Result<(Vec, Next), SdkRefusal> { let request = request?; - if left.root == right.root { + if left.is_some_and(|left| left.root == right.root) { let done = Next { pos: SdkValue::Null, cursor: SdkValue::Null, @@ -259,26 +259,33 @@ fn changes( let hints = CatalogHints::new(store.history()); // One memo for the page: lookups share the B-trees' upper pages. let memo = Memo::new(reader); - let key = (left.root, right.root); - let plan = match super::plans::find(key) { - Some(kept) => kept, - None => { - let made = Plan::make(&memo, &hints, &left.tree, &right.tree); - if let Some(error) = hints.failure.take() { - return Err(args::history(error)); + let plan = left + .map(|left| { + let key = (left.root, right.root); + match super::plans::find(key) { + Some(kept) => Ok(kept), + None => { + let made = Plan::make(&memo, &hints, &left.tree, &right.tree); + if let Some(error) = hints.failure.take() { + return Err(args::history(error)); + } + let plan = made.map_err(args::content)?.map(Arc::new); + super::plans::keep(key, plan.clone()); + Ok(plan) + } } - let plan = made.map_err(args::content)?.map(Arc::new); - super::plans::keep(key, plan.clone()); - plan - } - }; + }) + .transpose()?; let first = match &request.cursor { Some(cursor) => component(cursor, "/")?, None => None, }; - let mut walk = match &plan { - Some(plan) => ChangeWalk::planned(&memo, &left.tree, &right.tree, plan, first.as_ref()), - None => ChangeWalk::full(&memo, &left.tree, &right.tree, first.as_ref()), + let mut walk = match (left, plan.as_ref()) { + (Some(left), Some(Some(plan))) => { + ChangeWalk::planned(&memo, &left.tree, &right.tree, plan, first.as_ref()) + } + (Some(left), _) => ChangeWalk::full(&memo, &left.tree, &right.tree, first.as_ref()), + (None, _) => ChangeWalk::full_from_empty(&memo, &right.tree, first.as_ref()), } .map_err(args::content)?; emit(reader, &mut walk, &request) @@ -418,7 +425,7 @@ fn listed( request: Arg, sides: [SdkValue; 2], ) -> Answer { - let (items, next) = changes(store, reader, from, to, request)?; + let (items, next) = changes(store, reader, Some(from), to, request)?; let [from, to] = sides; answer(vec![ ("kind", SdkValue::text("ok")), @@ -454,6 +461,60 @@ pub fn diff_commits( listed(store, reader, from, to, request, [side(from), side(to)]) } +/// The changes a Commit made against its first parent, its base Layer for a +/// parentless merge, or an empty tree for a true root Commit. +pub fn changed_paths( + store: &Arc, + stack: &LayerStackRecord, + reader: &StorePorts, + commit: &str, + request: Arg, +) -> Answer { + let id = match args::node(commit)? { + layerfs_history::NodeId::Commit(id) => id, + layerfs_history::NodeId::Layer(_) => { + return Err(invalid("changed_paths takes a saved Commit")); + } + }; + let record = args::commit(store, id)?; + let to = snapshot::resolve(store, stack, reader, commit)?; + let from = if let Some(parent) = record.parent { + Some(snapshot::resolve( + store, + stack, + reader, + &args::hex(parent.as_slice()), + )?) + } else if record.second_parent.is_some() { + Some(snapshot::resolve( + store, + stack, + reader, + &args::hex(record.base_layer.as_slice()), + )?) + } else { + None + }; + let request = request.map(|mut request| { + request.hunks = false; + request + }); + let (items, next) = changes(store, reader, from.as_ref(), &to, request)?; + answer(vec![ + ("commit", SdkValue::text(args::hex(record.id.as_slice()))), + ( + "parent", + SdkValue::opt_text(record.parent.map(|id| args::hex(id.as_slice()))), + ), + ("items", SdkValue::List(items)), + ( + "hasMore", + SdkValue::Bool(!matches!(next.pos, SdkValue::Null)), + ), + ("nextPagePos", next.pos), + ]) +} + /// The changes from Branch (or main) `from` to `to`, one page; the names /// label the sides. pub fn diff_branches( From 23521895113a9587d048e5e2fd88939d2de2f13c Mon Sep 17 00:00:00 2001 From: Mola-maker <2249464964@qq.com> Date: Thu, 29 Oct 2026 14:11:14 +0800 Subject: [PATCH 05/39] P7: Commit details: author and labels (layerfs-connect#58) workspace.commit accepts trailing { author?, labels? }: one author identity slot plus free-form labels (order and duplicates preserved), bounded by author/label 4096 bytes, at most 64 labels and 4096 bytes of labels JSON, refused InvalidInput before any binding call. History reads return author (null when unset) and labels (always an array); both are creation-time annotations outside Commit identity, so verify_commit excludes them like time_ms and a repeated derived id keeps its original attribution. The commit table appends nullable author (index 17) and labels (index 18) through an additive writable-open migration, and the per-item budget cost becomes message + author + labels JSON + 1024 in list_commits and graph. CommitRecord MAXIMUM_ENCODED_BYTES is corrected to the codec-true 12608 (the old 4510 doc figure overstated the old layout by 100), pinned by a maximal-record width test. Production LOC: 103145 -> 103518 (delta +373); core+engine+api via python -I tools/production_loc.py --json; reference absent (retired R9c; last counted 65,417 at tree 498dd1917812). Co-Authored-By: Claude Code --- api/agent/src/client.js | 31 +- api/agent/src/workspace/commit.js | 6 +- api/agent/test/area-p.mjs | 33 +- api/agent/test/calls.mjs | 4 +- api/agent/test/fixture/cases-area-p.mjs | 30 +- api/agent/test/fixture/cases.mjs | 2 +- api/agent/test/fixture/fake-native.cjs | 4 +- api/cli/src/workspace/commit.js | 13 +- api/cli/test/cli.test.mjs | 9 +- api/docs/agent.md | 17 +- .../layerfs-history/src/contract/identity.rs | 11 +- .../layerfs-history/src/contract/labels.rs | 170 +++++++ .../layerfs-history/src/contract/mod.rs | 1 + .../layerfs-history/src/contract/records.rs | 45 +- core/crates/layerfs-history/src/lib.rs | 4 +- core/docs/architecture/16-history.md | 9 + core/docs/architecture/82-sdk-call-channel.md | 13 +- .../architecture/84-global-sqlite-store.md | 5 + docs/general/sdk.md | 5 +- .../crates/layerfs-api/src/sandbox/commit.rs | 2 + .../layerfs-api/tests/area_p7_attribution.rs | 427 ++++++++++++++++++ .../layerfs-api/tests/concurrent_commits.rs | 2 + .../layerfs-api/tests/golden/sdk_methods.txt | 24 +- .../layerfs-api/tests/kernel_application.rs | 2 +- engine/crates/layerfs-api/tests/lane_merge.rs | 4 + .../crates/layerfs-api/tests/merge_report.rs | 2 + .../crates/layerfs-api/tests/mount_control.rs | 28 +- .../crates/layerfs-api/tests/relink_commit.rs | 2 + .../crates/layerfs-api/tests/support/sdk.rs | 27 +- .../layerfs-bridge/src/control_history.rs | 73 ++- .../layerfs-bridge/src/control_merge.rs | 9 +- .../crates/layerfs-bridge/src/sdk_values.rs | 5 + .../layerfs-bridge/tests/control_records.rs | 2 + .../layerfs-bridge/tests/observed_records.rs | 2 + .../layerfs-daemon/src/control/operations.rs | 2 + .../layerfs-daemon/tests/forced_producers.rs | 2 + .../tests/mounted_concurrency.rs | 2 + .../layerfs-daemon/tests/mounted_cycles.rs | 2 + .../tests/mounted_orphan_commit.rs | 2 + .../tests/mounted_parallel_commits.rs | 2 + .../layerfs-daemon/tests/support/mounted.rs | 2 + engine/crates/layerfs-node/src/workspace.rs | 47 +- .../sql/sqlite/history.sql | 2 + .../queries/history/commit_commit_by_id.sql | 2 +- .../queries/history/commit_insert_commit.sql | 2 +- .../src/backend/sqlite/schema.rs | 45 +- .../layerfs-persistence/src/history/commit.rs | 10 +- .../layerfs-persistence/src/history/rows.rs | 5 + .../layerfs-persistence/src/store/open.rs | 1 + .../tests/old_format_open.rs | 13 +- .../src/history/branch.rs | 5 +- .../src/history/graph.rs | 5 +- .../layerfs-store-service/src/store/commit.rs | 2 + .../src/store/commit_types.rs | 4 + .../layerfs-store-service/src/write/files.rs | 2 + 55 files changed, 1089 insertions(+), 93 deletions(-) create mode 100644 core/crates/layerfs-history/src/contract/labels.rs create mode 100644 engine/crates/layerfs-api/tests/area_p7_attribution.rs diff --git a/api/agent/src/client.js b/api/agent/src/client.js index a212d146b..2a4556a08 100644 --- a/api/agent/src/client.js +++ b/api/agent/src/client.js @@ -139,12 +139,35 @@ export function diffOptions({ cursor, hunks } = {}) { return { ...(cursor == null ? {} : { cursor }), ...(hunks === undefined ? {} : { hunks }) }; } +function checkedText(value, name) { + if (value === undefined) return undefined; + if (typeof value !== "string" || Buffer.byteLength(value) > 4096 || [...value].some((c) => (c.codePointAt(0) < 32 && c !== "\n" && c !== "\t") || (c.codePointAt(0) >= 127 && c.codePointAt(0) <= 159) || (c.codePointAt(0) >= 0xd800 && c.codePointAt(0) <= 0xdfff))) { + throw new LayerFSError("InvalidInput", `${name} must be UTF-8, at most 4096 bytes, with no controls except newline/tab`, null); + } + return value; +} + export function checkedMessage(message) { - if (message === undefined) return undefined; - if (typeof message !== "string" || Buffer.byteLength(message) > 4096 || [...message].some((c) => (c.codePointAt(0) < 32 && c !== "\n" && c !== "\t") || (c.codePointAt(0) >= 127 && c.codePointAt(0) <= 159) || (c.codePointAt(0) >= 0xd800 && c.codePointAt(0) <= 0xdfff))) { - throw new LayerFSError("InvalidInput", "message must be UTF-8, at most 4096 bytes, with no controls except newline/tab", null); + return checkedText(message, "message"); +} + +export function commitOptions(options = {}) { + if (options === null || typeof options !== "object" || Array.isArray(options)) { + throw new LayerFSError("InvalidInput", "options must be { author?, labels? }", null); + } + for (const key of Object.keys(options)) { + if (!["author", "labels"].includes(key)) throw new LayerFSError("InvalidInput", `unknown workspace.commit option "${key}"`, null); + } + const author = checkedText(options.author, "author"); + const labels = options.labels; + if (labels !== undefined && (!Array.isArray(labels) || labels.length > 64 || [...labels].some((label) => typeof label !== "string"))) { + throw new LayerFSError("InvalidInput", "labels must be an array of at most 64 strings", null); + } + const checked = labels?.map((label) => checkedText(label, "each label")); + if (checked !== undefined && Buffer.byteLength(JSON.stringify(checked)) > 4096) { + throw new LayerFSError("InvalidInput", "labels JSON must be at most 4096 bytes", null); } - return message; + return { ...(author === undefined ? {} : { author }), ...(checked === undefined ? {} : { labels: checked }) }; } /** A write's last argument as call fields: a Commit message string, or diff --git a/api/agent/src/workspace/commit.js b/api/agent/src/workspace/commit.js index 7c8135644..fe4cd4402 100644 --- a/api/agent/src/workspace/commit.js +++ b/api/agent/src/workspace/commit.js @@ -1,5 +1,5 @@ // workspace.commit: one Commit of the Workspace's changes. -import { invokeClient, workspaceArgs, checkedMessage } from "../client.js"; -export function commit(client, ws, message, force = false) { - return invokeClient(client, "workspace.commit", { ...workspaceArgs(ws), message: checkedMessage(message), force }); +import { invokeClient, workspaceArgs, checkedMessage, commitOptions } from "../client.js"; +export function commit(client, ws, message, force = false, options = {}) { + return invokeClient(client, "workspace.commit", { ...workspaceArgs(ws), message: checkedMessage(message), force, ...commitOptions(options) }); } diff --git a/api/agent/test/area-p.mjs b/api/agent/test/area-p.mjs index 29cb249d5..7351331e7 100644 --- a/api/agent/test/area-p.mjs +++ b/api/agent/test/area-p.mjs @@ -38,6 +38,35 @@ for (const c of CASES) { } } +try { + const ws = { sandbox: "1", path: "/workspaces/w1" }; + const before = native.calls.length; + const totalOverflow = Array(64).fill(""); + totalOverflow[0] = "x".repeat(3904); + const invalid = [ + null, { unknown: true }, { author: null }, { author: 7 }, { labels: null }, { labels: "call-7" }, { labels: [7] }, { labels: [undefined] }, { labels: Array(1) }, + { author: "a".repeat(4097) }, { labels: ["bad\u0007"] }, { labels: Array(65).fill("x") }, { labels: totalOverflow }, + ]; + for (const options of invalid) { + await assert.rejects(lf.workspace.commit(ws, "invalid", false, options), { code: "InvalidInput" }); + } + assert.equal(native.calls.length, before, "invalid P7 options send nothing"); +} catch (failure) { + failures.push(`P7 option validation: ${failure.message}`); +} + +try { + const ws = { sandbox: "1", path: "/workspaces/w1" }; + const labels = Array(64).fill(""); + labels[0] = "x".repeat(3903); + assert.equal(Buffer.byteLength(JSON.stringify(labels)), 4096); + const result = await lf.workspace.commit(ws, "boundary", false, { author: "a".repeat(4096), labels }); + assert.equal(result.commit.author.length, 4096); + assert.deepEqual(result.commit.labels, labels); +} catch (failure) { + failures.push(`P7 option boundaries: ${failure.message}`); +} + native.branchList = BUDGET_BRANCH_NAMES; native.branchReplyBudget = 48 * 1024; const before = native.calls.length; @@ -71,7 +100,7 @@ try { await lf.close(); if (failures.length) { - console.error(`FAIL area-p: ${failures.length} of ${CASES.length + 1}\n${failures.join("\n")}`); + console.error(`FAIL area-p: ${failures.length} of ${CASES.length + 3}\n${failures.join("\n")}`); process.exit(1); } -console.log(`PASS area-p (${CASES.length} cases + 1 budget traversal)`); +console.log(`PASS area-p (${CASES.length} cases + 2 P7 validation groups + 1 budget traversal)`); diff --git a/api/agent/test/calls.mjs b/api/agent/test/calls.mjs index 9bebd3f14..dcf551d34 100644 --- a/api/agent/test/calls.mjs +++ b/api/agent/test/calls.mjs @@ -8,9 +8,9 @@ import { readdirSync } from "node:fs"; import { createRequire } from "node:module"; import { fileURLToPath } from "node:url"; import { CASES, ENV, contains, made, plain } from "./fixture/cases.mjs"; -import { P1_CASES } from "./fixture/cases-area-p.mjs"; +import { P1_CASES, P7_CASES } from "./fixture/cases-area-p.mjs"; -const ALL_CASES = [...CASES, ...P1_CASES]; +const ALL_CASES = [...CASES, ...P1_CASES, ...P7_CASES]; const FAKE = fileURLToPath(new URL("./fixture/fake-native.cjs", import.meta.url)); Object.assign(process.env, ENV, { LAYERFS_NATIVE: FAKE }); delete process.env.CLI_FAKE_LOG; diff --git a/api/agent/test/fixture/cases-area-p.mjs b/api/agent/test/fixture/cases-area-p.mjs index e9dbf993a..9bfeba582 100644 --- a/api/agent/test/fixture/cases-area-p.mjs +++ b/api/agent/test/fixture/cases-area-p.mjs @@ -121,7 +121,7 @@ for (const count of [0, 64, 65, 129]) { const graphResult = (branch, pagePos) => ({ branch: { name: branch, id: "branch1234567890ab", base: "base1234567890ab", head: "head1234567890ab", forkedFrom: "c0ffee" }, forkPoint: { kind: "commit", id: "c0ffee" }, - commits: [{ id: "decaf", branch, parent: "parent1234567890", logical: 120, newCanonical: 40, newStored: 30, newMetadata: 8, published: true }], + commits: [{ id: "decaf", branch, parent: "parent1234567890", author: null, labels: [], logical: 120, newCanonical: 40, newStored: 30, newMetadata: 8, published: true }], hasMore: true, nextPagePos: pagePos + 1, }); @@ -153,4 +153,30 @@ export const P1_CASES = [ }, ]; -export const CASES = [...branches, ...workspaces, ...P1_CASES]; +const WS = { sandbox: "1", path: "/workspaces/w1" }; +const attributedResult = (author, labels) => ({ + kind: "Committed", + head: "c0ffee", + commit: { id: "c0ffee", author, labels, logical: 120, newCanonical: 40, newStored: 30, newMetadata: 8 }, +}); + +export const P7_CASES = [ + { + name: "P7 workspace commit author and labels", + argv: ["workspace", "commit", "1:/workspaces/w1", "-m", "attributed", "--author", "agent-p7", "--labels", '["call-7","call-7","last"]'], + sdk: (lf) => lf.workspace.commit(WS, "attributed", false, { author: "agent-p7", labels: ["call-7", "call-7", "last"] }), + op: "workspace.commit", + sent: { ...WS, message: "attributed", force: false, author: "agent-p7", labels: ["call-7", "call-7", "last"] }, + result: attributedResult("agent-p7", ["call-7", "call-7", "last"]), + }, + { + name: "P7 workspace commit empty labels", + argv: ["workspace", "commit", "1:/workspaces/w1", "--author", "agent-empty", "--labels", "[]"], + sdk: (lf) => lf.workspace.commit(WS, undefined, false, { author: "agent-empty", labels: [] }), + op: "workspace.commit", + sent: { ...WS, force: false, author: "agent-empty", labels: [] }, + result: attributedResult("agent-empty", []), + }, +]; + +export const CASES = [...branches, ...workspaces, ...P1_CASES, ...P7_CASES]; diff --git a/api/agent/test/fixture/cases.mjs b/api/agent/test/fixture/cases.mjs index ec7072939..ad0bd6b9f 100644 --- a/api/agent/test/fixture/cases.mjs +++ b/api/agent/test/fixture/cases.mjs @@ -14,7 +14,7 @@ // returns (consistency). const WS = { sandbox: "1", path: "/workspaces/w1" }; const HANDLE = "1:/workspaces/w1"; -const commit = { id: "c0ffee", logical: 120, newCanonical: 40, newStored: 30, newMetadata: 8 }; +const commit = { id: "c0ffee", author: null, labels: [], logical: 120, newCanonical: 40, newStored: 30, newMetadata: 8 }; const committed = { kind: "Committed", head: "c0ffee", commit }; const page = { items: [], hasMore: false, nextPagePos: null }; diff --git a/api/agent/test/fixture/fake-native.cjs b/api/agent/test/fixture/fake-native.cjs index f78760354..2415dab8e 100644 --- a/api/agent/test/fixture/fake-native.cjs +++ b/api/agent/test/fixture/fake-native.cjs @@ -28,7 +28,7 @@ const log = (op, args) => { if (process.env.CLI_FAKE_LOG) appendFileSync(process.env.CLI_FAKE_LOG, `${JSON.stringify([op, plain(args)])}\n`); }; -const commit = { id: "c0ffee", logical: 120, newCanonical: 40, newStored: 30, newMetadata: 8 }; +const commit = { id: "c0ffee", author: null, labels: [], logical: 120, newCanonical: 40, newStored: 30, newMetadata: 8 }; const committed = { kind: "Committed", head: commit.id, commit }; const refusal = (code, message, detail = null) => ({ refused: { code, message, detail } }); @@ -92,7 +92,7 @@ function answer(op, args) { const code = Number(exit[1]); return { value: { kind: `exit:${code}`, exitCode: code, stdout: `out:${args.command}`, stderr: "err", truncated: false } }; } - case "workspace.commit": return { value: committed }; + case "workspace.commit": return { value: { ...committed, commit: { ...commit, author: args.author ?? null, labels: args.labels ?? [] } } }; case "workspace.unmount": return { value: args.force ? { kind: "ForceUnmounted", abort: "Written", detach: "Detached", cleanup: "Queued" } : { kind: "Unmounted" } }; case "backend.daemon.process.start": return { value: { kind: "running", process: "proc_1_ab", command: args.command } }; case "backend.store.project.get_history": return { value: exports.history ?? { kind: "ok", version: "h1", project: "website", branches: [], commits: [], more: {}, layers: [], workspaces: [] } }; diff --git a/api/cli/src/workspace/commit.js b/api/cli/src/workspace/commit.js index 457bae1fa..d1d340cf0 100644 --- a/api/cli/src/workspace/commit.js +++ b/api/cli/src/workspace/commit.js @@ -1,9 +1,16 @@ -// layerfs workspace commit: workspace.commit(ws, message?, force?). +// layerfs workspace commit: workspace.commit(ws, message?, force?, options?). import { MESSAGE } from "../common.js"; +import { UsageError } from "../argv.js"; export default { args: ["ws"], - options: { ...MESSAGE, force: "bool" }, + options: { ...MESSAGE, force: "bool", author: "text", labels: "json" }, summary: "Commit the Workspace's changes to its Branch", - run: (lf, a, o) => lf.workspace.commit(a.ws, o.message, o.force), + prepare(_a, o) { + if (o.labels !== undefined && (!Array.isArray(o.labels) || o.labels.some((label) => typeof label !== "string"))) { + throw new UsageError("--labels must be a JSON array of strings"); + } + return {}; + }, + run: (lf, a, o) => lf.workspace.commit(a.ws, o.message, o.force, { author: o.author, labels: o.labels }), }; diff --git a/api/cli/test/cli.test.mjs b/api/cli/test/cli.test.mjs index a5fea3a6d..5450ebdc6 100644 --- a/api/cli/test/cli.test.mjs +++ b/api/cli/test/cli.test.mjs @@ -16,9 +16,9 @@ import { CALLS, INIT } from "../src/calls.js"; import { usage } from "../src/help.js"; import { tables, written } from "./doc-tables.mjs"; import { CASES, ENV, contains, made, plain } from "../../agent/test/fixture/cases.mjs"; -import { P1_CASES } from "../../agent/test/fixture/cases-area-p.mjs"; +import { P1_CASES, P7_CASES } from "../../agent/test/fixture/cases-area-p.mjs"; -const AGREEMENT_CASES = [...CASES, ...P1_CASES]; +const AGREEMENT_CASES = [...CASES, ...P1_CASES, ...P7_CASES]; const CLI = fileURLToPath(new URL("../src/layerfs.js", import.meta.url)); const FAKE = fileURLToPath(new URL("../../agent/test/fixture/fake-native.cjs", import.meta.url)); @@ -133,6 +133,9 @@ for (const [why, argv, message] of [ ["bool with a value", ["branch", "diff_branches", "a", "b", "--no-hunks=yes"], "--no-hunks takes no value"], ["int that is not a whole number", ["project", "list_branches", "--page-size", "x"], "--page-size takes a whole number"], ["bad JSON option", ["branch", "merge", "a", "b", "ab", "--units", "{"], "--units is not JSON"], + ["malformed labels JSON", ["workspace", "commit", WS, "--labels", "{"], "--labels is not JSON"], + ["labels that are not an array", ["workspace", "commit", WS, "--labels", "null"], "--labels must be a JSON array of strings"], + ["a non-string label", ["workspace", "commit", WS, "--labels", '["call",7]'], "--labels must be a JSON array of strings"], ["option with no value", ["fs", "mkdir", "cart", "/d", "-m"], "-m needs a value"], ["bad Workspace handle", ["workspace", "commit", "1/workspaces/w1"], "a Workspace is :"], ["Workspace handle with no path", ["workspace", "commit", "1:"], "a Workspace is :"], @@ -211,7 +214,7 @@ test("exec --stream passes the command's output and exit code through", () => { test("--text prints readable results", () => { const r = cli(["--text", "workspace", "commit", WS]); - assert.equal(r.out, "kind: Committed\nhead: c0ffee\ncommit:\n id: c0ffee\n logical: 120\n newCanonical: 40\n newStored: 30\n newMetadata: 8\n"); + assert.equal(r.out, "kind: Committed\nhead: c0ffee\ncommit:\n id: c0ffee\n author: -\n labels: (none)\n logical: 120\n newCanonical: 40\n newStored: 30\n newMetadata: 8\n"); assert.equal(cli(["--text", "fs", "read", "main", "/a"]).out, "text: |\n a\nnextLine: 2\n"); }); diff --git a/api/docs/agent.md b/api/docs/agent.md index 21e753b1b..7372bb57c 100644 --- a/api/docs/agent.md +++ b/api/docs/agent.md @@ -195,7 +195,20 @@ base, activity, changed }` (`activity` Idle or Busy) plus the daemon's record. A `{ sandbox, path }`, its Sandbox's number and its path in that Sandbox (`/workspaces/1`, `/workspaces/2`, … numbered per Sandbox); `exec`, `status`, `commit` and `unmount` take it. Every Workspace of a project sees the same -Store, so which Sandbox holds it changes nothing a command sees. A Sandbox's +Store, so which Sandbox holds it changes nothing a command sees. + +`workspace.commit(ws, message?, force?, options?)` accepts trailing +`{ author?, labels? }`: one author string and an array of label strings, +preserving label order and duplicates. Author and each label are at most 4096 +UTF-8 bytes with no controls except newline/tab; there are at most 64 labels +and their complete JSON array is at most 4096 bytes. Null options/fields, +unknown keys and wrong shapes are refused `InvalidInput` before the binding. +The CLI equivalents are `--author ` and `--labels `. +Commit results and every history read carry `author` (string or null) and +`labels` (always an array). These creation-time annotations do not participate +in Commit identity; a repeated derived Commit keeps its original attribution. + +A Sandbox's container is `-`, labelled `layerfs.role=sandbox`, `layerfs.project` and `layerfs.sandbox=`; its only mount is the project's Store volume. Its records are `LAYERFS_HOME//sandboxes//`, with @@ -532,7 +545,7 @@ table differs, and when a family's files stop matching the SDK's. | `fs.mv` | `layerfs fs mv [-m ]` | | `workspace.mount` | `layerfs workspace mount ` | | `workspace.exec` | `layerfs workspace exec [-m ] [--stream]` | -| `workspace.commit` | `layerfs workspace commit [-m ] [--force]` | +| `workspace.commit` | `layerfs workspace commit [-m ] [--force] [--author ] [--labels ]` | | `workspace.unmount` | `layerfs workspace unmount [--discard] [--force]` | | `sandbox.create` | `layerfs sandbox create [--image ]` | | `sandbox.list_workspaces` | `layerfs sandbox list_workspaces [--page-size ] [--page-pos ]` | diff --git a/core/crates/layerfs-history/src/contract/identity.rs b/core/crates/layerfs-history/src/contract/identity.rs index d3234b1d6..e0e58e2a5 100644 --- a/core/crates/layerfs-history/src/contract/identity.rs +++ b/core/crates/layerfs-history/src/contract/identity.rs @@ -8,9 +8,10 @@ //! //! What a hash cannot carry is provenance: a Layer's source Branch and source //! Commit are deliberately outside its identity. Two records that derive the -//! same identity must therefore agree on every immutable field, and a -//! disagreement is an integrity failure rather than an overwrite. The catalog -//! enforces that; this module only supplies the values. +//! same identity must therefore agree on every identity-bearing field, and a +//! disagreement is an integrity failure rather than an overwrite. Creation-time +//! annotations such as a Commit's author, labels and time are not identity-bearing; +//! the first stored record wins. The catalog enforces both rules. //! //! Identity creation is an authority decision. Stack and Branch IDs and Workspace //! incarnations arrive as explicit checked inputs; this crate never reads a @@ -282,8 +283,8 @@ impl CommitId { /// extended by a merge's second parent. A Commit without a second parent /// keeps the identity it had before second parents existed. /// - /// The metadata's Branch, kind, attribution and merge origin are part of - /// the identity; its time is not. + /// The metadata's Branch, kind, message and merge origin are part of the + /// identity; its author, labels and time are creation-time annotations. pub fn derive( root: ObjectId, parent: Option, diff --git a/core/crates/layerfs-history/src/contract/labels.rs b/core/crates/layerfs-history/src/contract/labels.rs new file mode 100644 index 000000000..28da8cb6d --- /dev/null +++ b/core/crates/layerfs-history/src/contract/labels.rs @@ -0,0 +1,170 @@ +//! Canonical JSON storage for a Commit's bounded free-form labels. +use crate::error::{HistoryError, HistoryResult}; + +/// Encodes labels as one JSON array, preserving order and duplicates. +pub fn encode(labels: &[String]) -> String { + let mut out = String::from("["); + for (index, label) in labels.iter().enumerate() { + if index != 0 { + out.push(','); + } + out.push('"'); + for c in label.chars() { + match c { + '"' => out.push_str("\\\""), + '\\' => out.push_str("\\\\"), + '\n' => out.push_str("\\n"), + '\t' => out.push_str("\\t"), + '\r' => out.push_str("\\r"), + '\u{08}' => out.push_str("\\b"), + '\u{0c}' => out.push_str("\\f"), + c if c.is_control() => { + use std::fmt::Write as _; + write!(out, "\\u{:04x}", c as u32).expect("String write"); + } + c => out.push(c), + } + } + out.push('"'); + } + out.push(']'); + out +} + +/// Decodes one stored JSON array of strings. Malformed JSON is an integrity failure. +pub fn decode(text: &str) -> HistoryResult> { + let mut parser = Parser { text, at: 0 }; + let labels = parser.array()?; + parser.space(); + if parser.at == text.len() { + Ok(labels) + } else { + Err(malformed()) + } +} + +fn malformed() -> HistoryError { + HistoryError::Integrity("Commit labels JSON") +} + +struct Parser<'a> { + text: &'a str, + at: usize, +} + +impl Parser<'_> { + fn array(&mut self) -> HistoryResult> { + self.space(); + self.byte(b'[')?; + self.space(); + let mut labels = Vec::new(); + if self.take(b']') { + return Ok(labels); + } + loop { + labels.push(self.string()?); + self.space(); + if self.take(b']') { + return Ok(labels); + } + self.byte(b',')?; + } + } + + fn string(&mut self) -> HistoryResult { + self.space(); + self.byte(b'"')?; + let mut out = String::new(); + loop { + let Some(rest) = self.text.get(self.at..) else { + return Err(malformed()); + }; + let c = rest.chars().next().ok_or_else(malformed)?; + self.at += c.len_utf8(); + match c { + '"' => return Ok(out), + '\\' => self.escape(&mut out)?, + c if (c as u32) < 0x20 => return Err(malformed()), + c => out.push(c), + } + } + } + + fn escape(&mut self, out: &mut String) -> HistoryResult<()> { + let c = self.char()?; + match c { + '"' | '\\' | '/' => out.push(c), + 'b' => out.push('\u{08}'), + 'f' => out.push('\u{0c}'), + 'n' => out.push('\n'), + 'r' => out.push('\r'), + 't' => out.push('\t'), + 'u' => { + let first = self.hex()?; + let scalar = if (0xd800..0xdc00).contains(&first) { + self.byte(b'\\')?; + self.byte(b'u')?; + let second = self.hex()?; + if !(0xdc00..0xe000).contains(&second) { + return Err(malformed()); + } + 0x10000 + (((first - 0xd800) as u32) << 10) + (second - 0xdc00) as u32 + } else if (0xdc00..0xe000).contains(&first) { + return Err(malformed()); + } else { + first as u32 + }; + out.push(char::from_u32(scalar).ok_or_else(malformed)?); + } + _ => return Err(malformed()), + } + Ok(()) + } + + fn hex(&mut self) -> HistoryResult { + let mut value = 0u16; + for _ in 0..4 { + let digit = self.char()?.to_digit(16).ok_or_else(malformed)?; + value = (value << 4) | digit as u16; + } + Ok(value) + } + + fn space(&mut self) { + while self + .text + .as_bytes() + .get(self.at) + .is_some_and(|byte| matches!(byte, b' ' | b'\n' | b'\r' | b'\t')) + { + self.at += 1; + } + } + + fn char(&mut self) -> HistoryResult { + let c = self + .text + .get(self.at..) + .and_then(|rest| rest.chars().next()) + .ok_or_else(malformed)?; + self.at += c.len_utf8(); + Ok(c) + } + + fn byte(&mut self, expected: u8) -> HistoryResult<()> { + if self.take(expected) { + Ok(()) + } else { + Err(malformed()) + } + } + + fn take(&mut self, expected: u8) -> bool { + if self.text.as_bytes().get(self.at) == Some(&expected) { + self.at += 1; + true + } else { + false + } + } +} diff --git a/core/crates/layerfs-history/src/contract/mod.rs b/core/crates/layerfs-history/src/contract/mod.rs index 37313bcbc..6540945e8 100644 --- a/core/crates/layerfs-history/src/contract/mod.rs +++ b/core/crates/layerfs-history/src/contract/mod.rs @@ -2,5 +2,6 @@ pub mod catalog; pub mod error; pub mod identity; +pub mod labels; pub mod query; pub mod records; diff --git a/core/crates/layerfs-history/src/contract/records.rs b/core/crates/layerfs-history/src/contract/records.rs index 59cd40e8b..c1cd615fa 100644 --- a/core/crates/layerfs-history/src/contract/records.rs +++ b/core/crates/layerfs-history/src/contract/records.rs @@ -120,9 +120,17 @@ impl CommitVia { /// Maximum encoded UTF-8 bytes in a Commit or execution message. pub const MAXIMUM_MESSAGE_BYTES: usize = 4096; +/// Largest encoded UTF-8 bytes in a Commit author or one label. +pub const MAXIMUM_ATTRIBUTION_BYTES: usize = 4096; +/// Largest number of labels on one Commit. +pub const MAXIMUM_LABELS: usize = 64; +/// Largest encoded UTF-8 bytes of the complete labels JSON array. +pub const MAXIMUM_LABELS_JSON_BYTES: usize = 4096; + /// Commit provenance and publication facts. Branch, via, message, merge origin -/// and displaced head participate in identity; time, byte statistics, LWW -/// details and merge units are persisted facts outside that identity. +/// and displaced head participate in identity; author, labels, time, byte +/// statistics, LWW details and merge units are creation-time annotations or +/// persisted facts outside that identity. #[derive(Clone, Debug, Eq, PartialEq)] pub struct CommitMeta { /// Branch the Commit was made on; the catalog sets it from the request. @@ -131,6 +139,10 @@ pub struct CommitMeta { pub via: CommitVia, /// Description of the filesystem change; participates in Commit identity. pub message: Option, + /// Person or agent that made the Commit; outside Commit identity. + pub author: Option, + /// Free-form creation-time labels, preserving order and duplicates. + pub labels: Vec, /// Head a merge-on-commit started from, when one ran. pub merged_from: Option, /// Persisted newer-write-wins decisions. @@ -155,6 +167,8 @@ impl CommitMeta { branch: None, via, message: None, + author: None, + labels: Vec::new(), merged_from: None, lww: Vec::new(), lww_truncated: false, @@ -165,17 +179,32 @@ impl CommitMeta { } } - /// Checks the attribution bounds: at most - /// [`MAXIMUM_MESSAGE_BYTES`] bytes and no control character. + /// Checks message, author and label bounds before storage or transport. pub fn check(&self) -> crate::error::HistoryResult<()> { - for field in [&self.message].into_iter().flatten() { - if field.len() > MAXIMUM_MESSAGE_BYTES + fn text(field: &str, what: &'static str) -> crate::error::HistoryResult<()> { + if field.len() > MAXIMUM_ATTRIBUTION_BYTES || field .chars() .any(|c| c.is_control() && c != '\n' && c != '\t') { - return Err(crate::error::HistoryError::InvalidInput("Commit message")); + return Err(crate::error::HistoryError::InvalidInput(what)); } + Ok(()) + } + if let Some(message) = &self.message { + text(message, "Commit message")?; + } + if let Some(author) = &self.author { + text(author, "Commit author")?; + } + if self.labels.len() > MAXIMUM_LABELS { + return Err(crate::error::HistoryError::InvalidInput("Commit labels")); + } + for label in &self.labels { + text(label, "Commit label")?; + } + if crate::labels::encode(&self.labels).len() > MAXIMUM_LABELS_JSON_BYTES { + return Err(crate::error::HistoryError::InvalidInput("Commit labels JSON")); } Ok(()) } @@ -539,7 +568,7 @@ macro_rules! encoded_width { encoded_width!(LayerStackRecord, 179); encoded_width!(BranchRecord, 200); -encoded_width!(CommitRecord, 4510); +encoded_width!(CommitRecord, 12608); encoded_width!(LayerRecord, 176); /// Byte facts acknowledged by a construction producer and stored at publication. diff --git a/core/crates/layerfs-history/src/lib.rs b/core/crates/layerfs-history/src/lib.rs index 7ba2a839e..c7e12c0ad 100644 --- a/core/crates/layerfs-history/src/lib.rs +++ b/core/crates/layerfs-history/src/lib.rs @@ -21,6 +21,7 @@ mod contract; pub use contract::catalog; pub use contract::error; pub use contract::identity; +pub use contract::labels; pub use contract::query; pub use contract::records; @@ -35,5 +36,6 @@ pub use records::{ CommitStatistics, CommitVia, FileDigest, ForkRequest, ForkSource, InitStatistics, LayerHistoryRequest, LayerRecord, LayerStackRecord, MergeCommit, MergeHead, MergeSide, Page, PageResult, RecordKind, Reservation, ReserveRequest, StackInitialization, StoreStatistics, - FILE_DIGEST_WINDOW, MAXIMUM_MESSAGE_BYTES, MAXIMUM_PAGE_RECORDS, + FILE_DIGEST_WINDOW, MAXIMUM_ATTRIBUTION_BYTES, MAXIMUM_LABELS, MAXIMUM_LABELS_JSON_BYTES, + MAXIMUM_MESSAGE_BYTES, MAXIMUM_PAGE_RECORDS, }; diff --git a/core/docs/architecture/16-history.md b/core/docs/architecture/16-history.md index 524525619..d50d40b9b 100644 --- a/core/docs/architecture/16-history.md +++ b/core/docs/architecture/16-history.md @@ -79,6 +79,15 @@ > fields and identity description are superseded. See the [SDK channel](82-sdk-call-channel.md) > and [counter screen](../issues/327/COUNTER-SCREEN-20261015.md). +> **P7 Commit attribution, 2026-10-29:** Workspace Commits may carry one author +> (at most 4096 UTF-8 bytes) and at most 64 labels (each at most 4096 bytes, +> total JSON at most 4096 bytes), with newline/tab the only permitted controls. +> Author and labels are creation-time annotations outside `layerfs/commit/v3` +> identity, like time and statistics. Repeating a derived id returns the first +> stored attribution. Nullable columns are appended additively; old or NULL rows +> read as author null and labels `[]`. History values and the bounded reply +> budgets include both fields. + > **Commit metadata, overwrite publish and Branch merge, 2026-10-15 (#327):** > every Commit records `CommitMeta`: the Branch it was made on, how it was made > (`Workspace`, `Files`, `Merge`), the caller's `agent` and `call` (at most 128 diff --git a/core/docs/architecture/82-sdk-call-channel.md b/core/docs/architecture/82-sdk-call-channel.md index f0090fe08..7bded16a7 100644 --- a/core/docs/architecture/82-sdk-call-channel.md +++ b/core/docs/architecture/82-sdk-call-channel.md @@ -96,7 +96,9 @@ removed/added blocks with comparisonCoarse. Diff hunks may be omitted explicitly under comparison/reply bounds; equality uses immutable identity. Commit-v3 includes the message and operation/origin/displacement metadata in -identity, excluding time/counters/LWW details. History persists fork origins, +identity, excluding author/labels/time/counters/LWW details. Author and labels +are bounded creation-time annotations; a repeated derived id keeps the first +stored annotations. History persists fork origins, second parents, merge origins, displacement, LWW facts and overwritten Layers. The UI consumes those facts. Store accounting is maintained in original atomic publication/insertion transactions and read from one fixed row plus page PRAGMAs; @@ -128,7 +130,9 @@ The Dirty detail is bounded. Busy still represents held runtime/kernel/operation ownership. Reaping and requests on replaced inodes retain the owner's deferral. Disposable/WAL/OFF only; Durable is NOT_RUN pending explicit owner reauthorization. -New Store schema/identity records reject old Disposable Stores without migration. +New Store schema/identity records reject old Disposable Stores without migration; +P7's nullable Commit author/labels columns instead use the additive writable-open +migration described in [the global Store](84-global-sqlite-store.md). Issue #352 source update, 2026-10-19 (api shape recovery). Since F-020 the `fs`, `branch` and `project.list_branches` methods are not daemon calls: they @@ -242,8 +246,9 @@ layerfs-store-service/src/ [3706 shown] (partial: the engine) The public `sandbox` group (`create`, `mount`, `exec`, `status`, `commit`, `unmount`, `close`) is replaced by `workspace`: `mount(branch)`, -`exec(ws, command, message?)`, `status(ws)`, `commit(ws, message?, force?)`, -`unmount(ws, discard?)`. A Workspace is `{ sandbox, path }`. The binding +`exec(ws, command, message?)`, `status(ws)`, +`commit(ws, message?, force?, { author?, labels? }?)`, `unmount(ws, discard?)`. +A Workspace is `{ sandbox, path }`. The binding places each mount in the project's numbered Sandboxes under the project's placement lock (the lowest-numbered running one of the mount's image, `deploy.image` or the mount's `{ image }`, with fewer than 16 Workspaces, else diff --git a/core/docs/architecture/84-global-sqlite-store.md b/core/docs/architecture/84-global-sqlite-store.md index d320f7109..f5791c33f 100644 --- a/core/docs/architecture/84-global-sqlite-store.md +++ b/core/docs/architecture/84-global-sqlite-store.md @@ -81,6 +81,11 @@ adding a table or index is a schema-version change with an additive migration | Hints and rings | `binding_hint` (#352, additive), `content_signature` (bounded ring, ≤ 8,192 rows) | parent bindings for O(changed) diffs; similarity signatures for delta bases | | Init | `init_entry`, `init_host_file`, `init_operation` | Project Init's resumable records | +The Commit table appends nullable `author` and `labels` columns through an +additive writable-open migration. Labels are one JSON array; both columns have +4096-byte checks. They are creation-time annotations outside Commit identity, +and old NULL rows read as no author and no labels. + ## 5. Objects inside the file - **Content-addressed.** An object's id is the hash of its canonical bytes; diff --git a/docs/general/sdk.md b/docs/general/sdk.md index d0bf11f12..ab45dec18 100644 --- a/docs/general/sdk.md +++ b/docs/general/sdk.md @@ -135,7 +135,10 @@ Any other permitted process can use the same mount through ordinary filesystem calls; the filesystem daemon supervises no commands. [Commit](../../engine/crates/layerfs-api/src/sandbox/commit.rs) captures the shared -published frontier and returns Committed or UpToDate. It does not filter mutations +published frontier and returns Committed or UpToDate. `CommitControl` also carries +optional author text and an ordered label list; both are bounded creation-time +annotations outside Commit identity and are returned in every Commit value. +It does not filter mutations by launch route or command completion. [Status](../../engine/crates/layerfs-api/src/sandbox/status.rs) retains the existing maintained-state scope and never resolves an unknown operation. diff --git a/engine/crates/layerfs-api/src/sandbox/commit.rs b/engine/crates/layerfs-api/src/sandbox/commit.rs index 00c4c2c79..dabba0c25 100644 --- a/engine/crates/layerfs-api/src/sandbox/commit.rs +++ b/engine/crates/layerfs-api/src/sandbox/commit.rs @@ -30,6 +30,8 @@ impl WorkspaceApi<'_> { token, force: false, message: None, + author: None, + labels: Vec::new(), }); match self.exchange(request.clone())? { Reply::Committed(outcome) => Ok(outcome), diff --git a/engine/crates/layerfs-api/tests/area_p7_attribution.rs b/engine/crates/layerfs-api/tests/area_p7_attribution.rs new file mode 100644 index 000000000..c3bcd2eaf --- /dev/null +++ b/engine/crates/layerfs-api/tests/area_p7_attribution.rs @@ -0,0 +1,427 @@ +//! P7 Commit author/labels over real disposable Stores and the public SDK values. +#[path = "support/installed_store.rs"] +#[allow(dead_code)] +mod installed; +#[path = "support/sdk.rs"] +#[allow(dead_code)] +mod support; + +use layerfs_bridge::control::{Answer, Reply, SdkValue}; +use layerfs_content::ObjectId; +use layerfs_history::{ + BranchId, CommitHistoryRequest, CommitId, CommitMeta, CommitOutcome, CommitRecord, CommitRequest, + CommitStatistics, CommitVia, HistoryError, HistoryName, LayerId, LayerStackId, NodeId, PageResult, + WorkspaceId, MAXIMUM_ATTRIBUTION_BYTES, MAXIMUM_LABELS, +}; +use layerfs_store_service::store::{BindRequest, CommitOptions, CommitSuccess}; +use std::fs; +use support::{Change, Fixture}; + +fn text(value: &str) -> SdkValue { + SdkValue::text(value) +} + +fn options(author: Option, labels: Vec) -> CommitOptions { + CommitOptions { + force: false, + message: Some("P7 attributed Workspace change".into()), + author, + labels, + } +} + +fn committed(success: CommitSuccess) -> CommitRecord { + match success.history { + Some(CommitOutcome::Committed { record, .. }) => *record, + other => panic!("Commit outcome: {other:?}"), + } +} + +fn list(fixture: &Fixture, branch: &str, pos: i64) -> SdkValue { + fixture.call( + "branch.list_commits", + vec![ + ("branch", text(branch)), + ("pageSize", SdkValue::Int(1000)), + ("pagePos", SdkValue::Int(pos)), + ], + ) +} + +fn graph(fixture: &Fixture, branch: &str, pos: i64) -> SdkValue { + fixture.call( + "branch.graph", + vec![ + ("branch", text(branch)), + ("pageSize", SdkValue::Int(1000)), + ("pagePos", SdkValue::Int(pos)), + ], + ) +} + +fn attribution(value: &SdkValue, author: Option<&str>, labels: &[&str]) { + assert_eq!( + value.get("author"), + Some(&SdkValue::opt_text(author)), + "{value:?}" + ); + assert_eq!( + value.get("labels"), + Some(&SdkValue::List( + labels.iter().map(|label| text(label)).collect() + )), + "{value:?}" + ); +} + +fn boundary_labels() -> Vec { + let mut labels = vec![String::new(); MAXIMUM_LABELS]; + labels[0] = "x".repeat(MAXIMUM_ATTRIBUTION_BYTES - 193); + assert_eq!(layerfs_history::labels::encode(&labels).len(), 4096); + labels +} + +fn direct_commit( + fixture: &Fixture, + branch: BranchId, + root: ObjectId, + meta: CommitMeta, +) -> Result { + let snapshot = fixture + .service + .store + .history() + .branch_snapshot(branch)? + .unwrap(); + match fixture.service.store.history().commit_branch(&CommitRequest { + branch, + expected_head: snapshot.branch.head_commit, + expected_base: snapshot.branch.base_layer, + expected_root: snapshot.effective_root, + candidate_root: root, + profile: snapshot.profile, + scope: snapshot.scope, + second_parent: None, + meta, + force: false, + })? { + CommitOutcome::Committed { record, .. } => Ok(*record), + other => panic!("direct Commit outcome: {other:?}"), + } +} + +#[test] +fn workspace_attribution_round_trips_and_null_columns_read_as_unset() { + let fixture = Fixture::new("area-p7-round-trip", |source| { + fs::write(source.join("base.txt"), b"base\n").unwrap(); + 1 + }); + fixture.fork("p7"); + let first = committed(fixture.workspace_commit_with( + "p7", + &[Change::Write("/base.txt", b"first\n")], + 71, + || {}, + options( + Some("agent-p7".into()), + vec!["call-7".into(), "call-7".into(), "last".into()], + ), + )); + assert_eq!(first.meta.author.as_deref(), Some("agent-p7")); + assert_eq!(first.meta.labels, ["call-7", "call-7", "last"]); + + let listed = list(&fixture, "p7", 0); + attribution( + &support::items(&listed)[0], + Some("agent-p7"), + &["call-7", "call-7", "last"], + ); + let graphed = graph(&fixture, "p7", 0); + match graphed.get("commits") { + Some(SdkValue::List(commits)) => attribution( + &commits[0], + Some("agent-p7"), + &["call-7", "call-7", "last"], + ), + other => panic!("graph commits: {other:?}"), + } + + committed(fixture.workspace_commit_with( + "p7", + &[Change::Write("/base.txt", b"second\n")], + 72, + || {}, + options(None, Vec::new()), + )); + let listed = list(&fixture, "p7", 0); + attribution(&support::items(&listed)[0], None, &[]); + + let connection = rusqlite::Connection::open(&fixture.host.config.path).unwrap(); + connection + .execute( + "UPDATE \"commit\" SET author=NULL,labels=NULL WHERE commit_id=?1", + [first.id.as_slice()], + ) + .unwrap(); + let listed = list(&fixture, "p7", 0); + attribution(&support::items(&listed)[1], None, &[]); + let graphed = graph(&fixture, "p7", 0); + match graphed.get("commits") { + Some(SdkValue::List(commits)) => attribution(&commits[1], None, &[]), + other => panic!("graph commits: {other:?}"), + } + + connection + .execute( + "UPDATE \"commit\" SET labels='not JSON' WHERE commit_id=?1", + [first.id.as_slice()], + ) + .unwrap(); + let branch = BranchId::named( + LayerStackId::from_authority([23; 16]), + &HistoryName::new("p7").unwrap(), + ); + let failure = fixture + .service + .store + .history() + .commit_history(&CommitHistoryRequest { + branch, + start: None, + cursor: None, + limit: 128, + }) + .unwrap_err(); + assert!(matches!( + failure, + HistoryError::Integrity("Commit labels JSON") + )); + fixture.cleanup(); +} + +#[test] +fn attribution_bounds_accept_exact_maxima_and_refuse_each_excess() { + let fixture = Fixture::new("area-p7-bounds", |_source| 0); + let valid = CommitMeta { + author: Some("a".repeat(MAXIMUM_ATTRIBUTION_BYTES)), + labels: boundary_labels(), + ..CommitMeta::new(CommitVia::Workspace) + }; + valid.check().unwrap(); + + let mut invalid = valid.clone(); + invalid.author = Some("a".repeat(MAXIMUM_ATTRIBUTION_BYTES + 1)); + assert!(matches!( + invalid.check(), + Err(HistoryError::InvalidInput("Commit author")) + )); + let failure = direct_commit( + &fixture, + fixture.host.branch, + ObjectId::for_bytes(b"P7 invalid author"), + invalid, + ); + assert!(matches!( + failure, + Err(HistoryError::InvalidInput("Commit author")) + )); + + let mut invalid = valid.clone(); + invalid.labels[0].push('\u{07}'); + assert!(matches!( + invalid.check(), + Err(HistoryError::InvalidInput("Commit label")) + )); + let mut invalid = valid.clone(); + invalid.labels.push("extra".into()); + assert!(matches!( + invalid.check(), + Err(HistoryError::InvalidInput("Commit labels")) + )); + let mut invalid = valid; + invalid.labels[0].push('x'); + assert_eq!(layerfs_history::labels::encode(&invalid.labels).len(), 4097); + assert!(matches!( + invalid.check(), + Err(HistoryError::InvalidInput("Commit labels JSON")) + )); + fixture.cleanup(); +} + +#[test] +fn repeated_derived_identity_keeps_original_attribution() { + let fixture = Fixture::new("area-p7-first-wins", |_source| 0); + let root = ObjectId::for_bytes(b"P7 first wins root"); + let first = direct_commit( + &fixture, + fixture.host.branch, + root, + CommitMeta { + message: Some("same identity".into()), + author: Some("original-author".into()), + labels: vec!["original".into(), "original".into()], + ..CommitMeta::new(CommitVia::Workspace) + }, + ) + .unwrap(); + let connection = rusqlite::Connection::open(&fixture.host.config.path).unwrap(); + connection + .execute( + "UPDATE branch SET head_commit_id=NULL WHERE branch_id=?1", + [fixture.host.branch.as_slice()], + ) + .unwrap(); + let repeated = direct_commit( + &fixture, + fixture.host.branch, + root, + CommitMeta { + message: Some("same identity".into()), + author: Some("replacement-author".into()), + labels: vec!["replacement".into()], + ..CommitMeta::new(CommitVia::Workspace) + }, + ) + .unwrap(); + assert_eq!(repeated.id, first.id); + assert_eq!(repeated.meta.author, first.meta.author); + assert_eq!(repeated.meta.labels, first.meta.labels); + assert_eq!(repeated.meta.time_ms, first.meta.time_ms); + fixture.cleanup(); +} + +#[test] +fn identical_workspace_recommit_is_up_to_date_with_attribution() { + let fixture = Fixture::new("area-p7-up-to-date", |_source| 0); + let bound = fixture + .service + .store + .bind( + fixture.owner.client(), + BindRequest { + workspace: WorkspaceId::from_authority([73; 32]).unwrap(), + branch: fixture.host.branch, + }, + ) + .unwrap(); + let result = bound + .workspace + .commit_captured(&options( + Some("not-recorded-for-a-no-op".into()), + vec!["also-not-recorded".into()], + )) + .unwrap(); + assert!(matches!( + result.history, + Some(CommitOutcome::UpToDate { head: None, .. }) + )); + bound.workspace.close().unwrap(); + fixture.cleanup(); +} + +fn collect_pages(fixture: &Fixture, branch: &str, graphed: bool) -> (Vec, usize) { + let mut ids = Vec::new(); + let mut pos = 0; + let mut pages = 0; + loop { + pages += 1; + assert!(pages <= 6, "paging did not terminate"); + let page = if graphed { + graph(fixture, branch, pos) + } else { + list(fixture, branch, pos) + }; + let values = if graphed { + match page.get("commits") { + Some(SdkValue::List(values)) => values, + other => panic!("graph commits: {other:?}"), + } + } else { + support::items(&page) + }; + assert!((1..=3).contains(&values.len()), "page width: {page:?}"); + ids.extend(values.iter().map(|value| value.get_text("id").unwrap().to_owned())); + match page.get_int("nextPagePos") { + Some(next) => pos = next, + None => return (ids, pages), + } + } +} + +#[test] +fn attributed_cost_makes_short_list_and_graph_pages() { + let fixture = Fixture::new("area-p7-budget", |_source| 0); + fixture.fork("budget"); + let branch = BranchId::named( + LayerStackId::from_authority([23; 16]), + &HistoryName::new("budget").unwrap(), + ); + let mut expected = Vec::new(); + for n in 0..12 { + let record = direct_commit( + &fixture, + branch, + ObjectId::for_bytes(format!("P7 budget root {n}").as_bytes()), + CommitMeta { + message: Some("m".repeat(MAXIMUM_ATTRIBUTION_BYTES)), + author: Some("a".repeat(MAXIMUM_ATTRIBUTION_BYTES)), + labels: boundary_labels(), + ..CommitMeta::new(CommitVia::Workspace) + }, + ) + .unwrap(); + expected.push(record.id.to_string()); + } + expected.reverse(); + let (listed, list_pages) = collect_pages(&fixture, "budget", false); + let (graphed, graph_pages) = collect_pages(&fixture, "budget", true); + assert_eq!(listed, expected); + assert_eq!(graphed, expected); + assert_eq!((list_pages, graph_pages), (4, 4)); + fixture.cleanup(); +} + +#[test] +fn maximal_commit_record_matches_declared_encoded_width() { + let record = CommitRecord { + id: CommitId::from_bytes([0x12; 33]).unwrap(), + stack: LayerStackId::from_authority([0x31; 16]), + root: ObjectId::for_bytes(b"P7 maximum record"), + parent: Some(CommitId::from_bytes([0x12; 33]).unwrap()), + base_layer: LayerId::from_bytes([0x32; 33]).unwrap(), + second_parent: Some(NodeId::Layer( + LayerId::from_bytes([0x32; 33]).unwrap(), + )), + meta: CommitMeta { + branch: Some(BranchId::from_authority([0x11; 16])), + via: CommitVia::Workspace, + message: Some("m".repeat(MAXIMUM_ATTRIBUTION_BYTES)), + author: Some("a".repeat(MAXIMUM_ATTRIBUTION_BYTES)), + labels: boundary_labels(), + merged_from: Some(NodeId::Layer( + LayerId::from_bytes([0x32; 33]).unwrap(), + )), + lww: Vec::new(), + lww_truncated: true, + units: Vec::new(), + statistics: CommitStatistics { + logical: u64::MAX, + new_canonical: u64::MAX, + new_stored: u64::MAX, + new_metadata: u64::MAX, + }, + displaced: Some(CommitId::from_bytes([0x12; 33]).unwrap()), + time_ms: i64::MAX, + }, + }; + let answer = Answer { + id: 1, + reply: Reply::History(PageResult { + records: vec![record], + continuation: None, + }), + }; + let encoded = answer.encode().unwrap(); + assert_eq!(encoded.len() - 17, CommitRecord::MAXIMUM_ENCODED_BYTES); + assert_eq!(Answer::decode(&encoded).unwrap(), answer); +} diff --git a/engine/crates/layerfs-api/tests/concurrent_commits.rs b/engine/crates/layerfs-api/tests/concurrent_commits.rs index 17324b1c4..1a4d30e42 100644 --- a/engine/crates/layerfs-api/tests/concurrent_commits.rs +++ b/engine/crates/layerfs-api/tests/concurrent_commits.rs @@ -127,6 +127,8 @@ fn commit_together( token, force: false, message: None, + author: None, + labels: Vec::new(), })) .map(|done| done.reply) }) diff --git a/engine/crates/layerfs-api/tests/golden/sdk_methods.txt b/engine/crates/layerfs-api/tests/golden/sdk_methods.txt index 4f7db8249..808cdb6ec 100644 --- a/engine/crates/layerfs-api/tests/golden/sdk_methods.txt +++ b/engine/crates/layerfs-api/tests/golden/sdk_methods.txt @@ -7,24 +7,24 @@ branch.fork -> ok {kind: "Forked", branch: "other", base: "", head: null} branch.fork -> refused ReservedName "\"main\" names the project's main line" null branch.fork -> refused NameTaken "Branch feat already exists" null branch.fork -> refused InvalidInput "`nope` is not a Commit or Layer identity" null -fs.write -> ok {kind: "Committed", branch: "feat", head: "", commit: {id: "", branch: "feat", parent: null, secondParent: null, base: "", root: "", via: "fs", message: "first", mergedFrom: null, time: , logical: 43, newCanonical: 957, newStored: , newMetadata: , displaced: null, lww: [], lwwTruncated: false}} +fs.write -> ok {kind: "Committed", branch: "feat", head: "", commit: {id: "", branch: "feat", parent: null, secondParent: null, base: "", root: "", via: "fs", message: "first", author: null, labels: [], mergedFrom: null, time: , logical: 43, newCanonical: 957, newStored: , newMetadata: , displaced: null, lww: [], lwwTruncated: false}} fs.write -> refused ReadOnly "main changes only through publish" null fs.write -> ok {kind: "UpToDate", branch: "feat", head: "", root: ""} fs.write -> refused InvalidInput "expected an absolute path without empty, dot or parent components" {index: 0} fs.write -> refused InvalidInput "write content must be text or bytes" {index: 0} fs.write -> refused InvalidInput "message must be text" null -fs.edit -> ok {kind: "Committed", branch: "feat", head: "", commit: {id: "", branch: "feat", parent: "", secondParent: null, base: "", root: "", via: "fs", message: "Apply filesystem changes", mergedFrom: null, time: , logical: 43, newCanonical: 858, newStored: , newMetadata: , displaced: null, lww: [], lwwTruncated: false}} +fs.edit -> ok {kind: "Committed", branch: "feat", head: "", commit: {id: "", branch: "feat", parent: "", secondParent: null, base: "", root: "", via: "fs", message: "Apply filesystem changes", author: null, labels: [], mergedFrom: null, time: , logical: 43, newCanonical: 858, newStored: , newMetadata: , displaced: null, lww: [], lwwTruncated: false}} fs.edit -> refused NoMatch "/a.txt" {matches: 0, index: 0} -fs.mkdir -> ok {kind: "Committed", branch: "feat", head: "", commit: {id: "", branch: "feat", parent: "", secondParent: null, base: "", root: "", via: "fs", message: "Apply filesystem changes", mergedFrom: null, time: , logical: 43, newCanonical: 1206, newStored: , newMetadata: , displaced: null, lww: [], lwwTruncated: false}} -fs.cp -> ok {kind: "Committed", branch: "feat", head: "", commit: {id: "", branch: "feat", parent: "", secondParent: null, base: "", root: "", via: "fs", message: "Apply filesystem changes", mergedFrom: null, time: , logical: 60, newCanonical: 1572, newStored: , newMetadata: , displaced: null, lww: [], lwwTruncated: false}} -fs.mv -> ok {kind: "Committed", branch: "feat", head: "", commit: {id: "", branch: "feat", parent: "", secondParent: null, base: "", root: "", via: "fs", message: "Apply filesystem changes", mergedFrom: null, time: , logical: 60, newCanonical: 1518, newStored: , newMetadata: , displaced: null, lww: [], lwwTruncated: false}} -fs.rm -> ok {kind: "Committed", branch: "feat", head: "", commit: {id: "", branch: "feat", parent: "", secondParent: null, base: "", root: "", via: "fs", message: "Apply filesystem changes", mergedFrom: null, time: , logical: 47, newCanonical: 1204, newStored: , newMetadata: , displaced: null, lww: [], lwwTruncated: false}} +fs.mkdir -> ok {kind: "Committed", branch: "feat", head: "", commit: {id: "", branch: "feat", parent: "", secondParent: null, base: "", root: "", via: "fs", message: "Apply filesystem changes", author: null, labels: [], mergedFrom: null, time: , logical: 43, newCanonical: 1206, newStored: , newMetadata: , displaced: null, lww: [], lwwTruncated: false}} +fs.cp -> ok {kind: "Committed", branch: "feat", head: "", commit: {id: "", branch: "feat", parent: "", secondParent: null, base: "", root: "", via: "fs", message: "Apply filesystem changes", author: null, labels: [], mergedFrom: null, time: , logical: 60, newCanonical: 1572, newStored: , newMetadata: , displaced: null, lww: [], lwwTruncated: false}} +fs.mv -> ok {kind: "Committed", branch: "feat", head: "", commit: {id: "", branch: "feat", parent: "", secondParent: null, base: "", root: "", via: "fs", message: "Apply filesystem changes", author: null, labels: [], mergedFrom: null, time: , logical: 60, newCanonical: 1518, newStored: , newMetadata: , displaced: null, lww: [], lwwTruncated: false}} +fs.rm -> ok {kind: "Committed", branch: "feat", head: "", commit: {id: "", branch: "feat", parent: "", secondParent: null, base: "", root: "", via: "fs", message: "Apply filesystem changes", author: null, labels: [], mergedFrom: null, time: , logical: 47, newCanonical: 1204, newStored: , newMetadata: , displaced: null, lww: [], lwwTruncated: false}} fs.rm -> refused NoSuchPath "Content(PathNotFound)" {index: 0} -fs.apply -> ok {kind: "Committed", branch: "feat", head: "", commit: {id: "", branch: "feat", parent: "", secondParent: null, base: "", root: "", via: "fs", message: "Apply filesystem changes", mergedFrom: null, time: , logical: 55, newCanonical: 1439, newStored: , newMetadata: , displaced: null, lww: [], lwwTruncated: false}} +fs.apply -> ok {kind: "Committed", branch: "feat", head: "", commit: {id: "", branch: "feat", parent: "", secondParent: null, base: "", root: "", via: "fs", message: "Apply filesystem changes", author: null, labels: [], mergedFrom: null, time: , logical: 55, newCanonical: 1439, newStored: , newMetadata: , displaced: null, lww: [], lwwTruncated: false}} fs.apply -> refused NoSuchPath "Content(PathNotFound)" {index: 0} fs.apply -> refused InvalidInput "unknown change op bogus" {index: 1} fs.apply -> refused InvalidInput "apply requires a changes array" null -fs.write -> ok {kind: "Committed", branch: "other", head: "", commit: {id: "", branch: "other", parent: null, secondParent: null, base: "", root: "", via: "fs", message: "Apply filesystem changes", mergedFrom: null, time: , logical: 34, newCanonical: 774, newStored: , newMetadata: , displaced: null, lww: [], lwwTruncated: false}} +fs.write -> ok {kind: "Committed", branch: "other", head: "", commit: {id: "", branch: "other", parent: null, secondParent: null, base: "", root: "", via: "fs", message: "Apply filesystem changes", author: null, labels: [], mergedFrom: null, time: , logical: 34, newCanonical: 774, newStored: , newMetadata: , displaced: null, lww: [], lwwTruncated: false}} fs.read -> ok {kind: "ok", snapshot: "feat@", root: "", path: "/a.txt", text: "one\nTWO\nthree\n", startLine: 1, endLine: 3, lineTruncated: false, truncated: false, nextLine: null, size: 14} fs.read -> ok {kind: "ok", snapshot: "main@", root: "", path: "/a.txt", text: "one\ntwo\nthree\n", startLine: 1, endLine: 3, lineTruncated: false, truncated: false, nextLine: null, size: 14} fs.read -> ok {kind: "ok", snapshot: "feat@", root: "", path: "/a.txt", text: "TWO\n", startLine: 2, endLine: 2, lineTruncated: false, truncated: false, nextLine: null, size: 14} @@ -49,8 +49,8 @@ fs.blame -> ok {kind: "ok", snapshot: "feat@", root: "", path: "/a.txt fs.diff_files -> ok {kind: "Text", left: {path: "/a.txt", snapshot: "main@", inode: 2}, right: {path: "/a.txt", snapshot: "feat@", inode: 2}, contentEqual: false, sameInode: true, hunksOmitted: false, hunks: [{op: " ", text: "one"}, {op: "-", text: "two"}, {op: "+", text: "TWO"}, {op: " ", text: "three"}, {op: " ", text: ""}]} fs.diff_files -> refused IsDirectory "/dir is a directory" {path: "/dir"} fs.diff_files -> refused InvalidInput "missing `right` selector" null -branch.list_commits -> ok {items: [{id: "", branch: "feat", parent: "", secondParent: null, base: "", root: "", via: "fs", message: "Apply filesystem changes", mergedFrom: null, time: , logical: 55, newCanonical: 1439, newStored: , newMetadata: , displaced: null, lww: [], lwwTruncated: false}, {id: "", branch: "feat", parent: "", secondParent: null, base: "", root: "", via: "fs", message: "Apply filesystem changes", mergedFrom: null, time: , logical: 47, newCanonical: 1204, newStored: , newMetadata: , displaced: null, lww: [], lwwTruncated: false}, {id: "", branch: "feat", parent: "", secondParent: null, base: "", root: "", via: "fs", message: "Apply filesystem changes", mergedFrom: null, time: , logical: 60, newCanonical: 1518, newStored: , newMetadata: , displaced: null, lww: [], lwwTruncated: false}, {id: "", branch: "feat", parent: "", secondParent: null, base: "", root: "", via: "fs", message: "Apply filesystem changes", mergedFrom: null, time: , logical: 60, newCanonical: 1572, newStored: , newMetadata: , displaced: null, lww: [], lwwTruncated: false}, {id: "", branch: "feat", parent: "", secondParent: null, base: "", root: "", via: "fs", message: "Apply filesystem changes", mergedFrom: null, time: , logical: 43, newCanonical: 1206, newStored: , newMetadata: , displaced: null, lww: [], lwwTruncated: false}, {id: "", branch: "feat", parent: "", secondParent: null, base: "", root: "", via: "fs", message: "Apply filesystem changes", mergedFrom: null, time: , logical: 43, newCanonical: 858, newStored: , newMetadata: , displaced: null, lww: [], lwwTruncated: false}, {id: "", branch: "feat", parent: null, secondParent: null, base: "", root: "", via: "fs", message: "first", mergedFrom: null, time: , logical: 43, newCanonical: 957, newStored: , newMetadata: , displaced: null, lww: [], lwwTruncated: false}], hasMore: false, nextPagePos: null} -branch.list_commits -> ok {items: [{id: "", branch: "feat", parent: "", secondParent: null, base: "", root: "", via: "fs", message: "Apply filesystem changes", mergedFrom: null, time: , logical: 55, newCanonical: 1439, newStored: , newMetadata: , displaced: null, lww: [], lwwTruncated: false}, {id: "", branch: "feat", parent: "", secondParent: null, base: "", root: "", via: "fs", message: "Apply filesystem changes", mergedFrom: null, time: , logical: 47, newCanonical: 1204, newStored: , newMetadata: , displaced: null, lww: [], lwwTruncated: false}], hasMore: true, nextPagePos: 2} +branch.list_commits -> ok {items: [{id: "", branch: "feat", parent: "", secondParent: null, base: "", root: "", via: "fs", message: "Apply filesystem changes", author: null, labels: [], mergedFrom: null, time: , logical: 55, newCanonical: 1439, newStored: , newMetadata: , displaced: null, lww: [], lwwTruncated: false}, {id: "", branch: "feat", parent: "", secondParent: null, base: "", root: "", via: "fs", message: "Apply filesystem changes", author: null, labels: [], mergedFrom: null, time: , logical: 47, newCanonical: 1204, newStored: , newMetadata: , displaced: null, lww: [], lwwTruncated: false}, {id: "", branch: "feat", parent: "", secondParent: null, base: "", root: "", via: "fs", message: "Apply filesystem changes", author: null, labels: [], mergedFrom: null, time: , logical: 60, newCanonical: 1518, newStored: , newMetadata: , displaced: null, lww: [], lwwTruncated: false}, {id: "", branch: "feat", parent: "", secondParent: null, base: "", root: "", via: "fs", message: "Apply filesystem changes", author: null, labels: [], mergedFrom: null, time: , logical: 60, newCanonical: 1572, newStored: , newMetadata: , displaced: null, lww: [], lwwTruncated: false}, {id: "", branch: "feat", parent: "", secondParent: null, base: "", root: "", via: "fs", message: "Apply filesystem changes", author: null, labels: [], mergedFrom: null, time: , logical: 43, newCanonical: 1206, newStored: , newMetadata: , displaced: null, lww: [], lwwTruncated: false}, {id: "", branch: "feat", parent: "", secondParent: null, base: "", root: "", via: "fs", message: "Apply filesystem changes", author: null, labels: [], mergedFrom: null, time: , logical: 43, newCanonical: 858, newStored: , newMetadata: , displaced: null, lww: [], lwwTruncated: false}, {id: "", branch: "feat", parent: null, secondParent: null, base: "", root: "", via: "fs", message: "first", author: null, labels: [], mergedFrom: null, time: , logical: 43, newCanonical: 957, newStored: , newMetadata: , displaced: null, lww: [], lwwTruncated: false}], hasMore: false, nextPagePos: null} +branch.list_commits -> ok {items: [{id: "", branch: "feat", parent: "", secondParent: null, base: "", root: "", via: "fs", message: "Apply filesystem changes", author: null, labels: [], mergedFrom: null, time: , logical: 55, newCanonical: 1439, newStored: , newMetadata: , displaced: null, lww: [], lwwTruncated: false}, {id: "", branch: "feat", parent: "", secondParent: null, base: "", root: "", via: "fs", message: "Apply filesystem changes", author: null, labels: [], mergedFrom: null, time: , logical: 47, newCanonical: 1204, newStored: , newMetadata: , displaced: null, lww: [], lwwTruncated: false}], hasMore: true, nextPagePos: 2} branch.list_commits -> refused NoSuchBranch "Branch main does not exist" null branch.diff_branches -> ok {kind: "ok", from: {ref: "main", label: "main@", root: ""}, to: {ref: "feat", label: "feat@", root: ""}, items: [{path: "/a.txt", kind: "Modified", type: "file", before: {type: "file", inode: 2}, after: {type: "file", inode: 2}, content: true, identity: false, metadata: true, hunks: [{op: " ", text: "one"}, {op: "-", text: "two"}, {op: "+", text: "TWO"}, {op: " ", text: "three"}, {op: " ", text: ""}], hunksOmitted: false}, {path: "/applied-dir", kind: "Added", type: "dir", before: null, after: {type: "dir", inode: 201}, content: false, identity: false, metadata: false, hunks: null, hunksOmitted: false}, {path: "/applied.txt", kind: "Added", type: "file", before: null, after: {type: "file", inode: 200}, content: false, identity: false, metadata: false, hunks: null, hunksOmitted: false}, {path: "/made", kind: "Added", type: "dir", before: null, after: {type: "dir", inode: 72}, content: false, identity: false, metadata: false, hunks: null, hunksOmitted: false}, {path: "/made/deep", kind: "Added", type: "dir", before: null, after: {type: "dir", inode: 73}, content: false, identity: false, metadata: false, hunks: null, hunksOmitted: false}, {path: "/moved.txt", kind: "Added", type: "file", before: null, after: {type: "file", inode: 137}, content: false, identity: false, metadata: false, hunks: null, hunksOmitted: false}, {path: "/new.txt", kind: "Added", type: "file", before: null, after: {type: "file", inode: 8}, content: false, identity: false, metadata: false, hunks: null, hunksOmitted: false}], hasMore: false, nextPagePos: null, nextCursor: null} branch.diff_branches -> ok {kind: "ok", from: {ref: "main", label: "main@", root: ""}, to: {ref: "feat", label: "feat@", root: ""}, items: [{path: "/applied-dir", kind: "Added", type: "dir", before: null, after: {type: "dir", inode: 201}, content: false, identity: false, metadata: false, hunks: null, hunksOmitted: false}, {path: "/applied.txt", kind: "Added", type: "file", before: null, after: {type: "file", inode: 200}, content: false, identity: false, metadata: false, hunks: null, hunksOmitted: false}], hasMore: true, nextPagePos: 3, nextCursor: "/applied.txt"} @@ -61,9 +61,9 @@ branch.diff_commits -> ok {kind: "ok", from: {commit: "", root: ""}, t fs.read -> ok {kind: "ok", snapshot: "", root: "", path: "/new.txt", text: "hello\n", startLine: 1, endLine: 1, lineTruncated: false, truncated: false, nextLine: null, size: 6} branch.fork -> ok {kind: "Forked", branch: "at-commit", base: "", head: ""} branch.fork -> ok {kind: "Forked", branch: "left", base: "", head: null} -fs.write -> ok {kind: "Committed", branch: "left", head: "", commit: {id: "", branch: "left", parent: null, secondParent: null, base: "", root: "", via: "fs", message: "Apply filesystem changes", mergedFrom: null, time: , logical: 41, newCanonical: 956, newStored: , newMetadata: , displaced: null, lww: [], lwwTruncated: false}} +fs.write -> ok {kind: "Committed", branch: "left", head: "", commit: {id: "", branch: "left", parent: null, secondParent: null, base: "", root: "", via: "fs", message: "Apply filesystem changes", author: null, labels: [], mergedFrom: null, time: , logical: 41, newCanonical: 956, newStored: , newMetadata: , displaced: null, lww: [], lwwTruncated: false}} branch.fork -> ok {kind: "Forked", branch: "right", base: "", head: null} -fs.write -> ok {kind: "Committed", branch: "right", head: "", commit: {id: "", branch: "right", parent: null, secondParent: null, base: "", root: "", via: "fs", message: "Apply filesystem changes", mergedFrom: null, time: , logical: 42, newCanonical: 958, newStored: , newMetadata: , displaced: null, lww: [], lwwTruncated: false}} +fs.write -> ok {kind: "Committed", branch: "right", head: "", commit: {id: "", branch: "right", parent: null, secondParent: null, base: "", root: "", via: "fs", message: "Apply filesystem changes", author: null, labels: [], mergedFrom: null, time: , logical: 42, newCanonical: 958, newStored: , newMetadata: , displaced: null, lww: [], lwwTruncated: false}} branch.merge -> ok {kind: "Merged", fastForward: false, branch: "both", head: "", root: "", sources: [{ref: "left", node: ""}, {ref: "right", node: ""}], mergeBase: "", conflicts: [], lww: [], conflictsTruncated: false, lwwTruncated: false, units: [], unitsTruncated: false} fs.list -> ok {kind: "ok", snapshot: "both@", root: "", path: "/", items: [{name: "a.txt", path: "/a.txt", type: "file", inode: 2}, {name: "blob.bin", path: "/blob.bin", type: "file", inode: 3}, {name: "dir", path: "/dir", type: "dir", inode: 4}, {name: "left.txt", path: "/left.txt", type: "file", inode: 328}, {name: "right.txt", path: "/right.txt", type: "file", inode: 392}], hasMore: false, nextPagePos: null} branch.merge -> refused NameTaken "Branch both already exists" null diff --git a/engine/crates/layerfs-api/tests/kernel_application.rs b/engine/crates/layerfs-api/tests/kernel_application.rs index 614e0d80c..b85c65e4e 100644 --- a/engine/crates/layerfs-api/tests/kernel_application.rs +++ b/engine/crates/layerfs-api/tests/kernel_application.rs @@ -146,7 +146,7 @@ fn actual_daemon_install_hello_bind_status_no_constructor_and_session_end() { // The real binary's Commit of an unchanged Workspace: the same root, no // new Commit record. assert!( - matches!(control.call(Request::Commit(layerfs_bridge::control::CommitControl { token: bound.token, force: false, message: None, })).unwrap(), Reply::Committed(layerfs_history::CommitOutcome::UpToDate { head: None, root }) if root == bound.binding.effective_root) + matches!(control.call(Request::Commit(layerfs_bridge::control::CommitControl { token: bound.token, force: false, message: None, author: None, labels: Vec::new(), })).unwrap(), Reply::Committed(layerfs_history::CommitOutcome::UpToDate { head: None, root }) if root == bound.binding.effective_root) ); let after = layerfs_api::internal::workspace(&mut control) .status(bound.token) diff --git a/engine/crates/layerfs-api/tests/lane_merge.rs b/engine/crates/layerfs-api/tests/lane_merge.rs index eacf686a4..368497709 100644 --- a/engine/crates/layerfs-api/tests/lane_merge.rs +++ b/engine/crates/layerfs-api/tests/lane_merge.rs @@ -355,6 +355,8 @@ fn commit_held( token, force: false, message: None, + author: None, + labels: Vec::new(), })) .map(|done| done.reply) .map_err(|failure| format!("{failure:?}")) @@ -391,6 +393,8 @@ fn commit_plain(fixture: &Fixture, token: layerfs_bridge::control::WorkspaceToke token, force: false, message: None, + author: None, + labels: Vec::new(), })) .map(|done| done.reply) .map_err(|failure| format!("{failure:?}")); diff --git a/engine/crates/layerfs-api/tests/merge_report.rs b/engine/crates/layerfs-api/tests/merge_report.rs index 5051e1b17..67d6a43f1 100644 --- a/engine/crates/layerfs-api/tests/merge_report.rs +++ b/engine/crates/layerfs-api/tests/merge_report.rs @@ -93,6 +93,8 @@ fn conflicted_commit(label: &str, id: u8, count: usize) -> (Vec, bool) { token, force: false, message: None, + author: None, + labels: Vec::new(), })) .unwrap() .reply; diff --git a/engine/crates/layerfs-api/tests/mount_control.rs b/engine/crates/layerfs-api/tests/mount_control.rs index fcd075faa..99691e7df 100644 --- a/engine/crates/layerfs-api/tests/mount_control.rs +++ b/engine/crates/layerfs-api/tests/mount_control.rs @@ -151,6 +151,8 @@ fn original_lost_control_reply_is_not_replayed_or_settled_by_an_observer() { token, force: false, message: None, + author: None, + labels: Vec::new(), }) ); let OperationCause::Exchange(original) = &failure.cause else { @@ -221,6 +223,8 @@ fn another_control_channel_observes_running_commit_and_refuses_unmount_before_ef token, force: false, message: None, + author: None, + labels: Vec::new(), })) }); entered.recv_timeout(Duration::from_secs(3)).unwrap(); @@ -293,7 +297,9 @@ fn an_original_unknown_blocks_another_commit_and_normal_unmount() { let (token, binding) = mount(&mut client, identity(89), branch); fixture::write(&f.service, token, b'U'); assert!( - matches!(client.call(Request::Commit(layerfs_bridge::control::CommitControl { token, force: false, message: None, })).unwrap(),Reply::Refused(value) if value.code==ControlCode::Unknown) + matches!(client.call(Request::Commit(layerfs_bridge::control::CommitControl { token, force: false, message: None, + author: None, + labels: Vec::new(), })).unwrap(),Reply::Refused(value) if value.code==ControlCode::Unknown) ); let observed = status(&mut client, token); assert_eq!(observed.activity, Activity::Uncertain); @@ -301,14 +307,18 @@ fn an_original_unknown_blocks_another_commit_and_normal_unmount() { assert!(observed.published.is_none()); assert!(observed.local.unwrap().captured.is_some()); assert!( - matches!(f.service.execute_control(&Request::Commit(layerfs_bridge::control::CommitControl { token, force: false, message: None, })), + matches!(f.service.execute_control(&Request::Commit(layerfs_bridge::control::CommitControl { token, force: false, message: None, + author: None, + labels: Vec::new(), })), Err(layerfs_daemon::control::Failure::Custody(value)) if value.code == ControlCode::Unknown && value.published.is_none()) ); assert!( matches!(client.call(Request::Unmount(layerfs_bridge::control::UnmountControl { token, discard: true })).unwrap(),Reply::Refused(value) if value.code==ControlCode::Unknown) ); assert!( - matches!(client.call(Request::Commit(layerfs_bridge::control::CommitControl { token, force: false, message: None, })).unwrap(),Reply::Refused(value) if value.code==ControlCode::Unknown) + matches!(client.call(Request::Commit(layerfs_bridge::control::CommitControl { token, force: false, message: None, + author: None, + labels: Vec::new(), })).unwrap(),Reply::Refused(value) if value.code==ControlCode::Unknown) ); let retained = worker.join(); assert!(retained.outcome.is_err()); @@ -363,6 +373,8 @@ fn status_retains_known_publication_when_the_engine_is_unavailable() { token, force: false, message: None, + author: None, + labels: Vec::new(), })); (client, reply) }); @@ -432,6 +444,8 @@ fn product_control_commit_captures_the_frontier_releases_its_owners_and_is_then_ }, force: false, message: None, + author: None, + labels: Vec::new(), })), Err(layerfs_daemon::control::Failure::Rejected( ControlCode::Invalid, @@ -447,6 +461,8 @@ fn product_control_commit_captures_the_frontier_releases_its_owners_and_is_then_ token, force: false, message: None, + author: None, + labels: Vec::new(), })) .unwrap(); let record = match &done.reply { @@ -482,6 +498,8 @@ fn product_control_commit_captures_the_frontier_releases_its_owners_and_is_then_ token, force: false, message: None, + author: None, + labels: Vec::new(), })) .unwrap(); assert!( @@ -647,6 +665,8 @@ fn a_definite_commit_failure_whose_capture_is_not_resolved_answers_unknown() { token, force: false, message: None, + author: None, + labels: Vec::new(), })); (client, reply) }); @@ -749,6 +769,8 @@ fn an_unresolved_moved_head_failure_is_answered_and_not_dropped() { token, force: false, message: None, + author: None, + labels: Vec::new(), })); (client, reply) }); diff --git a/engine/crates/layerfs-api/tests/relink_commit.rs b/engine/crates/layerfs-api/tests/relink_commit.rs index 977bb3997..6e935a2a8 100644 --- a/engine/crates/layerfs-api/tests/relink_commit.rs +++ b/engine/crates/layerfs-api/tests/relink_commit.rs @@ -191,6 +191,8 @@ fn relink( &layerfs_store_service::store::CommitOptions { force: false, message: Some("relink".to_owned()), + author: None, + labels: Vec::new(), }, CommitVia::Files, ) diff --git a/engine/crates/layerfs-api/tests/support/sdk.rs b/engine/crates/layerfs-api/tests/support/sdk.rs index d8c45763b..a10337d9f 100644 --- a/engine/crates/layerfs-api/tests/support/sdk.rs +++ b/engine/crates/layerfs-api/tests/support/sdk.rs @@ -169,6 +169,28 @@ impl Fixture { changes: &[Change<'_>], id: u8, before: impl FnOnce(), + ) -> CommitSuccess { + self.workspace_commit_with( + branch, + changes, + id, + before, + CommitOptions { + force: false, + message: Some("Workspace changes".into()), + author: None, + labels: Vec::new(), + }, + ) + } + + pub fn workspace_commit_with( + &self, + branch: &str, + changes: &[Change<'_>], + id: u8, + before: impl FnOnce(), + options: CommitOptions, ) -> CommitSuccess { let bound = self .service @@ -318,10 +340,7 @@ impl Fixture { drop(operation); before(); let committed = workspace - .commit_captured(&CommitOptions { - force: false, - message: Some("Workspace changes".into()), - }) + .commit_captured(&options) .unwrap_or_else(|failure| panic!("Workspace Commit: {failure:?}")); workspace.close().unwrap(); committed diff --git a/engine/crates/layerfs-bridge/src/control_history.rs b/engine/crates/layerfs-bridge/src/control_history.rs index abdccb514..4ce62358e 100644 --- a/engine/crates/layerfs-bridge/src/control_history.rs +++ b/engine/crates/layerfs-bridge/src/control_history.rs @@ -151,7 +151,47 @@ pub(crate) fn put_commit(out: &mut Writer, value: &CommitRecord) -> Result<(), C put_node(out, value.second_parent)?; put_meta(out, &value.meta) } -/// Commit metadata: Branch, via, agent, call, merge origin and time. +pub(crate) fn put_attribution( + out: &mut Writer, + author: &Option, + labels: &[String], +) -> Result<(), ControlError> { + out.byte(u8::from(author.is_some()))?; + if let Some(author) = author { + if author.len() > layerfs_history::MAXIMUM_ATTRIBUTION_BYTES { + return Err(ControlError("Commit author")); + } + out.blob(author.as_bytes())?; + } + out.byte(u8::from(!labels.is_empty()))?; + if !labels.is_empty() { + let encoded = layerfs_history::labels::encode(labels); + if encoded.len() > layerfs_history::MAXIMUM_LABELS_JSON_BYTES { + return Err(ControlError("Commit labels JSON")); + } + out.blob(encoded.as_bytes())?; + } + Ok(()) +} + +pub(crate) fn attribution( + input: &mut Reader<'_>, +) -> Result<(Option, Vec), ControlError> { + let author = if boolean(input)? { + Some(input.text(layerfs_history::MAXIMUM_ATTRIBUTION_BYTES)?) + } else { + None + }; + let labels = if boolean(input)? { + let encoded = input.text(layerfs_history::MAXIMUM_LABELS_JSON_BYTES)?; + history(layerfs_history::labels::decode(&encoded))? + } else { + Vec::new() + }; + Ok((author, labels)) +} + +/// Commit metadata, including creation-time author and labels after the LWW flag. pub(crate) fn put_meta(out: &mut Writer, value: &CommitMeta) -> Result<(), ControlError> { out.byte(u8::from(value.branch.is_some()))?; if let Some(branch) = value.branch { @@ -178,7 +218,8 @@ pub(crate) fn put_meta(out: &mut Writer, value: &CommitMeta) -> Result<(), Contr put_head(out, value.displaced)?; // Full decisions remain in the Store and the separate merge report. A // bounded typed history record discloses their omission explicitly. - out.byte(u8::from(value.lww_truncated || !value.lww.is_empty())) + out.byte(u8::from(value.lww_truncated || !value.lww.is_empty()))?; + put_attribution(out, &value.author, &value.labels) } pub(crate) fn meta(input: &mut Reader<'_>) -> Result { let branch = if boolean(input)? { @@ -195,21 +236,29 @@ pub(crate) fn meta(input: &mut Reader<'_>) -> Result { } }; let message = text()?; + let merged_from = node(input)?; + let time_ms = i64::from_be_bytes(input.array()?); + let statistics = layerfs_history::CommitStatistics { + logical: u64::from_be_bytes(input.array()?), + new_canonical: u64::from_be_bytes(input.array()?), + new_stored: u64::from_be_bytes(input.array()?), + new_metadata: u64::from_be_bytes(input.array()?), + }; + let displaced = head(input)?; + let lww_truncated = boolean(input)?; + let (author, labels) = attribution(input)?; Ok(CommitMeta { branch, via, message, - merged_from: node(input)?, - time_ms: i64::from_be_bytes(input.array()?), - statistics: layerfs_history::CommitStatistics { - logical: u64::from_be_bytes(input.array()?), - new_canonical: u64::from_be_bytes(input.array()?), - new_stored: u64::from_be_bytes(input.array()?), - new_metadata: u64::from_be_bytes(input.array()?), - }, - displaced: head(input)?, + author, + labels, + merged_from, + time_ms, + statistics, + displaced, lww: Vec::new(), - lww_truncated: boolean(input)?, + lww_truncated, // Like the decisions, the unit list stays in the Store. units: Vec::new(), }) diff --git a/engine/crates/layerfs-bridge/src/control_merge.rs b/engine/crates/layerfs-bridge/src/control_merge.rs index 503f9204c..ddd0e429d 100644 --- a/engine/crates/layerfs-bridge/src/control_merge.rs +++ b/engine/crates/layerfs-bridge/src/control_merge.rs @@ -25,6 +25,10 @@ pub struct CommitControl { pub force: bool, /// Description of the captured filesystem change. pub message: Option, + /// Person or agent making the Commit. + pub author: Option, + /// Free-form creation-time labels. + pub labels: Vec, } /// A Commit published after merging with a moved head. @@ -72,7 +76,7 @@ pub(crate) fn put_commit_control( } out.blob(text.as_bytes())?; } - Ok(()) + crate::control_history::put_attribution(out, &value.author, &value.labels) } pub(crate) fn commit_control(input: &mut Reader<'_>) -> Result { @@ -86,10 +90,13 @@ pub(crate) fn commit_control(input: &mut Reader<'_>) -> Result) -> SdkValue { ("root", SdkValue::text(hex(record.root.as_bytes()))), ("via", SdkValue::text(via(meta.via))), ("message", SdkValue::opt_text(meta.message.clone())), + ("author", SdkValue::opt_text(meta.author.clone())), + ( + "labels", + SdkValue::List(meta.labels.iter().map(SdkValue::text).collect()), + ), ( "mergedFrom", SdkValue::opt_text(meta.merged_from.map(|id| hex(id.as_slice()))), diff --git a/engine/crates/layerfs-bridge/tests/control_records.rs b/engine/crates/layerfs-bridge/tests/control_records.rs index ffd01c5cf..5ab382a69 100644 --- a/engine/crates/layerfs-bridge/tests/control_records.rs +++ b/engine/crates/layerfs-bridge/tests/control_records.rs @@ -55,6 +55,8 @@ fn commands_are_bounded_exact_and_reject_every_truncated_prefix() { token, force: false, message: Some("agent".into()), + author: Some("author-p7".into()), + labels: vec!["call-7".into(), "call-7".into()], }), Request::Status(token), Request::Unmount(UnmountControl { diff --git a/engine/crates/layerfs-bridge/tests/observed_records.rs b/engine/crates/layerfs-bridge/tests/observed_records.rs index f4da63c84..378b424cb 100644 --- a/engine/crates/layerfs-bridge/tests/observed_records.rs +++ b/engine/crates/layerfs-bridge/tests/observed_records.rs @@ -49,6 +49,8 @@ fn one_checked_scoped_layer_preserves_point_request_capacity() { token, force: false, message: None, + author: None, + labels: Vec::new(), }), Request::Status(token), Request::Unmount(UnmountControl { diff --git a/engine/crates/layerfs-daemon/src/control/operations.rs b/engine/crates/layerfs-daemon/src/control/operations.rs index 3b44d8105..63895c65f 100644 --- a/engine/crates/layerfs-daemon/src/control/operations.rs +++ b/engine/crates/layerfs-daemon/src/control/operations.rs @@ -104,6 +104,8 @@ impl Service { workspace.commit_captured(&CommitOptions { force: control.force, message: control.message.clone(), + author: control.author.clone(), + labels: control.labels.clone(), }) }), Request::Unmount(control) => self.unmount(*control), diff --git a/engine/crates/layerfs-daemon/tests/forced_producers.rs b/engine/crates/layerfs-daemon/tests/forced_producers.rs index 41ffc1d52..9f197d039 100644 --- a/engine/crates/layerfs-daemon/tests/forced_producers.rs +++ b/engine/crates/layerfs-daemon/tests/forced_producers.rs @@ -598,6 +598,8 @@ fn commit( token, force: false, message: None, + author: None, + labels: Vec::new(), }); let result = match gate { Some(gate) => service.execute(&request, move |_, _, snapshot| { diff --git a/engine/crates/layerfs-daemon/tests/mounted_concurrency.rs b/engine/crates/layerfs-daemon/tests/mounted_concurrency.rs index cf52606be..4982b3cce 100644 --- a/engine/crates/layerfs-daemon/tests/mounted_concurrency.rs +++ b/engine/crates/layerfs-daemon/tests/mounted_concurrency.rs @@ -498,6 +498,8 @@ fn commit(rig: &Rig, token: WorkspaceToken, what: &str) -> CommitRecord { token, force: false, message: None, + author: None, + labels: Vec::new(), }, )) { Ok(done) => { diff --git a/engine/crates/layerfs-daemon/tests/mounted_cycles.rs b/engine/crates/layerfs-daemon/tests/mounted_cycles.rs index ea0362c04..3751d1c77 100644 --- a/engine/crates/layerfs-daemon/tests/mounted_cycles.rs +++ b/engine/crates/layerfs-daemon/tests/mounted_cycles.rs @@ -1063,6 +1063,8 @@ fn simultaneous_mounts_over_related_roots_are_isolated_and_a_stale_token_never_r token, force: false, message: None, + author: None, + labels: Vec::new(), }), Request::Attach(token), Request::Unmount(layerfs_bridge::control::UnmountControl { diff --git a/engine/crates/layerfs-daemon/tests/mounted_orphan_commit.rs b/engine/crates/layerfs-daemon/tests/mounted_orphan_commit.rs index 5f0799164..43ce2f4da 100644 --- a/engine/crates/layerfs-daemon/tests/mounted_orphan_commit.rs +++ b/engine/crates/layerfs-daemon/tests/mounted_orphan_commit.rs @@ -360,6 +360,8 @@ fn commit(service: Arc, token: WorkspaceToken) -> JoinHandle token, force: false, message: None, + author: None, + labels: Vec::new(), }); match service.execute_control(&request) { Ok(done) => Finished { diff --git a/engine/crates/layerfs-daemon/tests/mounted_parallel_commits.rs b/engine/crates/layerfs-daemon/tests/mounted_parallel_commits.rs index 0cb39aa2c..8e04ed923 100644 --- a/engine/crates/layerfs-daemon/tests/mounted_parallel_commits.rs +++ b/engine/crates/layerfs-daemon/tests/mounted_parallel_commits.rs @@ -110,6 +110,8 @@ fn parallel_workspace_commits_keep_distinct_captures_and_one_admission_each() { token, force: false, message: None, + author: None, + labels: Vec::new(), }, )); let _ = tx.send((tag, result)); diff --git a/engine/crates/layerfs-daemon/tests/support/mounted.rs b/engine/crates/layerfs-daemon/tests/support/mounted.rs index da9701843..e42a423ec 100644 --- a/engine/crates/layerfs-daemon/tests/support/mounted.rs +++ b/engine/crates/layerfs-daemon/tests/support/mounted.rs @@ -152,6 +152,8 @@ impl Harness { token, force: false, message: None, + author: None, + labels: Vec::new(), })) } pub fn try_unmount(&self, token: WorkspaceToken) -> Result { diff --git a/engine/crates/layerfs-node/src/workspace.rs b/engine/crates/layerfs-node/src/workspace.rs index 829df67f7..10c90c845 100644 --- a/engine/crates/layerfs-node/src/workspace.rs +++ b/engine/crates/layerfs-node/src/workspace.rs @@ -15,9 +15,49 @@ use crate::sandbox::{operation, WORKSPACES}; use layerfs_api::internal::workspace; use layerfs_api::sandbox::{values, Unmounted}; use layerfs_bridge::control::{CommitControl, SdkRefusal, SdkValue}; -use layerfs_history::WorkspaceId; +use layerfs_history::{CommitMeta, CommitVia, WorkspaceId}; use std::path::Path; +fn attribution(args: &SdkValue) -> Result<(Option, Vec), SdkRefusal> { + let author = match args.get("author") { + None => None, + Some(SdkValue::Text(author)) => Some(author.clone()), + _ => { + return Err(crate::job::refused( + "InvalidInput", + "workspace.commit author must be a string", + )) + } + }; + let labels = match args.get("labels") { + None => Vec::new(), + Some(SdkValue::List(labels)) => labels + .iter() + .map(|label| match label { + SdkValue::Text(label) => Ok(label.clone()), + _ => Err(crate::job::refused( + "InvalidInput", + "workspace.commit labels must be strings", + )), + }) + .collect::, _>>()?, + _ => { + return Err(crate::job::refused( + "InvalidInput", + "workspace.commit labels must be an array of strings", + )) + } + }; + CommitMeta { + author: author.clone(), + labels: labels.clone(), + ..CommitMeta::new(CommitVia::Workspace) + } + .check() + .map_err(|_| crate::job::refused("InvalidInput", "invalid workspace.commit attribution"))?; + Ok((author, labels)) +} + /// The Workspaces Sandbox directory `directory` records. fn recorded(directory: &Path) -> Result { let mut count = 0; @@ -235,8 +275,9 @@ pub(crate) fn status(args: &SdkValue) -> Outcome { Ok(value) } -/// `workspace.commit(ws, { message?, force? })`. +/// `workspace.commit(ws, { message?, force?, author?, labels? })`. pub(crate) fn commit(args: &SdkValue) -> Outcome { + let (author, labels) = attribution(args)?; let state = sandbox(args)?; let token = state.token(text(args, "path")?)?; let reply = workspace(&mut *crate::trace::control(&state.controls)?) @@ -244,6 +285,8 @@ pub(crate) fn commit(args: &SdkValue) -> Outcome { token, force: args.get_bool("force"), message: args.get_text("message").map(str::to_owned), + author, + labels, }) .map_err(operation)?; values::commit(&reply) diff --git a/engine/crates/layerfs-persistence/sql/sqlite/history.sql b/engine/crates/layerfs-persistence/sql/sqlite/history.sql index 9de411407..e013bca98 100644 --- a/engine/crates/layerfs-persistence/sql/sqlite/history.sql +++ b/engine/crates/layerfs-persistence/sql/sqlite/history.sql @@ -102,6 +102,8 @@ CREATE TABLE "commit" ( new_metadata INTEGER NOT NULL DEFAULT 0 CHECK(new_metadata>=0), lww BLOB NOT NULL, displaced_id BLOB CHECK(displaced_id IS NULL OR length(displaced_id)=33), + author TEXT CHECK (author IS NULL OR length(CAST(author AS BLOB)) <= 4096), + labels TEXT CHECK (labels IS NULL OR length(CAST(labels AS BLOB)) <= 4096), FOREIGN KEY (layer_stack_id, base_layer_id) REFERENCES layer(layer_stack_id, layer_id) DEFERRABLE INITIALLY DEFERRED, FOREIGN KEY (layer_stack_id, parent_commit_id) diff --git a/engine/crates/layerfs-persistence/sql/sqlite/queries/history/commit_commit_by_id.sql b/engine/crates/layerfs-persistence/sql/sqlite/queries/history/commit_commit_by_id.sql index 3a9a722c3..f70a1817a 100644 --- a/engine/crates/layerfs-persistence/sql/sqlite/queries/history/commit_commit_by_id.sql +++ b/engine/crates/layerfs-persistence/sql/sqlite/queries/history/commit_commit_by_id.sql @@ -1 +1 @@ -SELECT commit_id, layer_stack_id, root_id, parent_commit_id, base_layer_id, second_parent_id, branch_id, via, message, merged_from_id, created_ms, logical, new_canonical, new_stored, new_metadata, lww, displaced_id FROM "commit" WHERE commit_id = $1; +SELECT commit_id, layer_stack_id, root_id, parent_commit_id, base_layer_id, second_parent_id, branch_id, via, message, merged_from_id, created_ms, logical, new_canonical, new_stored, new_metadata, lww, displaced_id, author, labels FROM "commit" WHERE commit_id = $1; diff --git a/engine/crates/layerfs-persistence/sql/sqlite/queries/history/commit_insert_commit.sql b/engine/crates/layerfs-persistence/sql/sqlite/queries/history/commit_insert_commit.sql index 3b9a3f9c8..cfb7791d2 100644 --- a/engine/crates/layerfs-persistence/sql/sqlite/queries/history/commit_insert_commit.sql +++ b/engine/crates/layerfs-persistence/sql/sqlite/queries/history/commit_insert_commit.sql @@ -1 +1 @@ -INSERT INTO "commit" (commit_id, layer_stack_id, root_id, parent_commit_id, base_layer_id, second_parent_id, branch_id, via, message, merged_from_id, created_ms, logical, new_canonical, new_stored, new_metadata, lww, displaced_id) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17) RETURNING 1; +INSERT INTO "commit" (commit_id, layer_stack_id, root_id, parent_commit_id, base_layer_id, second_parent_id, branch_id, via, message, merged_from_id, created_ms, logical, new_canonical, new_stored, new_metadata, lww, displaced_id, author, labels) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19) RETURNING 1; diff --git a/engine/crates/layerfs-persistence/src/backend/sqlite/schema.rs b/engine/crates/layerfs-persistence/src/backend/sqlite/schema.rs index 88fb63097..7a3ece61a 100644 --- a/engine/crates/layerfs-persistence/src/backend/sqlite/schema.rs +++ b/engine/crates/layerfs-persistence/src/backend/sqlite/schema.rs @@ -7,6 +7,11 @@ pub(crate) const APPLICATION_ID: i64 = 1279677264; pub(crate) const OBJECTS: &str = include_str!("../../../sql/sqlite/objects.sql"); pub(crate) const METADATA: &str = include_str!("../../../sql/sqlite/metadata.sql"); pub(crate) const HISTORY: &str = include_str!("../../../sql/sqlite/history.sql"); +const ATTRIBUTION: &str = " author TEXT CHECK (author IS NULL OR length(CAST(author AS BLOB)) <= 4096),\n labels TEXT CHECK (labels IS NULL OR length(CAST(labels AS BLOB)) <= 4096),\n"; +const ATTRIBUTION_MIGRATION: &str = "ALTER TABLE \"commit\" ADD COLUMN author TEXT CHECK (author IS NULL OR length(CAST(author AS BLOB)) <= 4096);\nALTER TABLE \"commit\" ADD COLUMN labels TEXT CHECK (labels IS NULL OR length(CAST(labels AS BLOB)) <= 4096);\n"; +fn before_attribution(source: String) -> String { + source.replacen(ATTRIBUTION, "", 1) +} /// Lineage tables (#353); a Store written before them gains them at open. pub(crate) const LINEAGE: &str = include_str!("../../../sql/sqlite/lineage.sql"); /// Merge build records (#355); a Store written before them gains them at open. @@ -167,7 +172,7 @@ impl super::connection::Session { }; // The schema this migration records is the one before the // lineage and merge tables; later migrations add those. - let current = source((self.layout, self.acquisition)) + let current = before_attribution(source((self.layout, self.acquisition))) .replacen(DIGESTS, "", 1) .replacen(SCRATCH, "", 1) .replacen(LINEAGE, "", 1); @@ -214,7 +219,7 @@ impl super::connection::Session { let Some(row) = rows.first() else { return Ok(()); }; - let current = source((self.layout, self.acquisition)) + let current = before_attribution(source((self.layout, self.acquisition))) .replacen(DIGESTS, "", 1) .replacen(SCRATCH, "", 1); if row.get::(0)? != current.replacen(LINEAGE, "", 1) @@ -251,7 +256,7 @@ impl super::connection::Session { let Some(row) = rows.first() else { return Ok(()); }; - let current = source((self.layout, self.acquisition)).replacen(DIGESTS, "", 1); + let current = before_attribution(source((self.layout, self.acquisition))).replacen(DIGESTS, "", 1); if row.get::(0)? != current.replacen(SCRATCH, "", 1) || row.get::(1)? != definition(tx.connection, tx.work)? { @@ -286,7 +291,7 @@ impl super::connection::Session { let Some(row) = rows.first() else { return Ok(()); }; - let current = source((self.layout, self.acquisition)); + let current = before_attribution(source((self.layout, self.acquisition))); if row.get::(0)? != current.replacen(DIGESTS, "", 1) || row.get::(1)? != definition(tx.connection, tx.work)? { @@ -301,6 +306,38 @@ impl super::connection::Session { Ok(()) }) } + /// Appends nullable Commit author and labels columns without backfilling old rows. + pub(crate) fn add_commit_attribution(&self) -> Result<(), BackendError> { + self.run::<_, BackendError>(true, |tx| { + let columns = tx.query("PRAGMA table_info(\"commit\")", vec![])?; + if columns.iter().any(|row| { + row.get::(1) + .is_ok_and(|name| name == "author") + }) { + return Ok(()); + } + let rows = tx.query( + "SELECT schema_source,schema_definition FROM history_meta WHERE id=1", + vec![], + )?; + let Some(row) = rows.first() else { + return Ok(()); + }; + let current = source((self.layout, self.acquisition)); + if row.get::(0)? != before_attribution(current.clone()) + || row.get::(1)? != definition(tx.connection, tx.work)? + { + return Ok(()); + } + tx.bootstrap(ATTRIBUTION_MIGRATION)?; + let definition = definition(tx.connection, tx.work)?; + tx.query( + "UPDATE history_meta SET schema_source=?1,schema_definition=?2 WHERE id=1", + vec![Param::Text(current), Param::Text(definition)], + )?; + Ok(()) + }) + } pub(crate) fn validate( &self, catalog_id: layerfs_history::CatalogId, diff --git a/engine/crates/layerfs-persistence/src/history/commit.rs b/engine/crates/layerfs-persistence/src/history/commit.rs index ed1b43414..fa0ac014a 100644 --- a/engine/crates/layerfs-persistence/src/history/commit.rs +++ b/engine/crates/layerfs-persistence/src/history/commit.rs @@ -28,7 +28,8 @@ pub(crate) fn commit(tx: &Transaction<'_>, id: CommitId) -> HistoryResult