@melaya/runner 1.0.92 → 1.0.94

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.
@@ -205,13 +205,19 @@ def _build_agent():
205
205
  "TikTok). Opening the wrong app is a FAILED task, not a reasonable default. "
206
206
  "After the user authorizes the app, phone_list_apps reflects it immediately — "
207
207
  "re-check and open the REQUESTED app.\n"
208
- "- You are FULLY AUTONOMOUS on the phone: NEVER ask the user a question, "
209
- "never end your turn asking for confirmation or offering a choice make the "
210
- "reasonable decision and DO it (the ONE exception is the EXACT-APP rule above: "
211
- "a missing app is a stop, not a substitution). If a feed is algorithmic, use "
212
- "the app's own Search / Explore to find the right content yourself. Keep going "
213
- "until the task is COMPLETE (e.g. all N comments posted) or you are genuinely "
214
- "blocked; only then report what you did and what (if anything) is blocked.\n"
208
+ "- You are AUTONOMOUS on the phone: for routine choices, make the reasonable "
209
+ "decision and DO it never stall the run by ENDING YOUR TURN to ask in chat. "
210
+ "But when you hit a GENUINE fork you cannot reasonably resolve on your own (which "
211
+ "of several accounts to act as, a truly ambiguous target, a risky irreversible "
212
+ "step), or you want a go/no-go before continuing a long task, call "
213
+ "phone_ask_user(question): it expands the Melaya tile ON THE PHONE into a reply "
214
+ "card and BLOCKS for the user's typed answer WITHOUT ending your turn, so the run "
215
+ "continues seamlessly the moment they reply. Use it SPARINGLY, for real decisions "
216
+ "only — not routine ones. The EXACT-APP rule still holds (a missing app is a stop, "
217
+ "not a substitution). If a feed is algorithmic, use the app's own Search / Explore "
218
+ "to find the right content yourself. Keep going until the task is COMPLETE (e.g. "
219
+ "all N comments posted) or you are genuinely blocked; only then report what you "
220
+ "did and what (if anything) is blocked.\n"
215
221
  if phone_enabled else ""
216
222
  )
217
223
  connector_rule = (
@@ -246,13 +252,16 @@ def _build_agent():
246
252
  model_name=model,
247
253
  provider=provider,
248
254
  api_key="", # model.py resolves the provider's own creds (OAuth off disk / localhost)
249
- # Phone tasks are many-step (each comment screen_tree→tap→type→post), so
250
- # give them room like the device template (40); read-only Q&A needs few.
251
- # Connector WRITE tasks are equally many-step (e.g. "message all my recent
252
- # LinkedIn connections" = 1 list + N sends, each its own reasoning+HITL
253
- # round), so they need the same headroom 8 hit the cap mid-task and
254
- # forced _summarizing() on a half-finished tool batch.
255
- max_iters=40 if (phone_enabled or connector_services) else 8,
255
+ # Phone tasks that act on N items ("like + comment on 10 posts") take
256
+ # ~10-15 tool calls EACH (screen_tree→read→tap→type→post_comment→verify→
257
+ # swipe to the next), so a flat 40 capped a 10-post run at ~3 done and
258
+ # forced a mid-task stop which also ends the turn and tears down the
259
+ # phone working-overlay, snapping the phone back to the Melaya app before
260
+ # the task is finished. Give phone runs real headroom (they are HITL-gated,
261
+ # so each write still needs the user's tap — cost stays bounded). Connector
262
+ # WRITE fan-outs (1 list + N sends, each its own reasoning+HITL round) keep
263
+ # the 40 that fits them; read-only Q&A needs very few.
264
+ max_iters=100 if phone_enabled else (40 if connector_services else 8),
256
265
  reliability=True,
257
266
  bounded_memory=True,
258
267
  )
@@ -32,9 +32,22 @@
32
32
  * port, mints a per-session token, and exposes both via env vars
33
33
  * `MEL_LUMA_BROWSER_URL` + `MEL_LUMA_BROWSER_TOKEN` to the spawned
34
34
  * Python pipeline subprocess.
