threadwire 0.1.5 → 0.1.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.
@@ -3,6 +3,7 @@
3
3
  import {DEFAULT_TELEGRAM_REQUEST_TIMEOUT_MS, MAX_TIMER_DELAY_MS} from "../notifiers/fetch-transport.js"
4
4
  import {open as nodeOpen} from "node:fs/promises"
5
5
  import {constants as fsConstants} from "node:fs"
6
+ import {isAbsolute, normalize, parse, relative} from "node:path"
6
7
 
7
8
  const TELEGRAM_ID_PATTERN = /^-?[1-9]\d*$/u
8
9
  const DEFAULT_PORT = 8787
@@ -10,6 +11,7 @@ const DEFAULT_HOST = "127.0.0.1"
10
11
  const DEFAULT_MAX_CONCURRENT_WORKERS = 4
11
12
  const DEFAULT_UPDATE_ID_CAPACITY = 10_000
12
13
  const DEFAULT_UPDATE_ID_TTL_MS = 86_400_000
14
+ const DEFAULT_EVIDENCE_ROOT = "/var/lib/threadwire/evidence"
13
15
  export const MIN_WEBHOOK_SECRET_LENGTH = 32
14
16
  const MAX_SECRET_FILE_BYTES = 65_536
15
17
  const FILE_BACKED_SETTINGS = [
@@ -34,25 +36,90 @@ export async function resolveFileBackedSettings(source, names, operations = {})
34
36
  const file = environment[fileName]
35
37
  if (value !== undefined && file !== undefined) throw new Error(`${name} and ${fileName} must not both be set`)
36
38
  if (file === undefined) continue
37
- let handle
38
39
  try {
39
- handle = await (operations.open ?? nodeOpen)(file, fsConstants.O_RDONLY | fsConstants.O_NONBLOCK | fsConstants.O_NOFOLLOW)
40
- const stats = await handle.stat()
41
- if (!stats.isFile() || stats.size > MAX_SECRET_FILE_BYTES) throw new Error("invalid setting file")
42
- const buffer = Buffer.alloc(MAX_SECRET_FILE_BYTES + 1)
43
- const {bytesRead} = await handle.read(buffer, 0, buffer.length, 0)
44
- if (bytesRead > MAX_SECRET_FILE_BYTES) throw new Error("invalid setting file")
45
- environment[name] = buffer.subarray(0, bytesRead).toString("utf8").trim()
40
+ environment[name] = await readSecureSettingFile(file, operations)
46
41
  } catch {
47
42
  throw new Error(`${fileName} could not be read`)
48
- } finally {
49
- await handle?.close().catch(() => {})
50
43
  }
51
44
  delete environment[fileName]
52
45
  }
53
46
  return environment
54
47
  }
55
48
 
