From 9dfc827cdd350d4b030573c86082076cb303bd2b Mon Sep 17 00:00:00 2001 From: Airglow Loop Date: Sun, 25 Oct 2026 22:48:59 +0100 Subject: [PATCH] feat(logs): keep the created record URI on delivery rows and link to it (#13) --- app/islands/DeliveryLog.css.ts | 9 +++- app/islands/DeliveryLog.test.tsx | 22 ++++++++ app/islands/DeliveryLog.tsx | 19 ++++++- app/islands/delivery-status.ts | 7 +++ app/routes/api/automations/[rkey].ts | 1 + .../api/automations/[rkey]/logs.test.ts | 17 +++++++ app/routes/api/automations/[rkey]/logs.ts | 1 + app/routes/u/[handle]/[rkey]/logs.tsx | 1 + lib/actions/delivery.test.ts | 51 +++++++++++++++++++ lib/actions/delivery.ts | 12 +++++ 10 files changed, 138 insertions(+), 2 deletions(-) diff --git a/app/islands/DeliveryLog.css.ts b/app/islands/DeliveryLog.css.ts index e5582ac..944e54e 100644 --- a/app/islands/DeliveryLog.css.ts +++ b/app/islands/DeliveryLog.css.ts @@ -1,7 +1,7 @@ import { style } from "@vanilla-extract/css"; import { vars } from "../styles/theme.css.ts"; import { space } from "../styles/tokens/spacing.ts"; -import { fontSize, fontWeight } from "../styles/tokens/typography.ts"; +import { fontFamily, fontSize, fontWeight } from "../styles/tokens/typography.ts"; import { radii } from "../styles/tokens/radii.ts"; export const wrapper = style({ @@ -146,6 +146,13 @@ export const detailError = style({ color: vars.color.error, }); +/** The created record's AT URI: monospace, and free to break anywhere so a + * long URI wraps instead of widening the table. */ +export const detailLink = style({ + fontFamily: fontFamily.mono, + wordBreak: "break-all", +}); + export const loadMoreWrapper = style({ display: "flex", justifyContent: "center", diff --git a/app/islands/DeliveryLog.test.tsx b/app/islands/DeliveryLog.test.tsx index 3653c3b..c093a84 100644 --- a/app/islands/DeliveryLog.test.tsx +++ b/app/islands/DeliveryLog.test.tsx @@ -19,6 +19,7 @@ function makeLog(overrides: Partial & { id: number }): LogEntry { statusCode: 200, message: null, error: null, + recordUri: null, dryRun: false, attempt: 1, runState: null, @@ -218,6 +219,27 @@ describe("DeliveryLog interactions", () => { expect(container.textContent).not.toContain("Delivered successfully"); }); + it("expands a success row that carries only a created record, linking to it by its AT URI", async () => { + const recordUri = "at://did:plc:alice/app.bsky.graph.listitem/3abc"; + const container = renderLog([makeLog({ id: 2, recordUri }), makeLog({ id: 1 })]); + await vi.advanceTimersByTimeAsync(20); + + const [withRecord, bare] = [...container.querySelectorAll("tbody tr")]; + expect(container.querySelector("a")).toBeNull(); + + // A bare success has nothing to show, so it stays flat. + bare!.dispatchEvent(new MouseEvent("click", { bubbles: true })); + await vi.advanceTimersByTimeAsync(20); + expect(container.querySelectorAll("tbody tr")).toHaveLength(2); + + withRecord!.dispatchEvent(new MouseEvent("click", { bubbles: true })); + await vi.advanceTimersByTimeAsync(20); + const link = container.querySelector("tbody a"); + expect(link?.textContent).toBe(recordUri); + expect(link?.getAttribute("href")).toBe(`https://pdsls.dev/${recordUri}`); + expect(link?.getAttribute("target")).toBe("_blank"); + }); + it("Load more requests logs before the current last row's id and appends the response to the table", async () => { // attempt doubles as a visible marker: unlike `message`/`error`, it // renders in the collapsed row, so we can tell rows apart without expanding. diff --git a/app/islands/DeliveryLog.tsx b/app/islands/DeliveryLog.tsx index c768c21..d12317f 100644 --- a/app/islands/DeliveryLog.tsx +++ b/app/islands/DeliveryLog.tsx @@ -5,6 +5,7 @@ import { groupInProgress, groupLogsByRun, logRowLabel, relativeTime } from "./lo import { deliveryStatusText, deliveryStatusTitle, + recordViewerUrl, rowStepOutcome, type StepOutcome, } from "./delivery-status.js"; @@ -21,6 +22,9 @@ type LogEntry = { statusCode: number | null; message: string | null; error: string | null; + /** AT URI of the record a successful record-producing step created or + * updated; null on every other row. */ + recordUri: string | null; dryRun: boolean; attempt: number; runState: "in-progress" | null; @@ -90,7 +94,7 @@ export default function DeliveryLog({ const renderRow = (log: LogEntry, grouped: boolean) => { const expanded = expandedIds.has(log.id); - const hasDetails = !!(log.message || log.error); + const hasDetails = !!(log.message || log.error || log.recordUri); const toggleExpand = () => setExpandedIds((prev) => { const next = new Set(prev); @@ -133,6 +137,19 @@ export default function DeliveryLog({ Message: {log.message}

)} + {log.recordUri && ( +

+ Record:{" "} + + {log.recordUri} + +

+ )} {log.error && (

Error: {log.error} diff --git a/app/islands/delivery-status.ts b/app/islands/delivery-status.ts index bce26af..627e7b3 100644 --- a/app/islands/delivery-status.ts +++ b/app/islands/delivery-status.ts @@ -104,6 +104,13 @@ export function deliveryStatusTitle(row: DeliveryStatusRow): string | undefined return row.statusCode ? `HTTP ${row.statusCode}` : undefined; } +/** Where a row's created record opens: pdsls, a public record viewer that + * shows the record in whatever repo it was written to. The link text stays + * the AT URI itself, so the owner sees whose repo (DID) it landed in. */ +export function recordViewerUrl(recordUri: string): string { + return `https://pdsls.dev/${recordUri}`; +} + /** Resolves a row's StepOutcome the same way `logRowLabel` resolves its * label: by step id for steps-model rows, by legacy action position for * NULL-stepId rows. */ diff --git a/app/routes/api/automations/[rkey].ts b/app/routes/api/automations/[rkey].ts index 0d54db5..cc37ff9 100644 --- a/app/routes/api/automations/[rkey].ts +++ b/app/routes/api/automations/[rkey].ts @@ -79,6 +79,7 @@ export const GET = createRoute(async (c) => { statusCode: l.statusCode, message: l.message, error: l.error, + recordUri: l.recordUri, dryRun: l.dryRun, attempt: l.attempt, createdAt: l.createdAt.getTime(), diff --git a/app/routes/api/automations/[rkey]/logs.test.ts b/app/routes/api/automations/[rkey]/logs.test.ts index 06e357a..852da8f 100644 --- a/app/routes/api/automations/[rkey]/logs.test.ts +++ b/app/routes/api/automations/[rkey]/logs.test.ts @@ -120,6 +120,23 @@ describe("GET /api/automations/:rkey/logs", () => { } }); + it("returns the created record's URI", async () => { + const recordUri = "at://did:plc:test/app.bsky.graph.listitem/3abc"; + await db.insert(deliveryLogs).values({ + automationUri: TEST_AUTO.uri, + actionIndex: 0, + eventTimeUs: 1700000000000000, + statusCode: 200, + recordUri, + createdAt: new Date(), + attempt: 1, + }); + + const res = await app.request("http://localhost/api/automations/rk1/logs"); + const body = await res.json(); + expect(body.logs[0].recordUri).toBe(recordUri); + }); + it("returns 404 for non-existent automation", async () => { const res = await app.request("http://localhost/api/automations/nonexistent/logs"); expect(res.status).toBe(404); diff --git a/app/routes/api/automations/[rkey]/logs.ts b/app/routes/api/automations/[rkey]/logs.ts index 63d23ee..db4d91f 100644 --- a/app/routes/api/automations/[rkey]/logs.ts +++ b/app/routes/api/automations/[rkey]/logs.ts @@ -31,6 +31,7 @@ export const GET = createRoute(async (c) => { statusCode: l.statusCode, message: l.message, error: l.error, + recordUri: l.recordUri, dryRun: l.dryRun, attempt: l.attempt, runState: l.runState, diff --git a/app/routes/u/[handle]/[rkey]/logs.tsx b/app/routes/u/[handle]/[rkey]/logs.tsx index a5c7922..d7e3f59 100644 --- a/app/routes/u/[handle]/[rkey]/logs.tsx +++ b/app/routes/u/[handle]/[rkey]/logs.tsx @@ -94,6 +94,7 @@ export default createRoute(async (c) => { statusCode: l.statusCode, message: l.message, error: l.error, + recordUri: l.recordUri, dryRun: l.dryRun, attempt: l.attempt, runState: l.runState, diff --git a/lib/actions/delivery.test.ts b/lib/actions/delivery.test.ts index 177b159..4285b14 100644 --- a/lib/actions/delivery.test.ts +++ b/lib/actions/delivery.test.ts @@ -306,3 +306,54 @@ describe("healthy-to-failing transition hooks", () => { await expect(onDeliverySuccess(match, false)).resolves.toBeUndefined(); }); }); + +describe("created record URI on delivery rows", () => { + const RECORD_URI = "at://did:plc:testuser123/app.bsky.graph.listitem/3abc"; + + beforeEach(async () => { + vi.useFakeTimers(); + await db.delete(deliveryLogs); + await db.delete(automations); + await db.insert(automations).values(makeAutomation()); + execute.mockReset(); + mockNotifySystem.mockReset().mockResolvedValue("sent"); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("keeps the URI a successful record-producing action returns", async () => { + execute.mockResolvedValueOnce({ statusCode: 200, uri: RECORD_URI, cid: "bafy" }); + await wrapped(makeMatch(), 0); + + const rows = await db.query.deliveryLogs.findMany(); + expect(rows).toHaveLength(1); + expect(rows[0]!.recordUri).toBe(RECORD_URI); + }); + + it("leaves it null on a success without a record and on a failure", async () => { + execute + .mockResolvedValueOnce({ statusCode: 200 }) + .mockResolvedValueOnce({ statusCode: 400, error: "bad", uri: RECORD_URI }); + await wrapped(makeMatch(), 0); + await wrapped(makeMatch(), 0); + + const rows = await db.query.deliveryLogs.findMany(); + expect(rows.map((r) => r.recordUri)).toEqual([null, null]); + }); + + it("keeps the URI when a retry succeeds", async () => { + execute + .mockResolvedValueOnce({ statusCode: 503, error: "blip" }) + .mockResolvedValueOnce({ statusCode: 200, uri: RECORD_URI }); + await wrapped(makeMatch(), 0); + await vi.advanceTimersByTimeAsync(5_000); + + const rows = await db.query.deliveryLogs.findMany({ orderBy: deliveryLogs.id }); + expect(rows.map((r) => [r.attempt, r.recordUri])).toEqual([ + [1, null], + [2, RECORD_URI], + ]); + }); +}); diff --git a/lib/actions/delivery.ts b/lib/actions/delivery.ts index efe4b57..5f244c7 100644 --- a/lib/actions/delivery.ts +++ b/lib/actions/delivery.ts @@ -97,6 +97,9 @@ export type DeliveryLogEntry = { /** "in-progress" only while the run is parked at a suspending step. Absent * on every other row, which is what "resolved" means. */ runState?: DeliveryRunState | null; + /** AT URI of the record a successful record-producing action created or + * updated. Absent on every other row. */ + recordUri?: string | null; }; export async function logDelivery(entry: DeliveryLogEntry) { @@ -113,6 +116,7 @@ export async function logDelivery(entry: DeliveryLogEntry) { statusCode: entry.statusCode, error: entry.error ?? null, message: entry.message ?? null, + recordUri: entry.recordUri ?? null, dryRun: entry.dryRun ?? false, attempt: entry.attempt, createdAt: new Date(), @@ -183,6 +187,12 @@ export function parsePdsError(err: unknown): { statusCode: number; error: string return { statusCode, error: message }; } +/** The created/updated record's URI to persist for a result: only a + * successful result keeps it, so a failed attempt never links anywhere. */ +export function resultRecordUri(result: ActionResult): string | null { + return isSuccess(result.statusCode) ? (result.uri ?? null) : null; +} + type ExecuteFn = ( match: MatchedEvent, action: A, @@ -224,6 +234,7 @@ export function wrapWithDelivery( error: result.error ?? null, attempt: retryIndex + 2, message: result.message ?? null, + recordUri: resultRecordUri(result), }); if (isSuccess(result.statusCode)) { @@ -283,6 +294,7 @@ export function wrapWithDelivery( error: result.error ?? null, attempt: 1, message: result.message ?? null, + recordUri: resultRecordUri(result), }); // Skip the in-memory setTimeout retry path when the schedule runner is -- 2.54.0