@jc_stack/ez-agents 0.1.0-beta.33 → 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 (157) hide show
  1. package/.dockerignore +1 -0
  2. package/.env.example +9 -16
  3. package/AGENTS.md +17 -9
  4. package/CHANGELOG.md +92 -0
  5. package/Dockerfile +11 -9
  6. package/README.md +32 -27
  7. package/THIRD_PARTY_NOTICES.md +4 -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 +42 -5
  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 +32 -7
  17. package/docs/application-channel.md +38 -10
  18. package/docs/architecture/ai-selection.md +3 -1
  19. package/docs/architecture/authority-boundaries.md +2 -2
  20. package/docs/channel-backend.md +4 -3
  21. package/docs/docker-runtime.md +106 -21
  22. package/docs/host-service.md +17 -6
  23. package/docs/local-qa.md +2 -2
  24. package/docs/plugin-guide.md +202 -0
  25. package/docs/plugins.md +63 -2
  26. package/docs/releasing.md +13 -0
  27. package/docs/repair.md +1 -1
  28. package/docs/responsive-channels.md +1 -1
  29. package/docs/scheduling.md +4 -4
  30. package/docs/setup.md +96 -16
  31. package/docs/standalone-cli.md +6 -5
  32. package/docs/upgrades.md +46 -23
  33. package/package.json +5 -5
  34. package/scripts/smoke-scheduler.ts +12 -4
  35. package/scripts/smoke.ts +14 -11
  36. package/src/agent-guidance.ts +6 -9
  37. package/src/agent-install.ts +44 -9
  38. package/src/ai-cli.ts +9 -7
  39. package/src/ai.ts +7 -7
  40. package/src/application-channel.ts +88 -17
  41. package/src/application-cli.ts +2 -2
  42. package/src/approval-cli.ts +14 -7
  43. package/src/approval.ts +27 -37
  44. package/src/channel-backend.ts +2 -3
  45. package/src/codex-session.ts +5 -3
  46. package/src/config.ts +18 -28
  47. package/src/control-state.ts +187 -9
  48. package/src/conversation-menu.ts +6 -17
  49. package/src/delivery-socket-client.d.mts +4 -0
  50. package/src/delivery-socket-client.mjs +104 -0
  51. package/src/delivery-socket.ts +354 -0
  52. package/src/execution-authority.ts +21 -5
  53. package/src/executor.ts +110 -31
  54. package/src/files.ts +38 -11
  55. package/src/host-executor.ts +50 -17
  56. package/src/inbox.ts +15 -49
  57. package/src/index.ts +96 -52
  58. package/src/install-config.ts +2 -2
  59. package/src/install-tools.mjs +100 -23
  60. package/src/isolation.ts +25 -0
  61. package/src/ledger-endpoint.d.mts +2 -0
  62. package/src/ledger-endpoint.mjs +17 -0
  63. package/src/menu.ts +16 -8
  64. package/src/message-history.ts +13 -18
  65. package/src/message.ts +35 -18
  66. package/src/plugin-broker.mjs +396 -0
  67. package/src/plugins/connection.mjs +2 -2
  68. package/src/plugins/manager.mjs +178 -26
  69. package/src/plugins/native-tasks.mjs +2 -2
  70. package/src/prompt-suffix.ts +12 -0
  71. package/src/react.ts +6 -4
  72. package/src/runs.ts +156 -243
  73. package/src/schedule-cli.ts +29 -10
  74. package/src/scheduled-tasks.ts +53 -10
  75. package/src/scheduler.ts +20 -7
  76. package/src/service.ts +3 -3
  77. package/src/setup.ts +11 -7
  78. package/src/software-status.ts +36 -4
  79. package/src/task-executor.ts +6 -3
  80. package/src/task-workspace.ts +4 -3
  81. package/src/tasks.ts +1 -1
  82. package/src/telegram-provisioning-cli.ts +22 -0
  83. package/src/telegram-provisioning.ts +214 -0
  84. package/src/updates/control.mjs +77 -7
  85. package/src/updates/runtime.mjs +29 -9
  86. package/src/updates/status.mjs +25 -3
  87. package/src/updates/supervisor.mjs +31 -18
  88. package/src/version.ts +20 -0
  89. package/src/workspace-confine.ts +38 -0
  90. package/src/workspace.ts +24 -20
  91. package/templates/agent-purpose.md +1 -0
  92. package/templates/failure-review.md +1 -1
  93. package/templates/maintainer-purpose.md +1 -1
  94. package/test/agent-guidance.test.ts +18 -88
  95. package/test/agent-install.test.ts +48 -0
  96. package/test/ai-cli.test.ts +18 -0
  97. package/test/ai.test.ts +84 -0
  98. package/test/application-channel.test.ts +66 -15
  99. package/test/application-controls.test.ts +56 -3
  100. package/test/application-only.test.ts +16 -13
  101. package/test/channel-backend.test.ts +3 -7
  102. package/test/channel-delivery.test.ts +4 -1
  103. package/test/channel-owner.test.ts +6 -3
  104. package/test/codex-context.test.ts +63 -3
  105. package/test/codex-session.test.ts +6 -1
  106. package/test/config.test.ts +47 -37
  107. package/test/control-state.test.ts +83 -4
  108. package/test/conversation-menu.test.ts +2 -4
  109. package/test/delivery-transport.test.ts +72 -0
  110. package/test/docker-runtime.test.ts +27 -14
  111. package/test/engine-handoff.test.ts +12 -8
  112. package/test/event-sources.test.ts +2 -2
  113. package/test/execution-authority.test.ts +9 -9
  114. package/test/executor.test.ts +47 -11
  115. package/test/failure.test.ts +18 -5
  116. package/test/files.test.ts +2 -2
  117. package/test/helpers/ledger.ts +18 -0
  118. package/test/host-executor.test.ts +80 -0
  119. package/test/inbox.test.ts +8 -10
  120. package/test/install-config.test.ts +18 -6
  121. package/test/install-tools.test.mjs +80 -7
  122. package/test/intake-relay.test.ts +58 -11
  123. package/test/message-history.test.ts +39 -47
  124. package/test/native-tasks.test.ts +1 -1
  125. package/test/plugin-broker.test.mjs +172 -0
  126. package/test/plugin-manager.test.mjs +143 -5
  127. package/test/reaction.test.ts +4 -0
  128. package/test/relay.test.ts +5 -7
  129. package/test/repair-policy.test.ts +1 -4
  130. package/test/schedule-cli.test.ts +14 -9
  131. package/test/scheduled-tasks.test.ts +29 -6
  132. package/test/scheduler-host.test.ts +5 -5
  133. package/test/scheduler-relay.test.ts +15 -9
  134. package/test/scheduler.test.ts +2 -2
  135. package/test/security.test.ts +6 -3
  136. package/test/software-status.test.ts +21 -3
  137. package/test/telegram-provisioning.test.ts +160 -0
  138. package/test/updates.test.mjs +102 -13
  139. package/test/workspace-confine.test.ts +47 -0
  140. package/test/workspace.test.ts +27 -11
  141. package/bin/ezenciel-agents-watch.mjs +0 -8
  142. package/compose.workforce-watch.yaml +0 -33
  143. package/docker/recovery.ts +0 -11
  144. package/docs/pagerduty.md +0 -42
  145. package/docs/workforce-watch.md +0 -101
  146. package/src/pagerduty.ts +0 -109
  147. package/src/reply-context.ts +0 -44
  148. package/src/update-attention.ts +0 -18
  149. package/src/workforce-watch-cli.ts +0 -14
  150. package/src/workforce-watch.ts +0 -155
  151. package/templates/agent/AGENTS.md +0 -15
  152. package/templates/agent/SOUL.md +0 -11
  153. package/templates/agent/USER.md +0 -5
  154. package/templates/agent-guidance.md +0 -114
  155. package/test/pagerduty.test.ts +0 -104
  156. package/test/update-attention.test.ts +0 -20
  157. package/test/workforce-watch.test.ts +0 -180
