feat: add retained Worker session observation
This commit is contained in:
@@ -0,0 +1,84 @@
|
||||
import {
|
||||
isCurrentWorkerSessionRequest,
|
||||
workerSessionAction,
|
||||
type WorkerSessionRequestIdentity,
|
||||
workerSessionRequestInit,
|
||||
} from "./session-observation";
|
||||
import type { SessionSnapshot } from "$lib/generated/protocol";
|
||||
|
||||
declare const Deno: {
|
||||
test(name: string, fn: () => Promise<void> | void): void;
|
||||
};
|
||||
|
||||
function assert(condition: boolean, message: string): void {
|
||||
if (!condition) throw new Error(message);
|
||||
}
|
||||
|
||||
const snapshot: SessionSnapshot = {
|
||||
pending_submissions: {
|
||||
revision: 0,
|
||||
notification_count: 0,
|
||||
head_id: null,
|
||||
submissions: [],
|
||||
},
|
||||
entries: [],
|
||||
};
|
||||
|
||||
Deno.test("worker session request includes same-origin credentials", () => {
|
||||
const controller = new AbortController();
|
||||
const init = workerSessionRequestInit(controller.signal);
|
||||
assert(
|
||||
init.credentials === "same-origin",
|
||||
"session observation must use the Workspace auth cookie boundary",
|
||||
);
|
||||
assert(
|
||||
init.signal === controller.signal,
|
||||
"request must retain its abort fence",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("worker session observation selects live, retained, and unavailable behavior", () => {
|
||||
assert(
|
||||
workerSessionAction({ availability: "live_protocol" }).kind ===
|
||||
"subscribe_live",
|
||||
"live observation must subscribe",
|
||||
);
|
||||
const retained = workerSessionAction({
|
||||
availability: "retained_snapshot",
|
||||
snapshot,
|
||||
});
|
||||
assert(
|
||||
retained.kind === "apply_retained" && retained.snapshot === snapshot,
|
||||
"retained observation must apply its snapshot",
|
||||
);
|
||||
const unavailable = workerSessionAction({
|
||||
availability: "unavailable",
|
||||
message: "retention expired",
|
||||
});
|
||||
assert(
|
||||
unavailable.kind === "show_unavailable" &&
|
||||
unavailable.message === "retention expired",
|
||||
"unavailable observation must stay recoverable and visible",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("late worker session response cannot overwrite a newer selection", () => {
|
||||
const request: WorkerSessionRequestIdentity = {
|
||||
token: 7,
|
||||
workspaceId: "workspace-a",
|
||||
runtimeId: "runtime-a",
|
||||
workerId: "worker-a",
|
||||
};
|
||||
assert(
|
||||
isCurrentWorkerSessionRequest(request, request),
|
||||
"matching request identity must remain current",
|
||||
);
|
||||
assert(
|
||||
!isCurrentWorkerSessionRequest(request, {
|
||||
...request,
|
||||
token: 8,
|
||||
workerId: "worker-b",
|
||||
}),
|
||||
"late response for an older token/worker must be fenced",
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { SessionSnapshot } from "$lib/generated/protocol";
|
||||
|
||||
export type WorkerSessionObservation =
|
||||
| { availability: "live_protocol" }
|
||||
| { availability: "retained_snapshot"; snapshot: SessionSnapshot }
|
||||
| { availability: "unavailable"; message: string };
|
||||
|
||||
export type WorkerSessionAction =
|
||||
| { kind: "subscribe_live" }
|
||||
| { kind: "apply_retained"; snapshot: SessionSnapshot }
|
||||
| { kind: "show_unavailable"; message: string };
|
||||
|
||||
export interface WorkerSessionRequestIdentity {
|
||||
token: number;
|
||||
workspaceId: string;
|
||||
runtimeId: string;
|
||||
workerId: string;
|
||||
}
|
||||
|
||||
export function workerSessionRequestInit(signal: AbortSignal): RequestInit {
|
||||
return {
|
||||
signal,
|
||||
credentials: "same-origin",
|
||||
headers: { accept: "application/json" },
|
||||
};
|
||||
}
|
||||
|
||||
export function workerSessionAction(
|
||||
observation: WorkerSessionObservation,
|
||||
): WorkerSessionAction {
|
||||
switch (observation.availability) {
|
||||
case "live_protocol":
|
||||
return { kind: "subscribe_live" };
|
||||
case "retained_snapshot":
|
||||
return { kind: "apply_retained", snapshot: observation.snapshot };
|
||||
case "unavailable":
|
||||
return {
|
||||
kind: "show_unavailable",
|
||||
message: observation.message.slice(0, 512),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function isCurrentWorkerSessionRequest(
|
||||
request: WorkerSessionRequestIdentity,
|
||||
current: WorkerSessionRequestIdentity,
|
||||
): boolean {
|
||||
return (
|
||||
request.token === current.token &&
|
||||
request.workspaceId === current.workspaceId &&
|
||||
request.runtimeId === current.runtimeId &&
|
||||
request.workerId === current.workerId
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
WorkerLaunchOptionsResponse as SharedWorkerLaunchOptionsResponse,
|
||||
WorkerLaunchProfileCandidate as SharedWorkerLaunchProfileCandidate,
|
||||
WorkerLaunchRuntimeOption as SharedWorkerLaunchRuntimeOption,
|
||||
WorkerLaunchWorkerSummary as WorkerSummary,
|
||||
WorkingDirectoryRepositoryOption as SharedWorkingDirectoryRepositoryOption,
|
||||
} from "$lib/generated/worker-launch-api";
|
||||
import type {
|
||||
|
||||
@@ -52,6 +52,13 @@
|
||||
import { pushWorkspaceAlert } from "$lib/workspace/alerts/store";
|
||||
import { workspaceApiPath } from "$lib/workspace/api/http";
|
||||
import { workspaceMultiplexer, type WorkspaceMultiplexerSubscription } from "$lib/workspace/multiplexer";
|
||||
import {
|
||||
isCurrentWorkerSessionRequest,
|
||||
workerSessionAction,
|
||||
workerSessionRequestInit,
|
||||
type WorkerSessionObservation,
|
||||
type WorkerSessionRequestIdentity,
|
||||
} from "$lib/workspace/session-observation";
|
||||
import type { Diagnostic, Worker } from "$lib/workspace/sidebar/types";
|
||||
|
||||
type Props = {
|
||||
@@ -927,14 +934,108 @@
|
||||
}
|
||||
|
||||
function connectProtocolTransport(
|
||||
targetWorker: Worker | null,
|
||||
_targetWorker: Worker | null,
|
||||
token: number,
|
||||
target: ConsoleTarget,
|
||||
) {
|
||||
if (!targetWorker || targetWorker.state === "stopped") {
|
||||
protocolState = "closed";
|
||||
return;
|
||||
}
|
||||
protocolState = "connecting";
|
||||
const controller = new AbortController();
|
||||
let closeLiveSubscription: (() => void) | undefined;
|
||||
const requestIdentity: WorkerSessionRequestIdentity = {
|
||||
token,
|
||||
workspaceId: target.workspaceId,
|
||||
runtimeId: target.runtimeId,
|
||||
workerId: target.workerId,
|
||||
};
|
||||
void fetch(
|
||||
workerApiPath(
|
||||
`/runtimes/${encodeURIComponent(target.runtimeId)}/workers/${encodeURIComponent(target.workerId)}/session`,
|
||||
),
|
||||
workerSessionRequestInit(controller.signal),
|
||||
)
|
||||
.then(async (response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error(`failed to observe Worker Session (${response.status})`);
|
||||
}
|
||||
return (await response.json()) as WorkerSessionObservation;
|
||||
})
|
||||
.then((observation) => {
|
||||
const currentIdentity: WorkerSessionRequestIdentity = {
|
||||
token: reloadToken,
|
||||
workspaceId,
|
||||
runtimeId,
|
||||
workerId,
|
||||
};
|
||||
if (
|
||||
!isCurrentWorkerSessionRequest(requestIdentity, currentIdentity) ||
|
||||
controller.signal.aborted
|
||||
)
|
||||
return;
|
||||
const action = workerSessionAction(observation);
|
||||
if (action.kind === "show_unavailable") {
|
||||
protocolState = "closed";
|
||||
streamDiagnostics = [
|
||||
...streamDiagnostics,
|
||||
{
|
||||
code: "retained_session_unavailable",
|
||||
severity: "warning",
|
||||
message: action.message,
|
||||
},
|
||||
];
|
||||
return;
|
||||
}
|
||||
if (action.kind === "subscribe_live") {
|
||||
closeLiveSubscription = connectLiveProtocolTransport(token, target);
|
||||
return;
|
||||
}
|
||||
handleIncomingProtocolEvent({
|
||||
event: "snapshot",
|
||||
data: {
|
||||
session: action.snapshot,
|
||||
greeting: {
|
||||
worker_name: "retained worker",
|
||||
cwd: "",
|
||||
provider: "retained session",
|
||||
model: "",
|
||||
scope_summary: "read-only retained session",
|
||||
tools: [],
|
||||
context_window: 0,
|
||||
context_tokens: 0,
|
||||
},
|
||||
state: { last_command_id: 0, state: { kind: "idle" } },
|
||||
in_flight: { responses: [], commands: [] },
|
||||
internal_workers: [],
|
||||
},
|
||||
} as ProtocolEvent);
|
||||
protocolState = "closed";
|
||||
streamDiagnostics = [
|
||||
...streamDiagnostics,
|
||||
{
|
||||
code: "retained_session_read_only",
|
||||
severity: "info",
|
||||
message: "Showing a read-only retained Session snapshot.",
|
||||
},
|
||||
];
|
||||
})
|
||||
.catch((error) => {
|
||||
if (token !== reloadToken || controller.signal.aborted) return;
|
||||
protocolState = "error";
|
||||
streamDiagnostics = [
|
||||
...streamDiagnostics,
|
||||
{
|
||||
code: "worker_session_observation_failed",
|
||||
severity: "warning",
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
];
|
||||
});
|
||||
return () => {
|
||||
controller.abort();
|
||||
closeLiveSubscription?.();
|
||||
};
|
||||
}
|
||||
|
||||
function connectLiveProtocolTransport(token: number, target: ConsoleTarget) {
|
||||
protocolState = "connecting";
|
||||
const subscription = workspaceMultiplexer(target.workspaceId).subscribe(
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user