@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.
Files changed (142) hide show
  1. package/.dockerignore +1 -0
  2. package/.env.example +5 -16
  3. package/AGENTS.md +17 -9
  4. package/CHANGELOG.md +96 -0
  5. package/Dockerfile +20 -11
  6. package/README.md +10 -13
  7. package/THIRD_PARTY_NOTICES.md +7 -4
  8. package/bin/ez +97 -0
  9. package/bin/ezenciel-agents-plugin-broker.mjs +16 -0
  10. package/bin/ezenciel-agents-provision-telegram.mjs +13 -0
  11. package/bin/ezenciel-agents.mjs +1 -1
  12. package/compose.yaml +41 -4
  13. package/docker/entrypoint.sh +1 -1
  14. package/docker/healthcheck.mjs +12 -4
  15. package/docker/run.ts +5 -6
  16. package/docker/smoke.mjs +28 -6
  17. package/docs/application-channel.md +32 -10
  18. package/docs/architecture/ai-selection.md +10 -2
  19. package/docs/architecture/authority-boundaries.md +2 -2
  20. package/docs/channel-backend.md +4 -3
  21. package/docs/docker-runtime.md +98 -11
  22. package/docs/local-qa.md +2 -2
  23. package/docs/plugin-guide.md +202 -0
  24. package/docs/plugins.md +48 -2
  25. package/docs/releasing.md +13 -0
  26. package/docs/responsive-channels.md +1 -1
  27. package/docs/scheduling.md +4 -4
  28. package/docs/setup.md +11 -5
  29. package/docs/upgrades.md +46 -23
  30. package/package.json +5 -5
  31. package/scripts/smoke-scheduler.ts +10 -3
  32. package/scripts/smoke.ts +14 -11
  33. package/src/agent-guidance.ts +6 -9
  34. package/src/agent-install.ts +28 -7
  35. package/src/ai-cli.ts +1 -1
  36. package/src/ai.ts +131 -5
  37. package/src/application-channel.ts +78 -7
  38. package/src/application-cli.ts +2 -2
  39. package/src/approval-cli.ts +14 -7
  40. package/src/approval.ts +27 -37
  41. package/src/channel-backend.ts +2 -3
  42. package/src/config.ts +12 -27
  43. package/src/control-state.ts +175 -8
  44. package/src/conversation-menu.ts +6 -17
  45. package/src/delivery-socket-client.d.mts +4 -0
  46. package/src/delivery-socket-client.mjs +104 -0
  47. package/src/delivery-socket.ts +354 -0
  48. package/src/execution-authority.ts +21 -5
  49. package/src/executor.ts +81 -24
  50. package/src/files.ts +38 -11
  51. package/src/host-executor.ts +33 -17
  52. package/src/inbox.ts +15 -49
  53. package/src/index.ts +98 -54
  54. package/src/install-tools.mjs +97 -22
  55. package/src/ledger-endpoint.d.mts +2 -0
  56. package/src/ledger-endpoint.mjs +17 -0
  57. package/src/menu.ts +8 -3
  58. package/src/message-history.ts +13 -18
  59. package/src/message.ts +35 -18
  60. package/src/plugin-broker.mjs +396 -0
  61. package/src/plugins/connection.mjs +2 -2
  62. package/src/plugins/manager.mjs +131 -23
  63. package/src/plugins/native-tasks.mjs +2 -2
  64. package/src/prompt-suffix.ts +12 -0
  65. package/src/react.ts +6 -4
  66. package/src/runs.ts +156 -243
  67. package/src/schedule-cli.ts +29 -10
  68. package/src/scheduled-tasks.ts +53 -10
  69. package/src/scheduler.ts +20 -7
  70. package/src/service.ts +3 -3
  71. package/src/setup.ts +1 -0
  72. package/src/software-status.ts +36 -4
  73. package/src/task-executor.ts +2 -2
  74. package/src/task-workspace.ts +4 -3
  75. package/src/tasks.ts +1 -1
  76. package/src/telegram-provisioning-cli.ts +22 -0
  77. package/src/telegram-provisioning.ts +214 -0
  78. package/src/updates/control.mjs +77 -7
  79. package/src/updates/runtime.mjs +28 -8
  80. package/src/updates/status.mjs +25 -3
  81. package/src/updates/supervisor.mjs +15 -16
  82. package/src/version.ts +20 -0
  83. package/src/workspace.ts +3 -9
  84. package/templates/failure-review.md +1 -1
  85. package/templates/maintainer-purpose.md +1 -1
  86. package/test/agent-guidance.test.ts +14 -80
  87. package/test/agent-install.test.ts +14 -0
  88. package/test/ai.test.ts +220 -1
  89. package/test/application-channel.test.ts +61 -13
  90. package/test/application-only.test.ts +14 -13
  91. package/test/channel-backend.test.ts +3 -7
  92. package/test/channel-delivery.test.ts +4 -1
  93. package/test/channel-owner.test.ts +6 -3
  94. package/test/codex-context.test.ts +2 -2
  95. package/test/config.test.ts +38 -43
  96. package/test/control-state.test.ts +83 -4
  97. package/test/conversation-menu.test.ts +2 -4
  98. package/test/delivery-transport.test.ts +72 -0
  99. package/test/docker-runtime.test.ts +27 -14
  100. package/test/engine-handoff.test.ts +7 -6
  101. package/test/event-sources.test.ts +2 -2
  102. package/test/execution-authority.test.ts +9 -9
  103. package/test/executor.test.ts +120 -12
  104. package/test/failure.test.ts +12 -5
  105. package/test/files.test.ts +2 -2
  106. package/test/helpers/ledger.ts +18 -0
  107. package/test/host-executor.test.ts +87 -3
  108. package/test/inbox.test.ts +8 -10
  109. package/test/install-config.test.ts +1 -0
  110. package/test/install-tools.test.mjs +74 -7
  111. package/test/intake-relay.test.ts +58 -11
  112. package/test/message-history.test.ts +39 -47
  113. package/test/native-tasks.test.ts +1 -1
  114. package/test/plugin-broker.test.mjs +172 -0
  115. package/test/plugin-manager.test.mjs +136 -2
  116. package/test/reaction.test.ts +4 -0
  117. package/test/relay.test.ts +5 -7
  118. package/test/repair-policy.test.ts +1 -4
  119. package/test/schedule-cli.test.ts +14 -9
  120. package/test/scheduled-tasks.test.ts +29 -6
  121. package/test/scheduler-host.test.ts +5 -5
  122. package/test/scheduler-relay.test.ts +15 -9
  123. package/test/security.test.ts +6 -3
  124. package/test/software-status.test.ts +21 -3
  125. package/test/telegram-provisioning.test.ts +160 -0
  126. package/test/updates.test.mjs +93 -13
  127. package/test/workspace.test.ts +4 -1
  128. package/bin/ezenciel-agents-watch.mjs +0 -8
  129. package/compose.workforce-watch.yaml +0 -33
  130. package/docker/recovery.ts +0 -11
  131. package/docs/pagerduty.md +0 -42
  132. package/docs/workforce-watch.md +0 -101
  133. package/src/pagerduty.ts +0 -109
  134. package/src/reply-context.ts +0 -43
  135. package/src/update-attention.ts +0 -18
  136. package/src/workforce-watch-cli.ts +0 -14
  137. package/src/workforce-watch.ts +0 -155
  138. package/templates/agent/AGENTS.md +0 -5
  139. package/templates/agent-guidance.md +0 -17
  140. package/test/pagerduty.test.ts +0 -104
  141. package/test/update-attention.test.ts +0 -20
  142. package/test/workforce-watch.test.ts +0 -180
