@jc_stack/ez-agents 0.1.0-beta.46 → 0.1.0-beta.48

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 (91) hide show
  1. package/.dockerignore +2 -0
  2. package/.env.example +3 -0
  3. package/AGENTS.md +2 -2
  4. package/CHANGELOG.md +38 -0
  5. package/CONTRIBUTING.md +1 -1
  6. package/Dockerfile +18 -10
  7. package/THIRD_PARTY_NOTICES.md +6 -3
  8. package/bin/ez +7 -0
  9. package/docker/broker-readiness.mjs +54 -0
  10. package/docker/broker-update-smoke.mjs +86 -0
  11. package/docker/engine-pnpm-workspace.yaml +4 -0
  12. package/docker/run.ts +1 -1
  13. package/docker/smoke.mjs +1 -1
  14. package/docs/application-channel.md +32 -1
  15. package/docs/architecture/ai-selection.md +26 -5
  16. package/docs/architecture/authority-boundaries.md +2 -2
  17. package/docs/development-and-testing.md +1 -1
  18. package/docs/docker-runtime.md +39 -0
  19. package/docs/isolated-plugin-update-readiness.md +66 -0
  20. package/docs/plugin-guide.md +1 -1
  21. package/docs/plugins.md +1 -1
  22. package/docs/releasing.md +5 -1
  23. package/docs/scheduling.md +117 -9
  24. package/docs/selective-monitoring.md +13 -0
  25. package/docs/upgrades.md +38 -1
  26. package/docs/work-preflight.md +9 -0
  27. package/package.json +4 -3
  28. package/pnpm-workspace.yaml +7 -0
  29. package/scripts/smoke-scheduler.ts +4 -1
  30. package/src/ai.ts +41 -4
  31. package/src/application-channel.ts +43 -3
  32. package/src/application-cli.ts +7 -1
  33. package/src/application-origin.ts +2 -0
  34. package/src/audio.ts +37 -22
  35. package/src/client-defaults.ts +3 -2
  36. package/src/config.ts +9 -0
  37. package/src/control-state.ts +3 -1
  38. package/src/delivery-socket-client.d.mts +2 -0
  39. package/src/delivery-socket-client.mjs +2 -0
  40. package/src/delivery-socket.ts +38 -1
  41. package/src/event-sources.ts +2 -2
  42. package/src/executor.ts +72 -1
  43. package/src/files.ts +47 -3
  44. package/src/host-executor.ts +40 -26
  45. package/src/index.ts +42 -17
  46. package/src/install-tools.mjs +18 -9
  47. package/src/menu.ts +4 -2
  48. package/src/model-policy.ts +4 -0
  49. package/src/plugin-broker.mjs +47 -4
  50. package/src/plugins/manager.mjs +11 -4
  51. package/src/run-evidence.ts +78 -0
  52. package/src/runs.ts +16 -2
  53. package/src/schedule-cli.ts +80 -10
  54. package/src/schedule-preflight.ts +23 -0
  55. package/src/scheduled-tasks.ts +16 -12
  56. package/src/scheduler.ts +102 -16
  57. package/src/scripts.ts +167 -0
  58. package/src/setup.ts +2 -2
  59. package/src/task-capability.ts +17 -1
  60. package/src/task-executor.ts +11 -8
  61. package/src/task-mcp.ts +20 -8
  62. package/src/tasks.ts +89 -9
  63. package/src/updates/runtime.mjs +53 -11
  64. package/src/updates/supervisor.mjs +23 -13
  65. package/test/ai.test.ts +85 -5
  66. package/test/application-channel.test.ts +16 -1
  67. package/test/audio.test.ts +19 -0
  68. package/test/channel-owner.test.ts +2 -2
  69. package/test/config.test.ts +8 -0
  70. package/test/desktop-bridge.test.ts +2 -1
  71. package/test/executor.test.ts +37 -0
  72. package/test/failure.test.ts +4 -4
  73. package/test/files.test.ts +44 -0
  74. package/test/host-executor.test.ts +49 -6
  75. package/test/install-json.test.mjs +21 -0
  76. package/test/install-tools.test.mjs +6 -5
  77. package/test/native-tasks.test.ts +1 -1
  78. package/test/plugin-broker.test.mjs +64 -0
  79. package/test/plugin-manager.test.mjs +20 -0
  80. package/test/private-speech-config.test.ts +22 -0
  81. package/test/run-evidence.test.ts +78 -0
  82. package/test/schedule-cli.test.ts +47 -3
  83. package/test/schedule-preflight.test.ts +49 -0
  84. package/test/scheduled-tasks.test.ts +2 -2
  85. package/test/scheduler-host.test.ts +21 -3
  86. package/test/scheduler-relay.test.ts +84 -4
  87. package/test/scheduler.test.ts +21 -1
  88. package/test/script-schedules.test.ts +219 -0
  89. package/test/task-native.test.ts +50 -12
  90. package/test/tasks.test.ts +97 -4
  91. package/test/updates.test.mjs +77 -18
