Webhooks for the AT Protocol airglow.run
atproto atprotocol automation webhook
12
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: enable dry run and improve logs

Hugo 3e9749cc 5b7adc05

+204 -372
+5
app/islands/DeliveryLog.css.ts
··· 121 121 borderBlockStart: `1px solid ${vars.color.borderSubtle}`, 122 122 }); 123 123 124 + export const dryRunRow = style({ 125 + fontStyle: "italic", 126 + opacity: 0.75, 127 + }); 128 + 124 129 export const loadMoreWrapper = style({ 125 130 display: "flex", 126 131 justifyContent: "center",
+58 -8
app/islands/DeliveryLog.tsx
··· 7 7 actionIndex: number; 8 8 eventTimeUs: number; 9 9 statusCode: number | null; 10 + message: string | null; 10 11 error: string | null; 12 + dryRun: boolean; 11 13 attempt: number; 12 14 createdAt: number; 13 15 }; ··· 15 17 type Props = { 16 18 rkey: string; 17 19 active: boolean; 20 + dryRun: boolean; 18 21 initialLogs: LogEntry[]; 19 22 hasMore: boolean; 20 23 }; 21 24 22 - export default function DeliveryLog({ rkey, active, initialLogs, hasMore: initialHasMore }: Props) { 25 + function statusBadgeFor(active: boolean, dryRun: boolean) { 26 + if (!active) return { variant: badgeVariant.neutral, label: "Inactive" }; 27 + if (dryRun) return { variant: badgeVariant.warning, label: "Dry Run" }; 28 + return { variant: badgeVariant.success, label: "Active" }; 29 + } 30 + 31 + export default function DeliveryLog({ 32 + rkey, 33 + active, 34 + dryRun, 35 + initialLogs, 36 + hasMore: initialHasMore, 37 + }: Props) { 23 38 const [isActive, setIsActive] = useState(active); 39 + const [isDryRun, setIsDryRun] = useState(dryRun); 24 40 const [logs, setLogs] = useState(initialLogs); 25 41 const [loading, setLoading] = useState(false); 26 42 const [loadingMore, setLoadingMore] = useState(false); ··· 29 45 const logsRef = useRef(logs); 30 46 logsRef.current = logs; 31 47 48 + const updateStatusBadges = useCallback((newActive: boolean, newDryRun: boolean) => { 49 + const badge = statusBadgeFor(newActive, newDryRun); 50 + document.querySelectorAll("[data-automation-status] > span").forEach((el) => { 51 + el.className = badge.variant; 52 + el.textContent = badge.label; 53 + }); 54 + }, []); 55 + 32 56 const toggleActive = useCallback(async () => { 33 57 setLoading(true); 34 58 setError(""); ··· 44 68 } else { 45 69 const newActive = !isActive; 46 70 setIsActive(newActive); 47 - document.querySelectorAll("[data-automation-status] > span").forEach((el) => { 48 - el.className = newActive ? badgeVariant.success : badgeVariant.neutral; 49 - el.textContent = newActive ? "Active" : "Inactive"; 50 - }); 71 + updateStatusBadges(newActive, isDryRun); 51 72 } 52 73 } catch { 53 74 setError("Request failed"); 54 75 } finally { 55 76 setLoading(false); 56 77 } 57 - }, [rkey, isActive]); 78 + }, [rkey, isActive, isDryRun, updateStatusBadges]); 79 + 80 + const toggleDryRun = useCallback(async () => { 81 + setLoading(true); 82 + setError(""); 83 + try { 84 + const res = await fetch(`/api/automations/${rkey}`, { 85 + method: "PATCH", 86 + headers: { "Content-Type": "application/json" }, 87 + body: JSON.stringify({ dryRun: !isDryRun }), 88 + }); 89 + if (!res.ok) { 90 + const data = await res.json(); 91 + setError(data.error || "Failed to update"); 92 + } else { 93 + const newDryRun = !isDryRun; 94 + setIsDryRun(newDryRun); 95 + updateStatusBadges(isActive, newDryRun); 96 + } 97 + } catch { 98 + setError("Request failed"); 99 + } finally { 100 + setLoading(false); 101 + } 102 + }, [rkey, isActive, isDryRun, updateStatusBadges]); 58 103 59 104 const handleDelete = useCallback(async () => { 60 105 if (!confirm("Delete this automation? This cannot be undone.")) return; ··· 114 159 <button type="button" class={s.toggleBtn} onClick={toggleActive} disabled={loading}> 115 160 {isActive ? "Deactivate" : "Activate"} 116 161 </button> 162 + <button type="button" class={s.toggleBtn} onClick={toggleDryRun} disabled={loading}> 163 + {isDryRun ? "Disable Dry Run" : "Enable Dry Run"} 164 + </button> 117 165 <button type="button" class={s.deleteBtn} onClick={handleDelete} disabled={loading}> 118 166 Delete 119 167 </button> ··· 140 188 <th class={s.th}>Action</th> 141 189 <th class={s.th}>Status</th> 142 190 <th class={s.th}>Attempt</th> 191 + <th class={s.th}>Message</th> 143 192 <th class={s.th}>Error</th> 144 193 </tr> 145 194 </thead> 146 195 <tbody> 147 196 {logs.map((log) => ( 148 - <tr key={log.id}> 197 + <tr key={log.id} class={log.dryRun ? s.dryRunRow : undefined}> 149 198 <td class={s.td}>{new Date(log.createdAt).toLocaleString()}</td> 150 199 <td class={s.td}>{log.actionIndex + 1}</td> 151 - <td class={s.td}>{log.statusCode ?? "\u2014"}</td> 200 + <td class={s.td}>{log.dryRun ? "dry run" : (log.statusCode ?? "\u2014")}</td> 152 201 <td class={s.td}>{log.attempt}</td> 202 + <td class={s.td}>{log.message || "\u2014"}</td> 153 203 <td class={s.td}>{log.error || "\u2014"}</td> 154 204 </tr> 155 205 ))}
+7
app/routes/api/automations/[rkey].ts
··· 71 71 fetches: auto.fetches, 72 72 conditions: auto.conditions, 73 73 active: auto.active, 74 + dryRun: auto.dryRun, 74 75 indexedAt: auto.indexedAt.getTime(), 75 76 hasMore, 76 77 deliveryLogs: logs.map((l) => ({ ··· 78 79 actionIndex: l.actionIndex, 79 80 eventTimeUs: l.eventTimeUs, 80 81 statusCode: l.statusCode, 82 + message: l.message, 81 83 error: l.error, 84 + dryRun: l.dryRun, 82 85 attempt: l.attempt, 83 86 createdAt: l.createdAt.getTime(), 84 87 })), ··· 100 103 fetches?: Array<{ name: string; uri: string; comment?: string }>; 101 104 conditions?: Array<{ field: string; operator?: string; value: string; comment?: string }>; 102 105 active?: boolean; 106 + dryRun?: boolean; 103 107 }>(); 104 108 105 109 // Validate name/description if provided ··· 139 143 } 140 144 } 141 145 const active = body.active ?? auto.active; 146 + const dryRun = body.dryRun ?? auto.dryRun; 142 147 143 148 // Resolve fetch steps — full replacement when provided 144 149 let localFetches = auto.fetches; ··· 315 320 })), 316 321 conditions, 317 322 active, 323 + dryRun, 318 324 createdAt, 319 325 }); 320 326 } catch (err) { ··· 334 340 fetches: localFetches, 335 341 conditions, 336 342 active, 343 + dryRun, 337 344 indexedAt: now, 338 345 }) 339 346 .where(eq(automations.uri, auto.uri));
+2
app/routes/api/automations/[rkey]/logs.ts
··· 33 33 actionIndex: l.actionIndex, 34 34 eventTimeUs: l.eventTimeUs, 35 35 statusCode: l.statusCode, 36 + message: l.message, 36 37 error: l.error, 38 + dryRun: l.dryRun, 37 39 attempt: l.attempt, 38 40 createdAt: l.createdAt.getTime(), 39 41 })),
+7 -4
app/routes/dashboard/automations/[rkey].tsx
··· 60 60 actions={ 61 61 <div class={inlineCluster}> 62 62 <span data-automation-status> 63 - <Badge variant={auto.active ? "success" : "neutral"}> 64 - {auto.active ? "Active" : "Inactive"} 63 + <Badge variant={!auto.active ? "neutral" : auto.dryRun ? "warning" : "success"}> 64 + {!auto.active ? "Inactive" : auto.dryRun ? "Dry Run" : "Active"} 65 65 </Badge> 66 66 </span> 67 67 <Button href={`/dashboard/automations/${rkey}/edit`} variant="secondary" size="sm"> ··· 88 88 <dt>Status</dt> 89 89 <dd> 90 90 <span data-automation-status> 91 - <Badge variant={auto.active ? "success" : "neutral"}> 92 - {auto.active ? "Active" : "Inactive"} 91 + <Badge variant={!auto.active ? "neutral" : auto.dryRun ? "warning" : "success"}> 92 + {!auto.active ? "Inactive" : auto.dryRun ? "Dry Run" : "Active"} 93 93 </Badge> 94 94 </span> 95 95 </dd> ··· 192 192 <DeliveryLog 193 193 rkey={auto.rkey} 194 194 active={auto.active} 195 + dryRun={auto.dryRun} 195 196 hasMore={hasMore} 196 197 initialLogs={logs.map((l) => ({ 197 198 id: l.id, 198 199 actionIndex: l.actionIndex, 199 200 eventTimeUs: l.eventTimeUs, 200 201 statusCode: l.statusCode, 202 + message: l.message, 201 203 error: l.error, 204 + dryRun: l.dryRun, 202 205 attempt: l.attempt, 203 206 createdAt: l.createdAt.getTime(), 204 207 }))}
+2 -2
app/routes/dashboard/index.tsx
··· 72 72 {auto.actions.length} action{auto.actions.length !== 1 ? "s" : ""} 73 73 </td> 74 74 <td> 75 - <Badge variant={auto.active ? "success" : "neutral"}> 76 - {auto.active ? "Active" : "Inactive"} 75 + <Badge variant={!auto.active ? "neutral" : auto.dryRun ? "warning" : "success"}> 76 + {!auto.active ? "Inactive" : auto.dryRun ? "Dry Run" : "Active"} 77 77 </Badge> 78 78 </td> 79 79 <td>
+5
lexicons/run/airglow/automation.json
··· 64 64 "description": "Whether this automation is currently active.", 65 65 "default": true 66 66 }, 67 + "dryRun": { 68 + "type": "boolean", 69 + "description": "When true, events are processed but actions are not executed. A log entry is saved describing what would have been done.", 70 + "default": false 71 + }, 67 72 "createdAt": { 68 73 "type": "string", 69 74 "format": "datetime"
+1
lib/automations/pds.ts
··· 62 62 fetches?: PdsFetchStep[]; 63 63 conditions: Array<{ field: string; operator: string; value: string; comment?: string }>; 64 64 active: boolean; 65 + dryRun?: boolean; 65 66 createdAt: string; 66 67 }; 67 68
+4
lib/db/migrations/0000_chunky_sersi.sql lib/db/migrations/0000_majestic_taskmaster.sql
··· 5 5 `name` text NOT NULL, 6 6 `description` text, 7 7 `lexicon` text NOT NULL, 8 + `operation` text DEFAULT 'create' NOT NULL, 8 9 `actions` text DEFAULT '[]' NOT NULL, 9 10 `fetches` text DEFAULT '[]' NOT NULL, 10 11 `conditions` text DEFAULT '[]' NOT NULL, 11 12 `active` integer DEFAULT false NOT NULL, 13 + `dry_run` integer DEFAULT false NOT NULL, 12 14 `indexed_at` integer NOT NULL 13 15 ); 14 16 --> statement-breakpoint ··· 19 21 `event_time_us` integer NOT NULL, 20 22 `payload` text, 21 23 `status_code` integer, 24 + `message` text, 22 25 `error` text, 26 + `dry_run` integer DEFAULT false NOT NULL, 23 27 `attempt` integer DEFAULT 1 NOT NULL, 24 28 `created_at` integer NOT NULL, 25 29 FOREIGN KEY (`automation_uri`) REFERENCES `automations`(`uri`) ON UPDATE no action ON DELETE cascade
-1
lib/db/migrations/0001_brave_war_machine.sql
··· 1 - ALTER TABLE `automations` ADD `operation` text DEFAULT 'create' NOT NULL;
+42 -5
lib/db/migrations/meta/0000_snapshot.json
··· 1 1 { 2 2 "version": "6", 3 3 "dialect": "sqlite", 4 - "id": "053c31b3-dfd6-439c-af03-dd65e031d610", 4 + "id": "01f653dd-7499-4c2a-a4d8-41bdb94cedf5", 5 5 "prevId": "00000000-0000-0000-0000-000000000000", 6 6 "tables": { 7 7 "automations": { ··· 49 49 "notNull": true, 50 50 "autoincrement": false 51 51 }, 52 + "operation": { 53 + "name": "operation", 54 + "type": "text", 55 + "primaryKey": false, 56 + "notNull": true, 57 + "autoincrement": false, 58 + "default": "'create'" 59 + }, 52 60 "actions": { 53 61 "name": "actions", 54 62 "type": "text", ··· 81 89 "autoincrement": false, 82 90 "default": false 83 91 }, 92 + "dry_run": { 93 + "name": "dry_run", 94 + "type": "integer", 95 + "primaryKey": false, 96 + "notNull": true, 97 + "autoincrement": false, 98 + "default": false 99 + }, 84 100 "indexed_at": { 85 101 "name": "indexed_at", 86 102 "type": "integer", ··· 141 157 "notNull": false, 142 158 "autoincrement": false 143 159 }, 160 + "message": { 161 + "name": "message", 162 + "type": "text", 163 + "primaryKey": false, 164 + "notNull": false, 165 + "autoincrement": false 166 + }, 144 167 "error": { 145 168 "name": "error", 146 169 "type": "text", 147 170 "primaryKey": false, 148 171 "notNull": false, 149 172 "autoincrement": false 173 + }, 174 + "dry_run": { 175 + "name": "dry_run", 176 + "type": "integer", 177 + "primaryKey": false, 178 + "notNull": true, 179 + "autoincrement": false, 180 + "default": false 150 181 }, 151 182 "attempt": { 152 183 "name": "attempt", ··· 170 201 "name": "delivery_logs_automation_uri_automations_uri_fk", 171 202 "tableFrom": "delivery_logs", 172 203 "tableTo": "automations", 173 - "columnsFrom": ["automation_uri"], 174 - "columnsTo": ["uri"], 204 + "columnsFrom": [ 205 + "automation_uri" 206 + ], 207 + "columnsTo": [ 208 + "uri" 209 + ], 175 210 "onDelete": "cascade", 176 211 "onUpdate": "no action" 177 212 } ··· 308 343 "indexes": { 309 344 "users_did_unique": { 310 345 "name": "users_did_unique", 311 - "columns": ["did"], 346 + "columns": [ 347 + "did" 348 + ], 312 349 "isUnique": true 313 350 } 314 351 }, ··· 328 365 "internal": { 329 366 "indexes": {} 330 367 } 331 - } 368 + }
-339
lib/db/migrations/meta/0001_snapshot.json
··· 1 - { 2 - "version": "6", 3 - "dialect": "sqlite", 4 - "id": "63ec3970-d9f2-4540-918e-56c550f7f5b9", 5 - "prevId": "053c31b3-dfd6-439c-af03-dd65e031d610", 6 - "tables": { 7 - "automations": { 8 - "name": "automations", 9 - "columns": { 10 - "uri": { 11 - "name": "uri", 12 - "type": "text", 13 - "primaryKey": true, 14 - "notNull": true, 15 - "autoincrement": false 16 - }, 17 - "did": { 18 - "name": "did", 19 - "type": "text", 20 - "primaryKey": false, 21 - "notNull": true, 22 - "autoincrement": false 23 - }, 24 - "rkey": { 25 - "name": "rkey", 26 - "type": "text", 27 - "primaryKey": false, 28 - "notNull": true, 29 - "autoincrement": false 30 - }, 31 - "name": { 32 - "name": "name", 33 - "type": "text", 34 - "primaryKey": false, 35 - "notNull": true, 36 - "autoincrement": false 37 - }, 38 - "description": { 39 - "name": "description", 40 - "type": "text", 41 - "primaryKey": false, 42 - "notNull": false, 43 - "autoincrement": false 44 - }, 45 - "lexicon": { 46 - "name": "lexicon", 47 - "type": "text", 48 - "primaryKey": false, 49 - "notNull": true, 50 - "autoincrement": false 51 - }, 52 - "operation": { 53 - "name": "operation", 54 - "type": "text", 55 - "primaryKey": false, 56 - "notNull": true, 57 - "autoincrement": false, 58 - "default": "'create'" 59 - }, 60 - "actions": { 61 - "name": "actions", 62 - "type": "text", 63 - "primaryKey": false, 64 - "notNull": true, 65 - "autoincrement": false, 66 - "default": "'[]'" 67 - }, 68 - "fetches": { 69 - "name": "fetches", 70 - "type": "text", 71 - "primaryKey": false, 72 - "notNull": true, 73 - "autoincrement": false, 74 - "default": "'[]'" 75 - }, 76 - "conditions": { 77 - "name": "conditions", 78 - "type": "text", 79 - "primaryKey": false, 80 - "notNull": true, 81 - "autoincrement": false, 82 - "default": "'[]'" 83 - }, 84 - "active": { 85 - "name": "active", 86 - "type": "integer", 87 - "primaryKey": false, 88 - "notNull": true, 89 - "autoincrement": false, 90 - "default": false 91 - }, 92 - "indexed_at": { 93 - "name": "indexed_at", 94 - "type": "integer", 95 - "primaryKey": false, 96 - "notNull": true, 97 - "autoincrement": false 98 - } 99 - }, 100 - "indexes": {}, 101 - "foreignKeys": {}, 102 - "compositePrimaryKeys": {}, 103 - "uniqueConstraints": {}, 104 - "checkConstraints": {} 105 - }, 106 - "delivery_logs": { 107 - "name": "delivery_logs", 108 - "columns": { 109 - "id": { 110 - "name": "id", 111 - "type": "integer", 112 - "primaryKey": true, 113 - "notNull": true, 114 - "autoincrement": true 115 - }, 116 - "automation_uri": { 117 - "name": "automation_uri", 118 - "type": "text", 119 - "primaryKey": false, 120 - "notNull": true, 121 - "autoincrement": false 122 - }, 123 - "action_index": { 124 - "name": "action_index", 125 - "type": "integer", 126 - "primaryKey": false, 127 - "notNull": true, 128 - "autoincrement": false, 129 - "default": 0 130 - }, 131 - "event_time_us": { 132 - "name": "event_time_us", 133 - "type": "integer", 134 - "primaryKey": false, 135 - "notNull": true, 136 - "autoincrement": false 137 - }, 138 - "payload": { 139 - "name": "payload", 140 - "type": "text", 141 - "primaryKey": false, 142 - "notNull": false, 143 - "autoincrement": false 144 - }, 145 - "status_code": { 146 - "name": "status_code", 147 - "type": "integer", 148 - "primaryKey": false, 149 - "notNull": false, 150 - "autoincrement": false 151 - }, 152 - "error": { 153 - "name": "error", 154 - "type": "text", 155 - "primaryKey": false, 156 - "notNull": false, 157 - "autoincrement": false 158 - }, 159 - "attempt": { 160 - "name": "attempt", 161 - "type": "integer", 162 - "primaryKey": false, 163 - "notNull": true, 164 - "autoincrement": false, 165 - "default": 1 166 - }, 167 - "created_at": { 168 - "name": "created_at", 169 - "type": "integer", 170 - "primaryKey": false, 171 - "notNull": true, 172 - "autoincrement": false 173 - } 174 - }, 175 - "indexes": {}, 176 - "foreignKeys": { 177 - "delivery_logs_automation_uri_automations_uri_fk": { 178 - "name": "delivery_logs_automation_uri_automations_uri_fk", 179 - "tableFrom": "delivery_logs", 180 - "tableTo": "automations", 181 - "columnsFrom": ["automation_uri"], 182 - "columnsTo": ["uri"], 183 - "onDelete": "cascade", 184 - "onUpdate": "no action" 185 - } 186 - }, 187 - "compositePrimaryKeys": {}, 188 - "uniqueConstraints": {}, 189 - "checkConstraints": {} 190 - }, 191 - "lexicon_cache": { 192 - "name": "lexicon_cache", 193 - "columns": { 194 - "nsid": { 195 - "name": "nsid", 196 - "type": "text", 197 - "primaryKey": true, 198 - "notNull": true, 199 - "autoincrement": false 200 - }, 201 - "schema": { 202 - "name": "schema", 203 - "type": "text", 204 - "primaryKey": false, 205 - "notNull": true, 206 - "autoincrement": false 207 - }, 208 - "fetched_at": { 209 - "name": "fetched_at", 210 - "type": "integer", 211 - "primaryKey": false, 212 - "notNull": true, 213 - "autoincrement": false 214 - } 215 - }, 216 - "indexes": {}, 217 - "foreignKeys": {}, 218 - "compositePrimaryKeys": {}, 219 - "uniqueConstraints": {}, 220 - "checkConstraints": {} 221 - }, 222 - "oauth_sessions": { 223 - "name": "oauth_sessions", 224 - "columns": { 225 - "key": { 226 - "name": "key", 227 - "type": "text", 228 - "primaryKey": true, 229 - "notNull": true, 230 - "autoincrement": false 231 - }, 232 - "value": { 233 - "name": "value", 234 - "type": "text", 235 - "primaryKey": false, 236 - "notNull": true, 237 - "autoincrement": false 238 - }, 239 - "expires_at": { 240 - "name": "expires_at", 241 - "type": "integer", 242 - "primaryKey": false, 243 - "notNull": false, 244 - "autoincrement": false 245 - } 246 - }, 247 - "indexes": {}, 248 - "foreignKeys": {}, 249 - "compositePrimaryKeys": {}, 250 - "uniqueConstraints": {}, 251 - "checkConstraints": {} 252 - }, 253 - "oauth_states": { 254 - "name": "oauth_states", 255 - "columns": { 256 - "key": { 257 - "name": "key", 258 - "type": "text", 259 - "primaryKey": true, 260 - "notNull": true, 261 - "autoincrement": false 262 - }, 263 - "value": { 264 - "name": "value", 265 - "type": "text", 266 - "primaryKey": false, 267 - "notNull": true, 268 - "autoincrement": false 269 - }, 270 - "expires_at": { 271 - "name": "expires_at", 272 - "type": "integer", 273 - "primaryKey": false, 274 - "notNull": false, 275 - "autoincrement": false 276 - } 277 - }, 278 - "indexes": {}, 279 - "foreignKeys": {}, 280 - "compositePrimaryKeys": {}, 281 - "uniqueConstraints": {}, 282 - "checkConstraints": {} 283 - }, 284 - "users": { 285 - "name": "users", 286 - "columns": { 287 - "id": { 288 - "name": "id", 289 - "type": "integer", 290 - "primaryKey": true, 291 - "notNull": true, 292 - "autoincrement": true 293 - }, 294 - "did": { 295 - "name": "did", 296 - "type": "text", 297 - "primaryKey": false, 298 - "notNull": true, 299 - "autoincrement": false 300 - }, 301 - "handle": { 302 - "name": "handle", 303 - "type": "text", 304 - "primaryKey": false, 305 - "notNull": true, 306 - "autoincrement": false 307 - }, 308 - "created_at": { 309 - "name": "created_at", 310 - "type": "integer", 311 - "primaryKey": false, 312 - "notNull": true, 313 - "autoincrement": false 314 - } 315 - }, 316 - "indexes": { 317 - "users_did_unique": { 318 - "name": "users_did_unique", 319 - "columns": ["did"], 320 - "isUnique": true 321 - } 322 - }, 323 - "foreignKeys": {}, 324 - "compositePrimaryKeys": {}, 325 - "uniqueConstraints": {}, 326 - "checkConstraints": {} 327 - } 328 - }, 329 - "views": {}, 330 - "enums": {}, 331 - "_meta": { 332 - "schemas": {}, 333 - "tables": {}, 334 - "columns": {} 335 - }, 336 - "internal": { 337 - "indexes": {} 338 - } 339 - }
+3 -10
lib/db/migrations/meta/_journal.json
··· 5 5 { 6 6 "idx": 0, 7 7 "version": "6", 8 - "when": 1775679586984, 9 - "tag": "0000_chunky_sersi", 10 - "breakpoints": true 11 - }, 12 - { 13 - "idx": 1, 14 - "version": "6", 15 - "when": 1775725820160, 16 - "tag": "0001_brave_war_machine", 8 + "when": 1775733020381, 9 + "tag": "0000_majestic_taskmaster", 17 10 "breakpoints": true 18 11 } 19 12 ] 20 - } 13 + }
+4 -1
lib/db/schema.ts
··· 48 48 .$type<Array<{ field: string; operator: string; value: string; comment?: string }>>() 49 49 .default([]), 50 50 active: integer("active", { mode: "boolean" }).notNull().default(false), 51 + dryRun: integer("dry_run", { mode: "boolean" }).notNull().default(false), 51 52 indexedAt: integer("indexed_at", { mode: "timestamp_ms" }).notNull(), 52 53 }); 53 54 ··· 58 59 .references(() => automations.uri, { onDelete: "cascade" }), 59 60 actionIndex: integer("action_index").notNull().default(0), 60 61 eventTimeUs: integer("event_time_us").notNull(), 61 - payload: text("payload"), // JSON, stored for failed deliveries 62 + payload: text("payload"), // JSON, stored for failed deliveries and dry runs 62 63 statusCode: integer("status_code"), 64 + message: text("message"), 63 65 error: text("error"), 66 + dryRun: integer("dry_run", { mode: "boolean" }).notNull().default(false), 64 67 attempt: integer("attempt").notNull().default(1), 65 68 createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull(), 66 69 });
+63 -1
lib/jetstream/handler.ts
··· 1 - import { dispatch } from "../webhooks/dispatcher.js"; 1 + import { db } from "../db/index.js"; 2 + import { deliveryLogs, type WebhookAction, type RecordAction } from "../db/schema.js"; 3 + import { dispatch, buildPayload } from "../webhooks/dispatcher.js"; 2 4 import { executeAction } from "../actions/executor.js"; 3 5 import { resolveFetches } from "../actions/fetcher.js"; 6 + import { renderTemplate, type FetchContext } from "../actions/template.js"; 4 7 import type { MatchedEvent } from "./consumer.js"; 5 8 6 9 /** Handle a matched Jetstream event: resolve fetches, then dispatch all actions. */ ··· 19 22 } 20 23 } 21 24 25 + if (match.automation.dryRun) { 26 + const fetchErrors = 27 + match.automation.fetches.length > 0 28 + ? match.automation.fetches.filter((f) => !(f.name in fetchContext)).map((f) => f.name) 29 + : []; 30 + for (let i = 0; i < match.automation.actions.length; i++) { 31 + const action = match.automation.actions[i]!; 32 + logDryRun(match, i, action, fetchContext, fetchErrors).catch((err) => { 33 + console.error(`Dry-run log error for action ${i}:`, err); 34 + }); 35 + } 36 + return; 37 + } 38 + 22 39 for (let i = 0; i < match.automation.actions.length; i++) { 23 40 const action = match.automation.actions[i]!; 24 41 const handler = action.$type === "record" ? executeAction : dispatch; ··· 27 44 }); 28 45 } 29 46 } 47 + 48 + async function logDryRun( 49 + match: MatchedEvent, 50 + actionIndex: number, 51 + action: WebhookAction | RecordAction, 52 + fetchContext: FetchContext, 53 + failedFetches: string[], 54 + ) { 55 + let message: string | null = null; 56 + let error: string | null = null; 57 + let payload: string | null = null; 58 + 59 + if (failedFetches.length > 0) { 60 + error = `Fetch failed: ${failedFetches.join(", ")}`; 61 + } else if (action.$type === "webhook") { 62 + message = `Would POST to ${action.callbackUrl}`; 63 + payload = JSON.stringify(buildPayload(match, fetchContext)); 64 + } else { 65 + try { 66 + const rendered = renderTemplate( 67 + action.recordTemplate, 68 + match.event, 69 + fetchContext, 70 + match.automation.did, 71 + ); 72 + message = `Would create record in ${action.targetCollection}`; 73 + payload = JSON.stringify(rendered); 74 + } catch (err) { 75 + error = `Template error: ${err instanceof Error ? err.message : String(err)}`; 76 + } 77 + } 78 + 79 + await db.insert(deliveryLogs).values({ 80 + automationUri: match.automation.uri, 81 + actionIndex, 82 + eventTimeUs: match.event.time_us, 83 + payload, 84 + statusCode: null, 85 + message, 86 + error, 87 + dryRun: true, 88 + attempt: 1, 89 + createdAt: new Date(), 90 + }); 91 + }
+1 -1
lib/webhooks/dispatcher.ts
··· 26 26 fetches?: Record<string, { uri: string; cid: string; record: Record<string, unknown> }>; 27 27 }; 28 28 29 - function buildPayload(match: MatchedEvent, fetchContext?: FetchContext): WebhookPayload { 29 + export function buildPayload(match: MatchedEvent, fetchContext?: FetchContext): WebhookPayload { 30 30 const { automation, event } = match; 31 31 return { 32 32 automation: automation.uri,