package/src/files.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { mkdir, writeFile, realpath, stat } from 'node:fs/promises'
1
+ import { mkdir, readFile, writeFile, realpath, stat } from 'node:fs/promises'
2
2
  import { randomUUID } from 'node:crypto'
3
3
  import path from 'node:path'
4
4
 
@@ -43,28 +43,55 @@ export const sanitizeFileName = (name: string): string => {
43
43
  return base || 'file'
44
44
  }
45
45
 
46
+ // Runtime inbound staging lives under the agent control directory, never the
47
+ // workspace mind folder. The engine resolves the absolute path regardless of
48
+ // its working directory; the prompt carries no workspace-relative prefix.
46
49
  export const stageIncomingFile = async (
47
- workspaceDir: string,
50
+ stagingRoot: string,
48
51
  fileName: string,
49
52
  bytes: Buffer,
50
53
  ): Promise<{ relativePath: string; fullPath: string; fileType: DetectedFileType }> => {
51
54
  const fileType = detectFileType(bytes)
52
55
  if (fileType === 'unknown' || bytes.length > 20 * 1024 * 1024)
53
56
  throw new Error('Unsupported or oversized attachment')
54
- const inboxDir = path.join(workspaceDir, 'inbox')
55
- await mkdir(inboxDir, { recursive: true, mode: 0o700 })
57
+ const attachmentsDir = path.join(stagingRoot, 'attachments')
58
+ await mkdir(attachmentsDir, { recursive: true, mode: 0o700 })
56
59
  const sanitized = sanitizeFileName(fileName)
57
60
  const targetName = `${randomUUID()}_${sanitized}`
58
- await workspaceFile(workspaceDir, 'inbox', false)
59
- const fullPath = path.join(inboxDir, targetName)
61
+ await workspaceFile(stagingRoot, 'attachments', false)
62
+ const fullPath = path.join(attachmentsDir, targetName)
60
63
  await writeFile(fullPath, bytes, { mode: 0o600 })
61
64
  return {
62
- relativePath: path.join('inbox', targetName),
65
+ relativePath: path.join('attachments', targetName),
63
66
  fullPath,
64
67
  fileType,
65
68
  }
66
69
  }
