@jc_stack/ez-agents 0.1.0-beta.45 → 0.1.0-beta.47
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 +2 -0
- package/CHANGELOG.md +30 -0
- package/CONTRIBUTING.md +1 -1
- package/Dockerfile +13 -9
- package/docker/engine-pnpm-workspace.yaml +4 -0
- package/docker/smoke.mjs +1 -1
- package/docs/application-channel.md +3 -0
- package/docs/architecture/ai-selection.md +8 -2
- package/docs/architecture/authority-boundaries.md +2 -2
- package/docs/development-and-testing.md +1 -1
- package/docs/docker-runtime.md +10 -0
- package/docs/plugin-guide.md +1 -1
- package/docs/plugins.md +6 -1
- package/docs/releasing.md +1 -1
- package/docs/scheduling.md +17 -3
- package/docs/upgrades.md +7 -1
- package/package.json +4 -3
- package/pnpm-workspace.yaml +7 -0
- package/scripts/smoke-scheduler.ts +4 -1
- package/src/ai.ts +20 -2
- package/src/application-channel.ts +3 -1
- package/src/delivery-socket.ts +15 -1
- package/src/host-executor.ts +5 -0
- package/src/index.ts +3 -1
- package/src/install-tools.mjs +11 -5
- package/src/model-policy.ts +4 -0
- package/src/plugins/manager.mjs +7 -1
- package/src/runs.ts +4 -1
- package/src/schedule-cli.ts +12 -3
- package/src/scheduler.ts +28 -3
- package/src/task-executor.ts +2 -2
- package/src/updates/control.mjs +2 -1
- package/src/updates/runtime.mjs +15 -6
- package/src/updates/supervisor.mjs +23 -13
- package/test/ai.test.ts +45 -5
- package/test/application-controls.test.ts +3 -0
- package/test/channel-owner.test.ts +2 -2
- package/test/desktop-bridge.test.ts +2 -1
- package/test/failure.test.ts +4 -4
- package/test/host-executor.test.ts +4 -4
- package/test/install-tools.test.mjs +6 -5
- package/test/native-tasks.test.ts +1 -1
- package/test/plugin-manager.test.mjs +15 -0
- package/test/schedule-cli.test.ts +47 -3
- package/test/scheduled-tasks.test.ts +2 -2
- package/test/scheduler-host.test.ts +14 -3
- package/test/scheduler-relay.test.ts +47 -2
- package/test/scheduler.test.ts +21 -1
- package/test/tasks.test.ts +1 -1
- package/test/updates.test.mjs +68 -8
|
@@ -10,6 +10,8 @@ import { ControlStore } from '../src/control-state.js'
|
|
|
10
10
|
import { RunStore } from '../src/runs.js'
|
|
11
11
|
import { initialPreset } from '../src/ai.js'
|
|
12
12
|
import { serveTestLedger } from './helpers/ledger.js'
|
|
13
|
+
import { Scheduler } from '../src/scheduler.js'
|
|
14
|
+
import { callDeliverySocket, socketPathFor } from '../src/delivery-socket.js'
|
|
13
15
|
const exec=promisify(execFile),bin=fileURLToPath(new URL('../bin/ezenciel-agents-schedule.mjs',import.meta.url))
|
|
14
16
|
test('public scheduler CLI saves literal text, reads back, edits, pauses, and rejects external or finished callers',async t=>{
|
|
15
17
|
const dir=await mkdtemp(join(tmpdir(),'ez-schedule-cli-'));t.after(()=>rm(dir,{recursive:true,force:true}))
|
|
@@ -25,10 +27,11 @@ test('public scheduler CLI saves literal text, reads back, edits, pauses, and re
|
|
|
25
27
|
const context=JSON.parse((await exec(process.execPath,[bin,'context'],{env})).stdout)
|
|
26
28
|
assert.deepEqual(context.run.texts,['owner request']);assert.equal(context.busyReplies,undefined)
|
|
27
29
|
await assert.rejects(exec(process.execPath,[bin,'context'],{env:{...env,EZ_RUN_ID:''}}),/active owner run/)
|
|
28
|
-
|
|
30
|
+
await assert.rejects(exec(process.execPath,[bin,'create','missing-model','--now','--text','Do work'],{env}),/saved explicit model/)
|
|
31
|
+
const args=['create','test','--model','fixture-model','--at','2027-09-09T09:00:00+04:00','--text','Literal $(do-not-execute) /goal objective']
|
|
29
32
|
const saved=JSON.parse((await exec(process.execPath,[bin,...args],{env})).stdout)
|
|
30
33
|
assert.equal(saved.text,args.at(-1));assert.equal(saved.execution.preset.cli,'grok')
|
|
31
|
-
assert.equal(saved.execution.preset.model,
|
|
34
|
+
assert.equal(saved.execution.preset.model,'fixture-model');assert.equal(saved.execution.preset.effort,undefined)
|
|
32
35
|
await assert.rejects(exec(process.execPath,[bin,'create','blocked','--at','2027-09-09T09:00:00+04:00','--text','test','--model','gpt-5.6-terra','--effort','bad option'],{env}),/Invalid reasoning effort/)
|
|
33
36
|
const astra=JSON.parse((await exec(process.execPath,[bin,'create','astra','--at','2027-09-09T10:00:00+04:00','--text','Astra task','--model','gpt-6-astra'],{env})).stdout)
|
|
34
37
|
assert.equal(astra.execution.preset.model,'gpt-6-astra');assert.equal(astra.execution.preset.effort,undefined)
|
|
@@ -68,6 +71,47 @@ test('executor PATH exposes the extensionless scheduler command',async()=>{
|
|
|
68
71
|
assert.match((await exec(command,['--help'])).stdout,/Creates a scheduled task/)
|
|
69
72
|
})
|
|
70
73
|
|
|
74
|
+
test('trigger uses saved task settings after a chat switch, preserves cadence and reconciles its request key',async t=>{
|
|
75
|
+
const dir=await mkdtemp(join(tmpdir(),'ez-trigger-cli-'));t.after(()=>rm(dir,{recursive:true,force:true}))
|
|
76
|
+
const ledger=await serveTestLedger(dir);t.after(()=>ledger.stop())
|
|
77
|
+
const control=new ControlStore(dir,1000),runs=new RunStore(dir),scheduler=new Scheduler(dir)
|
|
78
|
+
await control.requestPairing(101,101);await control.approveOwner(101)
|
|
79
|
+
const owner=(await control.status()).owner!
|
|
80
|
+
const execution={sessionId:crypto.randomUUID(),preset:{id:'task',name:'Saved task',cli:'codex',model:'gpt-6-astra',effort:'medium'}}
|
|
81
|
+
const saved=await scheduler.save({id:'daily',name:'Daily',text:'Literal /goal task',owner,execution,enabled:true,trigger:{cron:'5 7 * * 1-5',timezone:'America/New_York',start:'2027-01-01T00:00:00Z'}},true)
|
|
82
|
+
const next=await scheduler.pendingOccurrence(saved)
|
|
83
|
+
const chat=await control.captureChoice({...initialPreset('codex'),model:'fixture-model'})
|
|
84
|
+
await control.savePreset({id:'chat',name:'Chat',cli:'codex',model:'gpt-6-luna',effort:'max'})
|
|
85
|
+
await control.selectPreset('chat',chat.sessionId)
|
|
86
|
+
assert.equal((await control.status()).ai?.selectedId,'chat')
|
|
87
|
+
const env={...process.env,EZ_CONTROL_DIR:dir,EZ_RUN_ID:''}
|
|
88
|
+
const args=[bin,'trigger','daily','--key','smoke-1']
|
|
89
|
+
const first=JSON.parse((await exec(process.execPath,args,{env})).stdout)
|
|
90
|
+
assert.equal(first.status,'queued');assert.deepEqual(first.execution.preset,execution.preset)
|
|
91
|
+
assert.notEqual(first.execution.sessionId,execution.sessionId)
|
|
92
|
+
assert.equal(first.scheduled.id,saved.id);assert.equal(first.scheduled.revision,saved.revision)
|
|
93
|
+
assert.equal(first.texts[1],saved.text)
|
|
94
|
+
assert.deepEqual(await scheduler.get('daily'),saved);assert.equal(await scheduler.pendingOccurrence(saved),next)
|
|
95
|
+
const retry=JSON.parse((await exec(process.execPath,args,{env})).stdout)
|
|
96
|
+
assert.equal(retry.id,first.id);assert.equal((await runs.list()).length,1)
|
|
97
|
+
await assert.rejects(exec(process.execPath,[bin,'trigger','daily','--key','different'],{env}),/queued or running/)
|
|
98
|
+
await assert.rejects(exec(process.execPath,[...args,'--model','gpt-6-luna'],{env}),/overrides are not allowed/)
|
|
99
|
+
await runs.patch(first.id,{status:'completed'})
|
|
100
|
+
const parallel=await Promise.allSettled(['smoke-2','smoke-3'].map(key=>callDeliverySocket(socketPathFor(dir),{op:'triggerSchedule',payload:{scheduleId:'daily',revision:saved.revision,key}})))
|
|
101
|
+
assert.equal(parallel.filter(r=>r.status==='fulfilled').length,1,'concurrent trigger keys cannot overlap')
|
|
102
|
+
for(const run of await runs.list())await runs.patch(run.id,{status:'completed'})
|
|
103
|
+
const restricted=await runs.create({chatId:101,telegramUserId:101,texts:[],execution,external:{sourceId:'source',bindingId:'binding',eventIds:['event']}})
|
|
104
|
+
await runs.patch(restricted.id,{status:'running'})
|
|
105
|
+
await assert.rejects(callDeliverySocket(socketPathFor(dir),{op:'triggerSchedule',payload:{scheduleId:'daily',revision:saved.revision,key:'restricted',callerRunId:restricted.id}}),/blocked/)
|
|
106
|
+
await assert.rejects(callDeliverySocket(socketPathFor(dir),{op:'triggerSchedule',payload:{scheduleId:'daily',revision:'stale',key:'stale'}}),/changed/)
|
|
107
|
+
await assert.rejects(exec(process.execPath,[bin,'trigger','daily','--key','../bad'],{env}),/Invalid record identifier/)
|
|
108
|
+
await scheduler.enable('daily',false)
|
|
109
|
+
await assert.rejects(exec(process.execPath,[bin,'trigger','daily','--key','paused'],{env}),/disabled/)
|
|
110
|
+
await scheduler.enable('daily',true)
|
|
111
|
+
await control.revokeOwner();await control.requestPairing(202,202);await control.approveOwner(202)
|
|
112
|
+
await assert.rejects(exec(process.execPath,[bin,'trigger','daily','--key','wrong-owner'],{env}),/ownership mismatch/)
|
|
113
|
+
})
|
|
114
|
+
|
|
71
115
|
|
|
72
116
|
test('deferred literal input retains owner-scoped conversation through source metadata',async t=>{
|
|
73
117
|
const {Scheduler}=await import('../src/scheduler.js')
|
|
@@ -75,7 +119,7 @@ test('deferred literal input retains owner-scoped conversation through source me
|
|
|
75
119
|
const ledger=await serveTestLedger(dir);t.after(()=>ledger.stop())
|
|
76
120
|
const control=new ControlStore(dir,1000),runs=new RunStore(dir)
|
|
77
121
|
await control.requestPairing(101,101);await control.approveOwner(101)
|
|
78
|
-
const execution=await control.captureChoice(initialPreset('codex'))
|
|
122
|
+
const execution=await control.captureChoice({...initialPreset('codex'),model:'fixture-model'})
|
|
79
123
|
await runs.create({id:'tg_1',chatId:101,telegramUserId:101,texts:['Use the blue ledger'],execution})
|
|
80
124
|
await runs.patch('tg_1',{status:'completed'})
|
|
81
125
|
await runs.create({id:'tg_2',chatId:101,telegramUserId:101,texts:['Do the same for March'],execution})
|
|
@@ -9,7 +9,7 @@ import { scheduledTaskDetailText, scheduledTasksText } from '../src/scheduled-ta
|
|
|
9
9
|
import { RunStore } from '../src/runs.js'
|
|
10
10
|
|
|
11
11
|
const owner = { telegramUserId: 101, telegramChatId: 101, pairedAt: '2026-09-11T00:00:00.000Z' }
|
|
12
|
-
const execution = { sessionId: randomUUID(), preset: { id: 'fixture', name: 'Fixture', cli: 'codex' } }
|
|
12
|
+
const execution = { sessionId: randomUUID(), preset: { id: 'fixture', name: 'Fixture', cli: 'codex', model: 'fixture-model' } }
|
|
13
13
|
|
|
14
14
|
test('scheduled task view is read-only, owner-bound, and shows active task prompts and effective AI settings', async (t) => {
|
|
15
15
|
const dir = await mkdtemp(join(tmpdir(), 'ez-scheduled-tasks-'))
|
|
@@ -40,7 +40,7 @@ test('scheduled task view is read-only, owner-bound, and shows active task promp
|
|
|
40
40
|
const active = await scheduler.listActiveReadOnly(await runs.list())
|
|
41
41
|
const text = scheduledTasksText(active, owner)
|
|
42
42
|
|
|
43
|
-
assert.equal(text, '📅 Scheduled tasks · 1 active\n\nAll times UTC. Use /tasks 1 for task details.\n\n1 · Daily report\n ● Active\n Next · Thu, Jan 1 · 05:00 UTC\n Last · ✓ Completed · Thu, Jan 1 · 05:04 UTC\n codex ·
|
|
43
|
+
assert.equal(text, '📅 Scheduled tasks · 1 active\n\nAll times UTC. Use /tasks 1 for task details.\n\n1 · Daily report\n ● Active\n Next · Thu, Jan 1 · 05:00 UTC\n Last · ✓ Completed · Thu, Jan 1 · 05:04 UTC\n codex · fixture-model · default effort\n Read the ledger and send the owner a concise report.')
|
|
44
44
|
assert.match(scheduledTaskDetailText(active.find(schedule => schedule.id === saved.id)!, 1), /Last run\n✓ Completed · Thu, Jan 1 · 05:04 UTC/)
|
|
45
45
|
assert.doesNotMatch(text, /Other owner task|This must never be visible/)
|
|
46
46
|
assert.equal(await readFile(join(scheduleDir, 'owner-task.json'), 'utf8'), before)
|
|
@@ -20,14 +20,25 @@ test('host transport keeps chat responsive beside a schedule in the bound mind',
|
|
|
20
20
|
const old={...EXECUTOR_REGISTRY.grok},token=process.env.TELEGRAM_BOT_TOKEN
|
|
21
21
|
EXECUTOR_REGISTRY.grok.command=process.execPath;EXECUTOR_REGISTRY.grok.buildArgs=()=>[script]
|
|
22
22
|
process.env.TELEGRAM_BOT_TOKEN='never-in-child'
|
|
23
|
-
const abort=new AbortController(),server=serveHostExecutor({cli:'grok',agents:[{name:'test',workspace,controlDir,binDir:root}]},abort.signal)
|
|
23
|
+
const abort=new AbortController(),server=serveHostExecutor({cli:'grok',agents:[{name:'test',workspace,controlDir,binDir:root}]},abort.signal,undefined,async()=>[{cli:'grok',model:'fixture-model',name:'Fixture',efforts:[]}])
|
|
24
24
|
const dir=join(controlDir,'host-executor'),runs=new RunStore(controlDir),id='r_schedule_fixture'
|
|
25
25
|
const exists=async(file:string)=>readFile(join(dir,file),'utf8').catch(()=>'')
|
|
26
26
|
try{
|
|
27
27
|
await until(async()=>Boolean(await exists('heartbeat.json')))
|
|
28
|
-
await runs.create({id,chatId:101,telegramUserId:101,texts:['slow'],execution:{sessionId:randomUUID(),preset:{id:'fixture',name:'Fixture',cli:'grok'}},scheduled:{id:'s',revision:'v',dueAt:new Date().toISOString(),pairedAt:'paired'}})
|
|
29
|
-
const submit=async(id:string)=>writeFile(join(dir,id+'.request.json'),JSON.stringify({texts:['fixture'],options:{workspace:'/evil',controlDir:'/evil',cli:'grok',timeoutMs:1}}))
|
|
28
|
+
await runs.create({id,chatId:101,telegramUserId:101,texts:['slow'],execution:{sessionId:randomUUID(),preset:{id:'fixture',name:'Fixture',cli:'grok',model:'fixture-model'}},scheduled:{id:'s',revision:'v',dueAt:new Date().toISOString(),pairedAt:'paired'}})
|
|
29
|
+
const submit=async(id:string)=>writeFile(join(dir,id+'.request.json'),JSON.stringify({texts:['fixture'],options:{workspace:'/evil',controlDir:'/evil',cli:'grok',model:'fixture-model',timeoutMs:1}}))
|
|
30
30
|
await ownerRun(controlDir,'tg_1')
|
|
31
|
+
for(const [badId,savedModel,requestedModel,reason] of [
|
|
32
|
+
['r_schedule_missing',undefined,undefined,'saved explicit model'],
|
|
33
|
+
['r_schedule_mismatch','fixture-model','wrong-model','does not match'],
|
|
34
|
+
] as const){
|
|
35
|
+
await runs.create({id:badId,chatId:101,telegramUserId:101,texts:['blocked'],execution:{sessionId:randomUUID(),preset:{id:'fixture',name:'Fixture',cli:'grok',model:savedModel}},scheduled:{id:badId,revision:'v',dueAt:new Date().toISOString(),pairedAt:'paired'}})
|
|
36
|
+
await runs.patch(badId,{status:'running'})
|
|
37
|
+
await writeFile(join(dir,badId+'.request.json'),JSON.stringify({texts:['blocked'],options:{cli:'grok',model:requestedModel}}))
|
|
38
|
+
await until(async()=> (await exists(badId+'.events')).includes('"stream":"exit"'))
|
|
39
|
+
assert.match(await exists(badId+'.events'),new RegExp(reason))
|
|
40
|
+
assert.equal(await exists(badId+'.process.json'),'','host must reject before engine spawn')
|
|
41
|
+
}
|
|
31
42
|
await runs.patch(id,{status:'running'})
|
|
32
43
|
await submit(id)
|
|
33
44
|
await until(async()=>Boolean(await exists(id+'.process.json')))
|
|
@@ -11,9 +11,54 @@ import { ControlStore } from '../src/control-state.js'
|
|
|
11
11
|
import { RunStore } from '../src/runs.js'
|
|
12
12
|
import { Scheduler } from '../src/scheduler.js'
|
|
13
13
|
import { initialPreset } from '../src/ai.js'
|
|
14
|
+
import type { ExecutorOptions } from '../src/executor.js'
|
|
14
15
|
|
|
15
16
|
const until=async(check:()=>Promise<boolean>)=>{for(let i=0;i<200;i++){if(await check())return;await new Promise(r=>setTimeout(r,20))}throw new Error('Timed out')}
|
|
16
17
|
|
|
18
|
+
test('executor admission rejects a legacy queued schedule with no model before spawning',async()=>{
|
|
19
|
+
const dir=await mkdtemp(join(tmpdir(),'ez-missing-task-model-')),control=new ControlStore(dir,1000),runs=new RunStore(dir),scheduler=new Scheduler(dir)
|
|
20
|
+
let launches=0
|
|
21
|
+
const relay=createRelay({workspace:dir,controlDir:dir,pairingTtlMs:1000,executorTimeoutMs:0,executorCli:'codex',telegramBotToken:'fixture'},async()=>{
|
|
22
|
+
launches++;throw new Error('Must not launch')
|
|
23
|
+
})
|
|
24
|
+
relay.bot.api.config.use(async()=>({ok:true,result:{message_id:42}}) as never)
|
|
25
|
+
try{
|
|
26
|
+
await control.requestPairing(101,101);await control.approveOwner(101)
|
|
27
|
+
const owner=(await control.status()).owner!,execution=await control.captureChoice({...initialPreset('codex'),model:'saved-model'})
|
|
28
|
+
const due=Date.now()+2000
|
|
29
|
+
const saved=await scheduler.save({id:'legacy',name:'Legacy',text:'Work',owner,execution,enabled:true,trigger:{at:new Date(due).toISOString()}},true)
|
|
30
|
+
const run=await runs.create({id:'r_schedule_legacy',chatId:101,telegramUserId:101,texts:['Work'],
|
|
31
|
+
execution:{...execution,preset:{...execution.preset,model:undefined}},
|
|
32
|
+
scheduled:{id:saved.id,revision:saved.revision,dueAt:new Date(due).toISOString(),pairedAt:owner.pairedAt}})
|
|
33
|
+
await relay.drainSources()
|
|
34
|
+
assert.equal((await runs.get(run.id))?.status,'failed');assert.equal(launches,0)
|
|
35
|
+
assert.equal((await scheduler.get(saved.id)).execution.preset.model,'saved-model')
|
|
36
|
+
}finally{await relay.stop();await rm(dir,{recursive:true,force:true})}
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
test('manual task trigger launches its saved model in a fresh native session after chat switches AI',async()=>{
|
|
40
|
+
const dir=await mkdtemp(join(tmpdir(),'ez-trigger-launch-')),control=new ControlStore(dir,1000),runs=new RunStore(dir),scheduler=new Scheduler(dir)
|
|
41
|
+
let launched:ExecutorOptions | undefined
|
|
42
|
+
const relay=createRelay({workspace:dir,controlDir:dir,pairingTtlMs:1000,executorTimeoutMs:0,executorCli:'codex',telegramBotToken:'fixture'},async(_texts,options)=>{
|
|
43
|
+
launched=options
|
|
44
|
+
const child=spawn(process.execPath,['-e','setTimeout(()=>{},10)'],{detached:process.platform!=='win32'})
|
|
45
|
+
await once(child,'spawn');return {child,cleanup:async()=>{},stdout:''}
|
|
46
|
+
})
|
|
47
|
+
try{
|
|
48
|
+
await control.requestPairing(101,101);await control.approveOwner(101)
|
|
49
|
+
const owner=(await control.status()).owner!,chat=await control.captureChoice(initialPreset('codex'))
|
|
50
|
+
await control.savePreset({id:'chat',name:'Chat',cli:'codex',model:'gpt-6-luna',effort:'max'})
|
|
51
|
+
await control.selectPreset('chat',chat.sessionId)
|
|
52
|
+
const saved=await scheduler.save({id:'daily',name:'Daily',text:'Saved work',owner,execution:{sessionId:chat.sessionId,preset:{id:'daily',name:'Daily model',cli:'codex',model:'gpt-6-astra',effort:'medium'}},enabled:true,trigger:{at:'2027-01-01T00:00:00Z'}},true)
|
|
53
|
+
const run=await scheduler.trigger(saved.id,saved.revision,'launch-smoke',owner,runs)
|
|
54
|
+
await relay.drainSources();await until(async()=> (await runs.get(run.id))?.status==='completed')
|
|
55
|
+
assert.equal(launched?.model,'gpt-6-astra');assert.equal(launched?.effort,'medium')
|
|
56
|
+
assert.equal(launched?.nativeSession,true);assert.equal(launched?.isResume,false)
|
|
57
|
+
assert.notEqual(launched?.sessionId,chat.sessionId)
|
|
58
|
+
assert.equal((await control.status()).ai?.selectedId,'chat')
|
|
59
|
+
}finally{await relay.stop();await rm(dir,{recursive:true,force:true})}
|
|
60
|
+
})
|
|
61
|
+
|
|
17
62
|
// Adversarial: the relay must not reserve wrapper semantics for an
|
|
18
63
|
// update-shaped run id or a stale attention notice.
|
|
19
64
|
test('a wrapper-shaped run id has no maintenance treatment and a stale notice is inert',async()=>{
|
|
@@ -55,7 +100,7 @@ test('Slack application replies beside a schedule and keeps both streams serial'
|
|
|
55
100
|
})
|
|
56
101
|
try{
|
|
57
102
|
await control.requestPairing(101,101);await control.approveOwner(101)
|
|
58
|
-
const owner=(await control.status()).owner!,execution=await control.captureChoice(initialPreset('grok'))
|
|
103
|
+
const owner=(await control.status()).owner!,execution=await control.captureChoice({...initialPreset('grok'),model:'fixture-model'})
|
|
59
104
|
const due=Date.now()+10000
|
|
60
105
|
for(const id of ['first','second'])await scheduler.save({id,name:id,text:'Long work',trigger:{at:new Date(due).toISOString()},enabled:true,owner,execution})
|
|
61
106
|
await scheduler.tick(owner,runs,due);await relay.drainSources()
|
|
@@ -103,7 +148,7 @@ test('owner chat replies while a scheduled CLI keeps running; targeted cancellat
|
|
|
103
148
|
relay.bot.api.config.use(async(_prev,method,payload)=>{if(method==='sendMessage')replies.push((payload as {text:string}).text);return {ok:true,result:{message_id:42}} as never})
|
|
104
149
|
try{
|
|
105
150
|
await control.requestPairing(101,101);await control.approveOwner(101)
|
|
106
|
-
const owner=(await control.status()).owner!,execution=await control.captureChoice(initialPreset('grok'))
|
|
151
|
+
const owner=(await control.status()).owner!,execution=await control.captureChoice({...initialPreset('grok'),model:'fixture-model'})
|
|
107
152
|
const due=Date.now()+2000
|
|
108
153
|
await scheduler.save({id:'slow',name:'Slow',text:'Long work',trigger:{at:new Date(due).toISOString()},enabled:true,owner,execution})
|
|
109
154
|
await scheduler.tick(owner,runs,due);await relay.drainSources()
|
package/test/scheduler.test.ts
CHANGED
|
@@ -28,7 +28,7 @@ const fixture=async(t:any)=>{
|
|
|
28
28
|
const dir=await mkdtemp(join(tmpdir(),'ez-schedules-'));t.after(()=>rm(dir,{recursive:true,force:true}))
|
|
29
29
|
const scheduler=new Scheduler(dir), runs=new RunStore(dir), now=Date.now()+10000
|
|
30
30
|
const owner={telegramUserId:101,telegramChatId:101,pairedAt:new Date().toISOString()}
|
|
31
|
-
const input={id:'test',name:'Test',text:'Do the work',owner,execution:{sessionId:randomUUID(),preset:{id:'fixture',name:'Fixture',cli:'codex'}},enabled:true,trigger:{at:new Date(now).toISOString()}}
|
|
31
|
+
const input={id:'test',name:'Test',text:'Do the work',owner,execution:{sessionId:randomUUID(),preset:{id:'fixture',name:'Fixture',cli:'codex',model:'fixture-model'}},enabled:true,trigger:{at:new Date(now).toISOString()}}
|
|
32
32
|
return {dir,scheduler,runs,now,owner,input}
|
|
33
33
|
}
|
|
34
34
|
test('durable dispatch survives cursor-write crash without duplicating an occurrence',async t=>{
|
|
@@ -158,3 +158,23 @@ test('ordinary recurring work still runs after a non-interrupted failure', async
|
|
|
158
158
|
await f.scheduler.tick(f.owner,f.runs,f.now+60000)
|
|
159
159
|
assert.equal((await f.runs.list()).length,2)
|
|
160
160
|
})
|
|
161
|
+
|
|
162
|
+
test('missing models cannot be saved, dispatched or triggered; legacy settings remain readable for repair',async t=>{
|
|
163
|
+
const f=await fixture(t)
|
|
164
|
+
const missing={...f.input,execution:{...f.input.execution,preset:{...f.input.execution.preset,model:undefined}}}
|
|
165
|
+
await assert.rejects(f.scheduler.save(missing),/saved explicit model/)
|
|
166
|
+
const saved=await f.scheduler.save(f.input,true),next=await f.scheduler.pendingOccurrence(saved)
|
|
167
|
+
const file=join(f.dir,'schedules/test.json')
|
|
168
|
+
await writeFile(file,JSON.stringify({...saved,execution:missing.execution}))
|
|
169
|
+
const legacy=await f.scheduler.get(saved.id)
|
|
170
|
+
assert.equal(legacy.execution.preset.model,undefined)
|
|
171
|
+
await f.scheduler.tick(f.owner,f.runs,f.now)
|
|
172
|
+
assert.equal((await f.runs.list()).length,0)
|
|
173
|
+
assert.equal(await f.scheduler.pendingOccurrence(legacy),next,'blocked dispatch must preserve the pending occurrence')
|
|
174
|
+
await assert.rejects(f.scheduler.trigger(saved.id,saved.revision,'legacy',f.owner,f.runs),/saved explicit model/)
|
|
175
|
+
const repaired=await f.scheduler.save({...legacy,execution:f.input.execution})
|
|
176
|
+
await f.scheduler.tick(f.owner,f.runs,f.now)
|
|
177
|
+
const [run]=await f.runs.list()
|
|
178
|
+
assert.equal(run.execution?.preset.model,f.input.execution.preset.model)
|
|
179
|
+
assert.equal(run.scheduled?.revision,repaired.revision)
|
|
180
|
+
})
|
package/test/tasks.test.ts
CHANGED
|
@@ -247,7 +247,7 @@ test('approved initial task crosses the real host file client and uses a fresh r
|
|
|
247
247
|
const { isHostRunId } = await import('../src/host-executor-protocol.js')
|
|
248
248
|
const f = await fixture(t), { run } = await f.activate()
|
|
249
249
|
assert.ok(isHostRunId(run.id))
|
|
250
|
-
await writeFile(join(f.dir, 'codex'), `#!${process.execPath}\nif(process.argv[2]==='--version')console.log('codex-cli 0.
|
|
250
|
+
await writeFile(join(f.dir, 'codex'), `#!${process.execPath}\nif(process.argv[2]==='--version')console.log('codex-cli 0.160.0');else if(process.argv[2]==='debug')console.log(JSON.stringify({models:[{slug:'fixture',tool_mode:'code_mode_only',apply_patch_tool_type:'freeform',multi_agent_version:'v2'}]}));else console.log(JSON.stringify({cwd:process.cwd(),args:process.argv.slice(2),control:process.env.EZ_CONTROL_DIR}));`, { mode: 0o700 })
|
|
251
251
|
const priorPath = process.env.PATH
|
|
252
252
|
process.env.PATH = `${f.dir}:${priorPath}`
|
|
253
253
|
const abort = new AbortController(), host = serveHostExecutor({ cli: 'codex', agents: [{ name: 'test', workspace: f.dir, controlDir: f.dir, binDir: f.dir }] }, abort.signal)
|
package/test/updates.test.mjs
CHANGED
|
@@ -13,7 +13,7 @@ import { perform, environment, packageManager, backupStateDirectory, execute } f
|
|
|
13
13
|
import { atomic, snapshot, compose, prepareCommand } from '../src/plugins/manager.mjs';
|
|
14
14
|
import { bindUpdates } from '../src/updates/binding.mjs';
|
|
15
15
|
import { status as runtimeStatus } from '../src/updates/status.mjs';
|
|
16
|
-
import { providerEnvironment, queueAutomatic, waitForHostHeartbeat } from '../src/updates/supervisor.mjs';
|
|
16
|
+
import { providerEnvironment, queueAutomatic, waitForHostHeartbeat, withIdleUpgrade } from '../src/updates/supervisor.mjs';
|
|
17
17
|
const exec=promisify(execFile);
|
|
18
18
|
|
|
19
19
|
test('structured runtime output excludes warnings but failures retain diagnostics',async()=>{
|
|
@@ -113,10 +113,10 @@ test('root preparation leaves private plugin updates readable by the installer',
|
|
|
113
113
|
const script=`const fs=require('fs');fs.readFileSync(${JSON.stringify(path.join(dir,'job.json'))});fs.readFileSync(${JSON.stringify(path.join(dir,'candidate.tgz'))});`;
|
|
114
114
|
await exec('setpriv',['--reuid=20001','--regid=20002','--clear-groups','--no-new-privs',process.execPath,'-e',script]);
|
|
115
115
|
});
|
|
116
|
-
function runtime(f,{fail,stopped=false,buildArgs={CODEX_CLI_VERSION:'0.156.1'}}={}) {
|
|
116
|
+
function runtime(f,{fail,stopped=false,buildArgs={CODEX_CLI_VERSION:'0.156.1'},latest='0.160.0'}={}) {
|
|
117
117
|
const calls=[];let failed=false;
|
|
118
118
|
const execute=async(command,args,opts)=>{
|
|
119
|
-
calls.push([command,...args]);if(fail&&!failed&&fail(command,args)){failed=true;throw Error('Synthetic failure');}
|
|
119
|
+
calls.push([command,...args]);if(args.includes('view'))return JSON.stringify(latest);if(fail&&!failed&&fail(command,args)){failed=true;throw Error('Synthetic failure');}
|
|
120
120
|
if(args.at(-1)==='--version')return '10.30.3';
|
|
121
121
|
if(args.includes('config'))return JSON.stringify({services:{relay:{build:{args:buildArgs}}}});
|
|
122
122
|
if(args.includes('ps'))return stopped?'':'container-id';
|
|
@@ -276,6 +276,16 @@ test('compatibility bridge hands off any matching canonical beta release',async
|
|
|
276
276
|
await atomic(path.join(f.source,'package.json'),{...candidate,version:'0.1.0-beta.34'});
|
|
277
277
|
await assert.rejects(eligibility(f.home,'main',f.source,false),/newer/);
|
|
278
278
|
});
|
|
279
|
+
test('private RC and QA builds graduate only to their matching beta release',async t=>{
|
|
280
|
+
const f=await fixture(t),prior=await read(path.join(f.old,'package.json')),candidate=await read(path.join(f.source,'package.json'));
|
|
281
|
+
for(const suffix of ['rc.7','qa.4']) {
|
|
282
|
+
await atomic(path.join(f.old,'package.json'),{...prior,version:'0.1.0-beta.46.'+suffix});
|
|
283
|
+
await atomic(path.join(f.source,'package.json'),{...candidate,version:'0.1.0-beta.46'});
|
|
284
|
+
assert.equal((await eligibility(f.home,'main',f.source,true)).pkg.version,'0.1.0-beta.46');
|
|
285
|
+
await atomic(path.join(f.source,'package.json'),{...candidate,version:'0.1.0-beta.45'});
|
|
286
|
+
await assert.rejects(eligibility(f.home,'main',f.source,false),/newer/);
|
|
287
|
+
}
|
|
288
|
+
});
|
|
279
289
|
test('reviewed Library beta14 command migration is exact and fail-closed',()=>{
|
|
280
290
|
const manifest=(version,document)=>({schemaVersion:1,id:'library',version,description:'library',commands:{library:{executable:'bin/ez-library.mjs',args:[]},'library-query':{executable:'bin/ez-library.mjs',args:[],channelQuery:true},...(document?{'library-document':{executable:'bin/ez-library.mjs',args:[],channelQuery:true}}:{})},skills:['skills/library/SKILL.md']});
|
|
281
291
|
const deployment=(query,document)=>({schemaVersion:3,services:{library:{buildTarget:'runtime',volumes:{data:'/state'},healthcheck:['node','/app/bin/ez-library.mjs','--version'],memoryMiB:4096,cpus:2}},commands:{'library-query':{service:'library',argv:['node','/app/bin/ez-library.mjs',query]},...(document?{'library-document':{service:'library',argv:['node','/app/bin/ez-library.mjs','document']}}:{})},sharedServices:{}});
|
|
@@ -296,7 +306,7 @@ test('main transaction stages before stopping, pins rollback image, preserves st
|
|
|
296
306
|
assert.notEqual(await fs.readFile(path.join(active,'bin/example.mjs'),'utf8'),'tampered');
|
|
297
307
|
assert(r.calls.findIndex(c=>c.includes('build'))<r.calls.findIndex(c=>c[0]==='stopHost'));
|
|
298
308
|
const build=r.calls.find(c=>c[0]==='docker'&&c[1]==='build');
|
|
299
|
-
assert.equal(build[build.indexOf('--build-arg')+1],'CODEX_CLI_VERSION=0.
|
|
309
|
+
assert.equal(build[build.indexOf('--build-arg')+1],'CODEX_CLI_VERSION=0.160.0');
|
|
300
310
|
const status=await command(f.home,['status']);assert(!JSON.stringify(status).includes('private-test-token'));assert(!('rollback'in status.jobs[0]));
|
|
301
311
|
assert.equal((await fs.stat(path.join(jobPath(f.home,job.id),'job.json'))).mode&0o777,0o600);
|
|
302
312
|
});
|
|
@@ -310,12 +320,14 @@ test('main activation and rollback bind guidance from the selected package root'
|
|
|
310
320
|
assert.equal((await perform(failed.home,await queued(failed),runtime(failed,{fail:(_command,args)=>args.includes('up')}))).status,'rolled-back');
|
|
311
321
|
assert.equal(await fs.readFile(path.join(failed.agent.workspace,'AGENTS.md'),'utf8'),'old guidance\n');
|
|
312
322
|
});
|
|
313
|
-
test('
|
|
323
|
+
test('latest overrides stale deployment pins and changing latest changes image bindings',async t=>{
|
|
314
324
|
const tags=[],hashes=[];
|
|
315
325
|
for(const pin of ['0.153.4','0.156.1']) {
|
|
316
|
-
const f=await fixture(t),job=await queued(f),r=runtime(f,{buildArgs:{CODEX_CLI_VERSION:pin}
|
|
326
|
+
const f=await fixture(t),job=await queued(f),r=runtime(f,{buildArgs:{CODEX_CLI_VERSION:'0.153.4'},latest:pin});
|
|
317
327
|
assert.equal((await perform(f.home,job,r)).status,'completed');
|
|
318
328
|
const build=r.calls.find(c=>c[0]==='docker'&&c[1]==='build'),tag=build[build.indexOf('-t')+1];
|
|
329
|
+
assert.ok(build.includes(`CODEX_CLI_VERSION=${pin}`));
|
|
330
|
+
assert.equal((await read(path.join(jobPath(f.home,job.id),'job.json'))).codexVersion,pin);
|
|
319
331
|
assert.match(await fs.readFile(path.join(f.config.deploymentDir,'docker.env'),'utf8'),new RegExp(tag));
|
|
320
332
|
tags.push(tag);hashes.push(job.sha256);
|
|
321
333
|
}
|
|
@@ -372,6 +384,33 @@ test('plugin updates register additive command routes on an unchanged deployment
|
|
|
372
384
|
const dispatch=await prepareCommand(f.home,'query',[]);assert.deepEqual(dispatch.argv.slice(-4),['node','sample','/app/bin/example.mjs','query']);
|
|
373
385
|
assert(r.calls.some(call=>call.includes('up')));
|
|
374
386
|
});
|
|
387
|
+
test('isolated plugin replacement refreshes broker version inventory after registry activation',async t=>{
|
|
388
|
+
const f=await fixture(t,'plugin');
|
|
389
|
+
await fs.appendFile(path.join(f.config.deploymentDir,'docker.env'),'EZ_EXECUTOR_TRANSPORT=local\n');
|
|
390
|
+
const job=await queued(f),r=runtime(f),execute=r.execute;
|
|
391
|
+
let observed;
|
|
392
|
+
r.execute=async(command,args,options)=>{
|
|
393
|
+
if(args.includes('restart')&&args.at(-1)==='plugin-broker') observed=(await read(path.join(f.home,'registry.json'))).plugins.sample.manifest.version;
|
|
394
|
+
return execute(command,args,options);
|
|
395
|
+
};
|
|
396
|
+
assert.equal((await perform(f.home,job,r)).status,'completed');
|
|
397
|
+
assert.equal(observed,'0.1.1');
|
|
398
|
+
assert.equal(r.calls.filter(call=>call.includes('restart')).length,1);
|
|
399
|
+
const interrupted=await read(path.join(jobPath(f.home,job.id),'job.json'));interrupted.status='applying';
|
|
400
|
+
assert.equal((await perform(f.home,interrupted,r)).status,'rolled-back');
|
|
401
|
+
assert.equal(observed,'0.1.0');
|
|
402
|
+
assert.equal(r.calls.filter(call=>call.includes('restart')).length,2);
|
|
403
|
+
});
|
|
404
|
+
test('plugin update preserves a created but never running service as stopped',async t=>{
|
|
405
|
+
const f=await fixture(t,'plugin'),job=await queued(f),r=runtime(f),execute=r.execute;
|
|
406
|
+
r.execute=async(command,args,options)=>{
|
|
407
|
+
if(args.includes('ps')) {r.calls.push([command,...args]);if(args.includes('view'))return JSON.stringify(latest);return args.includes('--status')?'':'created-container';}
|
|
408
|
+
return execute(command,args,options);
|
|
409
|
+
};
|
|
410
|
+
assert.equal((await perform(f.home,job,r)).status,'completed');
|
|
411
|
+
assert.equal(job.rollback.running,false);
|
|
412
|
+
assert.equal(r.calls.some(call=>call.includes('up')),false);
|
|
413
|
+
});
|
|
375
414
|
test('plugin command additions ignore object key order but reject collisions and changed routes',async t=>{
|
|
376
415
|
const f=await fixture(t,'plugin'),deployment=await read(path.join(f.source,'ez-deployment.json'));
|
|
377
416
|
deployment.services.sample={healthcheck:deployment.services.sample.healthcheck,volumes:deployment.services.sample.volumes,buildTarget:deployment.services.sample.buildTarget};
|
|
@@ -458,7 +497,7 @@ for(const provider of ['pnpm','corepack']) test(`supervisor with only ${provider
|
|
|
458
497
|
// Package archives never contain node_modules; the fake pnpm below provisions the fixture loader.
|
|
459
498
|
await fs.rm(path.join(f.source,'node_modules'),{recursive:true});
|
|
460
499
|
const log=path.join(f.root,'commands.jsonl');
|
|
461
|
-
await fs.writeFile(path.join(fake,provider),`#!${process.execPath}\nif(${JSON.stringify(provider)}==='corepack'&&process.argv[2]!=='pnpm@10.30.3')throw Error('Unpinned manager');if(process.argv.includes('--version')){console.log('10.30.3');process.exit(0)}const fs=require('fs');fs.mkdirSync('node_modules/tsx/dist',{recursive:true});fs.writeFileSync('node_modules/tsx/dist/loader.mjs','');`,{mode:0o755});
|
|
500
|
+
await fs.writeFile(path.join(fake,provider),`#!${process.execPath}\nif(${JSON.stringify(provider)}==='corepack'&&process.argv[2]!=='pnpm@10.30.3')throw Error('Unpinned manager');if(process.argv.includes('view')){console.log(JSON.stringify('0.160.0'));process.exit(0)}if(process.argv.includes('--version')){console.log('10.30.3');process.exit(0)}const fs=require('fs');fs.mkdirSync('node_modules/tsx/dist',{recursive:true});fs.writeFileSync('node_modules/tsx/dist/loader.mjs','');`,{mode:0o755});
|
|
462
501
|
await fs.writeFile(path.join(fake,'docker'),`#!${process.execPath}\nconst fs=require('fs');const a=process.argv.slice(2);fs.appendFileSync(${JSON.stringify(log)},JSON.stringify(a)+'\\n');if(a.includes('config'))console.log(JSON.stringify({services:{relay:{build:{args:{}}}}}));if(a.includes('ps'))console.log('cid');if(a[0]==='inspect')console.log('sha256:'+'a'.repeat(64));`,{mode:0o755});
|
|
463
502
|
const wrapper=path.join(f.root,'supervisor.mjs'),module=new URL('../src/updates/supervisor.mjs',import.meta.url).href;
|
|
464
503
|
await fs.writeFile(wrapper,`import {supervise} from ${JSON.stringify(module)};const a=new AbortController();process.on('SIGTERM',()=>a.abort());await supervise(${JSON.stringify(f.config.deploymentDir)},a.signal,{discover:async()=>{${provider==='pnpm' ? "throw Error('Synthetic discovery failure')" : 'return []'}}});`);
|
|
@@ -473,6 +512,7 @@ for(const provider of ['pnpm','corepack']) test(`supervisor with only ${provider
|
|
|
473
512
|
const requester=await exec(process.execPath,['--input-type=module','-e',`import {submit} from ${JSON.stringify(new URL('../src/updates/control.mjs',import.meta.url).href)};console.log(JSON.stringify(await submit(${JSON.stringify(f.home)},${JSON.stringify(job.id)},false)));`]);
|
|
474
513
|
assert.equal(JSON.parse(requester.stdout).status,'queued');
|
|
475
514
|
await new Promise(r=>setTimeout(r,1800));assert.equal((await read(path.join(jobPath(f.home,job.id),'job.json'))).status,'queued');
|
|
515
|
+
await assert.rejects(fs.readFile(path.join(f.agent.controlDir,'upgrade-pause.json')),e=>e.code==='ENOENT');
|
|
476
516
|
await fs.rm(running);
|
|
477
517
|
await wait(async()=>{const j=await read(path.join(jobPath(f.home,job.id),'job.json'));if(j.status==='failed'||j.status==='rolled-back')throw Error(JSON.stringify(j)+first.output());return j.status==='completed';});
|
|
478
518
|
const newBeat=await heartbeat();assert.notEqual(newBeat.pid,oldBeat.pid);assert.equal(await firstClosed,0,first.output());
|
|
@@ -492,6 +532,26 @@ for(const provider of ['pnpm','corepack']) test(`supervisor with only ${provider
|
|
|
492
532
|
const closed3=new Promise(r=>third.p.once('close',r));third.p.kill('SIGTERM');assert.equal(await closed3,0,third.output());
|
|
493
533
|
});
|
|
494
534
|
|
|
535
|
+
test('queued upgrades keep admission open while busy and release a raced or aborted pause',async t=>{
|
|
536
|
+
const root=await fs.mkdtemp(path.join(tmpdir(),'ez-upgrade-admission-'));
|
|
537
|
+
t.after(()=>fs.rm(root,{recursive:true,force:true}));
|
|
538
|
+
const pause=path.join(root,'upgrade-pause.json'),job={id:'test'},abort=new AbortController();
|
|
539
|
+
const absent=()=>assert.rejects(fs.readFile(pause),e=>e.code==='ENOENT');
|
|
540
|
+
let applied=0;
|
|
541
|
+
const apply=async()=>{assert.equal((await read(pause)).id,job.id);applied++;return 'applied';};
|
|
542
|
+
await withIdleUpgrade(root,job,abort.signal,apply,{isIdle:async()=>false});
|
|
543
|
+
await absent();assert.equal(applied,0);
|
|
544
|
+
let checks=0;
|
|
545
|
+
await withIdleUpgrade(root,job,abort.signal,apply,{isIdle:async()=>++checks===1,wait:async()=>{}});
|
|
546
|
+
await absent();assert.equal(applied,0);
|
|
547
|
+
assert.equal(await withIdleUpgrade(root,job,abort.signal,apply,{isIdle:async()=>true,wait:async()=>{}}),'applied');
|
|
548
|
+
await absent();assert.equal(applied,1);
|
|
549
|
+
await assert.rejects(withIdleUpgrade(root,job,abort.signal,async()=>{throw Error('apply failed');},{isIdle:async()=>true,wait:async()=>{}}),/apply failed/);
|
|
550
|
+
await absent();
|
|
551
|
+
await withIdleUpgrade(root,job,abort.signal,apply,{isIdle:async()=>true,wait:async()=>abort.abort()});
|
|
552
|
+
await absent();assert.equal(applied,1);
|
|
553
|
+
});
|
|
554
|
+
|
|
495
555
|
test('npm candidates verify exact version and integrity; automatic policy is enforced again at execution',async t=>{
|
|
496
556
|
const f=await fixture(t),data=await fs.readFile(await f.pack()),original=globalThis.fetch;
|
|
497
557
|
const {createHash}=await import('node:crypto');
|
|
@@ -590,7 +650,7 @@ for(const mode of ['exact','wrong-image','unhealthy']) test(`recovery reuses onl
|
|
|
590
650
|
const f=await fixture(t),job=await queued(f),r=runtime(f);await perform(f.home,job,r);
|
|
591
651
|
const interrupted=await read(path.join(jobPath(f.home,job.id),'job.json'));interrupted.status='applying';
|
|
592
652
|
const calls=[],execute=async(command,args,options)=>{
|
|
593
|
-
calls.push([command,...args]);
|
|
653
|
+
calls.push([command,...args]);if(args.includes('view'))return JSON.stringify(latest);
|
|
594
654
|
if(args.includes('ps'))return 'b'.repeat(64);
|
|
595
655
|
if(args[0]==='inspect'&&args.includes('{{json .}}'))return JSON.stringify({Image:'sha256:'+(mode==='wrong-image'?'c':'a').repeat(64),State:{Running:true,Health:{Status:mode==='unhealthy'?'unhealthy':'healthy'}}});
|
|
596
656
|
if(mode==='exact'&&args.includes('up'))throw Error('Pinned image index no longer exists');
|