@uniquedivine/tg-sai 0.0.5 → 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/custodyWorker.ts CHANGED
@@ -2,7 +2,17 @@ import { randomUUID } from "node:crypto"
2
2
  import { mkdir, open, readFile } from "node:fs/promises"
3
3
  import { dirname, join } from "node:path"
4
4
  import { configDir } from "./profiles"
5
- import { provisionSetup, safeProvisionError } from "./provisioning"
5
+ import {
6
+ closeProcessHoldStores,
7
+ processHoldStore,
8
+ } from "./mtcuteAdapter"
9
+ import {
10
+ cloudRetryAt,
11
+ HOLD_STATE_UNAVAILABLE,
12
+ HOLD_STATE_UNAVAILABLE_RETRY_MS,
13
+ provisionSetup,
14
+ safeProvisionError,
15
+ } from "./provisioning"
6
16
  import type {
7
17
  ProvisionCloud,
8
18
  ProvisionJournal,
@@ -15,6 +25,12 @@ import {
15
25
  withAccountLock,
16
26
  withProvisionTelegram,
17
27
  } from "./provisioningRuntime"
28
+ import type { HoldStoreSource } from "./state/guard"
29
+ import { HoldStore, remainingSeconds } from "./state/holds"
30
+ import type { HoldGroup } from "./state/holds"
31
+ import { WORKER_TICK_EVENT } from "./telemetry/db"
32
+ import { withTelemetryJob } from "./telemetry/middleware"
33
+ import type { TelemetryRun } from "./telemetry/run"
18
34
 
19
35
  export const CUSTODY_USER_ID = 8274304175
20
36
  export const REQUIRED_UNIQUE_ADMIN_ID = 1912700300
@@ -44,6 +60,8 @@ export interface WorkerOptions {
44
60
  run?: boolean
45
61
  now?: number
46
62
  after?: string
63
+ /** Once aborted, no new job starts; the running one is cut short. */
64
+ signal?: AbortSignal
47
65
  }
48
66
  /** Persistent random identity retains ownership of sticky cloud claims after restart. */
49
67
  export async function stableWorkerId(root = configDir()): Promise<string> {
@@ -68,12 +86,64 @@ export async function stableWorkerId(root = configDir()): Promise<string> {
68
86
  return id
69
87
  }
70
88
 
89
+ /** Hold groups a setup job uses: resolve handles, create, add members. */
90
+ export const SETUP_HOLD_GROUPS: readonly HoldGroup[] = [
91
+ "create",
92
+ "invite",
93
+ "resolve",
94
+ ]
95
+
96
+ /** Groups a setup that already has its chat still uses: no create. */
97
+ export const CONFIGURE_HOLD_GROUPS: readonly HoldGroup[] = ["invite", "resolve"]
98
+
99
+ /** Hold groups that block `setup`: create only matters before its chat. */
100
+ export function setupHoldGroups(
101
+ setup: Pick<Setup, "chatId">,
102
+ ): readonly HoldGroup[] {
103
+ return setup.chatId ? CONFIGURE_HOLD_GROUPS : SETUP_HOLD_GROUPS
104
+ }
105
+
106
+ /**
107
+ * When the custody account's saved flood holds allow setup work again:
108
+ * the latest `untilMs` of its holds on `groups` (default create, invite,
109
+ * and resolve), or `HOLD_STATE_UNAVAILABLE_RETRY_MS` from now when the
110
+ * store cannot be read. `null` when no hold applies. Never throws.
111
+ */
112
+ export function setupHoldUntil(
113
+ holds: HoldStoreSource,
114
+ accountId: number,
115
+ nowMs: number,
116
+ groups: readonly HoldGroup[] = SETUP_HOLD_GROUPS,
117
+ ): { untilMs: number; code: string } | null {
118
+ try {
119
+ const store = typeof holds === "function" ? holds() : holds
120
+ let untilMs = 0
121
+ for (const grp of groups) {
122
+ const hold = store.activeHold(accountId, grp, nowMs)
123
+ if (hold && hold.untilMs > untilMs) untilMs = hold.untilMs
124
+ }
125
+ return untilMs > nowMs
126
+ ? { untilMs, code: `TG_SAI_HOLD_${remainingSeconds(untilMs, nowMs)}` }
127
+ : null
128
+ } catch {
129
+ return {
130
+ untilMs: nowMs + HOLD_STATE_UNAVAILABLE_RETRY_MS,
131
+ code: HOLD_STATE_UNAVAILABLE,
132
+ }
133
+ }
134
+ }
135
+
71
136
  /** One bounded page per tick; authoritative queue and local checks both scope jobs. */
72
137
  export async function runWorkerOnce(
73
138
  options: WorkerOptions,
74
139
  deps: {
75
140
  cloud: ProvisionCloud & PendingQueue
76
141
  journal: ProvisionJournal
142
+ /**
143
+ * Saved flood holds; a held account is neither claimed nor contacted.
144
+ * A provider is called each time, so an unavailable store can reopen.
145
+ */
146
+ holds: HoldStoreSource
77
147
  telegram: <T>(
78
148
  setup: Setup,
79
149
  operation: (adapter: TelegramProvisioner) => Promise<T>,
@@ -108,6 +178,7 @@ export async function runWorkerOnce(
108
178
  error: row.reason,
109
179
  }))
110
180
  for (const selected of page.requests) {
181
+ if (options.signal?.aborted) break
111
182
  const setup = await deps.cloud.view(selected.requestId)
112
183
  if (
113
184
  setup.expectedUserId !== CUSTODY_USER_ID ||
@@ -163,6 +234,17 @@ export async function runWorkerOnce(
163
234
  })
164
235
  continue
165
236
  }
237
+ const nowMs = options.now ?? Date.now()
238
+ const hold = setupHoldUntil(
239
+ deps.holds,
240
+ setup.expectedUserId,
241
+ nowMs,
242
+ setupHoldGroups(setup),
243
+ )
244
+ if (hold) {
245
+ results.push(await reportHold(deps.cloud, setup, hold, nowMs))
246
+ continue
247
+ }
166
248
  try {
167
249
  const result = await deps.telegram(setup, (telegram) =>
168
250
  provisionSetup({
@@ -195,6 +277,46 @@ export async function runWorkerOnce(
195
277
  return { dryRun: !options.run, results, nextAfter: page.nextAfter }
196
278
  }
197
279
 
280
+ /**
281
+ * Waiting result for a held account. An incomplete setup this worker already
282
+ * holds the claim for gets `cloudRetryAt` of the hold's end as its cloud
283
+ * `retryAt`, through the
284
+ * same `progress` call a flood retry uses, so Deal Desk shows the time. Other
285
+ * setups are not claimed; Deal Desk cannot store a `retryAt` for them.
286
+ */
287
+ async function reportHold(
288
+ cloud: ProvisionCloud,
289
+ setup: Setup,
290
+ hold: { untilMs: number; code: string },
291
+ nowMs: number,
292
+ ): Promise<WorkerResult> {
293
+ // Same value provisioning saves for a hold, so the cloud time never ends
294
+ // before the local hold and the next tick does not report it again.
295
+ const retryAt =
296
+ cloudRetryAt(hold.untilMs, nowMs) ?? new Date(hold.untilMs).toISOString()
297
+ const result: WorkerResult = {
298
+ requestId: setup.requestId,
299
+ state: "waiting",
300
+ ...(setup.chatId ? { chatId: setup.chatId } : {}),
301
+ retryAt,
302
+ error: hold.code,
303
+ }
304
+ if (setup.state !== "incomplete" || !setup.claimToken) return result
305
+ try {
306
+ await cloud.progress(setup.requestId, setup.claimToken, {
307
+ state: "incomplete",
308
+ ...(setup.chatId ? { chatId: setup.chatId } : {}),
309
+ ...(setup.binding ? { binding: setup.binding } : {}),
310
+ error: hold.code,
311
+ retryAt,
312
+ })
313
+ } catch (error) {
314
+ // The local hold still keeps the job waiting; the cloud may lag.
315
+ return { ...result, error: `${hold.code}:${safeProvisionError(error)}` }
316
+ }
317
+ return result
318
+ }
319
+
198
320
  export async function runCustodyWorker(options: {
199
321
  custodyProfile: string
200
322
  once?: boolean
@@ -202,6 +324,8 @@ export async function runCustodyWorker(options: {
202
324
  limit?: number
203
325
  intervalMs?: number
204
326
  signal?: AbortSignal
327
+ /** Process run: worker.tick rows and per-job telemetry. */
328
+ telemetry?: Pick<TelemetryRun, "sink" | "context" | "client">
205
329
  }): Promise<void> {
206
330
  const interval = options.intervalMs ?? 5000
207
331
  if (!Number.isSafeInteger(interval) || interval < 1000 || interval > 60000)
@@ -212,44 +336,91 @@ export async function runCustodyWorker(options: {
212
336
  )
213
337
  const root = configDir()
214
338
  const workerId = await stableWorkerId(root)
339
+ const telemetry = options.telemetry
340
+ const clientTelemetry = telemetry?.client({
341
+ accountId: CUSTODY_USER_ID,
342
+ profile: options.custodyProfile,
343
+ })
344
+ // Each job's Telegram calls carry its setup ID as job_id.
345
+ const telegram = <T>(
346
+ setup: Setup,
347
+ operation: (adapter: TelegramProvisioner) => Promise<T>,
348
+ ): Promise<T> =>
349
+ withTelemetryJob(setup.requestId, () =>
350
+ withProvisionTelegram(setup, operation, clientTelemetry, options.signal),
351
+ )
352
+ let lastTickRecordedMs: number | null = null
353
+ const recordTick = (tick: {
354
+ startedAt: number
355
+ result?: Awaited<ReturnType<typeof runWorkerOnce>>
356
+ error?: unknown
357
+ }) => {
358
+ if (!telemetry) return
359
+ const nowMs = Date.now()
360
+ if (
361
+ !shouldRecordTick({
362
+ ...tick,
363
+ nowMs,
364
+ lastRecordedMs: lastTickRecordedMs,
365
+ })
366
+ ) {
367
+ return
368
+ }
369
+ lastTickRecordedMs = nowMs
370
+ recordWorkerTick(telemetry, {
371
+ ...tick,
372
+ custodyProfile: options.custodyProfile,
373
+ })
374
+ }
215
375
  const execute = async () => {
216
376
  const cloud = new CliProvisionCloud()
377
+ // Preview never reaches the hold check, so it opens no state file. A
378
+ // run shares the guard's store, which reopens after a failed open.
379
+ const holds: HoldStoreSource = options.run
380
+ ? () => processHoldStore()
381
+ : HoldStore.unavailable("preview")
217
382
  let after: string | undefined
218
- do {
219
- try {
220
- const result = await runWorkerOnce(
221
- { ...options, workerId, after },
222
- {
223
- cloud,
224
- journal: new FileProvisionJournal(root),
225
- telegram: withProvisionTelegram,
226
- },
227
- )
228
- after = result.nextAfter ?? undefined
229
- console.log(
230
- JSON.stringify({ ...result, observedAt: new Date().toISOString() }),
231
- )
232
- } catch (error) {
233
- if (options.once) throw error
234
- console.error(
235
- JSON.stringify({
383
+ try {
384
+ do {
385
+ const tickStartedAt = Date.now()
386
+ try {
387
+ const result = await runWorkerOnce(
388
+ { ...options, workerId, after },
389
+ {
390
+ cloud,
391
+ journal: new FileProvisionJournal(root),
392
+ holds,
393
+ telegram,
394
+ },
395
+ )
396
+ after = result.nextAfter ?? undefined
397
+ const line = { ...result, observedAt: new Date().toISOString() }
398
+ console.log(JSON.stringify(line))
399
+ recordTick({ startedAt: tickStartedAt, result })
400
+ } catch (error) {
401
+ recordTick({ startedAt: tickStartedAt, error })
402
+ if (options.once) throw error
403
+ const line = {
236
404
  state: "worker_tick_failed",
237
405
  observedAt: new Date().toISOString(),
238
406
  retryInMs: interval,
239
- }),
240
- )
241
- }
242
- if (options.once || options.signal?.aborted) break
243
- await new Promise<void>((resolve) => {
244
- const done = () => {
245
- clearTimeout(timer)
246
- options.signal?.removeEventListener("abort", done)
247
- resolve()
407
+ }
408
+ console.error(JSON.stringify(line))
248
409
  }
249
- const timer = setTimeout(done, interval)
250
- options.signal?.addEventListener("abort", done, { once: true })
251
- })
252
- } while (!options.signal?.aborted)
410
+ if (options.once || options.signal?.aborted) break
411
+ await new Promise<void>((resolve) => {
412
+ const done = () => {
413
+ clearTimeout(timer)
414
+ options.signal?.removeEventListener("abort", done)
415
+ resolve()
416
+ }
417
+ const timer = setTimeout(done, interval)
418
+ options.signal?.addEventListener("abort", done, { once: true })
419
+ })
420
+ } while (!options.signal?.aborted)
421
+ } finally {
422
+ if (options.run) closeProcessHoldStores()
423
+ }
253
424
  }
254
425
  if (!options.run) return execute()
255
426
  await withAccountLock(
@@ -257,3 +428,74 @@ export async function runCustodyWorker(options: {
257
428
  execute,
258
429
  )
259
430
  }
431
+
432
+ /** Counts results per state, e.g. `{ ready: 2, waiting: 1 }`. */
433
+ export function countStates(results: WorkerResult[]): Record<string, number> {
434
+ const counts: Record<string, number> = {}
435
+ for (const result of results) {
436
+ counts[result.state] = (counts[result.state] ?? 0) + 1
437
+ }
438
+ return counts
439
+ }
440
+
441
+ /** Idle ticks still write a `worker.tick` row this often, as a heartbeat. */
442
+ export const TICK_HEARTBEAT_MS = 5 * 60 * 1000
443
+
444
+ /** Job states that mean the tick did no work. */
445
+ const IDLE_STATES = new Set(["waiting", "blocked"])
446
+
447
+ /**
448
+ * Whether a tick earns a `worker.tick` row: it threw, a job did something
449
+ * (any result not `waiting`/`blocked`), it is the first tick, or the last
450
+ * recorded tick is `TICK_HEARTBEAT_MS` old. Idle polls are otherwise skipped
451
+ * so a 5s interval does not write ~17k rows a day.
452
+ */
453
+ export function shouldRecordTick(tick: {
454
+ result?: Awaited<ReturnType<typeof runWorkerOnce>>
455
+ error?: unknown
456
+ nowMs: number
457
+ lastRecordedMs: number | null
458
+ }): boolean {
459
+ if (tick.error !== undefined) return true
460
+ if (tick.result?.results.some((r) => !IDLE_STATES.has(r.state))) return true
461
+ if (tick.lastRecordedMs === null) return true
462
+ return tick.nowMs - tick.lastRecordedMs >= TICK_HEARTBEAT_MS
463
+ }
464
+
465
+ /** Writes one `worker.tick` row; never throws. */
466
+ export function recordWorkerTick(
467
+ telemetry: Pick<TelemetryRun, "sink" | "context">,
468
+ tick: {
469
+ startedAt: number
470
+ custodyProfile: string
471
+ result?: Awaited<ReturnType<typeof runWorkerOnce>>
472
+ error?: unknown
473
+ },
474
+ ): void {
475
+ try {
476
+ const { context } = telemetry
477
+ telemetry.sink.record({
478
+ runId: context.runId,
479
+ subsystem: context.subsystem,
480
+ command: context.command,
481
+ version: context.version,
482
+ accountId: CUSTODY_USER_ID,
483
+ profile: tick.custodyProfile,
484
+ event: WORKER_TICK_EVENT,
485
+ outcome: tick.error === undefined ? "ok" : "error",
486
+ durationMs: Date.now() - tick.startedAt,
487
+ err:
488
+ tick.error === undefined ? null : safeProvisionError(tick.error),
489
+ res: tick.result
490
+ ? {
491
+ dryRun: tick.result.dryRun,
492
+ jobs: tick.result.results.length,
493
+ states: countStates(tick.result.results),
494
+ more: tick.result.nextAfter !== null,
495
+ }
496
+ : null,
497
+ })
498
+ } catch {
499
+ // Telemetry never stops the worker.
500
+ }
501
+ }
@@ -1,5 +1,6 @@
1
1
  import type { Command } from "commander"
2
2
  import { runCustodyWorker } from "./custodyWorker"
3
+ import { activeTelemetryRun } from "./telemetry/run"
3
4
 
4
5
  /** Root main.ts calls this once beside addProvisionCommands. No MCP is involved. */
5
6
  export function addCustodyWorkerCommands(
@@ -32,7 +33,11 @@ export function addCustodyWorkerCommands(
32
33
  intervalMs: string
33
34
  }) => {
34
35
  const controller = new AbortController()
35
- const stop = () => controller.abort()
36
+ const telemetry = activeTelemetryRun() ?? undefined
37
+ const stop = () => {
38
+ telemetry?.markAborted()
39
+ controller.abort()
40
+ }
36
41
  process.once("SIGTERM", stop)
37
42
  process.once("SIGINT", stop)
38
43
  try {
@@ -42,6 +47,7 @@ export function addCustodyWorkerCommands(
42
47
  limit: Number(options.limit),
43
48
  intervalMs: Number(options.intervalMs),
44
49
  signal: controller.signal,
50
+ ...(telemetry ? { telemetry } : {}),
45
51
  })
46
52
  } finally {
47
53
  process.removeListener("SIGTERM", stop)