Galaxy Pages sync -- client, binding, tools, /sync command#114
Merged
Conversation
…yproject/galaxy#22361, "Galaxy Notebooks"). This lands the network layer for notebook<->Galaxy-page sync; no tools, commands, or context injection are wired up yet because the upstream PR is still open (last updated 2026-05-14 at SHA 8c702ecd) and the contract has been moving. Tools come in a follow-up once #22361 merges -- the client and tests exist now so the layer is reviewable and the next slice doesn't have to re-derive the API shape. Three correctness points worth calling out: - `getPageRevisions` returns `PageRevisionSummary[]` -- {id, page_id, edit_source, create_time, update_time} -- not full content. The body lives at `GET /pages/{id}/revisions/{revision_id}` (`PageRevisionDetails`), exposed as a separate `getPageRevisionDetails(pageId, revisionId)` call. The summary and details types are intentionally distinct so reading `rev.content` from the list is a compile-time error. - `edit_source` is dropped from the create body. The upstream `CreatePagePayload` doesn't declare it (only `UpdatePagePayload` does); the create model is `extra="allow"` so it doesn't 422 but it's a no-op, and sending it would mislead future readers into thinking create-time provenance was being tracked. - `content_format` defaults to `"markdown"` in this client even though the server default is `"html"`. Notebook content is always markdown; making the client default to it removes a foot-gun. Helpers (`galaxyPost`, `galaxyPut`) live next to the existing `galaxyGet` in galaxy-api.ts so all three share config + error handling. Tests mock those at the module boundary the same way invocation-check tests do, and use hand-rolled JSON literals tagged `// matches Galaxy PR #22361 @ 8c702ecd` rather than vendoring upstream files -- if the PR's schema moves before merge, this comment is the trail to re-verify.
…o its Galaxy page. Mirrors the `loom-invocation` block grammar in notebook-writer.ts (render / find / upsert keyed on `page_id`), so the notebook keeps its "pure markdown plus a small set of typed fenced blocks" shape from docs/agent/notebook-schema.md rather than picking up a sidecar dotfile. Sidecar was the first instinct but loses on three things: `.loom/*.json` matches the gitignored activity.jsonl/session.jsonl pattern (per-machine, lost on share), it's a second durable artifact with its own commit/watch rules, and hidden dotdirs are exactly what people forget when handing a project folder around. A typed block tracks alongside the notebook, survives `git clone` cleanly, and reuses the upsert pattern that's already proven for invocations. Block shape: ```loom-galaxy-page page_id: abc123 page_slug: my-analysis galaxy_server_url: "https://usegalaxy.org" history_id: hist-123 last_synced_revision: rev-789 bound_at: 2026-05-14T11:00:00Z ``` Keyed on `page_id` from day one even though MVP is one block per notebook -- the keyed `upsertGalaxyPageBlock` is the exact "find-by-id, replace-in-place" contract that `upsertInvocationBlock` uses, and switching to a different contract later when per-plan bindings show up would be unnecessary churn. v1 sync semantics are documented in the module header so future readers don't have to spelunk tool code: push is unconditional local-wins, pull is unconditional remote-wins, `last_synced_revision` is stored and refreshed after every successful sync but not enforced. The failure mode (user edits both local and remote between syncs, push silently discards remote) is real but acceptable because Galaxy Pages aren't the primary edit surface and the agent is the only push caller. v2 adds the pre-flight concurrency check once we have real usage data to know how it actually fails. Also ships `stripGalaxyPageBlocks(content)` for use by future push/pull flows -- on push, strip the binding block so Galaxy doesn't render Loom's internal metadata; on pull, defensive cleanup in case the remote echoed it back. `loom-invocation` blocks are deliberately preserved through both directions: those are analysis content, not binding state. Tests cover render/parse round-trip, multi-block ordering, in-place replacement preserving surroundings, multiple independent bindings keyed by page_id, empty-content append, strip semantics including the "don't touch loom-invocation" rule.
`GalaxyPageSummary` typed `revision_count?: number`, which doesn't exist anywhere in upstream `PageSummary` at the pinned SHA. The actual contract exposes `latest_revision_id` (required, encoded ID) and `revision_ids: list[EncodedDatabaseIdField]` (required). The miss matters because our settled design has push/pull writing `latest_revision_id` into the binding block's `last_synced_revision` after every successful sync -- if the field is `revision_count` in the type but actually `latest_revision_id` on the wire, the post-merge tool slice either ends up doing an extra revision-list fetch or, worse, dereferencing a phantom field. Fixed the type, added a doc comment noting this is a typed subset of the upstream model (the response actually carries username/email_hash/published/deleted/tags/etc., none of which the sync layer uses), and updated the four fixture literals in the tests to include the new required field. The `updatePage()` wrapper required callers to remember `edit_source: "agent"` on every call -- this client only ever sees Loom-authored sync writes, so making each future call site re-supply the same constant is a footgun. Defaulted to `edit_source: params.edit_source ?? "agent"` and split the test into four cases (explicit "agent", default-when-omitted, explicit-"user" override, and "no title/annotation when unset"). Create-omits-edit_source is preserved -- the upstream `CreatePagePayload` doesn't expose it (extra="allow" silently drops it), and that asymmetry is intentional in the contract.
The create path takes history_id and inserts a fresh loom-galaxy-page binding block into the notebook after Galaxy returns the new page id. The update path uses the existing binding's page_id, asserts the server URL still matches the active connection (fail-closed), and refreshes last_synced_revision from the update response. Both paths strip loom-galaxy-page blocks from the body sent to Galaxy but leave loom-invocation blocks intact -- those are analysis content that should show up on the Galaxy page for human readers.
Reads the page binding from notebook.md, asserts the server URL matches the active connection, fetches the page details, and writes the remote body back to notebook.md with the binding block re-applied on top. v1 is unconditional remote-wins so any local edits since the last pull are lost -- documented in galaxy-page-binding.ts and surfaced in the tool's description so the agent picks the right tool for the situation.
Validates the page exists via getPage, builds the binding YAML, and upserts it into notebook.md without touching the body content. Reads history_id off the response's extra fields (Galaxy notebooks pages carry it even though PageDetails doesn't type it on the loom side either); falls back to an explicit caller-supplied historyId. Errors clearly if neither is available rather than silently writing a binding with no history link.
Thin wrapper around pushNotebookToGalaxy in galaxy-pages-sync.ts. Lives in tools-sync.ts (not tools.ts) so the three sync tools can be tested in isolation and tools.ts stays focused on GTN / invocation tracking / skills fetch. The tool description tells the agent about the unconditional-local-wins contract so it picks the right tool when the user wants to share progress vs. fetch updates.
Thin wrapper around pullNotebookFromGalaxy. Description warns about the unconditional remote-wins semantics so the agent doesn't accidentally discard work the user did locally between syncs.
Thin wrapper around linkGalaxyPage -- creates a binding for an existing Galaxy page without touching its body. The only mutating tool that doesn't risk data loss; suitable for the user-started-in-Galaxy-UI flow where Loom is taking over an existing analysis.
User-facing peer of the notebook_*_galaxy tools. Shares the helpers in galaxy-pages-sync.ts so chat-level /sync and agent-level tool calls behave identically. /sync status reads the binding off notebook.md and prints page id + last synced revision; the mutating subcommands forward to the helpers and print the action they took. Lives in sync-command.ts to keep index.ts small.
When notebook.md has a loom-galaxy-page block, surface it to the agent at session start with page id, slug, server URL, and last synced revision. Tells the agent which tool to use for push vs pull and warns that pull is destructive on local edits, so the agent doesn't pick the wrong direction on a vague user request.
Describes the block grammar, the strip-on-push / re-apply-on-pull contract, and the v1 unconditional-overwrite sync semantics so future contributors don't have to read the helper code to understand what goes in notebook.md.
The client's GalaxyPageSummary already declares history_id as string | null | undefined, so the Record<string, unknown> cast was working around a type that no longer needed working around. Direct property access is simpler and passes strict typechecking.
One-shot "resume from Galaxy" so a user can pick up a page they left in the Galaxy UI without having to do `link` then `pull` separately. Same locking and fail-closed server-URL check as the existing helpers. Refuses to clobber when bound to a different page on the same server -- that path stays explicit (link, then pull). Idempotent when re-resuming the same page (action="refreshed", bound_at preserved so the original link time isn't lost).
Thin TypeBox wrapper around resumeGalaxyPage. Same response shape as the other notebook_*_galaxy tools so the agent can chain or branch on the returned action without special-casing this one.
User-facing entry to resumeGalaxyPage. Mirrors `/sync link` -- positional <page_id> plus optional --history -- but reports the action (linked vs. refreshed) so the user can tell whether they were starting fresh or re-resuming. Missing page_id surfaces a usage line instead of erroring.
Adds resume to the sync semantics list with the same level of detail as push and pull, and extends the server-URL fail-closed note to cover all three.
dannon
added a commit
that referenced
this pull request
May 26, 2026
PR #114 landed these two new modules still importing ExtensionAPI from @mariozechner/pi-coding-agent. PR #116 had retired that scope in favor of @earendil-works/pi-coding-agent, but the rename never reached the in-flight sync-tools branch -- and a pre-commit hook quirk swallowed the fix I tried to push at #114 merge time. Patch the imports here so this rebase can typecheck. A standalone follow-up PR to main covers the same fix for anything else that hits this.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
galaxyproject/galaxy#22361 ("Galaxy Notebooks") added
/api/pagesendpointsfor persistent narratives bound to a history. That PR merged upstream on
2026-05-20 (commit
45982c8e), so the contract is now stable and the fullnotebook<->page sync slice can land together.
Originally I had this scoped as foundation-only (the REST client + binding
format) with tools/
/sync/context-injection deferred to a follow-up while#22361 was still open. With upstream merged, the follow-up rolled forward
into this same PR -- one slice end-to-end is easier to review and rolls
back as a unit if anything in the sync semantics turns out wrong.
What lands
All under
extensions/loom/. Commits are kept distinct (no squash, perproject norms) so the foundation layer is reviewable on its own.
Foundation (3 commits, originally the scope of this PR):
cb24f51-- Pages REST client (galaxy-pages-api.ts,galaxy-api.ts+44,tests/galaxy-pages-api.test.ts)Typed wrappers for
listHistoryPages/getPage/createPage/updatePage/getPageRevisions/getPageRevisionDetails. Contractnotes:
GET /pages/{id}/revisionsreturnsPageRevisionSummary[](no body); body is atGET /pages/{id}/revisions/{revision_id}(PageRevisionDetails).Two distinct TS types so reading
rev.contentfrom the list is acompile-time error, not a runtime undefined.
edit_sourceis only onUpdatePagePayloadupstream;CreatePagePayloadhasextra="allow"and silently drops it.content_formatdefaults to"markdown"client-side (serverdefault is
"html"); notebook content is always markdown.949c86d--loom-galaxy-pagefenced YAML block(
galaxy-page-binding.ts,tests/galaxy-page-binding.test.ts)Mirrors the
loom-invocationblock grammar:render/find/upsert(keyed onpage_id) /strip. Keepsnotebook.mdas puremarkdown plus a small set of typed blocks rather than picking up a
sidecar dotfile. v1 sync contract documented in the module header:
push is unconditional local-wins, pull is unconditional remote-wins,
last_synced_revisionis stored and refreshed but not enforced(v2 adds the pre-flight check once we have real usage data).
75d8e0e-- Review-pass fixesrevision_count->latest_revision_id+revision_idsto matchupstream.
updatePage()defaultsedit_sourceto"agent"so callsites don't have to remember it.
Sync slice (14 commits):
galaxy-pages-sync.ts) --pushNotebookToGalaxy,pullNotebookFromGalaxy,linkGalaxyPage,resumeGalaxyPage.Fail-closed on missing Galaxy server URL before any network call.
Strips the binding block on push, re-applies it on pull/resume.
(Commits
743387c,29d75e7,feaec42,b586e5a,60db43a.)tools-sync.ts) --notebook_push_to_galaxy,notebook_pull_from_galaxy,notebook_link_galaxy_page,notebook_resume_from_galaxy. TypeBox param schemas, consistent{content, details}response shape. The resume tool is the one-shot"pick up a Galaxy page in a fresh Loom session" entry point -- saves
the user from having to do link + pull as two separate calls.
(Commits
9b1e4f7,2585e86,99fb388,908adf5.)/synccommand (sync-command.ts) --status/push/pull/link <pageId>/resume <pageId>subcommands with aminimal
--key value/--key="quoted"flag parser.(Commits
6ab5bd7,b4fc721.)context.ts) -- addsbuildGalaxyPageBindingBlock()to the agent's system prompt betweenthe notebook excerpt and recent-activity blocks, so the agent knows
which Galaxy page (if any) the current notebook is bound to.
(Commit
fb835ef.)docs/agent/notebook-schema.md) -- documents theblock grammar, the strip-on-push / re-apply-on-pull/resume contract,
and the fail-closed server URL guard.
(Commits
041c053,d0bd3fd.)Test plan
npm run typecheck-- cleancd app && npx tsc --noEmit-- cleannpm test-- 209/209 (foundation tests + 26 new across sync,tools, command, context-injection, and resume)