Skip to content

Galaxy Pages sync -- client, binding, tools, /sync command#114

Merged
dannon merged 17 commits into
mainfrom
feature/galaxy-pages-sync-foundation
May 26, 2026
Merged

Galaxy Pages sync -- client, binding, tools, /sync command#114
dannon merged 17 commits into
mainfrom
feature/galaxy-pages-sync-foundation

Conversation

@dannon
Copy link
Copy Markdown
Member

@dannon dannon commented May 16, 2026

Why

galaxyproject/galaxy#22361 ("Galaxy Notebooks") added /api/pages endpoints
for persistent narratives bound to a history. That PR merged upstream on
2026-05-20 (commit 45982c8e), so the contract is now stable and the full
notebook<->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, per
project 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. Contract
    notes:

    • Revisions split -- GET /pages/{id}/revisions returns
      PageRevisionSummary[] (no body); body is at
      GET /pages/{id}/revisions/{revision_id} (PageRevisionDetails).
      Two distinct TS types so reading rev.content from the list is a
      compile-time error, not a runtime undefined.
    • edit_source is only on UpdatePagePayload upstream;
      CreatePagePayload has extra="allow" and silently drops it.
    • content_format defaults to "markdown" client-side (server
      default is "html"); notebook content is always markdown.
  • 949c86d -- loom-galaxy-page fenced YAML block
    (galaxy-page-binding.ts, tests/galaxy-page-binding.test.ts)
    Mirrors the loom-invocation block grammar: render / find /
    upsert (keyed on page_id) / strip. Keeps notebook.md as pure
    markdown 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_revision is stored and refreshed but not enforced
    (v2 adds the pre-flight check once we have real usage data).

  • 75d8e0e -- Review-pass fixes
    revision_count -> latest_revision_id + revision_ids to match
    upstream. updatePage() defaults edit_source to "agent" so call
    sites don't have to remember it.

Sync slice (14 commits):

  • Helpers (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.)
  • Four tools (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.)
  • /sync command (sync-command.ts) -- status / push /
    pull / link <pageId> / resume <pageId> subcommands with a
    minimal --key value / --key="quoted" flag parser.
    (Commits 6ab5bd7, b4fc721.)
  • Context injection (context.ts) -- adds
    buildGalaxyPageBindingBlock() to the agent's system prompt between
    the notebook excerpt and recent-activity blocks, so the agent knows
    which Galaxy page (if any) the current notebook is bound to.
    (Commit fb835ef.)
  • Schema doc (docs/agent/notebook-schema.md) -- documents the
    block 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 -- clean
  • cd app && npx tsc --noEmit -- clean
  • npm test -- 209/209 (foundation tests + 26 new across sync,
    tools, command, context-injection, and resume)

dannon added 17 commits May 14, 2026 12:04
…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 dannon changed the title Galaxy Pages sync foundation (pre-merge, no tools wired) Galaxy Pages sync -- client, binding, tools, /sync command May 23, 2026
@dannon dannon marked this pull request as ready for review May 26, 2026 13:39
@dannon dannon merged commit 106baba into main May 26, 2026
3 checks passed
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant