mirror of
https://github.com/Kaelio/ktx.git
synced 2026-06-10 08:05:14 +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
|
|
@ -360,7 +360,7 @@ storage:
|
|||
|-------|------|---------|---------|
|
||||
| `state` | `sqlite` \| `postgres` | `sqlite` | Backend for ktx state. `sqlite` uses `.ktx/db.sqlite`; `postgres` expects a configured Postgres connection. |
|
||||
| `search` | `sqlite-fts5` \| `postgres-hybrid` | `sqlite-fts5` | Backend for search indexes. `postgres-hybrid` combines lexical and vector search in Postgres. |
|
||||
| `git.auto_commit` | `boolean` | `true` | When `true`, ktx auto-commits changes to the git-backed state store. |
|
||||
| `git.auto_commit` | `boolean` | `true` | When `true`, a context-source ingest run commits its changes to the git-backed state store. When `false`, the changes are applied to the working tree and left staged for you to commit. |
|
||||
| `git.author` | `string` | `ktx <ktx@example.com>` | Git author identity for auto-commits. Standard `Name <email>` form. |
|
||||
|
||||
## `llm`
|
||||
|
|
@ -619,7 +619,7 @@ memory:
|
|||
|
||||
| Field | Type | Default | Purpose |
|
||||
|-------|------|---------|---------|
|
||||
| `auto_commit` | `boolean` | `true` | When `true`, ktx auto-commits memory updates to the git-backed store. |
|
||||
| `auto_commit` | `boolean` | `true` | When `true`, a memory/wiki ingest run commits its updates to the git-backed store. When `false`, the updates are applied to the working tree and left staged for you to commit. |
|
||||
|
||||
## A full example
|
||||
|
||||
|
|
|
|||
|
|
@ -31,6 +31,10 @@ export type SquashMergeResult =
|
|||
| { ok: true; squashSha: string; touchedPaths: string[] }
|
||||
| { ok: false; conflict: true; conflictPaths: string[] };
|
||||
|
||||
export type StageSquashResult =
|
||||
| { ok: true; touchedPaths: string[]; stagedTree: string }
|
||||
| { ok: false; conflict: true; conflictPaths: string[] };
|
||||
|
||||
function mergeErrorMessage(error: unknown): string {
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
|
|
@ -681,6 +685,53 @@ export class GitService {
|
|||
authorEmail: string,
|
||||
commitMessage: string,
|
||||
): Promise<SquashMergeResult> {
|
||||
const applied = await this.applySquashToIndex(branch);
|
||||
if (!applied.ok) {
|
||||
return applied;
|
||||
}
|
||||
if (applied.touchedPaths.length === 0) {
|
||||
const head = (await this.git.revparse(['HEAD'])).trim();
|
||||
return { ok: true, squashSha: head, touchedPaths: [] };
|
||||
}
|
||||
|
||||
await this.git.commit(commitMessage, { '--author': `${author} <${authorEmail}>` });
|
||||
const squashSha = (await this.git.revparse(['HEAD'])).trim();
|
||||
return { ok: true, squashSha, touchedPaths: applied.touchedPaths };
|
||||
}
|
||||
|
||||
/**
|
||||
* Like {@link squashMergeIntoMain} but stops before committing: applies `branch` onto the
|
||||
* current branch's index + working tree and leaves the result staged for the user to commit.
|
||||
* Returns the staged tree's SHA, which is a valid diff/read ref (`git diff A..<tree>`,
|
||||
* `git show <tree>:path`) so callers can reconcile derived indexes without a commit.
|
||||
*
|
||||
* This backs the `auto_commit: false` ingest path: changes still reach the working tree (so
|
||||
* the run is not silently discarded), they are just not committed automatically.
|
||||
*
|
||||
* Caller must hold the `config:repo` lock, as with {@link squashMergeIntoMain}.
|
||||
*/
|
||||
async stageSquashMergeIntoMain(branch: string): Promise<StageSquashResult> {
|
||||
return this.withMutationQueue(() => this.stageSquashMergeIntoMainUnlocked(branch));
|
||||
}
|
||||
|
||||
private async stageSquashMergeIntoMainUnlocked(branch: string): Promise<StageSquashResult> {
|
||||
const applied = await this.applySquashToIndex(branch);
|
||||
if (!applied.ok) {
|
||||
return applied;
|
||||
}
|
||||
const stagedTree = (await this.git.raw(['write-tree'])).trim();
|
||||
return { ok: true, touchedPaths: applied.touchedPaths, stagedTree };
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared core of the squash-merge paths: applies `branch` onto the current branch's index +
|
||||
* working tree via `git merge --squash` WITHOUT committing, leaving the caller to either
|
||||
* commit (auto-commit on) or stage (auto-commit off). Returns the touched paths, or conflict
|
||||
* info after restoring a clean tree.
|
||||
*/
|
||||
private async applySquashToIndex(
|
||||
branch: string,
|
||||
): Promise<{ ok: true; touchedPaths: string[] } | { ok: false; conflict: true; conflictPaths: string[] }> {
|
||||
// Diff of HEAD..branch (two dots) lists commits/files reachable from `branch` that
|
||||
// aren't on HEAD — i.e. exactly what the squash would apply. Three dots (HEAD...branch)
|
||||
// is symmetric difference and would mis-classify cases where main moved ahead.
|
||||
|
|
@ -690,8 +741,7 @@ export class GitService {
|
|||
.map((l) => l.trim())
|
||||
.filter(Boolean);
|
||||
if (touchedPaths.length === 0) {
|
||||
const head = (await this.git.revparse(['HEAD'])).trim();
|
||||
return { ok: true, squashSha: head, touchedPaths: [] };
|
||||
return { ok: true, touchedPaths: [] };
|
||||
}
|
||||
|
||||
// `git merge --squash` may NOT throw on a textual conflict — it stages the clean
|
||||
|
|
@ -724,9 +774,7 @@ export class GitService {
|
|||
return { ok: false, conflict: true, conflictPaths };
|
||||
}
|
||||
|
||||
await this.git.commit(commitMessage, { '--author': `${author} <${authorEmail}>` });
|
||||
const squashSha = (await this.git.revparse(['HEAD'])).trim();
|
||||
return { ok: true, squashSha, touchedPaths };
|
||||
return { ok: true, touchedPaths };
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -2677,29 +2677,51 @@ export class IngestBundleRunner {
|
|||
throw error;
|
||||
}
|
||||
const commitMessage = this.buildCommitMessage(job, syncId, diffSummary, failedWorkUnits);
|
||||
// With auto-commit disabled, apply the run onto main's working tree and leave it staged
|
||||
// rather than committing. The wiki index is reconciled from the staged tree (a valid
|
||||
// diff/read ref), so search stays consistent with the staged files; only the git commit
|
||||
// and its message-enhancement job are skipped.
|
||||
const autoCommit = this.deps.storage.autoCommit;
|
||||
const squashResult = await this.deps.lockingService.withLock('config:repo', async () => {
|
||||
const preSquashSha = await this.deps.gitService.revParseHead();
|
||||
const merge = await this.deps.gitService.squashMergeIntoMain(
|
||||
sessionWorktree.branch,
|
||||
this.deps.storage.systemGitAuthor.name,
|
||||
this.deps.storage.systemGitAuthor.email,
|
||||
commitMessage,
|
||||
);
|
||||
return { preSquashSha, merge };
|
||||
if (autoCommit) {
|
||||
const merge = await this.deps.gitService.squashMergeIntoMain(
|
||||
sessionWorktree.branch,
|
||||
this.deps.storage.systemGitAuthor.name,
|
||||
this.deps.storage.systemGitAuthor.email,
|
||||
commitMessage,
|
||||
);
|
||||
return { preSquashSha, committed: true as const, merge };
|
||||
}
|
||||
const merge = await this.deps.gitService.stageSquashMergeIntoMain(sessionWorktree.branch);
|
||||
return { preSquashSha, committed: false as const, merge };
|
||||
});
|
||||
const mergeResult = squashResult.merge;
|
||||
if (!mergeResult.ok) {
|
||||
if (!squashResult.merge.ok) {
|
||||
await this.deps.runs.markFailed(runRow.id);
|
||||
throw new Error(`squash merge conflict: ${mergeResult.conflictPaths.join(', ')}`);
|
||||
throw new Error(`squash merge conflict: ${squashResult.merge.conflictPaths.join(', ')}`);
|
||||
}
|
||||
const touchedPaths = squashResult.merge.touchedPaths;
|
||||
const hasChanges = touchedPaths.length > 0;
|
||||
// `syncRef` is the tree-ish to diff/read when reconciling the wiki index: the new commit
|
||||
// SHA when committed, the staged tree SHA when staging. `commitSha` is only set when an
|
||||
// actual commit was created (it surfaces in the report and progress UI).
|
||||
let commitSha: string | null = null;
|
||||
let syncRef: string | null = null;
|
||||
if (hasChanges) {
|
||||
if (squashResult.committed) {
|
||||
commitSha = squashResult.merge.squashSha;
|
||||
syncRef = commitSha;
|
||||
} else {
|
||||
syncRef = squashResult.merge.stagedTree;
|
||||
}
|
||||
}
|
||||
const commitSha = mergeResult.touchedPaths.length === 0 ? null : mergeResult.squashSha;
|
||||
await runTrace.event(
|
||||
'debug',
|
||||
'squash',
|
||||
'squash_finished',
|
||||
{
|
||||
commitSha,
|
||||
touchedPaths: mergeResult.touchedPaths,
|
||||
touchedPaths,
|
||||
},
|
||||
undefined,
|
||||
Date.now() - squashStartedAt,
|
||||
|
|
@ -2714,18 +2736,28 @@ export class IngestBundleRunner {
|
|||
wikiCount: countMemoryFlowActions(memoryFlowSavedActions, 'wiki'),
|
||||
slCount: countMemoryFlowActions(memoryFlowSavedActions, 'sl'),
|
||||
});
|
||||
await stage6?.updateProgress(1.0, commitSha ? `Saved changes (${commitSha.slice(0, 8)})` : 'No changes to save');
|
||||
await stage6?.updateProgress(
|
||||
1.0,
|
||||
commitSha
|
||||
? `Saved changes (${commitSha.slice(0, 8)})`
|
||||
: hasChanges
|
||||
? 'Staged changes (auto-commit disabled)'
|
||||
: 'No changes to save',
|
||||
);
|
||||
|
||||
// Sync the shared `knowledge` index from the squashed diff in a single
|
||||
// transaction. If this throws, the run fails and no partial index state
|
||||
// survives (thanks to the transactional upsert in applyDiffTransactional).
|
||||
if (commitSha) {
|
||||
// `syncRef` is the new commit when committed, or the staged tree when staging.
|
||||
if (syncRef) {
|
||||
const indexSyncStartedAt = Date.now();
|
||||
// Multi-file squash → omit path so the handler diffs the whole commit
|
||||
// (a comma-joined pathspec would match nothing and the job would no-op).
|
||||
const pathFilter = mergeResult.touchedPaths.length === 1 ? mergeResult.touchedPaths[0] : '';
|
||||
await this.deps.commitMessages.enqueueForExternalCommit({ commitHash: commitSha }, commitMessage, pathFilter);
|
||||
await this.deps.wikiService.syncFromCommit(squashResult.preSquashSha, commitSha, runRow.id);
|
||||
const pathFilter = touchedPaths.length === 1 ? touchedPaths[0] : '';
|
||||
if (squashResult.committed) {
|
||||
await this.deps.commitMessages.enqueueForExternalCommit({ commitHash: syncRef }, commitMessage, pathFilter);
|
||||
}
|
||||
await this.deps.wikiService.syncFromCommit(squashResult.preSquashSha, syncRef, runRow.id);
|
||||
await this.syncKnowledgeSlRefsFromActions(job.connectionId, memoryFlowSavedActions);
|
||||
const touchedConnections = [
|
||||
...new Set(
|
||||
|
|
|
|||
|
|
@ -129,6 +129,10 @@ class LocalIngestStorage implements IngestStoragePort {
|
|||
this.homeDir = join(project.projectDir, '.ktx');
|
||||
}
|
||||
|
||||
get autoCommit(): boolean {
|
||||
return this.project.config.storage.git.auto_commit;
|
||||
}
|
||||
|
||||
resolveUploadDir(uploadId: string): string {
|
||||
return join(this.project.projectDir, '.ktx/cache/local-ingest', uploadId, 'upload');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -159,6 +159,11 @@ interface IngestGitAuthor {
|
|||
export interface IngestStoragePort {
|
||||
homeDir: string;
|
||||
systemGitAuthor: IngestGitAuthor;
|
||||
/**
|
||||
* Mirror of config `storage.git.auto_commit`. When false, an ingest run applies its squash
|
||||
* onto the project's working tree and leaves it staged instead of committing it.
|
||||
*/
|
||||
autoCommit: boolean;
|
||||
resolveUploadDir(uploadId: string): string;
|
||||
resolvePullDir(jobId: string): string;
|
||||
resolveTranscriptDir(jobId: string): string;
|
||||
|
|
|
|||
|
|
@ -116,6 +116,7 @@ export function createLocalProjectMemoryIngest(
|
|||
knowledge: { userScopedKnowledgeEnabled: false },
|
||||
slValidation: { probeRowCount: 0 },
|
||||
llm: { memoryIngestionModel: project.config.llm.models.default ?? 'local-memory-model' },
|
||||
autoCommit: project.config.memory.auto_commit,
|
||||
},
|
||||
promptService: new PromptService({ promptsDir, partials: [] }),
|
||||
skillsRegistry: new SkillsRegistryService({ skillsDir }),
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { join } from 'node:path';
|
|||
import * as YAML from 'yaml';
|
||||
import { z } from 'zod';
|
||||
import { type KtxLogger, noopLogger } from '../../context/core/config.js';
|
||||
import type { SquashMergeResult, StageSquashResult } from '../../context/core/git.service.js';
|
||||
import type { KtxRuntimeToolSet } from '../../context/llm/runtime-port.js';
|
||||
import { revertSourceToPreHead, type SlValidationDeps } from '../../context/sl/tools/sl-warehouse-validation.js';
|
||||
import type { SemanticLayerSource } from '../../context/sl/types.js';
|
||||
|
|
@ -253,13 +254,20 @@ export class MemoryAgentService {
|
|||
reconciledCrossRefs,
|
||||
gateRevertedSources,
|
||||
);
|
||||
const mergeResult = await this.deps.lockingService.withLock('config:repo', () =>
|
||||
this.deps.gitService.squashMergeIntoMain(
|
||||
sessionWorktree.branch,
|
||||
SYSTEM_GIT_AUTHOR.name,
|
||||
SYSTEM_GIT_AUTHOR.email,
|
||||
squashMessage,
|
||||
),
|
||||
// With auto-commit disabled, apply the session to main's working tree and leave it
|
||||
// staged rather than committing. The knowledge/SL DB is written eagerly during the
|
||||
// session (and rolled back on conflict below), so search stays consistent with the
|
||||
// staged files; we only skip the git commit and its message-enhancement job.
|
||||
const autoCommit = this.deps.settings.autoCommit;
|
||||
const mergeResult = await this.deps.lockingService.withLock<SquashMergeResult | StageSquashResult>('config:repo', () =>
|
||||
autoCommit
|
||||
? this.deps.gitService.squashMergeIntoMain(
|
||||
sessionWorktree.branch,
|
||||
SYSTEM_GIT_AUTHOR.name,
|
||||
SYSTEM_GIT_AUTHOR.email,
|
||||
squashMessage,
|
||||
)
|
||||
: this.deps.gitService.stageSquashMergeIntoMain(sessionWorktree.branch),
|
||||
);
|
||||
|
||||
if (!mergeResult.ok) {
|
||||
|
|
@ -269,17 +277,19 @@ export class MemoryAgentService {
|
|||
} else if (mergeResult.touchedPaths.length === 0) {
|
||||
sessionOutcome = 'empty';
|
||||
} else {
|
||||
squashSha = mergeResult.squashSha;
|
||||
touchedPaths = mergeResult.touchedPaths;
|
||||
// Single-file commits: pass the path so the handler diff is path-scoped.
|
||||
// Multi-file commits: omit path so the handler grabs the full commit diff
|
||||
// (a comma-joined pathspec would match nothing).
|
||||
const pathFilter = touchedPaths.length === 1 ? touchedPaths[0] : '';
|
||||
await this.deps.rootFileStore.enqueueCommitMessageJobForExternalCommit(
|
||||
{ commitHash: squashSha },
|
||||
squashMessage,
|
||||
pathFilter,
|
||||
);
|
||||
if ('squashSha' in mergeResult) {
|
||||
squashSha = mergeResult.squashSha;
|
||||
// Single-file commits: pass the path so the handler diff is path-scoped.
|
||||
// Multi-file commits: omit path so the handler grabs the full commit diff
|
||||
// (a comma-joined pathspec would match nothing).
|
||||
const pathFilter = touchedPaths.length === 1 ? touchedPaths[0] : '';
|
||||
await this.deps.rootFileStore.enqueueCommitMessageJobForExternalCommit(
|
||||
{ commitHash: squashSha },
|
||||
squashMessage,
|
||||
pathFilter,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
sessionCrashed = true;
|
||||
|
|
|
|||
|
|
@ -74,6 +74,11 @@ interface MemoryAgentSettings {
|
|||
llm: {
|
||||
memoryIngestionModel: string;
|
||||
};
|
||||
/**
|
||||
* When false (config `memory.auto_commit: false`), a completed session is applied to the
|
||||
* project's working tree and left staged instead of committed, so the user commits it.
|
||||
*/
|
||||
autoCommit: boolean;
|
||||
}
|
||||
|
||||
interface MemoryTelemetryPort {
|
||||
|
|
|
|||
|
|
@ -230,7 +230,12 @@ const setupSchema = z
|
|||
|
||||
const storageGitSchema = z
|
||||
.strictObject({
|
||||
auto_commit: z.boolean().default(true).describe('When true, KTX automatically commits state changes to the local Git-backed store.'),
|
||||
auto_commit: z
|
||||
.boolean()
|
||||
.default(true)
|
||||
.describe(
|
||||
'When true, a context-source ingest run (`ktx ingest <connection>`) commits its changes to the local Git-backed store. When false, the changes are applied to the working tree and left staged for you to commit.',
|
||||
),
|
||||
author: z
|
||||
.string()
|
||||
.min(1)
|
||||
|
|
@ -278,7 +283,12 @@ const agentSchema = z
|
|||
|
||||
const memorySchema = z
|
||||
.strictObject({
|
||||
auto_commit: z.boolean().default(true).describe('When true, KTX automatically commits memory updates to the Git-backed store.'),
|
||||
auto_commit: z
|
||||
.boolean()
|
||||
.default(true)
|
||||
.describe(
|
||||
'When true, a memory/wiki ingest run commits its updates to the Git-backed store. When false, the updates are applied to the working tree and left staged for you to commit.',
|
||||
),
|
||||
})
|
||||
.describe('Memory subsystem configuration.');
|
||||
|
||||
|
|
|
|||
|
|
@ -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