@specific.dev/spectest 0.38.0 → 0.41.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.
Files changed (103) hide show
  1. package/dist/components/k3s.js +1 -24
  2. package/dist/components/supabase.d.ts +87 -27
  3. package/dist/components/supabase.js +352 -69
  4. package/dist/daemon.d.ts +38 -0
  5. package/dist/daemon.js +405 -946
  6. package/dist/harness/build-context.d.ts +82 -0
  7. package/dist/harness/build-context.js +113 -0
  8. package/dist/harness/buildkit-progress.d.ts +37 -0
  9. package/dist/harness/buildkit-progress.js +66 -0
  10. package/dist/harness/container-run.d.ts +89 -0
  11. package/dist/harness/container-run.js +118 -0
  12. package/dist/harness/file-mounts.d.ts +91 -0
  13. package/dist/harness/file-mounts.js +119 -0
  14. package/dist/harness/hostmatch.d.ts +65 -0
  15. package/dist/harness/hostmatch.js +108 -0
  16. package/dist/harness/http-proxy.d.ts +62 -0
  17. package/dist/harness/http-proxy.js +104 -0
  18. package/dist/harness/ingress-table.d.ts +148 -0
  19. package/dist/harness/ingress-table.js +129 -0
  20. package/dist/harness/log-delta.d.ts +54 -0
  21. package/dist/harness/log-delta.js +83 -0
  22. package/dist/harness/main.d.ts +47 -0
  23. package/dist/harness/main.js +164 -0
  24. package/dist/harness/methods.d.ts +54 -0
  25. package/dist/harness/methods.js +65 -0
  26. package/dist/harness/names-registry.d.ts +63 -0
  27. package/dist/harness/names-registry.js +90 -0
  28. package/dist/harness/protocol.d.ts +88 -0
  29. package/dist/harness/protocol.js +96 -0
  30. package/dist/harness/ready-poll.d.ts +47 -0
  31. package/dist/harness/ready-poll.js +67 -0
  32. package/dist/harness/service-graph.d.ts +29 -0
  33. package/dist/harness/service-graph.js +92 -0
  34. package/dist/harness/volume-paths.d.ts +70 -0
  35. package/dist/harness/volume-paths.js +81 -0
  36. package/dist/index.d.ts +3 -3
  37. package/dist/ingress.d.ts +1 -1
  38. package/dist/inspect.d.ts +23 -0
  39. package/dist/inspect.js +65 -0
  40. package/dist/resolver.js +5 -8
  41. package/dist/vendor/rrweb-plugin-console-record.umd.js +521 -0
  42. package/dist/vendor/rrweb-record.min.js +5061 -0
  43. package/package.json +7 -1
  44. package/src/aws-sigv4.ts +218 -0
  45. package/src/browser.ts +2040 -0
  46. package/src/components/aws.ts +554 -0
  47. package/src/components/email.ts +398 -0
  48. package/src/components/expo.ts +167 -0
  49. package/src/components/index.ts +81 -0
  50. package/src/components/k3s.ts +2061 -0
  51. package/src/components/postgres.ts +132 -0
  52. package/src/components/replayFake.ts +1015 -0
  53. package/src/components/s3.ts +132 -0
  54. package/src/components/supabase.ts +1699 -0
  55. package/src/daemon.ts +5489 -0
  56. package/src/harness/build-context.test.ts +0 -0
  57. package/src/harness/build-context.ts +146 -0
  58. package/src/harness/buildkit-progress.test.ts +98 -0
  59. package/src/harness/buildkit-progress.ts +74 -0
  60. package/src/harness/container-run.test.ts +209 -0
  61. package/src/harness/container-run.ts +158 -0
  62. package/src/harness/file-mounts.test.ts +185 -0
  63. package/src/harness/file-mounts.ts +145 -0
  64. package/src/harness/hostmatch.test.ts +148 -0
  65. package/src/harness/hostmatch.ts +109 -0
  66. package/src/harness/http-proxy.test.ts +156 -0
  67. package/src/harness/http-proxy.ts +119 -0
  68. package/src/harness/ingress-rebind.test.ts +125 -0
  69. package/src/harness/ingress-table.test.ts +172 -0
  70. package/src/harness/ingress-table.ts +186 -0
  71. package/src/harness/log-delta.test.ts +125 -0
  72. package/src/harness/log-delta.ts +100 -0
  73. package/src/harness/main.test.ts +211 -0
  74. package/src/harness/main.ts +196 -0
  75. package/src/harness/methods.test.ts +63 -0
  76. package/src/harness/methods.ts +92 -0
  77. package/src/harness/names-registry.test.ts +137 -0
  78. package/src/harness/names-registry.ts +108 -0
  79. package/src/harness/protocol.test.ts +148 -0
  80. package/src/harness/protocol.ts +163 -0
  81. package/src/harness/ready-poll.test.ts +172 -0
  82. package/src/harness/ready-poll.ts +93 -0
  83. package/src/harness/service-graph.test.ts +97 -0
  84. package/src/harness/service-graph.ts +97 -0
  85. package/src/harness/volume-paths.test.ts +102 -0
  86. package/src/harness/volume-paths.ts +112 -0
  87. package/src/ids.ts +89 -0
  88. package/src/index.ts +2725 -0
  89. package/src/ingress.ts +305 -0
  90. package/src/inspect.ts +739 -0
  91. package/src/locator.ts +716 -0
  92. package/src/mobile.ts +133 -0
  93. package/src/record-secrets.ts +41 -0
  94. package/src/recorder.ts +846 -0
  95. package/src/redis.ts +202 -0
  96. package/src/replay-bundle.ts +108 -0
  97. package/src/resolver.ts +348 -0
  98. package/src/s3.ts +333 -0
  99. package/src/sql.ts +243 -0
  100. package/src/terminal.ts +740 -0
  101. package/src/url-match.ts +67 -0
  102. package/src/vendor/rrweb-plugin-console-record.umd.js +521 -0
  103. package/src/vendor/rrweb-record.min.js +5061 -0
