Skip to content

Commit 739b602

Browse files
authored
Merge pull request #182 from atomantic/claim/issue-159
fix: validate and serialize AI discovery runs
2 parents 57d40db + bf035a9 commit 739b602

6 files changed

Lines changed: 497 additions & 40 deletions

File tree

‎.changelog/NEXT.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@
5050

5151
## Fixed
5252

53+
- AI discovery now rejects unsafe batch settings, limits background runs to one per family database, and lets an active run be cancelled without leaving provider work behind.
5354
- **[issue-158] Unknown API routes now return JSON errors** — Requests to unrecognized `/api` paths receive a stable 404 error envelope instead of the browser app's HTML, while client-side navigation continues to use the SPA fallback.
5455
- Search results now keep their alphabetical ordering. The batch person-loader (`getPersonsBatch`) re-orders rows back to the requested order, fixing a regression where SQLite's `WHERE person_id IN (...)` returned rows in table order and silently discarded the search query's `ORDER BY display_name` (so the default, unsorted search view appeared randomly ordered).
5556
- Platform comparison now treats equivalent place spellings as matches: "Dallas, Texas, USA" vs "Dallas, Texas, United States" (and U.S.A. / United States of America / state abbreviations like TX vs Texas, UK vs United Kingdom, etc.) — no longer flagged as `different`. Place containment is now suffix-based, so "Texas" no longer falsely matches "Texarkana"

‎client/src/services/api.ts‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -787,6 +787,11 @@ export const api = {
787787
body: JSON.stringify(options || {})
788788
}),
789789

790+
cancelDiscovery: (dbId: string) =>
791+
fetchJson<{ runId: string; message: string }>(`/ai-discovery/${dbId}/cancel`, {
792+
method: 'POST',
793+
}),
794+
790795
getDiscoveryProgress: (runId: string) =>
791796
fetchJson<DiscoveryProgress>(`/ai-discovery/progress/${runId}`),
792797

@@ -1066,7 +1071,7 @@ export interface DiscoveryResult {
10661071
}
10671072

10681073
export interface DiscoveryProgress {
1069-
status: 'pending' | 'running' | 'completed' | 'failed';
1074+
status: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled';
10701075
totalPersons: number;
10711076
analyzedPersons: number;
10721077
candidatesFound: number;

‎docs/api.md‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,17 @@ Backend runs on port 6374 by default. Application JSON endpoints use the envelop
7171
| GET | `/api/favorites/db/:dbId/tags` | Get tags for database |
7272
| GET | `/api/favorites/db/:dbId/sparse-tree` | Get sparse tree data |
7373

74+
## AI Discovery
75+
76+
| Method | Path | Description |
77+
|--------|------|-------------|
78+
| POST | `/api/ai-discovery/:dbId/quick` | Analyze a small ancestor sample synchronously |
79+
| POST | `/api/ai-discovery/:dbId/start` | Start one bounded background discovery run for this database |
80+
| POST | `/api/ai-discovery/:dbId/cancel` | Cancel the active background discovery run |
81+
| GET | `/api/ai-discovery/progress/:runId` | Get background discovery progress |
82+
83+
`POST /api/ai-discovery/:dbId/start` accepts optional `{ batchSize, maxPersons }` values. Both must be positive integers; `batchSize` is capped at 100 and `maxPersons` at 1000. Defaults are 50 and 500 respectively. A second active run for the same database returns `409` with the active run ID.
84+
7485
## Augmentation
7586

7687
| Method | Path | Description |

‎server/src/routes/ai-discovery.routes.ts‎

Lines changed: 42 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
import { Router, Request, Response } from 'express';
2-
import { aiDiscoveryService } from '../services/ai-discovery.service.js';
2+
import {
3+
aiDiscoveryService,
4+
DiscoveryInputError,
5+
DiscoveryRunConflictError,
6+
normalizeFullDiscoveryOptions,
7+
} from '../services/ai-discovery.service.js';
38
import { favoritesService } from '../services/favorites.service.js';
49
import { logger } from '../lib/logger.js';
510
import { asyncHandler } from '../utils/asyncHandler.js';
@@ -52,21 +57,47 @@ router.post('/:dbId/quick', asyncHandler(async (req: Request, res: Response) =>
5257
*/
5358
router.post('/:dbId/start', asyncHandler(async (req: Request, res: Response) => {
5459
const { dbId } = req.params;
55-
const { batchSize, maxPersons } = req.body;
56-
57-
const result = await aiDiscoveryService.startDiscovery(dbId, {
58-
batchSize,
59-
maxPersons,
60-
}).catch(err => {
61-
res.status(500).json({ success: false, error: err.message });
62-
return null;
63-
});
60+
let options;
61+
try {
62+
options = normalizeFullDiscoveryOptions(req.body ?? {});
63+
} catch (err) {
64+
const message = err instanceof DiscoveryInputError ? err.message : 'Invalid discovery options';
65+
logger.warn('ai-discovery', `Rejected full discovery request dbId=${dbId}: ${message}`);
66+
res.status(400).json({ success: false, error: message });
67+
return;
68+
}
6469

65-
if (result !== null) {
70+
try {
71+
const result = await aiDiscoveryService.startDiscovery(dbId, options);
6672
res.json({ success: true, data: result });
73+
} catch (err) {
74+
if (err instanceof DiscoveryRunConflictError) {
75+
logger.warn('ai-discovery', `Rejected concurrent full discovery dbId=${dbId}, activeRunId=${err.runId}`);
76+
res.status(409).json({ success: false, error: err.message, data: { runId: err.runId } });
77+
return;
78+
}
79+
const message = err instanceof Error ? err.message : 'Unable to start discovery';
80+
logger.error('ai-discovery', `Failed to start full discovery dbId=${dbId}: ${message}`);
81+
res.status(500).json({ success: false, error: message });
6782
}
6883
}));
6984

85+
/**
86+
* Cancel the active full AI discovery run for a database.
87+
* POST /api/ai-discovery/:dbId/cancel
88+
*/
89+
router.post('/:dbId/cancel', (req: Request, res: Response) => {
90+
const { dbId } = req.params;
91+
const cancelled = aiDiscoveryService.cancelDiscovery(dbId);
92+
93+
if (!cancelled) {
94+
res.status(404).json({ success: false, error: 'No active discovery run for this database' });
95+
return;
96+
}
97+
98+
res.json({ success: true, data: { ...cancelled, message: 'Cancellation requested' } });
99+
});
100+
70101
/**
71102
* Get progress of a discovery run
72103
* GET /api/ai-discovery/progress/:runId

0 commit comments

Comments
 (0)