runline 0.18.0 → 0.20.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.
@@ -9,6 +9,30 @@ export interface EngineOptions {
9
9
  timeoutMs?: number;
10
10
  memoryLimitBytes?: number;
11
11
  }
12
+ /**
13
+ * One resolved action call, observed at the engine's dispatch point.
14
+ *
15
+ * Fired only around `action.execute()` — an unknown path or a failed
16
+ * input validation throws before plugin code runs and is not an
17
+ * invocation. `error` is the thrown value, unmodified, when execute
18
+ * rejected; the hook sees it in the host process even though the
19
+ * worker only ever receives the message string.
20
+ */
21
+ export interface ActionInvocation {
22
+ plugin: string;
23
+ action: string;
24
+ /** The full callable path, "plugin.action". */
25
+ path: string;
26
+ durationMs: number;
27
+ error?: unknown;
28
+ }
29
+ export interface EngineHooks {
30
+ /**
31
+ * Observer for every action invocation. Must not throw — but if it
32
+ * does, the engine swallows it: observability never breaks a run.
33
+ */
34
+ onAction?: (info: ActionInvocation) => void;
35
+ }
12
36
  /**
13
37
  * Whether to arm the host-side RSS watchdog for a worker run.
14
38
  *
@@ -48,9 +72,10 @@ export declare function shouldArmRssWatchdog(versions?: Partial<Record<string, s
48
72
  export declare class ExecutionEngine {
49
73
  private registry;
50
74
  private config;
75
+ private hooks;
51
76
  private pooled;
52
77
  private readonly maxRunsPerWorker;
53
- constructor(registry: PluginRegistry, config: RunlineConfig);
78
+ constructor(registry: PluginRegistry, config: RunlineConfig, hooks?: EngineHooks);
54
79
  /** Retire the pooled worker, if any. Safe to call repeatedly. */
55
80
  dispose(): void;
