@norskvideo/ctl-test-harness 0.1.19 → 0.1.21

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.
@@ -0,0 +1,26 @@
1
+ export type FetchLike = (url: string, init?: RequestInit) => Promise<Response>;
2
+ export declare const DEFAULT_COMPOSE_NODE = "video_compose";
3
+ /** The highest mediaIn across every node of that name; 0 when none. */
4
+ export declare function maxNodeMediaIn(summary: unknown, node: string): number;
5
+ /** One sample: the highest mediaIn of `node` across every root workflow under
6
+ * `base` (`.../visualiser`). An unreachable visualiser reads as 0 — the
7
+ * caller's second sample, not a throw, decides. */
8
+ export declare function composeFrames(opts: {
9
+ fetch: FetchLike;
10
+ base: string;
11
+ node?: string;
12
+ }): Promise<number>;
13
+ export interface ComposeAdvancingResult {
14
+ advancing: boolean;
15
+ before: number;
16
+ after: number;
17
+ node: string;
18
+ }
19
+ /** Two samples `settleMs` apart: advancing when the second is above the first
20
+ * and above zero — the fallback card and a stalled feed both read flat. */
21
+ export declare function composeAdvancing(opts: {
22
+ fetch: FetchLike;
23
+ base: string;
24
+ node?: string;
25
+ settleMs?: number;
26
+ }): Promise<ComposeAdvancingResult>;
@@ -0,0 +1,61 @@
1
+ // Is a programme really reaching the compositor? The daemon reports a source
2
+ // "running" as soon as its ffmpeg sidecar is up, even if the SRT never
3
+ // connected or the frames stalled; the only honest signal is the compose
4
+ // node's own mediaIn climbing between two samples of the engine's visualiser
5
+ // (`/visualiser/workflow` -> rootWorkflows[].wfid -> `/workflow/<wfid>/summary`
6
+ // -> nodes[].mediaIn). Playout's scripts/ensure-sources.sh:52-64 as TypeScript
7
+ // (05-demo s7 step 2), pure over an injected fetch so it is unit-tested and
8
+ // reachable through whichever base the caller has: the daemon proxy's
9
+ // `/instance/<id>/visualiser`, or `<id>-media-1:6791/visualiser` on norsk-net.
10
+ export const DEFAULT_COMPOSE_NODE = "video_compose";
11
+ const DEFAULT_SETTLE_MS = 6000;
12
+ /** The highest mediaIn across every node of that name; 0 when none. */
13
+ export function maxNodeMediaIn(summary, node) {
14
+ const nodes = summary?.nodes;
15
+ if (!Array.isArray(nodes))
16
+ return 0;
17
+ let max = 0;
18
+ for (const n of nodes) {
19
+ if (n?.name !== node)
20
+ continue;
21
+ const v = Number(n.mediaIn);
22
+ if (Number.isFinite(v) && v > max)
23
+ max = v;
24
+ }
25
+ return max;
26
+ }
27
+ async function getJson(fetch, url) {
28
+ const r = await fetch(url, { signal: AbortSignal.timeout(5000) });
29
+ if (!r.ok)
30
+ throw new Error(`GET ${url} -> ${r.status}`);
31
+ return r.json();
32
+ }
33
+ /** One sample: the highest mediaIn of `node` across every root workflow under
34
+ * `base` (`.../visualiser`). An unreachable visualiser reads as 0 — the
35
+ * caller's second sample, not a throw, decides. */
36
+ export async function composeFrames(opts) {
37
+ const node = opts.node ?? DEFAULT_COMPOSE_NODE;
38
+ try {
39
+ const wf = (await getJson(opts.fetch, `${opts.base}/workflow`));
40
+ let max = 0;
41
+ for (const root of wf?.rootWorkflows ?? []) {
42
+ if (typeof root?.wfid !== "string")
43
+ continue;
44
+ const summary = await getJson(opts.fetch, `${opts.base}/workflow/${encodeURIComponent(root.wfid)}/summary`);
45
+ max = Math.max(max, maxNodeMediaIn(summary, node));
46
+ }
47
+ return max;
48
+ }
49
+ catch {
50
+ return 0;
51
+ }
52
+ }
53
+ /** Two samples `settleMs` apart: advancing when the second is above the first
54
+ * and above zero — the fallback card and a stalled feed both read flat. */
55
+ export async function composeAdvancing(opts) {
56
+ const node = opts.node ?? DEFAULT_COMPOSE_NODE;
57
+ const before = await composeFrames({ fetch: opts.fetch, base: opts.base, node });
58
+ await new Promise((r) => setTimeout(r, opts.settleMs ?? DEFAULT_SETTLE_MS));
59
+ const after = await composeFrames({ fetch: opts.fetch, base: opts.base, node });
60
+ return { advancing: after > before && after > 0, before, after, node };
61
+ }
@@ -0,0 +1,48 @@
1
+ export interface SpawnResult {
2
+ stdout: string;
3
+ stderr: string;
4
+ error?: Error;
5
+ }
6
+ export type SpawnLike = (cmd: string, args: string[], opts: {
7
+ encoding: "utf8";
8
+ timeout: number;
9
+ }) => SpawnResult;
10
+ /** `docker ps` argv listing "<name>\t<state>" for ONE instance's containers. */
11
+ export declare function instanceContainersArgs(instanceId: string): string[];
12
+ /** `docker logs` argv tailing one container. Combined stdout+stderr at the call site. */
13
+ export declare function containerLogArgs(container: string, tail?: number): string[];
14
+ export interface ContainerLog {
15
+ name: string;
16
+ state: string;
17
+ log: string;
18
+ }
19
+ /** Parse `docker ps --format "{{.Names}}\t{{.State}}"` output into rows. */
20
+ export declare function parseContainerRows(psStdout: string): {
21
+ name: string;
22
+ state: string;
23
+ }[];
24
+ /** Pure: renders captured logs as an indented block per container. */
25
+ export declare function formatContainerLogs(instanceId: string, entries: ContainerLog[]): string;
26
+ /** Tail every container of one instance. Best-effort: docker unreachable is
27
+ * an empty list, never a throw, so a diagnostic cannot mask what it describes. */
28
+ export declare function collectInstanceContainerLogs(instanceId: string, tail?: number, spawn?: SpawnLike): ContainerLog[];
29
+ /** The ready-to-print diagnostic block (empty-marker when nothing is found). */
30
+ export declare function dumpInstanceContainerLogs(instanceId: string, tail?: number, spawn?: SpawnLike): string;
31
+ export interface LogHit {
32
+ container: string;
33
+ line: string;
34
+ }
35
+ /** A string forbids a substring; a RegExp is tested per line. */
36
+ export type LogPattern = string | RegExp;
37
+ /** Every line, in any container of the instance, that matches a forbidden
38
+ * pattern. `clean` when there are none — including when docker cannot be
39
+ * reached, which is absence of evidence, not a failure of the thing probed. */
40
+ export declare function containerLogsClean(opts: {
41
+ instanceId: string;
42
+ forbid: readonly LogPattern[];
43
+ tail?: number;
44
+ spawn?: SpawnLike;
45
+ }): {
46
+ clean: boolean;
47
+ hits: LogHit[];
48
+ };
@@ -0,0 +1,101 @@
1
+ // A launched instance's container logs, by the `norsk-ctl.instance=<id>`
2
+ // label every per-instance container carries (norsk-ctl docker/constants.ts)
3
+ // — so it finds them under either naming scheme (`<id>-media-1` on the
4
+ // released ctl, `norsk-inst-<id>-media` on older ones). Lifted from funke's
5
+ // tests/integration/support/container-logs.ts (05-demo s7 step 2): the
6
+ // diagnostic dump, plus containerLogsClean — iterate.sh's segfault guard
7
+ // generalised to "no forbidden line in any container of this instance".
8
+ // Pure argv/parse/format helpers; only the two exported probes shell out,
9
+ // through an injectable spawn.
10
+ import { spawnSync } from "node:child_process";
11
+ const defaultSpawn = (cmd, args, opts) => {
12
+ const r = spawnSync(cmd, args, opts);
13
+ return {
14
+ stdout: typeof r.stdout === "string" ? r.stdout : "",
15
+ stderr: typeof r.stderr === "string" ? r.stderr : "",
16
+ ...(r.error ? { error: r.error } : {}),
17
+ };
18
+ };
19
+ const DEFAULT_TAIL = 80;
20
+ /** `docker ps` argv listing "<name>\t<state>" for ONE instance's containers. */
21
+ export function instanceContainersArgs(instanceId) {
22
+ return ["ps", "-a", "--filter", `label=norsk-ctl.instance=${instanceId}`, "--format", "{{.Names}}\t{{.State}}"];
23
+ }
24
+ /** `docker logs` argv tailing one container. Combined stdout+stderr at the call site. */
25
+ export function containerLogArgs(container, tail = DEFAULT_TAIL) {
26
+ return ["logs", "--tail", String(tail), container];
27
+ }
28
+ /** Parse `docker ps --format "{{.Names}}\t{{.State}}"` output into rows. */
29
+ export function parseContainerRows(psStdout) {
30
+ return psStdout
31
+ .split("\n")
32
+ .map((l) => l.trim())
33
+ .filter(Boolean)
34
+ .map((l) => {
35
+ const [name, state] = l.split("\t");
36
+ return { name: name ?? "?", state: state ?? "?" };
37
+ });
38
+ }
39
+ /** Pure: renders captured logs as an indented block per container. */
40
+ export function formatContainerLogs(instanceId, entries) {
41
+ if (entries.length === 0) {
42
+ return ` container logs for ${instanceId}: <none found / docker unreachable>`;
43
+ }
44
+ return entries
45
+ .map((e) => {
46
+ const body = e.log.trim() === ""
47
+ ? " <empty>"
48
+ : e.log
49
+ .trimEnd()
50
+ .split("\n")
51
+ .map((l) => ` ${l}`)
52
+ .join("\n");
53
+ return ` --- ${e.name} (${e.state}) ---\n${body}`;
54
+ })
55
+ .join("\n");
56
+ }
57
+ /** Tail every container of one instance. Best-effort: docker unreachable is
58
+ * an empty list, never a throw, so a diagnostic cannot mask what it describes. */
59
+ export function collectInstanceContainerLogs(instanceId, tail = DEFAULT_TAIL, spawn = defaultSpawn) {
60
+ let rows = [];
61
+ try {
62
+ const ps = spawn("docker", instanceContainersArgs(instanceId), { encoding: "utf8", timeout: 15_000 });
63
+ if (ps.error)
64
+ return [];
65
+ rows = parseContainerRows(ps.stdout);
66
+ }
67
+ catch {
68
+ return [];
69
+ }
70
+ const entries = [];
71
+ for (const row of rows) {
72
+ let log = "";
73
+ try {
74
+ const r = spawn("docker", containerLogArgs(row.name, tail), { encoding: "utf8", timeout: 15_000 });
75
+ log = r.error ? "<docker logs failed>" : `${r.stdout}${r.stderr}`;
76
+ }
77
+ catch {
78
+ log = "<docker logs failed>";
79
+ }
80
+ entries.push({ name: row.name, state: row.state, log });
81
+ }
82
+ return entries;
83
+ }
84
+ /** The ready-to-print diagnostic block (empty-marker when nothing is found). */
85
+ export function dumpInstanceContainerLogs(instanceId, tail = DEFAULT_TAIL, spawn = defaultSpawn) {
86
+ return formatContainerLogs(instanceId, collectInstanceContainerLogs(instanceId, tail, spawn));
87
+ }
88
+ const matches = (pattern, line) => typeof pattern === "string" ? line.includes(pattern) : pattern.test(line);
89
+ /** Every line, in any container of the instance, that matches a forbidden
90
+ * pattern. `clean` when there are none — including when docker cannot be
91
+ * reached, which is absence of evidence, not a failure of the thing probed. */
92
+ export function containerLogsClean(opts) {
93
+ const hits = [];
94
+ for (const entry of collectInstanceContainerLogs(opts.instanceId, opts.tail ?? DEFAULT_TAIL, opts.spawn)) {
95
+ for (const line of entry.log.split("\n")) {
96
+ if (opts.forbid.some((p) => matches(p, line)))
97
+ hits.push({ container: entry.name, line });
98
+ }
99
+ }
100
+ return { clean: hits.length === 0, hits };
101
+ }
package/demo/cli.d.ts CHANGED
@@ -1,9 +1,11 @@
1
1
  #!/usr/bin/env bun