35
- * 3. Python's `luma_register_event` POSTs the registration body to
36
- * `${MEL_LUMA_BROWSER_URL}/luma/forward` with
37
- * `Authorization: Bearer ${MEL_LUMA_BROWSER_TOKEN}`. The bridge:
35
+ * 3. Two endpoints:
36
+ *
37
+ * POST /luma/register (PREFERRED, Option A — UI-driven)
38
+ * Python's `luma_register_event` posts `{ eventApiId, answers,
39
+ * ticketPreference }`. The bridge opens the REAL event page, installs a
40
+ * response interceptor on Luma's own register XHR, clicks the
41
+ * Register/RSVP button, fills required custom questions + ticket, submits,
42
+ * and returns Luma's REAL request body + endpoint + response. This lets
43
+ * Luma's frontend build the correct wire schema (whatever the current
44
+ * release wants) and handles fresh / invited / ticket / approval flows
45
+ * uniformly — no hand-built body. If the operator already has a spot the
46
+ * page shows a manage state and we return `pageState: already_registered`
47
+ * instead of hunting for a Register button.
48
+ *
49
+ * POST /luma/forward (LEGACY fallback — hand-built body replay)
50
+ * Python posts a full `{ url, method, headers, body }`. The bridge:
38
51
  * a. Spawns / reuses a headless Chromium with the saved storage
39
52
  * state, freshly reloaded from disk so a recent reconnect's
40
53
  * cookies are picked up immediately.
@@ -52,9 +65,10 @@
52
65
  * processes on the same machine can't fire requests without it
53
66
  * (the env var is only injected into the runner-spawned pipeline
54
67
  * subprocess).
55
- * - Endpoint is narrow: the URL must be a luma.com / api.luma.com /
56
- * api2.luma.com / lu.ma origin and the method must be GET / POST.
57
- * Anything else is rejected with 400.
68
+ * - Endpoints are narrow: /luma/forward requires a luma.com / api.luma.com /
69
+ * api2.luma.com / lu.ma origin URL with a GET/POST method; /luma/register
70
+ * requires an evt-… id and only ever opens a luma.com event page. Anything
71
+ * else is rejected with 400.
58
72
  * - Body size is capped at 64 KB.
59
73
  *
60
74
  * Cleanup
@@ -32,9 +32,22 @@
32
32
  * port, mints a per-session token, and exposes both via env vars
33
33
  * `MEL_LUMA_BROWSER_URL` + `MEL_LUMA_BROWSER_TOKEN` to the spawned
34
34
  * Python pipeline subprocess.
35
- * 3. Python's `luma_register_event` POSTs the registration body to
36
- * `${MEL_LUMA_BROWSER_URL}/luma/forward` with
37
- * `Authorization: Bearer ${MEL_LUMA_BROWSER_TOKEN}`. The bridge:
35
+ * 3. Two endpoints:
36
+ *
37
+ * POST /luma/register (PREFERRED, Option A — UI-driven)
38
+ * Python's `luma_register_event` posts `{ eventApiId, answers,
39
+ * ticketPreference }`. The bridge opens the REAL event page, installs a
40
+ * response interceptor on Luma's own register XHR, clicks the
41
+ * Register/RSVP button, fills required custom questions + ticket, submits,
42
+ * and returns Luma's REAL request body + endpoint + response. This lets
43
+ * Luma's frontend build the correct wire schema (whatever the current
44
+ * release wants) and handles fresh / invited / ticket / approval flows
45
+ * uniformly — no hand-built body. If the operator already has a spot the
46
+ * page shows a manage state and we return `pageState: already_registered`
47
+ * instead of hunting for a Register button.
48
+ *
49
+ * POST /luma/forward (LEGACY fallback — hand-built body replay)
50
+ * Python posts a full `{ url, method, headers, body }`. The bridge:
38
51
  * a. Spawns / reuses a headless Chromium with the saved storage
39
52
  * state, freshly reloaded from disk so a recent reconnect's
40
53
  * cookies are picked up immediately.
@@ -52,9 +65,10 @@
52
65
  * processes on the same machine can't fire requests without it
53
66
  * (the env var is only injected into the runner-spawned pipeline
54
67
  * subprocess).
55
- * - Endpoint is narrow: the URL must be a luma.com / api.luma.com /
56
- * api2.luma.com / lu.ma origin and the method must be GET / POST.
57
- * Anything else is rejected with 400.
68
+ * - Endpoints are narrow: /luma/forward requires a luma.com / api.luma.com /
69
+ * api2.luma.com / lu.ma origin URL with a GET/POST method; /luma/register
70
+ * requires an evt-… id and only ever opens a luma.com event page. Anything
71
+ * else is rejected with 400.
58
72
  * - Body size is capped at 64 KB.
59
73
  *
60
74
  * Cleanup
@@ -90,6 +104,176 @@ function _stateDir() {
90
104
  export function lumaStorageStatePath() {
91
105
  return path.join(_stateDir(), "storage-state.json");
92
106
  }
107
+ /** Detect the "you already have a spot" state. Luma shows a manage / "You're
108
+ * In" / "Request Sent" / "On the Waitlist" panel instead of a Register button
109
+ * for guests it already knows. Returns a short state label or "" if fresh. */
110
+ async function _detectAlreadyRegistered(page) {
111
+ try {
112
+ return await page.evaluate(() => {
113
+ const txt = (document.body.innerText || "").toLowerCase();
114
+ // Order matters: more specific first.
115
+ if (/pending approval|request sent|awaiting approval|pending review/.test(txt))
116
+ return "approval_pending";
117
+ if (/on the waitlist|you.?re on the waitlist|waitlist.?ed/.test(txt))
118
+ return "waitlisted";
119
+ if (/you.?re in|you are in|you.?re going|manage registration|change registration|cancel registration|edit registration/.test(txt))
120
+ return "already_registered";
121
+ return "";
122
+ });
123
+ }
124
+ catch {
125
+ return "";
126
+ }
127
+ }
128
+ /** Click the primary Register / RSVP / Join button. Returns true on click. */
129
+ async function _clickRegisterButton(page) {
130
+ // 1) data-testid / known attribute hooks first (most stable).
131
+ const testidSelectors = [
132
+ '[data-testid="register-button"]',
133
+ '[data-testid="rsvp-button"]',
134
+ 'button[data-luma-action="register"]',
135
+ ];
136
+ for (const sel of testidSelectors) {
137
+ try {
138
+ const el = page.locator(sel).first();
139
+ if (await el.count() && await el.isVisible()) {
140
+ await el.click({ timeout: 4000 });
141
+ return true;
142
+ }
143
+ }
144
+ catch { /* next */ }
145
+ }
146
+ // 2) button/anchor text — try each verb. getByRole with an accessible-name
147
+ // regex catches "Register", "RSVP", "Join", "One-Click Register", "Get
148
+ // Ticket", "Request to Join" without over-matching "Register a friend".
149
+ const nameRx = /^(register|rsvp|join|one[- ]?click register|get ticket|request to join|reserve( a)? spot)/i;
150
+ for (const role of ["button", "link"]) {
151
+ try {
152
+ const el = page.getByRole(role, { name: nameRx }).first();
153
+ if (await el.count() && await el.isVisible()) {
154
+ await el.click({ timeout: 4000 });
155
+ return true;
156
+ }
157
+ }
158
+ catch { /* next */ }
159
+ }
160
+ // 3) last resort: any visible button whose text starts with a register verb.
161
+ try {
162
+ const btn = page.locator("button, a[role='button']").filter({ hasText: nameRx }).first();
163
+ if (await btn.count() && await btn.isVisible()) {
164
+ await btn.click({ timeout: 4000 });
165
+ return true;
166
+ }
167
+ }
168
+ catch { /* give up */ }
169
+ return false;
170
+ }
171
+ /** Select a ticket when the event offers a choice. Best-effort: if a preferred
172
+ * ticket name/id is given, click the row containing it; otherwise pick the
173
+ * first free ("Free"/$0) ticket, else the first ticket. No-op when there's no
174
+ * ticket selector (plain free RSVP). */
175
+ async function _selectTicketIfPresent(page, preference) {
176
+ try {
177
+ // Luma renders ticket options as clickable rows/radios. We look for a
178
+ // container that mentions "ticket" then click a matching option.
179
+ if (preference) {
180
+ const pref = page.getByText(preference, { exact: false }).first();
181
+ if (await pref.count() && await pref.isVisible()) {
182
+ await pref.click({ timeout: 2500 });
183
+ return;
184
+ }
185
+ }
186
+ // Prefer a free ticket.
187
+ const free = page.locator("[class*='ticket'], [data-testid*='ticket']")
188
+ .filter({ hasText: /free|\$0(\.00)?/i }).first();
189
+ if (await free.count() && await free.isVisible()) {
190
+ await free.click({ timeout: 2500 });
191
+ return;
192
+ }
193
+ }
194
+ catch { /* no ticket selector — plain RSVP */ }
195
+ }
196
+ /** Fill required custom questions in the registration modal from the provided
197
+ * answers map, matched by visible label. Handles text inputs, textareas, and
198
+ * select dropdowns. Checkboxes labelled with agreement text are left to the
199
+ * operator's profile-driven answers (passed in `answers` as "yes"). */
200
+ async function _fillCustomQuestions(page, answers) {
201
+ for (const [label, rawVal] of Object.entries(answers)) {
202
+ const val = rawVal == null ? "" : String(rawVal);
203
+ if (!val)
204
+ continue;
205
+ const labelRx = new RegExp(label.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "i");
206
+ try {
207
+ // Try an input/textarea associated with a label matching the question.
208
+ const field = page.getByLabel(labelRx).first();
209
+ if (await field.count() && await field.isVisible()) {
210
+ const tag = await field.evaluate((el) => el.tagName.toLowerCase()).catch(() => "");
211
+ if (tag === "select") {
212
+ await field.selectOption({ label: val }).catch(async () => {
213
+ await field.selectOption(val).catch(() => { });
214
+ });
215
+ }
216
+ else if (val.toLowerCase() === "yes") {
217
+ await field.check({ timeout: 2000 }).catch(() => field.click({ timeout: 2000 }).catch(() => { }));
218
+ }
219
+ else {
220
+ await field.fill(val, { timeout: 2500 }).catch(() => { });
221
+ }
222
+ continue;
223
+ }
224
+ }
225
+ catch { /* fall through to placeholder match */ }
226
+ // Fallback: match by placeholder text.
227
+ try {
228
+ const ph = page.getByPlaceholder(labelRx).first();
229
+ if (await ph.count() && await ph.isVisible()) {
230
+ await ph.fill(val, { timeout: 2500 }).catch(() => { });
231
+ }
232
+ }
233
+ catch { /* skip this question — submit will surface if it was required */ }
234
+ }
235
+ }
236
+ /** Click the modal's final submit/confirm button. Distinct from the initial
237
+ * Register button: the modal's CTA is usually "Register" / "Confirm" / "Get
238
+ * Ticket" / "Request to Join". Idempotent — safe if there's no modal. */
239
+ async function _submitRegistration(page) {
240
+ const nameRx = /^(register|confirm|rsvp|get ticket|request to join|submit|one[- ]?click register|reserve)/i;
241
+ // Prefer a submit inside a dialog/modal so we don't re-click the page's
242
+ // primary button (which may just re-open the modal).
243
+ const scopes = [
244
+ page.getByRole("dialog"),
245
+ page.locator("[role='dialog'], [class*='modal'], [class*='Modal']"),
246
+ page, // whole page fallback
247
+ ];
248
+ for (const scope of scopes) {
249
+ try {
250
+ const anyScope = scope;
251
+ const btn = (typeof anyScope.getByRole === "function"
252
+ ? anyScope.getByRole("button", { name: nameRx })
253
+ : page.getByRole("button", { name: nameRx })).first();
254
+ if (await btn.count() && await btn.isVisible()) {
255
+ await btn.click({ timeout: 4000 });
256
+ return;
257
+ }
258
+ }
259
+ catch { /* next scope */ }
260
+ }
261
+ }
262
+ /** Screenshot to base64 PNG, capped, best-effort. Returns "" on failure. */
263
+ async function _safeScreenshot(page) {
264
+ try {
265
+ const buf = await page.screenshot({ type: "png", fullPage: false });
266
+ // Cap at ~1.5MB base64 to stay well under the bridge's 64KB *request*
267
+ // limit is irrelevant here (this is a RESPONSE); but keep it bounded so a
268
+ // huge PNG doesn't bloat the trace. Truncate rather than drop so the
269
+ // operator still gets a partial visual.
270
+ const b64 = buf.toString("base64");
271
+ return b64.length > 1_500_000 ? b64.slice(0, 1_500_000) : b64;
272
+ }
273
+ catch {
274
+ return "";
275
+ }
276
+ }
93
277
  // ── Public starter ──────────────────────────────────────────────────────────
94
278
  /**
95
279
  * Starts the bridge if a saved Luma storage state exists. Returns null
@@ -237,6 +421,165 @@ export async function startLumaBrowserBridge(opts) {
237
421
  }
238
422
  }
239
423
  }
424
+ // ── UI-driven register (Option A) ──────────────────────────────────────────
425
+ //
426
+ // Open the real event page, install a response interceptor on Luma's own
427
+ // register XHR, then click the Register/RSVP button and fill the form. This
428
+ // lets Luma's frontend build the CORRECT wire body (whatever the current
429
+ // release's schema is) and clears Cloudflare (cf_bm refreshes on-page, TLS
430
+ // is Chrome's). We return the intercepted request body + endpoint + upstream
431
+ // response verbatim.
432
+ async function _register(req) {
433
+ const eid = String(req.eventApiId || "").trim();
434
+ if (!eid.startsWith("evt-")) {
435
+ return { ok: false, status: 0, body: "", error: `invalid_event_api_id: ${eid}` };
436
+ }
437
+ const eventUrl = req.eventUrl && /^https?:\/\//.test(req.eventUrl)
438
+ ? req.eventUrl
439
+ : `https://luma.com/${eid}`;
440
+ try {
441
+ const u = new URL(eventUrl);
442
+ if (!ALLOWED_HOSTNAMES.has(u.hostname)) {
443
+ return { ok: false, status: 0, body: "", error: `host_not_allowed: ${u.hostname}` };
444
+ }
445
+ }
446
+ catch {
447
+ return { ok: false, status: 0, body: "", error: "invalid_event_url" };
448
+ }
449
+ const b = await _ensureBrowser();
450
+ let ctx = null;
451
+ try {
452
+ const storageState = await _readStorageState();
453
+ ctx = await b.newContext({
454
+ storageState: storageState,
455
+ userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " +
456
+ "(KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36",
457
+ viewport: { width: 1280, height: 900 },
458
+ });
459
+ const page = await ctx.newPage();
460
+ // Response interceptor: capture the FIRST register/RSVP XHR Luma fires.
461
+ // We match on the URL path so we catch whichever surface the current
462
+ // release uses (/event/register, /event/independent-register-for-event,
463
+ // or a future rename that still contains "register").
464
+ let capturedStatus = 0;
465
+ let capturedBody = "";
466
+ let capturedEndpoint = "";
467
+ let capturedRequestBody = "";
468
+ const REGISTER_PATH_RX = /\/event\/[a-z-]*register[a-z-]*/i;
469
+ page.on("request", (request) => {
470
+ try {
471
+ const url = request.url();
472
+ if (request.method() === "POST" && REGISTER_PATH_RX.test(new URL(url).pathname)) {
473
+ if (!capturedRequestBody) {
474
+ capturedEndpoint = new URL(url).pathname;
475
+ capturedRequestBody = request.postData() || "";
476
+ }
477
+ }
478
+ }
479
+ catch { /* ignore */ }
480
+ });
481
+ page.on("response", async (response) => {
482
+ try {
483
+ const url = response.url();
484
+ if (response.request().method() === "POST" && REGISTER_PATH_RX.test(new URL(url).pathname)) {
485
+ // First register response wins.
486
+ if (!capturedStatus) {
487
+ capturedStatus = response.status();
488
+ try {
489
+ capturedBody = await response.text();
490
+ }
491
+ catch {
492
+ capturedBody = "";
493
+ }
494
+ if (!capturedEndpoint)
495
+ capturedEndpoint = new URL(url).pathname;
496
+ }
497
+ }
498
+ }
499
+ catch { /* ignore */ }
500
+ });
501
+ try {
502
+ await page.goto(eventUrl, {
503
+ waitUntil: "domcontentloaded",
504
+ timeout: req.navigationTimeoutMs || PAGE_GOTO_TIMEOUT_MS,
505
+ });
506
+ }
507
+ catch (e) {
508
+ opts.log(`[luma-bridge] register goto failed (${e?.message || e}).`);
509
+ }
510
+ await page.waitForTimeout(PAGE_SETTLE_MS);
511
+ // If the operator already has a spot, Luma shows a "You're In" / manage
512
+ // state instead of a Register button. Detect it up-front so we don't
513
+ // hunt for a button that isn't there.
514
+ const alreadyState = await _detectAlreadyRegistered(page);
515
+ if (alreadyState) {
516
+ return {
517
+ ok: true, status: 0, body: "", pageState: alreadyState,
518
+ capturedEndpoint, capturedRequestBody,
519
+ };
520
+ }
521
+ // Click the Register / RSVP / Join button. Resilient to Luma's copy +
522
+ // markup drift: try data-testids, then a set of button texts, then any
523
+ // button whose accessible name contains a register-ish verb.
524
+ const clicked = await _clickRegisterButton(page);
525
+ if (!clicked) {
526
+ const shot = await _safeScreenshot(page);
527
+ return {
528
+ ok: false, status: 0, body: "",
529
+ pageState: "no_register_button",
530
+ screenshotB64: shot,
531
+ error: "could not locate a Register/RSVP button on the event page",
532
+ };
533
+ }
534
+ // A registration modal may appear with required custom questions and/or a
535
+ // ticket selector. Fill what we can, then submit. Best-effort — Luma's own
536
+ // client-side validation will block submit if something required is empty,
537
+ // and we surface that as a precise error rather than a silent 400.
538
+ await page.waitForTimeout(800);
539
+ await _selectTicketIfPresent(page, req.ticketPreference);
540
+ await _fillCustomQuestions(page, req.answers || {});
541
+ await _submitRegistration(page);
542
+ // Wait for the register XHR to fire + resolve. Poll up to ~15s.
543
+ const deadline = Date.now() + 15_000;
544
+ while (Date.now() < deadline && !capturedStatus) {
545
+ await page.waitForTimeout(300);
546
+ }
547
+ if (capturedStatus) {
548
+ return {
549
+ ok: true,
550
+ status: capturedStatus,
551
+ body: capturedBody,
552
+ capturedEndpoint,
553
+ capturedRequestBody,
554
+ };
555
+ }
556
+ // No register XHR observed. Re-check for an "already in" state the submit
557
+ // may have produced, else return a diagnosable failure with a screenshot.
558
+ const postState = await _detectAlreadyRegistered(page);
559
+ if (postState) {
560
+ return { ok: true, status: 0, body: "", pageState: postState, capturedEndpoint, capturedRequestBody };
561
+ }
562
+ const shot = await _safeScreenshot(page);
563
+ return {
564
+ ok: false, status: 0, body: "",
565
+ pageState: "no_register_xhr",
566
+ capturedEndpoint, capturedRequestBody,
567
+ screenshotB64: shot,
568
+ error: "clicked register but no register XHR fired (form may have unmet required fields)",
569
+ };
570
+ }
571
+ catch (e) {
572
+ return { ok: false, status: 0, body: "", error: `register_bridge_error: ${e?.message || e}` };
573
+ }
574
+ finally {
575
+ if (ctx) {
576
+ try {
577
+ await ctx.close();
578
+ }
579
+ catch { /* ignore */ }
580
+ }
581
+ }
582
+ }
240
583
  // ── HTTP server ──────────────────────────────────────────────────────────
