@decocms/mesh-sdk 1.2.1 → 1.2.3
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/README.md +10 -10
- package/package.json +7 -4
- package/src/context/index.ts +5 -1
- package/src/context/project-context.tsx +68 -29
- package/src/hooks/index.ts +10 -0
- package/src/hooks/use-collections.ts +179 -63
- package/src/hooks/use-connection.ts +50 -4
- package/src/hooks/use-mcp-client.ts +81 -11
- package/src/hooks/use-mcp-prompts.ts +16 -6
- package/src/hooks/use-mcp-resources.ts +15 -5
- package/src/hooks/use-virtual-mcp.ts +64 -0
- package/src/index.ts +119 -4
- package/src/lib/bridge-transport.test.ts +368 -0
- package/src/lib/bridge-transport.ts +6 -0
- package/src/lib/constants.test.ts +26 -0
- package/src/lib/constants.ts +193 -36
- package/src/lib/default-model.ts +281 -0
- package/src/lib/mcp-oauth.ts +139 -17
- package/src/lib/query-keys.ts +20 -4
- package/src/lib/server-client-bridge.ts +4 -0
- package/src/lib/usage.test.ts +229 -0
- package/src/lib/usage.ts +187 -0
- package/src/plugins/index.ts +15 -0
- package/src/plugins/plugin-context-provider.tsx +99 -0
- package/src/plugins/topbar-portal.tsx +118 -0
- package/src/types/ai-providers.ts +86 -0
- package/src/types/connection.ts +43 -20
- package/src/types/decopilot-events.test.ts +78 -0
- package/src/types/decopilot-events.ts +171 -0
- package/src/types/index.ts +48 -1
- package/src/types/virtual-mcp.test.ts +202 -0
- package/src/types/virtual-mcp.ts +514 -109
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Decopilot SSE Event Types
|
|
3
|
+
*
|
|
4
|
+
* Canonical type definitions for thread statuses and decopilot SSE events.
|
|
5
|
+
* Shared between server (emitter) and client (consumer) for full type safety.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
// ============================================================================
|
|
9
|
+
// Thread Status
|
|
10
|
+
// ============================================================================
|
|
11
|
+
|
|
12
|
+
/** Persisted thread statuses (written to DB). */
|
|
13
|
+
export const THREAD_STATUSES = [
|
|
14
|
+
"in_progress",
|
|
15
|
+
"requires_action",
|
|
16
|
+
"failed",
|
|
17
|
+
"completed",
|
|
18
|
+
] as const;
|
|
19
|
+
export type ThreadStatus = (typeof THREAD_STATUSES)[number];
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Display statuses include "expired" — a virtual status computed at read time
|
|
23
|
+
* for threads stuck in "in_progress" beyond a timeout threshold.
|
|
24
|
+
* Never persisted to DB, but appears in API responses and UI.
|
|
25
|
+
*/
|
|
26
|
+
export const THREAD_DISPLAY_STATUSES = [...THREAD_STATUSES, "expired"] as const;
|
|
27
|
+
export type ThreadDisplayStatus = (typeof THREAD_DISPLAY_STATUSES)[number];
|
|
28
|
+
|
|
29
|
+
// ============================================================================
|
|
30
|
+
// SSE Event Type Constants
|
|
31
|
+
// ============================================================================
|
|
32
|
+
|
|
33
|
+
export const DECOPILOT_EVENTS = {
|
|
34
|
+
STEP: "decopilot.step",
|
|
35
|
+
FINISH: "decopilot.finish",
|
|
36
|
+
THREAD_STATUS: "decopilot.thread.status",
|
|
37
|
+
} as const;
|
|
38
|
+
|
|
39
|
+
export type DecopilotEventType =
|
|
40
|
+
(typeof DECOPILOT_EVENTS)[keyof typeof DECOPILOT_EVENTS];
|
|
41
|
+
|
|
42
|
+
export const ALL_DECOPILOT_EVENT_TYPES: DecopilotEventType[] =
|
|
43
|
+
Object.values(DECOPILOT_EVENTS);
|
|
44
|
+
|
|
45
|
+
// ============================================================================
|
|
46
|
+
// Event Payloads (discriminated union on `type`)
|
|
47
|
+
// ============================================================================
|
|
48
|
+
|
|
49
|
+
interface BaseDecopilotEvent {
|
|
50
|
+
id: string;
|
|
51
|
+
source: "decopilot";
|
|
52
|
+
/** Thread ID this event relates to */
|
|
53
|
+
subject: string;
|
|
54
|
+
time: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface DecopilotStepEvent extends BaseDecopilotEvent {
|
|
58
|
+
type: typeof DECOPILOT_EVENTS.STEP;
|
|
59
|
+
data: { stepCount: number };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface DecopilotFinishEvent extends BaseDecopilotEvent {
|
|
63
|
+
type: typeof DECOPILOT_EVENTS.FINISH;
|
|
64
|
+
data: { status: ThreadStatus };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface DecopilotThreadStatusEvent extends BaseDecopilotEvent {
|
|
68
|
+
type: typeof DECOPILOT_EVENTS.THREAD_STATUS;
|
|
69
|
+
data: {
|
|
70
|
+
status: ThreadStatus;
|
|
71
|
+
virtual_mcp_id?: string;
|
|
72
|
+
/** User who created the thread; needed to populate filter-complete cache rows on the client. */
|
|
73
|
+
created_by?: string;
|
|
74
|
+
/** Automation trigger id; null for human-initiated, omitted when unknown. */
|
|
75
|
+
trigger_id?: string | null;
|
|
76
|
+
/** Thread title at emit time. Absent if caller didn't load the row. */
|
|
77
|
+
title?: string;
|
|
78
|
+
/** Branch this thread is pinned to (null when unpinned). Absent if caller didn't load the row. */
|
|
79
|
+
branch?: string | null;
|
|
80
|
+
/** Thread creation timestamp. Absent if caller didn't load the row. */
|
|
81
|
+
created_at?: string;
|
|
82
|
+
/** Last update timestamp; useful for the client to sort/dedupe. Absent if caller didn't load the row. */
|
|
83
|
+
updated_at?: string;
|
|
84
|
+
/** Free-form thread metadata snapshot. The chat UI keys off
|
|
85
|
+
* metadata.kind to switch between agent-thread and tool_call_run
|
|
86
|
+
* renderings (avatar, message-renderer), so the workflow that
|
|
87
|
+
* spawns those threads must include it on the first event or the
|
|
88
|
+
* row renders with the wrong icon until a refetch. */
|
|
89
|
+
metadata?: Record<string, unknown>;
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export type DecopilotSSEEvent =
|
|
94
|
+
| DecopilotStepEvent
|
|
95
|
+
| DecopilotFinishEvent
|
|
96
|
+
| DecopilotThreadStatusEvent;
|
|
97
|
+
|
|
98
|
+
/** Map from event type string → typed payload (useful for generic handlers) */
|
|
99
|
+
export interface DecopilotEventMap {
|
|
100
|
+
[DECOPILOT_EVENTS.STEP]: DecopilotStepEvent;
|
|
101
|
+
[DECOPILOT_EVENTS.FINISH]: DecopilotFinishEvent;
|
|
102
|
+
[DECOPILOT_EVENTS.THREAD_STATUS]: DecopilotThreadStatusEvent;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ============================================================================
|
|
106
|
+
// Server-side Factories (create typed events for SSEHub.emit)
|
|
107
|
+
// ============================================================================
|
|
108
|
+
|
|
109
|
+
export function createDecopilotStepEvent(
|
|
110
|
+
taskId: string,
|
|
111
|
+
stepCount: number,
|
|
112
|
+
): DecopilotStepEvent {
|
|
113
|
+
return {
|
|
114
|
+
id: crypto.randomUUID(),
|
|
115
|
+
type: DECOPILOT_EVENTS.STEP,
|
|
116
|
+
source: "decopilot",
|
|
117
|
+
subject: taskId,
|
|
118
|
+
data: { stepCount },
|
|
119
|
+
time: new Date().toISOString(),
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function createDecopilotFinishEvent(
|
|
124
|
+
taskId: string,
|
|
125
|
+
status: ThreadStatus,
|
|
126
|
+
): DecopilotFinishEvent {
|
|
127
|
+
return {
|
|
128
|
+
id: crypto.randomUUID(),
|
|
129
|
+
type: DECOPILOT_EVENTS.FINISH,
|
|
130
|
+
source: "decopilot",
|
|
131
|
+
subject: taskId,
|
|
132
|
+
data: { status },
|
|
133
|
+
time: new Date().toISOString(),
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function createDecopilotThreadStatusEvent(
|
|
138
|
+
taskId: string,
|
|
139
|
+
status: ThreadStatus,
|
|
140
|
+
opts?: {
|
|
141
|
+
virtualMcpId?: string;
|
|
142
|
+
createdBy?: string;
|
|
143
|
+
triggerId?: string | null;
|
|
144
|
+
title?: string;
|
|
145
|
+
branch?: string | null;
|
|
146
|
+
createdAt?: string;
|
|
147
|
+
updatedAt?: string;
|
|
148
|
+
metadata?: Record<string, unknown>;
|
|
149
|
+
},
|
|
150
|
+
): DecopilotThreadStatusEvent {
|
|
151
|
+
return {
|
|
152
|
+
id: crypto.randomUUID(),
|
|
153
|
+
type: DECOPILOT_EVENTS.THREAD_STATUS,
|
|
154
|
+
source: "decopilot",
|
|
155
|
+
subject: taskId,
|
|
156
|
+
data: {
|
|
157
|
+
status,
|
|
158
|
+
...(opts?.virtualMcpId !== undefined && {
|
|
159
|
+
virtual_mcp_id: opts.virtualMcpId,
|
|
160
|
+
}),
|
|
161
|
+
...(opts?.createdBy !== undefined && { created_by: opts.createdBy }),
|
|
162
|
+
...(opts?.triggerId !== undefined && { trigger_id: opts.triggerId }),
|
|
163
|
+
...(opts?.title !== undefined && { title: opts.title }),
|
|
164
|
+
...(opts?.branch !== undefined && { branch: opts.branch }),
|
|
165
|
+
...(opts?.createdAt !== undefined && { created_at: opts.createdAt }),
|
|
166
|
+
...(opts?.updatedAt !== undefined && { updated_at: opts.updatedAt }),
|
|
167
|
+
...(opts?.metadata !== undefined && { metadata: opts.metadata }),
|
|
168
|
+
},
|
|
169
|
+
time: new Date().toISOString(),
|
|
170
|
+
};
|
|
171
|
+
}
|
package/src/types/index.ts
CHANGED
|
@@ -19,9 +19,56 @@ export {
|
|
|
19
19
|
VirtualMCPEntitySchema,
|
|
20
20
|
VirtualMCPCreateDataSchema,
|
|
21
21
|
VirtualMCPUpdateDataSchema,
|
|
22
|
+
VirtualMcpUILayoutSchema,
|
|
23
|
+
VirtualMcpUILayoutTabSchema,
|
|
22
24
|
type VirtualMCPEntity,
|
|
23
25
|
type VirtualMCPCreateData,
|
|
24
26
|
type VirtualMCPUpdateData,
|
|
25
27
|
type VirtualMCPConnection,
|
|
26
|
-
type
|
|
28
|
+
type VirtualMcpUILayout,
|
|
29
|
+
type VirtualMcpUILayoutTab,
|
|
30
|
+
type VirtualMcpHomeTile,
|
|
31
|
+
getHomeTiles,
|
|
32
|
+
type GithubRepo,
|
|
33
|
+
SandboxMapSchema,
|
|
34
|
+
type SandboxMap,
|
|
35
|
+
SandboxRecordSchema,
|
|
36
|
+
type SandboxRecord,
|
|
37
|
+
type RuntimeMetadata,
|
|
38
|
+
type RuntimeEnvEntry,
|
|
39
|
+
ENV_VAR_KEY_RE,
|
|
40
|
+
parseSandboxRecord,
|
|
41
|
+
parseBranchMap,
|
|
42
|
+
normalizeSandboxMap,
|
|
43
|
+
type SandboxProviderKind,
|
|
27
44
|
} from "./virtual-mcp";
|
|
45
|
+
|
|
46
|
+
export {
|
|
47
|
+
PROVIDER_IDS,
|
|
48
|
+
MODEL_CAPABILITIES,
|
|
49
|
+
type ProviderId,
|
|
50
|
+
type ModelCapability,
|
|
51
|
+
type AiProviderModel,
|
|
52
|
+
type AiProviderModelLimits,
|
|
53
|
+
type AiProviderModelCosts,
|
|
54
|
+
type AiProviderKey,
|
|
55
|
+
type AiProviderInfo,
|
|
56
|
+
} from "./ai-providers";
|
|
57
|
+
|
|
58
|
+
export {
|
|
59
|
+
THREAD_STATUSES,
|
|
60
|
+
THREAD_DISPLAY_STATUSES,
|
|
61
|
+
DECOPILOT_EVENTS,
|
|
62
|
+
ALL_DECOPILOT_EVENT_TYPES,
|
|
63
|
+
createDecopilotStepEvent,
|
|
64
|
+
createDecopilotFinishEvent,
|
|
65
|
+
createDecopilotThreadStatusEvent,
|
|
66
|
+
type ThreadStatus,
|
|
67
|
+
type ThreadDisplayStatus,
|
|
68
|
+
type DecopilotEventType,
|
|
69
|
+
type DecopilotStepEvent,
|
|
70
|
+
type DecopilotFinishEvent,
|
|
71
|
+
type DecopilotThreadStatusEvent,
|
|
72
|
+
type DecopilotSSEEvent,
|
|
73
|
+
type DecopilotEventMap,
|
|
74
|
+
} from "./decopilot-events";
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { describe, expect, it, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
VirtualMCPEntitySchema,
|
|
4
|
+
VirtualMcpUILayoutSchema,
|
|
5
|
+
VirtualMCPUpdateDataSchema,
|
|
6
|
+
SandboxRecordSchema,
|
|
7
|
+
parseSandboxRecord,
|
|
8
|
+
parseBranchMap,
|
|
9
|
+
} from "./virtual-mcp";
|
|
10
|
+
|
|
11
|
+
describe("VirtualMcpUILayoutSchema tabs", () => {
|
|
12
|
+
it("parses a tabs array with ext-app view", () => {
|
|
13
|
+
const parsed = VirtualMcpUILayoutSchema.parse({
|
|
14
|
+
tabs: [
|
|
15
|
+
{
|
|
16
|
+
id: "analytics",
|
|
17
|
+
title: "Analytics",
|
|
18
|
+
icon: "BarChart",
|
|
19
|
+
view: { type: "ext-app", appId: "app_abc", args: { range: "7d" } },
|
|
20
|
+
},
|
|
21
|
+
],
|
|
22
|
+
defaultMainView: null,
|
|
23
|
+
});
|
|
24
|
+
expect(parsed.tabs).toHaveLength(1);
|
|
25
|
+
expect(parsed.tabs?.[0]!.view.type).toBe("ext-app");
|
|
26
|
+
expect(parsed.tabs?.[0]!.view.appId).toBe("app_abc");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("accepts tabs omitted (backwards compatible)", () => {
|
|
30
|
+
const parsed = VirtualMcpUILayoutSchema.parse({
|
|
31
|
+
defaultMainView: null,
|
|
32
|
+
});
|
|
33
|
+
expect(parsed.tabs).toBeUndefined();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("rejects a tab view with unknown type", () => {
|
|
37
|
+
const result = VirtualMcpUILayoutSchema.safeParse({
|
|
38
|
+
tabs: [
|
|
39
|
+
{
|
|
40
|
+
id: "bad",
|
|
41
|
+
title: "Bad",
|
|
42
|
+
view: { type: "mystery", appId: "app_x" },
|
|
43
|
+
},
|
|
44
|
+
],
|
|
45
|
+
});
|
|
46
|
+
expect(result.success).toBe(false);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("metadata.runtime is typed and round-trips through parse", () => {
|
|
51
|
+
const parsed = VirtualMCPEntitySchema.parse({
|
|
52
|
+
id: "x",
|
|
53
|
+
title: "x",
|
|
54
|
+
description: null,
|
|
55
|
+
icon: null,
|
|
56
|
+
created_at: "t",
|
|
57
|
+
updated_at: "t",
|
|
58
|
+
created_by: "u",
|
|
59
|
+
organization_id: "o",
|
|
60
|
+
status: "active",
|
|
61
|
+
pinned: false,
|
|
62
|
+
metadata: {
|
|
63
|
+
instructions: null,
|
|
64
|
+
runtime: { selected: "pnpm", port: "3000" },
|
|
65
|
+
},
|
|
66
|
+
connections: [],
|
|
67
|
+
});
|
|
68
|
+
expect(parsed.metadata.runtime?.selected).toBe("pnpm");
|
|
69
|
+
expect(parsed.metadata.runtime?.port).toBe("3000");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("metadata.runtime accepts null/empty values", () => {
|
|
73
|
+
const parsed = VirtualMCPEntitySchema.parse({
|
|
74
|
+
id: "x",
|
|
75
|
+
title: "x",
|
|
76
|
+
description: null,
|
|
77
|
+
icon: null,
|
|
78
|
+
created_at: "t",
|
|
79
|
+
updated_at: "t",
|
|
80
|
+
created_by: "u",
|
|
81
|
+
organization_id: "o",
|
|
82
|
+
status: "active",
|
|
83
|
+
pinned: false,
|
|
84
|
+
metadata: {
|
|
85
|
+
instructions: null,
|
|
86
|
+
runtime: { selected: null, port: null },
|
|
87
|
+
},
|
|
88
|
+
connections: [],
|
|
89
|
+
});
|
|
90
|
+
expect(parsed.metadata.runtime?.selected).toBeNull();
|
|
91
|
+
expect(parsed.metadata.runtime?.port).toBeNull();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("VirtualMCPUpdateDataSchema accepts metadata.runtime", () => {
|
|
95
|
+
const parsed = VirtualMCPUpdateDataSchema.parse({
|
|
96
|
+
metadata: { runtime: { selected: "bun", port: null } },
|
|
97
|
+
});
|
|
98
|
+
expect(parsed.metadata?.runtime?.selected).toBe("bun");
|
|
99
|
+
expect(parsed.metadata?.runtime?.port).toBeNull();
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("SandboxRecord.startedWith is optional with nullable packageManager/port/path", () => {
|
|
103
|
+
const a = SandboxRecordSchema.parse({ sandboxHandle: "v", previewUrl: null });
|
|
104
|
+
expect(a.startedWith).toBeUndefined();
|
|
105
|
+
const b = SandboxRecordSchema.parse({
|
|
106
|
+
sandboxHandle: "v",
|
|
107
|
+
previewUrl: null,
|
|
108
|
+
startedWith: { packageManager: "pnpm", port: "3000", path: "apps/web" },
|
|
109
|
+
});
|
|
110
|
+
expect(b.startedWith?.packageManager).toBe("pnpm");
|
|
111
|
+
expect(b.startedWith?.port).toBe("3000");
|
|
112
|
+
expect(b.startedWith?.path).toBe("apps/web");
|
|
113
|
+
const c = SandboxRecordSchema.parse({
|
|
114
|
+
sandboxHandle: "v",
|
|
115
|
+
previewUrl: null,
|
|
116
|
+
startedWith: { packageManager: null, port: null, path: null },
|
|
117
|
+
});
|
|
118
|
+
expect(c.startedWith?.packageManager).toBeNull();
|
|
119
|
+
expect(c.startedWith?.port).toBeNull();
|
|
120
|
+
expect(c.startedWith?.path).toBeNull();
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
describe("parseBranchMap", () => {
|
|
124
|
+
test("parses 3-level (kind-keyed) map with canonical kinds", () => {
|
|
125
|
+
const result = parseBranchMap({
|
|
126
|
+
cluster: {
|
|
127
|
+
sandboxHandle: "v1",
|
|
128
|
+
previewUrl: null,
|
|
129
|
+
sandboxProviderKind: "cluster",
|
|
130
|
+
},
|
|
131
|
+
"user-desktop": {
|
|
132
|
+
sandboxHandle: "v2",
|
|
133
|
+
previewUrl: null,
|
|
134
|
+
sandboxProviderKind: "user-desktop",
|
|
135
|
+
},
|
|
136
|
+
});
|
|
137
|
+
expect(result["cluster"]?.sandboxHandle).toBe("v1");
|
|
138
|
+
expect(result["user-desktop"]?.sandboxHandle).toBe("v2");
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("returns empty object for null/undefined/arrays", () => {
|
|
142
|
+
expect(parseBranchMap(null)).toEqual({});
|
|
143
|
+
expect(parseBranchMap(undefined)).toEqual({});
|
|
144
|
+
expect(parseBranchMap([])).toEqual({});
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("skips entries under legacy/retired kind keys", () => {
|
|
148
|
+
// Migrations 092/097 rewrote/dropped every legacy and retired key; reader
|
|
149
|
+
// no longer normalizes unknown keys, it just ignores them.
|
|
150
|
+
const result = parseBranchMap({
|
|
151
|
+
docker: {
|
|
152
|
+
sandboxHandle: "v-legacy",
|
|
153
|
+
previewUrl: null,
|
|
154
|
+
sandboxProviderKind: "cluster",
|
|
155
|
+
},
|
|
156
|
+
"local-docker": {
|
|
157
|
+
sandboxHandle: "v-retired",
|
|
158
|
+
previewUrl: null,
|
|
159
|
+
sandboxProviderKind: "cluster",
|
|
160
|
+
},
|
|
161
|
+
});
|
|
162
|
+
expect(result).toEqual({});
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
describe("parseSandboxRecord", () => {
|
|
167
|
+
test("accepts canonical sandboxProviderKind", () => {
|
|
168
|
+
const result = parseSandboxRecord({
|
|
169
|
+
sandboxHandle: "v1",
|
|
170
|
+
previewUrl: null,
|
|
171
|
+
sandboxProviderKind: "cluster",
|
|
172
|
+
});
|
|
173
|
+
expect(result.sandboxProviderKind).toBe("cluster");
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test("rejects legacy/retired kind values", () => {
|
|
177
|
+
expect(() =>
|
|
178
|
+
parseSandboxRecord({
|
|
179
|
+
sandboxHandle: "v1",
|
|
180
|
+
previewUrl: null,
|
|
181
|
+
sandboxProviderKind: "docker",
|
|
182
|
+
}),
|
|
183
|
+
).toThrow();
|
|
184
|
+
expect(() =>
|
|
185
|
+
parseSandboxRecord({
|
|
186
|
+
sandboxHandle: "v1",
|
|
187
|
+
previewUrl: null,
|
|
188
|
+
sandboxProviderKind: "local-docker",
|
|
189
|
+
}),
|
|
190
|
+
).toThrow();
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test("rejects rows missing `sandboxHandle` (legacy `vmId` no longer accepted)", () => {
|
|
194
|
+
expect(() =>
|
|
195
|
+
parseSandboxRecord({
|
|
196
|
+
vmId: "v-pre-rename",
|
|
197
|
+
previewUrl: null,
|
|
198
|
+
sandboxProviderKind: "cluster",
|
|
199
|
+
}),
|
|
200
|
+
).toThrow();
|
|
201
|
+
});
|
|
202
|
+
});
|