From 10c6496efe55e9528cd34839a778fa1096e71565 Mon Sep 17 00:00:00 2001 From: Chris Date: Wed, 12 Aug 2026 20:02:01 -0400 Subject: [PATCH 01/23] Make package.json the suggestion and the depot lock the authority. Drop jj-cli so Timeline is deckd-only; Hub keeps browse on screen across view transitions, rehashes live integrity at serve time, and walks four teaching stories. Co-authored-by: Cursor --- CLI.md | 2 +- apps/hub/hub.mjs | 160 +- apps/hub/hub/app/browse/browse.js | 30 +- apps/hub/hub/app/browse/importmap.json | 38 - apps/hub/hub/app/browse/index.html | 17 +- apps/hub/hub/app/browse/package.json | 15 + apps/hub/hub/app/run/importmap.json | 23 - apps/hub/hub/app/run/index.html | 17 +- apps/hub/hub/app/run/package.json | 12 + apps/hub/hub/app/run/run.js | 38 +- apps/hub/hub/app/start/importmap.json | 23 - apps/hub/hub/app/start/index.html | 23 +- apps/hub/hub/app/start/package.json | 12 + apps/hub/hub/app/start/start.js | 141 +- apps/hub/hub/app/track/importmap.json | 32 - apps/hub/hub/app/track/index.html | 17 +- apps/hub/hub/app/track/package.json | 13 + apps/hub/hub/app/track/track.js | 183 +- apps/hub/hub/importmap.json | 91 + apps/hub/hub/theme/latte/importmap.json | 9 - apps/hub/hub/theme/latte/package.json | 4 + apps/hub/hub/theme/mocha/importmap.json | 9 - apps/hub/hub/theme/mocha/package.json | 4 + .../theme/tokyo-night-light/importmap.json | 9 - .../hub/theme/tokyo-night-light/package.json | 4 + apps/hub/hub/ui/activity/activity.js | 23 +- apps/hub/hub/ui/activity/importmap.json | 10 - apps/hub/hub/ui/activity/package.json | 6 + apps/hub/hub/ui/nav/importmap.json | 10 - apps/hub/hub/ui/nav/nav.js | 207 +- apps/hub/hub/ui/nav/package.json | 6 + apps/hub/hub/ui/theme/boot.css | 37 + apps/hub/hub/ui/theme/boot.js | 52 + apps/hub/hub/ui/theme/importmap.json | 9 - apps/hub/hub/ui/theme/package.json | 4 + apps/hub/hub/ui/theme/theme.css | 88 +- apps/hub/hub/ui/timeline/importmap.json | 10 - apps/hub/hub/ui/timeline/package.json | 6 + apps/hub/hub/ui/tour/package.json | 9 + apps/hub/hub/ui/tour/tour.js | 661 ++ apps/hub/setup.mjs | 247 +- ...-a-package-resolves-through-its-own-map.md | 8 + docs/deck-hub-tour.md | 156 + docs/deck-lockfile-thought-experiment.md | 486 ++ docs/deck-package-json-and-depot-lock.md | 177 + docs/deck-unfrozen-mode.md | 227 + .../codelist/depot/app/viewer/package.json | 11 + examples/codelist/depot/app/viewer/viewer.js | 2 + examples/codelist/depot/importmap.json | 15 + .../depot/app/picker/package.json | 12 + .../depot/importmap.json | 20 + examples/deckd-demo/depot/importmap.json | 3 + examples/deckd-steps/depot/importmap.json | 15 + .../deckd-steps/depot/vale/sale/copy.json | 12 + .../depot/vale/sale/importmap.json | 0 .../deckd-steps/depot/vale/sale/index.html | 93 + .../depot/vale/sale/media/fabric.jpg | Bin 0 -> 251770 bytes .../depot/vale/sale/media/hero.jpg | Bin 0 -> 201394 bytes .../depot/vale/sale/media/rack.jpg | Bin 0 -> 264979 bytes .../deckd-steps/depot/vale/sale/package.json | 11 + examples/deckd-steps/depot/vale/sale/sale.css | 238 + examples/deckd-steps/depot/vale/sale/sale.js | 10 + .../files/literata-latin-400-italic.woff2 | Bin 0 -> 21148 bytes .../files/literata-latin-400-normal.woff2 | Bin 0 -> 20420 bytes .../files/literata-latin-600-normal.woff2 | Bin 0 -> 21860 bytes .../type/files/syne-latin-400-normal.woff2 | Bin 0 -> 13264 bytes .../type/files/syne-latin-700-normal.woff2 | Bin 0 -> 14072 bytes .../type/files/syne-latin-800-normal.woff2 | Bin 0 -> 13684 bytes .../deckd-steps/depot/vale/type/fonts.css | 45 + .../depot/vale/type/importmap.json | 0 .../deckd-steps/depot/vale/type/package.json | 8 + .../deckd-steps/depot/vale/type/readme.md | 3 + .../depot/vale/ui/importmap.json | 0 examples/deckd-steps/depot/vale/ui/index.js | 7 + examples/deckd-steps/depot/vale/ui/motion.js | 68 + .../deckd-steps/depot/vale/ui/package.json | 8 + examples/deckd-steps/depot/vale/ui/readme.md | 3 + examples/deckd-steps/depot/vale/ui/tokens.js | 12 + .../depot/app/library/package.json | 18 + examples/field-library/depot/importmap.json | 50 + .../depot/app/viewer/package.json | 11 + examples/fork-a-language/depot/importmap.json | 15 + .../fork-an-app/depot/app/viewer/package.json | 11 + examples/fork-an-app/depot/importmap.json | 15 + examples/hub/depot/importmap.json | 3 + examples/potluck/depot/.gitignore | 3 + examples/potluck/depot/importmap.json | 3 + examples/potluck/steps.mjs | 2 +- examples/prosemirror/demo.mjs | 21 +- .../prosemirror/depot/app/editor/editor.js | 6 +- .../prosemirror/depot/app/editor/package.json | 19 + examples/prosemirror/depot/importmap.json | 54 + examples/remix/depot/importmap.json | 16 + examples/shared/depot/importmap.json | 3 + examples/shared/publish.mjs | 15 +- examples/shared/ui/pills/package.json | 6 + examples/three-js/demo.mjs | 7 +- .../three-js/depot/app/studio/package.json | 11 + examples/three-js/depot/app/studio/studio.js | 8 +- examples/three-js/depot/importmap.json | 15 + examples/two-agents/depot/app/board/board.js | 6 +- .../two-agents/depot/app/board/package.json | 13 + examples/two-agents/depot/importmap.json | 25 + examples/vale-sale/depot/importmap.json | 15 + examples/vale-sale/depot/vale/sale/index.html | 4 +- .../vale-sale/depot/vale/sale/package.json | 11 + examples/vale-sale/depot/vale/sale/sale.css | 4 +- examples/vale-sale/depot/vale/sale/sale.js | 4 +- .../vale-sale/depot/vale/type/package.json | 6 + examples/vale-sale/depot/vale/ui/package.json | 6 + examples/vale-sale/steps.mjs | 2 +- .../variations/depot/app/chooser/chooser.js | 4 + .../variations/depot/app/chooser/package.json | 11 + .../variations/depot/app/preview/package.json | 11 + examples/variations/depot/importmap.json | 22 + packages/cli/src/commands/depend.ts | 66 +- packages/cli/src/commands/propose.ts | 18 +- packages/cli/src/commands/recall.ts | 14 +- packages/cli/src/commands/start.ts | 22 +- packages/cli/src/help.ts | 6 +- packages/cli/tests/cli-test.ts | 71 +- packages/conformance/src/at-rest.ts | 42 +- packages/conformance/src/fixture.ts | 76 +- packages/core/package.json | 1 + packages/core/src/deck-scan.ts | 9 +- packages/core/src/fork.ts | 32 +- packages/core/src/import-map-pack.ts | 96 +- packages/core/src/import-map.ts | 16 +- packages/core/src/lock.ts | 968 ++- packages/core/src/node.ts | 16 +- packages/core/src/package-json.ts | 213 + packages/core/src/resolve.ts | 7 +- packages/core/src/store.ts | 28 +- packages/core/src/verify-links.ts | 77 +- packages/core/tests/entries-test.ts | 2 + packages/core/tests/fork-test.ts | 37 +- packages/core/tests/index.ts | 1 + packages/core/tests/lock-test.ts | 538 +- packages/core/tests/offer-test.ts | 32 +- packages/core/tests/package-json-test.ts | 107 + packages/core/tests/signature-test.ts | 9 +- packages/core/tests/store-test.ts | 42 +- packages/core/tests/verify-links-test.ts | 30 +- packages/history/package.json | 3 +- packages/history/src/backend.ts | 16 +- packages/history/src/create.ts | 81 +- packages/history/src/historyd.ts | 20 +- packages/history/src/jj-cli.ts | 405 -- packages/history/tests/historyd-test.ts | 4 +- packages/history/tests/index.ts | 2 - packages/history/tests/jj-cli-test.ts | 208 - packages/history/tests/no-gitlink-test.ts | 147 - packages/server/src/activity.ts | 135 +- packages/server/src/browser-ui.ts | 2 +- packages/server/src/depot.ts | 52 +- packages/server/src/inject-importmap.ts | 15 +- packages/server/src/server.ts | 567 +- packages/server/src/watcher.ts | 26 +- packages/server/tests/browse-test.ts | 27 +- packages/server/tests/history-test.ts | 276 +- .../server/tests/history-unfrozen-test.ts | 386 ++ packages/server/tests/index.ts | 2 + packages/server/tests/inherit-serve-test.ts | 63 +- .../server/tests/live-autopublish-test.ts | 35 +- packages/server/tests/live-hmr-test.ts | 12 +- packages/server/tests/offers-test.ts | 71 +- packages/server/tests/seal-on-watch-test.ts | 59 +- packages/server/tests/server-test.ts | 65 +- packages/server/tests/unfrozen-serve-test.ts | 232 + packages/tools/src/derive.ts | 35 +- packages/tools/src/vendor-npm.ts | 6 + packages/tools/src/vendor-tree.ts | 7 +- packages/tools/tests/derive-test.ts | 17 +- packages/tools/tests/rebase-daemon-test.ts | 26 + packages/tools/tests/vendor-npm-test.ts | 8 +- pnpm-lock.yaml | 2 + reference/html.spec.whatwg.org/README.md | 27 + .../html.spec.whatwg.org/import-maps.html | 984 +++ .../html.spec.whatwg.org/webappapis.html | 5594 +++++++++++++++++ 179 files changed, 14148 insertions(+), 2462 deletions(-) delete mode 100644 apps/hub/hub/app/browse/importmap.json create mode 100644 apps/hub/hub/app/browse/package.json delete mode 100644 apps/hub/hub/app/run/importmap.json create mode 100644 apps/hub/hub/app/run/package.json delete mode 100644 apps/hub/hub/app/start/importmap.json create mode 100644 apps/hub/hub/app/start/package.json delete mode 100644 apps/hub/hub/app/track/importmap.json create mode 100644 apps/hub/hub/app/track/package.json create mode 100644 apps/hub/hub/importmap.json delete mode 100644 apps/hub/hub/theme/latte/importmap.json create mode 100644 apps/hub/hub/theme/latte/package.json delete mode 100644 apps/hub/hub/theme/mocha/importmap.json create mode 100644 apps/hub/hub/theme/mocha/package.json delete mode 100644 apps/hub/hub/theme/tokyo-night-light/importmap.json create mode 100644 apps/hub/hub/theme/tokyo-night-light/package.json delete mode 100644 apps/hub/hub/ui/activity/importmap.json create mode 100644 apps/hub/hub/ui/activity/package.json delete mode 100644 apps/hub/hub/ui/nav/importmap.json create mode 100644 apps/hub/hub/ui/nav/package.json create mode 100644 apps/hub/hub/ui/theme/boot.css create mode 100644 apps/hub/hub/ui/theme/boot.js delete mode 100644 apps/hub/hub/ui/theme/importmap.json create mode 100644 apps/hub/hub/ui/theme/package.json delete mode 100644 apps/hub/hub/ui/timeline/importmap.json create mode 100644 apps/hub/hub/ui/timeline/package.json create mode 100644 apps/hub/hub/ui/tour/package.json create mode 100644 apps/hub/hub/ui/tour/tour.js create mode 100644 docs/deck-hub-tour.md create mode 100644 docs/deck-lockfile-thought-experiment.md create mode 100644 docs/deck-package-json-and-depot-lock.md create mode 100644 docs/deck-unfrozen-mode.md create mode 100644 examples/codelist/depot/app/viewer/package.json create mode 100644 examples/codelist/depot/importmap.json create mode 100644 examples/contribute-to-codelist/depot/app/picker/package.json create mode 100644 examples/contribute-to-codelist/depot/importmap.json create mode 100644 examples/deckd-demo/depot/importmap.json create mode 100644 examples/deckd-steps/depot/importmap.json create mode 100644 examples/deckd-steps/depot/vale/sale/copy.json rename examples/{vale-sale => deckd-steps}/depot/vale/sale/importmap.json (100%) create mode 100644 examples/deckd-steps/depot/vale/sale/index.html create mode 100644 examples/deckd-steps/depot/vale/sale/media/fabric.jpg create mode 100644 examples/deckd-steps/depot/vale/sale/media/hero.jpg create mode 100644 examples/deckd-steps/depot/vale/sale/media/rack.jpg create mode 100644 examples/deckd-steps/depot/vale/sale/package.json create mode 100644 examples/deckd-steps/depot/vale/sale/sale.css create mode 100644 examples/deckd-steps/depot/vale/sale/sale.js create mode 100644 examples/deckd-steps/depot/vale/type/files/literata-latin-400-italic.woff2 create mode 100644 examples/deckd-steps/depot/vale/type/files/literata-latin-400-normal.woff2 create mode 100644 examples/deckd-steps/depot/vale/type/files/literata-latin-600-normal.woff2 create mode 100644 examples/deckd-steps/depot/vale/type/files/syne-latin-400-normal.woff2 create mode 100644 examples/deckd-steps/depot/vale/type/files/syne-latin-700-normal.woff2 create mode 100644 examples/deckd-steps/depot/vale/type/files/syne-latin-800-normal.woff2 create mode 100644 examples/deckd-steps/depot/vale/type/fonts.css rename examples/{vale-sale => deckd-steps}/depot/vale/type/importmap.json (100%) create mode 100644 examples/deckd-steps/depot/vale/type/package.json create mode 100644 examples/deckd-steps/depot/vale/type/readme.md rename examples/{vale-sale => deckd-steps}/depot/vale/ui/importmap.json (100%) create mode 100644 examples/deckd-steps/depot/vale/ui/index.js create mode 100644 examples/deckd-steps/depot/vale/ui/motion.js create mode 100644 examples/deckd-steps/depot/vale/ui/package.json create mode 100644 examples/deckd-steps/depot/vale/ui/readme.md create mode 100644 examples/deckd-steps/depot/vale/ui/tokens.js create mode 100644 examples/field-library/depot/app/library/package.json create mode 100644 examples/field-library/depot/importmap.json create mode 100644 examples/fork-a-language/depot/app/viewer/package.json create mode 100644 examples/fork-a-language/depot/importmap.json create mode 100644 examples/fork-an-app/depot/app/viewer/package.json create mode 100644 examples/fork-an-app/depot/importmap.json create mode 100644 examples/hub/depot/importmap.json create mode 100644 examples/potluck/depot/.gitignore create mode 100644 examples/potluck/depot/importmap.json create mode 100644 examples/prosemirror/depot/app/editor/package.json create mode 100644 examples/prosemirror/depot/importmap.json create mode 100644 examples/remix/depot/importmap.json create mode 100644 examples/shared/depot/importmap.json create mode 100644 examples/shared/ui/pills/package.json create mode 100644 examples/three-js/depot/app/studio/package.json create mode 100644 examples/three-js/depot/importmap.json create mode 100644 examples/two-agents/depot/app/board/package.json create mode 100644 examples/two-agents/depot/importmap.json create mode 100644 examples/vale-sale/depot/importmap.json create mode 100644 examples/vale-sale/depot/vale/sale/package.json create mode 100644 examples/vale-sale/depot/vale/type/package.json create mode 100644 examples/vale-sale/depot/vale/ui/package.json create mode 100644 examples/variations/depot/app/chooser/package.json create mode 100644 examples/variations/depot/app/preview/package.json create mode 100644 examples/variations/depot/importmap.json create mode 100644 packages/core/src/package-json.ts create mode 100644 packages/core/tests/package-json-test.ts delete mode 100644 packages/history/src/jj-cli.ts delete mode 100644 packages/history/tests/jj-cli-test.ts delete mode 100644 packages/history/tests/no-gitlink-test.ts create mode 100644 packages/server/tests/history-unfrozen-test.ts create mode 100644 packages/server/tests/unfrozen-serve-test.ts create mode 100644 reference/html.spec.whatwg.org/README.md create mode 100644 reference/html.spec.whatwg.org/import-maps.html create mode 100644 reference/html.spec.whatwg.org/webappapis.html diff --git a/CLI.md b/CLI.md index fc50392..a204ce3 100644 --- a/CLI.md +++ b/CLI.md @@ -54,7 +54,7 @@ That scaffolds a depot, serves it, and prints a URL. Open it, edit `app.js`, reload. Then the loop that runs for the rest of the day: ```bash -deck serve --root ./work --history auto +deck serve --root ./work --history deckd ``` `--root` is a directory *of* depots: every child directory becomes one, diff --git a/apps/hub/hub.mjs b/apps/hub/hub.mjs index c570caf..fe6bdac 100644 --- a/apps/hub/hub.mjs +++ b/apps/hub/hub.mjs @@ -123,6 +123,11 @@ async function demoDepots() { // alias — otherwise the depot would shadow the app, which is the correct // precedence but a confusing thing to arrange for yourself on purpose. if (APPS.some((app) => app.route === entry.name)) continue; + // `examples/hub` is a miniature twin of this process for docs — same + // depot name, empty tree. Mounting it after the real Hub made + // `byName.get('hub')` point at the empty one, so /start…/track 404'd + // every file that actually lives in apps/hub/hub. + if (entry.name === 'hub') continue; found.push({ name: entry.name, dir, @@ -170,20 +175,14 @@ async function serveSidecar(depot, path, res) { // --------------------------------------------------------------------------- -// Tier-2 history is on, and `DECK_HUB_HISTORY=0` turns it off. -// -// It was briefly opt-in, because jj colocates by default and a colocated -// `.git` inside a depot that lives in another git repository makes `git add` -// record a gitlink and silently drop every file underneath. That is fixed at -// the root now — the history backend initialises non-colocated, and -// packages/history/tests/no-gitlink-test.ts fails loudly if it ever stops — -// so there is no longer a reason to run the Hub with its own live loop -// switched off. +// Tier-2 Timeline is on via deckd; `DECK_HUB_HISTORY=0` turns it off. const WANT_HISTORY = process.env.DECK_HUB_HISTORY !== '0'; -// Prefer packages/deckd when DECKD_URL (or DECK_HISTORYD_URL) is set. -const HISTORY_KIND = - process.env.DECK_HUB_HISTORY_KIND ?? - (process.env.DECKD_URL || process.env.DECK_HISTORYD_URL ? 'deckd' : 'auto'); +// Without DECKD_URL the Hub stays Versions-only — never a local jj watcher. +if (WANT_HISTORY && !process.env.DECKD_URL && !process.env.DECK_HISTORYD_URL) { + console.warn( + ' history: DECKD_URL unset — Timeline off (run ./scripts/run-hub-with-deckd.sh)', + ); +} let hub = { ...HUB, @@ -192,23 +191,22 @@ let hub = { ? ( await createHistory({ dir: HUB.dir, - kind: HISTORY_KIND, + kind: 'deckd', onError: (error) => console.error(` history: ${error}`), }) ).backend : undefined, }; -// Demo depots already carry `.jj` when a demo (or `deck serve`) sealed them. -// /run loads Timeline from `//_history` on THIS server — without a -// history backend attached here, that route answers `no-history` and the -// tape reads "no steps here yet" even when the seals exist on disk. +// Demo depots seal through the same deckd. /run loads Timeline from +// `//_history` on THIS server — without a backend attached here that +// route answers `no-history` even when seals exist on disk. let demos = await demoDepots(); if (WANT_HISTORY) { demos = await Promise.all( demos.map(async (depot) => { let attachment = await createHistory({ dir: depot.dir, - kind: HISTORY_KIND, + kind: 'deckd', onError: (error) => console.error(` history ${depot.name}: ${error}`), }); return { ...depot, history: attachment.backend }; @@ -337,104 +335,8 @@ for (let depot of demos) { } // --------------------------------------------------------------------------- -// HEAT — where the work is happening, as one number per path. -// -// A list of ten depots that looks identical whether a depot was touched four -// seconds ago or four months ago is a list that cannot be navigated. What a -// reader wants is not "sort by date" — it is to glance at a list, see which -// row is warm, go into it, and see which row in THERE is warm, all the way -// down to the file. Following heat. -// -// That only works if heat AGGREGATES, and it only aggregates cleanly if it is -// a sum. So heat is a decayed count: -// -// score(path) = Σ over changes to path of e^(−age / τ) -// -// A change now is worth 1, a change τ ago is worth 0.37, a change 5τ ago is -// worth 0.007. Two properties fall out for free, and both are what makes this -// navigable rather than decorative: -// -// · A parent's score is the SUM of its children's. No special case, no -// second formula — a directory is hot because of what is inside it, and -// the arithmetic says so. -// · It is a RATE, not a timestamp. Fourteen edits in six minutes outranks -// one edit ten minutes ago, which is the ordering a person actually -// means by "where is the action". -// -// The absolute number is meaningless, so nothing displays it. Each view -// normalises against the hottest row it is showing, which keeps the full -// range in use and makes the only question the eye has to answer — "which of -// these is hotter?" — the one it is good at. -const TAU_MS = 2 * 60 * 60 * 1000; // 2 hours: a working session, roughly -const HEAT_WINDOW_MS = 48 * 60 * 60 * 1000; // beyond this, e^-24 is noise - -/** Every change this depot can account for, as [path, epochMs] pairs. */ -async function depotChanges(depot) { - let since = Date.now() - HEAT_WINDOW_MS; - // Tier 2: the seal log is an exact record of every save and what it touched. - if (depot.history) { - let seals = await depot.history.list(depot.dir).catch(() => []); - let changes = []; - for (let seal of seals) { - let when = Date.parse(seal.timestamp); - if (!Number.isFinite(when) || when < since) continue; - for (let entry of seal.filesSummary ?? []) { - // "M app/run/run.js" — the letter is the verb, the rest is the path. - let path = entry.replace(/^[A-Z]\s+/, ''); - if (path) changes.push([path, when]); - } - } - return changes; - } - // Tier 1: no seal log, so mtime is the only witness. It records the LAST - // change and not how many, which under-reports a busy file rather than - // inventing a number — the honest failure direction. - let changes = []; - async function walk(dir, prefix) { - let entries = await readdir(dir, { withFileTypes: true }).catch(() => []); - for (let entry of entries) { - if (entry.name.startsWith('.')) continue; - let path = prefix ? `${prefix}/${entry.name}` : entry.name; - if (entry.isDirectory()) { - await walk(join(dir, entry.name), path); - } else { - let info = await stat(join(dir, entry.name)).catch(() => undefined); - if (info && info.mtimeMs >= since) changes.push([path, info.mtimeMs]); - } - } - } - await walk(depot.dir, ''); - return changes; -} - -// Walking nine depots per request would make heat cost more than the data it -// decorates. It is recomputed when something actually changes, and otherwise -// at most this often. -const HEAT_TTL_MS = 5000; -let heatCache = { at: 0, body: undefined }; - -async function heatReport() { - if (heatCache.body && Date.now() - heatCache.at < HEAT_TTL_MS) { - return heatCache.body; - } - let now = Date.now(); - let scores = {}; - let last = {}; - for (let depot of depots) { - for (let [path, when] of await depotChanges(depot)) { - let full = `/${depot.name}/${path}`; - let weight = Math.exp(-(now - when) / TAU_MS); - scores[full] = (scores[full] ?? 0) + weight; - last[full] = Math.max(last[full] ?? 0, when); - } - } - // Leaves only. Rollup is the client's job because only the client knows - // what it is showing — and since the rollup is a sum, it is a fold over - // whichever prefix is on screen rather than a second index to keep true. - let body = JSON.stringify({ now, tau: TAU_MS, scores, last }); - heatCache = { at: now, body }; - return body; -} +// Heat moved to protocol `GET /_activity` (packages/server activity index). +// The old `/_hub/heat` leaf dump was a 10+ MB JSON bomb and is retired below. const built = new Set(); for (let app of APPS) { @@ -470,15 +372,21 @@ const server = createDeckServer({ fallback(req, res) { let requested = new URL(req.url ?? '/', 'http://localhost'); let path = requested.pathname; - // Where the work is. Every list in every app decorates itself from this. + // Retired: use GET /_activity. Kept as a tiny stub so old bookmarks do + // not trigger a multi-second multi-megabyte walk. if (path === '/_hub/heat') { - return heatReport().then((body) => { - res.writeHead(200, { - 'content-type': 'application/json; charset=utf-8', - 'cache-control': 'no-store', - }); - res.end(body); + res.writeHead(410, { + 'content-type': 'application/json; charset=utf-8', + 'cache-control': 'no-store', }); + res.end( + JSON.stringify({ + error: 'gone', + use: '/_activity?top=200&grain=package', + note: 'Heat is protocol now — see docs/deck-activity-protocol.md', + }), + ); + return; } // Local wrap-up only — same bytes as `deck claim --clear` / deleting the // file. Not protocol: the Deck server stays read-only; Hub is the @@ -561,10 +469,6 @@ const server = createDeckServer({ let watcher = watchDepot({ depot: hub, onLiveChange: (event) => { - // The heat report is stale the instant anything changes, and a five second - // TTL is five seconds of a warm row not warming. The whole point is that - // it moves while you watch. - heatCache = { at: 0, body: undefined }; server.liveBus.emit(event); }, onPublish: (event) => diff --git a/apps/hub/hub/app/browse/browse.js b/apps/hub/hub/app/browse/browse.js index 4a885dc..def3548 100644 --- a/apps/hub/hub/app/browse/browse.js +++ b/apps/hub/hub/app/browse/browse.js @@ -26,6 +26,7 @@ // because choosing between them is the same act. import 'ui/nav'; import { installNavStyles, onLiveChange, wrapTables } from 'ui/nav'; +import { resumeHubTour } from 'ui/tour'; import { activityCell, activityScale, @@ -569,7 +570,7 @@ function volatility(parsed, meta, seals) { let kind = parsed.seal ? 'seal' : selected === '' ? 'live' : 'sealed'; let sealCount = options.filter((option) => option.kind === 'seal').length; return ( - ` ` + + ` ` + ` ` + `` : '') + (rows.length - ? `
    ${(grouped() ? groupRuns(rows) : rows) - .map(entry) - .join('')}
` - : `

nothing here

`) + + ? `
    ` + + `` + : `

    nothing here

    `) + ``; + paintFeedWindow(); + watchFeedScroll(); } catch (error) { app.innerHTML = `
    ${esc(error.code ?? 'error')} — ` + @@ -872,12 +1002,20 @@ async function render() { cursor = -1; wrapTables(app); keys.keys = bindings(); + maybeResumeTour(); } // --------------------------------------------------------------------------- // Keyboard // --------------------------------------------------------------------------- +let tourResumed = false; +function maybeResumeTour() { + if (tourResumed) return; + tourResumed = true; + resumeHubTour({ waitMs: 80 }); +} + let cursor = -1; const rows$ = () => [...app.querySelectorAll('.entry:not([hidden])')]; @@ -1015,11 +1153,16 @@ app.addEventListener('input', (event) => { // announces every save on the SSE channel; the query cache just was not // listening. // -// Invalidate rather than refetch: query-core then only re-fetches what is -// actually mounted, and the seal log is the one query that grows. +// Debounced: a save-storm used to call render() once a second and restack +// multi-megabyte history fetches until the page never left "collecting…". +let liveRefreshTimer; onLiveChange(() => { client.invalidateQueries(); - render(); + if (liveRefreshTimer) return; + liveRefreshTimer = setTimeout(() => { + liveRefreshTimer = undefined; + if (!document.hidden) render(); + }, 1500); }); // The clock keeps moving even when the depot does not, and every row is @@ -1027,7 +1170,7 @@ onLiveChange(() => { // stays on screen for an hour. A minute is finer than the smallest unit the // feed prints, so nothing ever displays a time that has gone stale. setInterval(() => { - if (!document.hidden) render(); + if (!document.hidden) paintFeedWindow(); }, 60_000); render(); diff --git a/apps/hub/hub/importmap.json b/apps/hub/hub/importmap.json new file mode 100644 index 0000000..4d82f10 --- /dev/null +++ b/apps/hub/hub/importmap.json @@ -0,0 +1,91 @@ +{ + "imports": { + "@tanstack/query-core": "/hub/lib/tanstack-query-core@browser/build/modern/index.js", + "@tanstack/query-core/": "/hub/lib/tanstack-query-core@browser/", + "@tanstack/table-core": "/hub/lib/tanstack-table-core@browser/dist/index.js", + "@tanstack/table-core/": "/hub/lib/tanstack-table-core@browser/", + "diff": "/hub/lib/diff@9.0.0/libesm/index.js", + "diff/": "/hub/lib/diff@9.0.0/", + "driver.js": "/hub/lib/driver-js@1.8.0/dist/driver.js.mjs", + "driver.js/": "/hub/lib/driver-js@1.8.0/", + "highlight-js": "/hub/lib/highlight-js@esm/lib/core.js", + "highlight-js/": "/hub/lib/highlight-js@esm/", + "ui/activity": "/hub/ui/activity/activity.js", + "ui/activity/": "/hub/ui/activity/", + "ui/nav": "/hub/ui/nav/nav.js", + "ui/nav/": "/hub/ui/nav/", + "ui/timeline": "/hub/ui/timeline/timeline.js", + "ui/timeline/": "/hub/ui/timeline/", + "ui/tour": "/hub/ui/tour/tour.js", + "ui/tour/": "/hub/ui/tour/" + }, + "scopes": { + "/hub/app/browse/": { + "@tanstack/table-core": "/hub/lib/tanstack-table-core@browser/dist/index.js", + "@tanstack/table-core/": "/hub/lib/tanstack-table-core@browser/", + "diff": "/hub/lib/diff@9.0.0/libesm/index.js", + "diff/": "/hub/lib/diff@9.0.0/", + "highlight-js": "/hub/lib/highlight-js@esm/lib/core.js", + "highlight-js/": "/hub/lib/highlight-js@esm/", + "ui/activity": "/hub/ui/activity/activity.js", + "ui/activity/": "/hub/ui/activity/", + "ui/nav": "/hub/ui/nav/nav.js", + "ui/nav/": "/hub/ui/nav/", + "ui/timeline": "/hub/ui/timeline/timeline.js", + "ui/timeline/": "/hub/ui/timeline/", + "ui/tour": "/hub/ui/tour/tour.js", + "ui/tour/": "/hub/ui/tour/" + }, + "/hub/app/run/": { + "ui/activity": "/hub/ui/activity/activity.js", + "ui/activity/": "/hub/ui/activity/", + "ui/nav": "/hub/ui/nav/nav.js", + "ui/nav/": "/hub/ui/nav/", + "ui/timeline": "/hub/ui/timeline/timeline.js", + "ui/timeline/": "/hub/ui/timeline/", + "ui/tour": "/hub/ui/tour/tour.js", + "ui/tour/": "/hub/ui/tour/" + }, + "/hub/app/start/": { + "highlight-js": "/hub/lib/highlight-js@esm/lib/core.js", + "highlight-js/": "/hub/lib/highlight-js@esm/", + "ui/activity": "/hub/ui/activity/activity.js", + "ui/activity/": "/hub/ui/activity/", + "ui/nav": "/hub/ui/nav/nav.js", + "ui/nav/": "/hub/ui/nav/", + "ui/tour": "/hub/ui/tour/tour.js", + "ui/tour/": "/hub/ui/tour/" + }, + "/hub/app/track/": { + "@tanstack/query-core": "/hub/lib/tanstack-query-core@browser/build/modern/index.js", + "@tanstack/query-core/": "/hub/lib/tanstack-query-core@browser/", + "@tanstack/table-core": "/hub/lib/tanstack-table-core@browser/dist/index.js", + "@tanstack/table-core/": "/hub/lib/tanstack-table-core@browser/", + "ui/activity": "/hub/ui/activity/activity.js", + "ui/activity/": "/hub/ui/activity/", + "ui/nav": "/hub/ui/nav/nav.js", + "ui/nav/": "/hub/ui/nav/", + "ui/tour": "/hub/ui/tour/tour.js", + "ui/tour/": "/hub/ui/tour/" + }, + "/hub/lib/tanstack-table-core@9.1.1-browser.05cec605/": { + "@tanstack/store": "/hub/lib/tanstack-store@0.11.1/dist/index.js", + "@tanstack/store/": "/hub/lib/tanstack-store@0.11.1/", + "@tanstack/store/package.json": "/hub/lib/tanstack-store@0.11.1/package.json" + }, + "/hub/ui/tour/": { + "driver.js": "/hub/lib/driver-js@1.8.0/dist/driver.js.mjs", + "driver.js/": "/hub/lib/driver-js@1.8.0/" + } + }, + "integrity": { + "/hub/lib/diff@9.0.0/libesm/index.js": "sha256-sfQA/STaRDd4Z+3nq4R6NJQEI/i78/xUddjHZHdZoqo=", + "/hub/lib/driver-js@1.8.0/dist/driver.js.mjs": "sha256-kPXgQwWi0N8WpfYAC/frXTZstq7tG/mlw79cwruArTI=", + "/hub/lib/tanstack-store@0.11.1/dist/index.js": "sha256-vGy00gQx1KEuoHPrJ7SYx/jxMBzBVkdle9jPX2yviyI=", + "/hub/lib/tanstack-store@0.11.1/package.json": "sha256-Begc1Gky9Ci5b4xEVHrYYASacN2qEJ/eedh0dcnmYfU=", + "/hub/ui/activity/activity.js": "sha256-pgtj+AZQGgJwfF2uN0LakDbM29E3znGyLw7sxxAT/6g=", + "/hub/ui/nav/nav.js": "sha256-QUqHCLMMv5uOqDVsn+BhrVpuPPV9lrfVH40+zYp4nEI=", + "/hub/ui/timeline/timeline.js": "sha256-37tlbU7PuwfgpzbonCe5cv6uyF6lNB+1GAjTd0YfJUg=", + "/hub/ui/tour/tour.js": "sha256-+ENa/aoge91T2zPalLOHwdGbM7gjKsfGfMfEKFQ+Cc8=" + } +} diff --git a/apps/hub/hub/theme/latte/importmap.json b/apps/hub/hub/theme/latte/importmap.json deleted file mode 100644 index d6d73c9..0000000 --- a/apps/hub/hub/theme/latte/importmap.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "deck": { - "packages": { - "latte": { - "version": "1.0.0" - } - } - } -} diff --git a/apps/hub/hub/theme/latte/package.json b/apps/hub/hub/theme/latte/package.json new file mode 100644 index 0000000..7397e90 --- /dev/null +++ b/apps/hub/hub/theme/latte/package.json @@ -0,0 +1,4 @@ +{ + "name": "latte", + "version": "1.0.0" +} diff --git a/apps/hub/hub/theme/mocha/importmap.json b/apps/hub/hub/theme/mocha/importmap.json deleted file mode 100644 index eb43b71..0000000 --- a/apps/hub/hub/theme/mocha/importmap.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "deck": { - "packages": { - "mocha": { - "version": "1.0.0" - } - } - } -} diff --git a/apps/hub/hub/theme/mocha/package.json b/apps/hub/hub/theme/mocha/package.json new file mode 100644 index 0000000..5205a1c --- /dev/null +++ b/apps/hub/hub/theme/mocha/package.json @@ -0,0 +1,4 @@ +{ + "name": "mocha", + "version": "1.0.0" +} diff --git a/apps/hub/hub/theme/tokyo-night-light/importmap.json b/apps/hub/hub/theme/tokyo-night-light/importmap.json deleted file mode 100644 index 534b444..0000000 --- a/apps/hub/hub/theme/tokyo-night-light/importmap.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "deck": { - "packages": { - "tokyo-night-light": { - "version": "1.0.0" - } - } - } -} diff --git a/apps/hub/hub/theme/tokyo-night-light/package.json b/apps/hub/hub/theme/tokyo-night-light/package.json new file mode 100644 index 0000000..5b7a6fd --- /dev/null +++ b/apps/hub/hub/theme/tokyo-night-light/package.json @@ -0,0 +1,4 @@ +{ + "name": "tokyo-night-light", + "version": "1.0.0" +} diff --git a/apps/hub/hub/ui/activity/activity.js b/apps/hub/hub/ui/activity/activity.js index 8a215a5..883f763 100644 --- a/apps/hub/hub/ui/activity/activity.js +++ b/apps/hub/hub/ui/activity/activity.js @@ -26,7 +26,8 @@ let REPORT = { now: Date.now(), scores: {}, last: {} }; let listeners = new Set(); /** - * Fetch the report. ~5 KB, cached on the server for a few seconds. + * Fetch the report. Package grain — enough to decorate depot/deck lists. + * File lists inherit the enclosing package's warmth (see `scoreOf`). * * Never throws: a Hub that cannot report activity should lose the decoration * and keep the list. Everything below degrades to zero, which renders as an @@ -50,7 +51,7 @@ export async function loadActivity() { async function fetchActivity() { try { - let response = await fetch('/_activity?top=400&grain=file', { + let response = await fetch('/_activity?top=200&grain=package', { headers: { accept: 'application/json' }, }); if (response.ok) { @@ -110,6 +111,24 @@ export function scoreOf(prefix) { if (path !== clean && !path.startsWith(`${clean}/`)) continue; claims.push(...list); } + // Package-grain reports have no file leaves. A file row still wants a + // colour: use the longest scored prefix that contains it (usually the deck). + if (score === 0 && files === 0) { + let best = ''; + for (let path of Object.keys(REPORT.scores ?? {})) { + if (clean === path || clean.startsWith(`${path}/`)) { + if (path.length > best.length) best = path; + } + } + if (best) { + score = REPORT.scores[best] ?? 0; + last = REPORT.last?.[best] ?? 0; + files = 1; + } + for (let [path, list] of Object.entries(REPORT.claims ?? {})) { + if (clean === path || clean.startsWith(`${path}/`)) claims.push(...list); + } + } return { score, last, files, claims }; } diff --git a/apps/hub/hub/ui/activity/importmap.json b/apps/hub/hub/ui/activity/importmap.json deleted file mode 100644 index 0f8daec..0000000 --- a/apps/hub/hub/ui/activity/importmap.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "deck": { - "packages": { - "activity": { - "version": "0.1.0", - "entry": "$DECK/activity.js" - } - } - } -} diff --git a/apps/hub/hub/ui/activity/package.json b/apps/hub/hub/ui/activity/package.json new file mode 100644 index 0000000..7e787d6 --- /dev/null +++ b/apps/hub/hub/ui/activity/package.json @@ -0,0 +1,6 @@ +{ + "name": "activity", + "version": "0.1.0", + "type": "module", + "exports": { ".": "./activity.js" } +} diff --git a/apps/hub/hub/ui/nav/importmap.json b/apps/hub/hub/ui/nav/importmap.json deleted file mode 100644 index 6e6dbb9..0000000 --- a/apps/hub/hub/ui/nav/importmap.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "deck": { - "packages": { - "nav": { - "version": "0.1.0", - "entry": "$DECK/nav.js" - } - } - } -} diff --git a/apps/hub/hub/ui/nav/nav.js b/apps/hub/hub/ui/nav/nav.js index ef8ce0c..7b7dd32 100644 --- a/apps/hub/hub/ui/nav/nav.js +++ b/apps/hub/hub/ui/nav/nav.js @@ -169,22 +169,16 @@ const THEMES = [ const THEME_KEY = 'deck.theme'; /** - * The stored choice, or the default for the operating system's preference. + * The stored choice, or mocha. * - * Tokyo Night is the light default. It is the one with enough contrast for a - * page of prose — latte sets its body grey at about 3:1 against the - * background, which is fine for a filename and under the WCAG AA floor for a - * paragraph, and /start is mostly paragraphs. - * - * A stored choice always wins. This only decides what somebody sees before - * they have had an opinion. + * Mocha is the Hub default — dark shell, Catppuccin. Latte and Tokyo Night + * stay available from the theme picker. A stored choice always wins; this + * only decides what somebody sees before they have had an opinion. */ function theme() { let stored = localStorage.getItem(THEME_KEY); if (stored && THEMES.some(([name]) => name === stored)) return stored; - return matchMedia('(prefers-color-scheme: light)').matches - ? 'tokyo-night-light' - : 'mocha'; + return 'mocha'; } export function applyTheme(name = theme()) { @@ -361,7 +355,7 @@ export class DeckNav extends HTMLElement { let paint = async () => { if (painting) return; painting = true; - let claims = await fetch('/_activity?top=200&grain=file') + let claims = await fetch('/_activity?top=200&grain=package') .then((response) => (response.ok ? response.json() : { foci: [] })) .then((body) => (body.foci ?? []).flatMap((focus) => @@ -411,6 +405,10 @@ export class DeckNav extends HTMLElement { // `1`…`4` go to a section, the way the numbers in the nav imply. Ignored // while a field has focus, or the filter box would be unusable. + // + // Activate the real tab link (do not assign location.href): cross-document + // View Transitions only run for user-activated navigations, and a synthetic + // click on an keeps that activation. Scripted location changes flash. #numberKeys() { addEventListener('keydown', (event) => { if (event.metaKey || event.ctrlKey || event.altKey) return; @@ -419,7 +417,9 @@ export class DeckNav extends HTMLElement { let index = Number(event.key) - 1; let route = ROUTES[index]?.[0]; if (route && route !== this.getAttribute('app')) { - location.href = `/${route}`; + let link = this.querySelector(`a[data-route="${route}"]`); + if (link) link.click(); + else location.href = `/${route}`; } }); } @@ -526,6 +526,12 @@ export class DeckSubject extends HTMLElement { * need no shared clearing protocol between them. */ clear() { + // Same as number-keys: prefer a real navigation so View Transitions run. + let here = document.querySelector('deck-nav a[aria-current="page"]'); + if (here && here.getAttribute('data-inert') === '0') { + here.click(); + return; + } location.href = location.pathname; } @@ -553,10 +559,15 @@ export class DeckSubject extends HTMLElement { * key names. Every shortcut the page implements appears here, always, and * nothing appears here that the page does not implement. A legend that drifts * from the bindings is worse than no legend. + * + * While a Hub tour is running, `.tour` replaces the legend with chapter + * chrome (Prev / Next / Exit) plus a one-line protocol claim. The bar lights + * an accent; claims stay here so the exhibit is never covered by a popover. */ export class DeckKeys extends HTMLElement { #keys = []; #shown = localStorage.getItem('deck.keys') !== 'hidden'; + #tour = null; set keys(value) { this.#keys = value; @@ -567,32 +578,104 @@ export class DeckKeys extends HTMLElement { return this.#keys; } + /** @param {null | { + * chapter: string, + * chapterIndex: number, + * chapters: number, + * step: number, + * steps: number, + * label?: string, + * claim?: string, + * onPrev?: () => void, + * onNext?: () => void, + * onExit?: () => void, + * }} value */ + set tour(value) { + this.#tour = value; + this.render(); + } + + get tour() { + return this.#tour; + } + connectedCallback() { this.render(); // `h` hides and shows, and the choice sticks. Someone who has learned // the keys should not have to keep dismissing the reminder, and someone // who has not should not have to know it exists to find it — so when it // is hidden a single `?` remains, which is a smaller promise than a full - // bar and still an obvious one. + // bar and still an obvious one. Tour mode always shows; `h` is ignored. addEventListener('keydown', (event) => { if (event.metaKey || event.ctrlKey || event.altKey) return; let tag = document.activeElement?.tagName; if (tag === 'INPUT' || tag === 'SELECT' || tag === 'TEXTAREA') return; + if (this.#tour) { + if (event.key === 'Escape') { + event.preventDefault(); + this.#tour.onExit?.(); + return; + } + if (event.key === 'ArrowRight' || event.key === 'Enter') { + event.preventDefault(); + this.#tour.onNext?.(); + return; + } + if (event.key === 'ArrowLeft') { + event.preventDefault(); + this.#tour.onPrev?.(); + return; + } + return; + } if (event.key !== 'h' && event.key !== '?') return; this.toggle(); }); this.addEventListener('click', (event) => { if (event.target.closest('[data-role="keys-toggle"]')) this.toggle(); + if (!this.#tour) return; + if (event.target.closest('[data-role="tour-prev"]')) this.#tour.onPrev?.(); + if (event.target.closest('[data-role="tour-next"]')) this.#tour.onNext?.(); + if (event.target.closest('[data-role="tour-exit"]')) this.#tour.onExit?.(); }); } toggle(next = !this.#shown) { + if (this.#tour) return; this.#shown = next; localStorage.setItem('deck.keys', next ? 'shown' : 'hidden'); this.render(); } render() { + if (this.#tour) { + let t = this.#tour; + this.innerHTML = + `
    ` + + `
    ` + + `` + + `` + + `` + + `ch ${t.chapterIndex}/${t.chapters} ` + + `${esc(t.chapter)}` + + `${t.step}/${t.steps}` + + `` + + `` + + `
    ` + + (t.claim + ? `
    ` + + (t.label + ? `${esc(t.label)}` + : '') + + `${esc(t.claim)}
    ` + : '') + + `
    `; + return; + } if (!this.#shown) { this.innerHTML = `
    ` + @@ -738,8 +821,51 @@ export const NAV_CSS = ` padding: calc(var(--row, 1.5) * .5em) var(--gut, 2ch); background: var(--bg); border-top: 1px solid var(--line); font-size: 12px; + z-index: 40; } .deck-keys[data-collapsed] { border-top-color: transparent; background: none; } +.deck-keys[data-tour="1"] { + flex-direction: column; + align-items: stretch; + gap: .45em; + z-index: 1000000010; /* above driver.js overlay so the claim stays readable */ + border-top: 2px solid var(--a-live, #df8e1d); + background: color-mix(in srgb, var(--a-live, #df8e1d) 14%, var(--bg)); + box-shadow: 0 -8px 24px color-mix(in srgb, var(--a-live, #df8e1d) 18%, transparent); +} +.deck-keys[data-tour="1"] .deck-tour-row { + display: flex; flex-wrap: wrap; align-items: baseline; gap: .2em 2.5ch; +} +.deck-keys[data-tour="1"] .deck-tour-chapter b { + color: var(--a-live, #df8e1d); + font-weight: 700; + letter-spacing: .04em; +} +.deck-keys[data-tour="1"] .deck-tour-claim { + display: flex; flex-wrap: wrap; align-items: baseline; gap: .35em 1.2ch; + font-size: 13.5px; + line-height: 1.4; + max-width: 96ch; +} +.deck-keys[data-tour="1"] .deck-tour-label { + flex: 0 0 auto; + font-family: var(--mono, monospace); + font-size: 11px; + letter-spacing: .08em; + text-transform: uppercase; + color: var(--a-live, #df8e1d); + opacity: .9; +} +.deck-keys[data-tour="1"] .deck-tour-claim b { + flex: 1 1 36ch; + min-width: 0; + font-weight: 500; + letter-spacing: .01em; + white-space: normal; +} +.deck-keys[data-tour="1"] button:disabled { + opacity: .35; cursor: default; +} .deck-keys-item, .deck-keys-peek { display: inline-flex; align-items: baseline; gap: .6ch; background: none; border: 0; padding: 0; font: inherit; color: inherit; @@ -772,12 +898,57 @@ export function wrapTables(root = document) { } } +/** + * Boot status (`#boot`) is hidden by default (see HTML + boot.js). This only + * dismisses it once `#app` has real content — it must never re-show the + * message after a View Transition, or every Hub/tour navigation flashes + * "connecting…". + */ +function installBoot(doc = document) { + let boot = doc.getElementById('boot'); + let app = doc.getElementById('app'); + if (!boot || !app || boot.dataset.deckBoot === '1') return; + boot.dataset.deckBoot = '1'; + + let appReady = () => { + let text = (app.textContent || '').trim(); + return app.childElementCount > 0 || (text.length > 0 && text !== 'loading…'); + }; + let dismiss = () => { + boot.hidden = true; + boot.dataset.dismissed = '1'; + app.removeAttribute('aria-busy'); + }; + let sync = () => { + if (appReady()) dismiss(); + }; + + addEventListener('pagereveal', (event) => { + let vt = event.viewTransition; + if (!vt) return; + boot.hidden = true; + // Stay hidden after the morph — boot.js owns the slow-load reveal. + vt.finished.finally(() => { + if (appReady()) dismiss(); + else boot.hidden = true; + }); + }); + + new MutationObserver(sync).observe(app, { + childList: true, + characterData: true, + subtree: true, + }); + sync(); +} + export function installNavStyles(doc = document) { - if (doc.querySelector('style[data-deck-nav]')) { - return; - } + let existing = doc.querySelector('style[data-deck-nav]'); + if (existing?.dataset.deckNav === 'v5') return; + existing?.remove(); let style = doc.createElement('style'); - style.dataset.deckNav = 'v1'; + style.dataset.deckNav = 'v5'; style.textContent = NAV_CSS; doc.head.append(style); + installBoot(doc); } diff --git a/apps/hub/hub/ui/nav/package.json b/apps/hub/hub/ui/nav/package.json new file mode 100644 index 0000000..acf5221 --- /dev/null +++ b/apps/hub/hub/ui/nav/package.json @@ -0,0 +1,6 @@ +{ + "name": "nav", + "version": "0.1.0", + "type": "module", + "exports": { ".": "./nav.js" } +} diff --git a/apps/hub/hub/ui/theme/boot.css b/apps/hub/hub/ui/theme/boot.css new file mode 100644 index 0000000..82e3a5a --- /dev/null +++ b/apps/hub/hub/ui/theme/boot.css @@ -0,0 +1,37 @@ +/* Critical shell colours — linked in before any module runs. + * + * Cross-document View Transitions snapshot the NEW document as soon as it + * exists. If `--bg` is still unset (palette.css arrives via JS), the + * viewport is browser-white and the default root crossfade shows it. + * These fallbacks match the three Hub palettes / applyTheme() defaults. + */ + +:root { + --hub-flash: #1e1e2e; /* mocha — Hub default */ + color-scheme: dark; +} + +html[data-theme='mocha'] { + --hub-flash: #1e1e2e; + color-scheme: dark; +} +html[data-theme='latte'] { + --hub-flash: #eff1f5; + color-scheme: light; +} +html[data-theme='tokyo-night-light'] { + --hub-flash: #e1e2e7; + color-scheme: light; +} + +html, +body, +#app { + background-color: var(--bg, var(--hub-flash)); +} + +/* The VT layer sits above the document. Without a fill, opacity crossfades + of ::view-transition-old/new(root) punch through to white. */ +::view-transition { + background-color: var(--bg, var(--hub-flash)); +} diff --git a/apps/hub/hub/ui/theme/boot.js b/apps/hub/hub/ui/theme/boot.js new file mode 100644 index 0000000..378a350 --- /dev/null +++ b/apps/hub/hub/ui/theme/boot.js @@ -0,0 +1,52 @@ +// Sync, classic script in — runs before first paint / VT snapshot. +// Mirrors applyTheme() defaults in ui/nav so the palette link exists before +// the module graph. Must stay non-module so it is not deferred. +// +// `#boot` stays hidden in HTML on purpose. Showing "connecting…" on every MPA +// navigation painted a diagnostic into the View Transition and the tour. +// Only reveal after a real stall — never on the happy path. +(function () { + var KEY = 'deck.theme'; + var known = { mocha: 1, latte: 1, 'tokyo-night-light': 1 }; + var SLOW_MS = 1600; + var name = null; + try { + name = localStorage.getItem(KEY); + } catch (_) { + /* private mode */ + } + if (!known[name]) { + name = 'mocha'; + } + document.documentElement.dataset.theme = name; + if (!document.querySelector('link[data-role="palette"]')) { + var link = document.createElement('link'); + link.rel = 'stylesheet'; + link.href = '/hub/theme/' + name + '/palette.css'; + link.dataset.role = 'palette'; + document.head.appendChild(link); + } + + function appHasContent() { + var app = document.getElementById('app'); + if (!app) return true; + var text = (app.textContent || '').trim(); + return app.childElementCount > 0 || (text.length > 0 && text !== 'loading…'); + } + + function armSlowBoot() { + var boot = document.getElementById('boot'); + if (!boot) return; + boot.hidden = true; + setTimeout(function () { + if (appHasContent() || boot.dataset.dismissed === '1') return; + boot.hidden = false; + }, SLOW_MS); + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', armSlowBoot); + } else { + armSlowBoot(); + } +})(); diff --git a/apps/hub/hub/ui/theme/importmap.json b/apps/hub/hub/ui/theme/importmap.json deleted file mode 100644 index 0e34069..0000000 --- a/apps/hub/hub/ui/theme/importmap.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "deck": { - "packages": { - "theme": { - "version": "0.1.0" - } - } - } -} diff --git a/apps/hub/hub/ui/theme/package.json b/apps/hub/hub/ui/theme/package.json new file mode 100644 index 0000000..fb5a64c --- /dev/null +++ b/apps/hub/hub/ui/theme/package.json @@ -0,0 +1,4 @@ +{ + "name": "theme", + "version": "0.1.0" +} diff --git a/apps/hub/hub/ui/theme/theme.css b/apps/hub/hub/ui/theme/theme.css index d35f614..051f881 100644 --- a/apps/hub/hub/ui/theme/theme.css +++ b/apps/hub/hub/ui/theme/theme.css @@ -68,10 +68,89 @@ a.s-file { color: var(--fg); } * { box-sizing: border-box; } ::selection { background: var(--sel); } +/* ── Cross-document View Transitions (MPA tab strip) ───────────────────── + Opt-in `@view-transition { navigation: auto }` lives inline in each app's + HTML so the browser can start the transition before the stylesheet lands. + Names + nested groups live here so every page shares one chrome map. + + NO FADES. The UA default fades old↓ and new↑; at mid-transition the page + dims (and flashes white). Goal: the outgoing page (browse, run, …) stays + fully opaque on screen until the next document is ready, then cuts. */ +@media (prefers-reduced-motion: no-preference) { + body { + view-transition-name: hub-shell; + } + deck-nav { + view-transition-name: hub-nav; + view-transition-group: hub-shell; + } + deck-subject { + view-transition-name: hub-subject; + view-transition-group: hub-shell; + } + #app { + view-transition-name: hub-main; + view-transition-group: hub-shell; + } + deck-keys { + view-transition-name: hub-keys; + view-transition-group: hub-shell; + } + .hub-boot { + view-transition-name: none; + } + + /* Instant cut — no morph timing that could interpolate opacity/geometry. */ + ::view-transition-group(*) { + animation-duration: 0s; + animation-timing-function: step-end; + } + + /* Outgoing snapshot stays full strength; incoming stays invisible until the + cut. Never run the UA fade keyframes. */ + ::view-transition-old(*) { + animation: none !important; + opacity: 1 !important; + mix-blend-mode: normal; + z-index: 2; + } + ::view-transition-new(*) { + animation: none !important; + opacity: 0 !important; + mix-blend-mode: normal; + z-index: 1; + } +} + +/* Hold the main region's height so an empty `#app` during a view transition + does not collapse the shell (nav + keys stay put). */ +#app { + min-height: 50vh; +} + +/* Outside every named transition group — corner status, not a page flash. */ +.hub-boot { + position: fixed; + left: var(--gut); + bottom: 3.6em; + z-index: 4; + margin: 0; + color: var(--dim); + pointer-events: none; + letter-spacing: 0.04em; +} +.hub-boot[hidden] { + display: none; +} + +html { + background-color: var(--bg, var(--hub-flash, #1e1e2e)); +} + body { margin: 0; padding: 0 var(--gut) 6em; - background: var(--bg); + background: var(--bg, var(--hub-flash, #1e1e2e)); color: var(--fg); font-family: var(--mono); font-size: 13px; @@ -504,6 +583,13 @@ td.indent { padding-left: 2ch; } .sortbar label { color: var(--dim); margin-right: .6ch; } .feed { list-style: none; margin: 0; padding: 0; } +.feed-more { + text-align: center; + padding: calc(var(--row) * .8em) 0 calc(var(--row) * 1.2em); + letter-spacing: .06em; +} +.feed-more[data-state='loading'], +.feed-more[data-state='fetch'] { color: var(--a-live, var(--amber)); } /* RECENCY IS BRIGHTNESS. `--fresh` runs 1 → 0 over a day, and the entry fades with it, so "something just happened" reads from across the room and stops diff --git a/apps/hub/hub/ui/timeline/importmap.json b/apps/hub/hub/ui/timeline/importmap.json deleted file mode 100644 index b00573c..0000000 --- a/apps/hub/hub/ui/timeline/importmap.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "deck": { - "packages": { - "timeline": { - "version": "0.1.0", - "entry": "$DECK/timeline.js" - } - } - } -} diff --git a/apps/hub/hub/ui/timeline/package.json b/apps/hub/hub/ui/timeline/package.json new file mode 100644 index 0000000..b62b883 --- /dev/null +++ b/apps/hub/hub/ui/timeline/package.json @@ -0,0 +1,6 @@ +{ + "name": "timeline", + "version": "0.1.0", + "type": "module", + "exports": { ".": "./timeline.js" } +} diff --git a/apps/hub/hub/ui/tour/package.json b/apps/hub/hub/ui/tour/package.json new file mode 100644 index 0000000..1d8104d --- /dev/null +++ b/apps/hub/hub/ui/tour/package.json @@ -0,0 +1,9 @@ +{ + "name": "tour", + "version": "0.1.0", + "type": "module", + "exports": { ".": "./tour.js" }, + "dependencies": { + "driver.js": "1.8.0" + } +} diff --git a/apps/hub/hub/ui/tour/tour.js b/apps/hub/hub/ui/tour/tour.js new file mode 100644 index 0000000..ae2c70e --- /dev/null +++ b/apps/hub/hub/ui/tour/tour.js @@ -0,0 +1,661 @@ +// ui/tour — Hub protocol stories (driver.js highlight + bottom-bar caption). +// +// Implements docs/deck-hub-tour.md. The exhibit stays visible: driver.js +// cuts a stage and paints a theme-coloured ring. The story lives on +// . Next / Prev / Exit are the bar (and ← → esc). +// +// Beats may `prepare` a real interaction: list-browse, open volatility, +// scrub Timeline, scroll a feed, expand a review fold — show, don't narrate. +import { driver } from 'driver.js'; + +const STORAGE = 'deck.hub.tour'; +const TOUR_PARAM = 'tour'; + +/** @typedef {'none' | 'list-browse' | 'dropdown' | 'scroll' | 'scrub'} Interaction */ +/** @typedef {'open-vol' | 'expand-review' | 'scroll-feed' | 'list-browse' | 'scrub-timeline'} Prepare */ + +/** @typedef {{ + * chapter: number, + * title: string, + * page: string, + * element?: string, + * go: string, + * label: string, + * claim: string, + * interaction?: Interaction, + * prepare?: Prepare, + * }} TourStep */ + +/** Four stories — one use case each. Live paths over brittle @pins. */ +const CHAPTERS = [ + { + title: 'Eight desks, one board', + steps: /** @type {TourStep[]} */ ([ + { + chapter: 1, + title: 'Eight desks, one board', + page: 'browse', + element: 'main table tbody, main table, main', + go: '/browse?at=/two-agents', + label: 'catalog', + claim: + 'Each row is a publisher’s desk — agent-a, charts, ui, app. The board is not a branch; it is this list, composed at load.', + interaction: 'list-browse', + prepare: 'list-browse', + }, + { + chapter: 1, + title: 'Eight desks, one board', + page: 'run', + element: '[data-tour="stage"], [data-role="stage"], .stagewrap', + go: '/run?at=/two-agents/app/board/index.html', + label: 'board', + claim: + 'Review the swarm by watching the board. Pins across those desks are the composition — no integration step, because there was never a disintegration.', + }, + { + chapter: 1, + title: 'Eight desks, one board', + page: 'run', + element: '[data-tour="graph"], .runsplit-under', + go: '/run?at=/two-agents/app/board/index.html', + label: 'resolved map', + claim: + 'The resolved map is the merge. Freeze one pin and you quarantine one agent; the rest keep moving.', + }, + { + chapter: 1, + title: 'Eight desks, one board', + page: 'track', + element: '[data-tour="feed"], ul.feed, main', + go: '/track?at=/two-agents', + label: 'feed', + claim: + 'Every agent save is already a row with an address. The assembled thing existed the whole time those rows were written.', + }, + ]), + }, + { + title: 'Two currencies land in the same minute', + steps: /** @type {TourStep[]} */ ([ + { + chapter: 2, + title: 'Two currencies land in the same minute', + page: 'run', + element: '[data-tour="stage"], [data-role="stage"], .stagewrap, main', + go: '/run?at=/codelist/app/viewer/index.html', + label: 'exchange board', + claim: + 'A live exchange board, not a JSON table. When amendments land, rows appear and the count moves — a merge you can see from across the room.', + }, + { + chapter: 2, + title: 'Two currencies land in the same minute', + page: 'browse', + element: '[data-tour="vol"], select[data-role="vol"], .vol', + go: '/browse?at=/codelist/std/currencies@latest', + label: 'version', + claim: + 'Open the version control. @1.4.0 and @1.5.0 are immutable cuts; latest is a tag on a Version — never “whatever is on disk.”', + interaction: 'dropdown', + prepare: 'open-vol', + }, + { + chapter: 2, + title: 'Two currencies land in the same minute', + page: 'browse', + element: 'deck-subject, [data-tour="facts"], main', + go: '/browse?at=/codelist/std/currencies@1.5.0', + label: 'frozen cut', + claim: + 'Only the @segment moved. Same package path, frozen bytes forever. Consumers choose exposure per list.', + }, + { + chapter: 2, + title: 'Two currencies land in the same minute', + page: 'track', + element: '[data-tour="feed"], ul.feed, main', + go: '/track?at=/codelist', + label: 'feed', + claim: + 'Amendments show up as published rows when they seal. Publishing ceremony is not what forces every team to keep a patched copy.', + }, + ]), + }, + { + title: 'Try it before you take it', + steps: /** @type {TourStep[]} */ ([ + { + chapter: 3, + title: 'Try it before you take it', + page: 'run', + element: '[data-tour="stage"], [data-role="stage"], .stagewrap, main', + go: '/run?at=/contribute-to-codelist/app/picker/index.html', + label: 'picker', + claim: + 'Ana and Kim each patched the list. Side-by-side boards — “Try this one” is ?with= for one request, not a checkout.', + }, + { + chapter: 3, + title: 'Try it before you take it', + page: 'run', + element: '[data-tour="graph"], .runsplit-under, main', + go: '/run?at=/contribute-to-codelist/app/picker/index.html&with=std/currencies=ana/currencies', + label: 'trial map', + claim: + 'Only the rebound specifier moves. Everything else stays on its pins. A proposal is already a runnable URL.', + }, + { + chapter: 3, + title: 'Try it before you take it', + page: 'browse', + element: 'section.review, [data-role="fold"], main', + go: + '/browse?at=/contribute-to-codelist/ana/currencies@1.5.0-dev.1&vs=/contribute-to-codelist/std/currencies@1.4.0', + label: 'fork diff', + claim: + 'Publisher segment is the fork boundary. Expand the row: key-level truth, not a line conflict over two different additions.', + interaction: 'list-browse', + prepare: 'expand-review', + }, + ]), + }, + { + title: 'The hour you abandoned is still an address', + steps: /** @type {TourStep[]} */ ([ + { + chapter: 4, + title: 'The hour you abandoned is still an address', + page: 'browse', + element: '[data-tour="vol"], select[data-role="vol"], .vol', + go: '/browse?at=/vale-sale/vale/sale', + label: 'versions', + claim: + 'A real page, auto-published as -dev.N on every save. Open the stack — the interesting states live between releases.', + interaction: 'dropdown', + prepare: 'open-vol', + }, + { + chapter: 4, + title: 'The hour you abandoned is still an address', + page: 'run', + element: '[data-tour="stage"], [data-role="stage"], .stagewrap', + go: '/run?at=/vale-sale/vale/sale/index.html', + label: 'sale', + claim: + 'Past is not a backup — it is a URL you can load in this frame. Watch the page; the tape is underneath.', + }, + { + chapter: 4, + title: 'The hour you abandoned is still an address', + page: 'run', + element: '[data-role="timeline"], .tl', + go: '/run?at=/vale-sale/vale/sale/index.html', + label: 'timeline', + claim: + 'Scrub back one seal. The iframe follows /_history//…. Going back is reading and running an address.', + interaction: 'scrub', + prepare: 'scrub-timeline', + }, + { + chapter: 4, + title: 'The hour you abandoned is still an address', + page: 'track', + element: '[data-tour="feed"], ul.feed, main', + go: '/track?at=/vale-sale', + label: 'saved rows', + claim: + 'Saved rows deep-link into browse. The hour you abandon is sealed too — in case you were wrong.', + interaction: 'scroll', + prepare: 'scroll-feed', + }, + ]), + }, +]; + +const SCRIPT = CHAPTERS.flatMap((ch) => ch.steps); + +function ensureCss() { + if (document.querySelector('link[data-driver-css]')) return; + let href; + try { + href = import.meta.resolve('driver.js/dist/driver.css'); + } catch { + href = undefined; + } + if (!href) return; + let link = document.createElement('link'); + link.rel = 'stylesheet'; + link.href = href; + link.dataset.driverCss = '1'; + document.head.appendChild(link); +} + +function ensureTourCss() { + let existing = document.querySelector('style[data-deck-tour-css]'); + if (existing) { + existing.textContent = TOUR_CSS; + return; + } + let style = document.createElement('style'); + style.dataset.deckTourCss = '1'; + style.textContent = TOUR_CSS; + document.head.appendChild(style); +} + +function loadState() { + try { + let raw = sessionStorage.getItem(STORAGE); + return raw ? JSON.parse(raw) : null; + } catch { + return null; + } +} + +function saveState(state) { + if (!state || !state.active) { + sessionStorage.removeItem(STORAGE); + return; + } + sessionStorage.setItem(STORAGE, JSON.stringify(state)); +} + +function clearTourQuery() { + let url = new URL(location.href); + if (!url.searchParams.has(TOUR_PARAM)) return; + url.searchParams.delete(TOUR_PARAM); + history.replaceState(null, '', url); +} + +function withTourParam(href) { + let url = new URL(href, location.origin); + url.searchParams.set(TOUR_PARAM, '1'); + return url.pathname + url.search + url.hash; +} + +function currentApp() { + return document.querySelector('deck-nav')?.getAttribute('app') ?? ''; +} + +function firstSelector(selector) { + if (!selector) return undefined; + for (let part of selector.split(',').map((s) => s.trim())) { + if (document.querySelector(part)) return part; + } + return selector.split(',')[0]?.trim(); +} + +function chapterMeta(absoluteIndex) { + let step = SCRIPT[absoluteIndex]; + if (!step) return null; + let chapterSteps = CHAPTERS[step.chapter - 1].steps; + let stepInChapter = chapterSteps.indexOf(step) + 1; + return { + chapter: step.title, + chapterIndex: step.chapter, + chapters: CHAPTERS.length, + step: stepInChapter, + steps: chapterSteps.length, + label: step.label, + claim: step.claim, + }; +} + +function keysEl() { + return document.querySelector('deck-keys'); +} + +function paintTourBar(absoluteIndex) { + let meta = chapterMeta(absoluteIndex); + let el = keysEl(); + if (!el || !meta) return; + el.tour = { + ...meta, + onPrev: () => tourPrev(), + onNext: () => tourNext(), + onExit: () => abandonTour(), + }; +} + +function clearTourBar() { + let el = keysEl(); + if (el) el.tour = null; +} + +let activeDriver = null; +let tearingDown = false; +let cursor = 0; + +function destroyActive() { + cleanupPrepare(); + if (!activeDriver) return; + tearingDown = true; + try { + activeDriver.destroy(); + } finally { + tearingDown = false; + activeDriver = null; + } +} + +function finish() { + saveState(null); + clearTourQuery(); + clearTourBar(); + destroyActive(); +} + +function abandonTour() { + finish(); +} + +function revealExhibit(selector) { + if (!selector) return; + let el = document.querySelector(selector); + if (!el || typeof el.scrollIntoView !== 'function') return; + el.scrollIntoView({ block: 'center', inline: 'nearest', behavior: 'smooth' }); +} + +/** Undo transient UI the prepare helpers opened. */ +function cleanupPrepare() { + let sel = document.querySelector('select[data-role="vol"][data-tour-open="1"]'); + if (sel) { + sel.size = 1; + delete sel.dataset.tourOpen; + sel.blur(); + } + for (let row of document.querySelectorAll('tbody tr[data-tour-pick="1"]')) { + delete row.dataset.tourPick; + if (row.dataset.here === 'tour') delete row.dataset.here; + } +} + +/** + * Exercise the interaction named on the step — dropdown open, feed scroll, + * catalog row pick, review fold expand. Fail soft if the exhibit is thin. + * @param {Prepare | undefined} kind + */ +function runPrepare(kind) { + cleanupPrepare(); + if (!kind) return; + if (kind === 'open-vol') { + let sel = document.querySelector('select[data-role="vol"]'); + if (!sel) return; + sel.focus(); + let n = Math.min(sel.options.length, 7); + if (n > 1) { + sel.size = n; + sel.dataset.tourOpen = '1'; + } + return; + } + if (kind === 'scroll-feed') { + let feed = + document.querySelector('ul.feed.attention') || + document.querySelector('[data-tour="feed"]') || + document.querySelector('ul.feed'); + if (!feed) return; + let entries = [...feed.querySelectorAll('li.entry')]; + if (!entries.length) { + feed.scrollIntoView({ block: 'start', behavior: 'smooth' }); + return; + } + let target = + entries[Math.min(entries.length - 1, Math.max(1, Math.floor(entries.length * 0.55)))]; + target.scrollIntoView({ block: 'center', behavior: 'smooth' }); + return; + } + if (kind === 'list-browse') { + let rows = [...document.querySelectorAll('main table tbody tr')]; + if (!rows.length) return; + let preferred = + rows.find((row) => /agent-a|charts|ana\b|kim\b/i.test(row.textContent || '')) || + rows[Math.min(1, rows.length - 1)]; + preferred.dataset.tourPick = '1'; + preferred.dataset.here = preferred.dataset.here || 'tour'; + preferred.scrollIntoView({ block: 'center', behavior: 'smooth' }); + return; + } + if (kind === 'expand-review') { + let section = document.querySelector('section.review'); + let fold = section?.querySelector('[data-role="fold"]'); + if (!fold) return; + if (section.dataset.open !== '1') { + fold.dispatchEvent(new MouseEvent('click', { bubbles: true })); + } + section.scrollIntoView({ block: 'center', behavior: 'smooth' }); + return; + } + if (kind === 'scrub-timeline') { + let tape = document.querySelector('[data-role="timeline"]'); + if (!tape) return; + tape.scrollIntoView({ block: 'center', behavior: 'smooth' }); + // Click ◀ once the stage has painted — same control as keyboard `[`. + let back = tape.querySelector('[data-role="step-by"][data-by="-1"]'); + if (!back || back.disabled) return; + setTimeout(() => { + if (!document.contains(back) || back.disabled) return; + back.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }, 480); + } +} + +/** True when the current URL already carries this step's destination query. */ +function hrefMatchesStep(href) { + let want = new URL(href, location.origin); + let here = new URL(location.href); + for (let key of want.searchParams.keys()) { + if (key === TOUR_PARAM) continue; + if ((here.searchParams.get(key) ?? '') !== (want.searchParams.get(key) ?? '')) { + return false; + } + } + // Also require that *extra* significant params on here match when want set them + // (e.g. clearing `with=` when the next beat has no with). + for (let key of ['at', 'vs', 'with', 'kind']) { + let w = want.searchParams.get(key); + let h = here.searchParams.get(key); + if (w == null && h != null && key === 'with') return false; + if (w != null && w !== h) return false; + } + return true; +} + +function buildHighlight(absoluteIndex) { + ensureCss(); + ensureTourCss(); + let step = SCRIPT[absoluteIndex]; + if (!step || step.page !== currentApp()) return null; + let sel = firstSelector(step.element); + revealExhibit(sel); + // Prepare after a tick so layout/scroll settle under the cutout. + requestAnimationFrame(() => runPrepare(step.prepare)); + return driver({ + popoverClass: 'deck-tour-popover', + showProgress: false, + allowClose: false, + overlayOpacity: 0.48, + overlayClickBehavior: () => {}, + stagePadding: 10, + stageRadius: 2, + smoothScroll: false, + disableActiveInteraction: false, + steps: [ + { + element: sel, + popover: { + title: '', + description: '', + showButtons: [], + side: 'over', + align: 'center', + }, + }, + ], + onDestroyStarted: (_el, _step, { driver: d }) => { + if (tearingDown) d.destroy(); + }, + }); +} + +function showStep(absoluteIndex) { + if (absoluteIndex < 0 || absoluteIndex >= SCRIPT.length) { + finish(); + location.href = '/start'; + return; + } + cursor = absoluteIndex; + let step = SCRIPT[absoluteIndex]; + saveState({ active: true, index: absoluteIndex }); + paintTourBar(absoluteIndex); + + if (step.page !== currentApp() || !hrefMatchesStep(step.go)) { + location.href = withTourParam(step.go); + return; + } + destroyActive(); + activeDriver = buildHighlight(absoluteIndex); + activeDriver?.drive(0); +} + +function tourNext() { + cleanupPrepare(); + let next = cursor + 1; + if (next >= SCRIPT.length) { + finish(); + location.href = '/start'; + return; + } + showStep(next); +} + +function tourPrev() { + if (cursor <= 0) return; + cleanupPrepare(); + showStep(cursor - 1); +} + +export function startHubTour() { + saveState({ active: true, index: 0 }); + location.href = withTourParam(SCRIPT[0].go); +} + +export function resumeHubTour(options = {}) { + let params = new URLSearchParams(location.search); + let wanted = params.has(TOUR_PARAM) || loadState()?.active; + if (!wanted) return; + + let state = loadState(); + let index = state?.index ?? 0; + while (index < SCRIPT.length && SCRIPT[index].page !== currentApp()) { + index++; + } + if (index >= SCRIPT.length) { + finish(); + return; + } + // Same app, wrong subject — snap to this step's URL (showStep navigates). + saveState({ active: true, index }); + let waitMs = options.waitMs ?? 0; + let attempt = () => { + let step = SCRIPT[index]; + if (!hrefMatchesStep(step.go)) { + location.href = withTourParam(step.go); + return; + } + let sel = firstSelector(step.element); + if (sel && !document.querySelector(sel) && (options.retries ?? 0) < 40) { + setTimeout( + () => resumeHubTour({ ...options, retries: (options.retries ?? 0) + 1 }), + 100, + ); + return; + } + showStep(index); + }; + if (waitMs) setTimeout(attempt, waitMs); + else attempt(); +} + +export function mountTourButton(host) { + if (!host || host.querySelector('[data-role="tour"]')) return; + ensureTourCss(); + let bar = document.createElement('p'); + bar.className = 'tour-launch'; + bar.innerHTML = + ` ` + + `4 stories · run, versions, trial, scrub · ` + + `or read the Deckbook`; + host.insertBefore(bar, host.firstChild); + bar.querySelector('[data-role="tour"]').addEventListener('click', () => { + startHubTour(); + }); +} + +const TOUR_CSS = ` +.tour-launch { margin: 0 0 var(--s3, 1.5rem); } +.tour-btn { + font: inherit; + font-family: var(--mono); + cursor: pointer; + color: var(--bg, #111); + background: var(--a-live, #df8e1d); + border: 0; + padding: .2em .8em; + letter-spacing: .04em; +} +.tour-btn:hover { filter: brightness(1.08); } + +/* Caption lives on — never cover the exhibit with a popover. */ +.deck-tour-popover.driver-popover, +.driver-popover.deck-tour-popover { + display: none !important; +} +.driver-overlay { + pointer-events: none !important; +} +/* Dim the room enough that the cutout reads as a spotlight. */ +body.driver-active .driver-overlay { + opacity: 0.52 !important; +} + +/* Theme-native ring — grammar colours, not driver.js blue. */ +@keyframes deck-tour-glow { + 0%, 100% { + box-shadow: + 0 0 0 2px var(--a-live, #f9e2af), + 0 0 0 7px color-mix(in oklab, var(--a-live, #f9e2af) 28%, transparent), + 0 0 28px color-mix(in oklab, var(--a-package, #89b4fa) 35%, transparent); + } + 50% { + box-shadow: + 0 0 0 2px var(--a-package, #89b4fa), + 0 0 0 10px color-mix(in oklab, var(--a-package, #89b4fa) 32%, transparent), + 0 0 36px color-mix(in oklab, var(--a-live, #f9e2af) 40%, transparent); + } +} +.driver-active-element, +#driver-highlighted-element-stage { + outline: none !important; + border-radius: 2px; + animation: deck-tour-glow 1.5s ease-in-out infinite; +} +/* Opened volatility list during the dropdown beat. */ +select[data-role="vol"][data-tour-open="1"] { + position: relative; + z-index: 1000000005; + background: var(--panel, #181825); + box-shadow: + 0 0 0 2px var(--a-live, #f9e2af), + 0 12px 32px color-mix(in oklab, var(--bg, #000) 55%, transparent); +} +tbody tr[data-tour-pick="1"] { + box-shadow: inset 3px 0 0 var(--a-live, #f9e2af) !important; + background: var(--sel, #45475a) !important; +} + +/* Room for the multi-line story bar while scrolling exhibits into view. */ +body:has(.deck-keys[data-tour="1"]) { + padding-bottom: 6.5rem; +} +`; diff --git a/apps/hub/setup.mjs b/apps/hub/setup.mjs index 33b2e89..a13fea6 100644 --- a/apps/hub/setup.mjs +++ b/apps/hub/setup.mjs @@ -44,7 +44,7 @@ // nothing imports them — they are loaded on demand, by extension, at // runtime, which is how "universal highlighting" costs one request per // language you actually look at instead of 2.6 MB up front. -import { mkdir } from 'node:fs/promises'; +import { mkdir, rm } from 'node:fs/promises'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { pack } from '@cardstack/deck/pack'; @@ -61,19 +61,30 @@ const storeDir = storeDirFor(depotDir); // meaningful minimumReleaseAge. See L11. const POLICY = { minimumReleaseAge: 0 }; +await rm(storeDir, { recursive: true, force: true }); await mkdir(storeDir, { recursive: true }); const json = (value) => Buffer.from(`${JSON.stringify(value, null, 2)}\n`, 'utf8'); +/** Lineage only — identity and ranges live in the pack's package.json. */ +function lineageMap(name, extra = {}) { + return json({ + imports: {}, + deck: { + packages: { + [name]: extra, + }, + }, + }); +} + /** * Publish, unless this exact version is already there. * * A published exact version never changes (L4) and the store refuses to - * overwrite one — correctly, and this script must not fight it. Setup has to - * be runnable twice, so it asks first. If the version exists it is left - * alone and its recorded treeHash is reported, which also means a second run - * silently VERIFIES the first: same inputs, same content hash, or the - * mismatch shows up in the printed digest. + * overwrite one — correctly. This is demo data: setup clears the store + * first, then publishes. A second run still asks first so a leftover + * version is left alone. */ async function publishOnce(_storeDir, name, version, bytes, options) { let meta = await readStoreMeta(storeDir, name); @@ -136,17 +147,7 @@ console.log('\n DECK HUB — vendoring the Hub\'s own dependencies\n'); let bytes = pack([ { path: 'importmap.json', - bytes: json({ - deck: { - packages: { - diff: { - version: '9.0.0', - entry: `$DECK/${vendored.entryPath}`, - vendoredFrom: vendored.vendoredFrom, - }, - }, - }, - }), + bytes: lineageMap('diff', { vendoredFrom: vendored.vendoredFrom }), }, ...vendored.files, ]); @@ -199,40 +200,60 @@ const ESM_LINE = 'export default highlight;'; .filter((path) => !path.endsWith('.js.js') && !path.endsWith('.min.js')) .sort(); - // The version is derived from the patch so that re-running this script - // republishes the same bytes under the same name. A demo that publishes - // different content at the same immutable URL teaches the browser a lie - // it will hold on to for a year. - const version = `${HLJS}-esm.${hashBytes(patched).slice(0, 8)}`; + // Upstream `exports["."].import` is `./es/index.js`, which re-exports + // `lib/index.js` — a file this fork does not carry (we ship core + + // on-demand languages only). Point the entry at the patched core, and + // rename the package to the specifier Hub already depends on. + let manifest = files.get('package.json'); + if (!manifest) { + throw new Error(`highlight.js@${HLJS} has no package.json`); + } + let pkgJson = JSON.parse(manifest.toString('utf8')); + pkgJson.name = 'highlight-js'; + pkgJson.type = 'module'; + pkgJson.exports = { + '.': './lib/core.js', + './lib/core.js': './lib/core.js', + './es/languages/*': './es/languages/*', + }; + delete pkgJson.main; + delete pkgJson.module; + // Version from the fork payload (patched core + rewritten exports), so a + // change to either publishes a new immutable URL rather than silently + // reusing a broken pack. Version is filled in after the hash so it cannot + // feed back into itself. + let forHash = Buffer.from( + JSON.stringify({ ...pkgJson, version: undefined }), + 'utf8', + ); + const version = `${HLJS}-esm.${hashBytes( + Buffer.concat([patched, forHash]), + ).slice(0, 8)}`; + pkgJson.version = version; + let manifestBytes = Buffer.from(`${JSON.stringify(pkgJson, null, 2)}\n`, 'utf8'); let bytes = pack([ { path: 'importmap.json', - bytes: json({ - deck: { - packages: { - 'highlight-js': { - version, - entry: '$DECK/lib/core.js', - vendoredFrom: vendored.vendoredFrom, - // What was changed and why, in the tree, at rest, where a - // reader of the deck finds it without being told to look. - forkedFrom: { - package: 'highlight.js', - version: HLJS, - changed: ['lib/core.js'], - reason: - 'lib/core.js is CommonJS (module.exports) behind an ESM ' + - 'shim, so the published package cannot be imported by a ' + - 'browser. One line changed: `module.exports = highlight` ' + - 'became `export default highlight`. Nothing else differs.', - }, - }, - }, + bytes: lineageMap('highlight-js', { + vendoredFrom: vendored.vendoredFrom, + forkedFrom: { + package: 'highlight.js', + version: HLJS, + changed: ['lib/core.js', 'package.json'], + reason: + 'lib/core.js is CommonJS (module.exports) behind an ESM ' + + 'shim, so the published package cannot be imported by a ' + + 'browser. One line changed: `module.exports = highlight` ' + + 'became `export default highlight`. package.json is rewritten ' + + 'so the Hub specifier `highlight-js` resolves to that core ' + + '(upstream exports point at es/index.js → lib/index.js, which ' + + 'this fork does not carry).', }, }), }, { path: 'lib/core.js', bytes: patched }, + { path: 'package.json', bytes: manifestBytes }, ...languages.map((path) => ({ path, bytes: files.get(path) })), ]); let { treeHash, reused } = await publishOnce( @@ -252,6 +273,9 @@ const ESM_LINE = 'export default highlight;'; console.log( ` forked lib/core.js: 1 line of 2597 — \`${CJS_LINE}\` → \`${ESM_LINE}\``, ); + console.log( + ` package.json entry → lib/core.js (specifier highlight-js)`, + ); } // --------------------------------------------------------------------------- @@ -307,17 +331,7 @@ const TABLE_DECK = 'lib/tanstack-table-core'; let storeBytes = pack([ { path: 'importmap.json', - bytes: json({ - deck: { - packages: { - 'tanstack-store': { - version: '0.11.1', - entry: `$DECK/${store.entryPath}`, - vendoredFrom: store.vendoredFrom, - }, - }, - }, - }), + bytes: lineageMap('tanstack-store', { vendoredFrom: store.vendoredFrom }), }, ...store.files, ]); @@ -358,27 +372,18 @@ const TABLE_DECK = 'lib/tanstack-table-core'; let tableBytes = pack([ { path: 'importmap.json', - bytes: json({ - deck: { - packages: { - 'tanstack-table-core': { - version: tableVersion, - entry: `$DECK/${table.entryPath}`, - vendoredFrom: table.vendoredFrom, - forkedFrom: { - package: '@tanstack/table-core', - version: '9.1.1', - changed: patched, - reason: - 'Nine files guard dev-only warnings with ' + - '`process.env.NODE_ENV === "development"`, which is not ' + - 'defined in a browser. The guard is folded to `false` — the ' + - 'same constant-fold a bundler performs — so the package ' + - 'runs without a build step or a `process` shim.', - }, - }, - }, - dependencies: { '@tanstack/store': `${STORE_DECK}@^0.11.0` }, + bytes: lineageMap('tanstack-table-core', { + vendoredFrom: table.vendoredFrom, + forkedFrom: { + package: '@tanstack/table-core', + version: '9.1.1', + changed: patched, + reason: + 'Nine files guard dev-only warnings with ' + + '`process.env.NODE_ENV === "development"`, which is not ' + + 'defined in a browser. The guard is folded to `false` — the ' + + 'same constant-fold a bundler performs — so the package ' + + 'runs without a build step or a `process` shim.', }, }), }, @@ -422,24 +427,16 @@ const TABLE_DECK = 'lib/tanstack-table-core'; let bytes = pack([ { path: 'importmap.json', - bytes: json({ - deck: { - packages: { - 'tanstack-query-core': { - version, - entry: `$DECK/${vendored.entryPath}`, - vendoredFrom: vendored.vendoredFrom, - forkedFrom: { - package: '@tanstack/query-core', - version: '5.101.4', - changed: patched, - reason: - '`process.env.NODE_ENV === "development"` guards folded to ' + - '`false`, the same constant-fold a bundler performs, so the ' + - 'package runs in a browser without a build step or a shim.', - }, - }, - }, + bytes: lineageMap('tanstack-query-core', { + vendoredFrom: vendored.vendoredFrom, + forkedFrom: { + package: '@tanstack/query-core', + version: '5.101.4', + changed: patched, + reason: + '`process.env.NODE_ENV === "development"` guards folded to ' + + '`false`, the same constant-fold a bundler performs, so the ' + + 'package runs in a browser without a build step or a shim.', }, }), }, @@ -519,18 +516,13 @@ const FACES = [ let bytes = pack([ { path: 'importmap.json', - bytes: json({ - deck: { - packages: { - 'commit-mono': { - version: FONT, - // NO ENTRY. Nothing imports a font; this deck is addressable - // and deliberately not importable (L11 as a shape). - vendoredFrom: vendored.vendoredFrom, - }, - }, - }, - }), + bytes: lineageMap('commit-mono', { vendoredFrom: vendored.vendoredFrom }), + }, + { + path: 'package.json', + bytes: + files.get('package.json') ?? + json({ name: '@fontsource/commit-mono', version: FONT }), }, { path: 'font.css', bytes: Buffer.from(`${css}\n`, 'utf8') }, { path: 'LICENSE', bytes: files.get('LICENSE') ?? Buffer.from('', 'utf8') }, @@ -551,9 +543,52 @@ const FACES = [ console.log(' no entry declared — a deck you can address and cannot import'); } +// --------------------------------------------------------------------------- +// 6. driver.js — the product tour engine (ESM + CSS, unmodified). +// +// The module walk only reaches JS. The popover skin is a CSS export +// (`./dist/driver.css`) that nothing imports, so it has to be carried +// explicitly — same shape as highlight.js languages. +// --------------------------------------------------------------------------- +{ + // One ESM file + CSS. Upstream lost the module boundary at publish time; + // an explicit entry opts into that shape instead of failing the tree probe. + let vendored = await vendorFromNpmSource({ + spec: 'driver.js@1.8.0', + entry: 'dist/driver.js.mjs', + policy: POLICY, + }); + let cssPaths = ['dist/driver.css', 'dist/hints.css'].filter((path) => + vendored.pkg.files.has(path), + ); + let bytes = pack([ + { + path: 'importmap.json', + bytes: lineageMap('driver.js', { vendoredFrom: vendored.vendoredFrom }), + }, + ...vendored.files, + ...cssPaths.map((path) => ({ + path, + bytes: vendored.pkg.files.get(path), + })), + ]); + let { treeHash, reused } = await publishOnce( + storeDir, + 'lib/driver-js', + '1.8.0', + bytes, + { tag: 'latest' }, + ); + console.log( + ` lib/driver-js@1.8.0 ${vendored.modules.length} modules · ` + + `${cssPaths.length} css · ` + + `${treeHash.slice(0, 12)}…${reused ? ' (already sealed)' : ''}`, + ); +} + let store = await inspectStore(storeDir); console.log( `\n store: ${store.objects ?? store.objectCount ?? '?'} objects · ` + `${((store.bytes ?? store.totalBytes ?? 0) / 1024 / 1024).toFixed(1)} MB\n`, ); -console.log(' next: deck lock --depot apps/hub/hub --publisher app --name browse\n'); +console.log(' next: deck lock --depot apps/hub/hub\n'); diff --git a/docs/deck-a-package-resolves-through-its-own-map.md b/docs/deck-a-package-resolves-through-its-own-map.md index e0e8285..71c7305 100644 --- a/docs/deck-a-package-resolves-through-its-own-map.md +++ b/docs/deck-a-package-resolves-through-its-own-map.md @@ -1,5 +1,13 @@ # A package resolves through its own map +**Status: SUPERSEDED for the consuming depot** (2026-08-12) by +[`deck-package-json-and-depot-lock.md`](deck-package-json-and-depot-lock.md). +A package's `package.json` is a suggestion; the depot-root import map is +the only resolution authority. Same pack, two depots, two programs — that +is the owner's jurisdiction. This file remains the record of the earlier +ruling and still describes sealed-map behaviour for trees that have no +depot lock (dual-lock fallback). + **Status: RULING** (2026-08-11). Companions: [`deck-multi-package-design.md`](deck-multi-package-design.md) (the map format, and the design this narrows), diff --git a/docs/deck-hub-tour.md b/docs/deck-hub-tour.md new file mode 100644 index 0000000..38a9705 --- /dev/null +++ b/docs/deck-hub-tour.md @@ -0,0 +1,156 @@ +# Hub tour — four stories (read-only) + +A walk of the **protocol** through live Hub UI — not a tutorial on using the +Hub. Each chapter is one use case from +[`deck-use-cases.md`](deck-use-cases.md). The UI is the exhibit; the bottom +bar holds the story caption. Highlights are theme-coloured rings, not +overlays that bury the stage. + +**In the Hub:** `/start` → **Walk the stories**, or `/start?tour=1`. The +bottom bar owns Next / Prev / Exit. Esc abandons. Nothing in this script +publishes, releases, locks, accepts, or restores. + +Companions: [`deck-hub-brief.md`](deck-hub-brief.md), +[`deck-demo-brief.md`](deck-demo-brief.md), +[`deck-use-cases.md`](deck-use-cases.md). + +--- + +## Bring the Hub up + +```bash +node apps/hub/setup.mjs # once — Hub demo packs +# demos under examples/*/ must have been run so their depot/ stores exist +DECKD_URL=http://127.0.0.1:8787 node apps/hub/hub.mjs +``` + +| App | Question | +| --- | --- | +| `/start` | What is this machine holding right now? | +| `/browse` | What are these bytes? | +| `/run` | What do they do when assembled? | +| `/track` | What changed? | + +**Rule:** if a control would write — skip it. Prefer subject, volatility, +Timeline scrub, list browsing, and `?with=` trials. Prefer **live** paths +over brittle `@exact` pins when the tip is enough. + +--- + +## Product behaviour + +While the tour is active: + +- **Theme highlight** — a cutout plus a pulsing ring. No popover over the UI. +- **Caption in the bottom bar** — chapter · step · story line · exhibit. +- Interactions are *exercised*: **list-browse**, **dropdown**, **scroll**, + **Timeline scrub**. +- Chapter boundaries navigate with `?tour=1` and session state. + +--- + +## Chapter map + +| # | Chapter | Use case | Exhibit | +| --- | --- | --- | --- | +| 1 | Eight desks, one board | §1 swarm | `two-agents` | +| 2 | Two currencies land in the same minute | §2 codelist | `codelist` | +| 3 | Try it before you take it | §3 review-by-running | `contribute-to-codelist` | +| 4 | The hour you abandoned is still an address | §4 high-frequency keep | `vale-sale` | + +Within each chapter, flip browse ↔ run ↔ track on the **same subject** — +that flip is the lesson. + +--- + +## Interaction choreography + +| Interaction | Where | What the tour does | +| --- | --- | --- | +| **List-browse** | Ch1 — `/browse?at=/two-agents` | Select a desk row (`agent-a` / `charts`) | +| **Dropdown** | Ch2 — currencies `@latest`; Ch4 — vale/sale | Open the version `` + + `

    ` + + `` + + `
    ` + + `
    ` + + ``; +} + /** Branch and fork are one lineage control; mutability decides which verbs appear. */ async function repositoryPanel(parsed, meta) { if (!parsed.depot || !parsed.publisher || !parsed.pkg) return ''; @@ -1635,7 +1649,7 @@ async function repositoryPanel(parsed, meta) { `
    ${esc(parsed.publisher)}/` + `${esc(parsed.pkg)}imported · read-only
    ` + ``; + `data-from="${esc(member)}" data-spec="${esc(parsed.spec ?? '')}">Fork` + repositoryActionDialog(); } if (!summary) return ''; let state = await getJson(summary.url).catch(() => undefined); @@ -1669,7 +1683,7 @@ async function repositoryPanel(parsed, meta) { `` + `
    ` + (state.initialized ? `${review}` : '') + `` + - `` + `` + repositoryActionDialog() ); } @@ -2113,48 +2127,43 @@ function expandGap(control) { } app.addEventListener('click', async (event) => { + let closeRepositoryDialog = event.target.closest('[data-repository-dialog-close]'); + if (closeRepositoryDialog) { + closeRepositoryDialog.closest('dialog')?.close(); + return; + } let repositoryAction = event.target.closest('[data-repository-action]'); if (repositoryAction) { - repositoryAction.disabled = true; - let response; - if (repositoryAction.dataset.repositoryAction === 'fork') { - let packageName = repositoryAction.dataset.from.split('/').at(-1); - let to = prompt('Fork to publisher/package', `me/${packageName}`); - if (!to) { repositoryAction.disabled = false; return; } - response = await fetch('/_hub/forks', { - method: 'POST', - headers: { accept: 'application/json', 'content-type': 'application/json' }, - body: JSON.stringify({ - depot: repositoryAction.dataset.depot, - from: repositoryAction.dataset.from, - to, - ...(repositoryAction.dataset.spec ? { spec: repositoryAction.dataset.spec } : {}), - }), - }); - } else { - let name = prompt('Branch name'); - if (!name) { repositoryAction.disabled = false; return; } - response = await fetch(`${repositoryAction.dataset.endpoint}/branches`, { - method: 'POST', - headers: { accept: 'application/json', 'content-type': 'application/json' }, - body: JSON.stringify({ name, from: 'main' }), - }); - } - if (!response.ok) { - let body = await response.json().catch(() => ({})); - repositoryAction.insertAdjacentHTML( - 'afterend', - ` ${esc(body.error ?? response.statusText)}`, - ); - repositoryAction.disabled = false; - return; - } - if (repositoryAction.dataset.repositoryAction === 'fork') { - let body = await response.json(); - location.href = body.browseUrl; - return; - } - await render(); + let dialog = app.querySelector('[data-repository-dialog]'); + let form = dialog.querySelector('[data-repository-form]'); + let action = repositoryAction.dataset.repositoryAction; + let packageName = repositoryAction.dataset.from?.split('/').at(-1); + Object.assign(form.dataset, { + action, + depot: repositoryAction.dataset.depot ?? '', + from: repositoryAction.dataset.from ?? '', + spec: repositoryAction.dataset.spec ?? '', + endpoint: repositoryAction.dataset.endpoint ?? '', + }); + dialog.querySelector('[data-repository-dialog-title]').textContent = action === 'fork' ? 'Create a fork' : 'Create a branch'; + dialog.querySelector('[data-repository-dialog-label]').textContent = action === 'fork' ? 'Fork to publisher/package' : 'Branch name'; + dialog.querySelector('[data-repository-dialog-help]').textContent = action === 'fork' + ? `Creates a mutable package from ${repositoryAction.dataset.from}. The source stays unchanged.` + : 'Creates an isolated line of work from main in this package.'; + dialog.querySelector('[data-repository-dialog-submit]').textContent = action === 'fork' ? 'Create fork' : 'Create branch'; + dialog.querySelector('[data-repository-dialog-error]').textContent = ''; + let input = form.elements.target; + input.value = action === 'fork' ? `me/${packageName}` : ''; + input.pattern = action === 'fork' + ? '[a-z0-9][a-z0-9._-]{0,63}/[a-z0-9][a-z0-9._-]{0,63}' + : '(?!.*\\.\\.)[a-z0-9][a-z0-9._/-]{0,127}'; + input.title = action === 'fork' + ? 'Use publisher/package with lowercase letters, numbers, dots, dashes, or underscores.' + : 'Start with a lowercase letter or number; then use lowercase letters, numbers, dots, dashes, underscores, or slashes.'; + repositoryAction.closest('details')?.removeAttribute('open'); + dialog.showModal(); + input.focus(); + input.select(); return; } let gap = event.target.closest('.gap[data-gap]'); @@ -2192,6 +2201,53 @@ app.addEventListener('click', async (event) => { go(link.dataset.at, link.dataset.vs ?? currentVs()); }); +app.addEventListener('submit', async (event) => { + let form = event.target.closest('[data-repository-form]'); + if (!form) return; + event.preventDefault(); + let input = form.elements.target; + let value = input.value.trim(); + let valid = form.dataset.action === 'fork' + ? /^[a-z0-9][a-z0-9._-]{0,63}\/[a-z0-9][a-z0-9._-]{0,63}$/.test(value) + : /^[a-z0-9][a-z0-9._/-]{0,127}$/.test(value) && !value.includes('..'); + if (!valid) { + form.querySelector('[data-repository-dialog-error]').textContent = input.title; + input.focus(); + return; + } + let submit = form.querySelector('[type="submit"]'); + let error = form.querySelector('[data-repository-dialog-error]'); + submit.disabled = true; + error.textContent = ''; + repositoryMutationBusy = true; + try { + let response = form.dataset.action === 'fork' + ? await fetch('/_hub/forks', { + method: 'POST', + headers: { accept: 'application/json', 'content-type': 'application/json' }, + body: JSON.stringify({ depot: form.dataset.depot, from: form.dataset.from, to: value, ...(form.dataset.spec ? { spec: form.dataset.spec } : {}) }), + }) + : await fetch(`${form.dataset.endpoint}/branches`, { + method: 'POST', + headers: { accept: 'application/json', 'content-type': 'application/json' }, + body: JSON.stringify({ name: value, from: 'main' }), + }); + let body = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(body.error ?? response.statusText); + if (form.dataset.action === 'fork') { + location.href = body.browseUrl; + return; + } + form.closest('dialog').close(); + await render(); + } catch (caught) { + error.textContent = caught.message; + } finally { + repositoryMutationBusy = false; + submit.disabled = false; + } +}); + // Both controls are reachable from the keyboard, because both are buttons in // everything but element name — and a review you can only operate with a // pointer is a review this app's own legend would be lying about. @@ -2273,12 +2329,12 @@ app.addEventListener('change', (event) => { // they had folded, several times a minute while an agent is running. Reading // code in a live depot has to be possible. onActivityChange(() => { - if (app.querySelector('pre.blob')) return; + if (app.querySelector('pre.blob') || app.querySelector('dialog[open]')) return; render(); }); onLiveChange(() => { loadActivity(); - if (app.querySelector('[data-repository]')) render(); + if (!repositoryMutationBusy && !app.querySelector('dialog[open]') && app.querySelector('[data-repository]')) render(); }); render(); diff --git a/apps/hub/hub/ui/theme/theme.css b/apps/hub/hub/ui/theme/theme.css index 8dbceb6..669bfa7 100644 --- a/apps/hub/hub/ui/theme/theme.css +++ b/apps/hub/hub/ui/theme/theme.css @@ -951,6 +951,19 @@ button.aside { margin-left: 1.5ch; align-self: center; } .repo-new { border: 1px solid var(--line-strong); padding: .4em 1ch; background: var(--panel); color: var(--text); font: inherit; cursor: pointer; } .repo-new.primary { border-color: var(--accent); color: var(--accent); } .repo-context-readonly .visibility { margin-right: auto; } +.repo-action-dialog { width: min(31rem, calc(100vw - 2rem)); padding: 0; border: 1px solid var(--line-strong); background: var(--bg); color: var(--text); box-shadow: 0 1.5rem 4rem rgb(0 0 0 / .45); } +.repo-action-dialog::backdrop { background: rgb(20 22 30 / .55); backdrop-filter: blur(2px); } +.repo-action-dialog form { display: grid; gap: 1rem; padding: 1.1rem; } +.repo-action-dialog header { display: flex; align-items: flex-start; justify-content: space-between; gap: 1rem; } +.repo-action-dialog header > div { display: flex; align-items: baseline; gap: .75rem; } +.repo-action-dialog h2 { margin: 0; font-size: 1rem; font-weight: 650; } +.repo-dialog-close { border: 0; background: transparent; color: var(--dim); font: inherit; font-size: 1.25rem; cursor: pointer; } +.repo-action-dialog label { display: grid; gap: .4rem; color: var(--dim); font-size: .85rem; } +.repo-action-dialog input { width: 100%; box-sizing: border-box; padding: .65rem .7rem; border: 1px solid var(--line-strong); background: var(--panel); color: var(--text); font: inherit; } +.repo-action-dialog input:focus { outline: 2px solid var(--accent); outline-offset: 1px; } +.repo-action-dialog p { margin: 0; } +.repo-dialog-error:empty { display: none; } +.repo-action-dialog footer { display: flex; justify-content: flex-end; gap: .5rem; padding-top: .2rem; } @media (max-width: 44rem) { .repo-context { align-items: flex-start; } .repo-identity { flex-wrap: wrap; } From ade89ec4f43818eedb69439d8dba817f7fb620b1 Mon Sep 17 00:00:00 2001 From: Chris Date: Thu, 20 Aug 2026 08:55:29 +0800 Subject: [PATCH 08/23] feat: use a real fulfillment review fixture --- docs/deck-repository-tour.md | 12 +++++----- examples/collaboration/README.md | 7 ++++-- .../collaboration/depot/acme/editor/index.js | 12 +++++----- .../collaboration/depot/acme/theme/index.js | 11 ++++++--- .../depot/app/dashboard/index.html | 23 ++++++++++--------- .../depot/app/dashboard/index.js | 17 +++++++------- 6 files changed, 46 insertions(+), 36 deletions(-) diff --git a/docs/deck-repository-tour.md b/docs/deck-repository-tour.md index d6485c5..b9a3aa4 100644 --- a/docs/deck-repository-tour.md +++ b/docs/deck-repository-tour.md @@ -37,7 +37,7 @@ The direct command is: deck branch \ --depot examples/collaboration/depot \ --repo acme/dashboard \ - --name agent/review-workbench \ + --name ops/delivery-exceptions \ --from main ``` @@ -62,8 +62,8 @@ Edit any member directories. Then capture them atomically: deck commit \ --depot examples/collaboration/depot \ --repo acme/dashboard \ - --branch agent/review-workbench \ - --message "Build repository review workbench" + --branch ops/delivery-exceptions \ + --message "Prioritize delayed shipments" ``` One Revision now points to one Repository containing exact tree hashes for @@ -76,10 +76,10 @@ lands as a unit. deck review \ --depot examples/collaboration/depot \ --repo acme/dashboard \ - --source agent/review-workbench \ + --source ops/delivery-exceptions \ --target main \ - --title "Build repository review workbench" \ - --body "Changes the app, editor, and theme together." + --title "Prioritize delayed shipments" \ + --body "Adds risk-ranked exceptions, owner routing, and alert tokens across the dashboard, queue component, and theme." ``` Visit [Review](http://localhost:8788/review?repository=acme/dashboard). The diff --git a/examples/collaboration/README.md b/examples/collaboration/README.md index b16d8e7..56b9827 100644 --- a/examples/collaboration/README.md +++ b/examples/collaboration/README.md @@ -12,8 +12,11 @@ The Repository contains three independently addressable packages: - `acme/editor` - `acme/theme` -The current workbench change touches all three, making partial package landing -visibly wrong and exercising atomic Repository revisions. +The current delivery-exceptions change touches all three: the app introduces a +risk-ranked fulfillment view, the editor package supplies the exception queue, +and the theme adds operational alert tokens. Landing only one package would +break the runnable result, which makes this a concrete atomic Review rather +than an example about Deck itself. Start the real services: diff --git a/examples/collaboration/depot/acme/editor/index.js b/examples/collaboration/depot/acme/editor/index.js index 7412772..453d323 100644 --- a/examples/collaboration/depot/acme/editor/index.js +++ b/examples/collaboration/depot/acme/editor/index.js @@ -1,15 +1,15 @@ import { theme } from 'acme/theme'; -export function reviewQueue(items) { - return `
      ${items +export function exceptionQueue(items) { + return `
        ${items .map( - (item) => `
      1. ${item.state}` + - `
        ${item.title}${item.packages} packages · ${item.author}
        ` + - `#${item.number}
      2. `, + (item) => `
      3. ${item.risk}` + + `
        ${item.shipment}${item.route} · ${item.reason}
        ` + + `${item.delay}${item.owner}
      4. `, ) .join('')}
      `; } export function editorClass() { - return `editor editor--${theme.density}`; + return `operations operations--${theme.density}`; } diff --git a/examples/collaboration/depot/acme/theme/index.js b/examples/collaboration/depot/acme/theme/index.js index e277b6d..d37f227 100644 --- a/examples/collaboration/depot/acme/theme/index.js +++ b/examples/collaboration/depot/acme/theme/index.js @@ -1,5 +1,10 @@ export const theme = { - surface: 'surface-review', - density: 'compact', - colors: { accent: '#9bfab0', warning: '#ffd479', muted: '#8d91a7' }, + surface: 'surface-fulfillment', + density: 'comfortable', + colors: { + accent: '#7dd3fc', + critical: '#fb7185', + warning: '#fbbf24', + muted: '#94a3b8', + }, }; diff --git a/examples/collaboration/depot/app/dashboard/index.html b/examples/collaboration/depot/app/dashboard/index.html index f3bc1c5..ddb1e63 100644 --- a/examples/collaboration/depot/app/dashboard/index.html +++ b/examples/collaboration/depot/app/dashboard/index.html @@ -3,7 +3,7 @@ - Repository workbench + Delivery control diff --git a/examples/collaboration/depot/app/dashboard/index.js b/examples/collaboration/depot/app/dashboard/index.js index 9518595..009a233 100644 --- a/examples/collaboration/depot/app/dashboard/index.js +++ b/examples/collaboration/depot/app/dashboard/index.js @@ -1,16 +1,17 @@ -import { editorClass, reviewQueue } from 'acme/editor'; +import { editorClass, exceptionQueue } from 'acme/editor'; import { theme } from 'acme/theme'; -const work = [ - { number: 18, title: 'Unify deployment controls', packages: 3, author: 'Mina', state: 'ready' }, - { number: 17, title: 'Stream audit events', packages: 2, author: 'Theo', state: 'checks' }, - { number: 15, title: 'Retire legacy token flow', packages: 4, author: 'Ada', state: 'draft' }, +const exceptions = [ + { shipment: 'ACM-2048', route: 'Penang → Singapore', reason: 'customs inspection', delay: '+18h', owner: 'Mina', risk: 'critical' }, + { shipment: 'ACM-1987', route: 'Osaka → Seattle', reason: 'missed connection', delay: '+9h', owner: 'Theo', risk: 'high' }, + { shipment: 'ACM-2071', route: 'Berlin → Lisbon', reason: 'carrier capacity', delay: '+4h', owner: 'Ada', risk: 'watch' }, + { shipment: 'ACM-2059', route: 'Austin → Toronto', reason: 'weather hold', delay: '+3h', owner: 'Jules', risk: 'watch' }, ]; export function dashboard() { return `
      -

      ACME / OPERATIONS

      Repository workbench

      -
      3open reviews
      7member packages
      maindefault branch
      -

      Review queue

      updated now
      ${reviewQueue(work)}
      +

      ACME / FULFILLMENT

      Delivery control

      +
      12shipments at risk
      94.2%on time this week
      3carrier escalations
      +

      Exceptions requiring action

      ranked by customer impact
      ${exceptionQueue(exceptions)}
      `; } From dd0765fc8dc1a9f825c22e3dd59369a89b5bffa5 Mon Sep 17 00:00:00 2001 From: Chris Date: Thu, 20 Aug 2026 09:09:03 +0800 Subject: [PATCH 09/23] fix: explain missing proposal merge bases --- apps/hub/hub/app/track/track.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/hub/hub/app/track/track.js b/apps/hub/hub/app/track/track.js index 500564e..461d250 100644 --- a/apps/hub/hub/app/track/track.js +++ b/apps/hub/hub/app/track/track.js @@ -318,7 +318,8 @@ async function collect(scope, { historyLimit = HISTORY_PAGE } = {}) { publisher: publisher ?? '', pkg: pkg?.split('@')[0] ?? '', id: attrs.offer, - what: `${attrs.state ?? 'open'} · against ${attrs.upstream ?? ''}`, + what: `${attrs.state === 'unresolvable' ? 'merge base missing' : attrs.state ?? 'open'} · against ${attrs.upstream ?? ''}`, + detail: attrs.detail, state: attrs.state, when: '', at: `/${depot.name}/${attrs.offer}`, From 375e0b1089f892ad265dbd36b61f9a40ac1ff040 Mon Sep 17 00:00:00 2001 From: Chris Date: Fri, 21 Aug 2026 16:15:27 +0800 Subject: [PATCH 10/23] feat: checkpoint Deck collaboration and Boxel backport --- INSTALL.md | 14 +- README.md | 14 +- apps/hub/hub.mjs | 6 +- apps/hub/hub/app/browse/browse.js | 478 +++++++++---- apps/hub/hub/app/browse/package.json | 2 +- apps/hub/hub/app/run/package.json | 2 +- apps/hub/hub/app/run/run.js | 30 +- apps/hub/hub/app/start/start.js | 661 +++++++++++++----- apps/hub/hub/app/track/track.js | 4 +- apps/hub/hub/importmap.json | 4 +- .../timeline.js => history/history.js} | 32 +- apps/hub/hub/ui/history/package.json | 6 + apps/hub/hub/ui/timeline/package.json | 6 - apps/hub/hub/ui/tour/tour.js | 20 +- deckbook.html | 24 +- docs/artifacts/backport-gallery-scene.gts | 4 +- .../backport-remix-decklist-browser.json | 16 + .../backport-remix-decklist-rri.json | 16 - docs/artifacts/backport-remix-decklist.json | 14 +- docs/deck-a-package-carries-its-own-name.md | 4 +- ...-a-package-resolves-through-its-own-map.md | 9 +- docs/deck-activity-protocol.md | 2 +- docs/deck-at-rest.md | 140 ++-- docs/deck-boxel-backport-changelist-mockup.md | 510 ++++++++------ docs/deck-boxel-backport-for-team.md | 434 ++++++++---- docs/deck-boxel-backport-plan.md | 10 +- docs/deck-boxel-branch-index-backport.md | 589 ++++++++++++++++ docs/deck-boxel-host-publish-treehash.md | 600 ++++++++++++++++ docs/deck-boxel-monorepo-change-register.md | 10 +- docs/deck-cas-packages-vs-monorepos.md | 63 +- docs/deck-collaboration-protocol.md | 324 +++++---- docs/deck-competitive-gaps.md | 2 +- docs/deck-core-backport-split.md | 14 +- docs/deck-daemon.md | 179 +++-- docs/deck-depot-and-realm-backport.md | 89 ++- docs/deck-embroider-lessons.md | 2 +- docs/deck-fork-merge-pr-ux.md | 148 ++-- docs/deck-frame-version-vocabulary.md | 2 +- docs/deck-github-mirror-analysis.md | 30 +- docs/deck-hub-brief.md | 8 +- docs/deck-hub-tour.md | 12 +- docs/deck-in-motion.md | 6 +- docs/deck-live-autopublish.md | 2 +- docs/deck-lockfile-thought-experiment.md | 207 +++--- docs/deck-package-json-and-depot-lock.md | 122 +++- docs/deck-publish-as-a-pointer.md | 2 +- docs/deck-realm-development-use-cases.md | 2 +- docs/deck-repository-tour.md | 56 +- docs/deck-step-version-vocabulary.md | 232 +++--- docs/deck-storage-backends.md | 451 ++++++++++++ docs/deck-tick-version-vocabulary.md | 2 +- docs/deck-ui-spec.md | 2 +- docs/deck-unfrozen-mode.md | 61 +- docs/deck-vendor-source-mode.md | 16 +- docs/deck-work-plan-for-backport.md | 217 ++++-- examples/README.md | 2 +- examples/deckd-demo/depot/demo/hello/hello.js | 2 +- examples/hub/demos.mjs | 2 +- examples/potluck/demo.mjs | 4 +- examples/potluck/stages.mjs | 2 +- examples/potluck/steps.mjs | 2 +- examples/shared/depot.mjs | 2 +- examples/shared/reset.mjs | 6 +- examples/vale-sale/demo.mjs | 2 +- examples/vale-sale/steps.mjs | 2 +- packages/cli/src/commands/start.ts | 2 +- packages/cli/src/help.ts | 4 +- packages/cli/tests/cli-test.ts | 5 +- packages/core/src/lock.ts | 8 +- packages/core/src/store.ts | 2 +- packages/core/tests/store-test.ts | 2 +- packages/deckd/Cargo.toml | 4 +- packages/deckd/README.md | 56 +- packages/deckd/package.json | 2 +- packages/deckd/scripts/smoke.sh | 11 +- packages/deckd/src/api.rs | 34 +- packages/deckd/src/build.rs | 4 +- .../deckd/src/{timeline => history}/jj_lib.rs | 181 +++-- .../src/{timeline => history}/jj_lib_notes.rs | 8 +- .../deckd/src/{timeline => history}/mod.rs | 39 +- packages/deckd/src/main.rs | 24 +- packages/deckd/src/watch/mod.rs | 8 +- packages/history/package.json | 2 +- packages/history/src/create.ts | 4 +- packages/history/src/historyd.ts | 2 +- packages/server/src/browser-ui.ts | 2 +- packages/server/src/depot.ts | 2 +- packages/server/src/server.ts | 8 +- packages/server/src/watcher.ts | 16 +- packages/server/tests/history-test.ts | 12 +- .../server/tests/history-unfrozen-test.ts | 6 +- packages/server/tests/seal-on-watch-test.ts | 4 +- reference/at-rest-protocol-v2.md | 2 +- scripts/run-hub-with-deckd.sh | 4 +- 94 files changed, 4602 insertions(+), 1794 deletions(-) rename apps/hub/hub/ui/{timeline/timeline.js => history/history.js} (95%) create mode 100644 apps/hub/hub/ui/history/package.json delete mode 100644 apps/hub/hub/ui/timeline/package.json create mode 100644 docs/artifacts/backport-remix-decklist-browser.json delete mode 100644 docs/artifacts/backport-remix-decklist-rri.json create mode 100644 docs/deck-boxel-branch-index-backport.md create mode 100644 docs/deck-boxel-host-publish-treehash.md create mode 100644 docs/deck-storage-backends.md rename packages/deckd/src/{timeline => history}/jj_lib.rs (84%) rename packages/deckd/src/{timeline => history}/jj_lib_notes.rs (89%) rename packages/deckd/src/{timeline => history}/mod.rs (72%) diff --git a/INSTALL.md b/INSTALL.md index b83d69c..7d631f4 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -16,8 +16,8 @@ from this repository instead. | --- | --- | --- | | **Node** | ≥ 22 | required — the CLI is TypeScript with no build step, and relies on Node's native type stripping | | **pnpm** | 11 | required to install the workspace's dev dependencies (`packageManager` pins the exact version) | -| **jj** | any recent | optional — Timeline via the jj CLI when no daemon is running | -| **Rust / cargo** | stable | optional — only to build `packages/deckd`, the in-tree Timeline daemon | +| **jj** | any recent | optional — History via the jj CLI when no daemon is running | +| **Rust / cargo** | stable | optional — only to build `packages/deckd`, the in-tree History daemon | Verified on Node v24.17.0, pnpm 11.0.9, jj 0.43.0. @@ -98,16 +98,16 @@ pnpm test && pnpm lint Expect 189 core, 22 history, 79 server, 4 conformance, 13 CLI — all passing — and no lint or type errors. -## Check whether you got a Timeline +## Check whether every-save History is active ```bash deck hello ./playground ``` The banner it prints includes a line naming the history backend it attached -to, or the reason it could not. `jj` present means a Timeline and a Step per -save; anything else means Versions only, which is still every settled save as an -immutable version. +to, or the reason it could not. An attached deckd means every-save History and +a Step per save; anything else means Versions only, which still keeps every +settled save as an immutable version. ## Environment @@ -115,7 +115,7 @@ immutable version. | --- | --- | --- | | `DECK_KEYRING` | `~/.deck/keys` | where `deck keygen` writes private keys. They never leave the machine | | `JJ_BIN` | `jj` | which `jj` binary to seal with when no daemon URL is set | -| `DECKD_URL` | — | point the Timeline at in-tree **deckd** (`packages/deckd`, default listen `http://127.0.0.1:8787`). Preferred over `DECK_HISTORYD_URL` when both are set | +| `DECKD_URL` | — | point the History at in-tree **deckd** (`packages/deckd`, default listen `http://127.0.0.1:8787`). Preferred over `DECK_HISTORYD_URL` when both are set | | `DECK_HISTORYD_URL` | — | legacy alias for an external historyd; same HTTP contract as deckd | To run Hub with deckd from this clone: diff --git a/README.md b/README.md index a859ddd..2759911 100644 --- a/README.md +++ b/README.md @@ -255,7 +255,7 @@ of them in full. Open [deckbook.html](deckbook.html) (or `http://localhost:/deckbook.html` on a running server) for a teaching tour of the protocol against what this reference implementation actually runs — vocabulary, address space, live loop, -store/packs, Versions and the Timeline, vendor, fork/lock/offers, HTTP + CLI +store/packs, Versions and the History, vendor, fork/lock/offers, HTTP + CLI maps, four use cases worked end to end at the command line, and honest status. @@ -268,7 +268,7 @@ Design set in [docs/](docs/) — start with: here is commentary, design history, or a record of how something was measured. An implementation implements Deck by passing the suite. - [deck-at-rest.md](docs/deck-at-rest.md) — what a depot looks like on - disk: layout, store, addresses, and what the Timeline adds. The earlier + disk: layout, store, addresses, and what the History adds. The earlier ratified version is kept unedited at [reference/at-rest-protocol-v2.md](reference/at-rest-protocol-v2.md). - [deck-in-motion.md](docs/deck-in-motion.md) — the other half: offers, @@ -279,7 +279,7 @@ Design set in [docs/](docs/) — start with: is one suite Version against one tree hash, you run it locally, and the result is a statement signed by a person. Designed, not built. - [deck-step-version-vocabulary.md](docs/deck-step-version-vocabulary.md) - — Version, Step and Timeline, and the words they replaced. + — Version, Step and History, and the words they replaced. - [deck-hub-typography.md](docs/deck-hub-typography.md) — the type system for the documentation pages. - [deck-cas-v2-spec.md](docs/deck-cas-v2-spec.md) — next CAS work: @@ -309,7 +309,7 @@ Design set in [docs/](docs/) — start with: trie + optional `story=1` run/code/commentary) for gutters and the watch canvas. ([old heat note](docs/deck-heat-protocol.md) points here.) - [deck-step-version-vocabulary.md](docs/deck-step-version-vocabulary.md) - — **ruled.** UI: **Version** / **Step** / **Timeline**; imports: + — **ruled.** UI: **Version** / **Step** / **History**; imports: **Import** / **Pin** / **Entry** / **Leaf** / **Closure**. - [deck-pack-equivalence.md](docs/deck-pack-equivalence.md) — deck ⇄ pack: four homes, treeHash identity, carriage modes, round-trip @@ -374,12 +374,12 @@ into Tools. **three-way merge** (line-level for text, key-level for JSON), and **offers** — a fork with a recorded base, planned and rebased as a pure function. -- **`packages/history`** — the Timeline behind one contract, attached +- **`packages/history`** — the History behind one contract, attached through **deckd** (jj-lib over localhost HTTP). A dead daemon degrades to Versions only, with a printed reason. Restores replay forward as ordinary writes, so history only ever grows. -- **`packages/deckd`** — Rust Timeline daemon (jj-lib + watchexec-shaped FS - seals). Hub attaches it via `DECKD_URL`; see `./scripts/run-hub-with-deckd.sh`. +- **`packages/deckd`** — Rust History daemon (jj-lib + watchexec-shaped FS + captures). Hub attaches it via `DECKD_URL`; see `./scripts/run-hub-with-deckd.sh`. - **`packages/server`** — read-only zero-framework serving of depots: version-less = the working tree (`no-store`); exact = immutable + Content-Digest; range/tag = one 302 hop; bare/alias = 301; `?meta` diff --git a/apps/hub/hub.mjs b/apps/hub/hub.mjs index 4d71b59..725c3f8 100644 --- a/apps/hub/hub.mjs +++ b/apps/hub/hub.mjs @@ -178,12 +178,12 @@ async function serveSidecar(depot, path, res) { // --------------------------------------------------------------------------- -// Tier-2 Timeline is on via deckd; `DECK_HUB_HISTORY=0` turns it off. +// Tier-2 History is on via deckd; `DECK_HUB_HISTORY=0` turns it off. const WANT_HISTORY = process.env.DECK_HUB_HISTORY !== '0'; // Without DECKD_URL the Hub stays Versions-only — never a local jj watcher. if (WANT_HISTORY && !process.env.DECKD_URL && !process.env.DECK_HISTORYD_URL) { console.warn( - ' history: DECKD_URL unset — Timeline off (run ./scripts/run-hub-with-deckd.sh)', + ' history: DECKD_URL unset — History off (run ./scripts/run-hub-with-deckd.sh)', ); } @@ -200,7 +200,7 @@ let hub = { ).backend : undefined, }; -// Demo depots seal through the same deckd. /run loads Timeline from +// Demo depots seal through the same deckd. /run loads History from // `//_history` on THIS server — without a backend attached here that // route answers `no-history` even when seals exist on disk. let demos = await demoDepots(); diff --git a/apps/hub/hub/app/browse/browse.js b/apps/hub/hub/app/browse/browse.js index 47e3626..c4095f1 100644 --- a/apps/hub/hub/app/browse/browse.js +++ b/apps/hub/hub/app/browse/browse.js @@ -11,7 +11,7 @@ // // FOUR KINDS OF ADDRESS, one control. // ═══════════════════════════════════ -// L5 says a deck has three kinds of address. A depot with a Timeline has +// L5 says a deck has three kinds of address. A depot with every-save History has // a fourth, and it is the one that matters most: // // live //// every save, no-store @@ -38,11 +38,11 @@ import { import { attachScrub, foldBursts, - installTimelineStyles, + installHistoryStyles, stepBy, stepsFor, - timelineHtml, -} from 'ui/timeline'; + historyHtml, +} from 'ui/history'; import { diffLines, diffWordsWithSpace, structuredPatch } from 'diff'; import hljs from 'highlight-js'; import { @@ -56,7 +56,7 @@ import { storeReactivityBindings } from '@tanstack/table-core/dist/store-reactiv installNavStyles(); installActivityStyles(); -installTimelineStyles(); +installHistoryStyles(); const app = document.getElementById('app'); let repositoryMutationBusy = false; @@ -87,17 +87,41 @@ const esc = (value) => const DEPOT_LOCK = 'importmap.json'; const LANGUAGE = { - mjs: 'javascript', cjs: 'javascript', js: 'javascript', jsx: 'javascript', + mjs: 'javascript', + cjs: 'javascript', + js: 'javascript', + jsx: 'javascript', json: 'json', - ts: 'typescript', tsx: 'typescript', mts: 'typescript', - htm: 'xml', html: 'xml', svg: 'xml', vue: 'xml', - md: 'markdown', markdown: 'markdown', - yml: 'yaml', toml: 'ini', cfg: 'ini', - sh: 'bash', zsh: 'bash', bash: 'bash', - py: 'python', rb: 'ruby', rs: 'rust', kt: 'kotlin', gradle: 'groovy', - h: 'c', hpp: 'cpp', cc: 'cpp', mm: 'objectivec', - frag: 'glsl', vert: 'glsl', glsl: 'glsl', - gts: 'typescript', gjs: 'javascript', hbs: 'handlebars', + ts: 'typescript', + tsx: 'typescript', + mts: 'typescript', + htm: 'xml', + html: 'xml', + svg: 'xml', + vue: 'xml', + md: 'markdown', + markdown: 'markdown', + yml: 'yaml', + toml: 'ini', + cfg: 'ini', + sh: 'bash', + zsh: 'bash', + bash: 'bash', + py: 'python', + rb: 'ruby', + rs: 'rust', + kt: 'kotlin', + gradle: 'groovy', + h: 'c', + hpp: 'cpp', + cc: 'cpp', + mm: 'objectivec', + frag: 'glsl', + vert: 'glsl', + glsl: 'glsl', + gts: 'typescript', + gjs: 'javascript', + hbs: 'handlebars', }; const loaded = new Map(); @@ -221,11 +245,14 @@ function addressOf({ depot, publisher, pkg, spec, path, seal }) { */ function addressHtml(parsed) { let out = []; - if (parsed.depot) out.push(`${esc(parsed.depot)}`); + if (parsed.depot) + out.push(`${esc(parsed.depot)}`); if (parsed.seal) { out.push(`${HISTORY}`); if (parsed.seal !== true) { - out.push(`${esc(String(parsed.seal).slice(0, 12))}`); + out.push( + `${esc(String(parsed.seal).slice(0, 12))}`, + ); } } else { if (parsed.publisher) @@ -239,8 +266,7 @@ function addressHtml(parsed) { ); } } - if (parsed.path) - out.push(`${esc(parsed.path)}`); + if (parsed.path) out.push(`${esc(parsed.path)}`); return out.join('/'); } @@ -265,7 +291,11 @@ function go(at, vs = currentVs(), replace = false) { // must not look alike (UI spec §12). An invalid spec can never resolve — no // future publish can rescue it — while a legal tag nobody has published yet is // a question of time. -const PERMANENT = new Set(['invalid-version-spec', 'invalid-revision', 'not-found']); +const PERMANENT = new Set([ + 'invalid-version-spec', + 'invalid-revision', + 'not-found', +]); async function getJson(url) { let response = await fetch(url, { headers: { accept: 'application/json' } }); @@ -299,7 +329,7 @@ async function sealsOf(depot) { } // --------------------------------------------------------------------------- -// The Timeline, in the reading lens +// The History, in the reading lens // // /run has the same scrubber and scopes it differently, and the difference is // the whole reason both exist. @@ -361,8 +391,8 @@ function atStep(parsed, changeId) { return `/${[parsed.depot, HISTORY, changeId, within].filter(Boolean).join('/')}`; } -/** The Timeline the last render drew, so the keys agree with what is on screen. */ -let lastTimeline = []; +/** The History the last render drew, so the keys agree with what is on screen. */ +let lastHistory = []; /** * The scrubber, or nothing at all. @@ -371,13 +401,15 @@ let lastTimeline = []; * history, and a tape with no steps on it is furniture), and nothing on the * depot list, which is not an address in time. */ -async function timelineBar(parsed) { +async function historyBar(parsed) { if (!parsed.depot) return ''; let scope = scopeOf(parsed); - let steps = foldBursts(stepsFor(await sealsOf(parsed.depot), scope ? [scope] : [])); - lastTimeline = steps; + let steps = foldBursts( + stepsFor(await sealsOf(parsed.depot), scope ? [scope] : []), + ); + lastHistory = steps; if (!steps.length) return ''; - return timelineHtml({ + return historyHtml({ steps, current: parsed.seal && parsed.seal !== true ? String(parsed.seal) : '', // Not "undo". /run frames a running app, where stepping back IS the undo @@ -458,7 +490,8 @@ function crumbs(parsed) { * that is not a rearrangement — it is the wrong answer. */ function byVersionDesc(a, b) { - let parts = (value) => value.split(/[.\-+]/).map((bit) => (/^\d+$/.test(bit) ? Number(bit) : bit)); + let parts = (value) => + value.split(/[.\-+]/).map((bit) => (/^\d+$/.test(bit) ? Number(bit) : bit)); let left = parts(a); let right = parts(b); for (let index = 0; index < Math.max(left.length, right.length); index += 1) { @@ -519,17 +552,62 @@ function whenPublished(meta, version) { * of the software. Unknown sorts last rather than first: absent is not new. */ export function publishedOrder(record) { - let when = Date.parse(record?.upstreamPublishedAt ?? record?.publishedAt ?? ''); + let when = Date.parse( + record?.upstreamPublishedAt ?? record?.publishedAt ?? '', + ); return Number.isFinite(when) ? when : -Infinity; } -function addressOptions(parsed, meta, seals) { +function repositoryVersionNote(repository, version) { + if (!repository) return ''; + let branches = (repository.branches ?? []).filter( + (branch) => branch.projection?.version === version, + ); + if (!branches.length) return ''; + let main = branches.find((branch) => branch.name === 'main'); + let merged = + main && + (repository.pulls ?? []).find( + (pull) => + pull.state === 'merged' && + pull.events?.some( + (event) => + event.type === 'merged' && event.revisionHash === main.revisionHash, + ), + ); + let labels = []; + if (main) { + labels.push( + merged + ? `main · merged Review #${merged.number}: ${merged.title}` + : 'main', + ); + } + labels.push( + ...branches + .filter((branch) => branch !== main) + .map((branch) => branch.name), + ); + return `${labels.join(' · ')} · `; +} + +function addressOptions(parsed, meta, seals, repository) { let options = []; if (meta?.live) { - options.push({ value: '', label: 'live', note: 'bytes on disk · no-store', kind: 'live' }); + options.push({ + value: '', + label: 'live', + note: 'bytes on disk · no-store', + kind: 'live', + }); } for (let [tag, version] of Object.entries(meta?.distTags ?? {})) { - options.push({ value: tag, label: tag, note: `→ ${version} · one hop`, kind: 'tag' }); + options.push({ + value: tag, + label: tag, + note: `→ ${version} · one hop`, + kind: 'tag', + }); } // NEWEST FIRST, AND BY NUMBER. A lexical sort puts dev.10 between dev.1 and // dev.2, so the list read `dev.2, dev.10, dev.1` — which is not an ordering @@ -539,7 +617,7 @@ function addressOptions(parsed, meta, seals) { options.push({ value: version, label: version, - note: `${whenPublished(meta, version)}immutable · forever`, + note: `${repositoryVersionNote(repository, version)}${whenPublished(meta, version)}immutable · forever`, kind: 'exact', }); } @@ -556,9 +634,13 @@ function addressOptions(parsed, meta, seals) { // ago", and a picker whose entries are indistinguishable is a picker // you cannot use. The clock says which era, the id says which one. label: `${relative(seal.timestamp)} · ${seal.changeId.slice(0, 8)}`, - note: `${(seal.timestamp ?? '').slice(0, 16).replace('T', ' ')} · ${ - seal.changeId.slice(0, 12) - } · ${seal.description?.replace(/^save:\s*/, '') ?? 'step'}`.slice(0, 78), + note: `${(seal.timestamp ?? '').slice(0, 16).replace('T', ' ')} · ${seal.changeId.slice( + 0, + 12, + )} · ${seal.description?.replace(/^save:\s*/, '') ?? 'step'}`.slice( + 0, + 78, + ), kind: 'seal', }); } @@ -570,14 +652,16 @@ function addressOptions(parsed, meta, seals) { function addressFor(parsed, value) { if (value.startsWith('seal:')) { let rev = value.slice(5); - let path = [parsed.publisher, parsed.pkg, parsed.path].filter(Boolean).join('/'); + let path = [parsed.publisher, parsed.pkg, parsed.path] + .filter(Boolean) + .join('/'); return `/${parsed.depot}/${HISTORY}/${rev}${path ? `/${path}` : ''}`; } return addressOf({ ...parsed, seal: undefined, spec: value || undefined }); } -function volatility(parsed, meta, seals) { - let options = addressOptions(parsed, meta, seals); +function volatility(parsed, meta, seals, repository) { + let options = addressOptions(parsed, meta, seals, repository); if (!options.length) return ''; let selected = parsed.seal ? `seal:${parsed.seal}` : (parsed.spec ?? ''); let kind = parsed.seal ? 'seal' : selected === '' ? 'live' : 'sealed'; @@ -612,8 +696,8 @@ function volatility(parsed, meta, seals) { * so a comparison confined to one deck's version list can never express "what * did the fork change". The third is what you want at 2am. */ -function compare(parsed, meta, seals, vs) { - let options = addressOptions(parsed, meta, seals).filter( +function compare(parsed, meta, seals, vs, repository) { + let options = addressOptions(parsed, meta, seals, repository).filter( (option) => option.value !== '', ); let isAddress = vs.startsWith('/'); @@ -628,7 +712,9 @@ function compare(parsed, meta, seals, vs) { `${esc(option.label)}`, ) .join('') + - (isAddress ? `` : '') + + (isAddress + ? `` + : '') + ` ` ); @@ -807,7 +893,10 @@ function listing(parsed, attrs) { let at = addressOf({ ...parsed, path: entry.path }); return ( `` + - activityCell(scale(heats[index].score), { last: heats[index].last, claims: heats[index].claims }) + + activityCell(scale(heats[index].score), { + last: heats[index].last, + claims: heats[index].claims, + }) + `` + `${esc(entry.name)}${entry.type === 'dir' ? '/' : ''}` + @@ -861,7 +950,8 @@ function blob(text, path) { requestIdleCallback(() => { element.insertAdjacentHTML('beforeend', lineSpans(rest, CHUNK)); linkifyJsonUrls(element, path); - if (location.hash) document.querySelector(location.hash)?.scrollIntoView(); + if (location.hash) + document.querySelector(location.hash)?.scrollIntoView(); }); } else { linkifyJsonUrls(element, path); @@ -884,7 +974,9 @@ async function lockLabel(depot, atSeal) { let seals = await sealsOf(depot); if (atSeal) { let step = seals.find((seal) => seal.changeId === atSeal); - return step?.timestamp ? `compiled ${relative(step.timestamp)}` : 'compiled'; + return step?.timestamp + ? `compiled ${relative(step.timestamp)}` + : 'compiled'; } let steps = stepsFor(seals, [DEPOT_LOCK]); let when = steps[0]?.timestamp; @@ -906,7 +998,8 @@ function linkifyJsonUrls(root, path) { let walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); let nodes = []; while (walker.nextNode()) nodes.push(walker.currentNode); - let pattern = /\/[A-Za-z0-9._~-]+(?:\/[A-Za-z0-9._~@+/-]*)?|\.\/[A-Za-z0-9._~/@+-]+/g; + let pattern = + /\/[A-Za-z0-9._~-]+(?:\/[A-Za-z0-9._~@+/-]*)?|\.\/[A-Za-z0-9._~/@+-]+/g; for (let node of nodes) { if (node.parentElement?.closest('a')) continue; let text = node.nodeValue ?? ''; @@ -1140,7 +1233,9 @@ function reviewFile({ id, path, before, after, open = true, href, vs }) { if (beforeLines.at(-1) === '') beforeLines.pop(); if (afterLines.at(-1) === '') afterLines.pop(); - let patch = structuredPatch('a', 'b', before, after, '', '', { context: CONTEXT }); + let patch = structuredPatch('a', 'b', before, after, '', '', { + context: CONTEXT, + }); let added = 0; let removed = 0; let body = ''; @@ -1156,12 +1251,20 @@ function reviewFile({ id, path, before, after, open = true, href, vs }) { let flush = (addedLines) => { let paired = pairWords(pendingRemoved, addedLines); pendingRemoved.forEach((line, position) => { - body += diffLine('-', oldNumber - pendingRemoved.length + position, '', - paired ? paired[position][0] : esc(line)); + body += diffLine( + '-', + oldNumber - pendingRemoved.length + position, + '', + paired ? paired[position][0] : esc(line), + ); }); addedLines.forEach((line, position) => { - body += diffLine('+', '', newNumber - addedLines.length + position, - paired ? paired[position][1] : esc(line)); + body += diffLine( + '+', + '', + newNumber - addedLines.length + position, + paired ? paired[position][1] : esc(line), + ); }); pendingRemoved = []; }; @@ -1263,7 +1366,7 @@ async function viewDepots() { `${ depot.history ? `Timeline` + `data-at="/${esc(depot.name)}/${HISTORY}">History` : 'Versions' }` + `${esc(depot.dir)}`, @@ -1304,7 +1407,10 @@ async function viewCatalog(parsed) { let newest = deck.distTags?.latest ?? versions.at(-1); return ( `` + - activityCell(scale(heats[index].score), { last: heats[index].last, claims: heats[index].claims }) + + activityCell(scale(heats[index].score), { + last: heats[index].last, + claims: heats[index].claims, + }) + `` + `${parsed.publisher ? '' : `${esc(deck.publisher)}/`}` + `${esc(deck.name)}` + @@ -1353,7 +1459,7 @@ async function viewSealLog(parsed) { if (!seals.length) { return ( crumbs(parsed) + - `

      History: Versions — no Timeline

      ` + `

      Version history only

      ` ); } let rows = seals @@ -1388,7 +1494,7 @@ async function viewSeal(parsed, vs) { if (!attrs.filesAvailable) { return ( crumbs(parsed) + - `

      Timeline cannot list files for this step

      ` + `

      History cannot list files for this step

      ` ); } let files = attrs.files ?? []; @@ -1397,7 +1503,9 @@ async function viewSeal(parsed, vs) { // decks arrives here rather than at a single file, and each row descends // into the per-file diff carrying both revisions with it. if (vs) { - let other = await getJson(vs.replace(/^\/([^/]+)\/_history\//, '/$1/_history/')) + let other = await getJson( + vs.replace(/^\/([^/]+)\/_history\//, '/$1/_history/'), + ) .then((body) => body.data.attributes) .catch(() => undefined); if (!other?.filesAvailable) { @@ -1425,7 +1533,9 @@ async function viewSeal(parsed, vs) { let seals = await sealsOf(parsed.depot); let record = seals.find((seal) => seal.changeId === parsed.seal); let touched = new Set( - (record?.filesSummary ?? []).map((line) => line.replace(/^[A-Z]\s+/, '').trim()), + (record?.filesSummary ?? []).map((line) => + line.replace(/^[A-Z]\s+/, '').trim(), + ), ); let rows = touched.size ? [...touched].sort() @@ -1450,7 +1560,9 @@ async function viewSeal(parsed, vs) { // there. let [after, before] = await Promise.all([ fetch(at).then((response) => (response.ok ? response.text() : '')), - fetch(against).then((response) => (response.ok ? response.text() : '')), + fetch(against).then((response) => + response.ok ? response.text() : '', + ), ]); return { path, at, against, before, after }; }), @@ -1547,7 +1659,9 @@ async function viewSealFile(parsed, vs) { // Absent on the other side means this save CREATED the file, which is a // real and common answer — every line is an addition. Treating a 404 as // an error here would make "added" look like a broken link. - let before = await fetch(vs).then((other) => (other.ok ? other.text() : '')); + let before = await fetch(vs).then((other) => + other.ok ? other.text() : '', + ); // A STRING, not `{html}`. The object form is the contract for "there is a // blob to stream in after paint", and render() will go looking for the // `#blob` element it implies. A diff has already rendered its own body. @@ -1578,7 +1692,7 @@ async function viewSealFile(parsed, vs) { * * It sits at the depot root rather than inside a package, so `_browse` and * the package catalog never list it. Opening it still has to be the ordinary - * blob: syntax colour, a raw link, and the Timeline scoped to this path so + * blob: syntax colour, a raw link, and the History scoped to this path so * `[` / `]` walk the saves that rewrote the pins. */ async function viewDepotLock(parsed, vs) { @@ -1592,7 +1706,9 @@ async function viewDepotLock(parsed, vs) { let text = await response.text(); if (vs) { - let before = await fetch(vs).then((other) => (other.ok ? other.text() : '')); + let before = await fetch(vs).then((other) => + other.ok ? other.text() : '', + ); return ( crumbs(parsed) + `
      ` + @@ -1621,7 +1737,8 @@ function shortSeal(address) { } function repositoryActionDialog() { - return `` + + return ( + `` + `
      ` + `
      NEW

      ` + `
      ` + @@ -1630,60 +1747,106 @@ function repositoryActionDialog() { `` + `
      ` + `
      ` + - `
      `; + `
      ` + ); } -/** Branch and fork are one lineage control; mutability decides which verbs appear. */ -async function repositoryPanel(parsed, meta) { +async function repositoryContext(parsed, meta) { if (!parsed.depot || !parsed.publisher || !parsed.pkg) return ''; let member = `${parsed.publisher}/${parsed.pkg}`; - let catalog = await getJson('/_hub/repositories').catch(() => ({ repositories: [] })); + let catalog = await getJson('/_hub/repositories').catch(() => ({ + repositories: [], + })); let candidates = catalog.repositories.filter( (item) => item.depot === parsed.depot && item.members.includes(member), ); - let summary = candidates.find( - (item) => item.kind === 'package' && item.repository === member, - ) ?? candidates.find((item) => item.kind === 'group'); + let summary = + candidates.find((item) => item.kind === 'group' && item.initialized) ?? + candidates.find( + (item) => + item.kind === 'package' && + item.repository === member && + item.initialized, + ) ?? + candidates.find( + (item) => item.kind === 'package' && item.repository === member, + ) ?? + candidates.find((item) => item.kind === 'group'); + if (!summary) return { member, catalog, summary, state: undefined }; + let state = await getJson(summary.url).catch(() => undefined); + return { member, catalog, summary, state }; +} + +/** Branch and fork are one lineage control; mutability decides which verbs appear. */ +function repositoryPanel(parsed, meta, context) { + if (!context) return ''; + let { member, catalog, summary, state } = context; if (!summary && !meta?.live) { - return `
      ` + + return ( + `
      ` + `
      ${esc(parsed.publisher)}/` + `${esc(parsed.pkg)}imported · read-only
      ` + `
      ` + repositoryActionDialog(); + `data-from="${esc(member)}" data-spec="${esc(parsed.spec ?? '')}">Fork
      ` + + repositoryActionDialog() + ); } if (!summary) return ''; - let state = await getJson(summary.url).catch(() => undefined); if (!state) return ''; let open = state.pulls?.find((pull) => pull.state === 'open'); - let upstream = state.forkedFrom?.revisionHash && catalog.repositories.find( - (item) => item.depot === state.depot && item.repository === state.forkedFrom.package && item.initialized, - ); - let review = open ? `Review #${open.number}` : upstream ? 'Review upstream' : 'Reviews'; + let upstream = + state.forkedFrom?.revisionHash && + catalog.repositories.find( + (item) => + item.depot === state.depot && + item.repository === state.forkedFrom.package && + item.initialized, + ); + let review = open + ? `Review #${open.number}` + : upstream + ? 'Review upstream' + : 'Reviews'; let reviewRepository = upstream?.repository ?? state.repository; - let reviewSource = upstream ? `&source=${encodeURIComponent(state.repository)}` : ''; + let reviewSource = upstream + ? `&source=${encodeURIComponent(state.repository)}` + : ''; let current = (state.branches ?? []).find( (branch) => parsed.spec === branch.projection.version, ); - let branches = (state.branches ?? []).map((branch) => { - let at = branch.projection.members[member]; - return `` + - `${esc(branch.name)}${esc(branch.revisionHash.slice(0, 8))}`; - }).join(''); + let branches = (state.branches ?? []) + .map((branch) => { + let at = branch.projection.members[member]; + return ( + `` + + `${esc(branch.name)}${esc(branch.revisionHash.slice(0, 8))}` + ); + }) + .join(''); return ( `
      ` + `
      ${esc(state.owner)}` + `/${esc(state.name)}` + - (state.kind === 'group' ? `${state.members.length}-package group` : '') + - (state.forkedFrom ? `fork of ${esc(state.forkedFrom.package)}` : '') + `
      ` + + (state.kind === 'group' + ? `${state.members.length}-package group` + : '') + + (state.forkedFrom + ? `fork of ${esc(state.forkedFrom.package)}` + : '') + + `` + `
      ${esc(current?.name ?? 'live')} ⌄` + `
      ${branches || 'No branches yet'}` + `
      New…` + `` + `` + `
      ` + - (state.initialized ? `${review}` : '') + `
      ` + - `
      ` + repositoryActionDialog() + (state.initialized + ? `${review}` + : '') + + `` + + `` + + repositoryActionDialog() ); } @@ -1703,20 +1866,29 @@ async function viewAddress(input, vs) { let parsed = input; if (!input.spec && meta && !meta.live) { let fallback = - meta.distTags?.latest ?? Object.keys(meta.versions ?? {}).sort().at(-1); + meta.distTags?.latest ?? + Object.keys(meta.versions ?? {}) + .sort() + .at(-1); if (fallback) parsed = { ...input, spec: fallback }; } let at = addressOf(parsed); let { data } = await getJson(`/_browse${at}`); let attrs = data.attributes; + let repository = await repositoryContext(parsed, meta); let head = crumbs(parsed) + (await siblings(parsed)) + - `
      ${await repositoryPanel(parsed, meta)}
      ` + - (meta ? volatility(parsed, meta, seals) + compare(parsed, meta, seals, vs) : '') + + `
      ${repositoryPanel(parsed, meta, repository)}
      ` + + (meta + ? volatility(parsed, meta, seals, repository?.state) + + compare(parsed, meta, seals, vs, repository?.state) + : '') + hash(attrs.treeHash) + - (attrs.size !== undefined ? `
      size ${esc(bytes(attrs.size))}
      ` : '') + + (attrs.size !== undefined + ? `
      size ${esc(bytes(attrs.size))}
      ` + : '') + (attrs.sha256 ? hash(attrs.sha256, 'sha256') : '') + `
      `; @@ -1726,7 +1898,9 @@ async function viewAddress(input, vs) { let other = target.seal ? await fetch(addressOf(target)) .then((response) => - response.ok ? response.text().then((text) => ({ text })) : { missing: true }, + response.ok + ? response.text().then((text) => ({ text })) + : { missing: true }, ) .catch(() => ({ missing: true })) : await getJson(`/_browse${addressOf(target)}`) @@ -1784,7 +1958,9 @@ async function viewAddress(input, vs) { let here = { ...parsed }; let [left, right] = await Promise.all([metaOf(target), metaOf(here)]); if (!left || !right) { - let missing = [!right && labelOf(here), !left && labelOf(target)].filter(Boolean); + let missing = [!right && labelOf(here), !left && labelOf(target)].filter( + Boolean, + ); return ( head + `

      no digest inventory for ${missing.map(esc).join(' or ')}. ` + @@ -1832,20 +2008,22 @@ async function render() { else if (parsed.seal === true) out = await viewSealLog(parsed); else if (parsed.seal && !parsed.path) out = await viewSeal(parsed, vs); else if (parsed.seal) out = await viewSealFile(parsed, vs); - else if (parsed.path === DEPOT_LOCK && !parsed.pkg) out = await viewDepotLock(parsed, vs); + else if (parsed.path === DEPOT_LOCK && !parsed.pkg) + out = await viewDepotLock(parsed, vs); else if (parsed.pkg) out = await viewAddress(parsed, vs); else out = await viewCatalog(parsed); // The tape goes ABOVE the view, outside whatever that view builds, so it // is in the same place on every page rather than something each view has // to remember to include. - let tape = await timelineBar(parsed); + let tape = await historyBar(parsed); if (typeof out === 'string') { app.innerHTML = tape + out; } else { app.innerHTML = tape + out.html; document.getElementById('blob').append(blob(out.text, out.path)); - if (location.hash) document.querySelector(location.hash)?.scrollIntoView(); + if (location.hash) + document.querySelector(location.hash)?.scrollIntoView(); } } catch (error) { app.innerHTML = errorView(parsed, error); @@ -1971,10 +2149,10 @@ function parentAddress() { * and `live → tags → versions` is still an ordering through time. */ function stepTime(delta) { - if (lastTimeline.length) { + if (lastHistory.length) { let parsed = parseAddress(currentAt()); let here = parsed.seal && parsed.seal !== true ? String(parsed.seal) : ''; - return go(atStep(parsed, stepBy(lastTimeline, here, delta))); + return go(atStep(parsed, stepBy(lastHistory, here, delta))); } return stepVolatility(delta); } @@ -1995,7 +2173,8 @@ function stepChange(delta) { (line) => line.dataset.mark, ); let hunks = marks.filter( - (line, index) => index === 0 || line.previousElementSibling !== marks[index - 1], + (line, index) => + index === 0 || line.previousElementSibling !== marks[index - 1], ); if (!hunks.length) return; hunk = Math.max(0, Math.min(hunks.length - 1, hunk + delta)); @@ -2005,12 +2184,16 @@ let hunk = -1; /** The legend, derived from what this view actually offers right now. */ function bindings() { - let list = [[['j', 'k'], 'move'], ['↵', 'open'], ['u', 'up']]; + let list = [ + [['j', 'k'], 'move'], + ['↵', 'open'], + ['u', 'up'], + ]; if (app.querySelector('[data-role="filter"]')) list.push(['/', 'filter']); // The legend is derived from the view, so it has to name what the keys // will ACTUALLY do here — the tape when there is one, the volatility list // when there is not. Two different moves through time, one pair of keys. - if (lastTimeline.length) list.push([['[', ']'], 'step']); + if (lastHistory.length) list.push([['[', ']'], 'step']); else if (select('vol')) list.push([['[', ']'], 'time travel']); if (select('vs')) list.push(['d', 'compare']); if (app.querySelector('pre.diff')) list.push([['n', 'p'], 'change']); @@ -2127,7 +2310,9 @@ function expandGap(control) { } app.addEventListener('click', async (event) => { - let closeRepositoryDialog = event.target.closest('[data-repository-dialog-close]'); + let closeRepositoryDialog = event.target.closest( + '[data-repository-dialog-close]', + ); if (closeRepositoryDialog) { closeRepositoryDialog.closest('dialog')?.close(); return; @@ -2145,21 +2330,27 @@ app.addEventListener('click', async (event) => { spec: repositoryAction.dataset.spec ?? '', endpoint: repositoryAction.dataset.endpoint ?? '', }); - dialog.querySelector('[data-repository-dialog-title]').textContent = action === 'fork' ? 'Create a fork' : 'Create a branch'; - dialog.querySelector('[data-repository-dialog-label]').textContent = action === 'fork' ? 'Fork to publisher/package' : 'Branch name'; - dialog.querySelector('[data-repository-dialog-help]').textContent = action === 'fork' - ? `Creates a mutable package from ${repositoryAction.dataset.from}. The source stays unchanged.` - : 'Creates an isolated line of work from main in this package.'; - dialog.querySelector('[data-repository-dialog-submit]').textContent = action === 'fork' ? 'Create fork' : 'Create branch'; + dialog.querySelector('[data-repository-dialog-title]').textContent = + action === 'fork' ? 'Create a fork' : 'Create a branch'; + dialog.querySelector('[data-repository-dialog-label]').textContent = + action === 'fork' ? 'Fork to publisher/package' : 'Branch name'; + dialog.querySelector('[data-repository-dialog-help]').textContent = + action === 'fork' + ? `Creates a mutable package from ${repositoryAction.dataset.from}. The source stays unchanged.` + : 'Creates an isolated line of work from main in this package.'; + dialog.querySelector('[data-repository-dialog-submit]').textContent = + action === 'fork' ? 'Create fork' : 'Create branch'; dialog.querySelector('[data-repository-dialog-error]').textContent = ''; let input = form.elements.target; input.value = action === 'fork' ? `me/${packageName}` : ''; - input.pattern = action === 'fork' - ? '[a-z0-9][a-z0-9._-]{0,63}/[a-z0-9][a-z0-9._-]{0,63}' - : '(?!.*\\.\\.)[a-z0-9][a-z0-9._/-]{0,127}'; - input.title = action === 'fork' - ? 'Use publisher/package with lowercase letters, numbers, dots, dashes, or underscores.' - : 'Start with a lowercase letter or number; then use lowercase letters, numbers, dots, dashes, underscores, or slashes.'; + input.pattern = + action === 'fork' + ? '[a-z0-9][a-z0-9._-]{0,63}/[a-z0-9][a-z0-9._-]{0,63}' + : '(?!.*\\.\\.)[a-z0-9][a-z0-9._/-]{0,127}'; + input.title = + action === 'fork' + ? 'Use publisher/package with lowercase letters, numbers, dots, dashes, or underscores.' + : 'Start with a lowercase letter or number; then use lowercase letters, numbers, dots, dashes, underscores, or slashes.'; repositoryAction.closest('details')?.removeAttribute('open'); dialog.showModal(); input.focus(); @@ -2178,7 +2369,9 @@ app.addEventListener('click', async (event) => { if (by) { let parsed = parseAddress(currentAt()); let here = parsed.seal && parsed.seal !== true ? String(parsed.seal) : ''; - return go(atStep(parsed, stepBy(lastTimeline, here, Number(by.dataset.by)))); + return go( + atStep(parsed, stepBy(lastHistory, here, Number(by.dataset.by))), + ); } // The fold header now contains a link to the file itself, and the header is @@ -2207,11 +2400,13 @@ app.addEventListener('submit', async (event) => { event.preventDefault(); let input = form.elements.target; let value = input.value.trim(); - let valid = form.dataset.action === 'fork' - ? /^[a-z0-9][a-z0-9._-]{0,63}\/[a-z0-9][a-z0-9._-]{0,63}$/.test(value) - : /^[a-z0-9][a-z0-9._/-]{0,127}$/.test(value) && !value.includes('..'); + let valid = + form.dataset.action === 'fork' + ? /^[a-z0-9][a-z0-9._-]{0,63}\/[a-z0-9][a-z0-9._-]{0,63}$/.test(value) + : /^[a-z0-9][a-z0-9._/-]{0,127}$/.test(value) && !value.includes('..'); if (!valid) { - form.querySelector('[data-repository-dialog-error]').textContent = input.title; + form.querySelector('[data-repository-dialog-error]').textContent = + input.title; input.focus(); return; } @@ -2221,17 +2416,29 @@ app.addEventListener('submit', async (event) => { error.textContent = ''; repositoryMutationBusy = true; try { - let response = form.dataset.action === 'fork' - ? await fetch('/_hub/forks', { - method: 'POST', - headers: { accept: 'application/json', 'content-type': 'application/json' }, - body: JSON.stringify({ depot: form.dataset.depot, from: form.dataset.from, to: value, ...(form.dataset.spec ? { spec: form.dataset.spec } : {}) }), - }) - : await fetch(`${form.dataset.endpoint}/branches`, { - method: 'POST', - headers: { accept: 'application/json', 'content-type': 'application/json' }, - body: JSON.stringify({ name: value, from: 'main' }), - }); + let response = + form.dataset.action === 'fork' + ? await fetch('/_hub/forks', { + method: 'POST', + headers: { + accept: 'application/json', + 'content-type': 'application/json', + }, + body: JSON.stringify({ + depot: form.dataset.depot, + from: form.dataset.from, + to: value, + ...(form.dataset.spec ? { spec: form.dataset.spec } : {}), + }), + }) + : await fetch(`${form.dataset.endpoint}/branches`, { + method: 'POST', + headers: { + accept: 'application/json', + 'content-type': 'application/json', + }, + body: JSON.stringify({ name: value, from: 'main' }), + }); let body = await response.json().catch(() => ({})); if (!response.ok) throw new Error(body.error ?? response.statusText); if (form.dataset.action === 'fork') { @@ -2287,7 +2494,8 @@ app.addEventListener('input', (event) => { function toggleSort(key, column) { let sorting = SORT_STATE.get(key) ?? []; let active = sorting[0]; - if (!active || active.id !== column) SORT_STATE.set(key, [{ id: column, desc: false }]); + if (!active || active.id !== column) + SORT_STATE.set(key, [{ id: column, desc: false }]); else if (!active.desc) SORT_STATE.set(key, [{ id: column, desc: true }]); else SORT_STATE.delete(key); render(); @@ -2329,12 +2537,18 @@ app.addEventListener('change', (event) => { // they had folded, several times a minute while an agent is running. Reading // code in a live depot has to be possible. onActivityChange(() => { - if (app.querySelector('pre.blob') || app.querySelector('dialog[open]')) return; + if (app.querySelector('pre.blob') || app.querySelector('dialog[open]')) + return; render(); }); onLiveChange(() => { loadActivity(); - if (!repositoryMutationBusy && !app.querySelector('dialog[open]') && app.querySelector('[data-repository]')) render(); + if ( + !repositoryMutationBusy && + !app.querySelector('dialog[open]') && + app.querySelector('[data-repository]') + ) + render(); }); render(); diff --git a/apps/hub/hub/app/browse/package.json b/apps/hub/hub/app/browse/package.json index 1c1f685..8d6c027 100644 --- a/apps/hub/hub/app/browse/package.json +++ b/apps/hub/hub/app/browse/package.json @@ -9,7 +9,7 @@ "highlight-js": "esm", "@tanstack/table-core": "browser", "ui/activity": "workspace:*", - "ui/timeline": "workspace:*", + "ui/history": "workspace:*", "ui/tour": "workspace:*" } } diff --git a/apps/hub/hub/app/run/package.json b/apps/hub/hub/app/run/package.json index bd61d7a..2a80d57 100644 --- a/apps/hub/hub/app/run/package.json +++ b/apps/hub/hub/app/run/package.json @@ -5,7 +5,7 @@ "exports": { ".": "./run.js" }, "dependencies": { "ui/nav": "workspace:*", - "ui/timeline": "workspace:*", + "ui/history": "workspace:*", "ui/activity": "workspace:*", "ui/tour": "workspace:*" } diff --git a/apps/hub/hub/app/run/run.js b/apps/hub/hub/app/run/run.js index 6eea765..376d57b 100644 --- a/apps/hub/hub/app/run/run.js +++ b/apps/hub/hub/app/run/run.js @@ -19,12 +19,12 @@ import { resumeHubTour } from 'ui/tour'; import { attachScrub, foldBursts, - installTimelineStyles, + installHistoryStyles, measureFrame, stepBy, stepsFor, - timelineHtml, -} from 'ui/timeline'; + historyHtml, +} from 'ui/history'; import { ago, activityCell, @@ -37,7 +37,7 @@ import { installNavStyles(); installActivityStyles(); -installTimelineStyles(); +installHistoryStyles(); const app = document.getElementById('app'); const keys = document.querySelector('deck-keys'); @@ -71,7 +71,7 @@ function isHubApp(at) { } /** - * The Timeline for a running page: every Step that touched ANYTHING it loads. + * The History for a running page: every Step that touched ANYTHING it loads. * * Not just the page's own file. You are watching the assembled thing, and a * palette three decks away changes what you see — so "undo" has to mean "the @@ -106,7 +106,7 @@ function stepsOf(depot) { depot, getJson(`/${depot}/${HISTORY}`) .then((body) => body.data.map((step) => step.attributes)) - // Versions only, no Timeline. Not an error — fewer addresses. + // Version history only. Not an error — fewer addresses. .catch(() => []), ); } @@ -623,10 +623,10 @@ async function viewOne(input) { let map = await resolvedMap(url); let depotName = at.replace(/^\//, '').split('/')[0]; - let timeline = foldBursts( + let history = foldBursts( stepsFor(await stepsOf(depotName), closureOf(at, map)), ); - lastTimeline = timeline; + lastHistory = history; let scopes = Object.entries(map.scopes ?? {}); let specifiers = Object.keys(map.imports ?? {}).filter((key) => !key.endsWith('/')); let answered = specifiers.map((key) => @@ -653,8 +653,8 @@ async function viewOne(input) { // grid rows, so a fifth child lands in an implicit row and the whole // pane grows a band of dead space. A grid with a fixed track list is a // contract about how many children it has. - timelineHtml({ - steps: timeline, + historyHtml({ + steps: history, current: currentStep(), label: 'undo', }) + @@ -731,8 +731,8 @@ const stageWidth = () => localStorage.getItem(WIDTH_KEY) ?? '100%'; const isFull = () => localStorage.getItem(FULL_KEY) === '1'; const splitAt = () => Number(localStorage.getItem(SPLIT_KEY) ?? 62); -/** The timeline the last render drew, so the keys and buttons agree with it. */ -let lastTimeline = []; +/** The History the last render drew, so the keys and buttons agree with it. */ +let lastHistory = []; function applyStage() { let split = app.querySelector('[data-role="split"]'); @@ -914,9 +914,9 @@ addEventListener('keydown', (event) => { case 'f': return app.querySelector('[data-role="full"]')?.click(); case '[': - return go({ step: stepBy(lastTimeline, currentStep(), -1) }); + return go({ step: stepBy(lastHistory, currentStep(), -1) }); case ']': - return go({ step: stepBy(lastTimeline, currentStep(), 1) }); + return go({ step: stepBy(lastHistory, currentStep(), 1) }); case 'r': return app.querySelector('[data-role="reload"]')?.click(); default: @@ -939,7 +939,7 @@ app.addEventListener('click', (event) => { // walks that same order — so `◀` (-1) is one step older and `▶` (+1) is // one newer, which is what the arrows point at. This negated the delta // and the buttons ran backwards: pressing "back" went forward. - return go({ step: stepBy(lastTimeline, currentStep(), Number(by.dataset.by)) }); + return go({ step: stepBy(lastHistory, currentStep(), Number(by.dataset.by)) }); } let width = event.target.closest('[data-role="width"]'); diff --git a/apps/hub/hub/app/start/start.js b/apps/hub/hub/app/start/start.js index 8aaa771..34f4f30 100644 --- a/apps/hub/hub/app/start/start.js +++ b/apps/hub/hub/app/start/start.js @@ -101,7 +101,7 @@ function ago(when) { * * Activity and per-depot `/_history` used to sit on the critical path and * turned this panel into a multi-second wait behind seal-log walks. Claims - * arrive in `enrichNow`; step totals are no longer fetched here (the Timeline + * arrive in `enrichNow`; step totals are no longer fetched here (the History * link is enough — counting every seal on start was the expensive lie). */ async function now() { @@ -149,9 +149,11 @@ async function now() { /** Claims only — after the depot table is already on screen. */ async function enrichNow(state) { - let activity = await getJson('/_activity?top=200&grain=package').catch(() => ({ - foci: [], - })); + let activity = await getJson('/_activity?top=200&grain=package').catch( + () => ({ + foci: [], + }), + ); state.claims = (activity.foci ?? []).flatMap((focus) => (focus.claims ?? []).map((claim) => ({ ...claim, at: focus.id })), ); @@ -214,11 +216,13 @@ function depotTable(state) { depot.history ? `` + (tape?.steps == null - ? `Timeline` + ? `History` : `${tape.steps} Step${tape.steps === 1 ? '' : 's'}`) + `` + - (tape?.newest ? ` · ${esc(ago(tape.newest))}` : '') - : `Versions only — no Timeline` + (tape?.newest + ? ` · ${esc(ago(tape.newest))}` + : '') + : `Version history only` }` + `run →` + `` @@ -228,7 +232,7 @@ function depotTable(state) { return ( `` + `` + - `` + + `` + `${rows}
      depotdecksliveTimelineHistory
      ` ); } @@ -295,6 +299,113 @@ a name to a state, use a different command:

      This command copies no bytes. The command gives a name to a state that is already in the store.

      + +

      Work together on an ordinary package

      + +

      Every mutable package is already a repository. It needs no +repository.json and no initialization ceremony. Create a branch, +edit the package, capture an intentional Revision, open a Review, and merge +it:

      + +
      deck branch --depot ./work/site --repo acme/palette \\
      +  --name fix/contrast --from main
      +
      +# edit ./work/site/acme/palette, then capture the branch
      +deck commit --depot ./work/site --repo acme/palette \\
      +  --branch fix/contrast --message "Improve contrast"
      +
      +deck review --depot ./work/site --repo acme/palette \\
      +  --source fix/contrast --target main --title "Improve contrast"
      +
      +deck merge --depot ./work/site --repo acme/palette --number 1
      + +

      Browse shows the package, its branches, and its Revision history. Review +shows the exact source and target Revisions, the file differences, a runnable +preview, and the merge result. Both surfaces read the same durable objects +that the CLI writes.

      + +

      Fork an imported package before changing it

      + +

      An import is immutable input. Deck never gives an upstream package a +writable branch in place. Forking materializes the chosen Version under your +publisher, records its ancestry, and creates a mutable package repository:

      + +
      deck fork --depot ./work/site \\
      +  --from three/three@0.179.1 --as you/three --use-in you/app
      + +

      A branch and a fork use the same cheap content-addressed ancestry. The +difference is ownership: a branch stays inside one repository; a fork crosses +into another publisher's package and can be reviewed back upstream when it +has mutable upstream lineage.

      + +

      Group packages only when they must land together

      + +

      Most work stops at the package repository above. When a change to several +packages and the exact import map must be atomic, use +deck repo-init. It writes repository.json to name the +members and roots. This is Deck's monorepo: a Repository Revision points to +several independently addressable package trees and one exact lock.

      +`; + +const SYSTEM = ` +

      Deck has one content-addressed foundation and two kinds of +state. Files, package trees, Versions, Repositories and Revisions are immutable +objects. Branches, tags and Review heads are small mutable pointers to those +objects. That division is the whole system.

      + +
      files on disk
      +     │
      +     │ every settled save — automatic; no commit
      +     ▼
      +package tree ──hash──┬──▶ Version ──URL──▶ Browse + Run
      +                     │
      +                     └──▶ History Step ──▶ inspect · compare · restore
      +                                            DAILY SAFETY
      +
      +chosen package tree(s) + exact import-map lock
      +     │
      +     │ deck commit — intentional authorship
      +     ▼
      +Repository ◀── Revision ◀── branch  (mutable compare-and-swap ref)
      +
      +source branch ──exact Revision──┐
      +                                ├──▶ Review ──preview──▶ Browse + Run
      +target branch ──exact Revision──┘       │
      +                                       └──merge──▶ new two-parent Revision
      +                                                        │
      +                                                        └──CAS──▶ target branch
      + + + + + + + + + + +
      PackageThe independently addressable, +runnable and releasable unit. A mutable package is implicitly a repository.
      VersionA permanent semver name for one +package tree. Automatic -dev.N Versions preserve every settled +save.
      StepAn observed filesystem capture in a +depot History. It answers “what happened while work was in progress?”
      RevisionAn intentional authored history +event. It points to one Repository and its parent Revisions.
      BranchA named compare-and-swap ref inside +one owner and repository.
      ForkThe same isolation and ancestry +mechanics across an ownership boundary. Imported packages are read-only until +forked.
      ReviewDurable metadata connecting exact +source and target Revisions, plus append-only review and merge events.
      RepositoryThe atomic collaboration state. +Usually it contains one package implicitly. A repository.json +exists only when several packages and their lock must advance together.
      + +

      Distribution stays read-only. HTTP serves live and immutable package +addresses. The CLI and daemon author content-addressed objects and move refs. +Local Deck coordinates ref updates with atomic files; an S3-backed Deck uses +conditional object writes. Neither needs a Git object store.

      + +

      The Hub is an inspector for this state. Browse answers what exists +and which repository governs it. Run executes an exact package graph. Track +shows automatic Steps and activity. Review shows proposed Revision-to-Revision +changes and moves the target branch only after a clean merge.

      `; // ───────────────────────────────────────────────────────────────────────── @@ -330,7 +441,7 @@ const ANY_SYSTEM = [ marks it immutable, and the browser does not ask about it again. A second visit to the page transfers zero bytes. This is not a setting that somebody tuned. It is a result of the address, and the - timeline in run prints the number of bytes to show it.`, + transfer readout in run prints the number of bytes to show it.`, '/run', 'see 0 KB', ], @@ -346,15 +457,16 @@ const ANY_SYSTEM = [ 'read the catalog', ], [ - 'There is one kind of checkpoint', - `Many systems have three kinds: the undo in the editor, the milestone in - the tool, and the commit in the version control. Deck has one, and it is - the Step. To combine work that two people did at the same time, Deck - merges the two trees against the base tree that both started from. Deck - merges JSON as data, key by key. Two people who add different keys - therefore do not make a conflict.`, + 'Automatic history and authored history stay connected', + `A Step is what the filesystem watcher observed on a save. A Revision is + what a person or agent intentionally committed to a branch. Both point + into the same content-addressed store, so review can retain the authored + story without losing the work between commits. To combine concurrent + work, Deck merges package trees against the exact base Revision. JSON is + merged as data, key by key, so two additions at different keys do not + conflict.`, '/track', - 'see a merge', + 'see the work', ], [ 'Install, upgrade, remix and use are four different operations', @@ -362,9 +474,9 @@ const ANY_SYSTEM = [ package. The differences appear later, and they are painful. In Deck the four stay separate. To use a deck, add one line to an import map. To install it, make that line an exact address. To upgrade it, change the - address. To remix it, fork the deck into your own publisher space; the - fork records the tree that it came from, so you can offer your changes - back.`, + address. To remix an imported package, fork it into your own publisher + space. The import stays read-only; the fork becomes a mutable package + repository and records the exact ancestry needed to review changes back.`, '/browse', 'look at a fork', ], @@ -379,14 +491,13 @@ const ANY_SYSTEM = [ ], [ 'You can run a contribution before you accept it', - `A proposal is a fork that records the tree that it came from. A proposal - is therefore a Version, and you can run it. You do not have to imagine - the result of a difference between two files. Deck merges the clean - proposals. If a merge has a conflict, Deck reports the conflict and - changes nothing, because a machine that damages the work of a - contributor is worse than a machine that stops.`, - '/track?kind=proposed,conflicted', - 'open proposals', + `A Review durably names exact source and target Revisions. Deck prepares + the same three-way Repository merge for Preview that it will use for + Merge, then hands the resulting exact package Versions to Run. You do not + have to infer the assembled result from a diff. If the merge conflicts, + Deck reports qualified package paths and advances no branch.`, + '/review', + 'open Reviews', ], [ 'A fork copies no bytes until it differs', @@ -432,22 +543,22 @@ const ANY_SYSTEM = [ `Each change becomes a Step. An agent can also declare where it intends to work. A page that shows the result and a list that shows the changes can therefore both move while a person or an agent makes changes. This - application shows a simple form of this, with a frame and a timeline. A + application shows a simple form of this, with a frame and the History + scrubber. A system with a true preview surface can do more.`, '/run', 'the running thing', ], [ 'Review tools can use more than a difference between two files', - `The protocol records who did the work, what they declared before they - started, what they wrote afterwards, and which tree each statement is - about. It identifies the tree by its content, not by the name of a - branch. A review tool that reads this has more to show than two columns - of text. This application shows the simple form: a review of several - files, marks on the words that changed, and hidden lines that you can - open.`, - '/browse', - 'a review', + `A Review retains familiar source and target branch names while binding + them to exact immutable Revisions. Its event chain records source + updates, verdicts, merge preparation and completion. The Hub can + therefore show files, authored history, executable preview, merge state, + and the canonical objects underneath instead of reconstructing truth + from two moving branch names.`, + '/review', + 'inspect a Review', ], ]; @@ -473,18 +584,78 @@ const ANY_SYSTEM = [ // Each law carries which half it governs. Eleven of the twelve are at rest, // which is the clearest statement of where this system is finished. const LAWS = [ - ['L1', 'rest', 'Identity', 'Identity is tree-hash-v1 over the logical file tree, and nothing else enters it. Not zip framing, not compression, not the order bytes landed on disk, not the version anybody attached. Paths are Unicode NFC. Two trees with the same files hash the same on every implementation, forever.'], - ['L2', 'rest', 'Round trip', 'Packing and unpacking is lossless and byte-exact: pack(unpack(P)) === P for a canonical pack, and the treeHash survives the trip.'], - ['L3', 'rest', 'Pointer', 'Versions and dist-tags name treeHashes. Naming a state copies no bytes.'], - ['L4', 'rest', 'Immutability', 'A published exact version never changes, and re-publishing one is refused. There is no unpublish. The only way to say a version is bad is to say so about it.'], - ['L5', 'rest', 'Volatility', 'A deck has exactly three kinds of address and they are different URLs: no version segment is the working tree (no-store), @<exact> is one sealed state (immutable), and a range or tag is a name that costs one short hop. Ranges never match prereleases.'], - ['L6', 'rest', 'Hermeticity', 'A sealed deck resolves every specifier it contains — inside itself, or to a dependency it declares. Nothing reaches the live web at runtime. A deck that would need a CDN to work is refused rather than warned about.'], - ['L7', 'rest', 'Resolution', 'Exact before range, tags before ranges, and the same answer on every implementation given the same store.'], - ['L8', 'rest', 'Provenance', 'Where bytes came from travels with them, per version, and cannot be edited away by a later publish.'], - ['L9', 'rest', 'Endorsement', 'Signatures are about a tree hash, not about a package or a version — so a fork holding the same bytes reads the same endorsements.'], - ['L10', 'rest', 'Carry-over', 'A statement is addressed to a tree hash, so it applies wherever those exact bytes appear. Nothing is derived from a parent: the statement did not move, the content turned up in a second place.'], - ['L11', 'both', 'Fail closed', 'An unknown answer is not a safe answer. Unverifiable provenance, an unparsable publish time, a specifier that cannot be resolved: refuse, do not warn and continue.'], - ['L12', 'rest', 'Serving', 'The distribution surface is read-only — GET and HEAD. Every write goes through a tool that arranges state on disk; distribution follows. This is why declared activity is read from files rather than posted to an endpoint.'], + [ + 'L1', + 'rest', + 'Identity', + 'Identity is tree-hash-v1 over the logical file tree, and nothing else enters it. Not zip framing, not compression, not the order bytes landed on disk, not the version anybody attached. Paths are Unicode NFC. Two trees with the same files hash the same on every implementation, forever.', + ], + [ + 'L2', + 'rest', + 'Round trip', + 'Packing and unpacking is lossless and byte-exact: pack(unpack(P)) === P for a canonical pack, and the treeHash survives the trip.', + ], + [ + 'L3', + 'rest', + 'Pointer', + 'Versions and dist-tags name treeHashes. Naming a state copies no bytes.', + ], + [ + 'L4', + 'rest', + 'Immutability', + 'A published exact version never changes, and re-publishing one is refused. There is no unpublish. The only way to say a version is bad is to say so about it.', + ], + [ + 'L5', + 'rest', + 'Volatility', + 'A deck has exactly three kinds of address and they are different URLs: no version segment is the working tree (no-store), @<exact> is one sealed state (immutable), and a range or tag is a name that costs one short hop. Ranges never match prereleases.', + ], + [ + 'L6', + 'rest', + 'Hermeticity', + 'A sealed deck resolves every specifier it contains — inside itself, or to a dependency it declares. Nothing reaches the live web at runtime. A deck that would need a CDN to work is refused rather than warned about.', + ], + [ + 'L7', + 'rest', + 'Resolution', + 'Exact before range, tags before ranges, and the same answer on every implementation given the same store.', + ], + [ + 'L8', + 'rest', + 'Provenance', + 'Where bytes came from travels with them, per version, and cannot be edited away by a later publish.', + ], + [ + 'L9', + 'rest', + 'Endorsement', + 'Signatures are about a tree hash, not about a package or a version — so a fork holding the same bytes reads the same endorsements.', + ], + [ + 'L10', + 'rest', + 'Carry-over', + 'A statement is addressed to a tree hash, so it applies wherever those exact bytes appear. Nothing is derived from a parent: the statement did not move, the content turned up in a second place.', + ], + [ + 'L11', + 'both', + 'Fail closed', + 'An unknown answer is not a safe answer. Unverifiable provenance, an unparsable publish time, a specifier that cannot be resolved: refuse, do not warn and continue.', + ], + [ + 'L12', + 'rest', + 'Serving', + 'The distribution surface is read-only — GET and HEAD. Every write goes through a tool that arranges state on disk; distribution follows. This is why declared activity is read from files rather than posted to an endpoint.', + ], ]; const PROTOCOL = ` @@ -505,43 +676,44 @@ the same sense that a tarball and a digest are formats. The store layout, the command-line tool and the watcher are implementation, and the laws say so: nothing requires a server to store anything the way this one does.

      -

      At rest, and in motion

      +

      Immutable content, durable collaboration

      -

      Deck has two halves, and they are not equally finished. To read them as -one thing makes the settled half look provisional and the unsettled half look -decided.

      +

      Deck has two layers with different jobs. To collapse them makes immutable +identity look mutable and collaboration state look less durable than it is.

      At rest is what a byte, a tree and a Version are. The answer does not change with time, with who asks, or with what anybody intends to do next. This half is settled: the twelve laws state it, the conformance suite examines it, and you can write a second implementation of it today.

      -

      In motion is what people and agents do to each other's work: they -propose, review, merge, declare and comment. The answers change by the -minute, and more than one party is involved. This half is not settled.

      +

      Collaboration records what people and agents do to work. Repository, +Revision, branch-ref, Pull Request opening and event formats are implemented +and tested locally. The mutable parts are deliberately small: a branch ref, a +Review head, and its event pointer. Everything they name is immutable.

      -

      The question that divides them: does the answer change if nobody does -anything? A tree hash does not. A proposal does — it becomes old, it is -rebased, and somebody accepts it or abandons it.

      +

      The question that divides them is: is this an object or a pointer? A +tree, Repository, Revision and Review event never change. A branch or numbered +Review ref can move, but only with compare-and-swap against the value its +writer observed.

      - + - + - + - +
      at rest — settledin motion — not settled
      immutable objectsmutable coordination
      identitytree hash, packlist, object—
      namingVersion, dist-tag, range—
      namingVersion, Repository, Revisiondist-tag, range, branch ref
      distributionthe address grammar, the cache rules—
      timeStep, Timelinethe live loop that makes them
      timeStep, Revision, Review eventHistory head, branch head, Review head
      provenancewhere bytes came from, and when—
      trustan endorsement of a tree hashwhose endorsements you accept
      change—offer, rebase, conflict
      changePull Request opening and eventsbranch, fork, Review, merge
      intent—claim
      talk—note, reply
      presence—activity
      testingthe run record, over a tree hashrunning the suite, and who signs the result
      -

      Two entries sit on the side that surprises people, and both split the same -way: the record is at rest, the act that produced it is in motion.

      +

      The same split applies throughout: the record is immutable; the act that +produces it and the ref that selects it are mutable.

      A Step is at rest. It is one immutable capture with an address, and it reads the same in a year. The watcher that makes Steps is in motion; the @@ -567,26 +739,24 @@ freshness rule is hard because failing closed (L11) must not turn an unreachable list into a refusal to run. Do not build anything that depends on taking a signature back. See docs/deck-at-rest.md §7.

      -

      The at-rest half is closed. Do not add to it without a strong +

      The package at-rest half is closed. Do not add to it without a strong reason: an artifact stored today must still verify under a reader written next year. If a new idea can be expressed as data at an address, it belongs in motion. That is how claims were built — files at a known path, read by an ordinary GET, and no change to identity, naming or serving.

      -

      The in-motion half is where the unfinished work is. Notes are written but -not read back. Signing can say yes and cannot yet say no. Automated testing -is designed and not built — a suite is a deck, a run is one suite Version -against one tree hash, you run it locally, and the result is a statement -signed by a person rather than a mark issued by a service. Two runs of one -pair that disagree is the useful signal, because it means the suite is not -hermetic, and a service that runs the pair once cannot notice. See -docs/deck-testing.md.

      - -

      And one gap contains the others. The in-motion half has one large gap. Nothing here defines how a depot -offers a change to a depot on another machine, or how a review travels -between them. Until that exists, Deck in motion is a set of local -conventions with a good data model, and Deck at rest is the part another -implementation can join. See docs/deck-in-motion.md.

      +

      The collaboration object model is implemented; hosting is unfinished. +Local Deck has Repository and Revision objects, compare-and-swap branches, +durable Reviews, executable previews, three-way merge, and crash recovery. +Conditional object-store adapters implement the same ref contract for S3. +What remains is production AWS wiring, identity and permissions, policy for +required reviews and checks, and transport between independently administered +servers. Those are origin concerns, not missing package identity.

      + +

      Notes are written but not yet read back. Signing can say yes and cannot yet +say no. Automated tests as signed statements are designed and not built. See +docs/deck-collaboration-protocol.md, +docs/deck-in-motion.md, and docs/deck-testing.md.

      The shape of an address

      /<depot>/<publisher>/<package>[@<spec>]/<path>
      @@ -615,12 +785,12 @@ ${LAWS.map(
       GET …@<exact>/<path>one sealed state, immutable, with a content digest
       GET …@<range|tag>/<path>exactly one redirect to the exact address
       GET …@<exact>?format=packthe stored pack bytes, byte-exact (L2)
      -GET /_packagesthe catalog, and the capability truth — including whether each depot has a Timeline
      +GET /_packagesthe catalog, and the capability truth — including whether each depot has every-save History
       
       
       

      What a server MAY answer

      - + @@ -630,11 +800,33 @@ ${LAWS.map( fail. The capability block in /_packages is how it finds out, and a server MUST NOT report a capability it does not have (L11).

      +

      What a collaboration adapter MUST preserve

      +

      The HTTP distribution surface stays read-only. Collaboration writes go +through an authoring adapter, but local files and S3 objects obey the same +contract:

      +
      GET /<depot>/_history[/<changeId>/…]the Timeline, when a history backend is attached. Absence means the depot has Versions only. It is not an error.
      GET /<depot>/_history[/<changeId>/…]every-save History, when a history backend is attached. Otherwise the depot still has Version history.
      GET /<depot>/_offersopen proposals with their merge status
      GET /<depot>/_trees/<treeHash>/signaturesendorsements, addressed by tree (L9)
      GET /_activitywhere work is happening, and what anyone declared
      + + + + + +
      immutable putLocks, Repositories, +Revisions, Pull Request openings and events are canonical, content addressed, +and write-once.
      conditional ref updateA branch moves only +when its current Revision equals the writer's expected Revision. Local Deck +uses an atomic rename; S3 uses If-Match.
      unique allocationReview numbers, openings +and new event objects use create-if-absent. Concurrent writers reread and +retry allocation; they do not overwrite.
      recoverable mergeDeck records the prepared +merge Revision before advancing the target branch, then records completion. +Retry completes the same merge rather than creating another.
      preview parityPreview and Merge use the +same base, source, target and Repository merge engine, so an unchanged clean +preview names the state that will land.
      +

      What is not specified

      • how a server stores anything — the store layout here is one implementation of L1–L4, not part of the contract
      • which history backend provides Steps, or whether any does
      • -
      • how writes happen. L12 says the distribution surface is read-only; it does not say what arranges the bytes
      • +
      • which authoring transport exposes collaboration writes. The object and conditional-update semantics above are the contract; local CLI and hosted APIs are implementations
      • authentication and authorisation, which are a deployment’s business
      @@ -661,7 +853,12 @@ const BOOK = [ - + + + + + + @@ -671,46 +868,49 @@ const BOOK = [ - +
      VersionA published tree that does not change. A semver number addresses it.
      StepOne capture of the working tree. The server records one Step for each save.
      TimelineAll the Steps of one depot, in order.
      HistoryAll the Steps of one depot, in order.
      RepositoryThe exact package trees and lock that advance atomically. Every mutable package has an implicit one-member Repository.
      RevisionAn immutable authored history event pointing to one Repository and its parent Revisions.
      branchA named mutable ref to a Revision inside one owner and repository.
      forkA mutable package under a different publisher, with exact source ancestry when available.
      ReviewA durable proposal from one exact branch Revision to another, with append-only events, preview and merge state.
      ImportA specifier that a deck asks for.
      PinThe exact address that answers an Import.
      EntryThe module that a deck offers as the way into itself.
      depotA directory that is also a URL space.
      deckA package. It is <publisher>/<name>, and it has a package.json.
      publisherThe first segment of a deck name. It is the boundary at which a fork occurs.
      offerA fork that records the tree it came from. You can run an offer.
      offerThe older package-level proposal view derived from fork lineage. New team workflows use a durable Review.
      tree hashThe identity of a whole tree, calculated from its content. The code calls it treeHash.

      Three words are no longer in use. Write Version, Step or to seal, and never seal as a noun: it meant all three at once. Write Step and never frame or tick. Write - Timeline or Versions only, and never tier.

      `, + every-save History or Version history only, and never tier.

      `, ], [ 'The layout is the address', `

      What ls shows and what the URL says are the same shape. A depot carries its machinery inside itself and the machinery is invisible to the tree hash, so copying the directory takes the live files, the - published Versions and the Timeline with it.

      + published Versions and the History with it.

      <root>/site/acme/palette/palette.js      on disk
              /site/acme/palette/palette.js     live — every save, no-store
              /site/acme/palette@1.1.0/palette.js
                                                a Version, immutable forever
              /site/_history/<changeId>/acme/palette/palette.js
                                                any Step it was ever in
      -

      A depot is close to a monorepo. Many packages in one directory, one - history, one place to clone, and a change that crosses several of them is one - change. The differences are worth knowing:

      +

      A depot is a package address space, not automatically a monorepo. + It may contain many unrelated packages, forks and repositories. Each + mutable package is its own repository unless a repository.json + explicitly groups several packages so one Revision can advance them and + their lock atomically.

        -
      • Each deck in a depot has its own version line. A monorepo usually - either versions everything together or needs a tool to avoid it.
      • +
      • A package remains the independently addressable execution and release + boundary, even when it belongs to a multi-package Repository.
      • The directory layout is the URL space, so a consumer can import one module from one deck without a build step that extracts it.
      • -
      • The publisher segment means two people can hold the same package - name in one depot. That is a fork inside the repository, and it costs - nothing until the bytes differ.
      • -
      • There is no workspace file and no link step. A deck depends on another - deck by address, and deck lock turns the range into a pin.
      • +
      • A dependency edge means “needed to run.” Repository membership means + “allowed to change and land atomically.” They are deliberately different.
      • +
      • There is no workspace link step. A package depends on another by + address, and the exact import map is the executable lock.
      -

      The publisher segment is the fork boundary. +

      The publisher segment is an ownership boundary. acme/palette and you/palette are different decks - with different version lines in one depot, and an undiverged fork costs no - extra bytes.

      `, + with different version lines in one depot. That is a fork. A named ref + such as acme/palette:fix/contrast stays inside the same + repository and is shown as a branch. Both are cheap because unchanged + objects are shared by hash.

      `, ], [ 'Three levels of volatility', @@ -794,14 +994,14 @@ const BOOK = [ what keeps pack(unpack(P)) === P byte-exact.

      `, ], [ - 'History: Versions, or a Timeline', + 'History: every save, or Versions only', `

      A depot has one of two answers, and the server reports which one. It does not pretend.

      - - + @@ -869,10 +1072,12 @@ bytes: a CDN is someone else’s server that can change its mind, and vendoring makes the same graph yours, hermetic, and diffable. Deck can be your CDN; a CDN cannot be your registry. +commits you author in a repository object graph. Deck keeps automatic +Steps and authored Revisions over the same content-addressed package trees, +and gives every runnable state a URL. Deck now has familiar branches, forks, +Reviews and three-way merge, but does not reproduce Git's staging area, +working-copy checkout or history-rewriting model. Its branch refs select exact +Repositories whose package trees remain directly importable. `; const CLI = ` -

      Twenty-seven commands in eight groups, ordered the way the work happens. +

      Thirty-four commands in ten groups, ordered the way the work happens. deck help prints this map from the command records themselves, so it cannot drift from what exists.

      History: VersionsEach settled save becomes an immutable -dev.N Version. The Versions are the history. Always available.
      History: TimelineEach save also +
      History: Every saveEach save also becomes a Step, at /<depot>/_history/<changeId>/…, and deck history and deck restore work. Available @@ -823,13 +1023,16 @@ const BOOK = [ --depot ./work/site --publisher acme --tag latest`, ], [ - 'Fork, lock, offer, rebase', - `

      Contribution is a version you can run.

      + 'Fork, branch, Review, preview, merge', + `

      Contribution is exact state you can inspect and run.

      deck fork --depot ./work/site --from acme/palette --as you/palette --use-in you/app
       deck lock --depot ./work/site --publisher you --name app
      -deck offers --depot ./work/site
      -deck rebase --depot ./work/site
      -

      Or try someone else’s deck in your app without checking anything out:

      +deck branch --depot ./work/site --repo you/palette --name fix/contrast +deck commit --depot ./work/site --repo you/palette --branch fix/contrast --message "Improve contrast" +deck review --depot ./work/site --repo you/palette --source fix/contrast --title "Improve contrast" +deck merge --depot ./work/site --repo you/palette --number 1 +

      To try another package in your app without creating durable review + state:

      /site/you/app/index.html?with=acme/palette=you/palette

      The server rewrites resolution for that one request. No checkout, no build, and it is never cached as sealed content.

      `, @@ -845,7 +1048,7 @@ deck rebase --depot ./work/site
      GET …@<tag|range>/…one hop to exact
      ?metamachine-readable listing
      ?format=packthe stored pack bytes
      GET /<depot>/_historythe Timeline
      GET /<depot>/_historythe History
      GET /_packagescatalog and capability truth
      GET /_activitywhere the work is, and who declared it
      GET /_live/eventsthe change bus
      gitBoth keep every state. Git keeps -commits you author; Deck keeps saves it observed, automatically, -and gives each one a URL that serves. Nothing here competes with branching and -history rewriting — Deck’s Timeline is a tape of what actually happened, which -is a different and smaller promise.
      a bundlerThere isn’t one, and that is not an omission. If you bundle, the bytes that run are not the bytes you wrote, and every other claim here depends on those being the same. You can @@ -886,7 +1091,7 @@ to interpret.
      @@ -901,14 +1106,20 @@ it cannot drift from what exists.

      - + + +
      dependwhat a deck uses, and which bytes answer to it — vendor vendor-tree lock verify-links
      proposechange someone else’s deck without a -branch — fork offers rebase
      repositoryauthor durable collaboration +state — repo-init branch commit +review merge
      proposematerialize someone else's package +under your ownership — fork; inspect older package offers with +offers and rebase
      endorsewho stands behind these bytes — keygen sign verify-sig
      recallevery state the depot has been in — history restore
      provethe executable contract — conform
      saydeclare work and leave durable context — +claim note
      `; @@ -918,18 +1129,77 @@ const STATUS = [ ['live serve and auto-publish', 'built', 'tested'], ['vendoring hermetic graphs, with a cooldown policy', 'built', 'tested'], ['lock, fork, merge, offers, rebase', 'built', 'tested'], + [ + 'Repository, Revision and compare-and-swap branch refs', + 'built', + 'local and S3 adapters tested', + ], + [ + 'durable Reviews, exact preview, merge and recovery', + 'built', + 'implemented and tested locally', + ], + [ + 'single-package implicit repositories', + 'built', + 'no repository.json required', + ], + [ + 'multi-package repository.json grouping', + 'built', + 'atomic Repository Revision and merge', + ], + [ + 'Hub Browse and Review projections', + 'built', + 'read the same durable objects as the CLI', + ], ['try-by-URL overrides', 'built', 'tested'], ['import-map injection and the change bus', 'built', 'tested'], ['history backends — Steps and restore', 'built', 'attaches when available'], ['publish times, local and upstream', 'built', 'tested'], ['the activity protocol — touch and declared claims', 'built', 'tested'], ['signatures on tree hashes', 'built', 'tested'], - ['minified derived Versions, and compression', 'built', 'reproducible: verify-derivation re-runs it and compares hashes'], - ['cross-depot dependency pins', 'not built', 'lock refuses them rather than guessing'], - ['notes and replies on a tree hash', 'not built', 'deck note writes them; /track does not read them'], - ['revocation, warning, incident', 'not built', 'a signature can say yes and cannot yet say no — deck-at-rest.md §7'], - ['test runs as signed statements', 'not built', 'designed in deck-testing.md'], - ['anything between two servers', 'not built', 'offers and reviews stay inside one depot'], + [ + 'minified derived Versions, and compression', + 'built', + 'reproducible: verify-derivation re-runs it and compares hashes', + ], + [ + 'cross-depot dependency pins', + 'not built', + 'lock refuses them rather than guessing', + ], + [ + 'notes and replies on a tree hash', + 'not built', + 'deck note writes them; /track does not read them', + ], + [ + 'revocation, warning, incident', + 'not built', + 'a signature can say yes and cannot yet say no — deck-at-rest.md §7', + ], + [ + 'test runs as signed statements', + 'not built', + 'designed in deck-testing.md', + ], + [ + 'production S3 credentials and Hub wiring', + 'not built', + 'conditional object-store contracts are tested', + ], + [ + 'identity, permissions, required checks and review policy', + 'not built', + 'formats exist; enforcement does not', + ], + [ + 'federation between independently administered servers', + 'not built', + 'no remote Review transport yet', + ], ]; // --------------------------------------------------------------------------- @@ -978,7 +1248,6 @@ function view(state) { ``, ).join(''); - let book = BOOK.map( ([title, body]) => `

      ${esc(title)}

      ${body}
      `, @@ -1006,12 +1275,15 @@ function view(state) { 'now', 'What this server is running right now', `
      ${ - state ? depotTable(state) + activeNote(state) : `

      reading the depots…

      ` + state + ? depotTable(state) + activeNote(state) + : `

      reading the depots…

      ` }
      `, ], + ['system', 'How the whole Deck system fits together', SYSTEM], [ 'quickstart', - 'Quickstart: from nothing to a running deck', + 'Quickstart: run, branch, review and merge', QUICKSTART + // Said here, not in a closing section, because this is the paragraph // a reader has their hands on the keyboard for. @@ -1025,10 +1297,9 @@ function view(state) { 'What Deck gives any system', `

      Each item below is a mechanism in this repository. No item is a plan. Each link goes to the place where you can examine the - item.

      ` + - `
        ${benefit}
      `, + item.

      ` + `
        ${benefit}
      `, ], - ['protocol', 'The protocol: laws, addresses, endpoints', PROTOCOL], + ['protocol', 'The protocol: content, collaboration and hosting', PROTOCOL], ['compare', 'How Deck differs from npm, CDNs, git and bundlers', COMPARE], ['book', 'Reference: vocabulary, addressing, storage, history', book], ['cli', 'Command reference', CLI], @@ -1087,65 +1358,107 @@ function view(state) { `` + `

      version management without the commit
      ` + `package management without the install
      ` + + `code collaboration without the clone
      ` + `cloud delivery without the deploy

      ` + `
      ` + `deckbook` + `` + `
      ` + - // The unifying assumption, before any mechanism. Three claims that each + `

      Every save is kept. After a short quiet period, + Deck gives the package tree an immutable automatic Version. When a + every-save History is attached, the same save is also an ordered Step: the exact + depot state, when it happened, and which files changed. You can inspect, + run, compare and restore work that was never committed. A later + deck commit does a different job: it turns chosen package + state into an authored Repository Revision for branch and Review + history.

      ` + + // The unifying assumption, before any mechanism. Four claims that each // sound like a separate trick are one claim said three times, and a // reader who does not get that in the second paragraph reads the rest as // a feature list. - `

      One thing makes all three true: there is only one kind of - object here — a file, named by the hash of its own bytes, reachable at a - URL. A tree is a list of those names. A Version, a Step, a dependency, a - fork and a release are all pointers to trees. Commit, install and deploy - all do the same job: they move a copy to a second place that names things - differently. Here there is no second place.

      ` + + `

      One foundation makes all four true: files and package + trees are named by their content and reachable at URLs. Versions, + Repositories and Revisions name immutable state above those trees. + Branches, tags and Review heads are the small mutable pointers. Execution, + distribution and collaboration therefore share one address space instead + of copying code through a repository, package registry and deployment + system that each names it differently.

      ` + // The same claim again, in the reader's own vocabulary. The grouping is - // the argument: `fork` and `pull request` land on one line because they - // are one thing, and `build`, `deploy` and `CDN` land on one line because - // they are none. + // The argument is the separation between immutable objects and the small + // mutable refs that select them. Familiar workflow nouns stay distinct, + // even when they share content-addressed mechanics underneath. `
      ` + [ - ['A commit, a save, a snapshot are just', - 'the hash of the tree, taken when you stopped typing.'], - ['A version, a tag, a release, a staging environment are just', - 'pointers to one of those hashes. Moving a pointer copies nothing.'], - ['An import, an install, a lockfile, node_modules are just', - 'an address in an import map, written into a file you can read.'], - ['A branch, a fork, a pull request are just', - 'the same package under your own publisher, with the tree you started' + - ' from recorded. That record is the whole difference between a copy' + - ' and a proposal.'], - ['A code review, a preview environment are just', - 'the page loaded with ?with= pointing somewhere else, for' + - ' one request.'], - ['A merge is just', - 'three trees: yours, theirs, and the one you both started from.'], - ['A revert, a rollback, a checkout are just', - 'reading a different address. Nothing on disk changes.'], - ['A build, a bundle, a deploy, a CDN are just', - 'absent — except the bundle, which is another version derived from the' + - ' first, at its own address.'], + [ + 'Every settled save becomes', + 'an immutable automatic Version and—when attached—an ordered Step in History.', + ], + [ + 'A commit becomes', + 'an authored immutable Revision pointing to exact Repository state.', + ], + [ + 'A version, a tag, a release, a staging environment are just', + 'pointers to one of those hashes. Moving a pointer copies nothing.', + ], + [ + 'An import, an install, a lockfile, node_modules are just', + 'an address in an import map, written into a file you can read.', + ], + [ + 'A mutable package is', + 'already a one-package repository. No initialization file is required.', + ], + ['A branch is', 'a named ref inside the same owner and repository.'], + [ + 'A fork is', + 'a mutable package under another publisher, sharing unchanged objects' + + ' and recording exact ancestry when the source is mutable.', + ], + [ + 'A Review is', + 'durable source-to-target metadata bound to exact Revisions and an' + + ' append-only event history.', + ], + [ + 'A preview is', + 'the exact proposed Repository projected into package Versions and run' + + ' through the ordinary resolver. ?with= remains the' + + ' temporary substitution mechanism, not Review identity.', + ], + [ + 'A merge is', + 'a three-way merge of base, target and source Repositories, followed by' + + ' one compare-and-swap move of the target branch.', + ], + [ + 'A revert, a rollback, a checkout are just', + 'reading a different address. Nothing on disk changes.', + ], + [ + 'A build, a bundle, a deploy, a CDN are just', + 'absent — except the bundle, which is another version derived from the' + + ' first, at its own address.', + ], ] .map(([term, is]) => `
      ${term}
      ${is}
      `) .join('') + `
      ` + - `

      None of those is really a mechanism. They exist because - three systems each keep their own address space, and something has to - translate between them. With one address space, committing, installing - and deploying are not three things you do to code — they are three names - for pointing at it.

      ` + + `

      The workflow nouns remain useful because they express + ownership, intent and mergeability. Deck unifies their storage mechanics, + not their meaning. A branch and a fork are both cheap refs over shared + content, but only a fork crosses ownership. A Review uses runnable content, + but it remains durable coordination rather than a query parameter.

      ` + `

      What Deck specifies is small: the grammar of the address, the bytes that the address supplies, and how long a client may keep them. Twelve laws state it, and a test suite examines them. This server and its command-line tool are one implementation. If a different implementation obeys the test suite, that implementation is also Deck.

      ` + - `

      The three lenses at the top of the page ask three - questions about one address: what the address is, what it does, and what - changed. The lenses are decks. This server supplies them from the store - that they show to you.

      ` + + `

      The four working views ask different questions about the + same state. Browse asks what exists and who governs it. Run asks what an + exact graph does. Track asks what happened while it changed. Review asks + what is proposed, what exact state was examined, and whether it merged. + The views are decks supplied from the store they inspect.

      ` + statLine(state) + `` + sections @@ -1247,7 +1560,7 @@ addEventListener('keydown', (event) => { // NOT ON EVERY CHANGE. This page is the expensive one. // // A render here is thirteen-odd requests — the catalog, activity, and one -// Timeline per depot — and `onLiveChange` fires about once a second while +// History per depot — and `onLiveChange` fires about once a second while // somebody is editing. Wiring those together meant a render was always in // progress, the next one queued before it finished, and the page never // settled: permanently "loading…", server at nothing, nothing thrown. The @@ -1280,16 +1593,20 @@ setInterval(refresh, REFRESH_MS); // reader has opened or closed it themselves, that is their answer and resizing // a window is not a request to overrule it. let touched = false; -app.addEventListener('toggle', (event) => { - if (event.target.matches?.('[data-role="toc"]')) touched = true; -}, true); +app.addEventListener( + 'toggle', + (event) => { + if (event.target.matches?.('[data-role="toc"]')) touched = true; + }, + true, +); WIDE.addEventListener('change', (event) => { if (touched) return; let toc = app.querySelector('[data-role="toc"]'); if (toc) toc.open = event.matches; }); -// Same lattice as the three lenses. One face, one size, colour only where it +// Same lattice as the four working views. One face, one size, colour only where it // means something. The mark is ASCII because this is a terminal; the Deckbook // door is inverse because that is how this surface says "go here" — the same // fill as the current nav tab. diff --git a/apps/hub/hub/app/track/track.js b/apps/hub/hub/app/track/track.js index 461d250..ab8d3f3 100644 --- a/apps/hub/hub/app/track/track.js +++ b/apps/hub/hub/app/track/track.js @@ -13,7 +13,7 @@ // // So every row is an action, in the past tense: // -// saved a settled edit was sealed as a Step on the Timeline +// saved a settled edit was sealed as a Step on the History // auto-saved the watcher published that save as `-dev.N`, unasked // published somebody cut an immutable version on purpose // released a dist-tag moved onto one. No bytes copied; a name changed. @@ -235,7 +235,7 @@ async function collect(scope, { historyLimit = HISTORY_PAGE } = {}) { }) // Machinery is not content, wherever it turns up. // - // `.deck/` holds the store; `.jj` / `.jj.main-orphan` are Timeline + // `.deck/` holds the store; `.jj` / `.jj.main-orphan` are History // stubs and jj workspace leftovers. DEFAULT_IGNORE / `.jj.*` exist // so they never enter a tree — but older seals may still list them. // Filtering here keeps /track honest: a change to machinery is not diff --git a/apps/hub/hub/importmap.json b/apps/hub/hub/importmap.json index 0715280..ff8ba04 100644 --- a/apps/hub/hub/importmap.json +++ b/apps/hub/hub/importmap.json @@ -14,8 +14,8 @@ "ui/activity/": "/hub/ui/activity/", "ui/nav": "/hub/ui/nav/nav.js", "ui/nav/": "/hub/ui/nav/", - "ui/timeline": "/hub/ui/timeline/timeline.js", - "ui/timeline/": "/hub/ui/timeline/", + "ui/history": "/hub/ui/history/history.js", + "ui/history/": "/hub/ui/history/", "ui/tour": "/hub/ui/tour/tour.js", "ui/tour/": "/hub/ui/tour/" }, diff --git a/apps/hub/hub/ui/timeline/timeline.js b/apps/hub/hub/ui/history/history.js similarity index 95% rename from apps/hub/hub/ui/timeline/timeline.js rename to apps/hub/hub/ui/history/history.js index ef5705c..bcb9f20 100644 --- a/apps/hub/hub/ui/timeline/timeline.js +++ b/apps/hub/hub/ui/history/history.js @@ -1,8 +1,8 @@ -// The Timeline — a horizontal scrubber over Steps. +// The History — a horizontal scrubber over Steps. // // Vocabulary from docs/deck-step-version-vocabulary.md: a **Step** is one // settled save on the depot's every-save tape, a **Version** is an immutable -// published tree, and the **Timeline** is the tape itself. +// published tree, and the **History** is the tape itself. // // WHY A SCRUBBER AND NOT A DROPDOWN. A dropdown of 138 change ids answers // "which one" and refuses to answer the question people actually have, which @@ -17,8 +17,8 @@ // run steps that touched ANYTHING IN THE you are watching the // PAGE'S CLOSURE assembled thing // -// A palette change is a step on the run timeline for a canvas page — it -// changes what renders — and is not a step on the browse timeline for +// A palette change is a Step in the run History for a canvas page — it +// changes what renders — and is not a Step in the browse History for // canvas.js, because those bytes never moved. Same depot, same instant, two // correct answers, and the difference is exactly what the two lenses mean. // @@ -75,7 +75,7 @@ export function stepsFor(steps, prefixes) { * * This folded by elapsed time and that was wrong twice over. At ninety * seconds it swallowed a whole working session — forty-five Steps became two - * notches, and a timeline with two notches is not a timeline. Shrinking the + * notches, and History with two notches is not useful History. Shrinking the * window did not fix the idea. * * THE IDEA WAS WRONG FOR AGENTS. Time-folding assumes rapid saves are one @@ -179,10 +179,10 @@ export function stepBy(steps, current, delta) { * and none on the work. This is an index, not a clock; the clock is printed * beside it. */ -export function timelineHtml({ steps, current, label = 'timeline', busy }) { +export function historyHtml({ steps, current, label = 'history', busy }) { if (!steps.length) { return ( - `
      ` + + `
      ` + `${esc(label)}` + `no steps here yet
      ` ); @@ -227,7 +227,7 @@ export function timelineHtml({ steps, current, label = 'timeline', busy }) { .join(''); return ( - `
      ` + + `
      ` + `${esc(label)}` + `` + `` + @@ -407,7 +407,7 @@ export function attachScrub(root, { onScrub, onCommit } = {}) { /** * HOW FAST WAS THAT — measured, not claimed. * - * Scrubbing a timeline is pleasant rather than painful because every Step + * Scrubbing History is pleasant rather than painful because every Step * address is immutable, so the whole closure is `immutable`-cached after the * first visit. That is a real property of the protocol and it is invisible * unless somebody prints it, so this prints it: wall clock for the frame, how @@ -444,7 +444,7 @@ export function measureFrame(frame, into) { ); } -export const TIMELINE_CSS = ` +export const HISTORY_CSS = ` .tl { display: flex; align-items: center; gap: 1ch; padding: calc(var(--row, 1.5) * .3em) 0; @@ -465,7 +465,7 @@ export const TIMELINE_CSS = ` are three wide bars and ninety are ninety thin ones — the tape is always the same length and only its resolution changes, which is what makes the position of the marker mean something at a glance. An earlier version - capped notch width and right-aligned them, so a short timeline was a few + capped notch width and right-aligned them, so short History was a few small marks adrift in an empty rule, and read as broken. */ .tl-track { flex: 1; display: flex; align-items: stretch; @@ -473,7 +473,7 @@ export const TIMELINE_CSS = ` border-bottom: 1px solid var(--line); } /* Capped, so twelve steps read as a tape rather than a bar chart. Without a - maximum, a short timeline stretches each notch to 80px and the row stops + maximum, short History stretches each notch to 80px and the row stops looking like time at all. */ .tl-notch { flex: 1 1 0; min-width: 2px; padding: 0; @@ -511,10 +511,10 @@ export const TIMELINE_CSS = ` .tl-pop-id { margin-top: .2em; } `; -export function installTimelineStyles(doc = document) { - if (doc.querySelector('style[data-deck-timeline]')) return; +export function installHistoryStyles(doc = document) { + if (doc.querySelector('style[data-deck-history]')) return; let style = doc.createElement('style'); - style.dataset.deckTimeline = 'v1'; - style.textContent = TIMELINE_CSS; + style.dataset.deckHistory = 'v1'; + style.textContent = HISTORY_CSS; doc.head.append(style); } diff --git a/apps/hub/hub/ui/history/package.json b/apps/hub/hub/ui/history/package.json new file mode 100644 index 0000000..245e9b2 --- /dev/null +++ b/apps/hub/hub/ui/history/package.json @@ -0,0 +1,6 @@ +{ + "name": "history", + "version": "0.1.0", + "type": "module", + "exports": { ".": "./history.js" } +} diff --git a/apps/hub/hub/ui/timeline/package.json b/apps/hub/hub/ui/timeline/package.json deleted file mode 100644 index b62b883..0000000 --- a/apps/hub/hub/ui/timeline/package.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "name": "timeline", - "version": "0.1.0", - "type": "module", - "exports": { ".": "./timeline.js" } -} diff --git a/apps/hub/hub/ui/tour/tour.js b/apps/hub/hub/ui/tour/tour.js index 6dd2579..c5c2aa5 100644 --- a/apps/hub/hub/ui/tour/tour.js +++ b/apps/hub/hub/ui/tour/tour.js @@ -5,14 +5,14 @@ // . Next / Prev / Exit are the bar (and ← → esc). // // Beats may `prepare` a real interaction: list-browse, open volatility, -// scrub Timeline, scroll a feed, expand a review fold — show, don't narrate. +// scrub History, scroll a feed, expand a review fold — show, don't narrate. import { driver } from 'driver.js'; const STORAGE = 'deck.hub.tour'; const TOUR_PARAM = 'tour'; /** @typedef {'none' | 'list-browse' | 'dropdown' | 'scroll' | 'scrub'} Interaction */ -/** @typedef {'open-vol' | 'expand-review' | 'scroll-feed' | 'list-browse' | 'scrub-timeline'} Prepare */ +/** @typedef {'open-vol' | 'expand-review' | 'scroll-feed' | 'list-browse' | 'scrub-history'} Prepare */ /** @typedef {{ * chapter: number, @@ -183,19 +183,19 @@ const CHAPTERS = [ go: '/run?at=/vale-sale/vale/sale/index.html', label: 'sale', claim: - 'Past is not a backup — it is a URL you can load in this frame. Watch the page; the tape is underneath.', + 'Past is not a backup — it is a URL you can load in this frame. Watch the page; History is underneath.', }, { chapter: 4, title: 'The hour you abandoned is still an address', page: 'run', - element: '[data-role="timeline"], .tl', + element: '[data-role="history"], .tl', go: '/run?at=/vale-sale/vale/sale/index.html', - label: 'timeline', + label: 'history', claim: - 'Scrub back one seal. The iframe follows /_history//…. Going back is reading and running an address.', + 'Scrub back one Step. The iframe follows /_history//…. Going back is reading and running an address.', interaction: 'scrub', - prepare: 'scrub-timeline', + prepare: 'scrub-history', }, { chapter: 4, @@ -205,7 +205,7 @@ const CHAPTERS = [ go: '/track?at=/vale-sale', label: 'saved rows', claim: - 'Saved rows deep-link into browse. The hour you abandon is sealed too — in case you were wrong.', + 'Saved rows deep-link into browse. The hour you abandon remains in History too — in case you were wrong.', interaction: 'scroll', prepare: 'scroll-feed', }, @@ -426,8 +426,8 @@ function runPrepare(kind) { section.scrollIntoView({ block: 'center', behavior: 'smooth' }); return; } - if (kind === 'scrub-timeline') { - let tape = document.querySelector('[data-role="timeline"]'); + if (kind === 'scrub-history') { + let tape = document.querySelector('[data-role="history"]'); if (!tape) return; tape.scrollIntoView({ block: 'center', behavior: 'smooth' }); // Click ◀ once the stage has painted — same control as keyboard `[`. diff --git a/deckbook.html b/deckbook.html index bb3a1b9..7b93d5a 100644 --- a/deckbook.html +++ b/deckbook.html @@ -290,7 +290,7 @@

      Deckbook

    1. 4. Three volatilities
    2. 5. The live loop
    3. 6. Store & packs
    4. -
    5. 7. History: Versions, or a Timeline
    6. +
    7. 7. History: every save, or Versions only
    8. 8. Safe vendor
    9. 9. Fork, lock, offers
    10. 10. HTTP surface
    11. @@ -316,13 +316,13 @@

      The Deckbook

      are all pointers to trees. Commit, install and deploy all do the same job: they move a copy to a second place that names things differently. Here there is no second place. This book teaches the running reference - implementation: the CLI, the store, the server, the Timeline, and the + implementation: the CLI, the store, the server, the History, and the workflows they open up.

      Reference impl built Versions always on - Timeline optional + Every-save History optional No framework required
      @@ -340,7 +340,7 @@

      1. The promise

      Instant feedbackVersion-less URL → working tree, Cache-Control: no-store Never lose workAuto-publish <base>-dev.N + optional per-save seals - Revert anytimedeck unpack a pack, or deck restore <changeId> (with a Timeline) + Revert anytimedeck unpack a pack, or deck restore <changeId> (with every-save History) Ship any versiondeck release --from dev — pointer, zero bytes copied Share a milestonedeck pack → a Deck Pack (.pack.zip, a standard zip) Use any librarydeck vendor hermetic CDN graph → your store @@ -364,7 +364,7 @@

      2. Vocabulary

      VersionA published tree that does not change. A semver number addresses it. (Was “sealed deck”.) Deck Pack.pack.zip — one tree and its packlist in one file. Any zip tool opens it. (Was “cardpack”.) treeHashContent identity of a whole tree (tree-hash-v1). - Step / changeIdOne capture of the working tree, one for each save. The Steps in order are the Timeline. Needs a history backend (jj / historyd). + Step / changeIdOne capture of the working tree, one for each save. The Steps in order are History. Needs a history backend (deckd). offerA fork that records its upstream base — a proposal you can run. @@ -418,7 +418,7 @@

      5. The live loop

      # → debounce (~500ms quiet) # → treeHash unchanged? stop # → packFromDir → publish <base>-dev.N, tag:dev -# → [with a Timeline] the save is recorded as a Step too +# → [with every-save History] the save is recorded as a Step too deck release --depot ./work/site --publisher acme --name palette \ --version 1.1.0 --from dev --tag latest
      @@ -449,7 +449,7 @@

      6. Store & packs

      -

      7. History: Versions, or a Timeline

      +

      7. History: every save, or Versions only

      A depot has one of two answers, and the server reports which. These are not numbered levels — each one has a name that says what you get.

      @@ -461,7 +461,7 @@

      7. History: Versions, or a Timeline

      - + @@ -536,7 +536,7 @@

      10. HTTP surface (read-only server)

      - + @@ -1028,7 +1028,7 @@

      The conflict that is real, and resolving it deterministically

      Runnable: pnpm --filter @cardstack/deck-example-two-agents demo, or the - two-agents panel in the hub. It is also the demo that turns a Timeline + two-agents panel in the hub. It is also the demo that turns on every-save History history on, so every save there has a changeId and not just every publish.
      @@ -1089,7 +1089,7 @@

      14. Try it on this server

    12. /_packages — JSON catalog + history capability block
    13. /_live/hmr.js — drop-in HMR client source
    14. /<depot>/_offers — the open proposals, re-merged on read
    15. -
    16. /<depot>/_history — the Timeline, when a history backend is attached
    17. +
    18. /<depot>/_history — every-save History, when a history backend is attached
    19. And pnpm demo in the repo starts all seven examples at once — @@ -1126,7 +1126,7 @@

      15. Honest status

      - +
      Always on
      A TimelineEvery-save History Every save also becomes a Step at /<depot>/_history/<changeId>/…, with deck history and deck restore. A packlist can name the Step it was cut from. Implemented when --history auto|jj|historyd attaches
      ?metaMachine-readable listing (incl. seal when present)
      ?format=packFaithful stored pack bytes
      …/vendor.pack.zip!/inner.jsMount into a nested pack (depth-capped)
      GET /<depot>/_historyThe Timeline, when a history backend is attached
      GET /<depot>/_historyEvery-save History, when a history backend is attached
      GET /<depot>/_history/<id>/…File at any seal
      GET /_packagesCatalog + capability truth
      GET /_browse/…Directory / file JSON for the store browser
      Depot vs realm (backport prep)Ruled — docs/deck-depot-and-realm-backport.md (standalone = depot; Boxel realm stays placement-free)
      deck.land hostnamesRuled — docs/deck-land-hostnames.md (apex / local / cloud / cdn; hub is the app)
      Activity (/_activity)Proposed — docs/deck-activity-protocol.md (replaces /_heat; lean focus + story=1 canvas pack)
      UI vocabularyRuled — docs/deck-step-version-vocabulary.md (Version/Step/Timeline; Import/Pin/Entry/Leaf/Closure)
      UI vocabularyRuled — docs/deck-step-version-vocabulary.md (Version/Step/History; Import/Pin/Entry/Leaf/Closure)

      diff --git a/docs/artifacts/backport-gallery-scene.gts b/docs/artifacts/backport-gallery-scene.gts index e4d05be..be26349 100644 --- a/docs/artifacts/backport-gallery-scene.gts +++ b/docs/artifacts/backport-gallery-scene.gts @@ -4,8 +4,8 @@ import { tracked } from '@glimmer/tracking'; import { CardDef, Component, field, contains } from '@cardstack/base/card-api'; import StringField from '@cardstack/base/string'; // Bare import — answered by the realm decklist pin (see backport-remix-decklist.json). -// This module’s RRI is @workspace/acme/gallery/scene → three@0.169.0. -// @workspace/acme/gallery/legacy-viewer/scene can import 'three' too → 0.160.0 via scopes. +// This module’s RRI is @acme/gallery/scene → three@0.169.0. +// @acme/gallery/legacy-viewer/scene can import 'three' too → 0.160.0 via scopes. import * as THREE from 'three'; export class GalleryScene extends CardDef { diff --git a/docs/artifacts/backport-remix-decklist-browser.json b/docs/artifacts/backport-remix-decklist-browser.json new file mode 100644 index 0000000..5af12e9 --- /dev/null +++ b/docs/artifacts/backport-remix-decklist-browser.json @@ -0,0 +1,16 @@ +{ + "imports": { + "three": "https://app.boxel.ai/catalog/lib/three@0.169.0/build/three.module.js", + "three/addons/": "https://app.boxel.ai/catalog/lib/three@0.169.0/examples/jsm/", + "@cardstack/base/": "https://app.boxel.ai/base@1.12.0/" + }, + "scopes": { + "https://app.boxel.ai/workspace/acme/gallery/legacy-viewer/": { + "three": "https://app.boxel.ai/catalog/lib/three@0.160.0/build/three.module.js", + "three/addons/": "https://app.boxel.ai/catalog/lib/three@0.160.0/examples/jsm/" + } + }, + "deck": { + "extends": "https://app.boxel.ai/catalog/chris/gallery-board@2.1.0/" + } +} diff --git a/docs/artifacts/backport-remix-decklist-rri.json b/docs/artifacts/backport-remix-decklist-rri.json deleted file mode 100644 index 29753c5..0000000 --- a/docs/artifacts/backport-remix-decklist-rri.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "imports": { - "three": "@catalog/lib/three@0.169.0/build/three.module.js", - "three/addons/": "@catalog/lib/three@0.169.0/examples/jsm/", - "@cardstack/base/": "@cardstack/base@1.12.0/" - }, - "scopes": { - "@workspace/acme/gallery/legacy-viewer/": { - "three": "@catalog/lib/three@0.160.0/build/three.module.js", - "three/addons/": "@catalog/lib/three@0.160.0/examples/jsm/" - } - }, - "deck": { - "extends": "@catalog/chris/gallery-board@2.1.0/" - } -} diff --git a/docs/artifacts/backport-remix-decklist.json b/docs/artifacts/backport-remix-decklist.json index 5af12e9..5c619a7 100644 --- a/docs/artifacts/backport-remix-decklist.json +++ b/docs/artifacts/backport-remix-decklist.json @@ -1,16 +1,16 @@ { "imports": { - "three": "https://app.boxel.ai/catalog/lib/three@0.169.0/build/three.module.js", - "three/addons/": "https://app.boxel.ai/catalog/lib/three@0.169.0/examples/jsm/", - "@cardstack/base/": "https://app.boxel.ai/base@1.12.0/" + "three": "@catalog/three@0.169.0/build/three.module.js", + "three/addons/": "@catalog/three@0.169.0/examples/jsm/", + "@cardstack/base/": "@cardstack/base@1.12.0/" }, "scopes": { - "https://app.boxel.ai/workspace/acme/gallery/legacy-viewer/": { - "three": "https://app.boxel.ai/catalog/lib/three@0.160.0/build/three.module.js", - "three/addons/": "https://app.boxel.ai/catalog/lib/three@0.160.0/examples/jsm/" + "@acme/gallery/legacy-viewer/": { + "three": "@catalog/three@0.160.0/build/three.module.js", + "three/addons/": "@catalog/three@0.160.0/examples/jsm/" } }, "deck": { - "extends": "https://app.boxel.ai/catalog/chris/gallery-board@2.1.0/" + "extends": "@chris/gallery-board@2.1.0/" } } diff --git a/docs/deck-a-package-carries-its-own-name.md b/docs/deck-a-package-carries-its-own-name.md index 5bfc24e..d09d2f7 100644 --- a/docs/deck-a-package-carries-its-own-name.md +++ b/docs/deck-a-package-carries-its-own-name.md @@ -3,8 +3,8 @@ **Status: RULING** (2026-08-11). Companions: [`deck-a-package-resolves-through-its-own-map.md`](deck-a-package-resolves-through-its-own-map.md) (the same instinct applied to resolution), -[`deck-name-derivation-ruling.md`](deck-name-derivation-ruling.md) (how names -are derived when a fold is lossy), +the planned name-derivation ruling (how names are derived when a fold is +lossy), [`deck-multi-package-design.md`](deck-multi-package-design.md) (the design this corrects), [`deck-laws.md`](deck-laws.md) (paths are IDs). diff --git a/docs/deck-a-package-resolves-through-its-own-map.md b/docs/deck-a-package-resolves-through-its-own-map.md index 71c7305..6decb4d 100644 --- a/docs/deck-a-package-resolves-through-its-own-map.md +++ b/docs/deck-a-package-resolves-through-its-own-map.md @@ -2,8 +2,9 @@ **Status: SUPERSEDED for the consuming depot** (2026-08-12) by [`deck-package-json-and-depot-lock.md`](deck-package-json-and-depot-lock.md). -A package's `package.json` is a suggestion; the depot-root import map is -the only resolution authority. Same pack, two depots, two programs — that +A package's `package.json` is a suggestion; the consuming realm's canonical +composition of root and package/app-local import maps is the only resolution +authority. Same pack, two depots, two programs — that is the owner's jurisdiction. This file remains the record of the earlier ruling and still describes sealed-map behaviour for trees that have no depot lock (dual-lock fallback). @@ -101,10 +102,10 @@ tune the depot. - ~~**Whether a depot may ever override a sealed pin.**~~ **Settled** by [`deck-who-may-intervene.md`](deck-who-may-intervene.md). A sealed pin may - be advanced *within the range the package itself declared* — permission + be advanced _within the range the package itself declared_ — permission granted in writing at publish time, so it is not a depot second-guessing a package. Out-of-range remains open there. -- **What a packaged module's *unsealed* imports mean.** A `$DECK/` reference +- **What a packaged module's _unsealed_ imports mean.** A `$DECK/` reference is documented as the deliberate live escape hatch; whether a bare specifier with no sealed answer should refuse to resolve, or fall through to the depot, is open. Refusing is more honest and more annoying. diff --git a/docs/deck-activity-protocol.md b/docs/deck-activity-protocol.md index 1ac6116..fda6627 100644 --- a/docs/deck-activity-protocol.md +++ b/docs/deck-activity-protocol.md @@ -7,7 +7,7 @@ locator, commentary — without a second focus API. Companions: `deck-live-autopublish.md`, `deck-hub-brief.md` (`/track`, `/run`, `/browse`), `deck-land-hostnames.md`, `deck-laws.md`, -`deck-step-version-vocabulary.md` (Version / Step / Timeline). +`deck-step-version-vocabulary.md` (Version / Step / History). Supersedes: `deck-heat-protocol.md` (folded in here). --- diff --git a/docs/deck-at-rest.md b/docs/deck-at-rest.md index e2377ec..626cacf 100644 --- a/docs/deck-at-rest.md +++ b/docs/deck-at-rest.md @@ -1,4 +1,4 @@ -# Deck at rest +# Deck at rest inside a realm Written 2026-08-09. This document describes what a Deck depot looks like on disk, and what a second implementation must agree with. It uses ASD-STE100 @@ -15,7 +15,7 @@ formats. The store layout in section 3, the command-line tool and the watcher are implementation: no law requires a server to store anything this way. Deck's package-serving laws do not require federation. The first durable -Repository, Revision, and Ref layer is specified separately in +Repository, Checkpoint, and Ref layer is specified separately in [`deck-collaboration-protocol.md`](deck-collaboration-protocol.md). Its local review Request workflow and conditional object-store adapters are implemented; hosted identity, policy, and cross-server discovery remain unfinished. @@ -33,67 +33,109 @@ laws and the conformance suite disagree, the suite is correct. The vocabulary changed in `docs/deck-step-version-vocabulary.md`. The important changes from v2: -| now | was | why | -| --- | --- | --- | -| **Version** | sealed deck, seal | "Seal" was a noun for a published version, a noun for a history checkpoint, and a verb. Three meanings for one word. | -| **Step** | seal, frame, tick | One capture of the working tree. One Step for each save. | -| **Timeline** | history, the tape | The Steps in order. | -| **Deck Pack** | cardpack | The file form is `.pack.zip`. The name `cardpack` belongs to the v0.2 implementation. | -| **depot** | realm, collection | A directory that is also a URL space. | -| **Import / Pin / Entry** | dependency, resolution | What a deck asks for, the exact address that answers, and the module a deck offers. | +| now | was | why | +| ------------------------ | ---------------------- | -------------------------------------------------------------------------------------------------------------------- | +| **Version** | sealed deck, seal | "Seal" was a noun for a published version, a noun for a history checkpoint, and a verb. Three meanings for one word. | +| **Step** | seal, frame, tick | One capture of the working tree. One Step for each save. | +| **History** | timeline, the tape | The Steps in order. | +| **Deck Pack** | cardpack | The file form is `.pack.zip`. The name `cardpack` belongs to the v0.2 implementation. | +| **depot** | realm, collection | A directory that is also a URL space. | +| **Import / Pin / Entry** | dependency, resolution | What a deck asks for, the exact address that answers, and the module a deck offers. | -Words to avoid: *tier* for a level of history, *frame* or *tick* for a Step, -and *seal* alone. Write *Version*, *Step*, or *to seal*. +Words to avoid: _tier_ for a level of history, _frame_ or _tick_ for a Step, +and _seal_ alone. Write _Version_, _Step_, or _to seal_. --- ## 2. The layout -A depot is a directory. The layout of the directory is the URL space that the -server supplies. +A mutable Deck repository is rooted in one realm directory. A repository may +contain one package or several member packages, but all of its Deck machinery +is inside that realm. A host may aggregate many realms in one bucket or +process; it must not move their Deck objects into a parallel global tree. ``` -/ - //… the live tree — the files you edit +/ + /… default-branch files you edit + package.json conventional package metadata, where applicable + importmap.json optional root resolution input + apps//… conventional app/package locations + packages///… conventional package locations .deck/ + repository.json optional multi-package authoring manifest store/ _objects// one file's bytes, one time _trees//.json one tree's packlist, one time - _locks//.json one exact import map, one time + _locks//.json one composed exact lock, one time _repositories//.json one Repository state, one time - _revisions//.json one authored Revision, one time - _refs///heads/… mutable branch refs - _pull_requests//.json immutable Pull Request openings - _pull_request_events//.json append-only request events - _pulls///.json mutable numbered request refs - _pulls///_counter.json mutable number allocator + _checkpoints//.json one authored Checkpoint, one time + _revisions//.json legacy V1 objects; read-only //meta.json Versions and dist-tags + refs/heads/.json mutable branch heads + refs/published.json optional mutable published Release ref + reviews/ + objects//.json immutable Review openings + events//.json append-only Review events + numbers/.json mutable numbered Review refs + counter.json mutable number allocator + indexes///… completed index generations + releases/ + objects//.json immutable host Releases + branches// + source/ non-default branch materialization + history/repo/ branch-local jj History + transaction/ prepared recovery records + index-staging/ private index build files claims/.json declared work in progress policy.json the cooldown policy - .jj/ the Timeline, when a backend attached + history/ + repo/ default-branch every-save jj History + .jj/ thin jj workspace stub; not durable identity ``` -**Target (deckd):** Timeline moves under `.deck/timeline/` and derived build -cache under `.deck/build/` — **no top-level `.jj`**. See -[`deck-daemon.md`](deck-daemon.md). Until that lands, `.jj/` above remains -the reference layout. +This deliberately keeps the filesystem shape proven by Boxel's +`deck/at-rest-poc`: the default branch is the realm root, not +`branches/main/source` and not a generated checkout. Existing paths remain +valid. The examples above do not require every realm to contain `apps/`, +`packages/`, a root `package.json`, or a root `importmap.json`. + +`package.json` and `importmap.json` have different, composable jobs. +`package.json` lives at a conventional package boundary and carries identity, +exports, and dependency suggestions. A root import map and any applicable +nested import maps are authored resolution inputs. Deck resolves their +inheritance and scopes and stores the canonical composed result as the exact +lock object named by `lockHash`. A Checkpoint therefore closes over the +effective lock, not over an assumption that one root file controls the whole +realm. + +deckd stores default-branch History under `.deck/history/repo`; its small top-level +`.jj/` workspace stub points there because stock jj discovers that basename. +For a selected non-default branch, the realm view projects that branch's +physical History as `/.deck/history/repo` too. Agents never need the opaque +host path. +Derived build cache lives under `.deck/build/`. See +[`deck-daemon.md`](deck-daemon.md). Two rules make this work: -1. **The store path and the URL path have the same shape.** A package at - `///@/` is stored under - `/.deck/store///`. Neither path has to be +1. **The store path and the URL path have the same shape inside its realm.** A + package at `///@/` is stored under + `/.deck/store///`. Neither path has to be translated into the other. 2. **The machinery is invisible to the tree hash.** `DEFAULT_IGNORE` excludes `.deck`, `.jj`, `.git`, `node_modules` and `.DS_Store`. A caller can add to that set. A caller cannot remove from it, because two implementations would then hash one directory differently. -Copy the directory and you copy the live files, the Versions and the Timeline. +Copy the realm directory and you copy its live files, branches, Versions, +Reviews, indexes, and History. Creating a branch is narrower: clone the +authored realm tree while excluding `.deck`, `.jj`, `.git`, and other +implementation directories, then initialize the new branch's History and +index through their owning services. -**A depot is close to a monorepo**: many packages in one directory, one -history, one thing to clone, and a change that crosses several packages is -one change. Four differences are worth knowing. +**A multi-package realm is close to a monorepo**: many packages in one +repository boundary, one default branch, and a Checkpoint that can cross +several packages. Four differences are worth knowing. The architectural argument for keeping that coherent development experience while replacing the monorepo as the storage and coordination boundary is in @@ -101,7 +143,7 @@ while replacing the monorepo as the storage and coordination boundary is in - Each deck has its own version line. A monorepo usually versions everything together, or needs a tool so that it does not have to. -- The directory layout *is* the URL space. A consumer imports one module from +- The directory layout _is_ the URL space. A consumer imports one module from one deck, and no build step extracts it first. - The publisher segment lets two people hold the same package name in one depot. That is a fork inside the repository, and it costs nothing until the @@ -114,7 +156,7 @@ while replacing the monorepo as the storage and coordination boundary is in ## 3. The store The store is **content-addressed for each file**, and the objects belong to -the whole store, not to one package. +the realm, not to one package and not to a host-global parallel tree. - A Version is a **pointer**: a version number, and the tree hash it names. - A tree is a **packlist**: the paths in the tree, and the hash of each file. @@ -176,31 +218,31 @@ depending on when it was asked. The publisher segment is the fork boundary. A path segment that starts with `_` is reserved for the protocol. -| address | is | cache | -| --- | --- | --- | -| no version segment | the live tree | `no-store` | -| `@` | one Version | `immutable` | -| `@` or `@` | a name for a Version | one redirect to the exact address | -| `/_history//…` | one Step | `immutable` | +| address | is | cache | +| ------------------------ | -------------------- | --------------------------------- | +| no version segment | the live tree | `no-store` | +| `@` | one Version | `immutable` | +| `@` or `@` | a name for a Version | one redirect to the exact address | +| `/_history//…` | one Step | `immutable` | A range never matches a prerelease. `latest` is a dist-tag on a stable Version, and `latest` is not live. --- -## 5. The Timeline +## 5. The History A depot has one of two answers, and the server **reports** which one. It does not pretend. Do not number these, and do not call them tiers: the two answers have names. -- **Versions only.** Each settled save becomes an immutable `-dev.N` Version. - The Versions are the history. Always available. -- **A Timeline.** Each save also becomes a Step, at +- **Version history only.** Each settled save becomes an immutable `-dev.N` + Version. The Versions are coarse-grained history. Always available. +- **Every-save History.** Each save also becomes a Step, at `//_history//…`, and `deck history` and `deck restore` work. Available when a history backend is attached. -A backend that is missing or has stopped leaves the depot with Versions only. +A backend that is missing or has stopped leaves the depot with Version history only. The server prints the reason and does not stop the live loop. `/_packages` carries the answer for each depot, so a client asks instead of assuming. @@ -243,7 +285,7 @@ after the fact**: Three decisions already constrain the answer: 1. **There is no unpublish** (L4). A Version cannot be altered or removed, so - the only way to say it is bad is to say so *about* it. That is the same + the only way to say it is bad is to say so _about_ it. That is the same shape as an endorsement, and it is the correct shape. 2. **A statement is addressed to the tree hash** (L9), so a revocation reaches every publisher that holds those bytes, exactly as an endorsement diff --git a/docs/deck-boxel-backport-changelist-mockup.md b/docs/deck-boxel-backport-changelist-mockup.md index a71d328..1f0348f 100644 --- a/docs/deck-boxel-backport-changelist-mockup.md +++ b/docs/deck-boxel-backport-changelist-mockup.md @@ -4,7 +4,10 @@ [`deck-work-plan-for-backport.md`](deck-work-plan-for-backport.md) in the Deck repo — protocol questions close there first). Product context: [`deck-boxel-backport-for-team.md`](deck-boxel-backport-for-team.md) -(**How we execute**, **An app is a decklist**). +(**How we execute**, **An app is a decklist**). Multi-branch substrate: +[`deck-boxel-branch-index-backport.md`](deck-boxel-branch-index-backport.md). +Binding local/AWS storage contract: +[`deck-storage-backends.md`](deck-storage-backends.md). This is a story of the work, told as stacked pull requests. It was written after walking today’s Boxel tree (`runtime-common`, `realm-server`, `host`) @@ -14,103 +17,111 @@ will actually get when each slice lands. It is a design of the work — not a measured diff. The `+ / −` counts under each commit are order-of-magnitude guesses after reading the real files; -they tell you *where the mass is*, not what to put in a spreadsheet. +they tell you _where the mass is_, not what to put in a spreadsheet. -**This doc owns the numbering.** PR 0–14 below are the only identifiers; -the team brief groups them and does not number anything of its own. If you -see a "Phase 5" anywhere, it is stale. +**This doc owns the older product-benefit PR numbering.** PR 0–14 stays stable +for existing cross-references. The readiness ruling adds A0–A3 as execution +aliases for PR 0/1/3/1b and B0–B7 as branch-substrate slices. If you see a +"Phase 5" anywhere, it is stale. -**Campaign order beats numeric order.** Do not blindly merge 0→14. Prefer: +**Campaign order beats numeric order.** Do not blindly merge 0→14. Finish R0 +in Deck, then prefer: -- **Product spine:** PR 1 → **PR 3** (versioned addresses) → **PR 1b** (thin - verbs) → PR 4c (host restore) -- **Infra spine:** PR 1 → PR 4a (Steps on write) → PR 5 (CLI 3-way) +- **Product spine:** R0 → A0/PR 0 → PR 1 → **PR 3** (versioned addresses) → + **PR 1b** (thin verbs) → B0–B3 realm/storage/index substrate → PR 4c +- **Infra spine:** R0 → A0/PR 0 → PR 1 → B0–B3 realm/storage/index substrate → PR 4a + (Steps on branch write) → PR 5 (CLI 3-way) -PR 3 sits on the product spine because we ship on fully-qualified URLs (see -**Naming**): the version lives in the path, so nothing can pin one until the -realm server answers `@version` addresses. +The finalized readiness decisions add **R0**, the Deck-side RRI gate, before +the first clean Boxel stack **A0–A3**: A0 = PR 0, A1 = PR 1, A2 = PR 3, and +A3 = PR 1b. Finish and record R0, cut A0 alone from fetched `origin/main`, +then stack A1–A3. These aliases describe execution order; the older PR numbers +below remain stable for cross-references. -Timeline is **sliced** (4a–4d + PR 5), not one epic. Vendoring, minify, +PR 3 sits on the product spine because an exact Version RRI still needs an +immutable HTTP representation. A1 selects the RRI; A2/PR 3 makes it fetchable. + +History is **sliced** (4a–4d + PR 5), not one epic. Vendoring, minify, Matrix, and review tools are **off the critical path**. +**2026-08-21 sequencing correction:** PR 4a must not first introduce a +realm-global History. Before its Step hook, land `main` compatibility, +realm-contained local/AWS storage, branch refs, branch-qualified index +generations, and atomic source + completed index cloning as specified in +`deck-boxel-branch-index-backport.md` B0–B3. +The numbering in this older mockup stays stable, but those substrate commits +are a hard prerequisite to 4a. Merge later uses one target write batch and +adds one target Step; it does not replay the source branch's Steps. + Sample artifacts you can open beside this doc: -- [`artifacts/backport-remix-decklist.json`](artifacts/backport-remix-decklist.json) — **what to ship**: URL pins, scopes, `extends` -- [`artifacts/backport-remix-decklist-rri.json`](artifacts/backport-remix-decklist-rri.json) — the same decklist after the RRI migration +- [`artifacts/backport-remix-decklist.json`](artifacts/backport-remix-decklist.json) — **what to ship**: canonical RRI pins, RRI scopes, `extends` +- [`artifacts/backport-remix-decklist-browser.json`](artifacts/backport-remix-decklist-browser.json) — derived absolute browser projection; never authored or merged - [`artifacts/backport-gallery-scene.gts`](artifacts/backport-gallery-scene.gts) — a gallery card on the newer `three` --- -## Naming — build on URLs now, move to RRI when it lands +## Naming — RRI is identity; URL is transport -**Read this before the samples.** Every example below is written in RRI -notation because that is where we are going. It is *not* what the first PRs -should ship, and the difference is deliberate. +**Read this before the samples.** R0 makes Core RRI-native before A0 vendors +it, so the backport writes scoped RRIs from its first durable object. There is +no URL-first migration stage. ### What exists today -Boxel has **RRI** (`RealmResourceIdentifier`): a branded string that is -either a **scoped identifier** (`@cardstack/base/card-api`) or a **full -URL**. `VirtualNetwork` maps the scoped form to a serve URL. Three prefixes -are registered — `@cardstack/base/`, `@cardstack/catalog/`, -`@cardstack/skills/` — and the -[RRI migration](https://linear.app/cardstack/project/cardstackbase-defea0d67319) -that built it is at Phase 3 of 5 (Phase 3 itself 94%). +Current Boxel `origin/main` has **RRI** (`RealmResourceIdentifier`), +`VirtualNetwork.resolveRRI`, scoped realm mappings, RRI-preserving relative +resolution, and canonical RRI dependency tracking (#5776). Production already +registers platform mappings such as `@cardstack/base/`, +`@cardstack/catalog/`, and `@cardstack/skills/`. -**There is no version segment in that grammar, and no `@catalog/` or -`@workspace/` prefix.** `resolveRRI`'s spec covers scoped identifiers, URLs, -relative resolution, and `$thisRealm/` — nothing else. Unmapped user realms -deliberately stay in URL form. +What is missing is general authority: every mutable realm needs a stable RRI, +every exact imported Version needs an exact RRI claim, and mappings must be +selected through the importing branch's lock instead of one global prefix map. +Those are A1/B1 responsibilities, not reasons to author URLs temporarily. ### The ruling -**Ship the backport on fully-qualified URLs. Let the RRI migration move us to -aliases afterwards.** The goal now is buildability and runnability, not final -notation. - -This costs nothing in capability, because the version does not need an -identifier grammar to live in — it lives in the **path**: +**Scoped RRI is the canonical logical identity.** Use npm-shaped scoped roots: ```text -https://app.boxel.ai/catalog/lib/three@0.169.0/build/three.module.js +mutable realm/package @acme/gallery/ +resource @acme/gallery/scene +exact package Version @catalog/three@0.169.0/ +exact package resource @catalog/three@0.169.0/build/three.module.js ``` -That is Deck's own serve grammar, specified in `deck-at-rest.md` and covered -by `deck conform`. So the dependency moves off the `@cardstack/base` migration -and onto **PR 3**, the package-serve handler — a new read-only route rather -than a change to a 94%-complete persistence migration. Verified end to end in -the Deck repo: scopes, sibling-realm isolation, `extends` flattening and -specifier resolution all work with fully-qualified URLs on both sides. - -`extends` accepts all three forms **today** (Deck W1, amended 2026-08-09), so -the later swap needs no Deck change: +Branches and Checkpoints do not alter the RRI. They alter `RealmViewContext`, +which resolves the same logical RRI to an internal transport view: ```text -acme/blog@1.2.3 depot-local -https://app.example/catalog/acme/blog@1.2.3/ fully qualified ← ship this -@catalog/acme/blog@1.2.3/ scoped alias ← later, free -``` +@acme/gallery/scene + main + → https://app.boxel.ai/workspace/acme/gallery/scene -### Reading the samples +@acme/gallery/scene + branch improve-nav + → https://app.boxel.ai/workspace/acme/gallery/__deck/branch//scene +``` -| Sample notation | Ship as | Becomes | -| --- | --- | --- | -| `@catalog/lib/three@0.169.0/build/three.module.js` | `https://app.boxel.ai/catalog/lib/three@0.169.0/build/three.module.js` | itself, once the prefix exists | -| `@workspace/acme/gallery/scene` | `https://app.boxel.ai/workspace/acme/gallery/scene` | itself | -| `@cardstack/base/card-api` | **unchanged — this one is real today** | unchanged | -| `https://app.boxel.ai/…` | unchanged | may alias later | +The canonical lock is composed from the applicable authored root and nested +`importmap.json` files and contains exact RRI targets and importer-RRI scope +keys. A1 projects it deterministically into the URL-valued import map native +ESM consumes. That projection is disposable derived delivery state; it does +not get reviewed, merged, or hashed as the Repository's lock authority. -`@cardstack/base/card-api` is the exception: that prefix is registered, -`identify()` already emits it, and the base realm's startup identity already -flipped to it (CS-12048). Authors keep writing it. +### Reading and compatibility -The swap, when it comes, rewrites **decklist values** — one `importmap.json` -per deck, not card JSON scattered across the index — and Deck's classifier -(`leave` / `rebind` / `rewrite`, PR 2) is exactly the tool for it. +- Source imports, card/code refs, Repository member roots, locks, index keys, + fixtures, events, and UI use scoped RRI form. +- Exact Versions place `@version` on the RRI package name, for example + `@catalog/three@0.169.0/`; a mutable realm stays `@acme/gallery/`. +- URL-form RRIs remain readable for old Checkpoints and foreign intake. Reads do + not silently rewrite durable history. +- Absolute URLs appear in HTTP requests and the generated browser import map + only. -Old published content that still says `https://cardstack.com/base/…` keeps a -**permanent** VN read-mapping — published Versions never change, so that -compat layer is not a temporary flag. New authoring prefers `@cardstack/base/…`. +`@cardstack/base/card-api` is already canonical today. Old published content +that says `https://cardstack.com/base/…` keeps its permanent read mapping; +published Versions never change. --- @@ -135,8 +146,12 @@ a **Touch** list with dry-run diffs (blast radius), then **Do** / **Don’t** / 1b Thin install / remix / use (fixture) → early K5 ← product spine 2 Classify URLs on publish → Track C; parallel OK 3 Serve versioned addresses → K3; PREREQUISITE for any version pin +B0 V2 collaboration objects inside each realm +B1 Local FS + AWS S3 Files/conditional-S3 backends and branch heads +B2 Immutable branch-qualified index generations + query projections +B3 Atomic source + index + deckd History initialization for branch/fork 4a Steps on one write path → K1 start ← hinge slices -4b Timeline HTTP + restore through write +4b History HTTP + restore through write 4c Host: list / restore Steps 4d Agent writes → Steps + claims 5 CLI sync becomes three-way merge → K4 @@ -153,7 +168,36 @@ a **Touch** list with dry-run diffs (blast radius), then **Do** / **Don’t** / **K10** is the destination of the whole stack, not a separate ticket. -**Cut hard?** Product: **1 → 3 → 1b → 4c**. Infra: **1 → 4a → 5**. +**Cut hard?** Product: **R0 → A0 → 1 → 3 → 1b → B0–B3 → 4c**. Infra: +**R0 → A0 → 1 → B0–B3 → 4a → 5**. + +--- + +## R0 — Deck Core speaks RRI, Checkpoint, and Review V2 before vendoring + +**Not a Boxel PR. Blocks A0.** Current Boxel `origin/main` has RRI, but current +Deck Core still validates Repository members as `owner/package` and proves +scopes after they have become URL projections. Work-plan W11 closes that gap +in Deck first: + +- add RRI parse/normalize/relative-resolution primitives to browser-safe Core; +- write RRI-native locks and importer scopes; +- add V2 Repository/manifest, Checkpoint, branch-head, and Review objects; +- bind Checkpoints to exact deckd History and index generations while allowing + ordinary Steps to advance a branch head without commit ceremony; +- make all collaboration paths relative to the owning realm's `.deck/`; +- accept a host transport projector instead of storing absolute URLs as + logical identity; and +- keep V1 Repository, Revision, Pull Request, and URL-form objects readable + without rehashing them. + +**Done when.** V2 Repository and RRI lock hashes are unchanged across two URL +projections; Checkpoint/branch-head/Review V2 round-trip; a Step can advance a +head without changing its latest Checkpoint; legacy hashes remain readable; +and RRI conformance is independently green. + +Record the exact clean Deck commit. That commit, not this planning worktree or +the stale `deck/at-rest-poc` vendor tree, is A0's source. --- @@ -179,14 +223,15 @@ import { resolveSpecifier } from '@cardstack/deck'; import { mergeTrees, publishToStore } from '@cardstack/deck/node'; ``` -**Depends on.** Clean Boxel `main` **and** the Deck work-plan gates: -`extends`, classifier, provenance, bare/hermetic, lean trim, foreign -`deck conform`, scopes proof. If review would reopen those, stop and fix -Deck — do not “design in the monorepo PR.” +**Depends on.** Clean Boxel `main` **and R0/W11 green at a recorded clean Deck +commit**, after the existing gates: `extends`, classifier, provenance, +bare/hermetic, lean trim, foreign `deck conform`, scopes proof. If review +would reopen those, stop and fix Deck — do not “design in the monorepo PR.” ### Commit — Add `@cardstack/deck` to the workspace -**Story.** Copy or workspace-link the **lean** Core into `packages/deck`. +**Story.** Copy the **lean** Core from the recorded R0 commit into +`packages/deck`. Export two surfaces and wire the three consumers. Do not call any Deck API from Boxel yet — if behavior changes in this PR, something went wrong. Prefer the trimmed cone (~protocol + serve helpers + history + conform), not the @@ -210,7 +255,8 @@ that proves the host graph does not pull `node:fs`. **Don’t.** Bring Hub, examples, teaching CLI, `vendor-npm`, minify/`derive`, or rebase-daemon. “Helpfully” start rewriting the resolver in the same PR. -**Done when.** `pnpm` install and build succeed; no realm behavior change. +**Done when.** `pnpm` install and build succeed; RRI V2 Core tests and +conformance remain green; no realm behavior changes. --- @@ -219,8 +265,8 @@ or rebase-daemon. “Helpfully” start rewriting the resolver in the same PR. **Why.** This PR is where Boxel has to internalize a Deck thesis that is easy to miss: **an app is a decklist** — an import map of pins (RRI / Version), not a private copy of every dependency. Today -`VirtualNetwork.resolveImport` walks import-map prefixes and ignores *who -asked*. A realm cannot honestly hold two majors of the same library at +`VirtualNetwork.resolveImport` walks import-map prefixes and ignores _who +asked_. A realm cannot honestly hold two majors of the same library at once, so remix copies whole trees — the only way to get a private `three`. Deck’s answer is importer-aware resolution plus import-map **scopes**, keyed by the importer’s RRI prefix. Remix becomes “write a decklist” @@ -233,39 +279,22 @@ shipped the model. K3 / K11, and the “app = decklist” product story. **Authors see.** A decklist, and card code that still looks like normal GTS. -**Ship the first form** — see **Naming**; the second is the same decklist -after the RRI migration, and nothing in Deck has to change between them. +The canonical form is RRI-native from the start: ```json { "imports": { - "three": "https://app.boxel.ai/catalog/lib/three@0.169.0/build/three.module.js", - "@cardstack/base/": "https://app.boxel.ai/base@1.12.0/" - }, - "scopes": { - "https://app.boxel.ai/workspace/acme/gallery/legacy-viewer/": { - "three": "https://app.boxel.ai/catalog/lib/three@0.160.0/build/three.module.js" - } - }, - "deck": { - "extends": "https://app.boxel.ai/catalog/chris/gallery-board@2.1.0/" - } -} -``` - -```json -{ - "imports": { - "three": "@catalog/lib/three@0.169.0/build/three.module.js", + "three": "@catalog/three@0.169.0/build/three.module.js", + "three/addons/": "@catalog/three@0.169.0/examples/jsm/", "@cardstack/base/": "@cardstack/base@1.12.0/" }, "scopes": { - "@workspace/acme/gallery/legacy-viewer/": { - "three": "@catalog/lib/three@0.160.0/build/three.module.js" + "@acme/gallery/legacy-viewer/": { + "three": "@catalog/three@0.160.0/build/three.module.js" } }, "deck": { - "extends": "@catalog/chris/gallery-board@2.1.0/" + "extends": "@chris/gallery-board@2.1.0/" } } ``` @@ -282,11 +311,11 @@ Two details worth knowing before you write the resolver against these: `…/gallery` silently governs `…/gallery-legacy/…`, which resolves a neighbouring deck to the wrong version with no error. Deck's resolver enforces it; a Boxel adapter that re-implements matching must too. -- **`@cardstack/base/` is real today** and stays as written in both forms. - Only the *value* it maps to gains a version. +- **`@cardstack/base/` is real today.** The lock maps that stable RRI prefix + to the exact Version RRI `@cardstack/base@1.12.0/`. ```gts -// This module’s RRI: @workspace/acme/gallery/scene → three@0.169 +// This module’s RRI: @acme/gallery/scene → three@0.169 import * as THREE from 'three'; import { CardDef, field, contains } from '@cardstack/base/card-api'; @@ -297,11 +326,12 @@ export class GalleryScene extends CardDef { ``` ```gts -// @workspace/acme/gallery/legacy-viewer/scene → three@0.160 via scopes +// @acme/gallery/legacy-viewer/scene → three@0.160 via RRI scopes import * as THREE from 'three'; ``` -Full samples: the two artifacts linked at the top. +Full samples: the canonical lock and its derived browser projection linked at +the top. **Depends on.** PR 0 (you can stub, but calling Deck’s `resolveSpecifier` is the point). @@ -309,8 +339,9 @@ the point). ### Commit — Thread the importer into resolution **Story.** Change resolution from “global prefix table” to “this specifier, -from that module.” The second argument is the importer’s RRI (or URL that VN -can unresolve to one). Scope keys are importer RRI prefixes. Prefer making +from that module.” The second argument is the importer’s canonical scoped RRI. +Legacy URL inputs are normalized through VN before resolution. Scope keys are +importer RRI prefixes. Prefer making Boxel’s virtual network a thin adapter over `@cardstack/deck`’s `resolveSpecifier` rather than forking the algorithm again. Cache keys in the loader must include the importer — otherwise the wrong major wins forever @@ -332,13 +363,13 @@ behavior change until a scopes fixture appears. **Don’t.** Open `realm.ts`. Break today’s one-`three` realms. -**Done when.** Unit tests cover old prefix behavior *and* two scopes for +**Done when.** Unit tests cover old prefix behavior _and_ two scopes for one bare name resolving differently by importer RRI. ### Commit — Acceptance: two live majors of one library **Story.** This is the K8 gate, not a nice-to-have. Build a fixture realm -where one module’s RRI sits under `@workspace/…/gallery/` (newer `three`) +where one module’s RRI sits under `@acme/gallery/` (newer `three`) and another under `…/legacy-viewer/` (older `three`). Both load. No esm.sh query-string version hack, no `@ts-expect-error`. If this fails, the resolver work is not done — stop and fix it. @@ -362,7 +393,7 @@ gate** for K8 (coexistence). Product remix UI is the next PR. ## PR 1b — Thin decklist verbs (early product win) **Why.** If “app = decklist” is the thesis, authors must see it **before** -Timeline, Matrix, or catalog-authority theater. Install / remix / use +History, Matrix, or catalog-authority theater. Install / remix / use (upgrade may be stubby) mutate the app’s import map — `extends` + pins — and must not full-copy trees. A fixture LibSpec is enough; pretty catalog pages wait for PR 12 polish. @@ -372,7 +403,7 @@ pages wait for PR 12 polish. **Authors see.** ```text -Fixture LibSpec → @catalog/lib/three@0.169.0 +Fixture LibSpec → @catalog/three@0.169.0 ──────────────────────────────────────────── [ Use ] create instance, pin exact [ Install ] add pin to my decklist @@ -387,7 +418,7 @@ Catalog → @catalog/chris/gallery-board@2.1.0 → [Remix] ``` **Depends on.** PR 1 and **PR 3** (a pin needs a servable versioned address). -Does **not** depend on Timeline (PR 4*) or Matrix. +Does **not** depend on History (PR 4*) or Matrix. ### Commit — Map helpers @@ -425,7 +456,7 @@ packages/host/app/services/realm.ts +20 / -5 **Do.** Align product copy with “map, not copy.” Kill full-copy fallback. -**Don’t.** Wait for GitHub mirror, Matrix, or Timeline. Build the full +**Don’t.** Wait for GitHub mirror, Matrix, or History. Build the full review product. Quietly keep full-copy “just in case.” **Done when.** Author can install and remix from the fixture without a @@ -520,14 +551,13 @@ packages/postgres/migrations/….ts (additive only) +40 / -0 --- -## PR 3 — Serve versioned addresses - -**On the product spine.** Was "optional early"; the URL-first ruling (see -**Naming**) moved it. The version lives in the path, so until the realm -server answers `…/three@0.169.0/…` there is nothing for a decklist to pin -and PR 1b has no fixture to install from. Still small — a read-only handler -— but it is now a prerequisite, not a nice-to-have. +## PR 3 — Resolve and serve exact Version RRIs +**On the product spine.** A1 can select +`@catalog/three@0.169.0/`, but until the realm server resolves that claim to +an immutable `…/three@0.169.0/…` representation there is nothing native ESM +can fetch and PR 1b has no fixture to install. This stays a small read-only +handler plus a registry gate. **Why.** Third-party libraries should be first-class, policy-gated addresses in our world — not forever-links to esm.sh. The crawl and corpus migration @@ -535,14 +565,14 @@ can live on a hosted Deck outside the monorepo. Boxel still needs a **serve door** and a thin registry gate so republishing the same name at a different treeHash is a conscious act. -**Unlocks.** **K3** on the serve side, and the address space everything else -pins into: those packages become cacheable pins authors can put in a decklist -as fully-qualified URLs today, aliases later. +**Unlocks.** **K3** on the serve side, and the exact RRI claim space everything +else pins into. Authors and locks retain RRIs; the resolver produces immutable +transport URLs. **Authors see.** A Spec in the catalog and a fetchable Version: ```text -RRI: @catalog/lib/three@0.169.0/build/three.module.js +RRI: @catalog/three@0.169.0/build/three.module.js Serve: https://app.boxel.ai/catalog/lib/three@0.169.0/build/three.module.js Cache-Control: public, max-age=31536000, immutable (once PR 6 lands) ``` @@ -553,7 +583,7 @@ Serve: https://app.boxel.ai/catalog/lib/three@0.169.0/build/three.module.js "attributes": { "title": "three.js", "description": "JavaScript 3D library", - "moduleHref": "@catalog/lib/three@0.169.0/build/three.module.js" + "moduleHref": "@catalog/three@0.169.0/build/three.module.js" }, "meta": { "adoptsFrom": { @@ -600,11 +630,11 @@ the registry gate has a failing test if you skip the policy check. ### Commit — Start the `deck conform` CI job **Story.** This is the first Deck-shaped HTTP surface, so it is where the -program gate starts *running* instead of being an intention. Add a CI job +program gate starts _running_ instead of being an intention. Add a CI job pointing `deck conform ` at a deck-enabled fixture realm. Most checks will not apply yet — that is expected; conform reports `not applicable` per law rather than failing. Tighten as PR 6 (cache classes) -and PR 4b (Timeline) land. +and PR 4b (History) land. **Touch.** @@ -620,7 +650,7 @@ so a later PR shrinking the not-applicable list is visible. “it barely checks anything” — the point is that it exists and cannot regress. **Done when.** CI runs `deck conform` against the fixture and fails if any -*applicable* law fails. +_applicable_ law fails. ### Commit — LibSpec content (catalog) @@ -630,7 +660,7 @@ is the versioned package RRI. No new card type — `spec.gts` already carries **Touch.** Catalog content (separate content PR is fine) · roughly `+200 / -0`. -**Do.** Point `moduleHref` at `@catalog/lib/…@version/…`, not a bare CDN. +**Do.** Point `moduleHref` at `@catalog/@/…`, not a bare CDN. **Don’t.** Invent a LibSpec card class. @@ -638,30 +668,92 @@ is the versioned package RRI. No new card type — `spec.gts` already carries --- -## PR 4a — Steps on one write path ← hinge start +## B0–B3 — Realm-contained collaboration substrate + +These slices are mandatory Boxel PRs between A0–A3 and History. They are not +optional infrastructure and they expose no branch UI yet. + +### B0 — V2 protocol objects + +**Story.** Import RRI-native Repository, `deck-checkpoint-v2`, Review, branch +head, and `mergeTrees` contracts from the exact Deck commit. Keep legacy +`deck-revision-v1` and `deck-pull-request-v1` fixtures readable without +rewriting their hashes. + +**Done when.** A Repository, Checkpoint, Review, and branch head round-trip +through Boxel adapters with byte-identical local/AWS canonical JSON. + +### B1 — One realm, two storage backends + +**Story.** Put every Deck resource below the realm's `.deck/`: CAS store, +branch refs, Reviews, completed indexes, branch workspaces, and deckd/jj +History. Implement the regular-filesystem adapter for Desktop and the AWS +adapter for hosted Boxel. + +On AWS, authored workspaces, index staging, and +`.deck/history/repo` use S3 Files. Agents may read all `.deck` files, but their +POSIX principal cannot write `.deck/store`, `.deck/indexes`, `.deck/refs`, +`.deck/reviews`, or `.deck/releases`. Only realm-server's direct-S3 +credentials write those paths, +using `If-None-Match: *` for immutable objects and `If-Match` for existing +branch refs. A bucket-global parallel protocol tree is forbidden. + +**Done when.** The same object/ref conformance suite passes locally and on S3; +an agent can inspect a branch ref as a file, cannot overwrite it through POSIX, +and a direct-S3 ref update appears in the realm's S3 Files view. + +### B2 — Exact index generations + +**Story.** Give every completed index a canonical generation manifest/hash. +Carry it in `RealmViewContext`, Checkpoints, Reviews, and branch heads. Treat +Postgres/SQLite rows as query projections of that generation. + +**Done when.** Two generations for the same Realm RRI coexist and Browse, +Run, search, caches, jobs, prerendering, and events select the right one. + +### B3 — Atomic branch/fork initialization + +**Story.** Create a branch or fork by materializing exact source, cloning the +completed index semantically, and asking deckd to initialize a distinct jj +History from the exact source `historyHead`. Same-realm branches may initially +share an immutable index generation; cross-authority forks rebase +authority-sensitive index data. + +**Done when.** The real fixture proves divergent source, Browse, Run, search, +index isolation, independent `.deck/history/repo`, and fork RRI rebasing on +both local filesystem and AWS backends. The branch is invisible until all +three initializations complete. + +--- + +## PR 4a — Steps on one branch write path ← hinge start -**Track B.** First Timeline slice. Wire Core; do **not** reimplement +**Track B.** First History slice. Wire Core; do **not** reimplement merge/store in realm-server. -**Why.** Editable realms need a tape. This slice only: after a successful -write batch, record a **Step** (debounced). No host UI, no claims UX, no -CLI. Versions-only remains OK for some read-only catalog mounts — not for -working realms once this lands. +**Why.** Editable realm branches need a tape. This slice only: after a +successful branch-qualified write batch, record a **Step** (debounced). No +host UI, no claims UX, no CLI. Versions-only remains OK for some read-only +catalog mounts — not for working realms once this lands. **Unlocks.** Start of **K1**. Data plane for later slices. -**Authors see.** Nothing in host yet — engineers see Steps in store/tests. +**Authors see.** Nothing in host yet — engineers see Steps through deckd and +the realm's inspectable `.deck/history/`. -**Depends on.** PR 0. Pairing with PR 1 / 1b is nice; not required. +**Depends on.** PR 0 plus B0–B3 of +`deck-boxel-branch-index-backport.md`. Pairing with PR 1 / 1b is nice; not +required. ### Commit — History façade + surgical write hooks **Story.** Create `realm-history.ts` as the façade: `recordStep`, `listSteps`, `restoreStep`, `publishVersion`, via `@cardstack/deck/node`. -In `realm.ts`, after successful `_batchWriteUnlocked` and delete paths, -call `recordStep` with actor metadata when present. Debounce (~500ms -quiet). Reuse notify / broadcast — no parallel bus. Stay inside -`withWriteLock`. Surgical hooks only in the large `realm.ts`. +In `realm.ts`, after successful `_batchWriteUnlocked` and delete paths, call +`recordStep` with branch and actor metadata when present. Debounce (~500ms +quiet). Reuse branch-qualified notify / broadcast — no parallel bus. Stay +inside the `(Realm RRI, branch)` write lock. Surgical hooks only in the large +`realm.ts`. **Touch.** @@ -677,33 +769,36 @@ Document the debounce constant. **Don’t.** Reformat `realm.ts`. Treat a claim refresh as a Step. Ship host restore UI (that is **4c**). -**Done when.** Two writes produce Timeline entries; the façade can restore -in unit tests even if HTTP is the next PR. +**Done when.** Two writes to one branch produce History entries; another +branch's source, index, and History do not move. The façade can restore in +unit tests even if HTTP is the next PR. --- -## PR 4b — Timeline HTTP + restore through write +## PR 4b — History HTTP + restore through write **Why.** Realm-server needs list Steps / Versions, file-at-Step, and restore **through** normal `Realm.write` so indexing follows. Attach a -Timeline per **editable** realm; Versions-only mounts OK for RO catalog. - -**Runtime decided (2026-08-09): the `jj` binary in the realm-server image.** -Deck's history package offers two backends — the jj CLI (one subprocess per -operation) and `historyd` (jj-lib over localhost HTTP, measured ~5–6x faster -per call). Ship the binary: no second process to deploy, supervise or -version, and Steps are debounced so the fork/exec cost stays off the hot -path. `historyd` is the upgrade if profiles ever say so, and the contract is -identical, so swapping is configuration. Treat this as a **deployment** task -with an owner, not a line in a handler: the image needs `jj`, and a realm -whose Timeline cannot start must degrade to `kind: 'none'` with a printed -reason rather than failing writes. +History per **editable** realm; Versions-only mounts OK for RO catalog. + +**Runtime decided (superseding the 2026-08-09 jj-CLI choice): Deck's +`HistoryBackend` connected to deckd.** Run one supervised deckd per +realm-server host. Every branch has a stable opaque source directory, and its +selected realm view exposes `.deck/history/repo` as deckd's durable jj History +identity. On hosted AWS the repository is written through S3 Files. Treat +`.deck/timeline/` only as a one-way legacy migration input. Do not attach a `.jj` +repository to the realm-global root and do not ship jj CLI as an intermediate +backend. The daemon may manage its internal jj-lib store under branch +machinery, which source snapshots and clones exclude. A read-only imported +catalog realm may have `kind: 'none'`. A mutable realm is a repository and +must not accept writes when its deckd History cannot settle them; return a +clear unavailable error rather than silently dropping every-save safety. **Unlocks.** Remote restore API for host and CLI. **Depends on.** PR 4a. -### Commit — Server Timeline + history HTTP +### Commit — Server History + history HTTP **Story.** Invent `realm-history*` on the server. HTTP list/restore. Optionally bridge `realm-file-changes-listener` so events can carry step ids. @@ -731,7 +826,7 @@ matches the earlier Step. ## PR 4c — Host: list and restore Steps **Why.** Authors need one history list and restore confirmation. Vocabulary -is Step / Version / Timeline — never “jj” or “seal” in chrome. This is the +is Step / Version / History — never “jj” or “seal” in chrome. This is the **product-spine** end of Tape (with 1b on Map). **Unlocks.** User-visible **K1** restore. @@ -808,7 +903,7 @@ Ship CLI sync here (PR 5). ## PR 5 — CLI checkpoints and 3-way sync -**The last Timeline slice / Track B.** Infra spine end: **1 → 4a → 5**. +**The last History slice / Track B.** Infra spine end: **1 → 4a → 5**. **Which CLI.** `packages/boxel-cli` in the monorepo — **the only one**. The standalone `cardstack/boxel-cli` repo is being retired (decision 2026-08-09), @@ -834,11 +929,11 @@ boxel history ./workspace/acme/gallery boxel history ./workspace/acme/gallery -r 3 boxel milestone ./workspace/acme/gallery -n opening-night -# → Version @workspace/acme/gallery@0.3.0 (catalog RRI after publish) +# → Version @acme/gallery@0.3.0 boxel sync ./workspace/acme/gallery -# mergeTrees(base=@workspace/acme/gallery@0.3.0, local, remote) -# conflict: @workspace/acme/gallery/scene +# mergeTrees(base=@acme/gallery@0.3.0, local, remote) +# conflict: @acme/gallery/scene # conflict: BlogPost/opening-night.json ``` @@ -851,10 +946,10 @@ before after **Depends on.** PR 4b (4c nice for humans testing the same realm). -### Commit — Checkpoints speak Timeline +### Commit — Checkpoints speak History **Story.** `CheckpointManager` spawns git against `.boxel-history/.git`. -Replace the backend with realm Timeline APIs: create → Step, restore → +Replace the backend with realm History APIs: create → Step, restore → `restoreStep`, list → Steps. `markMilestone` becomes “publish / tag a Version,” not a git tag. Keep the class’s command-facing API stable where you can, so the `realm/*` callers don’t churn for sport — `find-checkpoint.ts` @@ -897,7 +992,7 @@ list paths and write nothing for those files. ### Commit — History and milestone commands -**Story.** Point `history` and `milestone` at realm Timeline/Versions. +**Story.** Point `history` and `milestone` at realm History/Versions. Document the old footgun in help text: stop watch before restore. **Touch.** @@ -951,7 +1046,7 @@ curl -sI \ Decklists keep pinning RRIs; HTTP just finally tells the truth about them. -**Depends on.** PR 4a (so exact Version URLs exist). +**Depends on.** PR 3/A2 (so exact Version RRIs have immutable URL projections). ### Commit — L12 cache classes on the serve path @@ -995,7 +1090,6 @@ repeat without evidence. **Demoted.** Ship only after PR 6 cache classes are trusted. Easy to gold-plate; not on either spine. - **Why.** Because exact Versions never change, minify and gzip are safe and worth it. Live must stay readable source for authors and agents. @@ -1004,9 +1098,9 @@ worth it. Live must stay readable source for authors and agents. **Authors see.** ```text -RRI @catalog/lib/three@0.169.0/build/three.module.js -derive @catalog/lib/three@0.169.0/build/three.module.min.js -live @workspace/acme/gallery/scene → always source +RRI @catalog/three@0.169.0/build/three.module.js +derive @catalog/three@0.169.0/build/three.module.min.js +live @acme/gallery/scene → always source ``` **Depends on.** PR 6. @@ -1038,7 +1132,6 @@ packages/realm-server/tests/… +80 / -0 **Highest blast radius. Only after PR 4c restore works.** - **Why.** `https://cardstack.com/base/` is a special snowflake. Base should be a pinned Deck Version like everything else — while authors keep writing `@cardstack/base/card-api` so identify/unresolve don’t explode. The code @@ -1111,8 +1204,8 @@ packages/realm-server/tests/… base pin +80 / -0 ## PR 9 — Agent presence in preview -**Why.** Agents already write realms. People need to see *who is working -where* without digging through the Timeline. Claims and touch heat are that +**Why.** Agents already write realms. People need to see _who is working +where_ without digging through the History. Claims and touch heat are that signal — standing presence, not toast spam. A claim without a Step is “about to work”; a Step without a claim is “something landed.” @@ -1126,7 +1219,7 @@ to work”; a Step without a claim is “something landed.” │ ┌──────────────────────────────────────────────┐ │ │ │ (canvas) │ │ │ └──────────────────────────────────────────────┘ │ -│ ● vale on @workspace/acme/gallery/scene 18m left│ +│ ● vale on @acme/gallery/scene 18m left│ │ denser lighting on the hero mesh │ └─────────────────────────────────────────────────────┘ ``` @@ -1140,12 +1233,12 @@ GET /workspace/acme/gallery/_activity "claims": [ { "actor": "vale", - "at": "@workspace/acme/gallery/scene", + "at": "@acme/gallery/scene", "text": "denser lighting on the hero mesh", "minutesLeft": 18 } ], - "heat": [{ "path": "@workspace/acme/gallery/scene", "score": 0.82 }] + "heat": [{ "path": "@acme/gallery/scene", "score": 0.82 }] } ``` @@ -1175,7 +1268,7 @@ packages/runtime-common/realm.ts +30 / -5 ### Commit — Preview chrome **Story.** Consume activity in the existing preview canvas. Standing row: -actor, RRI path, claim text; optional link into Timeline. +actor, RRI path, claim text; optional link into History. **Touch.** @@ -1202,18 +1295,18 @@ design; do not block bare/hermetic on it. ```bash curl -OJ 'https://app.boxel.ai/_download-realm?realm=https://app.boxel.ai/workspace/acme/gallery/' -# → pack whose provenance.source is @workspace/acme/gallery/ +# → pack whose provenance.source is @acme/gallery/ ``` ```json { "provenance": { - "source": "@workspace/acme/gallery/", + "source": "@acme/gallery/", "treeHash": "a3f1…c90", "mode": "bare", "decklist": { "imports": { - "three": "@catalog/lib/three@0.169.0/build/three.module.js" + "three": "@catalog/three@0.169.0/build/three.module.js" } } } @@ -1315,7 +1408,7 @@ PR without Base-Version bot/CI closes on RO catalog boxel share ./workspace/acme/gallery \ --repo cardstack/gallery-mirror \ --branch share/vale/denser-lighting -# Base-Version: @workspace/acme/gallery@0.3.0 +# Base-Version: @acme/gallery@0.3.0 # treeHash: a3f1…c90 # Deck-Tip: live # Actor: vale @@ -1375,7 +1468,7 @@ share/gather as the interim truck” story has no CLI behind it. **Decide before relying on it.** Either the interim truck is the mirror export plus `Base-Version` trailers alone, with no CLI verbs, or -`packages/boxel-cli` gains share/gather as *new* commands — a feature, not a +`packages/boxel-cli` gains share/gather as _new_ commands — a feature, not a rewrite, and it should be scoped as one. --- @@ -1437,7 +1530,7 @@ auditable pointer move. ```text Matrix room #catalog-submissions ───────────────────────────────── -vale proposed @workspace/vale/gallery@0.4.0-dev.3/ +vale proposed @vale/gallery@0.4.0-dev.3/ base: @catalog/chris/gallery-board@2.1.0/ treeHash: b77c…019 why: denser hero lighting — run the offer before merging @@ -1517,7 +1610,7 @@ exposed from PR 4 / PR 9. **Authors see.** ```text -Review · @workspace/vale/gallery@0.4.0-dev.3/ +Review · @vale/gallery@0.4.0-dev.3/ vs @catalog/chris/gallery-board@2.1.0/ ──────────────────────────────────────────────── scene.gts | ++ lighting rig @@ -1555,17 +1648,18 @@ packages/realm-server (only if a GET is missing) +50 / -0 ## Using this as an agent prompt -1. Respect **campaign order** (see top): do not start Timeline before PR 1b - unless you are on the infra spine on purpose. +1. Respect **campaign order** (see top): finish R0/W11 and record its Deck + commit, then cut A0–A3 from current `origin/main`; never start History + before B1–B3 branch/index identity, even on the infra spine. 2. Pick **one commit** — not a whole multi-commit PR unless it is tiny. - Timeline: one of **4a / 4b / 4c / 4d**, not “all of PR 4.” -3. Paste the commit’s **Story**, **Touch**, **Do**, **Don’t**, and **Done when**. + History: one of **4a / 4b / 4c / 4d**, not “all of PR 4.” +3. Paste the commit’s **Story**, **Touch**, **Do**, **Don’t**, and **Done when**. 4. Include the parent PR’s **Authors see** (and the RRI primer if names are - involved) so the agent optimizes for the user-visible outcome. -5. Point at `docs/artifacts/` when implementing PR 1 / 1b. + involved) so the agent optimizes for the user-visible outcome. +5. Point at `docs/artifacts/` when implementing PR 1 / 1b. 6. If the task needs a protocol design debate, stop and open the Deck [`deck-work-plan-for-backport.md`](deck-work-plan-for-backport.md) — do - not invent answers in the Boxel PR. + not invent answers in the Boxel PR. 7. Prove **Done when** (and program gates: wiring-only, import not fork, author remix-without-copy) before the next commit. @@ -1573,21 +1667,21 @@ packages/realm-server (only if a GET is missing) +50 / -0 ## Appendix — rough change volume -| PR | Scale | Where the mass is | -| --- | --- | --- | -| 0 | S | Lean dependency wiring | -| 1 | M | Virtual network + loader + coexistence fixture | -| **1b** | **M** | **Thin verbs + host remix-as-map** (product spine) | -| 2 | M | Publish handler (parallel) | -| **3** | **S–M** | **Serve handlers; corpus outside** (spine — the address space) | -| **4a–4d** | **L sliced** | **Write path → HTTP → host → claims** | -| 5 | L | CLI rewrite (`packages/boxel-cli` — the only CLI) | -| 6 | S | Headers + tests | -| 7 | S–M | Derive pipeline (demoted) | -| 8 | S code / **XL blast** | Base pin — after 4c | -| 9 | S | Activity + preview | -| 10 | L | Intake dominates download | -| 11 | M | Mirror job + GH share/gather interim | -| 12 | S–M | Catalog verb polish (after 1b) | -| 13 | M | Matrix; after A+B (demoted) | -| 14 | L content | Review product — trails tape | +| PR | Scale | Where the mass is | +| --------- | --------------------- | -------------------------------------------------------------- | +| 0 | S | Lean dependency wiring | +| 1 | M | Virtual network + loader + coexistence fixture | +| **1b** | **M** | **Thin verbs + host remix-as-map** (product spine) | +| 2 | M | Publish handler (parallel) | +| **3** | **S–M** | **Serve handlers; corpus outside** (spine — the address space) | +| **4a–4d** | **L sliced** | **Write path → HTTP → host → claims** | +| 5 | L | CLI rewrite (`packages/boxel-cli` — the only CLI) | +| 6 | S | Headers + tests | +| 7 | S–M | Derive pipeline (demoted) | +| 8 | S code / **XL blast** | Base pin — after 4c | +| 9 | S | Activity + preview | +| 10 | L | Intake dominates download | +| 11 | M | Mirror job + GH share/gather interim | +| 12 | S–M | Catalog verb polish (after 1b) | +| 13 | M | Matrix; after A+B (demoted) | +| 14 | L content | Review product — trails tape | diff --git a/docs/deck-boxel-backport-for-team.md b/docs/deck-boxel-backport-for-team.md index 4a9e71c..34f7f06 100644 --- a/docs/deck-boxel-backport-for-team.md +++ b/docs/deck-boxel-backport-for-team.md @@ -6,21 +6,27 @@ Internal numbering from our planning docs is kept in parentheses so you can match longer write-ups later. You do not need those docs to follow this. -| Shorthand | Means | -| --- | --- | -| **K1–K14** | The fourteen product benefits we want from Deck in Boxel | -| **P1–P4** | Four technical prerequisites that unlock most of those benefits | -| **PR 0–14** | The stack in [`deck-boxel-backport-changelist-mockup.md`](deck-boxel-backport-changelist-mockup.md). **The only numbering.** This doc groups those PRs; it does not number anything itself. | -| **Deck Core** | Protocol library: identity, packs, store, merge, serve grammar, optional timeline — not the Deck Hub app | +| Shorthand | Means | +| ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **K1–K14** | The fourteen product benefits we want from Deck in Boxel | +| **P1–P4** | Four technical prerequisites that unlock most of those benefits | +| **PR 0–14** | The older product-benefit stack in [`deck-boxel-backport-changelist-mockup.md`](deck-boxel-backport-changelist-mockup.md). Kept stable for existing cross-references. | +| **R0** | Deck-side V2 gate (W11): RRI Repository/lock, Checkpoint, branch head, Review, realm-relative `.deck` paths, transport projector, compatibility reads. | +| **A0–A3** | The first clean execution stack: current Lean Core, resolver/scopes, versioned serve, thin verbs. | +| **B0–B7** | The branch/index/History/Review/merge substrate slices in [`deck-boxel-branch-index-backport.md`](deck-boxel-branch-index-backport.md). | +| **Deck Core** | Protocol library: identity, packs, store, merge, serve grammar, optional timeline — not the Deck Hub app | **Two living docs for execution** (everything else is appendix): -| Where | Doc | Role | -| --- | --- | --- | -| **Deck repo** | [`deck-work-plan-for-backport.md`](deck-work-plan-for-backport.md) | Protocol questions closed *here* so Boxel PRs are wiring | -| **Boxel work** | [`deck-boxel-backport-changelist-mockup.md`](deck-boxel-backport-changelist-mockup.md) | Stacked PRs, blast radius, agent prompts | +| Where | Doc | Role | +| -------------- | -------------------------------------------------------------------------------------- | -------------------------------------------------------- | +| **Deck repo** | [`deck-work-plan-for-backport.md`](deck-work-plan-for-backport.md) | Protocol questions closed _here_ so Boxel PRs are wiring | +| **Boxel work** | [`deck-boxel-backport-changelist-mockup.md`](deck-boxel-backport-changelist-mockup.md) | Stacked PRs, blast radius, agent prompts | -Appendix (why / deep dives): `deck-boxel-monorepo-change-register.md`, `deck-boxel-backport-plan.md`, `deck-depot-and-realm-backport.md`, `deck-core-backport-split.md`, [`deck-daemon.md`](deck-daemon.md) (deckd / `.deck`-only Timeline + build). Sample decklist + `.gts` under `docs/artifacts/`. +Appendix (why / deep dives): `deck-boxel-monorepo-change-register.md`, `deck-boxel-backport-plan.md`, `deck-depot-and-realm-backport.md`, [`deck-boxel-branch-index-backport.md`](deck-boxel-branch-index-backport.md) (one realm, multiple branch indexes), [`deck-storage-backends.md`](deck-storage-backends.md) (realm-contained Desktop + AWS storage), `deck-core-backport-split.md`, [`deck-daemon.md`](deck-daemon.md) (deckd / `.deck`-only History + build). Sample decklist + `.gts` under `docs/artifacts/`. + +Published host-mode fast path and immutable browser caching: +[`deck-boxel-host-publish-treehash.md`](deck-boxel-host-publish-treehash.md). --- @@ -28,17 +34,22 @@ Appendix (why / deep dives): `deck-boxel-monorepo-change-register.md`, `deck-box The K1–K14 table is the destination. The **campaign** is shorter and stricter: -1. **Deck-side gate before Boxel PR 0.** `extends`, classifier, provenance, bare/hermetic packs, lean Core cone, and `deck conform` against a foreign URL — settled in the Deck work plan. Monorepo PRs must stay “call this / mount that,” not reopen design. -2. **Prove the decklist in product early.** After importer-aware resolve, ship a **thin** install / remix / use against a fixture LibSpec — before Timeline, before Matrix, before catalog authority theater. -3. **Slice the Timeline hinge.** Steps on one write path → restore API → one host restore surface → agent claims → CLI 3-way. Refuse Timeline PRs that reimplement merge/store in realm-server. +1. **Deck-side gate before Boxel A0.** Finish R0/W11 after the existing `extends`, classifier, provenance, bare/hermetic packs, lean Core cone, and foreign conformance work. The selected Core commit must write RRI-native Repository/lock plus Checkpoint/branch-head/Review V2 with realm-relative paths before it is vendored. Monorepo PRs must stay “call this / mount that,” not reopen design. +2. **Prove the decklist in product early.** After importer-aware resolve, ship a **thin** install / remix / use against a fixture LibSpec — before History, before Matrix, before catalog authority theater. +3. **Put branch/index identity below History.** Add `main` compatibility, + branch-qualified index generations, and atomic source + completed-index + clone first. Then slice the History hinge: Steps on one branch write path → + restore API → one host restore surface → agent claims → CLI 3-way. Refuse + History PRs that record realm-global Steps or reimplement merge/store in + realm-server. 4. **Three tracks, different merge bars.** -| Track | Owns | May lag | -| --- | --- | --- | -| **A — Map** | Resolver, decklist, thin verbs, LibSpec fixtures | Matrix, packs polish | -| **B — Tape** | Timeline / Steps / Versions, restore, CLI merge | Catalog CDN | -| **C — Carriage** | Classifier wiring, packs, GitHub share/gather interim | Base realm | -| **D — Build** | **deckd**: Timeline under `.deck` only; esbuild/AMD derived plane ([`deck-daemon.md`](deck-daemon.md)) | Not on Map/Tape critical path; D0 layout can trail Timeline attach | +| Track | Owns | May lag | +| ---------------- | ----------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- | +| **A — Map** | Resolver, decklist, thin verbs, LibSpec fixtures | Matrix, packs polish | +| **B — Tape** | History / Steps / Versions, restore, CLI merge | Catalog CDN | +| **C — Carriage** | Classifier wiring, packs, GitHub share/gather interim | Base realm | +| **D — Build** | **deckd**: History under `.deck` only; esbuild/AMD derived plane ([`deck-daemon.md`](deck-daemon.md)) | D0–D1 are required for per-branch History; only the D2 build plane may lag | 5. **Program gates** (falsifiable — not fourteen local Done whens): - Monorepo PRs are wiring, not design debates. @@ -50,42 +61,112 @@ The K1–K14 table is the destination. The **campaign** is shorter and stricter: **Spines if you cut hard:** -- **Product:** resolver → versioned serve door → thin decklist verbs → Timeline restore -- **Infra:** resolver → Timeline write path → CLI 3-way +- **First shippable stack:** R0 Deck V2 identity/collaboration gate → A0 Lean Core → A1 + resolver/scopes → A2 versioned serve door → A3 thin decklist verbs +- **Collaboration stack:** B0 protocol types → B1 realm-contained local/AWS + storage + branch refs → B2 + branch-qualified index → B3 clone → B4 deckd History → B5 exact Review → + B6 merge → B7 quiet UX + +The Q1–Q11 execution decisions, including RRI identity, URL grammar, and index cloning, are +closed in +[`deck-boxel-branch-index-backport.md`](deck-boxel-branch-index-backport.md#01-execution-decisions). + +### Latest-main cut plan + +The Boxel base is **fetched `origin/main` at the moment a stack is cut**, never the +developer's local `main` and never `deck/at-rest-poc`. On 2026-08-21 the +observed Boxel base was `81678bd877f7903d5bba95cf2925b09d076fb9ce`; the +local `main` checkout was 111 commits behind it. Fetch and record the base +again immediately before creating A0. A0 itself remains blocked until R0 is +green and its exact clean Deck commit is recorded. + +Use a clean worktree so unrelated files in the current checkout cannot enter +the backport: + +```text +origin/main at cut time + └── codex/deck-backport-a0-core + └── codex/deck-backport-a1-resolver + └── codex/deck-backport-a2-serve + └── codex/deck-backport-a3-verbs +``` + +The A stack is deliberately narrow: + +| Branch/PR | Contains | Must not contain | Primary proof | +| --------- | ---------------------------------------------------------------------------------------- | ---------------------------------------------------- | ----------------------------------------------------------------------------------------------- | +| **A0** | Current immutable Deck Core vendor at the selected R0 commit, exports, workspace wiring | Resolver behavior, routes, Host UX, old fork history | install/build; RRI V2 Core tests and conformance remain green; host graph has no Node built-ins | +| **A1** | RRI-native importer-aware resolver, RRI scopes, browser-map projection, lifted Map tests | Serve door, History, catalog redesign | one RRI lock resolves two versions by importer and projects exact transport URLs | +| **A2** | Resolve exact Version RRIs through read-only package serving and cache/address contract | Vendor corpus, publishing rewrite, verbs | registered realm-server tests plus first applicable `deck conform` | +| **A3** | Thin install/remix/use helpers and one real LibSpec/product fixture | History, Atlas/CRM rewrite, collaboration UI | remix changes the decklist and does not copy unchanged package files | + +For A0, select and record the exact clean Deck commit that passes R0/W11. Do +not vendor from a dirty Deck worktree or a pre-RRI Core. From +`deck/at-rest-poc`, transplant only tests or small adapter changes whose +behavior is still required; recreate them against current APIs instead of +cherry-picking old commits. + +After A0–A3 merge, fetch `origin/main` again and cut the collaboration stack +from that new base: + +| Slice | Implementation cut | Merge gate | +| ------- | ----------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | +| **B0** | Boxel adapter for Repository/Checkpoint/ref/Review types with Repository members expressed as RRI roots | RRI protocol objects round-trip; no realm behavior changes | +| **B1** | Realm-contained `.deck` layout; Desktop FS + AWS S3 Files/direct-S3 adapters; branch ID/source directory, `main` compatibility, CAS | Same state machine passes locally/AWS; agents inspect but cannot POSIX-write protocol state | +| **B2a** | Add immutable index-generation manifest/hash and assign/backfill canonical scoped RRIs without changing read selection | Postgres/SQLite project one canonical generation; URL-form legacy reads remain green | +| **B2b** | Make writer/query paths select a generation through RRI-bearing `RealmViewContext`; new index keys are scoped RRIs | Two completed generations for one realm return different answers for the same logical RRI | +| **B2c** | Qualify RRI loader/query caches, jobs, prerendering, invalidation, activity, and events | A branch write cannot invalidate or leak into `main`; no canonical cache key uses transport URL | +| **B3** | Atomic source + completed-index + deckd History initialization; cross-authority fork rebase | Architecture fixture proves source, Browse, search, Run, History isolation, and fork assertions on both backends | +| **B4** | One host-level deckd, independent per-branch jj History on local FS/S3 Files, save/restore through branch write path | Restore cannot cross branches; branch creation initializes History ancestry | +| **B5** | Exact Review opening and immutable target/source/candidate Browse + Run projections | Review does not drift when either live branch moves | +| **B6** | Core three-way merge, invisible candidate generation, target-head recheck, one target write batch and one Step | conflict/stale head changes nothing; success changes source/index/ref/History exactly once | +| **B7** | Quiet branch selector, History context, Review/merge UI, and agent/CLI branch verbs | UI and CLI operate only through the real B1–B6 state | + +B2a–B2c are separate reviewable PRs behind an unexposed capability; together +they satisfy B2. No branch UI appears before the B3 fixture is green. Host +publish treeHash work, full/incremental publish controls, Atlas, CRM rewrites, +Matrix, and catalog polish remain later stacks. + +Every implementation PR runs lint in each modified package. Type checking uses +Glint, never `tsc`. Realm-server work gets focused `TEST_FILES` coverage; +Host work gets focused Ember tests with complete output captured to a file; +schema changes use the Postgres migration generator and regenerate the SQLite +schema before review. --- ## What Deck is (one paragraph) -Deck is a **versioning and serving protocol for module trees** (and JSON): immutable published **Versions**, a working “live” tree, content-addressed storage, 3-way merge, and a **Timeline** of every-save **Steps**. In Boxel terms: a **realm can become deck-enabled** — same realm product word, with Deck’s versioning and serve rules underneath. Boxel stays the product; Deck Core is the library the realm server and host import. +Deck is a **versioning and serving protocol for module trees** (and JSON): immutable published **Versions**, a working “live” tree, content-addressed storage, 3-way merge, and a **History** of every-save **Steps**. In Boxel terms: a **realm can become deck-enabled** — same realm product word, with Deck’s versioning and serve rules underneath. Boxel stays the product; Deck Core is the library the realm server and host import. Deck Core is a library drop, not the whole Deck teaching repo (no Hub, no demos). --- -## Versions vs Timeline — what Boxel should turn on +## Versions vs History — what Boxel should turn on Deck can run **Versions-only** (publish/restore named cuts, no every-save tape). That is fine for some catalog mirrors. **It is not the product default for editable Boxel realms.** -For Boxel we generally want **Timeline on**: +For Boxel we generally want **History on**: -| Who is writing | What we want recorded | Why | -| --- | --- | --- | -| **Human in the UI** (card instances, fields, layout, code in the host) | A **Step** on every settled save | Undo/restore, “what did the canvas look like an hour ago,” safe experimentation | +| Who is writing | What we want recorded | Why | +| -------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- | +| **Human in the UI** (card instances, fields, layout, code in the host) | A **Step** on every settled save | Undo/restore, “what did the canvas look like an hour ago,” safe experimentation | | **Agent-based updates** (especially **definitions** — `.gts` / card defs — and any agent write into the realm) | A **Step** per settled write batch; **claims** for “I am working here” | Audit what the agent changed, rewind a bad turn, parallel agents don’t erase each other’s trail | -| **Publish / milestone / catalog cut** | A **Version** (immutable `@exact`) | What others pin, cache, and remix against — not every keystroke | +| **Publish / milestone / catalog cut** | A **Version** (immutable `@exact`) | What others pin, cache, and remix against — not every keystroke | ```text -UI edit or agent write → live tree changes → Step (Timeline) +UI edit or agent write → live tree changes → Step (History) └─ when you mean it → Version (store) ``` -- **Steps** answer “what happened while we were editing.” -- **Versions** answer “what is the named release others depend on.” -- Restoring a Step does **not** rewrite published Versions. +- **Steps** answer “what happened while we were editing.” +- **Versions** answer “what is the named release others depend on.” +- Restoring a Step does **not** rewrite published Versions. - Agent **claims** (intent) and **touch/activity** (observed heat) are separate from Steps — see K12; don’t conflate “working here” with “captured a Step.” -The Timeline hinge is therefore not “optional history if someone wants undo.” It is **Timeline + Steps for interactive and agent edits**, with Versions as the publish layer on top. Catalog read-only mirrors may stay Versions-only; personal and team working realms should ship with Timeline. +The History hinge is therefore not “optional history if someone wants undo.” It is **History + Steps for interactive and agent edits**, with Versions as the publish layer on top. Catalog read-only mirrors may stay Versions-only; personal and team working realms should ship with History. --- @@ -98,55 +179,70 @@ a **decklist** — an import map (plus optional `scopes` and `extends`) that says which **RRI / Version** answers each bare name. The cards and modules you author sit in the live tree; everything else is a pin. -| Verb | What actually changes | -| --- | --- | -| **Use** | Create an instance whose `adoptsFrom` is an exact versioned RRI | -| **Install** | Add pins to *my* decklist (manage upgrades later) | -| **Remix** | `extends` a parent Version + override only what I change — **not** a tree copy | -| **Upgrade** | Repoint a pin / parent and re-resolve | +| Verb | What actually changes | +| ----------- | ------------------------------------------------------------------------------ | +| **Use** | Create an instance whose `adoptsFrom` is an exact versioned RRI | +| **Install** | Add pins to _my_ decklist (manage upgrades later) | +| **Remix** | `extends` a parent Version + override only what I change — **not** a tree copy | +| **Upgrade** | Repoint a pin / parent and re-resolve | So K5 and K8 are not four product features and a performance tip. They are -consequences of “the app *is* the map.” If Boxel keeps teaching remix as +consequences of “the app _is_ the map.” If Boxel keeps teaching remix as copy-the-package, we have not backported the model — only some plumbing. Authors still write normal imports (`three`, `@cardstack/base/card-api`). The decklist is where versions and coexistence live. See the sample in `docs/artifacts/backport-remix-decklist.json` and PR 1 in the changelist. -**Naming: fully-qualified URLs now, RRI aliases later.** Boxel's RRI grammar -today is a scoped identifier (`@cardstack/base/card-api`) or a full URL — -there is no version segment, and no `@catalog/` or `@workspace/` prefix. We -are not waiting for one. The version lives in the **path** -(`https://app.boxel.ai/catalog/lib/three@0.169.0/…`), which is Deck's own -serve grammar, so the backport builds and runs on what exists. When the -[RRI migration](https://linear.app/cardstack/project/cardstackbase-defea0d67319) -adds the aliases, the swap is a rewrite of decklist values — one -`importmap.json` per deck, using the classifier we already need for publish. -Deck's `extends` accepts both forms today, so nothing there has to change. +**Naming: RRI before the first Boxel write.** RRI support is present in current +Boxel `origin/main`: `RealmResourceIdentifier`, `resolveRRI`, registered realm +mappings, and canonical dependency tracking. The backport completes the +missing authority for arbitrary realms and exact package Versions instead of +shipping a temporary URL identity. + +```text +mutable realm/package root @acme/gallery/ +resource @acme/gallery/scene +exact imported Version @catalog/three@0.169.0/ +exact imported resource @catalog/three@0.169.0/build/three.module.js +``` + +The branch is deliberately absent from the RRI. `@acme/gallery/scene` plus +`RealmViewContext(branch=improve-nav)` resolves to a URL containing the +internal `__deck/branch/` transport view. An exact Checkpoint uses the +same logical RRI with an immutable Checkpoint view. -The cost of the ruling is one sequencing change: **PR 3 moves onto the -spine**, because a pin needs a servable `@version` address to point at. +The branch's canonical lock is the RRI-native composition of its applicable +root and nested `importmap.json` inputs: bare names resolve to exact Version +RRIs and `scopes` are keyed by importer RRI. A1 deterministically projects this +lock into the absolute URL import map native ESM consumes. The projection is +transport, not authored identity, and is never merged separately. + +URL-form RRIs remain a compatibility and foreign-intake form. New source, +code refs, Repository members, lock values, index identities, fixtures, and UI +write or display scoped RRIs. **PR 3 stays on the spine** because an exact +Version RRI still needs a real immutable HTTP representation. --- ## What we are trying to buy (the benefits) -| # | Benefit in plain English | -| --- | --- | -| **K1** | **Timeline + Steps** behind UI and agent edits (restore/undo), plus **Versions** for publish/checkpoint — not Versions-only | -| **K2** | Immutable, cacheable module URLs (CDN-grade caching when pinned to an exact version) | -| **K3** | Our own unpkg-style vendoring of third-party libs, with policy, browsable as catalog specs | -| **K4** | One checkpoint/milestone story; sync uses **3-way merge** instead of “pick local or remote” | -| **K5** | Honest catalog verbs on a **decklist**: install / upgrade / remix / use (the app *is* the map) | -| **K6** | Catalog is the authority; GitHub is a read-only mirror (plus a safe intake path when needed) | -| **K7** | Submit / review / vote across realms (not “open a GitHub PR” as the product workflow) | -| **K8** | Remix = decklist `extends` + overrides — **without** copying the whole tree | -| **K9** | Published releases can be minified and gzipped (safe because exact versions never change) | -| **K10** | Versioned realm infra that can replace the github + npm + esm.sh combo for card apps/libs | -| **K11** | Base realm (card-api, etc.) distributed via Deck import maps instead of a special snowflake | -| **K12** | Live agent awareness / activity visible in preview while work happens | -| **K13** | Richer review / patch tools over protocol data (versions, diffs, claims, offers) | -| **K14** | Realm download / move as a real **Deck Pack** (with provenance); full “negotiate what to prune” later | +| # | Benefit in plain English | +| ------- | -------------------------------------------------------------------------------------------------------------------------- | +| **K1** | **History + Steps** behind UI and agent edits (restore/undo), plus **Versions** for publish/checkpoint — not Versions-only | +| **K2** | Immutable, cacheable module URLs (CDN-grade caching when pinned to an exact version) | +| **K3** | Our own unpkg-style vendoring of third-party libs, with policy, browsable as catalog specs | +| **K4** | One checkpoint/milestone story; sync uses **3-way merge** instead of “pick local or remote” | +| **K5** | Honest catalog verbs on a **decklist**: install / upgrade / remix / use (the app _is_ the map) | +| **K6** | Catalog is the authority; GitHub is a read-only mirror (plus a safe intake path when needed) | +| **K7** | Submit / review / vote across realms (not “open a GitHub PR” as the product workflow) | +| **K8** | Remix = decklist `extends` + overrides — **without** copying the whole tree | +| **K9** | Published releases can be minified and gzipped (safe because exact versions never change) | +| **K10** | Versioned realm infra that can replace the github + npm + esm.sh combo for card apps/libs | +| **K11** | Base realm (card-api, etc.) distributed via Deck import maps instead of a special snowflake | +| **K12** | Live agent awareness / activity visible in preview while work happens | +| **K13** | Richer review / patch tools over protocol data (versions, diffs, claims, offers) | +| **K14** | Realm download / move as a real **Deck Pack** (with provenance); full “negotiate what to prune” later | **K10** is the destination (the sum of the above), not a separate ticket. @@ -156,17 +252,17 @@ spine**, because a pin needs a servable `@version` address to point at. Think in subsystems, not Deck package names: -| Boxel area | What Deck asks it to do | -| --- | --- | -| **`packages/deck` (new)** | Vendor Deck Core. Host imports the **browser-safe** surface (resolution). Realm-server imports the **Node** surface (store, pack, history helpers, serve helpers). | -| **`runtime-common`** | Resolver takes the *importer* (so two versions of the same library can coexist); realm write path records **Steps** (and publishes **Versions** when appropriate); classification of absolute URLs imported from Core — not reinvented. | -| **`realm-server`** | Serve Deck-shaped addresses and cache headers; **Timeline attached for editable realms**; activity/claims read path; pack download/intake; later mirror job; ACL around offers. | -| **`host` (Ember app)** | Remix / install / use UX; preview shows activity; restore/browse Steps from UI edits and agent turns; eventually base-realm URLs via Deck. | -| **`packages/boxel-cli`** | Local checkpoints align with Timeline/Steps; sync conflicts become 3-way merges against a Version (or Step) base. **The standalone `cardstack/boxel-cli` is retired** — this is the only CLI to migrate. | -| **Catalog / realm content** | LibSpec cards, four consumption verbs, submission workflow cards, review tools — much of K5/K7/K13 is **content**, not core TS. | -| **Outside the monorepo (early)** | Hosted Deck / vendor pipeline / corpus migration for third-party libs (K3 bulk). | +| Boxel area | What Deck asks it to do | +| -------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **`packages/deck` (new)** | Vendor Deck Core. Host imports the **browser-safe** surface (resolution). Realm-server imports the **Node** surface (store, pack, history helpers, serve helpers). | +| **`runtime-common`** | Resolver takes the _importer_ (so two versions of the same library can coexist); realm write path records **Steps** (and publishes **Versions** when appropriate); classification of absolute URLs imported from Core — not reinvented. | +| **`realm-server`** | Serve Deck-shaped addresses and cache headers; **History attached for editable realms**; activity/claims read path; pack download/intake; later mirror job; ACL around offers. | +| **`host` (Ember app)** | Remix / install / use UX; preview shows activity; restore/browse Steps from UI edits and agent turns; eventually base-realm URLs via Deck. | +| **`packages/boxel-cli`** | Local checkpoints align with deckd History; sync conflicts become 3-way merges against an exact Checkpoint base. **The standalone `cardstack/boxel-cli` is retired** — this is the only CLI to migrate. | +| **Catalog / realm content** | LibSpec cards, four consumption verbs, submission workflow cards, review tools — much of K5/K7/K13 is **content**, not core TS. | +| **Outside the monorepo (early)** | Hosted Deck / vendor pipeline / corpus migration for third-party libs (K3 bulk). | -Boxel keeps the word **realm**. “Depot” is Deck-standalone vocabulary; a deck-enabled realm *plays the role* of a depot for the protocol. +Boxel keeps the word **realm**. “Depot” is Deck-standalone vocabulary; a deck-enabled realm _plays the role_ of a depot for the protocol. --- @@ -206,10 +302,10 @@ behavior change. **What we add:** importer context, longest-prefix, import-map **scopes**. -| Benefit unlocked | Why | -| --- | --- | -| **K8** Remix without copying | One card on A@new, another path on A@old, both live | -| Path to **K5**, **K3**, **K11** | All need scoped resolution | +| Benefit unlocked | Why | +| ------------------------------- | --------------------------------------------------- | +| **K8** Remix without copying | One card on A@new, another path on A@old, both live | +| Path to **K5**, **K3**, **K11** | All need scoped resolution | **Shape:** small. **First monorepo behavior PR.** @@ -223,11 +319,11 @@ behavior change. **What we add:** install / remix / use (upgrade can be stubby) that mutate the app **decklist** — `extends` + pins — and do **not** full-copy trees. -| Benefit unlocked | Why | -| --- | --- | +| Benefit unlocked | Why | +| ------------------------------------ | -------------------------------------------------------------- | | Early **K5** + author-visible **K8** | Proves “app = decklist” in the product, not only in unit tests | -**Shape:** small–medium. **Do this before staffing Timeline.** Full catalog +**Shape:** small–medium. **Do this before staffing History.** Full catalog polish and package-page affordances can trail (PR 12 verb polish). --- @@ -237,8 +333,8 @@ polish and package-page affordances can trail (PR 12 verb polish). **Change in:** `realm-server` publish handler; **import** `@cardstack/deck/classify` (do not fork). -| Benefit unlocked | Why | -| --- | --- | +| Benefit unlocked | Why | +| ------------------------------ | -------------------------------------------------- | | Publish hygiene / **K14** prep | leave / rebind / rewrite; assets never become pins | **Shape:** small; parallel with PR 1 / 1b. Not on the product spine. @@ -247,15 +343,16 @@ polish and package-page affordances can trail (PR 12 verb polish). ### Versioned serve addresses — PR 3 (**on the spine**) -**Was "optional early"; the URL-first naming ruling moved it.** The realm -server has to answer `…/three@0.169.0/…` before anything can pin a version, -so PR 1b has nothing to install from until this lands. The serve door is -small — a read-only handler plus a thin registry gate. +**Required by RRI-native locks.** The resolver can select +`@catalog/three@0.169.0/`, but the realm server must map that exact RRI to an +immutable `…/three@0.169.0/…` HTTP representation before anything can execute +the pin. PR 1b therefore depends on this small read-only handler and registry +gate. **PR 3 also starts the `deck conform` CI job** (decided 2026-08-09). It is the first Deck-shaped HTTP surface, so the program gate begins running here -rather than arriving finished and late. Most laws will report *not -applicable* at first; the job tightens as PR 6 and PR 4b land. +rather than arriving finished and late. Most laws will report _not +applicable_ at first; the job tightens as PR 6 and PR 4b land. What stays optional is the **corpus**: the npm crawl and bulk vendoring for third-party libs (**K3**, catalog **K2**/**K9**) can live on a hosted Deck @@ -264,35 +361,56 @@ staff the corpus when someone owns it. --- -### Timeline hinge — PRs 4a–4d, then 5 (**Track B**) — *sliced*, not one epic +### Branch and index substrate — B0–B3 (**hard prerequisite to History**) + +These are new slices below the older PR 4 numbering. They are not UI work. + +| Slice | Ship | Proof | +| ------ | ---------------------------------------------------------------------------------------------- | ------------------------------------------------------- | +| **B0** | Repository/Checkpoint/ref/Review types with RRI member roots | RRI protocol fixture round-trips | +| **B1** | Realm-local `.deck`; local FS + AWS S3 Files/direct-S3 adapters; `main`, metadata, CAS refs | Existing realm is unchanged; backend conformance passes | +| **B2** | Immutable index-generation identity with canonical RRI keys throughout writer/query/cache/jobs | Two indexes for one RRI coexist | +| **B3** | Source + completed-index + deckd History initialization; rebased fork clone | Browse/search/Run/History diverge without leaking | + +The logical realm grammar is fixed: `@//` is the stable +mutable RRI, while `@/@/` is an exact Version RRI. +The transport grammar is separate: unqualified URLs select the default branch; +`__deck/branch//…` selects a mutable branch; and +`__deck/checkpoint//…` selects an immutable Checkpoint. Branch source owns +its exact composed import-map lock. + +--- + +### History hinge — PRs 4a–4d, then 5 (**Track B**) — _sliced_, not one epic -**Runtime: the `jj` binary in the realm-server image** (decided 2026-08-09). -The faster `historyd` backend is a later swap behind an identical contract; -shipping a binary avoids a second process to deploy and supervise, and Steps -are debounced so the per-call cost stays off the hot path. It is a deployment -task with an owner. A realm whose Timeline cannot start degrades to -Versions-only with a printed reason — it never fails writes. +**Runtime: Deck's `HistoryBackend` connected to deckd**, one supervised daemon +per realm-server host. Each branch has a stable opaque source directory, so +deckd gives it an independent jj History exposed as `.deck/history/repo` in +that branch's realm view. Desktop uses the ordinary filesystem; hosted Boxel +uses S3 Files. Do not attach jj or `.jj` to the realm-global root. Read-only +imports may be Versions-only. A mutable realm must reject a write clearly if +deckd cannot settle its every-save History; it must not silently degrade. Do **not** ship editable realms as Versions-only and call K1 done. Do **not** land write path + host UI + claims + CLI sync in one PR. -| Slice | Ship | Explicit non-goal | -| --- | --- | --- | -| **4a** | Steps on *one* write path (definition saves or batched writes) via Deck façade | No host restore UI | -| **4b** | List/restore HTTP through normal `Realm.write` | No agent claims UX | -| **4c** | One host history list + restore | No CLI rewrite | -| **4d** | Agent writes → same Step path + claims; activity read if cheap | No 3-way sync | -| **5** | CLI checkpoints + `mergeTrees` 3-way (**K4**) | No minify / CDN | +| Slice | Ship | Explicit non-goal | +| ----------- | --------------------------------------------------------------------------- | ------------------ | +| **4a / B4** | Per-branch Steps on one write path via Deck façade | No host restore UI | +| **4b / B4** | List/restore HTTP through the same branch `Realm.write` path | No agent claims UX | +| **4c / B7** | One host History list + restore, with quiet branch context | No CLI rewrite | +| **4d** | Agent writes → same Step path + claims; activity read if cheap | No 3-way sync | +| **5 / B6** | CLI checkpoints + Core `mergeTrees` through one target write batch (**K4**) | No minify / CDN | Each slice: wire Core; **refuse** reimplementing merge/store in realm-server. Gate with integration tests and, when the realm speaks enough HTTP, `deck conform`. -| Benefit unlocked | Why | -| --- | --- | -| **K1** | Steps for edits; Versions when you publish a cut | -| **K4** (PR 5) | Real common ancestor for sync | -| Foundation for **K12** / **K13** | Claims and review read the same tape | +| Benefit unlocked | Why | +| -------------------------------- | ------------------------------------------------ | +| **K1** | Steps for edits; Versions when you publish a cut | +| **K4** (PR 5) | Real common ancestor for sync | +| Foundation for **K12** / **K13** | Claims and review read the same tape | --- @@ -319,7 +437,7 @@ export plus `Base-Version` trailers — no CLI verbs — unless someone scopes share/gather as new commands in `packages/boxel-cli`. Full Matrix submission / retire GH-PR-as- product (**K7**) **after** Tracks A+B are real — do not staff Matrix early. -Catalog verb *polish* (pretty package pages, upgrade UX) lands here if not +Catalog verb _polish_ (pretty package pages, upgrade UX) lands here if not finished in PR 1b. --- @@ -335,14 +453,18 @@ don’t block. ## Dependency picture (campaign) ```text -−1 Deck work plan gates → Boxel PRs stay wiring - 0 lean Core drop - 1 resolver → K8 -1b thin decklist verbs → early K5 (product spine) ← prove the model + R0 Deck V2 gate → RRI + Checkpoint + Review + realm paths + A0 lean Core drop → Boxel PRs stay wiring + A1 RRI resolver + projection → K8 + A2 exact-RRI serve door → a pin has an immutable representation + A3 thin decklist verbs → early K5 ← prove the model 2 classifier on publish → parallel (Track C) - 3 versioned serve door → spine: a pin needs an address ← prereq for 1b 3′ vendor corpus / CDN → optional; must not block A/B -4a–4d, 5 Timeline slices → K1, then K4 ← hinge, sliced + B0–B3 branch/index substrate → one realm, real isolated branches + B4 deckd History + restore → K1 + B5 exact Review → real Browse + Run preview + B6 target-batch merge → K4 + B7 quiet branch/Review UX → familiar workflow 8 base via Deck → K11 (after PR 4c restore) 6 cache, then 9 activity → K2, K12; PR 7 / K9 later 11 mirror; 13 Matrix later → K6, then K7 @@ -351,16 +473,16 @@ don’t block. --- -## What is *not* “port the Deck Hub” +## What is _not_ “port the Deck Hub” -| Stays out of the first Boxel drop | Why | -| --- | --- | -| Deck Hub UI | Inspiration only; Boxel has host + catalog UX | -| Deck example demos | Teaching artifacts | -| Full prune-negotiation protocol | Undesigned; ship bare/hermetic first | -| Treating GitHub as the registry | Opposite of K6 | -| Matrix submission as day-one work | Interim = share/gather + Base-Version | -| Fat Core (vendor-npm, minify, rebase-daemon) | Tools until a PR needs them | +| Stays out of the first Boxel drop | Why | +| -------------------------------------------- | --------------------------------------------- | +| Deck Hub UI | Inspiration only; Boxel has host + catalog UX | +| Deck example demos | Teaching artifacts | +| Full prune-negotiation protocol | Undesigned; ship bare/hermetic first | +| Treating GitHub as the registry | Opposite of K6 | +| Matrix submission as day-one work | Interim = share/gather + Base-Version | +| Fat Core (vendor-npm, minify, rebase-daemon) | Tools until a PR needs them | Collaboration across private realms long-term: **Matrix** (identity + room + message of intent) + optional Deck Pack; realm ACL for offer/rebase. Until @@ -372,13 +494,16 @@ standalone repo; see PR 11.) ## Practical “start Monday” checklist -1. Confirm the Deck work-plan gates — do not open Boxel design debates. -2. Clean Boxel `main`; add **lean** `packages/deck` (PR 0). -3. PR 1 resolver; author gate: **remix without copying**. -4. PR **3** serve door — versioned addresses, so a pin has a target. -5. PR **1b** thin install/remix/use on a fixture — **before** Timeline. +1. Complete Deck R0/W11 and record the clean commit where RRI/Checkpoint/Review V2 Core + tests and conformance pass. +2. Fetch Boxel `origin/main`; cut **A0 only** and add Lean `packages/deck` + from that exact commit (old PR 0). Do not cherry-pick the stale fork. +3. Stack A1 RRI resolver/scopes/projection; author gate: **remix without copying** and no new authored absolute identity. +4. Stack A2 exact-RRI serve door — a Version RRI gains its immutable HTTP representation. +5. Stack A3 thin install/remix/use on a fixture — **before** History. 6. Parallel (optional owners): classifier publish (2), vendor corpus (3′). -7. Staff Timeline as **4a → 4d → 5** slices with restore before base realm. +7. Land **B0 → B3** branch/index substrate, then **B4 → B7**. Never land a + realm-global History as an intermediate state. 8. Base realm (PR 8) only after Step restore works. 9. Mirror / share-gather (PR 11); Matrix retirement of GH submission last (PR 13). 10. Packs (PR 10) + review tools (PR 14) without blocking Map/Tape wins. @@ -387,20 +512,20 @@ standalone repo; see PR 11.) ## Cheat sheet -| PR | Main Boxel touchpoints | Benefits | Shape | -| --- | --- | --- | --- | -| — Deck gates | Deck repo work plan | — | **hard gate** | -| 0 Lean Core | `packages/deck` | — | dependency drop | -| 1 Resolver | `runtime-common` | K8 | small — first behavior | -| **1b Thin verbs** | host + helpers + fixture | **early K5** | **product spine** | -| 2 Classifier | `realm-server` | publish / K14 prep | parallel OK | -| **3 Serve door** | `realm-server` | **address space** | **spine — prereq for 1b** | -| 3′ Vendor corpus | outside + content | K3, catalog K2 | optional early | -| **4a–4d, 5 Tape** | realm write, HTTP, host, claims, CLI | **K1, K4** | **hinge — sliced** | -| 8 Base realm | `runtime-common`, `host` | K11 | highest blast radius | -| 6 Cache, 9 activity | `realm-server`, `host` | K2, K12 (K9 later) | medium | -| 11 Mirror; 13 Matrix later | content + jobs | K6, then K7 | process | -| 10 Packs; 14 review trails | content + `realm-server` | K14, K13 | after tape | +| PR | Main Boxel touchpoints | Benefits | Shape | +| -------------------------- | ------------------------------------ | ------------------ | ------------------------- | +| — Deck gates | Deck repo work plan | — | **hard gate** | +| 0 Lean Core | `packages/deck` | — | dependency drop | +| 1 Resolver | `runtime-common` | K8 | small — first behavior | +| **1b Thin verbs** | host + helpers + fixture | **early K5** | **product spine** | +| 2 Classifier | `realm-server` | publish / K14 prep | parallel OK | +| **3 Serve door** | `realm-server` | **address space** | **spine — prereq for 1b** | +| 3′ Vendor corpus | outside + content | K3, catalog K2 | optional early | +| **4a–4d, 5 Tape** | realm write, HTTP, host, claims, CLI | **K1, K4** | **hinge — sliced** | +| 8 Base realm | `runtime-common`, `host` | K11 | highest blast radius | +| 6 Cache, 9 activity | `realm-server`, `host` | K2, K12 (K9 later) | medium | +| 11 Mirror; 13 Matrix later | content + jobs | K6, then K7 | process | +| 10 Packs; 14 review trails | content + `realm-server` | K14, K13 | after tape | Commit-level Touch / Do / Don’t: `deck-boxel-backport-changelist-mockup.md`. @@ -408,9 +533,8 @@ Commit-level Touch / Do / Don’t: `deck-boxel-backport-changelist-mockup.md`. ## One-sentence summary for stakeholders -We finish protocol decisions in Deck, drop a lean Core into Boxel, treat an -**app as a decklist** addressed by fully-qualified URLs until the RRI -migration hands us aliases, prove remix/install without copying **before** we -attach a Timeline of Steps (Versions on publish), then serve/cache correctly -and move registry authority into the catalog — with GitHub share/gather as -interim collaboration and Matrix later, never as the source of truth. +We finish protocol decisions in Deck, stack Lean Core → resolver → versioned +serve → thin decklist verbs onto current Boxel main, then add branch refs and +cloned indexes before attaching a per-branch deckd History, exact Review, and +one-batch merge—after which caching, publishing, and catalog authority can +build on real collaboration state rather than UI simulation. diff --git a/docs/deck-boxel-backport-plan.md b/docs/deck-boxel-backport-plan.md index 90ee0cf..799d3e2 100644 --- a/docs/deck-boxel-backport-plan.md +++ b/docs/deck-boxel-backport-plan.md @@ -452,12 +452,14 @@ Delivers K9, half of K2, catalog-side K6. ### R3 — History into the realm server *(as proposed, and this is the hinge)* -jj/historyd in `packages/realm-server`, versioned resolver, restore from -tag/checkpoint, `boxel-cli` on Deck-style revisions. +deckd/jj History per branch in `packages/realm-server`, versioned RRI +resolver, restore from Step, and `boxel-cli` on Deck Checkpoints. Desktop uses +the local filesystem; hosted Boxel uses realm-contained S3 Files plus direct +conditional S3 refs as specified in `deck-storage-backends.md`. This is the first genuine monorepo PR and the one to rebase the `deck/*` branches onto. It delivers K1 and K4 together — and it should, because K4's -3-way merge needs the merge base that K1's Versions provide. Doing them +3-way merge needs the exact Checkpoint base that K1's History provides. Doing them separately means building the sync engine twice. Then, and only then, **R3.5: base realm via Deck** (K11), with restore @@ -700,7 +702,7 @@ right for every release after it. 1. **Version granularity on write (K1).** One Version per `Realm.write`, per debounced burst, or per explicit checkpoint? Deck's watcher debounces; - a realm write is already transactional. Storage growth and Timeline + a realm write is already transactional. Storage growth and History readability pull in opposite directions. 2. **Does `boxel-cli` keep git?** `checkpoint-manager.ts` is git-backed. K4 can either replace git with Deck Versions or keep git locally and diff --git a/docs/deck-boxel-branch-index-backport.md b/docs/deck-boxel-branch-index-backport.md new file mode 100644 index 0000000..67fb520 --- /dev/null +++ b/docs/deck-boxel-branch-index-backport.md @@ -0,0 +1,589 @@ +# Multi-branch realms — Deck backport design + +**Status: RULING** (2026-08-21). This is the Boxel backport shape implied by +Deck branches. It refines the History backport plan; it does not introduce a +second repository protocol. + +Companions: [`deck-collaboration-protocol.md`](deck-collaboration-protocol.md), +[`deck-boxel-backport-for-team.md`](deck-boxel-backport-for-team.md), and +[`deck-boxel-backport-changelist-mockup.md`](deck-boxel-backport-changelist-mockup.md). +Local filesystem and hosted AWS persistence are binding in +[`deck-storage-backends.md`](deck-storage-backends.md). +Host-mode publication and browser delivery are specialized in +[`deck-boxel-host-publish-treehash.md`](deck-boxel-host-publish-treehash.md). + +--- + +## 0. Ruling + +> A Boxel realm is one repository identity with one default branch and zero or +> more additional branches. Each branch has its own mutable source state, +> History, and index. Creating a branch or fork clones the selected source +> state **and its index**. Merging applies one reviewed change set to the target +> branch through the target realm's normal write path and records one History +> Step. + +This keeps the familiar model: + +- **branch**: another line of work inside the same realm, permissions, and + ownership boundary; +- **fork**: the same operation across a new realm identity and ownership + boundary; +- **Review**: exact source and target Checkpoints plus their indexed previews; +- **merge**: one atomic target mutation, one target reindex transaction, and + one target History Step. + +The index clone is an implementation operation, not a new protocol object. A +branch is still a mutable ref to source history. Boxel happens to maintain a +derived index beside each branch so Browse, Run, search, and previews remain +real. + +```text +Realm acme/app + │ + ├── main ───────── source state M3 ───────── index M3 ───────── History + │ ▲ ▲ + │ │ one merge batch │ one index transaction + │ └──────────────┬──────────┘ + │ │ + └── improve-nav ─ source state B7 ─ index B7 ─ History + │ │ + └──── Review ─────┘ + +create branch: clone source state M3 + clone index M3; then diverge +merge review: apply B7 − base to main once; append one Step to main +``` + +### 0.1 Execution decisions + +These choices close the readiness questions. They are implementation inputs, +not another protocol layer. + +| ID | Decision | +| ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Q1** | Cut a stack, not one feature-set PR: **R0** Deck RRI/Checkpoint/realm-relative path gate; **A0** current Lean Core vendor from that exact commit; **A1** resolver/scopes; **A2** versioned serve door; **A3** thin decklist verbs. Then cut **B0–B7** below. Each PR must be independently reviewable and green. | +| **Q2** | Start every Boxel PR from fetched `origin/main`. Re-vendor Deck Core at the chosen current Deck commit. Transplant the proven Map tests and small adapter changes from `deck/at-rest-poc`; do not replay the fork's commits, realm-global jj History, Atlas rewrite, or CRM rewrite. | +| **Q3** | Use the reserved Deck transport namespace: default `/`, branch `/__deck/branch//`, Checkpoint `/__deck/checkpoint//`. `` is unpadded base64url of the NFC UTF-8 branch name. The UI always shows the decoded familiar name. | +| **Q4** | “Clone the index” means publish an independently owned completed generation before the branch appears. Same-realm branches may initially point at the same immutable generation manifest; their first write creates a new generation. Cross-authority forks create a rebased generation. SQL/SQLite rows are rebuildable projections, not the durable generation identity. | +| **Q5** | Every branch contains its complete authored resolution inputs: conventional package `package.json` files plus any applicable root and nested `importmap.json` files. A Checkpoint hashes their canonical composed exact lock. V1 merges authored files normally, then compares the three composed locks with structural conflict detection; it does not silently re-lock. | +| **Q6** | Boxel v1 uses Deck's `HistoryBackend` against **deckd**, one supervised daemon per realm-server host. Every branch has a stable opaque source directory and independent jj History at `/.deck/history/repo`; its thin `.jj/repo` pointer is branch-local. `.deck/timeline/` is legacy input only and migrates one way. No `.jj` repository or stub is shared between branches or placed at the host-global root. | +| **Q7** | Import Deck Repository/Checkpoint/ref/Review types and `mergeTrees`; implement Boxel's mutable source, index, cache, job, and event substrate separately. Hub's checkout-less ref swap is not Boxel's realm write path. | +| **Q8** | Pin index generations for current branch heads, open Reviews, and published Releases. Retain closed/merged Review generations for 30 days. Other exact indexes are derived cache: evictable and rebuilt from the durable source snapshot on demand. | +| **Q9** | Add a distinct merge capability. Existing realms remain directly writable on `main` for compatibility; branch protection is opt-in. A protected branch rejects direct mutation and accepts Review merge only from a realm owner or principal with merge capability. | +| **Q10** | Build a private candidate generation for Review/merge. It is the target's next generation, seeded from the exact target generation and updated with the proposed change set. After a target-head recheck, the normal target write transaction publishes that source snapshot and generation once. | +| **Q11** | Use RRI as the canonical identity from R0 onward. Mutable realms use `@owner/repository/`; exact Versions use `@owner/repository@version/`. Locks, indexes, Repository records, and UI author RRIs. Absolute URLs are derived browser/HTTP projections. Branch and Checkpoint remain `RealmViewContext`, not RRI syntax. | +| **Q12** | AWS is inside the backport and all Deck data is realm-contained under `/.deck/`. Branch workspaces, index staging, and deckd/jj History use S3 Files. Agents may read all `.deck` files, but POSIX writes to `store/`, `indexes/`, `refs/`, `reviews/`, and `releases/` are denied; only direct S3 conditional writes mutate them. Desktop uses the same contracts over a regular filesystem. One coordinated deckd writer owns each branch workspace. | + +The first executable work is **R0 in Deck**. Once its RRI-native V2 Core is +green and committed, the first Boxel branch is **A0 only**, cut from +`origin/main` and pinned to that Deck commit. A1–A3 stack on it. No branch / +index, History, Atlas, host publish, or collaboration UI belongs in A0–A3. + +## 1. Why this is below the UI + +Today Boxel's index identity is effectively `(realm URL, resource URL, type)`. +`realm_generations` also has one current generation per realm URL. That means +two branches of one logical realm RRI cannot simultaneously contain different +indexed answers for the same resource RRI. + +A branch selector alone would therefore be false. Branch support must first +make the live realm, index writer, query engine, cache keys, jobs, and events +branch-aware. + +The existing single-realm behavior remains the compatibility case: + +```text +realm URL with no branch selector = realm's default branch (normally main) +``` + +## 2. Identities + +Keep these identities separate: + +| Identity | Mutable? | Meaning | +| ---------------- | --------------- | ---------------------------------------------------------- | +| Realm RRI | Stable | logical repository/package namespace, e.g. `@acme/shop/` | +| Realm URL | Stable location | HTTP route and control-plane lookup; not authored identity | +| Branch name | Mutable ref | named line of work within a realm | +| Source snapshot | No | exact files plus exact RRI import-map/dependency lock | +| History Step | No | every-save jj event on one branch | +| Checkpoint | No | intentional collaboration point with parent Checkpoint(s) | +| Index generation | No | derived searchable/renderable view of one branch state | + +A Step and a Checkpoint are not synonyms. Normal UI and agent saves advance the +branch state and append Steps without requiring commit ceremony. A Review +captures exact Checkpoints so its comparison does not move underneath the +reviewer. + +`realm.json`, conventional package manifests, and authored import maps are +branch content. Branch heads, +Checkpoints, Reviews, completed index manifests, and Repository objects are +realm-owned files below `/.deck/`; they are excluded from authored tree +hashes. The stable Realm RRI authority mapping, Realm URL, ownership, ACLs, +default branch, and branch protection may also have authenticated database +projections, but those projections do not relocate the Deck objects outside +the realm. + +## 3. Branch state + +The minimum durable branch record is conceptually: + +```ts +interface RealmBranch { + id: string; + realmRRI: RealmIdentifier; + realmURL: string; + name: string; + repositoryHash: string; + historyHead: string; + indexGenerationHash: string; + latestCheckpointHash: string | null; + refGeneration: number; + updatedAt: string; +} +``` + +Moving this record uses compare-and-swap. A writer supplies the branch head it +read; Boxel rejects a stale move instead of silently overwriting concurrent +work. + +`id` is an opaque stable identifier. Physical source and History directories +key on it, not on the display name, so renaming a branch does not move storage. +The physical host may keep non-default state at +`/.deck/branches//`, but the selected branch realm view always +projects its durable jj store as `/.deck/history/repo`; implementations must +not introduce a Boxel-specific product synonym. A pre-backport +`.deck/timeline/` directory is migrated only when `.deck/history/` is absent; +if both exist, startup refuses to choose. +`refGeneration` is the logical monotonic compare-and-swap generation. The local +adapter also verifies its filesystem token; the AWS adapter verifies the +observed S3 ETag. `historyHead` is deckd/jj state and +`latestCheckpointHash` is the most recent intentional collaboration point; +normal saves advance the former without inventing the latter. + +The Repository named by the branch record is the authoritative settled source +snapshot. The mutable workspace is its authoring materialization. For `main`, +that materialization remains the existing realm root. A non-default branch is +the only case that needs the new `.deck/branches//source/` location. It is +a clone of the authored realm tree, not a recursively nested realm and not a +copy of `.deck` or `.jj`. The index is +derived state, but its generation travels with the branch pointer so readers +never observe new files with an old index or an index for files that are not +yet visible. + +### 3.1 Physical placement: additive to `deck/at-rest-poc` + +The backport does not create `branches/main/` and does not move authored realm +content. It retains or deliberately evolves the POC paths as follows: + +| State | Placement | +| -------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | +| default-branch source | existing realm root, unchanged | +| package metadata and resolution inputs | existing conventional package/app paths, including root and nested maps | +| default-branch History | migrated from the POC's colocated root `.jj` repository to `.deck/history/repo`, with only a thin realm-local `.jj` workspace pointer | +| realm package/CAS store | moved from the POC's parallel realm-qualified `.package-store///` root to the owning realm's `.deck/store/` | +| completed indexes and collaboration objects | shared realm-owned namespaces under `.deck/indexes/`, `.deck/refs/`, `.deck/reviews/`, and `.deck/store/` | +| non-default branch source | new `.deck/branches//source/` | +| non-default branch History and private build state | new siblings under `.deck/branches//history/`, `transaction/`, and `index-staging/` | + +Thus the only new source-tree location is for a non-default branch. Everything +else either keeps the pushed realm shape or moves hidden implementation state +into the realm as required by the later realm-containment and History rulings. + +## 4. Create branch and fork + +Branch and fork use the same primitive with different destinations. + +```text +source realm + source branch/Checkpoint + │ + ├── clone source state + ├── clone completed index generation + └── initialize History ancestry + │ + ┌───────────┴───────────┐ + ▼ ▼ + branch in same realm fork in new realm + same ACL boundary new owner / ACL boundary +``` + +### 4.1 Clone contract + +The operation must: + +1. resolve an exact source snapshot and a **completed** index generation; +2. clone the complete authored realm tree from that snapshot, preserving its + existing paths and excluding `.deck`, `.jj`, `.git`, and other + implementation directories; +3. publish the destination index-generation reference (which may initially + name the same immutable generation for a same-realm branch); +4. ask deckd to initialize the destination's own `.deck/history/repo` from the + exact source `historyHead`, recording source Checkpoint provenance; +5. publish the destination branch pointer only after source, index, and + History initialization all succeed. + +The clone may use an immutable generation-manifest reference, copy-on-write, +database `INSERT … SELECT`, filesystem reflinks, or CAS references. Those are +backend choices. Semantically the destination owns an independent branch/index +line from the moment it is published. + +The local SQL/SQLite projection may use transactional `INSERT … SELECT` into a +fresh opaque generation ID. The AWS backend publishes an immutable generation +manifest; a same-realm branch may initially reference the source generation +and diverges on first write. Branch source bytes are materialized into the +destination's stable workspace. All conventional package boundaries and +root/nested import-map inputs survive unchanged. deckd initializes the +destination History via its History API; `.deck` and `.jj` machinery are never +copied as authored source. The destination never shares a mutable jj repository +with the source. + +If cloning the index fails, no branch is visible. Boxel must not publish an +empty branch and fill Browse/search later. + +### 4.2 Same-realm clone vs fork rebase + +A branch inside one realm preserves the logical Realm RRI namespace, so its +completed index generation can be cloned directly. + +A fork changes the Realm RRI authority and its transport URL. Boxel's current +index contains realm-qualified resource identities, dependencies, metadata, +and rendered output, so a fork cannot blindly byte-copy every row. It performs +a **rebased index clone**: + +1. copy the completed structured index generation; +2. change each realm-local row identity from the source Realm RRI prefix to + the destination Realm RRI prefix; +3. rebase only references that resolve inside the forked realm; external exact + dependencies remain unchanged; +4. regenerate prerendered/embedded output whose derived transport URLs cannot + be safely rebased; +5. verify that the finished destination index contains no accidental + source-realm identity before publishing the fork ref. + +This is still a clone, not an empty full crawl: the source completed index is +the seed and only namespace-sensitive derived fields are repaired. The +indexer's existing dependency and rendering rules remain authoritative. + +### 4.3 Why clone instead of share + +Cloning makes branch reads and writes operationally independent: + +- no parent-chain query semantics in the first backport; +- no accidental invalidation across branches; +- existing index queries need only select the branch's generation; +- a fork can leave the source realm without retaining a live dependency on it. + +Physical deduplication can follow. It must not change the branch contract. + +## 5. Write and index contract + +Every write carries a `RealmViewContext`: + +```ts +interface RealmViewContext { + realmRRI: RealmIdentifier; + realmURL: string; + branch: string; + repositoryHash: string; + indexGenerationHash: string; +} +``` + +For a settled human or agent write batch: + +1. acquire the write lock for `(Realm RRI, branch)`, not the whole realm; +2. mutate that branch's source state through its workspace filesystem; +3. ask deckd to append one History Step for the settled batch; +4. create the Repository and completed branch index generation; +5. compare-and-swap the branch head to Repository, History, and index together; +6. emit branch-qualified invalidation and activity events. + +Unrelated branches can therefore write and index concurrently. Writes on the +same branch remain serialized. + +All derived state must include the canonical RRI plus view context: loader +caches, query cache keys, index jobs, prerender jobs, invalidation events, +activity, and published HTML readiness. A cache keyed only by Realm RRI or +realm URL is incorrect after this backport. + +Agents can inspect the selected branch head, Checkpoints, Reviews, index +manifest, and History through ordinary realm paths. Agent writes below the +protocol-owned `.deck/store`, `.deck/indexes`, `.deck/refs`, and +`.deck/reviews`, and `.deck/releases` paths must fail. The realm service performs those writes +through the local atomic-file adapter or direct S3 conditional adapter. + +## 6. Index storage + +The first implementation adds an explicit branch/index-generation dimension +instead of manufacturing a different realm URL for every branch. A completed +generation has a canonical manifest/hash that can be stored locally or in S3. +Postgres and the host's SQLite mirror project that generation into queryable +rows; they are not the durable collaboration ref. + +Conceptually: + +```text +realm_branches + (realm_rri, branch) -> realm_url, repository_hash, index_generation_hash, + history_head, latest_checkpoint_hash, ref_generation + +realm_index_generations + hash -> realm_rri, branch_id, repository_hash, manifest, status + +boxel_index + (index_generation, rri, type) -> indexed row + +realm_file_meta / prerendered_html / working tables + all qualified by index_generation +``` + +The current `realm_generations(realm_url)` becomes a projection of generation +state scoped to a branch head. Existing rows migrate their resource URLs to +canonical RRIs through the registered Realm RRI authority. Existing realms +receive a `main` branch and one completed generation; unqualified callers +continue to resolve `main`. + +Do not implement branches by appending the branch name to stored RRIs. The +logical card/module identity stays inside the Realm RRI namespace. Branch is a +view used to choose source and index state. + +## 7. Addressing and resolution + +Canonical authored and indexed identities are RRIs: + +```text +mutable realm @acme/shop/ +mutable resource @acme/shop/products/sku-1 +exact Version @catalog/three@0.169.0/ +exact resource @catalog/three@0.169.0/build/three.module.js +``` + +Branch and Checkpoint are deliberately absent from that grammar. A logical RRI +plus `RealmViewContext` selects the source snapshot and index generation. The +resolver then projects that pair to a browser-safe absolute URL. + +The host and server use a branch-qualified transport view because query +parameters do not reliably propagate through relative ESM imports. The wire +grammar is: + +```text +default view / +branch view /__deck/branch// +checkpoint view /__deck/checkpoint// +``` + +`__deck` is reserved and cannot be authored as a top-level realm path. Branch +tokens omit base64 padding and decode to at most 128 UTF-8 bytes. Branch-name +validation rejects empty names, NUL, invalid UTF-8, and names whose NFC form is +not unique within the realm. Names may contain familiar `/`, `.`, and `-` +characters because the wire token is a single opaque segment. + +The server strips the transport prefix before selecting logical source/index +rows and sets `RealmViewContext`. Stored card data, import maps, dependency +edges, Repository records, and UI labels continue to use canonical RRIs. +Native relative ESM imports inherit the projected view prefix; the loader +carries the same context for bare and cross-realm RRI resolution. Branch +responses are mutable and revalidate. Exact Checkpoint responses are immutable +and may be cached forever. + +## 8. Review and merge + +A Review binds exact source and target Checkpoints and records their common base. +Browse and Run select the matching index generation for each side. They are not +mock previews and do not reconstruct state in the browser. + +Merge is deliberately a **target mutation**, not a branch-pointer swap: + +```text +base Checkpoint ─┐ +source Checkpoint ├── 3-way merge ── candidate change set +target Checkpoint ┘ │ + ▼ + target Realm.writeBatch(changes) + │ + ├── mutate target source + ├── update target index + ├── append ONE merge Step + └── CAS target branch head +``` + +The write batch includes every added, changed, and deleted path plus the exact +import-map lock change. It uses the same validation, indexing, invalidation, +and authorization path as ordinary realm writes. + +Review preparation builds an invisible candidate index generation from the +exact target generation plus this change set. Merge acquires target writer +ownership, rechecks the target ref, writes a prepared transaction, applies the +target write batch, and asks deckd for exactly one Step. The final conditional +target-head move publishes the Repository, candidate generation, History head, +and merge Checkpoint together. It does not index the same change twice. + +A moved target or failed candidate validation discovered before mutation +aborts cleanly. A crash after target mutation is different: the prepared +transaction causes recovery to complete the same ref move idempotently or +restore the workspace to its previously settled deckd History head. Browse, +Run, and Review never use uncommitted workspace bytes as branch truth. + +The single merge Step records: + +- Review ID; +- source, target, and base Checkpoint hashes; +- merge Checkpoint hash (with source and target parents); +- actor; +- complete changed-path summary; +- before/after source snapshot and index generation. + +If the source branch contains 47 Steps, merging does **not** replay 47 Steps +onto `main`. Those Steps remain the source branch's daily history. `main` +receives one coherent Step: “merge this Review.” + +The target branch pointer moves only after source mutation, index mutation, +and History append are ready to settle. A pre-mutation conflict or failed index +candidate leaves the target unchanged; a post-mutation interruption enters +explicit recovery rather than pretending the filesystem write never occurred. + +## 9. Query behavior + +- normal realm search queries the default branch's index; +- a branch workspace queries that branch's cloned index; +- a Review queries exact source, target, and optional merge-candidate indexes; +- federated search queries each realm's default branch, not every branch; +- historical Checkpoint indexes may be retained or rebuilt, but a Review's exact + preview must remain available for the Review's retention period. + +The initial retention policy pins current branch heads, open Reviews, and published Releases. +Closing or merging a Review starts a 30-day index-generation retention window. +After that, Boxel may evict the derived generation and rebuild it from the +durable source snapshot when requested; the Checkpoint and source bytes are not +evicted by this policy. + +## 10. Permissions + +Realm ACL remains the ownership boundary. + +- create/write/delete branch: realm write permission; +- merge: a separate merge capability, held by realm owners and explicitly + grantable to maintainers; +- fork: read permission on the source plus create permission in the destination + realm/owner; +- Review source/target ancestry is server-derived, not trusted from the client. + +Existing realms migrate with direct writes to `main` still allowed. Branch +protection is opt-in so the backport does not break today's authoring path. +When a branch is protected, direct source mutation is rejected and a Review +merge requires the merge capability. + +This is why a branch and a fork feel similar without becoming the same noun. + +## 11. Architecture-forcing fixture + +Before host UX, add one integration fixture with a real realm and real indexes: + +```text +realm: @acme/shop/ + +main + products/sku-1.json price: 10 + theme.gts color: blue + +branch: summer-sale + products/sku-1.json price: 8 + theme.gts color: coral + promotion.json new +``` + +The fixture must prove: + +1. branch creation clones `main` source **and its completed index**; +2. the same logical RRI returns price 10 on `main` and price 8 on + `summer-sale`; +3. search, Browse, prerender, and Run select the correct branch index; +4. a later `main` write does not change the branch result; +5. a branch write does not invalidate `main` caches; +6. Review compares exact Checkpoints and previews the branch's real app; +7. merge mutates `main`, updates its index, and adds exactly one `main` Step; +8. the source branch keeps its own Steps and remains addressable after merge; +9. a stale concurrent merge loses the CAS and leaves `main` unchanged; +10. a fork repeats the clone into `@vale/shop/`, a new Realm RRI authority and + ACL boundary; +11. every authored import-map input and the canonical composed lock, + dependency edges, index rows, Repository records, and Review payloads + contain RRIs rather than projected URLs; +12. the fixture passes once on the local filesystem backend and once with the + workspace/History on S3 Files plus conditional S3 object/ref adapters. + +The fork assertion also verifies that realm-local RRIs in the destination +index use `@vale/shop/`, while external exact Version RRIs are unchanged. Its +browser projection uses destination transport URLs, but those URLs never +become authored or reviewed identity. + +No branch UI should land before this fixture is green. + +## 12. Backport order + +This changes the old History order. History cannot be realm-global first and +made branch-aware later without migrating its identity twice. + +| Slice | Work | Proof | +| ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------- | +| **B0** | Deck Core V2 Repository/Checkpoint/Review/branch-head types, RRI roots, and backend-neutral conformance fixture | Protocol objects round-trip without Boxel | +| **B1** | Realm-contained local filesystem plus AWS S3 Files/object/ref adapters; branch metadata, `main` compatibility, inspectable agent reads, writer ownership, and prepared recovery | Same state machine passes locally and on AWS | +| **B2** | RRI-keyed immutable index generations and view-qualified writer/query/cache/job/event keys | Two generations for one RRI coexist | +| **B3** | Atomic source + completed-index + deckd History initialization for branch and fork | Architecture fixture items 1–5, 10, and 12 | +| **B4** | Per-branch deckd/jj Steps and restore through the branch write path on local FS and S3 Files | Save/restore cannot cross branches | +| **B5** | Exact Checkpoint comparison and real Browse/Run preview | Fixture item 6 | +| **B6** | Three-way merge through one prepared target write batch, one target Step, and conditional head move | Fixture items 7–9 and crash recovery | +| **B7** | Quiet branch selector, History context, Review/merge UX | UI only exposes already-real state | + +Likely Boxel seams: + +- `packages/runtime-common/realm.ts`: view context, branch-scoped adapter and + write lock; +- `packages/runtime-common/index-writer.ts`: generation-qualified index batch + and atomic promotion; +- database migrations: branch-head projections plus index-generation keys on + index/meta/prerender tables; +- storage adapters: local atomic files, S3 Files workspaces, conditional S3 + objects/refs, and immutable index-generation manifests; +- realm server routes and jobs: branch/Checkpoint view resolution; +- host loader/query services: carry view context; +- host Realm UI: quiet branch switcher, per-branch History, Review. + +## 13. Rejected shortcuts + +- **One realm URL per branch.** Fast initially, but breaks realm identity, + ownership, links, ACLs, and “branch within an org/repo.” +- **One index with a UI branch filter.** Cannot represent two answers for the + same RRI and makes Run/preview false. +- **Share one mutable index between branches.** Branch writes invalidate each + other and destroy isolation. +- **Replay source Steps onto main.** Turns one reviewed merge into noisy + pseudo-history and weakens atomic rollback. +- **Swap main to the branch's index.** Skips the target write path, target-side + validation, current-target changes, and coherent every-save History. +- **Create branch source now, index later.** Exposes a branch that Browse and + search cannot truthfully represent. + +## 14. Settled implementation choices + +The former open choices are closed for the first backport: + +1. reserved `__deck/branch/` and `__deck/checkpoint/` transport + views; +2. canonical RRI index identity and immutable generation manifests, with + SQL/SQLite as query projections and same-realm generation sharing allowed; +3. pin active heads, open Reviews, and Releases; retain closed Review indexes + for 30 days; rebuild other exact indexes on demand; +4. compatibility direct writes by default, optional branch protection, and a + separate merge capability; +5. an invisible candidate generation that a prepared target write transaction + publishes after compare-and-swap validation; +6. deckd/jj at `.deck/history/repo` on both local filesystems and S3 Files; +7. direct conditional S3 writes for immutable protocol objects and mutable + branch refs, with no dual-interface writes to the same keys. + +The invariant across all choices is: **RRI is the identity; RealmViewContext +selects its branch or Checkpoint; `__deck` is only transport; a branch/fork +begins with a complete cloned index; branches diverge independently; merge +changes the target through one atomic write batch and advances its History +once.** diff --git a/docs/deck-boxel-host-publish-treehash.md b/docs/deck-boxel-host-publish-treehash.md new file mode 100644 index 0000000..91c36b7 --- /dev/null +++ b/docs/deck-boxel-host-publish-treehash.md @@ -0,0 +1,600 @@ +# Host publishing from treeHash and branch indexes + +**Status: PROPOSAL, RRI-NATIVE AMENDMENT** (2026-08-21). This specializes the +multi-branch Realm backport for Boxel's published host mode. + +Companions: +[`deck-boxel-branch-index-backport.md`](deck-boxel-branch-index-backport.md), +[`deck-storage-backends.md`](deck-storage-backends.md), +[`deck-laws.md`](deck-laws.md), and +[`deck-tree-hash-v2-review.md`](deck-tree-hash-v2-review.md). + +--- + +## 0. Ruling + +> Publishing should name an exact source `treeHash`, build a candidate +> published index from the closest completed index, and atomically move the +> published ref only when source, index, rendering, and asset closure are +> ready. Canonical release and asset identities are RRIs. The stable page URL +> is their transport projection and revalidates; every exact projected asset +> URL is immutable. + +There are two fast paths: + +- **first publish or new destination:** clone the source branch's completed + index into the published Realm RRI namespace; +- **republish:** use the previous published tree and index as the base, diff + stored tree manifests, and reindex only the changed dependency closure. + +The author may explicitly request a **full publish** from the host UI or CLI. +That rebuilds and verifies the complete index, rendering, delivery tree, and +asset manifest instead of trusting an incremental base. Incremental remains +the default. + +```text +source branch + Checkpoint ──▶ source treeHash ──▶ source completed index + │ │ + │ tree-manifest diff │ clone/reuse rows + ▼ ▼ +previous release ── base index ─────────▶ candidate published index + │ │ + └── changed closure ──────┘ + │ + render + asset manifest complete + │ + ▼ +published Realm RRI ref ─ atomic CAS ──▶ Release +``` + +Publishing is not another source copy and it is not a second authoring +history. It is a deployment pointer to an immutable release projection. + +## 1. Current Boxel path and why it is slow + +The current `handle-publish-realm.ts` path: + +1. waits for the source realm to settle; +2. recursively copies the source directory to a temporary directory; +3. swaps that directory into `_published`; +4. clears the destination module and source caches; +5. enqueues a full reindex with `clearLastModified: true`; +6. waits for the destination's full prerender to catch up. + +That deliberately recomputes every card and rendering even when one file +changed. The safety is good; the unit of reuse is too coarse. + +Boxel already contains most of the mechanism needed for the first fast path: + +- `RealmIndexUpdater.copy()` enqueues `copy-index`; +- `IndexWriter.Batch.copyFrom()` clones and rebases structured index rows; +- `copyPrerenderedHtmlFrom()` copies the rendering channel; +- index working tables already provide candidate-then-promote behavior; +- generation guards and readiness checks already prevent incomplete HTML from + being reported as ready. + +The publish handler does not currently use that path. It full-copies and +full-indexes instead, partly because raw prerendered HTML may embed the source +transport URL and must not leak into the published destination. + +## 2. treeHash is identity; the tree manifest supplies the diff + +`tree-hash-v1` is a flat hash over sorted `(file sha256, path)` entries. The +root proves exact identity, but the root alone cannot say which files changed. +The stored `_trees/.json` packlist supplies that inventory. + +Therefore a publish needs: + +```text +sourceTreeHash +sourceTreeManifest: path -> blob sha256 +lockHash +source Checkpoint +source completed index generation +``` + +A republish also has the previous release's tree manifest. Comparing the two +manifests costs a scan of hashes, not a read or copy of every file body: + +```text +addedPaths +changedPaths +deletedPaths +unchangedPaths +``` + +If `sourceTreeHash`, `lockHash`, render policy, and toolchain identities are +unchanged, publish is a no-op except for optionally recording who requested +it. Do not create a new deployment merely because the wall clock changed. + +## 3. Release object + +The stable published Realm RRI ref points to a small immutable Release object; +the public URL is a control-plane transport alias: + +```json +{ + "schema": "boxel-host-release-v1", + "sourceRealmRRI": "@chris/shop/", + "sourceCheckpointHash": "sha256:…", + "sourceTreeHash": "sha256:…", + "lockHash": "sha256:…", + "publishedRealmRRI": "@chris/shop/", + "publishedURL": "https://chris.boxel.site/shop/", + "indexGenerationHash": "sha256:…", + "deliveryTreeHash": "sha256:…", + "assetManifestHash": "sha256:…", + "hostBuildHash": "sha256:…", + "renderPolicyHash": "sha256:…" +} +``` + +After hashing, the Release is addressable as an exact Version RRI such as +`@chris/shop@release./`. That exact RRI and the source/published Realm +RRIs are canonical. `publishedURL` is routing metadata and is excluded from +portable dependency identity. + +The release hash excludes timestamps. Audit metadata can record publisher and +time beside it without changing delivery identity. + +The Release object and mutable published ref remain inside the source realm: + +```text +/.deck/releases/objects//.json +/.deck/refs/published.json +``` + +On AWS agents may inspect both through S3 Files. Only the realm service writes +them through direct S3 conditional operations. + +The published ref moves with compare-and-swap: + +```text +expected previous Release hash -> new Release hash +``` + +A concurrent publish that started from an old release cannot overwrite a newer +one. The old release stays live while the candidate is being built, so +republishing has no half-indexed interval. + +## 4. Source materialization + +Publishing should stop recursively copying Realm directories. The published +Realm reads an exact tree manifest through a read-only CAS adapter: + +```text +path -> tree manifest -> blob sha256 -> CAS bytes +``` + +Local storage may materialize those references with hardlinks/reflinks. S3 +deployment may read the manifest and blobs directly. Both have the same +semantics: unchanged blobs are not copied, and the exact source tree remains +available after the branch advances. + +The current publish-only mutation +`includePrerenderedDefaultRealmIndex: true` must not rewrite the copied +`realm.json` after hashing. Move it into `renderPolicyHash`, or make the author +set it before sealing. A published tree that is silently modified is not the +tree named by its `treeHash`. + +## 5. Candidate index construction + +### 5.1 New destination: clone and rebase + +Clone from the exact source branch index only when it proves: + +```text +indexed sourceTreeHash == requested sourceTreeHash +indexed lockHash == requested lockHash +indexer version == current indexer version +``` + +The existing copy path already rebases row URLs, aliases, types, dependencies, +search document IDs, Realm metadata, and error documents. Change canonical row +and dependency identity to RRI, rebase Realm-local RRIs, and derive destination +URLs only while rendering/serving. Extend the path to carry the exact source +tree/lock identities and to distinguish origin-neutral from origin-sensitive +derived fields. + +Structured index data can normally be rebased. Raw `head_html`, +`isolated_html`, `atom_html`, fitted HTML values, embedded HTML values, and +generated CSS may contain absolute source URLs. They may be cloned only when a +validator proves them origin-neutral; otherwise enqueue those rows for +destination rendering before promotion. + +The destination is not exposed until that repair set has rendered and the +index and HTML generations agree. + +### 5.2 Republish: previous release as merge base + +For an existing published Realm RRI, its current release is the best base +because its index is already in the correct RRI namespace. + +1. clone the current published generation into a candidate generation; +2. compare previous and next tree manifests; +3. seed invalidation with added, changed, and deleted paths; +4. add reverse dependents using the base index's dependency graph; +5. add entries affected by `lockHash`, Realm config, render policy, indexer, + compiler, or host-build changes; +6. update/tombstone only that closure; +7. render only the affected rendering closure; +8. promote the candidate generation with the new Release ref. + +This is merge-base indexing: unchanged indexed rows survive from the published +base; changed rows come from the new exact source and its dependency fan-out. +It is not a textual merge of published content. Published releases are +read-only, so the authoring branch is authoritative. + +Where the source branch already has a completed index for the exact next tree, +the candidate builder may copy and rebase changed structured rows from it +instead of recomputing them. Destination-sensitive rendering still follows +the rules above. + +### 5.3 Invalidations wider than the file diff + +The safe invalidation closure is: + +```text +changed paths + + deleted paths + + transitive reverse dependencies + + Realm/index-card configuration dependents + + lock entries whose resolved target changed + + every compiler output when compiler/toolchain identity changed + + every rendering when renderer/host-build/render-policy identity changed +``` + +If the dependency graph is missing, stale, or belongs to a different +tree/lock/indexer identity, fall back to a full candidate index. The fast path +must be an optimization with a provably safe escape hatch. + +## 6. Delivery projection + +Source `treeHash` identifies authored bytes. A served JavaScript module may be +transpiled, CSS may be extracted, and rendering depends on a host build. Those +bytes cannot use source `treeHash` alone as their immutable cache identity. + +Publish therefore builds a derived **delivery tree**: + +```text +(sourceTreeHash, lockHash, compilerHash, hostBuildHash, renderPolicyHash) + │ + ▼ + deliveryTreeHash +``` + +The delivery tree contains or addresses: + +- transpiled JS modules; +- generated/scoped CSS; +- static images, fonts, audio, video, and data files; +- exact canonical RRI import map/lock and its derived browser projection; +- publish-time generated browser artifacts. + +It records provenance back to the source tree but has its own `treeHash`, as +required whenever derived bytes differ from source bytes. + +## 7. Browser and CDN cache contract + +The stable URL and exact bytes need different cache classes. + +| Response | URL identity | Cache-Control | Validator | +| ----------------------- | --------------------------------- | ------------------------------------- | ------------------------------------- | +| Published page shell | stable published URL | `public, max-age=0, must-revalidate` | strong ETag from Release + shell hash | +| Release manifest | exact Release hash | `public, max-age=31536000, immutable` | Release hash | +| Authored raw asset | exact source tree + path | `public, max-age=31536000, immutable` | blob sha256 | +| Derived JS/CSS/asset | exact delivery tree + path | `public, max-age=31536000, immutable` | derived blob sha256 | +| Exact card JSON/render | Release or exact index derivation | `public, max-age=31536000, immutable` | content hash | +| Mutable branch/live URL | branch name | `private, max-age=0, must-revalidate` | branch generation ETag | + +All immutable responses should also carry `Content-Digest` and stable content +type. Private exact previews may use `private, max-age=31536000, immutable`; +they must never be stored by a shared cache. + +Relative module imports must stay underneath the exact delivery-tree prefix. +Bare imports resolve through the exact RRI lock, then project to immutable tree +URLs. If an exact module resolves to a mutable RRI or URL, the release is not +closed and publish must fail. + +The host application's own JS/CSS must also use content-fingerprinted paths. +The shell may point at a new host build, but a browser that already has the +old fingerprinted asset never downloads it again. + +## 8. Making “all assets cached” observable + +`immutable` means “reuse after fetch”; it does not guarantee that a browser +has fetched every asset. Publish should also emit an immutable asset manifest: + +```json +{ + "schema": "boxel-asset-manifest-v1", + "release": "sha256:…", + "deliveryTreeHash": "sha256:…", + "assets": [ + { "url": "/_tree/…/app.js", "sha256": "…", "size": 1234 }, + { "url": "/_tree/…/theme.css", "sha256": "…", "size": 456 } + ] +} +``` + +The list is the complete exact runtime closure: host entry assets, delivery +tree assets, import-map targets, generated CSS, and indexed card assets. + +The host service worker maintains a Cache Storage namespace keyed by +`(Release hash, hostBuildHash)`: + +1. fetch the asset manifest; +2. download missing hashes concurrently with a bounded worker pool; +3. verify response digest/length; +4. mark the release `offlineReady` only after every entry succeeds; +5. atomically activate that cache for new navigations; +6. retain the previous release cache while an open client still uses it; +7. garbage-collect unreferenced release caches later. + +This gives two useful states rather than one vague “published” state: + +```text +ready source + index + required first-render assets are live +offlineReady complete asset manifest is verified in this browser +``` + +Storage quota or an interrupted background fill must not corrupt a release. +The app continues through immutable HTTP caching, but must not claim +`offlineReady` until the manifest is complete. + +For very large media, the manifest may mark an entry `cache: on-demand`; the +product must then say “core app cached” rather than “all assets cached.” The +protocol should not promise offline completeness that the browser quota cannot +provide. + +## 9. Atomic publish sequence + +```text +1. capture exact source Checkpoint, treeHash, lockHash, completed source index +2. CAS-store missing tree manifest and blobs +3. read expected current published Release +4. build candidate source view from CAS +5. clone source index (new) or clone published base index (republish) +6. apply safe invalidation closure; repair origin-sensitive renderings +7. build delivery tree + complete asset manifest +8. verify source/index/render/delivery identities and publishability +9. atomically CAS published ref + candidate index generation +10. append one Publish event/Step; return Release hash and readiness URLs +``` + +A failure before step 9 leaves the previous published Release untouched. A +retry with the same identities reuses the candidate artifacts. + +Publishing should add one source-branch History event referring to the exact +Release. It must not create a Step for every internal index or asset operation. + +## 10. Incremental and full publish modes + +Both modes are first-class and use the same publish endpoint, Release object, +validation, readiness gates, and atomic pointer move. + +| Mode | Reuses | Rebuilds | Intended use | +| ------------------------- | ---------------------------------------------------------------- | ------------------------------------------------------------------- | ---------------------------------------------------- | +| **Incremental** (default) | previous compatible index/render/delivery base and all CAS blobs | changed dependency/render closure | normal publishing | +| **Full** | content-addressed source blobs only | complete index, all renderings, delivery projection, asset manifest | repair, verification, derivation upgrades, debugging | + +Full publish does **not** mean copying unchanged source bytes again. CAS blobs +are already verified by hash. It means refusing derived-state reuse and +re-deriving everything from the exact source tree and lock. + +The server—not the browser or CLI—derives the tree diff, dependency closure, +and base compatibility. The request shape is intentionally small: + +```json +{ + "sourceRealmURL": "https://app.boxel.ai/chris/shop/", + "sourceBranch": "main", + "publishedRealmURL": "https://chris.boxel.site/shop/", + "strategy": "incremental", + "expectedRelease": "sha256:…" +} +``` + +`strategy` is `incremental` or `full`. `expectedRelease` protects the +published target from a concurrent update. A client does not submit a trusted +changed-path list or index generation. + +### 10.1 Safe incremental fallback + +Incremental publish automatically becomes a full publish when the server +cannot prove its base is compatible—for example: + +- no prior completed release/index exists; +- source tree or lock identity is missing; +- the dependency graph is incomplete; +- index schema/indexer compatibility changed; +- a candidate-resume artifact fails verification. + +The response and progress stream report +`requestedStrategy: incremental`, `effectiveStrategy: full`, and a stable +machine-readable reason. Safety must not depend on the user knowing when to +choose Full. + +### 10.2 Deterministic convergence + +Given the same source tree, lock, host build, compiler, and render policy, a +full and incremental publish must produce the same Release and delivery-tree +hashes. Add a conformance assertion for this. + +A full publish of an already-current deterministic Release may rebuild and +verify server artifacts, then leave the published pointer unchanged. It still +records a Publish operation result for audit, including whether repaired +artifacts differed. + +Full publish is not cache busting. A browser must continue using an immutable +asset when its URL/hash is unchanged. If regenerated bytes differ, the +delivery tree hash and URL change naturally. Never add a timestamp or random +query parameter to force a redownload. + +### 10.3 Host UI + +Keep the common action quiet: + +```text +┌─────────────────────────────────────────────────────┐ +│ Publish [▾] │ +├─────────────────────────────────────────────────────┤ +│ Publish changes │ +│ Reuses the current release; rebuilds affected work. │ +│ │ +│ Full publish… │ +│ Rebuilds and verifies the complete published app. │ +└─────────────────────────────────────────────────────┘ +``` + +`Publish changes` is the primary action and should normally be labeled simply +**Publish**. `Full publish…` opens a confirmation showing expected work and +explaining that immutable, byte-identical browser assets remain cached. + +Before starting, show a server-produced plan: + +```text +Publishing main → chris.boxel.site/shop + +Mode incremental +Base Release 8fd2… +Source Checkpoint 4ca1… · tree 01be… +Changed 3 files +Index/render 17 affected entries +Browser assets 2 new · 143 reused +``` + +For Full, replace the affected count with the complete count and state +“source blobs remain content-addressed; this rebuilds derived output.” If an +incremental request falls back, progress visibly changes to **Full publish** +and gives the reason rather than silently doing more work. + +### 10.4 CLI + +```sh +# default: incremental, with safe automatic fallback +boxel publish --branch main --to https://chris.boxel.site/shop/ + +# explicitly rebuild all derived publish state +boxel publish --branch main --to https://chris.boxel.site/shop/ --full + +# inspect the server-derived strategy and work set without publishing +boxel publish --branch main --to https://chris.boxel.site/shop/ --plan +``` + +Do not call the option `--force`: full publish does not bypass authorization, +publishability, validation, readiness, or compare-and-swap protection. + +Machine-readable automation uses: + +```sh +boxel publish --branch main --to "$TARGET" --full --json +``` + +The result includes requested/effective strategy, base and resulting Release +hashes, tree/lock/delivery hashes, counts for reused/rebuilt artifacts, +fallback reason, readiness, and `offlineReady` manifest identity. + +## 11. Architecture-forcing fixture + +Use the existing publish fixture, but make the second publish non-trivial: + +```text +source main tree A + app.gts -> imports theme.gts and logo.png + theme.gts -> blue + logo.png -> hash L1 + untouched-large-video.mp4 -> hash V1 + +source main tree B + theme.gts -> coral + new-font.woff2 -> hash F1 + logo.png unchanged + untouched-large-video.mp4 unchanged +``` + +Prove: + +1. first publish clones/rebases the source index and repairs destination HTML; +2. published rows contain the published Realm RRI, and derived HTML contains + its projected transport URL, never the source identity or source URL; +3. republish diffs tree manifests without reading/copying unchanged blobs; +4. only `theme.gts`, its reverse dependents, and the new font enter the index/ + render work set; +5. the video and logo retain identical immutable URLs and are browser cache + hits; +6. the stable HTML ETag changes with the Release; +7. every exact asset returns one-year `immutable`, digest, and correct MIME; +8. an exact module's complete import closure contains no mutable URL; +9. the old release keeps serving until candidate index and required renderings + are complete; +10. a stale concurrent publish loses CAS without exposing its candidate; +11. the service worker reports `offlineReady` only after verifying the entire + cacheable asset manifest; +12. same tree/lock/toolchain publish is a no-op. +13. explicit full publish rebuilds every derived entry but reuses CAS source + blobs; +14. full and incremental modes converge on the same Release and delivery hash; +15. incompatible incremental base reports a visible fallback to full; +16. full publish does not change immutable asset URLs when their bytes are + identical. + +## 12. Backport slices + +| Slice | Change | Main proof | +| ------ | --------------------------------------------------------------------------------- | --------------------------------------------------- | +| **P0** | RRI-native Release/tree/lock/index identity types in Deck Core | deterministic Release hash | +| **P1** | Seal Boxel branch snapshot into CAS without changing serving | current publish output hashes identically | +| **P2** | Read-only treeHash Realm adapter | exact snapshot runs without directory copy | +| **P3** | Harden partial `copy-index` and origin-sensitive render repair | first-publish fixture 1–2 | +| **P4** | Candidate index cloned from previous published generation + manifest diff fan-out | fixture 3–4, 9–10 | +| **P5** | Delivery tree and exact immutable serving | fixture 5–8, 12 | +| **P6** | Asset manifest and service-worker cache state | fixture 11 | +| **P7** | Replace directory-copy/full-reindex publish handler | observable publish latency and bytes-read reduction | +| **P8** | Host Publish menu + plan/confirmation and CLI `--full`/`--plan` | fixture 13–16 through both clients | + +Do not begin with the service worker. First make the server's release and +index identities exact; then make every delivered URL immutable; finally add +proactive browser caching. + +## 13. Expected performance shape + +Current repeat publish is approximately proportional to the whole Realm: + +```text +copy all bytes + index all files + render all affected cards pessimistically +``` + +The target repeat publish is: + +```text +scan two small tree manifests ++ read changed blobs ++ index changed paths and reverse-dependent closure ++ render origin/toolchain-affected closure ++ write one Release pointer +``` + +The protocol does not promise a constant-time publish: one base module change +can legitimately invalidate the whole Realm. It does guarantee that unchanged +content is not copied, indexed, rendered, or downloaded merely because the +Realm was published again. + +## 14. Non-negotiable invariants + +- `treeHash` identifies authored bytes; derived bytes get a derived treeHash. +- A published Release never refers to a branch/live URL. +- The published pointer never moves before its candidate index and required + rendering are ready. +- Index reuse is allowed only when source tree, lock, and derivation identities + prove compatibility. +- New-destination index cloning rebases Realm-local RRIs; URL rewriting is a + derived rendering/transport concern. +- Repeat publish reuses the published base index and invalidates the safe + dependency closure. +- Incremental and full publish converge on the same deterministic Release. +- Full publish rebuilds derived state; it never bypasses safety or fabricates a + cache-busting identity. +- Stable HTML revalidates; exact assets are immutable. +- “All assets cached” is a verified browser state, not a Cache-Control claim. diff --git a/docs/deck-boxel-monorepo-change-register.md b/docs/deck-boxel-monorepo-change-register.md index d34f8c5..8536b4d 100644 --- a/docs/deck-boxel-monorepo-change-register.md +++ b/docs/deck-boxel-monorepo-change-register.md @@ -4,7 +4,7 @@ order lives in [`deck-boxel-backport-for-team.md`](deck-boxel-backport-for-team.md) (**How we execute**) and [`deck-boxel-backport-changelist-mockup.md`](deck-boxel-backport-changelist-mockup.md) -(product spine **1 → 1b → 4c**; Timeline sliced). Deck-side gates: +(product spine **1 → 1b → 4c**; History sliced). Deck-side gates: [`deck-work-plan-for-backport.md`](deck-work-plan-for-backport.md). Companion for *why*: [`deck-boxel-backport-plan.md`](deck-boxel-backport-plan.md). @@ -115,9 +115,9 @@ the existing suite is the gate. **Migration:** a table for Version pointers and Steps. Additive. **Open policy, not code:** one Version per write, per debounced burst, or per -explicit checkpoint. Decides storage growth and Timeline readability. +explicit checkpoint. Decides storage growth and History readability. -**Deck Core supplies:** tree hash, store, pack, the Timeline adapter. +**Deck Core supplies:** tree hash, store, pack, the History adapter. ### P3 — serve grammar and cache classes @@ -208,7 +208,7 @@ run outside, on the hosted deck (Release 1b). | `boxel-cli/src/lib/checkpoint-manager.ts` | replace git (`rev-parse` `:257`, `show --stat` `:414`, `checkout -- .` `:454`) with Deck Versions | | `boxel-cli/src/commands/realm/sync.ts` | three-way merge; the `--prefer-local` / `--prefer-remote` / `--prefer-newest` strategies at `:588-590` become the *fallback* for genuine conflicts, not the only mechanism | | `boxel-cli/src/commands/realm/milestone.ts` | a milestone is a tag | -| `boxel-cli/src/commands/realm/history.ts` | reads the realm's Timeline instead of git log | +| `boxel-cli/src/commands/realm/history.ts` | reads the realm's History instead of git log | | `boxel-cli/src/lib/find-checkpoint.ts` | resolve by Version or tag | **Deck Core supplies:** `merge` (pure, already in the browser-safe set). @@ -244,7 +244,7 @@ belongs in the deck repo. Normative design: [`deck-github-mirror-analysis.md`](deck-github-mirror-analysis.md) — **one GitHub monorepo per depot**, Versions remain in `.deck/store`, -Timeline optional on `history`, catalog mirrors **read-only**, GitHub PRs +Every-save History optional on `history`, catalog mirrors **read-only**, GitHub PRs only via **intake** (`Base-Version` + `mergeTrees` + publish; no AI). Human anytime path keeps legacy **`share` / `gather`** verbs (§5b) but not copy-overwrite. Implement export → share → gather before bot intake. diff --git a/docs/deck-cas-packages-vs-monorepos.md b/docs/deck-cas-packages-vs-monorepos.md index 83ca00e..79a6413 100644 --- a/docs/deck-cas-packages-vs-monorepos.md +++ b/docs/deck-cas-packages-vs-monorepos.md @@ -40,10 +40,10 @@ one convenient boundary: the repository. Deck splits them without losing the useful experience: - a **package tree** says what one package's bytes are; -- a **Revision** says how one line of work reached those bytes; +- a **Checkpoint** says how one line of work reached those bytes; - a **Version** says what compatibility promise those bytes carry; - a **depot lock** says which exact package trees execute together; -- a **Repository Revision** says which package changes are atomic; +- a **Repository Checkpoint** says which package changes are atomic; - a **pull request** says which work should enter which target; - an **HTTP URL** serves the exact ESM file the browser executes. @@ -229,7 +229,7 @@ code graph: ```text decision record │ -issue ─────▶ pull request ─────▶ Revision ─────▶ treeHash +issue ─────▶ pull request ─────▶ Checkpoint ─────▶ treeHash │ │ │ │ │ └─ package files │ └─ checks / reviews / authorship @@ -273,8 +273,8 @@ ordinary files. Splitting a monorepo into conventional repositories reintroduces version-bump and submodule-pointer choreography. That is not the CAS model. -During development, packages compose by exact tree or Revision identity. -Changing a producer and three consumers creates one proposed Repository Revision. +During development, packages compose by exact tree or Checkpoint identity. +Changing a producer and three consumers creates one proposed Repository Checkpoint. No semver bump is required merely to make the repository coherent. Semver is assigned later, when somebody makes a compatibility promise to external consumers. @@ -285,7 +285,7 @@ DEVELOPMENT RELEASE tree A2 + tree B4 editor 3.0.0 ──▶ tree B4 │ dashboard 8.2.0 ──▶ tree A2 ▼ -Repository Revision 91ce… +Repository Checkpoint 91ce… one atomic repository change ``` @@ -388,17 +388,17 @@ app head ──▶ old tree visible for 40 ms app head ──▶ new tree visible later ``` -Deck needs an immutable **Repository** object, named by a Revision, that +Deck needs an immutable **Repository** object, named by a Checkpoint, that records the complete member package set produced by one atomic change: ```json { - "schema": "deck-repository-v1", - "roots": ["app/dashboard"], + "schema": "deck-repository-v2", + "roots": ["@acme/dashboard/"], "members": { - "app/dashboard": "tree-app-new", - "acme/editor": "tree-editor-new", - "acme/theme": "tree-theme-unchanged" + "@acme/dashboard/": "tree-app-new", + "@acme/editor/": "tree-editor-new", + "@acme/theme/": "tree-theme-unchanged" }, "lockHash": "sha256-of-import-map" } @@ -410,10 +410,10 @@ The branch is one CAS-updated pointer: immutable package trees written first │ ▼ -immutable Repository + Revision written second +immutable Repository + Checkpoint written second │ ▼ -acme/dashboard:main ── atomic CAS ──▶ new Revision +acme/dashboard:main ── atomic CAS ──▶ new Checkpoint ``` Readers see the complete old Repository or the complete new Repository, @@ -475,7 +475,7 @@ intent Add transaction API content editor B → B2; dashboard A → A2 resolution exact import-map change execution [ Run proposed Repository ] -verification checks bound to source + target Revisions +verification checks bound to source + target Checkpoints merge one depot-head pointer move ``` @@ -515,7 +515,7 @@ In Deck: ```text treeHash 46bd… - ├─ branch Revision points here + ├─ branch Checkpoint points here ├─ Version 2.4.1 points here ├─ pull-request preview points here └─ exact HTTP module URL serves files from here @@ -572,7 +572,7 @@ bundle. | ----------------------- | ---------------------------------------------------- | -------------------------------------------- | | Primary identity | repository commit | Repository hash + member treeHashes | | Package boundary | path/tool convention | immutable addressed object | -| Cross-package atomicity | one commit | one Repository Revision pointer | +| Cross-package atomicity | one commit | one Repository Checkpoint pointer | | Branch isolation | whole-repository worktree | Repository fork; unchanged objects shared | | Unchanged content | repeated in checkouts and artifacts; cached by tools | shared by identity | | Dependency selection | package-manager rules + lockfile | depot import map | @@ -584,8 +584,8 @@ bundle. | Global refactor | native directory-wide tooling | operate on materialized Repository | | Search/index | repository-wide by default | catalog/index over addressed packages | | Offline work | complete clone/worktree | explicit materialized working set required | -| Coordination | Git ref transaction + forge | CAS refs + durable Revision/request metadata | -| Garbage collection | Git reachability | Versions + refs + requests + Revisions | +| Coordination | Git ref transaction + forge | CAS refs + durable Checkpoint/request metadata | +| Garbage collection | Git reachability | Versions + refs + requests + Checkpoints | --- @@ -609,7 +609,7 @@ authoritative Repository acme/theme/ │ edit with ordinary tools ▼ -changed package trees + new Repository Revision +changed package trees + new Repository Checkpoint ``` On a laptop this can be ordinary local files. On AWS it can be an S3 Files @@ -627,14 +627,17 @@ CAS is not a magic word. Moving the boundary creates real engineering work. ### 12.1 Durable coordination -Immutable objects are easy. Mutable heads, pull requests, approvals, and -atomic depot heads require authenticated compare-and-swap and durable event -history. S3 Files alone does not supply this. +Immutable objects are easy. Mutable heads, Reviews, approvals, and atomic realm +heads require authenticated compare-and-swap and durable event history. S3 +Files supplies the inspectable POSIX view, but not conditional coordination. +All objects remain below the owning realm's `.deck/`; agents may read them +there, while the realm service writes protocol-owned subtrees through direct +conditional S3 operations. ### 12.2 Repository-scale atomicity Per-package refs are insufficient for product changes spanning several -packages. The Repository Revision and its atomic ref update are required before +packages. The Repository Checkpoint and its atomic ref update are required before Deck can claim full monorepo parity. ### 12.3 Materialization and offline work @@ -648,7 +651,7 @@ model. Removing the physical monorepo must not remove global understanding. Indexes must search a Repository or catalog, and refactoring tools must be able to materialize and update a multi-package closure as one proposed depot -Revision. +Checkpoint. ### 12.5 Small-object economics @@ -658,8 +661,8 @@ preload closure computation remain necessary. ### 12.6 Garbage collection -Reachability now crosses Versions, Revisions, refs, pull requests, depot -Revisions, signatures, and retention policy. Deleting by “no Version points +Reachability now crosses Versions, Checkpoints, refs, pull requests, depot +Checkpoints, signatures, and retention policy. Deleting by “no Version points here” would destroy branch-only work. ### 12.7 Ecosystem compatibility @@ -693,12 +696,12 @@ A credible transition does not begin by splitting every repository. ### Phase 3 — make branches Repository forks -- Create durable Revisions and CAS refs. +- Create durable Checkpoints and CAS refs. - Present same-owner forks as branches and cross-owner forks as forks. -- Open pull requests against exact source and target Revisions. +- Open pull requests against exact source and target Checkpoints. - Materialize only the packages a change needs. -### Phase 4 — make the Repository Revision authoritative +### Phase 4 — make the Repository Checkpoint authoritative - Land cross-package changes with one atomic depot-head update. - Generate a monorepo-shaped directory for legacy tools on demand. diff --git a/docs/deck-collaboration-protocol.md b/docs/deck-collaboration-protocol.md index 8c1fc63..964e485 100644 --- a/docs/deck-collaboration-protocol.md +++ b/docs/deck-collaboration-protocol.md @@ -2,10 +2,10 @@ There is no separate kind of thing called a “Deck Repository.” A mutable Deck package is already a repository: it can be forked, branched, reviewed, and -merged. The Revision, ref, and review objects in this document are the durable +merged. The Checkpoint, ref, and review objects in this document are the durable collaboration layer for packages. -`repository.json` handles only the additional case where several packages +`.deck/repository.json` handles only the additional case where several packages must advance atomically. That is Deck’s content-addressed equivalent of a traditional monorepo—not an opt-in required for ordinary package work. @@ -16,24 +16,31 @@ identity, records its base tree, and initializes that mutable package's preserving different ownership boundaries. When the source is itself mutable, a fork also records the exact source -Revision. Opening a cross-owner Review uses that Revision as the three-way +Checkpoint. Opening a cross-owner Review uses that Checkpoint as the three-way merge base. The Review retains the fork endpoint as authored metadata, but normalizes its one package tree into the upstream package member key before -preview and merge. Imported packages have no mutable upstream Revision, so a +preview and merge. Imported packages have no mutable upstream Checkpoint, so a fork of an import begins an independent repository rather than advertising a fake upstream Review. The Hub derives this base from the fork's stored lineage; -it does not trust a browser-supplied Revision hash. - -**Status: PROTOCOL V1 — local reference implementation plus conditional object -store adapters.** This document defines the durable objects beneath Deck's -familiar repository, branch, fork, pull request, and merge UX. The protocol -deliberately introduces no new product noun. A repository containing several -member packages is simply a monorepo. +it does not trust a browser-supplied Checkpoint hash. + +**Status: LEGACY V1 IMPLEMENTED; RRI/CHECKPOINT V2 REQUIRED BEFORE BOXEL A0.** +The local reference implementation currently writes V1 Repository member names +as `owner/package` and the legacy wire noun `deck-revision-v1`. Before Boxel A0, +Deck gate R0 adds V2: durable member/root identities are RRIs and intentional +collaboration points use `deck-checkpoint-v2`. V1 remains readable and +hash-stable; new backport objects write V2. This document defines +the durable objects beneath Deck's familiar repository, branch, fork, Review, +and merge UX. The protocol deliberately introduces no new product +noun. A repository containing several member packages is simply a monorepo. Companions: [`deck-laws.md`](deck-laws.md), [`deck-package-json-and-depot-lock.md`](deck-package-json-and-depot-lock.md), [`deck-cas-packages-vs-monorepos.md`](deck-cas-packages-vs-monorepos.md), and -[`deck-fork-merge-pr-ux.md`](deck-fork-merge-pr-ux.md). +[`deck-fork-merge-pr-ux.md`](deck-fork-merge-pr-ux.md). The Boxel mapping—one +realm with multiple branch-specific source states, indexes, and Histories—is +specified in +[`deck-boxel-branch-index-backport.md`](deck-boxel-branch-index-backport.md). --- @@ -42,28 +49,24 @@ Companions: [`deck-laws.md`](deck-laws.md), > **A Repository is the atomic collaboration boundary. A package remains the > independently addressable execution and release boundary.** -A Repository Revision can therefore change one package or many packages. It +A Repository Checkpoint can therefore change one package or many packages. It does not combine their source into one giant package and it does not make their dependency identities disappear. ```text -Branch - │ mutable compare-and-swap ref - ▼ -Revision - │ immutable authored history event - ▼ -Repository - ├── roots - ├── member package name → treeHash - └── lockHash - │ - ▼ - exact import map +Branch ── mutable compare-and-swap head ──▶ Repository + │ │ + ├── historyHead ──▶ deckd/jj Step ├── root RRIs + └── latestCheckpoint ──▶ Checkpoint ────────┤ + ├── member package RRI → treeHash + └── lockHash + │ + ▼ + exact import map ``` -The UI says Repository, Revision, Branch, Fork, Pull request, and Merge. -`deck-repository-v1` is only a wire-format name. +The UI says Repository, Checkpoint, Branch, Fork, Review, and Merge. +`deck-repository-v2` is only a wire-format name. Runnable walkthrough: [`deck-repository-tour.md`](deck-repository-tour.md). @@ -82,7 +85,8 @@ The Repository layer is above Deck's existing package laws: - `treeHash` still identifies one package tree. - a Version still points to a package `treeHash`. - `package.json` still declares package identity and dependency ranges. -- the depot's `importmap.json` is still the exact execution lock. +- the canonical composition of applicable import maps is still the exact + execution lock. - blobs and package trees remain content addressed and shared. The new layer answers a different question: **which exact package trees and @@ -90,59 +94,78 @@ lock were reviewed and landed together?** ## 2. Exact lock object -The lock object is the parsed JSON value of the depot's exact -`importmap.json`, encoded with Deck canonical JSON and addressed by SHA-256. -It includes `imports`, `scopes`, `integrity`, and any Deck-owned lock policy. +The lock object is the canonical composed resolution result for the authored +realm tree, encoded with Deck canonical JSON and addressed by SHA-256. Its +inputs may include a root `importmap.json`, nested package/app import maps, and +their declared inheritance. It includes the effective `imports`, `scopes`, +`integrity`, and Deck-owned lock policy. Exact Version RRIs are values; +importer RRIs are scope keys. `package.json` remains conventional package +metadata and dependency suggestion; it is not itself the exact lock. ```text -lockHash = sha256(canonical-json(importmap.json)) +lockHash = sha256(canonical-json(compose(authored import maps))) ``` -This is not another lock format. The browser import map remains the executable -artifact. Storing it by hash makes the exact resolution closed over by a -Repository and reconstructable on another machine. +This is not another dependency decision or a parallel lock. For native ESM, +Deck deterministically projects the same lock into a standard browser import +map whose targets and scopes are absolute transport URLs. The RRI lock is the +portable reviewed authority; the URL map is a derived delivery artifact. +Storing the canonical lock by hash makes exact resolution closed over by a +Repository and reconstructable on another machine without baking one host's +origin into authored history. ## 3. Repository membership -A hosted forge may keep repository membership as ordinary authenticated -metadata. Local Deck uses a small authoring manifest so it can reconstruct the -same boundary without a service: +A hosted forge may project repository membership into authenticated query +metadata. The canonical authoring manifest remains inside the owning realm at +`.deck/repository.json`, so an agent can inspect and move the realm without a +parallel service tree: ```json { - "schema": "deck-repository-manifest-v1", - "roots": ["app/dashboard"], - "members": ["acme/editor", "acme/theme", "app/dashboard"] + "schema": "deck-repository-manifest-v2", + "roots": ["@acme/dashboard/"], + "members": { + "@acme/editor/": "acme/editor", + "@acme/theme/": "acme/theme", + "@acme/dashboard/": "app/dashboard" + } } ``` -The manifest contains intent, not resolved identity. Sealing resolves each -member to a `treeHash` and the import map to a `lockHash`. +The manifest maps canonical member RRIs to local authoring paths. Those paths +are placement intent, not durable identity. Sealing resolves each member to a +`treeHash` and the effective composed resolution state to a `lockHash`. -## 4. `deck-repository-v1` +## 4. `deck-repository-v2` ```json { - "schema": "deck-repository-v1", - "roots": ["app/dashboard"], + "schema": "deck-repository-v2", + "roots": ["@acme/dashboard/"], "members": { - "acme/editor": "", - "acme/theme": "", - "app/dashboard": "" + "@acme/editor/": "", + "@acme/theme/": "", + "@acme/dashboard/": "" }, "lockHash": "" } ``` -- `roots` names runnable or publishable entry packages. Every root must be a +- `roots` contains runnable or publishable package RRIs. Every root must be a member. -- `members` maps independently addressable package names to exact package +- `members` maps independently addressable package RRI roots to exact package trees. Membership means “may change and land atomically,” not “is imported at runtime.” - `lockHash` names the exact runtime closure. -Names and roots are sorted in canonical form. A Repository must have at least -one member. Hashes are lowercase SHA-256 hex. +Member RRIs and roots are sorted in canonical form. A Repository must have at +least one member. Hashes are lowercase SHA-256 hex. + +V1 objects keep their original `owner/package` member keys and hashes. A V1 +reader may expose a derived scoped RRI, but must not rewrite or rehash the +stored object. V2 writers require scoped RRI roots and never emit the legacy +member form. ```text dependency edge = this package is needed to run @@ -152,13 +175,15 @@ member edge = this package may change and land with this repository External dependencies occur in the lock but not in `members`. A repository with one member is ordinary. A repository with several members is a monorepo. -## 5. `deck-revision-v1` +## 5. `deck-checkpoint-v2` ```json { - "schema": "deck-revision-v1", + "schema": "deck-checkpoint-v2", "repositoryHash": "", - "parents": [""], + "parents": [""], + "historyHead": "", + "indexGenerationHash": "", "author": { "id": "@chris:example.test", "name": "Chris" @@ -168,54 +193,74 @@ with one member is ordinary. A repository with several members is a monorepo. } ``` -A Revision is immutable and content addressed. Parent order is meaningful: +A Checkpoint is immutable and content addressed. Parent order is meaningful: the first parent is the target line; the second parent of a merge is the source line. Two authored events may point at the same Repository hash. This separation preserves both useful identities: - Repository hash: “are the executable bytes and resolution identical?” -- Revision hash: “is this the same history event?” +- Checkpoint hash: “is this the same history event?” + +`historyHead` binds the intentional collaboration point to the exact +every-save History state that produced it. It is opaque to the collaboration +protocol. `indexGenerationHash` binds the exact Browse/Run projection reviewed +with that state. A normal save creates a deckd/jj Step and advances the branch +head; it does not write a Checkpoint. Opening a Review, explicitly naming a +milestone, and merging do write Checkpoints. + +The implemented legacy object remains readable as `deck-revision-v1` under +`_revisions/`, with its original field names and canonical hash. Readers expose +it as a legacy Checkpoint but never rewrite it in place. V2 writers use +`deck-checkpoint-v2`, `checkpointHash`, and `_checkpoints/` exclusively. ## 6. Branches and forks -A branch ref is mutable metadata: +A branch head is mutable metadata and is also the settlement point for source, +History, and the completed index: ```json { - "schema": "deck-ref-v1", - "revisionHash": "", - "generation": 7 + "schema": "deck-branch-head-v2", + "generation": 7, + "repositoryHash": "", + "historyHead": "", + "indexGenerationHash": "", + "latestCheckpointHash": "" } ``` Ref address: ```text -_refs///heads/.json +/.deck/refs/heads/.json ``` +The realm root supplies owner/repository identity. A branch ref must not float +in a bucket-global namespace. A cross-owner fork gets a new destination realm +and therefore a new realm-local `.deck/` namespace. + The update contract is compare-and-swap: ```text -update(ref, expectedRevision, nextRevision) +update(ref, expectedGeneration, nextHead) -current == expected → write next -current != expected → conflict; write nothing +current generation == expected → write next head +current generation != expected → conflict; write nothing ``` Same owner and repository is presented as a branch. A different owner is presented as a fork. Both use isolated refs and identical ancestry rules. For a single-package cross-owner Review, source and target package identities -differ by design. Deck records an immutable normalized source Revision whose +differ by design. Deck records an immutable normalized source Checkpoint whose member key is the target package and whose tree is the fork's current tree. -The Pull Request still names the real source endpoint. Existing preview, +The Review still names the real source endpoint. Existing preview, three-way merge, and target-ref compare-and-swap then operate unchanged. ## 7. Merge -A merge produces package trees first, then one Repository, then one Revision: +A merge produces package trees first, then one Repository, then one Checkpoint: ```text base Repository ─┐ @@ -225,7 +270,7 @@ source Repository ┘ │ new Repository hash │ ▼ - Revision(parents=[target, source]) + Checkpoint(parents=[target, source]) │ ▼ CAS update target branch ref @@ -242,31 +287,38 @@ The package merge algorithm remains `mergeTrees`. Repository merge adds: 3. lock merge or deterministic re-lock; 4. one final branch compare-and-swap. -Conflicts are qualified by member package, for example -`acme/editor/index.js`. A conflicted merge writes no Repository, Revision, -branch ref, or pull-request state. Immutable package objects produced while +Conflicts are qualified by member RRI, for example +`@acme/editor/index.js`. A conflicted merge writes no Repository, Checkpoint, +branch ref, or Review state. Immutable package objects produced while planning may already exist and are harmless because no ref reaches them. ## 8. At-rest layout -The first local implementation extends the existing depot store: +Each realm owns its protocol data. A host may physically aggregate realms in +one bucket, but no Deck resource floats in a parallel protocol tree: ```text -.deck/store/ -├── _objects/ file blobs (existing) -├── _trees/ package trees (existing) -├── _locks//.json exact import maps -├── _repositories//.json Repository objects -├── _revisions//.json Revision objects -├── _refs///heads/*.json mutable branch refs -├── _pull_requests//.json immutable request openings -├── _pull_request_events//*.json append-only request events -├── _pulls///.json mutable numbered request refs -└── _pulls///_counter.json mutable number allocator +/.deck/ +├── store/ +│ ├── _objects/ file blobs +│ ├── _trees/ package trees +│ ├── _locks//.json exact import maps +│ ├── _repositories//.json Repository objects +│ ├── _checkpoints//.json Checkpoint objects +│ └── _revisions//.json legacy V1; read-only +├── refs/heads/.json mutable branch heads +├── reviews/ +│ ├── objects//.json immutable Review openings +│ ├── events//.json append-only Review events +│ ├── numbers/.json mutable numbered Review refs +│ └── counter.json mutable number allocator +├── indexes///… completed index generations +├── releases/objects//.json immutable published Releases +└── history/repo/ default-branch deckd/jj History ``` -Locks, repositories, and revisions are immutable write-once objects. Only refs -need coordination. +Locks, repositories, and Checkpoints are immutable write-once objects. Mutable +branch heads, Review number refs/counters, and event heads need coordination. ## 9. Local and S3 Files persistence @@ -274,7 +326,7 @@ need coordination. - Content-addressed writes use temporary file plus rename. - A branch update holds a per-ref exclusive directory lock, verifies the - expected Revision, and atomically renames the new ref. + expected generation, and atomically renames the new ref. - A failed expectation writes nothing. The local daemon should be the normal ref writer. A leftover lock after an @@ -283,60 +335,72 @@ second writer silently steals. ### Deck with S3 Files -S3 Files is appropriate for live/materialized package directories and local -tool compatibility. It is not the authority for mutable collaboration refs. +S3 Files is the hosted POSIX substrate for live package directories, index +staging, and deckd's jj repository at `.deck/history/repo`. It is not the +authority for mutable collaboration refs. The complete local/AWS split is +specified in [`deck-storage-backends.md`](deck-storage-backends.md). -- Immutable blobs, trees, locks, repositories, and revisions may be uploaded - through S3 or materialized through S3 Files; their hashes detect damage. -- Branch and pull-request refs are read and written through the S3 object API. +- Immutable blobs, trees, locks, repositories, Checkpoints, Reviews, and + completed index generations are written through direct S3 with + `If-None-Match: *`. S3 Files exposes them read-only for agent inspection. +- Branch and Review refs are read and written through the S3 object API. - Immutable request openings, events, and numbered refs are created with `If-None-Match: *`. -- Branch refs, the repository-local Pull Request counter, and Pull Request +- Branch refs, the realm-local Review counter, and Review event heads move with `If-Match: `. - A precondition failure is a concurrency conflict. Number and event writers reread and retry; a branch move reports the conflict to the merge caller. +- Agents may read these realm-local objects as files through S3 Files. Their + POSIX principal cannot write `store/`, `indexes/`, `refs/`, or `reviews/`; + only the realm service's direct-S3 credentials mutate those subtrees. +- Coordination reads use the direct object API. S3 Files reads are an + inspectable projection and may lag bucket synchronization. -This is the small “origin” Deck needs: durable immutable objects plus -conditional ref mutation. It does not require a Git server, routing database, -or consensus service. +This is the small durable coordination layer Deck needs: realm-local immutable +objects plus conditional ref mutation. It is not a Git origin and does not +require a Git server or commit-shaped history service. -## 10. Pull requests +## 10. Reviews -A `deck-pull-request-v1` opening durably names: +A `deck-review-v2` opening durably names: -- source repository ref and captured source Revision; -- target repository ref and captured target Revision; +- source repository ref and captured source Checkpoint; +- target repository ref and captured target Checkpoint; - author, title, body, and creation time. It must not contain package bytes. Those are already named by Repository and tree hashes. The opening is immutable and content addressed. A numbered mutable ref at -`_pulls///.json` names the opening, the newest event, its +`.deck/reviews/numbers/.json` names the opening, the newest event, its generation, and the visible state (`open`, `merged`, or `closed`). Events form an immutable backward chain: ```text -Pull request #1 ref +Review #1 ref │ ▼ merged ──▶ merge-started ──▶ reviewed ──▶ source-updated ``` -`source-updated` captures the new exact source Revision. `reviewed` binds its -verdict to a Revision. `merge-started` records the prepared two-parent merge -Revision before the target branch moves. `merged` records completion. +`source-updated` captures the new exact source Checkpoint. `reviewed` binds its +verdict to a Checkpoint. `merge-started` records the prepared two-parent merge +Checkpoint before the target branch moves. `merged` records completion. The two-event merge sequence makes local recovery idempotent: -1. write the merged Repository and merge Revision; -2. append `merge-started(mergeRevisionHash)`; +1. write the merged Repository and merge Checkpoint; +2. append `merge-started(mergeCheckpointHash)`; 3. compare-and-swap the target branch; -4. append `merged(mergeRevisionHash)`. +4. append `merged(mergeCheckpointHash)`. If the process stops after step 2 or 3, the next merge call reads `merge-started`, completes the missing ref update, and appends `merged`. It -never creates a second merge Revision. +never creates a second merge Checkpoint. + +The implemented `deck-pull-request-v1` opening/events remain readable with +their original bytes and paths. R0/B0 migrate writers and UI to +`deck-review-v2`; compatibility readers project legacy objects as Reviews. Preview runs the same Repository merge engine as Merge, using the captured target as base, the current target as “ours,” and the captured or explicitly @@ -345,23 +409,27 @@ name the same Repository hash. ## 11. Implementation status -| Layer | Status | -| ---------------------------------------------------- | ----------------------------------------------- | -| Canonical Repository, Revision, branch ref | Implemented and tested | -| Local compare-and-swap refs | Implemented and tested | -| Conditional object-store branch adapter for S3 | Implemented and tested | -| Multi-package Repository merge | Implemented and tested | -| Durable Pull Request opening, events, preview, merge | Implemented and tested locally | -| Crash recovery across branch + Pull Request refs | Implemented | -| Depot-owned `repository.json` discovery | Implemented | -| Agent CLI: init, branch, commit, review, merge | Implemented | -| Generic Hub Browse and Review projection | Implemented | -| Multi-package validation depot and guided tour | Implemented | -| Hosted authentication and authorization | Not implemented | -| Signed checks and required-review policy | Event shape exists; enforcement not implemented | -| S3 Pull Request numbering, creation, and event refs | Implemented and tested | -| Production AWS client, credentials, and Hub wiring | Not implemented | -| Production Hub repository UI | Specified, not implemented | +| Layer | Status | +| -------------------------------------------------------- | ----------------------------------------------- | +| Legacy V1 Repository, Revision, and branch ref | Implemented and tested | +| RRI-native Repository, Checkpoint, and branch-head V2 | Required by R0; not yet implemented | +| Local compare-and-swap refs | Implemented and tested | +| Conditional object-store branch adapter for S3 | Implemented and tested | +| Multi-package Repository merge | Implemented and tested | +| Legacy Pull Request V1 opening/events/preview/merge | Implemented and tested locally | +| Review V2 opening/events/preview/merge | Required by R0/B0; not yet implemented | +| Crash recovery across branch + Review refs | Implemented in V1; must port to V2 | +| Legacy depot-root `repository.json` discovery | Implemented | +| Realm-owned `.deck/repository.json` discovery | Required by R0; not yet implemented | +| Agent CLI: init, branch, commit, review, merge | Implemented | +| Generic Hub Browse and Review projection | Implemented | +| Multi-package validation depot and guided tour | Implemented | +| Hosted authentication and authorization | Not implemented | +| Signed checks and required-review policy | Event shape exists; enforcement not implemented | +| S3 legacy Pull Request numbering/events | Implemented and tested | +| Realm-local S3 Review numbering/events | Required by B1; not yet implemented | +| Production AWS object adapters and S3 Files/deckd wiring | Required by backport; not yet implemented | +| Production Hub repository UI | Specified, not implemented | The validation depot is the architectural test. UX must not invent state the protocol cannot reproduce. diff --git a/docs/deck-competitive-gaps.md b/docs/deck-competitive-gaps.md index 9c1bb7e..f482ec0 100644 --- a/docs/deck-competitive-gaps.md +++ b/docs/deck-competitive-gaps.md @@ -326,7 +326,7 @@ and attestation**, in three parts. **Authorship (G4) — cheap, high payoff.** Every seal records the Matrix ID that caused it. jj already has an author field; the historyd backend declares no actor and should. Blame becomes real, and in a swarm it -answers "which agent did this" without a convention. (When a Timeline is attached, +answers "which agent did this" without a convention. (When every-save History is attached, also record the jj change id as optional seal metadata — stable across amends, never identity; same move Radicle is considering for patches.) diff --git a/docs/deck-core-backport-split.md b/docs/deck-core-backport-split.md index 33ecb18..f980a76 100644 --- a/docs/deck-core-backport-split.md +++ b/docs/deck-core-backport-split.md @@ -2,14 +2,14 @@ **Status: PROPOSAL** (2026-08-09). Companion to [`deck-depot-and-realm-backport.md`](deck-depot-and-realm-backport.md), -[`deck-laws.md`](deck-laws.md), [`deck-at-rest-protocol.md`](deck-at-rest-protocol.md). +[`deck-laws.md`](deck-laws.md), [`deck-at-rest.md`](deck-at-rest.md). --- ## 0. Why Boxel needs the **Deck contract** — laws, serve grammar, packs/store, -Versions, Timeline (jj), restore — not the Hub UI, teaching demos, or +Versions, History (jj), restore — not the Hub UI, teaching demos, or CLI sugar built to exercise this repo. Today that contract is mixed with operator chrome in one workspace. @@ -55,7 +55,7 @@ and speak the HTTP grammar. Roughly today’s: ``` packages/core/src/ # identity, pack, store, lock, merge, … packages/server/src/ # serve live / @exact / tag / history / activity -packages/history/src/ # Timeline via jj or historyd; restore +packages/history/src/ # History via jj or historyd; restore packages/conformance/ # L1–L12 black box ``` @@ -66,7 +66,7 @@ packages/conformance/ # L1–L12 black box - Import-map injection / resolution helpers needed to *serve* hermetically - Watcher → auto-publish `-dev.N` (realm may replace the watcher; the publish/store APIs stay) -- Timeline: seal list, file-at-step, restore (append-only) +- History: seal list, file-at-step, restore (append-only) - Activity read path (`GET /_activity`) + claim file layout (writers may stay CLI/Tools) - `deck conform` suite @@ -139,7 +139,7 @@ a contract, not a package archaeology dig. 3. **Conformance runs against Core alone.** Hub is not required to say “this implements Deck.” 4. **jj is a Core peer:** `@deck/history` documents `jj` (or historyd) - as a runtime peer; absence means Versions-only (Timeline off), which + as a runtime peer; absence means Versions-only (History off), which is already the product rule. --- @@ -151,7 +151,7 @@ Boxel realm server └── Deck Core ├── store / pack / tree-hash (Versions) ├── serve grammar (live / @exact / tag / ?meta / digests) - ├── Timeline adapter (jj or Boxel’s history sidecar) + ├── History adapter (jj or Boxel’s history sidecar) └── conform suite (CI gate on the realm) ``` @@ -226,6 +226,6 @@ Disk for Core sources today: on the order of **~0.5 MB** (not counting ## 10. Summary Split the monorepo so **Deck Core** is the only thing Boxel backports — -serve grammar, store/packs, Timeline/jj, conformance — and **Deck Tools** +serve grammar, store/packs, History/jj, conformance — and **Deck Tools** holds Hub, CLI, and examples. Same protocol; clearer boundary; ~12k LOC (or ~8–10k lean) instead of “the whole Deck repo.” diff --git a/docs/deck-daemon.md b/docs/deck-daemon.md index 463e172..01b8cab 100644 --- a/docs/deck-daemon.md +++ b/docs/deck-daemon.md @@ -1,8 +1,8 @@ -# deckd — Timeline + build daemon +# deckd — History + build daemon **Status: RULING + OPEN QUESTIONS** (2026-08-09). -Deck-owned long-lived process that attaches a **Timeline** to a depot and +Deck-owned long-lived process that attaches a **History** to a depot and (optionally) runs **derived build work** (esbuild, AMD, and friends) so the realm server / `deck serve` HTTP surface stays thin and read-only. @@ -18,7 +18,7 @@ jj-lib source used for spikes / dependency work: `lib/`). That tree is **not** where AMD or esbuild live. **Implementation:** [`packages/deckd/`](../packages/deckd/) — historyd-compatible -HTTP, Timeline under `.deck/timeline`, jj-lib in-process with a watchexec-shaped +HTTP, History under `.deck/history`, jj-lib in-process with a watchexec-shaped FS seal plane (`jj_cli` feature optional). --- @@ -27,15 +27,15 @@ FS seal plane (`jj_cli` feature optional). Today: -| Concern | Where it lives | -| --- | --- | -| Versions / claims | `/.deck/store`, `.deck/claims` | -| Timeline Steps | top-level `/.jj/` via jj CLI, or external **historyd** | -| Boxel edit history | often a separate `.boxel-history` git story | -| AMD / expensive transform | Boxel realm-server lifecycle (request-adjacent) | -| Minify derived releases | Tools/`derive`, **terser** (A2) | +| Concern | Where it lives | +| ------------------------- | ------------------------------------------------------------- | +| Versions / claims | `/.deck/store`, `.deck/claims` | +| History Steps | top-level `/.jj/` via jj CLI, or external **historyd** | +| Boxel edit history | often a separate `.boxel-history` git story | +| AMD / expensive transform | Boxel realm-server lifecycle (request-adjacent) | +| Minify derived releases | Tools/`derive`, **terser** (A2) | -Problems: two (or three) hidden directories; Timeline backend unsupervised; +Problems: two (or three) hidden directories; History backend unsupervised; compile cost on the wrong process; serve path tempted to grow Embroider-shaped compat forever. @@ -59,7 +59,7 @@ machinery directory, build pain at settle/seal time — not on GET. deckd may: -1. **Timeline** — ensure repo, seal Step, list, file-at, restore-plan +1. **History** — ensure repo, seal Step, list, file-at, restore-plan (today’s historyd surface, gaps closed — see §3). 2. **Build plane** — run named, versioned **derived** transforms (esbuild graph work, AMD, etc.), cache outputs under `.deck/build/`. @@ -77,36 +77,61 @@ deckd must **not**: Target layout: ```text -/ - //… live content (hashed + served) +/ + /… existing default-branch content (hashed + served) + package.json conventional package metadata where present + importmap.json optional root resolution input + apps//… existing app-local trees and maps + packages///… existing package-local trees and maps .deck/ + repository.json optional multi-package manifest store/ Versions (unchanged role) + refs/ branch/published heads + reviews/ Review objects and events + indexes/ completed index generations claims/ WIP claims policy.json optional - timeline/ - repo/ jj op store + internal git ← durable Timeline + history/ + repo/ jj op store + internal git ← durable History + branches// + source/ non-default branch materialization + history/repo/ branch-local jj History + index-staging/ derived private build state build/ reserved (derived plane later; unused for now) .jj/ jj workspace stub ONLY (not a symlink) - repo file: "../.deck/timeline/repo" + repo file: "../.deck/history/repo" working_copy/ jj WC state (jj requires this next to .jj) ``` -- **No** `.jj` → `.deck/timeline` symlink. -- **No** empty `.deck/timeline/.jj` nested-repo marker. -- Durable Timeline bytes live only under `.deck/timeline/repo/`. +The default branch stays at the realm root, matching Boxel's existing +`deck/at-rest-poc` layout. Only non-default branches require the new +`.deck/branches//` locations. Their `source/` directory clones the +authored realm tree without `.deck`, `.jj`, or `.git`; it preserves ordinary +package boundaries and all applicable import-map inputs. + +- **No** `.jj` → `.deck/history` symlink. +- **No** empty `.deck/history/.jj` nested-repo marker. +- Durable History bytes live only under `.deck/history/repo/`. - Top-level `.jj/` remains a thin stub because stock jj discovers the workspace by that basename; jj-lib can later root the stub under `.deck` - and delete it. The stub’s `repo` *file* is jj’s supported out-of-tree + and delete it. The stub’s `repo` _file_ is jj’s supported out-of-tree pointer (same idea as git’s `gitdir:`), not a Deck hack. -- No `.boxel-history` once Timeline is attached. -- No colocated `.git` in the depot (no-gitlink law). +- No `.boxel-history` once History is attached. +- No colocated `.git` in the realm (no-gitlink law). + +All Deck resources remain inside the owning realm. A selected non-default +branch view projects its physical History as `/.deck/history/repo`, so deckd, +agents, and UI keep one stable path even though the host uses an opaque branch +directory. On AWS, this realm tree is on S3 Files. Agents can inspect History; +only deckd's POSIX principal writes the jj repository. `DEFAULT_IGNORE` / `snapshot.auto-track` exclude `.deck` so the store is never sealed into itself; `file-list-at` / restore also filter machinery paths. **Migration:** `ensure` converts symlink or full-`.jj` layouts to the thin -stub. Prefer a **fresh depot** for deckd experiments — do not share Timeline -state with older demos. +stub. It also atomically renames a lone legacy `.deck/timeline/` directory to +`.deck/history/` and rewrites the pointer. If both directories exist, it +refuses to choose and preserves both for manual repair. ### R4 — Identity vs derived (AMD / esbuild) @@ -134,21 +159,21 @@ at most it waits on deckd or returns a clear not-ready. - HTTP Deck / realm **surface**: read-only for what a later GET returns. - deckd is a **sidecar** that may write under `.deck/` (and the live tree only - via restore replay rules already defined for Timeline). + via restore replay rules already defined for History). - Transforms are RPC to deckd (`/ensure-build`), not `PUT` handlers on the serve origin. ### R7 — Campaign fit Not inside lean Core drop, resolver, or thin decklist verbs (PR 1 / 1b). -Treat as **Track D — Build**, after or parallel to Timeline slices: +Treat as **Track D — Build**, after or parallel to History slices: -| Slice | Outcome | -| --- | --- | -| **D0** | Layout only: Timeline under `.deck/timeline`; kill top-level `.jj` / `.boxel-history` | -| **D1** | Own supervised deckd Timeline (historyd contract + gaps) | -| **D2** | Build plane later; until then identity JS only (main Hub pipeline) | -| **D3** | Editable realms default to deckd; Versions-only remains for RO catalog mirrors | +| Slice | Outcome | +| ------ | ----------------------------------------------------------------------------------- | +| **D0** | Layout only: History under `.deck/history`; kill top-level `.jj` / `.boxel-history` | +| **D1** | Own supervised deckd History (historyd contract + gaps) | +| **D2** | Build plane later; until then identity JS only (main Hub pipeline) | +| **D3** | Editable realms default to deckd; Versions-only remains for RO catalog mirrors | Boxel can keep shipping **jj CLI in-image** until D1; same `HistoryBackend` contract. @@ -157,36 +182,36 @@ contract. ## 2. RPC surface (v1) -Timeline (compat with today’s historyd client in +History (compat with today’s historyd client in `packages/history/src/historyd.ts`): -| Route | Body (sketch) | Result | -| --- | --- | --- | -| `POST /ensure` | `{ dir }` | `{}` — repo at `.deck/timeline` | -| `POST /seal` | `{ dir, message, actor? }` | `{ changeId }` | -| `POST /list` | `{ dir }` | `HistoryEntry[]` (incl. author when actor was set) | -| `POST /file-at` | `{ dir, revisionId, path }` | bytes or not found | -| `POST /file-list-at` | `{ dir, revisionId }` | paths — **close the CLI gap** | -| `POST /restore-plan` | `{ dir, revisionId }` | `{ writes, deletes }` | +| Route | Body (sketch) | Result | +| -------------------- | --------------------------- | -------------------------------------------------- | +| `POST /ensure` | `{ dir }` | `{}` — repo at `.deck/history` | +| `POST /seal` | `{ dir, message, actor? }` | `{ changeId }` | +| `POST /list` | `{ dir }` | `HistoryEntry[]` (incl. author when actor was set) | +| `POST /file-at` | `{ dir, revisionId, path }` | bytes or not found | +| `POST /file-list-at` | `{ dir, revisionId }` | paths — **close the CLI gap** | +| `POST /restore-plan` | `{ dir, revisionId }` | `{ writes, deletes }` | Build (deferred — **not** on the Hub serve path): -| Route | Body (sketch) | Result | -| --- | --- | --- | +| Route | Body (sketch) | Result | +| -------------------- | ------------------------------------------- | ------------------------------------------ | | `POST /ensure-build` | `{ dir, tip?, transforms[], toolchain_id }` | derived cache under `.deck/build/` (later) | -| `POST /artifact-at` | `{ dir, tip, path, … }` | derived bytes (later) | +| `POST /artifact-at` | `{ dir, tip, path, … }` | derived bytes (later) | **Near-term serve:** Hub / `deck serve` keep **main’s identity JS pipeline** (live ESM from the working tree / sealed packs). No esbuild, no -`?derived=amd` hook in Deck. deckd’s job for D0–D1 is Timeline under -`.deck/timeline` so Hub stops forking `jj` CLI. +`?derived=amd` hook in Deck. deckd’s job for D0–D1 is History under +`.deck/history` so Hub stops forking `jj` CLI. Default listen: `http://127.0.0.1:8787` (today’s `DECK_HISTORYD_URL`); `deck serve` stays on 8788. Env: `DECKD_URL` supersedes `DECK_HISTORYD_URL` when both set. With `DECKD_URL`, `createHistory` attaches **deckd** (not jj CLI). **Sealer cutover:** when `history.kind === 'deckd'`, Hub `watchAndPublish` -defaults `sealOnWatch: false` — Versions + HMR only. Timeline seals come from +defaults `sealOnWatch: false` — Versions + HMR only. History seals come from deckd’s FS watch plane (`notify` → debounce → jj-lib). jj / historyd keep Node-side note/flush. @@ -194,12 +219,12 @@ Node-side note/flush. ## 3. Gaps vs today’s historyd (must close in D1) -| Gap | Today | deckd | -| --- | --- | --- | -| `actor` on seal | dropped | persisted; `list` shows author | -| `fileListAt` | absent | `/file-list-at` | -| Supervision | unsupervised sibling | started/supervised by `deck serve` / realm-server | -| Repo path | implicit `.jj` at `dir` | **`.deck/timeline` only** | +| Gap | Today | deckd | +| --------------- | ----------------------- | ------------------------------------------------- | +| `actor` on seal | dropped | persisted; `list` shows author | +| `fileListAt` | absent | `/file-list-at` | +| Supervision | unsupervised sibling | started/supervised by `deck serve` / realm-server | +| Repo path | implicit `.jj` at `dir` | **`.deck/history` only** | --- @@ -260,10 +285,12 @@ appear in derivation meta when used. - No minify in deckd at all — leave `deck derive` in Tools forever (fine; D2 does not require absorbing A/B delivery workstreams). -### Q5 — One daemon for many depots, or one per realm? +### Q5 — One daemon for many realm branches, or one per realm? **Recommendation:** **one deckd per host**, many `{ dir }` arguments (historyd -model). Realm-server passes absolute depot paths. +model). Realm-server passes the selected branch workspace path. One +coordinated deckd writer owns each branch History at a time; hosted paths may +be S3 Files mounts. **Other options:** @@ -274,7 +301,7 @@ model). Realm-server passes absolute depot paths. ### Q6 — Seal source tip, projected tip, or both? -**Recommendation:** Timeline **Steps always capture the live author tree** +**Recommendation:** History **Steps always capture the live author tree** (what humans/agents edited). Build outputs live only under `.deck/build/` and are not Step content. A Version may point at source and optionally record `derivation` for a published derived tree. @@ -283,7 +310,7 @@ are not Step content. A Version may point at source and optionally record - Seal after projecting into the live tree — mutates what authors see; easy to confuse with identity; discouraged. -- Parallel Timeline for build artifacts — complexity with little product win. +- Parallel History for build artifacts — complexity with little product win. ### Q7 — Failure mode when build fails? @@ -297,14 +324,14 @@ failed” without losing restore. Never leave a half-written identity Version. for agent/debug restore. - Roll back live tree on build fail — hostile to authors. -### Q8 — Name of Timeline subdirectory? +### Q8 — Name of History subdirectory? -**Recommendation:** `.deck/timeline/` (product word matches UI: Timeline). +**Recommendation:** `.deck/history/` (product word matches UI: History). **Other options:** - `.deck/jj/` — honest about backend; leaks impl into paths. -- `.deck/history/` — clashes with “History: Versions vs Timeline” chrome. +- `.deck/history/` — clashes with “History: Versions vs History” chrome. --- @@ -312,31 +339,31 @@ failed” without losing restore. Never leave a half-written identity Version. ```text HistoryBackend (TS) - ├─ jj CLI → temporary Boxel/default until D1 - ├─ historyd client → today’s external daemon (D1 replaces) - └─ deckd client → Timeline + ensure-build (target) + ├─ jj CLI → legacy fallback only + ├─ historyd client → legacy external compatibility + └─ deckd client → official History + ensure-build backend ``` -Capability block grows: `{ jj, historyd, deckd, build? }`. Doctor copy: -`History: Timeline · via deckd` (not “via jj” once D1 is default). +Capability block is `{ jj, historyd, deckd, build? }`. Doctor copy: +`History: Every save · via deckd` (not “via jj” once D1 is default). --- ## 6. What stays out of deckd -| Stays elsewhere | Why | -| --- | --- | -| Hub UI / teaching demos | Tools | -| Offer rebase daemon | Tools; write policy separate | -| npm vendor corpus pipeline | Tools / ops | -| Negotiated pack prune | undesigned | -| Replacing import maps with a bundler identity | laws / at-rest | +| Stays elsewhere | Why | +| --------------------------------------------- | ---------------------------- | +| Hub UI / teaching demos | Tools | +| Offer rebase daemon | Tools; write policy separate | +| npm vendor corpus pipeline | Tools / ops | +| Negotiated pack prune | undesigned | +| Replacing import maps with a bundler identity | laws / at-rest | --- ## 7. Acceptance sketches -**D0:** Timeline store under `.deck/timeline/repo`; thin `.jj/repo` file +**D0:** History store under `.deck/history/repo`; thin `.jj/repo` file pointer (no symlink, no nested marker); sealed `file-list-at` has zero `.deck/**`; `tree-hash` unchanged; no-gitlink tests green; restore still replays forward. @@ -347,14 +374,14 @@ replays forward. **D2 (deferred):** derived build plane under `.deck/build/` when Boxel needs it; Hub continues identity ESM until then. -**D3:** new editable realm defaults to Timeline+deckd; RO catalog mount may +**D3:** new editable realm defaults to History+deckd; RO catalog mount may stay Versions-only. --- ## 8. One-sentence summary -**deckd** is Deck’s supervised sidecar: Timeline under **`.deck` only** +**deckd** is Deck’s supervised sidecar: History under **`.deck` only** (replacing Hub’s jj CLI forks and top-level `.jj`), so Boxel/Deck stop growing extra hidden git folders. Derived build plane is optional later; identity JS stays on the Hub. diff --git a/docs/deck-depot-and-realm-backport.md b/docs/deck-depot-and-realm-backport.md index 50853ed..85da952 100644 --- a/docs/deck-depot-and-realm-backport.md +++ b/docs/deck-depot-and-realm-backport.md @@ -4,11 +4,12 @@ That word is Boxel’s. This note freezes the standalone terms and how they map when Deck is backported onto a realm server. -Companions: `deck-at-rest.md` (still realm-shaped in places — -historical), `deck-laws.md`, `packages/server` URL grammar, +Companions: `deck-at-rest.md`, `deck-laws.md`, `packages/server` URL grammar, `deck-core-backport-split.md` (isolate backportable Core vs Tools), `deck-github-mirror-analysis.md` (depot↔GitHub monorepo mirror + intake), -`deck-in-motion.md` (§3.7 cross-depot offer/rebase + Boxel/Matrix plan). +`deck-in-motion.md` (§3.7 cross-depot offer/rebase + Boxel/Matrix plan), +`deck-boxel-branch-index-backport.md` (one realm, multiple branch indexes), +and `deck-storage-backends.md` (realm-contained Desktop/AWS persistence). --- @@ -19,11 +20,11 @@ module trees (and JSON). It must work with no Boxel, no cards, no realm server. Using “realm” in the reference implementation teaches the wrong boundary and makes backport sound like a rename instead of an embedding. -| Avoid in standalone Deck | Use instead | -| --- | --- | -| realm (mount / URL slot) | **depot** | -| RealmMount | DepotMount (or equivalent) | -| “the realm’s store” | **the depot’s store** | +| Avoid in standalone Deck | Use instead | +| ------------------------ | -------------------------- | +| realm (mount / URL slot) | **depot** | +| RealmMount | DepotMount (or equivalent) | +| “the realm’s store” | **the depot’s store** | Keep **realm** only in backport docs, meaning the Boxel construct. @@ -87,12 +88,12 @@ A **realm** on a Boxel realm server is a **placement-free address space**: So: -| | **Depot** (Deck standalone) | **Realm** (Boxel) | -| --- | --- | --- | -| Role | A **place** for sealed + live trees | A **dereferenceable** world of files/cards | -| Layout | Convenient `publisher/package` shape | Anything anywhere | -| Store | Depot-local CAS (`_objects` / `_trees` / meta) | Realm machinery + history + … | -| Protocol | Deck serve / publish / lock / pack | Full realm server + (after backport) Deck | +| | **Depot** (Deck standalone) | **Realm** (Boxel) | +| -------- | ---------------------------------------------- | ------------------------------------------ | +| Role | A **place** for sealed + live trees | A **dereferenceable** world of files/cards | +| Layout | Convenient `publisher/package` shape | Anything anywhere | +| Store | Depot-local CAS (`_objects` / `_trees` / meta) | Realm machinery + history + … | +| Protocol | Deck serve / publish / lock / pack | Full realm server + (after backport) Deck | **Depot ⊂ capabilities a realm can offer.** A depot does not need to become placement-free. A realm does not need to give up placement freedom to speak @@ -110,13 +111,13 @@ When Deck is backported: Mapping: -| Standalone Deck | On a deck-enabled realm | -| --- | --- | -| depot name | realm URL prefix / realm handle (one realm ↔ one Deck mount from the client’s view) | -| publisher/package | **one** way to lay out decks inside the realm — not the only way paths exist | -| `//…/@pkg@1.2.3/…` | same serve grammar, rooted at the realm | -| depot store | realm store / bookmarks as today, as long as L1–L12 hold | -| `deck conform` | black-box against the realm’s Deck HTTP surface | +| Standalone Deck | On a deck-enabled realm | +| ------------------------- | -------------------------------------------------------------------------------------------------------------- | +| depot name | Realm RRI authority plus its transport mapping; one Realm RRI ↔ one repository identity from the client’s view | +| publisher/package | **one** way to lay out decks inside the realm — not the only way paths exist | +| `//…/@pkg@1.2.3/…` | exact Version RRI projected through the realm’s Deck HTTP surface | +| depot store | realm-owned `.deck/store`, refs, Reviews, indexes, and deckd History | +| `deck conform` | black-box against the realm’s RRI resolver and Deck HTTP projection | ### 3.1 What backport must not do @@ -127,10 +128,21 @@ Mapping: ### 3.2 What backport must do - Preserve Deck identity (`tree-hash-v1`), immutability, and pin/verify laws. +- Keep every Deck resource below the owning realm's `.deck/`; authenticated + database rows may index/project those files but do not replace or relocate + them into a parallel global protocol tree. +- On hosted AWS, let agents inspect `.deck` through S3 Files while denying + POSIX writes to protocol-owned store/index/ref/Review paths. realm-server + writes those paths through direct conditional S3; deckd writes only the + branch-local jj History through S3 Files. - Expose the Deck URL grammar (exact / tag / live / pack / digests). - Allow decks to be addressed even when their files are not under a tidy publisher folder — resolution goes through the realm map / decklist, not through folder layout alone. +- Allow one realm to carry multiple branches. Each branch has its own mutable + source state, History, and cloned index while retaining the same Realm RRI, + transport mapping, ownership, and ACL. See + `deck-boxel-branch-index-backport.md`. ### 3.3 Cross-realm offer / rebase (in-motion on deck-enabled realms) @@ -139,22 +151,23 @@ Across depots (and therefore across deck-enabled realms) the same three carriages apply — normative detail in `deck-in-motion.md` §3.7: 1. **Public realm readable by both sides** — ordinary Deck GETs of offer - tip, upstream Version, digests. -2. **Out-of-band message of intent** — not wire protocol yet. + tip, upstream Version, digests. +2. **Out-of-band message of intent** — not wire protocol yet. 3. **If the tip cannot be public:** a **Deck Pack** attached to that message; receiver unpacks, then local offer/rebase. **Boxel plan for (2):** use **Matrix** as the signal — -| Piece | Role | -| --- | --- | -| Matrix **identity** | who is offering / who may accept | -| Matrix **room** | where the negotiation lives | -| Matrix **message** | message of intent (base Version, treeHash, why) | +| Piece | Role | +| ------------------------------------- | ------------------------------------------------- | +| Matrix **identity** | who is offering / who may accept | +| Matrix **room** | where the negotiation lives | +| Matrix **message** | message of intent (base Version, treeHash, why) | | optional **Deck Pack** on the message | case (3) when the offer tip is not world-readable | -Realms stay **depot-shaped** for Deck (one realm ↔ one Deck mount from the -client’s view). Realm **access control** decides who may **issue** an offer +Realms stay one ownership/repository boundary for Deck. A realm may expose +multiple branches with distinct indexes without pretending each branch is a +different realm. Realm **access control** decides who may **issue** an offer and who may **run rebase / accept** a pointer move; public catalog realms are case (1). GitHub share/gather remains an interim stand-in until Matrix negotiation lands (`deck-github-mirror-analysis.md` §5b). Synthesis vs @@ -181,14 +194,14 @@ new APIs say depot.** ## 5. One-line glossary -| Term | One line | -| --- | --- | -| **server** | Process that mounts depots and serves Deck HTTP | -| **depot** | Named place: live tree + store + URL prefix | -| **publisher** | Namespace / ownership segment under a depot | -| **deck / package** | Named module tree; versions point at treeHashes | -| **realm** | Boxel placement-free world; may become deck-enabled | -| **deck-enabled realm** | Realm that implements the Deck contract | +| Term | One line | +| ---------------------- | --------------------------------------------------- | +| **server** | Process that mounts depots and serves Deck HTTP | +| **depot** | Named place: live tree + store + URL prefix | +| **publisher** | Namespace / ownership segment under a depot | +| **deck / package** | Named module tree; versions point at treeHashes | +| **realm** | Boxel placement-free world; may become deck-enabled | +| **deck-enabled realm** | Realm that implements the Deck contract | --- diff --git a/docs/deck-embroider-lessons.md b/docs/deck-embroider-lessons.md index 1cfdbf0..1aaf22e 100644 --- a/docs/deck-embroider-lessons.md +++ b/docs/deck-embroider-lessons.md @@ -52,7 +52,7 @@ Stops casual “just make the server understand this package shape.” ### 3. Keep compat out of `@deck/server` All npm/CJS/`exports` normalization stays in vendor / CLI / Tools. -Serve only: live tree, `@exact`, tags, digests, maps, Timeline. +Serve only: live tree, `@exact`, tags, digests, maps, History. If a behavior needs “best effort like ember-auto-import,” it is a vendor flag with an explicit lossy report — never a silent serve path. diff --git a/docs/deck-fork-merge-pr-ux.md b/docs/deck-fork-merge-pr-ux.md index 4e022f1..7009818 100644 --- a/docs/deck-fork-merge-pr-ux.md +++ b/docs/deck-fork-merge-pr-ux.md @@ -1,10 +1,11 @@ -# Deck fork, pull request, and merge UX +# Deck fork, review, and merge UX **Status: UX DIRECTION — protocol-backed.** This document specifies the familiar collaboration surface Deck should present over the implemented local -Repository, Revision, Ref, and Pull Request protocol. It supersedes the UI rule -that Deck should avoid the words _branch_, _pull request_, and _merge_. Those -words are useful: people already know what they mean. +Repository, Checkpoint, branch-head, and Review protocol. It supersedes the UI +rule that Deck should avoid familiar branch/review/merge ceremony. Those +concepts are useful; the Review noun avoids pretending that Deck is moving Git +refs or pulling from a remote. Companions: [`deck-ui-spec.md`](deck-ui-spec.md), [`deck-hub-brief.md`](deck-hub-brief.md), @@ -21,20 +22,20 @@ Companions: [`deck-ui-spec.md`](deck-ui-spec.md), Deck should look immediately legible to a GitHub user: - **Browse** owns repositories, code, branches, Versions, and Activity; -- **Review** owns proposed changes expressed as Pull Requests; -- a person can **Fork**, choose a branch, open a **Pull request**, review +- **Review** owns proposed changes; +- a person can **Fork**, choose a branch, open a **Review**, review changes, see checks, and press **Merge**; -- a fork is private until its owner deliberately opens a pull request; +- a fork is private until its owner deliberately opens a review; - conflicts and stale reviews have conventional, unmistakable states. Deck's advantage appears after orientation, not before it: -- every pull request has a **Run this change** action; +- every review has a **Run this change** action; - preview means executing the exact proposed Repository, not deploying an approximation; - dependency substitutions are visible; - a clean merge moves a pointer and copies no content; -- hundreds of agent pull requests collapse to the exceptional few that need +- hundreds of agent reviews collapse to the exceptional few that need attention. The product should not ask a new user to learn _offer_, _trial_, _seal_, @@ -46,22 +47,22 @@ navigation. ## 1. Familiar outside, Deck inside -| Familiar UI term | Deck mechanism | What the UI says | -| ------------------ | ------------------------------------------------------------ | -------------------------------------------------------------- | -| Repository | named collaboration boundary over one or more package trees | Repository | -| Owner | publisher | Owner | -| Branch | isolated fork ref presented inside the same owner/repository | Branch | -| Fork | the same isolated fork primitive under a different owner | Fork | -| Commit | durable Revision | Revision in detail; commit-compatible wording in import/export | -| Pull request | durable source-ref → target-ref request | Pull request | -| Merge commit | Revision with target and source parents | Merge | -| Release | Version pointing at a treeHash and Revision | Version / Release | -| Actions/checks | signed results bound to exact source + target Revisions | Checks | -| Deployment preview | exact proposed Repository + lock | Preview | +| Familiar UI term | Deck mechanism | What the UI says | +| ------------------ | ------------------------------------------------------------ | ---------------------------------------------------------------- | +| Repository | named collaboration boundary over one or more package trees | Repository | +| Owner | publisher | Owner | +| Branch | isolated fork ref presented inside the same owner/repository | Branch | +| Fork | the same isolated fork primitive under a different owner | Fork | +| Commit | durable Checkpoint | Checkpoint in detail; commit-compatible wording in import/export | +| Pull request | durable `deck-review-v2` source-head → target-head proposal | Review | +| Merge commit | Checkpoint with target and source parents | Merge | +| Release | Version pointing at a treeHash and Checkpoint | Version / Release | +| Actions/checks | signed results bound to exact source + target Checkpoints | Checks | +| Deployment preview | exact proposed Repository + lock | Preview | Two distinctions must remain explicit: -1. A **fork is not a pull request**. People fork to experiment privately. +1. A **fork is not a review**. People fork to experiment privately. 2. A **merge is not a release**. Merging changes a branch; releasing makes a semver promise. @@ -142,9 +143,9 @@ Design rules: /browse?at=/// Repository / package code /browse?at= branch package tree /review proposed-change list -/review?repository=/&number=N Pull Request +/review?repository=/&number=N Review /review?repository=&source= propose fork main upstream -/track?at= ref and Revision activity +/track?at= ref and Checkpoint activity ``` These are Hub viewer routes. Raw ESM continues to use the existing Deck URL @@ -238,7 +239,7 @@ The primary **Fork** button opens a familiar confirmation rather than immediately enrolling the fork in an upstream queue. The fork records both its content base and, when the source is a mutable Deck -package, the exact upstream Revision. **Review upstream** compares the fork's +package, the exact upstream Checkpoint. **Review upstream** compares the fork's current `main` against that recorded base. The Review keeps the fork's real owner/package endpoint visible, while its package tree is mapped back onto the upstream package identity for preview and merge. @@ -246,7 +247,7 @@ upstream package identity for preview and merge. ```text ┌─ Create a fork ──────────────────────────────────────────────────────────────┐ │ A fork is your independent copy of acme/currencies. │ -│ It will not notify acme until you open a pull request. │ +│ It will not notify acme until you open a review. │ │ │ │ Owner chris ▾ │ │ Repository currencies │ @@ -262,7 +263,7 @@ After creation: ```text ┌─ chris / currencies ─────────────────────────────────────────────────────────┐ -│ forked from acme/currencies · 0 revisions ahead · 0 behind │ +│ forked from acme/currencies · no content changes yet │ │ │ │ [ Code ] Branches 1 Versions 0 Activity [ Review ] [ Sync fork ▾ ]│ │ [ Contribute ]│ @@ -272,7 +273,7 @@ After creation: **Contribute** is the deliberate transition from private work to upstream proposal. It appears only when the fork differs from upstream. -### Branch versus fork in pull-request labels +### Branch versus fork in review labels The compare control follows the convention people already know: @@ -285,18 +286,18 @@ base: acme/currencies:main ← compare: chris:add-myr ``` The short `owner:branch` label is sufficient when repository names match. -The detail popover carries the full source repository and exact Revision. +The detail popover carries the full source repository and exact Checkpoint. --- -## 5. Open a pull request +## 5. Open a review ```text -┌─ Open a pull request ────────────────────────────────────────────────────────┐ +┌─ Open a review ────────────────────────────────────────────────────────┐ │ base repository: acme/currencies base: main ▾ │ │ head repository: acme/currencies compare: add-myr ▾ │ │ │ -│ ✓ Able to merge 2 revisions 3 files +42 −6 │ +│ ✓ Able to merge 3 files +42 −6 │ │ │ │ Title Add Malaysian Ringgit formatting │ │ ┌──────────────────────────────────────────────────────────────────────────┐ │ @@ -305,43 +306,43 @@ The detail popover carries the full source repository and exact Revision. │ │ │ REVIEW THE RUNNING RESULT │ │ ┌──────────────────────────────────────────────────────────────────────────┐ │ -│ │ Preview URL /run?with=acme/currencies=acme/currencies:add-myr@ │ │ +│ │ Preview URL /run?with=acme/currencies=acme/currencies:add-myr@ │ │ │ │ [ Run proposed change ] [ Compare with main ] │ │ │ └──────────────────────────────────────────────────────────────────────────┘ │ │ │ -│ Reviewers Add… Draft ☐ [ Create pull request ]│ +│ Reviewers Add… Draft ☐ [ Create review ]│ └──────────────────────────────────────────────────────────────────────────────┘ ``` -The exact source Revision is captured when the request opens. A later source -update becomes an explicit pull-request event and makes Revision-bound checks +The exact source Checkpoint is captured when the request opens. A later source +update becomes an explicit review event and makes Checkpoint-bound checks or approvals stale. --- -## 6. Pull request overview +## 6. Review overview The page should look conventional at first glance and unmistakably Deck on the second. ```text ┌─ Add Malaysian Ringgit formatting #418 ──────────────────────── Open ──────┐ -│ chris wants to merge 2 revisions into acme:main from acme:add-myr │ +│ chris wants to merge 3 changed files into acme:main from acme:add-myr │ │ │ │ [ Conversation ] Changes 3 Preview Checks 4 │ ├──────────────────────────────────────────────────────────────────────────────┤ -│ ┌─ RUNNABLE PULL REQUEST ─────────────────────────────────────────────────┐ │ -│ │ This preview executes source revision 9a4f2c1 against main 31c72d8. │ │ +│ ┌─ RUNNABLE REVIEW ───────────────────────────────────────────────────────┐ │ +│ │ This preview executes source Checkpoint 9a4f2c1 against main 31c72d8. │ │ │ │ [ Run proposed change ] [ Side-by-side with main ] [ Copy exact URL ] │ │ │ └──────────────────────────────────────────────────────────────────────────┘ │ │ │ -│ chris opened this pull request 18m │ +│ chris opened this review 18m │ │ Adds MYR metadata and locale-aware formatting. │ │ │ │ deck/check-links ✓ passed exact dependency closure │ │ deck/data-shape ✓ passed 181 currency records │ │ ui/preview ✓ passed artifact available │ -│ amy approved ✓ approved source revision 9a4f2c1 │ +│ amy approved ✓ approved source Checkpoint 9a4f2c1 │ │ │ │ ┌─ Ready to merge ────────────────────────────────────────────────────────┐ │ │ │ ✓ All required checks passed │ │ @@ -354,12 +355,12 @@ the second. ### The four tabs -| Tab | Job | -| ------------ | --------------------------------------------------------------- | -| Conversation | intent, review events, approvals, ref changes | -| Changes | file and semantic data diff | -| Preview | exact running result, import substitutions, side-by-side run | -| Checks | results bound to source Revision + target Revision + suite tree | +| Tab | Job | +| ------------ | ------------------------------------------------------------------- | +| Conversation | intent, review events, approvals, ref changes | +| Changes | file and semantic data diff | +| Preview | exact running result, import substitutions, side-by-side run | +| Checks | results bound to source Checkpoint + target Checkpoint + suite tree | GitHub makes the diff primary because source is what it can show reliably. Deck should give **Preview** equal rank because running the exact proposal is @@ -395,7 +396,7 @@ JSON/data diff: └───────────────────────────────────────────────────────────────────────────┘ ``` -Review comments bind to `(sourceRevision, path, semantic key or line)`. If +Review comments bind to `(sourceCheckpointHash, path, semantic key or line)`. If the source moves and the location cannot be mapped confidently, the comment stays visible as **Outdated** rather than floating onto unrelated bytes. @@ -404,11 +405,11 @@ stays visible as **Outdated** rather than floating onto unrelated bytes. ## 8. Preview: Deck's signature interaction ```text -┌─ Preview pull request #418 ─────────────────────────────────────────────────┐ -│ main 31c72d8 pull request 9a4f2c1 │ +┌─ Preview review #418 ─────────────────────────────────────────────────┐ +│ main 31c72d8 review 9a4f2c1 │ │ ┌───────────────────────────────┐ ┌─────────────────────────────────────┐ │ │ │ │ │ │ │ -│ │ RUNNING MAIN │ │ RUNNING PULL REQUEST │ │ +│ │ RUNNING MAIN │ │ RUNNING REVIEW │ │ │ │ │ │ │ │ │ │ 180 currencies │ │ 181 currencies │ │ │ │ │ │ MYR · Malaysian Ringgit · RM │ │ @@ -424,9 +425,9 @@ stays visible as **Outdated** rather than floating onto unrelated bytes. The preview banner must always show: -- pull-request number; -- exact source Revision; -- target Revision it was resolved against; +- review number; +- exact source Checkpoint; +- target Checkpoint it was resolved against; - changed dependency bindings; - one action to return to the unmodified target. @@ -476,11 +477,11 @@ Approved 9a4f2c1 · current source b71e833 [ Request review ] ## 10. Merge ```text -┌─ Merge pull request #418 ───────────────────────────────────────────────────┐ +┌─ Merge review #418 ───────────────────────────────────────────────────┐ │ │ │ Method ● Merge commit │ │ ○ Fast-forward when possible │ -│ ○ Squash into one Revision │ +│ ○ Squash into one Checkpoint │ │ │ │ Result acme/currencies:main │ │ Source acme/currencies:add-myr at 9a4f2c1 │ @@ -496,9 +497,9 @@ Successful landing: ```text ┌─ Merged ─────────────────────────────────────────────────────────────────────┐ -│ amy merged revision e42bc90 into acme/currencies:main just now │ +│ amy merged Checkpoint e42bc90 into acme/currencies:main just now │ │ │ -│ [ Run merged result ] [ View merge Revision ] [ Open release proposal ] │ +│ [ Run merged result ] [ View merge Checkpoint ] [ Open release proposal ] │ │ [ Delete branch ] │ └──────────────────────────────────────────────────────────────────────────────┘ ``` @@ -514,7 +515,7 @@ change is now on the target branch and can be run. Default list: ```text -┌─ Review · Pull requests ─────────────────────────────────────────────────────┐ +┌─ Review · Reviews ─────────────────────────────────────────────────────┐ │ [ 12 Open ] 184 Closed Filters ▾ Sort: recently updated ▾ │ │ │ │ ✓ Add MYR formatting acme:add-myr Ready 18m │ @@ -607,7 +608,7 @@ Build the UX in three slices. Add static, typed fixtures representing: -- clean pull request; +- clean review; - draft; - source updated after review; - real conflict with paths/JSON keys; @@ -615,32 +616,31 @@ Add static, typed fixtures representing: - merged request; - 200-request agent queue summarized to exceptions. -Build the repository shell, pull-request list, detail, changes, preview, and +Build the repository shell, review list, detail, changes, preview, and merge dialog against `examples/collaboration`. The same validation state must round-trip through the collaboration implementation; the UI does not own a second model of repository state. ### Slice U1 — read protocol objects -- Repository member, file, Revision, branch, and Version data from Hub +- Repository member, file, Checkpoint, branch, and Version data from Hub endpoints over the protocol store. -- Existing forks and `forkedFrom` records projected as compatibility pull - requests. +- Existing forks and `forkedFrom` records projected as compatibility Reviews. - Existing `mergeTrees` result projected into per-package ready/conflict state. - the captured Repository and lock power the Preview tab; `?with=` remains a - temporary trial mechanism, not the pull request identity. -- Existing checks, signatures, and Timeline facts displayed read-only. + temporary trial mechanism, not the review identity. +- Existing checks, signatures, and History facts displayed read-only. This slice proves the product with real Deck content and durable refs before write actions are enabled. -### Slice U2 — durable pull requests +### Slice U2 — durable reviews -The local core now has Pull Request openings, append-only events, exact +The local core now has Review openings, append-only events, exact preview, Repository merge, and crash-recoverable merge completion. Bind those objects to the Hub. The conditional object-store adapter already covers S3 -branch refs, Pull Request numbering, openings, and event heads. Hosted fork, -review, merge, and close actions still require the authenticated origin API. +branch refs, Review numbering, openings, and event heads. Hosted fork, +review, merge, and close actions still require the authenticated realm API. The visual contract does not change. The executable terminal tour in @@ -655,13 +655,13 @@ A first-time GitHub user should complete this without documentation: 1. Open `acme/currencies` and recognize a repository. 2. See that **Fork** and **New branch** are separate, familiar actions, and - that neither opens a pull request. + that neither opens a review. 3. As a team member, create an `acme/currencies:add-myr` branch; as an outside contributor, create `chris/currencies` as a fork. 4. Understand that both work lines have identical isolation even though the UI names them differently. 5. Press **Contribute** and compare base/head. -6. Open a pull request. +6. Open a review. 7. Run the exact proposal beside main. 8. Read code or semantic JSON changes. 9. See checks and approval state. @@ -670,7 +670,7 @@ A first-time GitHub user should complete this without documentation: An experienced Deck user should additionally be able to prove: -- which exact source and target Revisions were reviewed; +- which exact source and target Checkpoints were reviewed; - which Repository hash and member treeHashes executed in Preview; - which dependency bindings changed; - why an approval became stale; diff --git a/docs/deck-frame-version-vocabulary.md b/docs/deck-frame-version-vocabulary.md index 801558a..344823c 100644 --- a/docs/deck-frame-version-vocabulary.md +++ b/docs/deck-frame-version-vocabulary.md @@ -1,4 +1,4 @@ -# Frame · Version · Timeline → Step +# Frame · Version · History → Step **Superseded** (2026-08-09). Frame → Tick → **Step**. diff --git a/docs/deck-github-mirror-analysis.md b/docs/deck-github-mirror-analysis.md index 182a736..2871ea6 100644 --- a/docs/deck-github-mirror-analysis.md +++ b/docs/deck-github-mirror-analysis.md @@ -8,7 +8,7 @@ Companions: `deck-depot-and-realm-backport.md`, `deck-laws.md` (L1/L3/L4), `deck-catalog-consumption-modes.md`, `packages/core/src/merge.ts` (3-way). Original decisions: Chris, 2026-08-07 (one-way mirror). Extended 2026-08-09 -with store-vs-Timeline clarity, monorepo confirmation, provenance carriers, +with store-vs-History clarity, monorepo confirmation, provenance carriers, and a **deterministic merge-back** path when a GitHub repo is allowed to be read/write — still **without AI**. @@ -20,11 +20,11 @@ read/write — still **without AI**. | --- | --- | --- | | **Version** (immutable cut, `@1.2.3`) | `.deck/store/` — `meta.json` → `treeHash` + CAS blobs | Projected as annotated tag + tree on `main` | | **Dist-tag** (`latest`, `dev`) | store `meta.json` `tags` | Projected as moving branch | -| **Timeline Step** (every save) | `.jj` (Git-compatible under the hood) | Optional `history` branch | +| **History Step** (every save) | `.jj` (Git-compatible under the hood) | Optional `history` branch | | **Live tree** | depot working directories `publisher/package/…` | Tree inside the monorepo | **Versions are not stored in jj/git.** The store is the npm+unpkg half. -jj is optional Timeline only. A depot can publish forever with +jj is optional History only. A depot can publish forever with `History: Versions` and no `.jj` at all. **GitHub is never the registry.** At best it is a projection (mirror) or @@ -56,7 +56,7 @@ to it.** Prefer `publisher/package@version` for catalogs with many authors. - **`main`:** one commit per *semantic* event (publish, yank, quarantine, migration batch, …). Deterministic rebuild from depot events. -- **`history`:** raw Timeline (colocated jj/git) pushed as-is. Opt-in for +- **`history`:** raw History (colocated jj/git) pushed as-is. Opt-in for “share an app”; on for catalog if you want every Step public. - Readers choose; nothing authoritative lives only on GitHub. @@ -106,7 +106,7 @@ tags for Versions. | --- | --- | | Version `@1.2.3` | annotated tag `publisher/package@1.2.3` | | Dist-tag `latest` / `dev` | branch `latest` / `dev` (or `tags/latest` — choose one scheme) | -| Timeline Steps | branch `history` (optional) | +| History Steps | branch `history` (optional) | | Semantic export tip | branch `main` | Identity for merges and audit is **`treeHash`**, not `git rev-parse`. @@ -143,14 +143,14 @@ tree bytes the store has. treeHash: <64 hex> Deck-Version: publisher/package@1.2.3 Base-Version: publisher/package@1.2.0 -Change-Id: +Change-Id: Actor: ``` - **`treeHash`:** L1 identity of that package tree (required on Version tags). - **`Base-Version`:** parent cut when exporting a tip that will accept GitHub PRs (required for intake repos). -- **`Change-Id`:** Step linkage when Timeline exists; omit if Versions-only. +- **`Change-Id`:** Step linkage when History exists; omit if Versions-only. Tag message body may repeat the same fields for humans browsing Releases. @@ -166,7 +166,7 @@ Tag message body may repeat the same fields for humans browsing Releases. | Dist-tag move | force-with-lease update of tag branch | | Publish | curated `main` commit + tag | | Yank / quarantine | tombstone commit; tag retained | -| Restore (Timeline) | ordinary commit on `history` (+ `main` if it was also a publish) | +| Restore (History) | ordinary commit on `history` (+ `main` if it was also a publish) | --- @@ -213,11 +213,11 @@ that fix that. The GitHub handshake is still work we have not built. | Idea behind share/gather | Deck today | Verdict | | --- | --- | --- | | “Server is SoT; git is a side channel” | Depot + store are SoT by law; GitHub is explicitly not the registry (§0) | **Enabled** — this is the whole point of the design | -| Share *WIP anytime*, not only a release | Live tree is first-class; Versions optional; Timeline optional | **Enabled in model** — tip can be `live` with `Base-Version` = last publish | +| Share *WIP anytime*, not only a release | Live tree is first-class; Versions optional; every-save History optional | **Enabled in model** — tip can be `live` with `Base-Version` = last publish | | Bring work back without clobbering | `mergeTrees` + `treeHash` already in Core | **Enabled in model** — gather is “run the merge,” not “copy files” | | Know what you are merging against | Store holds immutable `@x.y.z`; `?meta` exposes digests (incl. live) | **Enabled** — Base-Version is a real object, not a milestone nickname | | Package-scoped move (not whole realm dump) | Addressing is already `publisher/package` | **Enabled** — monorepo mirror matches the unit people share | -| Don’t invent a second history for collab | jj/Timeline ≠ Versions; share branches are disposable | **Enabled** — we finally have vocabulary legacy lacked | +| Don’t invent a second history for collab | jj/History ≠ Versions; share branches are disposable | **Enabled** — we finally have vocabulary legacy lacked | | Teammate sees your intent while you work | Claims (`deck claim`) on paths | **Partially** — coordination inside a depot, not a substitute for a PR | | Review / comment / CI social graph | None in Deck Core | **Not really** — still GitHub’s job (or a future Boxel CR) | | One command that “just shares” with no repo setup | No mirror, no `deck share` yet | **Not built** — design only | @@ -295,7 +295,7 @@ the same provenance offers already use (`treeHash` + base Version). Two git models were intentional (and correct): -1. **`.boxel-history/`** — local undo only (Deck: Timeline / jj Steps). +1. **`.boxel-history/`** — local undo only (Deck: History / jj Steps). 2. **GitHub share/gather** — team collaboration layer (Deck: this doc). Source of truth stayed the **server**, not git. That ruling carries over @@ -390,7 +390,7 @@ deck gather \ | `--into` | Effect | | --- | --- | -| **`live`** (default for humans) | `mergeTrees(base, ours=PR, theirs=current live)` → write working tree; optional Timeline Step; **claim** may be refreshed/cleared by actor | +| **`live`** (default for humans) | `mergeTrees(base, ours=PR, theirs=current live)` → write working tree; optional History Step; **claim** may be refreshed/cleared by actor | | **`publish`** | Same merge, then verify-links → `publishToStore` (new Version). Same as §6 intake success path | Never: copy-overwrite live; never: move an existing Version tag; never: @@ -500,7 +500,7 @@ mess. Details retained from 2026-08-07 catalog cleanup notes: | Piece | Status | | --- | --- | | Store Versions + dist-tags | **Built** | -| Timeline via jj | **Built** (optional) | +| History via jj | **Built** (optional) | | npm/CDN vendor into store | **Built** (not GitHub) | | Deck → GitHub export bot | **Not built** (this doc) | | `deck share` / `deck gather` | **Not built** (§5b; verbs kept) | @@ -555,7 +555,7 @@ milestone-as-default without an explicit `--from`. - Intake: conflicting edit on same file vs `onto` fails with path list. - Intake: clean PR publishes new Version; old Version tag unchanged; `@exact` URL serves new treeHash. -- Depot with **no** `.jj` still exports Versions (Timeline optional). +- Depot with **no** every-save History still exports Versions. ### 9.4 Non-goals for v1 @@ -580,7 +580,7 @@ milestone-as-default without an explicit `--from`. ## 10. Summary - **Monorepo per depot** matches depot layout; Versions stay in - `.deck/store`; jj is Timeline only. + `.deck/store`; jj is History only. - **Catalog mirrors are read-only**; contribution uses **intake** repos with declared `Base-Version` and Deck’s 3-way merge — deterministic, no AI. diff --git a/docs/deck-hub-brief.md b/docs/deck-hub-brief.md index 133fe81..39d0c92 100644 --- a/docs/deck-hub-brief.md +++ b/docs/deck-hub-brief.md @@ -71,9 +71,9 @@ components at ordinary deck addresses. Repository identity and branches live in `/browse`, alongside the package tree they govern. A branch within an owner/repository uses the same isolated Repository ref as a fork, but the UI calls it a branch. `/review` begins with a -proposed change: Pull Request review links lead back to exact `/browse` +proposed change: Review links lead back to exact `/browse` comparisons, while Preview leads to `/run` with the proposed Repository's exact -package Versions. **Underneath** exposes the ref, Revision, Repository, Pull +package Versions. **Underneath** exposes the ref, Checkpoint, Repository, Pull Request opening, and append-only events behind those familiar labels. **`/read` is gone; `/track` replaces it.** Two moves, one reason each. @@ -110,8 +110,8 @@ The first thing a person sees after `deck serve`. It answers J1, J2, J4. and the depots mounted with their honest on-disk paths. The layout *is* the address space, so showing the path is showing the truth, not a debug detail. -**Per depot.** Deck count, version count, store object count and size, history -history (a Timeline from jj colocated or historyd, or Versions only), and the +**Per depot.** Deck count, version count, store object count and size, History +(every-save History from deckd, or Version history only), and the most recent seal. **The river.** `/_live/events` as a stream of change events — deck name, diff --git a/docs/deck-hub-tour.md b/docs/deck-hub-tour.md index 38a9705..6d305cd 100644 --- a/docs/deck-hub-tour.md +++ b/docs/deck-hub-tour.md @@ -32,7 +32,7 @@ DECKD_URL=http://127.0.0.1:8787 node apps/hub/hub.mjs | `/track` | What changed? | **Rule:** if a control would write — skip it. Prefer subject, volatility, -Timeline scrub, list browsing, and `?with=` trials. Prefer **live** paths +History scrub, list browsing, and `?with=` trials. Prefer **live** paths over brittle `@exact` pins when the tip is enough. --- @@ -44,7 +44,7 @@ While the tour is active: - **Theme highlight** — a cutout plus a pulsing ring. No popover over the UI. - **Caption in the bottom bar** — chapter · step · story line · exhibit. - Interactions are *exercised*: **list-browse**, **dropdown**, **scroll**, - **Timeline scrub**. + **History scrub**. - Chapter boundaries navigate with `?tour=1` and session state. --- @@ -70,7 +70,7 @@ that flip is the lesson. | **List-browse** | Ch1 — `/browse?at=/two-agents` | Select a desk row (`agent-a` / `charts`) | | **Dropdown** | Ch2 — currencies `@latest`; Ch4 — vale/sale | Open the version `