@jc_stack/ez-agents 0.1.0-beta.46 → 0.1.0-beta.48
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.dockerignore +2 -0
- package/.env.example +3 -0
- package/AGENTS.md +2 -2
- package/CHANGELOG.md +38 -0
- package/CONTRIBUTING.md +1 -1
- package/Dockerfile +18 -10
- 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 +86 -0
- package/docker/engine-pnpm-workspace.yaml +4 -0
- package/docker/run.ts +1 -1
- package/docker/smoke.mjs +1 -1
- package/docs/application-channel.md +32 -1
- package/docs/architecture/ai-selection.md +26 -5
- package/docs/architecture/authority-boundaries.md +2 -2
- package/docs/development-and-testing.md +1 -1
- package/docs/docker-runtime.md +39 -0
- package/docs/isolated-plugin-update-readiness.md +66 -0
- package/docs/plugin-guide.md +1 -1
- package/docs/plugins.md +1 -1
- package/docs/releasing.md +5 -1
- package/docs/scheduling.md +117 -9
- package/docs/selective-monitoring.md +13 -0
- package/docs/upgrades.md +38 -1
- package/docs/work-preflight.md +9 -0
- package/package.json +4 -3
- package/pnpm-workspace.yaml +7 -0
- package/scripts/smoke-scheduler.ts +4 -1
- package/src/ai.ts +41 -4
- package/src/application-channel.ts +43 -3
- package/src/application-cli.ts +7 -1
- package/src/application-origin.ts +2 -0
- package/src/audio.ts +37 -22
- package/src/client-defaults.ts +3 -2
- package/src/config.ts +9 -0
- package/src/control-state.ts +3 -1
- package/src/delivery-socket-client.d.mts +2 -0
- package/src/delivery-socket-client.mjs +2 -0
- package/src/delivery-socket.ts +38 -1
- package/src/event-sources.ts +2 -2
- package/src/executor.ts +72 -1
- package/src/files.ts +47 -3
- package/src/host-executor.ts +40 -26
- package/src/index.ts +42 -17
- package/src/install-tools.mjs +18 -9
- package/src/menu.ts +4 -2
- package/src/model-policy.ts +4 -0
- package/src/plugin-broker.mjs +47 -4
- package/src/plugins/manager.mjs +11 -4
- package/src/run-evidence.ts +78 -0
- package/src/runs.ts +16 -2
- package/src/schedule-cli.ts +80 -10
- package/src/schedule-preflight.ts +23 -0
- package/src/scheduled-tasks.ts +16 -12
- package/src/scheduler.ts +102 -16
- package/src/scripts.ts +167 -0
- package/src/setup.ts +2 -2
- package/src/task-capability.ts +17 -1
- package/src/task-executor.ts +11 -8
- package/src/task-mcp.ts +20 -8
- package/src/tasks.ts +89 -9
- package/src/updates/runtime.mjs +53 -11
- package/src/updates/supervisor.mjs +23 -13
- package/test/ai.test.ts +85 -5
- package/test/application-channel.test.ts +16 -1
- package/test/audio.test.ts +19 -0
- package/test/channel-owner.test.ts +2 -2
- package/test/config.test.ts +8 -0
- package/test/desktop-bridge.test.ts +2 -1
- package/test/executor.test.ts +37 -0
- package/test/failure.test.ts +4 -4
- package/test/files.test.ts +44 -0
- package/test/host-executor.test.ts +49 -6
- package/test/install-json.test.mjs +21 -0
- package/test/install-tools.test.mjs +6 -5
- package/test/native-tasks.test.ts +1 -1
- package/test/plugin-broker.test.mjs +64 -0
- package/test/plugin-manager.test.mjs +20 -0
- package/test/private-speech-config.test.ts +22 -0
- package/test/run-evidence.test.ts +78 -0
- package/test/schedule-cli.test.ts +47 -3
- package/test/schedule-preflight.test.ts +49 -0
- package/test/scheduled-tasks.test.ts +2 -2
- package/test/scheduler-host.test.ts +21 -3
- package/test/scheduler-relay.test.ts +84 -4
- package/test/scheduler.test.ts +21 -1
- package/test/script-schedules.test.ts +219 -0
- package/test/task-native.test.ts +50 -12
- package/test/tasks.test.ts +97 -4
- package/test/updates.test.mjs +77 -18
package/src/schedule-cli.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
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'
|
|
@@ -9,28 +10,49 @@ import type { RunRecord } from './runs.js'
|
|
|
9
10
|
import { callDeliverySocket, socketPathFor } from './delivery-socket.js'
|
|
10
11
|
import { initialPreset, isPreset } 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'
|
|
13
16
|
import { ownsRun } from './identity.js'
|
|
14
17
|
import { type Trigger } from './schedule-time.js'
|
|
15
18
|
|
|
16
19
|
async function main() {
|
|
17
|
-
const { values:v, positionals:[action='list',id] } = parseArgs({allowPositionals:true,options:{
|
|
20
|
+
const { values:v, positionals:[action='list',id,scriptId] } = parseArgs({allowPositionals:true,options:{
|
|
21
|
+
script:{type:'string'}, arg:{type:'string',multiple:true}, file:{type:'string'}, interpreter:{type:'string'}, 'timeout-seconds':{type:'string'},
|
|
18
22
|
cli:{type:'string'}, model:{type:'string'}, effort:{type:'string'},
|
|
19
|
-
all:{type:'boolean'}, limit:{type:'string'}, when:{type:'string'}, status:{type:'string'}, diagnosis:{type:'string'}, recovery:{type:'string'}, outcome:{type:'string'}, 'failed-at':{type:'string'},
|
|
20
|
-
name:{type:'string'}, text:{type:'string'}, 'text-file':{type:'string'}, at:{type:'string'}, now:{type:'boolean'},
|
|
23
|
+
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
|
+
'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
25
|
cron:{type:'string'}, timezone:{type:'string'}, 'every-seconds':{type:'string'}, start:{type:'string'}, until:{type:'string'}, help:{type:'boolean'},
|
|
22
26
|
}})
|
|
23
27
|
if(v.help){console.log(`ezenciel-agents-schedule list | runs | show ID | pause ID | resume ID | remove ID | cancel RUN_ID
|
|
24
28
|
failures [--all] [--limit N] | run RUN_ID | context
|
|
29
|
+
evidence [RUN_ID] [--offset N --limit N --expected SNAPSHOT_SHA256]
|
|
30
|
+
trigger SCHEDULE_ID --key REQUEST_KEY
|
|
25
31
|
review RUN_ID --failed-at ISO --status resolved|attention --diagnosis TEXT --recovery TEXT --outcome TEXT
|
|
26
|
-
create [ID] | edit ID --name NAME (--text TEXT | --text-file FILE)
|
|
32
|
+
create [ID] | edit ID --name NAME (--text TEXT | --text-file FILE | --script SCRIPT_ID [--arg=VALUE ...])
|
|
27
33
|
--now | --at ISO_WITH_OFFSET | --every-seconds N | --cron 'MIN HOUR DAY MONTH WEEKDAY' --timezone IANA
|
|
28
34
|
[--cli EXECUTOR] [--model MODEL] [--effort <native-effort>]
|
|
35
|
+
[--preflight-file FILE | --clear-preflight]
|
|
29
36
|
[--start ISO_WITH_OFFSET] [--until ISO_WITH_OFFSET] [--when unreviewed-failures]
|
|
37
|
+
script list | show SCRIPT_ID | remove SCRIPT_ID
|
|
38
|
+
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).
|
|
40
|
+
A script registration references an entry point inside the agent workspace and records its SHA-256. Core invokes
|
|
41
|
+
the installed interpreter with the entry point and arguments directly, without a shell or a model. Changed entry-point
|
|
42
|
+
bytes refuse to run until an explicit script update. The hash covers that file only, not imported dependencies.
|
|
43
|
+
Script runs receive EZ_RUN_ID, EZ_SCHEDULE_ID, EZ_SCHEDULE_REVISION, EZ_DUE_AT, EZ_SCRIPT_ID, EZ_SCRIPT_REVISION and
|
|
44
|
+
EZ_SCRIPT_SHA256 plus the normal executor allowlist. Stdout/stderr are bounded diagnostics in run, never owner messages.
|
|
45
|
+
Script runs have a timeout (default ${DEFAULT_SCRIPT_TIMEOUT_SECONDS}s) and are never retried automatically.
|
|
30
46
|
Context reads the current run only.
|
|
47
|
+
Evidence exports sanitized current-owner Telegram operational metadata only.
|
|
48
|
+
Follow nextOffset with --expected; a changed snapshot requires restarting.
|
|
49
|
+
Relay retention is incomplete. Content hashes identify submitted text, not recipient content.
|
|
50
|
+
Native sessions, tool/delegation events and failure/review prose are not exported.
|
|
31
51
|
Failures default to unreviewed owner runs. Review records a diagnosis; it never changes execution status or retries work.
|
|
32
52
|
A conditional review schedule consumes no model run when there are no unreviewed failures.
|
|
33
|
-
New tasks
|
|
53
|
+
New tasks capture the selected engine settings; edit preserves existing settings unless overridden.
|
|
54
|
+
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.
|
|
55
|
+
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.
|
|
34
56
|
Creates a scheduled task. Instructions are text, never shell commands.
|
|
35
57
|
Use --now to run once. Run completion is not delivery proof.
|
|
36
58
|
Edit replaces the full schedule. Pause/remove affect future work; cancel stops a particular run.
|
|
@@ -63,6 +85,10 @@ Cron uses numeric five-field syntax, lists/ranges/steps, and traditional day/wee
|
|
|
63
85
|
if(process.env.EZ_RUN_ID && (!caller || caller.status!=='running' || caller.external || caller.taskId || caller.replyOnly ||
|
|
64
86
|
!ownsRun(owner, caller) ||
|
|
65
87
|
(caller.scheduled && caller.scheduled.pairedAt!==owner.pairedAt)))throw new Error('Scheduling requires an active owner-authorized run')
|
|
88
|
+
// A script occurrence may read state and deliver through bound plugins; it
|
|
89
|
+
// cannot change registrations or schedules, including its own.
|
|
90
|
+
const readOnly=['list','runs','show','run','context','failures','evidence'].includes(action) || (action==='script' && ['list','show'].includes(id ?? ''))
|
|
91
|
+
if(caller?.script && !readOnly)throw new Error('Script runs cannot change scripts or schedules')
|
|
66
92
|
const owned=(s:{owner:typeof owner})=>sameOwner(s.owner,owner)
|
|
67
93
|
const ownsFailureRun=(r:RunRecord | null)=>r && ownsRun(owner,r) && (!r.scheduled || r.scheduled.pairedAt===owner.pairedAt)
|
|
68
94
|
const show=async(s:Awaited<ReturnType<Scheduler['get']>>)=>{
|
|
@@ -70,11 +96,38 @@ Cron uses numeric five-field syntax, lists/ranges/steps, and traditional day/wee
|
|
|
70
96
|
const interruptedRunIds=held.filter(r=>r.interrupted).map(r=>r.id)
|
|
71
97
|
const failedReviewRunIds=held.filter(r=>!r.interrupted).map(r=>r.id)
|
|
72
98
|
const next=s.enabled && !held.length ? await scheduler.pendingOccurrence(s) : null
|
|
73
|
-
return {...s,interruptedRunIds,failedReviewRunIds,nextEligibleAt:next===null ? null : new Date(next).toISOString(),
|
|
99
|
+
return {...s,executionType:executionType(s),preflightReceipt:await scheduler.preflightReceipt(s),interruptedRunIds,failedReviewRunIds,nextEligibleAt:next===null ? null : new Date(next).toISOString(),
|
|
74
100
|
...(held.length ? {recovery:'Inspect the failed run and explicitly edit this schedule to resume; pause/resume does not clear the stop.'} : {})}
|
|
75
101
|
}
|
|
102
|
+
const scripts=new Scripts(config.controlDir)
|
|
76
103
|
let result:unknown
|
|
77
|
-
if(action==='
|
|
104
|
+
if(action==='script'){
|
|
105
|
+
const workspace=process.env.EZ_AGENT_WORKSPACE?.trim() || process.cwd()
|
|
106
|
+
if(id==='list')result=await scripts.list(owner)
|
|
107
|
+
else if(!scriptId)throw new Error('Script ID required')
|
|
108
|
+
else if(id==='show'){
|
|
109
|
+
const registration=await scripts.owned(scriptId,owner)
|
|
110
|
+
let current:string|null=null
|
|
111
|
+
try{current=await fileSha256((await workspaceEntry(workspace,registration.entry)).file)}catch{}
|
|
112
|
+
result={...registration,currentSha256:current,matchesRegistration:current===registration.sha256,
|
|
113
|
+
schedules:(await scheduler.list()).filter(s=>owned(s) && s.script?.id===scriptId).map(s=>s.id)}
|
|
114
|
+
}else if(id==='remove'){
|
|
115
|
+
const users=(await scheduler.list()).filter(s=>owned(s) && s.script?.id===scriptId).map(s=>s.id)
|
|
116
|
+
if(users.length)throw new Error(`Script is used by schedules ${users.join(', ')}; edit or remove them first`)
|
|
117
|
+
await scripts.remove(scriptId,owner);result={removed:scriptId}
|
|
118
|
+
}else if(id==='register' || id==='update'){
|
|
119
|
+
const previous=id==='update' ? await scripts.owned(scriptId,owner) : undefined
|
|
120
|
+
if(id==='register' && (!v.file || !v.interpreter))throw new Error('Register requires --file and --interpreter')
|
|
121
|
+
const timeout=v['timeout-seconds']===undefined ? previous?.timeoutSeconds ?? DEFAULT_SCRIPT_TIMEOUT_SECONDS : Number(v['timeout-seconds'])
|
|
122
|
+
result=await scripts.save({id:scriptId,owner,workspace,entry:v.file ? resolve(v.file) : previous!.entry,interpreter:v.interpreter ?? previous!.interpreter,
|
|
123
|
+
args:v.arg ?? previous?.args ?? [],timeoutSeconds:timeout,pathValue:executorEnvironment().PATH},id==='register')
|
|
124
|
+
}else throw new Error('Unknown script action; use --help')
|
|
125
|
+
}else if(action==='evidence'){
|
|
126
|
+
result=await callDeliverySocket(socketPath,{op:'evidence',payload:{
|
|
127
|
+
...(caller ? {callerRunId:caller.id} : {}), ...(id ? {runId:id} : {}),
|
|
128
|
+
offset:Number(v.offset ?? 0),limit:Number(v.limit ?? 100),expected:v.expected,
|
|
129
|
+
}})
|
|
130
|
+
}else if(action==='context'){
|
|
78
131
|
if(!caller)throw new Error('Context requires an active owner run')
|
|
79
132
|
const origin=caller.scheduled?.originRunId ? await runs.get(caller.scheduled.originRunId) : null
|
|
80
133
|
if(origin && !ownsFailureRun(origin))throw new Error('Source context is outside this owner binding')
|
|
@@ -95,18 +148,34 @@ Cron uses numeric five-field syntax, lists/ranges/steps, and traditional day/wee
|
|
|
95
148
|
}
|
|
96
149
|
}else if(action==='list')result=await Promise.all((await scheduler.list()).filter(owned).map(show))
|
|
97
150
|
else if(action==='runs')result=(await runs.list()).filter(r=>r.scheduled && r.scheduled.pairedAt===owner.pairedAt && ownsRun(owner,r))
|
|
98
|
-
else if(action==='
|
|
151
|
+
else if(action==='trigger'){
|
|
152
|
+
if(!id || !v.key)throw new Error('Trigger requires SCHEDULE_ID and --key REQUEST_KEY')
|
|
153
|
+
if(Object.keys(v).some(key=>key!=='key'))throw new Error('Trigger uses saved task settings; overrides are not allowed')
|
|
154
|
+
const schedule=await scheduler.get(id)
|
|
155
|
+
if(!owned(schedule))throw new Error('Schedule ownership mismatch')
|
|
156
|
+
result=await callDeliverySocket(socketPath,{op:'triggerSchedule',payload:{scheduleId:id,revision:schedule.revision,key:v.key,...(caller ? {callerRunId:caller.id} : {})}})
|
|
157
|
+
}else if(action==='create' || action==='edit'){
|
|
99
158
|
if(action==='edit' && (!id || !owned(await scheduler.get(id))))throw new Error('Unknown schedule')
|
|
100
159
|
if(action==='create' && id && (await scheduler.list()).some(s=>s.id===id))throw new Error('Schedule exists; use edit')
|
|
101
160
|
if([v.now,v.at,v.cron,v['every-seconds']].filter(Boolean).length!==1)throw new Error('Choose exactly one trigger')
|
|
102
|
-
if(
|
|
161
|
+
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 --effort do not apply')
|
|
163
|
+
if(v.arg && !v.script)throw new Error('--arg applies to script schedules only')
|
|
103
164
|
const start=v.start || new Date(Date.now()+1000).toISOString()
|
|
104
165
|
const trigger:Trigger=v.now ? {at:new Date(Date.now()+1000).toISOString()} : v.at ? {at:v.at} :
|
|
105
166
|
v.cron ? {cron:v.cron,timezone:v.timezone!,start,until:v.until} : {everySeconds:Number(v['every-seconds']),start,until:v.until}
|
|
167
|
+
if(v['clear-preflight'] && v['preflight-file']) throw Error('Choose one preflight operation')
|
|
106
168
|
const previousSchedule = action === 'edit' ? await scheduler.get(id!) : undefined
|
|
169
|
+
const preflight = v['clear-preflight'] ? undefined : v['preflight-file'] ? JSON.parse(await readFile(v['preflight-file'],'utf8')) : previousSchedule?.preflight
|
|
107
170
|
const origin = caller?.application ?? caller?.delivery
|
|
108
171
|
const delivery = previousSchedule ? previousSchedule.delivery : (origin ? {bindingId:origin.bindingId,scope:origin.scope} : undefined)
|
|
109
172
|
if (!delivery && !owner.telegramChatId) throw new Error('Create the schedule from an authenticated channel turn to bind its reply destination')
|
|
173
|
+
if (v.script) {
|
|
174
|
+
result=await show(await scheduler.save({id:id || 's_'+randomUUID(),name:v.name || v.script,preflight,
|
|
175
|
+
originRunId:previousSchedule?.originRunId ?? caller?.scheduled?.originRunId ?? caller?.id,delivery,text:'',when:v.when as 'unreviewed-failures' | undefined,trigger,enabled:true,owner,
|
|
176
|
+
script:{id:v.script,args:v.arg ?? []}},action==='create'))
|
|
177
|
+
console.log(JSON.stringify(result,null,2));return
|
|
178
|
+
}
|
|
110
179
|
const previous = previousSchedule?.execution
|
|
111
180
|
const state = await control.status()
|
|
112
181
|
const selected = state.ai?.presets.find(p => p.id === state.ai!.selectedId)
|
|
@@ -114,6 +183,7 @@ Cron uses numeric five-field syntax, lists/ranges/steps, and traditional day/wee
|
|
|
114
183
|
const preset = executionOverrides(base.cli, base, v.model, v.effort)
|
|
115
184
|
if (!isPreset(preset)) throw new Error('Invalid task AI selection')
|
|
116
185
|
result=await show(await scheduler.save({id:id || 's_'+randomUUID(),name:v.name || 'Task',
|
|
186
|
+
preflight,
|
|
117
187
|
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,
|
|
118
188
|
execution:{sessionId:previous?.sessionId || randomUUID(),preset}},action==='create'))
|
|
119
189
|
}else{
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto'
|
|
2
|
+
import { callLocalSocket } from './delivery-socket-client.mjs'
|
|
3
|
+
|
|
4
|
+
export type Preflight = {on: 'eligible' | 'changed'; checks: {alias: string; args: string[]}[]}
|
|
5
|
+
export type PreflightReceipt = {eligible: boolean; fingerprint: string; observedAt: string; count: number}
|
|
6
|
+
export function validPreflight(v: unknown): v is Preflight {
|
|
7
|
+
const p=v as Preflight
|
|
8
|
+
return Boolean(p && !Array.isArray(p) && Object.keys(p).every(k=>['on','checks'].includes(k)) && ['eligible','changed'].includes(p.on) &&
|
|
9
|
+
Array.isArray(p.checks) && p.checks.length>0 && p.checks.length<=8 && p.checks.every(c=>c && Object.keys(c).every(k=>['alias','args'].includes(k)) &&
|
|
10
|
+
/^[a-z][a-z0-9-]{0,39}$/.test(c.alias) && Array.isArray(c.args) && c.args.length<=100 && c.args.every(a=>typeof a==='string' && !/[\0\r\n]/.test(a) && Buffer.byteLength(a)<=8192)))
|
|
11
|
+
}
|
|
12
|
+
export function combinePreflight(results: unknown[]): PreflightReceipt {
|
|
13
|
+
const values=results as (PreflightReceipt & {schemaVersion:number})[]
|
|
14
|
+
if(!values.length || values.some(v=>!v || v.schemaVersion!==1 || typeof v.eligible!=='boolean' || !/^[a-f0-9]{64}$/.test(v.fingerprint) ||
|
|
15
|
+
!Number.isSafeInteger(v.count) || v.count<0 || typeof v.observedAt!=='string' || !Number.isFinite(Date.parse(v.observedAt)) || Date.parse(v.observedAt)>Date.now()+1000 || Date.now()-Date.parse(v.observedAt)>300000)) throw Error('Invalid or stale plugin preflight result')
|
|
16
|
+
return {eligible:values.some(v=>v.eligible),fingerprint:createHash('sha256').update(JSON.stringify(values.map(v=>v.fingerprint))).digest('hex'),
|
|
17
|
+
observedAt:new Date().toISOString(),count:values.reduce((n,v)=>n+v.count,0)}
|
|
18
|
+
}
|
|
19
|
+
export async function installedPreflight(scheduleId:string, revision:string): Promise<PreflightReceipt> {
|
|
20
|
+
const socket=process.env.EZ_PLUGIN_BROKER_SOCKET
|
|
21
|
+
if(!socket) throw Error('Scheduled preflight requires the bound plugin broker')
|
|
22
|
+
return await callLocalSocket(socket,{version:1,id:randomUUID(),operation:'preflight',scheduleId,revision},120000) as PreflightReceipt
|
|
23
|
+
}
|
package/src/scheduled-tasks.ts
CHANGED
|
@@ -11,11 +11,16 @@ const ownsSchedule = (owner: Owner, schedule: Schedule) =>
|
|
|
11
11
|
// Saved schedules created under an earlier model policy remain inspectable. The
|
|
12
12
|
// executor will apply the same defaults (and enforce its current policy) when
|
|
13
13
|
// it starts the run; an outdated saved selection must not hide every menu row.
|
|
14
|
-
const displayedPreset = (
|
|
15
|
-
try { return executionDefaults(
|
|
16
|
-
catch { return
|
|
14
|
+
const displayedPreset = (execution: NonNullable<Schedule['execution']>) => {
|
|
15
|
+
try { return executionDefaults(execution.preset.cli, execution.preset) }
|
|
16
|
+
catch { return execution.preset }
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
// Script schedules have no AI preset; show what core actually invokes.
|
|
20
|
+
const engineLabel = (schedule: ActiveSchedule) => schedule.script
|
|
21
|
+
? `Script · ${schedule.script.id} · ${schedule.scriptRevision ? `rev ${schedule.scriptRevision.slice(0, 8)}` : 'not registered'}`
|
|
22
|
+
: presetLabel(displayedPreset(schedule.execution!))
|
|
23
|
+
|
|
19
24
|
const weekdays = ['Sun','Mon','Tue','Wed','Thu','Fri','Sat']
|
|
20
25
|
const months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']
|
|
21
26
|
const time = (value: number | string) => {
|
|
@@ -25,6 +30,7 @@ const time = (value: number | string) => {
|
|
|
25
30
|
}
|
|
26
31
|
|
|
27
32
|
const preview = (schedule: Schedule) => {
|
|
33
|
+
if (schedule.script) return schedule.script.args.length ? `Arguments: ${schedule.script.args.join(' ')}`.slice(0, 140) : 'No saved arguments'
|
|
28
34
|
const sentence = schedule.text.trim().replace(/\s+/gu,' ').split(/(?<=[.!?])\s/u)[0]
|
|
29
35
|
const chars = Array.from(sentence)
|
|
30
36
|
return chars.length > 140 ? chars.slice(0,139).join('')+'…' : sentence
|
|
@@ -46,15 +52,13 @@ export const ownedScheduledTasks = (schedules: ActiveSchedule[], owner: Owner) =
|
|
|
46
52
|
export const scheduledTasksText = (schedules: ActiveSchedule[], owner: Owner) => {
|
|
47
53
|
const owned = ownedScheduledTasks(schedules, owner)
|
|
48
54
|
if (!owned.length) return '📅 Scheduled tasks\n\nNo active scheduled tasks.'
|
|
49
|
-
return [`📅 Scheduled tasks · ${owned.length} active`, 'All times UTC. Use /tasks 1 for task details.', ...owned.map((schedule, index) =>
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
})].join('\n\n')
|
|
55
|
+
return [`📅 Scheduled tasks · ${owned.length} active`, 'All times UTC. Use /tasks 1 for task details.', ...owned.map((schedule, index) =>
|
|
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)}`
|
|
57
|
+
)].join('\n\n')
|
|
53
58
|
}
|
|
54
59
|
|
|
55
60
|
export const scheduledTaskDetailText = (schedule: ActiveSchedule, number: number, total?: number) => {
|
|
56
|
-
const
|
|
57
|
-
const instructions = schedule.text.trim()
|
|
61
|
+
const instructions = schedule.script ? preview(schedule) : schedule.text.trim()
|
|
58
62
|
const capped = instructions.length > 2000 ? instructions.slice(0, 2000) + '… (truncated; full text lives in the workspace)' : instructions
|
|
59
63
|
return [
|
|
60
64
|
`📅 ${number}${total === undefined ? '' : ` of ${total}`} · ${schedule.name}`,
|
|
@@ -66,10 +70,10 @@ export const scheduledTaskDetailText = (schedule: ActiveSchedule, number: number
|
|
|
66
70
|
'Last run',
|
|
67
71
|
lastRun(schedule),
|
|
68
72
|
'',
|
|
69
|
-
'Engine',
|
|
70
|
-
|
|
73
|
+
schedule.script ? 'Execution' : 'Engine',
|
|
74
|
+
engineLabel(schedule),
|
|
71
75
|
'',
|
|
72
|
-
'Instructions',
|
|
76
|
+
schedule.script ? 'Script arguments' : 'Instructions',
|
|
73
77
|
capped,
|
|
74
78
|
].join('\n')
|
|
75
79
|
}
|
package/src/scheduler.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { validPreflight, installedPreflight, type Preflight, type PreflightReceipt } from './schedule-preflight.js'
|
|
2
|
+
import { assertEffort, assertScheduledModel } from './model-policy.js'
|
|
2
3
|
import { needsFailureReview } from './failure.js'
|
|
3
4
|
import { mkdir, readFile, readdir, writeFile, rename, link, rm } from 'node:fs/promises'
|
|
4
5
|
import { randomUUID, createHash } from 'node:crypto'
|
|
@@ -10,12 +11,17 @@ import { assertId, ownsRun } from './identity.js'
|
|
|
10
11
|
import { type ExecutionChoice, isExecutionChoice, persistedPreset } from './ai.js'
|
|
11
12
|
import { type Trigger, validateTrigger, nextOccurrence } from './schedule-time.js'
|
|
12
13
|
import { RunStore, type RunRecord } from './runs.js'
|
|
14
|
+
import { Scripts, assertScriptArgs, assertScriptId, type ScriptRunRef } from './scripts.js'
|
|
13
15
|
|
|
14
16
|
export type Schedule = {
|
|
15
17
|
originRunId?: string
|
|
16
18
|
when?: 'unreviewed-failures'
|
|
19
|
+
preflight?: Preflight
|
|
17
20
|
version: 1; id: string; revision: string; name: string; text: string; trigger: Trigger; enabled: boolean
|
|
18
|
-
owner: Owner
|
|
21
|
+
owner: Owner
|
|
22
|
+
// Exactly one execution type: an agent turn (execution) or a registered script.
|
|
23
|
+
execution?: ExecutionChoice
|
|
24
|
+
script?: { id: string; args: string[] }
|
|
19
25
|
delivery?: { bindingId: string; scope: string }
|
|
20
26
|
}
|
|
21
27
|
export type ScheduledRunSummary = {
|
|
@@ -23,13 +29,24 @@ export type ScheduledRunSummary = {
|
|
|
23
29
|
at: string
|
|
24
30
|
currentRevision: boolean
|
|
25
31
|
}
|
|
26
|
-
export type ActiveSchedule = Schedule & { nextAt: number | null; runState?: 'queued' | 'running'; lastRun?: ScheduledRunSummary }
|
|
32
|
+
export type ActiveSchedule = Schedule & { nextAt: number | null; runState?: 'queued' | 'running'; lastRun?: ScheduledRunSummary; scriptRevision?: string }
|
|
27
33
|
export type ScheduledOrigin = { id: string; revision: string; dueAt: string; pairedAt: string; originRunId?: string }
|
|
28
34
|
export const validScheduledOrigin = (v: unknown): v is ScheduledOrigin => {
|
|
29
35
|
const s = v as ScheduledOrigin
|
|
30
36
|
return Boolean(s && /^[a-zA-Z0-9_-]+$/.test(s.id) && /^[a-zA-Z0-9_-]+$/.test(s.revision) &&
|
|
31
37
|
Number.isFinite(Date.parse(s.dueAt)) && typeof s.pairedAt === 'string' && (s.originRunId === undefined || /^[a-zA-Z0-9_-]+$/.test(s.originRunId)))
|
|
32
38
|
}
|
|
39
|
+
export const executionType = (s: Pick<Schedule,'script'>): 'agent' | 'script' => s.script ? 'script' : 'agent'
|
|
40
|
+
const validExecution = (s: Pick<Schedule,'execution'|'script'|'text'>) => s.script
|
|
41
|
+
? s.execution === undefined && (() => { try { assertScriptId(s.script!.id); assertScriptArgs(s.script!.args); return true } catch { return false } })()
|
|
42
|
+
: isExecutionChoice(s.execution) && typeof s.text === 'string' && Boolean(s.text.trim())
|
|
43
|
+
// The run carries the registration revision/hash current when it was queued.
|
|
44
|
+
const scriptRef = async (controlDir: string, s: Schedule, owner: Owner): Promise<ScriptRunRef> => {
|
|
45
|
+
const ref: ScriptRunRef = { id: s.script!.id, args: s.script!.args }
|
|
46
|
+
try { const r = await new Scripts(controlDir).owned(s.script!.id, owner); return { ...ref, revision: r.revision, sha256: r.sha256 } }
|
|
47
|
+
catch { return ref }
|
|
48
|
+
}
|
|
49
|
+
const runTexts = (s: Schedule, dueAt: string) => s.script ? [`[schedule ${s.id} due ${dueAt}] script ${s.script.id}`] : [`[schedule ${s.id} due ${dueAt}]`, s.text]
|
|
33
50
|
export const scheduledRunId = (s: Schedule, due: number) => 'r_schedule_' + createHash('sha256')
|
|
34
51
|
.update(JSON.stringify([s.id,s.revision,due])).digest('hex')
|
|
35
52
|
export const holdsSchedule = (s: Schedule, r: RunRecord): boolean =>
|
|
@@ -45,13 +62,13 @@ const atomic = async (file: string, value: unknown, exclusive = false) => {
|
|
|
45
62
|
}
|
|
46
63
|
export class Scheduler {
|
|
47
64
|
private dir: string
|
|
48
|
-
constructor(private controlDir: string) { this.dir = join(controlDir,'schedules') }
|
|
65
|
+
constructor(private controlDir: string, private checkPreflight = installedPreflight) { this.dir = join(controlDir,'schedules') }
|
|
49
66
|
private async ensure() { await mkdir(this.dir,{recursive:true,mode:0o700}) }
|
|
50
67
|
async get(id: string): Promise<Schedule> {
|
|
51
68
|
const s = JSON.parse(await readFile(join(this.dir,assertId(id)+'.json'),'utf8')) as Schedule
|
|
52
69
|
if (s.version !== 1 || s.id !== id || !validScheduledOrigin({id:s.id,revision:s.revision,dueAt:new Date().toISOString(),pairedAt:s.owner?.pairedAt,originRunId:s.originRunId}) ||
|
|
53
|
-
(s.when !== undefined && s.when !== 'unreviewed-failures') || typeof s.enabled !== 'boolean' || !s.name || typeof s.text !== 'string' ||
|
|
54
|
-
!validOwner(s.owner) || !
|
|
70
|
+
(s.when !== undefined && s.when !== 'unreviewed-failures') || (s.preflight !== undefined && !validPreflight(s.preflight)) || typeof s.enabled !== 'boolean' || !s.name || typeof s.text !== 'string' ||
|
|
71
|
+
!validOwner(s.owner) || !validExecution(s) ||
|
|
55
72
|
(s.delivery !== undefined && !validApplicationOrigin({...s.delivery, requestId: s.id})))
|
|
56
73
|
throw new Error('Invalid schedule record')
|
|
57
74
|
validateTrigger(s.trigger)
|
|
@@ -99,7 +116,8 @@ export class Scheduler {
|
|
|
99
116
|
if (!runState && current.some(r => holdsSchedule(s,r))) continue
|
|
100
117
|
try {
|
|
101
118
|
const nextAt = await this.pendingOccurrence(s)
|
|
102
|
-
|
|
119
|
+
const scriptRevision = s.script ? await new Scripts(this.controlDir).get(s.script.id).then(r => r.revision, () => undefined) : undefined
|
|
120
|
+
if (runState || nextAt !== null) active.push({...s,nextAt,runState,...(scriptRevision ? {scriptRevision} : {}),...(last ? {
|
|
103
121
|
lastRun: {status:last.status,at:last.endedAt ?? last.startedAt ?? last.createdAt,currentRevision:last.scheduled!.revision===s.revision},
|
|
104
122
|
} : {})})
|
|
105
123
|
}
|
|
@@ -110,10 +128,21 @@ export class Scheduler {
|
|
|
110
128
|
async save(input: Omit<Schedule,'version'|'revision'>, exclusive = false): Promise<Schedule> {
|
|
111
129
|
await this.ensure(); assertId(input.id)
|
|
112
130
|
if (input.when !== undefined && input.when !== 'unreviewed-failures') throw new Error('Unknown schedule condition')
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
131
|
+
if(input.preflight !== undefined && !validPreflight(input.preflight)) throw Error('Invalid schedule preflight')
|
|
132
|
+
let s: Schedule
|
|
133
|
+
if (input.script) {
|
|
134
|
+
if (input.execution !== undefined || !input.name) throw new Error('Script schedules need a name and no AI selection')
|
|
135
|
+
const script = {id:assertScriptId(input.script.id),args:assertScriptArgs(input.script.args)}
|
|
136
|
+
await new Scripts(this.controlDir).owned(script.id, input.owner)
|
|
137
|
+
s = {...input, text:'', script, trigger:validateTrigger(input.trigger),version:1,revision:randomUUID()}
|
|
138
|
+
} else {
|
|
139
|
+
if (!input.execution) throw new Error('Schedule needs name, text and an AI selection')
|
|
140
|
+
const execution: ExecutionChoice = {...input.execution, preset: persistedPreset(input.execution.preset)}
|
|
141
|
+
if (!input.name || !input.text?.trim() || !isExecutionChoice(execution)) throw new Error('Schedule needs name, text and an AI selection')
|
|
142
|
+
assertScheduledModel(execution.preset.model)
|
|
143
|
+
assertEffort(execution.preset.effort, execution.preset.model, execution.preset.cli)
|
|
144
|
+
s = {...input, execution, trigger:validateTrigger(input.trigger),version:1,revision:randomUUID()}
|
|
145
|
+
}
|
|
117
146
|
const now = Date.now()
|
|
118
147
|
let next = nextOccurrence(s.trigger,now-1)
|
|
119
148
|
if (next === null) throw new Error('Schedule has no future occurrence within eight years')
|
|
@@ -128,12 +157,26 @@ export class Scheduler {
|
|
|
128
157
|
}
|
|
129
158
|
// Publish the cursor first so a crash cannot expose a new revision with
|
|
130
159
|
// a missing cursor that falls back to the trigger's historical start.
|
|
131
|
-
await
|
|
160
|
+
const prior=await this.preflightReceipt(previous)
|
|
161
|
+
await atomic(join(this.dir,`${s.id}.${s.revision}.cursor`),{next,...(JSON.stringify(previous.preflight)===JSON.stringify(s.preflight) && prior ? {preflight:prior} : {})})
|
|
132
162
|
}
|
|
133
163
|
}
|
|
134
164
|
await atomic(join(this.dir,s.id+'.json'),s,exclusive)
|
|
135
165
|
return s
|
|
136
166
|
}
|
|
167
|
+
async preflight(id:string, revision:string) {
|
|
168
|
+
const s=await this.get(id)
|
|
169
|
+
if(s.revision!==revision || !s.enabled || !s.preflight) throw Error('Schedule preflight is unavailable')
|
|
170
|
+
const receipt=await this.checkPreflight(id,revision)
|
|
171
|
+
const cursor=join(this.dir,`${s.id}.${s.revision}.cursor`)
|
|
172
|
+
const previous=await readFile(cursor,'utf8').then(v=>JSON.parse(v)).catch(async e=>{if(e.code==='ENOENT')return {next:await this.pendingOccurrence(s)};throw e})
|
|
173
|
+
const admitted=receipt.eligible && (s.preflight.on!=='changed' || previous.preflight?.fingerprint!==receipt.fingerprint)
|
|
174
|
+
return {receipt,admitted,previous,cursor}
|
|
175
|
+
}
|
|
176
|
+
async preflightReceipt(s:Schedule) {
|
|
177
|
+
try {return JSON.parse(await readFile(join(this.dir,`${s.id}.${s.revision}.cursor`),'utf8')).preflight}
|
|
178
|
+
catch(e) {if((e as NodeJS.ErrnoException).code==='ENOENT')return undefined;throw e}
|
|
179
|
+
}
|
|
137
180
|
async enable(id: string, enabled: boolean): Promise<Schedule> {
|
|
138
181
|
const s = await this.get(id)
|
|
139
182
|
// Pausing preserves the cursor. Resuming coalesces missed recurrences like restart.
|
|
@@ -159,6 +202,39 @@ export class Scheduler {
|
|
|
159
202
|
try { await readFile(join(this.dir,assertId(runId)+'.cancel')); return true }
|
|
160
203
|
catch (e) { if ((e as NodeJS.ErrnoException).code === 'ENOENT') return false; throw e }
|
|
161
204
|
}
|
|
205
|
+
async trigger(id: string, revision: string, key: string, owner: Owner, runs: RunStore): Promise<RunRecord> {
|
|
206
|
+
const s=await this.get(id)
|
|
207
|
+
if (!sameOwner(s.owner,owner) || !s.enabled || s.revision !== revision) throw new Error('Schedule is disabled, changed or outside this owner binding')
|
|
208
|
+
if (s.script) await new Scripts(this.controlDir).owned(s.script.id, owner)
|
|
209
|
+
else assertScheduledModel(s.execution!.preset.model)
|
|
210
|
+
if (s.delivery) {
|
|
211
|
+
const binding=(await new ApplicationBindings(this.controlDir).list()).find(b=>b.bindingId===s.delivery!.bindingId)
|
|
212
|
+
if (!binding || !sameOwner(binding.owner,owner)) throw new Error('Schedule delivery binding is unavailable')
|
|
213
|
+
} else if (!owner.telegramChatId || owner.telegramChatId !== s.owner.telegramChatId || owner.telegramUserId !== s.owner.telegramUserId || (owner.telegramLinkedAt ?? owner.pairedAt) !== (s.owner.telegramLinkedAt ?? s.owner.pairedAt)) throw new Error('Schedule Telegram binding is unavailable')
|
|
214
|
+
const runId='r_schedule_manual_'+createHash('sha256').update(JSON.stringify([s.id,ownerId(owner),ownerEpoch(owner),assertId(key)])).digest('hex')
|
|
215
|
+
const existing=await runs.get(runId)
|
|
216
|
+
if (existing) {
|
|
217
|
+
if (!ownsRun(owner,existing) || existing.scheduled?.revision !== s.revision) throw new Error('Request key belongs to a different task revision or owner')
|
|
218
|
+
return existing
|
|
219
|
+
}
|
|
220
|
+
const all=await runs.list()
|
|
221
|
+
if (all.some(r=>holdsSchedule(s,r))) throw new Error('Inspect the held occurrence and edit the schedule before retrying')
|
|
222
|
+
if (s.when === 'unreviewed-failures' && !all.some(r=>needsFailureReview(r) && ownsRun(owner,r) && (!r.scheduled || r.scheduled.pairedAt===owner.pairedAt))) throw new Error('Schedule condition is not met')
|
|
223
|
+
let checked: Awaited<ReturnType<Scheduler['preflight']>> | undefined
|
|
224
|
+
if(s.preflight) {
|
|
225
|
+
checked=await this.preflight(s.id,s.revision)
|
|
226
|
+
if(!checked.receipt.eligible) throw Error('Schedule preflight has no eligible work')
|
|
227
|
+
const current=await this.get(s.id)
|
|
228
|
+
if(!current.enabled || current.revision!==s.revision) throw Error('Schedule changed during preflight')
|
|
229
|
+
}
|
|
230
|
+
const dueAt=new Date().toISOString()
|
|
231
|
+
const run=await runs.create({id:runId,ownerId:ownerId(owner),ownerEpoch:ownerEpoch(owner),
|
|
232
|
+
...(s.delivery ? {delivery:s.delivery} : {chatId:s.owner.telegramChatId,telegramUserId:s.owner.telegramUserId,telegramEpoch:s.owner.telegramLinkedAt ?? s.owner.pairedAt}),
|
|
233
|
+
texts:runTexts(s,dueAt),...(s.script ? {script:await scriptRef(this.controlDir,s,owner)} : {execution:{...s.execution!,sessionId:randomUUID()}}),
|
|
234
|
+
scheduled:{id:s.id,revision:s.revision,dueAt,pairedAt:s.owner.pairedAt,...(s.originRunId ? {originRunId:s.originRunId} : {})}},true)
|
|
235
|
+
if(checked) await atomic(checked.cursor,{...checked.previous,preflight:checked.receipt})
|
|
236
|
+
return run
|
|
237
|
+
}
|
|
162
238
|
async recover(runs: RunStore) {
|
|
163
239
|
for (const run of await runs.list()) {
|
|
164
240
|
if (!run.scheduled || run.status !== 'running') continue
|
|
@@ -180,6 +256,7 @@ export class Scheduler {
|
|
|
180
256
|
try {
|
|
181
257
|
const next = await this.pendingOccurrence(s)
|
|
182
258
|
if (next === null || next > now) continue
|
|
259
|
+
if (!s.script) assertScheduledModel(s.execution!.preset.model)
|
|
183
260
|
// One occurrence at a time. A failed reviewer stops this revision just like
|
|
184
261
|
// interrupted work: retain its receipt until an explicit schedule edit.
|
|
185
262
|
if ((await runs.list()).some(r => r.scheduled?.id === s.id &&
|
|
@@ -188,15 +265,24 @@ export class Scheduler {
|
|
|
188
265
|
if (s.when === 'unreviewed-failures' && !(await runs.list()).some(r => needsFailureReview(r) && ownsRun(owner, r) && (!r.scheduled || r.scheduled.pairedAt === owner.pairedAt))) {
|
|
189
266
|
await atomic(cursor,{next:future}); continue
|
|
190
267
|
}
|
|
268
|
+
let preflight: PreflightReceipt | undefined
|
|
269
|
+
if(s.preflight) {
|
|
270
|
+
try {
|
|
271
|
+
const checked=await this.preflight(s.id,s.revision)
|
|
272
|
+
preflight=checked.receipt
|
|
273
|
+
if(!checked.admitted) {await atomic(cursor,{next:future,preflight});continue}
|
|
274
|
+
const current=await this.get(s.id);if(!current.enabled || current.revision!==s.revision)continue
|
|
275
|
+
} catch {const prior=await readFile(cursor,'utf8').then(v=>JSON.parse(v)).catch(e=>{if(e.code==='ENOENT')return {};throw e});await atomic(cursor,{next:future,preflight:{...prior.preflight,state:'unavailable',observedAt:new Date().toISOString()}});continue}
|
|
276
|
+
}
|
|
191
277
|
const dueAt = new Date(next).toISOString()
|
|
192
278
|
await runs.create({id:scheduledRunId(s,next),
|
|
193
279
|
ownerId:ownerId(owner),ownerEpoch:ownerEpoch(owner),
|
|
194
280
|
...(s.delivery ? {delivery:s.delivery} : {chatId:s.owner.telegramChatId,telegramUserId:s.owner.telegramUserId,telegramEpoch:s.owner.telegramLinkedAt ?? s.owner.pairedAt}),
|
|
195
|
-
texts:
|
|
196
|
-
scheduled:{id:s.id,revision:s.revision,dueAt,pairedAt:s.owner.pairedAt,...(s.originRunId?{originRunId:s.originRunId}:{})}})
|
|
281
|
+
texts:runTexts(s,dueAt),...(s.script ? {script:await scriptRef(this.controlDir,s,owner)} : {execution:s.execution}),
|
|
282
|
+
scheduled:{id:s.id,revision:s.revision,dueAt,pairedAt:s.owner.pairedAt,...(s.originRunId?{originRunId:s.originRunId}:{})}},true)
|
|
197
283
|
// A restart between run creation and this cursor write sees the same occurrence ID.
|
|
198
|
-
await atomic(cursor,{next:future})
|
|
199
|
-
} catch { console.error('Schedule dispatch failed',s.id) }
|
|
284
|
+
await atomic(cursor,{next:future,...(preflight ? {preflight} : {})})
|
|
285
|
+
} catch (error) { console.error('Schedule dispatch failed',s.id,error instanceof Error ? error.message : 'Unknown error') }
|
|
200
286
|
}
|
|
201
287
|
}
|
|
202
288
|
}
|
package/src/scripts.ts
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { mkdir, readFile, readdir, writeFile, rename, link, rm, realpath, stat, access } from 'node:fs/promises'
|
|
2
|
+
import { constants as fsConstants } from 'node:fs'
|
|
3
|
+
import { randomUUID, createHash } from 'node:crypto'
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
import { type Owner, sameOwner, validOwner } from './control-state.js'
|
|
6
|
+
import { assertId } from './identity.js'
|
|
7
|
+
|
|
8
|
+
// Registered scripts reference agent-owned code in the workspace. Core keeps
|
|
9
|
+
// only the registration: no second source copy, interpreter or dependency setup.
|
|
10
|
+
export type ScriptRegistration = {
|
|
11
|
+
version: 1; id: string; revision: string; owner: Owner
|
|
12
|
+
entry: string; interpreter: string; args: string[]; sha256: string
|
|
13
|
+
timeoutSeconds: number; registeredAt: string; updatedAt: string
|
|
14
|
+
}
|
|
15
|
+
// Captured on the run when an occurrence is queued; the launcher verifies it.
|
|
16
|
+
export type ScriptRunRef = { id: string; revision?: string; sha256?: string; args: string[] }
|
|
17
|
+
|
|
18
|
+
export const DEFAULT_SCRIPT_TIMEOUT_SECONDS = 900
|
|
19
|
+
export const MAX_SCRIPT_TIMEOUT_SECONDS = 21600
|
|
20
|
+
const SCRIPT_ID = /^[a-z0-9][a-z0-9_-]{0,63}$/
|
|
21
|
+
const COMMAND = /^[A-Za-z0-9][A-Za-z0-9._+-]{0,63}$/
|
|
22
|
+
|
|
23
|
+
export const assertScriptId = (id: unknown): string => {
|
|
24
|
+
if (typeof id !== 'string' || !SCRIPT_ID.test(id)) throw new Error('Script ID must be 1-64 lowercase letters, digits, _ or -')
|
|
25
|
+
return id
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const assertScriptArgs = (args: unknown): string[] => {
|
|
29
|
+
if (!Array.isArray(args) || args.length > 64 || args.some(arg => typeof arg !== 'string' || arg.length > 4096 || arg.includes('\0')))
|
|
30
|
+
throw new Error('Script arguments must be at most 64 literal strings')
|
|
31
|
+
return args as string[]
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// An installed interpreter is a bare command resolved on the executor PATH or an
|
|
35
|
+
// absolute path. Anything with whitespace or shell syntax is a command string.
|
|
36
|
+
export const assertInterpreter = (value: unknown): string => {
|
|
37
|
+
if (typeof value !== 'string' || !(COMMAND.test(value) || (path.isAbsolute(value) && !/[\s\0;&|`$<>()'"\\*?]/.test(value))))
|
|
38
|
+
throw new Error('Interpreter must be an installed command name or absolute path, not a shell command')
|
|
39
|
+
return value
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export const assertTimeout = (value: unknown): number => {
|
|
43
|
+
if (!Number.isSafeInteger(value) || (value as number) < 1 || (value as number) > MAX_SCRIPT_TIMEOUT_SECONDS)
|
|
44
|
+
throw new Error(`Script timeout must be 1..${MAX_SCRIPT_TIMEOUT_SECONDS} seconds`)
|
|
45
|
+
return value as number
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export const validScriptRunRef = (value: unknown): value is ScriptRunRef => {
|
|
49
|
+
const ref = value as ScriptRunRef
|
|
50
|
+
try {
|
|
51
|
+
return Boolean(ref) && assertScriptId(ref.id) === ref.id && assertScriptArgs(ref.args) === ref.args &&
|
|
52
|
+
(ref.revision === undefined || (typeof ref.revision === 'string' && /^[a-zA-Z0-9_-]+$/.test(ref.revision))) &&
|
|
53
|
+
(ref.sha256 === undefined || (typeof ref.sha256 === 'string' && /^[a-f0-9]{64}$/.test(ref.sha256)))
|
|
54
|
+
} catch { return false }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// The interpreter must be an installed runtime, not unhashed workspace code.
|
|
58
|
+
export const resolveInterpreter = async (interpreter: string, pathValue = process.env.PATH, workspace?: string): Promise<string> => {
|
|
59
|
+
assertInterpreter(interpreter)
|
|
60
|
+
const candidates = path.isAbsolute(interpreter) ? [interpreter]
|
|
61
|
+
// Relative PATH entries would resolve against a different cwd at spawn time.
|
|
62
|
+
: (pathValue ?? '').split(path.delimiter).filter(directory => path.isAbsolute(directory)).map(directory => path.join(directory, interpreter))
|
|
63
|
+
for (const candidate of candidates) {
|
|
64
|
+
let real: string
|
|
65
|
+
try { await access(candidate, fsConstants.X_OK); if (!(await stat(candidate)).isFile()) continue; real = await realpath(candidate) } catch { continue }
|
|
66
|
+
if (workspace && real.startsWith(await realpath(workspace) + path.sep)) throw new Error('Interpreter must be an installed runtime outside the agent workspace')
|
|
67
|
+
return candidate
|
|
68
|
+
}
|
|
69
|
+
throw new Error(`Interpreter ${interpreter} is not installed on the executor PATH`)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Resolve a workspace-relative entry point to its real file inside the workspace.
|
|
73
|
+
export const workspaceEntry = async (workspace: string, entry: string): Promise<{ file: string; relative: string }> => {
|
|
74
|
+
if (typeof entry !== 'string' || !entry || entry.includes('\0')) throw new Error('Script entry point is required')
|
|
75
|
+
const root = await realpath(workspace)
|
|
76
|
+
let file: string
|
|
77
|
+
try { file = await realpath(path.resolve(root, entry)) }
|
|
78
|
+
catch { throw new Error('Script entry point does not exist in the agent workspace') }
|
|
79
|
+
if (!file.startsWith(root + path.sep) || !(await stat(file)).isFile()) throw new Error('Script entry point must be a file inside the agent workspace')
|
|
80
|
+
return { file, relative: path.relative(root, file) }
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export const fileSha256 = async (file: string) => createHash('sha256').update(await readFile(file)).digest('hex')
|
|
84
|
+
|
|
85
|
+
const atomic = async (file: string, value: unknown, exclusive = false) => {
|
|
86
|
+
const tmp = `${file}.${randomUUID()}.tmp`
|
|
87
|
+
try {
|
|
88
|
+
await writeFile(tmp, JSON.stringify(value) + '\n', { mode: 0o600, flag: 'wx' })
|
|
89
|
+
if (exclusive) await link(tmp, file)
|
|
90
|
+
else await rename(tmp, file)
|
|
91
|
+
} finally { await rm(tmp, { force: true }) }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export class Scripts {
|
|
95
|
+
private dir: string
|
|
96
|
+
constructor(controlDir: string) { this.dir = path.join(controlDir, 'scripts') }
|
|
97
|
+
|
|
98
|
+
async get(id: string): Promise<ScriptRegistration> {
|
|
99
|
+
const s = JSON.parse(await readFile(path.join(this.dir, assertId(assertScriptId(id)) + '.json'), 'utf8')) as ScriptRegistration
|
|
100
|
+
if (s.version !== 1 || s.id !== id || typeof s.revision !== 'string' || !/^[a-zA-Z0-9_-]+$/.test(s.revision) || !validOwner(s.owner) ||
|
|
101
|
+
typeof s.entry !== 'string' || !s.entry || path.isAbsolute(s.entry) || s.entry.split(/[\\/]/).includes('..') ||
|
|
102
|
+
!/^[a-f0-9]{64}$/.test(s.sha256)) throw new Error('Invalid script registration')
|
|
103
|
+
assertInterpreter(s.interpreter); assertScriptArgs(s.args); assertTimeout(s.timeoutSeconds)
|
|
104
|
+
return s
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async owned(id: string, owner: Owner): Promise<ScriptRegistration> {
|
|
108
|
+
let s: ScriptRegistration
|
|
109
|
+
try { s = await this.get(id) }
|
|
110
|
+
catch (error) {
|
|
111
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') throw new Error(`Script ${id} is not registered`)
|
|
112
|
+
throw error
|
|
113
|
+
}
|
|
114
|
+
if (!sameOwner(s.owner, owner)) throw new Error('Script registration is outside this owner binding')
|
|
115
|
+
return s
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async list(owner: Owner): Promise<ScriptRegistration[]> {
|
|
119
|
+
let names: string[]
|
|
120
|
+
try { names = await readdir(this.dir) }
|
|
121
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return []; throw error }
|
|
122
|
+
const result: ScriptRegistration[] = []
|
|
123
|
+
for (const name of names.filter(name => /^[a-z0-9][a-z0-9_-]*\.json$/.test(name)).sort()) {
|
|
124
|
+
try { const s = await this.get(name.slice(0, -5)); if (sameOwner(s.owner, owner)) result.push(s) }
|
|
125
|
+
catch { console.error('Unreadable script registration', name) }
|
|
126
|
+
}
|
|
127
|
+
return result
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Register or explicitly update: hashes the current entry-point bytes.
|
|
131
|
+
async save(input: { id: string; owner: Owner; workspace: string; entry: string; interpreter: string; args: string[]; timeoutSeconds: number; pathValue?: string }, create: boolean): Promise<ScriptRegistration> {
|
|
132
|
+
assertScriptId(input.id)
|
|
133
|
+
if (!validOwner(input.owner)) throw new Error('Script registration requires the paired owner')
|
|
134
|
+
const previous = create ? undefined : await this.owned(input.id, input.owner)
|
|
135
|
+
const { file, relative } = await workspaceEntry(input.workspace, input.entry)
|
|
136
|
+
await resolveInterpreter(assertInterpreter(input.interpreter), input.pathValue, input.workspace)
|
|
137
|
+
const now = new Date().toISOString()
|
|
138
|
+
const s: ScriptRegistration = { version: 1, id: input.id, revision: randomUUID(), owner: input.owner, entry: relative,
|
|
139
|
+
interpreter: input.interpreter, args: assertScriptArgs(input.args), sha256: await fileSha256(file),
|
|
140
|
+
timeoutSeconds: assertTimeout(input.timeoutSeconds), registeredAt: previous?.registeredAt ?? now, updatedAt: now }
|
|
141
|
+
await mkdir(this.dir, { recursive: true, mode: 0o700 })
|
|
142
|
+
try { await atomic(path.join(this.dir, s.id + '.json'), s, create) }
|
|
143
|
+
catch (error) {
|
|
144
|
+
if (create && (error as NodeJS.ErrnoException).code === 'EEXIST') throw new Error('Script is already registered; use update')
|
|
145
|
+
throw error
|
|
146
|
+
}
|
|
147
|
+
return s
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async remove(id: string, owner: Owner) {
|
|
151
|
+
await this.owned(id, owner)
|
|
152
|
+
await rm(path.join(this.dir, assertScriptId(id) + '.json'))
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Executor-side check, immediately before spawn: the queued registration
|
|
156
|
+
// revision must still be current and the entry-point bytes must match it.
|
|
157
|
+
async verifiedInvocation(ref: ScriptRunRef, workspace: string, pathValue?: string) {
|
|
158
|
+
if (!validScriptRunRef(ref) || !ref.revision || !ref.sha256) throw new Error(`Script ${ref?.id ?? ''} was not registered when queued`)
|
|
159
|
+
let s: ScriptRegistration
|
|
160
|
+
try { s = await this.get(ref.id) } catch { throw new Error(`Script ${ref.id} registration is unavailable`) }
|
|
161
|
+
if (s.revision !== ref.revision || s.sha256 !== ref.sha256) throw new Error(`Script ${ref.id} registration changed after this run was queued`)
|
|
162
|
+
const { file } = await workspaceEntry(workspace, s.entry)
|
|
163
|
+
if (await fileSha256(file) !== s.sha256) throw new Error(`Script ${ref.id} entry point changed since registration; inspect it and run an explicit registration update`)
|
|
164
|
+
const command = await resolveInterpreter(s.interpreter, pathValue, workspace)
|
|
165
|
+
return { command, args: [file, ...s.args, ...ref.args], registration: s }
|
|
166
|
+
}
|
|
167
|
+
}
|