@volter/twin-x 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +138 -0
  3. package/dist/client/x-mirror.bundle.js +321 -0
  4. package/dist/client/x-mirror.d.ts +45 -0
  5. package/dist/client/x-mirror.js +417 -0
  6. package/dist/src/cli.d.ts +2 -0
  7. package/dist/src/cli.js +29 -0
  8. package/dist/src/index.d.ts +14 -0
  9. package/dist/src/index.js +68 -0
  10. package/dist/src/x-budget.d.ts +54 -0
  11. package/dist/src/x-budget.js +123 -0
  12. package/dist/src/x-capabilities.d.ts +3 -0
  13. package/dist/src/x-capabilities.js +1106 -0
  14. package/dist/src/x-conformance.d.ts +8 -0
  15. package/dist/src/x-conformance.js +91 -0
  16. package/dist/src/x-connector.d.ts +125 -0
  17. package/dist/src/x-connector.js +546 -0
  18. package/dist/src/x-media.d.ts +87 -0
  19. package/dist/src/x-media.js +275 -0
  20. package/dist/src/x-mirror-ui.d.ts +61 -0
  21. package/dist/src/x-mirror-ui.js +253 -0
  22. package/dist/src/x-problems.d.ts +38 -0
  23. package/dist/src/x-problems.js +130 -0
  24. package/dist/src/x-scopes.d.ts +7 -0
  25. package/dist/src/x-scopes.js +62 -0
  26. package/dist/src/x-server.d.ts +14 -0
  27. package/dist/src/x-server.js +127 -0
  28. package/dist/src/x-twin.d.ts +21 -0
  29. package/dist/src/x-twin.js +1534 -0
  30. package/package.json +58 -0
  31. package/src/cli.ts +27 -0
  32. package/src/index.ts +132 -0
  33. package/src/x-budget.ts +150 -0
  34. package/src/x-capabilities.ts +1161 -0
  35. package/src/x-conformance.ts +113 -0
  36. package/src/x-connector.ts +546 -0
  37. package/src/x-media.ts +295 -0
  38. package/src/x-mirror-ui.ts +263 -0
  39. package/src/x-problems.ts +143 -0
  40. package/src/x-scopes.ts +67 -0
  41. package/src/x-server.ts +126 -0
  42. package/src/x-twin.ts +1545 -0
