@volter/twin-runhuman 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.
@@ -0,0 +1,470 @@
1
+ // Runhuman 1 capability manifest — the EXPECTED REAL-PRODUCT SURFACE, authored top-down from RH1's
2
+ // own source at volter-ai/runhuman 2f2fcb2c5 (the vendor is our own product, so its source is the
3
+ // specification). The denominator is the union of three first-party inventories:
4
+ // - the public REST reference, packages/static-site/src/content/docs/api.mdx (46 operations:
5
+ // jobs, organizations, projects, API keys, PATs, templates, schedules, GitHub, auth, billing);
6
+ // - the jobs router, packages/api/src/routes/jobs/jobs.routes.ts (32 operations: the customer's
7
+ // job reads and the tester's claim-to-completion lifecycle — the surface RH2's
8
+ // human-marketplace provider and the tester app drive);
9
+ // - the customer-API allowlist, packages/shared/src/routes/customer-api-routes.ts (the routes
10
+ // RH1 ships in its CLI / MCP server), plus GET /api/projects/:projectId/jobs from
11
+ // routes/projects.routes.ts.
12
+ // One capability per route × method (83), plus the behavioral and connector capabilities a real
13
+ // integration relies on. Most entries are `todo`; coverage reads LOW on purpose.
14
+ //
15
+ // The DONE slice is what the twin serves (src/runhuman-twin.ts, README ## Coverage). Every verify()
16
+ // drives the handler the fetch adapter wraps (`handleRunhumanTwinRequest`) on a FRESH temp root at
17
+ // pinned instants, seeds through the twin-only `/_twin/*` routes (RH1 creates orgs, projects, keys
18
+ // and testers outside the job API), and asserts VALUES plus the RH1 refusals its title names.
19
+ //
20
+ // UI: none. RH1's customers post jobs from code, and the tester web app's core loop is the tester
21
+ // API below (census.json ui.needsUi:false), so there is no `mirror` area.
22
+ import { mkdtempSync, rmSync } from 'node:fs';
23
+ import { tmpdir } from 'node:os';
24
+ import { join } from 'node:path';
25
+ import { checkCapabilities, type CapabilityReport, type CapabilitySpec, verifyBoundary } from '@volter/world-tooling';
26
+ import { handleRunhumanTwinRequest, type RunhumanTwinResponse } from './runhuman-twin.ts';
27
+
28
+ type Body = Record<string, any>;
29
+ type Step = { m: string; p: string; b?: unknown; raw?: string; auth?: string | null; at?: number; headers?: Record<string, string> };
30
+ type H = (s: Step) => Promise<RunhumanTwinResponse>;
31
+
32
+ const T0 = Date.parse('2026-03-01T12:00:00.000Z');
33
+ /** The pinned instant `s` seconds after T0. */
34
+ const iso = (s = 0) => new Date(T0 + s * 1000).toISOString();
35
+
36
+ const KEY = 'rh_capability_acme';
37
+ const OTHER_KEY = 'rh_capability_other';
38
+ const PAID_KEY = 'rh_capability_paid';
39
+ const TESS = 'Bearer clerk_tess@x.test';
40
+ const MIKE = 'Bearer clerk_mike@x.test';
41
+
42
+ /** A fresh world: org-1 (Acme) owns proj-1 (Web, default https://app.example.com) and KEY;
43
+ * org-2 owns proj-2 and OTHER_KEY; org-3 (Paid, an active subscription) owns proj-3 and PAID_KEY;
44
+ * testers tess and mike (desktop, English). */
45
+ async function withWorld(label: string, steps: (h: H) => Promise<boolean>): Promise<boolean> {
46
+ const root = mkdtempSync(join(tmpdir(), 'runhuman-cap-'));
47
+ const h: H = (s) =>
48
+ handleRunhumanTwinRequest({
49
+ method: s.m,
50
+ path: s.p,
51
+ ...(s.raw !== undefined ? { body: s.raw } : s.b !== undefined ? { body: JSON.stringify(s.b) } : {}),
52
+ headers: { ...(s.auth === null ? {} : { authorization: s.auth ?? `Bearer ${KEY}` }), 'content-type': 'application/json', ...(s.headers ?? {}) },
53
+ occurredAt: iso(s.at ?? 0),
54
+ root,
55
+ });
56
+ try {
57
+ return await verifyBoundary(label, async () => {
58
+ const seeds: Array<[string, Body]> = [
59
+ ['organizations', { id: 'org-1', name: 'Acme' }],
60
+ ['organizations', { id: 'org-2', name: 'Other' }],
61
+ ['organizations', { id: 'org-3', name: 'Paid', hasActiveSubscription: true }],
62
+ ['projects', { id: 'proj-1', organizationId: 'org-1', name: 'Web', defaultUrl: 'https://app.example.com' }],
63
+ ['projects', { id: 'proj-2', organizationId: 'org-2', name: 'Elsewhere' }],
64
+ ['projects', { id: 'proj-3', organizationId: 'org-3', name: 'Paid web' }],
65
+ ['api-keys', { organizationId: 'org-1', key: KEY }],
66
+ ['api-keys', { organizationId: 'org-2', key: OTHER_KEY }],
67
+ ['api-keys', { organizationId: 'org-3', key: PAID_KEY }],
68
+ ['testers', { email: 'tess@x.test', alias: 'tess' }],
69
+ ['testers', { email: 'mike@x.test', alias: 'mike' }],
70
+ ];
71
+ for (const [kind, body] of seeds) {
72
+ const r = await h({ m: 'POST', p: `/_twin/${kind}`, b: body, auth: null });
73
+ if (r.status !== 201) throw new Error(`seed ${kind} answered ${r.status}`);
74
+ }
75
+ return steps(h);
76
+ });
77
+ } finally {
78
+ rmSync(root, { recursive: true, force: true });
79
+ }
80
+ }
81
+
82
+ const b = (r: RunhumanTwinResponse) => r.body as Body;
83
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
84
+
85
+ async function createJob(h: H, extra: Body = {}, at = 0): Promise<string> {
86
+ const r = await h({ m: 'POST', p: '/api/jobs', b: { projectId: 'proj-1', url: 'https://app.example.com/checkout', description: 'Buy the cheapest item', ...extra }, at });
87
+ if (r.status !== 201) throw new Error(`create answered ${r.status}`);
88
+ return String(b(r).jobId);
89
+ }
90
+ /** Claim as a tester and return the testerToken the claim minted (from its webTestLink). */
91
+ async function claim(h: H, jobId: string, who = TESS, at = 0): Promise<string> {
92
+ const r = await h({ m: 'POST', p: `/api/jobs/${jobId}/claim`, b: { claimSource: 'tester-portal' }, auth: who, at });
93
+ if (r.status !== 200) throw new Error(`claim answered ${r.status}`);
94
+ const token = new URL(String(b(r).webTestLink)).searchParams.get('testerToken');
95
+ if (!token) throw new Error('claim returned no testerToken');
96
+ return token;
97
+ }
98
+ const tester = (token: string) => `Bearer ${token}`;
99
+ /** Submit a result and run the pipeline to a verdict: process-results → poll (extract) →
100
+ * issue-review continue → poll (finalize). */
101
+ async function toVerdict(h: H, jobId: string, token: string, success: boolean | undefined, at: number): Promise<boolean> {
102
+ const auth = tester(token);
103
+ const patch = await h({ m: 'PATCH', p: `/api/tester/jobs/${jobId}`, b: { result: { explanation: 'walked the flow', data: success === undefined ? {} : { success } } }, auth, at });
104
+ const pr = await h({ m: 'POST', p: `/api/tester/jobs/${jobId}/process-results`, b: {}, auth, at });
105
+ const poll1 = await h({ m: 'GET', p: `/api/tester/jobs/${jobId}/processing/${b(pr).processingJobId}`, auth, at });
106
+ const review = await h({ m: 'POST', p: `/api/tester/jobs/${jobId}/issue-review`, b: { action: 'continue' }, auth, at });
107
+ const poll2 = await h({ m: 'GET', p: `/api/tester/jobs/${jobId}/processing/${b(review).processingJobId}`, auth, at });
108
+ return patch.status === 200 && pr.status === 200 && b(poll1).status === 'completed' && review.status === 200 && b(poll2).status === 'completed';
109
+ }
110
+
111
+ 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 });
112
+ const todo = (id: string, area: string, title: string, dimension: CapabilitySpec['dimension'], tier: CapabilitySpec['tier']): CapabilitySpec => ({ id, area, title, dimension, tier, expected: 'todo' });
113
+
114
+ export const RUNHUMAN_CAPABILITIES: CapabilitySpec[] = [
115
+ // ── JOBS (customer side) ────────────────────────────────────────────────────────────────────
116
+ done('runhuman.jobs.create', 'jobs', 'POST /api/jobs creates a pending job (201 {jobId, message}, RH1 defaults 15+5 min) and refuses a missing key (401), a schema-invalid body (400 FST_ERR_VALIDATION), a non-http URL (400) and another org\'s project (403)', 'api', 'core', () =>
117
+ withWorld('runhuman.jobs.create', async (h) => {
118
+ const c = await h({ m: 'POST', p: '/api/jobs', b: { projectId: 'proj-1', url: 'https://app.example.com/checkout', description: 'Buy the cheapest item' } });
119
+ if (!(c.status === 201 && UUID.test(String(b(c).jobId)) && b(c).message === 'Job created successfully. Use GET /api/jobs/:id to check status.')) return false;
120
+ const g = await h({ m: 'GET', p: `/api/jobs/${b(c).jobId}` });
121
+ if (!(b(g).status === 'pending' && b(g).targetDurationMinutes === 15 && b(g).maxExtensionMinutes === 5 && b(g).description === 'Buy the cheapest item' && b(g).createdAt === iso(0))) return false;
122
+ const noAuth = await h({ m: 'POST', p: '/api/jobs', b: { projectId: 'proj-1', url: 'https://a.test', description: 'x' }, auth: null });
123
+ const badBody = await h({ m: 'POST', p: '/api/jobs', b: { projectId: 'proj-1', url: 5, description: 'x' } });
124
+ const ftp = await h({ m: 'POST', p: '/api/jobs', b: { projectId: 'proj-1', url: 'ftp://files.test', description: 'x' } });
125
+ const foreign = await h({ m: 'POST', p: '/api/jobs', b: { projectId: 'proj-2', url: 'https://a.test', description: 'x' } });
126
+ return noAuth.status === 401 && b(noAuth).error === 'Unauthorized'
127
+ && badBody.status === 400 && b(badBody).code === 'FST_ERR_VALIDATION' && b(badBody).error === 'Invalid request body: url: Expected string, received number'
128
+ && ftp.status === 400 && b(ftp).error === 'Invalid URL protocol "ftp:". URL must start with http:// or https://'
129
+ && foreign.status === 403 && b(foreign).error === 'Access denied: API key organization does not match project organization';
130
+ })),
131
+ done('runhuman.jobs.create.active_job_limit', 'jobs', 'POST /api/jobs holds an organization to 12 active jobs (the 13th is 429 ACTIVE_JOB_LIMIT) and a job that leaves the active set frees its slot', 'api', 'common', () =>
132
+ withWorld('runhuman.jobs.create.active_job_limit', async (h) => {
133
+ const ids: string[] = [];
134
+ for (let i = 0; i < 12; i++) ids.push(await createJob(h, {}, i));
135
+ const over = await h({ m: 'POST', p: '/api/jobs', b: { projectId: 'proj-1', url: 'https://app.example.com', description: '13th' }, at: 20 });
136
+ if (!(over.status === 429 && b(over).code === 'ACTIVE_JOB_LIMIT' && b(over).statusCode === 429)) return false;
137
+ const token = await claim(h, ids[0]!, TESS, 30);
138
+ const end = await h({ m: 'POST', p: `/api/tester/jobs/${ids[0]}/end`, b: { action: 'clear_and_cancel', note: 'cannot load' }, auth: tester(token), at: 40 });
139
+ const again = await h({ m: 'POST', p: '/api/jobs', b: { projectId: 'proj-1', url: 'https://app.example.com', description: '13th' }, at: 50 });
140
+ return end.status === 200 && again.status === 201;
141
+ })),
142
+ done('runhuman.jobs.create.surface_rules', 'jobs', 'POST /api/jobs answers RH1\'s claim-surface rules: a deviceClass/requiredDevices conflict is a 201 with a warning, a URL off the project\'s default host adds projectUrlMismatch, and external capture without a desktop-only surface is 400 VALIDATION_ERROR', 'api', 'common', () =>
143
+ withWorld('runhuman.jobs.create.surface_rules', async (h) => {
144
+ const conflict = await h({ m: 'POST', p: '/api/jobs', b: { projectId: 'proj-1', url: 'https://other.example.org', description: 'x', deviceClass: 'desktop', requiredDevices: ['ios'] } });
145
+ const external = await h({ m: 'POST', p: '/api/jobs', b: { projectId: 'proj-1', url: 'https://app.example.com', description: 'x', metadata: { captureMode: 'external' } } });
146
+ const externalDesktop = await h({ m: 'POST', p: '/api/jobs', b: { projectId: 'proj-1', url: 'https://www.app.example.com', description: 'x', deviceClass: 'desktop', metadata: { captureMode: 'external' } } });
147
+ const warnings = b(conflict).warnings as string[] | undefined;
148
+ return conflict.status === 201 && warnings?.length === 1 && warnings[0]!.startsWith('Device Class is set to Desktop, but the required device(s) iOS force the claim surface to mobile only.')
149
+ && b(conflict).projectUrlMismatch?.targetHost === 'other.example.org' && b(conflict).projectUrlMismatch?.projectDefaultHost === 'app.example.com' && b(conflict).projectUrlMismatch?.projectName === 'Web'
150
+ && external.status === 400 && b(external).code === 'VALIDATION_ERROR' && b(external).error === 'External capture requires a desktop browser assignment.'
151
+ && externalDesktop.status === 201 && b(externalDesktop).projectUrlMismatch === undefined && b(externalDesktop).warnings === undefined;
152
+ })),
153
+ todo('runhuman.jobs.create.integrations', 'jobs', 'POST /api/jobs branches that run RH1 services: templates, PR/issue test-plan generation, KB-enhanced instructions, githubRepos access and project auto-creation, outputSchema, attachments, sideload/social-video tiers, issue-tracker auto-create', 'api', 'common'),
154
+ done('runhuman.jobs.get', 'jobs', 'GET /api/jobs/:jobId returns the enriched, result-gated job (jobUrl, projectName, no testerToken in metadata; without a subscription the conversation is withheld and the owning org named, with one the testerResponse is served); unknown is 404, another org\'s key is 403', 'api', 'core', () =>
155
+ withWorld('runhuman.jobs.get', async (h) => {
156
+ const id = await createJob(h);
157
+ await claim(h, id, TESS, 10);
158
+ const g = await h({ m: 'GET', p: `/api/jobs/${id}` });
159
+ const j = b(g);
160
+ if (!(g.status === 200 && j.id === id && j.status === 'working' && j.testerAlias === 'tess' && j.jobUrl === `https://runhuman.com/dashboard/proj-1/jobs/${id}`
161
+ && j.projectName === 'Web' && j.richResultsGated === true && j.ownerOrganizationName === 'Acme' && j.recordingStatus === 'none'
162
+ && j.metadata?.source === 'api-async' && j.metadata?.testerToken === undefined && j.assignedTester === undefined && j.conversationHistory === undefined)) return false;
163
+ // result gating: without a subscription the conversation (and the testerResponse drawn from it) is withheld
164
+ const said = [{ role: 'user', content: 'Checkout works' }];
165
+ const paid = String(b(await h({ m: 'POST', p: '/api/jobs', b: { projectId: 'proj-3', url: 'https://paid.example.com', description: 'x' }, auth: `Bearer ${PAID_KEY}` })).jobId);
166
+ const paidToken = await claim(h, paid, MIKE, 10);
167
+ await h({ m: 'PATCH', p: `/api/tester/jobs/${paid}`, b: { conversationHistory: said }, auth: tester(paidToken), at: 20 });
168
+ const pg = await h({ m: 'GET', p: `/api/jobs/${paid}`, auth: `Bearer ${PAID_KEY}` });
169
+ if (!(b(pg).richResultsGated === false && b(pg).testerResponse === 'Checkout works' && b(pg).ownerOrganizationName === undefined)) return false;
170
+ const miss = await h({ m: 'GET', p: '/api/jobs/00000000-0000-4000-8000-000000000000' });
171
+ const foreign = await h({ m: 'GET', p: `/api/jobs/${id}`, auth: `Bearer ${OTHER_KEY}` });
172
+ return miss.status === 404 && b(miss).error === 'Job not found'
173
+ && foreign.status === 403 && b(foreign).message === 'API key organization does not match job project organization';
174
+ })),
175
+ done('runhuman.jobs.status', 'jobs', 'GET /api/jobs/:jobId/status returns the polling view (status, timing, jobUrl, gating) without the full row; unknown is 404, another org\'s key is 403', 'api', 'core', () =>
176
+ withWorld('runhuman.jobs.status', async (h) => {
177
+ const id = await createJob(h, { targetDurationMinutes: 10, maxExtensionMinutes: 2 });
178
+ const s = await h({ m: 'GET', p: `/api/jobs/${id}/status` });
179
+ const v = b(s);
180
+ if (!(s.status === 200 && v.id === id && v.status === 'pending' && v.targetDurationMinutes === 10 && v.maxExtensionMinutes === 2 && v.projectName === 'Web'
181
+ && v.jobUrl === `https://runhuman.com/dashboard/proj-1/jobs/${id}` && v.richResultsGated === true && v.recordingStatus === 'none'
182
+ && v.version === undefined && v.billedOrganizationId === undefined && v.metadata === undefined)) return false;
183
+ const miss = await h({ m: 'GET', p: '/api/jobs/00000000-0000-4000-8000-000000000000/status' });
184
+ const foreign = await h({ m: 'GET', p: `/api/jobs/${id}/status`, auth: `Bearer ${OTHER_KEY}` });
185
+ return miss.status === 404 && foreign.status === 403;
186
+ })),
187
+ todo('runhuman.jobs.list', 'jobs', 'GET /api/jobs — the caller\'s paginated job list (web and CLI)', 'api', 'common'),
188
+ todo('runhuman.jobs.run_sync', 'jobs', 'POST /api/run — create a job and block until a tester finishes (up to 60 minutes)', 'api', 'core'),
189
+ todo('runhuman.jobs.rerun', 'jobs', 'POST /api/jobs/:jobId/rerun — clone a job\'s settings and re-dispatch, with body overrides', 'api', 'common'),
190
+ todo('runhuman.jobs.artifact', 'jobs', 'GET /api/jobs/:jobId/artifacts/:artifactType — one job artifact by type', 'api', 'common'),
191
+ todo('runhuman.jobs.frame', 'jobs', 'GET /api/jobs/:jobId/frames/:frameId — capability redirect to an extracted video frame', 'api', 'niche'),
192
+ todo('runhuman.jobs.popout_details', 'jobs', 'GET /api/jobs/:jobId/popout-details — the dashboard pop-out detail view', 'api', 'niche'),
193
+ todo('runhuman.jobs.feedback', 'jobs', 'PATCH /api/jobs/:jobId/feedback — the customer\'s rating/feedback on a job', 'api', 'niche'),
194
+ todo('runhuman.jobs.share_enable', 'jobs', 'POST /api/jobs/:jobId/share — enable a public share link', 'api', 'niche'),
195
+ todo('runhuman.jobs.share_disable', 'jobs', 'DELETE /api/jobs/:jobId/share — disable the public share link', 'api', 'niche'),
196
+ todo('runhuman.jobs.public', 'jobs', 'GET /api/public/jobs/:jobId/:token — the shared job read without auth', 'api', 'niche'),
197
+ todo('runhuman.jobs.baked_video_status', 'jobs', 'GET /api/jobs/:jobId/baked-demo-video-status — latest baked demo-video render status', 'api', 'niche'),
198
+ todo('runhuman.jobs.baked_video_render', 'jobs', 'POST /api/jobs/:jobId/baked-demo-video — trigger an idempotent baked demo-video render', 'api', 'niche'),
199
+ todo('runhuman.jobs.timers', 'jobs', 'Time-driven job monitors: release throttle (pending → queued → waiting), claim-window expiry, no-response timeout, overdue', 'api', 'common'),
200
+
201
+ // ── PROJECTS ────────────────────────────────────────────────────────────────────────────────
202
+ done('runhuman.projects.jobs', 'projects', 'GET /api/projects/:projectId/jobs pages the project\'s jobs newest first in customer list columns with {total, limit, offset, hasMore}; unknown project is 404, another org\'s key is 403', 'api', 'common', () =>
203
+ withWorld('runhuman.projects.jobs', async (h) => {
204
+ const a = await createJob(h, { description: 'first' }, 0);
205
+ const bb = await createJob(h, { description: 'second' }, 10);
206
+ const c = await createJob(h, { description: 'third' }, 20);
207
+ const p1 = await h({ m: 'GET', p: '/api/projects/proj-1/jobs?limit=2' });
208
+ const p2 = await h({ m: 'GET', p: '/api/projects/proj-1/jobs?limit=2&offset=2' });
209
+ const items1 = b(p1).items as Body[];
210
+ const items2 = b(p2).items as Body[];
211
+ const pg1 = b(p1).pagination;
212
+ const pg2 = b(p2).pagination;
213
+ if (!(p1.status === 200 && items1.map((j) => j.id).join() === [c, bb].join() && items2.map((j) => j.id).join() === a
214
+ && pg1.total === 3 && pg1.limit === 2 && pg1.offset === 0 && pg1.hasMore === true && pg2.hasMore === false
215
+ && items1[0]!.description === 'third' && items1[0]!.version === undefined && items1[0]!.targetDurationMinutes === undefined)) return false;
216
+ const miss = await h({ m: 'GET', p: '/api/projects/nope/jobs' });
217
+ const foreign = await h({ m: 'GET', p: '/api/projects/proj-1/jobs', auth: `Bearer ${OTHER_KEY}` });
218
+ return miss.status === 404 && b(miss).error === 'Project not found' && foreign.status === 403 && b(foreign).error === 'Access denied';
219
+ })),
220
+ todo('runhuman.projects.list', 'projects', 'GET /api/projects — projects accessible to the caller', 'api', 'common'),
221
+ todo('runhuman.projects.create', 'projects', 'POST /api/projects — create a project in an organization', 'api', 'common'),
222
+ todo('runhuman.projects.get', 'projects', 'GET /api/projects/:projectId — project details', 'api', 'common'),
223
+ todo('runhuman.projects.update', 'projects', 'PATCH /api/projects/:projectId — update project details', 'api', 'niche'),
224
+ todo('runhuman.projects.delete', 'projects', 'DELETE /api/projects/:projectId — soft-delete a project and its data', 'api', 'niche'),
225
+
226
+ // ── ORGANIZATIONS ───────────────────────────────────────────────────────────────────────────
227
+ todo('runhuman.organizations.list', 'organizations', 'GET /api/organizations — organizations the caller belongs to', 'api', 'common'),
228
+ todo('runhuman.organizations.get', 'organizations', 'GET /api/organizations/:organizationId — organization details', 'api', 'common'),
229
+ todo('runhuman.organizations.create', 'organizations', 'POST /api/organizations — create an organization', 'api', 'niche'),
230
+ todo('runhuman.organizations.members', 'organizations', 'GET /api/organizations/:organizationId/members — list members', 'api', 'niche'),
231
+ todo('runhuman.organizations.invite', 'organizations', 'POST /api/organizations/:organizationId/invite — invite a user by email', 'api', 'niche'),
232
+ todo('runhuman.organizations.remove_member', 'organizations', 'DELETE /api/organizations/:organizationId/members/:userId — remove a member', 'api', 'niche'),
233
+ todo('runhuman.organizations.projects', 'organizations', 'GET /api/organizations/:organizationId/projects — the organization\'s projects', 'api', 'niche'),
234
+
235
+ // ── API KEYS / PATS ─────────────────────────────────────────────────────────────────────────
236
+ todo('runhuman.keys.list', 'keys', 'GET /api/keys?organizationId= — an organization\'s API keys', 'api', 'common'),
237
+ todo('runhuman.keys.create', 'keys', 'POST /api/keys — mint an organization-scoped API key', 'api', 'common'),
238
+ todo('runhuman.keys.revoke', 'keys', 'POST /api/keys/:keyId/revoke — revoke an API key', 'api', 'niche'),
239
+ todo('runhuman.keys.delete', 'keys', 'DELETE /api/keys/:keyId — permanently delete an API key', 'api', 'niche'),
240
+ todo('runhuman.pats.create', 'pats', 'POST /api/pats — create a Personal Access Token', 'api', 'niche'),
241
+ todo('runhuman.pats.list', 'pats', 'GET /api/pats — the caller\'s PATs', 'api', 'niche'),
242
+ todo('runhuman.pats.revoke', 'pats', 'POST /api/pats/:patId/revoke — revoke a PAT', 'api', 'niche'),
243
+ todo('runhuman.pats.delete', 'pats', 'DELETE /api/pats/:patId — permanently delete a PAT', 'api', 'niche'),
244
+
245
+ // ── TEMPLATES / SCHEDULES ───────────────────────────────────────────────────────────────────
246
+ todo('runhuman.templates.list', 'templates', 'GET /api/projects/:projectId/templates — a project\'s templates', 'api', 'common'),
247
+ todo('runhuman.templates.create', 'templates', 'POST /api/projects/:projectId/templates — create a template', 'api', 'common'),
248
+ todo('runhuman.templates.get', 'templates', 'GET /api/projects/:projectId/templates/:templateId — template details', 'api', 'common'),
249
+ todo('runhuman.templates.update', 'templates', 'PATCH /api/projects/:projectId/templates/:templateId — update a template', 'api', 'niche'),
250
+ todo('runhuman.templates.delete', 'templates', 'DELETE /api/projects/:projectId/templates/:templateId — delete a template', 'api', 'niche'),
251
+ todo('runhuman.schedules.list', 'schedules', 'GET /api/projects/:projectId/schedules — a project\'s schedules', 'api', 'niche'),
252
+ todo('runhuman.schedules.create', 'schedules', 'POST /api/projects/:projectId/schedules — create a recurring schedule', 'api', 'niche'),
253
+ todo('runhuman.schedules.get', 'schedules', 'GET /api/projects/:projectId/schedules/:scheduleId — schedule details with template name', 'api', 'niche'),
254
+ todo('runhuman.schedules.update', 'schedules', 'PATCH /api/projects/:projectId/schedules/:scheduleId — partial schedule update', 'api', 'niche'),
255
+ todo('runhuman.schedules.delete', 'schedules', 'DELETE /api/projects/:projectId/schedules/:scheduleId — delete a schedule', 'api', 'niche'),
256
+ todo('runhuman.schedules.executions', 'schedules', 'GET /api/projects/:projectId/schedules/:scheduleId/executions — a schedule\'s execution history', 'api', 'niche'),
257
+
258
+ // ── GITHUB / ISSUES ─────────────────────────────────────────────────────────────────────────
259
+ todo('runhuman.github.oauth_authorize', 'github', 'GET /api/github/oauth/authorize — start the GitHub connection', 'api', 'niche'),
260
+ todo('runhuman.github.issues_list', 'github', 'GET /api/github/issues — a connected repo\'s issues', 'api', 'niche'),
261
+ todo('runhuman.github.issue_get', 'github', 'GET /api/github/issues/:issueNumber — one issue', 'api', 'niche'),
262
+ todo('runhuman.github.issue_test', 'github', 'POST /api/github/issues/test — test an issue with a human tester', 'api', 'niche'),
263
+ todo('runhuman.github.issues_bulk_test', 'github', 'POST /api/github/issues/bulk-test — test several issues', 'api', 'niche'),
264
+ todo('runhuman.github.test_sessions', 'github', 'GET /api/github/issues/test-sessions — issue test sessions', 'api', 'niche'),
265
+ todo('runhuman.github.create_issue', 'github', 'POST /api/jobs/:jobId/create-issue — file a GitHub issue from a finding', 'api', 'niche'),
266
+ todo('runhuman.github.feedback_issue', 'github', 'POST /api/jobs/:jobId/feedback/:extractedFeedbackIndex/github — file extracted feedback to GitHub', 'api', 'niche'),
267
+ todo('runhuman.github.undo_issue_comment', 'github', 'POST /api/jobs/:jobId/extracted-issues/:extractedIssueIndex/undo-comment — undo a duplicate-issue comment', 'api', 'niche'),
268
+
269
+ // ── AUTH / BILLING ──────────────────────────────────────────────────────────────────────────
270
+ todo('runhuman.auth.me', 'auth', 'GET /api/auth/me — the current user', 'api', 'common'),
271
+ todo('runhuman.billing.balance', 'billing', 'GET /api/billing/balance — credit balance', 'api', 'niche'),
272
+ todo('runhuman.billing.has_credits', 'billing', 'GET /api/billing/has-credits — whether credits are available', 'api', 'niche'),
273
+ todo('runhuman.billing.checkout', 'billing', 'POST /api/billing/checkout — create a checkout session', 'api', 'niche'),
274
+ todo('runhuman.billing.subscription', 'billing', 'GET /api/billing/subscription — the subscription', 'api', 'niche'),
275
+ todo('runhuman.billing.change_plan', 'billing', 'POST /api/billing/change-plan — change the plan', 'api', 'niche'),
276
+
277
+ // ── TESTER NOTES (customer-authored guidance) ───────────────────────────────────────────────
278
+ todo('runhuman.notes.list', 'notes', 'GET /api/tester/notes — list/search tester notes', 'api', 'niche'),
279
+ todo('runhuman.notes.create', 'notes', 'POST /api/tester/notes — create a tester note', 'api', 'niche'),
280
+ todo('runhuman.notes.get', 'notes', 'GET /api/tester/notes/:noteId — one tester note', 'api', 'niche'),
281
+ todo('runhuman.notes.update', 'notes', 'PATCH /api/tester/notes/:noteId — update a tester note', 'api', 'niche'),
282
+ todo('runhuman.notes.archive', 'notes', 'POST /api/tester/notes/:noteId/archive — archive a tester note', 'api', 'niche'),
283
+
284
+ // ── TESTER LIFECYCLE ────────────────────────────────────────────────────────────────────────
285
+ done('runhuman.tester.claim', 'tester', 'POST /api/jobs/:jobId/claim moves a pending job to working and mints the testerToken in the webTestLink; a second claim is 409 job_already_claimed, an unknown job 404, a missing claimSource 400 FST_ERR_VALIDATION, an unknown web user 401', 'api', 'core', () =>
286
+ withWorld('runhuman.tester.claim', async (h) => {
287
+ const id = await createJob(h);
288
+ const c = await h({ m: 'POST', p: `/api/jobs/${id}/claim`, b: { claimSource: 'tester-portal' }, auth: TESS, at: 60 });
289
+ const link = String(b(c).webTestLink ?? '');
290
+ if (!(c.status === 200 && b(c).message === 'Job claimed successfully' && b(c).job?.id === id && b(c).job?.status === 'working' && b(c).job?.claimedAt === iso(60)
291
+ && link.startsWith(`https://runhuman.com/tester/test/${id}?testerToken=`) && link.split('testerToken=')[1]!.split('.').length === 3)) return false;
292
+ const s = await h({ m: 'GET', p: `/api/jobs/${id}/status` });
293
+ if (!(b(s).status === 'working' && b(s).testerAlias === 'tess' && b(s).responseDeadline === iso(60 + 20 * 60))) return false;
294
+ const twice = await h({ m: 'POST', p: `/api/jobs/${id}/claim`, b: { claimSource: 'tester-portal' }, auth: MIKE, at: 70 });
295
+ const miss = await h({ m: 'POST', p: '/api/jobs/00000000-0000-4000-8000-000000000000/claim', b: { claimSource: 'tester-portal' }, auth: MIKE });
296
+ const noSource = await h({ m: 'POST', p: `/api/jobs/${id}/claim`, b: {}, auth: MIKE });
297
+ const stranger = await h({ m: 'POST', p: `/api/jobs/${id}/claim`, b: { claimSource: 'tester-portal' }, auth: 'Bearer clerk_nobody@x.test' });
298
+ return twice.status === 409 && b(twice).code === 'job_already_claimed' && b(twice).error === 'Job already claimed'
299
+ && miss.status === 404 && b(miss).code === 'job_not_found'
300
+ && noSource.status === 400 && b(noSource).error === 'Invalid request body: claimSource: Required'
301
+ && stranger.status === 401;
302
+ })),
303
+ done('runhuman.tester.claim_gates', 'tester', 'POST /api/jobs/:jobId/claim enforces the pool and surface gates: a missing required language is 403 pool_requirements_not_met with the missing list, a mobile-only job claimed from the portal is 403 mobile_only, a tester already working is 409 tester_has_active_job', 'api', 'common', () =>
304
+ withWorld('runhuman.tester.claim_gates', async (h) => {
305
+ const spanish = await createJob(h, { requiredLanguages: ['spanish'] });
306
+ const mobile = await createJob(h, { requiredDevices: ['ios'] });
307
+ const first = await createJob(h);
308
+ const second = await createJob(h);
309
+ const pool = await h({ m: 'POST', p: `/api/jobs/${spanish}/claim`, b: { claimSource: 'tester-portal' }, auth: TESS });
310
+ const surface = await h({ m: 'POST', p: `/api/jobs/${mobile}/claim`, b: { claimSource: 'tester-portal' }, auth: TESS });
311
+ await claim(h, first, TESS, 10);
312
+ const busy = await h({ m: 'POST', p: `/api/jobs/${second}/claim`, b: { claimSource: 'tester-portal' }, auth: TESS, at: 20 });
313
+ const details = b(pool).details as string[];
314
+ return pool.status === 403 && b(pool).code === 'pool_requirements_not_met' && Array.isArray(details) && details.join() === 'Missing language(s): Spanish'
315
+ && surface.status === 403 && b(surface).code === 'mobile_only'
316
+ && busy.status === 409 && b(busy).code === 'tester_has_active_job' && b(busy).details === `Already working on job "${first}"`;
317
+ })),
318
+ done('runhuman.tester.job_get', 'tester', 'GET /api/tester/jobs/:jobId reads the claimed job under its testerToken (with organizationName); no token is 401, a forged token 401 invalid_signature, a valid token for another job 404', 'api', 'core', () =>
319
+ withWorld('runhuman.tester.job_get', async (h) => {
320
+ const id = await createJob(h);
321
+ const other = await createJob(h);
322
+ const token = await claim(h, id, TESS, 10);
323
+ const g = await h({ m: 'GET', p: `/api/tester/jobs/${id}`, auth: tester(token), at: 20 });
324
+ if (!(g.status === 200 && b(g).id === id && b(g).status === 'working' && b(g).organizationName === 'Acme' && b(g).testerAlias === 'tess' && b(g).description === 'Buy the cheapest item')) return false;
325
+ const [head, payload, sig] = token.split('.');
326
+ const forged = `${head}.${payload}.${sig!.slice(0, -2)}${sig!.endsWith('AA') ? 'BB' : 'AA'}`;
327
+ const none = await h({ m: 'GET', p: `/api/tester/jobs/${id}`, auth: null, at: 20 });
328
+ const bad = await h({ m: 'GET', p: `/api/tester/jobs/${id}`, auth: tester(forged), at: 20 });
329
+ const wrongJob = await h({ m: 'GET', p: `/api/tester/jobs/${other}`, auth: tester(token), at: 20 });
330
+ return none.status === 401 && bad.status === 401 && b(bad).reason === 'invalid_signature' && wrongJob.status === 404 && b(wrongJob).error === 'Job not found';
331
+ })),
332
+ done('runhuman.tester.job_update', 'tester', 'PATCH /api/tester/jobs/:jobId writes only the tester-writable fields (the conversation, stamped with the tester; never cost), refuses completion before an AI verdict (400) and a non-object body (400 FST_ERR_VALIDATION)', 'api', 'core', () =>
333
+ withWorld('runhuman.tester.job_update', async (h) => {
334
+ const id = await createJob(h);
335
+ const token = await claim(h, id, TESS, 10);
336
+ const u = await h({ m: 'PATCH', p: `/api/tester/jobs/${id}`, b: { conversationHistory: [{ role: 'user', content: 'Checkout works' }], costUsd: 999 }, auth: tester(token), at: 30 });
337
+ if (!(u.status === 200 && b(u).success === true && b(u).job?.id === id && b(u).job?.status === 'working')) return false;
338
+ const g = await h({ m: 'GET', p: `/api/tester/jobs/${id}`, auth: tester(token), at: 30 });
339
+ const history = b(g).conversationHistory as Body[];
340
+ if (!(history.length === 1 && history[0]!.content === 'Checkout works' && history[0]!.testerId === 'tess' && b(g).costUsd === undefined && b(g).lastActivity === iso(30))) return false;
341
+ const early = await h({ m: 'PATCH', p: `/api/tester/jobs/${id}`, b: { status: 'completed' }, auth: tester(token), at: 40 });
342
+ const arr = await h({ m: 'PATCH', p: `/api/tester/jobs/${id}`, raw: '[1]', auth: tester(token), at: 40 });
343
+ return early.status === 400 && b(early).error === 'Cannot complete job without an AI verdict'
344
+ && arr.status === 400 && b(arr).error === 'Invalid request body: Invalid input' && b(arr).code === 'FST_ERR_VALIDATION';
345
+ })),
346
+ done('runhuman.tester.process_results', 'tester', 'POST /api/tester/jobs/:jobId/process-results queues the results pipeline (200 {processingJobId, status:"processing"}); another job\'s testerToken is 403 "Invalid testerToken for job"', 'api', 'core', () =>
347
+ withWorld('runhuman.tester.process_results', async (h) => {
348
+ const id = await createJob(h);
349
+ const other = await createJob(h);
350
+ const token = await claim(h, id, TESS, 10);
351
+ const otherToken = await claim(h, other, MIKE, 10);
352
+ const pr = await h({ m: 'POST', p: `/api/tester/jobs/${id}/process-results`, b: { clientInfo: { browser: 'chrome' } }, auth: tester(token), at: 20 });
353
+ const g = await h({ m: 'GET', p: `/api/tester/jobs/${id}`, auth: tester(token), at: 20 });
354
+ const cross = await h({ m: 'POST', p: `/api/tester/jobs/${id}/process-results`, b: {}, auth: tester(otherToken), at: 20 });
355
+ return pr.status === 200 && typeof b(pr).processingJobId === 'string' && b(pr).status === 'processing' && String(b(pr).message).startsWith('Processing started.')
356
+ && b(g).metadata?.testerClient?.browser === 'chrome'
357
+ && cross.status === 403 && b(cross).error === 'Invalid testerToken for job';
358
+ })),
359
+ done('runhuman.tester.processing_status', 'tester', 'GET /api/tester/jobs/:jobId/processing/:processingJobId completes the queued extract run on its first read (the submitted result kept, no issues extracted); an unknown run is 404', 'api', 'core', () =>
360
+ withWorld('runhuman.tester.processing_status', async (h) => {
361
+ const id = await createJob(h);
362
+ const token = await claim(h, id, TESS, 10);
363
+ await h({ m: 'PATCH', p: `/api/tester/jobs/${id}`, b: { result: { explanation: 'paid with test card', data: { success: true } } }, auth: tester(token), at: 20 });
364
+ const pr = await h({ m: 'POST', p: `/api/tester/jobs/${id}/process-results`, b: {}, auth: tester(token), at: 30 });
365
+ const poll = await h({ m: 'GET', p: `/api/tester/jobs/${id}/processing/${b(pr).processingJobId}`, auth: tester(token), at: 40 });
366
+ const job = b(poll).result?.job as Body | undefined;
367
+ const miss = await h({ m: 'GET', p: `/api/tester/jobs/${id}/processing/nope`, auth: tester(token), at: 40 });
368
+ return poll.status === 200 && b(poll).status === 'completed' && b(poll).result?.isComplete === true
369
+ && job?.result?.explanation === 'paid with test card' && Array.isArray(job?.extractedIssues) && job.extractedIssues.length === 0 && Array.isArray(job?.extractedFeedback)
370
+ && miss.status === 404 && b(miss).error === 'Processing job not found';
371
+ })),
372
+ done('runhuman.tester.issue_review', 'tester', 'POST /api/tester/jobs/:jobId/issue-review `continue` queues the finalize run whose verdict follows result.data.success (false → fail); before extraction it is 409, an unknown action 400', 'api', 'core', () =>
373
+ withWorld('runhuman.tester.issue_review', async (h) => {
374
+ const id = await createJob(h);
375
+ const token = await claim(h, id, TESS, 10);
376
+ const early = await h({ m: 'POST', p: `/api/tester/jobs/${id}/issue-review`, b: { action: 'continue' }, auth: tester(token), at: 20 });
377
+ const badAction = await h({ m: 'POST', p: `/api/tester/jobs/${id}/issue-review`, b: { action: 'skip' }, auth: tester(token), at: 20 });
378
+ await h({ m: 'PATCH', p: `/api/tester/jobs/${id}`, b: { result: { explanation: 'checkout 500s', data: { success: false } } }, auth: tester(token), at: 30 });
379
+ const pr = await h({ m: 'POST', p: `/api/tester/jobs/${id}/process-results`, b: {}, auth: tester(token), at: 30 });
380
+ await h({ m: 'GET', p: `/api/tester/jobs/${id}/processing/${b(pr).processingJobId}`, auth: tester(token), at: 40 });
381
+ const review = await h({ m: 'POST', p: `/api/tester/jobs/${id}/issue-review`, b: { action: 'continue' }, auth: tester(token), at: 50 });
382
+ await h({ m: 'GET', p: `/api/tester/jobs/${id}/processing/${b(review).processingJobId}`, auth: tester(token), at: 60 });
383
+ const s = await h({ m: 'GET', p: `/api/jobs/${id}/status` });
384
+ return early.status === 409 && b(early).message === 'Issue review is not available until extraction has run.'
385
+ && badAction.status === 400 && b(badAction).message === 'action must be one of: reprocess, continue'
386
+ && review.status === 200 && typeof b(review).processingJobId === 'string' && b(review).rerunCount === 0 && b(review).softCapReached === false
387
+ && b(s).passStatus === 'fail';
388
+ })),
389
+ todo('runhuman.tester.issue_review_reprocess', 'tester', 'POST /api/tester/jobs/:jobId/issue-review `reprocess` — AI re-extraction with the tester\'s revise guidance', 'api', 'common'),
390
+ done('runhuman.tester.complete', 'tester', 'PATCH /api/tester/jobs/:jobId {status:"completed"} after a verdict completes the job with RH1\'s cost ($0.0085/s over claim → completion, capped at the allotted time) and settles the charge; a repeated completion is an idempotent 200', 'api', 'core', () =>
391
+ withWorld('runhuman.tester.complete', async (h) => {
392
+ const id = await createJob(h);
393
+ const token = await claim(h, id, TESS, 0);
394
+ if (!(await toVerdict(h, id, token, true, 60))) return false;
395
+ const c = await h({ m: 'PATCH', p: `/api/tester/jobs/${id}`, b: { status: 'completed' }, auth: tester(token), at: 120 });
396
+ const again = await h({ m: 'PATCH', p: `/api/tester/jobs/${id}`, b: { status: 'completed' }, auth: tester(token), at: 180 });
397
+ const g = await h({ m: 'GET', p: `/api/jobs/${id}` });
398
+ const j = b(g);
399
+ return c.status === 200 && b(c).job?.status === 'completed' && again.status === 200 && b(again).job?.status === 'completed'
400
+ && j.status === 'completed' && j.passStatus === 'pass' && j.completedAt === iso(120) && j.testDurationSeconds === 120
401
+ && Math.abs(Number(j.costUsd) - 120 * 0.0085) < 1e-9 && j.billedAmountCents === 102 && j.billedAt === iso(120);
402
+ })),
403
+ done('runhuman.tester.end', 'tester', 'POST /api/tester/jobs/:jobId/end cancels (tester_cancellation, cost 0) or files a platform issue (failed), answers an already-terminal job alreadyTerminal:true, and refuses a blank note (400) and an unknown action (400 FST_ERR_VALIDATION)', 'api', 'common', () =>
404
+ withWorld('runhuman.tester.end', async (h) => {
405
+ const id = await createJob(h);
406
+ const broken = await createJob(h);
407
+ const token = await claim(h, id, TESS, 10);
408
+ const brokenToken = await claim(h, broken, MIKE, 10);
409
+ const blank = await h({ m: 'POST', p: `/api/tester/jobs/${id}/end`, b: { action: 'clear_and_cancel', note: ' ' }, auth: tester(token), at: 20 });
410
+ const unknown = await h({ m: 'POST', p: `/api/tester/jobs/${id}/end`, b: { action: 'vanish', note: 'x' }, auth: tester(token), at: 20 });
411
+ const cancel = await h({ m: 'POST', p: `/api/tester/jobs/${id}/end`, b: { action: 'clear_and_cancel', note: 'site is down' }, auth: tester(token), at: 30 });
412
+ const repeat = await h({ m: 'POST', p: `/api/tester/jobs/${id}/end`, b: { action: 'clear_and_cancel', note: 'again' }, auth: tester(token), at: 40 });
413
+ const platform = await h({ m: 'POST', p: `/api/tester/jobs/${broken}/end`, b: { action: 'platform_issue', note: 'recorder crashed' }, auth: tester(brokenToken), at: 30 });
414
+ const g = await h({ m: 'GET', p: `/api/jobs/${id}` });
415
+ const gb = await h({ m: 'GET', p: `/api/jobs/${broken}` });
416
+ return blank.status === 400 && b(blank).message === 'A note is required for action "clear_and_cancel"'
417
+ && unknown.status === 400 && b(unknown).code === 'FST_ERR_VALIDATION'
418
+ && cancel.status === 200 && b(cancel).status === 'cancelled' && b(cancel).action === 'clear_and_cancel'
419
+ && repeat.status === 200 && b(repeat).alreadyTerminal === true
420
+ && b(g).status === 'cancelled' && b(g).modifier === 'tester_cancellation' && b(g).error === 'site is down' && b(g).costUsd === 0
421
+ && platform.status === 200 && b(gb).status === 'failed' && b(gb).modifier === 'platform_issue';
422
+ })),
423
+ done('runhuman.tester.release_reclaim', 'tester', 'POST /api/tester/jobs/:jobId/end clear_and_release returns the job to waiting with the tester cleared: the old testerToken stops working (404) and another tester can claim it', 'api', 'common', () =>
424
+ withWorld('runhuman.tester.release_reclaim', async (h) => {
425
+ const id = await createJob(h);
426
+ const token = await claim(h, id, TESS, 10);
427
+ const rel = await h({ m: 'POST', p: `/api/tester/jobs/${id}/end`, b: { action: 'clear_and_release', note: 'need a phone' }, auth: tester(token), at: 20 });
428
+ const s = await h({ m: 'GET', p: `/api/jobs/${id}/status` });
429
+ if (!(rel.status === 200 && b(rel).status === 'waiting' && b(s).status === 'waiting' && b(s).testerAlias === undefined && b(s).claimedAt === undefined)) return false;
430
+ const stale = await h({ m: 'GET', p: `/api/tester/jobs/${id}`, auth: tester(token), at: 30 });
431
+ const token2 = await claim(h, id, MIKE, 40);
432
+ const g = await h({ m: 'GET', p: `/api/tester/jobs/${id}`, auth: tester(token2), at: 50 });
433
+ return stale.status === 404 && b(g).status === 'working' && b(g).testerAlias === 'mike' && b(g).claimedAt === iso(40);
434
+ })),
435
+ todo('runhuman.tester.opened', 'tester', 'POST /api/tester/jobs/:jobId/opened — mark the job engaged when the tester opens it', 'api', 'common'),
436
+ todo('runhuman.tester.upload_urls', 'tester', 'GET /api/tester/jobs/:jobId/upload-urls — signed upload URLs for recordings', 'api', 'common'),
437
+ todo('runhuman.tester.decline_late_submission', 'tester', 'POST /api/tester/jobs/:jobId/decline-late-submission — drop an overdue job', 'api', 'niche'),
438
+ todo('runhuman.tester.access_ended_info', 'tester', 'GET /api/tester/jobs/:jobId/access-ended-info — why the tester\'s access ended', 'api', 'niche'),
439
+ todo('runhuman.tester.correlate', 'tester', 'POST /api/tester/jobs/:jobId/correlate — correlate browser-extension data with the session', 'api', 'niche'),
440
+ todo('runhuman.tester.data_status', 'tester', 'GET /api/tester/jobs/:jobId/data-status — captured-data status', 'api', 'niche'),
441
+ todo('runhuman.tester.extension_token', 'tester', 'POST /api/tester/jobs/:jobId/extension-token — mint the browser-extension token', 'api', 'niche'),
442
+ todo('runhuman.tester.retranscribe', 'tester', 'POST /api/tester/jobs/:jobId/retranscribe — re-run transcription', 'api', 'niche'),
443
+ todo('runhuman.tester.strip_audio', 'tester', 'POST /api/tester/jobs/:jobId/strip-audio — strip audio from the recording', 'api', 'niche'),
444
+
445
+ // ── CONNECTOR ───────────────────────────────────────────────────────────────────────────────
446
+ todo('runhuman.connector.pull', 'connector', 'syncRunhumanFromReal pulls a real account\'s jobs by paging GET /api/projects/:projectId/jobs', 'connector', 'common'),
447
+ ];
448
+
449
+ /** RH1's product areas, top-down from the public REST reference's sections (jobs, organizations,
450
+ * projects, API keys, PATs, templates, schedules, GitHub, auth, billing), the jobs router's tester
451
+ * side, the customer allowlist's tester notes, and the connector. */
452
+ export const RUNHUMAN_AREAS = [
453
+ 'auth',
454
+ 'billing',
455
+ 'connector',
456
+ 'github',
457
+ 'jobs',
458
+ 'keys',
459
+ 'notes',
460
+ 'organizations',
461
+ 'pats',
462
+ 'projects',
463
+ 'schedules',
464
+ 'templates',
465
+ 'tester',
466
+ ] as const;
467
+
468
+ export function runhumanCapabilities(): Promise<CapabilityReport> {
469
+ return checkCapabilities('runhuman', RUNHUMAN_CAPABILITIES);
470
+ }
@@ -0,0 +1,14 @@
1
+ import { handleRunhumanTwinRequest } from './runhuman-twin.ts';
2
+
3
+ export type RunhumanConformanceReport = { ok: boolean; checksRun: number; failures: string[] };
4
+
5
+ /** The fail-loudly floor: an unmodelled /api route answers RH1's JSON 404
6
+ * (routes/route-static-routing.ts `isJsonOnlySurface` → `{ error: 'Not found' }`). */
7
+ export async function checkRunhumanConformance(options: { root?: string } = {}): Promise<RunhumanConformanceReport> {
8
+ const failures: string[] = [];
9
+ const unknown = await handleRunhumanTwinRequest({ method: 'GET', path: '/api/never-a-real-endpoint', root: options.root });
10
+ if (!(unknown.status === 404 && JSON.stringify(unknown.body) === '{"error":"Not found"}')) {
11
+ failures.push('an unmodelled /api route must answer RH1\'s JSON 404 {"error":"Not found"}');
12
+ }
13
+ return { ok: failures.length === 0, checksRun: 1, failures };
14
+ }
@@ -0,0 +1,67 @@
1
+ // Runhuman connector — pull/push against an INJECTED execute (the real network exists only
2
+ // inside liveRunhumanExecute). v1 pulls NOTHING; the gap is filed as the runhuman.connector.pull
3
+ // todo in runhuman-capabilities.ts, never a silent omission.
4
+ import { observeResource } from '@volter/world-core';
5
+ import { RunhumanBudget, RunhumanBudgetError, runhumanCallWeight, type RunhumanBudgetOptions } from './runhuman-budget.ts';
6
+
7
+ export type RunhumanExecute = (req: { method: string; path: string; body?: unknown }) => Promise<{ status: number; data: unknown }>;
8
+
9
+ export const RUNHUMAN_API_BASE = 'https://runhuman.com';
10
+
11
+ /** THE CHOKE POINT — the one place this pack reaches the real Runhuman API. Reads RUNHUMAN_API_KEY.
12
+ * EVERY request is charged against the RunhumanBudget BEFORE it goes out (`checkBudget` throws
13
+ * instead of calling once the ceiling or a persisted cooldown says stop), and the response is fed
14
+ * back (`recordCall`) so a 429 / Retry-After becomes a persisted cooldown. There is no option to
15
+ * disable the guard: a `budget` that is not a real RunhumanBudget is refused. */
16
+ export function liveRunhumanExecute(opts: {
17
+ apiKey?: string;
18
+ baseUrl?: string;
19
+ fetchImpl?: typeof fetch;
20
+ /** An existing budget to share across executes. Omit and one is constructed. */
21
+ budget?: RunhumanBudget;
22
+ /** Construction options for the default budget (ledger path, clock). Cannot loosen it. */
23
+ budgetOptions?: RunhumanBudgetOptions;
24
+ } = {}): RunhumanExecute {
25
+ const apiKey = opts.apiKey ?? process.env.RUNHUMAN_API_KEY;
26
+ const baseUrl = (opts.baseUrl ?? RUNHUMAN_API_BASE).replace(/\/$/, '');
27
+ const doFetch = opts.fetchImpl ?? fetch;
28
+ if (!apiKey) throw new Error('liveRunhumanExecute: RUNHUMAN_API_KEY is not set (never pass a literal)');
29
+ if (opts.budget !== undefined && opts.budget !== null && !(opts.budget instanceof RunhumanBudget)) {
30
+ throw new Error('liveRunhumanExecute: `budget` must be a RunhumanBudget — refusing to build a live Runhuman client around an unverified rate guard');
31
+ }
32
+ // The default ledger is keyed by a hash of THIS key, so every checkout spending it shares one allowance.
33
+ const budget = opts.budget instanceof RunhumanBudget ? opts.budget : new RunhumanBudget({ token: apiKey, ...(opts.budgetOptions ?? {}) });
34
+ return async ({ method, path, body }) => {
35
+ const verb = (method || 'GET').toUpperCase();
36
+ const weight = runhumanCallWeight(verb, path);
37
+ // THROWS instead of calling. Nothing below this line runs when the budget refuses.
38
+ const reservation = budget.checkBudget(weight);
39
+ const res = await doFetch(`${baseUrl}${path}`, {
40
+ method: verb,
41
+ headers: { authorization: `Bearer ${apiKey}`, ...(body === undefined ? {} : { 'content-type': 'application/json' }) },
42
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }),
43
+ });
44
+ const headers: Record<string, string> = {};
45
+ res.headers.forEach((v: string, k: string) => { headers[k.toLowerCase()] = v; });
46
+ const text = await res.text();
47
+ // recordCall may THROW after arming the cooldown (a back-off beyond the cap). On a refused
48
+ // call that louder refusal wins; an answer Runhuman ACCEPTED is kept, so a write that landed is
49
+ // never recorded as failed and performed again on retry.
50
+ try {
51
+ budget.recordCall(weight, headers, { status: res.status, reservation });
52
+ } catch (error) {
53
+ if (!(error instanceof RunhumanBudgetError) || !res.ok) throw error;
54
+ }
55
+ return { status: res.status, data: text ? JSON.parse(text) : null };
56
+ };
57
+ }
58
+
59
+ /** D7 entry point. v1 pulls nothing (the rehearsal World seeds the twin); a pull would page
60
+ * GET /api/projects/:projectId/jobs. The runhuman.connector.pull capability records the gap. */
61
+ export async function syncRunhumanFromReal(_execute: RunhumanExecute, options: { root?: string; occurredAt?: string } = {}): Promise<{ pulled: number }> {
62
+ const resources: Array<{ type: string; id: string; fields: Record<string, unknown> }> = [];
63
+ // PROTOCOL 2: the pack OBSERVES each resource; the kernel diffs it against the tree and folds what changed
64
+ const at = options.occurredAt ?? new Date().toISOString();
65
+ for (const r of resources) observeResource('runhuman', { type: r.type, id: r.id, fields: r.fields }, { ...(options.root !== undefined ? { root: options.root } : {}), at });
66
+ return { pulled: resources.length };
67
+ }