@frockbot/plugin-shell 0.3.22 → 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.
@@ -0,0 +1,339 @@
1
+ /**
2
+ * What the Bot is doing to the focused Applet, in words a person reads.
3
+ *
4
+ * Building an Applet takes minutes: the Bot scaffolds it, edits the files on
5
+ * the Computer, runs `applet check` and `applet build` there, and only then
6
+ * publishes it. Until that publish lands there is nothing to run, so the
7
+ * canvas beside the conversation used to say "Not published yet" for the whole
8
+ * of it — true, unchanging, and no help to somebody watching. This module is
9
+ * the missing sentence: it reads what the client already knows and returns the
10
+ * one line that says where the work has got to.
11
+ *
12
+ * It is a projection, never an authority. Nothing here is stored, nothing here
13
+ * is asked of the backend, and every input is something the thread was already
14
+ * drawing. When the signals say nothing specific, the answer is the honest
15
+ * fallback rather than an invented step.
16
+ */
17
+ import type {
18
+ AppletBuildViewV1,
19
+ AppletSourceViewV1,
20
+ AppletSummaryV1,
21
+ } from "@frockbot/kernel-contracts";
22
+ import type { WebChatMessage, WebToolActivity } from "../shared.js";
23
+
24
+ /**
25
+ * Where the work has got to.
26
+ *
27
+ * The order is the order the Bot does them in, and the projection takes the
28
+ * furthest one it has evidence for. `unknown` is a draft with nothing said
29
+ * about it yet — the Bot has been asked, and the Turn that answers has not
30
+ * reached the Applet.
31
+ */
32
+ export type AppletProgressStageV1 =
33
+ | "unknown"
34
+ | "created"
35
+ | "writing"
36
+ | "checking"
37
+ | "building"
38
+ | "publishing"
39
+ | "published";
40
+
41
+ /** The tail of a check or a build, so a long log never fills the panel. */
42
+ export const APPLET_PROGRESS_OUTPUT_LINES_V1 = 12;
43
+ export const APPLET_PROGRESS_LINE_CHARACTERS_V1 = 200;
44
+ /** A failure is a sentence in the panel, not a wall of text. */
45
+ export const APPLET_PROGRESS_FAILURE_CHARACTERS_V1 = 400;
46
+
47
+ export interface AppletProgressV1 {
48
+ stage: AppletProgressStageV1;
49
+ /** The line the canvas and the phone both show. */
50
+ label: string;
51
+ /** True while the Turn doing this is still going. */
52
+ working: boolean;
53
+ /** True when this step finished and finished cleanly. */
54
+ done: boolean;
55
+ /** What went wrong, in the words of whatever refused. */
56
+ failure?: string;
57
+ /** The last lines the check or the build printed. */
58
+ output?: string[];
59
+ }
60
+
61
+ export interface AppletProgressInputV1 {
62
+ /** The focused Applet's directory entry, when there is one. */
63
+ applet?: AppletSummaryV1 | null;
64
+ /** The Applet's source, as the canvas already reads it. */
65
+ source?: AppletSourceViewV1;
66
+ /** The outcome the Applet authority recorded, when it has recorded one. */
67
+ build?: AppletBuildViewV1;
68
+ /** The newest Turn's tool activity, oldest first. */
69
+ tools?: readonly WebToolActivity[];
70
+ /** True while that Turn is still running. */
71
+ running?: boolean;
72
+ }
73
+
74
+ /**
75
+ * The words for each step, and for the step having finished.
76
+ *
77
+ * A check that has come back clean is worth its own sentence: "Checking the
78
+ * code" would keep saying a thing was happening after it stopped happening.
79
+ */
80
+ const LABELS: Record<AppletProgressStageV1, { doing: string; done: string }> = {
81
+ // Matches the Applets list, which says "Still being built" for a draft.
82
+ unknown: { doing: "Still being built", done: "Still being built" },
83
+ created: { doing: "Just getting started", done: "Just getting started" },
84
+ writing: { doing: "Writing the code", done: "Writing the code" },
85
+ checking: { doing: "Checking the code", done: "The code checks out" },
86
+ building: {
87
+ doing: "Putting it together",
88
+ done: "Built and ready to go live",
89
+ },
90
+ publishing: {
91
+ doing: "Getting it ready to open",
92
+ done: "Getting it ready to open",
93
+ },
94
+ published: { doing: "Ready to use", done: "Ready to use" },
95
+ };
96
+
97
+ const ORDER: AppletProgressStageV1[] = [
98
+ "unknown",
99
+ "created",
100
+ "writing",
101
+ "checking",
102
+ "building",
103
+ "publishing",
104
+ "published",
105
+ ];
106
+
107
+ function furthest(
108
+ left: AppletProgressStageV1,
109
+ right: AppletProgressStageV1,
110
+ ): AppletProgressStageV1 {
111
+ return ORDER.indexOf(right) > ORDER.indexOf(left) ? right : left;
112
+ }
113
+
114
+ /** The bare tool name, whether it arrived namespaced or native. */
115
+ function toolName(activity: WebToolActivity): string {
116
+ const slash = activity.name.lastIndexOf("/");
117
+ return slash < 0 ? activity.name : activity.name.slice(slash + 1);
118
+ }
119
+
120
+ /**
121
+ * Whether this tool call is about the Applet in the canvas.
122
+ *
123
+ * A dynamic call carries its parsed arguments, so a publish of some other
124
+ * Applet never moves this one's line. `applet_create` names no id — it is
125
+ * making one — so it counts for whichever Applet the Session then focuses,
126
+ * which is the one it just created.
127
+ */
128
+ function namesApplet(activity: WebToolActivity, appletId: string): boolean {
129
+ const input = activity.input;
130
+ if (!input || typeof input !== "object" || Array.isArray(input)) return true;
131
+ const named = (input as Record<string, unknown>).appletId;
132
+ return typeof named === "string" ? named === appletId : true;
133
+ }
134
+
135
+ function trimLine(line: string): string {
136
+ return line.length > APPLET_PROGRESS_LINE_CHARACTERS_V1
137
+ ? `${line.slice(0, APPLET_PROGRESS_LINE_CHARACTERS_V1 - 1)}…`
138
+ : line;
139
+ }
140
+
141
+ function tail(text: string): string[] {
142
+ const lines = text
143
+ .split("\n")
144
+ .map((line) => line.trimEnd())
145
+ .filter((line) => line.length > 0);
146
+ return lines.slice(-APPLET_PROGRESS_OUTPUT_LINES_V1).map(trimLine);
147
+ }
148
+
149
+ function sentence(text: string): string {
150
+ const trimmed = text.trim();
151
+ if (trimmed.length === 0) return "";
152
+ return trimmed.length > APPLET_PROGRESS_FAILURE_CHARACTERS_V1
153
+ ? `${trimmed.slice(0, APPLET_PROGRESS_FAILURE_CHARACTERS_V1 - 1)}…`
154
+ : trimmed;
155
+ }
156
+
157
+ /**
158
+ * Whether a shell command's output is the `applet` CLI reporting on itself.
159
+ *
160
+ * The client is never told what a `computer_exec` ran: the Turn projection
161
+ * carries the input of dynamic tool calls only, and `computer_exec` is a
162
+ * native tool. What it does carry is the result, and the CLI's output is a
163
+ * stated contract — `applet check:` on one line, and the three `dist/` paths a
164
+ * build writes — so recognising it is reading a published shape rather than
165
+ * guessing at a command. Anything else the Bot ran on the Computer looks like
166
+ * nothing here and is ignored, which is the right failure: no line rather than
167
+ * a wrong one.
168
+ */
169
+ export function appletCommandOutputV1(
170
+ text: string | undefined,
171
+ ): { command: "check" | "build"; output: string[] } | undefined {
172
+ if (!text) return undefined;
173
+ if (/^applet check:/m.test(text)) {
174
+ return { command: "check", output: tail(text) };
175
+ }
176
+ if (/dist\/manifest\.json\b/.test(text) && /dist\/server\.js\b/.test(text)) {
177
+ return { command: "build", output: tail(text) };
178
+ }
179
+ return undefined;
180
+ }
181
+
182
+ /** Whether the `applet check` output the CLI printed found errors. */
183
+ function checkFailed(text: string): boolean {
184
+ return /^applet check: \d+ error/m.test(text);
185
+ }
186
+
187
+ /**
188
+ * The one line about the focused Applet, and what sits under it.
189
+ *
190
+ * Returns `undefined` when there is no Applet in the canvas to say it about.
191
+ */
192
+ export function appletProgressV1(
193
+ input: AppletProgressInputV1,
194
+ ): AppletProgressV1 | undefined {
195
+ const applet = input.applet;
196
+ if (!applet) return undefined;
197
+
198
+ let stage: AppletProgressStageV1 = "unknown";
199
+ let failure: string | undefined;
200
+ let output: string[] | undefined;
201
+ let done = false;
202
+
203
+ // The directory entry is the settled fact: a generation is current, so the
204
+ // Applet runs. A Turn working on it now moves the line off this again.
205
+ if (applet.currentGenerationId) {
206
+ stage = "published";
207
+ done = true;
208
+ }
209
+
210
+ // What the Applet authority recorded, where it has recorded anything. It is
211
+ // read before the Turn's own evidence so a running Turn wins.
212
+ const recorded = input.build;
213
+ if (recorded && recorded.status !== "unknown") {
214
+ stage = furthest(
215
+ stage,
216
+ recorded.command === "build" ? "building" : "checking",
217
+ );
218
+ done = recorded.status === "passed";
219
+ if (recorded.status === "failed") {
220
+ failure = sentence(recorded.summary ?? "The last check did not pass.");
221
+ if (recorded.diagnostics && recorded.diagnostics.length > 0) {
222
+ output = recorded.diagnostics
223
+ .slice(-APPLET_PROGRESS_OUTPUT_LINES_V1)
224
+ .map(trimLine);
225
+ }
226
+ }
227
+ }
228
+
229
+ // Source on the Workspace means the Bot has written files, whether or not
230
+ // this Turn is the one that wrote them.
231
+ if ((input.source?.files.length ?? 0) > 0) stage = furthest(stage, "writing");
232
+
233
+ let working = false;
234
+ for (const activity of input.tools ?? []) {
235
+ const name = toolName(activity);
236
+ if (name === "computer_exec") {
237
+ const ran = appletCommandOutputV1(activity.text);
238
+ if (activity.status === "running") {
239
+ // A shell command in flight during a build is the Bot working on the
240
+ // Applet; which command it is only becomes knowable when it returns.
241
+ working = true;
242
+ done = false;
243
+ continue;
244
+ }
245
+ if (!ran) continue;
246
+ stage = furthest(
247
+ stage,
248
+ ran.command === "build" ? "building" : "checking",
249
+ );
250
+ output = ran.output;
251
+ const wrong =
252
+ activity.status === "failed" ||
253
+ (ran.command === "check" && checkFailed(activity.text ?? ""));
254
+ failure = wrong
255
+ ? ran.command === "build"
256
+ ? "Putting it together did not work."
257
+ : "The code has problems that need fixing."
258
+ : undefined;
259
+ done = !wrong;
260
+ continue;
261
+ }
262
+ if (!namesApplet(activity, applet.appletId)) continue;
263
+ if (name === "applet_create") {
264
+ stage = furthest(stage, "created");
265
+ if (activity.status === "running") {
266
+ working = true;
267
+ done = false;
268
+ } else if (activity.status === "failed") {
269
+ failure = sentence(activity.text ?? "This Applet could not be made.");
270
+ done = false;
271
+ } else done = true;
272
+ continue;
273
+ }
274
+ if (name === "applet_publish") {
275
+ if (activity.status === "running") {
276
+ stage = furthest(stage, "publishing");
277
+ working = true;
278
+ failure = undefined;
279
+ done = false;
280
+ continue;
281
+ }
282
+ if (activity.status === "failed") {
283
+ stage = furthest(stage, "publishing");
284
+ failure = sentence(
285
+ activity.text ?? "It could not be made ready to open.",
286
+ );
287
+ done = false;
288
+ continue;
289
+ }
290
+ stage = furthest(stage, "published");
291
+ failure = undefined;
292
+ done = true;
293
+ continue;
294
+ }
295
+ }
296
+
297
+ if (input.running) working = true;
298
+
299
+ return {
300
+ stage,
301
+ label: done ? LABELS[stage].done : LABELS[stage].doing,
302
+ working,
303
+ done,
304
+ ...(failure ? { failure } : {}),
305
+ ...(output && output.length > 0 ? { output } : {}),
306
+ };
307
+ }
308
+
309
+ /**
310
+ * The tool activity the line is read from: every Turn's, oldest first.
311
+ *
312
+ * Not just the Turn that is running. Building an Applet takes several Turns —
313
+ * the Bot writes, checks, fixes, builds, publishes, and the User says things
314
+ * in between — and the last thing that happened to the Applet is what a person
315
+ * wants to know, whether or not it happened in the Turn still open. The
316
+ * reducer takes the last relevant activity, so a settled failure stays on
317
+ * screen until something newer replaces it.
318
+ */
319
+ export function appletProgressToolsV1(
320
+ messages: readonly Pick<WebChatMessage, "role" | "tools">[],
321
+ ): WebToolActivity[] {
322
+ const tools: WebToolActivity[] = [];
323
+ for (const message of messages) {
324
+ if (message.role !== "assistant") continue;
325
+ tools.push(...message.tools);
326
+ }
327
+ return tools;
328
+ }
329
+
330
+ /**
331
+ * Whether the canvas should be showing the building view rather than the
332
+ * Applet. A published Applet with a Turn working on it keeps showing what it
333
+ * has: replacing a working Applet with a progress line takes something away.
334
+ */
335
+ export function appletIsBeingBuiltV1(
336
+ progress: AppletProgressV1 | undefined,
337
+ ): boolean {
338
+ return Boolean(progress && progress.stage !== "published");
339
+ }
@@ -0,0 +1,152 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ DEPLOYMENT_RELOAD_INTERVAL_MS_V1,
4
+ DEPLOYMENT_RELOAD_MARKER_V1,
5
+ DEPLOYMENT_UPDATED_MESSAGE_V1,
6
+ deploymentFollowV1,
7
+ deploymentStaleV1,
8
+ readDeploymentReloadV1,
9
+ writeDeploymentReloadV1,
10
+ type DeploymentFollowInputV1,
11
+ type DeploymentReloadStoreV1,
12
+ } from "./deployment.ts";
13
+
14
+ const idle: DeploymentFollowInputV1 = {
15
+ stale: true,
16
+ turnRunning: false,
17
+ draft: "",
18
+ overlayOpen: false,
19
+ listening: false,
20
+ holds: 0,
21
+ now: 10_000_000,
22
+ };
23
+
24
+ describe("deploymentStaleV1", () => {
25
+ test("a page whose application still answers is current", () => {
26
+ expect(deploymentStaleV1("hash-a", "hash-a")).toBe(false);
27
+ });
28
+
29
+ test("a page answered by another application is behind", () => {
30
+ expect(deploymentStaleV1("hash-a", "hash-b")).toBe(true);
31
+ });
32
+
33
+ test("a document that names no application is never behind", () => {
34
+ // The vite development document stamps no application hash, and local
35
+ // development reloads itself.
36
+ expect(deploymentStaleV1(undefined, "hash-b")).toBe(false);
37
+ });
38
+
39
+ test("an answer that names no application says nothing", () => {
40
+ expect(deploymentStaleV1("hash-a", undefined)).toBe(false);
41
+ });
42
+ });
43
+
44
+ describe("deploymentFollowV1", () => {
45
+ test("a current page is left alone", () => {
46
+ expect(deploymentFollowV1({ ...idle, stale: false })).toBe("none");
47
+ });
48
+
49
+ test("an idle page follows the release by itself", () => {
50
+ expect(deploymentFollowV1(idle)).toBe("reload");
51
+ });
52
+
53
+ test("a running Turn is offered the reload rather than given it", () => {
54
+ expect(deploymentFollowV1({ ...idle, turnRunning: true })).toBe("offer");
55
+ });
56
+
57
+ test("a typed message is not thrown away", () => {
58
+ expect(deploymentFollowV1({ ...idle, draft: "half a thought" })).toBe(
59
+ "offer",
60
+ );
61
+ });
62
+
63
+ test("whitespace is not a message", () => {
64
+ expect(deploymentFollowV1({ ...idle, draft: " \n " })).toBe("reload");
65
+ });
66
+
67
+ test("an open overlay is not closed underneath the User", () => {
68
+ expect(deploymentFollowV1({ ...idle, overlayOpen: true })).toBe("offer");
69
+ });
70
+
71
+ test("a live capture is not cut off", () => {
72
+ expect(deploymentFollowV1({ ...idle, listening: true })).toBe("offer");
73
+ });
74
+
75
+ test("live work another Package holds is respected", () => {
76
+ expect(deploymentFollowV1({ ...idle, holds: 1 })).toBe("offer");
77
+ });
78
+
79
+ test("a tab that just reloaded offers instead of looping", () => {
80
+ expect(
81
+ deploymentFollowV1({
82
+ ...idle,
83
+ reloadedAt: idle.now - (DEPLOYMENT_RELOAD_INTERVAL_MS_V1 - 1),
84
+ }),
85
+ ).toBe("offer");
86
+ });
87
+
88
+ test("a tab may reload again once the guard has passed", () => {
89
+ expect(
90
+ deploymentFollowV1({
91
+ ...idle,
92
+ reloadedAt: idle.now - DEPLOYMENT_RELOAD_INTERVAL_MS_V1,
93
+ }),
94
+ ).toBe("reload");
95
+ });
96
+ });
97
+
98
+ function memoryStore(initial?: string): DeploymentReloadStoreV1 & {
99
+ written: string[];
100
+ } {
101
+ const values = new Map<string, string>();
102
+ if (initial !== undefined) values.set(DEPLOYMENT_RELOAD_MARKER_V1, initial);
103
+ const written: string[] = [];
104
+ return {
105
+ written,
106
+ getItem: (key) => values.get(key) ?? null,
107
+ setItem: (key, value) => {
108
+ values.set(key, value);
109
+ written.push(value);
110
+ },
111
+ };
112
+ }
113
+
114
+ describe("the reload marker", () => {
115
+ test("round-trips through storage", () => {
116
+ const store = memoryStore();
117
+ writeDeploymentReloadV1(store, 1234);
118
+ expect(readDeploymentReloadV1(store)).toBe(1234);
119
+ });
120
+
121
+ test("a tab that has never reloaded reads as never", () => {
122
+ expect(readDeploymentReloadV1(memoryStore())).toBeUndefined();
123
+ });
124
+
125
+ test("a value that is not a time reads as never", () => {
126
+ expect(readDeploymentReloadV1(memoryStore("later"))).toBeUndefined();
127
+ });
128
+
129
+ test("no storage at all reads as never and swallows the write", () => {
130
+ expect(readDeploymentReloadV1(undefined)).toBeUndefined();
131
+ expect(() => writeDeploymentReloadV1(undefined, 1)).not.toThrow();
132
+ });
133
+
134
+ test("storage that throws does not stop the page following a release", () => {
135
+ const broken: DeploymentReloadStoreV1 = {
136
+ getItem: () => {
137
+ throw new Error("storage is unavailable");
138
+ },
139
+ setItem: () => {
140
+ throw new Error("storage is full");
141
+ },
142
+ };
143
+ expect(readDeploymentReloadV1(broken)).toBeUndefined();
144
+ expect(() => writeDeploymentReloadV1(broken, 1)).not.toThrow();
145
+ });
146
+ });
147
+
148
+ test("the bar says what happened in plain words", () => {
149
+ expect(DEPLOYMENT_UPDATED_MESSAGE_V1).toBe(
150
+ "FrockBot has updated. Reload when you're ready.",
151
+ );
152
+ });
@@ -0,0 +1,138 @@
1
+ /**
2
+ * Following a release in a page that is already open.
3
+ *
4
+ * FrockBot ships several times a day and a tab stays open for days, so the
5
+ * ordinary case is old client code talking to a new backend. Every answer
6
+ * names the application it came from; when that stops matching the one this
7
+ * page was served, the page is behind and has to be replaced.
8
+ *
9
+ * Replacing it is destructive — a reload throws away the composer draft, the
10
+ * open overlay, and any live capture — so the shell only does it on its own
11
+ * when there is nothing to lose, and otherwise offers it and waits.
12
+ */
13
+
14
+ /** What the shell should do about a page that is behind. */
15
+ export type DeploymentFollowV1 = "reload" | "offer" | "none";
16
+
17
+ /** The bar's whole text. Plain words: nobody needs to hear about a hash. */
18
+ export const DEPLOYMENT_UPDATED_MESSAGE_V1 =
19
+ "FrockBot has updated. Reload when you're ready.";
20
+
21
+ /** The bar's button. */
22
+ export const DEPLOYMENT_RELOAD_LABEL_V1 = "Reload";
23
+
24
+ /**
25
+ * Where the last automatic reload is remembered. Session storage, so it is
26
+ * per-tab and goes away with the tab, which is the same lifetime as the
27
+ * problem it guards.
28
+ */
29
+ export const DEPLOYMENT_RELOAD_MARKER_V1 = "frockbot.deployment-reloaded-v1";
30
+
31
+ /**
32
+ * The shortest gap between two automatic reloads of one tab.
33
+ *
34
+ * The guard matters because a reload is not guaranteed to fix the mismatch: a
35
+ * cached bundle, or a deploy still rolling out, can serve the old client
36
+ * again. Without this the page would reload forever. One a minute at worst
37
+ * leaves the bar to say the rest.
38
+ */
39
+ export const DEPLOYMENT_RELOAD_INTERVAL_MS_V1 = 60_000;
40
+
41
+ /** Whether the answering application is a different one from the served one. */
42
+ export function deploymentStaleV1(
43
+ served: string | undefined,
44
+ answered: string | undefined,
45
+ ): boolean {
46
+ if (!served || !answered) return false;
47
+ return served !== answered;
48
+ }
49
+
50
+ export interface DeploymentFollowInputV1 {
51
+ /** The answering application differs from the served one. */
52
+ stale: boolean;
53
+ /** A Turn is executing for the open Bot. */
54
+ turnRunning: boolean;
55
+ /** What is typed in the composer and not yet sent. */
56
+ draft: string;
57
+ /** A surface is floating over the workspace. */
58
+ overlayOpen: boolean;
59
+ /** A microphone is open, dictating or in a Voice session. */
60
+ listening: boolean;
61
+ /** Live work another Package holds, which a reload would throw away. */
62
+ holds: number;
63
+ now: number;
64
+ /** When this tab last reloaded itself, if it has. */
65
+ reloadedAt?: number;
66
+ }
67
+
68
+ export function deploymentFollowV1(
69
+ input: DeploymentFollowInputV1,
70
+ ): DeploymentFollowV1 {
71
+ if (!input.stale) return "none";
72
+ const busy =
73
+ input.turnRunning ||
74
+ input.draft.trim().length > 0 ||
75
+ input.overlayOpen ||
76
+ input.listening ||
77
+ input.holds > 0;
78
+ if (busy) return "offer";
79
+ if (
80
+ input.reloadedAt !== undefined &&
81
+ input.now - input.reloadedAt < DEPLOYMENT_RELOAD_INTERVAL_MS_V1
82
+ ) {
83
+ return "offer";
84
+ }
85
+ return "reload";
86
+ }
87
+
88
+ /** The narrowest slice of `sessionStorage` this needs, so a test can pass one. */
89
+ export interface DeploymentReloadStoreV1 {
90
+ getItem(key: string): string | null;
91
+ setItem(key: string, value: string): void;
92
+ }
93
+
94
+ /**
95
+ * When this tab last reloaded itself. A missing, unparseable, or absurd value
96
+ * reads as "never": storage can be unavailable or full, and a page that
97
+ * cannot remember should still be able to follow a release once.
98
+ */
99
+ export function readDeploymentReloadV1(
100
+ store: DeploymentReloadStoreV1 | undefined,
101
+ ): number | undefined {
102
+ if (!store) return undefined;
103
+ let raw: string | null;
104
+ try {
105
+ raw = store.getItem(DEPLOYMENT_RELOAD_MARKER_V1);
106
+ } catch {
107
+ return undefined;
108
+ }
109
+ if (raw === null) return undefined;
110
+ const at = Number(raw);
111
+ return Number.isFinite(at) && at > 0 ? at : undefined;
112
+ }
113
+
114
+ /**
115
+ * This tab's session storage, or nothing where the browser refuses it. A
116
+ * private window and a blocked-storage setting both throw on the property
117
+ * itself, before any read.
118
+ */
119
+ export function deploymentReloadStoreV1(): DeploymentReloadStoreV1 | undefined {
120
+ try {
121
+ return typeof window === "undefined" ? undefined : window.sessionStorage;
122
+ } catch {
123
+ return undefined;
124
+ }
125
+ }
126
+
127
+ export function writeDeploymentReloadV1(
128
+ store: DeploymentReloadStoreV1 | undefined,
129
+ now: number,
130
+ ): void {
131
+ if (!store) return;
132
+ try {
133
+ store.setItem(DEPLOYMENT_RELOAD_MARKER_V1, String(now));
134
+ } catch {
135
+ // A tab that cannot record the reload still reloads. The guard is a
136
+ // safeguard against a loop, not a precondition for following a release.
137
+ }
138
+ }
@@ -328,6 +328,77 @@ describe("application manifest protocol", () => {
328
328
  });
