mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-06 16:34:36 +00:00
Run and watch workspace scripts from the phone header menu (#7122)
* feat(app): run and watch workspace scripts from the compact header menu The compact header redesign dropped the mobile scripts button, so phones had no way to run, stop, or restart workspace scripts. Scripts are now a page of the ⋯ sheet: the root row shows what is running, tinted by health, and the ⋯ carries a dot while a running service is unhealthy. Desktop keeps its popover; both surfaces share one set of script actions and one status model. Script status also froze on native whenever the sidebar was closed and no agent tab was viewed. DirectorySynchronizer releases its subscriptions, including script_status_update, when nothing demands the directory, and the workspace screen never demanded the workspace it shows. Web hid this because the favicon sync holds demand for the whole session. The focused workspace now holds directory demand, which also lets Restart's second half (start once the daemon reports the script stopped) complete. * perf(app): keep workspace script status live without syncing the directory The focused workspace held full directory demand so the scripts menu saw script_status_update on phones with the sidebar closed and no agent tab. That fetched the host's agent and workspace lists on every focus and streamed every agent and workspace update for the whole host. Script status now has its own event subscription, which a workspace screen can hold alone. It fetches the workspace's scripts once when the subscription lands, because the subscription only carries changes. Directory demand also holds it, so the sidebar and agent tabs see the same updates as before.
This commit is contained in:
@@ -31,6 +31,7 @@ import { subscriptionFixture } from "../subscription-fixture";
|
||||
type WorkspaceFetchResult = Awaited<ReturnType<DaemonClient["fetchWorkspaces"]>>;
|
||||
type ProjectListResult = Awaited<ReturnType<DaemonClient["listProjects"]>>;
|
||||
type AgentFetchResult = Awaited<ReturnType<DaemonClient["fetchAgents"]>>;
|
||||
type WorkspaceScriptsResult = Awaited<ReturnType<DaemonClient["listWorkspaceScripts"]>>;
|
||||
|
||||
class FakeDirectoryClient {
|
||||
supportsBackgroundWorkspaces(): boolean {
|
||||
@@ -181,6 +182,14 @@ class FakeDirectoryClient {
|
||||
};
|
||||
}
|
||||
|
||||
listWorkspaceScriptsCalls = 0;
|
||||
workspaceScripts: WorkspaceScriptsResult["scripts"] = [];
|
||||
|
||||
async listWorkspaceScripts(workspaceId: string): Promise<WorkspaceScriptsResult> {
|
||||
this.listWorkspaceScriptsCalls += 1;
|
||||
return { requestId: "scripts", workspaceId, scripts: this.workspaceScripts, error: null };
|
||||
}
|
||||
|
||||
getLastServerInfoMessage() {
|
||||
return this.supportsWorkspaceLabels ? { features: { workspaceLabels: true } } : null;
|
||||
}
|
||||
@@ -482,6 +491,52 @@ describe("DirectorySync session readiness", () => {
|
||||
directory.dispose();
|
||||
});
|
||||
|
||||
it("keeps a workspace's script status live without demanding the directory", async () => {
|
||||
const serverId = "script-status-demand";
|
||||
const { client, directory } = createDirectory(serverId);
|
||||
useSessionStore.getState().initializeSession(serverId, client as unknown as DaemonClient, 1);
|
||||
const workspace = normalizeWorkspaceDescriptor({
|
||||
...createWorkspaceEntry("workspace-1"),
|
||||
scripts: [],
|
||||
});
|
||||
directory.acceptWorkspaces([workspace]);
|
||||
const script = {
|
||||
scriptName: "worker",
|
||||
type: "script" as const,
|
||||
hostname: "worker.paseo.localhost",
|
||||
port: null,
|
||||
proxyUrl: null,
|
||||
lifecycle: "running" as const,
|
||||
health: null,
|
||||
exitCode: null,
|
||||
terminalId: "terminal-1",
|
||||
};
|
||||
const lifecycle = () =>
|
||||
useSessionStore.getState().sessions[serverId]?.workspaces.get(workspace.id)?.scripts[0]
|
||||
?.lifecycle;
|
||||
client.workspaceScripts = [script];
|
||||
const source = {};
|
||||
|
||||
directory.setScriptStatusDemand(source, workspace.id);
|
||||
|
||||
await expect.poll(lifecycle).toBe("running");
|
||||
client.emit({
|
||||
type: "script_status_update",
|
||||
payload: { workspaceId: workspace.id, scripts: [{ ...script, lifecycle: "stopped" }] },
|
||||
});
|
||||
expect(lifecycle()).toBe("stopped");
|
||||
expect(client.fetchAgentsCalls).toBe(0);
|
||||
expect(client.fetchWorkspacesCalls).toBe(0);
|
||||
|
||||
directory.setScriptStatusDemand(source, null);
|
||||
client.emit({
|
||||
type: "script_status_update",
|
||||
payload: { workspaceId: workspace.id, scripts: [script] },
|
||||
});
|
||||
expect(lifecycle()).toBe("stopped");
|
||||
directory.dispose();
|
||||
});
|
||||
|
||||
it("subscribes to live agent updates when the directory is demanded", async () => {
|
||||
const serverId = "demanded-directory-subscription";
|
||||
const { client, directory } = createDirectory(serverId);
|
||||
|
||||
@@ -150,6 +150,7 @@ export class DirectorySync {
|
||||
private agentSubscription: OwnedSubscription<FetchAgentsPayload> | null = null;
|
||||
private workspaceSubscription: OwnedSubscription<FetchWorkspacesPayload> | null = null;
|
||||
private eventSubscription: ReturnType<DaemonClient["observeEvents"]> | null = null;
|
||||
private scriptStatusSubscription: ReturnType<DaemonClient["observeEvents"]> | null = null;
|
||||
private readonly abortSessionWaits = new Set<() => void>();
|
||||
private cacheLoad: Promise<void> | null = null;
|
||||
private cacheAccepted = false;
|
||||
@@ -157,6 +158,7 @@ export class DirectorySync {
|
||||
private workspaceRevision = 0;
|
||||
private readonly routeDemandIds = new Set<string>();
|
||||
private readonly fullDemandSources = new Set<object>();
|
||||
private readonly scriptStatusDemand = new Map<object, string>();
|
||||
private demandRefresh: Promise<void> | null = null;
|
||||
private satisfiedDemandSource: DirectorySourceToken | null = null;
|
||||
private cursors: DirectoryCheckpoint = {};
|
||||
@@ -189,10 +191,12 @@ export class DirectorySync {
|
||||
}
|
||||
this.flushAbortedTransactions();
|
||||
this.releaseSubscriptions();
|
||||
this.releaseScriptStatusEvents();
|
||||
workspaceLabels.disconnect(this.serverId);
|
||||
this.connection = connection;
|
||||
this.abortPendingSessionWaits();
|
||||
if (!connection.client || connection.status !== "online") return true;
|
||||
if (this.scriptStatusDemand.size > 0) this.observeScriptStatusEvents();
|
||||
// Reattach labels here because route-only demand can satisfy the epoch before full demand requests them.
|
||||
void this.connectWorkspaceLabels().catch(() => undefined);
|
||||
if (this.hasDemand()) void this.requestDemandRefresh().catch(() => undefined);
|
||||
@@ -226,10 +230,32 @@ export class DirectorySync {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps one workspace's script status live without demanding the directory: a
|
||||
* script_status_update subscription, plus one scripts fetch to cover what changed before it.
|
||||
*/
|
||||
setScriptStatusDemand(source: object, workspaceId: string | null): void {
|
||||
if (!workspaceId) {
|
||||
this.scriptStatusDemand.delete(source);
|
||||
if (!this.wantsScriptStatus()) this.releaseScriptStatusEvents();
|
||||
return;
|
||||
}
|
||||
this.scriptStatusDemand.set(source, workspaceId);
|
||||
if (!this.getOnlineConnection()) return;
|
||||
// A new subscription fetches every demanded workspace once its snapshot lands.
|
||||
if (!this.scriptStatusSubscription) this.observeScriptStatusEvents();
|
||||
else
|
||||
void this.scriptStatusSubscription.ready
|
||||
.then(() => this.fetchScriptStatus(workspaceId))
|
||||
.catch(() => undefined);
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.flushAbortedTransactions();
|
||||
this.abortPendingSessionWaits();
|
||||
this.scriptStatusDemand.clear();
|
||||
this.releaseSubscriptions();
|
||||
this.releaseScriptStatusEvents();
|
||||
this.fullDemandSources.clear();
|
||||
this.routeDemandIds.clear();
|
||||
workspaceLabels.disconnect(this.serverId);
|
||||
@@ -247,6 +273,12 @@ export class DirectorySync {
|
||||
this.workspaceSubscription = null;
|
||||
this.eventSubscription = null;
|
||||
this.satisfiedDemandSource = null;
|
||||
if (!this.wantsScriptStatus()) this.releaseScriptStatusEvents();
|
||||
}
|
||||
|
||||
private releaseScriptStatusEvents(): void {
|
||||
void this.scriptStatusSubscription?.release().catch(() => undefined);
|
||||
this.scriptStatusSubscription = null;
|
||||
}
|
||||
|
||||
private receiveAgentDelta(source: DirectorySourceToken, delta: AgentDirectoryDelta): void {
|
||||
@@ -276,22 +308,51 @@ export class DirectorySync {
|
||||
private observeDirectoryEvents(): void {
|
||||
if (this.eventSubscription) return;
|
||||
const { client, source } = this.requireOnline();
|
||||
this.eventSubscription = client.observeEvents(["project.update", "script_status_update"]);
|
||||
this.eventSubscription = client.observeEvents(["project.update"]);
|
||||
this.eventSubscription.subscribe({
|
||||
snapshot: () => {},
|
||||
update: (message) => {
|
||||
if (!this.isCurrent(client, source)) return;
|
||||
if (message.type === "project.update") this.receiveWorkspaceDelta(source, message.payload);
|
||||
if (message.type === "script_status_update")
|
||||
this.receiveWorkspaceDelta(source, { kind: "script_status", update: message.payload });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Script status has its own subscription so a workspace screen can hold it without the directory.
|
||||
private observeScriptStatusEvents(): void {
|
||||
if (this.scriptStatusSubscription) return;
|
||||
const { client, source } = this.requireOnline();
|
||||
this.scriptStatusSubscription = client.observeEvents(["script_status_update"]);
|
||||
this.scriptStatusSubscription.subscribe({
|
||||
snapshot: () => {
|
||||
for (const workspaceId of new Set(this.scriptStatusDemand.values()))
|
||||
void this.fetchScriptStatus(workspaceId).catch(() => undefined);
|
||||
},
|
||||
update: (message) => {
|
||||
if (!this.isCurrent(client, source) || message.type !== "script_status_update") return;
|
||||
this.receiveWorkspaceDelta(source, { kind: "script_status", update: message.payload });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async fetchScriptStatus(workspaceId: string): Promise<void> {
|
||||
const { client, source } = this.requireOnline();
|
||||
const result = await client.listWorkspaceScripts(workspaceId);
|
||||
if (!this.isCurrent(client, source) || result.error || !result.scripts) return;
|
||||
this.receiveWorkspaceDelta(source, {
|
||||
kind: "script_status",
|
||||
update: { workspaceId, scripts: result.scripts },
|
||||
});
|
||||
}
|
||||
|
||||
private hasDemand(): boolean {
|
||||
return this.fullDemandSources.size > 0 || this.routeDemandIds.size > 0;
|
||||
}
|
||||
|
||||
private wantsScriptStatus(): boolean {
|
||||
return this.hasDemand() || this.scriptStatusDemand.size > 0;
|
||||
}
|
||||
|
||||
private requestDemandRefresh(force = false): Promise<void> {
|
||||
if (this.demandRefresh) return this.demandRefresh;
|
||||
if (!this.hasDemand()) return Promise.resolve();
|
||||
@@ -299,6 +360,7 @@ export class DirectorySync {
|
||||
return this.fullDemandSources.size > 0 ? this.loadCachedDirectory() : Promise.resolve();
|
||||
}
|
||||
this.observeDirectoryEvents();
|
||||
this.observeScriptStatusEvents();
|
||||
const source = this.connection.source;
|
||||
if (
|
||||
!force &&
|
||||
|
||||
@@ -2606,6 +2606,14 @@ export class HostRuntimeStore {
|
||||
return () => directory.setDemand(source, false);
|
||||
}
|
||||
|
||||
acquireScriptStatusDemand(serverId: string, workspaceId: string): () => void {
|
||||
const directory = this.directorySyncByServer.get(serverId);
|
||||
if (!directory) return () => undefined;
|
||||
const source = {};
|
||||
directory.setScriptStatusDemand(source, workspaceId);
|
||||
return () => directory.setScriptStatusDemand(source, null);
|
||||
}
|
||||
|
||||
async prepareAgentRoute(serverId: string, agentId: string): Promise<void> {
|
||||
const directory = this.directorySyncByServer.get(serverId);
|
||||
if (!directory) throw new Error(`Unknown host runtime for serverId ${serverId}`);
|
||||
|
||||
Reference in New Issue
Block a user