@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.
- package/.dockerignore +1 -0
- package/.env.example +5 -16
- package/AGENTS.md +17 -9
- package/CHANGELOG.md +67 -0
- package/Dockerfile +7 -9
- package/README.md +10 -13
- 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 +40 -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/authority-boundaries.md +2 -2
- package/docs/channel-backend.md +4 -3
- package/docs/docker-runtime.md +79 -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/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 +20 -23
- package/src/files.ts +38 -11
- package/src/host-executor.ts +28 -14
- package/src/inbox.ts +15 -49
- package/src/index.ts +95 -52
- 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 +7 -2
- 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 +132 -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/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 +36 -0
- 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 +10 -11
- 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 +74 -0
- 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 +119 -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/schedule-cli.ts
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { parallelReplyHistory, ownerConversationContext } from './reply-context.js'
|
|
2
1
|
import { needsFailureReview, failureStamp, redactFailure } from './failure.js'
|
|
3
2
|
import { parseArgs } from 'node:util'
|
|
4
3
|
import { readFile } from 'node:fs/promises'
|
|
@@ -6,7 +5,8 @@ import { randomUUID } from 'node:crypto'
|
|
|
6
5
|
import { loadControlConfig } from './config.js'
|
|
7
6
|
import { ControlStore, sameOwner } from './control-state.js'
|
|
8
7
|
import { ApplicationBindings } from './application-channel.js'
|
|
9
|
-
import {
|
|
8
|
+
import type { RunRecord } from './runs.js'
|
|
9
|
+
import { callDeliverySocket, socketPathFor } from './delivery-socket.js'
|
|
10
10
|
import { initialPreset, isPreset } from './ai.js'
|
|
11
11
|
import { executionOverrides } from './model-policy.js'
|
|
12
12
|
import { holdsSchedule, Scheduler } from './scheduler.js'
|
|
@@ -27,27 +27,46 @@ async function main() {
|
|
|
27
27
|
--now | --at ISO_WITH_OFFSET | --every-seconds N | --cron 'MIN HOUR DAY MONTH WEEKDAY' --timezone IANA
|
|
28
28
|
[--cli EXECUTOR] [--model MODEL] [--effort <native-effort>]
|
|
29
29
|
[--start ISO_WITH_OFFSET] [--until ISO_WITH_OFFSET] [--when unreviewed-failures]
|
|
30
|
-
Context reads the current run
|
|
30
|
+
Context reads the current run only.
|
|
31
31
|
Failures default to unreviewed owner runs. Review records a diagnosis; it never changes execution status or retries work.
|
|
32
32
|
A conditional review schedule consumes no model run when there are no unreviewed failures.
|
|
33
33
|
New tasks inherit the selected engine settings. Omitted model/effort uses native defaults; edit preserves existing settings unless overridden.
|
|
34
|
-
Creates a
|
|
35
|
-
Use --now to
|
|
34
|
+
Creates a scheduled task. Instructions are text, never shell commands.
|
|
35
|
+
Use --now to run once. Run completion is not delivery proof.
|
|
36
36
|
Edit replaces the full schedule. Pause/remove affect future work; cancel stops a particular run.
|
|
37
37
|
Cron uses numeric five-field syntax, lists/ranges/steps, and traditional day/weekday OR semantics.`);return}
|
|
38
38
|
const config=loadControlConfig(), control=new ControlStore(config.controlDir,config.pairingTtlMs)
|
|
39
39
|
const owner=(await control.status()).owner
|
|
40
40
|
if(!owner)throw new Error('Pair an owner before scheduling')
|
|
41
|
-
const
|
|
41
|
+
const socketPath=socketPathFor(config.controlDir)
|
|
42
|
+
// Run records live in the relay memory ledger, reached through the delivery
|
|
43
|
+
// socket. Schedule files stay on disk, so list/show/create/edit/pause/
|
|
44
|
+
// resume/remove keep working with the relay stopped; run-inspecting actions
|
|
45
|
+
// (context/failures/run/review/runs/cancel) require the running relay.
|
|
46
|
+
const socketUnavailable = (error: unknown): boolean =>
|
|
47
|
+
error instanceof Error && /Delivery relay unavailable/.test(error.message)
|
|
48
|
+
const runs={
|
|
49
|
+
get: (runId: string): Promise<RunRecord | null> =>
|
|
50
|
+
callDeliverySocket(socketPath, { op: 'get', payload: { runId } }).then(run => run as RunRecord).catch(error => {
|
|
51
|
+
if (error instanceof Error && /Unknown run/.test(error.message)) return null
|
|
52
|
+
throw error
|
|
53
|
+
}),
|
|
54
|
+
list: (): Promise<RunRecord[]> => callDeliverySocket(socketPath, { op: 'list' }).then(result => result as RunRecord[]),
|
|
55
|
+
listBestEffort: (): Promise<RunRecord[]> => callDeliverySocket(socketPath, { op: 'list' })
|
|
56
|
+
.then(result => result as RunRecord[]).catch(error => { if (socketUnavailable(error)) return []; throw error }),
|
|
57
|
+
patch: (runId: string, change: unknown): Promise<RunRecord> =>
|
|
58
|
+
callDeliverySocket(socketPath, { op: 'patch', payload: { runId, change: change as Record<string, unknown> } }).then(run => run as RunRecord),
|
|
59
|
+
}
|
|
60
|
+
const scheduler=new Scheduler(config.controlDir)
|
|
42
61
|
const caller=process.env.EZ_RUN_ID ? await runs.get(process.env.EZ_RUN_ID) : null
|
|
43
62
|
if (caller?.application || caller?.delivery) await new ApplicationBindings(config.controlDir).authorize(caller)
|
|
44
63
|
if(process.env.EZ_RUN_ID && (!caller || caller.status!=='running' || caller.external || caller.taskId || caller.replyOnly ||
|
|
45
64
|
!ownsRun(owner, caller) ||
|
|
46
65
|
(caller.scheduled && caller.scheduled.pairedAt!==owner.pairedAt)))throw new Error('Scheduling requires an active owner-authorized run')
|
|
47
66
|
const owned=(s:{owner:typeof owner})=>sameOwner(s.owner,owner)
|
|
48
|
-
const ownsFailureRun=(r:
|
|
67
|
+
const ownsFailureRun=(r:RunRecord | null)=>r && ownsRun(owner,r) && (!r.scheduled || r.scheduled.pairedAt===owner.pairedAt)
|
|
49
68
|
const show=async(s:Awaited<ReturnType<Scheduler['get']>>)=>{
|
|
50
|
-
const held=(await runs.
|
|
69
|
+
const held=(await runs.listBestEffort()).filter(r=>holdsSchedule(s,r))
|
|
51
70
|
const interruptedRunIds=held.filter(r=>r.interrupted).map(r=>r.id)
|
|
52
71
|
const failedReviewRunIds=held.filter(r=>!r.interrupted).map(r=>r.id)
|
|
53
72
|
const next=s.enabled && !held.length ? nextOccurrence(s.trigger,Date.now()) : null
|
|
@@ -59,7 +78,7 @@ Cron uses numeric five-field syntax, lists/ranges/steps, and traditional day/wee
|
|
|
59
78
|
if(!caller)throw new Error('Context requires an active owner run')
|
|
60
79
|
const origin=caller.scheduled?.originRunId ? await runs.get(caller.scheduled.originRunId) : null
|
|
61
80
|
if(origin && !ownsFailureRun(origin))throw new Error('Source context is outside this owner binding')
|
|
62
|
-
result=
|
|
81
|
+
result={run:caller,...(origin ? {origin} : {})}
|
|
63
82
|
}else if(action==='failures'){
|
|
64
83
|
const limit=Number(v.limit || 20)
|
|
65
84
|
if(!Number.isSafeInteger(limit) || limit<1 || limit>100)throw new Error('Limit must be 1..100')
|
|
@@ -102,7 +121,7 @@ Cron uses numeric five-field syntax, lists/ranges/steps, and traditional day/wee
|
|
|
102
121
|
if(action==='cancel'){
|
|
103
122
|
const run=await runs.get(id)
|
|
104
123
|
if(!run?.scheduled || run.scheduled.pairedAt!==owner.pairedAt || !ownsRun(owner,run))throw new Error('Unknown background run')
|
|
105
|
-
await scheduler.cancel(id);result={cancelRequested:id}
|
|
124
|
+
await scheduler.cancel(id, run);result={cancelRequested:id}
|
|
106
125
|
}else{
|
|
107
126
|
const s=await scheduler.get(id)
|
|
108
127
|
if(!owned(s))throw new Error('Schedule ownership mismatch')
|
package/src/scheduled-tasks.ts
CHANGED
|
@@ -16,17 +16,60 @@ const displayedPreset = (schedule: Schedule) => {
|
|
|
16
16
|
catch { return schedule.execution.preset }
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
-
|
|
20
|
-
|
|
19
|
+
const weekdays = ['Sun','Mon','Tue','Wed','Thu','Fri','Sat']
|
|
20
|
+
const months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']
|
|
21
|
+
const time = (value: number | string) => {
|
|
22
|
+
const date = new Date(value)
|
|
23
|
+
if (!Number.isFinite(date.getTime())) return '—'
|
|
24
|
+
return `${weekdays[date.getUTCDay()]}, ${months[date.getUTCMonth()]} ${date.getUTCDate()} · ${String(date.getUTCHours()).padStart(2,'0')}:${String(date.getUTCMinutes()).padStart(2,'0')} UTC`
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const preview = (schedule: Schedule) => {
|
|
28
|
+
const sentence = schedule.text.trim().replace(/\s+/gu,' ').split(/(?<=[.!?])\s/u)[0]
|
|
29
|
+
const chars = Array.from(sentence)
|
|
30
|
+
return chars.length > 140 ? chars.slice(0,139).join('')+'…' : sentence
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const currentState = (schedule: ActiveSchedule) =>
|
|
34
|
+
schedule.runState === 'running' ? '▶ Running' : schedule.runState === 'queued' ? '◌ Queued' : '● Active'
|
|
35
|
+
|
|
36
|
+
const lastRun = (schedule: ActiveSchedule) => {
|
|
37
|
+
if (!schedule.lastRun) return '— Not run yet'
|
|
38
|
+
const status = schedule.lastRun.status === 'completed' ? '✓ Completed' : schedule.lastRun.status === 'failed' ? '✕ Failed' : '⊘ Cancelled'
|
|
39
|
+
return `${status} · ${time(schedule.lastRun.at)}${schedule.lastRun.currentRevision ? '' : ' · previous version'}`
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export const ownedScheduledTasks = (schedules: ActiveSchedule[], owner: Owner) =>
|
|
43
|
+
schedules.filter((schedule) => ownsSchedule(owner, schedule))
|
|
21
44
|
.sort((a, b) => a.name.localeCompare(b.name))
|
|
22
|
-
|
|
23
|
-
|
|
45
|
+
|
|
46
|
+
export const scheduledTasksText = (schedules: ActiveSchedule[], owner: Owner) => {
|
|
47
|
+
const owned = ownedScheduledTasks(schedules, owner)
|
|
48
|
+
if (!owned.length) return '📅 Scheduled tasks\n\nNo active scheduled tasks.'
|
|
49
|
+
return [`📅 Scheduled tasks · ${owned.length} active`, 'All times UTC. Use /tasks 1 for task details.', ...owned.map((schedule, index) => {
|
|
24
50
|
const preset = displayedPreset(schedule)
|
|
25
|
-
|
|
26
|
-
const chars = Array.from(sentence)
|
|
27
|
-
const preview = chars.length > 140 ? chars.slice(0,139).join('')+'…' : sentence
|
|
28
|
-
const next = schedule.nextAt === null ? '' : `Next: ${new Date(schedule.nextAt).toISOString().replace('T',' ').replace('.000Z',' UTC')}`
|
|
29
|
-
const timing = [schedule.runState === 'running' ? 'Running' : schedule.runState === 'queued' ? 'Queued' : '',next].filter(Boolean).join(' · ')
|
|
30
|
-
return `• ${schedule.name}\n ${presetLabel(preset)}\n ${timing}\n ${preview}`
|
|
51
|
+
return `${index + 1} · ${schedule.name}\n ${currentState(schedule)}\n Next · ${schedule.nextAt === null ? '—' : time(schedule.nextAt)}\n Last · ${lastRun(schedule)}\n ${presetLabel(preset)}\n ${preview(schedule)}`
|
|
31
52
|
})].join('\n\n')
|
|
32
53
|
}
|
|
54
|
+
|
|
55
|
+
export const scheduledTaskDetailText = (schedule: ActiveSchedule, number: number, total?: number) => {
|
|
56
|
+
const preset = displayedPreset(schedule)
|
|
57
|
+
const instructions = schedule.text.trim()
|
|
58
|
+
const capped = instructions.length > 2000 ? instructions.slice(0, 2000) + '… (truncated; full text lives in the workspace)' : instructions
|
|
59
|
+
return [
|
|
60
|
+
`📅 ${number}${total === undefined ? '' : ` of ${total}`} · ${schedule.name}`,
|
|
61
|
+
currentState(schedule),
|
|
62
|
+
'',
|
|
63
|
+
'Next run',
|
|
64
|
+
schedule.nextAt === null ? '—' : time(schedule.nextAt),
|
|
65
|
+
'',
|
|
66
|
+
'Last run',
|
|
67
|
+
lastRun(schedule),
|
|
68
|
+
'',
|
|
69
|
+
'Engine',
|
|
70
|
+
presetLabel(preset),
|
|
71
|
+
'',
|
|
72
|
+
'Instructions',
|
|
73
|
+
capped,
|
|
74
|
+
].join('\n')
|
|
75
|
+
}
|
package/src/scheduler.ts
CHANGED
|
@@ -18,7 +18,12 @@ export type Schedule = {
|
|
|
18
18
|
owner: Owner; execution: ExecutionChoice
|
|
19
19
|
delivery?: { bindingId: string; scope: string }
|
|
20
20
|
}
|
|
21
|
-
export type
|
|
21
|
+
export type ScheduledRunSummary = {
|
|
22
|
+
status: 'completed' | 'failed' | 'cancelled'
|
|
23
|
+
at: string
|
|
24
|
+
currentRevision: boolean
|
|
25
|
+
}
|
|
26
|
+
export type ActiveSchedule = Schedule & { nextAt: number | null; runState?: 'queued' | 'running'; lastRun?: ScheduledRunSummary }
|
|
22
27
|
export type ScheduledOrigin = { id: string; revision: string; dueAt: string; pairedAt: string; originRunId?: string }
|
|
23
28
|
export const validScheduledOrigin = (v: unknown): v is ScheduledOrigin => {
|
|
24
29
|
const s = v as ScheduledOrigin
|
|
@@ -85,11 +90,18 @@ export class Scheduler {
|
|
|
85
90
|
for (const s of await this.listReadOnly()) {
|
|
86
91
|
if (!s.enabled) continue
|
|
87
92
|
const current = runs.filter(r => r.scheduled?.id === s.id && r.scheduled.revision === s.revision && ownsRun(s.owner,r))
|
|
93
|
+
const history = runs.filter(r => r.scheduled?.id === s.id && r.scheduled.pairedAt === s.owner.pairedAt && ownsRun(s.owner,r))
|
|
94
|
+
.filter((r): r is RunRecord & { status: ScheduledRunSummary['status'] } =>
|
|
95
|
+
r.status === 'completed' || r.status === 'failed' || r.status === 'cancelled')
|
|
96
|
+
.sort((a,b) => (b.endedAt ?? b.startedAt ?? b.createdAt).localeCompare(a.endedAt ?? a.startedAt ?? a.createdAt))
|
|
97
|
+
const last = history[0]
|
|
88
98
|
const runState = current.some(r => r.status === 'running') ? 'running' : current.some(r => r.status === 'queued') ? 'queued' : undefined
|
|
89
99
|
if (!runState && current.some(r => holdsSchedule(s,r))) continue
|
|
90
100
|
try {
|
|
91
101
|
const nextAt = await this.pendingOccurrence(s)
|
|
92
|
-
if (runState || nextAt !== null) active.push({...s,nextAt,runState
|
|
102
|
+
if (runState || nextAt !== null) active.push({...s,nextAt,runState,...(last ? {
|
|
103
|
+
lastRun: {status:last.status,at:last.endedAt ?? last.startedAt ?? last.createdAt,currentRevision:last.scheduled!.revision===s.revision},
|
|
104
|
+
} : {})})
|
|
93
105
|
}
|
|
94
106
|
catch { console.error('Unreadable schedule cursor',s.id) }
|
|
95
107
|
}
|
|
@@ -121,10 +133,10 @@ export class Scheduler {
|
|
|
121
133
|
(!!s.delivery || ((s.owner.telegramLinkedAt ?? s.owner.pairedAt) === (owner.telegramLinkedAt ?? owner.pairedAt) && s.owner.telegramChatId === owner.telegramChatId))
|
|
122
134
|
} catch { return false }
|
|
123
135
|
}
|
|
124
|
-
async cancel(runId: string) {
|
|
136
|
+
async cancel(runId: string, run?: RunRecord | null) {
|
|
125
137
|
assertId(runId); await this.ensure()
|
|
126
|
-
const
|
|
127
|
-
if (!
|
|
138
|
+
const record = run ?? await new RunStore(this.controlDir).get(runId)
|
|
139
|
+
if (!record?.scheduled) throw new Error('Unknown background run')
|
|
128
140
|
await atomic(join(this.dir,runId+'.cancel'),{})
|
|
129
141
|
}
|
|
130
142
|
async cancelled(runId: string): Promise<boolean> {
|
|
@@ -160,11 +172,12 @@ export class Scheduler {
|
|
|
160
172
|
if (s.when === 'unreviewed-failures' && !(await runs.list()).some(r => needsFailureReview(r) && ownsRun(owner, r) && (!r.scheduled || r.scheduled.pairedAt === owner.pairedAt))) {
|
|
161
173
|
await atomic(cursor,{next:future}); continue
|
|
162
174
|
}
|
|
175
|
+
const dueAt = new Date(next).toISOString()
|
|
163
176
|
await runs.create({id:scheduledRunId(s,next),
|
|
164
177
|
ownerId:ownerId(owner),ownerEpoch:ownerEpoch(owner),
|
|
165
178
|
...(s.delivery ? {delivery:s.delivery} : {chatId:s.owner.telegramChatId,telegramUserId:s.owner.telegramUserId,telegramEpoch:s.owner.telegramLinkedAt ?? s.owner.pairedAt}),
|
|
166
|
-
texts:[s.text],execution:s.execution,
|
|
167
|
-
scheduled:{id:s.id,revision:s.revision,dueAt
|
|
179
|
+
texts:[`[schedule ${s.id} due ${dueAt}]`, s.text],execution:s.execution,
|
|
180
|
+
scheduled:{id:s.id,revision:s.revision,dueAt,pairedAt:s.owner.pairedAt,...(s.originRunId?{originRunId:s.originRunId}:{})}})
|
|
168
181
|
// A restart between run creation and this cursor write sees the same occurrence ID.
|
|
169
182
|
await atomic(cursor,{next:future})
|
|
170
183
|
} catch { console.error('Schedule dispatch failed',s.id) }
|
package/src/service.ts
CHANGED
|
@@ -8,21 +8,21 @@ import { parseEnv } from 'node:util'
|
|
|
8
8
|
const run = promisify(execFile)
|
|
9
9
|
const packageRoot = fileURLToPath(new URL('../', import.meta.url))
|
|
10
10
|
|
|
11
|
-
export const serviceDefinition = (directory: string) => {
|
|
11
|
+
export const serviceDefinition = (directory: string, includePluginBroker = false) => {
|
|
12
12
|
const root = path.resolve(directory)
|
|
13
13
|
if (/[\r\n\0]/.test(root)) throw new Error('Service paths must be single-line values.')
|
|
14
14
|
return { command: 'docker', args: ['compose', '--project-directory', packageRoot,
|
|
15
|
-
'--env-file', path.join(root, 'docker.env'), 'up', '-d', '--wait', 'relay'] }
|
|
15
|
+
'--env-file', path.join(root, 'docker.env'), 'up', '-d', '--wait', ...(includePluginBroker ? ['relay', 'plugin-broker'] : ['relay'])] }
|
|
16
16
|
}
|
|
17
17
|
|
|
18
18
|
// Docker owns lifecycle. No host-service fallback or provider onboarding here.
|
|
19
19
|
export const installService = async (directory: string) => {
|
|
20
|
-
const definition = serviceDefinition(directory)
|
|
21
20
|
const file = path.join(path.resolve(directory), 'docker.env')
|
|
22
21
|
if (!(await lstat(file)).isFile()) throw new Error('docker.env must be a regular file')
|
|
23
22
|
const values = parseEnv(await readFile(file, 'utf8'))
|
|
24
23
|
if (!values.COMPOSE_PROJECT_NAME || !values.EZ_RELAY_ENV_FILE)
|
|
25
24
|
throw new Error('docker.env must bind COMPOSE_PROJECT_NAME and EZ_RELAY_ENV_FILE; see docs/docker-runtime.md')
|
|
25
|
+
const definition = serviceDefinition(directory, values.EZ_EXECUTOR_TRANSPORT === 'local')
|
|
26
26
|
await run(definition.command, definition.args)
|
|
27
27
|
return { service: 'relay', runtime: 'docker', project: values.COMPOSE_PROJECT_NAME, active: true }
|
|
28
28
|
}
|
package/src/software-status.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { readFile } from 'node:fs/promises'
|
|
2
|
+
import { arch as relayArch, platform as relayPlatform } from 'node:os'
|
|
2
3
|
import path from 'node:path'
|
|
3
|
-
import {
|
|
4
|
+
import { isolationTransport } from './isolation.js'
|
|
5
|
+
import { relayVersionLabel } from './version.js'
|
|
4
6
|
|
|
5
7
|
export const installedPluginVersions = async (toolsHome?: string) => {
|
|
6
8
|
if (!toolsHome) return null
|
|
@@ -10,14 +12,44 @@ export const installedPluginVersions = async (toolsHome?: string) => {
|
|
|
10
12
|
} catch { return null }
|
|
11
13
|
}
|
|
12
14
|
|
|
13
|
-
export const softwareStatus = async (controlDir: string): Promise<string[]> => {
|
|
14
|
-
const lines = [`Relay: running ·
|
|
15
|
+
export const softwareStatus = async (controlDir: string, isolation?: string): Promise<string[]> => {
|
|
16
|
+
const lines = [`Relay: running · ${relayVersionLabel()}`]
|
|
17
|
+
let host: { version?: unknown; plugins?: unknown; platform?: unknown; arch?: unknown } | null = null
|
|
15
18
|
try {
|
|
16
19
|
const h = JSON.parse(await readFile(path.join(controlDir, 'host-executor/heartbeat.json'), 'utf8'))
|
|
17
20
|
if (!Number.isFinite(h.at) || h.at > Date.now() + 5000 || Date.now() - h.at >= 15000) throw Error('Stale host')
|
|
21
|
+
host = h
|
|
22
|
+
lines.push(`Env: ${envLabel(host, isolation)}`)
|
|
18
23
|
lines.push(`Host transport: running · ${typeof h.version === 'string' ? `v${h.version}` : 'version unknown'}`)
|
|
19
24
|
if (!Array.isArray(h.plugins)) lines.push('Plugins: unknown')
|
|
20
25
|
else lines.push(`Plugins: ${h.plugins.length ? h.plugins.map((p: {id: string; version: string}) => `${p.id} ${p.version}`).join(', ') : 'none installed'}`)
|
|
21
|
-
} catch {
|
|
26
|
+
} catch {
|
|
27
|
+
lines.push(`Env: ${envLabel(host, isolation)}`)
|
|
28
|
+
if (isolation === 'isolated') {
|
|
29
|
+
lines.push('Host transport: n/a · isolated execution')
|
|
30
|
+
lines.push((await brokerPlugins(controlDir)) ?? 'Plugins: isolated broker · versions via `ez tools list`')
|
|
31
|
+
} else lines.push('Host transport: unavailable', 'Plugins: unknown')
|
|
32
|
+
}
|
|
22
33
|
return lines
|
|
23
34
|
}
|
|
35
|
+
|
|
36
|
+
const envLabel = (host: { platform?: unknown; arch?: unknown } | null, isolation?: string): string => {
|
|
37
|
+
const rawPlatform = typeof host?.platform === 'string' && host.platform ? host.platform : relayPlatform()
|
|
38
|
+
const rawArch = typeof host?.arch === 'string' && host.arch ? host.arch : relayArch()
|
|
39
|
+
const where = rawPlatform === 'darwin' ? `mac (${rawPlatform}/${rawArch})` : `${rawPlatform}/${rawArch}`
|
|
40
|
+
const mode = isolation ?? 'unknown'
|
|
41
|
+
const transport = isolation === 'isolated' || isolation === 'host-capable' ? isolationTransport(isolation) : 'unknown'
|
|
42
|
+
return `${where} · ${mode} · ${transport}`
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const brokerPlugins = async (controlDir: string): Promise<string | null> => {
|
|
46
|
+
try {
|
|
47
|
+
const status = JSON.parse(await readFile(path.join(controlDir, 'plugin-broker-plugins.json'), 'utf8')) as {
|
|
48
|
+
plugins?: { id: unknown; version: unknown }[]
|
|
49
|
+
}
|
|
50
|
+
if (!Array.isArray(status.plugins)) return null
|
|
51
|
+
const entries = status.plugins.filter(p => typeof p?.id === 'string' && typeof p?.version === 'string')
|
|
52
|
+
if (entries.length !== status.plugins.length) return null
|
|
53
|
+
return `Plugins: ${entries.length ? entries.map(p => `${p.id} ${p.version}`).join(', ') : 'none installed'}`
|
|
54
|
+
} catch { return null }
|
|
55
|
+
}
|
package/src/task-executor.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { join } from 'node:path'
|
|
|
5
5
|
import { fileURLToPath } from 'node:url'
|
|
6
6
|
import { spawn, execFile } from 'node:child_process'
|
|
7
7
|
import { promisify } from 'node:util'
|
|
8
|
-
import {
|
|
8
|
+
import { readRun } from './delivery-socket.js'
|
|
9
9
|
import { Tasks } from './tasks.js'
|
|
10
10
|
import { executorEnvironment, terminateJob, type ExecutorOptions } from './executor.js'
|
|
11
11
|
|
|
@@ -38,7 +38,7 @@ export function taskArguments(directory: string, broker: string[], prompt: strin
|
|
|
38
38
|
'-'] // Literal input travels on stdin, including slash commands and leading options.
|
|
39
39
|
}
|
|
40
40
|
export async function startTaskExecutor(options: ExecutorOptions) {
|
|
41
|
-
const run = await
|
|
41
|
+
const run = await readRun(options.controlDir, options.runId)
|
|
42
42
|
if (!run || run.status !== 'running') throw new Error('No active task run')
|
|
43
43
|
const task = await new Tasks(options.controlDir).authorize(run, false)
|
|
44
44
|
const capabilityNames = (task.capabilities ?? []).map(item => `capability_${item.id}`)
|
package/src/task-workspace.ts
CHANGED
|
@@ -2,9 +2,10 @@ import { mkdir, lstat } from 'node:fs/promises'
|
|
|
2
2
|
import { join } from 'node:path'
|
|
3
3
|
import { assertId } from './identity.js'
|
|
4
4
|
|
|
5
|
-
// Fixed, agent-bound path
|
|
6
|
-
|
|
7
|
-
|
|
5
|
+
// Fixed, agent-bound path under the control directory, never the workspace
|
|
6
|
+
// mind folder and never a caller-provided cwd. No shared mutable task files.
|
|
7
|
+
export async function taskWorkspace(baseDir: string, id: string): Promise<string> {
|
|
8
|
+
const dirs=[join(baseDir,'work'),join(baseDir,'work','tasks'),join(baseDir,'work','tasks',assertId(id))]
|
|
8
9
|
for(const dir of dirs){
|
|
9
10
|
await mkdir(dir,{recursive:true,mode:0o700})
|
|
10
11
|
if(!(await lstat(dir)).isDirectory())throw new Error('Task workspace must not be a symlink')
|
package/src/tasks.ts
CHANGED
|
@@ -161,7 +161,7 @@ export class Tasks {
|
|
|
161
161
|
async ownerCall(runId: string, command: string, args: Record<string, unknown>) {
|
|
162
162
|
return this.serial(async () => {
|
|
163
163
|
const run = await requireOwnerExecution(this.controlDir, runId)
|
|
164
|
-
if (run.scheduled || run.id.startsWith('
|
|
164
|
+
if (run.scheduled || run.id.startsWith('r_schedule_')) throw new Error('Task changes require a current owner message')
|
|
165
165
|
if (command === 'list') return this.list()
|
|
166
166
|
if (command === 'revoke') {
|
|
167
167
|
const task = await this.get(String(args.taskId))
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { fileURLToPath } from 'node:url'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { provisionTelegramBot } from './telegram-provisioning.js'
|
|
4
|
+
|
|
5
|
+
const help = 'Usage: ezenciel-agents-provision-telegram --config PRIVATE_CONFIG_FILE < BOTFATHER_TOKEN\nConfigures one existing Ez deployment using its installed relay image and version from the deployment\'s docker.env; the config never selects an image. The token is accepted only through stdin, written only to that deployment\'s private relay secret, and is never printed.'
|
|
6
|
+
|
|
7
|
+
export const runTelegramProvisioningCli = async (args = process.argv.slice(2), input = process.stdin): Promise<void> => {
|
|
8
|
+
if (args.includes('--help') || args.includes('-h')) { console.log(help); return }
|
|
9
|
+
if (args.length !== 2 || args[0] !== '--config' || !args[1]) throw new Error(help)
|
|
10
|
+
if ((input as NodeJS.ReadStream).isTTY) throw new Error('Supply the BotFather token through stdin, not a command argument.')
|
|
11
|
+
let token = ''
|
|
12
|
+
for await (const chunk of input) {
|
|
13
|
+
token += chunk.toString()
|
|
14
|
+
if (token.length > 512) { (input as NodeJS.ReadStream).destroy?.(); throw new Error('Token input is too long.') }
|
|
15
|
+
}
|
|
16
|
+
await provisionTelegramBot(args[1], token.trim())
|
|
17
|
+
console.log(JSON.stringify({ configured: true }))
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
21
|
+
void runTelegramProvisioningCli().catch(error => { console.error((error as Error).message); process.exitCode = 1 })
|
|
22
|
+
}
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { lstat, mkdir, readFile, realpath, rename, rm, writeFile } from 'node:fs/promises'
|
|
2
|
+
import { randomUUID } from 'node:crypto'
|
|
3
|
+
import { spawn } from 'node:child_process'
|
|
4
|
+
import { parseEnv } from 'node:util'
|
|
5
|
+
import path from 'node:path'
|
|
6
|
+
|
|
7
|
+
export type TelegramProvisioningConfig = {
|
|
8
|
+
version: 1
|
|
9
|
+
composeFile: string
|
|
10
|
+
projectDirectory: string
|
|
11
|
+
projectName: string
|
|
12
|
+
service: 'relay'
|
|
13
|
+
relayEnvFile: string
|
|
14
|
+
overrideFile: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
type ComposeRunner = (args: string[], environment: NodeJS.ProcessEnv) => Promise<void>
|
|
18
|
+
|
|
19
|
+
const botToken = (value: string) => value.length <= 512 && /^\d{5,}:[A-Za-z0-9_-]{20,}$/.test(value)
|
|
20
|
+
const projectName = (value: string) => /^[a-z0-9][a-z0-9_-]{0,62}$/.test(value)
|
|
21
|
+
const redactToken = (value: string) => value.replace(/\d{5,}:[A-Za-z0-9_-]{20,}/g, '[redacted]')
|
|
22
|
+
|
|
23
|
+
const containedBy = (parent: string, child: string) => {
|
|
24
|
+
const relative = path.relative(parent, child)
|
|
25
|
+
return Boolean(relative) && !relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const resolveInside = async (file: string): Promise<string> => {
|
|
29
|
+
const tail: string[] = []
|
|
30
|
+
let current = file
|
|
31
|
+
for (let depth = 0; depth < 100; depth++) {
|
|
32
|
+
try {
|
|
33
|
+
return path.join(await realpath(current), ...tail)
|
|
34
|
+
} catch (error) {
|
|
35
|
+
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
|
36
|
+
tail.unshift(path.basename(current))
|
|
37
|
+
const parent = path.dirname(current)
|
|
38
|
+
if (parent === current) throw error
|
|
39
|
+
current = parent
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
throw new Error('Provisioning path is too deep')
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const absoluteRegularFile = async (file: string, ownerOnly = false): Promise<void> => {
|
|
46
|
+
if (!path.isAbsolute(file) || /[\r\n\0]/.test(file)) throw new Error('Provisioning paths must be absolute single-line paths')
|
|
47
|
+
const info = await lstat(file)
|
|
48
|
+
if (!info.isFile() || info.isSymbolicLink()) throw new Error('Provisioning files must be regular files')
|
|
49
|
+
if (ownerOnly && (info.mode & 0o077)) throw new Error('Provisioning configuration must be owner-only')
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const privateWrite = async (file: string, content: string) => {
|
|
53
|
+
await mkdir(path.dirname(file), { recursive: true, mode: 0o700 })
|
|
54
|
+
const temporary = `${file}.${randomUUID()}.tmp`
|
|
55
|
+
try {
|
|
56
|
+
await writeFile(temporary, content, { mode: 0o600, flag: 'wx' })
|
|
57
|
+
await rename(temporary, file)
|
|
58
|
+
} finally {
|
|
59
|
+
await rm(temporary, { force: true })
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const previousFile = async (file: string): Promise<string | null> => {
|
|
64
|
+
try {
|
|
65
|
+
await absoluteRegularFile(file)
|
|
66
|
+
return await readFile(file, 'utf8')
|
|
67
|
+
} catch (error) {
|
|
68
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null
|
|
69
|
+
throw error
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const restoreFile = async (file: string, previous: string | null) => {
|
|
74
|
+
if (previous === null) await rm(file, { force: true })
|
|
75
|
+
else await privateWrite(file, previous)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const parseConfig = (value: unknown): TelegramProvisioningConfig => {
|
|
79
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('Invalid Telegram provisioning configuration')
|
|
80
|
+
const config = value as Record<string, unknown>
|
|
81
|
+
if (Object.keys(config).sort().join(',') !== 'composeFile,overrideFile,projectDirectory,projectName,relayEnvFile,service,version')
|
|
82
|
+
throw new Error('Invalid Telegram provisioning configuration')
|
|
83
|
+
if (config.version !== 1 || config.service !== 'relay' || ![config.composeFile, config.projectDirectory, config.projectName, config.relayEnvFile, config.overrideFile].every(value => typeof value === 'string'))
|
|
84
|
+
throw new Error('Invalid Telegram provisioning configuration')
|
|
85
|
+
const parsed = config as unknown as TelegramProvisioningConfig
|
|
86
|
+
if (!projectName(parsed.projectName)) throw new Error('Invalid Telegram provisioning configuration')
|
|
87
|
+
return parsed
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// The deployment records its installed relay image and runtime values in its
|
|
91
|
+
// own docker.env; provisioning must use that record instead of a self-set tag.
|
|
92
|
+
const deploymentEnvironment = async (projectDirectory: string): Promise<{ file: string; values: NodeJS.Dict<string> } | null> => {
|
|
93
|
+
const file = path.join(projectDirectory, 'docker.env')
|
|
94
|
+
let text: string
|
|
95
|
+
try {
|
|
96
|
+
await absoluteRegularFile(file)
|
|
97
|
+
text = await readFile(file, 'utf8')
|
|
98
|
+
} catch (error) {
|
|
99
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null
|
|
100
|
+
throw error
|
|
101
|
+
}
|
|
102
|
+
const values = parseEnv(text)
|
|
103
|
+
if (!values.EZ_RELAY_IMAGE?.trim()) throw new Error('Deployment docker.env must define EZ_RELAY_IMAGE; provisioning never selects an image')
|
|
104
|
+
return { file, values }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export const readTelegramProvisioningConfig = async (configFile: string): Promise<TelegramProvisioningConfig> => {
|
|
108
|
+
await absoluteRegularFile(configFile, true)
|
|
109
|
+
const config = parseConfig(JSON.parse(await readFile(configFile, 'utf8')))
|
|
110
|
+
if (!path.isAbsolute(config.projectDirectory) || /[\r\n\0]/.test(config.projectDirectory)) throw new Error('Invalid Telegram provisioning configuration')
|
|
111
|
+
const projectInfo = await lstat(config.projectDirectory).catch(() => { throw new Error('Invalid Telegram provisioning configuration') })
|
|
112
|
+
if (!projectInfo.isDirectory()) throw new Error('Invalid Telegram provisioning configuration')
|
|
113
|
+
const projectDirectory = await realpath(config.projectDirectory)
|
|
114
|
+
await absoluteRegularFile(config.composeFile)
|
|
115
|
+
for (const file of [config.relayEnvFile, config.overrideFile]) {
|
|
116
|
+
if (!path.isAbsolute(file) || /[\r\n\0]/.test(file)) throw new Error('Telegram secrets must remain inside the deployment directory')
|
|
117
|
+
if (!containedBy(projectDirectory, await resolveInside(file))) throw new Error('Telegram secrets must remain inside the deployment directory')
|
|
118
|
+
}
|
|
119
|
+
return config
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const composeOverride = (config: TelegramProvisioningConfig) => [
|
|
123
|
+
'services:',
|
|
124
|
+
' relay:',
|
|
125
|
+
' environment:',
|
|
126
|
+
' EZ_TELEGRAM_ENABLED: "true"',
|
|
127
|
+
' secrets:',
|
|
128
|
+
' - source: relay_env',
|
|
129
|
+
' target: relay_env',
|
|
130
|
+
'secrets:',
|
|
131
|
+
' relay_env:',
|
|
132
|
+
` file: ${JSON.stringify(config.relayEnvFile)}`,
|
|
133
|
+
'',
|
|
134
|
+
].join('\n')
|
|
135
|
+
|
|
136
|
+
const runDockerCompose: ComposeRunner = async (args, environment) => await new Promise<void>((resolve, reject) => {
|
|
137
|
+
const child = spawn('docker', ['compose', ...args], { env: environment, stdio: ['ignore', 'pipe', 'pipe'] })
|
|
138
|
+
let output = ''
|
|
139
|
+
const capture = (chunk: Buffer) => { if (output.length < 4096) output += chunk.toString('utf8').slice(0, 4096 - output.length) }
|
|
140
|
+
child.stdout.on('data', capture)
|
|
141
|
+
child.stderr.on('data', capture)
|
|
142
|
+
child.once('error', reject)
|
|
143
|
+
child.once('close', (code) => code === 0 ? resolve() : reject(new Error(`Telegram relay could not start (${code ?? 'unknown'}): ${redactToken(output.replace(/\s+/g, ' ').trim()) || 'no diagnostic'}`)))
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
const composeArguments = (config: TelegramProvisioningConfig, includeOverride: boolean, envFile: string | null) => [
|
|
147
|
+
'--project-directory', config.projectDirectory,
|
|
148
|
+
'--project-name', config.projectName,
|
|
149
|
+
...(envFile ? ['--env-file', envFile] : []),
|
|
150
|
+
'-f', config.composeFile,
|
|
151
|
+
...(includeOverride ? ['-f', config.overrideFile] : []),
|
|
152
|
+
'up', '-d', '--wait', config.service,
|
|
153
|
+
]
|
|
154
|
+
|
|
155
|
+
export const provisionTelegramBot = async (
|
|
156
|
+
configFile: string,
|
|
157
|
+
token: string,
|
|
158
|
+
runCompose: ComposeRunner = runDockerCompose,
|
|
159
|
+
): Promise<void> => {
|
|
160
|
+
if (!botToken(token)) throw new Error('Invalid Telegram bot token')
|
|
161
|
+
const config = await readTelegramProvisioningConfig(configFile)
|
|
162
|
+
const deployment = await deploymentEnvironment(config.projectDirectory)
|
|
163
|
+
// The deployment's docker.env must win compose interpolation: the shell
|
|
164
|
+
// takes precedence over --env-file, so a stale exported EZ_RELAY_IMAGE or
|
|
165
|
+
// EZ_AGENT_WORKSPACE could otherwise restart the relay on the wrong image
|
|
166
|
+
// or mounts. Compose tooling variables (DOCKER_*, PATH, HOME) stay intact.
|
|
167
|
+
const runnerEnvironment = { ...process.env }
|
|
168
|
+
delete runnerEnvironment.TELEGRAM_BOT_TOKEN
|
|
169
|
+
for (const key of Object.keys(deployment?.values ?? {})) {
|
|
170
|
+
if (key.startsWith('EZ_') || key.startsWith('COMPOSE_')) delete runnerEnvironment[key]
|
|
171
|
+
}
|
|
172
|
+
const envFile = deployment?.file ?? null
|
|
173
|
+
const lock = path.join(config.projectDirectory, 'telegram-provisioning.lock')
|
|
174
|
+
try {
|
|
175
|
+
await writeFile(lock, JSON.stringify({ pid: process.pid, startedAt: new Date().toISOString() }), { mode: 0o600, flag: 'wx' })
|
|
176
|
+
} catch (error) {
|
|
177
|
+
if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error
|
|
178
|
+
let stale = false
|
|
179
|
+
try {
|
|
180
|
+
const prior = JSON.parse(await readFile(lock, 'utf8')) as { pid: unknown; startedAt: unknown }
|
|
181
|
+
if (Number.isSafeInteger(prior.pid)) {
|
|
182
|
+
try { process.kill(prior.pid as number, 0) }
|
|
183
|
+
catch (signal) { if ((signal as NodeJS.ErrnoException).code === 'ESRCH') stale = true }
|
|
184
|
+
}
|
|
185
|
+
if (!stale && typeof prior.startedAt === 'string' && Date.now() - Date.parse(prior.startedAt) > 3600_000) stale = true
|
|
186
|
+
} catch {
|
|
187
|
+
throw new Error('Telegram setup is already in progress (remove telegram-provisioning.lock if no setup is running)')
|
|
188
|
+
}
|
|
189
|
+
if (!stale) throw new Error('Telegram setup is already in progress')
|
|
190
|
+
await rm(lock, { force: true })
|
|
191
|
+
await writeFile(lock, JSON.stringify({ pid: process.pid, startedAt: new Date().toISOString() }), { mode: 0o600, flag: 'wx' })
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const previousSecret = await previousFile(config.relayEnvFile)
|
|
195
|
+
const previousOverride = await previousFile(config.overrideFile)
|
|
196
|
+
try {
|
|
197
|
+
await privateWrite(config.relayEnvFile, `TELEGRAM_BOT_TOKEN=${token}\n`)
|
|
198
|
+
await privateWrite(config.overrideFile, composeOverride(config))
|
|
199
|
+
await runCompose(composeArguments(config, true, envFile), runnerEnvironment)
|
|
200
|
+
} catch (error) {
|
|
201
|
+
const restored = await Promise.allSettled([restoreFile(config.relayEnvFile, previousSecret), restoreFile(config.overrideFile, previousOverride)])
|
|
202
|
+
try {
|
|
203
|
+
await runCompose(composeArguments(config, previousOverride !== null, envFile), runnerEnvironment)
|
|
204
|
+
} catch (rollbackError) {
|
|
205
|
+
console.error(`Telegram rollback restart failed: ${redactToken(rollbackError instanceof Error ? rollbackError.message : String(rollbackError))}`)
|
|
206
|
+
}
|
|
207
|
+
for (const result of restored) {
|
|
208
|
+
if (result.status === 'rejected') console.error(`Telegram rollback restore failed: ${redactToken(result.reason instanceof Error ? result.reason.message : String(result.reason))}`)
|
|
209
|
+
}
|
|
210
|
+
throw error
|
|
211
|
+
} finally {
|
|
212
|
+
await rm(lock, { force: true })
|
|
213
|
+
}
|
|
214
|
+
}
|