feat: govern autonomous campaign delivery schedules
This commit is contained in:
@@ -138,10 +138,15 @@ export type CampaignScheduleOccurrence = {
|
||||
id: string;
|
||||
schedule_id: string;
|
||||
scheduled_for: string;
|
||||
status: "prepared" | "failed" | string;
|
||||
status: "prepared" | "accepted" | "uncertain" | "failed" | "skipped" | "superseded" | string;
|
||||
idempotency_key?: string | null;
|
||||
generated_campaign_id?: string | null;
|
||||
generated_version_id?: string | null;
|
||||
error?: string | null;
|
||||
delivery_command_ids: string[];
|
||||
recovery_state: string;
|
||||
evidence: Record<string, unknown>;
|
||||
last_checked_at?: string | null;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
@@ -150,6 +155,7 @@ export type CampaignSchedule = {
|
||||
campaign_id: string;
|
||||
source_version_id: string;
|
||||
name: string;
|
||||
delivery_mode: "manual" | "autonomous";
|
||||
recurrence_kind: "once" | "daily" | "weekly" | "monthly";
|
||||
interval_count: number;
|
||||
timezone: string;
|
||||
@@ -163,6 +169,8 @@ export type CampaignSchedule = {
|
||||
last_fired_at?: string | null;
|
||||
last_campaign_id?: string | null;
|
||||
last_error?: string | null;
|
||||
last_outcome?: string | null;
|
||||
last_recovery_state?: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
occurrences: CampaignScheduleOccurrence[];
|
||||
@@ -171,6 +179,7 @@ export type CampaignSchedule = {
|
||||
export type CampaignScheduleCreate = {
|
||||
source_version_id: string;
|
||||
name: string;
|
||||
delivery_mode: CampaignSchedule["delivery_mode"];
|
||||
recurrence_kind: CampaignSchedule["recurrence_kind"];
|
||||
interval_count: number;
|
||||
timezone: string;
|
||||
|
||||
@@ -75,6 +75,7 @@ function defaultScheduleDraft(): CampaignScheduleCreate {
|
||||
return {
|
||||
source_version_id: "",
|
||||
name: "",
|
||||
delivery_mode: "manual",
|
||||
recurrence_kind: "once",
|
||||
interval_count: 1,
|
||||
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC",
|
||||
@@ -113,6 +114,10 @@ export default function CampaignOverviewPage({ settings, auth, campaignId }: {se
|
||||
const canDelete = Boolean(campaign) && campaign?.status === "draft" && hasScope(auth, "campaigns:campaign:delete");
|
||||
const canCopy = Boolean(data.currentVersion) && hasScope(auth, "campaigns:campaign:copy");
|
||||
const canSchedule = Boolean(data.currentVersion) && hasScope(auth, "campaigns:campaign:schedule") && hasScope(auth, "campaigns:campaign:copy");
|
||||
const canAutonomousSchedule = canSchedule
|
||||
&& hasScope(auth, "campaigns:campaign:queue")
|
||||
&& hasScope(auth, "campaigns:campaign:send")
|
||||
&& hasScope(auth, "mail:profile:use");
|
||||
|
||||
function openSection(section: string, fragment = "") {
|
||||
const params = new URLSearchParams();
|
||||
@@ -317,7 +322,9 @@ export default function CampaignOverviewPage({ settings, auth, campaignId }: {se
|
||||
});
|
||||
setSchedules((current) => [created, ...current]);
|
||||
setScheduleDialogOpen(false);
|
||||
setMessage("Campaign schedule created. Each occurrence prepares a fresh draft for review; it does not send automatically.");
|
||||
setMessage(created.delivery_mode === "autonomous"
|
||||
? "Autonomous schedule created from the exact approved source execution."
|
||||
: "Manual schedule created. Each occurrence prepares a fresh draft for review.");
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
@@ -433,17 +440,19 @@ export default function CampaignOverviewPage({ settings, auth, campaignId }: {se
|
||||
<CalendarClock size={16} aria-hidden="true" />
|
||||
Add schedule
|
||||
</Button> : undefined}>
|
||||
<p className="muted small-note">Due occurrences prepare independent campaign drafts. Validation, review, approval, and delivery are never started by the schedule.</p>
|
||||
<p className="muted small-note">Manual schedules prepare independent drafts. Opt-in autonomous schedules reuse the exact approved build through Mail's durable delivery outbox.</p>
|
||||
{schedules.length === 0 ? <p className="muted">No schedules configured.</p> : <div className="campaign-schedule-list">
|
||||
{schedules.map((schedule) => <div className="campaign-schedule-row" key={schedule.id}>
|
||||
<div className="campaign-schedule-main">
|
||||
<strong>{schedule.name}</strong>
|
||||
<span>{scheduleCadence(schedule)} · {schedule.occurrence_count}/{schedule.max_occurrences} prepared</span>
|
||||
<span>{scheduleCadence(schedule)} · {schedule.occurrence_count}/{schedule.max_occurrences} occurrences</span>
|
||||
<span><StatusBadge status={schedule.delivery_mode} /> {schedule.delivery_mode === "autonomous" ? "Approved source delivery" : "Draft preparation"}</span>
|
||||
{schedule.next_fire_at && <span>Next: {formatDateTime(schedule.next_fire_at)}</span>}
|
||||
{schedule.last_outcome && <span>Last outcome: <StatusBadge status={schedule.last_outcome} /> · recovery {schedule.last_recovery_state ?? "none"}</span>}
|
||||
{schedule.last_error && <span className="danger-text">Paused: {schedule.last_error}</span>}
|
||||
</div>
|
||||
<div className="button-row compact-actions">
|
||||
{schedule.last_campaign_id && <Link className="btn btn-secondary" to={`/campaigns/${schedule.last_campaign_id}`}>Open latest draft</Link>}
|
||||
{schedule.last_campaign_id && <Link className="btn btn-secondary" to={`/campaigns/${schedule.last_campaign_id}`}>{schedule.delivery_mode === "autonomous" ? "Open approved source" : "Open latest draft"}</Link>}
|
||||
{schedule.next_fire_at && <Button
|
||||
iconOnly
|
||||
aria-label={schedule.active ? "Pause schedule" : "Resume schedule"}
|
||||
@@ -573,7 +582,7 @@ export default function CampaignOverviewPage({ settings, auth, campaignId }: {se
|
||||
|
||||
<Dialog
|
||||
open={scheduleDialogOpen}
|
||||
title="Schedule campaign drafts"
|
||||
title="Schedule campaign"
|
||||
className="campaign-schedule-dialog"
|
||||
helpContextId="campaigns.action.schedule-drafts"
|
||||
closeDisabled={scheduleBusy}
|
||||
@@ -584,11 +593,19 @@ export default function CampaignOverviewPage({ settings, auth, campaignId }: {se
|
||||
{scheduleBusy ? "Creating schedule..." : "Create schedule"}
|
||||
</Button>
|
||||
</>}>
|
||||
<DismissibleAlert tone="info" resetKey="campaign-schedule-safety">
|
||||
A schedule creates a fresh draft at each due time. It never validates, approves, queues, or sends messages automatically.
|
||||
<DismissibleAlert tone="info" resetKey={`campaign-schedule-safety-${scheduleDraft.delivery_mode}`}>
|
||||
{scheduleDraft.delivery_mode === "autonomous"
|
||||
? "Autonomous delivery is opt-in. It requires a built, explicitly approved Mail-only source version and rechecks approval, policy, credentials, transport health, recipients, attachments, and snapshot integrity before each occurrence. Unknown outcomes pause the schedule and are never retried automatically."
|
||||
: "Manual mode creates a fresh draft at each due time. It never validates, approves, queues, or sends messages automatically and remains available without Mail."}
|
||||
</DismissibleAlert>
|
||||
<div className="campaign-schedule-form">
|
||||
<FormField label="Draft name">
|
||||
<FormField label="Execution mode" help="Mode is fixed for this schedule. Create a new schedule when the approved delivery plan changes.">
|
||||
<select value={scheduleDraft.delivery_mode} onChange={(event) => setScheduleDraft((current) => ({ ...current, delivery_mode: event.target.value as CampaignSchedule["delivery_mode"] }))}>
|
||||
<option value="manual">Manual draft preparation</option>
|
||||
<option value="autonomous" disabled={!canAutonomousSchedule}>Autonomous approved delivery</option>
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField label="Schedule name">
|
||||
<input value={scheduleDraft.name} onChange={(event) => setScheduleDraft((current) => ({ ...current, name: event.target.value }))} />
|
||||
</FormField>
|
||||
<FormField label="First occurrence">
|
||||
@@ -618,13 +635,13 @@ export default function CampaignOverviewPage({ settings, auth, campaignId }: {se
|
||||
<input value={scheduleDraft.timezone} onChange={(event) => setScheduleDraft((current) => ({ ...current, timezone: event.target.value }))} />
|
||||
</FormField>
|
||||
</div>
|
||||
<div className="campaign-copy-options">
|
||||
{scheduleDraft.delivery_mode === "manual" && <div className="campaign-copy-options">
|
||||
<CopyOption label="Recipients" detail="Recipient rows and values used to prepare each draft." checked={scheduleDraft.include_recipients} disabled={!hasScope(auth, "campaigns:recipient:read")} onChange={(checked) => setScheduleDraft((current) => ({ ...current, include_recipients: checked }))} />
|
||||
<CopyOption label="Files" detail="Attachment rules and Files references; generated evidence is never copied." checked={scheduleDraft.include_files} onChange={(checked) => setScheduleDraft((current) => ({ ...current, include_files: checked }))} />
|
||||
<CopyOption label="Shares" detail="Current active shares are sealed when the schedule is created." checked={scheduleDraft.include_shares} disabled={!hasScope(auth, "campaigns:campaign:share")} onChange={(checked) => setScheduleDraft((current) => ({ ...current, include_shares: checked }))} />
|
||||
<CopyOption label="Policies" detail="Campaign and validation policy configuration." checked={scheduleDraft.include_policies} onChange={(checked) => setScheduleDraft((current) => ({ ...current, include_policies: checked }))} />
|
||||
<CopyOption label="Mail profile" detail="Reusable Mail profile references; credentials remain Mail-owned." checked={scheduleDraft.include_mail_profile} onChange={(checked) => setScheduleDraft((current) => ({ ...current, include_mail_profile: checked }))} />
|
||||
</div>
|
||||
</div>}
|
||||
</Dialog>
|
||||
|
||||
<ConfirmDialog
|
||||
|
||||
@@ -21,6 +21,13 @@ assert.match(overview, /include_files/);
|
||||
assert.match(overview, /include_shares/);
|
||||
assert.match(overview, /include_policies/);
|
||||
assert.match(overview, /include_mail_profile/);
|
||||
assert.match(overview, /delivery_mode: "manual"/);
|
||||
assert.match(overview, /Autonomous approved delivery/);
|
||||
assert.match(overview, /campaigns:campaign:send/);
|
||||
assert.match(overview, /mail:profile:use/);
|
||||
assert.match(overview, /schedule\.last_outcome/);
|
||||
assert.match(overview, /schedule\.last_recovery_state/);
|
||||
assert.match(overview, /Unknown outcomes pause the schedule and are never retried automatically/);
|
||||
assert.match(overview, /Delivery jobs, outcomes, locks, reports, and audit evidence are never copied/);
|
||||
assert.match(overview, /await archiveCampaignVersion\(settings, campaign\.id, pending\.version\.id, pending\.policy\.state_token\)/);
|
||||
assert.match(api, /\/api\/v1\/campaigns\/\$\{campaignId\}\/archive/);
|
||||
|
||||
Reference in New Issue
Block a user