@jc_stack/ez-agents 0.1.0-beta.34 → 0.1.0-beta.37
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/.dockerignore +1 -0
- package/.env.example +5 -16
- package/AGENTS.md +17 -9
- package/CHANGELOG.md +96 -0
- package/Dockerfile +20 -11
- package/README.md +10 -13
- package/THIRD_PARTY_NOTICES.md +7 -4
- package/bin/ez +97 -0
- package/bin/ezenciel-agents-plugin-broker.mjs +16 -0
- package/bin/ezenciel-agents-provision-telegram.mjs +13 -0
- package/bin/ezenciel-agents.mjs +1 -1
- package/compose.yaml +41 -4
- package/docker/entrypoint.sh +1 -1
- package/docker/healthcheck.mjs +12 -4
- package/docker/run.ts +5 -6
- package/docker/smoke.mjs +28 -6
- package/docs/application-channel.md +32 -10
- package/docs/architecture/ai-selection.md +10 -2
- package/docs/architecture/authority-boundaries.md +2 -2
- package/docs/channel-backend.md +4 -3
- package/docs/docker-runtime.md +98 -11
- package/docs/local-qa.md +2 -2
- package/docs/plugin-guide.md +202 -0
- package/docs/plugins.md +48 -2
- package/docs/releasing.md +13 -0
- package/docs/responsive-channels.md +1 -1
- package/docs/scheduling.md +4 -4
- package/docs/setup.md +11 -5
- package/docs/upgrades.md +46 -23
- package/package.json +5 -5
- package/scripts/smoke-scheduler.ts +10 -3
- package/scripts/smoke.ts +14 -11
- package/src/agent-guidance.ts +6 -9
- package/src/agent-install.ts +28 -7
- package/src/ai-cli.ts +1 -1
- package/src/ai.ts +131 -5
- package/src/application-channel.ts +78 -7
- package/src/application-cli.ts +2 -2
- package/src/approval-cli.ts +14 -7
- package/src/approval.ts +27 -37
- package/src/channel-backend.ts +2 -3
- package/src/config.ts +12 -27
- package/src/control-state.ts +175 -8
- package/src/conversation-menu.ts +6 -17
- package/src/delivery-socket-client.d.mts +4 -0
- package/src/delivery-socket-client.mjs +104 -0
- package/src/delivery-socket.ts +354 -0
- package/src/execution-authority.ts +21 -5
- package/src/executor.ts +81 -24
- package/src/files.ts +38 -11
- package/src/host-executor.ts +33 -17
- package/src/inbox.ts +15 -49
- package/src/index.ts +98 -54
- package/src/install-tools.mjs +97 -22
- package/src/ledger-endpoint.d.mts +2 -0
- package/src/ledger-endpoint.mjs +17 -0
- package/src/menu.ts +8 -3
- package/src/message-history.ts +13 -18
- package/src/message.ts +35 -18
- package/src/plugin-broker.mjs +396 -0
- package/src/plugins/connection.mjs +2 -2
- package/src/plugins/manager.mjs +131 -23
- package/src/plugins/native-tasks.mjs +2 -2
- package/src/prompt-suffix.ts +12 -0
- package/src/react.ts +6 -4
- package/src/runs.ts +156 -243
- package/src/schedule-cli.ts +29 -10
- package/src/scheduled-tasks.ts +53 -10
- package/src/scheduler.ts +20 -7
- package/src/service.ts +3 -3
- package/src/setup.ts +1 -0
- package/src/software-status.ts +36 -4
- package/src/task-executor.ts +2 -2
- package/src/task-workspace.ts +4 -3
- package/src/tasks.ts +1 -1
- package/src/telegram-provisioning-cli.ts +22 -0
- package/src/telegram-provisioning.ts +214 -0
- package/src/updates/control.mjs +77 -7
- package/src/updates/runtime.mjs +28 -8
- package/src/updates/status.mjs +25 -3
- package/src/updates/supervisor.mjs +15 -16
- package/src/version.ts +20 -0
- package/src/workspace.ts +3 -9
- package/templates/failure-review.md +1 -1
- package/templates/maintainer-purpose.md +1 -1
- package/test/agent-guidance.test.ts +14 -80
- package/test/agent-install.test.ts +14 -0
- package/test/ai.test.ts +220 -1
- package/test/application-channel.test.ts +61 -13
- package/test/application-only.test.ts +14 -13
- package/test/channel-backend.test.ts +3 -7
- package/test/channel-delivery.test.ts +4 -1
- package/test/channel-owner.test.ts +6 -3
- package/test/codex-context.test.ts +2 -2
- package/test/config.test.ts +38 -43
- package/test/control-state.test.ts +83 -4
- package/test/conversation-menu.test.ts +2 -4
- package/test/delivery-transport.test.ts +72 -0
- package/test/docker-runtime.test.ts +27 -14
- package/test/engine-handoff.test.ts +7 -6
- package/test/event-sources.test.ts +2 -2
- package/test/execution-authority.test.ts +9 -9
- package/test/executor.test.ts +120 -12
- package/test/failure.test.ts +12 -5
- package/test/files.test.ts +2 -2
- package/test/helpers/ledger.ts +18 -0
- package/test/host-executor.test.ts +87 -3
- package/test/inbox.test.ts +8 -10
- package/test/install-config.test.ts +1 -0
- package/test/install-tools.test.mjs +74 -7
- package/test/intake-relay.test.ts +58 -11
- package/test/message-history.test.ts +39 -47
- package/test/native-tasks.test.ts +1 -1
- package/test/plugin-broker.test.mjs +172 -0
- package/test/plugin-manager.test.mjs +136 -2
- package/test/reaction.test.ts +4 -0
- package/test/relay.test.ts +5 -7
- package/test/repair-policy.test.ts +1 -4
- package/test/schedule-cli.test.ts +14 -9
- package/test/scheduled-tasks.test.ts +29 -6
- package/test/scheduler-host.test.ts +5 -5
- package/test/scheduler-relay.test.ts +15 -9
- package/test/security.test.ts +6 -3
- package/test/software-status.test.ts +21 -3
- package/test/telegram-provisioning.test.ts +160 -0
- package/test/updates.test.mjs +93 -13
- package/test/workspace.test.ts +4 -1
- package/bin/ezenciel-agents-watch.mjs +0 -8
- package/compose.workforce-watch.yaml +0 -33
- package/docker/recovery.ts +0 -11
- package/docs/pagerduty.md +0 -42
- package/docs/workforce-watch.md +0 -101
- package/src/pagerduty.ts +0 -109
- package/src/reply-context.ts +0 -43
- package/src/update-attention.ts +0 -18
- package/src/workforce-watch-cli.ts +0 -14
- package/src/workforce-watch.ts +0 -155
- package/templates/agent/AGENTS.md +0 -5
- package/templates/agent-guidance.md +0 -17
- package/test/pagerduty.test.ts +0 -104
- package/test/update-attention.test.ts +0 -20
- 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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
|
|
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
|
-
|
|
123
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
180
|
-
|
|
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
|
|
194
|
-
const
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|
-
|
|
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=(
|
|
226
|
-
|
|
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
|
|
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 = (
|
|
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
|
-
|
|
247
|
-
const
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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
|
|
250
|
+
return { failed, unknown }
|
|
259
251
|
}
|
|
260
252
|
|
|
261
|
-
private
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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 =
|
|
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
|
-
|
|
302
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
453
|
-
|
|
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
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
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
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
|
499
|
-
|
|
500
|
-
const
|
|
501
|
-
|
|
502
|
-
|
|
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
|
|
514
|
-
|
|
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
|
|
525
|
-
|
|
526
|
-
const
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
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
|
}
|