mirror of
https://github.com/Kaelio/ktx.git
synced 2026-06-25 08:48:08 +02:00
fix(ingest): honor storage.git.auto_commit and memory.auto_commit
Both documented flags were read only for status display; every ingest path squash-committed to main unconditionally, so setting either to false was a silent no-op (the reported symptom: 'Memory ingest (external_ingest): ...' commits despite memory.auto_commit: false). Gate the commit at the squash-merge onto main — the one point where ingest work becomes a permanent commit (intermediate session-worktree commits must still happen for the squash to collapse). When auto-commit is off, apply the squash to main's working tree and leave it staged instead of committing, so the run is never silently discarded: - GitService.stageSquashMergeIntoMain: shares the merge core with squashMergeIntoMain but stops before committing and returns the staged tree SHA (a valid diff/read ref). - memory.auto_commit gates MemoryAgentService (its DB writes are eager, so the staged files stay consistent); the commit-message job is skipped. - storage.git.auto_commit gates IngestBundleRunner; the wiki index is reconciled from the staged tree via the existing syncFromCommit (git diff/show accept a write-tree ref), and SL reindex already reads from files. Config descriptions now state precisely what each flag gates and the staged semantics when false.
This commit is contained in:
parent
1a6da14f62
commit
a02fcab487
15 changed files with 303 additions and 43 deletions
|
|
@ -400,6 +400,66 @@ describe('GitService', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('stageSquashMergeIntoMain', () => {
|
||||
it('applies the branch to main without committing, leaving the changes staged', async () => {
|
||||
const { commitHash: baseSha } = await writeAndCommit('seed.md', 'seed');
|
||||
const parent = await realpath(join(tempDir, '..'));
|
||||
const wtDir = join(parent, `wt-${Date.now()}-stage`);
|
||||
await service.addWorktree(wtDir, 'session/stage', baseSha);
|
||||
|
||||
const scoped = service.forWorktree(wtDir);
|
||||
await writeFile(join(wtDir, 'a.yaml'), 'one: 1\n', 'utf-8');
|
||||
await scoped.commitFile('a.yaml', 'wip a', 'System User', 'system@example.com');
|
||||
|
||||
const result = await service.stageSquashMergeIntoMain('session/stage');
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) {
|
||||
throw new Error('unreachable');
|
||||
}
|
||||
expect(result.touchedPaths).toEqual(['a.yaml']);
|
||||
expect(result.stagedTree).toMatch(/^[0-9a-f]{40}$/);
|
||||
|
||||
// HEAD did not advance: no commit was created.
|
||||
expect(await service.revParseHead()).toBe(baseSha);
|
||||
// The change is in main's working tree...
|
||||
await expect(readFile(join(tempDir, 'a.yaml'), 'utf-8')).resolves.toBe('one: 1\n');
|
||||
// ...and staged in the index for the user to commit.
|
||||
const stagedNames = await createSimpleGit(tempDir).raw(['diff', '--cached', '--name-only']);
|
||||
expect(stagedNames).toContain('a.yaml');
|
||||
// The staged tree is usable as a diff/read ref for DB sync.
|
||||
const treeListing = await createSimpleGit(tempDir).raw(['ls-tree', '-r', '--name-only', result.stagedTree]);
|
||||
expect(treeListing).toContain('a.yaml');
|
||||
|
||||
await service.removeWorktree(wtDir).catch(() => undefined);
|
||||
await rm(wtDir, { recursive: true, force: true }).catch(() => undefined);
|
||||
});
|
||||
|
||||
it('reports conflicts without committing or mutating main', async () => {
|
||||
const { commitHash: baseSha } = await writeAndCommit('conflict.md', 'base\n');
|
||||
const parent = await realpath(join(tempDir, '..'));
|
||||
const wtDir = join(parent, `wt-${Date.now()}-stage-conflict`);
|
||||
await service.addWorktree(wtDir, 'session/stage-conflict', baseSha);
|
||||
const scoped = service.forWorktree(wtDir);
|
||||
await writeFile(join(wtDir, 'conflict.md'), 'from-branch\n', 'utf-8');
|
||||
await scoped.commitFile('conflict.md', 'branch edit', 'System User', 'system@example.com');
|
||||
|
||||
// Move main ahead with a conflicting change.
|
||||
await writeAndCommit('conflict.md', 'from-main\n');
|
||||
const mainHead = await service.revParseHead();
|
||||
|
||||
const result = await service.stageSquashMergeIntoMain('session/stage-conflict');
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) {
|
||||
throw new Error('unreachable');
|
||||
}
|
||||
expect(result.conflictPaths).toContain('conflict.md');
|
||||
expect(await service.revParseHead()).toBe(mainHead);
|
||||
|
||||
await service.removeWorktree(wtDir).catch(() => undefined);
|
||||
await rm(wtDir, { recursive: true, force: true }).catch(() => undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe('squashMergeIntoMain', () => {
|
||||
it('merges a session branch as one commit on main, returning the new SHA + touched paths', async () => {
|
||||
const { commitHash: baseSha } = await writeAndCommit('seed.md', 'seed');
|
||||
|
|
|
|||
|
|
@ -199,6 +199,7 @@ function makeDeps(
|
|||
storage: {
|
||||
homeDir: join(runtime.configDir, '.ktx'),
|
||||
systemGitAuthor: { name: 'KTX Test', email: 'system@ktx.local' },
|
||||
autoCommit: true,
|
||||
resolveUploadDir: (id) => join(runtime.homeDir, 'upload', id),
|
||||
resolvePullDir: (id) => join(runtime.homeDir, 'pull', id),
|
||||
resolveTranscriptDir: (id) => join(runtime.configDir, '.ktx/ingest-transcripts', id),
|
||||
|
|
|
|||
|
|
@ -262,6 +262,7 @@ const buildRunner = (deps: ReturnType<typeof makeDeps> = makeDeps(), overrides:
|
|||
storage: {
|
||||
homeDir: '/tmp/ktx-test',
|
||||
systemGitAuthor: { name: 'KTX Test', email: 'system@ktx.local' },
|
||||
autoCommit: true,
|
||||
resolveUploadDir: (uploadId) => `/tmp/ktx-test/ingest-uploads/${uploadId}`,
|
||||
resolvePullDir: (jobId) => `/tmp/ktx-test/ingest-pulls/${jobId}`,
|
||||
resolveTranscriptDir: (jobId) => `/tmp/ktx-test/run/wu-transcripts/${jobId}`,
|
||||
|
|
@ -1519,6 +1520,7 @@ describe('IngestBundleRunner — Stages 1 → 7', () => {
|
|||
storage: {
|
||||
homeDir: tempRoot,
|
||||
systemGitAuthor: { name: 'KTX Test', email: 'system@ktx.local' },
|
||||
autoCommit: true,
|
||||
resolveUploadDir: (uploadId: string) => join(tempRoot, 'ingest-uploads', uploadId),
|
||||
resolvePullDir: (jobId: string) => join(tempRoot, 'ingest-pulls', jobId),
|
||||
resolveTranscriptDir: (jobId: string) => join(tempRoot, 'run', 'wu-transcripts', jobId),
|
||||
|
|
|
|||
|
|
@ -424,6 +424,60 @@ describe('canonical local ingest', () => {
|
|||
}
|
||||
});
|
||||
|
||||
it('with auto_commit disabled, stages ingest changes and indexes the wiki without committing', async () => {
|
||||
const projectDir = join(tempDir, 'no-autocommit-project');
|
||||
await initKtxProject({ projectDir });
|
||||
await writeFile(
|
||||
join(projectDir, 'ktx.yaml'),
|
||||
[
|
||||
'connections:',
|
||||
' warehouse:',
|
||||
' driver: postgres',
|
||||
'ingest:',
|
||||
' adapters:',
|
||||
' - fake',
|
||||
' embeddings:',
|
||||
' backend: none',
|
||||
'storage:',
|
||||
' git:',
|
||||
' auto_commit: false',
|
||||
'',
|
||||
].join('\n'),
|
||||
'utf-8',
|
||||
);
|
||||
const stagedProject = await loadKtxProject({ projectDir });
|
||||
const preHead = await stagedProject.git.revParseHead();
|
||||
|
||||
const sourceDir = join(tempDir, 'no-autocommit-source');
|
||||
await mkdir(join(sourceDir, 'orders'), { recursive: true });
|
||||
await writeFile(join(sourceDir, 'orders', 'orders.json'), '{"name":"orders"}\n', 'utf-8');
|
||||
|
||||
const result = await runLocalIngest({
|
||||
project: stagedProject,
|
||||
adapters: [new FakeSourceAdapter()],
|
||||
adapter: 'fake',
|
||||
connectionId: 'warehouse',
|
||||
sourceDir,
|
||||
jobId: 'wiki-staged-1',
|
||||
agentRunner: new WikiWritingAgentRunner(),
|
||||
});
|
||||
|
||||
expect(result.result.failedWorkUnits).toEqual([]);
|
||||
// No commit was created: HEAD is unchanged.
|
||||
expect(await stagedProject.git.revParseHead()).toBe(preHead);
|
||||
// ...yet the wiki page is on disk (staged) and indexed for search, reconciled from the
|
||||
// staged tree rather than a commit.
|
||||
await expect(readFile(join(projectDir, 'wiki', 'global', 'orders_context.md'), 'utf-8')).resolves.toContain('Orders');
|
||||
const db = new Database(join(projectDir, '.ktx', 'db.sqlite'), { readonly: true });
|
||||
try {
|
||||
expect(db.prepare('SELECT key, summary FROM knowledge_pages ORDER BY key').all()).toEqual([
|
||||
{ key: 'orders_context', summary: 'Orders source context' },
|
||||
]);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('does not persist noop embedding vectors when local embeddings are disabled', async () => {
|
||||
await writeFile(
|
||||
join(project.projectDir, 'ktx.yaml'),
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ interface BuiltMocks {
|
|||
slValidator: any;
|
||||
toolsetFactory: any;
|
||||
logger: any;
|
||||
autoCommit: boolean;
|
||||
}
|
||||
|
||||
const buildMocks = (overrides: Partial<BuiltMocks> = {}): BuiltMocks => {
|
||||
|
|
@ -111,6 +112,9 @@ const buildMocks = (overrides: Partial<BuiltMocks> = {}): BuiltMocks => {
|
|||
gitService: {
|
||||
revParseHead: vi.fn().mockResolvedValue('basesha'),
|
||||
squashMergeIntoMain: vi.fn().mockResolvedValue({ ok: true, squashSha: 'cafebabe', touchedPaths: ['a.yaml'] }),
|
||||
stageSquashMergeIntoMain: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ ok: true, touchedPaths: ['a.yaml'], stagedTree: 'deadbeeftree' }),
|
||||
},
|
||||
lockingService: {
|
||||
withLock: vi.fn().mockImplementation((_key: string, fn: () => Promise<unknown>) => fn()),
|
||||
|
|
@ -134,6 +138,7 @@ const buildMocks = (overrides: Partial<BuiltMocks> = {}): BuiltMocks => {
|
|||
}),
|
||||
},
|
||||
logger: { log: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
|
||||
autoCommit: true,
|
||||
};
|
||||
|
||||
return { ...defaults, ...overrides };
|
||||
|
|
@ -151,6 +156,7 @@ const buildService = (mocks: BuiltMocks): MemoryAgentService =>
|
|||
llm: {
|
||||
memoryIngestionModel: mocks.appSettings.settings.llm.memoryIngestionModel,
|
||||
},
|
||||
autoCommit: mocks.autoCommit,
|
||||
},
|
||||
promptService: mocks.prompt,
|
||||
skillsRegistry: mocks.skillsRegistry,
|
||||
|
|
@ -242,6 +248,26 @@ describe('MemoryAgentService.ingest — session-branch orchestration', () => {
|
|||
expect(result.commitHash).toBe('cafebabe');
|
||||
});
|
||||
|
||||
it('with auto_commit disabled, stages the session on main without committing or enqueuing a note', async () => {
|
||||
const mocks = buildMocks({ autoCommit: false });
|
||||
const svc = buildService(mocks);
|
||||
|
||||
const result = await svc.ingest(baseInput);
|
||||
|
||||
// Applied to main via the staging path, never the committing path.
|
||||
expect(mocks.gitService.stageSquashMergeIntoMain).toHaveBeenCalledWith('session/chat-1');
|
||||
expect(mocks.gitService.squashMergeIntoMain).not.toHaveBeenCalled();
|
||||
// No commit means no commit-message enhancement job.
|
||||
expect(mocks.configService.enqueueCommitMessageJobForExternalCommit).not.toHaveBeenCalled();
|
||||
// The session still applied successfully; there is just no commit hash.
|
||||
expect(result.commitHash).toBeNull();
|
||||
expect(mocks.sessionWorktreeService.cleanup).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ chatId: 'chat-1' }),
|
||||
'success',
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it('normalizes load_skill output to markdown while preserving structured payload', async () => {
|
||||
const tempDir = await mkdtemp(join(tmpdir(), 'ktx-memory-skill-'));
|
||||
const skillDir = join(tempDir, 'memory_agent');
|
||||
|
|
|
|||
|
|
@ -193,6 +193,7 @@ describe('MemoryAgentService.reconcileCrossRefs', () => {
|
|||
knowledge: { userScopedKnowledgeEnabled: false },
|
||||
slValidation: { probeRowCount: 1 },
|
||||
llm: { memoryIngestionModel: 'test-model' },
|
||||
autoCommit: true,
|
||||
},
|
||||
promptService: undefined as never,
|
||||
skillsRegistry: undefined as never,
|
||||
|
|
@ -369,6 +370,7 @@ describe('MemoryAgentService.gateRevertInvalidSources (J3)', () => {
|
|||
knowledge: { userScopedKnowledgeEnabled: false },
|
||||
slValidation: { probeRowCount: 1 },
|
||||
llm: { memoryIngestionModel: 'test-model' },
|
||||
autoCommit: true,
|
||||
},
|
||||
promptService: undefined as never,
|
||||
skillsRegistry: undefined as never,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue