@openparachute/hub 0.5.14-rc.5 → 0.5.14-rc.6

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/hub",
3
- "version": "0.5.14-rc.5",
3
+ "version": "0.5.14-rc.6",
4
4
  "description": "parachute \u2014 the local hub for the Parachute ecosystem (discovery, ports, lifecycle, soon OAuth).",
5
5
  "license": "AGPL-3.0",
6
6
  "publishConfig": {
@@ -0,0 +1,135 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { handleApiReady } from "../api-ready.ts";
3
+ import type { ModuleState, Supervisor } from "../supervisor.ts";
4
+
5
+ function stubSupervisor(states: ModuleState[]): Supervisor {
6
+ return {
7
+ list: () => states,
8
+ get: (short: string) => states.find((s) => s.short === short),
9
+ start: async () => {
10
+ throw new Error("not implemented");
11
+ },
12
+ stop: async () => undefined,
13
+ restart: async () => undefined,
14
+ } as unknown as Supervisor;
15
+ }
16
+
17
+ function req(): Request {
18
+ return new Request("http://127.0.0.1/api/ready", {
19
+ headers: { accept: "application/json" },
20
+ });
21
+ }
22
+
23
+ function moduleState(partial: Partial<ModuleState> & { short: string }): ModuleState {
24
+ return {
25
+ status: "running",
26
+ restartsInWindow: 0,
27
+ ...partial,
28
+ };
29
+ }
30
+
31
+ describe("handleApiReady — no supervisor (CLI mode)", () => {
32
+ test("returns ready=true + empty arrays when supervisor absent", async () => {
33
+ const res = handleApiReady(req());
34
+ expect(res.status).toBe(200);
35
+ const body = (await res.json()) as {
36
+ ready: boolean;
37
+ ready_modules: string[];
38
+ transient_modules: string[];
39
+ persistent_modules: string[];
40
+ };
41
+ expect(body.ready).toBe(true);
42
+ expect(body.ready_modules).toEqual([]);
43
+ expect(body.transient_modules).toEqual([]);
44
+ expect(body.persistent_modules).toEqual([]);
45
+ });
46
+ });
47
+
48
+ describe("handleApiReady — supervisor mode", () => {
49
+ test("all modules running past boot window → ready=true", async () => {
50
+ const now = 1_700_000_000_000;
51
+ const startedAt = new Date(now - 60_000).toISOString();
52
+ const sup = stubSupervisor([
53
+ moduleState({ short: "vault", status: "running", startedAt }),
54
+ moduleState({ short: "scribe", status: "running", startedAt }),
55
+ ]);
56
+ const res = handleApiReady(req(), { supervisor: sup, now: () => now });
57
+ const body = (await res.json()) as {
58
+ ready: boolean;
59
+ ready_modules: string[];
60
+ transient_modules: string[];
61
+ persistent_modules: string[];
62
+ };
63
+ expect(body.ready).toBe(true);
64
+ expect(body.ready_modules.sort()).toEqual(["scribe", "vault"]);
65
+ expect(body.transient_modules).toEqual([]);
66
+ expect(body.persistent_modules).toEqual([]);
67
+ });
68
+
69
+ test("module inside boot window → transient, ready=false", async () => {
70
+ const now = 1_700_000_000_000;
71
+ const sup = stubSupervisor([
72
+ moduleState({
73
+ short: "vault",
74
+ status: "running",
75
+ startedAt: new Date(now - 10_000).toISOString(),
76
+ }),
77
+ ]);
78
+ const res = handleApiReady(req(), { supervisor: sup, now: () => now });
79
+ const body = (await res.json()) as {
80
+ ready: boolean;
81
+ ready_modules: string[];
82
+ transient_modules: string[];
83
+ };
84
+ expect(body.ready).toBe(false);
85
+ expect(body.transient_modules).toEqual(["vault"]);
86
+ expect(body.ready_modules).toEqual([]);
87
+ });
88
+
89
+ test("starting + restarting + crashed all classified correctly", async () => {
90
+ const now = 1_700_000_000_000;
91
+ const sup = stubSupervisor([
92
+ moduleState({ short: "vault", status: "starting" }),
93
+ moduleState({ short: "scribe", status: "restarting" }),
94
+ moduleState({ short: "notes", status: "crashed" }),
95
+ moduleState({ short: "channel", status: "stopped" }),
96
+ moduleState({
97
+ short: "runner",
98
+ status: "running",
99
+ startedAt: new Date(now - 60_000).toISOString(),
100
+ }),
101
+ ]);
102
+ const res = handleApiReady(req(), { supervisor: sup, now: () => now });
103
+ const body = (await res.json()) as {
104
+ ready: boolean;
105
+ ready_modules: string[];
106
+ transient_modules: string[];
107
+ persistent_modules: string[];
108
+ };
109
+ expect(body.ready).toBe(false);
110
+ expect(body.ready_modules).toEqual(["runner"]);
111
+ expect(body.transient_modules.sort()).toEqual(["scribe", "vault"]);
112
+ expect(body.persistent_modules.sort()).toEqual(["channel", "notes"]);
113
+ });
114
+
115
+ test("only crashed/stopped + nothing transient → ready=false (still failing)", async () => {
116
+ const sup = stubSupervisor([moduleState({ short: "vault", status: "crashed" })]);
117
+ const res = handleApiReady(req(), { supervisor: sup });
118
+ const body = (await res.json()) as { ready: boolean };
119
+ expect(body.ready).toBe(false);
120
+ });
121
+ });
122
+
123
+ describe("handleApiReady — method check", () => {
124
+ test("rejects non-GET", () => {
125
+ const r = new Request("http://127.0.0.1/api/ready", { method: "POST" });
126
+ const res = handleApiReady(r);
127
+ expect(res.status).toBe(405);
128
+ });
129
+
130
+ test("accepts HEAD", () => {
131
+ const r = new Request("http://127.0.0.1/api/ready", { method: "HEAD" });
132
+ const res = handleApiReady(r);
133
+ expect(res.status).toBe(200);
134
+ });
135
+ });
@@ -17,6 +17,7 @@ import { clearNotesRedirectLogState } from "../notes-redirect.ts";
17
17
  import { pidPath } from "../process-state.ts";
18
18
  import { type ServiceEntry, writeManifest } from "../services-manifest.ts";
19
19
  import { rotateSigningKey } from "../signing-keys.ts";
20
+ import type { ModuleState, Supervisor } from "../supervisor.ts";
20
21
  import { createUser } from "../users.ts";
21
22
 
22
23
  interface Harness {
@@ -42,6 +43,33 @@ function mkdirIfMissing(dir: string): void {
42
43
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
43
44
  }
44
45
 
46
+ /**
47
+ * Minimal stub of the Supervisor surface that proxyRequest's classifier
48
+ * reads from. We don't drive real Bun.spawn — just hand back hand-crafted
49
+ * ModuleState values so the test can pin "vault is starting" / "vault has
50
+ * been running for a minute" exactly. See `supervisor.test.ts` for the
51
+ * real lifecycle tests.
52
+ */
53
+ function stubSupervisor(states: ModuleState[]): Supervisor {
54
+ return {
55
+ list: () => states,
56
+ get: (short: string) => states.find((s) => s.short === short),
57
+ start: async () => {
58
+ throw new Error("not implemented");
59
+ },
60
+ stop: async () => undefined,
61
+ restart: async () => undefined,
62
+ } as unknown as Supervisor;
63
+ }
64
+
65
+ function moduleState(partial: Partial<ModuleState> & { short: string }): ModuleState {
66
+ return {
67
+ status: "running",
68
+ restartsInWindow: 0,
69
+ ...partial,
70
+ };
71
+ }
72
+
45
73
  function vaultEntry(name: string): ServiceEntry {
46
74
  return {
47
75
  name: `parachute-vault-${name}`,
@@ -1912,10 +1940,12 @@ describe("hubFetch /vault/<name>/* dynamic proxy (#144)", () => {
1912
1940
  }
1913
1941
  });
1914
1942
 
1915
- test("returns 502 when the matching vault upstream is unreachable", async () => {
1943
+ test("returns 502 with persistent-state JSON when the matching vault upstream is unreachable", async () => {
1916
1944
  // Vault is in services.json but the port has nothing listening — vault
1917
1945
  // crashed, port shifted, or the user is mid-restart. We owe the caller a
1918
- // useful error instead of a hang or a silent 404.
1946
+ // useful error instead of a hang or a silent 404. No supervisor +
1947
+ // no pidfile → classifier returns "persistent" → 502 + admin_url
1948
+ // (hub#443 boot-readiness gating).
1919
1949
  const h = makeHarness();
1920
1950
  try {
1921
1951
  writeManifest(
@@ -1934,10 +1964,261 @@ describe("hubFetch /vault/<name>/* dynamic proxy (#144)", () => {
1934
1964
  h.manifestPath,
1935
1965
  );
1936
1966
  const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
1937
- const res = await fetcher(req("/vault/default/health"));
1967
+ const res = await fetcher(
1968
+ req("/vault/default/health", { headers: { accept: "application/json" } }),
1969
+ );
1970
+ expect(res.status).toBe(502);
1971
+ const body = (await res.json()) as {
1972
+ error: string;
1973
+ error_type: string;
1974
+ admin_url?: string;
1975
+ service: string;
1976
+ };
1977
+ expect(body.error_type).toBe("upstream_unreachable");
1978
+ expect(body.error).toBe("upstream_unreachable");
1979
+ expect(body.admin_url).toBe("/admin/modules");
1980
+ expect(body.service).toBe("vault");
1981
+ } finally {
1982
+ h.cleanup();
1983
+ }
1984
+ });
1985
+
1986
+ test("transient state (supervisor reports starting) → 503 + Retry-After when fetch fails", async () => {
1987
+ // Supervisor says vault is `starting` — the loopback port hasn't bound
1988
+ // yet, fetch fails with ECONNREFUSED. Classifier returns "transient",
1989
+ // proxy responds 503 with a Retry-After hint instead of 502.
1990
+ const h = makeHarness();
1991
+ try {
1992
+ writeManifest(
1993
+ {
1994
+ services: [
1995
+ {
1996
+ name: "parachute-vault",
1997
+ port: await pickClosedPort(),
1998
+ paths: ["/vault/default"],
1999
+ health: "/vault/default/health",
2000
+ version: "0.4.0",
2001
+ },
2002
+ ],
2003
+ },
2004
+ h.manifestPath,
2005
+ );
2006
+ const supervisor = stubSupervisor([moduleState({ short: "vault", status: "starting" })]);
2007
+ const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath, supervisor });
2008
+ const res = await fetcher(
2009
+ req("/vault/default/health", { headers: { accept: "application/json" } }),
2010
+ );
2011
+ expect(res.status).toBe(503);
2012
+ expect(res.headers.get("retry-after")).toBe("2");
2013
+ const body = (await res.json()) as {
2014
+ error_type: string;
2015
+ retry_after_ms: number;
2016
+ max_attempts: number;
2017
+ admin_url?: string;
2018
+ };
2019
+ expect(body.error_type).toBe("upstream_starting");
2020
+ expect(body.retry_after_ms).toBe(2000);
2021
+ expect(body.max_attempts).toBe(5);
2022
+ // Transient JSON MUST NOT carry an admin link.
2023
+ expect(body.admin_url).toBeUndefined();
2024
+ } finally {
2025
+ h.cleanup();
2026
+ }
2027
+ });
2028
+
2029
+ test("transient state + Accept: text/html → 503 HTML page with auto-refresh + poll, no admin link", async () => {
2030
+ const h = makeHarness();
2031
+ try {
2032
+ writeManifest(
2033
+ {
2034
+ services: [
2035
+ {
2036
+ name: "parachute-vault",
2037
+ port: await pickClosedPort(),
2038
+ paths: ["/vault/default"],
2039
+ health: "/vault/default/health",
2040
+ version: "0.4.0",
2041
+ },
2042
+ ],
2043
+ },
2044
+ h.manifestPath,
2045
+ );
2046
+ const supervisor = stubSupervisor([moduleState({ short: "vault", status: "starting" })]);
2047
+ const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath, supervisor });
2048
+ const res = await fetcher(req("/vault/default/health", { headers: { accept: "text/html" } }));
2049
+ expect(res.status).toBe(503);
2050
+ expect(res.headers.get("content-type")).toBe("text/html; charset=utf-8");
2051
+ const text = await res.text();
2052
+ expect(text).toContain(`<meta http-equiv="refresh"`);
2053
+ expect(text).toContain("/api/ready");
2054
+ expect(text).toContain("Just a moment");
2055
+ // Aaron design (d): transient page has no admin link.
2056
+ expect(text).not.toContain("/admin/modules");
2057
+ } finally {
2058
+ h.cleanup();
2059
+ }
2060
+ });
2061
+
2062
+ test("persistent state + Accept: text/html → 502 HTML page with admin link, no auto-refresh", async () => {
2063
+ const h = makeHarness();
2064
+ try {
2065
+ writeManifest(
2066
+ {
2067
+ services: [
2068
+ {
2069
+ name: "parachute-vault",
2070
+ port: await pickClosedPort(),
2071
+ paths: ["/vault/default"],
2072
+ health: "/vault/default/health",
2073
+ version: "0.4.0",
2074
+ },
2075
+ ],
2076
+ },
2077
+ h.manifestPath,
2078
+ );
2079
+ const supervisor = stubSupervisor([moduleState({ short: "vault", status: "crashed" })]);
2080
+ const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath, supervisor });
2081
+ const res = await fetcher(req("/vault/default/health", { headers: { accept: "text/html" } }));
1938
2082
  expect(res.status).toBe(502);
1939
- const body = (await res.json()) as { error: string };
1940
- expect(body.error).toContain("vault upstream unreachable");
2083
+ expect(res.headers.get("content-type")).toBe("text/html; charset=utf-8");
2084
+ expect(res.headers.get("retry-after")).toBeNull();
2085
+ const text = await res.text();
2086
+ expect(text).toContain("Module unreachable");
2087
+ expect(text).toContain("/admin/modules");
2088
+ expect(text).not.toContain(`http-equiv="refresh"`);
2089
+ } finally {
2090
+ h.cleanup();
2091
+ }
2092
+ });
2093
+
2094
+ test("supervisor running-but-fresh-startedAt → transient classification", async () => {
2095
+ // Supervisor says vault is running, but startedAt is recent enough that
2096
+ // we trust the boot-window heuristic over the failed fetch.
2097
+ const h = makeHarness();
2098
+ try {
2099
+ writeManifest(
2100
+ {
2101
+ services: [
2102
+ {
2103
+ name: "parachute-vault",
2104
+ port: await pickClosedPort(),
2105
+ paths: ["/vault/default"],
2106
+ health: "/vault/default/health",
2107
+ version: "0.4.0",
2108
+ },
2109
+ ],
2110
+ },
2111
+ h.manifestPath,
2112
+ );
2113
+ const supervisor = stubSupervisor([
2114
+ moduleState({
2115
+ short: "vault",
2116
+ status: "running",
2117
+ startedAt: new Date(Date.now() - 5_000).toISOString(),
2118
+ }),
2119
+ ]);
2120
+ const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath, supervisor });
2121
+ const res = await fetcher(
2122
+ req("/vault/default/health", { headers: { accept: "application/json" } }),
2123
+ );
2124
+ expect(res.status).toBe(503);
2125
+ const body = (await res.json()) as { error_type: string };
2126
+ expect(body.error_type).toBe("upstream_starting");
2127
+ } finally {
2128
+ h.cleanup();
2129
+ }
2130
+ });
2131
+
2132
+ test("supervisor running-but-old-startedAt → persistent (boot window expired)", async () => {
2133
+ const h = makeHarness();
2134
+ try {
2135
+ writeManifest(
2136
+ {
2137
+ services: [
2138
+ {
2139
+ name: "parachute-vault",
2140
+ port: await pickClosedPort(),
2141
+ paths: ["/vault/default"],
2142
+ health: "/vault/default/health",
2143
+ version: "0.4.0",
2144
+ },
2145
+ ],
2146
+ },
2147
+ h.manifestPath,
2148
+ );
2149
+ const supervisor = stubSupervisor([
2150
+ moduleState({
2151
+ short: "vault",
2152
+ status: "running",
2153
+ startedAt: new Date(Date.now() - 120_000).toISOString(),
2154
+ }),
2155
+ ]);
2156
+ const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath, supervisor });
2157
+ const res = await fetcher(
2158
+ req("/vault/default/health", { headers: { accept: "application/json" } }),
2159
+ );
2160
+ expect(res.status).toBe(502);
2161
+ const body = (await res.json()) as { error_type: string };
2162
+ expect(body.error_type).toBe("upstream_unreachable");
2163
+ } finally {
2164
+ h.cleanup();
2165
+ }
2166
+ });
2167
+
2168
+ test("non-vault upstream (scribe) classified through supervisor when starting", async () => {
2169
+ // Same logic as vault, but exercising the generic /<svc>/* dispatch path.
2170
+ const h = makeHarness();
2171
+ try {
2172
+ writeManifest(
2173
+ {
2174
+ services: [
2175
+ {
2176
+ name: "scribe",
2177
+ port: await pickClosedPort(),
2178
+ paths: ["/scribe"],
2179
+ health: "/scribe/health",
2180
+ version: "0.1.0",
2181
+ },
2182
+ ],
2183
+ },
2184
+ h.manifestPath,
2185
+ );
2186
+ const supervisor = stubSupervisor([moduleState({ short: "scribe", status: "starting" })]);
2187
+ const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath, supervisor });
2188
+ const res = await fetcher(req("/scribe/health", { headers: { accept: "application/json" } }));
2189
+ expect(res.status).toBe(503);
2190
+ const body = (await res.json()) as { error_type: string; service: string };
2191
+ expect(body.error_type).toBe("upstream_starting");
2192
+ expect(body.service).toBe("scribe");
2193
+ } finally {
2194
+ h.cleanup();
2195
+ }
2196
+ });
2197
+
2198
+ test("/api/ready returns supervisor view + is reachable pre-admin", async () => {
2199
+ // hub#443 endpoint is public + pre-admin (it has to be — the page that
2200
+ // polls it is itself served pre-auth when modules are still booting).
2201
+ const h = makeHarness();
2202
+ try {
2203
+ const supervisor = stubSupervisor([
2204
+ moduleState({ short: "vault", status: "starting" }),
2205
+ moduleState({
2206
+ short: "scribe",
2207
+ status: "running",
2208
+ startedAt: new Date(Date.now() - 60_000).toISOString(),
2209
+ }),
2210
+ ]);
2211
+ const fetcher = hubFetch(h.dir, { supervisor });
2212
+ const res = await fetcher(req("/api/ready"));
2213
+ expect(res.status).toBe(200);
2214
+ const body = (await res.json()) as {
2215
+ ready: boolean;
2216
+ ready_modules: string[];
2217
+ transient_modules: string[];
2218
+ };
2219
+ expect(body.ready).toBe(false);
2220
+ expect(body.transient_modules).toEqual(["vault"]);
2221
+ expect(body.ready_modules).toEqual(["scribe"]);
1941
2222
  } finally {
1942
2223
  h.cleanup();
1943
2224
  }
@@ -2562,9 +2843,10 @@ describe("hubFetch /<svc>/* generic proxy dispatch (#182)", () => {
2562
2843
  }
2563
2844
  });
2564
2845
 
2565
- test("returns 502 when the matching upstream is unreachable", async () => {
2846
+ test("returns 502 with persistent-state JSON when the matching upstream is unreachable", async () => {
2566
2847
  // Service is in services.json but the port has nothing listening — same
2567
- // shape as the vault-unreachable test, label is the entry's `name`.
2848
+ // shape as the vault-unreachable test (hub#443 boot-readiness gating).
2849
+ // No supervisor + no pidfile → persistent → 502 with admin_url.
2568
2850
  const h = makeHarness();
2569
2851
  try {
2570
2852
  writeManifest(
@@ -2582,10 +2864,17 @@ describe("hubFetch /<svc>/* generic proxy dispatch (#182)", () => {
2582
2864
  h.manifestPath,
2583
2865
  );
2584
2866
  const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
2585
- const res = await fetcher(req("/scribe/health"));
2867
+ const res = await fetcher(req("/scribe/health", { headers: { accept: "application/json" } }));
2586
2868
  expect(res.status).toBe(502);
2587
- const body = (await res.json()) as { error: string };
2588
- expect(body.error).toContain("scribe upstream unreachable");
2869
+ const body = (await res.json()) as {
2870
+ error: string;
2871
+ error_type: string;
2872
+ admin_url?: string;
2873
+ service: string;
2874
+ };
2875
+ expect(body.error_type).toBe("upstream_unreachable");
2876
+ expect(body.admin_url).toBe("/admin/modules");
2877
+ expect(body.service).toBe("scribe");
2589
2878
  } finally {
2590
2879
  h.cleanup();
2591
2880
  }