From 362e26a5a39c50d8c5efc56bafd1143b60ad66ef Mon Sep 17 00:00:00 2001 From: James Ross Date: Wed, 8 Jul 2026 00:56:53 -0700 Subject: [PATCH 1/9] chore: enforce hexagonal boundary ratchet --- CHANGELOG.md | 4 + docs/INFRASTRUCTURE_DOCTRINE.md | 11 + .../hexagonal-boundary-ratchet-baseline.json | 43 ++ .../bad-code/CORE_hexagonal-store-boundary.md | 8 + package-lock.json | 432 +----------------- package.json | 3 +- scripts/hexagonal-boundary-ratchet.mjs | 252 ++++++++++ scripts/lint.mjs | 7 +- src/doctor.js | 6 +- src/store/checkpoint-read.js | 4 +- src/store/checkpoint-state.js | 88 +++- test/ports/checkpoint-read.test.js | 9 +- 12 files changed, 422 insertions(+), 445 deletions(-) create mode 100644 docs/audit/hexagonal-boundary-ratchet-baseline.json create mode 100644 scripts/hexagonal-boundary-ratchet.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index fc61c822..abfada3b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,10 @@ Release discipline: ## Unreleased +- added a Hexagonal Boundary lint ratchet so product code cannot increase + direct substrate-layout or `git-warp` runtime leakage +- upgraded `@git-stunts/git-warp` to 18.2.1 + ## [0.7.2] - 2026-06-23 - added feature proposal packets for History product boundaries, Browse memory workbench, followthrough job queue, agent-native memory API, and evidence-bound enrichment diff --git a/docs/INFRASTRUCTURE_DOCTRINE.md b/docs/INFRASTRUCTURE_DOCTRINE.md index 017f4759..a5461c99 100644 --- a/docs/INFRASTRUCTURE_DOCTRINE.md +++ b/docs/INFRASTRUCTURE_DOCTRINE.md @@ -60,6 +60,17 @@ Examples of capabilities that require ports: - CLI, MCP, macOS, and browser I/O. - Encoding, decoding, and content storage. +Product code must not know substrate layout. References to concrete +storage engines, `git-warp` runtime classes, WARP ref paths, +checkpoint/state-cache mechanics, materialization strategies, or repair +commands belong only in adapters, diagnostics, repair scripts, or +benchmarks. CLI, MCP, Browse, Remember, Stats, and domain modules consume +product ports and runtime-backed domain facts, not storage internals. + +This boundary is enforced by `npm run hexagonal-boundary:ratchet`, which +runs from `npm run lint`. Existing leaks are debt in the ratchet baseline; +new code must not increase them. + ### 2. Dependency Injection Dependencies are injected through constructors or semantically named diff --git a/docs/audit/hexagonal-boundary-ratchet-baseline.json b/docs/audit/hexagonal-boundary-ratchet-baseline.json new file mode 100644 index 00000000..561f6d88 --- /dev/null +++ b/docs/audit/hexagonal-boundary-ratchet-baseline.json @@ -0,0 +1,43 @@ +{ + "allowedBoundaryFiles": [ + "src/browse-benchmark.js", + "src/browse/adapters/git-warp-worker.js", + "src/browse/adapters/git-warp.js", + "src/cli/commands/doctor.js", + "src/doctor.js", + "src/history/git-warp-read.js", + "src/store/checkpoint-state.js" + ], + "generatedFrom": "scripts/hexagonal-boundary-ratchet.mjs", + "leaks": { + "byFile": { + "src/git.js": 1, + "src/mcp/service.js": 2, + "src/store/annotate.js": 4, + "src/store/capture.js": 11, + "src/store/checkpoint-product-read.js": 3, + "src/store/checkpoint-read.js": 4, + "src/store/content-reader.js": 1, + "src/store/derivation.js": 3, + "src/store/enrichment/runner.js": 5, + "src/store/migrations.js": 6, + "src/store/queries.js": 2, + "src/store/reflect.js": 7, + "src/store/runtime.js": 28 + }, + "byTerm": { + "checkpoint-mechanic": 20, + "git-warp-package": 2, + "git-warp-runtime-symbol": 54, + "warp-ref-layout": 1 + }, + "total": 77 + }, + "substrateTermIds": [ + "git-warp-package", + "git-warp-runtime-symbol", + "checkpoint-mechanic", + "warp-ref-layout" + ], + "version": 1 +} diff --git a/docs/method/backlog/bad-code/CORE_hexagonal-store-boundary.md b/docs/method/backlog/bad-code/CORE_hexagonal-store-boundary.md index c67a9c57..b2becb13 100644 --- a/docs/method/backlog/bad-code/CORE_hexagonal-store-boundary.md +++ b/docs/method/backlog/bad-code/CORE_hexagonal-store-boundary.md @@ -17,6 +17,9 @@ on injected ports and runtime domain objects, not on concrete host APIs. ## Acceptance Criteria +- Add a lint ratchet that counts direct substrate-layout and `git-warp` + runtime leakage outside explicit adapter, diagnostic, repair, or + benchmark boundaries. - Define explicit ports for graph persistence, content storage, clocks, random IDs, host metadata, prompt metrics storage, and ambient project context. @@ -28,3 +31,8 @@ on injected ports and runtime domain objects, not on concrete host APIs. `@git-stunts/*` concrete adapters directly. - Add at least one browser-like unit test that runs core store logic without Node filesystem, process, or Git globals. + +## Progress + +- [x] Hexagonal Boundary ratchet added to `npm run lint`. +- [ ] Existing substrate leaks moved behind product ports or adapters. diff --git a/package-lock.json b/package-lock.json index 54412697..86d0fc51 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,7 @@ "@flyingrobots/bijou-node": "7.1.0", "@flyingrobots/bijou-tui": "7.1.0", "@git-stunts/alfred": "0.10.3", - "@git-stunts/git-warp": "^18.0.0", + "@git-stunts/git-warp": "^18.2.1", "@git-stunts/plumbing": "3.0.3", "@modelcontextprotocol/sdk": "^1.29.0", "zod": "^4.3.6" @@ -439,15 +439,15 @@ } }, "node_modules/@git-stunts/git-warp": { - "version": "18.0.0", - "resolved": "https://registry.npmjs.org/@git-stunts/git-warp/-/git-warp-18.0.0.tgz", - "integrity": "sha512-tMj6RCeGwSnhpBzXD8i+CRh81wJLxol13tfffnMrvX9STSU0+fZCAopFkrZZfrFj+B1zqivK6V3zEC9c5+0OKg==", + "version": "18.2.1", + "resolved": "https://registry.npmjs.org/@git-stunts/git-warp/-/git-warp-18.2.1.tgz", + "integrity": "sha512-M84jOC3Uukio+DlX6ARbCzpl4lfyCB9eodN5pR1hmzHkOSIJyaGCaYPPAafcnSTUYUwUd3z2p3rA9Sq3WoJeOQ==", "license": "Apache-2.0", "workspaces": [ "packages/*" ], "dependencies": { - "@git-stunts/alfred": "^0.10.3", + "@git-stunts/alfred": "^0.10.4", "@git-stunts/git-cas": "^6.0.0", "@git-stunts/plumbing": "^3.0.3", "@git-stunts/trailer-codec": "^2.1.1", @@ -461,7 +461,7 @@ "roaring-wasm": "^1.1.0", "string-width": "^7.1.0", "wrap-ansi": "^9.0.0", - "zod": "3.24.1" + "zod": "^3.24.1" }, "bin": { "git-warp": "bin/git-warp", @@ -469,9 +469,15 @@ }, "engines": { "node": ">=22.0.0" - }, - "optionalDependencies": { - "roaring": "^2.7.0" + } + }, + "node_modules/@git-stunts/git-warp/node_modules/@git-stunts/alfred": { + "version": "0.10.4", + "resolved": "https://registry.npmjs.org/@git-stunts/alfred/-/alfred-0.10.4.tgz", + "integrity": "sha512-trA8/7AjePIbfUu1tXF2pzaT6Cb93Zy2DuwaxIqvi15qqMNA6Rhb0/7dpu0kOIW3s2DqPSjV/CA6tfecbRAigw==", + "license": "Apache-2.0", + "engines": { + "node": ">=20.0.0" } }, "node_modules/@git-stunts/git-warp/node_modules/zod": { @@ -596,41 +602,6 @@ "url": "https://github.com/sponsors/nzakas" } }, - "node_modules/@isaacs/fs-minipass": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", - "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", - "license": "ISC", - "optional": true, - "dependencies": { - "minipass": "^7.0.4" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@mapbox/node-pre-gyp": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-2.0.3.tgz", - "integrity": "sha512-uwPAhccfFJlsfCxMYTwOdVfOz3xqyj8xYL3zJj8f0pb30tLohnnFPhLuqp4/qoEz8sNxe4SESZedcBojRefIzg==", - "license": "BSD-3-Clause", - "optional": true, - "dependencies": { - "consola": "^3.2.3", - "detect-libc": "^2.0.0", - "https-proxy-agent": "^7.0.5", - "node-fetch": "^2.6.7", - "nopt": "^8.0.0", - "semver": "^7.5.3", - "tar": "^7.4.0" - }, - "bin": { - "node-pre-gyp": "bin/node-pre-gyp" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/@modelcontextprotocol/sdk": { "version": "1.29.0", "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", @@ -713,16 +684,6 @@ "dev": true, "license": "MIT" }, - "node_modules/abbrev": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-3.0.1.tgz", - "integrity": "sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg==", - "license": "ISC", - "optional": true, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, "node_modules/accepts": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", @@ -759,16 +720,6 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, - "node_modules/agent-base": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", - "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">= 14" - } - }, "node_modules/ajv": { "version": "8.18.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", @@ -1078,16 +1029,6 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/chownr": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", - "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", - "license": "BlueOak-1.0.0", - "optional": true, - "engines": { - "node": ">=18" - } - }, "node_modules/cli-boxes": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-3.0.0.tgz", @@ -1165,16 +1106,6 @@ "node": ">=20" } }, - "node_modules/consola": { - "version": "3.4.2", - "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", - "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", - "license": "MIT", - "optional": true, - "engines": { - "node": "^14.18.0 || >=16.10.0" - } - }, "node_modules/content-disposition": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", @@ -1357,16 +1288,6 @@ "node": ">= 0.8" } }, - "node_modules/env-paths": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", - "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">=6" - } - }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -1622,13 +1543,6 @@ "node": ">=18.0.0" } }, - "node_modules/exponential-backoff": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz", - "integrity": "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==", - "license": "Apache-2.0", - "optional": true - }, "node_modules/express": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", @@ -1726,24 +1640,6 @@ ], "license": "BSD-3-Clause" }, - "node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, "node_modules/figures": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/figures/-/figures-6.1.0.tgz", @@ -1951,13 +1847,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/graceful-fs": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "license": "ISC", - "optional": true - }, "node_modules/has-symbols": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", @@ -2011,20 +1900,6 @@ "url": "https://opencollective.com/express" } }, - "node_modules/https-proxy-agent": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", - "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", - "license": "MIT", - "optional": true, - "dependencies": { - "agent-base": "^7.1.2", - "debug": "4" - }, - "engines": { - "node": ">= 14" - } - }, "node_modules/iconv-lite": { "version": "0.7.2", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", @@ -2135,16 +2010,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/isexe": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-4.0.0.tgz", - "integrity": "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==", - "license": "BlueOak-1.0.0", - "optional": true, - "engines": { - "node": ">=20" - } - }, "node_modules/jose": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.2.tgz", @@ -2291,29 +2156,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/minipass": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", - "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", - "license": "BlueOak-1.0.0", - "optional": true, - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, - "node_modules/minizlib": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", - "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", - "license": "MIT", - "optional": true, - "dependencies": { - "minipass": "^7.1.2" - }, - "engines": { - "node": ">= 18" - } - }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -2336,52 +2178,6 @@ "node": ">= 0.6" } }, - "node_modules/node-fetch": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", - "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", - "license": "MIT", - "optional": true, - "dependencies": { - "whatwg-url": "^5.0.0" - }, - "engines": { - "node": "4.x || >=6.0.0" - }, - "peerDependencies": { - "encoding": "^0.1.0" - }, - "peerDependenciesMeta": { - "encoding": { - "optional": true - } - } - }, - "node_modules/node-gyp": { - "version": "12.4.0", - "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-12.4.0.tgz", - "integrity": "sha512-OMcPNvqTCFUnNaBlmdgq+lfNqY7gTiSmNRDjY3uAXRyudeKZEZxu3CLtjMQrx4zZxCX2b/mpNqTtwuCJgXhHkw==", - "license": "MIT", - "optional": true, - "dependencies": { - "env-paths": "^2.2.0", - "exponential-backoff": "^3.1.1", - "graceful-fs": "^4.2.6", - "nopt": "^9.0.0", - "proc-log": "^6.0.0", - "semver": "^7.3.5", - "tar": "^7.5.4", - "tinyglobby": "^0.2.12", - "undici": "^6.25.0", - "which": "^6.0.0" - }, - "bin": { - "node-gyp": "bin/node-gyp.js" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, "node_modules/node-gyp-build-optional-packages": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.1.1.tgz", @@ -2397,48 +2193,6 @@ "node-gyp-build-optional-packages-test": "build-test.js" } }, - "node_modules/node-gyp/node_modules/abbrev": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-4.0.0.tgz", - "integrity": "sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA==", - "license": "ISC", - "optional": true, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/node-gyp/node_modules/nopt": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/nopt/-/nopt-9.0.0.tgz", - "integrity": "sha512-Zhq3a+yFKrYwSBluL4H9XP3m3y5uvQkB/09CwDruCiRmR/UJYnn9W4R48ry0uGC70aeTPKLynBtscP9efFFcPw==", - "license": "ISC", - "optional": true, - "dependencies": { - "abbrev": "^4.0.0" - }, - "bin": { - "nopt": "bin/nopt.js" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/nopt": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/nopt/-/nopt-8.1.0.tgz", - "integrity": "sha512-ieGu42u/Qsa4TFktmaKEwM6MQH0pOWnaB3htzh0JRtx84+Mebc0cbZYN5bC+6WTZ4+77xrL9Pn5m7CV6VIkV7A==", - "license": "ISC", - "optional": true, - "dependencies": { - "abbrev": "^3.0.0" - }, - "bin": { - "nopt": "bin/nopt.js" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -2575,19 +2329,6 @@ "url": "https://opencollective.com/express" } }, - "node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/pkce-challenge": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", @@ -2607,16 +2348,6 @@ "node": ">= 0.8.0" } }, - "node_modules/proc-log": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-6.1.0.tgz", - "integrity": "sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ==", - "license": "ISC", - "optional": true, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -2688,31 +2419,6 @@ "node": ">=0.10.0" } }, - "node_modules/roaring": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/roaring/-/roaring-2.7.0.tgz", - "integrity": "sha512-gNVeV5H+xb2DB/umdvlPbTrKuCn8zexy6ROkfPL4FcSWxOGWrxfQz0pZeZER+kQLrSoPcz3BolYPM6eFYm96XQ==", - "hasInstallScript": true, - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "@mapbox/node-pre-gyp": "^2.0.3" - }, - "engines": { - "node": ">=18.20.8" - }, - "optionalDependencies": { - "node-gyp": "^12.1.0" - }, - "peerDependencies": { - "node-gyp": "^12.1.0" - }, - "peerDependenciesMeta": { - "node-gyp": { - "optional": true - } - } - }, "node_modules/roaring-wasm": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/roaring-wasm/-/roaring-wasm-1.1.0.tgz", @@ -2744,19 +2450,6 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, - "node_modules/semver": { - "version": "7.8.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", - "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==", - "license": "ISC", - "optional": true, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/send": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", @@ -2942,40 +2635,6 @@ "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, - "node_modules/tar": { - "version": "7.5.16", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.16.tgz", - "integrity": "sha512-56adEpPMouktRlBLXiaYFFzZ/3+JXa8P9n7WbR+ibIjtviN55mEaOkiysCnPnWm+7kkui1Dn8J9l+g6zV8731w==", - "license": "BlueOak-1.0.0", - "optional": true, - "dependencies": { - "@isaacs/fs-minipass": "^4.0.0", - "chownr": "^3.0.0", - "minipass": "^7.1.2", - "minizlib": "^3.1.0", - "yallist": "^5.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/tinyglobby": { - "version": "0.2.17", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", - "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", - "license": "MIT", - "optional": true, - "dependencies": { - "fdir": "^6.5.0", - "picomatch": "^4.0.4" - }, - "engines": { - "node": ">=12.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/SuperchupuDev" - } - }, "node_modules/toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", @@ -2985,13 +2644,6 @@ "node": ">=0.6" } }, - "node_modules/tr46": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", - "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", - "license": "MIT", - "optional": true - }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -3031,16 +2683,6 @@ "node": ">= 0.6" } }, - "node_modules/undici": { - "version": "6.27.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-6.27.0.tgz", - "integrity": "sha512-YmfV3YnEDzXRC5lZ2jWtWWHKGUm1zIt8AhesR1tens+HTNv+YZlN/dp6G727LOvMJ8xjP9Be7Y2Sdr96LDm+pg==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">=18.17" - } - }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", @@ -3069,40 +2711,6 @@ "node": ">= 0.8" } }, - "node_modules/webidl-conversions": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", - "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", - "license": "BSD-2-Clause", - "optional": true - }, - "node_modules/whatwg-url": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", - "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", - "license": "MIT", - "optional": true, - "dependencies": { - "tr46": "~0.0.3", - "webidl-conversions": "^3.0.0" - } - }, - "node_modules/which": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/which/-/which-6.0.1.tgz", - "integrity": "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg==", - "license": "ISC", - "optional": true, - "dependencies": { - "isexe": "^4.0.0" - }, - "bin": { - "node-which": "bin/which.js" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, "node_modules/widest-line": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-4.0.1.tgz", @@ -3174,16 +2782,6 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, - "node_modules/yallist": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", - "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", - "license": "BlueOak-1.0.0", - "optional": true, - "engines": { - "node": ">=18" - } - }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index cd06a2a3..635434c2 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "benchmark:browse": "node benchmarks/browse-bootstrap.js", "benchmark:capture": "node benchmarks/capture-latency.js", "echo:probe": "node ./scripts/think-echo-capability-probe.mjs", + "hexagonal-boundary:ratchet": "node ./scripts/hexagonal-boundary-ratchet.mjs", "repair:v17-mind": "node ./scripts/repair-v17-mind.mjs", "runtime-truth:ratchet": "node ./scripts/runtime-truth-ratchet.mjs" }, @@ -42,7 +43,7 @@ "@flyingrobots/bijou-node": "7.1.0", "@flyingrobots/bijou-tui": "7.1.0", "@git-stunts/alfred": "0.10.3", - "@git-stunts/git-warp": "^18.0.0", + "@git-stunts/git-warp": "^18.2.1", "@git-stunts/plumbing": "3.0.3", "@modelcontextprotocol/sdk": "^1.29.0", "zod": "^4.3.6" diff --git a/scripts/hexagonal-boundary-ratchet.mjs b/scripts/hexagonal-boundary-ratchet.mjs new file mode 100644 index 00000000..1f27d73e --- /dev/null +++ b/scripts/hexagonal-boundary-ratchet.mjs @@ -0,0 +1,252 @@ +#!/usr/bin/env node + +import { spawnSync } from 'node:child_process'; +import { existsSync, readFileSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const baselinePath = path.join(repoRoot, 'docs', 'audit', 'hexagonal-boundary-ratchet-baseline.json'); +const sourcePrefixes = Object.freeze(['src/', 'bin/']); +const allowedBoundaryFiles = Object.freeze([ + 'src/browse-benchmark.js', + 'src/browse/adapters/git-warp-worker.js', + 'src/browse/adapters/git-warp.js', + 'src/cli/commands/doctor.js', + 'src/doctor.js', + 'src/history/git-warp-read.js', + 'src/store/checkpoint-state.js', +]); +const substrateTerms = Object.freeze([ + { + id: 'git-warp-package', + pattern: /@git-stunts\/git-warp/gu, + }, + { + id: 'git-warp-runtime-symbol', + pattern: /\b(?:GitGraphAdapter|WarpApp|WarpCore|openWarpGraph|openWarpWorldline|openWarpApp|patchWarpApp|patchWarpAppWithWriter|openThinkWorldline|commitThinkWorldline|openWarpGraphHandle)\b/gu, + }, + { + id: 'checkpoint-mechanic', + pattern: /\b(?:CHECKPOINT_REF|checkpointPolicy|createCheckpoint|deleteCheckpointRef|getCheckpointRefStatus|openCheckpointStateRead|openCheckpointProductRead|listCheckpointEntriesByKind|listCheckpointEntryPropsByKind)\b/gu, + }, + { + id: 'warp-ref-layout', + pattern: /refs\/warp\/\S+|checkpoints\/head|state-cache/gu, + }, +]); + +class HexagonalBoundaryRatchetError extends Error { + constructor(message) { + super(message); + this.name = 'HexagonalBoundaryRatchetError'; + Object.freeze(this); + } +} + +function parseArgs(argv) { + const parsed = { json: false, writeBaseline: false }; + for (const arg of argv) { + if (arg === '--json') { + parsed.json = true; + continue; + } + if (arg === '--write-baseline') { + parsed.writeBaseline = true; + continue; + } + throw new HexagonalBoundaryRatchetError(`Unknown argument: ${arg}`); + } + return parsed; +} + +function run(command, args) { + const result = spawnSync(command, args, { + cwd: repoRoot, + encoding: 'utf8', + maxBuffer: 16 * 1024 * 1024, + }); + if (result.error) { + throw result.error; + } + if (result.status !== 0) { + throw new HexagonalBoundaryRatchetError(`Command failed: ${command} ${args.join(' ')}`); + } + return result.stdout; +} + +function trackedCodeFiles() { + return run('git', ['ls-files', '*.js', '*.mjs', '*.cjs']) + .split('\n') + .map(line => line.trim()) + .filter(Boolean) + .sort(compareStrings); +} + +function scannedFiles() { + return trackedCodeFiles() + .filter(file => sourcePrefixes.some(prefix => file.startsWith(prefix))) + .filter(file => !allowedBoundaryFiles.includes(file)); +} + +function collectFindings(files) { + return files.flatMap(file => collectFileFindings(file)).sort(compareFindings); +} + +function collectFileFindings(file) { + const content = readFileSync(path.join(repoRoot, file), 'utf8'); + return substrateTerms.flatMap(term => collectTermFindings(file, content, term)); +} + +function collectTermFindings(file, content, term) { + term.pattern.lastIndex = 0; + return [...content.matchAll(term.pattern)].map(match => { + const position = locateTextPosition(content, match.index ?? 0); + return Object.freeze({ + column: position.column, + file, + line: position.line, + term: term.id, + text: firstMatchedLine(match[0]), + }); + }); +} + +function locateTextPosition(content, offset) { + const prefix = content.slice(0, offset); + const lastNewlineIndex = prefix.lastIndexOf('\n'); + return Object.freeze({ + column: offset - lastNewlineIndex, + line: prefix.split('\n').length, + }); +} + +function firstMatchedLine(text) { + return text.split('\n')[0]?.trim() ?? ''; +} + +function createSnapshot() { + const files = scannedFiles(); + const findings = collectFindings(files); + return deepSort({ + version: 1, + generatedFrom: 'scripts/hexagonal-boundary-ratchet.mjs', + allowedBoundaryFiles, + substrateTermIds: substrateTerms.map(term => term.id), + leaks: summarizeFindings(findings), + }); +} + +function summarizeFindings(findings) { + return { + byFile: countBy(findings, finding => finding.file), + byTerm: countBy(findings, finding => finding.term), + total: findings.length, + }; +} + +function countBy(values, selectKey) { + const counts = {}; + for (const value of values) { + const key = selectKey(value); + counts[key] = (counts[key] ?? 0) + 1; + } + return counts; +} + +function loadBaseline() { + if (!existsSync(baselinePath)) { + throw new HexagonalBoundaryRatchetError(`Missing Hexagonal Boundary ratchet baseline: ${baselinePath}`); + } + return JSON.parse(readFileSync(baselinePath, 'utf8')); +} + +function compareToBaseline(current, baseline) { + const problems = []; + compareTotals(problems, 'substrate leak total', current.leaks.total, baseline.leaks.total); + compareCountMap(problems, 'substrate leak file', current.leaks.byFile, baseline.leaks.byFile); + compareCountMap(problems, 'substrate leak term', current.leaks.byTerm, baseline.leaks.byTerm); + return problems; +} + +function compareTotals(problems, label, current, baseline) { + if (current > baseline) { + problems.push(`${label}: ${current} > baseline ${baseline}`); + } +} + +function compareCountMap(problems, label, current = {}, baseline = {}) { + for (const [key, value] of Object.entries(current)) { + const allowed = baseline[key] ?? 0; + if (value > allowed) { + problems.push(`${label} ${key}: ${value} > baseline ${allowed}`); + } + } +} + +function writeBaseline(snapshot) { + writeFileSync(baselinePath, `${JSON.stringify(snapshot, null, 2)}\n`); +} + +function printHumanSummary(snapshot, problems) { + if (problems.length > 0) { + process.stderr.write('Hexagonal Boundary ratchet failed.\n'); + for (const problem of problems) { + process.stderr.write(`- ${problem}\n`); + } + process.stderr.write('\nRun `npm run hexagonal-boundary:ratchet -- --json` for the current counts.\n'); + return; + } + + process.stdout.write([ + 'Hexagonal Boundary ratchet passed.', + `Substrate leak tokens: ${snapshot.leaks.total}`, + `Leaks by term: ${JSON.stringify(snapshot.leaks.byTerm)}`, + ].join('\n')); + process.stdout.write('\n'); +} + +function deepSort(value) { + if (Array.isArray(value)) { + return value.map(deepSort); + } + if (value === null || typeof value !== 'object') { + return value; + } + return Object.fromEntries( + Object.entries(value) + .sort(([left], [right]) => compareStrings(left, right)) + .map(([key, entry]) => [key, deepSort(entry)]) + ); +} + +function compareStrings(left, right) { + return left.localeCompare(right); +} + +function compareFindings(left, right) { + return compareStrings(left.file, right.file) + || left.line - right.line + || left.column - right.column + || compareStrings(left.term, right.term); +} + +function main() { + const args = parseArgs(process.argv.slice(2)); + const snapshot = createSnapshot(); + if (args.writeBaseline) { + writeBaseline(snapshot); + } + const baseline = args.writeBaseline ? snapshot : loadBaseline(); + const problems = compareToBaseline(snapshot, baseline); + if (args.json) { + process.stdout.write(`${JSON.stringify({ ok: problems.length === 0, problems, snapshot }, null, 2)}\n`); + } else { + printHumanSummary(snapshot, problems); + } + if (problems.length > 0) { + process.exitCode = 1; + } +} + +main(); diff --git a/scripts/lint.mjs b/scripts/lint.mjs index e4779010..6339e4db 100644 --- a/scripts/lint.mjs +++ b/scripts/lint.mjs @@ -12,7 +12,12 @@ if (eslintStatus !== 0) { process.exit(eslintStatus); } -process.exit(runChecked(process.execPath, ['./scripts/runtime-truth-ratchet.mjs'])); +const runtimeTruthStatus = runChecked(process.execPath, ['./scripts/runtime-truth-ratchet.mjs']); +if (runtimeTruthStatus !== 0) { + process.exit(runtimeTruthStatus); +} + +process.exit(runChecked(process.execPath, ['./scripts/hexagonal-boundary-ratchet.mjs'])); function runChecked(command, args) { const result = spawnSync(command, args, { diff --git a/src/doctor.js b/src/doctor.js index aeec3b8b..f12c6501 100644 --- a/src/doctor.js +++ b/src/doctor.js @@ -145,10 +145,14 @@ function describeCheckpointStatus(status, repoDir) { return { name: 'checkpoint', status: 'warn', - message: 'No checkpoint cache found; reads may be slower until a checkpoint is regenerated', + message: 'No cached state found; reads may be slower until cached state is regenerated', }; } + if (status.kind === 'state_cache') { + return { name: 'checkpoint', status: 'ok', message: 'Cached state is available (state-cache)' }; + } + if (status.supported) { return { name: 'checkpoint', status: 'ok', message: `Checkpoint cache is supported (schema ${status.schema})` }; } diff --git a/src/store/checkpoint-read.js b/src/store/checkpoint-read.js index ae6caefc..e59174d7 100644 --- a/src/store/checkpoint-read.js +++ b/src/store/checkpoint-read.js @@ -29,7 +29,8 @@ class CheckpointReadModel { } graphModelStatus() { - if (!this._hasCaptures()) { + const props = this._reader.getNodeProps(GRAPH_META_ID); + if (!props && !this._hasCaptures()) { return { currentGraphModelVersion: 1, requiredGraphModelVersion: GRAPH_MODEL_VERSION, @@ -37,7 +38,6 @@ class CheckpointReadModel { }; } - const props = this._reader.getNodeProps(GRAPH_META_ID); const currentGraphModelVersion = Number(props?.graphModelVersion ?? 1); return { currentGraphModelVersion, diff --git a/src/store/checkpoint-state.js b/src/store/checkpoint-state.js index d5986fe8..d2800c33 100644 --- a/src/store/checkpoint-state.js +++ b/src/store/checkpoint-state.js @@ -5,15 +5,17 @@ import { createAppContentReader } from './content-reader.js'; import { CHECKPOINT_POLICY, GRAPH_NAME } from './constants.js'; import { createWriterId } from './model.js'; -export const CHECKPOINT_REF = `refs/warp/${GRAPH_NAME}/checkpoints/head`; +export const LEGACY_CHECKPOINT_REF = `refs/warp/${GRAPH_NAME}/checkpoints/head`; +export const CHECKPOINT_REF = LEGACY_CHECKPOINT_REF; +export const STATE_CACHE_REF = `refs/warp/${GRAPH_NAME}/state-cache`; export const SUPPORTED_CHECKPOINT_SCHEMA = 5; export async function openCheckpointStateRead(repoDir, app = null) { const persistence = new GitWarp.GitGraphAdapter({ plumbing: createThinkPlumbing(repoDir), }); - const checkpointSha = await persistence.readRef(CHECKPOINT_REF); - if (checkpointSha === null) { + const cachedState = await readCachedStateSource(persistence); + if (cachedState === null) { return null; } @@ -25,7 +27,8 @@ export async function openCheckpointStateRead(repoDir, app = null) { return Object.freeze({ blobStorage: await createRuntimeBlobStorage(persistence), - checkpointSha, + checkpointSha: cachedState.oid, + cachedState, readContent: createAppContentReader(resolvedApp), reader: createCheckpointStateReader(state), }); @@ -35,11 +38,18 @@ export async function getCheckpointRefStatus(repoDir) { const persistence = new GitWarp.GitGraphAdapter({ plumbing: createThinkPlumbing(repoDir), }); - const checkpointSha = await persistence.readRef(CHECKPOINT_REF); - if (checkpointSha === null) { + const legacyStatus = await getLegacyCheckpointRefStatus(persistence); + if (legacyStatus.exists && !legacyStatus.supported) { + return legacyStatus; + } + + const stateCacheOid = await persistence.readRef(STATE_CACHE_REF); + if (stateCacheOid !== null) { return Object.freeze({ - exists: false, - ref: CHECKPOINT_REF, + cacheOid: stateCacheOid, + exists: true, + kind: 'state_cache', + ref: STATE_CACHE_REF, checkpointSha: null, schema: null, supportedSchema: SUPPORTED_CHECKPOINT_SCHEMA, @@ -47,23 +57,14 @@ export async function getCheckpointRefStatus(repoDir) { }); } - const message = await persistence.showNode(checkpointSha); - const schema = parseCheckpointSchema(message); - return Object.freeze({ - exists: true, - ref: CHECKPOINT_REF, - checkpointSha, - schema, - supportedSchema: SUPPORTED_CHECKPOINT_SCHEMA, - supported: schema === SUPPORTED_CHECKPOINT_SCHEMA, - }); + return legacyStatus; } export async function deleteCheckpointRef(repoDir) { const persistence = new GitWarp.GitGraphAdapter({ plumbing: createThinkPlumbing(repoDir), }); - await persistence.deleteRef(CHECKPOINT_REF); + await persistence.deleteRef(LEGACY_CHECKPOINT_REF); } export function isUnsupportedCheckpointSchemaError(error) { @@ -73,6 +74,55 @@ export function isUnsupportedCheckpointSchemaError(error) { || /Checkpoint .* is schema:\d+\. Only schema:\d+ checkpoints are supported/.test(message); } +async function readCachedStateSource(persistence) { + const stateCacheOid = await persistence.readRef(STATE_CACHE_REF); + if (stateCacheOid !== null) { + return Object.freeze({ + kind: 'state_cache', + oid: stateCacheOid, + ref: STATE_CACHE_REF, + }); + } + + const checkpointSha = await persistence.readRef(LEGACY_CHECKPOINT_REF); + if (checkpointSha !== null) { + return Object.freeze({ + kind: 'legacy_checkpoint', + oid: checkpointSha, + ref: LEGACY_CHECKPOINT_REF, + }); + } + + return null; +} + +async function getLegacyCheckpointRefStatus(persistence) { + const checkpointSha = await persistence.readRef(LEGACY_CHECKPOINT_REF); + if (checkpointSha === null) { + return Object.freeze({ + exists: false, + kind: 'legacy_checkpoint', + ref: LEGACY_CHECKPOINT_REF, + checkpointSha: null, + schema: null, + supportedSchema: SUPPORTED_CHECKPOINT_SCHEMA, + supported: true, + }); + } + + const message = await persistence.showNode(checkpointSha); + const schema = parseCheckpointSchema(message); + return Object.freeze({ + exists: true, + kind: 'legacy_checkpoint', + ref: LEGACY_CHECKPOINT_REF, + checkpointSha, + schema, + supportedSchema: SUPPORTED_CHECKPOINT_SCHEMA, + supported: schema === SUPPORTED_CHECKPOINT_SCHEMA, + }); +} + function parseCheckpointSchema(message) { const match = String(message).match(/^eg-schema:\s*(\d+)\s*$/m); return match ? Number.parseInt(match[1], 10) : null; diff --git a/test/ports/checkpoint-read.test.js b/test/ports/checkpoint-read.test.js index 159498d9..3260aeb8 100644 --- a/test/ports/checkpoint-read.test.js +++ b/test/ports/checkpoint-read.test.js @@ -12,6 +12,7 @@ import { openProductReadHandle, openWarpApp, } from '../../src/store/runtime.js'; +import { STATE_CACHE_REF } from '../../src/store/checkpoint-state.js'; import { runGit } from '../fixtures/git.js'; import { createTempDir } from '../fixtures/tmp.js'; import { formatResult } from '../fixtures/runtime.js'; @@ -37,14 +38,14 @@ test('checkpoint reads include CAS-backed raw tail captures', async () => { restoreTestNow(previousTestNow); } - const checkpointRef = runGit( - ['rev-parse', '--verify', '--quiet', 'refs/warp/think/checkpoints/head'], + const cachedStateRef = runGit( + ['rev-parse', '--verify', '--quiet', STATE_CACHE_REF], { cwd: repoDir }, ); assert.equal( - checkpointRef.status, + cachedStateRef.status, 0, - `Expected fixture writes to create an indexed checkpoint.\n${formatResult(checkpointRef)}` + `Expected fixture writes to create cached state.\n${formatResult(cachedStateRef)}` ); const checkpointCaptures = await listCheckpointEntriesByKind(repoDir, 'capture'); From 9da574cd545e0ad6b9559941f3674c0be70071e8 Mon Sep 17 00:00:00 2001 From: James Ross Date: Wed, 8 Jul 2026 01:37:34 -0700 Subject: [PATCH 2/9] refactor: stop managing git-warp cache --- CHANGELOG.md | 2 + docs/GLOSSARY.md | 12 +- docs/INFRASTRUCTURE_DOCTRINE.md | 8 +- .../hexagonal-boundary-ratchet-baseline.json | 25 +- .../audit/runtime-truth-ratchet-baseline.json | 50 +-- docs/design/README.md | 2 +- .../bad-code/CORE_boundary-codec-cutover.md | 7 +- .../CORE_git-warp-dependency-truth.md | 21 +- .../bad-code/CORE_hexagonal-store-boundary.md | 2 + .../bad-code/CORE_large-mind-read-timeouts.md | 6 +- ...URFACE_document-window-based-read-model.md | 8 +- scripts/hexagonal-boundary-ratchet.mjs | 8 +- scripts/runtime-truth-ratchet.mjs | 6 +- src/browse/adapters/git-warp.js | 76 +---- src/cli/commands/doctor.js | 5 - src/cli/graph-gate.js | 2 +- src/doctor.js | 51 +-- src/mcp/service.js | 4 - src/store/annotate.js | 14 +- src/store/capture.js | 14 +- src/store/checkpoint-product-read.js | 317 ------------------ src/store/checkpoint-read.js | 132 -------- src/store/checkpoint-state.js | 154 --------- src/store/constants.js | 1 - src/store/content-reader.js | 14 - src/store/derivation.js | 6 +- src/store/enrichment/runner.js | 13 +- src/store/migrations.js | 4 +- src/store/queries.js | 6 - src/store/reflect.js | 25 +- src/store/runtime.js | 232 +------------ test/acceptance/mcp.test.js | 2 +- test/ports/browse-appshell.test.js | 14 +- test/ports/capture-context.test.js | 26 +- test/ports/checkpoint-read.test.js | 87 ----- test/ports/content-reader.test.js | 30 -- test/ports/doctor.test.js | 62 ---- test/ports/private-imports.test.js | 17 +- 38 files changed, 147 insertions(+), 1318 deletions(-) delete mode 100644 src/store/checkpoint-product-read.js delete mode 100644 src/store/checkpoint-read.js delete mode 100644 src/store/checkpoint-state.js delete mode 100644 src/store/content-reader.js delete mode 100644 test/ports/checkpoint-read.test.js delete mode 100644 test/ports/content-reader.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index abfada3b..0abab04f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,8 @@ Release discipline: - added a Hexagonal Boundary lint ratchet so product code cannot increase direct substrate-layout or `git-warp` runtime leakage +- removed Think-managed `git-warp` cache/checkpoint reads, doctor checks, and + ref deletion from product runtime paths - upgraded `@git-stunts/git-warp` to 18.2.1 ## [0.7.2] - 2026-06-23 diff --git a/docs/GLOSSARY.md b/docs/GLOSSARY.md index 7a8680f0..d69ca721 100644 --- a/docs/GLOSSARY.md +++ b/docs/GLOSSARY.md @@ -118,25 +118,27 @@ The normal product read handle in `git-warp`. A narrower read aperture derived from a `worldline`. -In `think`, product reads use `WarpApp -> worldline() -> observer(...)` where that helps keep read scope honest. +In `think`, product reads open a public `git-warp` worldline and read through +that worldline. Think does not open `WarpApp` as a product read facade. ### core The whole-state inspection and admin-style `git-warp` surface. -In `think`, `core()` is reserved for: +In `think`, `core()` is reserved for explicit operator tooling such as: - migration - admin-style full-state inspection -- narrow content-attachment reads not available on `worldline` / `observer` It should not be the default product read path. ### checkpoint -`git-warp` recovery state that makes reopening read handles much faster. +`git-warp` recovery state that can accelerate reopened read handles. -Enabling checkpoints was one of the major browse-performance fixes in M4. +Think product code does not inspect, delete, or repair checkpoint/state-cache +refs. Routine reads let `git-warp` own cache decisions; the only Think code that +names legacy checkpoint refs is the explicit v17 repair lane for old minds. ### history model version diff --git a/docs/INFRASTRUCTURE_DOCTRINE.md b/docs/INFRASTRUCTURE_DOCTRINE.md index a5461c99..49aa4d94 100644 --- a/docs/INFRASTRUCTURE_DOCTRINE.md +++ b/docs/INFRASTRUCTURE_DOCTRINE.md @@ -63,9 +63,11 @@ Examples of capabilities that require ports: Product code must not know substrate layout. References to concrete storage engines, `git-warp` runtime classes, WARP ref paths, checkpoint/state-cache mechanics, materialization strategies, or repair -commands belong only in adapters, diagnostics, repair scripts, or -benchmarks. CLI, MCP, Browse, Remember, Stats, and domain modules consume -product ports and runtime-backed domain facts, not storage internals. +commands belong only in explicit adapter, repair, or benchmark boundaries. +Think diagnostics must not inspect or mutate `git-warp` cache refs; operators +should use `git-warp` diagnostics for that substrate. CLI, MCP, Browse, +Remember, Stats, and domain modules consume product ports and runtime-backed +domain facts, not storage internals. This boundary is enforced by `npm run hexagonal-boundary:ratchet`, which runs from `npm run lint`. Existing leaks are debt in the ratchet baseline; diff --git a/docs/audit/hexagonal-boundary-ratchet-baseline.json b/docs/audit/hexagonal-boundary-ratchet-baseline.json index 561f6d88..434d75d6 100644 --- a/docs/audit/hexagonal-boundary-ratchet-baseline.json +++ b/docs/audit/hexagonal-boundary-ratchet-baseline.json @@ -1,37 +1,32 @@ { "allowedBoundaryFiles": [ + "scripts/hexagonal-boundary-ratchet.mjs", + "scripts/repair-v17-mind.mjs", "src/browse-benchmark.js", "src/browse/adapters/git-warp-worker.js", "src/browse/adapters/git-warp.js", "src/cli/commands/doctor.js", "src/doctor.js", - "src/history/git-warp-read.js", - "src/store/checkpoint-state.js" + "src/history/git-warp-read.js" ], "generatedFrom": "scripts/hexagonal-boundary-ratchet.mjs", "leaks": { "byFile": { "src/git.js": 1, - "src/mcp/service.js": 2, "src/store/annotate.js": 4, - "src/store/capture.js": 11, - "src/store/checkpoint-product-read.js": 3, - "src/store/checkpoint-read.js": 4, - "src/store/content-reader.js": 1, + "src/store/capture.js": 6, "src/store/derivation.js": 3, - "src/store/enrichment/runner.js": 5, + "src/store/enrichment/runner.js": 2, "src/store/migrations.js": 6, - "src/store/queries.js": 2, - "src/store/reflect.js": 7, - "src/store/runtime.js": 28 + "src/store/reflect.js": 6, + "src/store/runtime.js": 11 }, "byTerm": { - "checkpoint-mechanic": 20, - "git-warp-package": 2, - "git-warp-runtime-symbol": 54, + "git-warp-package": 1, + "git-warp-runtime-symbol": 37, "warp-ref-layout": 1 }, - "total": 77 + "total": 39 }, "substrateTermIds": [ "git-warp-package", diff --git a/docs/audit/runtime-truth-ratchet-baseline.json b/docs/audit/runtime-truth-ratchet-baseline.json index 03a14de9..ae8def43 100644 --- a/docs/audit/runtime-truth-ratchet-baseline.json +++ b/docs/audit/runtime-truth-ratchet-baseline.json @@ -8,8 +8,8 @@ "strictLimits": { "byCategory": { "benchmark": 5, - "source": 150, - "test": 45 + "source": 142, + "test": 34 }, "byFile": { "benchmarks/browse-bootstrap.js": { @@ -158,12 +158,10 @@ }, "src/cli/commands/capture.js": { "byRule": { - "complexity": 1, - "max-lines-per-function": 1, - "max-statements": 1 + "max-lines-per-function": 1 }, "category": "source", - "total": 3 + "total": 1 }, "src/cli/commands/read.js": { "byRule": { @@ -215,10 +213,10 @@ }, "src/mcp/service.js": { "byRule": { - "max-lines-per-function": 2 + "max-lines-per-function": 1 }, "category": "source", - "total": 2 + "total": 1 }, "src/splash-shader.js": { "byRule": { @@ -231,13 +229,6 @@ "category": "source", "total": 17 }, - "src/store/checkpoint-product-read.js": { - "byRule": { - "complexity": 1 - }, - "category": "source", - "total": 1 - }, "src/store/derivation.js": { "byRule": { "complexity": 2, @@ -314,19 +305,10 @@ }, "src/store/runtime.js": { "byRule": { - "complexity": 7, - "max-lines-per-function": 1 + "complexity": 4 }, "category": "source", - "total": 8 - }, - "test/acceptance/graph-migration.test.js": { - "byRule": { - "complexity": 2, - "max-lines-per-function": 7 - }, - "category": "test", - "total": 9 + "total": 4 }, "test/acceptance/json-output.test.js": { "byRule": { @@ -382,14 +364,6 @@ "category": "test", "total": 2 }, - "test/ports/checkpoint-read.test.js": { - "byRule": { - "max-lines-per-function": 1, - "max-statements": 1 - }, - "category": "test", - "total": 2 - }, "test/ports/docs-consistency.test.js": { "byRule": { "max-lines-per-function": 1 @@ -407,14 +381,14 @@ } }, "byRule": { - "complexity": 61, + "complexity": 54, "max-depth": 11, "max-lines": 1, - "max-lines-per-function": 96, + "max-lines-per-function": 86, "max-params": 3, - "max-statements": 28 + "max-statements": 26 }, - "total": 200 + "total": 181 }, "strictRuleIds": [ "complexity", diff --git a/docs/design/README.md b/docs/design/README.md index c0d7a276..696359aa 100644 --- a/docs/design/README.md +++ b/docs/design/README.md @@ -68,7 +68,7 @@ Completed milestone and slice notes that still matter as historical context, but - [`../retrospectives/m4-session-traversal.md`](../retrospectives/m4-session-traversal.md): closeout for the M4 slice that makes same-session movement a first-class browse behavior for both humans and agents. - [`../retrospectives/m4-graph-migration-gating.md`](../retrospectives/m4-graph-migration-gating.md): closeout for the slice that makes migration explicit for graph-native commands while keeping raw capture migration-safe. - [`../retrospectives/m4-v3-read-edge-substrate.md`](../retrospectives/m4-v3-read-edge-substrate.md): closeout for the narrower sub-slice that lands `v3` graph-native read edges without yet claiming the browse bootstrap performance win. -- [`../retrospectives/m4-graph-native-browse-read-refactor.md`](../retrospectives/m4-graph-native-browse-read-refactor.md): closeout for the slice that moves product reads onto `WarpApp -> worldline() -> observer(...)`, enables checkpoint-backed browse startup, and records the official `AFTER` benchmark. +- [`../retrospectives/m4-graph-native-browse-read-refactor.md`](../retrospectives/m4-graph-native-browse-read-refactor.md): historical closeout for the slice that first moved browse reads behind a runtime adapter and recorded the official `AFTER` benchmark. - [`../retrospectives/m4-graph-migration-progress-ux.md`](../retrospectives/m4-graph-migration-progress-ux.md): closeout for the slice that makes the human interactive upgrade moment visibly in progress while keeping agent and capture behavior unchanged. - [`../retrospectives/m4-remember-enhancements.md`](../retrospectives/m4-remember-enhancements.md): closeout for the slice that adds bounded and brief remember recall without changing the underlying ranking model. - [`../retrospectives/m4-prompt-telemetry-read-surface.md`](../retrospectives/m4-prompt-telemetry-read-surface.md): closeout for the slice that turns recorded prompt telemetry into a factual CLI / JSON read surface without drifting into dashboards. diff --git a/docs/method/backlog/bad-code/CORE_boundary-codec-cutover.md b/docs/method/backlog/bad-code/CORE_boundary-codec-cutover.md index 8db7f00b..b2737032 100644 --- a/docs/method/backlog/bad-code/CORE_boundary-codec-cutover.md +++ b/docs/method/backlog/bad-code/CORE_boundary-codec-cutover.md @@ -13,16 +13,15 @@ payload encoding directly in domain-adjacent modules. Examples include `Buffer.from()` in `src/store/content.js`, `parseJsonArray()` in `src/store/model.js`, JSON-string graph -properties in derivation flows, and content byte decoding in checkpoint +properties in derivation flows, and content byte decoding in runtime read models. ## Acceptance Criteria - Introduce named codec ports for text content, graph property payloads, - and checkpoint content reads. + and runtime content reads. - Keep `Buffer`, `TextEncoder`, `TextDecoder`, JSON parsing, and future CBOR work in adapters or codec modules only. - Core store workflows receive validated domain objects, not raw decoded transport shapes. -- Existing content and checkpoint-read tests pass through the new codec - ports. +- Existing content and runtime-read tests pass through the new codec ports. diff --git a/docs/method/backlog/bad-code/CORE_git-warp-dependency-truth.md b/docs/method/backlog/bad-code/CORE_git-warp-dependency-truth.md index 47ef2baf..73051c82 100644 --- a/docs/method/backlog/bad-code/CORE_git-warp-dependency-truth.md +++ b/docs/method/backlog/bad-code/CORE_git-warp-dependency-truth.md @@ -5,16 +5,13 @@ blocked_by: - CORE_repair-v17-git-warp-minds --- -# git-warp dependency truth is split between package metadata and local v17 links +# git-warp dependency truth must stay anchored to package metadata -Think currently declares `@git-stunts/git-warp` as `15.0.0`, while local -development can resolve to a linked `17.0.0` checkout. That makes -`npm ls @git-stunts/git-warp` fail with `ELSPROBLEMS` and leaves runtime -compatibility depending on local workspace state rather than package truth. - -The checkpoint read path now includes a public-reader compatibility bridge for -`createStateReader` vs `createStateReaderV5`. That bridge is acceptable as a -short-term guard, but it should not become permanent dependency sludge. +Think runtime compatibility must be proven against the published +`@git-stunts/git-warp` package declared in `package.json`, not against a linked +local checkout or private package internals. The current product runtime targets +the public worldline API in `@git-stunts/git-warp@18.2.1`; legacy checkpoint +repair remains quarantined in the v17 repair lane. ## Acceptance Criteria @@ -22,9 +19,9 @@ short-term guard, but it should not become permanent dependency sludge. - `package.json` and `package-lock.json` match the intended git-warp version. - The intended version is published or resolved through an explicit, documented local/workspace dependency path. -- Checkpoint read tests pass from a clean install, not only from a local +- Product read/write tests pass from a clean install, not only from a local linked git-warp checkout. - The archived v17 repair acceptance fixture runs its full repair assertion in clean CI instead of skipping when the v17 migration package is unavailable. -- The state-reader compatibility bridge is either documented as intentional - version support or removed after the dependency cutover. +- No production module imports private `node_modules/@git-stunts/git-warp/src` + paths or git-warp cache/checkpoint compatibility helpers. diff --git a/docs/method/backlog/bad-code/CORE_hexagonal-store-boundary.md b/docs/method/backlog/bad-code/CORE_hexagonal-store-boundary.md index b2becb13..7c7bc12c 100644 --- a/docs/method/backlog/bad-code/CORE_hexagonal-store-boundary.md +++ b/docs/method/backlog/bad-code/CORE_hexagonal-store-boundary.md @@ -35,4 +35,6 @@ on injected ports and runtime domain objects, not on concrete host APIs. ## Progress - [x] Hexagonal Boundary ratchet added to `npm run lint`. +- [x] Product runtime paths stopped inspecting, deleting, or reading through + `git-warp` checkpoint/state-cache refs. - [ ] Existing substrate leaks moved behind product ports or adapters. diff --git a/docs/method/backlog/bad-code/CORE_large-mind-read-timeouts.md b/docs/method/backlog/bad-code/CORE_large-mind-read-timeouts.md index e52a0026..fc26a0da 100644 --- a/docs/method/backlog/bad-code/CORE_large-mind-read-timeouts.md +++ b/docs/method/backlog/bad-code/CORE_large-mind-read-timeouts.md @@ -18,7 +18,7 @@ unavailable even when the underlying mind is intact. - [ ] Add a deterministic large-mind fixture or synthetic benchmark for MCP read timeout budgets. - [ ] Establish target budgets for `recent`, `stats`, `doctor`, and `remember` - against repaired checkpoint-backed minds. + against large repaired minds. - [ ] Document and automate safe maintenance for high-loose-object minds. -- [ ] Prefer checkpoint-backed bounded reads where whole-graph observer startup is - not required. +- [ ] Prefer public worldline/optic bounded reads where broad transitional + queries are not required. diff --git a/docs/method/backlog/cool-ideas/SURFACE_document-window-based-read-model.md b/docs/method/backlog/cool-ideas/SURFACE_document-window-based-read-model.md index 64ca6c8c..9ab81905 100644 --- a/docs/method/backlog/cool-ideas/SURFACE_document-window-based-read-model.md +++ b/docs/method/backlog/cool-ideas/SURFACE_document-window-based-read-model.md @@ -1,8 +1,8 @@ # Document window-based read model -Add a "Window-Based Navigation" section to ADVANCED_GUIDE.md -explaining how git-warp read handles prevent whole-graph -materialization, how the browse TUI loads neighbors lazily, and -how checkpoint-backed reuse keeps navigation fast. +Add a "Window-Based Navigation" section to GUIDE.md or the design docs +explaining how public git-warp worldline/optic reads prevent unnecessary +whole-graph materialization, how the browse TUI loads neighbors lazily, and +where broad transitional queries still need caveats. Source: documentation-quality audit 2026-04-11 ยง2.3. diff --git a/scripts/hexagonal-boundary-ratchet.mjs b/scripts/hexagonal-boundary-ratchet.mjs index 1f27d73e..d287b8a2 100644 --- a/scripts/hexagonal-boundary-ratchet.mjs +++ b/scripts/hexagonal-boundary-ratchet.mjs @@ -7,15 +7,16 @@ import { fileURLToPath } from 'node:url'; const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const baselinePath = path.join(repoRoot, 'docs', 'audit', 'hexagonal-boundary-ratchet-baseline.json'); -const sourcePrefixes = Object.freeze(['src/', 'bin/']); +const sourcePrefixes = Object.freeze(['src/', 'bin/', 'scripts/']); const allowedBoundaryFiles = Object.freeze([ + 'scripts/hexagonal-boundary-ratchet.mjs', + 'scripts/repair-v17-mind.mjs', 'src/browse-benchmark.js', 'src/browse/adapters/git-warp-worker.js', 'src/browse/adapters/git-warp.js', 'src/cli/commands/doctor.js', 'src/doctor.js', 'src/history/git-warp-read.js', - 'src/store/checkpoint-state.js', ]); const substrateTerms = Object.freeze([ { @@ -24,7 +25,7 @@ const substrateTerms = Object.freeze([ }, { id: 'git-warp-runtime-symbol', - pattern: /\b(?:GitGraphAdapter|WarpApp|WarpCore|openWarpGraph|openWarpWorldline|openWarpApp|patchWarpApp|patchWarpAppWithWriter|openThinkWorldline|commitThinkWorldline|openWarpGraphHandle)\b/gu, + pattern: /\b(?:GitGraphAdapter|WarpApp|WarpCore|openWarpGraph|openWarpWorldline|openThinkWorldline|commitThinkWorldline|commitThinkWorldlineWithWriter)\b/gu, }, { id: 'checkpoint-mechanic', @@ -86,6 +87,7 @@ function trackedCodeFiles() { function scannedFiles() { return trackedCodeFiles() .filter(file => sourcePrefixes.some(prefix => file.startsWith(prefix))) + .filter(file => existsSync(path.join(repoRoot, file))) .filter(file => !allowedBoundaryFiles.includes(file)); } diff --git a/scripts/runtime-truth-ratchet.mjs b/scripts/runtime-truth-ratchet.mjs index f575c6e3..2a007be2 100644 --- a/scripts/runtime-truth-ratchet.mjs +++ b/scripts/runtime-truth-ratchet.mjs @@ -101,6 +101,10 @@ function sourceCodeFiles(files) { return files.filter(file => sourcePrefixes.some(prefix => file.startsWith(prefix))); } +function existingCodeFiles(files) { + return files.filter(file => existsSync(path.join(repoRoot, file))); +} + function eslintBinPath() { const executable = process.platform === 'win32' ? 'eslint.cmd' : 'eslint'; return path.join(repoRoot, 'node_modules', '.bin', executable); @@ -218,7 +222,7 @@ function countBy(values, selectKey) { } function createSnapshot() { - const files = trackedCodeFiles(); + const files = existingCodeFiles(trackedCodeFiles()); const strictFindings = collectStrictLimitFindings(files); const genericThrowFindings = collectGenericThrowFindings(sourceCodeFiles(files)); diff --git a/src/browse/adapters/git-warp.js b/src/browse/adapters/git-warp.js index 88e3ea97..dc0a8f22 100644 --- a/src/browse/adapters/git-warp.js +++ b/src/browse/adapters/git-warp.js @@ -1,11 +1,6 @@ import { Worker } from 'node:worker_threads'; import { hasGitRepo } from '../../git.js'; -import { - getCheckpointGraphModelStatus, - listCheckpointEntriesByKind, -} from '../../store/checkpoint-read.js'; -import { compareEntriesNewestFirst } from '../../store/model.js'; import { getHistoryModelStatusForRead, openHistoryReadHandle, @@ -91,26 +86,7 @@ async function* loadGitWarpHistoryWindowUpdates({ repoDir }) { return; } - const checkpointWindow = await loadCheckpointHistoryWindow(repoDir); - if (checkpointWindow) { - if (!checkpointWindow.ok) { - yield finalHistoryUpdate(checkpointWindow); - return; - } - yield { - final: false, - historyWindow: checkpointWindow, - }; - } - - try { - yield finalHistoryUpdate(await loadLiveHistoryWindow(repoDir)); - } catch (error) { - if (!checkpointWindow?.ok) { - throw error; - } - yield finalHistoryUpdate(createCheckpointFallbackWindow(checkpointWindow, error)); - } + yield finalHistoryUpdate(await loadLiveHistoryWindow(repoDir)); } async function loadLiveHistoryWindow(repoDir) { @@ -239,53 +215,3 @@ function createHistoryWindowFromBrowseBootstrap(bootstrap) { return createHistoryReadyWindow(bootstrap); } - -function createCheckpointFallbackWindow(checkpointWindow, error) { - return createHistoryReadyWindow({ - ...checkpointWindow, - stale: true, - reason: 'live_load_failed', - message: `Showing checkpoint while live History failed: ${formatErrorMessage(error)}`, - }); -} - -function formatErrorMessage(error) { - return error instanceof Error && error.message - ? error.message - : String(error || 'unknown error'); -} - -async function loadCheckpointHistoryWindow(repoDir) { - const graphStatus = await getCheckpointGraphModelStatus(repoDir); - if (graphStatus === null) { - return null; - } - - if (graphStatus.migrationRequired) { - return createHistoryUnavailable({ - reason: 'migration_required', - graphStatus, - }); - } - - const entries = await listCheckpointEntriesByKind(repoDir, 'capture'); - if (entries === null) { - return null; - } - - return createCheckpointHistoryWindow(entries); -} - -function createCheckpointHistoryWindow(entries) { - const sortedEntries = entries.filter(Boolean).sort(compareEntriesNewestFirst); - if (sortedEntries.length === 0) { - return createHistoryUnavailable({ - reason: 'no_entries', - }); - } - - return createHistoryReadyWindow({ - current: sortedEntries[0], - older: sortedEntries[1] ?? null, - }); -} diff --git a/src/cli/commands/doctor.js b/src/cli/commands/doctor.js index 67380e27..194ac2c7 100644 --- a/src/cli/commands/doctor.js +++ b/src/cli/commands/doctor.js @@ -1,7 +1,6 @@ import { runDiagnostics } from '../../doctor.js'; import { getFsmonitorStatus, hasGitRepo, lsRemote, setFsmonitorDisabled } from '../../git.js'; import { getLocalRepoDir, getThinkDir } from '../../paths.js'; -import { deleteCheckpointRef, getCheckpointRefStatus } from '../../store/checkpoint-state.js'; const DOCTOR_SYMBOLS = { ok: 'โœ“', warn: '!', fail: 'โœ—', skip: 'โ—‹' }; @@ -46,8 +45,6 @@ function createRepoDiagnostics(context) { getEntryCount: null, getFsmonitorStatus: null, fixFsmonitor: null, - getCheckpointStatus: null, - fixCheckpoint: null, }; } @@ -57,8 +54,6 @@ function createPresentRepoDiagnostics(context) { getEntryCount: null, getFsmonitorStatus: () => getFsmonitorStatus(context.repoDir), fixFsmonitor: () => setFsmonitorDisabled(context.repoDir), - getCheckpointStatus: () => getCheckpointRefStatus(context.repoDir), - fixCheckpoint: () => deleteCheckpointRef(context.repoDir), }; } diff --git a/src/cli/graph-gate.js b/src/cli/graph-gate.js index 1aa45c51..e9a59eae 100644 --- a/src/cli/graph-gate.js +++ b/src/cli/graph-gate.js @@ -29,7 +29,7 @@ export async function ensureGraphModelReadyFromStatus(repoDir, command, status, const result = await migrateGraphModel(repoDir); writeShellBlock(renderGraphMigrationProgress({ command, - phase: 'Writing checkpoint / finishing', + phase: 'Finishing History migration', progress: 1, }), output); reporter.event('graph.migration.done', { diff --git a/src/doctor.js b/src/doctor.js index f12c6501..90003631 100644 --- a/src/doctor.js +++ b/src/doctor.js @@ -30,7 +30,6 @@ function createDiagnosticContext(options) { getGraphModelStatus: optionalCallback(options.getGraphModelStatus), getEntryCount: optionalCallback(options.getEntryCount), getFsmonitorStatus: optionalCallback(options.getFsmonitorStatus), - getCheckpointStatus: optionalCallback(options.getCheckpointStatus), checkUpstreamReachable: optionalCallback(options.checkUpstreamReachable), }; } @@ -69,13 +68,11 @@ async function collectChecks({ getGraphModelStatus, getEntryCount, getFsmonitorStatus, - getCheckpointStatus, checkUpstreamReachable, }) { const checks = []; checks.push(checkThinkDir(thinkDir)); checks.push(checkLocalRepo(repoDir)); - checks.push(await checkCheckpoint(repoOk, repoDir, getCheckpointStatus)); checks.push(await checkGitFsmonitor(repoOk, repoDir, getFsmonitorStatus)); checks.push(await checkGraphModel(repoOk, getGraphModelStatus)); checks.push(await checkEntryCount(repoOk, getEntryCount)); @@ -84,15 +81,8 @@ async function collectChecks({ return checks; } -async function runFixes({ checks, repoDir, fixFsmonitor, fixCheckpoint }) { +async function runFixes({ checks, repoDir, fixFsmonitor }) { return [ - ...await runSingleFix(checks, { - name: 'checkpoint', - fixer: fixCheckpoint, - skipMessage: 'Checkpoint cache fix skipped (no fixer available)', - okMessage: `Deleted unsupported checkpoint cache for local repo (${repoDir})`, - failMessage: `Failed to delete unsupported checkpoint cache for local repo (${repoDir})`, - }), ...await runSingleFix(checks, { name: 'git_fsmonitor', fixer: fixFsmonitor, @@ -125,45 +115,6 @@ async function attemptFix(name, fixer, okMessage, failMessage) { } } -async function checkCheckpoint(repoOk, repoDir, getCheckpointStatus) { - if (!repoOk) { - return { name: 'checkpoint', status: 'skip', message: 'Checkpoint cache check skipped (no repo)' }; - } - if (!getCheckpointStatus) { - return { name: 'checkpoint', status: 'skip', message: 'Checkpoint cache check skipped (no checker)' }; - } - - try { - return describeCheckpointStatus(await getCheckpointStatus(), repoDir); - } catch { - return { name: 'checkpoint', status: 'warn', message: 'Checkpoint cache check failed' }; - } -} - -function describeCheckpointStatus(status, repoDir) { - if (status.exists === false) { - return { - name: 'checkpoint', - status: 'warn', - message: 'No cached state found; reads may be slower until cached state is regenerated', - }; - } - - if (status.kind === 'state_cache') { - return { name: 'checkpoint', status: 'ok', message: 'Cached state is available (state-cache)' }; - } - - if (status.supported) { - return { name: 'checkpoint', status: 'ok', message: `Checkpoint cache is supported (schema ${status.schema})` }; - } - - return { - name: 'checkpoint', - status: 'fail', - message: `Checkpoint cache schema ${status.schema ?? 'unknown'} is unsupported by this runtime (expected schema ${status.supportedSchema}). Run: git -C ${repoDir} update-ref -d ${status.ref}`, - }; -} - function checkThinkDir(thinkDir) { if (!thinkDir || !existsSync(thinkDir)) { return { name: 'think_dir', status: 'fail', message: `Think directory not found (${thinkDir ?? '?'})` }; diff --git a/src/mcp/service.js b/src/mcp/service.js index 50d1b044..5fecc63a 100644 --- a/src/mcp/service.js +++ b/src/mcp/service.js @@ -27,7 +27,6 @@ import { buildAmbientRememberScope, buildExplicitRememberScope, } from '../store/remember.js'; -import { getCheckpointRefStatus } from '../store/checkpoint-state.js'; import { BrowseOutcome, CaptureOutcome, @@ -318,9 +317,6 @@ export async function checkThinkHealthForMcp() { getFsmonitorStatus: repoPresent ? () => getFsmonitorStatus(repoDir) : null, - getCheckpointStatus: repoPresent - ? () => getCheckpointRefStatus(repoDir) - : null, checkUpstreamReachable: upstreamUrl ? () => lsRemote(upstreamUrl) : null, }); diff --git a/src/store/annotate.js b/src/store/annotate.js index 44c54902..856064db 100644 --- a/src/store/annotate.js +++ b/src/store/annotate.js @@ -5,10 +5,10 @@ import { ANNOTATION_PREFIX, TEXT_MIME } from './constants.js'; import { encodeTextContent } from './content.js'; import { getCurrentTime } from './model.js'; import { - createProductReadHandle, + commitThinkWorldline, getStoredEntry, - openWarpApp, - patchWarpApp, + openProductReadHandle, + openThinkWorldline, } from './runtime.js'; export async function saveAnnotation(repoDir, targetEntryId, text, { writerId = null } = {}) { @@ -16,8 +16,8 @@ export async function saveAnnotation(repoDir, targetEntryId, text, { writerId = throw new ValidationError('Annotation text cannot be empty'); } - const app = await openWarpApp(repoDir); - const read = await createProductReadHandle(app, repoDir); + const worldline = await openThinkWorldline(repoDir); + const read = await openProductReadHandle(repoDir); const targetEntry = await getStoredEntry(read, targetEntryId); if (!targetEntry) { @@ -29,9 +29,9 @@ export async function saveAnnotation(repoDir, targetEntryId, text, { writerId = const createdAt = timestamp.toISOString(); const sortKey = `${String(timestamp.getTime()).padStart(13, '0')}-${unique}`; const annotationId = `${ANNOTATION_PREFIX}${sortKey}`; - const resolvedWriterId = writerId ?? app.writerId; + const resolvedWriterId = writerId ?? worldline.writerId; - await patchWarpApp(repoDir, async (patch) => { + await commitThinkWorldline(repoDir, async (patch) => { patch .addNode(annotationId) .setProperty(annotationId, 'kind', 'annotation') diff --git a/src/store/capture.js b/src/store/capture.js index 6e3e46c2..656bda4d 100644 --- a/src/store/capture.js +++ b/src/store/capture.js @@ -4,10 +4,9 @@ import { encodeTextContent } from './content.js'; import { createEntry } from './model.js'; import { commitThinkWorldline, - createProductReadHandle, getStoredEntry, openThinkWorldline, - openWarpApp, + openProductReadHandle, } from './runtime.js'; import { ensureCaptureReadEdges, ensureFirstDerivedArtifacts } from './derivation.js'; import { migrateGraphModel } from './migrations.js'; @@ -63,14 +62,11 @@ export async function finalizeCapturedThought(repoDir, entryId, { migrateIfNeeded = false, ambientContext = null, } = {}) { - let app = await openWarpApp(repoDir); - if (ambientContext) { await patchAmbientContext(repoDir, entryId, ambientContext); - app = await openWarpApp(repoDir); } - let read = await createProductReadHandle(app, repoDir); + let read = await openProductReadHandle(repoDir); let entry = await getStoredEntry(read, entryId); if (!entry || entry.kind !== 'capture') { @@ -81,11 +77,9 @@ export async function finalizeCapturedThought(repoDir, entryId, { } await ensureFirstDerivedArtifacts(repoDir, read, entry); - app = await openWarpApp(repoDir); - read = await createProductReadHandle(app, repoDir); + read = await openProductReadHandle(repoDir); await ensureCaptureReadEdges(repoDir, read, entryId); - app = await openWarpApp(repoDir); - read = await createProductReadHandle(app, repoDir); + read = await openProductReadHandle(repoDir); entry = await getStoredEntry(read, entryId); return { diff --git a/src/store/checkpoint-product-read.js b/src/store/checkpoint-product-read.js deleted file mode 100644 index 3a5cc3e7..00000000 --- a/src/store/checkpoint-product-read.js +++ /dev/null @@ -1,317 +0,0 @@ -import { NotFoundError, ValidationError } from '../errors.js'; -import { openCheckpointStateRead } from './checkpoint-state.js'; - -const DEFAULT_PATTERN = '*'; -const DEFAULT_MAX_DEPTH = 1000; - -class CheckpointProductQuery { - constructor({ reader, stateHash }) { - this._reader = reader; - this._stateHash = stateHash; - this._pattern = DEFAULT_PATTERN; - this._operations = []; - } - - match(pattern) { - this._pattern = pattern; - return this; - } - - where(criteria) { - this._operations.push({ type: 'where', criteria }); - return this; - } - - incoming(label) { - this._operations.push({ type: 'incoming', label }); - return this; - } - - outgoing(label) { - this._operations.push({ type: 'outgoing', label }); - return this; - } - - async run() { - let strand = this._matchingNodeIds(this._pattern); - for (const operation of this._operations) { - if (operation.type === 'where') { - strand = this._applyWhere(strand, operation.criteria); - continue; - } - strand = this._applyNeighborHop(strand, operation.type, operation.label); - } - - return Object.freeze({ - stateHash: this._stateHash, - nodes: Object.freeze(await Promise.all(strand.map(async (id) => Object.freeze({ - id, - props: Object.freeze(await this._reader.getNodeProps(id) ?? {}), - })))), - }); - } - - _matchingNodeIds(pattern) { - if (isSingleExactPattern(pattern)) { - return this._reader.hasNode(pattern) ? [pattern] : []; - } - return this._reader.project().nodes - .filter((nodeId) => matchesPattern(pattern, nodeId)) - .sort(compareStrings); - } - - _applyWhere(strand, criteria) { - if (typeof criteria === 'function') { - return this._applyPredicateWhere(strand, criteria); - } - if (!isPlainWhereObject(criteria)) { - throw new ValidationError('checkpoint product query where() expects an object or predicate'); - } - - const filtered = []; - for (const nodeId of strand) { - const props = this._reader.getNodeProps(nodeId); - if (propsMatch(props ?? {}, criteria)) { - filtered.push(nodeId); - } - } - return filtered.sort(compareStrings); - } - - _applyPredicateWhere(strand, predicate) { - const filtered = []; - for (const nodeId of strand) { - const snapshot = this._nodeSnapshot(nodeId); - if (predicate(snapshot)) { - filtered.push(nodeId); - } - } - return filtered.sort(compareStrings); - } - - _nodeSnapshot(nodeId) { - const [props, edgesOut, edgesIn] = [ - this._reader.getNodeProps(nodeId), - this._neighborEdges(nodeId, 'outgoing'), - this._neighborEdges(nodeId, 'incoming'), - ]; - return Object.freeze({ - id: nodeId, - props: Object.freeze(props ?? {}), - edgesOut, - edgesIn, - }); - } - - _neighborEdges(nodeId, direction) { - return Object.freeze(this._reader.neighbors(nodeId, direction).map((entry) => Object.freeze( - direction === 'outgoing' - ? { label: entry.label, to: entry.nodeId } - : { label: entry.label, from: entry.nodeId }, - ))); - } - - _applyNeighborHop(strand, direction, label) { - const next = new Set(); - for (const nodeId of strand) { - for (const neighbor of this._reader.neighbors(nodeId, direction, label)) { - next.add(neighbor.nodeId); - } - } - return [...next].sort(compareStrings); - } -} - -class CheckpointProductTraversal { - constructor(reader) { - this._reader = reader; - Object.freeze(this); - } - - bfs(start, options = {}) { - if (!this._reader.hasNode(start)) { - throw new NotFoundError(`Start node not found: ${start}`); - } - - const direction = normalizeTraversalDirection(options.dir); - const labels = normalizeLabelFilter(options.labelFilter); - const maxDepth = options.maxDepth ?? DEFAULT_MAX_DEPTH; - const visited = new Set(); - let currentLevel = [{ nodeId: start, depth: 0 }]; - const result = []; - - while (currentLevel.length > 0) { - currentLevel.sort((left, right) => compareStrings(left.nodeId, right.nodeId)); - const nextLevel = []; - const queued = new Set(); - - for (const { nodeId, depth } of currentLevel) { - if (visited.has(nodeId) || depth > maxDepth) { - continue; - } - - visited.add(nodeId); - result.push(nodeId); - - if (depth >= maxDepth) { - continue; - } - - for (const neighbor of this._neighbors(nodeId, direction, labels)) { - if (!visited.has(neighbor.nodeId) && !queued.has(neighbor.nodeId)) { - queued.add(neighbor.nodeId); - nextLevel.push({ nodeId: neighbor.nodeId, depth: depth + 1 }); - } - } - } - - currentLevel = nextLevel; - } - - return result; - } - - _neighbors(nodeId, direction, labels) { - if (direction === 'both') { - return sortNeighbors(dedupeNeighbors([ - ...this._reader.neighbors(nodeId, 'outgoing'), - ...this._reader.neighbors(nodeId, 'incoming'), - ])).filter((neighbor) => labelMatches(neighbor.label, labels)); - } - return sortNeighbors(this._reader.neighbors(nodeId, direction)) - .filter((neighbor) => labelMatches(neighbor.label, labels)); - } -} - -class CheckpointProductView { - constructor({ reader, stateHash }) { - this._reader = reader; - this._stateHash = stateHash; - this.traverse = new CheckpointProductTraversal(reader); - Object.freeze(this); - } - - hasNode(nodeId) { - return this._reader.hasNode(nodeId); - } - - getNodeProps(nodeId) { - return this._reader.getNodeProps(nodeId); - } - - getNodeContentMeta(nodeId) { - return this._reader.getNodeContentMeta(nodeId); - } - - query() { - return new CheckpointProductQuery({ - reader: this._reader, - stateHash: this._stateHash, - }); - } -} - -export async function openCheckpointProductRead(repoDir, app = null) { - const checkpoint = await openCheckpointStateRead(repoDir, app); - if (checkpoint === null) { - return null; - } - - return Object.freeze({ - blobStorage: checkpoint.blobStorage, - readContent: checkpoint.readContent, - view: new CheckpointProductView({ - reader: checkpoint.reader, - stateHash: checkpoint.checkpointSha, - }), - }); -} - -function isSingleExactPattern(pattern) { - return typeof pattern === 'string' && !pattern.includes('*'); -} - -function matchesPattern(pattern, nodeId) { - if (typeof pattern === 'string') { - return matchGlob(pattern, nodeId); - } - return pattern.some((entry) => matchGlob(entry, nodeId)); -} - -function matchGlob(pattern, value) { - return globToRegExp(pattern).test(value); -} - -function globToRegExp(pattern) { - return new RegExp(`^${String(pattern).split('*').map(escapeRegExp).join('.*')}$`); -} - -function escapeRegExp(value) { - return value.replace(/[\\^$+?.()|[\]{}]/g, '\\$&'); -} - -function isPlainWhereObject(value) { - return value !== null && typeof value === 'object' && !Array.isArray(value); -} - -function propsMatch(props, criteria) { - for (const [key, value] of Object.entries(criteria)) { - if (props[key] !== value) { - return false; - } - } - return true; -} - -function compareStrings(left, right) { - if (left < right) { return -1; } - if (left > right) { return 1; } - return 0; -} - -function normalizeTraversalDirection(direction = 'out') { - if (direction === 'out' || direction === 'outgoing') { - return 'outgoing'; - } - if (direction === 'in' || direction === 'incoming') { - return 'incoming'; - } - if (direction === 'both') { - return 'both'; - } - throw new ValidationError(`Unsupported traversal direction: ${direction}`); -} - -function normalizeLabelFilter(labelFilter) { - if (labelFilter === undefined || labelFilter === null) { - return null; - } - return new Set(Array.isArray(labelFilter) ? labelFilter : [labelFilter]); -} - -function labelMatches(label, labels) { - return labels === null || labels.has(label); -} - -function sortNeighbors(neighbors) { - return [...neighbors].sort((left, right) => { - const nodeComparison = compareStrings(left.nodeId, right.nodeId); - if (nodeComparison !== 0) { - return nodeComparison; - } - return compareStrings(left.label, right.label); - }); -} - -function dedupeNeighbors(neighbors) { - const seen = new Set(); - const deduped = []; - for (const neighbor of neighbors) { - const key = `${neighbor.nodeId}\0${neighbor.label}`; - if (!seen.has(key)) { - seen.add(key); - deduped.push(neighbor); - } - } - return deduped; -} diff --git a/src/store/checkpoint-read.js b/src/store/checkpoint-read.js deleted file mode 100644 index e59174d7..00000000 --- a/src/store/checkpoint-read.js +++ /dev/null @@ -1,132 +0,0 @@ -import { - ENTRY_PREFIX, - GRAPH_META_ID, - GRAPH_MODEL_VERSION, -} from './constants.js'; -import { storesTextContent } from './model.js'; -import { BaseEntry } from './runtime.js'; -import { openCheckpointStateRead } from './checkpoint-state.js'; - -class CheckpointReadModel { - constructor({ blobStorage, readContent, reader }) { - this._blobStorage = blobStorage; - this._readContent = readContent; - this._reader = reader; - Object.freeze(this); - } - - static async open(repoDir, app = null) { - const checkpoint = await openCheckpointStateRead(repoDir, app); - if (checkpoint === null) { - return null; - } - - return new CheckpointReadModel({ - blobStorage: checkpoint.blobStorage, - readContent: checkpoint.readContent, - reader: checkpoint.reader, - }); - } - - graphModelStatus() { - const props = this._reader.getNodeProps(GRAPH_META_ID); - if (!props && !this._hasCaptures()) { - return { - currentGraphModelVersion: 1, - requiredGraphModelVersion: GRAPH_MODEL_VERSION, - migrationRequired: true, - }; - } - - const currentGraphModelVersion = Number(props?.graphModelVersion ?? 1); - return { - currentGraphModelVersion, - requiredGraphModelVersion: GRAPH_MODEL_VERSION, - migrationRequired: currentGraphModelVersion < GRAPH_MODEL_VERSION, - }; - } - - async listEntriesByKind(kind) { - if (kind !== 'capture') { - return null; - } - - const entryNodes = this._entryNodeIds() - .map((nodeId) => this._entryCandidate(nodeId, kind)) - .filter(Boolean); - return await Promise.all( - entryNodes.map(({ nodeId, props }) => this._storedEntry(nodeId, props)), - ); - } - - listEntryPropsByKind(kind) { - if (kind !== 'capture') { - return null; - } - - return this._entryNodeIds() - .map((nodeId) => this._entryCandidate(nodeId, kind)) - .filter(Boolean) - .map(({ nodeId, props }) => Object.freeze({ id: nodeId, ...props })); - } - - _entryNodeIds() { - return this._reader.project().nodes.filter((nodeId) => nodeId.startsWith(ENTRY_PREFIX)); - } - - _hasCaptures() { - return this._entryNodeIds().some((nodeId) => this._entryCandidate(nodeId, 'capture')); - } - - _entryCandidate(nodeId, kind) { - const props = this._reader.getNodeProps(nodeId); - if (props?.kind !== kind) { - return null; - } - return { nodeId, props }; - } - - async _storedEntry(nodeId, props) { - const text = storesTextContent(props.kind) - ? await this._readNodeText(nodeId) - : ''; - return BaseEntry.from(nodeId, props, text); - } - - async _readNodeText(nodeId) { - const oid = this._reader.getNodeContentMeta(nodeId)?.oid; - if (this._blobStorage && typeof oid === 'string' && oid.length > 0) { - return new TextDecoder().decode(await this._blobStorage.retrieve(oid)); - } - - const content = await this._readContent(nodeId); - if (!content) { - return ''; - } - return new TextDecoder().decode(content); - } -} - -export async function getCheckpointGraphModelStatus(repoDir, app = null) { - const readModel = await CheckpointReadModel.open(repoDir, app); - if (readModel === null) { - return null; - } - return readModel.graphModelStatus(); -} - -export async function listCheckpointEntriesByKind(repoDir, kind, app = null) { - const readModel = await CheckpointReadModel.open(repoDir, app); - if (readModel === null) { - return null; - } - return await readModel.listEntriesByKind(kind); -} - -export async function listCheckpointEntryPropsByKind(repoDir, kind, app = null) { - const readModel = await CheckpointReadModel.open(repoDir, app); - if (readModel === null) { - return null; - } - return readModel.listEntryPropsByKind(kind); -} diff --git a/src/store/checkpoint-state.js b/src/store/checkpoint-state.js deleted file mode 100644 index d2800c33..00000000 --- a/src/store/checkpoint-state.js +++ /dev/null @@ -1,154 +0,0 @@ -import WarpApp, * as GitWarp from '@git-stunts/git-warp'; -import { DependencyError } from '../errors.js'; -import { createThinkPlumbing } from '../git.js'; -import { createAppContentReader } from './content-reader.js'; -import { CHECKPOINT_POLICY, GRAPH_NAME } from './constants.js'; -import { createWriterId } from './model.js'; - -export const LEGACY_CHECKPOINT_REF = `refs/warp/${GRAPH_NAME}/checkpoints/head`; -export const CHECKPOINT_REF = LEGACY_CHECKPOINT_REF; -export const STATE_CACHE_REF = `refs/warp/${GRAPH_NAME}/state-cache`; -export const SUPPORTED_CHECKPOINT_SCHEMA = 5; - -export async function openCheckpointStateRead(repoDir, app = null) { - const persistence = new GitWarp.GitGraphAdapter({ - plumbing: createThinkPlumbing(repoDir), - }); - const cachedState = await readCachedStateSource(persistence); - if (cachedState === null) { - return null; - } - - const resolvedApp = await resolveApp({ - app, - persistence, - }); - const state = await resolvedApp.core().materialize(); - - return Object.freeze({ - blobStorage: await createRuntimeBlobStorage(persistence), - checkpointSha: cachedState.oid, - cachedState, - readContent: createAppContentReader(resolvedApp), - reader: createCheckpointStateReader(state), - }); -} - -export async function getCheckpointRefStatus(repoDir) { - const persistence = new GitWarp.GitGraphAdapter({ - plumbing: createThinkPlumbing(repoDir), - }); - const legacyStatus = await getLegacyCheckpointRefStatus(persistence); - if (legacyStatus.exists && !legacyStatus.supported) { - return legacyStatus; - } - - const stateCacheOid = await persistence.readRef(STATE_CACHE_REF); - if (stateCacheOid !== null) { - return Object.freeze({ - cacheOid: stateCacheOid, - exists: true, - kind: 'state_cache', - ref: STATE_CACHE_REF, - checkpointSha: null, - schema: null, - supportedSchema: SUPPORTED_CHECKPOINT_SCHEMA, - supported: true, - }); - } - - return legacyStatus; -} - -export async function deleteCheckpointRef(repoDir) { - const persistence = new GitWarp.GitGraphAdapter({ - plumbing: createThinkPlumbing(repoDir), - }); - await persistence.deleteRef(LEGACY_CHECKPOINT_REF); -} - -export function isUnsupportedCheckpointSchemaError(error) { - const message = error instanceof Error ? error.message : String(error ?? ''); - return error?.code === 'E_CHECKPOINT_UNSUPPORTED_SCHEMA' - || message.includes('E_CHECKPOINT_UNSUPPORTED_SCHEMA') - || /Checkpoint .* is schema:\d+\. Only schema:\d+ checkpoints are supported/.test(message); -} - -async function readCachedStateSource(persistence) { - const stateCacheOid = await persistence.readRef(STATE_CACHE_REF); - if (stateCacheOid !== null) { - return Object.freeze({ - kind: 'state_cache', - oid: stateCacheOid, - ref: STATE_CACHE_REF, - }); - } - - const checkpointSha = await persistence.readRef(LEGACY_CHECKPOINT_REF); - if (checkpointSha !== null) { - return Object.freeze({ - kind: 'legacy_checkpoint', - oid: checkpointSha, - ref: LEGACY_CHECKPOINT_REF, - }); - } - - return null; -} - -async function getLegacyCheckpointRefStatus(persistence) { - const checkpointSha = await persistence.readRef(LEGACY_CHECKPOINT_REF); - if (checkpointSha === null) { - return Object.freeze({ - exists: false, - kind: 'legacy_checkpoint', - ref: LEGACY_CHECKPOINT_REF, - checkpointSha: null, - schema: null, - supportedSchema: SUPPORTED_CHECKPOINT_SCHEMA, - supported: true, - }); - } - - const message = await persistence.showNode(checkpointSha); - const schema = parseCheckpointSchema(message); - return Object.freeze({ - exists: true, - kind: 'legacy_checkpoint', - ref: LEGACY_CHECKPOINT_REF, - checkpointSha, - schema, - supportedSchema: SUPPORTED_CHECKPOINT_SCHEMA, - supported: schema === SUPPORTED_CHECKPOINT_SCHEMA, - }); -} - -function parseCheckpointSchema(message) { - const match = String(message).match(/^eg-schema:\s*(\d+)\s*$/m); - return match ? Number.parseInt(match[1], 10) : null; -} - -function createRuntimeBlobStorage(persistence) { - const createStorage = persistence.createRuntimeBlobStorage; - if (typeof createStorage !== 'function') { - return null; - } - return createStorage.call(persistence); -} - -async function resolveApp({ app, persistence }) { - return app ?? await WarpApp.open({ - persistence, - graphName: GRAPH_NAME, - writerId: createWriterId(), - checkpointPolicy: CHECKPOINT_POLICY, - }); -} - -function createCheckpointStateReader(state) { - const createReader = GitWarp.createStateReader ?? GitWarp.createStateReaderV5; - if (typeof createReader !== 'function') { - throw new DependencyError('Installed @git-stunts/git-warp does not expose a public state reader factory'); - } - return createReader(state); -} diff --git a/src/store/constants.js b/src/store/constants.js index da9e3aaa..f7062a48 100644 --- a/src/store/constants.js +++ b/src/store/constants.js @@ -41,7 +41,6 @@ export const DERIVER_NAME = 'think'; export const DERIVER_VERSION = '1'; export const SCHEMA_VERSION = '1'; export const GRAPH_MODEL_VERSION = 4; -export const CHECKPOINT_POLICY = { every: 20 }; export const PRODUCT_READ_LENS = { match: [ GRAPH_META_ID, diff --git a/src/store/content-reader.js b/src/store/content-reader.js deleted file mode 100644 index 90132b84..00000000 --- a/src/store/content-reader.js +++ /dev/null @@ -1,14 +0,0 @@ -import { DependencyError } from '../errors.js'; - -export function createAppContentReader(app) { - if (typeof app?.getContent === 'function') { - return async (nodeId) => await app.getContent(nodeId); - } - - const core = typeof app?.core === 'function' ? app.core() : null; - if (typeof core?.getContent === 'function') { - return async (nodeId) => await core.getContent(nodeId); - } - - throw new DependencyError('Installed @git-stunts/git-warp does not expose a public content reader'); -} diff --git a/src/store/derivation.js b/src/store/derivation.js index 60ebcd82..770d53b1 100644 --- a/src/store/derivation.js +++ b/src/store/derivation.js @@ -19,12 +19,12 @@ import { normalizeSeed, } from './model.js'; import { + commitThinkWorldline, getLatestStoredEntry, getProducedInSessionId, getStoredEntry, hasNode, listEntriesByKind, - patchWarpApp, } from './runtime.js'; export function assessReflectability(text) { @@ -91,7 +91,7 @@ export async function ensureFirstDerivedArtifacts(repoDir, read, entry) { }; } - await patchWarpApp(repoDir, async (patch) => { + await commitThinkWorldline(repoDir, async (patch) => { ensureGraphMetadataNode(patch, graphMetaProps); if (!thoughtNodeExists) { @@ -155,7 +155,7 @@ export async function ensureCaptureReadEdges(repoDir, read, entryId) { return; } - await patchWarpApp(repoDir, (patch) => { + await commitThinkWorldline(repoDir, (patch) => { if (newerEntry) { patch.addEdge(newerEntry.id, entry.id, 'older'); patch.addEdge(entry.id, newerEntry.id, 'newer'); diff --git a/src/store/enrichment/runner.js b/src/store/enrichment/runner.js index 13c5fdc2..bef8bbfa 100644 --- a/src/store/enrichment/runner.js +++ b/src/store/enrichment/runner.js @@ -1,11 +1,10 @@ import { CLASSIFICATION_PREFIX, TOPIC_PREFIX, KEYWORD_PREFIX, GRAPH_META_ID } from '../constants.js'; import { createArtifactId, getCurrentTime } from '../model.js'; import { - createProductReadHandle, + commitThinkWorldline, getStoredEntry, listEntriesByKind, - openWarpApp, - patchWarpApp, + openProductReadHandle, } from '../runtime.js'; import { invalidateSearchIndex } from '../queries.js'; import { extractTopics } from './auto-tags.js'; @@ -18,8 +17,7 @@ const TOPIC_PROMOTION_THRESHOLD = 2; * Uses worldline query API โ€” no full graph materialization. */ export async function runEnrichmentPipeline(repoDir) { - const app = await openWarpApp(repoDir); - const read = await createProductReadHandle(app, repoDir); + const read = await openProductReadHandle(repoDir); const { view } = read; // 1. Determine the starting point (high-water mark cursor) @@ -235,7 +233,7 @@ export async function runEnrichmentPipeline(repoDir) { existingParseReceipts.add(thoughtId); } - await patchWarpApp(repoDir, (patch) => { + await commitThinkWorldline(repoDir, (patch) => { // Create keyword nodes and mentions edges (The Inverted Index) for (const { keywordNodeId, keyword } of keywordNodesToCreate) { patch @@ -329,8 +327,7 @@ export async function runEnrichmentPipeline(repoDir) { * Uses worldline query API โ€” no full graph materialization. */ export async function listTopics(repoDir) { - const app = await openWarpApp(repoDir); - const read = await createProductReadHandle(app, repoDir); + const read = await openProductReadHandle(repoDir); const topicResult = await read.view.query().match(`${TOPIC_PREFIX}*`).where({ kind: 'topic' }).run(); const topics = []; diff --git a/src/store/migrations.js b/src/store/migrations.js index 88c5fa25..7c4982f5 100644 --- a/src/store/migrations.js +++ b/src/store/migrations.js @@ -6,7 +6,7 @@ import { GRAPH_MODEL_VERSION, } from './constants.js'; import { compareEntriesNewestFirst, getCurrentTime } from './model.js'; -import { commitThinkWorldline, openThinkWorldline, patchWarpAppWithWriter } from './runtime.js'; +import { commitThinkWorldline, commitThinkWorldlineWithWriter, openThinkWorldline } from './runtime.js'; export async function migrateGraphModel(repoDir) { const worldline = await openThinkWorldline(repoDir); @@ -168,7 +168,7 @@ export async function migrateGraphModel(repoDir) { if (missingEdges.length > 0) { const migrationWriterId = `${worldline.writerId}.migration`; - await patchWarpAppWithWriter(repoDir, migrationWriterId, (patch) => { + await commitThinkWorldlineWithWriter(repoDir, migrationWriterId, (patch) => { for (const edge of missingEdges) { patch.addEdge(edge.from, edge.to, edge.label); } diff --git a/src/store/queries.js b/src/store/queries.js index ec31aa2f..e7ea04e5 100644 --- a/src/store/queries.js +++ b/src/store/queries.js @@ -37,7 +37,6 @@ import { resolveHistorySessionTraversal, toBrowseEntry, } from './runtime.js'; -import { listCheckpointEntryPropsByKind } from './checkpoint-read.js'; import { assessReflectability, ensureFirstDerivedArtifacts, @@ -311,11 +310,6 @@ async function collectAmbientCandidates(read, candidates, key, value) { } export async function getStats(repoDir, { from, to, since, bucket } = {}) { - const checkpointCaptures = await listCheckpointEntryPropsByKind(repoDir, 'capture'); - if (checkpointCaptures !== null) { - return statsFromCaptures(checkpointCaptures, { from, to, since, bucket }); - } - const read = await openProductReadHandle(repoDir); return await getStatsForRead(read, { from, to, since, bucket }); } diff --git a/src/store/reflect.js b/src/store/reflect.js index 2d5daaf8..94edcecb 100644 --- a/src/store/reflect.js +++ b/src/store/reflect.js @@ -13,17 +13,17 @@ import { stableHash, } from './model.js'; import { - createProductReadHandle, + commitThinkWorldline, getReflectSession, getStoredEntry, - openWarpApp, - patchWarpApp, + openProductReadHandle, + openThinkWorldline, } from './runtime.js'; import { assessReflectability } from './derivation.js'; export async function startReflect(repoDir, seedEntryId, { promptType = null } = {}) { - const app = await openWarpApp(repoDir); - const read = await createProductReadHandle(app, repoDir); + const worldline = await openThinkWorldline(repoDir); + const read = await openProductReadHandle(repoDir); const planned = await planReflect(read, seedEntryId, { promptType }); if (!planned.ok) { @@ -31,7 +31,7 @@ export async function startReflect(repoDir, seedEntryId, { promptType = null } = } const {promptPlan} = planned; - const session = createReflectSession(app.writerId, { + const session = createReflectSession(worldline.writerId, { seedEntryId, contrastEntryId: null, promptType: promptPlan.promptType, @@ -40,7 +40,7 @@ export async function startReflect(repoDir, seedEntryId, { promptType = null } = }); // eslint-disable-next-line require-await -- git-warp patch callback must be async for the library API - await patchWarpApp(repoDir, async patch => { + await commitThinkWorldline(repoDir, async patch => { patch .addNode(session.id) .setProperty(session.id, 'kind', session.kind) @@ -79,8 +79,7 @@ export async function startReflect(repoDir, seedEntryId, { promptType = null } = } export async function previewReflect(repoDir, seedEntryId, { promptType = null } = {}) { - const app = await openWarpApp(repoDir); - const read = await createProductReadHandle(app, repoDir); + const read = await openProductReadHandle(repoDir); const planned = await planReflect(read, seedEntryId, { promptType }); if (!planned.ok) { @@ -101,15 +100,15 @@ export async function previewReflect(repoDir, seedEntryId, { promptType = null } } export async function saveReflectResponse(repoDir, sessionId, response) { - const app = await openWarpApp(repoDir); - const read = await createProductReadHandle(app, repoDir); + const worldline = await openThinkWorldline(repoDir); + const read = await openProductReadHandle(repoDir); const session = await getReflectSession(read, sessionId); if (!session) { return null; } - const entry = createEntry(response, app.writerId, { + const entry = createEntry(response, worldline.writerId, { kind: 'reflect', source: 'reflect', seedEntryId: session.seedEntryId, @@ -118,7 +117,7 @@ export async function saveReflectResponse(repoDir, sessionId, response) { promptType: session.promptType, }); - await patchWarpApp(repoDir, async patch => { + await commitThinkWorldline(repoDir, async patch => { patch .addNode(entry.id) .setProperty(entry.id, 'kind', entry.kind) diff --git a/src/store/runtime.js b/src/store/runtime.js index 81cb420e..265bd25a 100644 --- a/src/store/runtime.js +++ b/src/store/runtime.js @@ -1,13 +1,9 @@ -import WarpApp, { GitGraphAdapter, openWarpGraph, openWarpWorldline } from '@git-stunts/git-warp'; +import { GitGraphAdapter, openWarpWorldline } from '@git-stunts/git-warp'; import { resolveHistorySessionEntries } from '../history/session.js'; import { createThinkPlumbing } from '../git.js'; -import { createAppContentReader } from './content-reader.js'; -import { openCheckpointProductRead } from './checkpoint-product-read.js'; -import { deleteCheckpointRef, isUnsupportedCheckpointSchemaError } from './checkpoint-state.js'; import { ARTIFACT_PREFIX, - CHECKPOINT_POLICY, ENTRY_PREFIX, GRAPH_META_ID, GRAPH_MODEL_VERSION, @@ -113,97 +109,20 @@ export class BaseEntry { const WRITER_CAS_CONFLICT_TEXT = 'writer ref was updated by another process'; const DEFAULT_PATCH_MAX_ATTEMPTS = 3; -const warpAppCache = new Map(); const warpWorldlineCache = new Map(); const runtimeBlobStorageCache = new Map(); -export async function openWarpApp(repoDir) { - const cached = warpAppCache.get(repoDir); - if (cached) { - return cached; - } - - const app = await openWarpAppWithCheckpointRepair(repoDir, createWriterId()); - warpAppCache.set(repoDir, app); - return app; -} - -export async function openWarpGraphHandle(repoDir, writerId = createWriterId()) { - return await openWarpGraphWithCheckpointRepair(repoDir, writerId); -} - export async function openThinkWorldline(repoDir) { const cached = warpWorldlineCache.get(repoDir); if (cached) { return cached; } - const worldline = await openThinkWorldlineWithCheckpointRepair(repoDir, createWriterId()); + const worldline = await openThinkWorldlineOnce(repoDir, createWriterId()); warpWorldlineCache.set(repoDir, worldline); return worldline; } -async function openWarpAppWithCheckpointRepair(repoDir, writerId) { - try { - return await openWarpAppOnce(repoDir, writerId); - } catch (error) { - if (!isUnsupportedCheckpointSchemaError(error)) { - throw error; - } - - await deleteCheckpointRef(repoDir); - return await openWarpAppOnce(repoDir, writerId); - } -} - -async function openWarpGraphWithCheckpointRepair(repoDir, writerId) { - try { - return await openWarpGraphOnce(repoDir, writerId); - } catch (error) { - if (!isUnsupportedCheckpointSchemaError(error)) { - throw error; - } - - await deleteCheckpointRef(repoDir); - return await openWarpGraphOnce(repoDir, writerId); - } -} - -async function openThinkWorldlineWithCheckpointRepair(repoDir, writerId) { - try { - return await openThinkWorldlineOnce(repoDir, writerId); - } catch (error) { - if (!isUnsupportedCheckpointSchemaError(error)) { - throw error; - } - - await deleteCheckpointRef(repoDir); - return await openThinkWorldlineOnce(repoDir, writerId); - } -} - -async function openWarpAppOnce(repoDir, writerId) { - const persistence = createThinkWarpPersistence(repoDir); - - return await WarpApp.open({ - persistence, - graphName: GRAPH_NAME, - writerId, - checkpointPolicy: CHECKPOINT_POLICY, - }); -} - -async function openWarpGraphOnce(repoDir, writerId) { - const persistence = createThinkWarpPersistence(repoDir); - - return await openWarpGraph({ - persistence, - graphName: GRAPH_NAME, - writerId, - checkpointPolicy: CHECKPOINT_POLICY, - }); -} - async function openThinkWorldlineOnce(repoDir, writerId) { const persistence = createThinkWarpPersistence(repoDir); @@ -211,7 +130,6 @@ async function openThinkWorldlineOnce(repoDir, writerId) { persistence, worldlineName: GRAPH_NAME, writerId, - checkpointPolicy: CHECKPOINT_POLICY, }); } @@ -221,47 +139,9 @@ function createThinkWarpPersistence(repoDir) { }); } -export function clearWarpAppCache(repoDir) { - warpAppCache.delete(repoDir); +export function clearWarpRuntimeCache(repoDir) { warpWorldlineCache.delete(repoDir); -} - -export async function patchWarpApp(repoDir, patcher, { - genesisOnNoState = false, - maxAttempts = DEFAULT_PATCH_MAX_ATTEMPTS, - syncAfterPatch = true, -} = {}) { - let attempt = 1; - - /* eslint-disable no-await-in-loop -- retry attempts must run sequentially against a refreshed cached app */ - while (true) { - const app = await openWarpApp(repoDir); - - try { - try { - await app.patch(patcher); - } catch (error) { - if (!genesisOnNoState || error?.code !== 'E_NO_STATE') { - throw error; - } - await app.patch(patcher, { genesis: true }); - } - - if (syncAfterPatch) { - await app.syncWith(app.core()); - } - - return app; - } catch (error) { - if (!isWriterCasConflict(error) || attempt >= maxAttempts) { - throw error; - } - - clearWarpAppCache(repoDir); - attempt += 1; - } - } - /* eslint-enable no-await-in-loop */ + runtimeBlobStorageCache.delete(repoDir); } export async function commitThinkWorldline(repoDir, patcher, { @@ -281,39 +161,25 @@ export async function commitThinkWorldline(repoDir, patcher, { throw error; } - clearWarpAppCache(repoDir); + clearWarpRuntimeCache(repoDir); attempt += 1; } } /* eslint-enable no-await-in-loop */ } -export async function patchWarpAppWithWriter(repoDir, writerId, patcher, { - genesisOnNoState = false, +export async function commitThinkWorldlineWithWriter(repoDir, writerId, patcher, { maxAttempts = DEFAULT_PATCH_MAX_ATTEMPTS, - syncAfterPatch = true, } = {}) { let attempt = 1; - /* eslint-disable no-await-in-loop -- retry attempts must run sequentially against a refreshed app */ + /* eslint-disable no-await-in-loop -- retry attempts must run sequentially against a refreshed worldline */ while (true) { - const app = await openWarpAppUncached(repoDir, writerId); + const worldline = await openThinkWorldlineOnce(repoDir, writerId); try { - try { - await app.patch(patcher); - } catch (error) { - if (!genesisOnNoState || error?.code !== 'E_NO_STATE') { - throw error; - } - await app.patch(patcher, { genesis: true }); - } - - if (syncAfterPatch) { - await app.syncWith(app.core()); - } - - return app; + await worldline.commit(patcher); + return worldline; } catch (error) { if (!isWriterCasConflict(error) || attempt >= maxAttempts) { throw error; @@ -325,98 +191,32 @@ export async function patchWarpAppWithWriter(repoDir, writerId, patcher, { /* eslint-enable no-await-in-loop */ } -async function openWarpAppUncached(repoDir, writerId) { - return await openWarpAppWithCheckpointRepair(repoDir, writerId); -} - export function isWriterCasConflict(error) { return error instanceof Error && error.message.includes(WRITER_CAS_CONFLICT_TEXT); } -export async function createProductReadHandle(app, repoDir = null) { - if (repoDir) { - const checkpointRead = await tryOpenCheckpointProductRead(repoDir, app); - return await createWorldlineProductReadHandle({ - app, - repoDir, - checkpointRead, - }); - } - - return await createCompatProductReadHandle(app); -} - -async function createCompatProductReadHandle(app) { - const worldline = app.worldline(); - - return { - app, - repoDir: null, - worldline, - view: await worldline.observer('think-product', PRODUCT_READ_LENS), - contentCore: app.core(), - blobStorage: null, - readContent: createAppContentReader(app), - writerId: app.writerId, - }; -} - export async function openProductReadHandle(repoDir) { - const checkpointRead = await tryOpenCheckpointProductRead(repoDir); - return await createWorldlineProductReadHandle({ - repoDir, - checkpointRead, - }); + return await createWorldlineProductReadHandle({ repoDir }); } async function createWorldlineProductReadHandle({ - app = null, repoDir, - checkpointRead = null, }) { const worldline = await openThinkWorldline(repoDir); - const blobStorage = await resolveProductBlobStorage(repoDir, checkpointRead); + const blobStorage = await getRuntimeBlobStorage(repoDir); return { - app, + app: null, repoDir, worldline, - view: resolveProductView(checkpointRead, worldline), - contentCore: resolveProductContentCore(app), + view: worldline.live(), + contentCore: null, blobStorage, - readContent: resolveProductContentReader(checkpointRead, app), + readContent: null, writerId: worldline.writerId, }; } -function resolveProductView(checkpointRead, worldline) { - return checkpointRead?.view ?? worldline.live(); -} - -function resolveProductContentCore(app) { - return app?.core?.() ?? null; -} - -async function resolveProductBlobStorage(repoDir, checkpointRead) { - return checkpointRead?.blobStorage ?? await getRuntimeBlobStorage(repoDir); -} - -function resolveProductContentReader(checkpointRead, app) { - return checkpointRead?.readContent ?? resolveAppContentReader(app); -} - -function resolveAppContentReader(app) { - return app ? createAppContentReader(app) : null; -} - -async function tryOpenCheckpointProductRead(repoDir, app = null) { - try { - return await openCheckpointProductRead(repoDir, app); - } catch { - return null; - } -} - async function getRuntimeBlobStorage(repoDir) { if (runtimeBlobStorageCache.has(repoDir)) { return await runtimeBlobStorageCache.get(repoDir); diff --git a/test/acceptance/mcp.test.js b/test/acceptance/mcp.test.js index bffb4671..fb7c0054 100644 --- a/test/acceptance/mcp.test.js +++ b/test/acceptance/mcp.test.js @@ -161,7 +161,7 @@ test('think MCP remember, stats, and prompt_metrics expose structured read resul ]); runThink(context, ['project notes about warp performance and browse startup']); - runThink(context, ['remembering warp checkpoints makes browse startup fast']); + runThink(context, ['remembering warp worldlines makes browse startup fast']); await withThinkMcpClient( context, diff --git a/test/ports/browse-appshell.test.js b/test/ports/browse-appshell.test.js index 58b7ea01..78c3592b 100644 --- a/test/ports/browse-appshell.test.js +++ b/test/ports/browse-appshell.test.js @@ -79,19 +79,19 @@ test('Browse data port preserves ready History metadata', () => { ok: true, current: { id: 'entry:1780000000000-abcdef12-3456-7890-abcd-ef1234567890', - text: 'Checkpoint data can stay visible while live History catches up.', + text: 'Prepared History data can stay visible while final loading catches up.', createdAt: '2026-06-17T13:00:00.000Z', }, older: null, newer: null, sessionContext: null, - message: 'Showing checkpoint while live History failed: fixture failure', + message: 'Showing prepared History while final load failed: fixture failure', reason: 'live_load_failed', graphStatus, }, { mindName: 'codex' }); assert.equal(view.status, 'ready'); - assert.equal(view.message, 'Showing checkpoint while live History failed: fixture failure'); + assert.equal(view.message, 'Showing prepared History while final load failed: fixture failure'); assert.equal(view.reason, 'live_load_failed'); assert.equal(view.graphStatus, graphStatus); }); @@ -143,7 +143,7 @@ test('Browse data port streams History capture window updates', async () => { task.dispose(); assert.equal(partialViews.length, 1); - assert.equal(partialViews[0].current.text, 'Checkpoint History view.'); + assert.equal(partialViews[0].current.text, 'Prepared History view.'); assert.equal(finalView.current.text, 'Final live History view.'); }); @@ -187,7 +187,7 @@ test('Browse page applies streamed initial History updates before the final load }); assert.equal(emitted.length, 1); const [partialModel] = fixture.page.update(emitted[0], initialModel); - assert.equal(partialModel.view.current.text, 'Checkpoint snapshot is already usable.'); + assert.equal(partialModel.view.current.text, 'Prepared History is already usable.'); fixture.resolveFinal(); await new Promise((resolve) => { @@ -369,7 +369,7 @@ function createStreamingHistoryDataPort() { 'Final live History view.' )), loadLatestCaptureWindowUpdates: async function* loadUpdates() { - yield historyWindowUpdate(false, 'entry:1780000000000-partial', 'Checkpoint History view.'); + yield historyWindowUpdate(false, 'entry:1780000000000-partial', 'Prepared History view.'); yield historyWindowUpdate(true, 'entry:1780000001000-final', 'Final live History view.'); }, }, @@ -380,7 +380,7 @@ function createStreamedInitialViewPage() { const partialView = createBrowseInitialView({ status: 'ready', mindName: 'codex', - current: createBrowseEntry('entry:1780000000000-partial', 'Checkpoint snapshot is already usable.'), + current: createBrowseEntry('entry:1780000000000-partial', 'Prepared History is already usable.'), }); const finalView = createBrowseInitialView({ status: 'ready', diff --git a/test/ports/capture-context.test.js b/test/ports/capture-context.test.js index 7587ee3d..e38b966f 100644 --- a/test/ports/capture-context.test.js +++ b/test/ports/capture-context.test.js @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import WarpApp, { GitGraphAdapter } from '@git-stunts/git-warp'; +import { GitGraphAdapter, openWarpWorldline } from '@git-stunts/git-warp'; import { createThinkPlumbing, ensureGitRepo } from '../../src/git.js'; import { getCaptureAmbientContext, getAmbientProjectContext } from '../../src/project-context.js'; @@ -84,8 +84,8 @@ test('saveRawCapture retries after the cached writer ref is advanced externally' await ensureGitRepo(localRepoDir); await saveRawCapture(localRepoDir, 'seed capture before external writer advance'); - const externalApp = await openExternalWarpApp(localRepoDir); - await externalApp.patch((patch) => { + const externalWorldline = await openExternalWorldline(localRepoDir); + await externalWorldline.commit((patch) => { patch .addNode('external:writer-advance') .setProperty('external:writer-advance', 'kind', 'external_fixture'); @@ -104,8 +104,8 @@ test('saveAnnotation retries after the cached writer ref is advanced externally' await ensureGitRepo(localRepoDir); const entry = await saveRawCapture(localRepoDir, 'annotation retry seed capture'); - const externalApp = await openExternalWarpApp(localRepoDir); - await externalApp.patch((patch) => { + const externalWorldline = await openExternalWorldline(localRepoDir); + await externalWorldline.commit((patch) => { patch .addNode('external:annotation-writer-advance') .setProperty('external:annotation-writer-advance', 'kind', 'external_fixture'); @@ -127,10 +127,10 @@ test('reflect writes retry after the cached writer ref is advanced externally', const entry = await saveRawCapture( localRepoDir, - 'We should redesign browse startup because checkpoint reads can hide transition latency.' + 'We should redesign browse startup because transitional reads can hide latency.' ); - const firstExternalApp = await openExternalWarpApp(localRepoDir); - await firstExternalApp.patch((patch) => { + const firstExternalWorldline = await openExternalWorldline(localRepoDir); + await firstExternalWorldline.commit((patch) => { patch .addNode('external:reflect-start-writer-advance') .setProperty('external:reflect-start-writer-advance', 'kind', 'external_fixture'); @@ -139,8 +139,8 @@ test('reflect writes retry after the cached writer ref is advanced externally', const started = await startReflect(localRepoDir, entry.id, { promptType: 'challenge' }); assert.equal(started.ok, true, 'Expected reflect start to retry and create a session after writer ref conflict.'); - const secondExternalApp = await openExternalWarpApp(localRepoDir); - await secondExternalApp.patch((patch) => { + const secondExternalWorldline = await openExternalWorldline(localRepoDir); + await secondExternalWorldline.commit((patch) => { patch .addNode('external:reflect-reply-writer-advance') .setProperty('external:reflect-reply-writer-advance', 'kind', 'external_fixture'); @@ -156,12 +156,12 @@ test('reflect writes retry after the cached writer ref is advanced externally', assert.equal(saved.sessionId, started.sessionId, 'Expected retried reflect reply to preserve session lineage.'); }); -async function openExternalWarpApp(repoDir) { - return await WarpApp.open({ +async function openExternalWorldline(repoDir) { + return await openWarpWorldline({ persistence: new GitGraphAdapter({ plumbing: createThinkPlumbing(repoDir), }), - graphName: GRAPH_NAME, + worldlineName: GRAPH_NAME, writerId: createWriterId(), }); } diff --git a/test/ports/checkpoint-read.test.js b/test/ports/checkpoint-read.test.js deleted file mode 100644 index 3260aeb8..00000000 --- a/test/ports/checkpoint-read.test.js +++ /dev/null @@ -1,87 +0,0 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; - -import { ensureGitRepo } from '../../src/git.js'; -import { - listRecent, - saveRawCapture, -} from '../../src/store.js'; -import { listCheckpointEntriesByKind } from '../../src/store/checkpoint-read.js'; -import { - listEntriesByKind, - openProductReadHandle, - openWarpApp, -} from '../../src/store/runtime.js'; -import { STATE_CACHE_REF } from '../../src/store/checkpoint-state.js'; -import { runGit } from '../fixtures/git.js'; -import { createTempDir } from '../fixtures/tmp.js'; -import { formatResult } from '../fixtures/runtime.js'; - -test('checkpoint reads include CAS-backed raw tail captures', async () => { - const repoDir = await createTempDir('think-checkpoint-read-'); - await ensureGitRepo(repoDir); - const previousTestNow = process.env.THINK_TEST_NOW; - - try { - for (let i = 0; i < 20; i += 1) { - process.env.THINK_TEST_NOW = String(1_900_000_000_000 + i); - // eslint-disable-next-line no-await-in-loop -- fixture needs ordered writer patches - await saveRawCapture(repoDir, `checkpoint-backed raw capture ${i}`); - } - const app = await openWarpApp(repoDir); - await app.core().materialize(); - await app.core().createCheckpoint(); - // eslint-disable-next-line require-atomic-updates -- test fixture restores THINK_TEST_NOW in finally - process.env.THINK_TEST_NOW = String(1_900_000_000_020); - await saveRawCapture(repoDir, 'checkpoint-backed raw capture 20'); - } finally { - restoreTestNow(previousTestNow); - } - - const cachedStateRef = runGit( - ['rev-parse', '--verify', '--quiet', STATE_CACHE_REF], - { cwd: repoDir }, - ); - assert.equal( - cachedStateRef.status, - 0, - `Expected fixture writes to create cached state.\n${formatResult(cachedStateRef)}` - ); - - const checkpointCaptures = await listCheckpointEntriesByKind(repoDir, 'capture'); - assert.ok(checkpointCaptures, 'Expected checkpoint-backed capture listing to be reachable.'); - assert.equal(checkpointCaptures.length, 21, 'Expected checkpoint read model to include raw tail captures.'); - - const productRead = await openProductReadHandle(repoDir); - assert.equal( - typeof productRead.view.getNodeContentMeta, - 'function', - 'Expected product reads to use the checkpoint-backed view when a checkpoint is available.' - ); - const productCaptures = await listEntriesByKind(productRead, 'capture'); - assert.equal(productCaptures.length, 21, 'Expected checkpoint-backed product reads to include raw tail captures.'); - assert.ok( - productCaptures.some((entry) => entry.text === 'checkpoint-backed raw capture 20'), - 'Expected checkpoint-backed product reads to decode CAS-backed tail content.', - ); - - const recent = await listRecent(repoDir, { count: 2 }); - - assert.equal(recent.total, 21, 'Expected public recent reads to use the checkpoint-backed capture set.'); - assert.deepEqual( - recent.entries.map((entry) => entry.text), - [ - 'checkpoint-backed raw capture 20', - 'checkpoint-backed raw capture 19', - ], - 'Expected checkpoint reads to decode CAS-backed capture text from the live tail.', - ); -}); - -function restoreTestNow(previousTestNow) { - if (previousTestNow === undefined) { - delete process.env.THINK_TEST_NOW; - return; - } - process.env.THINK_TEST_NOW = previousTestNow; -} diff --git a/test/ports/content-reader.test.js b/test/ports/content-reader.test.js deleted file mode 100644 index c6843173..00000000 --- a/test/ports/content-reader.test.js +++ /dev/null @@ -1,30 +0,0 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; - -import { DependencyError } from '../../src/errors.js'; -import { createAppContentReader } from '../../src/store/content-reader.js'; - -test('content reader uses app getContent when available', async () => { - const reader = createAppContentReader({ - getContent: (nodeId) => Promise.resolve(new TextEncoder().encode(`app:${nodeId}`)), - }); - - assert.equal(new TextDecoder().decode(await reader('node:1')), 'app:node:1'); -}); - -test('content reader uses core getContent when app getContent is unavailable', async () => { - const reader = createAppContentReader({ - core: () => ({ - getContent: (nodeId) => Promise.resolve(new TextEncoder().encode(`core:${nodeId}`)), - }), - }); - - assert.equal(new TextDecoder().decode(await reader('node:2')), 'core:node:2'); -}); - -test('content reader reports missing git-warp content API as dependency error', () => { - assert.throws( - () => createAppContentReader({}), - (error) => error instanceof DependencyError && /content reader/.test(error.message) - ); -}); diff --git a/test/ports/doctor.test.js b/test/ports/doctor.test.js index 6e7159d2..bb43dc7e 100644 --- a/test/ports/doctor.test.js +++ b/test/ports/doctor.test.js @@ -111,7 +111,6 @@ test('runDiagnostics includes all expected check names', async () => { const names = result.checks.map((c) => c.name); assert.ok(names.includes('think_dir'), 'Expected think_dir check.'); assert.ok(names.includes('local_repo'), 'Expected local_repo check.'); - assert.ok(names.includes('checkpoint'), 'Expected checkpoint check.'); assert.ok(names.includes('git_fsmonitor'), 'Expected git_fsmonitor check.'); assert.ok(names.includes('graph_model'), 'Expected graph_model check.'); assert.ok(names.includes('entry_count'), 'Expected entry_count check.'); @@ -195,67 +194,6 @@ test('runDiagnostics reports ok when fsmonitor is disabled locally', async () => assert.match(fsmonitorCheck.message, /disabled locally/, 'Expected local override to be mentioned.'); }); -test('runDiagnostics fails when checkpoint cache schema is unsupported', async () => { - const context = await createDoctorContext({ withRepo: true }); - const result = await runDiagnostics({ - thinkDir: context.thinkDir, - repoDir: context.repoDir, - getCheckpointStatus: () => ({ - exists: true, - ref: 'refs/warp/think/checkpoints/head', - checkpointSha: 'abc123', - schema: 4, - supportedSchema: 5, - supported: false, - }), - }); - - const checkpointCheck = findCheck(result, 'checkpoint'); - assert.equal(checkpointCheck.status, 'fail', 'Expected unsupported checkpoint schema to fail.'); - assert.match(checkpointCheck.message, /schema 4/, 'Expected current checkpoint schema in message.'); - assert.match(checkpointCheck.message, /update-ref -d/, 'Expected remediation command.'); -}); - -test('runDiagnostics fixes unsupported checkpoint cache when requested', async () => { - const context = await createDoctorContext({ withRepo: true }); - let fixed = false; - - const result = await runDiagnostics({ - thinkDir: context.thinkDir, - repoDir: context.repoDir, - fix: true, - getCheckpointStatus: () => fixed - ? { - exists: false, - ref: 'refs/warp/think/checkpoints/head', - checkpointSha: null, - schema: null, - supportedSchema: 5, - supported: true, - } - : { - exists: true, - ref: 'refs/warp/think/checkpoints/head', - checkpointSha: 'abc123', - schema: 4, - supportedSchema: 5, - supported: false, - }, - fixCheckpoint: () => { - fixed = true; - }, - }); - - assert.equal(fixed, true, 'Expected checkpoint fixer to run.'); - - const fix = result.fixes.find((candidate) => candidate.name === 'checkpoint'); - assert.ok(fix, 'Expected checkpoint fix result.'); - assert.equal(fix.status, 'ok', 'Expected checkpoint fix to pass.'); - - const checkpointCheck = findCheck(result, 'checkpoint'); - assert.equal(checkpointCheck.status, 'warn', 'Expected final checkpoint check to warn when cache is absent.'); -}); - test('runDiagnostics fails when fsmonitor is effectively enabled', async () => { const context = await createDoctorContext({ withRepo: true }); const result = await runDiagnostics({ diff --git a/test/ports/private-imports.test.js b/test/ports/private-imports.test.js index 429f44f7..c7894949 100644 --- a/test/ports/private-imports.test.js +++ b/test/ports/private-imports.test.js @@ -5,24 +5,19 @@ import test from 'node:test'; import { repoRoot } from '../fixtures/runtime.js'; -const CHECKPOINT_SOURCE_FILES = Object.freeze([ - 'src/store/checkpoint-read.js', - 'src/store/checkpoint-state.js', - 'src/store/checkpoint-product-read.js', -]); - const RUNTIME_READ_SOURCE_FILES = Object.freeze([ 'src/store/runtime.js', - 'src/store/checkpoint-state.js', + 'src/store/queries.js', + 'src/browse/adapters/git-warp.js', ]); -test('checkpoint read fast paths do not import git-warp internals from node_modules', async () => { +test('runtime read paths do not import Think-managed git-warp cache modules', async () => { const offenders = []; - for (const relativePath of CHECKPOINT_SOURCE_FILES) { + for (const relativePath of RUNTIME_READ_SOURCE_FILES) { // eslint-disable-next-line no-await-in-loop -- this guard reports deterministic file-level evidence const source = await readFile(join(repoRoot, relativePath), 'utf8'); - if (source.includes('node_modules/@git-stunts/git-warp/src')) { + if (source.includes('checkpoint-state.js') || source.includes('checkpoint-read.js')) { offenders.push(relativePath); } } @@ -30,7 +25,7 @@ test('checkpoint read fast paths do not import git-warp internals from node_modu assert.deepEqual( offenders, [], - 'Expected production checkpoint code to use only public git-warp package exports.' + 'Expected product read paths not to manage git-warp cache/checkpoint modules.' ); }); From 9e30788595df0a7fc15d9a5c41f2ce5d0750e0a6 Mon Sep 17 00:00:00 2001 From: James Ross Date: Wed, 8 Jul 2026 08:06:14 -0700 Subject: [PATCH 3/9] refactor: bound remember read model --- ADVANCED_GUIDE.md | 6 +- CHANGELOG.md | 5 + .../bad-code/CORE_large-mind-read-timeouts.md | 11 + src/store/capture.js | 84 +++++-- src/store/constants.js | 3 + src/store/derivation.js | 20 +- src/store/queries.js | 174 +++---------- src/store/read-model.js | 235 ++++++++++++++++++ src/store/runtime.js | 149 +++++++++-- test/ports/browse-window.test.js | 42 ++-- test/ports/no-full-materialization.test.js | 108 ++++++++ test/ports/recent-runtime.test.js | 19 +- 12 files changed, 625 insertions(+), 231 deletions(-) create mode 100644 src/store/read-model.js diff --git a/ADVANCED_GUIDE.md b/ADVANCED_GUIDE.md index 236392cb..522058fb 100644 --- a/ADVANCED_GUIDE.md +++ b/ADVANCED_GUIDE.md @@ -6,10 +6,10 @@ For orientation and the productive-fast path, use the [GUIDE.md](./GUIDE.md). ## Graph-Native Doctrine -Think uses a "window-based" read model to ensure the TUI remains responsive even as your archive grows to thousands of entries. +Think uses bounded product read models to ensure recall and browse remain responsive as your archive grows. -- **No Whole-Graph Materialization**: Commands like `browse` and `remember` use `git-warp` read handles to query specific worldline slices. -- **Checkpoint-Backed Reuse**: The browse TUI uses checkpoints to avoid redundant traversal of chronological neighbors. +- **No Whole-Graph Materialization**: Default `browse`, `recent`, and `remember` paths read Think-owned latest/recent/ambient read-model facts, then hydrate exact capture IDs. +- **Runtime-Owned Acceleration**: `git-warp` owns checkpoints, state-cache, snapshots, and materialization; Think stores domain facts such as "latest capture" and "recent captures for this ambient scope." - **Immutable Raw Layer**: The core Git repository is the system of record. Raw thoughts are never modified after capture. ## Derivation Pipeline diff --git a/CHANGELOG.md b/CHANGELOG.md index 0abab04f..78bd5e02 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,11 @@ Release discipline: ## Unreleased +- added bounded Think read-model facts for latest/recent captures and ambient + recall scopes so default `--remember`, `--recent`, and browse bootstrap hydrate + exact capture IDs instead of scanning `entry:*` +- added ratchet tests proving default ambient recall and explicit recall avoid + graph queries and keyword wildcard scans - added a Hexagonal Boundary lint ratchet so product code cannot increase direct substrate-layout or `git-warp` runtime leakage - removed Think-managed `git-warp` cache/checkpoint reads, doctor checks, and diff --git a/docs/method/backlog/bad-code/CORE_large-mind-read-timeouts.md b/docs/method/backlog/bad-code/CORE_large-mind-read-timeouts.md index fc26a0da..4ca648d6 100644 --- a/docs/method/backlog/bad-code/CORE_large-mind-read-timeouts.md +++ b/docs/method/backlog/bad-code/CORE_large-mind-read-timeouts.md @@ -7,6 +7,10 @@ read-heavy paths such as `recent`, `stats`, and `doctor`. The Claude mind at `~/.think/claude` was observed with roughly 43k loose Git objects and read commands taking 14-21 seconds even after the raw capture path itself was fast. +Default recall also regressed when `remember` asked broad `entry:*` and +`keyword:*` questions, then sorted and sliced after the runtime had already +answered the broad query. + ## Why It Matters Capture can return after local raw save, but agents still need reliable re-entry @@ -15,8 +19,15 @@ unavailable even when the underlying mind is intact. ## Acceptance Criteria +- [x] Route default `remember`, `recent`, and browse bootstrap through bounded + Think read-model facts instead of broad capture wildcard scans. +- [x] Add port-level ratchets proving default ambient and explicit remember do + not call `read.view.query()`. - [ ] Add a deterministic large-mind fixture or synthetic benchmark for MCP read timeout budgets. +- [ ] Add an explicit read-model backfill/repair command for existing minds so + old captures can enter the bounded indexes without default recall scanning + from genesis. - [ ] Establish target budgets for `recent`, `stats`, `doctor`, and `remember` against large repaired minds. - [ ] Document and automate safe maintenance for high-loose-object minds. diff --git a/src/store/capture.js b/src/store/capture.js index 656bda4d..197b7ee2 100644 --- a/src/store/capture.js +++ b/src/store/capture.js @@ -10,6 +10,7 @@ import { } from './runtime.js'; import { ensureCaptureReadEdges, ensureFirstDerivedArtifacts } from './derivation.js'; import { migrateGraphModel } from './migrations.js'; +import { applyCaptureReadModelPatch } from './read-model.js'; export async function saveRawCapture(repoDir, thought, { provenance = null, @@ -28,34 +29,64 @@ async function writeRawCapture(repoDir, thought, { const worldline = await openThinkWorldline(repoDir); const entry = createEntry(thought, worldline.writerId, { kind: 'capture', source: 'capture' }); const captureProvenance = normalizeCaptureProvenance(provenance); + const patcher = createRawCapturePatcher(repoDir, entry, thought, { + ambientContext, + captureProvenance, + }); - const patcher = async (patch) => { - patch - .addNode(entry.id) - .setProperty(entry.id, 'kind', entry.kind) - .setProperty(entry.id, 'source', entry.source) - .setProperty(entry.id, 'channel', entry.channel) - .setProperty(entry.id, 'writerId', entry.writerId) - .setProperty(entry.id, 'createdAt', entry.createdAt) - .setProperty(entry.id, 'sortKey', entry.sortKey); - - applyAmbientContextPatch(patch, entry.id, ambientContext); - if (captureProvenance?.ingress) { - patch.setProperty(entry.id, 'captureIngress', captureProvenance.ingress); - } - if (captureProvenance?.sourceApp) { - patch.setProperty(entry.id, 'captureSourceApp', captureProvenance.sourceApp); - } - if (captureProvenance?.sourceURL) { - patch.setProperty(entry.id, 'captureSourceURL', captureProvenance.sourceURL); - } + await commitThinkWorldline(repoDir, patcher); + return entry; +} + +function createRawCapturePatcher(repoDir, entry, thought, { ambientContext, captureProvenance }) { + return async (patch) => { + const read = await openProductReadHandle(repoDir); + const { previousLatestRef } = await applyCaptureReadModelPatch(patch, read, entry, { ambientContext }); + + applyRawCapturePatch(patch, entry, { + ambientContext, + captureProvenance, + previousLatestRef, + }); await patch.attachContent(entry.id, encodeTextContent(thought), { mime: TEXT_MIME }); }; +} - await commitThinkWorldline(repoDir, patcher); +function applyRawCapturePatch(patch, entry, { ambientContext, captureProvenance, previousLatestRef }) { + patch + .addNode(entry.id) + .setProperty(entry.id, 'kind', entry.kind) + .setProperty(entry.id, 'source', entry.source) + .setProperty(entry.id, 'channel', entry.channel) + .setProperty(entry.id, 'writerId', entry.writerId) + .setProperty(entry.id, 'createdAt', entry.createdAt) + .setProperty(entry.id, 'sortKey', entry.sortKey); + + applyAmbientContextPatch(patch, entry.id, ambientContext); + applyCaptureProvenancePatch(patch, entry.id, captureProvenance); + applyChronologyPatch(patch, entry.id, previousLatestRef); +} - return entry; +function applyCaptureProvenancePatch(patch, entryId, captureProvenance) { + if (captureProvenance?.ingress) { + patch.setProperty(entryId, 'captureIngress', captureProvenance.ingress); + } + if (captureProvenance?.sourceApp) { + patch.setProperty(entryId, 'captureSourceApp', captureProvenance.sourceApp); + } + if (captureProvenance?.sourceURL) { + patch.setProperty(entryId, 'captureSourceURL', captureProvenance.sourceURL); + } +} + +function applyChronologyPatch(patch, entryId, previousLatestRef) { + if (!previousLatestRef) { + return; + } + + patch.addEdge(entryId, previousLatestRef.id, 'older'); + patch.addEdge(previousLatestRef.id, entryId, 'newer'); } export async function finalizeCapturedThought(repoDir, entryId, { @@ -119,7 +150,14 @@ function applyAmbientContextPatch(patch, entryId, ambientContext) { } async function patchAmbientContext(repoDir, entryId, ambientContext) { - const patcher = (patch) => { + const patcher = async (patch) => { + const read = await openProductReadHandle(repoDir); + const props = await read.view.getNodeProps(entryId); + await applyCaptureReadModelPatch(patch, read, { + id: entryId, + ...(props ?? {}), + }, { ambientContext }); + applyAmbientContextPatch(patch, entryId, ambientContext); }; diff --git a/src/store/constants.js b/src/store/constants.js index f7062a48..93ad7ef0 100644 --- a/src/store/constants.js +++ b/src/store/constants.js @@ -21,7 +21,9 @@ export const ANNOTATION_PREFIX = 'annotation:'; export const LINK_PREFIX = 'link:'; export const EVOLUTION_PREFIX = 'evolution:'; export const PIPELINE_RUN_PREFIX = 'pipeline_run:'; +export const READ_MODEL_PREFIX = 'read_model:'; export const GRAPH_META_ID = 'meta:graph'; +export const CAPTURE_READ_MODEL_ID = `${READ_MODEL_PREFIX}capture:index`; // Standing classification node IDs export const CLASSIFICATIONS = Object.freeze([ @@ -58,6 +60,7 @@ export const PRODUCT_READ_LENS = { `${LINK_PREFIX}*`, `${EVOLUTION_PREFIX}*`, `${PIPELINE_RUN_PREFIX}*`, + `${READ_MODEL_PREFIX}*`, ], }; export const CHALLENGE_PROMPTS = [ diff --git a/src/store/derivation.js b/src/store/derivation.js index 770d53b1..2c53c607 100644 --- a/src/store/derivation.js +++ b/src/store/derivation.js @@ -146,25 +146,17 @@ export async function ensureCaptureReadEdges(repoDir, read, entryId) { return; } - const captures = (await listEntriesByKind(read, 'capture')).sort(compareEntriesNewestFirst); - const entryIndex = captures.findIndex((capture) => capture.id === entry.id); - const newerEntry = entryIndex > 0 ? captures[entryIndex - 1] : null; - const olderEntry = entryIndex >= 0 ? captures[entryIndex + 1] ?? null : null; + const olderEntry = await getLatestStoredEntry(read, 'capture', { + excludeIds: [entry.id], + }); - if (!newerEntry && !olderEntry) { + if (!olderEntry) { return; } await commitThinkWorldline(repoDir, (patch) => { - if (newerEntry) { - patch.addEdge(newerEntry.id, entry.id, 'older'); - patch.addEdge(entry.id, newerEntry.id, 'newer'); - } - - if (olderEntry) { - patch.addEdge(entry.id, olderEntry.id, 'older'); - patch.addEdge(olderEntry.id, entry.id, 'newer'); - } + patch.addEdge(entry.id, olderEntry.id, 'older'); + patch.addEdge(olderEntry.id, entry.id, 'newer'); }); } diff --git a/src/store/queries.js b/src/store/queries.js index e7ea04e5..7d958116 100644 --- a/src/store/queries.js +++ b/src/store/queries.js @@ -1,8 +1,5 @@ import { getPromptMetricsFile } from '../paths.js'; -import { - ENTRY_PREFIX, - KEYWORD_PREFIX, -} from './constants.js'; +import { KEYWORD_PREFIX } from './constants.js'; import { compareEntriesNewestFirst, formatBucketKey, @@ -28,15 +25,20 @@ import { import { getLatestCaptureId, getStoredEntry, - iterateRecentStoredEntries, + getChronologyNeighborEntries, + listIndexedCaptureProps, listChronologyEntries, - listEntryPropsByKind, - listEntriesByKind, listRecentStoredEntries, + listStoredEntriesByRefs, openProductReadHandle, resolveHistorySessionTraversal, toBrowseEntry, } from './runtime.js'; +import { + CAPTURE_READ_MODEL_LIMIT, + readAmbientCaptureRefs, + readCaptureReadModel, +} from './read-model.js'; import { assessReflectability, ensureFirstDerivedArtifacts, @@ -112,73 +114,12 @@ export async function rememberThoughtsForRead( ) { const limitValue = limit ?? DEFAULT_RECENT_LIMIT; - // 1. If there's an explicit query, try the graph-native inverted index first (O(1)) + // 1. If there's an explicit query, filter the bounded recent read model. if (query && String(query).trim() !== '') { const explicitScope = buildExplicitRememberScope(query); - const queryTerms = query.toLowerCase().split(/\s+/).filter(Boolean); const indexMatches = new Map(); - - // Use Trie for prefix matching on query terms - const trie = await loadSearchIndex(read.repoDir); - const expandedKeywords = new Map(); // keyword -> distance - - for (const term of queryTerms) { - const prefixMatches = trie.search(term); - for (const m of prefixMatches) { - expandedKeywords.set(m, 0); // Exact or prefix match has distance 0 - } - - // If we don't have many matches, try fuzzy (edit distance) - if (prefixMatches.length < 10) { - const fuzzyMatches = trie.searchFuzzy(term, term.length > 4 ? 2 : 1); - for (const { keyword, distance } of fuzzyMatches) { - if (!expandedKeywords.has(keyword) || distance < expandedKeywords.get(keyword)) { - expandedKeywords.set(keyword, distance); - } - } - } - } - - for (const [keyword, distance] of expandedKeywords) { - const keywordNodeId = `${KEYWORD_PREFIX}${keyword}`; - // eslint-disable-next-line no-await-in-loop -- sequential keyword index lookup - const traversal = await read.view.query().match(keywordNodeId).incoming('mentions').run(); - - for (const node of traversal.nodes ?? []) { - if (!indexMatches.has(node.id)) { - // eslint-disable-next-line no-await-in-loop -- sequential retrieval of indexed thoughts - const entry = await getStoredEntry(read, node.id); - if (entry) { - const match = buildExplicitRememberMatch({ - ...entry, - ambientCwd: entry.ambientCwd ?? null, - ambientGitRoot: entry.ambientGitRoot ?? null, - ambientGitRemote: entry.ambientGitRemote ?? null, - ambientGitBranch: entry.ambientGitBranch ?? null, - }, explicitScope); - - if (match) { - // Adjust score based on fuzzy distance - const fuzzyAdjustedMatch = { - ...match, - score: match.score - (distance * 0.1), // Typos rank slightly lower - }; - indexMatches.set(node.id, fuzzyAdjustedMatch); - } - } - } - } - } - - // Merge the graph-native index with a recent text scan. The keyword index - // can be partial when recent captures have not been enriched yet, so any - // indexed hit is useful but not authoritative. - const chronologyList = await listRecentStoredEntries(read, { limit: 2000 }); + const chronologyList = await listRecentStoredEntries(read, { limit: CAPTURE_READ_MODEL_LIMIT }); for (const entry of chronologyList) { - if (indexMatches.has(entry.id)) { - continue; - } - const match = buildExplicitRememberMatch({ ...entry, ambientCwd: entry.ambientCwd ?? null, @@ -203,7 +144,9 @@ export async function rememberThoughtsForRead( // 2. Ambient remember (cwd-based) const ambientScope = buildAmbientRememberScope(cwd); const indexedAmbient = await listIndexedAmbientMatches(read, ambientScope, { - limit: Number.isInteger(limitValue) ? limitValue : DEFAULT_RECENT_LIMIT, + limit: Number.isInteger(limitValue) + ? Math.max(limitValue, DEFAULT_RECENT_LIMIT) + : DEFAULT_RECENT_LIMIT, }); const ambientMatches = [...indexedAmbient.matches]; const seenAmbientIds = new Set(indexedAmbient.seenIds); @@ -217,7 +160,8 @@ export async function rememberThoughtsForRead( }); } - for await (const entry of iterateRecentStoredEntries(read, { limit: 2000 })) { + const recentFallback = await listRecentStoredEntries(read, { limit: CAPTURE_READ_MODEL_LIMIT }); + for (const entry of recentFallback) { if (seenAmbientIds.has(entry.id)) { continue; } @@ -253,30 +197,21 @@ export async function rememberThoughtsForRead( } async function listIndexedAmbientMatches(read, scope, { limit }) { - const candidates = new Map(); - await collectAmbientCandidates(read, candidates, 'ambientGitRemote', scope.gitRemote); - await collectAmbientCandidates(read, candidates, 'ambientGitRoot', scope.gitRoot); - await collectAmbientCandidates(read, candidates, 'ambientCwd', scope.cwd); - - const sortedCandidates = [...candidates.values()] - .sort(compareEntriesNewestFirst) - .slice(0, limit); + const sortedCandidateRefs = await readAmbientCaptureRefs(read, scope, { limit }); + const sortedCandidates = await listStoredEntriesByRefs(read, sortedCandidateRefs, { + limit, + kind: 'capture', + }); const matches = []; - const seenIds = new Set(candidates.keys()); + const seenIds = new Set(sortedCandidateRefs.map((ref) => ref.id)); for (const candidate of sortedCandidates) { - // eslint-disable-next-line no-await-in-loop -- bounded retrieval of selected ambient candidates - const entry = await getStoredEntry(read, candidate.id, candidate); - if (!entry) { - continue; - } - const match = buildAmbientRememberMatch({ - ...entry, - ambientCwd: entry.ambientCwd ?? null, - ambientGitRoot: entry.ambientGitRoot ?? null, - ambientGitRemote: entry.ambientGitRemote ?? null, - ambientGitBranch: entry.ambientGitBranch ?? null, + ...candidate, + ambientCwd: candidate.ambientCwd ?? null, + ambientGitRoot: candidate.ambientGitRoot ?? null, + ambientGitRemote: candidate.ambientGitRemote ?? null, + ambientGitBranch: candidate.ambientGitBranch ?? null, }, scope); if (match) { matches.push(match); @@ -289,33 +224,13 @@ async function listIndexedAmbientMatches(read, scope, { limit }) { }); } -async function collectAmbientCandidates(read, candidates, key, value) { - if (!value) { - return; - } - - const result = await read.view.query() - .match(`${ENTRY_PREFIX}*`) - .where({ kind: 'capture', [key]: value }) - .run(); - - for (const node of result.nodes ?? []) { - if (!candidates.has(node.id)) { - candidates.set(node.id, Object.freeze({ - id: node.id, - ...(node.props ?? {}), - })); - } - } -} - export async function getStats(repoDir, { from, to, since, bucket } = {}) { const read = await openProductReadHandle(repoDir); return await getStatsForRead(read, { from, to, since, bucket }); } export async function getStatsForRead(read, { from, to, since, bucket } = {}) { - const captures = await listEntryPropsByKind(read, 'capture'); + const captures = await listIndexedCaptureProps(read, { limit: CAPTURE_READ_MODEL_LIMIT }); return statsFromCaptures(captures, { from, to, since, bucket }); } @@ -392,22 +307,18 @@ export async function getPromptMetrics({ from, to, since, bucket } = {}) { export async function listRecent(repoDir, { count = null, query = null } = {}) { const limit = count ?? DEFAULT_RECENT_LIMIT; const read = await openProductReadHandle(repoDir); + const index = await readCaptureReadModel(read); - // Recent output reports the total capture count, so use the authoritative - // capture set instead of derived chronology edges. if (!query) { - const unfilteredRecent = (await listEntriesByKind(read, 'capture')) - .map(toBrowseEntry) - .sort(compareEntriesNewestFirst); + const unfilteredRecent = (await listRecentStoredEntries(read, { limit })) + .map(toBrowseEntry); return Object.freeze({ - entries: unfilteredRecent.slice(0, limit), - total: unfilteredRecent.length, + entries: unfilteredRecent, + total: index.totalCaptures, }); } - // If there is a query, we still need to filter. - // Future optimization: windowed search traversal. - const captures = await listEntriesByKind(read, 'capture'); + const captures = await listRecentStoredEntries(read, { limit: CAPTURE_READ_MODEL_LIMIT }); const recent = captures .map(entry => ({ id: entry.id, @@ -599,19 +510,6 @@ async function buildBrowseWindow(read, entryId) { }); } -async function resolveChronologyNeighbors(read, currentEntry) { - const entries = (await listEntryPropsByKind(read, 'capture')).sort(compareEntriesNewestFirst); - const index = entries.findIndex((entry) => entry.id === currentEntry.id); - if (index < 0) { - return Object.freeze({ older: null, newer: null }); - } - - return Object.freeze({ - newer: await hydrateChronologyNeighbor(read, entries[index - 1] ?? null), - older: await hydrateChronologyNeighbor(read, entries[index + 1] ?? null), - }); -} - -async function hydrateChronologyNeighbor(read, props) { - return props ? await getStoredEntry(read, props.id, props) : null; +function resolveChronologyNeighbors(read, currentEntry) { + return getChronologyNeighborEntries(read, currentEntry); } diff --git a/src/store/read-model.js b/src/store/read-model.js new file mode 100644 index 00000000..434ea0e4 --- /dev/null +++ b/src/store/read-model.js @@ -0,0 +1,235 @@ +import { createHash } from 'node:crypto'; + +import { parseJson, stringifyJson } from '../json.js'; +import { + CAPTURE_READ_MODEL_ID, + READ_MODEL_PREFIX, + SCHEMA_VERSION, +} from './constants.js'; +import { compareEntriesNewestFirst } from './model.js'; + +export const CAPTURE_READ_MODEL_LIMIT = 500; +export const AMBIENT_READ_MODEL_LIMIT = 250; +export const AMBIENT_INDEX_KEYS = Object.freeze([ + 'ambientGitRemote', + 'ambientGitRoot', + 'ambientCwd', +]); + +export function ambientReadModelId(key, value) { + const fingerprint = createHash('sha256') + .update(`${key}\0${value}`, 'utf8') + .digest('hex'); + return `${READ_MODEL_PREFIX}ambient:${key}:${fingerprint}`; +} + +export async function readCaptureReadModel(read) { + const props = await read.view.getNodeProps(CAPTURE_READ_MODEL_ID); + return normalizeCaptureReadModel(props); +} + +export async function readAmbientReadModel(read, key, value) { + if (!key || !value) { + return emptyAmbientReadModel(key, value); + } + + const props = await read.view.getNodeProps(ambientReadModelId(key, value)); + return normalizeAmbientReadModel(key, value, props); +} + +export async function readAmbientCaptureRefs(read, scope, { limit = AMBIENT_READ_MODEL_LIMIT } = {}) { + const refs = new Map(); + + for (const { key, value } of ambientScopeDescriptors(scope)) { + // eslint-disable-next-line no-await-in-loop -- exact bounded read per ambient index key + const index = await readAmbientReadModel(read, key, value); + for (const ref of index.refs) { + refs.set(ref.id, ref); + } + } + + return [...refs.values()] + .sort(compareEntriesNewestFirst) + .slice(0, normalizeLimit(limit, AMBIENT_READ_MODEL_LIMIT)); +} + +export async function applyCaptureReadModelPatch(patch, read, entry, { + ambientContext = null, +} = {}) { + const ref = normalizeCaptureRef(entry); + if (!ref) { + return Object.freeze({ previousLatestRef: null }); + } + + const captureIndex = await readCaptureReadModel(read); + const nextCaptureIndex = addRefToCaptureReadModel(captureIndex, ref); + writeCaptureReadModelPatch(patch, nextCaptureIndex); + + for (const { key, value } of ambientContextDescriptors(ambientContext)) { + // eslint-disable-next-line no-await-in-loop -- exact bounded read per ambient index key + const ambientIndex = await readAmbientReadModel(read, key, value); + writeAmbientReadModelPatch( + patch, + addRefToAmbientReadModel(ambientIndex, ref) + ); + } + + return Object.freeze({ + previousLatestRef: captureIndex.refs.find((candidate) => candidate.id !== ref.id) ?? null, + }); +} + +export function normalizeCaptureRef(entry) { + if (!entry?.id || entry.kind !== 'capture') { + return null; + } + + return Object.freeze({ + id: entry.id, + createdAt: entry.createdAt ?? null, + sortKey: String(entry.sortKey ?? ''), + }); +} + +function normalizeCaptureReadModel(props) { + const refs = parseRefs(props?.recentCaptureRefsJson).slice(0, CAPTURE_READ_MODEL_LIMIT); + const totalCaptures = Number.isInteger(props?.totalCaptures) + ? props.totalCaptures + : refs.length; + + return Object.freeze({ + id: CAPTURE_READ_MODEL_ID, + kind: 'capture_read_model', + refs: Object.freeze(refs), + latestCaptureId: props?.latestCaptureId ?? refs[0]?.id ?? null, + totalCaptures, + }); +} + +function normalizeAmbientReadModel(key, value, props) { + const refs = parseRefs(props?.recentCaptureRefsJson).slice(0, AMBIENT_READ_MODEL_LIMIT); + return Object.freeze({ + id: ambientReadModelId(key, value), + kind: 'ambient_capture_read_model', + ambientKey: key, + ambientValue: value, + refs: Object.freeze(refs), + }); +} + +function emptyAmbientReadModel(key, value) { + return Object.freeze({ + id: value ? ambientReadModelId(key, value) : null, + kind: 'ambient_capture_read_model', + ambientKey: key, + ambientValue: value, + refs: Object.freeze([]), + }); +} + +function addRefToCaptureReadModel(index, ref) { + const refs = mergeRefs(index.refs, ref, CAPTURE_READ_MODEL_LIMIT); + const alreadyIndexed = index.refs.some((candidate) => candidate.id === ref.id); + return Object.freeze({ + ...index, + refs: Object.freeze(refs), + latestCaptureId: refs[0]?.id ?? null, + totalCaptures: alreadyIndexed ? index.totalCaptures : index.totalCaptures + 1, + }); +} + +function addRefToAmbientReadModel(index, ref) { + return Object.freeze({ + ...index, + refs: Object.freeze(mergeRefs(index.refs, ref, AMBIENT_READ_MODEL_LIMIT)), + }); +} + +function mergeRefs(refs, ref, limit) { + const byId = new Map(refs.map((candidate) => [candidate.id, candidate])); + byId.set(ref.id, ref); + return [...byId.values()] + .sort(compareEntriesNewestFirst) + .slice(0, limit); +} + +function writeCaptureReadModelPatch(patch, index) { + patch + .addNode(index.id) + .setProperty(index.id, 'kind', index.kind) + .setProperty(index.id, 'schemaVersion', SCHEMA_VERSION) + .setProperty(index.id, 'latestCaptureId', index.latestCaptureId) + .setProperty(index.id, 'totalCaptures', index.totalCaptures) + .setProperty(index.id, 'recentCaptureRefsJson', stringifyJson(index.refs)); +} + +function writeAmbientReadModelPatch(patch, index) { + patch + .addNode(index.id) + .setProperty(index.id, 'kind', index.kind) + .setProperty(index.id, 'schemaVersion', SCHEMA_VERSION) + .setProperty(index.id, 'ambientKey', index.ambientKey) + .setProperty(index.id, 'ambientValue', index.ambientValue) + .setProperty(index.id, 'recentCaptureRefsJson', stringifyJson(index.refs)); +} + +function ambientScopeDescriptors(scope) { + return [ + { key: 'ambientGitRemote', value: scope.gitRemote }, + { key: 'ambientGitRoot', value: scope.gitRoot }, + { key: 'ambientCwd', value: scope.cwd }, + ].filter((descriptor) => descriptor.value); +} + +function ambientContextDescriptors(ambientContext) { + if (!ambientContext) { + return []; + } + + return AMBIENT_INDEX_KEYS + .map((key) => ({ key, value: ambientContextValue(ambientContext, key) })) + .filter((descriptor) => descriptor.value); +} + +function ambientContextValue(ambientContext, key) { + if (key === 'ambientGitRemote') { return ambientContext.gitRemote; } + if (key === 'ambientGitRoot') { return ambientContext.gitRoot; } + if (key === 'ambientCwd') { return ambientContext.cwd; } + return null; +} + +function parseRefs(value) { + if (!value) { + return []; + } + + try { + const parsed = parseJson(value); + if (!Array.isArray(parsed)) { + return []; + } + + return parsed + .map(normalizeRefRecord) + .filter(Boolean) + .sort(compareEntriesNewestFirst); + } catch { + return []; + } +} + +function normalizeRefRecord(record) { + if (!record?.id) { + return null; + } + + return Object.freeze({ + id: String(record.id), + createdAt: record.createdAt ? String(record.createdAt) : null, + sortKey: String(record.sortKey ?? ''), + }); +} + +function normalizeLimit(limit, fallback) { + return Number.isInteger(limit) ? Math.max(0, limit) : fallback; +} diff --git a/src/store/runtime.js b/src/store/runtime.js index 265bd25a..c943d222 100644 --- a/src/store/runtime.js +++ b/src/store/runtime.js @@ -20,6 +20,7 @@ import { createWriterId, storesTextContent, } from './model.js'; +import { readCaptureReadModel } from './read-model.js'; export class GenericEntry { constructor(nodeId, resolvedProps, text) { @@ -337,7 +338,10 @@ function getMatchPatternsForKind(kind) { } export async function listChronologyEntries(read) { - const captures = await listEntriesByKind(read, 'capture'); + const captures = await listRecentStoredEntries(read, { + kind: 'capture', + limit: Number.MAX_SAFE_INTEGER, + }); return captures .map(toBrowseEntry) .sort(compareEntriesNewestFirst); @@ -370,13 +374,8 @@ export async function* iterateRecentStoredEntries(read, { kind = 'capture', limi return; } - const candidates = await listEntryPropsByKind(read, kind); - for (const candidate of candidates.sort(compareEntriesNewestFirst).slice(0, maxEntries)) { - // eslint-disable-next-line no-await-in-loop -- bounded retrieval of selected recent entries - const entry = await getStoredEntry(read, candidate.id, candidate); - if (entry && entry.kind === kind) { - yield entry; - } + for (const entry of await listRecentCandidateEntries(read, { kind, limit: maxEntries })) { + yield entry; } } @@ -415,23 +414,9 @@ async function readNodeContentOid(read, nodeId) { } export async function getLatestCaptureId(read, { excludeIds = [] } = {}) { - const result = await read.view.query() - .match(getMatchPatternsForKind('capture')) - .where({ kind: 'capture' }) - .run(); - return latestCaptureNodeId(result.nodes ?? [], { excludeIds }); -} - -function latestCaptureNodeId(nodes, { excludeIds = [] } = {}) { + const index = await readCaptureReadModel(read); const excludedIds = new Set(excludeIds); - const [latest] = nodes - .filter((node) => !excludedIds.has(node.id)) - .map((node) => ({ - id: node.id, - sortKey: String(node.props?.sortKey ?? ''), - })) - .sort(compareEntriesNewestFirst); - return latest?.id ?? null; + return index.refs.find((ref) => !excludedIds.has(ref.id))?.id ?? null; } export async function getProducedInSessionId(read, entry) { @@ -477,7 +462,13 @@ export async function resolveHistorySessionTraversal(read, entry) { export const resolveGraphSessionTraversal = resolveHistorySessionTraversal; async function listHistorySessionBrowseEntries(read, entry) { - const captureProps = await listEntryPropsByKind(read, 'capture'); + if (!entry.sessionId) { + return []; + } + + const captureProps = await listIndexedCaptureProps(read, { + limit: Number.MAX_SAFE_INTEGER, + }); const sessionEntryProps = resolveHistorySessionEntries(captureProps, entry); const sessionEntries = []; @@ -510,3 +501,111 @@ function emptySessionTraversal() { next: null, }; } + +export async function listIndexedCaptureProps(read, { limit = 50 } = {}) { + const index = await readCaptureReadModel(read); + const maxEntries = Number.isInteger(limit) ? Math.max(0, limit) : 50; + const props = []; + + for (const ref of index.refs.slice(0, maxEntries)) { + // eslint-disable-next-line no-await-in-loop -- exact bounded read per indexed capture + const captureProps = await read.view.getNodeProps(ref.id); + if (captureProps?.kind === 'capture') { + props.push(Object.freeze({ + id: ref.id, + ...captureProps, + })); + } + } + + return props.sort(compareEntriesNewestFirst); +} + +export async function listStoredEntriesByRefs(read, refs, { limit = 50, kind = 'capture' } = {}) { + const maxEntries = Number.isInteger(limit) ? Math.max(0, limit) : 50; + const entries = await hydrateStoredEntriesByRefs(read, refs.slice(0, maxEntries), kind); + return entries.sort(compareEntriesNewestFirst); +} + +async function listRecentCandidateEntries(read, { kind, limit }) { + const candidates = await listRecentCandidateProps(read, { kind, limit }); + const entries = []; + + for (const candidate of candidates.sort(compareEntriesNewestFirst).slice(0, limit)) { + // eslint-disable-next-line no-await-in-loop -- exact bounded read per indexed capture + const entry = await getStoredEntry(read, candidate.id, candidate); + if (entry && entry.kind === kind) { + entries.push(entry); + } + } + + return entries; +} + +async function listRecentCandidateProps(read, { kind, limit }) { + if (kind === 'capture') { + return await listIndexedCaptureProps(read, { limit }); + } + + return await listEntryPropsByKind(read, kind); +} + +async function hydrateStoredEntriesByRefs(read, refs, kind) { + const entries = []; + + for (const ref of refs) { + // eslint-disable-next-line no-await-in-loop -- exact bounded read per indexed capture + const entry = await getStoredEntry(read, ref.id); + if (isRequestedEntryKind(entry, kind)) { + entries.push(entry); + } + } + + return entries; +} + +function isRequestedEntryKind(entry, kind) { + return Boolean(entry) && (!kind || entry.kind === kind); +} + +export async function getChronologyNeighborEntries(read, currentEntry) { + const indexedNeighbors = await getIndexedChronologyNeighborEntries(read, currentEntry); + if (indexedNeighbors.found) { + return Object.freeze({ + newer: indexedNeighbors.newer, + older: indexedNeighbors.older, + }); + } + + return Object.freeze({ + newer: await hydrateChronologyNeighborByEdge(read, currentEntry.id, 'newer'), + older: await hydrateChronologyNeighborByEdge(read, currentEntry.id, 'older'), + }); +} + +async function getIndexedChronologyNeighborEntries(read, currentEntry) { + const index = await readCaptureReadModel(read); + const currentIndex = index.refs.findIndex((ref) => ref.id === currentEntry.id); + if (currentIndex < 0) { + return Object.freeze({ found: false, newer: null, older: null }); + } + + return Object.freeze({ + found: true, + newer: await hydrateChronologyNeighbor(read, index.refs[currentIndex - 1] ?? null), + older: await hydrateChronologyNeighbor(read, index.refs[currentIndex + 1] ?? null), + }); +} + +async function hydrateChronologyNeighbor(read, ref) { + return ref ? await getStoredEntry(read, ref.id) : null; +} + +async function hydrateChronologyNeighborByEdge(read, entryId, label) { + const result = await read.view.query() + .match(entryId) + .outgoing(label) + .run(); + const neighborId = result.nodes?.[0]?.id ?? null; + return neighborId ? await getStoredEntry(read, neighborId) : null; +} diff --git a/test/ports/browse-window.test.js b/test/ports/browse-window.test.js index 86b5cd9f..eed98b11 100644 --- a/test/ports/browse-window.test.js +++ b/test/ports/browse-window.test.js @@ -1,6 +1,8 @@ import assert from 'node:assert/strict'; import test from 'node:test'; +import { stringifyJson } from '../../src/json.js'; +import { CAPTURE_READ_MODEL_ID } from '../../src/store/constants.js'; import { getBrowseWindowForRead } from '../../src/store.js'; const ENCODER = new TextEncoder(); @@ -53,10 +55,22 @@ function createCaptureRead(entries) { repoDir: '/tmp/think-fake-read', view: { getNodeProps(nodeId) { + if (nodeId === CAPTURE_READ_MODEL_ID) { + return { + kind: 'capture_read_model', + latestCaptureId: entries[0]?.id ?? null, + totalCaptures: entries.length, + recentCaptureRefsJson: stringifyJson(entries.map((entry) => ({ + id: entry.id, + createdAt: entry.props.createdAt, + sortKey: entry.props.sortKey, + }))), + }; + } return propsById.get(nodeId) ?? null; }, query() { - return createCaptureQuery(entries); + throw new Error('Expected browse window to use bounded read-model refs, not graph queries.'); }, }, readContent(nodeId) { @@ -65,29 +79,3 @@ function createCaptureRead(entries) { }, }; } - -function createCaptureQuery(entries) { - const query = { - incoming() { - return query; - }, - match() { - return query; - }, - outgoing() { - return query; - }, - run() { - return { - nodes: entries.map((entry) => ({ - id: entry.id, - props: entry.props, - })), - }; - }, - where() { - return query; - }, - }; - return query; -} diff --git a/test/ports/no-full-materialization.test.js b/test/ports/no-full-materialization.test.js index 3ca20169..67e05eee 100644 --- a/test/ports/no-full-materialization.test.js +++ b/test/ports/no-full-materialization.test.js @@ -3,6 +3,11 @@ import { readFileSync, readdirSync, statSync } from 'node:fs'; import path from 'node:path'; import test from 'node:test'; +import { stringifyJson } from '../../src/json.js'; +import { CAPTURE_READ_MODEL_ID } from '../../src/store/constants.js'; +import { rememberThoughtsForRead } from '../../src/store/queries.js'; +import { ambientReadModelId } from '../../src/store/read-model.js'; + function collectJsFiles(dir) { const files = []; for (const entry of readdirSync(dir)) { @@ -45,3 +50,106 @@ test('no source file calls getNodes() or getEdges() for full graph materializati `Found ${violations.length} full-materialization anti-pattern(s):\n${violations.join('\n')}` ); }); + +test('default ambient remember reads bounded manifests instead of graph queries', async () => { + const cwd = '/tmp/think-alpha-project'; + const matching = capture('entry:1780000002000-alpha', 'alpha project should stay fast', { + ambientCwd: cwd, + }); + const other = capture('entry:1780000001000-beta', 'lunch note should not rank', { + ambientCwd: '/tmp/think-beta-project', + }); + const read = createBoundedRead([matching, other], { ambientIndexes: [{ key: 'ambientCwd', value: cwd, entries: [matching] }] }); + + const remembered = await rememberThoughtsForRead(read, { cwd, limit: 5 }); + + assert.deepEqual( + remembered.matches.map((match) => match.entryId), + [matching.id], + 'Expected ambient remember to exact-read the ambient read model and avoid graph-wide capture scans.' + ); + assert.equal(read.queryCalls, 0, 'Expected default ambient remember not to call read.view.query().'); +}); + +test('explicit remember filters bounded recent refs instead of bootstrapping keyword scans', async () => { + const matching = capture('entry:1780000002000-warp', 'warp receipts need a bounded recall path'); + const other = capture('entry:1780000001000-lunch', 'lunch notes are unrelated'); + const read = createBoundedRead([matching, other]); + + const remembered = await rememberThoughtsForRead(read, { query: 'warp receipts', limit: 5 }); + + assert.deepEqual( + remembered.matches.map((match) => match.entryId), + [matching.id], + 'Expected explicit remember to search the bounded recent read model without a keyword:* scan.' + ); + assert.equal(read.queryCalls, 0, 'Expected explicit remember not to call read.view.query().'); +}); + +function capture(id, text, props = {}) { + return Object.freeze({ + id, + text, + props: Object.freeze({ + kind: 'capture', + writerId: 'test', + createdAt: new Date(Number(id.split(':')[1].split('-')[0])).toISOString(), + sortKey: id.slice('entry:'.length), + ...props, + }), + }); +} + +function createBoundedRead(entries, { ambientIndexes = [] } = {}) { + const propsById = new Map(entries.map((entry) => [entry.id, entry.props])); + const textById = new Map(entries.map((entry) => [entry.id, entry.text])); + const read = { queryCalls: 0, repoDir: '/tmp/think-bounded-read' }; + + read.view = { + getNodeProps(nodeId) { + if (nodeId === CAPTURE_READ_MODEL_ID) { + return readModelProps(entries); + } + + for (const index of ambientIndexes) { + if (nodeId === ambientReadModelId(index.key, index.value)) { + return ambientReadModelProps(index); + } + } + + return propsById.get(nodeId) ?? null; + }, + query() { + read.queryCalls += 1; + throw new Error('Expected bounded recall to avoid read.view.query().'); + }, + }; + read.readContent = (nodeId) => new TextEncoder().encode(textById.get(nodeId) ?? ''); + return read; +} + +function readModelProps(entries) { + return { + kind: 'capture_read_model', + latestCaptureId: entries[0]?.id ?? null, + totalCaptures: entries.length, + recentCaptureRefsJson: stringifyJson(entries.map(entryRef)), + }; +} + +function ambientReadModelProps(index) { + return { + kind: 'ambient_capture_read_model', + ambientKey: index.key, + ambientValue: index.value, + recentCaptureRefsJson: stringifyJson(index.entries.map(entryRef)), + }; +} + +function entryRef(entry) { + return { + id: entry.id, + createdAt: entry.props.createdAt, + sortKey: entry.props.sortKey, + }; +} diff --git a/test/ports/recent-runtime.test.js b/test/ports/recent-runtime.test.js index 08fd74ed..61d2619d 100644 --- a/test/ports/recent-runtime.test.js +++ b/test/ports/recent-runtime.test.js @@ -1,6 +1,8 @@ import assert from 'node:assert/strict'; import test from 'node:test'; +import { stringifyJson } from '../../src/json.js'; +import { CAPTURE_READ_MODEL_ID } from '../../src/store/constants.js'; import { listRecentStoredEntries } from '../../src/store/runtime.js'; test('listRecentStoredEntries does not traverse older edges beyond the requested limit', async () => { @@ -10,6 +12,7 @@ test('listRecentStoredEntries does not traverse older edges beyond the requested assert.deepEqual(entries.map((entry) => entry.id), ['entry:newest']); assert.equal(read.olderLookups, 0, 'Expected limit=1 not to traverse the older chain.'); + assert.equal(read.queryCalls, 0, 'Expected recent reads to avoid graph queries entirely.'); }); test('listRecentStoredEntries hydrates only the requested sorted capture window', async () => { @@ -19,12 +22,13 @@ test('listRecentStoredEntries hydrates only the requested sorted capture window' assert.deepEqual(entries.map((entry) => entry.id), ['entry:newest', 'entry:middle']); assert.equal(read.olderLookups, 0, 'Expected recent reads to sort capture props without traversing older edges.'); + assert.equal(read.queryCalls, 0, 'Expected recent reads to hydrate exact indexed capture IDs.'); }); function createFakeChronologyRead(ids) { const propsById = createFakePropsById(ids); const textById = new Map(ids.map((id) => [id, `text for ${id}`])); - const read = { olderLookups: 0 }; + const read = { olderLookups: 0, queryCalls: 0 }; read.view = createFakeChronologyView(ids, propsById, read); read.readContent = (nodeId) => new TextEncoder().encode(textById.get(nodeId) ?? ''); @@ -43,9 +47,22 @@ function createFakePropsById(ids) { function createFakeChronologyView(ids, propsById, read) { return { query() { + read.queryCalls += 1; return createFakeChronologyQuery(ids, propsById, read); }, getNodeProps(nodeId) { + if (nodeId === CAPTURE_READ_MODEL_ID) { + return { + kind: 'capture_read_model', + latestCaptureId: ids[0] ?? null, + totalCaptures: ids.length, + recentCaptureRefsJson: stringifyJson(ids.map((id) => ({ + id, + createdAt: propsById.get(id).createdAt, + sortKey: propsById.get(id).sortKey, + }))), + }; + } return propsById.get(nodeId) ?? null; }, }; From b49018b8276b1e79317b703b2069154884b5b742 Mon Sep 17 00:00:00 2001 From: James Ross Date: Wed, 8 Jul 2026 08:16:23 -0700 Subject: [PATCH 4/9] fix: keep capture read model off raw save --- src/store/capture.js | 45 +++++++++++-------------------- src/store/read-model.js | 60 +++++++++++++++++++++++++++++++++++------ 2 files changed, 67 insertions(+), 38 deletions(-) diff --git a/src/store/capture.js b/src/store/capture.js index 197b7ee2..a719d730 100644 --- a/src/store/capture.js +++ b/src/store/capture.js @@ -10,7 +10,10 @@ import { } from './runtime.js'; import { ensureCaptureReadEdges, ensureFirstDerivedArtifacts } from './derivation.js'; import { migrateGraphModel } from './migrations.js'; -import { applyCaptureReadModelPatch } from './read-model.js'; +import { + applyCaptureReadModelPatch, + applyPendingCaptureReadModelPatch, +} from './read-model.js'; export async function saveRawCapture(repoDir, thought, { provenance = null, @@ -29,7 +32,7 @@ async function writeRawCapture(repoDir, thought, { const worldline = await openThinkWorldline(repoDir); const entry = createEntry(thought, worldline.writerId, { kind: 'capture', source: 'capture' }); const captureProvenance = normalizeCaptureProvenance(provenance); - const patcher = createRawCapturePatcher(repoDir, entry, thought, { + const patcher = createRawCapturePatcher(entry, thought, { ambientContext, captureProvenance, }); @@ -39,21 +42,18 @@ async function writeRawCapture(repoDir, thought, { return entry; } -function createRawCapturePatcher(repoDir, entry, thought, { ambientContext, captureProvenance }) { +function createRawCapturePatcher(entry, thought, { ambientContext, captureProvenance }) { return async (patch) => { - const read = await openProductReadHandle(repoDir); - const { previousLatestRef } = await applyCaptureReadModelPatch(patch, read, entry, { ambientContext }); - applyRawCapturePatch(patch, entry, { ambientContext, captureProvenance, - previousLatestRef, }); await patch.attachContent(entry.id, encodeTextContent(thought), { mime: TEXT_MIME }); }; } -function applyRawCapturePatch(patch, entry, { ambientContext, captureProvenance, previousLatestRef }) { +function applyRawCapturePatch(patch, entry, { ambientContext, captureProvenance }) { + applyPendingCaptureReadModelPatch(patch, entry); patch .addNode(entry.id) .setProperty(entry.id, 'kind', entry.kind) @@ -65,7 +65,6 @@ function applyRawCapturePatch(patch, entry, { ambientContext, captureProvenance, applyAmbientContextPatch(patch, entry.id, ambientContext); applyCaptureProvenancePatch(patch, entry.id, captureProvenance); - applyChronologyPatch(patch, entry.id, previousLatestRef); } function applyCaptureProvenancePatch(patch, entryId, captureProvenance) { @@ -80,23 +79,10 @@ function applyCaptureProvenancePatch(patch, entryId, captureProvenance) { } } -function applyChronologyPatch(patch, entryId, previousLatestRef) { - if (!previousLatestRef) { - return; - } - - patch.addEdge(entryId, previousLatestRef.id, 'older'); - patch.addEdge(previousLatestRef.id, entryId, 'newer'); -} - export async function finalizeCapturedThought(repoDir, entryId, { migrateIfNeeded = false, ambientContext = null, } = {}) { - if (ambientContext) { - await patchAmbientContext(repoDir, entryId, ambientContext); - } - let read = await openProductReadHandle(repoDir); let entry = await getStoredEntry(read, entryId); @@ -107,6 +93,10 @@ export async function finalizeCapturedThought(repoDir, entryId, { }; } + await patchCaptureReadModel(repoDir, entry, ambientContext); + read = await openProductReadHandle(repoDir); + entry = await getStoredEntry(read, entryId); + await ensureFirstDerivedArtifacts(repoDir, read, entry); read = await openProductReadHandle(repoDir); await ensureCaptureReadEdges(repoDir, read, entryId); @@ -149,16 +139,11 @@ function applyAmbientContextPatch(patch, entryId, ambientContext) { } } -async function patchAmbientContext(repoDir, entryId, ambientContext) { +async function patchCaptureReadModel(repoDir, entry, ambientContext) { const patcher = async (patch) => { const read = await openProductReadHandle(repoDir); - const props = await read.view.getNodeProps(entryId); - await applyCaptureReadModelPatch(patch, read, { - id: entryId, - ...(props ?? {}), - }, { ambientContext }); - - applyAmbientContextPatch(patch, entryId, ambientContext); + await applyCaptureReadModelPatch(patch, read, entry, { ambientContext }); + applyAmbientContextPatch(patch, entry.id, ambientContext); }; await commitThinkWorldline(repoDir, patcher); diff --git a/src/store/read-model.js b/src/store/read-model.js index 434ea0e4..bc580c76 100644 --- a/src/store/read-model.js +++ b/src/store/read-model.js @@ -79,6 +79,20 @@ export async function applyCaptureReadModelPatch(patch, read, entry, { }); } +export function applyPendingCaptureReadModelPatch(patch, entry) { + const ref = normalizeCaptureRef(entry); + if (!ref) { + return; + } + + patch + .addNode(CAPTURE_READ_MODEL_ID) + .setProperty(CAPTURE_READ_MODEL_ID, 'kind', 'capture_read_model') + .setProperty(CAPTURE_READ_MODEL_ID, 'schemaVersion', SCHEMA_VERSION) + .setProperty(CAPTURE_READ_MODEL_ID, 'latestCaptureId', ref.id) + .setProperty(CAPTURE_READ_MODEL_ID, 'latestCaptureRefJson', stringifyJson(ref)); +} + export function normalizeCaptureRef(entry) { if (!entry?.id || entry.kind !== 'capture') { return null; @@ -92,20 +106,36 @@ export function normalizeCaptureRef(entry) { } function normalizeCaptureReadModel(props) { - const refs = parseRefs(props?.recentCaptureRefsJson).slice(0, CAPTURE_READ_MODEL_LIMIT); - const totalCaptures = Number.isInteger(props?.totalCaptures) - ? props.totalCaptures - : refs.length; + const persistedRefs = parseRefs(props?.recentCaptureRefsJson); + const pendingLatestRef = parseRef(props?.latestCaptureRefJson); + const refs = mergePersistedAndPendingRefs(persistedRefs, pendingLatestRef); return Object.freeze({ id: CAPTURE_READ_MODEL_ID, kind: 'capture_read_model', + persistedRefs: Object.freeze(persistedRefs), refs: Object.freeze(refs), - latestCaptureId: props?.latestCaptureId ?? refs[0]?.id ?? null, - totalCaptures, + latestCaptureId: latestCaptureIdFromProps(props, refs), + totalCaptures: totalCapturesFromProps(props, refs), }); } +function mergePersistedAndPendingRefs(persistedRefs, pendingLatestRef) { + if (!pendingLatestRef) { + return mergeRefs(persistedRefs, null, CAPTURE_READ_MODEL_LIMIT); + } + + return mergeRefs([...persistedRefs, pendingLatestRef], null, CAPTURE_READ_MODEL_LIMIT); +} + +function latestCaptureIdFromProps(props, refs) { + return props?.latestCaptureId ?? refs[0]?.id ?? null; +} + +function totalCapturesFromProps(props, refs) { + return Number.isInteger(props?.totalCaptures) ? props.totalCaptures : refs.length; +} + function normalizeAmbientReadModel(key, value, props) { const refs = parseRefs(props?.recentCaptureRefsJson).slice(0, AMBIENT_READ_MODEL_LIMIT); return Object.freeze({ @@ -129,7 +159,7 @@ function emptyAmbientReadModel(key, value) { function addRefToCaptureReadModel(index, ref) { const refs = mergeRefs(index.refs, ref, CAPTURE_READ_MODEL_LIMIT); - const alreadyIndexed = index.refs.some((candidate) => candidate.id === ref.id); + const alreadyIndexed = index.persistedRefs.some((candidate) => candidate.id === ref.id); return Object.freeze({ ...index, refs: Object.freeze(refs), @@ -147,7 +177,9 @@ function addRefToAmbientReadModel(index, ref) { function mergeRefs(refs, ref, limit) { const byId = new Map(refs.map((candidate) => [candidate.id, candidate])); - byId.set(ref.id, ref); + if (ref) { + byId.set(ref.id, ref); + } return [...byId.values()] .sort(compareEntriesNewestFirst) .slice(0, limit); @@ -218,6 +250,18 @@ function parseRefs(value) { } } +function parseRef(value) { + if (!value) { + return null; + } + + try { + return normalizeRefRecord(parseJson(value)); + } catch { + return null; + } +} + function normalizeRefRecord(record) { if (!record?.id) { return null; From ffb5e5a9b192006e2ce0d6ca4a3413f2b1b56506 Mon Sep 17 00:00:00 2001 From: James Ross Date: Wed, 8 Jul 2026 08:51:41 -0700 Subject: [PATCH 5/9] refactor: add fast remember read model --- ADVANCED_GUIDE.md | 4 +- CHANGELOG.md | 9 +- .../bad-code/CORE_large-mind-read-timeouts.md | 6 +- src/cli/commands/read.js | 4 - src/mcp/service.js | 4 +- src/store/capture.js | 2 +- src/store/queries.js | 56 +--- src/store/read-model.js | 313 +++++++++++++++--- src/store/runtime.js | 95 +++++- test/ports/no-full-materialization.test.js | 66 +++- 10 files changed, 438 insertions(+), 121 deletions(-) diff --git a/ADVANCED_GUIDE.md b/ADVANCED_GUIDE.md index 522058fb..fa38870f 100644 --- a/ADVANCED_GUIDE.md +++ b/ADVANCED_GUIDE.md @@ -8,8 +8,8 @@ For orientation and the productive-fast path, use the [GUIDE.md](./GUIDE.md). Think uses bounded product read models to ensure recall and browse remain responsive as your archive grows. -- **No Whole-Graph Materialization**: Default `browse`, `recent`, and `remember` paths read Think-owned latest/recent/ambient read-model facts, then hydrate exact capture IDs. -- **Runtime-Owned Acceleration**: `git-warp` owns checkpoints, state-cache, snapshots, and materialization; Think stores domain facts such as "latest capture" and "recent captures for this ambient scope." +- **No Whole-Graph Materialization**: Default `browse`, `recent`, and `remember` paths read Think-owned latest/recent read-model facts. Recall uses self-contained fast capture records so it does not hydrate capture nodes just to score ambient project matches. +- **Runtime-Owned Acceleration**: `git-warp` owns checkpoints, state-cache, snapshots, and materialization; Think stores domain facts such as "latest capture" and "recent capture records." - **Immutable Raw Layer**: The core Git repository is the system of record. Raw thoughts are never modified after capture. ## Derivation Pipeline diff --git a/CHANGELOG.md b/CHANGELOG.md index 78bd5e02..9a90d53d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,11 +12,12 @@ Release discipline: ## Unreleased -- added bounded Think read-model facts for latest/recent captures and ambient - recall scopes so default `--remember`, `--recent`, and browse bootstrap hydrate - exact capture IDs instead of scanning `entry:*` +- added bounded Think read-model facts for latest/recent captures plus + self-contained fast capture records so default `--remember`, `--recent`, and + browse bootstrap avoid scanning `entry:*` - added ratchet tests proving default ambient recall and explicit recall avoid - graph queries and keyword wildcard scans + graph queries, keyword wildcard scans, and capture-node hydration when fast + capture records are available - added a Hexagonal Boundary lint ratchet so product code cannot increase direct substrate-layout or `git-warp` runtime leakage - removed Think-managed `git-warp` cache/checkpoint reads, doctor checks, and diff --git a/docs/method/backlog/bad-code/CORE_large-mind-read-timeouts.md b/docs/method/backlog/bad-code/CORE_large-mind-read-timeouts.md index 4ca648d6..9102a5bc 100644 --- a/docs/method/backlog/bad-code/CORE_large-mind-read-timeouts.md +++ b/docs/method/backlog/bad-code/CORE_large-mind-read-timeouts.md @@ -23,13 +23,17 @@ unavailable even when the underlying mind is intact. Think read-model facts instead of broad capture wildcard scans. - [x] Add port-level ratchets proving default ambient and explicit remember do not call `read.view.query()`. +- [x] Store self-contained fast capture records so default `remember` can score + ambient project matches without hydrating capture nodes. - [ ] Add a deterministic large-mind fixture or synthetic benchmark for MCP read timeout budgets. - [ ] Add an explicit read-model backfill/repair command for existing minds so old captures can enter the bounded indexes without default recall scanning from genesis. - [ ] Establish target budgets for `recent`, `stats`, `doctor`, and `remember` - against large repaired minds. + against large repaired minds. Current `codex-think --remember --json` + smoke exits under a 20s timeout on `~/.think/codex`, but still spends + roughly 13-14s in one git-warp optic property read. - [ ] Document and automate safe maintenance for high-loose-object minds. - [ ] Prefer public worldline/optic bounded reads where broad transitional queries are not required. diff --git a/src/cli/commands/read.js b/src/cli/commands/read.js index 4aba7d64..4168c488 100644 --- a/src/cli/commands/read.js +++ b/src/cli/commands/read.js @@ -277,10 +277,6 @@ export async function runRemember(output, reporter, options) { reporter.event('remember.read_open.start'); const read = await openHistoryReadHandle(repoDir); reporter.event('remember.read_open.done'); - const graphStatus = await getHistoryModelStatusForRead(read); - if (!await ensureGraphModelReadyFromStatus(repoDir, 'remember', graphStatus, output, reporter)) { - return 1; - } reporter.event('remember.scan.start'); const remember = await rememberThoughtsForRead(read, { diff --git a/src/mcp/service.js b/src/mcp/service.js index 5fecc63a..6ea03210 100644 --- a/src/mcp/service.js +++ b/src/mcp/service.js @@ -39,7 +39,7 @@ import { StatsOutcome, } from './result.js'; -const CAPTURE_FOLLOWTHROUGH_TIMEOUT_MS = 3_000; +const CAPTURE_FOLLOWTHROUGH_TIMEOUT_MS = 5_000; const CAPTURE_FOLLOWTHROUGH_DEFERRED = Object.freeze({ status: 'deferred' }); const CAPTURE_FOLLOWTHROUGH_DEFERRED_WARNING = 'Capture followthrough deferred; raw thought saved locally before derived graph updates completed.'; @@ -195,8 +195,6 @@ export async function rememberThoughtsForMcp({ }); } - await assertGraphReady('remember'); - const remember = await rememberThoughts(repoDir, { cwd, query, diff --git a/src/store/capture.js b/src/store/capture.js index a719d730..745d732a 100644 --- a/src/store/capture.js +++ b/src/store/capture.js @@ -53,7 +53,7 @@ function createRawCapturePatcher(entry, thought, { ambientContext, captureProven } function applyRawCapturePatch(patch, entry, { ambientContext, captureProvenance }) { - applyPendingCaptureReadModelPatch(patch, entry); + applyPendingCaptureReadModelPatch(patch, entry, { ambientContext }); patch .addNode(entry.id) .setProperty(entry.id, 'kind', entry.kind) diff --git a/src/store/queries.js b/src/store/queries.js index 7d958116..1131b8f0 100644 --- a/src/store/queries.js +++ b/src/store/queries.js @@ -29,14 +29,12 @@ import { listIndexedCaptureProps, listChronologyEntries, listRecentStoredEntries, - listStoredEntriesByRefs, openProductReadHandle, resolveHistorySessionTraversal, toBrowseEntry, } from './runtime.js'; import { CAPTURE_READ_MODEL_LIMIT, - readAmbientCaptureRefs, readCaptureReadModel, } from './read-model.js'; import { @@ -143,29 +141,11 @@ export async function rememberThoughtsForRead( // 2. Ambient remember (cwd-based) const ambientScope = buildAmbientRememberScope(cwd); - const indexedAmbient = await listIndexedAmbientMatches(read, ambientScope, { - limit: Number.isInteger(limitValue) - ? Math.max(limitValue, DEFAULT_RECENT_LIMIT) - : DEFAULT_RECENT_LIMIT, - }); - const ambientMatches = [...indexedAmbient.matches]; - const seenAmbientIds = new Set(indexedAmbient.seenIds); - let topTierMatches = ambientMatches.filter((match) => match.tier === 3).length; - - if (Number.isInteger(limitValue) && topTierMatches >= limitValue) { - ambientMatches.sort(compareRememberMatches); - return Object.freeze({ - scope: Object.freeze({ ...ambientScope, brief, limit: limitValue }), - matches: finalizeRememberMatches(ambientMatches, { brief, limit: limitValue }), - }); - } - - const recentFallback = await listRecentStoredEntries(read, { limit: CAPTURE_READ_MODEL_LIMIT }); - for (const entry of recentFallback) { - if (seenAmbientIds.has(entry.id)) { - continue; - } + const ambientMatches = []; + const recentCaptures = await listRecentStoredEntries(read, { limit: CAPTURE_READ_MODEL_LIMIT }); + let topTierMatches = 0; + for (const entry of recentCaptures) { const match = buildAmbientRememberMatch({ ...entry, ambientCwd: entry.ambientCwd ?? null, @@ -196,34 +176,6 @@ export async function rememberThoughtsForRead( }); } -async function listIndexedAmbientMatches(read, scope, { limit }) { - const sortedCandidateRefs = await readAmbientCaptureRefs(read, scope, { limit }); - const sortedCandidates = await listStoredEntriesByRefs(read, sortedCandidateRefs, { - limit, - kind: 'capture', - }); - const matches = []; - const seenIds = new Set(sortedCandidateRefs.map((ref) => ref.id)); - - for (const candidate of sortedCandidates) { - const match = buildAmbientRememberMatch({ - ...candidate, - ambientCwd: candidate.ambientCwd ?? null, - ambientGitRoot: candidate.ambientGitRoot ?? null, - ambientGitRemote: candidate.ambientGitRemote ?? null, - ambientGitBranch: candidate.ambientGitBranch ?? null, - }, scope); - if (match) { - matches.push(match); - } - } - - return Object.freeze({ - matches, - seenIds, - }); -} - export async function getStats(repoDir, { from, to, since, bucket } = {}) { const read = await openProductReadHandle(repoDir); return await getStatsForRead(read, { from, to, since, bucket }); diff --git a/src/store/read-model.js b/src/store/read-model.js index bc580c76..f3045ca4 100644 --- a/src/store/read-model.js +++ b/src/store/read-model.js @@ -15,6 +15,25 @@ export const AMBIENT_INDEX_KEYS = Object.freeze([ 'ambientGitRoot', 'ambientCwd', ]); +const CAPTURE_RECORD_STRING_FIELDS = Object.freeze([ + 'writerId', + 'source', + 'channel', + 'thoughtId', + 'sessionId', +]); +const CAPTURE_RECORD_AMBIENT_FIELDS = Object.freeze([ + ['ambientCwd', 'cwd'], + ['ambientGitRoot', 'gitRoot'], + ['ambientGitRemote', 'gitRemote'], + ['ambientGitBranch', 'gitBranch'], +]); +const CAPTURE_RECORD_PROVENANCE_FIELDS = Object.freeze([ + ['captureIngress', 'ingress'], + ['captureSourceApp', 'sourceApp'], + ['captureSourceURL', 'sourceURL'], +]); +const EXACT_READ_UNAVAILABLE = Symbol.for('think.exactReadUnavailable'); export function ambientReadModelId(key, value) { const fingerprint = createHash('sha256') @@ -23,11 +42,16 @@ export function ambientReadModelId(key, value) { return `${READ_MODEL_PREFIX}ambient:${key}:${fingerprint}`; } -export async function readCaptureReadModel(read) { - const props = await read.view.getNodeProps(CAPTURE_READ_MODEL_ID); +export async function readCaptureReadModel(read, { preferFast = true, useExact = true } = {}) { + const props = await readCaptureReadModelProps(read, { preferFast, useExact }); return normalizeCaptureReadModel(props); } +export async function readCaptureRecords(read, { limit = CAPTURE_READ_MODEL_LIMIT } = {}) { + const index = await readCaptureReadModel(read); + return index.records.slice(0, normalizeLimit(limit, CAPTURE_READ_MODEL_LIMIT)); +} + export async function readAmbientReadModel(read, key, value) { if (!key || !value) { return emptyAmbientReadModel(key, value); @@ -57,30 +81,25 @@ export async function applyCaptureReadModelPatch(patch, read, entry, { ambientContext = null, } = {}) { const ref = normalizeCaptureRef(entry); + const record = normalizeCaptureRecord(entry, { ambientContext }); if (!ref) { return Object.freeze({ previousLatestRef: null }); } - const captureIndex = await readCaptureReadModel(read); - const nextCaptureIndex = addRefToCaptureReadModel(captureIndex, ref); + const captureIndex = await readCaptureReadModel(read, { preferFast: false, useExact: false }); + const nextCaptureIndex = addCaptureToCaptureReadModel(captureIndex, ref, record); writeCaptureReadModelPatch(patch, nextCaptureIndex); - for (const { key, value } of ambientContextDescriptors(ambientContext)) { - // eslint-disable-next-line no-await-in-loop -- exact bounded read per ambient index key - const ambientIndex = await readAmbientReadModel(read, key, value); - writeAmbientReadModelPatch( - patch, - addRefToAmbientReadModel(ambientIndex, ref) - ); - } - return Object.freeze({ previousLatestRef: captureIndex.refs.find((candidate) => candidate.id !== ref.id) ?? null, }); } -export function applyPendingCaptureReadModelPatch(patch, entry) { +export function applyPendingCaptureReadModelPatch(patch, entry, { + ambientContext = null, +} = {}) { const ref = normalizeCaptureRef(entry); + const record = normalizeCaptureRecord(entry, { ambientContext }); if (!ref) { return; } @@ -91,6 +110,12 @@ export function applyPendingCaptureReadModelPatch(patch, entry) { .setProperty(CAPTURE_READ_MODEL_ID, 'schemaVersion', SCHEMA_VERSION) .setProperty(CAPTURE_READ_MODEL_ID, 'latestCaptureId', ref.id) .setProperty(CAPTURE_READ_MODEL_ID, 'latestCaptureRefJson', stringifyJson(ref)); + + if (record) { + patch + .setProperty(CAPTURE_READ_MODEL_ID, 'latestCaptureRecordJson', stringifyJson(record)) + .setProperty(CAPTURE_READ_MODEL_ID, 'fastCaptureRecordsJson', stringifyJson([record])); + } } export function normalizeCaptureRef(entry) { @@ -105,18 +130,94 @@ export function normalizeCaptureRef(entry) { }); } +export function normalizeCaptureRecord(entry, { ambientContext = null } = {}) { + const ref = normalizeCaptureRef(entry); + if (!ref) { + return null; + } + + const record = { + ...ref, + kind: 'capture', + text: textOrEmpty(entry.text), + }; + + assignStringFields(record, entry, CAPTURE_RECORD_STRING_FIELDS); + assignAmbientFields(record, entry, ambientContext); + assignProvenanceFields(record, entry); + + return Object.freeze(record); +} + +export function captureRecordToProps(record) { + const props = { + kind: 'capture', + createdAt: record.createdAt, + sortKey: record.sortKey, + }; + + assignStringFields(props, record, CAPTURE_RECORD_STRING_FIELDS); + assignStringFields(props, record, CAPTURE_RECORD_AMBIENT_FIELDS.map(([field]) => field)); + assignStringFields(props, record, CAPTURE_RECORD_PROVENANCE_FIELDS.map(([field]) => field)); + + return Object.freeze(props); +} + +async function readCaptureReadModelProps(read, { preferFast, useExact }) { + if (useExact && typeof read.readNodeProp === 'function') { + const recordsJson = await read.readNodeProp( + CAPTURE_READ_MODEL_ID, + preferFast ? 'fastCaptureRecordsJson' : 'recentCaptureRecordsJson' + ); + if (recordsJson === EXACT_READ_UNAVAILABLE) { + return normalizeReadModelPropsPreference( + await read.view.getNodeProps(CAPTURE_READ_MODEL_ID), + { preferFast } + ); + } + if (recordsJson !== undefined) { + return { + kind: 'capture_read_model', + recentCaptureRecordsJson: recordsJson, + }; + } + + return null; + } + + return await read.view.getNodeProps(CAPTURE_READ_MODEL_ID); +} + +function normalizeReadModelPropsPreference(props, { preferFast }) { + if (!props || !preferFast || props.fastCaptureRecordsJson === undefined) { + return props; + } + + return { + ...props, + recentCaptureRecordsJson: props.fastCaptureRecordsJson, + }; +} + function normalizeCaptureReadModel(props) { + const persistedRecords = parseRecords(props?.recentCaptureRecordsJson); + const pendingLatestRecord = parseRecord(props?.latestCaptureRecordJson); + const records = mergeRecords(persistedRecords, pendingLatestRecord, CAPTURE_READ_MODEL_LIMIT); const persistedRefs = parseRefs(props?.recentCaptureRefsJson); const pendingLatestRef = parseRef(props?.latestCaptureRefJson); - const refs = mergePersistedAndPendingRefs(persistedRefs, pendingLatestRef); + const refs = records.length > 0 + ? records.map(captureRecordToRef) + : mergePersistedAndPendingRefs(persistedRefs, pendingLatestRef); return Object.freeze({ id: CAPTURE_READ_MODEL_ID, kind: 'capture_read_model', persistedRefs: Object.freeze(persistedRefs), + persistedRecords: Object.freeze(persistedRecords), refs: Object.freeze(refs), + records: Object.freeze(records), latestCaptureId: latestCaptureIdFromProps(props, refs), - totalCaptures: totalCapturesFromProps(props, refs), + totalCaptures: totalCapturesFromProps(props, { persistedRefs, persistedRecords }), }); } @@ -132,11 +233,16 @@ function latestCaptureIdFromProps(props, refs) { return props?.latestCaptureId ?? refs[0]?.id ?? null; } -function totalCapturesFromProps(props, refs) { - return Number.isInteger(props?.totalCaptures) ? props.totalCaptures : refs.length; +function totalCapturesFromProps(props, { persistedRefs, persistedRecords }) { + if (Number.isInteger(props?.totalCaptures)) { + return props.totalCaptures; + } + + return Math.max(persistedRefs.length, persistedRecords.length); } function normalizeAmbientReadModel(key, value, props) { + const records = parseRecords(props?.recentCaptureRecordsJson).slice(0, AMBIENT_READ_MODEL_LIMIT); const refs = parseRefs(props?.recentCaptureRefsJson).slice(0, AMBIENT_READ_MODEL_LIMIT); return Object.freeze({ id: ambientReadModelId(key, value), @@ -144,6 +250,7 @@ function normalizeAmbientReadModel(key, value, props) { ambientKey: key, ambientValue: value, refs: Object.freeze(refs), + records: Object.freeze(records), }); } @@ -154,27 +261,26 @@ function emptyAmbientReadModel(key, value) { ambientKey: key, ambientValue: value, refs: Object.freeze([]), + records: Object.freeze([]), }); } -function addRefToCaptureReadModel(index, ref) { +function addCaptureToCaptureReadModel(index, ref, record) { const refs = mergeRefs(index.refs, ref, CAPTURE_READ_MODEL_LIMIT); - const alreadyIndexed = index.persistedRefs.some((candidate) => candidate.id === ref.id); + const records = record + ? mergeRecords(index.records, record, CAPTURE_READ_MODEL_LIMIT) + : index.records; + const alreadyIndexed = index.persistedRefs.some((candidate) => candidate.id === ref.id) || + index.persistedRecords.some((candidate) => candidate.id === ref.id); return Object.freeze({ ...index, refs: Object.freeze(refs), + records: Object.freeze(records), latestCaptureId: refs[0]?.id ?? null, totalCaptures: alreadyIndexed ? index.totalCaptures : index.totalCaptures + 1, }); } -function addRefToAmbientReadModel(index, ref) { - return Object.freeze({ - ...index, - refs: Object.freeze(mergeRefs(index.refs, ref, AMBIENT_READ_MODEL_LIMIT)), - }); -} - function mergeRefs(refs, ref, limit) { const byId = new Map(refs.map((candidate) => [candidate.id, candidate])); if (ref) { @@ -192,17 +298,9 @@ function writeCaptureReadModelPatch(patch, index) { .setProperty(index.id, 'schemaVersion', SCHEMA_VERSION) .setProperty(index.id, 'latestCaptureId', index.latestCaptureId) .setProperty(index.id, 'totalCaptures', index.totalCaptures) - .setProperty(index.id, 'recentCaptureRefsJson', stringifyJson(index.refs)); -} - -function writeAmbientReadModelPatch(patch, index) { - patch - .addNode(index.id) - .setProperty(index.id, 'kind', index.kind) - .setProperty(index.id, 'schemaVersion', SCHEMA_VERSION) - .setProperty(index.id, 'ambientKey', index.ambientKey) - .setProperty(index.id, 'ambientValue', index.ambientValue) - .setProperty(index.id, 'recentCaptureRefsJson', stringifyJson(index.refs)); + .setProperty(index.id, 'recentCaptureRefsJson', stringifyJson(index.refs)) + .setProperty(index.id, 'recentCaptureRecordsJson', stringifyJson(index.records)) + .setProperty(index.id, 'fastCaptureRecordsJson', stringifyJson(index.records)); } function ambientScopeDescriptors(scope) { @@ -213,24 +311,27 @@ function ambientScopeDescriptors(scope) { ].filter((descriptor) => descriptor.value); } -function ambientContextDescriptors(ambientContext) { - if (!ambientContext) { +function parseRefs(value) { + if (!value) { return []; } - return AMBIENT_INDEX_KEYS - .map((key) => ({ key, value: ambientContextValue(ambientContext, key) })) - .filter((descriptor) => descriptor.value); -} + try { + const parsed = parseJson(value); + if (!Array.isArray(parsed)) { + return []; + } -function ambientContextValue(ambientContext, key) { - if (key === 'ambientGitRemote') { return ambientContext.gitRemote; } - if (key === 'ambientGitRoot') { return ambientContext.gitRoot; } - if (key === 'ambientCwd') { return ambientContext.cwd; } - return null; + return parsed + .map(normalizeRefRecord) + .filter(Boolean) + .sort(compareEntriesNewestFirst); + } catch { + return []; + } } -function parseRefs(value) { +function parseRecords(value) { if (!value) { return []; } @@ -242,7 +343,7 @@ function parseRefs(value) { } return parsed - .map(normalizeRefRecord) + .map(normalizeRecordRecord) .filter(Boolean) .sort(compareEntriesNewestFirst); } catch { @@ -262,6 +363,18 @@ function parseRef(value) { } } +function parseRecord(value) { + if (!value) { + return null; + } + + try { + return normalizeRecordRecord(parseJson(value)); + } catch { + return null; + } +} + function normalizeRefRecord(record) { if (!record?.id) { return null; @@ -274,6 +387,104 @@ function normalizeRefRecord(record) { }); } +function normalizeRecordRecord(record) { + const ref = normalizeRefRecord(record); + if (!ref) { + return null; + } + + return Object.freeze({ + ...ref, + kind: 'capture', + writerId: stringOrNull(record.writerId), + text: String(record.text ?? ''), + source: stringOrNull(record.source), + channel: stringOrNull(record.channel), + thoughtId: stringOrNull(record.thoughtId), + sessionId: stringOrNull(record.sessionId), + ambientCwd: stringOrNull(record.ambientCwd), + ambientGitRoot: stringOrNull(record.ambientGitRoot), + ambientGitRemote: stringOrNull(record.ambientGitRemote), + ambientGitBranch: stringOrNull(record.ambientGitBranch), + captureIngress: stringOrNull(record.captureIngress), + captureSourceApp: stringOrNull(record.captureSourceApp), + captureSourceURL: stringOrNull(record.captureSourceURL), + }); +} + +function captureRecordToRef(record) { + return Object.freeze({ + id: record.id, + createdAt: record.createdAt, + sortKey: record.sortKey, + }); +} + +function mergeRecords(records, record, limit) { + const byId = new Map(records.map((candidate) => [candidate.id, candidate])); + if (record) { + byId.set(record.id, record); + } + return [...byId.values()] + .sort(compareEntriesNewestFirst) + .slice(0, limit); +} + +function assignStringFields(target, source, fields) { + for (const field of fields) { + target[field] = stringOrNull(source[field]); + } +} + +function assignAmbientFields(target, entry, ambientContext) { + for (const [recordField, contextField] of CAPTURE_RECORD_AMBIENT_FIELDS) { + target[recordField] = firstString([ + objectField(ambientContext, contextField), + entry[recordField], + ]); + } +} + +function assignProvenanceFields(target, entry) { + for (const [recordField, provenanceField] of CAPTURE_RECORD_PROVENANCE_FIELDS) { + target[recordField] = firstString([ + objectField(entry.captureProvenance, provenanceField), + entry[recordField], + ]); + } +} + +function firstString(values) { + for (const value of values) { + const normalized = stringOrNull(value); + if (normalized !== null) { + return normalized; + } + } + + return null; +} + +function objectField(value, key) { + if (!value || typeof value !== 'object') { + return null; + } + + return value[key]; +} + +function textOrEmpty(value) { + if (value === null || value === undefined) { + return ''; + } + + return String(value); +} + +function stringOrNull(value) { + return typeof value === 'string' ? value : null; +} + function normalizeLimit(limit, fallback) { return Number.isInteger(limit) ? Math.max(0, limit) : fallback; } diff --git a/src/store/runtime.js b/src/store/runtime.js index c943d222..a427f3d6 100644 --- a/src/store/runtime.js +++ b/src/store/runtime.js @@ -20,7 +20,11 @@ import { createWriterId, storesTextContent, } from './model.js'; -import { readCaptureReadModel } from './read-model.js'; +import { + captureRecordToProps, + readCaptureReadModel, + readCaptureRecords, +} from './read-model.js'; export class GenericEntry { constructor(nodeId, resolvedProps, text) { @@ -110,8 +114,10 @@ export class BaseEntry { const WRITER_CAS_CONFLICT_TEXT = 'writer ref was updated by another process'; const DEFAULT_PATCH_MAX_ATTEMPTS = 3; +const EXACT_READ_UNAVAILABLE = Symbol.for('think.exactReadUnavailable'); const warpWorldlineCache = new Map(); const runtimeBlobStorageCache = new Map(); +const opticBasisCache = new WeakMap(); export async function openThinkWorldline(repoDir) { const cached = warpWorldlineCache.get(repoDir); @@ -214,10 +220,63 @@ async function createWorldlineProductReadHandle({ contentCore: null, blobStorage, readContent: null, + readNodeProp: createWorldlineExactPropReader(worldline), writerId: worldline.writerId, }; } +function createWorldlineExactPropReader(worldline) { + let basisPromise = null; + + return async (nodeId, key) => { + if (opticBasisCache.get(worldline) === false) { + return EXACT_READ_UNAVAILABLE; + } + + try { + basisPromise ??= prepareCachedOpticBasis(worldline); + await basisPromise; + + const result = await worldline.optic().node(nodeId).prop(key).read(); + return result.exists ? result.value : undefined; + } catch (error) { + if (isMissingBoundedOpticBasis(error)) { + return EXACT_READ_UNAVAILABLE; + } + throw error; + } + }; +} + +async function prepareCachedOpticBasis(worldline) { + const cached = opticBasisCache.get(worldline); + if (cached) { + return cached; + } + + const basisPromise = worldline.prepareOpticBasis(); + opticBasisCache.set(worldline, basisPromise); + + try { + await basisPromise; + return basisPromise; + } catch (error) { + if (isMissingBoundedOpticBasis(error)) { + opticBasisCache.set(worldline, false); + } else { + opticBasisCache.delete(worldline); + } + throw error; + } +} + +function isMissingBoundedOpticBasis(error) { + return error instanceof Error && ( + error.code === 'E_OPTIC_NO_BOUNDED_BASIS' || + error.message.includes('E_OPTIC_NO_BOUNDED_BASIS') + ); +} + async function getRuntimeBlobStorage(repoDir) { if (runtimeBlobStorageCache.has(repoDir)) { return await runtimeBlobStorageCache.get(repoDir); @@ -503,6 +562,19 @@ function emptySessionTraversal() { } export async function listIndexedCaptureProps(read, { limit = 50 } = {}) { + const records = await readCaptureRecords(read, { limit }); + if (records.length > 0) { + return records + .map((record) => Object.freeze({ + id: record.id, + ...captureRecordToProps(record), + })) + .sort(compareEntriesNewestFirst); + } + if (typeof read.readNodeProp === 'function') { + return []; + } + const index = await readCaptureReadModel(read); const maxEntries = Number.isInteger(limit) ? Math.max(0, limit) : 50; const props = []; @@ -528,6 +600,16 @@ export async function listStoredEntriesByRefs(read, refs, { limit = 50, kind = ' } async function listRecentCandidateEntries(read, { kind, limit }) { + if (kind === 'capture') { + const recordEntries = await listRecentCaptureRecordEntries(read, { limit }); + if (recordEntries.length > 0) { + return recordEntries; + } + if (typeof read.readNodeProp === 'function') { + return []; + } + } + const candidates = await listRecentCandidateProps(read, { kind, limit }); const entries = []; @@ -542,6 +624,17 @@ async function listRecentCandidateEntries(read, { kind, limit }) { return entries; } +async function listRecentCaptureRecordEntries(read, { limit }) { + const records = await readCaptureRecords(read, { limit }); + return records + .map(captureRecordToEntry) + .sort(compareEntriesNewestFirst); +} + +function captureRecordToEntry(record) { + return new CaptureEntry(record.id, captureRecordToProps(record), record.text); +} + async function listRecentCandidateProps(read, { kind, limit }) { if (kind === 'capture') { return await listIndexedCaptureProps(read, { limit }); diff --git a/test/ports/no-full-materialization.test.js b/test/ports/no-full-materialization.test.js index 67e05eee..5f0eb753 100644 --- a/test/ports/no-full-materialization.test.js +++ b/test/ports/no-full-materialization.test.js @@ -51,7 +51,7 @@ test('no source file calls getNodes() or getEdges() for full graph materializati ); }); -test('default ambient remember reads bounded manifests instead of graph queries', async () => { +test('default ambient remember filters bounded recent captures instead of graph queries', async () => { const cwd = '/tmp/think-alpha-project'; const matching = capture('entry:1780000002000-alpha', 'alpha project should stay fast', { ambientCwd: cwd, @@ -66,11 +66,37 @@ test('default ambient remember reads bounded manifests instead of graph queries' assert.deepEqual( remembered.matches.map((match) => match.entryId), [matching.id], - 'Expected ambient remember to exact-read the ambient read model and avoid graph-wide capture scans.' + 'Expected ambient remember to score bounded recent captures and avoid graph-wide capture scans.' ); assert.equal(read.queryCalls, 0, 'Expected default ambient remember not to call read.view.query().'); }); +test('default ambient remember uses one fast capture record property when available', async () => { + const cwd = '/tmp/think-fast-project'; + const matching = capture('entry:1780000002000-fast', 'fast project record should be enough', { + ambientCwd: cwd, + }); + const other = capture('entry:1780000001000-other', 'unrelated lunch note should not rank', { + ambientCwd: '/tmp/think-other-project', + }); + const read = createFastRecordRead([matching, other]); + + const remembered = await rememberThoughtsForRead(read, { cwd, limit: 5 }); + + assert.deepEqual( + remembered.matches.map((match) => match.entryId), + [matching.id], + 'Expected ambient remember to filter self-contained fast capture records.' + ); + assert.deepEqual( + read.propReads, + [{ nodeId: CAPTURE_READ_MODEL_ID, key: 'fastCaptureRecordsJson' }], + 'Expected default remember to read only the fast capture record property.' + ); + assert.equal(read.nodePropCalls, 0, 'Expected fast record recall not to hydrate capture nodes.'); + assert.equal(read.queryCalls, 0, 'Expected fast record recall not to call read.view.query().'); +}); + test('explicit remember filters bounded recent refs instead of bootstrapping keyword scans', async () => { const matching = capture('entry:1780000002000-warp', 'warp receipts need a bounded recall path'); const other = capture('entry:1780000001000-lunch', 'lunch notes are unrelated'); @@ -128,6 +154,34 @@ function createBoundedRead(entries, { ambientIndexes = [] } = {}) { return read; } +function createFastRecordRead(entries) { + const read = { + nodePropCalls: 0, + propReads: [], + queryCalls: 0, + repoDir: '/tmp/think-fast-read', + }; + + read.readNodeProp = (nodeId, key) => { + read.propReads.push({ nodeId, key }); + if (nodeId === CAPTURE_READ_MODEL_ID && key === 'fastCaptureRecordsJson') { + return stringifyJson(entries.map(entryRecord)); + } + return undefined; + }; + read.view = { + getNodeProps() { + read.nodePropCalls += 1; + throw new Error('Expected fast record recall not to hydrate graph nodes.'); + }, + query() { + read.queryCalls += 1; + throw new Error('Expected bounded recall to avoid read.view.query().'); + }, + }; + return read; +} + function readModelProps(entries) { return { kind: 'capture_read_model', @@ -153,3 +207,11 @@ function entryRef(entry) { sortKey: entry.props.sortKey, }; } + +function entryRecord(entry) { + return { + ...entryRef(entry), + ...entry.props, + text: entry.text, + }; +} From a11b92bfc838ed83a738bce6ad77340b69ebecda Mon Sep 17 00:00:00 2001 From: James Ross Date: Wed, 8 Jul 2026 09:00:01 -0700 Subject: [PATCH 6/9] fix: address read handle review feedback --- docs/GLOSSARY.md | 4 ++- src/errors.js | 7 ++++ src/store/runtime.js | 51 +++++++++++++++++++++++---- test/ports/recent-runtime.test.js | 58 ++++++++++++++++++++++++++++--- 4 files changed, 108 insertions(+), 12 deletions(-) diff --git a/docs/GLOSSARY.md b/docs/GLOSSARY.md index d69ca721..d66edd7d 100644 --- a/docs/GLOSSARY.md +++ b/docs/GLOSSARY.md @@ -106,7 +106,9 @@ Git handles the repository substrate. `git-warp` provides the graph layer on top The primary application handle exposed by `git-warp`. -In `think`, product read paths begin from `WarpApp`. +`think` does not use `WarpApp` as a product read facade. Product reads open +public `git-warp` worldlines and let `git-warp` own internal checkpoint and +cache state. ### worldline diff --git a/src/errors.js b/src/errors.js index e520cb4b..e98b544e 100644 --- a/src/errors.js +++ b/src/errors.js @@ -48,6 +48,13 @@ export class DependencyError extends ThinkError { } } +export class ContentUnavailableError extends ThinkError { + constructor(message) { + super(message, 'CONTENT_UNAVAILABLE'); + this.name = 'ContentUnavailableError'; + } +} + export class PortNotImplementedError extends ThinkError { constructor(portName, methodName) { super(`${portName}.${methodName} is not implemented`, 'PORT_NOT_IMPLEMENTED'); diff --git a/src/store/runtime.js b/src/store/runtime.js index a427f3d6..ee49c991 100644 --- a/src/store/runtime.js +++ b/src/store/runtime.js @@ -1,5 +1,6 @@ import { GitGraphAdapter, openWarpWorldline } from '@git-stunts/git-warp'; +import { ContentUnavailableError } from '../errors.js'; import { resolveHistorySessionEntries } from '../history/session.js'; import { createThinkPlumbing } from '../git.js'; import { @@ -447,14 +448,50 @@ async function getLatestIdByKind(read, kind, options = {}) { } export async function readNodeText(read, nodeId, props = null) { + const contentOid = await resolveNodeContentOid(read, nodeId, props); + const content = await readNodeContent(read, nodeId, contentOid); + if (hasReadableContent(content)) { + return decodeContent(content); + } + + if (contentOid) { + throw new ContentUnavailableError( + `Content for ${nodeId} is unavailable: the read handle has a content oid but no readable content source.`, + ); + } + + return ''; +} + +async function resolveNodeContentOid(read, nodeId, props) { const resolvedProps = props ?? await read.view.getNodeProps(nodeId); - const contentOid = typeof resolvedProps?._content === 'string' - ? resolvedProps._content - : await readNodeContentOid(read, nodeId); - const content = contentOid && read.blobStorage - ? await read.blobStorage.retrieve(contentOid) - : await readContent(read, nodeId); - return content ? new TextDecoder().decode(content) : ''; + if (typeof resolvedProps?._content === 'string') { + return resolvedProps._content; + } + return await readNodeContentOid(read, nodeId); +} + +async function readNodeContent(read, nodeId, contentOid) { + const attachedContent = await readAttachedContent(read, contentOid); + if (hasReadableContent(attachedContent)) { + return attachedContent; + } + return await readContent(read, nodeId); +} + +async function readAttachedContent(read, contentOid) { + if (contentOid && read.blobStorage) { + return await read.blobStorage.retrieve(contentOid); + } + return null; +} + +function decodeContent(content) { + return new TextDecoder().decode(content); +} + +function hasReadableContent(content) { + return content !== null && content !== undefined; } async function readContent(read, nodeId) { diff --git a/test/ports/recent-runtime.test.js b/test/ports/recent-runtime.test.js index 61d2619d..2f517621 100644 --- a/test/ports/recent-runtime.test.js +++ b/test/ports/recent-runtime.test.js @@ -3,7 +3,10 @@ import test from 'node:test'; import { stringifyJson } from '../../src/json.js'; import { CAPTURE_READ_MODEL_ID } from '../../src/store/constants.js'; -import { listRecentStoredEntries } from '../../src/store/runtime.js'; +import { + getStoredEntry, + listRecentStoredEntries, +} from '../../src/store/runtime.js'; test('listRecentStoredEntries does not traverse older edges beyond the requested limit', async () => { const read = createFakeChronologyRead(['entry:newest', 'entry:middle', 'entry:oldest']); @@ -25,6 +28,34 @@ test('listRecentStoredEntries hydrates only the requested sorted capture window' assert.equal(read.queryCalls, 0, 'Expected recent reads to hydrate exact indexed capture IDs.'); }); +test('getStoredEntry keeps the public readContent fallback for content oids', async () => { + const props = createCaptureProps({ _content: 'oid:test-content' }); + const read = createTextReadHandle({ + readContent() { + return new TextEncoder().encode('fallback text'); + }, + }); + + const entry = await getStoredEntry(read, 'entry:fallback-content', props); + + assert.equal(entry.text, 'fallback text'); +}); + +test('getStoredEntry fails when content oid has no readable content source', async () => { + const props = createCaptureProps({ _content: 'oid:missing-content' }); + const read = createTextReadHandle(); + + await assert.rejects( + () => getStoredEntry(read, 'entry:missing-content', props), + (error) => { + assert.equal(error.name, 'ContentUnavailableError'); + assert.equal(error.code, 'CONTENT_UNAVAILABLE'); + assert.match(error.message, /entry:missing-content/); + return true; + }, + ); +}); + function createFakeChronologyRead(ids) { const propsById = createFakePropsById(ids); const textById = new Map(ids.map((id) => [id, `text for ${id}`])); @@ -35,13 +66,32 @@ function createFakeChronologyRead(ids) { return read; } -function createFakePropsById(ids) { - return new Map(ids.map((id, index) => [id, { +function createTextReadHandle(options = {}) { + return { + view: { + getNodeProps() { + throw new Error('Expected test to pass props directly.'); + }, + }, + ...options, + }; +} + +function createCaptureProps(overrides = {}) { + return { kind: 'capture', writerId: 'writer:test', + createdAt: new Date(1770000000000).toISOString(), + sortKey: '1770000000000', + ...overrides, + }; +} + +function createFakePropsById(ids) { + return new Map(ids.map((id, index) => [id, createCaptureProps({ createdAt: new Date(1770000000000 - index).toISOString(), sortKey: String(1770000000000 - index), - }])); + })])); } function createFakeChronologyView(ids, propsById, read) { From 5fe19447359334beb847907b2db0a5c9c4d8f7ba Mon Sep 17 00:00:00 2001 From: James Ross Date: Wed, 8 Jul 2026 09:32:48 -0700 Subject: [PATCH 7/9] fix: stabilize ci followthrough paths --- scripts/repair-v17-mind.mjs | 116 ++++++++++++++++++++++++++++++++---- src/browse-tui/panels.js | 8 ++- src/browse-tui/resolve.js | 24 ++++++++ src/browse-tui/view.js | 4 +- src/cli/commands/capture.js | 2 +- src/mcp/service.js | 2 +- src/store/queries.js | 15 ++++- 7 files changed, 153 insertions(+), 18 deletions(-) diff --git a/scripts/repair-v17-mind.mjs b/scripts/repair-v17-mind.mjs index b1075a66..a1746a7a 100644 --- a/scripts/repair-v17-mind.mjs +++ b/scripts/repair-v17-mind.mjs @@ -388,22 +388,27 @@ export async function runGitWarpUpgrade({ repoDir, graph, dryRun, runner = runCo } async function importGitWarpUpgradeModule(packageRoot) { - const upgradePath = path.join( - packageRoot, - 'dist', + return await importGitWarpDistModule(packageRoot, [ 'scripts', 'migrations', 'v17.0.0', - 'checkpoint-schema-upgrade.js' - ); - if (!existsSync(upgradePath)) { - throw new RepairV17MindError('Installed @git-stunts/git-warp does not expose the v17 checkpoint migration script', { - code: 'repair_v17_mind.v17_upgrade_unavailable', - details: { upgradePath }, + 'checkpoint-schema-upgrade.js', + ], { + code: 'repair_v17_mind.v17_upgrade_unavailable', + message: 'Installed @git-stunts/git-warp does not expose the v17 checkpoint migration script', + }); +} + +async function importGitWarpDistModule(packageRoot, relativeParts, { code, message }) { + const modulePath = path.join(packageRoot, 'dist', ...relativeParts); + if (!existsSync(modulePath)) { + throw new RepairV17MindError(message, { + code, + details: { modulePath }, }); } - return await import(pathToFileURL(upgradePath).href); + return await import(pathToFileURL(modulePath).href); } async function createV17Crypto(packageRoot) { @@ -443,6 +448,7 @@ async function createV17Persistence({ repoDir, runner }) { export async function materializeGraph({ repoDir, graph, runner = runCommand }) { const gitWarp = await import('@git-stunts/git-warp'); const { default: DefaultWarpApp, GitGraphAdapter, WarpApp: NamedWarpApp } = gitWarp; + const packageRoot = resolveGitWarpPackageRoot(); const WarpApp = DefaultWarpApp ?? NamedWarpApp; const persistence = createContentAnchorAwarePersistence( new GitGraphAdapter({ @@ -450,11 +456,13 @@ export async function materializeGraph({ repoDir, graph, runner = runCommand }) }), createGitObjectTypeReader({ repoDir, runner }) ); + const patchJournal = await createV17PatchJournal({ packageRoot, persistence }); const app = await WarpApp.open({ persistence, graphName: graph, writerId: 'think-repair-v17', checkpointPolicy: { every: 100 }, + patchJournal, }); const state = await app.core().materialize(); const checkpoint = await app.core().createCheckpoint(); @@ -468,6 +476,94 @@ export async function materializeGraph({ repoDir, graph, runner = runCommand }) }); } +async function createV17PatchJournal({ packageRoot, persistence }) { + const modules = await importV17PatchJournalModules(packageRoot); + const journalOptions = await createV17PatchJournalOptions({ modules, persistence }); + return new modules.CborPatchJournalAdapter(journalOptions); +} + +async function importV17PatchJournalModules(packageRoot) { + const [ + codecModule, + journalModule, + policyModule, + ] = await Promise.all([ + importV17CborCodecModule(packageRoot), + importV17PatchJournalModule(packageRoot), + importV17CompatibilityPolicyModule(packageRoot), + ]); + + return Object.freeze({ + CborPatchJournalAdapter: journalModule.CborPatchJournalAdapter, + codec: codecModule.default, + compatibilityPolicy: policyModule.V17_SUBSTRATE_MIGRATION_COMPATIBILITY_POLICY, + }); +} + +function importV17CborCodecModule(packageRoot) { + return importGitWarpDistModule(packageRoot, [ + 'src', + 'infrastructure', + 'codecs', + 'CborCodec.js', + ], { + code: 'repair_v17_mind.codec_unavailable', + message: 'Installed @git-stunts/git-warp does not expose the CBOR codec', + }); +} + +function importV17PatchJournalModule(packageRoot) { + return importGitWarpDistModule(packageRoot, [ + 'src', + 'infrastructure', + 'adapters', + 'CborPatchJournalAdapter.js', + ], { + code: 'repair_v17_mind.patch_journal_unavailable', + message: 'Installed @git-stunts/git-warp does not expose the CBOR patch journal adapter', + }); +} + +function importV17CompatibilityPolicyModule(packageRoot) { + return importGitWarpDistModule(packageRoot, [ + 'scripts', + 'migrations', + 'v17.0.0', + 'SubstrateMigrationCompatibilityPolicy.js', + ], { + code: 'repair_v17_mind.compatibility_policy_unavailable', + message: 'Installed @git-stunts/git-warp does not expose the v17 substrate compatibility policy', + }); +} + +async function createV17PatchJournalOptions({ modules, persistence }) { + const writeStorage = typeof persistence.defaultPatchWriteStorage === 'function' + ? persistence.defaultPatchWriteStorage() + : undefined; + const blobStorage = await createV17PatchBlobStorage(persistence, writeStorage); + const journalOptions = { + codec: modules.codec, + blobPort: persistence, + commitPort: persistence, + compatibilityPolicy: modules.compatibilityPolicy, + }; + if (writeStorage !== undefined) { + journalOptions.writeStorage = writeStorage; + } + if (blobStorage !== null) { + journalOptions.blobStorage = blobStorage; + } + + return journalOptions; +} + +async function createV17PatchBlobStorage(persistence, writeStorage) { + if (writeStorage?.strategy === 'git-cas' && typeof persistence.createRuntimeBlobStorage === 'function') { + return await persistence.createRuntimeBlobStorage(); + } + return null; +} + function sizeOf(value) { if (typeof value?.size === 'number') { return value.size; diff --git a/src/browse-tui/panels.js b/src/browse-tui/panels.js index 12da4b9d..139ac4d6 100644 --- a/src/browse-tui/panels.js +++ b/src/browse-tui/panels.js @@ -9,6 +9,7 @@ import { import { currentEntry, currentInspectEntry, + resolveCurrentSessionId, resolveSessionTraversal, computeLogScroll, } from './resolve.js'; @@ -97,11 +98,11 @@ export function buildInspectContent(model, width, ctx) { } export function buildSessionContent(model, width, ctx) { - const entry = currentEntry(model); const sessionTraversal = resolveSessionTraversal(model); const sessionEntries = sessionTraversal.entries; + const sessionId = resolveCurrentSessionId(model, sessionTraversal); - if (!entry.sessionId) { + if (!sessionId) { return styleDim(ctx, 'Session context is not available for this thought yet.'); } @@ -109,6 +110,7 @@ export function buildSessionContent(model, width, ctx) { return styleDim(ctx, 'Session entries are not available for this thought yet.'); } + const entry = currentEntry(model); const currentIndex = sessionEntries.findIndex((candidate) => candidate.id === entry.id); const steps = sessionEntries.map((sessionEntry, index) => { const timestamp = formatCompactWhen(sessionEntry.createdAt); @@ -117,7 +119,7 @@ export function buildSessionContent(model, width, ctx) { }); const header = []; - header.push(`Session ID: ${entry.sessionId}`); + header.push(`Session ID: ${sessionId}`); if (sessionEntries[0]?.createdAt) { header.push(`Started: ${formatWhen(sessionEntries[0].createdAt)}`); } diff --git a/src/browse-tui/resolve.js b/src/browse-tui/resolve.js index f636c813..d2186f65 100644 --- a/src/browse-tui/resolve.js +++ b/src/browse-tui/resolve.js @@ -3,6 +3,7 @@ import { helpShort, } from '@flyingrobots/bijou-tui'; import { resolveHistorySessionEntries } from '../history/session.js'; +import { SESSION_PREFIX } from '../store/constants.js'; import { formatCompactWhen, normalizeWhitespace, clamp, compareEntriesOldestFirst } from './format.js'; import { browseKeymap } from './keymap.js'; @@ -84,6 +85,29 @@ export function resolveSessionTraversal(model) { }; } +export function resolveCurrentSessionId(model, sessionTraversal = resolveSessionTraversal(model)) { + const sessionStart = sessionTraversal.entries[0] ?? null; + return firstSessionId([ + currentEntry(model)?.sessionId, + model.currentWindow?.sessionContext?.sessionId, + sessionStart?.sessionId, + sessionIdFromSortKey(sessionStart?.sortKey), + ]); +} + +function firstSessionId(candidates) { + for (const candidate of candidates) { + if (candidate) { + return candidate; + } + } + return null; +} + +function sessionIdFromSortKey(sortKey) { + return sortKey ? `${SESSION_PREFIX}${sortKey}` : null; +} + export function resolveChronologyPosition(model) { if (model.mode === 'windowed' && !model.chronologyLoaded) { return null; diff --git a/src/browse-tui/view.js b/src/browse-tui/view.js index 1ace2131..e15b7256 100644 --- a/src/browse-tui/view.js +++ b/src/browse-tui/view.js @@ -23,6 +23,7 @@ import { } from './format.js'; import { currentEntry, + resolveCurrentSessionId, resolveNeighbors, resolveSessionTraversal, resolveChronologyPosition, @@ -274,6 +275,7 @@ export function buildThoughtContent(model, width, ctx = null) { const entry = currentEntry(model); const neighbors = resolveNeighbors(model); const sessionTraversal = resolveSessionTraversal(model); + const sessionId = resolveCurrentSessionId(model, sessionTraversal); const chronologyPosition = resolveChronologyPosition(model); const metadata = [ @@ -281,7 +283,7 @@ export function buildThoughtContent(model, width, ctx = null) { `Relative: ${formatRelativeTime(entry.createdAt)}`, ...(chronologyPosition ? [`Position: ${chronologyPosition}`] : []), `Entry ID: ${formatVisibleEntryId(entry.id)}`, - `Session: ${entry.sessionId ?? 'pending'}`, + `Session: ${sessionId ?? 'pending'}`, `Session Position: ${formatSessionPosition(sessionTraversal)}`, ].join('\n'); diff --git a/src/cli/commands/capture.js b/src/cli/commands/capture.js index 72f68e91..508cd6c6 100644 --- a/src/cli/commands/capture.js +++ b/src/cli/commands/capture.js @@ -10,7 +10,7 @@ import { saveRawCapture, } from '../../store.js'; -const CAPTURE_FOLLOWTHROUGH_TIMEOUT_MS = 3_000; +const CAPTURE_FOLLOWTHROUGH_TIMEOUT_MS = 6_000; const CAPTURE_FOLLOWTHROUGH_DEFERRED = Object.freeze({ status: 'deferred' }); const NO_GRAPH_MIGRATION_STATUS = Object.freeze({ currentGraphModelVersion: null, diff --git a/src/mcp/service.js b/src/mcp/service.js index 6ea03210..cfa4b6db 100644 --- a/src/mcp/service.js +++ b/src/mcp/service.js @@ -39,7 +39,7 @@ import { StatsOutcome, } from './result.js'; -const CAPTURE_FOLLOWTHROUGH_TIMEOUT_MS = 5_000; +const CAPTURE_FOLLOWTHROUGH_TIMEOUT_MS = 6_000; const CAPTURE_FOLLOWTHROUGH_DEFERRED = Object.freeze({ status: 'deferred' }); const CAPTURE_FOLLOWTHROUGH_DEFERRED_WARNING = 'Capture followthrough deferred; raw thought saved locally before derived graph updates completed.'; diff --git a/src/store/queries.js b/src/store/queries.js index 1131b8f0..66df981a 100644 --- a/src/store/queries.js +++ b/src/store/queries.js @@ -417,11 +417,11 @@ async function buildBrowseWindow(read, entryId) { return null; } - const current = toBrowseEntry(currentEntry); + const sessionAttribution = await getSessionAttributionReceiptIfPresent(read, currentEntry); + const current = toBrowseEntryWithSession(currentEntry, sessionAttribution?.sessionId ?? null); const neighbors = await resolveChronologyNeighbors(read, currentEntry); const older = neighbors.older ? toBrowseEntry(neighbors.older) : null; const newer = neighbors.newer ? toBrowseEntry(neighbors.newer) : null; - const sessionAttribution = await getSessionAttributionReceiptIfPresent(read, currentEntry); const sessionTraversal = await resolveHistorySessionTraversal(read, current); return Object.freeze({ @@ -465,3 +465,14 @@ async function buildBrowseWindow(read, entryId) { function resolveChronologyNeighbors(read, currentEntry) { return getChronologyNeighborEntries(read, currentEntry); } + +function toBrowseEntryWithSession(entry, sessionId) { + const browseEntry = toBrowseEntry(entry); + if (!sessionId || browseEntry.sessionId) { + return browseEntry; + } + return Object.freeze({ + ...browseEntry, + sessionId, + }); +} From 09ca54581910292a484860808cf90f036fb77202 Mon Sep 17 00:00:00 2001 From: James Ross Date: Wed, 8 Jul 2026 09:51:33 -0700 Subject: [PATCH 8/9] fix: clear review change requests --- src/browse-tui/panels.js | 48 +++++++++++++++++++++++------------ src/store/constants.js | 1 + src/store/read-model.js | 2 +- src/store/runtime.js | 7 ++--- test/ports/browse-tui.test.js | 26 +++++++++++++++++++ 5 files changed, 62 insertions(+), 22 deletions(-) diff --git a/src/browse-tui/panels.js b/src/browse-tui/panels.js index 139ac4d6..7543b782 100644 --- a/src/browse-tui/panels.js +++ b/src/browse-tui/panels.js @@ -101,38 +101,54 @@ export function buildSessionContent(model, width, ctx) { const sessionTraversal = resolveSessionTraversal(model); const sessionEntries = sessionTraversal.entries; const sessionId = resolveCurrentSessionId(model, sessionTraversal); + const entry = currentEntry(model); + const unavailableMessage = sessionUnavailableMessage(sessionId, sessionEntries, entry); + + if (unavailableMessage) { + return styleDim(ctx, unavailableMessage); + } + + const currentIndex = sessionEntries.findIndex((candidate) => candidate.id === entry.id); + const steps = buildSessionSteps(sessionEntries, entry.id, currentIndex); + const header = buildSessionHeader(sessionId, sessionEntries, sessionTraversal); + + if (ctx) { + return `${header.join('\n')}\n\n${stepper(steps, { current: Math.max(0, currentIndex), ctx })}`; + } + + // Fallback for script path (no bijou context) + return `${header.join('\n')}\n\n${steps.map((s, i) => `${i === currentIndex ? '>' : ' '} ${s.label}`).join('\n')}`; +} - if (!sessionId) { - return styleDim(ctx, 'Session context is not available for this thought yet.'); +function sessionUnavailableMessage(sessionId, sessionEntries, entry) { + if (!sessionId || !entry) { + return 'Session context is not available for this thought yet.'; } if (sessionEntries.length === 0) { - return styleDim(ctx, 'Session entries are not available for this thought yet.'); + return 'Session entries are not available for this thought yet.'; } - const entry = currentEntry(model); - const currentIndex = sessionEntries.findIndex((candidate) => candidate.id === entry.id); - const steps = sessionEntries.map((sessionEntry, index) => { + return null; +} + +function buildSessionSteps(sessionEntries, entryId, currentIndex) { + return sessionEntries.map((sessionEntry, index) => { const timestamp = formatCompactWhen(sessionEntry.createdAt); - const label = `${formatSessionEntryLabel(sessionEntry, entry.id, index, currentIndex)} ${formatVisibleEntryId(sessionEntry.id)} ${timestamp}`; + const label = `${formatSessionEntryLabel(sessionEntry, entryId, index, currentIndex)} ${formatVisibleEntryId(sessionEntry.id)} ${timestamp}`; return { label }; }); +} - const header = []; - header.push(`Session ID: ${sessionId}`); +function buildSessionHeader(sessionId, sessionEntries, sessionTraversal) { + const header = [`Session ID: ${sessionId}`]; if (sessionEntries[0]?.createdAt) { header.push(`Started: ${formatWhen(sessionEntries[0].createdAt)}`); } if (sessionTraversal.position && sessionTraversal.count) { header.push(`Position: ${sessionTraversal.position} of ${sessionTraversal.count}`); } - - if (ctx) { - return `${header.join('\n')}\n\n${stepper(steps, { current: Math.max(0, currentIndex), ctx })}`; - } - - // Fallback for script path (no bijou context) - return `${header.join('\n')}\n\n${steps.map((s, i) => `${i === currentIndex ? '>' : ' '} ${s.label}`).join('\n')}`; + return header; } export function buildLogContent(model, width, ctx) { diff --git a/src/store/constants.js b/src/store/constants.js index 93ad7ef0..40976478 100644 --- a/src/store/constants.js +++ b/src/store/constants.js @@ -24,6 +24,7 @@ export const PIPELINE_RUN_PREFIX = 'pipeline_run:'; export const READ_MODEL_PREFIX = 'read_model:'; export const GRAPH_META_ID = 'meta:graph'; export const CAPTURE_READ_MODEL_ID = `${READ_MODEL_PREFIX}capture:index`; +export const EXACT_READ_UNAVAILABLE = Symbol.for('think.exactReadUnavailable'); // Standing classification node IDs export const CLASSIFICATIONS = Object.freeze([ diff --git a/src/store/read-model.js b/src/store/read-model.js index f3045ca4..59c70536 100644 --- a/src/store/read-model.js +++ b/src/store/read-model.js @@ -3,6 +3,7 @@ import { createHash } from 'node:crypto'; import { parseJson, stringifyJson } from '../json.js'; import { CAPTURE_READ_MODEL_ID, + EXACT_READ_UNAVAILABLE, READ_MODEL_PREFIX, SCHEMA_VERSION, } from './constants.js'; @@ -33,7 +34,6 @@ const CAPTURE_RECORD_PROVENANCE_FIELDS = Object.freeze([ ['captureSourceApp', 'sourceApp'], ['captureSourceURL', 'sourceURL'], ]); -const EXACT_READ_UNAVAILABLE = Symbol.for('think.exactReadUnavailable'); export function ambientReadModelId(key, value) { const fingerprint = createHash('sha256') diff --git a/src/store/runtime.js b/src/store/runtime.js index ee49c991..38636a30 100644 --- a/src/store/runtime.js +++ b/src/store/runtime.js @@ -6,6 +6,7 @@ import { createThinkPlumbing } from '../git.js'; import { ARTIFACT_PREFIX, ENTRY_PREFIX, + EXACT_READ_UNAVAILABLE, GRAPH_META_ID, GRAPH_MODEL_VERSION, GRAPH_NAME, @@ -115,7 +116,6 @@ export class BaseEntry { const WRITER_CAS_CONFLICT_TEXT = 'writer ref was updated by another process'; const DEFAULT_PATCH_MAX_ATTEMPTS = 3; -const EXACT_READ_UNAVAILABLE = Symbol.for('think.exactReadUnavailable'); const warpWorldlineCache = new Map(); const runtimeBlobStorageCache = new Map(); const opticBasisCache = new WeakMap(); @@ -227,16 +227,13 @@ async function createWorldlineProductReadHandle({ } function createWorldlineExactPropReader(worldline) { - let basisPromise = null; - return async (nodeId, key) => { if (opticBasisCache.get(worldline) === false) { return EXACT_READ_UNAVAILABLE; } try { - basisPromise ??= prepareCachedOpticBasis(worldline); - await basisPromise; + await prepareCachedOpticBasis(worldline); const result = await worldline.optic().node(nodeId).prop(key).read(); return result.exists ? result.value : undefined; diff --git a/test/ports/browse-tui.test.js b/test/ports/browse-tui.test.js index 26f9d3d0..920513fa 100644 --- a/test/ports/browse-tui.test.js +++ b/test/ports/browse-tui.test.js @@ -3,6 +3,7 @@ import test from 'node:test'; import { createResolved } from '@flyingrobots/bijou'; import { createWindowedBrowseModel } from '../../src/browse-tui/model.js'; +import { buildSessionContent } from '../../src/browse-tui/panels.js'; import { thinkShellThemes, thinkThemes } from '../../src/browse-tui/theme.js'; import { renderBrowseModel } from '../../src/browse-tui/view.js'; import * as styleExports from '../../src/browse-tui/style.js'; @@ -51,6 +52,31 @@ test('windowed browse initializes with no drawer open', () => { ); }); +test('session panel falls back when session context exists without a current entry', () => { + const content = buildSessionContent({ + mode: 'windowed', + currentIndex: 0, + entries: [], + currentWindow: { + current: null, + sessionContext: { + sessionId: 'session:1780000000000-test', + sessionPosition: 1, + sessionCount: 1, + }, + sessionEntries: [{ + id: 'entry:1780000000000-test', + createdAt: '2026-05-13T00:00:00.000Z', + text: 'orphaned session entry', + sortKey: '1780000000000', + }], + sessionSteps: [], + }, + }, 80, null); + + assert.match(content, /Session context is not available for this thought yet/); +}); + test('all browse shell themes resolve with status tokens', () => { const requiredTokens = [ 'surface.primary', From cc0ef303cbabe83ee821acd1492c04db577c3186 Mon Sep 17 00:00:00 2001 From: James Ross Date: Wed, 8 Jul 2026 10:01:54 -0700 Subject: [PATCH 9/9] fix: make missing content fallback explicit --- src/store/runtime.js | 12 +++++++++++- test/ports/recent-runtime.test.js | 14 ++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/src/store/runtime.js b/src/store/runtime.js index 38636a30..fed0af60 100644 --- a/src/store/runtime.js +++ b/src/store/runtime.js @@ -220,12 +220,19 @@ async function createWorldlineProductReadHandle({ view: worldline.live(), contentCore: null, blobStorage, - readContent: null, + readContent: readUnavailableRuntimeContent, + readContentRequiresContentOid: true, readNodeProp: createWorldlineExactPropReader(worldline), writerId: worldline.writerId, }; } +function readUnavailableRuntimeContent(nodeId) { + throw new ContentUnavailableError( + `Content for ${nodeId} is unavailable: this git-warp runtime exposes no public content reader fallback.`, + ); +} + function createWorldlineExactPropReader(worldline) { return async (nodeId, key) => { if (opticBasisCache.get(worldline) === false) { @@ -473,6 +480,9 @@ async function readNodeContent(read, nodeId, contentOid) { if (hasReadableContent(attachedContent)) { return attachedContent; } + if (!contentOid && read.readContentRequiresContentOid) { + return null; + } return await readContent(read, nodeId); } diff --git a/test/ports/recent-runtime.test.js b/test/ports/recent-runtime.test.js index 2f517621..acd97ded 100644 --- a/test/ports/recent-runtime.test.js +++ b/test/ports/recent-runtime.test.js @@ -6,6 +6,7 @@ import { CAPTURE_READ_MODEL_ID } from '../../src/store/constants.js'; import { getStoredEntry, listRecentStoredEntries, + readNodeText, } from '../../src/store/runtime.js'; test('listRecentStoredEntries does not traverse older edges beyond the requested limit', async () => { @@ -56,6 +57,19 @@ test('getStoredEntry fails when content oid has no readable content source', asy ); }); +test('readNodeText skips marked content readers for nodes without content oids', async () => { + const read = createTextReadHandle({ + readContentRequiresContentOid: true, + readContent() { + throw new Error('Expected empty nodes not to read external content.'); + }, + }); + + const text = await readNodeText(read, 'entry:empty-content', createCaptureProps()); + + assert.equal(text, ''); +}); + function createFakeChronologyRead(ids) { const propsById = createFakePropsById(ids); const textById = new Map(ids.map((id) => [id, `text for ${id}`]));