Skip to content

Commit d729ec1

Browse files
anshulsaoclaude
andauthored
feat(ig): praxis ig — the only client of the ig catalog server (#59)
* feat(ig): add 'praxis ig' — the only client of the ig catalog server praxis ig list|sync|status|publish|claims|manifest push|manifest pull. praxis fetches catalogs over HTTP and writes them to $IG_HOME; the ig binary reads that filesystem and never learns servers exist (no HTTP client, no auth, no server URL in ig). The two never call each other. - internal/igcatalog: bearer-auth REST client (mirrors internal/duties), with exported function-var seams for tests. Endpoints under /ai-api/ig. - cmd/ig.go: cobra tree under the 'ig' noun (praxis already uses "catalog" for the skill catalog). sync materializes a bundle atomically (temp dir + rename), verifies the sha256 digest, uses If-None-Match for cheap 304s, and writes .sync.json beside the tree. .sync.json's refresh field is the exact reproducing command, appending --profile <p> whenever the active profile is not the default; ig echoes it verbatim as the fix on CATALOG_SYNC_STALE, composing nothing itself. TDD: transport tests (httptest) + orchestration tests (seam swaps) written first and watched fail, then implemented. make test / go test -race / go vet / gofmt -l / go run . ig --help all clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ig sync: validate archive contract before swap; reject catalog-prefixed bundles praxis ig sync extracted the server bundle verbatim and swapped it into $IG_HOME/projects/<catalog>/ with no check that the tree matched the catalog contract. When the server emitted catalog-prefixed entries (<catalog>/metadata.json, ...), the swap produced a double-nested projects/<catalog>/<catalog>/metadata.json, ig status reported NO_METADATA, and praxis still reported success. A client that cannot tell a good bundle from a bad one hides the next server regression. syncOne now calls validateBundleTree after extraction and before the swap: metadata.json MUST sit at the extracted-tree root. If not, sync fails loudly with a non-zero exit and the errBundleContract sentinel. The exact bug shape (a single directory named after the catalog, with metadata.json one level down) gets a specific diagnosis naming the catalog-prefix problem. The prefix is never stripped or "fixed up" -- silently tolerating a malformed bundle is what let the broken tree ship. The pre-existing temp-dir-then-rename design guarantees the live tree is untouched on failure since validation runs before any rename. Existing protections are unchanged: withinDir traversal guard, maxMemberFileBytes cap, temp-dir-then-rename, and the errBundleMismatch digest check. Tests (written first, RED confirmed: the prefixed archive was silently accepted before this change): a good bundle swaps in and leaves metadata.json at the catalog root; a catalog-prefixed archive is rejected with a message mentioning the catalog-prefix problem and the live tree survives intact and readable; an archive with no metadata.json anywhere is rejected too. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(ig): send publish_member as multipart/form-data (fix 422) praxis ig publish got HTTP 422 from the live server: the graph was sent as a raw application/gzip body with git/sha as query-string params, but the server handler publish_member requires multipart/form-data with a file part named "graph" plus optional git/sha form fields. Rebuild the PublishMember request with mime/multipart: - CreateFormFile("graph", "graph.json.gz") carrying the gzipped graph bytes - WriteField("git"/"sha") only when non-empty (server: Optional = Form(None)) - Content-Type from writer.FormDataContentType() (carries the boundary) - drop git/sha from the query string Reuses the existing sendBytes helper (it already takes a Content-Type param); JSON verbs are untouched. Adds a contract test that parses the request as multipart and asserts the graph part, form-value git/sha, empty-field omission, and that git/sha never leak into the query string. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(ig): decode catalog members as objects, not strings praxis ig list failed against the live catalog server with "cannot unmarshal object into Go struct field Catalog.members of type string": the server returns member objects ({name, kind, git, sha}) mirroring ig's own metadata.json, but the client declared Catalog.Members as []string. Add a Member type and change Catalog.Members to []Member. git/sha tolerate being absent or JSON null (the infra member carries no repo), both decoding to the empty string. The pretty printer's member count (len) is unchanged; --json now surfaces each member's git and sha. TDD: a probe proved the runtime unmarshal error first; permanent contract tests decode the exact live payload plus an infra/null-repo member. The HappyPath fixture moved from bare-string members to member objects. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(ig): reconcile catalog client with live server schema + conformance test Running the real praxis binary against the live ig catalog server surfaced client/server drift that both green test suites missed because neither tested against the other's artifact. Reconcile every verb against the captured live OpenAPI and lock it down with a schema-conformance test. Mismatches fixed: - claims: the server returns an envelope {git, catalogs[]} (IgClaimsResponse), not a bare array. Decoding it as []string killed `praxis ig claims` with "cannot unmarshal object into Go value of type []string". Add a claimsResponse envelope; Claims peels it and returns the catalog names. - manifest push: the request schema (IgManifestPushRequest) declares only content + git_sha; the server stamps pushed_by/pushed_at itself. The client reused the Manifest response type and put those two extra fields on the wire. Send a dedicated manifestPushRequest with content + git_sha only. - manifest pull: IgManifest marks catalog as required, but the Manifest struct dropped it. Add the Catalog field so the response type captures every required field. Conformance test (internal/igcatalog/conformance_test.go) embeds the captured OpenAPI as testdata and, for each response type the client decodes, asserts the round-trip: a schema-shaped example decodes cleanly, every required field survives the decode, and null optionals / unknown future fields are tolerated. A coverage guard fails if a new GET verb's response type has no table row. This would have caught all three of the drift bugs before a human ran the binary. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(ig): strip quoted ETag on read, re-quote on send for If-None-Match RFC 9110 8.8.3 requires an ETag header to be a quoted string (e.g. ETag: "v1.2.3"), but igcatalog.DownloadBundle stored the header verbatim. praxis ig sync then wrote the quotes straight into .sync.json's digest and from fields, and ig printed them in its human-facing provenance footer. Add unquoteETag (strips a leading W/ weak-validator prefix and surrounding quotes) and quoteETag (its inverse) in internal/igcatalog/igcatalog.go, applied to the ETag response header on both the 200 and 304 paths, and to the If-None-Match request header. Without the send-side fix, storing a bare digest would have broken the 304 fast-path against a spec-compliant server, since a bare token is not a valid If-None-Match value. Tests written first (RED confirmed against real httptest servers, not just the DownloadBundle seam) for: quote stripping on 200 and 304, weak-validator stripping, quoted If-None-Match on send, and the full syncOne -> .sync.json chain producing an unquoted digest/from. One pre-existing test that encoded the old bare-token send behavior was updated to match the fixed (quoted) behavior. * ci(release): bundle the ig build binary into the public praxis-cli release Source-repo CI needs the `ig` build tool to produce member graphs, but ig lives in the PRIVATE Facets-cloud/infragraphify repo, so its own release assets need auth to download — a GitHub PAT on every source repo, purely to fetch a build tool (nothing to do with graph distribution, which goes through praxis auth). praxis-cli is public and its release assets are token-free. So the release job now pulls the pinned ig-<os>-<arch> binaries from infragraphify's release and attaches them to THIS release. A source repo's CI then downloads `ig` alongside `praxis` from one public release, needing no token that can read infragraphify. Only the COMPILED binary is copied — ig source never leaves the private repo, and praxis-cli stays public-buildable because ig is a release artifact, not a Go-module dependency. Requires two repo settings on praxis-cli: - secret IG_FETCH_TOKEN — contents:read on Facets-cloud/infragraphify - variable IG_VERSION — the ig tag to bundle (e.g. v0.2.2); bump to ship a newer ig with the next praxis release The server (agent-factory) pins its own ig independently; this copy is only the one source-repo CI uses to BUILD member graphs. * Revert "ci(release): bundle the ig build binary into the public praxis-cli release" This reverts commit 1730c7c. --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent efcbcae commit d729ec1

6 files changed

Lines changed: 22516 additions & 0 deletions

File tree

0 commit comments

Comments
 (0)