Commit d729ec1
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
- cmd
- internal/igcatalog
- testdata
0 commit comments