@@ -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
+ }
@@ -1,6 +1,7 @@
1
1
  import * as fs from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
  import { randomUUID } from 'node:crypto';
4
+ import { parseEnv } from 'node:util';
4
5
  import { atomic, locked, snapshot } from '../plugins/manager.mjs';
5
6
  import { digest, extract, newer, compatible, version, releaseContract, registryVersion, registryCandidate, download } from './artifact.mjs';
6
7
 
@@ -8,6 +9,11 @@ export const read = async file => JSON.parse(await fs.readFile(file,'utf8'));
8
9
  export const missing = error => {if(error.code!=='ENOENT')throw error;return null;};
9
10
  export const targetId = value => {if(value!=='main'&&!/^[a-z][a-z0-9-]{0,39}$/.test(value))throw Error('Invalid update target');return value;};
10
11
  const jobId=/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/;
12
+ const compatibilityBuild=/^(.+)\.compat\.\d+$/;
13
+ const upgradeable=(candidate,installed)=>{
14
+ const match=compatibilityBuild.exec(installed);
15
+ return newer(candidate,installed)||Boolean(match&&match[1]===candidate);
16
+ };
11
17
  export const updateHome = home => path.join(home,'updates');
12
18
  export async function state(home) {
13
19
  const config=await read(path.join(home,'config.json'));
@@ -39,7 +45,7 @@ export async function check(home) {
39
45
  continue;
40
46
  }
41
47
  const candidate=await registryCandidate(old.pkg.name,p.channel);
42
- results.push({target,installed:old.pkg.version,available:candidate?.version??null,newer:Boolean(candidate&&newer(candidate.version,old.pkg.version)),policy:p,package:old.pkg.name});
48
+ results.push({target,installed:old.pkg.version,available:candidate?.version??null,newer:Boolean(candidate&&upgradeable(candidate.version,old.pkg.version)),policy:p,package:old.pkg.name});
43
49
  }catch(error){results.push({target,error:error.message});}
44
50
  }
45
51
  return results;
@@ -52,27 +58,74 @@ function additiveCommands(previous,next) {
52
58
  for(const [name,command] of Object.entries(previous.commands||{}))if(!same(next.commands?.[name],command))return false;
53
59
  return Object.values(next.commands||{}).every(command=>next.services?.[command.service]);
54
60
  }
