patchwork-os 0.2.0-beta.4 → 0.2.0-beta.5.canary.1

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,29 @@
1
+ /**
2
+ * On-disk config for the opt-in telemetry collector (endpoint + shared secret).
3
+ *
4
+ * Lives at ~/.claude/ide/analytics-config.json (respects CLAUDE_CONFIG_DIR).
5
+ * Mode 0600. Atomic writes via temp + rename.
6
+ *
7
+ * Resolution order in analyticsSend.ts:
8
+ * 1. process.env.PATCHWORK_ANALYTICS_ENDPOINT / _KEY (highest)
9
+ * 2. this file
10
+ * 3. upstream default (no key)
11
+ *
12
+ * Reason for the file: keeps the shared secret out of launchd plists
13
+ * (which are Time-Machine backed, iCloud synced, and survive reinstalls
14
+ * in an inconsistent way) and gives operators a single source of truth
15
+ * managed by the `patchwork analytics configure` CLI.
16
+ */
17
+ export interface AnalyticsConfig {
18
+ endpoint?: string;
19
+ key?: string;
20
+ }
21
+ export declare function configPath(): string;
22
+ /** Read the config file. Returns empty object on missing/invalid file. */
23
+ export declare function getAnalyticsConfig(): AnalyticsConfig;
24
+ /**
25
+ * Atomic write with mode 0600. Merges with existing config — pass
26
+ * `{ endpoint: undefined }` to clear a field explicitly.
27
+ */
28
+ export declare function setAnalyticsConfig(update: AnalyticsConfig): void;
29
+ export declare function clearAnalyticsConfig(): void;
@@ -0,0 +1,89 @@
1
+ /**
2
+ * On-disk config for the opt-in telemetry collector (endpoint + shared secret).
3
+ *
4
+ * Lives at ~/.claude/ide/analytics-config.json (respects CLAUDE_CONFIG_DIR).
5
+ * Mode 0600. Atomic writes via temp + rename.
6
+ *
7
+ * Resolution order in analyticsSend.ts:
8
+ * 1. process.env.PATCHWORK_ANALYTICS_ENDPOINT / _KEY (highest)
9
+ * 2. this file
10
+ * 3. upstream default (no key)
11
+ *
12
+ * Reason for the file: keeps the shared secret out of launchd plists
13
+ * (which are Time-Machine backed, iCloud synced, and survive reinstalls
14
+ * in an inconsistent way) and gives operators a single source of truth
15
+ * managed by the `patchwork analytics configure` CLI.
16
+ */
17
+ import fs from "node:fs";
18
+ import os from "node:os";
19
+ import path from "node:path";
20
+ export function configPath() {
21
+ const claudeDir = process.env.CLAUDE_CONFIG_DIR ?? path.join(os.homedir(), ".claude");
22
+ return path.join(claudeDir, "ide", "analytics-config.json");
23
+ }
24
+ function isValidEndpoint(value) {
25
+ if (typeof value !== "string" || value.length === 0)
26
+ return false;
27
+ try {
28
+ const u = new URL(value);
29
+ return u.protocol === "http:" || u.protocol === "https:";
30
+ }
31
+ catch {
32
+ return false;
33
+ }
34
+ }
35
+ /** Read the config file. Returns empty object on missing/invalid file. */
36
+ export function getAnalyticsConfig() {
37
+ try {
38
+ const raw = fs.readFileSync(configPath(), "utf-8");
39
+ const obj = JSON.parse(raw);
40
+ if (typeof obj !== "object" || obj === null)
41
+ return {};
42
+ const rec = obj;
43
+ const out = {};
44
+ if (isValidEndpoint(rec.endpoint))
45
+ out.endpoint = rec.endpoint;
46
+ if (typeof rec.key === "string" && rec.key.length > 0)
47
+ out.key = rec.key;
48
+ return out;
49
+ }
50
+ catch {
51
+ return {};
52
+ }
53
+ }
54
+ /**
55
+ * Atomic write with mode 0600. Merges with existing config — pass
56
+ * `{ endpoint: undefined }` to clear a field explicitly.
57
+ */
58
+ export function setAnalyticsConfig(update) {
59
+ const current = getAnalyticsConfig();
60
+ const next = { ...current };
61
+ if ("endpoint" in update) {
62
+ if (update.endpoint === undefined)
63
+ delete next.endpoint;
64
+ else if (isValidEndpoint(update.endpoint))
65
+ next.endpoint = update.endpoint;
66
+ else
67
+ throw new Error(`invalid endpoint (must be http(s) URL): ${update.endpoint}`);
68
+ }
69
+ if ("key" in update) {
70
+ if (update.key === undefined)
71
+ delete next.key;
72
+ else
73
+ next.key = update.key;
74
+ }
75
+ const p = configPath();
76
+ fs.mkdirSync(path.dirname(p), { recursive: true });
77
+ const tmp = `${p}.tmp.${process.pid}`;
78
+ fs.writeFileSync(tmp, JSON.stringify(next, null, 2) + "\n", { mode: 0o600 });
79
+ fs.renameSync(tmp, p);
80
+ }
81
+ export function clearAnalyticsConfig() {
82
+ try {
83
+ fs.unlinkSync(configPath());
84
+ }
85
+ catch {
86
+ /* file may not exist */
87
+ }
88
+ }
89
+ //# sourceMappingURL=analyticsConfig.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"analyticsConfig.js","sourceRoot":"","sources":["../src/analyticsConfig.ts"],"names":[],"mappings":"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"}
@@ -2,9 +2,25 @@
2
2
  * Sends an anonymized analytics summary to the usage endpoint.
