diff --git a/.changeset/calm-workflows-arrive.md b/.changeset/calm-workflows-arrive.md new file mode 100644 index 0000000000..d69a5474e5 --- /dev/null +++ b/.changeset/calm-workflows-arrive.md @@ -0,0 +1,7 @@ +--- +"@agent-native/core": minor +--- + +Add a durable, actor-attributed workflow event spine with immutable subscription versions, sequence-bounded virtual defaults, execution/effect/delivery truth, and one leased scheduled-work authority for retries, delays, debounce, escalation, and access-scoped runtime pause controls. + +Add evidence-aware notification outcomes and user-scoped inbox, browser, email, and personal Slack delivery routing without storing destination secrets in notification settings. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 85d54030ff..2c51a2291d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -174,6 +174,45 @@ jobs: - name: Run Core integration tests run: pnpm test:core-integration + workflow-postgres-tests: + name: Workflow Postgres tests + runs-on: ubuntu-latest + timeout-minutes: 20 + services: + postgres: + image: postgres:16 + env: + POSTGRES_USER: postgres + POSTGRES_HOST_AUTH_METHOD: trust + POSTGRES_DB: workflow_test + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + env: + DATABASE_URL: postgres://postgres@127.0.0.1:5432/workflow_test + WORKFLOW_POSTGRES_TEST_URL: postgres://postgres@127.0.0.1:5432/workflow_test + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + + - uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0 + + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: "22" + cache: "pnpm" + + - name: Restore dist + tsBuildInfo cache + uses: ./.github/actions/restore-dist-cache + + - run: pnpm install --frozen-lockfile + + - name: Run workflow Postgres integration tests + run: pnpm --filter @agent-native/core exec vitest --run src/workflow/store.postgres.integration.spec.ts + plan-e2e-tests: name: Plan E2E tests runs-on: ubuntu-latest diff --git a/packages/core/package.json b/packages/core/package.json index e80bcc63b5..08a7e69e3d 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -115,6 +115,7 @@ "./localization/actions/set-localization-preference": "./dist/localization/actions/set-localization-preference.js", "./credentials": "./dist/credentials/index.js", "./event-bus": "./dist/event-bus/index.js", + "./workflow": "./dist/workflow/index.js", "./fetch-tool": "./dist/extensions/fetch-tool.js", "./extensions/url-safety": "./dist/extensions/url-safety.js", "./tools/url-safety": "./dist/extensions/url-safety.js", diff --git a/packages/core/src/client/notifications/NotificationsBell.layout.spec.ts b/packages/core/src/client/notifications/NotificationsBell.layout.spec.ts new file mode 100644 index 0000000000..aa2fa8b65f --- /dev/null +++ b/packages/core/src/client/notifications/NotificationsBell.layout.spec.ts @@ -0,0 +1,36 @@ +import { readFileSync } from "node:fs"; + +import { describe, expect, it } from "vitest"; + +const source = readFileSync( + new URL("./NotificationsBell.tsx", import.meta.url), + "utf8", +); + +describe("NotificationsBell routing layout", () => { + it("progressively discloses personal routing without exposing secret values", () => { + expect(source).toContain('aria-label="Notification delivery settings"'); + expect(source).toContain('label="In-app inbox"'); + expect(source).toContain('label="Browser alerts"'); + expect(source).toContain('label="Email"'); + expect(source).toContain('label="Personal Slack"'); + expect(source).toContain("Webhook secret key name"); + expect(source).toContain("Store its webhook URL in Secrets"); + expect(source).not.toContain("Team Slack webhook"); + }); + + it("uses named client methods instead of route fetches", () => { + expect(source).not.toContain("fetch("); + expect(source).not.toContain("/_agent-native/notifications"); + expect(source).toContain("updatePersonalNotificationRouting"); + expect(source).toContain("listClientNotifications"); + }); + + it("keeps host-specific notification settings in the bell menu", () => { + expect(source).toContain("contextualSettings?: ReactNode"); + expect(source).toContain("{contextualSettings}"); + expect(source.indexOf("{contextualSettings}")).toBeGreaterThan( + source.indexOf('className="max-h-96 overflow-y-auto"'), + ); + }); +}); diff --git a/packages/core/src/client/notifications/NotificationsBell.tsx b/packages/core/src/client/notifications/NotificationsBell.tsx index c596c4c6fb..e58197836c 100644 --- a/packages/core/src/client/notifications/NotificationsBell.tsx +++ b/packages/core/src/client/notifications/NotificationsBell.tsx @@ -1,22 +1,41 @@ import { + IconArrowLeft, IconBell, IconBellRinging, + IconCheck, IconLoader2, + IconSettings, IconX, } from "@tabler/icons-react"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { + useCallback, + useEffect, + useRef, + useState, + type ReactNode, +} from "react"; +import type { PersonalNotificationRouting } from "../../notifications/routing.js"; import type { Notification as NotificationDto, NotificationSeverity, } from "../../notifications/types.js"; -import { agentNativePath, appPath } from "../api-path.js"; +import { appPath } from "../api-path.js"; import { Popover, PopoverContent, PopoverTrigger, } from "../components/ui/popover.js"; import { usePausingInterval } from "../use-pausing-interval.js"; +import { + countClientUnreadNotifications, + dismissClientNotification, + getPersonalNotificationRouting, + listClientNotifications, + markAllClientNotificationsRead, + markClientNotificationRead, + updatePersonalNotificationRouting, +} from "./api.js"; interface NotificationsBellProps { /** Poll interval in ms. Set to 0 to disable polling. Default: 10000. */ @@ -36,11 +55,20 @@ interface NotificationsBellProps { emptyDescription?: string; /** Optional notification for parent shells that need to coordinate overlays. */ onOpenChange?: (open: boolean) => void; + /** Optional host-rendered settings for the resource currently in view. */ + contextualSettings?: ReactNode; } const POLL_MS_DEFAULT = 10_000; const SUPPORTS_NOTIFICATION = typeof window !== "undefined" && "Notification" in window; +const DEFAULT_ROUTING: PersonalNotificationRouting = { + inbox: true, + browser: true, + email: false, + personalSlack: false, + personalSlackWebhookKey: null, +}; /** * Header-bar bell that shows the unread-notification count and a dropdown of @@ -56,10 +84,18 @@ export function NotificationsBell({ emptyTitle = "No app notifications yet.", emptyDescription, onOpenChange, + contextualSettings, }: NotificationsBellProps) { const [unreadCount, setUnreadCount] = useState(0); const [open, setOpen] = useState(false); const [items, setItems] = useState(null); + const [routing, setRouting] = + useState(DEFAULT_ROUTING); + const [routingDraft, setRoutingDraft] = + useState(DEFAULT_ROUTING); + const [showRouting, setShowRouting] = useState(false); + const [routingSaving, setRoutingSaving] = useState(false); + const [routingError, setRoutingError] = useState(null); // Init to "default" unconditionally so server and client render the same // HTML — reading Notification.permission at init would diverge between SSR // ("denied", no API) and hydration ("default"/"granted"), causing a mismatch @@ -70,6 +106,12 @@ export function NotificationsBell({ useEffect(() => { if (SUPPORTS_NOTIFICATION) setPermission(Notification.permission); + getPersonalNotificationRouting() + .then((next) => { + setRouting(next); + setRoutingDraft(next); + }) + .catch(() => {}); }, []); // Ids already popped as browser notifications. Seeded on first run so // existing unread don't pop retroactively on page load. @@ -77,11 +119,7 @@ export function NotificationsBell({ const loadItems = useCallback(async () => { try { - const res = await fetch( - agentNativePath("/_agent-native/notifications?limit=20"), - ); - if (!res.ok) return; - const rows = (await res.json()) as NotificationDto[]; + const rows = await listClientNotifications({ limit: 20 }); setItems(rows); } catch { // best-effort @@ -94,13 +132,12 @@ export function NotificationsBell({ // any new ids. When off, we fetch just /count. The unread-list branch also // opts out of visibility pause so popups still fire for backgrounded tabs. const refresh = useCallback(async () => { - if (browserNotifications) { + if (browserNotifications && routing.browser) { try { - const res = await fetch( - agentNativePath("/_agent-native/notifications?unread=true&limit=20"), - ); - if (!res.ok) return; - const rows = (await res.json()) as NotificationDto[]; + const rows = await listClientNotifications({ + unreadOnly: true, + limit: 20, + }); setUnreadCount(rows.length); // First run: treat everything as already seen so we don't pop // retroactively on page load. After that, rebuild from the current @@ -128,16 +165,11 @@ export function NotificationsBell({ return; } try { - const res = await fetch( - agentNativePath("/_agent-native/notifications/count"), - ); - if (!res.ok) return; - const data = (await res.json()) as { count: number }; - setUnreadCount(data.count); + setUnreadCount(await countClientUnreadNotifications()); } catch { // best-effort } - }, [browserNotifications]); + }, [browserNotifications, routing.browser]); usePausingInterval( refresh, @@ -155,10 +187,7 @@ export function NotificationsBell({ // `keepalive: true` lets the request survive page navigation — // without it, clicking a notification with a link aborts this // request mid-flight and the row stays unread. - await fetch(agentNativePath(`/_agent-native/notifications/${id}/read`), { - method: "POST", - keepalive: true, - }); + await markClientNotificationRead(id); setItems((prev) => prev ? prev.map((n) => @@ -193,9 +222,7 @@ export function NotificationsBell({ const markAllRead = async () => { try { - await fetch(agentNativePath(`/_agent-native/notifications/read-all`), { - method: "POST", - }); + await markAllClientNotificationsRead(); setItems((prev) => prev ? prev.map((n) => @@ -211,9 +238,7 @@ export function NotificationsBell({ const dismiss = async (id: string) => { try { - await fetch(agentNativePath(`/_agent-native/notifications/${id}`), { - method: "DELETE", - }); + await dismissClientNotification(id); setItems((prev) => (prev ? prev.filter((n) => n.id !== id) : prev)); refresh(); } catch { @@ -225,9 +250,31 @@ export function NotificationsBell({ const Icon = hasUnread ? IconBellRinging : IconBell; const setOpenAndNotify = (value: boolean) => { setOpen(value); + if (!value) { + setShowRouting(false); + setRoutingDraft(routing); + setRoutingError(null); + } onOpenChange?.(value); }; + const saveRouting = async () => { + setRoutingSaving(true); + setRoutingError(null); + try { + const saved = await updatePersonalNotificationRouting(routingDraft); + setRouting(saved); + setRoutingDraft(saved); + setShowRouting(false); + } catch (error) { + setRoutingError( + error instanceof Error ? error.message : "Could not save routing.", + ); + } finally { + setRoutingSaving(false); + } + }; + return ( @@ -258,112 +305,328 @@ export function NotificationsBell({ className="an-notifications-bell__menu w-80 p-0" >
- Notifications - {hasUnread ? ( - - ) : null} -
- {browserNotifications && - SUPPORTS_NOTIFICATION && - permission === "default" ? ( -
- Get a system popup for new notifications. - +
+ {showRouting ? ( + + ) : null} + {showRouting ? "Delivery settings" : "Notifications"}
- ) : null} -
- {items === null ? ( -
- Loading… + {!showRouting ? ( +
+ {hasUnread ? ( + + ) : null} +
- ) : items.length > 0 ? ( - items.map((n) => { - const rawLink = - typeof n.metadata?.link === "string" ? n.metadata.link : null; - const link = rawLink ? safeNotificationLink(rawLink) : null; - const onItemClick = () => { - if (!n.readAt) void markRead(n.id); - if (link) { - setOpenAndNotify(false); - window.location.assign(link); - } - }; - return ( -
+ {showRouting ? ( + void saveRouting()} + /> + ) : ( + <> + {browserNotifications && + routing.browser && + SUPPORTS_NOTIFICATION && + permission === "default" ? ( +
+ Get a system popup for new notifications. +
+ ) : null} +
+ {items === null ? ( +
+ Loading… +
+ ) : items.length > 0 ? ( + items.map((n) => { + const rawLink = + typeof n.metadata?.link === "string" + ? n.metadata.link + : null; + const link = rawLink ? safeNotificationLink(rawLink) : null; + const onItemClick = () => { + if (!n.readAt) void markRead(n.id); + if (link) { + setOpenAndNotify(false); + window.location.assign(link); } - > -
- - {n.title} - - + }; + return ( +
+ +
- {n.body ? ( - - {n.body} - - ) : null} - - {new Date(n.createdAt).toLocaleString()} - - - + ); + }) + ) : ( +
+

{emptyTitle}

+ {emptyDescription ? ( +

+ {emptyDescription} +

+ ) : null}
- ); - }) - ) : ( -
-

{emptyTitle}

- {emptyDescription ? ( -

- {emptyDescription} -

- ) : null} + )}
- )} -
+ {contextualSettings ? ( +
{contextualSettings}
+ ) : null} + + )} ); } +function NotificationRoutingEditor({ + value, + saving, + error, + onChange, + onSave, +}: { + value: PersonalNotificationRouting; + saving: boolean; + error: string | null; + onChange: (value: PersonalNotificationRouting) => void; + onSave: () => void; +}) { + const slackKeyMissing = + value.personalSlack && !value.personalSlackWebhookKey?.trim(); + return ( +
+
+

+ Choose where notifications meant for you should arrive. +

+
+ + onChange({ + ...value, + inbox, + browser: inbox ? value.browser : false, + }) + } + /> + onChange({ ...value, browser })} + /> + onChange({ ...value, email })} + /> +
+ + onChange({ ...value, personalSlack }) + } + /> + {value.personalSlack ? ( +
+ + + onChange({ + ...value, + personalSlackWebhookKey: + event.currentTarget.value.toUpperCase() || null, + }) + } + placeholder="PERSONAL_SLACK_WEBHOOK" + autoCapitalize="characters" + autoComplete="off" + spellCheck={false} + aria-invalid={slackKeyMissing} + aria-describedby="personal-slack-webhook-key-help" + className="h-8 w-full rounded-md border border-input bg-background px-2 text-xs text-foreground outline-none placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring aria-invalid:border-destructive" + /> +

+ Enter only the key name. Store its webhook URL in Secrets. +

+
+ ) : null} +
+
+ {error ? ( +

+ {error} +

+ ) : null} +
+
+ +
+
+ ); +} + +function RoutingToggle({ + label, + description, + checked, + disabled = false, + onCheckedChange, +}: { + label: string; + description: string; + checked: boolean; + disabled?: boolean; + onCheckedChange: (checked: boolean) => void; +}) { + return ( +
+
+

{label}

+

+ {description} +

+
+ +
+ ); +} + // Severity color pairs — use /20 opacity backdrops that work against both // light and dark theme backgrounds; text uses 700/300 so it stays readable // in each mode (the `dark:` prefix is one of the few places where explicit diff --git a/packages/core/src/client/notifications/api.spec.ts b/packages/core/src/client/notifications/api.spec.ts new file mode 100644 index 0000000000..afbb4ac0f3 --- /dev/null +++ b/packages/core/src/client/notifications/api.spec.ts @@ -0,0 +1,123 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + countClientUnreadNotifications, + dismissClientNotification, + getPersonalNotificationRouting, + listClientNotifications, + markAllClientNotificationsRead, + markClientNotificationRead, + NotificationRoutingClientError, + updatePersonalNotificationRouting, +} from "./api.js"; + +const routing = { + inbox: true, + browser: true, + email: false, + personalSlack: false, + personalSlackWebhookKey: null, +}; + +describe("notification routing client", () => { + afterEach(() => { + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); + }); + + it("reads routing through the mounted framework path", async () => { + const fetch = vi + .fn() + .mockResolvedValue( + new Response(JSON.stringify(routing), { status: 200 }), + ); + vi.stubGlobal("fetch", fetch); + vi.stubGlobal("window", { location: { pathname: "/content/page/one" } }); + vi.stubEnv("VITE_APP_BASE_PATH", "/content"); + + await expect(getPersonalNotificationRouting()).resolves.toEqual(routing); + expect(fetch).toHaveBeenCalledWith( + "/content/_agent-native/notifications/routing", + { signal: undefined }, + ); + }); + + it("writes only the routing profile supplied by the caller", async () => { + const next = { + ...routing, + email: true, + personalSlack: true, + personalSlackWebhookKey: "PERSONAL_SLACK_WEBHOOK", + }; + const fetch = vi + .fn() + .mockResolvedValue(new Response(JSON.stringify(next), { status: 200 })); + vi.stubGlobal("fetch", fetch); + vi.stubGlobal("window", { location: { pathname: "/" } }); + + await expect(updatePersonalNotificationRouting(next)).resolves.toEqual( + next, + ); + expect(fetch).toHaveBeenCalledWith("/_agent-native/notifications/routing", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(next), + signal: undefined, + }); + expect(JSON.stringify(fetch.mock.calls)).not.toContain("hooks.slack.com"); + }); + + it("surfaces route validation errors", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue( + new Response(JSON.stringify({ error: "Secret key required" }), { + status: 400, + }), + ), + ); + vi.stubGlobal("window", { location: { pathname: "/" } }); + + await expect(updatePersonalNotificationRouting(routing)).rejects.toEqual( + new NotificationRoutingClientError("Secret key required", 400), + ); + }); + + it("keeps inbox transport details behind named helpers", async () => { + const fetch = vi + .fn() + .mockResolvedValueOnce(new Response(JSON.stringify([]))) + .mockResolvedValueOnce(new Response(JSON.stringify({ count: 2 }))) + .mockImplementation( + async () => new Response(JSON.stringify({ ok: true }), { status: 200 }), + ); + vi.stubGlobal("fetch", fetch); + vi.stubGlobal("window", { location: { pathname: "/" } }); + + await listClientNotifications({ unreadOnly: true, limit: 20 }); + await expect(countClientUnreadNotifications()).resolves.toBe(2); + await markClientNotificationRead("notification/one"); + await markAllClientNotificationsRead(); + await dismissClientNotification("notification/one"); + + expect(fetch.mock.calls).toEqual([ + [ + "/_agent-native/notifications?unread=true&limit=20", + { signal: undefined }, + ], + ["/_agent-native/notifications/count", { signal: undefined }], + [ + "/_agent-native/notifications/notification%2Fone/read", + { method: "POST", keepalive: true, signal: undefined }, + ], + [ + "/_agent-native/notifications/read-all", + { method: "POST", signal: undefined }, + ], + [ + "/_agent-native/notifications/notification%2Fone", + { method: "DELETE", signal: undefined }, + ], + ]); + }); +}); diff --git a/packages/core/src/client/notifications/api.ts b/packages/core/src/client/notifications/api.ts new file mode 100644 index 0000000000..c0ec78dd8e --- /dev/null +++ b/packages/core/src/client/notifications/api.ts @@ -0,0 +1,113 @@ +import type { PersonalNotificationRouting } from "../../notifications/routing.js"; +import type { Notification } from "../../notifications/types.js"; +import { agentNativePath } from "../api-path.js"; + +export class NotificationRoutingClientError extends Error { + constructor( + message: string, + readonly status: number, + ) { + super(message); + this.name = "NotificationRoutingClientError"; + } +} + +export async function getPersonalNotificationRouting( + signal?: AbortSignal, +): Promise { + return notificationRequest( + "/_agent-native/notifications/routing", + undefined, + signal, + ); +} + +export async function updatePersonalNotificationRouting( + routing: PersonalNotificationRouting, + signal?: AbortSignal, +): Promise { + return notificationRequest( + "/_agent-native/notifications/routing", + { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(routing), + }, + signal, + ); +} + +export async function listClientNotifications( + options: { unreadOnly?: boolean; limit?: number } = {}, + signal?: AbortSignal, +): Promise { + const query = new URLSearchParams(); + if (options.unreadOnly) query.set("unread", "true"); + if (options.limit) query.set("limit", String(options.limit)); + const suffix = query.size ? `?${query}` : ""; + return notificationRequest( + `/_agent-native/notifications${suffix}`, + undefined, + signal, + ); +} + +export async function countClientUnreadNotifications( + signal?: AbortSignal, +): Promise { + const result = await notificationRequest<{ count: number }>( + "/_agent-native/notifications/count", + undefined, + signal, + ); + return result.count; +} + +export async function markClientNotificationRead(id: string): Promise { + await notificationRequest( + `/_agent-native/notifications/${encodeURIComponent(id)}/read`, + { method: "POST", keepalive: true }, + ); +} + +export async function markAllClientNotificationsRead(): Promise { + await notificationRequest("/_agent-native/notifications/read-all", { + method: "POST", + }); +} + +export async function dismissClientNotification(id: string): Promise { + await notificationRequest( + `/_agent-native/notifications/${encodeURIComponent(id)}`, + { method: "DELETE" }, + ); +} + +async function notificationRequest( + path: string, + init?: RequestInit, + signal?: AbortSignal, +): Promise { + const response = await fetch(agentNativePath(path), { ...init, signal }); + let payload: unknown; + try { + payload = await response.json(); + } catch { + throw new NotificationRoutingClientError( + response.ok + ? "Notification routing response was not valid JSON." + : response.statusText || `Request failed (HTTP ${response.status})`, + response.status, + ); + } + if (!response.ok) { + const message = + payload && + typeof payload === "object" && + typeof (payload as { error?: unknown }).error === "string" + ? (payload as { error: string }).error + : response.statusText || `Request failed (HTTP ${response.status})`; + throw new NotificationRoutingClientError(message, response.status); + } + return payload as T; +} diff --git a/packages/core/src/client/notifications/index.ts b/packages/core/src/client/notifications/index.ts index e3faf615bb..746c041e19 100644 --- a/packages/core/src/client/notifications/index.ts +++ b/packages/core/src/client/notifications/index.ts @@ -1 +1,11 @@ export { NotificationsBell } from "./NotificationsBell.js"; +export { + getPersonalNotificationRouting, + updatePersonalNotificationRouting, + listClientNotifications, + countClientUnreadNotifications, + markClientNotificationRead, + markAllClientNotificationsRead, + dismissClientNotification, + NotificationRoutingClientError, +} from "./api.js"; diff --git a/packages/core/src/db-admin/operations.ts b/packages/core/src/db-admin/operations.ts index 070c596a0b..795f7c98df 100644 --- a/packages/core/src/db-admin/operations.ts +++ b/packages/core/src/db-admin/operations.ts @@ -19,6 +19,10 @@ import { type DbExec, type Dialect, } from "../db/client.js"; +import { + assertMutationTableIsNotProtected, + assertSqlDoesNotMutateProtectedTable, +} from "../db/protected-mutations.js"; import { notifyActionChange } from "../server/action-change.js"; import type { DbAdminColumn, @@ -659,6 +663,7 @@ export async function applyMutations( runtime?: DbAdminRuntime, ): Promise { assertIdent(table, "table name"); + assertMutationTableIsNotProtected(table); const statements: { sql: string; args: unknown[] }[] = []; for (const row of m.inserts ?? []) statements.push(buildInsert(table, row)); @@ -762,6 +767,8 @@ export async function runSql( throw new Error("SQL is required"); } + assertSqlDoesNotMutateProtectedTable(sql); + if (isDestructiveSql(sql) && !opts.confirmDestructive) { throw new DbAdminConfirmRequiredError( "This statement is destructive (DROP / TRUNCATE / unscoped DELETE or UPDATE). Re-run with confirmDestructive: true to proceed.", diff --git a/packages/core/src/db/index.ts b/packages/core/src/db/index.ts index a43af3aebd..4f2c5dbc02 100644 --- a/packages/core/src/db/index.ts +++ b/packages/core/src/db/index.ts @@ -55,3 +55,10 @@ export { type EnsureAdditiveColumnsResult, type EnsureAdditiveColumnsLogger, } from "./ensure-additive-columns.js"; +export { + registerProtectedMutationTables, + assertMutationTableIsNotProtected, + assertSqlDoesNotMutateProtectedTable, + protectedMutationTableFromSql, + type ProtectedMutationTablesRegistration, +} from "./protected-mutations.js"; diff --git a/packages/core/src/db/protected-mutations.spec.ts b/packages/core/src/db/protected-mutations.spec.ts new file mode 100644 index 0000000000..17bd867709 --- /dev/null +++ b/packages/core/src/db/protected-mutations.spec.ts @@ -0,0 +1,74 @@ +import { afterEach, describe, expect, it } from "vitest"; + +import { + __resetProtectedMutationTables, + assertMutationTableIsNotProtected, + assertSqlDoesNotMutateProtectedTable, + registerProtectedMutationTables, +} from "./protected-mutations.js"; + +describe("protected mutation tables", () => { + afterEach(() => __resetProtectedMutationTables()); + + it("rejects raw writes to registered domain tables", () => { + registerProtectedMutationTables({ + tables: ["documents", "document_property_values"], + guidance: "Use a Content action instead.", + }); + + expect(() => + assertSqlDoesNotMutateProtectedTable( + "UPDATE documents SET title = ? WHERE id = ?", + ), + ).toThrow(/durable change events.*Content action/i); + expect(() => + assertSqlDoesNotMutateProtectedTable( + "INSERT INTO document_property_values (id) VALUES (?)", + ), + ).toThrow(/protected/i); + }); + + it("normalizes quoted names and leaves unrelated tables writable", () => { + registerProtectedMutationTables({ + tables: ["documents"], + guidance: "Use update-document.", + }); + + expect(() => assertMutationTableIsNotProtected('"documents"')).toThrow( + /update-document/, + ); + expect(() => + assertSqlDoesNotMutateProtectedTable( + "UPDATE application_state SET value = ? WHERE key = ?", + ), + ).not.toThrow(); + }); + + it("finds protected writes inside CTEs without matching comments or values", () => { + registerProtectedMutationTables({ + tables: ["documents"], + guidance: "Use update-document.", + }); + + expect(() => + assertSqlDoesNotMutateProtectedTable( + "WITH chosen AS (SELECT 'UPDATE documents SET title = 1' AS note) UPDATE \"documents\" SET title = ? WHERE id = ?", + ), + ).toThrow(/protected/i); + expect(() => + assertSqlDoesNotMutateProtectedTable( + "UPDATE notes SET body = 'UPDATE documents SET title = 1' WHERE id = ? -- DELETE FROM documents", + ), + ).not.toThrow(); + }); + + it("allows unregistering an app manifest", () => { + const unregister = registerProtectedMutationTables({ + tables: ["documents"], + guidance: "Use update-document.", + }); + unregister(); + + expect(() => assertMutationTableIsNotProtected("documents")).not.toThrow(); + }); +}); diff --git a/packages/core/src/db/protected-mutations.ts b/packages/core/src/db/protected-mutations.ts new file mode 100644 index 0000000000..9815e51738 --- /dev/null +++ b/packages/core/src/db/protected-mutations.ts @@ -0,0 +1,120 @@ +const PROTECTED_TABLES_KEY = Symbol.for( + "@agent-native/core/db.protected-mutation-tables", +); + +interface ProtectedMutationTable { + guidance: string; +} + +type GlobalWithProtectedTables = typeof globalThis & { + [PROTECTED_TABLES_KEY]?: Map; +}; + +function protectedTables(): Map { + const globalRef = globalThis as GlobalWithProtectedTables; + if (!globalRef[PROTECTED_TABLES_KEY]) { + globalRef[PROTECTED_TABLES_KEY] = new Map(); + } + return globalRef[PROTECTED_TABLES_KEY]!; +} + +function normalizeTableName(table: string): string { + return table + .trim() + .replace(/^["'`[]/, "") + .replace(/["'`\]]$/, "") + .split(".") + .at(-1)! + .toLowerCase(); +} + +export interface ProtectedMutationTablesRegistration { + tables: readonly string[]; + guidance: string; +} + +export function registerProtectedMutationTables( + registration: ProtectedMutationTablesRegistration, +): () => void { + const names = registration.tables.map(normalizeTableName); + for (const name of names) { + protectedTables().set(name, { guidance: registration.guidance }); + } + return () => { + for (const name of names) protectedTables().delete(name); + }; +} + +export function assertMutationTableIsNotProtected(table: string): void { + const normalized = normalizeTableName(table); + const registration = protectedTables().get(normalized); + if (!registration) return; + throw new Error( + `Table "${normalized}" is protected from raw database mutations because its domain action records durable change events. ${registration.guidance}`, + ); +} + +export function protectedMutationTableFromSql(sql: string): string | null { + let cleanSql = ""; + let state: "normal" | "single" | "line-comment" | "block-comment" = "normal"; + for (let index = 0; index < sql.length; index++) { + const char = sql[index]; + const next = sql[index + 1]; + if (state === "line-comment") { + if (char === "\n") { + cleanSql += " "; + state = "normal"; + } + continue; + } + if (state === "block-comment") { + if (char === "*" && next === "/") { + index++; + cleanSql += " "; + state = "normal"; + } + continue; + } + if (state === "single") { + if (char === "'" && next === "'") index++; + else if (char === "'") { + cleanSql += " "; + state = "normal"; + } + continue; + } + if (char === "-" && next === "-") { + index++; + state = "line-comment"; + continue; + } + if (char === "/" && next === "*") { + index++; + state = "block-comment"; + continue; + } + if (char === "'") { + state = "single"; + continue; + } + cleanSql += char; + } + + const tablePattern = + /\b(?:INSERT(?:\s+OR\s+\w+)?\s+INTO|REPLACE(?:\s+OR\s+\w+)?\s+INTO|UPDATE|DELETE\s+FROM)\s+((?:"[^"]+"|`[^`]+`|[\w]+)(?:\s*\.\s*(?:"[^"]+"|`[^`]+`|[\w]+))?)/gi; + let match: RegExpExecArray | null; + while ((match = tablePattern.exec(cleanSql)) !== null) { + const normalized = normalizeTableName(match[1]); + if (protectedTables().has(normalized)) return normalized; + } + return null; +} + +export function assertSqlDoesNotMutateProtectedTable(sql: string): void { + const table = protectedMutationTableFromSql(sql); + if (table) assertMutationTableIsNotProtected(table); +} + +export function __resetProtectedMutationTables(): void { + protectedTables().clear(); +} diff --git a/packages/core/src/deploy/build.spec.ts b/packages/core/src/deploy/build.spec.ts index bf9a31d131..af1dd4ab6a 100644 --- a/packages/core/src/deploy/build.spec.ts +++ b/packages/core/src/deploy/build.spec.ts @@ -18,6 +18,7 @@ import { CLOUDFLARE_WORKER_STUB_MODULES, copyDir, emitSingleTemplateNetlifyBackgroundFunction, + emitSingleTemplateNetlifyWorkflowScheduledFunction, findInstalledFfmpegStaticPackage, findInstalledResvgPackages, generateCloudflarePagesStaticShellFromManifest, @@ -1561,6 +1562,30 @@ describe("durable-background Netlify function emit (single-template, flag-gated) expect(entry).toContain("wrapper failed before reaching the route"); }); + it("emits the Content workflow minute sweep through the shared Nitro handler", () => { + const cwd = setupNetlifyOutput(); + + emitSingleTemplateNetlifyWorkflowScheduledFunction(cwd); + + const dest = path.join( + cwd, + ".netlify", + "functions-internal", + "server-workflow-scheduled", + ); + const entry = fs.readFileSync( + path.join(dest, "server-workflow-scheduled.mjs"), + "utf8", + ); + expect(entry).toContain( + "globalThis.__AGENT_NATIVE_WORKFLOW_SCHEDULED_RUNTIME__ = true", + ); + expect(entry).toContain('schedule: "* * * * *"'); + expect(entry).toContain('url.pathname = "/_agent-native/workflow/_drain"'); + expect(entry).toContain('await import("./main.mjs")'); + expect(fs.existsSync(path.join(dest, "server.mjs"))).toBe(false); + }); + it("does NOT touch the server /* catch-all (no excludedPath patch — default url is never shadowed)", () => { const cwd = setupNetlifyOutput(); diff --git a/packages/core/src/deploy/build.ts b/packages/core/src/deploy/build.ts index 073d48f82e..d92b0c6d84 100644 --- a/packages/core/src/deploy/build.ts +++ b/packages/core/src/deploy/build.ts @@ -2497,6 +2497,48 @@ export const config = { ); } +export function emitSingleTemplateNetlifyWorkflowScheduledFunction( + projectCwd: string, +): void { + const internalDir = path.join(projectCwd, ".netlify", "functions-internal"); + const serverDir = path.join(internalDir, "server"); + if (!fs.existsSync(path.join(serverDir, "main.mjs"))) { + throw new Error( + "Workflow scheduled-function emit requires .netlify/functions-internal/server/main.mjs", + ); + } + const functionName = "server-workflow-scheduled"; + const dest = path.join(internalDir, functionName); + fs.rmSync(dest, { recursive: true, force: true }); + copyDir(serverDir, dest); + fs.rmSync(path.join(dest, "server.mjs"), { force: true }); + const entry = `globalThis.__AGENT_NATIVE_WORKFLOW_SCHEDULED_RUNTIME__ = true; + +let cachedHandler; + +export default async function handler(request, context) { + cachedHandler ??= (await import("./main.mjs")).default; + const url = new URL(request.url); + url.pathname = "/_agent-native/workflow/_drain"; + const rewritten = new Request(url.toString(), { + method: "POST", + headers: request.headers, + }); + return cachedHandler(rewritten, context); +} + +export const config = { + name: "agent workflow scheduled drain", + generator: "agent-native build", + schedule: "* * * * *", + nodeBundler: "none", + includedFiles: ["**"], + preferStatic: false, +}; +`; + fs.writeFileSync(path.join(dest, `${functionName}.mjs`), entry); +} + /** * Nitro's Netlify preset can emit a harmful fallback rewrite to * `/.netlify/functions/server`. With `config.path: "/*"`, that default URL is @@ -3482,6 +3524,20 @@ export default bundle; } } + if ( + preset === "netlify" && + fs.existsSync(path.join(cwd, "actions", "manage-content-database-hook.ts")) + ) { + try { + emitSingleTemplateNetlifyWorkflowScheduledFunction(cwd); + } catch (err) { + console.warn( + "[build] Failed to emit workflow scheduled function (non-fatal):", + err instanceof Error ? err.message : err, + ); + } + } + if (preset === "netlify") { writeSingleTemplateNetlifyRedirects(cwd); assertSingleTemplateNetlifyBuildOutput(cwd); diff --git a/packages/core/src/deploy/workspace-deploy.ts b/packages/core/src/deploy/workspace-deploy.ts index fa2b61188c..6b6ab11bc6 100644 --- a/packages/core/src/deploy/workspace-deploy.ts +++ b/packages/core/src/deploy/workspace-deploy.ts @@ -680,6 +680,83 @@ function copyNetlifyFunctionIntoWorkspace( if (isDurableBackgroundDeployEnabled()) { emitNetlifyBackgroundFunction(workspaceRoot, app, src, workspaceApps); } + if (app === "content") { + emitNetlifyWorkflowScheduledFunction( + workspaceRoot, + app, + src, + workspaceApps, + ); + } +} + +function emitNetlifyWorkflowScheduledFunction( + workspaceRoot: string, + app: string, + srcServerDir: string, + workspaceApps: WorkspaceAppManifestEntry[], +): void { + const functionName = `${app}-workflow-scheduled`; + const dest = path.join(netlifyFunctionsDir(workspaceRoot), functionName); + fs.rmSync(dest, { recursive: true, force: true }); + copyDir(srcServerDir, dest); + fs.rmSync(path.join(dest, "server.mjs"), { force: true }); + const basePath = `/${app}`; + const workspaceAppAudience = workspaceAppAudienceForApp(workspaceApps, app); + const workspaceAppRouteAccess = workspaceAppRouteAccessForApp( + workspaceApps, + app, + ); + const entry = `globalThis.__AGENT_NATIVE_WORKFLOW_SCHEDULED_RUNTIME__ = true; + +const basePath = ${JSON.stringify(basePath)}; + +function setBasePathEnv() { + const processRef = globalThis.process ??= { env: {} }; + processRef.env ??= {}; + Object.assign(processRef.env, { + AGENT_NATIVE_WORKSPACE: "1", + AGENT_NATIVE_WORKSPACE_APP_ID: ${JSON.stringify(app)}, + APP_BASE_PATH: basePath, + AGENT_NATIVE_WORKSPACE_APP_AUDIENCE: ${JSON.stringify(workspaceAppAudience)}, + AGENT_NATIVE_WORKSPACE_APP_PUBLIC_PATHS: ${JSON.stringify(JSON.stringify(workspaceAppRouteAccess.publicPaths))}, + AGENT_NATIVE_WORKSPACE_APP_PROTECTED_PATHS: ${JSON.stringify(JSON.stringify(workspaceAppRouteAccess.protectedPaths))}, + VITE_AGENT_NATIVE_WORKSPACE: "1", + VITE_AGENT_NATIVE_WORKSPACE_APP_ID: ${JSON.stringify(app)}, + VITE_APP_BASE_PATH: basePath, + VITE_AGENT_NATIVE_WORKSPACE_APP_AUDIENCE: ${JSON.stringify(workspaceAppAudience)}, + VITE_AGENT_NATIVE_WORKSPACE_APP_PUBLIC_PATHS: ${JSON.stringify(JSON.stringify(workspaceAppRouteAccess.publicPaths))}, + VITE_AGENT_NATIVE_WORKSPACE_APP_PROTECTED_PATHS: ${JSON.stringify(JSON.stringify(workspaceAppRouteAccess.protectedPaths))}, + VITE_AGENT_NATIVE_WORKSPACE_APPS_JSON: ${JSON.stringify(JSON.stringify(workspaceApps))}, + ${JSON.stringify(WORKSPACE_APPS_ENV_KEY)}: ${JSON.stringify(JSON.stringify(workspaceApps))}, + }); +} + +setBasePathEnv(); +let cachedHandler; + +export default async function handler(request) { + setBasePathEnv(); + cachedHandler ??= (await import("./main.mjs")).default; + const url = new URL(request.url); + url.pathname = basePath + "/_agent-native/workflow/_drain"; + const rewritten = new Request(url.toString(), { + method: "POST", + headers: request.headers, + }); + return cachedHandler(rewritten); +} + +export const config = { + name: ${JSON.stringify(`${app} workflow scheduled drain`)}, + generator: "agent-native workspace deploy", + schedule: "* * * * *", + nodeBundler: "none", + includedFiles: ["**"], + preferStatic: false, +}; +`; + fs.writeFileSync(path.join(dest, `${functionName}.mjs`), entry); } /** diff --git a/packages/core/src/event-bus/authority.ts b/packages/core/src/event-bus/authority.ts new file mode 100644 index 0000000000..d08eb9865b --- /dev/null +++ b/packages/core/src/event-bus/authority.ts @@ -0,0 +1,17 @@ +/** + * Durable workflow topics are committed to the workflow outbox. The process + * bus may wake the claimant, but it must never become a second dispatch path. + */ +export function isCertifiedDurableEventTopic(topic: string): boolean { + return topic === "content" || topic.startsWith("content."); +} + +export function assertEphemeralEventTopic( + operation: "emit" | "subscribe", + topic: string, +): void { + if (!isCertifiedDurableEventTopic(topic)) return; + throw new Error( + `${operation}: "${topic}" is a certified durable workflow topic; use the workflow event log and shared claim engine`, + ); +} diff --git a/packages/core/src/event-bus/bus.spec.ts b/packages/core/src/event-bus/bus.spec.ts index d48a6f9207..45accfd044 100644 --- a/packages/core/src/event-bus/bus.spec.ts +++ b/packages/core/src/event-bus/bus.spec.ts @@ -84,6 +84,16 @@ describe("event-bus", () => { expect(() => emit("", {})).toThrow(/event name is required/); }); + it("refuses authoritative dispatch for certified durable topics", () => { + expect(() => subscribe("content.item.changed", () => {})).toThrow( + /certified durable workflow topic/, + ); + expect(() => emit("content.item.changed", {})).toThrow( + /certified durable workflow topic/, + ); + expect(listSubscriptions("content.item.changed")).toHaveLength(0); + }); + it("only delivers to subscribers of the matching event", () => { const a = vi.fn(); const b = vi.fn(); diff --git a/packages/core/src/event-bus/bus.ts b/packages/core/src/event-bus/bus.ts index 8420ef35d4..00f55d5b67 100644 --- a/packages/core/src/event-bus/bus.ts +++ b/packages/core/src/event-bus/bus.ts @@ -9,6 +9,7 @@ import { randomUUID } from "node:crypto"; import { EventEmitter } from "node:events"; +import { assertEphemeralEventTopic } from "./authority.js"; import { getEvent } from "./registry.js"; import type { EventMeta } from "./types.js"; @@ -43,6 +44,7 @@ export function subscribe(event: string, handler: Handler): string { if (typeof handler !== "function") { throw new Error("subscribe: handler must be a function"); } + assertEphemeralEventTopic("subscribe", event); const bus = getBus(); const id = randomUUID(); bus.subscriptions.set(id, { event, handler }); @@ -67,6 +69,7 @@ export function emit( if (typeof event !== "string" || !event) { throw new Error("emit: event name is required"); } + assertEphemeralEventTopic("emit", event); const bus = getBus(); const def = getEvent(event); diff --git a/packages/core/src/event-bus/index.ts b/packages/core/src/event-bus/index.ts index 907eaf3cee..82fdb7ec1e 100644 --- a/packages/core/src/event-bus/index.ts +++ b/packages/core/src/event-bus/index.ts @@ -1,3 +1,4 @@ export { registerEvent, listEvents, getEvent } from "./registry.js"; export { emit, subscribe, unsubscribe, listSubscriptions } from "./bus.js"; +export { isCertifiedDurableEventTopic } from "./authority.js"; export type { EventDefinition, EventSubscription, EventMeta } from "./types.js"; diff --git a/packages/core/src/notifications/channels.spec.ts b/packages/core/src/notifications/channels.spec.ts index e30d6b7ef0..938100c432 100644 --- a/packages/core/src/notifications/channels.spec.ts +++ b/packages/core/src/notifications/channels.spec.ts @@ -88,11 +88,12 @@ describe("webhook notification channel", () => { delete process.env.NOTIFICATIONS_WEBHOOK_URL; const channel = (await loadWebhookChannel())!; expect(channel).toBeDefined(); - await channel.deliver( + const outcome = await channel.deliver( { severity: "critical", title: "x" }, { owner: "alice@example.com" }, ); expect(fetchMock).not.toHaveBeenCalled(); + expect(outcome).toMatchObject({ status: "skipped" }); }); it("prefers metadata.webhookUrl over the env default", async () => { @@ -139,6 +140,46 @@ describe("webhook notification channel", () => { expect(JSON.parse(init.body).metadata).toEqual({ monitorId: "mon_1" }); }); + it("resolves a private delivery auth key and sends stable workflow idempotency", async () => { + resolveKeyReferencesWithRequestScopes.mockImplementation( + async (text: string) => ({ + resolved: text.includes("HOOK_AUTH") + ? "Bearer example-secret" + : text.includes("HOOK_SIGN") + ? "example-signing-secret" + : text, + usedKeys: [], + secretValues: [], + }), + ); + const channel = (await loadWebhookChannel())!; + + await channel.deliver( + { + severity: "info", + title: "Published", + metadata: { + delivery: { + webhookUrl: "https://hooks.example.com/publish", + webhookAuth: "Bearer ${keys.HOOK_AUTH}", + webhookSignature: "${keys.HOOK_SIGN}", + }, + }, + }, + { owner: "alice@example.com", workflowEffectId: "effect-example" }, + ); + + const [, init] = fetchMock.mock.calls[0]; + expect(init.headers.Authorization).toBe("Bearer example-secret"); + expect(init.headers["X-Agent-Native-Idempotency-Key"]).toBe( + "effect-example", + ); + expect(init.headers["X-Agent-Native-Signature"]).toMatch( + /^sha256=[a-f0-9]{64}$/, + ); + expect(JSON.parse(init.body).metadata).toBeUndefined(); + }); + it("POSTs the notification payload as JSON scoped to the owner", async () => { const channel = (await loadWebhookChannel())!; await channel.deliver( @@ -425,7 +466,7 @@ describe("Slack notification channel", () => { const channels = await loadChannels(); const channel = channels.find((c) => c.name === "slack")!; - await channel.deliver( + const outcome = await channel.deliver( { severity: "critical", title: "Clip uploads failing", @@ -442,6 +483,42 @@ describe("Slack notification channel", () => { const payload = JSON.parse(init.body); expect(payload.text).toContain("[critical] Clip uploads failing"); expect(payload.blocks[0].text.text).toBe("*Clip uploads failing*"); + expect(outcome).toEqual({ + status: "unknown", + evidence: { providerAccepted: true, httpStatus: 200 }, + }); + }); + + it("keeps personal Slack separate and resolves only its secret placeholder", async () => { + resolveKeyReferencesWithRequestScopes.mockImplementation( + async (text: string) => ({ + resolved: text.includes("PERSONAL_SLACK") + ? "https://hooks.slack.example.com/services/PERSONAL" + : text, + usedKeys: text.includes("PERSONAL_SLACK") ? ["PERSONAL_SLACK"] : [], + secretValues: [], + }), + ); + const channels = await loadChannels(); + const channel = channels.find((c) => c.name === "personal-slack")!; + + const outcome = await channel.deliver( + { + severity: "info", + title: "Review requested", + metadata: { + delivery: { + personalSlackWebhookUrl: "${keys.PERSONAL_SLACK}", + }, + }, + }, + { owner: "alice@example.com" }, + ); + + expect(fetchMock.mock.calls[0][0]).toBe( + "https://hooks.slack.example.com/services/PERSONAL", + ); + expect(outcome).toMatchObject({ status: "unknown" }); }); it("prefers metadata.slackWebhookUrl over the env default", async () => { diff --git a/packages/core/src/notifications/channels.ts b/packages/core/src/notifications/channels.ts index 3aa500cf07..8996591988 100644 --- a/packages/core/src/notifications/channels.ts +++ b/packages/core/src/notifications/channels.ts @@ -1,3 +1,4 @@ +import { createHmac } from "node:crypto"; /** * Built-in notification channels. * @@ -50,6 +51,7 @@ export function registerBuiltinNotificationChannels(): void { registerNotificationChannel( createSlackWebhookChannel(process.env.NOTIFICATIONS_SLACK_WEBHOOK_URL), ); + registerNotificationChannel(createPersonalSlackWebhookChannel()); // Email is always registered so per-notification `metadata.emailRecipients` // work without a workspace-wide env flag (same pattern as webhook/slack). registerNotificationChannel(createEmailChannel()); @@ -70,28 +72,54 @@ function createWebhookChannel( overrideUrlTemplate ?? metadataString(input.metadata, "webhookUrl") ?? envUrlTemplate?.trim(); + const overrideAuthTemplate = deliveryMetadataString( + input.metadata, + "webhookAuth", + ); + const signatureTemplate = deliveryMetadataString( + input.metadata, + "webhookSignature", + ); // No-op when neither a per-notification nor workspace URL is set — // mirrors email's empty-recipients behavior so notify-all stays quiet. - if (!urlTemplate) return false; - const { url, headers, assertUrlAllowed } = await resolveWebhookRequest( - urlTemplate, - overrideUrlTemplate ? undefined : authTemplate, - meta.owner, - "webhook", - ); + if (!urlTemplate) { + return { status: "skipped", reason: "No webhook is configured." }; + } + const { url, headers, assertUrlAllowed, resolvedAdditionalSecrets } = + await resolveWebhookRequest( + urlTemplate, + overrideAuthTemplate ?? + (overrideUrlTemplate ? undefined : authTemplate), + meta.owner, + "webhook", + signatureTemplate ? [signatureTemplate] : [], + ); + if (meta.workflowEffectId) { + headers["X-Agent-Native-Idempotency-Key"] = meta.workflowEffectId; + } + const body = JSON.stringify({ + severity: input.severity, + title: input.title, + body: input.body, + metadata: scrubDeliveryMetadata(input.metadata), + owner: meta.owner, + emittedAt: new Date().toISOString(), + }); + const signatureSecret = resolvedAdditionalSecrets[0]; + if (signatureSecret) { + headers["X-Agent-Native-Signature"] = `sha256=${createHmac( + "sha256", + signatureSecret, + ) + .update(body) + .digest("hex")}`; + } const res = await ssrfSafeFetch( url, { method: "POST", headers, - body: JSON.stringify({ - severity: input.severity, - title: input.title, - body: input.body, - metadata: scrubDeliveryMetadata(input.metadata), - owner: meta.owner, - emittedAt: new Date().toISOString(), - }), + body, }, { maxRedirects: 3, assertUrlAllowed }, ); @@ -102,6 +130,10 @@ function createWebhookChannel( }`, ); } + return { + status: "unknown", + evidence: { providerAccepted: true, httpStatus: res.status }, + }; }, }; } @@ -121,7 +153,12 @@ function createSlackWebhookChannel( overrideUrlTemplate ?? metadataString(input.metadata, "slackWebhookUrl") ?? envUrlTemplate?.trim(); - if (!urlTemplate) return false; + if (!urlTemplate) { + return { + status: "skipped", + reason: "No team Slack webhook is configured.", + }; + } const { url, headers, assertUrlAllowed } = await resolveWebhookRequest( urlTemplate, overrideUrlTemplate ? undefined : authTemplate, @@ -175,6 +212,56 @@ function createSlackWebhookChannel( }`, ); } + return { + status: "unknown", + evidence: { providerAccepted: true, httpStatus: res.status }, + }; + }, + }; +} + +function createPersonalSlackWebhookChannel(): NotificationChannel { + return { + name: "personal-slack", + async deliver(input, meta) { + const urlTemplate = deliveryMetadataString( + input.metadata, + "personalSlackWebhookUrl", + ); + if (!urlTemplate) { + return { + status: "skipped", + reason: "No personal Slack webhook is configured.", + }; + } + const { url, headers, assertUrlAllowed } = await resolveWebhookRequest( + urlTemplate, + undefined, + meta.owner, + "personal Slack webhook", + ); + const res = await ssrfSafeFetch( + url, + { + method: "POST", + headers, + body: JSON.stringify({ + text: slackText(input.severity, input.title, input.body), + }), + }, + { maxRedirects: 3, assertUrlAllowed }, + ); + if (!res.ok) { + throw new Error( + `[notifications] personal Slack webhook ${new URL(url).origin} returned ${res.status}${ + (await readErrorSnippet(res)) || "" + }`, + ); + } + return { + status: "unknown", + evidence: { providerAccepted: true, httpStatus: res.status }, + }; }, }; } @@ -184,7 +271,12 @@ function createEmailChannel(): NotificationChannel { name: "email", async deliver(input) { const recipients = notificationEmailRecipients(input.metadata); - if (recipients.length === 0) return false; + if (recipients.length === 0) { + return { + status: "skipped", + reason: "No email recipient is configured.", + }; + } const subject = typeof input.metadata?.emailSubject === "string" && input.metadata.emailSubject.trim() @@ -206,6 +298,10 @@ function createEmailChannel(): NotificationChannel { }), ), ); + return { + status: "unknown", + evidence: { providerAccepted: true, recipientCount: recipients.length }, + }; }, }; } @@ -215,10 +311,12 @@ async function resolveWebhookRequest( authTemplate: string | undefined, owner: string, label: string, + additionalSecretTemplates: string[] = [], ): Promise<{ url: string; headers: Record; assertUrlAllowed: (url: string) => void; + resolvedAdditionalSecrets: string[]; }> { // Resolve `${keys.NAME}` references through the same request-scope // cascade already used by extension fetches (extensions/routes.ts) and @@ -252,6 +350,11 @@ async function resolveWebhookRequest( ); headers.Authorization = authResult.resolved; } + const additionalResults = await Promise.all( + additionalSecretTemplates.map((template) => + resolveKeyReferencesWithRequestScopes(template, owner), + ), + ); // If the user set an allowlist on a referenced key, enforce it here — // origin-level check, same rule the automations fetch-tool applies. @@ -264,7 +367,11 @@ async function resolveWebhookRequest( string, { name: string; resolvedKey?: ResolvedKey } >(); - for (const result of authResult ? [urlResult, authResult] : [urlResult]) { + for (const result of [ + urlResult, + ...(authResult ? [authResult] : []), + ...additionalResults, + ]) { for (const name of result.usedKeys) { const resolved = (result.resolvedKeys ?? []).filter( (ref) => ref.name === name, @@ -299,7 +406,14 @@ async function resolveWebhookRequest( }); }; assertUrlAllowed(url); - return { url, headers, assertUrlAllowed }; + return { + url, + headers, + assertUrlAllowed, + resolvedAdditionalSecrets: additionalResults.map( + (result) => result.resolved, + ), + }; } function metadataString( @@ -340,7 +454,10 @@ function scrubDeliveryMetadata( function notificationEmailRecipients( metadata: Record | undefined, ): string[] { - const fromMetadata = stringArray(metadata?.emailRecipients); + const fromMetadata = [ + ...deliveryMetadataStringArray(metadata, "emailRecipients"), + ...stringArray(metadata?.emailRecipients), + ]; const fromEnv = commaList(process.env.NOTIFICATIONS_EMAIL_RECIPIENTS); const seen = new Set(); const recipients: string[] = []; @@ -353,6 +470,17 @@ function notificationEmailRecipients( return recipients; } +function deliveryMetadataStringArray( + metadata: NotificationInput["metadata"], + key: string, +): string[] { + const delivery = metadata?.delivery; + if (!delivery || typeof delivery !== "object" || Array.isArray(delivery)) { + return []; + } + return stringArray((delivery as Record)[key]); +} + function stringArray(value: unknown): string[] { if (!Array.isArray(value)) return []; return value.filter((item): item is string => typeof item === "string"); diff --git a/packages/core/src/notifications/index.ts b/packages/core/src/notifications/index.ts index 0150ab63e9..8a48b4b9ee 100644 --- a/packages/core/src/notifications/index.ts +++ b/packages/core/src/notifications/index.ts @@ -4,6 +4,7 @@ export type { NotificationInput, NotificationMeta, NotificationChannel, + NotificationChannelOutcome, } from "./types.js"; export { @@ -17,6 +18,17 @@ export { markNotificationRead, markAllNotificationsRead, deleteNotification, + type NotificationDeliveryResult, + type NotificationChannelDeliveryOutcome, } from "./registry.js"; export { registerBuiltinNotificationChannels } from "./channels.js"; + +export { + DEFAULT_PERSONAL_NOTIFICATION_ROUTING, + getPersonalNotificationRouting, + setPersonalNotificationRouting, + notifyPersonalWithDelivery, + normalizePersonalNotificationRouting, + type PersonalNotificationRouting, +} from "./routing.js"; diff --git a/packages/core/src/notifications/notifications.spec.ts b/packages/core/src/notifications/notifications.spec.ts index aef62119ba..9f2b9f569e 100644 --- a/packages/core/src/notifications/notifications.spec.ts +++ b/packages/core/src/notifications/notifications.spec.ts @@ -9,6 +9,9 @@ const mockMarkAllNotificationsRead = vi.fn(); const mockDeleteNotification = vi.fn(); const mockEmit = vi.fn(); const mockGetSession = vi.fn(); +const mockRecordDeliveryAttempt = vi.fn(); +const mockGetPersonalRouting = vi.fn(); +const mockSetPersonalRouting = vi.fn(); vi.mock("h3", () => ({ defineEventHandler: (handler: any) => handler, @@ -47,10 +50,26 @@ vi.mock("../event-bus/bus.js", () => ({ emit: (...args: unknown[]) => mockEmit(...args), })); +vi.mock("../workflow/store.js", () => ({ + recordNotificationDeliveryAttempt: (...args: unknown[]) => + mockRecordDeliveryAttempt(...args), +})); + vi.mock("../server/auth.js", () => ({ getSession: (...args: unknown[]) => mockGetSession(...args), })); +vi.mock("../server/h3-helpers.js", () => ({ + readBody: (event: any) => event._body, +})); + +vi.mock("./routing.js", () => ({ + getPersonalNotificationRouting: (...args: unknown[]) => + mockGetPersonalRouting(...args), + setPersonalNotificationRouting: (...args: unknown[]) => + mockSetPersonalRouting(...args), +})); + import { createNotificationToolEntries } from "./actions.js"; import { notify, @@ -68,6 +87,7 @@ function createEvent(path: string, method = "GET") { url: new URL(`http://app.test${path}`), context: {}, _status: 200, + _body: undefined as unknown, }; } @@ -78,6 +98,7 @@ describe("notifications registry", () => { mockGetSession.mockResolvedValue({ email: "boni@local" }); mockListNotifications.mockResolvedValue([]); mockCountUnread.mockResolvedValue(0); + mockRecordDeliveryAttempt.mockResolvedValue("attempt-1"); mockInsertNotification.mockResolvedValue({ id: "n-1", owner: "boni@local", @@ -123,6 +144,47 @@ describe("notifications registry", () => { ).rejects.toThrow(/owner is required/); }); + it("fails closed before workflow channel I/O when the delivery ledger is unavailable", async () => { + const deliver = vi.fn(); + registerNotificationChannel({ name: "slack", deliver }); + mockRecordDeliveryAttempt.mockRejectedValueOnce(new Error("db offline")); + + await expect( + notifyWithDelivery( + { + severity: "warning", + title: "Review requested", + channels: ["slack"], + }, + { owner: "boni@local", workflowEffectId: "effect-1" }, + ), + ).rejects.toThrow(/delivery ledger failed/i); + expect(deliver).not.toHaveBeenCalled(); + }); + + it("propagates a post-send ledger failure and leaves the reserved attempt unknown", async () => { + const deliver = vi.fn(async () => {}); + registerNotificationChannel({ name: "slack", deliver }); + mockRecordDeliveryAttempt + .mockResolvedValueOnce("attempt-1") + .mockRejectedValueOnce(new Error("db offline")); + + await expect( + notifyWithDelivery( + { + severity: "warning", + title: "Review requested", + channels: ["slack"], + }, + { owner: "boni@local", workflowEffectId: "effect-1" }, + ), + ).rejects.toThrow(/delivery ledger failed/i); + expect(deliver).toHaveBeenCalledTimes(1); + expect(mockRecordDeliveryAttempt.mock.calls[0][0]).toMatchObject({ + status: "unknown", + }); + }); + it("does not persist delivery-only webhook metadata in the inbox row", async () => { await notify( { @@ -167,7 +229,10 @@ describe("notifications registry", () => { const badDeliver = vi.fn(() => { throw new Error("slack is down"); }); - const goodDeliver = vi.fn(); + const goodDeliver = vi.fn(() => ({ + status: "delivered" as const, + evidence: { receiptId: "receipt-1" }, + })); registerNotificationChannel({ name: "slack", deliver: badDeliver }); registerNotificationChannel({ name: "pager", deliver: goodDeliver }); @@ -190,7 +255,10 @@ describe("notifications registry", () => { }); registerNotificationChannel({ name: "pager", - deliver: async () => {}, + deliver: async () => ({ + status: "delivered" as const, + evidence: { receiptId: "receipt-1" }, + }), }); await notify( @@ -230,7 +298,10 @@ describe("notifications registry", () => { }); it("explicit channels allowlist scopes delivery and excludes inbox when omitted", async () => { - const deliverSlack = vi.fn(); + const deliverSlack = vi.fn(() => ({ + status: "unknown" as const, + evidence: { providerAccepted: true }, + })); const deliverPager = vi.fn(); registerNotificationChannel({ name: "slack", deliver: deliverSlack }); registerNotificationChannel({ name: "pager", deliver: deliverPager }); @@ -255,16 +326,75 @@ describe("notifications registry", () => { ); expect(delivery.notification).toBeUndefined(); - expect(delivery.deliveredChannels).toEqual(["slack"]); + expect(delivery.deliveredChannels).toEqual([]); + expect(delivery.unknownChannels).toEqual(["slack"]); expect(mockInsertNotification).not.toHaveBeenCalled(); expect(mockEmit).toHaveBeenCalledWith( "notification.sent", expect.objectContaining({ notificationId: undefined, - deliveredChannels: ["slack"], + deliveredChannels: [], + unknownChannels: ["slack"], + }), + { owner: "boni@local" }, + ); + }); + + it("records skipped and receipt-free sends honestly", async () => { + registerNotificationChannel({ + name: "skipped", + deliver: () => ({ status: "skipped", reason: "not configured" }), + }); + registerNotificationChannel({ + name: "accepted", + deliver: () => ({ + status: "unknown", + evidence: { providerAccepted: true }, + }), + }); + + const delivery = await notifyWithDelivery( + { + severity: "info", + title: "Test", + channels: ["skipped", "accepted"], + }, + { owner: "boni@local", workflowEffectId: "effect-1" }, + ); + + expect(delivery).toMatchObject({ + deliveredChannels: [], + unknownChannels: ["accepted"], + skippedChannels: ["skipped"], + failedChannels: [], + }); + expect(mockRecordDeliveryAttempt).toHaveBeenCalledWith( + expect.objectContaining({ + channel: "skipped", + status: "skipped", + }), + ); + expect(mockRecordDeliveryAttempt).toHaveBeenCalledWith( + expect.objectContaining({ + channel: "accepted", + status: "unknown", }), + ); + }); + + it("requires evidence before a channel can claim delivery", async () => { + registerNotificationChannel({ + name: "receipt", + deliver: () => ({ status: "delivered", evidence: {} }), + }); + + const delivery = await notifyWithDelivery( + { severity: "info", title: "Test", channels: ["receipt"] }, { owner: "boni@local" }, ); + + expect(delivery.deliveredChannels).toEqual([]); + expect(delivery.unknownChannels).toEqual(["receipt"]); }); it("channels=['inbox'] persists but skips custom channels", async () => { @@ -316,6 +446,13 @@ describe("notifications routes", () => { mockGetSession.mockResolvedValue({ email: "boni@local" }); mockListNotifications.mockResolvedValue([]); mockCountUnread.mockResolvedValue(3); + mockGetPersonalRouting.mockResolvedValue({ + inbox: true, + browser: true, + email: false, + personalSlack: false, + personalSlackWebhookKey: null, + }); }); it("handles HEAD like GET for read endpoints", async () => { @@ -328,6 +465,28 @@ describe("notifications routes", () => { expect(mockCountUnread).toHaveBeenCalledWith("boni@local"); }); + it("reads and writes only the signed-in user's delivery routing", async () => { + const handler = createNotificationsHandler() as any; + const getEvent = createEvent("/routing"); + await handler(getEvent); + expect(mockGetPersonalRouting).toHaveBeenCalledWith("boni@local"); + + const putEvent = createEvent("/routing", "PUT"); + putEvent._body = { + inbox: true, + browser: false, + email: true, + personalSlack: false, + personalSlackWebhookKey: null, + }; + mockSetPersonalRouting.mockResolvedValue(putEvent._body); + await expect(handler(putEvent)).resolves.toEqual(putEvent._body); + expect(mockSetPersonalRouting).toHaveBeenCalledWith( + "boni@local", + putEvent._body, + ); + }); + it("clamps invalid list limits before reaching the store", async () => { const handler = createNotificationsHandler() as any; diff --git a/packages/core/src/notifications/registry.ts b/packages/core/src/notifications/registry.ts index d3e468f891..a78b46f0a2 100644 --- a/packages/core/src/notifications/registry.ts +++ b/packages/core/src/notifications/registry.ts @@ -4,6 +4,8 @@ import { emit as emitBusEvent } from "../event-bus/bus.js"; import { registerEvent } from "../event-bus/registry.js"; import type { EventDefinition } from "../event-bus/types.js"; import { truncate } from "../shared/truncate.js"; +import { recordNotificationDeliveryAttempt } from "../workflow/store.js"; +import type { WorkflowDeliveryStatus } from "../workflow/types.js"; import { insertNotification, updateDeliveredChannels } from "./store.js"; import { NOTIFICATION_SEVERITIES, @@ -11,23 +13,36 @@ import { type NotificationInput, type NotificationMeta, type Notification, + type NotificationChannelOutcome, } from "./types.js"; +export interface NotificationChannelDeliveryOutcome { + channel: string; + status: "delivered" | "unknown" | "skipped" | "failed"; + evidence?: Record; + errorMessage?: string; +} + export interface NotificationDeliveryResult { notification?: Notification; deliveredChannels: string[]; + unknownChannels: string[]; + skippedChannels: string[]; + failedChannels: string[]; + channelOutcomes: NotificationChannelDeliveryOutcome[]; } registerEvent({ name: "notification.sent", description: - "Fires after notify() delivers to at least one channel. Automations can chain off this — e.g. fan critical notifications to Slack.", + "Fires after notify() delivers to or is accepted by at least one channel. Automations can chain off this — e.g. fan critical notifications to Slack.", payloadSchema: z.object({ notificationId: z.string().optional(), severity: z.enum(NOTIFICATION_SEVERITIES), title: z.string(), body: z.string().optional(), deliveredChannels: z.array(z.string()), + unknownChannels: z.array(z.string()), }) as unknown as EventDefinition["payloadSchema"], example: { notificationId: "ntf_abc", @@ -35,6 +50,7 @@ registerEvent({ title: "Payment failed", body: "Card ending 4242 declined", deliveredChannels: ["inbox", "webhook"], + unknownChannels: [], }, }); @@ -109,10 +125,11 @@ export async function notifyWithDelivery( // The inbox channel is always included unless explicitly excluded. const runInbox = !input.channels || input.channels.includes("inbox"); - const delivered: string[] = []; + const outcomes: NotificationChannelDeliveryOutcome[] = []; let stored: Notification | undefined; if (runInbox) { + await recordDelivery(meta, "inbox", "unknown"); try { // Stored with just "inbox" first; the real delivered list is written // after fan-out so a failing webhook doesn't claim it was delivered. @@ -124,24 +141,53 @@ export async function notifyWithDelivery( metadata: storedMetadata, deliveredChannels: ["inbox"], }); - delivered.push("inbox"); + outcomes.push({ + channel: "inbox", + status: "delivered", + evidence: { notificationId: stored.id }, + }); + await recordDelivery(meta, "inbox", "delivered", stored.id); } catch (err) { + if (err instanceof WorkflowDeliveryLedgerError) throw err; console.error("[notifications] inbox persist failed:", err); + await recordDelivery(meta, "inbox", "failed", undefined, err); + outcomes.push({ + channel: "inbox", + status: "failed", + errorMessage: errorMessage(err), + }); } } // Await every channel so a 500-ing webhook doesn't end up in `delivered`. const results = await Promise.allSettled( channels.map(async (channel) => { - const delivered = await channel.deliver(input, meta); - // Explicit `false` means the channel skipped (no URL / recipients). - if (delivered === false) return null; - return channel.name; + await recordDelivery(meta, channel.name, "unknown", stored?.id); + try { + const rawOutcome = await channel.deliver(input, meta); + const outcome = normalizeChannelOutcome(channel.name, rawOutcome); + await recordDelivery( + meta, + channel.name, + outcome.status, + stored?.id, + outcome.errorMessage, + ); + return outcome; + } catch (error) { + if (error instanceof WorkflowDeliveryLedgerError) throw error; + await recordDelivery(meta, channel.name, "failed", stored?.id, error); + return { + channel: channel.name, + status: "failed" as const, + errorMessage: errorMessage(error), + }; + } }), ); results.forEach((r, i) => { if (r.status === "fulfilled") { - if (r.value) delivered.push(r.value); + outcomes.push(r.value); } else { console.error( `[notifications] channel "${channels[i].name}" failed:`, @@ -149,6 +195,17 @@ export async function notifyWithDelivery( ); } }); + const ledgerFailure = results.find( + (result) => + result.status === "rejected" && + result.reason instanceof WorkflowDeliveryLedgerError, + ); + if (ledgerFailure?.status === "rejected") throw ledgerFailure.reason; + + const delivered = channelsForStatus(outcomes, "delivered"); + const unknown = channelsForStatus(outcomes, "unknown"); + const skipped = channelsForStatus(outcomes, "skipped"); + const failed = channelsForStatus(outcomes, "failed"); const hasExtraChannel = delivered.some((c) => c !== "inbox"); if (stored && hasExtraChannel) { @@ -163,7 +220,7 @@ export async function notifyWithDelivery( // Only emit when at least one channel delivered — an emission with an // empty delivery list (and likely a null notificationId) would mislead // any automation chaining off this event. - if (delivered.length > 0) { + if (delivered.length > 0 || unknown.length > 0) { try { emitBusEvent( "notification.sent", @@ -173,6 +230,7 @@ export async function notifyWithDelivery( title: input.title, body: input.body, deliveredChannels: delivered, + unknownChannels: unknown, }, { owner: meta.owner }, ); @@ -181,7 +239,94 @@ export async function notifyWithDelivery( } } - return { notification: stored, deliveredChannels: delivered }; + return { + notification: stored, + deliveredChannels: delivered, + unknownChannels: unknown, + skippedChannels: skipped, + failedChannels: failed, + channelOutcomes: outcomes, + }; +} + +async function recordDelivery( + meta: NotificationMeta, + channel: string, + status: WorkflowDeliveryStatus, + notificationId?: string, + error?: unknown, +): Promise { + if (!meta.workflowEffectId) return; + try { + await recordNotificationDeliveryAttempt({ + effectId: meta.workflowEffectId, + notificationId, + channel, + attempt: meta.workflowAttempt ?? 1, + status, + errorMessage: + error == null + ? undefined + : error instanceof Error + ? error.message + : String(error), + }); + } catch (ledgerError) { + throw new WorkflowDeliveryLedgerError(channel, ledgerError); + } +} + +function normalizeChannelOutcome( + channel: string, + outcome: void | boolean | NotificationChannelOutcome, +): NotificationChannelDeliveryOutcome { + if (outcome === false) return { channel, status: "skipped" }; + if (outcome == null || outcome === true) { + return { channel, status: "unknown" }; + } + if (outcome.status === "delivered") { + if (Object.keys(outcome.evidence).length === 0) { + return { channel, status: "unknown" }; + } + return { channel, status: "delivered", evidence: outcome.evidence }; + } + if (outcome.status === "unknown") { + return { channel, status: "unknown", evidence: outcome.evidence }; + } + if (outcome.status === "skipped") { + return { + channel, + status: "skipped", + errorMessage: outcome.reason, + }; + } + return { + channel, + status: "failed", + errorMessage: outcome.errorMessage, + }; +} + +function channelsForStatus( + outcomes: NotificationChannelDeliveryOutcome[], + status: NotificationChannelDeliveryOutcome["status"], +): string[] { + return outcomes + .filter((outcome) => outcome.status === status) + .map((outcome) => outcome.channel); +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +class WorkflowDeliveryLedgerError extends Error { + constructor(channel: string, cause: unknown) { + super(`Notification delivery ledger failed for channel "${channel}"`, { + cause, + }); + this.name = "WorkflowDeliveryLedgerError"; + } } function scrubStoredMetadata( diff --git a/packages/core/src/notifications/routes.ts b/packages/core/src/notifications/routes.ts index 0ba8b5100c..d9dc850954 100644 --- a/packages/core/src/notifications/routes.ts +++ b/packages/core/src/notifications/routes.ts @@ -20,6 +20,11 @@ import { } from "h3"; import { getSession } from "../server/auth.js"; +import { readBody } from "../server/h3-helpers.js"; +import { + getPersonalNotificationRouting, + setPersonalNotificationRouting, +} from "./routing.js"; import { listNotifications, countUnread, @@ -58,6 +63,26 @@ export function createNotificationsHandler() { const parts = pathname ? pathname.split("/") : []; const owner = await resolveOwner(event); + // GET /routing — current user's cross-app personal delivery profile. + if (method === "GET" && parts.length === 1 && parts[0] === "routing") { + return getPersonalNotificationRouting(owner); + } + + // PUT /routing — stores booleans and a secret key name, never a secret. + if (method === "PUT" && parts.length === 1 && parts[0] === "routing") { + try { + return await setPersonalNotificationRouting( + owner, + await readBody(event), + ); + } catch (error) { + setResponseStatus(event, 400); + return { + error: error instanceof Error ? error.message : "Invalid routing", + }; + } + } + // GET / — list if (method === "GET" && parts.length === 0) { const q = getQuery(event); diff --git a/packages/core/src/notifications/routing.spec.ts b/packages/core/src/notifications/routing.spec.ts new file mode 100644 index 0000000000..2901e92467 --- /dev/null +++ b/packages/core/src/notifications/routing.spec.ts @@ -0,0 +1,104 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { getUserSetting, putUserSetting, notifyWithDelivery } = vi.hoisted( + () => ({ + getUserSetting: vi.fn(), + putUserSetting: vi.fn(), + notifyWithDelivery: vi.fn(), + }), +); + +vi.mock("../settings/user-settings.js", () => ({ + getUserSetting, + putUserSetting, +})); + +vi.mock("./registry.js", () => ({ notifyWithDelivery })); + +import { + DEFAULT_PERSONAL_NOTIFICATION_ROUTING, + getPersonalNotificationRouting, + notifyPersonalWithDelivery, + setPersonalNotificationRouting, +} from "./routing.js"; + +describe("personal notification routing", () => { + beforeEach(() => { + vi.clearAllMocks(); + getUserSetting.mockResolvedValue(null); + putUserSetting.mockResolvedValue(undefined); + notifyWithDelivery.mockResolvedValue({ + deliveredChannels: ["inbox"], + unknownChannels: [], + skippedChannels: [], + failedChannels: [], + channelOutcomes: [], + }); + }); + + it("defaults to inbox and browser without opting into external delivery", async () => { + await expect( + getPersonalNotificationRouting("alice@example.com"), + ).resolves.toEqual(DEFAULT_PERSONAL_NOTIFICATION_ROUTING); + }); + + it("stores only a normalized Slack secret key name", async () => { + await setPersonalNotificationRouting("alice@example.com", { + inbox: true, + browser: true, + email: true, + personalSlack: true, + personalSlackWebhookKey: "${keys.ALICE_SLACK_WEBHOOK}", + }); + + expect(putUserSetting).toHaveBeenCalledWith( + "alice@example.com", + "notification-routing", + expect.objectContaining({ + personalSlack: true, + personalSlackWebhookKey: "ALICE_SLACK_WEBHOOK", + }), + ); + expect(JSON.stringify(putUserSetting.mock.calls)).not.toContain( + "hooks.slack.com", + ); + }); + + it("rejects personal Slack routing without a valid secret key", async () => { + await expect( + setPersonalNotificationRouting("alice@example.com", { + personalSlack: true, + personalSlackWebhookKey: "https://hooks.slack.com/example", + }), + ).rejects.toThrow(/secret key/i); + expect(putUserSetting).not.toHaveBeenCalled(); + }); + + it("maps personal delivery to inbox, email, and isolated personal Slack", async () => { + getUserSetting.mockResolvedValue({ + inbox: true, + browser: true, + email: true, + personalSlack: true, + personalSlackWebhookKey: "ALICE_SLACK_WEBHOOK", + }); + + await notifyPersonalWithDelivery( + { severity: "info", title: "Review requested" }, + { owner: "alice@example.com", workflowEffectId: "effect-1" }, + ); + + expect(notifyWithDelivery).toHaveBeenCalledWith( + expect.objectContaining({ + channels: ["inbox", "email", "personal-slack"], + metadata: { + delivery: { + emailRecipients: ["alice@example.com"], + personalSlackWebhookUrl: "${keys.ALICE_SLACK_WEBHOOK}", + }, + }, + }), + { owner: "alice@example.com", workflowEffectId: "effect-1" }, + ); + }); +}); diff --git a/packages/core/src/notifications/routing.ts b/packages/core/src/notifications/routing.ts new file mode 100644 index 0000000000..945b27a0bc --- /dev/null +++ b/packages/core/src/notifications/routing.ts @@ -0,0 +1,124 @@ +import { getUserSetting, putUserSetting } from "../settings/user-settings.js"; +import { notifyWithDelivery } from "./registry.js"; +import type { NotificationInput, NotificationMeta } from "./types.js"; + +const SETTING_KEY = "notification-routing"; +const SECRET_KEY_PATTERN = /^[A-Z][A-Z0-9_]{0,127}$/; + +export interface PersonalNotificationRouting { + inbox: boolean; + browser: boolean; + email: boolean; + personalSlack: boolean; + /** Name of a user-scoped secret containing a Slack incoming-webhook URL. */ + personalSlackWebhookKey: string | null; +} + +export const DEFAULT_PERSONAL_NOTIFICATION_ROUTING: PersonalNotificationRouting = + { + inbox: true, + browser: true, + email: false, + personalSlack: false, + personalSlackWebhookKey: null, + }; + +export async function getPersonalNotificationRouting( + owner: string, +): Promise { + const stored = await getUserSetting(owner, SETTING_KEY); + return normalizePersonalNotificationRouting(stored); +} + +export async function setPersonalNotificationRouting( + owner: string, + input: unknown, +): Promise { + const routing = normalizePersonalNotificationRouting(input, true); + await putUserSetting(owner, SETTING_KEY, { ...routing }); + return routing; +} + +export async function notifyPersonalWithDelivery( + input: Omit, + meta: NotificationMeta, +) { + const routing = await getPersonalNotificationRouting(meta.owner); + const channels: string[] = []; + if (routing.inbox) channels.push("inbox"); + if (routing.email) channels.push("email"); + if (routing.personalSlack && routing.personalSlackWebhookKey) { + channels.push("personal-slack"); + } + + const delivery = + routing.email || (routing.personalSlack && routing.personalSlackWebhookKey) + ? { + ...(routing.email ? { emailRecipients: [meta.owner] } : {}), + ...(routing.personalSlack && routing.personalSlackWebhookKey + ? { + personalSlackWebhookUrl: `\${keys.${routing.personalSlackWebhookKey}}`, + } + : {}), + } + : undefined; + + return notifyWithDelivery( + { + ...input, + channels, + metadata: delivery ? { ...input.metadata, delivery } : input.metadata, + }, + meta, + ); +} + +export function normalizePersonalNotificationRouting( + input: unknown, + strict = false, +): PersonalNotificationRouting { + const value = isRecord(input) ? input : {}; + const key = normalizeSecretKey(value.personalSlackWebhookKey); + const personalSlack = booleanValue(value.personalSlack, false); + if (strict && personalSlack && !key) { + throw new Error( + "A personal Slack webhook secret key is required when personal Slack delivery is enabled.", + ); + } + if ( + strict && + value.personalSlackWebhookKey != null && + value.personalSlackWebhookKey !== "" && + !key + ) { + throw new Error( + "Personal Slack webhook secret keys must use uppercase letters, numbers, and underscores.", + ); + } + + const inbox = booleanValue(value.inbox, true); + return { + inbox, + browser: inbox && booleanValue(value.browser, true), + email: booleanValue(value.email, false), + personalSlack, + personalSlackWebhookKey: key, + }; +} + +function normalizeSecretKey(value: unknown): string | null { + if (typeof value !== "string") return null; + const placeholder = /^\$\{keys\.([A-Z][A-Z0-9_]{0,127})\}$/.exec( + value.trim(), + ); + const key = placeholder?.[1] ?? value.trim(); + return SECRET_KEY_PATTERN.test(key) ? key : null; +} + +function booleanValue(value: unknown, fallback: boolean): boolean { + return typeof value === "boolean" ? value : fallback; +} + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} diff --git a/packages/core/src/notifications/types.ts b/packages/core/src/notifications/types.ts index 760f9b0044..fe9ef95258 100644 --- a/packages/core/src/notifications/types.ts +++ b/packages/core/src/notifications/types.ts @@ -33,8 +33,26 @@ export interface NotificationInput { export interface NotificationMeta { /** Owner email — scopes the notification in the inbox. */ owner: string; + /** Durable workflow effect whose delivery attempts this notification fulfills. */ + workflowEffectId?: string; + /** Effect attempt number. Defaults to 1 when a workflow effect is supplied. */ + workflowAttempt?: number; } +export type NotificationChannelOutcome = + | { + /** The provider supplied concrete delivery evidence. */ + status: "delivered"; + evidence: Record; + } + | { + /** The provider accepted the send but supplied no delivery receipt. */ + status: "unknown"; + evidence?: Record; + } + | { status: "skipped"; reason?: string } + | { status: "failed"; errorMessage?: string }; + export interface NotificationChannel { /** Unique channel name, e.g. `"inbox"`, `"webhook"`, `"slack"`. */ name: string; @@ -42,11 +60,17 @@ export interface NotificationChannel { * Deliver the notification. Must be best-effort — throwing will be logged * but will not block other channels from running. * - * Return `false` to skip (e.g. no URL / no recipients configured) so the - * channel is not recorded in `deliveredChannels`. + * Return a structured outcome. `delivered` requires provider evidence; + * accepted sends without a delivery receipt are `unknown`. Legacy `true` + * and void returns are conservatively interpreted as `unknown`; `false` + * is interpreted as `skipped`. */ deliver( input: NotificationInput, meta: NotificationMeta, - ): void | boolean | Promise; + ): + | void + | boolean + | NotificationChannelOutcome + | Promise; } diff --git a/packages/core/src/scripts/db/exec.ts b/packages/core/src/scripts/db/exec.ts index 24677d83bc..35f8d3284f 100644 --- a/packages/core/src/scripts/db/exec.ts +++ b/packages/core/src/scripts/db/exec.ts @@ -17,6 +17,7 @@ import path from "path"; import { getDatabaseUrl } from "../../db/client.js"; +import { assertSqlDoesNotMutateProtectedTable } from "../../db/protected-mutations.js"; import { parseArgs, fail } from "../utils.js"; import { assertNoRawDbAccessControlWrite, @@ -205,6 +206,7 @@ function validateWriteSql(sql: string, index: number): string { assertNoSensitiveFrameworkTables(normalized, "write"); assertNoRawDbAccessControlWrite(normalized); assertNoSchemaQualifiedTables(normalized, "write"); + assertSqlDoesNotMutateProtectedTable(normalized); return normalized; } diff --git a/packages/core/src/scripts/db/patch.ts b/packages/core/src/scripts/db/patch.ts index 116a404758..e81161d21c 100644 --- a/packages/core/src/scripts/db/patch.ts +++ b/packages/core/src/scripts/db/patch.ts @@ -50,6 +50,7 @@ import path from "path"; import { getDatabaseUrl } from "../../db/client.js"; +import { assertMutationTableIsNotProtected } from "../../db/protected-mutations.js"; import { parseArgs, fail } from "../utils.js"; import { assertNoRawDbAccessControlPatchTarget, @@ -774,6 +775,7 @@ When to use db-patch vs other tools: `Invalid --column: "${column}". Must be a plain identifier (letters, digits, underscore).`, ); assertNoSensitiveFrameworkTables(table, "patch"); + assertMutationTableIsNotProtected(table); assertNoRawDbAccessControlPatchTarget(table, column); assertNoSensitiveFrameworkTables(where, "read"); validateWhere(where); diff --git a/packages/core/src/server/auth.ts b/packages/core/src/server/auth.ts index 322b3612df..1bbbf861c8 100644 --- a/packages/core/src/server/auth.ts +++ b/packages/core/src/server/auth.ts @@ -1778,6 +1778,13 @@ function createAuthGuardFn(): ( return; } + // Netlify's generated minute sweep enters this exact route from a dedicated + // scheduled-function isolate. The handler itself requires the isolate marker + // before touching the durable workflow ledger. + if (p === "/_agent-native/workflow/_drain") { + return; + } + // Read-only agent chat share links. The random token is the bearer secret; // the route returns a sanitized transcript plus bounded run summaries and // exposes no write surface, live event stream, tool payloads, or owner APIs. diff --git a/packages/core/src/server/core-routes-plugin.ts b/packages/core/src/server/core-routes-plugin.ts index 4833c9d5e3..6938764299 100644 --- a/packages/core/src/server/core-routes-plugin.ts +++ b/packages/core/src/server/core-routes-plugin.ts @@ -101,6 +101,8 @@ import { track } from "../tracking/index.js"; import { registerBuiltinProviders } from "../tracking/providers.js"; import { validateTrackPayload } from "../tracking/route.js"; import { createAutomationsHandler } from "../triggers/routes.js"; +import { authorizeWorkflowDrain } from "../workflow/drain-auth.js"; +import { drainWorkflowWork } from "../workflow/runtime.js"; import { createAgentEngineApiKeyHandler } from "./agent-engine-api-key-route.js"; import { getConfiguredAppBasePath, stripAppBasePath } from "./app-base-path.js"; import { getAppName } from "./app-name.js"; @@ -1530,6 +1532,41 @@ export function createCoreRoutesPlugin( ); } + getH3App(nitroApp).use( + `${P}/workflow/_drain`, + defineEventHandler(async (event) => { + setResponseHeader(event, "cache-control", "no-store"); + const scheduledRuntime = + (globalThis as Record) + .__AGENT_NATIVE_WORKFLOW_SCHEDULED_RUNTIME__ === true; + const configuredSecret = + process.env.AGENT_WORKFLOW_DRAIN_SECRET?.trim(); + const authorization = await authorizeWorkflowDrain({ + scheduledRuntime, + configuredSecret, + authorization: getHeader(event, "authorization"), + }); + if (authorization !== "authorized") { + setResponseStatus( + event, + authorization === "unauthorized" ? 401 : 503, + ); + return { + error: + authorization === "unauthorized" + ? "Unauthorized" + : "Workflow drain is not configured for this runtime", + }; + } + const result = await drainWorkflowWork({ + workerId: "scheduled-workflow", + maxItems: 100, + maxDurationMs: 20_000, + }); + return { ok: true, ...result }; + }), + ); + getH3App(nitroApp).use( `${P}/debug/runtime`, defineEventHandler(async (event) => { diff --git a/packages/core/src/triggers/actions/list-automations.ts b/packages/core/src/triggers/actions/list-automations.ts index b3834850ee..03be402db5 100644 --- a/packages/core/src/triggers/actions/list-automations.ts +++ b/packages/core/src/triggers/actions/list-automations.ts @@ -3,7 +3,10 @@ import { z } from "zod"; import { defineAction } from "../../action.js"; import { describeCron, isValidCron, nextOccurrence } from "../../jobs/cron.js"; import { resourceGetByPath, resourceList } from "../../resources/store.js"; -import { parseTriggerFrontmatter } from "../dispatcher.js"; +import { + getDurableAutomationStatus, + parseTriggerFrontmatter, +} from "../dispatcher.js"; const scopeSchema = z.enum(["personal", "organization"]); @@ -73,6 +76,7 @@ export default defineAction({ const full = await resourceGetByPath(owner, resource.path); if (!full || !hasTriggerFrontmatter(full.content)) continue; const { meta, body } = parseTriggerFrontmatter(full.content); + const durableStatus = await getDurableAutomationStatus(full, meta); automations.push({ id: full.id, name: resource.path.replace(/^jobs\//, "").replace(/\.md$/, ""), @@ -85,9 +89,15 @@ export default defineAction({ condition: meta.condition ?? null, body, enabled: meta.enabled, - lastRun: meta.lastRun ?? null, - lastStatus: meta.lastStatus ?? null, - lastError: meta.lastError ?? null, + lastRun: durableStatus + ? (durableStatus.lastRun ?? null) + : (meta.lastRun ?? null), + lastStatus: durableStatus + ? (durableStatus.lastStatus ?? null) + : (meta.lastStatus ?? null), + lastError: durableStatus + ? (durableStatus.lastError ?? null) + : (meta.lastError ?? null), nextRun: nextRun(meta), createdBy: meta.createdBy ?? null, canUpdate: true, diff --git a/packages/core/src/triggers/dispatcher.spec.ts b/packages/core/src/triggers/dispatcher.spec.ts index 0f59d2d367..a6fc16fc64 100644 --- a/packages/core/src/triggers/dispatcher.spec.ts +++ b/packages/core/src/triggers/dispatcher.spec.ts @@ -1,9 +1,13 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { initTriggerDispatcher } from "./dispatcher.js"; +import { + getDurableAutomationStatus, + initTriggerDispatcher, +} from "./dispatcher.js"; const resourceListAllOwnersMock = vi.hoisted(() => vi.fn()); const resourcePutMock = vi.hoisted(() => vi.fn()); +const resourceGetByPathMock = vi.hoisted(() => vi.fn()); const createThreadMock = vi.hoisted(() => vi.fn()); const subscribeMock = vi.hoisted(() => vi.fn()); const unsubscribeMock = vi.hoisted(() => vi.fn()); @@ -11,17 +15,35 @@ const runAgentLoopMock = vi.hoisted(() => vi.fn()); const recordUsageMock = vi.hoisted(() => vi.fn()); const dbExecuteMock = vi.hoisted(() => vi.fn()); const getDbExecMock = vi.hoisted(() => vi.fn()); +const upsertWorkflowSubscriptionMock = vi.hoisted(() => vi.fn()); +const listWorkflowSubscriptionsMock = vi.hoisted(() => vi.fn()); +const listWorkflowExecutionsMock = vi.hoisted(() => vi.fn()); +const registerWorkflowExecutionHandlerMock = vi.hoisted(() => vi.fn()); +const recordWorkflowEffectMock = vi.hoisted(() => vi.fn()); +const finalizeWorkflowEffectMock = vi.hoisted(() => vi.fn()); vi.mock("../resources/store.js", () => ({ + resourceGetByPath: resourceGetByPathMock, resourceListAllOwners: resourceListAllOwnersMock, resourcePut: resourcePutMock, })); vi.mock("../event-bus/index.js", () => ({ + isCertifiedDurableEventTopic: (topic: string) => + topic === "content" || topic.startsWith("content."), subscribe: subscribeMock, unsubscribe: unsubscribeMock, })); +vi.mock("../workflow/index.js", () => ({ + finalizeWorkflowEffect: finalizeWorkflowEffectMock, + listWorkflowExecutions: listWorkflowExecutionsMock, + listWorkflowSubscriptions: listWorkflowSubscriptionsMock, + recordWorkflowEffect: recordWorkflowEffectMock, + registerWorkflowExecutionHandler: registerWorkflowExecutionHandlerMock, + upsertWorkflowSubscription: upsertWorkflowSubscriptionMock, +})); + vi.mock("../chat-threads/store.js", () => ({ createThread: createThreadMock, })); @@ -116,6 +138,7 @@ Respond to the event.`, }, ]); resourcePutMock.mockResolvedValue(undefined); + resourceGetByPathMock.mockResolvedValue(null); createThreadMock.mockResolvedValue({ id: "thread-1" }); subscribeMock.mockImplementation((eventName: string) => `sub-${eventName}`); runAgentLoopMock.mockResolvedValue({ @@ -126,6 +149,152 @@ Respond to the event.`, model: "test-model", }); recordUsageMock.mockResolvedValue(undefined); + upsertWorkflowSubscriptionMock.mockResolvedValue(undefined); + listWorkflowSubscriptionsMock.mockResolvedValue([]); + listWorkflowExecutionsMock.mockResolvedValue([]); + registerWorkflowExecutionHandlerMock.mockReturnValue(() => {}); + recordWorkflowEffectMock.mockResolvedValue({ + effect: { id: "effect-1", status: "unknown" }, + created: true, + }); + finalizeWorkflowEffectMock.mockResolvedValue(true); + }); + + it("registers content event automations with the durable shared engine only", async () => { + resourceListAllOwnersMock.mockResolvedValue([ + { + id: "durable-resource", + owner: "alice+triggers@agent-native.test", + path: "jobs/content-review.md", + content: `--- +schedule: "" +enabled: true +triggerType: event +event: content.item.changed +mode: agentic +createdBy: alice+triggers@agent-native.test +--- + +Review the changed item.`, + }, + ]); + + await initTriggerDispatcher({ + getActions: () => ({}), + getSystemPrompt: async () => "system", + }); + + expect(upsertWorkflowSubscriptionMock).toHaveBeenCalledWith( + expect.objectContaining({ + id: "automation:durable-resource", + kind: "agentic", + eventPattern: "content.item.changed", + enabled: true, + config: expect.objectContaining({ domain: "automation" }), + }), + ); + expect(subscribeMock).not.toHaveBeenCalledWith( + "content.item.changed", + expect.anything(), + ); + }); + + it("derives durable automation status from the shared execution ledger", async () => { + listWorkflowExecutionsMock.mockResolvedValue([ + { + status: "failed", + createdAt: Date.parse("2026-07-17T13:00:00.000Z"), + errorMessage: "Agent run failed", + }, + ]); + + await expect( + getDurableAutomationStatus( + { id: "durable-resource" }, + { + schedule: "", + enabled: true, + triggerType: "event", + event: "content.item.changed", + mode: "agentic", + lastStatus: "success", + }, + ), + ).resolves.toEqual({ + lastStatus: "error", + lastRun: "2026-07-17T13:00:00.000Z", + lastError: "Agent run failed", + }); + expect(listWorkflowExecutionsMock).toHaveBeenCalledWith({ + subscriptionId: "automation:durable-resource", + limit: 1, + }); + }); + + it("runs a claimed durable automation once and records its agent effect", async () => { + const durableResource = { + id: "durable-resource", + owner: "alice+triggers@agent-native.test", + path: "jobs/content-review.md", + content: `--- +schedule: "" +enabled: true +triggerType: event +event: content.item.changed +mode: agentic +createdBy: alice+triggers@agent-native.test +--- + +Review the changed item.`, + }; + resourceListAllOwnersMock.mockResolvedValue([durableResource]); + resourceGetByPathMock.mockResolvedValue(durableResource); + + await initTriggerDispatcher({ + getActions: () => ({}), + getSystemPrompt: async () => "system", + model: "test-model", + }); + const handler = registerWorkflowExecutionHandlerMock.mock.calls.at(-1)?.[0]; + expect(handler).toMatchObject({ kind: "agentic", domain: "automation" }); + + const claim = { + id: "execution-1", + eventId: "event-1", + subscriptionId: "automation:durable-resource", + event: { + id: "event-1", + topic: "content.item.changed", + ownerEmail: "alice+triggers@agent-native.test", + payload: { status: "ready" }, + occurredAt: Date.parse("2026-07-17T12:00:00.000Z"), + }, + subscription: { + config: { + domain: "automation", + resourceOwner: durableResource.owner, + resourcePath: durableResource.path, + triggerName: "content-review", + }, + }, + }; + expect(await handler.execute(claim)).toEqual({ status: "succeeded" }); + expect(recordWorkflowEffectMock).toHaveBeenCalledWith({ + executionId: "execution-1", + kind: "agent-run", + idempotencyKey: "event-1:automation:durable-resource", + }); + expect(runAgentLoopMock).toHaveBeenCalledOnce(); + expect(finalizeWorkflowEffectMock).toHaveBeenCalledWith( + expect.objectContaining({ effectId: "effect-1", status: "delivered" }), + ); + + recordWorkflowEffectMock.mockResolvedValueOnce({ + effect: { id: "effect-1", status: "delivered" }, + created: false, + }); + expect(await handler.execute(claim)).toEqual({ status: "succeeded" }); + expect(runAgentLoopMock).toHaveBeenCalledOnce(); }); it("defers framework-added tools behind tool-search on the first trigger request when an initial tool list is supplied", async () => { diff --git a/packages/core/src/triggers/dispatcher.ts b/packages/core/src/triggers/dispatcher.ts index 7f6a04d32f..51ff216c89 100644 --- a/packages/core/src/triggers/dispatcher.ts +++ b/packages/core/src/triggers/dispatcher.ts @@ -1,9 +1,8 @@ /** - * Trigger dispatcher — bridges the event bus to the automation system. + * Trigger dispatcher — registers event Automations with their dispatch path. * - * On startup, loads all event-triggered jobs from the resources store, - * subscribes to their events, and dispatches them (condition eval → agent - * loop) when matching events fire. + * Certified durable topics become agentic subscriptions in the shared + * workflow engine. Explicitly ephemeral topics retain the process-local bus. */ import { @@ -21,10 +20,31 @@ import { import { attachToolSearch } from "../agent/tool-search.js"; import type { AgentChatEvent } from "../agent/types.js"; import { createThread } from "../chat-threads/store.js"; -import { subscribe, unsubscribe } from "../event-bus/index.js"; +import { assertEphemeralEventTopic } from "../event-bus/authority.js"; +import { + isCertifiedDurableEventTopic, + subscribe, + unsubscribe, +} from "../event-bus/index.js"; import type { EventMeta } from "../event-bus/types.js"; -import { resourceListAllOwners, resourcePut } from "../resources/store.js"; +import { + resourceGetByPath, + resourceListAllOwners, + resourcePut, + type Resource, +} from "../resources/store.js"; import { runWithRequestContext } from "../server/request-context.js"; +import { + finalizeWorkflowEffect, + listWorkflowExecutions, + listWorkflowSubscriptions, + recordWorkflowEffect, + registerWorkflowExecutionHandler, + upsertWorkflowSubscription, + type ClaimedWorkflowExecution, + type WorkflowExecutionHandler, + type WorkflowExecutionResult, +} from "../workflow/index.js"; import { evaluateCondition } from "./condition-evaluator.js"; import type { TriggerFrontmatter } from "./types.js"; @@ -180,6 +200,48 @@ const _eventSubscriptions = new Map(); // deployments; multi-instance would need a conditional DB update. const _dispatchingTriggers = new Set(); let _deps: TriggerDispatcherDeps | null = null; +let _workflowHandlerUnsubscribe: (() => void) | undefined; + +const AUTOMATION_SUBSCRIPTION_DOMAIN = "automation"; + +export function workflowSubscriptionIdForAutomation( + resource: Pick, +): string { + return `automation:${resource.id}`; +} + +export async function getDurableAutomationStatus( + resource: Pick, + meta: TriggerFrontmatter, +): Promise<{ + lastStatus?: TriggerFrontmatter["lastStatus"]; + lastRun?: string; + lastError?: string; +} | null> { + if ( + meta.triggerType !== "event" || + !meta.event || + !isCertifiedDurableEventTopic(meta.event) + ) { + return null; + } + const [execution] = await listWorkflowExecutions({ + subscriptionId: workflowSubscriptionIdForAutomation(resource), + limit: 1, + }); + if (!execution) return {}; + const lastStatus: TriggerFrontmatter["lastStatus"] = + execution.status === "succeeded" + ? "success" + : execution.status === "failed" || execution.status === "unknown" + ? "error" + : "running"; + return { + lastStatus, + lastRun: new Date(execution.createdAt).toISOString(), + lastError: execution.errorMessage ?? undefined, + }; +} /** * Initialize the trigger dispatcher. Call once at server startup. @@ -189,6 +251,10 @@ export async function initTriggerDispatcher( deps: TriggerDispatcherDeps, ): Promise { _deps = deps; + _workflowHandlerUnsubscribe?.(); + _workflowHandlerUnsubscribe = registerWorkflowExecutionHandler( + agenticAutomationHandler, + ); await refreshEventSubscriptions(); } @@ -201,10 +267,17 @@ export async function refreshEventSubscriptions(): Promise { const jobResources = await resourceListAllOwners("jobs/"); const eventNames = new Set(); + await syncDurableAutomationSubscriptions(jobResources); + for (const resource of jobResources) { if (!resource.path.endsWith(".md")) continue; const { meta } = parseTriggerFrontmatter(resource.content); - if (meta.triggerType === "event" && meta.event && meta.enabled) { + if ( + meta.triggerType === "event" && + meta.event && + meta.enabled && + !isCertifiedDurableEventTopic(meta.event) + ) { eventNames.add(meta.event); } } @@ -230,11 +303,187 @@ export async function refreshEventSubscriptions(): Promise { } } +interface DurableAutomationConfig extends Record { + domain: typeof AUTOMATION_SUBSCRIPTION_DOMAIN; + resourceOwner: string; + resourcePath: string; + triggerName: string; +} + +function durableAutomationConfig(resource: Resource): DurableAutomationConfig { + return { + domain: AUTOMATION_SUBSCRIPTION_DOMAIN, + resourceOwner: resource.owner, + resourcePath: resource.path, + triggerName: resource.path.replace(/^jobs\//, "").replace(/\.md$/, ""), + }; +} + +function isAutomationSubscriptionConfig( + config: Record, +): config is DurableAutomationConfig & Record { + return ( + config.domain === AUTOMATION_SUBSCRIPTION_DOMAIN && + typeof config.resourceOwner === "string" && + typeof config.resourcePath === "string" + ); +} + +async function syncDurableAutomationSubscriptions( + resources: Resource[], +): Promise { + const desiredIds = new Set(); + for (const resource of resources) { + if (!resource.path.endsWith(".md")) continue; + const { meta } = parseTriggerFrontmatter(resource.content); + if ( + meta.triggerType !== "event" || + !meta.event || + !isCertifiedDurableEventTopic(meta.event) + ) { + continue; + } + const id = workflowSubscriptionIdForAutomation(resource); + desiredIds.add(id); + await upsertWorkflowSubscription({ + id, + kind: "agentic", + eventPattern: meta.event, + ownerEmail: + resource.owner === "__shared__" + ? (meta.createdBy ?? resource.owner) + : resource.owner, + orgId: meta.orgId ?? null, + config: durableAutomationConfig(resource), + enabled: meta.enabled && meta.mode === "agentic", + }); + } + + const existing = await listWorkflowSubscriptions({ kind: "agentic" }); + for (const subscription of existing) { + if ( + desiredIds.has(subscription.id) || + !isAutomationSubscriptionConfig(subscription.config) || + !subscription.enabled + ) { + continue; + } + await upsertWorkflowSubscription({ + ...subscription, + enabled: false, + }); + } +} + +const agenticAutomationHandler: WorkflowExecutionHandler = { + kind: "agentic", + domain: AUTOMATION_SUBSCRIPTION_DOMAIN, + async execute( + claim: ClaimedWorkflowExecution, + ): Promise { + if (!_deps) { + return { + status: "retrying", + errorMessage: "Automation runtime is not initialized", + }; + } + const config = claim.subscription.config; + if (!isAutomationSubscriptionConfig(config)) { + return { + status: "failed", + errorMessage: "Automation subscription config is invalid", + }; + } + const resource = await resourceGetByPath( + config.resourceOwner, + config.resourcePath, + ); + if (!resource) { + return { + status: "failed", + errorMessage: "Automation resource no longer exists", + }; + } + const { meta, body } = parseTriggerFrontmatter(resource.content); + if ( + !meta.enabled || + meta.mode !== "agentic" || + meta.triggerType !== "event" || + !meta.event || + !isCertifiedDurableEventTopic(meta.event) + ) { + return { status: "succeeded" }; + } + + const owner = meta.createdBy || resource.owner; + const userApiKey = await getOwnerActiveApiKey(owner); + const apiKey = userApiKey || _deps.apiKey; + if (!apiKey) { + return { + status: "failed", + errorMessage: `No API key for automation "${config.triggerName}"`, + }; + } + if ( + !(await evaluateCondition(meta.condition, claim.event.payload, apiKey)) + ) { + return { status: "succeeded" }; + } + + const idempotencyKey = `${claim.eventId}:${claim.subscriptionId}`; + const reservation = await recordWorkflowEffect({ + executionId: claim.id, + kind: "agent-run", + idempotencyKey, + }); + if (!reservation.created) { + if (reservation.effect.status === "delivered") { + return { status: "succeeded" }; + } + if (reservation.effect.status === "failed") { + return { + status: "failed", + errorMessage: + reservation.effect.errorMessage ?? "Automation agent run failed", + }; + } + return { + status: "unknown", + errorMessage: + "Automation effect was reserved previously; refusing to duplicate the agent run", + }; + } + + const outcome = await dispatchAgentic( + resource, + meta, + body, + claim.event.payload, + { + eventId: claim.event.id, + emittedAt: new Date(claim.event.occurredAt).toISOString(), + owner: claim.event.ownerEmail, + }, + apiKey, + false, + ); + const delivered = outcome.status === "succeeded"; + await finalizeWorkflowEffect({ + effectId: reservation.effect.id, + status: delivered ? "delivered" : "failed", + errorMessage: outcome.errorMessage, + result: delivered ? { threadStarted: true } : undefined, + }); + return outcome; + }, +}; + async function handleEvent( eventName: string, payload: unknown, eventMeta: EventMeta, ): Promise { + assertEphemeralEventTopic("subscribe", eventName); if (!_deps) return; try { @@ -366,8 +615,14 @@ async function dispatchAgentic( payload: unknown, eventMeta: EventMeta, apiKey: string, -): Promise { - if (!_deps) return; + persistResourceStatus = true, +): Promise { + if (!_deps) { + return { + status: "retrying", + errorMessage: "Automation runtime is not initialized", + }; + } const triggerName = resource.path.replace(/^jobs\//, "").replace(/\.md$/, ""); const now = new Date(); @@ -385,30 +640,33 @@ async function dispatchAgentic( `[triggers] Skipping trigger "${triggerName}": ${validity.reason}. ` + `User/membership no longer valid — leaving entry for admin review.`, ); + if (persistResourceStatus) { + meta.lastRun = now.toISOString(); + meta.lastStatus = "skipped"; + meta.lastError = validity.reason; + await resourcePut( + resource.owner, + resource.path, + buildTriggerContent(meta, body), + ); + } + return { status: "succeeded" }; + } + + if (persistResourceStatus) { meta.lastRun = now.toISOString(); - meta.lastStatus = "skipped"; - meta.lastError = validity.reason; + meta.lastStatus = "running"; + meta.lastError = undefined; await resourcePut( resource.owner, resource.path, buildTriggerContent(meta, body), ); - return; } - // Mark as running - meta.lastRun = now.toISOString(); - meta.lastStatus = "running"; - meta.lastError = undefined; - await resourcePut( - resource.owner, - resource.path, - buildTriggerContent(meta, body), - ); - - await runWithRequestContext( + return await runWithRequestContext( { userEmail: jobUserEmail, orgId: jobOrgId }, - async () => { + async (): Promise => { try { const baseActions = _deps!.getActions(); const systemPrompt = await _deps!.getSystemPrompt(jobUserEmail); @@ -513,23 +771,30 @@ ${body}`; } } - meta.lastStatus = "success"; - await resourcePut( - resource.owner, - resource.path, - buildTriggerContent(meta, body), - ); + if (persistResourceStatus) { + meta.lastStatus = "success"; + await resourcePut( + resource.owner, + resource.path, + buildTriggerContent(meta, body), + ); + } console.log(`[triggers] "${triggerName}" completed successfully`); + return { status: "succeeded" }; } catch (err: any) { - meta.lastStatus = "error"; - meta.lastError = err?.message?.slice(0, 200) || "Unknown error"; - await resourcePut( - resource.owner, - resource.path, - buildTriggerContent(meta, body), - ); + const errorMessage = err?.message?.slice(0, 200) || "Unknown error"; + if (persistResourceStatus) { + meta.lastStatus = "error"; + meta.lastError = errorMessage; + await resourcePut( + resource.owner, + resource.path, + buildTriggerContent(meta, body), + ); + } console.error(`[triggers] "${triggerName}" failed:`, err?.message); + return { status: "failed", errorMessage }; } }, ); diff --git a/packages/core/src/triggers/routes.ts b/packages/core/src/triggers/routes.ts index 34b34e715d..bde40e6507 100644 --- a/packages/core/src/triggers/routes.ts +++ b/packages/core/src/triggers/routes.ts @@ -19,6 +19,7 @@ import { getSession } from "../server/auth.js"; import { readBody } from "../server/h3-helpers.js"; import { buildTriggerContent, + getDurableAutomationStatus, parseTriggerFrontmatter, refreshEventSubscriptions, } from "./dispatcher.js"; @@ -146,6 +147,7 @@ async function resourceToAutomationItem( resource: Resource, ): Promise { const { meta, body } = parseTriggerFrontmatter(resource.content); + const durableStatus = await getDurableAutomationStatus(resource, meta); return { id: resource.id, name: automationName(resource.path), @@ -165,9 +167,9 @@ async function resourceToAutomationItem( mode: meta.mode, domain: meta.domain, enabled: meta.enabled, - lastStatus: meta.lastStatus, - lastRun: meta.lastRun, - lastError: meta.lastError, + lastStatus: durableStatus ? durableStatus.lastStatus : meta.lastStatus, + lastRun: durableStatus ? durableStatus.lastRun : meta.lastRun, + lastError: durableStatus ? durableStatus.lastError : meta.lastError, nextRun: nextRunForMeta(meta), createdBy: meta.createdBy, body, diff --git a/packages/core/src/triggers/types.ts b/packages/core/src/triggers/types.ts index cbfae259f6..88a5d19d10 100644 --- a/packages/core/src/triggers/types.ts +++ b/packages/core/src/triggers/types.ts @@ -1,15 +1,15 @@ /** * Extended frontmatter for triggers (superset of JobFrontmatter). * - * Stored as markdown resources under `jobs/` — reuses the same storage - * and scheduler infrastructure. Event-triggered jobs are skipped by the - * cron scheduler and dispatched by the event bus instead. + * Stored as markdown resources under `jobs/` — reuses the same authoring + * surface. Certified durable events run through workflow subscriptions; + * explicitly ephemeral events retain the in-process bus path. */ export interface TriggerFrontmatter { schedule: string; enabled: boolean; - /** "schedule" = cron-based (legacy jobs). "event" = fires on bus event. */ + /** "schedule" = cron-based (legacy jobs). "event" = event-triggered. */ triggerType: "schedule" | "event"; /** For event triggers: the event name to subscribe to. */ event?: string; diff --git a/packages/core/src/workflow/drain-auth.spec.ts b/packages/core/src/workflow/drain-auth.spec.ts new file mode 100644 index 0000000000..5b93d50055 --- /dev/null +++ b/packages/core/src/workflow/drain-auth.spec.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; + +import { authorizeWorkflowDrain } from "./drain-auth.js"; + +describe("workflow drain authorization", () => { + it("trusts the generated scheduled-function runtime marker", async () => { + await expect( + authorizeWorkflowDrain({ scheduledRuntime: true }), + ).resolves.toBe("authorized"); + }); + + it("fails closed when a portable scheduler secret is not configured", async () => { + await expect( + authorizeWorkflowDrain({ scheduledRuntime: false }), + ).resolves.toBe("unconfigured"); + }); + + it("accepts only the exact configured bearer value", async () => { + await expect( + authorizeWorkflowDrain({ + scheduledRuntime: false, + configuredSecret: "drain-secret", + authorization: "Bearer drain-secret", + }), + ).resolves.toBe("authorized"); + await expect( + authorizeWorkflowDrain({ + scheduledRuntime: false, + configuredSecret: "drain-secret", + authorization: "Bearer wrong", + }), + ).resolves.toBe("unauthorized"); + }); +}); diff --git a/packages/core/src/workflow/drain-auth.ts b/packages/core/src/workflow/drain-auth.ts new file mode 100644 index 0000000000..b45261d973 --- /dev/null +++ b/packages/core/src/workflow/drain-auth.ts @@ -0,0 +1,39 @@ +export type WorkflowDrainAuthorization = + | "authorized" + | "unauthorized" + | "unconfigured"; + +async function digest(value: string): Promise { + const bytes = new TextEncoder().encode(value); + return new Uint8Array( + await globalThis.crypto.subtle.digest("SHA-256", bytes), + ); +} + +async function secretsEqual(left: string, right: string): Promise { + const [leftDigest, rightDigest] = await Promise.all([ + digest(left), + digest(right), + ]); + let difference = 0; + for (let index = 0; index < leftDigest.length; index += 1) { + difference |= leftDigest[index]! ^ rightDigest[index]!; + } + return difference === 0; +} + +export async function authorizeWorkflowDrain(input: { + scheduledRuntime: boolean; + configuredSecret?: string; + authorization?: string; +}): Promise { + if (input.scheduledRuntime) return "authorized"; + const secret = input.configuredSecret?.trim(); + if (!secret) return "unconfigured"; + return (await secretsEqual( + input.authorization?.trim() ?? "", + `Bearer ${secret}`, + )) + ? "authorized" + : "unauthorized"; +} diff --git a/packages/core/src/workflow/index.ts b/packages/core/src/workflow/index.ts new file mode 100644 index 0000000000..ee82897667 --- /dev/null +++ b/packages/core/src/workflow/index.ts @@ -0,0 +1,7 @@ +export * from "./schema.js"; +export * from "./store.js"; +export * from "./types.js"; +export * from "./drain-auth.js"; +export * from "./runtime.js"; +export * from "./virtual-subscriptions.js"; +export { emitWorkflowWake, subscribeWorkflowWake } from "./wake.js"; diff --git a/packages/core/src/workflow/runtime.ts b/packages/core/src/workflow/runtime.ts new file mode 100644 index 0000000000..7cdc8fa58b --- /dev/null +++ b/packages/core/src/workflow/runtime.ts @@ -0,0 +1,442 @@ +import { + claimNextScheduledWork, + claimNextWorkflowExecution, + finalizeScheduledWork, + finalizeWorkflowExecution, + getWorkflowExecution, + releaseWorkflowExecutionRetry, + scheduleWorkflowWork, + type ClaimedScheduledWork, +} from "./store.js"; +import type { + ClaimedWorkflowExecution, + WorkflowExecutionStatus, + WorkflowSubscriptionKind, +} from "./types.js"; +import { subscribeWorkflowWake } from "./wake.js"; + +export interface WorkflowExecutionResult { + status: Extract< + WorkflowExecutionStatus, + "succeeded" | "failed" | "retrying" | "unknown" + >; + errorMessage?: string; +} + +export interface WorkflowExecutionHandler { + kind: WorkflowSubscriptionKind; + /** Optional subscription config domain, such as `content`. */ + domain?: string; + execute( + claim: ClaimedWorkflowExecution, + ): Promise; +} + +export interface ScheduledWorkflowResult { + status?: "completed" | "failed" | "dead_letter" | "pending"; + errorMessage?: string; + dueAt?: number; +} + +export interface ScheduledWorkflowHandler { + workType: string; + execute(claim: ClaimedScheduledWork): Promise; +} + +export interface WorkflowRetryPolicy { + maxAttempts?: number; + baseDelayMs?: number; + maxDelayMs?: number; +} + +const HANDLERS_KEY = Symbol.for("@agent-native/core/workflow.handlers"); +const SCHEDULED_HANDLERS_KEY = Symbol.for( + "@agent-native/core/workflow.scheduled-handlers", +); + +function handlers(): WorkflowExecutionHandler[] { + const global = globalThis as typeof globalThis & { + [HANDLERS_KEY]?: WorkflowExecutionHandler[]; + }; + return (global[HANDLERS_KEY] ??= []); +} + +function scheduledHandlers(): ScheduledWorkflowHandler[] { + const global = globalThis as typeof globalThis & { + [SCHEDULED_HANDLERS_KEY]?: ScheduledWorkflowHandler[]; + }; + return (global[SCHEDULED_HANDLERS_KEY] ??= []); +} + +/** Registers an effect executor, not a queue consumer or worker. */ +export function registerWorkflowExecutionHandler( + handler: WorkflowExecutionHandler, +): () => void { + handlers().push(handler); + return () => { + const index = handlers().indexOf(handler); + if (index >= 0) handlers().splice(index, 1); + }; +} + +/** Register delayed work without introducing another timer or claim loop. */ +export function registerScheduledWorkflowHandler( + handler: ScheduledWorkflowHandler, +): () => void { + if (handler.workType === "execution_retry") { + throw new Error("execution_retry is reserved by the workflow runtime"); + } + scheduledHandlers().push(handler); + return () => { + const index = scheduledHandlers().indexOf(handler); + if (index >= 0) scheduledHandlers().splice(index, 1); + }; +} + +function domainOf(claim: ClaimedWorkflowExecution): string | undefined { + const domain = claim.subscription.config.domain; + return typeof domain === "string" ? domain : undefined; +} + +function findHandler( + claim: ClaimedWorkflowExecution, +): WorkflowExecutionHandler | undefined { + const domain = domainOf(claim); + return handlers().find( + (handler) => + handler.kind === claim.subscription.kind && + (handler.domain == null || handler.domain === domain), + ); +} + +function retrySettings(policy: WorkflowRetryPolicy | undefined) { + return { + maxAttempts: Math.min(Math.max(policy?.maxAttempts ?? 3, 1), 20), + baseDelayMs: Math.min( + Math.max(policy?.baseDelayMs ?? 1_000, 1), + 60 * 60_000, + ), + maxDelayMs: Math.min( + Math.max(policy?.maxDelayMs ?? 5 * 60_000, 1), + 24 * 60 * 60_000, + ), + }; +} + +function retryDelay(attempt: number, policy: WorkflowRetryPolicy | undefined) { + const settings = retrySettings(policy); + return Math.min( + settings.baseDelayMs * 2 ** Math.max(attempt - 1, 0), + settings.maxDelayMs, + ); +} + +async function deferExecutionRetry(input: { + claim: ClaimedWorkflowExecution; + errorMessage?: string; + now: number; + retryPolicy?: WorkflowRetryPolicy; +}): Promise { + const settings = retrySettings(input.retryPolicy); + if (input.claim.attempt >= settings.maxAttempts) { + await finalizeWorkflowExecution({ + executionId: input.claim.id, + leaseToken: input.claim.leaseToken, + fenceVersion: input.claim.fenceVersion, + status: "failed", + errorMessage: input.errorMessage ?? "Workflow retry limit exhausted", + now: input.now, + }); + return; + } + + await scheduleWorkflowWork({ + workType: "execution_retry", + subjectKey: input.claim.event.subjectKey, + eventId: input.claim.eventId, + subscriptionId: input.claim.subscriptionId, + payload: { + executionId: input.claim.id, + expectedAttempt: input.claim.attempt, + }, + dedupeKey: `execution_retry:${input.claim.id}`, + dueAt: input.now + retryDelay(input.claim.attempt, input.retryPolicy), + now: input.now, + }); + await finalizeWorkflowExecution({ + executionId: input.claim.id, + leaseToken: input.claim.leaseToken, + fenceVersion: input.claim.fenceVersion, + status: "retrying", + errorMessage: input.errorMessage, + now: input.now, + }); +} + +/** Claims and executes one immediate durable execution. */ +export async function processNextWorkflowExecution(options: { + workerId: string; + leaseMs?: number; + now?: number; + retryPolicy?: WorkflowRetryPolicy; +}): Promise { + const claim = await claimNextWorkflowExecution(options); + if (!claim) return null; + const now = options.now ?? Date.now(); + const handler = findHandler(claim); + if (!handler) { + await finalizeWorkflowExecution({ + executionId: claim.id, + leaseToken: claim.leaseToken, + fenceVersion: claim.fenceVersion, + status: "unknown", + errorMessage: `No workflow handler registered for ${claim.subscription.kind}:${domainOf(claim) ?? "*"}`, + now, + }); + return claim; + } + try { + const outcome = (await handler.execute(claim)) ?? { status: "succeeded" }; + if (outcome.status === "retrying") { + await deferExecutionRetry({ + claim, + errorMessage: outcome.errorMessage, + now, + retryPolicy: options.retryPolicy, + }); + } else { + await finalizeWorkflowExecution({ + executionId: claim.id, + leaseToken: claim.leaseToken, + fenceVersion: claim.fenceVersion, + status: outcome.status, + errorMessage: outcome.errorMessage, + now, + }); + } + } catch (error) { + await deferExecutionRetry({ + claim, + errorMessage: error instanceof Error ? error.message : String(error), + now, + retryPolicy: options.retryPolicy, + }); + } + return claim; +} + +async function executeScheduledWork( + claim: ClaimedScheduledWork, + options: { + now: number; + retryPolicy?: WorkflowRetryPolicy; + }, +): Promise { + if (claim.workType === "execution_retry") { + const executionId = claim.payload.executionId; + const expectedAttempt = claim.payload.expectedAttempt; + if ( + typeof executionId !== "string" || + typeof expectedAttempt !== "number" + ) { + await finalizeScheduledWork({ + ...claim, + status: "dead_letter", + errorMessage: "Invalid execution retry payload", + now: options.now, + }); + return; + } + const released = await releaseWorkflowExecutionRetry({ + executionId, + expectedAttempt, + now: options.now, + }); + if (!released) { + const execution = await getWorkflowExecution(executionId); + if ( + execution?.status === "running" && + execution.attempt === expectedAttempt + ) { + await finalizeScheduledWork({ + ...claim, + status: "pending", + dueAt: Math.max( + execution.leaseExpiresAt ?? options.now + 1_000, + options.now + 1, + ), + now: options.now, + }); + return; + } + } + await finalizeScheduledWork({ + ...claim, + status: "completed", + now: options.now, + }); + return; + } + + const handler = scheduledHandlers().find( + (candidate) => candidate.workType === claim.workType, + ); + if (!handler) { + await finalizeScheduledWork({ + ...claim, + status: "dead_letter", + errorMessage: `No scheduled workflow handler registered for ${claim.workType}`, + now: options.now, + }); + return; + } + try { + const result = (await handler.execute(claim)) ?? { status: "completed" }; + const status = result.status ?? "completed"; + await finalizeScheduledWork({ + ...claim, + status, + errorMessage: result.errorMessage, + dueAt: + status === "pending" + ? (result.dueAt ?? + options.now + retryDelay(claim.attempt, options.retryPolicy)) + : result.dueAt, + now: options.now, + }); + } catch (error) { + const settings = retrySettings(options.retryPolicy); + const exhausted = claim.attempt >= settings.maxAttempts; + await finalizeScheduledWork({ + ...claim, + status: exhausted ? "dead_letter" : "pending", + errorMessage: error instanceof Error ? error.message : String(error), + dueAt: exhausted + ? undefined + : options.now + retryDelay(claim.attempt, options.retryPolicy), + now: options.now, + }); + } +} + +/** + * Process one row through the sole workflow claim engine. Due scheduled work is + * drained before immediate executions, so delays, debounce, escalation, and + * retries all share the same lease authority. + */ +export async function processNextWorkflowWork(options: { + workerId: string; + leaseMs?: number; + now?: number; + retryPolicy?: WorkflowRetryPolicy; +}): Promise< + | { kind: "scheduled"; claim: ClaimedScheduledWork } + | { kind: "execution"; claim: ClaimedWorkflowExecution } + | null +> { + const now = options.now ?? Date.now(); + const scheduled = await claimNextScheduledWork({ ...options, now }); + if (scheduled) { + await executeScheduledWork(scheduled, { + now, + retryPolicy: options.retryPolicy, + }); + return { kind: "scheduled", claim: scheduled }; + } + const execution = await processNextWorkflowExecution({ ...options, now }); + return execution ? { kind: "execution", claim: execution } : null; +} + +/** Drain a bounded batch through the one workflow claim engine. */ +export async function drainWorkflowWork(options: { + workerId: string; + leaseMs?: number; + maxItems?: number; + maxDurationMs?: number; + retryPolicy?: WorkflowRetryPolicy; +}): Promise<{ processed: number; exhausted: boolean }> { + const maxItems = Math.min(Math.max(options.maxItems ?? 25, 1), 500); + const maxDurationMs = Math.min( + Math.max(options.maxDurationMs ?? 20_000, 100), + 50_000, + ); + const deadline = Date.now() + maxDurationMs; + let processed = 0; + while (processed < maxItems && Date.now() < deadline) { + const work = await processNextWorkflowWork(options); + if (!work) return { processed, exhausted: true }; + processed += 1; + } + return { processed, exhausted: false }; +} + +/** Connect wake hints and a safety sweep to the one durable claim engine. */ +export function startWorkflowWakeProcessor(options: { + workerId: string; + leaseMs?: number; + maxPerWake?: number; + pollIntervalMs?: number; + wakeDelayMs?: number; + busyRetryDelayMs?: number; + retryPolicy?: WorkflowRetryPolicy; + onError?: (error: unknown) => void; +}): () => void { + let draining = false; + let stopped = false; + let pendingDrain: ReturnType | undefined; + const wakeDelayMs = Math.min(Math.max(options.wakeDelayMs ?? 25, 0), 5_000); + const busyRetryDelayMs = Math.min( + Math.max(options.busyRetryDelayMs ?? 100, 1), + 30_000, + ); + const isBusyError = (error: unknown) => + error instanceof Error && + /(?:database is locked|database is busy|SQLITE_BUSY)/i.test(error.message); + const scheduleDrain = (delayMs = wakeDelayMs) => { + if (stopped) return; + if (pendingDrain) clearTimeout(pendingDrain); + pendingDrain = setTimeout(() => { + pendingDrain = undefined; + void drain(); + }, delayMs); + pendingDrain.unref?.(); + }; + const drain = async () => { + if (draining || stopped) { + if (!stopped) scheduleDrain(); + return; + } + draining = true; + try { + await drainWorkflowWork({ + ...options, + maxItems: options.maxPerWake, + maxDurationMs: 20_000, + }); + } catch (error) { + if (isBusyError(error)) scheduleDrain(busyRetryDelayMs); + else options.onError?.(error); + } finally { + draining = false; + } + }; + const unsubscribeWake = subscribeWorkflowWake(() => scheduleDrain()); + const pollIntervalMs = Math.min( + Math.max(options.pollIntervalMs ?? 30_000, 1_000), + 5 * 60_000, + ); + const timer = setInterval(() => scheduleDrain(), pollIntervalMs); + timer.unref?.(); + scheduleDrain(); + return () => { + stopped = true; + unsubscribeWake(); + clearInterval(timer); + if (pendingDrain) clearTimeout(pendingDrain); + }; +} + +export function __resetWorkflowExecutionHandlers(): void { + handlers().splice(0); + scheduledHandlers().splice(0); +} diff --git a/packages/core/src/workflow/schema.ts b/packages/core/src/workflow/schema.ts new file mode 100644 index 0000000000..63759ac8c7 --- /dev/null +++ b/packages/core/src/workflow/schema.ts @@ -0,0 +1,196 @@ +import { integer, table, text } from "../db/schema.js"; + +export const workflowEvents = table("workflow_events", { + id: text("id").primaryKey(), + eventSequence: integer("event_sequence").notNull(), + topic: text("topic").notNull(), + subjectType: text("subject_type").notNull(), + subjectId: text("subject_id").notNull(), + subjectKey: text("subject_key").notNull(), + ownerEmail: text("owner_email").notNull(), + orgId: text("org_id"), + payload: text("payload").notNull(), + actorContext: text("actor_context").notNull(), + causalEventId: text("causal_event_id"), + occurredAt: integer("occurred_at").notNull(), + availableAt: integer("available_at").notNull(), + createdAt: integer("created_at").notNull(), + materializedAt: integer("materialized_at"), +}); + +export const workflowMaterializationBacklog = table( + "workflow_materialization_backlog", + { + id: text("id").primaryKey(), + eventId: text("event_id").notNull(), + subscriptionId: text("subscription_id").notNull(), + subscriptionVersion: integer("subscription_version").notNull(), + subjectKey: text("subject_key").notNull(), + createdAt: integer("created_at").notNull(), + }, +); + +export const workflowSubscriptions = table("workflow_subscriptions", { + id: text("id").primaryKey(), + kind: text("kind", { enum: ["deterministic", "agentic"] }).notNull(), + eventPattern: text("event_pattern").notNull(), + ownerEmail: text("owner_email").notNull(), + orgId: text("org_id"), + config: text("config").notNull(), + enabled: integer("enabled", { mode: "boolean" }).notNull(), + createdAt: integer("created_at").notNull(), + updatedAt: integer("updated_at").notNull(), +}); + +export const workflowSubscriptionVersions = table( + "workflow_subscription_versions", + { + id: text("id").primaryKey(), + subscriptionId: text("subscription_id").notNull(), + version: integer("version").notNull(), + kind: text("kind", { enum: ["deterministic", "agentic"] }).notNull(), + eventPattern: text("event_pattern").notNull(), + ownerEmail: text("owner_email").notNull(), + orgId: text("org_id"), + config: text("config").notNull(), + enabled: integer("enabled", { mode: "boolean" }).notNull(), + activeAfterSequence: integer("active_after_sequence").notNull(), + activeAt: integer("active_at").notNull(), + createdAt: integer("created_at").notNull(), + }, +); + +export const workflowSequenceCounters = table("workflow_sequence_counters", { + name: text("name").primaryKey(), + value: integer("value").notNull(), +}); + +export const workflowVirtualProviderState = table( + "workflow_virtual_provider_state", + { + providerId: text("provider_id").primaryKey(), + evaluationStartSequence: integer("evaluation_start_sequence").notNull(), + createdAt: integer("created_at").notNull(), + }, +); + +export const workflowExecutions = table("workflow_executions", { + id: text("id").primaryKey(), + eventId: text("event_id").notNull(), + subscriptionId: text("subscription_id").notNull(), + subscriptionVersion: integer("subscription_version"), + subjectKey: text("subject_key").notNull(), + status: text("status", { + enum: [ + "pending", + "running", + "succeeded", + "failed", + "retrying", + "unknown", + "acknowledged", + ], + }).notNull(), + attempt: integer("attempt").notNull(), + leaseToken: text("lease_token"), + leaseExpiresAt: integer("lease_expires_at"), + fenceVersion: integer("fence_version").notNull(), + errorMessage: text("error_message"), + createdAt: integer("created_at").notNull(), + updatedAt: integer("updated_at").notNull(), + completedAt: integer("completed_at"), +}); + +export const workflowScheduledWork = table("workflow_scheduled_work", { + id: text("id").primaryKey(), + workType: text("work_type").notNull(), + subjectKey: text("subject_key").notNull(), + eventId: text("event_id"), + subscriptionId: text("subscription_id"), + payload: text("payload").notNull(), + dedupeKey: text("dedupe_key"), + dueAt: integer("due_at").notNull(), + status: text("status", { + enum: [ + "pending", + "running", + "completed", + "cancelled", + "failed", + "dead_letter", + ], + }).notNull(), + attempt: integer("attempt").notNull(), + leaseToken: text("lease_token"), + leaseExpiresAt: integer("lease_expires_at"), + fenceVersion: integer("fence_version").notNull(), + errorMessage: text("error_message"), + completedAt: integer("completed_at"), + createdAt: integer("created_at").notNull(), + updatedAt: integer("updated_at").notNull(), +}); + +export const workflowEffects = table("workflow_effects", { + id: text("id").primaryKey(), + executionId: text("execution_id").notNull(), + kind: text("kind").notNull(), + idempotencyKey: text("idempotency_key").notNull(), + status: text("status", { + enum: [ + "delivered", + "failed", + "retrying", + "unknown", + "skipped", + "suppressed", + "coalesced", + ], + }).notNull(), + result: text("result"), + errorMessage: text("error_message"), + createdAt: integer("created_at").notNull(), + updatedAt: integer("updated_at").notNull(), +}); + +export const workflowRuntimeControls = table("workflow_runtime_controls", { + id: text("id").primaryKey(), + ownerEmail: text("owner_email").notNull(), + orgId: text("org_id").notNull(), + domain: text("domain").notNull(), + scope: text("scope", { enum: ["global", "resource"] }).notNull(), + scopeId: text("scope_id").notNull(), + evaluatorPaused: integer("evaluator_paused", { mode: "boolean" }).notNull(), + effectsPaused: integer("effects_paused", { mode: "boolean" }).notNull(), + createdAt: integer("created_at").notNull(), + updatedAt: integer("updated_at").notNull(), +}); + +export const notificationDeliveryAttempts = table( + "notification_delivery_attempts", + { + id: text("id").primaryKey(), + effectId: text("effect_id").notNull(), + notificationId: text("notification_id"), + channel: text("channel").notNull(), + attempt: integer("attempt").notNull(), + status: text("status", { + enum: ["delivered", "failed", "retrying", "unknown", "skipped"], + }).notNull(), + errorMessage: text("error_message"), + createdAt: integer("created_at").notNull(), + updatedAt: integer("updated_at").notNull(), + }, +); + +export const workflowSchema = { + workflowSequenceCounters, + workflowVirtualProviderState, + workflowEvents, + workflowSubscriptions, + workflowSubscriptionVersions, + workflowExecutions, + workflowScheduledWork, + workflowEffects, + workflowRuntimeControls, + notificationDeliveryAttempts, +}; diff --git a/packages/core/src/workflow/store.postgres.integration.spec.ts b/packages/core/src/workflow/store.postgres.integration.spec.ts new file mode 100644 index 0000000000..f20031e68c --- /dev/null +++ b/packages/core/src/workflow/store.postgres.integration.spec.ts @@ -0,0 +1,193 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; + +import { closeDbExec, getDbExec } from "../db/client.js"; +import { + __resetWorkflowSchemaForTests, + claimNextWorkflowExecution, + ensureWorkflowSchema, + finalizeWorkflowExecution, + insertWorkflowEvent, + materializeWorkflowExecutions, + upsertWorkflowSubscription, +} from "./store.js"; + +const postgresUrl = process.env.WORKFLOW_POSTGRES_TEST_URL; +const describePostgres = postgresUrl ? describe : describe.skip; + +const workflowTables = [ + "notification_delivery_attempts", + "workflow_effects", + "workflow_scheduled_work", + "workflow_executions", + "workflow_materialization_backlog", + "workflow_subscription_versions", + "workflow_subscriptions", + "workflow_events", + "workflow_virtual_provider_state", + "workflow_runtime_controls", + "workflow_sequence_counters", +] as const; + +async function clearWorkflowTables(): Promise { + await getDbExec().execute( + `TRUNCATE TABLE ${workflowTables.join(", ")} RESTART IDENTITY CASCADE`, + ); +} + +async function seedSubscription(id: string): Promise { + await upsertWorkflowSubscription({ + id, + kind: "deterministic", + eventPattern: "content.item.changed", + ownerEmail: "owner@example.com", + config: { domain: "content" }, + }); +} + +async function seedEvent(id: string, subjectId = "item-1"): Promise { + await insertWorkflowEvent({ + id, + topic: "content.item.changed", + subjectType: "content.item", + subjectId, + ownerEmail: "owner@example.com", + payload: { id }, + actorContext: { kind: "user", userId: "user-1" }, + occurredAt: 100, + }); +} + +describePostgres("workflow store on Postgres", () => { + beforeAll(async () => { + process.env.DATABASE_URL = postgresUrl; + await closeDbExec(); + __resetWorkflowSchemaForTests(); + await ensureWorkflowSchema(); + }); + + beforeEach(async () => { + await clearWorkflowTables(); + __resetWorkflowSchemaForTests(); + await ensureWorkflowSchema(); + }); + + afterAll(async () => { + if (postgresUrl) await clearWorkflowTables(); + await closeDbExec(); + }); + + it("claims each subject in event order under concurrent workers", async () => { + await seedSubscription("ordered-rule"); + await seedEvent("event-a"); + await seedEvent("event-b"); + + const [firstWorker, secondWorker] = await Promise.all([ + claimNextWorkflowExecution({ workerId: "worker-a", now: 200 }), + claimNextWorkflowExecution({ workerId: "worker-b", now: 200 }), + ]); + const firstClaim = firstWorker ?? secondWorker; + + expect([firstWorker, secondWorker].filter(Boolean)).toHaveLength(1); + expect(firstClaim?.eventId).toBe("event-a"); + expect( + await finalizeWorkflowExecution({ + executionId: firstClaim!.id, + leaseToken: firstClaim!.leaseToken, + fenceVersion: firstClaim!.fenceVersion, + status: "succeeded", + now: 201, + }), + ).toBe(true); + + const nextClaim = await claimNextWorkflowExecution({ + workerId: "worker-c", + now: 202, + }); + expect(nextClaim?.eventId).toBe("event-b"); + }); + + it("rejects a stale worker after a lease is fenced and reclaimed", async () => { + await seedSubscription("fencing-rule"); + await seedEvent("fenced-event"); + + const staleClaim = await claimNextWorkflowExecution({ + workerId: "stale-worker", + leaseMs: 1_000, + now: 200, + }); + const currentClaim = await claimNextWorkflowExecution({ + workerId: "current-worker", + leaseMs: 1_000, + now: 1_201, + }); + + expect(currentClaim?.id).toBe(staleClaim?.id); + expect(currentClaim?.fenceVersion).toBe( + (staleClaim?.fenceVersion ?? 0) + 1, + ); + expect( + await finalizeWorkflowExecution({ + executionId: staleClaim!.id, + leaseToken: staleClaim!.leaseToken, + fenceVersion: staleClaim!.fenceVersion, + status: "succeeded", + now: 1_202, + }), + ).toBe(false); + expect( + await finalizeWorkflowExecution({ + executionId: currentClaim!.id, + leaseToken: currentClaim!.leaseToken, + fenceVersion: currentClaim!.fenceVersion, + status: "succeeded", + now: 1_203, + }), + ).toBe(true); + }); + + it("materializes a transactionally appended event only after commit", async () => { + await seedSubscription("commit-rule"); + const db = getDbExec(); + let markInserted!: () => void; + let releaseCommit!: () => void; + const inserted = new Promise((resolve) => { + markInserted = resolve; + }); + const canCommit = new Promise((resolve) => { + releaseCommit = resolve; + }); + + const transaction = db.transaction!(async (tx) => { + await insertWorkflowEvent( + { + id: "commit-event", + topic: "content.item.changed", + subjectType: "content.item", + subjectId: "item-commit", + ownerEmail: "owner@example.com", + actorContext: { kind: "agent", model: "test-model" }, + occurredAt: 300, + }, + { db: tx, now: 300 }, + ); + markInserted(); + await canCommit; + }); + + await inserted; + expect( + await materializeWorkflowExecutions({ + eventId: "commit-event", + now: 301, + }), + ).toBe(0); + releaseCommit(); + await transaction; + + const [firstMaterialization, duplicateMaterialization] = await Promise.all([ + materializeWorkflowExecutions({ eventId: "commit-event", now: 302 }), + materializeWorkflowExecutions({ eventId: "commit-event", now: 302 }), + ]); + expect(firstMaterialization + duplicateMaterialization).toBe(1); + }); +}); diff --git a/packages/core/src/workflow/store.spec.ts b/packages/core/src/workflow/store.spec.ts new file mode 100644 index 0000000000..02120c4e4e --- /dev/null +++ b/packages/core/src/workflow/store.spec.ts @@ -0,0 +1,1310 @@ +import Database from "better-sqlite3"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const state = vi.hoisted(() => ({ + exec: undefined as + | { + execute( + statement: string | { sql: string; args?: unknown[] }, + ): Promise<{ rows: unknown[]; rowsAffected: number }>; + } + | undefined, +})); + +vi.mock("../db/client.js", () => ({ + getDbExec: () => state.exec, + intType: () => "INTEGER", + isPostgres: () => false, + safeJsonParse: (value: unknown, fallback: T): T => { + try { + return JSON.parse(String(value)) as T; + } catch { + return fallback; + } + }, +})); + +import { + __resetWorkflowExecutionHandlers, + processNextWorkflowExecution, + processNextWorkflowWork, + registerScheduledWorkflowHandler, + registerWorkflowExecutionHandler, + startWorkflowWakeProcessor, +} from "./runtime.js"; +import { + __resetWorkflowSchemaForTests, + acknowledgeWorkflowExecution, + cancelWorkflowWork, + claimWorkflowEffectRetry, + claimNextScheduledWork, + claimNextWorkflowExecution, + ensureWorkflowSchema, + finalizeWorkflowEffect, + finalizeWorkflowExecution, + getWorkflowExecution, + getWorkflowEvent, + getWorkflowEffectByIdempotencyKey, + getWorkflowRuntimeControls, + getWorkflowSubscription, + insertWorkflowEvent, + listWorkflowExecutions, + materializeWorkflowExecutions, + recordNotificationDeliveryAttempt, + recordWorkflowEffect, + retryWorkflowExecution, + scheduleWorkflowWork, + setWorkflowRuntimeControl, + upsertWorkflowSubscription, +} from "./store.js"; +import { + __resetVirtualWorkflowSubscriptionProviders, + registerVirtualWorkflowSubscriptionProvider, +} from "./virtual-subscriptions.js"; +import { + __resetWorkflowWakeBus, + emitWorkflowWake, + subscribeWorkflowWake, +} from "./wake.js"; + +let sqlite: Database.Database; + +beforeEach(async () => { + sqlite = new Database(":memory:"); + state.exec = { + async execute(statement) { + const sql = typeof statement === "string" ? statement : statement.sql; + const args = typeof statement === "string" ? [] : (statement.args ?? []); + const prepared = sqlite.prepare(sql); + if (prepared.reader) { + return { rows: prepared.all(...args), rowsAffected: 0 }; + } + const result = prepared.run(...args); + return { rows: [], rowsAffected: result.changes }; + }, + }; + __resetWorkflowSchemaForTests(); + __resetWorkflowWakeBus(); + __resetWorkflowExecutionHandlers(); + __resetVirtualWorkflowSubscriptionProviders(); + await ensureWorkflowSchema(); +}); + +afterEach(() => sqlite.close()); + +async function seedTwoOrderedEvents(): Promise { + await upsertWorkflowSubscription({ + id: "content-ready", + kind: "deterministic", + eventPattern: "content.item.*", + ownerEmail: "owner@example.com", + config: { domain: "content" }, + }); + await insertWorkflowEvent({ + id: "event-a", + topic: "content.item.changed", + subjectType: "content.item", + subjectId: "item-1", + ownerEmail: "owner@example.com", + occurredAt: 100, + }); + await insertWorkflowEvent({ + id: "event-b", + topic: "content.item.changed", + subjectType: "content.item", + subjectId: "item-1", + ownerEmail: "owner@example.com", + occurredAt: 101, + }); +} + +describe("workflow runtime controls", () => { + const context = { + ownerEmail: "owner@example.com", + orgId: "org-1", + domain: "content", + resourceId: "database-1", + } as const; + + async function seedControlledEvent(id = "paused-event") { + await upsertWorkflowSubscription({ + id: "controlled-rule", + kind: "deterministic", + eventPattern: "content.item.changed", + ownerEmail: context.ownerEmail, + orgId: context.orgId, + config: { domain: context.domain, resourceId: context.resourceId }, + }); + await insertWorkflowEvent({ + id, + topic: "content.item.changed", + subjectType: "content.item", + subjectId: "item-1", + ownerEmail: context.ownerEmail, + orgId: context.orgId, + occurredAt: 100, + }); + } + + it("holds evaluator-paused events before materialization and materializes once on resume", async () => { + await setWorkflowRuntimeControl({ + ...context, + scope: "resource", + evaluatorPaused: true, + effectsPaused: false, + now: 90, + }); + await seedControlledEvent(); + expect(await materializeWorkflowExecutions({ now: 200 })).toBe(0); + expect(await listWorkflowExecutions()).toHaveLength(0); + + await setWorkflowRuntimeControl({ + ...context, + scope: "resource", + evaluatorPaused: false, + effectsPaused: false, + now: 201, + }); + expect(await materializeWorkflowExecutions({ now: 202 })).toBe(1); + expect(await materializeWorkflowExecutions({ now: 203 })).toBe(0); + expect(await listWorkflowExecutions()).toHaveLength(1); + }); + + it("rotates a paused backlog so another tenant cannot be starved", async () => { + await setWorkflowRuntimeControl({ + ownerEmail: "paused@example.com", + orgId: null, + domain: "content", + scope: "global", + evaluatorPaused: true, + effectsPaused: false, + now: 90, + }); + await upsertWorkflowSubscription({ + id: "paused-large-rule", + kind: "deterministic", + eventPattern: "paused.item.changed", + ownerEmail: "paused@example.com", + config: { domain: "content" }, + }); + for (let index = 0; index < 101; index += 1) { + await insertWorkflowEvent({ + id: `paused-large-${index}`, + topic: "paused.item.changed", + subjectType: "content.item", + subjectId: `paused-${index}`, + ownerEmail: "paused@example.com", + occurredAt: 100 + index, + }); + } + expect(await materializeWorkflowExecutions({ now: 500 })).toBe(0); + expect(await materializeWorkflowExecutions({ now: 500 })).toBe(0); + + await upsertWorkflowSubscription({ + id: "active-rule", + kind: "deterministic", + eventPattern: "active.item.changed", + ownerEmail: "active@example.com", + config: { domain: "content" }, + }); + await insertWorkflowEvent({ + id: "active-after-paused-prefix", + topic: "active.item.changed", + subjectType: "content.item", + subjectId: "active-item", + ownerEmail: "active@example.com", + occurredAt: 250, + }); + + await materializeWorkflowExecutions({ now: 600 }); + await materializeWorkflowExecutions({ now: 601 }); + expect( + (await listWorkflowExecutions({ limit: 200 })).some( + (execution) => execution.eventId === "active-after-paused-prefix", + ), + ).toBe(true); + }); + + it("keeps effect-paused executions pending and claims them once after resume", async () => { + await seedControlledEvent(); + await setWorkflowRuntimeControl({ + ...context, + scope: "resource", + evaluatorPaused: false, + effectsPaused: true, + }); + await materializeWorkflowExecutions({ now: 200 }); + expect( + await claimNextWorkflowExecution({ workerId: "paused", now: 201 }), + ).toBeNull(); + expect(await listWorkflowExecutions()).toEqual([ + expect.objectContaining({ status: "pending", attempt: 0 }), + ]); + + await setWorkflowRuntimeControl({ + ...context, + scope: "resource", + evaluatorPaused: false, + effectsPaused: false, + }); + const claim = await claimNextWorkflowExecution({ + workerId: "resumed", + now: 202, + }); + expect(claim).toMatchObject({ eventId: "paused-event", attempt: 1 }); + await finalizeWorkflowExecution({ + executionId: claim!.id, + leaseToken: claim!.leaseToken, + fenceVersion: claim!.fenceVersion, + status: "succeeded", + now: 203, + }); + expect( + await claimNextWorkflowExecution({ workerId: "again", now: 204 }), + ).toBeNull(); + expect(await listWorkflowExecutions()).toEqual([ + expect.objectContaining({ status: "succeeded", attempt: 1 }), + ]); + }); + + it("does not revoke an in-flight lease when effects are paused", async () => { + await seedControlledEvent("in-flight-event"); + const claim = await claimNextWorkflowExecution({ + workerId: "leased", + now: 200, + }); + await setWorkflowRuntimeControl({ + ...context, + scope: "resource", + evaluatorPaused: false, + effectsPaused: true, + now: 201, + }); + expect( + await finalizeWorkflowExecution({ + executionId: claim!.id, + leaseToken: claim!.leaseToken, + fenceVersion: claim!.fenceVersion, + status: "succeeded", + now: 202, + }), + ).toBe(true); + expect(await listWorkflowExecutions()).toEqual([ + expect.objectContaining({ status: "succeeded", attempt: 1 }), + ]); + }); + + it("isolates global and resource controls by owner and organization", async () => { + await setWorkflowRuntimeControl({ + ...context, + scope: "resource", + evaluatorPaused: true, + effectsPaused: false, + }); + expect( + (await getWorkflowRuntimeControls(context)).effective.evaluatorPaused, + ).toBe(true); + expect( + ( + await getWorkflowRuntimeControls({ + ...context, + orgId: "org-2", + }) + ).effective.evaluatorPaused, + ).toBe(false); + expect( + ( + await getWorkflowRuntimeControls({ + ...context, + ownerEmail: "other@example.com", + }) + ).effective.evaluatorPaused, + ).toBe(false); + + await setWorkflowRuntimeControl({ + ...context, + scope: "global", + evaluatorPaused: false, + effectsPaused: true, + }); + expect( + ( + await getWorkflowRuntimeControls({ + ...context, + resourceId: "database-2", + }) + ).effective.effectsPaused, + ).toBe(true); + }); + + it("keeps scheduled effect work pending until controls resume", async () => { + await upsertWorkflowSubscription({ + id: "scheduled-controlled-rule", + kind: "deterministic", + eventPattern: "content.item.changed", + ownerEmail: context.ownerEmail, + orgId: context.orgId, + config: { domain: context.domain, resourceId: context.resourceId }, + }); + await scheduleWorkflowWork({ + id: "paused-timer", + workType: "content_hook_timing", + subjectKey: "content.item:item-1", + subscriptionId: "scheduled-controlled-rule", + payload: {}, + dueAt: 100, + now: 90, + }); + await setWorkflowRuntimeControl({ + ...context, + scope: "resource", + evaluatorPaused: false, + effectsPaused: true, + }); + expect( + await claimNextScheduledWork({ workerId: "paused", now: 200 }), + ).toBeNull(); + await setWorkflowRuntimeControl({ + ...context, + scope: "resource", + evaluatorPaused: false, + effectsPaused: false, + }); + expect( + await claimNextScheduledWork({ workerId: "resumed", now: 201 }), + ).toMatchObject({ + id: "paused-timer", + attempt: 1, + }); + }); +}); + +describe("durable workflow claim engine", () => { + it("replaces a pre-portability wake bus during hot reload", () => { + const globalWakeState = globalThis as typeof globalThis & { + [key: symbol]: unknown; + }; + globalWakeState[Symbol.for("@agent-native/core/workflow.wake-bus")] = { + emitter: {}, + }; + const handler = vi.fn(); + const unsubscribe = subscribeWorkflowWake(handler); + + emitWorkflowWake({ + topic: "workflow.event.available", + rowId: "hot-reload-event", + }); + + expect(handler).toHaveBeenCalledOnce(); + unsubscribe(); + }); + + it("produces equivalent execution truth with wake hints disabled or repeated", async () => { + const executedTopics: string[] = []; + registerWorkflowExecutionHandler({ + kind: "deterministic", + domain: "content", + async execute(claim) { + executedTopics.push(claim.event.topic); + return { status: "succeeded" }; + }, + }); + + await upsertWorkflowSubscription({ + id: "bus-off-rule", + kind: "deterministic", + eventPattern: "content.item.bus-off", + ownerEmail: "owner@example.com", + config: { domain: "content" }, + }); + await insertWorkflowEvent({ + id: "bus-off-event", + topic: "content.item.bus-off", + subjectType: "content.item", + subjectId: "bus-off-item", + ownerEmail: "owner@example.com", + }); + await processNextWorkflowExecution({ workerId: "bus-off-worker" }); + + await upsertWorkflowSubscription({ + id: "wake-storm-rule", + kind: "deterministic", + eventPattern: "content.item.wake-storm", + ownerEmail: "owner@example.com", + config: { domain: "content" }, + }); + await insertWorkflowEvent({ + id: "wake-storm-event", + topic: "content.item.wake-storm", + subjectType: "content.item", + subjectId: "wake-storm-item", + ownerEmail: "owner@example.com", + }); + const wakeHandler = async ({ rowId }: { rowId: string }) => { + await materializeWorkflowExecutions({ eventId: rowId }); + }; + const unsubscribe = subscribeWorkflowWake(wakeHandler); + for (let index = 0; index < 8; index += 1) { + emitWorkflowWake({ + topic: "workflow.event.available", + rowId: "wake-storm-event", + }); + } + await vi.waitFor(() => { + const count = sqlite + .prepare( + "SELECT COUNT(*) AS count FROM workflow_executions WHERE subscription_id = ?", + ) + .get("wake-storm-rule") as { count: number }; + expect(count.count).toBe(1); + }); + unsubscribe(); + await processNextWorkflowExecution({ workerId: "wake-storm-worker" }); + + const normalizedExecution = (subscriptionId: string) => + sqlite + .prepare( + `SELECT subscription_version, status, attempt, error_message, + completed_at IS NOT NULL AS completed + FROM workflow_executions WHERE subscription_id = ?`, + ) + .get(subscriptionId); + expect(normalizedExecution("wake-storm-rule")).toEqual( + normalizedExecution("bus-off-rule"), + ); + expect(executedTopics).toEqual([ + "content.item.bus-off", + "content.item.wake-storm", + ]); + }); + + it("coalesces wake hints while retaining the durable safety sweep", async () => { + await upsertWorkflowSubscription({ + id: "wake-rule", + kind: "deterministic", + eventPattern: "content.item.changed", + ownerEmail: "owner@example.com", + config: { domain: "content" }, + }); + let executions = 0; + registerWorkflowExecutionHandler({ + kind: "deterministic", + domain: "content", + async execute() { + executions += 1; + return { status: "succeeded" }; + }, + }); + const stop = startWorkflowWakeProcessor({ + workerId: "wake-worker", + wakeDelayMs: 10, + pollIntervalMs: 60_000, + }); + await insertWorkflowEvent({ + id: "wake-event", + topic: "content.item.changed", + subjectType: "content.item", + subjectId: "wake-item", + ownerEmail: "owner@example.com", + }); + emitWorkflowWake({ + topic: "workflow.event.available", + rowId: "wake-event", + }); + emitWorkflowWake({ + topic: "workflow.event.available", + rowId: "wake-event", + }); + + await vi.waitFor(() => expect(executions).toBe(1)); + stop(); + }); + + it("reads one committed event by its durable id", async () => { + await insertWorkflowEvent({ + id: "event-readable", + topic: "content.item.changed", + subjectType: "content.item", + subjectId: "item-readable", + ownerEmail: "owner@example.com", + payload: { after: "ready" }, + actorContext: { executor: { kind: "agent", model: "example-model" } }, + causalEventId: "event-parent", + occurredAt: 123, + }); + + await expect(getWorkflowEvent("event-readable")).resolves.toMatchObject({ + id: "event-readable", + subjectKey: "content.item:item-readable", + payload: { after: "ready" }, + actorContext: { + executor: { kind: "agent", model: "example-model" }, + }, + causalEventId: "event-parent", + }); + await expect(getWorkflowEvent("missing")).resolves.toBeNull(); + }); + + it("pins executions to the subscription version active when the event committed", async () => { + await upsertWorkflowSubscription( + { + id: "versioned-rule", + kind: "deterministic", + eventPattern: "content.item.changed", + ownerEmail: "owner@example.com", + config: { message: "before" }, + }, + { now: 100 }, + ); + await insertWorkflowEvent( + { + id: "event-before-edit", + topic: "content.item.changed", + subjectType: "content.item", + subjectId: "item-before", + ownerEmail: "owner@example.com", + occurredAt: 150, + }, + { now: 150 }, + ); + await upsertWorkflowSubscription( + { + id: "versioned-rule", + kind: "deterministic", + eventPattern: "content.item.changed", + ownerEmail: "owner@example.com", + config: { message: "after" }, + }, + { now: 200 }, + ); + await insertWorkflowEvent( + { + id: "event-after-edit", + topic: "content.item.changed", + subjectType: "content.item", + subjectId: "item-after", + ownerEmail: "owner@example.com", + occurredAt: 250, + }, + { now: 250 }, + ); + + await materializeWorkflowExecutions({ now: 300 }); + const before = await claimNextWorkflowExecution({ + workerId: "worker", + now: 300, + }); + expect(before).toMatchObject({ + eventId: "event-before-edit", + subscriptionVersion: 1, + subscription: { version: 1, config: { message: "before" } }, + }); + await finalizeWorkflowExecution({ + executionId: before!.id, + leaseToken: before!.leaseToken, + fenceVersion: before!.fenceVersion, + status: "succeeded", + now: 301, + }); + const after = await claimNextWorkflowExecution({ + workerId: "worker", + now: 302, + }); + expect(after).toMatchObject({ + eventId: "event-after-edit", + subscriptionVersion: 2, + subscription: { version: 2, config: { message: "after" } }, + }); + }); + + it("does not create a version for an idempotent identical upsert", async () => { + await upsertWorkflowSubscription( + { + id: "idempotent-rule", + kind: "deterministic", + eventPattern: "content.item.changed", + ownerEmail: "owner@example.com", + config: { nested: { b: 2, a: 1 } }, + }, + { now: 100 }, + ); + const subscription = await upsertWorkflowSubscription( + { + id: "idempotent-rule", + kind: "deterministic", + eventPattern: "content.item.changed", + ownerEmail: "owner@example.com", + config: { nested: { a: 1, b: 2 } }, + }, + { now: 200 }, + ); + const row = sqlite + .prepare( + "SELECT COUNT(*) AS count FROM workflow_subscription_versions WHERE subscription_id = ?", + ) + .get("idempotent-rule") as { count: number }; + expect(row.count).toBe(1); + expect(subscription.version).toBe(1); + expect(subscription.updatedAt).toBe(100); + }); + + it("preserves pre-disable events while later events see the disabled version", async () => { + await upsertWorkflowSubscription( + { + id: "disable-rule", + kind: "deterministic", + eventPattern: "content.item.changed", + ownerEmail: "owner@example.com", + }, + { now: 100 }, + ); + await insertWorkflowEvent( + { + id: "event-before-disable", + topic: "content.item.changed", + subjectType: "content.item", + subjectId: "item-before-disable", + ownerEmail: "owner@example.com", + occurredAt: 150, + }, + { now: 150 }, + ); + await upsertWorkflowSubscription( + { + id: "disable-rule", + kind: "deterministic", + eventPattern: "content.item.changed", + ownerEmail: "owner@example.com", + enabled: false, + }, + { now: 200 }, + ); + await insertWorkflowEvent( + { + id: "event-after-disable", + topic: "content.item.changed", + subjectType: "content.item", + subjectId: "item-after-disable", + ownerEmail: "owner@example.com", + occurredAt: 250, + }, + { now: 250 }, + ); + + await materializeWorkflowExecutions({ now: 300 }); + const executionRows = sqlite + .prepare( + "SELECT event_id, subscription_version FROM workflow_executions ORDER BY event_id", + ) + .all(); + expect(executionRows).toEqual([ + { event_id: "event-before-disable", subscription_version: 1 }, + ]); + const claim = await claimNextWorkflowExecution({ + workerId: "worker", + now: 300, + }); + expect(claim).toMatchObject({ + eventId: "event-before-disable", + subscriptionVersion: 1, + subscription: { enabled: true, version: 1 }, + }); + }); + + it("uses durable sequence rather than wall-clock time for evaluation start", async () => { + await insertWorkflowEvent( + { + id: "same-ms-before", + topic: "content.item.same-ms", + subjectType: "content.item", + subjectId: "before", + ownerEmail: "owner@example.com", + occurredAt: 500, + availableAt: 500, + }, + { now: 500 }, + ); + await upsertWorkflowSubscription( + { + id: "same-ms-rule", + kind: "deterministic", + eventPattern: "content.item.same-ms", + ownerEmail: "owner@example.com", + config: { domain: "content", revision: "first" }, + }, + { now: 500 }, + ); + await insertWorkflowEvent( + { + id: "same-ms-after-first", + topic: "content.item.same-ms", + subjectType: "content.item", + subjectId: "after-first", + ownerEmail: "owner@example.com", + occurredAt: 500, + availableAt: 500, + }, + { now: 500 }, + ); + await upsertWorkflowSubscription( + { + id: "same-ms-rule", + kind: "deterministic", + eventPattern: "content.item.same-ms", + ownerEmail: "owner@example.com", + config: { domain: "content", revision: "second" }, + }, + { now: 500 }, + ); + await insertWorkflowEvent( + { + id: "same-ms-after-second", + topic: "content.item.same-ms", + subjectType: "content.item", + subjectId: "after-second", + ownerEmail: "owner@example.com", + occurredAt: 500, + availableAt: 500, + }, + { now: 500 }, + ); + + await materializeWorkflowExecutions({ now: 500 }); + expect( + sqlite + .prepare( + `SELECT event_id, subscription_version FROM workflow_executions + WHERE subscription_id = ? ORDER BY event_id`, + ) + .all("same-ms-rule"), + ).toEqual([ + { event_id: "same-ms-after-first", subscription_version: 1 }, + { event_id: "same-ms-after-second", subscription_version: 2 }, + ]); + }); + + it("materializes virtual subscriptions only for events after their durable evaluation boundary", async () => { + await insertWorkflowEvent({ + id: "virtual-before-registration", + topic: "content.person.changed", + subjectType: "content.item", + subjectId: "before-registration", + ownerEmail: "owner@example.com", + occurredAt: 500, + }); + let virtualVersion = 1; + let virtualEnabled = true; + registerVirtualWorkflowSubscriptionProvider({ + id: "content.default-person.v1", + evaluationStartSequence: 1, + subscriptionsForEvent(event) { + if (event.topic !== "content.person.changed") return []; + return [ + { + id: "content-default-person:database-1", + version: virtualVersion, + kind: "deterministic", + eventPattern: "content.person.changed", + ownerEmail: event.ownerEmail, + orgId: event.orgId, + enabled: virtualEnabled, + config: { domain: "content", resourceId: "database-1" }, + }, + ]; + }, + }); + await insertWorkflowEvent({ + id: "virtual-after-registration", + topic: "content.person.changed", + subjectType: "content.item", + subjectId: "after-registration", + ownerEmail: "owner@example.com", + occurredAt: 500, + }); + + expect(await materializeWorkflowExecutions({ now: 500 })).toBe(1); + expect(await materializeWorkflowExecutions({ now: 500 })).toBe(0); + expect( + sqlite + .prepare( + `SELECT event_id, subscription_id FROM workflow_executions + ORDER BY event_id`, + ) + .all(), + ).toEqual([ + { + event_id: "virtual-after-registration", + subscription_id: "content-default-person:database-1", + }, + ]); + + virtualVersion = 2; + virtualEnabled = false; + await insertWorkflowEvent({ + id: "virtual-after-policy-change", + topic: "content.person.changed", + subjectType: "content.item", + subjectId: "after-policy-change", + ownerEmail: "owner@example.com", + occurredAt: 501, + }); + expect(await materializeWorkflowExecutions({ now: 501 })).toBe(0); + await expect( + getWorkflowSubscription("content-default-person:database-1"), + ).resolves.toMatchObject({ version: 2, enabled: false }); + }); + + it("deduplicates repeated wakes and enforces per-subject ordering", async () => { + await seedTwoOrderedEvents(); + const wakeHandler = vi.fn(async ({ rowId }: { rowId: string }) => { + await materializeWorkflowExecutions({ eventId: rowId, now: 200 }); + }); + const unsubscribe = subscribeWorkflowWake(wakeHandler); + for (let index = 0; index < 5; index += 1) { + emitWorkflowWake({ topic: "workflow.event.available", rowId: "event-a" }); + } + await vi.waitFor(() => expect(wakeHandler).toHaveBeenCalledTimes(5)); + await materializeWorkflowExecutions({ now: 200 }); + + const executionCount = sqlite + .prepare("SELECT COUNT(*) AS count FROM workflow_executions") + .get() as { count: number }; + expect(executionCount.count).toBe(2); + + const first = await claimNextWorkflowExecution({ + workerId: "worker-a", + now: 200, + }); + expect(first?.eventId).toBe("event-a"); + await expect( + claimNextWorkflowExecution({ workerId: "worker-b", now: 200 }), + ).resolves.toBeNull(); + await finalizeWorkflowExecution({ + executionId: first!.id, + leaseToken: first!.leaseToken, + fenceVersion: first!.fenceVersion, + status: "succeeded", + now: 201, + }); + const second = await claimNextWorkflowExecution({ + workerId: "worker-b", + now: 202, + }); + expect(second?.eventId).toBe("event-b"); + unsubscribe(); + }); + + it("advances materialization beyond the oldest event window", async () => { + await upsertWorkflowSubscription({ + id: "large-log-rule", + kind: "deterministic", + eventPattern: "content.item.changed", + ownerEmail: "owner@example.com", + config: { domain: "content" }, + }); + for (let index = 0; index < 150; index += 1) { + await insertWorkflowEvent({ + id: `large-event-${index}`, + topic: "content.item.changed", + subjectType: "content.item", + subjectId: `item-${index}`, + ownerEmail: "owner@example.com", + occurredAt: 100 + index, + }); + } + + expect(await materializeWorkflowExecutions({ now: 500 })).toBe(100); + expect(await materializeWorkflowExecutions({ now: 500 })).toBe(50); + expect(await materializeWorkflowExecutions({ now: 500 })).toBe(0); + expect(await listWorkflowExecutions({ limit: 200 })).toHaveLength(150); + }); + + it("uses fencing to reject a stale worker after a lease is reclaimed", async () => { + await seedTwoOrderedEvents(); + const first = await claimNextWorkflowExecution({ + workerId: "worker-a", + leaseMs: 1_000, + now: 200, + }); + const replay = await claimNextWorkflowExecution({ + workerId: "worker-b", + leaseMs: 1_000, + now: 1_201, + }); + expect(replay?.id).toBe(first?.id); + expect(replay!.fenceVersion).toBe(first!.fenceVersion + 1); + await expect( + finalizeWorkflowExecution({ + executionId: first!.id, + leaseToken: first!.leaseToken, + fenceVersion: first!.fenceVersion, + status: "succeeded", + }), + ).resolves.toBe(false); + }); + + it("allows only eligible explicit retry and acknowledgement transitions", async () => { + await seedTwoOrderedEvents(); + const unknown = await claimNextWorkflowExecution({ + workerId: "operator-test", + now: 200, + }); + await finalizeWorkflowExecution({ + executionId: unknown!.id, + leaseToken: unknown!.leaseToken, + fenceVersion: unknown!.fenceVersion, + status: "unknown", + now: 201, + }); + await expect( + acknowledgeWorkflowExecution({ executionId: unknown!.id, now: 202 }), + ).resolves.toBe(true); + await expect( + acknowledgeWorkflowExecution({ executionId: unknown!.id, now: 203 }), + ).resolves.toBe(false); + await expect( + retryWorkflowExecution({ executionId: unknown!.id, now: 204 }), + ).resolves.toBe(false); + await expect(getWorkflowExecution(unknown!.id)).resolves.toMatchObject({ + status: "acknowledged", + attempt: 1, + }); + + const failed = await claimNextWorkflowExecution({ + workerId: "operator-test", + now: 205, + }); + await finalizeWorkflowExecution({ + executionId: failed!.id, + leaseToken: failed!.leaseToken, + fenceVersion: failed!.fenceVersion, + status: "failed", + now: 206, + }); + await expect( + retryWorkflowExecution({ executionId: failed!.id, now: 207 }), + ).resolves.toBe(true); + await expect( + retryWorkflowExecution({ executionId: failed!.id, now: 208 }), + ).resolves.toBe(false); + const replay = await claimNextWorkflowExecution({ + workerId: "operator-test", + now: 209, + }); + expect(replay).toMatchObject({ id: failed!.id, attempt: 2 }); + }); + + it("reuses a delivered effect during crash replay", async () => { + await seedTwoOrderedEvents(); + const first = await claimNextWorkflowExecution({ + workerId: "worker-a", + leaseMs: 1_000, + now: 200, + }); + const reserved = await recordWorkflowEffect({ + executionId: first!.id, + kind: "notification", + idempotencyKey: `${first!.eventId}:${first!.subscriptionId}:notification`, + }); + await finalizeWorkflowEffect({ + effectId: reserved.effect.id, + status: "delivered", + result: { notificationId: "notification-1" }, + }); + + const replay = await claimNextWorkflowExecution({ + workerId: "worker-b", + leaseMs: 1_000, + now: 1_201, + }); + const duplicate = await recordWorkflowEffect({ + executionId: replay!.id, + kind: "notification", + idempotencyKey: `${replay!.eventId}:${replay!.subscriptionId}:notification`, + }); + expect(duplicate.created).toBe(false); + expect(duplicate.effect.status).toBe("delivered"); + }); + + it("allows only one worker to claim a known-failed effect retry", async () => { + await seedTwoOrderedEvents(); + const execution = await claimNextWorkflowExecution({ + workerId: "worker-a", + now: 200, + }); + const { effect } = await recordWorkflowEffect({ + executionId: execution!.id, + kind: "webhook", + idempotencyKey: "failed-effect-retry", + }); + await finalizeWorkflowEffect({ + effectId: effect.id, + status: "failed", + errorMessage: "provider rejected the request", + now: 201, + }); + + const claims = await Promise.all([ + claimWorkflowEffectRetry({ effectId: effect.id, now: 202 }), + claimWorkflowEffectRetry({ effectId: effect.id, now: 202 }), + ]); + + expect(claims.sort()).toEqual([false, true]); + await expect( + getWorkflowEffectByIdempotencyKey("failed-effect-retry"), + ).resolves.toMatchObject({ + status: "unknown", + errorMessage: null, + result: null, + }); + }); +}); + +describe("scheduled work and delivery truth", () => { + it("supersedes debounce work in one timer store and can cancel it", async () => { + const first = await scheduleWorkflowWork({ + workType: "debounce", + subjectKey: "content.item:item-1", + dedupeKey: "rule-1:item-1", + dueAt: 1_000, + }); + const second = await scheduleWorkflowWork({ + workType: "debounce", + subjectKey: "content.item:item-1", + dedupeKey: "rule-1:item-1", + dueAt: 2_000, + }); + expect(second).toBe(first); + await expect(cancelWorkflowWork(first)).resolves.toBe(true); + await expect( + claimNextScheduledWork({ workerId: "timer", now: 3_000 }), + ).resolves.toBeNull(); + }); + + it("does not reclaim a failed execution until its retry timer is due", async () => { + await seedTwoOrderedEvents(); + registerWorkflowExecutionHandler({ + kind: "deterministic", + domain: "content", + execute: async () => { + throw new Error("temporary failure"); + }, + }); + + const first = await processNextWorkflowWork({ + workerId: "worker", + now: 200, + retryPolicy: { maxAttempts: 3, baseDelayMs: 100, maxDelayMs: 1_000 }, + }); + expect(first?.kind).toBe("execution"); + expect(await getWorkflowExecution(first!.claim.id)).toMatchObject({ + status: "retrying", + attempt: 1, + }); + await expect( + processNextWorkflowWork({ workerId: "worker", now: 299 }), + ).resolves.toBeNull(); + + const timer = await processNextWorkflowWork({ + workerId: "worker", + now: 300, + }); + expect(timer).toMatchObject({ kind: "scheduled" }); + expect(await getWorkflowExecution(first!.claim.id)).toMatchObject({ + status: "pending", + attempt: 1, + }); + }); + + it("survives a crash between scheduling a retry and releasing the execution lease", async () => { + await seedTwoOrderedEvents(); + const claim = await claimNextWorkflowExecution({ + workerId: "crashing-worker", + leaseMs: 1_000, + now: 200, + }); + await scheduleWorkflowWork({ + workType: "execution_retry", + subjectKey: claim!.event.subjectKey, + eventId: claim!.eventId, + subscriptionId: claim!.subscriptionId, + payload: { executionId: claim!.id, expectedAttempt: claim!.attempt }, + dedupeKey: `execution_retry:${claim!.id}`, + dueAt: 300, + now: 200, + }); + + await processNextWorkflowWork({ workerId: "timer", now: 300 }); + expect( + sqlite + .prepare( + "SELECT status, due_at FROM workflow_scheduled_work WHERE dedupe_key = ?", + ) + .get(`execution_retry:${claim!.id}`), + ).toMatchObject({ status: "pending", due_at: 1_200 }); + await processNextWorkflowWork({ workerId: "timer", now: 1_200 }); + expect(await getWorkflowExecution(claim!.id)).toMatchObject({ + status: "pending", + attempt: 1, + }); + }); + + it("dead-letters exhausted retries and unblocks the next subject event", async () => { + await seedTwoOrderedEvents(); + registerWorkflowExecutionHandler({ + kind: "deterministic", + domain: "content", + execute: async () => { + throw new Error("permanent failure"); + }, + }); + const policy = { maxAttempts: 2, baseDelayMs: 100, maxDelayMs: 1_000 }; + const first = await processNextWorkflowWork({ + workerId: "worker", + now: 200, + retryPolicy: policy, + }); + await processNextWorkflowWork({ workerId: "worker", now: 300 }); + await processNextWorkflowWork({ + workerId: "worker", + now: 300, + retryPolicy: policy, + }); + expect(await getWorkflowExecution(first!.claim.id)).toMatchObject({ + status: "failed", + attempt: 2, + errorMessage: "permanent failure", + }); + + const successor = await claimNextWorkflowExecution({ + workerId: "successor", + now: 301, + }); + expect(successor?.eventId).toBe("event-b"); + }); + + it("dispatches debounce and escalation work through the shared processor", async () => { + const handled: string[] = []; + registerScheduledWorkflowHandler({ + workType: "debounce", + execute: async (claim) => { + handled.push(claim.id); + }, + }); + const id = await scheduleWorkflowWork({ + workType: "debounce", + subjectKey: "content.item:item-1", + dedupeKey: "debounce:item-1", + dueAt: 1_000, + now: 100, + }); + await scheduleWorkflowWork({ + workType: "debounce", + subjectKey: "content.item:item-1", + dedupeKey: "debounce:item-1", + dueAt: 2_000, + now: 200, + }); + await expect( + processNextWorkflowWork({ workerId: "timer", now: 1_500 }), + ).resolves.toBeNull(); + await processNextWorkflowWork({ workerId: "timer", now: 2_000 }); + expect(handled).toEqual([id]); + }); + + it("retries scheduled handlers with backoff before dead-lettering", async () => { + registerScheduledWorkflowHandler({ + workType: "escalation", + execute: async () => { + throw new Error("destination unavailable"); + }, + }); + const id = await scheduleWorkflowWork({ + workType: "escalation", + subjectKey: "content.item:item-1", + dueAt: 100, + now: 0, + }); + const policy = { maxAttempts: 2, baseDelayMs: 50, maxDelayMs: 500 }; + await processNextWorkflowWork({ + workerId: "timer", + now: 100, + retryPolicy: policy, + }); + expect( + sqlite + .prepare( + "SELECT status, due_at FROM workflow_scheduled_work WHERE id = ?", + ) + .get(id), + ).toMatchObject({ status: "pending", due_at: 150 }); + await processNextWorkflowWork({ + workerId: "timer", + now: 150, + retryPolicy: policy, + }); + expect( + sqlite + .prepare( + "SELECT status, error_message FROM workflow_scheduled_work WHERE id = ?", + ) + .get(id), + ).toMatchObject({ + status: "dead_letter", + error_message: "destination unavailable", + }); + }); + + it("records explicit unknown, retrying, and skipped delivery attempts", async () => { + await seedTwoOrderedEvents(); + const claim = await claimNextWorkflowExecution({ + workerId: "worker", + now: 200, + }); + const { effect } = await recordWorkflowEffect({ + executionId: claim!.id, + kind: "notification", + idempotencyKey: "delivery-ledger-test", + }); + await recordNotificationDeliveryAttempt({ + effectId: effect.id, + channel: "slack", + attempt: 1, + status: "unknown", + }); + await recordNotificationDeliveryAttempt({ + effectId: effect.id, + channel: "slack", + attempt: 1, + status: "retrying", + }); + await recordNotificationDeliveryAttempt({ + effectId: effect.id, + channel: "email", + attempt: 1, + status: "skipped", + errorMessage: "No recipient configured", + }); + const row = sqlite + .prepare( + "SELECT status FROM notification_delivery_attempts WHERE effect_id = ? AND channel = 'slack'", + ) + .get(effect.id) as { status: string }; + expect(row.status).toBe("retrying"); + expect( + sqlite + .prepare( + "SELECT status, error_message FROM notification_delivery_attempts WHERE effect_id = ? AND channel = 'email'", + ) + .get(effect.id), + ).toMatchObject({ + status: "skipped", + error_message: "No recipient configured", + }); + expect( + await getWorkflowEffectByIdempotencyKey("delivery-ledger-test"), + ).toMatchObject({ + status: "unknown", + }); + }); +}); diff --git a/packages/core/src/workflow/store.ts b/packages/core/src/workflow/store.ts new file mode 100644 index 0000000000..59ce5bd4d8 --- /dev/null +++ b/packages/core/src/workflow/store.ts @@ -0,0 +1,2021 @@ +import type { DbExec } from "../db/client.js"; +import { getDbExec, intType, isPostgres, safeJsonParse } from "../db/client.js"; +import { + ensureColumnExists, + ensureIndexExists, + ensureTableExists, +} from "../db/ddl-guard.js"; +import type { + ClaimedWorkflowExecution, + WorkflowDeliveryStatus, + WorkflowEffectStatus, + WorkflowEvent, + WorkflowEventInput, + WorkflowExecutionStatus, + WorkflowStoreOptions, + WorkflowSubscription, + WorkflowSubscriptionInput, + WorkflowRuntimeControlContext, + WorkflowRuntimeControls, + WorkflowRuntimeControlTarget, + WorkflowRuntimeControlValue, +} from "./types.js"; +import { + listVirtualWorkflowSubscriptionProviders, + type VirtualWorkflowSubscriptionSnapshot, +} from "./virtual-subscriptions.js"; +import { emitWorkflowWake } from "./wake.js"; + +let schemaPromise: Promise | undefined; + +function randomUUID(): string { + return globalThis.crypto.randomUUID(); +} + +const TABLES: Array<{ name: string; sql: () => string }> = [ + { + name: "workflow_sequence_counters", + sql: () => `CREATE TABLE IF NOT EXISTS workflow_sequence_counters ( + name TEXT PRIMARY KEY, value ${intType()} NOT NULL + )`, + }, + { + name: "workflow_virtual_provider_state", + sql: () => `CREATE TABLE IF NOT EXISTS workflow_virtual_provider_state ( + provider_id TEXT PRIMARY KEY, + evaluation_start_sequence ${intType()} NOT NULL, + created_at ${intType()} NOT NULL + )`, + }, + { + name: "workflow_events", + sql: () => `CREATE TABLE IF NOT EXISTS workflow_events ( + id TEXT PRIMARY KEY, event_sequence ${intType()} NOT NULL, + topic TEXT NOT NULL, subject_type TEXT NOT NULL, + subject_id TEXT NOT NULL, subject_key TEXT NOT NULL, + owner_email TEXT NOT NULL, org_id TEXT, payload TEXT NOT NULL, + actor_context TEXT NOT NULL, causal_event_id TEXT, + occurred_at ${intType()} NOT NULL, available_at ${intType()} NOT NULL, + created_at ${intType()} NOT NULL, materialized_at ${intType()} + )`, + }, + { + name: "workflow_subscriptions", + sql: () => `CREATE TABLE IF NOT EXISTS workflow_subscriptions ( + id TEXT PRIMARY KEY, kind TEXT NOT NULL, event_pattern TEXT NOT NULL, + owner_email TEXT NOT NULL, org_id TEXT, config TEXT NOT NULL, + enabled ${isPostgres() ? "BOOLEAN" : "INTEGER"} NOT NULL, created_at ${intType()} NOT NULL, + updated_at ${intType()} NOT NULL + )`, + }, + { + name: "workflow_subscription_versions", + sql: () => `CREATE TABLE IF NOT EXISTS workflow_subscription_versions ( + id TEXT PRIMARY KEY, subscription_id TEXT NOT NULL, + version ${intType()} NOT NULL, kind TEXT NOT NULL, + event_pattern TEXT NOT NULL, owner_email TEXT NOT NULL, org_id TEXT, + config TEXT NOT NULL, + enabled ${isPostgres() ? "BOOLEAN" : "INTEGER"} NOT NULL, + active_after_sequence ${intType()} NOT NULL, + active_at ${intType()} NOT NULL, created_at ${intType()} NOT NULL + )`, + }, + { + name: "workflow_materialization_backlog", + sql: () => `CREATE TABLE IF NOT EXISTS workflow_materialization_backlog ( + id TEXT PRIMARY KEY, event_id TEXT NOT NULL, subscription_id TEXT NOT NULL, + subscription_version ${intType()} NOT NULL, subject_key TEXT NOT NULL, + created_at ${intType()} NOT NULL + )`, + }, + { + name: "workflow_executions", + sql: () => `CREATE TABLE IF NOT EXISTS workflow_executions ( + id TEXT PRIMARY KEY, event_id TEXT NOT NULL, subscription_id TEXT NOT NULL, + subscription_version ${intType()}, + subject_key TEXT NOT NULL, status TEXT NOT NULL, + attempt ${intType()} NOT NULL DEFAULT 0, lease_token TEXT, + lease_expires_at ${intType()}, fence_version ${intType()} NOT NULL DEFAULT 0, + error_message TEXT, created_at ${intType()} NOT NULL, + updated_at ${intType()} NOT NULL, completed_at ${intType()} + )`, + }, + { + name: "workflow_scheduled_work", + sql: () => `CREATE TABLE IF NOT EXISTS workflow_scheduled_work ( + id TEXT PRIMARY KEY, work_type TEXT NOT NULL, subject_key TEXT NOT NULL, + event_id TEXT, subscription_id TEXT, payload TEXT NOT NULL, + dedupe_key TEXT, due_at ${intType()} NOT NULL, status TEXT NOT NULL, + attempt ${intType()} NOT NULL DEFAULT 0, lease_token TEXT, + lease_expires_at ${intType()}, fence_version ${intType()} NOT NULL DEFAULT 0, + error_message TEXT, completed_at ${intType()}, + created_at ${intType()} NOT NULL, updated_at ${intType()} NOT NULL + )`, + }, + { + name: "workflow_effects", + sql: () => `CREATE TABLE IF NOT EXISTS workflow_effects ( + id TEXT PRIMARY KEY, execution_id TEXT NOT NULL, kind TEXT NOT NULL, + idempotency_key TEXT NOT NULL, status TEXT NOT NULL, result TEXT, + error_message TEXT, created_at ${intType()} NOT NULL, + updated_at ${intType()} NOT NULL + )`, + }, + { + name: "workflow_runtime_controls", + sql: () => `CREATE TABLE IF NOT EXISTS workflow_runtime_controls ( + id TEXT PRIMARY KEY, owner_email TEXT NOT NULL, org_id TEXT NOT NULL, + domain TEXT NOT NULL, scope TEXT NOT NULL, scope_id TEXT NOT NULL, + evaluator_paused ${isPostgres() ? "BOOLEAN" : "INTEGER"} NOT NULL, + effects_paused ${isPostgres() ? "BOOLEAN" : "INTEGER"} NOT NULL, + created_at ${intType()} NOT NULL, updated_at ${intType()} NOT NULL + )`, + }, + { + name: "notification_delivery_attempts", + sql: () => `CREATE TABLE IF NOT EXISTS notification_delivery_attempts ( + id TEXT PRIMARY KEY, effect_id TEXT NOT NULL, notification_id TEXT, + channel TEXT NOT NULL, attempt ${intType()} NOT NULL, + status TEXT NOT NULL, error_message TEXT, + created_at ${intType()} NOT NULL, updated_at ${intType()} NOT NULL + )`, + }, +]; + +const INDEXES = [ + [ + "workflow_events_materialization_idx", + "CREATE INDEX IF NOT EXISTS workflow_events_materialization_idx ON workflow_events (materialized_at, available_at, event_sequence)", + ], + [ + "workflow_materialization_backlog_uidx", + "CREATE UNIQUE INDEX IF NOT EXISTS workflow_materialization_backlog_uidx ON workflow_materialization_backlog (event_id, subscription_id)", + ], + [ + "workflow_materialization_backlog_order_idx", + "CREATE INDEX IF NOT EXISTS workflow_materialization_backlog_order_idx ON workflow_materialization_backlog (created_at, id)", + ], + [ + "workflow_events_available_idx", + "CREATE INDEX IF NOT EXISTS workflow_events_available_idx ON workflow_events (available_at, event_sequence)", + ], + [ + "workflow_events_sequence_uidx", + "CREATE UNIQUE INDEX IF NOT EXISTS workflow_events_sequence_uidx ON workflow_events (event_sequence)", + ], + [ + "workflow_events_subject_idx", + "CREATE INDEX IF NOT EXISTS workflow_events_subject_idx ON workflow_events (subject_key, event_sequence)", + ], + [ + "workflow_subscriptions_match_idx", + "CREATE INDEX IF NOT EXISTS workflow_subscriptions_match_idx ON workflow_subscriptions (enabled, event_pattern)", + ], + [ + "workflow_subscription_versions_uidx", + "CREATE UNIQUE INDEX IF NOT EXISTS workflow_subscription_versions_uidx ON workflow_subscription_versions (subscription_id, version)", + ], + [ + "workflow_subscription_versions_active_idx", + "CREATE INDEX IF NOT EXISTS workflow_subscription_versions_active_idx ON workflow_subscription_versions (subscription_id, active_after_sequence, version)", + ], + [ + "workflow_executions_event_subscription_uidx", + "CREATE UNIQUE INDEX IF NOT EXISTS workflow_executions_event_subscription_uidx ON workflow_executions (event_id, subscription_id)", + ], + [ + "workflow_executions_claim_idx", + "CREATE INDEX IF NOT EXISTS workflow_executions_claim_idx ON workflow_executions (status, lease_expires_at, created_at)", + ], + [ + "workflow_executions_subject_idx", + "CREATE INDEX IF NOT EXISTS workflow_executions_subject_idx ON workflow_executions (subject_key, status, created_at)", + ], + [ + "workflow_scheduled_work_due_idx", + "CREATE INDEX IF NOT EXISTS workflow_scheduled_work_due_idx ON workflow_scheduled_work (status, due_at)", + ], + [ + "workflow_scheduled_work_dedupe_uidx", + "CREATE UNIQUE INDEX IF NOT EXISTS workflow_scheduled_work_dedupe_uidx ON workflow_scheduled_work (dedupe_key)", + ], + [ + "workflow_effects_idempotency_uidx", + "CREATE UNIQUE INDEX IF NOT EXISTS workflow_effects_idempotency_uidx ON workflow_effects (idempotency_key)", + ], + [ + "workflow_runtime_controls_scope_uidx", + "CREATE UNIQUE INDEX IF NOT EXISTS workflow_runtime_controls_scope_uidx ON workflow_runtime_controls (owner_email, org_id, domain, scope, scope_id)", + ], + [ + "workflow_runtime_controls_lookup_idx", + "CREATE INDEX IF NOT EXISTS workflow_runtime_controls_lookup_idx ON workflow_runtime_controls (owner_email, org_id, domain)", + ], + [ + "notification_delivery_effect_idx", + "CREATE INDEX IF NOT EXISTS notification_delivery_effect_idx ON notification_delivery_attempts (effect_id, channel, attempt)", + ], + [ + "notification_delivery_attempt_uidx", + "CREATE UNIQUE INDEX IF NOT EXISTS notification_delivery_attempt_uidx ON notification_delivery_attempts (effect_id, channel, attempt)", + ], +] as const; + +const COLUMNS = [ + { + table: "workflow_events", + column: "materialized_at", + sql: () => + `ALTER TABLE workflow_events ADD COLUMN${isPostgres() ? " IF NOT EXISTS" : ""} materialized_at ${intType()}`, + }, + { + table: "workflow_events", + column: "event_sequence", + sql: () => + `ALTER TABLE workflow_events ADD COLUMN${isPostgres() ? " IF NOT EXISTS" : ""} event_sequence ${intType()}`, + }, + { + table: "workflow_subscription_versions", + column: "active_after_sequence", + sql: () => + `ALTER TABLE workflow_subscription_versions ADD COLUMN${isPostgres() ? " IF NOT EXISTS" : ""} active_after_sequence ${intType()}`, + }, + { + table: "workflow_executions", + column: "subscription_version", + sql: () => + `ALTER TABLE workflow_executions ADD COLUMN${isPostgres() ? " IF NOT EXISTS" : ""} subscription_version ${intType()}`, + }, +] as const; + +/** Install the additive, provider-portable workflow schema. */ +export async function ensureWorkflowSchema( + options: { db?: DbExec } = {}, +): Promise { + if (options.db) { + for (const definition of TABLES) await options.db.execute(definition.sql()); + await ensureWorkflowColumns(options.db); + await backfillWorkflowSequences(options.db); + for (const [, sql] of INDEXES) await options.db.execute(sql); + await backfillWorkflowSubscriptionVersions(options.db); + return; + } + if (!schemaPromise) { + schemaPromise = (async () => { + const db = getDbExec(); + for (const definition of TABLES) { + if (isPostgres()) { + await ensureTableExists(definition.name, definition.sql()); + } else { + await db.execute(definition.sql()); + } + } + await ensureWorkflowColumns(db); + await backfillWorkflowSequences(db); + for (const [name, sql] of INDEXES) { + if (isPostgres()) await ensureIndexExists(name, sql); + else await db.execute(sql); + } + await backfillWorkflowSubscriptionVersions(db); + })().catch((error) => { + schemaPromise = undefined; + throw error; + }); + } + await schemaPromise; +} + +async function ensureWorkflowColumns(db: DbExec): Promise { + for (const definition of COLUMNS) { + const sql = definition.sql(); + if (isPostgres()) { + await ensureColumnExists(definition.table, definition.column, sql, { + injectedClient: db, + }); + continue; + } + try { + await db.execute(sql); + } catch (error) { + if (!/duplicate column/i.test(String(error))) throw error; + } + } +} + +async function currentWorkflowEventSequence(db: DbExec): Promise { + const { rows } = await db.execute({ + sql: "SELECT value FROM workflow_sequence_counters WHERE name = ? LIMIT 1", + args: ["events"], + }); + return Number(rows[0]?.value ?? 0); +} + +/** Allocate commit order inside the same transaction as the domain mutation. */ +export async function allocateWorkflowEventSequence( + db: DbExec, +): Promise { + await db.execute({ + sql: `INSERT INTO workflow_sequence_counters (name, value) VALUES (?, 0) + ON CONFLICT (name) DO NOTHING`, + args: ["events"], + }); + const { rows } = await db.execute({ + sql: `UPDATE workflow_sequence_counters SET value = value + 1 + WHERE name = ? RETURNING value`, + args: ["events"], + }); + const sequence = Number(rows[0]?.value); + if (!Number.isSafeInteger(sequence) || sequence < 1) { + throw new Error("Workflow event sequence allocation failed"); + } + return sequence; +} + +export async function ensureVirtualWorkflowProviderEvaluationStart( + providerId: string, + options: { now?: number } = {}, +): Promise { + await ensureWorkflowSchema(); + const id = providerId.trim(); + if (!id) throw new Error("Virtual workflow provider id is required"); + const db = getDbExec(); + const start = await currentWorkflowEventSequence(db); + await db.execute({ + sql: `INSERT INTO workflow_virtual_provider_state + (provider_id, evaluation_start_sequence, created_at) + VALUES (?, ?, ?) ON CONFLICT (provider_id) DO NOTHING`, + args: [id, start, options.now ?? Date.now()], + }); + const { rows } = await db.execute({ + sql: `SELECT evaluation_start_sequence FROM workflow_virtual_provider_state + WHERE provider_id = ? LIMIT 1`, + args: [id], + }); + const persisted = Number(rows[0]?.evaluation_start_sequence); + if (!Number.isSafeInteger(persisted) || persisted < 0) { + throw new Error(`Virtual workflow provider "${id}" has invalid state`); + } + return persisted; +} + +async function backfillWorkflowSequences(db: DbExec): Promise { + const { rows: existing } = await db.execute( + `SELECT COALESCE(MAX(event_sequence), 0) AS sequence + FROM workflow_events WHERE event_sequence IS NOT NULL`, + ); + let sequence = Number(existing[0]?.sequence ?? 0); + const { rows: events } = await db.execute( + `SELECT id FROM workflow_events WHERE event_sequence IS NULL + ORDER BY created_at ASC, id ASC`, + ); + for (const event of events) { + sequence += 1; + await db.execute({ + sql: "UPDATE workflow_events SET event_sequence = ? WHERE id = ?", + args: [sequence, event.id], + }); + } + const current = await currentWorkflowEventSequence(db); + const counter = Math.max(current, sequence); + await db.execute({ + sql: `INSERT INTO workflow_sequence_counters (name, value) VALUES (?, ?) + ON CONFLICT (name) DO UPDATE SET value = excluded.value`, + args: ["events", counter], + }); + + const { rows: versions } = await db.execute( + `SELECT id, active_at FROM workflow_subscription_versions + WHERE active_after_sequence IS NULL ORDER BY active_at ASC, version ASC`, + ); + for (const version of versions) { + const { rows: prior } = await db.execute({ + sql: `SELECT COALESCE(MAX(event_sequence), 0) AS sequence + FROM workflow_events WHERE created_at < ?`, + args: [version.active_at], + }); + await db.execute({ + sql: `UPDATE workflow_subscription_versions + SET active_after_sequence = ? WHERE id = ?`, + args: [Number(prior[0]?.sequence ?? 0), version.id], + }); + } +} + +async function backfillWorkflowSubscriptionVersions(db: DbExec): Promise { + const { rows } = await db.execute(`SELECT s.id, s.kind, s.event_pattern, + s.owner_email, s.org_id, s.config, s.enabled, s.created_at + FROM workflow_subscriptions s WHERE NOT EXISTS ( + SELECT 1 FROM workflow_subscription_versions v + WHERE v.subscription_id = s.id + )`); + for (const row of rows) { + await db.execute({ + sql: `INSERT INTO workflow_subscription_versions + (id, subscription_id, version, kind, event_pattern, owner_email, + org_id, config, enabled, active_after_sequence, active_at, created_at) + VALUES (?, ?, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT (subscription_id, version) DO NOTHING`, + args: [ + randomUUID(), + row.id, + row.kind, + row.event_pattern, + row.owner_email, + row.org_id ?? null, + row.config, + row.enabled, + await currentWorkflowEventSequence(db), + row.created_at, + row.created_at, + ], + }); + } + const { rows: executions } = await db.execute(`SELECT x.id, x.subscription_id, + e.event_sequence FROM workflow_executions x + JOIN workflow_events e ON e.id = x.event_id + WHERE x.subscription_version IS NULL`); + for (const execution of executions) { + const version = await db.execute({ + sql: `SELECT version FROM workflow_subscription_versions + WHERE subscription_id = ? AND active_after_sequence < ? + ORDER BY active_after_sequence DESC, version DESC LIMIT 1`, + args: [execution.subscription_id, execution.event_sequence], + }); + if (version.rows[0]?.version != null) { + await db.execute({ + sql: `UPDATE workflow_executions SET subscription_version = ? + WHERE id = ? AND subscription_version IS NULL`, + args: [version.rows[0].version, execution.id], + }); + } + } +} + +export const workflowSchemaMigrations = { + tables: TABLES, + columns: COLUMNS, + indexes: INDEXES, +}; + +function json(value: unknown): string { + return JSON.stringify(value ?? {}); +} + +function canonicalJson(value: unknown): string { + if (Array.isArray(value)) { + return `[${value.map((entry) => canonicalJson(entry)).join(",")}]`; + } + if (value && typeof value === "object") { + const record = value as Record; + return `{${Object.keys(record) + .sort() + .map((key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}`) + .join(",")}}`; + } + return JSON.stringify(value ?? null); +} + +function booleanValue(value: boolean): boolean | number { + return isPostgres() ? value : value ? 1 : 0; +} + +const ACTIVE_WORKFLOW_CONTROL: WorkflowRuntimeControlValue = { + evaluatorPaused: false, + effectsPaused: false, +}; + +function normalizedOrgId(orgId: string | null | undefined) { + return orgId ?? ""; +} + +function workflowControlScopeId(target: WorkflowRuntimeControlTarget) { + if (target.scope === "global") return "*"; + const resourceId = target.resourceId?.trim(); + if (!resourceId) { + throw new Error("A resource-scoped workflow control requires resourceId."); + } + return resourceId; +} + +function runtimeContextFromConfig(input: { + ownerEmail: string; + orgId?: string | null; + config: unknown; +}): WorkflowRuntimeControlContext | null { + const config = + input.config && typeof input.config === "object" + ? (input.config as Record) + : safeJsonParse>(String(input.config ?? ""), {}); + const domain = typeof config.domain === "string" ? config.domain.trim() : ""; + if (!domain) return null; + return { + ownerEmail: input.ownerEmail, + orgId: input.orgId, + domain, + resourceId: + typeof config.resourceId === "string" && config.resourceId.trim() + ? config.resourceId + : null, + }; +} + +export async function setWorkflowRuntimeControl( + target: WorkflowRuntimeControlTarget & + WorkflowRuntimeControlValue & { + now?: number; + }, +): Promise { + await ensureWorkflowSchema(); + const ownerEmail = target.ownerEmail.trim(); + const domain = target.domain.trim(); + if (!ownerEmail || !domain) { + throw new Error("Workflow controls require ownerEmail and domain."); + } + const orgId = normalizedOrgId(target.orgId); + const scopeId = workflowControlScopeId(target); + const now = target.now ?? Date.now(); + await getDbExec().execute({ + sql: `INSERT INTO workflow_runtime_controls + (id, owner_email, org_id, domain, scope, scope_id, evaluator_paused, + effects_paused, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT (owner_email, org_id, domain, scope, scope_id) DO UPDATE SET + evaluator_paused = excluded.evaluator_paused, + effects_paused = excluded.effects_paused, + updated_at = excluded.updated_at`, + args: [ + randomUUID(), + ownerEmail, + orgId, + domain, + target.scope, + scopeId, + booleanValue(target.evaluatorPaused), + booleanValue(target.effectsPaused), + now, + now, + ], + }); + return getWorkflowRuntimeControls({ + ownerEmail, + orgId, + domain, + resourceId: target.resourceId, + }); +} + +export async function getWorkflowRuntimeControls( + context: WorkflowRuntimeControlContext, +): Promise { + await ensureWorkflowSchema(); + const resourceId = context.resourceId?.trim() ?? ""; + const { rows } = await getDbExec().execute({ + sql: `SELECT scope, scope_id, evaluator_paused, effects_paused + FROM workflow_runtime_controls + WHERE owner_email = ? AND org_id = ? AND domain = ? + AND ((scope = 'global' AND scope_id = '*') + OR (scope = 'resource' AND scope_id = ?))`, + args: [ + context.ownerEmail.trim(), + normalizedOrgId(context.orgId), + context.domain.trim(), + resourceId, + ], + }); + const value = (scope: "global" | "resource") => { + const row = rows.find((candidate) => candidate.scope === scope); + return row + ? { + evaluatorPaused: Boolean(row.evaluator_paused), + effectsPaused: Boolean(row.effects_paused), + } + : ACTIVE_WORKFLOW_CONTROL; + }; + const global = value("global"); + const resource = resourceId ? value("resource") : ACTIVE_WORKFLOW_CONTROL; + return { + global, + resource, + effective: { + evaluatorPaused: global.evaluatorPaused || resource.evaluatorPaused, + effectsPaused: global.effectsPaused || resource.effectsPaused, + }, + }; +} + +async function workflowControlForSubscription(input: { + ownerEmail: string; + orgId?: string | null; + config: unknown; +}) { + const context = runtimeContextFromConfig(input); + return context ? getWorkflowRuntimeControls(context) : null; +} + +function subjectKey(type: string, id: string): string { + if (!type.trim() || !id.trim()) + throw new Error("Workflow subject is required"); + return `${type}:${id}`; +} + +/** Values accepted by `tx.insert(workflowEvents).values(...)`. */ +export function createWorkflowEventValues( + input: WorkflowEventInput & { eventSequence: number }, + committedAt = Date.now(), +) { + if (!input.topic.trim()) throw new Error("Workflow event topic is required"); + if (!input.ownerEmail.trim()) + throw new Error("Workflow event owner is required"); + const now = input.occurredAt ?? Date.now(); + return { + id: input.id ?? randomUUID(), + eventSequence: input.eventSequence, + topic: input.topic, + subjectType: input.subjectType, + subjectId: input.subjectId, + subjectKey: subjectKey(input.subjectType, input.subjectId), + ownerEmail: input.ownerEmail, + orgId: input.orgId ?? null, + payload: json(input.payload), + actorContext: json(input.actorContext), + causalEventId: input.causalEventId ?? null, + occurredAt: now, + availableAt: input.availableAt ?? now, + createdAt: committedAt, + }; +} + +/** + * Append a committed event. Pass an existing transaction executor to make the + * domain write and event append atomic. Call `ensureWorkflowSchema()` before + * opening that transaction. + */ +export async function insertWorkflowEvent( + input: WorkflowEventInput, + options: WorkflowStoreOptions = {}, +): Promise { + if (!options.db) await ensureWorkflowSchema(); + const db = options.db ?? getDbExec(); + const write = async (tx: DbExec) => { + const eventSequence = + input.eventSequence ?? (await allocateWorkflowEventSequence(tx)); + const values = createWorkflowEventValues( + { ...input, eventSequence }, + options.now ?? Date.now(), + ); + await tx.execute({ + sql: `INSERT INTO workflow_events + (id, event_sequence, topic, subject_type, subject_id, subject_key, + owner_email, org_id, payload, actor_context, causal_event_id, + occurred_at, available_at, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + args: [ + values.id, + values.eventSequence, + values.topic, + values.subjectType, + values.subjectId, + values.subjectKey, + values.ownerEmail, + values.orgId, + values.payload, + values.actorContext, + values.causalEventId, + values.occurredAt, + values.availableAt, + values.createdAt, + ], + }); + return values; + }; + const values = options.db + ? await write(db) + : db.transaction + ? await db.transaction(write) + : await write(db); + if (!options.db) { + emitWorkflowWake({ topic: "workflow.event.available", rowId: values.id }); + } + return eventFromRow({ + ...values, + event_sequence: values.eventSequence, + subject_type: values.subjectType, + subject_id: values.subjectId, + subject_key: values.subjectKey, + owner_email: values.ownerEmail, + org_id: values.orgId, + actor_context: values.actorContext, + causal_event_id: values.causalEventId, + occurred_at: values.occurredAt, + available_at: values.availableAt, + created_at: values.createdAt, + }); +} + +const SUBSCRIPTION_WRITE_LOCK = Symbol.for( + "@agent-native/core/workflow.subscription-write-lock", +); + +async function withWorkflowSubscriptionWriteLock( + task: () => Promise, +): Promise { + const global = globalThis as typeof globalThis & { + [SUBSCRIPTION_WRITE_LOCK]?: Promise; + }; + const previous = global[SUBSCRIPTION_WRITE_LOCK] ?? Promise.resolve(); + let release!: () => void; + const current = new Promise((resolve) => { + release = resolve; + }); + const chained = previous.then(() => current); + global[SUBSCRIPTION_WRITE_LOCK] = chained; + await previous; + try { + return await task(); + } finally { + release(); + if (global[SUBSCRIPTION_WRITE_LOCK] === chained) { + delete global[SUBSCRIPTION_WRITE_LOCK]; + } + } +} + +export async function upsertWorkflowSubscription( + input: WorkflowSubscriptionInput, + options: { now?: number } = {}, +): Promise { + return withWorkflowSubscriptionWriteLock(() => + upsertWorkflowSubscriptionUnlocked(input, options), + ); +} + +async function upsertWorkflowSubscriptionUnlocked( + input: WorkflowSubscriptionInput, + options: { now?: number }, +): Promise { + await ensureWorkflowSchema(); + if ( + !input.id.trim() || + !input.eventPattern.trim() || + !input.ownerEmail.trim() + ) { + throw new Error( + "Workflow subscription id, pattern, and owner are required", + ); + } + const db = getDbExec(); + const now = options.now ?? Date.now(); + const config = canonicalJson(input.config ?? {}); + const enabled = input.enabled !== false; + const existing = await db.execute({ + sql: `SELECT id, kind, event_pattern, owner_email, org_id, config, enabled, + created_at, updated_at FROM workflow_subscriptions WHERE id = ? LIMIT 1`, + args: [input.id], + }); + if (existing.rows.length) { + const row = existing.rows[0]; + const unchanged = + String(row.kind) === input.kind && + String(row.event_pattern) === input.eventPattern && + String(row.owner_email) === input.ownerEmail && + (row.org_id == null ? null : String(row.org_id)) === + (input.orgId ?? null) && + canonicalJson(safeJsonParse(String(row.config ?? "{}"), {})) === config && + (row.enabled === true || Number(row.enabled) === 1) === enabled; + if (unchanged) return (await getWorkflowSubscription(input.id))!; + + const latest = await db.execute({ + sql: `SELECT version FROM workflow_subscription_versions + WHERE subscription_id = ? ORDER BY version DESC LIMIT 1`, + args: [input.id], + }); + const version = Number(latest.rows[0]?.version ?? 0) + 1; + const write = async (tx: DbExec) => { + const activeAfterSequence = await currentWorkflowEventSequence(tx); + await tx.execute({ + sql: `INSERT INTO workflow_subscription_versions + (id, subscription_id, version, kind, event_pattern, owner_email, + org_id, config, enabled, active_after_sequence, active_at, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + args: [ + randomUUID(), + input.id, + version, + input.kind, + input.eventPattern, + input.ownerEmail, + input.orgId ?? null, + config, + booleanValue(enabled), + activeAfterSequence, + now, + now, + ], + }); + await tx.execute({ + sql: `UPDATE workflow_subscriptions SET kind = ?, event_pattern = ?, + owner_email = ?, org_id = ?, config = ?, enabled = ?, updated_at = ? + WHERE id = ?`, + args: [ + input.kind, + input.eventPattern, + input.ownerEmail, + input.orgId ?? null, + config, + booleanValue(enabled), + now, + input.id, + ], + }); + }; + if (db.transaction) await db.transaction(write); + else await write(db); + } else { + const write = async (tx: DbExec) => { + const activeAfterSequence = await currentWorkflowEventSequence(tx); + await tx.execute({ + sql: `INSERT INTO workflow_subscriptions + (id, kind, event_pattern, owner_email, org_id, config, enabled, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + args: [ + input.id, + input.kind, + input.eventPattern, + input.ownerEmail, + input.orgId ?? null, + config, + booleanValue(enabled), + now, + now, + ], + }); + await tx.execute({ + sql: `INSERT INTO workflow_subscription_versions + (id, subscription_id, version, kind, event_pattern, owner_email, + org_id, config, enabled, active_after_sequence, active_at, created_at) + VALUES (?, ?, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + args: [ + randomUUID(), + input.id, + input.kind, + input.eventPattern, + input.ownerEmail, + input.orgId ?? null, + config, + booleanValue(enabled), + activeAfterSequence, + now, + now, + ], + }); + }; + if (db.transaction) await db.transaction(write); + else await write(db); + } + return (await getWorkflowSubscription(input.id))!; +} + +export async function getWorkflowSubscription( + id: string, +): Promise { + await ensureWorkflowSchema(); + const { rows } = await getDbExec().execute({ + sql: `SELECT s.id, s.kind, s.event_pattern, s.owner_email, s.org_id, + s.config, s.enabled, s.created_at, s.updated_at, + COALESCE(MAX(v.version), 1) AS version + FROM workflow_subscriptions s LEFT JOIN workflow_subscription_versions v + ON v.subscription_id = s.id WHERE s.id = ? + GROUP BY s.id, s.kind, s.event_pattern, s.owner_email, s.org_id, + s.config, s.enabled, s.created_at, s.updated_at LIMIT 1`, + args: [id], + }); + return rows[0] ? subscriptionFromRow(rows[0]) : null; +} + +export async function listWorkflowSubscriptions( + options: { + kind?: WorkflowSubscription["kind"]; + ownerEmail?: string; + enabled?: boolean; + limit?: number; + } = {}, +): Promise { + await ensureWorkflowSchema(); + const clauses: string[] = []; + const args: unknown[] = []; + if (options.kind) { + clauses.push("s.kind = ?"); + args.push(options.kind); + } + if (options.ownerEmail) { + clauses.push("s.owner_email = ?"); + args.push(options.ownerEmail); + } + if (options.enabled != null) { + clauses.push("s.enabled = ?"); + args.push(booleanValue(options.enabled)); + } + args.push(Math.min(Math.max(options.limit ?? 100, 1), 500)); + const { rows } = await getDbExec().execute({ + sql: `SELECT s.id, s.kind, s.event_pattern, s.owner_email, s.org_id, + s.config, s.enabled, s.created_at, s.updated_at, + COALESCE(MAX(v.version), 1) AS version + FROM workflow_subscriptions s LEFT JOIN workflow_subscription_versions v + ON v.subscription_id = s.id + ${clauses.length ? `WHERE ${clauses.join(" AND ")}` : ""} + GROUP BY s.id, s.kind, s.event_pattern, s.owner_email, s.org_id, + s.config, s.enabled, s.created_at, s.updated_at + ORDER BY s.updated_at DESC LIMIT ?`, + args, + }); + return rows.map(subscriptionFromRow); +} + +export async function listWorkflowEvents( + options: { + topic?: string; + subjectKey?: string; + limit?: number; + } = {}, +): Promise { + await ensureWorkflowSchema(); + const clauses: string[] = []; + const args: unknown[] = []; + if (options.topic) { + clauses.push("topic = ?"); + args.push(options.topic); + } + if (options.subjectKey) { + clauses.push("subject_key = ?"); + args.push(options.subjectKey); + } + args.push(Math.min(Math.max(options.limit ?? 100, 1), 500)); + const { rows } = await getDbExec().execute({ + sql: `SELECT id, event_sequence, topic, subject_type, subject_id, subject_key, owner_email, + org_id, payload, actor_context, causal_event_id, occurred_at, available_at, + created_at FROM workflow_events ${clauses.length ? `WHERE ${clauses.join(" AND ")}` : ""} + ORDER BY event_sequence ASC LIMIT ?`, + args, + }); + return rows.map(eventFromRow); +} + +export async function getWorkflowEvent( + id: string, +): Promise { + await ensureWorkflowSchema(); + const { rows } = await getDbExec().execute({ + sql: `SELECT id, event_sequence, topic, subject_type, subject_id, subject_key, owner_email, + org_id, payload, actor_context, causal_event_id, occurred_at, available_at, + created_at FROM workflow_events WHERE id = ? LIMIT 1`, + args: [id], + }); + return rows[0] ? eventFromRow(rows[0]) : null; +} + +function patternMatches(pattern: string, topic: string): boolean { + return ( + pattern === "*" || + pattern === topic || + (pattern.endsWith(".*") && topic.startsWith(pattern.slice(0, -1))) + ); +} + +async function persistVirtualSubscriptionSnapshot(input: { + db: DbExec; + event: WorkflowEvent; + providerId: string; + evaluationStartSequence: number; + snapshot: VirtualWorkflowSubscriptionSnapshot; +}): Promise { + const { db, event, snapshot } = input; + if ( + !Number.isSafeInteger(snapshot.version) || + snapshot.version < 1 || + snapshot.ownerEmail !== event.ownerEmail || + (snapshot.orgId ?? null) !== event.orgId || + !patternMatches(snapshot.eventPattern, event.topic) + ) { + throw new Error( + `Virtual workflow provider "${input.providerId}" returned an invalid subscription snapshot`, + ); + } + const config = canonicalJson(snapshot.config ?? {}); + const enabled = snapshot.enabled !== false; + const activeAfterSequence = Math.max( + input.evaluationStartSequence, + event.eventSequence - 1, + ); + const existing = await db.execute({ + sql: `SELECT owner_email, org_id FROM workflow_subscriptions + WHERE id = ? LIMIT 1`, + args: [snapshot.id], + }); + if ( + existing.rows[0] && + (String(existing.rows[0].owner_email) !== snapshot.ownerEmail || + (existing.rows[0].org_id == null + ? null + : String(existing.rows[0].org_id)) !== (snapshot.orgId ?? null)) + ) { + throw new Error( + `Virtual workflow subscription "${snapshot.id}" collides across authorities`, + ); + } + await db.execute({ + sql: `INSERT INTO workflow_subscriptions + (id, kind, event_pattern, owner_email, org_id, config, enabled, + created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT (id) DO NOTHING`, + args: [ + snapshot.id, + snapshot.kind, + snapshot.eventPattern, + snapshot.ownerEmail, + snapshot.orgId ?? null, + config, + booleanValue(enabled), + event.createdAt, + event.createdAt, + ], + }); + await db.execute({ + sql: `INSERT INTO workflow_subscription_versions + (id, subscription_id, version, kind, event_pattern, owner_email, org_id, + config, enabled, active_after_sequence, active_at, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT (subscription_id, version) DO NOTHING`, + args: [ + randomUUID(), + snapshot.id, + snapshot.version, + snapshot.kind, + snapshot.eventPattern, + snapshot.ownerEmail, + snapshot.orgId ?? null, + config, + booleanValue(enabled), + activeAfterSequence, + event.createdAt, + event.createdAt, + ], + }); + await db.execute({ + sql: `UPDATE workflow_subscriptions SET kind = ?, event_pattern = ?, + owner_email = ?, org_id = ?, config = ?, enabled = ?, updated_at = ? + WHERE id = ? AND NOT EXISTS ( + SELECT 1 FROM workflow_subscription_versions newer + WHERE newer.subscription_id = ? AND newer.version > ? + )`, + args: [ + snapshot.kind, + snapshot.eventPattern, + snapshot.ownerEmail, + snapshot.orgId ?? null, + config, + booleanValue(enabled), + event.createdAt, + snapshot.id, + snapshot.id, + snapshot.version, + ], + }); +} + +async function materializeVirtualSubscriptionsForEvents( + db: DbExec, + events: WorkflowEvent[], +): Promise { + for (const provider of listVirtualWorkflowSubscriptionProviders()) { + for (const event of events) { + if (event.eventSequence <= provider.evaluationStartSequence) continue; + const snapshots = await provider.subscriptionsForEvent(event); + for (const snapshot of snapshots) { + await persistVirtualSubscriptionSnapshot({ + db, + event, + providerId: provider.id, + evaluationStartSequence: provider.evaluationStartSequence, + snapshot, + }); + } + } + } +} + +/** Materialize the unique `(event_id, subscription_id)` work units. */ +export async function materializeWorkflowExecutions( + options: { eventId?: string; limit?: number; now?: number } = {}, +): Promise { + await ensureWorkflowSchema(); + const db = getDbExec(); + const now = options.now ?? Date.now(); + const args: unknown[] = [now]; + let eventWhere = "e.available_at <= ? AND e.materialized_at IS NULL"; + if (options.eventId) { + eventWhere += " AND e.id = ?"; + args.push(options.eventId); + } + args.push(Math.min(Math.max(options.limit ?? 100, 1), 500)); + const { rows: eventRows } = await db.execute({ + sql: `SELECT e.id, e.event_sequence, e.topic, e.subject_type, e.subject_id, + e.subject_key, e.owner_email, e.org_id, e.payload, e.actor_context, + e.causal_event_id, e.occurred_at, e.available_at, e.created_at, + e.materialized_at + FROM workflow_events e WHERE ${eventWhere} + ORDER BY e.event_sequence ASC LIMIT ?`, + args, + }); + await materializeVirtualSubscriptionsForEvents( + db, + eventRows.map(eventFromRow), + ); + const eventIds = eventRows.map((row) => String(row.id)); + const { rows: candidates } = eventIds.length + ? await db.execute({ + sql: `SELECT e.id AS event_id, e.topic, e.subject_key, + e.owner_email AS event_owner_email, e.org_id AS event_org_id, + e.event_sequence, e.occurred_at, v.subscription_id, + v.version AS subscription_version, + v.event_pattern, v.owner_email AS subscription_owner_email, + v.org_id AS subscription_org_id, v.config AS subscription_config + FROM workflow_events e JOIN workflow_subscription_versions v + ON v.active_after_sequence < e.event_sequence + WHERE e.id IN (${eventIds.map(() => "?").join(", ")}) + AND v.enabled = ? AND NOT EXISTS ( + SELECT 1 FROM workflow_subscription_versions newer + WHERE newer.subscription_id = v.subscription_id + AND newer.active_after_sequence < e.event_sequence + AND (newer.active_after_sequence > v.active_after_sequence OR + (newer.active_after_sequence = v.active_after_sequence + AND newer.version > v.version)) + ) ORDER BY e.event_sequence ASC, v.subscription_id ASC`, + args: [...eventIds, booleanValue(true)], + }) + : { rows: [] }; + let inserted = 0; + const ready: Record[] = []; + const deferred: Record[] = []; + for (const candidate of candidates) { + if ( + !patternMatches(String(candidate.event_pattern), String(candidate.topic)) + ) + continue; + if ( + String(candidate.subscription_owner_email) !== + String(candidate.event_owner_email) + ) + continue; + if ( + candidate.subscription_org_id != null && + String(candidate.subscription_org_id) !== + String(candidate.event_org_id ?? "") + ) + continue; + const controls = await workflowControlForSubscription({ + ownerEmail: String(candidate.subscription_owner_email), + orgId: + candidate.subscription_org_id == null + ? null + : String(candidate.subscription_org_id), + config: candidate.subscription_config, + }); + if (controls?.effective.evaluatorPaused) deferred.push(candidate); + else ready.push(candidate); + } + const persistMaterialization = async (tx: DbExec) => { + for (const candidate of ready) { + const result = await tx.execute({ + sql: `INSERT INTO workflow_executions + (id, event_id, subscription_id, subscription_version, subject_key, + status, attempt, lease_token, lease_expires_at, fence_version, + error_message, created_at, updated_at, completed_at) + VALUES (?, ?, ?, ?, ?, 'pending', 0, NULL, NULL, 0, NULL, ?, ?, NULL) + ON CONFLICT (event_id, subscription_id) DO NOTHING`, + args: [ + randomUUID(), + candidate.event_id, + candidate.subscription_id, + candidate.subscription_version, + candidate.subject_key, + now, + now, + ], + }); + inserted += result.rowsAffected ?? 0; + } + for (const candidate of deferred) { + await tx.execute({ + sql: `INSERT INTO workflow_materialization_backlog + (id, event_id, subscription_id, subscription_version, subject_key, created_at) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT (event_id, subscription_id) DO NOTHING`, + args: [ + randomUUID(), + candidate.event_id, + candidate.subscription_id, + candidate.subscription_version, + candidate.subject_key, + now, + ], + }); + } + if (eventIds.length) { + await tx.execute({ + sql: `UPDATE workflow_events SET materialized_at = ? + WHERE id IN (${eventIds.map(() => "?").join(", ")}) + AND materialized_at IS NULL`, + args: [now, ...eventIds], + }); + } + }; + if (db.transaction) await db.transaction(persistMaterialization); + else await persistMaterialization(db); + + const { rows: backlog } = await db.execute({ + sql: `SELECT b.id, b.event_id, b.subscription_id, b.subscription_version, + b.subject_key, v.owner_email AS subscription_owner_email, + v.org_id AS subscription_org_id, v.config AS subscription_config + FROM workflow_materialization_backlog b + JOIN workflow_subscription_versions v + ON v.subscription_id = b.subscription_id + AND v.version = b.subscription_version + ORDER BY b.created_at ASC LIMIT ?`, + args: [Math.min(Math.max(options.limit ?? 100, 1), 500)], + }); + for (const [backlogIndex, candidate] of backlog.entries()) { + const controls = await workflowControlForSubscription({ + ownerEmail: String(candidate.subscription_owner_email), + orgId: + candidate.subscription_org_id == null + ? null + : String(candidate.subscription_org_id), + config: candidate.subscription_config, + }); + if (controls?.effective.evaluatorPaused) { + await db.execute({ + sql: `UPDATE workflow_materialization_backlog SET created_at = ? + WHERE id = ?`, + args: [now + backlogIndex + 1, candidate.id], + }); + continue; + } + const release = async (tx: DbExec) => { + const result = await tx.execute({ + sql: `INSERT INTO workflow_executions + (id, event_id, subscription_id, subscription_version, subject_key, + status, attempt, lease_token, lease_expires_at, fence_version, + error_message, created_at, updated_at, completed_at) + VALUES (?, ?, ?, ?, ?, 'pending', 0, NULL, NULL, 0, NULL, ?, ?, NULL) + ON CONFLICT (event_id, subscription_id) DO NOTHING`, + args: [ + randomUUID(), + candidate.event_id, + candidate.subscription_id, + candidate.subscription_version, + candidate.subject_key, + now, + now, + ], + }); + await tx.execute({ + sql: "DELETE FROM workflow_materialization_backlog WHERE id = ?", + args: [candidate.id], + }); + inserted += result.rowsAffected ?? 0; + }; + if (db.transaction) await db.transaction(release); + else await release(db); + } + return inserted; +} + +export async function getWorkflowExecution(id: string): Promise<{ + id: string; + eventId: string; + subscriptionId: string; + subscriptionVersion: number; + status: WorkflowExecutionStatus; + attempt: number; + fenceVersion: number; + leaseExpiresAt: number | null; + errorMessage: string | null; +} | null> { + await ensureWorkflowSchema(); + const { rows } = await getDbExec().execute({ + sql: `SELECT id, event_id, subscription_id, subscription_version, status, attempt, + fence_version, lease_expires_at, error_message + FROM workflow_executions WHERE id = ? LIMIT 1`, + args: [id], + }); + const row = rows[0]; + if (!row) return null; + return { + id: String(row.id), + eventId: String(row.event_id), + subscriptionId: String(row.subscription_id), + subscriptionVersion: Number(row.subscription_version), + status: String(row.status) as WorkflowExecutionStatus, + attempt: Number(row.attempt), + fenceVersion: Number(row.fence_version), + leaseExpiresAt: + row.lease_expires_at == null ? null : Number(row.lease_expires_at), + errorMessage: row.error_message == null ? null : String(row.error_message), + }; +} + +/** Queue an explicit operator retry without introducing a second worker. */ +export async function retryWorkflowExecution(input: { + executionId: string; + now?: number; +}): Promise { + await ensureWorkflowSchema(); + const now = input.now ?? Date.now(); + const result = await getDbExec().execute({ + sql: `UPDATE workflow_executions SET status = 'pending', lease_token = NULL, + lease_expires_at = NULL, error_message = NULL, completed_at = NULL, + updated_at = ? WHERE id = ? AND status IN ('failed', 'unknown')`, + args: [now, input.executionId], + }); + if ((result.rowsAffected ?? 0) > 0) { + emitWorkflowWake({ + topic: "workflow.event.available", + rowId: input.executionId, + }); + return true; + } + return false; +} + +/** Accept an indeterminate outcome as terminal without pretending it delivered. */ +export async function acknowledgeWorkflowExecution(input: { + executionId: string; + now?: number; +}): Promise { + await ensureWorkflowSchema(); + const now = input.now ?? Date.now(); + const result = await getDbExec().execute({ + sql: `UPDATE workflow_executions SET status = 'acknowledged', + lease_token = NULL, lease_expires_at = NULL, updated_at = ?, + completed_at = COALESCE(completed_at, ?) + WHERE id = ? AND status = 'unknown'`, + args: [now, now, input.executionId], + }); + return (result.rowsAffected ?? 0) > 0; +} + +export async function listWorkflowExecutions( + options: { + eventId?: string; + subscriptionId?: string; + status?: WorkflowExecutionStatus; + limit?: number; + } = {}, +) { + await ensureWorkflowSchema(); + const clauses: string[] = []; + const args: unknown[] = []; + for (const [column, value] of [ + ["event_id", options.eventId], + ["subscription_id", options.subscriptionId], + ["status", options.status], + ] as const) { + if (value) { + clauses.push(`${column} = ?`); + args.push(value); + } + } + args.push(Math.min(Math.max(options.limit ?? 100, 1), 500)); + const { rows } = await getDbExec().execute({ + sql: `SELECT id, event_id, subscription_id, subscription_version, + subject_key, status, attempt, + lease_token, lease_expires_at, fence_version, error_message, created_at, + updated_at, completed_at FROM workflow_executions + ${clauses.length ? `WHERE ${clauses.join(" AND ")}` : ""} + ORDER BY created_at DESC LIMIT ?`, + args, + }); + return rows.map((row) => ({ + id: String(row.id), + eventId: String(row.event_id), + subscriptionId: String(row.subscription_id), + subscriptionVersion: Number(row.subscription_version), + subjectKey: String(row.subject_key), + status: String(row.status) as WorkflowExecutionStatus, + attempt: Number(row.attempt), + leaseToken: row.lease_token == null ? null : String(row.lease_token), + leaseExpiresAt: + row.lease_expires_at == null ? null : Number(row.lease_expires_at), + fenceVersion: Number(row.fence_version), + errorMessage: row.error_message == null ? null : String(row.error_message), + createdAt: Number(row.created_at), + updatedAt: Number(row.updated_at), + completedAt: row.completed_at == null ? null : Number(row.completed_at), + })); +} + +export async function claimNextWorkflowExecution(options: { + workerId: string; + leaseMs?: number; + now?: number; +}): Promise { + await ensureWorkflowSchema(); + const db = getDbExec(); + const now = options.now ?? Date.now(); + const leaseMs = Math.min( + Math.max(options.leaseMs ?? 60_000, 1_000), + 15 * 60_000, + ); + await materializeWorkflowExecutions({ now }); + await db.execute({ + sql: `UPDATE workflow_executions SET status = 'pending', lease_token = NULL, + lease_expires_at = NULL, updated_at = ? + WHERE status = 'running' AND lease_expires_at <= ?`, + args: [now, now], + }); + + for (let race = 0; race < 5; race += 1) { + const { rows } = await db.execute({ + sql: `SELECT x.id, x.fence_version, + v.owner_email AS subscription_owner_email, + v.org_id AS subscription_org_id, v.config AS subscription_config + FROM workflow_executions x + JOIN workflow_events e ON e.id = x.event_id + JOIN workflow_subscription_versions v + ON v.subscription_id = x.subscription_id + AND v.version = x.subscription_version + WHERE x.status = 'pending' + AND e.available_at <= ? + AND NOT EXISTS ( + SELECT 1 FROM workflow_executions prior + JOIN workflow_events pe ON pe.id = prior.event_id + WHERE prior.subject_key = x.subject_key + AND prior.status IN ('pending', 'retrying', 'running', 'unknown') + AND pe.event_sequence < e.event_sequence + ) + ORDER BY e.event_sequence ASC, x.created_at ASC LIMIT 25`, + args: [now], + }); + if (!rows[0]) return null; + let foundEligible = false; + for (const row of rows) { + const controls = await workflowControlForSubscription({ + ownerEmail: String(row.subscription_owner_email), + orgId: + row.subscription_org_id == null + ? null + : String(row.subscription_org_id), + config: row.subscription_config, + }); + if (controls?.effective.effectsPaused) continue; + foundEligible = true; + const id = String(row.id); + const fence = Number(row.fence_version ?? 0); + const leaseToken = `${options.workerId}:${randomUUID()}`; + const result = await db.execute({ + sql: `UPDATE workflow_executions SET status = 'running', + attempt = attempt + 1, lease_token = ?, lease_expires_at = ?, + fence_version = fence_version + 1, updated_at = ? + WHERE id = ? AND fence_version = ? AND status = 'pending'`, + args: [leaseToken, now + leaseMs, now, id, fence], + }); + if ((result.rowsAffected ?? 0) === 0) continue; + const claimed = await getClaimedExecution(id, leaseToken, fence + 1); + if (claimed) return claimed; + } + if (!foundEligible) return null; + } + return null; +} + +async function getClaimedExecution( + id: string, + leaseToken: string, + fenceVersion: number, +): Promise { + const { rows } = await getDbExec().execute({ + sql: `SELECT x.id AS execution_id, x.event_id, x.subscription_id, + x.subscription_version, + x.status, x.attempt, x.lease_token, x.lease_expires_at, x.fence_version, + e.id, e.event_sequence, e.topic, e.subject_type, e.subject_id, e.subject_key, + e.owner_email AS event_owner_email, e.org_id AS event_org_id, + e.payload, e.actor_context, e.causal_event_id, e.occurred_at, + e.available_at, e.created_at AS event_created_at, + v.kind, v.event_pattern, v.owner_email AS subscription_owner_email, + v.org_id AS subscription_org_id, v.config, v.enabled, + v.created_at AS subscription_created_at, + v.active_at AS subscription_updated_at + FROM workflow_executions x + JOIN workflow_events e ON e.id = x.event_id + JOIN workflow_subscription_versions v + ON v.subscription_id = x.subscription_id + AND v.version = x.subscription_version + WHERE x.id = ? AND x.lease_token = ? AND x.fence_version = ? LIMIT 1`, + args: [id, leaseToken, fenceVersion], + }); + if (!rows[0]) return null; + const row = rows[0]; + return { + id: String(row.execution_id), + eventId: String(row.event_id), + subscriptionId: String(row.subscription_id), + subscriptionVersion: Number(row.subscription_version), + status: "running", + attempt: Number(row.attempt), + leaseToken: String(row.lease_token), + leaseExpiresAt: Number(row.lease_expires_at), + fenceVersion: Number(row.fence_version), + event: eventFromRow({ + ...row, + owner_email: row.event_owner_email, + org_id: row.event_org_id, + created_at: row.event_created_at, + }), + subscription: subscriptionFromRow({ + id: row.subscription_id, + version: row.subscription_version, + kind: row.kind, + event_pattern: row.event_pattern, + owner_email: row.subscription_owner_email, + org_id: row.subscription_org_id, + config: row.config, + enabled: row.enabled, + created_at: row.subscription_created_at, + updated_at: row.subscription_updated_at, + }), + }; +} + +export async function finalizeWorkflowExecution(input: { + executionId: string; + leaseToken: string; + fenceVersion: number; + status: Exclude; + errorMessage?: string; + now?: number; +}): Promise { + await ensureWorkflowSchema(); + const now = input.now ?? Date.now(); + const result = await getDbExec().execute({ + sql: `UPDATE workflow_executions SET status = ?, error_message = ?, + lease_token = NULL, lease_expires_at = NULL, updated_at = ?, + completed_at = ? WHERE id = ? AND status = 'running' + AND lease_token = ? AND fence_version = ?`, + args: [ + input.status, + input.errorMessage?.slice(0, 2_000) ?? null, + now, + input.status === "retrying" ? null : now, + input.executionId, + input.leaseToken, + input.fenceVersion, + ], + }); + return (result.rowsAffected ?? 0) > 0; +} + +export interface WorkflowEffect { + id: string; + executionId: string; + kind: string; + idempotencyKey: string; + status: WorkflowEffectStatus; + result: Record | null; + errorMessage: string | null; + createdAt: number; + updatedAt: number; +} + +/** Reserve an effect before I/O. A replay receives the existing ledger row. */ +export async function recordWorkflowEffect(input: { + executionId: string; + kind: string; + idempotencyKey: string; + now?: number; +}): Promise<{ effect: WorkflowEffect; created: boolean }> { + await ensureWorkflowSchema(); + const db = getDbExec(); + const now = input.now ?? Date.now(); + const id = randomUUID(); + const inserted = await db.execute({ + sql: `INSERT INTO workflow_effects + (id, execution_id, kind, idempotency_key, status, result, error_message, + created_at, updated_at) + VALUES (?, ?, ?, ?, 'unknown', NULL, NULL, ?, ?) + ON CONFLICT (idempotency_key) DO NOTHING`, + args: [id, input.executionId, input.kind, input.idempotencyKey, now, now], + }); + const effect = await getWorkflowEffectByIdempotencyKey(input.idempotencyKey); + if (!effect) throw new Error("Workflow effect reservation was not persisted"); + return { effect, created: (inserted.rowsAffected ?? 0) > 0 }; +} + +export async function finalizeWorkflowEffect(input: { + effectId: string; + status: WorkflowEffectStatus; + result?: Record; + errorMessage?: string; + now?: number; +}): Promise { + await ensureWorkflowSchema(); + const result = await getDbExec().execute({ + sql: `UPDATE workflow_effects SET status = ?, result = ?, + error_message = ?, updated_at = ? WHERE id = ?`, + args: [ + input.status, + input.result ? json(input.result) : null, + input.errorMessage?.slice(0, 2_000) ?? null, + input.now ?? Date.now(), + input.effectId, + ], + }); + return (result.rowsAffected ?? 0) > 0; +} + +/** + * Claim a known-failed effect for one retry before provider I/O. A crash after + * this transition leaves the effect unknown, which blocks automatic replay. + */ +export async function claimWorkflowEffectRetry(input: { + effectId: string; + now?: number; +}): Promise { + await ensureWorkflowSchema(); + const result = await getDbExec().execute({ + sql: `UPDATE workflow_effects SET status = 'unknown', result = NULL, + error_message = NULL, updated_at = ? WHERE id = ? AND status = 'failed'`, + args: [input.now ?? Date.now(), input.effectId], + }); + return (result.rowsAffected ?? 0) > 0; +} + +export async function getWorkflowEffectByIdempotencyKey( + idempotencyKey: string, +): Promise { + await ensureWorkflowSchema(); + const { rows } = await getDbExec().execute({ + sql: `SELECT id, execution_id, kind, idempotency_key, status, result, + error_message, created_at, updated_at FROM workflow_effects + WHERE idempotency_key = ? LIMIT 1`, + args: [idempotencyKey], + }); + return rows[0] ? effectFromRow(rows[0]) : null; +} + +export async function recordNotificationDeliveryAttempt(input: { + effectId: string; + notificationId?: string | null; + channel: string; + attempt: number; + status: WorkflowDeliveryStatus; + errorMessage?: string; + now?: number; +}): Promise { + await ensureWorkflowSchema(); + const db = getDbExec(); + const now = input.now ?? Date.now(); + const existing = await db.execute({ + sql: `SELECT id FROM notification_delivery_attempts + WHERE effect_id = ? AND channel = ? AND attempt = ? LIMIT 1`, + args: [input.effectId, input.channel, input.attempt], + }); + const id = existing.rows[0]?.id ? String(existing.rows[0].id) : randomUUID(); + if (existing.rows.length) { + await db.execute({ + sql: `UPDATE notification_delivery_attempts SET notification_id = ?, + status = ?, error_message = ?, updated_at = ? WHERE id = ?`, + args: [ + input.notificationId ?? null, + input.status, + input.errorMessage?.slice(0, 2_000) ?? null, + now, + id, + ], + }); + } else { + await db.execute({ + sql: `INSERT INTO notification_delivery_attempts + (id, effect_id, notification_id, channel, attempt, status, + error_message, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + args: [ + id, + input.effectId, + input.notificationId ?? null, + input.channel, + input.attempt, + input.status, + input.errorMessage?.slice(0, 2_000) ?? null, + now, + now, + ], + }); + } + return id; +} + +export async function listNotificationDeliveryAttempts(effectId: string) { + await ensureWorkflowSchema(); + const { rows } = await getDbExec().execute({ + sql: `SELECT id, effect_id, notification_id, channel, attempt, status, + error_message, created_at, updated_at FROM notification_delivery_attempts + WHERE effect_id = ? ORDER BY channel ASC, attempt ASC`, + args: [effectId], + }); + return rows.map((row) => ({ + id: String(row.id), + effectId: String(row.effect_id), + notificationId: + row.notification_id == null ? null : String(row.notification_id), + channel: String(row.channel), + attempt: Number(row.attempt), + status: String(row.status) as WorkflowEffectStatus, + errorMessage: row.error_message == null ? null : String(row.error_message), + createdAt: Number(row.created_at), + updatedAt: Number(row.updated_at), + })); +} + +export async function scheduleWorkflowWork(input: { + id?: string; + workType: string; + subjectKey: string; + eventId?: string | null; + subscriptionId?: string | null; + payload?: Record; + dedupeKey?: string | null; + dueAt: number; + now?: number; +}): Promise { + await ensureWorkflowSchema(); + const db = getDbExec(); + const now = input.now ?? Date.now(); + if (input.dedupeKey) { + const existing = await db.execute({ + sql: `SELECT id FROM workflow_scheduled_work WHERE dedupe_key = ? LIMIT 1`, + args: [input.dedupeKey], + }); + if (existing.rows[0]?.id) { + const id = String(existing.rows[0].id); + await db.execute({ + sql: `UPDATE workflow_scheduled_work SET work_type = ?, subject_key = ?, + event_id = ?, subscription_id = ?, payload = ?, due_at = ?, + status = 'pending', lease_token = NULL, lease_expires_at = NULL, + attempt = 0, error_message = NULL, completed_at = NULL, + updated_at = ? WHERE id = ? AND status <> 'running'`, + args: [ + input.workType, + input.subjectKey, + input.eventId ?? null, + input.subscriptionId ?? null, + json(input.payload), + input.dueAt, + now, + id, + ], + }); + emitWorkflowWake({ + topic: "workflow.scheduled-work.available", + rowId: id, + }); + return id; + } + } + const id = input.id ?? randomUUID(); + await db.execute({ + sql: `INSERT INTO workflow_scheduled_work + (id, work_type, subject_key, event_id, subscription_id, payload, + dedupe_key, due_at, status, attempt, lease_token, lease_expires_at, + fence_version, error_message, completed_at, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'pending', 0, NULL, NULL, 0, NULL, NULL, ?, ?)`, + args: [ + id, + input.workType, + input.subjectKey, + input.eventId ?? null, + input.subscriptionId ?? null, + json(input.payload), + input.dedupeKey ?? null, + input.dueAt, + now, + now, + ], + }); + emitWorkflowWake({ topic: "workflow.scheduled-work.available", rowId: id }); + return id; +} + +export async function cancelWorkflowWork( + id: string, + options: { now?: number } = {}, +): Promise { + await ensureWorkflowSchema(); + const result = await getDbExec().execute({ + sql: `UPDATE workflow_scheduled_work SET status = 'cancelled', + updated_at = ? WHERE id = ? AND status = 'pending'`, + args: [options.now ?? Date.now(), id], + }); + return (result.rowsAffected ?? 0) > 0; +} + +export interface ClaimedScheduledWork { + id: string; + workType: string; + subjectKey: string; + eventId: string | null; + subscriptionId: string | null; + payload: Record; + attempt: number; + leaseToken: string; + leaseExpiresAt: number; + fenceVersion: number; +} + +export async function claimNextScheduledWork(input: { + workerId: string; + leaseMs?: number; + now?: number; +}): Promise { + await ensureWorkflowSchema(); + const db = getDbExec(); + const now = input.now ?? Date.now(); + const leaseMs = Math.min( + Math.max(input.leaseMs ?? 60_000, 1_000), + 15 * 60_000, + ); + await db.execute({ + sql: `UPDATE workflow_scheduled_work SET status = 'pending', + lease_token = NULL, lease_expires_at = NULL, updated_at = ? + WHERE status = 'running' AND lease_expires_at <= ?`, + args: [now, now], + }); + for (let race = 0; race < 5; race += 1) { + const { rows } = await db.execute({ + sql: `SELECT w.id, w.fence_version, + s.owner_email AS subscription_owner_email, + s.org_id AS subscription_org_id, s.config AS subscription_config + FROM workflow_scheduled_work w + LEFT JOIN workflow_subscriptions s ON s.id = w.subscription_id + WHERE w.status = 'pending' AND w.due_at <= ? + ORDER BY w.due_at ASC, w.id ASC LIMIT 25`, + args: [now], + }); + if (!rows[0]) return null; + let foundEligible = false; + for (const candidate of rows) { + const controls = candidate.subscription_owner_email + ? await workflowControlForSubscription({ + ownerEmail: String(candidate.subscription_owner_email), + orgId: + candidate.subscription_org_id == null + ? null + : String(candidate.subscription_org_id), + config: candidate.subscription_config, + }) + : null; + if (controls?.effective.effectsPaused) continue; + foundEligible = true; + const id = String(candidate.id); + const fence = Number(candidate.fence_version ?? 0); + const token = `${input.workerId}:${randomUUID()}`; + const result = await db.execute({ + sql: `UPDATE workflow_scheduled_work SET status = 'running', + attempt = attempt + 1, lease_token = ?, lease_expires_at = ?, + fence_version = fence_version + 1, updated_at = ? + WHERE id = ? AND status = 'pending' AND fence_version = ?`, + args: [token, now + leaseMs, now, id, fence], + }); + if ((result.rowsAffected ?? 0) === 0) continue; + const fetched = await db.execute({ + sql: `SELECT id, work_type, subject_key, event_id, subscription_id, + payload, attempt, lease_token, lease_expires_at, fence_version + FROM workflow_scheduled_work WHERE id = ? AND lease_token = ? LIMIT 1`, + args: [id, token], + }); + const row = fetched.rows[0]; + if (!row) continue; + return { + id, + workType: String(row.work_type), + subjectKey: String(row.subject_key), + eventId: row.event_id == null ? null : String(row.event_id), + subscriptionId: + row.subscription_id == null ? null : String(row.subscription_id), + payload: safeJsonParse(String(row.payload), {}), + attempt: Number(row.attempt), + leaseToken: token, + leaseExpiresAt: Number(row.lease_expires_at), + fenceVersion: Number(row.fence_version), + }; + } + if (!foundEligible) return null; + } + return null; +} + +export async function finalizeScheduledWork(input: { + id: string; + leaseToken: string; + fenceVersion: number; + status: "completed" | "failed" | "dead_letter" | "pending"; + errorMessage?: string; + dueAt?: number; + now?: number; +}): Promise { + await ensureWorkflowSchema(); + const result = await getDbExec().execute({ + sql: `UPDATE workflow_scheduled_work SET status = ?, lease_token = NULL, + lease_expires_at = NULL, error_message = ?, + due_at = COALESCE(?, due_at), updated_at = ?, completed_at = ? + WHERE id = ? AND status = 'running' + AND lease_token = ? AND fence_version = ?`, + args: [ + input.status, + input.errorMessage?.slice(0, 2_000) ?? null, + input.dueAt ?? null, + input.now ?? Date.now(), + input.status === "pending" ? null : (input.now ?? Date.now()), + input.id, + input.leaseToken, + input.fenceVersion, + ], + }); + return (result.rowsAffected ?? 0) > 0; +} + +/** Release an execution only when its durable retry timer fires. */ +export async function releaseWorkflowExecutionRetry(input: { + executionId: string; + expectedAttempt: number; + now?: number; +}): Promise { + await ensureWorkflowSchema(); + const result = await getDbExec().execute({ + sql: `UPDATE workflow_executions SET status = 'pending', lease_token = NULL, + lease_expires_at = NULL, updated_at = ? WHERE id = ? AND attempt = ? + AND (status = 'retrying' OR + (status = 'running' AND lease_expires_at <= ?))`, + args: [ + input.now ?? Date.now(), + input.executionId, + input.expectedAttempt, + input.now ?? Date.now(), + ], + }); + return (result.rowsAffected ?? 0) > 0; +} + +function eventFromRow(row: Record): WorkflowEvent { + return { + id: String(row.id), + eventSequence: Number(row.event_sequence), + topic: String(row.topic), + subjectType: String(row.subject_type), + subjectId: String(row.subject_id), + subjectKey: String(row.subject_key), + ownerEmail: String(row.owner_email), + orgId: row.org_id == null ? null : String(row.org_id), + payload: safeJsonParse(String(row.payload ?? "{}"), {}), + actorContext: safeJsonParse(String(row.actor_context ?? "{}"), {}), + causalEventId: + row.causal_event_id == null ? null : String(row.causal_event_id), + occurredAt: Number(row.occurred_at), + availableAt: Number(row.available_at), + createdAt: Number(row.created_at), + }; +} + +function subscriptionFromRow( + row: Record, +): WorkflowSubscription { + return { + id: String(row.id), + version: Number(row.version ?? 1), + kind: String(row.kind) as WorkflowSubscription["kind"], + eventPattern: String(row.event_pattern), + ownerEmail: String(row.owner_email), + orgId: row.org_id == null ? null : String(row.org_id), + config: safeJsonParse(String(row.config ?? "{}"), {}), + enabled: row.enabled === true || Number(row.enabled) === 1, + createdAt: Number(row.created_at), + updatedAt: Number(row.updated_at), + }; +} + +function effectFromRow(row: Record): WorkflowEffect { + return { + id: String(row.id), + executionId: String(row.execution_id), + kind: String(row.kind), + idempotencyKey: String(row.idempotency_key), + status: String(row.status) as WorkflowDeliveryStatus, + result: row.result ? safeJsonParse(String(row.result), null) : null, + errorMessage: row.error_message == null ? null : String(row.error_message), + createdAt: Number(row.created_at), + updatedAt: Number(row.updated_at), + }; +} + +export function __resetWorkflowSchemaForTests(): void { + schemaPromise = undefined; +} diff --git a/packages/core/src/workflow/types.ts b/packages/core/src/workflow/types.ts new file mode 100644 index 0000000000..6b75317173 --- /dev/null +++ b/packages/core/src/workflow/types.ts @@ -0,0 +1,152 @@ +import type { DbExec } from "../db/client.js"; + +export const WORKFLOW_SUBSCRIPTION_KINDS = [ + "deterministic", + "agentic", +] as const; +export type WorkflowSubscriptionKind = + (typeof WORKFLOW_SUBSCRIPTION_KINDS)[number]; + +export const WORKFLOW_EXECUTION_STATUSES = [ + "pending", + "running", + "succeeded", + "failed", + "retrying", + "unknown", + "acknowledged", +] as const; +export type WorkflowExecutionStatus = + (typeof WORKFLOW_EXECUTION_STATUSES)[number]; + +export const WORKFLOW_DELIVERY_STATUSES = [ + "delivered", + "failed", + "retrying", + "unknown", + "skipped", +] as const; +export type WorkflowDeliveryStatus = + (typeof WORKFLOW_DELIVERY_STATUSES)[number]; + +export const WORKFLOW_EFFECT_STATUSES = [ + ...WORKFLOW_DELIVERY_STATUSES, + "suppressed", + "coalesced", +] as const; +export type WorkflowEffectStatus = (typeof WORKFLOW_EFFECT_STATUSES)[number]; + +export const WORKFLOW_SCHEDULED_WORK_STATUSES = [ + "pending", + "running", + "completed", + "cancelled", + "failed", + "dead_letter", +] as const; +export type WorkflowScheduledWorkStatus = + (typeof WORKFLOW_SCHEDULED_WORK_STATUSES)[number]; + +export interface WorkflowEventInput { + id?: string; + /** Durable commit order. Domain transactions allocate this from core. */ + eventSequence?: number; + topic: string; + subjectType: string; + subjectId: string; + ownerEmail: string; + orgId?: string | null; + payload?: Record; + actorContext?: Record; + causalEventId?: string | null; + occurredAt?: number; + availableAt?: number; +} + +export interface WorkflowEvent { + id: string; + eventSequence: number; + topic: string; + subjectType: string; + subjectId: string; + subjectKey: string; + ownerEmail: string; + orgId: string | null; + payload: Record; + actorContext: Record; + causalEventId: string | null; + occurredAt: number; + availableAt: number; + createdAt: number; +} + +export interface WorkflowSubscriptionInput { + id: string; + kind: WorkflowSubscriptionKind; + eventPattern: string; + ownerEmail: string; + orgId?: string | null; + config?: Record; + enabled?: boolean; +} + +export type WorkflowRuntimeControlScope = "global" | "resource"; + +export interface WorkflowRuntimeControlValue { + evaluatorPaused: boolean; + effectsPaused: boolean; +} + +export interface WorkflowRuntimeControlTarget { + ownerEmail: string; + orgId?: string | null; + domain: string; + scope: WorkflowRuntimeControlScope; + resourceId?: string | null; +} + +export interface WorkflowRuntimeControlContext { + ownerEmail: string; + orgId?: string | null; + domain: string; + resourceId?: string | null; +} + +export interface WorkflowRuntimeControls { + global: WorkflowRuntimeControlValue; + resource: WorkflowRuntimeControlValue; + effective: WorkflowRuntimeControlValue; +} + +export interface WorkflowSubscription extends WorkflowSubscriptionInput { + version: number; + orgId: string | null; + config: Record; + enabled: boolean; + createdAt: number; + updatedAt: number; +} + +export interface ClaimedWorkflowExecution { + id: string; + eventId: string; + subscriptionId: string; + subscriptionVersion: number; + status: "running"; + attempt: number; + leaseToken: string; + leaseExpiresAt: number; + fenceVersion: number; + event: WorkflowEvent; + subscription: WorkflowSubscription; +} + +export interface WorkflowStoreOptions { + db?: DbExec; + now?: number; +} + +export interface WorkflowWake { + topic: "workflow.event.available" | "workflow.scheduled-work.available"; + rowId: string; +} diff --git a/packages/core/src/workflow/virtual-subscriptions.ts b/packages/core/src/workflow/virtual-subscriptions.ts new file mode 100644 index 0000000000..9597160b80 --- /dev/null +++ b/packages/core/src/workflow/virtual-subscriptions.ts @@ -0,0 +1,44 @@ +import type { WorkflowEvent, WorkflowSubscriptionInput } from "./types.js"; + +export interface VirtualWorkflowSubscriptionSnapshot extends WorkflowSubscriptionInput { + version: number; +} + +export interface VirtualWorkflowSubscriptionProvider { + id: string; + evaluationStartSequence: number; + subscriptionsForEvent( + event: WorkflowEvent, + ): + | VirtualWorkflowSubscriptionSnapshot[] + | Promise; +} + +const REGISTRY = Symbol.for( + "@agent-native/core/workflow.virtual-subscription-providers", +); + +function providers(): Map { + const global = globalThis as typeof globalThis & { + [REGISTRY]?: Map; + }; + if (!global[REGISTRY]) global[REGISTRY] = new Map(); + return global[REGISTRY]; +} + +export function registerVirtualWorkflowSubscriptionProvider( + provider: VirtualWorkflowSubscriptionProvider, +): () => void { + if (!provider.id.trim()) + throw new Error("Virtual workflow provider id is required"); + providers().set(provider.id, provider); + return () => providers().delete(provider.id); +} + +export function listVirtualWorkflowSubscriptionProviders(): VirtualWorkflowSubscriptionProvider[] { + return [...providers().values()]; +} + +export function __resetVirtualWorkflowSubscriptionProviders(): void { + providers().clear(); +} diff --git a/packages/core/src/workflow/wake.ts b/packages/core/src/workflow/wake.ts new file mode 100644 index 0000000000..15b04fe321 --- /dev/null +++ b/packages/core/src/workflow/wake.ts @@ -0,0 +1,36 @@ +import type { WorkflowWake } from "./types.js"; + +const WAKE_KEY = Symbol.for("@agent-native/core/workflow.wake-bus"); +type WakeHandler = (wake: WorkflowWake) => void | Promise; + +interface WakeState { + handlers: Set; +} + +function state(): WakeState { + const global = globalThis as typeof globalThis & { [WAKE_KEY]?: WakeState }; + if (!(global[WAKE_KEY]?.handlers instanceof Set)) { + global[WAKE_KEY] = { handlers: new Set() }; + } + return global[WAKE_KEY]; +} + +/** + * Emits only an ephemeral hint. Consumers must claim the durable row by id; + * this payload is never an authority for dispatch or execution. + */ +export function emitWorkflowWake(wake: WorkflowWake): void { + if (!wake.rowId.trim()) throw new Error("Workflow wake rowId is required"); + const frozenWake = Object.freeze({ ...wake }); + for (const handler of state().handlers) void handler(frozenWake); +} + +export function subscribeWorkflowWake(handler: WakeHandler): () => void { + const handlers = state().handlers; + handlers.add(handler); + return () => handlers.delete(handler); +} + +export function __resetWorkflowWakeBus(): void { + state().handlers.clear(); +} diff --git a/templates/content/.agents/skills/content/SKILL.md b/templates/content/.agents/skills/content/SKILL.md index 87a2a3da3a..3cc9dad28b 100644 --- a/templates/content/.agents/skills/content/SKILL.md +++ b/templates/content/.agents/skills/content/SKILL.md @@ -111,6 +111,22 @@ For a named intake workflow: from the result. The canonical Content row route is `/page/`; never invent a different path, slug, ID, or host. +Content Rules observe committed changes rather than a particular UI route. +Database owners may use `manage-content-database-hook` with stable property, +option, and Person-property IDs; inspect `list-content-database-hooks` and +`list-content-database-hook-executions` before creating a similar Rule. An +optional `conditions` group supports inspectable all/any predicates; preview +and execution use the same deterministic evaluator. +Natural-language agent work belongs in a framework Automation subscribed to the +same durable Content event, not in a duplicate deterministic hook. + +Newly added Person values receive personal Content notifications by default. +Any recipient may remove themself globally, per database, per rule, or per item +with `manage-content-notification-preference`; the most specific override wins. +Do not treat a personal Slack route as a team-channel announcement, and do not +use notification delivery as the conversational receipt for a Slack-invoked +agent command. + When the user supplies a complete description in one message, do not force a questionnaire: extract the matching fields, show only genuinely uncertain inferences for confirmation, then submit once. When required information is diff --git a/templates/content/AGENTS.md b/templates/content/AGENTS.md index 20517dcedd..86cd00c5f0 100644 --- a/templates/content/AGENTS.md +++ b/templates/content/AGENTS.md @@ -160,6 +160,15 @@ cd templates/content && pnpm action [args] | `delete-database-items` | `--databaseId ` or `--documentId --itemIds ` or `--documentIds ` | Preferred multi-row delete action; delete multiple database row pages atomically while preserving `delete-document` admin semantics | | `move-database-item` | `--itemId ` or `--documentId --position ` | Move a database row page to a new zero-based table position | | `update-content-database-view` | `--databaseId --viewConfig ` | Persist database views, sorts, filters, hidden properties, and view settings | +| `manage-content-database-policy` | `--databaseId [--schemaLocked true\|false] [--defaultPersonNotificationsEnabled true\|false]` | Owner-only database locking and default Person-notification policy | +| `manage-content-database-validation` | `--databaseId --validation ` | Owner-only configuration for property IDs required by atomic submissions and by transitions into specific status option IDs | +| `list-content-database-hooks` | `--databaseId ` | List deterministic owner-managed rules for committed item creation and property transitions | +| `manage-content-database-hook` | `--action create\|update\|delete --databaseId [...]` | Owner-only management for deterministic Content Rules; use stable property, option, and Person-property IDs | +| `preview-content-database-hook` | `--databaseId --hookId --eventId ` | Pure preview of whether one Rule version matches a committed event and which effects it would attempt | +| `list-content-database-hook-executions` | `--databaseId [--hookId ] [--limit ]` | Inspect durable hook execution truth, attempts, and terminal errors | +| `manage-content-database-hook-execution` | `--action retry\|acknowledge --databaseId --executionId ` | Owner-only incident control over a durable Rule execution | +| `get-content-notification-preference` | `--databaseId [--subscriptionId ] [--documentId ]` | Resolve the current user's personal notification preference with item, rule, database, then global precedence | +| `manage-content-notification-preference` | `--action set\|remove --target [--enabled true\|false]` | Set or remove the current user's global, database, rule, or item personal-notification override; personal unsubscribe always wins | | `list-document-properties` | `--documentId [--format json]` | List Notion-style property definitions and values for a document | | `configure-document-property` | `--documentId [--id ] --name --type [--description ] [--visibility always_show\|hide_when_empty\|always_hide] [--options ]` | Create or update a property definition and its option-level guidance | | `duplicate-document-property` | `--documentId --propertyId ` | Duplicate a property definition and its stored values | @@ -621,6 +630,29 @@ property names, accepts select/status option IDs or labels, rejects unknown options, writes primary and additional Blocks fields to their correct stores in one transaction, verifies the saved row, and returns `createdItemId`, `createdDocumentId`, `urlPath`, and `deepLink`. +Database owners can use `manage-content-database-validation` to require +stable property IDs for every atomic form/agent submission and to gate a +specific status option on other required property IDs. A blocked submission or +status transition fails before mutation with missing-field evidence. Ordinary +`add-database-item` creation remains a draft path and may be incomplete. +Database owners can author deterministic Rules with +`manage-content-database-hook`. Rules evaluate committed database events, so +the same rule applies to UI, form, agent, MCP/A2A, and other certified mutation +origins. Use stable property and option IDs from `get-content-database`; never +persist labels as rule identity. Inspect actual execution state with +`list-content-database-hook-executions` instead of inferring delivery from the +row's current status. Optional all/any condition groups use the same +deterministic evaluator for preview and execution. Agentic natural-language +work remains a framework +Automation subscribed to the same durable Content event, not a second Content +worker or rule copy. + +Person-property additions notify by default. A recipient may always opt out of +their personal stream globally, per database, per rule, or per item with +`manage-content-notification-preference`; the most specific override wins. +Personal Slack/email/browser routing is a recipient preference. A shared Slack +channel announcement or provider action is owner-controlled workflow behavior +and must not be represented as another person's personal preference. The active view menu can rename, duplicate, delete, or switch an existing view's layout between table, list, gallery, calendar, timeline, board, and form while preserving its sorts, filters, hidden properties, and layout-specific settings. diff --git a/templates/content/actions/_content-database-hooks.ts b/templates/content/actions/_content-database-hooks.ts new file mode 100644 index 0000000000..8c0a2604c6 --- /dev/null +++ b/templates/content/actions/_content-database-hooks.ts @@ -0,0 +1,626 @@ +import { getRequestUserEmail } from "@agent-native/core/server/request-context"; +import { assertAccess } from "@agent-native/core/sharing"; +import { + upsertWorkflowSubscription, + workflowSubscriptions, +} from "@agent-native/core/workflow"; +import { and, eq, isNull } from "drizzle-orm"; +import { z } from "zod"; + +import { getDb, schema } from "../server/db/index.js"; +import { contentMutationTriggerCoverage } from "../server/db/mutation-certification-manifest.js"; +import { + isBlocksPropertyType, + isComputedPropertyType, + parsePropertyOptions, + type DocumentPropertyType, +} from "../shared/properties.js"; +import { nanoid, normalizedValueJson } from "./_property-utils.js"; + +export const contentHookTriggerSchema = z.discriminatedUnion("kind", [ + z.object({ + kind: z.literal("item_created"), + }), + z.object({ + kind: z.literal("item_submitted"), + }), + z.object({ + kind: z.literal("property_changed"), + propertyId: z.string().min(1), + fromOptionId: z.string().min(1).nullable().optional(), + toOptionId: z.string().min(1).nullable().optional(), + }), + z.object({ + kind: z.literal("builder_publication_confirmed"), + publicationAction: z.enum(["publish", "unpublish"]).nullable().optional(), + }), +]); + +const secretKeyNameSchema = z + .string() + .trim() + .regex(/^[A-Z][A-Z0-9_]{1,127}$/); + +export const contentHookEffectSchema = z.discriminatedUnion("kind", [ + z.object({ + version: z.literal(1).default(1), + kind: z.literal("notify"), + recipientPersonPropertyId: z.string().min(1), + message: z.string().max(2_000).optional(), + }), + z.object({ + version: z.literal(1).default(1), + kind: z.literal("team_slack"), + webhookKey: secretKeyNameSchema, + title: z.string().trim().min(1).max(100).optional(), + message: z.string().max(2_000).optional(), + }), + z.object({ + version: z.literal(1).default(1), + kind: z.literal("webhook"), + urlKey: secretKeyNameSchema, + signatureKey: secretKeyNameSchema, + title: z.string().trim().min(1).max(100).optional(), + message: z.string().max(2_000).optional(), + }), + z.object({ + version: z.literal(1).default(1), + kind: z.literal("set_property"), + propertyId: z.string().min(1), + value: z.unknown(), + }), +]); +export const contentHookEffectsSchema = z + .array(contentHookEffectSchema) + .min(1) + .max(10); + +const contentHookValueConditionSchema = z.object({ + propertyId: z.string().min(1), + operator: z.enum(["equals", "not_equals", "contains"]), + value: z.unknown().refine((value) => value !== undefined, { + message: "A comparison value is required.", + }), +}); + +const contentHookEmptyConditionSchema = z + .object({ + propertyId: z.string().min(1), + operator: z.enum(["is_empty", "is_not_empty"]), + }) + .strict(); + +export const contentHookConditionSchema = z.union([ + contentHookValueConditionSchema, + contentHookEmptyConditionSchema, +]); +export const contentHookConditionsSchema = z.object({ + mode: z.enum(["all", "any"]), + clauses: z.array(contentHookConditionSchema).min(1).max(10), +}); + +export const contentHookTimingSchema = z.discriminatedUnion("kind", [ + z.object({ kind: z.literal("immediate") }), + z.object({ + kind: z.enum(["delayed", "debounced", "escalation"]), + delayMinutes: z.number().int().min(1).max(10_080), + }), +]); + +export type ContentHookTrigger = z.infer; +export type ContentHookEffect = z.infer; +export type ContentHookTiming = z.infer; +export type ContentHookCondition = z.infer; +export type ContentHookConditions = z.infer; +export type ContentHookTriggerKind = ContentHookTrigger["kind"]; + +export interface ContentHookTriggerAvailability { + kind: ContentHookTriggerKind; + available: boolean; + reason?: string; +} + +const itemCreatedCoverage = contentMutationTriggerCoverage("item_created"); +const itemSubmittedCoverage = contentMutationTriggerCoverage("item_submitted"); +const propertyChangedCoverage = + contentMutationTriggerCoverage("property_changed"); + +export const contentHookTriggerAvailability = [ + { + kind: "item_created", + available: itemCreatedCoverage.available, + ...(!itemCreatedCoverage.available + ? { + reason: `Item creation is missing certified adapters for: ${itemCreatedCoverage.missingPaths.join(", ")}.`, + } + : {}), + }, + { + kind: "item_submitted", + available: itemSubmittedCoverage.available, + ...(!itemSubmittedCoverage.available + ? { + reason: `Item submission is missing certified adapters for: ${itemSubmittedCoverage.missingPaths.join(", ")}.`, + } + : {}), + }, + { + kind: "property_changed", + available: propertyChangedCoverage.available, + ...(!propertyChangedCoverage.available + ? { + reason: `Editable field changes are missing certified adapters for: ${propertyChangedCoverage.missingPaths.join(", ")}.`, + } + : {}), + }, + { kind: "builder_publication_confirmed", available: true }, +] as const satisfies readonly ContentHookTriggerAvailability[]; + +export function contentHookTriggerPolicy(kind: ContentHookTriggerKind) { + return contentHookTriggerAvailability.find((entry) => entry.kind === kind)!; +} + +function assertNever(value: never): never { + throw new Error(`Unsupported Content hook effect: ${JSON.stringify(value)}`); +} + +function effectPropertyIds(effect: ContentHookEffect): string[] { + switch (effect.kind) { + case "notify": + return [effect.recipientPersonPropertyId]; + case "set_property": + return [effect.propertyId]; + case "team_slack": + case "webhook": + return []; + default: + return assertNever(effect); + } +} + +export interface ContentHookConfig { + domain: "content"; + resourceId: string; + databaseId: string; + name: string; + trigger: ContentHookTrigger; + conditions?: ContentHookConditions; + effects: ContentHookEffect[]; + timing: ContentHookTiming; + createdBy: string; + deletedAt?: number; +} + +export interface ContentDefaultPersonHookConfig { + domain: "content"; + resourceId: string; + system: "default_person_notifications"; + databaseId: string; + name: string; +} + +export type ContentWorkflowHookConfig = + | ContentHookConfig + | ContentDefaultPersonHookConfig; + +export interface ContentDatabaseHook { + id: string; + databaseId: string; + name: string; + enabled: boolean; + trigger: ContentHookTrigger; + conditions?: ContentHookConditions; + effects: ContentHookEffect[]; + timing: ContentHookTiming; + /** Compatibility alias for older clients that authored one effect. */ + effect: ContentHookEffect; + createdBy: string; + createdAt: number; + updatedAt: number; +} + +function eventPatternForTrigger(trigger: ContentHookTrigger) { + if (trigger.kind === "item_created") return "content.database.item.created"; + if (trigger.kind === "item_submitted") { + return "content.database.item.submitted"; + } + if (trigger.kind === "builder_publication_confirmed") { + return "content.builder.publication.confirmed"; + } + return "content.database.property.changed"; +} + +function parseConfig(value: string): ContentWorkflowHookConfig | null { + try { + const parsed = JSON.parse(value) as Partial< + ContentHookConfig & ContentDefaultPersonHookConfig + > & { effect?: unknown }; + if ( + parsed.domain === "content" && + parsed.system === "default_person_notifications" && + typeof parsed.databaseId === "string" && + typeof parsed.name === "string" + ) { + return { + domain: "content", + resourceId: parsed.databaseId, + system: "default_person_notifications", + databaseId: parsed.databaseId, + name: parsed.name, + }; + } + if ( + parsed.domain !== "content" || + typeof parsed.databaseId !== "string" || + typeof parsed.name !== "string" || + typeof parsed.createdBy !== "string" + ) { + return null; + } + const trigger = contentHookTriggerSchema.safeParse(parsed.trigger); + const effects = contentHookEffectsSchema.safeParse( + Array.isArray(parsed.effects) + ? parsed.effects + : parsed.effect + ? [parsed.effect] + : [], + ); + const timing = contentHookTimingSchema.safeParse( + parsed.timing ?? { kind: "immediate" }, + ); + const conditions = contentHookConditionsSchema.safeParse(parsed.conditions); + if ( + !trigger.success || + !effects.success || + !timing.success || + (parsed.conditions !== undefined && !conditions.success) + ) { + return null; + } + return { + domain: "content", + resourceId: parsed.databaseId, + databaseId: parsed.databaseId, + name: parsed.name, + trigger: trigger.data, + ...(conditions.success ? { conditions: conditions.data } : {}), + effects: effects.data, + timing: timing.data, + createdBy: parsed.createdBy, + ...(typeof parsed.deletedAt === "number" + ? { deletedAt: parsed.deletedAt } + : {}), + }; + } catch { + return null; + } +} + +export async function requireContentDatabaseAccess( + databaseId: string, + role: "viewer" | "admin", +) { + const db = getDb(); + const [database] = await db + .select() + .from(schema.contentDatabases) + .where( + and( + eq(schema.contentDatabases.id, databaseId), + isNull(schema.contentDatabases.deletedAt), + ), + ); + if (!database) throw new Error(`Database "${databaseId}" not found.`); + await assertAccess("document", database.documentId, role); + return database; +} + +export async function requireContentDatabaseOwner(databaseId: string) { + const database = await requireContentDatabaseAccess(databaseId, "admin"); + if (getRequestUserEmail() !== database.ownerEmail) { + throw Object.assign( + new Error("Only the database owner can manage shared hooks and policy."), + { statusCode: 403 }, + ); + } + return database; +} + +export function assertContentDatabaseSchemaUnlocked(database: { + ownerEmail: string; + viewConfigJson?: string | null; +}) { + let schemaLocked = false; + try { + schemaLocked = + database.viewConfigJson != null && + (JSON.parse(database.viewConfigJson) as { schemaLocked?: unknown }) + .schemaLocked === true; + } catch { + schemaLocked = false; + } + if (schemaLocked && getRequestUserEmail() !== database.ownerEmail) { + throw Object.assign( + new Error("This database is locked. Only its owner can change schema."), + { statusCode: 403 }, + ); + } +} + +export async function contentHookHasCurrentAuthority(args: { + databaseId: string; + ownerEmail: string; +}) { + const [database] = await getDb() + .select({ ownerEmail: schema.contentDatabases.ownerEmail }) + .from(schema.contentDatabases) + .where( + and( + eq(schema.contentDatabases.id, args.databaseId), + isNull(schema.contentDatabases.deletedAt), + ), + ); + return database?.ownerEmail === args.ownerEmail; +} + +async function validateStableHookReferences(args: { + databaseId: string; + trigger: ContentHookTrigger; + conditions?: ContentHookConditions; + effects: ContentHookEffect[]; +}) { + const db = getDb(); + const propertyIds = new Set([ + ...args.effects.flatMap(effectPropertyIds), + ...(args.trigger.kind === "property_changed" + ? [args.trigger.propertyId] + : []), + ...(args.conditions?.clauses.map((condition) => condition.propertyId) ?? + []), + ]); + const definitions = await db + .select() + .from(schema.documentPropertyDefinitions) + .where(eq(schema.documentPropertyDefinitions.databaseId, args.databaseId)); + const byId = new Map( + definitions.map((definition) => [definition.id, definition]), + ); + for (const propertyId of propertyIds) { + if (!byId.has(propertyId)) { + throw new Error( + `Property "${propertyId}" does not belong to this database.`, + ); + } + } + for (const effect of args.effects) { + switch (effect.kind) { + case "notify": { + const recipient = byId.get(effect.recipientPersonPropertyId); + if (recipient?.type !== "person") { + throw new Error( + "Notification recipients must come from a Person property.", + ); + } + break; + } + case "set_property": { + const definition = byId.get(effect.propertyId); + const type = definition?.type as DocumentPropertyType | undefined; + if ( + !definition || + !type || + isComputedPropertyType(type) || + isBlocksPropertyType(type) + ) { + throw new Error( + "Deterministic hooks may set only editable, non-Blocks properties.", + ); + } + const normalized = JSON.parse(normalizedValueJson(type, effect.value)); + if (type === "select" || type === "status" || type === "multi_select") { + const optionIds = new Set( + (parsePropertyOptions(definition.optionsJson).options ?? []).map( + (option) => option.id, + ), + ); + const selectedIds = Array.isArray(normalized) + ? normalized + : normalized == null + ? [] + : [normalized]; + if ( + selectedIds.some( + (optionId) => + typeof optionId !== "string" || !optionIds.has(optionId), + ) + ) { + throw new Error( + `The set_property value must use stable option IDs from property "${effect.propertyId}".`, + ); + } + } + break; + } + case "team_slack": + case "webhook": + break; + default: + assertNever(effect); + } + } + for (const condition of args.conditions?.clauses ?? []) { + const definition = byId.get(condition.propertyId); + const type = definition?.type as DocumentPropertyType | undefined; + if ( + !definition || + !type || + isComputedPropertyType(type) || + isBlocksPropertyType(type) + ) { + throw new Error( + "Rule conditions may inspect only editable, non-Blocks properties.", + ); + } + } + if (args.trigger.kind !== "property_changed") return; + const property = byId.get(args.trigger.propertyId)!; + const propertyType = property.type as DocumentPropertyType; + if ( + isComputedPropertyType(propertyType) || + isBlocksPropertyType(propertyType) + ) { + throw new Error( + "Rules can observe only editable, non-Blocks property changes.", + ); + } + const optionIds = new Set( + (parsePropertyOptions(property.optionsJson).options ?? []).map( + (option) => option.id, + ), + ); + for (const optionId of [args.trigger.fromOptionId, args.trigger.toOptionId]) { + if (optionId && !optionIds.has(optionId)) { + throw new Error( + `Option "${optionId}" does not belong to property "${property.id}".`, + ); + } + } +} + +export async function listContentDatabaseHooks(databaseId: string) { + const db = getDb(); + const [database] = await db + .select({ ownerEmail: schema.contentDatabases.ownerEmail }) + .from(schema.contentDatabases) + .where(eq(schema.contentDatabases.id, databaseId)); + if (!database) return []; + const rows = await db + .select() + .from(workflowSubscriptions) + .where( + and( + eq(workflowSubscriptions.kind, "deterministic"), + eq(workflowSubscriptions.ownerEmail, database.ownerEmail), + ), + ); + return rows.flatMap((row) => { + const config = parseConfig(row.config); + if ( + !config || + "system" in config || + config.deletedAt || + config.databaseId !== databaseId + ) { + return []; + } + return [ + { + id: row.id, + databaseId, + name: config.name, + enabled: row.enabled, + trigger: config.trigger, + conditions: config.conditions, + effects: config.effects, + timing: config.timing, + effect: config.effects[0], + createdBy: config.createdBy, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + } satisfies ContentDatabaseHook, + ]; + }); +} + +export function contentDefaultPersonSubscriptionId(databaseId: string) { + return `content-default-person:${databaseId}`; +} + +export async function getContentDatabaseHook( + databaseId: string, + hookId: string, +) { + return (await listContentDatabaseHooks(databaseId)).find( + (hook) => hook.id === hookId, + ); +} + +export async function saveContentDatabaseHook(args: { + id?: string; + databaseId: string; + name: string; + enabled: boolean; + trigger: ContentHookTrigger; + conditions?: ContentHookConditions; + effects: ContentHookEffect[]; + timing?: ContentHookTiming; + createdBy?: string; +}) { + const database = await requireContentDatabaseOwner(args.databaseId); + const triggerPolicy = contentHookTriggerPolicy(args.trigger.kind); + if (args.enabled && !triggerPolicy.available) { + throw new Error( + `${args.trigger.kind} hooks cannot be enabled: ${triggerPolicy.reason}`, + ); + } + await validateStableHookReferences(args); + const id = args.id ?? nanoid(); + const createdBy = + args.createdBy ?? getRequestUserEmail() ?? database.ownerEmail; + await upsertWorkflowSubscription({ + id, + kind: "deterministic", + eventPattern: eventPatternForTrigger(args.trigger), + ownerEmail: database.ownerEmail, + orgId: database.orgId, + enabled: args.enabled, + config: { + domain: "content", + resourceId: args.databaseId, + databaseId: args.databaseId, + name: args.name, + trigger: args.trigger, + ...(args.conditions ? { conditions: args.conditions } : {}), + effects: args.effects, + timing: args.timing ?? { kind: "immediate" }, + createdBy, + }, + }); + const hook = await getContentDatabaseHook(args.databaseId, id); + if (!hook) throw new Error("The hook was saved but could not be reloaded."); + return hook; +} + +export async function deleteContentDatabaseHook( + databaseId: string, + hookId: string, +) { + await requireContentDatabaseOwner(databaseId); + const hook = await getContentDatabaseHook(databaseId, hookId); + if (!hook) throw new Error(`Hook "${hookId}" not found.`); + const database = await requireContentDatabaseAccess(databaseId, "admin"); + await upsertWorkflowSubscription({ + id: hook.id, + kind: "deterministic", + eventPattern: eventPatternForTrigger(hook.trigger), + ownerEmail: database.ownerEmail, + orgId: database.orgId, + enabled: false, + config: { + domain: "content", + resourceId: databaseId, + databaseId, + name: hook.name, + trigger: hook.trigger, + ...(hook.conditions ? { conditions: hook.conditions } : {}), + effects: hook.effects, + timing: hook.timing, + createdBy: hook.createdBy, + deletedAt: Date.now(), + }, + }); +} + +export function contentHookConfigFromJson(value: string) { + return parseConfig(value); +} diff --git a/templates/content/actions/_content-database-item-mutations.ts b/templates/content/actions/_content-database-item-mutations.ts new file mode 100644 index 0000000000..6228e38e02 --- /dev/null +++ b/templates/content/actions/_content-database-item-mutations.ts @@ -0,0 +1,381 @@ +import type { ActionRunContext } from "@agent-native/core/action"; +import { getRequestUserEmail } from "@agent-native/core/server/request-context"; +import { assertAccess } from "@agent-native/core/sharing"; +import { and, eq, inArray, isNull, sql } from "drizzle-orm"; + +import { getDb, schema } from "../server/db/index.js"; +import { + blocksStorageTarget, + isBlocksPropertyType, + parsePropertyOptions, + serializePropertyValue, + type DocumentPropertyType, + type DocumentPropertyValue, +} from "../shared/properties.js"; +import { assertAtomicSubmissionReady } from "./_content-database-validation.js"; +import { ensureDocumentFilesMembership } from "./_content-files.js"; +import { + appendContentWorkflowEvent, + wakeContentWorkflowEvent, +} from "./_content-workflow.js"; +import { + databaseItemsPositionScope, + documentsPositionScope, + withPositionLock, +} from "./_position-utils.js"; +import { nanoid, parseDatabaseViewConfig } from "./_property-utils.js"; + +type PropertyDefinition = + typeof schema.documentPropertyDefinitions.$inferSelect; + +export interface CommitContentDatabaseItemInput { + databaseId: string; + title?: string; + values: ReadonlyMap; + intent: "draft" | "submitted"; + formViewId?: string; + actionContext?: ActionRunContext; +} + +export interface CommitContentDatabaseItemResult { + databaseId: string; + itemId: string; + documentId: string; + workflowEventId: string | null; + verified: true; +} + +function partitionValues( + definitions: PropertyDefinition[], + values: ReadonlyMap, +) { + const definitionById = new Map( + definitions.map((definition) => [definition.id, definition]), + ); + for (const propertyId of values.keys()) { + if (!definitionById.has(propertyId)) { + throw new Error( + `Property "${propertyId}" does not belong to this database.`, + ); + } + } + const primaryBlocks = definitions.find((definition) => { + const type = definition.type as DocumentPropertyType; + return ( + isBlocksPropertyType(type) && + blocksStorageTarget(parsePropertyOptions(definition.optionsJson)) === + "document_body" + ); + }); + const standardValues = [...values.entries()].filter(([propertyId]) => { + const definition = definitionById.get(propertyId)!; + return !isBlocksPropertyType(definition.type as DocumentPropertyType); + }); + const additionalBlocks = [...values.entries()].filter(([propertyId]) => { + const definition = definitionById.get(propertyId)!; + return ( + isBlocksPropertyType(definition.type as DocumentPropertyType) && + propertyId !== primaryBlocks?.id + ); + }); + const primaryValue = primaryBlocks ? values.get(primaryBlocks.id) : undefined; + return { + documentContent: typeof primaryValue === "string" ? primaryValue : "", + standardValues, + additionalBlocks, + }; +} + +export async function commitContentDatabaseItem( + input: CommitContentDatabaseItemInput, +): Promise { + const db = getDb(); + const [database] = await db + .select() + .from(schema.contentDatabases) + .where( + and( + eq(schema.contentDatabases.id, input.databaseId), + isNull(schema.contentDatabases.deletedAt), + ), + ); + if (!database) throw new Error(`Database "${input.databaseId}" not found.`); + + const access = await assertAccess("document", database.documentId, "editor"); + const databaseDocument = access.resource; + if ( + database.spaceId && + databaseDocument.spaceId && + databaseDocument.spaceId !== database.spaceId + ) { + throw new Error( + `Database "${input.databaseId}" has inconsistent Content space`, + ); + } + const databaseSpaceId = + database.spaceId ?? (databaseDocument.spaceId as string | null); + if (!databaseSpaceId) { + throw new Error("Database does not belong to a Content space."); + } + + const definitions = await db + .select() + .from(schema.documentPropertyDefinitions) + .where( + and( + eq(schema.documentPropertyDefinitions.databaseId, input.databaseId), + eq(schema.documentPropertyDefinitions.ownerEmail, database.ownerEmail), + ), + ); + if (input.intent === "submitted") { + assertAtomicSubmissionReady({ + databaseId: input.databaseId, + config: parseDatabaseViewConfig(database.viewConfigJson), + definitions, + values: input.values, + }); + } + const { documentContent, standardValues, additionalBlocks } = partitionValues( + definitions, + input.values, + ); + const normalizedTitle = input.title?.trim() ?? ""; + const documentId = nanoid(); + const itemId = nanoid(); + const now = new Date().toISOString(); + const createdBy = getRequestUserEmail() ?? database.ownerEmail; + + let workflowEventId: string | null = null; + await withPositionLock( + documentsPositionScope(database.ownerEmail, database.documentId), + () => + withPositionLock( + databaseItemsPositionScope(input.databaseId), + async () => { + await db.transaction(async (tx) => { + const [maxDocumentPosition] = await tx + .select({ max: sql`COALESCE(MAX(position), -1)` }) + .from(schema.documents) + .where( + and( + eq(schema.documents.ownerEmail, database.ownerEmail), + eq(schema.documents.parentId, database.documentId), + ), + ); + const [maxItemPosition] = await tx + .select({ max: sql`COALESCE(MAX(position), -1)` }) + .from(schema.contentDatabaseItems) + .where( + eq(schema.contentDatabaseItems.databaseId, input.databaseId), + ); + const inheritedShares = await tx + .select({ + principalType: schema.documentShares.principalType, + principalId: schema.documentShares.principalId, + role: schema.documentShares.role, + }) + .from(schema.documentShares) + .where(eq(schema.documentShares.resourceId, database.documentId)); + + if (!database.spaceId) { + await tx + .update(schema.contentDatabases) + .set({ spaceId: databaseSpaceId, updatedAt: now }) + .where(eq(schema.contentDatabases.id, input.databaseId)); + } + if (!databaseDocument.spaceId) { + await tx + .update(schema.documents) + .set({ spaceId: databaseSpaceId, updatedAt: now }) + .where(eq(schema.documents.id, database.documentId)); + await ensureDocumentFilesMembership(tx, database.documentId, now); + } + + await tx.insert(schema.documents).values({ + id: documentId, + spaceId: databaseSpaceId, + ownerEmail: database.ownerEmail, + orgId: database.orgId, + parentId: database.documentId, + title: normalizedTitle, + content: documentContent, + icon: null, + position: (maxDocumentPosition?.max ?? -1) + 1, + isFavorite: 0, + hideFromSearch: databaseDocument.hideFromSearch ?? 0, + visibility: databaseDocument.visibility ?? "private", + createdAt: now, + updatedAt: now, + }); + await tx.insert(schema.contentDatabaseItems).values({ + id: itemId, + ownerEmail: database.ownerEmail, + orgId: database.orgId, + databaseId: input.databaseId, + documentId, + position: (maxItemPosition?.max ?? -1) + 1, + createdAt: now, + updatedAt: now, + }); + if (inheritedShares.length > 0) { + await tx.insert(schema.documentShares).values( + inheritedShares.map((share) => ({ + id: nanoid(), + resourceId: documentId, + principalType: share.principalType, + principalId: share.principalId, + role: share.role, + createdBy, + createdAt: now, + })), + ); + } + if (standardValues.length > 0) { + await tx.insert(schema.documentPropertyValues).values( + standardValues.map(([propertyId, value]) => ({ + id: nanoid(), + ownerEmail: database.ownerEmail, + documentId, + propertyId, + valueJson: serializePropertyValue(value), + createdAt: now, + updatedAt: now, + })), + ); + } + if (additionalBlocks.length > 0) { + await tx.insert(schema.documentBlockFieldContents).values( + additionalBlocks.map(([propertyId, value]) => ({ + id: nanoid(), + ownerEmail: database.ownerEmail, + documentId, + propertyId, + content: typeof value === "string" ? value : "", + createdAt: now, + updatedAt: now, + })), + ); + } + await ensureDocumentFilesMembership(tx, documentId, now); + + if (input.intent === "submitted") { + workflowEventId = await appendContentWorkflowEvent(tx, { + topic: "content.database.item.submitted", + subjectType: "content_database_item", + subjectId: documentId, + databaseId: input.databaseId, + documentId, + ownerEmail: database.ownerEmail, + orgId: database.orgId, + occurredAt: now, + actionContext: input.actionContext, + payload: { + itemId, + ...(input.formViewId ? { formViewId: input.formViewId } : {}), + title: normalizedTitle, + propertyValues: Object.fromEntries(standardValues), + personPropertyIds: definitions + .filter((definition) => definition.type === "person") + .map((definition) => definition.id), + }, + }); + } + + const [savedDocument] = await tx + .select({ + title: schema.documents.title, + content: schema.documents.content, + }) + .from(schema.documents) + .where(eq(schema.documents.id, documentId)); + const [savedItem] = await tx + .select({ id: schema.contentDatabaseItems.id }) + .from(schema.contentDatabaseItems) + .where( + and( + eq(schema.contentDatabaseItems.id, itemId), + eq(schema.contentDatabaseItems.documentId, documentId), + eq(schema.contentDatabaseItems.databaseId, input.databaseId), + ), + ); + const savedValues = + standardValues.length === 0 + ? [] + : await tx + .select({ + propertyId: schema.documentPropertyValues.propertyId, + valueJson: schema.documentPropertyValues.valueJson, + }) + .from(schema.documentPropertyValues) + .where( + and( + eq( + schema.documentPropertyValues.documentId, + documentId, + ), + inArray( + schema.documentPropertyValues.propertyId, + standardValues.map(([propertyId]) => propertyId), + ), + ), + ); + const savedValueByPropertyId = new Map( + savedValues.map((value) => [value.propertyId, value.valueJson]), + ); + const savedBlocks = + additionalBlocks.length === 0 + ? [] + : await tx + .select({ + propertyId: schema.documentBlockFieldContents.propertyId, + content: schema.documentBlockFieldContents.content, + }) + .from(schema.documentBlockFieldContents) + .where( + and( + eq( + schema.documentBlockFieldContents.documentId, + documentId, + ), + inArray( + schema.documentBlockFieldContents.propertyId, + additionalBlocks.map(([propertyId]) => propertyId), + ), + ), + ); + const savedBlockByPropertyId = new Map( + savedBlocks.map((value) => [value.propertyId, value.content]), + ); + const verified = + savedDocument?.title === normalizedTitle && + savedDocument.content === documentContent && + Boolean(savedItem) && + standardValues.every( + ([propertyId, value]) => + savedValueByPropertyId.get(propertyId) === + serializePropertyValue(value), + ) && + additionalBlocks.every( + ([propertyId, value]) => + savedBlockByPropertyId.get(propertyId) === + (typeof value === "string" ? value : ""), + ); + if (!verified) { + throw new Error( + "The database item mutation could not be verified; no row was saved.", + ); + } + }); + }, + ), + ); + + if (workflowEventId) wakeContentWorkflowEvent(workflowEventId); + return { + databaseId: input.databaseId, + itemId, + documentId, + workflowEventId, + verified: true, + }; +} diff --git a/templates/content/actions/_content-database-validation.ts b/templates/content/actions/_content-database-validation.ts new file mode 100644 index 0000000000..17d9127d11 --- /dev/null +++ b/templates/content/actions/_content-database-validation.ts @@ -0,0 +1,234 @@ +import { eq } from "drizzle-orm"; + +import { getDb, schema } from "../server/db/index.js"; +import type { + ContentDatabaseValidationConfig, + ContentDatabaseViewConfig, +} from "../shared/api.js"; +import { + blocksStorageTarget, + isBlocksPropertyType, + isEmptyPropertyValue, + parsePropertyOptions, + type DocumentPropertyType, + type DocumentPropertyValue, +} from "../shared/properties.js"; +import { parseDatabaseViewConfig } from "./_property-utils.js"; + +type PropertyDefinition = + typeof schema.documentPropertyDefinitions.$inferSelect; + +export interface MissingContentProperty { + propertyId: string; + name: string; +} + +export class ContentReadinessError extends Error { + readonly code = "CONTENT_READINESS_REQUIRED"; + readonly statusCode = 409; + + constructor( + message: string, + readonly details: { + databaseId: string; + documentId?: string; + phase: "submission" | "status_transition"; + statusPropertyId?: string; + statusOptionId?: string; + missingFields: MissingContentProperty[]; + }, + ) { + super(message); + this.name = "ContentReadinessError"; + } +} + +export function missingRequiredContentProperties(args: { + definitions: PropertyDefinition[]; + requiredPropertyIds: string[]; + values: ReadonlyMap; +}) { + const byId = new Map( + args.definitions.map((definition) => [definition.id, definition]), + ); + return args.requiredPropertyIds.flatMap((propertyId) => { + const definition = byId.get(propertyId); + if (!definition) { + return [{ propertyId, name: `Missing property (${propertyId})` }]; + } + return isEmptyPropertyValue(args.values.get(propertyId) ?? null) + ? [{ propertyId, name: definition.name }] + : []; + }); +} + +export function assertAtomicSubmissionReady(args: { + databaseId: string; + config: ContentDatabaseViewConfig; + definitions: PropertyDefinition[]; + values: ReadonlyMap; +}) { + const missingFields = missingRequiredContentProperties({ + definitions: args.definitions, + requiredPropertyIds: args.config.validation?.requiredForSubmission ?? [], + values: args.values, + }); + if (missingFields.length === 0) return; + throw new ContentReadinessError( + `Required submission fields are missing: ${missingFields + .map((field) => field.name) + .join(", ")}.`, + { + databaseId: args.databaseId, + phase: "submission", + missingFields, + }, + ); +} + +export async function assertContentStatusTransitionReady(args: { + databaseId: string; + documentId: string; + statusPropertyId: string; + statusOptionId: string | null; +}) { + if (!args.statusOptionId) return; + const db = getDb(); + const [database] = await db + .select({ viewConfigJson: schema.contentDatabases.viewConfigJson }) + .from(schema.contentDatabases) + .where(eq(schema.contentDatabases.id, args.databaseId)); + if (!database) return; + const config = parseDatabaseViewConfig(database.viewConfigJson); + const requirement = config.validation?.statusRequirements.find( + (candidate) => + candidate.statusPropertyId === args.statusPropertyId && + candidate.statusOptionId === args.statusOptionId, + ); + if (!requirement) return; + + const definitions = await db + .select() + .from(schema.documentPropertyDefinitions) + .where(eq(schema.documentPropertyDefinitions.databaseId, args.databaseId)); + const [document] = await db + .select({ content: schema.documents.content }) + .from(schema.documents) + .where(eq(schema.documents.id, args.documentId)); + const values = new Map(); + const propertyValues = await db + .select({ + propertyId: schema.documentPropertyValues.propertyId, + valueJson: schema.documentPropertyValues.valueJson, + }) + .from(schema.documentPropertyValues) + .where(eq(schema.documentPropertyValues.documentId, args.documentId)); + for (const value of propertyValues) { + values.set(value.propertyId, JSON.parse(value.valueJson)); + } + const blockValues = await db + .select({ + propertyId: schema.documentBlockFieldContents.propertyId, + content: schema.documentBlockFieldContents.content, + }) + .from(schema.documentBlockFieldContents) + .where(eq(schema.documentBlockFieldContents.documentId, args.documentId)); + for (const value of blockValues) values.set(value.propertyId, value.content); + for (const definition of definitions) { + const type = definition.type as DocumentPropertyType; + if ( + isBlocksPropertyType(type) && + blocksStorageTarget(parsePropertyOptions(definition.optionsJson)) === + "document_body" + ) { + values.set(definition.id, document?.content ?? ""); + } + } + if (values.get(args.statusPropertyId) === args.statusOptionId) return; + values.set(args.statusPropertyId, args.statusOptionId); + + const missingFields = missingRequiredContentProperties({ + definitions, + requiredPropertyIds: requirement.requiredPropertyIds, + values, + }); + if (missingFields.length === 0) return; + throw new ContentReadinessError( + `Status cannot change until these fields are filled: ${missingFields + .map((field) => field.name) + .join(", ")}.`, + { + databaseId: args.databaseId, + documentId: args.documentId, + phase: "status_transition", + statusPropertyId: args.statusPropertyId, + statusOptionId: args.statusOptionId, + missingFields, + }, + ); +} + +export async function validateContentDatabaseValidationConfig( + databaseId: string, + config: ContentDatabaseValidationConfig, +) { + const definitions = await getDb() + .select() + .from(schema.documentPropertyDefinitions) + .where(eq(schema.documentPropertyDefinitions.databaseId, databaseId)); + const byId = new Map( + definitions.map((definition) => [definition.id, definition]), + ); + const referencedPropertyIds = new Set([ + ...config.requiredForSubmission, + ...config.statusRequirements.flatMap((requirement) => [ + requirement.statusPropertyId, + ...requirement.requiredPropertyIds, + ]), + ]); + for (const propertyId of referencedPropertyIds) { + const property = byId.get(propertyId); + if (!property) { + throw new Error( + `Property "${propertyId}" does not belong to this database.`, + ); + } + if ( + property.type === "formula" || + property.type === "rollup" || + property.type === "id" || + property.type === "created_time" || + property.type === "created_by" || + property.type === "last_edited_time" || + property.type === "last_edited_by" + ) { + throw new Error( + `Computed property "${property.name}" cannot be required readiness evidence.`, + ); + } + } + const seen = new Set(); + for (const requirement of config.statusRequirements) { + const key = `${requirement.statusPropertyId}:${requirement.statusOptionId}`; + if (seen.has(key)) { + throw new Error(`Duplicate status readiness rule "${key}".`); + } + seen.add(key); + const statusProperty = byId.get(requirement.statusPropertyId)!; + if (statusProperty.type !== "status") { + throw new Error( + `Property "${statusProperty.name}" is not a Status property.`, + ); + } + const optionIds = new Set( + (parsePropertyOptions(statusProperty.optionsJson).options ?? []).map( + (option) => option.id, + ), + ); + if (!optionIds.has(requirement.statusOptionId)) { + throw new Error( + `Option "${requirement.statusOptionId}" does not belong to status property "${statusProperty.id}".`, + ); + } + } +} diff --git a/templates/content/actions/_content-default-person-rule.ts b/templates/content/actions/_content-default-person-rule.ts new file mode 100644 index 0000000000..16b374012e --- /dev/null +++ b/templates/content/actions/_content-default-person-rule.ts @@ -0,0 +1,150 @@ +import { + ensureVirtualWorkflowProviderEvaluationStart, + registerVirtualWorkflowSubscriptionProvider, + type WorkflowEvent, + type WorkflowSubscriptionInput, +} from "@agent-native/core/workflow"; +import { and, desc, eq, isNull, lt } from "drizzle-orm"; + +import { getDb, schema } from "../server/db/index.js"; +import { contentDefaultPersonSubscriptionId } from "./_content-database-hooks.js"; + +const PROVIDER_ID = "content.default-person-notifications.v1"; +export const CONTENT_DEFAULT_PERSON_POLICY_KEY = "default_person_notifications"; + +export function contentDefaultPersonSubscriptionInput(input: { + databaseId: string; + ownerEmail: string; + orgId?: string | null; + enabled: boolean; +}): WorkflowSubscriptionInput { + return { + id: contentDefaultPersonSubscriptionId(input.databaseId), + kind: "deterministic", + eventPattern: "content.database.*", + ownerEmail: input.ownerEmail, + orgId: input.orgId ?? null, + enabled: input.enabled, + config: { + domain: "content", + resourceId: input.databaseId, + system: "default_person_notifications", + databaseId: input.databaseId, + name: "Content mention", + policy: { + enabled: input.enabled, + source: "database_policy", + ...(input.enabled ? {} : { disabledReason: "owner_disabled" }), + }, + }, + }; +} + +function hasNewPersonRecipients(event: WorkflowEvent): boolean { + if ( + event.topic === "content.database.property.changed" && + event.payload.propertyType === "person" + ) { + const before = new Set( + Array.isArray(event.payload.beforeValue) + ? event.payload.beforeValue.filter( + (value): value is string => + typeof value === "string" && Boolean(value), + ) + : [], + ); + return ( + Array.isArray(event.payload.afterValue) && + event.payload.afterValue.some( + (value) => + typeof value === "string" && Boolean(value) && !before.has(value), + ) + ); + } + if (event.topic !== "content.database.item.submitted") return false; + const personPropertyIds = Array.isArray(event.payload.personPropertyIds) + ? event.payload.personPropertyIds.filter( + (value): value is string => typeof value === "string", + ) + : []; + const propertyValues = + event.payload.propertyValues && + typeof event.payload.propertyValues === "object" && + !Array.isArray(event.payload.propertyValues) + ? (event.payload.propertyValues as Record) + : {}; + return personPropertyIds.some( + (propertyId) => + Array.isArray(propertyValues[propertyId]) && + propertyValues[propertyId].some( + (value) => typeof value === "string" && Boolean(value), + ), + ); +} + +export async function registerContentDefaultPersonVirtualRule(): Promise { + const evaluationStartSequence = + await ensureVirtualWorkflowProviderEvaluationStart(PROVIDER_ID); + registerVirtualWorkflowSubscriptionProvider({ + id: PROVIDER_ID, + evaluationStartSequence, + async subscriptionsForEvent(event) { + const databaseId = + typeof event.payload.databaseId === "string" + ? event.payload.databaseId + : ""; + if (!databaseId || !hasNewPersonRecipients(event)) return []; + const [database] = await getDb() + .select({ id: schema.contentDatabases.id }) + .from(schema.contentDatabases) + .where( + and( + eq(schema.contentDatabases.id, databaseId), + eq(schema.contentDatabases.ownerEmail, event.ownerEmail), + event.orgId == null + ? isNull(schema.contentDatabases.orgId) + : eq(schema.contentDatabases.orgId, event.orgId), + ), + ); + if (!database) return []; + const [policy] = await getDb() + .select({ + version: schema.contentDatabasePolicies.version, + enabled: schema.contentDatabasePolicies.enabled, + }) + .from(schema.contentDatabasePolicies) + .where( + and( + eq(schema.contentDatabasePolicies.databaseId, databaseId), + eq( + schema.contentDatabasePolicies.policyKey, + CONTENT_DEFAULT_PERSON_POLICY_KEY, + ), + eq(schema.contentDatabasePolicies.ownerEmail, event.ownerEmail), + eq(schema.contentDatabasePolicies.orgId, event.orgId ?? ""), + lt( + schema.contentDatabasePolicies.activeAfterSequence, + event.eventSequence, + ), + ), + ) + .orderBy( + desc(schema.contentDatabasePolicies.activeAfterSequence), + desc(schema.contentDatabasePolicies.version), + ) + .limit(1); + const enabled = policy?.enabled ?? true; + return [ + { + ...contentDefaultPersonSubscriptionInput({ + databaseId, + ownerEmail: event.ownerEmail, + orgId: event.orgId, + enabled, + }), + version: policy?.version ?? 1, + }, + ]; + }, + }); +} diff --git a/templates/content/actions/_content-hook-execution.ts b/templates/content/actions/_content-hook-execution.ts new file mode 100644 index 0000000000..92616f1874 --- /dev/null +++ b/templates/content/actions/_content-hook-execution.ts @@ -0,0 +1,1175 @@ +import { + notifyPersonalWithDelivery, + notifyWithDelivery, +} from "@agent-native/core/notifications"; +import { runWithRequestContext } from "@agent-native/core/server"; +import { resolveAccess } from "@agent-native/core/sharing"; +import type { + ClaimedWorkflowExecution, + ClaimedScheduledWork, + WorkflowEvent, + WorkflowSubscription, +} from "@agent-native/core/workflow"; +import { + claimWorkflowEffectRetry, + finalizeWorkflowEffect, + getWorkflowSubscription, + recordWorkflowEffect, + scheduleWorkflowWork, +} from "@agent-native/core/workflow"; +import { and, eq, isNull } from "drizzle-orm"; + +import { getDb, schema } from "../server/db/index.js"; +import { + contentHookHasCurrentAuthority, + contentHookConfigFromJson, + type ContentHookConditions, + type ContentHookConfig, + type ContentDatabaseHook, +} from "./_content-database-hooks.js"; +import { resolveContentNotificationPreference } from "./_content-notification-preferences.js"; +import { runWithContentWorkflowCausality } from "./_content-workflow.js"; +import { setDocumentPropertyValue } from "./set-document-property.js"; + +const MAX_CONTENT_HOOK_CHAIN_DEPTH = 8; + +function assertNever(value: never): never { + throw new Error(`Unsupported Content hook effect: ${JSON.stringify(value)}`); +} + +function propertyValuesFromEvent(event: WorkflowEvent) { + const value = event.payload.propertyValues; + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : {}; +} + +function stableJson(value: unknown): string { + if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`; + if (value && typeof value === "object") { + return `{${Object.entries(value as Record) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, entry]) => `${JSON.stringify(key)}:${stableJson(entry)}`) + .join(",")}}`; + } + return JSON.stringify(value) ?? "undefined"; +} + +function isEmptyConditionValue(value: unknown) { + return ( + value == null || + value === "" || + (Array.isArray(value) && value.length === 0) + ); +} + +export function contentHookConditionsMatch( + propertyValues: Record, + conditions?: ContentHookConditions, +) { + if (!conditions) return true; + const matches = conditions.clauses.map((condition) => { + const current = propertyValues[condition.propertyId]; + switch (condition.operator) { + case "equals": + return stableJson(current) === stableJson(condition.value); + case "not_equals": + return stableJson(current) !== stableJson(condition.value); + case "contains": + return typeof current === "string" && + typeof condition.value === "string" + ? current.includes(condition.value) + : Array.isArray(current) + ? current.some( + (entry) => stableJson(entry) === stableJson(condition.value), + ) + : false; + case "is_empty": + return isEmptyConditionValue(current); + case "is_not_empty": + return !isEmptyConditionValue(current); + default: + return assertNever(condition); + } + }); + return conditions.mode === "all" + ? matches.every(Boolean) + : matches.some(Boolean); +} + +export function matchesContentDatabaseHook( + event: WorkflowEvent, + hook: Pick, +) { + if (event.payload.databaseId !== hook.databaseId) return false; + if ( + !contentHookConditionsMatch(propertyValuesFromEvent(event), hook.conditions) + ) { + return false; + } + if (hook.trigger.kind === "item_created") { + return event.topic === "content.database.item.created"; + } + if (hook.trigger.kind === "item_submitted") { + return event.topic === "content.database.item.submitted"; + } + if (hook.trigger.kind === "builder_publication_confirmed") { + return ( + event.topic === "content.builder.publication.confirmed" && + (hook.trigger.publicationAction == null || + event.payload.effect === hook.trigger.publicationAction) + ); + } + if (event.topic !== "content.database.property.changed") return false; + if (event.payload.propertyId !== hook.trigger.propertyId) return false; + if ( + hook.trigger.fromOptionId !== undefined && + hook.trigger.fromOptionId !== event.payload.beforeValue + ) { + return false; + } + if ( + hook.trigger.toOptionId !== undefined && + hook.trigger.toOptionId !== event.payload.afterValue + ) { + return false; + } + return true; +} + +export function prepareContentNotificationEffect(args: { + event: WorkflowEvent; + subscription: WorkflowSubscription; +}) { + const config = contentHookConfigFromJson( + JSON.stringify(args.subscription.config), + ); + if (!config) return null; + if ("system" in config) { + if (args.event.payload.databaseId !== config.databaseId) return null; + let recipients: string[] = []; + if ( + args.event.topic === "content.database.property.changed" && + args.event.payload.propertyType === "person" + ) { + const before = new Set( + Array.isArray(args.event.payload.beforeValue) + ? args.event.payload.beforeValue.filter( + (value): value is string => + typeof value === "string" && value.length > 0, + ) + : [], + ); + recipients = Array.isArray(args.event.payload.afterValue) + ? args.event.payload.afterValue.filter( + (value): value is string => + typeof value === "string" && + value.length > 0 && + !before.has(value), + ) + : []; + } else if ( + args.event.topic === "content.database.item.created" || + args.event.topic === "content.database.item.submitted" + ) { + const personPropertyIds = Array.isArray( + args.event.payload.personPropertyIds, + ) + ? args.event.payload.personPropertyIds.filter( + (value): value is string => typeof value === "string", + ) + : []; + const values = propertyValuesFromEvent(args.event); + recipients = personPropertyIds.flatMap((propertyId) => { + const value = values[propertyId]; + return Array.isArray(value) + ? value.filter( + (recipient): recipient is string => + typeof recipient === "string" && recipient.length > 0, + ) + : []; + }); + } + recipients = [...new Set(recipients)]; + if (recipients.length === 0) return null; + return { + kind: "notification" as const, + idempotencyKey: `${args.event.id}:${args.subscription.id}:default`, + payload: { + recipients, + title: config.name, + message: "You were added to a Content database item.", + resource: { + type: "content_database_item", + id: args.event.subjectId, + urlPath: `/page/${args.event.subjectId}`, + }, + databaseId: config.databaseId, + eventId: args.event.id, + subscriptionId: args.subscription.id, + }, + }; + } + const hook = { + databaseId: config.databaseId, + trigger: config.trigger, + conditions: config.conditions, + }; + if (!matchesContentDatabaseHook(args.event, hook)) return null; + const effect = config.effects.find( + (candidate) => candidate.kind === "notify", + ); + if (!effect || effect.kind !== "notify") return null; + const rawRecipients = propertyValuesFromEvent(args.event)[ + effect.recipientPersonPropertyId + ]; + const recipients = Array.isArray(rawRecipients) + ? [ + ...new Set( + rawRecipients.filter( + (recipient): recipient is string => + typeof recipient === "string" && recipient.length > 0, + ), + ), + ] + : typeof rawRecipients === "string" && rawRecipients + ? [rawRecipients] + : []; + if (recipients.length === 0) return null; + return { + kind: "notification" as const, + idempotencyKey: `${args.event.id}:${args.subscription.id}:0`, + payload: { + recipients, + title: config.name, + message: + effect.message ?? + `Content database item ${args.event.subjectId} needs your attention.`, + resource: { + type: "content_database_item", + id: args.event.subjectId, + urlPath: `/page/${args.event.subjectId}`, + }, + databaseId: config.databaseId, + eventId: args.event.id, + subscriptionId: args.subscription.id, + }, + }; +} + +export function previewContentDatabaseHook(args: { + event: WorkflowEvent; + subscription: WorkflowSubscription; +}) { + const config = contentHookConfigFromJson( + JSON.stringify(args.subscription.config), + ); + if (!config || "system" in config) return null; + const matched = matchesContentDatabaseHook(args.event, { + databaseId: config.databaseId, + trigger: config.trigger, + conditions: config.conditions, + }); + const values = propertyValuesFromEvent(args.event); + return { + matched, + event: { + id: args.event.id, + topic: args.event.topic, + subjectId: args.event.subjectId, + actorContext: args.event.actorContext, + causalEventId: args.event.causalEventId, + }, + effects: config.effects.map((effect, index) => { + switch (effect.kind) { + case "notify": { + const rawRecipients = values[effect.recipientPersonPropertyId]; + const recipients = Array.isArray(rawRecipients) + ? [ + ...new Set( + rawRecipients.filter( + (recipient): recipient is string => + typeof recipient === "string" && recipient.length > 0, + ), + ), + ] + : typeof rawRecipients === "string" && rawRecipients + ? [rawRecipients] + : []; + return { + index, + kind: effect.kind, + wouldAttempt: matched && recipients.length > 0, + recipients, + message: + effect.message ?? + `Content database item ${args.event.subjectId} needs your attention.`, + }; + } + case "set_property": + return { + index, + kind: effect.kind, + wouldAttempt: matched, + propertyId: effect.propertyId, + value: effect.value, + }; + case "team_slack": + return { + index, + kind: effect.kind, + wouldAttempt: matched, + destinationKeyNames: [effect.webhookKey], + title: effect.title ?? config.name, + message: + effect.message ?? + `Content database item ${args.event.subjectId} changed.`, + }; + case "webhook": + return { + index, + kind: effect.kind, + wouldAttempt: matched, + destinationKeyNames: [effect.urlKey, effect.signatureKey], + title: effect.title ?? config.name, + message: + effect.message ?? + `Content database item ${args.event.subjectId} changed.`, + }; + default: + return assertNever(effect); + } + }), + }; +} + +export async function executeContentDatabaseHook( + claim: ClaimedWorkflowExecution, +) { + return runWithRequestContext( + { + userEmail: claim.subscription.ownerEmail, + orgId: claim.subscription.orgId ?? undefined, + }, + () => executeContentDatabaseHookInContext(claim), + ); +} + +async function executeContentDatabaseHookInContext( + claim: ClaimedWorkflowExecution, +) { + const config = contentHookConfigFromJson( + JSON.stringify(claim.subscription.config), + ); + if (!config) { + return { + status: "failed" as const, + errorMessage: "Content hook configuration is invalid.", + }; + } + if ( + !(await contentHookHasCurrentAuthority({ + databaseId: config.databaseId, + ownerEmail: claim.subscription.ownerEmail, + })) + ) { + return { + status: "failed" as const, + errorMessage: + "The database hook owner no longer has current authority over this database.", + }; + } + if ("system" in config) { + const prepared = prepareContentNotificationEffect({ + event: claim.event, + subscription: claim.subscription, + }); + return prepared + ? executePreparedNotification(claim, prepared) + : { status: "succeeded" as const }; + } + if ( + !matchesContentDatabaseHook(claim.event, { + databaseId: config.databaseId, + trigger: config.trigger, + conditions: config.conditions, + }) + ) { + return { status: "succeeded" as const }; + } + + if (config.timing.kind === "delayed" || config.timing.kind === "debounced") { + await scheduleContentHookTiming({ + claim, + timingKind: config.timing.kind, + delayMinutes: config.timing.delayMinutes, + stage: config.timing.kind, + }); + return { status: "succeeded" as const }; + } + const immediate = await executeContentHookEffects(claim, config, "initial"); + if (immediate.status === "succeeded" && config.timing.kind === "escalation") { + await scheduleContentHookTiming({ + claim, + timingKind: "escalation", + delayMinutes: config.timing.delayMinutes, + stage: "escalation", + }); + } + return immediate; +} + +async function executeContentHookEffects( + claim: ClaimedWorkflowExecution, + config: ContentHookConfig, + stage: "initial" | "delayed" | "debounced" | "escalation", +) { + for (const [index, effect] of config.effects.entries()) { + if (effect.kind === "notify") { + const rawRecipients = propertyValuesFromEvent(claim.event)[ + effect.recipientPersonPropertyId + ]; + const recipients = Array.isArray(rawRecipients) + ? [ + ...new Set( + rawRecipients.filter( + (recipient): recipient is string => + typeof recipient === "string" && recipient.length > 0, + ), + ), + ] + : typeof rawRecipients === "string" && rawRecipients + ? [rawRecipients] + : []; + if (recipients.length === 0) continue; + const outcome = await executePreparedNotification(claim, { + kind: "notification", + idempotencyKey: timedEffectKey( + claim.event.id, + claim.subscription.id, + index, + stage, + ), + payload: { + recipients, + title: config.name, + message: + effect.message ?? + `Content database item ${claim.event.subjectId} needs your attention.`, + resource: { + type: "content_database_item", + id: claim.event.subjectId, + urlPath: `/page/${claim.event.subjectId}`, + }, + databaseId: config.databaseId, + eventId: claim.event.id, + subscriptionId: claim.subscription.id, + }, + }); + if (outcome.status !== "succeeded") return outcome; + continue; + } + + if (effect.kind === "set_property") { + const outcome = await executePropertyMutationEffect( + claim, + effect, + index, + stage, + ); + if (outcome.status !== "succeeded") return outcome; + continue; + } + + if (effect.kind === "team_slack" || effect.kind === "webhook") { + const outcome = await executeSharedDestinationEffect( + claim, + effect, + index, + config.name, + stage, + ); + if (outcome.status !== "succeeded") return outcome; + continue; + } + assertNever(effect); + } + return { status: "succeeded" as const }; +} + +function mutationChain(claim: ClaimedWorkflowExecution) { + const actorLineage = claim.event.actorContext.lineage; + const lineage = + actorLineage && + typeof actorLineage === "object" && + !Array.isArray(actorLineage) + ? (actorLineage as Record) + : {}; + const subscriptionPath = Array.isArray(lineage.subscriptionPath) + ? lineage.subscriptionPath.filter( + (value): value is string => typeof value === "string", + ) + : []; + const recordedDepth = lineage.chainDepth; + const chainDepth = + typeof recordedDepth === "number" && + Number.isSafeInteger(recordedDepth) && + recordedDepth >= 0 + ? recordedDepth + : subscriptionPath.length; + if (subscriptionPath.includes(claim.subscription.id)) { + return { + stopped: "cycle_detected" as const, + chainDepth, + subscriptionPath, + }; + } + if (chainDepth >= MAX_CONTENT_HOOK_CHAIN_DEPTH) { + return { + stopped: "max_chain_depth_exceeded" as const, + chainDepth, + subscriptionPath, + }; + } + return { + stopped: null, + chainDepth: chainDepth + 1, + subscriptionPath: [...subscriptionPath, claim.subscription.id], + }; +} + +function originalInitiator(claim: ClaimedWorkflowExecution) { + const initiator = claim.event.actorContext.initiator; + if (!initiator || typeof initiator !== "object" || Array.isArray(initiator)) { + return undefined; + } + const value = initiator as Record; + if ( + (value.kind !== "human" && value.kind !== "system") || + typeof value.id !== "string" + ) { + return undefined; + } + return { + kind: value.kind as "human" | "system", + id: value.id, + }; +} + +async function executePropertyMutationEffect( + claim: ClaimedWorkflowExecution, + effect: { + version: 1; + kind: "set_property"; + propertyId: string; + value: unknown; + }, + index: number, + stage: "initial" | "delayed" | "debounced" | "escalation", +) { + const reserved = await recordWorkflowEffect({ + executionId: claim.id, + kind: effect.kind, + idempotencyKey: timedEffectKey( + claim.event.id, + claim.subscription.id, + index, + stage, + ), + }); + if (!reserved.created && isSettledEffect(reserved.effect.status)) { + return { status: "succeeded" as const }; + } + if (!reserved.created) { + if ( + reserved.effect.status !== "failed" || + !(await claimWorkflowEffectRetry({ effectId: reserved.effect.id })) + ) { + return { + status: "unknown" as const, + errorMessage: + "The property mutation was already reserved; refusing a duplicate write.", + }; + } + } + + const chain = mutationChain(claim); + if (chain.stopped) { + await finalizeWorkflowEffect({ + effectId: reserved.effect.id, + status: "suppressed", + result: { + outcome: chain.stopped, + chainDepth: chain.chainDepth, + subscriptionPath: chain.subscriptionPath, + stoppedSubscriptionId: claim.subscription.id, + }, + }); + return { status: "succeeded" as const }; + } + + try { + await runWithRequestContext( + { + userEmail: claim.subscription.ownerEmail, + orgId: claim.subscription.orgId ?? undefined, + }, + () => + runWithContentWorkflowCausality( + { + causalEventId: claim.event.id, + parentExecutionId: claim.id, + parentSubscriptionId: claim.subscription.id, + chainDepth: chain.chainDepth, + subscriptionPath: chain.subscriptionPath, + initiator: originalInitiator(claim), + }, + () => + setDocumentPropertyValue( + { + documentId: claim.event.subjectId, + propertyId: effect.propertyId, + value: effect.value, + }, + { + caller: "tool", + userEmail: claim.subscription.ownerEmail, + }, + ), + ), + ); + await finalizeWorkflowEffect({ + effectId: reserved.effect.id, + status: "delivered", + result: { + outcome: "property_set", + documentId: claim.event.subjectId, + propertyId: effect.propertyId, + chainDepth: chain.chainDepth, + subscriptionPath: chain.subscriptionPath, + }, + }); + return { status: "succeeded" as const }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + await finalizeWorkflowEffect({ + effectId: reserved.effect.id, + status: "failed", + result: { + outcome: "property_set_failed", + documentId: claim.event.subjectId, + propertyId: effect.propertyId, + }, + errorMessage, + }); + return { status: "failed" as const, errorMessage }; + } +} + +function timedEffectKey( + eventId: string, + subscriptionId: string, + index: number, + stage: "initial" | "delayed" | "debounced" | "escalation", +) { + const base = `${eventId}:${subscriptionId}:${index}`; + return stage === "initial" ? base : `${base}:${stage}`; +} + +async function recordHookSuppression( + claim: ClaimedWorkflowExecution, + reason: "evaluator_paused" | "effects_paused" | "condition_no_longer_matches", + stage: string, +) { + const reserved = await recordWorkflowEffect({ + executionId: claim.id, + kind: "content_hook_control", + idempotencyKey: `${claim.event.id}:${claim.subscription.id}:control:${stage}:${reason}`, + }); + await finalizeWorkflowEffect({ + effectId: reserved.effect.id, + status: + reason === "condition_no_longer_matches" ? "coalesced" : "suppressed", + result: { reason, stage }, + }); +} + +function scheduledClaimPayload( + claim: ClaimedWorkflowExecution, + stage: "delayed" | "debounced" | "escalation", +) { + return { + executionId: claim.id, + eventId: claim.eventId, + subscriptionId: claim.subscriptionId, + subscriptionVersion: claim.subscriptionVersion, + attempt: claim.attempt, + event: claim.event, + subscription: claim.subscription, + stage, + }; +} + +async function scheduleContentHookTiming(args: { + claim: ClaimedWorkflowExecution; + timingKind: "delayed" | "debounced" | "escalation"; + delayMinutes: number; + stage: "delayed" | "debounced" | "escalation"; +}) { + const dedupeKey = + args.timingKind === "debounced" + ? `content_hook_debounce:${args.claim.subscriptionId}:${args.claim.event.subjectKey}` + : `content_hook_timing:${args.claim.subscriptionId}:${args.claim.eventId}:${args.stage}`; + await scheduleWorkflowWork({ + workType: "content_hook_timing", + subjectKey: args.claim.event.subjectKey, + eventId: args.claim.eventId, + subscriptionId: args.claim.subscriptionId, + payload: scheduledClaimPayload(args.claim, args.stage), + dedupeKey, + dueAt: Date.now() + args.delayMinutes * 60_000, + }); +} + +function scheduledExecutionClaim( + work: ClaimedScheduledWork, +): ClaimedWorkflowExecution | null { + const payload = work.payload as Record; + if ( + typeof payload.executionId !== "string" || + typeof payload.eventId !== "string" || + typeof payload.subscriptionId !== "string" || + typeof payload.subscriptionVersion !== "number" || + typeof payload.attempt !== "number" || + !payload.event || + typeof payload.event !== "object" || + !payload.subscription || + typeof payload.subscription !== "object" + ) { + return null; + } + return { + id: payload.executionId, + eventId: payload.eventId, + subscriptionId: payload.subscriptionId, + subscriptionVersion: payload.subscriptionVersion, + status: "running", + attempt: payload.attempt, + leaseToken: work.leaseToken, + leaseExpiresAt: work.leaseExpiresAt, + fenceVersion: work.fenceVersion, + event: payload.event as WorkflowEvent, + subscription: payload.subscription as WorkflowSubscription, + }; +} + +async function contentHookCurrentStateMatches( + claim: ClaimedWorkflowExecution, + hook: Pick, +) { + const db = getDb(); + const [item] = await db + .select({ documentId: schema.contentDatabaseItems.documentId }) + .from(schema.contentDatabaseItems) + .innerJoin( + schema.contentDatabases, + eq(schema.contentDatabases.id, schema.contentDatabaseItems.databaseId), + ) + .where( + and( + eq(schema.contentDatabaseItems.databaseId, hook.databaseId), + eq(schema.contentDatabaseItems.documentId, claim.event.subjectId), + isNull(schema.contentDatabases.deletedAt), + ), + ); + if (!item) return false; + const propertyRows = await db + .select({ + propertyId: schema.documentPropertyValues.propertyId, + valueJson: schema.documentPropertyValues.valueJson, + }) + .from(schema.documentPropertyValues) + .where(eq(schema.documentPropertyValues.documentId, claim.event.subjectId)); + const currentPropertyValues = Object.fromEntries( + propertyRows.map((row) => [row.propertyId, JSON.parse(row.valueJson)]), + ); + if (!contentHookConditionsMatch(currentPropertyValues, hook.conditions)) { + return false; + } + if ( + hook.trigger.kind !== "property_changed" || + hook.trigger.toOptionId === undefined + ) { + return true; + } + const current = currentPropertyValues[hook.trigger.propertyId] ?? null; + return current === hook.trigger.toOptionId; +} + +export async function executeScheduledContentDatabaseHook( + work: ClaimedScheduledWork, +) { + const claim = scheduledExecutionClaim(work); + const stage = work.payload.stage; + if ( + !claim || + (stage !== "delayed" && stage !== "debounced" && stage !== "escalation") + ) { + return { + status: "dead_letter" as const, + errorMessage: "Invalid scheduled Content hook payload.", + }; + } + return runWithRequestContext( + { + userEmail: claim.subscription.ownerEmail, + orgId: claim.subscription.orgId ?? undefined, + }, + () => executeScheduledContentDatabaseHookInContext(work, claim, stage), + ); +} + +async function executeScheduledContentDatabaseHookInContext( + work: ClaimedScheduledWork, + claim: ClaimedWorkflowExecution, + stage: "delayed" | "debounced" | "escalation", +) { + const currentSubscription = await getWorkflowSubscription( + claim.subscriptionId, + ); + if ( + !currentSubscription || + !currentSubscription.enabled || + currentSubscription.version !== claim.subscriptionVersion + ) { + return { status: "completed" as const }; + } + claim.subscription = currentSubscription; + const config = contentHookConfigFromJson( + JSON.stringify(currentSubscription.config), + ); + if (!config || "system" in config) { + return { status: "completed" as const }; + } + if ( + !(await contentHookHasCurrentAuthority({ + databaseId: config.databaseId, + ownerEmail: currentSubscription.ownerEmail, + })) + ) { + return { status: "completed" as const }; + } + if ( + !matchesContentDatabaseHook(claim.event, config) || + !(await contentHookCurrentStateMatches(claim, config)) + ) { + await recordHookSuppression(claim, "condition_no_longer_matches", stage); + return { status: "completed" as const }; + } + const outcome = await executeContentHookEffects(claim, config, stage); + if (outcome.status === "succeeded") return { status: "completed" as const }; + throw new Error(outcome.errorMessage ?? "Scheduled Content hook failed."); +} + +function isSettledEffect(status: string) { + return ( + status === "delivered" || status === "suppressed" || status === "coalesced" + ); +} + +async function executePreparedNotification( + claim: ClaimedWorkflowExecution, + prepared: NonNullable>, +) { + for (const recipient of prepared.payload.recipients) { + const executionEffectKey = `${prepared.idempotencyKey}:${recipient}`; + const preference = await resolveContentNotificationPreference({ + ownerEmail: recipient, + orgId: claim.event.orgId, + databaseId: prepared.payload.databaseId, + subscriptionId: claim.subscription.id, + documentId: claim.event.subjectId, + }); + if (!preference.enabled) { + const reserved = await recordWorkflowEffect({ + executionId: claim.id, + kind: prepared.kind, + idempotencyKey: executionEffectKey, + }); + if (!reserved.created && isSettledEffect(reserved.effect.status)) { + continue; + } + await finalizeWorkflowEffect({ + effectId: reserved.effect.id, + status: "suppressed", + result: { + recipient, + outcome: "suppressed_by_preference", + preferenceScope: preference.source, + preferenceId: preference.preferenceId, + }, + }); + continue; + } + const recipientAccess = await resolveAccess( + "document", + claim.event.subjectId, + { + userEmail: recipient, + orgId: claim.event.orgId ?? undefined, + }, + { skipResourceBody: true }, + ); + if (!recipientAccess) { + const reserved = await recordWorkflowEffect({ + executionId: claim.id, + kind: prepared.kind, + idempotencyKey: executionEffectKey, + }); + if (!reserved.created && isSettledEffect(reserved.effect.status)) { + continue; + } + await finalizeWorkflowEffect({ + effectId: reserved.effect.id, + status: "failed", + result: { recipient, unroutable: true }, + errorMessage: + "Recipient does not have access to the notification resource.", + }); + continue; + } + + const deliveryClaim = await recordWorkflowEffect({ + executionId: claim.id, + kind: prepared.kind, + idempotencyKey: [ + "content-personal-notification", + claim.event.id, + claim.event.subjectId, + recipient, + "personal-routing", + ].join(":"), + }); + if ( + !deliveryClaim.created && + deliveryClaim.effect.executionId !== claim.id + ) { + const coalesced = await recordWorkflowEffect({ + executionId: claim.id, + kind: prepared.kind, + idempotencyKey: executionEffectKey, + }); + if (!coalesced.created && isSettledEffect(coalesced.effect.status)) { + continue; + } + await finalizeWorkflowEffect({ + effectId: coalesced.effect.id, + status: "coalesced", + result: { + recipient, + outcome: "coalesced_by_event_recipient_item_destination", + destination: "personal-routing", + coalescedIntoEffectId: deliveryClaim.effect.id, + coalescedIntoExecutionId: deliveryClaim.effect.executionId, + }, + }); + continue; + } + if ( + !deliveryClaim.created && + isSettledEffect(deliveryClaim.effect.status) + ) { + continue; + } + if (!deliveryClaim.created && deliveryClaim.effect.status === "unknown") { + return { + status: "unknown" as const, + errorMessage: + "The personal notification may already have been sent; refusing duplicate delivery without a receipt.", + }; + } + if ( + !deliveryClaim.created && + deliveryClaim.effect.status === "failed" && + !(await claimWorkflowEffectRetry({ effectId: deliveryClaim.effect.id })) + ) { + return { + status: "unknown" as const, + errorMessage: + "The personal notification retry was claimed by another worker.", + }; + } + const delivered = await notifyPersonalWithDelivery( + { + severity: "info", + title: prepared.payload.title, + body: prepared.payload.message, + metadata: { + ...prepared.payload.resource, + eventId: claim.event.id, + subscriptionId: claim.subscription.id, + }, + }, + { + owner: recipient, + workflowEffectId: deliveryClaim.effect.id, + workflowAttempt: claim.attempt, + }, + ); + const status = + delivered.deliveredChannels.length > 0 + ? "delivered" + : delivered.unknownChannels.length > 0 + ? "unknown" + : "failed"; + await finalizeWorkflowEffect({ + effectId: deliveryClaim.effect.id, + status, + result: { + recipient, + notificationId: delivered.notification?.id, + deliveredChannels: delivered.deliveredChannels, + unknownChannels: delivered.unknownChannels, + skippedChannels: delivered.skippedChannels, + failedChannels: delivered.failedChannels, + }, + errorMessage: + status === "failed" + ? "No configured notification channel accepted the delivery." + : undefined, + }); + if (status === "unknown") { + return { + status: "unknown" as const, + errorMessage: + "A personal notification was accepted without delivery evidence.", + }; + } + if (status === "failed") { + return { + status: "retrying" as const, + errorMessage: "Notification delivery did not reach any channel.", + }; + } + } + return { status: "succeeded" as const }; +} + +async function executeSharedDestinationEffect( + claim: ClaimedWorkflowExecution, + effect: + | { + kind: "team_slack"; + webhookKey: string; + title?: string; + message?: string; + } + | { + kind: "webhook"; + urlKey: string; + signatureKey: string; + title?: string; + message?: string; + }, + index: number, + hookName: string, + stage: "initial" | "delayed" | "debounced" | "escalation", +) { + const reserved = await recordWorkflowEffect({ + executionId: claim.id, + kind: effect.kind, + idempotencyKey: timedEffectKey( + claim.event.id, + claim.subscription.id, + index, + stage, + ), + }); + if (!reserved.created && isSettledEffect(reserved.effect.status)) { + return { status: "succeeded" as const }; + } + if (!reserved.created) { + if ( + reserved.effect.status !== "failed" || + !(await claimWorkflowEffectRetry({ effectId: reserved.effect.id })) + ) { + return { + status: "unknown" as const, + errorMessage: + "The external effect was already reserved; refusing duplicate delivery.", + }; + } + } + + const delivery = + effect.kind === "team_slack" + ? { + channel: "slack" as const, + metadata: { + delivery: { + slackWebhookUrl: `\${keys.${effect.webhookKey}}`, + }, + }, + } + : { + channel: "webhook" as const, + metadata: { + delivery: { + webhookUrl: `\${keys.${effect.urlKey}}`, + webhookSignature: `\${keys.${effect.signatureKey}}`, + }, + }, + }; + const delivered = await notifyWithDelivery( + { + severity: "info", + title: effect.title ?? hookName, + body: + effect.message ?? + `Content database item ${claim.event.subjectId} changed.`, + channels: [delivery.channel], + metadata: { + ...delivery.metadata, + resourceType: "content_database_item", + resourceId: claim.event.subjectId, + resourceUrlPath: `/page/${claim.event.subjectId}`, + eventId: claim.event.id, + subscriptionId: claim.subscription.id, + }, + }, + { + owner: claim.subscription.ownerEmail, + workflowEffectId: reserved.effect.id, + workflowAttempt: claim.attempt, + }, + ); + const didDeliver = delivered.deliveredChannels.includes(delivery.channel); + const isUnknown = delivered.unknownChannels.includes(delivery.channel); + await finalizeWorkflowEffect({ + effectId: reserved.effect.id, + status: didDeliver ? "delivered" : isUnknown ? "unknown" : "failed", + result: { + destination: effect.kind, + deliveredChannels: delivered.deliveredChannels, + unknownChannels: delivered.unknownChannels, + skippedChannels: delivered.skippedChannels, + failedChannels: delivered.failedChannels, + }, + errorMessage: didDeliver + ? undefined + : isUnknown + ? `The configured ${effect.kind} destination accepted the send without delivery evidence.` + : `The configured ${effect.kind} destination did not accept delivery.`, + }); + return didDeliver + ? { status: "succeeded" as const } + : isUnknown + ? { + status: "unknown" as const, + errorMessage: `The configured ${effect.kind} destination accepted the send without delivery evidence.`, + } + : { + status: "retrying" as const, + errorMessage: `The configured ${effect.kind} destination did not accept delivery.`, + }; +} diff --git a/templates/content/actions/_content-hook-runtime-controls.ts b/templates/content/actions/_content-hook-runtime-controls.ts new file mode 100644 index 0000000000..762ef7a5dd --- /dev/null +++ b/templates/content/actions/_content-hook-runtime-controls.ts @@ -0,0 +1,67 @@ +import { getRequestUserEmail } from "@agent-native/core/server/request-context"; +import { + getWorkflowRuntimeControls, + setWorkflowRuntimeControl, +} from "@agent-native/core/workflow"; + +import { + requireContentDatabaseAccess, + requireContentDatabaseOwner, +} from "./_content-database-hooks.js"; + +export type ContentHookRuntimeControlScope = "global" | "database"; + +export interface ContentHookRuntimeControlValue { + evaluatorPaused: boolean; + effectsPaused: boolean; +} + +export async function getContentHookRuntimeControls(databaseId: string) { + const database = await requireContentDatabaseAccess(databaseId, "viewer"); + const controls = await getWorkflowRuntimeControls({ + ownerEmail: database.ownerEmail, + orgId: database.orgId, + domain: "content", + resourceId: databaseId, + }); + return { + databaseId, + global: controls.global, + database: controls.resource, + effective: controls.effective, + canManageGlobal: getRequestUserEmail() === database.ownerEmail, + }; +} + +export async function resolveContentHookRuntimeControls(args: { + ownerEmail: string; + orgId?: string | null; + databaseId: string; +}) { + const controls = await getWorkflowRuntimeControls({ + ownerEmail: args.ownerEmail, + orgId: args.orgId, + domain: "content", + resourceId: args.databaseId, + }); + return controls.effective; +} + +export async function setContentHookRuntimeControl(args: { + databaseId: string; + scope: ContentHookRuntimeControlScope; + evaluatorPaused: boolean; + effectsPaused: boolean; +}) { + const database = await requireContentDatabaseOwner(args.databaseId); + await setWorkflowRuntimeControl({ + ownerEmail: database.ownerEmail, + orgId: database.orgId, + domain: "content", + scope: args.scope === "global" ? "global" : "resource", + resourceId: args.databaseId, + evaluatorPaused: args.evaluatorPaused, + effectsPaused: args.effectsPaused, + }); + return getContentHookRuntimeControls(args.databaseId); +} diff --git a/templates/content/actions/_content-notification-preference-access.ts b/templates/content/actions/_content-notification-preference-access.ts new file mode 100644 index 0000000000..862a41b056 --- /dev/null +++ b/templates/content/actions/_content-notification-preference-access.ts @@ -0,0 +1,60 @@ +import { assertAccess } from "@agent-native/core/sharing"; +import { workflowSubscriptions } from "@agent-native/core/workflow"; +import { and, eq } from "drizzle-orm"; + +import { getDb, schema } from "../server/db/index.js"; +import { + contentDefaultPersonSubscriptionId, + contentHookConfigFromJson, + requireContentDatabaseAccess, +} from "./_content-database-hooks.js"; +import type { ContentNotificationPreferenceTarget } from "./_content-notification-preferences.js"; + +export async function assertContentNotificationPreferenceTarget( + target: ContentNotificationPreferenceTarget, +) { + if (target.scope === "global") return; + if (!target.databaseId) { + throw new Error("A database ID is required for this preference scope."); + } + await requireContentDatabaseAccess(target.databaseId, "viewer"); + if (target.scope === "database") return; + + if (target.scope === "item") { + if (!target.documentId) throw new Error("An item page ID is required."); + await assertAccess("document", target.documentId, "viewer"); + const [membership] = await getDb() + .select({ id: schema.contentDatabaseItems.id }) + .from(schema.contentDatabaseItems) + .where( + and( + eq(schema.contentDatabaseItems.databaseId, target.databaseId), + eq(schema.contentDatabaseItems.documentId, target.documentId), + ), + ); + if (!membership) { + throw new Error("The item does not belong to this database."); + } + return; + } + + if (!target.subscriptionId) { + throw new Error("A notification rule ID is required."); + } + if ( + target.subscriptionId === + contentDefaultPersonSubscriptionId(target.databaseId) + ) { + return; + } + const [subscription] = await getDb() + .select({ config: workflowSubscriptions.config }) + .from(workflowSubscriptions) + .where(eq(workflowSubscriptions.id, target.subscriptionId)); + const config = subscription + ? contentHookConfigFromJson(subscription.config) + : null; + if (!config || config.databaseId !== target.databaseId) { + throw new Error("The notification rule does not belong to this database."); + } +} diff --git a/templates/content/actions/_content-notification-preferences.ts b/templates/content/actions/_content-notification-preferences.ts new file mode 100644 index 0000000000..064ff4995c --- /dev/null +++ b/templates/content/actions/_content-notification-preferences.ts @@ -0,0 +1,165 @@ +import { and, eq } from "drizzle-orm"; + +import { getDb, schema } from "../server/db/index.js"; +import { nanoid } from "./_property-utils.js"; + +export const CONTENT_NOTIFICATION_GLOBAL_SCOPE_ID = "content"; + +export type ContentNotificationPreferenceScope = + | "global" + | "database" + | "rule" + | "item"; + +export interface ContentNotificationPreferenceTarget { + scope: ContentNotificationPreferenceScope; + databaseId?: string; + subscriptionId?: string; + documentId?: string; +} + +export interface ResolvedContentNotificationPreference { + enabled: boolean; + source: ContentNotificationPreferenceScope | "default"; + preferenceId: string | null; +} + +export function contentNotificationPreferenceScopeId( + target: ContentNotificationPreferenceTarget, +): string { + if (target.scope === "global") return CONTENT_NOTIFICATION_GLOBAL_SCOPE_ID; + if (target.scope === "database" && target.databaseId) { + return target.databaseId; + } + if (target.scope === "rule" && target.subscriptionId) { + return target.subscriptionId; + } + if (target.scope === "item" && target.documentId) return target.documentId; + throw new Error( + `A stable ${target.scope} notification scope ID is required.`, + ); +} + +export async function setContentNotificationPreference(args: { + ownerEmail: string; + orgId?: string | null; + target: ContentNotificationPreferenceTarget; + enabled: boolean; +}) { + const db = getDb(); + const now = new Date().toISOString(); + const orgId = args.orgId ?? ""; + const scopeId = contentNotificationPreferenceScopeId(args.target); + const values = { + id: nanoid(), + ownerEmail: args.ownerEmail, + orgId, + scope: args.target.scope, + scopeId, + databaseId: args.target.databaseId ?? null, + subscriptionId: args.target.subscriptionId ?? null, + documentId: args.target.documentId ?? null, + enabled: args.enabled, + createdAt: now, + updatedAt: now, + }; + await db + .insert(schema.contentNotificationPreferences) + .values(values) + .onConflictDoUpdate({ + target: [ + schema.contentNotificationPreferences.ownerEmail, + schema.contentNotificationPreferences.orgId, + schema.contentNotificationPreferences.scope, + schema.contentNotificationPreferences.scopeId, + ], + set: { + databaseId: values.databaseId, + subscriptionId: values.subscriptionId, + documentId: values.documentId, + enabled: values.enabled, + updatedAt: now, + }, + }); + const [saved] = await db + .select() + .from(schema.contentNotificationPreferences) + .where( + and( + eq(schema.contentNotificationPreferences.ownerEmail, args.ownerEmail), + eq(schema.contentNotificationPreferences.orgId, orgId), + eq(schema.contentNotificationPreferences.scope, args.target.scope), + eq(schema.contentNotificationPreferences.scopeId, scopeId), + ), + ); + if (!saved) throw new Error("Notification preference was not persisted."); + return saved; +} + +export async function removeContentNotificationPreference(args: { + ownerEmail: string; + orgId?: string | null; + target: ContentNotificationPreferenceTarget; +}) { + const scopeId = contentNotificationPreferenceScopeId(args.target); + await getDb() + .delete(schema.contentNotificationPreferences) + .where( + and( + eq(schema.contentNotificationPreferences.ownerEmail, args.ownerEmail), + eq(schema.contentNotificationPreferences.orgId, args.orgId ?? ""), + eq(schema.contentNotificationPreferences.scope, args.target.scope), + eq(schema.contentNotificationPreferences.scopeId, scopeId), + ), + ); +} + +export async function resolveContentNotificationPreference(args: { + ownerEmail: string; + orgId?: string | null; + databaseId?: string; + subscriptionId?: string; + documentId?: string; +}): Promise { + const rows = await getDb() + .select() + .from(schema.contentNotificationPreferences) + .where( + and( + eq(schema.contentNotificationPreferences.ownerEmail, args.ownerEmail), + eq(schema.contentNotificationPreferences.orgId, args.orgId ?? ""), + ), + ); + const candidates: Array<{ + source: ContentNotificationPreferenceScope; + scopeId: string; + }> = []; + if (args.documentId) { + candidates.push({ source: "item", scopeId: args.documentId }); + } + if (args.subscriptionId) { + candidates.push({ source: "rule", scopeId: args.subscriptionId }); + } + if (args.databaseId) { + candidates.push({ source: "database", scopeId: args.databaseId }); + } + candidates.push({ + source: "global", + scopeId: CONTENT_NOTIFICATION_GLOBAL_SCOPE_ID, + }); + for (const candidate of candidates) { + const row = rows.find( + (preference) => + preference.scope === candidate.source && + preference.scopeId === candidate.scopeId, + ); + if (row) { + return { + enabled: row.enabled, + source: candidate.source, + preferenceId: row.id, + }; + } + } + return { enabled: true, source: "default", preferenceId: null }; +} diff --git a/templates/content/actions/_content-workflow.ts b/templates/content/actions/_content-workflow.ts new file mode 100644 index 0000000000..57ea7e6a8c --- /dev/null +++ b/templates/content/actions/_content-workflow.ts @@ -0,0 +1,280 @@ +import { AsyncLocalStorage } from "node:async_hooks"; +import { createHash } from "node:crypto"; + +import type { ActionRunContext } from "@agent-native/core/action"; +import { + getRequestContext, + getRequestRunContext, + getRequestUserEmail, +} from "@agent-native/core/server/request-context"; +import { + createWorkflowEventValues, + emitWorkflowWake, + workflowEvents, + workflowSequenceCounters, +} from "@agent-native/core/workflow"; +import { eq, sql, type SQL } from "drizzle-orm"; + +import { nanoid } from "./_property-utils.js"; + +export function contentWorkflowFingerprint(value: string | null | undefined) { + return createHash("sha256") + .update(value ?? "") + .digest("hex") + .slice(0, 24); +} + +interface WorkflowTransaction { + insert(table: typeof workflowEvents): { + values: (value: typeof workflowEvents.$inferInsert) => Promise; + }; + insert(table: typeof workflowSequenceCounters): { + values: (value: typeof workflowSequenceCounters.$inferInsert) => { + onConflictDoNothing: () => Promise; + }; + }; + update(table: typeof workflowSequenceCounters): { + set: (value: { value: SQL }) => { + where: (condition: SQL | undefined) => { + returning: (selection: { + value: typeof workflowSequenceCounters.value; + }) => PromiseLike>; + }; + }; + }; +} + +export async function allocateContentWorkflowEventSequence( + tx: WorkflowTransaction, +): Promise { + await tx + .insert(workflowSequenceCounters) + .values({ name: "events", value: 0 }) + .onConflictDoNothing(); + const [row] = await tx + .update(workflowSequenceCounters) + .set({ value: sql`${workflowSequenceCounters.value} + 1` }) + .where(eq(workflowSequenceCounters.name, "events")) + .returning({ value: workflowSequenceCounters.value }); + if (!row || !Number.isSafeInteger(row.value) || row.value < 1) { + throw new Error("Content workflow event sequence allocation failed"); + } + return row.value; +} + +export interface ContentWorkflowActorSnapshot extends Record { + initiator: { + kind: "human" | "system"; + id: string; + }; + executor: { + kind: + | "human" + | "agent" + | "api_client" + | "provider" + | "automation" + | "system"; + id: string; + model?: string; + engine?: string; + }; + origin: { + kind: "ui" | "agent" | "integration" | "api" | "cli" | "system"; + platform?: string; + sourceId?: string; + protocol?: string; + threadId?: string; + }; + lineage: { + integrationTaskId?: string; + parentTaskId?: string; + runId?: string; + networkId?: string; + parentExecutionId?: string; + parentSubscriptionId?: string; + chainDepth?: number; + subscriptionPath?: string[]; + }; + authority: { + ownerEmail: string; + }; +} + +export interface ContentWorkflowCausality { + causalEventId: string; + parentExecutionId: string; + parentSubscriptionId: string; + chainDepth: number; + subscriptionPath: string[]; + initiator?: ContentWorkflowActorSnapshot["initiator"]; +} + +const contentWorkflowCausality = + new AsyncLocalStorage(); + +export function runWithContentWorkflowCausality( + causality: ContentWorkflowCausality, + task: () => T, +): T { + return contentWorkflowCausality.run(causality, task); +} + +export function contentWorkflowActorSnapshot( + fallbackOwnerEmail: string, + actionContext?: ActionRunContext, + overrides: Partial = {}, +): ContentWorkflowActorSnapshot { + const request = getRequestContext(); + const run = getRequestRunContext(); + const integration = request?.integration; + const humanId = + actionContext?.userEmail ?? getRequestUserEmail() ?? run?.owner; + const isAgent = Boolean( + run?.model || + run?.threadId || + actionContext?.caller === "tool" || + actionContext?.caller === "mcp" || + actionContext?.caller === "a2a", + ); + const isIntegration = Boolean(integration); + const snapshot: ContentWorkflowActorSnapshot = { + initiator: { + kind: humanId ? "human" : "system", + id: humanId || "system", + }, + executor: isAgent + ? { + kind: "agent", + id: + run?.threadId ?? + actionContext?.threadId ?? + actionContext?.caller ?? + "agent", + ...(run?.model ? { model: run.model } : {}), + ...(run?.engine?.name ? { engine: run.engine.name } : {}), + } + : isIntegration + ? { + kind: "provider", + id: integration?.incoming.platform ?? "integration", + } + : actionContext?.caller === "http" + ? { kind: "api_client", id: humanId || "api" } + : { + kind: humanId ? "human" : "system", + id: humanId || "system", + }, + origin: isIntegration + ? { + kind: "integration", + platform: integration?.incoming.platform, + sourceId: integration?.lineage?.source?.id, + protocol: integration?.lineage?.network?.protocol, + threadId: run?.threadId, + } + : actionContext?.caller === "mcp" || actionContext?.caller === "a2a" + ? { kind: "api", protocol: actionContext.caller } + : isAgent + ? { kind: "agent", threadId: run?.threadId } + : actionContext?.caller === "cli" + ? { kind: "cli" } + : actionContext?.caller === "http" + ? { kind: "api" } + : request || actionContext?.caller === "frontend" + ? { kind: "ui" } + : { kind: "system" }, + lineage: { + integrationTaskId: integration?.taskId, + parentTaskId: integration?.lineage?.parentTaskId, + runId: integration?.lineage?.runId, + networkId: integration?.lineage?.network?.id, + }, + authority: { ownerEmail: fallbackOwnerEmail }, + }; + return { + ...snapshot, + ...overrides, + initiator: { ...snapshot.initiator, ...overrides.initiator }, + executor: { ...snapshot.executor, ...overrides.executor }, + origin: { ...snapshot.origin, ...overrides.origin }, + lineage: { ...snapshot.lineage, ...overrides.lineage }, + authority: { ...snapshot.authority, ...overrides.authority }, + }; +} + +export async function appendContentWorkflowEvent( + tx: WorkflowTransaction, + input: { + topic: `content.${string}`; + subjectType: string; + subjectId: string; + databaseId?: string; + documentId?: string; + ownerEmail: string; + orgId?: string | null; + payload: Record; + occurredAt: string | number; + actionContext?: ActionRunContext; + actorOverrides?: Partial; + causalEventId?: string | null; + }, +): Promise { + const causality = contentWorkflowCausality.getStore(); + const causalActorOverrides: Partial = causality + ? { + ...(causality.initiator ? { initiator: causality.initiator } : {}), + executor: { + kind: "automation", + id: `content-hook:${causality.parentSubscriptionId}`, + }, + origin: { kind: "system" }, + lineage: { + parentExecutionId: causality.parentExecutionId, + parentSubscriptionId: causality.parentSubscriptionId, + chainDepth: causality.chainDepth, + subscriptionPath: causality.subscriptionPath, + }, + } + : {}; + const id = nanoid(); + const eventSequence = await allocateContentWorkflowEventSequence(tx); + await tx.insert(workflowEvents).values( + createWorkflowEventValues({ + id, + eventSequence, + topic: input.topic, + subjectType: input.subjectType, + subjectId: input.subjectId, + ownerEmail: input.ownerEmail, + orgId: input.orgId ?? null, + payload: { + ...(input.databaseId ? { databaseId: input.databaseId } : {}), + ...(input.documentId ? { documentId: input.documentId } : {}), + ...input.payload, + }, + actorContext: contentWorkflowActorSnapshot( + input.ownerEmail, + input.actionContext, + { + ...causalActorOverrides, + ...input.actorOverrides, + lineage: { + ...causalActorOverrides.lineage, + ...input.actorOverrides?.lineage, + }, + }, + ), + causalEventId: input.causalEventId ?? causality?.causalEventId, + occurredAt: + typeof input.occurredAt === "number" + ? input.occurredAt + : Date.parse(input.occurredAt), + }), + ); + return id; +} + +export function wakeContentWorkflowEvent(eventId: string): void { + emitWorkflowWake({ topic: "workflow.event.available", rowId: eventId }); +} diff --git a/templates/content/actions/_property-utils.ts b/templates/content/actions/_property-utils.ts index 42a073940a..0b853298ae 100644 --- a/templates/content/actions/_property-utils.ts +++ b/templates/content/actions/_property-utils.ts @@ -194,6 +194,17 @@ export function defaultDatabaseViewConfig( sorts: view.sorts, filters: view.filters, columnWidths: view.columnWidths, + schemaLocked: false, + defaultPersonNotificationsEnabled: true, + defaultPersonNotificationsPolicyVersion: 1, + validation: defaultDatabaseValidationConfig(), + }; +} + +function defaultDatabaseValidationConfig() { + return { + requiredForSubmission: [], + statusRequirements: [], }; } @@ -238,7 +249,64 @@ function normalizeDatabaseViewConfig( sorts: activeView.sorts, filters: activeView.filters, columnWidths: activeView.columnWidths, + schemaLocked: value?.schemaLocked === true, + defaultPersonNotificationsEnabled: + value?.defaultPersonNotificationsEnabled !== false, + defaultPersonNotificationsPolicyVersion: + typeof value?.defaultPersonNotificationsPolicyVersion === "number" && + Number.isSafeInteger(value.defaultPersonNotificationsPolicyVersion) && + value.defaultPersonNotificationsPolicyVersion >= 1 + ? value.defaultPersonNotificationsPolicyVersion + : 1, + validation: normalizeDatabaseValidationConfig(value?.validation), + }; +} + +function normalizeDatabaseValidationConfig(value: unknown) { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return defaultDatabaseValidationConfig(); + } + const candidate = value as { + requiredForSubmission?: unknown; + statusRequirements?: unknown; }; + const requiredForSubmission = normalizeStringList( + candidate.requiredForSubmission, + ); + const seen = new Set(); + const statusRequirements = Array.isArray(candidate.statusRequirements) + ? candidate.statusRequirements.flatMap((entry) => { + if (!entry || typeof entry !== "object" || Array.isArray(entry)) { + return []; + } + const requirement = entry as { + statusPropertyId?: unknown; + statusOptionId?: unknown; + requiredPropertyIds?: unknown; + }; + const statusPropertyId = + typeof requirement.statusPropertyId === "string" + ? requirement.statusPropertyId.trim() + : ""; + const statusOptionId = + typeof requirement.statusOptionId === "string" + ? requirement.statusOptionId.trim() + : ""; + const key = `${statusPropertyId}:${statusOptionId}`; + if (!statusPropertyId || !statusOptionId || seen.has(key)) return []; + seen.add(key); + return [ + { + statusPropertyId, + statusOptionId, + requiredPropertyIds: normalizeStringList( + requirement.requiredPropertyIds, + ), + }, + ]; + }) + : []; + return { requiredForSubmission, statusRequirements }; } function defaultDatabaseView( diff --git a/templates/content/actions/add-content-database-source-field-property.ts b/templates/content/actions/add-content-database-source-field-property.ts index 3b56927751..c8b70b36c5 100644 --- a/templates/content/actions/add-content-database-source-field-property.ts +++ b/templates/content/actions/add-content-database-source-field-property.ts @@ -27,6 +27,7 @@ import { builderCmsQualifiedId, type BuilderCmsSourceEntry, } from "./_builder-cms-source-adapter.js"; +import { assertContentDatabaseSchemaUnlocked } from "./_content-database-hooks.js"; import { resolveDatabaseForSourceMutation, serializeSourceField, @@ -326,6 +327,7 @@ export default defineAction({ ): Promise => { const database = await resolveDatabaseForSourceMutation(args); if (!database) throw new Error("Database not found."); + assertContentDatabaseSchemaUnlocked(database); await assertAccess("document", database.documentId, "editor"); const db = getDb(); diff --git a/templates/content/actions/add-database-item.ts b/templates/content/actions/add-database-item.ts index 12d2f6bfe2..50f5f761ed 100644 --- a/templates/content/actions/add-database-item.ts +++ b/templates/content/actions/add-database-item.ts @@ -1,23 +1,17 @@ import { defineAction } from "@agent-native/core"; import { writeAppState } from "@agent-native/core/application-state"; -import { getRequestUserEmail } from "@agent-native/core/server/request-context"; -import { assertAccess } from "@agent-native/core/sharing"; -import { and, eq, inArray, isNull, sql } from "drizzle-orm"; +import { and, eq, inArray, isNull } from "drizzle-orm"; import { z } from "zod"; import { getDb, schema } from "../server/db/index.js"; import { isComputedPropertyType, type DocumentPropertyType, + type DocumentPropertyValue, } from "../shared/properties.js"; -import { ensureDocumentFilesMembership } from "./_content-files.js"; +import { commitContentDatabaseItem } from "./_content-database-item-mutations.js"; import { getContentDatabaseResponse } from "./_database-utils.js"; -import { - databaseItemsPositionScope, - documentsPositionScope, - withPositionLock, -} from "./_position-utils.js"; -import { nanoid, normalizedValueJson } from "./_property-utils.js"; +import { normalizedValueJson } from "./_property-utils.js"; export default defineAction({ description: "Add a page item to a content database table.", @@ -28,8 +22,14 @@ export default defineAction({ .record(z.string(), z.unknown()) .optional() .describe("Initial property values keyed by property definition ID"), + submissionIntent: z + .enum(["draft", "submitted"]) + .optional() + .describe( + "Whether this atomic operation is a draft row or a completed agent/API submission. Defaults to draft.", + ), }), - run: async ({ databaseId, title, propertyValues }) => { + run: async ({ databaseId, title, propertyValues, submissionIntent }, ctx) => { const db = getDb(); const [database] = await db .select() @@ -42,66 +42,16 @@ export default defineAction({ ); if (!database) throw new Error(`Database "${databaseId}" not found`); - const access = await assertAccess( - "document", - database.documentId, - "editor", - ); - const databaseDocument = access.resource; - if ( - database.spaceId && - databaseDocument.spaceId && - databaseDocument.spaceId !== database.spaceId - ) { - throw new Error( - `Database "${databaseId}" has inconsistent Content space`, - ); - } - const now = new Date().toISOString(); - const databaseSpaceId = - database.spaceId ?? (databaseDocument.spaceId as string | null); - if (!databaseSpaceId) { - throw new Error("Database does not belong to a Content space."); - } - if (databaseSpaceId && (!database.spaceId || !databaseDocument.spaceId)) { - await db.transaction(async (tx) => { - if (!database.spaceId) { - await tx - .update(schema.contentDatabases) - .set({ spaceId: databaseSpaceId, updatedAt: now }) - .where(eq(schema.contentDatabases.id, databaseId)); - } - if (!databaseDocument.spaceId) { - await tx - .update(schema.documents) - .set({ spaceId: databaseSpaceId, updatedAt: now }) - .where(eq(schema.documents.id, database.documentId)); - } - await ensureDocumentFilesMembership(tx, database.documentId, now); - }); - } - - const documentId = nanoid(); - const itemId = nanoid(); - - const inheritedShares = await db - .select({ - principalType: schema.documentShares.principalType, - principalId: schema.documentShares.principalId, - role: schema.documentShares.role, - }) - .from(schema.documentShares) - .where(eq(schema.documentShares.resourceId, database.documentId)); - const initialValues = Object.entries(propertyValues ?? {}); - const propertyValueRows: Array< - typeof schema.documentPropertyValues.$inferInsert + const normalizedValues = new Map(); + let definitions: Array< + typeof schema.documentPropertyDefinitions.$inferSelect > = []; if (initialValues.length > 0) { const requestedPropertyIds = initialValues.map( ([propertyId]) => propertyId, ); - const definitions = await db + definitions = await db .select() .from(schema.documentPropertyDefinitions) .where( @@ -125,95 +75,27 @@ export default defineAction({ const definition = definitionById.get(propertyId); const type = definition?.type as DocumentPropertyType | undefined; if (!definition || !type || isComputedPropertyType(type)) continue; - propertyValueRows.push({ - id: nanoid(), - ownerEmail: database.ownerEmail, - documentId, + normalizedValues.set( propertyId, - valueJson: normalizedValueJson(type, value), - createdAt: now, - updatedAt: now, - }); + JSON.parse(normalizedValueJson(type, value)) as DocumentPropertyValue, + ); } } - - await withPositionLock( - documentsPositionScope(database.ownerEmail, database.documentId), - () => - withPositionLock(databaseItemsPositionScope(databaseId), async () => { - await db.transaction(async (tx) => { - const [maxDocPos] = await tx - .select({ max: sql`COALESCE(MAX(position), -1)` }) - .from(schema.documents) - .where( - and( - eq(schema.documents.ownerEmail, database.ownerEmail), - eq(schema.documents.parentId, database.documentId), - ), - ); - const [maxItemPos] = await tx - .select({ max: sql`COALESCE(MAX(position), -1)` }) - .from(schema.contentDatabaseItems) - .where(eq(schema.contentDatabaseItems.databaseId, databaseId)); - - await tx.insert(schema.documents).values({ - id: documentId, - spaceId: databaseSpaceId, - ownerEmail: database.ownerEmail, - orgId: database.orgId, - parentId: database.documentId, - title: title?.trim() ?? "", - content: "", - icon: null, - position: (maxDocPos?.max ?? -1) + 1, - isFavorite: 0, - hideFromSearch: databaseDocument.hideFromSearch ?? 0, - visibility: databaseDocument.visibility ?? "private", - createdAt: now, - updatedAt: now, - }); - await tx.insert(schema.contentDatabaseItems).values({ - id: itemId, - ownerEmail: database.ownerEmail, - orgId: database.orgId, - databaseId, - documentId, - position: (maxItemPos?.max ?? -1) + 1, - createdAt: now, - updatedAt: now, - }); - if (inheritedShares.length > 0) { - await tx.insert(schema.documentShares).values( - inheritedShares.map((share) => ({ - id: nanoid(), - resourceId: documentId, - principalType: share.principalType, - principalId: share.principalId, - role: share.role, - createdBy: getRequestUserEmail() ?? database.ownerEmail, - createdAt: now, - })), - ); - } - if (propertyValueRows.length > 0) { - await tx - .insert(schema.documentPropertyValues) - .values(propertyValueRows); - } - await ensureDocumentFilesMembership(tx, documentId, now); - }); - }), - ); - + const mutation = await commitContentDatabaseItem({ + databaseId, + title, + values: normalizedValues, + intent: submissionIntent ?? "draft", + actionContext: ctx, + }); await writeAppState("refresh-signal", { ts: Date.now() }).catch(() => { - // The row is already committed; polling will reconcile if a concurrent - // SQLite writer briefly blocks this best-effort refresh hint. + // The row is already committed; polling will reconcile the refresh hint. }); return { ...(await getContentDatabaseResponse(databaseId)), - createdItemId: itemId, - createdDocumentId: documentId, + createdItemId: mutation.itemId, + createdDocumentId: mutation.documentId, }; }, }); diff --git a/templates/content/actions/bind-content-database-source-field.ts b/templates/content/actions/bind-content-database-source-field.ts index cb2a5c8164..5f4ffcef2d 100644 --- a/templates/content/actions/bind-content-database-source-field.ts +++ b/templates/content/actions/bind-content-database-source-field.ts @@ -10,6 +10,7 @@ import type { } from "../shared/api.js"; import { serializePropertyValue } from "../shared/properties.js"; import { chunks } from "./_batch-utils.js"; +import { assertContentDatabaseSchemaUnlocked } from "./_content-database-hooks.js"; import { resolveDatabaseForSourceMutation } from "./_database-source-utils.js"; import { getContentDatabaseResponse } from "./_database-utils.js"; import { nanoid } from "./_property-utils.js"; @@ -40,6 +41,7 @@ export default defineAction({ const database = await resolveDatabaseForSourceMutation(args); if (!database) throw new Error("Database not found."); await assertAccess("document", database.documentId, "editor"); + assertContentDatabaseSchemaUnlocked(database); const db = getDb(); const [field] = await db diff --git a/templates/content/actions/configure-document-property.ts b/templates/content/actions/configure-document-property.ts index 6f5a0b015f..bb4d865f43 100644 --- a/templates/content/actions/configure-document-property.ts +++ b/templates/content/actions/configure-document-property.ts @@ -16,6 +16,7 @@ import { normalizePropertyVisibility, type DocumentPropertyType, } from "../shared/properties.js"; +import { assertContentDatabaseSchemaUnlocked } from "./_content-database-hooks.js"; import { propertyDefinitionsPositionScope, withPositionLock, @@ -102,6 +103,7 @@ export default defineAction({ "Properties belong to databases. Create or open a database before adding properties.", ); } + assertContentDatabaseSchemaUnlocked(database); if (args.id) { const [existing] = await db diff --git a/templates/content/actions/content-database-hooks.db.test.ts b/templates/content/actions/content-database-hooks.db.test.ts new file mode 100644 index 0000000000..87ae0c1926 --- /dev/null +++ b/templates/content/actions/content-database-hooks.db.test.ts @@ -0,0 +1,1234 @@ +import { rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { runWithRequestContext } from "@agent-native/core/server"; +import { + createWorkflowEventValues, + getWorkflowSubscription, + materializeWorkflowExecutions, +} from "@agent-native/core/workflow"; +import { and, eq } from "drizzle-orm"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +import { serializePropertyOptions } from "../shared/properties.js"; +import { contentDefaultPersonSubscriptionId } from "./_content-database-hooks.js"; +import { + executeContentDatabaseHook, + matchesContentDatabaseHook, + prepareContentNotificationEffect, +} from "./_content-hook-execution.js"; +import { resolveContentNotificationPreference } from "./_content-notification-preferences.js"; +import { + allocateContentWorkflowEventSequence, + contentWorkflowActorSnapshot, + runWithContentWorkflowCausality, +} from "./_content-workflow.js"; + +const TEST_DB_PATH = join( + tmpdir(), + `content-database-hooks-${process.pid}-${Date.now()}.sqlite`, +); +const OWNER = "hooks-owner@example.com"; +const COLLABORATOR = "hooks-editor@example.com"; +const ADMIN = "hooks-admin@example.com"; + +type Schema = typeof import("../server/db/schema.js"); +let getDb: () => any; +let schema: Schema; +let manageHook: typeof import("./manage-content-database-hook.js").default; +let listHooks: typeof import("./list-content-database-hooks.js").default; +let setProperty: typeof import("./set-document-property.js").default; +let getRuntimeControls: typeof import("./get-content-hook-runtime-controls.js").default; +let manageRuntimeControl: typeof import("./manage-content-hook-runtime-control.js").default; +let managePreference: typeof import("./manage-content-notification-preference.js").default; +let getPreference: typeof import("./get-content-notification-preference.js").default; +let manageExecution: typeof import("./manage-content-database-hook-execution.js").default; +let listExecutions: typeof import("./list-content-database-hook-executions.js").default; +let managePolicy: typeof import("./manage-content-database-policy.js").default; +let configureProperty: typeof import("./configure-document-property.js").default; + +const ids = { + databaseDocumentId: "hooks_database_document", + databaseId: "hooks_database", + itemDocumentId: "hooks_item_document", + itemId: "hooks_item", + statusPropertyId: "hooks_status_property", + assigneePropertyId: "hooks_assignee_property", + draftOptionId: "hooks_draft", + reviewOptionId: "hooks_ready_for_review", +}; + +beforeAll(async () => { + process.env.DATABASE_URL = `file:${TEST_DB_PATH}`; + const dbModule = await import("../server/db/index.js"); + getDb = dbModule.getDb; + schema = dbModule.schema; + const plugin = (await import("../server/plugins/db.js")).default; + await plugin(undefined as any); + manageHook = (await import("./manage-content-database-hook.js")).default; + listHooks = (await import("./list-content-database-hooks.js")).default; + setProperty = (await import("./set-document-property.js")).default; + getRuntimeControls = (await import("./get-content-hook-runtime-controls.js")) + .default; + manageRuntimeControl = ( + await import("./manage-content-hook-runtime-control.js") + ).default; + managePreference = ( + await import("./manage-content-notification-preference.js") + ).default; + getPreference = (await import("./get-content-notification-preference.js")) + .default; + manageExecution = ( + await import("./manage-content-database-hook-execution.js") + ).default; + listExecutions = (await import("./list-content-database-hook-executions.js")) + .default; + managePolicy = (await import("./manage-content-database-policy.js")).default; + configureProperty = (await import("./configure-document-property.js")) + .default; + + const now = new Date().toISOString(); + await getDb() + .insert(schema.documents) + .values([ + { + id: ids.databaseDocumentId, + ownerEmail: OWNER, + title: "Design asks", + content: "", + visibility: "private", + createdAt: now, + updatedAt: now, + }, + { + id: ids.itemDocumentId, + ownerEmail: OWNER, + parentId: ids.databaseDocumentId, + title: "Homepage refresh", + content: "", + visibility: "private", + createdAt: now, + updatedAt: now, + }, + ]); + await getDb().insert(schema.contentDatabases).values({ + id: ids.databaseId, + ownerEmail: OWNER, + documentId: ids.databaseDocumentId, + title: "Design asks", + createdAt: now, + updatedAt: now, + }); + await getDb().insert(schema.contentDatabaseItems).values({ + id: ids.itemId, + ownerEmail: OWNER, + databaseId: ids.databaseId, + documentId: ids.itemDocumentId, + createdAt: now, + updatedAt: now, + }); + await getDb() + .insert(schema.documentPropertyDefinitions) + .values([ + { + id: ids.statusPropertyId, + ownerEmail: OWNER, + databaseId: ids.databaseId, + name: "Status", + type: "status", + optionsJson: serializePropertyOptions({ + options: [ + { id: ids.draftOptionId, name: "Draft", color: "gray" }, + { id: ids.reviewOptionId, name: "Ready for review", color: "blue" }, + ], + }), + createdAt: now, + updatedAt: now, + }, + { + id: ids.assigneePropertyId, + ownerEmail: OWNER, + databaseId: ids.databaseId, + name: "Assignee", + type: "person", + createdAt: now, + updatedAt: now, + }, + ]); + await getDb() + .insert(schema.documentPropertyValues) + .values([ + { + id: "hooks_status_value", + ownerEmail: OWNER, + documentId: ids.itemDocumentId, + propertyId: ids.statusPropertyId, + valueJson: JSON.stringify(ids.draftOptionId), + createdAt: now, + updatedAt: now, + }, + { + id: "hooks_assignee_value", + ownerEmail: OWNER, + documentId: ids.itemDocumentId, + propertyId: ids.assigneePropertyId, + valueJson: JSON.stringify(["reviewer@example.com"]), + createdAt: now, + updatedAt: now, + }, + ]); + await getDb() + .insert(schema.documentShares) + .values([ + { + id: "hooks_editor_share", + resourceId: ids.databaseDocumentId, + principalType: "user", + principalId: COLLABORATOR, + role: "editor", + createdBy: OWNER, + createdAt: now, + }, + { + id: "hooks_reviewer_share", + resourceId: ids.itemDocumentId, + principalType: "user", + principalId: "reviewer@example.com", + role: "viewer", + createdBy: OWNER, + createdAt: now, + }, + { + id: "hooks_reviewer_database_share", + resourceId: ids.databaseDocumentId, + principalType: "user", + principalId: "reviewer@example.com", + role: "viewer", + createdBy: OWNER, + createdAt: now, + }, + { + id: "hooks_admin_share", + resourceId: ids.databaseDocumentId, + principalType: "user", + principalId: ADMIN, + role: "admin", + createdBy: OWNER, + createdAt: now, + }, + ]); +}, 60_000); + +afterAll(() => { + for (const suffix of ["", "-shm", "-wal"]) { + rmSync(`${TEST_DB_PATH}${suffix}`, { force: true }); + } +}); + +describe("Content deterministic database hooks", () => { + it("keeps resource authority separate from a pure system actor", async () => { + const actor = await runWithRequestContext({}, () => + contentWorkflowActorSnapshot(OWNER), + ); + expect(actor).toMatchObject({ + initiator: { kind: "system", id: "system" }, + executor: { kind: "system", id: "system" }, + authority: { ownerEmail: OWNER }, + }); + expect( + contentWorkflowActorSnapshot(OWNER, { + caller: "mcp", + userEmail: COLLABORATOR, + }), + ).toMatchObject({ + initiator: { kind: "human", id: COLLABORATOR }, + executor: { kind: "agent", id: "mcp" }, + origin: { kind: "api", protocol: "mcp" }, + authority: { ownerEmail: OWNER }, + }); + }); + + it("allows only database admins to author stable-ID hooks", async () => { + await expect( + runWithRequestContext({ userEmail: COLLABORATOR }, () => + manageHook.run({ + action: "create", + databaseId: ids.databaseId, + name: "Ask for review", + trigger: { + kind: "property_changed", + propertyId: ids.statusPropertyId, + toOptionId: ids.reviewOptionId, + }, + effect: { + kind: "notify", + recipientPersonPropertyId: ids.assigneePropertyId, + }, + }), + ), + ).rejects.toThrow(); + + const propertyRule = await runWithRequestContext({ userEmail: OWNER }, () => + manageHook.run({ + action: "create", + databaseId: ids.databaseId, + name: "Ask for review", + trigger: { + kind: "property_changed", + propertyId: ids.statusPropertyId, + fromOptionId: ids.draftOptionId, + toOptionId: ids.reviewOptionId, + }, + conditions: { + mode: "all", + clauses: [ + { + propertyId: ids.assigneePropertyId, + operator: "is_not_empty", + }, + ], + }, + effect: { + kind: "notify", + recipientPersonPropertyId: ids.assigneePropertyId, + }, + }), + ); + expect(propertyRule.hook?.trigger).toEqual({ + kind: "property_changed", + propertyId: ids.statusPropertyId, + fromOptionId: ids.draftOptionId, + toOptionId: ids.reviewOptionId, + }); + expect(propertyRule.hook?.conditions).toEqual({ + mode: "all", + clauses: [ + { + propertyId: ids.assigneePropertyId, + operator: "is_not_empty", + }, + ], + }); + + await expect( + runWithRequestContext({ userEmail: OWNER }, () => + manageHook.run({ + action: "create", + databaseId: ids.databaseId, + name: "Invalid condition reference", + trigger: { kind: "item_submitted" }, + conditions: { + mode: "all", + clauses: [ + { propertyId: "another-database-field", operator: "is_empty" }, + ], + }, + effect: { + kind: "notify", + recipientPersonPropertyId: ids.assigneePropertyId, + }, + }), + ), + ).rejects.toThrow("does not belong to this database"); + + const created = await runWithRequestContext({ userEmail: OWNER }, () => + manageHook.run({ + action: "create", + databaseId: ids.databaseId, + name: "Share published Builder article", + trigger: { + kind: "builder_publication_confirmed", + publicationAction: "publish", + }, + effect: { + kind: "notify", + recipientPersonPropertyId: ids.assigneePropertyId, + message: "A design ask is ready for review.", + }, + }), + ); + expect(created.hook).toMatchObject({ + databaseId: ids.databaseId, + createdBy: OWNER, + trigger: { kind: "builder_publication_confirmed" }, + effect: { recipientPersonPropertyId: ids.assigneePropertyId }, + }); + const listed = await runWithRequestContext({ userEmail: OWNER }, () => + listHooks.run({ databaseId: ids.databaseId }), + ); + expect(listed.hooks).toHaveLength(2); + expect(listed.triggerAvailability).toContainEqual( + expect.objectContaining({ kind: "property_changed", available: true }), + ); + await runWithRequestContext({ userEmail: OWNER }, () => + manageHook.run({ + action: "delete", + databaseId: ids.databaseId, + hookId: propertyRule.hook!.id, + }), + ); + }); + + it("validates and versions deterministic property effects", async () => { + await expect( + runWithRequestContext({ userEmail: OWNER }, () => + manageHook.run({ + action: "create", + databaseId: ids.databaseId, + name: "Invalid status mutation", + trigger: { + kind: "builder_publication_confirmed", + publicationAction: "publish", + }, + effect: { + kind: "set_property", + propertyId: ids.statusPropertyId, + value: "not-a-stable-option", + }, + }), + ), + ).rejects.toThrow("must use stable option IDs"); + + const created = await runWithRequestContext({ userEmail: OWNER }, () => + manageHook.run({ + action: "create", + databaseId: ids.databaseId, + name: "Return published item to draft", + enabled: false, + trigger: { + kind: "builder_publication_confirmed", + publicationAction: "unpublish", + }, + effect: { + kind: "set_property", + propertyId: ids.statusPropertyId, + value: ids.draftOptionId, + }, + }), + ); + expect(created.hook?.effect).toMatchObject({ + version: 1, + kind: "set_property", + propertyId: ids.statusPropertyId, + value: ids.draftOptionId, + }); + await runWithRequestContext({ userEmail: OWNER }, () => + manageHook.run({ + action: "delete", + databaseId: ids.databaseId, + hookId: created.hook!.id, + }), + ); + }); + + it("keeps global and database incident pauses separate and access-scoped", async () => { + await expect( + runWithRequestContext({ userEmail: COLLABORATOR }, () => + manageRuntimeControl.run({ + databaseId: ids.databaseId, + scope: "database", + evaluatorPaused: true, + effectsPaused: false, + }), + ), + ).rejects.toThrow(); + + await expect( + runWithRequestContext({ userEmail: ADMIN }, () => + manageRuntimeControl.run({ + databaseId: ids.databaseId, + scope: "database", + evaluatorPaused: false, + effectsPaused: true, + }), + ), + ).rejects.toThrow("Only the database owner"); + await runWithRequestContext({ userEmail: OWNER }, () => + manageRuntimeControl.run({ + databaseId: ids.databaseId, + scope: "database", + evaluatorPaused: false, + effectsPaused: true, + }), + ); + await expect( + runWithRequestContext({ userEmail: ADMIN }, () => + manageRuntimeControl.run({ + databaseId: ids.databaseId, + scope: "global", + evaluatorPaused: true, + effectsPaused: false, + }), + ), + ).rejects.toThrow("Only the database owner"); + + await runWithRequestContext({ userEmail: OWNER }, () => + manageRuntimeControl.run({ + databaseId: ids.databaseId, + scope: "global", + evaluatorPaused: true, + effectsPaused: false, + }), + ); + const controls = await runWithRequestContext( + { userEmail: COLLABORATOR }, + () => getRuntimeControls.run({ databaseId: ids.databaseId }), + ); + expect(controls).toMatchObject({ + global: { evaluatorPaused: true, effectsPaused: false }, + database: { evaluatorPaused: false, effectsPaused: true }, + effective: { evaluatorPaused: true, effectsPaused: true }, + canManageGlobal: false, + }); + await runWithRequestContext({ userEmail: OWNER }, async () => { + await manageRuntimeControl.run({ + databaseId: ids.databaseId, + scope: "global", + evaluatorPaused: false, + effectsPaused: false, + }); + await manageRuntimeControl.run({ + databaseId: ids.databaseId, + scope: "database", + evaluatorPaused: false, + effectsPaused: false, + }); + }); + }); + + it("persists the owner-only default Person notification policy as immutable virtual snapshots", async () => { + await expect( + runWithRequestContext({ userEmail: COLLABORATOR }, () => + managePolicy.run({ + databaseId: ids.databaseId, + defaultPersonNotificationsEnabled: false, + }), + ), + ).rejects.toThrow("Requires admin role"); + + const appendPersonEvent = async (id: string, materialize = true) => { + const now = Date.now(); + await getDb().transaction(async (tx: any) => { + const eventSequence = await allocateContentWorkflowEventSequence(tx); + await tx.insert(schema.workflowEvents).values( + createWorkflowEventValues({ + id, + eventSequence, + topic: "content.database.property.changed", + subjectType: "content_database_item", + subjectId: `policy-item-${id}`, + ownerEmail: OWNER, + payload: { + databaseId: ids.databaseId, + propertyType: "person", + beforeValue: [], + afterValue: ["reviewer-policy@example.com"], + }, + occurredAt: now, + }), + ); + }); + if (materialize) { + await materializeWorkflowExecutions({ eventId: id, now: now + 1 }); + } + }; + + await appendPersonEvent("default-person-policy-before-disable", false); + + const disabled = await runWithRequestContext({ userEmail: OWNER }, () => + managePolicy.run({ + databaseId: ids.databaseId, + defaultPersonNotificationsEnabled: false, + }), + ); + expect(disabled).toMatchObject({ + defaultPersonNotificationsEnabled: false, + defaultPersonNotificationsPolicyVersion: 2, + }); + const [disabledPolicy, beforeDisableEvent] = await Promise.all([ + getDb() + .select() + .from(schema.contentDatabasePolicies) + .where( + and( + eq(schema.contentDatabasePolicies.databaseId, ids.databaseId), + eq(schema.contentDatabasePolicies.version, 2), + ), + ) + .then((rows) => rows[0]), + getDb() + .select({ eventSequence: schema.workflowEvents.eventSequence }) + .from(schema.workflowEvents) + .where( + eq(schema.workflowEvents.id, "default-person-policy-before-disable"), + ) + .then((rows) => rows[0]), + ]); + expect(disabledPolicy).toMatchObject({ + version: 2, + enabled: false, + ownerEmail: OWNER, + }); + expect(disabledPolicy!.activeAfterSequence).toBeGreaterThan( + beforeDisableEvent!.eventSequence, + ); + + await materializeWorkflowExecutions({ + eventId: "default-person-policy-before-disable", + now: Date.now() + 1, + }); + const beforeDisableExecution = await getDb() + .select() + .from(schema.workflowExecutions) + .where( + and( + eq( + schema.workflowExecutions.eventId, + "default-person-policy-before-disable", + ), + eq( + schema.workflowExecutions.subscriptionId, + contentDefaultPersonSubscriptionId(ids.databaseId), + ), + ), + ); + expect(beforeDisableExecution).toHaveLength(1); + expect(beforeDisableExecution[0]?.subscriptionVersion).toBe(1); + + await appendPersonEvent("default-person-policy-disabled"); + const [afterDisableEvent] = await getDb() + .select({ eventSequence: schema.workflowEvents.eventSequence }) + .from(schema.workflowEvents) + .where(eq(schema.workflowEvents.id, "default-person-policy-disabled")); + expect(disabledPolicy!.activeAfterSequence).toBeLessThan( + afterDisableEvent!.eventSequence, + ); + const disabledSnapshot = await getDb() + .select() + .from(schema.workflowSubscriptionVersions) + .where( + and( + eq( + schema.workflowSubscriptionVersions.subscriptionId, + contentDefaultPersonSubscriptionId(ids.databaseId), + ), + eq(schema.workflowSubscriptionVersions.version, 2), + ), + ); + expect(disabledSnapshot).toHaveLength(1); + expect(disabledSnapshot[0]).toMatchObject({ enabled: false }); + expect(JSON.parse(disabledSnapshot[0]!.config)).toMatchObject({ + policy: { + enabled: false, + source: "database_policy", + disabledReason: "owner_disabled", + }, + }); + const disabledExecutions = await getDb() + .select() + .from(schema.workflowExecutions) + .where( + and( + eq( + schema.workflowExecutions.eventId, + "default-person-policy-disabled", + ), + eq( + schema.workflowExecutions.subscriptionId, + contentDefaultPersonSubscriptionId(ids.databaseId), + ), + ), + ); + expect(disabledExecutions).toHaveLength(0); + + const enabled = await runWithRequestContext({ userEmail: OWNER }, () => + managePolicy.run({ + databaseId: ids.databaseId, + defaultPersonNotificationsEnabled: true, + }), + ); + expect(enabled).toMatchObject({ + defaultPersonNotificationsEnabled: true, + defaultPersonNotificationsPolicyVersion: 3, + }); + expect(beforeDisableExecution[0]?.subscriptionVersion).toBe(1); + await appendPersonEvent("default-person-policy-enabled"); + const enabledExecutions = await getDb() + .select() + .from(schema.workflowExecutions) + .where( + and( + eq( + schema.workflowExecutions.eventId, + "default-person-policy-enabled", + ), + eq( + schema.workflowExecutions.subscriptionId, + contentDefaultPersonSubscriptionId(ids.databaseId), + ), + ), + ); + expect(enabledExecutions).toHaveLength(1); + }); + + it("enforces the database schema lock for members while preserving owner edits", async () => { + await runWithRequestContext({ userEmail: OWNER }, () => + managePolicy.run({ databaseId: ids.databaseId, schemaLocked: true }), + ); + const propertyInput = { + documentId: ids.databaseDocumentId, + name: "Locked schema proof", + type: "text" as const, + }; + await expect( + runWithRequestContext({ userEmail: COLLABORATOR }, () => + configureProperty.run(propertyInput), + ), + ).rejects.toThrow("This database is locked"); + await expect( + runWithRequestContext({ userEmail: OWNER }, () => + configureProperty.run(propertyInput), + ), + ).resolves.toMatchObject({ databaseId: ids.databaseId }); + await runWithRequestContext({ userEmail: OWNER }, () => + managePolicy.run({ databaseId: ids.databaseId, schemaLocked: false }), + ); + }); + + it("commits the actor-aware property transition envelope atomically", async () => { + await runWithRequestContext( + { + userEmail: OWNER, + run: { + owner: OWNER, + threadId: "thread-hooks-1", + model: "test-agent-model", + }, + }, + () => + setProperty.run({ + documentId: ids.itemDocumentId, + propertyId: ids.statusPropertyId, + value: ids.reviewOptionId, + }), + ); + + const events = await getDb() + .select() + .from(schema.workflowEvents) + .where(eq(schema.workflowEvents.subjectId, ids.itemDocumentId)); + expect(events).toHaveLength(1); + const event = events[0]; + const payload = JSON.parse(event.payload); + const actor = JSON.parse(event.actorContext); + expect(payload).toMatchObject({ + databaseId: ids.databaseId, + propertyId: ids.statusPropertyId, + beforeValue: ids.draftOptionId, + afterValue: ids.reviewOptionId, + propertyValues: { + [ids.assigneePropertyId]: ["reviewer@example.com"], + }, + }); + expect(actor).toMatchObject({ + initiator: { kind: "human", id: OWNER }, + executor: { + kind: "agent", + id: "thread-hooks-1", + model: "test-agent-model", + }, + origin: { kind: "agent", threadId: "thread-hooks-1" }, + }); + expect( + matchesContentDatabaseHook( + { + ...event, + payload, + actorContext: actor, + }, + { + databaseId: ids.databaseId, + trigger: { + kind: "property_changed", + propertyId: ids.statusPropertyId, + fromOptionId: ids.draftOptionId, + toOptionId: ids.reviewOptionId, + }, + conditions: { + mode: "all", + clauses: [ + { + propertyId: ids.assigneePropertyId, + operator: "is_not_empty", + }, + ], + }, + }, + ), + ).toBe(true); + }); + + it("carries causal hook lineage into the atomic property event", async () => { + await runWithRequestContext({ userEmail: OWNER }, () => + runWithContentWorkflowCausality( + { + causalEventId: "parent-event-example", + parentExecutionId: "parent-execution-example", + parentSubscriptionId: "parent-subscription-example", + chainDepth: 2, + subscriptionPath: [ + "first-subscription", + "parent-subscription-example", + ], + initiator: { kind: "human", id: COLLABORATOR }, + }, + () => + setProperty.run({ + documentId: ids.itemDocumentId, + propertyId: ids.assigneePropertyId, + value: [ADMIN], + }), + ), + ); + + const [event] = await getDb() + .select() + .from(schema.workflowEvents) + .where(eq(schema.workflowEvents.causalEventId, "parent-event-example")); + expect(event).toBeTruthy(); + expect(JSON.parse(event.actorContext)).toMatchObject({ + initiator: { kind: "human", id: COLLABORATOR }, + executor: { + kind: "automation", + id: "content-hook:parent-subscription-example", + }, + origin: { kind: "system" }, + lineage: { + parentExecutionId: "parent-execution-example", + parentSubscriptionId: "parent-subscription-example", + chainDepth: 2, + subscriptionPath: ["first-subscription", "parent-subscription-example"], + }, + authority: { ownerEmail: OWNER }, + }); + }); + + it("notifies newly added people by default and honors item-to-global preference precedence", async () => { + const database = { + id: ids.databaseId, + title: "Design asks", + ownerEmail: OWNER, + orgId: null, + }; + await materializeWorkflowExecutions({ now: Date.now() }); + const subscription = await getWorkflowSubscription( + contentDefaultPersonSubscriptionId(database.id), + ); + expect(subscription).toMatchObject({ + id: contentDefaultPersonSubscriptionId(database.id), + enabled: true, + config: { + system: "default_person_notifications", + databaseId: database.id, + }, + }); + if (!subscription) + throw new Error("Virtual person rule was not materialized"); + const event = { + id: "default-person-event", + eventSequence: 100, + topic: "content.database.property.changed", + subjectType: "content_database_item", + subjectId: ids.itemDocumentId, + subjectKey: `content_database_item:${ids.itemDocumentId}`, + ownerEmail: OWNER, + orgId: null, + payload: { + databaseId: ids.databaseId, + propertyType: "person", + beforeValue: ["existing@example.com"], + afterValue: ["existing@example.com", "reviewer@example.com"], + }, + actorContext: {}, + causalEventId: null, + occurredAt: Date.now(), + availableAt: Date.now(), + createdAt: Date.now(), + }; + expect( + prepareContentNotificationEffect({ event, subscription }), + ).toMatchObject({ + payload: { recipients: ["reviewer@example.com"] }, + }); + expect( + prepareContentNotificationEffect({ + event: { + ...event, + id: "default-person-create-event", + topic: "content.database.item.created", + payload: { + databaseId: ids.databaseId, + personPropertyIds: [ids.assigneePropertyId], + propertyValues: { + [ids.assigneePropertyId]: ["reviewer@example.com"], + [ids.statusPropertyId]: ["not-a-person@example.com"], + }, + }, + }, + subscription, + }), + ).toMatchObject({ + payload: { recipients: ["reviewer@example.com"] }, + }); + + const actionContext = { + caller: "frontend" as const, + userEmail: "reviewer@example.com", + orgId: null, + }; + const managePersonalPreference = ( + args: Parameters[0], + ) => + runWithRequestContext({ userEmail: "reviewer@example.com" }, () => + managePreference.run(args, actionContext), + ); + await managePersonalPreference({ + action: "set", + target: { scope: "global" }, + enabled: false, + }); + await managePersonalPreference({ + action: "set", + target: { scope: "database", databaseId: ids.databaseId }, + enabled: true, + }); + await managePersonalPreference({ + action: "set", + target: { + scope: "rule", + databaseId: ids.databaseId, + subscriptionId: subscription.id, + }, + enabled: false, + }); + await managePersonalPreference({ + action: "set", + target: { + scope: "item", + databaseId: ids.databaseId, + documentId: ids.itemDocumentId, + }, + enabled: true, + }); + await expect( + runWithRequestContext({ userEmail: "reviewer@example.com" }, () => + getPreference.run( + { + scope: "item", + databaseId: ids.databaseId, + documentId: ids.itemDocumentId, + }, + actionContext, + ), + ), + ).resolves.toMatchObject({ + target: { + scope: "item", + databaseId: ids.databaseId, + documentId: ids.itemDocumentId, + }, + preference: { enabled: true, source: "item" }, + }); + await expect( + getPreference.run( + { + scope: "item", + databaseId: ids.databaseId, + documentId: ids.itemDocumentId, + subscriptionId: subscription.id, + }, + actionContext, + ), + ).rejects.toThrow("Unexpected identifiers for item scope."); + await expect( + resolveContentNotificationPreference({ + ownerEmail: "reviewer@example.com", + databaseId: ids.databaseId, + subscriptionId: subscription.id, + documentId: ids.itemDocumentId, + }), + ).resolves.toMatchObject({ enabled: true, source: "item" }); + + await managePersonalPreference({ + action: "remove", + target: { + scope: "item", + databaseId: ids.databaseId, + documentId: ids.itemDocumentId, + }, + }); + await expect( + resolveContentNotificationPreference({ + ownerEmail: "reviewer@example.com", + databaseId: ids.databaseId, + subscriptionId: subscription.id, + documentId: ids.itemDocumentId, + }), + ).resolves.toMatchObject({ enabled: false, source: "rule" }); + + await executeContentDatabaseHook({ + id: "suppressed-execution", + eventId: event.id, + subscriptionId: subscription.id, + subscriptionVersion: subscription.version, + status: "running", + attempt: 1, + leaseToken: "suppressed-lease", + leaseExpiresAt: Date.now() + 30_000, + fenceVersion: 1, + event, + subscription, + }); + const [effect] = await getDb() + .select() + .from(schema.workflowEffects) + .where(eq(schema.workflowEffects.executionId, "suppressed-execution")); + expect(effect.status).toBe("suppressed"); + expect(JSON.parse(effect.result)).toMatchObject({ + recipient: "reviewer@example.com", + outcome: "suppressed_by_preference", + preferenceScope: "rule", + }); + }); + + it("polls durable events without a bus wake and records unroutable recipients", async () => { + const eventId = "hooks_bus_off_event"; + const now = Date.now(); + await getDb().transaction(async (tx: any) => { + const eventSequence = await allocateContentWorkflowEventSequence(tx); + await tx.insert(schema.workflowEvents).values( + createWorkflowEventValues({ + id: eventId, + eventSequence, + topic: "content.builder.publication.confirmed", + subjectType: "content_database_item", + subjectId: ids.itemDocumentId, + ownerEmail: OWNER, + payload: { + databaseId: ids.databaseId, + documentId: ids.itemDocumentId, + effect: "publish", + propertyValues: { + [ids.assigneePropertyId]: ["no-access@example.com"], + }, + }, + actorContext: { + initiator: { kind: "system", id: "system" }, + executor: { kind: "system", id: "system" }, + authority: { ownerEmail: OWNER }, + }, + occurredAt: now, + }), + ); + }); + + const [hook] = await runWithRequestContext({ userEmail: OWNER }, () => + listHooks.run({ databaseId: ids.databaseId }), + ).then((result) => result.hooks); + let execution: any; + for (let attempt = 0; attempt < 30; attempt += 1) { + [execution] = await getDb() + .select() + .from(schema.workflowExecutions) + .where( + and( + eq(schema.workflowExecutions.eventId, eventId), + eq(schema.workflowExecutions.subscriptionId, hook.id), + ), + ); + if (execution?.status === "succeeded") break; + await new Promise((resolve) => setTimeout(resolve, 100)); + } + expect(execution?.status).toBe("succeeded"); + const [effect] = await getDb() + .select() + .from(schema.workflowEffects) + .where(eq(schema.workflowEffects.executionId, execution.id)); + expect(effect).toMatchObject({ status: "failed" }); + expect(JSON.parse(effect.result)).toEqual({ + recipient: "no-access@example.com", + unroutable: true, + }); + expect(effect.errorMessage).toContain("does not have access"); + }); + + it("derives inspector truth from the core ledger and limits retry acknowledgement to the owner", async () => { + const [hook] = await runWithRequestContext({ userEmail: OWNER }, () => + listHooks.run({ databaseId: ids.databaseId }), + ).then((result) => result.hooks); + const [version] = await getDb() + .select() + .from(schema.workflowSubscriptionVersions) + .where(eq(schema.workflowSubscriptionVersions.subscriptionId, hook.id)); + const now = Date.now(); + await getDb() + .insert(schema.workflowExecutions) + .values([ + { + id: "inspect-unknown-execution", + eventId: "inspect-event", + subscriptionId: hook.id, + subscriptionVersion: version.version, + subjectKey: `content_database_item:${ids.itemDocumentId}`, + status: "unknown", + attempt: 1, + fenceVersion: 1, + errorMessage: "Delivery outcome is unknown.", + createdAt: now, + updatedAt: now, + completedAt: now, + }, + { + id: "inspect-failed-execution", + eventId: "inspect-retry-event", + subscriptionId: hook.id, + subscriptionVersion: version.version, + subjectKey: "content_database_item:retry-item", + status: "failed", + attempt: 2, + fenceVersion: 2, + errorMessage: "Delivery failed.", + createdAt: now - 1, + updatedAt: now - 1, + completedAt: now - 1, + }, + ]); + await getDb() + .insert(schema.workflowEffects) + .values([ + { + id: "inspect-delivered-effect", + executionId: "inspect-unknown-execution", + kind: "notification", + idempotencyKey: "inspect-delivered-effect-key", + status: "delivered", + result: JSON.stringify({ + recipient: "reviewer@example.com", + notificationId: "inspect-notification", + }), + createdAt: now, + updatedAt: now, + }, + { + id: "inspect-coalesced-effect", + executionId: "inspect-unknown-execution", + kind: "notification", + idempotencyKey: "inspect-coalesced-effect-key", + status: "coalesced", + result: JSON.stringify({ + outcome: "coalesced_by_event_recipient_item_destination", + coalescedIntoExecutionId: "another-execution", + }), + createdAt: now + 1, + updatedAt: now + 1, + }, + ]); + await getDb().insert(schema.notificationDeliveryAttempts).values({ + id: "inspect-delivery-attempt", + effectId: "inspect-delivered-effect", + notificationId: "inspect-notification", + channel: "inbox", + attempt: 1, + status: "delivered", + createdAt: now, + updatedAt: now, + }); + + await expect( + runWithRequestContext({ userEmail: ADMIN }, () => + manageExecution.run( + { + action: "acknowledge", + databaseId: ids.databaseId, + executionId: "inspect-unknown-execution", + }, + { caller: "frontend", userEmail: ADMIN, orgId: null }, + ), + ), + ).rejects.toThrow(/only the database owner/i); + + const acknowledged = await runWithRequestContext({ userEmail: OWNER }, () => + manageExecution.run( + { + action: "acknowledge", + databaseId: ids.databaseId, + executionId: "inspect-unknown-execution", + }, + { caller: "frontend", userEmail: OWNER, orgId: null }, + ), + ); + expect(acknowledged.execution.status).toBe("acknowledged"); + await expect( + runWithRequestContext({ userEmail: OWNER }, () => + manageExecution.run( + { + action: "retry", + databaseId: ids.databaseId, + executionId: "inspect-unknown-execution", + }, + { caller: "frontend", userEmail: OWNER, orgId: null }, + ), + ), + ).rejects.toThrow(/only failed or unknown/i); + + const retried = await runWithRequestContext({ userEmail: OWNER }, () => + manageExecution.run( + { + action: "retry", + databaseId: ids.databaseId, + executionId: "inspect-failed-execution", + }, + { caller: "frontend", userEmail: OWNER, orgId: null }, + ), + ); + expect(retried.execution).toMatchObject({ + status: "pending", + attempt: 2, + }); + + const inspected = await runWithRequestContext({ userEmail: OWNER }, () => + listExecutions.run({ databaseId: ids.databaseId, limit: 50 }), + ); + const execution = inspected.executions.find( + (candidate) => candidate.id === "inspect-unknown-execution", + ); + expect(execution).toMatchObject({ + hookName: "Share published Builder article", + subscriptionVersion: version.version, + status: "acknowledged", + canRetry: false, + canAcknowledge: false, + effects: [ + { + id: "inspect-delivered-effect", + status: "delivered", + deliveryAttempts: [ + { + id: "inspect-delivery-attempt", + channel: "inbox", + status: "delivered", + }, + ], + }, + { + id: "inspect-coalesced-effect", + status: "coalesced", + result: { + outcome: "coalesced_by_event_recipient_item_destination", + }, + }, + ], + }); + }); +}); diff --git a/templates/content/actions/content-database-lifecycle.db.test.ts b/templates/content/actions/content-database-lifecycle.db.test.ts index 0e915436f6..49255c2795 100644 --- a/templates/content/actions/content-database-lifecycle.db.test.ts +++ b/templates/content/actions/content-database-lifecycle.db.test.ts @@ -378,6 +378,9 @@ describe("content database soft-delete actions and reads", () => { expect((await databaseRow(databaseId))?.deletedAt).toEqual( deleted.deletedAt, ); + await runWithRequestContext({ userEmail: OWNER }, () => + deleteContentDatabaseAction.run({ databaseId }), + ); const restored = await runWithRequestContext({ userEmail: OWNER }, () => restoreContentDatabaseAction.run({ databaseId }), @@ -385,6 +388,26 @@ describe("content database soft-delete actions and reads", () => { expect(restored.documentId).toBe(databaseDocumentId); expect(restored.deletedAt).toBeNull(); expect((await databaseRow(databaseId))?.deletedAt).toBeNull(); + await runWithRequestContext({ userEmail: OWNER }, () => + restoreContentDatabaseAction.run({ databaseId }), + ); + + const events = await getDb() + .select() + .from(schema.workflowEvents) + .where(eq(schema.workflowEvents.subjectId, databaseId)); + expect(events.map((event) => event.topic).sort()).toEqual([ + "content.database.archived", + "content.database.restored", + ]); + expect(events.map((event) => JSON.parse(event.payload))).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + databaseId, + documentId: databaseDocumentId, + }), + ]), + ); }); it("clears stale inline ownership when restoring after the owner block is gone", async () => { diff --git a/templates/content/actions/content-database-schema-lock.layout.test.ts b/templates/content/actions/content-database-schema-lock.layout.test.ts new file mode 100644 index 0000000000..6370170edc --- /dev/null +++ b/templates/content/actions/content-database-schema-lock.layout.test.ts @@ -0,0 +1,23 @@ +import { readFileSync } from "node:fs"; + +import { describe, expect, it } from "vitest"; + +const schemaMutationActions = [ + "configure-document-property.ts", + "delete-document-property.ts", + "duplicate-document-property.ts", + "reorder-document-property.ts", + "add-content-database-source-field-property.ts", + "bind-content-database-source-field.ts", + "materialize-builder-required-fields.ts", +]; + +describe("Content database schema lock coverage", () => { + it.each(schemaMutationActions)("guards %s", (fileName) => { + const source = readFileSync( + new URL(`./${fileName}`, import.meta.url), + "utf8", + ); + expect(source).toContain("assertContentDatabaseSchemaUnlocked"); + }); +}); diff --git a/templates/content/actions/content-database-validation.db.test.ts b/templates/content/actions/content-database-validation.db.test.ts new file mode 100644 index 0000000000..44267e0f08 --- /dev/null +++ b/templates/content/actions/content-database-validation.db.test.ts @@ -0,0 +1,344 @@ +import { rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { runWithRequestContext } from "@agent-native/core/server"; +import { and, eq } from "drizzle-orm"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +import { serializePropertyOptions } from "../shared/properties.js"; +import { ContentReadinessError } from "./_content-database-validation.js"; + +const TEST_DB_PATH = join( + tmpdir(), + `content-database-validation-${process.pid}-${Date.now()}.sqlite`, +); +const OWNER = "validation-owner@example.com"; +const EDITOR = "validation-editor@example.com"; + +type Schema = typeof import("../server/db/schema.js"); +let getDb: () => any; +let schema: Schema; +let manageValidation: typeof import("./manage-content-database-validation.js").default; +let setProperty: typeof import("./set-document-property.js").default; +let addItem: typeof import("./add-database-item.js").default; +const spaceId = "validation_space"; + +const ids = { + databaseDocumentId: "validation_database_document", + databaseId: "validation_database", + itemDocumentId: "validation_item_document", + itemId: "validation_item", + statusPropertyId: "validation_status", + briefPropertyId: "validation_brief", + draftOptionId: "validation_draft", + reviewOptionId: "validation_review", +}; + +beforeAll(async () => { + process.env.DATABASE_URL = `file:${TEST_DB_PATH}`; + const dbModule = await import("../server/db/index.js"); + getDb = dbModule.getDb; + schema = dbModule.schema; + const plugin = (await import("../server/plugins/db.js")).default; + await plugin(undefined as any); + manageValidation = (await import("./manage-content-database-validation.js")) + .default; + setProperty = (await import("./set-document-property.js")).default; + addItem = (await import("./add-database-item.js")).default; + + const now = new Date().toISOString(); + const { systemIdsForContentSpace } = await import("./_content-spaces.js"); + const filesIds = systemIdsForContentSpace(spaceId, "files"); + await getDb() + .insert(schema.documents) + .values([ + { + id: filesIds.documentId, + spaceId, + ownerEmail: OWNER, + title: "Files", + content: "", + visibility: "private", + createdAt: now, + updatedAt: now, + }, + { + id: ids.databaseDocumentId, + spaceId, + ownerEmail: OWNER, + title: "Design asks", + content: "", + visibility: "private", + createdAt: now, + updatedAt: now, + }, + { + id: ids.itemDocumentId, + spaceId, + ownerEmail: OWNER, + parentId: ids.databaseDocumentId, + title: "Homepage refresh", + content: "", + visibility: "private", + createdAt: now, + updatedAt: now, + }, + ]); + await getDb().insert(schema.contentDatabases).values({ + id: filesIds.databaseId, + spaceId, + systemRole: "files", + ownerEmail: OWNER, + documentId: filesIds.documentId, + title: "Files", + createdAt: now, + updatedAt: now, + }); + await getDb().insert(schema.contentDatabases).values({ + id: ids.databaseId, + spaceId, + ownerEmail: OWNER, + documentId: ids.databaseDocumentId, + title: "Design asks", + createdAt: now, + updatedAt: now, + }); + await getDb().insert(schema.contentDatabaseItems).values({ + id: ids.itemId, + ownerEmail: OWNER, + databaseId: ids.databaseId, + documentId: ids.itemDocumentId, + createdAt: now, + updatedAt: now, + }); + await getDb() + .insert(schema.documentPropertyDefinitions) + .values([ + { + id: ids.statusPropertyId, + ownerEmail: OWNER, + databaseId: ids.databaseId, + name: "Status", + type: "status", + optionsJson: serializePropertyOptions({ + options: [ + { id: ids.draftOptionId, name: "Draft", color: "gray" }, + { + id: ids.reviewOptionId, + name: "Ready for review", + color: "blue", + }, + ], + }), + createdAt: now, + updatedAt: now, + }, + { + id: ids.briefPropertyId, + ownerEmail: OWNER, + databaseId: ids.databaseId, + name: "Creative brief", + type: "text", + createdAt: now, + updatedAt: now, + }, + ]); + await getDb() + .insert(schema.documentPropertyValues) + .values({ + id: "validation_status_value", + ownerEmail: OWNER, + documentId: ids.itemDocumentId, + propertyId: ids.statusPropertyId, + valueJson: JSON.stringify(ids.draftOptionId), + createdAt: now, + updatedAt: now, + }); + await getDb().insert(schema.documentShares).values({ + id: "validation_editor_share", + resourceId: ids.databaseDocumentId, + principalType: "user", + principalId: EDITOR, + role: "editor", + createdBy: OWNER, + createdAt: now, + }); +}); + +afterAll(() => { + for (const suffix of ["", "-shm", "-wal"]) { + rmSync(`${TEST_DB_PATH}${suffix}`, { force: true }); + } +}); + +describe("Content database readiness validation", () => { + it("allows only database admins to configure stable-ID validation", async () => { + const validation = { + requiredForSubmission: [ids.briefPropertyId], + statusRequirements: [ + { + statusPropertyId: ids.statusPropertyId, + statusOptionId: ids.reviewOptionId, + requiredPropertyIds: [ids.briefPropertyId], + }, + ], + }; + await expect( + runWithRequestContext({ userEmail: EDITOR }, () => + manageValidation.run({ databaseId: ids.databaseId, validation }), + ), + ).rejects.toThrow(); + + const result = await runWithRequestContext({ userEmail: OWNER }, () => + manageValidation.run({ databaseId: ids.databaseId, validation }), + ); + expect(result.validation).toEqual(validation); + }); + + it("rejects validation references outside the database", async () => { + await expect( + runWithRequestContext({ userEmail: OWNER }, () => + manageValidation.run({ + databaseId: ids.databaseId, + validation: { + requiredForSubmission: ["foreign_property"], + statusRequirements: [], + }, + }), + ), + ).rejects.toThrow('Property "foreign_property" does not belong'); + }); + + it("keeps ordinary item creation as an incomplete draft", async () => { + const result = await runWithRequestContext({ userEmail: OWNER }, () => + addItem.run({ + databaseId: ids.databaseId, + title: "Draft without a brief", + }), + ); + const [created] = await getDb() + .select({ id: schema.contentDatabaseItems.id }) + .from(schema.contentDatabaseItems) + .where(eq(schema.contentDatabaseItems.id, result.createdItemId)); + expect(created?.id).toBe(result.createdItemId); + const submittedEvents = await getDb() + .select() + .from(schema.workflowEvents) + .where( + and( + eq(schema.workflowEvents.topic, "content.database.item.submitted"), + eq(schema.workflowEvents.subjectId, result.createdDocumentId), + ), + ); + expect(submittedEvents).toEqual([]); + }); + + it("emits one actor-aware submission event for a complete add", async () => { + const result = await runWithRequestContext( + { + userEmail: OWNER, + run: { + owner: OWNER, + threadId: "submission-thread", + model: "test-model", + }, + }, + () => + addItem.run({ + databaseId: ids.databaseId, + title: "Complete brief", + submissionIntent: "submitted", + propertyValues: { + [ids.briefPropertyId]: "All evidence is attached.", + }, + }), + ); + const events = await getDb() + .select() + .from(schema.workflowEvents) + .where( + and( + eq(schema.workflowEvents.topic, "content.database.item.submitted"), + eq(schema.workflowEvents.subjectId, result.createdDocumentId), + ), + ); + expect(events).toHaveLength(1); + expect(JSON.parse(events[0].actorContext)).toMatchObject({ + executor: { + kind: "agent", + id: "submission-thread", + model: "test-model", + }, + }); + }); + + it("blocks a configured status transition before mutation with structured evidence", async () => { + let error: unknown; + try { + await runWithRequestContext({ userEmail: OWNER }, () => + setProperty.run({ + documentId: ids.itemDocumentId, + propertyId: ids.statusPropertyId, + value: ids.reviewOptionId, + }), + ); + } catch (caught) { + error = caught; + } + expect(error).toBeInstanceOf(ContentReadinessError); + expect(error).toMatchObject({ + code: "CONTENT_READINESS_REQUIRED", + statusCode: 409, + details: { + phase: "status_transition", + databaseId: ids.databaseId, + documentId: ids.itemDocumentId, + statusPropertyId: ids.statusPropertyId, + statusOptionId: ids.reviewOptionId, + missingFields: [ + { propertyId: ids.briefPropertyId, name: "Creative brief" }, + ], + }, + }); + const [statusAfterFailure] = await getDb() + .select({ valueJson: schema.documentPropertyValues.valueJson }) + .from(schema.documentPropertyValues) + .where( + and( + eq(schema.documentPropertyValues.documentId, ids.itemDocumentId), + eq(schema.documentPropertyValues.propertyId, ids.statusPropertyId), + ), + ); + expect(statusAfterFailure.valueJson).toBe( + JSON.stringify(ids.draftOptionId), + ); + }); + + it("allows the transition after required evidence is present", async () => { + await runWithRequestContext({ userEmail: OWNER }, () => + setProperty.run({ + documentId: ids.itemDocumentId, + propertyId: ids.briefPropertyId, + value: "Approved copy and dimensions", + }), + ); + await runWithRequestContext({ userEmail: OWNER }, () => + setProperty.run({ + documentId: ids.itemDocumentId, + propertyId: ids.statusPropertyId, + value: ids.reviewOptionId, + }), + ); + const [status] = await getDb() + .select({ valueJson: schema.documentPropertyValues.valueJson }) + .from(schema.documentPropertyValues) + .where( + and( + eq(schema.documentPropertyValues.documentId, ids.itemDocumentId), + eq(schema.documentPropertyValues.propertyId, ids.statusPropertyId), + ), + ); + expect(status.valueJson).toBe(JSON.stringify(ids.reviewOptionId)); + }); +}); diff --git a/templates/content/actions/content-hook-effects.test.ts b/templates/content/actions/content-hook-effects.test.ts new file mode 100644 index 0000000000..556527f05b --- /dev/null +++ b/templates/content/actions/content-hook-effects.test.ts @@ -0,0 +1,864 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { + notifyWithDelivery, + notifyPersonalWithDelivery, + claimWorkflowEffectRetry, + recordWorkflowEffect, + finalizeWorkflowEffect, + resolveAccess, + resolveContentNotificationPreference, + resolveContentHookRuntimeControls, + scheduleWorkflowWork, + setDocumentPropertyValue, +} = vi.hoisted(() => ({ + notifyWithDelivery: vi.fn(), + notifyPersonalWithDelivery: vi.fn(), + claimWorkflowEffectRetry: vi.fn(), + recordWorkflowEffect: vi.fn(), + finalizeWorkflowEffect: vi.fn(), + resolveAccess: vi.fn(), + resolveContentNotificationPreference: vi.fn(), + resolveContentHookRuntimeControls: vi.fn(), + scheduleWorkflowWork: vi.fn(), + setDocumentPropertyValue: vi.fn(), +})); + +vi.mock("@agent-native/core/notifications", () => ({ + notifyWithDelivery, + notifyPersonalWithDelivery, +})); +vi.mock("@agent-native/core/sharing", async (importOriginal) => ({ + ...(await importOriginal()), + resolveAccess, +})); +vi.mock("@agent-native/core/workflow", async (importOriginal) => ({ + ...(await importOriginal()), + recordWorkflowEffect, + claimWorkflowEffectRetry, + finalizeWorkflowEffect, + scheduleWorkflowWork, +})); +vi.mock("./_content-notification-preferences.js", () => ({ + resolveContentNotificationPreference, +})); +vi.mock("./_content-hook-runtime-controls.js", () => ({ + resolveContentHookRuntimeControls, +})); +vi.mock("./set-document-property.js", () => ({ setDocumentPropertyValue })); +vi.mock("./_content-database-hooks.js", async (importOriginal) => ({ + ...(await importOriginal()), + contentHookHasCurrentAuthority: vi.fn(async () => true), +})); + +import { + getRequestOrgId, + getRequestUserEmail, +} from "@agent-native/core/server"; + +import { + contentHookConditionsMatch, + executeContentDatabaseHook, + matchesContentDatabaseHook, + previewContentDatabaseHook, +} from "./_content-hook-execution.js"; + +function mutationClaim(lineage: Record = {}) { + return { + id: "execution-mutation", + eventId: "event-mutation", + subscriptionId: "subscription-mutation", + subscriptionVersion: 1, + status: "running" as const, + attempt: 1, + leaseToken: "lease-mutation", + leaseExpiresAt: Date.now() + 30_000, + fenceVersion: 1, + event: { + id: "event-mutation", + topic: "content.database.property.changed", + subjectType: "content_database_item", + subjectId: "document-example", + subjectKey: "content_database_item:document-example", + ownerEmail: "owner@example.com", + orgId: null, + payload: { + databaseId: "database-example", + propertyId: "status-example", + beforeValue: "draft-example", + afterValue: "published-example", + }, + actorContext: { + initiator: { kind: "human", id: "author@example.com" }, + lineage, + }, + causalEventId: null, + occurredAt: 1, + availableAt: 1, + createdAt: 1, + }, + subscription: { + id: "subscription-mutation", + version: 1, + kind: "deterministic" as const, + eventPattern: "content.database.property.changed", + ownerEmail: "owner@example.com", + orgId: null, + enabled: true, + createdAt: 1, + updatedAt: 1, + config: { + domain: "content", + databaseId: "database-example", + name: "Set review owner", + trigger: { + kind: "property_changed", + propertyId: "status-example", + fromOptionId: "draft-example", + toOptionId: "published-example", + }, + effects: [ + { + version: 1, + kind: "set_property", + propertyId: "review-owner-example", + value: "owner@example.com", + }, + ], + timing: { kind: "immediate" }, + createdBy: "owner@example.com", + }, + }, + }; +} + +describe("Content shared-destination hook effects", () => { + beforeEach(() => { + vi.clearAllMocks(); + let effect = 0; + const effects = new Map>(); + recordWorkflowEffect.mockImplementation( + async ({ executionId, idempotencyKey, kind }) => { + const existing = effects.get(idempotencyKey); + if (existing) return { created: false, effect: existing }; + const created = { + id: `effect-${effect++}`, + executionId, + idempotencyKey, + kind, + status: "unknown", + }; + effects.set(idempotencyKey, created); + return { created: true, effect: created }; + }, + ); + finalizeWorkflowEffect.mockImplementation(async ({ effectId, status }) => { + for (const effectValue of effects.values()) { + if (effectValue.id === effectId) effectValue.status = status; + } + return true; + }); + claimWorkflowEffectRetry.mockImplementation(async ({ effectId }) => { + for (const effectValue of effects.values()) { + if (effectValue.id !== effectId || effectValue.status !== "failed") { + continue; + } + effectValue.status = "unknown"; + return true; + } + return false; + }); + notifyWithDelivery.mockImplementation(async (input) => ({ + deliveredChannels: input.channels ?? [], + unknownChannels: [], + skippedChannels: [], + failedChannels: [], + channelOutcomes: [], + notification: { id: "notification-example" }, + })); + notifyPersonalWithDelivery.mockResolvedValue({ + deliveredChannels: ["inbox"], + unknownChannels: [], + skippedChannels: [], + failedChannels: [], + channelOutcomes: [], + notification: { id: "notification-example" }, + }); + resolveAccess.mockResolvedValue({ role: "viewer" }); + resolveContentNotificationPreference.mockResolvedValue({ + enabled: true, + source: "default", + preferenceId: null, + }); + resolveContentHookRuntimeControls.mockResolvedValue({ + evaluatorPaused: false, + effectsPaused: false, + }); + scheduleWorkflowWork.mockResolvedValue("scheduled-example"); + setDocumentPropertyValue.mockResolvedValue({ + documentId: "document-example", + }); + }); + + it("evaluates inspectable all and any conditions against one property snapshot", () => { + const values = { + status: "ready", + title: "Launch notes", + tags: ["marketing", "sales"], + brief: "", + }; + expect( + contentHookConditionsMatch(values, { + mode: "all", + clauses: [ + { propertyId: "status", operator: "equals", value: "ready" }, + { propertyId: "title", operator: "contains", value: "Launch" }, + { propertyId: "tags", operator: "contains", value: "sales" }, + { propertyId: "brief", operator: "is_empty" }, + ], + }), + ).toBe(true); + expect( + contentHookConditionsMatch(values, { + mode: "any", + clauses: [ + { propertyId: "status", operator: "not_equals", value: "ready" }, + { propertyId: "brief", operator: "is_not_empty" }, + ], + }), + ).toBe(false); + }); + + it("applies conditions to the immutable event snapshot", () => { + const event = { + ...mutationClaim().event, + payload: { + ...mutationClaim().event.payload, + propertyValues: { status: "ready", requiredBrief: "" }, + }, + }; + expect( + matchesContentDatabaseHook(event, { + databaseId: "database-example", + trigger: { kind: "property_changed", propertyId: "status-example" }, + conditions: { + mode: "all", + clauses: [{ propertyId: "requiredBrief", operator: "is_empty" }], + }, + }), + ).toBe(true); + expect( + matchesContentDatabaseHook(event, { + databaseId: "database-example", + trigger: { kind: "property_changed", propertyId: "status-example" }, + conditions: { + mode: "all", + clauses: [{ propertyId: "requiredBrief", operator: "is_not_empty" }], + }, + }), + ).toBe(false); + }); + + it("does not execute an immediate Rule when its event-snapshot conditions fail", async () => { + const claim = mutationClaim() as any; + claim.event.payload.propertyValues = { requiredBrief: "" }; + claim.subscription.config.conditions = { + mode: "all", + clauses: [{ propertyId: "requiredBrief", operator: "is_not_empty" }], + }; + + await expect(executeContentDatabaseHook(claim)).resolves.toEqual({ + status: "succeeded", + }); + expect(setDocumentPropertyValue).not.toHaveBeenCalled(); + expect(recordWorkflowEffect).not.toHaveBeenCalled(); + }); + + it("matches logical submission independently from generic item creation", () => { + const event = { + ...mutationClaim().event, + topic: "content.database.item.submitted", + }; + expect( + matchesContentDatabaseHook(event, { + databaseId: "database-example", + trigger: { kind: "item_submitted" }, + }), + ).toBe(true); + expect( + matchesContentDatabaseHook(event, { + databaseId: "database-example", + trigger: { kind: "item_created" }, + }), + ).toBe(false); + }); + + it("runs ordered team Slack and signed-webhook effects without storing secret values", async () => { + const result = await executeContentDatabaseHook({ + id: "execution-example", + eventId: "event-example", + subscriptionId: "subscription-example", + subscriptionVersion: 1, + status: "running", + attempt: 1, + leaseToken: "lease-example", + leaseExpiresAt: Date.now() + 30_000, + fenceVersion: 1, + event: { + id: "event-example", + topic: "content.database.property.changed", + subjectType: "content_database_item", + subjectId: "document-example", + subjectKey: "content_database_item:document-example", + ownerEmail: "owner@example.com", + orgId: null, + payload: { + databaseId: "database-example", + propertyId: "status-example", + beforeValue: "draft-example", + afterValue: "published-example", + }, + actorContext: {}, + causalEventId: null, + occurredAt: 1, + availableAt: 1, + createdAt: 1, + }, + subscription: { + id: "subscription-example", + version: 1, + kind: "deterministic", + eventPattern: "content.database.property.changed", + ownerEmail: "owner@example.com", + orgId: null, + enabled: true, + createdAt: 1, + updatedAt: 1, + config: { + domain: "content", + databaseId: "database-example", + name: "Blog published", + trigger: { + kind: "property_changed", + propertyId: "status-example", + fromOptionId: "draft-example", + toOptionId: "published-example", + }, + effects: [ + { kind: "team_slack", webhookKey: "MARKETING_SLACK" }, + { + kind: "webhook", + urlKey: "PUBLISH_WEBHOOK_URL", + signatureKey: "PUBLISH_WEBHOOK_SIGNING_SECRET", + }, + ], + createdBy: "owner@example.com", + }, + }, + }); + + expect(result).toEqual({ status: "succeeded" }); + expect(recordWorkflowEffect.mock.calls.map(([call]) => call.kind)).toEqual([ + "team_slack", + "webhook", + ]); + expect(notifyWithDelivery).toHaveBeenCalledTimes(2); + expect(notifyWithDelivery.mock.calls[0][0]).toMatchObject({ + channels: ["slack"], + metadata: { + delivery: { slackWebhookUrl: "${keys.MARKETING_SLACK}" }, + }, + }); + expect(notifyWithDelivery.mock.calls[1][0]).toMatchObject({ + channels: ["webhook"], + metadata: { + delivery: { + webhookUrl: "${keys.PUBLISH_WEBHOOK_URL}", + webhookSignature: "${keys.PUBLISH_WEBHOOK_SIGNING_SECRET}", + }, + }, + }); + expect(JSON.stringify(notifyWithDelivery.mock.calls)).not.toContain( + "example-signing-secret", + ); + expect(finalizeWorkflowEffect).toHaveBeenCalledTimes(2); + }); + + it("keeps an accepted team Slack send unknown without a delivery receipt", async () => { + notifyWithDelivery.mockResolvedValueOnce({ + deliveredChannels: [], + unknownChannels: ["slack"], + skippedChannels: [], + failedChannels: [], + channelOutcomes: [ + { + channel: "slack", + status: "unknown", + evidence: { providerAccepted: true }, + }, + ], + }); + const claim = mutationClaim() as any; + claim.subscription.config.name = "Announce publication"; + claim.subscription.config.effects = [ + { kind: "team_slack", webhookKey: "MARKETING_SLACK" }, + ]; + + await expect(executeContentDatabaseHook(claim)).resolves.toMatchObject({ + status: "unknown", + }); + expect(finalizeWorkflowEffect).toHaveBeenCalledWith( + expect.objectContaining({ + status: "unknown", + result: expect.objectContaining({ + destination: "team_slack", + unknownChannels: ["slack"], + }), + }), + ); + }); + + it("retries a destination that explicitly failed before provider acceptance", async () => { + notifyWithDelivery.mockResolvedValueOnce({ + deliveredChannels: [], + unknownChannels: [], + skippedChannels: [], + failedChannels: ["slack"], + channelOutcomes: [ + { channel: "slack", status: "failed", error: "rejected" }, + ], + }); + const claim = mutationClaim() as any; + claim.subscription.config.effects = [ + { kind: "team_slack", webhookKey: "MARKETING_SLACK" }, + ]; + + await expect(executeContentDatabaseHook(claim)).resolves.toMatchObject({ + status: "retrying", + }); + claim.attempt = 2; + await expect(executeContentDatabaseHook(claim)).resolves.toEqual({ + status: "succeeded", + }); + expect(claimWorkflowEffectRetry).toHaveBeenCalledTimes(1); + expect(notifyWithDelivery).toHaveBeenCalledTimes(2); + }); + + it("binds destination delivery to the claimed owner and organization", async () => { + const claim = mutationClaim() as any; + claim.subscription.orgId = "org-claimed"; + claim.event.orgId = "org-claimed"; + claim.subscription.config.effects = [ + { kind: "webhook", urlKey: "URL", signatureKey: "SIGNATURE" }, + ]; + notifyWithDelivery.mockImplementationOnce(async (input) => { + expect(getRequestUserEmail()).toBe("owner@example.com"); + expect(getRequestOrgId()).toBe("org-claimed"); + return { + deliveredChannels: input.channels ?? [], + unknownChannels: [], + skippedChannels: [], + failedChannels: [], + channelOutcomes: [], + }; + }); + + await expect(executeContentDatabaseHook(claim)).resolves.toEqual({ + status: "succeeded", + }); + }); + + it("runs a versioned property effect through the certified mutation service", async () => { + await expect(executeContentDatabaseHook(mutationClaim())).resolves.toEqual({ + status: "succeeded", + }); + + expect(setDocumentPropertyValue).toHaveBeenCalledWith( + { + documentId: "document-example", + propertyId: "review-owner-example", + value: "owner@example.com", + }, + { caller: "tool", userEmail: "owner@example.com" }, + ); + expect(finalizeWorkflowEffect).toHaveBeenCalledWith( + expect.objectContaining({ + status: "delivered", + result: expect.objectContaining({ + outcome: "property_set", + chainDepth: 1, + subscriptionPath: ["subscription-mutation"], + }), + }), + ); + }); + + it.each([ + [ + "cycle_detected", + { subscriptionPath: ["subscription-mutation"], chainDepth: 1 }, + ], + [ + "max_chain_depth_exceeded", + { + subscriptionPath: Array.from( + { length: 8 }, + (_, index) => `subscription-${index}`, + ), + chainDepth: 8, + }, + ], + ])( + "records %s before refusing a property mutation", + async (outcome, lineage) => { + await expect( + executeContentDatabaseHook(mutationClaim(lineage)), + ).resolves.toEqual({ status: "succeeded" }); + + expect(setDocumentPropertyValue).not.toHaveBeenCalled(); + expect(finalizeWorkflowEffect).toHaveBeenCalledWith( + expect.objectContaining({ + status: "suppressed", + result: expect.objectContaining({ outcome }), + }), + ); + }, + ); + + it("previews matches and ordered effects without delivering anything", () => { + const preview = previewContentDatabaseHook({ + event: { + id: "event-preview", + topic: "content.database.property.changed", + subjectType: "content_database_item", + subjectId: "document-preview", + subjectKey: "content_database_item:document-preview", + ownerEmail: "owner@example.com", + orgId: null, + payload: { + databaseId: "database-example", + propertyId: "status-example", + beforeValue: "draft-example", + afterValue: "published-example", + propertyValues: { assignee: ["reviewer@example.com"] }, + }, + actorContext: { initiator: { kind: "human" } }, + causalEventId: null, + occurredAt: 1, + availableAt: 1, + createdAt: 1, + }, + subscription: { + id: "subscription-preview", + version: 1, + kind: "deterministic", + eventPattern: "content.database.property.changed", + ownerEmail: "owner@example.com", + orgId: null, + enabled: true, + createdAt: 1, + updatedAt: 1, + config: { + domain: "content", + databaseId: "database-example", + name: "Published", + trigger: { + kind: "property_changed", + propertyId: "status-example", + fromOptionId: "draft-example", + toOptionId: "published-example", + }, + effects: [ + { kind: "notify", recipientPersonPropertyId: "assignee" }, + { kind: "team_slack", webhookKey: "MARKETING_SLACK" }, + ], + createdBy: "owner@example.com", + }, + }, + }); + + expect(preview).toMatchObject({ + matched: true, + effects: [ + { + kind: "notify", + wouldAttempt: true, + recipients: ["reviewer@example.com"], + }, + { + kind: "team_slack", + wouldAttempt: true, + destinationKeyNames: ["MARKETING_SLACK"], + }, + ], + }); + expect(notifyWithDelivery).not.toHaveBeenCalled(); + expect(recordWorkflowEffect).not.toHaveBeenCalled(); + }); + + it("matches provider-confirmed Builder publication truth", () => { + const preview = previewContentDatabaseHook({ + event: { + id: "event-builder-published", + topic: "content.builder.publication.confirmed", + subjectType: "content_database_item", + subjectId: "document-builder", + subjectKey: "content_database_item:document-builder", + ownerEmail: "owner@example.com", + orgId: null, + payload: { databaseId: "database-example", effect: "publish" }, + actorContext: {}, + causalEventId: null, + occurredAt: 1, + availableAt: 1, + createdAt: 1, + }, + subscription: { + id: "subscription-builder", + version: 1, + kind: "deterministic", + eventPattern: "content.builder.publication.confirmed", + ownerEmail: "owner@example.com", + orgId: null, + enabled: true, + createdAt: 1, + updatedAt: 1, + config: { + domain: "content", + databaseId: "database-example", + name: "Builder published", + trigger: { + kind: "builder_publication_confirmed", + publicationAction: "publish", + }, + effects: [{ kind: "team_slack", webhookKey: "MARKETING_SLACK" }], + timing: { kind: "immediate" }, + createdBy: "owner@example.com", + }, + }, + }); + expect(preview?.matched).toBe(true); + }); + + it("schedules delayed and debounced effects on the shared durable timer", async () => { + const baseClaim = { + id: "execution-timed", + eventId: "event-timed", + subscriptionId: "subscription-timed", + subscriptionVersion: 1, + status: "running" as const, + attempt: 1, + leaseToken: "lease-timed", + leaseExpiresAt: Date.now() + 30_000, + fenceVersion: 1, + event: { + id: "event-timed", + topic: "content.database.item.created", + subjectType: "content_database_item", + subjectId: "document-timed", + subjectKey: "content_database_item:document-timed", + ownerEmail: "owner@example.com", + orgId: null, + payload: { databaseId: "database-example" }, + actorContext: {}, + causalEventId: null, + occurredAt: 1, + availableAt: 1, + createdAt: 1, + }, + subscription: { + id: "subscription-timed", + version: 1, + kind: "deterministic" as const, + eventPattern: "content.database.item.created", + ownerEmail: "owner@example.com", + orgId: null, + enabled: true, + createdAt: 1, + updatedAt: 1, + config: { + domain: "content", + databaseId: "database-example", + name: "Quiet assignment", + trigger: { kind: "item_created" }, + effects: [{ kind: "team_slack", webhookKey: "MARKETING_SLACK" }], + timing: { kind: "debounced", delayMinutes: 5 }, + createdBy: "owner@example.com", + }, + }, + }; + await expect(executeContentDatabaseHook(baseClaim)).resolves.toEqual({ + status: "succeeded", + }); + expect(scheduleWorkflowWork).toHaveBeenCalledWith( + expect.objectContaining({ + workType: "content_hook_timing", + dedupeKey: + "content_hook_debounce:subscription-timed:content_database_item:document-timed", + }), + ); + expect(notifyWithDelivery).not.toHaveBeenCalled(); + + await executeContentDatabaseHook({ + ...baseClaim, + id: "execution-later", + eventId: "event-later", + event: { ...baseClaim.event, id: "event-later" }, + }); + expect(scheduleWorkflowWork.mock.calls[1][0].dedupeKey).toBe( + scheduleWorkflowWork.mock.calls[0][0].dedupeKey, + ); + }); + + it("leaves pause authority to the core claim boundary", async () => { + resolveContentHookRuntimeControls.mockResolvedValue({ + evaluatorPaused: false, + effectsPaused: true, + }); + const claim = { + id: "execution-paused", + eventId: "event-paused", + subscriptionId: "subscription-paused", + subscriptionVersion: 1, + status: "running" as const, + attempt: 1, + leaseToken: "lease-paused", + leaseExpiresAt: Date.now() + 30_000, + fenceVersion: 1, + event: { + id: "event-paused", + topic: "content.database.item.created", + subjectType: "content_database_item", + subjectId: "document-paused", + subjectKey: "content_database_item:document-paused", + ownerEmail: "owner@example.com", + orgId: null, + payload: { databaseId: "database-example" }, + actorContext: {}, + causalEventId: null, + occurredAt: 1, + availableAt: 1, + createdAt: 1, + }, + subscription: { + id: "subscription-paused", + version: 1, + kind: "deterministic" as const, + eventPattern: "content.database.item.created", + ownerEmail: "owner@example.com", + orgId: null, + enabled: true, + createdAt: 1, + updatedAt: 1, + config: { + domain: "content", + databaseId: "database-example", + name: "Paused", + trigger: { kind: "item_created" }, + effects: [{ kind: "team_slack", webhookKey: "MARKETING_SLACK" }], + timing: { kind: "immediate" }, + createdBy: "owner@example.com", + }, + }, + }; + await expect(executeContentDatabaseHook(claim)).resolves.toEqual({ + status: "succeeded", + }); + expect(resolveContentHookRuntimeControls).not.toHaveBeenCalled(); + expect(recordWorkflowEffect).toHaveBeenCalledWith( + expect.objectContaining({ kind: "team_slack" }), + ); + expect(finalizeWorkflowEffect).toHaveBeenCalledWith( + expect.objectContaining({ + status: "delivered", + }), + ); + expect(notifyWithDelivery).toHaveBeenCalledTimes(1); + }); + + it("atomically coalesces default and custom personal hooks for one committed change", async () => { + const event = { + id: "event-coalesce", + topic: "content.database.item.created", + subjectType: "content_database_item", + subjectId: "document-coalesce", + subjectKey: "content_database_item:document-coalesce", + ownerEmail: "owner@example.com", + orgId: null, + payload: { + databaseId: "database-example", + personPropertyIds: ["assignee"], + propertyValues: { assignee: ["reviewer@example.com"] }, + }, + actorContext: {}, + causalEventId: null, + occurredAt: 1, + availableAt: 1, + createdAt: 1, + } as const; + const defaultSubscription = { + id: "content-default-person:database-example", + version: 1, + kind: "deterministic" as const, + eventPattern: "content.database.*", + ownerEmail: "owner@example.com", + orgId: null, + enabled: true, + createdAt: 1, + updatedAt: 1, + config: { + domain: "content", + system: "default_person_notifications", + databaseId: "database-example", + name: "Default mention", + }, + }; + const customSubscription = { + ...defaultSubscription, + id: "custom-assignment-hook", + eventPattern: "content.database.item.created", + config: { + domain: "content", + databaseId: "database-example", + name: "Assigned", + trigger: { kind: "item_created" }, + effects: [{ kind: "notify", recipientPersonPropertyId: "assignee" }], + createdBy: "owner@example.com", + }, + }; + const claim = (id: string, subscription: typeof defaultSubscription) => ({ + id, + eventId: event.id, + subscriptionId: subscription.id, + subscriptionVersion: 1, + status: "running" as const, + attempt: 1, + leaseToken: `lease-${id}`, + leaseExpiresAt: Date.now() + 30_000, + fenceVersion: 1, + event, + subscription, + }); + + await executeContentDatabaseHook( + claim("default-execution", defaultSubscription), + ); + await executeContentDatabaseHook( + claim( + "custom-execution", + customSubscription as typeof defaultSubscription, + ), + ); + + expect(notifyPersonalWithDelivery).toHaveBeenCalledTimes(1); + expect(finalizeWorkflowEffect).toHaveBeenCalledWith( + expect.objectContaining({ + status: "coalesced", + result: expect.objectContaining({ + recipient: "reviewer@example.com", + coalescedIntoExecutionId: "default-execution", + }), + }), + ); + }); +}); diff --git a/templates/content/actions/create-document.ts b/templates/content/actions/create-document.ts index 921ca53ba1..c7188d38b2 100644 --- a/templates/content/actions/create-document.ts +++ b/templates/content/actions/create-document.ts @@ -21,6 +21,11 @@ import { import { ensureDocumentFilesMembership } from "./_content-files.js"; import { resolveContentSpaceAccess } from "./_content-space-access.js"; import { provisionContentSpaces } from "./_content-spaces.js"; +import { + appendContentWorkflowEvent, + contentWorkflowFingerprint, + wakeContentWorkflowEvent, +} from "./_content-workflow.js"; import { documentsPositionScope, withPositionLock } from "./_position-utils.js"; function nanoid(size = 12): string { @@ -108,7 +113,7 @@ export default defineAction({ height: 900, }), }, - run: async (args) => { + run: async (args, ctx) => { const hasCreativeContextInput = Boolean( args.contextPackId || args.contextModeOverride || @@ -219,6 +224,7 @@ export default defineAction({ const now = new Date().toISOString(); const id = args.id || nanoid(); + let workflowEventId = ""; await withPositionLock( documentsPositionScope(ownerEmail, parentId), @@ -277,9 +283,26 @@ export default defineAction({ userEmail: currentUserEmail, orgId: orgId ?? undefined, }); + workflowEventId = await appendContentWorkflowEvent(tx, { + topic: "content.document.created", + subjectType: "document", + subjectId: id, + documentId: id, + ownerEmail, + orgId, + occurredAt: now, + actionContext: ctx, + payload: { + parentId, + spaceId, + changedFields: ["title", "content", "description", "icon"], + contentHash: contentWorkflowFingerprint(content), + }, + }); }); }, ); + wakeContentWorkflowEvent(workflowEventId); const [doc] = await db .select() diff --git a/templates/content/actions/database-row-batch-actions.db.test.ts b/templates/content/actions/database-row-batch-actions.db.test.ts index eb3af80003..d6154ed2ff 100644 --- a/templates/content/actions/database-row-batch-actions.db.test.ts +++ b/templates/content/actions/database-row-batch-actions.db.test.ts @@ -3,7 +3,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { runWithRequestContext } from "@agent-native/core/server"; -import { asc, eq, inArray } from "drizzle-orm"; +import { and, asc, eq, inArray } from "drizzle-orm"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; const TEST_DB_PATH = join( @@ -15,7 +15,9 @@ type Schema = typeof import("../server/db/schema.js"); let getDb: () => any; let schema: Schema; let duplicateDatabaseItemsAction: typeof import("./duplicate-database-items.js").default; +let duplicateDatabaseItemAction: typeof import("./duplicate-database-item.js").default; let deleteDatabaseItemsAction: typeof import("./delete-database-items.js").default; +let moveDatabaseItemAction: typeof import("./move-database-item.js").default; let addDatabaseItemAction: typeof import("./add-database-item.js").default; let spaceId: string; @@ -29,8 +31,11 @@ beforeAll(async () => { schema = dbModule.schema; duplicateDatabaseItemsAction = (await import("./duplicate-database-items.js")) .default; + duplicateDatabaseItemAction = (await import("./duplicate-database-item.js")) + .default; deleteDatabaseItemsAction = (await import("./delete-database-items.js")) .default; + moveDatabaseItemAction = (await import("./move-database-item.js")).default; addDatabaseItemAction = (await import("./add-database-item.js")).default; const plugin = (await import("../server/plugins/db.js")).default; await plugin(undefined as any); @@ -161,6 +166,29 @@ async function orderedRows(databaseId: string) { .orderBy(asc(schema.contentDatabaseItems.position)); } +async function workflowEventsForSubjects(topic: string, subjectIds: string[]) { + const rows = await getDb() + .select() + .from(schema.workflowEvents) + .where( + and( + eq(schema.workflowEvents.topic, topic), + inArray(schema.workflowEvents.subjectId, subjectIds), + ), + ); + return rows + .map((row: any) => ({ + ...row, + payload: JSON.parse(row.payload) as Record, + actorContext: JSON.parse(row.actorContext) as Record, + })) + .sort( + (left, right) => + Number(left.payload.position ?? left.payload.previousPosition ?? 0) - + Number(right.payload.position ?? right.payload.previousPosition ?? 0), + ); +} + describe("database row batch actions", () => { it("duplicates selected rows as one ordered block with copied values and inherited shares", async () => { const db = getDb(); @@ -286,11 +314,32 @@ describe("database row batch actions", () => { COLLABORATOR, COLLABORATOR, ]); + const events = await workflowEventsForSubjects( + "content.database.item.created", + result.duplicatedDocumentIds ?? [], + ); + expect(events).toHaveLength(2); + expect(events.map((event) => event.payload)).toEqual( + result.sourceToDuplicate?.map((duplicate, index) => + expect.objectContaining({ + databaseId, + documentId: duplicate.duplicatedDocumentId, + itemId: duplicate.duplicatedItemId, + sourceItemId: duplicate.sourceItemId, + sourceDocumentId: duplicate.sourceDocumentId, + position: 3 + index, + }), + ), + ); + expect(events.every((event) => !("content" in event.payload))).toBe(true); }); it("rejects mixed database duplicate batches before writing", async () => { const first = await createDatabaseWithRows(2); const second = await createDatabaseWithRows(1); + const eventCountBefore = ( + await getDb().select().from(schema.workflowEvents) + ).length; await expect( runWithRequestContext({ userEmail: OWNER }, () => @@ -303,6 +352,9 @@ describe("database row batch actions", () => { expect(await orderedRows(first.databaseId)).toHaveLength(2); expect(await orderedRows(second.databaseId)).toHaveLength(1); + expect((await getDb().select().from(schema.workflowEvents)).length).toBe( + eventCountBefore, + ); }); it("deletes selected rows recursively in one batch and renumbers survivors", async () => { @@ -354,6 +406,26 @@ describe("database row batch actions", () => { expect(remainingRows.map((row) => row.title)).toEqual(["Row 0", "Row 3"]); expect(remainingRows.map((row) => row.itemPosition)).toEqual([0, 1]); + const events = await workflowEventsForSubjects( + "content.database.item.deleted", + result.deletedDocumentIds, + ); + expect(events).toHaveLength(2); + expect(events.map((event) => event.payload)).toEqual([ + expect.objectContaining({ + databaseId, + documentId: rows[1].documentId, + itemId: rows[1].itemId, + previousPosition: 1, + }), + expect.objectContaining({ + databaseId, + documentId: rows[2].documentId, + itemId: rows[2].itemId, + previousPosition: 2, + }), + ]); + const deletedDocs = await db .select({ id: schema.documents.id }) .from(schema.documents) @@ -372,9 +444,66 @@ describe("database row batch actions", () => { expect(deletedValues).toEqual([]); }); + it("emits one bounded create event for a single duplicate and one move event only for a real move", async () => { + const { databaseId, rows } = await createDatabaseWithRows(2); + const duplicated = await runWithRequestContext( + { + userEmail: OWNER, + run: { owner: OWNER, threadId: "mutation-thread", model: "test-model" }, + }, + () => duplicateDatabaseItemAction.run({ itemId: rows[0].itemId }), + ); + const createdEvents = await workflowEventsForSubjects( + "content.database.item.created", + [duplicated.duplicatedDocumentId], + ); + expect(createdEvents).toHaveLength(1); + expect(createdEvents[0]).toMatchObject({ + payload: { + databaseId, + documentId: duplicated.duplicatedDocumentId, + itemId: duplicated.duplicatedItemId, + sourceItemId: rows[0].itemId, + sourceDocumentId: rows[0].documentId, + position: 1, + }, + actorContext: { + executor: { kind: "agent", id: "mutation-thread", model: "test-model" }, + }, + }); + + await runWithRequestContext({ userEmail: OWNER }, () => + moveDatabaseItemAction.run({ itemId: rows[1].itemId, position: 0 }), + ); + let movedEvents = await workflowEventsForSubjects( + "content.database.item.moved", + [rows[1].documentId], + ); + expect(movedEvents).toHaveLength(1); + expect(movedEvents[0].payload).toMatchObject({ + databaseId, + documentId: rows[1].documentId, + itemId: rows[1].itemId, + beforePosition: 2, + afterPosition: 0, + }); + + await runWithRequestContext({ userEmail: OWNER }, () => + moveDatabaseItemAction.run({ itemId: rows[1].itemId, position: 0 }), + ); + movedEvents = await workflowEventsForSubjects( + "content.database.item.moved", + [rows[1].documentId], + ); + expect(movedEvents).toHaveLength(1); + }); + it("rejects unauthorized delete batches before writing", async () => { const { databaseId, databaseDocumentId, rows } = await createDatabaseWithRows(2); + const eventCountBefore = ( + await getDb().select().from(schema.workflowEvents) + ).length; const db = getDb(); await db.insert(schema.documentShares).values({ id: nextId("share"), @@ -396,6 +525,9 @@ describe("database row batch actions", () => { ).rejects.toThrow(`No access to document ${rows[0].documentId}`); expect(await orderedRows(databaseId)).toHaveLength(2); + expect((await getDb().select().from(schema.workflowEvents)).length).toBe( + eventCountBefore, + ); }); it("rejects oversized batches before mutation", async () => { diff --git a/templates/content/actions/delete-comment.ts b/templates/content/actions/delete-comment.ts index d127fa7bdd..bcc8260b00 100644 --- a/templates/content/actions/delete-comment.ts +++ b/templates/content/actions/delete-comment.ts @@ -14,7 +14,7 @@ export default defineAction({ id: z.string().describe("Comment ID"), documentId: z.string().optional().describe("Document ID"), }), - run: async (args) => { + run: async (args, ctx) => { const db = getDb(); const [comment] = await db .select({ @@ -32,7 +32,7 @@ export default defineAction({ throw new Error(`Comment not found: ${args.id}`); } - const userEmail = getRequestUserEmail(); + const userEmail = ctx?.userEmail ?? getRequestUserEmail(); if (comment.authorEmail === userEmail) { await assertAccess("document", comment.documentId, "viewer"); } else { @@ -48,7 +48,7 @@ export default defineAction({ ), ); - await writeAppState("refresh-signal", { ts: Date.now() }); + await writeAppState("refresh-signal", { ts: Date.now() }).catch(() => {}); return { ok: true }; }, }); diff --git a/templates/content/actions/delete-content-database.ts b/templates/content/actions/delete-content-database.ts index 08fe83c5e9..9f260aa4fb 100644 --- a/templates/content/actions/delete-content-database.ts +++ b/templates/content/actions/delete-content-database.ts @@ -1,10 +1,14 @@ import { defineAction } from "@agent-native/core"; import { writeAppState } from "@agent-native/core/application-state"; -import { eq } from "drizzle-orm"; +import { and, eq, isNull } from "drizzle-orm"; import { z } from "zod"; import { getDb, schema } from "../server/db/index.js"; import { assertContentDatabaseLifecycleAccess } from "./_content-database-lifecycle.js"; +import { + appendContentWorkflowEvent, + wakeContentWorkflowEvent, +} from "./_content-workflow.js"; export default defineAction({ description: @@ -12,7 +16,7 @@ export default defineAction({ schema: z.object({ databaseId: z.string().describe("Content database ID"), }), - run: async ({ databaseId }) => { + run: async ({ databaseId }, ctx) => { const { database } = await assertContentDatabaseLifecycleAccess(databaseId); if (database.systemRole) { throw new Error("System Content databases cannot be deleted"); @@ -20,12 +24,35 @@ export default defineAction({ const db = getDb(); const deletedAt = database.deletedAt ?? new Date().toISOString(); + let workflowEventId = ""; if (!database.deletedAt) { - await db - .update(schema.contentDatabases) - .set({ deletedAt, updatedAt: deletedAt }) - .where(eq(schema.contentDatabases.id, databaseId)); + await db.transaction(async (tx) => { + const archived = await tx + .update(schema.contentDatabases) + .set({ deletedAt, updatedAt: deletedAt }) + .where( + and( + eq(schema.contentDatabases.id, databaseId), + isNull(schema.contentDatabases.deletedAt), + ), + ) + .returning({ id: schema.contentDatabases.id }); + if (archived.length === 0) return; + workflowEventId = await appendContentWorkflowEvent(tx, { + topic: "content.database.archived", + subjectType: "content_database", + subjectId: databaseId, + databaseId, + documentId: database.documentId, + ownerEmail: database.ownerEmail, + orgId: database.orgId, + occurredAt: deletedAt, + actionContext: ctx, + payload: { archivedAt: deletedAt }, + }); + }); } + if (workflowEventId) wakeContentWorkflowEvent(workflowEventId); await writeAppState("refresh-signal", { ts: Date.now() }); diff --git a/templates/content/actions/delete-database-items.ts b/templates/content/actions/delete-database-items.ts index d19535b45d..ff5c662fa3 100644 --- a/templates/content/actions/delete-database-items.ts +++ b/templates/content/actions/delete-database-items.ts @@ -3,6 +3,10 @@ import { writeAppState } from "@agent-native/core/application-state"; import { assertAccess } from "@agent-native/core/sharing"; import { getDb } from "../server/db/index.js"; +import { + appendContentWorkflowEvent, + wakeContentWorkflowEvent, +} from "./_content-workflow.js"; import { databaseRowBatchSchema, renumberDatabaseRows, @@ -15,7 +19,7 @@ export default defineAction({ description: "Delete multiple page rows from a content database in one atomic batch. Use this for two or more selected/named rows instead of looping delete-document.", schema: databaseRowBatchSchema, - run: async (args) => { + run: async (args, ctx) => { const db = getDb(); const { database, rows } = await resolveDatabaseRowsForBatch(args); @@ -28,6 +32,7 @@ export default defineAction({ const deletedDocumentIds = rows.map((row) => row.document.id); const now = new Date().toISOString(); + const workflowEventIds: string[] = []; await db.transaction(async (tx) => { for (const row of rows) { await deleteDocumentRecursive( @@ -41,7 +46,27 @@ export default defineAction({ database, now, ); + for (const row of rows) { + workflowEventIds.push( + await appendContentWorkflowEvent(tx, { + topic: "content.database.item.deleted", + subjectType: "content_database_item", + subjectId: row.document.id, + databaseId: database.id, + documentId: row.document.id, + ownerEmail: row.item.ownerEmail, + orgId: row.item.orgId, + occurredAt: now, + actionContext: ctx, + payload: { + itemId: row.item.id, + previousPosition: row.item.position, + }, + }), + ); + } }); + for (const eventId of workflowEventIds) wakeContentWorkflowEvent(eventId); await writeAppState("refresh-signal", { ts: Date.now() }); diff --git a/templates/content/actions/delete-document-property.ts b/templates/content/actions/delete-document-property.ts index 045aef08e0..6e736883ea 100644 --- a/templates/content/actions/delete-document-property.ts +++ b/templates/content/actions/delete-document-property.ts @@ -11,6 +11,7 @@ import { parsePropertyOptions, type DocumentPropertyType, } from "../shared/properties.js"; +import { assertContentDatabaseSchemaUnlocked } from "./_content-database-hooks.js"; import { listPropertiesForDocument, resolvePropertyDatabaseForDocument, @@ -29,6 +30,7 @@ export default defineAction({ const db = getDb(); const database = await resolvePropertyDatabaseForDocument(document); if (!database) throw new Error("Document is not part of a database."); + assertContentDatabaseSchemaUnlocked(database); const [definition] = await db .select() diff --git a/templates/content/actions/duplicate-database-item.ts b/templates/content/actions/duplicate-database-item.ts index eb467a4141..eacd1ade1c 100644 --- a/templates/content/actions/duplicate-database-item.ts +++ b/templates/content/actions/duplicate-database-item.ts @@ -7,6 +7,10 @@ import { z } from "zod"; import { getDb, schema } from "../server/db/index.js"; import { ensureDocumentFilesMembership } from "./_content-files.js"; +import { + appendContentWorkflowEvent, + wakeContentWorkflowEvent, +} from "./_content-workflow.js"; import { getContentDatabaseResponse } from "./_database-utils.js"; import { nanoid } from "./_property-utils.js"; @@ -18,7 +22,7 @@ export default defineAction({ documentId: z.string().optional().describe("Database row document ID"), title: z.string().optional().describe("Optional title for the duplicate"), }), - run: async ({ itemId, documentId, title }) => { + run: async ({ itemId, documentId, title }, ctx) => { if (!itemId && !documentId) { throw new Error("Either itemId or documentId is required."); } @@ -80,6 +84,7 @@ export default defineAction({ .from(schema.documentShares) .where(eq(schema.documentShares.resourceId, row.database.documentId)); + let workflowEventId = ""; await db.transaction(async (tx) => { await tx .update(schema.contentDatabaseItems) @@ -165,7 +170,25 @@ export default defineAction({ } await ensureDocumentFilesMembership(tx, nextDocumentId, now); + workflowEventId = await appendContentWorkflowEvent(tx, { + topic: "content.database.item.created", + subjectType: "content_database_item", + subjectId: nextDocumentId, + databaseId: row.item.databaseId, + documentId: nextDocumentId, + ownerEmail: row.item.ownerEmail, + orgId: row.item.orgId, + occurredAt: now, + actionContext: ctx, + payload: { + itemId: nextItemId, + sourceItemId: row.item.id, + sourceDocumentId: row.document.id, + position: nextPosition, + }, + }); }); + wakeContentWorkflowEvent(workflowEventId); await writeAppState("refresh-signal", { ts: Date.now() }); diff --git a/templates/content/actions/duplicate-database-items.ts b/templates/content/actions/duplicate-database-items.ts index 80cbaac502..a29688b9aa 100644 --- a/templates/content/actions/duplicate-database-items.ts +++ b/templates/content/actions/duplicate-database-items.ts @@ -6,6 +6,10 @@ import { and, eq, gte, inArray, sql } from "drizzle-orm"; import { getDb, schema } from "../server/db/index.js"; import { ensureDocumentsFilesMembership } from "./_content-files.js"; +import { + appendContentWorkflowEvent, + wakeContentWorkflowEvent, +} from "./_content-workflow.js"; import { databaseRowBatchSchema, resolveDatabaseRowsForBatch, @@ -17,7 +21,7 @@ export default defineAction({ description: "Duplicate multiple page rows in a content database in one atomic batch. Use this for two or more selected/named rows instead of looping duplicate-database-item.", schema: databaseRowBatchSchema, - run: async (args) => { + run: async (args, ctx) => { const db = getDb(); const { database, rows } = await resolveDatabaseRowsForBatch(args); if (!database.spaceId) { @@ -79,6 +83,7 @@ export default defineAction({ row, })); + const workflowEventIds: string[] = []; await db.transaction(async (tx) => { await tx .update(schema.contentDatabaseItems) @@ -176,7 +181,29 @@ export default defineAction({ duplicates.map((duplicate) => duplicate.duplicatedDocumentId), now, ); + for (const duplicate of duplicates) { + workflowEventIds.push( + await appendContentWorkflowEvent(tx, { + topic: "content.database.item.created", + subjectType: "content_database_item", + subjectId: duplicate.duplicatedDocumentId, + databaseId: database.id, + documentId: duplicate.duplicatedDocumentId, + ownerEmail: duplicate.row.item.ownerEmail, + orgId: duplicate.row.item.orgId, + occurredAt: now, + actionContext: ctx, + payload: { + itemId: duplicate.duplicatedItemId, + sourceItemId: duplicate.sourceItemId, + sourceDocumentId: duplicate.sourceDocumentId, + position: duplicate.position, + }, + }), + ); + } }); + for (const eventId of workflowEventIds) wakeContentWorkflowEvent(eventId); await writeAppState("refresh-signal", { ts: Date.now() }); diff --git a/templates/content/actions/duplicate-document-property.ts b/templates/content/actions/duplicate-document-property.ts index 4195e44732..23092f8710 100644 --- a/templates/content/actions/duplicate-document-property.ts +++ b/templates/content/actions/duplicate-document-property.ts @@ -10,6 +10,7 @@ import { serializePropertyOptions, type DocumentPropertyType, } from "../shared/properties.js"; +import { assertContentDatabaseSchemaUnlocked } from "./_content-database-hooks.js"; import { propertyDefinitionsPositionScope, withPositionLock, @@ -33,6 +34,7 @@ export default defineAction({ const db = getDb(); const database = await resolvePropertyDatabaseForDocument(document); if (!database) throw new Error("Document is not part of a database."); + assertContentDatabaseSchemaUnlocked(database); const [definition] = await db .select() diff --git a/templates/content/actions/edit-document.ts b/templates/content/actions/edit-document.ts index 7000f8d698..9450bcaf39 100644 --- a/templates/content/actions/edit-document.ts +++ b/templates/content/actions/edit-document.ts @@ -17,6 +17,11 @@ import { eq } from "drizzle-orm"; import { z } from "zod"; import { getDb, schema } from "../server/db/index.js"; +import { + appendContentWorkflowEvent, + contentWorkflowFingerprint, + wakeContentWorkflowEvent, +} from "./_content-workflow.js"; interface TextEdit { find: string; @@ -235,10 +240,12 @@ export default defineAction({ // Persist. The fresh updatedAt is the signal the open editor uses to tell an // intentional external edit apart from a stale autosave echo. const db = getDb(); + const now = new Date().toISOString(); + let workflowEventId = ""; await db.transaction(async (tx: any) => { await tx .update(schema.documents) - .set({ content, updatedAt: new Date().toISOString() }) + .set({ content, updatedAt: now }) .where(eq(schema.documents.id, id)); if (creativeContext) { await recordGenerationCreativeContext( @@ -251,7 +258,25 @@ export default defineAction({ { db: tx }, ); } + workflowEventId = await appendContentWorkflowEvent(tx, { + topic: "content.document.changed", + subjectType: "document", + subjectId: id, + documentId: id, + ownerEmail: existing.ownerEmail as string, + orgId: (existing.orgId as string | null | undefined) ?? null, + occurredAt: now, + actionContext: ctx, + payload: { + changeKind: "surgical_edit", + changedFields: ["content"], + before: { contentHash: contentWorkflowFingerprint(existing.content) }, + after: { contentHash: contentWorkflowFingerprint(content) }, + editCount: changeCount, + }, + }); }); + wakeContentWorkflowEvent(workflowEventId); // Make the agent edit VISIBLE as a live collaborator. Content's collab doc // binds the TipTap Y.XmlFragment("default"), so a live editing session is diff --git a/templates/content/actions/execute-builder-source-execution.test.ts b/templates/content/actions/execute-builder-source-execution.test.ts index 0719364bdb..1547ba1563 100644 --- a/templates/content/actions/execute-builder-source-execution.test.ts +++ b/templates/content/actions/execute-builder-source-execution.test.ts @@ -680,6 +680,16 @@ describe("execute Builder source execution", () => { body: expect.objectContaining({ published: "published" }), }), }); + expect(deps.markExecutionSucceeded).toHaveBeenCalledWith( + expect.objectContaining({ + confirmedEvent: expect.objectContaining({ + databaseId: "database-1", + sourceId: builderSource.id, + entryId: "builder-entry-1", + effect: "publish", + }), + }), + ); }); it("blocks publish transitions when the entry is already published", async () => { diff --git a/templates/content/actions/execute-builder-source-execution.ts b/templates/content/actions/execute-builder-source-execution.ts index d3b454044a..4848547f3f 100644 --- a/templates/content/actions/execute-builder-source-execution.ts +++ b/templates/content/actions/execute-builder-source-execution.ts @@ -44,6 +44,10 @@ import { executeBuilderCmsWrite, } from "./_builder-cms-write-client.js"; import { createBuilderSourceTiming } from "./_builder-source-timings.js"; +import { + appendContentWorkflowEvent, + wakeContentWorkflowEvent, +} from "./_content-workflow.js"; import { getContentDatabaseSourceSnapshotForWrite, resolveDatabaseForSourceMutation, @@ -110,7 +114,17 @@ export interface ExecuteBuilderSourceExecutionDeps { payload: unknown; now: string; attemptToken?: string; - }) => Promise; + confirmedEvent?: { + databaseId: string; + documentId?: string | null; + ownerEmail: string; + orgId?: string | null; + sourceId: string; + entryId?: string | null; + effect: BuilderCmsExecutionPayload["effect"]; + model: string; + }; + }) => Promise<{ workflowEventId?: string } | void>; markExecutionFailed: (args: { executionId: string; summary: string; @@ -758,6 +772,7 @@ export function realExecutionDeps( }, markExecutionSucceeded: async (args) => { const db = getDb(); + let workflowEventId: string | undefined; await db.transaction(async (tx) => { const result = await tx .update(schema.contentDatabaseSourceExecutions) @@ -791,7 +806,58 @@ export function realExecutionDeps( .where( eq(schema.contentDatabaseSourceChangeSets.id, args.changeSetId), ); + if (args.confirmedEvent) { + const confirmed = args.confirmedEvent; + const propertyValues = confirmed.documentId + ? Object.fromEntries( + ( + await tx + .select({ + propertyId: schema.documentPropertyValues.propertyId, + valueJson: schema.documentPropertyValues.valueJson, + }) + .from(schema.documentPropertyValues) + .where( + eq( + schema.documentPropertyValues.documentId, + confirmed.documentId, + ), + ) + ).map((row) => [row.propertyId, JSON.parse(row.valueJson)]), + ) + : {}; + workflowEventId = await appendContentWorkflowEvent(tx, { + topic: + confirmed.effect === "publish" || confirmed.effect === "unpublish" + ? "content.builder.publication.confirmed" + : "content.builder.write.confirmed", + subjectType: confirmed.documentId + ? "content_database_item" + : "content_database", + subjectId: confirmed.documentId ?? confirmed.databaseId, + databaseId: confirmed.databaseId, + documentId: confirmed.documentId ?? undefined, + ownerEmail: confirmed.ownerEmail, + orgId: confirmed.orgId, + payload: { + provider: "builder", + sourceId: confirmed.sourceId, + changeSetId: args.changeSetId, + entryId: confirmed.entryId ?? null, + model: confirmed.model, + effect: confirmed.effect, + confirmation: "provider_success", + propertyValues, + }, + occurredAt: args.now, + actorOverrides: { + executor: { kind: "provider", id: "builder" }, + origin: { kind: "integration", platform: "builder" }, + }, + }); + } }); + return workflowEventId ? { workflowEventId } : undefined; }, markExecutionFailed: async (args) => { await getDb() @@ -1078,13 +1144,27 @@ export async function executeBuilderSourceExecutionWithDeps( }); throw builderExecutionConflict(lastError); } - await deps.markExecutionSucceeded({ + const completed = await deps.markExecutionSucceeded({ executionId: execution.id, changeSetId: changeSet.id, summary: `Builder ${plan.pushMode} execution succeeded.`, payload: payloadWithResponse, now: reconciledAt, + confirmedEvent: { + databaseId: database.id, + documentId: changeSet.documentId, + ownerEmail: database.ownerEmail, + orgId: database.orgId, + sourceId: source.id, + entryId: + storedWriteResult.entryId ?? plan.payload.target.entryId ?? null, + effect: plan.payload.effect, + model: plan.payload.target.model, + }, }); + if (completed?.workflowEventId) { + wakeContentWorkflowEvent(completed.workflowEventId); + } const response = await deps.getResponse(database.id); timing.record("reconciliation_and_persistence", reconciliationStartedAt); const result = { ...response, timings: timing.finish() }; @@ -1241,14 +1321,27 @@ export async function executeBuilderSourceExecutionWithDeps( }); throw builderExecutionConflict(lastError); } - await deps.markExecutionSucceeded({ + const completed = await deps.markExecutionSucceeded({ executionId: execution.id, changeSetId: changeSet.id, summary: `Builder ${plan.pushMode} execution succeeded.`, payload: payloadWithResponse, now: succeededAt, attemptToken, + confirmedEvent: { + databaseId: database.id, + documentId: changeSet.documentId, + ownerEmail: database.ownerEmail, + orgId: database.orgId, + sourceId: source.id, + entryId: writeResult.entryId ?? plan.payload.target.entryId ?? null, + effect: plan.payload.effect, + model: plan.payload.target.model, + }, }); + if (completed?.workflowEventId) { + wakeContentWorkflowEvent(completed.workflowEventId); + } const response = await deps.getResponse(database.id); timing.record("reconciliation_and_persistence", reconciliationStartedAt); diff --git a/templates/content/actions/get-content-hook-runtime-controls.ts b/templates/content/actions/get-content-hook-runtime-controls.ts new file mode 100644 index 0000000000..8f0039b62f --- /dev/null +++ b/templates/content/actions/get-content-hook-runtime-controls.ts @@ -0,0 +1,12 @@ +import { defineAction } from "@agent-native/core"; +import { z } from "zod"; + +import { getContentHookRuntimeControls } from "./_content-hook-runtime-controls.js"; + +export default defineAction({ + description: + "Get global and database-level emergency pause controls for deterministic Content hooks.", + schema: z.object({ databaseId: z.string().min(1) }), + http: { method: "GET" }, + run: ({ databaseId }) => getContentHookRuntimeControls(databaseId), +}); diff --git a/templates/content/actions/get-content-notification-preference.ts b/templates/content/actions/get-content-notification-preference.ts new file mode 100644 index 0000000000..40df6ae96c --- /dev/null +++ b/templates/content/actions/get-content-notification-preference.ts @@ -0,0 +1,78 @@ +import { defineAction } from "@agent-native/core"; +import { z } from "zod"; + +import { contentDefaultPersonSubscriptionId } from "./_content-database-hooks.js"; +import { assertContentNotificationPreferenceTarget } from "./_content-notification-preference-access.js"; +import { resolveContentNotificationPreference } from "./_content-notification-preferences.js"; + +export default defineAction({ + description: + "Resolve the current user's effective personal notification preference for a Content database, rule, or item.", + schema: z.object({ + scope: z.enum(["global", "database", "rule", "item"]), + databaseId: z.string().min(1).optional(), + subscriptionId: z.string().min(1).optional(), + documentId: z.string().min(1).optional(), + }), + http: { method: "GET" }, + run: async (args, ctx) => { + if (!ctx?.userEmail) throw new Error("Not authenticated."); + if ( + (args.scope === "global" && + (args.databaseId || args.subscriptionId || args.documentId)) || + (args.scope === "database" && (args.subscriptionId || args.documentId)) || + (args.scope === "rule" && args.documentId) || + (args.scope === "item" && args.subscriptionId) + ) { + throw new Error(`Unexpected identifiers for ${args.scope} scope.`); + } + const target = (() => { + if (args.scope === "global") return { scope: "global" as const }; + if (!args.databaseId) { + throw new Error(`A database ID is required for ${args.scope} scope.`); + } + if (args.scope === "database") { + return { scope: "database" as const, databaseId: args.databaseId }; + } + if (args.scope === "rule") { + if (!args.subscriptionId) { + throw new Error("A subscription ID is required for rule scope."); + } + return { + scope: "rule" as const, + databaseId: args.databaseId, + subscriptionId: args.subscriptionId, + }; + } + if (!args.documentId) { + throw new Error("A document ID is required for item scope."); + } + return { + scope: "item" as const, + databaseId: args.databaseId, + documentId: args.documentId, + }; + })(); + await assertContentNotificationPreferenceTarget(target); + const databaseId = "databaseId" in target ? target.databaseId : undefined; + const documentId = "documentId" in target ? target.documentId : undefined; + const resolvedSubscriptionId = + target.scope === "rule" + ? target.subscriptionId + : databaseId + ? contentDefaultPersonSubscriptionId(databaseId) + : undefined; + const preference = await resolveContentNotificationPreference({ + ownerEmail: ctx.userEmail, + orgId: ctx.orgId, + databaseId, + subscriptionId: resolvedSubscriptionId, + documentId: documentId ?? "", + }); + return { + target, + documentId: documentId ?? null, + preference, + }; + }, +}); diff --git a/templates/content/actions/list-content-database-hook-executions.ts b/templates/content/actions/list-content-database-hook-executions.ts new file mode 100644 index 0000000000..473284bde3 --- /dev/null +++ b/templates/content/actions/list-content-database-hook-executions.ts @@ -0,0 +1,175 @@ +import { defineAction } from "@agent-native/core"; +import { + notificationDeliveryAttempts, + workflowEffects, + workflowExecutions, + workflowSubscriptionVersions, +} from "@agent-native/core/workflow"; +import { and, desc, eq, inArray } from "drizzle-orm"; +import { z } from "zod"; + +import { getDb } from "../server/db/index.js"; +import type { ContentDatabaseHookEffectExecution } from "../shared/api.js"; +import { + contentHookConfigFromJson, + requireContentDatabaseAccess, +} from "./_content-database-hooks.js"; + +function parseResult(value: string | null) { + if (!value) return null; + try { + const parsed = JSON.parse(value); + return parsed && typeof parsed === "object" + ? (parsed as Record) + : null; + } catch { + return null; + } +} + +function ledgerEffectKind( + value: string, +): ContentDatabaseHookEffectExecution["kind"] { + switch (value) { + case "notify": + case "notification": + case "team_slack": + case "webhook": + case "set_property": + case "content_hook_control": + return value; + default: + return "unknown"; + } +} + +export default defineAction({ + description: + "List recent Content Rule runs with action, coalescing, suppression, and delivery-attempt truth from the shared workflow ledger.", + schema: z.object({ + databaseId: z.string().min(1), + hookId: z.string().min(1).optional(), + limit: z.coerce.number().int().min(1).max(200).default(50), + }), + http: { method: "GET" }, + run: async ({ databaseId, hookId, limit }) => { + const database = await requireContentDatabaseAccess(databaseId, "viewer"); + const db = getDb(); + const candidates = await db + .select({ + id: workflowExecutions.id, + hookId: workflowExecutions.subscriptionId, + subscriptionVersion: workflowExecutions.subscriptionVersion, + eventId: workflowExecutions.eventId, + status: workflowExecutions.status, + attempts: workflowExecutions.attempt, + error: workflowExecutions.errorMessage, + createdAt: workflowExecutions.createdAt, + updatedAt: workflowExecutions.updatedAt, + config: workflowSubscriptionVersions.config, + }) + .from(workflowExecutions) + .innerJoin( + workflowSubscriptionVersions, + and( + eq( + workflowSubscriptionVersions.subscriptionId, + workflowExecutions.subscriptionId, + ), + eq( + workflowSubscriptionVersions.version, + workflowExecutions.subscriptionVersion, + ), + ), + ) + .where( + and( + eq(workflowSubscriptionVersions.kind, "deterministic"), + eq(workflowSubscriptionVersions.ownerEmail, database.ownerEmail), + ), + ) + .orderBy(desc(workflowExecutions.createdAt)) + .limit(Math.min(limit * 10, 1_000)); + const rows = candidates + .flatMap((row) => { + const config = contentHookConfigFromJson(row.config); + if ( + !config || + config.databaseId !== databaseId || + (hookId && row.hookId !== hookId) + ) { + return []; + } + return [ + { + ...row, + hookName: config.name, + canRetry: row.status === "failed" || row.status === "unknown", + canAcknowledge: row.status === "unknown", + }, + ]; + }) + .slice(0, limit); + if (rows.length === 0) return { databaseId, executions: [] }; + + const executionIds = rows.map((row) => row.id); + const effects = await db + .select() + .from(workflowEffects) + .where(inArray(workflowEffects.executionId, executionIds)) + .orderBy(workflowEffects.createdAt); + const effectIds = effects.map((effect) => effect.id); + const attempts = effectIds.length + ? await db + .select() + .from(notificationDeliveryAttempts) + .where(inArray(notificationDeliveryAttempts.effectId, effectIds)) + .orderBy( + notificationDeliveryAttempts.createdAt, + notificationDeliveryAttempts.attempt, + ) + : []; + const attemptsByEffect = new Map(); + for (const attempt of attempts) { + attemptsByEffect.set(attempt.effectId, [ + ...(attemptsByEffect.get(attempt.effectId) ?? []), + attempt, + ]); + } + const effectsByExecution = new Map< + string, + Array<{ + id: string; + kind: ContentDatabaseHookEffectExecution["kind"]; + status: string; + result: Record | null; + error: string | null; + createdAt: number; + updatedAt: number; + deliveryAttempts: typeof attempts; + }> + >(); + for (const effect of effects) { + effectsByExecution.set(effect.executionId, [ + ...(effectsByExecution.get(effect.executionId) ?? []), + { + id: effect.id, + kind: ledgerEffectKind(effect.kind), + status: effect.status, + result: parseResult(effect.result), + error: effect.errorMessage, + createdAt: effect.createdAt, + updatedAt: effect.updatedAt, + deliveryAttempts: attemptsByEffect.get(effect.id) ?? [], + }, + ]); + } + return { + databaseId, + executions: rows.map(({ config: _config, ...row }) => ({ + ...row, + effects: effectsByExecution.get(row.id) ?? [], + })), + }; + }, +}); diff --git a/templates/content/actions/list-content-database-hooks.ts b/templates/content/actions/list-content-database-hooks.ts new file mode 100644 index 0000000000..3d8154c413 --- /dev/null +++ b/templates/content/actions/list-content-database-hooks.ts @@ -0,0 +1,23 @@ +import { defineAction } from "@agent-native/core"; +import { z } from "zod"; + +import { + contentHookTriggerAvailability, + listContentDatabaseHooks, + requireContentDatabaseAccess, +} from "./_content-database-hooks.js"; + +export default defineAction({ + description: + "List deterministic Rules configured for a Content database, including trigger availability.", + schema: z.object({ databaseId: z.string().min(1) }), + http: { method: "GET" }, + run: async ({ databaseId }) => { + await requireContentDatabaseAccess(databaseId, "viewer"); + return { + databaseId, + hooks: await listContentDatabaseHooks(databaseId), + triggerAvailability: contentHookTriggerAvailability, + }; + }, +}); diff --git a/templates/content/actions/manage-content-database-hook-execution.ts b/templates/content/actions/manage-content-database-hook-execution.ts new file mode 100644 index 0000000000..37e03f78c4 --- /dev/null +++ b/templates/content/actions/manage-content-database-hook-execution.ts @@ -0,0 +1,66 @@ +import { defineAction } from "@agent-native/core"; +import { + acknowledgeWorkflowExecution, + getWorkflowExecution, + retryWorkflowExecution, + workflowExecutions, + workflowSubscriptions, +} from "@agent-native/core/workflow"; +import { and, eq } from "drizzle-orm"; +import { z } from "zod"; + +import { getDb } from "../server/db/index.js"; +import { + contentHookConfigFromJson, + requireContentDatabaseAccess, +} from "./_content-database-hooks.js"; + +export default defineAction({ + description: + "Retry or acknowledge an eligible Content hook execution. Only the database owner may resolve delivery uncertainty.", + schema: z.object({ + action: z.enum(["retry", "acknowledge"]), + databaseId: z.string().min(1), + executionId: z.string().min(1), + }), + run: async ({ action, databaseId, executionId }, ctx) => { + if (!ctx?.userEmail) throw new Error("Not authenticated."); + const database = await requireContentDatabaseAccess(databaseId, "admin"); + if (database.ownerEmail !== ctx.userEmail) { + throw new Error("Only the database owner can resolve hook executions."); + } + const [row] = await getDb() + .select({ config: workflowSubscriptions.config }) + .from(workflowExecutions) + .innerJoin( + workflowSubscriptions, + eq(workflowSubscriptions.id, workflowExecutions.subscriptionId), + ) + .where( + and( + eq(workflowExecutions.id, executionId), + eq(workflowSubscriptions.kind, "deterministic"), + ), + ); + const config = row ? contentHookConfigFromJson(row.config) : null; + if (!config || config.databaseId !== databaseId) { + throw new Error("Hook execution does not belong to this database."); + } + const changed = + action === "retry" + ? await retryWorkflowExecution({ executionId }) + : await acknowledgeWorkflowExecution({ executionId }); + if (!changed) { + throw new Error( + action === "retry" + ? "Only failed or unknown executions can be retried." + : "Only unknown executions can be acknowledged.", + ); + } + const execution = await getWorkflowExecution(executionId); + if (!execution) { + throw new Error("Hook execution changed but could not be reloaded."); + } + return { databaseId, execution }; + }, +}); diff --git a/templates/content/actions/manage-content-database-hook.ts b/templates/content/actions/manage-content-database-hook.ts new file mode 100644 index 0000000000..2a9acd4c0f --- /dev/null +++ b/templates/content/actions/manage-content-database-hook.ts @@ -0,0 +1,99 @@ +import { defineAction } from "@agent-native/core"; +import { z } from "zod"; + +import { + contentHookEffectSchema, + contentHookEffectsSchema, + contentHookConditionsSchema, + contentHookTimingSchema, + contentHookTriggerSchema, + deleteContentDatabaseHook, + getContentDatabaseHook, + requireContentDatabaseOwner, + saveContentDatabaseHook, +} from "./_content-database-hooks.js"; + +const schema = z + .object({ + action: z.enum(["create", "update", "delete"]), + databaseId: z.string().min(1), + hookId: z.string().min(1).optional(), + name: z.string().trim().min(1).max(200).optional(), + enabled: z.boolean().optional(), + trigger: contentHookTriggerSchema.optional(), + conditions: contentHookConditionsSchema.nullable().optional(), + effect: contentHookEffectSchema.optional(), + effects: contentHookEffectsSchema.optional(), + timing: contentHookTimingSchema.optional(), + }) + .superRefine((value, ctx) => { + if (value.effect && value.effects) { + ctx.addIssue({ + code: "custom", + message: "Provide effect or effects, not both.", + }); + } + if (value.action === "create") { + if (!value.name) { + ctx.addIssue({ code: "custom", path: ["name"], message: "Required" }); + } + if (!value.trigger) { + ctx.addIssue({ + code: "custom", + path: ["trigger"], + message: "Required", + }); + } + if (!value.effect && !value.effects) { + ctx.addIssue({ + code: "custom", + path: ["effects"], + message: "At least one effect is required.", + }); + } + } else if (!value.hookId) { + ctx.addIssue({ code: "custom", path: ["hookId"], message: "Required" }); + } + }); + +export default defineAction({ + description: + "Create, update, or delete an owner-managed deterministic Content database Rule. Use Automations instead when an agent must exercise judgment.", + schema, + run: async (args) => { + if (args.action === "delete") { + await deleteContentDatabaseHook(args.databaseId, args.hookId!); + return { databaseId: args.databaseId, deletedHookId: args.hookId }; + } + if (args.action === "update") { + await requireContentDatabaseOwner(args.databaseId); + } + const existing = + args.action === "update" + ? await getContentDatabaseHook(args.databaseId, args.hookId!) + : undefined; + if (args.action === "update" && !existing) { + throw new Error(`Rule "${args.hookId}" not found.`); + } + const hook = await saveContentDatabaseHook({ + id: args.action === "update" ? args.hookId! : undefined, + databaseId: args.databaseId, + name: args.name ?? existing!.name, + enabled: args.enabled ?? existing?.enabled ?? true, + trigger: args.trigger ?? existing!.trigger, + conditions: + "conditions" in args && args.conditions !== undefined + ? (args.conditions ?? undefined) + : existing?.conditions, + effects: + "effects" in args && args.effects + ? args.effects + : "effect" in args && args.effect + ? [args.effect] + : existing!.effects, + timing: args.timing ?? existing?.timing ?? { kind: "immediate" }, + createdBy: existing?.createdBy, + }); + return { databaseId: args.databaseId, hook }; + }, +}); diff --git a/templates/content/actions/manage-content-database-policy.ts b/templates/content/actions/manage-content-database-policy.ts new file mode 100644 index 0000000000..628555c8c8 --- /dev/null +++ b/templates/content/actions/manage-content-database-policy.ts @@ -0,0 +1,109 @@ +import { defineAction } from "@agent-native/core"; +import { and, desc, eq } from "drizzle-orm"; +import { z } from "zod"; + +import { getDb, schema } from "../server/db/index.js"; +import { requireContentDatabaseOwner } from "./_content-database-hooks.js"; +import { CONTENT_DEFAULT_PERSON_POLICY_KEY } from "./_content-default-person-rule.js"; +import { allocateContentWorkflowEventSequence } from "./_content-workflow.js"; +import { + nanoid, + parseDatabaseViewConfig, + serializeDatabaseViewConfig, +} from "./_property-utils.js"; + +export default defineAction({ + description: + "Update owner-only Content database policies for schema locking and default Person notifications.", + schema: z + .object({ + databaseId: z.string().min(1), + schemaLocked: z.boolean().optional(), + defaultPersonNotificationsEnabled: z.boolean().optional(), + }) + .refine( + (value) => + value.schemaLocked !== undefined || + value.defaultPersonNotificationsEnabled !== undefined, + { message: "At least one database policy must be provided." }, + ), + run: async ({ + databaseId, + schemaLocked, + defaultPersonNotificationsEnabled, + }) => { + const database = await requireContentDatabaseOwner(databaseId); + const viewConfig = parseDatabaseViewConfig(database.viewConfigJson); + const defaultPersonPolicyChanged = + defaultPersonNotificationsEnabled !== undefined && + defaultPersonNotificationsEnabled !== + viewConfig.defaultPersonNotificationsEnabled; + const now = new Date().toISOString(); + const next = await getDb().transaction(async (tx) => { + let nextPolicyVersion = + viewConfig.defaultPersonNotificationsPolicyVersion ?? 1; + if ( + defaultPersonPolicyChanged && + defaultPersonNotificationsEnabled !== undefined + ) { + const [latestPolicy] = await tx + .select({ version: schema.contentDatabasePolicies.version }) + .from(schema.contentDatabasePolicies) + .where( + and( + eq(schema.contentDatabasePolicies.databaseId, databaseId), + eq( + schema.contentDatabasePolicies.policyKey, + CONTENT_DEFAULT_PERSON_POLICY_KEY, + ), + ), + ) + .orderBy(desc(schema.contentDatabasePolicies.version)) + .limit(1); + nextPolicyVersion = (latestPolicy?.version ?? 1) + 1; + const activeAfterSequence = + await allocateContentWorkflowEventSequence(tx); + await tx.insert(schema.contentDatabasePolicies).values({ + id: nanoid(), + databaseId, + policyKey: CONTENT_DEFAULT_PERSON_POLICY_KEY, + version: nextPolicyVersion, + enabled: defaultPersonNotificationsEnabled, + activeAfterSequence, + ownerEmail: database.ownerEmail, + orgId: database.orgId ?? "", + createdAt: now, + }); + } + const serialized = serializeDatabaseViewConfig({ + ...viewConfig, + ...(schemaLocked === undefined ? {} : { schemaLocked }), + ...(defaultPersonNotificationsEnabled === undefined + ? {} + : { defaultPersonNotificationsEnabled }), + defaultPersonNotificationsPolicyVersion: nextPolicyVersion, + }); + await tx + .update(schema.contentDatabases) + .set({ viewConfigJson: serialized, updatedAt: now }) + .where(eq(schema.contentDatabases.id, databaseId)); + return serialized; + }); + const [saved] = await getDb() + .select({ viewConfigJson: schema.contentDatabases.viewConfigJson }) + .from(schema.contentDatabases) + .where(eq(schema.contentDatabases.id, databaseId)); + if (!saved || saved.viewConfigJson !== next) { + throw new Error("Database lock policy could not be verified."); + } + const savedConfig = parseDatabaseViewConfig(saved.viewConfigJson); + return { + databaseId, + schemaLocked: savedConfig.schemaLocked === true, + defaultPersonNotificationsEnabled: + savedConfig.defaultPersonNotificationsEnabled !== false, + defaultPersonNotificationsPolicyVersion: + savedConfig.defaultPersonNotificationsPolicyVersion ?? 1, + }; + }, +}); diff --git a/templates/content/actions/manage-content-database-validation.ts b/templates/content/actions/manage-content-database-validation.ts new file mode 100644 index 0000000000..fa4588a511 --- /dev/null +++ b/templates/content/actions/manage-content-database-validation.ts @@ -0,0 +1,64 @@ +import { defineAction } from "@agent-native/core"; +import { writeAppState } from "@agent-native/core/application-state"; +import { eq } from "drizzle-orm"; +import { z } from "zod"; + +import { getDb, schema } from "../server/db/index.js"; +import { requireContentDatabaseOwner } from "./_content-database-hooks.js"; +import { validateContentDatabaseValidationConfig } from "./_content-database-validation.js"; +import { + parseDatabaseViewConfig, + serializeDatabaseViewConfig, +} from "./_property-utils.js"; + +const validationSchema = z.object({ + requiredForSubmission: z.array(z.string().min(1)).default([]), + statusRequirements: z + .array( + z.object({ + statusPropertyId: z.string().min(1), + statusOptionId: z.string().min(1), + requiredPropertyIds: z.array(z.string().min(1)).default([]), + }), + ) + .default([]), +}); + +export default defineAction({ + description: + "Configure owner-managed required fields for atomic submissions and status transitions in a Content database.", + schema: z.object({ + databaseId: z.string().min(1), + validation: validationSchema, + }), + run: async ({ databaseId, validation }) => { + const database = await requireContentDatabaseOwner(databaseId); + await validateContentDatabaseValidationConfig(databaseId, validation); + const viewConfig = parseDatabaseViewConfig(database.viewConfigJson); + const nextViewConfig = { + ...viewConfig, + validation, + }; + const now = new Date().toISOString(); + const serialized = serializeDatabaseViewConfig(nextViewConfig); + await getDb() + .update(schema.contentDatabases) + .set({ + viewConfigJson: serialized, + updatedAt: now, + }) + .where(eq(schema.contentDatabases.id, databaseId)); + const [saved] = await getDb() + .select({ viewConfigJson: schema.contentDatabases.viewConfigJson }) + .from(schema.contentDatabases) + .where(eq(schema.contentDatabases.id, databaseId)); + if (!saved || saved.viewConfigJson !== serialized) { + throw new Error("Database validation settings could not be verified."); + } + await writeAppState("refresh-signal", { ts: Date.now() }); + return { + databaseId, + validation: parseDatabaseViewConfig(saved.viewConfigJson).validation, + }; + }, +}); diff --git a/templates/content/actions/manage-content-hook-runtime-control.ts b/templates/content/actions/manage-content-hook-runtime-control.ts new file mode 100644 index 0000000000..17c4c1a6eb --- /dev/null +++ b/templates/content/actions/manage-content-hook-runtime-control.ts @@ -0,0 +1,21 @@ +import { defineAction } from "@agent-native/core"; +import { writeAppState } from "@agent-native/core/application-state"; +import { z } from "zod"; + +import { setContentHookRuntimeControl } from "./_content-hook-runtime-controls.js"; + +export default defineAction({ + description: + "Pause or resume deterministic Content hook evaluation and outward effects globally or for one database.", + schema: z.object({ + databaseId: z.string().min(1), + scope: z.enum(["global", "database"]), + evaluatorPaused: z.boolean(), + effectsPaused: z.boolean(), + }), + run: async (args) => { + const result = await setContentHookRuntimeControl(args); + await writeAppState("refresh-signal", { ts: Date.now() }); + return result; + }, +}); diff --git a/templates/content/actions/manage-content-notification-preference.ts b/templates/content/actions/manage-content-notification-preference.ts new file mode 100644 index 0000000000..45a5802e68 --- /dev/null +++ b/templates/content/actions/manage-content-notification-preference.ts @@ -0,0 +1,65 @@ +import { defineAction } from "@agent-native/core"; +import { z } from "zod"; + +import { assertContentNotificationPreferenceTarget } from "./_content-notification-preference-access.js"; +import { + removeContentNotificationPreference, + setContentNotificationPreference, +} from "./_content-notification-preferences.js"; + +const targetSchema = z.discriminatedUnion("scope", [ + z.object({ scope: z.literal("global") }), + z.object({ + scope: z.literal("database"), + databaseId: z.string().min(1), + }), + z.object({ + scope: z.literal("rule"), + databaseId: z.string().min(1), + subscriptionId: z.string().min(1), + }), + z.object({ + scope: z.literal("item"), + databaseId: z.string().min(1), + documentId: z.string().min(1), + }), +]); + +export default defineAction({ + description: + "Set or remove the current user's personal Content notification preference. Item overrides rule, database, then global preferences. This never changes shared team destinations.", + schema: z + .object({ + action: z.enum(["set", "remove"]), + target: targetSchema, + enabled: z.boolean().optional(), + }) + .superRefine((value, ctx) => { + if (value.action === "set" && value.enabled === undefined) { + ctx.addIssue({ + code: "custom", + path: ["enabled"], + message: "Required when setting a preference.", + }); + } + }), + run: async (args, ctx) => { + if (!ctx?.userEmail) throw new Error("Not authenticated."); + await assertContentNotificationPreferenceTarget(args.target); + if (args.action === "remove") { + await removeContentNotificationPreference({ + ownerEmail: ctx.userEmail, + orgId: ctx.orgId, + target: args.target, + }); + return { target: args.target, preference: null }; + } + const preference = await setContentNotificationPreference({ + ownerEmail: ctx.userEmail, + orgId: ctx.orgId, + target: args.target, + enabled: args.enabled!, + }); + return { target: args.target, preference }; + }, +}); diff --git a/templates/content/actions/materialize-builder-required-fields.ts b/templates/content/actions/materialize-builder-required-fields.ts index 0e404d7e87..a9ddf07b1e 100644 --- a/templates/content/actions/materialize-builder-required-fields.ts +++ b/templates/content/actions/materialize-builder-required-fields.ts @@ -19,6 +19,7 @@ import { } from "../shared/properties.js"; import { chunks } from "./_batch-utils.js"; import { readBuilderCmsContentEntries } from "./_builder-cms-read-client.js"; +import { assertContentDatabaseSchemaUnlocked } from "./_content-database-hooks.js"; import { builderReferenceIdSourceValueKey, resolveDatabaseForSourceMutation, @@ -267,6 +268,7 @@ export default defineAction({ run: async (args) => { const database = await resolveDatabaseForSourceMutation(args); if (!database) throw new Error("Database not found."); + assertContentDatabaseSchemaUnlocked(database); await assertAccess("document", database.documentId, "editor"); const db = getDb(); const sourceFilters = [ diff --git a/templates/content/actions/move-database-item.ts b/templates/content/actions/move-database-item.ts index eafff63066..2bc26eb56a 100644 --- a/templates/content/actions/move-database-item.ts +++ b/templates/content/actions/move-database-item.ts @@ -5,6 +5,10 @@ import { and, asc, eq, inArray, isNull, sql } from "drizzle-orm"; import { z } from "zod"; import { getDb, schema } from "../server/db/index.js"; +import { + appendContentWorkflowEvent, + wakeContentWorkflowEvent, +} from "./_content-workflow.js"; import { getContentDatabaseResponse } from "./_database-utils.js"; function clamp(value: number, min: number, max: number) { @@ -27,7 +31,7 @@ export default defineAction({ documentId: z.string().optional().describe("Database row document ID"), position: z.coerce.number().int().describe("New zero-based row position"), }), - run: async ({ itemId, documentId, position }) => { + run: async ({ itemId, documentId, position }, ctx) => { if (!itemId && !documentId) { throw new Error("Either itemId or documentId is required."); } @@ -77,6 +81,7 @@ export default defineAction({ const itemIds = nextItems.map((item) => item.id); const documentIds = nextItems.map((item) => item.documentId); + let workflowEventId = ""; await db.transaction(async (tx) => { await tx .update(schema.contentDatabaseItems) @@ -112,7 +117,24 @@ export default defineAction({ inArray(schema.documents.id, documentIds), ), ); + workflowEventId = await appendContentWorkflowEvent(tx, { + topic: "content.database.item.moved", + subjectType: "content_database_item", + subjectId: row.item.documentId, + databaseId: row.item.databaseId, + documentId: row.item.documentId, + ownerEmail: row.item.ownerEmail, + orgId: row.item.orgId, + occurredAt: now, + actionContext: ctx, + payload: { + itemId: row.item.id, + beforePosition: currentIndex, + afterPosition: nextIndex, + }, + }); }); + wakeContentWorkflowEvent(workflowEventId); await writeAppState("refresh-signal", { ts: Date.now() }); diff --git a/templates/content/actions/preview-content-database-hook.ts b/templates/content/actions/preview-content-database-hook.ts new file mode 100644 index 0000000000..19cd04378f --- /dev/null +++ b/templates/content/actions/preview-content-database-hook.ts @@ -0,0 +1,46 @@ +import { defineAction } from "@agent-native/core"; +import { + getWorkflowEvent, + getWorkflowSubscription, +} from "@agent-native/core/workflow"; +import { z } from "zod"; + +import { + contentHookConfigFromJson, + requireContentDatabaseAccess, +} from "./_content-database-hooks.js"; +import { previewContentDatabaseHook } from "./_content-hook-execution.js"; + +export default defineAction({ + description: + "Preview a deterministic Content database Rule against one committed change without running its actions.", + schema: z.object({ + databaseId: z.string().min(1), + hookId: z.string().min(1), + eventId: z.string().min(1), + }), + readOnly: true, + run: async ({ databaseId, hookId, eventId }) => { + await requireContentDatabaseAccess(databaseId, "viewer"); + const [event, subscription] = await Promise.all([ + getWorkflowEvent(eventId), + getWorkflowSubscription(hookId), + ]); + if (!event) throw new Error("Committed change not found."); + if (!subscription || subscription.kind !== "deterministic") { + throw new Error("Content Rule not found."); + } + const config = contentHookConfigFromJson( + JSON.stringify(subscription.config), + ); + if (!config || "system" in config || config.databaseId !== databaseId) { + throw new Error("The Rule does not belong to this database."); + } + if (event.payload.databaseId !== databaseId) { + throw new Error("The committed change does not belong to this database."); + } + const preview = previewContentDatabaseHook({ event, subscription }); + if (!preview) throw new Error("The Rule cannot be previewed."); + return { databaseId, hookId, preview }; + }, +}); diff --git a/templates/content/actions/reorder-document-property.ts b/templates/content/actions/reorder-document-property.ts index 3d76729a75..5be98e40c7 100644 --- a/templates/content/actions/reorder-document-property.ts +++ b/templates/content/actions/reorder-document-property.ts @@ -5,6 +5,7 @@ import { and, asc, eq } from "drizzle-orm"; import { z } from "zod"; import { getDb, schema } from "../server/db/index.js"; +import { assertContentDatabaseSchemaUnlocked } from "./_content-database-hooks.js"; import { listPropertiesForDocument, resolvePropertyDatabaseForDocument, @@ -28,6 +29,7 @@ export default defineAction({ const db = getDb(); const database = await resolvePropertyDatabaseForDocument(document); if (!database) throw new Error("Document is not part of a database."); + assertContentDatabaseSchemaUnlocked(database); const definitions = await db .select() diff --git a/templates/content/actions/restore-content-database.ts b/templates/content/actions/restore-content-database.ts index 022c83b742..aac986914e 100644 --- a/templates/content/actions/restore-content-database.ts +++ b/templates/content/actions/restore-content-database.ts @@ -9,6 +9,10 @@ import { assertContentDatabaseLifecycleAccess, collectInlineDatabaseOwnerBlockIds, } from "./_content-database-lifecycle.js"; +import { + appendContentWorkflowEvent, + wakeContentWorkflowEvent, +} from "./_content-workflow.js"; import pullDocumentAction from "./pull-document.js"; async function shouldClearStaleInlineOwnership(args: { @@ -38,7 +42,7 @@ export default defineAction({ schema: z.object({ databaseId: z.string().describe("Content database ID"), }), - run: async ({ databaseId }) => { + run: async ({ databaseId }, ctx) => { const ownership = await assertContentDatabaseLifecycleAccess(databaseId); const db = getDb(); const now = new Date().toISOString(); @@ -47,16 +51,33 @@ export default defineAction({ ownerBlockId: ownership.database.ownerBlockId, }); - await db - .update(schema.contentDatabases) - .set({ - deletedAt: null, - updatedAt: now, - ...(clearInlineOwnership - ? { ownerDocumentId: null, ownerBlockId: null } - : {}), - }) - .where(eq(schema.contentDatabases.id, databaseId)); + let workflowEventId = ""; + await db.transaction(async (tx) => { + await tx + .update(schema.contentDatabases) + .set({ + deletedAt: null, + updatedAt: now, + ...(clearInlineOwnership + ? { ownerDocumentId: null, ownerBlockId: null } + : {}), + }) + .where(eq(schema.contentDatabases.id, databaseId)); + if (!ownership.database.deletedAt) return; + workflowEventId = await appendContentWorkflowEvent(tx, { + topic: "content.database.restored", + subjectType: "content_database", + subjectId: databaseId, + databaseId, + documentId: ownership.database.documentId, + ownerEmail: ownership.database.ownerEmail, + orgId: ownership.database.orgId, + occurredAt: now, + actionContext: ctx, + payload: { previousArchivedAt: ownership.database.deletedAt }, + }); + }); + if (workflowEventId) wakeContentWorkflowEvent(workflowEventId); await writeAppState("refresh-signal", { ts: Date.now() }); diff --git a/templates/content/actions/restore-document-version.ts b/templates/content/actions/restore-document-version.ts index e0d889a6a6..fd3ebb0add 100644 --- a/templates/content/actions/restore-document-version.ts +++ b/templates/content/actions/restore-document-version.ts @@ -9,6 +9,11 @@ import { parseDocumentFavorite, parseDocumentHideFromSearch, } from "../server/lib/documents.js"; +import { + appendContentWorkflowEvent, + contentWorkflowFingerprint, + wakeContentWorkflowEvent, +} from "./_content-workflow.js"; function nanoid(size = 12): string { const chars = @@ -26,7 +31,7 @@ export default defineAction({ documentId: z.string().optional().describe("Document ID"), versionId: z.string().optional().describe("Version ID"), }), - run: async (args) => { + run: async (args, ctx) => { if (!args.documentId) throw new Error("--documentId is required"); if (!args.versionId) throw new Error("--versionId is required"); @@ -50,28 +55,55 @@ export default defineAction({ if (!version) throw new Error(`Version not found: ${args.versionId}`); const now = new Date().toISOString(); - await db.insert(schema.documentVersions).values({ - id: nanoid(), - ownerEmail, - documentId: args.documentId, - title: doc.title, - content: doc.content, - createdAt: now, - }); + let workflowEventId = ""; + await db.transaction(async (tx) => { + await tx.insert(schema.documentVersions).values({ + id: nanoid(), + ownerEmail, + documentId: args.documentId!, + title: doc.title, + content: doc.content, + createdAt: now, + }); - await db - .update(schema.documents) - .set({ - title: version.title, - content: version.content, - updatedAt: now, - }) - .where( - and( - eq(schema.documents.id, args.documentId), - eq(schema.documents.ownerEmail, ownerEmail), - ), - ); + await tx + .update(schema.documents) + .set({ + title: version.title, + content: version.content, + updatedAt: now, + }) + .where( + and( + eq(schema.documents.id, args.documentId!), + eq(schema.documents.ownerEmail, ownerEmail), + ), + ); + workflowEventId = await appendContentWorkflowEvent(tx, { + topic: "content.document.changed", + subjectType: "document", + subjectId: args.documentId!, + documentId: args.documentId!, + ownerEmail, + orgId: (doc.orgId as string | null | undefined) ?? null, + occurredAt: now, + actionContext: ctx, + payload: { + changeKind: "version_restore", + versionId: args.versionId, + changedFields: ["title", "content"], + before: { + title: doc.title, + contentHash: contentWorkflowFingerprint(doc.content), + }, + after: { + title: version.title, + contentHash: contentWorkflowFingerprint(version.content), + }, + }, + }); + }); + wakeContentWorkflowEvent(workflowEventId); const [updated] = await db .select() diff --git a/templates/content/actions/run.ts b/templates/content/actions/run.ts index 1d6dab5f08..cb91dba3ed 100644 --- a/templates/content/actions/run.ts +++ b/templates/content/actions/run.ts @@ -1,2 +1,6 @@ import { runScript } from "@agent-native/core/scripts"; + +import { registerContentProtectedMutationTables } from "../server/db/protected-mutation-tables.js"; + +registerContentProtectedMutationTables(); runScript(); diff --git a/templates/content/actions/set-document-property.ts b/templates/content/actions/set-document-property.ts index bef4abdebb..aa3c38f2ed 100644 --- a/templates/content/actions/set-document-property.ts +++ b/templates/content/actions/set-document-property.ts @@ -1,4 +1,5 @@ import { defineAction } from "@agent-native/core"; +import type { ActionRunContext } from "@agent-native/core/action"; import { writeAppState } from "@agent-native/core/application-state"; import { assertAccess } from "@agent-native/core/sharing"; import { and, eq } from "drizzle-orm"; @@ -13,86 +14,149 @@ import { parsePropertyOptions, type DocumentPropertyType, } from "../shared/properties.js"; +import { assertContentStatusTransitionReady } from "./_content-database-validation.js"; +import { + appendContentWorkflowEvent, + wakeContentWorkflowEvent, +} from "./_content-workflow.js"; import { listPropertiesForDocument, nanoid, normalizedValueJson, resolvePropertyDatabaseForDocument, - writeBlockFieldContent, - writePrimaryBlocksContent, } from "./_property-utils.js"; -export default defineAction({ - description: "Set a Notion-style property value on a document.", - schema: z.object({ - documentId: z.string().describe("Document ID (required)"), - propertyId: z.string().describe("Property definition ID"), - value: z.unknown().describe("Value for the property type"), - }), - run: async ({ documentId, propertyId, value }) => { - const access = await assertAccess("document", documentId, "editor"); - const document = access.resource; - const db = getDb(); - const database = await resolvePropertyDatabaseForDocument(document); - if (!database) throw new Error("Document is not part of a database."); +export async function setDocumentPropertyValue( + { + documentId, + propertyId, + value, + }: { documentId: string; propertyId: string; value: unknown }, + ctx?: ActionRunContext, +) { + const access = await assertAccess("document", documentId, "editor"); + const document = access.resource; + const db = getDb(); + const database = await resolvePropertyDatabaseForDocument(document); + if (!database) throw new Error("Document is not part of a database."); - const [definition] = await db - .select() - .from(schema.documentPropertyDefinitions) - .where( - and( - eq(schema.documentPropertyDefinitions.id, propertyId), - eq( - schema.documentPropertyDefinitions.ownerEmail, - document.ownerEmail, - ), - eq(schema.documentPropertyDefinitions.databaseId, database.id), - ), - ); - if (!definition) throw new Error(`Property "${propertyId}" not found`); + const [definition] = await db + .select() + .from(schema.documentPropertyDefinitions) + .where( + and( + eq(schema.documentPropertyDefinitions.id, propertyId), + eq(schema.documentPropertyDefinitions.ownerEmail, document.ownerEmail), + eq(schema.documentPropertyDefinitions.databaseId, database.id), + ), + ); + if (!definition) throw new Error(`Property "${propertyId}" not found`); - const type = definition.type as DocumentPropertyType; - if (isComputedPropertyType(type)) { - throw new Error("Computed properties cannot be edited."); - } + const type = definition.type as DocumentPropertyType; + if (isComputedPropertyType(type)) { + throw new Error("Computed properties cannot be edited."); + } - const now = new Date().toISOString(); + const now = new Date().toISOString(); - // Blocks fields store rich-text content, not a property-values row. The - // primary "Content" field writes to the document body; additional Blocks - // fields write to their own independent store. - if (isBlocksPropertyType(type)) { - const normalized = normalizePropertyValue(type, value); - const content = typeof normalized === "string" ? normalized : ""; - const target = blocksStorageTarget( - parsePropertyOptions(definition.optionsJson), - ); + // Blocks fields store rich-text content, not a property-values row. The + // primary "Content" field writes to the document body; additional Blocks + // fields write to their own independent store. + if (isBlocksPropertyType(type)) { + const normalized = normalizePropertyValue(type, value); + const content = typeof normalized === "string" ? normalized : ""; + const target = blocksStorageTarget( + parsePropertyOptions(definition.optionsJson), + ); + let workflowEventId = ""; + await db.transaction(async (tx) => { if (target === "document_body") { - await writePrimaryBlocksContent({ documentId, content, now }); + await tx + .update(schema.documents) + .set({ content, updatedAt: now }) + .where(eq(schema.documents.id, documentId)); } else { - await writeBlockFieldContent({ - documentId, - propertyId, - ownerEmail: document.ownerEmail, - content, - now, - }); + await tx + .insert(schema.documentBlockFieldContents) + .values({ + id: nanoid(), + ownerEmail: document.ownerEmail, + documentId, + propertyId, + content, + createdAt: now, + updatedAt: now, + }) + .onConflictDoUpdate({ + target: [ + schema.documentBlockFieldContents.documentId, + schema.documentBlockFieldContents.propertyId, + ], + set: { content, updatedAt: now }, + }); } - await writeAppState("refresh-signal", { ts: Date.now() }); - return { - documentId, + const propertySnapshot = await tx + .select({ + propertyId: schema.documentPropertyValues.propertyId, + valueJson: schema.documentPropertyValues.valueJson, + }) + .from(schema.documentPropertyValues) + .where(eq(schema.documentPropertyValues.documentId, documentId)); + workflowEventId = await appendContentWorkflowEvent(tx, { + topic: "content.database.property.changed", + subjectType: "content_database_item", + subjectId: documentId, databaseId: database.id, - properties: await listPropertiesForDocument({ - ...document, - content: target === "document_body" ? content : document.content, - updatedAt: now, - }), - }; - } + documentId, + ownerEmail: document.ownerEmail, + orgId: database.orgId, + occurredAt: now, + actionContext: ctx, + payload: { + propertyId, + propertyType: type, + beforeValue: null, + afterValue: null, + valuesOmitted: "block_content", + propertyValues: Object.fromEntries( + propertySnapshot.map((row) => [ + row.propertyId, + JSON.parse(row.valueJson), + ]), + ), + }, + }); + }); + wakeContentWorkflowEvent(workflowEventId); + await writeAppState("refresh-signal", { ts: Date.now() }); + return { + documentId, + databaseId: database.id, + properties: await listPropertiesForDocument({ + ...document, + content: target === "document_body" ? content : document.content, + updatedAt: now, + }), + }; + } - const valueJson = normalizedValueJson(type, value); - const [existing] = await db - .select({ id: schema.documentPropertyValues.id }) + const valueJson = normalizedValueJson(type, value); + if (type === "status") { + const nextStatusOptionId = JSON.parse(valueJson) as string | null; + await assertContentStatusTransitionReady({ + databaseId: database.id, + documentId, + statusPropertyId: propertyId, + statusOptionId: nextStatusOptionId, + }); + } + let workflowEventId = ""; + await db.transaction(async (tx) => { + const [existingValue] = await tx + .select({ + id: schema.documentPropertyValues.id, + valueJson: schema.documentPropertyValues.valueJson, + }) .from(schema.documentPropertyValues) .where( and( @@ -100,14 +164,13 @@ export default defineAction({ eq(schema.documentPropertyValues.propertyId, propertyId), ), ); - - if (existing) { - await db + if (existingValue) { + await tx .update(schema.documentPropertyValues) .set({ valueJson, updatedAt: now }) - .where(eq(schema.documentPropertyValues.id, existing.id)); + .where(eq(schema.documentPropertyValues.id, existingValue.id)); } else { - await db.insert(schema.documentPropertyValues).values({ + await tx.insert(schema.documentPropertyValues).values({ id: nanoid(), ownerEmail: document.ownerEmail, documentId, @@ -117,13 +180,54 @@ export default defineAction({ updatedAt: now, }); } + const propertySnapshot = await tx + .select({ + propertyId: schema.documentPropertyValues.propertyId, + valueJson: schema.documentPropertyValues.valueJson, + }) + .from(schema.documentPropertyValues) + .where(eq(schema.documentPropertyValues.documentId, documentId)); + workflowEventId = await appendContentWorkflowEvent(tx, { + topic: "content.database.property.changed", + subjectType: "content_database_item", + subjectId: documentId, + databaseId: database.id, + documentId, + ownerEmail: document.ownerEmail, + orgId: database.orgId, + occurredAt: now, + actionContext: ctx, + payload: { + propertyId, + propertyType: type, + beforeValue: existingValue ? JSON.parse(existingValue.valueJson) : null, + afterValue: JSON.parse(valueJson), + propertyValues: Object.fromEntries( + propertySnapshot.map((row) => [ + row.propertyId, + JSON.parse(row.valueJson), + ]), + ), + }, + }); + }); + wakeContentWorkflowEvent(workflowEventId); - await writeAppState("refresh-signal", { ts: Date.now() }); + await writeAppState("refresh-signal", { ts: Date.now() }); - return { - documentId, - databaseId: database.id, - properties: await listPropertiesForDocument(document), - }; - }, + return { + documentId, + databaseId: database.id, + properties: await listPropertiesForDocument(document), + }; +} + +export default defineAction({ + description: "Set a Notion-style property value on a document.", + schema: z.object({ + documentId: z.string().describe("Document ID (required)"), + propertyId: z.string().describe("Property definition ID"), + value: z.unknown().describe("Value for the property type"), + }), + run: setDocumentPropertyValue, }); diff --git a/templates/content/actions/submit-content-database-form.db.test.ts b/templates/content/actions/submit-content-database-form.db.test.ts index 2850ddb744..676e3de978 100644 --- a/templates/content/actions/submit-content-database-form.db.test.ts +++ b/templates/content/actions/submit-content-database-form.db.test.ts @@ -258,6 +258,23 @@ describe("submit-content-database-form", () => { ), ); expect(notes.content).toBe("Route through the web design queue."); + + const events = await db + .select() + .from(schema.workflowEvents) + .where( + and( + eq(schema.workflowEvents.topic, "content.database.item.submitted"), + eq(schema.workflowEvents.subjectId, result.createdDocumentId), + ), + ); + expect(events).toHaveLength(1); + expect(JSON.parse(events[0].payload)).toMatchObject({ + databaseId: seeded.databaseId, + documentId: result.createdDocumentId, + itemId: result.createdItemId, + formViewId: "request-form", + }); }); it("rejects missing required questions without creating a partial row", async () => { @@ -286,6 +303,55 @@ describe("submit-content-database-form", () => { expect(after).toHaveLength(before.length); }); + it("enforces database submission requirements even when a form question is optional", async () => { + const seeded = await seedFormDatabase(); + const db = getDb(); + const [database] = await db + .select({ viewConfigJson: schema.contentDatabases.viewConfigJson }) + .from(schema.contentDatabases) + .where(eq(schema.contentDatabases.id, seeded.databaseId)); + const viewConfig = JSON.parse(database.viewConfigJson); + viewConfig.validation = { + requiredForSubmission: [seeded.deadlineId], + statusRequirements: [], + }; + await db + .update(schema.contentDatabases) + .set({ viewConfigJson: JSON.stringify(viewConfig) }) + .where(eq(schema.contentDatabases.id, seeded.databaseId)); + + let error: unknown; + try { + await runWithRequestContext({ userEmail: OWNER }, () => + submitForm.run({ + databaseId: seeded.databaseId, + viewId: "request-form", + title: "No deadline yet", + propertyValues: { + Description: "Create an illustration for the launch post.", + Priority: "P1 — High", + }, + }), + ); + } catch (caught) { + error = caught; + } + expect(error).toMatchObject({ + code: "CONTENT_READINESS_REQUIRED", + statusCode: 409, + details: { + phase: "submission", + databaseId: seeded.databaseId, + missingFields: [{ propertyId: seeded.deadlineId, name: "Deadline" }], + }, + }); + const items = await db + .select() + .from(schema.contentDatabaseItems) + .where(eq(schema.contentDatabaseItems.databaseId, seeded.databaseId)); + expect(items).toHaveLength(0); + }); + it("rejects unknown option labels before creating a row", async () => { const seeded = await seedFormDatabase(); await expect( diff --git a/templates/content/actions/submit-content-database-form.ts b/templates/content/actions/submit-content-database-form.ts index 0dd8f8772b..e28044e59f 100644 --- a/templates/content/actions/submit-content-database-form.ts +++ b/templates/content/actions/submit-content-database-form.ts @@ -1,9 +1,8 @@ import { defineAction, embedApp } from "@agent-native/core"; import { writeAppState } from "@agent-native/core/application-state"; import { buildDeepLink } from "@agent-native/core/server"; -import { getRequestUserEmail } from "@agent-native/core/server/request-context"; import { assertAccess } from "@agent-native/core/sharing"; -import { and, eq, inArray, isNull, sql } from "drizzle-orm"; +import { and, eq, isNull } from "drizzle-orm"; import { z } from "zod"; import { getDb, schema } from "../server/db/index.js"; @@ -13,19 +12,17 @@ import type { } from "../shared/api.js"; import { contentDatabaseFormQuestions } from "../shared/database-form.js"; import { - blocksStorageTarget, - isBlocksPropertyType, isEmptyPropertyValue, isComputedPropertyType, normalizePropertyValue, parsePropertyOptions, - serializePropertyValue, type DocumentPropertyOption, type DocumentPropertyType, type DocumentPropertyValue, } from "../shared/properties.js"; -import { ensureDocumentFilesMembership } from "./_content-files.js"; -import { nanoid, parseDatabaseViewConfig } from "./_property-utils.js"; +import { commitContentDatabaseItem } from "./_content-database-item-mutations.js"; +import { assertAtomicSubmissionReady } from "./_content-database-validation.js"; +import { parseDatabaseViewConfig } from "./_property-utils.js"; const submitContentDatabaseFormSchema = z.object({ databaseId: z.string().min(1).describe("Content database ID"), @@ -181,12 +178,10 @@ export default defineAction({ height: 900, }), }, - run: async ({ - databaseId, - viewId, - title, - propertyValues, - }): Promise => { + run: async ( + { databaseId, viewId, title, propertyValues }, + ctx, + ): Promise => { const db = getDb(); const [database] = await db .select() @@ -268,226 +263,34 @@ export default defineAction({ `Required form fields are missing: ${missing.join(", ")}.`, ); } - - const documentId = nanoid(); - const itemId = nanoid(); - const now = new Date().toISOString(); - const primaryBlocks = definitions.find((definition) => { - const type = definition.type as DocumentPropertyType; - return ( - isBlocksPropertyType(type) && - blocksStorageTarget(parsePropertyOptions(definition.optionsJson)) === - "document_body" - ); - }); - const primaryContent = primaryBlocks - ? values.get(primaryBlocks.id) - : undefined; - const documentContent = - typeof primaryContent === "string" ? primaryContent : ""; - const standardValues = [...values.entries()].filter(([propertyId]) => { - const definition = definitionById.get(propertyId); - return ( - definition && - !isBlocksPropertyType(definition.type as DocumentPropertyType) - ); - }); - const additionalBlocks = [...values.entries()].filter(([propertyId]) => { - const definition = definitionById.get(propertyId); - return ( - definition && - isBlocksPropertyType(definition.type as DocumentPropertyType) && - propertyId !== primaryBlocks?.id - ); + assertAtomicSubmissionReady({ + databaseId, + config: viewConfig, + definitions, + values, }); - const createdBy = getRequestUserEmail() ?? database.ownerEmail; - - await db.transaction(async (tx) => { - const [maxDocumentPosition] = await tx - .select({ max: sql`COALESCE(MAX(position), -1)` }) - .from(schema.documents) - .where( - and( - eq(schema.documents.ownerEmail, database.ownerEmail), - eq(schema.documents.parentId, database.documentId), - ), - ); - const [maxItemPosition] = await tx - .select({ max: sql`COALESCE(MAX(position), -1)` }) - .from(schema.contentDatabaseItems) - .where(eq(schema.contentDatabaseItems.databaseId, databaseId)); - const inheritedShares = await tx - .select({ - principalType: schema.documentShares.principalType, - principalId: schema.documentShares.principalId, - role: schema.documentShares.role, - }) - .from(schema.documentShares) - .where(eq(schema.documentShares.resourceId, database.documentId)); - - await tx.insert(schema.documents).values({ - id: documentId, - spaceId: database.spaceId, - ownerEmail: database.ownerEmail, - orgId: database.orgId, - parentId: database.documentId, - title: normalizedTitle, - content: documentContent, - icon: null, - position: (maxDocumentPosition?.max ?? -1) + 1, - isFavorite: 0, - hideFromSearch: databaseDocument.hideFromSearch ?? 0, - visibility: databaseDocument.visibility ?? "private", - createdAt: now, - updatedAt: now, - }); - await tx.insert(schema.contentDatabaseItems).values({ - id: itemId, - ownerEmail: database.ownerEmail, - orgId: database.orgId, - databaseId, - documentId, - position: (maxItemPosition?.max ?? -1) + 1, - createdAt: now, - updatedAt: now, - }); - if (inheritedShares.length > 0) { - await tx.insert(schema.documentShares).values( - inheritedShares.map((share) => ({ - id: nanoid(), - resourceId: documentId, - principalType: share.principalType, - principalId: share.principalId, - role: share.role, - createdBy, - createdAt: now, - })), - ); - } - if (standardValues.length > 0) { - await tx.insert(schema.documentPropertyValues).values( - standardValues.map(([propertyId, value]) => ({ - id: nanoid(), - ownerEmail: database.ownerEmail, - documentId, - propertyId, - valueJson: serializePropertyValue(value), - createdAt: now, - updatedAt: now, - })), - ); - } - if (additionalBlocks.length > 0) { - await tx.insert(schema.documentBlockFieldContents).values( - additionalBlocks.map(([propertyId, value]) => ({ - id: nanoid(), - ownerEmail: database.ownerEmail, - documentId, - propertyId, - content: typeof value === "string" ? value : "", - createdAt: now, - updatedAt: now, - })), - ); - } - - await ensureDocumentFilesMembership(tx, documentId, now); - const [savedDocument] = await tx - .select({ - id: schema.documents.id, - title: schema.documents.title, - content: schema.documents.content, - }) - .from(schema.documents) - .where(eq(schema.documents.id, documentId)); - const [savedItem] = await tx - .select({ id: schema.contentDatabaseItems.id }) - .from(schema.contentDatabaseItems) - .where( - and( - eq(schema.contentDatabaseItems.id, itemId), - eq(schema.contentDatabaseItems.documentId, documentId), - eq(schema.contentDatabaseItems.databaseId, databaseId), - ), - ); - const savedPropertyValues = - standardValues.length === 0 - ? [] - : await tx - .select({ - propertyId: schema.documentPropertyValues.propertyId, - valueJson: schema.documentPropertyValues.valueJson, - }) - .from(schema.documentPropertyValues) - .where( - and( - eq(schema.documentPropertyValues.documentId, documentId), - inArray( - schema.documentPropertyValues.propertyId, - standardValues.map(([propertyId]) => propertyId), - ), - ), - ); - const savedByPropertyId = new Map( - savedPropertyValues.map((value) => [value.propertyId, value.valueJson]), - ); - const standardValuesVerified = standardValues.every( - ([propertyId, value]) => - savedByPropertyId.get(propertyId) === serializePropertyValue(value), - ); - const savedAdditionalBlocks = - additionalBlocks.length === 0 - ? [] - : await tx - .select({ - propertyId: schema.documentBlockFieldContents.propertyId, - content: schema.documentBlockFieldContents.content, - }) - .from(schema.documentBlockFieldContents) - .where( - and( - eq(schema.documentBlockFieldContents.documentId, documentId), - inArray( - schema.documentBlockFieldContents.propertyId, - additionalBlocks.map(([propertyId]) => propertyId), - ), - ), - ); - const savedBlockByPropertyId = new Map( - savedAdditionalBlocks.map((value) => [value.propertyId, value.content]), - ); - const additionalBlocksVerified = additionalBlocks.every( - ([propertyId, value]) => - savedBlockByPropertyId.get(propertyId) === - (typeof value === "string" ? value : ""), - ); - if ( - !savedDocument || - !savedItem || - savedDocument.title !== normalizedTitle || - savedDocument.content !== documentContent || - !standardValuesVerified || - !additionalBlocksVerified - ) { - throw new Error( - "The form submission could not be verified; no row was saved.", - ); - } + const mutation = await commitContentDatabaseItem({ + databaseId, + title: normalizedTitle, + values, + intent: "submitted", + formViewId: formView.id, + actionContext: ctx, }); await writeAppState("refresh-signal", { ts: Date.now() }); const deepLink = buildDeepLink({ app: "content", view: "editor", - params: { documentId }, + params: { documentId: mutation.documentId }, }); return { databaseId, viewId: formView.id, - createdItemId: itemId, - createdDocumentId: documentId, - urlPath: `/page/${documentId}`, + createdItemId: mutation.itemId, + createdDocumentId: mutation.documentId, + urlPath: `/page/${mutation.documentId}`, deepLink, verified: true, }; diff --git a/templates/content/actions/update-comment.test.ts b/templates/content/actions/update-comment.test.ts index 14b381efcf..18db865cd8 100644 --- a/templates/content/actions/update-comment.test.ts +++ b/templates/content/actions/update-comment.test.ts @@ -17,7 +17,9 @@ type Row = { const state = vi.hoisted(() => ({ rows: [] as Row[] })); const mockAssertAccess = vi.hoisted(() => vi.fn()); const mockGetUserEmail = vi.hoisted(() => vi.fn(() => "author@example.com")); -const mockWriteAppState = vi.hoisted(() => vi.fn()); +const mockWriteAppState = vi.hoisted(() => + vi.fn().mockResolvedValue(undefined), +); vi.mock("@agent-native/core/sharing", () => ({ assertAccess: (...args: unknown[]) => mockAssertAccess(...args), @@ -112,6 +114,7 @@ function run(args: { beforeEach(() => { vi.resetAllMocks(); + mockWriteAppState.mockResolvedValue(undefined); mockGetUserEmail.mockReturnValue("author@example.com"); state.rows = [ { diff --git a/templates/content/actions/update-comment.ts b/templates/content/actions/update-comment.ts index 3a5f5a0c50..5b59b716ed 100644 --- a/templates/content/actions/update-comment.ts +++ b/templates/content/actions/update-comment.ts @@ -16,7 +16,7 @@ export default defineAction({ content: z.string().optional().describe("New comment text"), resolved: z.coerce.boolean().optional().describe("Resolved state"), }), - run: async (args) => { + run: async (args, ctx) => { const db = getDb(); const [comment] = await db .select({ @@ -35,7 +35,7 @@ export default defineAction({ throw new Error(`Comment not found: ${args.id}`); } - const userEmail = getRequestUserEmail(); + const userEmail = ctx?.userEmail ?? getRequestUserEmail(); if ( args.resolved === true || args.resolved === false || @@ -57,7 +57,7 @@ export default defineAction({ eq(schema.documentComments.threadId, comment.threadId), ), ); - await writeAppState("refresh-signal", { ts: Date.now() }); + await writeAppState("refresh-signal", { ts: Date.now() }).catch(() => {}); return { ok: true, resolved: true }; } @@ -71,7 +71,7 @@ export default defineAction({ eq(schema.documentComments.threadId, comment.threadId), ), ); - await writeAppState("refresh-signal", { ts: Date.now() }); + await writeAppState("refresh-signal", { ts: Date.now() }).catch(() => {}); return { ok: true, resolved: false }; } @@ -95,7 +95,7 @@ export default defineAction({ ), ); - await writeAppState("refresh-signal", { ts: Date.now() }); + await writeAppState("refresh-signal", { ts: Date.now() }).catch(() => {}); return { ok: true }; }, }); diff --git a/templates/content/actions/update-content-database-view.test.ts b/templates/content/actions/update-content-database-view.test.ts index c8a5f4ce59..36b8b97207 100644 --- a/templates/content/actions/update-content-database-view.test.ts +++ b/templates/content/actions/update-content-database-view.test.ts @@ -107,6 +107,10 @@ describe("update content database view", () => { }), ); expect(legacy.views[0].formQuestions).toEqual([]); + expect(legacy.validation).toEqual({ + requiredForSubmission: [], + statusRequirements: [], + }); const form = parseDatabaseViewConfig( JSON.stringify({ @@ -129,4 +133,36 @@ describe("update content database view", () => { formQuestions: [{ key: "name", enabled: true, required: true }], }); }); + + it("normalizes stable-ID submission and status requirements", () => { + const parsed = parseDatabaseViewConfig( + JSON.stringify({ + validation: { + requiredForSubmission: ["brief", "brief", "assets"], + statusRequirements: [ + { + statusPropertyId: "status", + statusOptionId: "ready", + requiredPropertyIds: ["brief", "brief"], + }, + { + statusPropertyId: "status", + statusOptionId: "ready", + requiredPropertyIds: ["ignored-duplicate-rule"], + }, + ], + }, + }), + ); + expect(parsed.validation).toEqual({ + requiredForSubmission: ["brief", "assets"], + statusRequirements: [ + { + statusPropertyId: "status", + statusOptionId: "ready", + requiredPropertyIds: ["brief"], + }, + ], + }); + }); }); diff --git a/templates/content/actions/update-content-database-view.ts b/templates/content/actions/update-content-database-view.ts index de44b8eb5b..b87ee94d38 100644 --- a/templates/content/actions/update-content-database-view.ts +++ b/templates/content/actions/update-content-database-view.ts @@ -6,7 +6,10 @@ import { z } from "zod"; import { getDb, schema } from "../server/db/index.js"; import { getContentDatabaseResponse } from "./_database-utils.js"; -import { serializeDatabaseViewConfig } from "./_property-utils.js"; +import { + parseDatabaseViewConfig, + serializeDatabaseViewConfig, +} from "./_property-utils.js"; const sortSchema = z.object({ key: z.string(), @@ -125,10 +128,14 @@ export default defineAction({ await assertAccess("document", database.documentId, "editor"); + const existingConfig = parseDatabaseViewConfig(database.viewConfigJson); await db .update(schema.contentDatabases) .set({ - viewConfigJson: serializeDatabaseViewConfig(viewConfig), + viewConfigJson: serializeDatabaseViewConfig({ + ...viewConfig, + validation: existingConfig.validation, + }), updatedAt: new Date().toISOString(), }) .where(eq(schema.contentDatabases.id, databaseId)); diff --git a/templates/content/actions/view-screen.ts b/templates/content/actions/view-screen.ts index 8ad90e0a87..5e4f836a9d 100644 --- a/templates/content/actions/view-screen.ts +++ b/templates/content/actions/view-screen.ts @@ -29,6 +29,7 @@ import { formulaValueText, isEmptyPropertyValue, } from "../shared/properties.js"; +import { listContentDatabaseHooks } from "./_content-database-hooks.js"; import { filterDatabaseContainedDocuments, getContentDatabaseResponse, @@ -650,6 +651,7 @@ export default defineAction({ http: false, run: async () => { const navigation = await readAppStateForCurrentTab("navigation"); + const hookContext = await readAppStateForCurrentTab("content-hook-context"); const localFilesState = await readAppState("local-files"); const contentSpaceState = await readAppState("content-space"); @@ -710,6 +712,13 @@ export default defineAction({ nav, databaseResponse, ); + screen.databaseHooks = { + hooks: await listContentDatabaseHooks(database.id), + context: + isRecord(hookContext) && hookContext.databaseId === database.id + ? hookContext + : undefined, + }; const previewDocumentId = typeof nav?.databasePreviewDocumentId === "string" diff --git a/templates/content/app/components/editor/DocumentEditor.layout.test.ts b/templates/content/app/components/editor/DocumentEditor.layout.test.ts index 0134118534..32a4fb124c 100644 --- a/templates/content/app/components/editor/DocumentEditor.layout.test.ts +++ b/templates/content/app/components/editor/DocumentEditor.layout.test.ts @@ -312,6 +312,15 @@ describe("document editor layout", () => { expect(documentEditorDefaultIconKind({ database: undefined })).toBeNull(); }); + it("tells the toolbar when the current document is a database page", () => { + const documentEditorSource = readFileSync( + new URL("./DocumentEditor.tsx", import.meta.url), + { encoding: "utf8" }, + ); + + expect(documentEditorSource).toContain("isDatabasePage={isDatabasePage}"); + }); + it("labels database row pages with their parent database", () => { expect( databaseMembershipDatabaseTitle({ diff --git a/templates/content/app/components/editor/DocumentEditor.tsx b/templates/content/app/components/editor/DocumentEditor.tsx index 00ece8c099..2173af74bf 100644 --- a/templates/content/app/components/editor/DocumentEditor.tsx +++ b/templates/content/app/components/editor/DocumentEditor.tsx @@ -1316,6 +1316,10 @@ function DocumentEditorBody({ documentId, document }: DocumentEditorBodyProps) { canEdit={canEdit} hideFromSearch={document.hideFromSearch} source={document.source} + notificationDatabaseId={ + document.database?.id ?? document.databaseMembership?.databaseId + } + isDatabasePage={isDatabasePage} /> {!isLocalFileDocument ? ( diff --git a/templates/content/app/components/editor/DocumentToolbar.tsx b/templates/content/app/components/editor/DocumentToolbar.tsx index 97af6fab08..0407b5f61d 100644 --- a/templates/content/app/components/editor/DocumentToolbar.tsx +++ b/templates/content/app/components/editor/DocumentToolbar.tsx @@ -1,5 +1,6 @@ import { AgentToggleButton, + NotificationsBell, PresenceBar, appPath, useActionMutation, @@ -8,11 +9,14 @@ import { } from "@agent-native/core/client"; import { ShareButton } from "@agent-native/core/client"; import { CreativeContextShareTab } from "@agent-native/creative-context/client"; +import { Switch } from "@agent-native/toolkit/ui/switch"; import type { DocumentSourceInfo } from "@shared/api"; import { IconArrowBarDown, IconArrowBarUp, IconAlertTriangle, + IconBell, + IconBellOff, IconCopy, IconDownload, IconDotsVertical, @@ -59,6 +63,10 @@ import { TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; +import { + useContentNotificationPreference, + useManageContentNotificationPreference, +} from "@/hooks/use-content-database"; import { useLocalStorage } from "@/hooks/use-local-storage"; import { useNotionConnection, @@ -259,6 +267,142 @@ interface DocumentToolbarProps { canEdit?: boolean; hideFromSearch?: boolean; source?: DocumentSourceInfo; + notificationDatabaseId?: string; + isDatabasePage?: boolean; +} + +function ItemNotificationSetting({ + databaseId, + documentId, + isDatabasePage, +}: { + databaseId: string; + documentId: string; + isDatabasePage: boolean; +}) { + const t = useT(); + const preference = useContentNotificationPreference({ + scope: "item", + databaseId, + documentId, + }); + const managePreference = useManageContentNotificationPreference(databaseId); + const enabled = preference.data?.preference.enabled ?? true; + const descriptionId = `page-notification-scope-${documentId}`; + + const handleToggle = useCallback(async () => { + try { + await managePreference.mutateAsync({ + action: "set", + target: { scope: "item", databaseId, documentId }, + enabled: !enabled, + }); + toast.success( + t( + enabled + ? "editor.toolbar.notificationsMutedForPage" + : "editor.toolbar.notificationsEnabledForPage", + ), + ); + } catch (error) { + toast.error(t("editor.toolbar.notificationPreferenceUpdateFailed"), { + description: + error instanceof Error ? error.message : t("empty.genericError"), + }); + } + }, [databaseId, documentId, enabled, managePreference, t]); + + return ( + + ); +} + +function PersonalNotificationSetting({ + databaseId, + scope, +}: { + databaseId: string; + scope: "database" | "global"; +}) { + const t = useT(); + const target = + scope === "global" + ? ({ scope: "global" } as const) + : ({ scope: "database", databaseId } as const); + const preference = useContentNotificationPreference(target); + const managePreference = useManageContentNotificationPreference(databaseId); + const enabled = preference.data?.preference.enabled ?? true; + const label = t( + scope === "global" + ? "database.personalContentNotifications" + : "database.personalDatabaseNotifications", + ); + + return ( +
+ + {label} + + {t( + scope === "global" + ? "database.personalContentNotificationsDescription" + : "database.personalDatabaseNotificationsDescription", + )} + + + + managePreference.mutate( + { action: "set", target, enabled: next }, + { + onError: (error) => + toast.error( + t("editor.toolbar.notificationPreferenceUpdateFailed"), + { + description: + error instanceof Error + ? error.message + : t("empty.genericError"), + }, + ), + }, + ) + } + /> +
+ ); } export function DocumentToolbar({ @@ -274,6 +418,8 @@ export function DocumentToolbar({ canEdit = true, hideFromSearch = false, source, + notificationDatabaseId, + isDatabasePage = false, }: DocumentToolbarProps) { const t = useT(); const navigate = useNavigate(); @@ -1206,6 +1352,28 @@ export function DocumentToolbar({ + + + + +
+ ) : undefined + } + />
diff --git a/templates/content/app/components/editor/database/DatabaseHookIncidentControls.tsx b/templates/content/app/components/editor/database/DatabaseHookIncidentControls.tsx new file mode 100644 index 0000000000..2209a049e3 --- /dev/null +++ b/templates/content/app/components/editor/database/DatabaseHookIncidentControls.tsx @@ -0,0 +1,127 @@ +import { useT } from "@agent-native/core/client"; +import { Label } from "@agent-native/toolkit/ui/label"; +import { Spinner } from "@agent-native/toolkit/ui/spinner"; +import { Switch } from "@agent-native/toolkit/ui/switch"; +import { IconAlertTriangle } from "@tabler/icons-react"; +import { toast } from "sonner"; + +import { + useContentHookRuntimeControls, + useManageContentHookRuntimeControl, +} from "@/hooks/use-content-database"; + +export function DatabaseHookIncidentControls({ + databaseId, + canManage, + hasExecutionIncident = false, +}: { + databaseId: string; + canManage: boolean; + hasExecutionIncident?: boolean; +}) { + const t = useT(); + const controls = useContentHookRuntimeControls(databaseId); + const manage = useManageContentHookRuntimeControl(databaseId); + const data = controls.data; + const needsAttention = + hasExecutionIncident || + data?.effective.evaluatorPaused || + data?.effective.effectsPaused; + + const update = async ( + scope: "global" | "database", + patch: { evaluatorPaused?: boolean; effectsPaused?: boolean }, + ) => { + if (!data) return; + try { + await manage.mutateAsync({ + databaseId, + scope, + evaluatorPaused: patch.evaluatorPaused ?? data[scope].evaluatorPaused, + effectsPaused: patch.effectsPaused ?? data[scope].effectsPaused, + }); + } catch (error) { + toast.error(t("database.hookPauseSaveFailed"), { + description: + error instanceof Error ? error.message : t("empty.genericError"), + }); + } + }; + + if (!controls.isLoading && !needsAttention) return null; + + return ( +
+ + + + + + + {t("database.hookIncidentControls")} + + + {data?.effective.evaluatorPaused || data?.effective.effectsPaused + ? t("database.hookProcessingPaused") + : t("database.executionFailed")} + + + +
+

+ {t("database.hookIncidentControlsDescription")} +

+ {controls.isLoading ? ( +
+ +
+ ) : data ? ( + (["database", "global"] as const).map((scope) => { + const editable = + canManage && (scope === "database" || data.canManageGlobal); + return ( +
+

+ {t( + scope === "database" + ? "database.thisDatabase" + : "database.allContentDatabases", + )} +

+
+ + + void update(scope, { evaluatorPaused }) + } + /> +
+
+ + + void update(scope, { effectsPaused }) + } + /> +
+
+ ); + }) + ) : null} +

+ {t("database.pausedHookEventsNotReplayed")} +

+
+
+ ); +} diff --git a/templates/content/app/components/editor/database/DatabaseHooksPanel.layout.test.ts b/templates/content/app/components/editor/database/DatabaseHooksPanel.layout.test.ts new file mode 100644 index 0000000000..e76b88c0aa --- /dev/null +++ b/templates/content/app/components/editor/database/DatabaseHooksPanel.layout.test.ts @@ -0,0 +1,174 @@ +import { readFileSync } from "node:fs"; + +import { describe, expect, it } from "vitest"; + +import { hookMessagesByLocale } from "../../../hook-i18n"; +import { hookRuntimeMessagesByLocale } from "../../../hook-runtime-i18n"; + +const panelSource = readFileSync( + new URL("./DatabaseHooksPanel.tsx", import.meta.url), + "utf8", +); +const databaseViewSource = readFileSync( + new URL("./DatabaseView.tsx", import.meta.url), + "utf8", +); +const incidentControlsSource = readFileSync( + new URL("./DatabaseHookIncidentControls.tsx", import.meta.url), + "utf8", +); +const headerSource = readFileSync( + new URL("../../layout/Header.tsx", import.meta.url), + "utf8", +); +const toolbarSource = readFileSync( + new URL("../DocumentToolbar.tsx", import.meta.url), + "utf8", +); +const i18nSource = readFileSync( + new URL("../../../i18n-data.ts", import.meta.url), + "utf8", +); +const hookRuntimeI18nSource = readFileSync( + new URL("../../../hook-runtime-i18n.ts", import.meta.url), + "utf8", +); + +describe("Content database hooks product surface", () => { + it("mounts notifications in ordinary and document-specific chrome", () => { + expect(headerSource).toContain(" { + expect(databaseViewSource).toContain('| "hooks"'); + expect(databaseViewSource).toContain(" onPanelChange("hooks")}', + ); + }); + + it("uses the shared action surface and stable database identifiers", () => { + expect(panelSource).toContain("useContentDatabaseHooks(databaseId)"); + expect(panelSource).toContain( + "useContentDatabaseHookExecutions(databaseId)", + ); + expect(panelSource).toContain("useManageContentDatabaseHook(databaseId)"); + expect(panelSource).toContain("propertyId: draft.propertyId"); + expect(panelSource).toContain("toOptionId:"); + expect(panelSource).toContain("recipientPersonPropertyId:"); + expect(panelSource).not.toContain("fetch("); + expect(panelSource).not.toContain("deliveryAttempt"); + }); + + it("keeps advanced transition detail and execution history disclosed", () => { + expect(panelSource).toContain("showPreviousValue"); + expect(panelSource).toContain(" { + expect(panelSource).toContain('kind: "immediate"'); + expect(panelSource).toContain('value="delayed"'); + expect(panelSource).toContain('value="debounced"'); + expect(panelSource).toContain('value="escalation"'); + expect(panelSource).toContain("delayMinutes"); + expect(panelSource).toContain("whenBuilderPublicationConfirmed"); + expect(panelSource).toContain("publicationAction"); + expect(panelSource).not.toContain("agentJudgmentUsesAutomations"); + expect(panelSource).toContain(" { + expect(panelSource).toContain('t("database.if")'); + expect(panelSource).toContain("addConditions"); + expect(panelSource).toContain("matchAllConditions"); + expect(panelSource).toContain("matchAnyCondition"); + expect(panelSource).toContain('value="not_equals"'); + expect(panelSource).toContain('value="contains"'); + expect(panelSource).toContain('value="is_empty"'); + expect(panelSource).toContain("condition.propertyId"); + expect(panelSource).toContain("conditions: draft.conditions"); + }); + + it("presents one Rules model with submission as the primary trigger", () => { + expect(panelSource).toContain('candidate.kind === "item_submitted"'); + expect(panelSource).toContain('value="item_submitted"'); + expect(panelSource).toContain("anItemIsSubmitted"); + expect(panelSource).toContain('value="item_created"'); + expect(panelSource).toContain( + 'availabilityFor("item_created")?.available !== true', + ); + expect(panelSource).toContain("itemCreatedUnavailable"); + expect(i18nSource).toContain('notificationsAndHooks: "Rules"'); + expect(i18nSource).toContain('effects: "Actions"'); + expect(i18nSource).toContain('addEffect: "Add action"'); + expect(hookRuntimeI18nSource).toContain("Rules and Automations"); + expect(i18nSource).not.toContain('notificationsAndHooks: "Notifications'); + for (const messages of Object.values(hookMessagesByLocale)) { + expect(messages.notificationsAndHooks).not.toMatch(/hooks?/i); + expect(messages.noHookExecutions).not.toMatch(/hooks?/i); + expect(messages.effects).not.toMatch(/effects?/i); + } + for (const messages of Object.values(hookRuntimeMessagesByLocale)) { + expect(messages.hookProcessingPaused).not.toMatch(/hooks?/i); + expect(messages.hookProcessingActive).not.toMatch(/hooks?/i); + expect(messages.agentJudgmentUsesAutomations).not.toMatch(/hooks?/i); + } + }); + + it("closes the versioned deterministic property-effect builder", () => { + expect(panelSource).toContain('value="set_property"'); + expect(panelSource).toContain("version: 1"); + expect(panelSource).toContain("deterministicEffectProperties"); + expect(panelSource).toContain("hookCycleStopped"); + expect(panelSource).toContain("hookDepthStopped"); + expect(panelSource).toContain("assertNever(effect)"); + }); + + it("exposes viewer-visible incident state through actions", () => { + expect(panelSource).toContain(" + ![ + "blocks", + "formula", + "rollup", + "id", + "created_time", + "created_by", + "last_edited_time", + "last_edited_by", + ].includes(property.definition.type), + ); +} + +function executionStatusKey(status: ContentDatabaseHookExecutionStatus) { + if (status === "pending") return "database.executionPending" as const; + if (status === "running") return "database.executionRunning" as const; + if (status === "succeeded") return "database.executionSucceeded" as const; + if (status === "failed") return "database.executionFailed" as const; + if (status === "retrying") return "database.executionRetrying" as const; + if (status === "acknowledged") + return "database.executionAcknowledged" as const; + return "database.executionUnknown" as const; +} + +function triggerSummaryKey(trigger: ContentDatabaseHookTrigger) { + switch (trigger.kind) { + case "item_submitted": + return "database.whenItemSubmitted" as const; + case "item_created": + return "database.whenItemCreated" as const; + case "property_changed": + return "database.whenPropertyChanges" as const; + case "builder_publication_confirmed": + return "database.whenBuilderPublicationConfirmed" as const; + default: + return assertNever(trigger); + } +} + +function emptyDraft( + properties: DocumentProperty[], + availability: ContentDatabaseHookTriggerAvailability[], +): HookDraft { + const personProperties = properties.filter( + (property) => property.definition.type === "person", + ); + const triggerKind = + availability.find( + (candidate) => candidate.kind === "item_submitted" && candidate.available, + )?.kind ?? + availability.find((candidate) => candidate.available)?.kind ?? + "item_submitted"; + return { + name: "", + enabled: true, + triggerKind, + propertyId: "", + fromOptionId: EMPTY_OPTION, + toOptionId: EMPTY_OPTION, + publicationAction: EMPTY_OPTION, + conditions: undefined, + timing: { kind: "immediate" }, + effects: [ + { + version: 1, + kind: "notify", + recipientPersonPropertyId: personProperties[0]?.definition.id ?? "", + }, + ], + }; +} + +function hookDraft(hook: ContentDatabaseHook): HookDraft { + return { + hookId: hook.id, + name: hook.name, + enabled: hook.enabled, + triggerKind: hook.trigger.kind, + propertyId: + hook.trigger.kind === "property_changed" ? hook.trigger.propertyId : "", + fromOptionId: + hook.trigger.kind === "property_changed" && hook.trigger.fromOptionId + ? hook.trigger.fromOptionId + : EMPTY_OPTION, + toOptionId: + hook.trigger.kind === "property_changed" && hook.trigger.toOptionId + ? hook.trigger.toOptionId + : EMPTY_OPTION, + publicationAction: + hook.trigger.kind === "builder_publication_confirmed" && + hook.trigger.publicationAction + ? hook.trigger.publicationAction + : EMPTY_OPTION, + conditions: hook.conditions, + timing: hook.timing, + effects: hook.effects, + }; +} + +function emptyEffect( + kind: ContentDatabaseHookEffect["kind"], + properties: DocumentProperty[], +): ContentDatabaseHookEffect { + switch (kind) { + case "notify": + return { + version: 1, + kind, + recipientPersonPropertyId: + properties.find((property) => property.definition.type === "person") + ?.definition.id ?? "", + }; + case "team_slack": + return { version: 1, kind, webhookKey: "" }; + case "webhook": + return { version: 1, kind, urlKey: "", signatureKey: "" }; + case "set_property": + return { + version: 1, + kind, + propertyId: + deterministicEffectProperties(properties)[0]?.definition.id ?? "", + value: null, + }; + default: + return assertNever(kind); + } +} + +function validEffect(effect: ContentDatabaseHookEffect) { + switch (effect.kind) { + case "notify": + return !!effect.recipientPersonPropertyId; + case "team_slack": + return !!effect.webhookKey.trim(); + case "webhook": + return !!effect.urlKey.trim() && !!effect.signatureKey.trim(); + case "set_property": + return !!effect.propertyId; + default: + return assertNever(effect); + } +} + +function emptyCondition( + properties: DocumentProperty[], +): ContentDatabaseHookCondition { + return { + propertyId: + deterministicEffectProperties(properties)[0]?.definition.id ?? "", + operator: "is_not_empty", + }; +} + +function validConditions(conditions?: ContentDatabaseHookConditions) { + return ( + !conditions || + (conditions.clauses.length > 0 && + conditions.clauses.every((condition) => !!condition.propertyId)) + ); +} + +export function DatabaseHooksPanel({ + databaseId, + properties, + canManage, + isOwner, + defaultPersonNotificationsEnabled, +}: { + databaseId: string; + properties: DocumentProperty[]; + canManage: boolean; + isOwner: boolean; + defaultPersonNotificationsEnabled: boolean; +}) { + const t = useT(); + const hooksQuery = useContentDatabaseHooks(databaseId); + const executionsQuery = useContentDatabaseHookExecutions(databaseId); + const manageHook = useManageContentDatabaseHook(databaseId); + const managePolicy = useManageContentDatabasePolicy(); + const personProperties = useMemo( + () => + properties.filter((property) => property.definition.type === "person"), + [properties], + ); + const [draft, setDraft] = useState(null); + const [defaultNotificationsEnabled, setDefaultNotificationsEnabled] = + useState(defaultPersonNotificationsEnabled); + const [deleteConfirmationOpen, setDeleteConfirmationOpen] = useState(false); + const [showPreviousValue, setShowPreviousValue] = useState(false); + const selectedProperty = properties.find( + (property) => property.definition.id === draft?.propertyId, + ); + const stableOptions = selectedProperty?.definition.options.options ?? []; + const hooks = hooksQuery.data?.hooks ?? []; + const executions = executionsQuery.data?.executions ?? []; + const hasExecutionIncident = executions.some( + (execution) => + execution.status === "failed" || execution.status === "retrying", + ); + const triggerAvailability = hooksQuery.data?.triggerAvailability ?? []; + const availabilityFor = (kind: TriggerKind) => + triggerAvailability.find((candidate) => candidate.kind === kind); + const draftTriggerAvailability = draft + ? availabilityFor(draft.triggerKind) + : undefined; + + useEffect(() => { + setDefaultNotificationsEnabled(defaultPersonNotificationsEnabled); + }, [defaultPersonNotificationsEnabled]); + + useEffect(() => { + const key = `content-hook-context:${getBrowserTabId()}`; + void setClientAppState( + key, + { + databaseId, + view: draft ? (draft.hookId ? "edit" : "create") : "list", + hookId: draft?.hookId, + }, + { requestSource: "frontend" }, + ).catch(() => {}); + return () => { + void setClientAppState(key, null, { + keepalive: true, + requestSource: "frontend", + }).catch(() => {}); + }; + }, [databaseId, draft?.hookId, Boolean(draft)]); + + const startCreate = () => { + setDraft(emptyDraft(properties, triggerAvailability)); + setShowPreviousValue(false); + }; + + const startEdit = (hook: ContentDatabaseHook) => { + const next = hookDraft(hook); + setDraft(next); + setShowPreviousValue(next.fromOptionId !== EMPTY_OPTION); + }; + + const saveDraft = async () => { + if ( + !draft || + !draft.name.trim() || + !draft.effects.length || + !draft.effects.every(validEffect) || + !validConditions(draft.conditions) + ) + return; + if (availabilityFor(draft.triggerKind)?.available !== true) return; + if (draft.triggerKind === "property_changed" && !draft.propertyId) return; + + let trigger: ContentDatabaseHookTrigger; + switch (draft.triggerKind) { + case "item_submitted": + trigger = { kind: "item_submitted" }; + break; + case "item_created": + trigger = { kind: "item_created" }; + break; + case "builder_publication_confirmed": + trigger = { + kind: "builder_publication_confirmed", + publicationAction: + draft.publicationAction === EMPTY_OPTION + ? null + : (draft.publicationAction as "publish" | "unpublish"), + }; + break; + case "property_changed": + trigger = { + kind: "property_changed", + propertyId: draft.propertyId, + fromOptionId: + showPreviousValue && draft.fromOptionId !== EMPTY_OPTION + ? draft.fromOptionId + : null, + toOptionId: + draft.toOptionId === EMPTY_OPTION ? null : draft.toOptionId, + }; + break; + } + try { + await manageHook.mutateAsync( + draft.hookId + ? { + action: "update", + databaseId, + hookId: draft.hookId, + name: draft.name.trim(), + enabled: draft.enabled, + trigger, + conditions: draft.conditions ?? null, + effects: draft.effects, + timing: draft.timing, + } + : { + action: "create", + databaseId, + name: draft.name.trim(), + enabled: draft.enabled, + trigger, + conditions: draft.conditions, + effects: draft.effects, + timing: draft.timing, + }, + ); + toast.success( + t( + draft.hookId + ? "database.notificationRuleUpdated" + : "database.notificationRuleCreated", + ), + ); + setDraft(null); + } catch (error) { + toast.error(t("database.notificationRuleSaveFailed"), { + description: + error instanceof Error ? error.message : t("empty.genericError"), + }); + } + }; + + const deleteHook = async (hookId: string) => { + try { + await manageHook.mutateAsync({ action: "delete", databaseId, hookId }); + toast.success(t("database.notificationRuleDeleted")); + setDeleteConfirmationOpen(false); + setDraft(null); + } catch (error) { + toast.error(t("database.notificationRuleDeleteFailed"), { + description: + error instanceof Error ? error.message : t("empty.genericError"), + }); + } + }; + + const setDefaultPersonNotifications = (enabled: boolean) => { + setDefaultNotificationsEnabled(enabled); + managePolicy.mutate( + { databaseId, defaultPersonNotificationsEnabled: enabled }, + { + onSuccess: () => + toast.success( + t( + enabled + ? "database.defaultPersonNotificationsEnabled" + : "database.defaultPersonNotificationsDisabled", + ), + ), + onError: (error) => { + setDefaultNotificationsEnabled(!enabled); + toast.error(t("database.defaultPersonNotificationsFailed"), { + description: + error instanceof Error ? error.message : t("empty.genericError"), + }); + }, + }, + ); + }; + + const setHookEnabled = async ( + hook: ContentDatabaseHook, + enabled: boolean, + ) => { + try { + await manageHook.mutateAsync({ + action: "update", + databaseId, + hookId: hook.id, + name: hook.name, + enabled, + trigger: hook.trigger, + conditions: hook.conditions, + effects: hook.effects, + timing: hook.timing, + }); + toast.success(t("database.notificationRuleUpdated")); + } catch (error) { + toast.error(t("database.notificationRuleSaveFailed"), { + description: + error instanceof Error ? error.message : t("empty.genericError"), + }); + } + }; + + if (hooksQuery.isLoading) { + return ( +
+ +
+ ); + } + + return ( +
+ +
+

+ {t("database.notificationRulesDescription")} +

+ {canManage ? ( + + ) : null} +
+ + {personProperties.length || hooks.length ? ( +
+ {personProperties.length ? ( +
+ + + + + + {t("database.defaultPersonNotifications")} + + + {t("database.defaultPersonNotificationsDescription")} + + + + {t( + defaultNotificationsEnabled + ? "database.ruleEnabled" + : "database.rulePaused", + )} + + {isOwner ? ( + + ) : null} +
+ ) : null} + {hooks.map((hook) => ( +
+ + + {t( + hook.enabled ? "database.ruleEnabled" : "database.rulePaused", + )} + + {canManage ? ( + + void setHookEnabled(hook, enabled) + } + /> + ) : null} +
+ ))} +
+ ) : ( +
+ {t("database.noNotificationRules")} +
+ )} + + { + if (!open) setDraft(null); + }} + > + + + + {draft?.hookId ? draft.name : t("database.addNotificationRule")} + + + {t("database.notificationRulesDescription")} + + + {draft ? ( +
+
+ + + setDraft((current) => + current + ? { ...current, name: event.target.value } + : current, + ) + } + /> +
+ +
+ + + {draftTriggerAvailability?.available === false && + draftTriggerAvailability.reason ? ( +

+ {draftTriggerAvailability.reason} +

+ ) : null} +
+ + {draft.triggerKind === "property_changed" ? ( +
+
+ + +
+ + {stableOptions.length ? ( + <> +
+ + +
+ + {showPreviousValue ? ( +
+ + +
+ ) : null} + + ) : null} +
+ ) : null} + + {draft.triggerKind === "builder_publication_confirmed" ? ( +
+ + +

+ {t("database.builderConfirmationDescription")} +

+
+ ) : null} + +
+
+ + {!draft.conditions ? ( + + ) : ( + + )} +
+ {draft.conditions ? ( +
+ + {draft.conditions.clauses.map((condition, index) => ( +
+ + + + {"value" in condition ? ( + { + const raw = event.target.value; + let value: unknown = raw; + try { + value = JSON.parse(raw); + } catch { + value = raw; + } + setDraft((current) => + current?.conditions + ? { + ...current, + conditions: { + ...current.conditions, + clauses: current.conditions.clauses.map( + (candidate, at) => + at === index && "value" in candidate + ? { ...candidate, value } + : candidate, + ), + }, + } + : current, + ); + }} + /> + ) : null} +
+ ))} + +
+ ) : null} +
+ +
+ + {t("database.hookTiming")} + +
+ + {draft.timing.kind !== "immediate" ? ( +
+ + { + const delayMinutes = Math.min( + Math.max(Number(event.target.value) || 1, 1), + 10080, + ); + setDraft((current) => + current && current.timing.kind !== "immediate" + ? { + ...current, + timing: { ...current.timing, delayMinutes }, + } + : current, + ); + }} + /> +

+ {t( + draft.timing.kind === "delayed" + ? "database.timingDelayedDescription" + : draft.timing.kind === "debounced" + ? "database.timingDebouncedDescription" + : "database.timingEscalationDescription", + )} +

+
+ ) : null} +
+
+ +
+
+ + +
+ {draft.effects.map((effect, index) => ( +
+
+ + {draft.effects.length > 1 ? ( + + ) : null} +
+ + {effect.kind === "notify" ? ( +
+ + + {!personProperties.length ? ( +

+ {t("database.personPropertyRequired")} +

+ ) : null} +
+ ) : effect.kind === "team_slack" ? ( +
+ + + setDraft((current) => + current + ? { + ...current, + effects: current.effects.map( + (candidate, at) => + at === index && + candidate.kind === "team_slack" + ? { + ...candidate, + webhookKey: event.target.value, + } + : candidate, + ), + } + : current, + ) + } + /> +
+ ) : effect.kind === "webhook" ? ( +
+
+ + + setDraft((current) => + current + ? { + ...current, + effects: current.effects.map( + (candidate, at) => + at === index && + candidate.kind === "webhook" + ? { + ...candidate, + urlKey: event.target.value, + } + : candidate, + ), + } + : current, + ) + } + /> +
+
+ + + setDraft((current) => + current + ? { + ...current, + effects: current.effects.map( + (candidate, at) => + at === index && + candidate.kind === "webhook" + ? { + ...candidate, + signatureKey: + event.target.value, + } + : candidate, + ), + } + : current, + ) + } + /> +
+
+ ) : effect.kind === "set_property" ? ( +
+
+ + +
+
+ + { + const raw = event.target.value; + let value: unknown = raw; + try { + value = JSON.parse(raw); + } catch { + value = raw; + } + setDraft((current) => + current + ? { + ...current, + effects: current.effects.map( + (candidate, at) => + at === index && + candidate.kind === "set_property" + ? { ...candidate, value } + : candidate, + ), + } + : current, + ); + }} + /> +
+
+ ) : ( + assertNever(effect) + )} +
+ ))} +
+ +
+
+ +

+ {t("database.ruleEnabledDescription")} +

+
+ + setDraft((current) => + current ? { ...current, enabled } : current, + ) + } + /> +
+ +
+ {draft.hookId ? ( + + ) : ( + + )} +
+ + +
+
+
+ ) : null} +
+
+ +
+ + {t("database.latestHookExecutions")} + +
+ {executions.length ? ( + executions.map((execution) => { + return ( +
+
+
+ {execution.hookName ?? t("database.deletedRule")} +
+ {execution.error ? ( +
+ {execution.error} +
+ ) : null} + {execution.effects.map((effect) => { + const outcome = effect.result?.outcome; + if ( + outcome !== "cycle_detected" && + outcome !== "max_chain_depth_exceeded" + ) { + return null; + } + return ( +
+ {t( + outcome === "cycle_detected" + ? "database.hookCycleStopped" + : "database.hookDepthStopped", + )} +
+ ); + })} +
+ + {t(executionStatusKey(execution.status))} + +
+ ); + }) + ) : ( +

+ {t("database.noHookExecutions")} +

+ )} +
+
+ + + + + + {t("database.deleteNotificationRuleQuestion")} + + + {t("database.deleteNotificationRuleDescription")} + + + + {t("database.cancel")} + { + if (!draft?.hookId) return; + void deleteHook(draft.hookId); + }} + > + {t("database.deleteRule")} + + + + +
+ ); +} diff --git a/templates/content/app/components/editor/database/DatabaseOwnerNotificationPolicy.layout.test.ts b/templates/content/app/components/editor/database/DatabaseOwnerNotificationPolicy.layout.test.ts new file mode 100644 index 0000000000..c4e30051d9 --- /dev/null +++ b/templates/content/app/components/editor/database/DatabaseOwnerNotificationPolicy.layout.test.ts @@ -0,0 +1,75 @@ +import { readFileSync } from "node:fs"; + +import { describe, expect, it } from "vitest"; + +const databaseViewSource = readFileSync( + new URL("./DatabaseView.tsx", import.meta.url), + "utf8", +); +const hooksPanelSource = readFileSync( + new URL("./DatabaseHooksPanel.tsx", import.meta.url), + "utf8", +); +const policyActionSource = readFileSync( + new URL( + "../../../../actions/manage-content-database-policy.ts", + import.meta.url, + ), + "utf8", +); +const virtualRuleSource = readFileSync( + new URL( + "../../../../actions/_content-default-person-rule.ts", + import.meta.url, + ), + "utf8", +); +const propertyUtilsSource = readFileSync( + new URL("../../../../actions/_property-utils.ts", import.meta.url), + "utf8", +); + +describe("default Person notification owner policy", () => { + it("defaults the persisted database policy to enabled with an immutable version", () => { + expect(propertyUtilsSource).toContain( + "defaultPersonNotificationsEnabled: true", + ); + expect(propertyUtilsSource).toContain( + "defaultPersonNotificationsPolicyVersion: 1", + ); + expect(policyActionSource).toContain( + "defaultPersonNotificationsPolicyVersion: nextPolicyVersion", + ); + expect(policyActionSource).toContain("requireContentDatabaseOwner"); + }); + + it("shows a compact owner-only switch through the shared action surface", () => { + expect(hooksPanelSource).toContain("useManageContentDatabasePolicy()"); + expect(hooksPanelSource).toContain("{isOwner ? ("); + expect(hooksPanelSource).toContain( + "defaultPersonNotificationsEnabled: enabled", + ); + expect(hooksPanelSource).toContain( + 't("database.defaultPersonNotificationsDescription")', + ); + expect(databaseViewSource).toContain('onPanelChange("hooks")'); + }); + + it("resolves immutable policy history at each event sequence", () => { + expect(virtualRuleSource).toContain( + "schema.contentDatabasePolicies.activeAfterSequence", + ); + expect(virtualRuleSource).toContain("event.eventSequence"); + expect(virtualRuleSource).toContain("version: policy?.version ?? 1"); + expect(policyActionSource).toContain( + "allocateContentWorkflowEventSequence(tx)", + ); + expect(policyActionSource).toContain( + "tx.insert(schema.contentDatabasePolicies)", + ); + expect(virtualRuleSource).toContain('disabledReason: "owner_disabled"'); + expect(virtualRuleSource).toContain( + "eq(schema.contentDatabases.ownerEmail, event.ownerEmail)", + ); + }); +}); diff --git a/templates/content/app/components/editor/database/DatabaseValidationPanel.layout.test.ts b/templates/content/app/components/editor/database/DatabaseValidationPanel.layout.test.ts new file mode 100644 index 0000000000..aec2136f65 --- /dev/null +++ b/templates/content/app/components/editor/database/DatabaseValidationPanel.layout.test.ts @@ -0,0 +1,49 @@ +import { readFileSync } from "node:fs"; + +import { describe, expect, it } from "vitest"; + +const panelSource = readFileSync( + new URL("./DatabaseValidationPanel.tsx", import.meta.url), + "utf8", +); +const databaseViewSource = readFileSync( + new URL("./DatabaseView.tsx", import.meta.url), + "utf8", +); +const hookSource = readFileSync( + new URL("../../../hooks/use-content-database.ts", import.meta.url), + "utf8", +); + +describe("Content database validation settings", () => { + it("mounts validation in its own settings panel with the saved database config", () => { + expect(databaseViewSource).toContain(" { + expect(panelSource).toContain( + "useManageContentDatabaseValidation(databaseId)", + ); + expect(hookSource).toContain('"manage-content-database-validation"'); + expect(panelSource).not.toContain("fetch("); + }); + + it("keeps editing progressive and visible truth available to viewers", () => { + expect(panelSource).toContain("aria-expanded={expanded}"); + expect(panelSource).toContain("disabled={!canManage}"); + expect(panelSource).toContain("canManage && !gateDraft"); + expect(panelSource).toContain("draft.statusRequirements.map"); + expect(panelSource).toContain("draft.requiredForSubmission.includes"); + }); + + it("uses stable property and option IDs with shadcn controls", () => { + expect(panelSource).toContain("statusPropertyId"); + expect(panelSource).toContain("statusOptionId"); + expect(panelSource).toContain("requiredPropertyIds"); + expect(panelSource).toContain(" + ![ + "formula", + "rollup", + "id", + "created_time", + "created_by", + "last_edited_time", + "last_edited_by", + ].includes(property.definition.type), + ); +} + +function toggleId(values: string[], id: string, checked: boolean) { + return checked + ? values.includes(id) + ? values + : [...values, id] + : values.filter((value) => value !== id); +} + +export function DatabaseValidationPanel({ + databaseId, + properties, + validation, + canManage, +}: { + databaseId: string; + properties: DocumentProperty[]; + validation?: ContentDatabaseValidationConfig; + canManage: boolean; +}) { + const t = useT(); + const manageValidation = useManageContentDatabaseValidation(databaseId); + const normalized = validation ?? EMPTY_VALIDATION; + const [expanded, setExpanded] = useState(false); + const [draft, setDraft] = useState(normalized); + const [dirty, setDirty] = useState(false); + const [gateDraft, setGateDraft] = useState(null); + const availableProperties = useMemo( + () => editableProperties(properties), + [properties], + ); + const statusProperties = useMemo( + () => + availableProperties.filter( + (property) => property.definition.type === "status", + ), + [availableProperties], + ); + const propertyById = useMemo( + () => + new Map(properties.map((property) => [property.definition.id, property])), + [properties], + ); + + useEffect(() => { + if (!dirty) setDraft(normalized); + }, [dirty, validation]); + + const beginGate = ( + requirement?: ContentDatabaseStatusRequirement, + index: number | null = null, + ) => { + const statusPropertyId = + requirement?.statusPropertyId ?? statusProperties[0]?.definition.id ?? ""; + const statusProperty = propertyById.get(statusPropertyId); + setGateDraft({ + index, + statusPropertyId, + statusOptionId: + requirement?.statusOptionId ?? + statusProperty?.definition.options.options?.[0]?.id ?? + "", + requiredPropertyIds: requirement?.requiredPropertyIds ?? [], + }); + }; + + const applyGateDraft = () => { + if ( + !gateDraft?.statusPropertyId || + !gateDraft.statusOptionId || + gateDraft.requiredPropertyIds.length === 0 + ) { + return; + } + const requirement: ContentDatabaseStatusRequirement = { + statusPropertyId: gateDraft.statusPropertyId, + statusOptionId: gateDraft.statusOptionId, + requiredPropertyIds: gateDraft.requiredPropertyIds, + }; + setDraft((current) => ({ + ...current, + statusRequirements: + gateDraft.index === null + ? [...current.statusRequirements, requirement] + : current.statusRequirements.map((existing, index) => + index === gateDraft.index ? requirement : existing, + ), + })); + setDirty(true); + setGateDraft(null); + }; + + const removeGate = (index: number) => { + setDraft((current) => ({ + ...current, + statusRequirements: current.statusRequirements.filter( + (_, candidateIndex) => candidateIndex !== index, + ), + })); + setDirty(true); + setGateDraft(null); + }; + + const save = async () => { + try { + const result = await manageValidation.mutateAsync({ + databaseId, + validation: draft, + }); + setDraft(result.validation); + setDirty(false); + setGateDraft(null); + toast.success(t("database.readinessSaved")); + } catch (error) { + toast.error(t("database.readinessSaveFailed"), { + description: + error instanceof Error ? error.message : t("empty.genericError"), + }); + } + }; + + const summary = + draft.requiredForSubmission.length || draft.statusRequirements.length + ? [ + draft.requiredForSubmission.length + ? t("database.submissionRequirementsConfigured") + : null, + draft.statusRequirements.length + ? t("database.statusGatesConfigured") + : null, + ] + .filter(Boolean) + .join(" · ") + : t("database.noReadinessRequirements"); + + return ( +
+ + + {expanded ? ( +
+

+ {t("database.readinessDescription")} +

+ +
+
+

+ {t("database.submissionRequirements")} +

+

+ {t("database.submissionRequirementsDescription")} +

+
+
+ {availableProperties.map((property) => { + const id = property.definition.id; + const checked = draft.requiredForSubmission.includes(id); + return ( + + ); + })} +
+
+ +
+
+
+

+ {t("database.statusGates")} +

+

+ {t("database.statusGatesDescription")} +

+
+ {canManage && !gateDraft && statusProperties.length ? ( + + ) : null} +
+ + {draft.statusRequirements.length ? ( +
+ {draft.statusRequirements.map((requirement, index) => { + const statusProperty = propertyById.get( + requirement.statusPropertyId, + ); + const option = + statusProperty?.definition.options.options?.find( + (candidate) => + candidate.id === requirement.statusOptionId, + ); + const requiredNames = requirement.requiredPropertyIds + .map( + (propertyId) => + propertyById.get(propertyId)?.definition.name ?? + propertyId, + ) + .join(", "); + return ( + + ); + })} +
+ ) : !gateDraft ? ( +

+ {t("database.noStatusGates")} +

+ ) : null} + + {gateDraft ? ( +
+
+ + +
+ +
+ + +
+ +
+ + {availableProperties + .filter( + (property) => + property.definition.id !== gateDraft.statusPropertyId, + ) + .map((property) => { + const id = property.definition.id; + return ( + + ); + })} +
+ +
+ {gateDraft.index !== null ? ( + + ) : ( + + )} +
+ + +
+
+
+ ) : null} +
+ + {canManage ? ( +
+ +
+ ) : null} +
+ ) : null} +
+ ); +} diff --git a/templates/content/app/components/editor/database/DatabaseView.error-toasts.test.tsx b/templates/content/app/components/editor/database/DatabaseView.error-toasts.test.tsx index aee251f042..04cda0946e 100644 --- a/templates/content/app/components/editor/database/DatabaseView.error-toasts.test.tsx +++ b/templates/content/app/components/editor/database/DatabaseView.error-toasts.test.tsx @@ -158,6 +158,7 @@ vi.mock("@/hooks/use-content-database", () => ({ useDuplicateDatabaseItems: () => benignMutation, useMoveDatabaseItem: () => benignMutation, useBuilderCmsModels: () => builderCmsModelsQuery, + useManageContentDatabasePolicy: () => benignMutation, })); vi.mock("@/hooks/use-document-properties", () => ({ diff --git a/templates/content/app/components/editor/database/DatabaseView.tsx b/templates/content/app/components/editor/database/DatabaseView.tsx index 36f68b6ae3..7c52916297 100644 --- a/templates/content/app/components/editor/database/DatabaseView.tsx +++ b/templates/content/app/components/editor/database/DatabaseView.tsx @@ -43,6 +43,7 @@ import { SheetTitle, } from "@agent-native/toolkit/ui/sheet"; import { Spinner } from "@agent-native/toolkit/ui/spinner"; +import { Switch } from "@agent-native/toolkit/ui/switch"; import { Tooltip, TooltipContent, @@ -62,6 +63,7 @@ import { type ContentDatabasePersonalViewOverrides, type ContentDatabaseView, type ContentDatabaseViewConfig, + type ContentDatabaseValidationConfig, type ContentDatabaseColumnCalculation, type ContentDatabaseFilter, type ContentDatabaseFilterMode, @@ -96,6 +98,8 @@ import { IconArrowRight, IconArrowUp, IconAdjustmentsHorizontal, + IconBell, + IconBellOff, IconArrowsDiagonal, IconArrowsSort, IconCalendar, @@ -126,6 +130,7 @@ import { IconPencil, IconRefresh, IconSearch, + IconShieldCheck, IconTable, IconTimeline, IconTrash, @@ -165,6 +170,9 @@ import { useExecuteBuilderSourceExecution, useMoveDatabaseItem, useMaterializeBuilderRequiredFields, + useManageContentDatabasePolicy, + useContentNotificationPreference, + useManageContentNotificationPreference, useNotionDatabaseSources, usePrepareBuilderSourceReview, usePreviewBuilderSourceReview, @@ -246,6 +254,8 @@ import { releasePreviewDocumentSaveController, } from "../previewDocumentSaveRegistry"; import { VisualEditor } from "../VisualEditor"; +import { DatabaseHooksPanel } from "./DatabaseHooksPanel"; +import { DatabaseValidationPanel } from "./DatabaseValidationPanel"; import { DatabaseFormView } from "./FormView"; import { DatabaseGalleryView } from "./GalleryView"; import { DatabaseListView } from "./ListView"; @@ -2579,6 +2589,7 @@ function DatabaseTable({ /> ) : ( ( ( + ) : panel === "hooks" ? ( + + ) : panel === "validation" ? ( + + ) : panel === "permissions" ? ( + ) : panel === "source" ? ( void; }) { const groupLabel = activeView.groupByPropertyId ? "On" : ""; @@ -7223,6 +7296,34 @@ function DatabaseSettingsMainPanel({ />
+ {canManage ? ( + } + label={dbText("notificationsAndHooks")} + onClick={() => onPanelChange("hooks")} + /> + ) : null} + {canManage ? ( + } + label={dbText("readiness")} + value={ + validation?.requiredForSubmission.length || + validation?.statusRequirements.length + ? dbText("submissionRequirementsConfigured") + : "" + } + onClick={() => onPanelChange("validation")} + /> + ) : null} + {isOwner ? ( + } + label={dbText("lockDatabaseSchema")} + value={schemaLocked ? dbText("databaseSchemaLocked") : ""} + onClick={() => onPanelChange("permissions")} + /> + ) : null} } label="Sources" @@ -7253,6 +7354,59 @@ function DatabaseSettingsMainPanel({ ); } +function DatabaseSchemaPermissionsPanel({ + databaseId, + schemaLocked, + isOwner, +}: { + databaseId: string; + schemaLocked: boolean; + isOwner: boolean; +}) { + const managePolicy = useManageContentDatabasePolicy(); + + return ( +
+
+ +

+ {dbText("lockDatabaseSchemaDescription")} +

+
+ + managePolicy.mutate( + { databaseId, schemaLocked: nextSchemaLocked }, + { + onSuccess: () => + toast.success( + dbText( + nextSchemaLocked + ? "databaseSchemaLocked" + : "databaseSchemaUnlocked", + ), + ), + onError: (error) => + toast.error(dbText("databaseSchemaLockFailed"), { + description: + error instanceof Error ? error.message : undefined, + }), + }, + ) + } + /> +
+ ); +} + export function builderReviewableChangeSets( source: ContentDatabaseSource | null, ) { @@ -17618,6 +17772,7 @@ export function databaseGroupIsCollapsed( } function DatabaseGroupedTableSection({ + databaseId, group, properties, columnWidths, @@ -17638,6 +17793,7 @@ function DatabaseGroupedTableSection({ onDeletedPreviewItem, onOpenPage, }: { + databaseId: string; group: DatabaseBoardGroup; properties: DocumentProperty[]; columnWidths: Record; @@ -17675,6 +17831,7 @@ function DatabaseGroupedTableSection({ <> {group.items.map((item, index) => ( ; @@ -17887,6 +18046,7 @@ function DatabaseTableRow({ })} {canEdit ? ( {dbText("duplicateRow")} + {databaseId ? ( + { + event.preventDefault(); + const enabled = preference.data?.preference.enabled ?? true; + managePreference.mutate({ + action: "set", + target: { + scope: "item", + databaseId, + documentId: item.document.id, + }, + enabled: !enabled, + }); + setMenuOpen(false); + }} + > + {preference.data?.preference.enabled === false ? ( + + ) : ( + + )} + {dbText( + preference.data?.preference.enabled === false + ? "subscribeToItem" + : "unsubscribeFromItem", + )} + + ) : null}
{actions} +
diff --git a/templates/content/app/database-policy-i18n.ts b/templates/content/app/database-policy-i18n.ts new file mode 100644 index 0000000000..861310001c --- /dev/null +++ b/templates/content/app/database-policy-i18n.ts @@ -0,0 +1,160 @@ +import type { LocaleCode } from "@agent-native/core/client"; + +const keys = [ + "lockDatabaseSchema", + "lockDatabaseSchemaDescription", + "databaseSchemaLocked", + "databaseSchemaUnlocked", + "databaseSchemaLockFailed", + "defaultPersonNotifications", + "defaultPersonNotificationsDescription", + "defaultPersonNotificationsEnabled", + "defaultPersonNotificationsDisabled", + "defaultPersonNotificationsFailed", +] as const; + +function messages(values: readonly string[]) { + return Object.fromEntries( + keys.map((key, index) => [key, values[index]!]), + ) as { + [K in (typeof keys)[number]]: string; + }; +} + +export const databasePolicyMessagesByLocale: Record< + LocaleCode, + ReturnType +> = { + "en-US": messages([ + "Lock database schema", + "Only the owner can change properties, validation, or hooks while locked.", + "Database schema locked", + "Database schema unlocked", + "Database lock could not be changed", + "Notify people added to a Person property", + "When someone is added to a Person property, notify that person.", + "Default Person notifications enabled", + "Default Person notifications disabled", + "Default Person notifications could not be changed", + ]), + "zh-TW": messages([ + "鎖定資料庫結構", + "鎖定後,只有擁有者可以變更屬性、驗證或鉤子。", + "資料庫結構已鎖定", + "資料庫結構已解鎖", + "無法變更資料庫鎖定", + "新增人員時通知", + "資料庫規則預設會通知新加入的人員;個人仍可取消訂閱。", + "已啟用預設人員通知", + "已停用預設人員通知", + "無法變更預設人員通知", + ]), + "zh-CN": messages([ + "锁定数据库结构", + "锁定后,只有所有者可以更改属性、验证或钩子。", + "数据库结构已锁定", + "数据库结构已解锁", + "无法更改数据库锁定", + "添加人员时通知", + "数据库规则默认通知新添加的人员;个人仍可取消订阅。", + "已启用默认人员通知", + "已停用默认人员通知", + "无法更改默认人员通知", + ]), + "es-ES": messages([ + "Bloquear esquema", + "Solo el propietario puede cambiar propiedades, validación o hooks mientras esté bloqueado.", + "Esquema bloqueado", + "Esquema desbloqueado", + "No se pudo cambiar el bloqueo", + "Notificar al añadir personas", + "La regla de la base notifica por defecto a las personas recién añadidas. Cada persona puede darse de baja.", + "Notificaciones predeterminadas activadas", + "Notificaciones predeterminadas desactivadas", + "No se pudieron cambiar las notificaciones predeterminadas", + ]), + "fr-FR": messages([ + "Verrouiller le schéma", + "Seul le propriétaire peut modifier les propriétés, la validation ou les hooks pendant le verrouillage.", + "Schéma verrouillé", + "Schéma déverrouillé", + "Impossible de modifier le verrouillage", + "Notifier les personnes ajoutées", + "La règle de la base avertit par défaut les personnes ajoutées. Chacun peut toujours se désabonner.", + "Notifications par défaut activées", + "Notifications par défaut désactivées", + "Impossible de modifier les notifications par défaut", + ]), + "de-DE": messages([ + "Datenbankschema sperren", + "Nur der Eigentümer kann bei einer Sperre Eigenschaften, Validierung oder Hooks ändern.", + "Schema gesperrt", + "Schema entsperrt", + "Sperre konnte nicht geändert werden", + "Hinzugefügte Personen benachrichtigen", + "Die Datenbankregel benachrichtigt neue Personen standardmäßig. Einzelne Personen können sich abmelden.", + "Standardbenachrichtigungen aktiviert", + "Standardbenachrichtigungen deaktiviert", + "Standardbenachrichtigungen konnten nicht geändert werden", + ]), + "ja-JP": messages([ + "データベーススキーマをロック", + "ロック中は所有者だけがプロパティ、検証、フックを変更できます。", + "スキーマをロックしました", + "スキーマのロックを解除しました", + "ロックを変更できませんでした", + "追加された人に通知", + "データベースルールは追加された人に既定で通知します。個人は引き続き解除できます。", + "既定の通知を有効にしました", + "既定の通知を無効にしました", + "既定の通知を変更できませんでした", + ]), + "ko-KR": messages([ + "데이터베이스 스키마 잠금", + "잠긴 동안에는 소유자만 속성, 유효성 검사 또는 후크를 변경할 수 있습니다.", + "스키마 잠김", + "스키마 잠금 해제", + "잠금을 변경하지 못했습니다", + "추가된 사람에게 알림", + "데이터베이스 규칙은 새로 추가된 사람에게 기본 알림을 보냅니다. 개인은 구독을 해제할 수 있습니다.", + "기본 알림 활성화됨", + "기본 알림 비활성화됨", + "기본 알림을 변경하지 못했습니다", + ]), + "pt-BR": messages([ + "Bloquear esquema", + "Somente o proprietário pode alterar propriedades, validação ou hooks enquanto estiver bloqueado.", + "Esquema bloqueado", + "Esquema desbloqueado", + "Não foi possível alterar o bloqueio", + "Notificar pessoas adicionadas", + "A regra do banco notifica novas pessoas por padrão. Cada pessoa ainda pode cancelar a inscrição.", + "Notificações padrão ativadas", + "Notificações padrão desativadas", + "Não foi possível alterar as notificações padrão", + ]), + "hi-IN": messages([ + "डेटाबेस स्कीमा लॉक करें", + "लॉक होने पर केवल मालिक गुण, सत्यापन या हुक बदल सकता है।", + "स्कीमा लॉक हुआ", + "स्कीमा अनलॉक हुआ", + "लॉक बदला नहीं जा सका", + "जोड़े गए लोगों को सूचित करें", + "डेटाबेस नियम नए लोगों को डिफ़ॉल्ट रूप से सूचित करता है। व्यक्ति सदस्यता छोड़ सकते हैं।", + "डिफ़ॉल्ट सूचनाएँ चालू हैं", + "डिफ़ॉल्ट सूचनाएँ बंद हैं", + "डिफ़ॉल्ट सूचनाएँ बदली नहीं जा सकीं", + ]), + "ar-SA": messages([ + "قفل مخطط قاعدة البيانات", + "لا يمكن أثناء القفل تغيير الخصائص أو التحقق أو الخطافات إلا بواسطة المالك.", + "تم قفل المخطط", + "تم إلغاء قفل المخطط", + "تعذر تغيير القفل", + "إشعار الأشخاص عند إضافتهم", + "تُشعر قاعدة البيانات الأشخاص المضافين افتراضيًا، ويمكن لكل شخص إلغاء الاشتراك.", + "تم تفعيل الإشعارات الافتراضية", + "تم تعطيل الإشعارات الافتراضية", + "تعذر تغيير الإشعارات الافتراضية", + ]), +}; diff --git a/templates/content/app/hook-i18n.ts b/templates/content/app/hook-i18n.ts new file mode 100644 index 0000000000..b83d0933ee --- /dev/null +++ b/templates/content/app/hook-i18n.ts @@ -0,0 +1,1273 @@ +const keys = [ + "notificationsAndHooks", + "notificationRulesDescription", + "addNotificationRule", + "noNotificationRules", + "whenItemCreated", + "whenPropertyChanges", + "ruleEnabled", + "rulePaused", + "ruleName", + "ruleNamePlaceholder", + "when", + "anItemIsCreated", + "aPropertyChanges", + "property", + "chooseAProperty", + "resultingOption", + "anyOption", + "previousOptionCondition", + "previousOption", + "notifyPeopleIn", + "choosePersonProperty", + "personPropertyRequired", + "ruleEnabledDescription", + "saveRule", + "deleteRule", + "deleteNotificationRuleQuestion", + "deleteNotificationRuleDescription", + "notificationRuleCreated", + "notificationRuleUpdated", + "notificationRuleDeleted", + "notificationRuleSaveFailed", + "notificationRuleDeleteFailed", + "latestHookExecutions", + "noHookExecutions", + "deletedRule", + "executionPending", + "executionRunning", + "executionSucceeded", + "executionFailed", + "executionRetrying", + "executionUnknown", +] as const; + +function messages(values: readonly string[]) { + return Object.fromEntries( + keys.map((key, index) => [key, values[index]!]), + ) as { + [K in (typeof keys)[number]]: string; + }; +} + +const baseHookMessagesByLocale = { + "zh-TW": messages([ + "通知和鉤子", + "當資料庫項目被建立或達到有意義的狀態時發送通知。", + "新增規則", + "尚無通知規則。", + "當項目被建立時", + "當屬性更改時", + "已啟用", + "已暫停", + "規則名稱", + "準備審核", + "當", + "項目被建立", + "屬性更改", + "屬性", + "選擇屬性", + "結果選項", + "任何選項", + "新增先前值條件", + "先前選項", + "通知此屬性中的人員", + "選擇 Person 屬性", + "在建立通知規則前,請先新增 Person 屬性至此資料庫。", + "當相符的變更被提交時執行此規則。", + "儲存規則", + "刪除規則", + "刪除此通知規則?", + "未來相符的變更將不再通知任何人。現有執行歷史將保留。", + "通知規則已建立", + "通知規則已更新", + "通知規則已刪除", + "無法儲存通知規則", + "無法刪除通知規則", + "最新執行", + "此資料庫還沒有任何執行記錄。", + "已刪除規則", + "待處理", + "執行中", + "成功", + "失敗", + "重試中", + "未知", + ]), + "zh-CN": messages([ + "通知和钩子", + "当数据库项目被创建或达到有意义的状态时发送通知。", + "添加规则", + "尚无通知规则。", + "当项目被创建时", + "当属性更改时", + "已启用", + "已暂停", + "规则名称", + "准备审核", + "当", + "项目被创建", + "属性更改", + "属性", + "选择属性", + "结果选项", + "任何选项", + "添加先前值条件", + "先前选项", + "通知此属性中的人员", + "选择 Person 属性", + "在创建通知规则前,请先在此数据库中添加 Person 属性。", + "当匹配的更改被提交时运行此规则。", + "保存规则", + "删除规则", + "删除此通知规则?", + "未来匹配的更改将不再通知任何人。保留现有运行历史。", + "通知规则已创建", + "通知规则已更新", + "通知规则已删除", + "无法保存通知规则", + "无法删除通知规则", + "最新运行", + "此数据库还没有任何运行记录。", + "已删除规则", + "待处理", + "运行中", + "成功", + "失败", + "重试中", + "未知", + ]), + "es-ES": messages([ + "Notificaciones y hooks", + "Envía una notificación cuando se crea un elemento de la base de datos o alcanza un estado significativo.", + "Añadir regla", + "Aún no hay reglas de notificación.", + "Cuando se crea un elemento", + "Cuando cambia una propiedad", + "Activada", + "Pausada", + "Nombre de la regla", + "Lista para revisión", + "Cuando", + "Se crea un elemento", + "Cambia una propiedad", + "Propiedad", + "Elegir una propiedad", + "Opción resultante", + "Cualquier opción", + "Añadir una condición de valor anterior", + "Opción anterior", + "Notificar a las personas en", + "Elegir una propiedad Persona", + "Añade una propiedad Persona a esta base de datos antes de crear una regla de notificación.", + "Ejecuta esta regla cuando se confirmen cambios coincidentes.", + "Guardar regla", + "Eliminar regla", + "¿Eliminar esta regla de notificación?", + "Los cambios coincidentes futuros ya no notificarán a nadie. Se conserva el historial de ejecuciones.", + "Regla de notificación creada", + "Regla de notificación actualizada", + "Regla de notificación eliminada", + "No se pudo guardar la regla de notificación", + "No se pudo eliminar la regla de notificación", + "Últimas ejecuciones", + "Esta base de datos aún no tiene ejecuciones de hooks.", + "Regla eliminada", + "Pendiente", + "En ejecución", + "Correcta", + "Fallida", + "Reintentando", + "Desconocida", + ]), + "fr-FR": messages([ + "Notifications et hooks", + "Envoyer une notification lorsqu’un élément de la base de données est créé ou atteint un état significatif.", + "Ajouter une règle", + "Aucune règle de notification pour le moment.", + "Quand un élément est créé", + "Quand une propriété change", + "Activée", + "En pause", + "Nom de la règle", + "Prêt pour examen", + "Quand", + "Un élément est créé", + "Une propriété change", + "Propriété", + "Choisir une propriété", + "Option résultante", + "N’importe quelle option", + "Ajouter une condition de valeur antérieure", + "Option précédente", + "Notifier les personnes dans", + "Choisir une propriété Personne", + "Ajoutez une propriété Personne à cette base de données avant de créer une règle de notification.", + "Exécuter cette règle lorsque des modifications correspondantes sont validées.", + "Enregistrer la règle", + "Supprimer la règle", + "Supprimer cette règle de notification ?", + "Les modifications correspondantes futures n’avertiront plus personne. L’historique existant est conservé.", + "Règle de notification créée", + "Règle de notification mise à jour", + "Règle de notification supprimée", + "Impossible d’enregistrer la règle de notification", + "Impossible de supprimer la règle de notification", + "Dernières exécutions", + "Cette base de données n’a pas encore d’exécutions de hooks.", + "Règle supprimée", + "En attente", + "En cours", + "Réussie", + "Échouée", + "Nouvelle tentative", + "Inconnue", + ]), + "de-DE": messages([ + "Benachrichtigungen und Hooks", + "Eine Benachrichtigung senden, wenn ein Datenbankeintrag erstellt wird oder einen wichtigen Zustand erreicht.", + "Regel hinzufügen", + "Noch keine Benachrichtigungsregeln.", + "Wenn ein Eintrag erstellt wird", + "Wenn sich eine Eigenschaft ändert", + "Aktiviert", + "Pausiert", + "Regelname", + "Bereit zur Überprüfung", + "Wenn", + "Ein Eintrag wird erstellt", + "Eine Eigenschaft ändert sich", + "Eigenschaft", + "Eigenschaft auswählen", + "Resultierende Option", + "Beliebige Option", + "Bedingung für vorherigen Wert hinzufügen", + "Vorherige Option", + "Personen benachrichtigen in", + "Person-Eigenschaft auswählen", + "Fügen Sie dieser Datenbank vor dem Erstellen einer Benachrichtigungsregel eine Person-Eigenschaft hinzu.", + "Diese Regel ausführen, wenn passende Änderungen übernommen werden.", + "Regel speichern", + "Regel löschen", + "Diese Benachrichtigungsregel löschen?", + "Zukünftig passende Änderungen benachrichtigen niemanden mehr. Der vorhandene Verlauf bleibt erhalten.", + "Benachrichtigungsregel erstellt", + "Benachrichtigungsregel aktualisiert", + "Benachrichtigungsregel gelöscht", + "Benachrichtigungsregel konnte nicht gespeichert werden", + "Benachrichtigungsregel konnte nicht gelöscht werden", + "Letzte Ausführungen", + "Diese Datenbank hat noch keine Hook-Ausführungen.", + "Gelöschte Regel", + "Ausstehend", + "Wird ausgeführt", + "Erfolgreich", + "Fehlgeschlagen", + "Wird wiederholt", + "Unbekannt", + ]), + "ja-JP": messages([ + "通知とフック", + "データベース項目が作成されるか、意味のある状態に達したときに通知を送信します。", + "ルールを追加", + "通知ルールはまだありません。", + "項目が作成されたとき", + "プロパティが変更されたとき", + "有効", + "一時停止", + "ルール名", + "レビュー準備完了", + "条件", + "項目が作成される", + "プロパティが変更される", + "プロパティ", + "プロパティを選択", + "変更後のオプション", + "任意のオプション", + "以前の値の条件を追加", + "以前のオプション", + "通知する対象", + "Person プロパティを選択", + "通知ルールを作成する前に、このデータベースに Person プロパティを追加してください。", + "一致する変更が確定したときにこのルールを実行します。", + "ルールを保存", + "ルールを削除", + "この通知ルールを削除しますか?", + "今後、一致する変更は誰にも通知されません。既存の実行履歴は保持されます。", + "通知ルールが作成されました", + "通知ルールが更新されました", + "通知ルールが削除されました", + "通知ルールを保存できませんでした", + "通知ルールを削除できませんでした", + "最新の実行", + "このデータベースにはまだフックの実行履歴がありません。", + "削除されたルール", + "保留中", + "実行中", + "成功", + "失敗", + "再試行中", + "不明", + ]), + "ko-KR": messages([ + "알림 및 훅", + "데이터베이스 항목이 생성되거나 의미 있는 상태에 도달할 때 알림을 보냅니다.", + "규칙 추가", + "아직 알림 규칙이 없습니다.", + "항목이 생성될 때", + "속성이 변경될 때", + "활성화됨", + "일시 중지됨", + "규칙 이름", + "검토 준비 완료", + "조건", + "항목이 생성됨", + "속성이 변경됨", + "속성", + "속성 선택", + "결과 옵션", + "모든 옵션", + "이전 값 조건 추가", + "이전 옵션", + "다음 속성의 사람에게 알림", + "Person 속성 선택", + "알림 규칙을 만들기 전에 이 데이터베이스에 Person 속성을 추가하세요.", + "일치하는 변경 사항이 커밋될 때 이 규칙을 실행합니다.", + "규칙 저장", + "규칙 삭제", + "이 알림 규칙을 삭제하시겠습니까?", + "앞으로 일치하는 변경 사항은 더 이상 누구에게도 알리지 않습니다. 기존 실행 기록은 유지됩니다.", + "알림 규칙이 생성되었습니다", + "알림 규칙이 업데이트되었습니다", + "알림 규칙이 삭제되었습니다", + "알림 규칙을 저장할 수 없습니다", + "알림 규칙을 삭제할 수 없습니다", + "최신 실행", + "이 데이터베이스에는 아직 훅 실행 기록이 없습니다.", + "삭제된 규칙", + "대기 중", + "실행 중", + "성공", + "실패", + "재시도 중", + "알 수 없음", + ]), + "pt-BR": messages([ + "Notificações e hooks", + "Envie uma notificação quando um item do banco de dados for criado ou atingir um estado significativo.", + "Adicionar regra", + "Ainda não há regras de notificação.", + "Quando um item é criado", + "Quando uma propriedade muda", + "Ativada", + "Pausada", + "Nome da regra", + "Pronto para revisão", + "Quando", + "Um item é criado", + "Uma propriedade muda", + "Propriedade", + "Escolher uma propriedade", + "Opção resultante", + "Qualquer opção", + "Adicionar condição de valor anterior", + "Opção anterior", + "Notificar pessoas em", + "Escolher uma propriedade Pessoa", + "Adicione uma propriedade Pessoa a este banco de dados antes de criar uma regra de notificação.", + "Execute esta regra quando as alterações correspondentes forem confirmadas.", + "Salvar regra", + "Excluir regra", + "Excluir esta regra de notificação?", + "Alterações correspondentes futuras não notificarão mais ninguém. O histórico existente será mantido.", + "Regra de notificação criada", + "Regra de notificação atualizada", + "Regra de notificação excluída", + "Não foi possível salvar a regra de notificação", + "Não foi possível excluir a regra de notificação", + "Execuções mais recentes", + "Este banco de dados ainda não tem execuções de hooks.", + "Regra excluída", + "Pendente", + "Em execução", + "Bem-sucedida", + "Falhou", + "Tentando novamente", + "Desconhecida", + ]), + "hi-IN": messages([ + "सूचनाएं और हुक", + "जब डेटाबेस आइटम बनाया जाए या किसी महत्वपूर्ण स्थिति तक पहुंचे तो सूचना भेजें।", + "नियम जोड़ें", + "अभी तक कोई सूचना नियम नहीं है।", + "जब कोई आइटम बनाया जाए", + "जब कोई प्रॉपर्टी बदले", + "सक्षम", + "रोका गया", + "नियम का नाम", + "समीक्षा के लिए तैयार", + "जब", + "एक आइटम बनाया जाए", + "एक प्रॉपर्टी बदले", + "प्रॉपर्टी", + "प्रॉपर्टी चुनें", + "परिणामी विकल्प", + "कोई भी विकल्प", + "पिछले मान की शर्त जोड़ें", + "पिछला विकल्प", + "इसमें लोगों को सूचित करें", + "Person प्रॉपर्टी चुनें", + "सूचना नियम बनाने से पहले इस डेटाबेस में Person प्रॉपर्टी जोड़ें।", + "मेल खाने वाले बदलाव दर्ज होने पर यह नियम चलाएं।", + "नियम सहेजें", + "नियम हटाएं", + "यह सूचना नियम हटाएं?", + "भविष्य के मेल खाने वाले बदलाव अब किसी को सूचित नहीं करेंगे। मौजूदा रन इतिहास रखा जाएगा।", + "सूचना नियम बनाया गया", + "सूचना नियम अपडेट किया गया", + "सूचना नियम हटाया गया", + "सूचना नियम सहेजा नहीं जा सका", + "सूचना नियम हटाया नहीं जा सका", + "नवीनतम रन", + "इस डेटाबेस में अभी तक कोई हुक रन नहीं है।", + "हटाया गया नियम", + "लंबित", + "चल रहा है", + "सफल", + "विफल", + "फिर से प्रयास जारी", + "अज्ञात", + ]), + "ar-SA": messages([ + "الإشعارات والخطافات", + "إرسال إشعار عند إنشاء عنصر في قاعدة البيانات أو وصوله إلى حالة مهمة.", + "إضافة قاعدة", + "لا توجد قواعد إشعارات حتى الآن.", + "عند إنشاء عنصر", + "عند تغيير خاصية", + "مفعّلة", + "متوقفة مؤقتاً", + "اسم القاعدة", + "جاهز للمراجعة", + "عندما", + "يتم إنشاء عنصر", + "تتغير خاصية", + "خاصية", + "اختر خاصية", + "الخيار الناتج", + "أي خيار", + "إضافة شرط للقيمة السابقة", + "الخيار السابق", + "إخطار الأشخاص في", + "اختر خاصية Person", + "أضف خاصية Person إلى قاعدة البيانات هذه قبل إنشاء قاعدة إشعارات.", + "تشغيل هذه القاعدة عند تثبيت التغييرات المطابقة.", + "حفظ القاعدة", + "حذف القاعدة", + "حذف قاعدة الإشعارات هذه؟", + "لن ترسل التغييرات المطابقة مستقبلاً إشعارات. سيُحتفظ بسجل التشغيل الحالي.", + "تم إنشاء قاعدة الإشعارات", + "تم تحديث قاعدة الإشعارات", + "تم حذف قاعدة الإشعارات", + "تعذر حفظ قاعدة الإشعارات", + "تعذر حذف قاعدة الإشعارات", + "آخر عمليات التشغيل", + "لا تحتوي قاعدة البيانات هذه على عمليات تشغيل للخطافات بعد.", + "القاعدة المحذوفة", + "قيد الانتظار", + "قيد التشغيل", + "نجحت", + "فشلت", + "إعادة المحاولة", + "غير معروفة", + ]), +} as const; + +const externalEffectMessagesByLocale = { + "zh-TW": [ + "效果", + "新增效果", + "個人通知", + "團隊 Slack 公告", + "已簽署的 Webhook", + "移除效果", + "Slack Webhook 密鑰名稱", + "Webhook URL 密鑰名稱", + "Webhook 簽署密鑰名稱", + "個人資料庫通知", + "接收此資料庫的通知,除非規則或項目另有設定。", + "個人", + "個人 Content 通知", + "設定您在 Content 中的個人通知預設值。", + "將此項目的通知靜音", + "接收此項目的通知", + "已確認", + ], + "zh-CN": [ + "效果", + "添加效果", + "个人通知", + "团队 Slack 公告", + "已签名的 Webhook", + "移除效果", + "Slack Webhook 密钥名称", + "Webhook URL 密钥名称", + "Webhook 签名密钥名称", + "个人数据库通知", + "接收此数据库的通知,除非规则或项目另有设置。", + "个人", + "个人 Content 通知", + "设置您在 Content 中的个人通知默认值。", + "将此项目的通知静音", + "接收此项目的通知", + "已确认", + ], + "es-ES": [ + "Efectos", + "Añadir efecto", + "Notificación personal", + "Anuncio de Slack del equipo", + "Webhook firmado", + "Quitar efecto", + "Clave secreta del webhook de Slack", + "Clave secreta de la URL del webhook", + "Clave secreta de firma del webhook", + "Notificaciones personales de la base de datos", + "Recibe notificaciones de esta base de datos salvo que una regla o elemento indique lo contrario.", + "Personal", + "Notificaciones personales de Content", + "Define tu valor predeterminado para las notificaciones personales en Content.", + "Silenciar notificaciones de este elemento", + "Recibir notificaciones de este elemento", + "Confirmada", + ], + "fr-FR": [ + "Effets", + "Ajouter un effet", + "Notification personnelle", + "Annonce Slack d’équipe", + "Webhook signé", + "Supprimer l’effet", + "Clé secrète du webhook Slack", + "Clé secrète de l’URL du webhook", + "Clé secrète de signature du webhook", + "Notifications personnelles de la base", + "Recevez les notifications de cette base sauf si une règle ou un élément les remplace.", + "Personnel", + "Notifications Content personnelles", + "Définissez votre préférence par défaut pour les notifications personnelles dans Content.", + "Désactiver les notifications pour cet élément", + "Recevoir les notifications de cet élément", + "Confirmée", + ], + "de-DE": [ + "Effekte", + "Effekt hinzufügen", + "Persönliche Benachrichtigung", + "Team-Ankündigung in Slack", + "Signierter Webhook", + "Effekt entfernen", + "Geheimnisschlüssel für Slack-Webhook", + "Geheimnisschlüssel für Webhook-URL", + "Geheimnisschlüssel für Webhook-Signatur", + "Persönliche Datenbankbenachrichtigungen", + "Benachrichtigungen aus dieser Datenbank erhalten, sofern keine Regel oder kein Element dies überschreibt.", + "Persönlich", + "Persönliche Content-Benachrichtigungen", + "Legen Sie Ihren Standard für persönliche Benachrichtigungen in Content fest.", + "Benachrichtigungen für dieses Element stummschalten", + "Benachrichtigungen für dieses Element erhalten", + "Bestätigt", + ], + "ja-JP": [ + "エフェクト", + "エフェクトを追加", + "個人通知", + "チーム Slack のお知らせ", + "署名付き Webhook", + "エフェクトを削除", + "Slack Webhook シークレットキー", + "Webhook URL シークレットキー", + "Webhook 署名シークレットキー", + "個人データベース通知", + "ルールまたは項目で上書きされない限り、このデータベースから通知を受け取ります。", + "個人", + "個人 Content 通知", + "Content 全体の個人通知のデフォルトを設定します。", + "この項目の通知をミュート", + "この項目の通知を受け取る", + "確認済み", + ], + "ko-KR": [ + "효과", + "효과 추가", + "개인 알림", + "팀 Slack 공지", + "서명된 Webhook", + "효과 제거", + "Slack Webhook 비밀 키", + "Webhook URL 비밀 키", + "Webhook 서명 비밀 키", + "개인 데이터베이스 알림", + "규칙이나 항목에서 재정의하지 않는 한 이 데이터베이스의 알림을 받습니다.", + "개인", + "개인 Content 알림", + "Content 전체에서 개인 알림의 기본값을 설정합니다.", + "이 항목 알림 끄기", + "이 항목 알림 받기", + "확인됨", + ], + "pt-BR": [ + "Efeitos", + "Adicionar efeito", + "Notificação pessoal", + "Anúncio da equipe no Slack", + "Webhook assinado", + "Remover efeito", + "Chave secreta do webhook do Slack", + "Chave secreta da URL do webhook", + "Chave secreta de assinatura do webhook", + "Notificações pessoais do banco de dados", + "Receba notificações deste banco, a menos que uma regra ou item substitua a preferência.", + "Pessoal", + "Notificações pessoais do Content", + "Defina seu padrão para notificações pessoais em todo o Content.", + "Silenciar notificações deste item", + "Receber notificações deste item", + "Confirmada", + ], + "hi-IN": [ + "प्रभाव", + "प्रभाव जोड़ें", + "व्यक्तिगत सूचना", + "टीम Slack घोषणा", + "हस्ताक्षरित Webhook", + "प्रभाव हटाएँ", + "Slack Webhook गुप्त कुंजी", + "Webhook URL गुप्त कुंजी", + "Webhook हस्ताक्षर गुप्त कुंजी", + "व्यक्तिगत डेटाबेस सूचनाएँ", + "इस डेटाबेस की सूचनाएँ पाएँ, जब तक कोई नियम या आइटम इसे न बदले।", + "व्यक्तिगत", + "व्यक्तिगत Content सूचनाएँ", + "पूरे Content में व्यक्तिगत सूचनाओं के लिए अपना डिफ़ॉल्ट सेट करें।", + "इस आइटम की सूचनाएँ बंद करें", + "इस आइटम की सूचनाएँ पाएँ", + "स्वीकार किया गया", + ], + "ar-SA": [ + "التأثيرات", + "إضافة تأثير", + "إشعار شخصي", + "إعلان Slack للفريق", + "Webhook موقّع", + "إزالة التأثير", + "مفتاح سر Webhook لـ Slack", + "مفتاح سر عنوان Webhook", + "مفتاح سر توقيع Webhook", + "إشعارات قاعدة البيانات الشخصية", + "تلقي إشعارات هذه القاعدة ما لم تتجاوزها قاعدة أو عنصر.", + "شخصي", + "إشعارات Content الشخصية", + "تعيين الإعداد الافتراضي للإشعارات الشخصية في Content.", + "كتم إشعارات هذا العنصر", + "تلقي إشعارات هذا العنصر", + "تم الإقرار", + ], +} as const; + +const externalEffectKeys = [ + "effects", + "addEffect", + "personalNotification", + "teamSlackAnnouncement", + "signedWebhook", + "removeEffect", + "slackWebhookSecretKey", + "webhookUrlSecretKey", + "webhookSigningSecretKey", + "personalDatabaseNotifications", + "personalDatabaseNotificationsDescription", + "personal", + "personalContentNotifications", + "personalContentNotificationsDescription", + "unsubscribeFromItem", + "subscribeToItem", + "executionAcknowledged", +] as const; + +const mutationEffectKeys = [ + "setPropertyEffect", + "propertyToSet", + "propertyValue", + "propertyValuePlaceholder", + "hookCycleStopped", + "hookDepthStopped", +] as const; + +const mutationEffectMessagesByLocale = { + "zh-TW": [ + "設定屬性", + "要設定的屬性", + "屬性值", + "輸入文字或 JSON 值", + "已停止循環鉤子鏈", + "已在深度限制處停止鉤子鏈", + ], + "zh-CN": [ + "设置属性", + "要设置的属性", + "属性值", + "输入文本或 JSON 值", + "已停止循环钩子链", + "已在深度限制处停止钩子链", + ], + "es-ES": [ + "Establecer propiedad", + "Propiedad que se establecerá", + "Valor de la propiedad", + "Introduce texto o un valor JSON", + "Se detuvo un ciclo de hooks", + "La cadena de hooks se detuvo en el límite de profundidad", + ], + "fr-FR": [ + "Définir une propriété", + "Propriété à définir", + "Valeur de la propriété", + "Saisissez du texte ou une valeur JSON", + "Une boucle de hooks a été arrêtée", + "La chaîne de hooks a été arrêtée à la limite de profondeur", + ], + "de-DE": [ + "Eigenschaft festlegen", + "Festzulegende Eigenschaft", + "Eigenschaftswert", + "Text oder JSON-Wert eingeben", + "Eine Hook-Schleife wurde gestoppt", + "Die Hook-Kette wurde am Tiefenlimit gestoppt", + ], + "ja-JP": [ + "プロパティを設定", + "設定するプロパティ", + "プロパティ値", + "テキストまたは JSON 値を入力", + "フックの循環を停止しました", + "深さの上限でフックチェーンを停止しました", + ], + "ko-KR": [ + "속성 설정", + "설정할 속성", + "속성 값", + "텍스트 또는 JSON 값을 입력하세요", + "훅 순환을 중지했습니다", + "깊이 제한에서 훅 체인을 중지했습니다", + ], + "pt-BR": [ + "Definir propriedade", + "Propriedade a definir", + "Valor da propriedade", + "Insira texto ou um valor JSON", + "Um ciclo de hooks foi interrompido", + "A cadeia de hooks foi interrompida no limite de profundidade", + ], + "hi-IN": [ + "प्रॉपर्टी सेट करें", + "सेट की जाने वाली प्रॉपर्टी", + "प्रॉपर्टी मान", + "टेक्स्ट या JSON मान दर्ज करें", + "हुक चक्र रोक दिया गया", + "हुक श्रृंखला गहराई सीमा पर रोक दी गई", + ], + "ar-SA": [ + "تعيين خاصية", + "الخاصية المطلوب تعيينها", + "قيمة الخاصية", + "أدخل نصًا أو قيمة JSON", + "تم إيقاف دورة متكررة للخطافات", + "تم إيقاف سلسلة الخطافات عند حد العمق", + ], +} as const; + +const rulesSurfaceMessagesByLocale = { + "zh-TW": { + notificationsAndHooks: "規則", + notificationRulesDescription: + "當已提交的項目、欄位變更或已確認的發布符合條件時執行動作。", + addNotificationRule: "新增規則", + noNotificationRules: "尚無規則。", + whenItemSubmitted: "當項目提交時", + anItemIsSubmitted: "項目已提交", + itemCreatedUnavailable: "項目已建立(尚未提供)", + effects: "動作", + addEffect: "新增動作", + removeEffect: "移除動作", + personPropertyRequired: "請先新增 Person 屬性,再建立此規則。", + deleteNotificationRuleQuestion: "刪除此規則?", + deleteNotificationRuleDescription: + "未來符合的變更將不再執行這些動作。現有執行記錄會保留。", + notificationRuleCreated: "規則已建立", + notificationRuleUpdated: "規則已更新", + notificationRuleDeleted: "規則已刪除", + notificationRuleSaveFailed: "無法儲存規則", + notificationRuleDeleteFailed: "無法刪除規則", + latestHookExecutions: "最近的規則執行", + noHookExecutions: "此資料庫尚無規則執行記錄。", + hookCycleStopped: "已停止循環規則鏈", + hookDepthStopped: "已在深度限制處停止規則鏈", + }, + "zh-CN": { + notificationsAndHooks: "规则", + notificationRulesDescription: + "当已提交的项目、字段更改或已确认的发布符合条件时运行操作。", + addNotificationRule: "新建规则", + noNotificationRules: "暂无规则。", + whenItemSubmitted: "当项目提交时", + anItemIsSubmitted: "项目已提交", + itemCreatedUnavailable: "项目已创建(暂不可用)", + effects: "操作", + addEffect: "添加操作", + removeEffect: "移除操作", + personPropertyRequired: "请先添加 Person 属性,再创建此规则。", + deleteNotificationRuleQuestion: "删除此规则?", + deleteNotificationRuleDescription: + "未来匹配的更改将不再运行这些操作。现有运行历史会保留。", + notificationRuleCreated: "规则已创建", + notificationRuleUpdated: "规则已更新", + notificationRuleDeleted: "规则已删除", + notificationRuleSaveFailed: "无法保存规则", + notificationRuleDeleteFailed: "无法删除规则", + latestHookExecutions: "最近的规则运行", + noHookExecutions: "此数据库还没有规则运行记录。", + hookCycleStopped: "已停止循环规则链", + hookDepthStopped: "已在深度限制处停止规则链", + }, + "es-ES": { + notificationsAndHooks: "Reglas", + notificationRulesDescription: + "Ejecuta acciones cuando coincide un elemento enviado, un cambio de campo o una publicación confirmada.", + addNotificationRule: "Nueva regla", + noNotificationRules: "Aún no hay reglas.", + whenItemSubmitted: "Cuando se envía un elemento", + anItemIsSubmitted: "Se envía un elemento", + itemCreatedUnavailable: "Se crea un elemento (aún no disponible)", + effects: "Acciones", + addEffect: "Añadir acción", + removeEffect: "Eliminar acción", + personPropertyRequired: + "Añade una propiedad Persona antes de crear esta regla.", + deleteNotificationRuleQuestion: "¿Eliminar esta regla?", + deleteNotificationRuleDescription: + "Los cambios futuros que coincidan ya no ejecutarán estas acciones. Se conserva el historial.", + notificationRuleCreated: "Regla creada", + notificationRuleUpdated: "Regla actualizada", + notificationRuleDeleted: "Regla eliminada", + notificationRuleSaveFailed: "No se pudo guardar la regla", + notificationRuleDeleteFailed: "No se pudo eliminar la regla", + latestHookExecutions: "Ejecuciones recientes de reglas", + noHookExecutions: "Esta base de datos aún no tiene ejecuciones de reglas.", + hookCycleStopped: "Se detuvo una cadena cíclica de reglas", + hookDepthStopped: + "La cadena de reglas se detuvo en el límite de profundidad", + }, + "fr-FR": { + notificationsAndHooks: "Règles", + notificationRulesDescription: + "Exécuter des actions lorsqu’un élément soumis, un champ modifié ou une publication confirmée correspond.", + addNotificationRule: "Nouvelle règle", + noNotificationRules: "Aucune règle pour le moment.", + whenItemSubmitted: "Lorsqu’un élément est soumis", + anItemIsSubmitted: "Un élément est soumis", + itemCreatedUnavailable: "Un élément est créé (bientôt disponible)", + effects: "Actions", + addEffect: "Ajouter une action", + removeEffect: "Supprimer l’action", + personPropertyRequired: + "Ajoutez une propriété Personne avant de créer cette règle.", + deleteNotificationRuleQuestion: "Supprimer cette règle ?", + deleteNotificationRuleDescription: + "Les prochaines modifications correspondantes n’exécuteront plus ces actions. L’historique est conservé.", + notificationRuleCreated: "Règle créée", + notificationRuleUpdated: "Règle mise à jour", + notificationRuleDeleted: "Règle supprimée", + notificationRuleSaveFailed: "Impossible d’enregistrer la règle", + notificationRuleDeleteFailed: "Impossible de supprimer la règle", + latestHookExecutions: "Exécutions récentes des règles", + noHookExecutions: "Cette base de données n’a pas encore exécuté de règle.", + hookCycleStopped: "Une chaîne de règles cyclique a été arrêtée", + hookDepthStopped: + "La chaîne de règles a été arrêtée à la limite de profondeur", + }, + "de-DE": { + notificationsAndHooks: "Regeln", + notificationRulesDescription: + "Aktionen ausführen, wenn eine eingereichte Zeile, eine Feldänderung oder eine bestätigte Veröffentlichung passt.", + addNotificationRule: "Neue Regel", + noNotificationRules: "Noch keine Regeln.", + whenItemSubmitted: "Wenn ein Eintrag eingereicht wird", + anItemIsSubmitted: "Ein Eintrag wird eingereicht", + itemCreatedUnavailable: "Ein Eintrag wird erstellt (noch nicht verfügbar)", + effects: "Aktionen", + addEffect: "Aktion hinzufügen", + removeEffect: "Aktion entfernen", + personPropertyRequired: + "Fügen Sie vor dem Erstellen dieser Regel eine Person-Eigenschaft hinzu.", + deleteNotificationRuleQuestion: "Diese Regel löschen?", + deleteNotificationRuleDescription: + "Künftige passende Änderungen führen diese Aktionen nicht mehr aus. Der Verlauf bleibt erhalten.", + notificationRuleCreated: "Regel erstellt", + notificationRuleUpdated: "Regel aktualisiert", + notificationRuleDeleted: "Regel gelöscht", + notificationRuleSaveFailed: "Regel konnte nicht gespeichert werden", + notificationRuleDeleteFailed: "Regel konnte nicht gelöscht werden", + latestHookExecutions: "Letzte Regelausführungen", + noHookExecutions: "Diese Datenbank hat noch keine Regelausführungen.", + hookCycleStopped: "Eine zyklische Regelkette wurde gestoppt", + hookDepthStopped: "Die Regelkette wurde am Tiefenlimit gestoppt", + }, + "ja-JP": { + notificationsAndHooks: "ルール", + notificationRulesDescription: + "送信済みの項目、フィールド変更、または確認済みの公開が一致したときにアクションを実行します。", + addNotificationRule: "新しいルール", + noNotificationRules: "ルールはまだありません。", + whenItemSubmitted: "項目が送信されたとき", + anItemIsSubmitted: "項目が送信される", + itemCreatedUnavailable: "項目が作成される(準備中)", + effects: "アクション", + addEffect: "アクションを追加", + removeEffect: "アクションを削除", + personPropertyRequired: + "このルールを作成する前に Person プロパティを追加してください。", + deleteNotificationRuleQuestion: "このルールを削除しますか?", + deleteNotificationRuleDescription: + "今後一致する変更では、これらのアクションは実行されません。履歴は保持されます。", + notificationRuleCreated: "ルールを作成しました", + notificationRuleUpdated: "ルールを更新しました", + notificationRuleDeleted: "ルールを削除しました", + notificationRuleSaveFailed: "ルールを保存できませんでした", + notificationRuleDeleteFailed: "ルールを削除できませんでした", + latestHookExecutions: "最近のルール実行", + noHookExecutions: "このデータベースにはまだルール実行がありません。", + hookCycleStopped: "循環するルールチェーンを停止しました", + hookDepthStopped: "深さの上限でルールチェーンを停止しました", + }, + "ko-KR": { + notificationsAndHooks: "규칙", + notificationRulesDescription: + "제출된 항목, 필드 변경 또는 확인된 게시가 일치할 때 작업을 실행합니다.", + addNotificationRule: "새 규칙", + noNotificationRules: "아직 규칙이 없습니다.", + whenItemSubmitted: "항목이 제출될 때", + anItemIsSubmitted: "항목이 제출됨", + itemCreatedUnavailable: "항목이 생성됨(아직 사용할 수 없음)", + effects: "작업", + addEffect: "작업 추가", + removeEffect: "작업 제거", + personPropertyRequired: "이 규칙을 만들기 전에 Person 속성을 추가하세요.", + deleteNotificationRuleQuestion: "이 규칙을 삭제할까요?", + deleteNotificationRuleDescription: + "앞으로 일치하는 변경은 이 작업을 실행하지 않습니다. 기존 기록은 유지됩니다.", + notificationRuleCreated: "규칙 생성됨", + notificationRuleUpdated: "규칙 업데이트됨", + notificationRuleDeleted: "규칙 삭제됨", + notificationRuleSaveFailed: "규칙을 저장하지 못했습니다", + notificationRuleDeleteFailed: "규칙을 삭제하지 못했습니다", + latestHookExecutions: "최근 규칙 실행", + noHookExecutions: "이 데이터베이스에는 아직 규칙 실행이 없습니다.", + hookCycleStopped: "순환 규칙 체인을 중지했습니다", + hookDepthStopped: "깊이 제한에서 규칙 체인을 중지했습니다", + }, + "pt-BR": { + notificationsAndHooks: "Regras", + notificationRulesDescription: + "Execute ações quando um item enviado, uma alteração de campo ou uma publicação confirmada corresponder.", + addNotificationRule: "Nova regra", + noNotificationRules: "Ainda não há regras.", + whenItemSubmitted: "Quando um item é enviado", + anItemIsSubmitted: "Um item é enviado", + itemCreatedUnavailable: "Um item é criado (ainda não disponível)", + effects: "Ações", + addEffect: "Adicionar ação", + removeEffect: "Remover ação", + personPropertyRequired: + "Adicione uma propriedade Pessoa antes de criar esta regra.", + deleteNotificationRuleQuestion: "Excluir esta regra?", + deleteNotificationRuleDescription: + "Futuras alterações correspondentes não executarão mais estas ações. O histórico será mantido.", + notificationRuleCreated: "Regra criada", + notificationRuleUpdated: "Regra atualizada", + notificationRuleDeleted: "Regra excluída", + notificationRuleSaveFailed: "Não foi possível salvar a regra", + notificationRuleDeleteFailed: "Não foi possível excluir a regra", + latestHookExecutions: "Execuções recentes de regras", + noHookExecutions: "Este banco de dados ainda não tem execuções de regras.", + hookCycleStopped: "Uma cadeia cíclica de regras foi interrompida", + hookDepthStopped: + "A cadeia de regras foi interrompida no limite de profundidade", + }, + "hi-IN": { + notificationsAndHooks: "नियम", + notificationRulesDescription: + "सबमिट किए गए आइटम, फ़ील्ड बदलाव या पुष्ट प्रकाशन के मेल खाने पर कार्रवाइयाँ चलाएँ।", + addNotificationRule: "नया नियम", + noNotificationRules: "अभी कोई नियम नहीं है।", + whenItemSubmitted: "जब कोई आइटम सबमिट हो", + anItemIsSubmitted: "आइटम सबमिट होता है", + itemCreatedUnavailable: "आइटम बनाया जाता है (अभी उपलब्ध नहीं)", + effects: "कार्रवाइयाँ", + addEffect: "कार्रवाई जोड़ें", + removeEffect: "कार्रवाई हटाएँ", + personPropertyRequired: "यह नियम बनाने से पहले Person प्रॉपर्टी जोड़ें।", + deleteNotificationRuleQuestion: "यह नियम हटाएँ?", + deleteNotificationRuleDescription: + "भविष्य के मेल खाते बदलाव ये कार्रवाइयाँ नहीं चलाएँगे। मौजूदा इतिहास रखा जाएगा।", + notificationRuleCreated: "नियम बनाया गया", + notificationRuleUpdated: "नियम अपडेट किया गया", + notificationRuleDeleted: "नियम हटाया गया", + notificationRuleSaveFailed: "नियम सहेजा नहीं जा सका", + notificationRuleDeleteFailed: "नियम हटाया नहीं जा सका", + latestHookExecutions: "हाल के नियम रन", + noHookExecutions: "इस डेटाबेस में अभी कोई नियम रन नहीं है।", + hookCycleStopped: "चक्रीय नियम श्रृंखला रोक दी गई", + hookDepthStopped: "नियम श्रृंखला गहराई सीमा पर रोक दी गई", + }, + "ar-SA": { + notificationsAndHooks: "القواعد", + notificationRulesDescription: + "شغّل الإجراءات عند تطابق عنصر مُرسل أو تغيير حقل أو نشر مؤكّد.", + addNotificationRule: "قاعدة جديدة", + noNotificationRules: "لا توجد قواعد بعد.", + whenItemSubmitted: "عند إرسال عنصر", + anItemIsSubmitted: "يتم إرسال عنصر", + itemCreatedUnavailable: "يتم إنشاء عنصر (غير متاح بعد)", + effects: "الإجراءات", + addEffect: "إضافة إجراء", + removeEffect: "إزالة الإجراء", + personPropertyRequired: "أضف خاصية Person قبل إنشاء هذه القاعدة.", + deleteNotificationRuleQuestion: "حذف هذه القاعدة؟", + deleteNotificationRuleDescription: + "لن تشغّل التغييرات المطابقة مستقبلًا هذه الإجراءات. سيبقى السجل الحالي.", + notificationRuleCreated: "تم إنشاء القاعدة", + notificationRuleUpdated: "تم تحديث القاعدة", + notificationRuleDeleted: "تم حذف القاعدة", + notificationRuleSaveFailed: "تعذر حفظ القاعدة", + notificationRuleDeleteFailed: "تعذر حذف القاعدة", + latestHookExecutions: "عمليات تشغيل القواعد الأخيرة", + noHookExecutions: "لا توجد عمليات تشغيل قواعد في قاعدة البيانات هذه بعد.", + hookCycleStopped: "تم إيقاف سلسلة قواعد دورية", + hookDepthStopped: "تم إيقاف سلسلة القواعد عند حد العمق", + }, +} as const; + +const conditionKeys = [ + "if", + "addConditions", + "removeConditions", + "matchAllConditions", + "matchAnyCondition", + "operatorEquals", + "operatorNotEquals", + "operatorContains", + "operatorIsEmpty", + "operatorIsNotEmpty", + "removeCondition", + "addCondition", + "conditionValuePlaceholder", +] as const; + +const conditionMessagesByLocale = { + "zh-TW": [ + "如果", + "新增條件", + "移除條件", + "符合所有條件", + "符合任一條件", + "等於", + "不等於", + "包含", + "為空", + "不為空", + "移除條件", + "新增條件", + "輸入文字或 JSON 值", + ], + "zh-CN": [ + "如果", + "添加条件", + "移除条件", + "匹配所有条件", + "匹配任一条件", + "等于", + "不等于", + "包含", + "为空", + "不为空", + "移除条件", + "添加条件", + "输入文本或 JSON 值", + ], + "es-ES": [ + "Si", + "Añadir condiciones", + "Quitar condiciones", + "Cumplir todas las condiciones", + "Cumplir cualquier condición", + "Es igual a", + "No es igual a", + "Contiene", + "Está vacío", + "No está vacío", + "Quitar condición", + "Añadir condición", + "Introduce texto o un valor JSON", + ], + "fr-FR": [ + "Si", + "Ajouter des conditions", + "Supprimer les conditions", + "Respecter toutes les conditions", + "Respecter une condition", + "Est égal à", + "N’est pas égal à", + "Contient", + "Est vide", + "N’est pas vide", + "Supprimer la condition", + "Ajouter une condition", + "Saisissez du texte ou une valeur JSON", + ], + "de-DE": [ + "Wenn", + "Bedingungen hinzufügen", + "Bedingungen entfernen", + "Alle Bedingungen erfüllen", + "Eine Bedingung erfüllen", + "Ist gleich", + "Ist nicht gleich", + "Enthält", + "Ist leer", + "Ist nicht leer", + "Bedingung entfernen", + "Bedingung hinzufügen", + "Text oder JSON-Wert eingeben", + ], + "ja-JP": [ + "条件", + "条件を追加", + "条件を削除", + "すべての条件に一致", + "いずれかの条件に一致", + "等しい", + "等しくない", + "含む", + "空である", + "空ではない", + "条件を削除", + "条件を追加", + "テキストまたは JSON 値を入力", + ], + "ko-KR": [ + "조건", + "조건 추가", + "조건 제거", + "모든 조건 일치", + "조건 하나 이상 일치", + "같음", + "같지 않음", + "포함", + "비어 있음", + "비어 있지 않음", + "조건 제거", + "조건 추가", + "텍스트 또는 JSON 값 입력", + ], + "pt-BR": [ + "Se", + "Adicionar condições", + "Remover condições", + "Corresponder a todas as condições", + "Corresponder a qualquer condição", + "É igual a", + "Não é igual a", + "Contém", + "Está vazio", + "Não está vazio", + "Remover condição", + "Adicionar condição", + "Insira texto ou um valor JSON", + ], + "hi-IN": [ + "यदि", + "शर्तें जोड़ें", + "शर्तें हटाएँ", + "सभी शर्तों से मेल", + "किसी भी शर्त से मेल", + "बराबर है", + "बराबर नहीं है", + "शामिल है", + "खाली है", + "खाली नहीं है", + "शर्त हटाएँ", + "शर्त जोड़ें", + "टेक्स्ट या JSON मान दर्ज करें", + ], + "ar-SA": [ + "إذا", + "إضافة شروط", + "إزالة الشروط", + "مطابقة جميع الشروط", + "مطابقة أي شرط", + "يساوي", + "لا يساوي", + "يحتوي", + "فارغ", + "غير فارغ", + "إزالة الشرط", + "إضافة شرط", + "أدخل نصًا أو قيمة JSON", + ], +} as const; + +export const hookMessagesByLocale = Object.fromEntries( + Object.entries(baseHookMessagesByLocale).map(([locale, base]) => [ + locale, + { + ...base, + ...Object.fromEntries( + externalEffectKeys.map((key, index) => [ + key, + externalEffectMessagesByLocale[ + locale as keyof typeof externalEffectMessagesByLocale + ][index], + ]), + ), + ...Object.fromEntries( + mutationEffectKeys.map((key, index) => [ + key, + mutationEffectMessagesByLocale[ + locale as keyof typeof mutationEffectMessagesByLocale + ][index], + ]), + ), + ...rulesSurfaceMessagesByLocale[ + locale as keyof typeof rulesSurfaceMessagesByLocale + ], + ...Object.fromEntries( + conditionKeys.map((key, index) => [ + key, + conditionMessagesByLocale[ + locale as keyof typeof conditionMessagesByLocale + ][index], + ]), + ), + }, + ]), +) as { + [K in keyof typeof baseHookMessagesByLocale]: (typeof baseHookMessagesByLocale)[K] & + Record< + (typeof externalEffectKeys)[number] | (typeof mutationEffectKeys)[number], + string + > & + (typeof rulesSurfaceMessagesByLocale)[K] & + Record<(typeof conditionKeys)[number], string>; +}; diff --git a/templates/content/app/hook-runtime-i18n.ts b/templates/content/app/hook-runtime-i18n.ts new file mode 100644 index 0000000000..fa0d5838a7 --- /dev/null +++ b/templates/content/app/hook-runtime-i18n.ts @@ -0,0 +1,498 @@ +import type { LocaleCode } from "@agent-native/core/client"; + +const keys = [ + "hookIncidentControls", + "hookProcessingPaused", + "hookProcessingActive", + "hookIncidentControlsDescription", + "thisDatabase", + "allContentDatabases", + "pauseHookEvaluation", + "pauseHookEffects", + "pausedHookEventsNotReplayed", + "hookPauseSaveFailed", + "whenBuilderPublicationConfirmed", + "builderPublicationIsConfirmed", + "publicationAction", + "publishOrUnpublish", + "publishConfirmed", + "unpublishConfirmed", + "builderConfirmationDescription", + "hookTiming", + "timingImmediate", + "timingDelayed", + "timingDebounced", + "timingEscalation", + "delayMinutes", + "timingDelayedDescription", + "timingDebouncedDescription", + "timingEscalationDescription", + "agentJudgmentUsesAutomations", +] as const; + +function messages(values: readonly string[]) { + return Object.fromEntries( + keys.map((key, index) => [key, values[index]!]), + ) as { + [K in (typeof keys)[number]]: string; + }; +} + +const baseHookRuntimeMessagesByLocale: Record< + LocaleCode, + ReturnType +> = { + "en-US": messages([ + "Rule processing needs attention", + "Hook processing is paused", + "Hook processing is active", + "Pause evaluation or effect delivery without editing your rules.", + "This database", + "All Content databases", + "Pause hook evaluation", + "Pause hook effects", + "Paused work stays durable and pending. It resumes from the same ledger without duplicate delivery.", + "Hook pause settings could not be saved", + "When Builder publication is confirmed", + "Builder publication is confirmed", + "Publication action", + "Publish or unpublish", + "Publish confirmed", + "Unpublish confirmed", + "Runs only after Builder confirms the provider operation succeeded.", + "Timing", + "Immediately", + "After a delay", + "Debounce", + "Escalate", + "Delay in minutes", + "Run once after the configured delay.", + "Wait for changes to settle, replacing earlier pending runs for the same item.", + "Run immediately, then repeat once after the configured delay if the condition still matches.", + "Need agent judgment? Use Automations. It consumes the same durable event ledger, not a second hook engine.", + ]), + "zh-TW": messages([ + "事件控制", + "鉤子處理已暫停", + "鉤子處理中", + "無需編輯規則即可暫停評估或效果傳送。", + "此資料庫", + "所有 Content 資料庫", + "暫停鉤子評估", + "暫停鉤子效果", + "暫停的工作會持久保留為待處理,並從同一帳本恢復而不重複傳送。", + "無法儲存鉤子暫停設定", + "Builder 發布確認時", + "Builder 發布已確認", + "發布動作", + "發布或取消發布", + "發布已確認", + "取消發布已確認", + "僅在 Builder 確認供應商操作成功後執行。", + "時機", + "立即", + "延遲後", + "防抖", + "升級提醒", + "延遲分鐘數", + "在設定的延遲後執行一次。", + "等待變更穩定,並取代同一項目較早的待執行工作。", + "立即執行;若條件仍符合,在設定延遲後再執行一次。", + "需要代理判斷?請使用自動化。它使用相同的持久事件帳本,而不是第二套鉤子引擎。", + ]), + "zh-CN": messages([ + "事件控制", + "钩子处理已暂停", + "钩子处理正在运行", + "无需编辑规则即可暂停评估或效果投递。", + "此数据库", + "所有 Content 数据库", + "暂停钩子评估", + "暂停钩子效果", + "暂停的工作会持久保留为待处理,并从同一账本恢复而不重复投递。", + "无法保存钩子暂停设置", + "Builder 发布确认时", + "Builder 发布已确认", + "发布操作", + "发布或取消发布", + "发布已确认", + "取消发布已确认", + "仅在 Builder 确认提供方操作成功后运行。", + "时机", + "立即", + "延迟后", + "防抖", + "升级提醒", + "延迟分钟数", + "在设置的延迟后运行一次。", + "等待更改稳定,并替换同一项目较早的待运行任务。", + "立即运行;若条件仍匹配,在设置的延迟后再运行一次。", + "需要代理判断?请使用自动化。它使用同一个持久事件账本,而不是第二套钩子引擎。", + ]), + "es-ES": messages([ + "Controles de incidentes", + "El procesamiento de hooks está pausado", + "El procesamiento de hooks está activo", + "Pausa la evaluación o la entrega de efectos sin editar las reglas.", + "Esta base de datos", + "Todas las bases de datos de Content", + "Pausar evaluación", + "Pausar efectos", + "El trabajo pausado permanece pendiente y se reanuda desde el mismo registro sin entregas duplicadas.", + "No se pudo guardar la pausa", + "Cuando Builder confirma la publicación", + "Publicación de Builder confirmada", + "Acción de publicación", + "Publicar o retirar", + "Publicación confirmada", + "Retirada confirmada", + "Solo se ejecuta después de que Builder confirme el éxito de la operación.", + "Momento", + "Inmediatamente", + "Tras una demora", + "Agrupar cambios", + "Escalar", + "Minutos de demora", + "Ejecutar una vez tras la demora configurada.", + "Esperar a que terminen los cambios y reemplazar ejecuciones pendientes anteriores del mismo elemento.", + "Ejecutar ahora y repetir una vez tras la demora si la condición sigue coincidiendo.", + "¿Necesitas criterio del agente? Usa Automatizaciones. Consume el mismo registro duradero de eventos, no otro motor de hooks.", + ]), + "fr-FR": messages([ + "Contrôles d’incident", + "Le traitement des hooks est en pause", + "Le traitement des hooks est actif", + "Suspendre l’évaluation ou la livraison des effets sans modifier les règles.", + "Cette base de données", + "Toutes les bases Content", + "Suspendre l’évaluation", + "Suspendre les effets", + "Le travail suspendu reste durable et en attente, puis reprend sans livraison en double.", + "Impossible d’enregistrer la pause", + "Quand Builder confirme la publication", + "Publication Builder confirmée", + "Action de publication", + "Publier ou dépublier", + "Publication confirmée", + "Dépublication confirmée", + "S’exécute uniquement après confirmation du succès par Builder.", + "Délai", + "Immédiatement", + "Après un délai", + "Anti-rebond", + "Escalader", + "Délai en minutes", + "Exécuter une fois après le délai configuré.", + "Attendre la fin des modifications et remplacer les exécutions précédentes du même élément.", + "Exécuter immédiatement puis répéter une fois si la condition correspond encore.", + "Besoin du jugement d’un agent ? Utilisez les Automatisations. Elles partagent le même journal d’événements durable, pas un second moteur de hooks.", + ]), + "de-DE": messages([ + "Störfallsteuerung", + "Hook-Verarbeitung pausiert", + "Hook-Verarbeitung aktiv", + "Auswertung oder Effektauslieferung pausieren, ohne Regeln zu ändern.", + "Diese Datenbank", + "Alle Content-Datenbanken", + "Hook-Auswertung pausieren", + "Hook-Effekte pausieren", + "Pausierte Arbeit bleibt dauerhaft ausstehend und wird ohne doppelte Auslieferung fortgesetzt.", + "Pause konnte nicht gespeichert werden", + "Wenn Builder die Veröffentlichung bestätigt", + "Builder-Veröffentlichung bestätigt", + "Veröffentlichungsaktion", + "Veröffentlichen oder zurückziehen", + "Veröffentlichung bestätigt", + "Zurückziehen bestätigt", + "Läuft erst, nachdem Builder den erfolgreichen Vorgang bestätigt hat.", + "Zeitpunkt", + "Sofort", + "Nach Verzögerung", + "Entprellen", + "Eskalieren", + "Verzögerung in Minuten", + "Einmal nach der festgelegten Verzögerung ausführen.", + "Auf das Ende der Änderungen warten und frühere offene Läufe desselben Eintrags ersetzen.", + "Sofort ausführen und bei weiterhin passender Bedingung einmal wiederholen.", + "Agentenurteil nötig? Nutzen Sie Automatisierungen. Sie verwenden dasselbe dauerhafte Ereignisprotokoll, keine zweite Hook-Engine.", + ]), + "ja-JP": messages([ + "インシデント制御", + "フック処理は一時停止中", + "フック処理は稼働中", + "ルールを編集せずに評価または効果配信を停止します。", + "このデータベース", + "すべての Content データベース", + "フック評価を停止", + "フック効果を停止", + "停止した処理は保留のまま永続化され、重複配信なしで同じ台帳から再開します。", + "停止設定を保存できませんでした", + "Builder の公開が確認されたとき", + "Builder の公開確認", + "公開操作", + "公開または非公開", + "公開を確認", + "非公開を確認", + "Builder がプロバイダー操作の成功を確認した後にのみ実行します。", + "タイミング", + "即時", + "遅延後", + "デバウンス", + "エスカレーション", + "遅延(分)", + "設定した遅延後に一度実行します。", + "変更が落ち着くまで待ち、同じ項目の以前の保留実行を置き換えます。", + "すぐに実行し、条件が続く場合は遅延後に一度繰り返します。", + "エージェントの判断が必要ならオートメーションを使います。同じ永続イベント台帳を利用し、別のフックエンジンは作りません。", + ]), + "ko-KR": messages([ + "인시던트 제어", + "후크 처리가 일시 중지됨", + "후크 처리 활성", + "규칙을 편집하지 않고 평가나 효과 전달을 중지합니다.", + "이 데이터베이스", + "모든 Content 데이터베이스", + "후크 평가 중지", + "후크 효과 중지", + "중지된 작업은 대기 상태로 보존되며 중복 전달 없이 같은 원장에서 재개됩니다.", + "중지 설정을 저장하지 못했습니다", + "Builder 게시가 확인될 때", + "Builder 게시 확인", + "게시 작업", + "게시 또는 게시 취소", + "게시 확인", + "게시 취소 확인", + "Builder가 공급자 작업 성공을 확인한 뒤에만 실행됩니다.", + "타이밍", + "즉시", + "지연 후", + "디바운스", + "에스컬레이션", + "지연 시간(분)", + "설정된 지연 후 한 번 실행합니다.", + "변경이 멈출 때까지 기다리고 같은 항목의 이전 대기 실행을 교체합니다.", + "즉시 실행하고 조건이 계속 맞으면 지연 후 한 번 반복합니다.", + "에이전트 판단이 필요하면 자동화를 사용하세요. 같은 영구 이벤트 원장을 사용하며 별도 후크 엔진이 아닙니다.", + ]), + "pt-BR": messages([ + "Controles de incidente", + "Processamento de hooks pausado", + "Processamento de hooks ativo", + "Pause a avaliação ou a entrega de efeitos sem editar as regras.", + "Este banco de dados", + "Todos os bancos do Content", + "Pausar avaliação", + "Pausar efeitos", + "O trabalho pausado permanece pendente e retoma do mesmo registro sem entrega duplicada.", + "Não foi possível salvar a pausa", + "Quando o Builder confirma a publicação", + "Publicação do Builder confirmada", + "Ação de publicação", + "Publicar ou despublicar", + "Publicação confirmada", + "Despublicação confirmada", + "Só executa após o Builder confirmar o sucesso da operação.", + "Momento", + "Imediatamente", + "Após um atraso", + "Agrupar alterações", + "Escalar", + "Atraso em minutos", + "Executar uma vez após o atraso configurado.", + "Esperar as alterações terminarem e substituir execuções pendentes anteriores do mesmo item.", + "Executar agora e repetir uma vez após o atraso se a condição ainda corresponder.", + "Precisa do julgamento do agente? Use Automações. Elas consomem o mesmo registro durável de eventos, não outro mecanismo de hooks.", + ]), + "hi-IN": messages([ + "घटना नियंत्रण", + "हुक प्रक्रिया रुकी है", + "हुक प्रक्रिया सक्रिय है", + "नियम बदले बिना मूल्यांकन या प्रभाव वितरण रोकें।", + "यह डेटाबेस", + "सभी Content डेटाबेस", + "हुक मूल्यांकन रोकें", + "हुक प्रभाव रोकें", + "रुका काम टिकाऊ और लंबित रहता है तथा बिना दोहरी डिलीवरी के उसी लेजर से फिर चलता है।", + "विराम सेटिंग सहेजी नहीं जा सकी", + "जब Builder प्रकाशन की पुष्टि करे", + "Builder प्रकाशन की पुष्टि", + "प्रकाशन कार्रवाई", + "प्रकाशित या अप्रकाशित", + "प्रकाशन पुष्टि", + "अप्रकाशन पुष्टि", + "Builder द्वारा सफलता की पुष्टि के बाद ही चलता है।", + "समय", + "तुरंत", + "देरी के बाद", + "डीबाउंस", + "एस्केलेट", + "देरी के मिनट", + "निर्धारित देरी के बाद एक बार चलाएँ।", + "बदलाव थमने तक प्रतीक्षा करें और उसी आइटम के पुराने लंबित रन को बदलें।", + "तुरंत चलाएँ और शर्त बनी रहे तो देरी के बाद एक बार दोहराएँ।", + "एजेंट के निर्णय के लिए Automations उपयोग करें। यह उसी टिकाऊ इवेंट लेजर का उपयोग करता है, दूसरी हुक प्रणाली का नहीं।", + ]), + "ar-SA": messages([ + "عناصر التحكم بالحوادث", + "معالجة الخطافات متوقفة", + "معالجة الخطافات نشطة", + "أوقف التقييم أو تسليم التأثيرات دون تعديل القواعد.", + "قاعدة البيانات هذه", + "كل قواعد بيانات Content", + "إيقاف تقييم الخطافات", + "إيقاف تأثيرات الخطافات", + "يبقى العمل المتوقف محفوظًا ومعلقًا، ثم يستأنف من السجل نفسه دون تسليم مكرر.", + "تعذر حفظ إعدادات التوقف", + "عند تأكيد نشر Builder", + "تم تأكيد نشر Builder", + "إجراء النشر", + "نشر أو إلغاء النشر", + "تم تأكيد النشر", + "تم تأكيد إلغاء النشر", + "لا يعمل إلا بعد تأكيد Builder نجاح عملية المزوّد.", + "التوقيت", + "فورًا", + "بعد تأخير", + "دمج التغييرات", + "تصعيد", + "دقائق التأخير", + "التشغيل مرة بعد التأخير المحدد.", + "انتظار استقرار التغييرات واستبدال التشغيلات المعلقة السابقة للعنصر نفسه.", + "التشغيل فورًا ثم التكرار مرة إذا ظل الشرط مطابقًا.", + "هل تحتاج إلى تقدير الوكيل؟ استخدم الأتمتة. فهي تستهلك سجل الأحداث الدائم نفسه، لا محرك خطافات آخر.", + ]), +}; + +const ruleRuntimeOverridesByLocale: Record< + LocaleCode, + Partial> +> = { + "en-US": { + hookProcessingPaused: "Rule processing is paused", + hookProcessingActive: "Rule processing is active", + hookIncidentControlsDescription: + "Pause rule evaluation or actions without editing your rules.", + pauseHookEvaluation: "Pause rule evaluation", + pauseHookEffects: "Pause rule actions", + hookPauseSaveFailed: "Rule pause settings could not be saved", + agentJudgmentUsesAutomations: + "Need agent judgment? Use Automations. Rules and Automations share the same durable event stream.", + }, + "zh-TW": { + hookProcessingPaused: "規則處理已暫停", + hookProcessingActive: "規則處理中", + hookIncidentControlsDescription: "無需編輯規則即可暫停規則評估或動作。", + pauseHookEvaluation: "暫停規則評估", + pauseHookEffects: "暫停規則動作", + hookPauseSaveFailed: "無法儲存規則暫停設定", + agentJudgmentUsesAutomations: + "需要代理判斷?請使用自動化。規則和自動化共用相同的持久事件串流。", + }, + "zh-CN": { + hookProcessingPaused: "规则处理已暂停", + hookProcessingActive: "规则处理正在运行", + hookIncidentControlsDescription: "无需编辑规则即可暂停规则评估或操作。", + pauseHookEvaluation: "暂停规则评估", + pauseHookEffects: "暂停规则操作", + hookPauseSaveFailed: "无法保存规则暂停设置", + agentJudgmentUsesAutomations: + "需要代理判断?请使用自动化。规则和自动化共享同一个持久事件流。", + }, + "es-ES": { + hookProcessingPaused: "El procesamiento de reglas está pausado", + hookProcessingActive: "El procesamiento de reglas está activo", + hookIncidentControlsDescription: + "Pausa la evaluación o las acciones sin editar las reglas.", + pauseHookEvaluation: "Pausar evaluación de reglas", + pauseHookEffects: "Pausar acciones de reglas", + hookPauseSaveFailed: "No se pudo guardar la pausa de reglas", + agentJudgmentUsesAutomations: + "¿Necesitas criterio del agente? Usa Automatizaciones. Las reglas y las Automatizaciones comparten el mismo flujo duradero de eventos.", + }, + "fr-FR": { + hookProcessingPaused: "Le traitement des règles est en pause", + hookProcessingActive: "Le traitement des règles est actif", + hookIncidentControlsDescription: + "Suspendre l’évaluation ou les actions sans modifier les règles.", + pauseHookEvaluation: "Suspendre l’évaluation des règles", + pauseHookEffects: "Suspendre les actions des règles", + hookPauseSaveFailed: "Impossible d’enregistrer la pause des règles", + agentJudgmentUsesAutomations: + "Besoin du jugement d’un agent ? Utilisez les Automatisations. Les règles et les Automatisations partagent le même flux d’événements durable.", + }, + "de-DE": { + hookProcessingPaused: "Regelverarbeitung pausiert", + hookProcessingActive: "Regelverarbeitung aktiv", + hookIncidentControlsDescription: + "Regelauswertung oder Aktionen pausieren, ohne Regeln zu bearbeiten.", + pauseHookEvaluation: "Regelauswertung pausieren", + pauseHookEffects: "Regelaktionen pausieren", + hookPauseSaveFailed: "Regelpause konnte nicht gespeichert werden", + agentJudgmentUsesAutomations: + "Agentenurteil nötig? Nutzen Sie Automatisierungen. Regeln und Automatisierungen teilen denselben dauerhaften Ereignisstrom.", + }, + "ja-JP": { + hookProcessingPaused: "ルール処理は一時停止中", + hookProcessingActive: "ルール処理は稼働中", + hookIncidentControlsDescription: + "ルールを編集せずに評価またはアクションを停止します。", + pauseHookEvaluation: "ルール評価を停止", + pauseHookEffects: "ルールアクションを停止", + hookPauseSaveFailed: "ルールの停止設定を保存できませんでした", + agentJudgmentUsesAutomations: + "エージェントの判断が必要ならオートメーションを使います。ルールとオートメーションは同じ永続イベントストリームを共有します。", + }, + "ko-KR": { + hookProcessingPaused: "규칙 처리가 일시 중지됨", + hookProcessingActive: "규칙 처리 활성", + hookIncidentControlsDescription: + "규칙을 편집하지 않고 평가나 작업을 중지합니다.", + pauseHookEvaluation: "규칙 평가 중지", + pauseHookEffects: "규칙 작업 중지", + hookPauseSaveFailed: "규칙 일시 중지 설정을 저장하지 못했습니다", + agentJudgmentUsesAutomations: + "에이전트 판단이 필요하면 자동화를 사용하세요. 규칙과 자동화는 같은 영구 이벤트 스트림을 공유합니다.", + }, + "pt-BR": { + hookProcessingPaused: "Processamento de regras pausado", + hookProcessingActive: "Processamento de regras ativo", + hookIncidentControlsDescription: + "Pause a avaliação ou as ações sem editar as regras.", + pauseHookEvaluation: "Pausar avaliação de regras", + pauseHookEffects: "Pausar ações de regras", + hookPauseSaveFailed: "Não foi possível salvar a pausa de regras", + agentJudgmentUsesAutomations: + "Precisa do julgamento do agente? Use Automações. Regras e Automações compartilham o mesmo fluxo durável de eventos.", + }, + "hi-IN": { + hookProcessingPaused: "नियम प्रक्रिया रुकी है", + hookProcessingActive: "नियम प्रक्रिया सक्रिय है", + hookIncidentControlsDescription: "नियम बदले बिना मूल्यांकन या कार्रवाइयाँ रोकें।", + pauseHookEvaluation: "नियम मूल्यांकन रोकें", + pauseHookEffects: "नियम कार्रवाइयाँ रोकें", + hookPauseSaveFailed: "नियम विराम सेटिंग सहेजी नहीं जा सकी", + agentJudgmentUsesAutomations: + "एजेंट के निर्णय के लिए Automations उपयोग करें। नियम और Automations एक ही टिकाऊ इवेंट स्ट्रीम साझा करते हैं।", + }, + "ar-SA": { + hookProcessingPaused: "معالجة القواعد متوقفة", + hookProcessingActive: "معالجة القواعد نشطة", + hookIncidentControlsDescription: + "أوقف تقييم القواعد أو إجراءاتها دون تعديل القواعد.", + pauseHookEvaluation: "إيقاف تقييم القواعد", + pauseHookEffects: "إيقاف إجراءات القواعد", + hookPauseSaveFailed: "تعذر حفظ إعدادات إيقاف القواعد", + agentJudgmentUsesAutomations: + "هل تحتاج إلى تقدير الوكيل؟ استخدم الأتمتة. تشترك القواعد والأتمتة في تدفق الأحداث الدائم نفسه.", + }, +}; + +export const hookRuntimeMessagesByLocale = Object.fromEntries( + Object.entries(baseHookRuntimeMessagesByLocale).map(([locale, base]) => [ + locale, + { + ...base, + ...ruleRuntimeOverridesByLocale[locale as LocaleCode], + }, + ]), +) as Record>; diff --git a/templates/content/app/hooks/use-content-database.test.ts b/templates/content/app/hooks/use-content-database.test.ts index 774036c1db..43db68589c 100644 --- a/templates/content/app/hooks/use-content-database.test.ts +++ b/templates/content/app/hooks/use-content-database.test.ts @@ -12,15 +12,62 @@ import { applySourceFieldPropertyToDatabaseResponse, clearDeletedContentDatabaseFromCache, contentDatabaseQueryKey, + contentNotificationPreferenceQueryKey, invalidateBuilderBodyHydrationQueries, invalidateContentDatabaseSourceRefreshQueries, readCachedContentDatabaseResponse, removeDocumentPropertyFromDatabaseResponse, + writeContentNotificationPreferenceResponseToCache, writeContentDatabaseResponseToCache, } from "./use-content-database"; const createdAt = "2026-06-15T12:00:00.000Z"; +it("writes successful notification mutations to the flat GET cache key", () => { + const queryClient = new QueryClient(); + const target = { + scope: "item" as const, + databaseId: "database", + documentId: "document", + }; + + writeContentNotificationPreferenceResponseToCache(queryClient, { + target, + preference: { + id: "preference", + ownerEmail: "owner@example.com", + orgId: "organization", + scope: "item", + scopeId: "document", + databaseId: "database", + subscriptionId: null, + documentId: "document", + enabled: false, + createdAt, + updatedAt: createdAt, + }, + }); + + expect( + queryClient.getQueryData(contentNotificationPreferenceQueryKey(target)), + ).toMatchObject({ + target, + documentId: "document", + preference: { + enabled: false, + source: "item", + preferenceId: "preference", + }, + }); + expect( + queryClient.getQueryData([ + "action", + "get-content-notification-preference", + { target }, + ]), + ).toBeUndefined(); +}); + function databaseResponse(): ContentDatabaseResponse { return { database: { diff --git a/templates/content/app/hooks/use-content-database.ts b/templates/content/app/hooks/use-content-database.ts index f260cc2b72..ae37252d50 100644 --- a/templates/content/app/hooks/use-content-database.ts +++ b/templates/content/app/hooks/use-content-database.ts @@ -8,6 +8,8 @@ import type { CancelPreparedBuilderSourceUpdateResponse, ChangeContentDatabaseSourceRoleRequest, ContentDatabaseResponse, + ContentHookRuntimeControlsResponse, + ContentNotificationPreferenceTarget, ContentDatabasePersonalViewResponse, ContentDatabaseSourceFieldMapping, CreateInlineDatabaseRequest, @@ -15,6 +17,9 @@ import type { DocumentPropertyType, ListTrashedContentDatabasesResponse, ListContentDatabasesResponse, + ListContentDatabaseHooksResponse, + ListContentDatabaseHookExecutionsResponse, + GetContentNotificationPreferenceResponse, ContentDatabaseSourceFieldPropertyResponse, ContentDatabaseSourceStatusResponse, CreateDatabaseRequest, @@ -26,6 +31,19 @@ import type { DuplicateDatabaseItemRequest, ExecuteBuilderSourceExecutionRequest, MoveDatabaseItemRequest, + ManageContentDatabaseHookRequest, + ManageContentDatabaseHookResponse, + ManageContentDatabaseHookExecutionRequest, + ManageContentDatabaseHookExecutionResponse, + ManageContentDatabasePolicyRequest, + ManageContentDatabasePolicyResponse, + ManageContentHookRuntimeControlRequest, + ManageContentDatabaseValidationRequest, + ManageContentDatabaseValidationResponse, + ManageContentNotificationPreferenceRequest, + ManageContentNotificationPreferenceResponse, + PreviewContentDatabaseHookRequest, + PreviewContentDatabaseHookResponse, PrepareBuilderSourceExecutionRequest, PreviewBuilderSourceReviewResponse, PrepareBuilderSourceReviewRequest, @@ -470,6 +488,173 @@ export function useContentDatabaseById(databaseId: string | null) { ); } +export function useContentDatabaseHooks(databaseId: string | null) { + return useActionQuery( + "list-content-database-hooks", + databaseId ? { databaseId } : undefined, + { enabled: !!databaseId, retry: false }, + ); +} + +export function useManageContentDatabasePolicy() { + const queryClient = useQueryClient(); + return useActionMutation< + ManageContentDatabasePolicyResponse, + ManageContentDatabasePolicyRequest + >("manage-content-database-policy", { + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: ["action", "get-content-database"], + }); + }, + }); +} + +export function useContentDatabaseHookExecutions( + databaseId: string | null, + hookId?: string, +) { + return useActionQuery( + "list-content-database-hook-executions", + databaseId ? { databaseId, hookId, limit: 20 } : undefined, + { enabled: !!databaseId, retry: false }, + ); +} + +export function useManageContentDatabaseHook(databaseId: string | null) { + const queryClient = useQueryClient(); + return useActionMutation< + ManageContentDatabaseHookResponse, + ManageContentDatabaseHookRequest + >("manage-content-database-hook", { + onSuccess: () => { + if (!databaseId) return; + queryClient.invalidateQueries({ + queryKey: ["action", "list-content-database-hooks", { databaseId }], + }); + queryClient.invalidateQueries({ + queryKey: ["action", "list-content-database-hook-executions"], + }); + }, + }); +} + +export function useManageContentDatabaseHookExecution( + databaseId: string | null, +) { + const queryClient = useQueryClient(); + return useActionMutation< + ManageContentDatabaseHookExecutionResponse, + ManageContentDatabaseHookExecutionRequest + >("manage-content-database-hook-execution", { + onSuccess: () => { + if (!databaseId) return; + queryClient.invalidateQueries({ + queryKey: ["action", "list-content-database-hook-executions"], + }); + }, + }); +} + +export function usePreviewContentDatabaseHook() { + return useActionMutation< + PreviewContentDatabaseHookResponse, + PreviewContentDatabaseHookRequest + >("preview-content-database-hook"); +} + +export function useContentHookRuntimeControls(databaseId: string | null) { + return useActionQuery( + "get-content-hook-runtime-controls", + databaseId ? { databaseId } : undefined, + { enabled: !!databaseId, retry: false }, + ); +} + +export function useManageContentHookRuntimeControl(databaseId: string | null) { + const queryClient = useQueryClient(); + return useActionMutation< + ContentHookRuntimeControlsResponse, + ManageContentHookRuntimeControlRequest + >("manage-content-hook-runtime-control", { + onSuccess: (response) => { + if (!databaseId) return; + queryClient.setQueryData( + ["action", "get-content-hook-runtime-controls", { databaseId }], + response, + ); + }, + }); +} + +export function useManageContentDatabaseValidation(databaseId: string | null) { + const queryClient = useQueryClient(); + return useActionMutation< + ManageContentDatabaseValidationResponse, + ManageContentDatabaseValidationRequest + >("manage-content-database-validation", { + onSuccess: () => { + if (!databaseId) return; + queryClient.invalidateQueries({ + queryKey: ["action", "get-content-database"], + }); + }, + }); +} + +export function useContentNotificationPreference( + target: ContentNotificationPreferenceTarget | null, +) { + return useActionQuery( + "get-content-notification-preference", + target ?? undefined, + { enabled: !!target, retry: false }, + ); +} + +export function contentNotificationPreferenceQueryKey( + target: ContentNotificationPreferenceTarget, +) { + return ["action", "get-content-notification-preference", target] as const; +} + +export function writeContentNotificationPreferenceResponseToCache( + queryClient: QueryClient, + response: ManageContentNotificationPreferenceResponse, +) { + if (!response.preference) return; + queryClient.setQueryData( + contentNotificationPreferenceQueryKey(response.target), + { + target: response.target, + documentId: + response.target.scope === "item" ? response.target.documentId : null, + preference: { + enabled: response.preference.enabled, + source: response.target.scope, + preferenceId: response.preference.id, + }, + }, + ); +} + +export function useManageContentNotificationPreference( + _databaseId: string | null, +) { + const queryClient = useQueryClient(); + return useActionMutation< + ManageContentNotificationPreferenceResponse, + ManageContentNotificationPreferenceRequest + >("manage-content-notification-preference", { + onSuccess: (response) => { + writeContentNotificationPreferenceResponseToCache(queryClient, response); + void queryClient.invalidateQueries({ + queryKey: ["action", "get-content-notification-preference"], + }); + }, + }); +} + export function useCreateContentDatabase(documentId: string | null) { const queryClient = useQueryClient(); return useActionMutation( diff --git a/templates/content/app/i18n-data.ts b/templates/content/app/i18n-data.ts index 70cf523fbc..2464aae180 100644 --- a/templates/content/app/i18n-data.ts +++ b/templates/content/app/i18n-data.ts @@ -1,9 +1,101 @@ import type { LocaleCode } from "@agent-native/core/client"; import { creativeContextMessagesByLocale } from "@agent-native/creative-context/messages"; +import { databasePolicyMessagesByLocale } from "./database-policy-i18n"; +import { hookMessagesByLocale } from "./hook-i18n"; +import { hookRuntimeMessagesByLocale } from "./hook-runtime-i18n"; import zhTW from "./i18n/zh-TW"; +import { validationMessagesByLocale } from "./validation-i18n"; const databaseMessages = { + ...databasePolicyMessagesByLocale["en-US"], + ...hookRuntimeMessagesByLocale["en-US"], + ...validationMessagesByLocale["en-US"], + setPropertyEffect: "Set property", + propertyToSet: "Property to set", + propertyValue: "Property value", + propertyValuePlaceholder: "Enter text or a JSON value", + hookCycleStopped: "Stopped a cyclic rule chain", + hookDepthStopped: "Stopped the rule chain at the depth limit", + notificationsAndHooks: "Rules", + notificationRulesDescription: + "Run actions when items in this database change.", + addNotificationRule: "New rule", + noNotificationRules: "No rules yet.", + whenItemSubmitted: "When an item is submitted", + whenItemCreated: "When an item is created", + whenPropertyChanges: "When a property changes", + ruleEnabled: "Active", + rulePaused: "Paused", + ruleName: "Rule name", + ruleNamePlaceholder: "Ready for review", + when: "When", + if: "If", + addConditions: "Add conditions", + removeConditions: "Remove conditions", + matchAllConditions: "Match all conditions", + matchAnyCondition: "Match any condition", + operatorEquals: "Equals", + operatorNotEquals: "Does not equal", + operatorContains: "Contains", + operatorIsEmpty: "Is empty", + operatorIsNotEmpty: "Is not empty", + removeCondition: "Remove condition", + addCondition: "Add condition", + conditionValuePlaceholder: "Enter text or a JSON value", + anItemIsSubmitted: "An item is submitted", + itemCreatedUnavailable: "An item is created (not available yet)", + anItemIsCreated: "An item is created", + aPropertyChanges: "A property changes", + property: "Property", + chooseAProperty: "Choose a property", + resultingOption: "Resulting option", + anyOption: "Any option", + previousOptionCondition: "Add a previous-value condition", + previousOption: "Previous option", + effects: "Actions", + addEffect: "Add action", + personalNotification: "Personal notification", + teamSlackAnnouncement: "Team Slack announcement", + signedWebhook: "Signed webhook", + removeEffect: "Remove action", + slackWebhookSecretKey: "Slack webhook secret key", + webhookUrlSecretKey: "Webhook URL secret key", + webhookSigningSecretKey: "Webhook signing secret key", + personalDatabaseNotifications: "Personal database notifications", + personalDatabaseNotificationsDescription: + "Receive notifications from this database unless a rule or item overrides it.", + personal: "Personal", + personalContentNotifications: "Personal Content notifications", + personalContentNotificationsDescription: + "Set your default for personal notifications across Content.", + unsubscribeFromItem: "Mute notifications for this item", + subscribeToItem: "Receive notifications for this item", + notifyPeopleIn: "Notify people in", + choosePersonProperty: "Choose a Person property", + personPropertyRequired: + "Add a Person property to this database before creating this rule.", + ruleEnabledDescription: "Run this rule when matching changes are committed.", + saveRule: "Save rule", + deleteRule: "Delete rule", + deleteNotificationRuleQuestion: "Delete this rule?", + deleteNotificationRuleDescription: + "Future matching changes will no longer run these actions. Existing run history is kept.", + notificationRuleCreated: "Rule created", + notificationRuleUpdated: "Rule updated", + notificationRuleDeleted: "Rule deleted", + notificationRuleSaveFailed: "Couldn’t save rule", + notificationRuleDeleteFailed: "Couldn’t delete rule", + latestHookExecutions: "Run history", + noHookExecutions: "This database has no rule runs yet.", + deletedRule: "Deleted rule", + executionPending: "Pending", + executionRunning: "Running", + executionSucceeded: "Succeeded", + executionFailed: "Failed", + executionRetrying: "Retrying", + executionUnknown: "Unknown", + executionAcknowledged: "Acknowledged", aField: "a field", addAnotherItemSourceBeforeChangingToDetails: "Add another item source before changing this source to details.", @@ -463,8 +555,18 @@ const databaseMessages = { const databaseMessagesByLocale = { "en-US": databaseMessages, - "zh-TW": zhTW.database, + "zh-TW": { + ...zhTW.database, + ...hookMessagesByLocale["zh-TW"], + ...hookRuntimeMessagesByLocale["zh-TW"], + ...databasePolicyMessagesByLocale["zh-TW"], + ...validationMessagesByLocale["zh-TW"], + }, "zh-CN": { + ...hookMessagesByLocale["zh-CN"], + ...hookRuntimeMessagesByLocale["zh-CN"], + ...databasePolicyMessagesByLocale["zh-CN"], + ...validationMessagesByLocale["zh-CN"], formChecked: "已勾选", formChooseOption: "选择一个选项", formDescription: "填写以下问题以向此数据库添加页面。", @@ -666,6 +768,10 @@ const databaseMessagesByLocale = { wrapAllContent: "换行显示所有内容", }, "es-ES": { + ...hookMessagesByLocale["es-ES"], + ...hookRuntimeMessagesByLocale["es-ES"], + ...databasePolicyMessagesByLocale["es-ES"], + ...validationMessagesByLocale["es-ES"], formChecked: "Marcado", formChooseOption: "Elige una opción", formDescription: @@ -889,6 +995,10 @@ const databaseMessagesByLocale = { wrapAllContent: "Ajustar todo el contenido", }, "fr-FR": { + ...hookMessagesByLocale["fr-FR"], + ...hookRuntimeMessagesByLocale["fr-FR"], + ...databasePolicyMessagesByLocale["fr-FR"], + ...validationMessagesByLocale["fr-FR"], formChecked: "Coché", formChooseOption: "Choisir une option", formDescription: @@ -1113,6 +1223,10 @@ const databaseMessagesByLocale = { wrapAllContent: "Renvoyer tout le contenu à la ligne", }, "de-DE": { + ...hookMessagesByLocale["de-DE"], + ...hookRuntimeMessagesByLocale["de-DE"], + ...databasePolicyMessagesByLocale["de-DE"], + ...validationMessagesByLocale["de-DE"], formChecked: "Ausgewählt", formChooseOption: "Option auswählen", formDescription: @@ -1337,6 +1451,10 @@ const databaseMessagesByLocale = { wrapAllContent: "Gesamten Inhalt umbrechen", }, "ja-JP": { + ...hookMessagesByLocale["ja-JP"], + ...hookRuntimeMessagesByLocale["ja-JP"], + ...databasePolicyMessagesByLocale["ja-JP"], + ...validationMessagesByLocale["ja-JP"], formChecked: "チェック済み", formChooseOption: "オプションを選択", formDescription: @@ -1555,6 +1673,10 @@ const databaseMessagesByLocale = { wrapAllContent: "すべての内容を折り返す", }, "ko-KR": { + ...hookMessagesByLocale["ko-KR"], + ...hookRuntimeMessagesByLocale["ko-KR"], + ...databasePolicyMessagesByLocale["ko-KR"], + ...validationMessagesByLocale["ko-KR"], formChecked: "선택됨", formChooseOption: "옵션 선택", formDescription: @@ -1770,6 +1892,10 @@ const databaseMessagesByLocale = { wrapAllContent: "모든 콘텐츠 줄바꿈", }, "pt-BR": { + ...hookMessagesByLocale["pt-BR"], + ...hookRuntimeMessagesByLocale["pt-BR"], + ...databasePolicyMessagesByLocale["pt-BR"], + ...validationMessagesByLocale["pt-BR"], formChecked: "Marcado", formChooseOption: "Escolha uma opção", formDescription: @@ -1993,6 +2119,10 @@ const databaseMessagesByLocale = { wrapAllContent: "Quebrar todo o conteúdo", }, "hi-IN": { + ...hookMessagesByLocale["hi-IN"], + ...hookRuntimeMessagesByLocale["hi-IN"], + ...databasePolicyMessagesByLocale["hi-IN"], + ...validationMessagesByLocale["hi-IN"], formChecked: "चयनित", formChooseOption: "एक विकल्प चुनें", formDescription: "इस डेटाबेस में पेज जोड़ने के लिए नीचे दिए सवाल भरें।", @@ -2203,6 +2333,10 @@ const databaseMessagesByLocale = { wrapAllContent: "सभी सामग्री रैप करें", }, "ar-SA": { + ...hookMessagesByLocale["ar-SA"], + ...hookRuntimeMessagesByLocale["ar-SA"], + ...databasePolicyMessagesByLocale["ar-SA"], + ...validationMessagesByLocale["ar-SA"], formChecked: "محدد", formChooseOption: "اختر خيارا", formDescription: "أجب عن الأسئلة أدناه لإضافة صفحة إلى قاعدة البيانات هذه.", @@ -2685,6 +2819,13 @@ const editorToolbarMessages = { morePageActions: "More page actions", noPagesFound: "No pages found", notifications: "Notifications", + muteNotificationsForPage: "Mute notifications for this page", + receiveNotificationsForPage: "Receive notifications for this page", + databasePageNotificationScope: + "Applies to this database page. Rows have their own notification settings.", + notificationsMutedForPage: "Notifications muted for this page", + notificationsEnabledForPage: "Notifications enabled for this page", + notificationPreferenceUpdateFailed: "Couldn’t update notification preference", notionSync: "Notion sync", notionPageUrlOrId: "Notion page URL or page ID", open: "Open", @@ -5684,6 +5825,13 @@ const editorMessagesByLocale = { morePageActions: "更多页面操作", noPagesFound: "没有找到页面", notifications: "通知", + muteNotificationsForPage: "将此页面的通知设为静音", + receiveNotificationsForPage: "接收此页面的通知", + databasePageNotificationScope: + "仅适用于此数据库页面。各行有自己的通知设置。", + notificationsMutedForPage: "已将此页面的通知设为静音", + notificationsEnabledForPage: "已启用此页面的通知", + notificationPreferenceUpdateFailed: "无法更新通知偏好设置", notionSync: "概念同步", openInNotion: "在概念中打开", orgCanFindAndView: "您组织中的任何人都可以查找和查看", @@ -6025,6 +6173,14 @@ const editorMessagesByLocale = { morePageActions: "Más acciones de página", noPagesFound: "No se encontraron páginas", notifications: "Notificaciones", + muteNotificationsForPage: "Silenciar las notificaciones de esta página", + receiveNotificationsForPage: "Recibir notificaciones de esta página", + databasePageNotificationScope: + "Se aplica a esta página de base de datos. Las filas tienen su propia configuración de notificaciones.", + notificationsMutedForPage: "Notificaciones silenciadas para esta página", + notificationsEnabledForPage: "Notificaciones activadas para esta página", + notificationPreferenceUpdateFailed: + "No se pudo actualizar la preferencia de notificaciones", notionSync: "Sincronización de nociones", openInNotion: "Abierto en noción", orgCanFindAndView: @@ -6375,6 +6531,14 @@ const editorMessagesByLocale = { morePageActions: "Plus d'actions sur la page", noPagesFound: "Aucune page trouvée", notifications: "Notifications [fr-FR]", + muteNotificationsForPage: "Désactiver les notifications pour cette page", + receiveNotificationsForPage: "Recevoir les notifications de cette page", + databasePageNotificationScope: + "S’applique à cette page de base de données. Les lignes ont leurs propres paramètres de notification.", + notificationsMutedForPage: "Notifications désactivées pour cette page", + notificationsEnabledForPage: "Notifications activées pour cette page", + notificationPreferenceUpdateFailed: + "Impossible de mettre à jour les préférences de notification", notionSync: "Synchronisation des notions", openInNotion: "Ouvrir dans Notion", orgCanFindAndView: @@ -6727,6 +6891,18 @@ const editorMessagesByLocale = { morePageActions: "Weitere Seitenaktionen", noPagesFound: "Keine Seiten gefunden", notifications: "Benachrichtigungen", + muteNotificationsForPage: + "Benachrichtigungen für diese Seite stummschalten", + receiveNotificationsForPage: + "Benachrichtigungen für diese Seite erhalten", + databasePageNotificationScope: + "Gilt für diese Datenbankseite. Zeilen haben eigene Benachrichtigungseinstellungen.", + notificationsMutedForPage: + "Benachrichtigungen für diese Seite stummgeschaltet", + notificationsEnabledForPage: + "Benachrichtigungen für diese Seite aktiviert", + notificationPreferenceUpdateFailed: + "Benachrichtigungseinstellung konnte nicht aktualisiert werden", notionSync: "Begriffssynchronisierung", openInNotion: "In Notion öffnen", orgCanFindAndView: @@ -7073,6 +7249,13 @@ const editorMessagesByLocale = { morePageActions: "その他のページアクション", noPagesFound: "ページが見つかりませんでした", notifications: "通知", + muteNotificationsForPage: "このページの通知をミュート", + receiveNotificationsForPage: "このページの通知を受け取る", + databasePageNotificationScope: + "このデータベースページに適用されます。各行には独自の通知設定があります。", + notificationsMutedForPage: "このページの通知をミュートしました", + notificationsEnabledForPage: "このページの通知を有効にしました", + notificationPreferenceUpdateFailed: "通知設定を更新できませんでした", notionSync: "Notionの同期", openInNotion: "概念で開く", orgCanFindAndView: "組織内の誰でも検索して表示できます", @@ -7416,6 +7599,14 @@ const editorMessagesByLocale = { morePageActions: "추가 페이지 작업", noPagesFound: "페이지를 찾을 수 없습니다", notifications: "알림", + muteNotificationsForPage: "이 페이지 알림 음소거", + receiveNotificationsForPage: "이 페이지 알림 받기", + databasePageNotificationScope: + "이 데이터베이스 페이지에 적용됩니다. 각 행에는 자체 알림 설정이 있습니다.", + notificationsMutedForPage: "이 페이지의 알림이 음소거되었습니다", + notificationsEnabledForPage: "이 페이지의 알림이 활성화되었습니다", + notificationPreferenceUpdateFailed: + "알림 환경설정을 업데이트할 수 없습니다", notionSync: "노션싱크", openInNotion: "노션에서 열기", orgCanFindAndView: "조직의 모든 사용자가 찾고 볼 수 있습니다.", @@ -7762,6 +7953,14 @@ const editorMessagesByLocale = { morePageActions: "Mais ações de página", noPagesFound: "Nenhuma página encontrada", notifications: "Notificações", + muteNotificationsForPage: "Silenciar notificações desta página", + receiveNotificationsForPage: "Receber notificações desta página", + databasePageNotificationScope: + "Aplica-se a esta página de banco de dados. As linhas têm suas próprias configurações de notificação.", + notificationsMutedForPage: "Notificações silenciadas para esta página", + notificationsEnabledForPage: "Notificações ativadas para esta página", + notificationPreferenceUpdateFailed: + "Não foi possível atualizar a preferência de notificações", notionSync: "Sincronização de noções", openInNotion: "Aberto em noção", orgCanFindAndView: @@ -8103,6 +8302,13 @@ const editorMessagesByLocale = { morePageActions: "अधिक पृष्ठ क्रियाएँ", noPagesFound: "कोई पेज नहीं मिला", notifications: "सूचनाएं", + muteNotificationsForPage: "इस पेज की सूचनाएं म्यूट करें", + receiveNotificationsForPage: "इस पेज की सूचनाएं पाएं", + databasePageNotificationScope: + "यह इस डेटाबेस पेज पर लागू होता है। पंक्तियों की अपनी सूचना सेटिंग होती हैं।", + notificationsMutedForPage: "इस पेज की सूचनाएं म्यूट की गईं", + notificationsEnabledForPage: "इस पेज की सूचनाएं चालू की गईं", + notificationPreferenceUpdateFailed: "सूचना प्राथमिकता अपडेट नहीं की जा सकी", notionSync: "धारणा सिंक", openInNotion: "धारणा में खोलें", orgCanFindAndView: "आपके संगठन का कोई भी व्यक्ति ढूंढ और देख सकता है", @@ -8441,6 +8647,13 @@ const editorMessagesByLocale = { morePageActions: "المزيد من إجراءات الصفحة", noPagesFound: "لم يتم العثور على صفحات", notifications: "الإخطارات", + muteNotificationsForPage: "كتم إشعارات هذه الصفحة", + receiveNotificationsForPage: "تلقي إشعارات هذه الصفحة", + databasePageNotificationScope: + "ينطبق على صفحة قاعدة البيانات هذه. للصفوف إعدادات إشعارات خاصة بها.", + notificationsMutedForPage: "تم كتم إشعارات هذه الصفحة", + notificationsEnabledForPage: "تم تفعيل إشعارات هذه الصفحة", + notificationPreferenceUpdateFailed: "تعذر تحديث تفضيل الإشعارات", notionSync: "مزامنة الفكرة", openInNotion: "فتح في Notion", orgCanFindAndView: "يمكن لأي شخص في مؤسستك البحث والعرض", @@ -8728,7 +8941,16 @@ function mergeMessagesForLocale( export const messagesByLocale = { "en-US": enUS, - "zh-TW": mergeMessagesForLocale("zh-TW", zhTW), + "zh-TW": mergeMessagesForLocale("zh-TW", { + ...zhTW, + database: { + ...zhTW.database, + ...hookMessagesByLocale["zh-TW"], + ...hookRuntimeMessagesByLocale["zh-TW"], + ...databasePolicyMessagesByLocale["zh-TW"], + ...validationMessagesByLocale["zh-TW"], + }, + }), "zh-CN": mergeMessagesForLocale("zh-CN", { database: { ...databaseMessagesByLocale["zh-CN"], diff --git a/templates/content/app/i18n/zh-TW.ts b/templates/content/app/i18n/zh-TW.ts index 8f3c9e00d7..db7c310e9c 100644 --- a/templates/content/app/i18n/zh-TW.ts +++ b/templates/content/app/i18n/zh-TW.ts @@ -1,5 +1,10 @@ import { creativeContextMessagesByLocale } from "@agent-native/creative-context/messages"; +import { databasePolicyMessagesByLocale } from "../database-policy-i18n"; +import { hookMessagesByLocale } from "../hook-i18n"; +import { hookRuntimeMessagesByLocale } from "../hook-runtime-i18n"; +import { validationMessagesByLocale } from "../validation-i18n"; + const messages = { creativeContext: creativeContextMessagesByLocale["zh-TW"], root: { @@ -507,6 +512,13 @@ const messages = { morePageActions: "更多頁面操作", noPagesFound: "沒有找到頁面", notifications: "通知", + muteNotificationsForPage: "將此頁面的通知設為靜音", + receiveNotificationsForPage: "接收此頁面的通知", + databasePageNotificationScope: + "僅適用於此資料庫頁面。各列有自己的通知設定。", + notificationsMutedForPage: "已將此頁面的通知設為靜音", + notificationsEnabledForPage: "已啟用此頁面的通知", + notificationPreferenceUpdateFailed: "無法更新通知偏好設定", notionSync: "概念同步", notionPageUrlOrId: "Notion 頁面 URL 或頁面 ID", open: "開啟", @@ -568,6 +580,10 @@ const messages = { updated: "Actualizado {{date}}", }, database: { + ...hookMessagesByLocale["zh-TW"], + ...hookRuntimeMessagesByLocale["zh-TW"], + ...databasePolicyMessagesByLocale["zh-TW"], + ...validationMessagesByLocale["zh-TW"], formChecked: "已勾選", formChooseOption: "選擇一個選項", formDescription: "填寫以下問題,將頁面新增至此資料庫。", diff --git a/templates/content/app/validation-i18n.ts b/templates/content/app/validation-i18n.ts new file mode 100644 index 0000000000..173927a304 --- /dev/null +++ b/templates/content/app/validation-i18n.ts @@ -0,0 +1,304 @@ +const english = { + readiness: "Validation", + noReadinessRequirements: "No readiness requirements", + submissionRequirementsConfigured: "Submission requirements", + statusGatesConfigured: "Status gates", + readinessDescription: + "Keep drafts flexible, then require evidence when work is submitted or reaches a meaningful status.", + submissionRequirements: "Required on submission", + submissionRequirementsDescription: + "Forms and agent submissions must include these fields. Manual rows can remain drafts.", + statusGates: "Status gates", + statusGatesDescription: + "Require specific fields before an item can enter a status.", + addStatusGate: "Add gate", + noStatusGates: "No status gates yet.", + statusProperty: "Status property", + chooseStatusProperty: "Choose a Status property", + statusOption: "Resulting status", + chooseStatusOption: "Choose a status", + requiredEvidence: "Required fields", + removeGate: "Remove gate", + applyGate: "Apply gate", + saveReadiness: "Save readiness", + readinessSaved: "Readiness settings saved", + readinessSaveFailed: "Couldn’t save readiness settings", +} as const; + +type ValidationMessages = { [K in keyof typeof english]: string }; +type ValidationLocale = + | "en-US" + | "zh-TW" + | "zh-CN" + | "es-ES" + | "fr-FR" + | "de-DE" + | "ja-JP" + | "ko-KR" + | "pt-BR" + | "hi-IN" + | "ar-SA"; + +export const validationMessagesByLocale: Record< + ValidationLocale, + ValidationMessages +> = { + "en-US": english, + "zh-TW": { + readiness: "就緒條件", + noReadinessRequirements: "沒有就緒條件", + submissionRequirementsConfigured: "提交必填條件", + statusGatesConfigured: "狀態門檻", + readinessDescription: + "草稿可保持彈性;提交工作或進入重要狀態時,再要求完整資料。", + submissionRequirements: "提交時必填", + submissionRequirementsDescription: + "表單與代理提交必須包含這些欄位;手動建立的資料列仍可保留為草稿。", + statusGates: "狀態門檻", + statusGatesDescription: "項目進入特定狀態前,必須填妥指定欄位。", + addStatusGate: "新增門檻", + noStatusGates: "尚無狀態門檻。", + statusProperty: "狀態屬性", + chooseStatusProperty: "選擇狀態屬性", + statusOption: "結果狀態", + chooseStatusOption: "選擇狀態", + requiredEvidence: "必填欄位", + removeGate: "移除門檻", + applyGate: "套用門檻", + saveReadiness: "儲存就緒條件", + readinessSaved: "已儲存就緒條件", + readinessSaveFailed: "無法儲存就緒條件", + }, + "zh-CN": { + readiness: "就绪条件", + noReadinessRequirements: "没有就绪条件", + submissionRequirementsConfigured: "提交必填条件", + statusGatesConfigured: "状态门槛", + readinessDescription: + "草稿可以保持灵活;提交工作或进入重要状态时,再要求完整资料。", + submissionRequirements: "提交时必填", + submissionRequirementsDescription: + "表单和代理提交必须包含这些字段;手动创建的行仍可保留为草稿。", + statusGates: "状态门槛", + statusGatesDescription: "项目进入特定状态前,必须填写指定字段。", + addStatusGate: "添加门槛", + noStatusGates: "尚无状态门槛。", + statusProperty: "状态属性", + chooseStatusProperty: "选择状态属性", + statusOption: "结果状态", + chooseStatusOption: "选择状态", + requiredEvidence: "必填字段", + removeGate: "移除门槛", + applyGate: "应用门槛", + saveReadiness: "保存就绪条件", + readinessSaved: "已保存就绪条件", + readinessSaveFailed: "无法保存就绪条件", + }, + "es-ES": { + readiness: "Preparación", + noReadinessRequirements: "Sin requisitos de preparación", + submissionRequirementsConfigured: "Requisitos de envío", + statusGatesConfigured: "Controles de estado", + readinessDescription: + "Mantén flexibles los borradores y exige pruebas al enviar el trabajo o alcanzar un estado importante.", + submissionRequirements: "Obligatorio al enviar", + submissionRequirementsDescription: + "Los formularios y envíos del agente deben incluir estos campos. Las filas manuales pueden seguir como borradores.", + statusGates: "Controles de estado", + statusGatesDescription: + "Exige campos concretos antes de que un elemento entre en un estado.", + addStatusGate: "Añadir control", + noStatusGates: "Aún no hay controles de estado.", + statusProperty: "Propiedad de estado", + chooseStatusProperty: "Elegir una propiedad Estado", + statusOption: "Estado resultante", + chooseStatusOption: "Elegir un estado", + requiredEvidence: "Campos obligatorios", + removeGate: "Quitar control", + applyGate: "Aplicar control", + saveReadiness: "Guardar preparación", + readinessSaved: "Configuración de preparación guardada", + readinessSaveFailed: "No se pudo guardar la preparación", + }, + "fr-FR": { + readiness: "État de préparation", + noReadinessRequirements: "Aucune condition de préparation", + submissionRequirementsConfigured: "Conditions de soumission", + statusGatesConfigured: "Verrous de statut", + readinessDescription: + "Gardez les brouillons souples, puis exigez des éléments lors de la soumission ou d’un statut important.", + submissionRequirements: "Requis à la soumission", + submissionRequirementsDescription: + "Les formulaires et soumissions de l’agent doivent inclure ces champs. Les lignes manuelles peuvent rester en brouillon.", + statusGates: "Verrous de statut", + statusGatesDescription: + "Exigez certains champs avant qu’un élément puisse atteindre un statut.", + addStatusGate: "Ajouter un verrou", + noStatusGates: "Aucun verrou de statut.", + statusProperty: "Propriété de statut", + chooseStatusProperty: "Choisir une propriété Statut", + statusOption: "Statut obtenu", + chooseStatusOption: "Choisir un statut", + requiredEvidence: "Champs requis", + removeGate: "Retirer le verrou", + applyGate: "Appliquer le verrou", + saveReadiness: "Enregistrer la préparation", + readinessSaved: "Paramètres de préparation enregistrés", + readinessSaveFailed: "Impossible d’enregistrer la préparation", + }, + "de-DE": { + readiness: "Bereitschaft", + noReadinessRequirements: "Keine Bereitschaftsanforderungen", + submissionRequirementsConfigured: "Einreichungsanforderungen", + statusGatesConfigured: "Statussperren", + readinessDescription: + "Entwürfe bleiben flexibel; bei Einreichung oder einem wichtigen Status werden Nachweise verlangt.", + submissionRequirements: "Bei Einreichung erforderlich", + submissionRequirementsDescription: + "Formulare und Agent-Einreichungen müssen diese Felder enthalten. Manuelle Zeilen dürfen Entwürfe bleiben.", + statusGates: "Statussperren", + statusGatesDescription: + "Bestimmte Felder müssen ausgefüllt sein, bevor ein Eintrag einen Status erreicht.", + addStatusGate: "Sperre hinzufügen", + noStatusGates: "Noch keine Statussperren.", + statusProperty: "Statuseigenschaft", + chooseStatusProperty: "Statuseigenschaft auswählen", + statusOption: "Zielstatus", + chooseStatusOption: "Status auswählen", + requiredEvidence: "Pflichtfelder", + removeGate: "Sperre entfernen", + applyGate: "Sperre anwenden", + saveReadiness: "Bereitschaft speichern", + readinessSaved: "Bereitschaftseinstellungen gespeichert", + readinessSaveFailed: "Bereitschaft konnte nicht gespeichert werden", + }, + "ja-JP": { + readiness: "準備条件", + noReadinessRequirements: "準備条件はありません", + submissionRequirementsConfigured: "送信要件", + statusGatesConfigured: "ステータス条件", + readinessDescription: + "下書きは柔軟に保ち、送信時や重要なステータスに進む際に必要情報を求めます。", + submissionRequirements: "送信時の必須項目", + submissionRequirementsDescription: + "フォームとエージェントの送信にはこれらの項目が必要です。手動の行は下書きのままにできます。", + statusGates: "ステータス条件", + statusGatesDescription: + "項目がステータスに進む前に、指定フィールドへの入力を求めます。", + addStatusGate: "条件を追加", + noStatusGates: "ステータス条件はまだありません。", + statusProperty: "ステータスプロパティ", + chooseStatusProperty: "ステータスプロパティを選択", + statusOption: "移行先ステータス", + chooseStatusOption: "ステータスを選択", + requiredEvidence: "必須フィールド", + removeGate: "条件を削除", + applyGate: "条件を適用", + saveReadiness: "準備条件を保存", + readinessSaved: "準備条件を保存しました", + readinessSaveFailed: "準備条件を保存できませんでした", + }, + "ko-KR": { + readiness: "준비 조건", + noReadinessRequirements: "준비 조건 없음", + submissionRequirementsConfigured: "제출 요구사항", + statusGatesConfigured: "상태 조건", + readinessDescription: + "초안은 유연하게 두고, 제출하거나 중요한 상태에 도달할 때 필요한 정보를 요구합니다.", + submissionRequirements: "제출 시 필수", + submissionRequirementsDescription: + "양식과 에이전트 제출에는 이 필드가 필요합니다. 수동 행은 초안으로 남길 수 있습니다.", + statusGates: "상태 조건", + statusGatesDescription: + "항목이 특정 상태로 이동하기 전에 지정 필드를 요구합니다.", + addStatusGate: "조건 추가", + noStatusGates: "아직 상태 조건이 없습니다.", + statusProperty: "상태 속성", + chooseStatusProperty: "상태 속성 선택", + statusOption: "결과 상태", + chooseStatusOption: "상태 선택", + requiredEvidence: "필수 필드", + removeGate: "조건 제거", + applyGate: "조건 적용", + saveReadiness: "준비 조건 저장", + readinessSaved: "준비 조건을 저장했습니다", + readinessSaveFailed: "준비 조건을 저장하지 못했습니다", + }, + "pt-BR": { + readiness: "Prontidão", + noReadinessRequirements: "Sem requisitos de prontidão", + submissionRequirementsConfigured: "Requisitos de envio", + statusGatesConfigured: "Controles de status", + readinessDescription: + "Mantenha os rascunhos flexíveis e exija evidências no envio ou ao atingir um status importante.", + submissionRequirements: "Obrigatório no envio", + submissionRequirementsDescription: + "Formulários e envios do agente devem incluir estes campos. Linhas manuais podem continuar como rascunhos.", + statusGates: "Controles de status", + statusGatesDescription: + "Exija campos específicos antes que um item entre em um status.", + addStatusGate: "Adicionar controle", + noStatusGates: "Ainda não há controles de status.", + statusProperty: "Propriedade de status", + chooseStatusProperty: "Escolher propriedade de Status", + statusOption: "Status resultante", + chooseStatusOption: "Escolher status", + requiredEvidence: "Campos obrigatórios", + removeGate: "Remover controle", + applyGate: "Aplicar controle", + saveReadiness: "Salvar prontidão", + readinessSaved: "Configurações de prontidão salvas", + readinessSaveFailed: "Não foi possível salvar a prontidão", + }, + "hi-IN": { + readiness: "तैयारी", + noReadinessRequirements: "तैयारी की कोई आवश्यकता नहीं", + submissionRequirementsConfigured: "सबमिशन आवश्यकताएँ", + statusGatesConfigured: "स्थिति शर्तें", + readinessDescription: + "ड्राफ्ट को लचीला रखें, फिर सबमिट करते समय या महत्वपूर्ण स्थिति पर प्रमाण माँगें।", + submissionRequirements: "सबमिशन पर ज़रूरी", + submissionRequirementsDescription: + "फ़ॉर्म और एजेंट सबमिशन में ये फ़ील्ड ज़रूरी हैं। मैन्युअल पंक्तियाँ ड्राफ्ट रह सकती हैं।", + statusGates: "स्थिति शर्तें", + statusGatesDescription: + "किसी आइटम को स्थिति में जाने से पहले तय फ़ील्ड भरना ज़रूरी करें।", + addStatusGate: "शर्त जोड़ें", + noStatusGates: "अभी कोई स्थिति शर्त नहीं है।", + statusProperty: "स्थिति प्रॉपर्टी", + chooseStatusProperty: "स्थिति प्रॉपर्टी चुनें", + statusOption: "परिणामी स्थिति", + chooseStatusOption: "स्थिति चुनें", + requiredEvidence: "ज़रूरी फ़ील्ड", + removeGate: "शर्त हटाएँ", + applyGate: "शर्त लागू करें", + saveReadiness: "तैयारी सहेजें", + readinessSaved: "तैयारी सेटिंग सहेजी गई", + readinessSaveFailed: "तैयारी सेटिंग सहेजी नहीं जा सकी", + }, + "ar-SA": { + readiness: "الجاهزية", + noReadinessRequirements: "لا توجد متطلبات جاهزية", + submissionRequirementsConfigured: "متطلبات الإرسال", + statusGatesConfigured: "شروط الحالة", + readinessDescription: + "اترك المسودات مرنة، ثم اطلب الأدلة عند الإرسال أو الوصول إلى حالة مهمة.", + submissionRequirements: "مطلوب عند الإرسال", + submissionRequirementsDescription: + "يجب أن تتضمن النماذج وعمليات إرسال الوكيل هذه الحقول. يمكن أن تبقى الصفوف اليدوية كمسودات.", + statusGates: "شروط الحالة", + statusGatesDescription: "اطلب حقولاً محددة قبل انتقال العنصر إلى حالة معينة.", + addStatusGate: "إضافة شرط", + noStatusGates: "لا توجد شروط حالة بعد.", + statusProperty: "خاصية الحالة", + chooseStatusProperty: "اختر خاصية الحالة", + statusOption: "الحالة الناتجة", + chooseStatusOption: "اختر حالة", + requiredEvidence: "الحقول المطلوبة", + removeGate: "إزالة الشرط", + applyGate: "تطبيق الشرط", + saveReadiness: "حفظ الجاهزية", + readinessSaved: "تم حفظ إعدادات الجاهزية", + readinessSaveFailed: "تعذر حفظ إعدادات الجاهزية", + }, +} as const; diff --git a/templates/content/changelog/2026-07-17-content-databases-now-support-owner-managed-rules-required-f.md b/templates/content/changelog/2026-07-17-content-databases-now-support-owner-managed-rules-required-f.md new file mode 100644 index 0000000000..d288d733a6 --- /dev/null +++ b/templates/content/changelog/2026-07-17-content-databases-now-support-owner-managed-rules-required-f.md @@ -0,0 +1,6 @@ +--- +type: added +date: 2026-07-17 +--- + +Content databases now support owner-managed Rules, required-field gates, and personal notification controls. diff --git a/templates/content/changelog/2026-07-17-database-rules-now-stay-focused-on-rule-creation-and-status-.md b/templates/content/changelog/2026-07-17-database-rules-now-stay-focused-on-rule-creation-and-status-.md new file mode 100644 index 0000000000..2785e1785e --- /dev/null +++ b/templates/content/changelog/2026-07-17-database-rules-now-stay-focused-on-rule-creation-and-status-.md @@ -0,0 +1,6 @@ +--- +type: improved +date: 2026-07-17 +--- + +Database rules now stay focused on rule creation and status, with validation and schema permissions in their own settings. diff --git a/templates/content/changelog/2026-07-17-page-notification-controls-now-live-with-your-inbox-and-deli.md b/templates/content/changelog/2026-07-17-page-notification-controls-now-live-with-your-inbox-and-deli.md new file mode 100644 index 0000000000..b72b3d3a3f --- /dev/null +++ b/templates/content/changelog/2026-07-17-page-notification-controls-now-live-with-your-inbox-and-deli.md @@ -0,0 +1,6 @@ +--- +type: improved +date: 2026-07-17 +--- + +Page notification controls now live with your inbox and delivery settings under the notification bell. diff --git a/templates/content/parity/matrix.md b/templates/content/parity/matrix.md index 5cdfb7a816..af9fc1adac 100644 --- a/templates/content/parity/matrix.md +++ b/templates/content/parity/matrix.md @@ -2,31 +2,32 @@ This generated matrix tracks whether high-value Content UI operations use the same action surface agents can call, or have an explicit exception. Edit `matrix.ts`, then regenerate this file. -| ID | Surface | User-visible action | Status | Actions | UI entrypoints | Durable effect | Exception / gap | Reliability risk | Spine priority | Test coverage | Coverage refs | Eval scenarios | Follow-up | -| -------------------------------------------------- | ----------- | ---------------------------------------------------------------------------------------------- | --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------- | -------------- | ------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------- | ------------------------------ | -| comments.threads | comments | List, add, reply, resolve, reopen, and delete comment threads | action-backed | `add-comment`, `delete-comment`, `list-comments`, `update-comment` | `app/components/editor/CommentsSidebar.tsx`, `app/hooks/use-comments.ts` | Comment threads, replies, anchors, mentions, resolution state, and deletion are stored through comment actions. | - | - | P0 | seeded | - | - | - | -| database.form-submissions | database | Submit public database forms as new rows | action-backed | `submit-content-database-form` | `app/components/editor/database/FormView.tsx` | A validated form submission atomically creates a database row document and its editable property values. | - | - | P0 | covered | `actions/submit-content-database-form.db.test.ts` | - | - | -| database.lifecycle-and-trash | database | Create, soft-delete, restore, list, and inspect content databases | action-backed | `create-content-database`, `create-inline-content-database`, `delete-content-database`, `get-content-database`, `list-content-databases`, `list-trashed-content-databases`, `restore-content-database` | `app/components/editor/SlashCommandMenu.tsx`, `app/hooks/use-content-database.ts`, `app/hooks/use-documents.ts` | Database pages and database records are created, read, soft-deleted, restored, and listed. | - | - | P0 | covered | `actions/content-database-lifecycle.db.test.ts` | `database-source-scope` | - | -| database.private-preview-drafts | database | Persist and reconcile a user's private database-page preview draft | action-backed | `get-preview-document-draft`, `update-preview-document-draft` | `app/components/editor/database/DatabaseView.tsx`, `app/hooks/use-documents.ts` | A user's private preview draft is read, saved, conflict-checked, or deleted without changing the shared database page until the normal save flow applies it. | These per-user editor-state actions are intentionally hidden from agent tools because preview drafts are a private UI recovery mechanism. | - | P1 | covered | `actions/preview-document-draft.db.test.ts` | - | - | -| database.properties-and-view-config | database | Configure properties, values, ordering, and saved views | action-backed | `configure-document-property`, `delete-document-property`, `duplicate-document-property`, `get-content-database-personal-view`, `list-document-properties`, `reorder-document-property`, `set-document-property`, `update-content-database-personal-view`, `update-content-database-view` | `app/components/editor/DocumentProperties.tsx`, `app/components/editor/DocumentDatabase.tsx`, `app/components/editor/database/DatabaseView.tsx`, `app/hooks/use-document-properties.ts` | Property schemas, property values, property order, filters, sorts, grouping, hidden columns, view type, and view settings are stored. | - | - | P0 | covered | `actions/bind-content-database-source-field.db.test.ts`, `actions/content-database-source-actions.test.ts`, `actions/resync-content-database-source.db.test.ts` | `database-source-scope` | - | -| database.rows | database | Add, duplicate, move, open, and delete database rows | action-backed | `add-database-item`, `delete-database-items`, `delete-document`, `duplicate-database-items`, `duplicate-database-item`, `move-database-item`, `set-document-property` | `app/components/editor/DocumentDatabase.tsx`, `app/components/editor/database/DatabaseView.tsx` | Database row backing documents and row ordering are created, duplicated, moved, edited, and deleted. | - | - | P0 | covered | `actions/database-row-batch-actions.db.test.ts`, `parity/__tests__/database-row-batch-reliability.test.ts` | `database-bulk-row-reliability` | - | -| editor.agent-assist-prompts | editor | Ask AI from slash generation or comment context | client-assist | - | `app/components/editor/SlashCommandMenu.tsx`, `app/components/editor/CommentsSidebar.tsx` | No direct durable mutation; the prompt asks the agent to use document actions when it decides to write. | - | - | P1 | none | - | - | - | -| editor.client-formatting-and-insertions | editor | Rich text formatting, selection state, slash block insertion, and copy actions | client-only-ephemeral | - | `app/components/editor/BubbleToolbar.tsx`, `app/components/editor/SlashCommandMenu.tsx`, `app/components/editor/DocumentToolbar.tsx` | - | - | - | P1 | none | - | - | - | -| editor.document-body-and-title | editor | Edit document title, body, icon, image alt text, and precise text | action-backed | `edit-document`, `pull-document`, `set-image-alt-text`, `transcribe-media`, `update-document` | `app/components/editor/DocumentEditor.tsx`, `app/components/editor/DocumentDatabase.tsx`, `app/components/editor/extensions/ImageBlock.tsx`, `app/components/editor/SlashCommandMenu.tsx` | Document content, title, icon, image metadata, and text replacements are saved to the same document source. | - | - | P0 | covered | `actions/content-database-lifecycle.db.test.ts`, `actions/_local-file-documents.test.ts` | `document-search-edit` | - | -| local-files.components-workspace | local-files | Register, list, and write local MDX component workspaces | host-only | `list-local-component-files`, `register-local-component-workspace`, `write-local-component-file` | `app/routes/_app.local-files.tsx`, `actions/register-local-component-workspace.ts`, `actions/list-local-component-files.ts`, `actions/write-local-component-file.ts` | Trusted local component workspace registration and component file reads/writes support local MDX previews. | Workspace registration depends on a trusted Desktop folder path and is intentionally hidden with agentTool: false. | - | P1 | seeded | - | - | Local folder exception/docs PR | -| local-files.host-folder-handles | local-files | Choose, persist, remove, and write trusted local folder handles | host-only | - | `app/routes/_app.local-files.tsx` | Host directory handles and browser/Desktop write permissions are managed outside SQL action state. | Mounted local folders require browser/Desktop host handles that agents cannot safely or portably hold as normal tools. | - | P0 | none | - | - | Local folder exception/docs PR | -| local-files.import-export-mounted-folder | local-files | Import, check, export, push, and remove local folder source files | action-backed | `connect-local-folder-source`, `disconnect-local-folder-source`, `export-content-source`, `import-content-source`, `remove-local-file-source`, `resolve-local-folder-conflict`, `sync-local-folder-source`, `sync-manifest-local-folder-source` | `app/routes/_app.local-files.tsx`, `actions/import-content-source.ts`, `actions/export-content-source.ts` | Local Markdown/MDX source files are imported into Content documents, editable Content documents are exported back to source-friendly files, and imported source entries can be removed without deleting files on disk. | - | - | P0 | covered | `actions/_local-file-documents.test.ts`, `actions/local-folder-source.db.test.ts` | `local-file-source-truth` | - | -| notion.route-backed-document-sync | source-sync | Notion document sync status, link, unlink, pull, push, resolve, create, search, and disconnect | action-backed | `connect-notion-status`, `create-and-link-notion-page`, `disconnect-notion`, `link-notion-page`, `list-notion-links`, `pull-notion-page`, `push-notion-page`, `refresh-notion-sync-status`, `resolve-notion-sync-conflict`, `search-notion-pages`, `sync-notion-comments`, `unlink-notion-page` | `app/hooks/use-notion.ts`, `app/components/editor/DocumentToolbar.tsx`, `app/components/editor/NotionSyncBar.tsx`, `app/components/editor/DocumentEditor.tsx` | Notion connection state, page search, link metadata, and local/remote document body sync state are read or mutated through Content actions. | Notion OAuth auth-url and callback routes remain route-shaped because they initiate and receive browser redirects rather than normal app data mutations. | - | P0 | covered | `parity/__tests__/matrix-route-gap-classify.test.ts` | - | - | -| sharing.document-discoverability-and-export | sharing | Share, hide from search, export, and reveal documents | action-backed | `export-document`, `reveal-local-source-file`, `set-document-discoverability`, `share-local-file-document` | `app/components/editor/DocumentToolbar.tsx`, `app/hooks/use-documents.ts` | Search discoverability, shareable copies, exports, and OS reveal requests are managed through Content actions. | - | - | P0 | covered | `actions/_local-file-documents.test.ts` | `local-file-source-truth` | - | -| sharing.os-reveal-local-source | sharing | Reveal a local source file in the system file manager | host-only | `reveal-local-source-file` | `app/components/editor/DocumentToolbar.tsx`, `actions/reveal-local-source-file.ts` | - | OS reveal depends on trusted local host capabilities and should not spend agent tool surface or imply portable hosted behavior. | - | P2 | seeded | - | - | Local folder exception/docs PR | -| sidebar.chrome-state | sidebar | Collapse sections and resize the sidebar | client-only-ephemeral | - | `app/components/sidebar/DocumentSidebar.tsx`, `app/components/layout/Layout.tsx` | - | - | - | P2 | none | - | - | - | -| sidebar.document-tree-crud | sidebar | Create, delete, move, favorite, list, search, and open pages | action-backed | `create-document`, `clone-creative-context-document`, `delete-document`, `get-document`, `list-documents`, `move-document`, `search-documents`, `update-document` | `app/components/sidebar/DocumentSidebar.tsx`, `app/components/sidebar/DocumentTreeItem.tsx`, `app/hooks/use-documents.ts` | Document tree rows and document metadata are created, updated, deleted, moved, searched, or read. | - | - | P0 | covered | `actions/content-database-lifecycle.db.test.ts`, `actions/_local-file-documents.test.ts` | `document-search-edit` | - | -| sidebar.navigation-and-screen-context | sidebar | Navigate between documents and expose current screen context | action-equivalent | `navigate`, `view-screen` | `app/components/sidebar/DocumentSidebar.tsx`, `actions/navigate.ts`, `actions/view-screen.ts` | Application navigation state is updated or read so the agent can reason about the user's current page/view. | Human navigation is router-local, while agent navigation/screen inspection uses application-state actions to produce the same workspace orientation effect. | - | P1 | seeded | - | - | - | -| source-sync.builder-body-hydration-worker | source-sync | Process queued Builder CMS body hydration work | action-backed | `process-builder-body-hydration` | `app/components/editor/DocumentEditor.tsx`, `app/components/editor/database/DatabaseView.tsx`, `app/hooks/use-content-database.ts` | Queued Builder body hydration entries are processed into readable Content document/database body state. | This action is intentionally hidden from the model with agentTool: false because it is an internal bounded queue worker; agents should use source refresh, review, and execution actions rather than manually driving hydration internals. | - | P0 | covered | `actions/_database-source-utils.test.ts` | - | - | -| source-sync.builder-cms-review-and-write-gates | source-sync | Review, stage, validate, cancel, and execute Builder CMS source writes | action-backed | `cancel-prepared-builder-source-update`, `execute-builder-source-batch`, `execute-builder-source-execution`, `prepare-builder-source-execution`, `prepare-builder-source-review`, `preview-builder-source-review`, `review-content-database-source-change-set`, `set-content-database-source-write-mode`, `stage-builder-source-bulk-update`, `stage-builder-revision`, `validate-builder-source-execution` | `app/components/editor/DocumentDatabase.tsx`, `app/components/editor/database/DatabaseView.tsx`, `app/components/editor/database-sources/BuilderSourceReviewDialog.tsx` | Builder source write mode, staged reviews, pre-dispatch cancellations, validation records, and bounded execution records are created through guarded actions. | - | - | P0 | covered | `actions/builder-source-review-gates.db.test.ts`, `actions/cancel-prepared-builder-source-update.db.test.ts`, `actions/execute-builder-source-execution.test.ts`, `actions/stage-builder-source-bulk-update.db.test.ts` | `builder-source-review-readonly` | - | -| source-sync.builder-documents | source-sync | List, pull, check, and push Builder docs/blog MDX documents | action-backed | `check-builder-doc`, `list-builder-docs`, `pull-builder-doc`, `push-builder-doc` | `actions/list-builder-docs.ts`, `actions/pull-builder-doc.ts`, `actions/check-builder-doc.ts`, `actions/push-builder-doc.ts` | Builder docs/blog entries can be read into Content, checked locally, and pushed through guarded Builder document actions. | - | - | P1 | seeded | - | - | - | -| source-sync.builder-required-field-materialization | source-sync | Add required Builder publishing fields to a connected collection | action-backed | `materialize-builder-required-fields` | `app/components/editor/database/DatabaseView.tsx`, `app/hooks/use-content-database.ts` | Required Builder fields are materialized as editable Content properties in one local mutation. | This bounded safe-model setup action is intentionally hidden from the agent tool list; the visible source settings surface invokes it. | - | P1 | covered | `actions/materialize-builder-required-fields.test.ts` | - | - | -| source-sync.database-source-bindings | source-sync | Attach, inspect, refresh, disconnect, join, and bind database sources | action-backed | `add-content-database-source-field-property`, `attach-content-database-source`, `bind-content-database-source-field`, `change-content-database-source-role`, `disconnect-content-database-source`, `get-content-database-source`, `list-builder-cms-models`, `list-notion-database-sources`, `refresh-content-database-source`, `suggest-source-join-key` | `app/components/editor/DocumentDatabase.tsx`, `app/components/editor/DocumentProperties.tsx`, `app/components/editor/database/DatabaseView.tsx`, `app/hooks/use-content-database.ts` | Mounted database source metadata, fields, source role, join keys, and source-field/property bindings are stored and refreshed. | - | - | P0 | covered | `actions/bind-content-database-source-field.db.test.ts`, `actions/content-database-source-actions.test.ts`, `actions/resync-content-database-source.db.test.ts` | `database-source-scope` | - | -| source-sync.provider-api-and-staged-datasets | source-sync | Inspect provider APIs and stage/query/delete large provider datasets | action-backed | `delete-staged-dataset`, `list-staged-datasets`, `provider-api-catalog`, `provider-api-docs`, `provider-api-request`, `query-staged-dataset` | `actions/provider-api-catalog.ts`, `actions/provider-api-docs.ts`, `actions/provider-api-request.ts`, `actions/query-staged-dataset.ts` | Provider API metadata and staged dataset scratch storage support scoped agent/source analysis. | - | - | P1 | seeded | - | - | - | -| versions.history-and-restore | versions | Open version history and restore a previous document version | action-backed | `list-document-versions`, `restore-document-version` | `app/components/editor/VersionHistoryPanel.tsx`, `app/hooks/use-document-versions.ts` | Document versions are listed and selected versions can restore the document while snapshotting current state. | - | - | P0 | seeded | - | - | - | -| workspace.spaces-and-files-catalog | workspace | Provision and navigate Content spaces through Files and Workspaces | action-backed | `backfill-content-files`, `ensure-content-spaces`, `list-content-spaces` | `app/components/sidebar/DocumentSidebar.tsx`, `app/hooks/use-content-spaces.ts` | Personal and organization spaces, their canonical Files databases, and the personal Workspaces catalog are provisioned and reconciled in SQL. | - | - | P0 | covered | `actions/content-spaces.db.test.ts`, `actions/content-files.db.test.ts` | - | - | +| ID | Surface | User-visible action | Status | Actions | UI entrypoints | Durable effect | Exception / gap | Reliability risk | Spine priority | Test coverage | Coverage refs | Eval scenarios | Follow-up | +| -------------------------------------------------- | ----------- | ---------------------------------------------------------------------------------------------- | --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------- | -------------- | ------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------- | ------------------------------ | +| comments.threads | comments | List, add, reply, resolve, reopen, and delete comment threads | action-backed | `add-comment`, `delete-comment`, `list-comments`, `update-comment` | `app/components/editor/CommentsSidebar.tsx`, `app/hooks/use-comments.ts` | Comment threads, replies, anchors, mentions, resolution state, and deletion are stored through comment actions. | - | - | P0 | seeded | - | - | - | +| database.form-submissions | database | Submit public database forms as new rows | action-backed | `submit-content-database-form` | `app/components/editor/database/FormView.tsx` | A validated form submission atomically creates a database row document and its editable property values. | - | - | P0 | covered | `actions/submit-content-database-form.db.test.ts` | - | - | +| database.lifecycle-and-trash | database | Create, soft-delete, restore, list, and inspect content databases | action-backed | `create-content-database`, `create-inline-content-database`, `delete-content-database`, `get-content-database`, `list-content-databases`, `list-trashed-content-databases`, `restore-content-database` | `app/components/editor/SlashCommandMenu.tsx`, `app/hooks/use-content-database.ts`, `app/hooks/use-documents.ts` | Database pages and database records are created, read, soft-deleted, restored, and listed. | - | - | P0 | covered | `actions/content-database-lifecycle.db.test.ts` | `database-source-scope` | - | +| database.private-preview-drafts | database | Persist and reconcile a user's private database-page preview draft | action-backed | `get-preview-document-draft`, `update-preview-document-draft` | `app/components/editor/database/DatabaseView.tsx`, `app/hooks/use-documents.ts` | A user's private preview draft is read, saved, conflict-checked, or deleted without changing the shared database page until the normal save flow applies it. | These per-user editor-state actions are intentionally hidden from agent tools because preview drafts are a private UI recovery mechanism. | - | P1 | covered | `actions/preview-document-draft.db.test.ts` | - | - | +| database.properties-and-view-config | database | Configure properties, values, ordering, and saved views | action-backed | `configure-document-property`, `delete-document-property`, `duplicate-document-property`, `get-content-database-personal-view`, `list-document-properties`, `reorder-document-property`, `set-document-property`, `update-content-database-personal-view`, `update-content-database-view` | `app/components/editor/DocumentProperties.tsx`, `app/components/editor/DocumentDatabase.tsx`, `app/components/editor/database/DatabaseView.tsx`, `app/hooks/use-document-properties.ts` | Property schemas, property values, property order, filters, sorts, grouping, hidden columns, view type, and view settings are stored. | - | - | P0 | covered | `actions/bind-content-database-source-field.db.test.ts`, `actions/content-database-source-actions.test.ts`, `actions/resync-content-database-source.db.test.ts` | `database-source-scope` | - | +| database.rows | database | Add, duplicate, move, open, and delete database rows | action-backed | `add-database-item`, `delete-database-items`, `delete-document`, `duplicate-database-items`, `duplicate-database-item`, `move-database-item`, `set-document-property` | `app/components/editor/DocumentDatabase.tsx`, `app/components/editor/database/DatabaseView.tsx` | Database row backing documents and row ordering are created, duplicated, moved, edited, and deleted. | - | - | P0 | covered | `actions/database-row-batch-actions.db.test.ts`, `parity/__tests__/database-row-batch-reliability.test.ts` | `database-bulk-row-reliability` | - | +| database.rules-and-notifications | database | Configure deterministic database Rules and inspect notification executions | action-backed | `get-content-hook-runtime-controls`, `get-content-notification-preference`, `list-content-database-hook-executions`, `list-content-database-hooks`, `manage-content-database-hook-execution`, `manage-content-database-hook`, `manage-content-database-policy`, `manage-content-database-validation`, `manage-content-hook-runtime-control`, `manage-content-notification-preference`, `preview-content-database-hook` | `app/components/editor/database/DatabaseHooksPanel.tsx`, `app/components/editor/database/DatabaseView.tsx`, `app/components/layout/Header.tsx`, `app/components/editor/DocumentToolbar.tsx` | Owner-authored deterministic subscriptions evaluate actor-aware committed database events through the shared workflow engine and record notification delivery in the core ledger. | - | - | P0 | covered | `actions/content-database-hooks.db.test.ts`, `app/components/editor/database/DatabaseHooksPanel.layout.test.ts` | - | - | +| editor.agent-assist-prompts | editor | Ask AI from slash generation or comment context | client-assist | - | `app/components/editor/SlashCommandMenu.tsx`, `app/components/editor/CommentsSidebar.tsx` | No direct durable mutation; the prompt asks the agent to use document actions when it decides to write. | - | - | P1 | none | - | - | - | +| editor.client-formatting-and-insertions | editor | Rich text formatting, selection state, slash block insertion, and copy actions | client-only-ephemeral | - | `app/components/editor/BubbleToolbar.tsx`, `app/components/editor/SlashCommandMenu.tsx`, `app/components/editor/DocumentToolbar.tsx` | - | - | - | P1 | none | - | - | - | +| editor.document-body-and-title | editor | Edit document title, body, icon, image alt text, and precise text | action-backed | `edit-document`, `pull-document`, `set-image-alt-text`, `transcribe-media`, `update-document` | `app/components/editor/DocumentEditor.tsx`, `app/components/editor/DocumentDatabase.tsx`, `app/components/editor/extensions/ImageBlock.tsx`, `app/components/editor/SlashCommandMenu.tsx` | Document content, title, icon, image metadata, and text replacements are saved to the same document source. | - | - | P0 | covered | `actions/content-database-lifecycle.db.test.ts`, `actions/_local-file-documents.test.ts` | `document-search-edit` | - | +| local-files.components-workspace | local-files | Register, list, and write local MDX component workspaces | host-only | `list-local-component-files`, `register-local-component-workspace`, `write-local-component-file` | `app/routes/_app.local-files.tsx`, `actions/register-local-component-workspace.ts`, `actions/list-local-component-files.ts`, `actions/write-local-component-file.ts` | Trusted local component workspace registration and component file reads/writes support local MDX previews. | Workspace registration depends on a trusted Desktop folder path and is intentionally hidden with agentTool: false. | - | P1 | seeded | - | - | Local folder exception/docs PR | +| local-files.host-folder-handles | local-files | Choose, persist, remove, and write trusted local folder handles | host-only | - | `app/routes/_app.local-files.tsx` | Host directory handles and browser/Desktop write permissions are managed outside SQL action state. | Mounted local folders require browser/Desktop host handles that agents cannot safely or portably hold as normal tools. | - | P0 | none | - | - | Local folder exception/docs PR | +| local-files.import-export-mounted-folder | local-files | Import, check, export, push, and remove local folder source files | action-backed | `connect-local-folder-source`, `disconnect-local-folder-source`, `export-content-source`, `import-content-source`, `remove-local-file-source`, `resolve-local-folder-conflict`, `sync-local-folder-source`, `sync-manifest-local-folder-source` | `app/routes/_app.local-files.tsx`, `actions/import-content-source.ts`, `actions/export-content-source.ts` | Local Markdown/MDX source files are imported into Content documents, editable Content documents are exported back to source-friendly files, and imported source entries can be removed without deleting files on disk. | - | - | P0 | covered | `actions/_local-file-documents.test.ts`, `actions/local-folder-source.db.test.ts` | `local-file-source-truth` | - | +| notion.route-backed-document-sync | source-sync | Notion document sync status, link, unlink, pull, push, resolve, create, search, and disconnect | action-backed | `connect-notion-status`, `create-and-link-notion-page`, `disconnect-notion`, `link-notion-page`, `list-notion-links`, `pull-notion-page`, `push-notion-page`, `refresh-notion-sync-status`, `resolve-notion-sync-conflict`, `search-notion-pages`, `sync-notion-comments`, `unlink-notion-page` | `app/hooks/use-notion.ts`, `app/components/editor/DocumentToolbar.tsx`, `app/components/editor/NotionSyncBar.tsx`, `app/components/editor/DocumentEditor.tsx` | Notion connection state, page search, link metadata, and local/remote document body sync state are read or mutated through Content actions. | Notion OAuth auth-url and callback routes remain route-shaped because they initiate and receive browser redirects rather than normal app data mutations. | - | P0 | covered | `parity/__tests__/matrix-route-gap-classify.test.ts` | - | - | +| sharing.document-discoverability-and-export | sharing | Share, hide from search, export, and reveal documents | action-backed | `export-document`, `reveal-local-source-file`, `set-document-discoverability`, `share-local-file-document` | `app/components/editor/DocumentToolbar.tsx`, `app/hooks/use-documents.ts` | Search discoverability, shareable copies, exports, and OS reveal requests are managed through Content actions. | - | - | P0 | covered | `actions/_local-file-documents.test.ts` | `local-file-source-truth` | - | +| sharing.os-reveal-local-source | sharing | Reveal a local source file in the system file manager | host-only | `reveal-local-source-file` | `app/components/editor/DocumentToolbar.tsx`, `actions/reveal-local-source-file.ts` | - | OS reveal depends on trusted local host capabilities and should not spend agent tool surface or imply portable hosted behavior. | - | P2 | seeded | - | - | Local folder exception/docs PR | +| sidebar.chrome-state | sidebar | Collapse sections and resize the sidebar | client-only-ephemeral | - | `app/components/sidebar/DocumentSidebar.tsx`, `app/components/layout/Layout.tsx` | - | - | - | P2 | none | - | - | - | +| sidebar.document-tree-crud | sidebar | Create, delete, move, favorite, list, search, and open pages | action-backed | `create-document`, `clone-creative-context-document`, `delete-document`, `get-document`, `list-documents`, `move-document`, `search-documents`, `update-document` | `app/components/sidebar/DocumentSidebar.tsx`, `app/components/sidebar/DocumentTreeItem.tsx`, `app/hooks/use-documents.ts` | Document tree rows and document metadata are created, updated, deleted, moved, searched, or read. | - | - | P0 | covered | `actions/content-database-lifecycle.db.test.ts`, `actions/_local-file-documents.test.ts` | `document-search-edit` | - | +| sidebar.navigation-and-screen-context | sidebar | Navigate between documents and expose current screen context | action-equivalent | `navigate`, `view-screen` | `app/components/sidebar/DocumentSidebar.tsx`, `actions/navigate.ts`, `actions/view-screen.ts` | Application navigation state is updated or read so the agent can reason about the user's current page/view. | Human navigation is router-local, while agent navigation/screen inspection uses application-state actions to produce the same workspace orientation effect. | - | P1 | seeded | - | - | - | +| source-sync.builder-body-hydration-worker | source-sync | Process queued Builder CMS body hydration work | action-backed | `process-builder-body-hydration` | `app/components/editor/DocumentEditor.tsx`, `app/components/editor/database/DatabaseView.tsx`, `app/hooks/use-content-database.ts` | Queued Builder body hydration entries are processed into readable Content document/database body state. | This action is intentionally hidden from the model with agentTool: false because it is an internal bounded queue worker; agents should use source refresh, review, and execution actions rather than manually driving hydration internals. | - | P0 | covered | `actions/_database-source-utils.test.ts` | - | - | +| source-sync.builder-cms-review-and-write-gates | source-sync | Review, stage, validate, cancel, and execute Builder CMS source writes | action-backed | `cancel-prepared-builder-source-update`, `execute-builder-source-batch`, `execute-builder-source-execution`, `prepare-builder-source-execution`, `prepare-builder-source-review`, `preview-builder-source-review`, `review-content-database-source-change-set`, `set-content-database-source-write-mode`, `stage-builder-source-bulk-update`, `stage-builder-revision`, `validate-builder-source-execution` | `app/components/editor/DocumentDatabase.tsx`, `app/components/editor/database/DatabaseView.tsx`, `app/components/editor/database-sources/BuilderSourceReviewDialog.tsx` | Builder source write mode, staged reviews, pre-dispatch cancellations, validation records, and bounded execution records are created through guarded actions. | - | - | P0 | covered | `actions/builder-source-review-gates.db.test.ts`, `actions/cancel-prepared-builder-source-update.db.test.ts`, `actions/execute-builder-source-execution.test.ts`, `actions/stage-builder-source-bulk-update.db.test.ts` | `builder-source-review-readonly` | - | +| source-sync.builder-documents | source-sync | List, pull, check, and push Builder docs/blog MDX documents | action-backed | `check-builder-doc`, `list-builder-docs`, `pull-builder-doc`, `push-builder-doc` | `actions/list-builder-docs.ts`, `actions/pull-builder-doc.ts`, `actions/check-builder-doc.ts`, `actions/push-builder-doc.ts` | Builder docs/blog entries can be read into Content, checked locally, and pushed through guarded Builder document actions. | - | - | P1 | seeded | - | - | - | +| source-sync.builder-required-field-materialization | source-sync | Add required Builder publishing fields to a connected collection | action-backed | `materialize-builder-required-fields` | `app/components/editor/database/DatabaseView.tsx`, `app/hooks/use-content-database.ts` | Required Builder fields are materialized as editable Content properties in one local mutation. | This bounded safe-model setup action is intentionally hidden from the agent tool list; the visible source settings surface invokes it. | - | P1 | covered | `actions/materialize-builder-required-fields.test.ts` | - | - | +| source-sync.database-source-bindings | source-sync | Attach, inspect, refresh, disconnect, join, and bind database sources | action-backed | `add-content-database-source-field-property`, `attach-content-database-source`, `bind-content-database-source-field`, `change-content-database-source-role`, `disconnect-content-database-source`, `get-content-database-source`, `list-builder-cms-models`, `list-notion-database-sources`, `refresh-content-database-source`, `suggest-source-join-key` | `app/components/editor/DocumentDatabase.tsx`, `app/components/editor/DocumentProperties.tsx`, `app/components/editor/database/DatabaseView.tsx`, `app/hooks/use-content-database.ts` | Mounted database source metadata, fields, source role, join keys, and source-field/property bindings are stored and refreshed. | - | - | P0 | covered | `actions/bind-content-database-source-field.db.test.ts`, `actions/content-database-source-actions.test.ts`, `actions/resync-content-database-source.db.test.ts` | `database-source-scope` | - | +| source-sync.provider-api-and-staged-datasets | source-sync | Inspect provider APIs and stage/query/delete large provider datasets | action-backed | `delete-staged-dataset`, `list-staged-datasets`, `provider-api-catalog`, `provider-api-docs`, `provider-api-request`, `query-staged-dataset` | `actions/provider-api-catalog.ts`, `actions/provider-api-docs.ts`, `actions/provider-api-request.ts`, `actions/query-staged-dataset.ts` | Provider API metadata and staged dataset scratch storage support scoped agent/source analysis. | - | - | P1 | seeded | - | - | - | +| versions.history-and-restore | versions | Open version history and restore a previous document version | action-backed | `list-document-versions`, `restore-document-version` | `app/components/editor/VersionHistoryPanel.tsx`, `app/hooks/use-document-versions.ts` | Document versions are listed and selected versions can restore the document while snapshotting current state. | - | - | P0 | seeded | - | - | - | +| workspace.spaces-and-files-catalog | workspace | Provision and navigate Content spaces through Files and Workspaces | action-backed | `backfill-content-files`, `ensure-content-spaces`, `list-content-spaces` | `app/components/sidebar/DocumentSidebar.tsx`, `app/hooks/use-content-spaces.ts` | Personal and organization spaces, their canonical Files databases, and the personal Workspaces catalog are provisioned and reconciled in SQL. | - | - | P0 | covered | `actions/content-spaces.db.test.ts`, `actions/content-files.db.test.ts` | - | - | diff --git a/templates/content/parity/matrix.ts b/templates/content/parity/matrix.ts index 53b1eb57af..dbda993881 100644 --- a/templates/content/parity/matrix.ts +++ b/templates/content/parity/matrix.ts @@ -275,6 +275,45 @@ export const parityMatrix: ParityRow[] = [ followUpPR: null, coverageRefs: ["actions/submit-content-database-form.db.test.ts"], }, + { + id: "database.rules-and-notifications", + surface: "database", + label: + "Configure deterministic database Rules and inspect notification executions", + uiEntrypoints: [ + "app/components/editor/database/DatabaseHooksPanel.tsx", + "app/components/editor/database/DatabaseView.tsx", + "app/components/layout/Header.tsx", + "app/components/editor/DocumentToolbar.tsx", + ], + durableEffect: + "Owner-authored deterministic subscriptions evaluate actor-aware committed database events through the shared workflow engine and record notification delivery in the core ledger.", + uiImplementation: + "Database settings use the same Rule management and execution-history actions available to the agent; the shared core bell renders the resulting inbox entries.", + status: "action-backed", + actions: [ + "get-content-hook-runtime-controls", + "get-content-notification-preference", + "list-content-database-hook-executions", + "list-content-database-hooks", + "manage-content-database-hook-execution", + "manage-content-database-hook", + "manage-content-database-policy", + "manage-content-database-validation", + "manage-content-hook-runtime-control", + "manage-content-notification-preference", + "preview-content-database-hook", + ], + exception: null, + reliabilityRisk: "none", + spinePriority: "P0", + testCoverage: "covered", + followUpPR: null, + coverageRefs: [ + "actions/content-database-hooks.db.test.ts", + "app/components/editor/database/DatabaseHooksPanel.layout.test.ts", + ], + }, { id: "database.rows", surface: "database", diff --git a/templates/content/server/db/content-workflow-schema-ownership.test.ts b/templates/content/server/db/content-workflow-schema-ownership.test.ts new file mode 100644 index 0000000000..99bcaec79a --- /dev/null +++ b/templates/content/server/db/content-workflow-schema-ownership.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; + +import * as schema from "./schema.js"; + +const DELIVERY_TRUTH_COLUMN = + /delivery|delivered|attempt|retry|dead.?letter|errorMessage|error_message/i; + +describe("Content workflow schema ownership", () => { + it("keeps delivery attempts and retry truth in the core workflow ledger", () => { + const contentWorkflowTables = Object.entries(schema).filter(([name]) => + /^content(?:Hook|Notification)/.test(name), + ); + + expect(contentWorkflowTables.length).toBeGreaterThan(0); + for (const [tableName, table] of contentWorkflowTables) { + const localColumns = Object.keys(table as object).filter( + (key) => !key.startsWith("_"), + ); + expect( + localColumns.filter((column) => DELIVERY_TRUTH_COLUMN.test(column)), + `${tableName} must project core delivery truth rather than store it`, + ).toEqual([]); + } + + expect(schema.notificationDeliveryAttempts).toBeDefined(); + expect(schema.workflowEffects).toBeDefined(); + }); +}); diff --git a/templates/content/server/db/mutation-certification-manifest.test.ts b/templates/content/server/db/mutation-certification-manifest.test.ts new file mode 100644 index 0000000000..9e6a228d34 --- /dev/null +++ b/templates/content/server/db/mutation-certification-manifest.test.ts @@ -0,0 +1,160 @@ +import { readdirSync, readFileSync } from "node:fs"; +import { dirname, join, relative } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { describe, expect, it } from "vitest"; + +import { contentHookTriggerAvailability } from "../../actions/_content-database-hooks.js"; +import { + CONTENT_MUTATION_CERTIFICATION_MANIFEST, + CONTENT_MUTATION_RESOURCE_IDENTIFIERS, + CONTENT_MUTATION_TRIGGER_FAMILIES, + type ContentMutationResource, +} from "./mutation-certification-manifest.js"; + +const templateRoot = join(dirname(fileURLToPath(import.meta.url)), "../.."); +const resourceSet = new Set(CONTENT_MUTATION_RESOURCE_IDENTIFIERS); +const physicalNames: Record = { + documents: "documents", + contentDatabases: "content_databases", + contentDatabaseItems: "content_database_items", + documentPropertyDefinitions: "document_property_definitions", + documentPropertyValues: "document_property_values", + documentBlockFieldContents: "document_block_field_contents", + documentVersions: "document_versions", +}; + +function sourceFiles(directory: string): string[] { + return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { + const path = join(directory, entry.name); + if (entry.isDirectory()) return sourceFiles(path); + if ( + !entry.name.endsWith(".ts") || + entry.name.endsWith(".test.ts") || + entry.name.endsWith(".spec.ts") + ) { + return []; + } + return [path]; + }); +} + +export function inventoryContentMutations( + source: string, +): Set { + const found = new Set(); + const aliases = new Map(); + for (const match of source.matchAll( + /\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*schema\.([A-Za-z_$][\w$]*)/g, + )) { + if (resourceSet.has(match[2])) { + aliases.set(match[1], match[2] as ContentMutationResource); + } + } + for (const match of source.matchAll( + /\.(?:insert|update|delete)\s*\(\s*(?:schema\.([A-Za-z_$][\w$]*)|([A-Za-z_$][\w$]*))/g, + )) { + const resource = match[1] + ? (match[1] as ContentMutationResource) + : aliases.get(match[2]); + if (resource && resourceSet.has(resource)) found.add(resource); + } + for (const resource of CONTENT_MUTATION_RESOURCE_IDENTIFIERS) { + const table = physicalNames[resource]; + const rawMutation = new RegExp( + "\\b(?:insert\\s+(?:or\\s+\\w+\\s+)?into|update|delete\\s+from|replace\\s+into)\\s+[`\"']?" + + table + + "[`\"']?\\b", + "i", + ); + if (rawMutation.test(source)) found.add(resource); + } + return found; +} + +describe("Content mutation certification manifest", () => { + it("recognizes direct, helper-aliased, and generic raw-SQL bypasses", () => { + const source = ` + const items = schema.contentDatabaseItems; + await tx.insert(schema.documents).values(row); + await helper.update(items).set(values); + await genericTool.execute(sql\`DELETE FROM document_property_values WHERE id = \${id}\`); + `; + expect([...inventoryContentMutations(source)].sort()).toEqual([ + "contentDatabaseItems", + "documentPropertyValues", + "documents", + ]); + }); + + it("classifies every protected production write path exactly", () => { + const actual = new Map(); + for (const root of ["actions", "server/lib", "server/routes"]) { + for (const path of sourceFiles(join(templateRoot, root))) { + const resources = [ + ...inventoryContentMutations(readFileSync(path, "utf8")), + ].sort(); + if (resources.length) { + actual.set(relative(templateRoot, path), resources); + } + } + } + const expected = new Map( + CONTENT_MUTATION_CERTIFICATION_MANIFEST.map((entry) => [ + entry.path, + [...entry.resources].sort(), + ]), + ); + expect(Object.fromEntries(actual)).toEqual(Object.fromEntries(expected)); + }); + + it("derives trigger availability from the executable coverage manifest", () => { + for (const entry of CONTENT_MUTATION_CERTIFICATION_MANIFEST) { + expect(Object.keys(entry.triggerFamilies).sort()).toEqual( + [...CONTENT_MUTATION_TRIGGER_FAMILIES].sort(), + ); + } + expect( + CONTENT_MUTATION_CERTIFICATION_MANIFEST.some( + (entry) => + entry.triggerFamilies.item_created === "disabled_missing_adapter", + ), + ).toBe(true); + expect( + CONTENT_MUTATION_CERTIFICATION_MANIFEST.some( + (entry) => entry.triggerFamilies.property_changed === "certified", + ), + ).toBe(true); + expect( + CONTENT_MUTATION_CERTIFICATION_MANIFEST.some( + (entry) => + entry.triggerFamilies.property_changed === "disabled_missing_adapter", + ), + ).toBe(false); + expect( + CONTENT_MUTATION_CERTIFICATION_MANIFEST.find( + (entry) => entry.path === "actions/_content-database-item-mutations.ts", + )?.triggerFamilies, + ).toEqual({ + item_created: "excluded_by_definition", + item_submitted: "certified", + property_changed: "excluded_by_definition", + }); + const availability = new Map( + contentHookTriggerAvailability.map((entry) => [entry.kind, entry]), + ); + expect(availability.size).toBe(contentHookTriggerAvailability.length); + expect(availability.get("item_created")).toMatchObject({ + available: false, + }); + expect(availability.get("item_submitted")).toMatchObject({ + available: true, + }); + expect(availability.get("property_changed")).toMatchObject({ + available: true, + }); + expect(availability.get("builder_publication_confirmed")).toMatchObject({ + available: true, + }); + }); +}); diff --git a/templates/content/server/db/mutation-certification-manifest.ts b/templates/content/server/db/mutation-certification-manifest.ts new file mode 100644 index 0000000000..28497ada70 --- /dev/null +++ b/templates/content/server/db/mutation-certification-manifest.ts @@ -0,0 +1,257 @@ +export const CONTENT_MUTATION_RESOURCE_IDENTIFIERS = [ + "documents", + "contentDatabases", + "contentDatabaseItems", + "documentPropertyDefinitions", + "documentPropertyValues", + "documentBlockFieldContents", + "documentVersions", +] as const; + +export type ContentMutationResource = + (typeof CONTENT_MUTATION_RESOURCE_IDENTIFIERS)[number]; + +export const CONTENT_MUTATION_TRIGGER_FAMILIES = [ + "item_created", + "item_submitted", + "property_changed", +] as const; + +export type ContentMutationTriggerFamily = + (typeof CONTENT_MUTATION_TRIGGER_FAMILIES)[number]; + +export type ContentMutationCertification = + | "certified" + | "excluded_by_definition" + | "disabled_missing_adapter"; + +export interface ContentMutationManifestEntry { + path: string; + resources: readonly ContentMutationResource[]; + triggerFamilies: Readonly< + Record + >; +} + +const certified = ( + path: string, + resources: readonly ContentMutationResource[], + families: readonly ContentMutationTriggerFamily[], +): ContentMutationManifestEntry => ({ + path, + resources, + triggerFamilies: { + item_created: families.includes("item_created") + ? "certified" + : "excluded_by_definition", + item_submitted: "excluded_by_definition", + property_changed: families.includes("property_changed") + ? "certified" + : "excluded_by_definition", + }, +}); + +const disabled = ( + path: string, + resources: readonly ContentMutationResource[], + families: readonly ContentMutationTriggerFamily[] = ["item_created"], +): ContentMutationManifestEntry => ({ + path, + resources, + triggerFamilies: { + item_created: families.includes("item_created") + ? "disabled_missing_adapter" + : "excluded_by_definition", + item_submitted: families.includes("item_submitted") + ? "disabled_missing_adapter" + : "excluded_by_definition", + property_changed: families.includes("property_changed") + ? "disabled_missing_adapter" + : "excluded_by_definition", + }, +}); + +const atomicSubmission = ( + path: string, + resources: readonly ContentMutationResource[], +): ContentMutationManifestEntry => ({ + path, + resources, + triggerFamilies: { + item_created: "excluded_by_definition", + item_submitted: "certified", + property_changed: "excluded_by_definition", + }, +}); + +/** + * Executable inventory of production Content write paths. Each entry + * classifies every trigger family independently. A trigger can only + * become available when every relevant mutation path is either certified or + * explicitly outside that trigger's definition. + */ +export const CONTENT_MUTATION_CERTIFICATION_MANIFEST = [ + disabled("actions/_builder-docs-client.ts", ["documents"]), + disabled("actions/_content-database-lifecycle.ts", ["contentDatabases"]), + disabled("actions/_content-files.ts", ["contentDatabaseItems", "documents"]), + disabled("actions/_content-spaces.ts", [ + "contentDatabaseItems", + "contentDatabases", + "documents", + ]), + disabled("actions/_database-row-batch.ts", [ + "contentDatabaseItems", + "documents", + ]), + disabled("actions/_database-source-utils.ts", [ + "contentDatabaseItems", + "contentDatabases", + "documentPropertyDefinitions", + "documentPropertyValues", + "documents", + ]), + disabled("actions/_database-utils.ts", [ + "contentDatabaseItems", + "contentDatabases", + "documentBlockFieldContents", + "documentPropertyDefinitions", + "documentPropertyValues", + ]), + disabled("actions/_property-utils.ts", [ + "contentDatabases", + "documentBlockFieldContents", + "documentPropertyDefinitions", + "documents", + ]), + disabled("actions/add-content-database-source-field-property.ts", [ + "documentPropertyDefinitions", + "documentPropertyValues", + ]), + atomicSubmission("actions/_content-database-item-mutations.ts", [ + "contentDatabaseItems", + "contentDatabases", + "documentBlockFieldContents", + "documentPropertyValues", + "documents", + ]), + disabled("actions/bind-content-database-source-field.ts", [ + "documentPropertyValues", + ]), + disabled("actions/change-content-database-source-role.ts", [ + "contentDatabaseItems", + ]), + disabled("actions/configure-document-property.ts", [ + "documentBlockFieldContents", + "documentPropertyDefinitions", + "documentPropertyValues", + ]), + disabled("actions/create-content-database.ts", [ + "contentDatabases", + "documents", + ]), + certified("actions/create-document.ts", ["documents"], []), + disabled("actions/create-inline-content-database.ts", ["contentDatabases"]), + certified("actions/delete-content-database.ts", ["contentDatabases"], []), + disabled("actions/delete-document-property.ts", [ + "contentDatabases", + "documentBlockFieldContents", + "documentPropertyDefinitions", + "documentPropertyValues", + "documents", + ]), + disabled("actions/delete-document.ts", [ + "contentDatabaseItems", + "contentDatabases", + "documentBlockFieldContents", + "documentPropertyDefinitions", + "documentPropertyValues", + "documentVersions", + "documents", + ]), + disabled("actions/disconnect-local-folder-source.ts", ["documents"]), + certified( + "actions/duplicate-database-item.ts", + ["contentDatabaseItems", "documentPropertyValues", "documents"], + ["item_created"], + ), + certified( + "actions/duplicate-database-items.ts", + ["contentDatabaseItems", "documentPropertyValues", "documents"], + ["item_created"], + ), + disabled("actions/duplicate-document-property.ts", [ + "documentPropertyDefinitions", + "documentPropertyValues", + ]), + certified("actions/edit-document.ts", ["documents"], []), + disabled("actions/import-content-source.ts", [ + "documentVersions", + "documents", + ]), + disabled("actions/manage-content-database-policy.ts", ["contentDatabases"]), + disabled("actions/manage-content-database-validation.ts", [ + "contentDatabases", + ]), + disabled("actions/materialize-builder-required-fields.ts", [ + "documentPropertyDefinitions", + "documentPropertyValues", + ]), + certified( + "actions/move-database-item.ts", + ["contentDatabaseItems", "documents"], + [], + ), + disabled("actions/move-document.ts", ["contentDatabases", "documents"]), + disabled("actions/remove-local-file-source.ts", ["documents"]), + disabled("actions/reorder-document-property.ts", [ + "documentPropertyDefinitions", + ]), + disabled("actions/resolve-local-folder-conflict.ts", [ + "documentVersions", + "documents", + ]), + certified("actions/restore-content-database.ts", ["contentDatabases"], []), + certified( + "actions/restore-document-version.ts", + ["documentVersions", "documents"], + [], + ), + disabled("actions/set-document-discoverability.ts", ["documents"]), + certified( + "actions/set-document-property.ts", + ["documentBlockFieldContents", "documentPropertyValues", "documents"], + ["property_changed"], + ), + disabled("actions/share-local-file-document.ts", ["documents"]), + disabled("actions/stage-builder-source-bulk-update.ts", [ + "documentPropertyValues", + ]), + disabled("actions/sync-local-folder-source.ts", [ + "documentVersions", + "documents", + ]), + disabled("actions/update-content-database-view.ts", ["contentDatabases"]), + disabled("actions/update-document.ts", [ + "contentDatabases", + "documentVersions", + "documents", + ]), + disabled("server/lib/native-creative-context.ts", ["documentVersions"]), + disabled("server/lib/notion-sync.ts", ["documentVersions", "documents"]), +] as const satisfies readonly ContentMutationManifestEntry[]; + +export function contentMutationTriggerCoverage( + family: ContentMutationTriggerFamily, +) { + const missingPaths = CONTENT_MUTATION_CERTIFICATION_MANIFEST.filter( + (entry) => entry.triggerFamilies[family] === "disabled_missing_adapter", + ).map((entry) => entry.path); + const certifiedPaths = CONTENT_MUTATION_CERTIFICATION_MANIFEST.filter( + (entry) => entry.triggerFamilies[family] === "certified", + ).map((entry) => entry.path); + return { + available: missingPaths.length === 0 && certifiedPaths.length > 0, + certifiedPaths, + missingPaths, + }; +} diff --git a/templates/content/server/db/protected-mutation-tables.ts b/templates/content/server/db/protected-mutation-tables.ts new file mode 100644 index 0000000000..be49934e28 --- /dev/null +++ b/templates/content/server/db/protected-mutation-tables.ts @@ -0,0 +1,46 @@ +import { registerProtectedMutationTables } from "@agent-native/core/db"; + +export const CONTENT_PROTECTED_MUTATION_TABLES = [ + "documents", + "document_versions", + "document_preview_drafts", + "document_comments", + "document_shares", + "document_sync_links", + "builder_doc_sidecars", + "document_property_definitions", + "document_property_values", + "document_block_field_contents", + "content_spaces", + "content_space_catalog_items", + "content_databases", + "content_database_items", + "content_database_body_hydration_queue", + "content_database_sources", + "content_database_source_fields", + "content_database_source_rows", + "content_database_source_change_sets", + "content_database_source_change_reviews", + "content_database_source_executions", + "content_database_source_execution_claims", + "content_notification_preferences", + "workflow_events", + "workflow_subscriptions", + "workflow_subscription_versions", + "workflow_executions", + "workflow_scheduled_work", + "workflow_effects", + "notification_delivery_attempts", +] as const; + +let registered = false; + +export function registerContentProtectedMutationTables(): void { + if (registered) return; + registerProtectedMutationTables({ + tables: CONTENT_PROTECTED_MUTATION_TABLES, + guidance: + "Use the corresponding Content action so access checks, collaboration state, and the actor-aware committed-change envelope remain atomic.", + }); + registered = true; +} diff --git a/templates/content/server/db/schema.ts b/templates/content/server/db/schema.ts index b5601fb3ca..3aef8a0e50 100644 --- a/templates/content/server/db/schema.ts +++ b/templates/content/server/db/schema.ts @@ -9,6 +9,17 @@ import { uniqueIndex, } from "@agent-native/core/db/schema"; +export { + notificationDeliveryAttempts, + workflowEffects, + workflowEvents, + workflowExecutions, + workflowScheduledWork, + workflowSequenceCounters, + workflowSubscriptionVersions, + workflowSubscriptions, +} from "@agent-native/core/workflow"; + export const documents = table("documents", { id: text("id").primaryKey(), spaceId: text("space_id"), @@ -425,6 +436,69 @@ export const contentDatabaseSourceExecutionClaims = table( }, ); +export const contentNotificationPreferences = table( + "content_notification_preferences", + { + id: text("id").primaryKey(), + ownerEmail: text("owner_email").notNull(), + orgId: text("org_id").notNull().default(""), + scope: text("scope", { + enum: ["global", "database", "rule", "item"], + }).notNull(), + scopeId: text("scope_id").notNull(), + databaseId: text("database_id"), + subscriptionId: text("subscription_id"), + documentId: text("document_id"), + enabled: integer("enabled", { mode: "boolean" }).notNull(), + createdAt: text("created_at").notNull().default(now()), + updatedAt: text("updated_at").notNull().default(now()), + }, + (preference) => [ + uniqueIndex("content_notification_preferences_scope_unique").on( + preference.ownerEmail, + preference.orgId, + preference.scope, + preference.scopeId, + ), + index("content_notification_preferences_resolution_idx").on( + preference.ownerEmail, + preference.orgId, + preference.databaseId, + preference.subscriptionId, + preference.documentId, + ), + ], +); + +export const contentDatabasePolicies = table( + "content_database_policies", + { + id: text("id").primaryKey(), + databaseId: text("database_id").notNull(), + policyKey: text("policy_key").notNull(), + version: integer("version").notNull(), + enabled: integer("enabled", { mode: "boolean" }).notNull(), + activeAfterSequence: integer("active_after_sequence").notNull(), + ownerEmail: text("owner_email").notNull(), + orgId: text("org_id").notNull().default(""), + createdAt: text("created_at").notNull().default(now()), + }, + (policy) => [ + uniqueIndex("content_database_policies_version_unique").on( + policy.databaseId, + policy.policyKey, + policy.version, + ), + index("content_database_policies_resolution_idx").on( + policy.databaseId, + policy.policyKey, + policy.ownerEmail, + policy.orgId, + policy.activeAfterSequence, + ), + ], +); + export const documentPropertyValues = table("document_property_values", { id: text("id").primaryKey(), ownerEmail: text("owner_email").notNull().default("local@localhost"), diff --git a/templates/content/server/plugins/db.spec.ts b/templates/content/server/plugins/db.spec.ts index eb0cd9b26b..1adf188e1a 100644 --- a/templates/content/server/plugins/db.spec.ts +++ b/templates/content/server/plugins/db.spec.ts @@ -16,6 +16,16 @@ import * as schema from "../db/schema"; const dbTsSource = readFileSync(new URL("./db.ts", import.meta.url), "utf8"); +const CORE_WORKFLOW_TABLE_EXPORTS = new Set([ + "notificationDeliveryAttempts", + "workflowEffects", + "workflowEvents", + "workflowExecutions", + "workflowScheduledWork", + "workflowSubscriptions", + "workflowSubscriptionVersions", +]); + interface DrizzleColumn { name: string; } @@ -45,6 +55,7 @@ function columnsOf(table: DrizzleTable): DrizzleColumn[] { describe("content db migrations cover every schema.ts column", () => { for (const [exportName, exported] of Object.entries(schema)) { + if (CORE_WORKFLOW_TABLE_EXPORTS.has(exportName)) continue; if (!isDrizzleTable(exported)) continue; const columns = columnsOf(exported as DrizzleTable); if (!columns.length) continue; diff --git a/templates/content/server/plugins/db.ts b/templates/content/server/plugins/db.ts index 372ee2308e..3bfa8a4dd2 100644 --- a/templates/content/server/plugins/db.ts +++ b/templates/content/server/plugins/db.ts @@ -3,8 +3,20 @@ import { getDbExec, runMigrations, } from "@agent-native/core/db"; +import { + ensureWorkflowSchema, + registerWorkflowExecutionHandler, + registerScheduledWorkflowHandler, + startWorkflowWakeProcessor, +} from "@agent-native/core/workflow"; +import { registerContentDefaultPersonVirtualRule } from "../../actions/_content-default-person-rule.js"; +import { + executeContentDatabaseHook, + executeScheduledContentDatabaseHook, +} from "../../actions/_content-hook-execution.js"; import { repairUnseededBlocksFields } from "../../actions/_property-utils.js"; +import { registerContentProtectedMutationTables } from "../db/protected-mutation-tables.js"; import * as schema from "../db/schema.js"; /** @@ -25,6 +37,40 @@ function isDrizzleTable(value: unknown): value is object { const schemaTables = Object.values(schema).filter(isDrizzleTable); +let contentWorkflowHandlerRegistered = false; +let sharedWorkflowProcessorStarted = false; +const sharedWorkflowWorkerId = "content-workflow"; + +function registerContentWorkflowHandler() { + if (contentWorkflowHandlerRegistered) return; + registerWorkflowExecutionHandler({ + kind: "deterministic", + domain: "content", + execute: executeContentDatabaseHook, + }); + registerScheduledWorkflowHandler({ + workType: "content_hook_timing", + execute: executeScheduledContentDatabaseHook, + }); + contentWorkflowHandlerRegistered = true; +} + +function startSharedWorkflowProcessor() { + if (sharedWorkflowProcessorStarted) return; + sharedWorkflowProcessorStarted = true; + startWorkflowWakeProcessor({ + workerId: sharedWorkflowWorkerId, + maxPerWake: 25, + wakeDelayMs: 1_000, + onError: (error) => { + console.warn( + "[workflow] shared claim drain failed:", + error instanceof Error ? error.message : error, + ); + }, + }); +} + function scheduleBlocksRepairRetry(attempt = 1): void { const delayMs = Math.min(30_000 * attempt, 5 * 60_000); const timeout = setTimeout(() => { @@ -847,6 +893,50 @@ const runContentMigrations = runMigrations( CREATE UNIQUE INDEX IF NOT EXISTS content_database_items_database_document_unique ON content_database_items (database_id, document_id)`, }, + { + version: 75, + name: "content-notification-preferences", + sql: `CREATE TABLE IF NOT EXISTS content_notification_preferences ( + id TEXT PRIMARY KEY, + owner_email TEXT NOT NULL, + org_id TEXT NOT NULL DEFAULT '', + scope TEXT NOT NULL, + scope_id TEXT NOT NULL, + database_id TEXT, + subscription_id TEXT, + document_id TEXT, + enabled INTEGER NOT NULL, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + CREATE UNIQUE INDEX IF NOT EXISTS content_notification_preferences_scope_unique + ON content_notification_preferences (owner_email, org_id, scope, scope_id); + CREATE INDEX IF NOT EXISTS content_notification_preferences_resolution_idx + ON content_notification_preferences ( + owner_email, org_id, database_id, subscription_id, document_id + )`, + }, + { + version: 76, + name: "content-database-policies", + sql: `CREATE TABLE IF NOT EXISTS content_database_policies ( + id TEXT PRIMARY KEY, + database_id TEXT NOT NULL, + policy_key TEXT NOT NULL, + version INTEGER NOT NULL, + enabled INTEGER NOT NULL, + active_after_sequence INTEGER NOT NULL, + owner_email TEXT NOT NULL, + org_id TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + CREATE UNIQUE INDEX IF NOT EXISTS content_database_policies_version_unique + ON content_database_policies (database_id, policy_key, version); + CREATE INDEX IF NOT EXISTS content_database_policies_resolution_idx + ON content_database_policies ( + database_id, policy_key, owner_email, org_id, active_after_sequence + )`, + }, ], { table: "content_migrations" }, ); @@ -889,8 +979,13 @@ const runContentSourceMigrations = runMigrations( export default async function contentDatabasePlugin( nitroApp: Parameters[0], ) { + registerContentProtectedMutationTables(); + registerContentWorkflowHandler(); await runContentMigrations(nitroApp); await runContentSourceMigrations(nitroApp); + await ensureWorkflowSchema(); + await registerContentDefaultPersonVirtualRule(); + startSharedWorkflowProcessor(); try { const summary = await ensureAdditiveColumns({ db: getDbExec(), diff --git a/templates/content/server/routes/api/comments/[id].delete.ts b/templates/content/server/routes/api/comments/[id].delete.ts index 2199f7791d..5b3445da80 100644 --- a/templates/content/server/routes/api/comments/[id].delete.ts +++ b/templates/content/server/routes/api/comments/[id].delete.ts @@ -1,69 +1,39 @@ import { getSession, runWithRequestContext } from "@agent-native/core/server"; -import { assertAccess, ForbiddenError } from "@agent-native/core/sharing"; -import { and, eq } from "drizzle-orm"; -import { defineEventHandler, setResponseStatus, getRouterParam } from "h3"; +import { ForbiddenError } from "@agent-native/core/sharing"; +import { defineEventHandler, getRouterParam, setResponseStatus } from "h3"; -import { getDb, schema } from "../../../db/index.js"; +import deleteComment from "../../../../actions/delete-comment.js"; -/** - * DELETE /api/comments/:id - * Delete a single comment. - */ export default defineEventHandler(async (event) => { const id = getRouterParam(event, "id"); if (!id) { setResponseStatus(event, 400); return { error: "id required" }; } - const session = await getSession(event).catch(() => null); if (!session?.email) { setResponseStatus(event, 401); return { error: "Unauthenticated" }; } - - return runWithRequestContext( - { userEmail: session.email, orgId: session.orgId }, - async () => { - const db = getDb(); - const [comment] = await db - .select({ - documentId: schema.documentComments.documentId, - authorEmail: schema.documentComments.authorEmail, - }) - .from(schema.documentComments) - .where(eq(schema.documentComments.id, id)) - .limit(1); - - if (!comment) { - setResponseStatus(event, 404); - return { error: "Comment not found" }; - } - - try { - if (comment.authorEmail === session.email) { - await assertAccess("document", comment.documentId, "viewer"); - } else { - await assertAccess("document", comment.documentId, "editor"); - } - } catch (err) { - if (err instanceof ForbiddenError) { - setResponseStatus(event, 404); - return { error: "Comment not found" }; - } - throw err; - } - - await db - .delete(schema.documentComments) - .where( - and( - eq(schema.documentComments.id, id), - eq(schema.documentComments.documentId, comment.documentId), - ), - ); - - return { ok: true }; - }, - ); + try { + return await runWithRequestContext( + { userEmail: session.email, orgId: session.orgId }, + () => + deleteComment.run( + { id }, + { + caller: "http", + actionName: "delete-comment", + userEmail: session.email, + orgId: session.orgId, + }, + ), + ); + } catch (error) { + if (error instanceof ForbiddenError) { + setResponseStatus(event, 404); + return { error: "Comment not found" }; + } + throw error; + } }); diff --git a/templates/content/server/routes/api/comments/[id].patch.ts b/templates/content/server/routes/api/comments/[id].patch.ts index 825e62e807..dc3280a8f2 100644 --- a/templates/content/server/routes/api/comments/[id].patch.ts +++ b/templates/content/server/routes/api/comments/[id].patch.ts @@ -3,107 +3,45 @@ import { readBody, runWithRequestContext, } from "@agent-native/core/server"; -import { assertAccess, ForbiddenError } from "@agent-native/core/sharing"; -import { and, eq } from "drizzle-orm"; -import { defineEventHandler, setResponseStatus, getRouterParam } from "h3"; +import { ForbiddenError } from "@agent-native/core/sharing"; +import { defineEventHandler, getRouterParam, setResponseStatus } from "h3"; -import { getDb, schema } from "../../../db/index.js"; +import updateComment from "../../../../actions/update-comment.js"; -/** - * PATCH /api/comments/:id - * Update a comment (resolve, edit content). - */ export default defineEventHandler(async (event) => { const id = getRouterParam(event, "id"); if (!id) { setResponseStatus(event, 400); return { error: "id required" }; } - - const body = await readBody(event); - const { content, resolved } = body as { - content?: string; - resolved?: boolean; - }; - const session = await getSession(event).catch(() => null); if (!session?.email) { setResponseStatus(event, 401); return { error: "Unauthenticated" }; } - - return runWithRequestContext( - { userEmail: session.email, orgId: session.orgId }, - async () => { - const db = getDb(); - const [comment] = await db - .select({ - documentId: schema.documentComments.documentId, - threadId: schema.documentComments.threadId, - authorEmail: schema.documentComments.authorEmail, - }) - .from(schema.documentComments) - .where(eq(schema.documentComments.id, id)) - .limit(1); - - if (!comment) { - setResponseStatus(event, 404); - return { error: "Comment not found" }; - } - - try { - // Resolving or reopening changes state for the whole thread (every - // author's comments), not just the caller's own row, so it always - // requires editor access — matching the update-comment action. - if ( - resolved === true || - resolved === false || - comment.authorEmail !== session.email - ) { - await assertAccess("document", comment.documentId, "editor"); - } else { - await assertAccess("document", comment.documentId, "viewer"); - } - } catch (err) { - if (err instanceof ForbiddenError) { - setResponseStatus(event, 404); - return { error: "Comment not found" }; - } - throw err; - } - - const updatedAt = new Date().toISOString(); - - if (resolved === true || resolved === false) { - // Resolving or reopening applies to every comment in the thread, not - // just the target row — matching the update-comment action. - await db - .update(schema.documentComments) - .set({ resolved: resolved ? 1 : 0, updatedAt }) - .where( - and( - eq(schema.documentComments.documentId, comment.documentId), - eq(schema.documentComments.threadId, comment.threadId), - ), - ); - return { ok: true, resolved }; - } - - if (content === undefined) { - return { ok: true }; - } - - await db - .update(schema.documentComments) - .set({ content, updatedAt }) - .where( - and( - eq(schema.documentComments.id, id), - eq(schema.documentComments.documentId, comment.documentId), - ), - ); - - return { ok: true }; - }, - ); + const body = (await readBody(event)) as { + content?: string; + resolved?: boolean; + }; + try { + return await runWithRequestContext( + { userEmail: session.email, orgId: session.orgId }, + () => + updateComment.run( + { id, content: body.content, resolved: body.resolved }, + { + caller: "http", + actionName: "update-comment", + userEmail: session.email, + orgId: session.orgId, + }, + ), + ); + } catch (error) { + if (error instanceof ForbiddenError) { + setResponseStatus(event, 404); + return { error: "Comment not found" }; + } + throw error; + } }); diff --git a/templates/content/server/routes/api/documents/[id]/move.patch.ts b/templates/content/server/routes/api/documents/[id]/move.patch.ts index 53305d4803..59fd6d90b1 100644 --- a/templates/content/server/routes/api/documents/[id]/move.patch.ts +++ b/templates/content/server/routes/api/documents/[id]/move.patch.ts @@ -3,181 +3,34 @@ import { readBody, runWithRequestContext, } from "@agent-native/core/server"; -import { assertAccess } from "@agent-native/core/sharing"; -import { and, eq, sql } from "drizzle-orm"; -import { defineEventHandler, createError } from "h3"; +import { createError, defineEventHandler, getRouterParam } from "h3"; -import { - documentsPositionScope, - withPositionLock, -} from "../../../../../actions/_position-utils.js"; -import { getDb } from "../../../../db/index.js"; -import { schema } from "../../../../db/index.js"; -import { - parseDocumentFavorite, - parseDocumentHideFromSearch, -} from "../../../../lib/documents.js"; - -async function assertParentIsNotDescendant({ - db, - ownerEmail, - id, - parentId, -}: { - db: ReturnType; - ownerEmail: string; - id: string; - parentId: string | null | undefined; -}) { - if (!parentId) return; - const queue = [id]; - const visited = new Set(); - - while (queue.length > 0) { - const currentId = queue.shift()!; - if (visited.has(currentId)) continue; - visited.add(currentId); - - const children = await db - .select({ id: schema.documents.id }) - .from(schema.documents) - .where( - and( - eq(schema.documents.ownerEmail, ownerEmail), - eq(schema.documents.parentId, currentId), - ), - ); - - for (const child of children) { - if (child.id === parentId) { - throw createError({ - statusCode: 400, - statusMessage: "A document cannot be moved under one of its children", - }); - } - queue.push(child.id); - } - } -} +import moveDocument from "../../../../../actions/move-document.js"; export default defineEventHandler(async (event) => { - const id = event.context.params!.id; - const body = await readBody(event); + const id = getRouterParam(event, "id"); + if (!id) { + throw createError({ statusCode: 400, statusMessage: "id required" }); + } const session = await getSession(event).catch(() => null); if (!session?.email) { throw createError({ statusCode: 401, statusMessage: "Unauthenticated" }); } - + const body = (await readBody(event)) as { + parentId?: string | null; + position?: number; + }; return runWithRequestContext( { userEmail: session.email, orgId: session.orgId }, - async () => { - const access = await assertAccess("document", id, "editor"); - const ownerEmail = access.resource.ownerEmail as string; - const db = getDb(); - - const existing = access.resource; - - if (!existing) { - throw createError({ - statusCode: 404, - statusMessage: "Document not found", - }); - } - - const updates: Record = { - updatedAt: new Date().toISOString(), - }; - - if (body.parentId !== undefined) { - if (body.parentId) { - const parentAccess = await assertAccess( - "document", - body.parentId, - "editor", - ); - if (parentAccess.resource.ownerEmail !== ownerEmail) { - throw createError({ - statusCode: 400, - statusMessage: "Parent document must belong to the same owner", - }); - } - await assertParentIsNotDescendant({ - db, - ownerEmail, - id, - parentId: body.parentId, - }); - } - updates.parentId = body.parentId; - } - - const applyUpdate = () => - db - .update(schema.documents) - .set(updates) - .where( - and( - eq(schema.documents.id, id), - eq(schema.documents.ownerEmail, ownerEmail), - ), - ); - - if (body.position !== undefined) { - updates.position = body.position; - await applyUpdate(); - } else if (body.parentId !== undefined) { - // Auto-assign position at end of new parent's children. Reads - // MAX(position) then writes MAX+1 — serialize the read through the - // write so a concurrent move/create/add targeting the same parent - // can't read the same MAX (see actions/_position-utils.ts). - const parentId = body.parentId; - await withPositionLock( - documentsPositionScope(ownerEmail, parentId), - async () => { - const maxPos = await db - .select({ max: sql`COALESCE(MAX(position), -1)` }) - .from(schema.documents) - .where( - parentId - ? and( - eq(schema.documents.ownerEmail, ownerEmail), - eq(schema.documents.parentId, parentId), - ) - : and( - eq(schema.documents.ownerEmail, ownerEmail), - sql`parent_id IS NULL`, - ), - ); - updates.position = (maxPos[0]?.max ?? -1) + 1; - await applyUpdate(); - }, - ); - } else { - await applyUpdate(); - } - - const [doc] = await db - .select() - .from(schema.documents) - .where( - and( - eq(schema.documents.id, id), - eq(schema.documents.ownerEmail, ownerEmail), - ), - ); - - return { - id: doc.id, - parentId: doc.parentId, - title: doc.title, - content: doc.content, - icon: doc.icon, - position: doc.position, - isFavorite: parseDocumentFavorite(doc.isFavorite), - hideFromSearch: parseDocumentHideFromSearch(doc.hideFromSearch), - createdAt: doc.createdAt, - updatedAt: doc.updatedAt, - }; - }, + () => + moveDocument.run( + { id, parentId: body.parentId, position: body.position }, + { + caller: "http", + actionName: "move-document", + userEmail: session.email, + orgId: session.orgId, + }, + ), ); }); diff --git a/templates/content/server/routes/api/documents/[id]/versions/[versionId].post.ts b/templates/content/server/routes/api/documents/[id]/versions/[versionId].post.ts index 9e26fe08fc..437369c060 100644 --- a/templates/content/server/routes/api/documents/[id]/versions/[versionId].post.ts +++ b/templates/content/server/routes/api/documents/[id]/versions/[versionId].post.ts @@ -1,114 +1,32 @@ import { getSession, runWithRequestContext } from "@agent-native/core/server"; -import { assertAccess } from "@agent-native/core/sharing"; -import { eq, and } from "drizzle-orm"; -import { defineEventHandler, createError } from "h3"; +import { createError, defineEventHandler, getRouterParam } from "h3"; -import { getDb } from "../../../../../db/index.js"; -import { schema } from "../../../../../db/index.js"; -import { - parseDocumentFavorite, - parseDocumentHideFromSearch, -} from "../../../../../lib/documents.js"; - -function nanoid(size = 12): string { - const chars = - "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; - let id = ""; - const bytes = crypto.getRandomValues(new Uint8Array(size)); - for (const byte of bytes) id += chars[byte % chars.length]; - return id; -} +import restoreDocumentVersion from "../../../../../../actions/restore-document-version.js"; export default defineEventHandler(async (event) => { - const { id, versionId } = event.context.params!; + const documentId = getRouterParam(event, "id"); + const versionId = getRouterParam(event, "versionId"); + if (!documentId || !versionId) { + throw createError({ + statusCode: 400, + statusMessage: "document and version IDs are required", + }); + } const session = await getSession(event).catch(() => null); if (!session?.email) { throw createError({ statusCode: 401, statusMessage: "Unauthenticated" }); } - return runWithRequestContext( { userEmail: session.email, orgId: session.orgId }, - async () => { - const access = await assertAccess("document", id, "editor").catch( - () => null, - ); - if (!access) { - throw createError({ - statusCode: 404, - statusMessage: "Document not found", - }); - } - const ownerEmail = access.resource.ownerEmail as string; - const db = getDb(); - - const doc = access.resource; - - const [version] = await db - .select() - .from(schema.documentVersions) - .where( - and( - eq(schema.documentVersions.id, versionId), - eq(schema.documentVersions.documentId, id), - eq(schema.documentVersions.ownerEmail, ownerEmail), - ), - ); - - if (!version) { - throw createError({ - statusCode: 404, - statusMessage: "Version not found", - }); - } - - // Snapshot current state before restoring so the restore is non-destructive - const now = new Date().toISOString(); - await db.insert(schema.documentVersions).values({ - id: nanoid(), - ownerEmail, - documentId: id, - title: doc.title, - content: doc.content, - createdAt: now, - }); - - // Restore the document to the selected version - await db - .update(schema.documents) - .set({ - title: version.title, - content: version.content, - updatedAt: now, - }) - .where( - and( - eq(schema.documents.id, id), - eq(schema.documents.ownerEmail, ownerEmail), - ), - ); - - const [updated] = await db - .select() - .from(schema.documents) - .where( - and( - eq(schema.documents.id, id), - eq(schema.documents.ownerEmail, ownerEmail), - ), - ); - - return { - id: updated.id, - parentId: updated.parentId, - title: updated.title, - content: updated.content, - icon: updated.icon, - position: updated.position, - isFavorite: parseDocumentFavorite(updated.isFavorite), - hideFromSearch: parseDocumentHideFromSearch(updated.hideFromSearch), - createdAt: updated.createdAt, - updatedAt: updated.updatedAt, - }; - }, + () => + restoreDocumentVersion.run( + { documentId, versionId }, + { + caller: "http", + actionName: "restore-document-version", + userEmail: session.email, + orgId: session.orgId, + }, + ), ); }); diff --git a/templates/content/shared/api.ts b/templates/content/shared/api.ts index 8f5ece9d7b..211abc758a 100644 --- a/templates/content/shared/api.ts +++ b/templates/content/shared/api.ts @@ -233,6 +233,313 @@ export interface ContentDatabase { updatedAt: string; } +export type ContentDatabaseHookTrigger = + | { kind: "item_created" } + | { kind: "item_submitted" } + | { + kind: "property_changed"; + propertyId: string; + fromOptionId?: string | null; + toOptionId?: string | null; + } + | { + kind: "builder_publication_confirmed"; + publicationAction?: "publish" | "unpublish" | null; + }; + +export type ContentDatabaseHookEffect = + | { + version: 1; + kind: "notify"; + recipientPersonPropertyId: string; + message?: string; + } + | { + version: 1; + kind: "team_slack"; + webhookKey: string; + title?: string; + message?: string; + } + | { + version: 1; + kind: "webhook"; + urlKey: string; + signatureKey: string; + title?: string; + message?: string; + } + | { + version: 1; + kind: "set_property"; + propertyId: string; + value: unknown; + }; + +export type ContentDatabaseHookCondition = + | { + propertyId: string; + operator: "equals" | "not_equals" | "contains"; + value: unknown; + } + | { + propertyId: string; + operator: "is_empty" | "is_not_empty"; + }; + +export interface ContentDatabaseHookConditions { + mode: "all" | "any"; + clauses: ContentDatabaseHookCondition[]; +} + +export type ContentDatabaseHookTiming = + | { kind: "immediate" } + | { + kind: "delayed" | "debounced" | "escalation"; + delayMinutes: number; + }; + +export interface ContentDatabaseHook { + id: string; + databaseId: string; + name: string; + enabled: boolean; + trigger: ContentDatabaseHookTrigger; + conditions?: ContentDatabaseHookConditions; + effects: ContentDatabaseHookEffect[]; + timing: ContentDatabaseHookTiming; + /** Compatibility alias for single-effect clients. */ + effect: ContentDatabaseHookEffect; + createdBy: string; + createdAt: number; + updatedAt: number; +} + +export interface ListContentDatabaseHooksResponse { + databaseId: string; + hooks: ContentDatabaseHook[]; + triggerAvailability: ContentDatabaseHookTriggerAvailability[]; +} + +export interface ContentDatabaseHookTriggerAvailability { + kind: ContentDatabaseHookTrigger["kind"]; + available: boolean; + reason?: string; +} + +export type ManageContentDatabaseHookRequest = + | { + action: "create"; + databaseId: string; + name: string; + enabled?: boolean; + trigger: ContentDatabaseHookTrigger; + conditions?: ContentDatabaseHookConditions; + effect: ContentDatabaseHookEffect; + timing?: ContentDatabaseHookTiming; + } + | { + action: "create"; + databaseId: string; + name: string; + enabled?: boolean; + trigger: ContentDatabaseHookTrigger; + conditions?: ContentDatabaseHookConditions; + effects: ContentDatabaseHookEffect[]; + timing?: ContentDatabaseHookTiming; + } + | { + action: "update"; + databaseId: string; + hookId: string; + name?: string; + enabled?: boolean; + trigger?: ContentDatabaseHookTrigger; + conditions?: ContentDatabaseHookConditions | null; + effect?: ContentDatabaseHookEffect; + effects?: ContentDatabaseHookEffect[]; + timing?: ContentDatabaseHookTiming; + } + | { + action: "delete"; + databaseId: string; + hookId: string; + }; + +export interface ManageContentDatabaseHookResponse { + databaseId: string; + hook?: ContentDatabaseHook; + deletedHookId?: string; +} + +export interface ContentHookRuntimeControlValue { + evaluatorPaused: boolean; + effectsPaused: boolean; +} + +export interface ContentHookRuntimeControlsResponse { + databaseId: string; + global: ContentHookRuntimeControlValue; + database: ContentHookRuntimeControlValue; + effective: ContentHookRuntimeControlValue; + canManageGlobal: boolean; +} + +export interface ManageContentHookRuntimeControlRequest extends ContentHookRuntimeControlValue { + databaseId: string; + scope: "global" | "database"; +} + +export type ContentDatabaseHookExecutionStatus = + | "pending" + | "running" + | "succeeded" + | "failed" + | "retrying" + | "unknown" + | "acknowledged"; + +export interface ContentDatabaseHookDeliveryAttempt { + id: string; + effectId: string; + notificationId: string | null; + channel: string; + attempt: number; + status: "delivered" | "failed" | "retrying" | "unknown" | "skipped"; + errorMessage: string | null; + createdAt: number; + updatedAt: number; +} + +export interface ContentDatabaseHookEffectExecution { + id: string; + kind: + | ContentDatabaseHookEffect["kind"] + | "notification" + | "content_hook_control" + | "unknown"; + status: + | "delivered" + | "failed" + | "retrying" + | "unknown" + | "skipped" + | "suppressed" + | "coalesced"; + result: Record | null; + error: string | null; + createdAt: number; + updatedAt: number; + deliveryAttempts: ContentDatabaseHookDeliveryAttempt[]; +} + +export interface ContentDatabaseHookExecution { + id: string; + hookId: string; + hookName: string; + subscriptionVersion: number; + eventId: string; + status: ContentDatabaseHookExecutionStatus; + attempts: number; + error: string | null; + createdAt: number; + updatedAt: number; + canRetry: boolean; + canAcknowledge: boolean; + effects: ContentDatabaseHookEffectExecution[]; +} + +export interface ListContentDatabaseHookExecutionsResponse { + databaseId: string; + executions: ContentDatabaseHookExecution[]; +} + +export interface ManageContentDatabaseHookExecutionRequest { + action: "retry" | "acknowledge"; + databaseId: string; + executionId: string; +} + +export interface ManageContentDatabaseHookExecutionResponse { + databaseId: string; + execution: { + id: string; + eventId: string; + subscriptionId: string; + subscriptionVersion: number; + status: ContentDatabaseHookExecutionStatus; + attempt: number; + fenceVersion: number; + leaseExpiresAt: number | null; + errorMessage: string | null; + }; +} + +export interface PreviewContentDatabaseHookRequest { + databaseId: string; + hookId: string; + eventId: string; +} + +export interface PreviewContentDatabaseHookResponse { + databaseId: string; + hookId: string; + preview: { + matched: boolean; + event: { + id: string; + topic: string; + subjectId: string; + actorContext: Record; + causalEventId: string | null; + }; + effects: Array>; + }; +} + +export type ContentNotificationPreferenceTarget = + | { scope: "global" } + | { scope: "database"; databaseId: string } + | { scope: "rule"; databaseId: string; subscriptionId: string } + | { scope: "item"; databaseId: string; documentId: string }; + +export type ManageContentNotificationPreferenceRequest = + | { + action: "set"; + target: ContentNotificationPreferenceTarget; + enabled: boolean; + } + | { action: "remove"; target: ContentNotificationPreferenceTarget }; + +export interface ContentNotificationPreference { + id: string; + ownerEmail: string; + orgId: string; + scope: "global" | "database" | "rule" | "item"; + scopeId: string; + databaseId: string | null; + subscriptionId: string | null; + documentId: string | null; + enabled: boolean; + createdAt: string; + updatedAt: string; +} + +export interface ManageContentNotificationPreferenceResponse { + target: ContentNotificationPreferenceTarget; + preference: ContentNotificationPreference | null; +} + +export interface GetContentNotificationPreferenceResponse { + target: ContentNotificationPreferenceTarget; + documentId: string | null; + preference: { + enabled: boolean; + source: "item" | "rule" | "database" | "global" | "default"; + preferenceId: string | null; + }; +} + export type ContentDatabaseSortDirection = "asc" | "desc"; export interface ContentDatabaseSort { @@ -332,6 +639,46 @@ export interface ContentDatabaseViewConfig { sorts: ContentDatabaseSort[]; filters: ContentDatabaseFilter[]; columnWidths: Record; + schemaLocked?: boolean; + defaultPersonNotificationsEnabled?: boolean; + defaultPersonNotificationsPolicyVersion?: number; + validation?: ContentDatabaseValidationConfig; +} + +export interface ManageContentDatabasePolicyRequest { + databaseId: string; + schemaLocked?: boolean; + defaultPersonNotificationsEnabled?: boolean; +} + +export interface ManageContentDatabasePolicyResponse { + databaseId: string; + schemaLocked: boolean; + defaultPersonNotificationsEnabled: boolean; + defaultPersonNotificationsPolicyVersion: number; +} + +export interface ContentDatabaseStatusRequirement { + statusPropertyId: string; + statusOptionId: string; + requiredPropertyIds: string[]; +} + +export interface ContentDatabaseValidationConfig { + /** Property definition IDs required for atomic form or agent submissions. */ + requiredForSubmission: string[]; + /** Required properties that gate entry into a specific status option. */ + statusRequirements: ContentDatabaseStatusRequirement[]; +} + +export interface ManageContentDatabaseValidationRequest { + databaseId: string; + validation: ContentDatabaseValidationConfig; +} + +export interface ManageContentDatabaseValidationResponse { + databaseId: string; + validation: ContentDatabaseValidationConfig; } export interface ContentDatabasePersonalViewOverrides { @@ -802,6 +1149,7 @@ export interface AddDatabaseItemRequest { databaseId: string; title?: string; propertyValues?: Record; + submissionIntent?: "draft" | "submitted"; } export interface SubmitContentDatabaseFormRequest {