56
81
  /**
@@ -71,6 +96,7 @@ export declare class ExecutionEngine {
71
96
  private release;
72
97
  execute(code: string, options?: EngineOptions): Promise<ExecuteResult>;
73
98
  private invokeAction;
99
+ private fireOnAction;
74
100
  /**
75
101
  * A miss on "salesforce.status" is often a plugin that registered
76
102
  * its action name with the plugin prefix ("salesforce.status" inside
@@ -1,5 +1,5 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import { readFileSync } from "node:fs";
2
+ import { createRequire } from "node:module";
3
3
  import { Worker } from "node:worker_threads";
4
4
  import { applyEnvOverrides, updateConnectionConfig } from "../config/loader.js";
5
5
  import { formatValidationError, helpInputs, isTypedInputSchema, validateTypedInput, } from "../plugin/schema.js";
@@ -81,11 +81,13 @@ const RSS_WATCHDOG_SLACK_BYTES = 128 * 1024 * 1024;
81
81
  export class ExecutionEngine {
82
82
  registry;
83
83
  config;
84
+ hooks;
84
85
  pooled = null;
85
86
  maxRunsPerWorker;
86
- constructor(registry, config) {
87
+ constructor(registry, config, hooks = {}) {
87
88
  this.registry = registry;
88
89
  this.config = config;
90
+ this.hooks = hooks;
89
91
  this.maxRunsPerWorker =
90
92
  config.maxRunsPerWorker ?? DEFAULT_MAX_RUNS_PER_WORKER;
91
93
  }
@@ -320,7 +322,35 @@ export class ExecutionEngine {
320
322
  throw new Error(formatValidationError(path, validation));
321
323
  }
322
324
  }
323
- return action.execute(args, ctx);
325
+ const started = performance.now();
326
+ try {
327
+ const value = await action.execute(args, ctx);
328
+ this.fireOnAction(plugin.name, action.name, path, started, false);
329
+ return value;
330
+ }
331
+ catch (err) {
332
+ // `failed` is a flag, not `error !== undefined` — a plugin that
333
+ // does `throw undefined` still failed.
334
+ this.fireOnAction(plugin.name, action.name, path, started, true, err);
335
+ throw err;
336
+ }
337
+ }
338
+ fireOnAction(pluginName, actionName, path, started, failed, error) {
339
+ const onAction = this.hooks.onAction;
340
+ if (!onAction)
341
+ return;
342
+ try {
343
+ onAction({
344
+ plugin: pluginName,
345
+ action: actionName,
346
+ path,
347
+ durationMs: performance.now() - started,
348
+ ...(failed ? { error: error ?? new Error("Unknown error") } : {}),
349
+ });
350
+ }
351
+ catch {
352
+ // Observability never breaks a run.
353
+ }
324
354
  }
325
355
  /**
326
356
  * A miss on "salesforce.status" is often a plugin that registered
@@ -385,14 +415,12 @@ function formatError(cause) {
385
415
  }
386
416
  return String(cause);
387
417
  }
388
- // MiniSearch UMD bundle, vendored at the package root and inlined into the
389
- // worker source. Inside the worker it is evaluated in a local scope with a
390
- // fresh `module`/`exports` pair so the UMD takes its CommonJS branch.
391
- //
392
- // `../../vendor/...` resolves identically from src/core/engine.ts (dev) and
393
- // dist/core/engine.js (published) because tsc preserves the `core/` subdir.
394
- // See vendor/README.md for the upgrade procedure.
395
- const minisearchSource = readFileSync(new URL("../../vendor/minisearch.umd.js", import.meta.url), "utf8");
418
+ // ferrosearch is a Rust native addon, so unlike the old minisearch UMD it
419
+ // cannot be inlined into the worker source as text. Resolve its entry point
420
+ // host-side and require it by absolute path inside the worker: eval'd worker
421
+ // code gets a cwd-relative `require`, so a bare specifier would resolve
422
+ // against the caller's cwd instead of runline's own node_modules.
423
+ const ferrosearchPath = createRequire(import.meta.url).resolve("@shift-labs/ferrosearch");
396
424
  function buildHelpData(plugins) {
397
425
  const data = {};
398
426
  for (const p of plugins) {
@@ -432,7 +460,7 @@ function buildWorkerSource(pluginNames = [], helpData = {}) {
432
460
  "console",
433
461
  "fetch",
434
462
  "require",
435
- "MiniSearch",
463
+ "FerroSearch",
436
464
  ];
437
465
  const wrapped = `"use strict";
438
466
  const { parentPort: __port } = require("node:worker_threads");
@@ -522,14 +550,11 @@ const __fmtErr = (e) => {
522
550
  // structured-clone-safe and preserves JSON value semantics.
523
551
  const __toJson = (v) => v === undefined ? undefined : JSON.parse(JSON.stringify(v));
524
552
 
525
- // Inlined MiniSearch UMD, evaluated with a local module/exports so the UMD
526
- // takes its CommonJS branch regardless of the worker's module scope.
527
- const MiniSearch = (function () {
528
- const module = { exports: {} };
529
- const exports = module.exports;
530
- ${minisearchSource}
531
- return module.exports;
532
- })();
553
+ // ferrosearch native addon, resolved to an absolute path by the host. Its
554
+ // index memory lives outside the V8 heap, which is the point — minisearch's
555
+ // JS index counted against maxOldGenerationSizeMb and grew worker RSS — but
556
+ // also means an index built by agent code is invisible to that limit.
557
+ const { FerroSearch } = require(${JSON.stringify(ferrosearchPath)});
533
558
 
534
559
  const __help = ${JSON.stringify(helpData)};
535
560
 
@@ -563,7 +588,7 @@ const __formatSignature = (plugin, entry) => {
563
588
  return plugin + '.' + entry.action + (fields ? '({ ' + fields + ' })' : '()');
564
589
  };
565
590
 
566
- // Build a MiniSearch index over every action path. Indexed at worker
591
+ // Build a FerroSearch index over every action path. Indexed at worker
567
592
  // startup, queried by actions.find().
568
593
  const __search = (() => {
569
594
  const docs = [];
@@ -577,7 +602,7 @@ const __search = (() => {
577
602
  description: entry.description || '',
578
603
  });
579
604
  }
580
- const ms = new MiniSearch({
605
+ const index = new FerroSearch({
581
606
  fields: ['path', 'plugin', 'action', 'description'],
582
607
  storeFields: ['path', 'description'],
583
608
  searchOptions: {
@@ -586,8 +611,8 @@ const __search = (() => {
586
611
  boost: { path: 3, action: 2, plugin: 2 },
587
612
  },
588
613
  });
589
- ms.addAll(docs);
590
- return ms;
614
+ index.addAll(docs);
615
+ return index;
591
616
  })();
592
617
 
593
618
  const __actionsApi = {
@@ -813,7 +838,7 @@ const __injectValues = __injectNames.map((n) => {
813
838
  case "console": return console;
814
839
  case "fetch": return fetch;
815
840
  case "require": return __bodyRequire;
816
- case "MiniSearch": return MiniSearch;
841
+ case "FerroSearch": return FerroSearch;
817
842
  default: return __makeProxy([n]);
818
843
  }
819
844
  });
package/dist/index.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  export { addConnection, findConfigDir, getConnection, loadConfig, removeConnection, saveConfig, updateConnectionConfig, } from "./config/loader.js";
2
2
  export type { RunlineConfig } from "./config/types.js";
3
3
  export { DEFAULT_CONFIG } from "./config/types.js";
4
- export type { EngineOptions, ExecuteResult } from "./core/engine.js";
4
+ export type { ActionInvocation, EngineHooks, EngineOptions, ExecuteResult, } from "./core/engine.js";
5
5
  export { ExecutionEngine } from "./core/engine.js";
6
6
  export type { BuildAuthUrlOptions, ExchangeCodeOptions, OAuthTokens, PKCEPair, RunOAuthOptions, } from "./core/oauth.js";
7
7
  export { buildAuthUrl, exchangeAuthCode, generatePKCE, OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_URI, runOAuth, } from "./core/oauth.js";
@@ -1,6 +1,7 @@
1
1
  import * as t from "typebox";
2
2
  import { registerIssueViewActions } from "./issue-views.js";
3
3
  import { registerIssueActions } from "./issues.js";
4
+ import { registerObjectActions } from "./objects.js";
4
5
  import { registerPageActions } from "./pages.js";
5
6
  import { registerProjectActions } from "./projects.js";
6
7
  import { registerTranscriptionActions } from "./transcription.js";
@@ -8,18 +9,17 @@ export default function shiftLabs(rl) {
8
9
  rl.setName("shiftLabs");
9
10
  rl.setVersion("0.1.0");
10
11
  rl.setConnectionSchema(t.Object({
12
+ // The API key is the tenant authority for every action; the cloud
13
+ // derives the organization from it (SHFT-852), so no org ID is needed.
11
14
  apiKey: t.String({
12
15
  description: "Shift Labs API key",
13
16
  env: "SHIFT_LABS_API_KEY",
14
17
  }),
15
- organizationId: t.Optional(t.String({
16
- description: "Shift Labs organization ID (required for transcription actions)",
17
- env: "SHIFT_LABS_ORG_ID",
18
- })),
19
18
  }));
20
19
  registerProjectActions(rl);
21
20
  registerIssueActions(rl);
22
21
  registerIssueViewActions(rl);
23
22
  registerPageActions(rl);
24
23
  registerTranscriptionActions(rl);
24
+ registerObjectActions(rl);
25
25
  }
@@ -0,0 +1,249 @@
1
+ import { openAsBlob } from "node:fs";
2
+ import { readFile, stat, writeFile } from "node:fs/promises";
3
+ import { basename, extname } from "node:path";
4
+ import * as t from "typebox";
5
+ import { enumSchema, pathSegment, request } from "./shared.js";
6
+ const OBJECTS_BASE = "/v1/services/objects";
7
+ export const OBJECT_STATUS = ["pending_upload", "ready", "archived"];
8
+ export const OBJECT_LINK_TARGET = [
9
+ "session",
10
+ "artifact",
11
+ "eval_case",
12
+ "db_record",
13
+ ];
14
+ /** Mirrors the public service contract's 5 GiB object ceiling. */
15
+ const MAX_OBJECT_BYTES = 5 * 1024 * 1024 * 1024;
16
+ const STRICT_OBJECT = { additionalProperties: false };
17
+ const Id = t.String({ minLength: 1, pattern: "\\S" });
18
+ const MEDIA_TYPES = {
19
+ csv: "text/csv",
20
+ doc: "application/msword",
21
+ docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
22
+ gif: "image/gif",
23
+ html: "text/html",
24
+ jpeg: "image/jpeg",
25
+ jpg: "image/jpeg",
26
+ json: "application/json",
27
+ md: "text/markdown",
28
+ mp3: "audio/mpeg",
29
+ mp4: "video/mp4",
30
+ pdf: "application/pdf",
31
+ png: "image/png",
32
+ txt: "text/plain",
33
+ wav: "audio/wav",
34
+ webp: "image/webp",
35
+ xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
36
+ zip: "application/zip",
37
+ };
38
+ function mediaTypeFor(path, override) {
39
+ if (override)
40
+ return override;
41
+ return (MEDIA_TYPES[extname(path).slice(1).toLowerCase()] ??
42
+ "application/octet-stream");
43
+ }
44
+ async function fileBody(path, sizeBytes) {
45
+ try {
46
+ return await openAsBlob(path);
47
+ }
48
+ catch {
49
+ // Runtimes without fs.openAsBlob fall back to buffering; refuse to
50
+ // buffer files that would strain the host process.
51
+ if (sizeBytes > 512 * 1024 * 1024) {
52
+ throw new Error("This runtime cannot stream uploads and the file is too large to buffer.");
53
+ }
54
+ return await readFile(path);
55
+ }
56
+ }
57
+ function linkSchema() {
58
+ return t.Object({
59
+ targetType: enumSchema("Link target type", OBJECT_LINK_TARGET),
60
+ targetId: t.String({
61
+ minLength: 1,
62
+ maxLength: 500,
63
+ pattern: "\\S",
64
+ description: "Target ID; db_record targets use source_system/table/row style ids, e.g. R2M_Sys/GoodsReceiptLog/42",
65
+ }),
66
+ role: t.Optional(t.String({
67
+ minLength: 1,
68
+ maxLength: 200,
69
+ pattern: "\\S",
70
+ description: "Link role, e.g. source",
71
+ })),
72
+ }, STRICT_OBJECT);
73
+ }
74
+ const provenanceFields = {
75
+ workspaceId: t.Optional(Id),
76
+ sessionId: t.Optional(Id),
77
+ artifactId: t.Optional(Id),
78
+ };
79
+ export function registerObjectActions(rl) {
80
+ rl.registerAction("object.upload", {
81
+ access: "write",
82
+ description: "Persist a local binary file (image, PDF, document, export) in the " +
83
+ "organization's durable object bucket. Uploads through a short-lived " +
84
+ "signed grant and returns the ready object with its stable ID — use " +
85
+ "that ID as a durable sourceObjectId in DB rows and artifact links.",
86
+ inputSchema: t.Object({
87
+ path: t.String({
88
+ minLength: 1,
89
+ pattern: "\\S",
90
+ description: "Host path of the file to store",
91
+ }),
92
+ contentType: t.Optional(t.String({
93
+ minLength: 1,
94
+ maxLength: 200,
95
+ pattern: "\\S",
96
+ description: "Explicit MIME type; inferred from the extension when omitted",
97
+ })),
98
+ filename: t.Optional(t.String({
99
+ minLength: 1,
100
+ maxLength: 500,
101
+ pattern: "\\S",
102
+ description: "Original filename; defaults to the path's basename",
103
+ })),
104
+ metadata: t.Optional(t.Record(t.String(), t.Unknown(), {
105
+ description: "Small caller-defined metadata object",
106
+ })),
107
+ ...provenanceFields,
108
+ }, STRICT_OBJECT),
109
+ async execute(input, ctx) {
110
+ const fields = input;
111
+ const contentType = mediaTypeFor(fields.path, fields.contentType);
112
+ const file = await stat(fields.path);
113
+ if (!file.isFile())
114
+ throw new Error(`${fields.path} is not a file`);
115
+ if (file.size === 0)
116
+ throw new Error("File is empty");
117
+ if (file.size > MAX_OBJECT_BYTES) {
118
+ throw new Error(`File is larger than the ${MAX_OBJECT_BYTES}-byte service limit`);
119
+ }
120
+ const created = await request(ctx, `${OBJECTS_BASE}/objects`, {
121
+ method: "POST",
122
+ // The API key is the tenant authority; no organizationId is sent.
123
+ body: JSON.stringify({
124
+ contentType,
125
+ sizeBytes: file.size,
126
+ filename: fields.filename ?? basename(fields.path),
127
+ workspaceId: fields.workspaceId,
128
+ sessionId: fields.sessionId,
129
+ artifactId: fields.artifactId,
130
+ metadata: fields.metadata,
131
+ }),
132
+ });
133
+ const uploadHeaders = new Headers(created.upload.headers);
134
+ if (!uploadHeaders.has("content-type")) {
135
+ uploadHeaders.set("content-type", contentType);
136
+ }
137
+ const upload = await fetch(created.upload.url, {
138
+ method: created.upload.method,
139
+ headers: uploadHeaders,
140
+ body: await fileBody(fields.path, file.size),
141
+ });
142
+ if (!upload.ok) {
143
+ throw new Error(`Object upload failed: HTTP ${upload.status}`);
144
+ }
145
+ return request(ctx, `${OBJECTS_BASE}/objects/${pathSegment(created.object.id)}/complete`, { method: "POST" });
146
+ },
147
+ });
148
+ rl.registerAction("object.list", {
149
+ access: "read",
150
+ description: "List stored objects, newest first. Archived objects appear only " +
151
+ "when filtered for by status.",
152
+ inputSchema: t.Object({
153
+ status: t.Optional(enumSchema("Object status", OBJECT_STATUS)),
154
+ contentType: t.Optional(t.String({ minLength: 1, maxLength: 200, pattern: "\\S" })),
155
+ ...provenanceFields,
156
+ }, STRICT_OBJECT),
157
+ async execute(input, ctx) {
158
+ const params = new URLSearchParams();
159
+ for (const [key, value] of Object.entries(input)) {
160
+ if (value !== undefined)
161
+ params.set(key, String(value));
162
+ }
163
+ const query = params.toString();
164
+ const body = await request(ctx, `${OBJECTS_BASE}/objects${query ? `?${query}` : ""}`);
165
+ return body.objects;
166
+ },
167
+ });
168
+ rl.registerAction("object.get", {
169
+ access: "read",
170
+ description: "Get a stored object's metadata by ID.",
171
+ inputSchema: t.Object({ id: Id }, STRICT_OBJECT),
172
+ async execute(input, ctx) {
173
+ const { id } = input;
174
+ return request(ctx, `${OBJECTS_BASE}/objects/${pathSegment(id)}`);
175
+ },
176
+ });
177
+ rl.registerAction("object.download", {
178
+ access: "read",
179
+ description: "Mint a short-lived signed download URL for a ready object. Set " +
180
+ "savePath to also download the bytes to a host file.",
181
+ inputSchema: t.Object({
182
+ id: Id,
183
+ savePath: t.Optional(t.String({
184
+ minLength: 1,
185
+ pattern: "\\S",
186
+ description: "Host path to write the downloaded bytes to",
187
+ })),
188
+ }, STRICT_OBJECT),
189
+ async execute(input, ctx) {
190
+ const fields = input;
191
+ const grant = await request(ctx, `${OBJECTS_BASE}/objects/${pathSegment(fields.id)}/download`, {
192
+ method: "POST",
193
+ });
194
+ if (!fields.savePath)
195
+ return grant;
196
+ const response = await fetch(grant.url, { method: grant.method });
197
+ if (!response.ok) {
198
+ throw new Error(`Object download failed: HTTP ${response.status}`);
199
+ }
200
+ const bytes = Buffer.from(await response.arrayBuffer());
201
+ await writeFile(fields.savePath, bytes);
202
+ return { savedTo: fields.savePath, sizeBytes: bytes.byteLength };
203
+ },
204
+ });
205
+ rl.registerAction("object.attach", {
206
+ access: "write",
207
+ description: "Link an object to what produced or consumed it: a session, " +
208
+ "artifact, eval case, or customer DB record. Links are append-only " +
209
+ "provenance; duplicates are rejected.",
210
+ inputSchema: t.Object({
211
+ id: Id,
212
+ links: t.Array(linkSchema(), { minItems: 1, maxItems: 20 }),
213
+ }, STRICT_OBJECT),
214
+ async execute(input, ctx) {
215
+ const fields = input;
216
+ const body = await request(ctx, `${OBJECTS_BASE}/objects/${pathSegment(fields.id)}/links`, { method: "POST", body: JSON.stringify({ links: fields.links }) });
217
+ return body.links;
218
+ },
219
+ });
220
+ rl.registerAction("object.links", {
221
+ access: "read",
222
+ description: "List an object's provenance links.",
223
+ inputSchema: t.Object({ id: Id }, STRICT_OBJECT),
224
+ async execute(input, ctx) {
225
+ const { id } = input;
226
+ const body = await request(ctx, `${OBJECTS_BASE}/objects/${pathSegment(id)}/links`);
227
+ return body.links;
228
+ },
229
+ });
230
+ rl.registerAction("object.archive", {
231
+ access: "write",
232
+ description: "Archive a stored object (idempotent). Archived objects drop out of " +
233
+ "default listings and refuse download grants; nothing is deleted.",
234
+ inputSchema: t.Object({ id: Id }, STRICT_OBJECT),
235
+ async execute(input, ctx) {
236
+ const { id } = input;
237
+ return request(ctx, `${OBJECTS_BASE}/objects/${pathSegment(id)}/archive`, { method: "POST" });
238
+ },
239
+ });
240
+ rl.registerAction("object.bucket", {
241
+ access: "read",
242
+ description: "Get the organization's durable object bucket, provisioning it on " +
243
+ "first use.",
244
+ inputSchema: t.Object({}, STRICT_OBJECT),
245
+ async execute(_input, ctx) {
246
+ return request(ctx, `${OBJECTS_BASE}/bucket`);
247
+ },
248
+ });
249
+ }
@@ -25,13 +25,6 @@ const MAX_MEDIA_BYTES = 5 * 1024 * 1024 * 1024;
25
25
  const MAX_TRANSCRIPT_BYTES = 2 * 1024 * 1024;