@@ -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,14 +113,17 @@ 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'}}={}) {
117
- const calls=[];let failed=false;
116
+ function runtime(f,{fail,stopped=false,buildArgs={CODEX_CLI_VERSION:'0.156.1'},latest='0.160.0'}={}) {
117
+ const calls=[];let failed=false,brokerImage='sha256:'+'a'.repeat(64);
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
- if(args.includes('ps'))return stopped?'':'container-id';
123
- if(args[0]==='inspect')return 'sha256:'+'a'.repeat(64);
122
+ if(args.includes('ps'))return stopped?'':'a'.repeat(64);
123
+ if(args.some(arg=>arg.includes('EZ_BROKER_STRUCTURAL_READINESS_FAILED'))) {const [hostSha256,registrySha256,configSha256]=args.slice(-3);return JSON.stringify({version:1,ready:true,hostSha256,registrySha256,configSha256,plugins:[]});}
124
+ if(args[0]==='image'&&args[1]==='inspect')return 'sha256:'+'b'.repeat(64);
125
+ if(args.includes('--force-recreate'))brokerImage=(await fs.readFile(args[args.lastIndexOf('--env-file')+1],'utf8')).trim().split('=')[1];
126
+ if(args[0]==='inspect')return brokerImage;
124
127
  if(args[0]==='volume'&&args[1]==='ls')return 'existing';
125
128
  if(args[0]==='volume')return JSON.stringify([{Name:args[2]}]);
126
129
  if(args[0]==='run')await fs.writeFile(opts.outputFile,'retained-private-state',{mode:0o600});
@@ -306,7 +309,7 @@ test('main transaction stages before stopping, pins rollback image, preserves st
306
309
  assert.notEqual(await fs.readFile(path.join(active,'bin/example.mjs'),'utf8'),'tampered');
307
310
  assert(r.calls.findIndex(c=>c.includes('build'))<r.calls.findIndex(c=>c[0]==='stopHost'));
308
311
  const build=r.calls.find(c=>c[0]==='docker'&&c[1]==='build');
309
- assert.equal(build[build.indexOf('--build-arg')+1],'CODEX_CLI_VERSION=0.156.1');
312
+ assert.equal(build[build.indexOf('--build-arg')+1],'CODEX_CLI_VERSION=0.160.0');
310
313
  const status=await command(f.home,['status']);assert(!JSON.stringify(status).includes('private-test-token'));assert(!('rollback'in status.jobs[0]));
311
314
  assert.equal((await fs.stat(path.join(jobPath(f.home,job.id),'job.json'))).mode&0o777,0o600);
312
315
  });
@@ -320,12 +323,14 @@ test('main activation and rollback bind guidance from the selected package root'
320
323
  assert.equal((await perform(failed.home,await queued(failed),runtime(failed,{fail:(_command,args)=>args.includes('up')}))).status,'rolled-back');
321
324
  assert.equal(await fs.readFile(path.join(failed.agent.workspace,'AGENTS.md'),'utf8'),'old guidance\n');
322
325
  });
