Skip to content

Commit f3c3445

Browse files
committed
feat(server): add activation commands and dispatch guard
1 parent 0aa3a20 commit f3c3445

6 files changed

Lines changed: 164 additions & 0 deletions

File tree

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { describe, expect, it } from "vitest";
2+
import { ActivationManager } from "../ws/activation.js";
3+
import { type CommandContext, dispatch } from "../ws/dispatch.js";
4+
import "../commands/activation.js";
5+
6+
describe("activation commands", () => {
7+
it("returns generation data from activation.claim", async () => {
8+
const ctx = {
9+
activationMgr: new ActivationManager(),
10+
} as unknown as CommandContext;
11+
12+
const result = await dispatch(
13+
{
14+
kind: "command",
15+
id: "00000000-0000-4000-8000-000000000001",
16+
op: "activation.claim",
17+
args: { clientInstanceId: "client-a" },
18+
},
19+
ctx,
20+
"ws-a"
21+
);
22+
23+
expect(result.ok).toBe(true);
24+
expect(result.data).toMatchObject({
25+
active: true,
26+
generation: 1,
27+
recoveryMode: "fresh",
28+
});
29+
});
30+
31+
it("rejects non-activation commands when activation is missing", async () => {
32+
const ctx = {
33+
activationMgr: new ActivationManager(),
34+
} as unknown as CommandContext;
35+
36+
const result = await dispatch(
37+
{
38+
kind: "command",
39+
id: "00000000-0000-4000-8000-000000000002",
40+
op: "workspace.list",
41+
args: {},
42+
},
43+
ctx,
44+
"ws-a"
45+
);
46+
47+
expect(result.ok).toBe(false);
48+
expect(result.error?.code).toBe("activation_required");
49+
});
50+
});
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { z } from "zod";
2+
import { registerCommand } from "../ws/dispatch.js";
3+
4+
registerCommand(
5+
"activation.claim",
6+
z.object({ clientInstanceId: z.string().min(1) }),
7+
async (args, ctx, clientId) => {
8+
return ctx.activationMgr.claim(args.clientInstanceId, clientId!);
9+
}
10+
);
11+
12+
registerCommand(
13+
"activation.heartbeat",
14+
z.object({ clientInstanceId: z.string(), generation: z.number().int().positive() }),
15+
async (args, ctx) => {
16+
return { ok: ctx.activationMgr.heartbeat(args.clientInstanceId, args.generation) };
17+
}
18+
);
19+
20+
registerCommand(
21+
"activation.release",
22+
z.object({ clientInstanceId: z.string(), generation: z.number().int().positive() }),
23+
async (args, ctx) => {
24+
ctx.activationMgr.release(args.clientInstanceId, args.generation);
25+
return { ok: true };
26+
}
27+
);

packages/server/src/commands/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
import "./workspace.js";
88
import "./workspace-activity.js";
9+
import "./activation.js";
910
import "./connection.js";
1011
import "./session.js";
1112
import "./terminal.js";

