deepline 0.3.213 → 0.3.214

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 (29) hide show
  1. package/dist/bundling-sources/sdk/src/client.ts +122 -32
  2. package/dist/bundling-sources/sdk/src/compat.ts +2 -0
  3. package/dist/bundling-sources/sdk/src/deployment-protection.ts +19 -0
  4. package/dist/bundling-sources/sdk/src/http.ts +3 -7
  5. package/dist/bundling-sources/sdk/src/index.ts +7 -0
  6. package/dist/bundling-sources/sdk/src/release.ts +1 -1
  7. package/dist/bundling-sources/sdk/src/types.ts +25 -0
  8. package/dist/bundling-sources/shared_libs/observability/scheduled-jobs.json +18 -0
  9. package/dist/bundling-sources/shared_libs/observability/scheduled-work-manual.ts +1 -0
  10. package/dist/bundling-sources/shared_libs/play-runtime/deployment/fly-registry.ts +33 -7
  11. package/dist/bundling-sources/shared_libs/play-runtime/runner-app/index.ts +11 -1
  12. package/dist/bundling-sources/shared_libs/play-runtime/runner-app/runtime-heartbeat.ts +3 -0
  13. package/dist/bundling-sources/shared_libs/play-runtime/runner-backends/backends/daytona-payload-transport.ts +53 -1
  14. package/dist/bundling-sources/shared_libs/play-runtime/runner-backends/backends/daytona-runtime-watchdog.ts +187 -6
  15. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/postgres.ts +12 -3
  16. package/dist/bundling-sources/shared_libs/play-runtime/test-runtime-seams.ts +5 -2
  17. package/dist/bundling-sources/shared_libs/plays/settings-contract.ts +176 -0
  18. package/dist/cli/index.js +560 -92
  19. package/dist/cli/index.mjs +560 -92
  20. package/dist/index.d.mts +165 -3
  21. package/dist/index.d.ts +165 -3
  22. package/dist/index.js +67 -35
  23. package/dist/index.mjs +67 -35
  24. package/dist/install-integrity.json +2 -0
  25. package/dist/release.d.mts +1 -1
  26. package/dist/release.d.ts +1 -1
  27. package/dist/release.js +1 -1
  28. package/dist/release.mjs +1 -1
  29. package/package.json +3 -2
@@ -1,5 +1,8 @@
1
1
  import { STANDARD_PLAY_SANDBOX_RUNTIME_LIMITS } from '../../../plays/sandbox-runtime-limits';
2
2
  import { RUNTIME_SANDBOX_MEMORY_ABORT_CODE } from '../../run-failure';
