yzcode-cli 1.0.1 → 1.0.3
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/assistant/sessionHistory.ts +87 -0
- package/bootstrap/state.ts +1769 -0
- package/bridge/bridgeApi.ts +539 -0
- package/bridge/bridgeConfig.ts +48 -0
- package/bridge/bridgeDebug.ts +135 -0
- package/bridge/bridgeEnabled.ts +202 -0
- package/bridge/bridgeMain.ts +2999 -0
- package/bridge/bridgeMessaging.ts +461 -0
- package/bridge/bridgePermissionCallbacks.ts +43 -0
- package/bridge/bridgePointer.ts +210 -0
- package/bridge/bridgeStatusUtil.ts +163 -0
- package/bridge/bridgeUI.ts +530 -0
- package/bridge/capacityWake.ts +56 -0
- package/bridge/codeSessionApi.ts +168 -0
- package/bridge/createSession.ts +384 -0
- package/bridge/debugUtils.ts +141 -0
- package/bridge/envLessBridgeConfig.ts +165 -0
- package/bridge/flushGate.ts +71 -0
- package/bridge/inboundAttachments.ts +175 -0
- package/bridge/inboundMessages.ts +80 -0
- package/bridge/initReplBridge.ts +569 -0
- package/bridge/jwtUtils.ts +256 -0
- package/bridge/pollConfig.ts +110 -0
- package/bridge/pollConfigDefaults.ts +82 -0
- package/bridge/remoteBridgeCore.ts +1008 -0
- package/bridge/replBridge.ts +2406 -0
- package/bridge/replBridgeHandle.ts +36 -0
- package/bridge/replBridgeTransport.ts +370 -0
- package/bridge/sessionIdCompat.ts +57 -0
- package/bridge/sessionRunner.ts +550 -0
- package/bridge/trustedDevice.ts +210 -0
- package/bridge/types.ts +262 -0
- package/bridge/workSecret.ts +127 -0
- package/buddy/CompanionSprite.tsx +371 -0
- package/buddy/companion.ts +133 -0
- package/buddy/prompt.ts +36 -0
- package/buddy/sprites.ts +514 -0
- package/buddy/types.ts +148 -0
- package/buddy/useBuddyNotification.tsx +98 -0
- package/coordinator/coordinatorMode.ts +369 -0
- package/memdir/findRelevantMemories.ts +141 -0
- package/memdir/memdir.ts +507 -0
- package/memdir/memoryAge.ts +53 -0
- package/memdir/memoryScan.ts +94 -0
- package/memdir/memoryTypes.ts +271 -0
- package/memdir/paths.ts +278 -0
- package/memdir/teamMemPaths.ts +292 -0
- package/memdir/teamMemPrompts.ts +100 -0
- package/migrations/migrateAutoUpdatesToSettings.ts +61 -0
- package/migrations/migrateBypassPermissionsAcceptedToSettings.ts +40 -0
- package/migrations/migrateEnableAllProjectMcpServersToSettings.ts +118 -0
- package/migrations/migrateFennecToOpus.ts +45 -0
- package/migrations/migrateLegacyOpusToCurrent.ts +57 -0
- package/migrations/migrateOpusToOpus1m.ts +43 -0
- package/migrations/migrateReplBridgeEnabledToRemoteControlAtStartup.ts +22 -0
- package/migrations/migrateSonnet1mToSonnet45.ts +48 -0
- package/migrations/migrateSonnet45ToSonnet46.ts +67 -0
- package/migrations/resetAutoModeOptInForDefaultOffer.ts +51 -0
- package/migrations/resetProToOpusDefault.ts +51 -0
- package/native-ts/color-diff/index.ts +999 -0
- package/native-ts/file-index/index.ts +370 -0
- package/native-ts/yoga-layout/enums.ts +134 -0
- package/native-ts/yoga-layout/index.ts +2578 -0
- package/outputStyles/loadOutputStylesDir.ts +98 -0
- package/package.json +22 -5
- package/plugins/builtinPlugins.ts +159 -0
- package/plugins/bundled/index.ts +23 -0
- package/schemas/hooks.ts +222 -0
- package/screens/Doctor.tsx +575 -0
- package/screens/REPL.tsx +5006 -0
- package/screens/ResumeConversation.tsx +399 -0
- package/server/createDirectConnectSession.ts +88 -0
- package/server/directConnectManager.ts +213 -0
- package/server/types.ts +57 -0
- package/skills/bundled/batch.ts +124 -0
- package/skills/bundled/claudeApi.ts +196 -0
- package/skills/bundled/claudeApiContent.ts +75 -0
- package/skills/bundled/claudeInChrome.ts +34 -0
- package/skills/bundled/debug.ts +103 -0
- package/skills/bundled/index.ts +79 -0
- package/skills/bundled/keybindings.ts +339 -0
- package/skills/bundled/loop.ts +92 -0
- package/skills/bundled/loremIpsum.ts +282 -0
- package/skills/bundled/remember.ts +82 -0
- package/skills/bundled/scheduleRemoteAgents.ts +447 -0
- package/skills/bundled/simplify.ts +69 -0
- package/skills/bundled/skillify.ts +197 -0
- package/skills/bundled/stuck.ts +79 -0
- package/skills/bundled/updateConfig.ts +475 -0
- package/skills/bundled/verify/SKILL.md +3 -0
- package/skills/bundled/verify/examples/cli.md +3 -0
- package/skills/bundled/verify/examples/server.md +3 -0
- package/skills/bundled/verify.ts +30 -0
- package/skills/bundled/verifyContent.ts +13 -0
- package/skills/bundledSkills.ts +220 -0
- package/skills/loadSkillsDir.ts +1086 -0
- package/skills/mcpSkillBuilders.ts +44 -0
- package/tasks/DreamTask/DreamTask.ts +157 -0
- package/tasks/InProcessTeammateTask/InProcessTeammateTask.tsx +126 -0
- package/tasks/InProcessTeammateTask/types.ts +121 -0
- package/tasks/LocalAgentTask/LocalAgentTask.tsx +683 -0
- package/tasks/LocalMainSessionTask.ts +479 -0
- package/tasks/LocalShellTask/LocalShellTask.tsx +523 -0
- package/tasks/LocalShellTask/guards.ts +41 -0
- package/tasks/LocalShellTask/killShellTasks.ts +76 -0
- package/tasks/RemoteAgentTask/RemoteAgentTask.tsx +856 -0
- package/tasks/pillLabel.ts +82 -0
- package/tasks/stopTask.ts +100 -0
- package/tasks/types.ts +46 -0
- package/upstreamproxy/relay.ts +455 -0
- package/upstreamproxy/upstreamproxy.ts +285 -0
- package/vim/motions.ts +82 -0
- package/vim/operators.ts +556 -0
- package/vim/textObjects.ts +186 -0
- package/vim/transitions.ts +490 -0
- package/vim/types.ts +199 -0
- package/voice/voiceModeEnabled.ts +54 -0
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import { logEvent } from '../services/analytics/index.js'
|
|
2
|
+
import { logForDebugging } from '../utils/debug.js'
|
|
3
|
+
import { logForDiagnosticsNoPII } from '../utils/diagLogs.js'
|
|
4
|
+
import { errorMessage } from '../utils/errors.js'
|
|
5
|
+
import { jsonParse } from '../utils/slowOperations.js'
|
|
6
|
+
|
|
7
|
+
/** Format a millisecond duration as a human-readable string (e.g. "5m 30s"). */
|
|
8
|
+
function formatDuration(ms: number): string {
|
|
9
|
+
if (ms < 60_000) return `${Math.round(ms / 1000)}s`
|
|
10
|
+
const m = Math.floor(ms / 60_000)
|
|
11
|
+
const s = Math.round((ms % 60_000) / 1000)
|
|
12
|
+
return s > 0 ? `${m}m ${s}s` : `${m}m`
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Decode a JWT's payload segment without verifying the signature.
|
|
17
|
+
* Strips the `sk-ant-si-` session-ingress prefix if present.
|
|
18
|
+
* Returns the parsed JSON payload as `unknown`, or `null` if the
|
|
19
|
+
* token is malformed or the payload is not valid JSON.
|
|
20
|
+
*/
|
|
21
|
+
export function decodeJwtPayload(token: string): unknown | null {
|
|
22
|
+
const jwt = token.startsWith('sk-ant-si-')
|
|
23
|
+
? token.slice('sk-ant-si-'.length)
|
|
24
|
+
: token
|
|
25
|
+
const parts = jwt.split('.')
|
|
26
|
+
if (parts.length !== 3 || !parts[1]) return null
|
|
27
|
+
try {
|
|
28
|
+
return jsonParse(Buffer.from(parts[1], 'base64url').toString('utf8'))
|
|
29
|
+
} catch {
|
|
30
|
+
return null
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Decode the `exp` (expiry) claim from a JWT without verifying the signature.
|
|
36
|
+
* @returns The `exp` value in Unix seconds, or `null` if unparseable
|
|
37
|
+
*/
|
|
38
|
+
export function decodeJwtExpiry(token: string): number | null {
|
|
39
|
+
const payload = decodeJwtPayload(token)
|
|
40
|
+
if (
|
|
41
|
+
payload !== null &&
|
|
42
|
+
typeof payload === 'object' &&
|
|
43
|
+
'exp' in payload &&
|
|
44
|
+
typeof payload.exp === 'number'
|
|
45
|
+
) {
|
|
46
|
+
return payload.exp
|
|
47
|
+
}
|
|
48
|
+
return null
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Refresh buffer: request a new token before expiry. */
|
|
52
|
+
const TOKEN_REFRESH_BUFFER_MS = 5 * 60 * 1000
|
|
53
|
+
|
|
54
|
+
/** Fallback refresh interval when the new token's expiry is unknown. */
|
|
55
|
+
const FALLBACK_REFRESH_INTERVAL_MS = 30 * 60 * 1000 // 30 minutes
|
|
56
|
+
|
|
57
|
+
/** Max consecutive failures before giving up on the refresh chain. */
|
|
58
|
+
const MAX_REFRESH_FAILURES = 3
|
|
59
|
+
|
|
60
|
+
/** Retry delay when getAccessToken returns undefined. */
|
|
61
|
+
const REFRESH_RETRY_DELAY_MS = 60_000
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Creates a token refresh scheduler that proactively refreshes session tokens
|
|
65
|
+
* before they expire. Used by both the standalone bridge and the REPL bridge.
|
|
66
|
+
*
|
|
67
|
+
* When a token is about to expire, the scheduler calls `onRefresh` with the
|
|
68
|
+
* session ID and the bridge's OAuth access token. The caller is responsible
|
|
69
|
+
* for delivering the token to the appropriate transport (child process stdin
|
|
70
|
+
* for standalone bridge, WebSocket reconnect for REPL bridge).
|
|
71
|
+
*/
|
|
72
|
+
export function createTokenRefreshScheduler({
|
|
73
|
+
getAccessToken,
|
|
74
|
+
onRefresh,
|
|
75
|
+
label,
|
|
76
|
+
refreshBufferMs = TOKEN_REFRESH_BUFFER_MS,
|
|
77
|
+
}: {
|
|
78
|
+
getAccessToken: () => string | undefined | Promise<string | undefined>
|
|
79
|
+
onRefresh: (sessionId: string, oauthToken: string) => void
|
|
80
|
+
label: string
|
|
81
|
+
/** How long before expiry to fire refresh. Defaults to 5 min. */
|
|
82
|
+
refreshBufferMs?: number
|
|
83
|
+
}): {
|
|
84
|
+
schedule: (sessionId: string, token: string) => void
|
|
85
|
+
scheduleFromExpiresIn: (sessionId: string, expiresInSeconds: number) => void
|
|
86
|
+
cancel: (sessionId: string) => void
|
|
87
|
+
cancelAll: () => void
|
|
88
|
+
} {
|
|
89
|
+
const timers = new Map<string, ReturnType<typeof setTimeout>>()
|
|
90
|
+
const failureCounts = new Map<string, number>()
|
|
91
|
+
// Generation counter per session — incremented by schedule() and cancel()
|
|
92
|
+
// so that in-flight async doRefresh() calls can detect when they've been
|
|
93
|
+
// superseded and should skip setting follow-up timers.
|
|
94
|
+
const generations = new Map<string, number>()
|
|
95
|
+
|
|
96
|
+
function nextGeneration(sessionId: string): number {
|
|
97
|
+
const gen = (generations.get(sessionId) ?? 0) + 1
|
|
98
|
+
generations.set(sessionId, gen)
|
|
99
|
+
return gen
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function schedule(sessionId: string, token: string): void {
|
|
103
|
+
const expiry = decodeJwtExpiry(token)
|
|
104
|
+
if (!expiry) {
|
|
105
|
+
// Token is not a decodable JWT (e.g. an OAuth token passed from the
|
|
106
|
+
// REPL bridge WebSocket open handler). Preserve any existing timer
|
|
107
|
+
// (such as the follow-up refresh set by doRefresh) so the refresh
|
|
108
|
+
// chain is not broken.
|
|
109
|
+
logForDebugging(
|
|
110
|
+
`[${label}:token] Could not decode JWT expiry for sessionId=${sessionId}, token prefix=${token.slice(0, 15)}…, keeping existing timer`,
|
|
111
|
+
)
|
|
112
|
+
return
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Clear any existing refresh timer — we have a concrete expiry to replace it.
|
|
116
|
+
const existing = timers.get(sessionId)
|
|
117
|
+
if (existing) {
|
|
118
|
+
clearTimeout(existing)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Bump generation to invalidate any in-flight async doRefresh.
|
|
122
|
+
const gen = nextGeneration(sessionId)
|
|
123
|
+
|
|
124
|
+
const expiryDate = new Date(expiry * 1000).toISOString()
|
|
125
|
+
const delayMs = expiry * 1000 - Date.now() - refreshBufferMs
|
|
126
|
+
if (delayMs <= 0) {
|
|
127
|
+
logForDebugging(
|
|
128
|
+
`[${label}:token] Token for sessionId=${sessionId} expires=${expiryDate} (past or within buffer), refreshing immediately`,
|
|
129
|
+
)
|
|
130
|
+
void doRefresh(sessionId, gen)
|
|
131
|
+
return
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
logForDebugging(
|
|
135
|
+
`[${label}:token] Scheduled token refresh for sessionId=${sessionId} in ${formatDuration(delayMs)} (expires=${expiryDate}, buffer=${refreshBufferMs / 1000}s)`,
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
const timer = setTimeout(doRefresh, delayMs, sessionId, gen)
|
|
139
|
+
timers.set(sessionId, timer)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Schedule refresh using an explicit TTL (seconds until expiry) rather
|
|
144
|
+
* than decoding a JWT's exp claim. Used by callers whose JWT is opaque
|
|
145
|
+
* (e.g. POST /v1/code/sessions/{id}/bridge returns expires_in directly).
|
|
146
|
+
*/
|
|
147
|
+
function scheduleFromExpiresIn(
|
|
148
|
+
sessionId: string,
|
|
149
|
+
expiresInSeconds: number,
|
|
150
|
+
): void {
|
|
151
|
+
const existing = timers.get(sessionId)
|
|
152
|
+
if (existing) clearTimeout(existing)
|
|
153
|
+
const gen = nextGeneration(sessionId)
|
|
154
|
+
// Clamp to 30s floor — if refreshBufferMs exceeds the server's expires_in
|
|
155
|
+
// (e.g. very large buffer for frequent-refresh testing, or server shortens
|
|
156
|
+
// expires_in unexpectedly), unclamped delayMs ≤ 0 would tight-loop.
|
|
157
|
+
const delayMs = Math.max(expiresInSeconds * 1000 - refreshBufferMs, 30_000)
|
|
158
|
+
logForDebugging(
|
|
159
|
+
`[${label}:token] Scheduled token refresh for sessionId=${sessionId} in ${formatDuration(delayMs)} (expires_in=${expiresInSeconds}s, buffer=${refreshBufferMs / 1000}s)`,
|
|
160
|
+
)
|
|
161
|
+
const timer = setTimeout(doRefresh, delayMs, sessionId, gen)
|
|
162
|
+
timers.set(sessionId, timer)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async function doRefresh(sessionId: string, gen: number): Promise<void> {
|
|
166
|
+
let oauthToken: string | undefined
|
|
167
|
+
try {
|
|
168
|
+
oauthToken = await getAccessToken()
|
|
169
|
+
} catch (err) {
|
|
170
|
+
logForDebugging(
|
|
171
|
+
`[${label}:token] getAccessToken threw for sessionId=${sessionId}: ${errorMessage(err)}`,
|
|
172
|
+
{ level: 'error' },
|
|
173
|
+
)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// If the session was cancelled or rescheduled while we were awaiting,
|
|
177
|
+
// the generation will have changed — bail out to avoid orphaned timers.
|
|
178
|
+
if (generations.get(sessionId) !== gen) {
|
|
179
|
+
logForDebugging(
|
|
180
|
+
`[${label}:token] doRefresh for sessionId=${sessionId} stale (gen ${gen} vs ${generations.get(sessionId)}), skipping`,
|
|
181
|
+
)
|
|
182
|
+
return
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (!oauthToken) {
|
|
186
|
+
const failures = (failureCounts.get(sessionId) ?? 0) + 1
|
|
187
|
+
failureCounts.set(sessionId, failures)
|
|
188
|
+
logForDebugging(
|
|
189
|
+
`[${label}:token] No OAuth token available for refresh, sessionId=${sessionId} (failure ${failures}/${MAX_REFRESH_FAILURES})`,
|
|
190
|
+
{ level: 'error' },
|
|
191
|
+
)
|
|
192
|
+
logForDiagnosticsNoPII('error', 'bridge_token_refresh_no_oauth')
|
|
193
|
+
// Schedule a retry so the refresh chain can recover if the token
|
|
194
|
+
// becomes available again (e.g. transient cache clear during refresh).
|
|
195
|
+
// Cap retries to avoid spamming on genuine failures.
|
|
196
|
+
if (failures < MAX_REFRESH_FAILURES) {
|
|
197
|
+
const retryTimer = setTimeout(
|
|
198
|
+
doRefresh,
|
|
199
|
+
REFRESH_RETRY_DELAY_MS,
|
|
200
|
+
sessionId,
|
|
201
|
+
gen,
|
|
202
|
+
)
|
|
203
|
+
timers.set(sessionId, retryTimer)
|
|
204
|
+
}
|
|
205
|
+
return
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Reset failure counter on successful token retrieval
|
|
209
|
+
failureCounts.delete(sessionId)
|
|
210
|
+
|
|
211
|
+
logForDebugging(
|
|
212
|
+
`[${label}:token] Refreshing token for sessionId=${sessionId}: new token prefix=${oauthToken.slice(0, 15)}…`,
|
|
213
|
+
)
|
|
214
|
+
logEvent('tengu_bridge_token_refreshed', {})
|
|
215
|
+
onRefresh(sessionId, oauthToken)
|
|
216
|
+
|
|
217
|
+
// Schedule a follow-up refresh so long-running sessions stay authenticated.
|
|
218
|
+
// Without this, the initial one-shot timer leaves the session vulnerable
|
|
219
|
+
// to token expiry if it runs past the first refresh window.
|
|
220
|
+
const timer = setTimeout(
|
|
221
|
+
doRefresh,
|
|
222
|
+
FALLBACK_REFRESH_INTERVAL_MS,
|
|
223
|
+
sessionId,
|
|
224
|
+
gen,
|
|
225
|
+
)
|
|
226
|
+
timers.set(sessionId, timer)
|
|
227
|
+
logForDebugging(
|
|
228
|
+
`[${label}:token] Scheduled follow-up refresh for sessionId=${sessionId} in ${formatDuration(FALLBACK_REFRESH_INTERVAL_MS)}`,
|
|
229
|
+
)
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function cancel(sessionId: string): void {
|
|
233
|
+
// Bump generation to invalidate any in-flight async doRefresh.
|
|
234
|
+
nextGeneration(sessionId)
|
|
235
|
+
const timer = timers.get(sessionId)
|
|
236
|
+
if (timer) {
|
|
237
|
+
clearTimeout(timer)
|
|
238
|
+
timers.delete(sessionId)
|
|
239
|
+
}
|
|
240
|
+
failureCounts.delete(sessionId)
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function cancelAll(): void {
|
|
244
|
+
// Bump all generations so in-flight doRefresh calls are invalidated.
|
|
245
|
+
for (const sessionId of generations.keys()) {
|
|
246
|
+
nextGeneration(sessionId)
|
|
247
|
+
}
|
|
248
|
+
for (const timer of timers.values()) {
|
|
249
|
+
clearTimeout(timer)
|
|
250
|
+
}
|
|
251
|
+
timers.clear()
|
|
252
|
+
failureCounts.clear()
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
return { schedule, scheduleFromExpiresIn, cancel, cancelAll }
|
|
256
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { z } from 'zod/v4'
|
|
2
|
+
import { getFeatureValue_CACHED_WITH_REFRESH } from '../services/analytics/growthbook.js'
|
|
3
|
+
import { lazySchema } from '../utils/lazySchema.js'
|
|
4
|
+
import {
|
|
5
|
+
DEFAULT_POLL_CONFIG,
|
|
6
|
+
type PollIntervalConfig,
|
|
7
|
+
} from './pollConfigDefaults.js'
|
|
8
|
+
|
|
9
|
+
// .min(100) on the seek-work intervals restores the old Math.max(..., 100)
|
|
10
|
+
// defense-in-depth floor against fat-fingered GrowthBook values. Unlike a
|
|
11
|
+
// clamp, Zod rejects the whole object on violation — a config with one bad
|
|
12
|
+
// field falls back to DEFAULT_POLL_CONFIG entirely rather than being
|
|
13
|
+
// partially trusted.
|
|
14
|
+
//
|
|
15
|
+
// The at_capacity intervals use a 0-or-≥100 refinement: 0 means "disabled"
|
|
16
|
+
// (heartbeat-only mode), ≥100 is the fat-finger floor. Values 1–99 are
|
|
17
|
+
// rejected so unit confusion (ops thinks seconds, enters 10) doesn't poll
|
|
18
|
+
// every 10ms against the VerifyEnvironmentSecretAuth DB path.
|
|
19
|
+
//
|
|
20
|
+
// The object-level refines require at least one at-capacity liveness
|
|
21
|
+
// mechanism enabled: heartbeat OR the relevant poll interval. Without this,
|
|
22
|
+
// the hb=0, atCapMs=0 drift config (ops disables heartbeat without
|
|
23
|
+
// restoring at_capacity) falls through every throttle site with no sleep —
|
|
24
|
+
// tight-looping /poll at HTTP-round-trip speed.
|
|
25
|
+
const zeroOrAtLeast100 = {
|
|
26
|
+
message: 'must be 0 (disabled) or ≥100ms',
|
|
27
|
+
}
|
|
28
|
+
const pollIntervalConfigSchema = lazySchema(() =>
|
|
29
|
+
z
|
|
30
|
+
.object({
|
|
31
|
+
poll_interval_ms_not_at_capacity: z.number().int().min(100),
|
|
32
|
+
// 0 = no at-capacity polling. Independent of heartbeat — both can be
|
|
33
|
+
// enabled (heartbeat runs, periodically breaks out to poll).
|
|
34
|
+
poll_interval_ms_at_capacity: z
|
|
35
|
+
.number()
|
|
36
|
+
.int()
|
|
37
|
+
.refine(v => v === 0 || v >= 100, zeroOrAtLeast100),
|
|
38
|
+
// 0 = disabled; positive value = heartbeat at this interval while at
|
|
39
|
+
// capacity. Runs alongside at-capacity polling, not instead of it.
|
|
40
|
+
// Named non_exclusive to distinguish from the old heartbeat_interval_ms
|
|
41
|
+
// (either-or semantics in pre-#22145 clients). .default(0) so existing
|
|
42
|
+
// GrowthBook configs without this field parse successfully.
|
|
43
|
+
non_exclusive_heartbeat_interval_ms: z.number().int().min(0).default(0),
|
|
44
|
+
// Multisession (bridgeMain.ts) intervals. Defaults match the
|
|
45
|
+
// single-session values so existing configs without these fields
|
|
46
|
+
// preserve current behavior.
|
|
47
|
+
multisession_poll_interval_ms_not_at_capacity: z
|
|
48
|
+
.number()
|
|
49
|
+
.int()
|
|
50
|
+
.min(100)
|
|
51
|
+
.default(
|
|
52
|
+
DEFAULT_POLL_CONFIG.multisession_poll_interval_ms_not_at_capacity,
|
|
53
|
+
),
|
|
54
|
+
multisession_poll_interval_ms_partial_capacity: z
|
|
55
|
+
.number()
|
|
56
|
+
.int()
|
|
57
|
+
.min(100)
|
|
58
|
+
.default(
|
|
59
|
+
DEFAULT_POLL_CONFIG.multisession_poll_interval_ms_partial_capacity,
|
|
60
|
+
),
|
|
61
|
+
multisession_poll_interval_ms_at_capacity: z
|
|
62
|
+
.number()
|
|
63
|
+
.int()
|
|
64
|
+
.refine(v => v === 0 || v >= 100, zeroOrAtLeast100)
|
|
65
|
+
.default(DEFAULT_POLL_CONFIG.multisession_poll_interval_ms_at_capacity),
|
|
66
|
+
// .min(1) matches the server's ge=1 constraint (work_v1.py:230).
|
|
67
|
+
reclaim_older_than_ms: z.number().int().min(1).default(5000),
|
|
68
|
+
session_keepalive_interval_v2_ms: z
|
|
69
|
+
.number()
|
|
70
|
+
.int()
|
|
71
|
+
.min(0)
|
|
72
|
+
.default(120_000),
|
|
73
|
+
})
|
|
74
|
+
.refine(
|
|
75
|
+
cfg =>
|
|
76
|
+
cfg.non_exclusive_heartbeat_interval_ms > 0 ||
|
|
77
|
+
cfg.poll_interval_ms_at_capacity > 0,
|
|
78
|
+
{
|
|
79
|
+
message:
|
|
80
|
+
'at-capacity liveness requires non_exclusive_heartbeat_interval_ms > 0 or poll_interval_ms_at_capacity > 0',
|
|
81
|
+
},
|
|
82
|
+
)
|
|
83
|
+
.refine(
|
|
84
|
+
cfg =>
|
|
85
|
+
cfg.non_exclusive_heartbeat_interval_ms > 0 ||
|
|
86
|
+
cfg.multisession_poll_interval_ms_at_capacity > 0,
|
|
87
|
+
{
|
|
88
|
+
message:
|
|
89
|
+
'at-capacity liveness requires non_exclusive_heartbeat_interval_ms > 0 or multisession_poll_interval_ms_at_capacity > 0',
|
|
90
|
+
},
|
|
91
|
+
),
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Fetch the bridge poll interval config from GrowthBook with a 5-minute
|
|
96
|
+
* refresh window. Validates the served JSON against the schema; falls back
|
|
97
|
+
* to defaults if the flag is absent, malformed, or partially-specified.
|
|
98
|
+
*
|
|
99
|
+
* Shared by bridgeMain.ts (standalone) and replBridge.ts (REPL) so ops
|
|
100
|
+
* can tune both poll rates fleet-wide with a single config push.
|
|
101
|
+
*/
|
|
102
|
+
export function getPollIntervalConfig(): PollIntervalConfig {
|
|
103
|
+
const raw = getFeatureValue_CACHED_WITH_REFRESH<unknown>(
|
|
104
|
+
'tengu_bridge_poll_interval_config',
|
|
105
|
+
DEFAULT_POLL_CONFIG,
|
|
106
|
+
5 * 60 * 1000,
|
|
107
|
+
)
|
|
108
|
+
const parsed = pollIntervalConfigSchema().safeParse(raw)
|
|
109
|
+
return parsed.success ? parsed.data : DEFAULT_POLL_CONFIG
|
|
110
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bridge poll interval defaults. Extracted from pollConfig.ts so callers
|
|
3
|
+
* that don't need live GrowthBook tuning (daemon via Agent SDK) can avoid
|
|
4
|
+
* the growthbook.ts → config.ts → file.ts → sessionStorage.ts → commands.ts
|
|
5
|
+
* transitive dependency chain.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Poll interval when actively seeking work (no transport / below maxSessions).
|
|
10
|
+
* Governs user-visible "connecting…" latency on initial work pickup and
|
|
11
|
+
* recovery speed after the server re-dispatches a work item.
|
|
12
|
+
*/
|
|
13
|
+
const POLL_INTERVAL_MS_NOT_AT_CAPACITY = 2000
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Poll interval when the transport is connected. Runs independently of
|
|
17
|
+
* heartbeat — when both are enabled, the heartbeat loop breaks out to poll
|
|
18
|
+
* at this interval. Set to 0 to disable at-capacity polling entirely.
|
|
19
|
+
*
|
|
20
|
+
* Server-side constraints that bound this value:
|
|
21
|
+
* - BRIDGE_LAST_POLL_TTL = 4h (Redis key expiry → environment auto-archived)
|
|
22
|
+
* - max_poll_stale_seconds = 24h (session-creation health gate, currently disabled)
|
|
23
|
+
*
|
|
24
|
+
* 10 minutes gives 24× headroom on the Redis TTL while still picking up
|
|
25
|
+
* server-initiated token-rotation redispatches within one poll cycle.
|
|
26
|
+
* The transport auto-reconnects internally for 10 minutes on transient WS
|
|
27
|
+
* failures, so poll is not the recovery path — it's strictly a liveness
|
|
28
|
+
* signal plus a backstop for permanent close.
|
|
29
|
+
*/
|
|
30
|
+
const POLL_INTERVAL_MS_AT_CAPACITY = 600_000
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Multisession bridge (bridgeMain.ts) poll intervals. Defaults match the
|
|
34
|
+
* single-session values so existing GrowthBook configs without these fields
|
|
35
|
+
* preserve current behavior. Ops can tune these independently via the
|
|
36
|
+
* tengu_bridge_poll_interval_config GB flag.
|
|
37
|
+
*/
|
|
38
|
+
const MULTISESSION_POLL_INTERVAL_MS_NOT_AT_CAPACITY =
|
|
39
|
+
POLL_INTERVAL_MS_NOT_AT_CAPACITY
|
|
40
|
+
const MULTISESSION_POLL_INTERVAL_MS_PARTIAL_CAPACITY =
|
|
41
|
+
POLL_INTERVAL_MS_NOT_AT_CAPACITY
|
|
42
|
+
const MULTISESSION_POLL_INTERVAL_MS_AT_CAPACITY = POLL_INTERVAL_MS_AT_CAPACITY
|
|
43
|
+
|
|
44
|
+
export type PollIntervalConfig = {
|
|
45
|
+
poll_interval_ms_not_at_capacity: number
|
|
46
|
+
poll_interval_ms_at_capacity: number
|
|
47
|
+
non_exclusive_heartbeat_interval_ms: number
|
|
48
|
+
multisession_poll_interval_ms_not_at_capacity: number
|
|
49
|
+
multisession_poll_interval_ms_partial_capacity: number
|
|
50
|
+
multisession_poll_interval_ms_at_capacity: number
|
|
51
|
+
reclaim_older_than_ms: number
|
|
52
|
+
session_keepalive_interval_v2_ms: number
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export const DEFAULT_POLL_CONFIG: PollIntervalConfig = {
|
|
56
|
+
poll_interval_ms_not_at_capacity: POLL_INTERVAL_MS_NOT_AT_CAPACITY,
|
|
57
|
+
poll_interval_ms_at_capacity: POLL_INTERVAL_MS_AT_CAPACITY,
|
|
58
|
+
// 0 = disabled. When > 0, at-capacity loops send per-work-item heartbeats
|
|
59
|
+
// at this interval. Independent of poll_interval_ms_at_capacity — both may
|
|
60
|
+
// run (heartbeat periodically yields to poll). 60s gives 5× headroom under
|
|
61
|
+
// the server's 300s heartbeat TTL. Named non_exclusive to distinguish from
|
|
62
|
+
// the old heartbeat_interval_ms field (either-or semantics in pre-#22145
|
|
63
|
+
// clients — heartbeat suppressed poll). Old clients ignore this key; ops
|
|
64
|
+
// can set both fields during rollout.
|
|
65
|
+
non_exclusive_heartbeat_interval_ms: 0,
|
|
66
|
+
multisession_poll_interval_ms_not_at_capacity:
|
|
67
|
+
MULTISESSION_POLL_INTERVAL_MS_NOT_AT_CAPACITY,
|
|
68
|
+
multisession_poll_interval_ms_partial_capacity:
|
|
69
|
+
MULTISESSION_POLL_INTERVAL_MS_PARTIAL_CAPACITY,
|
|
70
|
+
multisession_poll_interval_ms_at_capacity:
|
|
71
|
+
MULTISESSION_POLL_INTERVAL_MS_AT_CAPACITY,
|
|
72
|
+
// Poll query param: reclaim unacknowledged work items older than this.
|
|
73
|
+
// Matches the server's DEFAULT_RECLAIM_OLDER_THAN_MS (work_service.py:24).
|
|
74
|
+
// Enables picking up stale-pending work after JWT expiry, when the prior
|
|
75
|
+
// ack failed because the session_ingress_token was already stale.
|
|
76
|
+
reclaim_older_than_ms: 5000,
|
|
77
|
+
// 0 = disabled. When > 0, push a silent {type:'keep_alive'} frame to
|
|
78
|
+
// session-ingress at this interval so upstream proxies don't GC an idle
|
|
79
|
+
// remote-control session. 2 min is the default. _v2: bridge-only gate
|
|
80
|
+
// (pre-v2 clients read the old key, new clients ignore it).
|
|
81
|
+
session_keepalive_interval_v2_ms: 120_000,
|
|
82
|
+
}
|