@jc_stack/ez-agents 0.1.0-beta.34 → 0.1.0-beta.36

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.
Files changed (137) hide show
  1. package/.dockerignore +1 -0
  2. package/.env.example +5 -16
  3. package/AGENTS.md +17 -9
  4. package/CHANGELOG.md +67 -0
  5. package/Dockerfile +7 -9
  6. package/README.md +10 -13
  7. package/bin/ez +97 -0
  8. package/bin/ezenciel-agents-plugin-broker.mjs +16 -0
  9. package/bin/ezenciel-agents-provision-telegram.mjs +13 -0
  10. package/bin/ezenciel-agents.mjs +1 -1
  11. package/compose.yaml +40 -4
  12. package/docker/entrypoint.sh +1 -1
  13. package/docker/healthcheck.mjs +12 -4
  14. package/docker/run.ts +5 -6
  15. package/docker/smoke.mjs +28 -6
  16. package/docs/application-channel.md +32 -10
  17. package/docs/architecture/authority-boundaries.md +2 -2
  18. package/docs/channel-backend.md +4 -3
  19. package/docs/docker-runtime.md +79 -11
  20. package/docs/local-qa.md +2 -2
  21. package/docs/plugin-guide.md +202 -0
  22. package/docs/plugins.md +48 -2
  23. package/docs/releasing.md +13 -0
  24. package/docs/responsive-channels.md +1 -1
  25. package/docs/scheduling.md +4 -4
  26. package/docs/setup.md +11 -5
  27. package/docs/upgrades.md +46 -23
  28. package/package.json +5 -5
  29. package/scripts/smoke-scheduler.ts +10 -3
  30. package/scripts/smoke.ts +14 -11
  31. package/src/agent-guidance.ts +6 -9
  32. package/src/agent-install.ts +28 -7
  33. package/src/application-channel.ts +78 -7
  34. package/src/application-cli.ts +2 -2
  35. package/src/approval-cli.ts +14 -7
  36. package/src/approval.ts +27 -37
  37. package/src/channel-backend.ts +2 -3
  38. package/src/config.ts +12 -27
  39. package/src/control-state.ts +175 -8
  40. package/src/conversation-menu.ts +6 -17
  41. package/src/delivery-socket-client.d.mts +4 -0
  42. package/src/delivery-socket-client.mjs +104 -0
  43. package/src/delivery-socket.ts +354 -0
  44. package/src/execution-authority.ts +21 -5
  45. package/src/executor.ts +20 -23
  46. package/src/files.ts +38 -11
  47. package/src/host-executor.ts +28 -14
  48. package/src/inbox.ts +15 -49
  49. package/src/index.ts +95 -52
  50. package/src/install-tools.mjs +97 -22
  51. package/src/ledger-endpoint.d.mts +2 -0
  52. package/src/ledger-endpoint.mjs +17 -0
  53. package/src/menu.ts +7 -2
  54. package/src/message-history.ts +13 -18
  55. package/src/message.ts +35 -18
  56. package/src/plugin-broker.mjs +396 -0
  57. package/src/plugins/connection.mjs +2 -2
  58. package/src/plugins/manager.mjs +132 -23
  59. package/src/plugins/native-tasks.mjs +2 -2
  60. package/src/prompt-suffix.ts +12 -0
  61. package/src/react.ts +6 -4
  62. package/src/runs.ts +156 -243
  63. package/src/schedule-cli.ts +29 -10
  64. package/src/scheduled-tasks.ts +53 -10
  65. package/src/scheduler.ts +20 -7
  66. package/src/service.ts +3 -3
  67. package/src/software-status.ts +36 -4
  68. package/src/task-executor.ts +2 -2
  69. package/src/task-workspace.ts +4 -3
  70. package/src/tasks.ts +1 -1
  71. package/src/telegram-provisioning-cli.ts +22 -0
  72. package/src/telegram-provisioning.ts +214 -0
  73. package/src/updates/control.mjs +77 -7
  74. package/src/updates/runtime.mjs +28 -8
  75. package/src/updates/status.mjs +25 -3
  76. package/src/updates/supervisor.mjs +15 -16
  77. package/src/version.ts +20 -0
  78. package/src/workspace.ts +3 -9
  79. package/templates/failure-review.md +1 -1
  80. package/templates/maintainer-purpose.md +1 -1
  81. package/test/agent-guidance.test.ts +14 -80
  82. package/test/agent-install.test.ts +14 -0
  83. package/test/ai.test.ts +36 -0
  84. package/test/application-channel.test.ts +61 -13
  85. package/test/application-only.test.ts +14 -13
  86. package/test/channel-backend.test.ts +3 -7
  87. package/test/channel-delivery.test.ts +4 -1
  88. package/test/channel-owner.test.ts +6 -3
  89. package/test/codex-context.test.ts +2 -2
  90. package/test/config.test.ts +38 -43
  91. package/test/control-state.test.ts +83 -4
  92. package/test/conversation-menu.test.ts +2 -4
  93. package/test/delivery-transport.test.ts +72 -0
  94. package/test/docker-runtime.test.ts +27 -14
  95. package/test/engine-handoff.test.ts +7 -6
  96. package/test/event-sources.test.ts +2 -2
  97. package/test/execution-authority.test.ts +9 -9
  98. package/test/executor.test.ts +10 -11
  99. package/test/failure.test.ts +12 -5
  100. package/test/files.test.ts +2 -2
  101. package/test/helpers/ledger.ts +18 -0
  102. package/test/host-executor.test.ts +74 -0
  103. package/test/inbox.test.ts +8 -10
  104. package/test/install-config.test.ts +1 -0
  105. package/test/install-tools.test.mjs +74 -7
  106. package/test/intake-relay.test.ts +58 -11
  107. package/test/message-history.test.ts +39 -47
  108. package/test/native-tasks.test.ts +1 -1
  109. package/test/plugin-broker.test.mjs +172 -0
  110. package/test/plugin-manager.test.mjs +119 -2
  111. package/test/reaction.test.ts +4 -0
  112. package/test/relay.test.ts +5 -7
  113. package/test/repair-policy.test.ts +1 -4
  114. package/test/schedule-cli.test.ts +14 -9
  115. package/test/scheduled-tasks.test.ts +29 -6
  116. package/test/scheduler-host.test.ts +5 -5
  117. package/test/scheduler-relay.test.ts +15 -9
  118. package/test/security.test.ts +6 -3
  119. package/test/software-status.test.ts +21 -3
  120. package/test/telegram-provisioning.test.ts +160 -0
  121. package/test/updates.test.mjs +93 -13
  122. package/test/workspace.test.ts +4 -1
  123. package/bin/ezenciel-agents-watch.mjs +0 -8
  124. package/compose.workforce-watch.yaml +0 -33
  125. package/docker/recovery.ts +0 -11
  126. package/docs/pagerduty.md +0 -42
  127. package/docs/workforce-watch.md +0 -101
  128. package/src/pagerduty.ts +0 -109
  129. package/src/reply-context.ts +0 -43
  130. package/src/update-attention.ts +0 -18
  131. package/src/workforce-watch-cli.ts +0 -14
  132. package/src/workforce-watch.ts +0 -155
  133. package/templates/agent/AGENTS.md +0 -5
  134. package/templates/agent-guidance.md +0 -17
  135. package/test/pagerduty.test.ts +0 -104
  136. package/test/update-attention.test.ts +0 -20
  137. package/test/workforce-watch.test.ts +0 -180