61
+ function normalizedLibraryManifest(value) {
62
+ const result=JSON.parse(JSON.stringify(value));delete result.version;delete result.commands?.['library-document'];return result;
63
+ }
64
+ function normalizedLibraryDeployment(value) {
65
+ const result=JSON.parse(JSON.stringify(value));delete result.commands?.['library-document'];
66
+ if(result.commands?.['library-query'])result.commands['library-query'].argv=['node','/app/bin/ez-library.mjs','search'];
67
+ return result;
68
+ }
69
+ export function reviewedPluginDeploymentMigration(target,old,next) {
70
+ if(target!=='library'||old?.manifest?.id!=='library'||old.manifest.version!=='0.1.0-beta.13.qa.4'||next?.manifest?.id!=='library'||next.manifest.version!=='0.1.0-beta.14')return undefined;
71
+ const oldQuery=old.deployment?.commands?.['library-query'],nextQuery=next.deployment?.commands?.['library-query'];
72
+ const expected=['node','/app/bin/ez-library.mjs'];
73
+ if(!same(oldQuery?.argv,[...expected,'query-context'])||!same(nextQuery?.argv,[...expected,'search']))return undefined;
74
+ if(!old.deployment?.commands?.['library-document']||next.deployment?.commands?.['library-document']!==undefined||!old.manifest?.commands?.['library-document']||next.manifest?.commands?.['library-document']!==undefined)return undefined;
75
+ if(!same(normalizedLibraryManifest(old.manifest),normalizedLibraryManifest(next.manifest))||!same(normalizedLibraryDeployment(old.deployment),normalizedLibraryDeployment(next.deployment)))return undefined;
76
+ return {id:'library-query-contract-v1',files:['ez-plugin.json','ez-deployment.json'],changes:['remove library-document','route library-query through search']};
77
+ }
78
+ // Beta.34 added an optional isolation variable and moved a Compose fallback
79
+ // behind the already-bound purpose-file variable. Existing deployments must
80
+ // retain their effective runtime, while future service/volume/privilege changes
81
+ // remain a separately reviewed migration.
82
+ const compatibleRuntimeMigration='legacy-runtime-v1';
83
+ function effectiveCompose(text,env) {
84
+ const lines=text.split(/\r?\n/);
85
+ return lines.filter((line,index)=>{
86
+ const match=/^(\s+)EZ_ISOLATION: \$\{EZ_ISOLATION:-\}\s*$/.exec(line);
87
+ return !(match&&!env.EZ_ISOLATION?.trim()&&lines[index-1]?.trim()==='EZ_AGENT_PURPOSE_FILE: /run/agent-purpose.md');
88
+ }).map(line=>line.replace(/^(\s*file:\s*)\$\{EZ_AGENT_PURPOSE_FILE:-(\.\/templates\/agent\/SOUL\.md|\.\/templates\/agent-purpose\.md)\}(\s*)$/,(whole,prefix,_fallback,suffix)=>
89
+ env.EZ_AGENT_PURPOSE_FILE?.trim()?`${prefix}\${EZ_AGENT_PURPOSE_FILE}${suffix}`:whole)).join('\n');
90
+ }
91
+ async function mainDeployment(home,oldRoot,nextRoot) {
92
+ const oldCompose=await fs.readFile(path.join(oldRoot,'compose.yaml'),'utf8'),nextCompose=await fs.readFile(path.join(nextRoot,'compose.yaml'),'utf8');
93
+ const oldWhatsApp=await fs.readFile(path.join(oldRoot,'compose.whatsapp.yaml')),nextWhatsApp=await fs.readFile(path.join(nextRoot,'compose.whatsapp.yaml'));
94
+ if(oldCompose===nextCompose&&oldWhatsApp.compare(nextWhatsApp)===0)return undefined;
95
+ const {config}=await state(home);
96
+ // Legacy QA homes may predate docker.env. Treat that file as empty while
97
+ // keeping all other read failures fatal; explicit runtime bindings still
98
+ // come from the deployment environment when the file exists.
99
+ const envText=await fs.readFile(path.join(config.deploymentDir,'docker.env'),'utf8').catch(error=>{
100
+ if(error.code==='ENOENT')return '';
101
+ throw error;
102
+ });
103
+ const env=parseEnv(envText);
104
+ if(oldWhatsApp.compare(nextWhatsApp)!==0||effectiveCompose(oldCompose,env)!==effectiveCompose(nextCompose,env))throw Error('Runtime deployment changed; a separately reviewed migration is required');
105
+ return {id:compatibleRuntimeMigration,files:['compose.yaml']};
106
+ }
55
107
  export async function eligibility(home,target,root,automatic) {
56
108
  const old=await installed(home,target),pkg=await read(path.join(root,'package.json')),kind=target==='main'?'main':'plugin';
57
109
  if(pkg.name!==old.pkg.name)throw Error('Package identity mismatch');
58
110
  const next=releaseContract(pkg,kind),prior=releaseContract(old.pkg,kind);
59
111
  if(next.stateSchema!==prior.stateSchema)throw Error('State migration is unsupported by this updater; do not replace the installation');
60
- if(!newer(pkg.version,old.pkg.version))throw Error('Candidate must be newer than the installed version');
61
- let revision;
112
+ if(!upgradeable(pkg.version,old.pkg.version))throw Error('Candidate must be newer than the installed version');
113
+ let revision,deploymentMigration;
62
114
  if(kind==='plugin') {
63
115
  const s=await snapshot(root);revision=s.revision;
64
116
  if(s.manifest.id!==target||s.manifest.version!==pkg.version)throw Error('Plugin identity/version mismatch');
65
- if(!additiveCommands(old.record.deployment,s.deployment))throw Error('Deployment permissions/layout changed; a separately reviewed migration is required');
117
+ deploymentMigration=reviewedPluginDeploymentMigration(target,old.record,{manifest:s.manifest,deployment:s.deployment});
118
+ if(!additiveCommands(old.record.deployment,s.deployment)&&!deploymentMigration)throw Error('Deployment permissions/layout changed; a separately reviewed migration is required');
66
119
  const registry=await read(path.join(home,'registry.json'));
67
120
  for(const alias of Object.keys(s.manifest.commands))if(registry.commands[alias]&&registry.commands[alias]!==target)throw Error('CLI alias collision');
68
121
  }else {
69
- for(const file of ['compose.yaml','compose.whatsapp.yaml'])if(!Buffer.from(await fs.readFile(path.join(root,file))).equals(await fs.readFile(path.join(old.root,file))))throw Error('Runtime deployment changed; a separately reviewed migration is required');
122
+ deploymentMigration=await mainDeployment(home,old.root,root);
70
123
  }
71
124
  if(automatic) {
72
125
  const p=await policy(home,target);
73
126
  if(!p.automatic||!compatible(pkg.version,old.pkg.version)||(p.channel==='stable'&&version(pkg.version).pre))throw Error('Candidate is outside automatic update policy');
74
127
  }
75
- return {old,pkg,revision};
128
+ return {old,pkg,revision,deploymentMigration};
76
129
  }
