@jc_stack/ez-agents 0.1.0-beta.48 → 0.1.0-beta.50
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/AGENTS.md +8 -6
- package/CHANGELOG.md +24 -0
- package/README.md +9 -5
- package/bin/ez +16 -7
- package/docker/broker-update-smoke.mjs +16 -2
- package/docker/upgrade-smoke.mjs +1 -1
- package/docs/architecture/ai-selection.md +9 -1
- package/docs/docker-runtime.md +46 -2
- package/docs/plugin-guide.md +3 -3
- package/docs/plugins.md +13 -10
- package/docs/scheduling.md +84 -6
- package/docs/setup.md +2 -2
- package/docs/standalone-cli.md +4 -4
- package/docs/upgrades.md +13 -23
- package/package.json +1 -1
- package/src/agent-guidance.ts +2 -2
- package/src/ai-cli.ts +28 -15
- package/src/ai.ts +90 -14
- package/src/application-channel.ts +18 -14
- package/src/auth-profile.ts +53 -0
- package/src/codex-session.ts +14 -3
- package/src/control-state.ts +28 -21
- package/src/delivery-socket.ts +2 -0
- package/src/executor.ts +38 -26
- package/src/host-executor-client.ts +19 -7
- package/src/host-executor.ts +35 -10
- package/src/index.ts +320 -122
- package/src/menu.ts +8 -7
- package/src/plugin-broker.mjs +4 -11
- package/src/plugins/manager.mjs +70 -48
- package/src/runs.ts +74 -5
- package/src/schedule-cli.ts +39 -7
- package/src/scheduled-tasks.ts +42 -8
- package/src/scheduler.ts +9 -4
- package/src/session-close.ts +17 -0
- package/src/setup-fallback.ts +159 -0
- package/src/task-executor.ts +3 -2
- package/src/tools-line.d.mts +1 -0
- package/src/tools-line.mjs +3 -0
- package/src/updates/binding.mjs +4 -10
- package/src/updates/control.mjs +3 -2
- package/src/updates/runtime.mjs +79 -41
- package/src/updates/supervisor.mjs +27 -6
- package/src/workspace.ts +12 -1
- package/test/ai.test.ts +48 -2
- package/test/application-channel.test.ts +24 -0
- package/test/application-controls.test.ts +4 -1
- package/test/auth-profiles.test.ts +187 -0
- package/test/intake-relay.test.ts +63 -2
- package/test/model-policy.test.ts +1 -1
- package/test/plugin-broker.test.mjs +51 -1
- package/test/plugin-manager.test.mjs +76 -38
- package/test/run-handoff.test.ts +132 -0
- package/test/scheduled-tasks.test.ts +24 -2
- package/test/session-close.test.ts +17 -0
- package/test/setup-fallback.test.ts +423 -0
- package/test/updates.test.mjs +77 -15
- package/test/workspace.test.ts +6 -0
package/src/plugin-broker.mjs
CHANGED
|
@@ -6,7 +6,7 @@ import { createServer } from 'node:net';
|
|
|
6
6
|
import { randomBytes, randomUUID, createHash } from 'node:crypto';
|
|
7
7
|
import { spawn } from 'node:child_process';
|
|
8
8
|
import { fileURLToPath } from 'node:url';
|
|
9
|
-
import { prepareCommand, registry, run as dockerRun, stewardOwned } from './plugins/manager.mjs';
|
|
9
|
+
import { prepareCommand, registry, run as dockerRun, stewardOwned, publishBrokerPlugins } from './plugins/manager.mjs';
|
|
10
10
|
|
|
11
11
|
const MAX_FRAME = 4 * 1024 * 1024;
|
|
12
12
|
const MAX_ARGS = 100;
|
|
@@ -165,15 +165,7 @@ const writeReceipt = async (binding, receipt) => {
|
|
|
165
165
|
// Installed plugin versions for relay status. Rewritten on broker start and
|
|
166
166
|
// after every management call; the relay reads it without Docker access.
|
|
167
167
|
// Failures stay local so a status snapshot never breaks plugin operations.
|
|
168
|
-
const refreshBrokerPlugins =
|
|
169
|
-
const r = await registry(binding.home);
|
|
170
|
-
const plugins = Object.values(r.plugins).map(record => ({ id: record.manifest.id, version: record.manifest.version }));
|
|
171
|
-
const file = path.join(binding.controlDir, 'plugin-broker-plugins.json');
|
|
172
|
-
const temporary = `${file}.${randomUUID()}.tmp`;
|
|
173
|
-
await fs.writeFile(temporary, JSON.stringify({ version: 1, at: new Date().toISOString(), plugins }) + '\n', { mode: 0o600, flag: 'wx' });
|
|
174
|
-
await fs.rename(temporary, file);
|
|
175
|
-
await stewardOwned(file);
|
|
176
|
-
};
|
|
168
|
+
const refreshBrokerPlugins = binding => publishBrokerPlugins(binding.home,binding.controlDir);
|
|
177
169
|
|
|
178
170
|
const response = (socket, value) => {
|
|
179
171
|
if (socket.destroyed || !socket.writable) return;
|
|
@@ -255,6 +247,7 @@ const invokePlugin = async (binding, request, signal, scheduledPreflight = false
|
|
|
255
247
|
const command = await prepareCommand(binding.home, request.alias, request.args, {
|
|
256
248
|
revision: request.revision,
|
|
257
249
|
invocation: true,
|
|
250
|
+
signal,
|
|
258
251
|
environment: { EZ_CONTROL_DIR: binding.controlDir, ...(scheduledPreflight ? {} : {EZ_RUN_ID: request.runId}), EZ_DELIVERY_SOCKET: process.env.EZ_DELIVERY_SOCKET ?? deliverySocketPath(binding.controlDir) },
|
|
259
252
|
});
|
|
260
253
|
try {
|
|
@@ -360,7 +353,7 @@ export async function servePluginBroker(binding, signal = new AbortController().
|
|
|
360
353
|
const selected=recordForAlias(await registry(binding.home),check.alias);
|
|
361
354
|
const result=await invokePlugin(binding,{alias:check.alias,args:check.args,revision:selected.record.revision,runId:`preflight_${request.scheduleId}`,id:randomUUID()},abort.signal,true);
|
|
362
355
|
if(!result.ok || result.code!==0) throw Error('Scheduled plugin preflight unavailable');
|
|
363
|
-
values.push(JSON.parse(result.stdout));
|
|
356
|
+
try { values.push(JSON.parse(result.stdout)); } catch { throw Error('Scheduled plugin preflight returned invalid output'); }
|
|
364
357
|
}
|
|
365
358
|
response(socket,{version:1,id:request.id,ok:true,result:combinePreflight(values)});
|
|
366
359
|
return;
|
package/src/plugins/manager.mjs
CHANGED
|
@@ -7,7 +7,8 @@ import { fileURLToPath } from 'node:url';
|
|
|
7
7
|
import { createHash, randomUUID, randomBytes } from 'node:crypto';
|
|
8
8
|
import { spawn } from 'node:child_process';
|
|
9
9
|
import { callDeliverySocket } from '../delivery-socket-client.mjs';
|
|
10
|
-
import {
|
|
10
|
+
import { TOOLS_LINE } from '../tools-line.mjs';
|
|
11
|
+
import { readFileSync, readlinkSync, realpathSync } from 'node:fs';
|
|
11
12
|
|
|
12
13
|
const reserved = new Set(['status','updates','plugins','tools','message','owner','approval','react','setup','help','version']);
|
|
13
14
|
const id = value => { if(typeof value !== 'string' || !/^[a-z][a-z0-9-]{0,39}$/.test(value)) throw Error('Invalid identifier'); return value; };
|
|
@@ -41,47 +42,43 @@ export async function stewardOwned(file) {
|
|
|
41
42
|
const parent = await fs.stat(path.dirname(file));
|
|
42
43
|
await fs.chown(file, parent.uid, parent.gid);
|
|
43
44
|
}
|
|
44
|
-
//
|
|
45
|
-
//
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
if(config?.hostConfig) {
|
|
51
|
-
const host=await json(config.hostConfig);
|
|
52
|
-
if(host.isolation==='isolated') launcher='ez';
|
|
53
|
-
}
|
|
45
|
+
// Retire the former `ez tools` footer once: replace the marked block with
|
|
46
|
+
// TOOLS_LINE and leave every other byte untouched. The name is kept because
|
|
47
|
+
// upgraders released before this change call it on the target package;
|
|
48
|
+
// remove it when no installed core predates the change.
|
|
49
|
+
export async function bindToolDiscovery(_home,workspace) {
|
|
50
|
+
workspace=await fs.realpath(workspace);
|
|
54
51
|
const start='<!-- ez tools: begin -->',end='<!-- ez tools: end -->';
|
|
55
|
-
const block=start+'\nUse `'+launcher+'`; run it with `--help` or `tools list --details` to discover installed capabilities, and read the matching skill when needed.\nKeep the channel responsive: during a foreground owner turn, prefer `ezenciel-agents-schedule create --now ...` when work can continue independently. A `[schedule ...]` turn executes its assigned work directly and must not schedule it again.\n'+end;
|
|
56
|
-
if(Buffer.byteLength(block)>600)throw Error('Tool discovery guidance exceeds the 600-byte core budget');
|
|
57
|
-
return {block,start,end};
|
|
58
|
-
}
|
|
59
|
-
export async function bindToolDiscovery(home,workspace) {
|
|
60
|
-
home=await fs.realpath(home);workspace=await fs.realpath(workspace);
|
|
61
|
-
const {block,start,end}=await toolDiscoveryBlock(home);
|
|
62
52
|
for(const name of ['AGENTS.md','AGENTS.override.md']) {
|
|
63
53
|
const file=path.join(workspace,name);
|
|
64
54
|
const stat=await fs.lstat(file).catch(e=>{if(e.code==='ENOENT')return null;throw e;});
|
|
65
|
-
if(!stat
|
|
66
|
-
if(
|
|
67
|
-
const prior=
|
|
55
|
+
if(!stat)continue;
|
|
56
|
+
if(!stat.isFile())throw Error('Tool instructions must be a regular file');
|
|
57
|
+
const prior=await fs.readFile(file,'utf8');
|
|
68
58
|
const from=prior.indexOf(start),to=prior.indexOf(end);
|
|
69
|
-
if(
|
|
70
|
-
|
|
71
|
-
|
|
59
|
+
if(from<0&&to<0)continue;
|
|
60
|
+
if((from<0)!==(to<0)||to<from||prior.indexOf(start,from+start.length)>=0||prior.indexOf(end,to+end.length)>=0)throw Error('Malformed tool discovery block');
|
|
61
|
+
const next=prior.slice(0,from)+(prior.includes(TOOLS_LINE)?'':TOOLS_LINE)+prior.slice(to+end.length);
|
|
72
62
|
const tmp=file+'.'+randomUUID()+'.tmp';
|
|
73
63
|
try {await fs.writeFile(tmp,next,{mode:0o600,flag:'wx'});await fs.rename(tmp,file);}finally{await fs.rm(tmp,{force:true});}
|
|
74
64
|
}
|
|
75
65
|
}
|
|
76
66
|
const invocationDirectory = home => path.join(home,'command-invocations');
|
|
67
|
+
// A lease pid is only meaningful inside the PID namespace that wrote it. The
|
|
68
|
+
// isolated broker writes leases from its container, which the host cannot probe.
|
|
69
|
+
const pidNamespace=(()=>{try{return readlinkSync('/proc/self/ns/pid');}catch{return undefined;}})();
|
|
77
70
|
async function activeInvocations(home) {
|
|
78
71
|
const directory=invocationDirectory(home),files=await fs.readdir(directory).catch(error=>{if(error.code==='ENOENT')return [];throw error;});
|
|
79
72
|
for(const file of files) {
|
|
80
73
|
if(!/^[0-9a-f-]{36}\.json$/.test(file))throw Error('Invalid command invocation lease; inspect before recovery');
|
|
81
|
-
const lease=await json(path.join(directory,file));
|
|
74
|
+
const lease=await json(path.join(directory,file)).catch(error=>{if(error.code==='ENOENT')return null;throw error;});
|
|
75
|
+
if(!lease)continue;
|
|
82
76
|
if(!Number.isSafeInteger(lease.pid)||lease.pid<1||typeof lease.container!=='string'||!lease.container)throw Error('Invalid command invocation lease; inspect before recovery');
|
|
83
|
-
|
|
84
|
-
|
|
77
|
+
const foreign=typeof lease.pidNamespace==='string'&&lease.pidNamespace!==pidNamespace;
|
|
78
|
+
if(!foreign) {try {process.kill(lease.pid,0);return true;} catch(error) {if(error.code!=='ESRCH'&&error.code!=='EPERM')throw error;}}
|
|
79
|
+
const state=await run(['container','inspect','--format','{{.State.Running}}',lease.container],{capture:true});
|
|
80
|
+
// Foreign leases: the call container's own state is the only liveness signal.
|
|
81
|
+
if(state.code===0&&foreign&&state.stdout.trim()==='true')return true;
|
|
85
82
|
if(state.code===0)throw Error(`Stale command invocation lease; inspect container ${lease.container} before recovery`);
|
|
86
83
|
if(!/No such (?:object|container)/i.test(state.stderr+state.stdout))throw Error('Unable to verify a stale command invocation lease; inspect Docker before recovery');
|
|
87
84
|
await fs.rm(path.join(directory,file));
|
|
@@ -90,25 +87,35 @@ async function activeInvocations(home) {
|
|
|
90
87
|
}
|
|
91
88
|
async function invocationLease(home,container) {
|
|
92
89
|
const directory=invocationDirectory(home);await privateDir(directory);
|
|
93
|
-
const file=path.join(directory,randomUUID()+'.json');await fs.writeFile(file,JSON.stringify({pid:process.pid,container}),{mode:0o600,flag:'wx'});
|
|
90
|
+
const file=path.join(directory,randomUUID()+'.json');await fs.writeFile(file,JSON.stringify({pid:process.pid,container,...(pidNamespace?{pidNamespace}:{})}),{mode:0o600,flag:'wx'});
|
|
94
91
|
await stewardOwned(file);
|
|
95
92
|
return async()=>{await fs.rm(file,{force:true});};
|
|
96
93
|
}
|
|
97
|
-
export async function locked(home, fn, {allowInvocations=false, waitMs=0}={}) {
|
|
94
|
+
export async function locked(home, fn, {allowInvocations=false, waitMs=0, upgradeWaitMs=0, drainInvocations=false, signal}={}) {
|
|
98
95
|
const lock = path.join(home,'registry.lock');
|
|
99
96
|
let handle;
|
|
100
|
-
const
|
|
97
|
+
const started=Date.now();
|
|
101
98
|
while(!handle) {
|
|
99
|
+
signal?.throwIfAborted();
|
|
102
100
|
try { handle=await fs.open(lock,'wx',0o600); }
|
|
103
101
|
catch(error) {
|
|
104
102
|
if(error.code!=='EEXIST')throw error;
|
|
105
|
-
|
|
103
|
+
const owner=await json(lock).catch(()=>null);
|
|
104
|
+
const limit=owner?.kind==='plugin-upgrade'&&upgradeWaitMs?upgradeWaitMs:waitMs;
|
|
105
|
+
if(Date.now()-started>=limit)throw Error('Registry busy; inspect registry.lock before recovering an interrupted manager');
|
|
106
106
|
await new Promise(resolve=>setTimeout(resolve,25));
|
|
107
107
|
}
|
|
108
108
|
}
|
|
109
109
|
try {
|
|
110
|
-
await handle.writeFile(JSON.stringify({pid:process.pid}));
|
|
111
|
-
|
|
110
|
+
await handle.writeFile(JSON.stringify({pid:process.pid,...(drainInvocations?{kind:'plugin-upgrade'}:{})}));
|
|
111
|
+
// Keep admission closed while already-started calls release their leases.
|
|
112
|
+
// This is a lifecycle barrier, not an agent-turn drain or a command retry.
|
|
113
|
+
while(!allowInvocations&&await activeInvocations(home)) {
|
|
114
|
+
if(!drainInvocations)throw Error('Plugin commands are active; retry the registry or lifecycle change after they finish');
|
|
115
|
+
signal?.throwIfAborted();
|
|
116
|
+
await new Promise(resolve=>setTimeout(resolve,25));
|
|
117
|
+
}
|
|
118
|
+
signal?.throwIfAborted();
|
|
112
119
|
return await fn();
|
|
113
120
|
}
|
|
114
121
|
finally { await handle.close(); await fs.rm(lock); }
|
|
@@ -139,9 +146,11 @@ export async function snapshot(source) {
|
|
|
139
146
|
return {source,files,manifest,deployment,sharedRevisions,revision:`sha256:${digest.digest('hex')}`};
|
|
140
147
|
}
|
|
141
148
|
export function validate(m,d,files) {
|
|
142
|
-
keys(m,['schemaVersion','id','version','description','commands','skills']);
|
|
149
|
+
keys(m,['schemaVersion','id','version','description','commands','skills','example']);
|
|
143
150
|
if(m.schemaVersion!==1 || (typeof m.version!=='string' || !/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/.test(m.version))) throw Error('Unsupported manifest version');
|
|
144
151
|
id(m.id); strings(m.skills);
|
|
152
|
+
// One representative call shown by `tools list --details`; usage stays in --help/skill.
|
|
153
|
+
if(m.example!==undefined && (typeof m.example!=='string' || m.example.length>160 || /[\0-\x1f\x7f]/.test(m.example) || !Object.hasOwn(m.commands||{},m.example.split(' ')[0]))) throw Error('Manifest example must be one line of at most 160 characters starting with a declared command');
|
|
145
154
|
keys(d,['schemaVersion','services','commands','exports',...(d.schemaVersion>=2?['secrets']:[]),...(d.schemaVersion===3?['sharedServices']:[])]);
|
|
146
155
|
if(![1,2,3].includes(d.schemaVersion) || !d.services || !d.commands) throw Error('Unsupported deployment descriptor');
|
|
147
156
|
for(const [name,s] of Object.entries(d.services)) {
|
|
@@ -386,11 +395,13 @@ export function run(argv,{capture=false,container,signal,stdin,onStdout,onStart,
|
|
|
386
395
|
child.once('close',async(code,childSignal)=>{process.off('SIGTERM',term);process.off('SIGINT',int);
|
|
387
396
|
clearTimeout(killTimer);
|
|
388
397
|
clearTimeout(timeout);signal?.removeEventListener('abort',term);
|
|
389
|
-
|
|
398
|
+
// --rm only removes a container that ran. Compose stopping between
|
|
399
|
+
// create and start (cancelled, signalled or failed) strands it Created.
|
|
400
|
+
if(container&&(cancelled||code!==0)) {
|
|
390
401
|
try {
|
|
391
402
|
await removeCommandContainer(container);
|
|
392
403
|
} catch(error) {
|
|
393
|
-
const uncertain=error instanceof Error?error:Error('
|
|
404
|
+
const uncertain=error instanceof Error?error:Error('Command container cleanup failed');
|
|
394
405
|
uncertain.containerCleanupUncertain=true;return reject(uncertain);
|
|
395
406
|
}
|
|
396
407
|
}
|
|
@@ -398,7 +409,7 @@ export function run(argv,{capture=false,container,signal,stdin,onStdout,onStart,
|
|
|
398
409
|
resolve({code:cancelled?130:code??(childSignal?130:1),stdout,stderr});});
|
|
399
410
|
});
|
|
400
411
|
}
|
|
401
|
-
// Compose --rm can race
|
|
412
|
+
// Compose --rm can race this cleanup. Confirm disappearance instead of treating
|
|
402
413
|
// Docker's in-progress removal as either failure or completed cleanup.
|
|
403
414
|
export async function removeCommandContainer(container,execute=run) {
|
|
404
415
|
const cleanup=await execute(['container','rm','--force',container],{capture:true});
|
|
@@ -411,7 +422,7 @@ export async function removeCommandContainer(container,execute=run) {
|
|
|
411
422
|
await new Promise(resolve=>setTimeout(resolve,100));
|
|
412
423
|
}
|
|
413
424
|
}
|
|
414
|
-
throw Error(`
|
|
425
|
+
throw Error(`Command container cleanup failed: ${cleanup.stderr||cleanup.stdout}`);
|
|
415
426
|
}
|
|
416
427
|
|
|
417
428
|
async function checked(args) {
|
|
@@ -474,8 +485,16 @@ export async function registry(home) {
|
|
|
474
485
|
for(const [alias,plugin] of Object.entries(r.commands)) if(!r.plugins[plugin]?.deployment?.commands?.[alias]) throw Error('Corrupt command registry');
|
|
475
486
|
return r;
|
|
476
487
|
}
|
|
488
|
+
// Relay status reads this generated inventory without Docker/registry access.
|
|
489
|
+
export async function publishBrokerPlugins(home,controlDir) {
|
|
490
|
+
const r=await registry(home);
|
|
491
|
+
const plugins=Object.values(r.plugins).map(record=>({id:record.manifest.id,version:record.manifest.version}));
|
|
492
|
+
const file=path.join(controlDir,'plugin-broker-plugins.json');
|
|
493
|
+
await atomic(file,{version:1,at:new Date().toISOString(),plugins});
|
|
494
|
+
await stewardOwned(file);
|
|
495
|
+
}
|
|
477
496
|
// The lock protects admission and compose refresh, never a persistent connection.
|
|
478
|
-
export async function prepareCommand(home,alias,args,{revision,exclude,publish,invocation=false,environment=process.env}={}) {
|
|
497
|
+
export async function prepareCommand(home,alias,args,{revision,exclude,publish,invocation=false,environment=process.env,signal}={}) {
|
|
479
498
|
strings(args);
|
|
480
499
|
return locked(home,async()=>{
|
|
481
500
|
const config=await json(path.join(home,'config.json')),r=await registry(home);
|
|
@@ -505,13 +524,12 @@ export async function prepareCommand(home,alias,args,{revision,exclude,publish,i
|
|
|
505
524
|
const invocationRelease=invocation?await invocationLease(home,container):undefined;
|
|
506
525
|
const release=(invocationRelease||contextFile)?(async()=>{try{if(contextFile)await fs.rm(contextFile,{force:true});}finally{await invocationRelease?.();}}):undefined;
|
|
507
526
|
return {container,plugin:record.manifest.id,revision:record.revision,contextFile,argv:[...composeArgs(record),...(contextFile?['--file',contextFile]:[]),'run','--rm','--no-deps','-T','--name',container,...(publish?['--publish',publish]:[]),'--entrypoint',binding.argv[0],binding.service,...binding.argv.slice(1),...record.manifest.commands[alias].args,...args,...(binding.suffix||[])],release};
|
|
508
|
-
},{allowInvocations:true,waitMs:5000});
|
|
527
|
+
},{allowInvocations:true,waitMs:5000,upgradeWaitMs:invocation?300000:0,signal});
|
|
509
528
|
}
|
|
510
529
|
export async function init(home,workspace,catalogFile,hostConfig,standalone=false) {
|
|
511
530
|
if(standalone && hostConfig) throw Error('Standalone setup cannot bind a relay host config');
|
|
512
531
|
if(typeof home!=='string'||typeof workspace!=='string'||!path.isAbsolute(home)||!path.isAbsolute(workspace)||/[\r\n\0$:,]/.test(home+workspace)) throw Error('Explicit absolute home/workspace required');
|
|
513
532
|
workspace=await fs.realpath(workspace);await privateDir(home);home=await fs.realpath(home);if(hostConfig)hostConfig=await fs.realpath(hostConfig);
|
|
514
|
-
await toolDiscoveryBlock(home,hostConfig);
|
|
515
533
|
if(await fs.lstat(path.join(home,'registry.json')).catch(()=>null)) throw Error('Registry already exists; refusing replacement');
|
|
516
534
|
catalogFile=path.resolve(catalogFile||fileURLToPath(new URL('../../default-plugins.json',import.meta.url)));
|
|
517
535
|
const sources=await json(catalogFile);
|
|
@@ -544,7 +562,6 @@ export async function init(home,workspace,catalogFile,hostConfig,standalone=fals
|
|
|
544
562
|
agent.binDir=bin;agent.toolsHome=home;await atomic(hostConfig,host);
|
|
545
563
|
}
|
|
546
564
|
});
|
|
547
|
-
await bindToolDiscovery(home,workspace);
|
|
548
565
|
if(hostConfig && path.basename(hostConfig)==='host-executor.json') await (await import('../updates/binding.mjs')).bindUpdates(home,hostConfig);
|
|
549
566
|
return {ok:true,launcher:path.join(home,'bin','ez'),workspace};
|
|
550
567
|
}
|
|
@@ -632,12 +649,17 @@ export async function main(args) {
|
|
|
632
649
|
return emit({plugin:name,revision:record.revision,path:skill,content:await fs.readFile(file,'utf8')});
|
|
633
650
|
}
|
|
634
651
|
if(action==='list') {
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
652
|
+
// Self-sufficient index: aliases, purpose, skill and one call per plugin.
|
|
653
|
+
if(group==='tools' && args.length===1 && args[0]==='--details')return emit(Object.fromEntries(Object.entries(r.plugins).map(([name,p])=>{
|
|
654
|
+
const aliases=Object.keys(p.manifest.commands);
|
|
655
|
+
return [name,{
|
|
656
|
+
aliases,
|
|
657
|
+
purpose:typeof p.manifest.description==='string'?p.manifest.description.replace(/\s+/g,' ').trim().slice(0,200):'',
|
|
658
|
+
skills:p.manifest.skills.map(skill=>path.join(p.source,skill)),
|
|
659
|
+
skillReads:p.manifest.skills.map(skill=>['ez','tools','skill',name,skill]),
|
|
660
|
+
example:`ez ${p.manifest.example ?? `${aliases[0]} --help`}`,
|
|
661
|
+
}];
|
|
662
|
+
})));
|
|
641
663
|
if(args.length)throw Error('Use tools list [--details] or plugins list');
|
|
642
664
|
return emit(group==='tools'?r.commands:r.plugins);
|
|
643
665
|
}
|
package/src/runs.ts
CHANGED
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
import { validApplicationOrigin, type ApplicationOrigin } from './application-origin.js'
|
|
2
2
|
import { type FailureEvidence, type FailureReview, validFailureReview, failureStamp, failureEvidence } from './failure.js'
|
|
3
3
|
import { randomBytes } from 'node:crypto'
|
|
4
|
+
import { readFile, rename, rm, writeFile } from 'node:fs/promises'
|
|
5
|
+
import { join } from 'node:path'
|
|
6
|
+
import { isHostRunId } from './host-executor-protocol.js'
|
|
4
7
|
import { validOrigin, type ExternalOrigin } from './event-sources.js'
|
|
5
8
|
import { normalizeReactionEmoji } from './reaction.js'
|
|
6
9
|
import { validScheduledOrigin, type ScheduledOrigin } from './scheduler.js'
|
|
7
10
|
import type { IncomingItem } from './inbox.js'
|
|
8
11
|
import { assertId } from './identity.js'
|
|
9
|
-
import { isExecutionChoice, type ExecutionChoice } from './ai.js'
|
|
12
|
+
import { isExecutionChoice, type AiPreset, type ExecutionChoice } from './ai.js'
|
|
13
|
+
import type { SetupAttempt } from './setup-fallback.js'
|
|
10
14
|
import type { ScriptRunRef } from './scripts.js'
|
|
11
15
|
import {authorizeDeliveryContext,currentDeliveryOwner,type DeliveryContext} from './delivery-context.mjs'
|
|
12
16
|
|
|
@@ -32,7 +36,11 @@ export type RunRecord = {
|
|
|
32
36
|
pid?: number
|
|
33
37
|
blockReason?: string
|
|
34
38
|
execution?: ExecutionChoice
|
|
39
|
+
// Ordered task setups tried for this occurrence; the last one is active.
|
|
40
|
+
attempts?: SetupAttempt[]
|
|
35
41
|
replyOnly?: boolean
|
|
42
|
+
// Silent end-of-conversation turn in a replaced chat session (session-close.ts).
|
|
43
|
+
sessionClose?: true
|
|
36
44
|
exitCode?: number | null
|
|
37
45
|
failureReason?: string
|
|
38
46
|
failure?: FailureEvidence
|
|
@@ -83,9 +91,10 @@ export const retainedOutbox = (controlDir: string): StoredOutboxItem[] => [...ou
|
|
|
83
91
|
// Stateless pipe: the relay owns the ledger in process memory. Cross-process
|
|
84
92
|
// producers (engine children, plugin children, host executor) reach it through
|
|
85
93
|
// the delivery socket (see delivery-socket.ts), never through control/ files.
|
|
86
|
-
// A restart drops in-flight runs and queued items by design
|
|
87
|
-
//
|
|
88
|
-
// terminal records so memory
|
|
94
|
+
// A restart drops in-flight runs and queued items by design (except handed-off
|
|
95
|
+
// host-transport runs, below); in-flight sends report unknown rather than
|
|
96
|
+
// claiming success. Long-lived relays cap retained terminal records so memory
|
|
97
|
+
// stays bounded.
|
|
89
98
|
const MAX_TERMINAL_RUNS = 2000
|
|
90
99
|
const MAX_TERMINAL_OUTBOX = 2000
|
|
91
100
|
|
|
@@ -129,8 +138,57 @@ const pruneTerminals = (controlDir: string): void => {
|
|
|
129
138
|
}
|
|
130
139
|
}
|
|
131
140
|
|
|
141
|
+
// The setup the current attempt runs with: the latest recorded attempt, else the
|
|
142
|
+
// saved choice. Only a setup saved on the task itself is ever admitted.
|
|
143
|
+
export const activeSetup = (run: Pick<RunRecord, 'attempts' | 'execution'>): AiPreset | undefined => {
|
|
144
|
+
if (!run.execution) return undefined
|
|
145
|
+
const preset = [...run.attempts ?? []].reverse().find(attempt => attempt.outcome !== 'skipped')?.preset ?? run.execution.preset
|
|
146
|
+
return [run.execution.preset, ...run.execution.fallbacks ?? []].find(candidate => JSON.stringify(candidate) === JSON.stringify(preset))
|
|
147
|
+
}
|
|
148
|
+
|
|
132
149
|
export const newRunId = (): string => `r_${Date.now().toString(36)}_${randomBytes(3).toString('hex')}`
|
|
133
150
|
|
|
151
|
+
// The one exception to memory-only runs: a graceful relay stop hands its
|
|
152
|
+
// running host-transport runs to the next relay, whose executors outlive it.
|
|
153
|
+
// The next relay adopts and watches them; it never relaunches one.
|
|
154
|
+
const handoffFile = (controlDir: string): string => join(controlDir, 'run-handoff.json')
|
|
155
|
+
const handedRun = (value: unknown): value is RunRecord => {
|
|
156
|
+
const run = value as RunRecord
|
|
157
|
+
return Boolean(run && typeof run === 'object' && (run.version === 1 || run.version === 2) && typeof run.id === 'string' && isHostRunId(run.id) &&
|
|
158
|
+
run.status === 'running' && Array.isArray(run.texts) && run.texts.every(text => typeof text === 'string') && Number.isFinite(Date.parse(run.createdAt)) &&
|
|
159
|
+
(run.execution === undefined || isExecutionChoice(run.execution)) && (run.scheduled === undefined || validScheduledOrigin(run.scheduled)) &&
|
|
160
|
+
(run.external === undefined || validOrigin(run.external)) && (run.application === undefined || validApplicationOrigin(run.application)))
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export const saveRunHandoff = async (controlDir: string, records: RunRecord[]): Promise<void> => {
|
|
164
|
+
const file = handoffFile(controlDir), temporary = `${file}.${process.pid}.tmp`
|
|
165
|
+
await writeFile(temporary, JSON.stringify({ version: 1, writtenAt: new Date().toISOString(), runs: records }), { mode: 0o600 })
|
|
166
|
+
await rename(temporary, file)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const HANDOFF_MAX_AGE_MS = 24 * 60 * 60 * 1000
|
|
170
|
+
|
|
171
|
+
// Reads and removes the record. An unreadable, corrupt or stale record adopts
|
|
172
|
+
// nothing: those executors finish unobserved, like a restart without handoff.
|
|
173
|
+
export const takeRunHandoff = async (controlDir: string, now = Date.now()): Promise<RunRecord[]> => {
|
|
174
|
+
try {
|
|
175
|
+
let raw: string
|
|
176
|
+
try { raw = await readFile(handoffFile(controlDir), 'utf8') }
|
|
177
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return []; throw error }
|
|
178
|
+
await rm(handoffFile(controlDir))
|
|
179
|
+
const value = JSON.parse(raw) as { version?: unknown; writtenAt?: unknown; runs?: unknown }
|
|
180
|
+
const age = now - Date.parse(typeof value.writtenAt === 'string' ? value.writtenAt : '')
|
|
181
|
+
if (value.version !== 1 || !(age >= -60_000 && age <= HANDOFF_MAX_AGE_MS)) throw new Error('Invalid or stale run handoff')
|
|
182
|
+
if (!Array.isArray(value.runs) || value.runs.length > 100) throw new Error('Invalid run handoff')
|
|
183
|
+
const valid = value.runs.filter(handedRun)
|
|
184
|
+
if (valid.length !== value.runs.length) console.error('Ignoring invalid handed-off runs', value.runs.length - valid.length)
|
|
185
|
+
return [...new Map(valid.map(run => [run.id, run])).values()]
|
|
186
|
+
} catch (error) {
|
|
187
|
+
console.error('Ignoring unreadable run handoff', error instanceof Error ? error.message : 'Unknown error')
|
|
188
|
+
return []
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
134
192
|
export class RunStore {
|
|
135
193
|
constructor(private readonly controlDir: string) {}
|
|
136
194
|
|
|
@@ -151,6 +209,7 @@ export class RunStore {
|
|
|
151
209
|
application?: ApplicationOrigin
|
|
152
210
|
delivery?: { bindingId: string; scope: string }
|
|
153
211
|
script?: ScriptRunRef
|
|
212
|
+
sessionClose?: true
|
|
154
213
|
}, exclusiveSchedule = false): Promise<RunRecord> {
|
|
155
214
|
const store = runsFor(this.controlDir)
|
|
156
215
|
if (input.id) {
|
|
@@ -183,6 +242,7 @@ export class RunStore {
|
|
|
183
242
|
delivery: input.delivery,
|
|
184
243
|
scheduled: input.scheduled,
|
|
185
244
|
...(input.script ? { script: input.script } : {}),
|
|
245
|
+
...(input.sessionClose ? { sessionClose: true as const } : {}),
|
|
186
246
|
status: 'queued',
|
|
187
247
|
createdAt: new Date().toISOString(),
|
|
188
248
|
}
|
|
@@ -196,9 +256,18 @@ export class RunStore {
|
|
|
196
256
|
return runsFor(this.controlDir).get(id) ?? null
|
|
197
257
|
}
|
|
198
258
|
|
|
259
|
+
// Re-admit a run from the previous relay's handoff; its executor is already running.
|
|
260
|
+
async adopt(run: RunRecord): Promise<RunRecord> {
|
|
261
|
+
if (!handedRun(run)) throw new Error('Invalid handed-off run')
|
|
262
|
+
const existing = runsFor(this.controlDir).get(run.id)
|
|
263
|
+
if (existing && existing.status !== 'running') throw new Error('Handed-off run already settled')
|
|
264
|
+
runsFor(this.controlDir).set(run.id, run)
|
|
265
|
+
return run
|
|
266
|
+
}
|
|
267
|
+
|
|
199
268
|
async patch(
|
|
200
269
|
id: string,
|
|
201
|
-
change: Partial<Pick<RunRecord, 'status' | 'startedAt' | 'endedAt' | 'pid' | 'nativeSessionId' | 'interrupted' | 'blockReason' | 'backendSubmitted' | 'replyOnly' | 'exitCode' | 'failureReason' | 'failure' | 'failureReview' | 'externalReleased' | 'output' | 'timedOut'>>,
|
|
270
|
+
change: Partial<Pick<RunRecord, 'status' | 'startedAt' | 'endedAt' | 'pid' | 'nativeSessionId' | 'interrupted' | 'blockReason' | 'backendSubmitted' | 'replyOnly' | 'exitCode' | 'failureReason' | 'failure' | 'failureReview' | 'externalReleased' | 'output' | 'timedOut' | 'attempts'>>,
|
|
202
271
|
): Promise<RunRecord> {
|
|
203
272
|
const store = runsFor(this.controlDir)
|
|
204
273
|
const run = store.get(id)
|
package/src/schedule-cli.ts
CHANGED
|
@@ -8,18 +8,36 @@ import { ControlStore, sameOwner } from './control-state.js'
|
|
|
8
8
|
import { ApplicationBindings } from './application-channel.js'
|
|
9
9
|
import type { RunRecord } from './runs.js'
|
|
10
10
|
import { callDeliverySocket, socketPathFor } from './delivery-socket.js'
|
|
11
|
-
import { initialPreset, isPreset } from './ai.js'
|
|
11
|
+
import { initialPreset, isPreset, presetLabel, type AiPreset } from './ai.js'
|
|
12
12
|
import { executionOverrides } from './model-policy.js'
|
|
13
13
|
import { holdsSchedule, Scheduler, executionType } from './scheduler.js'
|
|
14
14
|
import { Scripts, fileSha256, workspaceEntry, DEFAULT_SCRIPT_TIMEOUT_SECONDS } from './scripts.js'
|
|
15
15
|
import { executorEnvironment } from './executor.js'
|
|
16
|
+
import { provisionedHome } from './auth-profile.js'
|
|
16
17
|
import { ownsRun } from './identity.js'
|
|
17
18
|
import { type Trigger } from './schedule-time.js'
|
|
18
19
|
|
|
20
|
+
// Each fallback selects its own native engine, credential home and model.
|
|
21
|
+
const fallbackSetup = (spec: string, index: number): AiPreset => {
|
|
22
|
+
const fields = Object.fromEntries(spec.split(',').map(part => {
|
|
23
|
+
const at = part.indexOf('=')
|
|
24
|
+
if (at < 1) throw new Error('Fallback uses cli=EXECUTOR,model=MODEL[,auth-profile=NAME][,effort=EFFORT][,provider=ID]')
|
|
25
|
+
return [part.slice(0, at), part.slice(at + 1)]
|
|
26
|
+
}))
|
|
27
|
+
if (Object.keys(fields).some(key => !['cli','model','auth-profile','effort','provider'].includes(key)) || !fields.cli || !fields.model)
|
|
28
|
+
throw new Error('Fallback uses cli=EXECUTOR,model=MODEL[,auth-profile=NAME][,effort=EFFORT][,provider=ID]')
|
|
29
|
+
const base = initialPreset(fields.cli)
|
|
30
|
+
const preset = executionOverrides(base.cli, {...base, ...(fields.provider ? {provider: fields.provider} : {}),
|
|
31
|
+
...(fields['auth-profile'] !== undefined ? {authProfile: fields['auth-profile']} : {})}, fields.model, fields.effort)
|
|
32
|
+
const named = {...preset, id: `fallback-${index + 1}`, name: presetLabel(preset).slice(0, 80)}
|
|
33
|
+
if (!isPreset(named)) throw new Error('Invalid fallback setup')
|
|
34
|
+
return named
|
|
35
|
+
}
|
|
36
|
+
|
|
19
37
|
async function main() {
|
|
20
38
|
const { values:v, positionals:[action='list',id,scriptId] } = parseArgs({allowPositionals:true,options:{
|
|
21
39
|
script:{type:'string'}, arg:{type:'string',multiple:true}, file:{type:'string'}, interpreter:{type:'string'}, 'timeout-seconds':{type:'string'},
|
|
22
|
-
cli:{type:'string'}, model:{type:'string'}, effort:{type:'string'},
|
|
40
|
+
cli:{type:'string'}, 'auth-profile':{type:'string'}, model:{type:'string'}, effort:{type:'string'}, fallback:{type:'string',multiple:true}, 'clear-fallbacks':{type:'boolean'},
|
|
23
41
|
all:{type:'boolean'}, limit:{type:'string'}, offset:{type:'string'}, expected:{type:'string'}, key:{type:'string'}, when:{type:'string'}, status:{type:'string'}, diagnosis:{type:'string'}, recovery:{type:'string'}, outcome:{type:'string'}, 'failed-at':{type:'string'},
|
|
24
42
|
'preflight-file':{type:'string'},'clear-preflight':{type:'boolean'},name:{type:'string'}, text:{type:'string'}, 'text-file':{type:'string'}, at:{type:'string'}, now:{type:'boolean'},
|
|
25
43
|
cron:{type:'string'}, timezone:{type:'string'}, 'every-seconds':{type:'string'}, start:{type:'string'}, until:{type:'string'}, help:{type:'boolean'},
|
|
@@ -31,12 +49,14 @@ async function main() {
|
|
|
31
49
|
review RUN_ID --failed-at ISO --status resolved|attention --diagnosis TEXT --recovery TEXT --outcome TEXT
|
|
32
50
|
create [ID] | edit ID --name NAME (--text TEXT | --text-file FILE | --script SCRIPT_ID [--arg=VALUE ...])
|
|
33
51
|
--now | --at ISO_WITH_OFFSET | --every-seconds N | --cron 'MIN HOUR DAY MONTH WEEKDAY' --timezone IANA
|
|
34
|
-
[--cli EXECUTOR] [--model MODEL] [--effort <native-effort>]
|
|
52
|
+
[--cli EXECUTOR] [--auth-profile NAME] [--model MODEL] [--effort <native-effort>]
|
|
53
|
+
[--fallback cli=EXECUTOR,model=MODEL[,auth-profile=NAME][,effort=EFFORT][,provider=ID] ... | --clear-fallbacks]
|
|
35
54
|
[--preflight-file FILE | --clear-preflight]
|
|
36
55
|
[--start ISO_WITH_OFFSET] [--until ISO_WITH_OFFSET] [--when unreviewed-failures]
|
|
37
56
|
script list | show SCRIPT_ID | remove SCRIPT_ID
|
|
38
57
|
script register|update SCRIPT_ID --file WORKSPACE_PATH --interpreter COMMAND [--arg=VALUE ...] [--timeout-seconds N]
|
|
39
|
-
Execution types: agent (prompt, engine, model, effort) or script (registered script ID and saved arguments).
|
|
58
|
+
Execution types: agent (prompt, engine, auth profile, model, effort) or script (registered script ID and saved arguments).
|
|
59
|
+
--auth-profile runs a Codex/Claude task under that provisioned login (ezenciel-agents-ai profiles); --cli alone uses the default login.
|
|
40
60
|
A script registration references an entry point inside the agent workspace and records its SHA-256. Core invokes
|
|
41
61
|
the installed interpreter with the entry point and arguments directly, without a shell or a model. Changed entry-point
|
|
42
62
|
bytes refuse to run until an explicit script update. The hash covers that file only, not imported dependencies.
|
|
@@ -52,6 +72,14 @@ Failures default to unreviewed owner runs. Review records a diagnosis; it never
|
|
|
52
72
|
A conditional review schedule consumes no model run when there are no unreviewed failures.
|
|
53
73
|
New tasks capture the selected engine settings; edit preserves existing settings unless overridden.
|
|
54
74
|
Every task requires a concrete saved model. Supply --model if the selected settings have none; existing tasks never fall back to chat or client defaults.
|
|
75
|
+
Fallbacks run in the saved order only when the previous setup's CLI is unavailable or its provider
|
|
76
|
+
rejected the turn before any work began, including an unavailable login. A Claude usage limit the run ended on
|
|
77
|
+
after work began continues on the next setup in a fresh session, told the stopped session and that its work may be
|
|
78
|
+
partly applied. Account/organization access denial, unknown errors, cancellation and other failures after work
|
|
79
|
+
began stop the chain; nothing is replayed.
|
|
80
|
+
A login or quota rejection skips later setups of the same native login profile
|
|
81
|
+
or upstream provider. Named profiles must be owner-provisioned logins for separate accounts; Ez never enables overage.
|
|
82
|
+
The run records each attempt, setup, failure category and any reported reset time.
|
|
55
83
|
Trigger runs an existing task once with its saved instructions, AI and delivery binding in a fresh session. Reuse the request key after an uncertain result; the regular schedule is unchanged.
|
|
56
84
|
Creates a scheduled task. Instructions are text, never shell commands.
|
|
57
85
|
Use --now to run once. Run completion is not delivery proof.
|
|
@@ -159,7 +187,8 @@ Cron uses numeric five-field syntax, lists/ranges/steps, and traditional day/wee
|
|
|
159
187
|
if(action==='create' && id && (await scheduler.list()).some(s=>s.id===id))throw new Error('Schedule exists; use edit')
|
|
160
188
|
if([v.now,v.at,v.cron,v['every-seconds']].filter(Boolean).length!==1)throw new Error('Choose exactly one trigger')
|
|
161
189
|
if([v.text,v['text-file'],v.script].filter(Boolean).length!==1)throw new Error('Choose --text, --text-file or --script')
|
|
162
|
-
if(v.script && (v.cli || v.model || v.effort))throw new Error('Script schedules run no model; --cli, --model and --
|
|
190
|
+
if(v.script && (v.cli || v['auth-profile'] || v.model || v.effort || v.fallback || v['clear-fallbacks']))throw new Error('Script schedules run no model; --cli, --auth-profile, --model, --effort and --fallback do not apply')
|
|
191
|
+
if(v.fallback && v['clear-fallbacks'])throw new Error('Choose --fallback or --clear-fallbacks')
|
|
163
192
|
if(v.arg && !v.script)throw new Error('--arg applies to script schedules only')
|
|
164
193
|
const start=v.start || new Date(Date.now()+1000).toISOString()
|
|
165
194
|
const trigger:Trigger=v.now ? {at:new Date(Date.now()+1000).toISOString()} : v.at ? {at:v.at} :
|
|
@@ -180,12 +209,15 @@ Cron uses numeric five-field syntax, lists/ranges/steps, and traditional day/wee
|
|
|
180
209
|
const state = await control.status()
|
|
181
210
|
const selected = state.ai?.presets.find(p => p.id === state.ai!.selectedId)
|
|
182
211
|
const base = v.cli ? initialPreset(v.cli) : previous?.preset || selected || initialPreset(process.env.EZ_EXECUTOR_CLI || 'codex')
|
|
183
|
-
const preset = executionOverrides(base.cli, base, v.model, v.effort)
|
|
212
|
+
const preset = executionOverrides(base.cli, v['auth-profile'] === undefined ? base : {...base, authProfile:v['auth-profile']}, v.model, v.effort)
|
|
184
213
|
if (!isPreset(preset)) throw new Error('Invalid task AI selection')
|
|
214
|
+
const fallbacks = v['clear-fallbacks'] ? undefined : v.fallback ? v.fallback.map(fallbackSetup) : previous?.fallbacks
|
|
215
|
+
for (const setup of [preset, ...fallbacks ?? []])
|
|
216
|
+
if (setup.authProfile !== undefined) await provisionedHome(config.controlDir, setup.cli, setup.authProfile)
|
|
185
217
|
result=await show(await scheduler.save({id:id || 's_'+randomUUID(),name:v.name || 'Task',
|
|
186
218
|
preflight,
|
|
187
219
|
originRunId:previousSchedule?.originRunId ?? caller?.scheduled?.originRunId ?? caller?.id,delivery,text:v.text || await readFile(v['text-file']!,'utf8'),when:v.when as 'unreviewed-failures' | undefined,trigger,enabled:true,owner,
|
|
188
|
-
execution:{sessionId:previous?.sessionId || randomUUID(),preset}},action==='create'))
|
|
220
|
+
execution:{sessionId:previous?.sessionId || randomUUID(),preset,...(fallbacks ? {fallbacks} : {})}},action==='create'))
|
|
189
221
|
}else{
|
|
190
222
|
if(!id)throw new Error('ID required')
|
|
191
223
|
if(action==='cancel'){
|
package/src/scheduled-tasks.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { executionDefaults } from './model-policy.js'
|
|
2
|
-
import { presetLabel } from './ai.js'
|
|
2
|
+
import { presetLabel, type AiPreset } from './ai.js'
|
|
3
|
+
import { AUTH_PROFILE_CLIS } from './auth-profile.js'
|
|
3
4
|
import type { Owner } from './control-state.js'
|
|
4
5
|
import type { Schedule, ActiveSchedule } from './scheduler.js'
|
|
5
6
|
|
|
@@ -11,15 +12,45 @@ const ownsSchedule = (owner: Owner, schedule: Schedule) =>
|
|
|
11
12
|
// Saved schedules created under an earlier model policy remain inspectable. The
|
|
12
13
|
// executor will apply the same defaults (and enforce its current policy) when
|
|
13
14
|
// it starts the run; an outdated saved selection must not hide every menu row.
|
|
14
|
-
const displayedPreset = (
|
|
15
|
-
try { return executionDefaults(
|
|
16
|
-
catch { return
|
|
15
|
+
const displayedPreset = (preset: AiPreset) => {
|
|
16
|
+
try { return executionDefaults(preset.cli, preset) }
|
|
17
|
+
catch { return preset }
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const setupLabel = (preset: AiPreset) => {
|
|
21
|
+
const label = presetLabel(displayedPreset(preset))
|
|
22
|
+
return AUTH_PROFILE_CLIS.includes(preset.cli) && preset.authProfile === undefined
|
|
23
|
+
? `${preset.cli} (default login)${label.slice(preset.cli.length)}` : label
|
|
17
24
|
}
|
|
18
25
|
|
|
19
26
|
// Script schedules have no AI preset; show what core actually invokes.
|
|
20
27
|
const engineLabel = (schedule: ActiveSchedule) => schedule.script
|
|
21
28
|
? `Script · ${schedule.script.id} · ${schedule.scriptRevision ? `rev ${schedule.scriptRevision.slice(0, 8)}` : 'not registered'}`
|
|
22
|
-
:
|
|
29
|
+
: [schedule.execution!.preset, ...schedule.execution!.fallbacks ?? []].map(setupLabel).join(' → ')
|
|
30
|
+
|
|
31
|
+
const every = (count: number, unit: string) => `Every ${count === 1 ? '' : `${count} `}${unit}${count === 1 ? '' : 's'}`
|
|
32
|
+
const frequency = (schedule: Schedule) => {
|
|
33
|
+
const trigger = schedule.trigger
|
|
34
|
+
if ('at' in trigger) return 'Once'
|
|
35
|
+
if ('everySeconds' in trigger) {
|
|
36
|
+
const seconds = trigger.everySeconds
|
|
37
|
+
for (const [size, unit] of [[86400,'day'],[3600,'hour'],[60,'minute']] as const)
|
|
38
|
+
if (seconds % size === 0) return every(seconds / size, unit)
|
|
39
|
+
return every(seconds, 'second')
|
|
40
|
+
}
|
|
41
|
+
const cron = trigger.cron.trim().replace(/\s+/g, ' ')
|
|
42
|
+
const [minute, hour, day, month, weekday] = cron.split(' ')
|
|
43
|
+
let label = `Cron ${cron}`
|
|
44
|
+
if (`${hour} ${day} ${month} ${weekday}` === '* * * *') {
|
|
45
|
+
const step = /^\*\/(\d+)$/.exec(minute)
|
|
46
|
+
if (minute === '*') label = 'Every minute'
|
|
47
|
+
else if (step && Number(step[1]) > 0 && 60 % Number(step[1]) === 0) label = every(Number(step[1]), 'minute')
|
|
48
|
+
else if (/^\d+$/.test(minute)) label = `Hourly at :${minute.padStart(2,'0')}`
|
|
49
|
+
} else if (/^\d+$/.test(minute) && /^\d+$/.test(hour) && day === '*' && month === '*' && ['*','1-5'].includes(weekday)) {
|
|
50
|
+
label = `${weekday === '*' ? 'Daily' : 'Weekdays'} at ${hour.padStart(2,'0')}:${minute.padStart(2,'0')}`
|
|
51
|
+
}
|
|
52
|
+
return `${label} · ${trigger.timezone}`
|
|
53
|
+
}
|
|
23
54
|
|
|
24
55
|
const weekdays = ['Sun','Mon','Tue','Wed','Thu','Fri','Sat']
|
|
25
56
|
const months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']
|
|
@@ -52,8 +83,8 @@ export const ownedScheduledTasks = (schedules: ActiveSchedule[], owner: Owner) =
|
|
|
52
83
|
export const scheduledTasksText = (schedules: ActiveSchedule[], owner: Owner) => {
|
|
53
84
|
const owned = ownedScheduledTasks(schedules, owner)
|
|
54
85
|
if (!owned.length) return '📅 Scheduled tasks\n\nNo active scheduled tasks.'
|
|
55
|
-
return [`📅 Scheduled tasks · ${owned.length} active`, '
|
|
56
|
-
`${index + 1} · ${schedule.name}\n ${currentState(schedule)}\n Next · ${schedule.nextAt === null ? '—' : time(schedule.nextAt)}\n Last · ${lastRun(schedule)}\n ${engineLabel(schedule)}\n ${preview(schedule)}`
|
|
86
|
+
return [`📅 Scheduled tasks · ${owned.length} active`, 'Next/last times UTC. Use /tasks 1 for task details.', ...owned.map((schedule, index) =>
|
|
87
|
+
`${index + 1} · ${schedule.name}\n ${currentState(schedule)}\n Schedule · ${frequency(schedule)}\n Next · ${schedule.nextAt === null ? '—' : time(schedule.nextAt)}\n Last · ${lastRun(schedule)}\n Execution · ${engineLabel(schedule)}\n ${preview(schedule)}`
|
|
57
88
|
)].join('\n\n')
|
|
58
89
|
}
|
|
59
90
|
|
|
@@ -64,13 +95,16 @@ export const scheduledTaskDetailText = (schedule: ActiveSchedule, number: number
|
|
|
64
95
|
`📅 ${number}${total === undefined ? '' : ` of ${total}`} · ${schedule.name}`,
|
|
65
96
|
currentState(schedule),
|
|
66
97
|
'',
|
|
98
|
+
'Schedule',
|
|
99
|
+
frequency(schedule),
|
|
100
|
+
'',
|
|
67
101
|
'Next run',
|
|
68
102
|
schedule.nextAt === null ? '—' : time(schedule.nextAt),
|
|
69
103
|
'',
|
|
70
104
|
'Last run',
|
|
71
105
|
lastRun(schedule),
|
|
72
106
|
'',
|
|
73
|
-
|
|
107
|
+
'Execution path',
|
|
74
108
|
engineLabel(schedule),
|
|
75
109
|
'',
|
|
76
110
|
schedule.script ? 'Script arguments' : 'Instructions',
|