@norskvideo/ctl-product-template-schema 0.1.18 → 0.1.20

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/index.d.ts CHANGED
@@ -49,6 +49,12 @@ export declare const ProductTemplateSideloadSchema: z.ZodObject<{
49
49
  llmVenv: z.ZodOptional<z.ZodBoolean>;
50
50
  }, z.core.$strip>;
51
51
  export type ProductTemplateSideload = z.infer<typeof ProductTemplateSideloadSchema>;
52
+ export declare const ProductTemplateAcceleratorSchema: z.ZodEnum<{
53
+ none: "none";
54
+ nvidia: "nvidia";
55
+ quadra: "quadra";
56
+ }>;
57
+ export type ProductTemplateAccelerator = z.infer<typeof ProductTemplateAcceleratorSchema>;
52
58
  export declare const ProductTemplateRequirementsSchema: z.ZodObject<{
53
59
  capacity: z.ZodOptional<z.ZodObject<{
54
60
  mode: z.ZodEnum<{
@@ -77,6 +83,23 @@ export declare const ProductTemplateRequirementsSchema: z.ZodObject<{
77
83
  model: z.ZodOptional<z.ZodString>;
78
84
  }, z.core.$strip>>;
79
85
  }, z.core.$strip>>;
86
+ accelerator: z.ZodOptional<z.ZodDiscriminatedUnion<[z.ZodObject<{
87
+ mode: z.ZodLiteral<"fixed">;
88
+ value: z.ZodEnum<{
89
+ none: "none";
90
+ nvidia: "nvidia";
91
+ quadra: "quadra";
92
+ }>;
93
+ }, z.core.$strip>, z.ZodObject<{
94
+ mode: z.ZodLiteral<"default">;
95
+ value: z.ZodEnum<{
96
+ none: "none";
97
+ nvidia: "nvidia";
98
+ quadra: "quadra";
99
+ }>;
100
+ }, z.core.$strip>, z.ZodObject<{
101
+ mode: z.ZodLiteral<"required">;
102
+ }, z.core.$strip>], "mode">>;
80
103
  capabilities: z.ZodOptional<z.ZodObject<{
81
104
  mode: z.ZodEnum<{
82
105
  default: "default";
@@ -99,6 +122,17 @@ export declare const ProductTemplateRequirementsSchema: z.ZodObject<{
99
122
  }, z.core.$strip>>;
100
123
  }, z.core.$strip>;
101
124
  export type ProductTemplateRequirements = z.infer<typeof ProductTemplateRequirementsSchema>;
125
+ export declare const PRODUCT_TEMPLATE_MIGRATION_STRATEGIES: readonly ["overlap", "stopThenStart", "handover"];
126
+ export type MigrationStrategy = (typeof PRODUCT_TEMPLATE_MIGRATION_STRATEGIES)[number];
127
+ export declare const DEFAULT_MIGRATION_STRATEGY: MigrationStrategy;
128
+ export declare const ProductTemplateMigrationSchema: z.ZodObject<{
129
+ strategy: z.ZodEnum<{
130
+ overlap: "overlap";
131
+ stopThenStart: "stopThenStart";
132
+ handover: "handover";
133
+ }>;
134
+ }, z.core.$strip>;
135
+ export type ProductTemplateMigration = z.infer<typeof ProductTemplateMigrationSchema>;
102
136
  export declare const ProductTemplateManifestSchema: z.ZodObject<{
103
137
  productTemplateSchemaVersion: z.ZodLiteral<1>;
104
138
  productName: z.ZodString;
@@ -196,6 +230,23 @@ export declare const ProductTemplateManifestSchema: z.ZodObject<{
196
230
  model: z.ZodOptional<z.ZodString>;
197
231
  }, z.core.$strip>>;
198
232
  }, z.core.$strip>>;
233
+ accelerator: z.ZodOptional<z.ZodDiscriminatedUnion<[z.ZodObject<{
234
+ mode: z.ZodLiteral<"fixed">;
235
+ value: z.ZodEnum<{
236
+ none: "none";
237
+ nvidia: "nvidia";
238
+ quadra: "quadra";
239
+ }>;
240
+ }, z.core.$strip>, z.ZodObject<{
241
+ mode: z.ZodLiteral<"default">;
242
+ value: z.ZodEnum<{
243
+ none: "none";
244
+ nvidia: "nvidia";
245
+ quadra: "quadra";
246
+ }>;
247
+ }, z.core.$strip>, z.ZodObject<{
248
+ mode: z.ZodLiteral<"required">;
249
+ }, z.core.$strip>], "mode">>;
199
250
  capabilities: z.ZodOptional<z.ZodObject<{
200
251
  mode: z.ZodEnum<{
201
252
  default: "default";
@@ -227,8 +278,19 @@ export declare const ProductTemplateManifestSchema: z.ZodObject<{
227
278
  preferred: z.ZodNumber;
228
279
  label: z.ZodOptional<z.ZodString>;
229
280
  }, z.core.$strip>>>;
281
+ migration: z.ZodOptional<z.ZodObject<{
282
+ strategy: z.ZodEnum<{
283
+ overlap: "overlap";
284
+ stopThenStart: "stopThenStart";
285
+ handover: "handover";
286
+ }>;
287
+ }, z.core.$strip>>;
230
288
  }, z.core.$strip>;
231
289
  export type ProductTemplateManifest = z.infer<typeof ProductTemplateManifestSchema>;
290
+ /** The migration strategy a manifest promises — `stopThenStart` when it
291
+ * declares none, since that is the only strategy safe for a product that
292
+ * has said nothing about how it tolerates being moved. */
293
+ export declare function migrationStrategyOf(manifest: Pick<ProductTemplateManifest, "migration">): MigrationStrategy;
232
294
  export declare const STANDARD_ADVANCED_OVERRIDES: {
233
295
  readonly containerUser: {};
234
296
  readonly publicHost: {};
@@ -374,6 +436,23 @@ export declare const ProductTemplateMaterialsSchema: z.ZodObject<{
374
436
  model: z.ZodOptional<z.ZodString>;
375
437
  }, z.core.$strip>>;
376
438
  }, z.core.$strip>>;
439
+ accelerator: z.ZodOptional<z.ZodDiscriminatedUnion<[z.ZodObject<{
440
+ mode: z.ZodLiteral<"fixed">;
441
+ value: z.ZodEnum<{
442
+ none: "none";
443
+ nvidia: "nvidia";
444
+ quadra: "quadra";
445
+ }>;
446
+ }, z.core.$strip>, z.ZodObject<{
447
+ mode: z.ZodLiteral<"default">;
448
+ value: z.ZodEnum<{
449
+ none: "none";
450
+ nvidia: "nvidia";
451
+ quadra: "quadra";
452
+ }>;
453
+ }, z.core.$strip>, z.ZodObject<{
454
+ mode: z.ZodLiteral<"required">;
455
+ }, z.core.$strip>], "mode">>;
377
456
  capabilities: z.ZodOptional<z.ZodObject<{
378
457
  mode: z.ZodEnum<{
379
458
  default: "default";
@@ -405,6 +484,13 @@ export declare const ProductTemplateMaterialsSchema: z.ZodObject<{
405
484
  preferred: z.ZodNumber;
406
485
  label: z.ZodOptional<z.ZodString>;
407
486
  }, z.core.$strip>>>;
487
+ migration: z.ZodOptional<z.ZodObject<{
488
+ strategy: z.ZodEnum<{
489
+ overlap: "overlap";
490
+ stopThenStart: "stopThenStart";
491
+ handover: "handover";
492
+ }>;
493
+ }, z.core.$strip>>;
408
494
  }, z.core.$strip>;
409
495
  composeYaml: z.ZodString;
410
496
  parameters: z.ZodOptional<z.ZodObject<{
package/index.js CHANGED
@@ -223,6 +223,12 @@ const ProductTemplateGpuRequirementSchema = z.object({
223
223
  capacity: z.number().positive().optional().meta({ description: "Fractional GPU reference units" }),
224
224
  model: z.string().optional().meta({ description: "GPU model match, e.g. 'RTX 4090'" }),
225
225
  });
226
+ // The accelerator a product needs reserved on media. Deliberately the same
227
+ // three values as the runner's own hardware knob, restated here rather than
228
+ // imported: this package is upstream of ctl's contract, not downstream of it.
229
+ // `none` is a real value, not an absence — it is how a CPU-only product says
230
+ // "reserve nothing" as a mandate the operator cannot override.
231
+ export const ProductTemplateAcceleratorSchema = z.enum(["none", "nvidia", "quadra"]);
226
232
  export const ProductTemplateRequirementsSchema = z
227
233
  .object({
228
234
  capacity: requirementDimension(z.number().positive()).optional().meta({
@@ -234,6 +240,20 @@ export const ProductTemplateRequirementsSchema = z
234
240
  gpu: requirementDimension(ProductTemplateGpuRequirementSchema, (v) => v.capacity !== undefined || v.model !== undefined)
235
241
  .optional()
236
242
  .meta({ description: "Fractional GPU need (capacity and/or model)." }),
243
+ // Not requirementDimension(): a discriminated union states in the type what
244
+ // that helper can only enforce at parse time — fixed/default always carry a
245
+ // value, required never does. Downstream that removes the guard for a state
246
+ // the schema already forbids.
247
+ accelerator: z
248
+ .discriminatedUnion("mode", [
249
+ z.object({ mode: z.literal("fixed"), value: ProductTemplateAcceleratorSchema }),
250
+ z.object({ mode: z.literal("default"), value: ProductTemplateAcceleratorSchema }),
251
+ z.object({ mode: z.literal("required") }),
252
+ ])
253
+ .optional()
254
+ .meta({
255
+ description: "Which accelerator media needs reserved. `fixed` mandates it — the operator cannot override, and a host without it is refused rather than launched unreserved (`fixed`/`none` is how a CPU-only product forbids a reservation). `default` merely prefers it: the operator may change it, and a host without it falls back to no reservation. `required` means the product cannot know, so the operator must choose at launch. Absent = the runner reserves nothing unless the operator asks.",
256
+ }),
237
257
  capabilities: requirementDimension(z.array(ProductTemplateCapabilityRequirementSchema).min(1))
238
258
  .optional()
239
259
  .meta({ description: "Counted/exclusive hardware & software capabilities (nvidia-gpu, quadra, decklink, …)." }),
@@ -242,6 +262,20 @@ export const ProductTemplateRequirementsSchema = z
242
262
  .meta({ description: "Fixed host sockets the job binds (exclusive)." }),
243
263
  })
244
264
  .meta({ id: "ProductTemplateRequirements", outputId: "ProductTemplateRequirements" });
265
+ // How a running instance of the product may be moved between nodes
266
+ // (norsk-mgr/docs/internal/design/job-migration.md §6). The manifest
267
+ // declares the strongest promise the product keeps; a migrate request may
268
+ // only down-grade to a safer strategy. Absent means `stopThenStart`, the
269
+ // one that is safe for any product.
270
+ export const PRODUCT_TEMPLATE_MIGRATION_STRATEGIES = ["overlap", "stopThenStart", "handover"];
271
+ export const DEFAULT_MIGRATION_STRATEGY = "stopThenStart";
272
+ export const ProductTemplateMigrationSchema = z
273
+ .object({
274
+ strategy: z.enum(PRODUCT_TEMPLATE_MIGRATION_STRATEGIES).meta({
275
+ description: "`overlap`: the target is started before the source is stopped, so both instances may produce briefly — a promise only a product whose outputs tolerate a second writer can make. `stopThenStart` (the default when absent): the target is prepared but not started, the source is stopped, then the target is started — safe for any product, at the cost of a gap. `handover`: the job speaks the worker's migration socket and hands over at an exact position, so the output continues seamlessly — a promise the job's own code keeps.",
276
+ }),
277
+ })
278
+ .meta({ id: "ProductTemplateMigration", outputId: "ProductTemplateMigration" });
245
279
  export const ProductTemplateManifestSchema = z
246
280
  .object({
247
281
  productTemplateSchemaVersion: z.literal(1),
@@ -284,8 +318,17 @@ export const ProductTemplateManifestSchema = z
284
318
  allocatedPorts: z.array(ProductTemplateAllocatedPortSchema).optional().meta({
285
319
  description: "Host-port bindings whose value rides a product-template parameter. Opt-in per launch via the param value: absent = closed, 'auto' = allocate a free port from `preferred`, number = use it. When opened the port is published as a managed host binding and surfaced to the containers via compose interpolation. Alongside the per-param scalars the runner sets NORSK_ALLOCATED_PORTS: a JSON object keyed by param, each value {port, protocol, service?}, describing every opened port ('{}' when none opted in; unset when nothing is declared) — so a product needing the allocation as data maps one var instead of folding scalars into JSON via interpolation.",
286
320
  }),
321
+ migration: ProductTemplateMigrationSchema.optional().meta({
322
+ description: "How a running instance may be moved between nodes. `strategy` is the strongest promise the product keeps: `overlap` (target started, then source stopped — both may produce briefly), `stopThenStart` (the default when absent — target prepared but not started, source stopped, target started), or `handover` (the job speaks the worker's migration socket and hands over at an exact position). A migrate request may down-grade to a safer strategy but never up-grade past the declaration. Only the manager reads this; the worker never does.",
323
+ }),
287
324
  })
288
325
  .meta({ id: "ProductTemplateManifest", outputId: "ProductTemplateManifest" });
326
+ /** The migration strategy a manifest promises — `stopThenStart` when it
327
+ * declares none, since that is the only strategy safe for a product that
328
+ * has said nothing about how it tolerates being moved. */
329
+ export function migrationStrategyOf(manifest) {
330
+ return manifest.migration?.strategy ?? DEFAULT_MIGRATION_STRATEGY;
331
+ }
289
332
  // Container user and external URL are runner knobs every product wants
290
333
  // overridable per instance — the runner only holds their default values.
291
334
  // Spread this into a manifest's `advanced` (adding product-specific keys
@@ -0,0 +1,120 @@
1
+ import { z } from "zod";
2
+ export declare const JOB_SOCKET_DEFAULT_PORT = 6797;
3
+ export declare const JOB_SOCKET_MAX_FRAME_BYTES: number;
4
+ export declare const JOB_SOCKET_HELLO_TIMEOUT_MS = 5000;
5
+ /** The name a container reaches its own worker's host under. Injected as an
6
+ * `extra_hosts` mapping on every service so it resolves on native Linux too. */
7
+ export declare const JOB_SOCKET_CONTAINER_HOST = "host.docker.internal";
8
+ /** The NodeInventory capability a worker advertises while its listener is up,
9
+ * so placement can require it for a handover target. */
10
+ export declare const JOB_SOCKET_CAPABILITY = "norsk.io/job-socket";
11
+ /** The two reserved env names a job reads. Absent when the worker runs
12
+ * without a listener (`--no-job-socket`), which is what `connectMigration`
13
+ * turns into its no-op client. */
14
+ export declare const JOB_SOCKET_ENV: {
15
+ readonly url: "NORSK_WORKER_WS_URL";
16
+ readonly token: "NORSK_WORKER_TOKEN";
17
+ };
18
+ /** Close codes the worker uses. All in the 4000–4999 application range so a
19
+ * client can tell them from transport failures. */
20
+ export declare const JobSocketCloseCode: {
21
+ /** A frame that breaks the protocol: unparseable, wrong side, wrong phase. */
22
+ readonly protocolError: 4400;
23
+ /** No hello in time, a wrong token, or a path naming no instance. */
24
+ readonly unauthorized: 4401;
25
+ /** A newer connection for the same instance replaced this one. */
26
+ readonly replaced: 4409;
27
+ /** A frame over the size cap. */
28
+ readonly frameTooLarge: 4413;
29
+ };
30
+ export declare function jobSocketPath(jobId: string, role: string): string;
31
+ /** The inverse of {@link jobSocketPath}. Query strings are ignored. */
32
+ export declare function parseJobSocketPath(path: string): {
33
+ jobId: string;
34
+ role: string;
35
+ } | undefined;
36
+ export declare function jobSocketUrl(args: {
37
+ host: string;
38
+ port: number;
39
+ jobId: string;
40
+ role: string;
41
+ }): string;
42
+ declare const JsonPayload: z.ZodJSONSchema;
43
+ /** Any JSON value — what a job may put in `payload`. */
44
+ export type MigrationPayload = z.infer<typeof JsonPayload>;
45
+ export declare const JobToWorkerMessageSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
46
+ type: z.ZodLiteral<"hello">;
47
+ token: z.ZodString;
48
+ }, z.core.$strip>, z.ZodObject<{
49
+ type: z.ZodLiteral<"state">;
50
+ payload: z.ZodJSONSchema;
51
+ }, z.core.$strip>, z.ZodObject<{
52
+ type: z.ZodLiteral<"state-refused">;
53
+ reason: z.ZodString;
54
+ }, z.core.$strip>, z.ZodObject<{
55
+ type: z.ZodLiteral<"target-ready">;
56
+ payload: z.ZodJSONSchema;
57
+ }, z.core.$strip>, z.ZodObject<{
58
+ type: z.ZodLiteral<"source-stopped-output">;
59
+ payload: z.ZodJSONSchema;
60
+ }, z.core.$strip>, z.ZodObject<{
61
+ type: z.ZodLiteral<"target-applied">;
62
+ applied: z.ZodOptional<z.ZodArray<z.ZodString>>;
63
+ failed: z.ZodOptional<z.ZodArray<z.ZodObject<{
64
+ id: z.ZodString;
65
+ reason: z.ZodString;
66
+ }, z.core.$strip>>>;
67
+ skewMs: z.ZodOptional<z.ZodNumber>;
68
+ }, z.core.$strip>, z.ZodObject<{
69
+ type: z.ZodLiteral<"abort">;
70
+ reason: z.ZodString;
71
+ }, z.core.$strip>], "type">;
72
+ export type JobToWorkerMessage = z.infer<typeof JobToWorkerMessageSchema>;
73
+ export declare const WorkerToJobMessageSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
74
+ type: z.ZodLiteral<"hello-ack">;
75
+ migration: z.ZodOptional<z.ZodObject<{
76
+ id: z.ZodString;
77
+ side: z.ZodLiteral<"target">;
78
+ }, z.core.$strip>>;
79
+ }, z.core.$strip>, z.ZodObject<{
80
+ type: z.ZodLiteral<"get-state">;
81
+ migrationId: z.ZodString;
82
+ }, z.core.$strip>, z.ZodObject<{
83
+ type: z.ZodLiteral<"initial-state">;
84
+ migrationId: z.ZodString;
85
+ payload: z.ZodJSONSchema;
86
+ }, z.core.$strip>, z.ZodObject<{
87
+ type: z.ZodLiteral<"cutover-ready">;
88
+ migrationId: z.ZodString;
89
+ payload: z.ZodJSONSchema;
90
+ }, z.core.$strip>, z.ZodObject<{
91
+ type: z.ZodLiteral<"cutover-apply">;
92
+ migrationId: z.ZodString;
93
+ payload: z.ZodJSONSchema;
94
+ }, z.core.$strip>, z.ZodObject<{
95
+ type: z.ZodLiteral<"abort">;
96
+ migrationId: z.ZodString;
97
+ reason: z.ZodString;
98
+ }, z.core.$strip>], "type">;
99
+ export type WorkerToJobMessage = z.infer<typeof WorkerToJobMessageSchema>;
100
+ export type ParsedFrame<T> = {
101
+ ok: true;
102
+ message: T;
103
+ } | {
104
+ ok: false;
105
+ reason: string;
106
+ };
107
+ /** What the worker does with a text frame from a job. */
108
+ export declare function parseJobToWorkerFrame(text: string): ParsedFrame<JobToWorkerMessage>;
109
+ /** What the job does with a text frame from the worker. */
110
+ export declare function parseWorkerToJobFrame(text: string): ParsedFrame<WorkerToJobMessage>;
111
+ /** A socket payload as it rides the gRPC `bytes payload` field: the UTF-8 of
112
+ * its JSON. `undefined` (a job that sent nothing) is carried as `null` so the
113
+ * bytes are always valid JSON. */
114
+ export declare function encodeMigrationPayload(payload: unknown): Uint8Array;
115
+ /** The inverse of {@link encodeMigrationPayload}. Empty bytes — a gRPC
116
+ * message with the field unset — read as `null`; bytes that are not JSON
117
+ * come back as the raw string rather than being dropped, so a foreign
118
+ * Manager's opaque payload still reaches the job. */
119
+ export declare function decodeMigrationPayload(bytes: Uint8Array | undefined): MigrationPayload;
120
+ export {};
@@ -0,0 +1,157 @@
1
+ // The job socket — the contract between a worker and a job it may hand over
2
+ // (norsk-mgr/docs/internal/design/job-migration.md §5). One module, used by
3
+ // BOTH ends: the worker's listener validates inbound frames with these
4
+ // schemas, and the SDK client (`connectMigration`) validates what the worker
5
+ // sends it. Neither side re-declares a message shape.
6
+ //
7
+ // Transport: a WebSocket on the worker host, default port 6797, reached from
8
+ // a container as `host.docker.internal`. Path `/instances/<jobId>/<role>`
9
+ // where `role` is the instance KEY (unique per migration target), both
10
+ // url-encoded. JSON text frames, each ≤ 64 KiB. `payload` is any JSON, opaque
11
+ // to the worker; on the gRPC wire it becomes the UTF-8 bytes of its
12
+ // JSON.stringify and comes back the same way.
13
+ import { z } from "zod";
14
+ export const JOB_SOCKET_DEFAULT_PORT = 6797;
15
+ export const JOB_SOCKET_MAX_FRAME_BYTES = 64 * 1024;
16
+ export const JOB_SOCKET_HELLO_TIMEOUT_MS = 5_000;
17
+ /** The name a container reaches its own worker's host under. Injected as an
18
+ * `extra_hosts` mapping on every service so it resolves on native Linux too. */
19
+ export const JOB_SOCKET_CONTAINER_HOST = "host.docker.internal";
20
+ /** The NodeInventory capability a worker advertises while its listener is up,
21
+ * so placement can require it for a handover target. */
22
+ export const JOB_SOCKET_CAPABILITY = "norsk.io/job-socket";
23
+ /** The two reserved env names a job reads. Absent when the worker runs
24
+ * without a listener (`--no-job-socket`), which is what `connectMigration`
25
+ * turns into its no-op client. */
26
+ export const JOB_SOCKET_ENV = {
27
+ url: "NORSK_WORKER_WS_URL",
28
+ token: "NORSK_WORKER_TOKEN",
29
+ };
30
+ /** Close codes the worker uses. All in the 4000–4999 application range so a
31
+ * client can tell them from transport failures. */
32
+ export const JobSocketCloseCode = {
33
+ /** A frame that breaks the protocol: unparseable, wrong side, wrong phase. */
34
+ protocolError: 4400,
35
+ /** No hello in time, a wrong token, or a path naming no instance. */
36
+ unauthorized: 4401,
37
+ /** A newer connection for the same instance replaced this one. */
38
+ replaced: 4409,
39
+ /** A frame over the size cap. */
40
+ frameTooLarge: 4413,
41
+ };
42
+ export function jobSocketPath(jobId, role) {
43
+ return `/instances/${encodeURIComponent(jobId)}/${encodeURIComponent(role)}`;
44
+ }
45
+ /** The inverse of {@link jobSocketPath}. Query strings are ignored. */
46
+ export function parseJobSocketPath(path) {
47
+ const m = /^\/instances\/([^/?#]+)\/([^/?#]+)\/?(?:[?#].*)?$/.exec(path);
48
+ if (!m?.[1] || !m[2])
49
+ return undefined;
50
+ try {
51
+ return { jobId: decodeURIComponent(m[1]), role: decodeURIComponent(m[2]) };
52
+ }
53
+ catch {
54
+ return undefined;
55
+ }
56
+ }
57
+ export function jobSocketUrl(args) {
58
+ return `ws://${args.host}:${args.port}${jobSocketPath(args.jobId, args.role)}`;
59
+ }
60
+ // ── frames ───────────────────────────────────────────────────────────────
61
+ const JsonPayload = z.json();
62
+ export const JobToWorkerMessageSchema = z.discriminatedUnion("type", [
63
+ z.object({ type: z.literal("hello"), token: z.string() }),
64
+ // The propose, answered by the SOURCE. Exactly one of these two goes
65
+ // back for a `get-state`: `state` accepts (a `null` payload is a
66
+ // perfectly good acceptance — a job with nothing to hand over still
67
+ // takes part), `state-refused` declines and the whole migration ends
68
+ // with no target ever placed. The refusal is its own frame rather than
69
+ // a field inside `payload`, so the worker can act on it without ever
70
+ // opening the opaque blob.
71
+ z.object({ type: z.literal("state"), payload: JsonPayload }),
72
+ z.object({ type: z.literal("state-refused"), reason: z.string() }),
73
+ z.object({ type: z.literal("target-ready"), payload: JsonPayload }),
74
+ z.object({ type: z.literal("source-stopped-output"), payload: JsonPayload }),
75
+ // The handover has been applied — and, past the point of no return, WHAT
76
+ // was applied. A job with more than one migration participant applies them
77
+ // in declared bands and cannot roll back once the source has stopped, so a
78
+ // participant that fails to apply is REPORTED rather than fatal. The three
79
+ // fields are envelope fields, outside any participant's opaque document, so
80
+ // the worker and the Manager can relay them without opening a blob.
81
+ //
82
+ // All three are optional: a job built before there was more than one
83
+ // participant sends the bare frame. And note that `z.object` STRIPS unknown
84
+ // keys rather than rejecting them — without these fields declared here the
85
+ // worker would accept the frame and silently drop the summary, which is why
86
+ // this and Studio's own copy
87
+ // (`norsk-studio workspaces/core/src/migration/socket-contract.ts`) have to
88
+ // move together.
89
+ z.object({
90
+ type: z.literal("target-applied"),
91
+ applied: z.array(z.string()).optional(),
92
+ failed: z.array(z.object({ id: z.string(), reason: z.string() })).optional(),
93
+ skewMs: z.number().optional(),
94
+ }),
95
+ z.object({ type: z.literal("abort"), reason: z.string() }),
96
+ ]);
97
+ export const WorkerToJobMessageSchema = z.discriminatedUnion("type", [
98
+ z.object({
99
+ type: z.literal("hello-ack"),
100
+ /** Present only when the instance was launched as a migration target. */
101
+ migration: z.object({ id: z.string(), side: z.literal("target") }).optional(),
102
+ }),
103
+ // The propose, asked of the SOURCE before any target has been placed.
104
+ z.object({ type: z.literal("get-state"), migrationId: z.string() }),
105
+ // The source's state, handed to the TARGET. Always the first frame
106
+ // after `hello-ack` when the instance was started with one, so a
107
+ // target's `ready` can depend on having applied it.
108
+ z.object({ type: z.literal("initial-state"), migrationId: z.string(), payload: JsonPayload }),
109
+ z.object({ type: z.literal("cutover-ready"), migrationId: z.string(), payload: JsonPayload }),
110
+ z.object({ type: z.literal("cutover-apply"), migrationId: z.string(), payload: JsonPayload }),
111
+ z.object({ type: z.literal("abort"), migrationId: z.string(), reason: z.string() }),
112
+ ]);
113
+ function parseFrame(schema, text) {
114
+ let raw;
115
+ try {
116
+ raw = JSON.parse(text);
117
+ }
118
+ catch {
119
+ return { ok: false, reason: "frame is not valid JSON" };
120
+ }
121
+ const parsed = schema.safeParse(raw);
122
+ if (!parsed.success)
123
+ return { ok: false, reason: `invalid frame: ${z.prettifyError(parsed.error)}` };
124
+ return { ok: true, message: parsed.data };
125
+ }
126
+ /** What the worker does with a text frame from a job. */
127
+ export function parseJobToWorkerFrame(text) {
128
+ return parseFrame(JobToWorkerMessageSchema, text);
129
+ }
130
+ /** What the job does with a text frame from the worker. */
131
+ export function parseWorkerToJobFrame(text) {
132
+ return parseFrame(WorkerToJobMessageSchema, text);
133
+ }
134
+ // ── payload ↔ gRPC bytes ─────────────────────────────────────────────────
135
+ const utf8Encoder = new TextEncoder();
136
+ const utf8Decoder = new TextDecoder();
137
+ /** A socket payload as it rides the gRPC `bytes payload` field: the UTF-8 of
138
+ * its JSON. `undefined` (a job that sent nothing) is carried as `null` so the
139
+ * bytes are always valid JSON. */
140
+ export function encodeMigrationPayload(payload) {
141
+ return utf8Encoder.encode(JSON.stringify(payload === undefined ? null : payload));
142
+ }
143
+ /** The inverse of {@link encodeMigrationPayload}. Empty bytes — a gRPC
144
+ * message with the field unset — read as `null`; bytes that are not JSON
145
+ * come back as the raw string rather than being dropped, so a foreign
146
+ * Manager's opaque payload still reaches the job. */
147
+ export function decodeMigrationPayload(bytes) {
148
+ if (!bytes || bytes.byteLength === 0)
149
+ return null;
150
+ const text = utf8Decoder.decode(bytes);
151
+ try {
152
+ return JSON.parse(text);
153
+ }
154
+ catch {
155
+ return text;
156
+ }
157
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@norskvideo/ctl-product-template-schema",
3
- "version": "0.1.18",
3
+ "version": "0.1.20",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {
@@ -26,6 +26,10 @@
26
26
  "./workdir-path": {
27
27
  "types": "./workdir-path.d.ts",
28
28
  "default": "./workdir-path.js"
29
+ },
30
+ "./migration-socket": {
31
+ "types": "./migration-socket.d.ts",
32
+ "default": "./migration-socket.js"
29
33
  }
30
34
  },
31
35
  "main": "./index.js",