From 09a4ef067b9bdfb9c2d6adb2ced4f1a1ac50efea Mon Sep 17 00:00:00 2001 From: knqiufan Date: Wed, 26 Aug 2026 19:19:42 +0800 Subject: [PATCH] Add the official typed HTTP Client for the client / C1 profile. Give callers 52 OpenAPI-backed methods, a strict Fetch transport, and Python Server call-through so a clean project can install the package without native addons. --- .github/workflows/ci.yml | 2 + CHANGELOG.md | 11 +- CONTRIBUTING.md | 2 + README.md | 5 +- conformance/runners/python/README.md | 6 +- conformance/runners/python/bootstrap.py | 4 + docs/adr/0007-node-lts-policy.md | 3 +- docs/develop/README.md | 1 + docs/develop/dsh-reuse.md | 74 +++++ docs/develop/packages.md | 6 +- docs/index.md | 7 +- docs/policies/risks.md | 2 +- docs/reviews/m1-client-exit-review.md | 43 +++ docs/roadmap.md | 5 +- docs/user/README.md | 14 +- docs/user/client.md | 23 ++ package.json | 2 +- packages/client/README.md | 96 +++++- packages/client/src/body.ts | 70 +++++ packages/client/src/call-options.ts | 21 ++ packages/client/src/client.ts | 140 +++++++++ packages/client/src/constants.ts | 22 ++ packages/client/src/content-type.ts | 49 ++++ packages/client/src/errors.ts | 94 ++++++ packages/client/src/headers.ts | 50 ++++ packages/client/src/index.ts | 46 ++- packages/client/src/methods.ts | 87 ++++++ packages/client/src/operation-types.ts | 68 +++++ packages/client/src/options.ts | 53 ++++ packages/client/src/package-info.ts | 21 ++ packages/client/src/request-prepare.ts | 104 +++++++ packages/client/src/response.ts | 152 ++++++++++ packages/client/src/signals.ts | 70 +++++ packages/client/src/tracing.ts | 77 +++++ packages/client/src/transport.ts | 64 ++++ packages/client/src/types.ts | 78 +++++ packages/client/src/url.ts | 63 ++++ packages/client/tests/call-through.test.ts | 122 ++++++++ packages/client/tests/fixtures/requests.ts | 276 ++++++++++++++++++ packages/client/tests/helpers/http.ts | 52 ++++ .../client/tests/helpers/python-server.ts | 177 +++++++++++ packages/client/tests/methods.test.ts | 74 +++++ packages/client/tests/package-info.test.ts | 26 +- packages/client/tests/request-types.ts | 33 +++ packages/client/tests/tracing.test.ts | 66 +++++ packages/client/tests/transport.test.ts | 259 ++++++++++++++++ .../protocol/src/generated/openapi-types.ts | 62 ++-- packages/protocol/src/index.ts | 1 + tools/generate-protocol/src/emit.ts | 6 +- tools/pack-smoke/run.mjs | 18 ++ 50 files changed, 2735 insertions(+), 72 deletions(-) create mode 100644 docs/develop/dsh-reuse.md create mode 100644 docs/reviews/m1-client-exit-review.md create mode 100644 docs/user/client.md create mode 100644 packages/client/src/body.ts create mode 100644 packages/client/src/call-options.ts create mode 100644 packages/client/src/client.ts create mode 100644 packages/client/src/constants.ts create mode 100644 packages/client/src/content-type.ts create mode 100644 packages/client/src/errors.ts create mode 100644 packages/client/src/headers.ts create mode 100644 packages/client/src/methods.ts create mode 100644 packages/client/src/operation-types.ts create mode 100644 packages/client/src/options.ts create mode 100644 packages/client/src/package-info.ts create mode 100644 packages/client/src/request-prepare.ts create mode 100644 packages/client/src/response.ts create mode 100644 packages/client/src/signals.ts create mode 100644 packages/client/src/tracing.ts create mode 100644 packages/client/src/transport.ts create mode 100644 packages/client/src/types.ts create mode 100644 packages/client/src/url.ts create mode 100644 packages/client/tests/call-through.test.ts create mode 100644 packages/client/tests/fixtures/requests.ts create mode 100644 packages/client/tests/helpers/http.ts create mode 100644 packages/client/tests/helpers/python-server.ts create mode 100644 packages/client/tests/methods.test.ts create mode 100644 packages/client/tests/request-types.ts create mode 100644 packages/client/tests/tracing.test.ts create mode 100644 packages/client/tests/transport.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index afb15fd..d263227 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,6 +40,8 @@ jobs: - run: pnpm typecheck - run: pnpm build - run: pnpm test + env: + POWERCONTEXT_CLIENT_CALLTHROUGH: '1' - run: pnpm license:check - run: pnpm generate:check - run: python conformance/runners/python/run.py --export-check diff --git a/CHANGELOG.md b/CHANGELOG.md index c2798aa..ade8393 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,11 +9,18 @@ are published. ### Added +- Official `@powercontext/client` typed HTTP Client: 52 methods, strict + transport, runtime validation, and Python Server call-through for the + `client` / C1 profile. - Generated Protocol contracts, runtime validators, and C1 wire / canonical conformance fixtures against the pinned Python OpenAPI 0.0.2 baseline. - Repository documentation for policies, ADRs, contributing, and security. ### Changed -- Public documentation now describes product profiles and milestones instead of - construction-phase exit reviews. +- OpenAPI-derived request types preserve required fields while leaving + server-defaulted fields optional. +- Undeclared 2xx responses are classified as Server errors, and malformed UTF-8 + success bodies are rejected instead of being decoded with replacement text. +- The Python-side DSH reuse design now uses the Client's exact Node range, + `>=22 <25`. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4cb72e1..15137a3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -50,6 +50,8 @@ pnpm lint pnpm typecheck pnpm build pnpm test +# Optional: require Python Server call-through after oracle bootstrap +# POWERCONTEXT_CLIENT_CALLTHROUGH=1 pnpm test pnpm generate:check python conformance/runners/python/run.py --export-check pnpm conformance diff --git a/README.md b/README.md index 2d57021..39a4f91 100644 --- a/README.md +++ b/README.md @@ -10,8 +10,9 @@ kit. ## Status The `client` profile has Protocol types, 52 operation contracts, runtime -validators, and C1 wire / canonical fixtures. The official HTTP Client -transport, Runtime, Server, MCP, and CLI are not shipped. +validators, C1 wire / canonical fixtures, and the official typed HTTP Client. +Runtime, Server, MCP, and CLI are not shipped. Docs claim `client` / C1, not +full-product. See [Current status](docs/user/README.md) and the [compatibility policy](docs/policies/compatibility.md). diff --git a/conformance/runners/python/README.md b/conformance/runners/python/README.md index 0a405d3..d7d22e6 100644 --- a/conformance/runners/python/README.md +++ b/conformance/runners/python/README.md @@ -17,8 +17,10 @@ The harness: 1. Verifies the pinned `uv.lock` digest from the baseline lock; 2. Creates `.venv` with `uv` and Python 3.11; -3. Checks out `python_commit` and runs `uv sync --locked --no-dev --no-editable` - into that environment; +3. Checks out `python_commit` and runs + `uv sync --locked --no-dev --no-editable --extra cli --extra server` + into that environment so the pinned Server CLI can start for Client + call-through; 4. Writes and verifies a lock marker containing the Python commit and dependency lock digest. diff --git a/conformance/runners/python/bootstrap.py b/conformance/runners/python/bootstrap.py index 75404db..ebcfea6 100644 --- a/conformance/runners/python/bootstrap.py +++ b/conformance/runners/python/bootstrap.py @@ -81,6 +81,10 @@ def sync_locked_project(source: Path, python: Path, lock: dict[str, object]) -> "--locked", "--no-dev", "--no-editable", + "--extra", + "cli", + "--extra", + "server", "--active", "--python", str(python), diff --git a/docs/adr/0007-node-lts-policy.md b/docs/adr/0007-node-lts-policy.md index f9e9091..71a4e70 100644 --- a/docs/adr/0007-node-lts-policy.md +++ b/docs/adr/0007-node-lts-policy.md @@ -16,7 +16,8 @@ 2. Client 与 Protocol 必须在 Node 22 与 Node 24 上由 CI 验证。`engines` 写 `>=22 <25`,或等价的“支持 22 与 24 LTS”。 3. 不维护 Node 20 兼容构建。EOL 运行时不进入支持矩阵。 -4. DSH 插件的 `>=20` 声明必须提升到 `>=22`。改造在 Python 仓库独立 PR 中进行, +4. DSH 插件的 `>=20` 声明必须调整为 `>=22 <25`,与 Client 的已验证范围一致, + 不得无测试地宣称支持 Node 25+。改造在 Python 仓库独立 PR 中进行, 可随官方 Client 发布一并落地。在那之前,官方 Client 文档必须写明 Node 20 不受支持。 5. 选择“提升 DSH 最低版本”,不选择“为 Node 20 维持第二套构建”。 diff --git a/docs/develop/README.md b/docs/develop/README.md index 5c2a84b..af41df2 100644 --- a/docs/develop/README.md +++ b/docs/develop/README.md @@ -15,5 +15,6 @@ The only Python → TypeScript channels are contract-sync and the oracle exporte | --- | --- | | Package boundaries | [packages.md](packages.md) | | Protocol generation | [generating-protocol.md](generating-protocol.md) | +| Official Client / DSH reuse | [dsh-reuse.md](dsh-reuse.md) | | Conformance kit | [conformance.md](conformance.md) | | Investigation notes | [investigations/README.md](investigations/README.md) | diff --git a/docs/develop/dsh-reuse.md b/docs/develop/dsh-reuse.md new file mode 100644 index 0000000..3850862 --- /dev/null +++ b/docs/develop/dsh-reuse.md @@ -0,0 +1,74 @@ +# DSH plugin reuse of `@powercontext/client` + +Phase 3 produces this design. Implementation is a Python-repository pull +request and may wait until the later host-acceptance milestone. + +## Current split + +The DSH plugin lives in the Python repository at `integrations/dsh`. Today it +owns a generic Fetch client, operation-id dispatch, scope derivation, mutation +approval, secret-like payload guards, PreparedContext wrapping, and fail-open +recall/capture. + +The official Client now owns the generic transport. The plugin should depend +on the published `@powercontext/client` package instead of keeping a second +Fetch implementation. + +## Move to the official Client + +- Base URL normalization +- `Authorization` / `User-Agent` / request-id capture +- Timeout plus caller `AbortSignal` +- Manual redirect rejection +- Bounded response bodies +- JSON / Markdown / download-bytes modes +- Runtime request and success validation +- `transport` / `unavailable` / `server` / `invalid-response` / + `unknown-operation` errors +- Optional tracing injection hook + +## Keep in the DSH plugin + +- Host scope derivation and long scope hashes +- Mutation approval and curated tool policy +- Secret-like payload rejection +- Fail-open recall/capture so a Server outage does not block the agent +- Host UI, commands, and DSH-specific skill text +- Untrusted-context wrapping for PreparedContext injection + +## Node engine + +ADR 0007 does not maintain a Node 20 build. The official Client declares +`engines.node: ">=22 <25"`. The plugin's `>=20` declaration must be raised to +`>=22 <25` in the Python-repository PR so it does not claim untested Node 25+ +runtimes. Hosts that remain on Node 20 keep calling the Python Server and must +not mark the official Client as supported. + +## Suggested adapter + +```ts +import { PowerContextClient } from '@powercontext/client' + +const official = new PowerContextClient({ + baseUrl, + authorization, + timeoutMs: requestTimeoutMs, + fetch, +}) + +export async function request(id: string, payload?: object, signal?: AbortSignal) { + return official.request(id, payload, { signal }) +} +``` + +The plugin keeps its operation table only if host tooling still needs it. +Wire types and validators come from `@powercontext/protocol` through the +Client. Do not copy OpenAPI snapshots into the plugin. + +## Acceptance + +- Current DSH unit and E2E behavior must not regress after the Python PR +- Plugin tests may keep host-level mocks; transport cases should use the + official Client +- This repository only publishes the Client and this design. It does not + modify the Python plugin tree diff --git a/docs/develop/packages.md b/docs/develop/packages.md index d0cf756..07b734c 100644 --- a/docs/develop/packages.md +++ b/docs/develop/packages.md @@ -1,11 +1,13 @@ # Packages -Public npm names are placeholders until the organization is confirmed. +The `@powercontext/*` names below are the public package contract. npm +namespace ownership and publication credentials are release-operations +prerequisites; they do not change consumer import paths. | Directory | Package | First milestone | | --- | --- | --- | | protocol | `@powercontext/protocol` | M1 | -| client | `@powercontext/client` | M1 | +| client | `@powercontext/client` | M1 (typed HTTP Client) | | core | `@powercontext/core` | M2 | | builtin | `@powercontext/builtin` | M2 | | server | `@powercontext/server` | M2 / M4 | diff --git a/docs/index.md b/docs/index.md index f3cb4eb..c312844 100644 --- a/docs/index.md +++ b/docs/index.md @@ -2,9 +2,10 @@ | Audience | Start here | | --- | --- | -| Users | [Current status](user/README.md) | +| Users | [Current status](user/README.md), [Official typed Client](user/client.md) | | Contributors | [Roadmap](roadmap.md), [CONTRIBUTING.md](../CONTRIBUTING.md), [development notes](develop/README.md) | -| Maintainers | [Policies](policies/README.md), [ADRs](adr/README.md), [RFC ledger](rfcs/README.md) | +| Maintainers | [Policies](policies/README.md), [ADRs](adr/README.md), [M1 exit review](reviews/m1-client-exit-review.md), [RFC ledger](rfcs/README.md) | [Roadmap](roadmap.md) is the living route. GitHub milestones and issues hold -assignments. Do not add construction-phase folders. +assignments. Exit evidence is stored as auditable review artifacts, not as a +second issue tracker. diff --git a/docs/policies/risks.md b/docs/policies/risks.md index 2dc3b34..65ea3c3 100644 --- a/docs/policies/risks.md +++ b/docs/policies/risks.md @@ -13,7 +13,7 @@ that can block parity or force a route change. | R6 | Provider | Non-deterministic output used for byte comparison; secrets in CI | Medium | Fake / recorded fixtures on the main path; live providers opt-in | M4 | | R7 | Migration | Dual writers without a global schema version | High | ADR 0002; do not write existing Python databases until it lands | ADR 0002, M2 | | R8 | Contract drift | The independent repository silently follows Python `main` | High | Biweekly bump, PR drift check, nightly advisory | [contract-sync.md](contract-sync.md) | -| R9 | DSH Node 20 | Plugin still declares `>=20`; Client is tested on 22 / 24 | Medium | ADR 0007; raise `engines` on the Python side | ADR 0007 | +| R9 | DSH Node range | Plugin still declares `>=20`; Client is tested on 22 / 24 | Medium | ADR 0007; set Python-side `engines` to `>=22 <25` | ADR 0007 | | R10 | Draft RFC creep | Unimplemented RFC 0048 / 0082 / 1223 clauses become M4 work | Medium | RFC ledger; Draft is not a fact source | [rfc-ledger.yaml](rfc-ledger.yaml) | When a risk becomes fact, write a compatibility decision before changing diff --git a/docs/reviews/m1-client-exit-review.md b/docs/reviews/m1-client-exit-review.md new file mode 100644 index 0000000..d4f059c --- /dev/null +++ b/docs/reviews/m1-client-exit-review.md @@ -0,0 +1,43 @@ +# M1 Client Exit Review + +- Review date: 2026-08-26 +- Construction scope: Phase 3 +- Status: release candidate; final commit-bound CI is still required before npm publication +- Baseline lock: [`contract/baseline.lock.yaml`](../../contract/baseline.lock.yaml), Python commit `733e4bf6b378785e76274ff07632029c699ecb09` +- Target profiles: `client` / C1 +- Capability report: [`conformance/reports/typescript.json`](../../conformance/reports/typescript.json) plus the Client transport tests listed below +- OpenAPI digest/count: `a97488e85ab3a9f1db3f1dce720ec74b07c626b1974cc860c67b91cabb22f7e3`; 52 operations; 177 schemas +- Database contract version: not applicable; the Client has no persistence or native database dependency +- MCP protocol/allowlist: not applicable; the Client does not ship an MCP server +- Supported Node/OS/CPU/database: Node 22 and 24; Client CI is configured for Node 22/24, and package smoke is configured for Linux, macOS, and Windows. The local review ran on Node 24.14.1, Windows x64. Database support is not part of this profile. +- Required CI runs: local gates passed on 2026-08-26: format, lint, typecheck, build, full test suite, license header check, generated-artifact drift check, pinned-contract verification, conformance, dependency-license check, and package smoke. The final commit must still pass the repository `quality`, Node 22/24 `client-matrix`, and three-OS `smoke` jobs before publication. +- Differential mismatches: none accepted for the `client` / C1 scope. Undeclared 2xx statuses now follow the Python Client's Server-error classification, and malformed UTF-8 is rejected. +- Security findings: no open Client finding in this review. URL credentials/query tokens, redirects, oversized bodies, malformed success bodies, and unsafe JSON integers are rejected; response bodies and timeouts are bounded. +- Performance budget/result: no standalone latency budget for a network Client. The implementation adds no retry loop, native addon, database, or model call. +- Known limitations: Python remains the semantic oracle and Server. Runtime, persistence, MCP, CLI, Dashboard, and DSH host policy are not shipped. The DSH implementation PR is intentionally deferred to the Python repository. npm publication still requires a final version/credentials and green commit-bound CI. +- Migration/recovery evidence: not applicable to state because the package is stateless. Consumers can roll back by restoring their previous Client package version; the Python Server and databases are unchanged. +- Product-line Go/No-Go and owners: Go for M1 release-candidate review; No-Go for npm publication until final commit-bound CI passes. Owners: `product-owner`, `protocol-owner`, and `conformance-owner`. +- Succession conclusion: `keep-python-mainline` +- Remaining Python-only capabilities: all local Runtime, SQLite/OceanBase persistence, inference/provider integration, HTTP Server, MCP, CLI, Dashboard, and host integrations +- Cutover / dual-run / rollback-to-Python evidence: the TypeScript Client calls the unchanged pinned Python Server. No database writer or Server cutover occurs. DSH stays on its existing Python-repository implementation until its separate adapter PR passes existing unit/E2E tests. +- Authority recommendation: keep the pinned Python implementation as semantic oracle; use the pinned OpenAPI snapshot plus generated validators for `client` / C1 wire enforcement + +## Phase 3 acceptance evidence + +| Requirement | Result | Evidence | +| --- | --- | --- | +| Transport core | Pass | [`packages/client/tests/transport.test.ts`](../../packages/client/tests/transport.test.ts) covers URL/auth/User-Agent/request ID, timeout, caller abort, redirect, body bound, JSON, text, and bytes. | +| 52 typed methods | Pass | Generated `OperationId` mapping, compile-time request assertions in [`packages/client/tests/request-types.ts`](../../packages/client/tests/request-types.ts), and 52-method runtime enumeration. | +| Success runtime validation and error layers | Pass | Protocol-generated validators plus invalid JSON/schema/UTF-8/status tests; public error classes are exercised by Client tests. | +| Optional tracing hook | Pass | [`packages/client/tests/tracing.test.ts`](../../packages/client/tests/tracing.test.ts); no OpenTelemetry SDK dependency. | +| TypeScript Client to Python Server 52/52 | Pass locally | [`packages/client/tests/call-through.test.ts`](../../packages/client/tests/call-through.test.ts) starts the pinned Python Server and requires every operation to return either a validated success or an OpenAPI-declared Server error. | +| DSH reuse design | Pass for Phase 3 design scope | [`docs/develop/dsh-reuse.md`](../develop/dsh-reuse.md) moves generic transport to the official Client, preserves host policy, and aligns the plugin engine to `>=22 <25`. Python-repository implementation remains a later acceptance item. | +| Release docs and compatibility | Pass | [`packages/client/README.md`](../../packages/client/README.md) provides install, quickstart, compatibility, transport, errors, and DSH boundaries. | +| Clean, no-native tarball | Pass locally | `pnpm pack:smoke` builds and installs the packed Protocol and Client in a clean temporary project and rejects native dependencies or unexpected tarball files. | + +## Release decision + +The Phase 3 implementation and local acceptance gates are complete for the +M1 release candidate. This review does not substitute for CI on the final +commit and does not claim that npm publication has occurred. M1 may be +published only after the commit-bound gates above are green. diff --git a/docs/roadmap.md b/docs/roadmap.md index 0074a85..2763622 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -14,7 +14,7 @@ The 2026-08-23 construction study is background only. It is not the tracker. | --- | --- | | Protocol types, 52 operation contracts, runtime validators | Done | | C1 wire and canonical conformance fixtures | Done | -| Official typed HTTP Client transport | Not started | +| Official typed HTTP Client transport | M1 release candidate (`client` / C1); see [exit review](reviews/m1-client-exit-review.md) | | Deterministic Core, SQLite Runtime, Server, MCP, CLI | Not started | Python remains the reference implementation and semantic oracle. @@ -75,7 +75,8 @@ the RFC ledger. 2. Issues for the workstreams above. Labels name the area (`client`, `core`, `persistence`, `conformance`), not a construction phase. 3. Closing a milestone updates [Current status](user/README.md) and - [CHANGELOG.md](../CHANGELOG.md). Do not add stage-exit review folders. + [CHANGELOG.md](../CHANGELOG.md), and records the applicable evidence in an + auditable [exit review](reviews/m1-client-exit-review.md). ## Background diff --git a/docs/user/README.md b/docs/user/README.md index 5da776c..7b657f8 100644 --- a/docs/user/README.md +++ b/docs/user/README.md @@ -5,13 +5,19 @@ reference and semantic oracle. | Surface | Status | | --- | --- | -| Protocol types, operation metadata, and runtime validators | Available (`client` / C1 foundation) | -| Official typed HTTP Client transport | Not shipped | +| Protocol types, operation metadata, and runtime validators | Available (`client` / C1) | +| Official typed HTTP Client transport | M1 release candidate (`client` / C1) | | SQLite / OceanBase Runtime | Not shipped | | HTTP Server, MCP, CLI, Dashboard | Not shipped | -Installable packages currently publish packable skeletons. Protocol and Client -tarballs must install without compiling a native addon. +`@powercontext/client` is the first independently packable product. Install it, import +`PowerContextClient`, and call operations against a compliant Server. Protocol +and Client tarballs must install without compiling a native addon. + +See [Official typed Client](client.md). + +The auditable acceptance record is the +[M1 Client exit review](../reviews/m1-client-exit-review.md). See [Compatibility policy](../policies/compatibility.md) for what each profile may claim. diff --git a/docs/user/client.md b/docs/user/client.md new file mode 100644 index 0000000..48b9fa7 --- /dev/null +++ b/docs/user/client.md @@ -0,0 +1,23 @@ +# Official typed Client + +`@powercontext/client` is the M1 product. It is a Fetch-based typed HTTP +Client for the pinned OpenAPI snapshot (52 operations). Install it in a clean +project, import `PowerContextClient`, and call operations against a compliant +Server. + +This package claims **`client` / C1** only. It is not a local Runtime, SQLite +database, MCP server, CLI, or full-product replacement for Python. + +## Support matrix + +| Surface | Claim | +| --- | --- | +| Protocol types and runtime validators | Available | +| Typed methods for 52 operations | Available | +| TypeScript Client → Python Server | C1 wire parity | +| Node 22 and Node 24 LTS | Supported | +| Node 20 | Not supported | +| Native addons on install | None | + +See the [package README](../../packages/client/README.md) for the quickstart +and transport rules. diff --git a/package.json b/package.json index 9125fbe..7766291 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "powercontext-ts", "private": true, "version": "0.0.0", - "description": "TypeScript PowerContext workspace. Public package names are placeholders until npm org confirmation.", + "description": "TypeScript PowerContext workspace for the official Client and parity implementation.", "license": "Apache-2.0", "type": "module", "packageManager": "pnpm@10.33.2", diff --git a/packages/client/README.md b/packages/client/README.md index d138832..f63ef75 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -1,6 +1,96 @@ # @powercontext/client -Official typed HTTP Client. This is the first publishable product (M1). +Official typed HTTP Client for PowerContext. This is the first publishable +product (`client` / C1). It talks to any compliant Server, including the +pinned Python reference. It does not ship a Runtime, SQLite, MCP server, or +host approval policy. -The current package is a packable skeleton. Transport and typed methods are -not shipped yet. Installing this package must not compile a native addon. +Node 22 and Node 24 LTS are supported. Node 20 is not. + +## Install + +```text +pnpm add @powercontext/client +``` + +The package depends only on `@powercontext/protocol` and the Fetch API. A +clean install must not compile a native addon. + +## Quickstart + +```ts +import { PowerContextClient } from '@powercontext/client' + +const client = new PowerContextClient({ + baseUrl: 'http://127.0.0.1:8000', + token: process.env.POWERCONTEXT_TOKEN, +}) + +const live = await client.get_liveness() +const remembered = await client.remember_memory({ + scope_id: 'project:demo', + kind: 'decision', + text: 'Keep the official Client on the public HTTP contract.', +}) +const report = await client.get_handoff_report({ + project_id: 'project-1', + format: 'markdown', +}) +const file = await client.download_handoff_report({ project_id: 'project-1' }) + +void live +void remembered +void report +void file +``` + +Generic operation-id calls are available for host plugins: + +```ts +const result = await client.request('search_memory', { + scope_id: 'project:demo', + query: 'official Client', +}) +``` + +## Compatibility + +| Client | Server | What this package claims | +| --- | --- | --- | +| TypeScript `@powercontext/client` | Python Server (pinned baseline) | `client` / C1 wire parity | +| TypeScript `@powercontext/client` | TypeScript Server | later milestone | +| Python Client | TypeScript Server | later milestone | + +This package does **not** claim `sqlite-fts`, `full-product`, or C5. + +| Runtime | Status | +| --- | --- | +| Node 22 LTS | Supported and CI-tested | +| Node 24 LTS | Supported and CI-tested | +| Node 20 | Unsupported (EOL). DSH hosts must use the Client range `>=22 <25`. | + +## Transport + +- Base URL normalization; credentials, fragments, and query tokens are rejected +- `Authorization` and `User-Agent: @powercontext/client/` +- Server `X-PowerContext-Request-ID` is captured on success and errors +- Caller `AbortSignal` combined with a timeout; listeners and timers are cleared +- Redirects are rejected (`redirect: 'manual'`) +- Response bodies are bounded (1 MiB by default) +- JSON, Markdown/text, and download bytes +- Success bodies are validated against the pinned OpenAPI snapshot +- Optional tracing hook; the OpenTelemetry SDK is not a dependency + +## Errors + +`ClientError` is the base. Transport failures are `UnavailableError` +(`TransportError`). Schema or redirect failures are `InvalidResponseError`. +Any HTTP status not declared as a success status for that operation is a +`ServerResponseError`, including an undeclared 2xx. Unknown operation ids are +`UnknownOperationError`. + +## DSH reuse + +Host-specific scope, approval, secret guard, and fail-open behavior stay in +the Python-repository DSH plugin. See +[docs/develop/dsh-reuse.md](../../docs/develop/dsh-reuse.md). diff --git a/packages/client/src/body.ts b/packages/client/src/body.ts new file mode 100644 index 0000000..47dbb3e --- /dev/null +++ b/packages/client/src/body.ts @@ -0,0 +1,70 @@ +/** + * Copyright (c) 2026 OceanBase. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { MAX_RESPONSE_BYTES } from './constants.js' +import { InvalidResponseError } from './errors.js' + +function responsePath(response: Response): string { + try { + return response.url === '' ? '/' : new URL(response.url).pathname + } catch { + return '/' + } +} + +function concatBytes(chunks: Uint8Array[], total: number): Uint8Array { + const out = new Uint8Array(total) + let offset = 0 + for (const chunk of chunks) { + out.set(chunk, offset) + offset += chunk.byteLength + } + return out +} + +export async function readLimitedBody( + response: Response, + maxBytes = MAX_RESPONSE_BYTES, +): Promise { + const path = responsePath(response) + if (response.body === null) { + const buffer = new Uint8Array(await response.arrayBuffer()) + if (buffer.byteLength > maxBytes) { + throw new InvalidResponseError(path) + } + return buffer + } + const reader = response.body.getReader() + const chunks: Uint8Array[] = [] + let total = 0 + while (true) { + const { done, value } = await reader.read() + if (done) { + break + } + total += value.byteLength + if (total > maxBytes) { + await reader.cancel() + throw new InvalidResponseError(path) + } + chunks.push(value) + } + return concatBytes(chunks, total) +} + +export function decodeUtf8(bytes: Uint8Array): string { + return new TextDecoder('utf-8', { fatal: true }).decode(bytes) +} diff --git a/packages/client/src/call-options.ts b/packages/client/src/call-options.ts new file mode 100644 index 0000000..a05a1bd --- /dev/null +++ b/packages/client/src/call-options.ts @@ -0,0 +1,21 @@ +/** + * Copyright (c) 2026 OceanBase. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export interface CallOptions { + readonly signal?: AbortSignal + readonly timeoutMs?: number + readonly headers?: Readonly> +} diff --git a/packages/client/src/client.ts b/packages/client/src/client.ts new file mode 100644 index 0000000..af4dc9e --- /dev/null +++ b/packages/client/src/client.ts @@ -0,0 +1,140 @@ +/** + * Copyright (c) 2026 OceanBase. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { getOperationContract, type OperationId } from '@powercontext/protocol' +import { readLimitedBody } from './body.js' +import { isRedirectStatus } from './content-type.js' +import { InvalidResponseError } from './errors.js' +import { createTypedMethods } from './methods.js' +import type { OperationRequest, TypedClientMethods } from './operation-types.js' +import { resolveClientOptions } from './options.js' +import { operationPath, prepareRequest, resolveOperationId } from './request-prepare.js' +import { parseSuccessResponse, serverErrorFromResponse } from './response.js' +import { createRequestSignal } from './signals.js' +import { finishSpan, injectSpan, outcomeFromError, startSpan } from './tracing.js' +import { sendRequest, wrapTransportError } from './transport.js' +import type { + CallOptions, + ClientOptions, + ClientSuccess, + ResolvedClientOptions, +} from './types.js' + +class PowerContextClientImpl { + private readonly options: ResolvedClientOptions + + constructor(options: ClientOptions) { + this.options = resolveClientOptions(options) + Object.assign(this, createTypedMethods(this.invoke)) + } + + async request( + operationId: Id | string, + payload?: OperationRequest, + options?: CallOptions, + ): Promise> { + const id = resolveOperationId(operationId) + const timeoutMs = options?.timeoutMs ?? this.options.timeoutMs + const requestSignal = createRequestSignal(timeoutMs, options?.signal) + const span = startSpan(this.options.tracer, id) + try { + return (await this.execute( + id, + payload, + options, + requestSignal.signal, + span, + )) as ClientSuccess + } finally { + requestSignal.dispose() + } + } + + async download_handoff_report( + request: OperationRequest<'get_handoff_report'>, + options?: CallOptions, + ): Promise { + const payload = { ...(request as Record), download: true } + const result = await this.request('get_handoff_report', payload as never, options) + if (result.kind !== 'bytes') { + throw new InvalidResponseError( + operationPath('get_handoff_report'), + result.requestId, + ) + } + return result.value + } + + private readonly invoke = ( + operationId: OperationId, + payload: unknown, + options?: CallOptions, + ): Promise => this.request(operationId, payload as never, options) + + private async execute( + operationId: OperationId, + payload: unknown, + options: CallOptions | undefined, + signal: AbortSignal, + span: ReturnType, + ): Promise { + const prepared = prepareRequest(this.options, operationId, payload, options, signal) + injectSpan(span, prepared.init.headers as Record) + try { + const response = await sendRequest( + this.options.fetch, + prepared.url, + prepared.init, + ) + const result = await this.readResponse( + prepared.path, + operationId, + prepared.payload, + response, + ) + finishSpan(span, 'success', { status: result.status }) + return result + } catch (error) { + finishSpan(span, outcomeFromError(error, options?.signal), { error }) + throw wrapTransportError(prepared.path, error) + } + } + + private async readResponse( + path: string, + operationId: OperationId, + payload: Record | undefined, + response: Response, + ): Promise { + if (isRedirectStatus(response.status)) { + throw new InvalidResponseError(path) + } + const bytes = await readLimitedBody(response, this.options.maxResponseBytes) + const declaredSuccess = getOperationContract(operationId).success.some( + (media) => media.status === response.status, + ) + if (!declaredSuccess) { + throw serverErrorFromResponse(response, bytes) + } + return parseSuccessResponse(operationId, path, payload, response, bytes) + } +} + +export type PowerContextClient = PowerContextClientImpl & TypedClientMethods + +export const PowerContextClient = PowerContextClientImpl as { + new (options: ClientOptions): PowerContextClient +} diff --git a/packages/client/src/constants.ts b/packages/client/src/constants.ts new file mode 100644 index 0000000..84cc43a --- /dev/null +++ b/packages/client/src/constants.ts @@ -0,0 +1,22 @@ +/** + * Copyright (c) 2026 OceanBase. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { PACKAGE_NAME, PACKAGE_VERSION } from './package-info.js' + +export const REQUEST_ID_HEADER = 'X-PowerContext-Request-ID' +export const MAX_RESPONSE_BYTES = 1_048_576 +export const DEFAULT_TIMEOUT_MS = 10_000 +export const CLIENT_USER_AGENT = `${PACKAGE_NAME}/${PACKAGE_VERSION}` diff --git a/packages/client/src/content-type.ts b/packages/client/src/content-type.ts new file mode 100644 index 0000000..097e37c --- /dev/null +++ b/packages/client/src/content-type.ts @@ -0,0 +1,49 @@ +/** + * Copyright (c) 2026 OceanBase. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export function mediaType(contentType: string | null): string { + if (contentType === null || contentType.trim() === '') { + return '' + } + return contentType.split(';', 1)[0]?.trim().toLowerCase() ?? '' +} + +export function acceptForOperation( + operationId: string, + payload: Record | undefined, +): string { + if (operationId !== 'get_handoff_report') { + return 'application/json' + } + if (payload?.['download'] === true) { + return 'application/octet-stream, text/markdown, application/json' + } + if (payload?.['format'] === 'json') { + return 'application/json' + } + return 'text/markdown' +} + +export function isDownloadRequest( + operationId: string, + payload: Record | undefined, +): boolean { + return operationId === 'get_handoff_report' && payload?.['download'] === true +} + +export function isRedirectStatus(status: number): boolean { + return status >= 300 && status < 400 +} diff --git a/packages/client/src/errors.ts b/packages/client/src/errors.ts new file mode 100644 index 0000000..c788d67 --- /dev/null +++ b/packages/client/src/errors.ts @@ -0,0 +1,94 @@ +/** + * Copyright (c) 2026 OceanBase. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export class ClientError extends Error { + readonly requestId: string | undefined + + constructor(message: string, requestId?: string | undefined) { + super(message) + this.name = new.target.name + this.requestId = requestId + } +} + +export class TransportError extends ClientError { + readonly path: string + + constructor(path: string, cause?: unknown, requestId?: string) { + super(`request to ${path} failed`, requestId) + this.path = path + this.cause = cause + } +} + +export class UnavailableError extends TransportError {} + +export class InvalidRequestError extends ClientError { + readonly operationId: string + + constructor(operationId: string, detail?: string) { + const suffix = detail === undefined ? '' : `: ${detail}` + super(`request for ${operationId} is invalid${suffix}`) + this.operationId = operationId + } +} + +export class InvalidResponseError extends ClientError { + readonly path: string + + constructor(path: string, requestId?: string | undefined) { + super(`response from ${path} violated the API schema`, requestId) + this.path = path + } +} + +export class UnknownOperationError extends ClientError { + readonly operationId: string + + constructor(operationId: string) { + super(`unknown PowerContext operation: ${operationId}`) + this.operationId = operationId + } +} + +export class ServerResponseError extends ClientError { + readonly statusCode: number + readonly code: string | undefined + readonly serverMessage: string | undefined + readonly details: Record | null | undefined + + constructor(options: { + statusCode: number + requestId?: string | undefined + code?: string | undefined + message?: string | undefined + details?: Record | null | undefined + }) { + const suffix = options.code === undefined ? '' : ` (${options.code})` + super( + `PowerContext Server returned HTTP ${String(options.statusCode)}${suffix}`, + options.requestId, + ) + this.statusCode = options.statusCode + this.code = options.code + this.serverMessage = options.message + this.details = options.details + } +} + +export function isClientError(error: unknown): error is ClientError { + return error instanceof ClientError +} diff --git a/packages/client/src/headers.ts b/packages/client/src/headers.ts new file mode 100644 index 0000000..9aae637 --- /dev/null +++ b/packages/client/src/headers.ts @@ -0,0 +1,50 @@ +/** + * Copyright (c) 2026 OceanBase. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { ClientOptions } from './types.js' + +export function resolveAuthorization(options: ClientOptions): string | undefined { + if (options.authorization !== undefined) { + return options.authorization + } + if (options.token !== undefined) { + return `Bearer ${options.token}` + } + return undefined +} + +export function buildRequestHeaders(input: { + accept: string + userAgent: string + authorization?: string | undefined + contentType?: string | undefined + extra?: Readonly> | undefined +}): Record { + const headers: Record = { + Accept: input.accept, + 'User-Agent': input.userAgent, + } + if (input.authorization !== undefined) { + headers['Authorization'] = input.authorization + } + if (input.contentType !== undefined) { + headers['Content-Type'] = input.contentType + } + if (input.extra !== undefined) { + Object.assign(headers, input.extra) + } + return headers +} diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 41c5ce4..9356534 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -14,8 +14,44 @@ * limitations under the License. */ -export const PACKAGE_NAME = '@powercontext/client' as const -export const PACKAGE_VERSION = '0.0.0' as const -export const PACKAGE_ROLE = 'client' as const -export const PACKAGE_PROFILE = 'client' as const -export const NATIVE_DEPENDENCIES = Object.freeze([]) +export { PowerContextClient } from './client.js' +export { + CLIENT_USER_AGENT, + DEFAULT_TIMEOUT_MS, + MAX_RESPONSE_BYTES, + REQUEST_ID_HEADER, +} from './constants.js' +export { + ClientError, + InvalidRequestError, + InvalidResponseError, + ServerResponseError, + TransportError, + UnavailableError, + UnknownOperationError, + isClientError, +} from './errors.js' +export type { + OperationJsonSuccess, + OperationMethod, + OperationRequest, + OperationResult, + TypedClientMethods, +} from './operation-types.js' +export { + NATIVE_DEPENDENCIES, + PACKAGE_NAME, + PACKAGE_PROFILE, + PACKAGE_ROLE, + PACKAGE_VERSION, +} from './package-info.js' +export type { + CallOptions, + ClientOptions, + ClientSpanHandle, + ClientSuccess, + ClientTraceDetails, + ClientTraceOutcome, + ClientTracer, + FetchFn, +} from './types.js' diff --git a/packages/client/src/methods.ts b/packages/client/src/methods.ts new file mode 100644 index 0000000..7a49140 --- /dev/null +++ b/packages/client/src/methods.ts @@ -0,0 +1,87 @@ +/** + * Copyright (c) 2026 OceanBase. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { listOperationIds, type OperationId } from '@powercontext/protocol' +import { ClientError, InvalidResponseError } from './errors.js' +import type { OperationResult, TypedClientMethods } from './operation-types.js' +import { operationPath } from './request-prepare.js' +import type { CallOptions, ClientSuccess } from './types.js' + +export type TypedInvoke = ( + operationId: OperationId, + payload: unknown, + options?: CallOptions, +) => Promise + +function isDownloadPayload(payload: unknown): boolean { + return ( + typeof payload === 'object' && + payload !== null && + 'download' in payload && + payload.download === true + ) +} + +export function unwrapTypedResult( + operationId: Id, + result: ClientSuccess, +): OperationResult { + if (operationId === 'get_handoff_report') { + if (result.kind === 'bytes') { + throw new InvalidResponseError(operationPath(operationId), result.requestId) + } + return result.value as OperationResult + } + if (result.kind !== 'json') { + throw new InvalidResponseError(operationPath(operationId), result.requestId) + } + return result.value as OperationResult +} + +export function createTypedMethods(invoke: TypedInvoke): TypedClientMethods { + const methods = {} as TypedClientMethods + for (const operationId of listOperationIds()) { + Object.assign(methods, { + [operationId]: (payloadOrOptions?: unknown, maybeOptions?: CallOptions) => { + const hasRequest = + payloadOrOptions !== undefined && + !isCallOptionsOnly(operationId, payloadOrOptions) + const payload = hasRequest ? payloadOrOptions : undefined + const options = hasRequest + ? maybeOptions + : (payloadOrOptions as CallOptions | undefined) + if (operationId === 'get_handoff_report' && isDownloadPayload(payload)) { + throw new ClientError('use download_handoff_report when download is true') + } + return invoke(operationId, payload, options).then((result) => + unwrapTypedResult(operationId, result), + ) + }, + }) + } + return methods +} + +function isCallOptionsOnly(operationId: OperationId, value: unknown): boolean { + if ( + operationId !== 'get_liveness' && + operationId !== 'get_readiness' && + operationId !== 'get_capabilities' + ) { + return false + } + return typeof value === 'object' && value !== null && !Array.isArray(value) +} diff --git a/packages/client/src/operation-types.ts b/packages/client/src/operation-types.ts new file mode 100644 index 0000000..75a9662 --- /dev/null +++ b/packages/client/src/operation-types.ts @@ -0,0 +1,68 @@ +/** + * Copyright (c) 2026 OceanBase. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { OperationId, operations } from '@powercontext/protocol' +import type { CallOptions } from './call-options.js' + +type JsonBody = operations[Id] extends { + requestBody: { content: { 'application/json': infer Body } } +} + ? Body + : never + +type QueryParams = operations[Id] extends { + parameters: { query: infer Query } +} + ? [Query] extends [never] + ? never + : Query + : never + +export type OperationRequest = [JsonBody] extends [never] + ? [QueryParams] extends [never] + ? undefined + : QueryParams + : JsonBody + +type SuccessStatus = 200 | 201 | 202 + +type JsonFromStatus = Status extends keyof Response + ? Response[Status] extends { content: { 'application/json': infer Json } } + ? Json + : never + : never + +export type OperationJsonSuccess = JsonFromStatus< + operations[Id]['responses'], + SuccessStatus +> + +export type OperationResult = Id extends 'get_handoff_report' + ? OperationJsonSuccess | string + : OperationJsonSuccess + +export type OperationMethod = [OperationRequest] extends [ + undefined, +] + ? (options?: CallOptions) => Promise> + : ( + request: OperationRequest, + options?: CallOptions, + ) => Promise> + +export type TypedClientMethods = { + [Id in OperationId]: OperationMethod +} diff --git a/packages/client/src/options.ts b/packages/client/src/options.ts new file mode 100644 index 0000000..a46c92a --- /dev/null +++ b/packages/client/src/options.ts @@ -0,0 +1,53 @@ +/** + * Copyright (c) 2026 OceanBase. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + CLIENT_USER_AGENT, + DEFAULT_TIMEOUT_MS, + MAX_RESPONSE_BYTES, +} from './constants.js' +import { ClientError } from './errors.js' +import { resolveAuthorization } from './headers.js' +import type { ClientOptions, ResolvedClientOptions } from './types.js' +import { normalizeBaseUrl } from './url.js' + +function resolveTimeout(timeoutMs: number | undefined): number { + const value = timeoutMs ?? DEFAULT_TIMEOUT_MS + if (!Number.isInteger(value) || value <= 0) { + throw new ClientError('timeoutMs must be a positive integer') + } + return value +} + +function resolveMaxBytes(maxBytes: number | undefined): number { + const value = maxBytes ?? MAX_RESPONSE_BYTES + if (!Number.isInteger(value) || value <= 0) { + throw new ClientError('maxResponseBytes must be a positive integer') + } + return value +} + +export function resolveClientOptions(options: ClientOptions): ResolvedClientOptions { + return { + baseUrl: normalizeBaseUrl(options.baseUrl), + authorization: resolveAuthorization(options), + timeoutMs: resolveTimeout(options.timeoutMs), + fetch: options.fetch ?? fetch, + maxResponseBytes: resolveMaxBytes(options.maxResponseBytes), + userAgent: options.userAgent ?? CLIENT_USER_AGENT, + tracer: options.tracer, + } +} diff --git a/packages/client/src/package-info.ts b/packages/client/src/package-info.ts new file mode 100644 index 0000000..41c5ce4 --- /dev/null +++ b/packages/client/src/package-info.ts @@ -0,0 +1,21 @@ +/** + * Copyright (c) 2026 OceanBase. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export const PACKAGE_NAME = '@powercontext/client' as const +export const PACKAGE_VERSION = '0.0.0' as const +export const PACKAGE_ROLE = 'client' as const +export const PACKAGE_PROFILE = 'client' as const +export const NATIVE_DEPENDENCIES = Object.freeze([]) diff --git a/packages/client/src/request-prepare.ts b/packages/client/src/request-prepare.ts new file mode 100644 index 0000000..5c97230 --- /dev/null +++ b/packages/client/src/request-prepare.ts @@ -0,0 +1,104 @@ +/** + * Copyright (c) 2026 OceanBase. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + OPERATION_METADATA, + listOperationIds, + validateOperationRequest, + type OperationId, +} from '@powercontext/protocol' +import { acceptForOperation } from './content-type.js' +import { InvalidRequestError, UnknownOperationError } from './errors.js' +import { buildRequestHeaders } from './headers.js' +import type { CallOptions, ResolvedClientOptions } from './types.js' +import { buildRequestUrl } from './url.js' + +export interface PreparedRequest { + readonly operationId: OperationId + readonly path: string + readonly url: string + readonly init: RequestInit + readonly payload: Record | undefined +} + +function asPayload( + operationId: OperationId, + value: unknown, +): Record | undefined { + if (value === undefined) { + return undefined + } + if (typeof value === 'object' && value !== null && !Array.isArray(value)) { + return value as Record + } + throw new InvalidRequestError(operationId, 'payload must be a JSON object') +} + +export function resolveOperationId(operationId: string): OperationId { + if (!listOperationIds().includes(operationId as OperationId)) { + throw new UnknownOperationError(operationId) + } + return operationId as OperationId +} + +export function validateRequestPayload( + operationId: OperationId, + payload: unknown, +): void { + const result = validateOperationRequest(operationId, payload) + if (!result.valid) { + throw new InvalidRequestError(operationId) + } +} + +export function prepareRequest( + options: ResolvedClientOptions, + operationId: OperationId, + payload: unknown, + call: CallOptions | undefined, + signal: AbortSignal, +): PreparedRequest { + const metadata = OPERATION_METADATA[operationId] + const record = asPayload(operationId, payload) + validateRequestPayload(operationId, payload) + const headers = buildRequestHeaders({ + accept: acceptForOperation(operationId, record), + userAgent: options.userAgent, + authorization: options.authorization, + contentType: metadata.location === 'body' ? 'application/json' : undefined, + extra: call?.headers, + }) + const init: RequestInit = { + method: metadata.method, + headers, + redirect: 'manual', + signal, + } + if (metadata.location === 'body') { + init.body = JSON.stringify(record ?? {}) + } + return { + operationId, + path: metadata.path, + url: buildRequestUrl(options.baseUrl, metadata.path, metadata.location, record), + init, + payload: record, + } +} + +export function operationPath(operationId: OperationId): string { + return OPERATION_METADATA[operationId].path +} diff --git a/packages/client/src/response.ts b/packages/client/src/response.ts new file mode 100644 index 0000000..bb4ff52 --- /dev/null +++ b/packages/client/src/response.ts @@ -0,0 +1,152 @@ +/** + * Copyright (c) 2026 OceanBase. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + findUnsafeIntegerTokens, + validateOperationSuccess, + validateWireValue, +} from '@powercontext/protocol' +import { decodeUtf8 } from './body.js' +import { REQUEST_ID_HEADER } from './constants.js' +import { isDownloadRequest, mediaType } from './content-type.js' +import { InvalidResponseError, ServerResponseError } from './errors.js' +import type { ClientSuccess } from './types.js' + +function requestIdOf(response: Response): string | undefined { + return response.headers.get(REQUEST_ID_HEADER) ?? undefined +} + +function parseJsonValue(text: string): unknown { + const unsafe = findUnsafeIntegerTokens(text) + if (unsafe.length > 0) { + throw new Error(`unsafe JSON integer token: ${unsafe.join(', ')}`) + } + return JSON.parse(text) as unknown +} + +function decodeErrorBody(bytes: Uint8Array): { + code?: string | undefined + message?: string | undefined + details?: Record | null | undefined +} { + try { + const parsed = parseJsonValue(decodeUtf8(bytes)) + const result = validateWireValue('ErrorResponse', parsed) + if (!result.valid || typeof parsed !== 'object' || parsed === null) { + return {} + } + const error = (parsed as { error?: Record }).error + if (error === undefined) { + return {} + } + return { + code: typeof error['code'] === 'string' ? error['code'] : undefined, + message: typeof error['message'] === 'string' ? error['message'] : undefined, + details: + error['details'] === null || + (typeof error['details'] === 'object' && error['details'] !== null) + ? (error['details'] as Record | null) + : undefined, + } + } catch { + return {} + } +} + +export function serverErrorFromResponse( + response: Response, + bytes: Uint8Array, +): ServerResponseError { + const decoded = decodeErrorBody(bytes) + return new ServerResponseError({ + statusCode: response.status, + requestId: requestIdOf(response), + code: decoded.code, + message: decoded.message, + details: decoded.details, + }) +} + +function validatedJson( + operationId: string, + status: number, + contentType: string, + path: string, + requestId: string | undefined, + bytes: Uint8Array, +): unknown { + try { + const value = parseJsonValue(decodeUtf8(bytes)) + const result = validateOperationSuccess(operationId, status, contentType, value) + if (!result.valid) { + throw new InvalidResponseError(path, requestId) + } + return value + } catch (error) { + if (error instanceof InvalidResponseError) { + throw error + } + throw new InvalidResponseError(path, requestId) + } +} + +export function parseSuccessResponse( + operationId: string, + path: string, + payload: Record | undefined, + response: Response, + bytes: Uint8Array, +): ClientSuccess { + const requestId = requestIdOf(response) + const contentType = mediaType(response.headers.get('content-type')) + if (isDownloadRequest(operationId, payload)) { + return { kind: 'bytes', value: bytes, status: response.status, requestId } + } + if (contentType === 'text/markdown' || contentType === 'text/plain') { + let text: string + try { + text = decodeUtf8(bytes) + } catch { + throw new InvalidResponseError(path, requestId) + } + const result = validateOperationSuccess( + operationId, + response.status, + contentType, + text, + ) + if (!result.valid) { + throw new InvalidResponseError(path, requestId) + } + return { kind: 'text', value: text, status: response.status, requestId } + } + if (contentType !== 'application/json') { + throw new InvalidResponseError(path, requestId) + } + return { + kind: 'json', + value: validatedJson( + operationId, + response.status, + contentType, + path, + requestId, + bytes, + ), + status: response.status, + requestId, + } +} diff --git a/packages/client/src/signals.ts b/packages/client/src/signals.ts new file mode 100644 index 0000000..5ce22e1 --- /dev/null +++ b/packages/client/src/signals.ts @@ -0,0 +1,70 @@ +/** + * Copyright (c) 2026 OceanBase. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export interface RequestSignal { + readonly signal: AbortSignal + dispose(): void +} + +function timeoutError(): DOMException { + return new DOMException('The operation timed out.', 'TimeoutError') +} + +export function createRequestSignal( + timeoutMs: number, + caller?: AbortSignal, +): RequestSignal { + const controller = new AbortController() + const listeners: Array<{ signal: AbortSignal; handler: () => void }> = [] + const timer = setTimeout(() => { + if (!controller.signal.aborted) { + controller.abort(timeoutError()) + } + }, timeoutMs) + + if (caller !== undefined) { + if (caller.aborted) { + clearTimeout(timer) + controller.abort(caller.reason) + } else { + const handler = (): void => { + if (!controller.signal.aborted) { + controller.abort(caller.reason) + } + } + caller.addEventListener('abort', handler, { once: true }) + listeners.push({ signal: caller, handler }) + } + } + + return { + signal: controller.signal, + dispose(): void { + clearTimeout(timer) + for (const { signal, handler } of listeners) { + signal.removeEventListener('abort', handler) + } + }, + } +} + +export function isTimeoutError(error: unknown): boolean { + return error instanceof Error && error.name === 'TimeoutError' +} + +export function isAbortError(error: unknown): boolean { + return error instanceof Error && error.name === 'AbortError' +} diff --git a/packages/client/src/tracing.ts b/packages/client/src/tracing.ts new file mode 100644 index 0000000..6d871ee --- /dev/null +++ b/packages/client/src/tracing.ts @@ -0,0 +1,77 @@ +/** + * Copyright (c) 2026 OceanBase. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { + ClientSpanHandle, + ClientTraceDetails, + ClientTraceOutcome, + ClientTracer, +} from './types.js' + +const noopSpan: ClientSpanHandle = { + inject(): void {}, + finish(): void {}, +} + +export function startSpan( + tracer: ClientTracer | undefined, + operationId: string, +): ClientSpanHandle { + if (tracer === undefined) { + return noopSpan + } + try { + return tracer.start(operationId) + } catch { + return noopSpan + } +} + +export function injectSpan( + span: ClientSpanHandle, + headers: Record, +): void { + try { + span.inject(headers) + } catch { + // Tracing must never fail the request. + } +} + +export function finishSpan( + span: ClientSpanHandle, + outcome: ClientTraceOutcome, + details?: ClientTraceDetails, +): void { + try { + span.finish(outcome, details) + } catch { + // Tracing must never fail the request. + } +} + +export function outcomeFromError( + error: unknown, + signal?: AbortSignal, +): ClientTraceOutcome { + if ( + signal?.aborted === true && + !(error instanceof Error && error.name === 'TimeoutError') + ) { + return 'cancelled' + } + return 'failure' +} diff --git a/packages/client/src/transport.ts b/packages/client/src/transport.ts new file mode 100644 index 0000000..f9625b5 --- /dev/null +++ b/packages/client/src/transport.ts @@ -0,0 +1,64 @@ +/** + * Copyright (c) 2026 OceanBase. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ClientError, UnavailableError } from './errors.js' +import { isAbortError, isTimeoutError } from './signals.js' +import type { FetchFn } from './types.js' + +export function wrapTransportError(path: string, error: unknown): UnavailableError { + if (error instanceof ClientError) { + throw error + } + return new UnavailableError(path, error) +} + +export function isTransportFailure(error: unknown): boolean { + return isTimeoutError(error) || isAbortError(error) +} + +function abortReason(signal: AbortSignal): unknown { + return signal.reason ?? new DOMException('The operation was aborted.', 'AbortError') +} + +export async function sendRequest( + fetchImpl: FetchFn, + url: string, + init: RequestInit, +): Promise { + const signal = init.signal + if (signal === undefined || signal === null) { + return await fetchImpl(url, init) + } + if (signal.aborted) { + throw abortReason(signal) + } + return await new Promise((resolve, reject) => { + const onAbort = (): void => { + reject(abortReason(signal)) + } + signal.addEventListener('abort', onAbort, { once: true }) + fetchImpl(url, init).then( + (response) => { + signal.removeEventListener('abort', onAbort) + resolve(response) + }, + (error: unknown) => { + signal.removeEventListener('abort', onAbort) + reject(error) + }, + ) + }) +} diff --git a/packages/client/src/types.ts b/packages/client/src/types.ts new file mode 100644 index 0000000..fc2c858 --- /dev/null +++ b/packages/client/src/types.ts @@ -0,0 +1,78 @@ +/** + * Copyright (c) 2026 OceanBase. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { OperationId } from '@powercontext/protocol' +import type { CallOptions } from './call-options.js' + +export type { CallOptions } + +export type FetchFn = (input: string, init: RequestInit) => Promise + +export type ClientTraceOutcome = 'success' | 'failure' | 'cancelled' + +export interface ClientTraceDetails { + readonly status?: number + readonly error?: unknown +} + +export interface ClientSpanHandle { + inject(headers: Record): void + finish(outcome: ClientTraceOutcome, details?: ClientTraceDetails): void +} + +export interface ClientTracer { + start(operationId: string): ClientSpanHandle +} + +export interface ClientOptions { + readonly baseUrl: string + readonly authorization?: string + readonly token?: string + readonly timeoutMs?: number + readonly fetch?: FetchFn + readonly maxResponseBytes?: number + readonly userAgent?: string + readonly tracer?: ClientTracer +} + +export interface ResolvedClientOptions { + readonly baseUrl: string + readonly authorization: string | undefined + readonly timeoutMs: number + readonly fetch: FetchFn + readonly maxResponseBytes: number + readonly userAgent: string + readonly tracer: ClientTracer | undefined +} + +export interface ClientSuccessBase { + readonly status: number + readonly requestId: string | undefined +} + +export type ClientSuccess<_Id extends OperationId = OperationId> = + | ({ + readonly kind: 'json' + readonly value: unknown + } & ClientSuccessBase) + | ({ + readonly kind: 'text' + readonly value: string + } & ClientSuccessBase) + | ({ + readonly kind: 'bytes' + readonly value: Uint8Array + } & ClientSuccessBase) diff --git a/packages/client/src/url.ts b/packages/client/src/url.ts new file mode 100644 index 0000000..79fb409 --- /dev/null +++ b/packages/client/src/url.ts @@ -0,0 +1,63 @@ +/** + * Copyright (c) 2026 OceanBase. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ClientError } from './errors.js' + +export function normalizeBaseUrl(value: string): string { + const trimmed = value.trim() + let parsed: URL + try { + parsed = new URL(trimmed) + } catch { + throw new ClientError('base URL must be an absolute http(s) URL') + } + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + throw new ClientError('base URL must use http or https') + } + if (parsed.username !== '' || parsed.password !== '') { + throw new ClientError('base URL must not include credentials') + } + if (parsed.hash !== '') { + throw new ClientError('base URL must not include a fragment') + } + if (parsed.search !== '') { + throw new ClientError('base URL must not include a query string') + } + const path = parsed.pathname.replace(/\/+$/u, '') + return `${parsed.origin}${path}` +} + +export function queryString(payload: Record | undefined): string { + const params = new URLSearchParams() + for (const [key, value] of Object.entries(payload ?? {})) { + if (value === undefined || value === null) { + continue + } + params.set(key, String(value)) + } + const encoded = params.toString() + return encoded === '' ? '' : `?${encoded}` +} + +export function buildRequestUrl( + baseUrl: string, + path: string, + location: 'body' | 'query' | null, + payload: Record | undefined, +): string { + const suffix = location === 'query' ? queryString(payload) : '' + return `${baseUrl}${path}${suffix}` +} diff --git a/packages/client/tests/call-through.test.ts b/packages/client/tests/call-through.test.ts new file mode 100644 index 0000000..f13b9bc --- /dev/null +++ b/packages/client/tests/call-through.test.ts @@ -0,0 +1,122 @@ +/** + * Copyright (c) 2026 OceanBase. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { getOperationContract, listOperationIds } from '@powercontext/protocol' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { PowerContextClient, ServerResponseError } from '../src/index.js' +import { CALL_THROUGH_REQUESTS, SCOPE_ID } from './fixtures/requests.js' +import { + oracleEnvironmentReady, + startPinnedPythonServer, + type StartedPythonServer, +} from './helpers/python-server.js' + +const required = process.env['POWERCONTEXT_CLIENT_CALLTHROUGH'] === '1' + +describe.skipIf(!oracleEnvironmentReady() && !required)( + 'TypeScript Client -> Python Server call-through', + () => { + let server: StartedPythonServer + let client: PowerContextClient + + beforeAll(async () => { + if (!oracleEnvironmentReady()) { + throw new Error( + 'POWERCONTEXT_CLIENT_CALLTHROUGH=1 requires a bootstrapped oracle environment', + ) + } + server = await startPinnedPythonServer() + client = new PowerContextClient({ + baseUrl: server.baseUrl, + timeoutMs: 15_000, + }) + }, 90_000) + + afterAll(async () => { + await server?.stop() + }) + + it( + 'reaches liveness, readiness and capabilities', + { timeout: 30_000 }, + async () => { + const live = await client.get_liveness() + expect(live).toMatchObject({ status: 'ok' }) + const ready = await client.get_readiness() + expect(['ready', 'degraded']).toContain(ready.status) + const capabilities = await client.get_capabilities() + expect(capabilities).toBeTypeOf('object') + }, + ) + + it( + 'remembers, searches, prepares and captures over HTTP', + { timeout: 60_000 }, + async () => { + const text = 'Keep the official Client on the public HTTP contract.' + const remembered = await client.remember_memory({ + scope_id: SCOPE_ID, + kind: 'decision', + text, + }) + expect(remembered.memory.family).toBe('memory') + const found = await client.search_memory({ + scope_id: SCOPE_ID, + query: 'official Client public HTTP', + }) + expect(found.hits.some((hit) => hit.text === text)).toBe(true) + const prepared = await client.prepare_context({ + scope_id: SCOPE_ID, + query: 'official Client public HTTP', + }) + expect(typeof prepared.content === 'string' || prepared.content === null).toBe( + true, + ) + const captured = await client.capture_content_source({ + scope_id: SCOPE_ID, + source_id: 'client-e2e-turn-1', + content: 'Call through the official Client without a model.', + }) + expect(captured.status).toBe('accepted') + }, + ) + + it( + 'covers all 52 operations against the pinned Python Server', + { timeout: 180_000 }, + async () => { + const seen = new Set() + for (const id of listOperationIds()) { + try { + await client.request(id, CALL_THROUGH_REQUESTS[id] as never) + seen.add(id) + } catch (error) { + expect(error, id).toBeInstanceOf(ServerResponseError) + if (!(error instanceof ServerResponseError)) { + throw error + } + const declaredErrorStatuses = getOperationContract(id).errors.map( + (media) => media.status, + ) + expect(declaredErrorStatuses, id).toContain(error.statusCode) + seen.add(id) + } + } + expect(seen.size).toBe(52) + }, + ) + }, +) diff --git a/packages/client/tests/fixtures/requests.ts b/packages/client/tests/fixtures/requests.ts new file mode 100644 index 0000000..82cec90 --- /dev/null +++ b/packages/client/tests/fixtures/requests.ts @@ -0,0 +1,276 @@ +/** + * Copyright (c) 2026 OceanBase. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { OperationId } from '@powercontext/protocol' + +export const SCOPE_ID = 'project:client-e2e' +export const SOURCE_REF = { name: 'content', source_id: 'missing-src' } +export const ARTIFACT_REF = { + family: 'memory', + artifact_id: 'missing-art', + revision: 1, +} +export const MEMORY_CITATION = { + memory_ref: ARTIFACT_REF, + entry_id: 'entry-1', + entry_version_id: 'ver-1', +} +export const SOURCE_CITATION = { kind: 'source' as const, source_ref: SOURCE_REF } + +const CLAIM = { + text: 'Keep the typed Client on the public HTTP contract.', + basis: 'declared' as const, + evidence: [SOURCE_CITATION], +} + +const STATEMENT = { + text: 'Continue from the official Client transport.', + citations: [SOURCE_CITATION], +} + +const WORK_CONTRACT = { + schema: 'powercontext.work-contract.v1' as const, + trust: 'untrusted_input' as const, + objective: 'Cover every Client operation.', + facts: [CLAIM], + in_scope: ['typed client'], + exclusions: [], + completion_criteria: ['52 operations reach the server'], + authorization_notes: [], + open_questions: [], +} + +const CURRENT_HANDOFF = { + schema: 'powercontext.current-work-handoff.v1' as const, + trust: 'untrusted_input' as const, + objective: 'Pause after Client transport.', + state: [CLAIM], + disposition: 'continuable' as const, + next_action: CLAIM, + omissions: [], +} + +const HANDOFF_CONTENT = { + schema: 'powercontext.handoff.v1' as const, + objective: 'Resume Client work.', + state: [STATEMENT], + disposition: 'continuable' as const, + next_action: STATEMENT, + omissions: [], +} + +const PREPARED_HANDOFF = { + schema: 'powercontext.prepared-handoff.v1' as const, + scope_id: SCOPE_ID, + base: null, + content: HANDOFF_CONTENT, +} + +const HANDOFF_DRAFT = { + objective: 'Inspect the draft.', + state: [STATEMENT], + disposition: 'continuable' as const, + next_action: STATEMENT, + omissions: [], +} + +const TASK_OUTCOME = { + schema: 'powercontext.task-outcome.v1' as const, + trust: 'untrusted_observation' as const, + objective: 'Record one outcome.', + status: 'unknown' as const, + summary: 'No real work was executed.', + observations: [CLAIM], + checks: [], + produced_artifacts: [], + remaining_work: [], +} + +const EXPERIENCE = { + situation: 'Need an official TypeScript Client.', + action: 'Hand-write a strict transport.', + outcome: '52 operations can call a Python Server.', + lesson: 'Validate responses at runtime.', +} + +const SKILL = { + name: 'client-coverage', + description: 'Call every HTTP operation.', + instructions: 'Use the official typed Client.', + validation: ['Contract coverage stays green.'], +} + +function scoped(extra: Record = {}): Record { + return { scope_id: SCOPE_ID, ...extra } +} + +export function callThroughPayload(operationId: OperationId): unknown { + return CALL_THROUGH_REQUESTS[operationId] +} + +export const CALL_THROUGH_REQUESTS: Record = { + get_liveness: undefined, + get_readiness: undefined, + get_capabilities: undefined, + capture_content_source: scoped({ + source_id: 'client-e2e-1', + content: 'Official Client call-through evidence.', + }), + prepare_context: scoped({ query: 'official typed client' }), + create_work_contract: scoped({ source_id: 'work-1', contract: WORK_CONTRACT }), + handoff_current_work: scoped({ source_id: 'handoff-1', handoff: CURRENT_HANDOFF }), + acknowledge_handoff: scoped({ + source_id: 'handoff-1', + receiver: 'client-e2e', + status: 'needs_clarification', + selection: 'prepared', + }), + record_task_outcome: scoped({ source_id: 'outcome-1', outcome: TASK_OUTCOME }), + activate_handoff: scoped({ boundary_source: SOURCE_REF, objective: 'Activate' }), + prepare_handoff: scoped({ objective: 'Prepare', evidence: [SOURCE_CITATION] }), + finalize_handoff: scoped({ draft: HANDOFF_DRAFT }), + commit_handoff: scoped({ handoff: PREPARED_HANDOFF }), + continue_handoff: scoped({ selection: 'latest' }), + flush_memory: scoped(), + remember_memory: scoped({ kind: 'decision', text: 'Keep the Client fetch-only.' }), + search_memory: scoped({ query: 'typed client' }), + list_memory_entries: scoped(), + get_memory_entry: scoped({ citation: MEMORY_CITATION }), + revise_memory_entry: scoped({ + citation: MEMORY_CITATION, + kind: 'decision', + text: 'Revise the Client note.', + }), + retire_memory_entry: scoped({ citation: MEMORY_CITATION }), + list_memory_changes: scoped(), + propose_experience: scoped({ + proposal: EXPERIENCE, + source_refs: [SOURCE_REF], + artifact_refs: [], + }), + generate_experience: scoped({ source_refs: [SOURCE_REF], artifact_refs: [] }), + get_experience: scoped({ + artifact: { family: 'experience', artifact_id: 'e1', revision: 1 }, + }), + propose_skill: scoped({ + proposal: SKILL, + source_refs: [SOURCE_REF], + artifact_refs: [], + }), + generate_skill: scoped({ + origin: 'source', + source_refs: [SOURCE_REF], + artifact_refs: [], + }), + get_skill: scoped({ artifact: { family: 'skill', artifact_id: 's1', revision: 1 } }), + scan_external_skills: scoped(), + list_external_skills: scoped(), + resolve_external_skill: scoped({ + external_skill_id: 'missing-skill', + fingerprint: 'a'.repeat(64), + }), + import_external_skill: scoped({ + external_skill_id: 'missing-skill', + fingerprint: 'a'.repeat(64), + mode: 'import', + }), + list_artifact_candidates: scoped(), + get_artifact_candidate: scoped({ candidate_id: 'cand-1' }), + approve_artifact_candidate: scoped({ candidate_id: 'cand-1', expected_version: 1 }), + reject_artifact_candidate: scoped({ + candidate_id: 'cand-1', + expected_version: 1, + reason: 'not used in call-through', + }), + revise_artifact_candidate: scoped({ + candidate_id: 'cand-1', + expected_version: 1, + proposal: EXPERIENCE, + source_refs: [SOURCE_REF], + artifact_refs: [], + }), + get_stats: scoped({ period: '7d' }), + create_handoff_report_project: { + project_key: 'client-e2e', + title: 'Client coverage', + }, + list_handoff_report_projects: {}, + get_handoff_report_project: { project_id: 'missing-project' }, + update_handoff_report_project: { + expected_version: 1, + project: { + schema: 'powercontext.project.v1', + project_id: 'missing-project', + project_key: 'client-e2e', + title: 'Updated', + description: null, + default_locale: 'zh-CN', + timezone: 'UTC', + catalog_state: 'included', + version: 1, + }, + }, + register_handoff_report_workstream: { + project_id: 'missing-project', + scope_id: SCOPE_ID, + title: 'Client', + kind: 'operations', + }, + list_handoff_report_workstreams: { project_id: 'missing-project' }, + update_handoff_report_workstream: { + expected_version: 1, + workstream: { + schema: 'powercontext.workstream.v1', + scope_id: SCOPE_ID, + project_id: 'missing-project', + key: 'client', + title: 'Updated', + kind: 'operations', + catalog_state: 'included', + external_refs: [], + labels: [], + version: 1, + }, + }, + get_handoff_report: { project_id: 'missing-project', format: 'json' }, + record_handoff_report_activity: { + project_id: 'missing-project', + source: 'other', + source_event_id: 'evt-1', + time_basis: 'host_observed', + }, + list_handoff_report_activities: { project_id: 'missing-project' }, + purge_handoff_report_activities: { + project_id: 'missing-project', + observed_before: '2026-01-01T00:00:00Z', + }, + get_handoff_report_workspace: { workspace_instance_id: 'ws-1' }, + attach_handoff_report_workspace: { + workspace_instance_id: 'ws-1', + project_id: 'missing-project', + expected_version: 1, + repository_ref: { + provider: 'local', + repository_id: 'repo-1', + normalized_remote: 'local/repo-1', + subpath: '.', + }, + }, + detach_handoff_report_workspace: { + workspace_instance_id: 'ws-1', + expected_version: 1, + }, +} diff --git a/packages/client/tests/helpers/http.ts b/packages/client/tests/helpers/http.ts new file mode 100644 index 0000000..929f096 --- /dev/null +++ b/packages/client/tests/helpers/http.ts @@ -0,0 +1,52 @@ +/** + * Copyright (c) 2026 OceanBase. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { FetchFn } from '../../src/index.js' + +export function jsonResponse( + status: number, + body: unknown, + headers?: Record, +): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json', ...headers }, + }) +} + +export function textResponse( + status: number, + body: string, + contentType = 'text/markdown', +): Response { + return new Response(body, { + status, + headers: { 'Content-Type': contentType }, + }) +} + +export function recordingFetch( + handler: (url: string, init: RequestInit) => Response | Promise, +): { fetch: FetchFn; calls: Array<{ url: string; init: RequestInit }> } { + const calls: Array<{ url: string; init: RequestInit }> = [] + return { + calls, + fetch: async (url, init) => { + calls.push({ url, init }) + return handler(url, init) + }, + } +} diff --git a/packages/client/tests/helpers/python-server.ts b/packages/client/tests/helpers/python-server.ts new file mode 100644 index 0000000..32589cb --- /dev/null +++ b/packages/client/tests/helpers/python-server.ts @@ -0,0 +1,177 @@ +/** + * Copyright (c) 2026 OceanBase. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { execFileSync, spawn, type ChildProcess } from 'node:child_process' +import { createServer } from 'node:net' +import { existsSync, mkdtempSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' + +const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..', '..') +const oracleVenv = join(repoRoot, 'conformance', 'runners', 'python', '.venv') + +export interface StartedPythonServer { + readonly baseUrl: string + stop(): Promise +} + +const CLI_BOOTSTRAP = + "from powercontext.cli.app import main; import sys; sys.argv=['powercontext']+sys.argv[1:]; raise SystemExit(main())" + +function existingPath(...candidates: string[]): string | undefined { + return candidates.find((candidate) => existsSync(candidate)) +} + +function oraclePython(): string | undefined { + return existingPath( + join(oracleVenv, 'Scripts', 'python.exe'), + join(oracleVenv, 'bin', 'python'), + ) +} + +function oracleCli(): { command: string; args: string[] } | undefined { + const script = existingPath( + join(oracleVenv, 'Scripts', 'powercontext.exe'), + join(oracleVenv, 'bin', 'powercontext'), + ) + if (script !== undefined) { + return { command: script, args: ['server', 'run'] } + } + const python = oraclePython() + if (python === undefined) { + return undefined + } + return { command: python, args: ['-c', CLI_BOOTSTRAP, 'server', 'run'] } +} + +export function oracleEnvironmentReady(): boolean { + const python = oraclePython() + if (python === undefined || oracleCli() === undefined) { + return false + } + try { + execFileSync( + python, + [ + '-c', + 'from powercontext.cli.app import main; from powercontext.server.cli import app', + ], + { stdio: 'ignore', timeout: 30_000 }, + ) + return true + } catch { + return false + } +} + +function unusedPort(): Promise { + return new Promise((resolve, reject) => { + const server = createServer() + server.listen(0, '127.0.0.1', () => { + const address = server.address() + if (address === null || typeof address === 'string') { + server.close() + reject(new Error('could not allocate a TCP port')) + return + } + const { port } = address + server.close((error) => { + if (error !== null && error !== undefined) { + reject(error) + return + } + resolve(port) + }) + }) + server.on('error', reject) + }) +} + +async function waitForUrl( + url: string, + timeoutMs: number, + child: ChildProcess, +): Promise { + const deadline = Date.now() + timeoutMs + let lastError: unknown + while (Date.now() < deadline) { + if (child.exitCode !== null) { + throw new Error(`Server process exited with ${String(child.exitCode)}`) + } + try { + const response = await fetch(url, { signal: AbortSignal.timeout(1000) }) + if (response.ok || response.status === 503) { + return + } + lastError = new Error(`HTTP ${String(response.status)}`) + } catch (error) { + lastError = error + } + await new Promise((resolve) => setTimeout(resolve, 200)) + } + throw new Error(`Server at ${url} did not become ready: ${String(lastError)}`) +} + +export async function startPinnedPythonServer(): Promise { + const cli = oracleCli() + if (cli === undefined) { + throw new Error('oracle environment is not bootstrapped') + } + const port = await unusedPort() + const home = mkdtempSync(join(tmpdir(), 'pc-client-e2e-')) + const child: ChildProcess = spawn(cli.command, cli.args, { + cwd: home, + env: { + ...process.env, + PYTHONUNBUFFERED: '1', + PYTHONNOUSERSITE: '1', + POWERCONTEXT_HOME: home, + POWERCONTEXT_SERVER_HTTP_HOST: '127.0.0.1', + POWERCONTEXT_SERVER_HTTP_PORT: String(port), + POWERCONTEXT_SERVER_DASHBOARD_ENABLED: 'false', + POWERCONTEXT_SERVER_MCP_ENABLED: 'false', + }, + stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true, + }) + const logs: string[] = [] + child.stdout?.on('data', (chunk: Buffer) => logs.push(chunk.toString())) + child.stderr?.on('data', (chunk: Buffer) => logs.push(chunk.toString())) + const baseUrl = `http://127.0.0.1:${String(port)}` + try { + await waitForUrl(`${baseUrl}/health/live`, 60_000, child) + } catch (error) { + child.kill() + throw new Error(`${String(error)}\n${logs.join('')}`) + } + return { + baseUrl, + async stop(): Promise { + if (child.killed !== true) { + child.kill() + } + await new Promise((resolve) => { + if (child.exitCode !== null) { + resolve() + return + } + child.once('exit', () => resolve()) + setTimeout(resolve, 3000) + }) + }, + } +} diff --git a/packages/client/tests/methods.test.ts b/packages/client/tests/methods.test.ts new file mode 100644 index 0000000..5d4eb9a --- /dev/null +++ b/packages/client/tests/methods.test.ts @@ -0,0 +1,74 @@ +/** + * Copyright (c) 2026 OceanBase. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + OPERATION_METADATA, + listOperationIds, + validateOperationRequest, +} from '@powercontext/protocol' +import { describe, expect, it } from 'vitest' +import { PowerContextClient, ServerResponseError } from '../src/index.js' +import { CALL_THROUGH_REQUESTS } from './fixtures/requests.js' +import { jsonResponse, recordingFetch } from './helpers/http.js' + +describe('typed Client methods', () => { + it('exposes one typed method per operation id', () => { + const client = new PowerContextClient({ + baseUrl: 'http://example.test', + fetch: async () => + jsonResponse(401, { + error: { code: 'unauthorized', message: 'n', details: null }, + }), + }) + for (const id of listOperationIds()) { + expect(typeof client[id]).toBe('function') + } + }) + + it('keeps every call-through fixture schema-valid', () => { + for (const id of listOperationIds()) { + const result = validateOperationRequest(id, CALL_THROUGH_REQUESTS[id]) + expect(result.valid, `${id}: ${JSON.stringify(result.errors)}`).toBe(true) + } + }) + + it('emits the generated method and path for every operation', async () => { + const { fetch, calls } = recordingFetch(() => + jsonResponse(401, { + error: { code: 'unauthorized', message: 'n', details: null }, + }), + ) + const client = new PowerContextClient({ + baseUrl: 'http://example.test', + fetch, + timeoutMs: 1000, + }) + for (const id of listOperationIds()) { + await expect( + client.request(id, CALL_THROUGH_REQUESTS[id] as never), + ).rejects.toBeInstanceOf(ServerResponseError) + } + expect(calls).toHaveLength(52) + listOperationIds().forEach((id, index) => { + const spec = OPERATION_METADATA[id] + expect(calls[index]?.init.method).toBe(spec.method) + expect(calls[index]?.url.startsWith(`http://example.test${spec.path}`)).toBe(true) + expect(Boolean(calls[index]?.init.body)).toBe( + spec.method === 'POST' && spec.location === 'body', + ) + }) + }) +}) diff --git a/packages/client/tests/package-info.test.ts b/packages/client/tests/package-info.test.ts index 3995f61..0d6768a 100644 --- a/packages/client/tests/package-info.test.ts +++ b/packages/client/tests/package-info.test.ts @@ -18,30 +18,32 @@ import { readFileSync } from 'node:fs' import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' import { describe, expect, it } from 'vitest' -import { NATIVE_DEPENDENCIES, PACKAGE_NAME, PACKAGE_PROFILE } from '../src/index.js' +import { + CLIENT_USER_AGENT, + NATIVE_DEPENDENCIES, + PACKAGE_NAME, + PACKAGE_PROFILE, + PACKAGE_VERSION, +} from '../src/index.js' const packageRoot = dirname(fileURLToPath(import.meta.url)) -describe('@powercontext/client skeleton', () => { +describe('@powercontext/client package', () => { it('declares the client profile and no native dependencies', () => { expect(PACKAGE_NAME).toBe('@powercontext/client') expect(PACKAGE_PROFILE).toBe('client') expect(NATIVE_DEPENDENCIES).toEqual([]) }) - it('does not pull SQLite, MCP server or provider SDKs into the package', () => { + it('keeps User-Agent aligned with package.json and claims client/C1', () => { const manifest = JSON.parse( readFileSync(join(packageRoot, '..', 'package.json'), 'utf8'), - ) as { - dependencies?: Record - optionalDependencies?: Record - } - const names = [ - ...Object.keys(manifest.dependencies ?? {}), - ...Object.keys(manifest.optionalDependencies ?? {}), - ] + ) as { version: string; dependencies?: Record } + expect(PACKAGE_VERSION).toBe(manifest.version) + expect(CLIENT_USER_AGENT).toBe(`@powercontext/client/${manifest.version}`) + const names = Object.keys(manifest.dependencies ?? {}) + expect(names).toEqual(['@powercontext/protocol']) expect(names.some((name) => name.includes('sqlite'))).toBe(false) - expect(names.some((name) => name.includes('better-sqlite'))).toBe(false) expect(names.some((name) => name.includes('modelcontextprotocol'))).toBe(false) }) }) diff --git a/packages/client/tests/request-types.ts b/packages/client/tests/request-types.ts new file mode 100644 index 0000000..3879f6e --- /dev/null +++ b/packages/client/tests/request-types.ts @@ -0,0 +1,33 @@ +/** + * Copyright (c) 2026 OceanBase. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { PowerContextClient } from '../src/index.js' + +declare const client: PowerContextClient + +// Required OpenAPI request fields remain required on every typed method. +void client.remember_memory({ + scope_id: 'project:demo', + kind: 'decision', + text: 'keep required fields required', +}) + +// @ts-expect-error scope_id, kind and text are required by RememberMemoryRequest. +void client.remember_memory({}) + +// Server-defaulted OpenAPI fields stay optional for Client callers. +void client.get_stats({ scope_id: 'project:demo' }) +void client.get_handoff_report({ project_id: 'demo' }) diff --git a/packages/client/tests/tracing.test.ts b/packages/client/tests/tracing.test.ts new file mode 100644 index 0000000..7d9ab06 --- /dev/null +++ b/packages/client/tests/tracing.test.ts @@ -0,0 +1,66 @@ +/** + * Copyright (c) 2026 OceanBase. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, expect, it } from 'vitest' +import { + PowerContextClient, + type ClientSpanHandle, + type ClientTracer, +} from '../src/index.js' +import { jsonResponse, recordingFetch } from './helpers/http.js' + +describe('OpenTelemetry injection hook', () => { + it('injects tracer headers without requiring an SDK', async () => { + const finishes: string[] = [] + const tracer: ClientTracer = { + start(operationId): ClientSpanHandle { + return { + inject(headers): void { + headers['traceparent'] = `00-${operationId}-01` + }, + finish(outcome): void { + finishes.push(`${operationId}:${outcome}`) + }, + } + }, + } + const { fetch, calls } = recordingFetch(() => jsonResponse(200, { status: 'ok' })) + const client = new PowerContextClient({ + baseUrl: 'http://127.0.0.1:8000', + fetch, + tracer, + }) + await client.get_liveness() + expect(new Headers(calls[0]?.init.headers).get('traceparent')).toBe( + '00-get_liveness-01', + ) + expect(finishes).toEqual(['get_liveness:success']) + }) + + it('swallows tracer failures so the request still proceeds', async () => { + const tracer: ClientTracer = { + start(): ClientSpanHandle { + throw new Error('otel missing') + }, + } + const client = new PowerContextClient({ + baseUrl: 'http://127.0.0.1:8000', + fetch: async () => jsonResponse(200, { status: 'ok' }), + tracer, + }) + await expect(client.get_liveness()).resolves.toMatchObject({ status: 'ok' }) + }) +}) diff --git a/packages/client/tests/transport.test.ts b/packages/client/tests/transport.test.ts new file mode 100644 index 0000000..d064453 --- /dev/null +++ b/packages/client/tests/transport.test.ts @@ -0,0 +1,259 @@ +/** + * Copyright (c) 2026 OceanBase. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, expect, it } from 'vitest' +import { + CLIENT_USER_AGENT, + InvalidRequestError, + InvalidResponseError, + PowerContextClient, + ServerResponseError, + UnavailableError, + UnknownOperationError, +} from '../src/index.js' +import { jsonResponse, recordingFetch, textResponse } from './helpers/http.js' + +function clientWith( + fetchImpl: (url: string, init: RequestInit) => Response | Promise, + extra?: { + token?: string + timeoutMs?: number + maxResponseBytes?: number + }, +): PowerContextClient { + return new PowerContextClient({ + baseUrl: 'http://127.0.0.1:8000/', + fetch: async (url, init) => fetchImpl(url, init), + timeoutMs: extra?.timeoutMs ?? 1000, + ...(extra?.token === undefined ? {} : { token: extra.token }), + ...(extra?.maxResponseBytes === undefined + ? {} + : { maxResponseBytes: extra.maxResponseBytes }), + }) +} + +describe('PowerContextClient transport', () => { + it('normalizes the base URL and rejects credentials or query tokens', () => { + expect( + () => new PowerContextClient({ baseUrl: 'https://user:secret@example.test' }), + ).toThrow(/credentials/) + expect( + () => + new PowerContextClient({ baseUrl: 'https://example.test/api?token=secret' }), + ).toThrow(/query/) + expect(() => new PowerContextClient({ baseUrl: 'ftp://example.test' })).toThrow( + /http/, + ) + }) + + it('POSTs JSON, Authorization, User-Agent and captures the request ID', async () => { + const { fetch, calls } = recordingFetch(() => + jsonResponse( + 200, + { memory: { family: 'memory', artifact_id: 'm1', revision: 1 } }, + { 'X-PowerContext-Request-ID': 'req-1' }, + ), + ) + const client = clientWith(fetch, { token: 'secret-token' }) + const result = await client.request('remember_memory', { + scope_id: 'project:demo', + kind: 'decision', + text: 'keep API async', + }) + expect(result).toMatchObject({ kind: 'json', status: 200, requestId: 'req-1' }) + expect(calls).toHaveLength(1) + const headers = new Headers(calls[0]?.init.headers) + expect(calls[0]?.url).toBe('http://127.0.0.1:8000/v1/memory/remember') + expect(calls[0]?.init.method).toBe('POST') + expect(calls[0]?.init.redirect).toBe('manual') + expect(headers.get('Authorization')).toBe('Bearer secret-token') + expect(headers.get('User-Agent')).toBe(CLIENT_USER_AGENT) + expect(headers.get('Content-Type')).toBe('application/json') + }) + + it('sends get_stats as a GET query string without a body', async () => { + const { fetch, calls } = recordingFetch(() => + jsonResponse(401, { + error: { code: 'unauthorized', message: 'missing token', details: null }, + }), + ) + const client = clientWith(fetch) + await expect( + client.request('get_stats', { scope_id: 'project:demo', period: '7d' }), + ).rejects.toBeInstanceOf(ServerResponseError) + expect(calls[0]?.url).toBe( + 'http://127.0.0.1:8000/v1/stats?scope_id=project%3Ademo&period=7d', + ) + expect(calls[0]?.init.method).toBe('GET') + expect(calls[0]?.init.body).toBeUndefined() + }) + + it('returns markdown text and raw bytes for get_handoff_report', async () => { + const markdown = clientWith(() => textResponse(200, '# Report')) + await expect( + markdown.request('get_handoff_report', { project_id: 'p1', format: 'markdown' }), + ).resolves.toMatchObject({ kind: 'text', value: '# Report' }) + const bytesClient = clientWith( + () => new Response(new Uint8Array([1, 2, 3]), { status: 200 }), + ) + const downloaded = await bytesClient.request('get_handoff_report', { + project_id: 'p1', + download: true, + }) + expect(downloaded.kind).toBe('bytes') + if (downloaded.kind === 'bytes') { + expect([...downloaded.value]).toEqual([1, 2, 3]) + } + await expect( + bytesClient.download_handoff_report({ project_id: 'p1' }), + ).resolves.toBeInstanceOf(Uint8Array) + }) + + it('maps server errors, unknown operations and invalid requests', async () => { + const client = clientWith(() => + jsonResponse( + 409, + { error: { code: 'conflict', message: 'citation mismatch', details: null } }, + { 'X-PowerContext-Request-ID': 'req-9' }, + ), + ) + await expect( + client.request('revise_memory_entry', { + scope_id: 'project:demo', + citation: { + memory_ref: { family: 'memory', artifact_id: 'm1', revision: 1 }, + entry_id: 'e1', + entry_version_id: 'v1', + }, + kind: 'decision', + text: 'next', + }), + ).rejects.toMatchObject({ + statusCode: 409, + code: 'conflict', + requestId: 'req-9', + } satisfies Partial) + await expect(client.request('not_an_operation')).rejects.toBeInstanceOf( + UnknownOperationError, + ) + await expect( + client.request('get_liveness', { extra: true } as never), + ).rejects.toBeInstanceOf(InvalidRequestError) + }) + + it('maps an undeclared 2xx status to a server response error', async () => { + const client = clientWith(() => + jsonResponse(200, { + status: 'accepted', + source_id: 'source-1', + }), + ) + await expect( + client.capture_content_source({ + scope_id: 'project:demo', + source_id: 'source-1', + content: 'capture me', + }), + ).rejects.toMatchObject({ statusCode: 200 } satisfies Partial) + }) + + it('maps network failure, timeout, abort, redirect and oversize', async () => { + const down = clientWith(() => { + throw new TypeError('fetch failed') + }) + await expect(down.request('get_liveness')).rejects.toBeInstanceOf(UnavailableError) + + const redirected = clientWith( + () => + new Response(null, { + status: 302, + headers: { Location: 'https://evil.example' }, + }), + ) + await expect(redirected.request('get_liveness')).rejects.toBeInstanceOf( + InvalidResponseError, + ) + + const oversized = clientWith(() => new Response('x'.repeat(32), { status: 200 }), { + maxResponseBytes: 16, + }) + await expect(oversized.request('get_liveness')).rejects.toBeInstanceOf( + InvalidResponseError, + ) + + const delayed = clientWith(() => new Promise(() => undefined), { timeoutMs: 20 }) + await expect(delayed.request('get_liveness')).rejects.toBeInstanceOf( + UnavailableError, + ) + + const controller = new AbortController() + controller.abort() + const aborted = clientWith(() => jsonResponse(200, { status: 'ok' })) + await expect( + aborted.request('get_liveness', undefined, { signal: controller.signal }), + ).rejects.toBeInstanceOf(UnavailableError) + }) + + it('rejects invalid JSON and extra fields on success bodies', async () => { + const invalidJson = clientWith( + () => + new Response('{', { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ) + await expect(invalidJson.request('get_liveness')).rejects.toBeInstanceOf( + InvalidResponseError, + ) + const extra = clientWith(() => + jsonResponse( + 200, + { status: 'ok', unexpected: true }, + { + 'X-PowerContext-Request-ID': 'request-123', + }, + ), + ) + await expect(extra.request('get_liveness')).rejects.toMatchObject({ + requestId: 'request-123', + }) + }) + + it('rejects invalid UTF-8 in JSON and text success bodies', async () => { + const invalidBytes = new Uint8Array([0xc3, 0x28]) + const invalidJson = clientWith( + () => + new Response(invalidBytes, { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ) + await expect(invalidJson.get_liveness()).rejects.toBeInstanceOf( + InvalidResponseError, + ) + + const invalidText = clientWith( + () => + new Response(invalidBytes, { + status: 200, + headers: { 'Content-Type': 'text/markdown' }, + }), + ) + await expect( + invalidText.get_handoff_report({ project_id: 'p1' }), + ).rejects.toBeInstanceOf(InvalidResponseError) + }) +}) diff --git a/packages/protocol/src/generated/openapi-types.ts b/packages/protocol/src/generated/openapi-types.ts index 6f840b9..b55002b 100644 --- a/packages/protocol/src/generated/openapi-types.ts +++ b/packages/protocol/src/generated/openapi-types.ts @@ -1004,9 +1004,9 @@ export interface components { boundary_source: components["schemas"]["SourceReference"]; objective: string; /** @default [] */ - evidence: components["schemas"]["HandoffCitation"][]; + evidence?: components["schemas"]["HandoffCitation"][]; /** @default 8000 */ - max_bytes: number; + max_bytes?: number; }; ArtifactReference: { family: string; @@ -1046,17 +1046,17 @@ export interface components { * @description Whether the configured model can generate reviewed Experience Candidates. * @default false */ - experience_generation: boolean; + experience_generation?: boolean; /** * @description Whether the configured model can generate reviewed managed Skill Candidates. * @default false */ - managed_skill_generation: boolean; + managed_skill_generation?: boolean; /** * @description Whether host-local external Skill discovery and exact resolution are configured. * @default false */ - external_skill_registry: boolean; + external_skill_registry?: boolean; /** @description Whether exact evidence can be generated into an inspectable Handoff Draft. */ handoff_generation: boolean; search_modes: components["schemas"]["MemorySearchMode"][]; @@ -1185,7 +1185,7 @@ export interface components { GetStatsRequest: { scope_id: string; /** @default 30d */ - period: components["schemas"]["StatsPeriod"]; + period?: components["schemas"]["StatsPeriod"]; }; /** @enum {string} */ WorkClaimBasis: "declared" | "verified"; @@ -1409,7 +1409,7 @@ export interface components { objective: string; evidence: components["schemas"]["HandoffCitation"][]; /** @default 8000 */ - max_bytes: number; + max_bytes?: number; }; PreparedHandoff: { schema: components["schemas"]["PreparedHandoffSchema"]; @@ -1524,16 +1524,16 @@ export interface components { title: string; description?: string | null; /** @default zh-CN */ - default_locale: components["schemas"]["ReportLocale"]; + default_locale?: components["schemas"]["ReportLocale"]; /** @default UTC */ - timezone: string; + timezone?: string; }; ListHandoffReportProjectsRequest: { cursor?: string | null; /** @default 50 */ - limit: number; + limit?: number; /** @default false */ - include_archived: boolean; + include_archived?: boolean; }; GetHandoffReportProjectRequest: { project_id: string; @@ -1549,19 +1549,19 @@ export interface components { title: string; kind: components["schemas"]["WorkstreamKind"]; /** @default included */ - catalog_state: components["schemas"]["ReportCatalogState"]; + catalog_state?: components["schemas"]["ReportCatalogState"]; /** @default [] */ - external_refs: components["schemas"]["HandoffReportExternalReference"][]; + external_refs?: components["schemas"]["HandoffReportExternalReference"][]; /** @default [] */ - labels: string[]; + labels?: string[]; }; ListHandoffReportWorkstreamsRequest: { project_id: string; cursor?: string | null; /** @default 50 */ - limit: number; + limit?: number; /** @default false */ - include_archived: boolean; + include_archived?: boolean; }; UpdateHandoffReportWorkstreamRequest: { workstream: components["schemas"]["WorkstreamDescriptor"]; @@ -1571,13 +1571,13 @@ export interface components { project_id: string; locale?: components["schemas"]["ReportLocale"]; /** @default true */ - include_evidence_checks: boolean; + include_evidence_checks?: boolean; /** @default markdown */ - format: components["schemas"]["ReportFormat"]; + format?: components["schemas"]["ReportFormat"]; /** @default false */ - include_archived: boolean; + include_archived?: boolean; /** @default false */ - download: boolean; + download?: boolean; period?: components["schemas"]["HandoffReportPeriodRequest"]; }; HandoffReportPeriodRequest: { @@ -1587,7 +1587,7 @@ export interface components { end: string; timezone?: string | null; /** @default false */ - compare_to_previous_period: boolean; + compare_to_previous_period?: boolean; }; HandoffReportResponse: { format: components["schemas"]["ReportFormat"]; @@ -1625,7 +1625,7 @@ export interface components { session_id?: string | null; vcs_context?: components["schemas"]["HandoffReportActivityVcsContext"]; /** @default [] */ - evidence_refs: components["schemas"]["HandoffReportExternalReference"][]; + evidence_refs?: components["schemas"]["HandoffReportExternalReference"][]; }; HandoffReportActivity: { /** @enum {string} */ @@ -1662,10 +1662,10 @@ export interface components { period_end?: string | null; sources?: components["schemas"]["ReportActivitySource"][] | null; /** @default 0 */ - after_cursor: number; + after_cursor?: number; through_cursor?: number | null; /** @default 50 */ - limit: number; + limit?: number; }; HandoffReportActivityPage: { items: components["schemas"]["HandoffReportActivity"][]; @@ -1778,7 +1778,7 @@ export interface components { * @description Include inactive entries from the current Memory head for explicit audit. * @default false */ - include_inactive: boolean; + include_inactive?: boolean; }; ListMemoryEntriesResponse: { memory?: components["schemas"]["ArtifactReference"]; @@ -1787,16 +1787,16 @@ export interface components { ListArtifactCandidatesRequest: { scope_id: string; /** @default pending */ - status: components["schemas"]["CandidateStatus"]; + status?: components["schemas"]["CandidateStatus"]; family?: components["schemas"]["CandidateFamily"]; cursor?: string | null; /** @default 50 */ - limit: number; + limit?: number; }; ListExternalSkillsRequest: { scope_id: string; /** @default false */ - include_unavailable: boolean; + include_unavailable?: boolean; }; MemoryEntry: { citation: components["schemas"]["MemoryCitation"]; @@ -1824,7 +1824,7 @@ export interface components { scope_id: string; query: string; /** @default 8000 */ - max_bytes: number; + max_bytes?: number; }; ProposeExperienceRequest: { scope_id: string; @@ -1933,9 +1933,9 @@ export interface components { scope_id: string; query: string; /** @default 10 */ - limit: number; + limit?: number; /** @default auto */ - mode: components["schemas"]["MemorySearchMode"]; + mode?: components["schemas"]["MemorySearchMode"]; }; ScanExternalSkillsRequest: { scope_id: string; diff --git a/packages/protocol/src/index.ts b/packages/protocol/src/index.ts index 6297100..d70b4ed 100644 --- a/packages/protocol/src/index.ts +++ b/packages/protocol/src/index.ts @@ -31,6 +31,7 @@ export { } from './integers.js' export type { HttpMethod, + OperationId, OperationMetadata, RequestLocation, } from './generated/operations.js' diff --git a/tools/generate-protocol/src/emit.ts b/tools/generate-protocol/src/emit.ts index 4803032..619350c 100644 --- a/tools/generate-protocol/src/emit.ts +++ b/tools/generate-protocol/src/emit.ts @@ -116,7 +116,11 @@ export async function renderTypesSource( openapiPath: string, sourceDigest: string, ): Promise { - const ast = await openapiTS(pathToFileURL(openapiPath)) + const ast = await openapiTS(pathToFileURL(openapiPath), { + // OpenAPI defaults are applied by the Server. They must not turn an + // otherwise optional request property into a required Client argument. + defaultNonNullable: false, + }) return `${generatedFileBanner('openapi-typescript wire types', sourceDigest)}${astToString(ast)}` } diff --git a/tools/pack-smoke/run.mjs b/tools/pack-smoke/run.mjs index 14eaa0b..ee25670 100644 --- a/tools/pack-smoke/run.mjs +++ b/tools/pack-smoke/run.mjs @@ -92,6 +92,23 @@ function assertNoNativeBinding(tarballPath) { } } +function assertPublishableTarball(tarballPath, name) { + const listing = run('tar', ['-tf', tarballPath], ROOT).replaceAll('\\', '/') + const forbidden = [ + '/src/', + '/tests/', + '/scripts/', + '.env', + 'secrets', + 'dev-scripts', + ].filter((marker) => listing.includes(marker)) + if (forbidden.length > 0) { + throw new Error( + `${name} tarball contains unpublished paths: ${forbidden.join(', ')}`, + ) + } +} + function findNativeBuildFiles(directory, found = []) { for (const entry of readdirSync(directory, { withFileTypes: true })) { const path = join(directory, entry.name) @@ -253,6 +270,7 @@ function main() { assertCuratedExports(item.dir, item.name) const tarball = packOne(item.dir) assertNoNativeBinding(tarball) + assertPublishableTarball(tarball, item.name) tarballs.push({ ...item, tarball }) } importPackedGraph(tarballs)