2
2
  import { type DemoRunOptions, type DemoRunResult, demoPorts } from "./run.js";
3
- import type { DemoSpec } from "./spec.js";
3
+ import type { DemoDaemonPolicy, DemoMode, DemoSpec } from "./spec.js";
4
4
  export interface DemoArgs {
5
5
  action: "up" | "check" | "down" | "spec";
6
- mode: "dev" | "standalone";
6
+ /** Absent: the spec's `mode`, else dev. */
7
+ mode?: DemoMode | "standalone";
8
+ daemon: DemoDaemonPolicy;
7
9
  exportOnly: boolean;
8
10
  json: boolean;
9
11
  spec: string;
@@ -27,6 +29,8 @@ export interface ResolvedSpecView {
27
29
  };
28
30
  launch?: DemoSpec["launch"];
29
31
  prerequisites?: DemoSpec["prerequisites"];
32
+ extras: string[];
33
+ beforeLaunch: boolean;
30
34
  sources: Array<{
31
35
  name: string;
32
36
  asset: NonNullable<DemoSpec["sources"]>[number]["asset"];
package/demo/cli.js CHANGED
@@ -9,8 +9,9 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
9
9
  };
10
10
  // `ctl-demo`, the bin behind every product's `bun run demo` (05-demo s4):
11
11
  //
12
- // demo up [--mode dev] launch, print the URLs, hold; Ctrl-C tears down
13
- // demo check [--mode dev] the same, then tear down and exit 0/1 (what CI runs)
12
+ // demo up [--mode dev|image] [--daemon private|reuse]
13
+ // launch, print the URLs, hold; Ctrl-C tears down
14
+ // demo check [--mode dev|image] the same, then tear down and exit 0/1 (what CI runs)
14
15
  // demo check --mode standalone --export-only build + export-workdir + every symlink resolves
15
16
  // demo down tear down what `up` recorded, from any shell
16
17
  // demo spec [--json] the resolved spec: ports, template, sources, URLs
@@ -20,15 +21,21 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
20
21
  // under bun only: the spec is TypeScript and so is every product's dev shell.
21
22
  import { resolve } from "node:path";
22
23
  import { demoDown, demoPorts, demoSlug, runDemo, runExportCheck, } from "./run.js";
23
- const USAGE = `usage: demo <up|check|down|spec> [--mode dev|standalone] [--export-only] [--json] [--spec <path>]
24
+ const USAGE = `usage: demo <up|check|down|spec> [--mode dev|image|standalone] [--daemon private|reuse] [--export-only] [--json] [--spec <path>]
24
25
 
25
- up launch on a private daemon, print the URLs, hold (Ctrl-C tears down)
26
- check up, then tear down and exit 0/1 — what CI runs
26
+ up launch, print the URLs, hold (Ctrl-C tears down)
27
+ check up, then tear down and exit 0/1 — what CI runs, always on a private daemon
27
28
  check --mode standalone --export-only
28
29
  build the template, export the standalone workdir, assert every symlink resolves
29
30
  down tear down what \`up\` recorded
30
31
  spec [--json] print the resolved spec without launching anything
31
32
 
33
+ --mode dev run the product from source (bun run dev) and add it by URL
34
+ --mode image add the built product image — the customer path
35
+ --daemon private a throwaway daemon on its own store (default)
36
+ --daemon reuse your listening daemon and real store; the driver deletes the instance,
37
+ removes the template and product, then adds again (stored templates
38
+ are immutable by name)
32
39
  --spec <path> the demo spec (default tests/demo.spec.ts)`;
33
40
  const DEFAULT_SPEC = "tests/demo.spec.ts";
34
41
  export function parseDemoArgs(argv) {
@@ -36,7 +43,7 @@ export function parseDemoArgs(argv) {
36
43
  if (action !== "up" && action !== "check" && action !== "down" && action !== "spec") {
37
44
  throw new Error(action ? `unknown action '${action}'\n${USAGE}` : USAGE);
38
45
  }
39
- const args = { action, mode: "dev", exportOnly: false, json: false, spec: DEFAULT_SPEC };
46
+ const args = { action, daemon: "private", exportOnly: false, json: false, spec: DEFAULT_SPEC };
40
47
  for (let i = 0; i < rest.length; i++) {
41
48
  const flag = rest[i];
42
49
  const value = () => {
@@ -48,13 +55,18 @@ export function parseDemoArgs(argv) {
48
55
  switch (flag) {
49
56
  case "--mode": {
50
57
  const m = value();
51
- if (m === "image")
52
- throw new Error("--mode image is not yet implemented (05-demo s7 step 2); use --mode dev");
53
- if (m !== "dev" && m !== "standalone")
58
+ if (m !== "dev" && m !== "image" && m !== "standalone")
54
59
  throw new Error(`unknown mode '${m}'\n${USAGE}`);
55
60
  args.mode = m;
56
61
  break;
57
62
  }
63
+ case "--daemon": {
64
+ const d = value();
65
+ if (d !== "private" && d !== "reuse")
66
+ throw new Error(`unknown daemon policy '${d}'\n${USAGE}`);
67
+ args.daemon = d;
68
+ break;
69
+ }
58
70
  case "--export-only":
59
71
  args.exportOnly = true;
60
72
  break;
@@ -74,6 +86,9 @@ export function parseDemoArgs(argv) {
74
86
  if (args.exportOnly && args.mode !== "standalone") {
75
87
  throw new Error("--export-only belongs to --mode standalone");
76
88
  }
89
+ if (args.action === "check" && args.daemon === "reuse") {
90
+ throw new Error("check runs on a private daemon (it is what CI runs); to reload your own daemon use `up --daemon reuse`");
91
+ }
77
92
  return args;
78
93
  }
79
94
  /** `demo spec --json`: what will run, resolved as far as it can be without a
@@ -109,6 +124,8 @@ export function resolvedSpecView(spec, opts) {
109
124
  template,
110
125
  ...(spec.launch !== undefined ? { launch: spec.launch } : {}),
111
126
  ...(spec.prerequisites !== undefined ? { prerequisites: spec.prerequisites } : {}),
127
+ extras: (spec.extras ?? []).map((e) => e.name),
128
+ beforeLaunch: spec.beforeLaunch !== undefined,
112
129
  sources: (spec.sources ?? []).map((s) => ({
113
130
  name: s.name,
114
131
  asset: s.asset ?? { preset: "camera1" },
@@ -158,10 +175,11 @@ export async function demoMain(argv, io) {
158
175
  }
159
176
  return 1;
160
177
  }
178
+ const mode = args.mode ?? spec.mode ?? "dev";
161
179
  try {
162
180
  switch (args.action) {
163
181
  case "spec": {
164
- const view = resolvedSpecView(spec, { mode: args.mode });
182
+ const view = resolvedSpecView(spec, { mode });
165
183
  io.stdout(args.json ? JSON.stringify(view, null, 2) : renderView(view));
166
184
  return 0;
167
185
  }
@@ -174,9 +192,13 @@ export async function demoMain(argv, io) {
174
192
  await io.runExportCheck(spec, { cwd: io.cwd });
175
193
  return 0;
176
194
  }
195
+ if (mode === "standalone") {
196
+ throw new Error("the full standalone tier is not yet implemented (05-demo s7 step 3)");
197
+ }
177
198
  await io.runDemo(spec, {
178
199
  action: args.action,
179
- mode: "dev",
200
+ mode,
201
+ daemon: args.daemon,
180
202
  cwd: io.cwd,
181
203
  ...(io.abort ? { abort: io.abort } : {}),
182
204
  });
@@ -195,6 +217,10 @@ function renderView(v) {
195
217
  ` daemon :${v.ports.daemonPort} dev backend :${v.ports.backendPort} proxy :${v.ports.proxyPort} studio :${v.ports.studioHostPort}`,
196
218
  ` template ${"name" in v.template ? v.template.name : `build ${v.template.build.name}`}`,
197
219
  ];
220
+ if (v.image)
221
+ lines.push(` image ${v.image}`);
222
+ for (const e of v.extras)
223
+ lines.push(` extra ${e}`);
198
224
  for (const s of v.sources)
199
225
  lines.push(` source ${s.name}: ${JSON.stringify(s.asset)} -> ingest ${JSON.stringify(s.ingest)}`);
200
226
  for (const g of v.ready)
@@ -0,0 +1,22 @@
1
+ import { type LogPattern, type SpawnLike } from "../container-logs.js";
2
+ import type { DemoReady } from "./spec.js";
3
+ /** Thrown by a gate whose failure is final: the driver stops polling and the
4
+ * run fails with this message, teardown included. */
5
+ export declare class DemoGateFailure extends Error {
6
+ constructor(message: string);
7
+ }
8
+ /** No forbidden line in any container of the instance (`Segmentation fault`,
9
+ * say — funke's iterate.sh:156). Holds on clean logs; a hit fails the run at
10
+ * once, naming the container and the line. */
11
+ export declare function logsCleanGate(opts: {
12
+ forbid: readonly LogPattern[];
13
+ tail?: number;
14
+ spawn?: SpawnLike;
15
+ }): DemoReady;
16
+ /** The compositor's mediaIn is climbing (playout's ensure-sources.sh gate),
17
+ * read through the daemon proxy's visualiser route for this instance. */
18
+ export declare function composeAdvancingGate(opts?: {
19
+ node?: string;
20
+ settleMs?: number;
21
+ timeoutMs?: number;
22
+ }): DemoReady;
package/demo/gates.js ADDED
@@ -0,0 +1,56 @@
1
+ // Ready gates a spec composes rather than writes (05-demo s5): the lifted
2
+ // probes as `{ custom, label }` entries for `ready: [...]`. A gate returns
3
+ // false to be polled again; it throws DemoGateFailure to stop the run at once
4
+ // — a segfault is not going to un-happen by waiting.
5
+ import { composeAdvancing, DEFAULT_COMPOSE_NODE } from "../compose-advancing.js";
6
+ import { containerLogsClean } from "../container-logs.js";
7
+ /** Thrown by a gate whose failure is final: the driver stops polling and the
8
+ * run fails with this message, teardown included. */
9
+ export class DemoGateFailure extends Error {
10
+ constructor(message) {
11
+ super(message);
12
+ this.name = "DemoGateFailure";
13
+ }
14
+ }
15
+ /** No forbidden line in any container of the instance (`Segmentation fault`,
16
+ * say — funke's iterate.sh:156). Holds on clean logs; a hit fails the run at
17
+ * once, naming the container and the line. */
18
+ export function logsCleanGate(opts) {
19
+ const label = `container logs clean of ${opts.forbid.map(String).join(", ")}`;
20
+ return {
21
+ label,
22
+ custom: async (ctx) => {
23
+ const r = containerLogsClean({
24
+ instanceId: ctx.instanceId,
25
+ forbid: opts.forbid,
26
+ ...(opts.tail !== undefined ? { tail: opts.tail } : {}),
27
+ ...(opts.spawn !== undefined ? { spawn: opts.spawn } : {}),
28
+ });
29
+ if (r.clean)
30
+ return true;
31
+ const lines = r.hits.map((h) => ` ${h.container}: ${h.line}`).join("\n");
32
+ throw new DemoGateFailure(`forbidden line(s) in the instance's container logs:\n${lines}`);
33
+ },
34
+ };
35
+ }
36
+ /** The compositor's mediaIn is climbing (playout's ensure-sources.sh gate),
37
+ * read through the daemon proxy's visualiser route for this instance. */
38
+ export function composeAdvancingGate(opts = {}) {
39
+ const node = opts.node ?? DEFAULT_COMPOSE_NODE;
40
+ return {
41
+ label: `${node} advancing`,
42
+ ...(opts.timeoutMs !== undefined ? { timeoutMs: opts.timeoutMs } : {}),
43
+ custom: async (ctx) => {
44
+ const base = ctx.url({ proxy: "/instance/{id}/visualiser" });
45
+ const r = await composeAdvancing({
46
+ fetch: ctx.fetch,
47
+ base,
48
+ node,
49
+ ...(opts.settleMs !== undefined ? { settleMs: opts.settleMs } : {}),
50
+ });
51
+ if (!r.advancing)
52
+ ctx.log(`${node} not advancing yet (${r.before} -> ${r.after})`);
53
+ return r.advancing;
54
+ },
55
+ };
56
+ }
package/demo/index.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  export type { DemoArgs, DemoCliIo, ResolvedSpecView } from "./cli.js";
2
2
  export { defaultDemoCliIo, demoMain, parseDemoArgs, resolvedSpecView } from "./cli.js";
3
- export type { CliResult, DemoDeps, DemoPorts, DemoRunOptions, DemoRunResult, DemoState, DemoStateStore, DemoTimeouts, ExportCheckOptions, IngestPortRow, ProcessHandle, TemplateParams, } from "./run.js";
4
- export { defaultDemoDeps, demoDown, demoPorts, demoSlug, expandHome, fileStateStore, findBrokenSymlinks, killGroup, resolveIngestPort, runDemo, runExportCheck, } from "./run.js";
5
- export type { DemoContext, DemoIngest, DemoOpen, DemoReady, DemoSource, DemoSpec, DemoTemplate, DemoUrl, } from "./spec.js";
3
+ export { composeAdvancingGate, DemoGateFailure, logsCleanGate } from "./gates.js";
4
+ export type { CliResult, DemoDeps, DemoPorts, DemoRunOptions, DemoRunResult, DemoState, DemoStateStore, DemoTimeouts, ExportCheckOptions, IngestPortRow, PinMismatch, ProcessHandle, TemplateParams, } from "./run.js";
5
+ export { checkTemplatePins, defaultDemoDeps, demoDown, demoPorts, demoSlug, expandHome, fileStateStore, findBrokenSymlinks, killGroup, resolveIngestPort, runDemo, runExportCheck, } from "./run.js";
6
+ export type { DemoContext, DemoDaemonPolicy, DemoExtra, DemoIngest, DemoLaunchContext, DemoMode, DemoOpen, DemoReady, DemoSource, DemoSpec, DemoTemplate, DemoUrl, } from "./spec.js";
6
7
  export { DemoSpecSchema, defineDemo, formatSpecIssues } from "./spec.js";
package/demo/index.js CHANGED
@@ -1,3 +1,4 @@
1
1
  export { defaultDemoCliIo, demoMain, parseDemoArgs, resolvedSpecView } from "./cli.js";
2
- export { defaultDemoDeps, demoDown, demoPorts, demoSlug, expandHome, fileStateStore, findBrokenSymlinks, killGroup, resolveIngestPort, runDemo, runExportCheck, } from "./run.js";
2
+ export { composeAdvancingGate, DemoGateFailure, logsCleanGate } from "./gates.js";
3
+ export { checkTemplatePins, defaultDemoDeps, demoDown, demoPorts, demoSlug, expandHome, fileStateStore, findBrokenSymlinks, killGroup, resolveIngestPort, runDemo, runExportCheck, } from "./run.js";
3
4
  export { DemoSpecSchema, defineDemo, formatSpecIssues } from "./spec.js";
package/demo/run.d.ts CHANGED
@@ -1,8 +1,9 @@
1
+ import { type ManifestSeed } from "@norskvideo/ctl-sdk/manifest-seed";
1
2
  import { ensureRunnerOnNetwork } from "../container-net.js";
2
3
  import { type DaemonProcess, type StartDaemonOptions } from "../daemon.js";
3
4
  import { type BaseHarnessPorts } from "../harness-config.js";
4
5
  import { type SourceHandle, type SrtPumpTarget } from "../source-pump.js";
5
- import type { DemoIngest, DemoSpec } from "./spec.js";
6
+ import type { DemoDaemonPolicy, DemoIngest, DemoMode, DemoSpec } from "./spec.js";
6
7
  export interface CliResult {
7
8
  stdout: string;
8
9
  stderr: string;
@@ -20,6 +21,10 @@ export interface DemoState {
20
21
  daemonPort: number;
21
22
  instanceId?: string;
22
23
  devPid?: number;
24
+ /** Absent on records from before the policy existed: private. */
25
+ daemon?: DemoDaemonPolicy;
26
+ /** Extra containers `up` started, for `down` to remove. */
27
+ extras?: string[];
23
28
  }
24
29
  export interface DemoStateStore {
25
30
  read(product: string): DemoState | null;
@@ -29,7 +34,12 @@ export interface DemoStateStore {
29
34
  export interface DemoDeps {
30
35
  licenseFile(): string;
31
36
  storeDir(slug: string): string;
37
+ /** The developer's real store, for `--daemon reuse`. */
38
+ realStoreDir(): string;
32
39
  writeFile(path: string, contents: string): void;
40
+ /** File contents, or null when unreadable (compose.yml, manifest.seed.json,
41
+ * config.yaml, proxy-secret). */
42
+ readFile(path: string): string | null;
33
43
  fileExists(path: string): boolean;
34
44
  startDaemon(storeDir: string, options: StartDaemonOptions): {
35
45
  daemon: DaemonProcess;
@@ -42,11 +52,14 @@ export interface DemoDeps {
42
52
  cwd: string;
43
53
  env: Record<string, string>;
44
54
  }): ProcessHandle;
55
+ /** One `docker <argv>` invocation: extras, and the reuse policy's control-plane container sweep. */
56
+ docker(argv: string[]): CliResult;
45
57
  fetch(url: string, init?: RequestInit): Promise<Response>;
46
58
  startSources(opts: {
47
59
  daemonPort: number;
48
60
  instanceId: string;
49
61
  targets: readonly SrtPumpTarget[];
62
+ proxySecret?: string;
50
63
  timeoutMs?: number;
51
64
  }): Promise<SourceHandle[]>;
52
65
  stopSources(handles: SourceHandle[]): Promise<void>;
@@ -83,8 +96,12 @@ export interface DemoTimeouts {
83
96
  }
84
97
  export interface DemoRunOptions {
85
98
  action: "up" | "check";
86
- mode: "dev";
87
- /** The product repo root: dev command cwd, relative inputs and links. */
99
+ mode: DemoMode;
100
+ /** Default private. */
101
+ daemon?: DemoDaemonPolicy;
102
+ /** `reuse`: the daemon's port (default NORSK_CTL_PORT, else 8333). */
103
+ daemonPort?: number;
104
+ /** The product repo root: dev command cwd, relative inputs and links, manifest.seed.json. */
88
105
  cwd: string;
89
106
  /** Default: the product name without its `norsk-` prefix. */
90
107
  slug?: string;
@@ -134,6 +151,23 @@ export interface TemplateParams {
134
151
  * no parameter carries it and the instance offers it as the conventional
135
152
  * default, so the demo follows the template when the template moves. */
136
153
  export declare function resolveIngestPort(ingest: DemoIngest, rows: IngestPortRow[], t?: TemplateParams): number;
154
+ /** A stored template's `image:` ref that disagrees with the product's seed. */
155
+ export interface PinMismatch {
156
+ repo: string;
157
+ stored: string;
158
+ seed: string;
159
+ }
160
+ /** Image pins are read, never typed (05-demo s4): for every repo the seed's
161
+ * `latest` names (studio, media), the stored compose must pin the same ref.
162
+ * A repo the compose does not name is not a mismatch — the guard is about a
163
+ * stale snapshot, not template completeness. */
164
+ export declare function checkTemplatePins(composeText: string, seed: ManifestSeed): PinMismatch[];
165
+ /** The two config.yaml scalars the proxy URL needs; a regex, not a YAML
166
+ * parser, because that is all the driver reads from a store it does not own. */
167
+ export declare function parseProxyConfig(configText: string | null): {
168
+ scheme: "http" | "https";
169
+ port: number;
170
+ };
137
171
  export declare function findBrokenSymlinks(dir: string): string[];
138
172
  /** `<cwd>/test-temp/demo/<product>.json` — beside the store dirs, inside the
139
173
  * consumer's repo, where `down` from another shell can find it. */