@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.
- package/.dockerignore +1 -0
- package/.env.example +9 -16
- package/AGENTS.md +17 -9
- package/CHANGELOG.md +92 -0
- package/Dockerfile +11 -9
- package/README.md +32 -27
- package/THIRD_PARTY_NOTICES.md +4 -4
- package/bin/ez +97 -0
- package/bin/ezenciel-agents-plugin-broker.mjs +16 -0
- package/bin/ezenciel-agents-provision-telegram.mjs +13 -0
- package/bin/ezenciel-agents.mjs +1 -1
- package/compose.yaml +42 -5
- package/docker/entrypoint.sh +1 -1
- package/docker/healthcheck.mjs +12 -4
- package/docker/run.ts +5 -6
- package/docker/smoke.mjs +32 -7
- package/docs/application-channel.md +38 -10
- package/docs/architecture/ai-selection.md +3 -1
- package/docs/architecture/authority-boundaries.md +2 -2
- package/docs/channel-backend.md +4 -3
- package/docs/docker-runtime.md +106 -21
- package/docs/host-service.md +17 -6
- package/docs/local-qa.md +2 -2
- package/docs/plugin-guide.md +202 -0
- package/docs/plugins.md +63 -2
- package/docs/releasing.md +13 -0
- package/docs/repair.md +1 -1
- package/docs/responsive-channels.md +1 -1
- package/docs/scheduling.md +4 -4
- package/docs/setup.md +96 -16
- package/docs/standalone-cli.md +6 -5
- package/docs/upgrades.md +46 -23
- package/package.json +5 -5
- package/scripts/smoke-scheduler.ts +12 -4
- package/scripts/smoke.ts +14 -11
- package/src/agent-guidance.ts +6 -9
- package/src/agent-install.ts +44 -9
- package/src/ai-cli.ts +9 -7
- package/src/ai.ts +7 -7
- package/src/application-channel.ts +88 -17
- package/src/application-cli.ts +2 -2
- package/src/approval-cli.ts +14 -7
- package/src/approval.ts +27 -37
- package/src/channel-backend.ts +2 -3
- package/src/codex-session.ts +5 -3
- package/src/config.ts +18 -28
- package/src/control-state.ts +187 -9
- package/src/conversation-menu.ts +6 -17
- package/src/delivery-socket-client.d.mts +4 -0
- package/src/delivery-socket-client.mjs +104 -0
- package/src/delivery-socket.ts +354 -0
- package/src/execution-authority.ts +21 -5
- package/src/executor.ts +110 -31
- package/src/files.ts +38 -11
- package/src/host-executor.ts +50 -17
- package/src/inbox.ts +15 -49
- package/src/index.ts +96 -52
- package/src/install-config.ts +2 -2
- package/src/install-tools.mjs +100 -23
- package/src/isolation.ts +25 -0
- package/src/ledger-endpoint.d.mts +2 -0
- package/src/ledger-endpoint.mjs +17 -0
- package/src/menu.ts +16 -8
- package/src/message-history.ts +13 -18
- package/src/message.ts +35 -18
- package/src/plugin-broker.mjs +396 -0
- package/src/plugins/connection.mjs +2 -2
- package/src/plugins/manager.mjs +178 -26
- package/src/plugins/native-tasks.mjs +2 -2
- package/src/prompt-suffix.ts +12 -0
- package/src/react.ts +6 -4
- package/src/runs.ts +156 -243
- package/src/schedule-cli.ts +29 -10
- package/src/scheduled-tasks.ts +53 -10
- package/src/scheduler.ts +20 -7
- package/src/service.ts +3 -3
- package/src/setup.ts +11 -7
- package/src/software-status.ts +36 -4
- package/src/task-executor.ts +6 -3
- package/src/task-workspace.ts +4 -3
- package/src/tasks.ts +1 -1
- package/src/telegram-provisioning-cli.ts +22 -0
- package/src/telegram-provisioning.ts +214 -0
- package/src/updates/control.mjs +77 -7
- package/src/updates/runtime.mjs +29 -9
- package/src/updates/status.mjs +25 -3
- package/src/updates/supervisor.mjs +31 -18
- package/src/version.ts +20 -0
- package/src/workspace-confine.ts +38 -0
- package/src/workspace.ts +24 -20
- package/templates/agent-purpose.md +1 -0
- package/templates/failure-review.md +1 -1
- package/templates/maintainer-purpose.md +1 -1
- package/test/agent-guidance.test.ts +18 -88
- package/test/agent-install.test.ts +48 -0
- package/test/ai-cli.test.ts +18 -0
- package/test/ai.test.ts +84 -0
- package/test/application-channel.test.ts +66 -15
- package/test/application-controls.test.ts +56 -3
- package/test/application-only.test.ts +16 -13
- package/test/channel-backend.test.ts +3 -7
- package/test/channel-delivery.test.ts +4 -1
- package/test/channel-owner.test.ts +6 -3
- package/test/codex-context.test.ts +63 -3
- package/test/codex-session.test.ts +6 -1
- package/test/config.test.ts +47 -37
- package/test/control-state.test.ts +83 -4
- package/test/conversation-menu.test.ts +2 -4
- package/test/delivery-transport.test.ts +72 -0
- package/test/docker-runtime.test.ts +27 -14
- package/test/engine-handoff.test.ts +12 -8
- package/test/event-sources.test.ts +2 -2
- package/test/execution-authority.test.ts +9 -9
- package/test/executor.test.ts +47 -11
- package/test/failure.test.ts +18 -5
- package/test/files.test.ts +2 -2
- package/test/helpers/ledger.ts +18 -0
- package/test/host-executor.test.ts +80 -0
- package/test/inbox.test.ts +8 -10
- package/test/install-config.test.ts +18 -6
- package/test/install-tools.test.mjs +80 -7
- package/test/intake-relay.test.ts +58 -11
- package/test/message-history.test.ts +39 -47
- package/test/native-tasks.test.ts +1 -1
- package/test/plugin-broker.test.mjs +172 -0
- package/test/plugin-manager.test.mjs +143 -5
- package/test/reaction.test.ts +4 -0
- package/test/relay.test.ts +5 -7
- package/test/repair-policy.test.ts +1 -4
- package/test/schedule-cli.test.ts +14 -9
- package/test/scheduled-tasks.test.ts +29 -6
- package/test/scheduler-host.test.ts +5 -5
- package/test/scheduler-relay.test.ts +15 -9
- package/test/scheduler.test.ts +2 -2
- package/test/security.test.ts +6 -3
- package/test/software-status.test.ts +21 -3
- package/test/telegram-provisioning.test.ts +160 -0
- package/test/updates.test.mjs +102 -13
- package/test/workspace-confine.test.ts +47 -0
- package/test/workspace.test.ts +27 -11
- package/bin/ezenciel-agents-watch.mjs +0 -8
- package/compose.workforce-watch.yaml +0 -33
- package/docker/recovery.ts +0 -11
- package/docs/pagerduty.md +0 -42
- package/docs/workforce-watch.md +0 -101
- package/src/pagerduty.ts +0 -109
- package/src/reply-context.ts +0 -44
- package/src/update-attention.ts +0 -18
- package/src/workforce-watch-cli.ts +0 -14
- package/src/workforce-watch.ts +0 -155
- package/templates/agent/AGENTS.md +0 -15
- package/templates/agent/SOUL.md +0 -11
- package/templates/agent/USER.md +0 -5
- package/templates/agent-guidance.md +0 -114
- package/test/pagerduty.test.ts +0 -104
- package/test/update-attention.test.ts +0 -20
- 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
|
+
}
|
package/src/updates/control.mjs
CHANGED
|
@@ -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&&
|
|
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(!
|
|
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
|
-
|
|
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]&®istry.commands[alias]!==target)throw Error('CLI alias collision');
|
|
68
121
|
}else {
|
|
69
|
-
|
|
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');
|
package/src/updates/runtime.mjs
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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'
|
|
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();
|
|
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'
|
|
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']);
|
package/src/updates/status.mjs
CHANGED
|
@@ -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
|
-
|
|
17
|
-
|
|
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
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
}
|
|
17
|
-
return
|
|
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});
|
|
65
|
-
|
|
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'));
|
|
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
|
-
|
|
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)
|