329
329
  });
330
330
 
331
+ describe("following a release", () => {
332
+ async function mountWithDeployment(servedDeployment?: string): Promise<{
333
+ web: Ref<FrockBotWebData>;
334
+ answer: (deployment: string) => void;
335
+ }> {
336
+ let provided: Ref<FrockBotWebData> | undefined;
337
+ let observer: ((deployment: string) => void) | undefined;
338
+ await shellClientPlugin({
339
+ transport: {
340
+ ...(servedDeployment ? { servedDeployment } : {}),
341
+ observeDeployment: (candidate) => {
342
+ observer = candidate;
343
+ return () => {
344
+ observer = undefined;
345
+ };
346
+ },
347
+ turn: () => Promise.resolve({ runId: "run", text: "", events: [] }),
348
+ },
349
+ slot: () => () => {},
350
+ inject: () => {
351
+ throw new Error("unexpected client provider injection");
352
+ },
353
+ provide: (_key, value) => {
354
+ provided = value as Ref<FrockBotWebData>;
355
+ return () => {};
356
+ },
357
+ });
358
+ if (!provided) throw new Error("shell data was not provided");
359
+ if (!observer) throw new Error("the deployment was not observed");
360
+ const answer = observer;
361
+ return { web: provided, answer };
362
+ }
363
+
364
+ test("the same application answering leaves the page alone", async () => {
365
+ const { web, answer } = await mountWithDeployment("hash-a");
366
+
367
+ expect(web.value.deploymentStale).toBe(false);
368
+ answer("hash-a");
369
+ expect(web.value.deploymentStale).toBe(false);
370
+ });
371
+
372
+ test("another application answering puts the page behind", async () => {
373
+ const { web, answer } = await mountWithDeployment("hash-a");
374
+
375
+ answer("hash-b");
376
+ expect(web.value.deploymentStale).toBe(true);
377
+ });
378
+
379
+ test("a document that names no application is never behind", async () => {
380
+ // The vite development document stamps none, and there the page reloads
381
+ // itself already.
382
+ const { web, answer } = await mountWithDeployment();
383
+
384
+ answer("hash-b");
385
+ expect(web.value.deploymentStale).toBe(false);
386
+ });
387
+
388
+ test("holds are counted, and letting go twice does not count twice", async () => {
389
+ const { web } = await mountWithDeployment("hash-a");
390
+
391
+ const first = web.value.holdReload();
392
+ const second = web.value.holdReload();
393
+ expect(web.value.reloadHolds).toBe(2);
394
+ first();
395
+ first();
396
+ expect(web.value.reloadHolds).toBe(1);
397
+ second();
398
+ expect(web.value.reloadHolds).toBe(0);
399
+ });
400
+ });
401
+
331
402
  describe("composer hydration context", () => {
332
403
  test("hides Connection controls when the platform cannot authorize", async () => {
333
404
  let provided: Ref<FrockBotWebData> | undefined;