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.

doc: follow actions docs

Hugo 49709cf3 6c8c7c45

+369
+320
docs/follow-actions.md
··· 1 + # Follow actions 2 + 3 + A **follow action** writes a follow record on one of the AT Protocol social 4 + graphs Airglow knows about (Bluesky, Tangled, Sifa). Every supported target 5 + shares the same record shape — `{ subject, createdAt }` — so a single executor 6 + covers all three; the only thing that differs is the collection NSID written 7 + to and the profile NSID checked before the write. 8 + 9 + ```json 10 + { "type": "follow", "target": "bluesky", "subject": "{{event.did}}" } 11 + ``` 12 + 13 + The most common use case is a cross-graph follow mirror ("when I follow someone 14 + on Bluesky, also follow them on Tangled"). To make that one-liner safe out of 15 + the box, two pre-flight checks are baked into the executor — users do not 16 + configure them, but they are always on. See "Built-in safety checks" below. 17 + 18 + ## Anatomy 19 + 20 + [lib/actions/follow.ts](../lib/actions/follow.ts): 21 + 22 + 1. **Render the subject template** — `{{event.did}}`, 23 + `{{event.commit.record.subject}}`, etc. The rendered value is trimmed and 24 + validated against `DID_RE`. Failures return 400 with no retry. 25 + 2. **Profile pre-check** — does the subject have a profile record on the 26 + target graph? If not, return 204 ("skip"). Implemented as a single 27 + `fetchRecord` call against the target's profile NSID 28 + (see `FOLLOW_TARGET_PROFILE` in [lib/db/schema.ts](../lib/db/schema.ts)). 29 + 3. **Already-follows pre-check** — does the automation owner already follow 30 + the subject on the target graph? If so, return 204. Implemented by 31 + constructing a synthetic `FetchStepSearch` and handing it to 32 + [`executeSearch`](../lib/actions/searcher.ts), which transparently uses the 33 + Bluesky appview's `getRelationships` for `target: "bluesky"` and falls back 34 + to a paginated `listRecords` scan for Tangled/Sifa. 35 + 4. **Write the follow record** — `createArbitraryRecord(automation.did, 36 + targetCollection, { subject, createdAt })`. Errors are parsed for an HTTP 37 + status (regex `/\((\d{3})\)/` against the error message); 5xx and network 38 + failures schedule a retry chain via `RETRY_DELAYS`. 39 + 40 + The executor never throws to the handler. Every error path returns an 41 + `ActionResult` with a status code so the delivery log records something useful. 42 + 43 + ## The 204 "skip" status 44 + 45 + The pre-flight checks return `{ statusCode: SKIP_STATUS, message: "..." }` 46 + where `SKIP_STATUS = 204` ([lib/actions/delivery.ts](../lib/actions/delivery.ts)). 47 + 204 is chosen so it slots into the existing semantics with no special-casing: 48 + 49 + | Predicate | Source | 204 behaviour | 50 + | ----------------------------------------------- | ----------------------------------------------- | ------------------------------------------------------------ | 51 + | `isSuccess` (`code >= 200 && code < 300`) | [delivery.ts](../lib/actions/delivery.ts) | true — counts as success for the action chain. | 52 + | `isActionSuccess` (`code >= 200 && code < 400`) | [handler.ts](../lib/jetstream/handler.ts) | true — the action chain continues to the next action. | 53 + | `isRetryable` (`code >= 500 \|\| code === 0`) | [delivery.ts](../lib/actions/delivery.ts) | false — no retry. | 54 + | Rate-limit count | [rate-limit.ts](../lib/jetstream/rate-limit.ts) | excluded — `ne(statusCode, SKIP_STATUS)` in the count query. | 55 + | `actionN` context injection | [handler.ts](../lib/jetstream/handler.ts) | not injected — `result.uri` is undefined on a skip. | 56 + 57 + The net effect: a skipped follow is a no-op in the timeline. It writes a 58 + delivery-log row with the message ("Skipped: no Bluesky profile for did:plc:…" 59 + or "Skipped: already following did:plc:… on Bluesky") so the user can see what 60 + happened, but it does not retry, does not stop a downstream action, does not 61 + burn the rate-limit budget, and does not pollute `{{actionN.*}}` chaining. 62 + 63 + ## Built-in safety checks 64 + 65 + Without the pre-flight checks, a "follow on Tangled when I follow on Bluesky" 66 + automation would happily write thousands of dead Tangled follow records — one 67 + for every Bluesky follow whose subject doesn't actually use Tangled. And every 68 + re-trigger of the source event would create a duplicate. The two checks turn 69 + the failure modes into 204s. 70 + 71 + ### Profile check 72 + 73 + ``` 74 + at://{subject}/{FOLLOW_TARGET_PROFILE[target].collection}/{FOLLOW_TARGET_PROFILE[target].rkey} 75 + ``` 76 + 77 + A `fetchRecord` call. `found: false` → skip with `"Skipped: no <AppName> 78 + profile for <did>"`. Transient lookup failures (DID unresolvable, PDS down) 79 + fall through to the write — _fail-open_. Rationale: a 502 from the subject's 80 + PDS is not the same signal as a 404; if the follow genuinely can't succeed, 81 + the subsequent `createRecord` will surface the failure with the right status 82 + code and the retry machinery will pick it up. 83 + 84 + ### Already-follows check 85 + 86 + ```ts 87 + const synthetic: FetchStepSearch = { 88 + kind: "search", 89 + name: "__follow_preflight_already_follows", 90 + repo: match.automation.did, 91 + collection: FOLLOW_TARGET_COLLECTION[action.target], 92 + where: [{ field: "subject", operator: "eq", value: subject }], 93 + limit: 1, 94 + }; 95 + await executeSearch(synthetic, match.event, match.automation.did, {}); 96 + ``` 97 + 98 + For `target: "bluesky"`, `executeSearch` recognises the 99 + `app.bsky.graph.follow` + `subject eq <DID>` shape and answers from the 100 + appview's `app.bsky.graph.getRelationships` in O(1). For Tangled and Sifa the 101 + fallback is a paginated `listRecords` scan over the owner's repo, capped at 102 + 100 pages × 100 records = 10,000 follows. Owners with more than ~10k follows 103 + on those graphs may see false negatives (and a duplicate row); see the comment 104 + in `checkNotAlreadyFollowing` for context. Same fail-open policy as the 105 + profile check. 106 + 107 + The check is **not atomic** with the subsequent write. Two events racing for 108 + the same subject can both observe `found: false` and both create a record. 109 + Bluesky's appview collapses duplicate follows by subject in the public graph, 110 + so this is cosmetic (extra storage, same observable state) until cleaned up. 111 + 112 + ### Why inline, not synthetic fetches 113 + 114 + We considered injecting the two checks as synthesized entries into 115 + `fetchContext` so they'd flow through the existing fetch + condition 116 + machinery. We didn't because: 117 + 118 + - They are action-specific semantics, not user-visible data. Synthesizing 119 + them would pollute `{{actionN.*}}` chaining and complicate dry-run output. 120 + - All the primitives we need (`fetchRecord`, `executeSearch`) already exist 121 + and can be called directly. No new abstraction earns its keep here. 122 + 123 + ## Dry-run mode 124 + 125 + Pre-flight checks are **not run** in dry-run. The dry-run log instead 126 + advertises that they will run when the automation is enabled: 127 + 128 + ``` 129 + Would follow did:plc:abcd… on bluesky (will skip if no Bluesky profile exists or already following) 130 + ``` 131 + 132 + Rationale: dry-run is supposed to be cheap. Running the two pre-flights on 133 + every preview burns PDS calls for what is essentially documentation. 134 + 135 + ## Retry semantics 136 + 137 + Same chain as every other action. `RETRY_DELAYS = [5_000, 30_000]`. Retries 138 + fire only when the result is **not successful** _and_ retryable 139 + (`code >= 500 || code === 0`). 204 is success, so skips don't retry; 4xx is 140 + non-retryable, so template / DID-format errors don't either; 5xx and network 141 + failures do. Each retry writes its own delivery-log row with `attempt` 142 + incrementing from 1. 143 + 144 + ## Validation 145 + 146 + [lib/actions/validation.ts](../lib/actions/validation.ts): 147 + 148 + - `target` must be in `VALID_FOLLOW_TARGETS` (the runtime allow-list). 149 + - `subject` must be present, non-empty, ≤512 chars, and a valid text template 150 + (`validateTextTemplate` — placeholders must reference defined fetches / 151 + earlier actions). 152 + - The runtime `DID_RE` check happens at execution time against the rendered 153 + value, not at validation time. A subject of `{{event.commit.record.subject}}` 154 + validates fine even if some events deliver a non-DID subject; that case 155 + becomes a 400 in the delivery log at run time. 156 + 157 + ## Rate limiting 158 + 159 + `checkRateLimit` ([lib/jetstream/rate-limit.ts](../lib/jetstream/rate-limit.ts)) 160 + sums non-dry-run delivery-log rows per window. The query explicitly excludes 161 + `statusCode = SKIP_STATUS`: 162 + 163 + ```ts 164 + or(ne(deliveryLogs.statusCode, SKIP_STATUS), isNull(deliveryLogs.statusCode)); 165 + ``` 166 + 167 + So flooding a follow automation with events that all skip (subject has no 168 + profile / already followed) does **not** trip the rate limit. Only fires that 169 + actually wrote (or genuinely failed to write — 4xx/5xx/0) count. 170 + 171 + The `IS NULL` half is forward-compat: dry-run rows have a null status code and 172 + are filtered out earlier by `eq(dryRun, false)`, but the disjunction stays 173 + robust if any future non-dry-run path lands a null status. 174 + 175 + --- 176 + 177 + # Adding a new follow target 178 + 179 + Walk-through for adding a hypothetical new social graph "Foo" living at 180 + `org.foo` with profile collection `org.foo.actor.profile/self` and follow 181 + collection `org.foo.graph.follow`. Three of the changes are caught by the 182 + parity test in [lib/automations/action-catalogue.test.ts](../lib/automations/action-catalogue.test.ts); 183 + the rest are user-visible enough that a missed touchpoint will surface in 184 + manual testing or a related test. 185 + 186 + ## 1. Lexicon 187 + 188 + [lexicons/run/airglow/automation.json](../lexicons/run/airglow/automation.json) — add 189 + `"foo"` to `followAction.properties.target.knownValues`. After editing, run: 190 + 191 + ```sh 192 + goat lex lint lexicons/ 193 + ``` 194 + 195 + ## 2. Backend types and maps 196 + 197 + [lib/db/schema.ts](../lib/db/schema.ts) — three things: 198 + 199 + ```ts 200 + export type FollowTarget = "bluesky" | "tangled" | "sifa" | "foo"; 201 + 202 + export const FOLLOW_TARGET_COLLECTION: Record<FollowTarget, string> = { 203 + // ... 204 + foo: "org.foo.graph.follow", 205 + }; 206 + 207 + export const FOLLOW_TARGET_PROFILE: Record<FollowTarget, { collection: string; rkey: string }> = { 208 + // ... 209 + foo: { collection: "org.foo.actor.profile", rkey: "self" }, 210 + }; 211 + ``` 212 + 213 + [lib/actions/validation.ts](../lib/actions/validation.ts): 214 + 215 + ```ts 216 + export const VALID_FOLLOW_TARGETS = new Set(["bluesky", "tangled", "sifa", "foo"]); 217 + ``` 218 + 219 + …and update the `Invalid follow target` error string nearby to mention the 220 + new target. 221 + 222 + [lib/automations/pds.ts](../lib/automations/pds.ts) and 223 + [lib/automations/sanitize.ts](../lib/automations/sanitize.ts) — extend the 224 + inline `target: "bluesky" | "tangled" | "sifa"` literal type to include 225 + `"foo"`. (These can't reference `FollowTarget` because they're at the PDS 226 + serialization boundary, but a future refactor could collapse them.) 227 + 228 + ## 3. UI metadata 229 + 230 + [lib/automations/follow-targets.ts](../lib/automations/follow-targets.ts) — 231 + add an entry to `FOLLOW_TARGET_META`: 232 + 233 + ```ts 234 + foo: { 235 + catId: "apps", // or "bluesky" if it belongs in the Bluesky tile group 236 + colorKey: "foo", // requires a matching theme color, see step 4 237 + appName: "Foo", 238 + label: "Follow on Foo", 239 + faviconDomain: "foo.example", 240 + description: "Follow someone on Foo", 241 + }, 242 + ``` 243 + 244 + Add `"foo"` to the `ColorKey` union in the same file. 245 + 246 + [lib/automations/labels.ts](../lib/automations/labels.ts) — add 247 + `foo: "Foo"` to `followTargetLabels` (used by the dashboard log view). 248 + 249 + ## 4. Catalogue tile 250 + 251 + [lib/automations/action-catalogue.ts](../lib/automations/action-catalogue.ts) — 252 + add `"follow-foo"` to the `AddableActionId` union and an entry under the 253 + appropriate category: 254 + 255 + ```ts 256 + { 257 + id: "follow-foo", 258 + label: FOLLOW_TARGET_META.foo.label, 259 + description: FOLLOW_TARGET_META.foo.description, 260 + icon: UserPlus, 261 + available: true, 262 + colorKey: FOLLOW_TARGET_META.foo.colorKey, 263 + faviconDomain: FOLLOW_TARGET_META.foo.faviconDomain, 264 + }, 265 + ``` 266 + 267 + Update the corresponding tile-order test in 268 + [action-catalogue.test.ts](../lib/automations/action-catalogue.test.ts) so 269 + the new ordering is intentional and reviewable. 270 + 271 + ## 5. Theme color 272 + 273 + [app/styles/tokens/colors.ts](../app/styles/tokens/colors.ts) — add `foo` 274 + and `fooSubtle` entries in both the dark and light palettes. 275 + 276 + [app/styles/theme.css.ts](../app/styles/theme.css.ts) — register the new 277 + tokens on `vars.color`. 278 + 279 + [app/styles/action-header.css.ts](../app/styles/action-header.css.ts) — add 280 + the matching `&[data-cat="foo"]` selector. (A typo silently loses the accent.) 281 + 282 + ## 6. Favicon 283 + 284 + [app/components/Favicon/index.tsx](../app/components/Favicon/index.tsx) — 285 + register the local SVG so the tile renders the right brandmark instead of a 286 + generic icon. Drop the SVG into `public/static/favicons/foo.example.<hash>.svg` 287 + and add the entry to the favicon map. 288 + 289 + ## 7. Tests 290 + 291 + The parity test 292 + ([lib/automations/action-catalogue.test.ts](../lib/automations/action-catalogue.test.ts)) 293 + already pins `FOLLOW_TARGET_COLLECTION` ↔ `VALID_FOLLOW_TARGETS` ↔ 294 + `FOLLOW_TARGET_META`, so it will fail until those three are in sync. Beyond 295 + that, add or update: 296 + 297 + - [lib/actions/follow.test.ts](../lib/actions/follow.test.ts) — extend 298 + `"maps tangled and sifa targets to their collections"` to cover `foo`, 299 + and `"uses the target-specific profile NSID for the profile pre-check"` 300 + to assert the new profile URI shape. 301 + 302 + > **Drift caveat.** The parity test does **not** include 303 + > `FOLLOW_TARGET_PROFILE`. If you add a new target and forget the profile 304 + > entry, the executor will throw `Cannot read property 'collection' of 305 + undefined` at the first fire. Worth adding to the parity test next time 306 + > someone touches that file. 307 + 308 + ## 8. Manual verification 309 + 310 + Run `vp check && vp test`, then in `vp dev`: 311 + 312 + 1. Create a follow automation with the new target. Trigger it with a subject 313 + that has a profile on the new graph and isn't already followed → record 314 + created, delivery log shows 200. 315 + 2. Trigger with a subject that has no profile → 204 with the right "no Foo 316 + profile" message. 317 + 3. Trigger again with the same already-followed subject → 204 with the right 318 + "already following" message. 319 + 4. Confirm the catalogue tile renders with the right color, favicon, and 320 + label, and that the automation detail page shows the right collection NSID.
+49
lib/pds/fetch-with-retry.ts
··· 1 + /** Short in-process backoff schedule for transient PDS failures. 2 + * Not a persistent queue: if the process dies between attempts the event 3 + * is lost. Sufficient for brief PDS blips, not for extended outages. */ 4 + export const FETCH_RETRY_DELAYS_MS: readonly number[] = [1_000, 3_000]; 5 + 6 + /** `fetch` with retries on 5xx responses and thrown network/timeout errors. 7 + * 8 + * After retries are exhausted, returns the final response (5xx case) or 9 + * rethrows the error (network case) so callers can handle normally. 4xx 10 + * responses (including 404 and XRPC `RecordNotFound`) are stable answers 11 + * and never retried. 12 + * 13 + * A per-attempt timeout is applied via `AbortSignal.timeout`; callers 14 + * should not pass their own `signal`. 15 + */ 16 + export async function fetchWithRetry( 17 + input: URL | string, 18 + init: Omit<RequestInit, "signal"> & { timeoutMs?: number } = {}, 19 + ): Promise<Response> { 20 + const { timeoutMs = 10_000, ...rest } = init; 21 + const delays = FETCH_RETRY_DELAYS_MS; 22 + 23 + let attempt = 0; 24 + while (true) { 25 + try { 26 + const res = await fetch(input, { 27 + ...rest, 28 + signal: AbortSignal.timeout(timeoutMs), 29 + }); 30 + if (res.status >= 500 && attempt < delays.length) { 31 + await sleep(delays[attempt]!); 32 + attempt++; 33 + continue; 34 + } 35 + return res; 36 + } catch (err) { 37 + if (attempt < delays.length) { 38 + await sleep(delays[attempt]!); 39 + attempt++; 40 + continue; 41 + } 42 + throw err; 43 + } 44 + } 45 + } 46 + 47 + function sleep(ms: number): Promise<void> { 48 + return new Promise((resolve) => setTimeout(resolve, ms)); 49 + }