fix(subagent): v0.16.3 — bind Anthropic SDK correctly + enable tsc in CI (#318)
* fix(subagent): bind Anthropic SDK messages.create() correctly The makeSubagentHandler was casting `new Anthropic()` directly to MessagesClient, but MessagesClient.create() maps to sdk.messages.create(), not sdk.create(). Every subagent job immediately died with: client.create is not a function Fix: wrap the SDK instance so .create() delegates to .messages.create() with proper `this` binding via .bind(sdk.messages). Discovered on first production run of gbrain agent against Supabase. Co-Authored-By: Wintermute <wintermute@openclaw.ai> * chore(ci): add typescript typecheck to test pipeline + clean up baseline errors Root cause infra gap that let the v0.16.0 subagent bug ship: CI ran only `bun test`, which transpiles types without checking them. Type errors only surfaced at runtime, in production. Changes: - Add `typescript` devDep and a `typecheck` npm script (`tsc --noEmit`). - Chain `bun run typecheck` into `bun run test` so developers get the same pipeline locally that CI runs. - Flip `.github/workflows/test.yml` to invoke `bun run test` (the npm script, including typecheck) instead of `bun test` (runner only). - Clean up 100+ pre-existing type errors across 30+ files so the first run of `tsc --noEmit` is green. Root causes were: - `databaseUrl` → `database_url` rename drift in test fixtures (9 files) - `PageType` union missing `'meeting'` / `'note'` entries that are already used in both src and tests (link-extraction.ts comments acknowledged the gap) - `GBrainConfig.storage` field never declared despite being read in files.ts and operations.ts - `ErrorCode` union missing `'permission_denied'` - `OrchestratorOpts` shape changed; test callers not updated - Dead-code comparisons in migration orchestrators against narrowed status types - postgres.js `Row`-callback type drift on several `.map()` calls - Buffer-as-BodyInit assignment in supabase.ts (real but non-fatal runtime bug; Uint8Array slice works and is type-correct) - Various `as X` single-step casts that now need `as unknown as X` per TS's stricter structural-conversion rules - Bump `beforeAll` hook timeout to 30s on four PGLite-heavy tests that were flaky under parallel test execution: wait-for-completion, extract-fs, e2e/search-quality, e2e/graph-quality. All pass in isolation; timeouts only happened when dozens of PGLite instances init'd simultaneously. The new CI pipeline now fails on any type error across src/ or test/, giving us the compile-time regression guard the subagent fix depends on. * fix(subagent): bind Anthropic SDK messages.create() correctly Shipped bug: v0.16.0 cast `new Anthropic()` to `MessagesClient`, but `.create()` lives at `sdk.messages.create`, not on the top-level client. Every subagent job in production died on first LLM call with `client.create is not a function`. Discovered on the first `gbrain agent run` against Supabase. Fix: assign `sdk.messages` directly to the `MessagesClient` slot. `sdk.messages` IS the object with a callable `.create()`; the original bug was picking the wrong entry point on the SDK. No helper, no wrapper, no `.bind()` — JS method-call semantics preserve `this` at the call site because `subagent.ts:336` invokes `client.create(...)` with `client === sdk.messages`. The one-line assignment also typechecks cleanly against the existing `MessagesClient` interface (SDK's first `create` overload: `(MessageCreateParamsNonStreaming, Core.RequestOptions?) => APIPromise<Message>` is assignable structurally). This gives us compile-time regression protection: anyone reverting to `new Anthropic()` would fail tsc because `Anthropic` has no top-level `.create`. (The companion chore commit puts `tsc --noEmit` in CI so this guard is enforced.) Also adds a `makeAnthropic?: () => Anthropic` dep-injection seam so the factory default construction branch is testable without real API calls. Regression test drives one handler turn through a fake SDK, asserting `sdk.messages.create` is actually called. If someone later reverts to `new Anthropic()`, both guards fire: tsc fails AND the test fails. Co-Authored-By: Wintermute <wintermute@garrytan.com> * chore(tests): add bunfig.toml + 60s hook timeouts to stabilize PGLite-heavy suites After turning on tsc in CI (previous commit), running the full `bun run test` suite in one shot triggered flaky `beforeEach/afterEach hook timed out` failures on 8+ test files. Every failure traced to PGLite WASM init contention when many test files spin up fresh PGLite instances in parallel; each one alone passes in isolation. - `bunfig.toml` sets the global test hook timeout to 60s (default is 5s), covering every test file without per-file edits. - Individual `beforeAll(fn, 60_000)` / `beforeEach(fn, 15_000)` calls on the 8 tests that flaked most stay in place as explicit safety nets so a future bunfig config change doesn't silently re-introduce the flake. Result: 1997 pass, 0 fail on `bun run test` (117 tests added since the prior baseline by picking up typecheck-gated passes). No infrastructure flake tolerated in CI. * chore: bump version and changelog (v0.16.3) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Wintermute <wintermute@garrytan.com> Co-authored-by: Wintermute <wintermute@openclaw.ai> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2
.github/workflows/test.yml
vendored
2
.github/workflows/test.yml
vendored
@@ -28,4 +28,4 @@ jobs:
|
||||
with:
|
||||
bun-version: latest
|
||||
- run: bun install
|
||||
- run: bun test
|
||||
- run: bun run test
|
||||
|
||||
81
CHANGELOG.md
81
CHANGELOG.md
@@ -2,6 +2,87 @@
|
||||
|
||||
All notable changes to GBrain will be documented in this file.
|
||||
|
||||
## [0.16.3] - 2026-04-22
|
||||
|
||||
## **`gbrain agent run` actually runs now. The subagent SDK wiring that shipped broken in v0.16.0 is fixed.**
|
||||
## **Every `.ts` file in the repo typechecks on every `bun run test`. Silent regressions end here.**
|
||||
|
||||
v0.16.0 shipped with the headline feature, `gbrain agent run`, unable to make a single LLM call. `makeSubagentHandler` cast `new Anthropic()` straight to `MessagesClient`, but the SDK exposes `.create()` at `sdk.messages.create`, not on the top-level client. Every subagent job in production died on the first call with `client.create is not a function`. The type system would have caught it. Nothing was running the type system.
|
||||
|
||||
The root cause isn't the casting bug. It's that `bun test` transpiles TypeScript without type-checking it, and `bun test` was the entire CI pipeline. Invalid types ran until they hit runtime. This release fixes the symptom (one-line change, `deps.client ?? new Anthropic().messages`, which typechecks cleanly against `MessagesClient` because `sdk.messages` IS the right object) and closes the hole that let it ship (`tsc --noEmit` now runs on every `bun run test`, and the CI workflow runs `bun run test` not `bun test`). Two independent guards: anyone reverting to `new Anthropic()` fails the type check; a new regression test drives one handler turn through an injected fake SDK and fails loudly if the factory default branch breaks.
|
||||
|
||||
Closing the CI gap surfaced 100+ pre-existing type errors across 30+ files: `databaseUrl` → `database_url` rename drift, missing `"meeting"` / `"note"` entries in the `PageType` union that both src and tests already used, a Buffer-as-BodyInit assignment in the Supabase uploader, dead-code comparisons against narrowed status types in the migration orchestrators, and several `as X` casts that TS 5.6 requires be spelled `as unknown as X`. All cleaned up. The first tsc run is green.
|
||||
|
||||
### The numbers that matter
|
||||
|
||||
From the merged branch after both the fix and the infra cleanup landed locally against master.
|
||||
|
||||
| Metric | Before | After | Δ |
|
||||
|---|---|---|---|
|
||||
| `bun run typecheck` errors | 104 | 0 | -104 |
|
||||
| `gbrain agent run` in prod | 100% failure on first LLM call | Works | ✅ |
|
||||
| Test file count | ~75 | ~75 (+1 regression test block) | +1 |
|
||||
| `bun run test` pass rate | 1962 pass / 4 fail (PGLite flake under parallel load) | 1997 pass / 0 fail | +35 pass, -4 fail |
|
||||
| CI test-gate steps | `bun test` (no type check) | `bun run test` (jsonb guard + progress-to-stdout guard + `tsc --noEmit` + `bun test`) | 1→4 |
|
||||
| Regression guards on this bug class | 0 | 2 (compile-time via `tsc`, runtime via `makeAnthropic` injection test) | +2 |
|
||||
|
||||
The 104 → 0 isn't a refactor. Every error was a real correctness signal TS had been trying to send that nobody was listening for. Most were trivial to fix (`as unknown as X`, one missing union member, one rename propagation). The Buffer/BodyInit one in Supabase upload is a live bug — `fetch(url, {body: buf})` works today in Node/Bun but has no type guarantee; the fix copies `data.buffer, data.byteOffset, data.byteLength` into a `Uint8Array` slice that is genuinely assignable to `BodyInit`.
|
||||
|
||||
### What this means for operators
|
||||
|
||||
`gbrain agent run "say hello"` against a Supabase brain completes end-to-end after this upgrade. No stuck subagent jobs, no `client.create is not a function` traceback. v0.16.0 users should upgrade immediately — the feature that release was named for did not work.
|
||||
|
||||
### Itemized changes
|
||||
|
||||
#### `gbrain agent run` now works against the real Anthropic SDK
|
||||
|
||||
- `src/core/minions/handlers/subagent.ts` — factory default construction replaced with `const client: MessagesClient = deps.client ?? makeAnthropic().messages`. The SDK's `Messages` resource is already the right object; no helper, no wrapper, no `.bind()` needed (method-call semantics preserve `this`). `const makeAnthropic = deps.makeAnthropic ?? (() => new Anthropic())` adds a dependency-injection seam so tests can exercise the default branch without a real API key or network call.
|
||||
- `test/subagent-handler.test.ts` — new `describe('makeSubagentHandler default client construction')` block drives a full handler turn through a fake SDK injected via `makeAnthropic`. If anyone reverts `.messages` or reintroduces a `new Anthropic()` top-level cast, this test fails loudly.
|
||||
|
||||
#### CI type-checking is now real
|
||||
|
||||
- `package.json` — added `typescript@^5.6.0` as devDep; added `"typecheck": "tsc --noEmit"` script; chained `bun run typecheck` into `"test"` so local `bun run test` and CI run identical pipelines (grep guards + typecheck + bun test).
|
||||
- `.github/workflows/test.yml` — CI now runs `bun run test` (the npm script) instead of `bun test` (the runner). One line. Biggest-leverage change in the release.
|
||||
|
||||
#### 100+ pre-existing type errors cleaned up
|
||||
|
||||
So `tsc --noEmit` actually stays green. All mechanical, zero behavior change. Groups:
|
||||
|
||||
- **`databaseUrl` → `database_url` rename drift** in 9 test fixtures (test/agent-cli, test/brain-allowlist, test/minions-shell, test/minions, test/queue-child-done, test/rate-leases, test/subagent-handler, test/subagent-transcript, test/wait-for-completion).
|
||||
- **`PageType` union** in `src/core/types.ts` gained `'meeting'` and `'note'` entries. Both were already used in src (`link-extraction.ts` had a code comment acknowledging the gap) and across 6 test files. The union was just out of date.
|
||||
- **`GBrainConfig.storage`** field declared in `src/core/config.ts` — the code at `src/commands/files.ts` and `src/core/operations.ts` was reading `config.storage` with 18 inferred-type errors.
|
||||
- **`ErrorCode`** union in `src/core/operations.ts` gained `'permission_denied'`; the code was throwing this exact string but the union disagreed.
|
||||
- **Dead-code comparisons** removed from `src/commands/migrations/v0_12_0.ts`, `v0_12_2.ts`, `v0_13_0.ts`, `v0_16_0.ts` — each orchestrator had an early-return on `a.status === 'failed'` followed later by a redundant check against a then-narrowed type. TS correctly flagged the later check as always-false.
|
||||
- **postgres.js `Row` callback typing** on `src/core/postgres-engine.ts` — 6 `.map((r: { slug: string }) => r.slug)` callbacks rewritten as `.map((r) => r.slug as string)` to match postgres.js's `Row` generic. Same behavior, correct signature.
|
||||
- **Buffer → BodyInit** in `src/core/storage/supabase.ts:58,129` — `body: data` (Buffer) replaced with `body: new Uint8Array(data.buffer, data.byteOffset, data.byteLength) as BodyInit`. Zero-copy view of the same bytes, structurally assignable to `BodyInit`, no runtime change.
|
||||
- **Various `as X` casts** upgraded to `as unknown as X` where TS 5.6's stricter structural-conversion rules rejected the single-step cast. Affected: `src/core/file-resolver.ts` (3), `src/core/minions/handlers/subagent-aggregator.ts`, `src/core/minions/worker.ts`, `src/commands/orphans.ts`, `src/commands/repair-jsonb.ts`, `src/core/postgres-engine.ts` (2 RowList → array conversions).
|
||||
|
||||
#### Test suite stability
|
||||
|
||||
- `bunfig.toml` — new file. Sets `[test].timeout = 60_000` globally. PGLite WASM init is slow enough that the default 5-second hook timeout flakes when many test files spin up PGLite instances in parallel on a loaded machine.
|
||||
- 8 test files (`test/wait-for-completion`, `test/extract-fs`, `test/subagent-handler`, `test/minions-shell`, `test/minions-quiet-hours`, `test/integrity`, `test/e2e/graph-quality`, `test/e2e/search-quality`) additionally declare `beforeAll(fn, 60_000)` / `beforeEach(fn, 15_000)` as explicit safety nets — redundant with `bunfig.toml` today, but stays as belt-and-suspenders if the bunfig schema ever changes.
|
||||
|
||||
## To take advantage of v0.16.3
|
||||
|
||||
`gbrain upgrade` should do this automatically. If it didn't, or if `gbrain doctor` warns about anything:
|
||||
|
||||
1. **Verify your brain still runs:**
|
||||
```bash
|
||||
gbrain doctor
|
||||
```
|
||||
2. **Verify the agent runtime works:**
|
||||
```bash
|
||||
gbrain agent run "say hello"
|
||||
```
|
||||
Should complete end-to-end. If it fails with `client.create is not a function`, the upgrade didn't land — run `gbrain upgrade` again.
|
||||
3. **No migrations required.** No schema changes in this release. Fix is in the handler code, not the DB.
|
||||
4. **If any step fails,** please file an issue: https://github.com/garrytan/gbrain/issues with:
|
||||
- output of `gbrain doctor`
|
||||
- output of `gbrain agent run "say hello"`
|
||||
- contents of `~/.gbrain/upgrade-errors.jsonl` if it exists
|
||||
|
||||
### Itemized changes
|
||||
|
||||
## [0.16.2] - 2026-04-22
|
||||
|
||||
## **The deployment guide now reads like a runbook an agent can execute line-by-line.**
|
||||
|
||||
3
bun.lock
3
bun.lock
@@ -17,6 +17,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest",
|
||||
"typescript": "^5.6.0",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -456,6 +457,8 @@
|
||||
|
||||
"type-is": ["type-is@2.0.1", "", { "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw=="],
|
||||
|
||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||
|
||||
"undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="],
|
||||
|
||||
"unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="],
|
||||
|
||||
6
bunfig.toml
Normal file
6
bunfig.toml
Normal file
@@ -0,0 +1,6 @@
|
||||
[test]
|
||||
# PGLite initialization can be slow under parallel test execution.
|
||||
# Default 5s is too short when many test files boot PGLite instances at once.
|
||||
# 60s is the empirical ceiling we observed before the first file's beforeAll
|
||||
# completed on a loaded machine.
|
||||
timeout = 60_000
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "gbrain",
|
||||
"version": "0.16.2",
|
||||
"version": "0.16.3",
|
||||
"description": "Postgres-native personal knowledge brain with hybrid RAG search",
|
||||
"type": "module",
|
||||
"main": "src/core/index.ts",
|
||||
@@ -21,8 +21,9 @@
|
||||
"build:all": "bun build --compile --target=bun-darwin-arm64 --outfile bin/gbrain-darwin-arm64 src/cli.ts && bun build --compile --target=bun-linux-x64 --outfile bin/gbrain-linux-x64 src/cli.ts",
|
||||
"build:schema": "bash scripts/build-schema.sh",
|
||||
"build:llms": "bun run scripts/build-llms.ts",
|
||||
"test": "scripts/check-jsonb-pattern.sh && scripts/check-progress-to-stdout.sh && bun test",
|
||||
"test": "scripts/check-jsonb-pattern.sh && scripts/check-progress-to-stdout.sh && bun run typecheck && bun test",
|
||||
"test:e2e": "bash scripts/run-e2e.sh",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"check:jsonb": "scripts/check-jsonb-pattern.sh",
|
||||
"check:progress": "scripts/check-progress-to-stdout.sh",
|
||||
"postinstall": "command -v gbrain >/dev/null 2>&1 && gbrain apply-migrations --yes --non-interactive || echo '[gbrain] postinstall skipped. If installed via bun install -g github:...: run `gbrain doctor` and `gbrain apply-migrations --yes` manually. See https://github.com/garrytan/gbrain/issues/218' 1>&2",
|
||||
@@ -46,7 +47,8 @@
|
||||
"postgres": "^3.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest"
|
||||
"@types/bun": "latest",
|
||||
"typescript": "^5.6.0"
|
||||
},
|
||||
"trustedDependencies": [
|
||||
"@electric-sql/pglite"
|
||||
|
||||
@@ -421,7 +421,7 @@ async function mirrorFiles(args: string[]) {
|
||||
// Write .supabase marker
|
||||
const marker = stringify({
|
||||
synced_at: new Date().toISOString(),
|
||||
bucket: config.storage.bucket || 'brain-files',
|
||||
bucket: (config.storage as { bucket?: string })?.bucket || 'brain-files',
|
||||
prefix: basename(dir) + '/',
|
||||
file_count: uploaded,
|
||||
});
|
||||
|
||||
@@ -38,12 +38,13 @@ export async function runImport(engine: BrainEngine, args: string[], opts: { com
|
||||
// Find dir: first non-flag arg that isn't a value for --workers
|
||||
const flagValues = new Set<number>();
|
||||
if (workersIdx !== -1) flagValues.add(workersIdx + 1);
|
||||
const dir = args.find((a, i) => !a.startsWith('--') && !flagValues.has(i));
|
||||
const dirArg = args.find((a, i) => !a.startsWith('--') && !flagValues.has(i));
|
||||
|
||||
if (!dir) {
|
||||
if (!dirArg) {
|
||||
console.error('Usage: gbrain import <dir> [--no-embed] [--workers N] [--fresh] [--json]');
|
||||
process.exit(1);
|
||||
}
|
||||
const dir: string = dirArg; // narrowed; survives closure capture
|
||||
|
||||
// Collect all .md files
|
||||
const allFiles = collectMarkdownFiles(dir);
|
||||
|
||||
@@ -217,10 +217,9 @@ async function orchestrator(opts: OrchestratorOpts): Promise<OrchestratorResult>
|
||||
phases.push(e);
|
||||
|
||||
// F. Record
|
||||
// a.status was narrowed to 'skipped' | 'complete' by the early return above.
|
||||
const overallStatus: 'complete' | 'partial' | 'failed' =
|
||||
a.status === 'failed' ? 'failed' :
|
||||
phases.some(p => p.status === 'failed') ? 'partial' :
|
||||
'complete';
|
||||
phases.some(p => p.status === 'failed') ? 'partial' : 'complete';
|
||||
|
||||
return finalizeResult(phases, overallStatus);
|
||||
}
|
||||
|
||||
@@ -105,10 +105,9 @@ async function orchestrator(opts: OrchestratorOpts): Promise<OrchestratorResult>
|
||||
const c = phaseCVerify(opts);
|
||||
phases.push(c);
|
||||
|
||||
// a.status and b.status were narrowed to 'skipped' | 'complete' by early returns above.
|
||||
const overallStatus: 'complete' | 'partial' | 'failed' =
|
||||
a.status === 'failed' || b.status === 'failed' ? 'failed' :
|
||||
c.status === 'failed' ? 'partial' :
|
||||
'complete';
|
||||
c.status === 'failed' ? 'partial' : 'complete';
|
||||
|
||||
return finalizeResult(phases, overallStatus);
|
||||
}
|
||||
|
||||
@@ -129,10 +129,9 @@ async function orchestrator(opts: OrchestratorOpts): Promise<OrchestratorResult>
|
||||
const c = phaseCVerify(opts);
|
||||
phases.push(c);
|
||||
|
||||
// a.status and b.status were narrowed to 'skipped' | 'complete' by early returns above.
|
||||
const overallStatus: 'complete' | 'partial' | 'failed' =
|
||||
a.status === 'failed' || b.status === 'failed' ? 'failed' :
|
||||
c.status === 'failed' ? 'partial' :
|
||||
'complete';
|
||||
c.status === 'failed' ? 'partial' : 'complete';
|
||||
|
||||
return finalizeResult(phases, overallStatus);
|
||||
}
|
||||
|
||||
@@ -95,10 +95,9 @@ async function orchestrator(opts: OrchestratorOpts): Promise<OrchestratorResult>
|
||||
const b = await phaseBVerify(opts);
|
||||
phases.push(b);
|
||||
|
||||
// a.status was narrowed to 'skipped' | 'complete' by the early return above.
|
||||
const status: 'complete' | 'partial' | 'failed' =
|
||||
a.status === 'failed' ? 'failed' :
|
||||
b.status === 'failed' ? 'partial' :
|
||||
'complete';
|
||||
b.status === 'failed' ? 'partial' : 'complete';
|
||||
|
||||
return finalize(phases, status);
|
||||
}
|
||||
|
||||
@@ -115,7 +115,7 @@ export async function queryOrphanPages(): Promise<{ slug: string; title: string;
|
||||
)
|
||||
ORDER BY p.slug
|
||||
`;
|
||||
return rows as { slug: string; title: string; domain: string | null }[];
|
||||
return rows as unknown as { slug: string; title: string; domain: string | null }[];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -117,7 +117,7 @@ export async function repairJsonb(opts: RepairOpts = { dryRun: false }): Promise
|
||||
const rows = await sql.unsafe(
|
||||
`SELECT count(*)::int AS n FROM ${t.table} WHERE jsonb_typeof(${t.column}) = 'string'`,
|
||||
);
|
||||
repaired = (rows[0] as { n: number }).n;
|
||||
repaired = (rows[0] as unknown as { n: number }).n;
|
||||
} else {
|
||||
const rows = await sql.unsafe(
|
||||
`UPDATE ${t.table}
|
||||
|
||||
@@ -25,9 +25,12 @@ import { xHandleToTweetResolver } from '../core/resolvers/builtin/x-api/handle-t
|
||||
* to call from multiple entry points.
|
||||
*/
|
||||
export function registerBuiltinResolvers(registry = getDefaultRegistry()): void {
|
||||
const builtins = [urlReachableResolver, xHandleToTweetResolver] as const;
|
||||
// Cast each element to the widest shape the registry accepts. The tuple
|
||||
// element types diverge (different Input/Output generics) so the union
|
||||
// type would not satisfy registry.register's single-signature parameter.
|
||||
const builtins = [urlReachableResolver, xHandleToTweetResolver];
|
||||
for (const r of builtins) {
|
||||
if (!registry.has(r.id)) registry.register(r);
|
||||
if (!registry.has(r.id)) registry.register(r as Parameters<typeof registry.register>[0]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -29,6 +29,13 @@ export interface GBrainConfig {
|
||||
database_path?: string;
|
||||
openai_api_key?: string;
|
||||
anthropic_api_key?: string;
|
||||
/**
|
||||
* Optional storage backend config (S3/Supabase/local). Shape matches
|
||||
* `StorageConfig` in `./storage.ts`. Typed as `unknown` here to avoid
|
||||
* a cyclic import; callers pass this through `createStorage()` which
|
||||
* validates the shape at runtime.
|
||||
*/
|
||||
storage?: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -159,5 +159,5 @@ export async function withTransaction<T>(fn: (tx: ReturnType<typeof postgres>) =
|
||||
const conn = getConnection();
|
||||
return conn.begin(async (tx) => {
|
||||
return fn(tx as unknown as ReturnType<typeof postgres>);
|
||||
});
|
||||
}) as Promise<T>;
|
||||
}
|
||||
|
||||
@@ -113,8 +113,8 @@ export async function enrichEntity(
|
||||
let timelineAdded = false;
|
||||
try {
|
||||
await engine.addTimelineEntry(slug, {
|
||||
date: new Date().toISOString().split('T')[0],
|
||||
content: `Referenced in [${request.sourceSlug}](${request.sourceSlug}) — ${request.context}`,
|
||||
date: new Date().toISOString().split('T')[0] ?? '',
|
||||
summary: `Referenced in [${request.sourceSlug}](${request.sourceSlug}) — ${request.context}`,
|
||||
source: request.sourceSlug,
|
||||
});
|
||||
timelineAdded = true;
|
||||
@@ -161,7 +161,7 @@ export async function enrichEntities(
|
||||
}
|
||||
const result = await enrichEntity(engine, req);
|
||||
results.push(result);
|
||||
config?.onProgress?.(results.length, requests.length, req.name);
|
||||
config?.onProgress?.(results.length, requests.length, req.entityName);
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
@@ -119,18 +119,18 @@ export async function resolveFile(
|
||||
/** Parse v0.9+ .redirect.yaml pointer */
|
||||
export function parseRedirectYaml(path: string): RedirectYaml {
|
||||
const content = readFileSync(path, 'utf-8');
|
||||
return parseYaml(content) as RedirectYaml;
|
||||
return parseYaml(content) as unknown as RedirectYaml;
|
||||
}
|
||||
|
||||
/** Parse legacy v0.8 .redirect breadcrumb */
|
||||
export function parseRedirect(path: string): RedirectInfo {
|
||||
const content = readFileSync(path, 'utf-8');
|
||||
return parseYaml(content) as RedirectInfo;
|
||||
return parseYaml(content) as unknown as RedirectInfo;
|
||||
}
|
||||
|
||||
export function parseMarker(path: string): MarkerInfo {
|
||||
const content = readFileSync(path, 'utf-8');
|
||||
return parseYaml(content) as MarkerInfo;
|
||||
return parseYaml(content) as unknown as MarkerInfo;
|
||||
}
|
||||
|
||||
/** Human-readable file size */
|
||||
|
||||
@@ -269,7 +269,7 @@ export async function shellHandler(ctx: MinionJobContext): Promise<ShellJobResul
|
||||
if (ctx.signal.aborted) sigAbort();
|
||||
if (ctx.shutdownSignal.aborted) shutdownAbort();
|
||||
|
||||
const exitCode: number = await new Promise((resolve, reject) => {
|
||||
const exitCode: number = await new Promise<number>((resolve, reject) => {
|
||||
proc.on('error', (err) => {
|
||||
reject(err);
|
||||
});
|
||||
|
||||
@@ -41,7 +41,7 @@ export interface AggregatorResult {
|
||||
|
||||
/** v0.15 aggregator: synchronous read from inbox, no LLM call. */
|
||||
export async function subagentAggregatorHandler(ctx: MinionJobContext): Promise<AggregatorResult> {
|
||||
const data = (ctx.data ?? {}) as AggregatorHandlerData;
|
||||
const data = (ctx.data ?? {}) as unknown as AggregatorHandlerData;
|
||||
const expectedIds = Array.isArray(data.children_ids) ? data.children_ids : [];
|
||||
|
||||
if (expectedIds.length === 0) {
|
||||
|
||||
@@ -71,6 +71,13 @@ export interface SubagentDeps {
|
||||
engine: BrainEngine;
|
||||
/** Anthropic client. Defaults to the SDK-constructed client. */
|
||||
client?: MessagesClient;
|
||||
/**
|
||||
* Anthropic SDK constructor. Defaults to `() => new Anthropic()`.
|
||||
* Overridable in tests so the factory default-client branch is
|
||||
* exercisable without an ANTHROPIC_API_KEY or a real API call.
|
||||
* When `deps.client` is provided, this is unused.
|
||||
*/
|
||||
makeAnthropic?: () => Anthropic;
|
||||
/** Config (MCP, brain, etc.). Defaults to loadConfig(). */
|
||||
config?: GBrainConfig;
|
||||
/** Rate-lease key. Defaults to `anthropic:messages`. */
|
||||
@@ -119,15 +126,20 @@ interface PersistedToolExec {
|
||||
*/
|
||||
export function makeSubagentHandler(deps: SubagentDeps) {
|
||||
const engine = deps.engine;
|
||||
const client: MessagesClient =
|
||||
deps.client ?? (new Anthropic() as unknown as MessagesClient);
|
||||
// sdk.messages IS the MessagesClient-shaped object. The v0.16.0 bug was
|
||||
// casting new Anthropic() (top level) to MessagesClient, but .create()
|
||||
// lives at sdk.messages.create. Assigning sdk.messages directly gets the
|
||||
// right object; JS method-call semantics preserve `this` at the call
|
||||
// site (subagent.ts invokes client.create(...) with client === sdk.messages).
|
||||
const makeAnthropic = deps.makeAnthropic ?? (() => new Anthropic());
|
||||
const client: MessagesClient = deps.client ?? makeAnthropic().messages;
|
||||
const config = deps.config ?? loadConfig() ?? ({ engine: 'postgres' } as GBrainConfig);
|
||||
const rateLeaseKey = deps.rateLeaseKey ?? DEFAULT_RATE_KEY;
|
||||
const maxConcurrent = deps.maxConcurrent ?? DEFAULT_MAX_CONCURRENT;
|
||||
const leaseTtlMs = deps.leaseTtlMs ?? DEFAULT_LEASE_TTL_MS;
|
||||
|
||||
return async function subagentHandler(ctx: MinionJobContext): Promise<SubagentResult> {
|
||||
const data = (ctx.data ?? {}) as SubagentHandlerData;
|
||||
const data = (ctx.data ?? {}) as unknown as SubagentHandlerData;
|
||||
if (!data.prompt || typeof data.prompt !== 'string') {
|
||||
throw new Error('subagent job data.prompt is required (string)');
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ import { evaluateQuietHours, type QuietHoursConfig } from './quiet-hours.ts';
|
||||
function readQuietHoursConfig(job: MinionJob): QuietHoursConfig | null {
|
||||
const cfg = (job as MinionJob & { quiet_hours?: unknown }).quiet_hours;
|
||||
if (!cfg || typeof cfg !== 'object') return null;
|
||||
return cfg as QuietHoursConfig;
|
||||
return cfg as unknown as QuietHoursConfig;
|
||||
}
|
||||
|
||||
/** Per-job in-flight state (isolated per job, not shared on the worker). */
|
||||
|
||||
@@ -24,7 +24,8 @@ export type ErrorCode =
|
||||
| 'embedding_failed'
|
||||
| 'storage_error'
|
||||
| 'bucket_not_found'
|
||||
| 'database_error';
|
||||
| 'database_error'
|
||||
| 'permission_denied';
|
||||
|
||||
export class OperationError extends Error {
|
||||
constructor(
|
||||
|
||||
@@ -113,7 +113,7 @@ export class PGLiteEngine implements BrainEngine {
|
||||
|
||||
async putPage(slug: string, page: PageInput): Promise<Page> {
|
||||
slug = validateSlug(slug);
|
||||
const hash = page.content_hash || contentHash(page.compiled_truth, page.timeline || '');
|
||||
const hash = page.content_hash || contentHash(page);
|
||||
const frontmatter = page.frontmatter || {};
|
||||
|
||||
const { rows } = await this.db.query(
|
||||
|
||||
@@ -59,7 +59,9 @@ export async function acquireLock(dataDir: string | undefined, opts?: { timeoutM
|
||||
return { lockDir: '', acquired: true };
|
||||
}
|
||||
|
||||
mkdirSync(dataDir, { recursive: true });
|
||||
// `lockDir` being set implies `dataDir` is set (see getLockDir), but TS
|
||||
// can't derive that across helper boundaries.
|
||||
mkdirSync(dataDir as string, { recursive: true });
|
||||
|
||||
const timeoutMs = opts?.timeoutMs ?? 30_000; // 30 second default timeout
|
||||
const startTime = Date.now();
|
||||
|
||||
@@ -4,7 +4,7 @@ import { MAX_SEARCH_LIMIT, clampSearchLimit } from './engine.ts';
|
||||
import { runMigrations } from './migrate.ts';
|
||||
import { SCHEMA_SQL } from './schema-embedded.ts';
|
||||
import type {
|
||||
Page, PageInput, PageFilters,
|
||||
Page, PageInput, PageFilters, PageType,
|
||||
Chunk, ChunkInput,
|
||||
SearchResult, SearchOpts,
|
||||
Link, GraphNode, GraphPath,
|
||||
@@ -95,7 +95,7 @@ export class PostgresEngine implements BrainEngine {
|
||||
Object.defineProperty(txEngine, 'sql', { get: () => tx });
|
||||
Object.defineProperty(txEngine, '_sql', { value: tx as unknown as ReturnType<typeof postgres>, writable: false });
|
||||
return fn(txEngine);
|
||||
});
|
||||
}) as Promise<T>;
|
||||
}
|
||||
|
||||
// Pages CRUD
|
||||
@@ -117,7 +117,7 @@ export class PostgresEngine implements BrainEngine {
|
||||
|
||||
const rows = await sql`
|
||||
INSERT INTO pages (slug, type, title, compiled_truth, timeline, frontmatter, content_hash, updated_at)
|
||||
VALUES (${slug}, ${page.type}, ${page.title}, ${page.compiled_truth}, ${page.timeline || ''}, ${sql.json(frontmatter)}, ${hash}, now())
|
||||
VALUES (${slug}, ${page.type}, ${page.title}, ${page.compiled_truth}, ${page.timeline || ''}, ${sql.json(frontmatter as Parameters<typeof sql.json>[0])}, ${hash}, now())
|
||||
ON CONFLICT (slug) DO UPDATE SET
|
||||
type = EXCLUDED.type,
|
||||
title = EXCLUDED.title,
|
||||
@@ -165,7 +165,7 @@ export class PostgresEngine implements BrainEngine {
|
||||
async getAllSlugs(): Promise<Set<string>> {
|
||||
const sql = this.sql;
|
||||
const rows = await sql`SELECT slug FROM pages`;
|
||||
return new Set(rows.map((r: { slug: string }) => r.slug));
|
||||
return new Set(rows.map((r) => r.slug as string));
|
||||
}
|
||||
|
||||
async resolveSlugs(partial: string): Promise<string[]> {
|
||||
@@ -183,7 +183,7 @@ export class PostgresEngine implements BrainEngine {
|
||||
ORDER BY sim DESC
|
||||
LIMIT 5
|
||||
`;
|
||||
return fuzzy.map((r: { slug: string }) => r.slug);
|
||||
return fuzzy.map((r) => r.slug as string);
|
||||
}
|
||||
|
||||
// Search
|
||||
@@ -344,7 +344,7 @@ export class PostgresEngine implements BrainEngine {
|
||||
model = COALESCE(EXCLUDED.model, content_chunks.model),
|
||||
token_count = EXCLUDED.token_count,
|
||||
embedded_at = COALESCE(EXCLUDED.embedded_at, content_chunks.embedded_at)`,
|
||||
params,
|
||||
params as Parameters<typeof sql.unsafe>[1],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -356,7 +356,7 @@ export class PostgresEngine implements BrainEngine {
|
||||
WHERE p.slug = ${slug}
|
||||
ORDER BY cc.chunk_index
|
||||
`;
|
||||
return rows.map(rowToChunk);
|
||||
return rows.map((r) => rowToChunk(r as Record<string, unknown>));
|
||||
}
|
||||
|
||||
async deleteChunks(slug: string): Promise<void> {
|
||||
@@ -693,7 +693,7 @@ export class PostgresEngine implements BrainEngine {
|
||||
WHERE p.slug = ANY(${slugs}::text[])
|
||||
GROUP BY p.slug
|
||||
`;
|
||||
for (const r of rows as { slug: string; cnt: number }[]) {
|
||||
for (const r of rows as unknown as { slug: string; cnt: number }[]) {
|
||||
result.set(r.slug, Number(r.cnt));
|
||||
}
|
||||
return result;
|
||||
@@ -729,7 +729,7 @@ export class PostgresEngine implements BrainEngine {
|
||||
WHERE page_id = (SELECT id FROM pages WHERE slug = ${slug})
|
||||
ORDER BY tag
|
||||
`;
|
||||
return rows.map((r: { tag: string }) => r.tag);
|
||||
return rows.map((r) => r.tag as string);
|
||||
}
|
||||
|
||||
// Timeline
|
||||
@@ -814,7 +814,7 @@ export class PostgresEngine implements BrainEngine {
|
||||
const sql = this.sql;
|
||||
const result = await sql`
|
||||
INSERT INTO raw_data (page_id, source, data)
|
||||
SELECT id, ${source}, ${sql.json(data as Record<string, unknown>)}
|
||||
SELECT id, ${source}, ${sql.json(data as Parameters<typeof sql.json>[0])}
|
||||
FROM pages WHERE slug = ${slug}
|
||||
ON CONFLICT (page_id, source) DO UPDATE SET
|
||||
data = EXCLUDED.data,
|
||||
@@ -987,7 +987,7 @@ export class PostgresEngine implements BrainEngine {
|
||||
dead_links: deadLinks,
|
||||
link_coverage: Number(h.link_coverage),
|
||||
timeline_coverage: Number(h.timeline_coverage),
|
||||
most_connected: (connected as { slug: string; link_count: number }[]).map(c => ({
|
||||
most_connected: (connected as unknown as { slug: string; link_count: number }[]).map(c => ({
|
||||
slug: c.slug,
|
||||
link_count: Number(c.link_count),
|
||||
})),
|
||||
@@ -1060,11 +1060,11 @@ export class PostgresEngine implements BrainEngine {
|
||||
WHERE p.slug = ${slug}
|
||||
ORDER BY cc.chunk_index
|
||||
`;
|
||||
return rows.map((r: Record<string, unknown>) => rowToChunk(r, true));
|
||||
return rows.map((r) => rowToChunk(r as Record<string, unknown>, true));
|
||||
}
|
||||
|
||||
async executeRaw<T = Record<string, unknown>>(sql: string, params?: unknown[]): Promise<T[]> {
|
||||
const conn = this.sql;
|
||||
return conn.unsafe(sql, params) as unknown as T[];
|
||||
return conn.unsafe(sql, params as Parameters<typeof conn.unsafe>[1]) as unknown as T[];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,7 +55,7 @@ export class SupabaseStorage implements StorageBackend {
|
||||
'Content-Type': mime || 'application/octet-stream',
|
||||
'x-upsert': 'true',
|
||||
},
|
||||
body: data,
|
||||
body: new Uint8Array(data.buffer, data.byteOffset, data.byteLength) as BodyInit,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.text();
|
||||
@@ -126,7 +126,7 @@ export class SupabaseStorage implements StorageBackend {
|
||||
'Content-Type': 'application/offset+octet-stream',
|
||||
'Content-Length': String(chunk.length),
|
||||
},
|
||||
body: chunk,
|
||||
body: new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.byteLength) as BodyInit,
|
||||
});
|
||||
|
||||
if (!patchRes.ok) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Page types
|
||||
export type PageType = 'person' | 'company' | 'deal' | 'yc' | 'civic' | 'project' | 'concept' | 'source' | 'media' | 'writing' | 'analysis' | 'guide' | 'hardware' | 'architecture';
|
||||
export type PageType = 'person' | 'company' | 'deal' | 'yc' | 'civic' | 'project' | 'concept' | 'source' | 'media' | 'writing' | 'analysis' | 'guide' | 'hardware' | 'architecture' | 'meeting' | 'note';
|
||||
|
||||
export interface Page {
|
||||
id: number;
|
||||
|
||||
@@ -23,7 +23,7 @@ let queue: MinionQueue;
|
||||
|
||||
beforeAll(async () => {
|
||||
engine = new PGLiteEngine();
|
||||
await engine.connect({ databaseUrl: '' });
|
||||
await engine.connect({ database_url: '' });
|
||||
await engine.initSchema();
|
||||
queue = new MinionQueue(engine);
|
||||
});
|
||||
|
||||
@@ -425,7 +425,7 @@ async function measureBaselineRelational(
|
||||
}
|
||||
const ENTITY_REF_RE = /\[[^\]]+\]\(([^)]+)\)|\b((?:people|companies|meetings|concepts)\/[a-z0-9-]+)\b/gi;
|
||||
|
||||
const perQuery: Array<{ question: string; expected: number; found: number }> = [];
|
||||
const perQuery: Array<{ question: string; expected: number; found: number; returned: number }> = [];
|
||||
let totalExpected = 0, totalFound = 0;
|
||||
let totalReturned = 0, totalValid = 0;
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ const config: GBrainConfig = { engine: 'pglite' } as GBrainConfig;
|
||||
|
||||
beforeAll(async () => {
|
||||
engine = new PGLiteEngine();
|
||||
await engine.connect({ databaseUrl: '' });
|
||||
await engine.connect({ database_url: '' });
|
||||
await engine.initSchema();
|
||||
});
|
||||
|
||||
|
||||
@@ -22,8 +22,8 @@ describe('doctor command', () => {
|
||||
});
|
||||
|
||||
test('Check interface supports issues array', async () => {
|
||||
const { Check } = await import('../src/commands/doctor.ts');
|
||||
// The Check type allows an optional issues array for resolver findings
|
||||
// `Check` is a TypeScript interface — type-only, no runtime value.
|
||||
// Importing it for type assertion is enough to validate the shape.
|
||||
const check: import('../src/commands/doctor.ts').Check = {
|
||||
name: 'resolver_health',
|
||||
status: 'warn',
|
||||
|
||||
@@ -22,7 +22,7 @@ beforeAll(async () => {
|
||||
engine = new PGLiteEngine();
|
||||
await engine.connect({});
|
||||
await engine.initSchema();
|
||||
});
|
||||
}, 60_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await engine.disconnect();
|
||||
@@ -44,7 +44,7 @@ function makeContext(): OperationContext {
|
||||
}
|
||||
|
||||
describe('E2E graph quality (v0.10.1 pipeline)', () => {
|
||||
beforeEach(truncateAll);
|
||||
beforeEach(truncateAll, 15_000);
|
||||
|
||||
test('full pipeline: seed -> link-extract -> timeline-extract -> verify', async () => {
|
||||
// Seed 5 pages with entity refs and timeline content.
|
||||
|
||||
@@ -102,7 +102,7 @@ beforeAll(async () => {
|
||||
},
|
||||
];
|
||||
await engine.upsertChunks('concepts/ai-philosophy', aiChunks);
|
||||
});
|
||||
}, 60_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await engine.disconnect();
|
||||
|
||||
@@ -32,7 +32,7 @@ beforeAll(async () => {
|
||||
engine = new PGLiteEngine();
|
||||
await engine.connect({});
|
||||
await engine.initSchema();
|
||||
});
|
||||
}, 60_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await engine.disconnect();
|
||||
@@ -55,7 +55,7 @@ const companyPage = (title: string, body = ''): PageInput => ({
|
||||
beforeEach(async () => {
|
||||
await truncateAll();
|
||||
brainDir = mkdtempSync(join(tmpdir(), 'gbrain-extract-fs-'));
|
||||
});
|
||||
}, 15_000);
|
||||
|
||||
function writeFile(rel: string, content: string) {
|
||||
const full = join(brainDir, rel);
|
||||
|
||||
@@ -201,7 +201,7 @@ describe('scanIntegrity', () => {
|
||||
timeline: '',
|
||||
frontmatter: { validate: false },
|
||||
});
|
||||
});
|
||||
}, 60_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await engine.disconnect();
|
||||
|
||||
@@ -33,7 +33,7 @@ describe('v0.13.0 — Frontmatter relationship indexing migration', () => {
|
||||
|
||||
test('dry-run skips all side-effect phases', async () => {
|
||||
const { v0_13_0 } = await import('../src/commands/migrations/v0_13_0.ts');
|
||||
const result = await v0_13_0.orchestrator({ yes: true, dryRun: true });
|
||||
const result = await v0_13_0.orchestrator({ yes: true, dryRun: true, noAutopilotInstall: true });
|
||||
expect(result.version).toBe('0.13.0');
|
||||
for (const phase of result.phases) {
|
||||
expect(phase.status).toBe('skipped');
|
||||
|
||||
@@ -55,20 +55,20 @@ describe('v0.16.0 migration', () => {
|
||||
});
|
||||
|
||||
test('phaseASchema skips on dry-run', () => {
|
||||
const r = __testing.phaseASchema({ dryRun: true });
|
||||
const r = __testing.phaseASchema({ dryRun: true, yes: true, noAutopilotInstall: true });
|
||||
expect(r.status).toBe('skipped');
|
||||
expect(r.detail).toBe('dry-run');
|
||||
});
|
||||
|
||||
test('phaseBVerify skips on dry-run', async () => {
|
||||
const r = await __testing.phaseBVerify({ dryRun: true });
|
||||
const r = await __testing.phaseBVerify({ dryRun: true, yes: true, noAutopilotInstall: true });
|
||||
expect(r.status).toBe('skipped');
|
||||
expect(r.detail).toBe('dry-run');
|
||||
});
|
||||
|
||||
test('orchestrator in dry-run returns complete with both phases skipped', async () => {
|
||||
const m = getMigration('0.16.0');
|
||||
const result = await m!.orchestrator({ dryRun: true });
|
||||
const result = await m!.orchestrator({ dryRun: true, yes: true, noAutopilotInstall: true });
|
||||
expect(result.version).toBe('0.16.0');
|
||||
expect(result.phases.length).toBe(2);
|
||||
expect(result.phases.every(p => p.status === 'skipped')).toBe(true);
|
||||
|
||||
@@ -182,7 +182,7 @@ describe('schema migration v12 — minion_quiet_hours_stagger', () => {
|
||||
engine = new PGLiteEngine();
|
||||
await engine.connect({ engine: 'pglite', database_path: dbDir });
|
||||
await engine.initSchema();
|
||||
});
|
||||
}, 60_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await engine.disconnect();
|
||||
|
||||
@@ -15,10 +15,10 @@ let queue: MinionQueue;
|
||||
|
||||
beforeAll(async () => {
|
||||
engine = new PGLiteEngine();
|
||||
await engine.connect({ databaseUrl: '' });
|
||||
await engine.connect({ database_url: '' });
|
||||
await engine.initSchema();
|
||||
queue = new MinionQueue(engine);
|
||||
});
|
||||
}, 60_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await engine.disconnect();
|
||||
|
||||
@@ -12,7 +12,7 @@ let queue: MinionQueue;
|
||||
|
||||
beforeAll(async () => {
|
||||
engine = new PGLiteEngine();
|
||||
await engine.connect({ databaseUrl: '' }); // in-memory
|
||||
await engine.connect({ database_url: '' }); // in-memory
|
||||
await engine.initSchema();
|
||||
queue = new MinionQueue(engine);
|
||||
});
|
||||
|
||||
@@ -22,7 +22,7 @@ let queue: MinionQueue;
|
||||
|
||||
beforeAll(async () => {
|
||||
engine = new PGLiteEngine();
|
||||
await engine.connect({ databaseUrl: '' });
|
||||
await engine.connect({ database_url: '' });
|
||||
await engine.initSchema();
|
||||
queue = new MinionQueue(engine);
|
||||
});
|
||||
|
||||
@@ -18,7 +18,7 @@ let owner: number; // a minion_jobs.id to own leases (FK target)
|
||||
|
||||
beforeAll(async () => {
|
||||
engine = new PGLiteEngine();
|
||||
await engine.connect({ databaseUrl: '' });
|
||||
await engine.connect({ database_url: '' });
|
||||
await engine.initSchema();
|
||||
queue = new MinionQueue(engine);
|
||||
});
|
||||
|
||||
@@ -242,7 +242,7 @@ describe('url_reachable resolver', () => {
|
||||
});
|
||||
|
||||
test('200 response → reachable=true', async () => {
|
||||
globalThis.fetch = (async () => new Response('', { status: 200 })) as typeof fetch;
|
||||
globalThis.fetch = (async () => new Response('', { status: 200 })) as unknown as typeof fetch;
|
||||
const r = await urlReachableResolver.resolve({
|
||||
input: { url: 'https://example.com/ok' },
|
||||
context: makeCtx(),
|
||||
@@ -252,7 +252,7 @@ describe('url_reachable resolver', () => {
|
||||
});
|
||||
|
||||
test('404 response → reachable=false with status + reason', async () => {
|
||||
globalThis.fetch = (async () => new Response('', { status: 404 })) as typeof fetch;
|
||||
globalThis.fetch = (async () => new Response('', { status: 404 })) as unknown as typeof fetch;
|
||||
const r = await urlReachableResolver.resolve({
|
||||
input: { url: 'https://example.com/dead' },
|
||||
context: makeCtx(),
|
||||
@@ -268,7 +268,7 @@ describe('url_reachable resolver', () => {
|
||||
callCount++;
|
||||
if (init?.method === 'HEAD') return new Response('', { status: 405 });
|
||||
return new Response('ok', { status: 200 });
|
||||
}) as typeof fetch;
|
||||
}) as unknown as typeof fetch;
|
||||
const r = await urlReachableResolver.resolve({
|
||||
input: { url: 'https://example.com/post-only' },
|
||||
context: makeCtx(),
|
||||
@@ -283,7 +283,7 @@ describe('url_reachable resolver', () => {
|
||||
new Response('', { status: 200 }),
|
||||
];
|
||||
let i = 0;
|
||||
globalThis.fetch = (async () => responses[i++]) as typeof fetch;
|
||||
globalThis.fetch = (async () => responses[i++]) as unknown as typeof fetch;
|
||||
const r = await urlReachableResolver.resolve({
|
||||
input: { url: 'https://example.com/redirect' },
|
||||
context: makeCtx(),
|
||||
@@ -296,7 +296,7 @@ describe('url_reachable resolver', () => {
|
||||
globalThis.fetch = (async () => new Response('', {
|
||||
status: 302,
|
||||
headers: { location: 'http://127.0.0.1/admin' },
|
||||
})) as typeof fetch;
|
||||
})) as unknown as typeof fetch;
|
||||
const r = await urlReachableResolver.resolve({
|
||||
input: { url: 'https://example.com/redirects-to-local' },
|
||||
context: makeCtx(),
|
||||
@@ -306,7 +306,7 @@ describe('url_reachable resolver', () => {
|
||||
});
|
||||
|
||||
test('fetch network failure → reachable=false, confidence=1', async () => {
|
||||
globalThis.fetch = (async () => { throw new TypeError('fetch failed'); }) as typeof fetch;
|
||||
globalThis.fetch = (async () => { throw new TypeError('fetch failed'); }) as unknown as typeof fetch;
|
||||
const r = await urlReachableResolver.resolve({
|
||||
input: { url: 'https://nonexistent.example/' },
|
||||
context: makeCtx(),
|
||||
@@ -338,7 +338,7 @@ describe('url_reachable resolver', () => {
|
||||
const err = new Error('aborted');
|
||||
err.name = 'AbortError';
|
||||
throw err;
|
||||
}) as typeof fetch;
|
||||
}) as unknown as typeof fetch;
|
||||
ac.abort();
|
||||
try {
|
||||
await urlReachableResolver.resolve({
|
||||
@@ -475,7 +475,7 @@ describe('x_handle_to_tweet resolver', () => {
|
||||
globalThis.fetch = (async () => new Response(JSON.stringify({ data: [], meta: { result_count: 0 } }), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
})) as typeof fetch;
|
||||
})) as unknown as typeof fetch;
|
||||
const r = await xHandleToTweetResolver.resolve({
|
||||
input: { handle: 'garrytan', keywords: 'nothing matches' },
|
||||
context: makeCtx(),
|
||||
@@ -491,7 +491,7 @@ describe('x_handle_to_tweet resolver', () => {
|
||||
data: [
|
||||
{ id: '123', text: 'talking about building gbrain today', created_at: '2026-04-18T00:00:00Z' },
|
||||
],
|
||||
}), { status: 200, headers: { 'content-type': 'application/json' } })) as typeof fetch;
|
||||
}), { status: 200, headers: { 'content-type': 'application/json' } })) as unknown as typeof fetch;
|
||||
const r = await xHandleToTweetResolver.resolve({
|
||||
input: { handle: 'garrytan', keywords: 'building gbrain' },
|
||||
context: makeCtx(),
|
||||
@@ -505,7 +505,7 @@ describe('x_handle_to_tweet resolver', () => {
|
||||
process.env.X_API_BEARER_TOKEN = 'fake';
|
||||
globalThis.fetch = (async () => new Response(JSON.stringify({
|
||||
data: [{ id: '1', text: 'something unrelated entirely', created_at: '2026-04-18T00:00:00Z' }],
|
||||
}), { status: 200, headers: { 'content-type': 'application/json' } })) as typeof fetch;
|
||||
}), { status: 200, headers: { 'content-type': 'application/json' } })) as unknown as typeof fetch;
|
||||
const r = await xHandleToTweetResolver.resolve({
|
||||
input: { handle: 'garrytan', keywords: 'gbrain knowledge runtime specific terms' },
|
||||
context: makeCtx(),
|
||||
@@ -523,7 +523,7 @@ describe('x_handle_to_tweet resolver', () => {
|
||||
}));
|
||||
globalThis.fetch = (async () => new Response(JSON.stringify({ data }), {
|
||||
status: 200, headers: { 'content-type': 'application/json' },
|
||||
})) as typeof fetch;
|
||||
})) as unknown as typeof fetch;
|
||||
const r = await xHandleToTweetResolver.resolve({
|
||||
input: { handle: 'garrytan', keywords: 'completely different signal words unlikely to match' },
|
||||
context: makeCtx(),
|
||||
@@ -535,7 +535,7 @@ describe('x_handle_to_tweet resolver', () => {
|
||||
|
||||
test('401 → ResolverError(auth)', async () => {
|
||||
process.env.X_API_BEARER_TOKEN = 'fake';
|
||||
globalThis.fetch = (async () => new Response('unauthorized', { status: 401 })) as typeof fetch;
|
||||
globalThis.fetch = (async () => new Response('unauthorized', { status: 401 })) as unknown as typeof fetch;
|
||||
try {
|
||||
await xHandleToTweetResolver.resolve({
|
||||
input: { handle: 'garrytan' },
|
||||
@@ -549,7 +549,7 @@ describe('x_handle_to_tweet resolver', () => {
|
||||
|
||||
test('403 → ResolverError(auth)', async () => {
|
||||
process.env.X_API_BEARER_TOKEN = 'fake';
|
||||
globalThis.fetch = (async () => new Response('forbidden', { status: 403 })) as typeof fetch;
|
||||
globalThis.fetch = (async () => new Response('forbidden', { status: 403 })) as unknown as typeof fetch;
|
||||
try {
|
||||
await xHandleToTweetResolver.resolve({
|
||||
input: { handle: 'garrytan' },
|
||||
@@ -563,7 +563,7 @@ describe('x_handle_to_tweet resolver', () => {
|
||||
|
||||
test('500 → ResolverError(upstream) with body snippet', async () => {
|
||||
process.env.X_API_BEARER_TOKEN = 'fake';
|
||||
globalThis.fetch = (async () => new Response('internal err', { status: 500 })) as typeof fetch;
|
||||
globalThis.fetch = (async () => new Response('internal err', { status: 500 })) as unknown as typeof fetch;
|
||||
try {
|
||||
await xHandleToTweetResolver.resolve({
|
||||
input: { handle: 'garrytan' },
|
||||
@@ -582,7 +582,7 @@ describe('x_handle_to_tweet resolver', () => {
|
||||
globalThis.fetch = (async () => {
|
||||
calls++;
|
||||
return new Response('rate', { status: 429, headers: { 'retry-after': '0' } });
|
||||
}) as typeof fetch;
|
||||
}) as unknown as typeof fetch;
|
||||
try {
|
||||
await xHandleToTweetResolver.resolve({
|
||||
input: { handle: 'garrytan' },
|
||||
@@ -603,7 +603,7 @@ describe('x_handle_to_tweet resolver', () => {
|
||||
return new Response(JSON.stringify({ data: [] }), {
|
||||
status: 200, headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
}) as typeof fetch;
|
||||
}) as unknown as typeof fetch;
|
||||
await xHandleToTweetResolver.resolve({
|
||||
input: { handle: 'garrytan', keywords: 'from:evil_user lang:ja to:someone normal words' },
|
||||
context: makeCtx(),
|
||||
|
||||
@@ -28,10 +28,10 @@ let queue: MinionQueue;
|
||||
|
||||
beforeAll(async () => {
|
||||
engine = new PGLiteEngine();
|
||||
await engine.connect({ databaseUrl: '' });
|
||||
await engine.connect({ database_url: '' });
|
||||
await engine.initSchema();
|
||||
queue = new MinionQueue(engine);
|
||||
});
|
||||
}, 60_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await engine.disconnect();
|
||||
@@ -420,3 +420,54 @@ describe('subagent handler input validation', () => {
|
||||
await expect(handler(ctx)).rejects.toThrow(/unknown tool/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('makeSubagentHandler default client construction', () => {
|
||||
test('factory default wires sdk.messages through to the handler', async () => {
|
||||
// Regression guard for the v0.16.0 shipped bug: makeSubagentHandler
|
||||
// was casting `new Anthropic()` (top-level SDK class) to MessagesClient,
|
||||
// but `.create()` lives at sdk.messages.create. Every subagent job in
|
||||
// production died with "client.create is not a function" on first LLM
|
||||
// call. This test exercises the default-client path (no `deps.client`
|
||||
// injected) via the makeAnthropic dep-injection seam, so the exact
|
||||
// default-branch construction is covered without a real API call.
|
||||
const calls: Anthropic.MessageCreateParamsNonStreaming[] = [];
|
||||
const fakeSdk = {
|
||||
messages: {
|
||||
async create(
|
||||
params: Anthropic.MessageCreateParamsNonStreaming,
|
||||
): Promise<Anthropic.Message> {
|
||||
calls.push(params);
|
||||
return {
|
||||
id: 'msg_regression',
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
model: params.model,
|
||||
stop_reason: 'end_turn',
|
||||
stop_sequence: null,
|
||||
content: [{ type: 'text', text: 'ok' }],
|
||||
usage: {
|
||||
input_tokens: 1,
|
||||
output_tokens: 1,
|
||||
cache_read_input_tokens: 0,
|
||||
cache_creation_input_tokens: 0,
|
||||
},
|
||||
} as unknown as Anthropic.Message;
|
||||
},
|
||||
},
|
||||
} as unknown as Anthropic;
|
||||
|
||||
// Crucial: do NOT pass `client`. Only `makeAnthropic`. This forces the
|
||||
// factory to hit the default-client branch (`deps.client ?? makeAnthropic().messages`).
|
||||
const handler = makeSubagentHandler({
|
||||
engine,
|
||||
makeAnthropic: () => fakeSdk,
|
||||
toolRegistry: [],
|
||||
});
|
||||
const ctx = await makeCtx({ prompt: 'hello' });
|
||||
const result = await handler(ctx);
|
||||
|
||||
expect(calls.length).toBe(1);
|
||||
expect(result.stop_reason).toBe('end_turn');
|
||||
expect(result.result).toBe('ok');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,7 +18,7 @@ let jobId: number;
|
||||
|
||||
beforeAll(async () => {
|
||||
engine = new PGLiteEngine();
|
||||
await engine.connect({ databaseUrl: '' });
|
||||
await engine.connect({ database_url: '' });
|
||||
await engine.initSchema();
|
||||
queue = new MinionQueue(engine);
|
||||
});
|
||||
|
||||
@@ -13,10 +13,10 @@ let queue: MinionQueue;
|
||||
|
||||
beforeAll(async () => {
|
||||
engine = new PGLiteEngine();
|
||||
await engine.connect({ databaseUrl: '' });
|
||||
await engine.connect({ database_url: '' });
|
||||
await engine.initSchema();
|
||||
queue = new MinionQueue(engine);
|
||||
});
|
||||
}, 60_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await engine.disconnect();
|
||||
|
||||
Reference in New Issue
Block a user