mirror of
https://github.com/vercel-labs/fx.git
synced 2026-10-28 18:25:19 +08:00
Fix embedded Node runtime boundaries
Honor host-owned startup roots and propagate native runtime failures through the JavaScript adapter. Discard canceled fetches before reuse and include the project license in package artifacts.
This commit is contained in:
21 files changed
+310
-482
No files matched your search
@@ -27,29 +27,19 @@ jobs:
|
||||
backfill:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Resolve versions
|
||||
id: versions
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
REQUESTED_VERSION: ${{ inputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ "$REQUESTED_VERSION" = "all" ]; then
|
||||
if [ "${{ inputs.version }}" = "all" ]; then
|
||||
VERSIONS=$(gh release list --repo "$GITHUB_REPOSITORY" --limit 100 --json tagName --jq '.[].tagName' | sort -V)
|
||||
else
|
||||
if ! [[ "$REQUESTED_VERSION" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then
|
||||
echo "::error::version must be strict SemVer or all"
|
||||
exit 1
|
||||
fi
|
||||
gh release view "$REQUESTED_VERSION" --repo "$GITHUB_REPOSITORY" --json tagName --jq '.tagName' >/dev/null
|
||||
VERSIONS="$REQUESTED_VERSION"
|
||||
gh release view "${{ inputs.version }}" --repo "$GITHUB_REPOSITORY" --json tagName --jq '.tagName' >/dev/null
|
||||
VERSIONS="${{ inputs.version }}"
|
||||
fi
|
||||
while IFS= read -r VERSION; do
|
||||
[ -z "$VERSION" ] && continue
|
||||
[[ "$VERSION" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]] || exit 1
|
||||
done <<< "$VERSIONS"
|
||||
echo "Versions to backfill:"
|
||||
echo "$VERSIONS"
|
||||
{
|
||||
@@ -62,10 +52,10 @@ jobs:
|
||||
env:
|
||||
BLOB_READ_WRITE_TOKEN: ${{ secrets.BLOB_READ_WRITE_TOKEN }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
DRY_RUN: ${{ inputs.dry-run }}
|
||||
VERSIONS: ${{ steps.versions.outputs.list }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
DRY_RUN="${{ inputs.dry-run }}"
|
||||
UPLOADED=0
|
||||
SKIPPED=0
|
||||
FAILED=0
|
||||
@@ -137,7 +127,7 @@ jobs:
|
||||
|
||||
rm -rf "$TMPDIR"
|
||||
trap - EXIT
|
||||
done <<< "$VERSIONS"
|
||||
done <<< "${{ steps.versions.outputs.list }}"
|
||||
|
||||
echo ""
|
||||
echo "--- Summary ---"
|
||||
@@ -153,10 +143,8 @@ jobs:
|
||||
if: inputs.update-latest && inputs.version != 'all' && !inputs.dry-run
|
||||
env:
|
||||
BLOB_READ_WRITE_TOKEN: ${{ secrets.BLOB_READ_WRITE_TOKEN }}
|
||||
VERSION: ${{ inputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
[[ "$VERSION" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]
|
||||
VERSION="${{ inputs.version }}"
|
||||
echo -n "$VERSION" > /tmp/latest.txt
|
||||
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" \
|
||||
-X PUT "https://blob.vercel-storage.com/${CDN_BASE}/latest.txt" \
|
||||
|
||||
@@ -261,7 +261,7 @@ jobs:
|
||||
|
||||
rm -rf sdk/dist/libfx
|
||||
mkdir -p sdk/dist/libfx
|
||||
cp sdk/package.json sdk/README.md sdk/browser.js sdk/node.js sdk/fx-sdk.js sdk/dist/libfx/
|
||||
cp sdk/package.json sdk/README.md sdk/browser.js sdk/node.js sdk/fx-sdk.js LICENSE sdk/dist/libfx/
|
||||
cp wasm-artifacts/fx-core.wasm wasm-artifacts/fx-term.wasm sdk/dist/libfx/
|
||||
cp native-addons/*.node sdk/dist/libfx/
|
||||
jq --arg version "$LIBFX_VERSION" '.version = $version | del(.files)' \
|
||||
@@ -276,7 +276,7 @@ jobs:
|
||||
const report = require("/tmp/libfx-pack.json")[0];
|
||||
const names = new Set(report.files.map(({ path }) => path));
|
||||
const required = [
|
||||
"package.json", "README.md", "browser.js", "node.js", "fx-sdk.js",
|
||||
"package.json", "README.md", "LICENSE", "browser.js", "node.js", "fx-sdk.js",
|
||||
"fx-core.wasm", "fx-term.wasm",
|
||||
"libfx.linux-x64.node", "libfx.linux-arm64.node",
|
||||
"libfx.darwin-x64.node", "libfx.darwin-arm64.node",
|
||||
|
||||
@@ -1,428 +0,0 @@
|
||||
# N-API implementation process
|
||||
|
||||
This is internal maintainer documentation for implementing, reviewing, testing, packaging, and releasing the native Node-API backend for `libfx`. It records the engineering process and ownership decisions behind the implementation. It is not consumer-facing API documentation.
|
||||
|
||||
For the exact runtime architecture, ABI, limits, error codes, and security invariants, read [`sdk/NAPI.md`](../sdk/NAPI.md). For supported public usage, read [`sdk/README.md`](../sdk/README.md).
|
||||
|
||||
## Goal and scope
|
||||
|
||||
The implementation adds a native Node backend for the headless fx agent while preserving the existing WebAssembly SDK and shared JavaScript API.
|
||||
|
||||
The intended outcome is:
|
||||
|
||||
- Node loads a platform-specific `.node` addon when available.
|
||||
- Browser consumers continue to load the dependency-free WebAssembly SDK.
|
||||
- Node can explicitly select `native`, `wasm`, or automatic fallback behavior.
|
||||
- Both backends feed the same ACP client and event translation in `sdk/fx-sdk.js`.
|
||||
- The native addon remains a narrow ACP byte transport, not a second agent implementation.
|
||||
- Native execution does not implicitly grant native fx tools, MCP processes, filesystem access, command execution, or secret-store access.
|
||||
|
||||
The native surface is headless core only. The terminal remains WebAssembly-backed.
|
||||
|
||||
## Ownership map
|
||||
|
||||
Start changes in the module that owns the behavior:
|
||||
|
||||
| Concern | Owner |
|
||||
| --- | --- |
|
||||
| Native Node-API entry point, queues, lifecycle, fetch bridge | `src/napi_core_main.zig` |
|
||||
| Shared host-driven Gateway streaming | `src/gateway/host_stream_provider.zig` |
|
||||
| WebAssembly host transport adapter | `src/gateway/js_host_stream_provider.zig` |
|
||||
| ACP callback transport and embedded configuration | `src/acp/`, `src/core/cli/acp_runner.zig` |
|
||||
| Shared JavaScript agent and terminal behavior | `sdk/fx-sdk.js` |
|
||||
| Node backend discovery and adaptation | `sdk/node.js` |
|
||||
| Browser-only exports | `sdk/browser.js` |
|
||||
| Build surfaces and optimization policy | `build.zig` |
|
||||
| npm manifest and package assembly | `sdk/package.json`, `sdk/scripts/package-libfx.mjs` |
|
||||
| CI and publishing | `.github/workflows/ci.yml`, `.github/workflows/publish-libfx.yml` |
|
||||
| Native regression tests | `sdk/tests/test-native-core-*.mjs` |
|
||||
| Cross-backend loader tests | `sdk/tests/test-libfx-loader.mjs`, `sdk/tests/test-node-*.mjs` |
|
||||
|
||||
Do not move product behavior into the addon or duplicate the ACP-to-JavaScript translation in `sdk/node.js`.
|
||||
|
||||
## Implementation sequence
|
||||
|
||||
The safest implementation order is contract-first. Each stage should compile and have focused coverage before advancing.
|
||||
|
||||
### 1. Define the embedded ACP boundary
|
||||
|
||||
The native addon needs to run the existing ACP server without owning process stdin or stdout and without reading process-global configuration.
|
||||
|
||||
The reusable boundary consists of:
|
||||
|
||||
- callback-backed ACP input and output;
|
||||
- configuration overrides for credentials, home, workspace, and model;
|
||||
- explicit capability switches for native tools and ACP MCP;
|
||||
- lifecycle behavior that can terminate when the embedding host closes input.
|
||||
|
||||
The relevant shared changes are in:
|
||||
|
||||
- `src/acp/jsonrpc.zig` for callback output;
|
||||
- `src/acp/server.zig` and `src/acp/sessions.zig` for transport/configuration composition;
|
||||
- `src/acp/prompt.zig` for embedded prompt behavior;
|
||||
- `src/core/cli/acp_runner.zig` for typed overrides and capability flags.
|
||||
|
||||
Keep native CLI defaults unchanged. Embedding restrictions must be explicit in the N-API composition rather than silently changing the ordinary `fx acp` server.
|
||||
|
||||
### 2. Extract the host-stream provider
|
||||
|
||||
WebAssembly already delegated Gateway HTTP streaming to JavaScript. Native Node needs the same trust boundary because Node must own `fetch`, cancellation, and response streaming.
|
||||
|
||||
The common provider lives in `src/gateway/host_stream_provider.zig`. It accepts a typed `Transport` with four operations:
|
||||
|
||||
- open a request;
|
||||
- poll for response status;
|
||||
- read the next response chunk;
|
||||
- close the stream.
|
||||
|
||||
The provider owns request construction, Gateway headers, delivery state, status handling, SSE consumption, cooperative pulses, and cancellation mapping. Host adapters own only transport-specific mechanics.
|
||||
|
||||
`src/gateway/js_host_stream_provider.zig` is now a thin adapter over WebAssembly imports. `src/napi_core_main.zig` supplies the equivalent queue-backed transport for Node.
|
||||
|
||||
This extraction must preserve WebAssembly behavior. Build and test both WASM surfaces whenever the shared provider changes.
|
||||
|
||||
A non-obvious Zig constraint is that the WebAssembly provider is initialized at container scope. Its context must remain comptime-constructible. Do not replace the static provider context with runtime assignment during adapter creation.
|
||||
|
||||
### 3. Implement the native addon as a bounded transport
|
||||
|
||||
`src/napi_core_main.zig` is the native composition root. It should contain integration and boundary code, not duplicate agent behavior.
|
||||
|
||||
Each runtime contains:
|
||||
|
||||
- a bounded ACP input queue;
|
||||
- a bounded ACP output queue;
|
||||
- a bounded host-fetch bridge;
|
||||
- one ACP worker thread;
|
||||
- synchronization for queue access and destruction;
|
||||
- copied, bounded runtime configuration;
|
||||
- an atomic process-wide runtime slot reservation.
|
||||
|
||||
The worker thread runs `acp_server.runWithTransport()`. It never calls N-API. JavaScript invokes N-API on the Node thread to push ACP input, drain ACP output, take fetch requests, and publish fetch responses.
|
||||
|
||||
Keep the low-level export set small and versioned. The current ABI is documented in `sdk/NAPI.md`. Every exported operation must:
|
||||
|
||||
1. validate argument count and type before access;
|
||||
2. verify the private `napi_type_tag` on runtime handles;
|
||||
3. reject closed handles safely;
|
||||
4. preserve JavaScript exceptions raised by property access;
|
||||
5. return stable error codes where JavaScript branches on the failure class;
|
||||
6. avoid holding queue locks while calling unrelated host operations.
|
||||
|
||||
Use a wrapped object and finalizer rather than exposing pointers or numeric handles. Explicit destruction and garbage collection must converge on the same idempotent path.
|
||||
|
||||
### 4. Design shutdown before the happy path
|
||||
|
||||
Thread and worker shutdown are the highest-risk part of the implementation. Establish these invariants before adding streaming features:
|
||||
|
||||
- destruction removes the runtime pointer before freeing anything;
|
||||
- input is closed before joining the ACP thread;
|
||||
- closing input wakes a blocked reader;
|
||||
- shutting down wakes status waits, response reads, and backpressure waits;
|
||||
- an active Node `fetch` receives an `AbortSignal` cancellation;
|
||||
- Node worker termination can finalize an active stalled runtime without another JavaScript callback;
|
||||
- repeated destruction is harmless;
|
||||
- no allocation is freed while the worker thread may still reference it.
|
||||
|
||||
Test explicit close, abandoned-handle garbage collection, same-environment concurrency, worker isolation, and worker termination separately. A passing request/response test does not prove lifecycle safety.
|
||||
|
||||
### 5. Keep the native capability profile restrictive
|
||||
|
||||
Running native code is not permission to expose native fx capabilities. The N-API core composes ACP with:
|
||||
|
||||
- native tools disabled;
|
||||
- ACP MCP disabled;
|
||||
- background processes unavailable;
|
||||
- secret store unavailable;
|
||||
- list/read/command limits set to zero;
|
||||
- only the host-backed Gateway stream enabled.
|
||||
|
||||
`home` and `workspaceRoot` provide session identity and context only. They are not filesystem capabilities.
|
||||
|
||||
Any future capability requires a typed host boundary, permission review, resource limits, cancellation behavior, and native security tests. Do not enable a shared native provider merely because it is easy to import.
|
||||
|
||||
### 6. Validate Gateway destinations twice
|
||||
|
||||
The JavaScript adapter and native addon both validate `FX_GATEWAY_CHAT_URL`.
|
||||
|
||||
Accepted destinations are:
|
||||
|
||||
- the canonical production Gateway URL; or
|
||||
- explicit HTTP loopback on `127.0.0.1`, `localhost`, or `[::1]`, including a port.
|
||||
|
||||
Reject credentials, fragments, arbitrary HTTPS origins, non-loopback HTTP, and unsupported schemes. The duplicate check is intentional defense in depth because consumers can load `libfx.node` directly and bypass `sdk/node.js`.
|
||||
|
||||
Keep JavaScript, N-API, and shared streamable HTTP endpoint policy aligned.
|
||||
|
||||
### 7. Add the Node adapter without forking the SDK API
|
||||
|
||||
`sdk/node.js` is the Node-specific entry point. It imports the shared implementation from `sdk/fx-sdk.js` and supplies a native `runtimeFactory` when the addon is selected.
|
||||
|
||||
The runtime adapter must implement the same contract as the WASM runtime:
|
||||
|
||||
- `write(data)`;
|
||||
- `closeStdin()`;
|
||||
- `setLineHandler(handler)`;
|
||||
- `exited`;
|
||||
- `abortHostEffects()`;
|
||||
- `abort()`.
|
||||
|
||||
The adapter polls native output and pending fetch work. It buffers ACP output to newline boundaries before JSON parsing. It performs Node `fetch` with an `AbortController`, sends status first, and streams response chunks under native backpressure.
|
||||
|
||||
Backend selection behavior is:
|
||||
|
||||
- `backend: "native"` requires a compatible native addon and fails closed;
|
||||
- `backend: "wasm"` skips native discovery and requires JSPI;
|
||||
- `backend: "auto"` prefers native and may fall back to WASM when JSPI is available.
|
||||
|
||||
Validate `libfxApiVersion` and required exports before use. Automatic discovery may load only the package's expected local addon names. Passing `nativeAddon` explicitly is an explicit request to execute that module.
|
||||
|
||||
Keep `sdk/browser.js` free of Node imports. Keep `sdk/fx-sdk.js` dependency-free and usable directly by browser consumers.
|
||||
|
||||
### 8. Add explicit build surfaces
|
||||
|
||||
`build.zig` exposes independent surface selectors:
|
||||
|
||||
```sh
|
||||
zig build -Dwasm-surface=core
|
||||
zig build -Dwasm-surface=term
|
||||
zig build -Dnapi-surface=core
|
||||
```
|
||||
|
||||
The output paths are:
|
||||
|
||||
- `zig-out/bin/fx-core.wasm`;
|
||||
- `zig-out/bin/fx-term.wasm`;
|
||||
- `zig-out/lib/libfx.node`.
|
||||
|
||||
Optimization policy is intentional:
|
||||
|
||||
- WASM forces `ReleaseSmall`, strips symbols, disables unnecessary frame/unwind/error-tracing features, and remains single-threaded.
|
||||
- N-API forces `ReleaseSafe` and strips symbols. Safety checks remain enabled because the addon handles untrusted JavaScript and protocol input.
|
||||
- The ordinary `fx` executable continues to use the standard optimization option.
|
||||
|
||||
The N-API build locates `node_api.h` from the active Node installation by default. Use `-Dnode-include-dir=<path>` when building against a nonstandard Node layout. Node resolves the addon's undefined N-API symbols at load time.
|
||||
|
||||
When merging build-system work, preserve unrelated selectors such as PGSO artifacts. A merge conflict near top-level enums can silently remove an independent build mode if resolved by choosing one side wholesale.
|
||||
|
||||
### 9. Package all supported native tuples atomically
|
||||
|
||||
The npm package contains:
|
||||
|
||||
- `browser.js`;
|
||||
- `node.js`;
|
||||
- `fx-sdk.js`;
|
||||
- both WASM artifacts;
|
||||
- one native addon for each supported tuple;
|
||||
- `README.md` and `package.json`.
|
||||
|
||||
Supported native names are:
|
||||
|
||||
- `libfx.linux-x64.node`;
|
||||
- `libfx.linux-arm64.node`;
|
||||
- `libfx.darwin-x64.node`;
|
||||
- `libfx.darwin-arm64.node`.
|
||||
|
||||
`package-libfx.mjs` rejects missing, duplicate, and unexpected platform artifacts for publishable assembly. The package has no runtime dependencies and no install script. Do not download or compile native code during consumer installation.
|
||||
|
||||
Validate the exact `npm pack --dry-run --json` file list before publishing. Packaging should copy already-built, already-tested artifacts and must not execute repository code in the publish job.
|
||||
|
||||
### 10. Separate build/test jobs from publication
|
||||
|
||||
`.github/workflows/publish-libfx.yml` builds each native addon on its matching native runner, runs the N-API lane there, and uploads a platform-named artifact. A separate WASM job builds both `ReleaseSmall` surfaces and runs the Node/WASM lane.
|
||||
|
||||
The publish job:
|
||||
|
||||
1. downloads the four native artifacts and both WASM artifacts;
|
||||
2. checks the expected package manifest constraints;
|
||||
3. stages an exact file allowlist;
|
||||
4. sets the release-specific package version;
|
||||
5. validates the npm archive contents;
|
||||
6. checks that a dev publish still targets current `main`;
|
||||
7. publishes with npm trusted publishing and provenance.
|
||||
|
||||
Stable publication is tied to the release tag for the exact commit. Dev versions include the workflow run and commit identity. Never assemble a package from artifacts built for different commits.
|
||||
|
||||
## Resource and security review
|
||||
|
||||
Before review, walk every value that crosses JavaScript, ACP, or fetch boundaries.
|
||||
|
||||
Verify:
|
||||
|
||||
- string lengths are checked before allocation;
|
||||
- queue limits use overflow-safe subtraction;
|
||||
- output and response backpressure cannot grow native memory without bound;
|
||||
- runtime count is atomically limited;
|
||||
- fake handles and use-after-close fail safely;
|
||||
- malformed options release partially constructed state;
|
||||
- URL policy is enforced before any request is handed to Node;
|
||||
- API keys are neither logged nor serialized;
|
||||
- no native tool or MCP advertisement leaks into initialization;
|
||||
- cancellation wakes every possible blocking state;
|
||||
- finalizers are safe during Node worker teardown.
|
||||
|
||||
The authoritative numeric limits and error-code table are in `sdk/NAPI.md`. Update that document and tests together whenever a contract changes.
|
||||
|
||||
## Test strategy
|
||||
|
||||
Use Node.js 24. JSPI-backed tests require `--experimental-wasm-jspi`, which the package scripts apply where needed.
|
||||
|
||||
### Focused development loop
|
||||
|
||||
For native-only changes:
|
||||
|
||||
```sh
|
||||
zig fmt --check src/napi_core_main.zig src/gateway/host_stream_provider.zig
|
||||
zig build -Dnapi-surface=core
|
||||
npm run --prefix sdk test:node-napi
|
||||
```
|
||||
|
||||
For shared loader or JavaScript adapter changes:
|
||||
|
||||
```sh
|
||||
npm run --prefix sdk test:node-napi
|
||||
npm run --prefix sdk test:node-wasm
|
||||
```
|
||||
|
||||
For shared host-stream changes:
|
||||
|
||||
```sh
|
||||
zig build -Dwasm-surface=core
|
||||
zig build -Dwasm-surface=term
|
||||
npm run --prefix sdk test:node-wasm
|
||||
npm run --prefix sdk test:node-napi
|
||||
```
|
||||
|
||||
For browser behavior:
|
||||
|
||||
```sh
|
||||
npm run --prefix sdk test:browser-wasm
|
||||
```
|
||||
|
||||
For terminal adapter changes:
|
||||
|
||||
```sh
|
||||
npm ci --prefix sdk/node
|
||||
npm run --prefix sdk/node test:term
|
||||
```
|
||||
|
||||
### Full local proof for this surface
|
||||
|
||||
```sh
|
||||
zig fmt --check build.zig src/gateway/host_stream_provider.zig \
|
||||
src/gateway/js_host_stream_provider.zig src/napi_core_main.zig
|
||||
zig build test
|
||||
zig build
|
||||
./zig-out/bin/fx --version
|
||||
zig build -Dwasm-surface=core
|
||||
zig build -Dwasm-surface=term
|
||||
zig build -Dnapi-surface=core
|
||||
npm run --prefix sdk test:node-wasm
|
||||
npm run --prefix sdk test:node-napi
|
||||
npm run --prefix sdk test:browser-wasm
|
||||
npm run --prefix sdk/node test:term
|
||||
```
|
||||
|
||||
The native lane covers malformed input, resource limits, handle branding, backpressure, lifecycle, worker concurrency, security restrictions, ACP sessions, Gateway streaming, cancellation, and loader behavior.
|
||||
|
||||
A local pass is not the ship gate. Full CI must pass for the exact current commit on all required Linux and macOS runners, and the built CLI must be exercised locally before reporting the branch ready.
|
||||
|
||||
## Binary-size verification
|
||||
|
||||
Shared transport refactors can affect three artifact classes independently:
|
||||
|
||||
- the ordinary native `fx` executable;
|
||||
- `fx-core.wasm` and `fx-term.wasm`;
|
||||
- the new `libfx.node` addon.
|
||||
|
||||
Measure comparable builds from isolated source copies. Use the same compiler, target, optimization mode, source metadata, and build options. Compare both byte count and SHA-256 when checking for exact identity.
|
||||
|
||||
For the host-stream extraction in this implementation:
|
||||
|
||||
- the `ReleaseSafe` native `fx` executable was byte-for-byte identical before and after;
|
||||
- `fx-core.wasm` increased by 441 bytes;
|
||||
- `fx-term.wasm` increased by 465 bytes;
|
||||
- the N-API addon had no pre-existing baseline because it was introduced by this work.
|
||||
|
||||
Do not compare a dirty incremental output against a clean build or compare artifacts with different embedded commit/version metadata.
|
||||
|
||||
## Patch and recovery workflow
|
||||
|
||||
Before a risky merge or broad conflict resolution:
|
||||
|
||||
1. inspect `git status --short --branch`;
|
||||
2. create a recoverable copy that includes `.git`, staged changes, untracked files, and stashes;
|
||||
3. exclude only generated caches and build outputs;
|
||||
4. verify source and backup `HEAD` values match;
|
||||
5. compare hashes of `git status --porcelain=v1 -z` from source and backup.
|
||||
|
||||
A suitable backup command is:
|
||||
|
||||
```sh
|
||||
stamp=$(date -u +%Y%m%dT%H%M%SZ)
|
||||
dest="/tmp/fx-wasm-napi-backup-$stamp"
|
||||
mkdir -p "$dest"
|
||||
rsync -a --exclude '.zig-cache/' --exclude 'zig-out/' ./ "$dest/"
|
||||
```
|
||||
|
||||
For an uncommitted feature branch, create a named stash, merge current `origin/main`, then reapply the stash. If `git stash pop --index` fails because upstream changed index context, confirm the stash remains intact and apply it without `--index`. Resolve content conflicts manually, stage the resolved files, and retain the safety stash until builds and tests pass.
|
||||
|
||||
When sharing a patch externally, generate it from the intended diff explicitly. For this branch that means the staged feature work plus the merge-base-to-branch committed changes as appropriate. Include a manifest with base commit, head commit, branch, status, file list, and checksums. Never include generated binaries, caches, credentials, or local configuration.
|
||||
|
||||
GitHub secret gists are unlisted, not strongly access-controlled. Anyone with the URL can read them. Use them only for code that is acceptable to expose to URL holders, and call the visibility model out when sharing the link.
|
||||
|
||||
## Common failure modes
|
||||
|
||||
### Container-scope provider is no longer comptime-known
|
||||
|
||||
Symptom: WASM compilation reports that a provider initializer cannot be evaluated at comptime.
|
||||
|
||||
Cause: the thin WebAssembly adapter mutates or constructs provider context at runtime even though the provider is stored in a container-level constant.
|
||||
|
||||
Fix: retain a static `ProviderContext` initialized from compile-time function pointers and return a provider referencing that context.
|
||||
|
||||
### Native tests pass but a worker hangs on termination
|
||||
|
||||
Cause: destruction waits for a native thread that is blocked in input, fetch status, response read, or response backpressure.
|
||||
|
||||
Fix: mark shutdown, close input, broadcast every relevant condition, abort host fetch, then join. Add or run the worker-termination regression directly.
|
||||
|
||||
### Native mode silently falls back
|
||||
|
||||
Cause: forced and automatic backend selection were conflated.
|
||||
|
||||
Fix: `backend: "native"` must surface addon load, version, export, or startup errors. Only automatic mode may fall back.
|
||||
|
||||
### Direct addon callers bypass URL policy
|
||||
|
||||
Cause: validation exists only in `sdk/node.js`.
|
||||
|
||||
Fix: keep equivalent validation in native code before exposing the request to Node.
|
||||
|
||||
### Package succeeds with one local addon
|
||||
|
||||
Cause: a development packaging path was reused for publication without enforcing the full platform matrix.
|
||||
|
||||
Fix: publish assembly must require exactly all four platform names and reject extras. A one-addon package is only a local development artifact.
|
||||
|
||||
### Main executable grows unexpectedly
|
||||
|
||||
Cause: shared N-API code became reachable from `src/main.zig`, or the comparison used different build metadata/options.
|
||||
|
||||
Fix: inspect the import graph, rebuild isolated trees with identical options, and compare hashes. N-API composition should remain rooted at `src/napi_core_main.zig`.
|
||||
|
||||
## Change checklist
|
||||
|
||||
Before landing N-API work:
|
||||
|
||||
1. Confirm the addon remains a transport around shared ACP behavior.
|
||||
2. Confirm native capabilities remain disabled unless explicitly designed and reviewed.
|
||||
3. Review every blocking wait and its shutdown wakeup.
|
||||
4. Review every allocation and partial-construction cleanup path.
|
||||
5. Keep JavaScript and native endpoint validation aligned.
|
||||
6. Run native, Node/WASM, and browser/WASM lanes when shared code changes.
|
||||
7. Build both WASM surfaces after host-stream changes.
|
||||
8. Validate the exact package file allowlist and all native tuples.
|
||||
9. Check formatting and `git diff --check`.
|
||||
10. Exercise `./zig-out/bin/fx` so the ordinary product path is not regressed.
|
||||
11. Require Full CI for the exact current commit before declaring the work ready.
|
||||
12. Update `sdk/NAPI.md` for contract changes and `sdk/README.md` for supported public behavior changes.
|
||||
+2
-1
@@ -76,6 +76,7 @@ The adapter polls `drainCore()` and `takeCoreFetch()` every 2 milliseconds. ACP
|
||||
| `finishCoreFetch(handle)` / `failCoreFetch(handle)` | Completes the host stream successfully or with transport failure. |
|
||||
| `abortCoreFetch(handle)` | Wakes the Zig worker while Node aborts the matching `AbortController`. |
|
||||
| `coreExited(handle)` | Reports whether the ACP thread has exited. |
|
||||
| `coreExitCode(handle)` | Returns the ACP thread's numeric exit status. |
|
||||
| `destroyCore(handle)` | Closes input, joins the thread, and releases native memory. |
|
||||
|
||||
This ABI is internal. Consumers should use `createFxAgent()` from `sdk/node.js`; exposing the primitive functions keeps the native boundary small and testable.
|
||||
@@ -219,7 +220,7 @@ Published packages contain one addon for each supported tuple:
|
||||
- `darwin-x64`;
|
||||
- `darwin-arm64`.
|
||||
|
||||
`package-libfx.mjs` requires exactly those four names when assembling a publishable multi-platform package. It rejects missing, duplicate, or unexpected addon names. The publish workflow builds and tests each addon on its native runner before combining the artifacts with both WebAssembly surfaces.
|
||||
`package-libfx.mjs` requires exactly those four names when assembling a publishable multi-platform package. It rejects missing, duplicate, or unexpected addon names. The publish workflow builds and tests each addon on its native runner before combining the artifacts with both WebAssembly surfaces, the README, and the Apache-2.0 license.
|
||||
|
||||
## Error model
|
||||
|
||||
|
||||
@@ -1092,6 +1092,7 @@ export async function createFxAgent(options) {
|
||||
waiters.splice(0).forEach((resolve) => resolve({ done: true }));
|
||||
});
|
||||
turn.stopReason = turn.result.then((turnResult) => turnResult.stopReason);
|
||||
void turn.stopReason.catch(() => {});
|
||||
if (signal?.aborted) turn.cancel();
|
||||
return turn;
|
||||
},
|
||||
|
||||
+4
-3
@@ -1,6 +1,7 @@
|
||||
import { access, readFile } from "node:fs/promises";
|
||||
import { createRequire } from "node:module";
|
||||
import { homedir } from "node:os";
|
||||
import { isAbsolute, resolve } from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import {
|
||||
createFxAgent as createWasmAgent,
|
||||
@@ -48,7 +49,7 @@ async function loadNativeCandidate(candidate) {
|
||||
if (typeof candidate !== "string") {
|
||||
throw new TypeError("nativeAddon must be a module, path, URL, false, or undefined");
|
||||
}
|
||||
if (candidate.endsWith(".node")) return require(candidate);
|
||||
if (candidate.endsWith(".node")) return require(isAbsolute(candidate) ? candidate : resolve(candidate));
|
||||
const imported = await import(candidate.startsWith("file:") ? candidate : pathToFileURL(candidate).href);
|
||||
return imported.default ?? imported;
|
||||
}
|
||||
@@ -175,7 +176,7 @@ function createNativeCoreRuntime(addon, options) {
|
||||
}
|
||||
addon.finishCoreFetch(core);
|
||||
} catch (error) {
|
||||
if (error?.name !== "AbortError") {
|
||||
if (error?.name !== "AbortError" || !controller.signal.aborted) {
|
||||
try { addon.failCoreFetch(core); } catch {}
|
||||
}
|
||||
} finally {
|
||||
@@ -197,7 +198,7 @@ function createNativeCoreRuntime(addon, options) {
|
||||
if (line) lineHandler(JSON.parse(line));
|
||||
}
|
||||
}
|
||||
if (addon.coreExited(core)) finish(0);
|
||||
if (addon.coreExited(core)) finish(addon.coreExitCode(core));
|
||||
} catch {
|
||||
finish(1);
|
||||
}
|
||||
|
||||
+2
-1
@@ -32,7 +32,8 @@
|
||||
"fx-core.wasm",
|
||||
"fx-term.wasm",
|
||||
"*.node",
|
||||
"README.md"
|
||||
"README.md",
|
||||
"LICENSE"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
|
||||
@@ -18,6 +18,7 @@ const requiredNativeNames = new Set([
|
||||
const files = [
|
||||
["sdk/package.json", "package.json"],
|
||||
["sdk/README.md", "README.md"],
|
||||
["LICENSE", "LICENSE"],
|
||||
["sdk/browser.js", "browser.js"],
|
||||
["sdk/node.js", "node.js"],
|
||||
["sdk/fx-sdk.js", "fx-sdk.js"],
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
#!/usr/bin/env node
|
||||
import { strict as assert } from "node:assert";
|
||||
import { createRequire } from "node:module";
|
||||
import { resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const scriptDir = fileURLToPath(new URL(".", import.meta.url));
|
||||
const addonPath = resolve(process.argv[2] || resolve(scriptDir, "../../zig-out/lib/libfx.node"));
|
||||
const addon = require(addonPath);
|
||||
const core = addon.createCore({
|
||||
apiKey: "cancel-before-fetch-key",
|
||||
model: "native/test-model",
|
||||
home: "/tmp",
|
||||
workspaceRoot: "/tmp",
|
||||
gatewayChatUrl: "http://127.0.0.1:31337/chat",
|
||||
});
|
||||
|
||||
let nextId = 1;
|
||||
let buffered = "";
|
||||
async function request(method, params = {}) {
|
||||
const id = nextId++;
|
||||
addon.writeCore(core, Buffer.from(`${JSON.stringify({ jsonrpc: "2.0", id, method, params })}\n`));
|
||||
const deadline = Date.now() + 5000;
|
||||
while (Date.now() < deadline) {
|
||||
buffered += addon.drainCore(core).toString("utf8");
|
||||
const lines = buffered.split("\n");
|
||||
buffered = lines.pop();
|
||||
for (const line of lines) {
|
||||
if (!line) continue;
|
||||
const message = JSON.parse(line);
|
||||
if (message.id === id) return message;
|
||||
}
|
||||
await new Promise((resolveWait) => setTimeout(resolveWait, 2));
|
||||
}
|
||||
throw new Error(`timed out waiting for ${method}`);
|
||||
}
|
||||
|
||||
try {
|
||||
assert.ok((await request("initialize", { protocolVersion: 1, clientCapabilities: {} })).result);
|
||||
const created = await request("session/new");
|
||||
const promptId = nextId++;
|
||||
addon.writeCore(core, Buffer.from(`${JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
id: promptId,
|
||||
method: "session/prompt",
|
||||
params: { sessionId: created.result.sessionId, prompt: [{ type: "text", text: "cancel before host fetch" }] },
|
||||
})}\n`));
|
||||
await new Promise((resolveWait) => setTimeout(resolveWait, 100));
|
||||
addon.writeCore(core, Buffer.from(`${JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
method: "session/cancel",
|
||||
params: { sessionId: created.result.sessionId },
|
||||
})}\n`));
|
||||
addon.abortCoreFetch(core);
|
||||
await new Promise((resolveWait) => setTimeout(resolveWait, 10));
|
||||
assert.equal(addon.takeCoreFetch(core), null, "cancelled native request remained available to the Node fetch poller");
|
||||
console.log("native pre-fetch cancellation passed: no stale request survives abort");
|
||||
} finally {
|
||||
addon.closeCore(core);
|
||||
addon.destroyCore(core);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
#!/usr/bin/env node
|
||||
import { strict as assert } from "node:assert";
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { createServer } from "node:http";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { createFxAgent } from "../node.js";
|
||||
|
||||
const marker = "LIBFX_EXPLICIT_WORKSPACE_CONTEXT";
|
||||
const originalCwd = process.cwd();
|
||||
const processWorkspace = await mkdtemp(join(tmpdir(), "libfx-process-workspace-"));
|
||||
const runtimeHome = await mkdtemp(join(tmpdir(), "libfx-runtime-home-"));
|
||||
const runtimeWorkspace = await mkdtemp(join(tmpdir(), "libfx-runtime-workspace-"));
|
||||
await writeFile(join(processWorkspace, ".fx.json"), `${JSON.stringify({ context: false })}\n`);
|
||||
await writeFile(join(runtimeWorkspace, ".fx.json"), `${JSON.stringify({ context: true })}\n`);
|
||||
await writeFile(join(runtimeWorkspace, "AGENTS.md"), `# Context\n\n${marker}\n`);
|
||||
|
||||
let requestBody = "";
|
||||
const server = createServer((request, response) => {
|
||||
request.setEncoding("utf8");
|
||||
request.on("data", (chunk) => { requestBody += chunk; });
|
||||
request.on("end", () => {
|
||||
response.writeHead(200, { "content-type": "text/event-stream" });
|
||||
response.write('data: {"type":"text-delta","delta":"isolated"}\n\n');
|
||||
response.write('data: {"type":"finish","finishReason":{"unified":"stop","raw":"stop"},"usage":{"inputTokens":{"total":1},"outputTokens":{"total":1}}}\n\n');
|
||||
response.end("data: [DONE]\n\n");
|
||||
});
|
||||
});
|
||||
await new Promise((resolveListen) => server.listen(0, "127.0.0.1", resolveListen));
|
||||
const { port } = server.address();
|
||||
const scriptDir = fileURLToPath(new URL(".", import.meta.url));
|
||||
const addon = resolve(process.argv[2] || resolve(scriptDir, "../../zig-out/lib/libfx.node"));
|
||||
|
||||
try {
|
||||
process.chdir(processWorkspace);
|
||||
const agent = await createFxAgent({
|
||||
nativeAddon: addon,
|
||||
backend: "native",
|
||||
home: runtimeHome,
|
||||
workspaceRoot: runtimeWorkspace,
|
||||
env: {
|
||||
AI_GATEWAY_API_KEY: "native-core-config-key",
|
||||
FX_GATEWAY_CHAT_URL: `http://127.0.0.1:${port}/chat`,
|
||||
FX_MODEL: "native/test-model",
|
||||
},
|
||||
});
|
||||
const session = await agent.createSession();
|
||||
const turn = session.prompt("read the explicit workspace context");
|
||||
await turn.result;
|
||||
assert.match(requestBody, new RegExp(marker), "native startup must load context policy from workspaceRoot, not process.cwd()" );
|
||||
await session.close();
|
||||
assert.equal(await agent.close(), 0);
|
||||
console.log("native config isolation passed: explicit home and workspace own startup state");
|
||||
} finally {
|
||||
process.chdir(originalCwd);
|
||||
server.closeAllConnections();
|
||||
server.close();
|
||||
await Promise.all([
|
||||
rm(processWorkspace, { recursive: true, force: true }),
|
||||
rm(runtimeHome, { recursive: true, force: true }),
|
||||
rm(runtimeWorkspace, { recursive: true, force: true }),
|
||||
]);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
#!/usr/bin/env node
|
||||
import { strict as assert } from "node:assert";
|
||||
import { createRequire } from "node:module";
|
||||
import { resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const scriptDir = fileURLToPath(new URL(".", import.meta.url));
|
||||
const addonPath = resolve(process.argv[2] || resolve(scriptDir, "../../zig-out/lib/libfx.node"));
|
||||
const addon = require(addonPath);
|
||||
const core = addon.createCore({ apiKey: "exit-code-test-key", home: "/tmp", workspaceRoot: "/tmp" });
|
||||
|
||||
try {
|
||||
addon.writeCore(core, Buffer.from("{}\n".repeat(180_000)));
|
||||
const deadline = Date.now() + 5000;
|
||||
while (!addon.coreExited(core) && Date.now() < deadline) {
|
||||
await new Promise((resolveWait) => setTimeout(resolveWait, 5));
|
||||
}
|
||||
assert.equal(addon.coreExited(core), true, "output backpressure did not stop the native runtime");
|
||||
assert.notEqual(addon.coreExitCode(core), 0, "native output failure must not report a graceful exit");
|
||||
console.log("native exit code passed: output backpressure reports a nonzero runtime status");
|
||||
} finally {
|
||||
addon.destroyCore(core);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
#!/usr/bin/env node
|
||||
import { strict as assert } from "node:assert";
|
||||
import { resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { createFxAgent } from "../node.js";
|
||||
|
||||
const scriptDir = fileURLToPath(new URL(".", import.meta.url));
|
||||
const addon = resolve(process.argv[2] || resolve(scriptDir, "../../zig-out/lib/libfx.node"));
|
||||
let timeoutId;
|
||||
const timeout = new Promise((_, reject) => {
|
||||
timeoutId = setTimeout(() => reject(new Error("native host-fetch failure hung")), 5000);
|
||||
});
|
||||
const agent = await createFxAgent({
|
||||
nativeAddon: addon,
|
||||
backend: "native",
|
||||
fetch() {
|
||||
const error = new Error("host timeout");
|
||||
error.name = "AbortError";
|
||||
throw error;
|
||||
},
|
||||
env: {
|
||||
AI_GATEWAY_API_KEY: "native-core-fetch-failure-key",
|
||||
FX_MODEL: "native/test-model",
|
||||
},
|
||||
});
|
||||
|
||||
let closed = false;
|
||||
try {
|
||||
const session = await agent.createSession();
|
||||
const turn = session.prompt("fail host fetch");
|
||||
await assert.rejects(
|
||||
Promise.race([turn.result, timeout]),
|
||||
(error) => error.message !== "native host-fetch failure hung",
|
||||
);
|
||||
await session.close();
|
||||
assert.equal(await agent.close(), 0);
|
||||
closed = true;
|
||||
console.log("native host-fetch failure passed: independent AbortError fails without hanging");
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
if (!closed) agent.abort();
|
||||
}
|
||||
@@ -16,6 +16,7 @@ for (const [name, args] of [
|
||||
["closeCore", []],
|
||||
["drainCore", []],
|
||||
["coreExited", []],
|
||||
["coreExitCode", []],
|
||||
["destroyCore", []],
|
||||
]) {
|
||||
assert.throws(() => addon[name](...args), {
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
#!/usr/bin/env node
|
||||
import { strict as assert } from "node:assert";
|
||||
import { resolve } from "node:path";
|
||||
import { relative, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { createFxAgent } from "../node.js";
|
||||
|
||||
const scriptDir = fileURLToPath(new URL(".", import.meta.url));
|
||||
const addon = resolve(process.argv[2] || resolve(scriptDir, "../../zig-out/lib/libfx.node"));
|
||||
const defaultAddon = resolve(scriptDir, "../../zig-out/lib/libfx.node");
|
||||
const addon = process.argv[2] || `./${relative(process.cwd(), defaultAddon)}`;
|
||||
const events = [];
|
||||
const agent = await createFxAgent({
|
||||
nativeAddon: addon,
|
||||
|
||||
@@ -8,8 +8,12 @@ const scripts = [
|
||||
"test-native-core-workers.mjs",
|
||||
"test-native-core-worker-termination.mjs",
|
||||
"test-native-core-security.mjs",
|
||||
"test-native-core-exit-code.mjs",
|
||||
"test-native-core.mjs",
|
||||
"test-native-core-config-isolation.mjs",
|
||||
"test-native-core-stream.mjs",
|
||||
"test-native-core-fetch-failure.mjs",
|
||||
"test-native-core-cancel-before-fetch.mjs",
|
||||
"test-native-core-cancel.mjs",
|
||||
"test-libfx-loader.mjs",
|
||||
];
|
||||
|
||||
+25
-13
@@ -1120,6 +1120,29 @@ fn parseInitializeRequest(
|
||||
return request;
|
||||
}
|
||||
|
||||
fn loadConfiguredStartupState(state: *const ServerState, alloc: Allocator, default_fast_mode: bool) !app_lifecycle.StartupState {
|
||||
if (state.cfg.home_override) |home_dir| {
|
||||
if (state.cfg.workspace_root_override) |workspace_root| {
|
||||
return app_lifecycle.loadEmbeddedStartupState(
|
||||
alloc,
|
||||
home_dir,
|
||||
workspace_root,
|
||||
state.cfg.default_model,
|
||||
default_fast_mode,
|
||||
state.cfg.default_agent_step_limit,
|
||||
);
|
||||
}
|
||||
}
|
||||
return app_lifecycle.loadStartupState(
|
||||
alloc,
|
||||
state.cfg.gateway_provider.oauth_transport,
|
||||
state.cfg.secret_store,
|
||||
state.cfg.default_model,
|
||||
default_fast_mode,
|
||||
state.cfg.default_agent_step_limit,
|
||||
);
|
||||
}
|
||||
|
||||
fn handleInitialize(state: *ServerState, alloc: Allocator, msg: *jsonrpc.Message) !void {
|
||||
if (state.initialized) {
|
||||
return state.writer.writeError(alloc, msg.id, .{
|
||||
@@ -1136,14 +1159,7 @@ fn handleInitialize(state: *ServerState, alloc: Allocator, msg: *jsonrpc.Message
|
||||
};
|
||||
|
||||
const effective_default_fast_mode = state.cfg.default_fast_mode and state.cfg.model_override == null;
|
||||
var startup = app_lifecycle.loadStartupState(
|
||||
alloc,
|
||||
state.cfg.gateway_provider.oauth_transport,
|
||||
state.cfg.secret_store,
|
||||
state.cfg.default_model,
|
||||
effective_default_fast_mode,
|
||||
state.cfg.default_agent_step_limit,
|
||||
) catch |err| {
|
||||
var startup = loadConfiguredStartupState(state, alloc, effective_default_fast_mode) catch |err| {
|
||||
return state.writer.writeError(alloc, msg.id, .{
|
||||
.code = ErrorCode.internal_error,
|
||||
.message = sandbox.configErrorMessage(err) orelse "Failed to load startup state",
|
||||
@@ -1170,11 +1186,7 @@ fn handleInitialize(state: *ServerState, alloc: Allocator, msg: *jsonrpc.Message
|
||||
}
|
||||
}
|
||||
|
||||
state.workspace_root = if (state.cfg.workspace_root_override) |root|
|
||||
try alloc.dupe(u8, root)
|
||||
else
|
||||
startup.takeWorkspaceRoot();
|
||||
if (state.cfg.workspace_root_override != null) alloc.free(startup.takeWorkspaceRoot());
|
||||
state.workspace_root = startup.takeWorkspaceRoot();
|
||||
state.workspace_access = startup.takeWorkspaceAccess();
|
||||
var credential = if (state.cfg.credential_override) |override| credentials.Credential{
|
||||
.token = try alloc.dupe(u8, override),
|
||||
|
||||
@@ -267,12 +267,34 @@ pub fn loadStartupState(
|
||||
default_agent_step_limit: usize,
|
||||
) !StartupState {
|
||||
const workspace_root = try io_mod.realpathAlloc(alloc, ".");
|
||||
return loadStartupStateFromOwnedWorkspace(alloc, transport, secret_store, workspace_root, default_model, default_fast_mode, default_agent_step_limit, .refresh_if_needed);
|
||||
return loadStartupStateFromOwnedWorkspace(alloc, transport, secret_store, workspace_root, default_model, default_fast_mode, default_agent_step_limit, null, .refresh_if_needed);
|
||||
}
|
||||
|
||||
pub fn loadStartupStateWithoutCredentials(alloc: Allocator, default_model: []const u8, default_fast_mode: bool, default_agent_step_limit: usize) !StartupState {
|
||||
const workspace_root = try io_mod.realpathAlloc(alloc, ".");
|
||||
return loadStartupStateFromOwnedWorkspace(alloc, oauth_transport.unavailable_provider, host.unavailable_secret_store, workspace_root, default_model, default_fast_mode, default_agent_step_limit, null);
|
||||
return loadStartupStateFromOwnedWorkspace(alloc, oauth_transport.unavailable_provider, host.unavailable_secret_store, workspace_root, default_model, default_fast_mode, default_agent_step_limit, null, null);
|
||||
}
|
||||
|
||||
pub fn loadEmbeddedStartupState(
|
||||
alloc: Allocator,
|
||||
home_dir: []const u8,
|
||||
workspace_root: []const u8,
|
||||
default_model: []const u8,
|
||||
default_fast_mode: bool,
|
||||
default_agent_step_limit: usize,
|
||||
) !StartupState {
|
||||
const owned_workspace_root = try io_mod.realpathAlloc(alloc, workspace_root);
|
||||
return loadStartupStateFromOwnedWorkspace(
|
||||
alloc,
|
||||
oauth_transport.unavailable_provider,
|
||||
host.unavailable_secret_store,
|
||||
owned_workspace_root,
|
||||
default_model,
|
||||
default_fast_mode,
|
||||
default_agent_step_limit,
|
||||
home_dir,
|
||||
null,
|
||||
);
|
||||
}
|
||||
|
||||
pub fn loadCatalogStartupState(
|
||||
@@ -283,7 +305,7 @@ pub fn loadCatalogStartupState(
|
||||
default_agent_step_limit: usize,
|
||||
) !StartupState {
|
||||
const workspace_root = try io_mod.realpathAlloc(alloc, ".");
|
||||
return loadStartupStateFromOwnedWorkspace(alloc, oauth_transport.unavailable_provider, secret_store, workspace_root, default_model, default_fast_mode, default_agent_step_limit, .stored);
|
||||
return loadStartupStateFromOwnedWorkspace(alloc, oauth_transport.unavailable_provider, secret_store, workspace_root, default_model, default_fast_mode, default_agent_step_limit, null, .stored);
|
||||
}
|
||||
|
||||
pub fn loadStartupStatus(
|
||||
@@ -338,12 +360,12 @@ pub fn applyWorkspaceLaunch(
|
||||
|
||||
fn loadStartupStateForWorkspace(alloc: Allocator, workspace_root: []const u8, default_model: []const u8, default_agent_step_limit: usize) !StartupState {
|
||||
const owned_workspace_root = try alloc.dupe(u8, workspace_root);
|
||||
return loadStartupStateFromOwnedWorkspace(alloc, oauth_transport.unavailable_provider, host.unavailable_secret_store, owned_workspace_root, default_model, false, default_agent_step_limit, null);
|
||||
return loadStartupStateFromOwnedWorkspace(alloc, oauth_transport.unavailable_provider, host.unavailable_secret_store, owned_workspace_root, default_model, false, default_agent_step_limit, null, null);
|
||||
}
|
||||
|
||||
fn loadStartupStateForWorkspaceWithFastDefault(alloc: Allocator, workspace_root: []const u8, default_model: []const u8, default_agent_step_limit: usize) !StartupState {
|
||||
const owned_workspace_root = try alloc.dupe(u8, workspace_root);
|
||||
return loadStartupStateFromOwnedWorkspace(alloc, oauth_transport.unavailable_provider, host.unavailable_secret_store, owned_workspace_root, default_model, true, default_agent_step_limit, null);
|
||||
return loadStartupStateFromOwnedWorkspace(alloc, oauth_transport.unavailable_provider, host.unavailable_secret_store, owned_workspace_root, default_model, true, default_agent_step_limit, null, null);
|
||||
}
|
||||
|
||||
const CredentialLoadMode = credentials.LoadMode;
|
||||
@@ -356,6 +378,7 @@ fn loadStartupStateFromOwnedWorkspace(
|
||||
default_model: []const u8,
|
||||
default_fast_mode: bool,
|
||||
default_agent_step_limit: usize,
|
||||
profile_home: ?[]const u8,
|
||||
credential_mode: ?CredentialLoadMode,
|
||||
) !StartupState {
|
||||
var state = StartupState{ .agent_step_limit = default_agent_step_limit };
|
||||
@@ -363,7 +386,10 @@ fn loadStartupStateFromOwnedWorkspace(
|
||||
|
||||
state.workspace_root = owned_workspace_root;
|
||||
debug_trace.configureFromEnv(alloc, state.workspace_root);
|
||||
var detailed = try config_runtime.loadMergedSettingsDetailed(alloc, state.workspace_root);
|
||||
var detailed = if (profile_home) |home_dir|
|
||||
try config_runtime.loadMergedSettingsDetailedFromHome(alloc, home_dir, state.workspace_root)
|
||||
else
|
||||
try config_runtime.loadMergedSettingsDetailed(alloc, state.workspace_root);
|
||||
defer detailed.deinit(alloc);
|
||||
const settings = &detailed.settings;
|
||||
|
||||
|
||||
@@ -24,8 +24,7 @@ const provider_context = host_stream_provider.initContext(builtin_gateway.buildA
|
||||
.close_fn = close,
|
||||
});
|
||||
|
||||
pub fn provider(build_fn: stream_provider.BuildFn) stream_provider.Provider {
|
||||
_ = build_fn;
|
||||
pub fn provider() stream_provider.Provider {
|
||||
return host_stream_provider.provider(@constCast(&provider_context));
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -427,7 +427,7 @@ const App = struct {
|
||||
|
||||
pub fn agentStreamProvider(_: *const Self) agent_stream_provider.Provider {
|
||||
return if (comptime host_target.is_wasm)
|
||||
js_host_stream_provider.provider(builtin_gateway.buildAgentRequest)
|
||||
js_host_stream_provider.provider()
|
||||
else
|
||||
builtin_gateway.agent_stream_provider;
|
||||
}
|
||||
|
||||
+30
-2
@@ -6,6 +6,7 @@ const background_process_provider = @import("core/execution/background_process_p
|
||||
const gateway_provider = @import("core/gateway/gateway_provider.zig");
|
||||
const generation_usage_provider = @import("core/session/generation_usage_provider.zig");
|
||||
const host = @import("core/hosts/host.zig");
|
||||
const debug_trace = @import("core/shared/debug_trace.zig");
|
||||
const io_mod = @import("core/shared/io.zig");
|
||||
const streamable_http = @import("core/mcp/streamable_http.zig");
|
||||
const host_stream_provider = @import("gateway/host_stream_provider.zig");
|
||||
@@ -159,6 +160,13 @@ const FetchBridge = struct {
|
||||
shutting_down: bool = false,
|
||||
status: u16 = 0,
|
||||
|
||||
fn clearPendingRequest(self: *FetchBridge, reason: []const u8) void {
|
||||
if (!self.request_pending) return;
|
||||
debug_trace.logf("napi", "dropping pending host fetch reason={s} bytes={d}", .{ reason, self.request.items.len });
|
||||
self.request.clearRetainingCapacity();
|
||||
self.request_pending = false;
|
||||
}
|
||||
|
||||
fn open(raw: ?*anyopaque, method: []const u8, url: []const u8, headers: []const u8, body: []const u8) !i32 {
|
||||
const self: *FetchBridge = @ptrCast(@alignCast(raw.?));
|
||||
const io = io_mod.getIo();
|
||||
@@ -166,6 +174,7 @@ const FetchBridge = struct {
|
||||
defer self.mutex.unlock(io);
|
||||
if (self.shutting_down) return error.HostStreamUnavailable;
|
||||
if (self.aborted) {
|
||||
self.clearPendingRequest("open_after_abort");
|
||||
self.aborted = false;
|
||||
return error.Cancelled;
|
||||
}
|
||||
@@ -226,6 +235,7 @@ const FetchBridge = struct {
|
||||
const self: *FetchBridge = @ptrCast(@alignCast(raw.?));
|
||||
const io = io_mod.getIo();
|
||||
self.mutex.lockUncancelable(io);
|
||||
self.clearPendingRequest("stream_close");
|
||||
self.response_started = false;
|
||||
self.response_done = false;
|
||||
self.failed = false;
|
||||
@@ -298,6 +308,7 @@ const FetchBridge = struct {
|
||||
fn abort(self: *FetchBridge) void {
|
||||
const io = io_mod.getIo();
|
||||
self.mutex.lockUncancelable(io);
|
||||
self.clearPendingRequest("abort");
|
||||
self.aborted = true;
|
||||
self.wake.broadcast(io);
|
||||
self.mutex.unlock(io);
|
||||
@@ -306,6 +317,7 @@ const FetchBridge = struct {
|
||||
fn shutdown(self: *FetchBridge) void {
|
||||
const io = io_mod.getIo();
|
||||
self.mutex.lockUncancelable(io);
|
||||
self.clearPendingRequest("shutdown");
|
||||
self.shutting_down = true;
|
||||
self.aborted = true;
|
||||
self.wake.broadcast(io);
|
||||
@@ -340,7 +352,11 @@ const Runtime = struct {
|
||||
|
||||
fn writeOutput(context: ?*anyopaque, bytes: []const u8) !void {
|
||||
const self: *Runtime = @ptrCast(@alignCast(context.?));
|
||||
try self.output.write(self.alloc, bytes);
|
||||
self.output.write(self.alloc, bytes) catch |err| {
|
||||
debug_trace.logf("napi", "native output failed err={s}", .{@errorName(err)});
|
||||
self.exit_code.store(1, .seq_cst);
|
||||
return err;
|
||||
};
|
||||
}
|
||||
|
||||
fn run(self: *Runtime) void {
|
||||
@@ -385,7 +401,8 @@ const Runtime = struct {
|
||||
},
|
||||
jsonrpc.Reader.initCallback(self, Runtime.readInput),
|
||||
jsonrpc.Writer.initCallback(self, Runtime.writeOutput),
|
||||
) catch {
|
||||
) catch |err| {
|
||||
debug_trace.logf("napi", "native runtime failed err={s}", .{@errorName(err)});
|
||||
self.exit_code.store(1, .seq_cst);
|
||||
};
|
||||
self.exited.store(true, .seq_cst);
|
||||
@@ -801,6 +818,16 @@ fn coreExited(env: c.napi_env, info: c.napi_callback_info) callconv(.c) c.napi_v
|
||||
return value;
|
||||
}
|
||||
|
||||
fn coreExitCode(env: c.napi_env, info: c.napi_callback_info) callconv(.c) c.napi_value {
|
||||
var argv: [1]c.napi_value = undefined;
|
||||
const handle = runtimeHandleArg(env, info, &argv) orelse return null;
|
||||
const runtime = lockRuntime(env, handle) orelse return null;
|
||||
defer unlockRuntime(handle);
|
||||
var value: c.napi_value = undefined;
|
||||
_ = c.napi_create_uint32(env, runtime.exit_code.load(.seq_cst), &value);
|
||||
return value;
|
||||
}
|
||||
|
||||
fn destroyCore(env: c.napi_env, info: c.napi_callback_info) callconv(.c) c.napi_value {
|
||||
var argv: [1]c.napi_value = undefined;
|
||||
const handle = runtimeHandleArg(env, info, &argv) orelse return null;
|
||||
@@ -832,6 +859,7 @@ export fn napi_register_module_v1(env: c.napi_env, exports: c.napi_value) callco
|
||||
if (!exportFunction(env, exports, "failCoreFetch", failCoreFetch)) return null;
|
||||
if (!exportFunction(env, exports, "abortCoreFetch", abortCoreFetch)) return null;
|
||||
if (!exportFunction(env, exports, "coreExited", coreExited)) return null;
|
||||
if (!exportFunction(env, exports, "coreExitCode", coreExitCode)) return null;
|
||||
if (!exportFunction(env, exports, "destroyCore", destroyCore)) return null;
|
||||
return exports;
|
||||
}
|
||||
@@ -57,7 +57,7 @@ pub fn main(init: std.process.Init) !void {
|
||||
}
|
||||
|
||||
const js_host_gateway_provider = gateway_provider.Provider{
|
||||
.agent_stream = js_host_stream_provider.provider(builtin_gateway.buildAgentRequest),
|
||||
.agent_stream = js_host_stream_provider.provider(),
|
||||
.oauth_transport = oauth_transport.unavailable_provider,
|
||||
.chat_url = .{ .resolve_fn = resolveChatUrl },
|
||||
.cli_model_catalog = .{ .fetch_fn = fetchCliModelCatalog },
|
||||
|
||||
Reference in new issue
Block a user