@frockbot/plugin-shell 0.3.21 → 0.3.23

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@frockbot/plugin-shell",
3
- "version": "0.3.21",
3
+ "version": "0.3.23",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "exports": {
@@ -52,42 +52,42 @@
52
52
  },
53
53
  "dependencies": {
54
54
  "@cordisjs/plugin-webui": "0.8.2",
55
- "@frockbot/agent-runtime": "0.3.21",
56
- "@frockbot/application-foundation": "0.3.21",
57
- "@frockbot/catalog-core": "0.3.21",
58
- "@frockbot/client-core": "0.3.21",
59
- "@frockbot/client-ui": "0.3.21",
60
- "@frockbot/computer-core": "0.3.21",
61
- "@frockbot/computer-host-protocol": "0.3.21",
62
- "@frockbot/configuration-core": "0.3.21",
63
- "@frockbot/connection-core": "0.3.21",
64
- "@frockbot/kernel-agent-loop": "0.3.21",
65
- "@frockbot/kernel-composition": "0.3.21",
66
- "@frockbot/kernel-contracts": "0.3.21",
67
- "@frockbot/kernel-do": "0.3.21",
68
- "@frockbot/machine-protocol": "0.3.21",
69
- "@frockbot/plugin-applets": "0.3.21",
70
- "@frockbot/plugin-authoring": "0.3.21",
71
- "@frockbot/plugin-bot-template": "0.3.21",
72
- "@frockbot/plugin-computer": "0.3.21",
73
- "@frockbot/plugin-flock": "0.3.21",
74
- "@frockbot/plugin-image": "0.3.21",
75
- "@frockbot/plugin-machine-messages": "0.3.21",
76
- "@frockbot/plugin-mcp": "0.3.21",
77
- "@frockbot/plugin-memory": "0.3.21",
78
- "@frockbot/plugin-package-catalog": "0.3.21",
79
- "@frockbot/plugin-routines": "0.3.21",
80
- "@frockbot/plugin-skills": "0.3.21",
81
- "@frockbot/plugin-subagents": "0.3.21",
82
- "@frockbot/plugin-user-machine": "0.3.21",
83
- "@frockbot/protocol": "0.3.21",
55
+ "@frockbot/agent-runtime": "0.3.23",
56
+ "@frockbot/application-foundation": "0.3.23",
57
+ "@frockbot/catalog-core": "0.3.23",
58
+ "@frockbot/client-core": "0.3.23",
59
+ "@frockbot/client-ui": "0.3.23",
60
+ "@frockbot/computer-core": "0.3.23",
61
+ "@frockbot/computer-host-protocol": "0.3.23",
62
+ "@frockbot/configuration-core": "0.3.23",
63
+ "@frockbot/connection-core": "0.3.23",
64
+ "@frockbot/kernel-agent-loop": "0.3.23",
65
+ "@frockbot/kernel-composition": "0.3.23",
66
+ "@frockbot/kernel-contracts": "0.3.23",
67
+ "@frockbot/kernel-do": "0.3.23",
68
+ "@frockbot/machine-protocol": "0.3.23",
69
+ "@frockbot/plugin-applets": "0.3.23",
70
+ "@frockbot/plugin-authoring": "0.3.23",
71
+ "@frockbot/plugin-bot-template": "0.3.23",
72
+ "@frockbot/plugin-computer": "0.3.23",
73
+ "@frockbot/plugin-flock": "0.3.23",
74
+ "@frockbot/plugin-image": "0.3.23",
75
+ "@frockbot/plugin-machine-messages": "0.3.23",
76
+ "@frockbot/plugin-mcp": "0.3.23",
77
+ "@frockbot/plugin-memory": "0.3.23",
78
+ "@frockbot/plugin-package-catalog": "0.3.23",
79
+ "@frockbot/plugin-routines": "0.3.23",
80
+ "@frockbot/plugin-skills": "0.3.23",
81
+ "@frockbot/plugin-subagents": "0.3.23",
82
+ "@frockbot/plugin-user-machine": "0.3.23",
83
+ "@frockbot/protocol": "0.3.23",
84
84
  "cordis": "4.0.0-rc.8",
85
85
  "vue": "3.5.41"
86
86
  },
