@byok-sdk/client 0.1.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/LICENSE +21 -0
- package/README.md +11 -0
- package/dist/adapters/claude/claude-adapter.d.ts +155 -0
- package/dist/adapters/claude/events.d.ts +91 -0
- package/dist/adapters/claude/permission-mapping.d.ts +142 -0
- package/dist/adapters/claude/process-client.d.ts +103 -0
- package/dist/adapters/claude/resolve-approval-mcp-bin.d.ts +45 -0
- package/dist/adapters/claude/resolve-bin.d.ts +27 -0
- package/dist/adapters/codex/codex-adapter.d.ts +97 -0
- package/dist/adapters/codex/events.d.ts +41 -0
- package/dist/adapters/codex/permission-mapping.d.ts +107 -0
- package/dist/adapters/codex/process-runner.d.ts +86 -0
- package/dist/adapters/codex/resolve-bin.d.ts +25 -0
- package/dist/adapters/pi/events.d.ts +58 -0
- package/dist/adapters/pi/permission-mapping.d.ts +46 -0
- package/dist/adapters/pi/pi-adapter.d.ts +29 -0
- package/dist/adapters/pi/resolve-bin.d.ts +44 -0
- package/dist/adapters/pi/rpc-client.d.ts +101 -0
- package/dist/bin/approval-mcp-server.d.ts +69 -0
- package/dist/bin/audit-log.d.ts +212 -0
- package/dist/bin/byok-agent.d.ts +2 -0
- package/dist/bin/byok-agent.js +12347 -0
- package/dist/bin/byok-agent.js.map +1 -0
- package/dist/bin/byok-approval-mcp.d.ts +2 -0
- package/dist/bin/byok-approval-mcp.js +498 -0
- package/dist/bin/byok-approval-mcp.js.map +1 -0
- package/dist/bin/commands/approvals.d.ts +31 -0
- package/dist/bin/commands/approve-reject.d.ts +29 -0
- package/dist/bin/commands/doctor.d.ts +15 -0
- package/dist/bin/commands/pair.d.ts +7 -0
- package/dist/bin/commands/runtimes.d.ts +7 -0
- package/dist/bin/commands/service.d.ts +54 -0
- package/dist/bin/commands/start.d.ts +21 -0
- package/dist/bin/commands/status.d.ts +25 -0
- package/dist/bin/commands/support-bundle.d.ts +7 -0
- package/dist/bin/commands/tasks.d.ts +27 -0
- package/dist/bin/commands/unpair.d.ts +124 -0
- package/dist/bin/commands/workspaces.d.ts +16 -0
- package/dist/bin/config.d.ts +41 -0
- package/dist/bin/control-client.d.ts +50 -0
- package/dist/bin/format.d.ts +67 -0
- package/dist/bin/runtime-probe.d.ts +34 -0
- package/dist/bin/tasks-view.d.ts +49 -0
- package/dist/daemon/approvals.d.ts +87 -0
- package/dist/daemon/auth-manager.d.ts +52 -0
- package/dist/daemon/blob-client.d.ts +22 -0
- package/dist/daemon/connection-manager.d.ts +502 -0
- package/dist/daemon/control-protocol.d.ts +299 -0
- package/dist/daemon/control-server.d.ts +61 -0
- package/dist/daemon/create-daemon.d.ts +364 -0
- package/dist/daemon/cursor-store.d.ts +30 -0
- package/dist/daemon/daemon-owner.d.ts +24 -0
- package/dist/daemon/deterministic-jitter.d.ts +20 -0
- package/dist/daemon/device-keys.d.ts +45 -0
- package/dist/daemon/device-proof-signer.d.ts +41 -0
- package/dist/daemon/environment.d.ts +96 -0
- package/dist/daemon/git-workspace-store.d.ts +49 -0
- package/dist/daemon/git-workspace.d.ts +80 -0
- package/dist/daemon/http-client.d.ts +8 -0
- package/dist/daemon/journal/journal.d.ts +342 -0
- package/dist/daemon/journal/sqlite-journal.d.ts +173 -0
- package/dist/daemon/journal/sqlite-support.d.ts +96 -0
- package/dist/daemon/journal/storage-policy.d.ts +368 -0
- package/dist/daemon/long-poll-transport.d.ts +142 -0
- package/dist/daemon/observer.d.ts +345 -0
- package/dist/daemon/operational-health.d.ts +87 -0
- package/dist/daemon/policy.d.ts +16 -0
- package/dist/daemon/progress-batcher.d.ts +31 -0
- package/dist/daemon/runtime-capabilities.d.ts +36 -0
- package/dist/daemon/session-workspace-store.d.ts +75 -0
- package/dist/daemon/store.d.ts +54 -0
- package/dist/daemon/task-runner.d.ts +862 -0
- package/dist/daemon/truth-memory-client.d.ts +94 -0
- package/dist/daemon/url.d.ts +60 -0
- package/dist/daemon/ws-transport.d.ts +110 -0
- package/dist/diagnostics/diagnostics.d.ts +114 -0
- package/dist/diagnostics/support-bundle.d.ts +81 -0
- package/dist/index.d.ts +42 -0
- package/dist/index.js +10056 -0
- package/dist/index.js.map +1 -0
- package/dist/lifecycle/create-service-lifecycle.d.ts +59 -0
- package/dist/lifecycle/exec-runner.d.ts +141 -0
- package/dist/lifecycle/launchd.d.ts +55 -0
- package/dist/lifecycle/service-types.d.ts +177 -0
- package/dist/lifecycle/systemd.d.ts +46 -0
- package/dist/lifecycle/winsw.d.ts +64 -0
- package/dist/types.d.ts +178 -0
- package/dist/util/async-queue.d.ts +45 -0
- package/dist/util/atomic-write.d.ts +86 -0
- package/dist/util/secure-dir.d.ts +124 -0
- package/package.json +66 -0
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* M4 Phase 2: minimal pending-approval registry backing the control
|
|
3
|
+
* socket's `approvals.list`/`approvals.resolve` methods.
|
|
4
|
+
*
|
|
5
|
+
* Nothing PRODUCES an approval yet in Phase 2 — every one of the three
|
|
6
|
+
* bundled runtime adapters (pi/claude/codex) still has no interactive
|
|
7
|
+
* `needs_approval` path (see `create-daemon.ts`'s `toRuntimeInfoCapabilities`
|
|
8
|
+
* doc comment) — so `list()` always returns `[]` and `resolve()` always
|
|
9
|
+
* throws {@link ApprovalNotFoundError} against a real daemon today. This
|
|
10
|
+
* class exists now so Phase 3 (the claude permission-prompt path) only has
|
|
11
|
+
* to call `register()` from wherever it detects a prompt; the control-socket
|
|
12
|
+
* plumbing (`control-server.ts`'s method registry, the CLI's `approve`/
|
|
13
|
+
* `reject` commands) is already wired end-to-end against this same registry.
|
|
14
|
+
*/
|
|
15
|
+
export type ApprovalDecision = 'approve' | 'reject';
|
|
16
|
+
/**
|
|
17
|
+
* M4 (additive-minor, `task.approval_resolved`): distinguishes a resolution
|
|
18
|
+
* that arrived over the wire (a server-sent `task.approve`/`task.reject`,
|
|
19
|
+
* relayed here via `TaskContext.approvalChannel.resolve` — `task-runner.ts`'s
|
|
20
|
+
* `handleOffer`) from one this device decided on its own (the local
|
|
21
|
+
* `approvals.resolve` control-socket RPC, a fail-closed `requestApproval`
|
|
22
|
+
* timeout, or a fail-closed finish/eviction rejection). `TaskRunner` uses
|
|
23
|
+
* this to decide whether to report `task.approval_resolved` back to the
|
|
24
|
+
* server: a wire-triggered resolution is something the server already knows
|
|
25
|
+
* about (it sent the decision itself) and must never be echoed back;
|
|
26
|
+
* everything else is new information only the device has, and — capability
|
|
27
|
+
* permitting — gets reported. `'local'` is the default (see `resolve()`
|
|
28
|
+
* below) precisely because it's the common case: every call site in this
|
|
29
|
+
* module and `task-runner.ts` except the one wire-relay closure is local by
|
|
30
|
+
* construction.
|
|
31
|
+
*/
|
|
32
|
+
export type ApprovalOrigin = 'wire' | 'local';
|
|
33
|
+
/** What `approvals.list` returns per pending approval — deliberately small; a runtime-specific payload (e.g. the exact tool call awaiting approval) is Phase 3's concern, not this registry's. */
|
|
34
|
+
export interface PendingApproval {
|
|
35
|
+
approvalId: string;
|
|
36
|
+
taskId: string;
|
|
37
|
+
summary?: string;
|
|
38
|
+
createdAt: string;
|
|
39
|
+
}
|
|
40
|
+
export declare class ApprovalNotFoundError extends Error {
|
|
41
|
+
constructor(approvalId: string);
|
|
42
|
+
}
|
|
43
|
+
/** Cap on simultaneously pending approvals — generous for any plausible concurrent-approval workload, and existing purely as a defensive bound (mirrors `task-runner.ts`'s `MAX_TRACKED_TASK_IDS`/`observer.ts`'s `MAX_TRACKED_TASKS`), not a real-world limit this is expected to ever approach. */
|
|
44
|
+
export declare const MAX_PENDING_APPROVALS = 200;
|
|
45
|
+
type ResolveCallback = (decision: ApprovalDecision, reason: string | undefined, origin: ApprovalOrigin) => void;
|
|
46
|
+
/**
|
|
47
|
+
* `register()`/`resolve()` are the producer/consumer halves of one pending
|
|
48
|
+
* approval: a future runtime adapter integration calls `register()` when it
|
|
49
|
+
* pauses a task awaiting a decision and gets called back via `onResolve`
|
|
50
|
+
* once `resolve()` is invoked (locally, or — Phase 2's actual wiring — via
|
|
51
|
+
* the control socket's `approvals.resolve` RPC). `list()` is a pure read
|
|
52
|
+
* for `approvals.list`.
|
|
53
|
+
*/
|
|
54
|
+
export declare class ApprovalRegistry {
|
|
55
|
+
private readonly pending;
|
|
56
|
+
/**
|
|
57
|
+
* Registers a new pending approval, evicting the OLDEST entry first if
|
|
58
|
+
* already at {@link MAX_PENDING_APPROVALS} — bounded, not unbounded
|
|
59
|
+
* growth, for a long-lived daemon. The evicted entry's own `onResolve` is
|
|
60
|
+
* called (as a reject, with a reason naming the eviction) rather than
|
|
61
|
+
* simply dropped: whatever registered it (a future Phase 3 producer,
|
|
62
|
+
* e.g. a paused runtime session awaiting a decision) is very likely
|
|
63
|
+
* still waiting on that callback firing at all — silently stranding it
|
|
64
|
+
* would leave that producer hanging forever instead of unblocking it
|
|
65
|
+
* with a clear, if unwelcome, outcome.
|
|
66
|
+
*/
|
|
67
|
+
register(approval: PendingApproval, onResolve: ResolveCallback): void;
|
|
68
|
+
list(): PendingApproval[];
|
|
69
|
+
/**
|
|
70
|
+
* Throws {@link ApprovalNotFoundError} for an unknown/already-resolved id —
|
|
71
|
+
* never silently no-ops, since a caller (the control socket's
|
|
72
|
+
* `approvals.resolve`) needs to distinguish "resolved" from "nothing to
|
|
73
|
+
* resolve".
|
|
74
|
+
*
|
|
75
|
+
* `origin` defaults to `'local'` (see {@link ApprovalOrigin}'s own doc
|
|
76
|
+
* comment for why that's the correct default, not just a convenient one):
|
|
77
|
+
* every existing caller of this method — the control socket's
|
|
78
|
+
* `approvals.resolve` RPC (`create-daemon.ts`), `TaskRunner`'s
|
|
79
|
+
* `requestApproval` timeout and `finish()` fail-closed cleanup
|
|
80
|
+
* (`task-runner.ts`) — resolves a decision this device made on its own.
|
|
81
|
+
* The one exception, a server-sent wire `task.approve`/`task.reject`
|
|
82
|
+
* relayed through `TaskContext.approvalChannel.resolve`
|
|
83
|
+
* (`task-runner.ts`'s `handleOffer`), passes `'wire'` explicitly.
|
|
84
|
+
*/
|
|
85
|
+
resolve(approvalId: string, decision: ApprovalDecision, reason?: string, origin?: ApprovalOrigin): void;
|
|
86
|
+
}
|
|
87
|
+
export {};
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { DeviceStore, type DeviceRecord } from './store';
|
|
2
|
+
/**
|
|
3
|
+
* Thrown when the server has revoked this device: a 401 on `/byok/challenge`
|
|
4
|
+
* or `/byok/token` (protocol §6.3). The only recourse is a fresh
|
|
5
|
+
* `/byok/pair` — callers must surface a clear "re-pair needed" state and
|
|
6
|
+
* must NOT retry the renewal in a loop.
|
|
7
|
+
*/
|
|
8
|
+
export declare class DeviceRevokedError extends Error {
|
|
9
|
+
constructor(message?: string);
|
|
10
|
+
}
|
|
11
|
+
export interface AuthManagerOptions {
|
|
12
|
+
serverUrl: string;
|
|
13
|
+
store: DeviceStore;
|
|
14
|
+
deviceName?: string;
|
|
15
|
+
/** Called once revocation is detected, so a caller (ConnectionManager) can stop retrying and surface the state instead of looping. */
|
|
16
|
+
onRevoked?: () => void;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Owns device pairing and the access token lifecycle (protocol §6):
|
|
20
|
+
* generates/reuses the device Ed25519 keypair, pairs, and renews the access
|
|
21
|
+
* token both proactively (before expiry) and reactively (on a 401 from any
|
|
22
|
+
* caller). This is the single source of truth for "the current valid JWT"
|
|
23
|
+
* that WS connects, blob HTTP calls, and the long-poll fallback all use.
|
|
24
|
+
*/
|
|
25
|
+
export declare class AuthManager {
|
|
26
|
+
private readonly opts;
|
|
27
|
+
private record;
|
|
28
|
+
private renewing;
|
|
29
|
+
private proactiveTimer;
|
|
30
|
+
private revoked;
|
|
31
|
+
private stopped;
|
|
32
|
+
private pairing;
|
|
33
|
+
private credentialMutationTail;
|
|
34
|
+
constructor(opts: AuthManagerOptions);
|
|
35
|
+
get deviceId(): string | undefined;
|
|
36
|
+
isRevoked(): boolean;
|
|
37
|
+
/** Load a previously-paired device record from disk, if any (idempotent — a second call is a no-op once loaded). */
|
|
38
|
+
loadExisting(): Promise<DeviceRecord | undefined>;
|
|
39
|
+
/** `POST /byok/pair` (v2): generates a device keypair on first pair, reuses it on any subsequent (e.g. post-revocation) re-pair. */
|
|
40
|
+
pair(pairingCode: string): Promise<DeviceRecord>;
|
|
41
|
+
/** The current, non-expired access token — renews first if it's expired or close to it. Throws {@link DeviceRevokedError} if the device has been revoked. */
|
|
42
|
+
getValidAccessToken(): Promise<string>;
|
|
43
|
+
/** Force a renewal regardless of the cached token's remaining lifetime — the reactive path, used after a caller sees a 401. */
|
|
44
|
+
handleUnauthorized(): Promise<string>;
|
|
45
|
+
stop(): Promise<void>;
|
|
46
|
+
private renew;
|
|
47
|
+
private doRenew;
|
|
48
|
+
/** Always throws — `never` return type lets call sites use `if (x === 401) this.markRevoked();` without an explicit `return`/`throw` of their own. */
|
|
49
|
+
private markRevoked;
|
|
50
|
+
private scheduleProactiveRenewal;
|
|
51
|
+
private runCredentialMutation;
|
|
52
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { BlobRef } from '@byok-sdk/protocol';
|
|
2
|
+
import type { AuthManager } from './auth-manager';
|
|
3
|
+
/** Seam `TaskRunner` depends on, so tests can substitute a fake without spinning up real HTTP endpoints. */
|
|
4
|
+
export interface BlobResolver {
|
|
5
|
+
resolveInstruction(blobRef: BlobRef): Promise<string>;
|
|
6
|
+
uploadArtifact(content: string | Uint8Array, contentType: string): Promise<BlobRef>;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* HTTP-side blob transfer (protocol §7): resolving an instruction `blobRef`
|
|
10
|
+
* into its actual content, and uploading an artifact too large to inline.
|
|
11
|
+
* Both require a valid bearer token, handled via `authedFetch`.
|
|
12
|
+
*/
|
|
13
|
+
export declare class BlobClient implements BlobResolver {
|
|
14
|
+
#private;
|
|
15
|
+
private readonly serverUrl;
|
|
16
|
+
private readonly auth;
|
|
17
|
+
constructor(serverUrl: string, auth: AuthManager);
|
|
18
|
+
/** `blobRef` -> `GET /byok/blobs/:id/url` -> fetch the presigned download URL -> text content. Always resolves fresh rather than trusting any inlined `BlobRef.url`, per docs/protocol.md §7. */
|
|
19
|
+
resolveInstruction(blobRef: BlobRef): Promise<string>;
|
|
20
|
+
/** `POST /byok/blobs` (declares size/contentType/contentHash) -> PUT the bytes to the presigned upload URL -> a `BlobRef` for `task.artifact.blobRef`. */
|
|
21
|
+
uploadArtifact(content: string | Uint8Array, contentType: string): Promise<BlobRef>;
|
|
22
|
+
}
|
|
@@ -0,0 +1,502 @@
|
|
|
1
|
+
import { type CapabilityFlag, type Envelope, type RuntimeInfo } from '@byok-sdk/protocol';
|
|
2
|
+
import { AuthManager } from './auth-manager';
|
|
3
|
+
import type { CursorStore } from './cursor-store';
|
|
4
|
+
import { type FleetJitter } from './deterministic-jitter';
|
|
5
|
+
import { type BackoffOptions, type ConnectionState, type LivenessOptions } from './ws-transport';
|
|
6
|
+
export type { ConnectionState } from './ws-transport';
|
|
7
|
+
export interface ConnectionManagerOptions {
|
|
8
|
+
serverUrl: string;
|
|
9
|
+
deviceId: string;
|
|
10
|
+
productId: string;
|
|
11
|
+
capabilities: CapabilityFlag[];
|
|
12
|
+
runtimes: RuntimeInfo[];
|
|
13
|
+
auth: AuthManager;
|
|
14
|
+
cursorStore: CursorStore;
|
|
15
|
+
/**
|
|
16
|
+
* May return a promise; `ConnectionManager` awaits it before considering
|
|
17
|
+
* this envelope "processed" (findings F2/F3 — see `deliver`/`process`
|
|
18
|
+
* below). A handler that throws/rejects is caught here, not propagated.
|
|
19
|
+
*/
|
|
20
|
+
onEnvelope: (envelope: Envelope) => void | Promise<void>;
|
|
21
|
+
onStateChange?: (state: ConnectionState) => void;
|
|
22
|
+
backoff?: BackoffOptions;
|
|
23
|
+
liveness?: LivenessOptions;
|
|
24
|
+
/** Consecutive never-acked WS connect failures before falling back to long-poll (protocol §8). Default 3. */
|
|
25
|
+
wsFailureThreshold?: number;
|
|
26
|
+
/** While long-polling, how often to retry establishing WS (protocol §8, "e.g. every 5 min"). Default 5 minutes. */
|
|
27
|
+
wsRetryIntervalMs?: number;
|
|
28
|
+
/** Backoff between failed long-poll HTTP attempts. Default 2s. */
|
|
29
|
+
longPollRetryDelayMs?: number;
|
|
30
|
+
/** Minimum delay before the next long-poll request after an empty (no-events) response. Default 250ms. */
|
|
31
|
+
longPollIdleDelayMs?: number;
|
|
32
|
+
fleetJitter?: FleetJitter;
|
|
33
|
+
onOperationalOutcome?: (outcome: 'success' | 'failure', source: 'reconnect' | 'upload') => void;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Owns the daemon's one logical connection to the server, which may be
|
|
37
|
+
* backed by either transport the wire protocol defines: WS (the normal
|
|
38
|
+
* path) or long-poll (protocol §8's fallback for environments where an
|
|
39
|
+
* outbound WSS connection isn't viable). Both funnel every received
|
|
40
|
+
* envelope through the same cursor-dedupe/persistence logic (protocol §9),
|
|
41
|
+
* so redelivery is safe regardless of which transport happens to deliver a
|
|
42
|
+
* given envelope — including during the brief overlap window when handing
|
|
43
|
+
* off between them.
|
|
44
|
+
*
|
|
45
|
+
* `send()` (Design B, finding N4) pushes onto a single shared outbox this
|
|
46
|
+
* class owns and drains through whichever transport is currently active —
|
|
47
|
+
* WS raw-sends while acked, `POST /byok/messages` while long-polling
|
|
48
|
+
* (finding F6, long-poll is a full transport, not receive-only; see
|
|
49
|
+
* docs/protocol.md §8) — so a transport switch mid-flight never strands a
|
|
50
|
+
* queued envelope. See `drainOutbox`.
|
|
51
|
+
*/
|
|
52
|
+
export declare class ConnectionManager {
|
|
53
|
+
private readonly opts;
|
|
54
|
+
private readonly fleetJitter;
|
|
55
|
+
private readonly ws;
|
|
56
|
+
private readonly longPoll;
|
|
57
|
+
private mode;
|
|
58
|
+
private consecutiveFailures;
|
|
59
|
+
private wsRetryTimer;
|
|
60
|
+
private wsProbeSequence;
|
|
61
|
+
private uploadRetryAttempt;
|
|
62
|
+
private cursor;
|
|
63
|
+
/**
|
|
64
|
+
* Finding F3 (at-most-once redelivery): the lowest `task.*` envelope `seq`
|
|
65
|
+
* whose handler failed and hasn't yet been successfully reprocessed. While
|
|
66
|
+
* set, the cursor is frozen at its pre-failure value even if later
|
|
67
|
+
* envelopes succeed — advancing past a still-unresolved failure would
|
|
68
|
+
* mean a future reconnect's redelivery skips it forever (it's <= the
|
|
69
|
+
* persisted cursor), which is exactly the bug this fixes. Cleared once an
|
|
70
|
+
* envelope carrying this exact seq is reprocessed (via redelivery after a
|
|
71
|
+
* reconnect) and succeeds; everything from there back up to the new
|
|
72
|
+
* cursor gets safely re-attempted too, relying on the idempotency
|
|
73
|
+
* guarantees in docs/protocol.md §9.
|
|
74
|
+
*/
|
|
75
|
+
private stalledAtSeq;
|
|
76
|
+
/**
|
|
77
|
+
* Design A (Wave 2, F3-on-long-poll): the second, in-memory watermark
|
|
78
|
+
* alongside the durable `cursor`. `cursor` only ever advances AFTER a
|
|
79
|
+
* `task.*` handler's side effects resolve successfully, and is persisted
|
|
80
|
+
* (see `advanceCursor`) — that semantics is unchanged. `deliveredSeq`
|
|
81
|
+
* advances eagerly, the instant a `task.*` envelope is admitted past
|
|
82
|
+
* dedup (see `deliver`/`noteDelivered`), independent of whether its
|
|
83
|
+
* handler has even started, let alone succeeded. It exists so a
|
|
84
|
+
* long-poll re-query (`LongPollClient`'s `getCursor`) doesn't re-pull an
|
|
85
|
+
* envelope that's already been delivered once and is still in flight —
|
|
86
|
+
* `handleOffer` is NOT idempotent and must never be re-pulled while a
|
|
87
|
+
* first attempt is still running. On WS this same field is written the
|
|
88
|
+
* same way, but since a live WS connection only ever pushes a given `seq`
|
|
89
|
+
* once, it never has an observable effect there beyond mirroring
|
|
90
|
+
* `cursor` (see `dedupWatermark`'s doc comment for why redelivery
|
|
91
|
+
* correctness doesn't depend on resetting it anywhere).
|
|
92
|
+
*/
|
|
93
|
+
private deliveredSeq;
|
|
94
|
+
/** Finding F3: serializes `onEnvelope` calls into a per-connection FIFO — one envelope's handler always fully settles before the next one starts. */
|
|
95
|
+
private processingChain;
|
|
96
|
+
/**
|
|
97
|
+
* Design B (finding N4): the ONE outbound queue both transports drain
|
|
98
|
+
* from — holds `Envelope` OBJECTS, never re-encoded/rebuilt strings, so a
|
|
99
|
+
* resend after a failed send attempt is byte-identical to the original
|
|
100
|
+
* (same `id`), which is what lets the server's per-(deviceId,id) dedup
|
|
101
|
+
* (Wave 1) recognize it as a safe no-op retry rather than a second
|
|
102
|
+
* application (protocol §9). A transport switch (long-poll <-> WS) never
|
|
103
|
+
* touches this queue — see `drainOutbox` — so nothing queued while one
|
|
104
|
+
* transport was active is ever stranded when the other takes over.
|
|
105
|
+
*/
|
|
106
|
+
private readonly outbox;
|
|
107
|
+
/**
|
|
108
|
+
* Finding F5(b): how many envelopes `drainOutbox`'s long-poll branch has
|
|
109
|
+
* currently spliced OUT of `this.outbox` for an in-flight (not yet
|
|
110
|
+
* confirmed delivered) `postBatch` call — 0 the rest of the time. See
|
|
111
|
+
* `outboxLength`'s own doc comment for why this needs to be tracked
|
|
112
|
+
* separately from `this.outbox.length` at all.
|
|
113
|
+
*/
|
|
114
|
+
private inFlightBatchSize;
|
|
115
|
+
private draining;
|
|
116
|
+
private stopped;
|
|
117
|
+
private revoked;
|
|
118
|
+
private settledWaiters;
|
|
119
|
+
private pendingCursorSave;
|
|
120
|
+
/**
|
|
121
|
+
* Finding P2 (Fix 2b): seqs currently admitted into `processingChain` but
|
|
122
|
+
* not yet settled — added in `deliver()` the moment a `task.*` envelope is
|
|
123
|
+
* accepted past the ordinary watermark check, removed in `process()`'s
|
|
124
|
+
* `finally` once that specific attempt resolves (success OR failure).
|
|
125
|
+
* While stalled, `dedupWatermark()` deliberately stays frozen below
|
|
126
|
+
* already-delivered seqs (see its own doc comment) so the failed seq's own
|
|
127
|
+
* redelivery can get through — but that same frozen watermark also means
|
|
128
|
+
* every OTHER seq above it rides along on every re-poll too. Without this,
|
|
129
|
+
* a seq already mid-flight (e.g. a `task.offer` whose `adapter.start()`
|
|
130
|
+
* hasn't resolved yet) would be re-enqueued into `processingChain` on
|
|
131
|
+
* every such re-poll, piling up duplicate copies that — once the first
|
|
132
|
+
* finally resolves and the chain unwinds through them — run its handler
|
|
133
|
+
* again; for `task.offer` specifically, a second adapter session
|
|
134
|
+
* orphaning the first (`TaskRunner`'s own `this.tasks.has` guard, finding
|
|
135
|
+
* P2c, is the second, independent layer against exactly that).
|
|
136
|
+
*/
|
|
137
|
+
private readonly inFlightSeqs;
|
|
138
|
+
/**
|
|
139
|
+
* Finding P2 (Fix 2b): seqs whose handler has already resolved
|
|
140
|
+
* successfully at least once this session, tracked only while a stall is
|
|
141
|
+
* in effect — cleared the moment `stalledAtSeq` itself clears (see
|
|
142
|
+
* `process()`), since once unstalled the ordinary watermark check via
|
|
143
|
+
* `deliveredSeq` already covers everything delivered so far, making this
|
|
144
|
+
* redundant. Needed because the stall-gap-prevention rule in `process()`
|
|
145
|
+
* deliberately does NOT advance `cursor` past a seq above the
|
|
146
|
+
* still-unresolved `stalledAtSeq`, even once that seq's own handler
|
|
147
|
+
* succeeds — so `dedupWatermark()` alone can't distinguish "already
|
|
148
|
+
* succeeded, don't re-run" from "never yet attempted" for anything in
|
|
149
|
+
* that gap.
|
|
150
|
+
*/
|
|
151
|
+
private readonly processedSeqs;
|
|
152
|
+
/**
|
|
153
|
+
* Finding P3: the pending `drainOutbox` long-poll retry backoff, if any —
|
|
154
|
+
* cancellable so `enterRevoked()` can unblock it immediately instead of
|
|
155
|
+
* waiting out the rest of the delay before the loop notices `revoked` and
|
|
156
|
+
* exits. See `drainRetryDelay`.
|
|
157
|
+
*/
|
|
158
|
+
private cancelPendingDrainRetry;
|
|
159
|
+
/**
|
|
160
|
+
* The capabilities the CURRENTLY connected server advertised in its
|
|
161
|
+
* `conn.ack` — untyped `string[]` (forward-compat: a server may advertise
|
|
162
|
+
* a flag this build doesn't recognize yet), populated by {@link onAcked}
|
|
163
|
+
* and read by {@link getServerCapabilities}. Empty until the very first
|
|
164
|
+
* successful handshake.
|
|
165
|
+
*
|
|
166
|
+
* Finding R2 (cross-model re-review — was P1): strictly PER-CONNECTION,
|
|
167
|
+
* not per-daemon-lifetime. Cleared to `[]` the instant the acked WS
|
|
168
|
+
* connection ends for ANY reason — an ordinary disconnect (`onWsOutcome`'s
|
|
169
|
+
* `acked` branch), `stop()`, or a transport switch to long-poll
|
|
170
|
+
* (`enterLongPoll`) — and only ever repopulated by a FRESH `conn.ack`.
|
|
171
|
+
* The previous version of this doc comment claimed long-poll mode simply
|
|
172
|
+
* "stays at whatever the last real WS `conn.ack` said" — that was the bug:
|
|
173
|
+
* a daemon that once learned e.g. `approval_resolved` from an earlier WS
|
|
174
|
+
* session kept believing it applied to whatever it's connected to NOW,
|
|
175
|
+
* even after a disconnect/degrade where nothing has actually confirmed
|
|
176
|
+
* that's still true (a reconnect could land on a DIFFERENT server behind a
|
|
177
|
+
* load balancer; long-poll fallback itself never performs an equivalent
|
|
178
|
+
* handshake at all). Concretely, `TaskRunner.sendApprovalResolved` gates
|
|
179
|
+
* `task.approval_resolved` on this list — sending it to a server that
|
|
180
|
+
* doesn't actually understand it over the long-poll path would get a
|
|
181
|
+
* batch-level 400 from `MessagesSendRequestSchema` (protocol §8.2), which
|
|
182
|
+
* `drainOutbox`'s retry-the-same-batch-forever loop then head-of-line
|
|
183
|
+
* blocks EVERY envelope queued behind it on, permanently. Clearing this
|
|
184
|
+
* eagerly means that gate reliably fails closed (falls back to the
|
|
185
|
+
* pre-existing implicit-resume inference, unconditionally — see
|
|
186
|
+
* `sendApprovalResolved`'s own doc comment) the moment the connection that
|
|
187
|
+
* advertised the capability is no longer the one actually in use.
|
|
188
|
+
*/
|
|
189
|
+
private serverCapabilities;
|
|
190
|
+
constructor(opts: ConnectionManagerOptions);
|
|
191
|
+
start(): Promise<void>;
|
|
192
|
+
/**
|
|
193
|
+
* Design B (finding N4): push onto the single shared outbox and try to
|
|
194
|
+
* drain it now. Never routes directly to either transport itself — see
|
|
195
|
+
* `drainOutbox`.
|
|
196
|
+
*/
|
|
197
|
+
send(envelope: Envelope): void;
|
|
198
|
+
/**
|
|
199
|
+
* Design B (finding N4): drain the shared outbox through whichever
|
|
200
|
+
* transport is currently active, re-checking `this.mode` fresh on every
|
|
201
|
+
* iteration so a transport switch mid-drain is picked up immediately
|
|
202
|
+
* rather than fighting a stale decision made before the switch.
|
|
203
|
+
*
|
|
204
|
+
* WS: a synchronous, one-at-a-time `sendNow` per envelope while open+
|
|
205
|
+
* acked; stops (without dropping anything — the remainder stays queued)
|
|
206
|
+
* the moment it isn't, and is re-invoked once `onAcked` fires.
|
|
207
|
+
*
|
|
208
|
+
* Long-poll: POSTs the outbox in chunks of at most
|
|
209
|
+
* `MAX_MESSAGES_PER_BATCH` (finding P1) — the server hard-caps a single
|
|
210
|
+
* `/byok/messages` batch there (`MessagesSendRequestSchema`, protocol
|
|
211
|
+
* §8.2) and 400s the WHOLE request if it's exceeded, which — before this
|
|
212
|
+
* fix — meant more than that queued during an outage produced an oversize
|
|
213
|
+
* batch that the server would reject forever, since the client re-queued
|
|
214
|
+
* and retried the identical (still oversize) batch unchanged. Each chunk
|
|
215
|
+
* is one `LongPollClient.postBatch` call; on success the loop continues
|
|
216
|
+
* (more may still be queued, or the next chunk still needs sending), on
|
|
217
|
+
* failure that SAME chunk is unshifted back (order-preserving, same
|
|
218
|
+
* Envelope objects/ids — never rebuilt, so a retry is exactly the resend
|
|
219
|
+
* Wave 1's server-side dedup expects) and retried after a short backoff,
|
|
220
|
+
* re-reading `this.mode` each time so a WS recovery that happens
|
|
221
|
+
* mid-retry is honored on the very next loop iteration instead of only
|
|
222
|
+
* after this attempt's backoff chain gives up.
|
|
223
|
+
*
|
|
224
|
+
* Re-entrancy is guarded by `draining`: a call arriving while a drain is
|
|
225
|
+
* already in progress just returns — the in-progress loop's own
|
|
226
|
+
* `while (this.outbox.length > 0)` check will pick up anything newly
|
|
227
|
+
* pushed (or left over after a mode switch) on its very next iteration.
|
|
228
|
+
*/
|
|
229
|
+
private drainOutbox;
|
|
230
|
+
/**
|
|
231
|
+
* Finding P3: backoff delay for `drainOutbox`'s long-poll retry loop.
|
|
232
|
+
* Unlike a plain `setTimeout`-based wait, this is (a) cancellable —
|
|
233
|
+
* `enterRevoked()` calls `cancelPendingDrainRetry()` to unblock an
|
|
234
|
+
* in-flight wait immediately instead of leaving `drainOutbox` parked here
|
|
235
|
+
* for up to the rest of the delay before it next checks `this.revoked` —
|
|
236
|
+
* and (b) unref'd, so the timer never keeps the Node process alive by
|
|
237
|
+
* itself while nothing else (a live long-poll GET, an open WS connection)
|
|
238
|
+
* legitimately is.
|
|
239
|
+
*/
|
|
240
|
+
private drainRetryDelay;
|
|
241
|
+
isTransportDegraded(): boolean;
|
|
242
|
+
/**
|
|
243
|
+
* The capabilities the CURRENTLY connected server advertised in its
|
|
244
|
+
* `conn.ack` — e.g. lets a caller gate a daemon->server message on whether
|
|
245
|
+
* THIS server understands it before sending (see `task-runner.ts`'s
|
|
246
|
+
* `sendApprovalResolved`, gated on `approval_resolved`). Empty before the
|
|
247
|
+
* first handshake completes, AND (finding R2) once again empty after any
|
|
248
|
+
* disconnect/degrade — see `serverCapabilities`'s own doc comment for why
|
|
249
|
+
* this is strictly per-connection rather than "sticky" across one.
|
|
250
|
+
*/
|
|
251
|
+
getServerCapabilities(): readonly string[];
|
|
252
|
+
isConnected(): boolean;
|
|
253
|
+
isRevoked(): boolean;
|
|
254
|
+
/**
|
|
255
|
+
* Resolves once the connection has settled — either a working, acked WS
|
|
256
|
+
* connection, or the long-poll fallback taking over (protocol §8). This
|
|
257
|
+
* lets `daemon.start()` return promptly even when WS is unavailable from
|
|
258
|
+
* the very first attempt, rather than hanging until a WS `conn.ack` that
|
|
259
|
+
* may never come.
|
|
260
|
+
*
|
|
261
|
+
* Rejects with {@link DeviceRevokedError} — instead of hanging until
|
|
262
|
+
* `timeoutMs` — if the device turns out to be revoked while settling (or
|
|
263
|
+
* already was): a cold `daemon.start()` against an already-revoked device
|
|
264
|
+
* must fail fast, not surface a generic timeout (protocol §6.3).
|
|
265
|
+
*/
|
|
266
|
+
waitForAck(timeoutMs?: number): Promise<void>;
|
|
267
|
+
/**
|
|
268
|
+
* Stops both transports and waits for every in-flight envelope handler
|
|
269
|
+
* (the F3 FIFO chain) and the most recent cursor write to actually land on
|
|
270
|
+
* disk — otherwise a `stop()` racing a just-processed envelope's
|
|
271
|
+
* persistence could lose that cursor advance, or leave a handler running
|
|
272
|
+
* unobserved after the daemon reports itself stopped.
|
|
273
|
+
*
|
|
274
|
+
* Finding F5(b) (cross-model adversarial review): `drainTimeoutMs`, when
|
|
275
|
+
* passed, bounds how long this waits for the shared outbox (`this.outbox`
|
|
276
|
+
* — Design B) to actually finish draining BEFORE flipping `this.stopped`
|
|
277
|
+
* and closing the transports. Before this fix, `stop()` set `stopped`
|
|
278
|
+
* synchronously and never waited for `drainOutbox` at all: an envelope
|
|
279
|
+
* `send()` had just pushed moments earlier (e.g. `TaskRunner.shutdownTask`'s
|
|
280
|
+
* own `task.fail`, sent right before `create-daemon.ts`'s
|
|
281
|
+
* `performControlShutdown` calls this) could still be sitting UNSENT in
|
|
282
|
+
* `this.outbox` — mid long-poll retry backoff, or simply not yet picked up
|
|
283
|
+
* by the fire-and-forget `drainOutbox()` `send()` kicked off — and this
|
|
284
|
+
* method would happily proceed to `stopped = true` / `ws.close()` regardless,
|
|
285
|
+
* after which NOTHING ever drains it again: silently lost, even though
|
|
286
|
+
* `TaskRunner` believed it had been sent. `drainTimeoutMs` omitted (the
|
|
287
|
+
* default) preserves the EXACT prior behavior for every other existing
|
|
288
|
+
* caller (an ordinary `daemon.stop()`/`unpair()`) — only the control-socket
|
|
289
|
+
* shutdown path opts into the bounded wait (see `create-daemon.ts`'s
|
|
290
|
+
* `performControlShutdown`). This can never claim delivery that didn't
|
|
291
|
+
* happen: on a timeout, whatever's still queued stays exactly where it is
|
|
292
|
+
* (readable via {@link outboxLength} immediately after this resolves) —
|
|
293
|
+
* it does NOT force-flush or pretend success.
|
|
294
|
+
*/
|
|
295
|
+
stop(drainTimeoutMs?: number): Promise<void>;
|
|
296
|
+
/**
|
|
297
|
+
* Finding F5(b): how many envelopes are neither confirmed delivered NOR
|
|
298
|
+
* safely re-queued — meant to be read right after a bounded {@link stop}
|
|
299
|
+
* call returns, to know honestly whether the drain actually finished (0)
|
|
300
|
+
* or timed out with something still stuck (>0). See `create-daemon.ts`'s
|
|
301
|
+
* `performControlShutdown`, which surfaces this on the `shutdown-complete`
|
|
302
|
+
* audit event rather than silently claiming everything was delivered.
|
|
303
|
+
*
|
|
304
|
+
* Deliberately `this.outbox.length + this.inFlightBatchSize`, NOT just
|
|
305
|
+
* `this.outbox.length` alone: `drainOutbox`'s long-poll branch SPLICES a
|
|
306
|
+
* batch out of `this.outbox` before awaiting `postBatch` (so a concurrent
|
|
307
|
+
* `send()` sees an accurate, non-double-counted queue) — while that POST
|
|
308
|
+
* is in flight (or, this finding's whole point, genuinely STALLED and
|
|
309
|
+
* never resolving), those envelopes have already left `this.outbox` but
|
|
310
|
+
* are not delivered either. Reading `this.outbox.length` alone at exactly
|
|
311
|
+
* that moment would undercount to 0 — silently implying full delivery
|
|
312
|
+
* for the one case (a hung POST) this finding exists to catch honestly.
|
|
313
|
+
*/
|
|
314
|
+
outboxLength(): number;
|
|
315
|
+
/**
|
|
316
|
+
* Finding F5(b): polls {@link outboxLength} (not `this.outbox.length`
|
|
317
|
+
* alone — see that method's own doc comment for why a spliced-out,
|
|
318
|
+
* in-flight batch would otherwise be invisible here) rather than hooking
|
|
319
|
+
* a single `drainOutbox()` promise directly — a drain in progress can
|
|
320
|
+
* itself loop through multiple retry/backoff cycles (`drainRetryDelay`)
|
|
321
|
+
* while the server is unreachable, and a fresh, INDEPENDENT
|
|
322
|
+
* `drainOutbox()` call can also be triggered concurrently (`send()`, a
|
|
323
|
+
* mode switch's own `void this.drainOutbox()`) — polling the one thing
|
|
324
|
+
* that actually matters (is anything still undelivered) can never go
|
|
325
|
+
* stale the way capturing one specific in-flight promise reference
|
|
326
|
+
* could. Kicks off one more `drainOutbox()` attempt itself first
|
|
327
|
+
* (harmless no-op if one is already running — see its own re-entrancy
|
|
328
|
+
* guard) in case nothing is currently actively retrying (e.g. WS just
|
|
329
|
+
* dropped and long-poll hasn't taken over yet), so this bounded wait
|
|
330
|
+
* isn't just passively hoping something else happens to be making
|
|
331
|
+
* progress.
|
|
332
|
+
*/
|
|
333
|
+
private waitForOutboxDrained;
|
|
334
|
+
/**
|
|
335
|
+
* Findings F2 + F3. Two rules, both pinned in docs/protocol.md §1.2/§9:
|
|
336
|
+
*
|
|
337
|
+
* - F2 (redelivery dead on reconnect): cursor accounting covers ONLY
|
|
338
|
+
* `task.*` envelopes. `conn.ack` carries a `seq` too (required by the
|
|
339
|
+
* schema for schema uniformity across every server->daemon type), but a
|
|
340
|
+
* reconnecting server sends it BEFORE replaying the backlog and always
|
|
341
|
+
* assigns it the next (i.e. highest-so-far) per-device seq — advancing
|
|
342
|
+
* the cursor for it would make every backlog envelope's (necessarily
|
|
343
|
+
* lower) seq look already-delivered and drop it. `conn.*` envelopes
|
|
344
|
+
* never advance the cursor.
|
|
345
|
+
* - F3 (at-most-once): the old code persisted the cursor advance BEFORE
|
|
346
|
+
* `onEnvelope` even ran (fire-and-forget) — a handler that then failed
|
|
347
|
+
* left a redelivery-proof envelope permanently marked processed. Inbound
|
|
348
|
+
* envelopes are now serialized through `processingChain` (one handler
|
|
349
|
+
* fully settles before the next starts) and the cursor only advances
|
|
350
|
+
* AFTER the handler resolves successfully; a rejection leaves the
|
|
351
|
+
* cursor where it was (see `stalledAtSeq`), so a future reconnect's
|
|
352
|
+
* redelivery re-attempts it — safe because every server->daemon type is
|
|
353
|
+
* documented idempotent (protocol §9).
|
|
354
|
+
*/
|
|
355
|
+
private deliver;
|
|
356
|
+
/**
|
|
357
|
+
* Design A: the watermark `deliver()` dedupes inbound `task.*` envelopes
|
|
358
|
+
* against, and the same value `LongPollClient` queries the next
|
|
359
|
+
* `GET /byok/events` cursor with (see the constructor). Normally this is
|
|
360
|
+
* `deliveredSeq` — which is always >= `cursor` (every envelope that
|
|
361
|
+
* reaches `advanceCursor` already passed through `noteDelivered` first,
|
|
362
|
+
* see `deliver`) — so this is the literal `max(cursor, deliveredSeq)` the
|
|
363
|
+
* design calls for, just expressed via that invariant rather than an
|
|
364
|
+
* explicit `Math.max`.
|
|
365
|
+
*
|
|
366
|
+
* While `stalledAtSeq` is set, this collapses to the durable `cursor`
|
|
367
|
+
* alone, deliberately ignoring however far `deliveredSeq` had already run
|
|
368
|
+
* ahead before the failure was known: that's what lets the stalled
|
|
369
|
+
* envelope's own redelivery (and everything after it, right up to a
|
|
370
|
+
* fresh success) get past this same dedup check instead of being
|
|
371
|
+
* self-deduped by the client's own earlier eager tracking of envelopes
|
|
372
|
+
* whose outcome wasn't known yet. No separate "reset deliveredSeq on
|
|
373
|
+
* reconnect" step is needed for this to be correct — collapsing to
|
|
374
|
+
* `cursor` exactly while stalled already produces the right answer on
|
|
375
|
+
* every redelivery path (long-poll re-query AND a WS reconnect's
|
|
376
|
+
* backlog replay alike), and NOT resetting it unconditionally on every
|
|
377
|
+
* reconnect is what lets `deliveredSeq` keep doing its job of not
|
|
378
|
+
* re-pulling/re-dispatching something already in flight across a
|
|
379
|
+
* reconnect that happens to land while a handler is still running.
|
|
380
|
+
*/
|
|
381
|
+
private dedupWatermark;
|
|
382
|
+
/** Design A: eagerly advance the in-memory delivery watermark — called for every `task.*` envelope `deliver()` admits past dedup, regardless of transport or of whether its handler has even started yet. */
|
|
383
|
+
private noteDelivered;
|
|
384
|
+
private process;
|
|
385
|
+
/**
|
|
386
|
+
* M4 Phase 4 (version-negotiation drill fix): `LongPollClient` calls this
|
|
387
|
+
* for a batch entry it could not parse into a known `Envelope` at all (an
|
|
388
|
+
* unrecognized message type — mirrors `ws-transport.ts`'s identical
|
|
389
|
+
* per-frame tolerance, see `long-poll-transport.ts`'s own doc comment on
|
|
390
|
+
* `parseLooseEventsPollResponse`) but which still carried a numeric,
|
|
391
|
+
* task-class envelope-level `seq` (the caller only invokes this for a
|
|
392
|
+
* `task.`-prefixed type — see `long-poll-transport.ts`'s own
|
|
393
|
+
* `extractSkippableSeq`; `conn.*`-shaped or type-less entries never reach
|
|
394
|
+
* here at all, mirroring F2's "conn.* is never cursor-tracked" rule).
|
|
395
|
+
* There is no real `Envelope` to hand to a handler — a genuinely
|
|
396
|
+
* unrecognized type has nothing this build could ever act on.
|
|
397
|
+
*
|
|
398
|
+
* GATEKEEPER-CAUGHT REGRESSION (fixed here): this used to call
|
|
399
|
+
* `advanceCursor(seq)` DIRECTLY, synchronously, the instant a skip was
|
|
400
|
+
* detected in `LongPollClient.loop()`'s per-entry for-loop. That is NOT
|
|
401
|
+
* "instantaneous and race-free" the way the previous version of this
|
|
402
|
+
* comment claimed — the hazard was never the skip racing against itself,
|
|
403
|
+
* it was the skip racing AHEAD of an EARLIER real envelope in the SAME
|
|
404
|
+
* batch that is still in flight on `processingChain` (`deliver()`, above,
|
|
405
|
+
* only ever CHAINS `process()` onto that promise chain — it never awaits
|
|
406
|
+
* it before returning). Concretely, batch `[real seq1, unknown seq2]`:
|
|
407
|
+
* `deliver(seq1)` chains `process(seq1)` but returns immediately without
|
|
408
|
+
* running it; the for-loop then reaches `seq2` and (pre-fix) called
|
|
409
|
+
* `advanceCursor(2)` synchronously, BEFORE `process(seq1)` had even
|
|
410
|
+
* started, let alone failed. If `seq1`'s handler then failed,
|
|
411
|
+
* `stalledAtSeq` became 1 — but the durable cursor was already 2, so
|
|
412
|
+
* `dedupWatermark()` returned 2, and every future redelivery of seq1 was
|
|
413
|
+
* dedup-dropped as "already past the cursor" forever: permanent envelope
|
|
414
|
+
* loss, exactly the F3 bug class the whole `stalledAtSeq`/frozen-watermark
|
|
415
|
+
* mechanism exists to prevent.
|
|
416
|
+
*
|
|
417
|
+
* Fix: the cursor-advancing half is now CHAINED onto `processingChain`
|
|
418
|
+
* too, exactly like `process()`'s own post-handler bookkeeping — so it
|
|
419
|
+
* only ever runs once every earlier envelope already queued ahead of it
|
|
420
|
+
* has fully settled (success or failure), and can observe `stalledAtSeq`'s
|
|
421
|
+
* REAL, up-to-date value rather than whatever it happened to be at the
|
|
422
|
+
* instant the skip was first noticed. The guard mirrors `process()`'s own
|
|
423
|
+
* success-path guard exactly: never advance past a still-unresolved
|
|
424
|
+
* earlier failure, unless (degenerate, cannot really happen for a skip)
|
|
425
|
+
* this exact seq IS the stalled one.
|
|
426
|
+
*
|
|
427
|
+
* `noteDelivered` (the eager, in-memory watermark) stays UNCHAINED —
|
|
428
|
+
* called immediately, unconditionally, regardless of `stalledAtSeq` —
|
|
429
|
+
* matching `deliver()`'s own eager, unconditional call for a real
|
|
430
|
+
* envelope: its only job is "don't re-pull something already handed off,"
|
|
431
|
+
* independent of outcome, and that property does not depend on FIFO
|
|
432
|
+
* ordering the way the DURABLE cursor does.
|
|
433
|
+
*
|
|
434
|
+
* Deliberately NO top-level `dedupWatermark() <= seq` early-return before
|
|
435
|
+
* queuing the chained callback (an earlier draft of this fix had one, and
|
|
436
|
+
* it was itself subtly wrong): `deliveredSeq` can already reflect a seq
|
|
437
|
+
* from the FIRST time it was ever seen, while the DURABLE cursor is still
|
|
438
|
+
* behind it because a stall intervened before that seq's chained
|
|
439
|
+
* advancement ran — a pre-check keyed on `deliveredSeq` would then
|
|
440
|
+
* wrongly treat a LATER redelivery of the same seq (arriving once the
|
|
441
|
+
* stall has since cleared) as "already accounted for" and never queue
|
|
442
|
+
* another attempt, permanently stranding the cursor one seq short. Always
|
|
443
|
+
* queuing is safe and cheap: `advanceCursor`'s own `seq <= this.cursor`
|
|
444
|
+
* guard already makes a genuinely-redundant call a no-op, so there is no
|
|
445
|
+
* correctness reason to short-circuit earlier, only a (here, unnecessary)
|
|
446
|
+
* micro-optimization one.
|
|
447
|
+
*/
|
|
448
|
+
private noteSkippedSeq;
|
|
449
|
+
/**
|
|
450
|
+
* Finding R1 (cross-model re-review — was NOT-CLOSED against F1):
|
|
451
|
+
* `LongPollClient` calls this for a batch entry whose `type` it
|
|
452
|
+
* recognized but whose payload failed schema validation
|
|
453
|
+
* ({@link EnvelopeValidationError}) — a genuine delivery failure at that
|
|
454
|
+
* seq, unlike `noteSkippedSeq`'s forward-compat case. Deliberately mirrors
|
|
455
|
+
* `process()`'s own catch block (`if (tracked && this.stalledAtSeq ===
|
|
456
|
+
* undefined) this.stalledAtSeq = envelope.seq;`) as closely as possible:
|
|
457
|
+
* the SAME "only the lowest unresolved failure holds the stall" rule, the
|
|
458
|
+
* SAME resulting freeze of `dedupWatermark()` at the durable cursor
|
|
459
|
+
* (protocol §9 keeps this seq alive), and — because it's the SAME
|
|
460
|
+
* `stalledAtSeq` field `process()`'s own post-success guard already
|
|
461
|
+
* checks — anything ELSE delivered after this seq (same batch or a later
|
|
462
|
+
* one) is automatically held back from advancing the cursor too, with
|
|
463
|
+
* zero changes needed to `process()` itself.
|
|
464
|
+
*
|
|
465
|
+
* Chained onto `processingChain` for exactly the reason `noteSkippedSeq`
|
|
466
|
+
* documents for its own identical chaining (see that method's sibling
|
|
467
|
+
* doc comment on `LongPollClient`, "GATEKEEPER-CAUGHT REGRESSION"): an
|
|
468
|
+
* EARLIER real envelope in the SAME batch may still be in flight on that
|
|
469
|
+
* FIFO chain when this is called (`deliver()` only ever chains
|
|
470
|
+
* `process()` onto it, never awaits before returning) — mutating
|
|
471
|
+
* `stalledAtSeq` synchronously here could race ahead of that still-
|
|
472
|
+
* unresolved earlier envelope. Chaining instead guarantees this only
|
|
473
|
+
* takes effect once every earlier-queued envelope has already settled,
|
|
474
|
+
* and reads `stalledAtSeq`'s real, up-to-date value rather than whatever
|
|
475
|
+
* it happened to be the instant the failure was first noticed.
|
|
476
|
+
*
|
|
477
|
+
* No `noteDelivered` call here (contrast `noteSkippedSeq`, which does
|
|
478
|
+
* call it): a validation-failed entry never becomes a real `Envelope` and
|
|
479
|
+
* never reaches `deliver()`, so it was never "delivered" in the eager
|
|
480
|
+
* in-memory-watermark sense that field tracks — there is nothing for it
|
|
481
|
+
* to eagerly mark. Once a corrected redelivery of this exact seq DOES
|
|
482
|
+
* arrive as a real envelope, it flows through the ordinary `deliver()`
|
|
483
|
+
* path (which calls `noteDelivered` itself) and, on success, clears the
|
|
484
|
+
* stall via `process()`'s own existing logic — no special-casing needed.
|
|
485
|
+
*/
|
|
486
|
+
private noteValidationFailure;
|
|
487
|
+
private advanceCursor;
|
|
488
|
+
/**
|
|
489
|
+
* Fires the moment a connection attempt reaches `conn.ack` — independent
|
|
490
|
+
* of whether/when it later closes. This is the ONLY place that can
|
|
491
|
+
* reliably detect "WS is back up" while long-polling: a healthy
|
|
492
|
+
* connection stays open indefinitely, so it never reaches `onWsOutcome`
|
|
493
|
+
* (which is close-only) at all.
|
|
494
|
+
*/
|
|
495
|
+
private onAcked;
|
|
496
|
+
private onWsOutcome;
|
|
497
|
+
private notifySettled;
|
|
498
|
+
private enterLongPoll;
|
|
499
|
+
private exitLongPoll;
|
|
500
|
+
private scheduleWsProbe;
|
|
501
|
+
private enterRevoked;
|
|
502
|
+
}
|