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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/lib/do-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,7 @@ export interface CreateSignalInput {
tags: string[];
signature?: string;
disclosure?: string;
agent_name?: string | null;
}

export interface CooldownInfo {
Expand Down
2 changes: 2 additions & 0 deletions src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,8 @@ export interface Signal {
readonly reviewed_at: string | null;
/** Models, tools, and skills used to produce this signal */
readonly disclosure: string;
/** Agent display name captured at filing time (nullable for older signals) */
readonly agent_name?: string | null;
/** Auto-computed quality score (0–100) assigned at submission time */
readonly quality_score: number | null;
/** Per-dimension breakdown of quality_score (parsed from JSON in DB) */
Expand Down
30 changes: 25 additions & 5 deletions src/objects/news-do.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import type { Env, Beat, Signal, SignalStatus, Streak, Brief, Classified, Classi
import { validateSlug, validateHexColor, sanitizeString, validateDateFormat } from "../lib/validators";
import { generateId, getUTCDate, getUTCYesterday, getUTCDayStart, getUTCDayEnd, getNextDate } from "../lib/helpers";
import { CLASSIFIED_DURATION_DAYS, CLASSIFIED_BRIEF_SLOTS, CLASSIFIED_BRIEF_MAX_CHARS, CLASSIFIED_STATUSES, SIGNAL_COOLDOWN_HOURS, BEAT_EXPIRY_DAYS, MAX_SIGNALS_PER_DAY, MAX_INCLUDED_SIGNALS_PER_BRIEF, MAX_APPROVED_SIGNALS_PER_DAY, SIGNAL_STATUSES, REVIEWABLE_SIGNAL_STATUSES, CONFIG_PUBLISHER_ADDRESS, BRIEF_INCLUSION_PAYOUT_SATS, WEEKLY_PRIZE_1ST_SATS, WEEKLY_PRIZE_2ND_SATS, WEEKLY_PRIZE_3RD_SATS, SCORING_WEIGHTS, PAYMENT_STAGE_TTL_MS } from "../lib/constants";
import { SCHEMA_SQL, MIGRATION_PHASE0_SQL, MIGRATION_PAYMENTS_SQL, MIGRATION_BEAT_RESTRUCTURE_SQL, MIGRATION_SBTC_TRACKING_SQL, MIGRATION_CLASSIFIEDS_CLEANUP_SQL, MIGRATION_CLASSIFIEDS_REVIEW_SQL, MIGRATION_SNAPSHOTS_SQL, MIGRATION_BEAT_CLAIMS_SQL, MIGRATION_RETRACTION_SQL, MIGRATION_BEAT_NETWORK_FOCUS_SQL, MIGRATION_BITCOIN_MACRO_SQL, MIGRATION_QUANTUM_BEAT_SQL, MIGRATION_PAYMENT_STAGING_SQL, MIGRATION_APPROVAL_CAP_INDEX_SQL, MIGRATION_BEAT_EDITORS_SQL, MIGRATION_EDITORIAL_REVIEWS_SQL, MIGRATION_EDITOR_REVIEW_RATE_SQL, MIGRATION_CURATION_CLEANUP_SQL, MIGRATION_LEADERBOARD_INDEXES_SQL, MIGRATION_BEAT_CONSOLIDATION_SQL, MIGRATION_SIGNAL_SCORING_SQL, MIGRATION_APR7_EARNINGS_SQL, MIGRATION_CLASSIFIEDS_TXID_UNIQUE_SQL, MIGRATION_SIGNAL_HOT_PATH_INDEXES_SQL, MIGRATION_CORRESPONDENTS_BUNDLE_INDEXES_SQL } from "./schema";
import { SCHEMA_SQL, MIGRATION_PHASE0_SQL, MIGRATION_PAYMENTS_SQL, MIGRATION_BEAT_RESTRUCTURE_SQL, MIGRATION_SBTC_TRACKING_SQL, MIGRATION_CLASSIFIEDS_CLEANUP_SQL, MIGRATION_CLASSIFIEDS_REVIEW_SQL, MIGRATION_SNAPSHOTS_SQL, MIGRATION_BEAT_CLAIMS_SQL, MIGRATION_RETRACTION_SQL, MIGRATION_BEAT_NETWORK_FOCUS_SQL, MIGRATION_BITCOIN_MACRO_SQL, MIGRATION_QUANTUM_BEAT_SQL, MIGRATION_PAYMENT_STAGING_SQL, MIGRATION_APPROVAL_CAP_INDEX_SQL, MIGRATION_BEAT_EDITORS_SQL, MIGRATION_EDITORIAL_REVIEWS_SQL, MIGRATION_EDITOR_REVIEW_RATE_SQL, MIGRATION_CURATION_CLEANUP_SQL, MIGRATION_LEADERBOARD_INDEXES_SQL, MIGRATION_BEAT_CONSOLIDATION_SQL, MIGRATION_SIGNAL_SCORING_SQL, MIGRATION_APR7_EARNINGS_SQL, MIGRATION_CLASSIFIEDS_TXID_UNIQUE_SQL, MIGRATION_SIGNAL_HOT_PATH_INDEXES_SQL, MIGRATION_CORRESPONDENTS_BUNDLE_INDEXES_SQL, MIGRATION_AGENT_NAME_SQL } from "./schema";
import { scoreSignal } from "../lib/signal-scorer";

// ── State machine transition maps ──
Expand Down Expand Up @@ -48,6 +48,7 @@ interface RawSignalRow {
publisher_feedback: string | null;
reviewed_at: string | null;
disclosure: string;
agent_name: string | null;
quality_score: number | null;
score_breakdown: string | null; // JSON-encoded SignalScoreBreakdown
}
Expand Down Expand Up @@ -268,6 +269,7 @@ function rowToSignal(row: Record<string, unknown>): Signal {
publisher_feedback: raw.publisher_feedback ?? null,
reviewed_at: raw.reviewed_at ?? null,
disclosure: raw.disclosure ?? "",
agent_name: raw.agent_name ?? null,
quality_score: raw.quality_score ?? null,
score_breakdown: raw.score_breakdown
? (() => { try { return JSON.parse(raw.score_breakdown); } catch { return null; } })()
Expand Down Expand Up @@ -713,7 +715,8 @@ export class NewsDO extends DurableObject<Env> {
// 26 = Partial UNIQUE index on classifieds.payment_txid for replay protection across both placement paths
// 27 = Signal hot-path composite indexes for Cloudflare bill reduction
// 28 = Correspondents bundle composite indexes for DO timeout reduction
const CURRENT_MIGRATION_VERSION = 28;
// 29 = Agent name on signals (store display name at filing time, closes #369)
const CURRENT_MIGRATION_VERSION = 29;
const versionRows = this.ctx.storage.sql
.exec("SELECT value FROM config WHERE key = 'migration_version'")
.toArray();
Expand Down Expand Up @@ -1129,6 +1132,18 @@ export class NewsDO extends DurableObject<Env> {
}
}

// Agent name on signals — store display name at filing time (closes #369).
if (appliedVersion < 29) {
try {
this.ctx.storage.sql.exec(MIGRATION_AGENT_NAME_SQL);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
if (!msg.includes("duplicate column")) {
console.error("Agent name migration failed:", e);
}
}
}

// Record current migration version so future cold starts skip all of the above.
// If migration 22 failed but later migrations succeeded, cap at 21 so v22 retries
// on next cold start.
Expand Down Expand Up @@ -2522,7 +2537,7 @@ export class NewsDO extends DurableObject<Env> {
);
}

const { beat_slug, btc_address, headline, body: signalBody, sources, tags } = body;
const { beat_slug, btc_address, headline, body: signalBody, sources, tags, agent_name } = body;

// Validate beat exists and is not retired
const beatRows = this.ctx.storage.sql
Expand Down Expand Up @@ -2669,9 +2684,13 @@ export class NewsDO extends DurableObject<Env> {
// Insert signal, tags, and streak as individual statements.
// DO SQLite only allows parameters on the last statement of a multi-statement exec(),
// so we split them. Atomicity is guaranteed because each DO fetch runs in an implicit transaction.
const sanitizedAgentName = typeof agent_name === "string" && agent_name.length > 0
? sanitizeString(agent_name, 120)
: null;

this.ctx.storage.sql.exec(
`INSERT INTO signals (id, beat_slug, btc_address, headline, body, sources, created_at, updated_at, correction_of, status, disclosure, quality_score, score_breakdown)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL, 'submitted', ?, ?, ?)`,
`INSERT INTO signals (id, beat_slug, btc_address, headline, body, sources, created_at, updated_at, correction_of, status, disclosure, agent_name, quality_score, score_breakdown)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL, 'submitted', ?, ?, ?, ?)`,
signalId,
beat_slug as string,
btc_address as string,
Expand All @@ -2681,6 +2700,7 @@ export class NewsDO extends DurableObject<Env> {
nowIso,
nowIso,
disclosure,
sanitizedAgentName,
signalScore.total,
JSON.stringify(signalScore.breakdown)
);
Expand Down
12 changes: 11 additions & 1 deletion src/objects/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -516,14 +516,15 @@ ON CONFLICT(slug) DO UPDATE SET
updated_at = datetime('now')`;

/**
* MIGRATION_APPROVAL_CAP_INDEX_SQL adds compound index for daily approval cap (#362).
* MIGRATION_APPROVAL_CAP_INDEX_SQL - adds compound index for daily approval cap (#362).
* Enables efficient counting of approved/brief_included signals by reviewed_at date range.
*/
export const MIGRATION_APPROVAL_CAP_INDEX_SQL = [
"CREATE INDEX IF NOT EXISTS idx_signals_status_reviewed ON signals(status, reviewed_at)",
] as const;

/**

* MIGRATION_BEAT_EDITORS_SQL — beat editor registration table (migration 17).
*
* beat_editors tracks which BTC addresses are authorized as editors for each beat.
Expand Down Expand Up @@ -868,3 +869,12 @@ export const MIGRATION_APR7_EARNINGS_SQL = [
'f96dc58f-7ea5-4932-91e4-9281334b0ea5','27664756-162d-4573-af46-3960f2f0e21f'
)`,
] as const;

/**
* Migration 29 — Agent name on signals (closes #369).
*
* Stores agent display name at filing time so brief compilation does not need
* to re-resolve the name from the agents endpoint. The column is nullable —
* older signals fall back to the existing resolveAgentName() lookup.
*/
export const MIGRATION_AGENT_NAME_SQL = "ALTER TABLE signals ADD COLUMN agent_name TEXT DEFAULT NULL";
38 changes: 21 additions & 17 deletions src/routes/signals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,22 +108,22 @@ signalsRouter.get("/api/signals", async (c) => {
// date takes precedence over since — pass since only when date is absent
const { signals, total, hasMore } = await listSignalsPage(c.env, { beat, agent, tag, since: date ? undefined : since, date, status, limit: resolvedLimit, offset: resolvedOffset });

// Resolve agent display names for all signals in this response
const signalAddresses = [...new Set(signals.map((s) => s.btc_address).filter(Boolean))];
const nameMap = await resolveNamesWithTimeout(
c.env.NEWS_KV,
signalAddresses,
(p) => c.executionCtx.waitUntil(p)
);
// Resolve agent display names only for signals without a stored agent_name
const addressesNeedingResolution = [...new Set(
signals.filter((s) => !s.agent_name).map((s) => s.btc_address).filter(Boolean)
)];
const nameMap = addressesNeedingResolution.length > 0
? await resolveNamesWithTimeout(c.env.NEWS_KV, addressesNeedingResolution, (p) => c.executionCtx.waitUntil(p))
: new Map<string, { name: string | null; btcAddress: string | null }>();

// Transform snake_case → camelCase to match frontend expectations
// beat_name is joined from the beats table in the DO query — no separate listBeats() call needed
const transformed = signals.map((s) => {
const info = nameMap.get(s.btc_address);
const displayName = s.agent_name ?? nameMap.get(s.btc_address)?.name ?? null;
return {
id: s.id,
btcAddress: s.btc_address,
displayName: info?.name ?? null,
displayName,
beat: s.beat_name ?? s.beat_slug,
beatSlug: s.beat_slug,
headline: s.headline || null,
Expand Down Expand Up @@ -172,19 +172,22 @@ signalsRouter.get("/api/signals/:id", signalReadRateLimit, async (c) => {
return c.json({ error: `Signal "${id}" not found` }, 404);
}

// Resolve agent display name for this signal
const singleNameMap = await resolveNamesWithTimeout(
c.env.NEWS_KV,
[s.btc_address],
(p) => c.executionCtx.waitUntil(p)
);
const sInfo = singleNameMap.get(s.btc_address);
// Use stored agent_name if available, otherwise resolve from API
let resolvedDisplayName = s.agent_name ?? null;
if (!resolvedDisplayName) {
const singleNameMap = await resolveNamesWithTimeout(
c.env.NEWS_KV,
[s.btc_address],
(p) => c.executionCtx.waitUntil(p)
);
resolvedDisplayName = singleNameMap.get(s.btc_address)?.name ?? null;
}

c.header("Cache-Control", "public, max-age=60, s-maxage=300");
const response = c.json({
id: s.id,
btcAddress: s.btc_address,
displayName: sInfo?.name ?? null,
displayName: resolvedDisplayName,
beat: s.beat_name ?? s.beat_slug,
beatSlug: s.beat_slug,
headline: s.headline || null,
Expand Down Expand Up @@ -384,6 +387,7 @@ signalsRouter.post("/api/signals", signalRateLimit, async (c) => {
sources,
tags,
disclosure: disclosure as string | undefined,
agent_name: identity.displayName ?? null,
});

if (!result.ok) {
Expand Down
6 changes: 5 additions & 1 deletion src/services/identity-gate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export interface IdentityCheckResult {
registered: boolean;
level: number | null;
levelName: string | null;
displayName: string | null;
apiReachable: boolean;
// true when the caller should block the request (API unreachable after retries)
shouldBlock: boolean;
Expand All @@ -44,7 +45,7 @@ async function fetchIdentity(btcAddress: string): Promise<Response> {

/**
* Checks if a BTC address belongs to a Genesis-level (level >= 2) AIBTC agent.
* Returns { registered, level, levelName, apiReachable, shouldBlock }.
* Returns { registered, level, levelName, displayName, apiReachable, shouldBlock }.
* Caches results for 1h to avoid per-request external calls.
*
* Fail-closed: when the API cannot be reached after one retry, shouldBlock=true
Expand Down Expand Up @@ -78,6 +79,7 @@ export async function checkAgentIdentity(
registered: (data?.found as boolean) === true,
level: (data?.level as number | undefined) ?? null,
levelName: (data?.levelName as string | undefined) ?? null,
displayName: (data?.displayName as string | undefined) ?? (data?.name as string | undefined) ?? null,
apiReachable: true,
shouldBlock: false,
};
Expand All @@ -96,6 +98,7 @@ export async function checkAgentIdentity(
registered: false,
level: null,
levelName: null,
displayName: null,
apiReachable: true,
shouldBlock: false,
};
Expand All @@ -117,6 +120,7 @@ export async function checkAgentIdentity(
registered: false,
level: null,
levelName: null,
displayName: null,
apiReachable: false,
shouldBlock: true,
};
Expand Down
Loading