87
87
  "devDependencies": {
88
88
  "@cloudflare/workers-types": "latest",
89
- "@frockbot/plugin-prompt": "0.3.21",
90
- "@frockbot/plugin-tools": "0.3.21",
89
+ "@frockbot/plugin-prompt": "0.3.23",
90
+ "@frockbot/plugin-tools": "0.3.23",
91
91
  "@types/bun": "1.3.6",
92
92
  "@types/node": "26.2.0",
93
93
  "@vitejs/plugin-vue": "6.0.8",
@@ -8,6 +8,7 @@ import {
8
8
  resolveAppletCompositionV1,
9
9
  APPLET_DIRECTORY_REVISION_SEEN_KEY,
10
10
  type AppletInstanceBindingV1,
11
+ type AppletSourceSyncOutcomeV1,
11
12
  type AppletUserDirectoryV1,
12
13
  } from "./backend-applets.js";
13
14
  import {
@@ -344,6 +345,7 @@ describe("ctx.applets", () => {
344
345
  directory?: Partial<AppletUserDirectoryV1>;
345
346
  instance?: Partial<AppletInstanceBindingV1>;
346
347
  storage?: ReturnType<typeof memoryStorage>;
348
+ synced?: AppletSourceSyncOutcomeV1;
347
349
  }) {
348
350
  const storage = options.storage ?? memoryStorage();
349
351
  const recorded: unknown[] = [];
@@ -455,6 +457,9 @@ describe("ctx.applets", () => {
455
457
  stat: () =>
456
458
  Promise.resolve({ status: "not-found", reason: "" }) as never,
457
459
  },
460
+ ...(options.synced
461
+ ? { syncSourceRootNow: () => Promise.resolve(options.synced!) }
462
+ : {}),
458
463
  composition: {
459
464
  current: () => bootstrap(),
460
465
  lastKnownGood: () => bootstrap(),
@@ -488,6 +493,39 @@ describe("ctx.applets", () => {
488
493
  );
489
494
  });
490
495
 
496
+ // Production, 2026-09-04: a 470 KB `dist/ui.html` the Bot had just built was
497
+ // reported as `not-found: run \`applet build\` … first`, and the Bot ran the
498
+ // build again, three times. The build was fine; the sync could not carry the
499
+ // file. The publish says which of the two it was now.
500
+ test("publish says a built file did not reach the Workspace rather than blaming the build", async () => {
501
+ const { host: capability } = host({
502
+ files: {},
503
+ synced: {
504
+ status: "degraded",
505
+ detail:
506
+ '1 sync operation failed at "dist/ui.html": unavailable: the Computer could not answer.',
507
+ },
508
+ });
509
+ const outcome = await capability.publish({ appletId: APPLET }, scope);
510
+ expect(outcome.status).toBe("failed");
511
+ const reason = outcome.status === "failed" ? outcome.reason : "";
512
+ expect(reason).toBe(
513
+ '"dist/server.js" is on the Computer but did not reach the Workspace: 1 sync operation failed at "dist/ui.html": unavailable: the Computer could not answer.',
514
+ );
515
+ expect(reason).not.toMatch(/applet build/);
516
+ });
517
+
518
+ test("publish still blames the build when the pull of the source root was clean", async () => {
519
+ const { host: capability } = host({
520
+ files: {},
521
+ synced: { status: "ok", detail: "" },
522
+ });
523
+ const outcome = await capability.publish({ appletId: APPLET }, scope);
524
+ expect(outcome.status === "failed" && outcome.reason).toMatch(
525
+ /applet build/,
526
+ );
527
+ });
528
+
491
529
  test("publish refuses a manifest whose hashes do not match the bytes", async () => {
492
530
  const { host: capability } = host({
493
531
  files: {
@@ -58,6 +58,20 @@ export const APPLET_PUBLISH_EFFECT_PREFIX = "applets:publish-effect:";
58
58
  */
59
59
  export const APPLET_DIRECTORY_REVISION_SEEN_KEY = "applets:directory-revision";
60
60
 
61
+ /**
62
+ * How the pre-publish pull of the Applet's source root went.
63
+ *
64
+ * `ok` and `skipped` mean the store now holds what the Computer holds. Anything
65
+ * else means the two may differ, and a `dist/` file the publish cannot find is
66
+ * then far more likely to be one the sync could not carry than one `applet
67
+ * build` never wrote.
68
+ */
69
+ export interface AppletSourceSyncOutcomeV1 {
70
+ status: "ok" | "degraded" | "unavailable" | "refused" | "skipped";
71
+ /** What the sync said went wrong, empty when it had nothing to say. */
72
+ detail: string;
73
+ }
74
+
61
75
  /** The three files `applet build` writes, read from the durable root. */
62
76
  export const APPLET_DIST_FILES_V1 = [
63
77
  "dist/server.js",
@@ -293,8 +307,12 @@ export interface AppletCapabilityHostOptionsV1 {
293
307
  * store is read as it stands — correct but possibly stale — and the Bot is
294
308
  * told so in the failure when the files are missing, rather than the publish
295
309
  * silently using old bytes.
310
+ *
311
+ * What it answers is how that pull went. A build that is on the Computer but
312
+ * did not reach the store is a different failure from a build that was never
313
+ * run, and the publish can only say which when it is told.
296
314
  */
297
- syncSourceRootNow?(appletId: string): Promise<void>;
315
+ syncSourceRootNow?(appletId: string): Promise<AppletSourceSyncOutcomeV1>;
298
316
  composition: Pick<CompositionStore, "current" | "lastKnownGood" | "propose">;
299
317
  now?(): Date;
300
318
  }
@@ -594,11 +612,20 @@ export function createAppletCapabilityHostV1(
594
612
  appletId: string,
595
613
  file: string,
596
614
  maximum: number,
615
+ synced?: AppletSourceSyncOutcomeV1,
597
616
  ): Promise<{ text: string } | { failure: string }> {
598
617
  const outcome = await options.workspace.read(
599
618
  appletDistPathV1(options.userId, appletId, file),
600
619
  );
601
620
  if (outcome.status !== "ok") {
621
+ // A pull that did not finish is the likelier explanation than an
622
+ // unbuilt Applet, and telling the Bot to build again would send it
623
+ // round a loop that cannot end.
624
+ if (synced && synced.status !== "ok" && synced.status !== "skipped") {
625
+ return {
626
+ failure: `"${file}" is on the Computer but did not reach the Workspace: ${synced.detail || "the sync could not carry it"}`,
627
+ };
628
+ }
602
629
  return {
603
630
  failure: `"${file}" is ${outcome.status}: run \`applet build\` in applets/${appletId} on the Computer first`,
604
631
  };
@@ -731,12 +758,13 @@ export function createAppletCapabilityHostV1(
731
758
 
732
759
  // Force a pull of the source root so the publish sees what the Bot just
733
760
  // built, not the last synced copy. See the seam note on the option.
734
- await options.syncSourceRootNow?.(input.appletId);
761
+ const synced = await options.syncSourceRootNow?.(input.appletId);
735
762
 
736
763
  const server = await readFile(
737
764
  input.appletId,
738
765
  "dist/server.js",
739
766
  APPLET_MAX_SERVER_BYTES_V1,
767
+ synced,
740
768
  );
741
769
  if ("failure" in server) {
742
770
  return settle(failed(input.appletId, "unbuilt", server.failure));
@@ -745,6 +773,7 @@ export function createAppletCapabilityHostV1(
745
773
  input.appletId,
746
774
  "dist/ui.html",
747
775
  APPLET_MAX_UI_BYTES_V1,
776
+ synced,
748
777
  );
749
778
  if ("failure" in ui) {
750
779
  return settle(failed(input.appletId, "unbuilt", ui.failure));
@@ -753,6 +782,7 @@ export function createAppletCapabilityHostV1(
753
782
  input.appletId,
754
783
  "dist/manifest.json",
755
784
  APPLET_MAX_MANIFEST_BYTES_V1,
785
+ synced,
756
786
  );
757
787
  if ("failure" in manifestFile) {
758
788
  return settle(failed(input.appletId, "unbuilt", manifestFile.failure));
@@ -19,6 +19,7 @@ import { planBotRunRecovery } from "./backend-recovery.js";
19
19
  import {
20
20
  CLIENT_RUN_LIST_MAX_BYTES,
21
21
  CLIENT_RUN_PAGE_LIMIT,
22
+ CLIENT_RUN_SCAN_LIMIT,
22
23
  clientRunListWireBytes,
23
24
  } from "./run-protocol.js";
24
25
 
@@ -1475,6 +1476,139 @@ describe("Bot recovery", () => {
1475
1476
  ).toBe(false);
1476
1477
  });
1477
1478
 
1479
+ /**
1480
+ * The run index is global; the transcript is one conversation. These cover
1481
+ * the shape that used to answer "this conversation is empty" whenever the
1482
+ * newest page of the index happened to hold nothing the reader could see.
1483
+ */
1484
+ async function writeRunHistory(
1485
+ storage: MemoryStorage,
1486
+ runs: Array<{
1487
+ runId: string;
1488
+ sessionId: string;
1489
+ turnType?: "chat" | "automation";
1490
+ }>,
1491
+ ): Promise<void> {
1492
+ const baseTime = Date.parse("2026-09-01T00:00:00.000Z");
1493
+ for (const [index, entry] of runs.entries()) {
1494
+ const acceptedAt = new Date(baseTime + index * 1_000).toISOString();
1495
+ const run = {
1496
+ runId: entry.runId,
1497
+ commandFingerprint: `fingerprint-${index}`,
1498
+ sessionId: entry.sessionId,
1499
+ acceptedAt,
1500
+ input: "hello",
1501
+ events: [],
1502
+ effectAdmissions: [],
1503
+ status: "completed",
1504
+ phase: "executing",
1505
+ compositionGenerationId: "test-composition-generation",
1506
+ configurationSnapshot: initializeBotSettingsV1("primary"),
1507
+ previousEventCount: 0,
1508
+ responseText: "answer",
1509
+ ...(entry.turnType && entry.turnType !== "chat"
1510
+ ? { admission: { schemaVersion: 1, turnType: entry.turnType } }
1511
+ : {}),
1512
+ } satisfies StoredRun;
1513
+ await storage.put({
1514
+ [`run:${entry.runId}`]: run,
1515
+ [`run-index:${acceptedAt}:${entry.runId}`]: entry.runId,
1516
+ });
1517
+ }
1518
+ }
1519
+
1520
+ test("reaches an older conversation buried under newer Turns", async () => {
1521
+ const storage = new MemoryStorage();
1522
+ await writeRunHistory(storage, [
1523
+ { runId: "run-older", sessionId: "user:primary:older" },
1524
+ ...Array.from({ length: CLIENT_RUN_PAGE_LIMIT + 2 }, (_value, index) => ({
1525
+ runId: `run-newer-${index.toString().padStart(3, "0")}`,
1526
+ sessionId: "user:primary:newer",
1527
+ })),
1528
+ ]);
1529
+ const contribution = createShellBotBackendContribution({
1530
+ state: { storage } as unknown as DurableObjectState,
1531
+ env: {} as never,
1532
+ });
1533
+
1534
+ const page = await contribution.listRuns({
1535
+ schemaVersion: 1,
1536
+ conversationId: "user:primary:older",
1537
+ });
1538
+
1539
+ expect(page.runs.map((run) => run.runId)).toEqual(["run-older"]);
1540
+ expect(page.page.truncated).toBe(false);
1541
+ });
1542
+
1543
+ test("keeps the visible chat reachable behind a page of automation", async () => {
1544
+ const storage = new MemoryStorage();
1545
+ await writeRunHistory(storage, [
1546
+ { runId: "run-chat", sessionId: "user:primary" },
1547
+ ...Array.from({ length: CLIENT_RUN_PAGE_LIMIT + 2 }, (_value, index) => ({
1548
+ runId: `run-automation-${index.toString().padStart(3, "0")}`,
1549
+ sessionId: "user:primary",
1550
+ turnType: "automation" as const,
1551
+ })),
1552
+ ]);
1553
+ const contribution = createShellBotBackendContribution({
1554
+ state: { storage } as unknown as DurableObjectState,
1555
+ env: {} as never,
1556
+ });
1557
+
1558
+ const page = await contribution.listRuns({
1559
+ schemaVersion: 1,
1560
+ conversationId: "user:primary",
1561
+ });
1562
+
1563
+ expect(page.runs.map((run) => run.runId)).toEqual(["run-chat"]);
1564
+ });
1565
+
1566
+ test("offers a continuation when a scanned page selects nothing", async () => {
1567
+ const storage = new MemoryStorage();
1568
+ const buried = CLIENT_RUN_SCAN_LIMIT + CLIENT_RUN_PAGE_LIMIT;
1569
+ await writeRunHistory(storage, [
1570
+ { runId: "run-older", sessionId: "user:primary:older" },
1571
+ ...Array.from({ length: buried }, (_value, index) => ({
1572
+ runId: `run-newer-${index.toString().padStart(4, "0")}`,
1573
+ sessionId: "user:primary:newer",
1574
+ })),
1575
+ ]);
1576
+ const contribution = createShellBotBackendContribution({
1577
+ state: { storage } as unknown as DurableObjectState,
1578
+ env: {} as never,
1579
+ });
1580
+
1581
+ let page = await contribution.listRuns({
1582
+ schemaVersion: 1,
1583
+ conversationId: "user:primary:older",
1584
+ });
1585
+ // The budget stops the scan short of the match, so the page must be empty
1586
+ // *and* say where to resume. Answering `truncated: false` here is the
1587
+ // defect: it told the client the conversation had nothing in it.
1588
+ expect(page.runs).toEqual([]);
1589
+ expect(page.page.truncated).toBe(true);
1590
+ expect(page.page.nextCursor).toBeDefined();
1591
+
1592
+ const seen = new Set<string>();
1593
+ let requests = 0;
1594
+ while (page.runs.length === 0 && page.page.nextCursor) {
1595
+ if (seen.has(page.page.nextCursor)) {
1596
+ throw new Error("run list cursor stopped advancing");
1597
+ }
1598
+ seen.add(page.page.nextCursor);
1599
+ requests += 1;
1600
+ if (requests > 20) throw new Error("run list did not converge");
1601
+ page = await contribution.listRuns({
1602
+ schemaVersion: 1,
1603
+ conversationId: "user:primary:older",
1604
+ before: page.page.nextCursor,
1605
+ });
1606
+ }
1607
+
1608
+ expect(page.runs.map((run) => run.runId)).toEqual(["run-older"]);
1609
+ expect(page.page.truncated).toBe(false);
1610
+ });
1611
+
1478
1612
  test("pages large run history with bounded indexed reads and wire bytes", async () => {
1479
1613
  const storage = new MemoryStorage();
1480
1614
  const baseTime = Date.parse("2026-08-28T00:00:00.000Z");
package/src/backend.ts CHANGED
@@ -404,6 +404,7 @@ import {
404
404
  import {
405
405
  CLIENT_RUN_LIST_MAX_BYTES,
406
406
  CLIENT_RUN_PAGE_LIMIT,
407
+ CLIENT_RUN_SCAN_LIMIT,
407
408
  clientRunListWireBytes,
408
409
  createClientRunListV1,
409
410
  createClientRunStopReceiptV1,
@@ -2492,7 +2493,9 @@ export class ShellBotBackendContribution {
2492
2493
  sessions: typeof active.mounted.runtime.root.sessions;
2493
2494
  };
2494
2495
  const computerIdentity = { userId: identity.userId };
2495
- if (!root.computers?.assignment(computerIdentity)) return;
2496
+ if (!root.computers?.assignment(computerIdentity)) {
2497
+ return { status: "skipped", detail: "" } as const;
2498
+ }
2496
2499
  const session = root.sessions.get(active.sessionId);
2497
2500
  const started = session?.events.findLast(
2498
2501
  (event) => event.type === "step/start",
@@ -2503,7 +2506,7 @@ export class ShellBotBackendContribution {
2503
2506
  { botId: identity.botId },
2504
2507
  { signal: active.signal },
2505
2508
  );
2506
- await syncWorkspaceRootNowV1({
2509
+ const summary = await syncWorkspaceRootNowV1({
2507
2510
  computer,
2508
2511
  sessions: root.sessions,
2509
2512
  sessionId: active.sessionId,
@@ -2514,6 +2517,7 @@ export class ShellBotBackendContribution {
2514
2517
  ),
2515
2518
  signal: active.signal,
2516
2519
  });
2520
+ return { status: summary.status, detail: summary.detail };
2517
2521
  }
2518
2522
  : undefined,
2519
2523
  composition: {
@@ -5830,7 +5834,7 @@ export class ShellBotBackendContribution {
5830
5834
 
5831
5835
  const selected = new Map<string, { cursor?: string; run: ClientRunV1 }>();
5832
5836
  if (activeRunId) {
5833
- const active = await this.authority.readStoredRun(activeRunId);
5837
+ const active = await this.authority.readStoredRunForDisplay(activeRunId);
5834
5838
  // An automation firing occupies the object like any other run, and is
5835
5839
  // still not part of the conversation: the visible transcript never
5836
5840
  // shows one, running or settled.
@@ -5839,60 +5843,110 @@ export class ShellBotBackendContribution {
5839
5843
  run: projectClientRunOrDegradedV1(active),
5840
5844
  });
5841
5845
  }
5842
- const available = candidates.slice(0, CLIENT_RUN_PAGE_LIMIT);
5846
+ // The run index is global and the transcript is one conversation, so a
5847
+ // page of candidates is not a page of answers: 33 Turns of another
5848
+ // conversation, or of automation, used to come back as an empty,
5849
+ // *untruncated* page that told the client there was nothing older. The
5850
+ // scan cursor now advances over every candidate this call consumed,
5851
+ // whether or not it was kept, so a filtered-out page still says where to
5852
+ // resume; and the scan keeps reading batches, up to a budget, so the
5853
+ // common case answers in one request rather than making the client walk
5854
+ // the history a page at a time. Filtering reads run records only —
5855
+ // hydrating a Turn's journal is what selection costs, not what the scan
5856
+ // costs.
5843
5857
  let stoppedEarly = false;
5844
- for (const candidate of available) {
5845
- if (selected.has(candidate.runId)) {
5846
- const current = selected.get(candidate.runId)!;
5847
- selected.set(candidate.runId, { ...current, cursor: candidate.cursor });
5848
- continue;
5849
- }
5850
- const stored = await this.authority.readStoredRun(candidate.runId);
5851
- if (!stored || !isVisibleRunV1(stored) || !inConversation(stored))
5852
- continue;
5853
- const projected = projectClientRunOrDegradedV1(stored);
5854
- const tentative = [
5855
- ...selected.values(),
5856
- { cursor: candidate.cursor, run: projected },
5857
- ];
5858
- const ordered = tentative
5859
- .map((entry) => entry.run)
5860
- .sort(
5861
- (left, right) =>
5862
- left.admittedAt.localeCompare(right.admittedAt) ||
5863
- left.runId.localeCompare(right.runId),
5858
+ let exhausted = false;
5859
+ let scanCursor: string | undefined;
5860
+ let scanned = 0;
5861
+ let batch = candidates;
5862
+ for (;;) {
5863
+ const available = batch.slice(0, CLIENT_RUN_PAGE_LIMIT);
5864
+ const hasMore = batch.length > CLIENT_RUN_PAGE_LIMIT;
5865
+ for (const candidate of available) {
5866
+ if (selected.has(candidate.runId)) {
5867
+ const current = selected.get(candidate.runId)!;
5868
+ selected.set(candidate.runId, {
5869
+ ...current,
5870
+ cursor: candidate.cursor,
5871
+ });
5872
+ scanCursor = candidate.cursor;
5873
+ continue;
5874
+ }
5875
+ const header = await this.authority.readRunHeader(candidate.runId);
5876
+ if (!header || !isVisibleRunV1(header) || !inConversation(header)) {
5877
+ scanCursor = candidate.cursor;
5878
+ continue;
5879
+ }
5880
+ const stored = await this.authority.readStoredRunForDisplay(
5881
+ candidate.runId,
5864
5882
  );
5865
- const tentativePage = createClientRunListV1(ordered, {
5866
- truncated: true,
5867
- nextCursor: candidate.cursor,
5883
+ if (!stored) {
5884
+ scanCursor = candidate.cursor;
5885
+ continue;
5886
+ }
5887
+ const projected = projectClientRunOrDegradedV1(stored);
5888
+ const tentative = [
5889
+ ...selected.values(),
5890
+ { cursor: candidate.cursor, run: projected },
5891
+ ];
5892
+ const ordered = tentative
5893
+ .map((entry) => entry.run)
5894
+ .sort(
5895
+ (left, right) =>
5896
+ left.admittedAt.localeCompare(right.admittedAt) ||
5897
+ left.runId.localeCompare(right.runId),
5898
+ );
5899
+ const tentativePage = createClientRunListV1(ordered, {
5900
+ truncated: true,
5901
+ nextCursor: candidate.cursor,
5902
+ });
5903
+ const isNewestTerminal =
5904
+ ![...selected.values()].some(
5905
+ (entry) =>
5906
+ entry.run.status === "completed" || entry.run.status === "failed",
5907
+ ) &&
5908
+ (projected.status === "completed" || projected.status === "failed");
5909
+ if (
5910
+ selected.size >= CLIENT_RUN_PAGE_LIMIT ||
5911
+ (!isNewestTerminal &&
5912
+ clientRunListWireBytes(tentativePage) > CLIENT_RUN_LIST_MAX_BYTES)
5913
+ ) {
5914
+ stoppedEarly = true;
5915
+ break;
5916
+ }
5917
+ selected.set(stored.runId, {
5918
+ cursor: candidate.cursor,
5919
+ run: projected,
5920
+ });
5921
+ scanCursor = candidate.cursor;
5922
+ }
5923
+ scanned += available.length;
5924
+ if (stoppedEarly) break;
5925
+ if (!hasMore) {
5926
+ exhausted = true;
5927
+ break;
5928
+ }
5929
+ if (selected.size >= CLIENT_RUN_PAGE_LIMIT) break;
5930
+ if (scanned >= CLIENT_RUN_SCAN_LIMIT || scanCursor === undefined) break;
5931
+ batch = await this.authority.listRunIndex({
5932
+ limit: CLIENT_RUN_PAGE_LIMIT + 1,
5933
+ before: scanCursor,
5868
5934
  });
5869
- const isNewestTerminal =
5870
- ![...selected.values()].some(
5871
- (entry) =>
5872
- entry.run.status === "completed" || entry.run.status === "failed",
5873
- ) &&
5874
- (projected.status === "completed" || projected.status === "failed");
5875
- if (
5876
- selected.size >= CLIENT_RUN_PAGE_LIMIT ||
5877
- (!isNewestTerminal &&
5878
- clientRunListWireBytes(tentativePage) > CLIENT_RUN_LIST_MAX_BYTES)
5879
- ) {
5880
- stoppedEarly = true;
5935
+ if (batch.length === 0) {
5936
+ exhausted = true;
5881
5937
  break;
5882
5938
  }
5883
- selected.set(stored.runId, { cursor: candidate.cursor, run: projected });
5884
5939
  }
5885
5940
  const orderedEntries = [...selected.values()].sort(
5886
5941
  (left, right) =>
5887
5942
  left.run.admittedAt.localeCompare(right.run.admittedAt) ||
5888
5943
  left.run.runId.localeCompare(right.run.runId),
5889
5944
  );
5890
- const oldestCursor = orderedEntries.find((entry) => entry.cursor)?.cursor;
5891
- const truncated = stoppedEarly || candidates.length > CLIENT_RUN_PAGE_LIMIT;
5945
+ const truncated = !exhausted;
5892
5946
  const page = createClientRunListV1(
5893
5947
  orderedEntries.map((entry) => entry.run),
5894
- truncated && oldestCursor
5895
- ? { truncated: true, nextCursor: oldestCursor }
5948
+ truncated && scanCursor
5949
+ ? { truncated: true, nextCursor: scanCursor }
5896
5950
  : { truncated: false },
5897
5951
  // Announcements belong to the Session, not to a page of Turns, so only
5898
5952
  // the newest page carries them.