@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/README.md +17 -0
- package/RUNBOOK.md +67 -8
- package/actionExecutor.ts +157 -105
- package/custodyWorker.ts +274 -32
- package/custodyWorkerCli.ts +7 -1
- package/main.ts +422 -118
- package/mtcuteAdapter.ts +255 -7
- package/package.json +10 -2
- package/profiles.ts +23 -5
- package/provisioning.ts +175 -44
- package/provisioningCli.ts +8 -1
- package/provisioningRuntime.ts +42 -5
- package/runtimeHealth.ts +8 -1
- package/runtimeHealthCli.ts +3 -1
- package/state/cli.ts +641 -0
- package/state/db.ts +72 -0
- package/state/guard.ts +270 -0
- package/state/holds.ts +381 -0
- package/state/migrations/0001-flood-hold.sql +16 -0
- package/telemetry/cli.ts +698 -0
- package/telemetry/db.ts +471 -0
- package/telemetry/middleware.ts +475 -0
- package/telemetry/migrations/0001-event.sql +33 -0
- package/telemetry/migrations/0002-event-job-index.sql +2 -0
- package/telemetry/migrations/0003-event-log.sql +10 -0
- package/telemetry/queries.ts +475 -0
- package/telemetry/run.ts +491 -0
- package/telemetry/sqlAssets.d.ts +4 -0
- package/telemetry/summarize.ts +219 -0
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 {
|
|
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
|
-
|
|
219
|
-
|
|
220
|
-
const
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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
|
+
}
|
package/custodyWorkerCli.ts
CHANGED
|
@@ -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
|
|
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)
|