@@ -0,0 +1,90 @@
1
+ /**
2
+ * The DNS names registry: the file the harness writes and
3
+ * `spectest-resolver` reads.
4
+ *
5
+ * Ported out of `daemon.ts`. The point of a shared module is that this
6
+ * format had **two independent implementations** — the writer here and
7
+ * the reader in `resolver.ts` — with nothing keeping them in step. That
8
+ * is the same shape as the `config.rs` ↔ SDK drift the harness split
9
+ * exists to remove, just inside one language.
10
+ *
11
+ * The registry answers names that Docker's own DNS cannot:
12
+ * - fake hostnames (`api.stripe.com` → the bridge gateway),
13
+ * - names bound at runtime by `ctx.dnsName`,
14
+ * - **wildcards** (`*.us-east-1.amazonaws.com`), which have no
15
+ * `--add-host` or `--network-alias` equivalent and therefore *only*
16
+ * exist here.
17
+ *
18
+ * Lookup is exact-first, then longest matching suffix — the same
19
+ * precedence the ingress routes and the SNI cert table use.
20
+ */
21
+ export const EMPTY_REGISTRY = { hosts: {}, wildcards: [] };
22
+ /** Serialise for the resolver. */
23
+ export function encodeRegistry(doc, now) {
24
+ return JSON.stringify({
25
+ hosts: doc.hosts,
26
+ wildcards: doc.wildcards,
27
+ updatedAt: now,
28
+ });
29
+ }
30
+ /**
31
+ * Parse a registry file.
32
+ *
33
+ * Deliberately total: a truncated or malformed file yields an **empty**
34
+ * registry rather than throwing. The resolver reads this on a hot path
35
+ * while the harness may be rewriting it, and a parse error there would
36
+ * take out DNS for the whole environment — every name, not just the one
37
+ * being added. Degrading to "no custom names" is recoverable; the next
38
+ * successful read restores everything.
39
+ */
40
+ export function decodeRegistry(text) {
41
+ try {
42
+ const raw = JSON.parse(text);
43
+ const hosts = raw.hosts && typeof raw.hosts === "object" ? raw.hosts : {};
44
+ const wildcards = Array.isArray(raw.wildcards)
45
+ ? raw.wildcards.filter((w) => !!w && typeof w.suffix === "string" && typeof w.ip === "string")
46
+ : [];
47
+ return { hosts, wildcards, updatedAt: raw.updatedAt };
48
+ }
49
+ catch {
50
+ return { hosts: {}, wildcards: [] };
51
+ }
52
+ }
53
+ /** `"*.example.com"` → `".example.com"`, the form stored in `wildcards`. */
54
+ export function suffixOf(pattern) {
55
+ return pattern.startsWith("*.") ? pattern.slice(1) : pattern;
56
+ }
57
+ /**
58
+ * Resolve a name: exact entry first, then the longest matching wildcard
59
+ * suffix. Returns `null` when nothing matches, which is the resolver's
60
+ * signal to fall through to Docker's DNS and then upstream.
61
+ */
62
+ export function lookup(doc, name) {
63
+ const exact = doc.hosts[name];
64
+ if (exact)
65
+ return exact;
66
+ let best = null;
67
+ for (const w of doc.wildcards) {
68
+ if (name.endsWith(w.suffix) && (!best || w.suffix.length > best.suffix.length)) {
69
+ best = w;
70
+ }
71
+ }
72
+ return best?.ip ?? null;
73
+ }
74
+ /**
75
+ * Add or replace a name.
76
+ *
77
+ * A wildcard replaces any existing entry for the same suffix rather than
78
+ * appending, so re-registering a name (which `ctx.dnsName` allows, and
79
+ * which a re-run of a `setup` hook does) cannot grow the list without
80
+ * bound or leave two entries racing on equal suffix length.
81
+ */
82
+ export function upsert(doc, hostname, ip) {
83
+ if (hostname.startsWith("*.")) {
84
+ const suffix = suffixOf(hostname);
85
+ const wildcards = doc.wildcards.filter((w) => w.suffix !== suffix);
86
+ wildcards.push({ suffix, ip });
87
+ return { ...doc, wildcards };
88
+ }
89
+ return { ...doc, hosts: { ...doc.hosts, [hostname]: ip } };
90
+ }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * The supervisor↔harness protocol — TypeScript half.
3
+ *
4
+ * Mirrors `crates/spectest-vm-agent/src/protocol.rs`. Read that file's
5
+ * header for the design; the rules that matter when editing this one:
6
+ *
7
+ * - `hello` carries a protocol version AND a capability list in both
8
+ * directions, from the first release. A release that announces nothing
9
+ * can only ever be treated as "oldest".
10
+ * - **Unknown fields are ignored in both directions.** Nothing here
11
+ * validates against a closed field set, deliberately — that is the
12
+ * mistake `config.rs` made, where a newer SDK's config was rejected
13
+ * outright by an older server.
14
+ * - A message never changes meaning. It only gains optional fields.
15
+ * - An unrecognised *method* must still parse, so the receiver can answer
16
+ * with an error instead of dropping the frame and hanging the sender.
17
+ *
18
+ * Anti-drift: the messages are defined once in
19
+ * `protocols/supervisor-harness/frames.json`, and both this module's test suite and
20
+ * the Rust one parse that file. A field added on one side and forgotten on
21
+ * the other fails there rather than in a VM.
22
+ */
23
+ /** Protocol version this build speaks. Must match `PROTOCOL_VERSION` in protocol.rs. */
24
+ export declare const PROTOCOL_VERSION = 1;
25
+ /** Methods either side can send. */
26
+ export type Method = "hello" | "load" | "loadTests" | "fingerprint" | "bootstrap" | "run" | "eval" | "teardown" | "shutdown" | "createArtifact" | "completeArtifact" | "progress" | "step";
27
+ /** Methods this build knows how to dispatch. A method outside this set still
28
+ * *parses* — it is answered with an error, never dropped. */
29
+ export declare const KNOWN_METHODS: ReadonlySet<string>;
30
+ export interface ProtocolError {
31
+ message: string;
32
+ code?: string;
33
+ }
34
+ export interface RequestFrame {
35
+ kind: "request";
36
+ /** Correlates the response. */
37
+ id: string;
38
+ /** Typed as `string`, not `Method`: a newer peer's method must parse. */
39
+ method: string;
40
+ params?: Record<string, unknown>;
41
+ }
42
+ export interface ResponseFrame {
43
+ kind: "response";
44
+ id: string;
45
+ ok: boolean;
46
+ result?: Record<string, unknown>;
47
+ error?: ProtocolError;
48
+ }
49
+ /** Fire-and-forget. No id, no reply — progress that blocked on an ack would
50
+ * make the UI a correctness dependency. */
51
+ export interface EventFrame {
52
+ kind: "event";
53
+ method: string;
54
+ params?: Record<string, unknown>;
55
+ }
56
+ export type Frame = RequestFrame | ResponseFrame | EventFrame;
57
+ /** `hello` payload, sent in both directions. `sdkVersion` is on the
58
+ * harness's reply only. */
59
+ export interface Hello {
60
+ protocolVersion: number;
61
+ capabilities?: string[];
62
+ sdkVersion?: string;
63
+ }
64
+ /** One contribution to the L2 declared cache key. The supervisor hashes
65
+ * these without interpreting them — a fingerprint it could interpret
66
+ * would just be the schema coupling again. */
67
+ export interface Contribution {
68
+ id: string;
69
+ kind: string;
70
+ key: string;
71
+ }
72
+ /**
73
+ * Parse one NDJSON line.
74
+ *
75
+ * Throws only on frames that cannot be *routed* — malformed JSON, or a
76
+ * missing `kind`/`id`/`method`. An unknown method is not such a case: it
77
+ * parses, and the dispatcher answers with an error. Unknown *fields* are
78
+ * left untouched on the returned object rather than stripped, so a value
79
+ * this build ignores still round-trips if forwarded.
80
+ */
81
+ export declare function decodeFrame(line: string): Frame;
82
+ /** Encode one frame as a single NDJSON line, newline included. */
83
+ export declare function encodeFrame(frame: Frame): string;
84
+ /** A successful reply to `id`. */
85
+ export declare function ok(id: string, result?: Record<string, unknown>): ResponseFrame;
86
+ /** A failed reply to `id`. Always carries the id: a request that failed must
87
+ * never leave its caller waiting. */
88
+ export declare function fail(id: string, message: string, code?: string): ResponseFrame;
@@ -0,0 +1,96 @@
1
+ /**
2
+ * The supervisor↔harness protocol — TypeScript half.
3
+ *
4
+ * Mirrors `crates/spectest-vm-agent/src/protocol.rs`. Read that file's
5
+ * header for the design; the rules that matter when editing this one:
6
+ *
7
+ * - `hello` carries a protocol version AND a capability list in both
8
+ * directions, from the first release. A release that announces nothing
9
+ * can only ever be treated as "oldest".
10
+ * - **Unknown fields are ignored in both directions.** Nothing here
11
+ * validates against a closed field set, deliberately — that is the
12
+ * mistake `config.rs` made, where a newer SDK's config was rejected
13
+ * outright by an older server.
14
+ * - A message never changes meaning. It only gains optional fields.
15
+ * - An unrecognised *method* must still parse, so the receiver can answer
16
+ * with an error instead of dropping the frame and hanging the sender.
17
+ *
18
+ * Anti-drift: the messages are defined once in
19
+ * `protocols/supervisor-harness/frames.json`, and both this module's test suite and
20
+ * the Rust one parse that file. A field added on one side and forgotten on
21
+ * the other fails there rather than in a VM.
22
+ */
23
+ /** Protocol version this build speaks. Must match `PROTOCOL_VERSION` in protocol.rs. */
24
+ export const PROTOCOL_VERSION = 1;
25
+ /** Methods this build knows how to dispatch. A method outside this set still
26
+ * *parses* — it is answered with an error, never dropped. */
27
+ export const KNOWN_METHODS = new Set([
28
+ "hello",
29
+ "load",
30
+ "loadTests",
31
+ "fingerprint",
32
+ "bootstrap",
33
+ "run",
34
+ "eval",
35
+ "teardown",
36
+ "shutdown",
37
+ "createArtifact",
38
+ "completeArtifact",
39
+ "progress",
40
+ "step",
41
+ ]);
42
+ /**
43
+ * Parse one NDJSON line.
44
+ *
45
+ * Throws only on frames that cannot be *routed* — malformed JSON, or a
46
+ * missing `kind`/`id`/`method`. An unknown method is not such a case: it
47
+ * parses, and the dispatcher answers with an error. Unknown *fields* are
48
+ * left untouched on the returned object rather than stripped, so a value
49
+ * this build ignores still round-trips if forwarded.
50
+ */
51
+ export function decodeFrame(line) {
52
+ let raw;
53
+ try {
54
+ raw = JSON.parse(line);
55
+ }
56
+ catch (e) {
57
+ throw new Error(`harness protocol: malformed JSON frame: ${e.message}`);
58
+ }
59
+ if (typeof raw !== "object" || raw === null) {
60
+ throw new Error("harness protocol: frame is not an object");
61
+ }
62
+ const f = raw;
63
+ switch (f.kind) {
64
+ case "request":
65
+ if (typeof f.id !== "string")
66
+ throw new Error("harness protocol: request has no id");
67
+ if (typeof f.method !== "string")
68
+ throw new Error("harness protocol: request has no method");
69
+ return f;
70
+ case "response":
71
+ if (typeof f.id !== "string")
72
+ throw new Error("harness protocol: response has no id");
73
+ if (typeof f.ok !== "boolean")
74
+ throw new Error("harness protocol: response has no ok");
75
+ return f;
76
+ case "event":
77
+ if (typeof f.method !== "string")
78
+ throw new Error("harness protocol: event has no method");
79
+ return f;
80
+ default:
81
+ throw new Error(`harness protocol: unknown frame kind ${JSON.stringify(f.kind)}`);
82
+ }
83
+ }
84
+ /** Encode one frame as a single NDJSON line, newline included. */
85
+ export function encodeFrame(frame) {
86
+ return `${JSON.stringify(frame)}\n`;
87
+ }
88
+ /** A successful reply to `id`. */
89
+ export function ok(id, result = {}) {
90
+ return { kind: "response", id, ok: true, result };
91
+ }
92
+ /** A failed reply to `id`. Always carries the id: a request that failed must
93
+ * never leave its caller waiting. */
94
+ export function fail(id, message, code) {
95
+ return { kind: "response", id, ok: false, error: code ? { message, code } : { message } };
96
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * The polling schedule behind a readiness probe.
3
+ *
4
+ * Ported out of `daemon.ts`, separated from the probes themselves so the
5
+ * timing rules can be tested without opening a socket or spawning a
6
+ * `docker exec`.
7
+ *
8
+ * Why a ramp rather than a fixed interval: a flat 500 ms delay quantises
9
+ * *every* service's measured ready latency to a multiple of 500 ms, and
10
+ * that error compounds down a `dependsOn` chain — a four-deep chain of
11
+ * services that are each genuinely ready in 60 ms reports two seconds.
12
+ * Probing fast at first catches quick services honestly; ramping up caps
13
+ * the polling load on genuinely slow ones.
14
+ *
15
+ * Exec probes keep a higher floor because each attempt spawns a
16
+ * `docker exec`, which costs far more than a TCP connect.
17
+ */
18
+ export type ProbeKind = "tcp" | "http" | "exec";
19
+ /** Delays between attempts, in ms. The last entry repeats forever. */
20
+ export declare const RAMP_DEFAULT: readonly number[];
21
+ export declare const RAMP_EXEC: readonly number[];
22
+ export declare const DEFAULT_TIMEOUT_SECS = 60;
23
+ export declare function rampFor(kind: ProbeKind): readonly number[];
24
+ /** Delay before attempt number `attempt` (0-based), clamped to the ramp's tail. */
25
+ export declare function delayForAttempt(ramp: readonly number[], attempt: number): number;
26
+ export interface PollOptions {
27
+ kind: ProbeKind;
28
+ timeoutSecs?: number;
29
+ /** Injectable for tests; defaults to the wall clock. */
30
+ now?: () => number;
31
+ sleep?: (ms: number) => Promise<void>;
32
+ }
33
+ export interface PollResult {
34
+ ready: boolean;
35
+ attempts: number;
36
+ elapsedMs: number;
37
+ }
38
+ /**
39
+ * Poll `probe` until it returns true or the deadline passes.
40
+ *
41
+ * The probe is **always attempted at least once**, even with a zero or
42
+ * negative timeout. A deadline check placed before the first attempt
43
+ * would make `timeoutSecs: 0` mean "never check", which reads as "no
44
+ * timeout" to anyone writing it — and would fail a service that was
45
+ * ready all along.
46
+ */
47
+ export declare function pollUntilReady(probe: () => Promise<boolean>, opts: PollOptions): Promise<PollResult>;
@@ -0,0 +1,67 @@
1
+ /**
2
+ * The polling schedule behind a readiness probe.
3
+ *
4
+ * Ported out of `daemon.ts`, separated from the probes themselves so the
5
+ * timing rules can be tested without opening a socket or spawning a
6
+ * `docker exec`.
7
+ *
8
+ * Why a ramp rather than a fixed interval: a flat 500 ms delay quantises
9
+ * *every* service's measured ready latency to a multiple of 500 ms, and
10
+ * that error compounds down a `dependsOn` chain — a four-deep chain of
11
+ * services that are each genuinely ready in 60 ms reports two seconds.
12
+ * Probing fast at first catches quick services honestly; ramping up caps
13
+ * the polling load on genuinely slow ones.
14
+ *
15
+ * Exec probes keep a higher floor because each attempt spawns a
16
+ * `docker exec`, which costs far more than a TCP connect.
17
+ */
18
+ /** Delays between attempts, in ms. The last entry repeats forever. */
19
+ export const RAMP_DEFAULT = [50, 100, 150, 250, 400, 500];
20
+ export const RAMP_EXEC = [250, 250, 400, 400, 500];
21
+ export const DEFAULT_TIMEOUT_SECS = 60;
22
+ export function rampFor(kind) {
23
+ return kind === "exec" ? RAMP_EXEC : RAMP_DEFAULT;
24
+ }
25
+ /** Delay before attempt number `attempt` (0-based), clamped to the ramp's tail. */
26
+ export function delayForAttempt(ramp, attempt) {
27
+ return ramp[Math.min(attempt, ramp.length - 1)];
28
+ }
29
+ /**
30
+ * Poll `probe` until it returns true or the deadline passes.
31
+ *
32
+ * The probe is **always attempted at least once**, even with a zero or
33
+ * negative timeout. A deadline check placed before the first attempt
34
+ * would make `timeoutSecs: 0` mean "never check", which reads as "no
35
+ * timeout" to anyone writing it — and would fail a service that was
36
+ * ready all along.
37
+ */
38
+ export async function pollUntilReady(probe, opts) {
39
+ const now = opts.now ?? Date.now;
40
+ const sleep = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
41
+ const timeoutSecs = opts.timeoutSecs ?? DEFAULT_TIMEOUT_SECS;
42
+ const ramp = rampFor(opts.kind);
43
+ const started = now();
44
+ const deadline = started + timeoutSecs * 1000;
45
+ let attempts = 0;
46
+ for (;;) {
47
+ attempts++;
48
+ if (await probe()) {
49
+ return { ready: true, attempts, elapsedMs: now() - started };
50
+ }
51
+ const remaining = deadline - now();
52
+ if (remaining <= 0) {
53
+ return { ready: false, attempts, elapsedMs: now() - started };
54
+ }
55
+ // Clamp rather than give up early. An earlier version returned as soon
56
+ // as `now + delay` passed the deadline, which stopped probing up to a
57
+ // full ramp step (500 ms) BEFORE the timeout the user asked for — so a
58
+ // service that became ready in that final window was reported as
59
+ // failed. Harmless when everything is fast, and exactly wrong under
60
+ // load, which is when readiness is marginal in the first place.
61
+ //
62
+ // Clamping keeps the original "probe until the deadline" behaviour and
63
+ // still never sleeps past it.
64
+ const delay = Math.min(delayForAttempt(ramp, attempts - 1), remaining);
65
+ await sleep(delay);
66
+ }
67
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * The `dependsOn` graph: validation, and the traversal order bring-up
3
+ * uses.
4
+ *
5
+ * Ported out of `daemon.ts`. Validation runs before anything starts, so
6
+ * the DAG runner can assume a clean graph — an unknown dependency or a
7
+ * cycle must be a clear error at load, not a container that never becomes
8
+ * ready and eventually times out with no explanation.
9
+ */
10
+ export interface GraphNode {
11
+ name: string;
12
+ dependsOn?: readonly string[];
13
+ }
14
+ /**
15
+ * Check the graph and return the name→node map used to walk it.
16
+ *
17
+ * Rejects a dependency on a service that doesn't exist, and any cycle.
18
+ */
19
+ export declare function validateServiceGraph<T extends GraphNode>(services: readonly T[]): Map<string, T>;
20
+ /**
21
+ * Services in an order where every node follows its dependencies.
22
+ *
23
+ * Bring-up itself does **not** walk levels — each service starts the
24
+ * moment its own dependencies are ready, so an unrelated slow probe never
25
+ * holds back a branch that is ready. This ordering is for the places that
26
+ * genuinely need a sequence (teardown, reporting), and for asserting the
27
+ * graph is walkable at all.
28
+ */
29
+ export declare function topologicalOrder<T extends GraphNode>(services: readonly T[]): T[];
@@ -0,0 +1,92 @@
1
+ /**
2
+ * The `dependsOn` graph: validation, and the traversal order bring-up
3
+ * uses.
4
+ *
5
+ * Ported out of `daemon.ts`. Validation runs before anything starts, so
6
+ * the DAG runner can assume a clean graph — an unknown dependency or a
7
+ * cycle must be a clear error at load, not a container that never becomes
8
+ * ready and eventually times out with no explanation.
9
+ */
10
+ /**
11
+ * Check the graph and return the name→node map used to walk it.
12
+ *
13
+ * Rejects a dependency on a service that doesn't exist, and any cycle.
14
+ */
15
+ export function validateServiceGraph(services) {
16
+ const byName = new Map(services.map((s) => [s.name, s]));
17
+ // A duplicate name would silently shadow one definition and make the
18
+ // graph lie about what is running. The old code built the map without
19
+ // checking, so the second service simply replaced the first.
20
+ if (byName.size !== services.length) {
21
+ const seen = new Set();
22
+ const dupe = services.find((s) => !seen.add(s.name))?.name;
23
+ throw new Error(`duplicate service name ${JSON.stringify(dupe)}`);
24
+ }
25
+ for (const s of services) {
26
+ for (const d of s.dependsOn ?? []) {
27
+ if (!byName.has(d)) {
28
+ throw new Error(`service ${s.name} depends on unknown service ${d}`);
29
+ }
30
+ if (d === s.name) {
31
+ throw new Error(`service ${s.name} depends on itself`);
32
+ }
33
+ }
34
+ }
35
+ // Cycle detection by DFS colouring: white = unseen, gray = on the
36
+ // current stack (so meeting gray again is a cycle), black = finished.
37
+ const WHITE = 0;
38
+ const GRAY = 1;
39
+ const BLACK = 2;
40
+ const color = new Map(services.map((s) => [s.name, WHITE]));
41
+ const stack = [];
42
+ const visit = (name) => {
43
+ color.set(name, GRAY);
44
+ stack.push(name);
45
+ for (const d of byName.get(name).dependsOn ?? []) {
46
+ const c = color.get(d);
47
+ if (c === GRAY) {
48
+ // Naming the cycle matters: "service dependency cycle" alone
49
+ // leaves the user to find it by eye in a large services map.
50
+ const from = stack.indexOf(d);
51
+ const loop = [...stack.slice(from), d].join(" → ");
52
+ throw new Error(`service dependency cycle: ${loop}`);
53
+ }
54
+ if (c === WHITE)
55
+ visit(d);
56
+ }
57
+ stack.pop();
58
+ color.set(name, BLACK);
59
+ };
60
+ for (const s of services)
61
+ if (color.get(s.name) === WHITE)
62
+ visit(s.name);
63
+ return byName;
64
+ }
65
+ /**
66
+ * Services in an order where every node follows its dependencies.
67
+ *
68
+ * Bring-up itself does **not** walk levels — each service starts the
69
+ * moment its own dependencies are ready, so an unrelated slow probe never
70
+ * holds back a branch that is ready. This ordering is for the places that
71
+ * genuinely need a sequence (teardown, reporting), and for asserting the
72
+ * graph is walkable at all.
73
+ */
74
+ export function topologicalOrder(services) {
75
+ const byName = validateServiceGraph(services);
76
+ const out = [];
77
+ const done = new Set();
78
+ const visit = (name) => {
79
+ if (done.has(name))
80
+ return;
81
+ const node = byName.get(name);
82
+ for (const d of node.dependsOn ?? [])
83
+ visit(d);
84
+ done.add(name);
85
+ out.push(node);
86
+ };
87
+ // Declaration order among independent services, so the result is stable
88
+ // rather than dependent on Map iteration incidentals.
89
+ for (const s of services)
90
+ visit(s.name);
91
+ return out;
92
+ }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Where a service's volume is backed on the VM's filesystem.
3
+ *
4
+ * Ported out of `daemon.ts`. Pure path arithmetic, but it decides
5
+ * something with real consequences: **which volumes survive a
6
+ * delta-restore teardown**.
7
+ *
8
+ * Teardown wipes `/workspace` to give a restored environment fresh-state
9
+ * semantics. Anything that must survive it therefore has to live outside
10
+ * `/workspace` — and that is exactly what `cache: true` selects, by
11
+ * rooting the directory under `/var/cache/spectest/volumes` instead.
12
+ *
13
+ * The flag is only ever correct for **content-addressed accelerator
14
+ * data**: package stores, layer caches — data whose presence can change
15
+ * how *fast* something runs but never *what* it does. It is wrong for any
16
+ * real state, because a restored environment would then start with a
17
+ * previous run's data and stop being reproducible. (Counter-example worth
18
+ * remembering: `k3s()` deliberately does not cache its containerd store —
19
+ * a fresh cluster over an un-cleanly-killed store wedged the apiserver.)
20
+ */
21
+ /** Root of the per-environment state tree. Wiped by delta teardown. */
22
+ export declare const DEFAULT_WORKSPACE = "/workspace";
23
+ /** Root of the cache tree. Deliberately outside the workspace. */
24
+ export declare const CACHE_ROOT = "/var/cache/spectest/volumes";
25
+ /** Directory holding named shared volumes, under whichever root applies. */
26
+ export declare const SHARED_DIR = "_shared";
27
+ export interface VolumeSpec {
28
+ /** Named shared volume — every service mounting this name gets the same
29
+ * directory. Mutually exclusive with `source`. */
30
+ name?: string;
31
+ /** Host path. Absolute paths are used as-is; relative ones resolve under
32
+ * the service's own directory. */
33
+ source?: string;
34
+ /** Path inside the container. Used to derive a directory when neither
35
+ * `name` nor `source` is given. */
36
+ target: string;
37
+ /** Survive the delta-restore teardown. Content-addressed data only. */
38
+ cache?: boolean;
39
+ }
40
+ /**
41
+ * Make an arbitrary string safe as a single path segment.
42
+ *
43
+ * Strips leading slashes, replaces anything outside `[A-Za-z0-9_-]`, and
44
+ * trims the dashes that leaves at the edges. The character filter also
45
+ * removes `.`, so `..` cannot survive — a volume name or target can't
46
+ * escape its root.
47
+ */
48
+ export declare function sanitizeSegment(p: string): string;
49
+ /**
50
+ * The host directory backing one volume mount.
51
+ *
52
+ * Resolution order:
53
+ * 1. `name` — a shared directory under `_shared`, so two services
54
+ * mounting the same name genuinely share one directory.
55
+ * 2. An **absolute** `source` — used verbatim. The project asked for a
56
+ * specific path, so it gets it.
57
+ * 3. A relative `source`, or nothing at all — under the service's own
58
+ * directory, derived from `target` when `source` is absent.
59
+ *
60
+ * `workspace` is a parameter rather than a module constant so the rule is
61
+ * testable without touching the filesystem.
62
+ */
63
+ export declare function resolveHostPath(service: string, vol: VolumeSpec, workspace?: string): string;
64
+ /**
65
+ * Does this volume survive a delta-restore teardown?
66
+ *
67
+ * True for cache-flagged volumes and for absolute sources outside the
68
+ * workspace — the two ways a directory ends up beyond `rm -rf /workspace`.
69
+ */
70
+ export declare function survivesTeardown(vol: VolumeSpec, service: string, workspace?: string): boolean;
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Where a service's volume is backed on the VM's filesystem.
3
+ *
4
+ * Ported out of `daemon.ts`. Pure path arithmetic, but it decides
5
+ * something with real consequences: **which volumes survive a
6
+ * delta-restore teardown**.
7
+ *
8
+ * Teardown wipes `/workspace` to give a restored environment fresh-state
9
+ * semantics. Anything that must survive it therefore has to live outside
10
+ * `/workspace` — and that is exactly what `cache: true` selects, by
11
+ * rooting the directory under `/var/cache/spectest/volumes` instead.
12
+ *
13
+ * The flag is only ever correct for **content-addressed accelerator
14
+ * data**: package stores, layer caches — data whose presence can change
15
+ * how *fast* something runs but never *what* it does. It is wrong for any
16
+ * real state, because a restored environment would then start with a
17
+ * previous run's data and stop being reproducible. (Counter-example worth
18
+ * remembering: `k3s()` deliberately does not cache its containerd store —
19
+ * a fresh cluster over an un-cleanly-killed store wedged the apiserver.)
20
+ */
21
+ import path from "node:path";
22
+ /** Root of the per-environment state tree. Wiped by delta teardown. */
23
+ export const DEFAULT_WORKSPACE = "/workspace";
24
+ /** Root of the cache tree. Deliberately outside the workspace. */
25
+ export const CACHE_ROOT = "/var/cache/spectest/volumes";
26
+ /** Directory holding named shared volumes, under whichever root applies. */
27
+ export const SHARED_DIR = "_shared";
28
+ /**
29
+ * Make an arbitrary string safe as a single path segment.
30
+ *
31
+ * Strips leading slashes, replaces anything outside `[A-Za-z0-9_-]`, and
32
+ * trims the dashes that leaves at the edges. The character filter also
33
+ * removes `.`, so `..` cannot survive — a volume name or target can't
34
+ * escape its root.
35
+ */
36
+ export function sanitizeSegment(p) {
37
+ return p
38
+ .replace(/^\/+/, "")
39
+ .replace(/[^A-Za-z0-9_-]/g, "-")
40
+ .replace(/^-+|-+$/g, "");
41
+ }
42
+ /**
43
+ * The host directory backing one volume mount.
44
+ *
45
+ * Resolution order:
46
+ * 1. `name` — a shared directory under `_shared`, so two services
47
+ * mounting the same name genuinely share one directory.
48
+ * 2. An **absolute** `source` — used verbatim. The project asked for a
49
+ * specific path, so it gets it.
50
+ * 3. A relative `source`, or nothing at all — under the service's own
51
+ * directory, derived from `target` when `source` is absent.
52
+ *
53
+ * `workspace` is a parameter rather than a module constant so the rule is
54
+ * testable without touching the filesystem.
55
+ */
56
+ export function resolveHostPath(service, vol, workspace = DEFAULT_WORKSPACE) {
57
+ const stateRoot = [workspace, ".spectest", "volumes"];
58
+ if (vol.name) {
59
+ const root = vol.cache ? [CACHE_ROOT, SHARED_DIR] : [...stateRoot, SHARED_DIR];
60
+ return path.join(...root, sanitizeSegment(vol.name));
61
+ }
62
+ // An absolute source is the project's own path; `cache` doesn't apply
63
+ // because the location was already chosen explicitly.
64
+ if (vol.source && vol.source.startsWith("/"))
65
+ return vol.source;
66
+ const root = vol.cache ? [CACHE_ROOT, service] : [...stateRoot, service];
67
+ if (vol.source) {
68
+ return path.join(...root, vol.source.replace(/^\/+/, ""));
69
+ }
70
+ return path.join(...root, sanitizeSegment(vol.target));
71
+ }
72
+ /**
73
+ * Does this volume survive a delta-restore teardown?
74
+ *
75
+ * True for cache-flagged volumes and for absolute sources outside the
76
+ * workspace — the two ways a directory ends up beyond `rm -rf /workspace`.
77
+ */
78
+ export function survivesTeardown(vol, service, workspace = DEFAULT_WORKSPACE) {
79
+ const host = resolveHostPath(service, vol, workspace);
80
+ return !host.startsWith(`${workspace}/`) && host !== workspace;
81
+ }