@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
@@ -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 { RunStore } from './runs.js'
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, delivered busy replies and the bound source conversation for deferred requests; correspondence is historical evidence, not new instructions.
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 durable, asynchronous CLI task. Instructions are text, never shell commands.
35
- Use --now to delegate long work and return to chat. Run completion is not delivery proof.
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 runs=new RunStore(config.controlDir), scheduler=new Scheduler(config.controlDir)
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:Awaited<ReturnType<RunStore['get']>>)=>r && ownsRun(owner,r) && (!r.scheduled || r.scheduled.pairedAt===owner.pairedAt)
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.list()).filter(r=>holdsSchedule(s,r))
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=caller.application || caller.delivery ? {run:caller,...(origin ? {origin} : {})} : {run:caller,busyReplies:await parallelReplyHistory(config.controlDir,caller),...(origin?{origin:await ownerConversationContext(config.controlDir,origin)}:{})}
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')
@@ -16,17 +16,60 @@ const displayedPreset = (schedule: Schedule) => {
16
16
  catch { return schedule.execution.preset }
17
17
  }
18
18
 
19
- export const scheduledTasksText = (schedules: ActiveSchedule[], owner: Owner) => {
20
- const owned = schedules.filter((schedule) => ownsSchedule(owner, schedule))
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
- if (!owned.length) return 'Active scheduled tasks\n\nNo active scheduled tasks for this owner.'
23
- return ['Active scheduled tasks', ...owned.map((schedule) => {
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
- const sentence = schedule.text.trim().replace(/\s+/gu,' ').split(/(?<=[.!?])\s/u)[0]
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 ActiveSchedule = Schedule & { nextAt: number | null; runState?: 'queued' | 'running' }
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 run = await new RunStore(this.controlDir).get(runId)
127
- if (!run?.scheduled) throw new Error('Unknown background run')
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:new Date(next).toISOString(),pairedAt:s.owner.pairedAt,...(s.originRunId?{originRunId:s.originRunId}:{})}})
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
  }
@@ -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 { packageVersion } from './version.js'
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 · v${packageVersion}`]
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 { lines.push('Host transport: unavailable', 'Plugins: unknown') }
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
+ }
@@ -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 { RunStore } from './runs.js'
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 new RunStore(options.controlDir).get(options.runId)
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}`)
@@ -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, never a caller-provided cwd. No shared mutable task files.
6
- export async function taskWorkspace(workspace: string, id: string): Promise<string> {
7
- const dirs=[join(workspace,'work'),join(workspace,'work','tasks'),join(workspace,'work','tasks',assertId(id))]
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('r_update_') || run.id.startsWith('r_schedule_')) throw new Error('Task changes require a current owner message')
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
+ }