From 9dfc827cdd350d4b030573c86082076cb303bd2b Mon Sep 17 00:00:00 2001
From: Airglow Loop
+ 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