3
3
  * - Fire-and-forget is NOT used: callers must await this with a timeout race.
4
4
  * - All errors are caught and swallowed — telemetry must never affect bridge operation.
5
- * - Endpoint is hardcoded (not runtime-configurable) to prevent redirect attacks.
5
+ * - Endpoint defaults to the upstream collector. Operators may override via:
6
+ * 1. env: PATCHWORK_ANALYTICS_ENDPOINT (highest precedence; for CI/headless)
7
+ * 2. config file: ~/.claude/ide/analytics-config.json (preferred — managed by
8
+ * `patchwork analytics configure`, keeps secrets out of launchd plists)
9
+ * 3. default upstream collector
10
+ * Endpoint is resolved per-call (cheap fs read) so live config changes take
11
+ * effect on the next send without restart. Invalid values fall back through.
12
+ * Never read from the network or summary payload — preserves the
13
+ * redirect-attack property the original hardcoded constant was protecting.
6
14
  */
7
15
  import type { AnalyticsSummary } from "./analyticsAggregator.js";
16
+ export declare function resolveAnalyticsTarget(): {
17
+ endpoint: string;
18
+ key: string | undefined;
19
+ source: {
20
+ endpoint: "env" | "config" | "default";
21
+ key: "env" | "config" | "none";
22
+ };
23
+ };
8
24
  /**
9
25
  * Sends the summary to the analytics endpoint.
10
26
  * Resolves (never rejects) — all errors are swallowed silently.
@@ -2,11 +2,63 @@
2
2
  * Sends an anonymized analytics summary to the usage endpoint.
3
3
  * - Fire-and-forget is NOT used: callers must await this with a timeout race.
4
4
  * - All errors are caught and swallowed — telemetry must never affect bridge operation.
5
- * - Endpoint is hardcoded (not runtime-configurable) to prevent redirect attacks.
5
+ * - Endpoint defaults to the upstream collector. Operators may override via:
6
+ * 1. env: PATCHWORK_ANALYTICS_ENDPOINT (highest precedence; for CI/headless)
7
+ * 2. config file: ~/.claude/ide/analytics-config.json (preferred — managed by
8
+ * `patchwork analytics configure`, keeps secrets out of launchd plists)
9
+ * 3. default upstream collector
10
+ * Endpoint is resolved per-call (cheap fs read) so live config changes take
11
+ * effect on the next send without restart. Invalid values fall back through.
12
+ * Never read from the network or summary payload — preserves the
13
+ * redirect-attack property the original hardcoded constant was protecting.
6
14
  */
15
+ import { getAnalyticsConfig } from "./analyticsConfig.js";
7
16
  import { recordAnalyticsSent } from "./analyticsPrefs.js";