67
70
 
71
+ // Reads a staged attachment produced by stageIncomingFile. Relative paths
72
+ // resolve under the first containing root (backward compatible with
73
+ // workspace inbox staging); absolute paths must stay inside one of the roots.
74
+ export const readStagedAttachment = async (roots: (string | undefined)[], file: string): Promise<Buffer> => {
75
+ const candidates = [...new Set(roots.filter((root): root is string => Boolean(root)))]
76
+ if (!path.isAbsolute(file)) {
77
+ for (const root of candidates) {
78
+ try { return await readFile(await workspaceFile(root, file)) } catch {}
79
+ }
80
+ throw new Error('File is outside the workspace')
81
+ }
82
+ const target = await realpath(file).catch(() => { throw new Error('File is outside the workspace') })
83
+ for (const root of candidates) {
84
+ const base = await realpath(root).catch(() => null)
85
+ if (!base) continue
86
+ const relative = path.relative(base, target)
87
+ if (!relative.startsWith('..') && !path.isAbsolute(relative) && relative !== '') {
88
+ if (!(await stat(target)).isFile()) throw new Error('Expected a regular file')
89
+ return readFile(target)
90
+ }
91
+ }
92
+ throw new Error('File is outside the workspace')
93
+ }
94
+
68
95
  export const workspaceFile = async (workspace: string, file: string, regular = true): Promise<string> => {
69
96
  const root = await realpath(workspace)
70
97
  const target = await realpath(path.resolve(root, file))
@@ -76,12 +103,12 @@ export const workspaceFile = async (workspace: string, file: string, regular = t
76
103
 
77
104
  // Canonical native-engine attachment metadata for every inbound channel.
78
105
  export const MAX_INCOMING_ATTACHMENT_BYTES = 10 * 1024 * 1024
79
- export const stageChatAttachment = async (workspace: string, name: string, bytes: Buffer, comment: string) => {
106
+ export const stageChatAttachment = async (stagingRoot: string, name: string, bytes: Buffer, comment: string) => {
80
107
  const type = detectFileType(bytes)
81
108
  if (!bytes.length || bytes.length > MAX_INCOMING_ATTACHMENT_BYTES || type === 'unknown' ||
82
109
  (type === 'text' && !/\.(txt|md|markdown)$/i.test(name))) throw new Error('Invalid application attachment: unsupported type or size')
83
- const staged = await stageIncomingFile(workspace, name, bytes)
110
+ const staged = await stageIncomingFile(stagingRoot, name, bytes)
84
111
  const kind = ['jpeg', 'png', 'webp'].includes(staged.fileType) ? 'image' : 'document'
85
- return { text: `[Attached ${kind} staged at ${staged.relativePath} (type: ${staged.fileType}, size: ${bytes.length} bytes)]${comment ? `\n\nCaption: ${comment}` : ''}`,
86
- attachment: {path: staged.relativePath, type: staged.fileType}, fullPath: staged.fullPath }
112
+ return { text: `[Attached ${kind} staged at ${staged.fullPath} (type: ${staged.fileType}, size: ${bytes.length} bytes)]${comment ? `\n\nCaption: ${comment}` : ''}`,
113
+ attachment: {path: staged.fullPath, type: staged.fileType}, fullPath: staged.fullPath }
87
114
  }
@@ -1,15 +1,15 @@
1
- import { installAgentGuidance } from './agent-guidance.js'
2
1
  import { redactFailure } from './failure.js'
3
- import { RunStore } from './runs.js'
4
2
  import { Tasks } from './tasks.js'
5
- import { requireOwnerExecution } from './execution-authority.js'
3
+ import { ControlStore } from './control-state.js'
4
+ import { authorizeRun, readRun } from './delivery-socket.js'
5
+ import { runPromptSuffix } from './prompt-suffix.js'
6
6
  import { mkdir, readFile, writeFile, readdir, rename, rm, appendFile, realpath } from 'node:fs/promises'
7
7
  import path from 'node:path'
8
8
  import { isHostRunId } from './host-executor-protocol.js'
9
9
  import { fileURLToPath } from 'node:url'
10
10
  import { startExecutorJob, terminateJob, resolveExecutor, validateCodexProvider, type CodexProviderBinding, type ExecutorOptions } from './executor.js'
11
11
  import { parseIsolationClass, type IsolationClass } from './isolation.js'
12
- import { readModels, validateSelection } from './ai.js'
12
+ import { readModels, validateSelection, validateOpencodeProviders } from './ai.js'
13
13
  import type { ChildProcess } from 'node:child_process'
14
14
  import { taskWorkspace } from './task-workspace.js'
15
15
  import { packageVersion } from './version.js'
@@ -17,7 +17,7 @@ import { installedPluginVersions } from './software-status.js'
17
17
  import { processSnapshot } from './process-tree.js'
18
18
 
19
19
  export type PluginNetworkRoute = { revisions:string[]; bindings:{service:string;network:string}[] }
20
- export type HostBinding = { name: string; workspace: string; controlDir: string; binDir: string; toolsHome?: string; sharedWorkspace?: string; additionalWorkspaces?: string[]; pluginNetworkBindings?: Record<string, PluginNetworkRoute>; pluginFolderRoots?: Record<string,string[]>; codexProviders?: CodexProviderBinding[] }
20
+ export type HostBinding = { name: string; workspace: string; controlDir: string; binDir: string; toolsHome?: string; sharedWorkspace?: string; additionalWorkspaces?: string[]; pluginNetworkBindings?: Record<string, PluginNetworkRoute>; pluginFolderRoots?: Record<string,string[]>; codexProviders?: CodexProviderBinding[]; opencodeProviders?: string[] }
21
21
  export type HostInstallation = { cli: string; isolation?: IsolationClass; agents: HostBinding[] }
22
22
 
23
23
  const processStart = async (pid:number) => (await processSnapshot()).get(pid)?.birth
@@ -35,16 +35,24 @@ export const serveHostExecutor = async (installation: HostInstallation, signal:
35
35
  const sharedWorkspaces = new Map<HostBinding, string>()
36
36
  const additionalWorkspaces = new Map<HostBinding, string[]>()
37
37
  const catalog = async (agent: HostBinding) => {
38
- const discovered = await readModels(undefined, undefined, path.join(agent.controlDir, 'cli', 'codex'))
38
+ const discovered = await readModels(undefined, undefined, path.join(agent.controlDir, 'cli', 'codex'),
39
+ undefined, undefined, validateOpencodeProviders(agent.opencodeProviders), agent.controlDir)
39
40
  const providers = (agent.codexProviders ?? []).map(validateCodexProvider)
40
- const declared = providers.flatMap(provider => provider.models.map(model => ({cli:'codex',provider:provider.id,model,name:`${provider.name} · ${model}`.slice(0,80),efforts:[]})))
41
- return [...discovered, ...declared]
41
+ const declared = providers.flatMap(provider => provider.models.map(model => {
42
+ const native = discovered.find(candidate => candidate.cli === 'codex' && !candidate.provider && candidate.model === model)
43
+ return {cli:'codex',provider:provider.id,model,name:`${provider.name} · ${model}`.slice(0,80),efforts:native?.efforts ?? []}
44
+ }))
45
+ const declaredModels = new Set(declared.map(model => model.model))
46
+ const isReplacedByProvider = (model: {cli:string; model?:string}) =>
47
+ model.model !== undefined && declaredModels.has(model.model) && model.cli === 'codex'
48
+ return [...discovered.filter(model => !isReplacedByProvider(model)), ...declared]
42
49
  }
43
50
  try {
44
51
  for (const agent of installation.agents) {
45
52
  if (![agent.workspace,agent.controlDir,agent.binDir].every(path.isAbsolute)) throw new Error('Host bindings require absolute paths')
46
53
  const providers=(agent.codexProviders ?? []).map(validateCodexProvider)
47
54
  if (new Set(providers.map(provider=>provider.id)).size !== providers.length) throw new Error('Codex provider IDs must be unique')
55
+ validateOpencodeProviders(agent.opencodeProviders)
48
56
  if (agent.sharedWorkspace && !path.isAbsolute(agent.sharedWorkspace)) throw new Error('Shared workspace requires an absolute path')
49
57
  if (agent.sharedWorkspace) sharedWorkspaces.set(agent, await realpath(agent.sharedWorkspace))
50
58
  if (agent.additionalWorkspaces) {
@@ -71,8 +79,10 @@ export const serveHostExecutor = async (installation: HostInstallation, signal:
71
79
  catch(error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error }
72
80
  await writeFile(lock,JSON.stringify({pid:process.pid,started:await processStart(process.pid)}),{mode:0o600,flag:'wx'})
73
81
  locks.push(lock)
74
- await installAgentGuidance(agent.workspace)
75
- await writeFile(path.join(directory,'models.json'),JSON.stringify(await catalog(agent)),{mode:0o600})
82
+ const models = await catalog(agent)
83
+ await new ControlStore(agent.controlDir, 900000).normalizeProviderBindings(models)
84
+ const modelsFile=path.join(directory,'models.json')
85
+ await writeFile(modelsFile+'.tmp',JSON.stringify(models),{mode:0o600});await rename(modelsFile+'.tmp',modelsFile)
76
86
  // A host crash is terminal for a claimed job. Never replay an action.
77
87
  for (const file of await readdir(directory)) if (file.endsWith('.running.json')) {
78
88
  const base=path.join(directory,file.slice(0,-13))
@@ -88,27 +98,32 @@ export const serveHostExecutor = async (installation: HostInstallation, signal:
88
98
  if (agent.toolsHome) await (await import('./plugins/workspace-lease.mjs')).recoverNativeLease(agent.toolsHome)
89
99
  }
90
100
  let catalogAt=Date.now()
101
+ const catalogSeen=new Map<HostBinding,string>()
91
102
  while (!signal.aborted) {
92
103
  const parent=Number(process.env.EZ_HOST_SUPERVISOR_PID)
93
104
  if(parent) { try { process.kill(parent,0) } catch { break } }
94
105
  if(Date.now()-catalogAt>30000){
95
106
  for(const agent of installation.agents){
96
- const models=JSON.stringify(await catalog(agent))
107
+ const catalogModels = await catalog(agent)
108
+ const models=JSON.stringify(catalogModels)
109
+ if(catalogSeen.get(agent)===models)continue
110
+ await new ControlStore(agent.controlDir, 900000).normalizeProviderBindings(catalogModels)
97
111
  const file=path.join(agent.controlDir,'host-executor/models.json')
98
112
  await writeFile(file+'.tmp',models,{mode:0o600});await rename(file+'.tmp',file)
113
+ catalogSeen.set(agent,models)
99
114
  }
100
115
  catalogAt=Date.now()
101
116
  }
102
117
  for (const agent of installation.agents) {
103
118
  const directory=path.join(agent.controlDir,'host-executor')
104
- await writeFile(path.join(directory,'heartbeat.tmp'),JSON.stringify({at:Date.now(),pid:process.pid,version:packageVersion,plugins:await installedPluginVersions(agent.toolsHome)}),{mode:0o600})
119
+ await writeFile(path.join(directory,'heartbeat.tmp'),JSON.stringify({at:Date.now(),pid:process.pid,version:packageVersion,platform:process.platform,arch:process.arch,plugins:await installedPluginVersions(agent.toolsHome)}),{mode:0o600})
105
120
  await rename(path.join(directory,'heartbeat.tmp'),path.join(directory,'heartbeat.json'))
106
121
  for (const file of await readdir(directory)) {
107
122
  if (!file.endsWith('.request.json') || !isHostRunId(file.slice(0,-13))) continue
108
123
  const id=file.slice(0,-13)
109
124
  let run
110
125
  try {
111
- run = await new RunStore(agent.controlDir).get(id)
126
+ run = await readRun(agent.controlDir, id)
112
127
  if(id.startsWith('r_schedule_') && !run?.scheduled) throw new Error('Missing scheduled run')
113
128
  } catch {
114
129
  await appendFile(path.join(directory,id+'.events'),JSON.stringify({stream:'exit',code:1})+'\n',{mode:0o600})
@@ -131,11 +146,11 @@ export const serveHostExecutor = async (installation: HostInstallation, signal:
131
146
  try { await readFile(base+'.cancel'); throw new Error('Cancelled') } catch(error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error }
132
147
  const request=JSON.parse(await readFile(base+'.running.json','utf8'))
133
148
  if (!Array.isArray(request.texts) || request.texts.some((text:unknown)=>typeof text!=='string')) throw new Error('Invalid job')
134
- const run = await new RunStore(agent.controlDir).get(path.basename(base))
149
+ const run = await readRun(agent.controlDir, path.basename(base))
135
150
  if (run?.taskId) {
136
151
  if (run.status !== 'running') throw new Error('No active task run')
137
152
  await new Tasks(agent.controlDir).authorize(run, false)
138
- } else await requireOwnerExecution(agent.controlDir, path.basename(base))
153
+ } else await authorizeRun(agent.controlDir, path.basename(base))
139
154
  const opts=request.options as ExecutorOptions
140
155
  const cli = opts.cli || installation.cli
141
156
  resolveExecutor(cli)
@@ -149,9 +164,10 @@ export const serveHostExecutor = async (installation: HostInstallation, signal:
149
164
  if (!provider && model && (agent.codexProviders ?? []).some(binding=>binding.models.includes(model)))
150
165
  throw new Error('Selected Codex provider is required for this model')
151
166
  if (cli !== installation.cli) await validateSelection({id:'selected',name:'Selected model',cli,provider:opts.provider,model,effort:opts.effort},await catalog(agent))
152
- const options:ExecutorOptions={workspace:run?.scheduled ? await taskWorkspace(agent.workspace,id) : agent.workspace,controlDir:agent.controlDir,binDir:agent.binDir,toolsHome:agent.toolsHome,sharedWorkspace,additionalWorkspaces:additionalWorkspaces.get(agent),cli,
167
+ const options:ExecutorOptions={workspace:run?.scheduled ? await taskWorkspace(agent.controlDir,id) : agent.workspace,controlDir:agent.controlDir,binDir:agent.binDir,toolsHome:agent.toolsHome,sharedWorkspace,additionalWorkspaces:additionalWorkspaces.get(agent),cli,
153
168
  runId:path.basename(base),timeoutMs:0,repairEnabled:opts.repairEnabled,
154
- sessionId:opts.sessionId,isResume:opts.isResume,eventSource:opts.eventSource,model,effort:opts.effort,provider:opts.provider,codexAutoCompactTokens:opts.codexAutoCompactTokens,codexProvider:provider}
169
+ sessionId:opts.sessionId,isResume:opts.isResume,model,effort:opts.effort,provider:opts.provider,codexAutoCompactTokens:opts.codexAutoCompactTokens,codexProvider:provider,
170
+ promptSuffix:runPromptSuffix(run),taskRun:Boolean(run?.taskId),nativeSession:Boolean(run?.scheduled)}
155
171
  job=await launch(request.texts,options)
156
172
  active.set(base,job.child)
157
173
  await writeFile(base+'.process.json',JSON.stringify({pid:job.child.pid}),{mode:0o600})
package/src/inbox.ts CHANGED
@@ -1,5 +1,3 @@
1
- import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
2
- import { join } from 'node:path'
3
1
  import type { Update } from 'grammy/types'
4
2
  import { isExecutionChoice, type ExecutionChoice } from './ai.js'
5
3
  import { isOwner } from './identity.js'
@@ -21,58 +19,26 @@ type Entry = { update: Update; receivedAt: number; execution?: ExecutionChoice }
21
19
  export type InboxBatch = { id: string; entries: Entry[]; status: 'pending' | 'failed' | 'cancelled' }
22
20
  type State = { version: 1; seen: number[]; waiting: Entry[]; batches: InboxBatch[] }
23
21
 
24
- // One relay writer. Serialize read/modify/rename, including commands arriving during STT.
22
+ // Stateless pipe: process memory only. Intake dedup lives in the relay that
23
+ // polls Telegram, so no other process reads this journal. A restart drops
24
+ // unprocessed updates by design; Telegram redelivery is the replay mechanism.
25
+ const statesByDirectory = new Map<string, State>()
26
+
27
+ const stateFor = (directory: string): State => {
28
+ let state = statesByDirectory.get(directory)
29
+ if (!state) { state = { version: 1, seen: [], waiting: [], batches: [] }; statesByDirectory.set(directory, state) }
30
+ return state
31
+ }
32
+
25
33
  export class InboxStore {
26
34
  private lock: Promise<unknown> = Promise.resolve()
27
- private readonly file: string
28
35
  constructor(
29
36
  private readonly directory: string,
30
37
  private readonly now = Date.now,
31
- ) {
32
- this.file = join(directory, 'inbox.json')
33
- }
34
-
35
- private async read(): Promise<State> {
36
- try {
37
- const state = JSON.parse(await readFile(this.file, 'utf8')) as State
38
- const entry = (e: Entry) =>
39
- e && Number.isSafeInteger(e.update?.update_id) && Number.isFinite(e.receivedAt) &&
40
- (e.execution === undefined || isExecutionChoice(e.execution))
41
- if (
42
- state.version !== 1 ||
43
- !Array.isArray(state.seen) ||
44
- !state.seen.every(Number.isSafeInteger) ||
45
- !Array.isArray(state.waiting) ||
46
- !state.waiting.every(entry) ||
47
- !Array.isArray(state.batches) ||
48
- !state.batches.every(
49
- (b) =>
50
- /^tg_\d+$/.test(b.id) &&
51
- ['pending', 'failed', 'cancelled'].includes(b.status) &&
52
- Array.isArray(b.entries) &&
53
- b.entries.length > 0 &&
54
- b.entries.every(entry),
55
- )
56
- )
57
- throw new Error('Invalid inbox shape')
58
- return state
59
- } catch (error) {
60
- if ((error as NodeJS.ErrnoException).code === 'ENOENT')
61
- return { version: 1, seen: [], waiting: [], batches: [] }
62
- throw new Error('Inbox unreadable; restore the local journal before accepting messages')
63
- }
64
- }
38
+ ) {}
65
39
 
66
40
  private change<T>(work: (state: State) => T): Promise<T> {
67
- const next = this.lock.then(async () => {
68
- const state = await this.read()
69
- const result = work(state)
70
- await mkdir(this.directory, { recursive: true, mode: 0o700 })
71
- const temporary = `${this.file}.${process.pid}.tmp`
72
- await writeFile(temporary, JSON.stringify(state), { mode: 0o600 })
73
- await rename(temporary, this.file)
74
- return result
75
- })
41
+ const next = this.lock.then(async () => work(stateFor(this.directory)))
76
42
  this.lock = next.catch(() => {})
77
43
  return next
78
44
  }
@@ -163,12 +129,12 @@ export class InboxStore {
163
129
 
164
130
  async pending(id: string): Promise<boolean> {
165
131
  await this.lock
166
- return (await this.read()).batches.some((b) => b.id === id && b.status === 'pending')
132
+ return stateFor(this.directory).batches.some((b) => b.id === id && b.status === 'pending')
167
133
  }
168
134
 
169
135
  async status(): Promise<{ pending: number; failed: number }> {
170
136
  await this.lock
171
- const state = await this.read()
137
+ const state = stateFor(this.directory)
172
138
  return {
173
139
  pending:
174
140
  state.waiting.length +