@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.
- package/LICENSE +202 -0
- package/README.md +81 -0
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +25 -0
- package/dist/src/index.d.ts +8 -0
- package/dist/src/index.js +44 -0
- package/dist/src/runhuman-budget.d.ts +23 -0
- package/dist/src/runhuman-budget.js +45 -0
- package/dist/src/runhuman-capabilities.d.ts +7 -0
- package/dist/src/runhuman-capabilities.js +451 -0
- package/dist/src/runhuman-conformance.d.ts +10 -0
- package/dist/src/runhuman-conformance.js +11 -0
- package/dist/src/runhuman-connector.d.ts +32 -0
- package/dist/src/runhuman-connector.js +58 -0
- package/dist/src/runhuman-server.d.ts +13 -0
- package/dist/src/runhuman-server.js +25 -0
- package/dist/src/runhuman-twin.d.ts +16 -0
- package/dist/src/runhuman-twin.js +1162 -0
- package/dist/src/runhuman-validate.d.ts +40 -0
- package/dist/src/runhuman-validate.js +161 -0
- package/package.json +51 -0
- package/src/cli.ts +24 -0
- package/src/index.ts +61 -0
- package/src/runhuman-budget.ts +66 -0
- package/src/runhuman-capabilities.ts +470 -0
- package/src/runhuman-conformance.ts +14 -0
- package/src/runhuman-connector.ts +67 -0
- package/src/runhuman-server.ts +32 -0
- package/src/runhuman-twin.ts +1078 -0
- package/src/runhuman-validate.ts +185 -0
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { RunhumanTwinResponse } from './runhuman-twin.js';
|
|
2
|
+
export type FieldKind = {
|
|
3
|
+
kind: 'string';
|
|
4
|
+
} | {
|
|
5
|
+
kind: 'number';
|
|
6
|
+
} | {
|
|
7
|
+
kind: 'boolean';
|
|
8
|
+
} | {
|
|
9
|
+
kind: 'enum';
|
|
10
|
+
values: readonly string[];
|
|
11
|
+
} | {
|
|
12
|
+
kind: 'object';
|
|
13
|
+
} | {
|
|
14
|
+
kind: 'array';
|
|
15
|
+
of: FieldKind;
|
|
16
|
+
} | {
|
|
17
|
+
kind: 'unknown';
|
|
18
|
+
};
|
|
19
|
+
export type FieldSpec = FieldKind & {
|
|
20
|
+
required?: boolean;
|
|
21
|
+
refine?: (value: unknown) => string | undefined;
|
|
22
|
+
};
|
|
23
|
+
/** Validate a parsed JSON body against a z.object-shaped field map (unknown keys are stripped, as
|
|
24
|
+
* zod's default object does). Returns the 400 RH1 answers, or undefined when the body passes. */
|
|
25
|
+
export declare function validateBody(body: unknown, fields: Record<string, FieldSpec>): RunhumanTwinResponse | undefined;
|
|
26
|
+
/** A `z.custom` objectGuard over the whole body (RH1's PATCH /tester/jobs/:jobId schema). */
|
|
27
|
+
export declare function validateObjectBody(body: unknown): RunhumanTwinResponse | undefined;
|
|
28
|
+
export declare const DEVICE_CLASSES: readonly ["desktop", "mobile", "both"];
|
|
29
|
+
export declare const TESTER_DEVICES: readonly ["ios", "android", "pc", "mac"];
|
|
30
|
+
export declare const TESTER_LANGUAGES: readonly ["english", "spanish"];
|
|
31
|
+
export declare const CLAIM_SOURCES: readonly ["tester-portal", "mobile-app", "slack", "discord-activity"];
|
|
32
|
+
/** createTestJobRequestSchema (POST /api/jobs). */
|
|
33
|
+
export declare const CREATE_JOB_FIELDS: Record<string, FieldSpec>;
|
|
34
|
+
/** claimJobRequestSchema (POST /api/jobs/:jobId/claim): `claimSource` is REQUIRED by the schema
|
|
35
|
+
* even though the handler carries a default. */
|
|
36
|
+
export declare const CLAIM_JOB_FIELDS: Record<string, FieldSpec>;
|
|
37
|
+
/** endJobRequestSchema (POST /api/tester/jobs/:jobId/end). */
|
|
38
|
+
export declare const END_JOB_FIELDS: Record<string, FieldSpec>;
|
|
39
|
+
/** processTestResultsBodySchema (POST /api/tester/jobs/:jobId/process-results). */
|
|
40
|
+
export declare const PROCESS_RESULTS_FIELDS: Record<string, FieldSpec>;
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
function zodType(value) {
|
|
2
|
+
if (value === null)
|
|
3
|
+
return 'null';
|
|
4
|
+
if (Array.isArray(value))
|
|
5
|
+
return 'array';
|
|
6
|
+
if (typeof value === 'number' && Number.isNaN(value))
|
|
7
|
+
return 'nan';
|
|
8
|
+
return typeof value;
|
|
9
|
+
}
|
|
10
|
+
function checkKind(value, kind, path, issues) {
|
|
11
|
+
const at = (message) => issues.push(path ? `${path}: ${message}` : message);
|
|
12
|
+
switch (kind.kind) {
|
|
13
|
+
case 'unknown':
|
|
14
|
+
return;
|
|
15
|
+
case 'string':
|
|
16
|
+
case 'number':
|
|
17
|
+
case 'boolean':
|
|
18
|
+
if (typeof value !== kind.kind || (kind.kind === 'number' && Number.isNaN(value)))
|
|
19
|
+
at(`Expected ${kind.kind}, received ${zodType(value)}`);
|
|
20
|
+
return;
|
|
21
|
+
case 'enum':
|
|
22
|
+
if (typeof value !== 'string') {
|
|
23
|
+
at(`Expected ${kind.values.map((v) => `'${v}'`).join(' | ')}, received ${zodType(value)}`);
|
|
24
|
+
}
|
|
25
|
+
else if (!kind.values.includes(value)) {
|
|
26
|
+
at(`Invalid enum value. Expected ${kind.values.map((v) => `'${v}'`).join(' | ')}, received '${value}'`);
|
|
27
|
+
}
|
|
28
|
+
return;
|
|
29
|
+
case 'object':
|
|
30
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value))
|
|
31
|
+
at('Invalid input');
|
|
32
|
+
return;
|
|
33
|
+
case 'array':
|
|
34
|
+
if (!Array.isArray(value)) {
|
|
35
|
+
at(`Expected array, received ${zodType(value)}`);
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
value.forEach((el, i) => checkKind(el, kind.of, path ? `${path}.${i}` : String(i), issues));
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/** Validate a parsed JSON body against a z.object-shaped field map (unknown keys are stripped, as
|
|
43
|
+
* zod's default object does). Returns the 400 RH1 answers, or undefined when the body passes. */
|
|
44
|
+
export function validateBody(body, fields) {
|
|
45
|
+
const input = body === undefined || body === null ? {} : body;
|
|
46
|
+
const issues = [];
|
|
47
|
+
if (typeof input !== 'object' || Array.isArray(input)) {
|
|
48
|
+
issues.push(`Expected object, received ${zodType(input)}`);
|
|
49
|
+
}
|
|
50
|
+
else {
|
|
51
|
+
const record = input;
|
|
52
|
+
for (const [name, spec] of Object.entries(fields)) {
|
|
53
|
+
const value = record[name];
|
|
54
|
+
if (value === undefined) {
|
|
55
|
+
if (spec.required)
|
|
56
|
+
issues.push(`${name}: Required`);
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
const before = issues.length;
|
|
60
|
+
checkKind(value, spec, name, issues);
|
|
61
|
+
if (issues.length === before && spec.refine) {
|
|
62
|
+
const message = spec.refine(value);
|
|
63
|
+
if (message)
|
|
64
|
+
issues.push(`${name}: ${message}`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
if (issues.length === 0)
|
|
69
|
+
return undefined;
|
|
70
|
+
const error = `Invalid request body: ${issues.join('; ')}`;
|
|
71
|
+
return { status: 400, body: { error, statusCode: 400, code: 'FST_ERR_VALIDATION' } };
|
|
72
|
+
}
|
|
73
|
+
/** A `z.custom` objectGuard over the whole body (RH1's PATCH /tester/jobs/:jobId schema). */
|
|
74
|
+
export function validateObjectBody(body) {
|
|
75
|
+
const input = body === undefined || body === null ? {} : body;
|
|
76
|
+
if (typeof input === 'object' && !Array.isArray(input))
|
|
77
|
+
return undefined;
|
|
78
|
+
return { status: 400, body: { error: 'Invalid request body: Invalid input', statusCode: 400, code: 'FST_ERR_VALIDATION' } };
|
|
79
|
+
}
|
|
80
|
+
const S = { kind: 'string' };
|
|
81
|
+
const N = { kind: 'number' };
|
|
82
|
+
const B = { kind: 'boolean' };
|
|
83
|
+
const O = { kind: 'object' };
|
|
84
|
+
const PREF = { kind: 'enum', values: ['inherit', 'always', 'never'] };
|
|
85
|
+
// packages/shared/src/job/device-class.types.ts, tester/tester.types.ts (TESTER_DEVICES,
|
|
86
|
+
// TESTER_LANGUAGES, CLAIM_SOURCES).
|
|
87
|
+
export const DEVICE_CLASSES = ['desktop', 'mobile', 'both'];
|
|
88
|
+
export const TESTER_DEVICES = ['ios', 'android', 'pc', 'mac'];
|
|
89
|
+
export const TESTER_LANGUAGES = ['english', 'spanish'];
|
|
90
|
+
export const CLAIM_SOURCES = ['tester-portal', 'mobile-app', 'slack', 'discord-activity'];
|
|
91
|
+
/** createTestJobRequestSchema (POST /api/jobs). */
|
|
92
|
+
export const CREATE_JOB_FIELDS = {
|
|
93
|
+
projectId: S,
|
|
94
|
+
organizationId: S,
|
|
95
|
+
url: S,
|
|
96
|
+
description: S,
|
|
97
|
+
template: S,
|
|
98
|
+
templateContent: S,
|
|
99
|
+
targetDurationMinutes: N,
|
|
100
|
+
maxExtensionMinutes: N,
|
|
101
|
+
outputSchema: O,
|
|
102
|
+
resultsTemplate: S,
|
|
103
|
+
deviceClass: { kind: 'enum', values: DEVICE_CLASSES },
|
|
104
|
+
screenSize: { kind: 'unknown' },
|
|
105
|
+
attachments: { kind: 'array', of: O },
|
|
106
|
+
metadata: {
|
|
107
|
+
kind: 'object',
|
|
108
|
+
refine: (value) => {
|
|
109
|
+
const captureMode = value.captureMode;
|
|
110
|
+
return captureMode === undefined || captureMode === 'supplier' || captureMode === 'external'
|
|
111
|
+
? undefined
|
|
112
|
+
: 'metadata.captureMode must be supplier or external';
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
additionalValidationInstructions: S,
|
|
116
|
+
githubRepos: { kind: 'array', of: S },
|
|
117
|
+
githubRepo: S,
|
|
118
|
+
autoCreateGithubIssuesRepo: S,
|
|
119
|
+
githubToken: S,
|
|
120
|
+
commitSha: S,
|
|
121
|
+
enableCodeContext: B,
|
|
122
|
+
prNumbers: { kind: 'array', of: N },
|
|
123
|
+
issueNumbers: { kind: 'array', of: N },
|
|
124
|
+
checkTestability: B,
|
|
125
|
+
requiredDevices: { kind: 'array', of: { kind: 'enum', values: TESTER_DEVICES } },
|
|
126
|
+
requiredLanguages: { kind: 'array', of: { kind: 'enum', values: TESTER_LANGUAGES } },
|
|
127
|
+
requireSocialVideos: B,
|
|
128
|
+
requireSideload: B,
|
|
129
|
+
requiresRunhumanApkInstall: B,
|
|
130
|
+
autoCreateGithubIssues: B,
|
|
131
|
+
autoCreateGithubFeedback: B,
|
|
132
|
+
autoCreateJiraFeedback: B,
|
|
133
|
+
autoCreateJiraIssues: B,
|
|
134
|
+
autoCreateLinearIssues: B,
|
|
135
|
+
enhancedVideo: B,
|
|
136
|
+
enhanceInstructions: B,
|
|
137
|
+
autoCreateOnlyTesterSurfaced: B,
|
|
138
|
+
reopenIssuesOnDuplicate: B,
|
|
139
|
+
commentOnDuplicate: B,
|
|
140
|
+
slackNotification: O,
|
|
141
|
+
emailOnCompletion: PREF,
|
|
142
|
+
inAppOnCompletion: PREF,
|
|
143
|
+
emailOnFailure: PREF,
|
|
144
|
+
inAppOnFailure: PREF,
|
|
145
|
+
};
|
|
146
|
+
/** claimJobRequestSchema (POST /api/jobs/:jobId/claim): `claimSource` is REQUIRED by the schema
|
|
147
|
+
* even though the handler carries a default. */
|
|
148
|
+
export const CLAIM_JOB_FIELDS = {
|
|
149
|
+
claimSource: { kind: 'enum', values: CLAIM_SOURCES, required: true },
|
|
150
|
+
};
|
|
151
|
+
/** endJobRequestSchema (POST /api/tester/jobs/:jobId/end). */
|
|
152
|
+
export const END_JOB_FIELDS = {
|
|
153
|
+
action: { kind: 'enum', values: ['clear_and_release', 'clear_and_cancel', 'save_and_cancel', 'platform_issue'], required: true },
|
|
154
|
+
note: { kind: 'string', required: true },
|
|
155
|
+
};
|
|
156
|
+
/** processTestResultsBodySchema (POST /api/tester/jobs/:jobId/process-results). */
|
|
157
|
+
export const PROCESS_RESULTS_FIELDS = {
|
|
158
|
+
job: O,
|
|
159
|
+
prefillTemplate: S,
|
|
160
|
+
clientInfo: O,
|
|
161
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@volter/twin-runhuman",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Local Runhuman 1 (runhuman.com/api) twin: job create/read/list, the tester claim-to-completion lifecycle, RH1-exact refusals. Built on @volter/world-core.",
|
|
5
|
+
"author": "Volter (https://github.com/volter-ai)",
|
|
6
|
+
"license": "Apache-2.0",
|
|
7
|
+
"files": [
|
|
8
|
+
"src",
|
|
9
|
+
"README.md",
|
|
10
|
+
"LICENSE",
|
|
11
|
+
"!**/*.test.ts",
|
|
12
|
+
"!**/*.test.tsx",
|
|
13
|
+
"dist"
|
|
14
|
+
],
|
|
15
|
+
"repository": {
|
|
16
|
+
"type": "git",
|
|
17
|
+
"url": "git+https://github.com/volter-ai/twin.git",
|
|
18
|
+
"directory": "packages/twin/runhuman"
|
|
19
|
+
},
|
|
20
|
+
"homepage": "https://github.com/volter-ai/twin/tree/main/packages/twin/runhuman#readme",
|
|
21
|
+
"type": "module",
|
|
22
|
+
"exports": {
|
|
23
|
+
".": {
|
|
24
|
+
"types": "./dist/src/index.d.ts",
|
|
25
|
+
"default": "./dist/src/index.js"
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
"bin": {
|
|
29
|
+
"world-runhuman": "dist/src/cli.js"
|
|
30
|
+
},
|
|
31
|
+
"scripts": {
|
|
32
|
+
"test": "bun test src/*.test.ts",
|
|
33
|
+
"typecheck": "tsc --noEmit",
|
|
34
|
+
"build": "node ../../../scripts/publish/build.mjs",
|
|
35
|
+
"prepack": "node ../../../scripts/publish/prepare-publish.mjs prepack",
|
|
36
|
+
"postpack": "node ../../../scripts/publish/prepare-publish.mjs postpack"
|
|
37
|
+
},
|
|
38
|
+
"peerDependencies": {
|
|
39
|
+
"@volter/world-core": "2.0.0"
|
|
40
|
+
},
|
|
41
|
+
"devDependencies": {
|
|
42
|
+
"@types/bun": "^1.2.20",
|
|
43
|
+
"@types/node": "^24.0.0",
|
|
44
|
+
"@volter/world-core": "2.0.0",
|
|
45
|
+
"@volter/world-tooling": "0.1.0",
|
|
46
|
+
"typescript": "^5.9.0"
|
|
47
|
+
},
|
|
48
|
+
"engines": {
|
|
49
|
+
"node": ">=22.3"
|
|
50
|
+
}
|
|
51
|
+
}
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { keepProcessAlive } from '@volter/world-core/lifecycle';
|
|
3
|
+
// world-runhuman CLI: serve the KERNEL-BACKED Runhuman 1 API twin, or run conformance. State lives
|
|
4
|
+
// in the @volter/world-core action log under --root. Conformance is dev-only + lazy-imported.
|
|
5
|
+
import { hasFlag, optionValue } from '@volter/world-core/args';
|
|
6
|
+
import { createRunhumanTwinServer } from './runhuman-server.ts';
|
|
7
|
+
|
|
8
|
+
const [cmd, ...rest] = process.argv.slice(2);
|
|
9
|
+
const port = Number(optionValue(rest, '--port', String(process.env.PORT ?? '0'))) || undefined;
|
|
10
|
+
const root = optionValue(rest, '--root') || process.env.VOLTER_STATE_DIR || undefined;
|
|
11
|
+
const readOnly = hasFlag(rest, '--read-only');
|
|
12
|
+
|
|
13
|
+
if (cmd === 'serve' || cmd === undefined) {
|
|
14
|
+
const s = await createRunhumanTwinServer({ readOnly, ...(root ? { root } : {}), ...(port ? { port } : {}) });
|
|
15
|
+
process.stdout.write(`runhuman twin (Runhuman 1 jobs API + tester lifecycle)${readOnly ? ' [read-only]' : ''} at http://127.0.0.1:${s.port}\n`);
|
|
16
|
+
await keepProcessAlive();
|
|
17
|
+
} else if (cmd === 'conformance') {
|
|
18
|
+
const { checkRunhumanConformance } = await import('./runhuman-conformance.ts');
|
|
19
|
+
const report = await checkRunhumanConformance(root ? { root } : {});
|
|
20
|
+
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
|
|
21
|
+
if (!report.ok) process.exitCode = 1;
|
|
22
|
+
} else {
|
|
23
|
+
process.stdout.write('Usage: world-runhuman serve|conformance [--port N] [--root DIR] [--read-only]\n');
|
|
24
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { TwinPack } from '@volter/world-core';
|
|
2
|
+
export { handleRunhumanTwinRequest, RUNHUMAN_RESOURCE_TYPES } from './runhuman-twin.ts';
|
|
3
|
+
export type { RunhumanTwinRequest, RunhumanTwinResponse } from './runhuman-twin.ts';
|
|
4
|
+
export { createRunhumanTwinFetch, createRunhumanTwinServer } from './runhuman-server.ts';
|
|
5
|
+
export { liveRunhumanExecute, syncRunhumanFromReal, RUNHUMAN_API_BASE } from './runhuman-connector.ts';
|
|
6
|
+
export {
|
|
7
|
+
RUNHUMAN_BUDGET_CEILING,
|
|
8
|
+
RUNHUMAN_BUDGET_MAX_RETRY_AFTER_S,
|
|
9
|
+
RUNHUMAN_BUDGET_WINDOW_MS,
|
|
10
|
+
RUNHUMAN_CALL_WEIGHTS,
|
|
11
|
+
RUNHUMAN_RATE_BUDGET,
|
|
12
|
+
RunhumanBudget,
|
|
13
|
+
RunhumanBudgetError,
|
|
14
|
+
runhumanBudgetPath,
|
|
15
|
+
runhumanCallWeight,
|
|
16
|
+
} from './runhuman-budget.ts';
|
|
17
|
+
export type { RunhumanBudgetErrorKind, RunhumanBudgetOptions, RunhumanBudgetReservation, RunhumanBudgetSnapshot } from './runhuman-budget.ts';
|
|
18
|
+
import { RUNHUMAN_RATE_BUDGET as RATE_BUDGET } from './runhuman-budget.ts';
|
|
19
|
+
|
|
20
|
+
// Registry descriptor (TwinPack) — the pack self-describes so tooling can discover it, and it is
|
|
21
|
+
// the SINGLE HOME for this vendor's world-facing facts. `bun scripts/pack-facts.ts` compiles
|
|
22
|
+
// `adoption` / `hosts` / `endpointEnv` into packages/world-core/generated/pack-facts.json;
|
|
23
|
+
// re-run it after ANY edit below or the drift gate (scripts/pack-facts.test.ts) goes RED.
|
|
24
|
+
export const pack: TwinPack = {
|
|
25
|
+
vendor: 'runhuman',
|
|
26
|
+
transport: 'rest',
|
|
27
|
+
protocol: '2', // the platform protocol major this package targets (runtime contract R16)
|
|
28
|
+
// The branch round trip: an organization, its project and an API key seeded (RH1 has no public API that makes them;
|
|
29
|
+
// the seeds refuse an id already held, so the branch's re-seed is refused and changes nothing), then a job created
|
|
30
|
+
// through RH1's own API, the write a branch repeats (each create is a new job).
|
|
31
|
+
roundTrip: [
|
|
32
|
+
{ method: 'POST', path: '/_twin/organizations', body: { id: 'roundtrip-org', name: 'Round Trip' } },
|
|
33
|
+
{ method: 'POST', path: '/_twin/projects', body: { id: 'roundtrip-proj', organizationId: 'roundtrip-org', name: 'Round Trip' } },
|
|
34
|
+
{ method: 'POST', path: '/_twin/api-keys', body: { id: 'roundtrip-key', organizationId: 'roundtrip-org', key: 'rh_roundtripkey' } },
|
|
35
|
+
{ method: 'POST', path: '/api/jobs', body: { projectId: 'roundtrip-proj', url: 'https://round.trip.test/', description: 'round trip' }, headers: { authorization: 'Bearer rh_roundtripkey' } },
|
|
36
|
+
],
|
|
37
|
+
archetype: 'crud',
|
|
38
|
+
bin: 'world-runhuman',
|
|
39
|
+
rateBudget: RATE_BUDGET,
|
|
40
|
+
resources: ['organization', 'project', 'api_key', 'user', 'tester_profile', 'job', 'pipeline_run'],
|
|
41
|
+
specSource: 'RH1 source at volter-ai/runhuman origin/main 2f2fcb2c5 — packages/api/src/routes/jobs/** (create/get/claim/tester handlers), routes/projects.routes.ts, middleware/{auth,dual-auth,clerk-auth}.ts, packages/shared/src/contracts/{job,tester-job}.schema.ts; the vendor is our own product, so its source is the specification (no published OpenAPI).',
|
|
42
|
+
description: 'Runhuman 1 (runhuman.com/api) jobs twin — POST /api/jobs, GET /api/jobs/:id(/status), GET /api/projects/:id/jobs, the tester lifecycle (claim → testerToken PATCH → process-results → issue-review → complete, or end), RH1-exact refusals; the AI pipeline is folded into the status poll, timers and integrations are unmodelled (JSON 404 with x-twin-gap). Kernel-backed, no mirror.',
|
|
43
|
+
|
|
44
|
+
// ADOPTION — the RH1 CLI (npm `runhuman`, packages/cli) is the first-party client of this surface.
|
|
45
|
+
// No envStem and no scope: RH2 ships under the same brand (`RUNHUMAN`, `@runhuman/`), so a
|
|
46
|
+
// stem here would attribute RH2's code to RH1.
|
|
47
|
+
adoption: {
|
|
48
|
+
sdks: ['runhuman'],
|
|
49
|
+
},
|
|
50
|
+
|
|
51
|
+
// INTERCEPTION — RH1 serves its API under runhuman.com/api (the CLI's default apiUrl is
|
|
52
|
+
// https://runhuman.com; packages/cli/src/commands/auth/login.ts). Only /api/* is this twin's.
|
|
53
|
+
hosts: [{ host: 'runhuman.com', pathPattern: '^/api/' }],
|
|
54
|
+
|
|
55
|
+
// WORLD WIRING — the RH1 CLI reads RUNHUMAN_API_URL as its base URL
|
|
56
|
+
// (packages/cli/src/lib/config.ts: `if (process.env.RUNHUMAN_API_URL) config.apiUrl = …`).
|
|
57
|
+
endpointEnv: {
|
|
58
|
+
name: 'RUNHUMAN_API_URL',
|
|
59
|
+
note: 'grounded in volter-ai/runhuman packages/cli/src/lib/config.ts, which overrides the CLI apiUrl from RUNHUMAN_API_URL; RH2 reads its human-marketplace base URL from the provider row spec.url instead, which a rehearsal World sets to this same twin URL.',
|
|
60
|
+
},
|
|
61
|
+
};
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// Runhuman client-side rate budget. The mechanism is the kernel's shared RateBudget; only
|
|
2
|
+
// Runhuman's declaration lives here.
|
|
3
|
+
//
|
|
4
|
+
// Vendor source (Runhuman is our own product; read-only checkout of volter-ai/runhuman):
|
|
5
|
+
// packages/static-site/src/content/docs/api.mdx "Rate Limiting" and docs/rate-limiting.md. The API
|
|
6
|
+
// enforces two PER-IP tiers: 200 requests/minute per route pattern and 500 requests/minute across
|
|
7
|
+
// all routes, answering 429 with Retry-After past either. Both are per-IP, not per-API-key, so
|
|
8
|
+
// callers behind one egress IP share them — a per-key ledger cannot see that sharing. This
|
|
9
|
+
// declaration therefore stays AT the kernel fallback, far under both published tiers: 60 weighted
|
|
10
|
+
// units per minute; reads cost 2 (30/min), writes 3 (20/min), and job creation 5 (12/min) because
|
|
11
|
+
// every POST /api/jobs dispatches a PAID human tester.
|
|
12
|
+
import {
|
|
13
|
+
declareRateBudget,
|
|
14
|
+
rateBudgetPath,
|
|
15
|
+
rateBudgetWeight,
|
|
16
|
+
RateBudget,
|
|
17
|
+
type RateBudgetDeclaration,
|
|
18
|
+
type RateBudgetOptions,
|
|
19
|
+
type RateBudgetReservation,
|
|
20
|
+
type RateBudgetSnapshot,
|
|
21
|
+
} from '@volter/world-core';
|
|
22
|
+
|
|
23
|
+
const VENDOR = 'runhuman';
|
|
24
|
+
export const RUNHUMAN_BUDGET_WINDOW_MS = 60_000;
|
|
25
|
+
export const RUNHUMAN_BUDGET_CEILING = 60;
|
|
26
|
+
export const RUNHUMAN_BUDGET_MAX_RETRY_AFTER_S = 300;
|
|
27
|
+
export const RUNHUMAN_CALL_WEIGHTS = { createJob: 5, mutation: 3, read: 2 } as const;
|
|
28
|
+
|
|
29
|
+
export const RUNHUMAN_RATE_BUDGET: RateBudgetDeclaration = {
|
|
30
|
+
windowMs: RUNHUMAN_BUDGET_WINDOW_MS,
|
|
31
|
+
ceiling: RUNHUMAN_BUDGET_CEILING,
|
|
32
|
+
defaultWeight: RUNHUMAN_CALL_WEIGHTS.read,
|
|
33
|
+
maxRetryAfterSeconds: RUNHUMAN_BUDGET_MAX_RETRY_AFTER_S,
|
|
34
|
+
rules: [
|
|
35
|
+
{ match: '^POST /api/jobs$', weight: RUNHUMAN_CALL_WEIGHTS.createJob },
|
|
36
|
+
{ match: '^(POST|PUT|PATCH|DELETE) ', weight: RUNHUMAN_CALL_WEIGHTS.mutation },
|
|
37
|
+
],
|
|
38
|
+
reason:
|
|
39
|
+
'Runhuman documents two per-IP limits (volter-ai/runhuman api.mdx "Rate Limiting", docs/rate-limiting.md): ' +
|
|
40
|
+
'200 requests/minute per route and 500 requests/minute aggregate, 429 + Retry-After past them. Per-IP, not ' +
|
|
41
|
+
'per-key, so a per-key ledger stays at the kernel fallback, well under both: 60 weighted units per minute; ' +
|
|
42
|
+
'reads 2, writes 3, job creation 5 (each dispatches a paid human tester).',
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
declareRateBudget(VENDOR, RUNHUMAN_RATE_BUDGET);
|
|
46
|
+
|
|
47
|
+
export function runhumanCallWeight(method: string, path: string): number {
|
|
48
|
+
return rateBudgetWeight(VENDOR, `${method.toUpperCase()} ${path.split('?')[0] ?? path}`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function runhumanBudgetPath(opts: { root?: string; token?: string } | string = {}): string {
|
|
52
|
+
const value = typeof opts === 'string' ? { root: opts } : opts;
|
|
53
|
+
return rateBudgetPath({ ...value, vendor: VENDOR });
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export type RunhumanBudgetOptions = Omit<RateBudgetOptions, 'vendor'>;
|
|
57
|
+
export class RunhumanBudget extends RateBudget {
|
|
58
|
+
constructor(opts: RunhumanBudgetOptions = {}) {
|
|
59
|
+
super({ ...opts, vendor: VENDOR });
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export type { RateBudgetErrorKind as RunhumanBudgetErrorKind } from '@volter/world-core';
|
|
64
|
+
export { RateBudgetError as RunhumanBudgetError } from '@volter/world-core';
|
|
65
|
+
export type RunhumanBudgetReservation = RateBudgetReservation;
|
|
66
|
+
export type RunhumanBudgetSnapshot = RateBudgetSnapshot;
|