@bitkyc08/opencodex 2.91.0 → 2.92.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.
- package/bin/ocx.mjs +37 -3
- package/gui/dist/assets/{App-nZ34cR5G.js → App-sUf_8afJ.js} +18 -18
- package/gui/dist/assets/{Tray-BuLy7hL1.js → Tray-BSsxznDm.js} +1 -1
- package/gui/dist/assets/index-BlFE_VB_.js +94 -0
- package/gui/dist/assets/quota-summary-BRlbXgw0.js +1 -0
- package/gui/dist/index.html +1 -1
- package/gui/dist/provider-icons/workbuddy-color.svg +29 -0
- package/package.json +4 -3
- package/src/adapters/google-vertex-endpoint.ts +9 -1
- package/src/adapters/google-vertex-key-project.ts +223 -0
- package/src/adapters/google.ts +13 -3
- package/src/adapters/openai-chat/tool-name-registry.ts +3 -1
- package/src/adapters/openai-chat.ts +2 -2
- package/src/adapters/openai-responses/request-strips.ts +60 -0
- package/src/adapters/registry.ts +5 -0
- package/src/adapters/workbuddy-schema.ts +61 -0
- package/src/adapters/workbuddy.ts +68 -0
- package/src/bridge/response-json.ts +1 -1
- package/src/bridge/sse.ts +1 -1
- package/src/chat/outbound.ts +11 -4
- package/src/cli/capabilities-agents-routing.ts +3 -3
- package/src/cli/codex-cli-update.ts +8 -0
- package/src/cli/combo-result.ts +6 -0
- package/src/cli/combo-stats.ts +3 -0
- package/src/cli/combo.ts +66 -42
- package/src/cli/launcher-context.ts +49 -2
- package/src/cli/link.ts +45 -32
- package/src/cli/provider.ts +2 -1
- package/src/cli/usage-compare.ts +68 -12
- package/src/client/link-ingress.ts +1 -1
- package/src/client/link-readiness.ts +97 -0
- package/src/client/link-relay-transport.ts +79 -11
- package/src/client/link-relay.ts +74 -15
- package/src/client/link-tunnel.ts +45 -18
- package/src/client/machine-listener.ts +20 -2
- package/src/client/runtime.ts +20 -2
- package/src/codex/account-store.ts +17 -3
- package/src/codex/app-server-restart-service.ts +6 -5
- package/src/codex/catalog/effort.ts +9 -2
- package/src/codex/catalog/gather-capture.ts +4 -2
- package/src/codex/catalog/provider-models.ts +14 -8
- package/src/codex/chatgpt-refresh-failure.ts +6 -2
- package/src/codex/cli-install-provenance.ts +418 -0
- package/src/codex/cli-installation-targets.ts +5 -1
- package/src/codex/cli-update-plan.ts +43 -16
- package/src/codex/desktop-app/picker-freshness.ts +10 -8
- package/src/codex/desktop-compatibility/usage-activation.ts +2 -1
- package/src/codex/desktop-compatibility/usage-controller.ts +7 -1
- package/src/codex/pool-refresh-backoff.ts +3 -1
- package/src/codex/user-identity.ts +35 -6
- package/src/codex/windows-current-sid.ts +162 -0
- package/src/combos/jev-quota-activation.ts +5 -1
- package/src/combos/jev-quota-route.ts +1 -1
- package/src/combos/jev-quota.ts +9 -1
- package/src/combos/jev-session-pin-config.ts +51 -0
- package/src/combos/jev.ts +6 -0
- package/src/combos/types.ts +19 -0
- package/src/config/provider-validation.ts +7 -0
- package/src/config/schema/leaf-validators.ts +1 -0
- package/src/generated/compatibility-version.json +194 -122
- package/src/lib/decision-quota-sink.ts +10 -0
- package/src/lib/translator-budget.ts +4 -4
- package/src/lib/upstream-retry.ts +4 -1
- package/src/link/diagnostics.ts +193 -0
- package/src/link/relay-auth.ts +6 -0
- package/src/link/status-projection.ts +39 -0
- package/src/link/supervisor.ts +45 -12
- package/src/link/tunnel-history.ts +109 -0
- package/src/link/tunnel-state.ts +80 -17
- package/src/messaging/remote-files.ts +24 -1
- package/src/oauth/generic-account-failover.ts +91 -7
- package/src/oauth/index.ts +15 -0
- package/src/oauth/model-discovery-auth.ts +5 -0
- package/src/oauth/store.ts +4 -1
- package/src/oauth/token-guardian.ts +7 -0
- package/src/oauth/types.ts +9 -0
- package/src/oauth/workbuddy.ts +139 -0
- package/src/providers/antigravity-quota-identity.ts +37 -0
- package/src/providers/base-url-choices.ts +8 -5
- package/src/providers/derive.ts +23 -3
- package/src/providers/model-discovery.ts +2 -0
- package/src/providers/model-rename-fields.ts +1 -0
- package/src/providers/quota/account-cache.ts +3 -2
- package/src/providers/quota/antigravity.ts +25 -14
- package/src/providers/quota/report-cache.ts +13 -4
- package/src/providers/quota/vendor-probes-key.ts +60 -0
- package/src/providers/quota/workbuddy.ts +83 -0
- package/src/providers/quota-decision-antigravity.ts +64 -0
- package/src/providers/quota-decision-publication.ts +11 -0
- package/src/providers/quota-decision-snapshot.ts +21 -4
- package/src/providers/quota-types.ts +2 -0
- package/src/providers/quota.ts +3 -1
- package/src/providers/registry/entries-core.ts +9 -0
- package/src/providers/registry/entries-extended.ts +20 -7
- package/src/providers/registry/model-ids.ts +3 -0
- package/src/providers/registry/model-seeds.ts +27 -28
- package/src/providers/registry/types.ts +19 -1
- package/src/providers/resolved-model-policy.ts +4 -4
- package/src/providers/routed-config.ts +15 -0
- package/src/providers/stale-model-roster-migration.ts +83 -15
- package/src/providers/workbuddy-credit-balance.ts +81 -0
- package/src/providers/workbuddy-models.ts +37 -0
- package/src/providers/workbuddy.ts +60 -0
- package/src/responses/reasoning-replay-cache.ts +18 -14
- package/src/router.ts +2 -0
- package/src/routing/analytics.ts +2 -3
- package/src/routing/fallback-policy.ts +3 -3
- package/src/server/auth-cors.ts +1 -0
- package/src/server/chat-completions.ts +14 -1
- package/src/server/chat-native-reasoning.ts +10 -3
- package/src/server/chat-native.ts +13 -2
- package/src/server/claude-messages.ts +3 -0
- package/src/server/index/link-relay-sessions.ts +9 -5
- package/src/server/index/optional-listeners.ts +4 -0
- package/src/server/local-management-read-client.ts +3 -1
- package/src/server/management/combo-routes.ts +10 -4
- package/src/server/management/link-routes.ts +5 -2
- package/src/server/management/provider-routes.ts +1 -1
- package/src/server/port-reclaim.ts +51 -9
- package/src/server/request-log-conversation.ts +49 -0
- package/src/server/request-log.ts +2 -0
- package/src/server/responses/anthropic-thinking-recovery.ts +1 -0
- package/src/server/responses/compact.ts +10 -2
- package/src/server/responses/core-combo.ts +53 -31
- package/src/server/responses/core-lifetime.ts +2 -2
- package/src/server/responses/core-normalize.ts +6 -3
- package/src/server/responses/core-opaque-recovery.ts +1 -0
- package/src/server/responses/core-options.ts +5 -0
- package/src/server/responses/core-replay.ts +2 -1
- package/src/server/responses/jev-combo-choice.ts +39 -0
- package/src/server/responses/jev-session-pin.ts +203 -0
- package/src/server/responses/passthrough-delivery.ts +4 -0
- package/src/server/responses/request-prepare.ts +10 -5
- package/src/server/responses/request-transport.ts +25 -6
- package/src/server/responses/run-turn-execution.ts +2 -4
- package/src/server/responses/sidecar-execution.ts +2 -4
- package/src/server/responses-phase-inference.ts +267 -0
- package/src/server/sse-payload-rewrite.ts +14 -1
- package/src/service/definition.ts +20 -8
- package/src/types/config.ts +6 -0
- package/src/types/provider.ts +9 -1
- package/src/types/request.ts +1 -0
- package/src/types/wire.ts +1 -0
- package/src/update/codex-cli-update-launch-policy.d.mts +9 -0
- package/src/update/codex-cli-update-launch-policy.mjs +30 -0
- package/src/usage/jev-stats.ts +54 -14
- package/gui/dist/assets/index-DGXS51sp.js +0 -94
- package/gui/dist/assets/quota-summary-DO1Sx5TM.js +0 -1
|
@@ -18,6 +18,10 @@ import type { CodexAccountCredentialRecord } from "../types";
|
|
|
18
18
|
|
|
19
19
|
export type DecisionPoolRead = readonly { id: string; usable: boolean; windows?: readonly DecisionQuotaWindow[] }[];
|
|
20
20
|
export interface MainDecisionRow { generation: number; usable: boolean }
|
|
21
|
+
/** Opaque, process-local producer receipt; only the active snapshot can validate it. */
|
|
22
|
+
export interface AntigravityQuotaReceipt { readonly kind: "antigravity" }
|
|
23
|
+
export interface AntigravityQuotaObservation { name: string; provider: OcxProviderConfig; receipt: AntigravityQuotaReceipt; windows: readonly DecisionQuotaWindow[] }
|
|
24
|
+
export interface DecisionKeyObservation { name: string; provider: OcxProviderConfig; windows: readonly DecisionQuotaWindow[] }
|
|
21
25
|
|
|
22
26
|
export interface DecisionQuotaSink {
|
|
23
27
|
observeKeyCredential(reference: string, resolved: string | undefined): void;
|
|
@@ -32,6 +36,8 @@ export interface DecisionQuotaSink {
|
|
|
32
36
|
mainRosterChanged(row: MainDecisionRow | undefined): void;
|
|
33
37
|
readKeyQuota(name: string, provider: OcxProviderConfig): readonly DecisionQuotaWindow[] | undefined;
|
|
34
38
|
readPool(provider: string): DecisionPoolRead | undefined;
|
|
39
|
+
captureAntigravityReceipt(name: string, provider: OcxProviderConfig, id: string, generation: string): AntigravityQuotaReceipt | undefined;
|
|
40
|
+
replaceCollectedQuotas(keys: readonly DecisionKeyObservation[], oauth: readonly AntigravityQuotaObservation[]): void;
|
|
35
41
|
}
|
|
36
42
|
|
|
37
43
|
let sink: DecisionQuotaSink | undefined;
|
|
@@ -71,6 +77,10 @@ export function notifyCodexStoreLoaded(store: Readonly<Record<string, CodexAccou
|
|
|
71
77
|
export function notifyMainRosterChanged(row: () => MainDecisionRow | undefined): void { forward(s => s.mainRosterChanged(row())); }
|
|
72
78
|
export function readLoadedDecisionKeyQuota(name: string, provider: OcxProviderConfig): readonly DecisionQuotaWindow[] | undefined { return read(s => s.readKeyQuota(name, provider)); }
|
|
73
79
|
export function readLoadedDecisionQuotaPool(provider: string): DecisionPoolRead | undefined { return read(s => s.readPool(provider)); }
|
|
80
|
+
/** Capture from the producer's already-loaded credential, never from a decision read. */
|
|
81
|
+
export function captureAntigravityQuotaReceipt(name: string, provider: OcxProviderConfig, id: string, generation: string): AntigravityQuotaReceipt | undefined { return read(s => s.captureAntigravityReceipt(name, provider, id, generation)); }
|
|
82
|
+
/** Accepted collector atomically replaces its key/instance evidence set. */
|
|
83
|
+
export function replaceCollectedDecisionQuotas(keys: readonly DecisionKeyObservation[], oauth: readonly AntigravityQuotaObservation[]): void { forward(s => s.replaceCollectedQuotas(keys, oauth)); }
|
|
74
84
|
|
|
75
85
|
/** Test-only: drop any registration a previous test left behind. */
|
|
76
86
|
export function resetDecisionQuotaSinkForTests(): void { sink = undefined; }
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { isNonReplayableResponse, markResponseNonReplayable } from "./upstream-retry";
|
|
1
|
+
import { carryReplayRefusal, isNonReplayableResponse, markResponseNonReplayable } from "./upstream-retry";
|
|
2
2
|
|
|
3
3
|
export const TRANSLATOR_MAX_CALL_ARGUMENT_BYTES = 2 * 1024 * 1024;
|
|
4
4
|
export const TRANSLATOR_MAX_TURN_BYTES = 32 * 1024 * 1024;
|
|
@@ -413,8 +413,8 @@ export function finalizeTranslatorBudgetResponse(response: Response, budget: Tra
|
|
|
413
413
|
if (signal?.aborted) finalize();
|
|
414
414
|
else signal?.addEventListener("abort", finalize, { once: true });
|
|
415
415
|
const finalizedResponse = new Response(body, { status: response.status, statusText: response.statusText, headers: response.headers });
|
|
416
|
-
//
|
|
417
|
-
//
|
|
416
|
+
// Preserve both replay suppression and refusal provenance across the rebuild
|
|
417
|
+
// so deferred logging can distinguish synthetic refusals from upstream 429s.
|
|
418
418
|
if (isNonReplayableResponse(response)) markResponseNonReplayable(finalizedResponse);
|
|
419
|
-
return finalizedResponse;
|
|
419
|
+
return carryReplayRefusal(response, finalizedResponse);
|
|
420
420
|
}
|
|
@@ -770,7 +770,10 @@ export async function fetchWithTransientRetry(
|
|
|
770
770
|
for (let attempt = 0; sent < budget; attempt++) {
|
|
771
771
|
// A non-replayable gateway status was settled after the request body had already left
|
|
772
772
|
// for the origin; retrying it here is the automatic resend the marker exists to forbid.
|
|
773
|
-
|
|
773
|
+
// An upstream that answers with the exact `x-should-retry: false` verdict has said the same
|
|
774
|
+
// thing about its own failure, so the ladder stops and hands the response back untouched.
|
|
775
|
+
if (res.ok || !isTransientUpstreamStatus(res.status) || isNonReplayableResponse(res)
|
|
776
|
+
|| res.headers.get(REPLAY_REFUSAL_NO_RETRY_HEADER) === REPLAY_REFUSAL_NO_RETRY_VALUE) return res;
|
|
774
777
|
// Checked before cancelResponseBodyBestEffort so an already-aborted caller never receives
|
|
775
778
|
// a response whose body we just cancelled.
|
|
776
779
|
if (opts.abortSignal?.aborted) return res;
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { constants } from "node:fs";
|
|
2
|
+
import { chmod, lstat, mkdir, open, rename, unlink } from "node:fs/promises";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { assertNotRealHomeUnderTest } from "../lib/test-home-guard";
|
|
5
|
+
import { hardenSecretDirAsync, hardenSecretPathAsync, reattributeHardenedSecretPath, windowsSecretAclApplies } from "../lib/windows-secret-acl";
|
|
6
|
+
import { linkDir } from "./paths";
|
|
7
|
+
import type { TunnelState } from "./tunnel-state";
|
|
8
|
+
|
|
9
|
+
export type LinkDiagnosticRecord = {
|
|
10
|
+
event: "owner_lookup" | "relay_retry" | "relay_failure" | "relay_recovered" | "tunnel_transition";
|
|
11
|
+
outcome: "match" | "mismatch" | "lookup_error" | "deadline" | "stale" | "unavailable"
|
|
12
|
+
| "identity_unknown" | "identity_mismatch" | "auth_timeout" | "auth_failed"
|
|
13
|
+
| "transport_error" | "client_abort" | "header_timeout" | "recovered"
|
|
14
|
+
// tunnel_transition: the Home's -R tunnel changed state, and why (src/link/tunnel-history.ts).
|
|
15
|
+
| "connected" | "spawned" | "stopped" | "auth" | "hostkey" | "forward" | "remote_forward_conflict"
|
|
16
|
+
| "timeout" | "network" | "unknown" | "spawn_failed" | "listener_unavailable";
|
|
17
|
+
requestId?: string;
|
|
18
|
+
checkId?: string;
|
|
19
|
+
generation?: number;
|
|
20
|
+
state?: string;
|
|
21
|
+
previousState?: string;
|
|
22
|
+
elapsedMs?: number;
|
|
23
|
+
attempt?: number;
|
|
24
|
+
tunnelPort?: number;
|
|
25
|
+
retryAfterMs?: number;
|
|
26
|
+
};
|
|
27
|
+
export type LinkDiagnosticSink = (record: LinkDiagnosticRecord) => void;
|
|
28
|
+
|
|
29
|
+
const EVENTS = new Set(["owner_lookup", "relay_retry", "relay_failure", "relay_recovered", "tunnel_transition"]);
|
|
30
|
+
const OUTCOMES = new Set([
|
|
31
|
+
"match", "mismatch", "lookup_error", "deadline", "stale", "unavailable", "identity_unknown",
|
|
32
|
+
"identity_mismatch", "auth_timeout", "auth_failed", "transport_error", "client_abort", "header_timeout", "recovered",
|
|
33
|
+
"connected", "spawned", "stopped", "auth", "hostkey", "forward", "remote_forward_conflict", "timeout", "network",
|
|
34
|
+
"unknown", "spawn_failed", "listener_unavailable",
|
|
35
|
+
]);
|
|
36
|
+
const STATES = new Set<TunnelState["kind"]>(["idle", "connecting", "connected", "reconnecting", "failed"]);
|
|
37
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
38
|
+
const MAX_BYTES = 256 * 1024;
|
|
39
|
+
|
|
40
|
+
/** Copy only bounded enum, UUID and numeric fields; unknown fields never reach disk. */
|
|
41
|
+
function metadata(record: LinkDiagnosticRecord): LinkDiagnosticRecord | null {
|
|
42
|
+
if (!record) return null;
|
|
43
|
+
const { event, outcome, state, previousState } = record;
|
|
44
|
+
if (!EVENTS.has(event) || !OUTCOMES.has(outcome)) return null;
|
|
45
|
+
const result: LinkDiagnosticRecord = { event, outcome };
|
|
46
|
+
for (const key of ["requestId", "checkId"] as const) {
|
|
47
|
+
const value = record[key];
|
|
48
|
+
if (typeof value === "string" && value.length === 36 && UUID.test(value)) result[key] = value;
|
|
49
|
+
}
|
|
50
|
+
if (typeof state === "string" && STATES.has(state as TunnelState["kind"])) result.state = state;
|
|
51
|
+
if (typeof previousState === "string" && STATES.has(previousState as TunnelState["kind"])) result.previousState = previousState;
|
|
52
|
+
for (const key of ["generation", "elapsedMs", "attempt", "retryAfterMs"] as const) {
|
|
53
|
+
const value = record[key];
|
|
54
|
+
if (typeof value === "number" && Number.isFinite(value) && value >= 0 && value <= Number.MAX_SAFE_INTEGER
|
|
55
|
+
&& (key === "elapsedMs" || key === "retryAfterMs" || Number.isSafeInteger(value))) result[key] = value;
|
|
56
|
+
}
|
|
57
|
+
const { tunnelPort } = record;
|
|
58
|
+
if (typeof tunnelPort === "number" && Number.isInteger(tunnelPort) && tunnelPort >= 1 && tunnelPort <= 65_535) result.tunnelPort = tunnelPort;
|
|
59
|
+
return result;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Append through a private no-follow handle, keeping at most one 256 KiB rotated backup. */
|
|
63
|
+
async function append(dir: string, line: string): Promise<void> {
|
|
64
|
+
assertNotRealHomeUnderTest(dirname(dir));
|
|
65
|
+
await mkdir(dir, { recursive: true, mode: 0o700 });
|
|
66
|
+
const directory = await lstat(dir);
|
|
67
|
+
if (!directory.isDirectory() || directory.isSymbolicLink()) return;
|
|
68
|
+
const windows = windowsSecretAclApplies();
|
|
69
|
+
if (windows) await hardenSecretDirAsync(dir, { required: true });
|
|
70
|
+
else await chmod(dir, 0o700);
|
|
71
|
+
const path = join(dir, "diagnostics.jsonl");
|
|
72
|
+
const backup = `${path}.1`;
|
|
73
|
+
let file: Awaited<ReturnType<typeof open>> | undefined;
|
|
74
|
+
try {
|
|
75
|
+
// No read of log contents, and no following a file symlink or writing to a pipe.
|
|
76
|
+
const flags = constants.O_WRONLY | constants.O_APPEND | constants.O_CREAT | constants.O_NOFOLLOW | constants.O_NONBLOCK;
|
|
77
|
+
file = await open(path, flags, 0o600);
|
|
78
|
+
const current = await file.stat();
|
|
79
|
+
if (!current.isFile() || current.nlink !== 1) return;
|
|
80
|
+
if (windows) await hardenSecretPathAsync(path, { required: true });
|
|
81
|
+
else await file.chmod(0o600);
|
|
82
|
+
const previous = await lstat(backup).catch(error => {
|
|
83
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
84
|
+
return null;
|
|
85
|
+
});
|
|
86
|
+
if (previous) {
|
|
87
|
+
if (!previous.isFile() || previous.isSymbolicLink() || previous.nlink !== 1) return;
|
|
88
|
+
if (previous.size > MAX_BYTES) await unlink(backup);
|
|
89
|
+
else if (windows) await hardenSecretPathAsync(backup, { required: true });
|
|
90
|
+
else await chmod(backup, 0o600);
|
|
91
|
+
}
|
|
92
|
+
if (current.size > MAX_BYTES) await file.truncate(0);
|
|
93
|
+
else if (current.size + Buffer.byteLength(line) > MAX_BYTES) {
|
|
94
|
+
await file.close();
|
|
95
|
+
file = undefined;
|
|
96
|
+
await rename(path, backup);
|
|
97
|
+
file = await open(path, flags, 0o600);
|
|
98
|
+
const fresh = await file.stat();
|
|
99
|
+
if (!fresh.isFile() || fresh.nlink !== 1 || fresh.size + Buffer.byteLength(line) > MAX_BYTES) return;
|
|
100
|
+
if (windows) await hardenSecretPathAsync(path, { required: true });
|
|
101
|
+
else await file.chmod(0o600);
|
|
102
|
+
}
|
|
103
|
+
await file.write(line);
|
|
104
|
+
if (windows) {
|
|
105
|
+
const written = await file.stat({ bigint: true });
|
|
106
|
+
const named = await lstat(path, { bigint: true });
|
|
107
|
+
if (!named.isSymbolicLink() && named.dev === written.dev && named.ino === written.ino) {
|
|
108
|
+
// Windows publishes content-write ChangeTime on close; retain only the same object's ACL memo.
|
|
109
|
+
await file.close();
|
|
110
|
+
file = undefined;
|
|
111
|
+
reattributeHardenedSecretPath(path);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
} finally {
|
|
115
|
+
await file?.close();
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Create a best-effort metadata sink with one-second event/outcome buckets and a bounded queue.
|
|
121
|
+
* File and ACL work starts on the first eligible record and never blocks admission. flush() waits
|
|
122
|
+
* at most one second; its timeout does not cancel the writer or guarantee durable storage.
|
|
123
|
+
*/
|
|
124
|
+
export function createLinkDiagnosticSink(
|
|
125
|
+
{ configDir, now = Date.now }: { configDir?: string; now?: () => number } = {},
|
|
126
|
+
): LinkDiagnosticSink & { flush: (timeoutMs?: number) => Promise<void> } {
|
|
127
|
+
// At most 56 buckets; correlation IDs never become keys or retained memory.
|
|
128
|
+
const buckets = new Map<string, { at: number; suppressed: number }>();
|
|
129
|
+
const queue: Array<{ dir: string; line: string }> = [];
|
|
130
|
+
let flight: Promise<void> | undefined;
|
|
131
|
+
const drain = async () => {
|
|
132
|
+
while (queue.length) {
|
|
133
|
+
const record = queue.shift()!;
|
|
134
|
+
try { await append(record.dir, record.line); } catch { /* Best effort; never publish raw errors. */ }
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
const startWriter = () => {
|
|
138
|
+
if (flight) return;
|
|
139
|
+
flight = drain().finally(() => {
|
|
140
|
+
flight = undefined;
|
|
141
|
+
if (queue.length) startWriter();
|
|
142
|
+
});
|
|
143
|
+
};
|
|
144
|
+
const sink: LinkDiagnosticSink = record => {
|
|
145
|
+
try {
|
|
146
|
+
const safe = metadata(record);
|
|
147
|
+
if (!safe) return;
|
|
148
|
+
const at = now();
|
|
149
|
+
if (!Number.isSafeInteger(at) || at < 0) return;
|
|
150
|
+
const key = `${safe.event}:${safe.outcome}`;
|
|
151
|
+
const bucket = buckets.get(key);
|
|
152
|
+
// Wall-clock rollback starts a new window, even when the writer is backed up.
|
|
153
|
+
if (bucket && at >= bucket.at && at - bucket.at < 1_000) {
|
|
154
|
+
bucket.suppressed = Math.min(Number.MAX_SAFE_INTEGER, bucket.suppressed + 1);
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
if (queue.length >= 56) {
|
|
158
|
+
buckets.set(key, { at: Math.min(bucket?.at ?? at, at),
|
|
159
|
+
suppressed: Math.min(Number.MAX_SAFE_INTEGER, (bucket?.suppressed ?? 0) + 1) });
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
const suppressed = bucket?.suppressed ?? 0;
|
|
163
|
+
// Failed writes are rate-limited too; diagnostics cannot hammer an unavailable disk.
|
|
164
|
+
buckets.set(key, { at, suppressed: 0 });
|
|
165
|
+
queue.push({ dir: linkDir(configDir), line: `${JSON.stringify({ ...safe, timestamp: at, ...(suppressed ? { suppressed } : {}) })}\n` });
|
|
166
|
+
startWriter();
|
|
167
|
+
} catch { /* Diagnostics must never change request admission or expose raw errors. */ }
|
|
168
|
+
};
|
|
169
|
+
return Object.assign(sink, { flush: async (timeoutMs = 1_000) => {
|
|
170
|
+
if (!flight) return;
|
|
171
|
+
// Keep shutdown bounded even if an asynchronous filesystem or ACL operation stalls.
|
|
172
|
+
// Timeout leaves the existing writer alone; it may still finish before process exit.
|
|
173
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
174
|
+
try {
|
|
175
|
+
await Promise.race([
|
|
176
|
+
(async () => { while (flight) await flight; })(),
|
|
177
|
+
new Promise<void>(resolve => { timer = setTimeout(resolve, Math.min(1_000, Math.max(0, timeoutMs))); }),
|
|
178
|
+
]);
|
|
179
|
+
} catch { /* Best effort; never publish raw errors. */ }
|
|
180
|
+
finally { clearTimeout(timer); }
|
|
181
|
+
} });
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
let defaultSink: ReturnType<typeof createLinkDiagnosticSink> | undefined;
|
|
185
|
+
/** Lazily enqueue sanitized evidence in the default sink without propagating diagnostic failures. */
|
|
186
|
+
export function recordLinkDiagnostic(record: LinkDiagnosticRecord): void {
|
|
187
|
+
try { (defaultSink ??= createLinkDiagnosticSink())(record); } catch { /* Best effort. */ }
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Drain existing diagnostics for at most one second without activating an unused sink. */
|
|
191
|
+
export async function flushLinkDiagnostics(): Promise<void> {
|
|
192
|
+
await defaultSink?.flush();
|
|
193
|
+
}
|
package/src/link/relay-auth.ts
CHANGED
|
@@ -2,6 +2,12 @@ import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
|
|
2
2
|
|
|
3
3
|
export const LINK_RELAY_AUTH_PATH = "/.well-known/opencodex/link-relay-auth";
|
|
4
4
|
export const LINK_RELAY_SESSION_HEADER = "x-opencodex-link-session";
|
|
5
|
+
/**
|
|
6
|
+
* Marks a challenge that only asks whether the Home answers (the Child's readiness preflight): the
|
|
7
|
+
* Home proves itself without reserving a session, so no data request can follow it and it holds
|
|
8
|
+
* neither session capacity nor a drain lease. An older Home ignores it and reserves as before.
|
|
9
|
+
*/
|
|
10
|
+
export const LINK_RELAY_PROBE_HEADER = "x-opencodex-link-probe";
|
|
5
11
|
export const LINK_RELAY_AUTH_VERSION = "2";
|
|
6
12
|
export const LINK_RELAY_AUTH_TIMEOUT_MS = 5_000;
|
|
7
13
|
|
|
@@ -22,6 +22,8 @@ export interface LinkStatusDto {
|
|
|
22
22
|
tunnelPort: number;
|
|
23
23
|
/** Home only: what the link carried from this Child, and the compatibility verdict. */
|
|
24
24
|
peer?: LinkPeerStatus;
|
|
25
|
+
/** Home-initiated links on request (`?diagnostics=1`): retry progress and the last failure. */
|
|
26
|
+
tunnel?: LinkTunnelDiagnostics;
|
|
25
27
|
}>;
|
|
26
28
|
child: null | {
|
|
27
29
|
alias: string;
|
|
@@ -33,6 +35,43 @@ export interface LinkStatusDto {
|
|
|
33
35
|
local?: LinkLocalPeer;
|
|
34
36
|
}
|
|
35
37
|
|
|
38
|
+
/**
|
|
39
|
+
* The Home's account of one `-R` tunnel: ssh attempts since it was last connected, when the next
|
|
40
|
+
* one is due (null while one runs or none is scheduled), the last state change and the last
|
|
41
|
+
* failure. `cause` is a fixed word from `tunnel-history.ts`, never ssh output.
|
|
42
|
+
*/
|
|
43
|
+
export interface LinkTunnelDiagnostics {
|
|
44
|
+
attempt: number;
|
|
45
|
+
nextRetryAt: string | null;
|
|
46
|
+
lastTransition: { from: LinkWireState; to: LinkWireState; at: string; cause: string | null } | null;
|
|
47
|
+
lastFailure: { cause: string; at: string } | null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function iso(at: number): string {
|
|
51
|
+
return new Date(at).toISOString();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Add `tunnel` to each Home-initiated link the supervisor reports. */
|
|
55
|
+
export function attachLinkTunnelDiagnostics(dto: LinkStatusDto, supervisorStates: readonly LinkTunnelStatus[]): LinkStatusDto {
|
|
56
|
+
const byId = new Map(supervisorStates.map(status => [status.linkId, status]));
|
|
57
|
+
for (const link of dto.links) {
|
|
58
|
+
const status = byId.get(link.id);
|
|
59
|
+
if (!status || status.direction !== "hub-initiated") continue;
|
|
60
|
+
const { state, history } = status;
|
|
61
|
+
const due = (state.kind === "failed" || state.kind === "reconnecting") && state.retryAt !== undefined && state.inFlight !== true
|
|
62
|
+
? state.retryAt : null;
|
|
63
|
+
link.tunnel = {
|
|
64
|
+
attempt: history?.attempt ?? 0,
|
|
65
|
+
nextRetryAt: due === null ? null : iso(due),
|
|
66
|
+
lastTransition: history?.lastTransition
|
|
67
|
+
? { from: history.lastTransition.from, to: history.lastTransition.to, at: iso(history.lastTransition.at), cause: history.lastTransition.cause }
|
|
68
|
+
: null,
|
|
69
|
+
lastFailure: history?.lastFailure ? { cause: history.lastFailure.cause, at: iso(history.lastFailure.at) } : null,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
return dto;
|
|
73
|
+
}
|
|
74
|
+
|
|
36
75
|
/** Add this build's identity and, on a Home, each Child's observed peer status. */
|
|
37
76
|
export function attachLinkPeers(dto: LinkStatusDto, local: LinkLocalPeer, observation: (linkId: string) => LinkPeerEntry): LinkStatusDto {
|
|
38
77
|
if (dto.role === "home") {
|
package/src/link/supervisor.ts
CHANGED
|
@@ -4,13 +4,16 @@ import { atomicWriteFile } from "../config/atomic-write";
|
|
|
4
4
|
import { linkDir, linkKnownHostsPath, linkStorePath } from "./paths";
|
|
5
5
|
import { buildTunnelArgv } from "./ssh-argv";
|
|
6
6
|
import { createSshRunner, type SshChild, type SshRunner } from "./ssh-runner";
|
|
7
|
+
import type { LinkDiagnosticSink } from "./diagnostics";
|
|
8
|
+
import { createTunnelHistoryBook, exitCause, type TunnelCause, type TunnelHistory } from "./tunnel-history";
|
|
7
9
|
import {
|
|
8
10
|
classifySshStderr,
|
|
9
11
|
dueForSpawn,
|
|
10
|
-
|
|
12
|
+
failedSpawnTunnel,
|
|
11
13
|
HOME_TUNNEL_RETRY_POLICY,
|
|
12
14
|
IDLE,
|
|
13
15
|
reduceTunnel,
|
|
16
|
+
type TunnelFailure,
|
|
14
17
|
type TunnelState,
|
|
15
18
|
} from "./tunnel-state";
|
|
16
19
|
import { emptyLinkStore, readLinkStore, type LinkRecord, type LinkStore } from "./store";
|
|
@@ -22,6 +25,8 @@ export type LinkTunnelStatus =
|
|
|
22
25
|
state: TunnelState;
|
|
23
26
|
pid: number | null;
|
|
24
27
|
orphan?: "orphan-unverified";
|
|
28
|
+
/** Attempts, last change and last failure cause of this tunnel (`tunnel-history.ts`). */
|
|
29
|
+
history?: TunnelHistory;
|
|
25
30
|
}
|
|
26
31
|
| {
|
|
27
32
|
linkId: string;
|
|
@@ -72,6 +77,10 @@ export interface LinkSupervisorDeps {
|
|
|
72
77
|
apiKeys?: () => readonly { id: string; name: string }[];
|
|
73
78
|
revokeApiKey?: (id: string) => boolean;
|
|
74
79
|
warn?: (message: string) => void;
|
|
80
|
+
/** Where tunnel state changes are recorded; production passes the bounded diagnostics file. */
|
|
81
|
+
diagnostic?: LinkDiagnosticSink;
|
|
82
|
+
/** Bounded drain of `diagnostic` at stop. */
|
|
83
|
+
flushDiagnostics?: () => Promise<void>;
|
|
75
84
|
}
|
|
76
85
|
|
|
77
86
|
const TIMER_MS = 1_000;
|
|
@@ -166,6 +175,10 @@ export function createLinkSupervisor(deps: LinkSupervisorDeps = {}): LinkSupervi
|
|
|
166
175
|
/** The generation of the child whose exit is still being classified, per link. */
|
|
167
176
|
const exiting = new Map<string, number>();
|
|
168
177
|
const recordInstances = new Map<string, string>();
|
|
178
|
+
const history = createTunnelHistoryBook({
|
|
179
|
+
diagnostic: deps.diagnostic,
|
|
180
|
+
tunnelPort: linkId => store.links.find(link => link.id === linkId)?.tunnelPort,
|
|
181
|
+
});
|
|
169
182
|
|
|
170
183
|
const reconcileApiKeys = (): void => {
|
|
171
184
|
const keys = deps.apiKeys?.() ?? [];
|
|
@@ -218,12 +231,22 @@ export function createLinkSupervisor(deps: LinkSupervisorDeps = {}): LinkSupervi
|
|
|
218
231
|
}
|
|
219
232
|
};
|
|
220
233
|
|
|
221
|
-
|
|
222
|
-
|
|
234
|
+
/** Every state change goes through here, so the history sees each transition and its cause. */
|
|
235
|
+
const put = (linkId: string, next: TunnelState, cause: TunnelCause | null = null): TunnelState => {
|
|
236
|
+
const before = states.get(linkId) ?? IDLE;
|
|
223
237
|
states.set(linkId, next);
|
|
238
|
+
history.observe(linkId, before, next, cause, now());
|
|
224
239
|
return next;
|
|
225
240
|
};
|
|
226
241
|
|
|
242
|
+
const setEvent = (linkId: string, event: Parameters<typeof reduceTunnel>[1], cause: TunnelCause | null = null): TunnelState =>
|
|
243
|
+
put(linkId, reduceTunnel(states.get(linkId) ?? IDLE, event, deps.random, HOME_TUNNEL_RETRY_POLICY), cause);
|
|
244
|
+
|
|
245
|
+
/** Fail a link whose attempt could not start; a due retry that fails this way counts as one. */
|
|
246
|
+
const failAttempt = (linkId: string, prior: TunnelState | undefined, reason: TunnelFailure, cause: TunnelCause): void => {
|
|
247
|
+
put(linkId, failedSpawnTunnel(prior, reason, now(), HOME_TUNNEL_RETRY_POLICY), cause);
|
|
248
|
+
};
|
|
249
|
+
|
|
227
250
|
const spawnFor = (record: LinkRecord): void => {
|
|
228
251
|
if (stopping || holds.has(record.id) || record.direction !== "hub-initiated" || children.has(record.id)) return;
|
|
229
252
|
// Only a fresh link or a due attempt spawns. A reload must neither skip the backoff nor start
|
|
@@ -231,7 +254,8 @@ export function createLinkSupervisor(deps: LinkSupervisorDeps = {}): LinkSupervi
|
|
|
231
254
|
const prior = states.get(record.id);
|
|
232
255
|
if (prior && prior.kind !== "idle" && !dueForSpawn(prior, now())) return;
|
|
233
256
|
if (store.listenerPort === null) {
|
|
234
|
-
|
|
257
|
+
// Retried like any forward failure; a reload that brings the listener back starts it fresh.
|
|
258
|
+
failAttempt(record.id, prior, "forward", "listener_unavailable");
|
|
235
259
|
return;
|
|
236
260
|
}
|
|
237
261
|
let argv: string[];
|
|
@@ -250,6 +274,7 @@ export function createLinkSupervisor(deps: LinkSupervisorDeps = {}): LinkSupervi
|
|
|
250
274
|
exiting.delete(record.id);
|
|
251
275
|
children.set(record.id, { child, argv, spawnedAt: now() });
|
|
252
276
|
orphanUnverified.delete(record.id);
|
|
277
|
+
history.spawned(record.id);
|
|
253
278
|
setEvent(record.id, { type: "spawn", now: now() });
|
|
254
279
|
writePidfile(pidfilePath(record.id), { version: 1, linkId: record.id, pid: child.pid, argv });
|
|
255
280
|
void child.exited.then(async () => {
|
|
@@ -264,7 +289,8 @@ export function createLinkSupervisor(deps: LinkSupervisorDeps = {}): LinkSupervi
|
|
|
264
289
|
try { stderr = child.stderr ? await child.stderr : ""; } catch { /* classified as unknown */ }
|
|
265
290
|
// A reconnect may have started a fresh child meanwhile; its own lifecycle owns the state.
|
|
266
291
|
if (generations.get(record.id) !== generation) return;
|
|
267
|
-
|
|
292
|
+
const stderrClass = classifySshStderr(stderr);
|
|
293
|
+
setEvent(record.id, { type: "exit", now: now(), stderrClass }, exitCause(stderrClass));
|
|
268
294
|
} finally {
|
|
269
295
|
if (exiting.get(record.id) === generation) exiting.delete(record.id);
|
|
270
296
|
}
|
|
@@ -272,11 +298,10 @@ export function createLinkSupervisor(deps: LinkSupervisorDeps = {}): LinkSupervi
|
|
|
272
298
|
if (children.get(record.id)?.child !== child) return;
|
|
273
299
|
children.delete(record.id);
|
|
274
300
|
conditionalRemovePidfile(record.id, child.pid);
|
|
275
|
-
if (!stopping) setEvent(record.id, { type: "exit", now: now(), stderrClass: "network" });
|
|
301
|
+
if (!stopping) setEvent(record.id, { type: "exit", now: now(), stderrClass: "network" }, "network");
|
|
276
302
|
});
|
|
277
303
|
} catch {
|
|
278
|
-
|
|
279
|
-
states.set(record.id, failedTunnel("forward", at, HOME_TUNNEL_RETRY_POLICY, prior?.kind === "failed" ? prior.since : at));
|
|
304
|
+
failAttempt(record.id, prior, "forward", "spawn_failed");
|
|
280
305
|
}
|
|
281
306
|
};
|
|
282
307
|
|
|
@@ -300,7 +325,7 @@ export function createLinkSupervisor(deps: LinkSupervisorDeps = {}): LinkSupervi
|
|
|
300
325
|
setEvent(record.id, { type: "ready", now: current });
|
|
301
326
|
continue;
|
|
302
327
|
}
|
|
303
|
-
const next = setEvent(record.id, { type: "tick", now: current });
|
|
328
|
+
const next = setEvent(record.id, { type: "tick", now: current }, "timeout");
|
|
304
329
|
if (next.kind === "failed" && !next.inFlight && child && retryInFlight(before)) {
|
|
305
330
|
// A retry started just before the outage limit has not had its grace yet. It carries on as
|
|
306
331
|
// the failed state's retry instead of being killed with a link that may already be back.
|
|
@@ -349,7 +374,7 @@ export function createLinkSupervisor(deps: LinkSupervisorDeps = {}): LinkSupervi
|
|
|
349
374
|
const stopChild = async (linkId: string): Promise<void> => {
|
|
350
375
|
const current = children.get(linkId);
|
|
351
376
|
if (!current) {
|
|
352
|
-
|
|
377
|
+
put(linkId, IDLE);
|
|
353
378
|
return;
|
|
354
379
|
}
|
|
355
380
|
const generation = generations.get(linkId);
|
|
@@ -358,7 +383,7 @@ export function createLinkSupervisor(deps: LinkSupervisorDeps = {}): LinkSupervi
|
|
|
358
383
|
await current.child.exited;
|
|
359
384
|
conditionalRemovePidfile(linkId, current.child.pid);
|
|
360
385
|
// A stop or removal reservation made while this child exited owns the next state.
|
|
361
|
-
if (generations.get(linkId) === generation)
|
|
386
|
+
if (generations.get(linkId) === generation) put(linkId, IDLE);
|
|
362
387
|
};
|
|
363
388
|
|
|
364
389
|
const invalidateLink = (linkId: string): void => {
|
|
@@ -407,6 +432,7 @@ export function createLinkSupervisor(deps: LinkSupervisorDeps = {}): LinkSupervi
|
|
|
407
432
|
if (nextInstances.has(linkId) && !changed.has(linkId)) continue;
|
|
408
433
|
await stopChild(linkId);
|
|
409
434
|
states.delete(linkId);
|
|
435
|
+
history.forget(linkId);
|
|
410
436
|
orphanUnverified.delete(linkId);
|
|
411
437
|
recordInstances.delete(linkId);
|
|
412
438
|
if (stopping) return;
|
|
@@ -414,9 +440,13 @@ export function createLinkSupervisor(deps: LinkSupervisorDeps = {}): LinkSupervi
|
|
|
414
440
|
for (const record of nextStore.links) {
|
|
415
441
|
if (changed.has(record.id)) {
|
|
416
442
|
states.delete(record.id);
|
|
443
|
+
history.forget(record.id);
|
|
417
444
|
orphanUnverified.delete(record.id);
|
|
418
445
|
}
|
|
419
446
|
}
|
|
447
|
+
for (const linkId of [...states.keys()]) {
|
|
448
|
+
if (!nextInstances.has(linkId)) history.forget(linkId);
|
|
449
|
+
}
|
|
420
450
|
recordInstances.clear();
|
|
421
451
|
for (const [linkId, instance] of nextInstances) recordInstances.set(linkId, instance);
|
|
422
452
|
for (const record of nextStore.links) {
|
|
@@ -471,12 +501,14 @@ export function createLinkSupervisor(deps: LinkSupervisorDeps = {}): LinkSupervi
|
|
|
471
501
|
return { linkId: record.id, direction: record.direction, state: "client-owned", pid: null };
|
|
472
502
|
}
|
|
473
503
|
const child = children.get(record.id);
|
|
504
|
+
const recorded = history.get(record.id);
|
|
474
505
|
return {
|
|
475
506
|
linkId: record.id,
|
|
476
507
|
direction: record.direction,
|
|
477
508
|
state: states.get(record.id) ?? IDLE,
|
|
478
509
|
pid: child?.child.pid ?? null,
|
|
479
510
|
...(orphanUnverified.has(record.id) ? { orphan: "orphan-unverified" as const } : {}),
|
|
511
|
+
...(recorded ? { history: recorded } : {}),
|
|
480
512
|
};
|
|
481
513
|
});
|
|
482
514
|
},
|
|
@@ -489,9 +521,10 @@ export function createLinkSupervisor(deps: LinkSupervisorDeps = {}): LinkSupervi
|
|
|
489
521
|
await runLifecycle(async () => {
|
|
490
522
|
await Promise.all([...children.keys()].map(stopChild));
|
|
491
523
|
for (const record of store.links) {
|
|
492
|
-
if (record.direction === "hub-initiated")
|
|
524
|
+
if (record.direction === "hub-initiated") put(record.id, IDLE);
|
|
493
525
|
}
|
|
494
526
|
});
|
|
527
|
+
try { await deps.flushDiagnostics?.(); } catch { /* best effort */ }
|
|
495
528
|
},
|
|
496
529
|
};
|
|
497
530
|
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the Home remembers about one `-R` tunnel besides its current state: how many ssh attempts
|
|
3
|
+
* the current outage has cost, the last state change and the last failure with its cause. It lives
|
|
4
|
+
* in memory next to the supervisor's state, appears in `GET /api/link/status` as `tunnel`, and
|
|
5
|
+
* each change it records also goes to the bounded diagnostics file (`diagnostics.ts`), so a link
|
|
6
|
+
* that failed for an hour leaves evidence of why after the fact.
|
|
7
|
+
*
|
|
8
|
+
* Nothing here carries ssh output, argv, host names or keys: a cause is one of a fixed set of
|
|
9
|
+
* words, and times are numbers.
|
|
10
|
+
*/
|
|
11
|
+
import type { LinkDiagnosticSink } from "./diagnostics";
|
|
12
|
+
import type { StderrClass, TunnelState } from "./tunnel-state";
|
|
13
|
+
|
|
14
|
+
export type TunnelCause =
|
|
15
|
+
| "auth" | "hostkey" | "forward" | "remote_forward_conflict" | "timeout" | "network" | "unknown"
|
|
16
|
+
| "spawn_failed" | "listener_unavailable";
|
|
17
|
+
|
|
18
|
+
export const TUNNEL_CAUSES: readonly TunnelCause[] = [
|
|
19
|
+
"auth", "hostkey", "forward", "remote_forward_conflict", "timeout", "network", "unknown",
|
|
20
|
+
"spawn_failed", "listener_unavailable",
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
export interface TunnelTransitionRecord {
|
|
24
|
+
from: TunnelState["kind"];
|
|
25
|
+
to: TunnelState["kind"];
|
|
26
|
+
at: number;
|
|
27
|
+
cause: TunnelCause | null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface TunnelHistory {
|
|
31
|
+
/** ssh attempts started since the link was last connected (0 while connected). */
|
|
32
|
+
attempt: number;
|
|
33
|
+
lastTransition: TunnelTransitionRecord | null;
|
|
34
|
+
lastFailure: { cause: TunnelCause; at: number } | null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function exitCause(stderrClass: StderrClass): TunnelCause {
|
|
38
|
+
return stderrClass === "remote_forward" ? "remote_forward_conflict" : stderrClass;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* A retry of a failed link ended without connecting: one that ran while the state read failed, or
|
|
43
|
+
* one that could not even start (the retry count still grew).
|
|
44
|
+
*/
|
|
45
|
+
function retryFailedAgain(before: TunnelState, after: TunnelState): boolean {
|
|
46
|
+
if (before.kind !== "failed" || after.kind !== "failed" || after.inFlight === true) return false;
|
|
47
|
+
return before.inFlight === true || (after.retries ?? 0) > (before.retries ?? 0);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface TunnelHistoryBook {
|
|
51
|
+
/** Count one ssh attempt for the link. */
|
|
52
|
+
spawned(linkId: string): void;
|
|
53
|
+
/** Record a state change; `cause` names why the link left a working state, when known. */
|
|
54
|
+
observe(linkId: string, before: TunnelState, after: TunnelState, cause: TunnelCause | null, at: number): void;
|
|
55
|
+
get(linkId: string): TunnelHistory | undefined;
|
|
56
|
+
forget(linkId: string): void;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function createTunnelHistoryBook(
|
|
60
|
+
options: { diagnostic?: LinkDiagnosticSink; tunnelPort?: (linkId: string) => number | undefined } = {},
|
|
61
|
+
): TunnelHistoryBook {
|
|
62
|
+
const histories = new Map<string, TunnelHistory>();
|
|
63
|
+
const entry = (linkId: string): TunnelHistory => {
|
|
64
|
+
let history = histories.get(linkId);
|
|
65
|
+
if (!history) {
|
|
66
|
+
history = { attempt: 0, lastTransition: null, lastFailure: null };
|
|
67
|
+
histories.set(linkId, history);
|
|
68
|
+
}
|
|
69
|
+
return history;
|
|
70
|
+
};
|
|
71
|
+
return {
|
|
72
|
+
spawned(linkId) {
|
|
73
|
+
entry(linkId).attempt += 1;
|
|
74
|
+
},
|
|
75
|
+
observe(linkId, before, after, cause, at) {
|
|
76
|
+
const refailed = retryFailedAgain(before, after);
|
|
77
|
+
if (before.kind === after.kind && !refailed) return;
|
|
78
|
+
const history = entry(linkId);
|
|
79
|
+
if (after.kind === "connected" || after.kind === "idle") history.attempt = 0;
|
|
80
|
+
if (cause && (after.kind === "failed" || after.kind === "reconnecting")) history.lastFailure = { cause, at };
|
|
81
|
+
history.lastTransition = { from: before.kind, to: after.kind, at, cause };
|
|
82
|
+
const retryAt = after.kind === "failed" || after.kind === "reconnecting" ? after.retryAt : undefined;
|
|
83
|
+
const tunnelPort = options.tunnelPort?.(linkId);
|
|
84
|
+
try {
|
|
85
|
+
options.diagnostic?.({
|
|
86
|
+
event: "tunnel_transition",
|
|
87
|
+
outcome: cause ?? (after.kind === "connected" ? "connected" : after.kind === "idle" ? "stopped" : "spawned"),
|
|
88
|
+
state: after.kind,
|
|
89
|
+
previousState: before.kind,
|
|
90
|
+
attempt: history.attempt,
|
|
91
|
+
...(tunnelPort !== undefined ? { tunnelPort } : {}),
|
|
92
|
+
...(retryAt !== undefined ? { retryAfterMs: Math.max(0, retryAt - at) } : {}),
|
|
93
|
+
});
|
|
94
|
+
} catch { /* Diagnostics never change the tunnel lifecycle. */ }
|
|
95
|
+
},
|
|
96
|
+
get(linkId) {
|
|
97
|
+
const history = histories.get(linkId);
|
|
98
|
+
if (!history) return undefined;
|
|
99
|
+
return {
|
|
100
|
+
attempt: history.attempt,
|
|
101
|
+
lastTransition: history.lastTransition && { ...history.lastTransition },
|
|
102
|
+
lastFailure: history.lastFailure && { ...history.lastFailure },
|
|
103
|
+
};
|
|
104
|
+
},
|
|
105
|
+
forget(linkId) {
|
|
106
|
+
histories.delete(linkId);
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
}
|