mirror of
https://github.com/garrytan/gbrain.git
synced 2026-08-14 00:48:18 +00:00
fix(ingest,sync,serve): three singleton P0s — type round-trip, deleted-slug embed noise, stateless width guard (#3140)
- #1035: importFromContent preserves an existing page's type when incoming frontmatter omits an explicit type: field. Explicit type stays an override; absence means preserve; new pages still path-infer. The existing-page fetch moved above the content-hash compute so a no-op re-put stays a hash-match skip. Root-cause fix covers put_page, sync, capture — every caller. - #1284: sync's end-of-run auto-embed no longer receives slugs deleted in the same run (embedPage threw 'Page not found' per deleted slug and serr-logged noise on every rename/delete sync). pagesAffected stays the full manifest for extract/report paths; a slug deleted then re-imported in the same run stays embeddable. - #1196: gbrain serve --http now runs doctor's embedding_width_consistency check at startup and prints a loud stderr banner (with the paste-ready recipe + GBRAIN_EMBEDDING_MODEL/DIMENSIONS hint) when the resolved width diverges from the brain's vector(N) column — the stateless-container fallthrough that broke every write. Fail-open; reads unaffected. Co-authored-by: Garry Tan <garrytan@gmail.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Garry Tan
Claude Fable 5
parent
3594c316b5
commit
69e7e79a1f
@@ -449,6 +449,34 @@ export function skillPublishStatus(publishSkills: boolean): { bannerValue: strin
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* #1196: startup embedding-width guard for stateless host deployments.
|
||||
*
|
||||
* `embedding_model` / `embedding_dimensions` are file/env-plane only, so a
|
||||
* container booted WITHOUT a config.json (stateless host) resolves the
|
||||
* compiled-in default embedding width. Against an existing brain whose
|
||||
* `content_chunks.embedding` is a different `vector(N)`, every write then
|
||||
* fails with an opaque dim mismatch. Run doctor's existing
|
||||
* embedding_width_consistency check at serve startup and return a loud
|
||||
* banner (with the paste-ready recipe) when it isn't ok. Fail-open: a check
|
||||
* error never blocks serving read traffic.
|
||||
*/
|
||||
export async function embeddingWidthStartupWarning(engine: BrainEngine): Promise<string | null> {
|
||||
try {
|
||||
const { checkEmbeddingWidthConsistency } = await import('./doctor.ts');
|
||||
const check = await checkEmbeddingWidthConsistency(engine);
|
||||
if (check.status === 'ok') return null;
|
||||
return (
|
||||
`[serve-http] WARNING: embedding width check failed — writes that embed will fail until fixed.\n` +
|
||||
`${check.message}\n` +
|
||||
`Stateless hosts: embedding_model/embedding_dimensions resolve from env/config.json only — ` +
|
||||
`set GBRAIN_EMBEDDING_MODEL / GBRAIN_EMBEDDING_DIMENSIONS (or mount config.json) to match the brain's schema.`
|
||||
);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function runServeHttp(engine: BrainEngine, options: ServeHttpOptions) {
|
||||
const { port, tokenTtl, enableDcr, enableDcrInsecure, publicUrl, logFullParams } = options;
|
||||
// v0.34.1 (#864, D11): default bind flipped from 0.0.0.0 to 127.0.0.1.
|
||||
@@ -473,6 +501,14 @@ export async function runServeHttp(engine: BrainEngine, options: ServeHttpOption
|
||||
);
|
||||
}
|
||||
|
||||
// #1196: fail-loud at startup when the resolved embedding width diverges
|
||||
// from the brain's actual vector(N) column (stateless containers falling
|
||||
// through to the compiled-in default). Non-fatal: reads still work.
|
||||
{
|
||||
const widthWarn = await embeddingWidthStartupWarning(engine);
|
||||
if (widthWarn) console.error(widthWarn);
|
||||
}
|
||||
|
||||
// Skill-publishing status for the banner + nudge. Mirrors readMcpPublishSkills
|
||||
// (skill-catalog.ts): the DB plane (`gbrain config set`) wins over the file
|
||||
// plane. When OFF, a connected coding agent can't see the host's skill
|
||||
|
||||
+21
-4
@@ -2475,6 +2475,13 @@ async function performSyncInner(engine: BrainEngine, opts: SyncOpts): Promise<Sy
|
||||
};
|
||||
|
||||
const pagesAffected: string[] = [];
|
||||
// #1284: slugs deleted this run (delete loop, or renamed-away old slugs are
|
||||
// NOT pushed — only confirmed deletes land here). pagesAffected stays the
|
||||
// full manifest for extract/report paths, but the auto-embed at the end
|
||||
// must NOT be handed deleted slugs: embedPage throws 'Page not found' for
|
||||
// each one and serr-logs noise. A slug re-imported later in the same run
|
||||
// (delete + re-add) is removed from this set at its push site.
|
||||
const deletedSlugs = new Set<string>();
|
||||
// issue #1939: file paths that imported cleanly this run. The failure-ledger
|
||||
// gate clears these so a previously-failing file's `attempts` streak resets
|
||||
// on success (consecutive-failure semantics for the auto-skip valve).
|
||||
@@ -2635,6 +2642,7 @@ async function performSyncInner(engine: BrainEngine, opts: SyncOpts): Promise<Sy
|
||||
// slugs (paths in filtered.deleted but with no DB row) so
|
||||
// downstream extract/embed don't waste lookups.
|
||||
pagesAffected.push(...deleted);
|
||||
for (const s of deleted) deletedSlugs.add(s);
|
||||
// v0.42.x (#1794): the whole batch is handled (deleted or already
|
||||
// gone); checkpoint every path so a resume skips it.
|
||||
for (const p of batch) await markCompleted(p);
|
||||
@@ -2647,6 +2655,7 @@ async function performSyncInner(engine: BrainEngine, opts: SyncOpts): Promise<Sy
|
||||
try {
|
||||
await engine.deletePage(slugs[j], deleteScopedOpts);
|
||||
pagesAffected.push(slugs[j]);
|
||||
deletedSlugs.add(slugs[j]);
|
||||
await markCompleted(batch[j]);
|
||||
} catch (perSlugErr) {
|
||||
failedFiles.push({
|
||||
@@ -2674,6 +2683,7 @@ async function performSyncInner(engine: BrainEngine, opts: SyncOpts): Promise<Sy
|
||||
try {
|
||||
await engine.deletePage(slug, deleteOpts);
|
||||
pagesAffected.push(slug);
|
||||
deletedSlugs.add(slug);
|
||||
await markCompleted(path);
|
||||
} catch (err) {
|
||||
failedFiles.push({
|
||||
@@ -2774,6 +2784,7 @@ async function performSyncInner(engine: BrainEngine, opts: SyncOpts): Promise<Sy
|
||||
}
|
||||
}
|
||||
pagesAffected.push(newSlug);
|
||||
deletedSlugs.delete(newSlug); // #1284: rename landed on a previously-deleted slug → embeddable again
|
||||
await markCompleted(to);
|
||||
progress.tick(1, newSlug);
|
||||
}
|
||||
@@ -2974,6 +2985,7 @@ async function performSyncInner(engine: BrainEngine, opts: SyncOpts): Promise<Sy
|
||||
if (result.status === 'imported') {
|
||||
chunksCreated += result.chunks;
|
||||
pagesAffected.push(result.slug);
|
||||
deletedSlugs.delete(result.slug); // #1284: deleted-then-re-added in the same run → embeddable again
|
||||
// issue #1939: record the file path (not slug) so the gate clears any
|
||||
// prior failure-ledger row — success resets the auto-skip attempt streak.
|
||||
succeededPaths.push(path);
|
||||
@@ -3396,14 +3408,19 @@ async function performSyncInner(engine: BrainEngine, opts: SyncOpts): Promise<Sy
|
||||
// sync. Non-mismatch errors stay best-effort (rate limits, transient
|
||||
// network) — those shouldn't break sync.
|
||||
let embedded = 0;
|
||||
if (!noEmbed && pagesAffected.length > 0 && pagesAffected.length <= 100) {
|
||||
// #1284: never hand deleted slugs to the embedder — embedPage throws
|
||||
// 'Page not found' per deleted slug and logs one error line each. Filter
|
||||
// against this run's confirmed-deleted set (slugs re-imported later in the
|
||||
// run were removed from it at their push sites).
|
||||
const embedSlugs = pagesAffected.filter((s) => !deletedSlugs.has(s));
|
||||
if (!noEmbed && embedSlugs.length > 0 && pagesAffected.length <= 100) {
|
||||
try {
|
||||
const { runEmbedCore } = await import('./embed.ts');
|
||||
const embedOpts = opts.sourceId
|
||||
? { slugs: pagesAffected, sourceId: opts.sourceId }
|
||||
: { slugs: pagesAffected };
|
||||
? { slugs: embedSlugs, sourceId: opts.sourceId }
|
||||
: { slugs: embedSlugs };
|
||||
await runEmbedCore(engine, embedOpts);
|
||||
embedded = pagesAffected.length;
|
||||
embedded = embedSlugs.length;
|
||||
} catch (e: unknown) {
|
||||
const { EmbeddingDimMismatchError } = await import('./embed.ts');
|
||||
if (e instanceof EmbeddingDimMismatchError) {
|
||||
|
||||
+14
-1
@@ -537,6 +537,20 @@ export async function importFromContent(
|
||||
// is real, unbounded embedding spend). Same bug class as the captured_at /
|
||||
// ingested_at fix above; the gate re-derives the markers deterministically
|
||||
// on the next import, so dropping them from the hash is safe.
|
||||
// #1035: fetch the existing page BEFORE the hash compute so (a) the type
|
||||
// preservation below participates in the hash (a no-op re-put stays a
|
||||
// hash-match skip) and (b) the hash short-circuit below reuses this row.
|
||||
const existing = await engine.getPage(slug, sourceId ? { sourceId } : undefined);
|
||||
|
||||
// #1035: absence of an explicit frontmatter `type:` on an EXISTING page
|
||||
// means "preserve the stored type", not "re-infer". Pre-fix, a round-trip
|
||||
// put (get_page → edit body → put_page without `type:`) silently regressed
|
||||
// a curated type to the path-inferred default ('concept' for bare slugs).
|
||||
// Explicit frontmatter type stays an override; new pages still infer.
|
||||
if (parsed.typeExplicit !== true && existing) {
|
||||
parsed.type = existing.type;
|
||||
}
|
||||
|
||||
const HASH_EPHEMERAL_FRONTMATTER_KEYS = [
|
||||
'captured_at',
|
||||
'ingested_at',
|
||||
@@ -569,7 +583,6 @@ export async function importFromContent(
|
||||
tags: parsed.tags,
|
||||
};
|
||||
|
||||
const existing = await engine.getPage(slug, sourceId ? { sourceId } : undefined);
|
||||
if (existing?.content_hash === hash && !opts.forceRechunk) {
|
||||
return { slug, status: 'skipped', chunks: 0, parsedPage };
|
||||
}
|
||||
|
||||
+10
-1
@@ -44,6 +44,13 @@ export interface ParsedMarkdown {
|
||||
timeline: string;
|
||||
slug: string;
|
||||
type: PageType;
|
||||
/**
|
||||
* #1035: true when `type` came from an explicit frontmatter `type:` field,
|
||||
* false when it was inferred from the file path (or defaulted to 'concept').
|
||||
* Importers use this to preserve an existing page's type on round-trip:
|
||||
* explicit frontmatter type is an override; absence means "don't change it".
|
||||
*/
|
||||
typeExplicit?: boolean;
|
||||
title: string;
|
||||
tags: string[];
|
||||
/** Present iff opts.validate. Empty array means no errors. */
|
||||
@@ -132,7 +139,8 @@ export function parseMarkdown(
|
||||
// coerceFrontmatterString turns a scalar/date into a usable string (a date slug
|
||||
// `2024-06-01` is legitimate); the NON_STRING_FIELD lint finding below still
|
||||
// surfaces the un-quoted field so it can be cleaned up.
|
||||
const type = coerceFrontmatterString(frontmatter.type) || (
|
||||
const explicitType = coerceFrontmatterString(frontmatter.type);
|
||||
const type = explicitType || (
|
||||
opts?.activePack ? inferTypeFromPack(filePath, opts.activePack) : inferType(filePath)
|
||||
);
|
||||
// #2446: title precedence is frontmatter `title:` > the body's first H1 >
|
||||
@@ -160,6 +168,7 @@ export function parseMarkdown(
|
||||
timeline: timeline.trim(),
|
||||
slug,
|
||||
type,
|
||||
typeExplicit: explicitType !== '',
|
||||
title,
|
||||
tags,
|
||||
};
|
||||
|
||||
@@ -760,3 +760,89 @@ body unchanged
|
||||
expect(shortCircuited).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// #1035 — type round-trip preservation
|
||||
//
|
||||
// put_page → importFromContent → parseMarkdown infers type from the file
|
||||
// path when frontmatter omits `type:`, and bare slugs infer 'concept'.
|
||||
// Pre-fix, a round-trip put (get_page → edit body → put_page WITHOUT a
|
||||
// type: line) silently regressed a curated type to 'concept'. Absence of
|
||||
// an explicit frontmatter type on an EXISTING page must preserve the
|
||||
// stored type; an explicit type stays an override; new pages still infer.
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('importFromContent type round-trip (#1035)', () => {
|
||||
test('re-put without type: preserves the existing page type', async () => {
|
||||
let putType: string | undefined;
|
||||
const engine = mockEngine({
|
||||
getPage: () => Promise.resolve({
|
||||
slug: 'founder-notes-example',
|
||||
type: 'person',
|
||||
content_hash: 'different-hash',
|
||||
updated_at: new Date(),
|
||||
created_at: new Date(),
|
||||
} as any),
|
||||
putPage: (_slug: string, page: any) => {
|
||||
putType = page.type;
|
||||
return Promise.resolve(null);
|
||||
},
|
||||
});
|
||||
const result = await importFromContent(engine, 'founder-notes-example', [
|
||||
'---',
|
||||
'title: Notes',
|
||||
'---',
|
||||
'',
|
||||
'Edited body, no type in frontmatter.',
|
||||
].join('\n'), { noEmbed: true });
|
||||
expect(result.status).toBe('imported');
|
||||
expect(putType).toBe('person'); // pre-fix: 'concept' (bare-slug inference default)
|
||||
});
|
||||
|
||||
test('explicit frontmatter type: still overrides the existing type', async () => {
|
||||
let putType: string | undefined;
|
||||
const engine = mockEngine({
|
||||
getPage: () => Promise.resolve({
|
||||
slug: 'founder-notes-example',
|
||||
type: 'person',
|
||||
content_hash: 'different-hash',
|
||||
updated_at: new Date(),
|
||||
created_at: new Date(),
|
||||
} as any),
|
||||
putPage: (_slug: string, page: any) => {
|
||||
putType = page.type;
|
||||
return Promise.resolve(null);
|
||||
},
|
||||
});
|
||||
const result = await importFromContent(engine, 'founder-notes-example', [
|
||||
'---',
|
||||
'type: note',
|
||||
'title: Notes',
|
||||
'---',
|
||||
'',
|
||||
'Explicit type wins.',
|
||||
].join('\n'), { noEmbed: true });
|
||||
expect(result.status).toBe('imported');
|
||||
expect(putType).toBe('note');
|
||||
});
|
||||
|
||||
test('new page without type: still infers from path', async () => {
|
||||
let putType: string | undefined;
|
||||
const engine = mockEngine({
|
||||
getPage: () => Promise.resolve(null),
|
||||
putPage: (_slug: string, page: any) => {
|
||||
putType = page.type;
|
||||
return Promise.resolve(null);
|
||||
},
|
||||
});
|
||||
const result = await importFromContent(engine, 'people/alice-example', [
|
||||
'---',
|
||||
'title: Alice',
|
||||
'---',
|
||||
'',
|
||||
'A new person page.',
|
||||
].join('\n'), { noEmbed: true });
|
||||
expect(result.status).toBe('imported');
|
||||
expect(putType).toBe('person'); // /people/ path-prefix inference intact
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* #1196 — serve --http startup embedding-width guard.
|
||||
*
|
||||
* A stateless host (container without config.json) resolves the compiled-in
|
||||
* default embedding width; against an existing brain with a different
|
||||
* vector(N) column, every write fails. runServeHttp now runs doctor's
|
||||
* embedding_width_consistency check at startup and prints a loud stderr
|
||||
* banner. This pins the helper: mismatch → banner with the recipe;
|
||||
* match → null (no banner noise on healthy brains).
|
||||
*/
|
||||
|
||||
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
|
||||
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
|
||||
import { configureGateway, resetGateway } from '../src/core/ai/gateway.ts';
|
||||
import { embeddingWidthStartupWarning } from '../src/commands/serve-http.ts';
|
||||
|
||||
let engine: PGLiteEngine;
|
||||
|
||||
beforeAll(async () => {
|
||||
engine = new PGLiteEngine();
|
||||
await engine.connect({});
|
||||
await engine.initSchema();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
// Rule: files that configureGateway must resetGateway in afterAll.
|
||||
// The legacy-embedding preload's beforeEach re-applies defaults for
|
||||
// subsequent files in the same shard.
|
||||
resetGateway();
|
||||
await engine.disconnect();
|
||||
});
|
||||
|
||||
async function schemaDims(): Promise<number> {
|
||||
const rows = await engine.executeRaw<{ format_type: string }>(
|
||||
`SELECT format_type(atttypid, atttypmod) AS format_type
|
||||
FROM pg_attribute
|
||||
WHERE attrelid = 'content_chunks'::regclass
|
||||
AND attname = 'embedding'
|
||||
AND NOT attisdropped`,
|
||||
);
|
||||
const m = rows[0].format_type.match(/vector\((\d+)\)/i);
|
||||
return parseInt(m![1], 10);
|
||||
}
|
||||
|
||||
describe('embeddingWidthStartupWarning (#1196)', () => {
|
||||
test('resolved width matches the schema: no banner', async () => {
|
||||
const dims = await schemaDims();
|
||||
configureGateway({
|
||||
embedding_model: 'openai:text-embedding-3-large',
|
||||
embedding_dimensions: dims,
|
||||
env: { ...process.env },
|
||||
});
|
||||
expect(await embeddingWidthStartupWarning(engine)).toBeNull();
|
||||
});
|
||||
|
||||
test('resolved width diverges from the schema: loud banner with recipe', async () => {
|
||||
// Simulate the stateless-container fallthrough: gateway resolves a
|
||||
// width different from the brain's actual vector(N) column.
|
||||
configureGateway({
|
||||
embedding_model: 'openai:text-embedding-3-small',
|
||||
embedding_dimensions: 768,
|
||||
env: { ...process.env },
|
||||
});
|
||||
const warn = await embeddingWidthStartupWarning(engine);
|
||||
expect(warn).not.toBeNull();
|
||||
expect(warn!).toContain('[serve-http] WARNING');
|
||||
expect(warn!).toContain('mismatch');
|
||||
expect(warn!).toContain('GBRAIN_EMBEDDING_DIMENSIONS');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* #1284 — sync auto-embed must not be handed slugs deleted in the same run.
|
||||
*
|
||||
* The delete loop pushes confirmed-deleted slugs into pagesAffected (the
|
||||
* full manifest for extract/report paths), but the end-of-run auto-embed
|
||||
* used to pass that same list to runEmbedCore. embedPage throws
|
||||
* 'Page not found: <slug>' for each deleted slug and the per-slug loop
|
||||
* serr-logs 'Error embedding <slug>: Page not found' — pure noise on every
|
||||
* rename/delete sync. The embed call now filters against the run's
|
||||
* deleted-slug set (a slug re-imported later in the run stays embeddable).
|
||||
*/
|
||||
|
||||
import { describe, test, expect, beforeAll, afterAll, beforeEach, afterEach } from 'bun:test';
|
||||
import { mkdtempSync, writeFileSync, rmSync, mkdirSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { execSync } from 'child_process';
|
||||
import { tmpdir } from 'os';
|
||||
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
|
||||
import { resetPgliteState } from './helpers/reset-pglite.ts';
|
||||
import { configureGateway, resetGateway } from '../src/core/ai/gateway.ts';
|
||||
|
||||
let engine: PGLiteEngine;
|
||||
let repoPath: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
engine = new PGLiteEngine();
|
||||
await engine.connect({});
|
||||
await engine.initSchema();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
resetGateway(); // preload beforeEach restores legacy defaults for later files
|
||||
await engine.disconnect();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await resetPgliteState(engine);
|
||||
repoPath = mkdtempSync(join(tmpdir(), 'gbrain-sync-del-embed-'));
|
||||
execSync('git init', { cwd: repoPath, stdio: 'pipe' });
|
||||
execSync('git config user.email "test@test.com"', { cwd: repoPath, stdio: 'pipe' });
|
||||
execSync('git config user.name "Test"', { cwd: repoPath, stdio: 'pipe' });
|
||||
mkdirSync(join(repoPath, 'people'), { recursive: true });
|
||||
writeFileSync(join(repoPath, 'people/alice-example.md'), [
|
||||
'---',
|
||||
'type: person',
|
||||
'title: Alice Example',
|
||||
'---',
|
||||
'',
|
||||
'Alice is a person page that will be deleted.',
|
||||
].join('\n'));
|
||||
execSync('git add -A && git commit -m "initial"', { cwd: repoPath, stdio: 'pipe' });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (repoPath) rmSync(repoPath, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('sync auto-embed vs deleted slugs (#1284)', () => {
|
||||
test('delete-only incremental sync does not log Page not found from embed', async () => {
|
||||
const { performSync } = await import('../src/commands/sync.ts');
|
||||
|
||||
// Seed: first sync with --no-embed imports alice and sets the bookmark.
|
||||
const first = await performSync(engine, { repoPath, noPull: true, noEmbed: true });
|
||||
expect(first.status).toBe('first_sync');
|
||||
expect(await engine.getPage('people/alice-example')).not.toBeNull();
|
||||
|
||||
// Delete the file and commit.
|
||||
execSync('git rm -q people/alice-example.md && git commit -qm "delete alice"', {
|
||||
cwd: repoPath, stdio: 'pipe', shell: '/bin/bash',
|
||||
});
|
||||
|
||||
// Configure the gateway to MATCH the schema width with creds present,
|
||||
// so runEmbedCore's preflights (assertEmbeddingEnabled, creds check,
|
||||
// dim-mismatch check) all pass and the per-slug embed loop actually
|
||||
// runs. Pre-fix, that loop received the deleted slug and serr-logged
|
||||
// 'Error embedding people/alice-example: Page not found'.
|
||||
const rows = await engine.executeRaw<{ format_type: string }>(
|
||||
`SELECT format_type(atttypid, atttypmod) AS format_type
|
||||
FROM pg_attribute
|
||||
WHERE attrelid = 'content_chunks'::regclass
|
||||
AND attname = 'embedding'
|
||||
AND NOT attisdropped`,
|
||||
);
|
||||
const dims = parseInt(rows[0].format_type.match(/vector\((\d+)\)/i)![1], 10);
|
||||
configureGateway({
|
||||
embedding_model: 'openai:text-embedding-3-large',
|
||||
embedding_dimensions: dims,
|
||||
env: { ...process.env, OPENAI_API_KEY: 'sk-test-not-real' },
|
||||
});
|
||||
|
||||
// Capture both stderr channels serr() can write to.
|
||||
const captured: string[] = [];
|
||||
const origErr = console.error;
|
||||
const origWrite = process.stderr.write.bind(process.stderr);
|
||||
console.error = (...args: unknown[]) => { captured.push(args.map(String).join(' ')); };
|
||||
(process.stderr as { write: unknown }).write = ((s: unknown) => {
|
||||
captured.push(String(s));
|
||||
return true;
|
||||
}) as typeof process.stderr.write;
|
||||
|
||||
let result: Awaited<ReturnType<typeof performSync>>;
|
||||
try {
|
||||
// NOTE: no noEmbed — the auto-embed path must run to pin the bug.
|
||||
result = await performSync(engine, { repoPath, noPull: true });
|
||||
} finally {
|
||||
console.error = origErr;
|
||||
(process.stderr as { write: unknown }).write = origWrite;
|
||||
}
|
||||
|
||||
expect(result.status).toBe('synced');
|
||||
expect(result.deleted).toBe(1);
|
||||
// pagesAffected stays the full manifest for extract/report consumers.
|
||||
expect(result.pagesAffected).toContain('people/alice-example');
|
||||
// The regression: deleted slug handed to the embedder.
|
||||
const all = captured.join('\n');
|
||||
expect(all).not.toContain('Page not found');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user