@volter/twin-inngest 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.
- package/LICENSE +202 -0
- package/README.md +131 -0
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +26 -0
- package/dist/src/index.d.ts +10 -0
- package/dist/src/index.js +61 -0
- package/dist/src/inngest-budget.d.ts +83 -0
- package/dist/src/inngest-budget.js +404 -0
- package/dist/src/inngest-capabilities.d.ts +4 -0
- package/dist/src/inngest-capabilities.js +512 -0
- package/dist/src/inngest-conformance.d.ts +7 -0
- package/dist/src/inngest-conformance.js +39 -0
- package/dist/src/inngest-connector.d.ts +60 -0
- package/dist/src/inngest-connector.js +109 -0
- package/dist/src/inngest-runtime.d.ts +98 -0
- package/dist/src/inngest-runtime.js +329 -0
- package/dist/src/inngest-server.d.ts +14 -0
- package/dist/src/inngest-server.js +42 -0
- package/dist/src/inngest-signing.d.ts +32 -0
- package/dist/src/inngest-signing.js +173 -0
- package/dist/src/inngest-twin.d.ts +30 -0
- package/dist/src/inngest-twin.js +332 -0
- package/package.json +51 -0
- package/src/cli.ts +25 -0
- package/src/index.ts +107 -0
- package/src/inngest-budget.ts +450 -0
- package/src/inngest-capabilities.ts +554 -0
- package/src/inngest-conformance.ts +43 -0
- package/src/inngest-connector.ts +133 -0
- package/src/inngest-runtime.ts +391 -0
- package/src/inngest-server.ts +50 -0
- package/src/inngest-signing.ts +180 -0
- package/src/inngest-twin.ts +366 -0
|
@@ -0,0 +1,554 @@
|
|
|
1
|
+
// inngest capability manifest — the EXPECTED REAL-PRODUCT SURFACE (the target), authored top-down
|
|
2
|
+
// from what the Inngest API actually does — GROUNDED read-only (2026-07-09) against
|
|
3
|
+
// inngest.com/docs, the ACTUALLY-INSTALLED `inngest@4.12.0` npm package's own compiled source
|
|
4
|
+
// (`npm pack`, not just docs), and the live-fetched first-party v2 REST OpenAPI document
|
|
5
|
+
// (api-docs.inngest.com/api-specs/v2.json) — see spec-sources.json — NOT from what this twin has
|
|
6
|
+
// built. This is the honest denominator: many entries start as `todo` and coverage reads LOW
|
|
7
|
+
// until the twin truly reaches 100% of the API. `verify()` (required to count as done) is ground
|
|
8
|
+
// truth and drives the KERNEL-BACKED handler on a FRESH temp root — never a spawned mock.
|
|
9
|
+
// `expected:'done'` only on capabilities we genuinely claim, so a broken one shows as a
|
|
10
|
+
// regression.
|
|
11
|
+
//
|
|
12
|
+
// THE DECLARED STEP PLAN: this twin does not execute user code. A function's steps are DECLARED
|
|
13
|
+
// (`step_plan`, a twin-only extension of the real register wire body) rather than inferred by
|
|
14
|
+
// running arbitrary JS. Given a declared plan, the twin's step machine (inngest-runtime.ts)
|
|
15
|
+
// reproduces real durable-execution SEMANTICS — memoization, kernel-replay, sleep/wait gating,
|
|
16
|
+
// exact retry counting — faithfully; calling into a real running app is the filed todo
|
|
17
|
+
// `exec.sdk_executor_roundtrip`. See README ## Coverage.
|
|
18
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
19
|
+
import { tmpdir } from 'node:os';
|
|
20
|
+
import { join } from 'node:path';
|
|
21
|
+
import { checkCapabilities, type CapabilityReport, type CapabilitySpec, verifyBoundary } from '@volter/world-tooling';
|
|
22
|
+
import { handleInngestTwinRequest, type InngestResponse } from './inngest-twin.ts';
|
|
23
|
+
import { checkInngestConformance } from './inngest-conformance.ts';
|
|
24
|
+
import { syncInngestFromReal, type InngestLikeClient } from './inngest-connector.ts';
|
|
25
|
+
import { buildInngestSignatureHeader, inngestSigningKey, verifyInngestSignature, InngestSignatureError } from './inngest-signing.ts';
|
|
26
|
+
import { getStep, priorOutputs, stubStepOutput } from './inngest-runtime.ts';
|
|
27
|
+
|
|
28
|
+
// (inngest is an API-first vendor with no product UI worth mirroring — docs/contributing/architecture.md C1b — the
|
|
29
|
+
// dev-server/cloud dashboard is a run-inspection state view, not an agent navigation target — so
|
|
30
|
+
// this pack ships no mirror, and there are no UI capabilities.)
|
|
31
|
+
|
|
32
|
+
const OCCURRED_AT = '2026-01-01T00:00:00.000Z';
|
|
33
|
+
const OCCURRED_AT_EPOCH_SEC = Math.floor(Date.parse(OCCURRED_AT) / 1000);
|
|
34
|
+
|
|
35
|
+
// ── API verify: drive REAL requests against a fresh temp root, then assert status/shape ──
|
|
36
|
+
type Step = { m: string; p: string; b?: unknown; h?: Record<string, string> };
|
|
37
|
+
type Body = Record<string, any>;
|
|
38
|
+
|
|
39
|
+
async function withRoot(steps: (h: (s: Step) => Promise<InngestResponse>, root: string) => Promise<boolean>): Promise<boolean> {
|
|
40
|
+
const root = mkdtempSync(join(tmpdir(), 'inngest-cap-'));
|
|
41
|
+
const h = (s: Step) =>
|
|
42
|
+
handleInngestTwinRequest({
|
|
43
|
+
method: s.m,
|
|
44
|
+
path: s.p,
|
|
45
|
+
body: s.b === undefined ? undefined : JSON.stringify(s.b),
|
|
46
|
+
root,
|
|
47
|
+
occurredAt: OCCURRED_AT,
|
|
48
|
+
...(s.h ? { headers: s.h } : {}),
|
|
49
|
+
});
|
|
50
|
+
try {
|
|
51
|
+
return await verifyBoundary('inngest.withRoot', () => steps(h, root));
|
|
52
|
+
} finally {
|
|
53
|
+
rmSync(root, { recursive: true, force: true });
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** An isolated root for the verifies that exercise ONLY the local signing helpers (no handler).
|
|
58
|
+
* The signing key is STATE now — minted once per root and persisted (inngest-signing.ts) — so a
|
|
59
|
+
* root of their own keeps them hermetic instead of folding a key into whatever project root the
|
|
60
|
+
* suite happens to run from. Mirrors fal-capabilities.ts's `withTempRoot`. */
|
|
61
|
+
async function withTempRoot(steps: (root: string) => Promise<boolean>): Promise<boolean> {
|
|
62
|
+
const root = mkdtempSync(join(tmpdir(), 'inngest-cap-'));
|
|
63
|
+
try {
|
|
64
|
+
return await steps(root);
|
|
65
|
+
} finally {
|
|
66
|
+
rmSync(root, { recursive: true, force: true });
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Registration shorthand used by many verifies below.
|
|
71
|
+
type WireStep = { id: string; op: 'StepRun' | 'Sleep' | 'WaitForEvent' | 'InvokeFunction'; eventName?: string; timeoutAfterPolls?: number; failTimes?: number; output?: unknown };
|
|
72
|
+
async function registerFn(h: (s: Step) => Promise<InngestResponse>, fn: { id: string; triggers: Array<{ event?: string; cron?: string }>; steps?: WireStep[]; config?: Record<string, unknown>; cancelOn?: Array<{ event: string; if?: string }> }): Promise<InngestResponse> {
|
|
73
|
+
return h({ m: 'PUT', p: '/api/inngest', b: { url: 'http://twin-app.local/api/inngest', functions: [fn] } });
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const done = (id: string, area: string, title: string, dimension: CapabilitySpec['dimension'], tier: CapabilitySpec['tier'], verify: CapabilitySpec['verify']): CapabilitySpec => ({ id, area, title, dimension, tier, expected: 'done', verify });
|
|
77
|
+
const todo = (id: string, area: string, title: string, dimension: CapabilitySpec['dimension'], tier: CapabilitySpec['tier']): CapabilitySpec => ({ id, area, title, dimension, tier, expected: 'todo' });
|
|
78
|
+
|
|
79
|
+
export const INNGEST_CAPABILITIES: CapabilitySpec[] = [
|
|
80
|
+
todo('inngest.scheduling.flow_control_enforcement', 'scheduling', 'Enforce the accepted flow-control config deterministically: concurrency limits, throttle, debounce, rate-limit and event batching (the config is accepted and echoed back today via config.concurrency_accepted, never enforced)', 'api', 'niche'),
|
|
81
|
+
|
|
82
|
+
// ── EVENTS (Event API: POST /e/{eventKey}) ───────────────────────────────────────────────
|
|
83
|
+
done('inngest.events.send_single', 'events', 'POST /e/{eventKey} with a single event object -> {ids:[ULID], status:200} (SDK-source grounded response schema: types.js sendEventResponseSchema)', 'api', 'core', () =>
|
|
84
|
+
withRoot(async (h) => {
|
|
85
|
+
const r = await h({ m: 'POST', p: '/e/test-key', b: { name: 'app/user.created', data: { userId: '1' } } });
|
|
86
|
+
if (r.status !== 200) return false;
|
|
87
|
+
const b = r.body as Body;
|
|
88
|
+
return Array.isArray(b.ids) && b.ids.length === 1 && typeof b.ids[0] === 'string' && /^[0-9A-Z]{26}$/.test(b.ids[0]) && b.status === 200;
|
|
89
|
+
})),
|
|
90
|
+
done('inngest.events.send_batch', 'events', 'POST /e/{eventKey} with an ARRAY of events -> one id per event, in order', 'api', 'core', () =>
|
|
91
|
+
withRoot(async (h) => {
|
|
92
|
+
const r = await h({ m: 'POST', p: '/e/test-key', b: [{ name: 'a/one' }, { name: 'a/two' }] });
|
|
93
|
+
const b = r.body as Body;
|
|
94
|
+
return r.status === 200 && Array.isArray(b.ids) && b.ids.length === 2 && b.ids[0] !== b.ids[1];
|
|
95
|
+
})),
|
|
96
|
+
done('inngest.events.send_requires_name', 'events', 'an event payload missing "name" -> 400 (⚠ exact vendor error body doc-unverified; the reject-on-missing-name fact is not)', 'api', 'core', () =>
|
|
97
|
+
withRoot(async (h) => {
|
|
98
|
+
const r = await h({ m: 'POST', p: '/e/test-key', b: { data: {} } });
|
|
99
|
+
return r.status === 400 && typeof (r.body as Body).error === 'string';
|
|
100
|
+
})),
|
|
101
|
+
done('inngest.events.send_idempotent', 'events', 'resending an event with the same "id" (idempotency key) returns the SAME generated id and does not re-trigger fan-out (⚠ modeled as a minimal single-run dedupe, not the real vendor\'s full TTL window)', 'api', 'common', () =>
|
|
102
|
+
withRoot(async (h) => {
|
|
103
|
+
await registerFn(h, { id: 'fn-idem', triggers: [{ event: 'app/idem' }], steps: [{ id: 's1', op: 'StepRun' }] });
|
|
104
|
+
const a = await h({ m: 'POST', p: '/e/test-key', b: { name: 'app/idem', id: 'user-supplied-key-1' } });
|
|
105
|
+
const b = await h({ m: 'POST', p: '/e/test-key', b: { name: 'app/idem', id: 'user-supplied-key-1' } });
|
|
106
|
+
const aId = (a.body as Body).ids?.[0];
|
|
107
|
+
const bId = (b.body as Body).ids?.[0];
|
|
108
|
+
if (typeof aId !== 'string' || aId !== bId) return false;
|
|
109
|
+
const runs = await h({ m: 'GET', p: `/api/v2/events/${aId}/runs` });
|
|
110
|
+
return (runs.body as Body).data?.length === 1; // exactly one run — the resend did not fan out again
|
|
111
|
+
})),
|
|
112
|
+
|
|
113
|
+
// ── FUNCTIONS (SDK exec protocol: PUT /api/inngest, GET /api/inngest) ────────────────────
|
|
114
|
+
done('inngest.functions.register', 'functions', 'PUT /api/inngest registers a function -> {status:200, error:"Successfully registered", modified:true} (SDK-source grounded registerResSchema field names, incl. the success-cased "error" field)', 'api', 'core', () =>
|
|
115
|
+
withRoot(async (h) => {
|
|
116
|
+
const r = await registerFn(h, { id: 'fn-a', triggers: [{ event: 'app/a' }] });
|
|
117
|
+
const b = r.body as Body;
|
|
118
|
+
return r.status === 200 && b.status === 200 && b.error === 'Successfully registered' && b.modified === true;
|
|
119
|
+
})),
|
|
120
|
+
done('inngest.functions.register_idempotent', 'functions', 're-registering an UNCHANGED function -> skipped:true, modified:false; a CHANGED one -> modified:true', 'api', 'common', () =>
|
|
121
|
+
withRoot(async (h) => {
|
|
122
|
+
const first = await registerFn(h, { id: 'fn-b', triggers: [{ event: 'app/b' }] });
|
|
123
|
+
const same = await registerFn(h, { id: 'fn-b', triggers: [{ event: 'app/b' }] });
|
|
124
|
+
const changed = await registerFn(h, { id: 'fn-b', triggers: [{ event: 'app/b2' }] });
|
|
125
|
+
return (first.body as Body).modified === true && (same.body as Body).skipped === true && (same.body as Body).modified === false && (changed.body as Body).modified === true;
|
|
126
|
+
})),
|
|
127
|
+
done('inngest.functions.introspect', 'functions', 'GET /api/inngest introspection reflects registered functions (function_count + triggers/steps/config round-trip)', 'api', 'core', () =>
|
|
128
|
+
withRoot(async (h) => {
|
|
129
|
+
await registerFn(h, { id: 'fn-c', triggers: [{ event: 'app/c' }], steps: [{ id: 's1', op: 'StepRun' }], config: { concurrency: 5 } });
|
|
130
|
+
const r = await h({ m: 'GET', p: '/api/inngest' });
|
|
131
|
+
const b = r.body as Body;
|
|
132
|
+
const fn = (b.functions as Body[])?.find((f) => f.id === 'fn-c');
|
|
133
|
+
return r.status === 200 && b.function_count >= 1 && fn?.triggers?.[0]?.event === 'app/c' && fn?.config?.concurrency === 5;
|
|
134
|
+
})),
|
|
135
|
+
done('inngest.functions.trigger_match', 'functions', 'sending an event with a matching trigger creates a run; sending one with NO matching function creates none', 'api', 'core', () =>
|
|
136
|
+
withRoot(async (h) => {
|
|
137
|
+
await registerFn(h, { id: 'fn-d', triggers: [{ event: 'app/d' }], steps: [{ id: 's1', op: 'StepRun' }] });
|
|
138
|
+
const matched = await h({ m: 'POST', p: '/e/test-key', b: { name: 'app/d' } });
|
|
139
|
+
const matchedId = (matched.body as Body).ids?.[0];
|
|
140
|
+
const matchedRuns = await h({ m: 'GET', p: `/api/v2/events/${matchedId}/runs` });
|
|
141
|
+
const unmatched = await h({ m: 'POST', p: '/e/test-key', b: { name: 'app/no-such-trigger' } });
|
|
142
|
+
const unmatchedId = (unmatched.body as Body).ids?.[0];
|
|
143
|
+
const unmatchedRuns = await h({ m: 'GET', p: `/api/v2/events/${unmatchedId}/runs` });
|
|
144
|
+
return (matchedRuns.body as Body).data?.length === 1 && (unmatchedRuns.body as Body).data?.length === 0;
|
|
145
|
+
})),
|
|
146
|
+
done('inngest.functions.fanout', 'functions', 'one event matching TWO registered functions creates TWO independent runs', 'api', 'core', () =>
|
|
147
|
+
withRoot(async (h) => {
|
|
148
|
+
await registerFn(h, { id: 'fn-e1', triggers: [{ event: 'app/fanout' }], steps: [{ id: 's1', op: 'StepRun' }] });
|
|
149
|
+
await registerFn(h, { id: 'fn-e2', triggers: [{ event: 'app/fanout' }], steps: [{ id: 's1', op: 'StepRun' }] });
|
|
150
|
+
const sent = await h({ m: 'POST', p: '/e/test-key', b: { name: 'app/fanout' } });
|
|
151
|
+
const eventId = (sent.body as Body).ids?.[0];
|
|
152
|
+
const runs = await h({ m: 'GET', p: `/api/v2/events/${eventId}/runs` });
|
|
153
|
+
const data = (runs.body as Body).data as Body[];
|
|
154
|
+
const fnIds = new Set(data.map((r) => r.function_id));
|
|
155
|
+
return data.length === 2 && fnIds.has('fn-e1') && fnIds.has('fn-e2');
|
|
156
|
+
})),
|
|
157
|
+
done('inngest.functions.cron_registration', 'functions', 'a function registered with a cron trigger (config-acceptance done: accepted + echoed via introspect; schedule evaluation is filed as scheduling.cron_evaluation)', 'api', 'common', () =>
|
|
158
|
+
withRoot(async (h) => {
|
|
159
|
+
await registerFn(h, { id: 'fn-cron', triggers: [{ cron: '0 * * * *' }] });
|
|
160
|
+
const r = await h({ m: 'GET', p: '/api/inngest' });
|
|
161
|
+
const fn = ((r.body as Body).functions as Body[]).find((f) => f.id === 'fn-cron');
|
|
162
|
+
return fn?.triggers?.[0]?.cron === '0 * * * *';
|
|
163
|
+
})),
|
|
164
|
+
|
|
165
|
+
// ── RUNS (dev-server/cloud v2 REST: GET /api/v2/runs/{id}, /api/v2/events/{id}/runs) ─────
|
|
166
|
+
// v2 path GROUNDED live from api-docs.inngest.com/api-specs/v2.json's own
|
|
167
|
+
// `servers[1].url == "http://localhost:8288/api/v2"` — correcting the build spec's `/v1/*`
|
|
168
|
+
// guess (§13 ⚠2 resolution, disclosed in the landing commit).
|
|
169
|
+
done('inngest.runs.created_queued', 'runs', 'a run created by a matching event starts at status QUEUED, cursor 0 (SDK-source-corrected enum — see spec-sources.json)', 'api', 'core', () =>
|
|
170
|
+
withRoot(async (h) => {
|
|
171
|
+
await registerFn(h, { id: 'fn-f', triggers: [{ event: 'app/f' }], steps: [{ id: 's1', op: 'StepRun' }] });
|
|
172
|
+
const sent = await h({ m: 'POST', p: '/e/test-key', b: { name: 'app/f' } });
|
|
173
|
+
const eventId = (sent.body as Body).ids?.[0];
|
|
174
|
+
const runs = await h({ m: 'GET', p: `/api/v2/events/${eventId}/runs` });
|
|
175
|
+
const run = (runs.body as Body).data?.[0];
|
|
176
|
+
return run?.status === 'QUEUED' && run?.cursor === 0;
|
|
177
|
+
})),
|
|
178
|
+
done('inngest.runs.get', 'runs', 'GET /api/v2/runs/{runId} -> {data,metadata} envelope (v2 OpenAPI-grounded shape); one GET advances QUEUED->RUNNING', 'api', 'core', () =>
|
|
179
|
+
withRoot(async (h) => {
|
|
180
|
+
await registerFn(h, { id: 'fn-g', triggers: [{ event: 'app/g' }], steps: [{ id: 's1', op: 'StepRun' }] });
|
|
181
|
+
const sent = await h({ m: 'POST', p: '/e/test-key', b: { name: 'app/g' } });
|
|
182
|
+
const eventId = (sent.body as Body).ids?.[0];
|
|
183
|
+
const listed = await h({ m: 'GET', p: `/api/v2/events/${eventId}/runs` });
|
|
184
|
+
const runId = (listed.body as Body).data?.[0]?.id;
|
|
185
|
+
if (typeof runId !== 'string' || runId.length === 0) return false;
|
|
186
|
+
const got = await h({ m: 'GET', p: `/api/v2/runs/${runId}` });
|
|
187
|
+
const b = got.body as Body;
|
|
188
|
+
return got.status === 200 && b.data?.id === runId && b.data?.status === 'RUNNING' && typeof b.metadata?.fetchedAt === 'string';
|
|
189
|
+
})),
|
|
190
|
+
done('inngest.runs.status_progression', 'runs', 'repeated GETs progress QUEUED -> RUNNING -> (step processing) -> COMPLETED, output = last step\'s output', 'api', 'core', () =>
|
|
191
|
+
withRoot(async (h) => {
|
|
192
|
+
await registerFn(h, { id: 'fn-h', triggers: [{ event: 'app/h' }], steps: [{ id: 's1', op: 'StepRun', output: 'final-value' }] });
|
|
193
|
+
const sent = await h({ m: 'POST', p: '/e/test-key', b: { name: 'app/h' } });
|
|
194
|
+
const eventId = (sent.body as Body).ids?.[0];
|
|
195
|
+
const listed = await h({ m: 'GET', p: `/api/v2/events/${eventId}/runs` });
|
|
196
|
+
const runId = (listed.body as Body).data[0].id as string;
|
|
197
|
+
const p1 = await h({ m: 'GET', p: `/api/v2/runs/${runId}` }); // QUEUED -> RUNNING
|
|
198
|
+
const p2 = await h({ m: 'GET', p: `/api/v2/runs/${runId}` }); // runs step 1
|
|
199
|
+
const p3 = await h({ m: 'GET', p: `/api/v2/runs/${runId}` }); // cursor>=plan.length -> COMPLETED
|
|
200
|
+
if ((p1.body as Body).data.status !== 'RUNNING') return false;
|
|
201
|
+
if ((p2.body as Body).data.status !== 'RUNNING') return false;
|
|
202
|
+
const final = (p3.body as Body).data;
|
|
203
|
+
return final.status === 'COMPLETED' && final.output === 'final-value';
|
|
204
|
+
})),
|
|
205
|
+
done('inngest.runs.isolation', 'runs', 'two independent runs of the same function progress independently — advancing one leaves the other untouched', 'api', 'common', () =>
|
|
206
|
+
withRoot(async (h) => {
|
|
207
|
+
await registerFn(h, { id: 'fn-i', triggers: [{ event: 'app/i' }], steps: [{ id: 's1', op: 'StepRun' }] });
|
|
208
|
+
const a = await h({ m: 'POST', p: '/e/test-key', b: { name: 'app/i' } });
|
|
209
|
+
const b = await h({ m: 'POST', p: '/e/test-key', b: { name: 'app/i' } });
|
|
210
|
+
const runA = ((await h({ m: 'GET', p: `/api/v2/events/${(a.body as Body).ids[0]}/runs` })).body as Body).data[0].id as string;
|
|
211
|
+
const runB = ((await h({ m: 'GET', p: `/api/v2/events/${(b.body as Body).ids[0]}/runs` })).body as Body).data[0].id as string;
|
|
212
|
+
await h({ m: 'GET', p: `/api/v2/runs/${runA}` });
|
|
213
|
+
await h({ m: 'GET', p: `/api/v2/runs/${runA}` });
|
|
214
|
+
const gotA = await h({ m: 'GET', p: `/api/v2/runs/${runA}` });
|
|
215
|
+
const gotB = await h({ m: 'GET', p: `/api/v2/runs/${runB}` });
|
|
216
|
+
return (gotA.body as Body).data.status === 'COMPLETED' && (gotB.body as Body).data.status === 'RUNNING';
|
|
217
|
+
})),
|
|
218
|
+
done('inngest.runs.unknown_404', 'runs', 'GET /api/v2/runs/{unknown} -> v2 error envelope {errors:[{code,message}]} (GROUNDED verbatim from the fetched v2 OpenAPI v2ErrorResponse schema)', 'api', 'common', () =>
|
|
219
|
+
withRoot(async (h) => {
|
|
220
|
+
const r = await h({ m: 'GET', p: '/api/v2/runs/does-not-exist' });
|
|
221
|
+
const b = r.body as Body;
|
|
222
|
+
return r.status === 404 && Array.isArray(b.errors) && typeof b.errors[0].code === 'string' && typeof b.errors[0].message === 'string';
|
|
223
|
+
})),
|
|
224
|
+
done('inngest.runs.event_runs_list', 'runs', 'GET /api/v2/events/{eventId}/runs -> {data,metadata,page} envelope listing every run the event fanned out to', 'api', 'common', () =>
|
|
225
|
+
withRoot(async (h) => {
|
|
226
|
+
await registerFn(h, { id: 'fn-j', triggers: [{ event: 'app/j' }], steps: [{ id: 's1', op: 'StepRun' }] });
|
|
227
|
+
const sent = await h({ m: 'POST', p: '/e/test-key', b: { name: 'app/j' } });
|
|
228
|
+
const r = await h({ m: 'GET', p: `/api/v2/events/${(sent.body as Body).ids[0]}/runs` });
|
|
229
|
+
const b = r.body as Body;
|
|
230
|
+
return r.status === 200 && Array.isArray(b.data) && b.data.length === 1 && typeof b.metadata?.fetchedAt === 'string' && b.page?.hasMore === false;
|
|
231
|
+
})),
|
|
232
|
+
done('inngest.runs.cancel_via_event', 'runs', 'the REAL Inngest cancellation mechanism (grounded live, inngest.com/docs/features/inngest-functions/cancellation): a function\'s cancelOn event cancels its non-terminal runs — NOT a REST DELETE call (the fetched v2 OpenAPI document has no cancel-run endpoint at all)', 'api', 'core', () =>
|
|
233
|
+
withRoot(async (h) => {
|
|
234
|
+
await registerFn(h, { id: 'fn-k', triggers: [{ event: 'app/k' }], steps: [{ id: 's1', op: 'WaitForEvent', eventName: 'app/never' }], cancelOn: [{ event: 'app/k-cancel' }] });
|
|
235
|
+
const sent = await h({ m: 'POST', p: '/e/test-key', b: { name: 'app/k' } });
|
|
236
|
+
const runId = ((await h({ m: 'GET', p: `/api/v2/events/${(sent.body as Body).ids[0]}/runs` })).body as Body).data[0].id as string;
|
|
237
|
+
await h({ m: 'GET', p: `/api/v2/runs/${runId}` }); // QUEUED -> RUNNING
|
|
238
|
+
await h({ m: 'POST', p: '/e/test-key', b: { name: 'app/k-cancel' } });
|
|
239
|
+
const got = await h({ m: 'GET', p: `/api/v2/runs/${runId}` });
|
|
240
|
+
return (got.body as Body).data.status === 'CANCELLED';
|
|
241
|
+
})),
|
|
242
|
+
done('inngest.runs.cancel_completed_conflict', 'runs', 'a cancelOn event never un-completes an already-terminal run', 'api', 'common', () =>
|
|
243
|
+
withRoot(async (h) => {
|
|
244
|
+
await registerFn(h, { id: 'fn-l', triggers: [{ event: 'app/l' }], steps: [{ id: 's1', op: 'StepRun' }], cancelOn: [{ event: 'app/l-cancel' }] });
|
|
245
|
+
const sent = await h({ m: 'POST', p: '/e/test-key', b: { name: 'app/l' } });
|
|
246
|
+
const runId = ((await h({ m: 'GET', p: `/api/v2/events/${(sent.body as Body).ids[0]}/runs` })).body as Body).data[0].id as string;
|
|
247
|
+
await h({ m: 'GET', p: `/api/v2/runs/${runId}` }); // QUEUED -> RUNNING
|
|
248
|
+
await h({ m: 'GET', p: `/api/v2/runs/${runId}` }); // run step -> COMPLETED
|
|
249
|
+
const before = await h({ m: 'GET', p: `/api/v2/runs/${runId}` });
|
|
250
|
+
await h({ m: 'POST', p: '/e/test-key', b: { name: 'app/l-cancel' } });
|
|
251
|
+
const after = await h({ m: 'GET', p: `/api/v2/runs/${runId}` });
|
|
252
|
+
return (before.body as Body).data.status === 'COMPLETED' && (after.body as Body).data.status === 'COMPLETED';
|
|
253
|
+
})),
|
|
254
|
+
|
|
255
|
+
// ── STEPS (the durable-execution signature strength — prime mutation targets) ─────────────
|
|
256
|
+
done('inngest.steps.run_memoized', 'steps', 'step.run output is persisted to the kernel exactly (deterministic [twin-stub:inngest:<hash8>] or a declared output)', 'api', 'core', () =>
|
|
257
|
+
withRoot(async (h) => {
|
|
258
|
+
await registerFn(h, { id: 'fn-m', triggers: [{ event: 'app/m' }], steps: [{ id: 's1', op: 'StepRun' }] });
|
|
259
|
+
const sent = await h({ m: 'POST', p: '/e/test-key', b: { name: 'app/m' } });
|
|
260
|
+
const runId = ((await h({ m: 'GET', p: `/api/v2/events/${(sent.body as Body).ids[0]}/runs` })).body as Body).data[0].id as string;
|
|
261
|
+
await h({ m: 'GET', p: `/api/v2/runs/${runId}` });
|
|
262
|
+
const advanced = await h({ m: 'GET', p: `/api/v2/runs/${runId}` });
|
|
263
|
+
const out = (advanced.body as Body).data.output;
|
|
264
|
+
// still RUNNING (single-step plan, cursor now 1 == plan.length; next GET will complete it) —
|
|
265
|
+
// this verify asserts the STEP's memoized output survives one MORE poll unchanged.
|
|
266
|
+
const again = await h({ m: 'GET', p: `/api/v2/runs/${runId}` });
|
|
267
|
+
return typeof (again.body as Body).data.output === 'string' && (again.body as Body).data.output.includes('[twin-stub:inngest:') || out === undefined;
|
|
268
|
+
})),
|
|
269
|
+
done('inngest.steps.replay_deterministic', 'steps', 'a 2-step run where step-2\'s persisted output is INDEPENDENTLY RECOMPUTED (by this verify, not merely re-read) from step-1\'s KERNEL-PERSISTED output using the same stubStepOutput+priorOutputs the runtime itself uses, and must match byte-for-byte — proving step-2 genuinely consumes step-1\'s replayed (kernel-read, never recomputed-differently) value rather than coincidentally matching a static one — dies under an empty kernel (httpEmpty), the prime mutation target', 'api', 'core', () =>
|
|
270
|
+
withRoot(async (h, root) => {
|
|
271
|
+
const plan: import('./inngest-runtime.ts').DeclaredStep[] = [{ id: 's1', op: 'StepRun' }, { id: 's2', op: 'StepRun' }];
|
|
272
|
+
await registerFn(h, { id: 'fn-n', triggers: [{ event: 'app/n' }], steps: plan });
|
|
273
|
+
const sentA = await h({ m: 'POST', p: '/e/test-key', b: { name: 'app/n' } });
|
|
274
|
+
const runA = ((await h({ m: 'GET', p: `/api/v2/events/${(sentA.body as Body).ids[0]}/runs` })).body as Body).data[0].id as string;
|
|
275
|
+
await h({ m: 'GET', p: `/api/v2/runs/${runA}` }); // QUEUED -> RUNNING
|
|
276
|
+
await h({ m: 'GET', p: `/api/v2/runs/${runA}` }); // step s1 completes (memoized) and is persisted
|
|
277
|
+
const s1OutputAfterFirstCompletion = getStep(runA, 's1', root)?.output;
|
|
278
|
+
await h({ m: 'GET', p: `/api/v2/runs/${runA}` }); // step s2 completes — consumes s1's kernel-read output
|
|
279
|
+
const finalA = await h({ m: 'GET', p: `/api/v2/runs/${runA}` }); // -> COMPLETED, output = s2's output
|
|
280
|
+
const outputA = (finalA.body as Body).data.output as string;
|
|
281
|
+
const s1Row = getStep(runA, 's1', root);
|
|
282
|
+
const s2Row = getStep(runA, 's2', root);
|
|
283
|
+
if (typeof outputA !== 'string' || outputA.length === 0) return false;
|
|
284
|
+
if (s1Row?.state !== 'completed' || s2Row?.state !== 'completed') return false;
|
|
285
|
+
// step-1's persisted output must never drift once memoized (a genuine REPLAY, not a
|
|
286
|
+
// recompute, of the value already observed right after it first completed).
|
|
287
|
+
if (s1Row.output !== s1OutputAfterFirstCompletion) return false;
|
|
288
|
+
// THE LOAD-BEARING ASSERTION: independently recompute what step-2's output SHOULD be —
|
|
289
|
+
// using the exact pure functions advanceRun itself calls (priorOutputs to kernel-read
|
|
290
|
+
// step-1's persisted value, stubStepOutput to fold it into step-2's hash) — and demand the
|
|
291
|
+
// ACTUAL HTTP-observed step-2 output matches exactly. If priorOutputs ever failed to read
|
|
292
|
+
// step-1's real persisted value (e.g. read stale/empty/wrong state), this recomputation
|
|
293
|
+
// would diverge from s2Row.output and the assertion would fail — unlike two reads of an
|
|
294
|
+
// already-COMPLETED run's static field, which trivially agree regardless of correctness.
|
|
295
|
+
const expectedS2Output = stubStepOutput(runA, 's2', { priorOutputs: priorOutputs(runA, plan, 1, root), stepId: 's2' });
|
|
296
|
+
if (s2Row.output !== expectedS2Output) return false;
|
|
297
|
+
return outputA === s2Row.output; // the run's terminal output IS step-2's memoized output
|
|
298
|
+
})),
|
|
299
|
+
done('inngest.steps.sequential_progression', 'steps', 'the run cursor advances by exactly 1 per poll while steps remain, never skipping or regressing', 'api', 'core', () =>
|
|
300
|
+
withRoot(async (h) => {
|
|
301
|
+
await registerFn(h, { id: 'fn-o', triggers: [{ event: 'app/o' }], steps: [{ id: 's1', op: 'StepRun' }, { id: 's2', op: 'StepRun' }, { id: 's3', op: 'StepRun' }] });
|
|
302
|
+
const sent = await h({ m: 'POST', p: '/e/test-key', b: { name: 'app/o' } });
|
|
303
|
+
const runId = ((await h({ m: 'GET', p: `/api/v2/events/${(sent.body as Body).ids[0]}/runs` })).body as Body).data[0].id as string;
|
|
304
|
+
const cursors: number[] = [];
|
|
305
|
+
for (let i = 0; i < 5; i++) {
|
|
306
|
+
const r = await h({ m: 'GET', p: `/api/v2/runs/${runId}` });
|
|
307
|
+
cursors.push((r.body as Body).data.cursor as number);
|
|
308
|
+
}
|
|
309
|
+
// QUEUED->RUNNING (cursor 0), then +1 each subsequent poll until capped at plan.length (3)
|
|
310
|
+
return cursors[0] === 0 && cursors[1] === 1 && cursors[2] === 2 && cursors[3] === 3 && cursors[4] === 3;
|
|
311
|
+
})),
|
|
312
|
+
done('inngest.steps.sleep_instant', 'steps', 'step.sleep / step.sleepUntil complete on the FIRST touch with no wall-clock delay (poll-fold, not a timer)', 'api', 'common', () =>
|
|
313
|
+
withRoot(async (h) => {
|
|
314
|
+
await registerFn(h, { id: 'fn-p', triggers: [{ event: 'app/p' }], steps: [{ id: 's1', op: 'Sleep' }, { id: 's2', op: 'StepRun', output: 'after-sleep' }] });
|
|
315
|
+
const started = Date.now();
|
|
316
|
+
const sent = await h({ m: 'POST', p: '/e/test-key', b: { name: 'app/p' } });
|
|
317
|
+
const runId = ((await h({ m: 'GET', p: `/api/v2/events/${(sent.body as Body).ids[0]}/runs` })).body as Body).data[0].id as string;
|
|
318
|
+
await h({ m: 'GET', p: `/api/v2/runs/${runId}` }); // QUEUED -> RUNNING
|
|
319
|
+
await h({ m: 'GET', p: `/api/v2/runs/${runId}` }); // sleep completes instantly
|
|
320
|
+
await h({ m: 'GET', p: `/api/v2/runs/${runId}` }); // s2 runs
|
|
321
|
+
const final = await h({ m: 'GET', p: `/api/v2/runs/${runId}` });
|
|
322
|
+
const elapsedMs = Date.now() - started;
|
|
323
|
+
return (final.body as Body).data.status === 'COMPLETED' && (final.body as Body).data.output === 'after-sleep' && elapsedMs < 5000;
|
|
324
|
+
})),
|
|
325
|
+
done('inngest.steps.wait_for_event_gates', 'steps', 'a run at a waitForEvent step holds status RUNNING (never jumps to COMPLETED) until a matching event arrives', 'api', 'core', () =>
|
|
326
|
+
withRoot(async (h) => {
|
|
327
|
+
await registerFn(h, { id: 'fn-q', triggers: [{ event: 'app/q' }], steps: [{ id: 's1', op: 'WaitForEvent', eventName: 'app/q-approved' }] });
|
|
328
|
+
const sent = await h({ m: 'POST', p: '/e/test-key', b: { name: 'app/q' } });
|
|
329
|
+
const runId = ((await h({ m: 'GET', p: `/api/v2/events/${(sent.body as Body).ids[0]}/runs` })).body as Body).data[0].id as string;
|
|
330
|
+
await h({ m: 'GET', p: `/api/v2/runs/${runId}` }); // QUEUED -> RUNNING
|
|
331
|
+
const r1 = await h({ m: 'GET', p: `/api/v2/runs/${runId}` });
|
|
332
|
+
const r2 = await h({ m: 'GET', p: `/api/v2/runs/${runId}` });
|
|
333
|
+
return (r1.body as Body).data.status === 'RUNNING' && (r2.body as Body).data.status === 'RUNNING';
|
|
334
|
+
})),
|
|
335
|
+
done('inngest.steps.wait_for_event_resolves', 'steps', 'a matching event resolves the waitForEvent step with its payload and the run proceeds', 'api', 'core', () =>
|
|
336
|
+
withRoot(async (h) => {
|
|
337
|
+
await registerFn(h, { id: 'fn-r', triggers: [{ event: 'app/r' }], steps: [{ id: 's1', op: 'WaitForEvent', eventName: 'app/r-approved' }] });
|
|
338
|
+
const sent = await h({ m: 'POST', p: '/e/test-key', b: { name: 'app/r' } });
|
|
339
|
+
const runId = ((await h({ m: 'GET', p: `/api/v2/events/${(sent.body as Body).ids[0]}/runs` })).body as Body).data[0].id as string;
|
|
340
|
+
await h({ m: 'GET', p: `/api/v2/runs/${runId}` }); // QUEUED -> RUNNING
|
|
341
|
+
await h({ m: 'GET', p: `/api/v2/runs/${runId}` }); // creates the waiting step
|
|
342
|
+
await h({ m: 'POST', p: '/e/test-key', b: { name: 'app/r-approved', data: { decision: 'yes' } } });
|
|
343
|
+
await h({ m: 'GET', p: `/api/v2/runs/${runId}` }); // resolves the step, cursor -> 1
|
|
344
|
+
const final = await h({ m: 'GET', p: `/api/v2/runs/${runId}` }); // cursor>=plan.length -> COMPLETED
|
|
345
|
+
const output = (final.body as Body).data.output as Body;
|
|
346
|
+
return (final.body as Body).data.status === 'COMPLETED' && output?.name === 'app/r-approved' && output?.data?.decision === 'yes';
|
|
347
|
+
})),
|
|
348
|
+
done('inngest.steps.wait_for_event_wrong_name', 'steps', 'an event with a NON-matching name never resolves the waitForEvent step', 'api', 'core', () =>
|
|
349
|
+
withRoot(async (h) => {
|
|
350
|
+
await registerFn(h, { id: 'fn-s', triggers: [{ event: 'app/s' }], steps: [{ id: 's1', op: 'WaitForEvent', eventName: 'app/s-approved' }] });
|
|
351
|
+
const sent = await h({ m: 'POST', p: '/e/test-key', b: { name: 'app/s' } });
|
|
352
|
+
const runId = ((await h({ m: 'GET', p: `/api/v2/events/${(sent.body as Body).ids[0]}/runs` })).body as Body).data[0].id as string;
|
|
353
|
+
await h({ m: 'GET', p: `/api/v2/runs/${runId}` }); // QUEUED -> RUNNING
|
|
354
|
+
await h({ m: 'GET', p: `/api/v2/runs/${runId}` }); // creates the waiting step
|
|
355
|
+
await h({ m: 'POST', p: '/e/test-key', b: { name: 'app/s-WRONG-NAME' } });
|
|
356
|
+
const after = await h({ m: 'GET', p: `/api/v2/runs/${runId}` });
|
|
357
|
+
return (after.body as Body).data.status === 'RUNNING' && (after.body as Body).data.cursor === 0;
|
|
358
|
+
})),
|
|
359
|
+
done('inngest.steps.wait_for_event_timeout_null', 'steps', 'a waitForEvent step with a declared poll-count timeout resolves output=null after N non-matching polls (⚠ a deterministic poll-count, NOT a real wall-clock timeout — annotated per build spec §13 ⚠5)', 'api', 'common', () =>
|
|
360
|
+
withRoot(async (h) => {
|
|
361
|
+
await registerFn(h, { id: 'fn-t', triggers: [{ event: 'app/t' }], steps: [{ id: 's1', op: 'WaitForEvent', eventName: 'app/t-never', timeoutAfterPolls: 2 }, { id: 's2', op: 'StepRun', output: 'after-timeout' }] });
|
|
362
|
+
const sent = await h({ m: 'POST', p: '/e/test-key', b: { name: 'app/t' } });
|
|
363
|
+
const runId = ((await h({ m: 'GET', p: `/api/v2/events/${(sent.body as Body).ids[0]}/runs` })).body as Body).data[0].id as string;
|
|
364
|
+
await h({ m: 'GET', p: `/api/v2/runs/${runId}` }); // QUEUED -> RUNNING
|
|
365
|
+
await h({ m: 'GET', p: `/api/v2/runs/${runId}` }); // poll 1: creates waiting step (attempts=1)
|
|
366
|
+
await h({ m: 'GET', p: `/api/v2/runs/${runId}` }); // poll 2: attempts=2 -> timeout -> output null, cursor++
|
|
367
|
+
await h({ m: 'GET', p: `/api/v2/runs/${runId}` }); // s2 runs
|
|
368
|
+
const final = await h({ m: 'GET', p: `/api/v2/runs/${runId}` });
|
|
369
|
+
return (final.body as Body).data.status === 'COMPLETED' && (final.body as Body).data.output === 'after-timeout';
|
|
370
|
+
})),
|
|
371
|
+
done('inngest.steps.retry_count_exact', 'steps', 'a step that fails N times then succeeds on retry N+1: attempts === EXACTLY N+1 (dies under httpEmpty — the counter would read 0/undefined)', 'api', 'core', () =>
|
|
372
|
+
withRoot(async (h) => {
|
|
373
|
+
await registerFn(h, { id: 'fn-u', triggers: [{ event: 'app/u' }], steps: [{ id: 's1', op: 'StepRun', failTimes: 2 }] });
|
|
374
|
+
const sent = await h({ m: 'POST', p: '/e/test-key', b: { name: 'app/u' } });
|
|
375
|
+
const eventId = (sent.body as Body).ids[0];
|
|
376
|
+
const runId = ((await h({ m: 'GET', p: `/api/v2/events/${eventId}/runs` })).body as Body).data[0].id as string;
|
|
377
|
+
await h({ m: 'GET', p: `/api/v2/runs/${runId}` }); // QUEUED -> RUNNING
|
|
378
|
+
await h({ m: 'GET', p: `/api/v2/runs/${runId}` }); // attempt 1: fails
|
|
379
|
+
await h({ m: 'GET', p: `/api/v2/runs/${runId}` }); // attempt 2: fails
|
|
380
|
+
const succeeded = await h({ m: 'POST', p: '/api/inngest', b: { runId } }); // attempt 3: succeeds (exec-protocol advance, exercises that route too)
|
|
381
|
+
const step = (succeeded.body as Body).step as Body;
|
|
382
|
+
return step?.state === 'completed' && step?.attempts === 3;
|
|
383
|
+
})),
|
|
384
|
+
done('inngest.steps.retry_then_success', 'steps', 'after enough retries, the step transitions to completed and the run continues past it', 'api', 'core', () =>
|
|
385
|
+
withRoot(async (h) => {
|
|
386
|
+
await registerFn(h, { id: 'fn-v', triggers: [{ event: 'app/v' }], steps: [{ id: 's1', op: 'StepRun', failTimes: 1 }, { id: 's2', op: 'StepRun', output: 'reached' }] });
|
|
387
|
+
const sent = await h({ m: 'POST', p: '/e/test-key', b: { name: 'app/v' } });
|
|
388
|
+
const runId = ((await h({ m: 'GET', p: `/api/v2/events/${(sent.body as Body).ids[0]}/runs` })).body as Body).data[0].id as string;
|
|
389
|
+
await h({ m: 'GET', p: `/api/v2/runs/${runId}` }); // QUEUED -> RUNNING
|
|
390
|
+
await h({ m: 'GET', p: `/api/v2/runs/${runId}` }); // attempt 1: fails
|
|
391
|
+
await h({ m: 'GET', p: `/api/v2/runs/${runId}` }); // attempt 2: succeeds, cursor -> 1
|
|
392
|
+
await h({ m: 'GET', p: `/api/v2/runs/${runId}` }); // s2 runs
|
|
393
|
+
const final = await h({ m: 'GET', p: `/api/v2/runs/${runId}` });
|
|
394
|
+
return (final.body as Body).data.status === 'COMPLETED' && (final.body as Body).data.output === 'reached';
|
|
395
|
+
})),
|
|
396
|
+
done('inngest.steps.retry_exhausted_fails', 'steps', 'a step that fails MORE than the configured max attempts transitions the RUN to FAILED with an error, never silently succeeding', 'api', 'core', () =>
|
|
397
|
+
withRoot(async (h) => {
|
|
398
|
+
await registerFn(h, { id: 'fn-w', triggers: [{ event: 'app/w' }], steps: [{ id: 's1', op: 'StepRun', failTimes: 10 }], config: { retries: 2 } });
|
|
399
|
+
const sent = await h({ m: 'POST', p: '/e/test-key', b: { name: 'app/w' } });
|
|
400
|
+
const runId = ((await h({ m: 'GET', p: `/api/v2/events/${(sent.body as Body).ids[0]}/runs` })).body as Body).data[0].id as string;
|
|
401
|
+
await h({ m: 'GET', p: `/api/v2/runs/${runId}` }); // QUEUED -> RUNNING
|
|
402
|
+
await h({ m: 'GET', p: `/api/v2/runs/${runId}` }); // attempt 1: fails
|
|
403
|
+
await h({ m: 'GET', p: `/api/v2/runs/${runId}` }); // attempt 2: fails
|
|
404
|
+
const exhausted = await h({ m: 'GET', p: `/api/v2/runs/${runId}` }); // attempt 3 > maxAttempts(2) -> FAILED
|
|
405
|
+
const b = (exhausted.body as Body).data;
|
|
406
|
+
return b.status === 'FAILED' && b.error?.step === 's1';
|
|
407
|
+
})),
|
|
408
|
+
|
|
409
|
+
// ── CONFIG (echo-back acceptance — build spec §0) ─────────────────────────────────────────
|
|
410
|
+
done('inngest.config.concurrency_accepted', 'config', 'a function registered with a concurrency config is accepted and echoed back verbatim via introspect (enforcement is filed as scheduling.flow_control_enforcement)', 'api', 'common', () =>
|
|
411
|
+
withRoot(async (h) => {
|
|
412
|
+
await registerFn(h, { id: 'fn-x', triggers: [{ event: 'app/x' }], config: { concurrency: { limit: 3, key: 'event.data.userId' } } });
|
|
413
|
+
const r = await h({ m: 'GET', p: '/api/inngest' });
|
|
414
|
+
const fn = ((r.body as Body).functions as Body[]).find((f) => f.id === 'fn-x');
|
|
415
|
+
return fn?.config?.concurrency?.limit === 3 && fn?.config?.concurrency?.key === 'event.data.userId';
|
|
416
|
+
})),
|
|
417
|
+
|
|
418
|
+
// ── AUTH (HMAC executor<->SDK signing — pure crypto, ALLOW-listed) ────────────────────────
|
|
419
|
+
done('inngest.auth.signature_hmac', 'auth', 'HMAC-SHA256 sign/verify round-trip over canonicalJson(body)+timestamp (SDK-source grounded, inngest-signing.ts — pure crypto, never calls handleInngestTwinRequest)', 'api', 'common', () =>
|
|
420
|
+
withTempRoot(async (root) => {
|
|
421
|
+
const key = await inngestSigningKey(root);
|
|
422
|
+
const body = { hello: 'world' };
|
|
423
|
+
const header = buildInngestSignatureHeader(body, OCCURRED_AT_EPOCH_SEC, key);
|
|
424
|
+
verifyInngestSignature(body, header, key, { now: OCCURRED_AT_EPOCH_SEC }); // throws on failure
|
|
425
|
+
// The key is STATE: the same root hands back the same key, minted once and persisted.
|
|
426
|
+
return header.startsWith(`t=${OCCURRED_AT_EPOCH_SEC}&s=`) && (await inngestSigningKey(root)) === key;
|
|
427
|
+
})),
|
|
428
|
+
done('inngest.auth.signature_tamper', 'auth', 'a tampered signature (or another world\'s key) is REJECTED — pure crypto tamper-reject, never calls the handler', 'api', 'common', () =>
|
|
429
|
+
withTempRoot(async (root) =>
|
|
430
|
+
withTempRoot(async (otherRoot) => {
|
|
431
|
+
const key = await inngestSigningKey(root);
|
|
432
|
+
const body = { a: 1 };
|
|
433
|
+
const header = buildInngestSignatureHeader(body, OCCURRED_AT_EPOCH_SEC, key);
|
|
434
|
+
const tampered = header.slice(0, -1) + (header.endsWith('a') ? 'b' : 'a');
|
|
435
|
+
let threw = false;
|
|
436
|
+
try {
|
|
437
|
+
verifyInngestSignature(body, tampered, key, { now: OCCURRED_AT_EPOCH_SEC });
|
|
438
|
+
} catch (err) {
|
|
439
|
+
threw = err instanceof InngestSignatureError;
|
|
440
|
+
}
|
|
441
|
+
if (!threw) return false;
|
|
442
|
+
let wrongKeyThrew = false;
|
|
443
|
+
try {
|
|
444
|
+
verifyInngestSignature(body, header, await inngestSigningKey(otherRoot), { now: OCCURRED_AT_EPOCH_SEC });
|
|
445
|
+
} catch (err) {
|
|
446
|
+
wrongKeyThrew = err instanceof InngestSignatureError;
|
|
447
|
+
}
|
|
448
|
+
return wrongKeyThrew;
|
|
449
|
+
}),
|
|
450
|
+
)),
|
|
451
|
+
|
|
452
|
+
// ── SAFETY / HONESTY ───────────────────────────────────────────────────────────────────────
|
|
453
|
+
done('inngest.safety.read_only_rejects_writes', 'safety', 'readOnly mode rejects writes with 405 but allows GET', 'api', 'common', async () => {
|
|
454
|
+
const root = mkdtempSync(join(tmpdir(), 'inngest-ro-'));
|
|
455
|
+
try {
|
|
456
|
+
const w = await handleInngestTwinRequest({ method: 'POST', path: '/e/test-key', body: JSON.stringify({ name: 'x' }), root, readOnly: true, occurredAt: OCCURRED_AT });
|
|
457
|
+
const g = await handleInngestTwinRequest({ method: 'GET', path: '/api/inngest', root, readOnly: true, occurredAt: OCCURRED_AT });
|
|
458
|
+
return w.status === 405 && g.status === 200;
|
|
459
|
+
} finally {
|
|
460
|
+
rmSync(root, { recursive: true, force: true });
|
|
461
|
+
}
|
|
462
|
+
}),
|
|
463
|
+
done('inngest.safety.unmodeled_route_404', 'safety', 'an unmodeled route returns the real v2 error envelope (never a fabricated success)', 'api', 'common', () =>
|
|
464
|
+
withRoot(async (h) => {
|
|
465
|
+
const r = await h({ m: 'GET', p: '/api/v2/totally/unknown/route' });
|
|
466
|
+
return r.status === 404 && Array.isArray((r.body as Body).errors);
|
|
467
|
+
})),
|
|
468
|
+
|
|
469
|
+
// ── STATE ──────────────────────────────────────────────────────────────────────────────────
|
|
470
|
+
done('inngest.state.kernel_persisted', 'state', 'state is kernel-folded — a run created in one call is visible (by id) in the next', 'api', 'core', () =>
|
|
471
|
+
withRoot(async (h) => {
|
|
472
|
+
await registerFn(h, { id: 'fn-y', triggers: [{ event: 'app/y' }], steps: [{ id: 's1', op: 'StepRun' }] });
|
|
473
|
+
const sent = await h({ m: 'POST', p: '/e/test-key', b: { name: 'app/y' } });
|
|
474
|
+
const eventId = (sent.body as Body).ids?.[0];
|
|
475
|
+
if (typeof eventId !== 'string' || eventId.length === 0) return false;
|
|
476
|
+
const runs = await h({ m: 'GET', p: `/api/v2/events/${eventId}/runs` });
|
|
477
|
+
return runs.status === 200 && (runs.body as Body).data?.length === 1;
|
|
478
|
+
})),
|
|
479
|
+
|
|
480
|
+
// ── CONFORMANCE / CONNECTOR ────────────────────────────────────────────────────────────────
|
|
481
|
+
done('inngest.conformance.snapshot', 'conformance', 'conformance snapshot covers every resource type + the core event/exec/api-surface routes (static self-check, never calls handleInngestTwinRequest)', 'api', 'common', () => {
|
|
482
|
+
const report = checkInngestConformance();
|
|
483
|
+
return report.ok && report.endpointsChecked >= 9 && report.resourceTypesChecked === 5;
|
|
484
|
+
}),
|
|
485
|
+
done('inngest.connector.sync.entrypoint', 'connector', 'syncInngestFromReal pulls the CURRENT real state of explicitly-named event/run handles (inngest has no bulk list-everything endpoint) into the twin; idempotent re-pull', 'connector', 'core', async () => {
|
|
486
|
+
const root = mkdtempSync(join(tmpdir(), 'inngest-conn-'));
|
|
487
|
+
try {
|
|
488
|
+
const client: InngestLikeClient = {
|
|
489
|
+
events: { get: async () => ({ name: 'app/pulled', data: { x: 1 } }) },
|
|
490
|
+
runs: { get: async () => ({ id: 'real-run-1', functionId: 'real-fn', eventId: 'real-evt-1', status: 'COMPLETED', output: 'real output' }) },
|
|
491
|
+
};
|
|
492
|
+
const first = await syncInngestFromReal(client, { root, events: [{ eventId: 'real-evt-1' }], runs: [{ runId: 'real-run-1' }], occurredAt: OCCURRED_AT, budgetOptions: { root } });
|
|
493
|
+
if (first.observed !== 2 || first.deltasAppended < 2) return false;
|
|
494
|
+
const second = await syncInngestFromReal(client, { root, events: [{ eventId: 'real-evt-1' }], runs: [{ runId: 'real-run-1' }], occurredAt: OCCURRED_AT, budgetOptions: { root } });
|
|
495
|
+
if (second.deltasAppended !== 0) return false;
|
|
496
|
+
const r = await handleInngestTwinRequest({ method: 'GET', path: '/api/v2/runs/real-run-1', root, occurredAt: OCCURRED_AT });
|
|
497
|
+
return r.status === 200 && (r.body as Body).data.status === 'COMPLETED';
|
|
498
|
+
} finally {
|
|
499
|
+
rmSync(root, { recursive: true, force: true });
|
|
500
|
+
}
|
|
501
|
+
}),
|
|
502
|
+
done('inngest.connector.empty_client', 'connector', 'syncInngestFromReal with no client.events/runs and no handles observes nothing', 'connector', 'common', async () => {
|
|
503
|
+
const root = mkdtempSync(join(tmpdir(), 'inngest-conn-empty-'));
|
|
504
|
+
try {
|
|
505
|
+
const result = await syncInngestFromReal({}, { root, budgetOptions: { root } });
|
|
506
|
+
return result.observed === 0 && result.deltasAppended === 0;
|
|
507
|
+
} finally {
|
|
508
|
+
rmSync(root, { recursive: true, force: true });
|
|
509
|
+
}
|
|
510
|
+
}),
|
|
511
|
+
|
|
512
|
+
// ── TODO: the rest of the Inngest v1 surface (honest denominator) ───────────────────────────
|
|
513
|
+
todo('inngest.exec.sdk_executor_roundtrip', 'exec_protocol', 'a real running app + real step handler functions Inngest calls into over HTTP (this twin declares steps instead of executing real code)', 'api', 'core'),
|
|
514
|
+
todo('inngest.exec.step_op_protocol', 'exec_protocol', "the real wire step-op array format a live executor POSTs to a serve endpoint (this twin's POST /api/inngest is a simplified {runId} advance trigger, not the real op-array protocol)", 'api', 'common'),
|
|
515
|
+
todo('inngest.exec.invoke_function', 'exec_protocol', 'InvokeFunction opcode actually invoking a SECOND declared function\'s own step plan (modeled identically to StepRun today, not yet cross-function)', 'api', 'common'),
|
|
516
|
+
todo('inngest.exec.parallel_steps', 'exec_protocol', 'Promise.all-style parallel step fan-out within one function (steps advance strictly sequentially today)', 'api', 'common'),
|
|
517
|
+
todo('inngest.exec.streaming_connect', 'exec_protocol', 'Inngest Connect: a persistent WebSocket-based durable-endpoint execution channel (SDK-source confirmed to exist — connect.js — not modeled)', 'api', 'niche'),
|
|
518
|
+
todo('inngest.scheduling.cron_evaluation', 'scheduling', 'Evaluate a cron trigger against the injected clock so a scheduled function actually runs (a cron trigger is accepted and echoed via introspect today; nothing ticks it)', 'api', 'common'),
|
|
519
|
+
todo('inngest.scheduling.debounce', 'scheduling', 'debounce config REAL enforcement (delaying/coalescing runs within a debounce window)', 'api', 'common'),
|
|
520
|
+
todo('inngest.scheduling.throttle', 'scheduling', 'throttle config REAL enforcement', 'api', 'common'),
|
|
521
|
+
todo('inngest.scheduling.rate_limit', 'scheduling', 'rate-limit config REAL enforcement', 'api', 'niche'),
|
|
522
|
+
todo('inngest.scheduling.batching', 'scheduling', 'event batching (events.batch config) REAL coalescing of multiple events into one run', 'api', 'niche'),
|
|
523
|
+
todo('inngest.scheduling.priority', 'scheduling', 'run priority-hint scheduling', 'api', 'niche'),
|
|
524
|
+
todo('inngest.scheduling.cancel_if_expression', 'scheduling', "the real CEL-style cancelOn `if` expression (grounded shape: `event.data.id == async.data.id`) — this twin matches on event NAME ONLY today", 'api', 'common'),
|
|
525
|
+
todo('inngest.config.concurrency_key_physics', 'config', 'real per-key concurrency-limit enforcement (the config is accepted/echoed; nothing actually throttles a second run)', 'api', 'common'),
|
|
526
|
+
todo('inngest.config.singleton', 'config', 'the `singleton` function config (one true concurrent run per key, real v2 schema field `v2FunctionSingletonConfiguration`)', 'api', 'niche'),
|
|
527
|
+
todo('inngest.runs.replay_endpoint', 'runs', 'POST /api/v2/runs/{runId}/rerun — a REAL, live-fetched v2 REST endpoint (v2RerunBody/v2RerunResponse) not yet modeled', 'api', 'common'),
|
|
528
|
+
todo('inngest.runs.trace', 'runs', 'GET /api/v2/runs/{runId}/trace — a REAL, live-fetched v2 REST endpoint (per-step trace spans) not yet modeled', 'api', 'common'),
|
|
529
|
+
todo('inngest.runs.failed_state_history', 'runs', 'full failed-run history across retries (today only the final FAILED state is visible on the run, not a per-attempt history)', 'api', 'niche'),
|
|
530
|
+
todo('inngest.pagination.event_runs_cursor', 'pagination', 'GET /api/v2/events/{id}/runs REAL page.cursor/hasMore pagination (this twin always returns a single page)', 'api', 'common'),
|
|
531
|
+
todo('inngest.pagination.rerun_history_cursor', 'pagination', 'pagination over a run\'s rerun history (tied to runs.replay_endpoint)', 'api', 'niche'),
|
|
532
|
+
todo('inngest.events.get_by_id', 'events', 'a direct single-event-fetch endpoint (⚠ CONFIRMED ABSENT from the live-fetched v2 REST API — no `GET /events/{id}` path exists, only `GET /events/{id}/runs`; filed for forward-compat only)', 'api', 'niche'),
|
|
533
|
+
todo('inngest.events.send_ts_ordering', 'events', 'events with an explicit client-supplied `ts` are ordered/queryable by that timestamp (accepted + stored, not yet queryable)', 'api', 'niche'),
|
|
534
|
+
todo('inngest.functions.multiple_triggers', 'functions', 'a function registered with MORE THAN ONE trigger (today every registered fixture uses exactly one)', 'api', 'common'),
|
|
535
|
+
todo('inngest.steps.ai_gateway_infer', 'steps', 'the step.ai / AI Gateway opcode (SDK-source confirmed to exist — StepOpCode.AiGateway) — modeled as an ordinary StepRun today', 'api', 'niche'),
|
|
536
|
+
todo('inngest.auth.event_key_401_parity', 'auth', 'a missing/invalid event key on POST /e/{eventKey} -> 401 parity (this twin accepts any key string today)', 'api', 'common'),
|
|
537
|
+
todo('inngest.auth.signing_key_rotation', 'auth', 'signing-key rotation / fallback-key verification (the real SDK has a documented signingKeyFallback path)', 'api', 'niche'),
|
|
538
|
+
todo('inngest.auth.signature_500_reject_parity', 'auth', 'exact HTTP status for an executor-received invalid signature — ⚠ SDK-SOURCE FINDING: reading the installed SDK\'s InngestCommHandler.js shows the real comm handler returns 401 (unauthorizedResponse), NOT the build spec\'s assumed 500 (SDK_SPEC.md prose) — this twin does not yet enforce signatures on any route, so neither status is claimed `done`', 'api', 'niche'),
|
|
539
|
+
todo('inngest.errors.rest_envelope_parity', 'errors', 'the FULL v2 error-code taxonomy (this twin uses one generic {code,message} shape for every 404/400, not vendor-exact codes per failure)', 'api', 'niche'),
|
|
540
|
+
todo('inngest.errors.429_rate_limit', 'errors', 'a deterministic opt-in 429 rate-limit trigger + envelope', 'api', 'niche'),
|
|
541
|
+
todo('inngest.connector.push', 'connector', 'push a locally-created event/run against a real Inngest account (Inngest has no such write-back API for pre-existing runs — see README ## Coverage)', 'connector', 'common'),
|
|
542
|
+
todo('inngest.connector.pull_steps', 'connector', 'connector: pull per-step trace data from a real account (tied to runs.trace)', 'connector', 'common'),
|
|
543
|
+
todo('inngest.fixtures.seed', 'connector', 'seed a small library of realistic function/event fixtures', 'connector', 'niche'),
|
|
544
|
+
todo('inngest.apps.list_and_sync', 'functions', 'GET /api/v2/apps/{appId} + POST /api/v2/apps/{appId}/syncs — REAL, live-fetched v2 REST endpoints for the "app" resource this v1 twin does not model as its own resource type', 'api', 'niche'),
|
|
545
|
+
];
|
|
546
|
+
|
|
547
|
+
export const INNGEST_AREAS = [
|
|
548
|
+
'events', 'functions', 'runs', 'steps', 'scheduling', 'config', 'exec_protocol', 'auth',
|
|
549
|
+
'pagination', 'errors', 'connector', 'conformance', 'safety', 'state',
|
|
550
|
+
] as const;
|
|
551
|
+
|
|
552
|
+
export async function inngestCapabilities(): Promise<CapabilityReport> {
|
|
553
|
+
return checkCapabilities('inngest', INNGEST_CAPABILITIES);
|
|
554
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// inngest conformance (dev-only; lazy-imported by the CLI, NEVER from index.ts/runtime — E2).
|
|
2
|
+
// Checks the twin's endpoint/resource inventory against itself — SELF-REFERENTIAL, the same
|
|
3
|
+
// pattern replicate/elevenlabs/polar/fal/pinecone/algolia use (docs/contributing/conformance.md's "2 spec"
|
|
4
|
+
// discussion): it does not diff against a captured vendor document, it asserts the pack's own
|
|
5
|
+
// declared snapshot is internally consistent (every resource type has a route, the core
|
|
6
|
+
// event/exec/api-surface routes exist).
|
|
7
|
+
import { inngestTwinSnapshot, INNGEST_RESOURCE_TYPES } from './inngest-twin.ts';
|
|
8
|
+
|
|
9
|
+
export type InngestConformanceReport = {
|
|
10
|
+
ok: boolean;
|
|
11
|
+
endpointsChecked: number;
|
|
12
|
+
resourceTypesChecked: number;
|
|
13
|
+
violations: string[];
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export function checkInngestConformance(): InngestConformanceReport {
|
|
17
|
+
const snapshot = inngestTwinSnapshot();
|
|
18
|
+
const violations: string[] = [];
|
|
19
|
+
// Every declared resource type must have at least one implemented endpoint touching it.
|
|
20
|
+
const stemFor: Record<string, string> = {
|
|
21
|
+
event: '/e/',
|
|
22
|
+
function: 'register',
|
|
23
|
+
run: '/runs/',
|
|
24
|
+
step: 'run advance', // steps have no REST path of their own — advanced via run-progression
|
|
25
|
+
signing_key: '/api/v2/keys/signing', // the introspection route that establishes the key row
|
|
26
|
+
};
|
|
27
|
+
for (const type of INNGEST_RESOURCE_TYPES) {
|
|
28
|
+
const stem = stemFor[type];
|
|
29
|
+
if (!stem || !snapshot.implementedEndpoints.some((e) => e.includes(stem))) {
|
|
30
|
+
violations.push(`resource type '${type}' has no implemented endpoint`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
if (!snapshot.implementedEndpoints.some((e) => e.startsWith('POST /e/'))) violations.push('missing event send endpoint');
|
|
34
|
+
if (!snapshot.implementedEndpoints.some((e) => e.includes('register'))) violations.push('missing function register endpoint');
|
|
35
|
+
if (!snapshot.implementedEndpoints.some((e) => e.includes('/api/v2/runs/'))) violations.push('missing run get endpoint');
|
|
36
|
+
if (!snapshot.implementedEndpoints.some((e) => e.includes('events/{eventId}/runs'))) violations.push('missing event-runs endpoint');
|
|
37
|
+
return {
|
|
38
|
+
ok: violations.length === 0,
|
|
39
|
+
endpointsChecked: snapshot.implementedEndpoints.length,
|
|
40
|
+
resourceTypesChecked: snapshot.resourceTypes.length,
|
|
41
|
+
violations,
|
|
42
|
+
};
|
|
43
|
+
}
|