@volter/twin-livekit 0.1.0
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/LICENSE +202 -0
- package/README.md +106 -0
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +23 -0
- package/dist/src/egress-service-cli.d.ts +2 -0
- package/dist/src/egress-service-cli.js +101 -0
- package/dist/src/index.d.ts +14 -0
- package/dist/src/index.js +53 -0
- package/dist/src/livekit-budget.d.ts +57 -0
- package/dist/src/livekit-budget.js +131 -0
- package/dist/src/livekit-capabilities.d.ts +3 -0
- package/dist/src/livekit-capabilities.js +728 -0
- package/dist/src/livekit-conformance.d.ts +8 -0
- package/dist/src/livekit-conformance.js +13 -0
- package/dist/src/livekit-connector.d.ts +130 -0
- package/dist/src/livekit-connector.js +382 -0
- package/dist/src/livekit-data.d.ts +86 -0
- package/dist/src/livekit-data.js +246 -0
- package/dist/src/livekit-perform-harness.d.ts +5 -0
- package/dist/src/livekit-perform-harness.js +17 -0
- package/dist/src/livekit-server.d.ts +23 -0
- package/dist/src/livekit-server.js +45 -0
- package/dist/src/livekit-service-cli.d.ts +2 -0
- package/dist/src/livekit-service-cli.js +88 -0
- package/dist/src/livekit-token.d.ts +68 -0
- package/dist/src/livekit-token.js +76 -0
- package/dist/src/livekit-twin.d.ts +4 -0
- package/dist/src/livekit-twin.js +1161 -0
- package/dist/src/livekit-types.d.ts +203 -0
- package/dist/src/livekit-types.js +1 -0
- package/dist/src/livekit-webhook.d.ts +26 -0
- package/dist/src/livekit-webhook.js +79 -0
- package/dist/src/redis-service-cli.d.ts +2 -0
- package/dist/src/redis-service-cli.js +35 -0
- package/dist/test-fixtures/livekit-openapi-operations.SOURCE.md +88 -0
- package/dist/test-fixtures/livekit-openapi-operations.json +442 -0
- package/dist/test-fixtures/protobufs/cloud_replay.proto +82 -0
- package/dist/test-fixtures/protobufs/livekit_agent.proto +185 -0
- package/dist/test-fixtures/protobufs/livekit_agent_dispatch.proto +103 -0
- package/dist/test-fixtures/protobufs/livekit_agent_simulation.proto +422 -0
- package/dist/test-fixtures/protobufs/livekit_agent_worker.proto +29 -0
- package/dist/test-fixtures/protobufs/livekit_agentdb.proto +226 -0
- package/dist/test-fixtures/protobufs/livekit_analytics.proto +312 -0
- package/dist/test-fixtures/protobufs/livekit_cloud_agent.proto +376 -0
- package/dist/test-fixtures/protobufs/livekit_connector.proto +41 -0
- package/dist/test-fixtures/protobufs/livekit_connector_twilio.proto +65 -0
- package/dist/test-fixtures/protobufs/livekit_connector_whatsapp.proto +171 -0
- package/dist/test-fixtures/protobufs/livekit_egress.proto +641 -0
- package/dist/test-fixtures/protobufs/livekit_ingress.proto +222 -0
- package/dist/test-fixtures/protobufs/livekit_internal.proto +228 -0
- package/dist/test-fixtures/protobufs/livekit_metrics.proto +103 -0
- package/dist/test-fixtures/protobufs/livekit_models.proto +992 -0
- package/dist/test-fixtures/protobufs/livekit_phone_number.proto +151 -0
- package/dist/test-fixtures/protobufs/livekit_room.proto +312 -0
- package/dist/test-fixtures/protobufs/livekit_rtc.proto +673 -0
- package/dist/test-fixtures/protobufs/livekit_sip.proto +1000 -0
- package/dist/test-fixtures/protobufs/livekit_token_source.proto +49 -0
- package/dist/test-fixtures/protobufs/livekit_webhook.proto +62 -0
- package/package.json +58 -0
- package/src/cli.ts +22 -0
- package/src/egress-service-cli.ts +111 -0
- package/src/index.ts +103 -0
- package/src/livekit-budget.ts +158 -0
- package/src/livekit-capabilities.ts +789 -0
- package/src/livekit-conformance.ts +15 -0
- package/src/livekit-connector.ts +406 -0
- package/src/livekit-data.ts +278 -0
- package/src/livekit-perform-harness.ts +17 -0
- package/src/livekit-server.ts +67 -0
- package/src/livekit-service-cli.ts +93 -0
- package/src/livekit-token.ts +133 -0
- package/src/livekit-twin.ts +1162 -0
- package/src/livekit-types.ts +211 -0
- package/src/livekit-webhook.ts +91 -0
- package/src/redis-service-cli.ts +39 -0
- package/test-fixtures/livekit-openapi-operations.SOURCE.md +88 -0
- package/test-fixtures/livekit-openapi-operations.json +442 -0
- package/test-fixtures/protobufs/cloud_replay.proto +82 -0
- package/test-fixtures/protobufs/livekit_agent.proto +185 -0
- package/test-fixtures/protobufs/livekit_agent_dispatch.proto +103 -0
- package/test-fixtures/protobufs/livekit_agent_simulation.proto +422 -0
- package/test-fixtures/protobufs/livekit_agent_worker.proto +29 -0
- package/test-fixtures/protobufs/livekit_agentdb.proto +226 -0
- package/test-fixtures/protobufs/livekit_analytics.proto +312 -0
- package/test-fixtures/protobufs/livekit_cloud_agent.proto +376 -0
- package/test-fixtures/protobufs/livekit_connector.proto +41 -0
- package/test-fixtures/protobufs/livekit_connector_twilio.proto +65 -0
- package/test-fixtures/protobufs/livekit_connector_whatsapp.proto +171 -0
- package/test-fixtures/protobufs/livekit_egress.proto +641 -0
- package/test-fixtures/protobufs/livekit_ingress.proto +222 -0
- package/test-fixtures/protobufs/livekit_internal.proto +228 -0
- package/test-fixtures/protobufs/livekit_metrics.proto +103 -0
- package/test-fixtures/protobufs/livekit_models.proto +992 -0
- package/test-fixtures/protobufs/livekit_phone_number.proto +151 -0
- package/test-fixtures/protobufs/livekit_room.proto +312 -0
- package/test-fixtures/protobufs/livekit_rtc.proto +673 -0
- package/test-fixtures/protobufs/livekit_sip.proto +1000 -0
- package/test-fixtures/protobufs/livekit_token_source.proto +49 -0
- package/test-fixtures/protobufs/livekit_webhook.proto +62 -0
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { handleLiveKitTwinRequest } from './livekit-twin.ts';
|
|
2
|
+
|
|
3
|
+
export type LiveKitConformanceReport = { ok: boolean; checksRun: number; failures: string[] };
|
|
4
|
+
|
|
5
|
+
export async function checkLiveKitConformance(options: { root?: string } = {}): Promise<LiveKitConformanceReport> {
|
|
6
|
+
const failures: string[] = [];
|
|
7
|
+
const check = async (name: string, ok: boolean) => { if (!ok) failures.push(name); };
|
|
8
|
+
const create = await handleLiveKitTwinRequest({ method: 'POST', path: '/twirp/livekit.RoomService/CreateRoom', body: JSON.stringify({ name: 'test-conformance', emptyTimeout: 600 }), root: options.root });
|
|
9
|
+
await check('create room returns room name', create.status === 200 && (create.body as any).name === 'test-conformance');
|
|
10
|
+
const list = await handleLiveKitTwinRequest({ method: 'POST', path: '/twirp/livekit.RoomService/ListRooms', body: JSON.stringify({ names: ['test-conformance'] }), root: options.root });
|
|
11
|
+
await check('list rooms returns created room', list.status === 200 && (list.body as any).rooms?.[0]?.name === 'test-conformance');
|
|
12
|
+
const bad = await handleLiveKitTwinRequest({ method: 'POST', path: '/twirp/livekit.RoomService/CreateRoom', body: JSON.stringify({}), root: options.root });
|
|
13
|
+
await check('missing room name rejects', bad.status === 400 && (bad.body as any).code === 'invalid_argument');
|
|
14
|
+
return { ok: failures.length === 0, checksRun: 3, failures };
|
|
15
|
+
}
|
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
// LiveKit CONNECTOR — pull/push against an INJECTED `LiveKitExecute`, so every test and every
|
|
2
|
+
// capability verify runs offline against a deterministic fake. `liveLiveKitExecute` below is the pack's
|
|
3
|
+
// single construction site for a REAL one, with the fail-closed rate budget inside it.
|
|
4
|
+
import { observeResources } from '@volter/world-core';
|
|
5
|
+
import type { PerformContext, PushOutcome, RemoteExecute, SyncResource, TwinAction } from '@volter/world-core';
|
|
6
|
+
import { LiveKitBudget, LiveKitBudgetError, liveKitCallWeight, type LiveKitBudgetOptions } from './livekit-budget.ts';
|
|
7
|
+
import { EGRESS_STATUS, SERVICE, defaultParticipant, egressResourceId, ingressResourceId, roomId } from './livekit-data.ts';
|
|
8
|
+
import { mintAccessToken, type LiveKitVideoGrant } from './livekit-token.ts';
|
|
9
|
+
import type {
|
|
10
|
+
LiveKitAgentDispatch,
|
|
11
|
+
LiveKitEgress,
|
|
12
|
+
LiveKitIngress,
|
|
13
|
+
LiveKitParticipant,
|
|
14
|
+
LiveKitRoom,
|
|
15
|
+
LiveKitSIPDispatchRule,
|
|
16
|
+
LiveKitSIPInboundTrunk,
|
|
17
|
+
LiveKitSIPOutboundTrunk,
|
|
18
|
+
} from './livekit-types.ts';
|
|
19
|
+
|
|
20
|
+
export type LiveKitService = 'RoomService' | 'Egress' | 'Ingress' | 'SIP' | 'AgentDispatchService';
|
|
21
|
+
export type LiveKitExecute = (req: { service: LiveKitService; method: string; body?: unknown }) => Promise<{ status: number; data: unknown }>;
|
|
22
|
+
|
|
23
|
+
const DEFAULT_OCCURRED_AT = '1970-01-01T00:00:00.000Z';
|
|
24
|
+
|
|
25
|
+
// ── THE CHOKE POINT ─────────────────────────────────────────────────────────────────────────
|
|
26
|
+
// `liveLiveKitExecute` is the pack's ONE construction site for a real, network-calling execute —
|
|
27
|
+
// the choke point the rate budget sits inside. Before it existed there was nowhere for a guard to
|
|
28
|
+
// live: the pack only ever received an execute, so a budget could only be something the caller
|
|
29
|
+
// remembered to opt into, and "the caller remembered" is exactly the assumption that cost a ~4.5-day
|
|
30
|
+
// Figma token lockout (2026-07-25). LiveKit's Server API limit is 1,000 requests/minute PER PROJECT
|
|
31
|
+
// and this connector's snapshot fans out one request PER ROOM — see `livekit-budget.ts`.
|
|
32
|
+
//
|
|
33
|
+
// The injected contract is UNCHANGED: everything else in this file still takes a plain `LiveKitExecute`.
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The video grant each Twirp METHOD requires. A hand-mirror of the `GRANT_REQUIRED` table in
|
|
37
|
+
* `livekit-twin.ts` (which itself mirrors LiveKit's own auth middleware), kept here rather than
|
|
38
|
+
* imported so the connector does not pull the twin's request handler into its module graph —
|
|
39
|
+
* and BOUND to it by a test (`livekit-budget.test.ts`) that fails the moment the two disagree,
|
|
40
|
+
* so the copy cannot drift into a silent 403. (§9 finding, 2026-07-26.)
|
|
41
|
+
*
|
|
42
|
+
* PER-METHOD, not per-service: the token is minted per call anyway, so least privilege is free.
|
|
43
|
+
* A service-granularity union would have put `roomCreate` — the grant `RoomService.DeleteRoom`
|
|
44
|
+
* needs — on every `ListRooms` this connector issues, which is a strictly broader token than the
|
|
45
|
+
* call requires and than livekit-server-sdk would sign.
|
|
46
|
+
*/
|
|
47
|
+
export const LIVEKIT_METHOD_VIDEO_GRANT: Record<string, keyof LiveKitVideoGrant> = {
|
|
48
|
+
'RoomService.ListRooms': 'roomList',
|
|
49
|
+
'RoomService.ListParticipants': 'roomAdmin',
|
|
50
|
+
'RoomService.UpdateRoomMetadata': 'roomAdmin',
|
|
51
|
+
'Egress.ListEgress': 'roomRecord',
|
|
52
|
+
'Egress.StopEgress': 'roomRecord',
|
|
53
|
+
'Ingress.ListIngress': 'ingressAdmin',
|
|
54
|
+
'AgentDispatchService.ListDispatch': 'roomAdmin',
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The fallback when a caller invokes a method the table above does not name: the UNION of the
|
|
59
|
+
* grants that service's methods need, which is what livekit-server-sdk's service clients sign.
|
|
60
|
+
* Broader than a per-method grant, deliberately — an unknown method must still be able to
|
|
61
|
+
* authorize — but never broader than its own service.
|
|
62
|
+
*
|
|
63
|
+
* SIP is the one service that authorizes off the SIP grant rather than a video grant; that is
|
|
64
|
+
* LiveKit's own scheme, recorded in `livekit-twin.ts`'s note on the same table.
|
|
65
|
+
*/
|
|
66
|
+
const SERVICE_GRANT_UNION: Record<LiveKitService, { video?: LiveKitVideoGrant; sip?: { admin: boolean } }> = {
|
|
67
|
+
RoomService: { video: { roomCreate: true, roomList: true, roomAdmin: true } },
|
|
68
|
+
Egress: { video: { roomRecord: true } },
|
|
69
|
+
Ingress: { video: { ingressAdmin: true } },
|
|
70
|
+
SIP: { sip: { admin: true } },
|
|
71
|
+
AgentDispatchService: { video: { roomAdmin: true } },
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
/** The least-privilege grant for one call: the per-method one when known, else the service union. */
|
|
75
|
+
export function liveKitGrantFor(service: LiveKitService, method: string): { video?: LiveKitVideoGrant; sip?: { admin: boolean } } {
|
|
76
|
+
const only = LIVEKIT_METHOD_VIDEO_GRANT[`${service}.${method}`];
|
|
77
|
+
if (only) return { video: { [only]: true } as LiveKitVideoGrant };
|
|
78
|
+
return SERVICE_GRANT_UNION[service] ?? {};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The HTTP origin for a LiveKit Server API call. LiveKit hands out a `wss://`/`ws://` project URL
|
|
83
|
+
* (`LIVEKIT_URL`) because that is what the media SDKs want; the Server APIs live at the same host over
|
|
84
|
+
* HTTP(S), which is why every LiveKit server SDK performs exactly this scheme swap. An `http(s)://`
|
|
85
|
+
* URL is passed through unchanged (a self-hosted deployment is usually configured that way).
|
|
86
|
+
*/
|
|
87
|
+
export function liveKitHttpOrigin(url: string): string {
|
|
88
|
+
const trimmed = url.trim().replace(/\/+$/, '');
|
|
89
|
+
if (/^wss:\/\//i.test(trimmed)) return `https://${trimmed.slice(6)}`;
|
|
90
|
+
if (/^ws:\/\//i.test(trimmed)) return `http://${trimmed.slice(5)}`;
|
|
91
|
+
if (/^https?:\/\//i.test(trimmed)) return trimmed;
|
|
92
|
+
throw new Error(`LIVEKIT_URL must be a ws://, wss://, http:// or https:// URL (got ${url || '<empty>'})`);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The Twirp path for one RPC. LiveKit's server routes every Server API call under this shape.
|
|
97
|
+
*
|
|
98
|
+
* The method name is VALIDATED, not merely interpolated: it arrives from the caller, and a value
|
|
99
|
+
* containing `/`, `..` or a query string would reshape the URL this factory then sends a credentialed
|
|
100
|
+
* request to. A Twirp method is a protobuf RPC name, so the identifier grammar is exact, not a guess.
|
|
101
|
+
* (§9 hardening, 2026-07-26.)
|
|
102
|
+
*/
|
|
103
|
+
export function liveKitTwirpPath(service: LiveKitService, method: string): string {
|
|
104
|
+
if (typeof method !== 'string' || !/^[A-Za-z][A-Za-z0-9_]*$/.test(method)) {
|
|
105
|
+
throw new Error(`liveKitTwirpPath: "${String(method)}" is not a Twirp method name — refusing to build a request URL from it`);
|
|
106
|
+
}
|
|
107
|
+
return `/twirp/livekit.${service}/${method}`;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* THE CHOKE POINT — the one place this pack builds a `LiveKitExecute` that really reaches a LiveKit
|
|
112
|
+
* server, and therefore the one place the rate budget has to be enforced.
|
|
113
|
+
*
|
|
114
|
+
* Reads `LIVEKIT_URL`, `LIVEKIT_API_KEY` and `LIVEKIT_API_SECRET` from the environment (the same three
|
|
115
|
+
* variables the world runtime already injects, and that `egress-service-cli.ts` already reads) and
|
|
116
|
+
* mints a short-lived HS256 access token PER CALL with only the grant that service needs — using this
|
|
117
|
+
* pack's own `mintAccessToken`, which has byte-level parity with `livekit-server-sdk`'s
|
|
118
|
+
* `AccessToken.toJwt()`. The secret never leaves the process; only the signed JWT is sent.
|
|
119
|
+
*
|
|
120
|
+
* EVERY request is guarded: the budget is charged BEFORE it goes out (`checkBudget`, which THROWS
|
|
121
|
+
* instead of returning once the ceiling or a persisted cooldown says stop) and the response is fed
|
|
122
|
+
* back (`recordCall`) so a `Retry-After` / 429 / rate-limit-exhaustion signal becomes a persisted
|
|
123
|
+
* cooldown that makes every later call fail fast WITHOUT touching LiveKit. There is deliberately NO
|
|
124
|
+
* option to disable the guard.
|
|
125
|
+
*
|
|
126
|
+
* No vendor SDK is imported: real-transport `fetch` plus this pack's own token minting keeps
|
|
127
|
+
* `livekit-server-sdk` a dev-only dependency, which the architecture guardrail requires.
|
|
128
|
+
*/
|
|
129
|
+
export function liveLiveKitExecute(
|
|
130
|
+
opts: {
|
|
131
|
+
url?: string;
|
|
132
|
+
apiKey?: string;
|
|
133
|
+
apiSecret?: string;
|
|
134
|
+
fetchImpl?: typeof fetch;
|
|
135
|
+
/** Seconds an issued token stays valid. Short by default, like the SDK's per-request tokens. */
|
|
136
|
+
tokenTtlSeconds?: number;
|
|
137
|
+
/** Unix SECONDS, for the token's nbf/exp. Injectable so tests are deterministic. */
|
|
138
|
+
nowSeconds?: () => number;
|
|
139
|
+
/** An existing budget to share across executes. Omit and one is constructed. Cannot be null. */
|
|
140
|
+
budget?: LiveKitBudget;
|
|
141
|
+
/** Construction options for the default budget (ledger path, clock). Cannot loosen it. */
|
|
142
|
+
budgetOptions?: LiveKitBudgetOptions;
|
|
143
|
+
} = {},
|
|
144
|
+
): LiveKitExecute {
|
|
145
|
+
const url = opts.url ?? process.env.LIVEKIT_URL ?? '';
|
|
146
|
+
if (!url) throw new Error('LIVEKIT_URL is not set — the live LiveKit client needs the project URL');
|
|
147
|
+
const apiKey = opts.apiKey ?? process.env.LIVEKIT_API_KEY ?? '';
|
|
148
|
+
const apiSecret = opts.apiSecret ?? process.env.LIVEKIT_API_SECRET ?? '';
|
|
149
|
+
if (!apiKey || !apiSecret) throw new Error('LIVEKIT_API_KEY and LIVEKIT_API_SECRET are not set — the live LiveKit client needs both to sign a token');
|
|
150
|
+
const origin = liveKitHttpOrigin(url);
|
|
151
|
+
const doFetch = opts.fetchImpl ?? fetch;
|
|
152
|
+
const ttlSeconds = opts.tokenTtlSeconds ?? 600;
|
|
153
|
+
const nowSeconds = opts.nowSeconds ?? (() => Math.floor(Date.now() / 1000));
|
|
154
|
+
// There is no value a caller can pass to end up with an unguarded execute. `null`/`undefined` (or
|
|
155
|
+
// omitting it) build the default budget; anything that is not a REAL `LiveKitBudget` is refused
|
|
156
|
+
// loudly rather than trusted — a duck-typed stand-in with a no-op `checkBudget` would otherwise be
|
|
157
|
+
// the one clean way around the guard.
|
|
158
|
+
if (opts.budget !== undefined && opts.budget !== null && !(opts.budget instanceof LiveKitBudget)) {
|
|
159
|
+
throw new Error('liveLiveKitExecute: `budget` must be a LiveKitBudget — refusing to build a live LiveKit client around an unverified rate guard');
|
|
160
|
+
}
|
|
161
|
+
// The default ledger is keyed by a hash of THIS api key — the Server API limit is per PROJECT and
|
|
162
|
+
// the key identifies the project, so a cwd-scoped ledger would hand the same project a fresh
|
|
163
|
+
// allowance per checkout/worktree/CI leg.
|
|
164
|
+
const budget = opts.budget instanceof LiveKitBudget
|
|
165
|
+
? opts.budget
|
|
166
|
+
: new LiveKitBudget({ token: apiKey, ...(opts.budgetOptions ?? {}) });
|
|
167
|
+
return async (req) => {
|
|
168
|
+
// Validate the RPC name BEFORE charging: a malformed method is a caller bug, not vendor traffic,
|
|
169
|
+
// and it must not cost the project any of its allowance.
|
|
170
|
+
const path = liveKitTwirpPath(req.service, req.method);
|
|
171
|
+
const weight = liveKitCallWeight(req.service, req.method);
|
|
172
|
+
// THROWS instead of calling. Nothing below this line runs when the budget refuses.
|
|
173
|
+
const reservation = budget.checkBudget(weight);
|
|
174
|
+
const grants = liveKitGrantFor(req.service, req.method);
|
|
175
|
+
const jwt = mintAccessToken({
|
|
176
|
+
apiKey,
|
|
177
|
+
apiSecret,
|
|
178
|
+
ttlSeconds,
|
|
179
|
+
nowSeconds: nowSeconds(),
|
|
180
|
+
...(grants.video ? { video: grants.video } : {}),
|
|
181
|
+
...(grants.sip ? { sip: grants.sip } : {}),
|
|
182
|
+
});
|
|
183
|
+
const res = await doFetch(`${origin}${path}`, {
|
|
184
|
+
method: 'POST', // every Twirp JSON call is a POST, including the read-only ones
|
|
185
|
+
headers: {
|
|
186
|
+
Authorization: `Bearer ${jwt}`,
|
|
187
|
+
'Content-Type': 'application/json',
|
|
188
|
+
Accept: 'application/json',
|
|
189
|
+
},
|
|
190
|
+
body: JSON.stringify(req.body ?? {}),
|
|
191
|
+
});
|
|
192
|
+
const headers = lowerCasedHeaders(res.headers);
|
|
193
|
+
const text = await res.text();
|
|
194
|
+
let data: unknown = null;
|
|
195
|
+
try { data = text ? JSON.parse(text) : null; } catch { data = { raw: text }; }
|
|
196
|
+
// Settles the reservation and, on a back-off signal, arms the cooldown. May itself throw (a
|
|
197
|
+
// Retry-After beyond the cap is not something to sleep off) — the cooldown is persisted first
|
|
198
|
+
// either way, so the refusal survives the throw.
|
|
199
|
+
// recordCall may THROW after arming the cooldown (a back-off beyond the cap). On a refused
|
|
200
|
+
// call that louder refusal wins; an answer LiveKit ACCEPTED is kept, so a write that landed is
|
|
201
|
+
// never recorded as failed and performed again on retry.
|
|
202
|
+
try {
|
|
203
|
+
budget.recordCall(weight, headers, { status: res.status, reservation });
|
|
204
|
+
} catch (error) {
|
|
205
|
+
if (!(error instanceof LiveKitBudgetError) || !res.ok) throw error;
|
|
206
|
+
}
|
|
207
|
+
return { status: res.status, data };
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Response headers as a plain lower-cased record — what the kernel's back-off reader expects. */
|
|
212
|
+
function lowerCasedHeaders(h: Headers): Record<string, string> {
|
|
213
|
+
const out: Record<string, string> = {};
|
|
214
|
+
h.forEach((v: string, k: string) => { out[k.toLowerCase()] = v; });
|
|
215
|
+
return out;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// ── COLLECT (real → SyncResource[], no fold) ─────────────────────────────────────────
|
|
219
|
+
// Private fetch helpers shared by the existing per-domain `pull*` functions (which fold their
|
|
220
|
+
// own slice) and the D7 `syncLiveKitFromReal` entry point (which folds ALL slices in one syncPull).
|
|
221
|
+
|
|
222
|
+
async function collectLiveKitSnapshot(execute: LiveKitExecute, roomNames: string[], occurredAt: string): Promise<SyncResource[]> {
|
|
223
|
+
const resources: SyncResource[] = [];
|
|
224
|
+
const rooms = await execute({ service: 'RoomService', method: 'ListRooms', body: { names: roomNames } });
|
|
225
|
+
if (rooms.status >= 200 && rooms.status < 300) {
|
|
226
|
+
for (const room of ((rooms.data as { rooms?: LiveKitRoom[] }).rooms ?? [])) {
|
|
227
|
+
resources.push({ type: 'room', id: roomId(room.name), fields: { ...room, id: roomId(room.name) } as unknown as Record<string, unknown> });
|
|
228
|
+
const participants = await execute({ service: 'RoomService', method: 'ListParticipants', body: { room: room.name } });
|
|
229
|
+
if (participants.status >= 200 && participants.status < 300) {
|
|
230
|
+
for (const participant of ((participants.data as { participants?: LiveKitParticipant[] }).participants ?? [])) {
|
|
231
|
+
const row = defaultParticipant(room.name, participant.identity, occurredAt, participant);
|
|
232
|
+
resources.push({ type: 'participant', id: row.id, fields: row as unknown as Record<string, unknown> });
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
const egress = await execute({ service: 'Egress', method: 'ListEgress', body: {} });
|
|
238
|
+
if (egress.status >= 200 && egress.status < 300) {
|
|
239
|
+
for (const row of ((egress.data as { items?: LiveKitEgress[] }).items ?? [])) {
|
|
240
|
+
const id = egressResourceId(row.egressId);
|
|
241
|
+
resources.push({ type: 'egress', id, fields: { ...row, id } as unknown as Record<string, unknown> });
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
const ingress = await execute({ service: 'Ingress', method: 'ListIngress', body: {} });
|
|
245
|
+
if (ingress.status >= 200 && ingress.status < 300) {
|
|
246
|
+
for (const row of ((ingress.data as { items?: LiveKitIngress[] }).items ?? [])) {
|
|
247
|
+
const id = ingressResourceId(row.ingressId);
|
|
248
|
+
resources.push({ type: 'ingress', id, fields: { ...row, id } as unknown as Record<string, unknown> });
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
return resources;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
async function collectLiveKitSIP(execute: LiveKitExecute): Promise<SyncResource[]> {
|
|
255
|
+
const resources: SyncResource[] = [];
|
|
256
|
+
const inbound = await execute({ service: 'SIP', method: 'ListSIPInboundTrunk', body: {} });
|
|
257
|
+
if (inbound.status >= 200 && inbound.status < 300) {
|
|
258
|
+
for (const t of ((inbound.data as { items?: LiveKitSIPInboundTrunk[] }).items ?? [])) {
|
|
259
|
+
const id = `sip_inbound_trunk_${t.sipTrunkId}`;
|
|
260
|
+
resources.push({ type: 'sip_inbound_trunk', id, fields: { ...t, id } as unknown as Record<string, unknown> });
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
const outbound = await execute({ service: 'SIP', method: 'ListSIPOutboundTrunk', body: {} });
|
|
264
|
+
if (outbound.status >= 200 && outbound.status < 300) {
|
|
265
|
+
for (const t of ((outbound.data as { items?: LiveKitSIPOutboundTrunk[] }).items ?? [])) {
|
|
266
|
+
const id = `sip_outbound_trunk_${t.sipTrunkId}`;
|
|
267
|
+
resources.push({ type: 'sip_outbound_trunk', id, fields: { ...t, id } as unknown as Record<string, unknown> });
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
const rules = await execute({ service: 'SIP', method: 'ListSIPDispatchRule', body: {} });
|
|
271
|
+
if (rules.status >= 200 && rules.status < 300) {
|
|
272
|
+
for (const r of ((rules.data as { items?: LiveKitSIPDispatchRule[] }).items ?? [])) {
|
|
273
|
+
const id = `sip_dispatch_rule_${r.sipDispatchRuleId}`;
|
|
274
|
+
resources.push({ type: 'sip_dispatch_rule', id, fields: { ...r, id } as unknown as Record<string, unknown> });
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
return resources;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
async function collectLiveKitAgentDispatch(execute: LiveKitExecute, roomNames: string[]): Promise<SyncResource[]> {
|
|
281
|
+
const resources: SyncResource[] = [];
|
|
282
|
+
for (const room of roomNames) {
|
|
283
|
+
const res = await execute({ service: 'AgentDispatchService', method: 'ListDispatch', body: { room } });
|
|
284
|
+
if (res.status < 200 || res.status >= 300) continue;
|
|
285
|
+
for (const a of ((res.data as { agentDispatches?: Array<{ id: string; agentName: string; room?: string; metadata?: string; state?: { createdAt?: string } }> }).agentDispatches ?? [])) {
|
|
286
|
+
const dispatchId = a.id;
|
|
287
|
+
const id = `agent_dispatch_${dispatchId}`;
|
|
288
|
+
const row: LiveKitAgentDispatch = {
|
|
289
|
+
id,
|
|
290
|
+
dispatchId,
|
|
291
|
+
agentName: a.agentName,
|
|
292
|
+
room: a.room ?? room,
|
|
293
|
+
metadata: a.metadata ?? '',
|
|
294
|
+
createdAt: a.state?.createdAt ?? '0',
|
|
295
|
+
};
|
|
296
|
+
resources.push({ type: 'agent_dispatch', id, fields: row as unknown as Record<string, unknown> });
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
return resources;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// ── PULL (per-domain — unchanged signatures; each folds its own slice) ────────────────
|
|
303
|
+
export async function pullLiveKitSnapshot(execute: LiveKitExecute, roomNames: string[], opts: { root?: string; occurredAt?: string } = {}): Promise<void> {
|
|
304
|
+
const occurredAt = opts.occurredAt ?? DEFAULT_OCCURRED_AT;
|
|
305
|
+
const resources = await collectLiveKitSnapshot(execute, roomNames, occurredAt);
|
|
306
|
+
fold(resources, { occurredAt, ...(opts.root !== undefined ? { root: opts.root } : {}) });
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Pull SIP resources (inbound/outbound trunks + dispatch rules) through the
|
|
311
|
+
* injected client into local state. Idempotent: re-pulling the same snapshot
|
|
312
|
+
* records no new deltas.
|
|
313
|
+
*/
|
|
314
|
+
export async function pullLiveKitSIP(execute: LiveKitExecute, opts: { root?: string; occurredAt?: string } = {}): Promise<void> {
|
|
315
|
+
const occurredAt = opts.occurredAt ?? DEFAULT_OCCURRED_AT;
|
|
316
|
+
const resources = await collectLiveKitSIP(execute);
|
|
317
|
+
fold(resources, { occurredAt, ...(opts.root !== undefined ? { root: opts.root } : {}) });
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Pull agent dispatch resources for the given rooms through the injected client.
|
|
322
|
+
* Idempotent: re-pulling the same snapshot records no new deltas.
|
|
323
|
+
*/
|
|
324
|
+
export async function pullLiveKitAgentDispatch(execute: LiveKitExecute, roomNames: string[], opts: { root?: string; occurredAt?: string } = {}): Promise<void> {
|
|
325
|
+
const occurredAt = opts.occurredAt ?? DEFAULT_OCCURRED_AT;
|
|
326
|
+
const resources = await collectLiveKitAgentDispatch(execute, roomNames);
|
|
327
|
+
fold(resources, { occurredAt, ...(opts.root !== undefined ? { root: opts.root } : {}) });
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* D7 consumer-facing pull entry point: gather resources across ALL LiveKit domains
|
|
332
|
+
* (rooms/participants/egress/ingress + SIP + agent dispatch) and fold them into the twin
|
|
333
|
+
* via a SINGLE syncPull. Domain inputs (room names) ride on `opts`. Idempotent: a re-pull
|
|
334
|
+
* of identical state appends no new deltas.
|
|
335
|
+
*/
|
|
336
|
+
/** One fold onto the head: protocol 2's observe, one batch, one instant. */
|
|
337
|
+
function fold(resources: SyncResource[], opts: { root?: string; occurredAt: string }) {
|
|
338
|
+
return observeResources(SERVICE, resources.map((r) => ({ type: r.type, id: r.id, fields: r.fields })), {
|
|
339
|
+
...(opts.root !== undefined ? { root: opts.root } : {}), at: opts.occurredAt, batch: `obs:${SERVICE}:${opts.occurredAt}`,
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
export async function syncLiveKitFromReal(
|
|
344
|
+
execute: LiveKitExecute,
|
|
345
|
+
opts: { root?: string; occurredAt?: string; roomNames?: string[] } = {},
|
|
346
|
+
): Promise<{ observed: number; deltasAppended: number }> {
|
|
347
|
+
const occurredAt = opts.occurredAt ?? DEFAULT_OCCURRED_AT;
|
|
348
|
+
const roomNames = opts.roomNames ?? [];
|
|
349
|
+
const resources = [
|
|
350
|
+
...(await collectLiveKitSnapshot(execute, roomNames, occurredAt)),
|
|
351
|
+
...(await collectLiveKitSIP(execute)),
|
|
352
|
+
...(await collectLiveKitAgentDispatch(execute, roomNames)),
|
|
353
|
+
];
|
|
354
|
+
const result = fold(resources, { occurredAt, ...(opts.root !== undefined ? { root: opts.root } : {}) });
|
|
355
|
+
return { observed: result.observed, deltasAppended: result.appended };
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
export function liveKitRequestForAction(action: TwinAction): { service: LiveKitService; method: string; body?: unknown } | null {
|
|
359
|
+
const fields = action.fields as Record<string, unknown>;
|
|
360
|
+
if (action.operation === 'room.update_metadata') return { service: 'RoomService', method: 'UpdateRoomMetadata', body: { room: fields.name, metadata: fields.metadata ?? '' } };
|
|
361
|
+
if (action.operation === 'egress.stop') return { service: 'Egress', method: 'StopEgress', body: { egressId: fields.egressId } };
|
|
362
|
+
return null;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
export async function pushLiveKitAction(action: TwinAction, execute: LiveKitExecute): Promise<boolean> {
|
|
366
|
+
const req = liveKitRequestForAction(action);
|
|
367
|
+
if (!req) return false;
|
|
368
|
+
const result = await execute(req);
|
|
369
|
+
if (result.status < 200 || result.status >= 300) return false;
|
|
370
|
+
const data = result.data as Record<string, unknown>;
|
|
371
|
+
// the vendor's answer has to SHOW the change, or it did not happen
|
|
372
|
+
if (action.operation === 'room.update_metadata' && data.metadata !== (action.fields as Record<string, unknown>).metadata) return false;
|
|
373
|
+
if (action.operation === 'egress.stop' && data.status !== EGRESS_STATUS.COMPLETE) return false;
|
|
374
|
+
return true;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// ── PROTOCOL 2: the pack's half of the real state system ────────────────────────────────────
|
|
378
|
+
/** The pack's executor over the kernel's: LiveKit's Twirp RPCs, carried by the head. */
|
|
379
|
+
export function liveKitExecuteOver(execute: RemoteExecute): LiveKitExecute {
|
|
380
|
+
return async ({ service, method, body }) => {
|
|
381
|
+
const res = await execute({
|
|
382
|
+
method: 'POST', path: liveKitTwirpPath(service, method),
|
|
383
|
+
headers: { accept: 'application/json', 'content-type': 'application/json' },
|
|
384
|
+
body: JSON.stringify(body ?? {}),
|
|
385
|
+
});
|
|
386
|
+
let data: unknown = {};
|
|
387
|
+
if (res.body) { try { data = JSON.parse(res.body); } catch { data = { msg: res.body }; } }
|
|
388
|
+
return { status: res.status, data };
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
/** The refresh adapter: pull the rooms, egresses and ingresses this credential can see. */
|
|
392
|
+
export async function syncLiveKitFromRemote(execute: RemoteExecute, opts: { root?: string; origin?: string; occurredAt?: string } = {}): Promise<{ observed: number; deltasAppended: number }> {
|
|
393
|
+
return syncLiveKitFromReal(liveKitExecuteOver(execute), {
|
|
394
|
+
...(opts.root !== undefined ? { root: opts.root } : {}),
|
|
395
|
+
...(opts.occurredAt !== undefined ? { occurredAt: opts.occurredAt } : {}),
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
/** The perform adapter: one entry crosses to LiveKit, or settles with the reason it never could. A room's
|
|
399
|
+
* metadata update and an egress stop are checked against the vendor's ANSWER, not its status code. */
|
|
400
|
+
export async function performLiveKitAction(execute: RemoteExecute, action: TwinAction, _ctx: PerformContext): Promise<PushOutcome> {
|
|
401
|
+
const req = liveKitRequestForAction(action);
|
|
402
|
+
if (!req) return { externalId: action.subject.id, data: { performed: false, reason: `${action.operation ?? action.subject.type} is the twin's own record — nothing at LiveKit to write` } };
|
|
403
|
+
const ok = await pushLiveKitAction(action, liveKitExecuteOver(execute));
|
|
404
|
+
if (!ok) throw new Error(`livekit perform ${action.operation ?? action.subject.type} refused: the answer did not show the change`);
|
|
405
|
+
return { externalId: action.subject.id };
|
|
406
|
+
}
|