@@ -0,0 +1,546 @@
1
+ // X CONNECTOR — the live-vendor path for the posting surface.
2
+ //
3
+ // PULL (real → twin): TWO timelines, because an org's public voice has two halves. The MENTIONS
4
+ // TIMELINE (`GET /2/users/:id/mentions`) is what was said TO the org — the input the demo's
5
+ // reply class consumes. The OWN TIMELINE (`GET /2/users/:id/tweets`) is what the org itself
6
+ // said, INCLUDING what it said somewhere else: by a human on x.com, or by an earlier run.
7
+ // Without the second half, a twin that has been away comes back believing it never spoke.
8
+ // Both are PAGED: a busy account's timeline arrives in pages joined by `meta.next_token`, and a
9
+ // pull that read only the first page would put the older half of the account permanently outside
10
+ // the twin while reporting a complete fold.
11
+ //
12
+ // PUSH (twin → real): unlike xidentity, this vendor HAS a write API, so push is real. A pending
13
+ // `x.post.create` / `x.post.reply` / `x.post.quote` becomes `POST /2/tweets` (each carrying the
14
+ // same body field the twin dispatched on — `reply.in_reply_to_tweet_id` for a reply,
15
+ // `quote_tweet_id` for a quote, neither for an original post); a pending `x.post.delete`
16
+ // becomes `DELETE /2/tweets/:id`. Each is confirmed with the id the REAL vendor minted, so the
17
+ // twin's projection stops claiming a local id for a post that now exists in public.
18
+ //
19
+ // MEDIA. A post that carries images or a video names each one on its entry by key, local media id
20
+ // and DIGEST (x-twin.ts createPost). The local media id means nothing at X, and an upload is not an
21
+ // entry of its own (x-media.ts), so there is no adopted subject to resolve: the perform adapter
22
+ // reads each file's bytes off the blob seam by digest, uploads them to the vendor through the same
23
+ // v2 media endpoints the twin serves (one-shot for an image; initialize/append/finalize for a video,
24
+ // then STATUS until X's processing says `succeeded`), and creates the post with the media ids X
25
+ // minted. X media ids expire a day after upload, so uploading at perform time is also what the
26
+ // vendor asks for.
27
+ //
28
+ // THE PERFORM PATH IS BUDGETED. The kernel hands `performXAction` its own executor
29
+ // (buildRemoteExecute: the World's sealed credential), which knows nothing of X's limits, so the
30
+ // adapter wraps it (`budgetedXExecute`) in this pack's XBudget ledger — the same mechanism
31
+ // `liveXExecute` uses: every call is charged BEFORE it goes out, a refusal THROWS without calling
32
+ // X, and a 429 / Retry-After arms the persisted cooldown. The ledger is the box's X ledger
33
+ // (VOLTER_HOME), shared by every World and branch that performs to X from here, so no number of
34
+ // branches multiplies the allowance.
35
+ //
36
+ // THE BOUND ON A VIDEO. X's processing can take longer than anyone should hold a request for. The
37
+ // wait on STATUS honours each `check_after_secs` (at least 1 s) and stops at 120 s in total or 30
38
+ // polls, whichever comes first; past that the perform fails with a RETRYABLE reason ("video still
39
+ // processing at the vendor"). The kernel records that as a `failed` receipt, which leaves the entry
40
+ // deployable, so the next deploy performs it again (and uploads afresh — an unfinished media id is
41
+ // not reused). So a deploy, and a write at an `auto` head (which performs inside the request),
42
+ // spends at most ~120 s waiting on X's processing per video, plus the upload itself. At an `auto`
43
+ // head the kernel reverts a write whose perform failed (head.ts performAtHead): the app is told no,
44
+ // with that reason, and retries the write itself.
45
+ //
46
+ // The vendor I/O is an INJECTED executor (the auth boundary): the kernel and this pack hold NO X
47
+ // credential and import NO network client. Offline/tests pass a fake executor; live runs pass
48
+ // `liveXExecute(accessToken)`. Same code path either way.
49
+ import { assertBudgetGuardIntact, confirmAction, pendingActions, syncPull } from '@volter/world-core';
50
+ import type { PerformContext, PushOutcome, RemoteExecute, SyncResource, TwinAction } from '@volter/world-core';
51
+ import { XBudget, XBudgetError, xBudgetPath, xCallWeight, type XBudgetOptions } from './x-budget.ts';
52
+ import { readXBlob } from './x-media.ts';
53
+
54
+ const SERVICE = 'x';
55
+
56
+ /** X's real host — the live executor's routing table. ONLY mapped paths may be called live, so a
57
+ * typo or a widened caller cannot reach an X endpoint this pack has never modelled. */
58
+ const HOST = 'https://api.x.com';
59
+ const ALLOWED_PATHS: ReadonlyArray<RegExp> = [
60
+ /^\/2\/tweets$/,
61
+ /^\/2\/tweets\/[^/]+$/,
62
+ /^\/2\/users\/[^/]+\/mentions$/,
63
+ /^\/2\/users\/[^/]+\/tweets$/,
64
+ ];
65
+
66
+ /** The modelled tweet.fields a pull requests — every field the twin's post rows can hold. */
67
+ export const PULL_TWEET_FIELDS = ['author_id', 'created_at', 'conversation_id', 'in_reply_to_user_id', 'referenced_tweets'] as const;
68
+
69
+ export type XExecute = (
70
+ method: 'GET' | 'POST' | 'DELETE',
71
+ path: string,
72
+ init?: { headers?: Record<string, string>; body?: string },
73
+ ) => Promise<Record<string, any>>;
74
+
75
+ export type LiveXOptions = {
76
+ /** Injected `fetch`, so a test can COUNT the requests the guard did or did not let through. */
77
+ fetchImpl?: typeof fetch;
78
+ /** An existing budget to share across executors. Omit and one is constructed. Cannot be null. */
79
+ budget?: XBudget;
80
+ /** Construction options for the default budget (ledger path, clock). Cannot loosen it. */
81
+ budgetOptions?: XBudgetOptions;
82
+ };
83
+
84
+ /**
85
+ * A live executor against the real X API, holding the operator's OWN user access token.
86
+ *
87
+ * THIS IS THE ONE PLACE this pack issues a live X request, and therefore the one place the rate
88
+ * budget has to be enforced. EVERY call is guarded: the budget is charged BEFORE the request goes
89
+ * out (`checkBudget`, which THROWS instead of returning when the ceiling or a cooldown says stop)
90
+ * and the response is fed back (`recordCall`) so a 429 / `Retry-After` becomes a PERSISTED
91
+ * cooldown that makes every later call fail fast WITHOUT touching X. There is deliberately no
92
+ * option to disable the guard and no value of `budget` that yields an unguarded client
93
+ * (`assertBudgetGuardIntact`). This matters more here than on a read-only vendor: an unguarded
94
+ * loop at this vendor does not waste quota, it POSTS IN PUBLIC.
95
+ */
96
+ export function liveXExecute(accessToken: string, opts: LiveXOptions = {}): XExecute {
97
+ const doFetch = opts.fetchImpl ?? fetch;
98
+ const budget = opts.budget !== undefined && opts.budget !== null
99
+ ? assertBudgetGuardIntact(opts.budget, XBudget, 'liveXExecute')
100
+ : new XBudget({ ...(opts.budgetOptions ?? {}), token: accessToken });
101
+ const explicitLedger = opts.budgetOptions?.path !== undefined || opts.budgetOptions?.root !== undefined;
102
+ if (opts.budget && !explicitLedger && budget.path !== xBudgetPath({ token: accessToken })) {
103
+ throw new Error('liveXExecute: injected budget is not keyed to the credential this client will send');
104
+ }
105
+ return async (method, path, init) => {
106
+ const bare = path.split('?')[0] ?? path;
107
+ if (!ALLOWED_PATHS.some((allowed) => allowed.test(bare))) throw new Error(`liveXExecute: refusing to call an unmapped X path: ${bare}`);
108
+ const weight = xCallWeight(method, path);
109
+ if (Object.keys(init?.headers ?? {}).some((name) => name.toLowerCase() === 'authorization')) {
110
+ throw new Error('liveXExecute: refusing an injected Authorization header; the guarded credential is fixed at construction');
111
+ }
112
+ // THROWS instead of calling. Nothing below this line runs when the budget refuses.
113
+ const reservation = budget.checkBudget(weight);
114
+ const res = await doFetch(`${HOST}${path}`, {
115
+ method,
116
+ headers: { 'content-type': 'application/json', ...(init?.headers ?? {}), Authorization: `Bearer ${accessToken}` },
117
+ ...(init?.body !== undefined ? { body: init.body } : {}),
118
+ });
119
+ const resHeaders: Record<string, string> = {};
120
+ res.headers.forEach((v: string, k: string) => {
121
+ resHeaders[k.toLowerCase()] = v;
122
+ });
123
+ // Settles the reservation and, on a back-off signal, arms the cooldown. The cooldown is
124
+ // persisted before body parsing or any throw, so even an HTML/plain-text 429 survives it.
125
+ // recordCall may THROW after arming it (a back-off beyond the cap). On a refused call that
126
+ // louder refusal wins; on a call X ACCEPTED the answer is kept — a post that landed in public
127
+ // must be confirmed under its id, never recorded as failed and posted again on retry.
128
+ try {
129
+ budget.recordCall(weight, resHeaders, { status: res.status, reservation });
130
+ } catch (error) {
131
+ if (!(error instanceof XBudgetError) || !res.ok) throw error;
132
+ }
133
+ const raw = await res.text();
134
+ let parsed: Record<string, any>;
135
+ try {
136
+ parsed = raw === '' ? {} : JSON.parse(raw) as Record<string, any>;
137
+ } catch {
138
+ throw new Error(`x returned non-JSON for ${method} ${bare}: HTTP ${res.status}`);
139
+ }
140
+ // A REFUSED call is NOT an empty result. X answers failures with a problem envelope
141
+ // (`title`/`type`), a legacy `errors` array, OR a 200 whose body carries partial `errors` —
142
+ // a status check alone cannot tell refusal from emptiness, so every refusal shape throws.
143
+ if (res.status >= 400) throw new Error(`x refused ${method} ${bare}: HTTP ${res.status} ${JSON.stringify(parsed)}`);
144
+ if (parsed && typeof parsed === 'object' && !('data' in parsed) && !('meta' in parsed) && ('errors' in parsed || 'title' in parsed)) {
145
+ throw new Error(`x refused ${method} ${bare}: ${JSON.stringify(parsed)}`);
146
+ }
147
+ return parsed;
148
+ };
149
+ }
150
+
151
+ /** Map one real timeline post → the twin's `post` SyncResource. Nothing invented: a field the
152
+ * response omits records NOTHING rather than a placeholder, so a partial reply can never fold a
153
+ * null over a previously observed value. */
154
+ export function mapTimelinePost(row: Record<string, any>): SyncResource {
155
+ return {
156
+ type: 'post',
157
+ id: String(row.id ?? ''),
158
+ fields: {
159
+ ...(typeof row.text === 'string' ? { text: row.text } : {}),
160
+ ...(typeof row.author_id === 'string' ? { author_id: row.author_id } : {}),
161
+ ...(typeof row.created_at === 'string' ? { created_at: row.created_at } : {}),
162
+ ...(typeof row.conversation_id === 'string' ? { conversation_id: row.conversation_id } : {}),
163
+ ...(typeof row.in_reply_to_user_id === 'string' ? { in_reply_to_user_id: row.in_reply_to_user_id } : {}),
164
+ ...(Array.isArray(row.referenced_tweets) ? { referenced_tweets: row.referenced_tweets } : {}),
165
+ deleted: false,
166
+ pulled: true,
167
+ },
168
+ };
169
+ }
170
+
171
+ /**
172
+ * A MOVING pull timestamp, forced strictly increasing within the process — never a pinned
173
+ * constant (ADDING_A_TWIN.md §6: under a fixed poll time a vendor value that REVERTS across polls
174
+ * collides with its own earlier observation and the delta silently vanishes).
175
+ */
176
+ let lastPollMs = 0;
177
+ function pollTimestamp(): string {
178
+ const now = Date.now();
179
+ lastPollMs = now > lastPollMs ? now : lastPollMs + 1;
180
+ return new Date(lastPollMs).toISOString();
181
+ }
182
+
183
+ /** How many pages one pull will follow before it refuses. A vendor that keeps handing back a
184
+ * token is a loop or a bug, not a deep timeline; the pull stops and SAYS SO rather than paging
185
+ * forever or quietly truncating. 32 pages at X's 100-per-page ceiling is its own 3200-post
186
+ * timeline depth. */
187
+ const MAX_PULL_PAGES = 32;
188
+
189
+ /**
190
+ * Read ONE timeline to its end, following `meta.next_token`.
191
+ *
192
+ * The refusal checks live HERE, not only in the live executor: an injected executor (or a vendor
193
+ * 200 carrying a problem envelope) must never fold an empty timeline over observed state. X OMITS
194
+ * `data` on an empty timeline and answers with `meta` alone, so an empty page is
195
+ * `meta.result_count === 0` — a body with NEITHER `data` nor `meta` is a refusal and throws.
196
+ */
197
+ async function collectXTimeline(execute: XExecute, label: string, basePath: string): Promise<SyncResource[]> {
198
+ const collected: SyncResource[] = [];
199
+ const seenTokens = new Set<string>();
200
+ let token: string | undefined;
201
+ for (let page = 0; page < MAX_PULL_PAGES; page += 1) {
202
+ const paging = token === undefined ? '' : `&pagination_token=${encodeURIComponent(token)}`;
203
+ const body = await execute('GET', `${basePath}?tweet.fields=${PULL_TWEET_FIELDS.join(',')}${paging}`);
204
+ if (!body || typeof body !== 'object') throw new Error(`x ${label} pull refused or malformed: no body`);
205
+ const rows = body.data;
206
+ if (rows === undefined) {
207
+ if (body.meta && typeof body.meta === 'object') return collected;
208
+ throw new Error(`x ${label} pull refused or malformed: ${JSON.stringify(body).slice(0, 200)}`);
209
+ }
210
+ if (!Array.isArray(rows)) throw new Error(`x ${label} pull refused or malformed: ${JSON.stringify(body).slice(0, 200)}`);
211
+ // An X post id is a NUMERIC snowflake string. A row without one is not a post this twin can
212
+ // key, and folding it would put a foreign id into the projection's id space.
213
+ for (const row of rows) {
214
+ if (!row || typeof row !== 'object' || typeof row.id !== 'string' || !/^\d+$/.test(row.id)) {
215
+ throw new Error(`x ${label} pull returned a row with no X-shaped id: ${JSON.stringify(row).slice(0, 200)}`);
216
+ }
217
+ }
218
+ collected.push(...(rows as Array<Record<string, any>>).map(mapTimelinePost));
219
+ const next = body.meta?.next_token;
220
+ if (next === undefined || next === null) return collected;
221
+ if (typeof next !== 'string' || next === '') throw new Error(`x ${label} pull returned an unusable next_token: ${JSON.stringify(next)}`);
222
+ // A token this pull has already followed means the vendor is not advancing. Following it
223
+ // again loops; stopping SILENTLY would report a complete pull that is missing rows.
224
+ if (seenTokens.has(next)) throw new Error(`x ${label} pull was handed a repeated next_token (${next}); refusing to page in a loop`);
225
+ seenTokens.add(next);
226
+ token = next;
227
+ }
228
+ throw new Error(`x ${label} pull exceeded ${MAX_PULL_PAGES} pages; refusing to page further`);
229
+ }
230
+
231
+ async function collectXMentions(execute: XExecute, userId: string): Promise<SyncResource[]> {
232
+ return collectXTimeline(execute, 'mentions', `/2/users/${encodeURIComponent(userId)}/mentions`);
233
+ }
234
+
235
+ /** The org's OWN posted timeline — what it said, including what it said somewhere else. */
236
+ async function collectXOwnTimeline(execute: XExecute, userId: string): Promise<SyncResource[]> {
237
+ return collectXTimeline(execute, 'own timeline', `/2/users/${encodeURIComponent(userId)}/tweets`);
238
+ }
239
+
240
+ /** PULL the org's mentions timeline into the twin's observed log (idempotent). */
241
+ export async function pullXMentions(execute: XExecute, userId: string, root?: string, occurredAt?: string): Promise<number> {
242
+ const resources = await collectXMentions(execute, userId);
243
+ syncPull({ service: SERVICE, resources, occurredAt: occurredAt ?? pollTimestamp(), ...(root !== undefined ? { root } : {}) });
244
+ return resources.length;
245
+ }
246
+
247
+ /** PULL the org's OWN posted timeline into the twin's observed log (idempotent). */
248
+ export async function pullXOwnTimeline(execute: XExecute, userId: string, root?: string, occurredAt?: string): Promise<number> {
249
+ const resources = await collectXOwnTimeline(execute, userId);
250
+ syncPull({ service: SERVICE, resources, occurredAt: occurredAt ?? pollTimestamp(), ...(root !== undefined ? { root } : {}) });
251
+ return resources.length;
252
+ }
253
+
254
+ /**
255
+ * D7 consumer-facing pull entry point: pull everything readable from the real X posting surface
256
+ * and fold it into the twin in ONE shadow-diffed `syncPull`, returning the standard
257
+ * `{ observed, deltasAppended }`. Idempotent — a re-pull of identical state appends nothing.
258
+ */
259
+ export async function syncXFromReal(
260
+ execute: XExecute,
261
+ opts: { userId: string; root?: string; occurredAt?: string; includeOwnTimeline?: boolean },
262
+ ): Promise<{ observed: number; deltasAppended: number }> {
263
+ const occurredAt = opts.occurredAt ?? pollTimestamp();
264
+ const mentions = await collectXMentions(execute, opts.userId);
265
+ // BOTH halves by DEFAULT. `includeOwnTimeline: false` exists for a caller that genuinely only
266
+ // wants the inbox — never as the default, which is exactly the hole the pull audit filed as
267
+ // `x.connector.pull_own_timeline`.
268
+ const own = opts.includeOwnTimeline === false ? [] : await collectXOwnTimeline(execute, opts.userId);
269
+ // At the vendor a post can only be on one of the two timelines (the mentions timeline excludes
270
+ // the account's own posts), but keying the fold by id keeps it single-valued rather than
271
+ // trusting that.
272
+ const byId = new Map<string, SyncResource>();
273
+ for (const resource of [...mentions, ...own]) byId.set(resource.id, resource);
274
+ const resources = [...byId.values()];
275
+ const result = syncPull({ service: SERVICE, resources, occurredAt, ...(opts.root !== undefined ? { root: opts.root } : {}) });
276
+ return { observed: result.observed, deltasAppended: result.deltasAppended };
277
+ }
278
+
279
+ /**
280
+ * The request ONE pending twin action becomes at the real vendor. A pure shape builder — it
281
+ * issues nothing, so it can be asserted directly. The reply's discriminator travels here exactly
282
+ * as the twin read it: `reply.in_reply_to_tweet_id`, the same field, in the same place.
283
+ */
284
+ export function xRequestForAction(action: { operation?: string; subject?: { type: string; id: string }; fields?: Record<string, any> }):
285
+ | { method: 'POST' | 'DELETE'; path: string; body?: string }
286
+ | undefined {
287
+ const fields = action.fields ?? {};
288
+ const id = action.subject?.id ?? '';
289
+ // `media.media_ids`, when the post carries media: the ids as the fields name them — the vendor's
290
+ // once performXAction has uploaded and swapped them in.
291
+ const mediaIds = Array.isArray(fields.media) ? fields.media.map((m: any) => String(m?.media_id ?? '')).filter((m: string) => m !== '') : [];
292
+ const media = mediaIds.length > 0 ? { media: { media_ids: mediaIds } } : {};
293
+ if (action.operation === 'x.post.create') {
294
+ return { method: 'POST', path: '/2/tweets', body: JSON.stringify({ text: String(fields.text ?? ''), ...media }) };
295
+ }
296
+ if (action.operation === 'x.post.reply') {
297
+ const referenced = Array.isArray(fields.referenced_tweets) ? fields.referenced_tweets : [];
298
+ const parent = referenced.find((r: any) => r?.type === 'replied_to');
299
+ if (!parent?.id) return undefined;
300
+ return { method: 'POST', path: '/2/tweets', body: JSON.stringify({ text: String(fields.text ?? ''), reply: { in_reply_to_tweet_id: String(parent.id) }, ...media }) };
301
+ }
302
+ if (action.operation === 'x.post.quote') {
303
+ const referenced = Array.isArray(fields.referenced_tweets) ? fields.referenced_tweets : [];
304
+ const quoted = referenced.find((r: any) => r?.type === 'quoted');
305
+ if (!quoted?.id) return undefined;
306
+ return { method: 'POST', path: '/2/tweets', body: JSON.stringify({ text: String(fields.text ?? ''), quote_tweet_id: String(quoted.id), ...media }) };
307
+ }
308
+ if (action.operation === 'x.post.delete') {
309
+ return { method: 'DELETE', path: `/2/tweets/${encodeURIComponent(id)}` };
310
+ }
311
+ return undefined;
312
+ }
313
+
314
+ /**
315
+ * PUSH one pending action to the real vendor and confirm it with what the vendor answered.
316
+ *
317
+ * A create/reply is confirmed under the REAL id X minted, not the twin's locally minted one:
318
+ * after a push the public post has a public id, and a projection still claiming the local id
319
+ * would make every later delete address a post that does not exist. The local subject is
320
+ * confirmed as superseded in the same call.
321
+ */
322
+ export async function pushXAction(
323
+ execute: XExecute,
324
+ action: { id: string; operation?: string; subject?: { type: string; id: string }; fields?: Record<string, any> },
325
+ opts: { root?: string; occurredAt?: string } = {},
326
+ ): Promise<{ pushed: boolean; realId?: string; reason?: string }> {
327
+ // A post's media ids are local; only the perform adapter uploads the files and swaps in X's ids.
328
+ // So a create/reply/quote CARRYING media is not pushable here — reported, not thrown, so a push
329
+ // of many entries carries on past it. A delete never uploads anything and is pushed as ever.
330
+ const writesPost = action.operation === 'x.post.create' || action.operation === 'x.post.reply' || action.operation === 'x.post.quote';
331
+ if (writesPost && Array.isArray(action.fields?.media) && action.fields.media.length > 0) {
332
+ return { pushed: false, reason: `${action.operation} carries media, which crosses through performXAction (it uploads the files first)` };
333
+ }
334
+ const request = xRequestForAction(action);
335
+ if (!request) return { pushed: false };
336
+ const occurredAt = opts.occurredAt ?? new Date().toISOString();
337
+ const localId = action.subject?.id ?? '';
338
+ const reply = await execute(request.method, request.path, request.body === undefined ? undefined : { body: request.body });
339
+
340
+ if (request.method === 'DELETE') {
341
+ if (reply?.data?.deleted !== true) throw new Error(`x refused the delete of ${localId}: ${JSON.stringify(reply).slice(0, 200)}`);
342
+ confirmAction({ service: SERVICE, actionId: action.id, subject: { type: 'post', id: localId }, fields: { ...(action.fields ?? {}), deleted: true, pushed: true }, occurredAt, ...(opts.root !== undefined ? { root: opts.root } : {}) });
343
+ return { pushed: true, realId: localId };
344
+ }
345
+
346
+ const realId = reply?.data?.id;
347
+ if (typeof realId !== 'string' || !/^\d+$/.test(realId)) throw new Error(`x returned no post id for ${action.operation}: ${JSON.stringify(reply).slice(0, 200)}`);
348
+ confirmAction({
349
+ service: SERVICE,
350
+ actionId: action.id,
351
+ subject: { type: 'post', id: realId },
352
+ fields: { ...(action.fields ?? {}), pushed: true, local_id: localId },
353
+ // The locally minted subject is retired in the SAME compound confirmation, so no window
354
+ // exists in which both ids look live.
355
+ ...(localId !== realId ? { additionalObservations: [{ subject: { type: 'post', id: localId }, fields: { ...(action.fields ?? {}), superseded_by: realId, deleted: true } }] } : {}),
356
+ occurredAt,
357
+ ...(opts.root !== undefined ? { root: opts.root } : {}),
358
+ });
359
+ return { pushed: true, realId };
360
+ }
361
+
362
+ /** Push every pending action this connector knows how to push. */
363
+ export async function pushPendingXActions(
364
+ execute: XExecute,
365
+ opts: { root?: string; occurredAt?: string } = {},
366
+ ): Promise<{ pushed: number; unpushable: number }> {
367
+ let pushed = 0;
368
+ let unpushable = 0;
369
+ for (const action of pendingActions(SERVICE, opts.root)) {
370
+ const result = await pushXAction(execute, action as never, opts);
371
+ if (result.pushed) pushed += 1;
372
+ else unpushable += 1;
373
+ }
374
+ return { pushed, unpushable };
375
+ }
376
+
377
+ /**
378
+ * The perform adapter (runtime contract R18): what a World's deploy calls for each landed X entry
379
+ * on a twin whose root is the platform. A post, reply, quote or delete crosses through the host's
380
+ * executor, which adds the sealed credential; the kernel records the landing from the returned id.
381
+ * Anything else (a seeded account or token) is the twin's own record and crosses nothing. A
382
+ * reply's or quote's parent authored against a local id crosses against the id X minted for it.
383
+ */
384
+ /**
385
+ * The kernel executor, charged to this pack's XBudget: the check RESERVES before the call and
386
+ * throws (XBudgetError) instead of calling when the ceiling, the burst bound or a cooldown says
387
+ * stop; the response settles the reservation and arms a cooldown on 429 / Retry-After.
388
+ */
389
+ export function budgetedXExecute(execute: RemoteExecute, budget: XBudget = new XBudget()): RemoteExecute {
390
+ const guard = assertBudgetGuardIntact(budget, XBudget, 'budgetedXExecute');
391
+ return async (request) => {
392
+ const weight = xCallWeight(request.method, request.path);
393
+ const reservation = guard.checkBudget(weight);
394
+ const res = await execute(request);
395
+ // The vendor has ANSWERED: whatever the ledger says next, the answer goes back. recordCall
396
+ // persists any cooldown BEFORE it throws (a back-off beyond the cap), so the throw is caught
397
+ // here and the cooldown stands — the next call is refused by it. Dropping the answer would
398
+ // record a write X accepted as failed: at an auto head the entry is reverted, and a retry
399
+ // posts it twice. A refused answer carries its own status to the caller.
400
+ try {
401
+ guard.recordCall(weight, Object.fromEntries(Object.entries(res.headers ?? {}).map(([k, v]) => [k.toLowerCase(), v])), { status: res.status, reservation });
402
+ } catch (error) {
403
+ if (!(error instanceof XBudgetError)) throw error;
404
+ }
405
+ return res;
406
+ };
407
+ }
408
+
409
+ export async function performXAction(kernelExecute: RemoteExecute, action: TwinAction, ctx: PerformContext): Promise<PushOutcome> {
410
+ // ONE LEDGER PER CREDENTIAL: X's limits belong to the token, so every World and branch performing
411
+ // with one sealed credential shares that credential's ledger (keyed by its fingerprint); with none
412
+ // sealed (a twin-only perform) the ledger is the control root's, never one machine-wide file
413
+ const execute = budgetedXExecute(kernelExecute, new XBudget(ctx.credential !== undefined ? { token: ctx.credential } : ctx.root !== undefined ? { root: ctx.root } : {}));
414
+ const fields = { ...(action.fields ?? {}) } as Record<string, any>;
415
+ if (Array.isArray(fields.referenced_tweets)) {
416
+ fields.referenced_tweets = fields.referenced_tweets.map((r: any) => (r?.id ? { ...r, id: ctx.resolve('post', String(r.id)) } : r));
417
+ }
418
+ const subjectId = action.subject?.id ?? '';
419
+ const target = action.operation === 'x.post.delete' ? ctx.resolve('post', subjectId) : subjectId;
420
+ // Media first: each file goes to the vendor, and the post names the ids X minted for them.
421
+ const vendorMediaIds: string[] = [];
422
+ if (action.operation !== 'x.post.delete' && Array.isArray(fields.media) && fields.media.length > 0) {
423
+ const uploaded: Array<Record<string, any>> = [];
424
+ for (const media of fields.media as Array<Record<string, any>>) {
425
+ const bytes = typeof media.sha256 === 'string' ? await readXBlob(media.sha256, ctx.root) : null;
426
+ if (!bytes) throw new Error(`x cannot perform ${action.operation} on ${subjectId}: the bytes of ${String(media.media_key)} are not on this twin's blob seam`);
427
+ const vendor = await uploadMediaToVendor(execute, media, bytes);
428
+ vendorMediaIds.push(vendor.id);
429
+ uploaded.push({ ...media, media_id: vendor.id, ...(vendor.media_key ? { media_key: vendor.media_key } : {}) });
430
+ }
431
+ fields.media = uploaded;
432
+ }
433
+ const request = xRequestForAction({ operation: action.operation, subject: { type: 'post', id: target }, fields });
434
+ if (!request) {
435
+ // seeded state (x.twin.*) is the twin's own record; a post it cannot express fails loudly, never a silent receipt
436
+ if ((action.operation ?? '').startsWith('x.twin.')) return { externalId: ctx.resolve(action.subject?.type ?? 'post', subjectId), data: { performed: false, reason: `${action.operation} is the twin's own record — nothing at X to write` } };
437
+ throw new Error(`x cannot perform ${action.operation ?? 'this entry'} on ${subjectId}: no X request expresses it`);
438
+ }
439
+ const res = await execute({
440
+ method: request.method,
441
+ path: request.path,
442
+ headers: { accept: 'application/json', ...(request.body === undefined ? {} : { 'content-type': 'application/json' }) },
443
+ ...(request.body === undefined ? {} : { body: request.body }),
444
+ });
445
+ if (res.status < 200 || res.status >= 300) throw new Error(`x refused ${action.operation}: HTTP ${res.status} ${res.body.slice(0, 200)}`);
446
+ let reply: Record<string, any> = {};
447
+ try { reply = res.body ? JSON.parse(res.body) : {}; } catch { throw new Error(`x answered ${action.operation} with a body that is not JSON: ${res.body.slice(0, 200)}`); }
448
+ if (request.method === 'DELETE') {
449
+ if (reply?.data?.deleted !== true) throw new Error(`x refused the delete of ${target}: ${res.body.slice(0, 200)}`);
450
+ return { externalId: target, data: { deleted: true } };
451
+ }
452
+ const realId = reply?.data?.id;
453
+ if (typeof realId !== 'string' || !/^\d+$/.test(realId)) throw new Error(`x returned no post id for ${action.operation}: ${res.body.slice(0, 200)}`);
454
+ return { externalId: realId, url: `https://x.com/i/web/status/${realId}`, data: { text: reply.data.text, ...(vendorMediaIds.length > 0 ? { media: fields.media } : {}) } };
455
+ }
456
+
457
+ // ── media to the vendor ──────────────────────────────────────────────────────────────────────
458
+
459
+ /** How much of a video one APPEND carries. X accepts segments up to 5 MB. */
460
+ const APPEND_CHUNK_BYTES = 4 * 1024 * 1024;
461
+ /** How long, in total, a perform waits on X's video processing before it fails the entry
462
+ * retryably — the bound on a request at an `auto` head (see the header). */
463
+ export const MAX_PROCESSING_WAIT_MS = 120_000;
464
+ /** At most this many STATUS reads per video, whatever `check_after_secs` says. */
465
+ export const MAX_PROCESSING_POLLS = 30;
466
+
467
+ /** The vendor has the video but has not finished processing it inside the bound: retry later. */
468
+ export class XVideoStillProcessingError extends Error {
469
+ readonly retryable = true;
470
+ constructor(readonly mediaId: string, waitedMs: number, polls: number) {
471
+ super(`video still processing at the vendor (media ${mediaId}) after ${Math.round(waitedMs / 1000)}s and ${polls} STATUS reads — the entry stays pending; the next deploy performs it again`);
472
+ this.name = 'XVideoStillProcessingError';
473
+ }
474
+ }
475
+
476
+ /** A multipart/form-data body as bytes, with the boundary its content-type names. */
477
+ async function multipart(parts: Record<string, string | Uint8Array>): Promise<{ body: Uint8Array; contentType: string }> {
478
+ const form = new FormData();
479
+ for (const [name, value] of Object.entries(parts)) {
480
+ if (typeof value === 'string') form.append(name, value);
481
+ else { const copy = new Uint8Array(value.length); copy.set(value); form.append(name, new Blob([copy]), 'blob'); }
482
+ }
483
+ const encoded = new Response(form);
484
+ // read the boundary BEFORE the body: Bun drops the content-type once the body has been consumed
485
+ const contentType = encoded.headers.get('content-type');
486
+ if (!contentType) throw new Error('x: the runtime did not name a multipart boundary');
487
+ return { body: new Uint8Array(await encoded.arrayBuffer()), contentType };
488
+ }
489
+
490
+ async function vendorCall(execute: RemoteExecute, label: string, request: Parameters<RemoteExecute>[0]): Promise<Record<string, any>> {
491
+ const res = await execute({ ...request, headers: { accept: 'application/json', ...(request.headers ?? {}) } });
492
+ if (res.status < 200 || res.status >= 300) throw new Error(`x refused ${label}: HTTP ${res.status} ${res.body.slice(0, 200)}`);
493
+ let parsed: Record<string, any>;
494
+ try { parsed = res.body ? JSON.parse(res.body) : {}; } catch { throw new Error(`x answered ${label} with a body that is not JSON: ${res.body.slice(0, 200)}`); }
495
+ if (parsed?.errors && !parsed?.data) throw new Error(`x refused ${label}: ${res.body.slice(0, 200)}`);
496
+ return parsed;
497
+ }
498
+
499
+ /** One file to the vendor: an image in one request; a video initialized, appended in segments,
500
+ * finalized, then polled on STATUS as X's `processing_info` asks until it has succeeded. */
501
+ async function uploadMediaToVendor(execute: RemoteExecute, media: Record<string, any>, bytes: Uint8Array): Promise<{ id: string; media_key?: string }> {
502
+ const minted = (answer: Record<string, any>) => ({ id: String(answer.data.id), ...(typeof answer.data.media_key === 'string' ? { media_key: answer.data.media_key } : {}) });
503
+ const category = typeof media.media_category === 'string' ? media.media_category : media.type === 'video' ? 'tweet_video' : 'tweet_image';
504
+ if (media.type !== 'video') {
505
+ const form = await multipart({ media_category: category, media: bytes });
506
+ const answer = await vendorCall(execute, 'the image upload', { method: 'POST', path: '/2/media/upload', headers: { 'content-type': form.contentType }, body: form.body });
507
+ const id = answer?.data?.id;
508
+ if (typeof id !== 'string' || !/^\d+$/.test(id)) throw new Error(`x returned no media id for the image upload: ${JSON.stringify(answer).slice(0, 200)}`);
509
+ return minted(answer);
510
+ }
511
+ const init = await vendorCall(execute, 'the video upload (initialize)', {
512
+ method: 'POST', path: '/2/media/upload/initialize', headers: { 'content-type': 'application/json' },
513
+ body: JSON.stringify({ media_type: String(media.media_type ?? 'video/mp4'), total_bytes: bytes.length, media_category: category }),
514
+ });
515
+ const id = init?.data?.id;
516
+ if (typeof id !== 'string' || !/^\d+$/.test(id)) throw new Error(`x returned no media id for the video upload: ${JSON.stringify(init).slice(0, 200)}`);
517
+ for (let at = 0, index = 0; at < bytes.length; at += APPEND_CHUNK_BYTES, index += 1) {
518
+ const form = await multipart({ segment_index: String(index), media: bytes.subarray(at, at + APPEND_CHUNK_BYTES) });
519
+ await vendorCall(execute, `the video upload (append ${index})`, { method: 'POST', path: `/2/media/upload/${id}/append`, headers: { 'content-type': form.contentType }, body: form.body });
520
+ }
521
+ let state = await vendorCall(execute, 'the video upload (finalize)', { method: 'POST', path: `/2/media/upload/${id}/finalize` });
522
+ const started = Date.now();
523
+ const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
524
+ let polls = 0;
525
+ for (;;) {
526
+ const info = state?.data?.processing_info;
527
+ if (!info || info.state === 'succeeded') return minted({ data: { ...init.data, ...(state?.data ?? {}) } });
528
+ if (info.state === 'failed') throw new Error(`x failed to process video ${id}: ${JSON.stringify(state.errors ?? info).slice(0, 200)}`);
529
+ const remaining = MAX_PROCESSING_WAIT_MS - (Date.now() - started);
530
+ const waitMs = Math.max(1, Number(info.check_after_secs ?? 1)) * 1000;
531
+ if (polls >= MAX_PROCESSING_POLLS || waitMs > remaining) throw new XVideoStillProcessingError(id, Date.now() - started, polls);
532
+ await sleep(waitMs);
533
+ try {
534
+ state = await vendorCall(execute, 'the video upload (status)', { method: 'GET', path: `/2/media/upload?command=STATUS&media_id=${id}` });
535
+ } catch (error) {
536
+ // the budget's BURST bound refusing a poll is a wait, not a failure, while the bound has room
537
+ // (the poll is not sent until the budget admits it); past the bound it is the same verdict as
538
+ // any other wait that outlasts it — the video is still processing, retry on the next deploy
539
+ if (!(error instanceof XBudgetError) || error.kind !== 'burst') throw error;
540
+ if (error.retryAfterMs > MAX_PROCESSING_WAIT_MS - (Date.now() - started)) throw new XVideoStillProcessingError(id, Date.now() - started, polls);
541
+ await sleep(error.retryAfterMs);
542
+ continue;
543
+ }
544
+ polls += 1;
545
+ }
546
+ }