mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-10-26 18:55:22 +00:00
从官方 Git 仓库导入 chandra447/pi-hermes-memory 在 tag v0.9.10 (df624056a2e8c68bc89ba0a81e1c120856ec9665,2026-10-02)的源码快照。 本次仅导入源码,尚未接入组合包:package.json、加载顺序、依赖与权限策略 均未改动。 - 原样导入 src/、tests/、docs/、scripts/、README、CHANGELOG、PLAN、 AGENTS 与 LICENSE;该快照共 156 个上游文件。 - 剔除嵌套 .git、上游 .github 工作流、.ralph/、.claude/、node_modules、 覆盖率与构建产物。 - 新增中文 UPSTREAM.md,记录来源,以及启用前必须完成的待决事项: 在 pi-compaction 之前注册加载顺序、better-sqlite3 版本与根 allowScripts 对齐、strip-ansi 解析确认。 - 说明选择 tag 而非 main 的原因:导入时上游 main 已领先至 24fcd8ad26fc27badb8d7dbaf8034645de07b893(2026-10-11),选 tag 是为 对应一个已发布、可复现的版本。 尚未接线。启用前还需反转 tests/extension-load.test.ts 中"不得加载 Hermes"的断言,并同步 pi-tool-search 分组、权限策略与文档。 验证:快照 156 个上游文件全部导入,目录内仅多出 UPSTREAM.md 一份; 已确认与上游 main 内容存在差异,证明取的是 tag 而非 main。
248 lines
8.7 KiB
TypeScript
248 lines
8.7 KiB
TypeScript
import { describe, it, beforeEach, afterEach } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
import os from 'node:os';
|
|
import { DatabaseManager } from '../../src/store/db.js';
|
|
import {
|
|
scheduleSessionBackfill,
|
|
joinSessionBackfill,
|
|
waitForSessionBackfill,
|
|
type SessionBackfillState,
|
|
} from '../../src/handlers/session-backfill.js';
|
|
import { indexAllSessions, touchBackfillTimestamp } from '../../src/store/session-indexer.js';
|
|
|
|
function writeJsonlSession(sessionsDir: string, projectDir: string, sessionId: string, text = 'Hello from backfill'): void {
|
|
const projDir = path.join(sessionsDir, projectDir);
|
|
fs.mkdirSync(projDir, { recursive: true });
|
|
const lines = [
|
|
JSON.stringify({ type: 'session', id: sessionId, timestamp: '2026-05-03T00:00:00Z', cwd: `/work/${projectDir}` }),
|
|
JSON.stringify({
|
|
type: 'message',
|
|
id: `${sessionId}-m1`,
|
|
parentId: null,
|
|
timestamp: '2026-05-03T00:01:00Z',
|
|
message: { role: 'user', content: [{ type: 'text', text }], timestamp: Date.now() },
|
|
}),
|
|
];
|
|
fs.writeFileSync(path.join(projDir, `${sessionId}.jsonl`), lines.join('\n'));
|
|
}
|
|
|
|
describe('session backfill handler', () => {
|
|
let tmpDir: string;
|
|
let sessionsDir: string;
|
|
let dbManager: DatabaseManager;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'session-backfill-test-'));
|
|
sessionsDir = path.join(tmpDir, 'sessions');
|
|
dbManager = new DatabaseManager(path.join(tmpDir, 'memory'));
|
|
});
|
|
|
|
afterEach(() => {
|
|
dbManager.close();
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it('schedules backfill without indexing synchronously, then indexes unindexed sessions', async () => {
|
|
writeJsonlSession(sessionsDir, 'project-a', 's1');
|
|
const callbacks: (() => void)[] = [];
|
|
const state: SessionBackfillState = { inProgress: false, promise: null };
|
|
|
|
const scheduled = scheduleSessionBackfill(dbManager, sessionsDir, {
|
|
state,
|
|
setTimeoutFn: (callback) => {
|
|
callbacks.push(callback);
|
|
return 0;
|
|
},
|
|
});
|
|
|
|
assert.equal(scheduled, true);
|
|
assert.equal(callbacks.length, 1);
|
|
assert.equal(dbManager.getStats().sessions, 0, 'session_start should not index synchronously');
|
|
|
|
const promise = state.promise;
|
|
assert.ok(promise);
|
|
callbacks[0]();
|
|
await promise;
|
|
|
|
assert.equal(dbManager.getStats().sessions, 1);
|
|
assert.equal(dbManager.getStats().messages, 1);
|
|
});
|
|
|
|
it('does not schedule backfill when counts match and timestamp is recent', () => {
|
|
writeJsonlSession(sessionsDir, 'project-a', 's1');
|
|
indexAllSessions(dbManager, sessionsDir);
|
|
touchBackfillTimestamp(dbManager);
|
|
|
|
const callbacks: (() => void)[] = [];
|
|
const state: SessionBackfillState = { inProgress: false, promise: null };
|
|
const scheduled = scheduleSessionBackfill(dbManager, sessionsDir, {
|
|
state,
|
|
setTimeoutFn: (callback) => {
|
|
callbacks.push(callback);
|
|
return 0;
|
|
},
|
|
});
|
|
|
|
assert.equal(scheduled, false);
|
|
assert.equal(callbacks.length, 0);
|
|
assert.equal(state.inProgress, false);
|
|
});
|
|
|
|
it('keeps manual indexAllSessions idempotent with auto-backfill', async () => {
|
|
writeJsonlSession(sessionsDir, 'project-a', 's1');
|
|
const state: SessionBackfillState = { inProgress: false, promise: null };
|
|
|
|
const scheduled = scheduleSessionBackfill(dbManager, sessionsDir, {
|
|
state,
|
|
setTimeoutFn: (callback) => {
|
|
queueMicrotask(callback);
|
|
return 0;
|
|
},
|
|
});
|
|
assert.equal(scheduled, true);
|
|
await state.promise;
|
|
|
|
const manualResult = indexAllSessions(dbManager, sessionsDir);
|
|
assert.equal(manualResult.sessionsProcessed, 1);
|
|
assert.equal(manualResult.sessionsSkipped, 1);
|
|
assert.equal(manualResult.sessionsIndexed, 0);
|
|
});
|
|
|
|
it('does not mark backfill complete when startup parse limit is reached', async () => {
|
|
const state: SessionBackfillState = { inProgress: false, promise: null };
|
|
let touched = false;
|
|
const notifications: { message: string; level: string }[] = [];
|
|
|
|
const scheduled = scheduleSessionBackfill(dbManager, sessionsDir, {
|
|
state,
|
|
needsBackfillFn: () => true,
|
|
indexSessionsFn: () => ({
|
|
sessionsProcessed: 1,
|
|
sessionsIndexed: 1,
|
|
sessionsSkipped: 0,
|
|
messagesIndexed: 1,
|
|
errors: [],
|
|
reachedLimit: true,
|
|
}),
|
|
touchBackfillTimestampFn: () => { touched = true; },
|
|
notify: (message, level) => notifications.push({ message, level }),
|
|
setTimeoutFn: (callback) => {
|
|
queueMicrotask(callback);
|
|
return 0;
|
|
},
|
|
});
|
|
|
|
assert.equal(scheduled, true);
|
|
await state.promise;
|
|
assert.equal(touched, false);
|
|
assert.equal(notifications[0].level, 'warning');
|
|
assert.match(notifications[0].message, /startup limit reached/);
|
|
});
|
|
|
|
it('scheduled task is best-effort and does not reject when indexing throws', async () => {
|
|
const state: SessionBackfillState = { inProgress: false, promise: null };
|
|
const notifications: { message: string; level: string }[] = [];
|
|
|
|
const scheduled = scheduleSessionBackfill(dbManager, sessionsDir, {
|
|
state,
|
|
needsBackfillFn: () => true,
|
|
indexSessionsFn: () => {
|
|
throw new Error('boom');
|
|
},
|
|
notify: (message, level) => notifications.push({ message, level }),
|
|
setTimeoutFn: (callback) => {
|
|
queueMicrotask(callback);
|
|
return 0;
|
|
},
|
|
});
|
|
|
|
assert.equal(scheduled, true);
|
|
await state.promise;
|
|
assert.equal(state.inProgress, false);
|
|
assert.match(notifications[0].message, /Session backfill failed: boom/);
|
|
assert.equal(notifications[0].level, 'warning');
|
|
});
|
|
|
|
it('shutdown wait resolves true when an in-progress backfill completes before timeout', async () => {
|
|
let resolveBackfill!: () => void;
|
|
const state: SessionBackfillState = {
|
|
inProgress: true,
|
|
promise: new Promise<void>((resolve) => {
|
|
resolveBackfill = resolve;
|
|
}),
|
|
};
|
|
|
|
setTimeout(resolveBackfill, 5);
|
|
const completed = await waitForSessionBackfill(100, state);
|
|
|
|
assert.equal(completed, true);
|
|
});
|
|
|
|
it('shutdown wait resolves false when an in-progress backfill exceeds timeout', async () => {
|
|
const state: SessionBackfillState = {
|
|
inProgress: true,
|
|
promise: new Promise<void>(() => {}),
|
|
};
|
|
|
|
const completed = await waitForSessionBackfill(5, state);
|
|
|
|
assert.equal(completed, false);
|
|
});
|
|
|
|
it('first-use join does not inherit the five-second shutdown deadline', async (t) => {
|
|
t.mock.timers.enable({ apis: ['setTimeout'] });
|
|
const backfill = Promise.withResolvers<void>();
|
|
const state: SessionBackfillState = { inProgress: true, promise: backfill.promise };
|
|
let joined = false;
|
|
const firstUse = joinSessionBackfill(state).then(() => { joined = true; });
|
|
const shutdown = waitForSessionBackfill(5000, state);
|
|
t.mock.timers.tick(6000);
|
|
assert.equal(await shutdown, false);
|
|
assert.equal(joined, false);
|
|
backfill.resolve();
|
|
await firstUse;
|
|
assert.equal(joined, true);
|
|
});
|
|
|
|
it('propagates the retention cutoff to the backfill file set', async () => {
|
|
// Only an expired file exists (mtime older than the 30-day cutoff). Even
|
|
// with recent backfill timestamps, the backfill must not be scheduled
|
|
// because the file is outside the retained window.
|
|
writeJsonlSession(sessionsDir, 'project-a', 'old');
|
|
const oldFile = path.join(sessionsDir, 'project-a', 'old.jsonl');
|
|
const cutoffMs = Date.now() - 30 * 24 * 60 * 60 * 1000;
|
|
const oldMtime = cutoffMs - 1000;
|
|
fs.utimesSync(oldFile, new Date(oldMtime), new Date(oldMtime));
|
|
|
|
touchBackfillTimestamp(dbManager);
|
|
|
|
let receivedCutoff: number | undefined;
|
|
let indexOptions: { retentionCutoffMs?: number } | undefined;
|
|
const state: SessionBackfillState = { inProgress: false, promise: null };
|
|
|
|
const scheduled = scheduleSessionBackfill(dbManager, sessionsDir, {
|
|
state,
|
|
retentionCutoffMs: cutoffMs,
|
|
setTimeoutFn: () => 0,
|
|
// Capture the cutoff that would be forwarded to the store so we prove
|
|
// the wiring reaches both the eligibility check and the indexer.
|
|
needsBackfillFn: (_db, _dir, _now, cutoff) => {
|
|
receivedCutoff = cutoff;
|
|
return false;
|
|
},
|
|
indexSessionsFn: (_db, _dir, options) => {
|
|
indexOptions = options;
|
|
return { sessionsProcessed: 0, sessionsIndexed: 0, sessionsSkipped: 0, messagesIndexed: 0, reachedLimit: undefined, errors: [] };
|
|
},
|
|
});
|
|
|
|
// needsBackfill ran and received the cutoff; because it returned false for
|
|
// an expired file set, no backfill pass was scheduled.
|
|
assert.equal(scheduled, false);
|
|
assert.equal(receivedCutoff, cutoffMs);
|
|
assert.equal(indexOptions, undefined);
|
|
});
|
|
});
|