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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 15 additions & 4 deletions lib/actions/briefings.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
DEFAULT_STEP_BUDGETS,
DEFAULT_STORIES,
DISCOVERY_MAXIMUMS,
FREE_BRIEFINGS,
HOLD_REASONS,
JUDGMENT_ONLY_PROVIDERS,
MAX_AI_KEYS_PER_ACCOUNT,
Expand All @@ -46,9 +47,10 @@ import {
STORY_KINDS,
VERDICTS,
} from "@/lib/briefing-rules";
import { countManualRunsToday, isOverManualRuns } from "@/lib/briefing-runs";
import { countManualRunsToday, isOverManualRuns, nextFreeRunAt } from "@/lib/briefing-runs";
import { describeBriefingSchedule, getBriefingScheduleProblem, nextBriefingRunAt, normalizeBriefingSchedule } from "@/lib/briefing-schedule";
import { recordVerdict } from "@/lib/briefing-verdicts";
import { isOnFreeTier } from "@/lib/plans";
import { READER_LANGUAGES } from "@/lib/reader-language-copy";
import { isObjectIdString, normalizeEmail } from "@/lib/validation";
import { getWebhookUrlProblem } from "@/lib/webhook-url";
Expand All @@ -61,7 +63,7 @@ import BriefingVerdict from "@/models/BriefingVerdict";
import TeamSeat from "@/models/TeamSeat";

const GROUP = "Briefings";
const HOW_IT_WORKS = `A briefing belongs to a newsletter. There is no list of sources: from its brief (a topic, the audience, what makes a story useful and example stories) it searches the open web on its schedule, with free indexes and the account's own search key if it has one, picks the stories its audience cares about, reads the original pages, writes a short issue in the newsletter's style with every paragraph linked to its sources, checks the issue and saves it as a draft campaign for review, with a test email to testEmail and a note to the owner with a keep or drop link per story. Each step (query, triage, extract, write, check, translate) runs on a model of the account's own AI key (add_ai_key), so the model cost is the account's, capped by budgetUsd per run and shared between the steps by stepBudgets. A briefing never sends by itself: reviewMode draft is the only mode for now.`;
const HOW_IT_WORKS = `A briefing belongs to a newsletter. There is no list of sources: from its brief (a topic, the audience, what makes a story useful and example stories) it searches the open web on its schedule, with free indexes and the account's own search key if it has one, picks the stories its audience cares about, reads the original pages, writes a short issue in the newsletter's style with every paragraph linked to its sources, checks the issue and saves it as a draft campaign for review, with a test email to testEmail and a note to the owner with a keep or drop link per story. Each step (query, triage, extract, write, check, translate) runs on a model of the account's own AI key (add_ai_key), so the model cost is the account's, capped by budgetUsd per run and shared between the steps by stepBudgets. A briefing never sends by itself: reviewMode draft is the only mode for now. On the Free plan a briefing runs once a week: its schedule has one day, and it has one manual run a week.`;
// query, extract, write and translate write text; TypeSafe answers judgments, so it can only triage and check.
const JUDGMENT_STEPS = ["triage", "check"];
const HOST_PATTERN = /^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z][a-z0-9-]{0,61}[a-z0-9]$/;
Expand Down Expand Up @@ -494,6 +496,13 @@ function getSectionsProblem(sections) {
return null;
}

// Free's briefing runs weekly (FREE_BRIEFINGS, owner decision D8); a paid plan's schedule kept after a downgrade is held to weekly
// by the engine, and changing it asks for one day.
function getFreePlanProblem(account, settings) {
if (!isOnFreeTier(account) || normalizeBriefingSchedule(settings.schedule).days.length <= FREE_BRIEFINGS.days) return null;
return "On the Free plan a briefing runs once a week: choose one day for its schedule, or upgrade for more (change_plan)";
}

