@frockbot/plugin-shell 0.3.22 → 0.3.24
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-composition.test.ts +221 -0
- package/src/backend-composition.ts +90 -0
- package/src/backend-recovery-integration.test.ts +134 -0
- package/src/backend.ts +133 -43
- 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.24",
|
|
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.24",
|
|
56
|
+
"@frockbot/application-foundation": "0.3.24",
|
|
57
|
+
"@frockbot/catalog-core": "0.3.24",
|
|
58
|
+
"@frockbot/client-core": "0.3.24",
|
|
59
|
+
"@frockbot/client-ui": "0.3.24",
|
|
60
|
+
"@frockbot/computer-core": "0.3.24",
|
|
61
|
+
"@frockbot/computer-host-protocol": "0.3.24",
|
|
62
|
+
"@frockbot/configuration-core": "0.3.24",
|
|
63
|
+
"@frockbot/connection-core": "0.3.24",
|
|
64
|
+
"@frockbot/kernel-agent-loop": "0.3.24",
|
|
65
|
+
"@frockbot/kernel-composition": "0.3.24",
|
|
66
|
+
"@frockbot/kernel-contracts": "0.3.24",
|
|
67
|
+
"@frockbot/kernel-do": "0.3.24",
|
|
68
|
+
"@frockbot/machine-protocol": "0.3.24",
|
|
69
|
+
"@frockbot/plugin-applets": "0.3.24",
|
|
70
|
+
"@frockbot/plugin-authoring": "0.3.24",
|
|
71
|
+
"@frockbot/plugin-bot-template": "0.3.24",
|
|
72
|
+
"@frockbot/plugin-computer": "0.3.24",
|
|
73
|
+
"@frockbot/plugin-flock": "0.3.24",
|
|
74
|
+
"@frockbot/plugin-image": "0.3.24",
|
|
75
|
+
"@frockbot/plugin-machine-messages": "0.3.24",
|
|
76
|
+
"@frockbot/plugin-mcp": "0.3.24",
|
|
77
|
+
"@frockbot/plugin-memory": "0.3.24",
|
|
78
|
+
"@frockbot/plugin-package-catalog": "0.3.24",
|
|
79
|
+
"@frockbot/plugin-routines": "0.3.24",
|
|
80
|
+
"@frockbot/plugin-skills": "0.3.24",
|
|
81
|
+
"@frockbot/plugin-subagents": "0.3.24",
|
|
82
|
+
"@frockbot/plugin-user-machine": "0.3.24",
|
|
83
|
+
"@frockbot/protocol": "0.3.24",
|
|
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.24",
|
|
90
|
+
"@frockbot/plugin-tools": "0.3.24",
|
|
91
91
|
"@types/bun": "1.3.6",
|
|
92
92
|
"@types/node": "26.2.0",
|
|
93
93
|
"@vitejs/plugin-vue": "6.0.8",
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import type { ApplicationPlan } from "@frockbot/kernel-composition/compiler";
|
|
3
|
+
import {
|
|
4
|
+
compositionArtifactSetHashV1,
|
|
5
|
+
decodeCompositionGenerationV1,
|
|
6
|
+
type CompositionGenerationV1,
|
|
7
|
+
type CompositionMemberV1,
|
|
8
|
+
} from "@frockbot/kernel-composition/generation";
|
|
9
|
+
import {
|
|
10
|
+
bootstrapCompositionGeneration,
|
|
11
|
+
resolveDeploymentCompositionV1,
|
|
12
|
+
DEPLOYMENT_FOLLOW_SUMMARY_V1,
|
|
13
|
+
} from "./backend-composition.js";
|
|
14
|
+
|
|
15
|
+
const ARTIFACT_A = {
|
|
16
|
+
contentHash: "a".repeat(64),
|
|
17
|
+
size: 10,
|
|
18
|
+
mediaType: "application/javascript" as const,
|
|
19
|
+
bundlerVersion: "1",
|
|
20
|
+
};
|
|
21
|
+
const ARTIFACT_B = { ...ARTIFACT_A, contentHash: "b".repeat(64) };
|
|
22
|
+
|
|
23
|
+
/** A compiled application: the shell in-process, the Applets as an artifact. */
|
|
24
|
+
function plan(input: {
|
|
25
|
+
appletsManifest: unknown;
|
|
26
|
+
appletsArtifact?: typeof ARTIFACT_A;
|
|
27
|
+
extra?: boolean;
|
|
28
|
+
}): ApplicationPlan {
|
|
29
|
+
return {
|
|
30
|
+
packages: [
|
|
31
|
+
{
|
|
32
|
+
id: "shell",
|
|
33
|
+
specifier: "@frockbot/plugin-shell",
|
|
34
|
+
version: "1.0.0",
|
|
35
|
+
manifest: { id: "shell" },
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
id: "applets",
|
|
39
|
+
specifier: "@frockbot/plugin-applets",
|
|
40
|
+
version: "1.0.0",
|
|
41
|
+
manifest: input.appletsManifest,
|
|
42
|
+
artifact: input.appletsArtifact ?? ARTIFACT_A,
|
|
43
|
+
},
|
|
44
|
+
...(input.extra
|
|
45
|
+
? [
|
|
46
|
+
{
|
|
47
|
+
id: "extra",
|
|
48
|
+
specifier: "@frockbot/plugin-extra",
|
|
49
|
+
version: "1.0.0",
|
|
50
|
+
manifest: { id: "extra" },
|
|
51
|
+
},
|
|
52
|
+
]
|
|
53
|
+
: []),
|
|
54
|
+
],
|
|
55
|
+
} as unknown as ApplicationPlan;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function store(current: CompositionGenerationV1) {
|
|
59
|
+
const proposed: CompositionGenerationV1[] = [];
|
|
60
|
+
return {
|
|
61
|
+
proposed,
|
|
62
|
+
current: () => Promise.resolve(current),
|
|
63
|
+
propose: (generation: CompositionGenerationV1) => {
|
|
64
|
+
proposed.push(generation);
|
|
65
|
+
return Promise.resolve();
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The generation a Bot pinned under yesterday's deployment, plus a User member. */
|
|
71
|
+
async function pinned(
|
|
72
|
+
deployment: ApplicationPlan,
|
|
73
|
+
extraMembers: CompositionMemberV1[] = [],
|
|
74
|
+
): Promise<CompositionGenerationV1> {
|
|
75
|
+
const bootstrap = await bootstrapCompositionGeneration(
|
|
76
|
+
deployment,
|
|
77
|
+
"2026-09-04T09:00:00.000Z",
|
|
78
|
+
);
|
|
79
|
+
const members = [...bootstrap.members, ...extraMembers].sort((a, b) =>
|
|
80
|
+
a.packageId.localeCompare(b.packageId),
|
|
81
|
+
);
|
|
82
|
+
const applets = [
|
|
83
|
+
{
|
|
84
|
+
kind: "applet" as const,
|
|
85
|
+
appletId: "user-1.aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
|
86
|
+
generationId: "g1",
|
|
87
|
+
tools: [
|
|
88
|
+
{
|
|
89
|
+
name: "add_todo",
|
|
90
|
+
description: "Add",
|
|
91
|
+
inputSchema: { type: "object" },
|
|
92
|
+
},
|
|
93
|
+
],
|
|
94
|
+
provenance: {
|
|
95
|
+
kind: "user" as const,
|
|
96
|
+
packageId: "user-1.aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
|
97
|
+
version: "g1",
|
|
98
|
+
userId: "user-1",
|
|
99
|
+
authoredAt: "2026-09-04T09:00:00.000Z",
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
];
|
|
103
|
+
const artifactSetHash = await compositionArtifactSetHashV1(members, applets);
|
|
104
|
+
return decodeCompositionGenerationV1({
|
|
105
|
+
schemaVersion: 1,
|
|
106
|
+
generationId: `2026-09-04T09:00:00.000Z:${artifactSetHash.slice(0, 16)}`,
|
|
107
|
+
artifactSetHash,
|
|
108
|
+
createdAt: "2026-09-04T09:00:00.000Z",
|
|
109
|
+
origin: { kind: "bootstrap" },
|
|
110
|
+
members,
|
|
111
|
+
applets,
|
|
112
|
+
status: "active",
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const USER_MEMBER: CompositionMemberV1 = {
|
|
117
|
+
packageId: "user-1.notes",
|
|
118
|
+
specifier: "frockbot:user-1.notes",
|
|
119
|
+
version: "3",
|
|
120
|
+
manifestHash: "d".repeat(64),
|
|
121
|
+
provenance: {
|
|
122
|
+
kind: "user",
|
|
123
|
+
packageId: "user-1.notes",
|
|
124
|
+
version: "3",
|
|
125
|
+
userId: "user-1",
|
|
126
|
+
authoredAt: "2026-09-04T10:00:00.000Z",
|
|
127
|
+
},
|
|
128
|
+
artifact: { ...ARTIFACT_A, contentHash: "e".repeat(64) },
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
describe("a Bot's built-in members follow the deployment", () => {
|
|
132
|
+
test("an unchanged deployment proposes nothing", async () => {
|
|
133
|
+
const yesterday = plan({ appletsManifest: { id: "applets", v: 1 } });
|
|
134
|
+
const composition = store(await pinned(yesterday, [USER_MEMBER]));
|
|
135
|
+
const result = await resolveDeploymentCompositionV1({
|
|
136
|
+
plan: yesterday,
|
|
137
|
+
composition,
|
|
138
|
+
now: new Date("2026-09-05T07:30:00.000Z"),
|
|
139
|
+
});
|
|
140
|
+
expect(result).toBeUndefined();
|
|
141
|
+
expect(composition.proposed).toHaveLength(0);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("a changed manifest or artifact is a new pinned generation with the old one as parent", async () => {
|
|
145
|
+
const yesterday = plan({ appletsManifest: { id: "applets", v: 1 } });
|
|
146
|
+
const current = await pinned(yesterday, [USER_MEMBER]);
|
|
147
|
+
const composition = store(current);
|
|
148
|
+
const today = plan({
|
|
149
|
+
appletsManifest: { id: "applets", v: 2 },
|
|
150
|
+
appletsArtifact: ARTIFACT_B,
|
|
151
|
+
});
|
|
152
|
+
const result = await resolveDeploymentCompositionV1({
|
|
153
|
+
plan: today,
|
|
154
|
+
composition,
|
|
155
|
+
now: new Date("2026-09-05T07:30:00.000Z"),
|
|
156
|
+
});
|
|
157
|
+
expect(result).toBeDefined();
|
|
158
|
+
expect(composition.proposed).toEqual([result!]);
|
|
159
|
+
expect(result!.status).toBe("pending");
|
|
160
|
+
expect(result!.parentGenerationId).toBe(current.generationId);
|
|
161
|
+
expect(result!.summary).toBe(DEPLOYMENT_FOLLOW_SUMMARY_V1);
|
|
162
|
+
const deployed = await bootstrapCompositionGeneration(
|
|
163
|
+
today,
|
|
164
|
+
result!.createdAt,
|
|
165
|
+
);
|
|
166
|
+
const applets = result!.members.find((m) => m.packageId === "applets");
|
|
167
|
+
expect(applets).toEqual(
|
|
168
|
+
deployed.members.find((m) => m.packageId === "applets"),
|
|
169
|
+
);
|
|
170
|
+
// The shell did not change, the User's own member is carried over
|
|
171
|
+
// untouched, and the Applet members stay pinned.
|
|
172
|
+
expect(result!.members.find((m) => m.packageId === "shell")).toEqual(
|
|
173
|
+
current.members.find((m) => m.packageId === "shell"),
|
|
174
|
+
);
|
|
175
|
+
expect(result!.members.find((m) => m.packageId === "user-1.notes")).toEqual(
|
|
176
|
+
USER_MEMBER,
|
|
177
|
+
);
|
|
178
|
+
expect(result!.applets).toEqual(current.applets);
|
|
179
|
+
expect(result!.artifactSetHash).toBe(
|
|
180
|
+
await compositionArtifactSetHashV1(result!.members, result!.applets),
|
|
181
|
+
);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test("a built-in Package the deployment added or dropped changes the set", async () => {
|
|
185
|
+
const yesterday = plan({ appletsManifest: { id: "applets", v: 1 } });
|
|
186
|
+
const composition = store(await pinned(yesterday));
|
|
187
|
+
const today = plan({
|
|
188
|
+
appletsManifest: { id: "applets", v: 1 },
|
|
189
|
+
extra: true,
|
|
190
|
+
});
|
|
191
|
+
const added = await resolveDeploymentCompositionV1({
|
|
192
|
+
plan: today,
|
|
193
|
+
composition,
|
|
194
|
+
});
|
|
195
|
+
expect(added!.members.map((m) => m.packageId)).toEqual([
|
|
196
|
+
"applets",
|
|
197
|
+
"extra",
|
|
198
|
+
"shell",
|
|
199
|
+
]);
|
|
200
|
+
|
|
201
|
+
const dropped = await resolveDeploymentCompositionV1({
|
|
202
|
+
plan: yesterday,
|
|
203
|
+
composition: store(await pinned(today)),
|
|
204
|
+
});
|
|
205
|
+
expect(dropped!.members.map((m) => m.packageId)).toEqual([
|
|
206
|
+
"applets",
|
|
207
|
+
"shell",
|
|
208
|
+
]);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test("a version bump alone does not spend a generation", async () => {
|
|
212
|
+
const yesterday = plan({ appletsManifest: { id: "applets", v: 1 } });
|
|
213
|
+
const composition = store(await pinned(yesterday));
|
|
214
|
+
const bumped = {
|
|
215
|
+
packages: yesterday.packages.map((pkg) => ({ ...pkg, version: "1.0.1" })),
|
|
216
|
+
} as unknown as ApplicationPlan;
|
|
217
|
+
expect(
|
|
218
|
+
await resolveDeploymentCompositionV1({ plan: bumped, composition }),
|
|
219
|
+
).toBeUndefined();
|
|
220
|
+
});
|
|
221
|
+
});
|
|
@@ -18,9 +18,13 @@ import {
|
|
|
18
18
|
import type { ApplicationPlan } from "@frockbot/kernel-composition/compiler";
|
|
19
19
|
import {
|
|
20
20
|
bootstrapGeneration,
|
|
21
|
+
compositionArtifactSetHashV1,
|
|
22
|
+
compositionGenerationIdV1,
|
|
23
|
+
decodeCompositionGenerationV1,
|
|
21
24
|
type CompositionGenerationV1,
|
|
22
25
|
type CompositionHost,
|
|
23
26
|
type CompositionMemberV1,
|
|
27
|
+
type CompositionStore,
|
|
24
28
|
type MountedComposition,
|
|
25
29
|
} from "@frockbot/kernel-composition/generation";
|
|
26
30
|
import {
|
|
@@ -57,6 +61,92 @@ export function bootstrapCompositionGeneration(
|
|
|
57
61
|
);
|
|
58
62
|
}
|
|
59
63
|
|
|
64
|
+
/** The audit line a deployment-following generation carries. */
|
|
65
|
+
export const DEPLOYMENT_FOLLOW_SUMMARY_V1 =
|
|
66
|
+
"Updated the built-in Packages to this deployment's";
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Resolve the deployment's built-in Packages into this Bot's next generation.
|
|
70
|
+
*
|
|
71
|
+
* A first-party member's manifest and artifact live in the compiled
|
|
72
|
+
* application, keyed by hash. A deploy that changes one of them (2026-09-05:
|
|
73
|
+
* the Applets list page) leaves every pinned generation naming a manifest this
|
|
74
|
+
* deployment no longer ships, and every Turn of every Bot fails to mount. A
|
|
75
|
+
* built-in member is the deployment's to update, never the Bot's, so before a
|
|
76
|
+
* Turn is admitted the Bot's first-party members are brought up to the
|
|
77
|
+
* application's, as a new generation with the old one as its parent. Members
|
|
78
|
+
* the Bot or its User put in are carried over exactly as they are; Applet
|
|
79
|
+
* members stay pinned; a deployment that changes nothing proposes nothing.
|
|
80
|
+
*
|
|
81
|
+
* Only the manifest and the artifact decide. A version string that moved with
|
|
82
|
+
* a release while both stayed the same mounts exactly as before, and a
|
|
83
|
+
* generation per Bot per release would eat the User's retention quota for
|
|
84
|
+
* nothing.
|
|
85
|
+
*/
|
|
86
|
+
export async function resolveDeploymentCompositionV1(options: {
|
|
87
|
+
plan: ApplicationPlan;
|
|
88
|
+
composition: Pick<CompositionStore, "current" | "propose">;
|
|
89
|
+
now?: Date;
|
|
90
|
+
}): Promise<CompositionGenerationV1 | undefined> {
|
|
91
|
+
const current = await options.composition.current();
|
|
92
|
+
const createdAt = (options.now ?? new Date()).toISOString();
|
|
93
|
+
const deployed = await bootstrapCompositionGeneration(
|
|
94
|
+
options.plan,
|
|
95
|
+
createdAt,
|
|
96
|
+
);
|
|
97
|
+
const shipped = new Map(
|
|
98
|
+
deployed.members.map((member) => [member.packageId, member]),
|
|
99
|
+
);
|
|
100
|
+
const members: CompositionMemberV1[] = [];
|
|
101
|
+
let changed = false;
|
|
102
|
+
for (const member of current.members) {
|
|
103
|
+
if (member.provenance.kind !== "first-party") {
|
|
104
|
+
members.push(member);
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
const replacement = shipped.get(member.packageId);
|
|
108
|
+
shipped.delete(member.packageId);
|
|
109
|
+
if (!replacement) {
|
|
110
|
+
// The deployment no longer ships it; nothing could mount it anyway.
|
|
111
|
+
changed = true;
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
if (
|
|
115
|
+
replacement.manifestHash !== member.manifestHash ||
|
|
116
|
+
replacement.artifact?.contentHash !== member.artifact?.contentHash
|
|
117
|
+
) {
|
|
118
|
+
changed = true;
|
|
119
|
+
members.push(replacement);
|
|
120
|
+
} else {
|
|
121
|
+
members.push(member);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
for (const added of shipped.values()) {
|
|
125
|
+
changed = true;
|
|
126
|
+
members.push(added);
|
|
127
|
+
}
|
|
128
|
+
if (!changed) return undefined;
|
|
129
|
+
const ordered = members.sort((left, right) =>
|
|
130
|
+
left.packageId.localeCompare(right.packageId),
|
|
131
|
+
);
|
|
132
|
+
const applets = current.applets ?? [];
|
|
133
|
+
const artifactSetHash = await compositionArtifactSetHashV1(ordered, applets);
|
|
134
|
+
const generation = decodeCompositionGenerationV1({
|
|
135
|
+
schemaVersion: 1,
|
|
136
|
+
generationId: compositionGenerationIdV1(createdAt, artifactSetHash),
|
|
137
|
+
artifactSetHash,
|
|
138
|
+
parentGenerationId: current.generationId,
|
|
139
|
+
summary: DEPLOYMENT_FOLLOW_SUMMARY_V1,
|
|
140
|
+
createdAt,
|
|
141
|
+
origin: { kind: "bootstrap" },
|
|
142
|
+
members: ordered,
|
|
143
|
+
...(applets.length === 0 ? {} : { applets }),
|
|
144
|
+
status: "pending",
|
|
145
|
+
});
|
|
146
|
+
await options.composition.propose(generation, { pin: true });
|
|
147
|
+
return generation;
|
|
148
|
+
}
|
|
149
|
+
|
|
60
150
|
export interface ShellMountedComposition extends MountedComposition {
|
|
61
151
|
readonly runtime: FoundationRuntime;
|
|
62
152
|
}
|
|
@@ -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");
|