Skip to content
Merged
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
34 changes: 29 additions & 5 deletions src/services/api-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1427,6 +1427,8 @@ export async function handleDetectTagMigration(): Promise<

interface MigrationProgress {
processed: number;
/** Next index in the memory list to attempt; advances even when tagging fails. */
cursor: number;
total: number;
currentBatch: number;
totalBatches: number;
Expand All @@ -1436,6 +1438,7 @@ interface MigrationProgress {

const migrationProgress: MigrationProgress = {
processed: 0,
cursor: 0,
total: 0,
currentBatch: 0,
totalBatches: 0,
Expand All @@ -1449,7 +1452,7 @@ export async function handleGetTagMigrationProgress(): Promise<ApiResponse<Migra

export async function handleRunTagMigrationBatch(
batchSize: number = 5
): Promise<ApiResponse<{ processed: number; total: number; hasMore: boolean }>> {
): Promise<ApiResponse<{ processed: number; total: number; hasMore: boolean; errors: number }>> {
try {
await ensureTursoReady();
const { AIProviderFactory } = await import("./ai/ai-provider-factory.js");
Expand All @@ -1471,13 +1474,19 @@ export async function handleRunTagMigrationBatch(
}
}

if (migrationProgress.total === 0) {
// Fresh run (or retry after a completed pass): reset counters so soft
// failures from a prior attempt can be retried instead of being skipped.
if (migrationProgress.total === 0 || migrationProgress.isComplete) {
migrationProgress.processed = 0;
migrationProgress.cursor = 0;
migrationProgress.total = allMemories.length;
migrationProgress.totalBatches = Math.ceil(allMemories.length / batchSize);
migrationProgress.currentBatch = 0;
migrationProgress.isComplete = false;
migrationProgress.errors = [];
}

const startIdx = migrationProgress.processed;
const startIdx = migrationProgress.cursor;
const endIdx = Math.min(startIdx + batchSize, allMemories.length);

for (let i = startIdx; i < endIdx; i++) {
Expand Down Expand Up @@ -1519,6 +1528,15 @@ export async function handleRunTagMigrationBatch(
currentTags.join(","),
m.id,
]);
} else {
// Soft failure (e.g. empty tool-call args): do not mark processed.
// Cursor still advances below so the batch window moves forward.
const errorMsg = `Tag generation failed for memory ${m.id}: ${
result.error ?? "no tags returned"
}`;
migrationProgress.errors.push(errorMsg);
log("Migration error for memory", { id: m.id, error: errorMsg });
continue;
}
}

Expand All @@ -1538,16 +1556,22 @@ export async function handleRunTagMigrationBatch(
}
}

migrationProgress.cursor = endIdx;
migrationProgress.currentBatch++;
const hasMore = migrationProgress.processed < migrationProgress.total;
const hasMore = migrationProgress.cursor < allMemories.length;

if (!hasMore) {
migrationProgress.isComplete = true;
}

return {
success: true,
data: { processed: migrationProgress.processed, total: migrationProgress.total, hasMore },
data: {
processed: migrationProgress.processed,
total: migrationProgress.total,
hasMore,
errors: migrationProgress.errors.length,
},
};
} catch (error) {
return { success: false, error: String(error) };
Expand Down
182 changes: 182 additions & 0 deletions tests/tag-migration-soft-failure.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
import { afterEach, describe, expect, it } from "bun:test";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";

const tempDirs: string[] = [];

afterEach(() => {
while (tempDirs.length > 0) {
const dir = tempDirs.pop();
if (dir) rmSync(dir, { recursive: true, force: true });
}
});

const apiHandlersUrl = new URL("../src/services/api-handlers.js", import.meta.url).href;
const embeddingUrl = new URL("../src/services/embedding.js", import.meta.url).href;
const vectorUtilsUrl = new URL("../src/services/turso/vector-utils.js", import.meta.url).href;
const connectionManagerUrl = new URL("../src/services/turso/connection-manager.js", import.meta.url)
.href;
const shardManagerUrl = new URL("../src/services/turso/shard-manager.js", import.meta.url).href;
const vectorSearchUrl = new URL("../src/services/turso/vector-search.js", import.meta.url).href;
const readyUrl = new URL("../src/services/turso/ready.js", import.meta.url).href;
const userPromptManagerUrl = new URL(
"../src/services/user-prompt/user-prompt-manager.js",
import.meta.url
).href;
const loggerUrl = new URL("../src/services/logger.js", import.meta.url).href;
const factoryUrl = new URL("../src/services/ai/ai-provider-factory.js", import.meta.url).href;
const providerConfigUrl = new URL("../src/services/ai/provider-config.js", import.meta.url).href;
const configUrl = new URL("../src/config.js", import.meta.url).href;

function runScenario(scriptBody: string) {
const dir = mkdtempSync(join(tmpdir(), "opencode-mem-tag-mig-"));
tempDirs.push(dir);
const scriptPath = join(dir, "scenario.mjs");
const script = `
import { mock } from "bun:test";

const updateVectorCalls = [];
const updateTagCalls = [];
const memories = [
{ id: "m1", content: "untagged one", tags: null },
{ id: "m2", content: "untagged two", tags: "" },
];

mock.module(${JSON.stringify(embeddingUrl)}, () => ({
embeddingService: {
isWarmedUp: true,
warmup: async () => {},
embedWithTimeout: async () => new Float32Array([1, 2, 3]),
},
}));

mock.module(${JSON.stringify(vectorUtilsUrl)}, () => ({
formatTagsForEmbedding: (tags) => tags.join(","),
}));

mock.module(${JSON.stringify(connectionManagerUrl)}, () => ({
tursoConnectionManager: {
getConnection: async () => ({
all: async () => memories,
run: async (sql, params) => {
updateTagCalls.push({ sql, params });
},
get: async () => ({ count: memories.length }),
}),
closeAll: async () => {},
},
}));

mock.module(${JSON.stringify(shardManagerUrl)}, () => ({
tursoShardManager: {
async getAllShards() {
return [{ id: 1, scope: "project", scopeHash: "h", shardIndex: 0, dbPath: "/tmp/shard.db" }];
},
async withScopeWriteLock(_scope, _hash, operation) {
return operation();
},
async getWriteShard() {
return { id: 1, scope: "project", scopeHash: "h", shardIndex: 0, dbPath: "/tmp/shard.db" };
},
async incrementVectorCount() {},
},
}));

mock.module(${JSON.stringify(vectorSearchUrl)}, () => ({
tursoVectorSearch: {
updateVector: async (_db, id) => {
updateVectorCalls.push(id);
},
insertVector: async () => {},
listMemories: async () => [],
},
}));

mock.module(${JSON.stringify(readyUrl)}, () => ({
ensureTursoReady: async () => {},
}));

mock.module(${JSON.stringify(userPromptManagerUrl)}, () => ({
userPromptManager: {},
}));

mock.module(${JSON.stringify(loggerUrl)}, () => ({
log: () => {},
}));

mock.module(${JSON.stringify(configUrl)}, () => ({
CONFIG: { memoryProvider: "openai-chat" },
}));

mock.module(${JSON.stringify(providerConfigUrl)}, () => ({
buildMemoryProviderConfig: () => ({}),
}));

mock.module(${JSON.stringify(factoryUrl)}, () => ({
AIProviderFactory: {
createProvider() {
return {
async executeToolCall() {
return { success: false, error: "empty tool-call arguments" };
},
};
},
},
}));

const {
handleRunTagMigrationBatch,
handleGetTagMigrationProgress,
} = await import(${JSON.stringify(apiHandlersUrl)});

${scriptBody}
`;
writeFileSync(scriptPath, script, "utf-8");
const result = Bun.spawnSync({
cmd: [process.execPath, scriptPath],
stdout: "pipe",
stderr: "pipe",
});
const stdout = Buffer.from(result.stdout).toString("utf8").trim();
const stderr = Buffer.from(result.stderr).toString("utf8").trim();
const jsonLine = stdout
.split("\n")
.reverse()
.find((line) => line.trim().startsWith("{"));

return {
exitCode: result.exitCode,
stdout,
stderr,
parsed: jsonLine ? JSON.parse(jsonLine) : null,
};
}

describe("tag migration soft failure", () => {
it("does not count untagged memories as processed when tag generation soft-fails", () => {
const result = runScenario(`
const batch = await handleRunTagMigrationBatch(5);
const progress = await handleGetTagMigrationProgress();
console.log(JSON.stringify({
batch,
progress,
updateVectorCalls,
updateTagCalls,
}));
`);

expect(result.exitCode).toBe(0);
expect(result.parsed).not.toBeNull();
expect(result.parsed.batch.success).toBe(true);
expect(result.parsed.batch.data.processed).toBe(0);
expect(result.parsed.batch.data.hasMore).toBe(false);
expect(result.parsed.batch.data.errors).toBe(2);
expect(result.parsed.progress.data.processed).toBe(0);
expect(result.parsed.progress.data.isComplete).toBe(true);
expect(result.parsed.progress.data.errors.length).toBe(2);
expect(result.parsed.progress.data.errors[0]).toContain("m1");
expect(result.parsed.updateVectorCalls).toEqual([]);
expect(result.parsed.updateTagCalls).toEqual([]);
});
});
23 changes: 19 additions & 4 deletions web/src/lib/components/explorer/TagMigrationDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,9 @@ export function TagMigrationDialog({ open = false, count = 0, onOpenChange, onCo

let hasMore = true;
let attempts = 0;
let totalErrors = 0;
let totalItems = 0;
let totalProcessed = 0;
const maxAttempts = 1000;

while (hasMore && attempts < maxAttempts) {
Expand All @@ -52,6 +55,7 @@ export function TagMigrationDialog({ open = false, count = 0, onOpenChange, onCo
processed: number;
hasMore: boolean;
total: number;
errors?: number;
}>("/api/migration/tags/run-batch", {
method: "POST",
headers: { "Content-Type": "application/json" },
Expand All @@ -64,11 +68,12 @@ export function TagMigrationDialog({ open = false, count = 0, onOpenChange, onCo
return;
}

const processed = result.data.processed;
totalProcessed = result.data.processed;
hasMore = result.data.hasMore;
const total = result.data.total;
setProgress(total > 0 ? Math.round((processed / total) * 100) : 0);
setStatus(t("status-migration-progress", { current: processed, total }));
totalItems = result.data.total;
totalErrors = result.data.errors ?? 0;
setProgress(totalItems > 0 ? Math.round((totalProcessed / totalItems) * 100) : 0);
setStatus(t("status-migration-progress", { current: totalProcessed, total: totalItems }));
if (hasMore) {
await new Promise((resolve) => setTimeout(resolve, 100));
}
Expand All @@ -80,6 +85,16 @@ export function TagMigrationDialog({ open = false, count = 0, onOpenChange, onCo
return;
}

if (totalErrors > 0) {
const finalProgress = totalItems > 0 ? Math.round((totalProcessed / totalItems) * 100) : 0;
setProgress(finalProgress);
const failedMsg = t("toast-migration-tag-failures", { count: totalErrors });
setStatus(failedMsg);
toast.error(failedMsg);
setRunning(false);
return;
}

setProgress(100);
setStatus(t("toast-migration-success"));
toast.success(t("toast-migration-success"));
Expand Down
3 changes: 3 additions & 0 deletions web/src/lib/i18n/translations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ export const translations = {
"toast-bulk-delete-failed": "Failed to delete selected memories",
"toast-migration-success": "Migration completed successfully",
"toast-migration-failed": "Migration failed",
"toast-migration-tag-failures": "Migration failed: {count} memory tag generation failure(s)",
"toast-fresh-start-success": "Fresh start completed successfully",
"toast-fresh-start-failed": "Fresh start failed",
"confirm-delete": "Delete this memory?",
Expand Down Expand Up @@ -216,6 +217,7 @@ export const translations = {
"toast-bulk-delete-failed": "删除选中的记忆失败",
"toast-migration-success": "迁移完成",
"toast-migration-failed": "迁移失败",
"toast-migration-tag-failures": "迁移失败:{count} 条记忆标签生成失败",
"toast-fresh-start-success": "重新开始完成",
"toast-fresh-start-failed": "重新开始失败",
"confirm-delete": "删除这条记忆?",
Expand Down Expand Up @@ -384,6 +386,7 @@ export const translations = {

"toast-migration-success": "اكتملت عملية الترحيل بنجاح",
"toast-migration-failed": "فشلت عملية الترحيل",
"toast-migration-tag-failures": "فشلت عملية الترحيل: {count} من حالات فشل توليد الوسوم",

"toast-fresh-start-success": "تمت البداية الجديدة بنجاح",
"toast-fresh-start-failed": "فشلت البداية الجديدة",
Expand Down
Loading