function getBriefingProblem(settings, keys, teamEmails) {
if (!settings.name?.trim()) return "Name the briefing";
const briefProblem = getBriefProblem(settings);
Expand Down Expand Up @@ -550,7 +559,7 @@ function toDocument(settings) {
async function checkSettings(account, settings, { checkEmail }) {
const keys = await loadKeys(account);
const teamEmails = checkEmail && settings.testEmail ? await findTeamEmails(account) : null;
const problem = getBriefingProblem(settings, keys, teamEmails);
const problem = getBriefingProblem(settings, keys, teamEmails) ?? getFreePlanProblem(account, settings);
if (problem) throw actionError("validation_failed", problem);
return keys;
}
Expand Down Expand Up @@ -810,7 +819,7 @@ const runBriefing = defineAction({
name: "run_briefing",
title: "Run a briefing now",
group: GROUP,
description: "Asks for a run now, whatever the schedule and whether the briefing is paused: the engine takes it within about 10 minutes and the result is a draft campaign, never a send, with the run in the history (list_briefing_runs). It needs the brief's topic and a key and model for each step it uses. Three manual runs a briefing per local day; a fourth answers rate_limited. Where the server doesn't run the briefings engine it answers unavailable.",
description: "Asks for a run now, whatever the schedule and whether the briefing is paused: the engine takes it within about 10 minutes and the result is a draft campaign, never a send, with the run in the history (list_briefing_runs). It needs the brief's topic and a key and model for each step it uses. Three manual runs a briefing per local day; a fourth answers rate_limited. On the Free plan, one manual run a week. Where the server doesn't run the briefings engine it answers unavailable.",
http: { method: "POST", path: "/briefings/{id}/run" },
openWorld: true,
rateLimit: { limit: 6, window: "1 h" },
Expand All @@ -823,6 +832,8 @@ const runBriefing = defineAction({
const settings = fromDocument(briefing);
const keys = await checkSettings(account, { ...settings, status: "active" }, { checkEmail: false });
if (isOverManualRuns(await countManualRunsToday(briefing))) throw actionError("rate_limited", "This briefing has had its 3 manual runs today; the next one can be asked for tomorrow, by the schedule's time zone");
const freeWait = isOnFreeTier(account) ? await nextFreeRunAt(briefing, { isManual: true }) : null;
if (freeWait) throw actionError("rate_limited", `On the Free plan a briefing has one manual run a week; the next one can be asked for from ${freeWait.toISOString()}, or upgrade for more (change_plan)`);
await Briefing.updateOne(scoped(account, { _id: briefing._id }), { $set: { manualRunAt: new Date() } });
return showBriefing(account, await findBriefing(account, input.id), keys);
},
Expand Down
5 changes: 5 additions & 0 deletions lib/briefing-rules.js
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,11 @@ export const ACCOUNT_FETCH_LIMITS = { hour: 600, day: 3000 };

// Runs asked for with run_briefing, per briefing per local day (lib/briefing-runs.js).
export const MANUAL_RUNS_PER_DAY = 3;
// The Free plan's briefing (owner decision D8): one, as Free has one newsletter, drafts only as on every plan for now, and weekly.
// days: run days a week its schedule may have. scheduledGapDays: a scheduled run waits this long after the last scheduled one, so a
// daily schedule kept from a paid plan runs weekly after a downgrade; a day short of a week, so a weekly slot that moves with
// daylight saving still runs. manualGapDays: a manual run waits this long after the last manual one.
export const FREE_BRIEFINGS = { days: 1, scheduledGapDays: 6, manualGapDays: 7 };
// A briefing's issue as labelled sections (docs/BRIEFINGS-DESIGN.md, Sections): each is a pass over the ranked stories with its
// own cap and share of the extract and write budget. `sentences` is a lead or full stories; `one-liners` is a bullet per item.
export const SECTION_FORMATS = ["sentences", "one-liners"];
Expand Down
16 changes: 15 additions & 1 deletion lib/briefing-runs.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { scoped } from "./account-scope.js";
import { MANUAL_RUNS_PER_DAY } from "./briefing-rules.js";
import { FREE_BRIEFINGS, MANUAL_RUNS_PER_DAY } from "./briefing-rules.js";
import { getBriefingSlot } from "./briefing-schedule.js";
import BriefingRun from "../models/BriefingRun.js";

Expand All @@ -16,3 +16,17 @@ export async function countManualRunsToday(briefing, now = new Date()) {
export function isOverManualRuns(count) {
return count >= MANUAL_RUNS_PER_DAY;
}

const DAY_MS = 24 * 60 * 60_000;

// On the Free plan (FREE_BRIEFINGS) a briefing runs weekly: when its next scheduled (or manual) run may start, or null when it may
// start now. run_briefing refuses a manual run before then, and the engine skips a scheduled slot or drops a manual run that comes
// too soon. The caller checks the account is on Free.
export async function nextFreeRunAt(briefing, { isManual, now = new Date() }) {
const gap = (isManual ? FREE_BRIEFINGS.manualGapDays : FREE_BRIEFINGS.scheduledGapDays) * DAY_MS;
const last = await BriefingRun.findOne(scoped(briefing.accountId, { briefingId: briefing._id, isManual, createdAt: { $gte: new Date(now.getTime() - gap) } }))
.sort({ createdAt: -1 })
.select({ createdAt: 1 })
.lean();
return last ? new Date(new Date(last.createdAt).getTime() + gap) : null;
}
19 changes: 15 additions & 4 deletions lib/briefings/index.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { scoped } from "../account-scope.js";
import { isBlocked, isDeletionPending } from "../account-state.js";
import { countManualRunsToday, isOverManualRuns } from "../briefing-runs.js";
import { countManualRunsToday, isOverManualRuns, nextFreeRunAt } from "../briefing-runs.js";
import { getBriefingSlot, nextBriefingRunAt } from "../briefing-schedule.js";
import { DEFAULT_BUDGET_USD, DEFAULT_MIN_SCORE } from "../briefing-rules.js";
import { connectDb } from "../db.js";
import { isOnFreeTier } from "../plans.js";
import { rawCollection } from "../docdb/index.js";
import Briefing from "../../models/Briefing.js";
import BriefingRun from "../../models/BriefingRun.js";
Expand Down Expand Up @@ -38,6 +39,16 @@ async function isOverHourlyCap(accountId, now) {
return (await BriefingRun.countDocuments(scoped(accountId, { createdAt: { $gte: since } }))) >= TICK.runsPerAccountHour;
}

// Read raw, as a run reads its account below: the Account model would cost the Worker 4 KB for the plan's three fields.
async function isOnFree(accountId) {
return isOnFreeTier(await rawCollection("accounts").findOne({ _id: String(accountId) }, { projection: { plan: 1, subscriptionStatus: 1, comp: 1 } }));
}

// On Free a briefing runs weekly (FREE_BRIEFINGS): a slot or a manual run that comes too soon is passed over.
async function isTooSoonOnFree(briefing, { isManual, now }) {
return (await isOnFree(briefing.accountId)) && Boolean(await nextFreeRunAt(briefing, { isManual, now }));
}

// Creates the run for a slot, or answers null when the slot already has one.
async function startRun({ briefing, slot, isManual, now }) {
try {
Expand Down Expand Up @@ -86,7 +97,7 @@ async function claimScheduled({ now, nextRunAt, started }) {
{ accountId: briefing.accountId, _id: briefing._id, status: "active", nextRunAt: briefing.nextRunAt },
{ $set: { nextRunAt: next ?? null } }
);
if (!modifiedCount) continue;
if (!modifiedCount || (await isTooSoonOnFree(briefing, { isManual: false, now }))) continue;
const run = await startRun({ briefing, slot: getBriefingSlot(briefing.schedule, new Date(briefing.nextRunAt)), isManual: false, now });
if (run) {
started.push(run._id);
Expand Down Expand Up @@ -116,8 +127,8 @@ async function claimManual({ now, started }) {
newlyCapped = true;
continue;
}
// A briefing has MANUAL_RUNS_PER_DAY manual runs a local day: one asked for past that is dropped, not kept waiting.
const overDaily = isOverManualRuns(await countManualRunsToday(briefing, now));
// A briefing has MANUAL_RUNS_PER_DAY manual runs a local day, and one a week on Free: one asked for past that is dropped, not kept waiting.
const overDaily = isOverManualRuns(await countManualRunsToday(briefing, now)) || (await isTooSoonOnFree(briefing, { isManual: true, now }));
const { modifiedCount } = await Briefing.updateOne({ accountId: briefing.accountId, _id: briefing._id, manualRunAt: briefing.manualRunAt }, { $unset: { manualRunAt: 1 } });
if (!modifiedCount || overDaily) continue;
const run = await startRun({ briefing, slot: `manual:${new Date(briefing.manualRunAt).toISOString()}`, isManual: true, now });
Expand Down
2 changes: 1 addition & 1 deletion lib/docs/briefings-guide.js
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ export const BRIEFINGS_GUIDE = [
title: "Schedule and time zone",
paragraphs: [
`\`schedule\` has the days it runs (1 Monday to 7 Sunday), a time (HH:MM) and an IANA time zone such as Europe/Paris. The default is weekdays at ${DEFAULT_SCHEDULE.time} ${DEFAULT_SCHEDULE.timeZone}. The time is wall-clock time in that zone, so 06:00 stays 06:00 across a clock change. \`nextRunAt\` is the next run; a paused briefing has none.`,
`A new briefing is paused. It turns on with \`resume_briefing\` (or \`status: active\`), which needs the brief's topic and a key and model for each step it uses. \`run_briefing\` asks for a run now, whatever the schedule: the engine takes it within about 10 minutes, ${MANUAL_RUNS_PER_DAY} manual runs per briefing per local day. Where the server doesn't run the briefings engine, it answers \`unavailable\`.`,
`A new briefing is paused. It turns on with \`resume_briefing\` (or \`status: active\`), which needs the brief's topic and a key and model for each step it uses. \`run_briefing\` asks for a run now, whatever the schedule: the engine takes it within about 10 minutes, ${MANUAL_RUNS_PER_DAY} manual runs per briefing per local day. On the Free plan a briefing runs once a week: its schedule has one day and it has one manual run a week, so set both lookback windows to ${DISCOVERY_MAXIMUMS.lookbackHours} hours. Paid plans have one briefing per newsletter. Where the server doesn't run the briefings engine, it answers \`unavailable\`.`,
],
},
{
Expand Down
30 changes: 30 additions & 0 deletions tests/briefings-actions.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -353,6 +353,36 @@ describe("managing a briefing", () => {
});
});

describe("the Free plan", () => {
let user;
let openai;

before(async () => {
user = await t.customer("briefings-free@example.com");
const Account = (await import("../models/Account.js")).default;
await Account.updateOne({ _id: user.accountId }, { $set: { subscriptionStatus: "canceled" } });
openai = await addKey(user, "openai");
});

test("a briefing on Free runs once a week: one schedule day, and one manual run a week", async () => {
const daily = await t.api(user.key, "POST", "/briefings", { name: "Free briefing", brief: BRIEF, schedule: { days: [1, 2, 3, 4, 5], time: "06:00", timeZone: "UTC" } });
assert.match(t.assertError(daily, 400, "validation_failed"), /Free plan a briefing runs once a week/);
const created = await t.api(user.key, "POST", "/briefings", { name: "Free briefing", brief: BRIEF, schedule: { days: [3], time: "06:00", timeZone: "UTC" }, steps: allSteps(openai, openai) });
assert.equal(created.status, 201, JSON.stringify(created.body));
assert.match(t.assertError(await t.api(user.key, "PATCH", `/briefings/${created.body.id}`, { schedule: { days: [3, 4] } }), 400, "validation_failed"), /once a week/);

await withEnv({ BRIEFINGS_ENABLED: "true" }, async () => {
assert.equal((await t.api(user.key, "POST", `/briefings/${created.body.id}/run`)).status, 200);
const ranAt = new Date(Date.now() - 2 * 24 * 60 * 60_000);
await m.BriefingRun.create({ accountId: user.accountId, briefingId: created.body.id, newsletterId: created.body.newsletterId, slot: "manual:free-1", isManual: true, status: "drafted", startedAt: ranAt, createdAt: ranAt });
const again = await t.api(user.key, "POST", `/briefings/${created.body.id}/run`);
const message = t.assertError(again, 429, "rate_limited");
assert.match(message, /one manual run a week/);
assert.ok(message.includes(new Date(ranAt.getTime() + 7 * 24 * 60 * 60_000).toISOString()), message);
});
});
});

describe("verdict links", () => {
let user;
let briefing;
Expand Down
33 changes: 31 additions & 2 deletions tests/briefings-engine.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -212,7 +212,8 @@ let usedKeys;
let rejectedKeys;

async function makeBriefing(overrides = {}) {
const account = await m.Account.create({ ownerId: new m.ObjectId() });
// A paid plan unless the test says otherwise: on Free a briefing runs weekly.
const account = await m.Account.create({ ownerId: new m.ObjectId(), plan: "starter", subscriptionStatus: "active", ...overrides.account });
const newsletter = await m.Newsletter.create({ accountId: account._id, name: "Battery news", slug: `battery-${account._id}`, fromEmail: "news@example.com" });
const ids = { typesafe: new m.ObjectId(), openai: new m.ObjectId(), openrouter: new m.ObjectId(), tinyfish: new m.ObjectId() };
keys.set(String(ids.typesafe), { provider: "typesafe", baseUrl: "https://api.typesafe.ai/v1", accountRef: null, secret: TYPESAFE_SECRET });
Expand All @@ -228,7 +229,7 @@ async function makeBriefing(overrides = {}) {
translate: { keyId: ids.openai, model: "gpt-4o-mini" },
...overrides.steps?.(ids),
};
const { discovery, ...rest } = overrides;
const { discovery, account: _account, ...rest } = overrides;
const briefing = await m.Briefing.create({
accountId: account._id,
newsletterId: newsletter._id,
Expand Down Expand Up @@ -907,6 +908,34 @@ describe("the security review's findings", () => {
await m.Briefing.updateMany({ accountId: account._id }, { $set: { status: "paused" } });
});

test("on Free a briefing runs weekly: a scheduled slot within 6 days of the last is passed over, and so is a second manual run in a week", async () => {
const world = makeWorld();
const free = { subscriptionStatus: "canceled" };
const { account, briefing } = await makeBriefing({ account: free, manualRunAt: undefined, nextRunAt: NOW });
const fiveDaysAgo = new Date(NOW.getTime() - 5 * 24 * HOUR);
await m.BriefingRun.create({ accountId: account._id, briefingId: briefing._id, newsletterId: briefing.newsletterId, slot: "2026-10-23", isManual: false, status: "drafted", stage: "done", startedAt: fiveDaysAgo, createdAt: fiveDaysAgo });
assert.equal((await tick({ world, timeBudgetMs: 0 })).claimed, 0);
const saved = await m.Briefing.findOne({ accountId: account._id, _id: briefing._id }).lean();
assert.ok(saved.nextRunAt > NOW, "the slot is passed over, not kept due");

await m.BriefingRun.updateMany({ accountId: account._id, briefingId: briefing._id }, { $set: { createdAt: new Date(NOW.getTime() - 6 * 24 * HOUR - 1) } });
await m.Briefing.updateOne({ accountId: account._id, _id: briefing._id }, { $set: { nextRunAt: NOW } });
assert.equal((await tick({ world, timeBudgetMs: 0 })).claimed, 1, "6 days after the last, the slot runs");

await m.Briefing.updateOne({ accountId: account._id, _id: briefing._id }, { $set: { status: "paused" } });
const threeDaysAgo = new Date(NOW.getTime() - 3 * 24 * HOUR);
await m.BriefingRun.create({ accountId: account._id, briefingId: briefing._id, newsletterId: briefing.newsletterId, slot: "manual:free-earlier", isManual: true, status: "drafted", stage: "done", startedAt: threeDaysAgo, createdAt: threeDaysAgo });
await m.Briefing.updateOne({ accountId: account._id, _id: briefing._id }, { $set: { manualRunAt: NOW } });
const later = new Date(NOW.getTime() + 2 * HOUR);
assert.equal((await tick({ world, timeBudgetMs: 0, now: later })).claimed, 0);
assert.equal((await m.Briefing.findOne({ accountId: account._id, _id: briefing._id }).lean()).manualRunAt, undefined, "the request is dropped, not kept waiting");

const paid = await makeBriefing({ manualRunAt: undefined, nextRunAt: NOW });
await m.BriefingRun.create({ accountId: paid.account._id, briefingId: paid.briefing._id, newsletterId: paid.briefing.newsletterId, slot: "2026-10-27", isManual: false, status: "drafted", stage: "done", startedAt: fiveDaysAgo, createdAt: new Date(NOW.getTime() - 24 * HOUR) });
assert.equal((await tick({ world, timeBudgetMs: 0 })).claimed, 1, "a paid plan's daily schedule runs daily");
await m.Briefing.updateMany({ accountId: paid.account._id }, { $set: { status: "paused" } });
});

test("a briefing gets 3 manual runs a local day at most", async () => {
const world = makeWorld();
const { account, briefing } = await makeBriefing({ manualRunAt: undefined, schedule: { days: [1, 2, 3, 4, 5, 6, 7], time: "06:00", timeZone: "America/New_York" } });
Expand Down
Loading