package/src/runs.ts CHANGED
@@ -1,7 +1,5 @@
1
1
  import { validApplicationOrigin, type ApplicationOrigin } from './application-origin.js'
2
2
  import { type FailureEvidence, type FailureReview, validFailureReview, failureStamp, failureEvidence } from './failure.js'
3
- import { mkdir, readdir, readFile, rename, rm, writeFile } from 'node:fs/promises'
4
- import path from 'node:path'
5
3
  import { randomBytes } from 'node:crypto'
6
4
  import { validOrigin, type ExternalOrigin } from './event-sources.js'
7
5
  import { normalizeReactionEmoji } from './reaction.js'
@@ -44,6 +42,7 @@ export type RunRecord = {
44
42
  external?: ExternalOrigin
45
43
  externalReleased?: true
46
44
  application?: ApplicationOrigin
45
+ telegramApplication?: ApplicationOrigin
47
46
  delivery?: { bindingId: string; scope: string }
48
47
  }
49
48
 
@@ -66,65 +65,65 @@ export type OutboxItem = {
66
65
  createdAt: string
67
66
  }
68
67
 
69
- const isRun = (value: unknown): value is RunRecord => {
70
- if (!value || typeof value !== 'object') return false
71
- const candidate = value as Partial<RunRecord>
72
- return (
73
- ((candidate.version === 1 && candidate.taskId === undefined) || (candidate.version === 2 && typeof candidate.taskId === 'string' && /^task_[a-f0-9]{32}$/.test(candidate.taskId))) &&
74
- typeof candidate.id === 'string' &&
75
- /^[a-zA-Z0-9_-]+$/.test(candidate.id) &&
76
- ((Number.isSafeInteger(candidate.chatId) && Number.isSafeInteger(candidate.telegramUserId)) ||
77
- (typeof candidate.ownerId === 'string' && /^[a-zA-Z0-9_:.-]{1,200}$/.test(candidate.ownerId) &&
78
- typeof candidate.ownerEpoch === 'string' && (/^[a-f0-9-]{36}$/.test(candidate.ownerEpoch) || Number.isFinite(Date.parse(candidate.ownerEpoch))) &&
79
- candidate.chatId === undefined && candidate.telegramUserId === undefined)) &&
80
- Array.isArray(candidate.texts) &&
81
- candidate.texts.every((text) => typeof text === 'string') &&
82
- ['queued', 'running', 'completed', 'failed', 'cancelled'].includes(candidate.status ?? '') &&
83
- typeof candidate.createdAt === 'string' &&
84
- Number.isFinite(Date.parse(candidate.createdAt)) &&
85
- (candidate.failureReview === undefined || validFailureReview(candidate.failureReview)) &&
86
- (candidate.failure === undefined || (typeof candidate.failure.error === 'string' && candidate.failure.error.length <= 4096 && typeof candidate.failure.relayVersion === 'string')) &&
87
- (candidate.replyOnly === undefined || typeof candidate.replyOnly === 'boolean') &&
88
- (candidate.backendSubmitted === undefined || typeof candidate.backendSubmitted === 'boolean') &&
89
- (candidate.pid === undefined || (Number.isSafeInteger(candidate.pid) && candidate.pid > 0)) &&
90
- (candidate.scheduled === undefined || validScheduledOrigin(candidate.scheduled)) &&
91
- (candidate.blockReason === undefined || ['owner-mismatch', 'external-execution-unavailable'].includes(candidate.blockReason)) &&
92
- (candidate.external === undefined || validOrigin(candidate.external)) &&
93
- (candidate.externalReleased === undefined || candidate.external !== undefined && candidate.externalReleased === true) &&
94
- (candidate.id.startsWith('r_app_') === (candidate.application !== undefined)) &&
95
- (candidate.application === undefined || (validApplicationOrigin(candidate.application) && candidate.external === undefined && candidate.scheduled === undefined && candidate.taskId === undefined && !candidate.replyOnly)) &&
96
- (candidate.delivery === undefined || (!!candidate.scheduled && validApplicationOrigin({...candidate.delivery, requestId: candidate.id}) && candidate.application === undefined)) &&
97
- (candidate.execution === undefined || isExecutionChoice(candidate.execution))
98
- )
68
+ export type StoredOutboxItem = OutboxItem & { state?: 'queued' | 'sending' | 'sent' | 'failed'; deliveryError?: string; deliveryUnknown?: boolean; receipt?: unknown }
69
+
70
+ // Memory scan of settled outbox items for explicit history reads.
71
+ export const sentOutbox = (controlDir: string): StoredOutboxItem[] =>
72
+ [...outboxFor(controlDir).values()].filter(item => item.state === 'sent')
73
+
74
+ // Stateless pipe: the relay owns the ledger in process memory. Cross-process
75
+ // producers (engine children, plugin children, host executor) reach it through
76
+ // the delivery socket (see delivery-socket.ts), never through control/ files.
77
+ // A restart drops in-flight runs and queued items by design; in-flight sends
78
+ // report unknown rather than claiming success. Long-lived relays cap retained
79
+ // terminal records so memory stays bounded.
80
+ const MAX_TERMINAL_RUNS = 2000
81
+ const MAX_TERMINAL_OUTBOX = 2000
82
+
83
+ const runsByControl = new Map<string, Map<string, RunRecord>>()
84
+ const outboxByControl = new Map<string, Map<string, StoredOutboxItem>>()
85
+ const deliveryWaiters = new Map<string, Map<string, Set<() => void>>>()
86
+
87
+ const runsFor = (controlDir: string): Map<string, RunRecord> => {
88
+ let map = runsByControl.get(controlDir)
89
+ if (!map) { map = new Map(); runsByControl.set(controlDir, map) }
90
+ return map
99
91
  }
100
92
 
101
- export const newRunId = (): string => `r_${Date.now().toString(36)}_${randomBytes(3).toString('hex')}`
93
+ const outboxFor = (controlDir: string): Map<string, StoredOutboxItem> => {
94
+ let map = outboxByControl.get(controlDir)
95
+ if (!map) { map = new Map(); outboxByControl.set(controlDir, map) }
96
+ return map
97
+ }
102
98
 
103
- export class RunStore {
104
- private readonly changes = new Map<string, Promise<unknown>>()
105
- private readonly runsDir: string
106
- private readonly outboxDir: string
99
+ const waitersFor = (controlDir: string): Map<string, Set<() => void>> => {
100
+ let map = deliveryWaiters.get(controlDir)
101
+ if (!map) { map = new Map(); deliveryWaiters.set(controlDir, map) }
102
+ return map
103
+ }
107
104
 
108
- constructor(private readonly controlDir: string) {
109
- this.runsDir = path.join(controlDir, 'runs')
110
- this.outboxDir = path.join(controlDir, 'outbox')
111
- }
105
+ const notifyDelivery = (controlDir: string, id: string): void => {
106
+ const waiters = deliveryWaiters.get(controlDir)?.get(id)
107
+ if (!waiters) return
108
+ deliveryWaiters.get(controlDir)!.delete(id)
109
+ for (const resolve of [...waiters]) resolve()
110
+ }
112
111
 
113
- private async ensure(): Promise<void> {
114
- await mkdir(this.runsDir, { recursive: true, mode: 0o700 })
115
- await mkdir(this.outboxDir, { recursive: true, mode: 0o700 })
112
+ const pruneTerminals = (controlDir: string): void => {
113
+ const store = runsFor(controlDir)
114
+ const terminal = [...store.values()].filter(run => ['completed', 'failed', 'cancelled'].includes(run.status)).sort((a, b) => a.createdAt.localeCompare(b.createdAt))
115
+ for (const run of terminal.slice(0, Math.max(0, terminal.length - MAX_TERMINAL_RUNS))) store.delete(run.id)
116
+ const outbox = outboxFor(controlDir)
117
+ const settled = [...outbox.values()].filter(item => item.state === 'sent' || item.state === 'failed').sort((a, b) => a.createdAt.localeCompare(b.createdAt))
118
+ for (const item of settled.slice(0, Math.max(0, settled.length - MAX_TERMINAL_OUTBOX))) {
119
+ if (!deliveryWaiters.get(controlDir)?.has(item.id)) outbox.delete(item.id)
116
120
  }
121
+ }
117
122
 
118
- private runPath(id: string): string {
119
- return path.join(this.runsDir, `${assertId(id)}.json`)
120
- }
123
+ export const newRunId = (): string => `r_${Date.now().toString(36)}_${randomBytes(3).toString('hex')}`
121
124
 
122
- private async writeRun(run: RunRecord): Promise<void> {
123
- await this.ensure()
124
- const temporary = `${this.runPath(run.id)}.${process.pid}.tmp`
125
- await writeFile(temporary, `${JSON.stringify(run, null, 2)}\n`, { mode: 0o600 })
126
- await rename(temporary, this.runPath(run.id))
127
- }
125
+ export class RunStore {
126
+ constructor(private readonly controlDir: string) {}
128
127
 
129
128
  async create(input: {
130
129
  id?: string
@@ -143,8 +142,10 @@ export class RunStore {
143
142
  application?: ApplicationOrigin
144
143
  delivery?: { bindingId: string; scope: string }
145
144
  }): Promise<RunRecord> {
145
+ const store = runsFor(this.controlDir)
146
146
  if (input.id) {
147
- const existing = await this.get(input.id)
147
+ assertId(input.id)
148
+ const existing = store.get(input.id)
148
149
  if (existing) {
149
150
  if (existing.ownerId !== input.ownerId || existing.ownerEpoch !== input.ownerEpoch || existing.chatId !== input.chatId || existing.telegramUserId !== input.telegramUserId)
150
151
  throw new Error('Run ownership mismatch')
@@ -171,65 +172,60 @@ export class RunStore {
171
172
  status: 'queued',
172
173
  createdAt: new Date().toISOString(),
173
174
  }
174
- await this.writeRun(run)
175
+ store.set(run.id, run)
176
+ pruneTerminals(this.controlDir)
175
177
  return run
176
178
  }
177
179
 
178
180
  async get(id: string): Promise<RunRecord | null> {
179
- try {
180
- const parsed: unknown = JSON.parse(await readFile(this.runPath(id), 'utf8'))
181
- if (!isRun(parsed) || parsed.id !== id) throw new Error('Run record has an unsupported shape')
182
- return parsed
183
- } catch (error: unknown) {
184
- if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null
185
- throw error
186
- }
181
+ assertId(id)
182
+ return runsFor(this.controlDir).get(id) ?? null
187
183
  }
188
184
 
189
185
  async patch(
190
186
  id: string,
191
187
  change: Partial<Pick<RunRecord, 'status' | 'startedAt' | 'endedAt' | 'pid' | 'nativeSessionId' | 'interrupted' | 'blockReason' | 'backendSubmitted' | 'replyOnly' | 'exitCode' | 'failureReason' | 'failure' | 'failureReview' | 'externalReleased'>>,
192
188
  ): Promise<RunRecord> {
193
- const prior = this.changes.get(id) || Promise.resolve()
194
- const work = prior.catch(() => {}).then(async () => {
195
- const run = await this.get(id)
196
- if (!run) throw new Error(`Unknown run ${id}`)
197
- if (change.failureReview && (!validFailureReview(change.failureReview) || run.status !== 'failed' || change.failureReview.failedAt !== failureStamp(run))) throw new Error('Failure changed or review is invalid; inspect the run again')
198
- const failure = change.status === 'failed' && !change.failure ? await failureEvidence(this.controlDir, change.failureReason || (change.interrupted ? 'Execution interrupted by relay restart; inspect effects before recovery' : 'No error detail recorded')) : undefined
199
- const next = { ...run, ...(failure ? {failure} : {}), ...change }
200
- await this.writeRun(next)
201
- return next
202
- })
203
- this.changes.set(id, work)
204
- try { return await work }
205
- finally { if (this.changes.get(id) === work) this.changes.delete(id) }
189
+ const store = runsFor(this.controlDir)
190
+ const run = store.get(id)
191
+ if (!run) throw new Error(`Unknown run ${id}`)
192
+ if (change.failureReview && (!validFailureReview(change.failureReview) || run.status !== 'failed' || change.failureReview.failedAt !== failureStamp(run))) throw new Error('Failure changed or review is invalid; inspect the run again')
193
+ const failure = change.status === 'failed' && !change.failure
194
+ ? await failureEvidence(this.controlDir, change.failureReason || (change.interrupted ? 'Execution interrupted by relay restart; inspect effects before recovery' : 'No error detail recorded'))
195
+ : undefined
196
+ const next = { ...run, ...(failure ? { failure } : {}), ...change }
197
+ store.set(id, next)
198
+ pruneTerminals(this.controlDir)
199
+ return next
200
+ }
201
+
202
+ async attachTelegramApplication(id: string, application: ApplicationOrigin): Promise<RunRecord> {
203
+ const store = runsFor(this.controlDir)
204
+ const run = store.get(id)
205
+ if (!run) throw new Error(`Unknown run ${id}`)
206
+ if (run.application || run.telegramApplication || run.taskId || run.external || run.scheduled || run.replyOnly || run.telegramUserId === undefined)
207
+ throw new Error('Run is not an unadmitted Telegram run')
208
+ if (run.status !== 'queued' && run.status !== 'running')
209
+ throw new Error('Run already finished; application admission conflicts with its terminal state')
210
+ if (!validApplicationOrigin(application)) throw new Error('Invalid application context')
211
+ const next = { ...run, telegramApplication: application }
212
+ store.set(id, next)
213
+ return next
206
214
  }
207
215
 
208
216
  async list(): Promise<RunRecord[]> {
209
- await this.ensure()
210
- const names = await readdir(this.runsDir)
211
- const runs: RunRecord[] = []
212
- for (const name of names) {
213
- if (!name.endsWith('.json') || name.includes('.tmp')) continue
214
- try {
215
- const run = await this.get(name.slice(0, -5))
216
- if (run) runs.push(run)
217
- } catch {
218
- console.error('Unreadable run record', name)
219
- }
220
- }
221
- return runs.sort((left, right) => left.createdAt.localeCompare(right.createdAt))
217
+ return [...runsFor(this.controlDir).values()].sort((left, right) => left.createdAt.localeCompare(right.createdAt))
222
218
  }
223
219
 
224
220
  async pruneTaskHistory(taskId:string,keep=100):Promise<void>{
225
- const terminal=(await this.list()).filter(run=>run.taskId===taskId&&['completed','failed','cancelled'].includes(run.status)&&(run.external===undefined||run.externalReleased===true)).sort((a,b)=>a.createdAt.localeCompare(b.createdAt))
226
- for(const run of terminal.slice(0,Math.max(0,terminal.length-keep)))await rm(this.runPath(run.id),{force:true})
221
+ const terminal=[...runsFor(this.controlDir).values()].filter(run=>run.taskId===taskId&&['completed','failed','cancelled'].includes(run.status)&&(run.external===undefined||run.externalReleased===true)).sort((a,b)=>a.createdAt.localeCompare(b.createdAt))
222
+ const store = runsFor(this.controlDir)
223
+ for(const run of terminal.slice(0,Math.max(0,terminal.length-keep)))store.delete(run.id)
227
224
  }
228
225
 
229
226
  async running(background?: boolean): Promise<RunRecord | undefined> {
230
- const runs = await this.list()
231
227
  let first: RunRecord | undefined
232
- for (const run of runs) {
228
+ for (const run of [...runsFor(this.controlDir).values()].sort((a,b)=>a.createdAt.localeCompare(b.createdAt))) {
233
229
  if (run.status === 'running' && (background === undefined || Boolean(run.scheduled) === background)) {
234
230
  first ??= run
235
231
  }
@@ -238,37 +234,30 @@ export class RunStore {
238
234
  }
239
235
 
240
236
  async nextQueued(background?: boolean): Promise<RunRecord | undefined> {
241
- const queued = (await this.list()).filter((run) => run.status === 'queued' && (background === undefined || Boolean(run.scheduled) === background))
237
+ const queued = [...runsFor(this.controlDir).values()].sort((a,b)=>a.createdAt.localeCompare(b.createdAt)).filter((run) => run.status === 'queued' && (background === undefined || Boolean(run.scheduled) === background))
242
238
  return queued.find(run => !run.taskId) ?? queued[0]
243
239
  }
244
240
 
245
241
  async deliveryStatus(): Promise<{ failed: number; unknown: number }> {
246
- await this.ensure()
247
- const names = await readdir(this.outboxDir)
248
- const result = { failed: 0, unknown: names.filter((name) => name.endsWith('.sending.json')).length }
249
- for (const name of names.filter((name) => name.endsWith('.failed.json'))) {
250
- try {
251
- const item = JSON.parse(await readFile(path.join(this.outboxDir, name), 'utf8'))
252
- if (item.deliveryUnknown) result.unknown++
253
- else result.failed++
254
- } catch {
255
- result.unknown++
256
- }
242
+ let failed = 0, unknown = 0
243
+ for (const item of outboxFor(this.controlDir).values()) {
244
+ // A claimed but unsettled send is awaiting confirmation, exactly like a
245
+ // failed send with an unknown outcome.
246
+ if (item.state === 'sending') unknown++
247
+ else if (item.state === 'failed' && item.deliveryUnknown) unknown++
248
+ else if (item.state === 'failed') failed++
257
249
  }
258
- return result
250
+ return { failed, unknown }
259
251
  }
260
252
 
261
- private async writeOutboxItem(item: OutboxItem): Promise<OutboxItem> {
262
- await this.ensure()
263
- const file = path.join(this.outboxDir, `${item.id}.json`)
264
- const temporary = `${file}.${process.pid}.tmp`
265
- await writeFile(temporary, `${JSON.stringify(item, null, 2)}\n`, { mode: 0o600 })
266
- await rename(temporary, file)
253
+ private writeOutboxItem(item: OutboxItem): OutboxItem {
254
+ const stored: StoredOutboxItem = { ...item, state: 'queued' }
255
+ outboxFor(this.controlDir).set(item.id, stored)
256
+ notifyDelivery(this.controlDir, item.id)
267
257
  return item
268
258
  }
269
259
 
270
260
  async enqueueOwnerDelivery(context: DeliveryContext, payload: {type:'message'|'document'|'voice';text?:string;documentPath?:string;voiceText?:string;replyToMessageId?:number}): Promise<OutboxItem> {
271
- await this.ensure()
272
261
  const authorized=authorizeDeliveryContext(context,await currentDeliveryOwner(this.controlDir))
273
262
  const item:OutboxItem={...payload,id:`delivery_${Date.now().toString(36)}_${randomBytes(8).toString('hex')}`,deliveryContext:authorized,chatId:authorized.owner.telegramChatId,createdAt:new Date().toISOString()}
274
263
  return this.writeOutboxItem(item)
@@ -278,14 +267,12 @@ export class RunStore {
278
267
  assertId(id)
279
268
  const owner=await currentDeliveryOwner(this.controlDir)
280
269
  authorizeDeliveryContext(context,owner)
281
- for(const [suffix,status] of [['sent.json','delivered'],['failed.json','failed'],['sending.json','sending'],['json','queued']] as const) {
282
- let item
283
- try {item=JSON.parse(await readFile(path.join(this.outboxDir,`${id}.${suffix}`),'utf8'))}catch(error){if((error as NodeJS.ErrnoException).code==='ENOENT')continue;throw error}
284
- authorizeDeliveryContext(item.deliveryContext,owner)
285
- if(item.runId||item.chatId!==owner!.telegramChatId)throw new Error('Outbox ownership mismatch')
286
- return {outbox_id:id,status:item.deliveryUnknown?'unknown':status,...(item.receipt?{receipt:item.receipt}:{}),...(item.deliveryError?{error:item.deliveryError}:{})}
287
- }
288
- throw new Error('Unknown owner delivery receipt')
270
+ const item = outboxFor(this.controlDir).get(id)
271
+ if (!item) throw new Error('Unknown owner delivery receipt')
272
+ authorizeDeliveryContext(item.deliveryContext,owner)
273
+ if(item.runId||item.chatId!==owner!.telegramChatId)throw new Error('Outbox ownership mismatch')
274
+ const status = item.state === 'sent' ? 'delivered' : item.state === 'failed' ? 'failed' : item.state === 'sending' ? 'sending' : 'queued'
275
+ return {outbox_id:id,status:item.deliveryUnknown?'unknown':status,...(item.receipt?{receipt:item.receipt}:{}),...(item.deliveryError?{error:item.deliveryError}:{})}
289
276
  }
290
277
 
291
278
  async enqueueMessage(
@@ -293,15 +280,13 @@ export class RunStore {
293
280
  text: string,
294
281
  options?: { replyToMessageId?: number; id?: string },
295
282
  ): Promise<OutboxItem> {
296
- const run = await this.get(runId)
283
+ const run = runsFor(this.controlDir).get(runId)
297
284
  if (!run) throw new Error(`Unknown run ${runId}`)
298
285
  if (run.status !== 'running' && run.status !== 'queued') throw new Error(`Run ${runId} cannot send`)
299
286
  if (options?.id) {
300
287
  assertId(options.id)
301
- for (const suffix of ['json', 'sending.json', 'sent.json', 'failed.json']) {
302
- try { return JSON.parse(await readFile(path.join(this.outboxDir, `${options.id}.${suffix}`), 'utf8')) }
303
- catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error }
304
- }
288
+ const existing = outboxFor(this.controlDir).get(options.id)
289
+ if (existing) return existing
305
290
  }
306
291
  const item: OutboxItem = {
307
292
  id: options?.id ?? `${runId}_${Date.now().toString(36)}_${randomBytes(2).toString('hex')}`,
@@ -320,7 +305,7 @@ export class RunStore {
320
305
  documentPath: string,
321
306
  options?: { caption?: string; replyToMessageId?: number },
322
307
  ): Promise<OutboxItem> {
323
- const run = await this.get(runId)
308
+ const run = runsFor(this.controlDir).get(runId)
324
309
  if (!run) throw new Error(`Unknown run ${runId}`)
325
310
  if (run.status !== 'running' && run.status !== 'queued') throw new Error(`Run ${runId} cannot send`)
326
311
  const item: OutboxItem = {
@@ -341,7 +326,7 @@ export class RunStore {
341
326
  voiceText: string,
342
327
  options?: { replyToMessageId?: number },
343
328
  ): Promise<OutboxItem> {
344
- const run = await this.get(runId)
329
+ const run = runsFor(this.controlDir).get(runId)
345
330
  if (!run) throw new Error(`Unknown run ${runId}`)
346
331
  if (run.status !== 'running' && run.status !== 'queued') throw new Error(`Run ${runId} cannot send`)
347
332
  const item: OutboxItem = {
@@ -362,7 +347,7 @@ export class RunStore {
362
347
  actionId: string,
363
348
  options?: { replyToMessageId?: number },
364
349
  ): Promise<OutboxItem> {
365
- const run = await this.get(runId)
350
+ const run = runsFor(this.controlDir).get(runId)
366
351
  if (!run) throw new Error(`Unknown run ${runId}`)
367
352
  if (run.status !== 'running' && run.status !== 'queued')
368
353
  throw new Error(`Run ${runId} cannot request approval`)
@@ -380,7 +365,7 @@ export class RunStore {
380
365
  }
381
366
 
382
367
  async enqueueReaction(runId: string, rawEmoji: string): Promise<OutboxItem> {
383
- const run = await this.get(runId)
368
+ const run = runsFor(this.controlDir).get(runId)
384
369
  if (!run) throw new Error(`Unknown run ${runId}`)
385
370
  if (run.status !== 'running' && run.status !== 'queued') throw new Error(`Run ${runId} cannot react`)
386
371
  if (!run.messageId) throw new Error(`Run ${runId} has no messageId to react to`)
@@ -401,151 +386,79 @@ export class RunStore {
401
386
  }
402
387
 
403
388
  async pendingOutbox(): Promise<OutboxItem[]> {
404
- await this.ensure()
405
- const names = (await readdir(this.outboxDir)).filter(
406
- (name) =>
407
- name.endsWith('.json') &&
408
- !name.endsWith('.sent.json') &&
409
- !name.endsWith('.failed.json') &&
410
- !name.endsWith('.sending.json') &&
411
- !name.includes('.tmp'),
412
- )
413
- const items: OutboxItem[] = []
414
- for (const name of names) {
415
- try {
416
- const parsed: unknown = JSON.parse(await readFile(path.join(this.outboxDir, name), 'utf8'))
417
- if (
418
- parsed &&
419
- typeof parsed === 'object' &&
420
- ('text' in parsed ||
421
- 'emoji' in parsed ||
422
- 'documentPath' in parsed ||
423
- 'voiceText' in parsed ||
424
- 'approvalPrompt' in parsed ||
425
- 'type' in parsed)
426
- ) {
427
- items.push(parsed as OutboxItem)
428
- }
429
- } catch (error: unknown) {
430
- if ((error as NodeJS.ErrnoException).code !== 'ENOENT')
431
- console.error('Unreadable outbox record', name)
432
- }
433
- }
434
- return items.sort((left, right) => left.createdAt.localeCompare(right.createdAt))
389
+ return [...outboxFor(this.controlDir).values()].filter(item => item.state === 'queued').sort((left, right) => left.createdAt.localeCompare(right.createdAt))
435
390
  }
436
391
 
437
392
  async applicationMessages(runId: string): Promise<{ id: string; text: string }[]> {
438
393
  assertId(runId)
439
- await this.ensure()
440
- const messages = new Map<string, OutboxItem>()
441
- for (const name of await readdir(this.outboxDir)) {
442
- if (!name.startsWith(`${runId}_`) || !name.endsWith('.json') || name.includes('.tmp') || name.endsWith('.failed.json')) continue
443
- try {
444
- const item = JSON.parse(await readFile(path.join(this.outboxDir, name), 'utf8')) as OutboxItem
445
- if (item.runId === runId && (!item.type || item.type === 'message') && typeof item.text === 'string') messages.set(item.id, item)
446
- } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error }
447
- }
448
- return [...messages.values()].sort((a, b) => a.createdAt.localeCompare(b.createdAt)).map(item => ({ id: item.id, text: item.text! }))
394
+ return [...outboxFor(this.controlDir).values()].filter(item => item.runId === runId && (!item.type || item.type === 'message') && typeof item.text === 'string' && item.state !== 'failed').sort((a, b) => a.createdAt.localeCompare(b.createdAt)).map(item => ({ id: item.id, text: item.text! }))
449
395
  }
450
396
 
451
397
  async applicationApprovals(runId:string):Promise<{id:string;prompt:string;state:string}[]> {
452
- assertId(runId);await this.ensure();const result=[] as {id:string;prompt:string;state:string}[]
453
- for(const name of await readdir(this.outboxDir))if(name.startsWith(`${runId}_`)&&name.endsWith('.json')&&!name.includes('.tmp')&&!name.endsWith('.failed.json'))try{
454
- const item=JSON.parse(await readFile(path.join(this.outboxDir,name),'utf8')) as OutboxItem
455
- if(item.runId===runId&&item.type==='approval'&&item.approvalActionId&&item.approvalPrompt)result.push({id:item.approvalActionId,prompt:item.approvalPrompt,state:name.endsWith('.sent.json')?'delivered':'pending'})
456
- }catch(error){if((error as NodeJS.ErrnoException).code!=='ENOENT')throw error}
457
- return result
398
+ assertId(runId)
399
+ return [...outboxFor(this.controlDir).values()].filter(item=>item.runId===runId&&item.type==='approval'&&item.approvalActionId&&item.approvalPrompt&&item.state!=='failed').map(item=>({id:item.approvalActionId!,prompt:item.approvalPrompt!,state:item.state==='sent'?'delivered':'pending'}))
458
400
  }
459
401
 
460
402
  async claimOutbox(id: string): Promise<boolean> {
461
403
  assertId(id)
462
- const from = path.join(this.outboxDir, `${id}.json`)
463
- const to = path.join(this.outboxDir, `${id}.sending.json`)
464
- try {
465
- await rename(from, to)
466
- return true
467
- } catch (error: unknown) {
468
- if ((error as NodeJS.ErrnoException).code === 'ENOENT') return false
469
- throw error
470
- }
404
+ const item = outboxFor(this.controlDir).get(id)
405
+ if (!item || item.state !== 'queued') return false
406
+ outboxFor(this.controlDir).set(id, { ...item, state: 'sending' })
407
+ return true
471
408
  }
472
409
 
410
+ // Resolves when the relay pump settles the item. Same contract as the
411
+ // former file poll: delivered receipt, failed error, or unknown on timeout.
473
412
  async waitForDelivery(id: string, timeoutMs = 120_000): Promise<unknown> {
474
413
  assertId(id)
475
414
  const deadline = Date.now() + timeoutMs
476
- while (Date.now() < deadline) {
477
- for (const status of ['sent', 'failed'] as const) {
478
- let item
479
- try {
480
- item = JSON.parse(await readFile(path.join(this.outboxDir, `${id}.${status}.json`), 'utf8'))
481
- } catch (error) {
482
- if ((error as NodeJS.ErrnoException).code === 'ENOENT') continue
483
- throw error
484
- }
485
- if (status === 'failed' && item.deliveryUnknown)
415
+ for (;;) {
416
+ const item = outboxFor(this.controlDir).get(id)
417
+ if (item && (item.state === 'sent' || item.state === 'failed')) {
418
+ if (item.state === 'failed' && item.deliveryUnknown)
486
419
  throw new Error(`Delivery outcome unknown; inspect before retrying: ${item.deliveryError || id}`)
487
- if (status === 'failed')
420
+ if (item.state === 'failed')
488
421
  throw new Error(item.deliveryError || 'Delivery failed; inspect the local outbox')
489
422
  return item.receipt ?? { delivered: true }
490
423
  }
491
- await new Promise((resolve) => setTimeout(resolve, 100))
424
+ const remaining = deadline - Date.now()
425
+ if (remaining <= 0) throw new Error(`Delivery outcome unknown for ${id}; inspect the outbox before retrying`)
426
+ await new Promise<void>((resolve) => {
427
+ const timer = setTimeout(() => {
428
+ waitersFor(this.controlDir).get(id)?.delete(wrapped)
429
+ resolve()
430
+ }, remaining)
431
+ const wrapped = () => { clearTimeout(timer); resolve() }
432
+ let set = waitersFor(this.controlDir).get(id)
433
+ if (!set) { set = new Set(); waitersFor(this.controlDir).set(id, set) }
434
+ set.add(wrapped)
435
+ })
492
436
  }
493
- throw new Error(`Delivery outcome unknown for ${id}; inspect the outbox before retrying`)
494
437
  }
495
438
 
496
439
  async failOutbox(id: string, reason = 'Delivery failed', deliveryUnknown = false): Promise<void> {
497
440
  assertId(id)
498
- const source = path.join(this.outboxDir, `${id}.sending.json`)
499
- const item = JSON.parse(await readFile(source, 'utf8'))
500
- const temporary = `${source}.${process.pid}.tmp`
501
- await writeFile(temporary, JSON.stringify({ ...item, deliveryError: reason, deliveryUnknown }), {
502
- mode: 0o600,
503
- })
504
- await rename(temporary, source)
505
- await rename(
506
- path.join(this.outboxDir, `${id}.sending.json`),
507
- path.join(this.outboxDir, `${id}.failed.json`),
508
- )
441
+ const item = outboxFor(this.controlDir).get(id)
442
+ if (!item) return
443
+ const next = { ...item, state: 'failed' as const, deliveryError: reason, deliveryUnknown }
444
+ outboxFor(this.controlDir).set(id, next)
445
+ notifyDelivery(this.controlDir, id)
446
+ pruneTerminals(this.controlDir)
509
447
  }
510
448
 
511
449
  async unclaimOutbox(id: string): Promise<void> {
512
450
  assertId(id)
513
- const from = path.join(this.outboxDir, `${id}.sending.json`)
514
- const to = path.join(this.outboxDir, `${id}.json`)
515
- try {
516
- await rename(from, to)
517
- } catch (error: unknown) {
518
- if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
519
- }
451
+ const item = outboxFor(this.controlDir).get(id)
452
+ if (item && item.state === 'sending') outboxFor(this.controlDir).set(id, { ...item, state: 'queued' })
520
453
  }
521
454
 
522
455
  async markOutboxSent(id: string, messageIds: number[] = []): Promise<void> {
523
456
  assertId(id)
524
- const sendingFile = path.join(this.outboxDir, `${id}.sending.json`)
525
- const normalFile = path.join(this.outboxDir, `${id}.json`)
526
- const target = path.join(this.outboxDir, `${id}.sent.json`)
527
- if (messageIds.length) {
528
- const record = JSON.parse(await readFile(sendingFile, 'utf8'))
529
- const temporary = `${sendingFile}.${process.pid}.tmp`
530
- await writeFile(
531
- temporary,
532
- JSON.stringify({ ...record, receipt: { messageIds, deliveredAt: new Date().toISOString() } }),
533
- { mode: 0o600 },
534
- )
535
- await rename(temporary, sendingFile)
536
- }
537
- try {
538
- await rename(sendingFile, target)
539
- } catch (err: unknown) {
540
- if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
541
- try {
542
- await rename(normalFile, target)
543
- } catch (e: unknown) {
544
- if ((e as NodeJS.ErrnoException).code !== 'ENOENT') throw e
545
- }
546
- } else {
547
- throw err
548
- }
549
- }
457
+ const item = outboxFor(this.controlDir).get(id)
458
+ if (!item) return
459
+ const next = { ...item, state: 'sent' as const, ...(messageIds.length ? { receipt: { messageIds, deliveredAt: new Date().toISOString() } } : {}) }
460
+ outboxFor(this.controlDir).set(id, next)
461
+ notifyDelivery(this.controlDir, id)
462
+ pruneTerminals(this.controlDir)
550
463
  }
551
464
  }