mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 22:15:33 +00:00
* merge master: rebump v0.41.25.0 → v0.41.27.0 (queue collision) Master shipped v0.41.25.0 (#1538 batched sync deletes) and v0.41.26.0 (#1571 dream --source fix) while this branch was in flight. Conflict resolution rebumps to the next available slot. - VERSION: 0.41.25.0 → 0.41.27.0 - package.json: synced - CHANGELOG.md: my v0.41.27.0 entry placed above master's v0.41.26.0 and v0.41.25.0; in-entry version references updated 0.41.25.0 → 0.41.27.0 and forward-references bumped to v0.41.28+. - TODOS.md: kept master's v0.41.20.x section + my v0.41.27.0+ follow-ups No source-file conflicts during the merge. * feat(diagnostics): db-disconnect audit + doctor surface (v0.41.27.0) Instruments every db.disconnect() and PostgresEngine.disconnect() call with a JSONL audit record so the next user-reported #1570 cycle gives us the offender's caller stack instead of the symptomatic "No database connection" error. Audit shape (~/.gbrain/audit/db-disconnect-YYYY-Www.jsonl): {ts, engine_kind, connection_style, caller_stack[], command, pid} - src/core/audit/db-disconnect-audit.ts (NEW): the audit writer, built on the v0.40.4.0 createAuditWriter cathedral. Captures a 6-frame stack via new Error().stack so the offender is readable without spending stderr noise. - src/core/db.ts: logDbDisconnect call at the top of disconnect() (best-effort; never blocks the real teardown). - src/core/postgres-engine.ts: same instrumentation in PostgresEngine.disconnect() — distinguishes 'module' vs 'instance' connection_style so we can tell legitimate worker-pool teardowns apart from the load-bearing module-singleton class. - src/commands/doctor.ts: extends batch_retry_health to surface 24h disconnect count + most-recent caller stack. Warns when the caller frame isn't a known CLI-exit frame (e.g. cli.ts's finally block at the end of an op-dispatch). This is the diagnostic that tells v0.41.28+ where to apply the real ownership fix. - test/db-disconnect-audit.test.ts: unit coverage for the audit writer + caller-stack capture + JSONL shape. - test/e2e/db-singleton-shared-recovery.test.ts: real-Postgres regression that exercises the singleton-null path end-to-end. Refs #1570 * feat(retry): self-heal on null singleton — closes #1570 symptom (v0.41.27.0) withRetry gains an opt-in reconnect callback that fires between the isRetryableConnError classification and the inter-attempt sleep. PostgresEngine.batchRetry injects this.reconnect() — race-safe via the existing _reconnecting guard, handles module and instance pools. Closes the production loss reported in #1570: dream cycles on Supabase no longer drop ~150 link rows per cycle when the singleton goes null mid-batch. The retry now rebuilds the connection between attempts so the second try has somewhere to write to. - src/core/retry.ts: WithRetryOpts gains `reconnect?: () => Promise<void>`. Awaited in the catch branch. onRetry is also now awaited (back-compat- safe: every existing in-tree caller is a sync arrow). Reconnect failures propagate as the real cause — replaces the symptomatic "No database connection" error with whatever the connect() throw was, so operators see the truth. - src/core/postgres-engine.ts:batchRetry — injects `reconnect: () => this.reconnect()`. Covers all 9 batch-retry call sites (addLinksBatch, addTimelineEntriesBatch, upsertChunks, plus the 6 caller-supplied auditSite labels in extract / sync / reindex). - test/core/retry-reconnect.test.ts: 8 hermetic cases pinning the contract — reconnect fires before sleep, only on retryable errors, back-compat when omitted, signal-aborted bypasses reconnect, onRetry is awaited, full success path end-to-end. The deeper bug (who's calling disconnect mid-cycle) is left unaddressed in this commit by design — the diagnostic instrumentation in the prior commit will tell us in the next production run. Refs #1570 * feat(facts): drainPending() + CLI await before disconnect (v0.41.27.0) Closes the silent 'No database connection' tail-end errors after gbrain capture / put_page: the facts:absorb fire-and-forget queue sometimes outlived the CLI process's connection lifetime, so absorb attempts after engine.disconnect() landed in stderr as the GBrainError shape. - src/core/facts/queue.ts: new drainPending({timeout: 1000}) method distinct from shutdown(). Stops accepting new enqueues, awaits in-flight settle, bounded by timeout, returns count of unfinished. Semantically different from shutdown() (which aborts in-flight) so the symptom — drop work that hasn't started yet but let in-flight work finish — matches what CLI exit actually needs. - src/cli.ts: op-dispatch finally block awaits the drain BEFORE engine.disconnect(). Bounded 1s. Opt-out env GBRAIN_NO_FACTS_DRAIN for callers that don't enqueue (keeps fast-exit paths fast). Mirrors the v0.41.8.0 awaitPendingLastRetrievedWrites pattern. - test/facts-queue-drain-pending.test.ts: 6 hermetic cases — empty drain returns immediately, single in-flight settles, timeout bounds wait, shutdown-after-drain is idempotent, post-drain enqueues are dropped, signal-aborted skips waiting. Refs #1570 * docs: update project documentation for v0.41.27.0 README.md: added troubleshooting entry for the v0.41.27.0 retry-reconnect + facts:absorb drain fix (closes #1570), pointing operators at `gbrain doctor --json` to find the offending disconnect caller. CLAUDE.md: extended `src/core/retry.ts` entry with the new optional `reconnect` callback (v0.41.27.0); added two new Key Files entries for `src/core/audit/db-disconnect-audit.ts` (the diagnostic half of the "instrument first, fix later" pivot) and `FactsQueue.drainPending`; extended `doctor.ts:checkBatchRetryHealth` entry with the in-place extension that surfaces 24h disconnect-call count. llms-full.txt: regenerated to absorb CLAUDE.md edits. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * chore: rebump v0.41.27.0 → v0.41.28.0 (queue collision with #1573) Master shipped v0.41.27.0 (#1573 git-aware sync_freshness) claiming the same slot. Rebump to the next available version. - VERSION + package.json → 0.41.28.0 - CHANGELOG.md: my entry header + in-entry refs 0.41.27.0 → 0.41.28.0 - TODOS.md: my #1570 follow-up section header + body refs bumped * test: pin gateway in put-page-provenance + embedding-dim-check (CI shard fix) Both files failed on CI shards 1 and 8 under the cross-file gateway-state leak class (CLAUDE.md "Test-isolation lint and helpers"). The v0.41.28.0 merge reshuffled the weight-based shard bin-packing, landing a gateway-mutating sibling ahead of these two victims in the same `bun test` process. Mechanism: - put-page-provenance: put_page embeds via the gateway. A sibling left the gateway configured with OpenAI + the CI placeholder `sk-test` (captured at configureGateway time, survives the withEnv restore as cached gateway state). put_page's embed then fired against live OpenAI and 401'd. The bunfig legacy-embedding preload's beforeEach only re-applies legacy when the gateway was RESET — it does NOT correct a sibling that configured a different LIVE config. - embedding-dim-check: initSchema builds the content_chunks vector column at the gateway's configured dim. A sibling leaking ZE/1280 made the column 1280-d, so `expect(dims).toBe(1536)` failed. Fix (victim-side pinning, the escape hatch the preload documents): - Both: configure the gateway explicitly in beforeAll BEFORE initSchema (OpenAI/1536), resetGateway() in afterAll so neither leaks onward. - put-page-provenance also stubs the embed transport via __setEmbedTransportForTests so embed is deterministic and offline; a dummy OPENAI_API_KEY is supplied in the gateway env because instantiateEmbedding builds the OpenAI client (key check) BEFORE the stubbed transport is reached — the stub then intercepts the actual call so the key never leaves the process. Verified: CI shards 1 (1337 pass) + 8 (905 pass) green with OPENAI_API_KEY unset, plus adversarial sibling orderings (gateway.test / doctor-ze-checks preceding). Typecheck + check-test-isolation clean. --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
348 lines
14 KiB
TypeScript
348 lines
14 KiB
TypeScript
/**
|
|
* v0.39.3.0 WARN-8 / A1 / CV6 / CV12 / T2 — put_page provenance.
|
|
*
|
|
* Migration v81 added 4 nullable provenance columns to `pages`
|
|
* (source_kind, source_uri, ingested_via, ingested_at). This test
|
|
* file pins the put_page op contract:
|
|
*
|
|
* 1. Trusted local callers (ctx.remote === false) can populate
|
|
* source_kind / source_uri / ingested_via via params; the engine
|
|
* stamps ingested_at server-side.
|
|
* 2. Remote MCP callers (ctx.remote !== false) get server-stamped
|
|
* `mcp:put_page` REGARDLESS of what they pass — CV6 spoofing guard.
|
|
* 3. COALESCE-preserve UPDATE: a second put_page that omits provenance
|
|
* does NOT overwrite the original ingestion's audit trail — first
|
|
* write wins; routine edits survive without erasing it (CV12).
|
|
* 4. Subagent namespace check still fires when provenance params are
|
|
* present — adding params must not bypass the v0.26.9 F7b
|
|
* fail-closed contract (T2 regression guard).
|
|
*
|
|
* All cases run against in-memory PGLite (hermetic, no DATABASE_URL).
|
|
* Per CLAUDE.md "Test-isolation lint and helpers (v0.26.7)" the engine
|
|
* is created in beforeAll, reset in beforeEach, disconnected in afterAll.
|
|
*/
|
|
|
|
import { describe, test, expect, beforeAll, beforeEach, afterAll } from 'bun:test';
|
|
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
|
|
import { operations } from '../src/core/operations.ts';
|
|
import type { OperationContext } from '../src/core/operations.ts';
|
|
import { OperationError } from '../src/core/operations.ts';
|
|
import { configureGateway, resetGateway, __setEmbedTransportForTests } from '../src/core/ai/gateway.ts';
|
|
|
|
const putPageOp = operations.find((o) => o.name === 'put_page')!;
|
|
|
|
let engine: PGLiteEngine;
|
|
|
|
beforeAll(async () => {
|
|
// Hermeticity guard (cross-file gateway-state leak class — see CLAUDE.md
|
|
// "Test-isolation lint and helpers"). put_page embeds via the gateway.
|
|
// A sibling file in the same shard can leave the gateway configured with
|
|
// a live provider/key (e.g. OpenAI + a CI placeholder `sk-test`); the
|
|
// weight-based shard bin-packing reshuffles which files share a process,
|
|
// so we cannot rely on a benign neighbor. Pin the gateway to legacy
|
|
// OpenAI/1536 (so initSchema builds a 1536-d column) AND stub the embed
|
|
// transport so put_page's embed never touches the network — these tests
|
|
// assert provenance columns, not embeddings. A dummy key is required in
|
|
// the gateway env because instantiateEmbedding builds the OpenAI client
|
|
// (which checks for a key) BEFORE the stubbed transport is reached; the
|
|
// stub then intercepts the actual call, so the key never leaves the
|
|
// process. Reset in afterAll so this file doesn't leak onward.
|
|
configureGateway({
|
|
embedding_model: 'openai:text-embedding-3-large',
|
|
embedding_dimensions: 1536,
|
|
env: { ...process.env, OPENAI_API_KEY: process.env.OPENAI_API_KEY || 'sk-test-stub' },
|
|
});
|
|
__setEmbedTransportForTests(async ({ values }: any) => ({
|
|
embeddings: values.map(() => new Array(1536).fill(0)),
|
|
usage: { tokens: 0 },
|
|
}) as any);
|
|
|
|
engine = new PGLiteEngine();
|
|
await engine.connect({});
|
|
await engine.initSchema();
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await engine.disconnect();
|
|
__setEmbedTransportForTests(null);
|
|
resetGateway();
|
|
});
|
|
|
|
beforeEach(async () => {
|
|
// Wipe pages so each test starts from a known empty state. We use
|
|
// executeRaw rather than a TRUNCATE-by-name sweep because this file
|
|
// only touches one table.
|
|
await engine.executeRaw('DELETE FROM pages', []);
|
|
});
|
|
|
|
function makeCtx(opts: Partial<OperationContext> = {}): OperationContext {
|
|
return {
|
|
engine,
|
|
config: { engine: 'pglite' as const },
|
|
logger: {
|
|
info: () => { /* noop */ },
|
|
warn: () => { /* noop */ },
|
|
error: () => { /* noop */ },
|
|
},
|
|
dryRun: false,
|
|
remote: false,
|
|
sourceId: 'default',
|
|
...opts,
|
|
};
|
|
}
|
|
|
|
// Helper — read provenance columns straight from the DB so we don't depend
|
|
// on the get_page op (Phase 3b extension).
|
|
async function readProvenance(slug: string): Promise<{
|
|
source_kind: string | null;
|
|
source_uri: string | null;
|
|
ingested_via: string | null;
|
|
ingested_at: Date | null;
|
|
}> {
|
|
const rows = await engine.executeRaw(
|
|
'SELECT source_kind, source_uri, ingested_via, ingested_at FROM pages WHERE slug = $1',
|
|
[slug],
|
|
) as Array<{ source_kind: unknown; source_uri: unknown; ingested_via: unknown; ingested_at: unknown }>;
|
|
const r = rows[0];
|
|
return {
|
|
source_kind: (r?.source_kind as string | null) ?? null,
|
|
source_uri: (r?.source_uri as string | null) ?? null,
|
|
ingested_via: (r?.ingested_via as string | null) ?? null,
|
|
ingested_at: r?.ingested_at ? new Date(r.ingested_at as string) : null,
|
|
};
|
|
}
|
|
|
|
describe('put_page provenance — trusted local caller (ctx.remote === false)', () => {
|
|
test('client params honored: source_kind / source_uri / ingested_via populate DB', async () => {
|
|
const ctx = makeCtx({ remote: false });
|
|
await putPageOp.handler(ctx, {
|
|
slug: 'wiki/p3a-local-write',
|
|
content: '---\ntype: note\ntitle: Local Write\n---\n\nbody',
|
|
source_kind: 'capture-cli',
|
|
source_uri: 'file:///tmp/test.md',
|
|
ingested_via: 'put_page',
|
|
});
|
|
const prov = await readProvenance('wiki/p3a-local-write');
|
|
expect(prov.source_kind).toBe('capture-cli');
|
|
expect(prov.source_uri).toBe('file:///tmp/test.md');
|
|
expect(prov.ingested_via).toBe('put_page');
|
|
// Server stamps ingested_at when ANY provenance field fires
|
|
expect(prov.ingested_at).toBeInstanceOf(Date);
|
|
expect(prov.ingested_at!.getTime()).toBeGreaterThan(Date.now() - 60_000);
|
|
});
|
|
|
|
test('omitting all provenance params leaves all 4 DB columns null', async () => {
|
|
const ctx = makeCtx({ remote: false });
|
|
await putPageOp.handler(ctx, {
|
|
slug: 'wiki/p3a-no-provenance',
|
|
content: '---\ntype: note\ntitle: No Prov\n---\n\nbody',
|
|
});
|
|
const prov = await readProvenance('wiki/p3a-no-provenance');
|
|
expect(prov.source_kind).toBeNull();
|
|
expect(prov.source_uri).toBeNull();
|
|
expect(prov.ingested_via).toBeNull();
|
|
expect(prov.ingested_at).toBeNull();
|
|
});
|
|
|
|
test('partial provenance (source_kind only) still triggers ingested_at stamp', async () => {
|
|
const ctx = makeCtx({ remote: false });
|
|
await putPageOp.handler(ctx, {
|
|
slug: 'wiki/p3a-partial',
|
|
content: '---\ntype: note\ntitle: Partial\n---\n\nbody',
|
|
source_kind: 'capture-cli',
|
|
});
|
|
const prov = await readProvenance('wiki/p3a-partial');
|
|
expect(prov.source_kind).toBe('capture-cli');
|
|
expect(prov.source_uri).toBeNull();
|
|
expect(prov.ingested_via).toBeNull();
|
|
expect(prov.ingested_at).toBeInstanceOf(Date);
|
|
});
|
|
});
|
|
|
|
describe('put_page provenance — CV6 spoofing guard (ctx.remote !== false)', () => {
|
|
test('remote caller cannot claim source_kind: capture-cli', async () => {
|
|
const ctx = makeCtx({ remote: true });
|
|
await putPageOp.handler(ctx, {
|
|
slug: 'wiki/p3a-remote-spoof-attempt',
|
|
content: '---\ntype: note\ntitle: Spoof\n---\n\nbody',
|
|
source_kind: 'capture-cli', // client lies: pretends to be local CLI
|
|
source_uri: 'spoofed://attacker-supplied',
|
|
ingested_via: 'file-watcher', // client lies: claims daemon source
|
|
});
|
|
const prov = await readProvenance('wiki/p3a-remote-spoof-attempt');
|
|
// Server overrode with mcp:put_page; client claims discarded
|
|
expect(prov.source_kind).toBe('mcp:put_page');
|
|
expect(prov.source_uri).toBeNull();
|
|
expect(prov.ingested_via).toBe('mcp:put_page');
|
|
expect(prov.ingested_at).toBeInstanceOf(Date);
|
|
});
|
|
|
|
test('ctx.remote === undefined (no explicit trust) is treated as remote', async () => {
|
|
// v0.26.9 F7b discipline: anything that isn't strictly `false` is remote.
|
|
const ctx: OperationContext = {
|
|
engine,
|
|
config: { engine: 'pglite' as const },
|
|
logger: { info: () => {}, warn: () => {}, error: () => {} },
|
|
dryRun: false,
|
|
// remote: explicitly undefined to exercise the fail-closed path
|
|
remote: undefined as unknown as boolean,
|
|
sourceId: 'default',
|
|
};
|
|
await putPageOp.handler(ctx, {
|
|
slug: 'wiki/p3a-undefined-trust',
|
|
content: '---\ntype: note\ntitle: Undefined\n---\n\nbody',
|
|
source_kind: 'capture-cli',
|
|
});
|
|
const prov = await readProvenance('wiki/p3a-undefined-trust');
|
|
expect(prov.source_kind).toBe('mcp:put_page');
|
|
});
|
|
});
|
|
|
|
describe('put_page provenance — CV12 COALESCE-preserve UPDATE', () => {
|
|
test('second write WITHOUT provenance preserves first-write audit', async () => {
|
|
const ctx = makeCtx({ remote: false });
|
|
|
|
// First write stamps capture-cli
|
|
await putPageOp.handler(ctx, {
|
|
slug: 'wiki/p3a-preserve',
|
|
content: '---\ntype: note\ntitle: V1\n---\n\noriginal body',
|
|
source_kind: 'capture-cli',
|
|
ingested_via: 'put_page',
|
|
});
|
|
const first = await readProvenance('wiki/p3a-preserve');
|
|
expect(first.source_kind).toBe('capture-cli');
|
|
const firstStamp = first.ingested_at!.getTime();
|
|
|
|
// Second write — same slug, no provenance params
|
|
await new Promise((r) => setTimeout(r, 10)); // ensure now() would tick
|
|
await putPageOp.handler(ctx, {
|
|
slug: 'wiki/p3a-preserve',
|
|
content: '---\ntype: note\ntitle: V2\n---\n\nedited body',
|
|
});
|
|
const second = await readProvenance('wiki/p3a-preserve');
|
|
// CV12: provenance preserved — first-write wins
|
|
expect(second.source_kind).toBe('capture-cli');
|
|
expect(second.ingested_via).toBe('put_page');
|
|
// ingested_at preserved too (audit trail truthful about WHEN the first
|
|
// ingestion happened, not WHEN the edit landed)
|
|
expect(second.ingested_at!.getTime()).toBe(firstStamp);
|
|
});
|
|
|
|
test('second write WITH new provenance overwrites (explicit re-ingestion)', async () => {
|
|
const ctx = makeCtx({ remote: false });
|
|
|
|
await putPageOp.handler(ctx, {
|
|
slug: 'wiki/p3a-reingest',
|
|
content: '---\ntype: note\ntitle: V1\n---\n\nbody',
|
|
source_kind: 'capture-cli',
|
|
});
|
|
|
|
await new Promise((r) => setTimeout(r, 10));
|
|
await putPageOp.handler(ctx, {
|
|
slug: 'wiki/p3a-reingest',
|
|
content: '---\ntype: note\ntitle: V2\n---\n\nbody',
|
|
source_kind: 'file-watcher', // explicit re-ingest under different kind
|
|
source_uri: 'file:///watched/path.md',
|
|
ingested_via: 'file-watcher',
|
|
});
|
|
const prov = await readProvenance('wiki/p3a-reingest');
|
|
// COALESCE(EXCLUDED.source_kind, pages.source_kind) — EXCLUDED non-null wins
|
|
expect(prov.source_kind).toBe('file-watcher');
|
|
expect(prov.source_uri).toBe('file:///watched/path.md');
|
|
expect(prov.ingested_via).toBe('file-watcher');
|
|
});
|
|
|
|
test('remote second write WITHOUT explicit provenance does NOT erase local first-write', async () => {
|
|
// First: local CLI capture
|
|
const localCtx = makeCtx({ remote: false });
|
|
await putPageOp.handler(localCtx, {
|
|
slug: 'wiki/p3a-local-then-remote',
|
|
content: '---\ntype: note\ntitle: V1\n---\n\nbody',
|
|
source_kind: 'capture-cli',
|
|
ingested_via: 'put_page',
|
|
});
|
|
|
|
// Second: remote MCP edit (server stamps mcp:put_page)
|
|
const remoteCtx = makeCtx({ remote: true });
|
|
await putPageOp.handler(remoteCtx, {
|
|
slug: 'wiki/p3a-local-then-remote',
|
|
content: '---\ntype: note\ntitle: V2\n---\n\nremote edit',
|
|
});
|
|
|
|
// Remote second write is itself a provenance write (server-stamped),
|
|
// so COALESCE(EXCLUDED.source_kind='mcp:put_page', pages.source_kind='capture-cli')
|
|
// resolves to EXCLUDED's non-null value: 'mcp:put_page'. This is the
|
|
// honest answer — the MOST RECENT ingestion source is mcp:put_page,
|
|
// and the system says so. CV12 first-write-wins applies when the second
|
|
// write OMITS provenance entirely (covered by the earlier test); when
|
|
// the second write IS an ingestion, it gets to record itself.
|
|
const prov = await readProvenance('wiki/p3a-local-then-remote');
|
|
expect(prov.source_kind).toBe('mcp:put_page');
|
|
});
|
|
});
|
|
|
|
describe('put_page provenance — T2 subagent namespace regression', () => {
|
|
test('subagent with provenance params STILL gets wiki/agents/<id>/ rejection', async () => {
|
|
// Adding provenance params to put_page must NOT bypass the
|
|
// viaSubagent + sandbox namespace check (operations.ts:556-578).
|
|
// The check runs against `ctx.viaSubagent`, NOT against the params.
|
|
// This regression guard pins it.
|
|
const ctx = makeCtx({
|
|
remote: true,
|
|
viaSubagent: true,
|
|
subagentId: 42,
|
|
// No allowedSlugPrefixes — falls through to legacy default agent-namespace check
|
|
});
|
|
|
|
// Attempt to write OUTSIDE the wiki/agents/42/ namespace WITH spoofed
|
|
// provenance params (trying every angle to escape).
|
|
await expect(
|
|
putPageOp.handler(ctx, {
|
|
slug: 'wiki/secret/leak',
|
|
content: '---\ntype: note\ntitle: Leak\n---\n\nbody',
|
|
source_kind: 'capture-cli',
|
|
source_uri: 'file:///tmp/spoof',
|
|
ingested_via: 'put_page',
|
|
}),
|
|
).rejects.toThrow(/wiki\/agents\/42/);
|
|
});
|
|
|
|
test('subagent within wiki/agents/<id>/ namespace succeeds; provenance respects CV6', async () => {
|
|
const ctx = makeCtx({
|
|
remote: true,
|
|
viaSubagent: true,
|
|
subagentId: 42,
|
|
});
|
|
|
|
await putPageOp.handler(ctx, {
|
|
slug: 'wiki/agents/42/scratch',
|
|
content: '---\ntype: note\ntitle: Subagent OK\n---\n\nbody',
|
|
source_kind: 'capture-cli', // Spoof attempt — ignored by CV6
|
|
});
|
|
const prov = await readProvenance('wiki/agents/42/scratch');
|
|
// CV6 gate fires AFTER the namespace check; client param ignored
|
|
expect(prov.source_kind).toBe('mcp:put_page');
|
|
});
|
|
|
|
test('subagent missing subagentId fails-closed regardless of provenance params', async () => {
|
|
const ctx: OperationContext = {
|
|
engine,
|
|
config: { engine: 'pglite' as const },
|
|
logger: { info: () => {}, warn: () => {}, error: () => {} },
|
|
dryRun: false,
|
|
remote: true,
|
|
sourceId: 'default',
|
|
viaSubagent: true,
|
|
// subagentId intentionally absent
|
|
};
|
|
|
|
await expect(
|
|
putPageOp.handler(ctx, {
|
|
slug: 'wiki/agents/42/anything',
|
|
content: '---\ntype: note\n---\n\nbody',
|
|
source_kind: 'capture-cli',
|
|
}),
|
|
).rejects.toThrow(OperationError);
|
|
});
|
|
});
|