typeclaw 0.37.4 → 0.37.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/agent/doctor.ts +6 -1
- package/src/agent/plugin-tools.ts +23 -1
- package/src/agent/subagents.ts +146 -14
- package/src/agent/todo/scope.ts +4 -2
- package/src/agent/tools/channel-reply.ts +7 -9
- package/src/bundled-plugins/doc-render/index.ts +10 -0
- package/src/bundled-plugins/doc-render/skills/typeclaw-render-pdf/SKILL.md +171 -165
- package/src/bundled-plugins/doc-render/templates/lib.typ +339 -0
- package/src/bundled-plugins/github-cli-auth/gh-command.ts +95 -11
- package/src/bundled-plugins/github-cli-auth/git-command.ts +11 -0
- package/src/bundled-plugins/github-cli-auth/index.ts +68 -7
- package/src/bundled-plugins/memory/index.ts +9 -6
- package/src/bundled-plugins/memory/load-memory.ts +16 -2
- package/src/bundled-plugins/memory/slug.ts +19 -0
- package/src/bundled-plugins/security/policies/private-surface-read.ts +4 -1
- package/src/channels/adapters/github/inbound.ts +68 -43
- package/src/channels/adapters/github/index.ts +57 -9
- package/src/channels/adapters/github/recover-failed-deliveries.ts +270 -0
- package/src/channels/adapters/kakaotalk.ts +5 -1
- package/src/channels/adapters/mention-hints.ts +17 -0
- package/src/channels/manager.ts +77 -1
- package/src/channels/router.ts +181 -12
- package/src/cli/compose.ts +11 -2
- package/src/cli/dreams.ts +2 -2
- package/src/cli/inspect.ts +2 -2
- package/src/cli/logs.ts +2 -2
- package/src/cli/mount.ts +5 -5
- package/src/cli/require-agent-dir.ts +31 -0
- package/src/cli/restart.ts +2 -1
- package/src/cli/shell.ts +2 -2
- package/src/cli/start.ts +2 -1
- package/src/cli/stop.ts +2 -2
- package/src/cli/tui.ts +20 -6
- package/src/cli/ui.ts +13 -0
- package/src/compose/restart.ts +1 -1
- package/src/compose/start.ts +4 -2
- package/src/config/config.ts +200 -9
- package/src/container/shared.ts +18 -0
- package/src/container/start.ts +1 -1
- package/src/cron/consumer.ts +3 -3
- package/src/hostd/client.ts +48 -52
- package/src/hostd/daemon.ts +82 -39
- package/src/hostd/paths.ts +22 -2
- package/src/hostd/spawn.ts +7 -0
- package/src/init/dockerfile.ts +11 -8
- package/src/init/kakaotalk-auth.ts +2 -2
- package/src/init/packagejson.ts +2 -2
- package/src/plugin/loader.ts +7 -4
- package/src/sandbox/session-tmp.ts +6 -1
- package/src/secrets/export-claude-credentials-file.ts +2 -2
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
import { GITHUB_API_BASE, githubJsonHeaders } from './auth-pat'
|
|
2
|
+
|
|
3
|
+
// Recovers webhook events whose delivery to our ingress FAILED and that GitHub
|
|
4
|
+
// never successfully redelivered. The production failure mode is inbound-only: a
|
|
5
|
+
// cloudflare-quick tunnel drops ~half its deliveries with 502 "failed to connect
|
|
6
|
+
// to host", and GitHub does not auto-redeliver issue_comment events — so a
|
|
7
|
+
// `@bot review please` comment is lost with no log entry and no reply.
|
|
8
|
+
//
|
|
9
|
+
// This sweep is OUTBOUND-only, so it never touches the broken inbound leg: it
|
|
10
|
+
// lists each managed hook's delivery log, finds events with no successful
|
|
11
|
+
// delivery, fetches the original payload from GitHub's authenticated deliveries
|
|
12
|
+
// API, and feeds it through the SAME processVerifiedGithubDelivery core a live
|
|
13
|
+
// webhook uses (passed in as `process`). It is a floor, not the primary path —
|
|
14
|
+
// webhooks remain low-latency when delivery works; reconcile-open-prs.ts is the
|
|
15
|
+
// sibling floor for review-state drift.
|
|
16
|
+
|
|
17
|
+
export type ManagedHook = { repo: string; hookId: number }
|
|
18
|
+
|
|
19
|
+
// Recovery-owned idempotency keyed by delivery GUID, retained for the FULL
|
|
20
|
+
// lookback window. The shared live dedup cannot serve this alone: it is a
|
|
21
|
+
// fixed 1000-entry LRU, so during a 70h lookback across many repos it can evict
|
|
22
|
+
// a GUID we already recovered, after which the still-listed failed delivery
|
|
23
|
+
// would be re-fetched and re-routed. This TTL log holds only RECOVERED GUIDs
|
|
24
|
+
// (failed-then-recovered deliveries, a small set), expiring each exactly when it
|
|
25
|
+
// also falls out of the scan window — so a recovered delivery is routed once
|
|
26
|
+
// regardless of live-dedup churn. (The shared dedup still guards the live-vs-
|
|
27
|
+
// sweep concurrency race; this guards cross-sweep durability.)
|
|
28
|
+
export type RecoveredGuidLog = { has: (guid: string) => boolean; record: (guid: string) => void }
|
|
29
|
+
|
|
30
|
+
export function createRecoveredGuidLog(ttlMs: number, now: () => number = Date.now): RecoveredGuidLog {
|
|
31
|
+
const expiresAt = new Map<string, number>()
|
|
32
|
+
return {
|
|
33
|
+
has(guid: string): boolean {
|
|
34
|
+
const expiry = expiresAt.get(guid)
|
|
35
|
+
if (expiry === undefined) return false
|
|
36
|
+
if (expiry <= now()) {
|
|
37
|
+
expiresAt.delete(guid)
|
|
38
|
+
return false
|
|
39
|
+
}
|
|
40
|
+
return true
|
|
41
|
+
},
|
|
42
|
+
record(guid: string): void {
|
|
43
|
+
const t = now()
|
|
44
|
+
for (const [g, expiry] of expiresAt) if (expiry <= t) expiresAt.delete(g)
|
|
45
|
+
expiresAt.set(guid, t + ttlMs)
|
|
46
|
+
},
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export type RecoverFailedDeliveriesOptions = {
|
|
51
|
+
hooks: readonly ManagedHook[]
|
|
52
|
+
token: (repoSlug: string) => Promise<string>
|
|
53
|
+
// The shared processVerifiedGithubDelivery, bound to the adapter's handler
|
|
54
|
+
// options. `delivery` is the GUID; the core dedups, filters by allowlist,
|
|
55
|
+
// drops self-authored, and routes exactly as the live path does.
|
|
56
|
+
process: (input: { event: string; delivery: string; payload: Record<string, unknown> }) => Promise<void>
|
|
57
|
+
// Fast-path skip backed by the LIVE delivery dedup (shared with the webhook
|
|
58
|
+
// handler): a guid here was just routed live (or reserved by `process` on
|
|
59
|
+
// entry), so skip it. Best-effort only — it is a 1000-entry LRU and may evict
|
|
60
|
+
// within the lookback window, which is exactly why `recoveredLog` exists.
|
|
61
|
+
alreadySeen: (guid: string) => boolean
|
|
62
|
+
// Durable recovery idempotency for the whole lookback window (see
|
|
63
|
+
// createRecoveredGuidLog). Caller-owned so it persists across sweeps.
|
|
64
|
+
recoveredLog: RecoveredGuidLog
|
|
65
|
+
lookbackMs: number
|
|
66
|
+
maxPerSweep: number
|
|
67
|
+
logger: { info: (m: string) => void; warn: (m: string) => void }
|
|
68
|
+
now?: () => number
|
|
69
|
+
fetchImpl?: typeof fetch
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export type RecoverOutcome = { recovered: number; scanned: number }
|
|
73
|
+
|
|
74
|
+
export async function recoverFailedGithubDeliveries(options: RecoverFailedDeliveriesOptions): Promise<RecoverOutcome> {
|
|
75
|
+
const fetchImpl = options.fetchImpl ?? fetch
|
|
76
|
+
const now = options.now ?? Date.now
|
|
77
|
+
const cutoff = now() - options.lookbackMs
|
|
78
|
+
let recovered = 0
|
|
79
|
+
let scanned = 0
|
|
80
|
+
|
|
81
|
+
for (const hook of options.hooks) {
|
|
82
|
+
// maxPerSweep is a GLOBAL budget across all hooks (an LLM-session storm
|
|
83
|
+
// guard), so pass the remaining budget into each hook rather than letting
|
|
84
|
+
// every hook recover up to the full cap independently.
|
|
85
|
+
const remaining = options.maxPerSweep - recovered
|
|
86
|
+
if (remaining <= 0) break
|
|
87
|
+
const target = parseRepo(hook.repo)
|
|
88
|
+
if (target === null) {
|
|
89
|
+
options.logger.warn(`[github] recovery skipped malformed repo slug "${hook.repo}"`)
|
|
90
|
+
continue
|
|
91
|
+
}
|
|
92
|
+
try {
|
|
93
|
+
const result = await recoverHook(hook, target, cutoff, options, remaining, fetchImpl)
|
|
94
|
+
scanned += result.scanned
|
|
95
|
+
recovered += result.recovered
|
|
96
|
+
if (result.recovered > 0) {
|
|
97
|
+
options.logger.info(`[github] recovered ${result.recovered} missed delivery(s) on ${hook.repo}`)
|
|
98
|
+
}
|
|
99
|
+
} catch (err) {
|
|
100
|
+
// Per-hook isolation: one repo's token/list/detail failure must not abort
|
|
101
|
+
// the others. The next interval retries this hook.
|
|
102
|
+
options.logger.warn(`[github] delivery recovery failed for ${hook.repo}: ${describe(err)}`)
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return { recovered, scanned }
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function recoverHook(
|
|
109
|
+
hook: ManagedHook,
|
|
110
|
+
target: RepoTarget,
|
|
111
|
+
cutoff: number,
|
|
112
|
+
options: RecoverFailedDeliveriesOptions,
|
|
113
|
+
budget: number,
|
|
114
|
+
fetchImpl: typeof fetch,
|
|
115
|
+
): Promise<RecoverOutcome> {
|
|
116
|
+
const token = await options.token(hook.repo)
|
|
117
|
+
const deliveries = await listRecentDeliveries(fetchImpl, token, target, hook.hookId, cutoff)
|
|
118
|
+
|
|
119
|
+
// Any 2xx/3xx delivery for a guid means the event got through (e.g. GitHub
|
|
120
|
+
// auto-redelivered, or a manual redeliver succeeded). Never recover those.
|
|
121
|
+
const succeededGuids = new Set<string>()
|
|
122
|
+
for (const d of deliveries) {
|
|
123
|
+
if (isSuccess(d.statusCode)) succeededGuids.add(d.guid)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
let recovered = 0
|
|
127
|
+
let scanned = 0
|
|
128
|
+
const handledThisSweep = new Set<string>()
|
|
129
|
+
for (const delivery of deliveries) {
|
|
130
|
+
if (recovered >= budget) break
|
|
131
|
+
if (isSuccess(delivery.statusCode)) continue
|
|
132
|
+
scanned += 1
|
|
133
|
+
const guid = delivery.guid
|
|
134
|
+
if (guid === '') continue
|
|
135
|
+
if (
|
|
136
|
+
succeededGuids.has(guid) ||
|
|
137
|
+
handledThisSweep.has(guid) ||
|
|
138
|
+
options.alreadySeen(guid) ||
|
|
139
|
+
options.recoveredLog.has(guid)
|
|
140
|
+
) {
|
|
141
|
+
continue
|
|
142
|
+
}
|
|
143
|
+
handledThisSweep.add(guid)
|
|
144
|
+
|
|
145
|
+
const payload = await fetchDeliveryPayload(fetchImpl, token, target, hook.hookId, delivery.id)
|
|
146
|
+
if (payload === null) continue
|
|
147
|
+
await options.process({ event: delivery.event, delivery: guid, payload })
|
|
148
|
+
// Record AFTER process resolves: an unexpected throw leaves the guid
|
|
149
|
+
// unrecorded so the next sweep retries it. A no-op classify still records
|
|
150
|
+
// (process returned), so a non-routable failed delivery is not refetched.
|
|
151
|
+
options.recoveredLog.record(guid)
|
|
152
|
+
recovered += 1
|
|
153
|
+
}
|
|
154
|
+
return { recovered, scanned }
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
type RepoTarget = { owner: string; repo: string }
|
|
158
|
+
|
|
159
|
+
type DeliverySummary = { id: number; guid: string; event: string; statusCode: number }
|
|
160
|
+
|
|
161
|
+
async function listRecentDeliveries(
|
|
162
|
+
fetchImpl: typeof fetch,
|
|
163
|
+
token: string,
|
|
164
|
+
target: RepoTarget,
|
|
165
|
+
hookId: number,
|
|
166
|
+
cutoff: number,
|
|
167
|
+
): Promise<DeliverySummary[]> {
|
|
168
|
+
const summaries: DeliverySummary[] = []
|
|
169
|
+
let url: string | null =
|
|
170
|
+
`${GITHUB_API_BASE}/repos/${target.owner}/${target.repo}/hooks/${hookId}/deliveries?per_page=100`
|
|
171
|
+
while (url !== null) {
|
|
172
|
+
const response = await fetchImpl(url, { headers: githubJsonHeaders(token) })
|
|
173
|
+
if (!response.ok) {
|
|
174
|
+
const body = await response.text().catch(() => '')
|
|
175
|
+
throw new Error(`GitHub deliveries ${response.status}${body !== '' ? `: ${body}` : ''}`)
|
|
176
|
+
}
|
|
177
|
+
const page = (await response.json().catch(() => null)) as DeliveryRow[] | null
|
|
178
|
+
if (page === null) throw new Error('GitHub deliveries returned non-JSON')
|
|
179
|
+
// Deliveries are newest-first; once a page's oldest entry predates the
|
|
180
|
+
// lookback cutoff we can stop paginating instead of walking the full log.
|
|
181
|
+
let reachedCutoff = false
|
|
182
|
+
for (const row of page) {
|
|
183
|
+
const parsed = parseDeliveryRow(row)
|
|
184
|
+
if (parsed === null) continue
|
|
185
|
+
if (parsed.deliveredAt !== null && parsed.deliveredAt < cutoff) {
|
|
186
|
+
reachedCutoff = true
|
|
187
|
+
continue
|
|
188
|
+
}
|
|
189
|
+
summaries.push(parsed.summary)
|
|
190
|
+
}
|
|
191
|
+
if (reachedCutoff) break
|
|
192
|
+
url = nextLink(response.headers.get('link'))
|
|
193
|
+
}
|
|
194
|
+
return summaries
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async function fetchDeliveryPayload(
|
|
198
|
+
fetchImpl: typeof fetch,
|
|
199
|
+
token: string,
|
|
200
|
+
target: RepoTarget,
|
|
201
|
+
hookId: number,
|
|
202
|
+
deliveryId: number,
|
|
203
|
+
): Promise<Record<string, unknown> | null> {
|
|
204
|
+
const response = await fetchImpl(
|
|
205
|
+
`${GITHUB_API_BASE}/repos/${target.owner}/${target.repo}/hooks/${hookId}/deliveries/${deliveryId}`,
|
|
206
|
+
{ headers: githubJsonHeaders(token) },
|
|
207
|
+
)
|
|
208
|
+
if (!response.ok) return null
|
|
209
|
+
const raw = (await response.json().catch(() => null)) as { request?: { payload?: unknown } } | null
|
|
210
|
+
return coercePayload(raw?.request?.payload)
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function coercePayload(value: unknown): Record<string, unknown> | null {
|
|
214
|
+
if (typeof value === 'string') {
|
|
215
|
+
try {
|
|
216
|
+
const parsed = JSON.parse(value) as unknown
|
|
217
|
+
return isRecord(parsed) ? parsed : null
|
|
218
|
+
} catch {
|
|
219
|
+
return null
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
return isRecord(value) ? value : null
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// GitHub records a non-delivery (connection refused / DNS / tunnel down) as
|
|
226
|
+
// status_code 0, and HTTP failures as 4xx/5xx. Treat 2xx and 3xx as success.
|
|
227
|
+
function isSuccess(statusCode: number): boolean {
|
|
228
|
+
return statusCode >= 200 && statusCode < 400
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
type DeliveryRow = {
|
|
232
|
+
id?: unknown
|
|
233
|
+
guid?: unknown
|
|
234
|
+
event?: unknown
|
|
235
|
+
status_code?: unknown
|
|
236
|
+
delivered_at?: unknown
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function parseDeliveryRow(row: DeliveryRow): { summary: DeliverySummary; deliveredAt: number | null } | null {
|
|
240
|
+
const id = typeof row.id === 'number' ? row.id : null
|
|
241
|
+
const guid = typeof row.guid === 'string' ? row.guid : null
|
|
242
|
+
const event = typeof row.event === 'string' ? row.event : null
|
|
243
|
+
const statusCode = typeof row.status_code === 'number' ? row.status_code : null
|
|
244
|
+
if (id === null || guid === null || event === null || statusCode === null) return null
|
|
245
|
+
const deliveredAt = typeof row.delivered_at === 'string' ? Date.parse(row.delivered_at) || null : null
|
|
246
|
+
return { summary: { id, guid, event, statusCode }, deliveredAt }
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function parseRepo(slug: string): RepoTarget | null {
|
|
250
|
+
const [owner, repo, ...rest] = slug.trim().split('/')
|
|
251
|
+
if (owner === undefined || owner === '' || repo === undefined || repo === '' || rest.length > 0) return null
|
|
252
|
+
return { owner, repo }
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function nextLink(linkHeader: string | null): string | null {
|
|
256
|
+
if (linkHeader === null) return null
|
|
257
|
+
for (const part of linkHeader.split(',')) {
|
|
258
|
+
const m = /<([^>]+)>;\s*rel="next"/.exec(part)
|
|
259
|
+
if (m !== null) return m[1] ?? null
|
|
260
|
+
}
|
|
261
|
+
return null
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
265
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function describe(err: unknown): string {
|
|
269
|
+
return err instanceof Error ? err.message : String(err)
|
|
270
|
+
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { basename } from 'node:path'
|
|
2
|
+
|
|
1
3
|
import {
|
|
2
4
|
KakaoCredentialManager,
|
|
3
5
|
KakaoTalkClient as RealKakaoTalkClient,
|
|
@@ -203,7 +205,9 @@ export function createOutboundCallback(deps: {
|
|
|
203
205
|
try {
|
|
204
206
|
items = await Promise.all(
|
|
205
207
|
attachments.map(async (a) => {
|
|
206
|
-
|
|
208
|
+
// basename (not split('/')) so a native-Windows host's backslash
|
|
209
|
+
// attachment paths still yield just the filename (issue #899).
|
|
210
|
+
const filename = a.filename ?? (basename(a.path) || 'file')
|
|
207
211
|
const data = await readFile(a.path)
|
|
208
212
|
return { data, filename }
|
|
209
213
|
}),
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { AdapterId } from '../schema'
|
|
2
|
+
|
|
1
3
|
export type DiscordMentionUser = { id: string; username?: string; global_name?: string | null }
|
|
2
4
|
|
|
3
5
|
export type MentionHintOptions = { botUserId?: string | null }
|
|
@@ -12,6 +14,21 @@ const SLACK_MENTION_PATTERN = /<@([UW][A-Z0-9]+)(?:\|[^>]*)?>/g
|
|
|
12
14
|
// irrelevant to the target user, so it is captured but discarded on rewrite.
|
|
13
15
|
const DISCORD_MENTION_PATTERN = /<@!?(\d+)>/g
|
|
14
16
|
|
|
17
|
+
// User ids the agent addressed by @-mention in an outbound message, so the
|
|
18
|
+
// router can grant them sticky credit (their reply answers us without a
|
|
19
|
+
// re-mention). Only Slack (`<@U…>`) and Discord (`<@id>`) encode mentions as
|
|
20
|
+
// raw id tokens that map 1:1 to an inbound `authorId`; Telegram (`@username`),
|
|
21
|
+
// GitHub (`@login`), LINE and KakaoTalk have no token→authorId mapping here, so
|
|
22
|
+
// they return [] by design and the caller falls back to existing rules.
|
|
23
|
+
export function extractMentionedUserIds(adapter: AdapterId, text: string): string[] {
|
|
24
|
+
const pattern =
|
|
25
|
+
adapter === 'slack-bot' ? SLACK_MENTION_PATTERN : adapter === 'discord-bot' ? DISCORD_MENTION_PATTERN : null
|
|
26
|
+
if (pattern === null) return []
|
|
27
|
+
const ids = new Set<string>()
|
|
28
|
+
for (const match of text.matchAll(pattern)) ids.add(match[1]!)
|
|
29
|
+
return Array.from(ids)
|
|
30
|
+
}
|
|
31
|
+
|
|
15
32
|
export async function addSlackMentionHints(
|
|
16
33
|
text: string,
|
|
17
34
|
resolveUserName: (id: string) => Promise<string>,
|
package/src/channels/manager.ts
CHANGED
|
@@ -104,6 +104,17 @@ export type ChannelManagerOptions = {
|
|
|
104
104
|
// unregistered. See CreateChannelRouterOptions.onReload/onRestart.
|
|
105
105
|
onReload?: () => Promise<string>
|
|
106
106
|
onRestart?: (ctx?: RestartCommandContext) => Promise<string>
|
|
107
|
+
// Persistent messenger SDKs usually reconnect themselves, but a host sleep/offline
|
|
108
|
+
// cycle can leave a socket half-dead forever. The manager watches live adapters
|
|
109
|
+
// and restarts one that stays disconnected past this grace period. Test seams are
|
|
110
|
+
// optional so production uses normal timers/time.
|
|
111
|
+
connectionRecovery?: {
|
|
112
|
+
checkIntervalMs?: number
|
|
113
|
+
disconnectedGraceMs?: number
|
|
114
|
+
now?: () => number
|
|
115
|
+
setInterval?: (fn: () => void, ms: number) => unknown
|
|
116
|
+
clearInterval?: (handle: unknown) => void
|
|
117
|
+
}
|
|
107
118
|
}
|
|
108
119
|
|
|
109
120
|
export type ChannelManager = {
|
|
@@ -132,6 +143,8 @@ type AnyAdapter =
|
|
|
132
143
|
type AdapterEntry = {
|
|
133
144
|
adapter: AnyAdapter
|
|
134
145
|
credentialSignature: string
|
|
146
|
+
disconnectedSinceMs: number | null
|
|
147
|
+
recoveryRestartQueued: boolean
|
|
135
148
|
}
|
|
136
149
|
|
|
137
150
|
export function createChannelManager(options: ChannelManagerOptions): ChannelManager {
|
|
@@ -158,6 +171,14 @@ export function createChannelManager(options: ChannelManagerOptions): ChannelMan
|
|
|
158
171
|
|
|
159
172
|
const live = new Map<AdapterId, AdapterEntry>()
|
|
160
173
|
const perAdapterSerial = new Map<AdapterId, Promise<unknown>>()
|
|
174
|
+
const recovery = options.connectionRecovery ?? {}
|
|
175
|
+
const recoveryCheckIntervalMs = recovery.checkIntervalMs ?? 30_000
|
|
176
|
+
const recoveryDisconnectedGraceMs = recovery.disconnectedGraceMs ?? 90_000
|
|
177
|
+
const recoveryNow = recovery.now ?? (() => Date.now())
|
|
178
|
+
const recoverySetInterval = recovery.setInterval ?? ((fn: () => void, ms: number) => setInterval(fn, ms))
|
|
179
|
+
const recoveryClearInterval =
|
|
180
|
+
recovery.clearInterval ?? ((handle: unknown) => clearInterval(handle as ReturnType<typeof setInterval>))
|
|
181
|
+
let recoveryTimer: unknown = null
|
|
161
182
|
|
|
162
183
|
const runSerially = <T>(name: AdapterId, op: () => Promise<T>): Promise<T> => {
|
|
163
184
|
const prev = perAdapterSerial.get(name) ?? Promise.resolve()
|
|
@@ -271,7 +292,12 @@ export function createChannelManager(options: ChannelManagerOptions): ChannelMan
|
|
|
271
292
|
}
|
|
272
293
|
try {
|
|
273
294
|
await adapter.start()
|
|
274
|
-
live.set(name, {
|
|
295
|
+
live.set(name, {
|
|
296
|
+
adapter,
|
|
297
|
+
credentialSignature: signature,
|
|
298
|
+
disconnectedSinceMs: adapter.isConnected() ? null : recoveryNow(),
|
|
299
|
+
recoveryRestartQueued: false,
|
|
300
|
+
})
|
|
275
301
|
logger.info(`[channels] adapter "${name}" started`)
|
|
276
302
|
return true
|
|
277
303
|
} catch (err) {
|
|
@@ -292,6 +318,54 @@ export function createChannelManager(options: ChannelManagerOptions): ChannelMan
|
|
|
292
318
|
}
|
|
293
319
|
}
|
|
294
320
|
|
|
321
|
+
const checkConnectionRecovery = (): void => {
|
|
322
|
+
const now = recoveryNow()
|
|
323
|
+
for (const [name, entry] of live) {
|
|
324
|
+
if (entry.adapter.isConnected()) {
|
|
325
|
+
entry.disconnectedSinceMs = null
|
|
326
|
+
entry.recoveryRestartQueued = false
|
|
327
|
+
continue
|
|
328
|
+
}
|
|
329
|
+
if (entry.disconnectedSinceMs === null) {
|
|
330
|
+
entry.disconnectedSinceMs = now
|
|
331
|
+
logger.warn(`[channels] adapter "${name}" is disconnected; waiting for SDK recovery`)
|
|
332
|
+
continue
|
|
333
|
+
}
|
|
334
|
+
const disconnectedForMs = now - entry.disconnectedSinceMs
|
|
335
|
+
if (disconnectedForMs < recoveryDisconnectedGraceMs || entry.recoveryRestartQueued) continue
|
|
336
|
+
entry.recoveryRestartQueued = true
|
|
337
|
+
logger.warn(
|
|
338
|
+
`[channels] adapter "${name}" disconnected for ${Math.round(disconnectedForMs)}ms; restarting adapter`,
|
|
339
|
+
)
|
|
340
|
+
void runSerially(name, async () => {
|
|
341
|
+
try {
|
|
342
|
+
const current = live.get(name)
|
|
343
|
+
if (current !== entry) return
|
|
344
|
+
const currentCfg = options.channelsConfigRef()[name]
|
|
345
|
+
if (currentCfg === undefined || currentCfg.enabled === false) {
|
|
346
|
+
logger.info(`[channels] recovery restart for "${name}" skipped; adapter no longer enabled`)
|
|
347
|
+
return
|
|
348
|
+
}
|
|
349
|
+
await stopAdapter(name)
|
|
350
|
+
await startAdapter(name, currentCfg)
|
|
351
|
+
} finally {
|
|
352
|
+
if (live.get(name) === entry) entry.recoveryRestartQueued = false
|
|
353
|
+
}
|
|
354
|
+
})
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const startRecoveryTimer = (): void => {
|
|
359
|
+
if (recoveryTimer !== null) return
|
|
360
|
+
recoveryTimer = recoverySetInterval(checkConnectionRecovery, recoveryCheckIntervalMs)
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const stopRecoveryTimer = (): void => {
|
|
364
|
+
if (recoveryTimer === null) return
|
|
365
|
+
recoveryClearInterval(recoveryTimer)
|
|
366
|
+
recoveryTimer = null
|
|
367
|
+
}
|
|
368
|
+
|
|
295
369
|
return {
|
|
296
370
|
router,
|
|
297
371
|
|
|
@@ -313,9 +387,11 @@ export function createChannelManager(options: ChannelManagerOptions): ChannelMan
|
|
|
313
387
|
const results = await Promise.allSettled(starts)
|
|
314
388
|
const failure = results.find((r): r is PromiseRejectedResult => r.status === 'rejected')
|
|
315
389
|
if (failure !== undefined) throw failure.reason
|
|
390
|
+
startRecoveryTimer()
|
|
316
391
|
},
|
|
317
392
|
|
|
318
393
|
async stop(): Promise<void> {
|
|
394
|
+
stopRecoveryTimer()
|
|
319
395
|
for (const name of Array.from(live.keys())) await runSerially(name, () => stopAdapter(name))
|
|
320
396
|
await router.stop()
|
|
321
397
|
},
|