@jc_stack/ez-agents 0.1.0-beta.19 → 0.1.0-beta.21
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/CHANGELOG.md +16 -0
- package/docker/healthcheck.mjs +11 -4
- package/docs/architecture/ai-selection.md +4 -2
- package/docs/docker-runtime.md +7 -5
- package/package.json +1 -1
- package/src/index.ts +1 -1
- package/src/menu.ts +14 -8
- package/src/updates/runtime.mjs +13 -1
- package/templates/agent-guidance.md +13 -0
- package/test/agent-guidance.test.ts +13 -0
- package/test/ai.test.ts +40 -0
- package/test/failure.test.ts +27 -1
- package/test/intake-relay.test.ts +11 -3
- package/test/updates.test.mjs +15 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.0-beta.21
|
|
4
|
+
|
|
5
|
+
- Initialize fresh Telegram bots before reading their identity. Beta.19 could
|
|
6
|
+
retry forever before polling, causing main upgrades to fail their health gate.
|
|
7
|
+
- Restore the unchanged deployment layout required for upgrades from beta.18
|
|
8
|
+
and beta.19; extending health grace does not fix the initialization failure.
|
|
9
|
+
- Include beta.20 health diagnostics. Preserve the earlier immutable unpublished
|
|
10
|
+
candidate; existing beta acceptance limits remain.
|
|
11
|
+
|
|
12
|
+
## 0.1.0-beta.20
|
|
13
|
+
|
|
14
|
+
- Retain a bounded, content-free health predicate in a failed main-upgrade
|
|
15
|
+
receipt before rollback replaces the candidate relay. This distinguishes relay
|
|
16
|
+
polling and host-executor heartbeat failures without persisting control-state,
|
|
17
|
+
provider, environment, or Docker diagnostic content.
|
|
18
|
+
|
|
3
19
|
## 0.1.0-beta.19
|
|
4
20
|
|
|
5
21
|
- Preserve host-backed run completion and delivery evidence while the relay
|
package/docker/healthcheck.mjs
CHANGED
|
@@ -1,8 +1,15 @@
|
|
|
1
1
|
import { readFileSync } from 'node:fs';
|
|
2
|
-
const
|
|
3
|
-
|
|
2
|
+
const relayControl = process.env.EZ_HEALTH_RELAY_CONTROL_DIR || '/state/control';
|
|
3
|
+
const fail = code => { process.stderr.write(`EZ_HEALTH_${code}\n`); process.exit(1); };
|
|
4
|
+
let value;
|
|
5
|
+
try { value = JSON.parse(readFileSync(relayControl + '/heartbeat.json', 'utf8')); }
|
|
6
|
+
catch { fail('RELAY_UNREADABLE'); }
|
|
7
|
+
if (!value.polling) fail('RELAY_NOT_POLLING');
|
|
8
|
+
if (!Number.isFinite(value.at) || Date.now() - value.at > 20000) fail('RELAY_STALE');
|
|
4
9
|
|
|
5
10
|
if (process.env.EZ_EXECUTOR_TRANSPORT === 'host') {
|
|
6
|
-
|
|
7
|
-
|
|
11
|
+
let host;
|
|
12
|
+
try { host = JSON.parse(readFileSync(process.env.EZ_CONTROL_DIR + '/host-executor/heartbeat.json', 'utf8')); }
|
|
13
|
+
catch { fail('HOST_UNREADABLE'); }
|
|
14
|
+
if (!Number.isFinite(host.at) || Date.now() - host.at > 15000) fail('HOST_STALE');
|
|
8
15
|
}
|
|
@@ -28,8 +28,10 @@ offer their own default only in this slice. Refresh by opening the native client
|
|
|
28
28
|
the relay does not install models, manage subscriptions or guess aliases.
|
|
29
29
|
|
|
30
30
|
Setup initialization and relay startup seed one default choice per installed client.
|
|
31
|
-
|
|
32
|
-
|
|
31
|
+
Choose AI opens the available installed-model catalog directly; Settings keeps
|
|
32
|
+
saved choices and Refresh available AIs repeats default discovery. Active/default
|
|
33
|
+
presets and queued snapshots are preserved; discovery only refreshes unused
|
|
34
|
+
detected entries.
|
|
33
35
|
Codex uses its native `config/read` interface; Grok reads its documented user
|
|
34
36
|
model/effort settings (or `models` for the default model). Claude reads user and
|
|
35
37
|
workspace JSON settings; OpenCode reports resolved config. Unknown defaults and
|
package/docs/docker-runtime.md
CHANGED
|
@@ -61,11 +61,13 @@ Live smoke uses the same host CLI and requires an actual Telegram receipt.
|
|
|
61
61
|
Restart preserves pairing and files. One kernel lock excludes relay/smoke
|
|
62
62
|
writers; exit 73 means a writer is active. Do not delete its lock to bypass it.
|
|
63
63
|
Health requires recent polling and host-transport heartbeats, not just a process.
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
64
|
+
Relay replacement retains the existing 30-second startup grace and deployment
|
|
65
|
+
layout, so compatible releases remain eligible for agent-owned upgrades.
|
|
66
|
+
An interrupted Telegram poller keeps authorized work and in-flight outbox writes
|
|
67
|
+
alive while it retries intake. A `409 Conflict` still requires the operator to
|
|
68
|
+
stop the competing poller; it remains unhealthy after the startup grace. Pending
|
|
69
|
+
and uncertain deliveries keep their existing outbox/receipt semantics; executor
|
|
70
|
+
stdout is not replayed as a reply.
|
|
69
71
|
Signal and fatal-error paths share one shutdown; a secondary cleanup error is
|
|
70
72
|
reported without replacing the original startup/polling failure.
|
|
71
73
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jc_stack/ez-agents",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
3
|
+
"version": "0.1.0-beta.21",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "A lightweight foundation for persistent business AI assistants using existing AI harnesses, workspaces and plugins.",
|
package/src/index.ts
CHANGED
|
@@ -1029,7 +1029,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
1029
1029
|
await bot.api.deleteWebhook({ drop_pending_updates: false })
|
|
1030
1030
|
await bot.api.setMyCommands(commands)
|
|
1031
1031
|
await bot.api.setMyCommands(commands, { scope: { type: 'all_private_chats' } })
|
|
1032
|
-
|
|
1032
|
+
await bot.init()
|
|
1033
1033
|
await bot.start({
|
|
1034
1034
|
drop_pending_updates: false,
|
|
1035
1035
|
onStart: (botInfo) => console.log(`✓ Bot @${botInfo.username} polling for messages...`),
|
package/src/menu.ts
CHANGED
|
@@ -53,9 +53,14 @@ export const createAiMenu = (control: ControlStore, cli: string, catalog = readM
|
|
|
53
53
|
: 'Selected for this conversation. Queued work unchanged.'}`)
|
|
54
54
|
}
|
|
55
55
|
const list = async (ctx: Context, settings = false) => {
|
|
56
|
+
if (!settings) {
|
|
57
|
+
const models = await catalog()
|
|
58
|
+
if (models.length) return available(ctx, 0, models)
|
|
59
|
+
}
|
|
56
60
|
const state = await control.aiState(initial)
|
|
57
61
|
const keyboard = new InlineKeyboard()
|
|
58
|
-
|
|
62
|
+
const presets = settings ? state.presets : state.presets.filter((preset) => preset.id === initial.id)
|
|
63
|
+
for (const preset of presets) button(keyboard,
|
|
59
64
|
`${preset.id === (settings ? state.defaultId : state.selectedId) ? '✓ ' : ''}${preset.name}`,
|
|
60
65
|
async (next) => {
|
|
61
66
|
if (settings) {
|
|
@@ -64,16 +69,17 @@ export const createAiMenu = (control: ControlStore, cli: string, catalog = readM
|
|
|
64
69
|
await next.reply(`Default: ${preset.name}. Applies to new conversations only.`)
|
|
65
70
|
} else await choose(next, preset)
|
|
66
71
|
})
|
|
67
|
-
button(keyboard, '
|
|
68
|
-
|
|
72
|
+
button(keyboard, 'Browse available models', (next) => available(next))
|
|
73
|
+
button(keyboard, 'Refresh available AIs', async (next) => {
|
|
69
74
|
await refresh()
|
|
70
75
|
await list(next, true)
|
|
71
76
|
})
|
|
72
|
-
await ctx.reply(settings
|
|
73
|
-
|
|
77
|
+
await ctx.reply(settings
|
|
78
|
+
? 'Default for new conversations\nChoose a saved AI. Current work will not change.'
|
|
79
|
+
: 'Choose AI\nNo client catalog available. Showing the current client setup only.', { reply_markup: keyboard })
|
|
74
80
|
}
|
|
75
|
-
const available = async (ctx: Context, page = 0) => {
|
|
76
|
-
const models = await catalog()
|
|
81
|
+
const available = async (ctx: Context, page = 0, listed?: ModelChoice[]) => {
|
|
82
|
+
const models = listed ?? await catalog()
|
|
77
83
|
const keyboard = new InlineKeyboard()
|
|
78
84
|
for (const model of models.slice(page * 8, page * 8 + 8)) {
|
|
79
85
|
button(keyboard, `${model.cli} · ${model.name}`, async (next) => {
|
|
@@ -86,7 +92,7 @@ export const createAiMenu = (control: ControlStore, cli: string, catalog = readM
|
|
|
86
92
|
if (page > 0) button(keyboard, 'Previous', (next) => available(next, page - 1))
|
|
87
93
|
if (models.length > (page + 1) * 8) button(keyboard, 'Next', (next) => available(next, page + 1))
|
|
88
94
|
await ctx.reply(models.length
|
|
89
|
-
? '
|
|
95
|
+
? 'Choose AI\nAvailable models are populated automatically from the installed clients. Grok/Codex use their local catalog; other clients use their own default. Choosing one saves it; it does not switch AI.'
|
|
90
96
|
: 'No client catalog available. Open the installed CLI once, then try again.', { reply_markup: keyboard })
|
|
91
97
|
}
|
|
92
98
|
const save = async (ctx: Context, model: ModelChoice, effort?: string) => {
|
package/src/updates/runtime.mjs
CHANGED
|
@@ -43,6 +43,14 @@ export async function packageManager(root,run=execute) {
|
|
|
43
43
|
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
44
|
}
|
|
45
45
|
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']);
|
|
47
|
+
async function healthEvidence(config,run) {
|
|
48
|
+
const id=(await run('docker',[...relayArgs(config),'ps','-q','relay'])).trim();
|
|
49
|
+
if(!/^[a-f0-9]{12,64}$/i.test(id))return '';
|
|
50
|
+
const raw=await run('docker',['inspect','--format','{{json .State.Health}}',id]);
|
|
51
|
+
const health=JSON.parse(raw),entry=health?.Log?.at(-1),match=typeof entry?.Output==='string'&&entry.Output.match(/^EZ_HEALTH_([A-Z_]+)\s*$/);
|
|
52
|
+
return match&&healthCodes.has(match[1])?` (health=${match[1].toLowerCase().replaceAll('_','-')})`:'';
|
|
53
|
+
}
|
|
46
54
|
function envValue(text,key,value) {
|
|
47
55
|
if(/[\r\n\0']/.test(value))throw Error('Unsafe deployment value');
|
|
48
56
|
const line=`${key}='${value}'`;
|
|
@@ -118,7 +126,11 @@ export async function perform(home,job,hooks) {
|
|
|
118
126
|
job.runtimeVerified=running;
|
|
119
127
|
}
|
|
120
128
|
job.status='completed';job.endedAt=new Date().toISOString();await save();return job;
|
|
121
|
-
}catch(error){
|
|
129
|
+
}catch(error){
|
|
130
|
+
let message=error.message;
|
|
131
|
+
if(job.target==='main'&&job.rollback&&/is unhealthy/.test(message))message+=await healthEvidence(config,run).catch(()=> '');
|
|
132
|
+
job.error=message;await save();return recover(home,job,hooks);
|
|
133
|
+
}
|
|
122
134
|
}
|
|
123
135
|
export async function recover(home,job,hooks) {
|
|
124
136
|
const run=hooks.execute||execute,{config}=await state(home);
|
|
@@ -12,6 +12,19 @@ delegation, model, and effort from the task—not a fixed routing rule. Keep one
|
|
|
12
12
|
writer per workspace; the primary agent owns integration, verification, and
|
|
13
13
|
external actions.
|
|
14
14
|
|
|
15
|
+
## AI selection
|
|
16
|
+
|
|
17
|
+
An explicit owner request to change this conversation's AI or reasoning effort
|
|
18
|
+
is a supported Ez control, not a request to edit the host Codex configuration,
|
|
19
|
+
inspect a native session record, or restart the runtime. Run
|
|
20
|
+
`ezenciel-agents-ai list`, then select only a returned choice with
|
|
21
|
+
`ezenciel-agents-ai select --cli <cli> --model <model> --effort <effort>`.
|
|
22
|
+
This changes subsequent owner messages only; a running or queued job retains
|
|
23
|
+
its captured choice, and the installation default is unchanged. Switching CLI
|
|
24
|
+
starts a fresh native conversation while preserving the workspace. Report the
|
|
25
|
+
confirmed selected choice from the command output; do not infer it from a
|
|
26
|
+
host-level setting or the current native session.
|
|
27
|
+
|
|
15
28
|
## Telegram replies
|
|
16
29
|
|
|
17
30
|
Use the messaging CLI for the current run's source chat, normally the paired
|
|
@@ -45,6 +45,19 @@ test('shared guidance teaches source-chat delivery and real Telegram line breaks
|
|
|
45
45
|
assert.ok(shared.includes('ezenciel-agents-message --text-file ./work/reply.md'))
|
|
46
46
|
})
|
|
47
47
|
|
|
48
|
+
test('shared guidance makes owner AI selection a relay control, not host configuration', async () => {
|
|
49
|
+
const shared = await readFile(sharedGuidancePath, 'utf8')
|
|
50
|
+
for (const prompt of [
|
|
51
|
+
executorJobPrompt('tg_owner', ['change to Terra medium']),
|
|
52
|
+
desktopJobPrompt('tg_owner_gui', ['change to Terra medium'], undefined, '/tmp/bin', '/tmp/control'),
|
|
53
|
+
]) {
|
|
54
|
+
assert.ok(prompt.includes('`ezenciel-agents-ai list`'))
|
|
55
|
+
assert.ok(prompt.includes('`ezenciel-agents-ai select --cli <cli> --model <model> --effort <effort>`'))
|
|
56
|
+
assert.ok(prompt.includes('not a request to edit the host Codex configuration'))
|
|
57
|
+
assert.match(prompt, /a running or queued job retains\s+its captured choice/)
|
|
58
|
+
}
|
|
59
|
+
})
|
|
60
|
+
|
|
48
61
|
test('package guidance resolution ignores a workspace shadow file', async () => {
|
|
49
62
|
const root = path.join(tmpdir(), `ez-guidance-${randomUUID()}`)
|
|
50
63
|
await mkdir(root, { recursive: true })
|
package/test/ai.test.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { tmpdir } from 'node:os'
|
|
|
5
5
|
import { join } from 'node:path'
|
|
6
6
|
import { ControlStore } from '../src/control-state.js'
|
|
7
7
|
import { initialPreset, chatPreset, readModels, isPreset } from '../src/ai.js'
|
|
8
|
+
import { createAiMenu } from '../src/menu.js'
|
|
8
9
|
import { EXECUTOR_REGISTRY, nativeSessionId } from '../src/executor.js'
|
|
9
10
|
import { InboxStore } from '../src/inbox.js'
|
|
10
11
|
import type { Update } from 'grammy/types'
|
|
@@ -92,6 +93,45 @@ test('model catalog projects native metadata only, excluding hidden entries and
|
|
|
92
93
|
} finally { await rm(home, { recursive: true, force: true }) }
|
|
93
94
|
})
|
|
94
95
|
|
|
96
|
+
test('Choose AI opens the available installed-model catalog without an Add AI step', async () => {
|
|
97
|
+
const dir = await mkdtemp(join(tmpdir(), 'ez-ai-menu-'))
|
|
98
|
+
try {
|
|
99
|
+
const menu = createAiMenu(new ControlStore(dir, 1000), 'grok', async () => [{
|
|
100
|
+
cli: 'codex', model: 'fixture-model', name: 'Fixture', efforts: ['medium'],
|
|
101
|
+
}])
|
|
102
|
+
let reply = ''
|
|
103
|
+
let keyboard: { inline_keyboard?: Array<Array<{ text: string }>> } | undefined
|
|
104
|
+
await menu.list({ reply: async (text: string, options?: { reply_markup?: unknown }) => {
|
|
105
|
+
reply = text
|
|
106
|
+
keyboard = options?.reply_markup as typeof keyboard
|
|
107
|
+
return {} as never
|
|
108
|
+
} } as never)
|
|
109
|
+
assert.match(reply, /Available models are populated automatically/)
|
|
110
|
+
assert.deepEqual(keyboard?.inline_keyboard?.flat().map((button) => button.text), ['codex · Fixture'])
|
|
111
|
+
} finally { await rm(dir, { recursive: true, force: true }) }
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
test('Choose AI does not expose saved model choices without a catalog to validate them', async () => {
|
|
115
|
+
const dir = await mkdtemp(join(tmpdir(), 'ez-ai-menu-empty-'))
|
|
116
|
+
try {
|
|
117
|
+
const store = new ControlStore(dir, 1000)
|
|
118
|
+
await store.aiState(initialPreset('grok'))
|
|
119
|
+
await store.savePreset({ id: 'saved', name: 'Saved model', cli: 'codex', model: 'fixture-model', effort: 'medium' })
|
|
120
|
+
await store.savePreset({ id: 'saved-default', name: 'Saved client default', cli: 'claude' })
|
|
121
|
+
const menu = createAiMenu(store, 'grok', async () => [])
|
|
122
|
+
let reply = ''
|
|
123
|
+
let keyboard: { inline_keyboard?: Array<Array<{ text: string }>> } | undefined
|
|
124
|
+
await menu.list({ reply: async (text: string, options?: { reply_markup?: unknown }) => {
|
|
125
|
+
reply = text
|
|
126
|
+
keyboard = options?.reply_markup as typeof keyboard
|
|
127
|
+
return {} as never
|
|
128
|
+
} } as never)
|
|
129
|
+
assert.match(reply, /current client setup only/)
|
|
130
|
+
assert.ok(!keyboard?.inline_keyboard?.flat().some((button) => button.text.includes('Saved model')))
|
|
131
|
+
assert.ok(!keyboard?.inline_keyboard?.flat().some((button) => button.text.includes('Saved client default')))
|
|
132
|
+
} finally { await rm(dir, { recursive: true, force: true }) }
|
|
133
|
+
})
|
|
134
|
+
|
|
95
135
|
test('model catalog can read an agent-bound Codex home', async () => {
|
|
96
136
|
const home = await mkdtemp(join(tmpdir(), 'ez-catalog-home-'))
|
|
97
137
|
const codexHome = await mkdtemp(join(tmpdir(), 'ez-catalog-codex-'))
|
package/test/failure.test.ts
CHANGED
|
@@ -17,7 +17,10 @@ import { TelegramSource } from '../src/telegram-source.js'
|
|
|
17
17
|
import { packageVersion } from '../src/version.js'
|
|
18
18
|
import type { Update } from 'grammy/types'
|
|
19
19
|
const exec=promisify(execFile),bin=fileURLToPath(new URL('../bin/ezenciel-agents-schedule.mjs',import.meta.url))
|
|
20
|
-
|
|
20
|
+
// This integration test starts subprocesses, persists their receipts, and invokes
|
|
21
|
+
// the schedule CLI. Give a busy CI worker time to settle without changing the
|
|
22
|
+
// production recovery deadline.
|
|
23
|
+
const until=async(check:()=>Promise<boolean>,timeoutMs=15_000)=>{const deadline=Date.now()+timeoutMs;while(!(await check())){if(Date.now()>=deadline)throw new Error(`Timed out after ${timeoutMs}ms`);await new Promise(r=>setTimeout(r,20))}}
|
|
21
24
|
|
|
22
25
|
test('failure evidence is bounded and redacts configured credentials, headers, tokens, URLs and keys',()=>{
|
|
23
26
|
const token='123456789:abcdefghijklmnopqrstuvwxyz123456789'
|
|
@@ -137,6 +140,29 @@ test('relay shutdown waits for executor cleanup and final run state', async () =
|
|
|
137
140
|
} finally {release();await relay.stop();await rm(dir,{recursive:true,force:true})}
|
|
138
141
|
})
|
|
139
142
|
|
|
143
|
+
test('a fresh relay initializes its bot identity before polling', async () => {
|
|
144
|
+
const dir=await mkdtemp(join(tmpdir(),'ez-cold-start-'))
|
|
145
|
+
const relay=createRelay({workspace:dir,controlDir:dir,pairingTtlMs:1000,executorTimeoutMs:0,executorCli:'grok',telegramBotToken:'fixture'},async()=>{throw new Error('No executor expected')})
|
|
146
|
+
const methods:string[]=[]
|
|
147
|
+
relay.bot.api.config.use(async(_prev,method,_payload,signal)=>{
|
|
148
|
+
methods.push(method)
|
|
149
|
+
if(method==='getMe') return {ok:true,result:{id:999,is_bot:true,first_name:'Fixture',username:'fixture_bot'}} as any
|
|
150
|
+
if(method==='getUpdates') {
|
|
151
|
+
if(signal && !signal.aborted) await new Promise<void>(resolve=>signal.addEventListener('abort',()=>resolve(),{once:true}))
|
|
152
|
+
return {ok:true,result:[]} as any
|
|
153
|
+
}
|
|
154
|
+
return {ok:true,result:true} as any
|
|
155
|
+
})
|
|
156
|
+
const started=relay.start()
|
|
157
|
+
try {
|
|
158
|
+
await until(async()=>methods.includes('getUpdates'))
|
|
159
|
+
assert.equal(relay.bot.isRunning(),true)
|
|
160
|
+
assert.equal(methods.filter(method=>method==='getMe').length,1)
|
|
161
|
+
assert.ok(methods.indexOf('getMe')<methods.indexOf('getUpdates'))
|
|
162
|
+
} finally {await relay.stop();await started;await rm(dir,{recursive:true,force:true})}
|
|
163
|
+
assert.equal(relay.bot.isRunning(),false)
|
|
164
|
+
})
|
|
165
|
+
|
|
140
166
|
for (const cleanupFails of [false,true]) test(`polling conflict preserves work until an explicit shutdown (cleanup fails: ${cleanupFails})`, async t => {
|
|
141
167
|
const dir=await mkdtemp(join(tmpdir(),'ez-polling-conflict-')),control=new ControlStore(dir,1000),runs=new RunStore(dir)
|
|
142
168
|
let release!:()=>void,entered!:()=>void,releaseDelivery!:()=>void,sending!:()=>void,child:ReturnType<typeof spawn>|undefined,polls=0
|
|
@@ -201,7 +201,7 @@ test('owner group discovery routes only to the private chat and rechecks identit
|
|
|
201
201
|
} finally { await f.close() }
|
|
202
202
|
})
|
|
203
203
|
|
|
204
|
-
test('four-item menu is owner-only;
|
|
204
|
+
test('four-item menu is owner-only; available AI choices work and forged/stale buttons cannot change settings', async () => {
|
|
205
205
|
const f = await fixture()
|
|
206
206
|
const callback = (id: number, data: string, user = 101): Update => ({
|
|
207
207
|
update_id: id,
|
|
@@ -220,10 +220,18 @@ test('four-item menu is owner-only; saved AI buttons work and forged/stale butto
|
|
|
220
220
|
await f.relay.bot.handleUpdate(callback(4, 'ai:forged'))
|
|
221
221
|
assert.equal(await store.getActiveSession(), null)
|
|
222
222
|
await f.relay.bot.handleUpdate(callback(5, pick))
|
|
223
|
+
if (!(await store.getActiveSession())) {
|
|
224
|
+
let useNow = f.keyboards.at(-1)!.flat().find((button) => button.text === 'Use now')?.callback_data
|
|
225
|
+
if (!useNow) {
|
|
226
|
+
await f.relay.bot.handleUpdate(callback(6, f.keyboards.at(-1)!.flat()[0].callback_data))
|
|
227
|
+
useNow = f.keyboards.at(-1)!.flat().find((button) => button.text === 'Use now')!.callback_data
|
|
228
|
+
}
|
|
229
|
+
await f.relay.bot.handleUpdate(callback(7, useNow))
|
|
230
|
+
}
|
|
223
231
|
assert.ok(await store.getActiveSession())
|
|
224
|
-
await f.relay.bot.handleUpdate(callback(
|
|
232
|
+
await f.relay.bot.handleUpdate(callback(8, pick))
|
|
225
233
|
assert.match(f.replies.at(-1)!, /Menu expired/)
|
|
226
|
-
await f.relay.bot.handleUpdate(message(
|
|
234
|
+
await f.relay.bot.handleUpdate(message(9, '/settings'))
|
|
227
235
|
assert.match(f.replies.at(-1)!, /Default for new conversations/)
|
|
228
236
|
await f.relay.bot.handleUpdate(message(8, '/status'))
|
|
229
237
|
assert.ok(f.keyboards.at(-1)!.flat().some((button) => button.text === 'Scheduled tasks'))
|
package/test/updates.test.mjs
CHANGED
|
@@ -86,6 +86,16 @@ test('status distinguishes installed, running, legacy and stale main versions wi
|
|
|
86
86
|
const result=await exec(process.execPath,[new URL('../bin/ezenciel-agents-tools.mjs',import.meta.url).pathname,'--home',f.home,'status']);
|
|
87
87
|
assert.equal(JSON.parse(result.stdout).main.installedVersion,'0.1.0');
|
|
88
88
|
});
|
|
89
|
+
test('relay healthcheck emits bounded predicate evidence without state contents',async t=>{
|
|
90
|
+
const root=await fs.realpath(await fs.mkdtemp(path.join(tmpdir(),'ez-healthcheck-')));t.after(()=>fs.rm(root,{recursive:true,force:true}));
|
|
91
|
+
const relay=path.join(root,'relay'),host=path.join(root,'host');await fs.mkdir(relay,{recursive:true});await fs.mkdir(path.join(host,'host-executor'),{recursive:true});
|
|
92
|
+
const check=async()=>exec(process.execPath,[new URL('../docker/healthcheck.mjs',import.meta.url).pathname],{env:{...process.env,EZ_HEALTH_RELAY_CONTROL_DIR:relay,EZ_EXECUTOR_TRANSPORT:'host',EZ_CONTROL_DIR:host}});
|
|
93
|
+
await fs.writeFile(path.join(relay,'heartbeat.json'),JSON.stringify({polling:true,at:Date.now()}));await fs.writeFile(path.join(host,'host-executor/heartbeat.json'),JSON.stringify({at:Date.now()}));await check();
|
|
94
|
+
await fs.writeFile(path.join(relay,'heartbeat.json'),JSON.stringify({polling:false,at:Date.now(),private:'must-not-appear'}));
|
|
95
|
+
await assert.rejects(check(),error=>{assert.match(error.stderr,/EZ_HEALTH_RELAY_NOT_POLLING/);assert.doesNotMatch(error.stderr,/must-not-appear/);return true;});
|
|
96
|
+
await fs.writeFile(path.join(relay,'heartbeat.json'),JSON.stringify({polling:true,at:Date.now()}));await fs.writeFile(path.join(host,'host-executor/heartbeat.json'),JSON.stringify({at:Date.now()-20000}));
|
|
97
|
+
await assert.rejects(check(),error=>{assert.match(error.stderr,/EZ_HEALTH_HOST_STALE/);return true;});
|
|
98
|
+
});
|
|
89
99
|
test('plugin status verifies images and reports stopped, mismatched and unreachable runtimes honestly',async t=>{
|
|
90
100
|
const f=await fixture(t,'plugin'),id='a'.repeat(64),image='sha256:'+'b'.repeat(64);
|
|
91
101
|
for(const mode of ['running','ndjson','stopped','missing','mismatched','offline']) {
|
|
@@ -157,6 +167,11 @@ test('failed preparation never stops runtime; failed activation rolls back code
|
|
|
157
167
|
else assert((await fs.readFile(path.join(f.config.deploymentDir,'docker.env'),'utf8')).includes('sha256:'+'a'.repeat(64)));
|
|
158
168
|
}
|
|
159
169
|
});
|
|
170
|
+
test('main health-gate rollback retains only the recognized sanitized predicate',async t=>{
|
|
171
|
+
const f=await fixture(t),job=await queued(f),base=runtime(f);let failed=false;
|
|
172
|
+
const r={...base,execute:async(c,a,o)=>{if(a.includes('up')&&!failed){failed=true;throw Error('container relay is unhealthy');}return a.includes('ps')?'a'.repeat(64):a[0]==='inspect'&&a[2]==='{{json .State.Health}}'?JSON.stringify({Log:[{Output:'EZ_HEALTH_HOST_STALE\n'}]}):base.execute(c,a,o);}};
|
|
173
|
+
const result=await perform(f.home,job,r);assert.equal(result.status,'rolled-back');assert.match(result.error,/health=host-stale/);assert.doesNotMatch(result.error,/private-test-token/);
|
|
174
|
+
});
|
|
160
175
|
test('plugin transaction preserves named volumes, backs up stopped data and rolls back failed health',async t=>{
|
|
161
176
|
for(const failed of [false,true]) {
|
|
162
177
|
const f=await fixture(t,'plugin'),job=await queued(f),r=runtime(f,{fail:(_c,a)=>failed&&a.includes('up')});
|