@frockbot/kernel-do 0.3.41 → 0.3.43
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/package.json +3 -3
- package/src/authority.ts +97 -2
- package/src/composition-store.test.ts +120 -0
- package/src/composition-store.ts +19 -1
- package/src/run-records.ts +57 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@frockbot/kernel-do",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.43",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -12,8 +12,8 @@
|
|
|
12
12
|
"typecheck": "tsc --noEmit -p tsconfig.json"
|
|
13
13
|
},
|
|
14
14
|
"dependencies": {
|
|
15
|
-
"@frockbot/kernel-composition": "0.3.
|
|
16
|
-
"@frockbot/kernel-contracts": "0.3.
|
|
15
|
+
"@frockbot/kernel-composition": "0.3.43",
|
|
16
|
+
"@frockbot/kernel-contracts": "0.3.43",
|
|
17
17
|
"cordis": "4.0.0-rc.8"
|
|
18
18
|
},
|
|
19
19
|
"devDependencies": {
|
package/src/authority.ts
CHANGED
|
@@ -10,7 +10,10 @@ import {
|
|
|
10
10
|
type SessionEvent,
|
|
11
11
|
} from "@frockbot/kernel-contracts";
|
|
12
12
|
import type { CompositionGenerationV1 } from "@frockbot/kernel-composition/generation";
|
|
13
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
DurableCompositionStore,
|
|
15
|
+
decodeCompositionPinV1,
|
|
16
|
+
} from "./composition-store.js";
|
|
14
17
|
import { DurableCompositionFailureLog } from "./composition-failures.js";
|
|
15
18
|
import {
|
|
16
19
|
boundedRunFailureV1,
|
|
@@ -22,11 +25,13 @@ import {
|
|
|
22
25
|
storedRunRecordV2,
|
|
23
26
|
storedRunSubagentRoleV1,
|
|
24
27
|
storedRunTurnTypeV1,
|
|
28
|
+
unreadableStoredRunV1,
|
|
25
29
|
type BotNotificationIntent,
|
|
26
30
|
type BotTurnCommand,
|
|
27
31
|
type BotTurnCompletion,
|
|
28
32
|
type StoredRunCodecV1,
|
|
29
33
|
type StoredRunV1,
|
|
34
|
+
type UnreadableStoredRunV1,
|
|
30
35
|
} from "./run-records.js";
|
|
31
36
|
import {
|
|
32
37
|
completeStoredRun,
|
|
@@ -67,6 +72,7 @@ import {
|
|
|
67
72
|
} from "./conversations.js";
|
|
68
73
|
import {
|
|
69
74
|
ACTIVE_RUN_KEY,
|
|
75
|
+
COMPOSITION_CURRENT_KEY,
|
|
70
76
|
CONVERSATION_INDEX_KEY,
|
|
71
77
|
CONVERSATION_KEY,
|
|
72
78
|
MAX_LISTED_CONVERSATIONS,
|
|
@@ -235,6 +241,14 @@ function runWasDiscardedV1(
|
|
|
235
241
|
return Boolean(run?.stopRequestedAt || run?.supersededAt);
|
|
236
242
|
}
|
|
237
243
|
|
|
244
|
+
/**
|
|
245
|
+
* One run as the display-only read boundary sees it: either the decoded
|
|
246
|
+
* record, or the bounded identity of a record that could not be decoded.
|
|
247
|
+
*/
|
|
248
|
+
export type DisplayRunReadV1<Snapshot> =
|
|
249
|
+
| { readonly readable: true; readonly run: StoredRunV1<Snapshot> }
|
|
250
|
+
| { readonly readable: false; readonly run: UnreadableStoredRunV1 };
|
|
251
|
+
|
|
238
252
|
export interface BotDurableAuthorityOptions<Snapshot> {
|
|
239
253
|
state: DurableObjectState;
|
|
240
254
|
codec: StoredRunCodecV1<Snapshot>;
|
|
@@ -400,6 +414,14 @@ export class BotDurableAuthority<Snapshot> {
|
|
|
400
414
|
* Makes the durably queued run the active one, recomputing the history it
|
|
401
415
|
* starts from: the Turn it waited behind appended events, and the queued
|
|
402
416
|
* Turn's model request derives from everything that is durable now.
|
|
417
|
+
*
|
|
418
|
+
* The Composition pin is recomputed here for the same reason. A queued Turn
|
|
419
|
+
* is admitted but has not started, and "an in-flight Turn keeps its pinned
|
|
420
|
+
* implementation" is about a Turn that is running, not one that is waiting.
|
|
421
|
+
* The Turn ahead of it can author a Package or follow a deployment while it
|
|
422
|
+
* waits, and pinning what the pointer said at admission ran the queued Turn
|
|
423
|
+
* without the member that was just added — the tool the Bot had told the
|
|
424
|
+
* person it had built.
|
|
403
425
|
*/
|
|
404
426
|
private async promoteQueuedRun(runId: string): Promise<
|
|
405
427
|
| "not-queued"
|
|
@@ -449,9 +471,18 @@ export class BotDurableAuthority<Snapshot> {
|
|
|
449
471
|
// repair applies before this Turn starts on it.
|
|
450
472
|
const repaired = repairedSessionLogV1(run.sessionId, storedEvents);
|
|
451
473
|
const latestEvents = repaired ?? storedEvents;
|
|
474
|
+
// The pointer is materialized at admission, so it is there; a Bot whose
|
|
475
|
+
// record somehow is not keeps the generation it was admitted under
|
|
476
|
+
// rather than failing a Turn it is owed.
|
|
477
|
+
const pointer = await transaction.get<unknown>(COMPOSITION_CURRENT_KEY);
|
|
478
|
+
const compositionGenerationId =
|
|
479
|
+
pointer === undefined
|
|
480
|
+
? run.compositionGenerationId
|
|
481
|
+
: decodeCompositionPinV1(pointer).generationId;
|
|
452
482
|
const promoted = this.codec.require({
|
|
453
483
|
...run,
|
|
454
484
|
phase: "admitted",
|
|
485
|
+
compositionGenerationId,
|
|
455
486
|
previousEventCount: latestEvents.length,
|
|
456
487
|
...storedRunEventFieldsV2(latestEvents.length, []),
|
|
457
488
|
} satisfies StoredRunV1<Snapshot>);
|
|
@@ -1227,6 +1258,61 @@ export class BotDurableAuthority<Snapshot> {
|
|
|
1227
1258
|
return this.readRunFrom(this.ctx.storage, runId, "display");
|
|
1228
1259
|
}
|
|
1229
1260
|
|
|
1261
|
+
/**
|
|
1262
|
+
* The record alone, for display, never throwing on a record it cannot read.
|
|
1263
|
+
*
|
|
1264
|
+
* The transcript is the one reader that must survive a bad row. Execution
|
|
1265
|
+
* and recovery stay strict — they act on the record, and acting on a record
|
|
1266
|
+
* nobody can decode is how a Turn gets settled twice — but a read that only
|
|
1267
|
+
* draws the conversation owes the person the other forty Turns. An
|
|
1268
|
+
* undecodable record comes back as {@link UnreadableStoredRunV1}: the run id
|
|
1269
|
+
* from the lookup key plus whatever scraped strings are safe, which is
|
|
1270
|
+
* enough to render exactly one degraded row.
|
|
1271
|
+
*/
|
|
1272
|
+
async readRunHeaderForDisplay(
|
|
1273
|
+
runId: string,
|
|
1274
|
+
): Promise<DisplayRunReadV1<Snapshot> | undefined> {
|
|
1275
|
+
const raw = await this.ctx.storage.get<unknown>(`${RUN_PREFIX}${runId}`);
|
|
1276
|
+
if (raw === undefined) return undefined;
|
|
1277
|
+
try {
|
|
1278
|
+
return { readable: true, run: this.codec.require(raw) };
|
|
1279
|
+
} catch {
|
|
1280
|
+
return { readable: false, run: unreadableStoredRunV1(runId, raw) };
|
|
1281
|
+
}
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
/**
|
|
1285
|
+
* The journal behind a display header, degrading rather than throwing.
|
|
1286
|
+
*
|
|
1287
|
+
* Takes the header the caller already read rather than the run id: a
|
|
1288
|
+
* transcript page reads one record per candidate and hydrates only the ones
|
|
1289
|
+
* it keeps, and re-reading the record here would put that read back.
|
|
1290
|
+
*/
|
|
1291
|
+
async hydrateRunForDisplay(
|
|
1292
|
+
header: DisplayRunReadV1<Snapshot>,
|
|
1293
|
+
): Promise<DisplayRunReadV1<Snapshot>> {
|
|
1294
|
+
if (!header.readable) return header;
|
|
1295
|
+
try {
|
|
1296
|
+
return {
|
|
1297
|
+
readable: true,
|
|
1298
|
+
run: await this.hydrateRun(this.ctx.storage, header.run, "display"),
|
|
1299
|
+
};
|
|
1300
|
+
} catch {
|
|
1301
|
+
return {
|
|
1302
|
+
readable: false,
|
|
1303
|
+
run: unreadableStoredRunV1(header.run.runId, header.run),
|
|
1304
|
+
};
|
|
1305
|
+
}
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
/** {@link readRunHeaderForDisplay} with its journal hydrated. */
|
|
1309
|
+
async readStoredRunForDisplayOrDegraded(
|
|
1310
|
+
runId: string,
|
|
1311
|
+
): Promise<DisplayRunReadV1<Snapshot> | undefined> {
|
|
1312
|
+
const header = await this.readRunHeaderForDisplay(runId);
|
|
1313
|
+
return header ? this.hydrateRunForDisplay(header) : undefined;
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1230
1316
|
private async readRunFrom(
|
|
1231
1317
|
storage: SessionEventLogStorage,
|
|
1232
1318
|
runId: string,
|
|
@@ -1235,7 +1321,16 @@ export class BotDurableAuthority<Snapshot> {
|
|
|
1235
1321
|
const run = this.codec.optional(
|
|
1236
1322
|
await storage.get<unknown>(`${RUN_PREFIX}${runId}`),
|
|
1237
1323
|
);
|
|
1238
|
-
if (!run
|
|
1324
|
+
if (!run) return undefined;
|
|
1325
|
+
return this.hydrateRun(storage, run, fidelity);
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
private async hydrateRun(
|
|
1329
|
+
storage: SessionEventLogStorage,
|
|
1330
|
+
run: StoredRunV1<Snapshot>,
|
|
1331
|
+
fidelity: "exact" | "display",
|
|
1332
|
+
): Promise<StoredRunV1<Snapshot>> {
|
|
1333
|
+
if (!run.eventRange) return run;
|
|
1239
1334
|
const log = new SessionEventLog(storage);
|
|
1240
1335
|
const events =
|
|
1241
1336
|
fidelity === "display"
|
|
@@ -3,6 +3,9 @@ import {
|
|
|
3
3
|
bootstrapGeneration,
|
|
4
4
|
compositionArtifactSetHashV1,
|
|
5
5
|
compositionGenerationIdV1,
|
|
6
|
+
CompositionPinConflictError,
|
|
7
|
+
COMPOSITION_PIN_ATTEMPTS_V1,
|
|
8
|
+
pinCompositionWithRetryV1,
|
|
6
9
|
type CompositionGenerationV1,
|
|
7
10
|
type CompositionMemberV1,
|
|
8
11
|
} from "@frockbot/kernel-composition/generation";
|
|
@@ -506,6 +509,123 @@ describe("Bot Durable Object Composition records", () => {
|
|
|
506
509
|
});
|
|
507
510
|
});
|
|
508
511
|
|
|
512
|
+
describe("a pinning proposal compares and swaps the pointer", () => {
|
|
513
|
+
test("a proposal derived from the generation the pointer still names wins", async () => {
|
|
514
|
+
const storage = new MemoryStorage();
|
|
515
|
+
const store = createStore(storage);
|
|
516
|
+
const parent = await store.current();
|
|
517
|
+
const next = await grownSuccessor(parent, "2026-09-01T00:00:00.000Z");
|
|
518
|
+
|
|
519
|
+
await store.propose(next, {
|
|
520
|
+
pin: true,
|
|
521
|
+
expectedCurrentGenerationId: parent.generationId,
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
expect((await store.current()).generationId).toBe(next.generationId);
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
test("a proposal derived from a pointer that has since moved is refused whole", async () => {
|
|
528
|
+
const storage = new MemoryStorage();
|
|
529
|
+
const store = createStore(storage);
|
|
530
|
+
// What the losing writer snapshotted before it yielded.
|
|
531
|
+
const stale = await store.current();
|
|
532
|
+
// What landed while it was yielded: a Package the Bot authored.
|
|
533
|
+
const authored = await grownSuccessor(stale, "2026-09-01T00:00:00.000Z");
|
|
534
|
+
await store.propose(authored, {
|
|
535
|
+
pin: true,
|
|
536
|
+
expectedCurrentGenerationId: stale.generationId,
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
const derivedFromStale = await successor(stale, "2026-09-01T00:00:01.000Z");
|
|
540
|
+
await expect(
|
|
541
|
+
store.propose(derivedFromStale, {
|
|
542
|
+
pin: true,
|
|
543
|
+
expectedCurrentGenerationId: stale.generationId,
|
|
544
|
+
}),
|
|
545
|
+
).rejects.toBeInstanceOf(CompositionPinConflictError);
|
|
546
|
+
|
|
547
|
+
// The pointer still names the winner, and the loser wrote nothing at all:
|
|
548
|
+
// no generation record, no index entry, so no retention quota was spent on
|
|
549
|
+
// a proposal that never applied.
|
|
550
|
+
expect((await store.current()).generationId).toBe(authored.generationId);
|
|
551
|
+
expect(await store.read(derivedFromStale.generationId)).toBeUndefined();
|
|
552
|
+
expect(await store.retainedCount()).toBe(2);
|
|
553
|
+
// And the authored member is still there — the whole point of refusing.
|
|
554
|
+
expect(
|
|
555
|
+
(await store.current()).members.map((member) => member.packageId),
|
|
556
|
+
).toEqual(["bot-authored-greeter", "shell"]);
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
test("the loser re-derives from the winner and keeps both members", async () => {
|
|
560
|
+
const storage = new MemoryStorage();
|
|
561
|
+
const store = createStore(storage);
|
|
562
|
+
const stale = await store.current();
|
|
563
|
+
const authored = await grownSuccessor(stale, "2026-09-01T00:00:00.000Z");
|
|
564
|
+
await store.propose(authored, {
|
|
565
|
+
pin: true,
|
|
566
|
+
expectedCurrentGenerationId: stale.generationId,
|
|
567
|
+
});
|
|
568
|
+
|
|
569
|
+
let derivedFrom = stale;
|
|
570
|
+
let attempts = 0;
|
|
571
|
+
const pinned = await pinCompositionWithRetryV1(async () => {
|
|
572
|
+
attempts += 1;
|
|
573
|
+
// A first attempt that derives from the snapshot it took before it
|
|
574
|
+
// yielded, and a retry that re-reads — which is the merge.
|
|
575
|
+
const parent = attempts === 1 ? stale : await store.current();
|
|
576
|
+
derivedFrom = parent;
|
|
577
|
+
const generation = await successor(
|
|
578
|
+
parent,
|
|
579
|
+
`2026-09-01T00:00:0${attempts}.000Z`,
|
|
580
|
+
);
|
|
581
|
+
await store.propose(generation, {
|
|
582
|
+
pin: true,
|
|
583
|
+
expectedCurrentGenerationId: parent.generationId,
|
|
584
|
+
});
|
|
585
|
+
return generation;
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
expect(attempts).toBe(2);
|
|
589
|
+
expect(derivedFrom.generationId).toBe(authored.generationId);
|
|
590
|
+
expect((await store.current()).generationId).toBe(pinned.generationId);
|
|
591
|
+
expect(pinned.members.map((member) => member.packageId)).toEqual([
|
|
592
|
+
"bot-authored-greeter",
|
|
593
|
+
"shell",
|
|
594
|
+
]);
|
|
595
|
+
});
|
|
596
|
+
|
|
597
|
+
test("gives up after a bounded number of losses rather than spinning", async () => {
|
|
598
|
+
const storage = new MemoryStorage();
|
|
599
|
+
const store = createStore(storage);
|
|
600
|
+
const stale = await store.current();
|
|
601
|
+
let attempts = 0;
|
|
602
|
+
|
|
603
|
+
await expect(
|
|
604
|
+
pinCompositionWithRetryV1(async () => {
|
|
605
|
+
attempts += 1;
|
|
606
|
+
// Someone else always wins: every attempt derives from a pointer that
|
|
607
|
+
// has already moved by the time it proposes.
|
|
608
|
+
const winner = await successor(
|
|
609
|
+
await store.current(),
|
|
610
|
+
`2026-09-0${attempts}T00:00:00.000Z`,
|
|
611
|
+
);
|
|
612
|
+
await store.propose(winner, { pin: true });
|
|
613
|
+
const generation = await successor(
|
|
614
|
+
stale,
|
|
615
|
+
`2026-09-0${attempts}T00:00:01.000Z`,
|
|
616
|
+
);
|
|
617
|
+
await store.propose(generation, {
|
|
618
|
+
pin: true,
|
|
619
|
+
expectedCurrentGenerationId: stale.generationId,
|
|
620
|
+
});
|
|
621
|
+
return generation;
|
|
622
|
+
}),
|
|
623
|
+
).rejects.toBeInstanceOf(CompositionPinConflictError);
|
|
624
|
+
|
|
625
|
+
expect(attempts).toBe(COMPOSITION_PIN_ATTEMPTS_V1);
|
|
626
|
+
});
|
|
627
|
+
});
|
|
628
|
+
|
|
509
629
|
interface TurnProbe {
|
|
510
630
|
authority: BotDurableAuthority<undefined>;
|
|
511
631
|
store: DurableCompositionStore;
|
package/src/composition-store.ts
CHANGED
|
@@ -8,6 +8,7 @@ import { decodeCompositionFailureV1 } from "@frockbot/kernel-composition/activat
|
|
|
8
8
|
import {
|
|
9
9
|
assertCompositionArtifactSetHashV1,
|
|
10
10
|
compositionGenerationIdV1,
|
|
11
|
+
CompositionPinConflictError,
|
|
11
12
|
type CompositionGenerationV1,
|
|
12
13
|
type CompositionOriginV1,
|
|
13
14
|
type CompositionStore,
|
|
@@ -151,7 +152,7 @@ export class DurableCompositionStore implements CompositionStore {
|
|
|
151
152
|
|
|
152
153
|
async propose(
|
|
153
154
|
generation: CompositionGenerationV1,
|
|
154
|
-
options: { pin?: boolean } = {},
|
|
155
|
+
options: { pin?: boolean; expectedCurrentGenerationId?: string } = {},
|
|
155
156
|
): Promise<void> {
|
|
156
157
|
const proposed = decodeCompositionGenerationV1(generation);
|
|
157
158
|
if (proposed.status !== "pending") {
|
|
@@ -164,6 +165,23 @@ export class DurableCompositionStore implements CompositionStore {
|
|
|
164
165
|
await this.ctx.storage.transaction(async (transaction) => {
|
|
165
166
|
const bootstrap = await this.bootstrapGeneration(transaction);
|
|
166
167
|
this.assertRequiredCoreSet(bootstrap, proposed);
|
|
168
|
+
// Compare-and-swap before anything is written: a proposal derived from a
|
|
169
|
+
// pointer that has since moved would drop whatever the winner added, so
|
|
170
|
+
// it is refused whole rather than merged blind. Nothing has been put yet,
|
|
171
|
+
// so the caller re-derives from the pointer it lost to and tries again.
|
|
172
|
+
if (options.pin && options.expectedCurrentGenerationId !== undefined) {
|
|
173
|
+
const pointer = await transaction.get<unknown>(COMPOSITION_CURRENT_KEY);
|
|
174
|
+
const currentId =
|
|
175
|
+
pointer === undefined
|
|
176
|
+
? undefined
|
|
177
|
+
: decodeCompositionPinV1(pointer).generationId;
|
|
178
|
+
if (currentId !== options.expectedCurrentGenerationId) {
|
|
179
|
+
throw new CompositionPinConflictError(
|
|
180
|
+
options.expectedCurrentGenerationId,
|
|
181
|
+
currentId,
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
167
185
|
const key = compositionGenerationKey(proposed.generationId);
|
|
168
186
|
if ((await transaction.get<unknown>(key)) !== undefined) {
|
|
169
187
|
throw new Error(
|
package/src/run-records.ts
CHANGED
|
@@ -980,3 +980,60 @@ export interface BotTurnCompletion {
|
|
|
980
980
|
events: SessionEvent[];
|
|
981
981
|
notification?: BotNotificationIntent;
|
|
982
982
|
}
|
|
983
|
+
|
|
984
|
+
/**
|
|
985
|
+
* The little that can be trusted about a run record nobody can decode.
|
|
986
|
+
*
|
|
987
|
+
* A transcript read is display-only: it never resumes, settles or recovers a
|
|
988
|
+
* Turn, so it does not need the record to be valid — it needs enough to draw
|
|
989
|
+
* one row saying which Turn could not be read. The run id comes from the
|
|
990
|
+
* admission index key, which is authority; everything else is scraped from the
|
|
991
|
+
* raw value and kept only where it is plainly a safe, bounded string, so a
|
|
992
|
+
* record corrupt in any other field still yields a renderable row.
|
|
993
|
+
*/
|
|
994
|
+
export interface UnreadableStoredRunV1 {
|
|
995
|
+
readonly runId: string;
|
|
996
|
+
readonly sessionId?: string;
|
|
997
|
+
readonly acceptedAt?: string;
|
|
998
|
+
readonly input?: string;
|
|
999
|
+
readonly admission?: { readonly turnType?: TurnTypeV1 };
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
/** Scrape a record that failed to decode down to {@link UnreadableStoredRunV1}. */
|
|
1003
|
+
export function unreadableStoredRunV1(
|
|
1004
|
+
runId: string,
|
|
1005
|
+
raw: unknown,
|
|
1006
|
+
): UnreadableStoredRunV1 {
|
|
1007
|
+
const candidate =
|
|
1008
|
+
raw && typeof raw === "object" && !Array.isArray(raw)
|
|
1009
|
+
? (raw as Record<string, unknown>)
|
|
1010
|
+
: {};
|
|
1011
|
+
const admission =
|
|
1012
|
+
candidate.admission &&
|
|
1013
|
+
typeof candidate.admission === "object" &&
|
|
1014
|
+
!Array.isArray(candidate.admission)
|
|
1015
|
+
? (candidate.admission as Record<string, unknown>)
|
|
1016
|
+
: undefined;
|
|
1017
|
+
let turnType: TurnTypeV1 | undefined;
|
|
1018
|
+
try {
|
|
1019
|
+
if (admission?.turnType !== undefined) {
|
|
1020
|
+
turnType = decodeTurnTypeV1(admission.turnType);
|
|
1021
|
+
}
|
|
1022
|
+
} catch {
|
|
1023
|
+
turnType = undefined;
|
|
1024
|
+
}
|
|
1025
|
+
return {
|
|
1026
|
+
runId,
|
|
1027
|
+
...(boundedString(candidate.sessionId, 257)
|
|
1028
|
+
? { sessionId: candidate.sessionId }
|
|
1029
|
+
: {}),
|
|
1030
|
+
...(boundedString(candidate.acceptedAt, 64) &&
|
|
1031
|
+
Number.isFinite(Date.parse(candidate.acceptedAt))
|
|
1032
|
+
? { acceptedAt: candidate.acceptedAt }
|
|
1033
|
+
: {}),
|
|
1034
|
+
...(boundedString(candidate.input, 32_000)
|
|
1035
|
+
? { input: candidate.input }
|
|
1036
|
+
: {}),
|
|
1037
|
+
...(turnType ? { admission: { turnType } } : {}),
|
|
1038
|
+
};
|
|
1039
|
+
}
|