mirror of
https://github.com/garrytan/gbrain.git
synced 2026-08-15 01:12:20 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9e044021ef | ||
|
|
553515c82c |
Vendored
+56
File diff suppressed because one or more lines are too long
Vendored
-57
File diff suppressed because one or more lines are too long
Vendored
+1
-1
@@ -7,7 +7,7 @@
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
|
||||
<script type="module" crossorigin src="/admin/assets/index-CzLjRij_.js"></script>
|
||||
<script type="module" crossorigin src="/admin/assets/index-CoGEje3-.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/admin/assets/index-GxkWX7v3.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -39,7 +39,6 @@ export const api = {
|
||||
stats: () => apiFetch('/admin/api/stats'),
|
||||
health: () => apiFetch('/admin/api/health-indicators'),
|
||||
agents: () => apiFetch('/admin/api/agents'),
|
||||
sources: () => apiFetch('/admin/api/sources'),
|
||||
requests: (page = 1, qs = '') => apiFetch(`/admin/api/requests?page=${page}${qs}`),
|
||||
apiKeys: () => apiFetch('/admin/api/api-keys'),
|
||||
createApiKey: (name: string) => apiFetch('/admin/api/api-keys', { method: 'POST', body: JSON.stringify({ name }) }),
|
||||
|
||||
@@ -23,16 +23,9 @@ interface Agent {
|
||||
total_requests: number;
|
||||
requests_today: number;
|
||||
token_ttl: number | null;
|
||||
source_id: string | null;
|
||||
federated_read: string[] | null;
|
||||
status: 'active' | 'revoked';
|
||||
}
|
||||
|
||||
interface SourceOption {
|
||||
id: string;
|
||||
name: string | null;
|
||||
}
|
||||
|
||||
interface ApiKey {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -95,7 +88,6 @@ export function AgentsPage() {
|
||||
<th>Name</th>
|
||||
<th>Type</th>
|
||||
<th>Scopes</th>
|
||||
<th>Source</th>
|
||||
<th>Status</th>
|
||||
<th>Requests</th>
|
||||
<th>Last Used</th>
|
||||
@@ -116,9 +108,6 @@ export function AgentsPage() {
|
||||
<span key={s} className={`badge badge-${s}`} style={{ marginRight: 4 }}>{s}</span>
|
||||
))}
|
||||
</td>
|
||||
<td className="mono" style={{ fontSize: 12, color: 'var(--text-secondary)' }}>
|
||||
{a.auth_type === 'oauth' ? (a.source_id || 'default') : 'all'}
|
||||
</td>
|
||||
<td>
|
||||
<span className={`badge ${a.status === 'active' ? 'badge-success' : 'badge-danger'}`}>{a.status}</span>
|
||||
</td>
|
||||
@@ -268,29 +257,9 @@ function RegisterModal({ onClose, onRegistered }: {
|
||||
Object.fromEntries(ALLOWED_SCOPES_LIST.map(s => [s, s === 'read'])) as Record<Scope, boolean>,
|
||||
);
|
||||
const [ttl, setTtl] = useState('86400'); // 24h default
|
||||
const [redirectUris, setRedirectUris] = useState('');
|
||||
const [sources, setSources] = useState<SourceOption[]>([{ id: 'default', name: 'default' }]);
|
||||
const [sourceId, setSourceId] = useState('default');
|
||||
const [readSources, setReadSources] = useState<Record<string, boolean>>({ default: true });
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
api.sources()
|
||||
.then((rows: SourceOption[]) => {
|
||||
const nextSources = rows.length > 0 ? rows : [{ id: 'default', name: 'default' }];
|
||||
const nextSourceId = nextSources.some(s => s.id === sourceId) ? sourceId : nextSources[0].id;
|
||||
setSources(nextSources);
|
||||
setSourceId(nextSourceId);
|
||||
setReadSources(prev => {
|
||||
const next = Object.fromEntries(nextSources.map(s => [s.id, Boolean(prev[s.id])])) as Record<string, boolean>;
|
||||
next[nextSourceId] = true;
|
||||
return next;
|
||||
});
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const ttlOptions = [
|
||||
{ label: '1 hour', value: '3600' },
|
||||
{ label: '24 hours', value: '86400' },
|
||||
@@ -308,24 +277,11 @@ function RegisterModal({ onClose, onRegistered }: {
|
||||
try {
|
||||
// Use the CLI registration endpoint (POST to admin API)
|
||||
const selectedScopes = Object.entries(scopes).filter(([, v]) => v).map(([k]) => k).join(' ');
|
||||
const federatedRead = sources.map(s => s.id).filter(id => readSources[id]);
|
||||
if (federatedRead.length === 0) { setError('Select at least one read source'); setLoading(false); return; }
|
||||
// #1036: redirect URIs, one per line. Mirrors the CLI convention
|
||||
// (src/commands/auth.ts): presence of redirect URIs implies
|
||||
// authorization_code + refresh_token grants unless the user had none.
|
||||
const uris = redirectUris.split('\n').map(u => u.trim()).filter(Boolean);
|
||||
const res = await fetch('/admin/api/register-client', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: name.trim(),
|
||||
scopes: selectedScopes,
|
||||
tokenTtl: ttl === '0' ? 315360000 : Number(ttl),
|
||||
source_id: sourceId,
|
||||
federated_read: federatedRead,
|
||||
...(uris.length > 0 ? { redirectUris: uris, grantTypes: ['authorization_code', 'refresh_token'] } : {}),
|
||||
}),
|
||||
body: JSON.stringify({ name: name.trim(), scopes: selectedScopes, tokenTtl: ttl === '0' ? 315360000 : Number(ttl) }),
|
||||
});
|
||||
if (!res.ok) throw new Error('Registration failed');
|
||||
const data = await res.json();
|
||||
@@ -356,42 +312,6 @@ function RegisterModal({ onClose, onRegistered }: {
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<label>Write Source</label>
|
||||
<select value={sourceId} onChange={e => {
|
||||
const id = e.target.value;
|
||||
setSourceId(id);
|
||||
setReadSources(p => ({ ...p, [id]: true }));
|
||||
}}
|
||||
style={{ width: '100%', background: 'var(--bg-secondary)', color: 'var(--text-primary)', border: '1px solid var(--border)', borderRadius: 6, padding: '6px 10px', fontSize: 14 }}>
|
||||
{sources.map(s => <option key={s.id} value={s.id}>{s.name && s.name !== s.id ? `${s.id} (${s.name})` : s.id}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<label>Read Sources</label>
|
||||
<div className="checkbox-group">
|
||||
{sources.map(s => (
|
||||
<label key={s.id} className="checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={Boolean(readSources[s.id])}
|
||||
onChange={e => setReadSources(p => ({ ...p, [s.id]: e.target.checked }))}
|
||||
/>
|
||||
{s.name && s.name !== s.id ? `${s.id} (${s.name})` : s.id}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<label>Redirect URIs <span style={{ color: 'var(--text-secondary)', fontWeight: 400 }}>(optional, one per line — enables authorization_code flow)</span></label>
|
||||
<textarea
|
||||
placeholder={'https://example.com/callback'}
|
||||
value={redirectUris}
|
||||
onChange={e => setRedirectUris(e.target.value)}
|
||||
rows={2}
|
||||
style={{ width: '100%', background: 'var(--bg-secondary)', color: 'var(--text-primary)', border: '1px solid var(--border)', borderRadius: 6, padding: '6px 10px', fontSize: 13, fontFamily: 'inherit', resize: 'vertical' }}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ marginBottom: 20 }}>
|
||||
<label>Token Lifetime</label>
|
||||
<select value={ttl} onChange={e => setTtl(e.target.value)}
|
||||
@@ -608,8 +528,6 @@ function AgentDrawer({ agent, onClose, onRevoked }: { agent: Agent; onClose: ()
|
||||
client_name: agentName,
|
||||
auth_type: agent.auth_type,
|
||||
scope: agent.scope,
|
||||
source_id: agent.source_id,
|
||||
federated_read: agent.federated_read,
|
||||
}, null, 2),
|
||||
};
|
||||
|
||||
@@ -629,10 +547,6 @@ function AgentDrawer({ agent, onClose, onRevoked }: { agent: Agent; onClose: ()
|
||||
<span>{(agent.scope || '').split(' ').filter(Boolean).map(s => (
|
||||
<span key={s} className={`badge badge-${s}`} style={{ marginRight: 4 }}>{s}</span>
|
||||
))}</span>
|
||||
<span style={{ color: 'var(--text-secondary)' }}>Write Source</span>
|
||||
<span className="mono">{agent.source_id || (isOAuth ? 'default' : 'all')}</span>
|
||||
<span style={{ color: 'var(--text-secondary)' }}>Read Sources</span>
|
||||
<span className="mono">{isOAuth ? (agent.federated_read && agent.federated_read.length > 0 ? agent.federated_read.join(', ') : (agent.source_id || 'default')) : 'all'}</span>
|
||||
<span style={{ color: 'var(--text-secondary)' }}>Registered</span>
|
||||
<span>{new Date(agent.created_at).toLocaleDateString()}</span>
|
||||
<span style={{ color: 'var(--text-secondary)' }}>Token TTL</span>
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
// AUTO-GENERATED — do not edit by hand.
|
||||
// Run `bun run scripts/build-admin-embedded.ts` to regenerate.
|
||||
// Source: admin/dist/ at 2026-07-21.
|
||||
// Source: admin/dist/ at 2026-05-27.
|
||||
//
|
||||
// Bun resolves the file: imports to a path that works at runtime even
|
||||
// inside a compiled binary (`bun build --compile`). The manifest maps
|
||||
// the request path the express handler sees to (resolved-path, mime).
|
||||
|
||||
// @ts-ignore — type: 'file' is Bun ESM, not in lib.d.ts
|
||||
import A_0_assets_index_CzLjRij__js from '../admin/dist/assets/index-CzLjRij_.js' with { type: 'file' };
|
||||
import A_0_assets_index_CoGEje3__js from '../admin/dist/assets/index-CoGEje3-.js' with { type: 'file' };
|
||||
// @ts-ignore — type: 'file' is Bun ESM, not in lib.d.ts
|
||||
import A_1_assets_index_GxkWX7v3_css from '../admin/dist/assets/index-GxkWX7v3.css' with { type: 'file' };
|
||||
// @ts-ignore — type: 'file' is Bun ESM, not in lib.d.ts
|
||||
@@ -19,7 +19,7 @@ export interface AdminAsset {
|
||||
}
|
||||
|
||||
export const ADMIN_ASSETS: Record<string, AdminAsset> = {
|
||||
"/admin/assets/index-CzLjRij_.js": { path: A_0_assets_index_CzLjRij__js as unknown as string, mime: "application/javascript; charset=utf-8" },
|
||||
"/admin/assets/index-CoGEje3-.js": { path: A_0_assets_index_CoGEje3__js as unknown as string, mime: "application/javascript; charset=utf-8" },
|
||||
"/admin/assets/index-GxkWX7v3.css": { path: A_1_assets_index_GxkWX7v3_css as unknown as string, mime: "text/css; charset=utf-8" },
|
||||
"/admin/index.html": { path: A_2_index_html as unknown as string, mime: "text/html; charset=utf-8" },
|
||||
};
|
||||
|
||||
@@ -2059,6 +2059,8 @@ export async function registerBuiltinHandlers(
|
||||
sourceId,
|
||||
windowSeconds,
|
||||
brainDir: repoPath,
|
||||
// #2750: worker cancel/timeout/lock-loss propagates into the drain.
|
||||
abortSignal: job.signal,
|
||||
});
|
||||
} catch (e) {
|
||||
if (e instanceof LockUnavailableError) {
|
||||
|
||||
@@ -39,7 +39,6 @@ import * as db from '../core/db.ts';
|
||||
import { sqlQueryForEngine, executeRawJsonb } from '../core/sql-query.ts';
|
||||
import { MinionQueue } from '../core/minions/queue.ts';
|
||||
import { isRetryableError } from '../core/retry-matcher.ts';
|
||||
import { normalizeAdminClientSourceScope } from '../core/admin-source-scope.ts';
|
||||
import {
|
||||
computeContentHash,
|
||||
validateIngestionEvent,
|
||||
@@ -1087,7 +1086,6 @@ export async function runServeHttp(engine: BrainEngine, options: ServeHttpOption
|
||||
const oauthClients = await sql`
|
||||
SELECT c.client_id as id, c.client_name as name, 'oauth' as auth_type,
|
||||
c.grant_types, c.scope, c.created_at, c.token_ttl,
|
||||
c.source_id, c.federated_read,
|
||||
CASE WHEN c.deleted_at IS NOT NULL THEN 'revoked' ELSE 'active' END as status,
|
||||
(SELECT max(created_at) FROM mcp_request_log WHERE token_name = c.client_id) as last_used_at,
|
||||
(SELECT count(*)::int FROM mcp_request_log WHERE token_name = c.client_id) as total_requests,
|
||||
@@ -1097,7 +1095,6 @@ export async function runServeHttp(engine: BrainEngine, options: ServeHttpOption
|
||||
const legacyKeys = await sql`
|
||||
SELECT a.id, a.name, 'api_key' as auth_type,
|
||||
'{"bearer"}' as grant_types, 'read write admin' as scope, a.created_at, null as token_ttl,
|
||||
null as source_id, null as federated_read,
|
||||
CASE WHEN a.revoked_at IS NOT NULL THEN 'revoked' ELSE 'active' END as status,
|
||||
a.last_used_at,
|
||||
(SELECT count(*)::int FROM mcp_request_log WHERE token_name = a.name) as total_requests,
|
||||
@@ -1110,17 +1107,6 @@ export async function runServeHttp(engine: BrainEngine, options: ServeHttpOption
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/admin/api/sources', requireAdmin, async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const sources = await engine.listAllSources({ includeArchived: false });
|
||||
const options = sources.map(s => ({ id: s.id, name: s.name }));
|
||||
if (!options.some(s => s.id === 'default')) options.unshift({ id: 'default', name: 'default' });
|
||||
res.json(options);
|
||||
} catch (e) {
|
||||
res.status(503).json({ error: 'service_unavailable' });
|
||||
}
|
||||
});
|
||||
|
||||
// v0.38 Slice 4 — per-OAuth-client agent spend viewer. Pre-computes today's
|
||||
// spend (committed + pending reservations) per client so the Agents tab
|
||||
// can render a "$X / $Y today" cell. Read-side endpoint only — no mutation.
|
||||
@@ -1454,8 +1440,6 @@ export async function runServeHttp(engine: BrainEngine, options: ServeHttpOption
|
||||
// missing, empty) and rejects the rest with a structured 400.
|
||||
const { name, tokenTtl, grantTypes, redirectUris, tokenEndpointAuthMethod } = req.body;
|
||||
const rawScopes = (req.body as Record<string, unknown>).scopes ?? (req.body as Record<string, unknown>).scope;
|
||||
const rawSourceId = (req.body as Record<string, unknown>).source_id ?? (req.body as Record<string, unknown>).sourceId;
|
||||
const rawFederatedRead = (req.body as Record<string, unknown>).federated_read ?? (req.body as Record<string, unknown>).federatedRead;
|
||||
if (!name) { res.status(400).json({ error: 'Name required' }); return; }
|
||||
let scopeString: string;
|
||||
try {
|
||||
@@ -1486,23 +1470,8 @@ export async function runServeHttp(engine: BrainEngine, options: ServeHttpOption
|
||||
});
|
||||
return;
|
||||
}
|
||||
let sourceScope: ReturnType<typeof normalizeAdminClientSourceScope>;
|
||||
try {
|
||||
const sources = await engine.listAllSources({ includeArchived: false });
|
||||
sourceScope = normalizeAdminClientSourceScope(
|
||||
rawSourceId,
|
||||
rawFederatedRead,
|
||||
sources.map(s => s.id),
|
||||
);
|
||||
} catch (e) {
|
||||
res.status(400).json({
|
||||
error: 'invalid_source_scope',
|
||||
message: e instanceof Error ? e.message : String(e),
|
||||
});
|
||||
return;
|
||||
}
|
||||
const result = await oauthProvider.registerClientManual(
|
||||
name, grants, scopeString, uris, sourceScope.sourceId, sourceScope.federatedRead, validatedAuthMethod,
|
||||
name, grants, scopeString, uris, 'default', undefined, validatedAuthMethod,
|
||||
);
|
||||
// Set per-client TTL if specified
|
||||
if (tokenTtl && Number(tokenTtl) > 0) {
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
import { isValidSourceId } from './source-id.ts';
|
||||
|
||||
export interface AdminClientSourceScope {
|
||||
sourceId: string;
|
||||
federatedRead: string[];
|
||||
}
|
||||
|
||||
function parseFederatedReadInput(raw: unknown, sourceId: string): string[] {
|
||||
if (raw === undefined || raw === null) return [sourceId];
|
||||
if (typeof raw === 'string') {
|
||||
const values = raw.split(',').map(s => s.trim()).filter(Boolean);
|
||||
return values.length > 0 ? values : [sourceId];
|
||||
}
|
||||
if (Array.isArray(raw)) {
|
||||
if (!raw.every(v => typeof v === 'string')) {
|
||||
throw new Error('federated_read must be an array of source ids');
|
||||
}
|
||||
const values = raw.map(v => v.trim()).filter(Boolean);
|
||||
return values.length > 0 ? values : [sourceId];
|
||||
}
|
||||
throw new Error('federated_read must be a string or array');
|
||||
}
|
||||
|
||||
export function normalizeAdminClientSourceScope(
|
||||
rawSourceId: unknown,
|
||||
rawFederatedRead: unknown,
|
||||
availableSourceIds: string[],
|
||||
): AdminClientSourceScope {
|
||||
const knownSources = new Set(availableSourceIds);
|
||||
const fallbackSourceId = knownSources.has('default') ? 'default' : (availableSourceIds[0] ?? 'default');
|
||||
const sourceId = rawSourceId === undefined || rawSourceId === null || rawSourceId === ''
|
||||
? fallbackSourceId
|
||||
: rawSourceId;
|
||||
if (!isValidSourceId(sourceId)) {
|
||||
throw new Error(`Invalid source_id: ${JSON.stringify(sourceId)}`);
|
||||
}
|
||||
if (knownSources.size > 0 && !knownSources.has(sourceId)) {
|
||||
throw new Error(`Unknown source_id: ${sourceId}`);
|
||||
}
|
||||
|
||||
const federatedRead = Array.from(new Set(parseFederatedReadInput(rawFederatedRead, sourceId)));
|
||||
for (const id of federatedRead) {
|
||||
if (!isValidSourceId(id)) {
|
||||
throw new Error(`Invalid federated_read source_id: ${JSON.stringify(id)}`);
|
||||
}
|
||||
if (knownSources.size > 0 && !knownSources.has(id)) {
|
||||
throw new Error(`Unknown federated_read source_id: ${id}`);
|
||||
}
|
||||
}
|
||||
return { sourceId, federatedRead };
|
||||
}
|
||||
@@ -23,6 +23,10 @@
|
||||
*/
|
||||
|
||||
import type { BrainEngine } from '../engine.ts';
|
||||
import { anySignal } from '../abort-check.ts';
|
||||
|
||||
/** Fresh cleanup budget for the lock release after the window signal fires. */
|
||||
const LOCK_RELEASE_GRACE_MS = 5_000;
|
||||
|
||||
export interface ExtractAtomsDrainDeps {
|
||||
/**
|
||||
@@ -30,13 +34,13 @@ export interface ExtractAtomsDrainDeps {
|
||||
* via `withRefreshingLock`. MUST throw when the lock is held by another
|
||||
* process (e.g. `LockUnavailableError`) — the drain lets that propagate so
|
||||
* the caller can report `cycle_already_running` and exit, matching the
|
||||
* routine cycle's skip contract.
|
||||
* routine cycle's skip contract. The signal bounds lock acquisition too.
|
||||
*/
|
||||
withLock: <T>(work: () => Promise<T>) => Promise<T>;
|
||||
/** Process one bounded batch (rediscovers eligibility). Returns counts. */
|
||||
runBatch: () => Promise<{ extracted: number; skipped: number }>;
|
||||
withLock: <T>(work: () => Promise<T>, signal: AbortSignal) => Promise<T>;
|
||||
/** Process one batch. The signal fires at the drain wallclock deadline. */
|
||||
runBatch: (signal: AbortSignal) => Promise<{ extracted: number; skipped: number }>;
|
||||
/** Count remaining eligible-but-unextracted pages, or null on query error. */
|
||||
countRemaining: () => Promise<number | null>;
|
||||
countRemaining: (signal: AbortSignal) => Promise<number | null>;
|
||||
/** Injectable clock. Production: Date.now. */
|
||||
now: () => number;
|
||||
/** Optional progress sink (one line per batch). */
|
||||
@@ -48,6 +52,8 @@ export interface ExtractAtomsDrainOpts {
|
||||
windowMs: number;
|
||||
/** Hard cap on batches (belt-and-suspenders against a 0-progress loop). Default 1000. */
|
||||
maxBatches?: number;
|
||||
/** External caller cancellation (worker timeout / shutdown). */
|
||||
abortSignal?: AbortSignal;
|
||||
}
|
||||
|
||||
export interface ExtractAtomsDrainResult {
|
||||
@@ -68,35 +74,79 @@ export async function runExtractAtomsDrain(
|
||||
opts: ExtractAtomsDrainOpts,
|
||||
): Promise<ExtractAtomsDrainResult> {
|
||||
const maxBatches = opts.maxBatches ?? 1000;
|
||||
return deps.withLock(async () => {
|
||||
const deadline = deps.now() + opts.windowMs;
|
||||
const deadline = deps.now() + opts.windowMs;
|
||||
// #2750: the window used to be checked only BETWEEN batches, so one slow
|
||||
// batch (sequential LLM calls) or a hung lock/count/write overran it without
|
||||
// bound (observed window=120s → 282.5s). A real-time deadline signal now
|
||||
// cancels (Postgres) or abandons (PGLite, cooperative) whatever is in
|
||||
// flight; the injected clock still drives loop-boundary checks so the pure
|
||||
// loop stays unit-testable.
|
||||
const signal = anySignal(
|
||||
AbortSignal.timeout(Math.max(1, opts.windowMs)),
|
||||
opts.abortSignal,
|
||||
);
|
||||
const result: ExtractAtomsDrainResult = await deps.withLock(async () => {
|
||||
let extracted = 0;
|
||||
let skipped = 0;
|
||||
let batches = 0;
|
||||
let stopped: ExtractAtomsDrainResult['stopped'] = 'window';
|
||||
|
||||
while (deps.now() < deadline) {
|
||||
while (deps.now() < deadline && !signal.aborted) {
|
||||
if (batches >= maxBatches) { stopped = 'max_batches'; break; }
|
||||
|
||||
const before = await deps.countRemaining();
|
||||
let before: number | null;
|
||||
try {
|
||||
before = await deps.countRemaining(signal);
|
||||
} catch (err) {
|
||||
if (signal.aborted) break;
|
||||
throw err;
|
||||
}
|
||||
if (before === 0) { stopped = 'drained'; break; }
|
||||
|
||||
const r = await deps.runBatch();
|
||||
// The backlog count consumed the same wallclock budget — re-check so a
|
||||
// slow count can't hand the batch a window that already expired.
|
||||
if (deps.now() >= deadline || signal.aborted) break;
|
||||
|
||||
let r: { extracted: number; skipped: number };
|
||||
try {
|
||||
r = await deps.runBatch(signal);
|
||||
} catch (err) {
|
||||
if (signal.aborted) break;
|
||||
throw err;
|
||||
}
|
||||
extracted += r.extracted;
|
||||
skipped += r.skipped;
|
||||
batches++;
|
||||
deps.onBatch?.({ batch: batches, extracted: r.extracted, remaining: before });
|
||||
|
||||
// A deadline abort inside the batch can surface as zero progress;
|
||||
// window exhaustion wins over the generic no_progress label.
|
||||
if (deps.now() >= deadline || signal.aborted) break;
|
||||
|
||||
// Stop if a batch made zero forward progress — extraction is failing or
|
||||
// everything left is ineligible (e.g. all skipped). Prevents a hot loop
|
||||
// that spends budget without draining.
|
||||
if (r.extracted === 0 && r.skipped === 0) { stopped = 'no_progress'; break; }
|
||||
}
|
||||
|
||||
const remaining = await deps.countRemaining();
|
||||
// After the window elapsed, don't spend more unbounded time on a final
|
||||
// count — report remaining as unknown instead of overrunning further.
|
||||
const windowElapsed = signal.aborted || deps.now() >= deadline;
|
||||
let remaining: number | null = null;
|
||||
if (!windowElapsed) {
|
||||
try {
|
||||
remaining = await deps.countRemaining(signal);
|
||||
} catch (err) {
|
||||
if (!signal.aborted) throw err;
|
||||
}
|
||||
}
|
||||
if (remaining === 0) stopped = 'drained';
|
||||
return { phase: 'extract_atoms', status: 'ok', extracted, skipped, remaining, batches, stopped };
|
||||
});
|
||||
}, signal);
|
||||
// Internal window expiry is a normal partial result. An EXTERNAL abort
|
||||
// (worker cancel/timeout/shutdown) must reject so Minion records the abort.
|
||||
if (opts.abortSignal?.aborted) throw opts.abortSignal.reason;
|
||||
return result;
|
||||
}
|
||||
|
||||
// ─── Shared wiring helper (v0.42.x #1685 DECISION 5A) ──────────────────────
|
||||
@@ -134,6 +184,8 @@ export interface DrainForSourceOpts {
|
||||
maxBatches?: number;
|
||||
/** Optional per-batch progress sink (stderr line in dream; job progress in the handler). */
|
||||
onBatch?: ExtractAtomsDrainDeps['onBatch'];
|
||||
/** Worker cancellation / shutdown signal (Minion `job.signal`). */
|
||||
abortSignal?: AbortSignal;
|
||||
}
|
||||
|
||||
export async function runExtractAtomsDrainForSource(
|
||||
@@ -149,12 +201,17 @@ export async function runExtractAtomsDrainForSource(
|
||||
|
||||
return runExtractAtomsDrain(
|
||||
{
|
||||
withLock: (work) => withRefreshingLock(engine, lockId, work, { ttlMinutes: 5 }),
|
||||
runBatch: async () => {
|
||||
withLock: (work, signal) => withRefreshingLock(engine, lockId, work, {
|
||||
ttlMinutes: 5,
|
||||
signal,
|
||||
releaseTimeoutMs: LOCK_RELEASE_GRACE_MS,
|
||||
}),
|
||||
runBatch: async (signal) => {
|
||||
const r = await runPhaseExtractAtoms(engine, {
|
||||
sourceId: extractionSourceId,
|
||||
dryRun: false,
|
||||
brainDir: opts.brainDir,
|
||||
abortSignal: signal,
|
||||
});
|
||||
const d = (r.details ?? {}) as Record<string, unknown>;
|
||||
return {
|
||||
@@ -162,10 +219,14 @@ export async function runExtractAtomsDrainForSource(
|
||||
skipped: Number(d.duplicates_skipped ?? 0),
|
||||
};
|
||||
},
|
||||
countRemaining: () => countExtractAtomsBacklog(engine, extractionSourceId),
|
||||
countRemaining: (signal) => countExtractAtomsBacklog(engine, extractionSourceId, signal),
|
||||
now: Date.now,
|
||||
onBatch: opts.onBatch,
|
||||
},
|
||||
{ windowMs: opts.windowSeconds * 1000, maxBatches: opts.maxBatches },
|
||||
{
|
||||
windowMs: opts.windowSeconds * 1000,
|
||||
maxBatches: opts.maxBatches,
|
||||
abortSignal: opts.abortSignal,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -58,6 +58,10 @@ import { createHash } from 'crypto';
|
||||
import { slugifySegment } from '../sync.ts';
|
||||
|
||||
const DEFAULT_BUDGET_USD = 0.3;
|
||||
// #2750: fresh wallclock budget for the receipt/rollup bookkeeping writes when
|
||||
// the caller's deadline already fired — committed atoms must not lose their
|
||||
// cost/receipt trail, but the writes can't be unbounded either.
|
||||
const BOOKKEEPING_GRACE_MS = 5_000;
|
||||
|
||||
// v0.42+ TODO: read atom_type enum from active pack manifest at runtime.
|
||||
const ATOM_TYPES = [
|
||||
@@ -155,6 +159,13 @@ export interface ExtractAtomsOpts {
|
||||
* `heartbeat()` on the passed reporter.
|
||||
*/
|
||||
progress?: ProgressReporter;
|
||||
/**
|
||||
* #2750: caller deadline/cancellation. Forwarded to every gateway call and
|
||||
* DB query/write so the drain window bounds real lifetime, plus a
|
||||
* cooperative between-item check (the PGLite path, where query abort only
|
||||
* abandons the waiter).
|
||||
*/
|
||||
abortSignal?: AbortSignal;
|
||||
}
|
||||
|
||||
interface ExtractedAtom {
|
||||
@@ -212,6 +223,7 @@ export async function discoverExtractablePages(
|
||||
engine: BrainEngine,
|
||||
sourceId: string,
|
||||
affectedSlugs?: string[],
|
||||
abortSignal?: AbortSignal,
|
||||
): Promise<DiscoveredPage[]> {
|
||||
const hasFilter = Array.isArray(affectedSlugs) && affectedSlugs.length > 0;
|
||||
const sql = `
|
||||
@@ -251,13 +263,16 @@ export async function discoverExtractablePages(
|
||||
slug: string;
|
||||
compiled_truth: string;
|
||||
content_hash: string;
|
||||
}>(sql, params);
|
||||
}>(sql, params, { signal: abortSignal });
|
||||
return rows.map((r) => ({
|
||||
slug: r.slug,
|
||||
content: r.compiled_truth,
|
||||
contentHash: r.content_hash,
|
||||
}));
|
||||
} catch (err) {
|
||||
// A deadline abort is not a fail-soft condition — propagate so the
|
||||
// caller stops instead of proceeding with an empty page list.
|
||||
if (abortSignal?.aborted) throw err;
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
console.error(`[extract_atoms] page-discovery query failed: ${msg}`);
|
||||
return []; // fail-soft: transcript path still proceeds
|
||||
@@ -282,6 +297,7 @@ export async function discoverExtractablePages(
|
||||
export async function countExtractAtomsBacklog(
|
||||
engine: BrainEngine,
|
||||
sourceId?: string,
|
||||
abortSignal?: AbortSignal,
|
||||
): Promise<number | null> {
|
||||
try {
|
||||
// Two modes: scoped (the phase's per-source `remaining`) vs brain-wide
|
||||
@@ -321,9 +337,10 @@ export async function countExtractAtomsBacklog(
|
||||
const params = scoped
|
||||
? [sourceId, extractableTypes, MIN_PAGE_CHARS_FOR_EXTRACTION]
|
||||
: [extractableTypes, MIN_PAGE_CHARS_FOR_EXTRACTION];
|
||||
const rows = await engine.executeRaw<{ cnt: string | number }>(sql, params);
|
||||
const rows = await engine.executeRaw<{ cnt: string | number }>(sql, params, { signal: abortSignal });
|
||||
return Number(rows[0]?.cnt ?? 0);
|
||||
} catch (err) {
|
||||
if (abortSignal?.aborted) throw err;
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
console.error(`[extract_atoms] backlog count failed: ${msg}`);
|
||||
return null;
|
||||
@@ -350,6 +367,7 @@ export async function atomsExistingForHashes(
|
||||
engine: BrainEngine,
|
||||
sourceId: string,
|
||||
contentHash16s: string[],
|
||||
abortSignal?: AbortSignal,
|
||||
): Promise<Set<string>> {
|
||||
if (contentHash16s.length === 0) return new Set();
|
||||
try {
|
||||
@@ -361,9 +379,11 @@ export async function atomsExistingForHashes(
|
||||
AND deleted_at IS NULL
|
||||
AND frontmatter->>'source_hash' = ANY($2::text[])`,
|
||||
[sourceId, contentHash16s],
|
||||
{ signal: abortSignal },
|
||||
);
|
||||
return new Set(rows.map(r => r.h));
|
||||
} catch (err) {
|
||||
if (abortSignal?.aborted) throw err;
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
console.error(`[extract_atoms] batch idempotency check failed (assuming none extracted): ${msg}`);
|
||||
return new Set();
|
||||
@@ -384,6 +404,7 @@ export async function runPhaseExtractAtoms(
|
||||
): Promise<PhaseResult> {
|
||||
const sourceId = opts.sourceId ?? 'default';
|
||||
const chat = opts._chat ?? gatewayChat;
|
||||
if (opts.abortSignal?.aborted) throw opts.abortSignal.reason;
|
||||
|
||||
// 1a. Get transcripts (test seam OR production discovery).
|
||||
// v0.41.2.1: config loader switched to loadConfigWithEngine() so the
|
||||
@@ -425,7 +446,7 @@ export async function runPhaseExtractAtoms(
|
||||
if (opts._pages !== undefined) {
|
||||
pages = opts._pages;
|
||||
} else {
|
||||
pages = await discoverExtractablePages(engine, sourceId, opts.affectedSlugs);
|
||||
pages = await discoverExtractablePages(engine, sourceId, opts.affectedSlugs, opts.abortSignal);
|
||||
}
|
||||
|
||||
// 2. Apply transcript-side source-hash idempotency in ONE batch query
|
||||
@@ -437,7 +458,7 @@ export async function runPhaseExtractAtoms(
|
||||
// Surface a heartbeat before the batch query so even an instant
|
||||
// short-circuit shows a sign of life (closes Issue 2 silent-phase pain).
|
||||
opts.progress?.heartbeat(`checking existing atoms for ${allHashes16.length} transcripts`);
|
||||
const existingHashes = await atomsExistingForHashes(engine, sourceId, allHashes16);
|
||||
const existingHashes = await atomsExistingForHashes(engine, sourceId, allHashes16, opts.abortSignal);
|
||||
for (const t of transcripts) {
|
||||
if (existingHashes.has(t.contentHash.slice(0, 16))) {
|
||||
duplicatesSkipped++;
|
||||
@@ -501,6 +522,7 @@ export async function runPhaseExtractAtoms(
|
||||
const failures: Array<{ source: string; error: string }> = [];
|
||||
let estimatedSpendUsd = 0;
|
||||
const budgetCap = DEFAULT_BUDGET_USD;
|
||||
let deadlineAborted = false;
|
||||
|
||||
// v0.41.19.0 (T3): throttled yield helper. Fires `opts.yieldDuringPhase`
|
||||
// every 30s. Cycle.ts threads `buildYieldDuringPhase(lock, outer)` so
|
||||
@@ -526,6 +548,12 @@ export async function runPhaseExtractAtoms(
|
||||
}
|
||||
|
||||
for (const item of work) {
|
||||
// #2750: cooperative between-item abort. Works on every engine — this is
|
||||
// the primary bound on PGLite, where query abort only abandons the waiter.
|
||||
if (opts.abortSignal?.aborted) {
|
||||
deadlineAborted = true;
|
||||
break;
|
||||
}
|
||||
await maybeYield();
|
||||
if (estimatedSpendUsd >= budgetCap) {
|
||||
if (item.kind === 'transcript') transcriptsSkipped++;
|
||||
@@ -544,16 +572,22 @@ export async function runPhaseExtractAtoms(
|
||||
},
|
||||
],
|
||||
maxTokens: 2000,
|
||||
abortSignal: opts.abortSignal,
|
||||
});
|
||||
// Rough cost estimate — Haiku at ~$0.80/M input + $4/M output.
|
||||
// A completed gateway call is billable even if the deadline fires
|
||||
// immediately afterward, so record usage BEFORE the abort check.
|
||||
estimatedSpendUsd +=
|
||||
(result.usage.input_tokens * 0.8 + result.usage.output_tokens * 4.0) / 1_000_000;
|
||||
if (opts.abortSignal?.aborted) {
|
||||
deadlineAborted = true;
|
||||
break;
|
||||
}
|
||||
// Post-await yield: closes the "long LLM call past TTL" hazard
|
||||
// codex flagged. The 30s throttle inside maybeYield bounds the
|
||||
// actual refresh rate so this is cheap when calls are fast.
|
||||
await maybeYield();
|
||||
|
||||
// Rough cost estimate — Haiku at ~$0.80/M input + $4/M output
|
||||
estimatedSpendUsd +=
|
||||
(result.usage.input_tokens * 0.8 + result.usage.output_tokens * 4.0) / 1_000_000;
|
||||
|
||||
const atoms = parseAtomsResponse(result.text);
|
||||
if (atoms.length === 0) {
|
||||
if (item.kind === 'transcript') transcriptsProcessed++;
|
||||
@@ -592,7 +626,7 @@ export async function runPhaseExtractAtoms(
|
||||
},
|
||||
timeline: '',
|
||||
},
|
||||
{ sourceId },
|
||||
{ sourceId, signal: opts.abortSignal },
|
||||
);
|
||||
totalAtomsExtracted++;
|
||||
}
|
||||
@@ -605,6 +639,11 @@ export async function runPhaseExtractAtoms(
|
||||
// Reporter rate-limits to ~1 line/sec; safe to tick every iter.
|
||||
opts.progress?.tick(1, `${totalAtomsExtracted} atoms / ${duplicatesSkipped} skipped`);
|
||||
} catch (err) {
|
||||
// A deadline abort is a partial result, not a per-item failure.
|
||||
if (opts.abortSignal?.aborted) {
|
||||
deadlineAborted = true;
|
||||
break;
|
||||
}
|
||||
failures.push({
|
||||
source: originLabel,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
@@ -615,6 +654,12 @@ export async function runPhaseExtractAtoms(
|
||||
// v0.42 Wave B2: write extract receipt + rollup row when the phase
|
||||
// actually extracted atoms. Both are best-effort per F-OUT-19 —
|
||||
// audit-trail / search-visibility surfaces don't block the phase result.
|
||||
//
|
||||
// #2750: bookkeeping runs on a FRESH short grace signal, never the caller's
|
||||
// work deadline — the deadline may have already fired (partial run) and
|
||||
// committed atoms must not lose their receipt/cost trail; but the writes
|
||||
// stay bounded so the overrun is capped at the grace window.
|
||||
const bookkeepingSignal = opts.dryRun ? undefined : AbortSignal.timeout(BOOKKEEPING_GRACE_MS);
|
||||
if (!opts.dryRun && totalAtomsExtracted > 0) {
|
||||
const runId = `atoms-${Date.now().toString(36)}-${sourceId.slice(0, 4)}`;
|
||||
try {
|
||||
@@ -629,19 +674,24 @@ export async function runPhaseExtractAtoms(
|
||||
summary:
|
||||
`Extracted ${totalAtomsExtracted} atoms from ` +
|
||||
`${transcriptsProcessed} transcripts + ${pagesProcessed} pages.`,
|
||||
});
|
||||
}, { signal: bookkeepingSignal });
|
||||
} catch (err) {
|
||||
console.error(`[extract_atoms] receipt write failed: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
if (!opts.dryRun) {
|
||||
await upsertExtractRollup(engine, {
|
||||
kind: 'atoms',
|
||||
source_id: sourceId,
|
||||
cost_delta: estimatedSpendUsd,
|
||||
round_completed_delta: failures.length === 0 ? 1 : 0,
|
||||
halt_delta: failures.length > 0 ? 1 : 0,
|
||||
});
|
||||
try {
|
||||
await upsertExtractRollup(engine, {
|
||||
kind: 'atoms',
|
||||
source_id: sourceId,
|
||||
cost_delta: estimatedSpendUsd,
|
||||
// A deadline-truncated run is not a completed round.
|
||||
round_completed_delta: failures.length === 0 && !deadlineAborted ? 1 : 0,
|
||||
halt_delta: failures.length > 0 ? 1 : 0,
|
||||
}, { signal: bookkeepingSignal });
|
||||
} catch (err) {
|
||||
console.error(`[extract_atoms] rollup write failed: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -670,6 +720,7 @@ export async function runPhaseExtractAtoms(
|
||||
budget_usd: budgetCap,
|
||||
source_id: sourceId,
|
||||
dry_run: opts.dryRun ?? false,
|
||||
deadline_aborted: deadlineAborted,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
+50
-22
@@ -26,7 +26,8 @@ import type { BrainEngine } from './engine.ts';
|
||||
|
||||
export interface DbLockHandle {
|
||||
id: string;
|
||||
release: () => Promise<void>;
|
||||
/** Optional signal bounds the release DELETE (deadline-bound callers). */
|
||||
release: (signal?: AbortSignal) => Promise<void>;
|
||||
refresh: () => Promise<void>;
|
||||
}
|
||||
|
||||
@@ -173,6 +174,7 @@ export async function tryAcquireDbLock(
|
||||
engine: BrainEngine,
|
||||
lockId: string,
|
||||
ttlMinutes: number = DEFAULT_TTL_MINUTES,
|
||||
opts: { signal?: AbortSignal } = {},
|
||||
): Promise<DbLockHandle | null> {
|
||||
const pid = process.pid;
|
||||
const host = hostname();
|
||||
@@ -205,20 +207,26 @@ export async function tryAcquireDbLock(
|
||||
// `gbrain sync --break-lock --max-age <s>` uses last_refreshed_at (not
|
||||
// acquired_at) to identify wedged-but-alive holders without stealing
|
||||
// healthy long-running holders that are actively refreshing.
|
||||
const rows: Array<{ id: string }> = await sql`
|
||||
INSERT INTO gbrain_cycle_locks (id, holder_pid, holder_host, acquired_at, ttl_expires_at, last_refreshed_at)
|
||||
VALUES (${lockId}, ${pid}, ${host}, NOW(), NOW() + ${ttl}::interval, NOW())
|
||||
ON CONFLICT (id) DO UPDATE
|
||||
SET holder_pid = ${pid},
|
||||
holder_host = ${host},
|
||||
acquired_at = NOW(),
|
||||
ttl_expires_at = NOW() + ${ttl}::interval,
|
||||
last_refreshed_at = NOW()
|
||||
WHERE gbrain_cycle_locks.ttl_expires_at < NOW()
|
||||
AND (gbrain_cycle_locks.last_refreshed_at IS NULL
|
||||
OR gbrain_cycle_locks.last_refreshed_at < NOW() - ${stealGraceSeconds} * INTERVAL '1 second')
|
||||
RETURNING id
|
||||
`;
|
||||
// #2750: routed through executeRaw so a deadline-bound caller's signal
|
||||
// can cancel a hung acquire (pool exhaustion). Cancellation is
|
||||
// transactional; in the rare ambiguous-commit case the row's TTL is the
|
||||
// backstop (drain locks use a short 5-minute TTL).
|
||||
const rows = await engine.executeRaw<{ id: string }>(
|
||||
`INSERT INTO gbrain_cycle_locks (id, holder_pid, holder_host, acquired_at, ttl_expires_at, last_refreshed_at)
|
||||
VALUES ($1, $2, $3, NOW(), NOW() + $4::interval, NOW())
|
||||
ON CONFLICT (id) DO UPDATE
|
||||
SET holder_pid = $2,
|
||||
holder_host = $3,
|
||||
acquired_at = NOW(),
|
||||
ttl_expires_at = NOW() + $4::interval,
|
||||
last_refreshed_at = NOW()
|
||||
WHERE gbrain_cycle_locks.ttl_expires_at < NOW()
|
||||
AND (gbrain_cycle_locks.last_refreshed_at IS NULL
|
||||
OR gbrain_cycle_locks.last_refreshed_at < NOW() - $5 * INTERVAL '1 second')
|
||||
RETURNING id`,
|
||||
[lockId, pid, host, ttl, stealGraceSeconds],
|
||||
{ signal: opts.signal },
|
||||
);
|
||||
if (rows.length === 0) return null;
|
||||
const deregister = registerCleanup(`db-lock:${lockId}`, async () => {
|
||||
await sql`
|
||||
@@ -241,12 +249,17 @@ export async function tryAcquireDbLock(
|
||||
[ttl, lockId, pid],
|
||||
);
|
||||
},
|
||||
release: async () => {
|
||||
release: async (signal?: AbortSignal) => {
|
||||
deregister();
|
||||
await sql`
|
||||
DELETE FROM gbrain_cycle_locks
|
||||
WHERE id = ${lockId} AND holder_pid = ${pid}
|
||||
`;
|
||||
// Direct session pool (same rationale as refresh, #1794) + optional
|
||||
// signal so a deadline-bound caller's release can't hang forever on
|
||||
// an exhausted pooler. TTL is the backstop if the DELETE is cancelled.
|
||||
await engine.executeRawDirect(
|
||||
`DELETE FROM gbrain_cycle_locks
|
||||
WHERE id = $1 AND holder_pid = $2`,
|
||||
[lockId, pid],
|
||||
{ signal },
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -303,6 +316,11 @@ export async function tryAcquireDbLock(
|
||||
const first = await acquireOnce();
|
||||
if (first) return first;
|
||||
|
||||
// #2750: deadline-bound callers prefer an honest busy result over the
|
||||
// best-effort same-host takeover below, whose inspect/delete/retry calls
|
||||
// are not signal-bounded. The initial upsert already reclaims expired locks.
|
||||
if (opts.signal) return null;
|
||||
|
||||
// v0.42 (#1780 Gap 3): the lock is held and its TTL hasn't expired (the
|
||||
// upsert's ON CONFLICT ... WHERE ttl_expires_at < NOW() returned no row).
|
||||
// If the holder is on THIS host, provably dead, and past the grace window,
|
||||
@@ -796,6 +814,10 @@ export interface WithRefreshingLockOpts {
|
||||
ttlMinutes?: number;
|
||||
/** Heartbeat-fail threshold in ms — abort if SELECT 1 takes longer. Default 30000. */
|
||||
heartbeatTimeoutMs?: number;
|
||||
/** #2750: bound lock acquisition with the caller's deadline signal. */
|
||||
signal?: AbortSignal;
|
||||
/** Fresh cleanup budget for the release DELETE when `signal` is set. Default 5000. */
|
||||
releaseTimeoutMs?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -815,7 +837,7 @@ export async function withRefreshingLock<T>(
|
||||
// Refresh 6x per TTL window so a missed tick doesn't expire the lock.
|
||||
const refreshIntervalMs = Math.max(15000, (ttlMinutes * 60 * 1000) / 6);
|
||||
|
||||
const handle = await tryAcquireDbLock(engine, lockId, ttlMinutes);
|
||||
const handle = await tryAcquireDbLock(engine, lockId, ttlMinutes, { signal: opts.signal });
|
||||
if (!handle) throw new LockUnavailableError(lockId);
|
||||
|
||||
let healthOk = true;
|
||||
@@ -854,7 +876,13 @@ export async function withRefreshingLock<T>(
|
||||
return await work();
|
||||
} finally {
|
||||
clearInterval(interval);
|
||||
try { await handle.release(); } catch { /* idempotent */ }
|
||||
// #2750: when the caller is deadline-bound, its work signal may already
|
||||
// have fired — release on a FRESH short grace signal so cleanup neither
|
||||
// inherits the spent deadline nor hangs unbounded. TTL is the backstop.
|
||||
const releaseSignal = opts.signal
|
||||
? AbortSignal.timeout(opts.releaseTimeoutMs ?? 5_000)
|
||||
: undefined;
|
||||
try { await handle.release(releaseSignal); } catch { /* idempotent; TTL backstop */ }
|
||||
if (!healthOk) {
|
||||
// Surface that the heartbeat detected backend trouble — caller can
|
||||
// log to the connection-events audit if desired.
|
||||
|
||||
+9
-1
@@ -696,8 +696,16 @@ export interface BrainEngine {
|
||||
* is included in the INSERT column list so ON CONFLICT (source_id, slug)
|
||||
* DO UPDATE actually targets the intended row instead of fabricating a
|
||||
* duplicate at (default, slug). Multi-source brains MUST pass sourceId.
|
||||
*
|
||||
* `opts.signal` (#2750): optional cancellation for deadline-bound writers.
|
||||
* Postgres cancels the in-flight statement; PGLite pre-checks only (query
|
||||
* cancellation is not possible in-process — cooperative abort between calls).
|
||||
*/
|
||||
putPage(slug: string, page: PageInput, opts?: { sourceId?: string }): Promise<Page>;
|
||||
putPage(
|
||||
slug: string,
|
||||
page: PageInput,
|
||||
opts?: { sourceId?: string; signal?: AbortSignal },
|
||||
): Promise<Page>;
|
||||
/**
|
||||
* v0.41.13 (#1309) — identity-based dedup pre-check for the import pipeline.
|
||||
*
|
||||
|
||||
@@ -187,6 +187,7 @@ function buildReceiptFrontmatter(input: ExtractReceiptInput): Record<string, unk
|
||||
export async function writeReceipt(
|
||||
engine: BrainEngine,
|
||||
input: ExtractReceiptInput,
|
||||
opts?: { signal?: AbortSignal },
|
||||
): Promise<{ slug: string; page: Page }> {
|
||||
const slug = receiptSlug(input);
|
||||
const title = `${input.kind} — ${input.round} — ${input.source_id}`;
|
||||
@@ -201,7 +202,7 @@ export async function writeReceipt(
|
||||
compiled_truth,
|
||||
frontmatter,
|
||||
},
|
||||
{ sourceId: input.source_id },
|
||||
{ sourceId: input.source_id, signal: opts?.signal },
|
||||
);
|
||||
|
||||
return { slug, page };
|
||||
|
||||
@@ -70,6 +70,7 @@ function today(): string {
|
||||
export async function upsertExtractRollup(
|
||||
engine: BrainEngine,
|
||||
input: RollupUpsertInput,
|
||||
opts?: { signal?: AbortSignal },
|
||||
): Promise<{ ok: boolean; error?: string }> {
|
||||
const day = input.day ?? today();
|
||||
const cost = input.cost_delta ?? 0;
|
||||
@@ -96,9 +97,12 @@ export async function upsertExtractRollup(
|
||||
rollup_write_failures = extract_rollup_7d.rollup_write_failures + EXCLUDED.rollup_write_failures,
|
||||
updated_at = now()`,
|
||||
[input.kind, input.source_id, day, cost, halts, evalFails, evalPasses, completed, failures],
|
||||
{ signal: opts?.signal },
|
||||
);
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
// Signal-bounded callers get the abort surfaced, not a swallowed `ok:false`.
|
||||
if (opts?.signal?.aborted) throw err;
|
||||
const msg = (err as Error).message || String(err);
|
||||
// Don't spam: log once per process per (kind, day) error class.
|
||||
rollupErrorLogOnce(input.kind, day, msg);
|
||||
|
||||
@@ -1003,7 +1003,15 @@ export class PGLiteEngine implements BrainEngine {
|
||||
return { slug: r.slug, id: Number(r.id) };
|
||||
}
|
||||
|
||||
async putPage(slug: string, page: PageInput, opts?: { sourceId?: string }): Promise<Page> {
|
||||
async putPage(
|
||||
slug: string,
|
||||
page: PageInput,
|
||||
opts?: { sourceId?: string; signal?: AbortSignal },
|
||||
): Promise<Page> {
|
||||
// #2750: PGLite is in-process WASM — no query cancellation. Pre-check so
|
||||
// an already-fired deadline skips the write; abort is cooperative
|
||||
// between calls (same posture as executeRaw's documented gap).
|
||||
if (opts?.signal?.aborted) throw new DOMException('aborted', 'AbortError');
|
||||
slug = validateSlug(slug);
|
||||
const hash = page.content_hash || contentHash(page);
|
||||
const frontmatter = page.frontmatter || {};
|
||||
|
||||
@@ -72,6 +72,32 @@ function escapeSqlStringLiteral(value: string): string {
|
||||
return value.replace(/'/g, "''");
|
||||
}
|
||||
|
||||
/**
|
||||
* #2750: race a promise against an AbortSignal, detaching the listener once
|
||||
* settled (long-lived drain signals are reused across many calls, so a bare
|
||||
* Promise.race would leak one listener per call). The abandoned promise keeps
|
||||
* running; used only for pool-acquisition waits where that is harmless.
|
||||
*/
|
||||
function waitForSignal<T>(work: Promise<T>, signal?: AbortSignal): Promise<T> {
|
||||
if (!signal) return work;
|
||||
if (signal.aborted) return Promise.reject(new DOMException('aborted', 'AbortError'));
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
let settled = false;
|
||||
const finish = (fn: () => void) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
signal.removeEventListener('abort', onAbort);
|
||||
fn();
|
||||
};
|
||||
const onAbort = () => finish(() => reject(new DOMException('aborted', 'AbortError')));
|
||||
signal.addEventListener('abort', onAbort, { once: true });
|
||||
work.then(
|
||||
(value) => finish(() => resolve(value)),
|
||||
(err) => finish(() => reject(err)),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export function getPostgresSchema(
|
||||
dims: number = DEFAULT_EMBEDDING_DIMENSIONS,
|
||||
model: string = DEFAULT_EMBEDDING_MODEL,
|
||||
@@ -1061,7 +1087,12 @@ export class PostgresEngine implements BrainEngine {
|
||||
});
|
||||
}
|
||||
|
||||
async putPage(slug: string, page: PageInput, opts?: { sourceId?: string }): Promise<Page> {
|
||||
async putPage(
|
||||
slug: string,
|
||||
page: PageInput,
|
||||
opts?: { sourceId?: string; signal?: AbortSignal },
|
||||
): Promise<Page> {
|
||||
if (opts?.signal?.aborted) throw new DOMException('aborted', 'AbortError');
|
||||
slug = validateSlug(slug);
|
||||
const sql = this.sql;
|
||||
const hash = page.content_hash || contentHash(page);
|
||||
@@ -1096,7 +1127,7 @@ export class PostgresEngine implements BrainEngine {
|
||||
const sourceUri = page.source_uri ?? null;
|
||||
const ingestedVia = page.ingested_via ?? null;
|
||||
const ingestedAt = (sourceKind || sourceUri || ingestedVia) ? new Date() : null;
|
||||
const rows = await sql`
|
||||
const pending = sql`
|
||||
INSERT INTO pages (source_id, slug, type, page_kind, title, compiled_truth, timeline, frontmatter, content_hash, updated_at, effective_date, effective_date_source, import_filename, chunker_version, source_path, source_kind, source_uri, ingested_via, ingested_at)
|
||||
VALUES (${sourceId}, ${slug}, ${page.type}, ${pageKind}, ${page.title}, ${page.compiled_truth}, ${page.timeline || ''}, ${sql.json(frontmatter as Parameters<typeof sql.json>[0])}, ${hash}, now(), ${effectiveDate}, ${effectiveDateSource}, ${importFilename}, COALESCE(${chunkerVersion}::smallint, ${MARKDOWN_CHUNKER_VERSION}), ${sourcePath}, ${sourceKind}, ${sourceUri}, ${ingestedVia}, ${ingestedAt})
|
||||
ON CONFLICT (source_id, slug) DO UPDATE SET
|
||||
@@ -1119,6 +1150,22 @@ export class PostgresEngine implements BrainEngine {
|
||||
ingested_at = COALESCE(EXCLUDED.ingested_at, pages.ingested_at)
|
||||
RETURNING id, source_id, slug, type, title, compiled_truth, timeline, frontmatter, content_hash, created_at, updated_at, effective_date, effective_date_source, import_filename, source_kind, source_uri, ingested_via, ingested_at
|
||||
`;
|
||||
// #2750: cancel the in-flight statement when the caller's deadline fires,
|
||||
// same .cancel() wiring as runUnsafe (postgres.js pending queries).
|
||||
if (opts?.signal) {
|
||||
const signal = opts.signal;
|
||||
const onAbort = () => {
|
||||
try { (pending as unknown as { cancel?: () => void }).cancel?.(); } catch { /* best-effort */ }
|
||||
};
|
||||
signal.addEventListener('abort', onAbort, { once: true });
|
||||
try {
|
||||
const rows = await pending;
|
||||
return rowToPage(rows[0]);
|
||||
} finally {
|
||||
signal.removeEventListener('abort', onAbort);
|
||||
}
|
||||
}
|
||||
const rows = await pending;
|
||||
return rowToPage(rows[0]);
|
||||
}
|
||||
|
||||
@@ -5807,11 +5854,16 @@ export class PostgresEngine implements BrainEngine {
|
||||
params?: unknown[],
|
||||
opts?: { signal?: AbortSignal },
|
||||
): Promise<T[]> {
|
||||
// #2750: an already-fired signal short-circuits BEFORE any pool routing,
|
||||
// and the direct-pool acquisition itself is signal-bounded — under pooler
|
||||
// exhaustion `ddl()` can stall indefinitely, which used to make even a
|
||||
// "bounded" lock release hang past its caller's deadline.
|
||||
if (opts?.signal?.aborted) throw new DOMException('aborted', 'AbortError');
|
||||
// Inside an open transaction, _sql is the reserved tx connection (set via
|
||||
// defineProperty in transaction()); never reroute off it.
|
||||
const inTransaction = this._sql !== null && this.connectionManager?.peekReadPool() !== this._sql;
|
||||
const conn = (!inTransaction && this.connectionManager?.isDualPoolActive())
|
||||
? await this.connectionManager.ddl()
|
||||
? await waitForSignal(this.connectionManager.ddl(), opts?.signal)
|
||||
: this.sql;
|
||||
return this.runUnsafe<T>(conn, sql, params, opts);
|
||||
}
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { normalizeAdminClientSourceScope } from '../src/core/admin-source-scope.ts';
|
||||
|
||||
describe('admin register-client source scope normalization', () => {
|
||||
const sources = ['default', 'team-a', 'team-b'];
|
||||
|
||||
test('defaults write and read scope to default source', () => {
|
||||
expect(normalizeAdminClientSourceScope(undefined, undefined, sources)).toEqual({
|
||||
sourceId: 'default',
|
||||
federatedRead: ['default'],
|
||||
});
|
||||
});
|
||||
|
||||
test('accepts snake-case body fields with multiple read sources', () => {
|
||||
expect(normalizeAdminClientSourceScope('team-a', ['team-a', 'team-b'], sources)).toEqual({
|
||||
sourceId: 'team-a',
|
||||
federatedRead: ['team-a', 'team-b'],
|
||||
});
|
||||
});
|
||||
|
||||
test('accepts comma-separated federated_read for API callers', () => {
|
||||
expect(normalizeAdminClientSourceScope('team-a', 'team-b, default,team-b', sources)).toEqual({
|
||||
sourceId: 'team-a',
|
||||
federatedRead: ['team-b', 'default'],
|
||||
});
|
||||
});
|
||||
|
||||
test('falls back to selected write source when federated_read is empty', () => {
|
||||
expect(normalizeAdminClientSourceScope('team-b', [], sources)).toEqual({
|
||||
sourceId: 'team-b',
|
||||
federatedRead: ['team-b'],
|
||||
});
|
||||
});
|
||||
|
||||
test('rejects malformed source ids before registration', () => {
|
||||
expect(() => normalizeAdminClientSourceScope('../secret', undefined, sources)).toThrow(/Invalid source_id/);
|
||||
expect(() => normalizeAdminClientSourceScope('team-a', ['snake_case'], sources)).toThrow(/Invalid federated_read/);
|
||||
});
|
||||
|
||||
test('rejects valid-looking but unregistered source ids', () => {
|
||||
expect(() => normalizeAdminClientSourceScope('ghost', undefined, sources)).toThrow(/Unknown source_id/);
|
||||
expect(() => normalizeAdminClientSourceScope('team-a', ['ghost'], sources)).toThrow(/Unknown federated_read/);
|
||||
});
|
||||
});
|
||||
@@ -17,6 +17,7 @@ import { runPhaseExtractAtoms, parseAtomsResponse } from '../../src/core/cycle/e
|
||||
import { runPhaseSynthesizeConcepts } from '../../src/core/cycle/synthesize-concepts.ts';
|
||||
import { resetPgliteState } from '../helpers/reset-pglite.ts';
|
||||
import type { ChatResult, ChatOpts } from '../../src/core/ai/gateway.ts';
|
||||
import type { BrainEngine } from '../../src/core/engine.ts';
|
||||
|
||||
let engine: PGLiteEngine;
|
||||
|
||||
@@ -177,6 +178,180 @@ describe('v0.41 T5: runPhaseExtractAtoms via stubbed chat', () => {
|
||||
expect((result.details?.failures as unknown[]).length).toBe(1);
|
||||
});
|
||||
|
||||
// ── #2750: caller deadline bounds the phase ────────────────────────────
|
||||
|
||||
test('caller deadline aborts a hung chat before processing the next item', async () => {
|
||||
let calls = 0;
|
||||
const chat = async (opts: ChatOpts) => {
|
||||
calls++;
|
||||
return await new Promise<never>((_resolve, reject) => {
|
||||
const signal = opts.abortSignal;
|
||||
if (!signal) return reject(new Error('missing abort signal'));
|
||||
if (signal.aborted) return reject(signal.reason);
|
||||
signal.addEventListener('abort', () => reject(signal.reason), { once: true });
|
||||
});
|
||||
};
|
||||
const started = Date.now();
|
||||
const result = await runPhaseExtractAtoms(engine, {
|
||||
_transcripts: [
|
||||
{ filePath: '/hung.txt', content: 'a', contentHash: 'hung-a' },
|
||||
{ filePath: '/never.txt', content: 'b', contentHash: 'hung-b' },
|
||||
],
|
||||
_pages: [],
|
||||
_chat: chat as typeof import('../../src/core/ai/gateway.ts').chat,
|
||||
abortSignal: AbortSignal.timeout(25),
|
||||
});
|
||||
expect(Date.now() - started).toBeLessThan(2_000);
|
||||
expect(calls).toBe(1);
|
||||
expect(result.status).toBe('ok');
|
||||
expect(result.details?.deadline_aborted).toBe(true);
|
||||
expect(result.details?.atoms_extracted).toBe(0);
|
||||
expect(result.details?.failures).toEqual([]);
|
||||
});
|
||||
|
||||
test('billable chat usage is counted when the deadline fires as the response resolves', async () => {
|
||||
const controller = new AbortController();
|
||||
const chat = async (opts: ChatOpts): Promise<ChatResult> => {
|
||||
controller.abort(new DOMException('deadline', 'TimeoutError'));
|
||||
return stubChat(`[{"title":"late","atom_type":"insight","body":"b"}]`, {
|
||||
input_tokens: 1_000,
|
||||
output_tokens: 500,
|
||||
})(opts);
|
||||
};
|
||||
const result = await runPhaseExtractAtoms(engine, {
|
||||
_transcripts: [{ filePath: '/late.txt', content: 'a', contentHash: 'late' }],
|
||||
_pages: [],
|
||||
_chat: chat,
|
||||
abortSignal: controller.signal,
|
||||
});
|
||||
expect(result.details?.deadline_aborted).toBe(true);
|
||||
expect(Number(result.details?.estimated_spend_usd)).toBeGreaterThan(0);
|
||||
expect(result.details?.atoms_extracted).toBe(0);
|
||||
});
|
||||
|
||||
test('deadline after partial progress still writes receipt and incomplete rollup', async () => {
|
||||
const controller = new AbortController();
|
||||
let calls = 0;
|
||||
let notifySecondChat!: () => void;
|
||||
const secondChatStarted = new Promise<void>((resolve) => { notifySecondChat = resolve; });
|
||||
const chat = async (opts: ChatOpts): Promise<ChatResult> => {
|
||||
calls++;
|
||||
if (calls === 1) {
|
||||
return stubChat(`[{"title":"committed","atom_type":"insight","body":"b"}]`)(opts);
|
||||
}
|
||||
notifySecondChat();
|
||||
return await new Promise<never>((_resolve, reject) => {
|
||||
const signal = opts.abortSignal;
|
||||
if (!signal) return reject(new Error('missing abort signal'));
|
||||
signal.addEventListener('abort', () => reject(signal.reason), { once: true });
|
||||
});
|
||||
};
|
||||
|
||||
const pending = runPhaseExtractAtoms(engine, {
|
||||
_transcripts: [
|
||||
{ filePath: '/committed.txt', content: 'a', contentHash: 'committed-a' },
|
||||
{ filePath: '/hung.txt', content: 'b', contentHash: 'hung-b' },
|
||||
],
|
||||
_pages: [],
|
||||
_chat: chat,
|
||||
abortSignal: controller.signal,
|
||||
});
|
||||
await secondChatStarted;
|
||||
controller.abort(new DOMException('deadline', 'TimeoutError'));
|
||||
const result = await pending;
|
||||
|
||||
const atoms = await engine.executeRaw<{ n: number }>(
|
||||
`SELECT COUNT(*)::int AS n FROM pages WHERE type = 'atom'`,
|
||||
);
|
||||
const receipts = await engine.executeRaw<{ n: number }>(
|
||||
`SELECT COUNT(*)::int AS n FROM pages WHERE type = 'extract_receipt'`,
|
||||
);
|
||||
const rollups = await engine.executeRaw<{
|
||||
cost_usd: string | number;
|
||||
round_completed_count: string | number;
|
||||
}>(
|
||||
`SELECT cost_usd, round_completed_count
|
||||
FROM extract_rollup_7d
|
||||
WHERE kind = 'atoms' AND source_id = 'default'`,
|
||||
);
|
||||
expect(result.details?.deadline_aborted).toBe(true);
|
||||
expect(atoms[0].n).toBe(1);
|
||||
expect(receipts[0].n).toBe(1);
|
||||
expect(Number(rollups[0].cost_usd)).toBeGreaterThan(0);
|
||||
expect(Number(rollups[0].round_completed_count)).toBe(0);
|
||||
});
|
||||
|
||||
test('bookkeeping runs on a fresh grace signal, not the fired work deadline', async () => {
|
||||
const controller = new AbortController();
|
||||
let putCalls = 0;
|
||||
let receiptSignal: AbortSignal | undefined;
|
||||
let rollupSignal: AbortSignal | undefined;
|
||||
const signalAwareEngine = {
|
||||
executeRaw: async (sql: string, _params?: unknown[], opts?: { signal?: AbortSignal }) => {
|
||||
if (sql.includes('INSERT INTO extract_rollup_7d')) rollupSignal = opts?.signal;
|
||||
return [];
|
||||
},
|
||||
putPage: async (_slug: string, _page: unknown, opts?: { signal?: AbortSignal }) => {
|
||||
putCalls++;
|
||||
if (putCalls === 1) {
|
||||
// Atom write in flight; the work deadline fires before bookkeeping.
|
||||
controller.abort(new DOMException('work deadline', 'TimeoutError'));
|
||||
} else {
|
||||
receiptSignal = opts?.signal;
|
||||
}
|
||||
return {};
|
||||
},
|
||||
} as unknown as BrainEngine;
|
||||
|
||||
const result = await runPhaseExtractAtoms(signalAwareEngine, {
|
||||
_transcripts: [{ filePath: '/one.txt', content: 'a', contentHash: 'one' }],
|
||||
_pages: [],
|
||||
_chat: stubChat(`[{"title":"one","atom_type":"insight","body":"b"}]`),
|
||||
abortSignal: controller.signal,
|
||||
});
|
||||
|
||||
expect(result.details?.atoms_extracted).toBe(1);
|
||||
expect(putCalls).toBe(2); // atom write + receipt write
|
||||
expect(receiptSignal).toBeDefined();
|
||||
expect(receiptSignal).not.toBe(controller.signal);
|
||||
expect(receiptSignal?.aborted).toBe(false);
|
||||
expect(rollupSignal).toBe(receiptSignal);
|
||||
});
|
||||
|
||||
test('caller deadline cancels a hung atom write and stops the phase', async () => {
|
||||
const controller = new AbortController();
|
||||
let notifyWriteStarted!: () => void;
|
||||
const writeStarted = new Promise<void>((resolve) => { notifyWriteStarted = resolve; });
|
||||
let writeCalls = 0;
|
||||
const signalAwareEngine = {
|
||||
executeRaw: async () => [],
|
||||
putPage: async (_slug: string, _page: unknown, opts?: { signal?: AbortSignal }) => {
|
||||
writeCalls++;
|
||||
notifyWriteStarted();
|
||||
return await new Promise<never>((_resolve, reject) => {
|
||||
const signal = opts?.signal;
|
||||
if (!signal) return reject(new Error('missing abort signal'));
|
||||
if (signal.aborted) return reject(signal.reason);
|
||||
signal.addEventListener('abort', () => reject(signal.reason), { once: true });
|
||||
});
|
||||
},
|
||||
} as unknown as BrainEngine;
|
||||
|
||||
const pending = runPhaseExtractAtoms(signalAwareEngine, {
|
||||
_transcripts: [{ filePath: '/hung-write.txt', content: 'a', contentHash: 'hung-write' }],
|
||||
_pages: [],
|
||||
_chat: stubChat(`[{"title":"hung write","atom_type":"insight","body":"b"}]`),
|
||||
abortSignal: controller.signal,
|
||||
});
|
||||
await writeStarted;
|
||||
controller.abort(new DOMException('deadline', 'TimeoutError'));
|
||||
const result = await pending;
|
||||
|
||||
expect(writeCalls).toBe(1);
|
||||
expect(result.details?.deadline_aborted).toBe(true);
|
||||
expect(result.details?.atoms_extracted).toBe(0);
|
||||
});
|
||||
|
||||
// v0.41.2.1 regression case (D9 #14 wording): with _pages:[] and same
|
||||
// _transcripts, all PRE-EXISTING PhaseResult.details fields match
|
||||
// pre-fix values byte-for-byte. The new fields (pages_processed,
|
||||
|
||||
@@ -43,26 +43,135 @@ describe('runExtractAtomsDrain (issue #1678)', () => {
|
||||
expect(batches).toBe(3);
|
||||
});
|
||||
|
||||
it('stops at the wallclock window with remaining > 0', async () => {
|
||||
// SYNC stepping clock: now() #1 sets deadline (0+100=100); the while-check
|
||||
// then sees 50, 50 (two batches), then 999999 → past deadline → stop.
|
||||
const times = [0, 50, 50, 999_999];
|
||||
let ti = 0;
|
||||
const now = () => times[Math.min(ti++, times.length - 1)];
|
||||
it('stops at the wallclock window; remaining is unknown (no post-window count)', async () => {
|
||||
// Each batch consumes 60ms of the 100ms window: two batches fit, the
|
||||
// third boundary check sees 120 ≥ 100 and stops. #2750: after the window
|
||||
// elapses the final countRemaining is SKIPPED (it would overrun the
|
||||
// window), so remaining reports null.
|
||||
let now = 0;
|
||||
const result = await runExtractAtomsDrain(
|
||||
{
|
||||
withLock: passThroughLock,
|
||||
countRemaining: async () => 5, // never drains
|
||||
runBatch: async () => ({ extracted: 1, skipped: 0 }),
|
||||
now,
|
||||
runBatch: async () => {
|
||||
now += 60;
|
||||
return { extracted: 1, skipped: 0 };
|
||||
},
|
||||
now: () => now,
|
||||
},
|
||||
{ windowMs: 100 },
|
||||
);
|
||||
expect(result.stopped).toBe('window');
|
||||
expect(result.remaining).toBe(5);
|
||||
expect(result.remaining).toBeNull();
|
||||
expect(result.batches).toBe(2);
|
||||
});
|
||||
|
||||
it('passes one drain-level deadline signal into count and batch', async () => {
|
||||
const seen: AbortSignal[] = [];
|
||||
const controller = new AbortController();
|
||||
let now = 0;
|
||||
const result = await runExtractAtomsDrain(
|
||||
{
|
||||
withLock: passThroughLock,
|
||||
countRemaining: async (signal) => {
|
||||
seen.push(signal);
|
||||
return 5;
|
||||
},
|
||||
runBatch: async (signal) => {
|
||||
seen.push(signal);
|
||||
now = 100;
|
||||
return { extracted: 1, skipped: 0 };
|
||||
},
|
||||
now: () => now,
|
||||
},
|
||||
{ windowMs: 100, abortSignal: controller.signal },
|
||||
);
|
||||
expect(result.stopped).toBe('window');
|
||||
expect(result.batches).toBe(1);
|
||||
expect(seen.length).toBe(2);
|
||||
expect(seen[0]).toBe(seen[1]);
|
||||
// Combined (timeout + external) signal, not the raw external one.
|
||||
expect(seen[0]).not.toBe(controller.signal);
|
||||
});
|
||||
|
||||
it('aborts a hung backlog count at the window deadline and releases the lock', async () => {
|
||||
let released = false;
|
||||
const result = await runExtractAtomsDrain(
|
||||
{
|
||||
withLock: async (work) => {
|
||||
try { return await work(); }
|
||||
finally { released = true; }
|
||||
},
|
||||
// Hangs until the drain's real-time deadline signal fires (10ms).
|
||||
countRemaining: (signal) => new Promise((_resolve, reject) => {
|
||||
signal.addEventListener('abort', () => reject(signal.reason), { once: true });
|
||||
}),
|
||||
runBatch: async () => ({ extracted: 0, skipped: 0 }),
|
||||
now: () => 0, // injected clock never advances — the SIGNAL must save us
|
||||
},
|
||||
{ windowMs: 10 },
|
||||
);
|
||||
expect(result.stopped).toBe('window');
|
||||
expect(result.remaining).toBeNull();
|
||||
expect(released).toBe(true);
|
||||
});
|
||||
|
||||
it('rethrows external cancellation after releasing the lock', async () => {
|
||||
const controller = new AbortController();
|
||||
let released = false;
|
||||
const pending = runExtractAtomsDrain(
|
||||
{
|
||||
withLock: async (work) => {
|
||||
try { return await work(); }
|
||||
finally { released = true; }
|
||||
},
|
||||
countRemaining: (signal) => new Promise((_resolve, reject) => {
|
||||
signal.addEventListener('abort', () => reject(signal.reason), { once: true });
|
||||
}),
|
||||
runBatch: async () => ({ extracted: 0, skipped: 0 }),
|
||||
now: () => 0,
|
||||
},
|
||||
{ windowMs: 1_000_000, abortSignal: controller.signal },
|
||||
);
|
||||
controller.abort(new DOMException('worker timeout', 'AbortError'));
|
||||
await expect(pending).rejects.toThrow('worker timeout');
|
||||
expect(released).toBe(true);
|
||||
});
|
||||
|
||||
it('classifies a deadline-exhausted zero-progress batch as window, not no_progress', async () => {
|
||||
let now = 0;
|
||||
const result = await runExtractAtomsDrain(
|
||||
{
|
||||
withLock: passThroughLock,
|
||||
countRemaining: async () => 5,
|
||||
runBatch: async () => {
|
||||
now = 100; // batch consumed the whole window and returned nothing
|
||||
return { extracted: 0, skipped: 0 };
|
||||
},
|
||||
now: () => now,
|
||||
},
|
||||
{ windowMs: 100 },
|
||||
);
|
||||
expect(result.stopped).toBe('window');
|
||||
expect(result.batches).toBe(1);
|
||||
});
|
||||
|
||||
it('bounds a hung lock acquisition with the drain deadline signal', async () => {
|
||||
const started = Date.now();
|
||||
await expect(runExtractAtomsDrain(
|
||||
{
|
||||
withLock: (_work, signal) => new Promise((_resolve, reject) => {
|
||||
signal.addEventListener('abort', () => reject(signal.reason), { once: true });
|
||||
}),
|
||||
countRemaining: async () => 1,
|
||||
runBatch: async () => ({ extracted: 0, skipped: 0 }),
|
||||
now: Date.now,
|
||||
},
|
||||
{ windowMs: 10 },
|
||||
)).rejects.toThrow();
|
||||
expect(Date.now() - started).toBeLessThan(1_000);
|
||||
});
|
||||
|
||||
it('stops on a zero-progress batch (no hot loop)', async () => {
|
||||
let batches = 0;
|
||||
const result = await runExtractAtomsDrain(
|
||||
@@ -133,4 +242,17 @@ describe('shared wiring helper holds the cycle lock (5A)', () => {
|
||||
expect(src).toContain('cycleLockIdFor(opts.sourceId)');
|
||||
expect(src).toContain('withRefreshingLock(engine, lockId');
|
||||
});
|
||||
|
||||
// #2750: the deadline signal must reach the phase, the backlog count, AND
|
||||
// the lock wrapper — and the transcript path (brainDir) must stay wired
|
||||
// exactly as the routine callers expect (PR #2752 takeover reverted its
|
||||
// unsanctioned transcript-suppression scope change).
|
||||
it('threads the drain deadline signal through phase, count, and lock', () => {
|
||||
const jobsSrc = readFileSync(join(import.meta.dir, '../src/commands/jobs.ts'), 'utf8');
|
||||
expect(src).toContain('abortSignal: signal');
|
||||
expect(src).toContain('countExtractAtomsBacklog(engine, extractionSourceId, signal)');
|
||||
expect(src).toContain('brainDir: opts.brainDir');
|
||||
expect(src).not.toContain('_transcripts');
|
||||
expect(jobsSrc).toContain('abortSignal: job.signal');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -78,8 +78,6 @@ describe('v0.36.1.x #1077 — admin register-client supports PKCE public clients
|
||||
// (under either name) from req.body. Pin the fallback pattern so the
|
||||
// PKCE-fix regression contract stays load-bearing.
|
||||
expect(src).toMatch(/req\.body[^;]*scopes\s*\?\?\s*[^;]*scope\b/);
|
||||
expect(src).toMatch(/req\.body[^;]*source_id\s*\?\?\s*[^;]*sourceId\b/);
|
||||
expect(src).toMatch(/req\.body[^;]*federated_read\s*\?\?\s*[^;]*federatedRead\b/);
|
||||
// v0.41.3 (T4 atomicity fix, codex F4): admin endpoint now validates
|
||||
// tokenEndpointAuthMethod via the shared validator and passes it to
|
||||
// registerClientManual as a positional arg. Pre-v0.41.3 the route did
|
||||
@@ -89,7 +87,6 @@ describe('v0.36.1.x #1077 — admin register-client supports PKCE public clients
|
||||
// UPDATE block (the regex deliberately asserts the post-insert UPDATE
|
||||
// is GONE).
|
||||
expect(src).toMatch(/validateTokenEndpointAuthMethod\(tokenEndpointAuthMethod\)/);
|
||||
expect(src).toMatch(/normalizeAdminClientSourceScope\(/);
|
||||
expect(src).toMatch(/registerClientManual\([^)]*validatedAuthMethod[^)]*\)/);
|
||||
// Regression guard: post-insert UPDATE flipping client_secret_hash to
|
||||
// NULL based on a runtime check is exactly the non-atomic pattern T4
|
||||
@@ -270,26 +267,3 @@ describe('v0.42.43.0 #2095 — volunteer-events sink + cycle purge wiring (struc
|
||||
expect(src).toMatch(/purged_volunteer_events_count/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#1490 + #1036 — admin Register Agent form exposes source scope + redirect URIs', () => {
|
||||
test('Agents.tsx RegisterModal sends source_id + federated_read in the POST body (#1490)', () => {
|
||||
const src = readFileSync('admin/src/pages/Agents.tsx', 'utf8');
|
||||
expect(src).toMatch(/source_id:\s*sourceId/);
|
||||
expect(src).toMatch(/federated_read:\s*federatedRead/);
|
||||
// The source list comes from the requireAdmin-gated endpoint, not a hardcoded 'default'.
|
||||
expect(src).toMatch(/api\.sources\(\)/);
|
||||
});
|
||||
|
||||
test('serve-http.ts serves /admin/api/sources behind requireAdmin (#1490)', () => {
|
||||
const src = readFileSync('src/commands/serve-http.ts', 'utf8');
|
||||
expect(src).toMatch(/app\.get\('\/admin\/api\/sources',\s*requireAdmin/);
|
||||
});
|
||||
|
||||
test('Agents.tsx RegisterModal sends redirectUris + authorization_code grants when URIs are entered (#1036)', () => {
|
||||
const src = readFileSync('admin/src/pages/Agents.tsx', 'utf8');
|
||||
// One-per-line textarea split into an array...
|
||||
expect(src).toMatch(/redirectUris\.split\('\\n'\)/);
|
||||
// ...included in the body with the CLI's grant-type inference convention.
|
||||
expect(src).toMatch(/redirectUris:\s*uris,\s*grantTypes:\s*\['authorization_code',\s*'refresh_token'\]/);
|
||||
});
|
||||
});
|
||||
|
||||
Vendored
+9
-6
@@ -24,15 +24,18 @@ import type { BrainEngine } from '../../src/core/engine.ts';
|
||||
// Mock engine: healthCheck() calls engine.executeRaw; return empty rows so
|
||||
// the query path exercises without needing Postgres.
|
||||
//
|
||||
// #1849: start() now acquires the queue-scoped DB singleton lock via
|
||||
// tryAcquireDbLock, which uses the postgres `sql` tagged-template escape hatch.
|
||||
// The stub returns a single row from every call so acquire succeeds (length 1
|
||||
// → acquired) and refresh/release are no-ops. Each spawned runner is a fresh
|
||||
// process, so there's no cross-test lock state to clean up.
|
||||
// #1849: start() acquires the queue-scoped DB singleton lock via
|
||||
// tryAcquireDbLock. #2750 routed the acquire upsert through engine.executeRaw
|
||||
// (signal-boundable) and release through engine.executeRawDirect, so the
|
||||
// stub returns a single row from the lock upsert (length 1 → acquired) and
|
||||
// empty rows everywhere else. Each spawned runner is a fresh process, so
|
||||
// there's no cross-test lock state to clean up.
|
||||
const sqlStub = (..._args: unknown[]) => Promise.resolve([{ id: 'supervisor-lock' }]);
|
||||
const mockEngine: Partial<BrainEngine> = {
|
||||
kind: 'postgres' as const,
|
||||
executeRaw: async () => [],
|
||||
executeRaw: async (query: string) =>
|
||||
query.includes('gbrain_cycle_locks') ? [{ id: 'supervisor-lock' }] : [],
|
||||
executeRawDirect: async () => [],
|
||||
sql: sqlStub,
|
||||
} as unknown as BrainEngine;
|
||||
|
||||
|
||||
@@ -125,30 +125,6 @@ describe('client registration', () => {
|
||||
expect(client!.client_name).toBe('test-agent');
|
||||
});
|
||||
|
||||
test('registerClientManual persists source_id and federated_read into AuthInfo', async () => {
|
||||
await sql`
|
||||
INSERT INTO sources (id, name) VALUES (${'team-a'}, ${'Team A'}), (${'team-b'}, ${'Team B'})
|
||||
ON CONFLICT DO NOTHING
|
||||
`;
|
||||
const { clientId, clientSecret } = await provider.registerClientManual(
|
||||
'source-scoped-test',
|
||||
['client_credentials'],
|
||||
'read write',
|
||||
[],
|
||||
'team-a',
|
||||
['team-a', 'team-b'],
|
||||
);
|
||||
|
||||
const tokens = await provider.exchangeClientCredentials(clientId, clientSecret!, 'read');
|
||||
const authInfo = await provider.verifyAccessToken(tokens.access_token) as {
|
||||
sourceId?: string;
|
||||
allowedSources?: string[];
|
||||
};
|
||||
|
||||
expect(authInfo.sourceId).toBe('team-a');
|
||||
expect(authInfo.allowedSources).toEqual(['team-a', 'team-b']);
|
||||
});
|
||||
|
||||
test('getClient returns undefined for unknown client', async () => {
|
||||
const client = await provider.clientsStore.getClient('nonexistent');
|
||||
expect(client).toBeUndefined();
|
||||
|
||||
@@ -98,14 +98,39 @@ describe('PostgresEngine.executeRawDirect — routing decision (PR #1816)', () =
|
||||
});
|
||||
|
||||
test('already-aborted signal short-circuits with AbortError before routing the query', async () => {
|
||||
const readConn = fakeSql('read');
|
||||
let unsafeCalls = 0;
|
||||
let ddlCalls = 0;
|
||||
const readConn: FakeSql = { unsafe: async () => { unsafeCalls++; return []; } };
|
||||
const directConn = fakeSql('direct');
|
||||
const engine = makeEngine({ dualPoolActive: true, readConn, directConn });
|
||||
const e = engine as unknown as { connectionManager: { ddl: () => Promise<FakeSql> } };
|
||||
e.connectionManager.ddl = async () => { ddlCalls++; return directConn; };
|
||||
|
||||
const ac = new AbortController();
|
||||
ac.abort();
|
||||
await expect(
|
||||
engine.executeRawDirect('UPDATE minion_jobs SET x=1', [], { signal: ac.signal }),
|
||||
).rejects.toThrow(/abort/i);
|
||||
// #2750: short-circuits BEFORE pool routing — no ddl(), no unsafe().
|
||||
expect(ddlCalls).toBe(0);
|
||||
expect(unsafeCalls).toBe(0);
|
||||
});
|
||||
|
||||
test('#2750: signal bounds a stalled direct-pool acquisition before unsafe starts', async () => {
|
||||
let unsafeCalls = 0;
|
||||
const readConn: FakeSql = { unsafe: async () => { unsafeCalls++; return []; } };
|
||||
const directConn = fakeSql('direct');
|
||||
const engine = makeEngine({ dualPoolActive: true, readConn, directConn });
|
||||
const e = engine as unknown as { connectionManager: { ddl: () => Promise<FakeSql> } };
|
||||
e.connectionManager.ddl = () => new Promise<FakeSql>(() => {}); // pooler exhausted: never resolves
|
||||
|
||||
const started = Date.now();
|
||||
await expect(engine.executeRawDirect(
|
||||
'DELETE FROM gbrain_cycle_locks',
|
||||
[],
|
||||
{ signal: AbortSignal.timeout(10) },
|
||||
)).rejects.toThrow(/abort/i);
|
||||
expect(Date.now() - started).toBeLessThan(1_000);
|
||||
expect(unsafeCalls).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user