49
+ /**
50
+ * Read one bounded regular secret file without following a final symlink.
51
+ * @param {string} file
52
+ * @param {{open?: (path: string, flags: number) => Promise<import("node:fs/promises").FileHandle>}} [operations]
53
+ */
54
+ export async function readSecureSettingFile(file, operations = {}) {
55
+ if (!isAbsolute(file) || normalize(file) !== file || relative(parse(file).root, file).split("/").includes("..")) {
56
+ throw new Error("invalid setting file")
57
+ }
58
+ const openImpl = operations.open ?? nodeOpen
59
+ /** @type {import("node:fs/promises").FileHandle[]} */
60
+ const directories = []
61
+ let handle
62
+ try {
63
+ if (operations.open === undefined) {
64
+ let directory = await openImpl(parse(file).root, fsConstants.O_RDONLY | fsConstants.O_DIRECTORY | fsConstants.O_NOFOLLOW)
65
+ directories.push(directory)
66
+ for (const component of relative(parse(file).root, file).split("/").slice(0, -1)) {
67
+ const stats = await directory.stat()
68
+ if (!stats.isDirectory() || !trustedDirectory(stats)) throw new Error("invalid setting file")
69
+ directory = await openImpl(`/proc/self/fd/${directory.fd}/${component}`, fsConstants.O_RDONLY | fsConstants.O_DIRECTORY | fsConstants.O_NOFOLLOW)
70
+ directories.push(directory)
71
+ }
72
+ const parentStats = await directory.stat()
73
+ if (!parentStats.isDirectory() || !trustedDirectory(parentStats)) throw new Error("invalid setting file")
74
+ const name = relative(parse(file).root, file).split("/").at(-1)
75
+ handle = await openImpl(`/proc/self/fd/${directory.fd}/${name}`, fsConstants.O_RDONLY | fsConstants.O_NONBLOCK | fsConstants.O_NOFOLLOW)
76
+ } else {
77
+ handle = await openImpl(file, fsConstants.O_RDONLY | fsConstants.O_NONBLOCK | fsConstants.O_NOFOLLOW)
78
+ }
79
+ const stats = await handle.stat()
80
+ if (!stats.isFile() || stats.nlink !== 1 || stats.size > MAX_SECRET_FILE_BYTES || !trustedSecretFile(stats)) throw new Error("invalid setting file")
81
+ const buffer = Buffer.alloc(MAX_SECRET_FILE_BYTES + 1)
82
+ const {bytesRead} = await handle.read(buffer, 0, buffer.length, 0)
83
+ if (bytesRead > MAX_SECRET_FILE_BYTES) throw new Error("invalid setting file")
84
+ const value = buffer.subarray(0, bytesRead).toString("utf8").trim()
85
+ if (value.length === 0) throw new Error("invalid setting file")
86
+ return value
87
+ } finally {
88
+ await handle?.close().catch(() => {})
89
+ await Promise.all(directories.map((directory) => directory.close().catch(() => {})))
90
+ }
91
+ }
92
+
93
+ /** @param {import("node:fs").Stats} stats */
94
+ function trustedDirectory(stats) {
95
+ const mode = stats.mode & 0o7777
96
+ return (stats.uid === 0 || stats.uid === process.getuid?.())
97
+ && ((mode & 0o022) === 0 || (stats.uid === 0 && (mode & 0o1000) !== 0))
98
+ }
99
+
100
+ /** @param {import("node:fs").Stats} stats */
101
+ function trustedSecretFile(stats) {
102
+ const mode = stats.mode & 0o777
103
+ if (stats.uid === 0) return (mode & 0o222) === 0
104
+ return stats.uid === process.getuid?.() && (mode & 0o077) === 0
105
+ }
106
+
107
+ /** @param {NodeJS.ProcessEnv} environment */
108
+ export async function collectEvidenceRedactions(environment) {
109
+ const redactions = []
110
+ for (const [key, value] of Object.entries(environment)) {
111
+ if (value === undefined) continue
112
+ if (/(?:TOKEN|SECRET|PASSWORD|API_KEY|PRIVATE_KEY)_FILE$/u.test(key)) {
113
+ if (value.length >= 4) redactions.push(value)
114
+ const secret = await readSecureSettingFile(value)
115
+ if (secret.length >= 4) redactions.push(secret)
116
+ } else if (/(?:TOKEN|SECRET|PASSWORD|API_KEY|PRIVATE_KEY)$/u.test(key) && value.length >= 4) {
117
+ redactions.push(value)
118
+ }
119
+ }
120
+ return redactions
121
+ }
122
+
56
123
  /**
57
124
  * Resolve every supported ingress setting from a literal or bounded file.
58
125
  * @param {NodeJS.ProcessEnv} source
@@ -75,6 +142,7 @@ export async function resolveIngressEnvironment(source, operations = {}) {
75
142
  * telegramRequestTimeoutMs: number,
76
143
  * updateIdCapacity: number,
77
144
  * updateIdTtlMs: number
145
+ * evidenceRoot?: string
78
146
  * }} IngressConfig
79
147
  */
80
148
 