8
- /** Hardcoded endpoint — not configurable at runtime. */
9
- const ANALYTICS_ENDPOINT = "https://analytics.claude-ide-bridge.dev/v1/usage";
17
+ const DEFAULT_ENDPOINT = "https://analytics.claude-ide-bridge.dev/v1/usage";
18
+ function validUrl(value) {
19
+ if (!value)
20
+ return undefined;
21
+ try {
22
+ const u = new URL(value);
23
+ if (u.protocol !== "https:" && u.protocol !== "http:")
24
+ return undefined;
25
+ return u.toString();
26
+ }
27
+ catch {
28
+ return undefined;
29
+ }
30
+ }
31
+ export function resolveAnalyticsTarget() {
32
+ const envEndpoint = validUrl(process.env.PATCHWORK_ANALYTICS_ENDPOINT);
33
+ const envKey = process.env.PATCHWORK_ANALYTICS_KEY;
34
+ const cfg = getAnalyticsConfig();
35
+ const cfgEndpoint = validUrl(cfg.endpoint);
36
+ let endpoint = DEFAULT_ENDPOINT;
37
+ let endpointSource = "default";
38
+ if (envEndpoint) {
39
+ endpoint = envEndpoint;
40
+ endpointSource = "env";
41
+ }
42
+ else if (cfgEndpoint) {
43
+ endpoint = cfgEndpoint;
44
+ endpointSource = "config";
45
+ }
46
+ let key;
47
+ let keySource = "none";
48
+ if (envKey) {
49
+ key = envKey;
50
+ keySource = "env";
51
+ }
52
+ else if (cfg.key) {
53
+ key = cfg.key;
54
+ keySource = "config";
55
+ }
56
+ return {
57
+ endpoint,
58
+ key,
59
+ source: { endpoint: endpointSource, key: keySource },
60
+ };
61
+ }
10
62
  const SEND_TIMEOUT_MS = 3000;
11
63
  /**
12
64
  * Sends the summary to the analytics endpoint.
@@ -14,12 +66,18 @@ const SEND_TIMEOUT_MS = 3000;
14
66
  */
