fleet-memory/hindsight-integrations/openclaw/src/backfill.test.ts
Nicolò Boschi 576016f5dc
feat: add @vectorize-io/hindsight-all daemon lifecycle package (#949)
* feat: add @vectorize-io/hindsight-embed daemon lifecycle package

Create a new top-level `hindsight-embed-npm/` package that owns the daemon
lifecycle for the Python `hindsight-embed` CLI: spawning via `uvx`, writing
the profile, waiting for `/health`, and shutting down. Nothing more.

Deliberately does not ship an HTTP client — `@vectorize-io/hindsight-client`
already covers retain / recall / reflect / createBank against the Hindsight
API, and the two packages compose: once `manager.start()` returns, consumers
talk to the daemon via `new HindsightClient({ baseUrl: manager.getBaseUrl() })`.

`HindsightEmbedManagerOptions.env` forwards an arbitrary `Record<string,
string>` to both the daemon process and the profile config via `--env K=V`,
and `extraProfileCreateArgs` / `extraDaemonStartArgs` escape hatches cover
any new CLI flag without waiting for a wrapper release.

Refactor `hindsight-integrations/openclaw` to consume both packages:
`HindsightEmbedManager` for daemon lifecycle in local mode, `HindsightClient`
for all HTTP memory operations. Drop the bespoke subprocess/HTTP client that
used to live in openclaw. The retain queue stays local to openclaw (it's a
client-side reliability workaround with a single consumer today — will move
to the client package or server-side when a second consumer needs it).

Wire the new package into the main release pipeline (versioned alongside
the other core packages, published from `v*` tags) and add a CI build job.

* docs: add Embedded Node.js SDK page for @vectorize-io/hindsight-embed

* refactor: rename hindsight-embed-npm to hindsight-all, restructure docs sidebar

The Node package previously named @vectorize-io/hindsight-embed was
semantically misnamed: hindsight-embed (Python) is a CLI tool, while what
this Node package actually provides is the Node equivalent of hindsight-all
— a programmatic lifecycle manager for a local Hindsight daemon. Rename to
match.

Package rename
  - hindsight-embed-npm/ → hindsight-all-npm/ (git mv, history preserved)
  - @vectorize-io/hindsight-embed → @vectorize-io/hindsight-all
  - class HindsightEmbedManager → HindsightServer (matches Python hindsight-all)
  - HindsightEmbedManagerOptions → HindsightServerOptions
  - src/manager.ts → src/server.ts, src/manager.test.ts → src/server.test.ts
  - openclaw (index.ts, backfill.ts, tests) and the claude-code Python port
    updated to reference the new names

Docs restructure
  - Split sdks/python.md: now client-only content. New sdks/hindsight-all.md
    covers the programmatic hindsight-all Python package (HindsightServer and
    HindsightEmbedded).
  - Rename sdks/embed-npm.md → sdks/hindsight-all-npm.md with HindsightServer
    examples.
  - New "Installation" sidebar section, placed after Hosting, containing
    Docker / Kubernetes / Bare Metal (anchor links into developer/installation)
    plus Programmatic API (Python), Programmatic API (Node.js), and Daemon CLI.
  - Add si-docker, si-kubernetes, si-nodedotjs, lu-hard-drive to the sidebar
    ICON_MAP.

Docs dev-server fix
  - docusaurus.config.ts: drop the flaky NODE_ENV sniff for including the
    "Next" version. Use INCLUDE_CURRENT_VERSION exclusively. NODE_ENV was
    unreliable across hot-reload paths and caused the Next version to
    disappear intermittently when editing files.
  - scripts/dev/start-docs.sh: export INCLUDE_CURRENT_VERSION=true so local
    dev always shows Next; production builds leave it unset.

Lockfile cleanup
  - package-lock.json and hindsight-integrations/openclaw/package-lock.json
    had extraneous hindsight-embed-npm blocks left over from the rename.
    Removed manually and verified with npm install.

* ci: fix openclaw jobs by pre-building workspace deps; regenerate docs-skill

The build-openclaw-integration and test-openclaw-integration jobs failed
with "Failed to resolve entry for package @vectorize-io/hindsight-all"
because openclaw depends on two monorepo workspaces via `file:` deps
(@vectorize-io/hindsight-client and @vectorize-io/hindsight-all) whose
`dist/` directories are gitignored and never built before openclaw's npm ci.
Both jobs now install the root workspace and build the two deps first,
mirroring the release-control-plane pattern.

Also regenerate skills/hindsight-docs/references/* via
./scripts/generate-docs-skill.sh:
  - new skill pages for sdks/hindsight-all{.md,-npm.md}
  - updated skill pages for sdks/embed.md and sdks/python.md to match
    the new H1s and split content
  - incidental refreshes to changelog/index.md, developer/models.md,
    openapi.json, and uv.lock that verify-generated-files picked up

* ci: build openclaw before running tests so symlink test can realpath dist
2026-04-10 15:51:44 +02:00

155 lines
6 KiB
TypeScript

import { afterEach, describe, expect, it, vi } from 'vitest';
import { mkdtempSync, symlinkSync, writeFileSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import { pathToFileURL } from 'url';
import type { BankStats, PluginConfig } from './types.js';
import type { BackfillCheckpoint, BackfillPlanEntry } from './backfill-lib.js';
const managerStart = vi.fn();
const managerStop = vi.fn();
const managerGetBaseUrl = vi.fn(() => 'http://127.0.0.1:9077');
vi.mock('@vectorize-io/hindsight-all', async () => {
const actual = await vi.importActual<typeof import('@vectorize-io/hindsight-all')>('@vectorize-io/hindsight-all');
return {
...actual,
HindsightServer: vi.fn(class {
start = managerStart;
stop = managerStop;
getBaseUrl = managerGetBaseUrl;
}),
};
});
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
managerStart.mockReset();
managerStop.mockReset();
managerGetBaseUrl.mockClear();
});
function makeEntry(bankId: string, sessionId: string): BackfillPlanEntry {
return {
filePath: `/tmp/${sessionId}.jsonl`,
agentId: bankId,
sessionId,
bankId,
documentId: `backfill::${bankId}::${sessionId}`,
transcript: '[role: user]\nhello\n[user:end]',
messageCount: 1,
};
}
function makeStats(overrides: Partial<BankStats> = {}): BankStats {
return {
bank_id: 'bank',
total_nodes: 0,
total_links: 0,
total_documents: 0,
pending_operations: 0,
failed_operations: 0,
pending_consolidation: 0,
last_consolidated_at: null,
total_observations: 0,
...overrides,
};
}
describe('backfill helpers', () => {
it('resume skips only completed entries', async () => {
const { filterEntriesForResume, splitResumeEntries } = await import('./backfill.js');
const entries = [makeEntry('bank-a', '1'), makeEntry('bank-a', '2'), makeEntry('bank-a', '3')];
const checkpoint: BackfillCheckpoint = {
version: 1,
entries: {
'bank-a::backfill::bank-a::1': { status: 'completed', bankId: 'bank-a', filePath: '/tmp/1', sessionId: '1', updatedAt: 'now' },
'bank-a::backfill::bank-a::2': { status: 'enqueued', bankId: 'bank-a', filePath: '/tmp/2', sessionId: '2', updatedAt: 'now' },
'bank-a::backfill::bank-a::3': { status: 'failed', bankId: 'bank-a', filePath: '/tmp/3', sessionId: '3', updatedAt: 'now' },
},
};
const resumable = filterEntriesForResume(entries, checkpoint, true);
expect(resumable.map((entry) => entry.sessionId)).toEqual(['2', '3']);
expect(splitResumeEntries(resumable, checkpoint, false).entriesToEnqueue.map((entry) => entry.sessionId)).toEqual(['2', '3']);
expect(splitResumeEntries(resumable, checkpoint, true)).toEqual({
entriesToEnqueue: [entries[2]],
alreadyEnqueuedKeys: ['bank-a::backfill::bank-a::2'],
});
});
it('normalizes legacy queued checkpoint entries', async () => {
const { loadCheckpoint } = await import('./backfill-lib.js');
const dir = mkdtempSync(join(tmpdir(), 'hindsight-backfill-'));
const checkpointPath = join(dir, 'checkpoint.json');
writeFileSync(checkpointPath, JSON.stringify({
version: 1,
entries: {
legacy: { status: 'queued', bankId: 'bank-a', filePath: '/tmp/a', sessionId: 'a', updatedAt: 'now' },
},
}), 'utf8');
const checkpoint = loadCheckpoint(checkpointPath);
expect(checkpoint.entries.legacy.status).toBe('enqueued');
});
it('marks drained entries completed and leaves aggregate-failure banks enqueued', async () => {
const { applyDrainResults } = await import('./backfill.js');
const checkpoint: BackfillCheckpoint = {
version: 1,
entries: {
a: { status: 'enqueued', bankId: 'bank-a', filePath: '/tmp/a', sessionId: 'a', updatedAt: 'now' },
b: { status: 'enqueued', bankId: 'bank-b', filePath: '/tmp/b', sessionId: 'b', updatedAt: 'now' },
},
};
const touchedEntriesByBank = new Map([
['bank-a', ['a']],
['bank-b', ['b']],
]);
const finalStatsByBank = new Map<string, BankStats>([
['bank-a', makeStats({ bank_id: 'bank-a', pending_operations: 0, failed_operations: 0 })],
['bank-b', makeStats({ bank_id: 'bank-b', pending_operations: 0, failed_operations: 2 })],
]);
const initialFailedByBank = new Map([
['bank-a', 0],
['bank-b', 0],
]);
const result = applyDrainResults(checkpoint, touchedEntriesByBank, finalStatsByBank, initialFailedByBank);
expect(result.completed).toBe(1);
expect(result.unresolved).toBe(1);
expect(result.warnings).toEqual([
'bank bank-b reported 2 new failed operations during drain; leaving 1 checkpoint entries enqueued',
]);
expect(checkpoint.entries.a.status).toBe('completed');
expect(checkpoint.entries.b.status).toBe('enqueued');
});
it('starts local daemon when no external API is configured and health check fails', async () => {
const fetchMock = vi.fn().mockRejectedValue(new Error('offline'));
vi.stubGlobal('fetch', fetchMock);
const { createBackfillRuntime } = await import('./backfill.js');
const pluginConfig: PluginConfig = {
apiPort: 9077,
llmProvider: 'openai-codex',
llmModel: 'gpt-5.4',
};
const runtime = await createBackfillRuntime(pluginConfig);
expect(managerStart).toHaveBeenCalledTimes(1);
expect(runtime.apiUrl).toBe('http://127.0.0.1:9077');
await runtime.stop();
expect(managerStop).toHaveBeenCalledTimes(1);
});
it('treats a symlinked bin path as direct execution', async () => {
const { isDirectExecution } = await import('./backfill.js');
const dir = mkdtempSync(join(tmpdir(), 'hindsight-backfill-bin-'));
const modulePath = join(process.cwd(), 'dist', 'backfill.js');
const symlinkPath = join(dir, 'hindsight-openclaw-backfill');
symlinkSync(modulePath, symlinkPath);
const moduleUrl = pathToFileURL(modulePath).href;
expect(isDirectExecution(symlinkPath, moduleUrl)).toBe(true);
expect(isDirectExecution(join(dir, 'other-entrypoint'), moduleUrl)).toBe(false);
});
});