323
- test('the same archive with different deployment CLI pins gets distinct image bindings',async t=>{
326
+ test('latest overrides stale deployment pins and changing latest changes image bindings',async t=>{
324
327
  const tags=[],hashes=[];
325
328
  for(const pin of ['0.153.4','0.156.1']) {
326
- const f=await fixture(t),job=await queued(f),r=runtime(f,{buildArgs:{CODEX_CLI_VERSION:pin}});
329
+ const f=await fixture(t),job=await queued(f),r=runtime(f,{buildArgs:{CODEX_CLI_VERSION:'0.153.4'},latest:pin});
327
330
  assert.equal((await perform(f.home,job,r)).status,'completed');
328
331
  const build=r.calls.find(c=>c[0]==='docker'&&c[1]==='build'),tag=build[build.indexOf('-t')+1];
332
+ assert.ok(build.includes(`CODEX_CLI_VERSION=${pin}`));
333
+ assert.equal((await read(path.join(jobPath(f.home,job.id),'job.json'))).codexVersion,pin);
329
334
  assert.match(await fs.readFile(path.join(f.config.deploymentDir,'docker.env'),'utf8'),new RegExp(tag));
330
335
  tags.push(tag);hashes.push(job.sha256);
331
336
  }
@@ -382,27 +387,60 @@ test('plugin updates register additive command routes on an unchanged deployment
382
387
  const dispatch=await prepareCommand(f.home,'query',[]);assert.deepEqual(dispatch.argv.slice(-4),['node','sample','/app/bin/example.mjs','query']);
383
388
  assert(r.calls.some(call=>call.includes('up')));
384
389
  });
385
- test('isolated plugin replacement refreshes broker version inventory after registry activation',async t=>{
390
+ test('isolated plugin replacement recreates only the pinned broker and verifies activation and rollback',async t=>{
386
391
  const f=await fixture(t,'plugin');
387
392
  await fs.appendFile(path.join(f.config.deploymentDir,'docker.env'),'EZ_EXECUTOR_TRANSPORT=local\n');
388
393
  const job=await queued(f),r=runtime(f),execute=r.execute;
389
394
  let observed;
390
395
  r.execute=async(command,args,options)=>{
391
- if(args.includes('restart')&&args.at(-1)==='plugin-broker') observed=(await read(path.join(f.home,'registry.json'))).plugins.sample.manifest.version;
396
+ if(args.some(arg=>arg.includes('EZ_BROKER_STRUCTURAL_READINESS_FAILED'))) observed=(await read(path.join(f.home,'registry.json'))).plugins.sample.manifest.version;
397
+ if(args.includes('--force-recreate')) {
398
+ assert.equal(args.at(-1),'plugin-broker');
399
+ for(const flag of ['--no-deps','--no-build','--wait'])assert(args.includes(flag));
400
+ assert.equal(args[args.indexOf('--pull')+1],'never');
401
+ assert.equal(await fs.readFile(args[args.lastIndexOf('--env-file')+1],'utf8'),'EZ_RELAY_IMAGE=sha256:'+ 'a'.repeat(64)+'\n');
402
+ }
392
403
  return execute(command,args,options);
393
404
  };
394
405
  assert.equal((await perform(f.home,job,r)).status,'completed');
395
- assert.equal(observed,'0.1.1');
396
- assert.equal(r.calls.filter(call=>call.includes('restart')).length,1);
406
+ assert.equal(observed,'0.1.1');assert.equal(job.brokerReadiness.ready,true);
397
407
  const interrupted=await read(path.join(jobPath(f.home,job.id),'job.json'));interrupted.status='applying';
398
408
  assert.equal((await perform(f.home,interrupted,r)).status,'rolled-back');
399
- assert.equal(observed,'0.1.0');
400
- assert.equal(r.calls.filter(call=>call.includes('restart')).length,2);
409
+ assert.equal(observed,'0.1.0');assert.equal(interrupted.rollbackBrokerReadiness.ready,true);
410
+ assert.equal(r.calls.filter(call=>call.includes('--force-recreate')).length,2);
411
+ assert(!r.calls.some(call=>call.includes('restart')||call[0]==='stopHost'));
412
+ });
413
+ for(const boundary of ['recreate','probe','readback'])test(`isolated broker ${boundary} failure cannot complete an update`,async t=>{
414
+ const f=await fixture(t,'plugin');await fs.appendFile(path.join(f.config.deploymentDir,'docker.env'),'EZ_EXECUTOR_TRANSPORT=local\n');
415
+ const job=await queued(f),r=runtime(f),base=r.execute;
416
+ r.execute=async(c,a,o)=>{
417
+ if(boundary==='recreate'&&a.includes('--force-recreate')||boundary==='probe'&&a.some(arg=>arg.includes('EZ_BROKER_STRUCTURAL_READINESS_FAILED')))throw Error('Synthetic broker failure');
418
+ if(boundary==='readback'&&a.some(arg=>arg.includes('EZ_BROKER_STRUCTURAL_READINESS_FAILED')))return JSON.stringify({version:1,ready:true,hostSha256:'0'.repeat(64)});
419
+ return base(c,a,o);
420
+ };
421
+ const result=await perform(f.home,job,r);
422
+ assert.equal(result.status,'recovery-required');assert(result.error);assert(result.recoveryError);
423
+ assert.equal((await read(path.join(f.home,'registry.json'))).plugins.sample.manifest.version,'0.1.0');
424
+ });
425
+ test('main isolated activation and retained-relay rollback also refresh the broker',async t=>{
426
+ const f=await fixture(t);await fs.appendFile(path.join(f.config.deploymentDir,'docker.env'),'EZ_EXECUTOR_TRANSPORT=local\n');
427
+ const job=await queued(f),r=runtime(f);
428
+ assert.equal((await perform(f.home,job,r)).status,'completed');assert.equal(job.brokerReadiness.ready,true);
429
+ const base=r.execute;r.execute=async(c,a,o)=>a[0]==='inspect'&&a.includes('{{json .}}')?JSON.stringify({Image:'sha256:'+'a'.repeat(64),State:{Running:true,Health:{Status:'healthy'}}}):base(c,a,o);
430
+ const interrupted=await read(path.join(jobPath(f.home,job.id),'job.json'));interrupted.status='applying';
431
+ assert.equal((await perform(f.home,interrupted,r)).status,'rolled-back');
432
+ assert.equal(interrupted.rollbackBrokerReadiness.ready,true);
433
+ assert.equal(r.calls.filter(call=>call.includes('--force-recreate')).length,2);
434
+ assert.equal(r.calls.filter(call=>call.includes('up')&&call.at(-1)==='relay').length,1);
435
+ });
436
+ test('host-capable updates never inspect, recreate or probe an isolated broker',async t=>{
437
+ const f=await fixture(t,'plugin'),r=runtime(f);assert.equal((await perform(f.home,await queued(f),r)).status,'completed');
438
+ assert(!r.calls.some(call=>call.includes('plugin-broker')||call.some(arg=>arg.includes('EZ_BROKER_STRUCTURAL_READINESS_FAILED'))));
401
439
  });
402
440
  test('plugin update preserves a created but never running service as stopped',async t=>{
403
441
  const f=await fixture(t,'plugin'),job=await queued(f),r=runtime(f),execute=r.execute;
404
442
  r.execute=async(command,args,options)=>{
405
- if(args.includes('ps')) {r.calls.push([command,...args]);return args.includes('--status')?'':'created-container';}
443
+ if(args.includes('ps')) {r.calls.push([command,...args]);if(args.includes('view'))return JSON.stringify(latest);return args.includes('--status')?'':'created-container';}
406
444
  return execute(command,args,options);
407
445
  };
408
446
  assert.equal((await perform(f.home,job,r)).status,'completed');
@@ -495,7 +533,7 @@ for(const provider of ['pnpm','corepack']) test(`supervisor with only ${provider
495
533
  // Package archives never contain node_modules; the fake pnpm below provisions the fixture loader.
496
534
  await fs.rm(path.join(f.source,'node_modules'),{recursive:true});
497
535
  const log=path.join(f.root,'commands.jsonl');
498
- 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});
536
+ 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});
499
537
  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});
500
538
  const wrapper=path.join(f.root,'supervisor.mjs'),module=new URL('../src/updates/supervisor.mjs',import.meta.url).href;
501
539
  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 []'}}});`);
@@ -510,6 +548,7 @@ for(const provider of ['pnpm','corepack']) test(`supervisor with only ${provider
510
548
  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)));`]);
511
549
  assert.equal(JSON.parse(requester.stdout).status,'queued');
512
550
  await new Promise(r=>setTimeout(r,1800));assert.equal((await read(path.join(jobPath(f.home,job.id),'job.json'))).status,'queued');
551
+ await assert.rejects(fs.readFile(path.join(f.agent.controlDir,'upgrade-pause.json')),e=>e.code==='ENOENT');
513
552
  await fs.rm(running);
514
553
  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';});
515
554
  const newBeat=await heartbeat();assert.notEqual(newBeat.pid,oldBeat.pid);assert.equal(await firstClosed,0,first.output());
@@ -529,6 +568,26 @@ for(const provider of ['pnpm','corepack']) test(`supervisor with only ${provider
529
568
  const closed3=new Promise(r=>third.p.once('close',r));third.p.kill('SIGTERM');assert.equal(await closed3,0,third.output());
530
569
  });
531
570
 
571
+ test('queued upgrades keep admission open while busy and release a raced or aborted pause',async t=>{
572
+ const root=await fs.mkdtemp(path.join(tmpdir(),'ez-upgrade-admission-'));
573
+ t.after(()=>fs.rm(root,{recursive:true,force:true}));
574
+ const pause=path.join(root,'upgrade-pause.json'),job={id:'test'},abort=new AbortController();
575
+ const absent=()=>assert.rejects(fs.readFile(pause),e=>e.code==='ENOENT');
576
+ let applied=0;
577
+ const apply=async()=>{assert.equal((await read(pause)).id,job.id);applied++;return 'applied';};
578
+ await withIdleUpgrade(root,job,abort.signal,apply,{isIdle:async()=>false});
579
+ await absent();assert.equal(applied,0);
580
+ let checks=0;
581
+ await withIdleUpgrade(root,job,abort.signal,apply,{isIdle:async()=>++checks===1,wait:async()=>{}});
582
+ await absent();assert.equal(applied,0);
583
+ assert.equal(await withIdleUpgrade(root,job,abort.signal,apply,{isIdle:async()=>true,wait:async()=>{}}),'applied');
584
+ await absent();assert.equal(applied,1);
585
+ await assert.rejects(withIdleUpgrade(root,job,abort.signal,async()=>{throw Error('apply failed');},{isIdle:async()=>true,wait:async()=>{}}),/apply failed/);
586
+ await absent();
587
+ await withIdleUpgrade(root,job,abort.signal,apply,{isIdle:async()=>true,wait:async()=>abort.abort()});
588
+ await absent();assert.equal(applied,1);
589
+ });
590
+
532
591
  test('npm candidates verify exact version and integrity; automatic policy is enforced again at execution',async t=>{
533
592
  const f=await fixture(t),data=await fs.readFile(await f.pack()),original=globalThis.fetch;
534
593
  const {createHash}=await import('node:crypto');
@@ -627,7 +686,7 @@ for(const mode of ['exact','wrong-image','unhealthy']) test(`recovery reuses onl
627
686
  const f=await fixture(t),job=await queued(f),r=runtime(f);await perform(f.home,job,r);
628
687
  const interrupted=await read(path.join(jobPath(f.home,job.id),'job.json'));interrupted.status='applying';
629
688
  const calls=[],execute=async(command,args,options)=>{
630
- calls.push([command,...args]);
689
+ calls.push([command,...args]);if(args.includes('view'))return JSON.stringify(latest);
631
690
  if(args.includes('ps'))return 'b'.repeat(64);
632
691
  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'}}});
633
692
  if(mode==='exact'&&args.includes('up'))throw Error('Pinned image index no longer exists');