@@ -109,6 +177,7 @@ export function parseIngressConfig(environment) {
109
177
  "THREADWIRE_UPDATE_ID_TTL_MS",
110
178
  DEFAULT_UPDATE_ID_TTL_MS
111
179
  )
180
+ const evidenceRoot = parseEvidenceRoot(environment.THREADWIRE_EVIDENCE_ROOT)
112
181
  return {
113
182
  botToken,
114
183
  webhookSecret,
@@ -120,10 +189,18 @@ export function parseIngressConfig(environment) {
120
189
  toolMessages,
121
190
  telegramRequestTimeoutMs,
122
191
  updateIdCapacity,
123
- updateIdTtlMs
192
+ updateIdTtlMs,
193
+ evidenceRoot
124
194
  }
125
195
  }
126
196
 
197
+ /** @param {string | undefined} value */
198
+ function parseEvidenceRoot(value) {
199
+ const root = value ?? DEFAULT_EVIDENCE_ROOT
200
+ if (!isAbsolute(root) || normalize(root) !== root) throw new Error("THREADWIRE_EVIDENCE_ROOT must be a normalized absolute path")
201
+ return root
202
+ }
203
+
127
204
  /** @param {NodeJS.ProcessEnv} environment */
128
205
  export function parseTelegramRequestTimeoutMs(environment) {
129
206
  const timeoutMs = parsePositiveSafeInteger(
@@ -165,6 +242,7 @@ function isIngressSecretKey(key) {
165
242
  key === "THREADWIRE_WEBHOOK_PORT" ||
166
243
  key === "THREADWIRE_WEBHOOK_HOST" ||
167
244
  key === "THREADWIRE_MAX_CONCURRENT_WORKERS" ||
245
+ key === "THREADWIRE_EVIDENCE_ROOT" ||
168
246
  key === "THREADWIRE_TOOL_MESSAGES" ||
169
247
  key === "THREADWIRE_TELEGRAM_REQUEST_TIMEOUT_MS" ||
170
248
  key === "THREADWIRE_UPDATE_ID_CAPACITY" ||
@@ -1,30 +1,34 @@
1
1
  // @ts-check
2
2
 
3
- import {createHash, timingSafeEqual} from "node:crypto"
3
+ import {createHash, randomUUID, timingSafeEqual} from "node:crypto"
4
+ import {evidenceTelegramDestination} from "../evidence-store.js"
4
5
  import {NoticeQueue} from "../notice-queue.js"
5
6
  import {createFetchTransport} from "../notifiers/fetch-transport.js"
6
7
  import {createTelegramSender} from "../notifiers/telegram.js"
7
8
  import {createProvider} from "../providers/index.js"
8
9
  import {Relay} from "../relay.js"
9
10
  import {runWorker} from "../run-worker.js"
11
+ import {WorkerControl} from "../worker-control.js"
10
12
  import {resolveWorkspaceProfile} from "../workspace-profile.js"
11
- import {buildProviderEnvironment} from "./config.js"
12
- import {parseCodeCommand} from "./command.js"
13
+ import {buildProviderEnvironment, collectEvidenceRedactions} from "./config.js"
14
+ import {parseCodeCommand, parseEvidenceCommand} from "./command.js"
13
15
 
14
16
  /**
15
17
  * @typedef {{platform: "telegram", chatId: string, threadId: number | null}} TelegramTarget
16
- * @typedef {{provider: "codex" | "claude" | "opencode", prompt: string, target: TelegramTarget}} AcceptedJob
18
+ * @typedef {{provider: "codex" | "claude" | "opencode", prompt: string, target: TelegramTarget, senderId: string} | {type: "evidence", request: unknown, target: TelegramTarget, senderId: string}} AcceptedJob
17
19
  * @typedef {{
18
20
  * createProvider?: typeof createProvider,
19
21
  * createFetchTransport?: typeof createFetchTransport,
20
22
  * createTelegramSender?: typeof createTelegramSender,
21
23
  * runWorker?: typeof runWorker,
24
+ * WorkerControl?: typeof WorkerControl,
22
25
  * NoticeQueue?: typeof NoticeQueue,
23
26
  * Relay?: typeof Relay,
24
27
  * processNumber?: number,
25
28
  * providerEnvironment?: NodeJS.ProcessEnv,
26
29
  * workspaceProfileOperations?: import("../workspace-profile.js").WorkspaceProfileOperations,
27
30
  * activity?: Pick<import("../activity-log.js").ActivityLog, "recordWorkspace" | "recordStarted" | "recordSession" | "close">,
31
+ * evidenceStore?: import("../evidence-store.js").EvidenceStore,
28
32
  * onWorkerFailure?: (message: string) => void,
29
33
  * onWorkerSettled?: () => void
30
34
  * }} IngressDependencies
@@ -55,11 +59,12 @@ export function interpretUpdate(update, config) {
55
59
  if (!isRecord(update)) return null
56
60
  const message = update.message
57
61
  if (!isRecord(message)) return null
62
+ if ([
63
+ "forward_origin", "forward_from", "forward_from_chat", "forward_signature",
64
+ "forward_sender_name", "is_automatic_forward"
65
+ ].some((field) => Object.prototype.hasOwnProperty.call(message, field))) return null
58
66
  if (typeof message.text !== "string") return null
59
67
 
60
- const command = parseCodeCommand(message.text)
61
- if (!command) return null
62
-
63
68
  const chatId = telegramIdString(isRecord(message.chat) ? message.chat.id : undefined)
64
69
  if (chatId === null || !config.allowedChatIds.has(chatId)) return null
65
70
 
@@ -69,7 +74,11 @@ export function interpretUpdate(update, config) {
69
74
  const target = deriveTarget(chatId, message.message_thread_id)
70
75
  if (target === null) return null
71
76
 
72
- return {provider: command.provider, prompt: command.prompt, target}
77
+ const command = parseCodeCommand(message.text)
78
+ if (command) return {provider: command.provider, prompt: command.prompt, target, senderId}
79
+ const evidence = parseEvidenceCommand(message.text)
80
+ if (evidence) return {type: "evidence", request: evidence, target, senderId}
81
+ return null
73
82
  }
74
83
 
75
84
  /**
@@ -83,10 +92,12 @@ export function interpretUpdate(update, config) {
83
92
  * @returns {Promise<void>}
84
93
  */
85
94
  export async function dispatchWorker(job, config, dependencies = {}) {
95
+ if ("type" in job) throw new Error("Worker dispatch requires a worker job")
86
96
  const createProviderImpl = dependencies.createProvider ?? createProvider
87
97
  const createFetchTransportImpl = dependencies.createFetchTransport ?? createFetchTransport
88
98
  const createTelegramSenderImpl = dependencies.createTelegramSender ?? createTelegramSender
89
99
  const runWorkerImpl = dependencies.runWorker ?? runWorker
100
+ const WorkerControlImpl = dependencies.WorkerControl ?? WorkerControl
90
101
  const NoticeQueueImpl = dependencies.NoticeQueue ?? NoticeQueue
91
102
  const RelayImpl = dependencies.Relay ?? Relay
92
103
  const processNumber = dependencies.processNumber ?? process.pid
@@ -106,111 +117,172 @@ export async function dispatchWorker(job, config, dependencies = {}) {
106
117
  )
107
118
  const transport = createFetchTransportImpl(config.botToken, undefined, config.telegramRequestTimeoutMs)
108
119
  const sender = createTelegramSenderImpl(job.target, transport)
109
- const queue = new NoticeQueueImpl({sender})
110
- const relay = new RelayImpl({queue, processNumber, toolMessages: config.toolMessages ?? false})
111
-
112
- /** @type {{resolve: () => void, reject: (error: Error) => void, settled: boolean}} */
113
- const spawnGate = {
114
- settled: false,
115
- resolve() {},
116
- reject() {}
117
- }
118
- const spawned = new Promise((resolve, reject) => {
119
- spawnGate.resolve = () => {
120
- if (spawnGate.settled) return
121
- spawnGate.settled = true
122
- resolve(undefined)
123
- }
124
- spawnGate.reject = (error) => {
125
- if (spawnGate.settled) return
126
- spawnGate.settled = true
127
- reject(error)
128
- }
120
+ const control = new WorkerControlImpl({
121
+ sender,
122
+ processNumber,
123
+ toolMessages: config.toolMessages ?? false,
124
+ NoticeQueueClass: NoticeQueueImpl,
125
+ RelayClass: RelayImpl
126
+ })
127
+ const evidenceOwner = dependencies.evidenceStore?.createOwnerScope({
128
+ destinationId: evidenceTelegramDestination(job.target, job.senderId),
129
+ runId: randomUUID()
130
+ })
131
+ const evidence = evidenceOwner === undefined ? undefined : await dependencies.evidenceStore?.createArtifact(evidenceOwner, {
132
+ contentType: "text/plain; charset=utf-8",
133
+ redactions: await collectEvidenceRedactions(dependencies.providerEnvironment ?? {})
129
134
  })
135
+ let evidenceTransferred = false
136
+ try {
137
+ await evidence?.append("prompt", `prompt\n${job.prompt}\nprovider-stream\n`)
130
138
 
131
- /** @param {unknown} error */
132
- const reportBackgroundFailure = (error) => {
133
- const failure = error instanceof Error ? error : new Error(safeFailureMessage(error))
134
- try {
135
- dependencies.onWorkerFailure?.(safeFailureMessage(failure))
136
- } catch {
137
- /* Observer errors must not become unhandled rejections. */
139
+ /** @type {{resolve: () => void, reject: (error: Error) => void, settled: boolean}} */
140
+ const spawnGate = {
141
+ settled: false,
142
+ resolve() {},
143
+ reject() {}
138
144
  }
139
- }
145
+ const spawned = new Promise((resolve, reject) => {
146
+ spawnGate.resolve = () => {
147
+ if (spawnGate.settled) return
148
+ spawnGate.settled = true
149
+ resolve(undefined)
150
+ }
151
+ spawnGate.reject = (error) => {
152
+ if (spawnGate.settled) return
153
+ spawnGate.settled = true
154
+ reject(error)
155
+ }
156
+ })
140
157
 
141
- const notifySettled = () => {
142
- try {
143
- dependencies.onWorkerSettled?.()
144
- } catch {
158
+ /** @param {unknown} error */
159
+ const reportBackgroundFailure = (error) => {
160
+ const failure = error instanceof Error ? error : new Error(safeFailureMessage(error))
161
+ try {
162
+ dependencies.onWorkerFailure?.(safeFailureMessage(failure))
163
+ } catch {
145
164
  /* Observer errors must not become unhandled rejections. */
165
+ }
146
166
  }
147
- }
148
167
 
149
- const running = runWorkerImpl({
150
- executable: provider.executable,
151
- arguments: provider.arguments,
152
- cwd: workspace.cwd,
153
- environment: providerEnvironment,
154
- parse: provider.parse,
155
- onEvent: (event) => relay.accept(event),
156
- onSpawn: (pid) => {
157
- if (pid !== undefined) dependencies.activity?.recordStarted(provider.name, pid)
158
- spawnGate.resolve()
159
- },
160
- onRecord: (record) => {
161
- const id = provider.sessionId(record)
162
- if (id !== undefined) dependencies.activity?.recordSession(provider.name, id)
168
+ const notifySettled = () => {
169
+ try {
170
+ dependencies.onWorkerSettled?.()
171
+ } catch {
172
+ /* Observer errors must not become unhandled rejections. */
173
+ }
163
174
  }
164
- })
165
175
 
166
- // Detached observer: never resolves the spawn gate from process exit — only
167
- // from onSpawn. Pre-spawn failure rejects the gate; post-spawn failure is
168
- // reported. Always notify settlement so concurrency slots are released.
169
- void (async () => {
170
- try {
176
+ const running = runWorkerImpl({
177
+ executable: provider.executable,
178
+ arguments: provider.arguments,
179
+ cwd: workspace.cwd,
180
+ environment: providerEnvironment,
181
+ parse: provider.parse,
182
+ onEvent: async (event) => control.accept(event),
183
+ onSpawn: (pid) => {
184
+ if (pid !== undefined) dependencies.activity?.recordStarted(provider.name, pid)
185
+ spawnGate.resolve()
186
+ },
187
+ onRecord: async (record) => {
188
+ const id = provider.sessionId(record)
189
+ if (id !== undefined) dependencies.activity?.recordSession(provider.name, id)
190
+ },
191
+ onStdoutChunk: (chunk) => evidence?.append("provider-stdout", chunk),
192
+ onStderrChunk: (chunk) => evidence?.append("provider-stderr", chunk)
193
+ })
194
+
195
+ // Detached observer: never resolves the spawn gate from process exit — only
196
+ // from onSpawn. Pre-spawn failure rejects the gate; post-spawn failure is
197
+ // reported. Always notify settlement so concurrency slots are released.
198
+ evidenceTransferred = true
199
+ void (async () => {
171
200
  try {
172
- await running
173
- } catch (error) {
174
- const failure = error instanceof Error ? error : new Error(safeFailureMessage(error))
175
- if (spawnGate.settled) {
176
- reportBackgroundFailure(failure)
177
- } else {
178
- spawnGate.reject(failure)
179
- }
180
201
  try {
181
- await relay.close()
182
- } catch {
202
+ await running
203
+ } catch (error) {
204
+ const failure = error instanceof Error ? error : new Error(safeFailureMessage(error))
205
+ if (spawnGate.settled) {
206
+ reportBackgroundFailure(failure)
207
+ } else {
208
+ spawnGate.reject(failure)
209
+ }
210
+ try {
211
+ await control.close()
212
+ } catch {
183
213
  /* Best-effort close after worker failure. */
214
+ }
215
+ return
184
216
  }
185
- return
186
- }
187
217
 
188
- try {
189
- await relay.close()
218
+ try {
219
+ await control.close()
220
+ } catch (error) {
221
+ if (spawnGate.settled) {
222
+ reportBackgroundFailure(error)
223
+ } else {
224
+ spawnGate.reject(error instanceof Error ? error : new Error(safeFailureMessage(error)))
225
+ }
226
+ return
227
+ }
228
+
229
+ if (!spawnGate.settled) {
230
+ spawnGate.reject(new Error("Worker exited before spawn was observed"))
231
+ }
190
232
  } catch (error) {
191
233
  if (spawnGate.settled) {
192
234
  reportBackgroundFailure(error)
193
235
  } else {
194
236
  spawnGate.reject(error instanceof Error ? error : new Error(safeFailureMessage(error)))
195
237
  }
196
- return
238
+ } finally {
239
+ if (evidence !== undefined) {
240
+ try {
241
+ await evidence.finalize()
242
+ await sender.send(`Evidence: ${evidence.handle}`)
243
+ } catch (error) {
244
+ await evidence.abort()
245
+ reportBackgroundFailure(error)
246
+ }
247
+ }
248
+ notifySettled()
197
249
  }
250
+ })()
198
251
 
199
- if (!spawnGate.settled) {
200
- spawnGate.reject(new Error("Worker exited before spawn was observed"))
201
- }
202
- } catch (error) {
203
- if (spawnGate.settled) {
204
- reportBackgroundFailure(error)
205
- } else {
206
- spawnGate.reject(error instanceof Error ? error : new Error(safeFailureMessage(error)))
207
- }
208
- } finally {
209
- notifySettled()
210
- }
211
- })()
252
+ await spawned
253
+ } finally {
254
+ if (!evidenceTransferred) await evidence?.abort()
255
+ }
256
+ }
212
257
 
213
- await spawned
258
+ /** @param {Extract<AcceptedJob, {type: "evidence"}>} job @param {{botToken: string, telegramRequestTimeoutMs?: number}} config @param {IngressDependencies} dependencies */
259
+ export async function dispatchEvidenceRead(job, config, dependencies) {
260
+ if (!dependencies.evidenceStore) throw new Error("Evidence retrieval is unavailable")
261
+ const transport = (dependencies.createFetchTransport ?? createFetchTransport)(config.botToken, undefined, config.telegramRequestTimeoutMs)
262
+ const sender = (dependencies.createTelegramSender ?? createTelegramSender)(job.target, transport)
263
+ const result = await dependencies.evidenceStore.readTelegram({...job.target, senderId: job.senderId}, job.request)
264
+ const label = result.encoding === "base64"
265
+ ? `Evidence (${result.contentType}, base64, truncated):\n`
266
+ : `Evidence (${result.contentType}, redacted, truncated):\n`
267
+ const boundedData = telegramEvidenceText(result.data, Math.max(0, 3_000 - Buffer.byteLength(label)))
268
+ await sender.send({
269
+ text: result.encoding === "base64"
270
+ ? `Evidence (${result.contentType}, base64${boundedData.truncated ? ", truncated" : ""}):\n${boundedData.text}`
271
+ : `Evidence (${result.contentType}${result.redacted ? ", redacted" : ""}${result.truncated || boundedData.truncated ? ", truncated" : ""}):\n${boundedData.text}`
272
+ })
273
+ }
274
+
275
+ /** @param {string} value @param {number} maxBytes */
276
+ function telegramEvidenceText(value, maxBytes) {
277
+ let text = ""
278
+ let bytes = 0
279
+ for (const character of value) {
280
+ const width = Buffer.byteLength(character, "utf8")
281
+ if (bytes + width > maxBytes) return {text, truncated: true}
282
+ text += character
283
+ bytes += width
284
+ }
285
+ return {text, truncated: false}
214
286
  }
215
287
 
216
288
  /** @param {string} chatId @param {unknown} threadId @returns {TelegramTarget | null} */
@@ -1,7 +1,7 @@
1
1
  // @ts-check
2
2
 
3
3
  import {createConcurrencyLimiter} from "./concurrency.js"
4
- import {dispatchWorker, interpretUpdate, secretsEqual} from "./core.js"
4
+ import {dispatchEvidenceRead, dispatchWorker, interpretUpdate, secretsEqual} from "./core.js"
5
5
  import {createUpdateGuard, parseUpdateId} from "./update-guard.js"
6
6
  import {WorkspaceProviderMismatchError} from "../workspace-profile.js"
7
7
 
@@ -112,6 +112,22 @@ async function handleRequest(request, response, config, dependencies) {
112
112
  return
113
113
  }
114
114
 
115
+ if ("type" in job) {
116
+ try {
117
+ await dispatchEvidenceRead(job, {
118
+ botToken: config.botToken,
119
+ telegramRequestTimeoutMs: config.telegramRequestTimeoutMs
120
+ }, dependencies)
121
+ dependencies.updateGuard.complete(updateId)
122
+ send(response, 200)
123
+ } catch (error) {
124
+ dependencies.updateGuard.release(updateId)
125
+ dependencies.onOperationalError?.(error instanceof Error ? error.message : "Evidence retrieval failed")
126
+ send(response, 500)
127
+ }
128
+ return
129
+ }
130
+
115
131
  if (!dependencies.concurrency.tryAcquire()) {
116
132
  dependencies.updateGuard.release(updateId)
117
133
  send(response, 503)
@@ -2,6 +2,7 @@
2
2
 
3
3
  import {createServer} from "node:http"
4
4
  import {ActivityLog} from "./activity-log.js"
5
+ import {EvidenceStore} from "./evidence-store.js"
5
6
  import {buildProviderEnvironment, parseIngressConfig, resolveIngressEnvironment} from "./telegram-ingress/config.js"
6
7
  import {createWebhookHandler} from "./telegram-ingress/http.js"
7
8
 
@@ -15,6 +16,7 @@ import {createWebhookHandler} from "./telegram-ingress/http.js"
15
16
  * onWorkerFailure?: (message: string) => void,
16
17
  * providerEnvironment?: NodeJS.ProcessEnv,
17
18
  * activity?: Pick<ActivityLog, "recordWorkspace" | "recordStarted" | "recordSession" | "close">,
19
+ * evidenceStore?: EvidenceStore,
18
20
  * handlerDependencies?: import("./telegram-ingress/http.js").WebhookDependencies
19
21
  * }} [options]
20
22
  * @returns {Promise<{server: import("node:http").Server, config: import("./telegram-ingress/config.js").IngressConfig, handler: (request: import("node:http").IncomingMessage, response: import("node:http").ServerResponse) => void}>}
@@ -33,15 +35,29 @@ export async function startTelegramWebhook(options = {}) {
33
35
  options.providerEnvironment ?? environment
34
36
  )
35
37
  const activity = options.activity ?? options.handlerDependencies?.activity ?? new ActivityLog("/var/lib/threadwire/activity/threadwire.jsonl")
38
+ const evidenceStore = options.evidenceStore ?? options.handlerDependencies?.evidenceStore ?? await EvidenceStore.open({
39
+ root: config.evidenceRoot ?? "/var/lib/threadwire/evidence"
40
+ })
36
41
 
37
42
  const handler = createWebhookHandler(config, {
38
43
  ...options.handlerDependencies,
39
44
  activity,
45
+ evidenceStore,
40
46
  providerEnvironment,
41
47
  onOperationalError,
42
48
  onWorkerFailure
43
49
  })
44
50
  const server = createServerImpl(handler)
51
+ if (options.evidenceStore === undefined && options.handlerDependencies?.evidenceStore === undefined) {
52
+ const cleanupTimer = setInterval(() => {
53
+ void evidenceStore.cleanup().catch((error) => onOperationalError(error instanceof Error ? error.message : "Evidence cleanup failed"))
54
+ }, 60 * 60 * 1000)
55
+ cleanupTimer.unref()
56
+ server.once("close", () => {
57
+ clearInterval(cleanupTimer)
58
+ void evidenceStore.close()
59
+ })
60
+ }
45
61
 
46
62
  if (options.listen) {
47
63
  await options.listen(server, config.port, config.host)
package/src/types.js CHANGED
@@ -7,8 +7,8 @@
7
7
  * @typedef {{type: "diagnostic", level: "warning" | "error", summary: string}} DiagnosticEvent
8
8
  * @typedef {TextDeltaEvent | ToolEvent | LifecycleEvent | DiagnosticEvent} WorkerEvent
9
9
  * @typedef {{platform: "telegram", chatId: string, threadId: number | null}} TelegramTarget
10
- * @typedef {{text: string, format?: "javascript-code", label?: string, batchSeparator?: string}} TextNotice
11
- * @typedef {{activity: {key: string, phase: "started" | "finished"}, text: string, parseMode?: "HTML"}} ActivityNotice
10
+ * @typedef {{text: string, format?: "javascript-code", label?: string, batchSeparator?: string, metricClass?: string}} TextNotice
11
+ * @typedef {{activity: {key: string, phase: "started" | "finished"}, text: string, parseMode?: "HTML", metricClass?: string}} ActivityNotice
12
12
  * @typedef {TextNotice | ActivityNotice} RenderedNotice
13
13
  * @typedef {{text: string, parseMode?: "HTML"}} OutgoingMessage
14
14
  * @typedef {{messageId: number}} SentMessage