@jc_stack/ez-agents 0.1.0-beta.47 → 0.1.0-beta.49
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/.env.example +3 -0
- package/AGENTS.md +2 -2
- package/CHANGELOG.md +30 -0
- package/Dockerfile +5 -1
- package/THIRD_PARTY_NOTICES.md +6 -3
- package/bin/ez +7 -0
- package/docker/broker-readiness.mjs +54 -0
- package/docker/broker-update-smoke.mjs +100 -0
- package/docker/run.ts +1 -1
- package/docker/upgrade-smoke.mjs +1 -1
- package/docs/application-channel.md +32 -1
- package/docs/architecture/ai-selection.md +23 -4
- package/docs/docker-runtime.md +57 -0
- package/docs/isolated-plugin-update-readiness.md +66 -0
- package/docs/releasing.md +4 -0
- package/docs/scheduling.md +141 -6
- package/docs/selective-monitoring.md +13 -0
- package/docs/upgrades.md +33 -4
- package/docs/work-preflight.md +9 -0
- package/package.json +1 -1
- package/src/ai-cli.ts +28 -15
- package/src/ai.ts +98 -15
- package/src/application-channel.ts +52 -12
- package/src/application-cli.ts +7 -1
- package/src/application-origin.ts +2 -0
- package/src/audio.ts +37 -22
- package/src/auth-profile.ts +53 -0
- package/src/client-defaults.ts +3 -2
- package/src/codex-session.ts +14 -3
- package/src/config.ts +9 -0
- package/src/control-state.ts +31 -22
- package/src/delivery-socket-client.d.mts +2 -0
- package/src/delivery-socket-client.mjs +2 -0
- package/src/delivery-socket.ts +26 -1
- package/src/event-sources.ts +2 -2
- package/src/executor.ts +81 -7
- package/src/files.ts +47 -3
- package/src/host-executor.ts +68 -34
- package/src/index.ts +227 -110
- package/src/install-tools.mjs +8 -5
- package/src/menu.ts +9 -7
- package/src/plugin-broker.mjs +50 -14
- package/src/plugins/manager.mjs +34 -8
- package/src/run-evidence.ts +78 -0
- package/src/runs.ts +24 -2
- package/src/schedule-cli.ts +100 -13
- package/src/schedule-preflight.ts +23 -0
- package/src/scheduled-tasks.ts +16 -12
- package/src/scheduler.ts +85 -19
- package/src/scripts.ts +167 -0
- package/src/setup-fallback.ts +122 -0
- package/src/setup.ts +2 -2
- package/src/task-capability.ts +17 -1
- package/src/task-executor.ts +13 -9
- package/src/task-mcp.ts +20 -8
- package/src/tasks.ts +89 -9
- package/src/updates/runtime.mjs +107 -30
- package/src/updates/supervisor.mjs +27 -6
- package/test/ai.test.ts +40 -0
- package/test/application-channel.test.ts +16 -1
- package/test/audio.test.ts +19 -0
- package/test/auth-profiles.test.ts +187 -0
- package/test/config.test.ts +8 -0
- package/test/executor.test.ts +37 -0
- package/test/files.test.ts +44 -0
- package/test/host-executor.test.ts +45 -2
- package/test/install-json.test.mjs +21 -0
- package/test/plugin-broker.test.mjs +67 -0
- package/test/plugin-manager.test.mjs +44 -0
- package/test/private-speech-config.test.ts +22 -0
- package/test/run-evidence.test.ts +78 -0
- package/test/schedule-preflight.test.ts +49 -0
- package/test/scheduler-host.test.ts +7 -0
- package/test/scheduler-relay.test.ts +38 -3
- package/test/scheduler.test.ts +1 -1
- package/test/script-schedules.test.ts +219 -0
- package/test/setup-fallback.test.ts +245 -0
- package/test/task-native.test.ts +50 -12
- package/test/tasks.test.ts +97 -4
- package/test/updates.test.mjs +100 -11
package/src/menu.ts
CHANGED
|
@@ -4,8 +4,9 @@ import path from 'node:path'
|
|
|
4
4
|
import { randomBytes } from 'node:crypto'
|
|
5
5
|
import { InlineKeyboard, type Context } from 'grammy'
|
|
6
6
|
import { ControlStore, type ControlGuard } from './control-state.js'
|
|
7
|
-
import { chatPreset, installed, persistedPreset, presetLabel, presetProvider, readModels, validateSelection, type AiPreset, type ModelChoice } from './ai.js'
|
|
7
|
+
import { chatPreset, installed, persistedPreset, presetLabel, presetProvider, readModels, sameChoice, sameEngine, validateSelection, type AiPreset, type ModelChoice } from './ai.js'
|
|
8
8
|
import { discoverDefaults } from './client-defaults.js'
|
|
9
|
+
import { claudeConfigHome } from './executor.js'
|
|
9
10
|
|
|
10
11
|
export const mainCommands = [
|
|
11
12
|
{ command: 'new', description: 'New conversation' },
|
|
@@ -24,7 +25,7 @@ export const mainKeyboard = () => new InlineKeyboard()
|
|
|
24
25
|
const clientLabel = (cli: string) => cli === 'codex-gui' ? 'codex-gui (desktop)' : cli
|
|
25
26
|
|
|
26
27
|
const matchesModel = (preset: AiPreset, model: ModelChoice) =>
|
|
27
|
-
model
|
|
28
|
+
sameEngine(model, preset) && model.provider === preset.provider && (preset.model === undefined || model.model === preset.model) &&
|
|
28
29
|
(preset.effort === undefined || model.efforts.includes(preset.effort))
|
|
29
30
|
|
|
30
31
|
// Short-lived opaque button IDs: no model names or executable arguments from callbacks.
|
|
@@ -38,14 +39,15 @@ export const createAiMenu = (control: ControlStore, cli: string, catalog = readM
|
|
|
38
39
|
// directory, but the default catalog probed the process home instead. Bind it
|
|
39
40
|
// to the same agent-bound home so /ai lists the models Codex can actually run.
|
|
40
41
|
else if (codexHome && catalog === readModels) catalog = () => readModels(undefined, isInstalled, codexHome, undefined, opencodeDataHome, undefined, process.env.EZ_CONTROL_DIR)
|
|
41
|
-
const
|
|
42
|
+
const claudeHome = process.env.EZ_CONTROL_DIR ? claudeConfigHome(process.env.EZ_CONTROL_DIR) : undefined
|
|
43
|
+
const refresh = async () => control.syncClientPresets(initial, host ? [] : await discoverDefaults(workspace, { codexHome, claudeHome }))
|
|
42
44
|
const validate = async (preset: AiPreset) => {
|
|
43
45
|
assertEffort(preset.effort, preset.model, preset.cli)
|
|
44
46
|
if (preset.id === initial.id) return
|
|
45
47
|
if (preset.id.startsWith('detected_')) {
|
|
46
48
|
if (host) await validateSelection(preset, await catalog(), async name => (await catalog()).some(model => model.cli === name))
|
|
47
49
|
else {
|
|
48
|
-
const detected = await discoverDefaults(workspace, { codexHome })
|
|
50
|
+
const detected = await discoverDefaults(workspace, { codexHome, claudeHome })
|
|
49
51
|
if (!detected.some((p) => p.id === preset.id)) throw new Error('Client settings changed. Refresh available AIs and select the updated choice.')
|
|
50
52
|
}
|
|
51
53
|
} else await validateSelection(preset, await catalog(), host ? async name => (await catalog()).some(model => model.cli === name) : isInstalled)
|
|
@@ -64,7 +66,7 @@ export const createAiMenu = (control: ControlStore, cli: string, catalog = readM
|
|
|
64
66
|
const state = (await control.status()).ai
|
|
65
67
|
if (!state) throw new Error('AI settings not initialized')
|
|
66
68
|
const current = state.presets.find((p) => p.id === state.selectedId)!
|
|
67
|
-
const fresh = current
|
|
69
|
+
const fresh = !sameEngine(current, preset) || presetProvider(current) !== presetProvider(preset) || Boolean(session && !session.cli)
|
|
68
70
|
if (!await control.selectPreset(preset.id, expectedSession, fresh, guard)) throw new Error('AI binding changed. Refresh available AIs before trying again.')
|
|
69
71
|
return { preset, fresh }
|
|
70
72
|
}
|
|
@@ -125,9 +127,9 @@ export const createAiMenu = (control: ControlStore, cli: string, catalog = readM
|
|
|
125
127
|
const state = (await control.status()).ai
|
|
126
128
|
if (!state) throw new Error('AI settings not initialized')
|
|
127
129
|
const candidate: AiPreset = { id: randomBytes(8).toString('hex'),
|
|
128
|
-
name: `${model.name}${effort ? ` · ${effort}` : ''}`.slice(0, 80), cli: model.cli, provider:model.provider, model: model.model, effort }
|
|
130
|
+
name: `${model.name}${effort ? ` · ${effort}` : ''}`.slice(0, 80), cli: model.cli, provider:model.provider, ...(model.authProfile ? {authProfile:model.authProfile} : {}), model: model.model, effort }
|
|
129
131
|
const stored = persistedPreset(candidate)
|
|
130
|
-
const existing = state.presets.find((preset) => preset
|
|
132
|
+
const existing = state.presets.find((preset) => sameChoice(preset, stored))
|
|
131
133
|
const preset = existing ?? candidate
|
|
132
134
|
await validateSelection(preset, await catalog(), host ? async name => (await catalog()).some(model => model.cli === name) : isInstalled)
|
|
133
135
|
// Private scopes persist their selection on the session, not the owner menu.
|
package/src/plugin-broker.mjs
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
|
+
import { combinePreflight } from './schedule-preflight.js';
|
|
1
2
|
import * as fs from 'node:fs/promises';
|
|
3
|
+
import {callDeliverySocket} from './delivery-socket-client.mjs';
|
|
2
4
|
import path from 'node:path';
|
|
3
5
|
import { createServer } from 'node:net';
|
|
4
6
|
import { randomBytes, randomUUID, createHash } from 'node:crypto';
|
|
5
7
|
import { spawn } from 'node:child_process';
|
|
6
8
|
import { fileURLToPath } from 'node:url';
|
|
7
|
-
import { prepareCommand, registry, run as dockerRun, stewardOwned } from './plugins/manager.mjs';
|
|
9
|
+
import { prepareCommand, registry, run as dockerRun, stewardOwned, publishBrokerPlugins } from './plugins/manager.mjs';
|
|
8
10
|
|
|
9
11
|
const MAX_FRAME = 4 * 1024 * 1024;
|
|
10
12
|
const MAX_ARGS = 100;
|
|
@@ -163,15 +165,7 @@ const writeReceipt = async (binding, receipt) => {
|
|
|
163
165
|
// Installed plugin versions for relay status. Rewritten on broker start and
|
|
164
166
|
// after every management call; the relay reads it without Docker access.
|
|
165
167
|
// Failures stay local so a status snapshot never breaks plugin operations.
|
|
166
|
-
const refreshBrokerPlugins =
|
|
167
|
-
const r = await registry(binding.home);
|
|
168
|
-
const plugins = Object.values(r.plugins).map(record => ({ id: record.manifest.id, version: record.manifest.version }));
|
|
169
|
-
const file = path.join(binding.controlDir, 'plugin-broker-plugins.json');
|
|
170
|
-
const temporary = `${file}.${randomUUID()}.tmp`;
|
|
171
|
-
await fs.writeFile(temporary, JSON.stringify({ version: 1, at: new Date().toISOString(), plugins }) + '\n', { mode: 0o600, flag: 'wx' });
|
|
172
|
-
await fs.rename(temporary, file);
|
|
173
|
-
await stewardOwned(file);
|
|
174
|
-
};
|
|
168
|
+
const refreshBrokerPlugins = binding => publishBrokerPlugins(binding.home,binding.controlDir);
|
|
175
169
|
|
|
176
170
|
const response = (socket, value) => {
|
|
177
171
|
if (socket.destroyed || !socket.writable) return;
|
|
@@ -223,23 +217,38 @@ const managerCommand = async (binding, args, signal, runId) => new Promise((reso
|
|
|
223
217
|
});
|
|
224
218
|
});
|
|
225
219
|
|
|
226
|
-
const
|
|
220
|
+
const authorizeTaskLaunch=async(binding,request)=>{
|
|
221
|
+
const input=JSON.parse(request.stdin)
|
|
222
|
+
if(Object.keys(input).some(k=>!['taskId','taskToken','expiresAt'].includes(k)) || typeof input.taskToken!=='string' || !/^[a-f0-9]{64}$/.test(input.taskToken))throw Error('Invalid task launch input')
|
|
223
|
+
const ticketHash=createHash('sha256').update(input.taskToken).digest('hex')
|
|
224
|
+
const grant=await callDeliverySocket(deliverySocketPath(binding.controlDir),{op:'taskApplication',payload:{runId:request.runId,ticketHash}})
|
|
225
|
+
if(input.taskId!==grant.taskId || input.expiresAt!==grant.expiresAt)throw Error('Task launch authority mismatch')
|
|
226
|
+
return grant.launch
|
|
227
|
+
}
|
|
228
|
+
const invokePlugin = async (binding, request, signal, scheduledPreflight = false) => {
|
|
227
229
|
const startedAt = new Date().toISOString();
|
|
228
230
|
let plugin = request.alias;
|
|
229
231
|
let revision = request.revision;
|
|
230
232
|
let result;
|
|
231
233
|
let failure;
|
|
232
234
|
try {
|
|
233
|
-
|
|
235
|
+
if (!scheduledPreflight) {
|
|
236
|
+
if(request.operation==='task-launch') {
|
|
237
|
+
const launch=await authorizeTaskLaunch(binding,request)
|
|
238
|
+
if(launch.command!==request.alias || JSON.stringify(launch.args)!==JSON.stringify(request.args))throw Error('Task launch command changed')
|
|
239
|
+
}else await authorize(binding, request.runId);
|
|
240
|
+
}
|
|
234
241
|
const current = await registry(binding.home);
|
|
235
242
|
const selected = recordForAlias(current, request.alias);
|
|
243
|
+
if (scheduledPreflight && (!selected.command.exposure || selected.command.exposure.changesRecords !== false || selected.command.exposure.sendsExternally !== false)) throw Error('Scheduled preflight requires an explicitly read-only plugin command');
|
|
236
244
|
plugin = selected.plugin;
|
|
237
245
|
revision = selected.record.revision;
|
|
238
246
|
if (selected.record.revision !== request.revision) throw Error('Plugin changed; discover again');
|
|
239
247
|
const command = await prepareCommand(binding.home, request.alias, request.args, {
|
|
240
248
|
revision: request.revision,
|
|
241
249
|
invocation: true,
|
|
242
|
-
|
|
250
|
+
signal,
|
|
251
|
+
environment: { EZ_CONTROL_DIR: binding.controlDir, ...(scheduledPreflight ? {} : {EZ_RUN_ID: request.runId}), EZ_DELIVERY_SOCKET: process.env.EZ_DELIVERY_SOCKET ?? deliverySocketPath(binding.controlDir) },
|
|
243
252
|
});
|
|
244
253
|
try {
|
|
245
254
|
result = await dockerRun(command.argv, {
|
|
@@ -282,8 +291,17 @@ const invokePlugin = async (binding, request, signal) => {
|
|
|
282
291
|
|
|
283
292
|
export async function validateBrokerRequest(request, workspace) {
|
|
284
293
|
if (!request || typeof request !== 'object' || request.version !== 1 || !REQUEST_ID.test(request.id)) throw Error('Invalid plugin broker request');
|
|
285
|
-
if
|
|
294
|
+
if(request.operation === 'preflight') {
|
|
295
|
+
validId(request.scheduleId,RUN_ID,'schedule ID'); validId(request.revision,/^[a-f0-9-]{36}$/,'schedule revision');
|
|
296
|
+
if(Object.keys(request).some(k=>!['version','id','operation','scheduleId','revision'].includes(k))) throw Error('Unexpected schedule preflight field');
|
|
297
|
+
return request;
|
|
298
|
+
}
|
|
299
|
+
if (!['resolve', 'invoke', 'manager','task-launch'].includes(request.operation)) throw Error('Unknown plugin broker operation');
|
|
286
300
|
validId(request.runId, RUN_ID, 'plugin run ID');
|
|
301
|
+
if(request.operation==='task-launch') {
|
|
302
|
+
if(Object.keys(request).some(k=>!['version','id','operation','runId','stdin'].includes(k)))throw Error('Unexpected task launch field')
|
|
303
|
+
boundedString(request.stdin,1024,'task launch stdin');return request
|
|
304
|
+
}
|
|
287
305
|
if (request.operation === 'manager') {
|
|
288
306
|
await validateManagerArgs(request.args, workspace);
|
|
289
307
|
if (Object.keys(request).some(key => !['version', 'id', 'operation', 'runId', 'args'].includes(key))) throw Error('Unexpected broker management field');
|
|
@@ -328,6 +346,18 @@ export async function servePluginBroker(binding, signal = new AbortController().
|
|
|
328
346
|
(async () => {
|
|
329
347
|
try {
|
|
330
348
|
await validateBrokerRequest(request, binding.workspace);
|
|
349
|
+
if (request.operation === 'preflight') {
|
|
350
|
+
const condition=await (await import('./delivery-socket-client.mjs')).callDeliverySocket(deliverySocketPath(binding.controlDir),{op:'schedulePreflight',payload:{scheduleId:request.scheduleId,revision:request.revision}});
|
|
351
|
+
const values=[];
|
|
352
|
+
for(const check of condition.checks) {
|
|
353
|
+
const selected=recordForAlias(await registry(binding.home),check.alias);
|
|
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);
|
|
355
|
+
if(!result.ok || result.code!==0) throw Error('Scheduled plugin preflight unavailable');
|
|
356
|
+
try { values.push(JSON.parse(result.stdout)); } catch { throw Error('Scheduled plugin preflight returned invalid output'); }
|
|
357
|
+
}
|
|
358
|
+
response(socket,{version:1,id:request.id,ok:true,result:combinePreflight(values)});
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
331
361
|
if (request.operation === 'resolve') {
|
|
332
362
|
await authorize(binding, request.runId);
|
|
333
363
|
const selected = recordForAlias(await registry(binding.home), request.alias);
|
|
@@ -338,6 +368,12 @@ export async function servePluginBroker(binding, signal = new AbortController().
|
|
|
338
368
|
response(socket, { version: 1, id: request.id, ok: true, plugin: selected.plugin, alias: request.alias, revision: selected.record.revision, capability });
|
|
339
369
|
return;
|
|
340
370
|
}
|
|
371
|
+
if(request.operation==='task-launch') {
|
|
372
|
+
const launch=await authorizeTaskLaunch(binding,request)
|
|
373
|
+
const selected=recordForAlias(await registry(binding.home),launch.command)
|
|
374
|
+
const result=await invokePlugin(binding,{...request,alias:launch.command,args:launch.args,revision:selected.record.revision},abort.signal)
|
|
375
|
+
response(socket,{version:1,id:request.id,...result});return
|
|
376
|
+
}
|
|
341
377
|
if (request.operation === 'manager') {
|
|
342
378
|
await authorize(binding, request.runId);
|
|
343
379
|
const result = await managerCommand(binding, request.args, abort.signal, request.runId);
|
package/src/plugins/manager.mjs
CHANGED
|
@@ -78,7 +78,8 @@ async function activeInvocations(home) {
|
|
|
78
78
|
const directory=invocationDirectory(home),files=await fs.readdir(directory).catch(error=>{if(error.code==='ENOENT')return [];throw error;});
|
|
79
79
|
for(const file of files) {
|
|
80
80
|
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));
|
|
81
|
+
const lease=await json(path.join(directory,file)).catch(error=>{if(error.code==='ENOENT')return null;throw error;});
|
|
82
|
+
if(!lease)continue;
|
|
82
83
|
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
|
try {process.kill(lease.pid,0);return true;} catch(error) {if(error.code!=='ESRCH'&&error.code!=='EPERM')throw error;}
|
|
84
85
|
const state=await run(['container','inspect',lease.container],{capture:true});
|
|
@@ -94,14 +95,31 @@ async function invocationLease(home,container) {
|
|
|
94
95
|
await stewardOwned(file);
|
|
95
96
|
return async()=>{await fs.rm(file,{force:true});};
|
|
96
97
|
}
|
|
97
|
-
export async function locked(home, fn, {allowInvocations=false}={}) {
|
|
98
|
+
export async function locked(home, fn, {allowInvocations=false, waitMs=0, upgradeWaitMs=0, drainInvocations=false, signal}={}) {
|
|
98
99
|
const lock = path.join(home,'registry.lock');
|
|
99
100
|
let handle;
|
|
100
|
-
|
|
101
|
-
|
|
101
|
+
const started=Date.now();
|
|
102
|
+
while(!handle) {
|
|
103
|
+
signal?.throwIfAborted();
|
|
104
|
+
try { handle=await fs.open(lock,'wx',0o600); }
|
|
105
|
+
catch(error) {
|
|
106
|
+
if(error.code!=='EEXIST')throw error;
|
|
107
|
+
const owner=await json(lock).catch(()=>null);
|
|
108
|
+
const limit=owner?.kind==='plugin-upgrade'&&upgradeWaitMs?upgradeWaitMs:waitMs;
|
|
109
|
+
if(Date.now()-started>=limit)throw Error('Registry busy; inspect registry.lock before recovering an interrupted manager');
|
|
110
|
+
await new Promise(resolve=>setTimeout(resolve,25));
|
|
111
|
+
}
|
|
112
|
+
}
|
|
102
113
|
try {
|
|
103
|
-
await handle.writeFile(JSON.stringify({pid:process.pid}));
|
|
104
|
-
|
|
114
|
+
await handle.writeFile(JSON.stringify({pid:process.pid,...(drainInvocations?{kind:'plugin-upgrade'}:{})}));
|
|
115
|
+
// Keep admission closed while already-started calls release their leases.
|
|
116
|
+
// This is a lifecycle barrier, not an agent-turn drain or a command retry.
|
|
117
|
+
while(!allowInvocations&&await activeInvocations(home)) {
|
|
118
|
+
if(!drainInvocations)throw Error('Plugin commands are active; retry the registry or lifecycle change after they finish');
|
|
119
|
+
signal?.throwIfAborted();
|
|
120
|
+
await new Promise(resolve=>setTimeout(resolve,25));
|
|
121
|
+
}
|
|
122
|
+
signal?.throwIfAborted();
|
|
105
123
|
return await fn();
|
|
106
124
|
}
|
|
107
125
|
finally { await handle.close(); await fs.rm(lock); }
|
|
@@ -467,8 +485,16 @@ export async function registry(home) {
|
|
|
467
485
|
for(const [alias,plugin] of Object.entries(r.commands)) if(!r.plugins[plugin]?.deployment?.commands?.[alias]) throw Error('Corrupt command registry');
|
|
468
486
|
return r;
|
|
469
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
|
+
}
|
|
470
496
|
// The lock protects admission and compose refresh, never a persistent connection.
|
|
471
|
-
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}={}) {
|
|
472
498
|
strings(args);
|
|
473
499
|
return locked(home,async()=>{
|
|
474
500
|
const config=await json(path.join(home,'config.json')),r=await registry(home);
|
|
@@ -498,7 +524,7 @@ export async function prepareCommand(home,alias,args,{revision,exclude,publish,i
|
|
|
498
524
|
const invocationRelease=invocation?await invocationLease(home,container):undefined;
|
|
499
525
|
const release=(invocationRelease||contextFile)?(async()=>{try{if(contextFile)await fs.rm(contextFile,{force:true});}finally{await invocationRelease?.();}}):undefined;
|
|
500
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};
|
|
501
|
-
},{allowInvocations:true});
|
|
527
|
+
},{allowInvocations:true,waitMs:5000,upgradeWaitMs:invocation?300000:0,signal});
|
|
502
528
|
}
|
|
503
529
|
export async function init(home,workspace,catalogFile,hostConfig,standalone=false) {
|
|
504
530
|
if(standalone && hostConfig) throw Error('Standalone setup cannot bind a relay host config');
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import { readOnlyOwner, requireOwnerExecution } from './execution-authority.js'
|
|
3
|
+
import { ownsRun, assertId } from './identity.js'
|
|
4
|
+
import { RunStore, retainedOutbox, type RunRecord, type StoredOutboxItem } from './runs.js'
|
|
5
|
+
import type { Owner } from './control-state.js'
|
|
6
|
+
|
|
7
|
+
const sha256 = (value: string) => createHash('sha256').update(value).digest('hex')
|
|
8
|
+
const version = (value?: string) => value && /^[0-9A-Za-z.+-]{1,80}$/.test(value) ? value : undefined
|
|
9
|
+
|
|
10
|
+
function delivery(item: StoredOutboxItem) {
|
|
11
|
+
const receipt = item.receipt as { messageIds?: unknown; deliveredAt?: unknown } | undefined
|
|
12
|
+
const valid = item.state === 'sent' && !item.deliveryUnknown &&
|
|
13
|
+
Array.isArray(receipt?.messageIds) && receipt.messageIds.length > 0 &&
|
|
14
|
+
receipt.messageIds.every(id => Number.isSafeInteger(id) && id > 0) &&
|
|
15
|
+
typeof receipt.deliveredAt === 'string' && Number.isFinite(Date.parse(receipt.deliveredAt))
|
|
16
|
+
const field = typeof item.text === 'string' ? 'text' : typeof item.voiceText === 'string' ? 'voiceText' : undefined
|
|
17
|
+
const content = field === 'text' ? item.text : field === 'voiceText' ? item.voiceText : undefined
|
|
18
|
+
return { id: item.id, createdAt: item.createdAt, type: item.type ?? 'message',
|
|
19
|
+
state: item.deliveryUnknown ? 'unknown' : item.state ?? 'queued', receiptVerified: Boolean(valid),
|
|
20
|
+
...(valid ? { deliveredAt: receipt!.deliveredAt, messageCount: (receipt!.messageIds as number[]).length } : {}),
|
|
21
|
+
...(content === undefined ? {} : { content: { field, sha256: sha256(content), bytes: Buffer.byteLength(content), representation: 'submitted UTF-8' } }),
|
|
22
|
+
recipientContentReadback: 'unavailable', attachmentContent: 'not exported' }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Projection only: never read native sessions, tool arguments/results or model text.
|
|
26
|
+
export function projectRunEvidence(runs: RunRecord[], outbox: StoredOutboxItem[], owner: Owner) {
|
|
27
|
+
return runs.filter(run => ownsRun(owner, run) && Date.parse(run.createdAt) >= Date.parse(owner.pairedAt) &&
|
|
28
|
+
!run.external && !run.taskId && !run.application && !run.delivery &&
|
|
29
|
+
(!run.scheduled || run.scheduled.pairedAt === owner.pairedAt))
|
|
30
|
+
.sort((a, b) => a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id))
|
|
31
|
+
.map(run => ({ id: run.id, status: run.status, createdAt: run.createdAt,
|
|
32
|
+
startedAt: run.startedAt, endedAt: run.endedAt, exitCode: run.exitCode, interrupted: run.interrupted,
|
|
33
|
+
scheduled: run.scheduled ? { id: run.scheduled.id, revision: run.scheduled.revision, dueAt: run.scheduled.dueAt } : undefined,
|
|
34
|
+
executionType: run.script ? 'script' : 'agent',
|
|
35
|
+
script: run.script ? { id: run.script.id, revision: run.script.revision, sha256: run.script.sha256, timedOut: Boolean(run.timedOut),
|
|
36
|
+
outputSha256: run.output ? sha256(run.output) : undefined } : undefined,
|
|
37
|
+
preset: run.execution ? { cli: run.execution.preset.cli, model: run.execution.preset.model, effort: run.execution.preset.effort } : undefined,
|
|
38
|
+
failure: run.failure ? { recorded: true, errorSha256: sha256(run.failure.error), relayVersion: version(run.failure.relayVersion), hostVersion: version(run.failure.hostVersion) } : undefined,
|
|
39
|
+
failureReasonRecorded: Boolean(run.failureReason), blockReasonRecorded: Boolean(run.blockReason),
|
|
40
|
+
failureReview: run.failureReview ? { failedAt: run.failureReview.failedAt, reviewedAt: run.failureReview.reviewedAt, status: run.failureReview.status } : undefined,
|
|
41
|
+
nativeSessionRecorded: Boolean(run.nativeSessionId),
|
|
42
|
+
toolDelegationMetadata: { state: 'unavailable', reason: 'The relay does not retain native tool or delegation events.' },
|
|
43
|
+
deliveries: outbox.filter(item => item.runId === run.id && item.chatId === owner.telegramChatId &&
|
|
44
|
+
Date.parse(item.createdAt) >= Date.parse(owner.telegramLinkedAt ?? owner.pairedAt))
|
|
45
|
+
.sort((a, b) => a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id)).map(delivery),
|
|
46
|
+
}))
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export type EvidenceOptions = { callerRunId?: string; runId?: string; offset?: number; limit?: number; expected?: string }
|
|
50
|
+
|
|
51
|
+
export async function runEvidence(controlDir: string, options: EvidenceOptions = {}) {
|
|
52
|
+
const readStartedAt = new Date().toISOString()
|
|
53
|
+
const offset = options.offset ?? 0, limit = options.limit ?? 100
|
|
54
|
+
if (!Number.isSafeInteger(offset) || offset < 0 || !Number.isSafeInteger(limit) || limit < 1 || limit > 100)
|
|
55
|
+
throw new Error('Offset must be nonnegative; limit must be 1..100')
|
|
56
|
+
if (offset > 0 && !options.expected) throw new Error('Continuation requires --expected snapshot SHA-256')
|
|
57
|
+
if (options.expected !== undefined && !/^[a-f0-9]{64}$/.test(options.expected)) throw new Error('Invalid expected snapshot SHA-256')
|
|
58
|
+
if (options.callerRunId !== undefined) {
|
|
59
|
+
const caller = await requireOwnerExecution(controlDir, options.callerRunId)
|
|
60
|
+
if (caller.application || caller.delivery || caller.replyOnly) throw new Error('Evidence requires Telegram owner execution or the bound operator')
|
|
61
|
+
}
|
|
62
|
+
const owner = await readOnlyOwner(controlDir)
|
|
63
|
+
if (!owner) throw new Error('No authorized owner')
|
|
64
|
+
const records = projectRunEvidence(await new RunStore(controlDir).list(), retainedOutbox(controlDir), owner)
|
|
65
|
+
.filter(run => options.runId === undefined || run.id === assertId(options.runId))
|
|
66
|
+
if (options.runId !== undefined && !records.length) throw new Error('Unknown owner run')
|
|
67
|
+
const snapshotSha256 = sha256(JSON.stringify(records))
|
|
68
|
+
if (options.expected !== undefined && options.expected !== snapshotSha256) throw new Error('Evidence changed; restart at offset zero')
|
|
69
|
+
if (offset > records.length) throw new Error('Offset exceeds retained coverage')
|
|
70
|
+
const page = records.slice(offset, offset + limit)
|
|
71
|
+
return { schemaVersion: 1, readStartedAt, observedAt: new Date().toISOString(), snapshotSha256,
|
|
72
|
+
coverage: { scope: 'current-owner Telegram foreground and scheduled runs', total: records.length, offset, limit, returned: page.length,
|
|
73
|
+
nextOffset: offset + page.length < records.length ? offset + page.length : null,
|
|
74
|
+
history: 'relay memory; resets on restart; at most 2000 terminal runs and 2000 terminal outbox items retained',
|
|
75
|
+
completeness: 'retained records only; older history and unassociated deliveries unknown',
|
|
76
|
+
excluded: ['external tasks', 'isolated tasks', 'application/channel runs', 'native sessions', 'tool/delegation events', 'failure/review prose', 'message content', 'recipient-side content readback'] },
|
|
77
|
+
runs: page }
|
|
78
|
+
}
|
package/src/runs.ts
CHANGED
|
@@ -6,7 +6,9 @@ import { normalizeReactionEmoji } from './reaction.js'
|
|
|
6
6
|
import { validScheduledOrigin, type ScheduledOrigin } from './scheduler.js'
|
|
7
7
|
import type { IncomingItem } from './inbox.js'
|
|
8
8
|
import { assertId } from './identity.js'
|
|
9
|
-
import { isExecutionChoice, type ExecutionChoice } from './ai.js'
|
|
9
|
+
import { isExecutionChoice, type AiPreset, type ExecutionChoice } from './ai.js'
|
|
10
|
+
import type { SetupAttempt } from './setup-fallback.js'
|
|
11
|
+
import type { ScriptRunRef } from './scripts.js'
|
|
10
12
|
import {authorizeDeliveryContext,currentDeliveryOwner,type DeliveryContext} from './delivery-context.mjs'
|
|
11
13
|
|
|
12
14
|
export type RunStatus = 'queued' | 'running' | 'completed' | 'failed' | 'cancelled'
|
|
@@ -31,6 +33,8 @@ export type RunRecord = {
|
|
|
31
33
|
pid?: number
|
|
32
34
|
blockReason?: string
|
|
33
35
|
execution?: ExecutionChoice
|
|
36
|
+
// Ordered task setups tried for this occurrence; the last one is active.
|
|
37
|
+
attempts?: SetupAttempt[]
|
|
34
38
|
replyOnly?: boolean
|
|
35
39
|
exitCode?: number | null
|
|
36
40
|
failureReason?: string
|
|
@@ -44,6 +48,11 @@ export type RunRecord = {
|
|
|
44
48
|
application?: ApplicationOrigin
|
|
45
49
|
telegramApplication?: ApplicationOrigin
|
|
46
50
|
delivery?: { bindingId: string; scope: string }
|
|
51
|
+
// Registered script occurrence (no execution preset or native session).
|
|
52
|
+
script?: ScriptRunRef
|
|
53
|
+
// Bounded, redacted script stdout/stderr tail; diagnostic, never delivered.
|
|
54
|
+
output?: string
|
|
55
|
+
timedOut?: boolean
|
|
47
56
|
}
|
|
48
57
|
|
|
49
58
|
export type OutboxItemType = 'message' | 'reaction' | 'document' | 'voice' | 'approval'
|
|
@@ -71,6 +80,9 @@ export type StoredOutboxItem = OutboxItem & { state?: 'queued' | 'sending' | 'se
|
|
|
71
80
|
export const sentOutbox = (controlDir: string): StoredOutboxItem[] =>
|
|
72
81
|
[...outboxFor(controlDir).values()].filter(item => item.state === 'sent')
|
|
73
82
|
|
|
83
|
+
// Explicit operational evidence reads; the projection verifies owner/run scope.
|
|
84
|
+
export const retainedOutbox = (controlDir: string): StoredOutboxItem[] => [...outboxFor(controlDir).values()]
|
|
85
|
+
|
|
74
86
|
// Stateless pipe: the relay owns the ledger in process memory. Cross-process
|
|
75
87
|
// producers (engine children, plugin children, host executor) reach it through
|
|
76
88
|
// the delivery socket (see delivery-socket.ts), never through control/ files.
|
|
@@ -120,6 +132,14 @@ const pruneTerminals = (controlDir: string): void => {
|
|
|
120
132
|
}
|
|
121
133
|
}
|
|
122
134
|
|
|
135
|
+
// The setup the current attempt runs with: the latest recorded attempt, else the
|
|
136
|
+
// saved choice. Only a setup saved on the task itself is ever admitted.
|
|
137
|
+
export const activeSetup = (run: Pick<RunRecord, 'attempts' | 'execution'>): AiPreset | undefined => {
|
|
138
|
+
if (!run.execution) return undefined
|
|
139
|
+
const preset = [...run.attempts ?? []].reverse().find(attempt => attempt.outcome !== 'skipped')?.preset ?? run.execution.preset
|
|
140
|
+
return [run.execution.preset, ...run.execution.fallbacks ?? []].find(candidate => JSON.stringify(candidate) === JSON.stringify(preset))
|
|
141
|
+
}
|
|
142
|
+
|
|
123
143
|
export const newRunId = (): string => `r_${Date.now().toString(36)}_${randomBytes(3).toString('hex')}`
|
|
124
144
|
|
|
125
145
|
export class RunStore {
|
|
@@ -141,6 +161,7 @@ export class RunStore {
|
|
|
141
161
|
taskId?: string
|
|
142
162
|
application?: ApplicationOrigin
|
|
143
163
|
delivery?: { bindingId: string; scope: string }
|
|
164
|
+
script?: ScriptRunRef
|
|
144
165
|
}, exclusiveSchedule = false): Promise<RunRecord> {
|
|
145
166
|
const store = runsFor(this.controlDir)
|
|
146
167
|
if (input.id) {
|
|
@@ -172,6 +193,7 @@ export class RunStore {
|
|
|
172
193
|
application: input.application,
|
|
173
194
|
delivery: input.delivery,
|
|
174
195
|
scheduled: input.scheduled,
|
|
196
|
+
...(input.script ? { script: input.script } : {}),
|
|
175
197
|
status: 'queued',
|
|
176
198
|
createdAt: new Date().toISOString(),
|
|
177
199
|
}
|
|
@@ -187,7 +209,7 @@ export class RunStore {
|
|
|
187
209
|
|
|
188
210
|
async patch(
|
|
189
211
|
id: string,
|
|
190
|
-
change: Partial<Pick<RunRecord, 'status' | 'startedAt' | 'endedAt' | 'pid' | 'nativeSessionId' | 'interrupted' | 'blockReason' | 'backendSubmitted' | 'replyOnly' | 'exitCode' | 'failureReason' | 'failure' | 'failureReview' | 'externalReleased'>>,
|
|
212
|
+
change: Partial<Pick<RunRecord, 'status' | 'startedAt' | 'endedAt' | 'pid' | 'nativeSessionId' | 'interrupted' | 'blockReason' | 'backendSubmitted' | 'replyOnly' | 'exitCode' | 'failureReason' | 'failure' | 'failureReview' | 'externalReleased' | 'output' | 'timedOut' | 'attempts'>>,
|
|
191
213
|
): Promise<RunRecord> {
|
|
192
214
|
const store = runsFor(this.controlDir)
|
|
193
215
|
const run = store.get(id)
|
package/src/schedule-cli.ts
CHANGED
|
@@ -1,38 +1,80 @@
|
|
|
1
1
|
import { needsFailureReview, failureStamp, redactFailure } from './failure.js'
|
|
2
2
|
import { parseArgs } from 'node:util'
|
|
3
3
|
import { readFile } from 'node:fs/promises'
|
|
4
|
+
import { resolve } from 'node:path'
|
|
4
5
|
import { randomUUID } from 'node:crypto'
|
|
5
6
|
import { loadControlConfig } from './config.js'
|
|
6
7
|
import { ControlStore, sameOwner } from './control-state.js'
|
|
7
8
|
import { ApplicationBindings } from './application-channel.js'
|
|
8
9
|
import type { RunRecord } from './runs.js'
|
|
9
10
|
import { callDeliverySocket, socketPathFor } from './delivery-socket.js'
|
|
10
|
-
import { initialPreset, isPreset } from './ai.js'
|
|
11
|
+
import { initialPreset, isPreset, presetLabel, MAX_FALLBACKS, type AiPreset } from './ai.js'
|
|
11
12
|
import { executionOverrides } from './model-policy.js'
|
|
12
|
-
import { holdsSchedule, Scheduler } from './scheduler.js'
|
|
13
|
+
import { holdsSchedule, Scheduler, executionType } from './scheduler.js'
|
|
14
|
+
import { Scripts, fileSha256, workspaceEntry, DEFAULT_SCRIPT_TIMEOUT_SECONDS } from './scripts.js'
|
|
15
|
+
import { executorEnvironment } from './executor.js'
|
|
16
|
+
import { provisionedHome } from './auth-profile.js'
|
|
13
17
|
import { ownsRun } from './identity.js'
|
|
14
18
|
import { type Trigger } from './schedule-time.js'
|
|
15
19
|
|
|
20
|
+
// `cli=claude,model=sonnet,effort=medium` names one fallback setup.
|
|
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[,effort=EFFORT][,provider=ID]')
|
|
25
|
+
return [part.slice(0, at), part.slice(at + 1)]
|
|
26
|
+
}))
|
|
27
|
+
if (Object.keys(fields).some(key => !['cli','model','effort','provider'].includes(key)) || !fields.cli || !fields.model)
|
|
28
|
+
throw new Error('Fallback uses cli=EXECUTOR,model=MODEL[,effort=EFFORT][,provider=ID]')
|
|
29
|
+
const base = initialPreset(fields.cli)
|
|
30
|
+
const preset = executionOverrides(base.cli, {...base, ...(fields.provider ? {provider: fields.provider} : {})}, fields.model, fields.effort)
|
|
31
|
+
const named = {...preset, id: `fallback-${index + 1}`, name: presetLabel(preset).slice(0, 80)}
|
|
32
|
+
if (!isPreset(named)) throw new Error('Invalid fallback setup')
|
|
33
|
+
return named
|
|
34
|
+
}
|
|
35
|
+
|
|
16
36
|
async function main() {
|
|
17
|
-
const { values:v, positionals:[action='list',id] } = parseArgs({allowPositionals:true,options:{
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
37
|
+
const { values:v, positionals:[action='list',id,scriptId] } = parseArgs({allowPositionals:true,options:{
|
|
38
|
+
script:{type:'string'}, arg:{type:'string',multiple:true}, file:{type:'string'}, interpreter:{type:'string'}, 'timeout-seconds':{type:'string'},
|
|
39
|
+
cli:{type:'string'}, 'auth-profile':{type:'string'}, model:{type:'string'}, effort:{type:'string'}, fallback:{type:'string',multiple:true}, 'clear-fallbacks':{type:'boolean'},
|
|
40
|
+
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'},
|
|
41
|
+
'preflight-file':{type:'string'},'clear-preflight':{type:'boolean'},name:{type:'string'}, text:{type:'string'}, 'text-file':{type:'string'}, at:{type:'string'}, now:{type:'boolean'},
|
|
21
42
|
cron:{type:'string'}, timezone:{type:'string'}, 'every-seconds':{type:'string'}, start:{type:'string'}, until:{type:'string'}, help:{type:'boolean'},
|
|
22
43
|
}})
|
|
23
44
|
if(v.help){console.log(`ezenciel-agents-schedule list | runs | show ID | pause ID | resume ID | remove ID | cancel RUN_ID
|
|
24
45
|
failures [--all] [--limit N] | run RUN_ID | context
|
|
46
|
+
evidence [RUN_ID] [--offset N --limit N --expected SNAPSHOT_SHA256]
|
|
25
47
|
trigger SCHEDULE_ID --key REQUEST_KEY
|
|
26
48
|
review RUN_ID --failed-at ISO --status resolved|attention --diagnosis TEXT --recovery TEXT --outcome TEXT
|
|
27
|
-
create [ID] | edit ID --name NAME (--text TEXT | --text-file FILE)
|
|
49
|
+
create [ID] | edit ID --name NAME (--text TEXT | --text-file FILE | --script SCRIPT_ID [--arg=VALUE ...])
|
|
28
50
|
--now | --at ISO_WITH_OFFSET | --every-seconds N | --cron 'MIN HOUR DAY MONTH WEEKDAY' --timezone IANA
|
|
29
|
-
[--cli EXECUTOR] [--model MODEL] [--effort <native-effort>]
|
|
51
|
+
[--cli EXECUTOR] [--auth-profile NAME] [--model MODEL] [--effort <native-effort>]
|
|
52
|
+
[--fallback cli=EXECUTOR,model=MODEL[,effort=EFFORT][,provider=ID] ... | --clear-fallbacks]
|
|
53
|
+
[--preflight-file FILE | --clear-preflight]
|
|
30
54
|
[--start ISO_WITH_OFFSET] [--until ISO_WITH_OFFSET] [--when unreviewed-failures]
|
|
55
|
+
script list | show SCRIPT_ID | remove SCRIPT_ID
|
|
56
|
+
script register|update SCRIPT_ID --file WORKSPACE_PATH --interpreter COMMAND [--arg=VALUE ...] [--timeout-seconds N]
|
|
57
|
+
Execution types: agent (prompt, engine, auth profile, model, effort) or script (registered script ID and saved arguments).
|
|
58
|
+
--auth-profile runs a Codex/Claude task under that provisioned login (ezenciel-agents-ai profiles); --cli alone uses the default login.
|
|
59
|
+
A script registration references an entry point inside the agent workspace and records its SHA-256. Core invokes
|
|
60
|
+
the installed interpreter with the entry point and arguments directly, without a shell or a model. Changed entry-point
|
|
61
|
+
bytes refuse to run until an explicit script update. The hash covers that file only, not imported dependencies.
|
|
62
|
+
Script runs receive EZ_RUN_ID, EZ_SCHEDULE_ID, EZ_SCHEDULE_REVISION, EZ_DUE_AT, EZ_SCRIPT_ID, EZ_SCRIPT_REVISION and
|
|
63
|
+
EZ_SCRIPT_SHA256 plus the normal executor allowlist. Stdout/stderr are bounded diagnostics in run, never owner messages.
|
|
64
|
+
Script runs have a timeout (default ${DEFAULT_SCRIPT_TIMEOUT_SECONDS}s) and are never retried automatically.
|
|
31
65
|
Context reads the current run only.
|
|
66
|
+
Evidence exports sanitized current-owner Telegram operational metadata only.
|
|
67
|
+
Follow nextOffset with --expected; a changed snapshot requires restarting.
|
|
68
|
+
Relay retention is incomplete. Content hashes identify submitted text, not recipient content.
|
|
69
|
+
Native sessions, tool/delegation events and failure/review prose are not exported.
|
|
32
70
|
Failures default to unreviewed owner runs. Review records a diagnosis; it never changes execution status or retries work.
|
|
33
71
|
A conditional review schedule consumes no model run when there are no unreviewed failures.
|
|
34
72
|
New tasks capture the selected engine settings; edit preserves existing settings unless overridden.
|
|
35
73
|
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.
|
|
74
|
+
Fallbacks (at most ${MAX_FALLBACKS}, in order) run only when the previous setup's CLI is unavailable or its provider
|
|
75
|
+
rejected the turn before any work began. Access denial, unknown errors, cancellation and failure after work began stop
|
|
76
|
+
the chain; nothing is replayed. A quota rejection skips later setups of the same client or provider (quota scope),
|
|
77
|
+
even with different credentials. The run records each attempt, setup, failure category and any reported reset time.
|
|
36
78
|
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.
|
|
37
79
|
Creates a scheduled task. Instructions are text, never shell commands.
|
|
38
80
|
Use --now to run once. Run completion is not delivery proof.
|
|
@@ -66,6 +108,10 @@ Cron uses numeric five-field syntax, lists/ranges/steps, and traditional day/wee
|
|
|
66
108
|
if(process.env.EZ_RUN_ID && (!caller || caller.status!=='running' || caller.external || caller.taskId || caller.replyOnly ||
|
|
67
109
|
!ownsRun(owner, caller) ||
|
|
68
110
|
(caller.scheduled && caller.scheduled.pairedAt!==owner.pairedAt)))throw new Error('Scheduling requires an active owner-authorized run')
|
|
111
|
+
// A script occurrence may read state and deliver through bound plugins; it
|
|
112
|
+
// cannot change registrations or schedules, including its own.
|
|
113
|
+
const readOnly=['list','runs','show','run','context','failures','evidence'].includes(action) || (action==='script' && ['list','show'].includes(id ?? ''))
|
|
114
|
+
if(caller?.script && !readOnly)throw new Error('Script runs cannot change scripts or schedules')
|
|
69
115
|
const owned=(s:{owner:typeof owner})=>sameOwner(s.owner,owner)
|
|
70
116
|
const ownsFailureRun=(r:RunRecord | null)=>r && ownsRun(owner,r) && (!r.scheduled || r.scheduled.pairedAt===owner.pairedAt)
|
|
71
117
|
const show=async(s:Awaited<ReturnType<Scheduler['get']>>)=>{
|
|
@@ -73,11 +119,38 @@ Cron uses numeric five-field syntax, lists/ranges/steps, and traditional day/wee
|
|
|
73
119
|
const interruptedRunIds=held.filter(r=>r.interrupted).map(r=>r.id)
|
|
74
120
|
const failedReviewRunIds=held.filter(r=>!r.interrupted).map(r=>r.id)
|
|
75
121
|
const next=s.enabled && !held.length ? await scheduler.pendingOccurrence(s) : null
|
|
76
|
-
return {...s,interruptedRunIds,failedReviewRunIds,nextEligibleAt:next===null ? null : new Date(next).toISOString(),
|
|
122
|
+
return {...s,executionType:executionType(s),preflightReceipt:await scheduler.preflightReceipt(s),interruptedRunIds,failedReviewRunIds,nextEligibleAt:next===null ? null : new Date(next).toISOString(),
|
|
77
123
|
...(held.length ? {recovery:'Inspect the failed run and explicitly edit this schedule to resume; pause/resume does not clear the stop.'} : {})}
|
|
78
124
|
}
|
|
125
|
+
const scripts=new Scripts(config.controlDir)
|
|
79
126
|
let result:unknown
|
|
80
|
-
if(action==='
|
|
127
|
+
if(action==='script'){
|
|
128
|
+
const workspace=process.env.EZ_AGENT_WORKSPACE?.trim() || process.cwd()
|
|
129
|
+
if(id==='list')result=await scripts.list(owner)
|
|
130
|
+
else if(!scriptId)throw new Error('Script ID required')
|
|
131
|
+
else if(id==='show'){
|
|
132
|
+
const registration=await scripts.owned(scriptId,owner)
|
|
133
|
+
let current:string|null=null
|
|
134
|
+
try{current=await fileSha256((await workspaceEntry(workspace,registration.entry)).file)}catch{}
|
|
135
|
+
result={...registration,currentSha256:current,matchesRegistration:current===registration.sha256,
|
|
136
|
+
schedules:(await scheduler.list()).filter(s=>owned(s) && s.script?.id===scriptId).map(s=>s.id)}
|
|
137
|
+
}else if(id==='remove'){
|
|
138
|
+
const users=(await scheduler.list()).filter(s=>owned(s) && s.script?.id===scriptId).map(s=>s.id)
|
|
139
|
+
if(users.length)throw new Error(`Script is used by schedules ${users.join(', ')}; edit or remove them first`)
|
|
140
|
+
await scripts.remove(scriptId,owner);result={removed:scriptId}
|
|
141
|
+
}else if(id==='register' || id==='update'){
|
|
142
|
+
const previous=id==='update' ? await scripts.owned(scriptId,owner) : undefined
|
|
143
|
+
if(id==='register' && (!v.file || !v.interpreter))throw new Error('Register requires --file and --interpreter')
|
|
144
|
+
const timeout=v['timeout-seconds']===undefined ? previous?.timeoutSeconds ?? DEFAULT_SCRIPT_TIMEOUT_SECONDS : Number(v['timeout-seconds'])
|
|
145
|
+
result=await scripts.save({id:scriptId,owner,workspace,entry:v.file ? resolve(v.file) : previous!.entry,interpreter:v.interpreter ?? previous!.interpreter,
|
|
146
|
+
args:v.arg ?? previous?.args ?? [],timeoutSeconds:timeout,pathValue:executorEnvironment().PATH},id==='register')
|
|
147
|
+
}else throw new Error('Unknown script action; use --help')
|
|
148
|
+
}else if(action==='evidence'){
|
|
149
|
+
result=await callDeliverySocket(socketPath,{op:'evidence',payload:{
|
|
150
|
+
...(caller ? {callerRunId:caller.id} : {}), ...(id ? {runId:id} : {}),
|
|
151
|
+
offset:Number(v.offset ?? 0),limit:Number(v.limit ?? 100),expected:v.expected,
|
|
152
|
+
}})
|
|
153
|
+
}else if(action==='context'){
|
|
81
154
|
if(!caller)throw new Error('Context requires an active owner run')
|
|
82
155
|
const origin=caller.scheduled?.originRunId ? await runs.get(caller.scheduled.originRunId) : null
|
|
83
156
|
if(origin && !ownsFailureRun(origin))throw new Error('Source context is outside this owner binding')
|
|
@@ -108,23 +181,37 @@ Cron uses numeric five-field syntax, lists/ranges/steps, and traditional day/wee
|
|
|
108
181
|
if(action==='edit' && (!id || !owned(await scheduler.get(id))))throw new Error('Unknown schedule')
|
|
109
182
|
if(action==='create' && id && (await scheduler.list()).some(s=>s.id===id))throw new Error('Schedule exists; use edit')
|
|
110
183
|
if([v.now,v.at,v.cron,v['every-seconds']].filter(Boolean).length!==1)throw new Error('Choose exactly one trigger')
|
|
111
|
-
if(
|
|
184
|
+
if([v.text,v['text-file'],v.script].filter(Boolean).length!==1)throw new Error('Choose --text, --text-file or --script')
|
|
185
|
+
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')
|
|
186
|
+
if(v.fallback && v['clear-fallbacks'])throw new Error('Choose --fallback or --clear-fallbacks')
|
|
187
|
+
if(v.arg && !v.script)throw new Error('--arg applies to script schedules only')
|
|
112
188
|
const start=v.start || new Date(Date.now()+1000).toISOString()
|
|
113
189
|
const trigger:Trigger=v.now ? {at:new Date(Date.now()+1000).toISOString()} : v.at ? {at:v.at} :
|
|
114
190
|
v.cron ? {cron:v.cron,timezone:v.timezone!,start,until:v.until} : {everySeconds:Number(v['every-seconds']),start,until:v.until}
|
|
191
|
+
if(v['clear-preflight'] && v['preflight-file']) throw Error('Choose one preflight operation')
|
|
115
192
|
const previousSchedule = action === 'edit' ? await scheduler.get(id!) : undefined
|
|
193
|
+
const preflight = v['clear-preflight'] ? undefined : v['preflight-file'] ? JSON.parse(await readFile(v['preflight-file'],'utf8')) : previousSchedule?.preflight
|
|
116
194
|
const origin = caller?.application ?? caller?.delivery
|
|
117
195
|
const delivery = previousSchedule ? previousSchedule.delivery : (origin ? {bindingId:origin.bindingId,scope:origin.scope} : undefined)
|
|
118
196
|
if (!delivery && !owner.telegramChatId) throw new Error('Create the schedule from an authenticated channel turn to bind its reply destination')
|
|
197
|
+
if (v.script) {
|
|
198
|
+
result=await show(await scheduler.save({id:id || 's_'+randomUUID(),name:v.name || v.script,preflight,
|
|
199
|
+
originRunId:previousSchedule?.originRunId ?? caller?.scheduled?.originRunId ?? caller?.id,delivery,text:'',when:v.when as 'unreviewed-failures' | undefined,trigger,enabled:true,owner,
|
|
200
|
+
script:{id:v.script,args:v.arg ?? []}},action==='create'))
|
|
201
|
+
console.log(JSON.stringify(result,null,2));return
|
|
202
|
+
}
|
|
119
203
|
const previous = previousSchedule?.execution
|
|
120
204
|
const state = await control.status()
|
|
121
205
|
const selected = state.ai?.presets.find(p => p.id === state.ai!.selectedId)
|
|
122
206
|
const base = v.cli ? initialPreset(v.cli) : previous?.preset || selected || initialPreset(process.env.EZ_EXECUTOR_CLI || 'codex')
|
|
123
|
-
const preset = executionOverrides(base.cli, base, v.model, v.effort)
|
|
207
|
+
const preset = executionOverrides(base.cli, v['auth-profile'] === undefined ? base : {...base, authProfile:v['auth-profile']}, v.model, v.effort)
|
|
124
208
|
if (!isPreset(preset)) throw new Error('Invalid task AI selection')
|
|
209
|
+
const fallbacks = v['clear-fallbacks'] ? undefined : v.fallback ? v.fallback.map(fallbackSetup) : previous?.fallbacks
|
|
210
|
+
if (preset.authProfile !== undefined) await provisionedHome(config.controlDir, preset.cli, preset.authProfile)
|
|
125
211
|
result=await show(await scheduler.save({id:id || 's_'+randomUUID(),name:v.name || 'Task',
|
|
212
|
+
preflight,
|
|
126
213
|
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,
|
|
127
|
-
execution:{sessionId:previous?.sessionId || randomUUID(),preset}},action==='create'))
|
|
214
|
+
execution:{sessionId:previous?.sessionId || randomUUID(),preset,...(fallbacks ? {fallbacks} : {})}},action==='create'))
|
|
128
215
|
}else{
|
|
129
216
|
if(!id)throw new Error('ID required')
|
|
130
217
|
if(action==='cancel'){
|