packages/server/src/server.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import type { TerminalDatabase } from "./terminal/types.js";
3838
import { deleteWorkspaceUploads, runStartupGc } from "./uploads/cleanup.js";
3939
import { STARTUP_GC_DELAY_MS } from "./uploads/constants.js";
4040
import { WorkspaceManager } from "./workspace/manager.js";
41+
import { ActivationManager } from "./ws/activation.js";
4142
import type { CommandContext } from "./ws/dispatch.js";
4243
import { dispatch } from "./ws/dispatch.js";
4344
import { FencingManager } from "./ws/fencing.js";
@@ -66,6 +67,7 @@ export async function createServer(
6667

6768
const db = openDatabase(config.dataDir);
6869
const eventBus = new EventBus();
70+
const activationMgr = new ActivationManager();
6971
const fencingMgr = new FencingManager();
7072
const wsHub = new WsHub({ eventBus, commandContext: null, config, fencingMgr });
7173
let workspaceMgr: WorkspaceManager;
@@ -211,6 +213,7 @@ export async function createServer(
211213
autoFetch,
212214
providerRuntimeDeps,
213215
providerInstallMgr,
216+
activationMgr,
214217
};
215218

216219
wsHub.setCommandContext(commandContext);
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
export interface ActivationLease {
2+
clientInstanceId: string;
3+
wsClientId: string;
4+
generation: number;
5+
}
6+
7+
export interface ActivationClaimResult {
8+
active: true;
9+
generation: number;
10+
recoveryMode: "fresh" | "grace_recover" | "takeover";
11+
}
12+
13+
export class ActivationManager {
14+
private lease: ActivationLease | null = null;
15+
private generation = 0;
16+
17+
claim(clientInstanceId: string, wsClientId: string): ActivationClaimResult {
18+
const current = this.lease;
19+
20+
if (current && current.clientInstanceId === clientInstanceId) {
21+
current.wsClientId = wsClientId;
22+
return {
23+
active: true,
24+
generation: current.generation,
25+
recoveryMode: "grace_recover",
26+
};
27+
}
28+
29+
this.generation += 1;
30+
this.lease = {
31+
clientInstanceId,
32+
wsClientId,
33+
generation: this.generation,
34+
};
35+
36+
return {
37+
active: true,
38+
generation: this.lease.generation,
39+
recoveryMode: current ? "takeover" : "fresh",
40+
};
41+
}
42+
43+
heartbeat(clientInstanceId: string, generation: number): boolean {
44+
return (
45+
this.lease?.clientInstanceId === clientInstanceId && this.lease.generation === generation
46+
);
47+
}
48+
49+
release(clientInstanceId: string, generation: number): void {
50+
if (this.lease?.clientInstanceId !== clientInstanceId || this.lease.generation !== generation) {
51+
return;
52+
}
53+
54+
this.lease = null;
55+
}
56+
57+
getLease(): ActivationLease | null {
58+
return this.lease;
59+
}
60+
}

packages/server/src/ws/dispatch.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import type { Database } from "../storage/database.js";
1515
import type { SupervisorManager } from "../supervisor/manager.js";
1616
import type { TerminalManager } from "../terminal/manager.js";
1717
import type { WorkspaceManager } from "../workspace/manager.js";
18+
import type { ActivationManager } from "./activation.js";
1819
import type { FencingManager } from "./fencing.js";
1920
import type { Broadcaster } from "./hub.js";
2021

@@ -34,6 +35,7 @@ export interface CommandContext {
3435
autoFetch: AutoFetchRuntime;
3536
providerRuntimeDeps?: RuntimeStatusDeps;
3637
providerInstallMgr?: ProviderInstallManager;
38+
activationMgr: ActivationManager;
3739
}
3840

3941
/**
@@ -56,6 +58,12 @@ const handlers = new Map<string, CommandHandler>();
5658
* Registry of all command schemas
5759
*/
5860
const schemas = new Map<string, CommandSchema>();
61+
const ACTIVATION_ALLOWLIST = new Set([
62+
"activation.claim",
63+
"activation.heartbeat",
64+
"activation.release",
65+
"connection.probe",
66+
]);
5967

6068
/**
6169
* Register a command handler
@@ -77,6 +85,21 @@ export async function dispatch(
7785
ctx: CommandContext,
7886
clientId?: string
7987
): Promise<Result> {
88+
if (!ACTIVATION_ALLOWLIST.has(msg.op)) {
89+
const lease = ctx.activationMgr.getLease();
90+
if (!clientId || !lease || lease.wsClientId !== clientId) {
91+
return {
92+
kind: "result",
93+
id: msg.id,
94+
ok: false,
95+
error: {
96+
code: "activation_required",
97+
message: "This tab is no longer the active session",
98+
},
99+
};
100+
}
101+
}
102+
80103
const handler = handlers.get(msg.op);
81104

82105
if (!handler) {

0 commit comments

Comments
 (0)