241
584
  const server = createServer(async (req, res) => {
242
585
  res.setHeader("X-Frame-Options", "DENY");
@@ -256,7 +599,8 @@ export async function startLumaBrowserBridge(opts) {
256
599
  res.end(JSON.stringify({ ok: false, error: "unauthorized" }));
257
600
  return;
258
601
  }
259
- if (req.url !== "/luma/forward") {
602
+ const route = req.url || "";
603
+ if (route !== "/luma/forward" && route !== "/luma/register") {
260
604
  res.statusCode = 404;
261
605
  res.end(JSON.stringify({ ok: false, error: "not_found" }));
262
606
  return;
@@ -287,12 +631,32 @@ export async function startLumaBrowserBridge(opts) {
287
631
  res.end(JSON.stringify({ ok: false, error: "invalid_json" }));
288
632
  return;
289
633
  }
290
- if (!payload || typeof payload !== "object" || !payload.url) {
634
+ if (!payload || typeof payload !== "object") {
635
+ res.statusCode = 400;
636
+ res.end(JSON.stringify({ ok: false, error: "invalid_payload" }));
637
+ return;
638
+ }
639
+ // Route to the UI-driven register path (Option A) or the legacy forward.
640
+ if (route === "/luma/register") {
641
+ const rreq = payload;
642
+ if (!rreq.eventApiId) {
643
+ res.statusCode = 400;
644
+ res.end(JSON.stringify({ ok: false, error: "missing_eventApiId" }));
645
+ return;
646
+ }
647
+ const rout = await _register(rreq);
648
+ res.statusCode = 200;
649
+ res.setHeader("Content-Type", "application/json");
650
+ res.end(JSON.stringify(rout));
651
+ return;
652
+ }
653
+ const freq = payload;
654
+ if (!freq.url) {
291
655
  res.statusCode = 400;
292
656
  res.end(JSON.stringify({ ok: false, error: "missing_url" }));
293
657
  return;
294
658
  }
295
- const out = await _forward(payload);
659
+ const out = await _forward(freq);
296
660
  res.statusCode = 200;
297
661
  res.setHeader("Content-Type", "application/json");
298
662
  res.end(JSON.stringify(out));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@melaya/runner",
3
- "version": "1.0.92",
3
+ "version": "1.0.94",
4
4
  "description": "Run Melaya AI pipelines locally with your own LM Studio or Ollama models",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,