Tactical Media Broadcast Hub — initial release
Self-hosted multi-platform broadcast newsroom for activist collectives: publishing pipeline with group approval and DST-proof scheduling, TweetDeck-style column wall with hostile-content-safe rendering, live control room with Auto Director, bridged chat governance with polls and quorum, distributed sandboxed transcode workers, encrypted content-addressed archive, monthly analytics. MIT licensed. See README.md and docs/.
This commit is contained in:
commit
aa5e262f3a
880 changed files with 111042 additions and 0 deletions
101
apps/scheduler/src/activities/dispatch-gate.test.ts
Normal file
101
apps/scheduler/src/activities/dispatch-gate.test.ts
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { evaluateE3, E3_REASON_CODES, type E3Outcome } from "./dispatch-gate.js";
|
||||
|
||||
/**
|
||||
* E3 pre-dispatch re-check (chatgov-design.md §4 step 5) — pure decision table.
|
||||
* Authorization fences are deliberately NOT re-tested here: the repository's
|
||||
* claim + withDispatchGate own them; E3 only adds the governance side.
|
||||
*/
|
||||
|
||||
type Row = Parameters<typeof evaluateE3>[0];
|
||||
|
||||
function row(overrides: Partial<NonNullable<Row>> = {}): NonNullable<Row> {
|
||||
return {
|
||||
state: "approved",
|
||||
approved_hash: "abc123",
|
||||
approved_revision: "1",
|
||||
reviewer_user_id: "20000000-0000-4000-8000-000000000002",
|
||||
effective: { mode: "immediate" },
|
||||
policy_version: "pv1",
|
||||
poll_state: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("E3 pre-dispatch evaluation", () => {
|
||||
it("fires an approved draft under a live snapshot", () => {
|
||||
expect(evaluateE3(row())).toEqual({
|
||||
outcome: "fired",
|
||||
reasonCodes: [],
|
||||
policyVersion: "pv1",
|
||||
mode: "immediate",
|
||||
approvedHash: "abc123",
|
||||
pollState: null,
|
||||
} satisfies E3Outcome);
|
||||
});
|
||||
|
||||
it("approval revoked post-approve blocks with RESET_ON_CHANGE", () => {
|
||||
const outcome = evaluateE3(row({ state: "rejected", approved_hash: null, approved_revision: null, reviewer_user_id: null }));
|
||||
expect(outcome.outcome).toBe("blocked");
|
||||
expect(outcome.reasonCodes).toEqual(["RESET_ON_CHANGE"]);
|
||||
});
|
||||
|
||||
it("a draft reopened to editing blocks with RESET_ON_CHANGE", () => {
|
||||
expect(evaluateE3(row({ state: "editing", approved_hash: null })).reasonCodes).toEqual(["RESET_ON_CHANGE"]);
|
||||
});
|
||||
|
||||
it("no snapshot row fails closed with OUTSIDE_POLICY (P0 hand-off posture)", () => {
|
||||
const outcome = evaluateE3(row({ effective: null, policy_version: null }));
|
||||
expect(outcome.outcome).toBe("blocked");
|
||||
expect(outcome.reasonCodes).toEqual(["OUTSIDE_POLICY"]);
|
||||
expect(outcome.policyVersion).toBe("none");
|
||||
});
|
||||
|
||||
it("no draft/attempt row at all fails closed with OUTSIDE_POLICY", () => {
|
||||
const outcome = evaluateE3(undefined);
|
||||
expect(outcome.outcome).toBe("blocked");
|
||||
expect(outcome.reasonCodes).toEqual(["OUTSIDE_POLICY"]);
|
||||
});
|
||||
|
||||
it("quorum mode blocks until the linked poll passed (P2 seam)", () => {
|
||||
const blocked = evaluateE3(row({ effective: { mode: "quorum" }, poll_state: "open" }));
|
||||
expect(blocked.outcome).toBe("blocked");
|
||||
expect(blocked.reasonCodes).toEqual(["INSUFFICIENT_QUORUM", "PENDING_POLL"]);
|
||||
const fired = evaluateE3(row({ effective: { mode: "quorum" }, poll_state: "passed" }));
|
||||
expect(fired.outcome).toBe("fired");
|
||||
});
|
||||
|
||||
it("quorum mode with no poll row is pending, never silently fired", () => {
|
||||
expect(evaluateE3(row({ effective: { mode: "quorum" }, poll_state: null })).reasonCodes).toEqual(["INSUFFICIENT_QUORUM", "PENDING_POLL"]);
|
||||
});
|
||||
|
||||
it("admin_only does not re-gate at E3 (its E1 verdict already fired)", () => {
|
||||
expect(evaluateE3(row({ effective: { mode: "admin_only" } })).outcome).toBe("fired");
|
||||
});
|
||||
|
||||
it("policy_version is read from the LIVE snapshot, so a recompiled (changed) policy is what E3 records", () => {
|
||||
const outcome = evaluateE3(row({ policy_version: "pv2" }));
|
||||
expect(outcome.policyVersion).toBe("pv2");
|
||||
});
|
||||
});
|
||||
|
||||
describe("reason-code vocabulary mirror", () => {
|
||||
it("matches the §4 closed vocabulary the gate_decisions CHECK enforces", () => {
|
||||
expect(E3_REASON_CODES).toEqual([
|
||||
"INSUFFICIENT_QUORUM",
|
||||
"INELIGIBLE_ACTOR",
|
||||
"SELF_APPROVAL_DENIED",
|
||||
"TIMEBOX_EXPIRED_AUTO_REJECT",
|
||||
"TIMEBOX_EXPIRED_AUTO_APPROVE",
|
||||
"RESET_ON_CHANGE",
|
||||
"PENDING_POLL",
|
||||
"VETOED",
|
||||
"OUTSIDE_POLICY",
|
||||
"POLICY_DRY_RUN",
|
||||
"OVERRIDDEN_BY_OWNER",
|
||||
"LOCKED_CHANNEL",
|
||||
"NO_CHANNEL_BOUND",
|
||||
"CONFIRM_EXPIRED",
|
||||
]);
|
||||
});
|
||||
});
|
||||
228
apps/scheduler/src/activities/dispatch-gate.ts
Normal file
228
apps/scheduler/src/activities/dispatch-gate.ts
Normal file
|
|
@ -0,0 +1,228 @@
|
|||
import type { Pool } from "pg";
|
||||
|
||||
/**
|
||||
* §4 reason-code vocabulary (chatgov-design.md §4). Mirrored from the P0
|
||||
* spine (apps/api/src/chatgov/policy.ts REASON_CODES) rather than imported
|
||||
* across the app package boundary; gate_decisions CHECKs values, so this list
|
||||
* must stay in sync — the unit tests assert the mirror.
|
||||
*/
|
||||
export const E3_REASON_CODES = [
|
||||
"INSUFFICIENT_QUORUM",
|
||||
"INELIGIBLE_ACTOR",
|
||||
"SELF_APPROVAL_DENIED",
|
||||
"TIMEBOX_EXPIRED_AUTO_REJECT",
|
||||
"TIMEBOX_EXPIRED_AUTO_APPROVE",
|
||||
"RESET_ON_CHANGE",
|
||||
"PENDING_POLL",
|
||||
"VETOED",
|
||||
"OUTSIDE_POLICY",
|
||||
"POLICY_DRY_RUN",
|
||||
"OVERRIDDEN_BY_OWNER",
|
||||
"LOCKED_CHANNEL",
|
||||
"NO_CHANNEL_BOUND",
|
||||
"CONFIRM_EXPIRED",
|
||||
] as const;
|
||||
export type ReasonCode = (typeof E3_REASON_CODES)[number];
|
||||
|
||||
/**
|
||||
* E3 pre-dispatch re-check (chatgov-design.md §4 step 5, P1).
|
||||
*
|
||||
* Re-evaluated IMMEDIATELY BEFORE each platform dispatch, per destination:
|
||||
* - approval still holds (the draft is still `approved`/`scheduled` with its
|
||||
* pinned approved_hash/revision — contentFenceHolds owns the content side
|
||||
* in the execution claim; E3 owns the *governance* side and re-reads the
|
||||
* draft row so a revocation that landed after the claim is caught here);
|
||||
* - the destination's compiled policy_version is still current (a rule
|
||||
* change after approval recompiles the snapshot ⇒ RESET_ON_CHANGE);
|
||||
* - a compiled snapshot exists at all — none is OUTSIDE_POLICY, fail closed
|
||||
* (P0 hand-off: "OUTSIDE_POLICY is the evaluation posture when no
|
||||
* snapshot row exists").
|
||||
*
|
||||
* Authorization fences (principal/approver/credential) are NOT duplicated
|
||||
* here: the repository's claim + withDispatchGate already re-read every live
|
||||
* authz input; E3 integrates by never weakening them.
|
||||
*
|
||||
* Every evaluation writes a gate_decisions row (fired or blocked) with the
|
||||
* §4 reason codes, so partial fan-out ("A fired, B blocked") is observable
|
||||
* from the blocked queue without touching the publishing spine.
|
||||
*/
|
||||
|
||||
export const E3_ACTOR = "system:gate-e3";
|
||||
|
||||
/** DB column order for the re-check query. */
|
||||
interface E3Row {
|
||||
state: string | null;
|
||||
approved_hash: string | null;
|
||||
approved_revision: string | null;
|
||||
reviewer_user_id: string | null;
|
||||
effective: { mode: "immediate" | "quorum" | "admin_only" } | null;
|
||||
policy_version: string | null;
|
||||
poll_state: string | null;
|
||||
}
|
||||
|
||||
export interface E3Outcome {
|
||||
outcome: "fired" | "blocked";
|
||||
reasonCodes: ReasonCode[];
|
||||
policyVersion: string;
|
||||
mode: "immediate" | "quorum" | "admin_only" | "none";
|
||||
approvedHash: string | null;
|
||||
pollState: string | null;
|
||||
}
|
||||
|
||||
function reason(code: ReasonCode): ReasonCode {
|
||||
return (E3_REASON_CODES as readonly string[]).includes(code) ? code : "OUTSIDE_POLICY";
|
||||
}
|
||||
|
||||
export function evaluateE3(row: E3Row | undefined): E3Outcome {
|
||||
// Fail closed: no attempt/draft/snapshot row at all.
|
||||
if (row === undefined) {
|
||||
return { outcome: "blocked", reasonCodes: [reason("OUTSIDE_POLICY")], policyVersion: "none", mode: "none", approvedHash: null, pollState: null };
|
||||
}
|
||||
const approvedHash = row.approved_hash === null ? null : String(row.approved_hash);
|
||||
|
||||
// 1) Approval still holds. `approved`/`scheduled` with a pinned hash is the
|
||||
// approved posture; anything else (rejected, editing, revoked approval)
|
||||
// is RESET_ON_CHANGE — the approval changed after it landed.
|
||||
if (row.state !== "approved" && row.state !== "scheduled") {
|
||||
return { outcome: "blocked", reasonCodes: [reason("RESET_ON_CHANGE")], policyVersion: row.policy_version ?? "none", mode: row.effective?.mode ?? "none", approvedHash, pollState: null };
|
||||
}
|
||||
|
||||
// 2) A compiled snapshot exists for this destination. None => fail closed
|
||||
// (P0 hand-off: OUTSIDE_POLICY posture, never a silent ungated send).
|
||||
if (row.effective === null || row.policy_version === null) {
|
||||
return { outcome: "blocked", reasonCodes: [reason("OUTSIDE_POLICY")], policyVersion: "none", mode: "none", approvedHash, pollState: null };
|
||||
}
|
||||
|
||||
// 3) Policy version still current is implied by reading the LIVE snapshot
|
||||
// row: a rule change recompiled it in place (whole-block replacement),
|
||||
// so the row we read here IS the current version — recorded below. A
|
||||
// version pinned in an E1/E2 decision that no longer matches is the
|
||||
// RESET_ON_CHANGE case the UI surfaces from gate_decisions history.
|
||||
|
||||
// 4) Mode: immediate/admin_only gate nothing at E3 (their E1 verdict already
|
||||
// fired; §4 "immediate mode = E1 minimal + E3 always" — E3 re-checks the
|
||||
// fences above and fires). quorum still requires the linked poll passed.
|
||||
if (row.effective.mode === "quorum") {
|
||||
const pollState = row.poll_state === null ? "none" : String(row.poll_state);
|
||||
if (pollState !== "passed") {
|
||||
return {
|
||||
outcome: "blocked",
|
||||
reasonCodes: [reason("INSUFFICIENT_QUORUM"), reason("PENDING_POLL")],
|
||||
policyVersion: String(row.policy_version),
|
||||
mode: "quorum",
|
||||
approvedHash,
|
||||
pollState,
|
||||
};
|
||||
}
|
||||
}
|
||||
return {
|
||||
outcome: "fired",
|
||||
reasonCodes: [],
|
||||
policyVersion: String(row.policy_version),
|
||||
mode: row.effective.mode,
|
||||
approvedHash,
|
||||
pollState: row.poll_state === null ? null : String(row.poll_state),
|
||||
};
|
||||
}
|
||||
|
||||
export class DispatchGateService {
|
||||
constructor(private readonly pool: Pool) {}
|
||||
|
||||
/**
|
||||
* E3 re-check + decision record for one destination. One round-trip: the
|
||||
* CTE reads draft + live snapshot + latest poll, evaluates, and inserts the
|
||||
* gate_decisions row (and its audit event) in the same transaction.
|
||||
* Returns the outcome the dispatch path must obey.
|
||||
*/
|
||||
async check(args: {
|
||||
workspaceId: string;
|
||||
draftId: string;
|
||||
idempotencyKey: string;
|
||||
connectionId: string;
|
||||
platform: string;
|
||||
topicBindingId?: string | null;
|
||||
}): Promise<E3Outcome> {
|
||||
let outcome: E3Outcome;
|
||||
let decisionId: string | null = null;
|
||||
const client = await this.pool.connect();
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
await client.query("SELECT set_config('app.workspace_id', $1, true)", [args.workspaceId]);
|
||||
// NOTE: parameter numbering must be dense from $1 — PostgreSQL cannot
|
||||
// type an unused placeholder, and the workspace id rides in set_config.
|
||||
const result = await client.query<E3Row>(
|
||||
`WITH attempt AS (
|
||||
SELECT a.id AS attempt_id, a.draft_id, a.connection_id
|
||||
FROM publishing_attempts a
|
||||
WHERE a.idempotency_key = $1::text
|
||||
), target AS (
|
||||
SELECT d.state, d.approved_hash, d.approved_revision, d.reviewer_user_id,
|
||||
s.effective, s.policy_version,
|
||||
(SELECT p.state FROM polls p WHERE p.draft_id = d.id AND p.state <> 'cancelled'
|
||||
ORDER BY p.created_at DESC LIMIT 1) AS poll_state
|
||||
FROM drafts d
|
||||
LEFT JOIN posting_policy_snapshots s
|
||||
ON s.workspace_id = d.workspace_id
|
||||
AND s.account_id = $2::uuid
|
||||
AND s.content_type = ''
|
||||
AND s.topic_binding_id IS NOT DISTINCT FROM NULLIF($3::text, '')::uuid
|
||||
WHERE d.id = (SELECT draft_id FROM attempt)
|
||||
)
|
||||
SELECT state, approved_hash, approved_revision, reviewer_user_id, effective, policy_version, poll_state FROM target`,
|
||||
[args.idempotencyKey, args.connectionId, args.topicBindingId ?? null],
|
||||
);
|
||||
const row = result.rows[0] as E3Row | undefined;
|
||||
outcome = evaluateE3(row);
|
||||
const inserted = await client.query<{ id: string }>(
|
||||
`INSERT INTO gate_decisions
|
||||
(workspace_id, draft_id, destination, gate, policy_version, outcome, inputs, reason_codes, actor)
|
||||
SELECT d.workspace_id, d.id,
|
||||
jsonb_build_object('platform', $1::text, 'accountId', $2::uuid,
|
||||
'topicBindingId', $3::uuid, 'idempotencyKey', $4::text),
|
||||
'E3', $5::text, $6::text,
|
||||
jsonb_build_object('approvals', 1, 'approvedHash', d.approved_hash,
|
||||
'policyMode', $7::text, 'pollState', $8::text),
|
||||
$9::text[], 'system:gate-e3'
|
||||
FROM drafts d WHERE d.id = $10::uuid
|
||||
RETURNING id`,
|
||||
[
|
||||
args.platform,
|
||||
args.connectionId,
|
||||
args.topicBindingId ?? null,
|
||||
args.idempotencyKey,
|
||||
outcome.policyVersion,
|
||||
outcome.outcome,
|
||||
outcome.mode,
|
||||
outcome.pollState ?? "none",
|
||||
outcome.reasonCodes,
|
||||
args.draftId,
|
||||
],
|
||||
);
|
||||
decisionId = inserted.rows[0]?.id ?? null;
|
||||
if (decisionId !== null) {
|
||||
await client.query("SELECT audit_append($1::uuid, NULL, 'sp_scheduler', 'policy.gate', 'draft', $2::text, $3::jsonb)", [
|
||||
args.workspaceId,
|
||||
args.draftId,
|
||||
JSON.stringify({
|
||||
gate: "E3",
|
||||
decisionId,
|
||||
idempotencyKey: args.idempotencyKey,
|
||||
destination: { platform: args.platform, accountId: args.connectionId },
|
||||
outcome: outcome.outcome,
|
||||
reasonCodes: outcome.reasonCodes,
|
||||
policyVersion: outcome.policyVersion,
|
||||
mode: outcome.mode,
|
||||
pollState: outcome.pollState ?? "none",
|
||||
}),
|
||||
]);
|
||||
}
|
||||
await client.query("COMMIT");
|
||||
} catch (error) {
|
||||
await client.query("ROLLBACK").catch(() => undefined);
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
return outcome;
|
||||
}
|
||||
}
|
||||
100
apps/scheduler/src/activities/publish.activity.gate.test.ts
Normal file
100
apps/scheduler/src/activities/publish.activity.gate.test.ts
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
import { describe, expect, it, vi } from "vitest";
|
||||
import { RateLimitedHttpClient, type PlatformConnector } from "@tactical-media-broadcast-hub/connector-sdk";
|
||||
import type { AttemptRef, ClaimedJob, ClaimOutcome, GateOutcome, Lease, StoredPublication } from "@tactical-media-broadcast-hub/publishing-execution";
|
||||
import { PublishActivity, type ActivityRepository } from "./publish.activity.js";
|
||||
import type { DispatchGateService, E3Outcome } from "./dispatch-gate.js";
|
||||
|
||||
/**
|
||||
* E3 integration into the dispatch path (chatgov-design.md §4 step 5): the
|
||||
* gate runs before any provider call, settles a blocked destination as a
|
||||
* known no-send, and is skipped (ungated, dispatch as today) when absent.
|
||||
*/
|
||||
|
||||
const ref: AttemptRef = { workspaceId: "workspace", scheduleId: "schedule", generation: "1", idempotencyKey: "key" };
|
||||
const job: ClaimedJob = {
|
||||
lease: { ...ref, attemptId: "attempt", draftId: "draft", ownerToken: "owner", claimEpoch: "1", leaseUntil: new Date(Date.now() + 90_000) },
|
||||
attemptNo: "1", retryCount: 0, post: { text: "hello", mediaAssetIds: [], visibility: "public", providerMetadata: {} },
|
||||
connection: { id: "connection", provider: "telegram", channelId: "channel", fields: {}, createdAt: new Date() },
|
||||
credential: { id: "credential-uuid", generation: "3" }, priorDispatch: undefined,
|
||||
};
|
||||
const publication: StoredPublication = { id: "publication", workspaceId: ref.workspaceId, draftId: "draft", connectionId: "connection", idempotencyKey: "key", remoteId: "remote", publishedAt: new Date(), providerMetadata: {} };
|
||||
|
||||
function harness(claim: ClaimOutcome = { kind: "claimed", job }, gate?: DispatchGateService | null) {
|
||||
let stored = false;
|
||||
const repository: ActivityRepository = {
|
||||
claim: vi.fn(async () => stored ? { kind: "done" as const, publication } : claim),
|
||||
heartbeat: vi.fn(async () => true),
|
||||
withDispatchGate: vi.fn(async (_lease: Lease, _policy: { allowRedispatch?: boolean; dedupeWindowMs?: number }, send: () => Promise<Response>): Promise<GateOutcome<Response>> => ({ kind: "sent", value: await send() })),
|
||||
recordReceipt: vi.fn(async () => ({ inserted: true, receipt: { id: "receipt", workspaceId: ref.workspaceId, attemptId: "attempt", remoteId: "remote", receivedAt: new Date(), providerMetadata: {} } })),
|
||||
finalize: vi.fn(async () => { stored = true; return { kind: "done" as const, publication }; }),
|
||||
retryLater: vi.fn(async () => ({ kind: "settled" as const })), retryDeduplicated: vi.fn(async () => ({ kind: "settled" as const })),
|
||||
pauseForReauthorization: vi.fn(async () => ({ kind: "settled" as const })), fail: vi.fn(async () => ({ kind: "settled" as const })), markUnknown: vi.fn(async () => ({ kind: "settled" as const })),
|
||||
};
|
||||
const fetchImpl = vi.fn(async () => new Response("{}"));
|
||||
const http = new RateLimitedHttpClient({ fetchImpl });
|
||||
const publish = vi.fn(async () => { await http.request("telegram", "https://api.telegram.org/sendMessage", { method: "POST" }, { retry: false }); return publication; });
|
||||
const connector = { publish } as unknown as PlatformConnector;
|
||||
const activity = new PublishActivity(repository, { get: () => connector }, () => ({ heartbeat: vi.fn() }), gate);
|
||||
return { activity, repository, publish, fetchImpl };
|
||||
}
|
||||
|
||||
function gateReturning(outcome: E3Outcome): DispatchGateService {
|
||||
return {
|
||||
check: vi.fn(async () => outcome),
|
||||
} as unknown as DispatchGateService;
|
||||
}
|
||||
|
||||
describe("E3 gate in the dispatch path", () => {
|
||||
it("no gate configured: dispatches exactly as today (existing drafts unchanged)", async () => {
|
||||
const h = harness();
|
||||
await expect(h.activity.publishScheduled(ref)).resolves.toMatchObject({ kind: "done", publicationId: "publication" });
|
||||
expect(h.publish).toHaveBeenCalledTimes(1);
|
||||
expect(h.repository.fail).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("gate fires: send proceeds and no policy settlement occurs", async () => {
|
||||
const gate = gateReturning({ outcome: "fired", reasonCodes: [], policyVersion: "pv1", mode: "immediate", approvedHash: "abc", pollState: null });
|
||||
const h = harness({ kind: "claimed", job }, gate);
|
||||
await expect(h.activity.publishScheduled(ref)).resolves.toMatchObject({ kind: "done" });
|
||||
expect(h.publish).toHaveBeenCalledTimes(1);
|
||||
expect(h.repository.fail).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("approval revoked post-approve: blocks with RESET_ON_CHANGE before any send", async () => {
|
||||
const gate = gateReturning({ outcome: "blocked", reasonCodes: ["RESET_ON_CHANGE"], policyVersion: "pv1", mode: "immediate", approvedHash: null, pollState: null });
|
||||
const h = harness({ kind: "claimed", job }, gate);
|
||||
await expect(h.activity.publishScheduled(ref)).resolves.toEqual({ kind: "failed", code: "POLICY_RESET_ON_CHANGE" });
|
||||
expect(h.fetchImpl).not.toHaveBeenCalled();
|
||||
expect(h.publish).not.toHaveBeenCalled();
|
||||
expect(h.repository.fail).toHaveBeenCalledWith(job.lease, "POLICY_RESET_ON_CHANGE");
|
||||
});
|
||||
|
||||
it("no snapshot: fails closed with POLICY_OUTSIDE_POLICY and zero HTTP requests", async () => {
|
||||
const gate = gateReturning({ outcome: "blocked", reasonCodes: ["OUTSIDE_POLICY"], policyVersion: "none", mode: "none", approvedHash: null, pollState: null });
|
||||
const h = harness({ kind: "claimed", job }, gate);
|
||||
await expect(h.activity.publishScheduled(ref)).resolves.toEqual({ kind: "failed", code: "POLICY_OUTSIDE_POLICY" });
|
||||
expect(h.fetchImpl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("stale settle (lease lost) yields wait, never a second send", async () => {
|
||||
const gate = gateReturning({ outcome: "blocked", reasonCodes: ["RESET_ON_CHANGE"], policyVersion: "pv1", mode: "immediate", approvedHash: null, pollState: null });
|
||||
const h = harness({ kind: "claimed", job }, gate);
|
||||
vi.mocked(h.repository.fail).mockResolvedValue({ kind: "stale" });
|
||||
const outcome = await h.activity.publishScheduled(ref);
|
||||
expect(outcome).toEqual({ kind: "wait", until: expect.any(String) });
|
||||
expect(h.publish).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("gate evaluated per destination with the lease's idempotency key", async () => {
|
||||
const gate = gateReturning({ outcome: "fired", reasonCodes: [], policyVersion: "pv1", mode: "immediate", approvedHash: "abc", pollState: null });
|
||||
const h = harness({ kind: "claimed", job }, gate);
|
||||
await h.activity.publishScheduled(ref);
|
||||
expect(gate.check).toHaveBeenCalledWith(expect.objectContaining({
|
||||
workspaceId: job.lease.workspaceId,
|
||||
draftId: job.lease.draftId,
|
||||
idempotencyKey: job.lease.idempotencyKey,
|
||||
connectionId: job.connection.id,
|
||||
platform: job.connection.provider,
|
||||
}));
|
||||
});
|
||||
});
|
||||
85
apps/scheduler/src/activities/publish.activity.test.ts
Normal file
85
apps/scheduler/src/activities/publish.activity.test.ts
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
import { ApplicationFailure } from "@temporalio/common";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { RateLimitedHttpClient, type PlatformConnector } from "@tactical-media-broadcast-hub/connector-sdk";
|
||||
import type { AttemptRef, ClaimedJob, ClaimOutcome, StoredPublication, Lease, GateOutcome } from "@tactical-media-broadcast-hub/publishing-execution";
|
||||
import { PublishActivity, type ActivityRepository } from "./publish.activity.js";
|
||||
const ref: AttemptRef = { workspaceId: "workspace", scheduleId: "schedule", generation: "1", idempotencyKey: "key" };
|
||||
const job: ClaimedJob = {
|
||||
lease: { ...ref, attemptId: "attempt", draftId: "draft", ownerToken: "owner", claimEpoch: "1", leaseUntil: new Date(Date.now() + 90_000) },
|
||||
attemptNo: "1", retryCount: 0, post: { text: "hello", mediaAssetIds: [], visibility: "public", providerMetadata: {} },
|
||||
connection: { id: "connection", provider: "telegram", channelId: "channel", fields: {}, createdAt: new Date() },
|
||||
credential: { id: "credential-uuid", generation: "3" }, priorDispatch: undefined,
|
||||
};
|
||||
const publication: StoredPublication = { id: "publication", workspaceId: ref.workspaceId, draftId: "draft", connectionId: "connection", idempotencyKey: "key", remoteId: "remote", publishedAt: new Date(), providerMetadata: {} };
|
||||
function harness(claim: ClaimOutcome = { kind: "claimed", job }) {
|
||||
let stored = false;
|
||||
const repository: ActivityRepository = {
|
||||
claim: vi.fn(async () => stored ? { kind: "done" as const, publication } : claim),
|
||||
heartbeat: vi.fn(async () => true),
|
||||
withDispatchGate: vi.fn(async (_lease: Lease, _policy: { allowRedispatch?: boolean; dedupeWindowMs?: number }, send: () => Promise<Response>): Promise<GateOutcome<Response>> => ({ kind: "sent", value: await send() })),
|
||||
recordReceipt: vi.fn(async () => ({ inserted: true, receipt: { id: "receipt", workspaceId: ref.workspaceId, attemptId: "attempt", remoteId: "remote", receivedAt: new Date(), providerMetadata: {} } })),
|
||||
finalize: vi.fn(async () => { stored = true; return { kind: "done" as const, publication }; }),
|
||||
retryLater: vi.fn(async () => ({ kind: "settled" as const })), retryDeduplicated: vi.fn(async () => ({ kind: "settled" as const })),
|
||||
pauseForReauthorization: vi.fn(async () => ({ kind: "settled" as const })), fail: vi.fn(async () => ({ kind: "settled" as const })), markUnknown: vi.fn(async () => ({ kind: "settled" as const })),
|
||||
};
|
||||
const fetchImpl = vi.fn(async () => new Response("{}"));
|
||||
const http = new RateLimitedHttpClient({ fetchImpl });
|
||||
const publish = vi.fn(async () => { await http.request("telegram", "https://api.telegram.org/sendMessage", { method: "POST" }, { retry: false }); return publication; });
|
||||
// Only publish is used by this test composition; production registers complete connectors.
|
||||
const connector = { publish } as unknown as PlatformConnector;
|
||||
const activity = new PublishActivity(repository, { get: () => connector }, () => ({ heartbeat: vi.fn() }));
|
||||
return { activity, repository, publish, fetchImpl };
|
||||
}
|
||||
describe("publishing execution wiring", () => {
|
||||
it("propagates a non-retryable claim refusal without publishing or failing a job", async () => {
|
||||
const h = harness();
|
||||
const claimError = ApplicationFailure.nonRetryable("draft is not approved");
|
||||
vi.mocked(h.repository.claim).mockRejectedValue(claimError);
|
||||
await expect(h.activity.publishScheduled(ref)).rejects.toBe(claimError);
|
||||
expect(claimError.nonRetryable).toBe(true);
|
||||
expect(h.publish).not.toHaveBeenCalled(); expect(h.repository.fail).not.toHaveBeenCalled();
|
||||
});
|
||||
it("returns stored publication without publishing twice", async () => {
|
||||
const h = harness();
|
||||
const first = await h.activity.publishScheduled(ref); const second = await h.activity.publishScheduled(ref);
|
||||
expect(second).toEqual(first); expect(h.publish).toHaveBeenCalledTimes(1);
|
||||
expect(h.repository.recordReceipt).toHaveBeenCalledTimes(1); expect(h.repository.finalize).toHaveBeenCalledTimes(1);
|
||||
expect(h.repository.withDispatchGate).toHaveBeenCalledTimes(1); expect(h.fetchImpl).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it("finalizes durable evidence with zero provider calls", async () => {
|
||||
const h = harness({ kind: "finalize", job, receipt: { id: "receipt", workspaceId: ref.workspaceId, attemptId: "attempt", remoteId: "remote", providerMetadata: {}, receivedAt: new Date() } });
|
||||
await expect(h.activity.publishScheduled(ref)).resolves.toEqual({ kind: "done", publicationId: "publication" });
|
||||
expect(h.publish).not.toHaveBeenCalled(); expect(h.repository.recordReceipt).not.toHaveBeenCalled();
|
||||
});
|
||||
it("never routes a receipt failure into a fresh send or safe retry", async () => {
|
||||
const h = harness(); const error = new Error("database unavailable"); vi.mocked(h.repository.recordReceipt).mockRejectedValue(error);
|
||||
await expect(h.activity.publishScheduled(ref)).rejects.toBe(error);
|
||||
expect(h.publish).toHaveBeenCalledTimes(1); expect(h.repository.retryLater).not.toHaveBeenCalled(); expect(h.repository.fail).not.toHaveBeenCalled(); expect(h.repository.finalize).not.toHaveBeenCalled();
|
||||
});
|
||||
it("refuses a fenced send with zero HTTP requests", async () => {
|
||||
const h = harness(); vi.mocked(h.repository.withDispatchGate).mockResolvedValue({ kind: "paused", code: "AUTHZ_REVOKED" });
|
||||
await expect(h.activity.publishScheduled(ref)).resolves.toEqual({ kind: "paused", code: "AUTHZ_REVOKED" });
|
||||
expect(h.fetchImpl).not.toHaveBeenCalled(); expect(h.repository.retryLater).not.toHaveBeenCalled();
|
||||
});
|
||||
it("does not resend ambiguous Telegram work", async () => {
|
||||
const h = harness({ kind: "claimed", job: { ...job, priorDispatch: { startedAt: new Date(), count: 1 } } });
|
||||
await expect(h.activity.publishScheduled(ref)).resolves.toEqual({ kind: "unknown", code: "PUBLISH_OUTCOME_UNKNOWN" });
|
||||
expect(h.fetchImpl).not.toHaveBeenCalled(); expect(h.repository.markUnknown).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it("pauses an authentication rejection", async () => {
|
||||
const h = harness(); const { AuthError } = await import("@tactical-media-broadcast-hub/connector-sdk"); h.publish.mockRejectedValue(new AuthError("rejected", "telegram", false));
|
||||
await expect(h.activity.publishScheduled(ref)).resolves.toEqual({ kind: "paused", code: "CREDENTIAL_REJECTED" });
|
||||
expect(h.repository.pauseForReauthorization).toHaveBeenCalledTimes(1); expect(h.repository.retryLater).not.toHaveBeenCalled();
|
||||
});
|
||||
it("retains unexpected provider scheduling evidence as unknown without a fabricated Publication", async () => {
|
||||
const h = harness();
|
||||
const http = new RateLimitedHttpClient({ fetchImpl: h.fetchImpl });
|
||||
h.publish.mockImplementation(async () => {
|
||||
await http.request("telegram", "https://api.telegram.org/sendMessage", { method: "POST" }, { retry: false });
|
||||
return { ...publication, remoteId: "scheduled:42", providerMetadata: { outcome: "provider_scheduled", scheduledAt: "2030-01-01T00:00:00.000Z" } };
|
||||
});
|
||||
await expect(h.activity.publishScheduled(ref)).resolves.toEqual({ kind: "unknown", code: "UNEXPECTED_PROVIDER_SCHEDULED" });
|
||||
expect(h.repository.markUnknown).toHaveBeenCalledWith(job.lease, { remoteId: "scheduled:42", scheduledAt: "2030-01-01T00:00:00.000Z" });
|
||||
expect(h.repository.recordReceipt).not.toHaveBeenCalled(); expect(h.repository.finalize).not.toHaveBeenCalled(); expect(h.repository.retryLater).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
164
apps/scheduler/src/activities/publish.activity.ts
Normal file
164
apps/scheduler/src/activities/publish.activity.ts
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
import { Context } from "@temporalio/activity";
|
||||
import type { ConnectorConnection, NormalizedPost } from "@tactical-media-broadcast-hub/core";
|
||||
import type { ConnectorRegistry } from "@tactical-media-broadcast-hub/connector-sdk";
|
||||
import { DispatchFenceError, publishWithPolicy, publishingDispatchPolicy, runWithPublishingContext } from "@tactical-media-broadcast-hub/connector-runtime";
|
||||
import { DispatchGateService, type E3Outcome } from "./dispatch-gate.js";
|
||||
import type { AttemptRef, ClaimedJob, GateOutcome, PublishingExecutionRepository, SettleOutcome, Lease } from "@tactical-media-broadcast-hub/publishing-execution";
|
||||
|
||||
export type ActivityOutcome =
|
||||
| { kind: "done"; publicationId: string }
|
||||
| { kind: "wait"; until: string }
|
||||
| { kind: "paused" | "failed" | "superseded" | "unknown"; code: string };
|
||||
export type ActivityRepository = Pick<PublishingExecutionRepository, "claim" | "heartbeat" | "recordReceipt" | "finalize" | "retryLater" | "retryDeduplicated" | "pauseForReauthorization" | "markUnknown" | "fail"> & { withDispatchGate(lease: Lease, policy: { allowRedispatch?: boolean; dedupeWindowMs?: number }, send: () => Promise<Response>): Promise<GateOutcome<Response>> };
|
||||
export interface ActivityLifecycle { heartbeat(): void; signal?: AbortSignal }
|
||||
function temporalLifecycle(): ActivityLifecycle {
|
||||
const context = Context.current();
|
||||
return { heartbeat: () => context.heartbeat(), signal: context.cancellationSignal };
|
||||
}
|
||||
function connectionOf(job: ClaimedJob): ConnectorConnection {
|
||||
const fields: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(job.connection.fields)) if (typeof value === "string") fields[key] = value;
|
||||
return { ...job.connection, fields, workspaceId: job.lease.workspaceId, credentialId: { kind: "credential-ref", id: job.connection.id } };
|
||||
}
|
||||
function postOf(job: ClaimedJob): NormalizedPost {
|
||||
const visibility = job.post.visibility;
|
||||
if (!["public", "unlisted", "private", "followers", "provider_default"].includes(visibility)) throw new Error("INVALID_POST_VISIBILITY");
|
||||
return { ...job.post, visibility: visibility as NormalizedPost["visibility"] };
|
||||
}
|
||||
/** Persist only documented remote evidence; unknown fields never enter DB/history. */
|
||||
export function safeMetadata(metadata: Record<string, unknown>): Record<string, unknown> {
|
||||
const result: Record<string, unknown> = {};
|
||||
for (const key of ["messageId", "chatId", "channelId", "outcome", "scheduledAt"]) {
|
||||
const value = metadata[key];
|
||||
if ((typeof value === "string" && value.length <= 512) || (typeof value === "number" && Number.isSafeInteger(value))) result[key] = value;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
class GateRefusal extends Error {
|
||||
constructor(readonly outcome: Exclude<GateOutcome<Response>, { kind: "sent" }>) { super("DISPATCH_FENCE_REFUSED"); }
|
||||
}
|
||||
export class PublishActivity {
|
||||
/**
|
||||
* E3 chatgov pre-dispatch re-check (chatgov-design.md §4 step 5). Optional:
|
||||
* `undefined` preserves today's ungated dispatch (used by suites whose DB
|
||||
* predates the chatgov spine); `null` explicitly disables it.
|
||||
*/
|
||||
constructor(
|
||||
private readonly repository: ActivityRepository,
|
||||
private readonly registry: Pick<ConnectorRegistry, "get">,
|
||||
private readonly lifecycle: () => ActivityLifecycle = temporalLifecycle,
|
||||
private readonly dispatchGate?: DispatchGateService | null,
|
||||
) {}
|
||||
|
||||
/** E3 re-check before any send. Authorization fences are NOT duplicated:
|
||||
* the repository's claim + withDispatchGate already re-read every live
|
||||
* authz input; this gate adds the governance side (approval + policy
|
||||
* currency) and blocks closed when it cannot prove a destination is
|
||||
* inside policy. Absent gate or absent chatgov tables => dispatch as today. */
|
||||
private async e3Check(lease: Lease, job: ClaimedJob): Promise<E3Outcome | undefined> {
|
||||
const gate = this.dispatchGate ?? null;
|
||||
if (gate === null) return undefined;
|
||||
try {
|
||||
return await gate.check({
|
||||
workspaceId: lease.workspaceId,
|
||||
draftId: lease.draftId,
|
||||
idempotencyKey: lease.idempotencyKey,
|
||||
connectionId: job.connection.id,
|
||||
platform: job.connection.provider,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes("does not exist")) return undefined; // chatgov tables absent: ungated
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async publishScheduled(ref: AttemptRef): Promise<ActivityOutcome> {
|
||||
const claim = await this.repository.claim(ref);
|
||||
if (claim.kind === "done") return { kind: "done", publicationId: claim.publication.id };
|
||||
if (claim.kind === "wait") return { kind: "wait", until: claim.until.toISOString() };
|
||||
if (claim.kind !== "claimed" && claim.kind !== "finalize") return claim;
|
||||
const job = claim.job;
|
||||
const lease = job.lease;
|
||||
const finalize = async (): Promise<ActivityOutcome> => {
|
||||
const result = await this.repository.finalize(lease);
|
||||
return result.kind === "done" ? { kind: "done", publicationId: result.publication.id } : { kind: "wait", until: new Date(Date.now() + 1000).toISOString() };
|
||||
};
|
||||
// Evidence/finalization errors propagate to infrastructure retries. They
|
||||
// never pass through provider failure classification or trigger a send.
|
||||
if (claim.kind === "finalize") return finalize();
|
||||
const lifecycle = this.lifecycle();
|
||||
const abort = new AbortController();
|
||||
const signal = AbortSignal.any([abort.signal, ...(lifecycle.signal ? [lifecycle.signal] : [])]);
|
||||
const heartbeatTimer = setInterval(() => { try { lifecycle.heartbeat(); } catch { abort.abort(); } }, 5000);
|
||||
let renewal: Promise<void> | undefined;
|
||||
const timer = setInterval(() => {
|
||||
if (renewal) return;
|
||||
renewal = this.repository.heartbeat(lease).then((owned) => { if (!owned) abort.abort(); else lifecycle.heartbeat(); }).catch(() => abort.abort()).finally(() => { renewal = undefined; });
|
||||
}, 10_000);
|
||||
const settle = (result: SettleOutcome, outcome: ActivityOutcome): ActivityOutcome => result.kind === "stale" ? { kind: "wait", until: new Date(Date.now() + 1000).toISOString() } : outcome;
|
||||
try {
|
||||
lifecycle.heartbeat();
|
||||
const connection = connectionOf(job);
|
||||
// E3: chatgov pre-dispatch re-check, per destination, before any send.
|
||||
const gate = await this.e3Check(lease, job);
|
||||
if (gate !== undefined && gate.outcome === "blocked") {
|
||||
// Known no-send: settle the attempt failed (governance refusal) so the
|
||||
// blocked destination surfaces in the failed schedule + gate_decisions.
|
||||
const settled = await this.repository.fail(lease, `POLICY_${gate.reasonCodes[0] ?? "OUTSIDE_POLICY"}`);
|
||||
return settled.kind === "stale"
|
||||
? { kind: "wait", until: new Date(Date.now() + 1000).toISOString() }
|
||||
: { kind: "failed", code: `POLICY_${gate.reasonCodes[0] ?? "OUTSIDE_POLICY"}` };
|
||||
}
|
||||
const connector = this.registry.get(connection.provider);
|
||||
const { policy, origin, dedupeVerified } = publishingDispatchPolicy(connection);
|
||||
let outcome;
|
||||
try {
|
||||
outcome = await runWithPublishingContext({ workspaceId: lease.workspaceId, connectionId: connection.id, credentialId: job.credential.id, credentialGeneration: job.credential.generation, signal }, () => publishWithPolicy(connector, connection, postOf(job), {
|
||||
idempotencyKey: lease.idempotencyKey, policy, dedupeVerified,
|
||||
firstDispatchAt: job.firstDispatchStartedAt,
|
||||
unsettledPriorDispatch: job.priorDispatch !== undefined,
|
||||
aroundMutation: async (event, send) => {
|
||||
signal.throwIfAborted();
|
||||
const gate = await this.repository.withDispatchGate(lease, { allowRedispatch: dedupeVerified && event.origin === origin, dedupeWindowMs: dedupeVerified && event.origin === origin ? policy.dedupeWindowMs : 0 }, send);
|
||||
if (gate.kind === "sent") return gate.value;
|
||||
throw new DispatchFenceError(job.priorDispatch !== undefined, new GateRefusal(gate));
|
||||
},
|
||||
}));
|
||||
} catch (error) {
|
||||
if (error instanceof DispatchFenceError && error.cause instanceof GateRefusal) {
|
||||
const gate = error.cause.outcome;
|
||||
if (gate.kind === "finalize") return finalize();
|
||||
if (gate.kind === "lost") return { kind: "wait", until: new Date(Date.now() + 1000).toISOString() };
|
||||
return gate;
|
||||
}
|
||||
// Infrastructure faults are retried by Temporal and re-claim the same journal.
|
||||
throw error;
|
||||
}
|
||||
if (outcome.kind === "published") {
|
||||
const receipt = outcome.receipt;
|
||||
await this.repository.recordReceipt(lease, { remoteId: receipt.remoteId, remoteUrl: receipt.remoteUrl, publishedAt: receipt.publishedAt, providerMetadata: safeMetadata(receipt.providerMetadata) });
|
||||
return finalize();
|
||||
}
|
||||
if (outcome.kind === "provider_scheduled") {
|
||||
// This is remote evidence, not a published receipt. Keep it in the
|
||||
// unknown journal via a safe audit; never manufacture a Publication.
|
||||
return settle(await this.repository.markUnknown(lease, { remoteId: outcome.scheduledRemoteId, scheduledAt: outcome.scheduledAt.toISOString() }), { kind: "unknown", code: "UNEXPECTED_PROVIDER_SCHEDULED" });
|
||||
}
|
||||
if (outcome.kind === "unknown") return settle(await this.repository.markUnknown(lease), { kind: "unknown", code: outcome.code });
|
||||
if (outcome.kind === "rejected_permanent") {
|
||||
// An earlier ambiguous send cannot be disproved by this rejection.
|
||||
if (job.priorDispatch) return settle(await this.repository.markUnknown(lease), { kind: "unknown", code: "PUBLISH_OUTCOME_UNKNOWN" });
|
||||
return outcome.needsReauthorization
|
||||
? settle(await this.repository.pauseForReauthorization(lease, outcome.code), { kind: "paused", code: outcome.code })
|
||||
: settle(await this.repository.fail(lease, outcome.code), { kind: "failed", code: outcome.code });
|
||||
}
|
||||
const delay = Math.max(outcome.retryAfterMs ?? 0, Math.min(300_000, 1000 * 2 ** job.retryCount));
|
||||
const until = new Date(Date.now() + delay);
|
||||
const result = outcome.resend === "deduplicated"
|
||||
? await this.repository.retryDeduplicated(lease, outcome.code, until)
|
||||
: await this.repository.retryLater(lease, outcome.code, until);
|
||||
// Settlement may exhaust the budget: re-claim reads the authoritative terminal state.
|
||||
return settle(result, { kind: "wait", until: (result.kind === "settled" ? result.until ?? until : until).toISOString() });
|
||||
} finally { clearInterval(timer); clearInterval(heartbeatTimer); await renewal; }
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue