@jc_stack/ez-agents 0.1.0-beta.18 → 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.
Files changed (56) hide show
  1. package/.env.example +5 -0
  2. package/AGENTS.md +6 -0
  3. package/CHANGELOG.md +30 -0
  4. package/CONTRIBUTING.md +29 -3
  5. package/Dockerfile +6 -0
  6. package/README.md +8 -2
  7. package/bin/ezenciel-agents-watch.mjs +8 -0
  8. package/compose.workforce-watch.yaml +33 -0
  9. package/docker/healthcheck.mjs +11 -4
  10. package/docs/architecture/ai-selection.md +18 -9
  11. package/docs/docker-runtime.md +7 -5
  12. package/docs/plugin-catalog.md +1 -0
  13. package/docs/plugins.md +34 -0
  14. package/docs/responsive-channels.md +57 -0
  15. package/docs/scheduling.md +6 -4
  16. package/docs/setup.md +10 -6
  17. package/docs/workforce-watch.md +101 -0
  18. package/package.json +4 -2
  19. package/src/agent-guidance.ts +4 -0
  20. package/src/ai.ts +14 -6
  21. package/src/control-state.ts +5 -3
  22. package/src/desktop-bridge.ts +4 -2
  23. package/src/executor.ts +4 -2
  24. package/src/host-executor-client.ts +7 -1
  25. package/src/index.ts +58 -18
  26. package/src/menu.ts +18 -12
  27. package/src/model-policy.ts +8 -5
  28. package/src/plugins/manager.mjs +70 -2
  29. package/src/reply-context.ts +7 -3
  30. package/src/reply-executor.ts +2 -1
  31. package/src/reply-mcp.ts +1 -1
  32. package/src/runs.ts +0 -13
  33. package/src/schedule-cli.ts +1 -1
  34. package/src/scheduled-tasks.ts +33 -0
  35. package/src/scheduler.ts +11 -2
  36. package/src/setup.ts +2 -2
  37. package/src/task-executor.ts +2 -1
  38. package/src/updates/runtime.mjs +15 -2
  39. package/src/workforce-watch-cli.ts +14 -0
  40. package/src/workforce-watch.ts +155 -0
  41. package/templates/agent-guidance.md +24 -0
  42. package/templates/chat-guidance.md +23 -0
  43. package/test/agent-guidance.test.ts +28 -0
  44. package/test/ai.test.ts +80 -1
  45. package/test/event-sources.test.ts +4 -0
  46. package/test/failure.test.ts +40 -8
  47. package/test/host-executor.test.ts +16 -0
  48. package/test/intake-relay.test.ts +15 -3
  49. package/test/model-policy.test.ts +9 -1
  50. package/test/plugin-manager.test.mjs +49 -0
  51. package/test/reply.test.ts +22 -0
  52. package/test/runs.test.ts +7 -0
  53. package/test/schedule-cli.test.ts +2 -0
  54. package/test/scheduled-tasks.test.ts +43 -0
  55. package/test/updates.test.mjs +15 -0
  56. package/test/workforce-watch.test.ts +180 -0
@@ -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
- const until=async(check:()=>Promise<boolean>)=>{for(let n=0;n<200;n++){if(await check())return;await new Promise(r=>setTimeout(r,20))}throw new Error('Timed out')}
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,7 +140,30 @@ 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
 
140
- for (const cleanupFails of [false,true]) test(`fatal polling conflict waits for shared shutdown without retrying (cleanup fails: ${cleanupFails})`, async t => {
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
+
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
143
169
  const gate=new Promise<void>(resolve=>{release=resolve}),cleaning=new Promise<void>(resolve=>{entered=resolve})
@@ -170,20 +196,26 @@ for (const cleanupFails of [false,true]) test(`fatal polling conflict waits for
170
196
  const delivery=relay.drainOutbox()
171
197
  await deliveryStarted
172
198
  let finished=false
173
- const start=assert.rejects(relay.start(),/409.*Conflict/).then(()=>{finished=true})
174
- await cleaning
199
+ const start=relay.start().finally(()=>{finished=true})
200
+ await until(async()=>polls===1)
201
+ assert.equal(sourceStops,0,'a polling conflict must not stop the relay')
202
+ assert.equal((await runs.get('tg_92'))?.status,'running')
203
+ assert.ok(child && child.exitCode===null && child.signalCode===null)
175
204
  const stopping=relay.stop()
176
205
  assert.equal(relay.stop(),stopping,'concurrent stop calls share one promise')
177
206
  const stopped=cleanupFails?assert.rejects(stopping,/Synthetic shutdown failure/):stopping
178
- assert.equal(finished,false,'polling failure must wait for executor cleanup')
207
+ assert.equal(finished,false,'relay start must wait for the explicit shutdown')
208
+ await cleaning
179
209
  release()
180
210
  await until(async()=>(await runs.get('tg_92'))?.status!=='running')
181
- assert.equal(finished,false,'polling failure must wait for the in-flight delivery receipt')
182
- releaseDelivery();await delivery;await start;await stopped
211
+ assert.equal(finished,false,'shutdown must wait for the in-flight delivery receipt')
212
+ releaseDelivery();await delivery
213
+ if(cleanupFails) await assert.rejects(start,/Synthetic shutdown failure/);else await start
214
+ await stopped
183
215
  assert.equal(relay.stop(),stopping,'finished shutdown remains idempotent')
184
216
  assert.equal(sourceStops,1)
185
217
  assert.deepEqual(JSON.parse(await readFile(join(dir,'outbox',`${item.id}.sent.json`),'utf8')).receipt.messageIds,[1])
186
- assert.equal(polls,1,'a conflict must not start another polling loop')
218
+ assert.equal(polls,1,'a conflict must not start another polling loop before the retry delay')
187
219
  assert.equal(relay.bot.isRunning(),false)
188
220
  assert.ok(child && (child.exitCode!==null || child.signalCode!==null))
189
221
  assert.notEqual((await runs.get('tg_92'))?.status,'running')
@@ -160,6 +160,22 @@ test('client tolerates missing heartbeat and consumes completion before checking
160
160
  } finally {client.kill();await closed;await rm(root,{recursive:true,force:true})}
161
161
  })
162
162
 
163
+ test('client records a relay interruption before exiting 130',async()=>{
164
+ const root=await mkdtemp(path.join(tmpdir(),'ez-host-interrupt-'))
165
+ const directory=path.join(root,'host-executor');await mkdir(directory)
166
+ const client=spawn(process.execPath,['--import',fileURLToPath(new URL('../node_modules/tsx/dist/loader.mjs',import.meta.url)),fileURLToPath(new URL('../src/host-executor-client.ts',import.meta.url)),root,'tg_97'],{stdio:['pipe','pipe','pipe']})
167
+ let stderr='';client.stderr.on('data',chunk=>stderr+=chunk);client.stdout.resume()
168
+ client.stdin.end(JSON.stringify({texts:['test'],options:{}}))
169
+ try {
170
+ for(let n=0;n<100;n++){try{await readFile(path.join(directory,'tg_97.request.json'));break}catch{await new Promise(r=>setTimeout(r,20))}}
171
+ const closed=new Promise<number|null>(resolve=>client.once('close',resolve))
172
+ client.kill('SIGTERM')
173
+ assert.equal(await closed,130)
174
+ assert.equal(await readFile(path.join(directory,'tg_97.cancel'),'utf8'),'')
175
+ assert.match(stderr,/Host executor client interrupted by SIGTERM/)
176
+ } finally {if(client.exitCode===null && client.signalCode===null)client.kill();await rm(root,{recursive:true,force:true})}
177
+ })
178
+
163
179
  test('client cancels on stale or invalid heartbeat instead of waiting indefinitely',async()=>{
164
180
  for(const heartbeat of [{at:Date.now()-60000},{at:'invalid'}]) {
165
181
  const root=await mkdtemp(path.join(tmpdir(),'ez-heartbeat-invalid-'))
@@ -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; saved AI buttons work and forged/stale buttons cannot change settings', async () => {
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,11 +220,23 @@ 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(6, pick))
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(7, '/settings'))
234
+ await f.relay.bot.handleUpdate(message(9, '/settings'))
227
235
  assert.match(f.replies.at(-1)!, /Default for new conversations/)
236
+ await f.relay.bot.handleUpdate(message(8, '/status'))
237
+ assert.ok(f.keyboards.at(-1)!.flat().some((button) => button.text === 'Scheduled tasks'))
238
+ await f.relay.bot.handleUpdate(callback(9, 'menu:scheduled-tasks'))
239
+ assert.match(f.replies.at(-1)!, /No scheduled tasks for this owner/)
228
240
  assert.equal(f.launched.length, 0)
229
241
  } finally { await f.close() }
230
242
  })
@@ -11,7 +11,7 @@ import { taskArguments } from '../src/task-executor.js'
11
11
  import { runCodexSession } from '../src/codex-session.js'
12
12
  import { runDesktopTurn } from '../src/desktop-bridge.js'
13
13
 
14
- test('all model selections and launches reject effort above high before spawning', async () => {
14
+ test('all non-Luna model selections and launches reject effort above high before spawning', async () => {
15
15
  for (const cli of ['codex', 'codex-gui', 'grok', 'claude', 'opencode', 'agy']) {
16
16
  for (const effort of ['xhigh', 'max', 'ultra', 'unknown']) {
17
17
  const preset = { id:'blocked', name:'Blocked', cli, model:'any-model', effort }
@@ -23,6 +23,14 @@ test('all model selections and launches reject effort above high before spawning
23
23
  await assert.rejects(runDesktopTurn({workspace:'/unused',controlDir:'/unused',binDir:'/unused',runId:'unused',prompt:'',effort:'ultra'}), /capped at high/)
24
24
  })
25
25
 
26
+ test('Codex Luna accepts xhigh while every other model and CLI remains capped', async () => {
27
+ const luna = { id:'luna', name:'Luna', cli:'codex', model:'gpt-5.6-luna', effort:'xhigh' }
28
+ await validateSelection(luna, [{ cli:'codex', model:'gpt-5.6-luna', name:'Luna', efforts:['high','xhigh'] }], async () => true)
29
+ assert.deepEqual(executionDefaults('codex', { model:'gpt-5.6-luna', effort:'xhigh' }), { model:'gpt-5.6-luna', effort:'xhigh' })
30
+ assert.throws(() => executionDefaults('grok', { model:'gpt-5.6-luna', effort:'xhigh' }), /capped at high/)
31
+ assert.throws(() => executionDefaults('codex', { model:'gpt-5.6-terra', effort:'xhigh' }), /capped at high/)
32
+ })
33
+
26
34
  test('restricted tasks pin Terra high, preserve explicit choices and reject higher effort', () => {
27
35
  const args = taskArguments('/unused', ['broker'], 'prompt')
28
36
  assert.equal(args[args.indexOf('--model')+1], 'gpt-5.6-terra')
@@ -225,3 +225,52 @@ test('standalone rejects relay binding and preserves literal plugin arguments ac
225
225
  await fs.writeFile(path.join(f.home,'config.json'),JSON.stringify({schemaVersion:1,workspace:f.workspace,catalog:{},deploymentDir:'/missing'}));
226
226
  await assert.rejects(exec(launcher,['status']),/deployment-bound/); // never hide a broken relay binding
227
227
  });
228
+
229
+ test('existing folders are read-only, persistent and fail closed when missing', async t => {
230
+ const f=await fixture(t);await init(f.home,f.workspace);
231
+ const inspected=JSON.parse((await f.call('plugins','inspect','sample','--source',f.source)).stdout);
232
+ await f.call('plugins','install','sample','--source',f.source,'--revision',inspected.revision);
233
+ const source=await fs.realpath(f.workspace);
234
+ await f.call('plugins','folder-bind','sample','--service','sample','--source',source,'--target','/data/files');
235
+ const config=JSON.parse(await fs.readFile(path.join(f.home,'config.json'),'utf8'));
236
+ assert.deepEqual(config.folders.sample,[{service:'sample',source,target:'/data/files'}]);
237
+ const c=JSON.parse(await fs.readFile(path.join(f.home,'packages/sample/compose.json'),'utf8'));
238
+ assert.deepEqual(c.services.sample.volumes.find(v=>v.target==='/data/files'),{type:'bind',source,target:'/data/files',read_only:true,bind:{create_host_path:false}});
239
+ await assert.rejects(f.call('plugins','folder-bind','sample','--service','sample','--source',source,'--target','/data'),/child of a declared volume|Overlapping/);
240
+ await assert.rejects(f.call('plugins','folder-bind','sample','--service','sample','--source',source,'--target','/data/files/child'),/Overlapping/);
241
+ await assert.rejects(f.call('plugins','folder-bind','sample','--service','sample','--source',await fs.realpath(f.home),'--target','/data/private'),/private plugin state/);
242
+ await assert.rejects(f.call('plugins','folder-bind','sample','--service','sample','--source','/','--target','/data/root'),/private plugin state/);
243
+ await f.call('plugins','start','sample');
244
+ await fs.rename(f.workspace,f.workspace+'-moved');
245
+ await assert.rejects(f.call('plugins','start','sample'),/ENOENT/);
246
+ await assert.rejects(f.call('sample','read'),/ENOENT/);
247
+ await f.call('plugins','folder-unbind','sample','--service','sample','--target','/data/files');
248
+ assert.deepEqual(JSON.parse((await f.call('plugins','folders','sample')).stdout),[]);
249
+ });
250
+
251
+ test('folder bindings survive compatible descriptors and reject incompatible updates',async t=>{
252
+ const f=await fixture(t);const p=await snapshot(f.source);
253
+ const record={...p,project:'ezp-test-sample'};
254
+ const config={workspace:f.workspace,folders:{sample:[{service:'sample',source:f.workspace,target:'/data/files'}]}};
255
+ assert.equal(compose(config,record).services.sample.volumes.find(v=>v.target==='/data/files').read_only,true);
256
+ const next=structuredClone(record);next.deployment.services.sample.volumes={data:'/new-state'};
257
+ assert.throws(()=>compose(config,next),/child of a declared volume/);
258
+ });
259
+
260
+ test('folder rebind rejects every live project container including one-shots',async t=>{
261
+ const f=await fixture(t);await init(f.home,f.workspace);const p=await snapshot(f.source);
262
+ await f.call('plugins','install','sample','--source',f.source,'--revision',p.revision);
263
+ await fs.writeFile(path.join(f.fake,'docker'),`#!${process.execPath}\nif(process.argv[2]==='ps')console.log('paused-or-restarting-or-one-shot');\n`,{mode:0o700});
264
+ await assert.rejects(f.call('plugins','folder-bind','sample','--service','sample','--source',await fs.realpath(f.workspace),'--target','/data/files'),/Stop the plugin/);
265
+ });
266
+
267
+ test('registered calls honor registry lock and regenerate stale Compose from current folders',async t=>{
268
+ const f=await fixture(t);await init(f.home,f.workspace);const p=await snapshot(f.source);
269
+ await f.call('plugins','install','sample','--source',f.source,'--revision',p.revision);
270
+ await f.call('plugins','folder-bind','sample','--service','sample','--source',await fs.realpath(f.workspace),'--target','/data/files');
271
+ const file=path.join(f.home,'packages/sample/compose.json');await fs.writeFile(file,'{}');
272
+ await f.call('sample','read');
273
+ assert.equal(JSON.parse(await fs.readFile(file,'utf8')).services.sample.volumes.find(v=>v.target==='/data/files').read_only,true);
274
+ await fs.writeFile(path.join(f.home,'registry.lock'),'test');
275
+ await assert.rejects(f.call('sample','read'),/busy|EEXIST|locked/i);
276
+ });
@@ -129,3 +129,25 @@ test('a parallel reply delivered during a normal turn is retained for the follow
129
129
  assert.deepEqual(await parallelReplyHistory(root,current),[])
130
130
  }finally{await rm(root,{recursive:true,force:true})}
131
131
  })