77
130
  export async function prepare(home,target,{file,release}) {
78
131
  targetId(target);if(Boolean(file)===Boolean(release))throw Error('Supply one --file tarball or --version exact-version');
@@ -87,7 +140,7 @@ export async function prepare(home,target,{file,release}) {
87
140
  const next=await eligibility(home,target,root,false);
88
141
  if(origin.type==='npm'&&next.pkg.version!==origin.version)throw Error('Artifact version differs from registry metadata');
89
142
  await fs.writeFile(path.join(dir,'candidate.tgz'),data,{mode:0o600,flag:'wx'});
90
- const job={id,target,source:root,releaseNotes:path.join(root,'CHANGELOG.md'),status:'prepared',version:next.pkg.version,previousVersion:next.old.pkg.version,previousRoot:next.old.root,sha256:digest(data),origin,createdAt:new Date().toISOString()};
143
+ const job={id,target,source:root,releaseNotes:path.join(root,'CHANGELOG.md'),status:'prepared',version:next.pkg.version,previousVersion:next.old.pkg.version,previousRoot:next.old.root,sha256:digest(data),origin,...(next.deploymentMigration?{deploymentMigration:next.deploymentMigration}:{}),createdAt:new Date().toISOString()};
91
144
  await atomic(path.join(dir,'job.json'),job);return job;
92
145
  } catch(error){await fs.rm(dir,{recursive:true,force:true});throw error;}
93
146
  }
@@ -102,6 +155,23 @@ export async function jobs(home) {
102
155
  const found=await Promise.all(entries.filter(e=>e.isDirectory()&&jobId.test(e.name)).map(e=>read(path.join(jobPath(home,e.name),'job.json')).catch(missing)));
103
156
  return found.filter(Boolean);
104
157
  }
