@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,1078 @@
|
|
|
1
|
+
// Runhuman 1 (RH1) API twin — REQUEST HANDLER. Contract:
|
|
2
|
+
// handleRunhumanTwinRequest({ method, path, body, headers, occurredAt, root, readOnly }) -> { status, body }
|
|
3
|
+
//
|
|
4
|
+
// SOURCE OF TRUTH: the vendor is our own product, so RH1's source IS the specification —
|
|
5
|
+
// volter-ai/runhuman at origin/main, read for this build at 2f2fcb2c5. Every route, status, error
|
|
6
|
+
// string and field below is transcribed from these files (paths relative to that repo):
|
|
7
|
+
// packages/api/src/routes/jobs/jobs.routes.ts route table + preHandlers
|
|
8
|
+
// packages/api/src/routes/jobs/handlers/create-job.handler.ts POST /api/jobs
|
|
9
|
+
// ../utils/{resolve-job-project,validate-job-fields,create-and-dispatch-job}.ts
|
|
10
|
+
// packages/api/src/services/job/job-creation.ts the job row, admission cap
|
|
11
|
+
// packages/api/src/routes/jobs/handlers/get-job.handler.ts GET /api/jobs/:jobId(/status)
|
|
12
|
+
// ../enrich-job.ts, services/result-gating.service.ts the enriched + gated body
|
|
13
|
+
// packages/api/src/routes/projects.routes.ts GET /api/projects/:projectId/jobs
|
|
14
|
+
// packages/api/src/routes/jobs/handlers/claim-job.handler.ts POST /api/jobs/:jobId/claim
|
|
15
|
+
// services/job-claim.service.ts the claim gate + update
|
|
16
|
+
// packages/api/src/routes/tester-auth-helpers.ts testerToken auth
|
|
17
|
+
// .../handlers/{get-tester-job,update-tester-job,process-test-results,get-processing-status,
|
|
18
|
+
// issue-review,end-job}.handler.ts the tester's side
|
|
19
|
+
// services/job/{job-completion,job-status}.service.ts, utils/pricing.ts completion + cost
|
|
20
|
+
// middleware/{auth,dual-auth,clerk-auth,project-authorization}.ts auth refusals
|
|
21
|
+
// utils/error-handler.ts, utils/zod-validator-compiler.ts error envelopes
|
|
22
|
+
// routes/route-static-routing.ts JSON 404 for /api/*
|
|
23
|
+
//
|
|
24
|
+
// AUTH. API keys are `rh_…` bearer tokens bound to an organization (RH1 api_keys). Web users —
|
|
25
|
+
// the TESTER side — authenticate the way RH1's own Clerk MOCK MODE does
|
|
26
|
+
// (middleware/clerk-auth.ts `isClerkMockMode()`): `Authorization: Bearer clerk_<email>` names
|
|
27
|
+
// the user whose clerkId is `clerk_<email>`. The tester's per-job credential is the HS256
|
|
28
|
+
// testerToken the claim mints (utils/jwt.util.ts, 24h), signed with a per-world secret generated
|
|
29
|
+
// once from entropy and persisted as state (or seeded through `/_twin/settings`).
|
|
30
|
+
//
|
|
31
|
+
// WORLD SEEDING. RH1 creates organizations, projects, API keys and tester profiles through its
|
|
32
|
+
// dashboard, Clerk webhooks and admin approval — none of which a job caller touches. The twin
|
|
33
|
+
// seeds them through twin-only routes under `/_twin/` that a real client never calls
|
|
34
|
+
// (ADDING_A_TWIN §6 seed-route rule): organizations, projects, api-keys, testers, and the
|
|
35
|
+
// deployment's tester-token signing secret (settings).
|
|
36
|
+
//
|
|
37
|
+
// WHAT RUNS ELSEWHERE IN RH1 (see README ## Coverage):
|
|
38
|
+
// - the release throttle (pending → queued → waiting) and every time-driven monitor (claim
|
|
39
|
+
// window expiry, no-response timeout, overdue) run as workers on timers; the twin runs none,
|
|
40
|
+
// so a job stays `pending` until claimed;
|
|
41
|
+
// - the AI results pipeline (BullMQ `pipeline` queue) is FOLDED INTO THE STATUS POLL: the first
|
|
42
|
+
// read of a run's processing status completes it. Extract carries the tester's submitted
|
|
43
|
+
// `result` through and extracts no issues; finalize records the verdict from
|
|
44
|
+
// `result.data.success` (boolean → pass/fail) or `not_applicable`. No model runs.
|
|
45
|
+
// - instruction validation (an LLM call) and URL safety checks accept every input.
|
|
46
|
+
// A request that needs an unmodelled branch (templates, PR/issue test plans, KB enhancement,
|
|
47
|
+
// GitHub repos, output schemas, attachments, sideload/social-video tiers, integrations) answers
|
|
48
|
+
// RH1's JSON 404 `{ error: 'Not found' }` with an `x-twin-gap` header naming the branch — never a
|
|
49
|
+
// fabricated success.
|
|
50
|
+
import { createHash, createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
51
|
+
import { applyTwinWrite, applyTwinWriteAtomic, projectResources, type TwinResource } from '@volter/world-core';
|
|
52
|
+
import {
|
|
53
|
+
CLAIM_JOB_FIELDS,
|
|
54
|
+
CREATE_JOB_FIELDS,
|
|
55
|
+
END_JOB_FIELDS,
|
|
56
|
+
PROCESS_RESULTS_FIELDS,
|
|
57
|
+
validateBody,
|
|
58
|
+
validateObjectBody,
|
|
59
|
+
} from './runhuman-validate.ts';
|
|
60
|
+
|
|
61
|
+
const SERVICE = 'runhuman';
|
|
62
|
+
|
|
63
|
+
export type RunhumanTwinRequest = {
|
|
64
|
+
method: string;
|
|
65
|
+
path: string;
|
|
66
|
+
body?: string;
|
|
67
|
+
headers?: Record<string, string>;
|
|
68
|
+
occurredAt?: string;
|
|
69
|
+
root?: string;
|
|
70
|
+
readOnly?: boolean;
|
|
71
|
+
};
|
|
72
|
+
export type RunhumanTwinResponse = { status: number; body: unknown; headers?: Record<string, string> };
|
|
73
|
+
|
|
74
|
+
export const RUNHUMAN_RESOURCE_TYPES = ['organization', 'project', 'api_key', 'user', 'tester_profile', 'job', 'pipeline_run', 'setting'] as const;
|
|
75
|
+
|
|
76
|
+
type Json = Record<string, unknown>;
|
|
77
|
+
|
|
78
|
+
// ── RH1 constants (packages/shared/src/config/timing.ts, api/src/config/constants.ts,
|
|
79
|
+
// api/src/utils/pricing.ts, shared/src/job/job-priority.ts) ─────────────────────────────────
|
|
80
|
+
const DEFAULT_TARGET_DURATION_MINUTES = 15;
|
|
81
|
+
const DEFAULT_MAX_EXTENSION_MINUTES = 5;
|
|
82
|
+
const SYSTEM_MAX_SESSION_MINUTES = 120;
|
|
83
|
+
const MAX_ACTIVE_JOBS_PER_ORG = 12;
|
|
84
|
+
const USD_PER_SECOND = 0.0085;
|
|
85
|
+
const FREE_TIER_CLAIM_WINDOW_MINUTES = 360; // CLAIM_WINDOW_MINUTES_BY_TIER.free
|
|
86
|
+
const TESTER_JWT_TTL_SECONDS = 24 * 60 * 60;
|
|
87
|
+
const ISSUE_REVIEW_RERUN_SOFT_CAP = 5; // shared/src/contracts/tester-job.contract.ts
|
|
88
|
+
const ACTIVE_STATUSES = new Set(['pending', 'preparing', 'queued', 'waiting', 'working', 'overdue', 'admin_hold']);
|
|
89
|
+
const TERMINAL_STATUSES = new Set(['completed', 'cancelled', 'expired', 'failed']);
|
|
90
|
+
|
|
91
|
+
// ── envelopes ────────────────────────────────────────────────────────────────────────────────
|
|
92
|
+
const json = (status: number, body: unknown): RunhumanTwinResponse => ({ status, body });
|
|
93
|
+
const notFoundRoute = (): RunhumanTwinResponse => json(404, { error: 'Not found' });
|
|
94
|
+
function gap(branch: string): RunhumanTwinResponse {
|
|
95
|
+
return { status: 404, body: { error: 'Not found' }, headers: { 'x-twin-gap': `runhuman twin does not model ${branch}` } };
|
|
96
|
+
}
|
|
97
|
+
function readOnlyRejection(): RunhumanTwinResponse {
|
|
98
|
+
return json(405, { error: 'Method Not Allowed', message: 'twin is read-only; omit --read-only to accept writes' });
|
|
99
|
+
}
|
|
100
|
+
/** RH1's global error handler shape for a thrown AppError: `{ error, statusCode, code }`. */
|
|
101
|
+
const appError = (status: number, code: string, error: string, details?: Json): RunhumanTwinResponse =>
|
|
102
|
+
json(status, { error, statusCode: status, code, ...(details ? { details } : {}) });
|
|
103
|
+
|
|
104
|
+
type Parsed = { ok: true; value: unknown } | { ok: false; response: RunhumanTwinResponse };
|
|
105
|
+
function parseJson(body?: string): Parsed {
|
|
106
|
+
if (body === undefined || body.trim() === '') return { ok: true, value: undefined };
|
|
107
|
+
try {
|
|
108
|
+
return { ok: true, value: JSON.parse(body) };
|
|
109
|
+
} catch {
|
|
110
|
+
// Fastify's content-type parser error, serialised by RH1's global error handler.
|
|
111
|
+
return { ok: false, response: appError(400, 'FST_ERR_CTP_INVALID_JSON_BODY', "Body is not valid JSON but content-type is set to 'application/json'") };
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ── the tree ─────────────────────────────────────────────────────────────────────────────────
|
|
116
|
+
// Every subject stores its vendor document whole under `doc` (subject id `<type>:<id>`), so a
|
|
117
|
+
// write replaces the document and a cleared column is simply absent.
|
|
118
|
+
function docsOf(type: string, root?: string, all?: readonly TwinResource[]): Json[] {
|
|
119
|
+
return (all ?? projectResources(SERVICE, root))
|
|
120
|
+
.filter((r) => r.type === type && typeof r.doc === 'object' && r.doc !== null)
|
|
121
|
+
.map((r) => r.doc as Json);
|
|
122
|
+
}
|
|
123
|
+
function docOf(type: string, id: string, root?: string): Json | undefined {
|
|
124
|
+
const row = projectResources(SERVICE, root).find((r) => r.type === type && r.id === `${type}:${id}`);
|
|
125
|
+
return row && typeof row.doc === 'object' && row.doc !== null ? (row.doc as Json) : undefined;
|
|
126
|
+
}
|
|
127
|
+
async function put(type: string, id: string, doc: Json, operation: string, req: RunhumanTwinRequest): Promise<void> {
|
|
128
|
+
await applyTwinWrite(SERVICE, {
|
|
129
|
+
operation,
|
|
130
|
+
subjectType: type,
|
|
131
|
+
subjectId: `${type}:${id}`,
|
|
132
|
+
fields: { doc: stripUndefined(doc) },
|
|
133
|
+
...(req.occurredAt ? { occurredAt: req.occurredAt } : {}),
|
|
134
|
+
actor: { kind: 'system' },
|
|
135
|
+
}, req.root);
|
|
136
|
+
}
|
|
137
|
+
function stripUndefined(doc: Json): Json {
|
|
138
|
+
return JSON.parse(JSON.stringify(doc)) as Json;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** A deterministic RFC-4122-shaped id from a state-derived seed (RH1 mints `randomUUID()`; the
|
|
142
|
+
* twin derives it from the world instant + the type's all-time ordinal so replays agree). */
|
|
143
|
+
function mintUuid(seed: string): string {
|
|
144
|
+
const h = createHash('sha256').update(`runhuman-twin:${seed}`).digest('hex');
|
|
145
|
+
const variant = ((parseInt(h[16]!, 16) & 0x3) | 0x8).toString(16);
|
|
146
|
+
return `${h.slice(0, 8)}-${h.slice(8, 12)}-4${h.slice(13, 16)}-${variant}${h.slice(17, 20)}-${h.slice(20, 32)}`;
|
|
147
|
+
}
|
|
148
|
+
function mintFor(type: string, req: RunhumanTwinRequest, resources: readonly TwinResource[]): string {
|
|
149
|
+
const taken = new Set(resources.filter((r) => r.type === type).map((r) => r.id));
|
|
150
|
+
let ordinal = taken.size;
|
|
151
|
+
for (;;) {
|
|
152
|
+
const id = mintUuid(`${type}:${now(req)}:${ordinal}`);
|
|
153
|
+
if (!taken.has(`${type}:${id}`)) return id;
|
|
154
|
+
ordinal += 1;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
const now = (req: RunhumanTwinRequest): string => req.occurredAt ?? new Date().toISOString();
|
|
158
|
+
|
|
159
|
+
// ── tester JWT (utils/jwt.util.ts: HS256, { sub, email }, expiresIn 24h) ─────────────────────
|
|
160
|
+
const b64url = (input: Buffer | string): string => Buffer.from(input).toString('base64url');
|
|
161
|
+
async function jwtSecret(root?: string): Promise<string> {
|
|
162
|
+
const stored = docOf('setting', 'jwt_secret', root)?.secret;
|
|
163
|
+
if (typeof stored === 'string') return stored;
|
|
164
|
+
const { value } = await applyTwinWriteAtomic<string>(SERVICE, (resources) => {
|
|
165
|
+
const existing = resources.find((r) => r.type === 'setting' && r.id === 'setting:jwt_secret')?.doc as Json | undefined;
|
|
166
|
+
if (typeof existing?.secret === 'string') return { kind: 'skip', value: existing.secret };
|
|
167
|
+
const secret = randomBytes(32).toString('hex');
|
|
168
|
+
return { kind: 'write', value: secret, write: { operation: 'setting.jwt_secret', subjectType: 'setting', subjectId: 'setting:jwt_secret', fields: { doc: { secret } }, actor: { kind: 'system' } } };
|
|
169
|
+
}, root);
|
|
170
|
+
return value;
|
|
171
|
+
}
|
|
172
|
+
function signTesterJwt(secret: string, sub: string, email: string | undefined, iat: number): string {
|
|
173
|
+
const header = b64url(JSON.stringify({ alg: 'HS256', typ: 'JWT' }));
|
|
174
|
+
const payload = b64url(JSON.stringify({ sub, ...(email ? { email } : {}), iat, exp: iat + TESTER_JWT_TTL_SECONDS }));
|
|
175
|
+
const signature = createHmac('sha256', secret).update(`${header}.${payload}`).digest('base64url');
|
|
176
|
+
return `${header}.${payload}.${signature}`;
|
|
177
|
+
}
|
|
178
|
+
type TesterTokenFailure = 'expired' | 'invalid_signature' | 'malformed' | 'not_yet_valid';
|
|
179
|
+
function verifyTesterJwt(secret: string, token: string, nowSeconds: number): { ok: true; sub: string } | { ok: false; reason: TesterTokenFailure } {
|
|
180
|
+
const parts = token.split('.');
|
|
181
|
+
if (parts.length !== 3) return { ok: false, reason: 'malformed' };
|
|
182
|
+
let payload: Json;
|
|
183
|
+
try {
|
|
184
|
+
payload = JSON.parse(Buffer.from(parts[1]!, 'base64url').toString('utf8')) as Json;
|
|
185
|
+
JSON.parse(Buffer.from(parts[0]!, 'base64url').toString('utf8'));
|
|
186
|
+
} catch {
|
|
187
|
+
return { ok: false, reason: 'malformed' };
|
|
188
|
+
}
|
|
189
|
+
const expected = createHmac('sha256', secret).update(`${parts[0]}.${parts[1]}`).digest();
|
|
190
|
+
const given = Buffer.from(parts[2]!, 'base64url');
|
|
191
|
+
if (given.length !== expected.length || !timingSafeEqual(given, expected)) return { ok: false, reason: 'invalid_signature' };
|
|
192
|
+
if (typeof payload.exp === 'number' && payload.exp <= nowSeconds) return { ok: false, reason: 'expired' };
|
|
193
|
+
if (typeof payload.nbf === 'number' && payload.nbf > nowSeconds) return { ok: false, reason: 'not_yet_valid' };
|
|
194
|
+
return { ok: true, sub: String(payload.sub ?? '') };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// ── authentication (middleware/auth.ts, dual-auth.ts, clerk-auth.ts mock mode) ───────────────
|
|
198
|
+
type Principal = { kind: 'apiKey'; apiKey: Json } | { kind: 'user'; user: Json };
|
|
199
|
+
type AuthResult = { ok: true; principal: Principal } | { ok: false; response: RunhumanTwinResponse };
|
|
200
|
+
|
|
201
|
+
const header = (req: RunhumanTwinRequest, name: string): string | undefined => req.headers?.[name] ?? req.headers?.[name.toLowerCase()];
|
|
202
|
+
const isPatToken = (t: string) => t.startsWith('rh_pat_');
|
|
203
|
+
const isApiKeyToken = (t: string) => !isPatToken(t) && !t.startsWith('rh_svc_') && (t.startsWith('rh_') || t.startsWith('qa_live_'));
|
|
204
|
+
const unauthorized = (message: string): RunhumanTwinResponse => json(401, { error: 'Unauthorized', message });
|
|
205
|
+
|
|
206
|
+
function authenticateApiKey(req: RunhumanTwinRequest): AuthResult {
|
|
207
|
+
const authHeader = header(req, 'authorization');
|
|
208
|
+
if (!authHeader) return { ok: false, response: unauthorized('Missing Authorization header. Include your API key as: Authorization: Bearer <your-key>') };
|
|
209
|
+
const parts = authHeader.split(' ');
|
|
210
|
+
if (parts.length !== 2 || parts[0] !== 'Bearer') return { ok: false, response: unauthorized('Invalid Authorization header format. Use: Authorization: Bearer <your-key>') };
|
|
211
|
+
const key = parts[1]!;
|
|
212
|
+
if (!isApiKeyToken(key)) return { ok: false, response: unauthorized('Invalid API key format') };
|
|
213
|
+
const apiKey = docsOf('api_key', req.root).find((k) => k.key === key && k.isActive !== false);
|
|
214
|
+
if (!apiKey) return { ok: false, response: unauthorized('Invalid or inactive API key') };
|
|
215
|
+
return { ok: true, principal: { kind: 'apiKey', apiKey } };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** RH1's Clerk MOCK MODE web auth (clerk-auth.ts `authenticateWebUser`, isClerkMockMode branch). */
|
|
219
|
+
function authenticateWebUser(req: RunhumanTwinRequest): AuthResult {
|
|
220
|
+
const authHeader = header(req, 'authorization');
|
|
221
|
+
if (!authHeader) return { ok: false, response: unauthorized('Missing Authorization header') };
|
|
222
|
+
if (!authHeader.startsWith('Bearer ')) return { ok: false, response: unauthorized('Invalid Authorization header format. Expected: Bearer <token>') };
|
|
223
|
+
if (authHeader.startsWith('Bearer clerk_')) {
|
|
224
|
+
const clerkId = `clerk_${authHeader.slice('Bearer clerk_'.length)}`;
|
|
225
|
+
const user = docsOf('user', req.root).find((u) => u.clerkId === clerkId);
|
|
226
|
+
if (user) return { ok: true, principal: { kind: 'user', user } };
|
|
227
|
+
return { ok: false, response: unauthorized('User not found. Create user with createMockUser() first.') };
|
|
228
|
+
}
|
|
229
|
+
return { ok: false, response: unauthorized('Invalid token format') };
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** dual-auth.ts: an `rh_` token goes through API-key auth (whose 401 is the answer the client
|
|
233
|
+
* sees); a PAT the twin never issued falls through to web auth. */
|
|
234
|
+
function dualAuthentication(req: RunhumanTwinRequest): AuthResult {
|
|
235
|
+
const token = header(req, 'authorization')?.split(' ')[1];
|
|
236
|
+
if (token && isApiKeyToken(token)) return authenticateApiKey(req);
|
|
237
|
+
return authenticateWebUser(req);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function isOrgMember(user: Json, organizationId: unknown): boolean {
|
|
241
|
+
return Array.isArray(user.organizationIds) && user.organizationIds.includes(organizationId);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// ── job views ────────────────────────────────────────────────────────────────────────────────
|
|
245
|
+
function origin(req: RunhumanTwinRequest): string {
|
|
246
|
+
const host = header(req, 'host');
|
|
247
|
+
if (!host) return 'https://runhuman.com';
|
|
248
|
+
const proto = header(req, 'x-forwarded-proto') ?? (host.startsWith('127.0.0.1') || host.startsWith('localhost') ? 'http' : 'https');
|
|
249
|
+
return `${proto}://${host}`;
|
|
250
|
+
}
|
|
251
|
+
/** routes/jobs/utils/build-job-url.ts */
|
|
252
|
+
function buildJobUrl(req: RunhumanTwinRequest, job: Json): string {
|
|
253
|
+
const base = origin(req);
|
|
254
|
+
const metadata = (job.metadata ?? {}) as Json;
|
|
255
|
+
if (metadata.publicToken && !metadata.publicTokenDisabled) return `${base}/j/${job.id}/${metadata.publicToken}`;
|
|
256
|
+
if (job.projectId) return `${base}/dashboard/${job.projectId}/jobs/${job.id}`;
|
|
257
|
+
return `${base}/dashboard/jobs/${job.id}`;
|
|
258
|
+
}
|
|
259
|
+
function hasSubscription(org: Json | undefined): boolean {
|
|
260
|
+
return org?.hasActiveSubscription === true;
|
|
261
|
+
}
|
|
262
|
+
/** result-gating.service.ts `sanitizeJobForResponse`: always strip metadata.testerToken; without an
|
|
263
|
+
* active subscription also strip the rich fields and report the owning org. */
|
|
264
|
+
function gateJob(job: Json, org: Json | undefined, viewerUserId: string | undefined): { job: Json; richResultsGated: boolean; ownerOrganizationName?: string; viewerIsOrganizationOwner?: boolean } {
|
|
265
|
+
const metadata = job.metadata ? { ...(job.metadata as Json) } : undefined;
|
|
266
|
+
if (metadata) delete metadata.testerToken;
|
|
267
|
+
const customerJob: Json = { ...job, ...(metadata ? { metadata } : {}) };
|
|
268
|
+
if (hasSubscription(org)) return { job: customerJob, richResultsGated: false };
|
|
269
|
+
const gated: Json = { ...customerJob, keyMoments: [] };
|
|
270
|
+
delete gated.transcription;
|
|
271
|
+
delete gated.conversationHistory;
|
|
272
|
+
if (gated.sessionEvents) gated.sessionEvents = { ...(gated.sessionEvents as Json), screenshots: [], consoleMessages: [], networkRequests: [] };
|
|
273
|
+
if (!org) return { job: gated, richResultsGated: true, viewerIsOrganizationOwner: false };
|
|
274
|
+
return { job: gated, richResultsGated: true, ownerOrganizationName: String(org.name), viewerIsOrganizationOwner: viewerUserId !== undefined && viewerUserId === org.ownerUserId };
|
|
275
|
+
}
|
|
276
|
+
function testerResponseOf(history: unknown): string | undefined {
|
|
277
|
+
if (!Array.isArray(history)) return undefined;
|
|
278
|
+
const lines = history.filter((m) => (m as Json)?.role === 'user').map((m) => String((m as Json).content ?? '').trim()).filter((c) => c.length > 0);
|
|
279
|
+
return lines.length > 0 ? lines.join('\n') : undefined;
|
|
280
|
+
}
|
|
281
|
+
/** shared/src/job/recording-availability.ts — the twin records no LiveKit sessions. */
|
|
282
|
+
function recordingStatusOf(job: Json): string {
|
|
283
|
+
const sessions = Array.isArray(job.livekitRecordingSessions) ? job.livekitRecordingSessions : [];
|
|
284
|
+
if (sessions.length > 0 || job.livekitRecordingUrl) return 'ready';
|
|
285
|
+
return job.egressRequestStartMs || job.livekitEgressId ? 'processing' : 'none';
|
|
286
|
+
}
|
|
287
|
+
/** enrich-job.ts `enrichJobForResponse` in `sanitize` mode (GET /api/jobs/:jobId). */
|
|
288
|
+
function enrichedJob(req: RunhumanTwinRequest, job: Json, viewerUserId: string | undefined): Json {
|
|
289
|
+
const project = job.projectId ? docOf('project', String(job.projectId), req.root) : undefined;
|
|
290
|
+
const org = orgOfJob(job, req.root);
|
|
291
|
+
const gated = gateJob(job, org, viewerUserId);
|
|
292
|
+
const { sessionEvents, conversationHistory, assignedTester: _a, assignedTesterName: _b, ...rest } = gated.job;
|
|
293
|
+
return stripUndefined({
|
|
294
|
+
...rest,
|
|
295
|
+
testerData: sessionEvents,
|
|
296
|
+
conversationHistory,
|
|
297
|
+
testerResponse: testerResponseOf(conversationHistory),
|
|
298
|
+
testerAlias: gated.job.testerAlias,
|
|
299
|
+
testerAvatarUrl: gated.job.testerAvatarUrl,
|
|
300
|
+
jobUrl: buildJobUrl(req, gated.job),
|
|
301
|
+
projectName: project?.name,
|
|
302
|
+
richResultsGated: gated.richResultsGated,
|
|
303
|
+
ownerOrganizationName: gated.ownerOrganizationName,
|
|
304
|
+
viewerIsOrganizationOwner: gated.viewerIsOrganizationOwner,
|
|
305
|
+
// normalizeJobData returns undefined until a recording anchor resolves; the twin records none.
|
|
306
|
+
normalizedData: undefined,
|
|
307
|
+
recordingStatus: recordingStatusOf(job),
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
/** get-job.handler.ts `getJobStatusHandler` (GET /api/jobs/:jobId/status). */
|
|
311
|
+
function statusView(req: RunhumanTwinRequest, job: Json, viewerUserId: string | undefined): Json {
|
|
312
|
+
const project = job.projectId ? docOf('project', String(job.projectId), req.root) : undefined;
|
|
313
|
+
const gated = gateJob(job, orgOfJob(job, req.root), viewerUserId);
|
|
314
|
+
const g = gated.job;
|
|
315
|
+
return stripUndefined({
|
|
316
|
+
id: g.id, url: g.url, description: g.description, augmentedDescription: g.augmentedDescription,
|
|
317
|
+
status: g.status, modifier: g.modifier ?? undefined, result: g.result, error: g.error,
|
|
318
|
+
claimedAt: g.claimedAt, completedAt: g.completedAt, testDurationSeconds: g.testDurationSeconds, costUsd: g.costUsd,
|
|
319
|
+
testerData: g.sessionEvents, testerResponse: testerResponseOf(g.conversationHistory),
|
|
320
|
+
testerAlias: g.testerAlias, testerAvatarUrl: g.testerAvatarUrl, jobUrl: buildJobUrl(req, g), projectName: project?.name,
|
|
321
|
+
targetDurationMinutes: g.targetDurationMinutes, maxExtensionMinutes: g.maxExtensionMinutes, responseDeadline: g.responseDeadline,
|
|
322
|
+
keyMoments: g.keyMoments, transcription: g.transcription, conversationHistory: g.conversationHistory,
|
|
323
|
+
richResultsGated: gated.richResultsGated, ownerOrganizationName: gated.ownerOrganizationName, viewerIsOrganizationOwner: gated.viewerIsOrganizationOwner,
|
|
324
|
+
deviceClass: g.deviceClass, requiredDevices: g.requiredDevices, requiredLanguages: g.requiredLanguages,
|
|
325
|
+
requireSocialVideos: g.requireSocialVideos, requireSideload: g.requireSideload,
|
|
326
|
+
extractedIssues: g.extractedIssues, createdIssues: g.createdIssues, normalizedData: undefined,
|
|
327
|
+
passStatus: g.passStatus, recordingStatus: recordingStatusOf(job),
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
function orgOfJob(job: Json, root?: string): Json | undefined {
|
|
331
|
+
if (job.billedOrganizationId) return docOf('organization', String(job.billedOrganizationId), root);
|
|
332
|
+
const project = job.projectId ? docOf('project', String(job.projectId), root) : undefined;
|
|
333
|
+
return project ? docOf('organization', String(project.organizationId), root) : undefined;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** routes/jobs/utils/authorize-job.ts `authorizeJobAccess`. */
|
|
337
|
+
function authorizeJobAccess(req: RunhumanTwinRequest, job: Json, principal: Principal): RunhumanTwinResponse | undefined {
|
|
338
|
+
if (!job.projectId) return json(403, { error: 'Forbidden', message: 'Job has no associated project' });
|
|
339
|
+
const project = docOf('project', String(job.projectId), req.root);
|
|
340
|
+
if (!project) return json(403, { error: 'Forbidden', message: 'Project not found' });
|
|
341
|
+
if (principal.kind === 'user') {
|
|
342
|
+
if (isOrgMember(principal.user, project.organizationId)) return undefined;
|
|
343
|
+
return json(403, { error: 'Forbidden', message: 'You do not have permission to access this job' });
|
|
344
|
+
}
|
|
345
|
+
if (project.organizationId !== principal.apiKey.organizationId) {
|
|
346
|
+
return json(403, { error: 'Forbidden', message: 'API key organization does not match job project organization' });
|
|
347
|
+
}
|
|
348
|
+
return undefined;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// ── POST /api/jobs ───────────────────────────────────────────────────────────────────────────
|
|
352
|
+
async function createJob(req: RunhumanTwinRequest): Promise<RunhumanTwinResponse> {
|
|
353
|
+
const parsed = parseJson(req.body);
|
|
354
|
+
if (!parsed.ok) return parsed.response;
|
|
355
|
+
const invalid = validateBody(parsed.value, CREATE_JOB_FIELDS);
|
|
356
|
+
if (invalid) return invalid;
|
|
357
|
+
const auth = dualAuthentication(req);
|
|
358
|
+
if (!auth.ok) return auth.response;
|
|
359
|
+
if (req.readOnly) return readOnlyRejection();
|
|
360
|
+
const body = (parsed.value ?? {}) as Json;
|
|
361
|
+
const principal = auth.principal;
|
|
362
|
+
|
|
363
|
+
if (body.requiresRunhumanApkInstall === true) {
|
|
364
|
+
// authorize-runhuman-self-test.ts: internal users only; the twin seeds none.
|
|
365
|
+
return json(403, { error: 'requiresRunhumanApkInstall is an internal-only flag', code: 'RUNHUMAN_SELF_TEST_FORBIDDEN' });
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// resolve-job-project.ts
|
|
369
|
+
const requestGithubRepos = (body.githubRepos as string[] | undefined) ?? (typeof body.githubRepo === 'string' ? [body.githubRepo] : undefined);
|
|
370
|
+
if (principal.kind === 'user' && !body.projectId && !body.organizationId) {
|
|
371
|
+
return json(400, {
|
|
372
|
+
error: 'Missing required field: projectId or organizationId',
|
|
373
|
+
message: 'This auth method requires either projectId or organizationId. Use an API key for automatic project resolution.',
|
|
374
|
+
code: 'PAT_REQUIRES_PROJECT_OR_ORG',
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
let project: Json | undefined;
|
|
378
|
+
if (typeof body.projectId === 'string' && body.projectId.length > 0) {
|
|
379
|
+
project = docOf('project', body.projectId, req.root);
|
|
380
|
+
if (!project || project.deletedAt) return json(404, { error: 'Project not found' });
|
|
381
|
+
if (principal.kind === 'apiKey') {
|
|
382
|
+
if (!project.organizationId) return json(403, { error: 'Access denied: Project has no organization' });
|
|
383
|
+
if (project.organizationId !== principal.apiKey.organizationId) {
|
|
384
|
+
return json(403, { error: 'Access denied: API key organization does not match project organization' });
|
|
385
|
+
}
|
|
386
|
+
} else {
|
|
387
|
+
if (!docOf('organization', String(project.organizationId), req.root)) return json(403, { error: 'Project organization not found', code: 'ORG_NOT_FOUND' });
|
|
388
|
+
if (!isOrgMember(principal.user, project.organizationId)) {
|
|
389
|
+
return json(403, { error: 'You do not have permission to create jobs in this organization', code: 'MISSING_PERMISSION' });
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
} else if (requestGithubRepos?.length) {
|
|
393
|
+
return gap('project auto-creation from githubRepos');
|
|
394
|
+
} else if (principal.kind === 'user' && body.organizationId) {
|
|
395
|
+
return json(400, {
|
|
396
|
+
error: 'Missing required field: githubRepos is required when using organizationId for project auto-creation',
|
|
397
|
+
code: 'PAT_ORG_REQUIRES_GITHUB_REPOS',
|
|
398
|
+
});
|
|
399
|
+
} else {
|
|
400
|
+
return json(400, { error: 'Missing project information' });
|
|
401
|
+
}
|
|
402
|
+
const projectId = String(project.id);
|
|
403
|
+
const orgId = String(project.organizationId);
|
|
404
|
+
|
|
405
|
+
// Branches whose RH1 behaviour runs services the twin does not model.
|
|
406
|
+
if (body.requireSideload === true || body.requireSocialVideos === true) return gap('sideload / social-video tier gates');
|
|
407
|
+
if (body.template !== undefined || body.templateContent !== undefined) return gap('job templates');
|
|
408
|
+
if ((body.prNumbers as unknown[] | undefined)?.length || (body.issueNumbers as unknown[] | undefined)?.length) return gap('PR/issue test-plan generation');
|
|
409
|
+
if (body.enhanceInstructions === true) return gap('KB-enhanced instructions');
|
|
410
|
+
if (requestGithubRepos?.length) return gap('GitHub repository access checks');
|
|
411
|
+
if (body.outputSchema !== undefined && body.outputSchema !== null) return gap('outputSchema validation');
|
|
412
|
+
if (body.attachments !== undefined) return gap('attachment validation');
|
|
413
|
+
if (body.screenSize !== undefined && body.screenSize !== null && body.deviceClass == null) return gap('legacy screenSize mapping');
|
|
414
|
+
if (body.autoCreateGithubIssues === true || body.autoCreateJiraIssues === true || body.autoCreateLinearIssues === true || body.autoCreateGithubFeedback === true || body.autoCreateJiraFeedback === true) {
|
|
415
|
+
return gap('issue-tracker integrations');
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// merge-template-and-defaults.ts (no template): metadata defaults, project default URL.
|
|
419
|
+
const requestMetadata = (body.metadata ?? {}) as Json;
|
|
420
|
+
const metadata: Json = {
|
|
421
|
+
...requestMetadata,
|
|
422
|
+
source: requestMetadata.source ?? 'api-async',
|
|
423
|
+
sourceCreatedAt: requestMetadata.sourceCreatedAt ?? now(req),
|
|
424
|
+
commitSha: body.commitSha === undefined ? requestMetadata.commitSha : body.commitSha,
|
|
425
|
+
enableCodeContext: body.enableCodeContext === undefined ? requestMetadata.enableCodeContext : body.enableCodeContext,
|
|
426
|
+
};
|
|
427
|
+
const url = (body.url as string | undefined) ?? (project.defaultUrl as string | undefined);
|
|
428
|
+
const description = body.description as string | undefined;
|
|
429
|
+
const deviceClass = body.deviceClass as string | undefined;
|
|
430
|
+
const targetDurationMinutes = body.targetDurationMinutes as number | undefined;
|
|
431
|
+
const maxExtensionMinutes = body.maxExtensionMinutes as number | undefined;
|
|
432
|
+
|
|
433
|
+
// validate-job-fields.ts
|
|
434
|
+
if (!url || !description) return json(400, { error: 'Missing required fields: url and description are required (either directly or via template)' });
|
|
435
|
+
try {
|
|
436
|
+
const parsedUrl = new URL(url.trim());
|
|
437
|
+
if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') {
|
|
438
|
+
return json(400, { error: `Invalid URL protocol "${parsedUrl.protocol}". URL must start with http:// or https://` });
|
|
439
|
+
}
|
|
440
|
+
} catch {
|
|
441
|
+
return json(400, { error: 'Invalid URL format. URL must start with http:// or https://' });
|
|
442
|
+
}
|
|
443
|
+
if (targetDurationMinutes !== undefined && maxExtensionMinutes !== undefined && targetDurationMinutes + maxExtensionMinutes > SYSTEM_MAX_SESSION_MINUTES) {
|
|
444
|
+
return json(400, {
|
|
445
|
+
error: `Total session time (targetDurationMinutes + maxExtensionMinutes) cannot exceed ${SYSTEM_MAX_SESSION_MINUTES} minutes. Got ${targetDurationMinutes} + ${maxExtensionMinutes} = ${targetDurationMinutes + maxExtensionMinutes}.`,
|
|
446
|
+
code: 'SYSTEM_LIMIT_EXCEEDED',
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// job-creation.ts: external capture needs a desktop-only claim surface.
|
|
451
|
+
const requiredDevices = body.requiredDevices as string[] | undefined;
|
|
452
|
+
if (metadata.captureMode === 'external' && claimSurface({ deviceClass, requiredDevices }) !== 'desktop-only') {
|
|
453
|
+
return appError(400, 'VALIDATION_ERROR', 'External capture requires a desktop browser assignment.');
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
const createdAt = now(req);
|
|
457
|
+
const org = docOf('organization', orgId, req.root);
|
|
458
|
+
const isInternal = org?.isInternal === true;
|
|
459
|
+
const relaxed = metadata.source === 'scheduler' || metadata.source === 'bulk-test-queue';
|
|
460
|
+
const ci = metadata.source === 'qa-test-action' || metadata.source === 'issue-tester-action';
|
|
461
|
+
// Twin organizations carry no Polar customer, so RH1 resolves them to the free tier.
|
|
462
|
+
const priorityScore = (relaxed ? -10 : 0) + (isInternal ? -50 : 0);
|
|
463
|
+
const claimWindowMinutes = ci ? 30 : FREE_TIER_CLAIM_WINDOW_MINUTES;
|
|
464
|
+
|
|
465
|
+
const { value: outcome } = await applyTwinWriteAtomic<{ ok: true; job: Json } | { ok: false }>(SERVICE, (resources) => {
|
|
466
|
+
const active = docsOf('job', undefined, resources).filter((j) => j.billedOrganizationId === orgId && ACTIVE_STATUSES.has(String(j.status)));
|
|
467
|
+
if (active.length + 1 > MAX_ACTIVE_JOBS_PER_ORG) return { kind: 'skip', value: { ok: false } };
|
|
468
|
+
const id = mintFor('job', req, resources);
|
|
469
|
+
const job: Json = stripUndefined({
|
|
470
|
+
id,
|
|
471
|
+
url: url.trim(),
|
|
472
|
+
description,
|
|
473
|
+
targetDurationMinutes: targetDurationMinutes ?? DEFAULT_TARGET_DURATION_MINUTES,
|
|
474
|
+
maxExtensionMinutes: maxExtensionMinutes ?? DEFAULT_MAX_EXTENSION_MINUTES,
|
|
475
|
+
outputSchema: null,
|
|
476
|
+
resultsTemplate: (body.resultsTemplate as string | undefined) ?? null,
|
|
477
|
+
priorityScore,
|
|
478
|
+
expiresAt: new Date(Date.parse(createdAt) + claimWindowMinutes * 60_000).toISOString(),
|
|
479
|
+
deviceClass,
|
|
480
|
+
additionalValidationInstructions: body.additionalValidationInstructions,
|
|
481
|
+
userId: principal.kind === 'user' ? principal.user.id : undefined,
|
|
482
|
+
apiKeyId: principal.kind === 'apiKey' ? principal.apiKey.id : undefined,
|
|
483
|
+
projectId,
|
|
484
|
+
billedOrganizationId: orgId,
|
|
485
|
+
metadata: {
|
|
486
|
+
...metadata,
|
|
487
|
+
...(body.slackNotification ? { slackNotification: body.slackNotification } : {}),
|
|
488
|
+
},
|
|
489
|
+
autoCreateOnlyTesterSurfaced: body.autoCreateOnlyTesterSurfaced,
|
|
490
|
+
reopenIssuesOnDuplicate: body.reopenIssuesOnDuplicate,
|
|
491
|
+
commentOnDuplicate: body.commentOnDuplicate,
|
|
492
|
+
autoCreateEnhancedVideo: body.enhancedVideo,
|
|
493
|
+
requiredDevices,
|
|
494
|
+
requiredLanguages: body.requiredLanguages,
|
|
495
|
+
emailOnCompletion: body.emailOnCompletion,
|
|
496
|
+
inAppOnCompletion: body.inAppOnCompletion,
|
|
497
|
+
emailOnFailure: body.emailOnFailure,
|
|
498
|
+
inAppOnFailure: body.inAppOnFailure,
|
|
499
|
+
status: 'pending',
|
|
500
|
+
version: 1,
|
|
501
|
+
createdAt,
|
|
502
|
+
updatedAt: createdAt,
|
|
503
|
+
conversationHistory: [],
|
|
504
|
+
});
|
|
505
|
+
return { kind: 'write', value: { ok: true, job }, write: { operation: 'job.create', subjectType: 'job', subjectId: `job:${id}`, fields: { doc: job }, occurredAt: createdAt, actor: { kind: 'system' } } };
|
|
506
|
+
}, req.root);
|
|
507
|
+
if (!outcome.ok) {
|
|
508
|
+
return appError(429, 'ACTIVE_JOB_LIMIT', `Active job limit reached: an organization may have at most ${MAX_ACTIVE_JOBS_PER_ORG} active jobs at once. Wait for in-flight jobs to finish before creating more.`);
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
// create-and-dispatch-job.ts response.
|
|
512
|
+
const surface = claimSurfaceConflict(deviceClass, requiredDevices);
|
|
513
|
+
const hostMismatch = project.defaultUrl ? urlHostMismatch(url, String(project.defaultUrl), String(project.name)) : undefined;
|
|
514
|
+
return json(201, stripUndefined({
|
|
515
|
+
jobId: outcome.job.id,
|
|
516
|
+
message: 'Job created successfully. Use GET /api/jobs/:id to check status.',
|
|
517
|
+
...(surface ? { warnings: [surface] } : {}),
|
|
518
|
+
...(hostMismatch ? { projectUrlMismatch: hostMismatch } : {}),
|
|
519
|
+
}));
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/** shared/src/job/claim-surface.ts `requiredClaimSurface`. */
|
|
523
|
+
function claimSurface(job: { deviceClass?: unknown; requiredDevices?: unknown; requiresRunhumanApkInstall?: unknown }): 'desktop-only' | 'mobile-only' | 'any' {
|
|
524
|
+
if (job.requiresRunhumanApkInstall === true) return 'desktop-only';
|
|
525
|
+
const devices = Array.isArray(job.requiredDevices) ? (job.requiredDevices as string[]) : [];
|
|
526
|
+
if (devices.length > 0) {
|
|
527
|
+
const mobile = devices.some((d) => d === 'ios' || d === 'android');
|
|
528
|
+
const desktop = devices.some((d) => d === 'pc' || d === 'mac');
|
|
529
|
+
if (mobile && desktop) return 'any';
|
|
530
|
+
if (mobile) return 'mobile-only';
|
|
531
|
+
if (desktop) return 'desktop-only';
|
|
532
|
+
}
|
|
533
|
+
if (job.deviceClass === 'mobile') return 'mobile-only';
|
|
534
|
+
if (job.deviceClass === 'desktop') return 'desktop-only';
|
|
535
|
+
return 'any';
|
|
536
|
+
}
|
|
537
|
+
/** shared/src/job/claim-surface.ts `detectClaimSurfaceConflict` (+ buildConflictMessage). */
|
|
538
|
+
function claimSurfaceConflict(deviceClass: string | undefined, requiredDevices: string[] | undefined): string | undefined {
|
|
539
|
+
if (!requiredDevices?.length) return undefined;
|
|
540
|
+
const fromDevices = claimSurface({ requiredDevices });
|
|
541
|
+
const hasOsDevice = requiredDevices.some((d) => d in DEVICE_LABELS);
|
|
542
|
+
if (!hasOsDevice) return undefined;
|
|
543
|
+
if (deviceClass !== 'desktop' && deviceClass !== 'mobile') return undefined;
|
|
544
|
+
if (claimSurface({ deviceClass }) === fromDevices) return undefined;
|
|
545
|
+
const surfaceLabel = { 'mobile-only': 'mobile only', 'desktop-only': 'desktop only', any: 'either desktop or mobile' }[fromDevices];
|
|
546
|
+
const deviceClassLabel = deviceClass === 'desktop' ? 'Desktop' : 'Mobile';
|
|
547
|
+
const deviceList = requiredDevices.map((d) => DEVICE_LABELS[d] ?? d).join(', ');
|
|
548
|
+
return `Device Class is set to ${deviceClassLabel}, but the required device(s) ${deviceList} force the claim surface to ${surfaceLabel}. Required devices win — this job will be claimable from ${surfaceLabel}.`;
|
|
549
|
+
}
|
|
550
|
+
/** shared/src/utils/url-host-mismatch.ts `detectUrlHostMismatch` + the handler's projectName. */
|
|
551
|
+
function urlHostMismatch(url: string, defaultUrl: string, projectName: string): Json | undefined {
|
|
552
|
+
const comparable = (h: string) => (h.startsWith('www.') ? h.slice(4) : h);
|
|
553
|
+
try {
|
|
554
|
+
const targetHost = new URL(url).hostname.toLowerCase();
|
|
555
|
+
const projectDefaultHost = new URL(defaultUrl).hostname.toLowerCase();
|
|
556
|
+
return comparable(targetHost) === comparable(projectDefaultHost) ? undefined : { targetHost, projectDefaultHost, projectName };
|
|
557
|
+
} catch {
|
|
558
|
+
return undefined;
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
// ── GET /api/jobs/:jobId, GET /api/jobs/:jobId/status ────────────────────────────────────────
|
|
563
|
+
function readJob(req: RunhumanTwinRequest, jobId: string, view: 'details' | 'status'): RunhumanTwinResponse {
|
|
564
|
+
const auth = dualAuthentication(req);
|
|
565
|
+
if (!auth.ok) return auth.response;
|
|
566
|
+
const job = docOf('job', jobId, req.root);
|
|
567
|
+
if (!job) return json(404, { error: 'Job not found' });
|
|
568
|
+
// getJobDetailsHandler always authorizes; getJobStatusHandler only when the job has a project.
|
|
569
|
+
if (view === 'details' || job.projectId) {
|
|
570
|
+
const refused = authorizeJobAccess(req, job, auth.principal);
|
|
571
|
+
if (refused) return refused;
|
|
572
|
+
}
|
|
573
|
+
const viewer = auth.principal.kind === 'user' ? String(auth.principal.user.id) : undefined;
|
|
574
|
+
return json(200, view === 'details' ? enrichedJob(req, job, viewer) : statusView(req, job, viewer));
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
// ── GET /api/projects/:projectId/jobs ────────────────────────────────────────────────────────
|
|
578
|
+
// job-list-columns.ts CUSTOMER_JOB_LIST_COLUMNS (JOB_LIST_BASE_COLUMNS + CUSTOMER extras).
|
|
579
|
+
const LIST_COLUMNS = ['id', 'status', 'modifier', 'url', 'description', 'createdAt', 'updatedAt', 'apiKeyId', 'projectId', 'testerAlias', 'testerAvatarUrl',
|
|
580
|
+
'testerProfileId', 'costUsd', 'testDurationSeconds', 'completedAt', 'claimedAt', 'error', 'passStatus', 'outputSchema', 'githubRepos', 'metadata', 'augmentedDescription'];
|
|
581
|
+
function listProjectJobs(req: RunhumanTwinRequest, projectId: string, query: URLSearchParams): RunhumanTwinResponse {
|
|
582
|
+
const auth = dualAuthentication(req);
|
|
583
|
+
if (!auth.ok) return auth.response;
|
|
584
|
+
const project = docOf('project', projectId, req.root);
|
|
585
|
+
if (!project || project.deletedAt) return json(404, { error: 'Project not found' });
|
|
586
|
+
if (auth.principal.kind === 'apiKey' ? project.organizationId !== auth.principal.apiKey.organizationId : !isOrgMember(auth.principal.user, project.organizationId)) {
|
|
587
|
+
return json(403, { error: 'Access denied' });
|
|
588
|
+
}
|
|
589
|
+
// shared/src/pagination/pagination.types.ts parsePaginationParams (default 50, max 100).
|
|
590
|
+
const limitRaw = parseInt(query.get('limit') || '50', 10) || 50;
|
|
591
|
+
const limit = Math.min(Math.max(1, limitRaw), 100);
|
|
592
|
+
const offset = Math.max(0, parseInt(query.get('offset') || '0', 10) || 0);
|
|
593
|
+
const all = docsOf('job', req.root)
|
|
594
|
+
.filter((j) => j.projectId === projectId)
|
|
595
|
+
.sort((a, b) => String(b.createdAt).localeCompare(String(a.createdAt)) || String(b.id).localeCompare(String(a.id)));
|
|
596
|
+
const items = all.slice(offset, offset + limit).map((job) => {
|
|
597
|
+
const row: Json = {};
|
|
598
|
+
for (const column of LIST_COLUMNS) if (job[column] !== undefined) row[column] = job[column];
|
|
599
|
+
const result = job.result as Json | undefined;
|
|
600
|
+
if (result) row.result = stripUndefined({ explanation: result.explanation, data: result.data, text: result.text });
|
|
601
|
+
return row;
|
|
602
|
+
});
|
|
603
|
+
return json(200, { items, pagination: { total: all.length, limit, offset, hasMore: offset + items.length < all.length } });
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
// ── POST /api/jobs/:jobId/claim ──────────────────────────────────────────────────────────────
|
|
607
|
+
const CLAIM_ERROR_HTTP: Record<string, { status: number; error: string }> = {
|
|
608
|
+
tester_not_found: { status: 403, error: 'Not a tester' },
|
|
609
|
+
tester_not_active: { status: 403, error: 'Tester not active' },
|
|
610
|
+
tester_invisible: { status: 403, error: 'Tester is invisible' },
|
|
611
|
+
tester_has_active_job: { status: 409, error: 'Tester already has an active job' },
|
|
612
|
+
job_not_found: { status: 404, error: 'Job not found' },
|
|
613
|
+
job_already_claimed: { status: 409, error: 'Job already claimed' },
|
|
614
|
+
job_not_available: { status: 409, error: 'Job not available' },
|
|
615
|
+
pool_requirements_not_met: { status: 403, error: 'Pool requirements not met' },
|
|
616
|
+
mobile_only: { status: 403, error: 'Mobile only' },
|
|
617
|
+
desktop_only: { status: 403, error: 'Desktop only' },
|
|
618
|
+
};
|
|
619
|
+
const DEVICE_LABELS: Record<string, string> = { ios: 'iOS', android: 'Android', pc: 'PC', mac: 'Mac' };
|
|
620
|
+
const LANGUAGE_LABELS: Record<string, string> = { english: 'English', spanish: 'Spanish' };
|
|
621
|
+
|
|
622
|
+
async function claimJob(req: RunhumanTwinRequest, jobId: string): Promise<RunhumanTwinResponse> {
|
|
623
|
+
const parsed = parseJson(req.body);
|
|
624
|
+
if (!parsed.ok) return parsed.response;
|
|
625
|
+
const invalid = validateBody(parsed.value, CLAIM_JOB_FIELDS);
|
|
626
|
+
if (invalid) return invalid;
|
|
627
|
+
const auth = authenticateWebUser(req);
|
|
628
|
+
if (!auth.ok) return auth.response;
|
|
629
|
+
if (auth.principal.kind !== 'user') return json(401, { error: 'Authentication required' });
|
|
630
|
+
if (req.readOnly) return readOnlyRejection();
|
|
631
|
+
const user = auth.principal.user;
|
|
632
|
+
const claimSource = String(((parsed.value ?? {}) as Json).claimSource ?? 'tester-portal');
|
|
633
|
+
const profile = docsOf('tester_profile', req.root).find((p) => p.userId === user.id);
|
|
634
|
+
if (!profile) return json(403, { code: 'tester_not_found', error: 'Not a tester', details: 'You must be an approved tester to claim jobs' });
|
|
635
|
+
|
|
636
|
+
const secret = await jwtSecret(req.root);
|
|
637
|
+
const claimedAt = now(req);
|
|
638
|
+
const token = signTesterJwt(secret, String(user.id), user.email as string | undefined, Math.floor(Date.parse(claimedAt) / 1000));
|
|
639
|
+
|
|
640
|
+
const { value: outcome } = await applyTwinWriteAtomic<{ ok: true; job: Json } | { ok: false; code: string; message: string; details?: string[] }>(SERVICE, (resources) => {
|
|
641
|
+
const fail = (code: string, message: string, details?: string[]) => ({ kind: 'skip' as const, value: { ok: false as const, code, message, ...(details ? { details } : {}) } });
|
|
642
|
+
// job-claim.service.ts validateTester
|
|
643
|
+
if (profile.status !== 'active') return fail('tester_not_active', `Tester status is: ${profile.status}`);
|
|
644
|
+
if (profile.availability === 'invisible') return fail('tester_invisible', 'Tester is invisible — set status to Online before claiming');
|
|
645
|
+
// validateJob
|
|
646
|
+
const job = resources.find((r) => r.type === 'job' && r.id === `job:${jobId}`)?.doc as Json | undefined;
|
|
647
|
+
if (!job) return fail('job_not_found', 'Job not found');
|
|
648
|
+
if (job.testerProfileId) return fail('job_already_claimed', 'Job already claimed');
|
|
649
|
+
if (job.status !== 'pending' && job.status !== 'waiting') return fail('job_not_available', `Job status is: ${job.status}`);
|
|
650
|
+
const surface = claimSurface(job);
|
|
651
|
+
if (surface === 'mobile-only' && claimSource !== 'mobile-app') return fail('mobile_only', 'Mobile-only jobs can only be claimed via the mobile app');
|
|
652
|
+
if (surface === 'desktop-only' && claimSource === 'mobile-app') return fail('desktop_only', 'Dual-device self-tests must be claimed from the tester portal on a computer');
|
|
653
|
+
// checkPoolRequirements
|
|
654
|
+
const missing: string[] = [];
|
|
655
|
+
const devices = (profile.devices as string[] | undefined) ?? [];
|
|
656
|
+
const languages = (profile.languages as string[] | undefined) ?? [];
|
|
657
|
+
const requiredDevices = (job.requiredDevices as string[] | undefined) ?? [];
|
|
658
|
+
if (requiredDevices.length > 0 && !requiredDevices.some((d) => devices.includes(d))) missing.push(`Missing device(s): ${requiredDevices.map((d) => DEVICE_LABELS[d] ?? d).join(', ')}`);
|
|
659
|
+
const missingLanguages = ((job.requiredLanguages as string[] | undefined) ?? []).filter((l) => !languages.includes(l));
|
|
660
|
+
if (missingLanguages.length > 0) missing.push(`Missing language(s): ${missingLanguages.map((l) => LANGUAGE_LABELS[l] ?? l).join(', ')}`);
|
|
661
|
+
if (missing.length > 0) return fail('pool_requirements_not_met', 'Tester does not meet pool requirements', missing);
|
|
662
|
+
const busy = docsOf('job', undefined, resources).find((j) => j.testerProfileId === profile.id && j.status === 'working');
|
|
663
|
+
if (busy) return fail('tester_has_active_job', `Already working on job "${busy.id}"`);
|
|
664
|
+
const allottedMs = (Number(job.targetDurationMinutes) + Number(job.maxExtensionMinutes)) * 60_000;
|
|
665
|
+
const updated: Json = stripUndefined({
|
|
666
|
+
...job,
|
|
667
|
+
status: 'working',
|
|
668
|
+
testerProfileId: profile.id,
|
|
669
|
+
assignedTester: profile.alias,
|
|
670
|
+
assignedTesterName: profile.alias,
|
|
671
|
+
testerAlias: profile.alias,
|
|
672
|
+
testerAvatarUrl: profile.avatarUrl ?? undefined,
|
|
673
|
+
claimedAt,
|
|
674
|
+
lastActivity: claimedAt,
|
|
675
|
+
responseDeadline: new Date(Date.parse(claimedAt) + allottedMs).toISOString(),
|
|
676
|
+
expiresAt: undefined,
|
|
677
|
+
claimSource,
|
|
678
|
+
version: Number(job.version ?? 0) + 1,
|
|
679
|
+
updatedAt: claimedAt,
|
|
680
|
+
metadata: { ...((job.metadata ?? {}) as Json), testerToken: token },
|
|
681
|
+
});
|
|
682
|
+
return { kind: 'write', value: { ok: true, job: updated }, write: { operation: 'job.claim', subjectType: 'job', subjectId: `job:${jobId}`, fields: { doc: updated }, occurredAt: claimedAt, actor: { kind: 'human', id: String(user.id) } } };
|
|
683
|
+
}, req.root);
|
|
684
|
+
|
|
685
|
+
if (!outcome.ok) {
|
|
686
|
+
const mapped = CLAIM_ERROR_HTTP[outcome.code]!;
|
|
687
|
+
return json(mapped.status, { code: outcome.code, error: mapped.error, details: outcome.details ?? outcome.message });
|
|
688
|
+
}
|
|
689
|
+
const job = outcome.job;
|
|
690
|
+
return json(200, {
|
|
691
|
+
message: 'Job claimed successfully',
|
|
692
|
+
job: { id: job.id, url: job.url, description: job.description, targetDurationMinutes: job.targetDurationMinutes, status: job.status, claimedAt: job.claimedAt },
|
|
693
|
+
webTestLink: `${origin(req)}/tester/test/${job.id}?testerToken=${encodeURIComponent(token)}`,
|
|
694
|
+
});
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
// ── tester token routes (tester-auth-helpers.ts) ─────────────────────────────────────────────
|
|
698
|
+
type TesterAuth = { ok: true; job: Json; token: string; sub: string } | { ok: false; response: RunhumanTwinResponse };
|
|
699
|
+
async function testerJob(req: RunhumanTwinRequest, jobId: string, mismatch?: { code: number; error: string }): Promise<TesterAuth> {
|
|
700
|
+
const authHeader = header(req, 'authorization');
|
|
701
|
+
const token = authHeader?.startsWith('Bearer ') ? authHeader.substring(7) : undefined;
|
|
702
|
+
if (!token) return { ok: false, response: json(401, { error: 'Unauthorized', message: 'Missing testerToken — send Authorization: Bearer <token>' }) };
|
|
703
|
+
const verified = verifyTesterJwt(await jwtSecret(req.root), token, Math.floor(Date.parse(now(req)) / 1000));
|
|
704
|
+
if (!verified.ok) return { ok: false, response: json(401, { error: 'Unauthorized', message: 'Invalid or expired testerToken', reason: verified.reason }) };
|
|
705
|
+
const job = docOf('job', jobId, req.root);
|
|
706
|
+
if (job && (job.metadata as Json | undefined)?.testerToken === token) {
|
|
707
|
+
const surface = header(req, 'x-tester-surface');
|
|
708
|
+
if (surface !== undefined) {
|
|
709
|
+
const sources = ['tester-portal', 'mobile-app', 'slack', 'discord-activity'];
|
|
710
|
+
if (!sources.includes(surface)) return { ok: false, response: json(400, { error: 'Bad Request', message: `Invalid X-Tester-Surface header. Expected one of: ${sources.join(', ')}` }) };
|
|
711
|
+
const need = claimSurface(job);
|
|
712
|
+
if (need === 'mobile-only' && surface !== 'mobile-app') return { ok: false, response: json(403, { code: 'mobile_only', error: 'Mobile only', message: 'This job can only be tested on the mobile tester app.' }) };
|
|
713
|
+
if (need === 'desktop-only' && surface === 'mobile-app') return { ok: false, response: json(403, { code: 'desktop_only', error: 'Desktop only', message: 'This job can only be tested in a desktop browser.' }) };
|
|
714
|
+
}
|
|
715
|
+
return { ok: true, job, token, sub: verified.sub };
|
|
716
|
+
}
|
|
717
|
+
if (job && mismatch) return { ok: false, response: json(mismatch.code, { error: mismatch.error }) };
|
|
718
|
+
return { ok: false, response: json(404, { error: 'Job not found' }) };
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
async function getTesterJob(req: RunhumanTwinRequest, jobId: string): Promise<RunhumanTwinResponse> {
|
|
722
|
+
const auth = await testerJob(req, jobId);
|
|
723
|
+
if (!auth.ok) return auth.response;
|
|
724
|
+
const org = auth.job.billedOrganizationId ? docOf('organization', String(auth.job.billedOrganizationId), req.root) : undefined;
|
|
725
|
+
return json(200, stripUndefined({
|
|
726
|
+
...auth.job,
|
|
727
|
+
description: auth.job.augmentedDescription ?? auth.job.description,
|
|
728
|
+
organizationName: org?.name,
|
|
729
|
+
}));
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
const TESTER_WRITABLE_FIELDS = ['status', 'modifier', 'lastActivity', 'conversationHistory', 'result', 'keyMoments', 'phases', 'userRating', 'userFeedback', 'testerAppStartTime', 'testerAppEndTime', 'actualPageLoadTime'];
|
|
733
|
+
function pickTesterWritable(body: Json): Json {
|
|
734
|
+
const out: Json = {};
|
|
735
|
+
for (const key of TESTER_WRITABLE_FIELDS) if (body[key] !== undefined) out[key] = body[key];
|
|
736
|
+
return out;
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
/** PATCH /api/tester/jobs/:jobId — update-tester-job.handler.ts */
|
|
740
|
+
async function patchTesterJob(req: RunhumanTwinRequest, jobId: string): Promise<RunhumanTwinResponse> {
|
|
741
|
+
const parsed = parseJson(req.body);
|
|
742
|
+
if (!parsed.ok) return parsed.response;
|
|
743
|
+
const invalid = validateObjectBody(parsed.value);
|
|
744
|
+
if (invalid) return invalid;
|
|
745
|
+
const auth = await testerJob(req, jobId);
|
|
746
|
+
if (!auth.ok) return auth.response;
|
|
747
|
+
if (req.readOnly) return readOnlyRejection();
|
|
748
|
+
const existing = auth.job;
|
|
749
|
+
const incoming = { ...((parsed.value ?? {}) as Json) };
|
|
750
|
+
delete incoming.organizationName;
|
|
751
|
+
delete incoming.sensorShortCode;
|
|
752
|
+
if (existing.modifier === 'admin_terminated') return json(409, { adminTerminated: true, message: 'This job was ended by an admin and can no longer be submitted.' });
|
|
753
|
+
if (Array.isArray(incoming.conversationHistory)) {
|
|
754
|
+
incoming.conversationHistory = incoming.conversationHistory.map((m) => ({ ...(m as Json), testerId: existing.assignedTester }));
|
|
755
|
+
}
|
|
756
|
+
const at = now(req);
|
|
757
|
+
incoming.lastActivity = at;
|
|
758
|
+
if (incoming.status === 'completed' && existing.status === 'overdue' && incoming.modifier !== 'late_submission') {
|
|
759
|
+
return json(409, { overdue: true, message: 'This job is overdue — its response deadline passed. You can still submit your results as a late submission.' });
|
|
760
|
+
}
|
|
761
|
+
if (incoming.status === 'completed' && existing.status === 'completed') {
|
|
762
|
+
return json(200, { success: true, job: stripUndefined({ id: existing.id, status: existing.status, result: existing.result }) });
|
|
763
|
+
}
|
|
764
|
+
if (incoming.status === 'completed') {
|
|
765
|
+
if (!['pass', 'fail', 'not_applicable'].includes(String(existing.passStatus))) {
|
|
766
|
+
return json(400, { error: 'Cannot complete job without an AI verdict', message: 'Job has no server-recorded Pass/Fail result yet; AI processing must finish before completion.' });
|
|
767
|
+
}
|
|
768
|
+
const idempotencyKey = header(req, 'idempotency-key');
|
|
769
|
+
const merged: Json = {
|
|
770
|
+
...existing,
|
|
771
|
+
...pickTesterWritable(incoming),
|
|
772
|
+
status: existing.status,
|
|
773
|
+
result: incoming.result ?? existing.result,
|
|
774
|
+
keyMoments: incoming.keyMoments ?? existing.keyMoments,
|
|
775
|
+
metadata: idempotencyKey ? { ...((existing.metadata ?? {}) as Json), submitIdempotencyKey: idempotencyKey } : existing.metadata,
|
|
776
|
+
updatedAt: at,
|
|
777
|
+
};
|
|
778
|
+
const completed = completeJob(merged, at);
|
|
779
|
+
if (completed) await put('job', jobId, completed, 'job.complete', req);
|
|
780
|
+
else await put('job', jobId, merged, 'job.tester_update', req);
|
|
781
|
+
const finalJob = docOf('job', jobId, req.root) ?? merged;
|
|
782
|
+
return json(200, { success: true, job: stripUndefined({ id: finalJob.id ?? jobId, status: finalJob.status ?? 'completed', result: finalJob.result }) });
|
|
783
|
+
}
|
|
784
|
+
const updates = pickTesterWritable(incoming);
|
|
785
|
+
if (updates.testerAppStartTime && !existing.testerAppStartTime) {
|
|
786
|
+
const allottedMs = (Number(existing.targetDurationMinutes) + Number(existing.maxExtensionMinutes)) * 60_000;
|
|
787
|
+
updates.responseDeadline = new Date(Number(updates.testerAppStartTime) + allottedMs).toISOString();
|
|
788
|
+
}
|
|
789
|
+
const updated: Json = { ...existing, ...updates, updatedAt: at };
|
|
790
|
+
await put('job', jobId, updated, 'job.tester_update', req);
|
|
791
|
+
return json(200, { success: true, job: stripUndefined({ id: updated.id, status: updated.status, result: updated.result }) });
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
/** job-completion.service.ts `handleJobCompleted` + job-status.service.ts + pricing.ts + billing-handler.ts
|
|
795
|
+
* `calculateCompletedJobCost` (no recording sessions in the twin: on-time jobs bill the
|
|
796
|
+
* testerApp window, else claimedAt → completedAt; late ones the allotted window). Returns
|
|
797
|
+
* undefined when RH1 would skip (already completed / non-completable). */
|
|
798
|
+
function completeJob(job: Json, completedAt: string): Json | undefined {
|
|
799
|
+
const from = String(job.status);
|
|
800
|
+
if (from === 'completed' || !['working', 'overdue', 'admin_hold'].includes(from)) return undefined;
|
|
801
|
+
const target = Number(job.targetDurationMinutes);
|
|
802
|
+
const extension = Number(job.maxExtensionMinutes);
|
|
803
|
+
const capSeconds = (target + extension) * 60;
|
|
804
|
+
let durationSeconds = 0;
|
|
805
|
+
if (job.claimedAt) {
|
|
806
|
+
if (from === 'overdue' || from === 'admin_hold') {
|
|
807
|
+
durationSeconds = capSeconds;
|
|
808
|
+
} else {
|
|
809
|
+
const start = job.testerAppStartTime && job.testerAppEndTime ? Number(job.testerAppStartTime) : Date.parse(String(job.claimedAt));
|
|
810
|
+
const end = job.testerAppStartTime && job.testerAppEndTime ? Number(job.testerAppEndTime) : Date.parse(completedAt);
|
|
811
|
+
durationSeconds = Math.min(Math.ceil(Math.max(0, end - start) / 1000), capSeconds);
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
const costUsd = durationSeconds * USD_PER_SECOND;
|
|
815
|
+
return {
|
|
816
|
+
...job,
|
|
817
|
+
status: 'completed',
|
|
818
|
+
...(from === 'overdue' ? { modifier: 'late_submission' } : {}),
|
|
819
|
+
completedAt,
|
|
820
|
+
testDurationSeconds: durationSeconds,
|
|
821
|
+
costUsd,
|
|
822
|
+
// billing-handler.ts: the billing queue settles the owed charge and records it on the job. The twin runs no
|
|
823
|
+
// queue, so the charge settles with the completion that owes it; a job no organization pays is never billed.
|
|
824
|
+
...(job.billedOrganizationId ? { billedAt: completedAt, billedAmountCents: Math.round(costUsd * 100), billingIdempotencyKey: job.id } : {}),
|
|
825
|
+
updatedAt: completedAt,
|
|
826
|
+
};
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
// ── the results pipeline (process-results, issue-review, processing status) ──────────────────
|
|
830
|
+
const pipelineKey = (jobId: string) => `pipeline__${jobId.replace(/:/g, '_')}`;
|
|
831
|
+
|
|
832
|
+
async function enqueuePipeline(req: RunhumanTwinRequest, job: Json, mode: 'extract' | 'finalize', triggeredBy: string): Promise<string> {
|
|
833
|
+
const key = pipelineKey(String(job.id));
|
|
834
|
+
const existing = docOf('pipeline_run', key, req.root);
|
|
835
|
+
// enqueueIdempotent: attach to an in-flight run; a terminal one is replaced by a fresh run.
|
|
836
|
+
if (existing && existing.state !== 'completed' && existing.state !== 'failed') return key;
|
|
837
|
+
await put('pipeline_run', key, { processingJobId: key, jobId: job.id, mode, triggeredBy, state: 'waiting', enqueuedAt: now(req) }, 'pipeline.enqueue', req);
|
|
838
|
+
return key;
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
async function processResults(req: RunhumanTwinRequest, jobId: string): Promise<RunhumanTwinResponse> {
|
|
842
|
+
const parsed = parseJson(req.body);
|
|
843
|
+
if (!parsed.ok) return parsed.response;
|
|
844
|
+
const invalid = validateBody(parsed.value, PROCESS_RESULTS_FIELDS);
|
|
845
|
+
if (invalid) return invalid;
|
|
846
|
+
const auth = await testerJob(req, jobId, { code: 403, error: 'Invalid testerToken for job' });
|
|
847
|
+
if (!auth.ok) return auth.response;
|
|
848
|
+
if (req.readOnly) return readOnlyRejection();
|
|
849
|
+
const body = (parsed.value ?? {}) as Json;
|
|
850
|
+
let job = auth.job;
|
|
851
|
+
if (body.clientInfo) {
|
|
852
|
+
job = { ...job, metadata: { ...((job.metadata ?? {}) as Json), testerClient: body.clientInfo }, updatedAt: now(req) };
|
|
853
|
+
await put('job', jobId, job, 'job.client_info', req);
|
|
854
|
+
}
|
|
855
|
+
const processingJobId = await enqueuePipeline(req, job, 'extract', 'tester');
|
|
856
|
+
return json(200, { processingJobId, status: 'processing', message: 'Processing started. Poll /api/tester/jobs/:jobId/processing/:processingJobId for status.' });
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
async function issueReview(req: RunhumanTwinRequest, jobId: string): Promise<RunhumanTwinResponse> {
|
|
860
|
+
const parsed = parseJson(req.body);
|
|
861
|
+
if (!parsed.ok) return parsed.response;
|
|
862
|
+
const body = (parsed.value ?? undefined) as Json | undefined;
|
|
863
|
+
if (!body || (body.action !== 'reprocess' && body.action !== 'continue')) {
|
|
864
|
+
return json(400, { error: 'Bad Request', message: 'action must be one of: reprocess, continue' });
|
|
865
|
+
}
|
|
866
|
+
const auth = await testerJob(req, jobId);
|
|
867
|
+
if (!auth.ok) return auth.response;
|
|
868
|
+
if (req.readOnly) return readOnlyRejection();
|
|
869
|
+
const job = auth.job;
|
|
870
|
+
if (job.status !== 'working' && job.status !== 'overdue') {
|
|
871
|
+
return json(409, { error: 'Conflict', message: `Cannot review issues for a job in status "${job.status}". Job must be "working" or "overdue".` });
|
|
872
|
+
}
|
|
873
|
+
if (!Array.isArray(job.extractedIssues) || !Array.isArray(job.extractedFeedback)) {
|
|
874
|
+
return json(409, { error: 'Conflict', message: 'Issue review is not available until extraction has run.' });
|
|
875
|
+
}
|
|
876
|
+
const apply = (items: Json[], decisions: unknown): Json[] => {
|
|
877
|
+
const byId = new Map((Array.isArray(decisions) ? decisions : []).map((d) => [(d as Json).id, d as Json]));
|
|
878
|
+
return items.map((item) => {
|
|
879
|
+
const decision = byId.get(item.id);
|
|
880
|
+
if (!decision) return item;
|
|
881
|
+
return stripUndefined({ ...item, reviewStatus: decision.status, testerComment: decision.status === 'revise' ? decision.comment : undefined });
|
|
882
|
+
});
|
|
883
|
+
};
|
|
884
|
+
const issues = apply(job.extractedIssues as Json[], body.decisions);
|
|
885
|
+
const feedback = apply(job.extractedFeedback as Json[], body.feedbackDecisions);
|
|
886
|
+
const resolved = (i: Json) => i.reviewStatus === 'accepted' || i.reviewStatus === 'declined';
|
|
887
|
+
if (body.action === 'continue' && (!issues.every(resolved) || !feedback.every(resolved))) {
|
|
888
|
+
return json(400, { error: 'Bad Request', message: 'Every issue and feedback item must be accepted or declined before continuing.' });
|
|
889
|
+
}
|
|
890
|
+
const prior = Number(((job.metadata ?? {}) as Json & { issueReview?: Json }).issueReview?.rerunCount ?? 0);
|
|
891
|
+
const rerunCount = body.action === 'reprocess' ? prior + 1 : prior;
|
|
892
|
+
await put('job', jobId, { ...job, extractedIssues: issues, extractedFeedback: feedback, updatedAt: now(req) }, 'job.issue_review', req);
|
|
893
|
+
if (body.action === 'reprocess') return gap('issue-review reprocess (AI re-extraction with guidance)');
|
|
894
|
+
const processingJobId = await enqueuePipeline(req, { ...job }, 'finalize', 'tester');
|
|
895
|
+
return json(200, { processingJobId, rerunCount, softCapReached: rerunCount >= ISSUE_REVIEW_RERUN_SOFT_CAP });
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
/** GET /api/tester/jobs/:jobId/processing/:processingJobId — the POLL-FOLD: a waiting run is
|
|
899
|
+
* completed by this read (the pipeline worker's effect, derived from stored state + the
|
|
900
|
+
* request's instant), then answered with processing-status.util.ts `buildPipelineStatus`. */
|
|
901
|
+
async function processingStatus(req: RunhumanTwinRequest, jobId: string, processingJobId: string): Promise<RunhumanTwinResponse> {
|
|
902
|
+
const auth = await testerJob(req, jobId, { code: 403, error: 'Invalid testerToken for job' });
|
|
903
|
+
if (!auth.ok) return auth.response;
|
|
904
|
+
let run = docOf('pipeline_run', processingJobId, req.root);
|
|
905
|
+
if (!run) return json(404, { error: 'Processing job not found' });
|
|
906
|
+
if (run.jobId !== jobId) return json(403, { error: 'Processing job does not belong to this job' });
|
|
907
|
+
if (run.state === 'waiting' && !req.readOnly) {
|
|
908
|
+
const job = docOf('job', jobId, req.root)!;
|
|
909
|
+
const at = now(req);
|
|
910
|
+
let next: Json;
|
|
911
|
+
if (run.mode === 'extract') {
|
|
912
|
+
const result = (job.result as Json | undefined) ?? { explanation: '', data: {} };
|
|
913
|
+
next = { ...job, result, extractedIssues: [], extractedFeedback: [], updatedAt: at };
|
|
914
|
+
} else {
|
|
915
|
+
const data = ((job.result as Json | undefined)?.data ?? {}) as Json;
|
|
916
|
+
const passStatus = data.success === true ? 'pass' : data.success === false ? 'fail' : 'not_applicable';
|
|
917
|
+
next = { ...job, passStatus, updatedAt: at };
|
|
918
|
+
}
|
|
919
|
+
await put('job', jobId, next, `pipeline.${run.mode}`, req);
|
|
920
|
+
run = { ...run, state: 'completed', finishedAt: at, resultSummary: { isComplete: true } };
|
|
921
|
+
await put('pipeline_run', processingJobId, run, 'pipeline.complete', req);
|
|
922
|
+
}
|
|
923
|
+
if (run.state === 'waiting' || run.state === 'active') return json(200, { status: 'processing', logs: [] });
|
|
924
|
+
if (run.state === 'failed') return json(200, { status: 'failed', error: run.error ?? 'Pipeline run failed', logs: [] });
|
|
925
|
+
const fresh = docOf('job', jobId, req.root);
|
|
926
|
+
if (!fresh) return json(200, { status: 'failed', error: `Job ${jobId} disappeared after completion`, logs: [] });
|
|
927
|
+
const columns = ['id', 'status', 'keyMoments', 'result', 'testerAppStartTime', 'testerAppEndTime', 'metadata', 'extractedIssues', 'extractedFeedback'];
|
|
928
|
+
const projected: Json = {};
|
|
929
|
+
for (const c of columns) if (fresh[c] !== undefined) projected[c] = fresh[c];
|
|
930
|
+
return json(200, { status: 'completed', result: { job: projected, isComplete: true }, logs: [] });
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
/** POST /api/tester/jobs/:jobId/end — end-job.handler.ts */
|
|
934
|
+
async function endJob(req: RunhumanTwinRequest, jobId: string): Promise<RunhumanTwinResponse> {
|
|
935
|
+
const parsed = parseJson(req.body);
|
|
936
|
+
if (!parsed.ok) return parsed.response;
|
|
937
|
+
const invalid = validateBody(parsed.value, END_JOB_FIELDS);
|
|
938
|
+
if (invalid) return invalid;
|
|
939
|
+
const body = parsed.value as Json;
|
|
940
|
+
const action = String(body.action);
|
|
941
|
+
const note = String(body.note ?? '').trim();
|
|
942
|
+
if (!note) return json(400, { error: 'Bad Request', message: `A note is required for action "${action}"` });
|
|
943
|
+
const auth = await testerJob(req, jobId);
|
|
944
|
+
if (!auth.ok) return auth.response;
|
|
945
|
+
if (req.readOnly) return readOnlyRejection();
|
|
946
|
+
const job = auth.job;
|
|
947
|
+
if (job.status === 'overdue') {
|
|
948
|
+
return json(409, { error: 'Conflict', message: `Cannot end an overdue job. Use POST /api/tester/jobs/${jobId}/decline-late-submission to drop, or re-submit results.` });
|
|
949
|
+
}
|
|
950
|
+
if (job.status !== 'working') {
|
|
951
|
+
if (TERMINAL_STATUSES.has(String(job.status))) return json(200, { success: true, action, status: job.status, alreadyTerminal: true });
|
|
952
|
+
return json(409, { error: 'Conflict', message: `Cannot end job in status "${job.status}". Job must be in "working" status.` });
|
|
953
|
+
}
|
|
954
|
+
const at = now(req);
|
|
955
|
+
let next: Json;
|
|
956
|
+
if (action === 'clear_and_release') {
|
|
957
|
+
// job-release.service.ts resetJobToWaiting
|
|
958
|
+
const metadata = { ...((job.metadata ?? {}) as Json) };
|
|
959
|
+
delete metadata.testerToken;
|
|
960
|
+
next = { ...job, status: 'waiting', metadata, conversationHistory: [], extractionAttempts: 0, warningsSent: 0, lastActivity: at, updatedAt: at };
|
|
961
|
+
for (const c of ['testerProfileId', 'assignedTester', 'assignedTesterName', 'testerAlias', 'testerAvatarUrl', 'claimedAt', 'livekitRoomName', 'livekitEgressId', 'responseDeadline']) delete next[c];
|
|
962
|
+
} else if (action === 'platform_issue') {
|
|
963
|
+
next = { ...job, status: 'failed', modifier: 'platform_issue', error: note, lastActivity: at, completedAt: at, costUsd: 0, updatedAt: at };
|
|
964
|
+
} else {
|
|
965
|
+
// clear_and_cancel / save_and_cancel (the twin holds no captured evidence to wipe)
|
|
966
|
+
next = { ...job, status: 'cancelled', modifier: 'tester_cancellation', error: note, lastActivity: at, completedAt: at, costUsd: 0, updatedAt: at };
|
|
967
|
+
}
|
|
968
|
+
await put('job', jobId, next, `job.end.${action}`, req);
|
|
969
|
+
return json(200, { success: true, action, status: next.status });
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
// ── /_twin seed routes (never called by a real client) ───────────────────────────────────────
|
|
973
|
+
async function seed(req: RunhumanTwinRequest, kind: string): Promise<RunhumanTwinResponse> {
|
|
974
|
+
if (req.readOnly) return readOnlyRejection();
|
|
975
|
+
const parsed = parseJson(req.body);
|
|
976
|
+
if (!parsed.ok) return parsed.response;
|
|
977
|
+
const body = (parsed.value ?? {}) as Json;
|
|
978
|
+
const resources = projectResources(SERVICE, req.root);
|
|
979
|
+
const str = (v: unknown) => (typeof v === 'string' && v.length > 0 ? v : undefined);
|
|
980
|
+
const at = now(req);
|
|
981
|
+
// a seed makes a subject, never replaces one: a named id (or key) already held is refused, as a second sign-up is
|
|
982
|
+
const taken = (type: string, id: string | undefined): RunhumanTwinResponse | undefined =>
|
|
983
|
+
id !== undefined && docOf(type, id, req.root) ? json(409, { error: `${type} ${id} already exists` }) : undefined;
|
|
984
|
+
if (kind === 'organizations') {
|
|
985
|
+
const refused = taken('organization', str(body.id)); if (refused) return refused;
|
|
986
|
+
const id = str(body.id) ?? mintFor('organization', req, resources);
|
|
987
|
+
const org = stripUndefined({ id, name: str(body.name) ?? 'Twin Organization', ownerUserId: str(body.ownerUserId), isInternal: body.isInternal === true, hasActiveSubscription: body.hasActiveSubscription === true, createdAt: at });
|
|
988
|
+
await put('organization', id, org, 'seed.organization', req);
|
|
989
|
+
return json(201, org);
|
|
990
|
+
}
|
|
991
|
+
if (kind === 'projects') {
|
|
992
|
+
const organizationId = str(body.organizationId);
|
|
993
|
+
if (!organizationId || !docOf('organization', organizationId, req.root)) return json(404, { error: 'organization not found; seed it first via POST /_twin/organizations' });
|
|
994
|
+
const refused = taken('project', str(body.id)); if (refused) return refused;
|
|
995
|
+
const id = str(body.id) ?? mintFor('project', req, resources);
|
|
996
|
+
const project = stripUndefined({ id, organizationId, name: str(body.name) ?? 'Twin Project', defaultUrl: str(body.defaultUrl), createdAt: at });
|
|
997
|
+
await put('project', id, project, 'seed.project', req);
|
|
998
|
+
return json(201, project);
|
|
999
|
+
}
|
|
1000
|
+
if (kind === 'api-keys') {
|
|
1001
|
+
const organizationId = str(body.organizationId);
|
|
1002
|
+
if (!organizationId || !docOf('organization', organizationId, req.root)) return json(404, { error: 'organization not found; seed it first via POST /_twin/organizations' });
|
|
1003
|
+
// A twin-minted key is stored and handed back, so it is state (R9): derived from the world
|
|
1004
|
+
// instant and the keys already held, in RH1's `rh_<48 hex>` shape.
|
|
1005
|
+
const key = str(body.key) ?? `rh_${createHash('sha256').update(`runhuman-twin:api_key:${now(req)}:${resources.filter((r) => r.type === 'api_key').length}`).digest('hex').slice(0, 48)}`;
|
|
1006
|
+
if (!isApiKeyToken(key)) return json(400, { error: 'an RH1 API key starts with rh_ (not rh_pat_ / rh_svc_) or qa_live_' });
|
|
1007
|
+
const refused = taken('api_key', str(body.id)); if (refused) return refused;
|
|
1008
|
+
if (docsOf('api_key', req.root, resources).some((k) => k.key === key)) return json(409, { error: 'that API key already exists' });
|
|
1009
|
+
const id = str(body.id) ?? mintFor('api_key', req, resources);
|
|
1010
|
+
const apiKey = { id, key, organizationId, name: str(body.name) ?? 'twin key', isActive: true, createdAt: at };
|
|
1011
|
+
await put('api_key', id, apiKey, 'seed.api_key', req);
|
|
1012
|
+
return json(201, apiKey);
|
|
1013
|
+
}
|
|
1014
|
+
if (kind === 'testers') {
|
|
1015
|
+
const email = str(body.email);
|
|
1016
|
+
if (!email) return json(400, { error: 'email is required' });
|
|
1017
|
+
const existing = docsOf('user', req.root, resources).find((u) => u.email === email);
|
|
1018
|
+
const userId = String(existing?.id ?? str(body.userId) ?? mintFor('user', req, resources));
|
|
1019
|
+
const user = stripUndefined({ id: userId, email, clerkId: `clerk_${email}`, organizationIds: Array.isArray(body.organizationIds) ? body.organizationIds : [], createdAt: at });
|
|
1020
|
+
await put('user', userId, user, 'seed.user', req);
|
|
1021
|
+
const priorProfile = docsOf('tester_profile', req.root, resources).find((p) => p.userId === userId);
|
|
1022
|
+
const profileId = String(priorProfile?.id ?? mintFor('tester_profile', req, resources));
|
|
1023
|
+
const profile = stripUndefined({
|
|
1024
|
+
id: profileId, userId, alias: str(body.alias) ?? email.split('@')[0], status: str(body.status) ?? 'active', availability: str(body.availability) ?? 'online',
|
|
1025
|
+
devices: Array.isArray(body.devices) ? body.devices : ['pc', 'mac'], languages: Array.isArray(body.languages) ? body.languages : ['english'], avatarUrl: str(body.avatarUrl), createdAt: at,
|
|
1026
|
+
});
|
|
1027
|
+
await put('tester_profile', profileId, profile, 'seed.tester_profile', req);
|
|
1028
|
+
return json(201, { user, testerProfile: profile, authorization: `Bearer clerk_${email}` });
|
|
1029
|
+
}
|
|
1030
|
+
if (kind === 'settings') {
|
|
1031
|
+
// RH1 signs testerTokens with its deployment's JWT_SECRET (utils/jwt.util.ts): operator
|
|
1032
|
+
// configuration, not API state. A world mints its own from entropy on first use (jwtSecret);
|
|
1033
|
+
// a caller that must name a tester token ahead of time — a scripted replay — seeds the
|
|
1034
|
+
// secret before anything signs with it. Once a world signs with one secret it keeps it:
|
|
1035
|
+
// swapping it would silently void every live testerToken.
|
|
1036
|
+
const secret = str(body.jwtSecret);
|
|
1037
|
+
if (!secret) return json(400, { error: 'jwtSecret is required' });
|
|
1038
|
+
const { value } = await applyTwinWriteAtomic<RunhumanTwinResponse>(SERVICE, (held) => {
|
|
1039
|
+
const existing = held.find((r) => r.type === 'setting' && r.id === 'setting:jwt_secret')?.doc as Json | undefined;
|
|
1040
|
+
if (typeof existing?.secret === 'string') {
|
|
1041
|
+
return { kind: 'skip', value: existing.secret === secret ? json(200, { setting: 'jwt_secret' }) : json(409, { error: 'this world already signs tester tokens with another jwtSecret' }) };
|
|
1042
|
+
}
|
|
1043
|
+
return { kind: 'write', value: json(201, { setting: 'jwt_secret' }), write: { operation: 'seed.jwt_secret', subjectType: 'setting', subjectId: 'setting:jwt_secret', fields: { doc: { secret } }, ...(req.occurredAt ? { occurredAt: req.occurredAt } : {}), actor: { kind: 'system' } } };
|
|
1044
|
+
}, req.root);
|
|
1045
|
+
return value;
|
|
1046
|
+
}
|
|
1047
|
+
return json(404, { error: 'unknown seed; one of organizations, projects, api-keys, testers, settings' });
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
// ── router ───────────────────────────────────────────────────────────────────────────────────
|
|
1051
|
+
export async function handleRunhumanTwinRequest(req: RunhumanTwinRequest): Promise<RunhumanTwinResponse> {
|
|
1052
|
+
const url = new URL(req.path, 'http://twin.local');
|
|
1053
|
+
const path = url.pathname.replace(/\/+$/, '') || '/';
|
|
1054
|
+
const seg = path.split('/').filter(Boolean).map((s) => decodeURIComponent(s));
|
|
1055
|
+
const method = req.method.toUpperCase();
|
|
1056
|
+
|
|
1057
|
+
if (seg[0] === '_twin' && seg.length === 2 && method === 'POST') return seed(req, seg[1]!);
|
|
1058
|
+
if (seg[0] !== 'api') return notFoundRoute();
|
|
1059
|
+
const [, a, b, c, d, e] = seg;
|
|
1060
|
+
|
|
1061
|
+
if (a === 'jobs') {
|
|
1062
|
+
if (seg.length === 2 && method === 'POST') return createJob(req);
|
|
1063
|
+
if (seg.length === 3 && method === 'GET') return readJob(req, b!, 'details');
|
|
1064
|
+
if (seg.length === 4 && c === 'status' && method === 'GET') return readJob(req, b!, 'status');
|
|
1065
|
+
if (seg.length === 4 && c === 'claim' && method === 'POST') return claimJob(req, b!);
|
|
1066
|
+
}
|
|
1067
|
+
if (a === 'projects' && seg.length === 4 && c === 'jobs' && method === 'GET') return listProjectJobs(req, b!, url.searchParams);
|
|
1068
|
+
if (a === 'tester' && b === 'jobs' && c) {
|
|
1069
|
+
if (seg.length === 4 && method === 'GET') return getTesterJob(req, c);
|
|
1070
|
+
if (seg.length === 4 && method === 'PATCH') return patchTesterJob(req, c);
|
|
1071
|
+
if (seg.length === 5 && d === 'process-results' && method === 'POST') return processResults(req, c);
|
|
1072
|
+
if (seg.length === 6 && d === 'processing' && method === 'GET') return processingStatus(req, c, e!);
|
|
1073
|
+
if (seg.length === 5 && d === 'issue-review' && method === 'POST') return issueReview(req, c);
|
|
1074
|
+
if (seg.length === 5 && d === 'end' && method === 'POST') return endJob(req, c);
|
|
1075
|
+
}
|
|
1076
|
+
// Every other /api route — real in RH1 or not — answers RH1's JSON 404 from this twin.
|
|
1077
|
+
return { status: 404, body: { error: 'Not found' }, headers: { 'x-twin-gap': `runhuman twin does not model ${method} ${path}` } };
|
|
1078
|
+
}
|