132
+
133
+
134
+ test('reply handoff accepts independent worker choices and rejects invalid or unauthorized overrides', async () => {
135
+ const root=await mkdtemp(join(tmpdir(),'ez-reply-worker-')), runs=new RunStore(root)
136
+ try {
137
+ await ownerRun(root,'owner')
138
+ await runs.create({id:'tg_10',chatId:101,telegramUserId:101,texts:['Analyze the report'],execution:{sessionId:'c5dd1edc-be24-47b8-a579-0bc70f44cf43',preset:{id:'chat',name:'Chat',cli:'codex',model:'gpt-5.6-sol',effort:'medium'}}})
139
+ await runs.patch('tg_10',{status:'running',replyOnly:true})
140
+ for (const args of [{model:42}, {model:'bad model'}, {effort:'ultra'}, {effort:'invalid'}, {cli:'claude'}])
141
+ await assert.rejects(replyCall(root,'tg_10',root,'defer',{text:'Analyze and verify the result',...args}))
142
+ await assert.rejects(replyCall(root,'tg_10',root,'send',{text:'Hello',model:'gpt-6-astra'}),/Unexpected/)
143
+ await replyCall(root,'tg_10',root,'defer',{text:'Analyze and verify the result',model:'gpt-6-astra',effort:'high'})
144
+ const file=join(root,'schedules','s_reply_tg_10.json')
145
+ const saved=JSON.parse(await readFile(file,'utf8'))
146
+ assert.equal(saved.execution.preset.model,'gpt-6-astra')
147
+ assert.equal(saved.execution.preset.effort,'high')
148
+ await replyCall(root,'tg_10',root,'defer',{text:'retry',model:'gpt-5.6-sol',effort:'low'})
149
+ assert.deepEqual(JSON.parse(await readFile(file,'utf8')),saved)
150
+ await new ControlStore(root,900000).revokeOwner()
151
+ await assert.rejects(replyCall(root,'tg_10',root,'defer',{text:'after revocation',model:'gpt-6-astra'}),/owner-mismatch/)
152
+ } finally { await rm(root,{recursive:true,force:true}) }
153
+ })
package/test/runs.test.ts CHANGED
@@ -22,6 +22,13 @@ test('creates a queued run and binds chat id outside the workspace', async () =>
22
22
  assert.equal((await store.get(created.id))?.chatId, 101)
23
23
  }))
