@delexec/ops 0.1.4 → 0.1.5

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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/cli.js +254 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@delexec/ops",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
4
4
  "description": "Unified operator CLI for delegated execution clients",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli.js CHANGED
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env node
2
+ import crypto from "node:crypto";
2
3
  import { execFile, spawn } from "node:child_process";
3
4
  import fs from "node:fs";
4
5
  import path from "node:path";
@@ -14,6 +15,7 @@ import {
14
15
  ensureOpsState,
15
16
  ensureResponderIdentity,
16
17
  loadHotlineRegistrationDraft,
18
+ readResolvedOpsSecrets,
17
19
  removeHotline,
18
20
  saveOpsState,
19
21
  setHotlineEnabled,
@@ -33,6 +35,8 @@ const FIXED_PRICE_MODEL = "fixed_price";
33
35
  const DEFAULT_PRICING_CURRENCY = "PTS";
34
36
  const DEFAULT_TRUST_TIER = "untrusted";
35
37
  const TRUST_TIERS = Object.freeze(["untrusted", "trusted", "verified"]);
38
+ const DEFAULT_CALL_HOTLINE_TIMEOUT_MS = 60000;
39
+ const DEFAULT_CALL_HOTLINE_POLL_INTERVAL_MS = 1000;
36
40
 
37
41
  function getOpsSessionFile() {
38
42
  return path.join(ensureOpsDirectories(), "run", "session.json");
@@ -47,6 +51,7 @@ function usage() {
47
51
  delexec-ops ui start [--host <host>] [--port <port>] [--open] [--no-browser]
48
52
  delexec-ops mcp spec
49
53
  delexec-ops auth register --email <email> [--local] [--platform <url>]
54
+ delexec-ops call-hotline --platform <url> --hotline-id <id> --responder-id <id> [--text <text> | --payload-json <json>] [--request-id <id>] [--max-charge-cents <amount>]
50
55
  delexec-ops enable-responder [--responder-id <id>] [--display-name <name>]
51
56
  delexec-ops add-hotline --type <process|http> --hotline-id <id> [--cmd <command> | --url <url>] [--cwd <path>] [--env KEY=VALUE] [--fixed-price-cents <amount>] [--currency <code>] [--billing-disclosure-url <url>]
52
57
  delexec-ops attach-project --project-path <path> [--project-name <name>] [--project-description <text>] [--hotline-id <id>] [--cmd <command> | --url <url>] [--cwd <path>] [--env KEY=VALUE] [--task-type <type>] [--capability <capability>]
@@ -266,6 +271,118 @@ function parseOptionalNonNegativeInteger(value, fieldName) {
266
271
  return parsed;
267
272
  }
268
273
 
274
+ function parsePositiveInteger(value, fieldName, fallback) {
275
+ if (value === undefined || value === null || value === false || value === "") {
276
+ return fallback;
277
+ }
278
+ const parsed = Number(value);
279
+ if (!Number.isSafeInteger(parsed) || parsed <= 0) {
280
+ throw new Error(`${fieldName}_must_be_positive_integer`);
281
+ }
282
+ return parsed;
283
+ }
284
+
285
+ function requireStringArg(args, key) {
286
+ const value = String(args[key] || "").trim();
287
+ if (!value) {
288
+ throw new Error(`${key.replace(/-/g, "_")}_required`);
289
+ }
290
+ return value;
291
+ }
292
+
293
+ function parsePayloadArg(args = {}) {
294
+ if (args["payload-json"] !== undefined && args["payload-json"] !== false) {
295
+ try {
296
+ const parsed = JSON.parse(String(args["payload-json"]));
297
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
298
+ throw new Error("payload_json_must_be_object");
299
+ }
300
+ return parsed;
301
+ } catch (error) {
302
+ if (error instanceof Error && error.message === "payload_json_must_be_object") {
303
+ throw error;
304
+ }
305
+ throw new Error("payload_json_invalid");
306
+ }
307
+ }
308
+ return {
309
+ text: String(args.text || "Run this paid hotline request.").trim()
310
+ };
311
+ }
312
+
313
+ function resolveCallerApiKey(state) {
314
+ const secrets = readResolvedOpsSecrets(state);
315
+ const apiKey =
316
+ secrets.caller_api_key ||
317
+ state.config.caller?.api_key ||
318
+ state.env.CALLER_PLATFORM_API_KEY ||
319
+ state.env.PLATFORM_API_KEY ||
320
+ process.env.CALLER_PLATFORM_API_KEY ||
321
+ process.env.PLATFORM_API_KEY ||
322
+ null;
323
+ if (!apiKey) {
324
+ throw new Error("caller_platform_api_key_required");
325
+ }
326
+ return apiKey;
327
+ }
328
+
329
+ function buildBearerHeaders(apiKey) {
330
+ return { Authorization: `Bearer ${apiKey}` };
331
+ }
332
+
333
+ function normalizeBaseUrl(value, fieldName) {
334
+ const raw = String(value || "").trim();
335
+ if (!raw) {
336
+ throw new Error(`${fieldName}_required`);
337
+ }
338
+ try {
339
+ return new URL(raw).toString().replace(/\/+$/, "");
340
+ } catch {
341
+ throw new Error(`${fieldName}_invalid`);
342
+ }
343
+ }
344
+
345
+ function deriveRelayBaseUrl(platformUrl, args = {}, state = null) {
346
+ const explicit = String(args.relay || "").trim();
347
+ if (explicit) {
348
+ return normalizeBaseUrl(explicit, "relay");
349
+ }
350
+ const envRelay =
351
+ process.env.TRANSPORT_BASE_URL ||
352
+ state?.env?.TRANSPORT_BASE_URL ||
353
+ state?.config?.runtime?.transport?.relay_http?.base_url ||
354
+ "";
355
+ if (envRelay) {
356
+ return normalizeBaseUrl(envRelay, "relay");
357
+ }
358
+ try {
359
+ const url = new URL(platformUrl);
360
+ if (url.hostname === "callanything.xyz" && url.pathname.replace(/\/+$/, "") === "/platform") {
361
+ url.pathname = "/relay";
362
+ url.search = "";
363
+ url.hash = "";
364
+ return url.toString().replace(/\/+$/, "");
365
+ }
366
+ } catch {}
367
+ return null;
368
+ }
369
+
370
+ function buildResultDelivery(requestId) {
371
+ return {
372
+ kind: "relay_http",
373
+ address: `local://relay/caller-controller/${requestId}`
374
+ };
375
+ }
376
+
377
+ function ensureSuccess(response, expectedStatus, code) {
378
+ const expected = Array.isArray(expectedStatus) ? expectedStatus : [expectedStatus];
379
+ if (!expected.includes(response.status)) {
380
+ const upstreamCode = response.body?.error?.code || code;
381
+ throw new Error(`${code}:${response.status}:${upstreamCode}`);
382
+ }
383
+ return response.body;
384
+ }
385
+
269
386
  function parsePricingHint(args = {}) {
270
387
  const fixedPriceCents = parseOptionalNonNegativeInteger(args["fixed-price-cents"], "fixed_price_cents");
271
388
  if (fixedPriceCents === null) {
@@ -1181,6 +1298,139 @@ async function commandRunExample(args) {
1181
1298
  });
1182
1299
  }
1183
1300
 
1301
+ async function commandCallHotline(args) {
1302
+ const state = ensureOpsState();
1303
+ const platformUrl = normalizeBaseUrl(requireStringArg(args, "platform"), "platform");
1304
+ const hotlineId = requireStringArg(args, "hotline-id");
1305
+ const responderId = requireStringArg(args, "responder-id");
1306
+ const requestId = String(args["request-id"] || `req_${crypto.randomUUID()}`).trim();
1307
+ const maxChargeCents = parseOptionalNonNegativeInteger(args["max-charge-cents"], "max_charge_cents") ?? 500;
1308
+ const currency = String(args.currency || DEFAULT_PRICING_CURRENCY).trim() || DEFAULT_PRICING_CURRENCY;
1309
+ const trustTier = String(args["trust-tier"] || DEFAULT_TRUST_TIER).trim() || DEFAULT_TRUST_TIER;
1310
+ if (!TRUST_TIERS.includes(trustTier)) {
1311
+ throw new Error("trust_tier_unsupported");
1312
+ }
1313
+ const callerBaseUrl = normalizeBaseUrl(
1314
+ args["caller-base-url"] || `http://127.0.0.1:${process.env.OPS_PORT_CALLER || state.config.runtime.ports.caller || 8081}`,
1315
+ "caller_base_url"
1316
+ );
1317
+ const relayBaseUrl = deriveRelayBaseUrl(platformUrl, args, state);
1318
+ const pollIntervalMs = parsePositiveInteger(args["poll-interval-ms"], "poll_interval_ms", DEFAULT_CALL_HOTLINE_POLL_INTERVAL_MS);
1319
+ const timeoutMs = parsePositiveInteger(args["timeout-ms"], "timeout_ms", DEFAULT_CALL_HOTLINE_TIMEOUT_MS);
1320
+ const payload = parsePayloadArg(args);
1321
+ const apiKey = resolveCallerApiKey(state);
1322
+ const resultDelivery = buildResultDelivery(requestId);
1323
+ const billing = {
1324
+ acknowledged: true,
1325
+ pricing_model: FIXED_PRICE_MODEL,
1326
+ currency,
1327
+ max_charge_cents: maxChargeCents,
1328
+ trust_tier: trustTier
1329
+ };
1330
+
1331
+ const tokenResponse = await requestJson(platformUrl, "/v1/tokens/task", {
1332
+ method: "POST",
1333
+ headers: buildBearerHeaders(apiKey),
1334
+ body: {
1335
+ request_id: requestId,
1336
+ responder_id: responderId,
1337
+ hotline_id: hotlineId,
1338
+ billing
1339
+ }
1340
+ });
1341
+ const tokenBody = ensureSuccess(tokenResponse, 201, "CALL_HOTLINE_TOKEN_FAILED");
1342
+
1343
+ const deliveryMetaResponse = await requestJson(platformUrl, `/v1/requests/${encodeURIComponent(requestId)}/delivery-meta`, {
1344
+ method: "POST",
1345
+ headers: buildBearerHeaders(apiKey),
1346
+ body: {
1347
+ responder_id: responderId,
1348
+ hotline_id: hotlineId,
1349
+ task_token: tokenBody.task_token,
1350
+ result_delivery: resultDelivery
1351
+ }
1352
+ });
1353
+ const deliveryMeta = ensureSuccess(deliveryMetaResponse, 200, "CALL_HOTLINE_DELIVERY_META_FAILED");
1354
+
1355
+ const createdResponse = await requestJson(callerBaseUrl, "/controller/requests", {
1356
+ method: "POST",
1357
+ body: {
1358
+ request_id: requestId,
1359
+ responder_id: responderId,
1360
+ hotline_id: hotlineId,
1361
+ task_token: tokenBody.task_token,
1362
+ delivery_meta: deliveryMeta,
1363
+ result_delivery: deliveryMeta.result_delivery || resultDelivery,
1364
+ expected_signer_public_key_pem: deliveryMeta.responder_public_key_pem || null,
1365
+ task_input: payload,
1366
+ payload
1367
+ }
1368
+ });
1369
+ const request = ensureSuccess(createdResponse, 201, "CALL_HOTLINE_CREATE_FAILED");
1370
+
1371
+ const dispatchResponse = await requestJson(callerBaseUrl, `/controller/requests/${encodeURIComponent(requestId)}/dispatch`, {
1372
+ method: "POST",
1373
+ body: {
1374
+ payload,
1375
+ task_input: payload,
1376
+ task_token: tokenBody.task_token,
1377
+ result_delivery: deliveryMeta.result_delivery || resultDelivery
1378
+ }
1379
+ });
1380
+ const dispatch = ensureSuccess(dispatchResponse, 202, "CALL_HOTLINE_DISPATCH_FAILED");
1381
+
1382
+ const inbox = await waitFor(async () => {
1383
+ const current = await requestJson(callerBaseUrl, "/controller/inbox/pull", {
1384
+ method: "POST",
1385
+ body: {
1386
+ receiver: "caller-controller"
1387
+ }
1388
+ });
1389
+ if (current.status !== 200 || !Array.isArray(current.body?.accepted) || current.body.accepted.length === 0) {
1390
+ throw new Error("call_hotline_inbox_not_ready");
1391
+ }
1392
+ return current.body;
1393
+ }, { timeoutMs, intervalMs: pollIntervalMs });
1394
+
1395
+ const result = await waitFor(async () => {
1396
+ const current = await requestJson(callerBaseUrl, `/controller/requests/${encodeURIComponent(requestId)}/result`);
1397
+ if (current.status !== 200 || current.body?.available !== true || !current.body?.result_package) {
1398
+ throw new Error("call_hotline_result_not_ready");
1399
+ }
1400
+ return current.body;
1401
+ }, { timeoutMs, intervalMs: pollIntervalMs });
1402
+
1403
+ const eventsResponse = await requestJson(platformUrl, `/v1/requests/${encodeURIComponent(requestId)}/events`, {
1404
+ headers: buildBearerHeaders(apiKey)
1405
+ });
1406
+ const events = ensureSuccess(eventsResponse, 200, "CALL_HOTLINE_EVENTS_FAILED");
1407
+ const balanceResponse = await requestJson(platformUrl, "/v1/tenants/me/balance", {
1408
+ headers: buildBearerHeaders(apiKey)
1409
+ });
1410
+ const balance = ensureSuccess(balanceResponse, 200, "CALL_HOTLINE_BALANCE_FAILED");
1411
+ const ledgerResponse = await requestJson(platformUrl, "/v1/tenants/me/ledger?limit=20", {
1412
+ headers: buildBearerHeaders(apiKey)
1413
+ });
1414
+ const ledger = ensureSuccess(ledgerResponse, 200, "CALL_HOTLINE_LEDGER_FAILED");
1415
+
1416
+ emit({
1417
+ ok: true,
1418
+ request_id: request.request_id,
1419
+ hotline_id: hotlineId,
1420
+ responder_id: responderId,
1421
+ relay_base_url: relayBaseUrl,
1422
+ task_token_present: Boolean(tokenBody.task_token),
1423
+ task_token_claims: tokenBody.claims || null,
1424
+ delivery_meta: deliveryMeta,
1425
+ dispatch,
1426
+ inbox,
1427
+ result,
1428
+ events,
1429
+ balance,
1430
+ ledger
1431
+ });
1432
+ }
1433
+
1184
1434
  async function commandBootstrap(args) {
1185
1435
  const steps = [];
1186
1436
  const initialState = ensureOpsState();
@@ -1548,6 +1798,10 @@ async function main() {
1548
1798
  await commandRunExample(args);
1549
1799
  return;
1550
1800
  }
1801
+ if (group === "call-hotline") {
1802
+ await commandCallHotline(args);
1803
+ return;
1804
+ }
1551
1805
  if (group === "auth" && command === "register") {
1552
1806
  await commandAuthRegister(args);
1553
1807
  return;