@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,512 @@
|
|
|
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, verifyBoundary } from '@volter/world-tooling';
|
|
22
|
+
import { handleInngestTwinRequest } from "./inngest-twin.js";
|
|
23
|
+
import { checkInngestConformance } from "./inngest-conformance.js";
|
|
24
|
+
import { syncInngestFromReal } from "./inngest-connector.js";
|
|
25
|
+
import { buildInngestSignatureHeader, inngestSigningKey, verifyInngestSignature, InngestSignatureError } from "./inngest-signing.js";
|
|
26
|
+
import { getStep, priorOutputs, stubStepOutput } from "./inngest-runtime.js";
|
|
27
|
+
// (inngest is an API-first vendor with no product UI worth mirroring — docs/contributing/architecture.md C1b — the
|
|
28
|
+
// dev-server/cloud dashboard is a run-inspection state view, not an agent navigation target — so
|
|
29
|
+
// this pack ships no mirror, and there are no UI capabilities.)
|
|
30
|
+
const OCCURRED_AT = '2026-01-01T00:00:00.000Z';
|
|
31
|
+
const OCCURRED_AT_EPOCH_SEC = Math.floor(Date.parse(OCCURRED_AT) / 1000);
|
|
32
|
+
async function withRoot(steps) {
|
|
33
|
+
const root = mkdtempSync(join(tmpdir(), 'inngest-cap-'));
|
|
34
|
+
const h = (s) => handleInngestTwinRequest({
|
|
35
|
+
method: s.m,
|
|
36
|
+
path: s.p,
|
|
37
|
+
body: s.b === undefined ? undefined : JSON.stringify(s.b),
|
|
38
|
+
root,
|
|
39
|
+
occurredAt: OCCURRED_AT,
|
|
40
|
+
...(s.h ? { headers: s.h } : {}),
|
|
41
|
+
});
|
|
42
|
+
try {
|
|
43
|
+
return await verifyBoundary('inngest.withRoot', () => steps(h, root));
|
|
44
|
+
}
|
|
45
|
+
finally {
|
|
46
|
+
rmSync(root, { recursive: true, force: true });
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
/** An isolated root for the verifies that exercise ONLY the local signing helpers (no handler).
|
|
50
|
+
* The signing key is STATE now — minted once per root and persisted (inngest-signing.ts) — so a
|
|
51
|
+
* root of their own keeps them hermetic instead of folding a key into whatever project root the
|
|
52
|
+
* suite happens to run from. Mirrors fal-capabilities.ts's `withTempRoot`. */
|
|
53
|
+
async function withTempRoot(steps) {
|
|
54
|
+
const root = mkdtempSync(join(tmpdir(), 'inngest-cap-'));
|
|
55
|
+
try {
|
|
56
|
+
return await steps(root);
|
|
57
|
+
}
|
|
58
|
+
finally {
|
|
59
|
+
rmSync(root, { recursive: true, force: true });
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
async function registerFn(h, fn) {
|
|
63
|
+
return h({ m: 'PUT', p: '/api/inngest', b: { url: 'http://twin-app.local/api/inngest', functions: [fn] } });
|
|
64
|
+
}
|
|
65
|
+
const done = (id, area, title, dimension, tier, verify) => ({ id, area, title, dimension, tier, expected: 'done', verify });
|
|
66
|
+
const todo = (id, area, title, dimension, tier) => ({ id, area, title, dimension, tier, expected: 'todo' });
|
|
67
|
+
export const INNGEST_CAPABILITIES = [
|
|
68
|
+
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'),
|
|
69
|
+
// ── EVENTS (Event API: POST /e/{eventKey}) ───────────────────────────────────────────────
|
|
70
|
+
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', () => withRoot(async (h) => {
|
|
71
|
+
const r = await h({ m: 'POST', p: '/e/test-key', b: { name: 'app/user.created', data: { userId: '1' } } });
|
|
72
|
+
if (r.status !== 200)
|
|
73
|
+
return false;
|
|
74
|
+
const b = r.body;
|
|
75
|
+
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;
|
|
76
|
+
})),
|
|
77
|
+
done('inngest.events.send_batch', 'events', 'POST /e/{eventKey} with an ARRAY of events -> one id per event, in order', 'api', 'core', () => withRoot(async (h) => {
|
|
78
|
+
const r = await h({ m: 'POST', p: '/e/test-key', b: [{ name: 'a/one' }, { name: 'a/two' }] });
|
|
79
|
+
const b = r.body;
|
|
80
|
+
return r.status === 200 && Array.isArray(b.ids) && b.ids.length === 2 && b.ids[0] !== b.ids[1];
|
|
81
|
+
})),
|
|
82
|
+
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', () => withRoot(async (h) => {
|
|
83
|
+
const r = await h({ m: 'POST', p: '/e/test-key', b: { data: {} } });
|
|
84
|
+
return r.status === 400 && typeof r.body.error === 'string';
|
|
85
|
+
})),
|
|
86
|
+
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', () => withRoot(async (h) => {
|
|
87
|
+
await registerFn(h, { id: 'fn-idem', triggers: [{ event: 'app/idem' }], steps: [{ id: 's1', op: 'StepRun' }] });
|
|
88
|
+
const a = await h({ m: 'POST', p: '/e/test-key', b: { name: 'app/idem', id: 'user-supplied-key-1' } });
|
|
89
|
+
const b = await h({ m: 'POST', p: '/e/test-key', b: { name: 'app/idem', id: 'user-supplied-key-1' } });
|
|
90
|
+
const aId = a.body.ids?.[0];
|
|
91
|
+
const bId = b.body.ids?.[0];
|
|
92
|
+
if (typeof aId !== 'string' || aId !== bId)
|
|
93
|
+
return false;
|
|
94
|
+
const runs = await h({ m: 'GET', p: `/api/v2/events/${aId}/runs` });
|
|
95
|
+
return runs.body.data?.length === 1; // exactly one run — the resend did not fan out again
|
|
96
|
+
})),
|
|
97
|
+
// ── FUNCTIONS (SDK exec protocol: PUT /api/inngest, GET /api/inngest) ────────────────────
|
|
98
|
+
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', () => withRoot(async (h) => {
|
|
99
|
+
const r = await registerFn(h, { id: 'fn-a', triggers: [{ event: 'app/a' }] });
|
|
100
|
+
const b = r.body;
|
|
101
|
+
return r.status === 200 && b.status === 200 && b.error === 'Successfully registered' && b.modified === true;
|
|
102
|
+
})),
|
|
103
|
+
done('inngest.functions.register_idempotent', 'functions', 're-registering an UNCHANGED function -> skipped:true, modified:false; a CHANGED one -> modified:true', 'api', 'common', () => withRoot(async (h) => {
|
|
104
|
+
const first = await registerFn(h, { id: 'fn-b', triggers: [{ event: 'app/b' }] });
|
|
105
|
+
const same = await registerFn(h, { id: 'fn-b', triggers: [{ event: 'app/b' }] });
|
|
106
|
+
const changed = await registerFn(h, { id: 'fn-b', triggers: [{ event: 'app/b2' }] });
|
|
107
|
+
return first.body.modified === true && same.body.skipped === true && same.body.modified === false && changed.body.modified === true;
|
|
108
|
+
})),
|
|
109
|
+
done('inngest.functions.introspect', 'functions', 'GET /api/inngest introspection reflects registered functions (function_count + triggers/steps/config round-trip)', 'api', 'core', () => withRoot(async (h) => {
|
|
110
|
+
await registerFn(h, { id: 'fn-c', triggers: [{ event: 'app/c' }], steps: [{ id: 's1', op: 'StepRun' }], config: { concurrency: 5 } });
|
|
111
|
+
const r = await h({ m: 'GET', p: '/api/inngest' });
|
|
112
|
+
const b = r.body;
|
|
113
|
+
const fn = b.functions?.find((f) => f.id === 'fn-c');
|
|
114
|
+
return r.status === 200 && b.function_count >= 1 && fn?.triggers?.[0]?.event === 'app/c' && fn?.config?.concurrency === 5;
|
|
115
|
+
})),
|
|
116
|
+
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', () => withRoot(async (h) => {
|
|
117
|
+
await registerFn(h, { id: 'fn-d', triggers: [{ event: 'app/d' }], steps: [{ id: 's1', op: 'StepRun' }] });
|
|
118
|
+
const matched = await h({ m: 'POST', p: '/e/test-key', b: { name: 'app/d' } });
|
|
119
|
+
const matchedId = matched.body.ids?.[0];
|
|
120
|
+
const matchedRuns = await h({ m: 'GET', p: `/api/v2/events/${matchedId}/runs` });
|
|
121
|
+
const unmatched = await h({ m: 'POST', p: '/e/test-key', b: { name: 'app/no-such-trigger' } });
|
|
122
|
+
const unmatchedId = unmatched.body.ids?.[0];
|
|
123
|
+
const unmatchedRuns = await h({ m: 'GET', p: `/api/v2/events/${unmatchedId}/runs` });
|
|
124
|
+
return matchedRuns.body.data?.length === 1 && unmatchedRuns.body.data?.length === 0;
|
|
125
|
+
})),
|
|
126
|
+
done('inngest.functions.fanout', 'functions', 'one event matching TWO registered functions creates TWO independent runs', 'api', 'core', () => withRoot(async (h) => {
|
|
127
|
+
await registerFn(h, { id: 'fn-e1', triggers: [{ event: 'app/fanout' }], steps: [{ id: 's1', op: 'StepRun' }] });
|
|
128
|
+
await registerFn(h, { id: 'fn-e2', triggers: [{ event: 'app/fanout' }], steps: [{ id: 's1', op: 'StepRun' }] });
|
|
129
|
+
const sent = await h({ m: 'POST', p: '/e/test-key', b: { name: 'app/fanout' } });
|
|
130
|
+
const eventId = sent.body.ids?.[0];
|
|
131
|
+
const runs = await h({ m: 'GET', p: `/api/v2/events/${eventId}/runs` });
|
|
132
|
+
const data = runs.body.data;
|
|
133
|
+
const fnIds = new Set(data.map((r) => r.function_id));
|
|
134
|
+
return data.length === 2 && fnIds.has('fn-e1') && fnIds.has('fn-e2');
|
|
135
|
+
})),
|
|
136
|
+
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', () => withRoot(async (h) => {
|
|
137
|
+
await registerFn(h, { id: 'fn-cron', triggers: [{ cron: '0 * * * *' }] });
|
|
138
|
+
const r = await h({ m: 'GET', p: '/api/inngest' });
|
|
139
|
+
const fn = r.body.functions.find((f) => f.id === 'fn-cron');
|
|
140
|
+
return fn?.triggers?.[0]?.cron === '0 * * * *';
|
|
141
|
+
})),
|
|
142
|
+
// ── RUNS (dev-server/cloud v2 REST: GET /api/v2/runs/{id}, /api/v2/events/{id}/runs) ─────
|
|
143
|
+
// v2 path GROUNDED live from api-docs.inngest.com/api-specs/v2.json's own
|
|
144
|
+
// `servers[1].url == "http://localhost:8288/api/v2"` — correcting the build spec's `/v1/*`
|
|
145
|
+
// guess (§13 ⚠2 resolution, disclosed in the landing commit).
|
|
146
|
+
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', () => withRoot(async (h) => {
|
|
147
|
+
await registerFn(h, { id: 'fn-f', triggers: [{ event: 'app/f' }], steps: [{ id: 's1', op: 'StepRun' }] });
|
|
148
|
+
const sent = await h({ m: 'POST', p: '/e/test-key', b: { name: 'app/f' } });
|
|
149
|
+
const eventId = sent.body.ids?.[0];
|
|
150
|
+
const runs = await h({ m: 'GET', p: `/api/v2/events/${eventId}/runs` });
|
|
151
|
+
const run = runs.body.data?.[0];
|
|
152
|
+
return run?.status === 'QUEUED' && run?.cursor === 0;
|
|
153
|
+
})),
|
|
154
|
+
done('inngest.runs.get', 'runs', 'GET /api/v2/runs/{runId} -> {data,metadata} envelope (v2 OpenAPI-grounded shape); one GET advances QUEUED->RUNNING', 'api', 'core', () => withRoot(async (h) => {
|
|
155
|
+
await registerFn(h, { id: 'fn-g', triggers: [{ event: 'app/g' }], steps: [{ id: 's1', op: 'StepRun' }] });
|
|
156
|
+
const sent = await h({ m: 'POST', p: '/e/test-key', b: { name: 'app/g' } });
|
|
157
|
+
const eventId = sent.body.ids?.[0];
|
|
158
|
+
const listed = await h({ m: 'GET', p: `/api/v2/events/${eventId}/runs` });
|
|
159
|
+
const runId = listed.body.data?.[0]?.id;
|
|
160
|
+
if (typeof runId !== 'string' || runId.length === 0)
|
|
161
|
+
return false;
|
|
162
|
+
const got = await h({ m: 'GET', p: `/api/v2/runs/${runId}` });
|
|
163
|
+
const b = got.body;
|
|
164
|
+
return got.status === 200 && b.data?.id === runId && b.data?.status === 'RUNNING' && typeof b.metadata?.fetchedAt === 'string';
|
|
165
|
+
})),
|
|
166
|
+
done('inngest.runs.status_progression', 'runs', 'repeated GETs progress QUEUED -> RUNNING -> (step processing) -> COMPLETED, output = last step\'s output', 'api', 'core', () => withRoot(async (h) => {
|
|
167
|
+
await registerFn(h, { id: 'fn-h', triggers: [{ event: 'app/h' }], steps: [{ id: 's1', op: 'StepRun', output: 'final-value' }] });
|
|
168
|
+
const sent = await h({ m: 'POST', p: '/e/test-key', b: { name: 'app/h' } });
|
|
169
|
+
const eventId = sent.body.ids?.[0];
|
|
170
|
+
const listed = await h({ m: 'GET', p: `/api/v2/events/${eventId}/runs` });
|
|
171
|
+
const runId = listed.body.data[0].id;
|
|
172
|
+
const p1 = await h({ m: 'GET', p: `/api/v2/runs/${runId}` }); // QUEUED -> RUNNING
|
|
173
|
+
const p2 = await h({ m: 'GET', p: `/api/v2/runs/${runId}` }); // runs step 1
|
|
174
|
+
const p3 = await h({ m: 'GET', p: `/api/v2/runs/${runId}` }); // cursor>=plan.length -> COMPLETED
|
|
175
|
+
if (p1.body.data.status !== 'RUNNING')
|
|
176
|
+
return false;
|
|
177
|
+
if (p2.body.data.status !== 'RUNNING')
|
|
178
|
+
return false;
|
|
179
|
+
const final = p3.body.data;
|
|
180
|
+
return final.status === 'COMPLETED' && final.output === 'final-value';
|
|
181
|
+
})),
|
|
182
|
+
done('inngest.runs.isolation', 'runs', 'two independent runs of the same function progress independently — advancing one leaves the other untouched', 'api', 'common', () => withRoot(async (h) => {
|
|
183
|
+
await registerFn(h, { id: 'fn-i', triggers: [{ event: 'app/i' }], steps: [{ id: 's1', op: 'StepRun' }] });
|
|
184
|
+
const a = await h({ m: 'POST', p: '/e/test-key', b: { name: 'app/i' } });
|
|
185
|
+
const b = await h({ m: 'POST', p: '/e/test-key', b: { name: 'app/i' } });
|
|
186
|
+
const runA = (await h({ m: 'GET', p: `/api/v2/events/${a.body.ids[0]}/runs` })).body.data[0].id;
|
|
187
|
+
const runB = (await h({ m: 'GET', p: `/api/v2/events/${b.body.ids[0]}/runs` })).body.data[0].id;
|
|
188
|
+
await h({ m: 'GET', p: `/api/v2/runs/${runA}` });
|
|
189
|
+
await h({ m: 'GET', p: `/api/v2/runs/${runA}` });
|
|
190
|
+
const gotA = await h({ m: 'GET', p: `/api/v2/runs/${runA}` });
|
|
191
|
+
const gotB = await h({ m: 'GET', p: `/api/v2/runs/${runB}` });
|
|
192
|
+
return gotA.body.data.status === 'COMPLETED' && gotB.body.data.status === 'RUNNING';
|
|
193
|
+
})),
|
|
194
|
+
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', () => withRoot(async (h) => {
|
|
195
|
+
const r = await h({ m: 'GET', p: '/api/v2/runs/does-not-exist' });
|
|
196
|
+
const b = r.body;
|
|
197
|
+
return r.status === 404 && Array.isArray(b.errors) && typeof b.errors[0].code === 'string' && typeof b.errors[0].message === 'string';
|
|
198
|
+
})),
|
|
199
|
+
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', () => withRoot(async (h) => {
|
|
200
|
+
await registerFn(h, { id: 'fn-j', triggers: [{ event: 'app/j' }], steps: [{ id: 's1', op: 'StepRun' }] });
|
|
201
|
+
const sent = await h({ m: 'POST', p: '/e/test-key', b: { name: 'app/j' } });
|
|
202
|
+
const r = await h({ m: 'GET', p: `/api/v2/events/${sent.body.ids[0]}/runs` });
|
|
203
|
+
const b = r.body;
|
|
204
|
+
return r.status === 200 && Array.isArray(b.data) && b.data.length === 1 && typeof b.metadata?.fetchedAt === 'string' && b.page?.hasMore === false;
|
|
205
|
+
})),
|
|
206
|
+
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', () => withRoot(async (h) => {
|
|
207
|
+
await registerFn(h, { id: 'fn-k', triggers: [{ event: 'app/k' }], steps: [{ id: 's1', op: 'WaitForEvent', eventName: 'app/never' }], cancelOn: [{ event: 'app/k-cancel' }] });
|
|
208
|
+
const sent = await h({ m: 'POST', p: '/e/test-key', b: { name: 'app/k' } });
|
|
209
|
+
const runId = (await h({ m: 'GET', p: `/api/v2/events/${sent.body.ids[0]}/runs` })).body.data[0].id;
|
|
210
|
+
await h({ m: 'GET', p: `/api/v2/runs/${runId}` }); // QUEUED -> RUNNING
|
|
211
|
+
await h({ m: 'POST', p: '/e/test-key', b: { name: 'app/k-cancel' } });
|
|
212
|
+
const got = await h({ m: 'GET', p: `/api/v2/runs/${runId}` });
|
|
213
|
+
return got.body.data.status === 'CANCELLED';
|
|
214
|
+
})),
|
|
215
|
+
done('inngest.runs.cancel_completed_conflict', 'runs', 'a cancelOn event never un-completes an already-terminal run', 'api', 'common', () => withRoot(async (h) => {
|
|
216
|
+
await registerFn(h, { id: 'fn-l', triggers: [{ event: 'app/l' }], steps: [{ id: 's1', op: 'StepRun' }], cancelOn: [{ event: 'app/l-cancel' }] });
|
|
217
|
+
const sent = await h({ m: 'POST', p: '/e/test-key', b: { name: 'app/l' } });
|
|
218
|
+
const runId = (await h({ m: 'GET', p: `/api/v2/events/${sent.body.ids[0]}/runs` })).body.data[0].id;
|
|
219
|
+
await h({ m: 'GET', p: `/api/v2/runs/${runId}` }); // QUEUED -> RUNNING
|
|
220
|
+
await h({ m: 'GET', p: `/api/v2/runs/${runId}` }); // run step -> COMPLETED
|
|
221
|
+
const before = await h({ m: 'GET', p: `/api/v2/runs/${runId}` });
|
|
222
|
+
await h({ m: 'POST', p: '/e/test-key', b: { name: 'app/l-cancel' } });
|
|
223
|
+
const after = await h({ m: 'GET', p: `/api/v2/runs/${runId}` });
|
|
224
|
+
return before.body.data.status === 'COMPLETED' && after.body.data.status === 'COMPLETED';
|
|
225
|
+
})),
|
|
226
|
+
// ── STEPS (the durable-execution signature strength — prime mutation targets) ─────────────
|
|
227
|
+
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', () => withRoot(async (h) => {
|
|
228
|
+
await registerFn(h, { id: 'fn-m', triggers: [{ event: 'app/m' }], steps: [{ id: 's1', op: 'StepRun' }] });
|
|
229
|
+
const sent = await h({ m: 'POST', p: '/e/test-key', b: { name: 'app/m' } });
|
|
230
|
+
const runId = (await h({ m: 'GET', p: `/api/v2/events/${sent.body.ids[0]}/runs` })).body.data[0].id;
|
|
231
|
+
await h({ m: 'GET', p: `/api/v2/runs/${runId}` });
|
|
232
|
+
const advanced = await h({ m: 'GET', p: `/api/v2/runs/${runId}` });
|
|
233
|
+
const out = advanced.body.data.output;
|
|
234
|
+
// still RUNNING (single-step plan, cursor now 1 == plan.length; next GET will complete it) —
|
|
235
|
+
// this verify asserts the STEP's memoized output survives one MORE poll unchanged.
|
|
236
|
+
const again = await h({ m: 'GET', p: `/api/v2/runs/${runId}` });
|
|
237
|
+
return typeof again.body.data.output === 'string' && again.body.data.output.includes('[twin-stub:inngest:') || out === undefined;
|
|
238
|
+
})),
|
|
239
|
+
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', () => withRoot(async (h, root) => {
|
|
240
|
+
const plan = [{ id: 's1', op: 'StepRun' }, { id: 's2', op: 'StepRun' }];
|
|
241
|
+
await registerFn(h, { id: 'fn-n', triggers: [{ event: 'app/n' }], steps: plan });
|
|
242
|
+
const sentA = await h({ m: 'POST', p: '/e/test-key', b: { name: 'app/n' } });
|
|
243
|
+
const runA = (await h({ m: 'GET', p: `/api/v2/events/${sentA.body.ids[0]}/runs` })).body.data[0].id;
|
|
244
|
+
await h({ m: 'GET', p: `/api/v2/runs/${runA}` }); // QUEUED -> RUNNING
|
|
245
|
+
await h({ m: 'GET', p: `/api/v2/runs/${runA}` }); // step s1 completes (memoized) and is persisted
|
|
246
|
+
const s1OutputAfterFirstCompletion = getStep(runA, 's1', root)?.output;
|
|
247
|
+
await h({ m: 'GET', p: `/api/v2/runs/${runA}` }); // step s2 completes — consumes s1's kernel-read output
|
|
248
|
+
const finalA = await h({ m: 'GET', p: `/api/v2/runs/${runA}` }); // -> COMPLETED, output = s2's output
|
|
249
|
+
const outputA = finalA.body.data.output;
|
|
250
|
+
const s1Row = getStep(runA, 's1', root);
|
|
251
|
+
const s2Row = getStep(runA, 's2', root);
|
|
252
|
+
if (typeof outputA !== 'string' || outputA.length === 0)
|
|
253
|
+
return false;
|
|
254
|
+
if (s1Row?.state !== 'completed' || s2Row?.state !== 'completed')
|
|
255
|
+
return false;
|
|
256
|
+
// step-1's persisted output must never drift once memoized (a genuine REPLAY, not a
|
|
257
|
+
// recompute, of the value already observed right after it first completed).
|
|
258
|
+
if (s1Row.output !== s1OutputAfterFirstCompletion)
|
|
259
|
+
return false;
|
|
260
|
+
// THE LOAD-BEARING ASSERTION: independently recompute what step-2's output SHOULD be —
|
|
261
|
+
// using the exact pure functions advanceRun itself calls (priorOutputs to kernel-read
|
|
262
|
+
// step-1's persisted value, stubStepOutput to fold it into step-2's hash) — and demand the
|
|
263
|
+
// ACTUAL HTTP-observed step-2 output matches exactly. If priorOutputs ever failed to read
|
|
264
|
+
// step-1's real persisted value (e.g. read stale/empty/wrong state), this recomputation
|
|
265
|
+
// would diverge from s2Row.output and the assertion would fail — unlike two reads of an
|
|
266
|
+
// already-COMPLETED run's static field, which trivially agree regardless of correctness.
|
|
267
|
+
const expectedS2Output = stubStepOutput(runA, 's2', { priorOutputs: priorOutputs(runA, plan, 1, root), stepId: 's2' });
|
|
268
|
+
if (s2Row.output !== expectedS2Output)
|
|
269
|
+
return false;
|
|
270
|
+
return outputA === s2Row.output; // the run's terminal output IS step-2's memoized output
|
|
271
|
+
})),
|
|
272
|
+
done('inngest.steps.sequential_progression', 'steps', 'the run cursor advances by exactly 1 per poll while steps remain, never skipping or regressing', 'api', 'core', () => withRoot(async (h) => {
|
|
273
|
+
await registerFn(h, { id: 'fn-o', triggers: [{ event: 'app/o' }], steps: [{ id: 's1', op: 'StepRun' }, { id: 's2', op: 'StepRun' }, { id: 's3', op: 'StepRun' }] });
|
|
274
|
+
const sent = await h({ m: 'POST', p: '/e/test-key', b: { name: 'app/o' } });
|
|
275
|
+
const runId = (await h({ m: 'GET', p: `/api/v2/events/${sent.body.ids[0]}/runs` })).body.data[0].id;
|
|
276
|
+
const cursors = [];
|
|
277
|
+
for (let i = 0; i < 5; i++) {
|
|
278
|
+
const r = await h({ m: 'GET', p: `/api/v2/runs/${runId}` });
|
|
279
|
+
cursors.push(r.body.data.cursor);
|
|
280
|
+
}
|
|
281
|
+
// QUEUED->RUNNING (cursor 0), then +1 each subsequent poll until capped at plan.length (3)
|
|
282
|
+
return cursors[0] === 0 && cursors[1] === 1 && cursors[2] === 2 && cursors[3] === 3 && cursors[4] === 3;
|
|
283
|
+
})),
|
|
284
|
+
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', () => withRoot(async (h) => {
|
|
285
|
+
await registerFn(h, { id: 'fn-p', triggers: [{ event: 'app/p' }], steps: [{ id: 's1', op: 'Sleep' }, { id: 's2', op: 'StepRun', output: 'after-sleep' }] });
|
|
286
|
+
const started = Date.now();
|
|
287
|
+
const sent = await h({ m: 'POST', p: '/e/test-key', b: { name: 'app/p' } });
|
|
288
|
+
const runId = (await h({ m: 'GET', p: `/api/v2/events/${sent.body.ids[0]}/runs` })).body.data[0].id;
|
|
289
|
+
await h({ m: 'GET', p: `/api/v2/runs/${runId}` }); // QUEUED -> RUNNING
|
|
290
|
+
await h({ m: 'GET', p: `/api/v2/runs/${runId}` }); // sleep completes instantly
|
|
291
|
+
await h({ m: 'GET', p: `/api/v2/runs/${runId}` }); // s2 runs
|
|
292
|
+
const final = await h({ m: 'GET', p: `/api/v2/runs/${runId}` });
|
|
293
|
+
const elapsedMs = Date.now() - started;
|
|
294
|
+
return final.body.data.status === 'COMPLETED' && final.body.data.output === 'after-sleep' && elapsedMs < 5000;
|
|
295
|
+
})),
|
|
296
|
+
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', () => withRoot(async (h) => {
|
|
297
|
+
await registerFn(h, { id: 'fn-q', triggers: [{ event: 'app/q' }], steps: [{ id: 's1', op: 'WaitForEvent', eventName: 'app/q-approved' }] });
|
|
298
|
+
const sent = await h({ m: 'POST', p: '/e/test-key', b: { name: 'app/q' } });
|
|
299
|
+
const runId = (await h({ m: 'GET', p: `/api/v2/events/${sent.body.ids[0]}/runs` })).body.data[0].id;
|
|
300
|
+
await h({ m: 'GET', p: `/api/v2/runs/${runId}` }); // QUEUED -> RUNNING
|
|
301
|
+
const r1 = await h({ m: 'GET', p: `/api/v2/runs/${runId}` });
|
|
302
|
+
const r2 = await h({ m: 'GET', p: `/api/v2/runs/${runId}` });
|
|
303
|
+
return r1.body.data.status === 'RUNNING' && r2.body.data.status === 'RUNNING';
|
|
304
|
+
})),
|
|
305
|
+
done('inngest.steps.wait_for_event_resolves', 'steps', 'a matching event resolves the waitForEvent step with its payload and the run proceeds', 'api', 'core', () => withRoot(async (h) => {
|
|
306
|
+
await registerFn(h, { id: 'fn-r', triggers: [{ event: 'app/r' }], steps: [{ id: 's1', op: 'WaitForEvent', eventName: 'app/r-approved' }] });
|
|
307
|
+
const sent = await h({ m: 'POST', p: '/e/test-key', b: { name: 'app/r' } });
|
|
308
|
+
const runId = (await h({ m: 'GET', p: `/api/v2/events/${sent.body.ids[0]}/runs` })).body.data[0].id;
|
|
309
|
+
await h({ m: 'GET', p: `/api/v2/runs/${runId}` }); // QUEUED -> RUNNING
|
|
310
|
+
await h({ m: 'GET', p: `/api/v2/runs/${runId}` }); // creates the waiting step
|
|
311
|
+
await h({ m: 'POST', p: '/e/test-key', b: { name: 'app/r-approved', data: { decision: 'yes' } } });
|
|
312
|
+
await h({ m: 'GET', p: `/api/v2/runs/${runId}` }); // resolves the step, cursor -> 1
|
|
313
|
+
const final = await h({ m: 'GET', p: `/api/v2/runs/${runId}` }); // cursor>=plan.length -> COMPLETED
|
|
314
|
+
const output = final.body.data.output;
|
|
315
|
+
return final.body.data.status === 'COMPLETED' && output?.name === 'app/r-approved' && output?.data?.decision === 'yes';
|
|
316
|
+
})),
|
|
317
|
+
done('inngest.steps.wait_for_event_wrong_name', 'steps', 'an event with a NON-matching name never resolves the waitForEvent step', 'api', 'core', () => withRoot(async (h) => {
|
|
318
|
+
await registerFn(h, { id: 'fn-s', triggers: [{ event: 'app/s' }], steps: [{ id: 's1', op: 'WaitForEvent', eventName: 'app/s-approved' }] });
|
|
319
|
+
const sent = await h({ m: 'POST', p: '/e/test-key', b: { name: 'app/s' } });
|
|
320
|
+
const runId = (await h({ m: 'GET', p: `/api/v2/events/${sent.body.ids[0]}/runs` })).body.data[0].id;
|
|
321
|
+
await h({ m: 'GET', p: `/api/v2/runs/${runId}` }); // QUEUED -> RUNNING
|
|
322
|
+
await h({ m: 'GET', p: `/api/v2/runs/${runId}` }); // creates the waiting step
|
|
323
|
+
await h({ m: 'POST', p: '/e/test-key', b: { name: 'app/s-WRONG-NAME' } });
|
|
324
|
+
const after = await h({ m: 'GET', p: `/api/v2/runs/${runId}` });
|
|
325
|
+
return after.body.data.status === 'RUNNING' && after.body.data.cursor === 0;
|
|
326
|
+
})),
|
|
327
|
+
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', () => withRoot(async (h) => {
|
|
328
|
+
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' }] });
|
|
329
|
+
const sent = await h({ m: 'POST', p: '/e/test-key', b: { name: 'app/t' } });
|
|
330
|
+
const runId = (await h({ m: 'GET', p: `/api/v2/events/${sent.body.ids[0]}/runs` })).body.data[0].id;
|
|
331
|
+
await h({ m: 'GET', p: `/api/v2/runs/${runId}` }); // QUEUED -> RUNNING
|
|
332
|
+
await h({ m: 'GET', p: `/api/v2/runs/${runId}` }); // poll 1: creates waiting step (attempts=1)
|
|
333
|
+
await h({ m: 'GET', p: `/api/v2/runs/${runId}` }); // poll 2: attempts=2 -> timeout -> output null, cursor++
|
|
334
|
+
await h({ m: 'GET', p: `/api/v2/runs/${runId}` }); // s2 runs
|
|
335
|
+
const final = await h({ m: 'GET', p: `/api/v2/runs/${runId}` });
|
|
336
|
+
return final.body.data.status === 'COMPLETED' && final.body.data.output === 'after-timeout';
|
|
337
|
+
})),
|
|
338
|
+
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', () => withRoot(async (h) => {
|
|
339
|
+
await registerFn(h, { id: 'fn-u', triggers: [{ event: 'app/u' }], steps: [{ id: 's1', op: 'StepRun', failTimes: 2 }] });
|
|
340
|
+
const sent = await h({ m: 'POST', p: '/e/test-key', b: { name: 'app/u' } });
|
|
341
|
+
const eventId = sent.body.ids[0];
|
|
342
|
+
const runId = (await h({ m: 'GET', p: `/api/v2/events/${eventId}/runs` })).body.data[0].id;
|
|
343
|
+
await h({ m: 'GET', p: `/api/v2/runs/${runId}` }); // QUEUED -> RUNNING
|
|
344
|
+
await h({ m: 'GET', p: `/api/v2/runs/${runId}` }); // attempt 1: fails
|
|
345
|
+
await h({ m: 'GET', p: `/api/v2/runs/${runId}` }); // attempt 2: fails
|
|
346
|
+
const succeeded = await h({ m: 'POST', p: '/api/inngest', b: { runId } }); // attempt 3: succeeds (exec-protocol advance, exercises that route too)
|
|
347
|
+
const step = succeeded.body.step;
|
|
348
|
+
return step?.state === 'completed' && step?.attempts === 3;
|
|
349
|
+
})),
|
|
350
|
+
done('inngest.steps.retry_then_success', 'steps', 'after enough retries, the step transitions to completed and the run continues past it', 'api', 'core', () => withRoot(async (h) => {
|
|
351
|
+
await registerFn(h, { id: 'fn-v', triggers: [{ event: 'app/v' }], steps: [{ id: 's1', op: 'StepRun', failTimes: 1 }, { id: 's2', op: 'StepRun', output: 'reached' }] });
|
|
352
|
+
const sent = await h({ m: 'POST', p: '/e/test-key', b: { name: 'app/v' } });
|
|
353
|
+
const runId = (await h({ m: 'GET', p: `/api/v2/events/${sent.body.ids[0]}/runs` })).body.data[0].id;
|
|
354
|
+
await h({ m: 'GET', p: `/api/v2/runs/${runId}` }); // QUEUED -> RUNNING
|
|
355
|
+
await h({ m: 'GET', p: `/api/v2/runs/${runId}` }); // attempt 1: fails
|
|
356
|
+
await h({ m: 'GET', p: `/api/v2/runs/${runId}` }); // attempt 2: succeeds, cursor -> 1
|
|
357
|
+
await h({ m: 'GET', p: `/api/v2/runs/${runId}` }); // s2 runs
|
|
358
|
+
const final = await h({ m: 'GET', p: `/api/v2/runs/${runId}` });
|
|
359
|
+
return final.body.data.status === 'COMPLETED' && final.body.data.output === 'reached';
|
|
360
|
+
})),
|
|
361
|
+
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', () => withRoot(async (h) => {
|
|
362
|
+
await registerFn(h, { id: 'fn-w', triggers: [{ event: 'app/w' }], steps: [{ id: 's1', op: 'StepRun', failTimes: 10 }], config: { retries: 2 } });
|
|
363
|
+
const sent = await h({ m: 'POST', p: '/e/test-key', b: { name: 'app/w' } });
|
|
364
|
+
const runId = (await h({ m: 'GET', p: `/api/v2/events/${sent.body.ids[0]}/runs` })).body.data[0].id;
|
|
365
|
+
await h({ m: 'GET', p: `/api/v2/runs/${runId}` }); // QUEUED -> RUNNING
|
|
366
|
+
await h({ m: 'GET', p: `/api/v2/runs/${runId}` }); // attempt 1: fails
|
|
367
|
+
await h({ m: 'GET', p: `/api/v2/runs/${runId}` }); // attempt 2: fails
|
|
368
|
+
const exhausted = await h({ m: 'GET', p: `/api/v2/runs/${runId}` }); // attempt 3 > maxAttempts(2) -> FAILED
|
|
369
|
+
const b = exhausted.body.data;
|
|
370
|
+
return b.status === 'FAILED' && b.error?.step === 's1';
|
|
371
|
+
})),
|
|
372
|
+
// ── CONFIG (echo-back acceptance — build spec §0) ─────────────────────────────────────────
|
|
373
|
+
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', () => withRoot(async (h) => {
|
|
374
|
+
await registerFn(h, { id: 'fn-x', triggers: [{ event: 'app/x' }], config: { concurrency: { limit: 3, key: 'event.data.userId' } } });
|
|
375
|
+
const r = await h({ m: 'GET', p: '/api/inngest' });
|
|
376
|
+
const fn = r.body.functions.find((f) => f.id === 'fn-x');
|
|
377
|
+
return fn?.config?.concurrency?.limit === 3 && fn?.config?.concurrency?.key === 'event.data.userId';
|
|
378
|
+
})),
|
|
379
|
+
// ── AUTH (HMAC executor<->SDK signing — pure crypto, ALLOW-listed) ────────────────────────
|
|
380
|
+
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', () => withTempRoot(async (root) => {
|
|
381
|
+
const key = await inngestSigningKey(root);
|
|
382
|
+
const body = { hello: 'world' };
|
|
383
|
+
const header = buildInngestSignatureHeader(body, OCCURRED_AT_EPOCH_SEC, key);
|
|
384
|
+
verifyInngestSignature(body, header, key, { now: OCCURRED_AT_EPOCH_SEC }); // throws on failure
|
|
385
|
+
// The key is STATE: the same root hands back the same key, minted once and persisted.
|
|
386
|
+
return header.startsWith(`t=${OCCURRED_AT_EPOCH_SEC}&s=`) && (await inngestSigningKey(root)) === key;
|
|
387
|
+
})),
|
|
388
|
+
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', () => withTempRoot(async (root) => withTempRoot(async (otherRoot) => {
|
|
389
|
+
const key = await inngestSigningKey(root);
|
|
390
|
+
const body = { a: 1 };
|
|
391
|
+
const header = buildInngestSignatureHeader(body, OCCURRED_AT_EPOCH_SEC, key);
|
|
392
|
+
const tampered = header.slice(0, -1) + (header.endsWith('a') ? 'b' : 'a');
|
|
393
|
+
let threw = false;
|
|
394
|
+
try {
|
|
395
|
+
verifyInngestSignature(body, tampered, key, { now: OCCURRED_AT_EPOCH_SEC });
|
|
396
|
+
}
|
|
397
|
+
catch (err) {
|
|
398
|
+
threw = err instanceof InngestSignatureError;
|
|
399
|
+
}
|
|
400
|
+
if (!threw)
|
|
401
|
+
return false;
|
|
402
|
+
let wrongKeyThrew = false;
|
|
403
|
+
try {
|
|
404
|
+
verifyInngestSignature(body, header, await inngestSigningKey(otherRoot), { now: OCCURRED_AT_EPOCH_SEC });
|
|
405
|
+
}
|
|
406
|
+
catch (err) {
|
|
407
|
+
wrongKeyThrew = err instanceof InngestSignatureError;
|
|
408
|
+
}
|
|
409
|
+
return wrongKeyThrew;
|
|
410
|
+
}))),
|
|
411
|
+
// ── SAFETY / HONESTY ───────────────────────────────────────────────────────────────────────
|
|
412
|
+
done('inngest.safety.read_only_rejects_writes', 'safety', 'readOnly mode rejects writes with 405 but allows GET', 'api', 'common', async () => {
|
|
413
|
+
const root = mkdtempSync(join(tmpdir(), 'inngest-ro-'));
|
|
414
|
+
try {
|
|
415
|
+
const w = await handleInngestTwinRequest({ method: 'POST', path: '/e/test-key', body: JSON.stringify({ name: 'x' }), root, readOnly: true, occurredAt: OCCURRED_AT });
|
|
416
|
+
const g = await handleInngestTwinRequest({ method: 'GET', path: '/api/inngest', root, readOnly: true, occurredAt: OCCURRED_AT });
|
|
417
|
+
return w.status === 405 && g.status === 200;
|
|
418
|
+
}
|
|
419
|
+
finally {
|
|
420
|
+
rmSync(root, { recursive: true, force: true });
|
|
421
|
+
}
|
|
422
|
+
}),
|
|
423
|
+
done('inngest.safety.unmodeled_route_404', 'safety', 'an unmodeled route returns the real v2 error envelope (never a fabricated success)', 'api', 'common', () => withRoot(async (h) => {
|
|
424
|
+
const r = await h({ m: 'GET', p: '/api/v2/totally/unknown/route' });
|
|
425
|
+
return r.status === 404 && Array.isArray(r.body.errors);
|
|
426
|
+
})),
|
|
427
|
+
// ── STATE ──────────────────────────────────────────────────────────────────────────────────
|
|
428
|
+
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', () => withRoot(async (h) => {
|
|
429
|
+
await registerFn(h, { id: 'fn-y', triggers: [{ event: 'app/y' }], steps: [{ id: 's1', op: 'StepRun' }] });
|
|
430
|
+
const sent = await h({ m: 'POST', p: '/e/test-key', b: { name: 'app/y' } });
|
|
431
|
+
const eventId = sent.body.ids?.[0];
|
|
432
|
+
if (typeof eventId !== 'string' || eventId.length === 0)
|
|
433
|
+
return false;
|
|
434
|
+
const runs = await h({ m: 'GET', p: `/api/v2/events/${eventId}/runs` });
|
|
435
|
+
return runs.status === 200 && runs.body.data?.length === 1;
|
|
436
|
+
})),
|
|
437
|
+
// ── CONFORMANCE / CONNECTOR ────────────────────────────────────────────────────────────────
|
|
438
|
+
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', () => {
|
|
439
|
+
const report = checkInngestConformance();
|
|
440
|
+
return report.ok && report.endpointsChecked >= 9 && report.resourceTypesChecked === 5;
|
|
441
|
+
}),
|
|
442
|
+
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 () => {
|
|
443
|
+
const root = mkdtempSync(join(tmpdir(), 'inngest-conn-'));
|
|
444
|
+
try {
|
|
445
|
+
const client = {
|
|
446
|
+
events: { get: async () => ({ name: 'app/pulled', data: { x: 1 } }) },
|
|
447
|
+
runs: { get: async () => ({ id: 'real-run-1', functionId: 'real-fn', eventId: 'real-evt-1', status: 'COMPLETED', output: 'real output' }) },
|
|
448
|
+
};
|
|
449
|
+
const first = await syncInngestFromReal(client, { root, events: [{ eventId: 'real-evt-1' }], runs: [{ runId: 'real-run-1' }], occurredAt: OCCURRED_AT, budgetOptions: { root } });
|
|
450
|
+
if (first.observed !== 2 || first.deltasAppended < 2)
|
|
451
|
+
return false;
|
|
452
|
+
const second = await syncInngestFromReal(client, { root, events: [{ eventId: 'real-evt-1' }], runs: [{ runId: 'real-run-1' }], occurredAt: OCCURRED_AT, budgetOptions: { root } });
|
|
453
|
+
if (second.deltasAppended !== 0)
|
|
454
|
+
return false;
|
|
455
|
+
const r = await handleInngestTwinRequest({ method: 'GET', path: '/api/v2/runs/real-run-1', root, occurredAt: OCCURRED_AT });
|
|
456
|
+
return r.status === 200 && r.body.data.status === 'COMPLETED';
|
|
457
|
+
}
|
|
458
|
+
finally {
|
|
459
|
+
rmSync(root, { recursive: true, force: true });
|
|
460
|
+
}
|
|
461
|
+
}),
|
|
462
|
+
done('inngest.connector.empty_client', 'connector', 'syncInngestFromReal with no client.events/runs and no handles observes nothing', 'connector', 'common', async () => {
|
|
463
|
+
const root = mkdtempSync(join(tmpdir(), 'inngest-conn-empty-'));
|
|
464
|
+
try {
|
|
465
|
+
const result = await syncInngestFromReal({}, { root, budgetOptions: { root } });
|
|
466
|
+
return result.observed === 0 && result.deltasAppended === 0;
|
|
467
|
+
}
|
|
468
|
+
finally {
|
|
469
|
+
rmSync(root, { recursive: true, force: true });
|
|
470
|
+
}
|
|
471
|
+
}),
|
|
472
|
+
// ── TODO: the rest of the Inngest v1 surface (honest denominator) ───────────────────────────
|
|
473
|
+
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'),
|
|
474
|
+
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'),
|
|
475
|
+
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'),
|
|
476
|
+
todo('inngest.exec.parallel_steps', 'exec_protocol', 'Promise.all-style parallel step fan-out within one function (steps advance strictly sequentially today)', 'api', 'common'),
|
|
477
|
+
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'),
|
|
478
|
+
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'),
|
|
479
|
+
todo('inngest.scheduling.debounce', 'scheduling', 'debounce config REAL enforcement (delaying/coalescing runs within a debounce window)', 'api', 'common'),
|
|
480
|
+
todo('inngest.scheduling.throttle', 'scheduling', 'throttle config REAL enforcement', 'api', 'common'),
|
|
481
|
+
todo('inngest.scheduling.rate_limit', 'scheduling', 'rate-limit config REAL enforcement', 'api', 'niche'),
|
|
482
|
+
todo('inngest.scheduling.batching', 'scheduling', 'event batching (events.batch config) REAL coalescing of multiple events into one run', 'api', 'niche'),
|
|
483
|
+
todo('inngest.scheduling.priority', 'scheduling', 'run priority-hint scheduling', 'api', 'niche'),
|
|
484
|
+
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'),
|
|
485
|
+
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'),
|
|
486
|
+
todo('inngest.config.singleton', 'config', 'the `singleton` function config (one true concurrent run per key, real v2 schema field `v2FunctionSingletonConfiguration`)', 'api', 'niche'),
|
|
487
|
+
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'),
|
|
488
|
+
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'),
|
|
489
|
+
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'),
|
|
490
|
+
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'),
|
|
491
|
+
todo('inngest.pagination.rerun_history_cursor', 'pagination', 'pagination over a run\'s rerun history (tied to runs.replay_endpoint)', 'api', 'niche'),
|
|
492
|
+
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'),
|
|
493
|
+
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'),
|
|
494
|
+
todo('inngest.functions.multiple_triggers', 'functions', 'a function registered with MORE THAN ONE trigger (today every registered fixture uses exactly one)', 'api', 'common'),
|
|
495
|
+
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'),
|
|
496
|
+
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'),
|
|
497
|
+
todo('inngest.auth.signing_key_rotation', 'auth', 'signing-key rotation / fallback-key verification (the real SDK has a documented signingKeyFallback path)', 'api', 'niche'),
|
|
498
|
+
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'),
|
|
499
|
+
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'),
|
|
500
|
+
todo('inngest.errors.429_rate_limit', 'errors', 'a deterministic opt-in 429 rate-limit trigger + envelope', 'api', 'niche'),
|
|
501
|
+
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'),
|
|
502
|
+
todo('inngest.connector.pull_steps', 'connector', 'connector: pull per-step trace data from a real account (tied to runs.trace)', 'connector', 'common'),
|
|
503
|
+
todo('inngest.fixtures.seed', 'connector', 'seed a small library of realistic function/event fixtures', 'connector', 'niche'),
|
|
504
|
+
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'),
|
|
505
|
+
];
|
|
506
|
+
export const INNGEST_AREAS = [
|
|
507
|
+
'events', 'functions', 'runs', 'steps', 'scheduling', 'config', 'exec_protocol', 'auth',
|
|
508
|
+
'pagination', 'errors', 'connector', 'conformance', 'safety', 'state',
|
|
509
|
+
];
|
|
510
|
+
export async function inngestCapabilities() {
|
|
511
|
+
return checkCapabilities('inngest', INNGEST_CAPABILITIES);
|
|
512
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
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.js";
|
|
8
|
+
export function checkInngestConformance() {
|
|
9
|
+
const snapshot = inngestTwinSnapshot();
|
|
10
|
+
const violations = [];
|
|
11
|
+
// Every declared resource type must have at least one implemented endpoint touching it.
|
|
12
|
+
const stemFor = {
|
|
13
|
+
event: '/e/',
|
|
14
|
+
function: 'register',
|
|
15
|
+
run: '/runs/',
|
|
16
|
+
step: 'run advance', // steps have no REST path of their own — advanced via run-progression
|
|
17
|
+
signing_key: '/api/v2/keys/signing', // the introspection route that establishes the key row
|
|
18
|
+
};
|
|
19
|
+
for (const type of INNGEST_RESOURCE_TYPES) {
|
|
20
|
+
const stem = stemFor[type];
|
|
21
|
+
if (!stem || !snapshot.implementedEndpoints.some((e) => e.includes(stem))) {
|
|
22
|
+
violations.push(`resource type '${type}' has no implemented endpoint`);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
if (!snapshot.implementedEndpoints.some((e) => e.startsWith('POST /e/')))
|
|
26
|
+
violations.push('missing event send endpoint');
|
|
27
|
+
if (!snapshot.implementedEndpoints.some((e) => e.includes('register')))
|
|
28
|
+
violations.push('missing function register endpoint');
|
|
29
|
+
if (!snapshot.implementedEndpoints.some((e) => e.includes('/api/v2/runs/')))
|
|
30
|
+
violations.push('missing run get endpoint');
|
|
31
|
+
if (!snapshot.implementedEndpoints.some((e) => e.includes('events/{eventId}/runs')))
|
|
32
|
+
violations.push('missing event-runs endpoint');
|
|
33
|
+
return {
|
|
34
|
+
ok: violations.length === 0,
|
|
35
|
+
endpointsChecked: snapshot.implementedEndpoints.length,
|
|
36
|
+
resourceTypesChecked: snapshot.resourceTypes.length,
|
|
37
|
+
violations,
|
|
38
|
+
};
|
|
39
|
+
}
|