26
26
  const STRICT_OBJECT = { additionalProperties: false };
27
27
  const Id = t.String({ minLength: 1, pattern: "\\S" });
28
- function organizationId(ctx) {
29
- const value = ctx.connection.config.organizationId;
30
- if (typeof value !== "string" || !value) {
31
- throw new Error("SHIFT_LABS_ORG_ID is not set. Transcription actions need the organization ID that owns the API key.");
32
- }
33
- return value;
34
- }
35
28
  function mediaTypeFor(path) {
36
29
  const mediaType = MEDIA_TYPES[extname(path).slice(1).toLowerCase()];
37
30
  if (!mediaType) {
@@ -83,7 +76,6 @@ export function registerTranscriptionActions(rl) {
83
76
  }, STRICT_OBJECT),
84
77
  async execute(input, ctx) {
85
78
  const fields = input;
86
- const org = organizationId(ctx);
87
79
  const contentType = mediaTypeFor(fields.path);
88
80
  const file = await stat(fields.path);
89
81
  if (!file.isFile())
@@ -95,8 +87,8 @@ export function registerTranscriptionActions(rl) {
95
87
  }
96
88
  const created = await request(ctx, "/v1/services/transcription/assets", {
97
89
  method: "POST",
90
+ // The API key is the tenant authority; no organizationId is sent.
98
91
  body: JSON.stringify({
99
- organizationId: org,
100
92
  contentType,
101
93
  sizeBytes: file.size,
102
94
  }),
@@ -117,7 +109,6 @@ export function registerTranscriptionActions(rl) {
117
109
  const job = await request(ctx, "/v1/services/transcription/jobs", {
118
110
  method: "POST",
119
111
  body: JSON.stringify({
120
- organizationId: org,
121
112
  assetId: created.asset.id,
122
113
  language: fields.language,
123
114
  diarize: fields.diarize,
@@ -135,7 +126,7 @@ export function registerTranscriptionActions(rl) {
135
126
  description: "List transcription jobs for the API key's organization, newest first.",
136
127
  inputSchema: t.Object({}, STRICT_OBJECT),
137
128
  async execute(_input, ctx) {
138
- const body = await request(ctx, `/v1/services/transcription/jobs?organizationId=${pathSegment(organizationId(ctx))}`);
129
+ const body = await request(ctx, "/v1/services/transcription/jobs");
139
130
  return body.jobs;
140
131
  },
141
132
  });