158
+ const jobTime=job=>{const value=Date.parse(job.endedAt||job.createdAt||'');return Number.isFinite(value)?value:0;};
159
+ const laterJob=(a,b)=>jobTime(a)>jobTime(b)||(jobTime(a)===jobTime(b)&&a.id>b.id);
160
+ const terminalStatuses=new Set(['completed','failed','rolled-back']);
161
+ export async function cleanupStaleBackups(home) {
162
+ const records=await jobs(home),latest=new Map();
163
+ for(const job of records.filter(item=>item.status==='completed')) {
164
+ const prior=latest.get(job.target);if(!prior||laterJob(job,prior))latest.set(job.target,job);
165
+ }
166
+ const removed=[];
167
+ for(const job of records) {
168
+ const current=latest.get(job.target);
169
+ if(!terminalStatuses.has(job.status)||!current||current.id===job.id)continue;
170
+ const backup=path.join(jobPath(home,job.id),'backup');
171
+ if(await fs.stat(backup).catch(missing)){await fs.rm(backup,{recursive:true,force:true});removed.push(job.id);}
172
+ }
173
+ return {kept:[...latest.values()].map(job=>job.id),removed};
174
+ }
105
175
  async function requireSupervisor(directory) {
106
176
  const h=await read(path.join(directory,'supervisor.json'));
107
177
  if(!Number.isFinite(h.at)||Date.now()-h.at>10000||h.at>Date.now()+5000)throw Error('Upgrade supervisor is offline or heartbeat is invalid');
@@ -2,18 +2,22 @@ import { sharedIdentity } from '../plugins/shared.mjs';
2
2
  import * as fs from 'node:fs/promises';
3
3
  import { createWriteStream } from 'node:fs';
4
4
  import path from 'node:path';
5
+ import { pathToFileURL } from 'node:url';
5
6
  import { spawn } from 'node:child_process';
7
+ import { parseEnv } from 'node:util';
6
8
  import { atomic, compose, snapshot, checkFolders } from '../plugins/manager.mjs';
7
- import { read, state, eligibility, jobPath } from './control.mjs';
9
+ import { read, state, eligibility, jobPath, cleanupStaleBackups } from './control.mjs';
8
10
  import { bindUpdates } from './binding.mjs';
9
11
  import { extract, digest } from './artifact.mjs';
10
12
 
11
13
  export function environment() {
12
- return Object.fromEntries(['HOME','PATH','LANG','LC_ALL','TMPDIR','DOCKER_HOST','DOCKER_CONTEXT','DOCKER_CONFIG','BUILDX_CONFIG'].filter(k=>process.env[k]!==undefined).map(k=>[k,process.env[k]]));
14
+ return Object.fromEntries(['HOME','PATH','LANG','LC_ALL','TMPDIR','DOCKER_HOST','DOCKER_CONTEXT','DOCKER_CONFIG','BUILDX_CONFIG','EZ_DOCKER_COMPOSE'].filter(k=>process.env[k]!==undefined).map(k=>[k,process.env[k]]));
13
15
  }
14
16
  export function execute(command,args,options={}) {
15
17
  return new Promise((resolve,reject)=>{
16
- const child=spawn(command,args,{cwd:options.cwd,env:environment(),stdio:['ignore','pipe','pipe']});let output='';
18
+ const invocation=process.env.EZ_DOCKER_COMPOSE==='standalone'&&command==='docker'&&args[0]==='compose'
19
+ ? {command:'docker-compose',args:args.slice(1)} : {command,args};
20
+ const child=spawn(invocation.command,invocation.args,{cwd:options.cwd,env:environment(),stdio:['ignore','pipe','pipe']});let output='';
17
21
  const timer=setTimeout(()=>child.kill('SIGKILL'),options.timeout||600000);
18
22
  const file=options.outputFile?createWriteStream(options.outputFile,{flags:'wx',mode:0o600}):null;
19
23
  const written=file?new Promise((resolve,reject)=>{file.once('finish',resolve);file.once('error',reject);}):Promise.resolve();
@@ -21,7 +25,7 @@ export function execute(command,args,options={}) {
21
25
  child.stderr.on('data',b=>{output=(output+b).slice(-16000);});
22
26
  void written.catch(()=>child.kill('SIGKILL'));
23
27
  child.once('error',error=>{clearTimeout(timer);reject(error);});
24
- child.once('close',async code=>{clearTimeout(timer);try{await written;if(code!==0)throw Error(`${command} failed (${code}): ${output}`);resolve(output);}catch(error){reject(error);}});
28
+ child.once('close',async code=>{clearTimeout(timer);try{await written;if(code!==0)throw Error(`${invocation.command} failed (${code}): ${output}`);resolve(output);}catch(error){reject(error);}});
25
29
  });
26
30
  }
27
31
  export async function textAtomic(file,text) {
@@ -43,7 +47,18 @@ export async function packageManager(root,run=execute) {
43
47
  throw Error(`Upgrade prerequisite unavailable: ${required}. ${failures.join('; ')}. Check the host supervisor service PATH (shell aliases do not count). Reuse its installed pnpm or Corepack; expose the launcher directory to that service and restart it after the current turn. If neither exists, provision the pinned manager first. Do not substitute npm install or reinstall the agent. After repair, prepare/apply a new job when status is failed; recover is only for recovery-required.`);
44
48
  }
45
49
  export const relayArgs = config => ['compose','--env-file',path.join(config.deploymentDir,'docker.env')];
46
- const healthCodes = new Set(['RELAY_UNREADABLE','RELAY_NOT_POLLING','RELAY_STALE','HOST_UNREADABLE','HOST_STALE']);
50
+ // The managed guidance block is replaced only during an upgrade, never by
51
+ // relay/host startup. Malformed personal text stays untouched and only logs.
52
+ async function replaceGuidance(root, workspace, run) {
53
+ const entry = path.join(root, 'src', 'agent-guidance.ts');
54
+ const script = `const m = await import(${JSON.stringify(pathToFileURL(entry).href)}); await m.installAgentGuidance(${JSON.stringify(workspace)});`;
55
+ await run(process.execPath, ['--import', path.join(root, 'node_modules/tsx/dist/loader.mjs'), '--input-type=module', '-e', script], { cwd: root, timeout: 60000 });
56
+ }
57
+ export async function relayServices(config) {
58
+ const env = await fs.readFile(path.join(config.deploymentDir,'docker.env'),'utf8').catch(() => '');
59
+ return parseEnv(env).EZ_EXECUTOR_TRANSPORT === 'local' ? ['relay','plugin-broker'] : ['relay'];
60
+ }
61
+ const healthCodes = new Set(['RELAY_UNREADABLE','RELAY_NOT_POLLING','RELAY_STALE','HOST_UNREADABLE','HOST_STALE','PLUGIN_BROKER_UNREADABLE']);
47
62
  async function healthEvidence(config,run) {
48
63
  const id=(await run('docker',[...relayArgs(config),'ps','-q','relay'])).trim();
49
64
  if(!/^[a-f0-9]{12,64}$/i.test(id))return '';
@@ -89,6 +104,8 @@ export async function perform(home,job,hooks) {
89
104
  await fs.copyFile(path.join(root,'docker/pnpm-lock.yaml'),path.join(root,'pnpm-lock.yaml'));
90
105
  await run(job.packageManager.command,[...job.packageManager.args,'install','--frozen-lockfile','--ignore-scripts'],{cwd:root});
91
106
  await run(process.execPath,['--import',path.join(root,'node_modules/tsx/dist/loader.mjs'),path.join(root,'bin/ezenciel-agents.mjs'),'--version'],{cwd:root});
107
+ try { await replaceGuidance(root, config.workspace, run); }
108
+ catch(error) { console.error(`Workspace guidance replacement failed; upgrade continues: ${error.message}`); }
92
109
  const image=`ez-upgrade-${job.sha256.slice(0,24)}`;
93
110
  await run('docker',['build','--target','runtime','-t',image,root]);
94
111
  const envFile=path.join(config.deploymentDir,'docker.env'),oldEnv=await fs.readFile(envFile,'utf8');
@@ -105,13 +122,13 @@ export async function perform(home,job,hooks) {
105
122
  await textAtomic(envFile,env);
106
123
  await bindUpdates(home,path.join(config.deploymentDir,'host-executor.json'),root);
107
124
  await hooks.startHost(root);
108
- await run('docker',[...relayArgs(config),'up','-d','--wait','--wait-timeout','90','--no-build','relay']);
125
+ await run('docker',[...relayArgs(config),'up','-d','--wait','--wait-timeout','90','--no-build',...(await relayServices(config))]);
109
126
  } else {
110
127
  const old=next.old.record,r=await read(path.join(home,'registry.json'));
111
128
  const secrets=await read(path.join(home,'packages',job.target,'secrets.json')).catch(e=>{if(e.code==='ENOENT')return {};throw e;});
112
129
  const s=await snapshot(root),candidate={...old,source:root,revision:s.revision,manifest:s.manifest,deployment:s.deployment,sharedRevisions:s.sharedRevisions};
113
130
  for (const key of old.sharedEnabled || []) if (sharedIdentity(old, key).fingerprint !== sharedIdentity(candidate, key).fingerprint) throw Error('Shared worker changed; disable this client and coordinate an explicit shared worker upgrade before updating');
114
- await checkFolders(config,candidate);
131
+ await checkFolders(config,candidate,home);
115
132
  const stage={...candidate,compose:path.join(dir,'compose.json')};await atomic(stage.compose,await compose(config,stage,secrets,home));
116
133
  for(const [service,spec] of Object.entries(stage.deployment.services))await run('docker',[...pluginArgs(stage),spec.image?'pull':'build',service]);
117
134
  const running=Boolean((await run('docker',[...pluginArgs(old),'ps','-q'])).trim());
@@ -127,7 +144,10 @@ export async function perform(home,job,hooks) {
127
144
  await atomic(path.join(home,'registry.json'),r);
128
145
  job.runtimeVerified=running;
129
146
  }
130
- job.status='completed';job.endedAt=new Date().toISOString();await save();return job;
147
+ job.status='completed';job.endedAt=new Date().toISOString();await save();
148
+ try { job.cleanup=await cleanupStaleBackups(home);await save(); }
149
+ catch(error) { job.cleanupError=error.message;await save().catch(()=>{}); }
150
+ return job;
131
151
  }catch(error){
132
152
  let message=error.message;
133
153
  if(job.target==='main'&&job.rollback&&/is unhealthy/.test(message))message+=await healthEvidence(config,run).catch(()=> '');
@@ -143,7 +163,7 @@ export async function recover(home,job,hooks) {
143
163
  await textAtomic(path.join(config.deploymentDir,'docker.env'),job.rollback.env);
144
164
  await bindUpdates(home,path.join(config.deploymentDir,'host-executor.json'),job.rollback.packageRoot);
145
165
  await hooks.startHost(job.rollback.packageRoot);
146
- await run('docker',[...relayArgs(config),'up','-d','--wait','--wait-timeout','90','--no-build','relay']);
166
+ await run('docker',[...relayArgs(config),'up','-d','--wait','--wait-timeout','90','--no-build',...(await relayServices(config))]);
147
167
  } else {
148
168
  const b=job.rollback;
149
169
  await run('docker',[...pluginArgs(b.record),'stop']);
@@ -1,6 +1,7 @@
1
1
  import path from 'node:path';
2
2
  import { read, state, jobs } from './control.mjs';
3
3
  import { execute, pluginArgs } from './runtime.mjs';
4
+ import { callDeliverySocket } from '../delivery-socket-client.mjs';
4
5
 
5
6
  async function heartbeat(file,maxAge,polling=false) {
6
7
  try {
@@ -10,11 +11,32 @@ async function heartbeat(file,maxAge,polling=false) {
10
11
  }catch(error){return {state:error.code==='ENOENT'?'offline':'unknown',runningVersion:null};}
11
12
  }
12
13
 
14
+ // The relay owns liveness in memory and answers through the control-volume
15
+ // socket; the legacy heartbeat file remains the fallback when it is offline.
16
+ async function relay(control) {
17
+ const status=await callDeliverySocket(path.join(control,'delivery.sock'),{op:'status'},2000).catch(()=>null);
18
+ if(status)return {state:status.polling===true?'running':'offline',runningVersion:typeof status.version==='string'?status.version:null};
19
+ return heartbeat(path.join(control,'heartbeat.json'),20000,true);
20
+ }
21
+
13
22
  async function plugin(record,run) {
14
23
  const result={id:record.manifest.id,installedVersion:record.manifest.version,runningVersion:null,state:'unknown',services:[]};
15
24
  try {
16
- const output=(await run('docker',[...pluginArgs(record),'ps','--all','--format','json'],{timeout:15000})).trim();
17
- const rows=output?output.startsWith('[')?JSON.parse(output):output.split('\n').map(line=>JSON.parse(line)):[];
25
+ let rows;
26
+ if(process.env.EZ_DOCKER_COMPOSE==='standalone') {
27
+ const output=(await run('docker',[...pluginArgs(record),'ps','--all','-q'],{timeout:15000})).trim();
28
+ const ids=output?output.split('\n').filter(Boolean):[];
29
+ rows=await Promise.all(ids.map(async id=>{
30
+ if(!/^[a-f0-9]{12,64}$/i.test(id))throw Error('Invalid container identity');
31
+ const inspected=JSON.parse(await run('docker',['inspect',id],{timeout:15000}))[0];
32
+ const labels=inspected?.Config?.Labels||{},state=inspected?.State||{};
33
+ return {ID:id,Service:labels['com.docker.compose.service'],State:state.Status,Health:state.Health?.Status||null};
34
+ }));
35
+ } else {
36
+ const output=(await run('docker',[...pluginArgs(record),'ps','--all','--format','json'],{timeout:15000})).trim();
37
+ const json=output.slice(output.search(/^[[{]/m));
38
+ rows=!json?[]:json.startsWith('[')?JSON.parse(json):json.split('\n').map(line=>line.trim()).filter(line=>line.startsWith('{')).map(line=>JSON.parse(line));
39
+ }
18
40
  for(const [service,spec] of Object.entries(record.deployment.services)) {
19
41
  const containers=rows.filter(row=>row.Service===service);
20
42
  if(!containers.length){result.services.push({service,state:'not-created',health:null,imageMatches:false});continue;}
@@ -47,7 +69,7 @@ export async function status(home,run=execute) {
47
69
  let installedVersion=null;
48
70
  try {installedVersion=(await read(path.join(config.packageRoot,'package.json'))).version;}catch {}
49
71
  return {
50
- main:{installedVersion,...await heartbeat(path.join(agent.controlDir,'heartbeat.json'),20000,true),
72
+ main:{installedVersion,...await relay(agent.controlDir),
51
73
  host:await heartbeat(path.join(agent.controlDir,'host-executor/heartbeat.json'),15000)},
52
74
  plugins:await Promise.all(Object.values(registry.plugins).map(record=>plugin(record,run))),
53
75
  jobs:(await jobs(home)).map(({rollback,...job})=>job)
@@ -3,21 +3,30 @@ import path from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
4
  import { spawn } from 'node:child_process';
5
5
  import { atomic, locked } from '../plugins/manager.mjs';
6
- import { state, read, jobs, jobPath, check, missing } from './control.mjs';
6
+ import { callDeliverySocket } from '../delivery-socket-client.mjs';
7
+ import { state, read, jobs, jobPath, check, missing, cleanupStaleBackups } from './control.mjs';
7
8
  import { perform, environment } from './runtime.mjs';
8
- import { digest } from './artifact.mjs';
9
+
10
+ const reservedProviderKeys=new Set(['HOME','LANG','LC_ALL','LOGNAME','PATH','SHELL','TERM','TMPDIR','USER','CODEX_HOME','NODE_OPTIONS']);
11
+ const providerEnvironmentKey=value=>{
12
+ if(typeof value!=='string'||value.length>64||!/^[A-Z][A-Z0-9_]{1,63}$/.test(value))throw Error('Invalid Codex provider environment key');
13
+ if(reservedProviderKeys.has(value)||value.startsWith('EZ_')||value.startsWith('TELEGRAM_'))throw Error('Reserved Codex provider environment key');
14
+ return value;
15
+ };
16
+
17
+ export function providerEnvironment(host, environment=process.env) {
18
+ const names=(host.agents||[]).flatMap(agent=>(agent.codexProviders||[]).map(provider=>providerEnvironmentKey(provider.envKey)));
19
+ return Object.fromEntries([...new Set(names)].flatMap(name=>environment[name]===undefined?[]:[[name,environment[name]]]));
20
+ }
9
21
 
10
22
  const sleep=ms=>new Promise(resolve=>setTimeout(resolve,ms));
11
23
  export async function idle(control) {
12
24
  const host=await fs.readdir(path.join(control,'host-executor')).catch(e=>{if(e.code==='ENOENT')return [];throw e;});
13
25
  if(host.some(f=>f.endsWith('.running.json')||f.endsWith('.request.json')))return false;
14
- for(const file of await fs.readdir(path.join(control,'runs')).catch(e=>{if(e.code==='ENOENT')return [];throw e;})) {
15
- if(file.endsWith('.json')&&(await read(path.join(control,'runs',file))).status==='running')return false;
16
- }
17
- return true;
18
- }
19
- export async function notice(control,key) {
20
- await atomic(path.join(control,'update-attention.json'),{id:digest(key)});
26
+ // Run records live in relay memory: the live socket status is the only
27
+ // cross-process view of active work. A down relay cannot own a run.
28
+ const status=await callDeliverySocket(path.join(control,'delivery.sock'),{op:'status'},2000).catch(()=>null);
29
+ return !status||status.running===0;
21
30
  }
22
31
  export async function supervise(deployment,signal,{discover=check}={}) {
23
32
  const host=await read(path.join(deployment,'host-executor.json'));
@@ -38,8 +47,9 @@ export async function supervise(deployment,signal,{discover=check}={}) {
38
47
  const timer=setTimeout(()=>running.kill('SIGKILL'),10000);await closed;clearTimeout(timer);
39
48
  };
40
49
  const startHost=async root=>{
50
+ if(host.isolation==='isolated')return
41
51
  child=spawn(process.execPath,['--import',path.join(root,'node_modules/tsx/dist/loader.mjs'),path.join(root,'src/host-executor.ts'),path.join(deployment,'host-executor.json')],
42
- {env:{...environment(),EZ_HOST_SUPERVISOR_PID:String(process.pid)},stdio:['ignore','inherit','inherit']});
52
+ {env:{...environment(),...providerEnvironment(host),EZ_HOST_SUPERVISOR_PID:String(process.pid)},stdio:['ignore','inherit','inherit']});
43
53
  let error;child.once('error',e=>{error=e;});
44
54
  for(let n=0;n<100;n++) {
45
55
  if(error||child.exitCode!==null)throw error||Error('Host transport failed to start');
@@ -53,6 +63,8 @@ export async function supervise(deployment,signal,{discover=check}={}) {
53
63
  const beat=setInterval(()=>{void atomic(path.join(directory,'supervisor.json'),{pid:process.pid,at:Date.now()}).catch(()=>{});},1000);
54
64
  try {
55
65
  await atomic(path.join(directory,'supervisor.json'),{pid:process.pid,at:Date.now()});
66
+ try { await cleanupStaleBackups(home); }
67
+ catch(error) { console.error(`Update backup cleanup failed; host remains running: ${error.message}`); }
56
68
  // Reclaim only a lock left by this deployment's dead supervisor, including
57
69
  // a crash between queue claim and the first transaction journal write.
58
70
  const registryLock=path.join(home,'registry.lock'),owner=await read(registryLock).catch(missing);
@@ -61,11 +73,14 @@ export async function supervise(deployment,signal,{discover=check}={}) {
61
73
  await sleep(1200);
62
74
  const interrupted=(await jobs(home)).find(j=>j.status==='applying');
63
75
  if(interrupted){
64
- await atomic(pause,{id:interrupted.id});await locked(home,()=>perform(home,interrupted,{stopHost,startHost}));await fs.rm(pause,{force:true});await notice(agent.controlDir,interrupted.id);}
65
- if(!child)await startHost((await state(home)).config.packageRoot);
76
+ await atomic(pause,{id:interrupted.id});const result=await locked(home,()=>perform(home,interrupted,{stopHost,startHost}));await fs.rm(pause,{force:true});
77
+ if(result.status==='completed'&&interrupted.target==='main')return;
78
+ }
79
+ const isolated=host.isolation==='isolated'
80
+ if(!isolated && !child)await startHost((await state(home)).config.packageRoot);
66
81
  let nextCheck=0;
67
82
  while(!signal.aborted) {
68
- if(child?.exitCode!==null&&child?.exitCode!==undefined)throw Error('Host transport exited; supervisor service should restart');
83
+ if(!isolated && child?.exitCode!==null&&child?.exitCode!==undefined)throw Error('Host transport exited; supervisor service should restart');
69
84
  const pending=(await jobs(home)).find(j=>j.status==='queued');
70
85
  if(pending) {
71
86
  await atomic(pause,{id:pending.id});
@@ -73,22 +88,20 @@ export async function supervise(deployment,signal,{discover=check}={}) {
73
88
  let quiet=0;
74
89
  while(!signal.aborted&&quiet<3){quiet=await idle(agent.controlDir)?quiet+1:0;await sleep(500);}
75
90
  if(signal.aborted)break;
91
+ let result;
76
92
  try {
77
- await locked(home,async()=>{const latest=await read(path.join(jobPath(home,pending.id),'job.json'));await perform(home,latest,{stopHost,startHost});});
93
+ result=await locked(home,async()=>{const latest=await read(path.join(jobPath(home,pending.id),'job.json'));return perform(home,latest,{stopHost,startHost});});
78
94
  } catch(error) {
79
95
  // A pre-switch rejection is terminal. Applying jobs keep their journal for recovery.
80
96
  const latest=await read(path.join(jobPath(home,pending.id),'job.json'));
81
97
  if(latest.status==='queued'){latest.status='failed';latest.error=error.message;await atomic(path.join(jobPath(home,pending.id),'job.json'),latest);}else throw error;
82
98
  } finally {await fs.rm(pause,{force:true});}
83
- await notice(agent.controlDir,pending.id);
99
+ if(result?.status==='completed'&&pending.target==='main')return;
84
100
  }
85
101
  if(Date.now()>=nextCheck) {
86
102
  nextCheck=Date.now()+6*60*60*1000;
87
103
  try {
88
104
  const results=await discover(home);await atomic(path.join(directory,'available.json'),results);
89
- const available=results.filter(r=>r.newer&&r.policy.automatic);
90
- const key=digest(JSON.stringify(available)),saved=await read(path.join(directory,'discovery.json')).catch(missing);
91
- if(available.length&&saved?.key!==key){await notice(agent.controlDir,key);await atomic(path.join(directory,'discovery.json'),{key});}
92
105
  } catch {
93
106
  // Discovery is optional; its failure must not terminate the host.
94
107
  // Do not expose registry response bodies or credentials in logs.
package/src/version.ts CHANGED
@@ -2,3 +2,23 @@ import { readFileSync } from 'node:fs'
2
2
 
3
3
  // Capture the loaded package version once; an upgrade must not relabel old processes.
4
4
  export const packageVersion: string = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version
5
+
6
+ // Optional image build identity (tag + commit), baked by Docker builds that pass
7
+ // BUILD_TAG/BUILD_SHA. Absent for source checkouts and CI builds without args.
8
+ export const buildInfo: { tag?: string; sha?: string } = (() => {
9
+ try {
10
+ const info = JSON.parse(readFileSync(new URL('../build.json', import.meta.url), 'utf8'))
11
+ const tag = typeof info.tag === 'string' && info.tag ? info.tag : undefined
12
+ const sha = typeof info.sha === 'string' && info.sha ? info.sha : undefined
13
+ return { ...(tag ? { tag } : {}), ...(sha ? { sha } : {}) }
14
+ } catch { return {} }
15
+ })()
16
+
17
+ export const formatRelayLabel = (version: string, tag?: string, sha?: string): string => {
18
+ const short = sha && sha.length > 12 ? sha.slice(0, 12) : sha
19
+ if (tag) return `${tag} (v${version}${short ? ` · ${short}` : ''})`
20
+ return short ? `v${version} (${short})` : `v${version}`
21
+ }
22
+
23
+ export const relayVersionLabel = (): string =>
24
+ formatRelayLabel(packageVersion, buildInfo.tag, buildInfo.sha)