Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 23 additions & 9 deletions src/services/infrastructure/WorktreeAdoption.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { existsSync } from 'fs';
import { spawnSync } from 'child_process';
import { logger } from '../../utils/logger.js';
import { getProjectContext } from '../../utils/project-name.js';
import { ChromaSync } from '../sync/ChromaSync.js';
import { ChromaSync, MergedIntoProjectTarget } from '../sync/ChromaSync.js';
import { paths } from '../../shared/paths.js';
import { openConfiguredSqliteDatabase } from '../sqlite/connection.js';

Expand Down Expand Up @@ -178,7 +178,7 @@ export async function adoptMergedWorktrees(opts: {
return result;
}

const adoptedSqliteIds: number[] = [];
const adoptedChromaTargets: MergedIntoProjectTarget[] = [];

let db: import('bun:sqlite').Database | null = null;
try {
Expand Down Expand Up @@ -207,6 +207,11 @@ export async function adoptMergedWorktrees(opts: {
WHERE project = ?
AND (merged_into_project IS NULL OR merged_into_project = ?)`
);
const selectSumForPatch = db.prepare(
`SELECT id FROM session_summaries
WHERE project = ?
AND (merged_into_project IS NULL OR merged_into_project = ?)`
);
const updateObs = db.prepare(
'UPDATE observations SET merged_into_project = ? WHERE project = ? AND merged_into_project IS NULL'
);
Expand All @@ -220,10 +225,19 @@ export async function adoptMergedWorktrees(opts: {
worktreeProject,
parentProject
) as Array<{ id: number }>;
const summaryRows = selectSumForPatch.all(
worktreeProject,
parentProject
) as Array<{ id: number }>;

const obsChanges = updateObs.run(parentProject, worktreeProject).changes;
const sumChanges = updateSum.run(parentProject, worktreeProject).changes;
for (const r of rows) adoptedSqliteIds.push(r.id);
for (const r of rows) {
adoptedChromaTargets.push({ docType: 'observation', sqliteId: r.id });
}
for (const r of summaryRows) {
adoptedChromaTargets.push({ docType: 'session_summary', sqliteId: r.id });
}
result.adoptedObservations += obsChanges;
result.adoptedSummaries += sumChanges;
};
Expand Down Expand Up @@ -264,27 +278,27 @@ export async function adoptMergedWorktrees(opts: {
db?.close();
}

if (!dryRun && adoptedSqliteIds.length > 0) {
if (!dryRun && adoptedChromaTargets.length > 0) {
const chromaSync = new ChromaSync('claude-mem');
try {
await chromaSync.updateMergedIntoProject(adoptedSqliteIds, parentProject);
result.chromaUpdates = adoptedSqliteIds.length;
await chromaSync.updateMergedIntoProject(adoptedChromaTargets, parentProject);
result.chromaUpdates = adoptedChromaTargets.length;
} catch (err) {
if (err instanceof Error) {
logger.error(
'SYSTEM',
'Worktree adoption Chroma patch failed (SQL already committed)',
{ parentProject, sqliteIdCount: adoptedSqliteIds.length },
{ parentProject, sqliteIdCount: adoptedChromaTargets.length },
err
);
} else {
logger.error(
'SYSTEM',
'Worktree adoption Chroma patch failed (SQL already committed)',
{ parentProject, sqliteIdCount: adoptedSqliteIds.length, error: String(err) }
{ parentProject, sqliteIdCount: adoptedChromaTargets.length, error: String(err) }
);
}
result.chromaFailed = adoptedSqliteIds.length;
result.chromaFailed = adoptedChromaTargets.length;
}
}

Expand Down
23 changes: 14 additions & 9 deletions src/services/sqlite/SessionStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1744,8 +1744,8 @@ export class SessionStore {
const additionalConditions: string[] = [];

if (project) {
additionalConditions.push('o.project = ?');
params.push(project);
additionalConditions.push('(o.project = ? OR o.merged_into_project = ?)');
params.push(project, project);
}

if (platformSource) {
Expand Down Expand Up @@ -2190,8 +2190,8 @@ export class SessionStore {
const additionalConditions: string[] = [];

if (project) {
additionalConditions.push('ss.project = ?');
params.push(project);
additionalConditions.push('(ss.project = ? OR ss.merged_into_project = ?)');
params.push(project, project);
}

if (platformSource) {
Expand Down Expand Up @@ -2295,13 +2295,18 @@ export class SessionStore {
prompts: any[];
} {
const normalizedPlatformSource = platformSource ? normalizePlatformSource(platformSource) : undefined;
const buildScope = (rowAlias: string, sessionAlias: string): { clause: string; params: any[] } => {
const buildScope = (rowAlias: string, sessionAlias: string, includeMergedProject: boolean = false): { clause: string; params: any[] } => {
const conditions: string[] = [];
const params: any[] = [];

if (project) {
conditions.push(`${rowAlias}.project = ?`);
params.push(project);
if (includeMergedProject) {
conditions.push(`(${rowAlias}.project = ? OR ${rowAlias}.merged_into_project = ?)`);
params.push(project, project);
} else {
conditions.push(`${rowAlias}.project = ?`);
params.push(project);
}
}

if (normalizedPlatformSource) {
Expand All @@ -2314,8 +2319,8 @@ export class SessionStore {
params
};
};
const observationScope = buildScope('o', 'src');
const summaryScope = buildScope('ss', 'src');
const observationScope = buildScope('o', 'src', true);
const summaryScope = buildScope('ss', 'src', true);
const promptScope = buildScope('s', 's');

let startEpoch: number;
Expand Down
78 changes: 47 additions & 31 deletions src/services/sync/ChromaSync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,11 @@ export interface ChromaDocument {
metadata: Record<string, string | number>;
}

export interface MergedIntoProjectTarget {
docType: 'observation' | 'session_summary';
sqliteId: number;
}

interface StoredObservation {
id: number;
memory_session_id: string;
Expand Down Expand Up @@ -986,52 +991,63 @@ export class ChromaSync {
}

async updateMergedIntoProject(
sqliteIds: number[],
targets: MergedIntoProjectTarget[],
mergedIntoProject: string
): Promise<void> {
if (sqliteIds.length === 0) return;
if (targets.length === 0) return;

await this.ensureCollectionExists();
const chromaMcp = ChromaMcpManager.getInstance();

let totalPatched = 0;

for (let i = 0; i < sqliteIds.length; i += this.BATCH_SIZE) {
const idBatch = sqliteIds.slice(i, i + this.BATCH_SIZE);
for (const docType of ['observation', 'session_summary'] as const) {
const sqliteIds = targets
.filter(target => target.docType === docType)
.map(target => target.sqliteId);

const existing = await chromaMcp.callTool('chroma_get_documents', {
collection_name: this.collectionName,
where: { sqlite_id: { $in: idBatch } },
include: ['metadatas']
}) as { ids?: string[]; metadatas?: Array<Record<string, any> | null> };

const docIds: string[] = existing?.ids ?? [];
if (docIds.length === 0) continue;

const metadatas = (existing?.metadatas ?? []).map(m => {
const merged: Record<string, any> = {
...(m ?? {}),
merged_into_project: mergedIntoProject
};
return Object.fromEntries(
Object.entries(merged).filter(
([, v]) => v !== null && v !== undefined && v !== ''
)
);
});
for (let i = 0; i < sqliteIds.length; i += this.BATCH_SIZE) {
const idBatch = sqliteIds.slice(i, i + this.BATCH_SIZE);

await chromaMcp.callTool('chroma_update_documents', {
collection_name: this.collectionName,
ids: docIds,
metadatas
});
totalPatched += docIds.length;
const existing = await chromaMcp.callTool('chroma_get_documents', {
collection_name: this.collectionName,
where: {
$and: [
{ doc_type: docType },
{ sqlite_id: { $in: idBatch } }
]
},
include: ['metadatas']
}) as { ids?: string[]; metadatas?: Array<Record<string, any> | null> };

const docIds: string[] = existing?.ids ?? [];
if (docIds.length === 0) continue;

const metadatas = (existing?.metadatas ?? []).map(m => {
const merged: Record<string, any> = {
...(m ?? {}),
merged_into_project: mergedIntoProject
};
return Object.fromEntries(
Object.entries(merged).filter(
([, v]) => v !== null && v !== undefined && v !== ''
)
);
});

await chromaMcp.callTool('chroma_update_documents', {
collection_name: this.collectionName,
ids: docIds,
metadatas
});
totalPatched += docIds.length;
}
}

logger.info('CHROMA_SYNC', 'merged_into_project metadata patched', {
collection: this.collectionName,
mergedIntoProject,
sqliteIdCount: sqliteIds.length,
sqliteIdCount: targets.length,
chromaDocsPatched: totalPatched
});
}
Expand Down
10 changes: 9 additions & 1 deletion tests/context/formatters/agent-formatter.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import { describe, it, expect, mock, beforeEach } from 'bun:test';
import { describe, it, expect, mock, beforeEach, afterAll } from 'bun:test';

import * as realModeManager from '../../../src/services/domain/ModeManager.js';

const realModeManagerSnapshot = { ...realModeManager };

mock.module('../../../src/services/domain/ModeManager.js', () => ({
ModeManager: {
Expand Down Expand Up @@ -90,6 +94,10 @@ function createTestConfig(overrides: Partial<ContextConfig> = {}): ContextConfig
};
}

afterAll(() => {
mock.module('../../../src/services/domain/ModeManager.js', () => realModeManagerSnapshot);
});

describe('AgentFormatter', () => {
describe('renderAgentHeader', () => {
it('should produce valid markdown header with project name', () => {
Expand Down
10 changes: 9 additions & 1 deletion tests/hooks/server-client.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
// SPDX-License-Identifier: Apache-2.0

import { describe, it, expect, beforeEach, afterEach, mock } from 'bun:test';
import { describe, it, expect, beforeEach, afterEach, afterAll, mock } from 'bun:test';

import * as realWorkerUtils from '../../src/shared/worker-utils.js';

const realWorkerUtilsSnapshot = { ...realWorkerUtils };

mock.module('../../src/shared/worker-utils.js', () => ({
fetchWithTimeout: async (url: string, init: RequestInit, _timeoutMs: number) => {
Expand Down Expand Up @@ -53,6 +57,10 @@ describe('ServerClient', () => {
globalThis.fetch = originalFetch;
});

afterAll(() => {
mock.module('../../src/shared/worker-utils.js', () => realWorkerUtilsSnapshot);
});

it('throws missing_api_key when apiKey is empty', async () => {
const client = new ServerClient({ serverBaseUrl: 'http://x', apiKey: '' });
let caught: unknown;
Expand Down
Loading
Loading