15
67
  export async function sendAnalytics(summary) {
16
68
  try {
69
+ const { endpoint, key } = resolveAnalyticsTarget();
17
70
  const controller = new AbortController();
18
71
  const timer = setTimeout(() => controller.abort(), SEND_TIMEOUT_MS);
19
72
  try {
20
- const res = await fetch(ANALYTICS_ENDPOINT, {
73
+ const headers = {
74
+ "Content-Type": "application/json",
75
+ };
76
+ if (key)
77
+ headers["X-Analytics-Key"] = key;
78
+ const res = await fetch(endpoint, {
21
79
  method: "POST",
22
- headers: { "Content-Type": "application/json" },
80
+ headers,
23
81
  body: JSON.stringify(summary),
24
82
  signal: controller.signal,
25
83
  });
@@ -1 +1 @@
1
- {"version":3,"file":"analyticsSend.js","sourceRoot":"","sources":["../src/analyticsSend.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAGH,OAAO,EAAE,mBAAmB,EAAE,MAAM,qBAAqB,CAAC;AAE1D,wDAAwD;AACxD,MAAM,kBAAkB,GAAG,kDAAkD,CAAC;AAE9E,MAAM,eAAe,GAAG,IAAI,CAAC;AAE7B;;;GAGG;AACH,MAAM,CAAC,KAAK,UAAU,aAAa,CAAC,OAAyB;IAC3D,IAAI,CAAC;QACH,MAAM,UAAU,GAAG,IAAI,eAAe,EAAE,CAAC;QACzC,MAAM,KAAK,GAAG,UAAU,CAAC,GAAG,EAAE,CAAC,UAAU,CAAC,KAAK,EAAE,EAAE,eAAe,CAAC,CAAC;QACpE,IAAI,CAAC;YACH,MAAM,GAAG,GAAG,MAAM,KAAK,CAAC,kBAAkB,EAAE;gBAC1C,MAAM,EAAE,MAAM;gBACd,OAAO,EAAE,EAAE,cAAc,EAAE,kBAAkB,EAAE;gBAC/C,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC,OAAO,CAAC;gBAC7B,MAAM,EAAE,UAAU,CAAC,MAAM;aAC1B,CAAC,CAAC;YACH,IAAI,GAAG,CAAC,EAAE,EAAE,CAAC;gBACX,mBAAmB,EAAE,CAAC;YACxB,CAAC;QACH,CAAC;gBAAS,CAAC;YACT,YAAY,CAAC,KAAK,CAAC,CAAC;QACtB,CAAC;IACH,CAAC;IAAC,MAAM,CAAC;QACP,yEAAyE;IAC3E,CAAC;AACH,CAAC"}
1
+ {"version":3,"file":"analyticsSend.js","sourceRoot":"","sources":["../src/analyticsSend.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,8 @@
1
+ /**
2
+ * `patchwork analytics` CLI — manage the self-hosted telemetry collector config.
3
+ *
4
+ * Replaces the brittle pattern of putting endpoint + secret in a launchd plist.
5
+ * Config lives at ~/.claude/ide/analytics-config.json (mode 0600), read on every
6
+ * send. Env vars still win for headless / CI use.
7
+ */
8
+ export declare function runAnalyticsCommand(argv: string[]): Promise<number>;
@@ -0,0 +1,134 @@
1
+ /**
2
+ * `patchwork analytics` CLI — manage the self-hosted telemetry collector config.
3
+ *
4
+ * Replaces the brittle pattern of putting endpoint + secret in a launchd plist.
5
+ * Config lives at ~/.claude/ide/analytics-config.json (mode 0600), read on every
6
+ * send. Env vars still win for headless / CI use.
7
+ */
8
+ import { clearAnalyticsConfig, configPath, getAnalyticsConfig, setAnalyticsConfig, } from "../analyticsConfig.js";
9
+ import { resolveAnalyticsTarget, sendAnalytics } from "../analyticsSend.js";
10
+ const USAGE = "Usage: patchwork analytics <subcommand>\n\n" +
11
+ " show Print active endpoint, key (masked), and source\n" +
12
+ " configure --endpoint URL [--key KEY] Write endpoint and/or key to the config file\n" +
13
+ " clear Remove the config file\n" +
14
+ " test Send a tiny synthetic payload and report HTTP status\n" +
15
+ "\n" +
16
+ "Resolution order: env (PATCHWORK_ANALYTICS_ENDPOINT/_KEY) > config file > upstream default.\n";
17
+ function maskKey(key) {
18
+ if (!key)
19
+ return "<unset>";
20
+ if (key.length <= 8)
21
+ return "***";
22
+ return `${key.slice(0, 4)}…${key.slice(-4)} (len=${key.length})`;
23
+ }
24
+ function parseFlags(args) {
25
+ const out = {};
26
+ for (let i = 0; i < args.length; i++) {
27
+ const a = args[i];
28
+ if (!a || !a.startsWith("--"))
29
+ continue;
30
+ const name = a.slice(2);
31
+ const next = args[i + 1];
32
+ if (next && !next.startsWith("--")) {
33
+ out[name] = next;
34
+ i++;
35
+ }
36
+ else {
37
+ out[name] = true;
38
+ }
39
+ }
40
+ return out;
41
+ }
42
+ export async function runAnalyticsCommand(argv) {
43
+ const sub = argv[0];
44
+ if (!sub || sub === "--help" || sub === "-h") {
45
+ process.stdout.write(USAGE);
46
+ return sub ? 0 : 1;
47
+ }
48
+ if (sub === "show") {
49
+ const target = resolveAnalyticsTarget();
50
+ const cfg = getAnalyticsConfig();
51
+ process.stdout.write(`Active endpoint: ${target.endpoint} (source: ${target.source.endpoint})\n` +
52
+ `Active key: ${maskKey(target.key)} (source: ${target.source.key})\n` +
53
+ `Config file: ${configPath()}\n` +
54
+ ` endpoint: ${cfg.endpoint ?? "<unset>"}\n` +
55
+ ` key: ${maskKey(cfg.key)}\n` +
56
+ `Env:\n` +
57
+ ` PATCHWORK_ANALYTICS_ENDPOINT: ${process.env.PATCHWORK_ANALYTICS_ENDPOINT ?? "<unset>"}\n` +
58
+ ` PATCHWORK_ANALYTICS_KEY: ${maskKey(process.env.PATCHWORK_ANALYTICS_KEY)}\n`);
59
+ return 0;
60
+ }
61
+ if (sub === "configure") {
62
+ const flags = parseFlags(argv.slice(1));
63
+ const update = {};
64
+ if (typeof flags.endpoint === "string")
65
+ update.endpoint = flags.endpoint;
66
+ if (typeof flags.key === "string")
67
+ update.key = flags.key;
68
+ if (Object.keys(update).length === 0) {
69
+ process.stderr.write("configure requires at least --endpoint URL or --key KEY\n");
70
+ return 1;
71
+ }
72
+ try {
73
+ setAnalyticsConfig(update);
74
+ }
75
+ catch (err) {
76
+ process.stderr.write(`configure failed: ${err instanceof Error ? err.message : String(err)}\n`);
77
+ return 1;
78
+ }
79
+ process.stdout.write(`wrote ${configPath()}\n`);
80
+ return 0;
81
+ }
82
+ if (sub === "clear") {
83
+ clearAnalyticsConfig();
84
+ process.stdout.write(`removed ${configPath()}\n`);
85
+ return 0;
86
+ }
87
+ if (sub === "test") {
88
+ const target = resolveAnalyticsTarget();
89
+ process.stdout.write(`sending synthetic summary to ${target.endpoint} (key source: ${target.source.key})…\n`);
90
+ // Build minimal real-shaped summary
91
+ const summary = {
92
+ bridgeVersion: "cli-test",
93
+ sessionDurationMs: 1,
94
+ toolStats: [
95
+ { tool: "getDiagnostics", calls: 1, errors: 0, p50Ms: 1, p95Ms: 1 },
96
+ ],
97
+ };
98
+ // sendAnalytics swallows errors, so we shadow it with a direct fetch
99
+ // to surface the actual HTTP status to the operator.
100
+ try {
101
+ const controller = new AbortController();
102
+ const timer = setTimeout(() => controller.abort(), 5000);
103
+ const headers = {
104
+ "Content-Type": "application/json",
105
+ };
106
+ if (target.key)
107
+ headers["X-Analytics-Key"] = target.key;
108
+ const res = await fetch(target.endpoint, {
109
+ method: "POST",
110
+ headers,
111
+ body: JSON.stringify(summary),
112
+ signal: controller.signal,
113
+ });
114
+ clearTimeout(timer);
115
+ process.stdout.write(`HTTP ${res.status}\n`);
116
+ if (!res.ok) {
117
+ const body = await res.text().catch(() => "");
118
+ if (body)
119
+ process.stdout.write(`body: ${body.slice(0, 500)}\n`);
120
+ return 1;
121
+ }
122
+ // Also exercise the real send path so prefs.lastSentAt updates.
123
+ await sendAnalytics(summary);
124
+ return 0;
125
+ }
126
+ catch (err) {
127
+ process.stderr.write(`test failed: ${err instanceof Error ? err.message : String(err)}\n`);
128
+ return 1;
129
+ }
130
+ }
131
+ process.stderr.write(`unknown subcommand: ${sub}\n\n${USAGE}`);
132
+ return 1;
133
+ }
134
+ //# sourceMappingURL=analytics.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"analytics.js","sourceRoot":"","sources":["../../src/commands/analytics.ts"],"names":[],"mappings":"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"}
package/dist/index.js CHANGED
@@ -134,6 +134,7 @@ const KNOWN_SUBCOMMANDS = [
134
134
  "panic",
135
135
  "halts",
136
136
  "judgments",
137
+ "analytics",
137
138
  ];
138
139
  const __invokedSubcommand = (() => {
139
140
  const sub = process.argv[2];
@@ -193,8 +194,12 @@ if (process.argv[2] === "--help" ||
193
194
  ` recipe --help Full recipe subcommand index\n\n` +
194
195
  `Diagnose\n` +
195
196
  ` halts [--window 1h|24h|overnight|7d] Morning summary of recent recipe halts\n` +
197
+ ` judgments [--window ...] [--recipe N] Recent judge-step verdicts across runs\n` +
196
198
  ` traces export Bundle approval / recipe / decision traces\n` +
197
199
  ` print-token [--port N] Print the active bridge auth token\n\n` +
200
+ `Safety\n` +
201
+ ` kill-switch <engage|release|status> Block / resume write-tier tools across bridges\n` +
202
+ ` panic [--reason "..."] Shorthand for kill-switch engage\n\n` +
198
203
  `Daemon (no subcommand)\n` +
199
204
  ` --workspace <dir> Start the bridge in foreground\n` +
200
205
  ` --watch Auto-restart supervisor\n` +
@@ -1763,11 +1768,22 @@ if (process.argv[2] === "kill-switch") {
1763
1768
  // form is `kill-switch engage`; this alias matches it so shell history six
1764
1769
  // months later still makes sense. Does not accept sub-verbs — just runs engage.
1765
1770
  if (process.argv[2] === "panic") {
1771
+ const extra = process.argv.slice(3); // e.g. --reason "..." --force-local
1772
+ // Guard against `panic --help` engaging the kill switch — a real
1773
+ // footgun if you tab-completed the verb to confirm syntax before
1774
+ // committing to the action. `panic` is an alias, so we honor --help
1775
+ // here ourselves rather than forwarding to kill-switch engage.
1776
+ if (extra.includes("--help") || extra.includes("-h")) {
1777
+ console.log('Usage: patchwork panic [--reason "..."] [--force-local]\n\n' +
1778
+ " Alias for `patchwork kill-switch engage` — blocks all write-tier\n" +
1779
+ " tool calls across every running bridge. Use --reason to leave a\n" +
1780
+ " note in the audit trail. Release with `patchwork kill-switch release`.\n");
1781
+ process.exit(0);
1782
+ }
1766
1783
  // Spawn self with kill-switch engage to reuse the full handler without
1767
1784
  // duplicating 200+ LOC. Passes through any flags (--reason, --force-local).
1768
1785
  import("node:child_process").then(({ spawnSync }) => {
1769
1786
  const self = process.argv[1] ?? process.execPath;
1770
- const extra = process.argv.slice(3); // e.g. --reason "..." --force-local
1771
1787
  const result = spawnSync(process.execPath, [self, "kill-switch", "engage", ...extra], { stdio: "inherit" });
1772
1788
  process.exit(result.status ?? 1);
1773
1789
  });
@@ -1834,14 +1850,6 @@ if (process.argv[2] === "halts") {
1834
1850
  process.stderr.write("No running bridge found. Start one with `patchwork start` (or `--driver subprocess`).\n");
1835
1851
  process.exit(2);
1836
1852
  }
1837
- // Single-bridge default: query the first. Multi-bridge users will
1838
- // typically have one orchestrator anyway; expanding to fan-out is a
1839
- // follow-up if needed.
1840
- const lock = liveLocks[0];
1841
- if (!lock) {
1842
- process.stderr.write("No running bridge found.\n");
1843
- process.exit(2);
1844
- }
1845
1853
  const sinceMs = windowSinceMs(window);
1846
1854
  const params = [];
1847
1855
  if (sinceMs != null)
@@ -1849,20 +1857,35 @@ if (process.argv[2] === "halts") {
1849
1857
  if (recipeFilter)
1850
1858
  params.push(`recipe=${encodeURIComponent(recipeFilter)}`);
1851
1859
  const qs = params.length > 0 ? `?${params.join("&")}` : "";
1852
- const controller = new AbortController();
1853
- const timer = setTimeout(() => controller.abort(), 10_000);
1854
- let res;
1855
- try {
1856
- res = await fetch(`http://127.0.0.1:${lock.port}/runs/halt-summary${qs}`, {
1857
- headers: { Authorization: `Bearer ${lock.authToken}` },
1858
- signal: controller.signal,
1859
- });
1860
- }
1861
- finally {
1862
- clearTimeout(timer);
1860
+ // Walk live bridges in order; first responsive one wins. See the
1861
+ // matching block in the `judgments` handler — findAllLiveBridges
1862
+ // can include stale entries when a recycled PID still answers
1863
+ // `kill(pid, 0)` but the lock points at a dead bridge.
1864
+ let res = null;
1865
+ let lastStatus = 0;
1866
+ for (const lock of liveLocks) {
1867
+ const controller = new AbortController();
1868
+ const timer = setTimeout(() => controller.abort(), 10_000);
1869
+ try {
1870
+ const candidate = await fetch(`http://127.0.0.1:${lock.port}/runs/halt-summary${qs}`, {
1871
+ headers: { Authorization: `Bearer ${lock.authToken}` },
1872
+ signal: controller.signal,
1873
+ });
1874
+ if (candidate.ok) {
1875
+ res = candidate;
1876
+ break;
1877
+ }
1878
+ lastStatus = candidate.status;
1879
+ }
1880
+ catch {
1881
+ /* unreachable lock — try next */
1882
+ }
1883
+ finally {
1884
+ clearTimeout(timer);
1885
+ }
1863
1886
  }
1864
- if (!res.ok) {
1865
- process.stderr.write(`Bridge returned ${res.status} for /runs/halt-summary\n`);
1887
+ if (!res) {
1888
+ process.stderr.write(`No live bridge served /runs/halt-summary (last status: ${lastStatus || "unreachable"}).\n`);
1866
1889
  process.exit(1);
1867
1890
  }
1868
1891
  const summary = (await res.json());
@@ -1917,6 +1940,16 @@ if (process.argv[2] === "halts") {
1917
1940
  }
1918
1941
  })();
1919
1942
  }
1943
+ // `patchwork analytics` — manage the self-hosted telemetry collector config.
1944
+ // Replaces the brittle "endpoint+secret in launchd plist" pattern with a
1945
+ // proper config file the bridge reads at startup.
1946
+ if (process.argv[2] === "analytics") {
1947
+ (async () => {
1948
+ const { runAnalyticsCommand } = await import("./commands/analytics.js");
1949
+ const code = await runAnalyticsCommand(process.argv.slice(3));
1950
+ process.exit(code);
1951
+ })();
1952
+ }
1920
1953
  // `patchwork judgments` — PR3b sibling of `patchwork halts`. Same window
1921
1954
  // + recipe filter shape; queries /runs/judge-summary and prints a
1922
1955
  // per-verdict breakdown plus the 5 most-recent verdicts.
@@ -1974,11 +2007,6 @@ if (process.argv[2] === "judgments") {
1974
2007
  process.stderr.write("No running bridge found. Start one with `patchwork start` (or `--driver subprocess`).\n");
1975
2008
  process.exit(2);
1976
2009
  }
1977
- const lock = liveLocks[0];
1978
- if (!lock) {
1979
- process.stderr.write("No running bridge found.\n");
1980
- process.exit(2);
1981
- }
1982
2010
  const sinceMs = windowSinceMs(window);
1983
2011
  const params = [];
1984
2012
  if (sinceMs != null)
@@ -1986,20 +2014,37 @@ if (process.argv[2] === "judgments") {
1986
2014
  if (recipeFilter)
1987
2015
  params.push(`recipe=${encodeURIComponent(recipeFilter)}`);
1988
2016
  const qs = params.length > 0 ? `?${params.join("&")}` : "";
1989
- const controller = new AbortController();
1990
- const timer = setTimeout(() => controller.abort(), 10_000);
1991
- let res;
1992
- try {
1993
- res = await fetch(`http://127.0.0.1:${lock.port}/runs/judge-summary${qs}`, {
1994
- headers: { Authorization: `Bearer ${lock.authToken}` },
1995
- signal: controller.signal,
1996
- });
1997
- }
1998
- finally {
1999
- clearTimeout(timer);
2017
+ // Walk live bridges in order; the first responsive one wins.
2018
+ // findAllLiveBridges uses `kill(pid, 0)` for liveness, which
2019
+ // returns true for any recycled PID — so liveLocks can contain
2020
+ // stale entries from dead bridges. Previously we picked [0]
2021
+ // unconditionally and surfaced a confusing 404; now we try each
2022
+ // and only fall through to the error path when *all* fail.
2023
+ let res = null;
2024
+ let lastStatus = 0;
2025
+ for (const lock of liveLocks) {
2026
+ const controller = new AbortController();
2027
+ const timer = setTimeout(() => controller.abort(), 10_000);
2028
+ try {
2029
+ const candidate = await fetch(`http://127.0.0.1:${lock.port}/runs/judge-summary${qs}`, {
2030
+ headers: { Authorization: `Bearer ${lock.authToken}` },
2031
+ signal: controller.signal,
2032
+ });
2033
+ if (candidate.ok) {
2034
+ res = candidate;
2035
+ break;
2036
+ }
2037
+ lastStatus = candidate.status;
2038
+ }
2039
+ catch {
2040
+ /* unreachable lock — try next */
2041
+ }
2042
+ finally {
2043
+ clearTimeout(timer);
2044
+ }
2000
2045
  }
2001
- if (!res.ok) {
2002
- process.stderr.write(`Bridge returned ${res.status} for /runs/judge-summary\n`);
2046
+ if (!res) {
2047
+ process.stderr.write(`No live bridge served /runs/judge-summary (last status: ${lastStatus || "unreachable"}).\n`);
2003
2048
  process.exit(1);
2004
2049
  }
2005
2050
  const summary = (await res.json());