@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 +32 -32
- package/src/backend-applets.test.ts +38 -0
- package/src/backend-applets.ts +32 -2
- package/src/backend-recovery-integration.test.ts +134 -0
- package/src/backend.ts +98 -44
- package/src/client/AppletCanvas.vue +128 -30
- package/src/client/FrockBotApp.vue +111 -2
- package/src/client/applet-building-view.test.ts +69 -0
- package/src/client/applet-progress.test.ts +300 -0
- package/src/client/applet-progress.ts +339 -0
- package/src/client/deployment.test.ts +152 -0
- package/src/client/deployment.ts +138 -0
- package/src/client/index.test.ts +71 -0
- package/src/client/index.ts +58 -0
- package/src/client/styles.css +91 -4
- package/src/run-protocol.ts +10 -0
- package/src/shared.ts +14 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@frockbot/plugin-shell",
|
|
3
|
-
"version": "0.3.
|
|
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.
|
|
56
|
-
"@frockbot/application-foundation": "0.3.
|
|
57
|
-
"@frockbot/catalog-core": "0.3.
|
|
58
|
-
"@frockbot/client-core": "0.3.
|
|
59
|
-
"@frockbot/client-ui": "0.3.
|
|
60
|
-
"@frockbot/computer-core": "0.3.
|
|
61
|
-
"@frockbot/computer-host-protocol": "0.3.
|
|
62
|
-
"@frockbot/configuration-core": "0.3.
|
|
63
|
-
"@frockbot/connection-core": "0.3.
|
|
64
|
-
"@frockbot/kernel-agent-loop": "0.3.
|
|
65
|
-
"@frockbot/kernel-composition": "0.3.
|
|
66
|
-
"@frockbot/kernel-contracts": "0.3.
|
|
67
|
-
"@frockbot/kernel-do": "0.3.
|
|
68
|
-
"@frockbot/machine-protocol": "0.3.
|
|
69
|
-
"@frockbot/plugin-applets": "0.3.
|
|
70
|
-
"@frockbot/plugin-authoring": "0.3.
|
|
71
|
-
"@frockbot/plugin-bot-template": "0.3.
|
|
72
|
-
"@frockbot/plugin-computer": "0.3.
|
|
73
|
-
"@frockbot/plugin-flock": "0.3.
|
|
74
|
-
"@frockbot/plugin-image": "0.3.
|
|
75
|
-
"@frockbot/plugin-machine-messages": "0.3.
|
|
76
|
-
"@frockbot/plugin-mcp": "0.3.
|
|
77
|
-
"@frockbot/plugin-memory": "0.3.
|
|
78
|
-
"@frockbot/plugin-package-catalog": "0.3.
|
|
79
|
-
"@frockbot/plugin-routines": "0.3.
|
|
80
|
-
"@frockbot/plugin-skills": "0.3.
|
|
81
|
-
"@frockbot/plugin-subagents": "0.3.
|
|
82
|
-
"@frockbot/plugin-user-machine": "0.3.
|
|
83
|
-
"@frockbot/protocol": "0.3.
|
|
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.
|
|
90
|
-
"@frockbot/plugin-tools": "0.3.
|
|
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: {
|
package/src/backend-applets.ts
CHANGED
|
@@ -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<
|
|
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))
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
5845
|
-
|
|
5846
|
-
|
|
5847
|
-
|
|
5848
|
-
|
|
5849
|
-
|
|
5850
|
-
const
|
|
5851
|
-
|
|
5852
|
-
|
|
5853
|
-
|
|
5854
|
-
|
|
5855
|
-
|
|
5856
|
-
|
|
5857
|
-
|
|
5858
|
-
|
|
5859
|
-
|
|
5860
|
-
|
|
5861
|
-
|
|
5862
|
-
|
|
5863
|
-
|
|
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
|
-
|
|
5866
|
-
|
|
5867
|
-
|
|
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
|
-
|
|
5870
|
-
|
|
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
|
|
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 &&
|
|
5895
|
-
? { truncated: true, nextCursor:
|
|
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.
|