Skip to content

feat(core): default run storage outside the project#55

Merged
ElbertePlinio merged 5 commits into
mainfrom
feat/34-artifact-storage
Jul 23, 2026
Merged

feat(core): default run storage outside the project#55
ElbertePlinio merged 5 commits into
mainfrom
feat/34-artifact-storage

Conversation

@ElbertePlinio

Copy link
Copy Markdown
Member

Summary

Implements #34 under the workspace storage policy:

  • default home mode: ~/.pickforge/picklab/projects/<slug-sha256:16>/runs/ (stable id from the canonical project path); project-local restores .picklab/runs/ opt-in; custom requires an absolute, non-project-contained path
  • one resolver seam (resolveRunStorage: config layers + PICKLAB_STORAGE_MODE/PICKLAB_STORAGE_PATH env, env winning) used by runs, evidence, CLI artifacts, and MCP tools/resources
  • non-destructive legacy handling: read-only project-local fallback root in the catalog; PICKLAB_HOME default moves ~/.picklab~/.pickforge/picklab with per-file read fallback, session dir-merge, writes always to the new home; picklab doctor warns on a detected legacy home
  • trust hardening from review: a repo-committed .picklab/config.json can no longer select custom storage (user-owned global config or env only; falls back to home + doctor storage-custom-rejected warn), and custom paths inside the project are rejected
  • retention pruning scoped to the primary root — legacy runs are never deleted (reproduced-then-fixed P0 with an on-disk regression test); session destroy removes both new-home and legacy copies

Closes #34. Follow-up filed: #54 (pre-existing write-path symlink verification, exposure reduced by this PR).

Validation

  • typecheck, build, focused suites green; full suite 957 passed / 43 failed with the 43 verified byte-identical to origin/main's platform-environment cluster (proc/Xvfb, macOS tmpdir realpath, CDP)
  • coverage thresholds could not flush on this macOS host (pre-existing) — ubuntu CI on this PR is the coverage gate

Local review: serious-backend risk class — Opus 4.8 (data-safety), Sonnet 5 (operational), Sonnet 5 (security/trust); adjudicated P0+P1+3×P2 all fixed; targeted fix-verification clean. KISS keep.

Screenshots, logs, manifests, and evidence journals now default to
~/.pickforge/picklab/projects/<projectId>/runs/ instead of
<project>/.picklab/runs/, so a default run never appears in `git
status`. PICKLAB_HOME now defaults to ~/.pickforge/picklab (was
~/.picklab); the old default is still read non-destructively as a
fallback for existing global config, agent state, and sessions.

- Add a single storage resolver (resolveRunStorage) for home,
  project-local, and custom modes, with a stable per-project id
  derived from the canonical project path. Route run creation, the
  run catalog, and active-evidence-pointer resolution in core, CLI,
  and MCP through it.
- openRunCatalog layers the resolved primary root with a read-only
  legacy project-local fallback root, so pre-existing project-local
  runs stay discoverable without migration.
- Add PICKLAB_STORAGE_MODE / PICKLAB_STORAGE_PATH env overrides and a
  project/global config `storage` field.
- Add a non-destructive legacy read fallback (resolveReadablePath,
  per-entry) for global config, agent state, and sessions across the
  ~/.picklab -> ~/.pickforge/picklab default-root change; picklab
  doctor flags a detected legacy home.
- Update README/SECURITY docs and record the default change in
  docs/releases/UNRELEASED.md.

Closes #34
readAgentsState previously only checked the new PICKLAB_HOME default,
so "is this agent registered?" silently forgot pre-#34 ~/.picklab
state instead of falling back to it non-destructively, unlike global
config and sessions.
pruneFinalizedEvidenceRuns computed retention over the full merged
run catalog, which under home/custom mode includes the read-only
legacy project-local fallback root added for discovery. Once more
than `keep` finalized runs existed across both roots, it would
rm -rf the oldest ones regardless of which root they lived in,
deleting pre-existing legacy runs it never owned and never migrated.
Reproduced live: 15 legacy + 10 new runs, keep=5 deleted 5 legacy
dirs.

Scope both the candidate set and the active-pointer bookkeeping to
entries whose rootDir matches the resolved primary storage root; the
legacy fallback root is read-only by contract. Also corrects the
docstring, which still claimed no finalization producer called this
function (finalizeActiveEvidenceRun has called it automatically since
this feature shipped).
…oject paths

Project-committed .picklab/config.json could select storage.mode
"custom" with an arbitrary path, and that config travels with `git
clone` — a hostile or careless repo could silently redirect artifact
writes (screenshots, which may carry secrets) to any absolute path
with zero prompt.

Only the user-owned global config or the PICKLAB_STORAGE_MODE /
PICKLAB_STORAGE_PATH env override may now select "custom" (and
supply its path). Project config may still select "project-local"
(blast radius already scoped to itself) or "home". A project config
requesting "custom" is not an error: it falls back to global
config's mode, then "home", so a cloned repo with a hostile or
misconfigured project config never bricks `picklab`. The rejection
is reported on the resolved result (`rejectedProjectCustom`) and
surfaced by `picklab doctor` as a warning.

Also reject a custom path that equals or is nested inside the
project directory — otherwise it just reintroduces project pollution
under a different name.

Implemented by reading the global and project config layers
separately (config.ts's new loadConfigLayers) instead of resolving
storage from loadConfig's already-merged blob, since the merge alone
can't tell which layer a value came from.
…le one

destroySessionRecord removed only the path resolveReadablePath would
pick for a read (new home if present, else legacy). A session
created under the legacy home and later updated writes to the new
home on every update but never touches the stale legacy copy, so
once both exist, destroying it only deleted the new-home copy —
resolveReadablePath then picked the surviving legacy file on the
next read, resurrecting the "destroyed" session via getSession /
listSessions.

Remove both the new-home and legacy paths unconditionally (force);
either or both may not exist.
@ElbertePlinio
ElbertePlinio merged commit 9b1f3c9 into main Jul 23, 2026
1 of 2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Store run artifacts outside project repositories by default

1 participant