3
+ import { PLAY_RUNTIME_TRANSPORT_ATTEMPT_HEADER } from '../../runtime-contract';
4
+ import { RUNTIME_CLAIM_HEARTBEAT_DIVISOR } from '../../runtime-constants';
5
+ import { RUNNER_HEARTBEAT_REQUEST_TIMEOUT_MS } from '../../runner-app/runtime-heartbeat';
3
6
  import {
4
7
  RUNTIME_MEMORY_LEDGER_PATH_ENV,
5
8
  RUNTIME_MEMORY_PRESSURE_PATH_ENV,
@@ -116,11 +119,11 @@ export const RUNTIME_SANDBOX_MEMORY_ABORT_REASON =
116
119
  RUNTIME_SANDBOX_MEMORY_ABORT_CODE;
117
120
 
118
121
  /**
119
- * Build the tiny parent process that owns the hard runtime deadline and the
120
- * sandbox memory envelope outside the customer-code Node event loop. A
121
- * synchronous infinite loop can block the runner's own timer, but it cannot
122
- * block this separate process; nor can a runner whose heap is thrashing run
123
- * its own memory watch reliably.
122
+ * Build the parent process that owns runner liveness, the hard runtime
123
+ * deadline, and the sandbox memory envelope outside the customer-code Node
124
+ * event loop. A synchronous infinite loop can block the runner's timers, but
125
+ * it cannot block this separate process; nor can a runner whose heap is
126
+ * thrashing run its own memory watch reliably.
124
127
  *
125
128
  * argv (after `node -e <source>`):
126
129
  * 1 runnerPath, 2 configPath, 3 runtimeStartedPath, 4 runtimeCompletedPath,
@@ -185,6 +188,178 @@ let settled = false;
185
188
  let forceKillTimer = null;
186
189
  let runtimeTimer = null;
187
190
  let terminalGraceTimer = null;
191
+ let heartbeatTimer = null;
192
+ let heartbeatController = null;
193
+ let heartbeatInFlight = false;
194
+ let heartbeatStopped = false;
195
+ let heartbeatConfigRead = false;
196
+ let heartbeatConfig = null;
197
+ let heartbeatLastSuccessAtMs = 0;
198
+ let heartbeatAttempt = 0;
199
+ let heartbeatLastErrorCode = null;
200
+ let heartbeatStopBeforeLeaseMs = 0;
201
+ let stopRequested = false;
202
+
203
+ function readRunnerHeartbeatConfig() {
204
+ if (heartbeatConfigRead) return heartbeatConfig;
205
+ heartbeatConfigRead = true;
206
+ try {
207
+ const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
208
+ const context = config && config.context;
209
+ const push = context && context.runnerPushExecution;
210
+ const gatewayBaseUrl = String(context && context.receiptGatewayBaseUrl || '').replace(/\\/$/, '');
211
+ const executorToken = context && context.executorToken;
212
+ const runId = push && push.runId;
213
+ const leaseSeconds = Number(push && push.leaseSeconds);
214
+ if (
215
+ !gatewayBaseUrl ||
216
+ typeof executorToken !== 'string' ||
217
+ !executorToken.trim() ||
218
+ typeof runId !== 'string' ||
219
+ !runId.trim() ||
220
+ !Number.isFinite(leaseSeconds) ||
221
+ leaseSeconds <= 0
222
+ ) {
223
+ return null;
224
+ }
225
+ heartbeatConfig = {
226
+ gatewayBaseUrl,
227
+ executorToken,
228
+ runId,
229
+ leaseMs: Math.max(1_000, Math.floor(leaseSeconds * 1_000)),
230
+ };
231
+ } catch {}
232
+ return heartbeatConfig;
233
+ }
234
+
235
+ function stopRunnerHeartbeat() {
236
+ heartbeatStopped = true;
237
+ if (heartbeatTimer) {
238
+ clearInterval(heartbeatTimer);
239
+ heartbeatTimer = null;
240
+ }
241
+ if (heartbeatController) {
242
+ heartbeatController.abort();
243
+ heartbeatController = null;
244
+ }
245
+ }
246
+
247
+ function runnerHeartbeatErrorCode(error) {
248
+ const code = error && typeof error.code === 'string' ? error.code : '';
249
+ if (/^(?:E[A-Z0-9_]+|ERR_[A-Z0-9_]+|UND_ERR_[A-Z0-9_]+)$/.test(code)) return code;
250
+ const name = error && typeof error.name === 'string' ? error.name : '';
251
+ if (name === 'AbortError' || name === 'TimeoutError') return name;
252
+ return 'unknown';
253
+ }
254
+
255
+ function startRunnerHeartbeatSupervisor() {
256
+ if (heartbeatTimer || heartbeatStopped || settled) return;
257
+ const config = readRunnerHeartbeatConfig();
258
+ if (!config) return;
259
+ let handoff = null;
260
+ try {
261
+ handoff = JSON.parse(fs.readFileSync(runtimeStartedPath, 'utf8'));
262
+ } catch {}
263
+ const handoffAt = Number(handoff && handoff.lastHeartbeatAtMs);
264
+ heartbeatLastSuccessAtMs = Number.isFinite(handoffAt) ? handoffAt : Date.now();
265
+ const cadenceMs = Math.max(250, Math.floor(config.leaseMs / ${RUNTIME_CLAIM_HEARTBEAT_DIVISOR}));
266
+ heartbeatStopBeforeLeaseMs = Math.max(
267
+ 0,
268
+ config.leaseMs - cadenceMs - ${RUNNER_HEARTBEAT_REQUEST_TIMEOUT_MS},
269
+ );
270
+ process.stderr.write(
271
+ '[play-runner.heartbeat] watchdog_owner_started ' +
272
+ JSON.stringify({ runId: config.runId, cadenceMs, handoffHeartbeatAgeMs: Math.max(0, Date.now() - heartbeatLastSuccessAtMs) }) +
273
+ '\\n',
274
+ );
275
+ void runnerHeartbeatTick();
276
+ heartbeatTimer = setInterval(runnerHeartbeatTick, cadenceMs);
277
+ heartbeatTimer.unref?.();
278
+ }
279
+
280
+ async function runnerHeartbeatTick() {
281
+ if (heartbeatStopped || heartbeatInFlight || settled || !child || child.exitCode !== null) return;
282
+ const config = readRunnerHeartbeatConfig();
283
+ if (!config) return;
284
+ heartbeatInFlight = true;
285
+ heartbeatAttempt += 1;
286
+ const startedAt = Date.now();
287
+ const controller = new AbortController();
288
+ heartbeatController = controller;
289
+ const timeout = setTimeout(() => controller.abort(), ${RUNNER_HEARTBEAT_REQUEST_TIMEOUT_MS});
290
+ try {
291
+ const response = await fetch(
292
+ config.gatewayBaseUrl + '/api/v2/plays/runtime',
293
+ {
294
+ method: 'POST',
295
+ headers: {
296
+ 'content-type': 'application/json',
297
+ authorization: 'Bearer ' + config.executorToken,
298
+ ${JSON.stringify(PLAY_RUNTIME_TRANSPORT_ATTEMPT_HEADER)}: require('node:crypto').randomUUID(),
299
+ },
300
+ body: JSON.stringify({ action: 'runner_heartbeat', runId: config.runId }),
301
+ redirect: 'manual',
302
+ signal: controller.signal,
303
+ },
304
+ );
305
+ if (response.status >= 300 && response.status < 400) {
306
+ await response.body?.cancel().catch(() => undefined);
307
+ throw Object.assign(new Error('gateway_redirect'), { code: 'redirect_' + response.status });
308
+ }
309
+ if (!response.ok) {
310
+ await response.body?.cancel().catch(() => undefined);
311
+ throw Object.assign(new Error('gateway_http_error'), { code: 'http_' + response.status });
312
+ }
313
+ const body = await response.json().catch(() => null);
314
+ if (body && body.ok === false && ['cancelled', 'failed', 'completed'].includes(body.terminal)) {
315
+ process.stderr.write(
316
+ '[play-runner.heartbeat] watchdog_owner_terminal ' +
317
+ JSON.stringify({ runId: config.runId, terminal: body.terminal, attempt: heartbeatAttempt }) +
318
+ '\\n',
319
+ );
320
+ stopRunnerHeartbeat();
321
+ stopChild('runner_scheduler_terminal', { terminal: body.terminal, heartbeatAttempt });
322
+ return;
323
+ }
324
+ if (!body || body.ok !== true || (body.schedulerStatus !== undefined && !['queued', 'running', 'waiting'].includes(body.schedulerStatus))) {
325
+ throw Object.assign(new Error('gateway_protocol_error'), { code: 'protocol_error' });
326
+ }
327
+ heartbeatLastSuccessAtMs = Date.now();
328
+ heartbeatLastErrorCode = null;
329
+ } catch (error) {
330
+ if (heartbeatStopped || settled) return;
331
+ heartbeatLastErrorCode = controller.signal.aborted
332
+ ? 'TimeoutError'
333
+ : runnerHeartbeatErrorCode(error);
334
+ const heartbeatAgeMs = Math.max(0, Date.now() - heartbeatLastSuccessAtMs);
335
+ process.stderr.write(
336
+ '[play-runner.heartbeat] watchdog_owner_retrying ' +
337
+ JSON.stringify({ runId: config.runId, attempt: heartbeatAttempt, durationMs: Math.max(0, Date.now() - startedAt), heartbeatAgeMs, errorCode: heartbeatLastErrorCode }) +
338
+ '\\n',
339
+ );
340
+ // Stop before the scheduler's lease expires. A failed request can consume
341
+ // its full timeout, and the next cadence would otherwise let the scheduler
342
+ // win the terminal race and report an ambiguous runner loss.
343
+ if (heartbeatAgeMs >= heartbeatStopBeforeLeaseMs) {
344
+ stopRunnerHeartbeat();
345
+ const diagnostic = {
346
+ heartbeatAttempt,
347
+ heartbeatAgeMs,
348
+ leaseMs: config.leaseMs,
349
+ stopBeforeLeaseMs: heartbeatStopBeforeLeaseMs,
350
+ lastErrorCode: heartbeatLastErrorCode,
351
+ };
352
+ process.stderr.write(
353
+ 'RUNTIME_CONTROL_PLANE_UNAVAILABLE: ' + JSON.stringify(diagnostic) + '\\n',
354
+ );
355
+ stopChild('runner_heartbeat_unreachable', diagnostic);
356
+ }
357
+ } finally {
358
+ clearTimeout(timeout);
359
+ if (heartbeatController === controller) heartbeatController = null;
360
+ heartbeatInFlight = false;
361
+ }
362
+ }
188
363
 
189
364
  function writeTerminationDiagnostic(reason, details = {}) {
190
365
  if (!terminationDiagnosticPath) return;
@@ -204,6 +379,8 @@ function writeTerminationDiagnostic(reason, details = {}) {
204
379
  }
205
380
 
206
381
  function stopChild(reason, details) {
382
+ if (settled || stopRequested) return;
383
+ stopRequested = true;
207
384
  writeTerminationDiagnostic(reason, details);
208
385
  child.kill('SIGTERM');
209
386
  forceKillTimer = setTimeout(() => child.kill('SIGKILL'), ${RUNNER_TERMINATION_KILL_GRACE_MS});
@@ -391,7 +568,10 @@ function armTerminalGrace() {
391
568
  }
392
569
 
393
570
  const runtimeBoundaryPoll = setInterval(() => {
394
- if (fs.existsSync(runtimeStartedPath)) armRuntimeDeadline();
571
+ if (fs.existsSync(runtimeStartedPath)) {
572
+ armRuntimeDeadline();
573
+ startRunnerHeartbeatSupervisor();
574
+ }
395
575
  if (fs.existsSync(runtimeCompletedPath)) armTerminalGrace();
396
576
  }, 25);
397
577
  const startupTimer = setTimeout(() => {
@@ -413,6 +593,7 @@ function finish(code) {
413
593
  settled = true;
414
594
  clearInterval(runtimeBoundaryPoll);
415
595
  clearInterval(memoryWatch);
596
+ stopRunnerHeartbeat();
416
597
  clearTimeout(startupTimer);
417
598
  if (runtimeTimer) clearTimeout(runtimeTimer);
418
599
  if (terminalGraceTimer) clearTimeout(terminalGraceTimer);
@@ -13891,24 +13891,33 @@ export async function upsertPostgresSchedulerObservation(
13891
13891
  /** Read every observation for a run/attempt, ordered for deterministic folds. */
13892
13892
  export async function getPostgresSchedulerObservations(
13893
13893
  client: PostgresSchedulerQueryClient,
13894
- input: { runId: string; attempt?: number | null },
13894
+ input: {
13895
+ runId: string;
13896
+ attempt?: number | null;
13897
+ /** Return only the newest settled invocation for each node. */
13898
+ latestSettledPerNode?: boolean;
13899
+ },
13895
13900
  options: PostgresSchedulerReceiptOptions,
13896
13901
  ): Promise<DocflowObservationRecord[]> {
13897
13902
  const hasAttempt = input.attempt != null;
13903
+ const latestSettledPerNode = input.latestSettledPerNode === true;
13898
13904
  const params: unknown[] = [receiptOrgId(options), input.runId];
13899
13905
  if (hasAttempt) {
13900
13906
  params.push(normalizePostgresSchedulerReceiptRunAttempt(input.attempt));
13901
13907
  }
13902
13908
  const result = await client.query<ObservationRow>(
13903
13909
  `
13904
- SELECT run_id, attempt, node_id, invocation_seq, invocation_id, status,
13910
+ SELECT ${latestSettledPerNode ? 'DISTINCT ON (node_id)' : ''}
13911
+ run_id, attempt, node_id, invocation_seq, invocation_id, status,
13905
13912
  inputs_json, outputs_json, inputs_truncated, outputs_truncated,
13906
13913
  error, started_at, settled_at
13907
13914
  FROM ${tableName('observations', options)}
13908
13915
  WHERE org_id = $1
13909
13916
  AND run_id = $2
13910
13917
  ${hasAttempt ? 'AND attempt = $3' : ''}
13911
- ORDER BY node_id, attempt, invocation_seq
13918
+ ${latestSettledPerNode ? "AND status IN ('completed', 'failed')" : ''}
13919
+ ORDER BY node_id, attempt ${latestSettledPerNode ? 'DESC' : ''},
13920
+ invocation_seq ${latestSettledPerNode ? 'DESC' : ''}
13912
13921
  `,
13913
13922
  params,
13914
13923
  );
@@ -219,11 +219,14 @@ export function runtimeSheetPageTailHoldCompletedEvent(
219
219
  tableNamespace: string,
220
220
  ): PlayRunnerEvent | null {
221
221
  if (!Number.isInteger(holdMs) || (holdMs ?? 0) <= 0) return null;
222
+ const at = new Date().toISOString();
222
223
  return {
223
224
  type: 'log',
224
- at: new Date().toISOString(),
225
+ at,
225
226
  source: 'play',
226
- line: `[runtime.sheet-page-tail-hold] phase=finish table=${tableNamespace} hold_ms=${holdMs}`,
227
+ // The status projection keeps the line but drops the event envelope's
228
+ // timestamp, so include the emission time for runtime-preview diagnostics.
229
+ line: `[${at}] [runtime.sheet-page-tail-hold] phase=finish table=${tableNamespace} hold_ms=${holdMs}`,
227
230
  };
228
231
  }
229
232
 
@@ -0,0 +1,176 @@
1
+ import { z } from 'zod';
2
+
3
+ /** Keep schema diagnostics readable in both interactive settings and the CLI. */
4
+ export function formatPlaySettingsError(error: unknown): string {
5
+ if (error instanceof z.ZodError)
6
+ return [...new Set(error.issues.map((issue) => issue.message))].join(' ');
7
+ return error instanceof Error ? error.message : String(error);
8
+ }
9
+
10
+ /** Location organizes Plays. Existing workspace permissions still apply. */
11
+ export const PLAY_LOCATION_ROOTS = [
12
+ {
13
+ value: 'person',
14
+ label: 'Personal',
15
+ description:
16
+ 'The creator’s personal root, displayed as their name followed by “’s team”.',
17
+ },
18
+ {
19
+ value: 'team',
20
+ label: 'Team library',
21
+ description:
22
+ 'The shared team library. Publishing moves personal locations here, keeping their folders.',
23
+ },
24
+ ] as const;
25
+
26
+ const folder = z
27
+ .string()
28
+ .min(1)
29
+ .max(128)
30
+ .refine(
31
+ (value) =>
32
+ value === value.trim() &&
33
+ value !== '.' &&
34
+ value !== '..' &&
35
+ !/[\\/\u0000-\u001f\u007f]/u.test(value),
36
+ 'Folder names must be trimmed, cannot be . or .., and cannot contain slashes or control characters.',
37
+ );
38
+ export const playPathSchema = z
39
+ .union([
40
+ z.null(),
41
+ z
42
+ .object({
43
+ root: z.enum(['person', 'team']),
44
+ folders: z.array(folder).max(16).default([]),
45
+ })
46
+ .strict(),
47
+ ])
48
+ .transform((path) =>
49
+ path?.root === 'person' && path.folders.length === 0 ? null : path,
50
+ );
51
+ export type PlayPath = z.output<typeof playPathSchema>;
52
+
53
+ export function parsePlayPath(value: string): PlayPath {
54
+ const [root, ...parts] = value.split('/');
55
+ if (!PLAY_LOCATION_ROOTS.some((option) => option.value === root)) {
56
+ throw new Error(
57
+ 'Path must start with person or team. Example: person/Research/ICP.',
58
+ );
59
+ }
60
+ let folders: string[];
61
+ try {
62
+ folders = parts.map(decodeURIComponent);
63
+ } catch {
64
+ throw new Error(
65
+ 'Path contains an invalid percent escape. Encode a literal % as %25.',
66
+ );
67
+ }
68
+ return playPathSchema.parse({ root, folders });
69
+ }
70
+ export function formatPlayPath(path: PlayPath): string {
71
+ return path === null
72
+ ? 'person'
73
+ : [path.root, ...path.folders.map(encodeURIComponent)].join('/');
74
+ }
75
+ export function personalPlayRootLabel(ownerName?: string | null): string {
76
+ const name = ownerName?.trim();
77
+ return name ? `${name.split(/\s+/)[0]}’s team` : 'Personal';
78
+ }
79
+ export function displayPlayPath(
80
+ path: PlayPath,
81
+ ownerName?: string | null,
82
+ ): string {
83
+ return [
84
+ path?.root === 'team' ? 'Team library' : personalPlayRootLabel(ownerName),
85
+ ...(path?.folders ?? []),
86
+ ].join(' / ');
87
+ }
88
+ export function publishedPlayPath(path: PlayPath): PlayPath {
89
+ return { root: 'team', folders: path?.folders ?? [] };
90
+ }
91
+
92
+ /** One registry drives validation, defaults, CLI keys/help and settings controls. */
93
+ export const PLAY_SETTING_FIELDS = {
94
+ path: {
95
+ schema: playPathSchema
96
+ .default(null)
97
+ .describe(
98
+ 'Play location. Null means the creator’s personal root. Publishing moves it into Team library, preserving folders.',
99
+ ),
100
+ label: 'Location',
101
+ description:
102
+ 'Organize this Play in the creator’s personal root or Team library. Publishing moves it to Team library and keeps its folders. Workspace sharing permissions apply in both roots.',
103
+ get defaultValue(): PlayPath {
104
+ return this.schema.parse(undefined);
105
+ },
106
+ cli: {
107
+ valueSyntax: 'person[/folder/…] | team[/folder/…] | null',
108
+ examples: [
109
+ 'person',
110
+ 'person/Research/ICP',
111
+ 'team',
112
+ 'team/Research',
113
+ 'null',
114
+ ],
115
+ parse: (value: string) =>
116
+ value === 'null' ? null : parsePlayPath(value),
117
+ format: formatPlayPath,
118
+ },
119
+ ui: { control: 'play-location-picker' as const },
120
+ },
121
+ } as const;
122
+ export const playSettingsSchema = z.object(
123
+ Object.fromEntries(
124
+ Object.entries(PLAY_SETTING_FIELDS).map(([key, field]) => [
125
+ key,
126
+ field.schema,
127
+ ]),
128
+ ) as {
129
+ [K in keyof typeof PLAY_SETTING_FIELDS]: (typeof PLAY_SETTING_FIELDS)[K]['schema'];
130
+ },
131
+ ).passthrough();
132
+ export const playSettingsPatchSchema = z
133
+ .object(
134
+ Object.fromEntries(
135
+ Object.entries(PLAY_SETTING_FIELDS).map(([key, field]) => [
136
+ key,
137
+ field.schema.unwrap().optional(),
138
+ ]),
139
+ ) as {
140
+ [K in keyof typeof PLAY_SETTING_FIELDS]: z.ZodOptional<
141
+ ReturnType<(typeof PLAY_SETTING_FIELDS)[K]['schema']['unwrap']>
142
+ >;
143
+ },
144
+ )
145
+ .strict()
146
+ .refine(
147
+ (patch) => Object.keys(patch).length > 0,
148
+ 'Supply at least one setting.',
149
+ );
150
+ export type PlaySettings = {
151
+ [K in keyof typeof PLAY_SETTING_FIELDS]: z.output<
152
+ (typeof PLAY_SETTING_FIELDS)[K]['schema']
153
+ >;
154
+ };
155
+ export type PlaySettingsPatch = z.output<typeof playSettingsPatchSchema>;
156
+ /** Validate registered keys while preserving future keys in stored objects. */
157
+ export function parsePlaySettings(value: unknown): PlaySettings {
158
+ return playSettingsSchema.parse(value) as PlaySettings;
159
+ }
160
+ export const DEFAULT_PLAY_SETTINGS: PlaySettings = parsePlaySettings({});
161
+ export type PlaySettingsResult = {
162
+ playDefinitionId: string;
163
+ ownerUserId: string | null;
164
+ ownerName: string | null;
165
+ settings: PlaySettings;
166
+ };
167
+ export type PlaySettingsPathsPage = {
168
+ paths: Array<{
169
+ path: PlayPath;
170
+ ownerUserId: string | null;
171
+ ownerName: string | null;
172
+ playName: string;
173
+ }>;
174
+ cursor: string | null;
175
+ hasMore: boolean;
176
+ };