24
24
 
25
+ test('running does not reap a process from another executor namespace', async () => fixture(async (store) => {
26
+ const created = await store.create({ chatId: 101, telegramUserId: 101, texts: ['host-backed'] })
27
+ await store.patch(created.id, { status: 'running', pid: 999_999_999 })
28
+ assert.equal((await store.running())?.id, created.id)
29
+ assert.equal((await store.get(created.id))?.status, 'running')
30
+ }))
31
+
25
32
  test('ez message writes an outbox item for the bound run, not a telegram send', async () => fixture(async (store) => {
26
33
  const created = await store.create({ chatId: 9, telegramUserId: 9, texts: ['hello'] })
27
34
  await store.patch(created.id, { status: 'running' })
@@ -24,6 +24,8 @@ test('public scheduler CLI saves literal text, reads back, edits, pauses, and re
24
24
  assert.equal(saved.text,args.at(-1));assert.equal(saved.execution.preset.cli,'codex')
25
25
  assert.equal(saved.execution.preset.model,'gpt-5.6-terra');assert.equal(saved.execution.preset.effort,'high')
26
26
  await assert.rejects(exec(process.execPath,[bin,'create','blocked','--at','2027-09-09T09:00:00+04:00','--text','test','--effort','xhigh'],{env}),/capped at high/)
27
+ const luna=JSON.parse((await exec(process.execPath,[bin,'create','luna','--at','2027-09-10T09:00:00+04:00','--text','Luna xhigh task','--model','gpt-5.6-luna','--effort','xhigh'],{env})).stdout)
28
+ assert.equal(luna.execution.preset.model,'gpt-5.6-luna');assert.equal(luna.execution.preset.effort,'xhigh')
27
29
  assert.equal(saved.nextEligibleAt,'2027-09-09T05:00:00.000Z')
28
30
  await assert.rejects(exec(process.execPath,[bin,...args],{env}),/exists/)
29
31
  assert.equal(JSON.parse((await exec(process.execPath,[bin,'pause','test'],{env})).stdout).enabled,false)
@@ -0,0 +1,43 @@
1
+ import test from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { access, mkdtemp, readFile, readdir, rm } from 'node:fs/promises'
4
+ import { randomUUID } from 'node:crypto'
5
+ import { join } from 'node:path'
6
+ import { tmpdir } from 'node:os'
7
+ import { Scheduler } from '../src/scheduler.js'
8
+ import { scheduledTasksText } from '../src/scheduled-tasks.js'
9
+
10
+ const owner = { telegramUserId: 101, telegramChatId: 101, pairedAt: '2026-09-11T00:00:00.000Z' }
11
+ const execution = { sessionId: randomUUID(), preset: { id: 'fixture', name: 'Fixture', cli: 'codex' } }
12
+
13
+ test('scheduled task view is read-only, owner-bound, and shows stored task contents', async (t) => {
14
+ const dir = await mkdtemp(join(tmpdir(), 'ez-scheduled-tasks-'))
15
+ t.after(() => rm(dir, { recursive: true, force: true }))
16
+ const scheduler = new Scheduler(dir)
17
+
18
+ assert.deepEqual(await scheduler.listReadOnly(), [])
19
+ await assert.rejects(access(join(dir, 'schedules')), /ENOENT/)
20
+
21
+ await scheduler.save({
22
+ id: 'owner-task', name: 'Daily report', text: 'Read the ledger and send the owner a concise report.', owner, execution, enabled: true,
23
+ trigger: { cron: '0 9 * * 1-5', timezone: 'Asia/Dubai', start: '2026-01-01T00:00:00.000Z' },
24
+ })
25
+ await scheduler.save({
26
+ id: 'other-task', name: 'Other owner task', text: 'This must never be visible.',
27
+ owner: { ...owner, telegramUserId: 202, telegramChatId: 202 }, execution, enabled: true,
28
+ trigger: { at: '2027-01-01T00:00:00.000Z' },
29
+ })
30
+ const scheduleDir = join(dir, 'schedules')
31
+ const before = await readFile(join(scheduleDir, 'owner-task.json'), 'utf8')
32
+ const entries = await readdir(scheduleDir)
33
+ const text = scheduledTasksText(await scheduler.listReadOnly(), owner, Date.parse('2026-09-11T00:00:00.000Z'))
34
+
35
+ assert.match(text, /Title: Daily report/)
36
+ assert.match(text, /Instructions:\nRead the ledger and send the owner a concise report\./)
37
+ assert.match(text, /Timing: Cron 0 9 \* \* 1-5 · Asia\/Dubai/)
38
+ assert.match(text, /State: Scheduled/)
39
+ assert.match(text, /Next run: 2026-09-11T05:00:00.000Z/)
40
+ assert.doesNotMatch(text, /Other owner task|This must never be visible/)
41
+ assert.equal(await readFile(join(scheduleDir, 'owner-task.json'), 'utf8'), before)
42
+ assert.deepEqual(await readdir(scheduleDir), entries)
43
+ })
@@ -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')});
@@ -0,0 +1,180 @@
1
+ import assert from 'node:assert/strict'
2
+ import test from 'node:test'
3
+ import { mkdtemp, rm } from 'node:fs/promises'
4
+ import { tmpdir } from 'node:os'
5
+ import { join } from 'node:path'
6
+ import { WorkforceWatch, WorkforceWatchServer } from '../src/workforce-watch.js'
7
+
8
+ const fixture = async () => {
9
+ const stateDir = await mkdtemp(join(tmpdir(), 'workforce-watch-'))
10
+ let now = 0
11
+ const notices: string[] = []
12
+ const watch = new WorkforceWatch({ stateDir, enrollmentToken: 'fleet-secret', now: () => now, notify: async text => { notices.push(text) } })
13
+ return { stateDir, watch, notices, advance: (ms: number) => { now += ms }, close: () => rm(stateDir, { recursive: true, force: true }) }
14
+ }
15
+
16
+ test('worker enrollment, terminal alert context, and sustained recovery preserve only redacted state', async () => {
17
+ const f = await fixture()
18
+ try {
19
+ await assert.rejects(() => f.watch.enroll('wrong', { workerId: 'stocks-production', checkInSeconds: 10, graceSeconds: 0 }), /Unauthorized/)
20
+ const enrolled = await f.watch.enroll('fleet-secret', { workerId: 'stocks-production', checkInSeconds: 10, graceSeconds: 0, runbookUrl: 'https://runbooks.example/stocks' })
21
+ await assert.rejects(() => f.watch.checkIn('stocks-production', 'wrong', { status: 'ok' }), /Unauthorized/)
22
+ await f.watch.checkIn(enrolled.workerId, enrolled.workerToken, { status: 'failed', terminal: true, activity: 'IBKR session probe', error: 'No broker server time', runId: 'daily-42', logsHint: 'journalctl --user -u ez-stocks.service --since 30m' })
23
+ assert.match(f.notices[0], /IBKR session probe/)
24
+ assert.match(f.notices[0], /No broker server time/)
25
+ const state = await f.watch.inspect('stocks-production') as Record<string, unknown>
26
+ assert.equal('tokenHash' in state, false)
27
+ assert.deepEqual((state.history as Array<Record<string, unknown>>).at(-1)?.runId, 'daily-42')
28
+ await f.watch.checkIn(enrolled.workerId, enrolled.workerToken, { status: 'ok', activity: 'retry one' })
29
+ await f.watch.checkIn(enrolled.workerId, enrolled.workerToken, { status: 'ok', activity: 'retry two' })
30
+ assert.equal(f.notices.length, 2)
31
+ assert.match(f.notices[1], /recovered/)
32
+ } finally { await f.close() }
33
+ })
34
+
35
+ test('a missed check-in alerts once and requires clean check-ins to recover', async () => {
36
+ const f = await fixture()
37
+ try {
38
+ const enrolled = await f.watch.enroll('fleet-secret', { workerId: 'annie-pa', checkInSeconds: 10, graceSeconds: 2, severity: 'warning' })
39
+ f.advance(12_001)
40
+ await f.watch.evaluate(); await f.watch.evaluate()
41
+ assert.equal(f.notices.length, 1)
42
+ assert.match(f.notices[0], /missed check-in/)
43
+ await f.watch.checkIn(enrolled.workerId, enrolled.workerToken, { status: 'ok', activity: 'relay available' })
44
+ assert.equal(f.notices.length, 1)
45
+ await f.watch.checkIn(enrolled.workerId, enrolled.workerToken, { status: 'ok', activity: 'relay available' })
46
+ assert.equal(f.notices.length, 2)
47
+ } finally { await f.close() }
48
+ })
49
+
50
+ test('PagerDuty uses one worker-specific deduplication key and resolves only a triggered incident', async () => {
51
+ const stateDir = await mkdtemp(join(tmpdir(), 'workforce-watch-'))
52
+ const pages: Array<{ action: string; dedupKey: string; customDetails: Record<string, string> }> = []
53
+ const watch = new WorkforceWatch({ stateDir, enrollmentToken: 'fleet-secret', page: async event => { pages.push(event) } })
54
+ try {
55
+ const enrolled = await watch.enroll('fleet-secret', { workerId: 'stocks', checkInSeconds: 10, graceSeconds: 0, severity: 'critical' })
56
+ await watch.checkIn(enrolled.workerId, enrolled.workerToken, { status: 'failed', terminal: true, activity: 'broker probe', error: 'gateway unavailable', logsHint: 'journalctl -u stocks' })
57
+ await watch.checkIn(enrolled.workerId, enrolled.workerToken, { status: 'ok' })
58
+ await watch.checkIn(enrolled.workerId, enrolled.workerToken, { status: 'ok' })
59
+ assert.deepEqual(pages.map(page => [page.action, page.dedupKey]), [['trigger', 'ez:workforce:stocks'], ['resolve', 'ez:workforce:stocks']])
60
+ assert.equal(pages[0]?.customDetails.error, 'gateway unavailable')
61
+ } finally { await rm(stateDir, { recursive: true, force: true }) }
62
+ })
63
+
64
+ test('PagerDuty trigger is not duplicated when supplemental Telegram delivery fails', async () => {
65
+ const stateDir = await mkdtemp(join(tmpdir(), 'workforce-watch-'))
66
+ const pages: string[] = []
67
+ const watch = new WorkforceWatch({ stateDir, enrollmentToken: 'fleet-secret', page: async event => { pages.push(event.action) }, notify: async () => { throw new Error('Telegram unavailable') } })
68
+ try {
69
+ const enrolled = await watch.enroll('fleet-secret', { workerId: 'aifit', checkInSeconds: 10, graceSeconds: 0 })
70
+ await assert.rejects(() => watch.checkIn(enrolled.workerId, enrolled.workerToken, { status: 'failed', terminal: true }), /Telegram unavailable/)
71
+ await assert.rejects(() => watch.evaluate(), /Telegram unavailable/)
72
+ assert.deepEqual(pages, ['trigger'])
73
+ } finally { await rm(stateDir, { recursive: true, force: true }) }
74
+ })
75
+
76
+ test('a failed recovery notification stays pending and is retried on evaluation', async () => {
77
+ const stateDir = await mkdtemp(join(tmpdir(), 'workforce-watch-'))
78
+ let now = 0, failRecovery = true
79
+ const notices: string[] = []
80
+ const watch = new WorkforceWatch({ stateDir, enrollmentToken: 'fleet-secret', now: () => now, notify: async text => {
81
+ if (text.includes('recovered') && failRecovery) throw new Error('Telegram unavailable')
82
+ notices.push(text)
83
+ } })
84
+ try {
85
+ const enrolled = await watch.enroll('fleet-secret', { workerId: 'dusk-dune', checkInSeconds: 10, graceSeconds: 0 })
86
+ await watch.checkIn(enrolled.workerId, enrolled.workerToken, { status: 'failed', terminal: true })
87
+ await watch.checkIn(enrolled.workerId, enrolled.workerToken, { status: 'ok' })
88
+ await assert.rejects(() => watch.checkIn(enrolled.workerId, enrolled.workerToken, { status: 'ok' }), /Telegram unavailable/)
89
+ assert.ok((await watch.inspect('dusk-dune') as { incident?: unknown }).incident)
90
+ failRecovery = false; now += 1
91
+ await watch.evaluate()
92
+ assert.equal((await watch.inspect('dusk-dune') as { incident?: unknown }).incident, undefined)
93
+ assert.equal(notices.length, 2)
94
+ } finally { await rm(stateDir, { recursive: true, force: true }) }
95
+ })
96
+
97
+ test('an incident whose opening alert never delivered resolves quietly', async () => {
98
+ const stateDir = await mkdtemp(join(tmpdir(), 'workforce-watch-'))
99
+ const notices: string[] = []
100
+ const watch = new WorkforceWatch({ stateDir, enrollmentToken: 'fleet-secret', notify: async text => {
101
+ if (text.includes('alert')) throw new Error('Telegram unavailable')
102
+ notices.push(text)
103
+ } })
104
+ try {
105
+ const enrolled = await watch.enroll('fleet-secret', { workerId: 'jc-stack', checkInSeconds: 10, graceSeconds: 0 })
106
+ await assert.rejects(() => watch.checkIn(enrolled.workerId, enrolled.workerToken, { status: 'failed', terminal: true }), /Telegram unavailable/)
107
+ await assert.rejects(() => watch.checkIn(enrolled.workerId, enrolled.workerToken, { status: 'ok' }), /Telegram unavailable/)
108
+ await watch.checkIn(enrolled.workerId, enrolled.workerToken, { status: 'ok' })
109
+ assert.deepEqual(notices, [])
110
+ assert.equal((await watch.inspect('jc-stack') as { incident?: unknown }).incident, undefined)
111
+ } finally { await rm(stateDir, { recursive: true, force: true }) }
112
+ })
113
+
114
+ test('an enrollment-authorized token rotation invalidates the prior worker secret', async () => {
115
+ const f = await fixture()
116
+ try {
117
+ const enrolled = await f.watch.enroll('fleet-secret', { workerId: 'aifit', checkInSeconds: 10, graceSeconds: 0 })
118
+ await assert.rejects(() => f.watch.rotate('wrong', 'aifit'), /Unauthorized/)
119
+ const rotated = await f.watch.rotate('fleet-secret', 'aifit')
120
+ await assert.rejects(() => f.watch.checkIn('aifit', enrolled.workerToken, { status: 'ok' }), /Unauthorized/)
121
+ await f.watch.checkIn('aifit', rotated.workerToken, { status: 'ok' })
122
+ } finally { await f.close() }
123
+ })
124
+
125
+ test('HTTP enrollment and check-in endpoints reject secrets not owned by the caller', async () => {
126
+ const f = await fixture(), server = new WorkforceWatchServer(f.watch, 'fleet-secret', () => {})
127
+ try {
128
+ await server.listen(0, '127.0.0.1')
129
+ const base = `http://127.0.0.1:${server.port()}`
130
+ assert.equal((await fetch(`${base}/v1/workers`)).status, 401)
131
+ const enrollment = await fetch(`${base}/v1/enroll`, { method: 'POST', headers: { authorization: 'Bearer fleet-secret', 'content-type': 'application/json' }, body: JSON.stringify({ workerId: 'ez-cto', checkInSeconds: 10, graceSeconds: 0 }) })
132
+ assert.equal(enrollment.status, 201)
133
+ const { workerToken } = await enrollment.json() as { workerToken: string }
134
+ assert.equal((await fetch(`${base}/v1/workers/ez-cto/check-in`, { method: 'POST', headers: { authorization: 'Bearer wrong', 'content-type': 'application/json' }, body: '{"status":"ok"}' })).status, 401)
135
+ assert.equal((await fetch(`${base}/v1/workers/ez-cto/check-in`, { method: 'POST', headers: { authorization: `Bearer ${workerToken}`, 'content-type': 'application/json' }, body: '{"status":"ok","activity":"relay started"}' })).status, 200)
136
+ const rotation = await fetch(`${base}/v1/workers/ez-cto/rotate`, { method: 'POST', headers: { authorization: 'Bearer fleet-secret' } })
137
+ assert.equal(rotation.status, 200)
138
+ const rotated = await rotation.json() as { workerToken: string }
139
+ assert.equal((await fetch(`${base}/v1/workers/ez-cto/check-in`, { method: 'POST', headers: { authorization: `Bearer ${workerToken}`, 'content-type': 'application/json' }, body: '{"status":"ok"}' })).status, 401)
140
+ assert.equal((await fetch(`${base}/v1/workers/ez-cto/check-in`, { method: 'POST', headers: { authorization: `Bearer ${rotated.workerToken}`, 'content-type': 'application/json' }, body: '{"status":"ok"}' })).status, 200)
141
+ } finally { await server.close(); await f.close() }
142
+ })
143
+
144
+ for (const recoveredChannel of ['pagerduty', 'telegram'] as const) {
145
+ for (const relapse of ['terminal', 'failed', 'missed'] as const) {
146
+ test(`a ${relapse} relapse reopens only the recovered ${recoveredChannel} channel after restart`, async () => {
147
+ const stateDir = await mkdtemp(join(tmpdir(), 'workforce-relapse-'))
148
+ let now = 0, failRecovery = true
149
+ const pages: string[] = [], notices: string[] = []
150
+ const options = { stateDir, enrollmentToken: 'synthetic', now: () => now,
151
+ page: async (event: { action: string }) => {
152
+ if (event.action === 'resolve' && recoveredChannel === 'telegram' && failRecovery) throw new Error('recovery unavailable')
153
+ pages.push(event.action)
154
+ },
155
+ notify: async (message: string) => {
156
+ if (message.includes('recovered') && recoveredChannel === 'pagerduty' && failRecovery) throw new Error('recovery unavailable')
157
+ notices.push(message.includes('recovered') ? 'resolve' : 'trigger')
158
+ } }
159
+ try {
160
+ let watch = new WorkforceWatch(options)
161
+ const worker = await watch.enroll('synthetic', { workerId: 'synthetic-worker', checkInSeconds: 10, graceSeconds: 0 })
162
+ await watch.checkIn(worker.workerId, worker.workerToken, { status: 'failed', terminal: true })
163
+ await watch.checkIn(worker.workerId, worker.workerToken, { status: 'ok' })
164
+ await assert.rejects(() => watch.checkIn(worker.workerId, worker.workerToken, { status: 'ok' }), /recovery unavailable/)
165
+ watch = new WorkforceWatch(options)
166
+ now = relapse === 'missed' ? 10_001 : 1
167
+ if (relapse === 'missed') await watch.evaluate()
168
+ else await watch.checkIn(worker.workerId, worker.workerToken, { status: 'failed', terminal: relapse === 'terminal' })
169
+ assert.deepEqual(pages, recoveredChannel === 'pagerduty' ? ['trigger', 'resolve', 'trigger'] : ['trigger'])
170
+ assert.deepEqual(notices, recoveredChannel === 'telegram' ? ['trigger', 'resolve', 'trigger'] : ['trigger'])
171
+ failRecovery = false
172
+ await watch.checkIn(worker.workerId, worker.workerToken, { status: 'ok' })
173
+ await watch.checkIn(worker.workerId, worker.workerToken, { status: 'ok' })
174
+ assert.equal((await watch.inspect(worker.workerId) as { incident?: unknown }).incident, undefined)
175
+ assert.equal(pages.at(-1), 'resolve')
176
+ assert.equal(notices.at(-1), 'resolve')
177
+ } finally { await rm(stateDir, { recursive: true, force: true }) }
178
+ })
179
+ }
180
+ }