@norskvideo/norsk-auto-manager 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/src/clock.ts ADDED
@@ -0,0 +1,29 @@
1
+ // Clock abstraction so AutoManager can be tested deterministically.
2
+ // Production code uses RealClock; tests inject a MockClock that exposes
3
+ // advanceTime() to drive timers manually.
4
+
5
+ export interface CancellableTimer {
6
+ cancel(): void;
7
+ }
8
+
9
+ export interface Clock {
10
+ now(): Date;
11
+ setTimeout(fn: () => void, ms: number): CancellableTimer;
12
+ setInterval(fn: () => void, ms: number): CancellableTimer;
13
+ }
14
+
15
+ export class RealClock implements Clock {
16
+ public now(): Date {
17
+ return new Date();
18
+ }
19
+
20
+ public setTimeout(fn: () => void, ms: number): CancellableTimer {
21
+ const handle = setTimeout(fn, ms);
22
+ return { cancel: () => clearTimeout(handle) };
23
+ }
24
+
25
+ public setInterval(fn: () => void, ms: number): CancellableTimer {
26
+ const handle = setInterval(fn, ms);
27
+ return { cancel: () => clearInterval(handle) };
28
+ }
29
+ }
@@ -0,0 +1,458 @@
1
+ // PB ↔ AutoManager-typed conversions. The SDK passes raw protobuf
2
+ // payloads through for the new AutoManager events; AutoManager runs
3
+ // these converters at the boundary so the rest of the codebase works
4
+ // against the cleaner typed shapes from `./types`.
5
+ //
6
+ // Converters are deliberately permissive about defaults: an empty map
7
+ // from PB becomes `undefined` to match the optional-field convention
8
+ // used in the typed model, an absent ID wrapper stays the empty string.
9
+
10
+ import { create } from "@bufbuild/protobuf";
11
+ import { timestampDate, timestampFromDate } from "@bufbuild/protobuf/wkt";
12
+ import * as ManagerPB from "@norskvideo/norsk-api/lib/manager_pb";
13
+ import * as CommonPB from "@norskvideo/norsk-api/lib/shared/common_pb";
14
+ import {
15
+ BundleIdSchema,
16
+ CapabilityRequirementSchema,
17
+ CapabilitySchema,
18
+ OptionalIntSchema,
19
+ OptionalStringSchema,
20
+ } from "@norskvideo/norsk-api/lib/shared/common_pb";
21
+ import { fromProductTemplateRef, toProductTemplateRef } from "@norskvideo/norsk-manager-sdk";
22
+ import { JobSchema as PbJobSchema } from "@norskvideo/norsk-api/lib/manager_pb";
23
+ import {
24
+ Bundle,
25
+ BundleId,
26
+ BundleJobSpec,
27
+ BundleState,
28
+ Capability,
29
+ CapabilityRequirement,
30
+ AntiAffinity,
31
+ GpuResource,
32
+ IntraReplicaRules,
33
+ JobRequirements,
34
+ LaunchSpec,
35
+ NodeAssignedJob,
36
+ NodeInventory,
37
+ PriorityBand,
38
+ ResiliencePolicy,
39
+ } from "./types";
40
+
41
+ // ----- enums -----------------------------------------------------------
42
+
43
+ export function fromPbPriorityBand(pb: ManagerPB.PriorityBand): PriorityBand {
44
+ switch (pb) {
45
+ case ManagerPB.PriorityBand.GOLD:
46
+ return "gold";
47
+ case ManagerPB.PriorityBand.SILVER:
48
+ return "silver";
49
+ case ManagerPB.PriorityBand.BRONZE:
50
+ return "bronze";
51
+ }
52
+ }
53
+
54
+ export function fromPbPlacementRule(
55
+ pb: ManagerPB.PlacementRule
56
+ ): "off" | "soft" | "hard" {
57
+ switch (pb) {
58
+ case ManagerPB.PlacementRule.OFF:
59
+ return "off";
60
+ case ManagerPB.PlacementRule.SOFT:
61
+ return "soft";
62
+ case ManagerPB.PlacementRule.HARD:
63
+ return "hard";
64
+ }
65
+ }
66
+
67
+ // ----- capabilities ----------------------------------------------------
68
+
69
+ export function fromPbCapability(pb: CommonPB.Capability): Capability {
70
+ return {
71
+ name: pb.name,
72
+ count: pb.count,
73
+ attributes: emptyMapToUndefined(pb.attributes),
74
+ };
75
+ }
76
+
77
+ export function fromPbCapabilityRequirement(
78
+ pb: CommonPB.CapabilityRequirement
79
+ ): CapabilityRequirement {
80
+ return {
81
+ name: pb.name,
82
+ count: pb.count,
83
+ attributeMatches: emptyMapToUndefined(pb.attributeMatches),
84
+ };
85
+ }
86
+
87
+ // ----- GPU -------------------------------------------------------------
88
+
89
+ export function fromPbGpuResource(pb: CommonPB.GpuResource): GpuResource {
90
+ return {
91
+ index: pb.index,
92
+ model: pb.model?.value,
93
+ totalCapacity: pb.totalCapacity,
94
+ reservedCapacity: pb.reservedCapacity,
95
+ exclusivelyReserved: pb.exclusivelyReserved,
96
+ };
97
+ }
98
+
99
+ // ----- requirements ----------------------------------------------------
100
+
101
+ export function fromPbJobRequirements(
102
+ pb: ManagerPB.JobRequirements
103
+ ): JobRequirements {
104
+ return {
105
+ requiredCapacity: pb.requiredCapacity,
106
+ requiredCores: pb.requiredCores?.value,
107
+ requiredGpuCapacity: pb.requiredGpuCapacity?.value,
108
+ requiredGpuModel: pb.requiredGpuModel?.value,
109
+ requiredCapabilities: pb.requiredCapabilities.map(fromPbCapabilityRequirement),
110
+ priorityBand: fromPbPriorityBand(pb.priorityBand),
111
+ };
112
+ }
113
+
114
+ // ----- bundle ----------------------------------------------------------
115
+
116
+ export function fromPbBundle(pb: ManagerPB.Bundle): Bundle {
117
+ return {
118
+ bundleId: bundleIdString(pb.bundleId),
119
+ pool: pb.pool,
120
+ jobs: pb.jobs.map(fromPbBundleJobSpec),
121
+ intraReplicaPlacement: pb.intraReplicaPlacement
122
+ ? fromPbIntraReplicaRules(pb.intraReplicaPlacement)
123
+ : undefined,
124
+ resiliencePolicy: pb.resiliencePolicy ? fromPbResiliencePolicy(pb.resiliencePolicy) : undefined,
125
+ state: fromPbBundleState(pb.state),
126
+ };
127
+ }
128
+
129
+ export function fromPbBundleState(pb: ManagerPB.BundleState): BundleState {
130
+ switch (pb) {
131
+ case ManagerPB.BundleState.DELETING:
132
+ return "deleting";
133
+ default:
134
+ return "live";
135
+ }
136
+ }
137
+
138
+ export function toPbBundleState(s: BundleState): ManagerPB.BundleState {
139
+ switch (s) {
140
+ case "deleting":
141
+ return ManagerPB.BundleState.DELETING;
142
+ case "live":
143
+ return ManagerPB.BundleState.LIVE;
144
+ }
145
+ }
146
+
147
+ export function fromPbBundleJobSpec(
148
+ pb: ManagerPB.BundleJobSpec
149
+ ): BundleJobSpec {
150
+ return {
151
+ jobName: pb.jobName,
152
+ requirements: pb.requirements
153
+ ? fromPbJobRequirements(pb.requirements)
154
+ : ({} as JobRequirements),
155
+ coLocateWith: emptyArrayToUndefined(pb.coLocateWith),
156
+ separateFrom: emptyArrayToUndefined(pb.separateFrom),
157
+ launch: pb.job ? fromPbLaunchSpec(pb.job) : undefined,
158
+ };
159
+ }
160
+
161
+ /**
162
+ * Reconstruct the typed `LaunchSpec` from the wire-form `Job` carried
163
+ * on a `BundleJobSpec`. If the carried Job has `productTemplate` set, this
164
+ * is a product-template launch; otherwise it's the legacy services/volumes
165
+ * path. The legacy variant passes the Job through as-is (cast to the
166
+ * SDK's typed shape — same trick the v0 code used).
167
+ */
168
+ /** Tags starting with this prefix are SDK-managed (well-known reserved
169
+ * names) — `__stopDateTime` carries the auto-stop instant for example.
170
+ * Callers can't set them via `LaunchSpec.tags` (silently stripped on
171
+ * outbound) and they're surfaced as typed fields rather than raw tags
172
+ * on inbound. */
173
+ export const RESERVED_TAG_PREFIX = "__";
174
+ /** Well-known reserved tag carrying the operator's submitted auto-stop
175
+ * instant. The daemon has no first-class concept of stop time, so we
176
+ * ride along on its tag-preservation behaviour. */
177
+ export const STOP_DATE_TIME_TAG = "__stopDateTime";
178
+
179
+ function buildJobTags(
180
+ stopDateTime: Date | undefined,
181
+ userTags: Record<string, string> | undefined,
182
+ ): Record<string, string> {
183
+ const out: Record<string, string> = {};
184
+ if (userTags) {
185
+ for (const [k, v] of Object.entries(userTags)) {
186
+ if (k.startsWith(RESERVED_TAG_PREFIX)) continue; // silently strip
187
+ out[k] = v;
188
+ }
189
+ }
190
+ if (stopDateTime) out[STOP_DATE_TIME_TAG] = stopDateTime.toISOString();
191
+ return out;
192
+ }
193
+
194
+ function splitJobTags(tags: Record<string, string>): {
195
+ stopDateTime?: Date;
196
+ userTags?: Record<string, string>;
197
+ } {
198
+ const userTags: Record<string, string> = {};
199
+ let stopDateTime: Date | undefined;
200
+ for (const [k, v] of Object.entries(tags)) {
201
+ if (k === STOP_DATE_TIME_TAG) {
202
+ const d = new Date(v);
203
+ if (!Number.isNaN(d.getTime())) stopDateTime = d;
204
+ continue;
205
+ }
206
+ if (k.startsWith(RESERVED_TAG_PREFIX)) continue; // unknown reserved — hide
207
+ userTags[k] = v;
208
+ }
209
+ return {
210
+ ...(stopDateTime ? { stopDateTime } : {}),
211
+ ...(Object.keys(userTags).length > 0 ? { userTags } : {}),
212
+ };
213
+ }
214
+
215
+ export function fromPbLaunchSpec(pbJob: ManagerPB.Job): LaunchSpec {
216
+ if (pbJob.productTemplate) {
217
+ const { stopDateTime, userTags } = splitJobTags(pbJob.tags ?? {});
218
+ return {
219
+ kind: "productTemplate",
220
+ ref: fromProductTemplateRef(pbJob.productTemplate),
221
+ parameters:
222
+ Object.keys(pbJob.productTemplateParameters).length > 0
223
+ ? pbJob.productTemplateParameters
224
+ : undefined,
225
+ // Preserve the operator's submitted schedule so consumers can
226
+ // surface it even before a JobPending event has been streamed
227
+ // (e.g. bundles in mgr's bundleMap whose materialised jobs
228
+ // haven't arrived yet — calendar view depends on this).
229
+ ...(pbJob.startDateTime ? { startDateTime: timestampDate(pbJob.startDateTime) } : {}),
230
+ ...(stopDateTime ? { stopDateTime } : {}),
231
+ ...(userTags ? { tags: userTags } : {}),
232
+ };
233
+ }
234
+ return { kind: "legacy", job: pbJob as never };
235
+ }
236
+
237
+ export function fromPbIntraReplicaRules(
238
+ pb: ManagerPB.IntraReplicaRules
239
+ ): IntraReplicaRules {
240
+ return {
241
+ sameNodeDefault: pb.sameNodeDefault,
242
+ sameAz: fromPbPlacementRule(pb.sameAz),
243
+ };
244
+ }
245
+
246
+ export function fromPbResiliencePolicy(pb: ManagerPB.ResiliencePolicy): ResiliencePolicy {
247
+ return {
248
+ ...(pb.pool.length > 0 ? { pool: pb.pool } : {}),
249
+ sameNode: fromPbAntiAffinity(pb.sameNode),
250
+ sameAz: fromPbAntiAffinity(pb.sameAz),
251
+ sameCloud: fromPbAntiAffinity(pb.sameCloud),
252
+ };
253
+ }
254
+
255
+ function fromPbAntiAffinity(pb: ManagerPB.AntiAffinity): AntiAffinity {
256
+ return pb === ManagerPB.AntiAffinity.FORBID ? "forbid" : "allow";
257
+ }
258
+
259
+ function toPbAntiAffinity(a: AntiAffinity): ManagerPB.AntiAffinity {
260
+ return a === "forbid" ? ManagerPB.AntiAffinity.FORBID : ManagerPB.AntiAffinity.ALLOW;
261
+ }
262
+
263
+ // ----- inventory -------------------------------------------------------
264
+
265
+ export function fromPbNodeInventory(
266
+ pb: CommonPB.NodeInventory
267
+ ): NodeInventory {
268
+ return {
269
+ totalCapacity: pb.totalCapacity,
270
+ utilizationCap: pb.utilizationCap,
271
+ totalCores: pb.totalCores,
272
+ gpus: pb.gpus.map(fromPbGpuResource),
273
+ capabilities: pb.capabilities.map(fromPbCapability),
274
+ reservedCapacity: pb.reservedCapacity,
275
+ reservedCores: pb.reservedCores,
276
+ reservedCapabilities: pb.reservedCapabilities.map(fromPbCapability),
277
+ reachable: pb.reachable,
278
+ lastSeen: pb.lastSeen
279
+ ? new Date(Number(pb.lastSeen.seconds) * 1000 + (pb.lastSeen.nanos ?? 0) / 1_000_000)
280
+ : new Date(0),
281
+ cordoned: pb.cordoned,
282
+ assignedJobs: pb.assignedJobs.map(fromPbNodeAssignedJob),
283
+ };
284
+ }
285
+
286
+ export function fromPbNodeAssignedJob(
287
+ pb: CommonPB.NodeAssignedJob
288
+ ): NodeAssignedJob {
289
+ return {
290
+ jobId: jobIdString(pb.jobId),
291
+ coreSet: pb.coreSet.length > 0 ? [...pb.coreSet] : undefined,
292
+ gpuIndex: pb.gpuIndex?.value,
293
+ };
294
+ }
295
+
296
+ // ----- helpers ---------------------------------------------------------
297
+
298
+ function bundleIdString(pb?: { bundleId: string }): BundleId {
299
+ return pb?.bundleId ?? "";
300
+ }
301
+
302
+ function jobIdString(pb?: { jobId: string }): string {
303
+ return pb?.jobId ?? "";
304
+ }
305
+
306
+ function emptyMapToUndefined<V>(
307
+ m: Record<string, V>
308
+ ): Record<string, V> | undefined {
309
+ return Object.keys(m).length > 0 ? { ...m } : undefined;
310
+ }
311
+
312
+ function emptyArrayToUndefined<V>(arr: V[]): V[] | undefined {
313
+ return arr.length > 0 ? [...arr] : undefined;
314
+ }
315
+
316
+ // =============================================================================
317
+ // AutoManager-typed → PB conversions. Used when AutoManager submits a Bundle
318
+ // (or future request types) over the wire.
319
+ // =============================================================================
320
+
321
+ import {
322
+ BundleSchema,
323
+ BundleJobSpecSchema,
324
+ IntraReplicaRulesSchema,
325
+ ResiliencePolicySchema,
326
+ JobRequirementsSchema,
327
+ PlacementRule,
328
+ PriorityBand as PbPriorityBand,
329
+ } from "@norskvideo/norsk-api/lib/manager_pb";
330
+ import { OptionalFloatSchema } from "@norskvideo/norsk-api/lib/shared/common_pb";
331
+
332
+ export function toPbPriorityBand(b: PriorityBand): PbPriorityBand {
333
+ switch (b) {
334
+ case "gold":
335
+ return PbPriorityBand.GOLD;
336
+ case "silver":
337
+ return PbPriorityBand.SILVER;
338
+ case "bronze":
339
+ return PbPriorityBand.BRONZE;
340
+ }
341
+ }
342
+
343
+ export function toPbPlacementRule(r: "off" | "soft" | "hard"): PlacementRule {
344
+ switch (r) {
345
+ case "off":
346
+ return PlacementRule.OFF;
347
+ case "soft":
348
+ return PlacementRule.SOFT;
349
+ case "hard":
350
+ return PlacementRule.HARD;
351
+ }
352
+ }
353
+
354
+ export function toPbCapability(c: Capability): CommonPB.Capability {
355
+ return create(CapabilitySchema, {
356
+ name: c.name,
357
+ count: c.count,
358
+ attributes: c.attributes ?? {},
359
+ });
360
+ }
361
+
362
+ export function toPbCapabilityRequirement(
363
+ r: CapabilityRequirement
364
+ ): CommonPB.CapabilityRequirement {
365
+ return create(CapabilityRequirementSchema, {
366
+ name: r.name,
367
+ count: r.count,
368
+ attributeMatches: r.attributeMatches ?? {},
369
+ });
370
+ }
371
+
372
+ export function toPbJobRequirements(
373
+ r: JobRequirements
374
+ ): ManagerPB.JobRequirements {
375
+ return create(JobRequirementsSchema, {
376
+ requiredCapacity: r.requiredCapacity,
377
+ requiredCores:
378
+ r.requiredCores !== undefined
379
+ ? create(OptionalIntSchema, { value: r.requiredCores })
380
+ : undefined,
381
+ requiredGpuCapacity:
382
+ r.requiredGpuCapacity !== undefined
383
+ ? create(OptionalFloatSchema, { value: r.requiredGpuCapacity })
384
+ : undefined,
385
+ requiredGpuModel:
386
+ r.requiredGpuModel !== undefined
387
+ ? create(OptionalStringSchema, { value: r.requiredGpuModel })
388
+ : undefined,
389
+ requiredCapabilities: r.requiredCapabilities.map(toPbCapabilityRequirement),
390
+ priorityBand: toPbPriorityBand(r.priorityBand),
391
+ });
392
+ }
393
+
394
+ export function toPbBundleJobSpec(j: BundleJobSpec): ManagerPB.BundleJobSpec {
395
+ return create(BundleJobSpecSchema, {
396
+ jobName: j.jobName,
397
+ requirements: toPbJobRequirements(j.requirements),
398
+ coLocateWith: j.coLocateWith ?? [],
399
+ separateFrom: j.separateFrom ?? [],
400
+ job: j.launch ? toPbLaunchSpec(j.launch) : undefined,
401
+ });
402
+ }
403
+
404
+ /**
405
+ * Project a typed `LaunchSpec` into the wire-form `Job` slot on
406
+ * `BundleJobSpec`. Manager stamps this template per replica during
407
+ * bundle expansion (setting jobId, tags, state, currentHash). For the
408
+ * product-template variant only `productTemplate`, `productTemplateParameters`, and
409
+ * `startDateTime` are meaningful — the rest stays at proto defaults.
410
+ * The worker reads `productTemplate` + `productTemplateParameters` and ignores
411
+ * services / volumes / writeFiles.
412
+ *
413
+ * `startDateTime` defaults to `new Date()` (immediate) when callers
414
+ * omit it; Manager-daemon's `apiToJobTemplate` rejects unset values.
415
+ */
416
+ export function toPbLaunchSpec(launch: LaunchSpec): ManagerPB.Job {
417
+ if (launch.kind === "productTemplate") {
418
+ const tags = buildJobTags(launch.stopDateTime, launch.tags);
419
+ return create(PbJobSchema, {
420
+ startDateTime: timestampFromDate(launch.startDateTime ?? new Date()),
421
+ productTemplate: toProductTemplateRef(launch.ref),
422
+ productTemplateParameters: launch.parameters ?? {},
423
+ ...(Object.keys(tags).length > 0 ? { tags } : {}),
424
+ });
425
+ }
426
+ return launch.job as never;
427
+ }
428
+
429
+ export function toPbIntraReplicaRules(
430
+ r: IntraReplicaRules
431
+ ): ManagerPB.IntraReplicaRules {
432
+ return create(IntraReplicaRulesSchema, {
433
+ sameNodeDefault: r.sameNodeDefault,
434
+ sameAz: toPbPlacementRule(r.sameAz),
435
+ });
436
+ }
437
+
438
+ export function toPbResiliencePolicy(r: ResiliencePolicy): ManagerPB.ResiliencePolicy {
439
+ return create(ResiliencePolicySchema, {
440
+ pool: r.pool ?? "",
441
+ sameNode: toPbAntiAffinity(r.sameNode),
442
+ sameAz: toPbAntiAffinity(r.sameAz),
443
+ sameCloud: toPbAntiAffinity(r.sameCloud),
444
+ });
445
+ }
446
+
447
+ export function toPbBundle(b: Bundle): ManagerPB.Bundle {
448
+ return create(BundleSchema, {
449
+ bundleId: create(BundleIdSchema, { bundleId: b.bundleId }),
450
+ pool: b.pool,
451
+ jobs: b.jobs.map(toPbBundleJobSpec),
452
+ intraReplicaPlacement: b.intraReplicaPlacement
453
+ ? toPbIntraReplicaRules(b.intraReplicaPlacement)
454
+ : undefined,
455
+ resiliencePolicy: b.resiliencePolicy ? toPbResiliencePolicy(b.resiliencePolicy) : undefined,
456
+ state: toPbBundleState(b.state ?? "live"),
457
+ });
458
+ }
package/src/index.ts ADDED
@@ -0,0 +1,27 @@
1
+ // Public exports for @norskvideo/norsk-auto-manager.
2
+ export * from "./automanager";
3
+ export * from "./types";
4
+ export { Clock, RealClock, CancellableTimer } from "./clock";
5
+ export { KnownCapabilities, KnownCapabilityName } from "./knownCapabilities";
6
+ export {
7
+ validateBundle,
8
+ ValidationError,
9
+ ValidationErrorCode,
10
+ ValidationResult,
11
+ PoolDescriptor,
12
+ NodeProfile,
13
+ } from "./validation";
14
+ export * from "./placement";
15
+ export {
16
+ freeCapability,
17
+ freeCapacity,
18
+ freeCores,
19
+ findCapability,
20
+ usedCapacityFraction,
21
+ } from "./inventory";
22
+ export {
23
+ AutoSettingsValidationError,
24
+ SettingsValidationError,
25
+ SettingsValidationErrorCode,
26
+ validateAutoSettings,
27
+ } from "./settingsValidation";
@@ -0,0 +1,64 @@
1
+ // Derivation helpers over NodeInventory snapshots. These are pure
2
+ // projections — no state of their own — so the placement algorithm can
3
+ // remain a pure function over what the worker advertised.
4
+
5
+ import { Capability, NodeInventory } from "./types";
6
+
7
+ /**
8
+ * Free capacity available for placement. `(totalCapacity * utilizationCap)
9
+ * - reservedCapacity`. Can be negative if the node is over-allocated;
10
+ * placement should treat negative values as "no room".
11
+ *
12
+ * @public
13
+ */
14
+ export function freeCapacity(inv: NodeInventory): number {
15
+ return inv.totalCapacity * inv.utilizationCap - inv.reservedCapacity;
16
+ }
17
+
18
+ /** @public */
19
+ export function freeCores(inv: NodeInventory): number {
20
+ return inv.totalCores - inv.reservedCores;
21
+ }
22
+
23
+ /**
24
+ * Fraction (0-1) of the node's total capacity currently reserved.
25
+ * Used by the bin-pack scoring strategy.
26
+ *
27
+ * @public
28
+ */
29
+ export function usedCapacityFraction(inv: NodeInventory): number {
30
+ if (inv.totalCapacity <= 0) return 0;
31
+ return inv.reservedCapacity / inv.totalCapacity;
32
+ }
33
+
34
+ /**
35
+ * Free count of a specific capability on this node. Looks up the total
36
+ * by name and subtracts whatever's currently reserved. Returns 0 if the
37
+ * capability isn't advertised.
38
+ *
39
+ * @public
40
+ */
41
+ export function freeCapability(inv: NodeInventory, name: string): number {
42
+ const total = capabilityCount(inv.capabilities, name);
43
+ const used = capabilityCount(inv.reservedCapabilities, name);
44
+ return Math.max(0, total - used);
45
+ }
46
+
47
+ /**
48
+ * Find the advertised capability with the given name. Returns undefined
49
+ * if not present. Useful when callers also need attribute info.
50
+ *
51
+ * @public
52
+ */
53
+ export function findCapability(
54
+ inv: NodeInventory,
55
+ name: string
56
+ ): Capability | undefined {
57
+ return inv.capabilities.find((c) => c.name === name);
58
+ }
59
+
60
+ function capabilityCount(caps: Capability[], name: string): number {
61
+ let total = 0;
62
+ for (const c of caps) if (c.name === name) total += c.count;
63
+ return total;
64
+ }
@@ -0,0 +1,23 @@
1
+ // Standard capability names shipped with Norsk. Use these constants
2
+ // rather than literal strings — typos in user code surface at compile
3
+ // time rather than as silent placement misses.
4
+
5
+ /** @public */
6
+ export const KnownCapabilities = {
7
+ /** Whole NVIDIA GPU, exclusive. For fractional, use requiredGpuCapacity. */
8
+ NvidiaGpu: "norsk.io/nvidia-gpu",
9
+ /** Intel Quick Sync Video — fixed-function media accelerator. */
10
+ IntelQsv: "norsk.io/intel-qsv",
11
+ /** Netint Quadra T1U accelerator. */
12
+ Quadra: "norsk.io/quadra",
13
+ /** Blackmagic DeckLink capture/playout card. */
14
+ Decklink: "norsk.io/decklink",
15
+ /** SMPTE ST 2110 capable NIC. */
16
+ St2110Nic: "norsk.io/st2110-nic",
17
+ /** Media eXchange Layer slot, provided by an MXL bridge NodeService. */
18
+ Mxl: "norsk.io/mxl",
19
+ } as const;
20
+
21
+ /** @public */
22
+ export type KnownCapabilityName =
23
+ (typeof KnownCapabilities)[keyof typeof KnownCapabilities];