fix(MULTIVERSION-0001): fix version indexing pipeline state and UI reactivity
- Add updateVersion() helper to IndexingPipeline that writes to repository_versions - Set version state to indexing/indexed/error at the appropriate pipeline stages - Add computeVersionStats() to count snippets for a specific version - Replace Map<string,string> with Record<string,string|undefined> for activeVersionJobs to fix Svelte 5 reactivity edge cases - Remove premature loadVersions() call from handleIndexVersion (oncomplete fires it instead) - Add refreshRepo() to version oncomplete callback so stat badges update after indexing - Disable Index button when activeVersionJobs has an entry for that tag (not just version.state) - Add three pipeline test cases covering versionId indexing, error, and no-touch paths Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -75,6 +75,28 @@ function insertRepo(db: Database.Database, overrides: Partial<Record<string, unk
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function insertVersion(
|
||||||
|
db: Database.Database,
|
||||||
|
overrides: Partial<Record<string, unknown>> = {}
|
||||||
|
): string {
|
||||||
|
const id = crypto.randomUUID();
|
||||||
|
db.prepare(
|
||||||
|
`INSERT INTO repository_versions
|
||||||
|
(id, repository_id, tag, title, state, total_snippets, indexed_at, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
|
||||||
|
).run(
|
||||||
|
overrides.id ?? id,
|
||||||
|
overrides.repository_id ?? '/test/repo',
|
||||||
|
overrides.tag ?? 'v1.0.0',
|
||||||
|
overrides.title ?? null,
|
||||||
|
overrides.state ?? 'pending',
|
||||||
|
overrides.total_snippets ?? 0,
|
||||||
|
overrides.indexed_at ?? null,
|
||||||
|
overrides.created_at ?? now
|
||||||
|
);
|
||||||
|
return (overrides.id as string) ?? id;
|
||||||
|
}
|
||||||
|
|
||||||
function insertJob(
|
function insertJob(
|
||||||
db: Database.Database,
|
db: Database.Database,
|
||||||
overrides: Partial<Record<string, unknown>> = {}
|
overrides: Partial<Record<string, unknown>> = {}
|
||||||
@@ -272,8 +294,12 @@ describe('IndexingPipeline', () => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeJob(repositoryId = '/test/repo') {
|
function makeJob(repositoryId = '/test/repo', versionId?: string) {
|
||||||
const jobId = insertJob(db, { repository_id: repositoryId, status: 'queued' });
|
const jobId = insertJob(db, {
|
||||||
|
repository_id: repositoryId,
|
||||||
|
version_id: versionId ?? null,
|
||||||
|
status: 'queued'
|
||||||
|
});
|
||||||
return db.prepare(`SELECT * FROM indexing_jobs WHERE id = ?`).get(jobId) as {
|
return db.prepare(`SELECT * FROM indexing_jobs WHERE id = ?`).get(jobId) as {
|
||||||
id: string;
|
id: string;
|
||||||
repositoryId?: string;
|
repositoryId?: string;
|
||||||
@@ -644,4 +670,63 @@ describe('IndexingPipeline', () => {
|
|||||||
expect(finalJob.status).toBe('done');
|
expect(finalJob.status).toBe('done');
|
||||||
expect(finalJob.progress).toBe(100);
|
expect(finalJob.progress).toBe(100);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('updates repository_versions state to indexing then indexed when job has versionId', async () => {
|
||||||
|
const versionId = insertVersion(db, { tag: 'v1.0.0', state: 'pending' });
|
||||||
|
const files = [
|
||||||
|
{
|
||||||
|
path: 'README.md',
|
||||||
|
content: '# Hello\n\nThis is documentation.',
|
||||||
|
sha: 'sha-readme',
|
||||||
|
language: 'markdown'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
const pipeline = makePipeline({ files, totalFiles: 1 });
|
||||||
|
const job = makeJob('/test/repo', versionId);
|
||||||
|
|
||||||
|
await pipeline.run(job as never);
|
||||||
|
|
||||||
|
const version = db
|
||||||
|
.prepare(`SELECT state, total_snippets, indexed_at FROM repository_versions WHERE id = ?`)
|
||||||
|
.get(versionId) as { state: string; total_snippets: number; indexed_at: number | null };
|
||||||
|
|
||||||
|
expect(version.state).toBe('indexed');
|
||||||
|
expect(version.total_snippets).toBeGreaterThan(0);
|
||||||
|
expect(version.indexed_at).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates repository_versions state to error when pipeline throws and job has versionId', async () => {
|
||||||
|
const versionId = insertVersion(db, { tag: 'v1.0.0', state: 'pending' });
|
||||||
|
const errorCrawl = vi.fn().mockRejectedValue(new Error('crawl failed'));
|
||||||
|
const pipeline = new IndexingPipeline(
|
||||||
|
db,
|
||||||
|
errorCrawl as never,
|
||||||
|
{ crawl: errorCrawl } as never,
|
||||||
|
null
|
||||||
|
);
|
||||||
|
const job = makeJob('/test/repo', versionId);
|
||||||
|
|
||||||
|
await expect(pipeline.run(job as never)).rejects.toThrow('crawl failed');
|
||||||
|
|
||||||
|
const version = db
|
||||||
|
.prepare(`SELECT state FROM repository_versions WHERE id = ?`)
|
||||||
|
.get(versionId) as { state: string };
|
||||||
|
|
||||||
|
expect(version.state).toBe('error');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not touch repository_versions when job has no versionId', async () => {
|
||||||
|
const versionId = insertVersion(db, { tag: 'v1.0.0', state: 'pending' });
|
||||||
|
const pipeline = makePipeline({ files: [], totalFiles: 0 });
|
||||||
|
const job = makeJob('/test/repo'); // no versionId
|
||||||
|
|
||||||
|
await pipeline.run(job as never);
|
||||||
|
|
||||||
|
const version = db
|
||||||
|
.prepare(`SELECT state FROM repository_versions WHERE id = ?`)
|
||||||
|
.get(versionId) as { state: string };
|
||||||
|
|
||||||
|
// State should remain 'pending' — pipeline with no versionId must not touch it
|
||||||
|
expect(version.state).toBe('pending');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -90,6 +90,9 @@ export class IndexingPipeline {
|
|||||||
|
|
||||||
// Mark repo as actively indexing.
|
// Mark repo as actively indexing.
|
||||||
this.updateRepo(repo.id, { state: 'indexing' });
|
this.updateRepo(repo.id, { state: 'indexing' });
|
||||||
|
if (normJob.versionId) {
|
||||||
|
this.updateVersion(normJob.versionId, { state: 'indexing' });
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Stage 1: Crawl -------------------------------------------------
|
// ---- Stage 1: Crawl -------------------------------------------------
|
||||||
const crawlResult = await this.crawl(repo);
|
const crawlResult = await this.crawl(repo);
|
||||||
@@ -229,6 +232,15 @@ export class IndexingPipeline {
|
|||||||
lastIndexedAt: Math.floor(Date.now() / 1000)
|
lastIndexedAt: Math.floor(Date.now() / 1000)
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (normJob.versionId) {
|
||||||
|
const versionStats = this.computeVersionStats(normJob.versionId);
|
||||||
|
this.updateVersion(normJob.versionId, {
|
||||||
|
state: 'indexed',
|
||||||
|
totalSnippets: versionStats.totalSnippets,
|
||||||
|
indexedAt: Math.floor(Date.now() / 1000)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
this.updateJob(job.id, {
|
this.updateJob(job.id, {
|
||||||
status: 'done',
|
status: 'done',
|
||||||
progress: 100,
|
progress: 100,
|
||||||
@@ -246,6 +258,9 @@ export class IndexingPipeline {
|
|||||||
|
|
||||||
// Restore repo to error state but preserve any existing indexed data.
|
// Restore repo to error state but preserve any existing indexed data.
|
||||||
this.updateRepo(repositoryId, { state: 'error' });
|
this.updateRepo(repositoryId, { state: 'error' });
|
||||||
|
if (normJob.versionId) {
|
||||||
|
this.updateVersion(normJob.versionId, { state: 'error' });
|
||||||
|
}
|
||||||
|
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
@@ -384,6 +399,16 @@ export class IndexingPipeline {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private computeVersionStats(versionId: string): { totalSnippets: number } {
|
||||||
|
const row = this.db
|
||||||
|
.prepare<[string], { total_snippets: number }>(
|
||||||
|
`SELECT COUNT(*) as total_snippets FROM snippets WHERE version_id = ?`
|
||||||
|
)
|
||||||
|
.get(versionId);
|
||||||
|
|
||||||
|
return { totalSnippets: row?.total_snippets ?? 0 };
|
||||||
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
// Private — DB helpers
|
// Private — DB helpers
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
@@ -433,6 +458,14 @@ export class IndexingPipeline {
|
|||||||
const values = [...Object.values(allFields), id];
|
const values = [...Object.values(allFields), id];
|
||||||
this.db.prepare(`UPDATE repositories SET ${sets} WHERE id = ?`).run(...values);
|
this.db.prepare(`UPDATE repositories SET ${sets} WHERE id = ?`).run(...values);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private updateVersion(id: string, fields: Record<string, unknown>): void {
|
||||||
|
const sets = Object.keys(fields)
|
||||||
|
.map((k) => `${toSnake(k)} = ?`)
|
||||||
|
.join(', ');
|
||||||
|
const values = [...Object.values(fields), id];
|
||||||
|
this.db.prepare(`UPDATE repository_versions SET ${sets} WHERE id = ?`).run(...values);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -52,6 +52,9 @@
|
|||||||
let showDiscoverPanel = $state(false);
|
let showDiscoverPanel = $state(false);
|
||||||
let registerBusy = $state(false);
|
let registerBusy = $state(false);
|
||||||
|
|
||||||
|
// Active version indexing jobs: tag -> jobId
|
||||||
|
let activeVersionJobs = $state<Record<string, string | undefined>>({});
|
||||||
|
|
||||||
// Remove confirm
|
// Remove confirm
|
||||||
let removeTag = $state<string | null>(null);
|
let removeTag = $state<string | null>(null);
|
||||||
|
|
||||||
@@ -115,6 +118,16 @@
|
|||||||
activeJobId = d.job.id;
|
activeJobId = d.job.id;
|
||||||
}
|
}
|
||||||
const versionCount = d.versionJobs?.length ?? 0;
|
const versionCount = d.versionJobs?.length ?? 0;
|
||||||
|
if (versionCount > 0) {
|
||||||
|
let next = { ...activeVersionJobs };
|
||||||
|
for (const vj of d.versionJobs) {
|
||||||
|
const matched = versions.find((v) => v.id === vj.versionId);
|
||||||
|
if (matched) {
|
||||||
|
next = { ...next, [matched.tag]: vj.id };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
activeVersionJobs = next;
|
||||||
|
}
|
||||||
successMessage =
|
successMessage =
|
||||||
versionCount > 0
|
versionCount > 0
|
||||||
? `Re-indexing started. Also queued ${versionCount} version job${versionCount === 1 ? '' : 's'}.`
|
? `Re-indexing started. Also queued ${versionCount} version job${versionCount === 1 ? '' : 's'}.`
|
||||||
@@ -157,6 +170,10 @@
|
|||||||
const d = await res.json();
|
const d = await res.json();
|
||||||
throw new Error(d.error ?? 'Failed to add version');
|
throw new Error(d.error ?? 'Failed to add version');
|
||||||
}
|
}
|
||||||
|
const d = await res.json();
|
||||||
|
if (d.job?.id) {
|
||||||
|
activeVersionJobs = { ...activeVersionJobs, [tag]: d.job.id };
|
||||||
|
}
|
||||||
addVersionTag = '';
|
addVersionTag = '';
|
||||||
await loadVersions();
|
await loadVersions();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -177,7 +194,10 @@
|
|||||||
const d = await res.json();
|
const d = await res.json();
|
||||||
throw new Error(d.error ?? 'Failed to queue version indexing');
|
throw new Error(d.error ?? 'Failed to queue version indexing');
|
||||||
}
|
}
|
||||||
await loadVersions();
|
const d = await res.json();
|
||||||
|
if (d.job?.id) {
|
||||||
|
activeVersionJobs = { ...activeVersionJobs, [tag]: d.job.id };
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
errorMessage = (e as Error).message;
|
errorMessage = (e as Error).message;
|
||||||
}
|
}
|
||||||
@@ -244,8 +264,9 @@
|
|||||||
registerBusy = true;
|
registerBusy = true;
|
||||||
errorMessage = null;
|
errorMessage = null;
|
||||||
try {
|
try {
|
||||||
await Promise.all(
|
const tags = [...selectedDiscoveredTags];
|
||||||
[...selectedDiscoveredTags].map((tag) =>
|
const responses = await Promise.all(
|
||||||
|
tags.map((tag) =>
|
||||||
fetch(`/api/v1/libs/${encodeURIComponent(repo.id)}/versions`, {
|
fetch(`/api/v1/libs/${encodeURIComponent(repo.id)}/versions`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
@@ -253,6 +274,15 @@
|
|||||||
})
|
})
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
const results = await Promise.all(responses.map((r) => (r.ok ? r.json() : null)));
|
||||||
|
let next = { ...activeVersionJobs };
|
||||||
|
for (let i = 0; i < tags.length; i++) {
|
||||||
|
const result = results[i];
|
||||||
|
if (result?.job?.id) {
|
||||||
|
next = { ...next, [tags[i]]: result.job.id };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
activeVersionJobs = next;
|
||||||
showDiscoverPanel = false;
|
showDiscoverPanel = false;
|
||||||
discoveredTags = [];
|
discoveredTags = [];
|
||||||
selectedDiscoveredTags = new Set();
|
selectedDiscoveredTags = new Set();
|
||||||
@@ -346,7 +376,13 @@
|
|||||||
{#if activeJobId}
|
{#if activeJobId}
|
||||||
<div class="mt-4 rounded-xl border border-blue-100 bg-blue-50 p-4">
|
<div class="mt-4 rounded-xl border border-blue-100 bg-blue-50 p-4">
|
||||||
<p class="mb-2 text-sm font-medium text-blue-700">Indexing in progress</p>
|
<p class="mb-2 text-sm font-medium text-blue-700">Indexing in progress</p>
|
||||||
<IndexingProgress jobId={activeJobId} />
|
<IndexingProgress
|
||||||
|
jobId={activeJobId}
|
||||||
|
oncomplete={() => {
|
||||||
|
activeJobId = null;
|
||||||
|
refreshRepo();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
{:else if repo.state === 'error'}
|
{:else if repo.state === 'error'}
|
||||||
<div class="mt-4 rounded-xl border border-red-100 bg-red-50 p-4">
|
<div class="mt-4 rounded-xl border border-red-100 bg-red-50 p-4">
|
||||||
@@ -461,7 +497,8 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<div class="divide-y divide-gray-100">
|
<div class="divide-y divide-gray-100">
|
||||||
{#each versions as version (version.id)}
|
{#each versions as version (version.id)}
|
||||||
<div class="flex items-center justify-between py-2.5">
|
<div class="py-2.5">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<span class="font-mono text-sm font-medium text-gray-900">{version.tag}</span>
|
<span class="font-mono text-sm font-medium text-gray-900">{version.tag}</span>
|
||||||
<span
|
<span
|
||||||
@@ -474,10 +511,10 @@
|
|||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
onclick={() => handleIndexVersion(version.tag)}
|
onclick={() => handleIndexVersion(version.tag)}
|
||||||
disabled={version.state === 'indexing'}
|
disabled={version.state === 'indexing' || !!activeVersionJobs[version.tag]}
|
||||||
class="rounded-lg border border-blue-200 px-3 py-1 text-xs font-medium text-blue-600 hover:bg-blue-50 disabled:cursor-not-allowed disabled:opacity-50"
|
class="rounded-lg border border-blue-200 px-3 py-1 text-xs font-medium text-blue-600 hover:bg-blue-50 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{version.state === 'indexing' ? 'Indexing...' : 'Index'}
|
{version.state === 'indexing' || !!activeVersionJobs[version.tag] ? 'Indexing...' : 'Index'}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onclick={() => (removeTag = version.tag)}
|
onclick={() => (removeTag = version.tag)}
|
||||||
@@ -487,6 +524,18 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{#if !!activeVersionJobs[version.tag]}
|
||||||
|
<IndexingProgress
|
||||||
|
jobId={activeVersionJobs[version.tag]!}
|
||||||
|
oncomplete={() => {
|
||||||
|
const { [version.tag]: _, ...rest } = activeVersionJobs;
|
||||||
|
activeVersionJobs = rest;
|
||||||
|
loadVersions();
|
||||||
|
refreshRepo();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
Reference in New Issue
Block a user