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.
- package/dist/bundling-sources/sdk/src/client.ts +122 -32
- package/dist/bundling-sources/sdk/src/compat.ts +2 -0
- package/dist/bundling-sources/sdk/src/deployment-protection.ts +19 -0
- package/dist/bundling-sources/sdk/src/http.ts +3 -7
- package/dist/bundling-sources/sdk/src/index.ts +7 -0
- package/dist/bundling-sources/sdk/src/release.ts +1 -1
- package/dist/bundling-sources/sdk/src/types.ts +25 -0
- package/dist/bundling-sources/shared_libs/observability/scheduled-jobs.json +18 -0
- package/dist/bundling-sources/shared_libs/observability/scheduled-work-manual.ts +1 -0
- package/dist/bundling-sources/shared_libs/play-runtime/deployment/fly-registry.ts +33 -7
- package/dist/bundling-sources/shared_libs/play-runtime/runner-app/index.ts +11 -1
- package/dist/bundling-sources/shared_libs/play-runtime/runner-app/runtime-heartbeat.ts +3 -0
- package/dist/bundling-sources/shared_libs/play-runtime/runner-backends/backends/daytona-payload-transport.ts +53 -1
- package/dist/bundling-sources/shared_libs/play-runtime/runner-backends/backends/daytona-runtime-watchdog.ts +187 -6
- package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/postgres.ts +12 -3
- package/dist/bundling-sources/shared_libs/play-runtime/test-runtime-seams.ts +5 -2
- package/dist/bundling-sources/shared_libs/plays/settings-contract.ts +176 -0
- package/dist/cli/index.js +560 -92
- package/dist/cli/index.mjs +560 -92
- package/dist/index.d.mts +165 -3
- package/dist/index.d.ts +165 -3
- package/dist/index.js +67 -35
- package/dist/index.mjs +67 -35
- package/dist/install-integrity.json +2 -0
- package/dist/release.d.mts +1 -1
- package/dist/release.d.ts +1 -1
- package/dist/release.js +1 -1
- package/dist/release.mjs +1 -1
- 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
|
|
120
|
-
* sandbox memory envelope outside the customer-code Node
|
|
121
|
-
* synchronous infinite loop can block the runner's
|
|
122
|
-
* block this separate process; nor can a runner whose heap is
|
|
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))
|
|
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: {
|
|
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
|
|
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
|
-
|
|
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
|
|
225
|
+
at,
|
|
225
226
|
source: 'play',
|
|
226
|
-
line
|
|
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
|
+
};
|