@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
|
@@ -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,3 +1,4 @@
|
|
|
1
|
+
import { validPreflight, installedPreflight, type Preflight, type PreflightReceipt } from './schedule-preflight.js'
|
|
1
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'
|
|
@@ -7,15 +8,20 @@ import { type Owner, sameOwner, validOwner, ownerId, ownerEpoch } from './contro
|
|
|
7
8
|
import { validApplicationOrigin } from './application-origin.js'
|
|
8
9
|
import { ApplicationBindings } from './application-channel.js'
|
|
9
10
|
import { assertId, ownsRun } from './identity.js'
|
|
10
|
-
import { type ExecutionChoice, isExecutionChoice, persistedPreset } from './ai.js'
|
|
11
|
+
import { type AiPreset, 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,11 +128,26 @@ 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
|
-
|
|
117
|
-
|
|
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
|
+
const setups = [execution.preset, ...execution.fallbacks ?? []]
|
|
143
|
+
for (const preset of setups) {
|
|
144
|
+
assertScheduledModel(preset.model)
|
|
145
|
+
assertEffort(preset.effort, preset.model, preset.cli)
|
|
146
|
+
}
|
|
147
|
+
const key = (p: AiPreset) => JSON.stringify([p.cli, p.provider, p.model, p.effort])
|
|
148
|
+
if (new Set(setups.map(key)).size !== setups.length) throw new Error('Task setups must be distinct')
|
|
149
|
+
s = {...input, execution, trigger:validateTrigger(input.trigger),version:1,revision:randomUUID()}
|
|
150
|
+
}
|
|
118
151
|
const now = Date.now()
|
|
119
152
|
let next = nextOccurrence(s.trigger,now-1)
|
|
120
153
|
if (next === null) throw new Error('Schedule has no future occurrence within eight years')
|
|
@@ -129,12 +162,26 @@ export class Scheduler {
|
|
|
129
162
|
}
|
|
130
163
|
// Publish the cursor first so a crash cannot expose a new revision with
|
|
131
164
|
// a missing cursor that falls back to the trigger's historical start.
|
|
132
|
-
await
|
|
165
|
+
const prior=await this.preflightReceipt(previous)
|
|
166
|
+
await atomic(join(this.dir,`${s.id}.${s.revision}.cursor`),{next,...(JSON.stringify(previous.preflight)===JSON.stringify(s.preflight) && prior ? {preflight:prior} : {})})
|
|
133
167
|
}
|
|
134
168
|
}
|
|
135
169
|
await atomic(join(this.dir,s.id+'.json'),s,exclusive)
|
|
136
170
|
return s
|
|
137
171
|
}
|
|
172
|
+
async preflight(id:string, revision:string) {
|
|
173
|
+
const s=await this.get(id)
|
|
174
|
+
if(s.revision!==revision || !s.enabled || !s.preflight) throw Error('Schedule preflight is unavailable')
|
|
175
|
+
const receipt=await this.checkPreflight(id,revision)
|
|
176
|
+
const cursor=join(this.dir,`${s.id}.${s.revision}.cursor`)
|
|
177
|
+
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})
|
|
178
|
+
const admitted=receipt.eligible && (s.preflight.on!=='changed' || previous.preflight?.fingerprint!==receipt.fingerprint)
|
|
179
|
+
return {receipt,admitted,previous,cursor}
|
|
180
|
+
}
|
|
181
|
+
async preflightReceipt(s:Schedule) {
|
|
182
|
+
try {return JSON.parse(await readFile(join(this.dir,`${s.id}.${s.revision}.cursor`),'utf8')).preflight}
|
|
183
|
+
catch(e) {if((e as NodeJS.ErrnoException).code==='ENOENT')return undefined;throw e}
|
|
184
|
+
}
|
|
138
185
|
async enable(id: string, enabled: boolean): Promise<Schedule> {
|
|
139
186
|
const s = await this.get(id)
|
|
140
187
|
// Pausing preserves the cursor. Resuming coalesces missed recurrences like restart.
|
|
@@ -163,7 +210,8 @@ export class Scheduler {
|
|
|
163
210
|
async trigger(id: string, revision: string, key: string, owner: Owner, runs: RunStore): Promise<RunRecord> {
|
|
164
211
|
const s=await this.get(id)
|
|
165
212
|
if (!sameOwner(s.owner,owner) || !s.enabled || s.revision !== revision) throw new Error('Schedule is disabled, changed or outside this owner binding')
|
|
166
|
-
|
|
213
|
+
if (s.script) await new Scripts(this.controlDir).owned(s.script.id, owner)
|
|
214
|
+
else assertScheduledModel(s.execution!.preset.model)
|
|
167
215
|
if (s.delivery) {
|
|
168
216
|
const binding=(await new ApplicationBindings(this.controlDir).list()).find(b=>b.bindingId===s.delivery!.bindingId)
|
|
169
217
|
if (!binding || !sameOwner(binding.owner,owner)) throw new Error('Schedule delivery binding is unavailable')
|
|
@@ -177,11 +225,20 @@ export class Scheduler {
|
|
|
177
225
|
const all=await runs.list()
|
|
178
226
|
if (all.some(r=>holdsSchedule(s,r))) throw new Error('Inspect the held occurrence and edit the schedule before retrying')
|
|
179
227
|
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')
|
|
228
|
+
let checked: Awaited<ReturnType<Scheduler['preflight']>> | undefined
|
|
229
|
+
if(s.preflight) {
|
|
230
|
+
checked=await this.preflight(s.id,s.revision)
|
|
231
|
+
if(!checked.receipt.eligible) throw Error('Schedule preflight has no eligible work')
|
|
232
|
+
const current=await this.get(s.id)
|
|
233
|
+
if(!current.enabled || current.revision!==s.revision) throw Error('Schedule changed during preflight')
|
|
234
|
+
}
|
|
180
235
|
const dueAt=new Date().toISOString()
|
|
181
|
-
|
|
236
|
+
const run=await runs.create({id:runId,ownerId:ownerId(owner),ownerEpoch:ownerEpoch(owner),
|
|
182
237
|
...(s.delivery ? {delivery:s.delivery} : {chatId:s.owner.telegramChatId,telegramUserId:s.owner.telegramUserId,telegramEpoch:s.owner.telegramLinkedAt ?? s.owner.pairedAt}),
|
|
183
|
-
texts:
|
|
238
|
+
texts:runTexts(s,dueAt),...(s.script ? {script:await scriptRef(this.controlDir,s,owner)} : {execution:{...s.execution!,sessionId:randomUUID()}}),
|
|
184
239
|
scheduled:{id:s.id,revision:s.revision,dueAt,pairedAt:s.owner.pairedAt,...(s.originRunId ? {originRunId:s.originRunId} : {})}},true)
|
|
240
|
+
if(checked) await atomic(checked.cursor,{...checked.previous,preflight:checked.receipt})
|
|
241
|
+
return run
|
|
185
242
|
}
|
|
186
243
|
async recover(runs: RunStore) {
|
|
187
244
|
for (const run of await runs.list()) {
|
|
@@ -204,7 +261,7 @@ export class Scheduler {
|
|
|
204
261
|
try {
|
|
205
262
|
const next = await this.pendingOccurrence(s)
|
|
206
263
|
if (next === null || next > now) continue
|
|
207
|
-
assertScheduledModel(s.execution
|
|
264
|
+
if (!s.script) assertScheduledModel(s.execution!.preset.model)
|
|
208
265
|
// One occurrence at a time. A failed reviewer stops this revision just like
|
|
209
266
|
// interrupted work: retain its receipt until an explicit schedule edit.
|
|
210
267
|
if ((await runs.list()).some(r => r.scheduled?.id === s.id &&
|
|
@@ -213,14 +270,23 @@ export class Scheduler {
|
|
|
213
270
|
if (s.when === 'unreviewed-failures' && !(await runs.list()).some(r => needsFailureReview(r) && ownsRun(owner, r) && (!r.scheduled || r.scheduled.pairedAt === owner.pairedAt))) {
|
|
214
271
|
await atomic(cursor,{next:future}); continue
|
|
215
272
|
}
|
|
273
|
+
let preflight: PreflightReceipt | undefined
|
|
274
|
+
if(s.preflight) {
|
|
275
|
+
try {
|
|
276
|
+
const checked=await this.preflight(s.id,s.revision)
|
|
277
|
+
preflight=checked.receipt
|
|
278
|
+
if(!checked.admitted) {await atomic(cursor,{next:future,preflight});continue}
|
|
279
|
+
const current=await this.get(s.id);if(!current.enabled || current.revision!==s.revision)continue
|
|
280
|
+
} catch (error) {console.error('Schedule preflight unavailable',s.id,error instanceof Error ? error.message : 'Unknown error');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}
|
|
281
|
+
}
|
|
216
282
|
const dueAt = new Date(next).toISOString()
|
|
217
283
|
await runs.create({id:scheduledRunId(s,next),
|
|
218
284
|
ownerId:ownerId(owner),ownerEpoch:ownerEpoch(owner),
|
|
219
285
|
...(s.delivery ? {delivery:s.delivery} : {chatId:s.owner.telegramChatId,telegramUserId:s.owner.telegramUserId,telegramEpoch:s.owner.telegramLinkedAt ?? s.owner.pairedAt}),
|
|
220
|
-
texts:
|
|
286
|
+
texts:runTexts(s,dueAt),...(s.script ? {script:await scriptRef(this.controlDir,s,owner)} : {execution:s.execution}),
|
|
221
287
|
scheduled:{id:s.id,revision:s.revision,dueAt,pairedAt:s.owner.pairedAt,...(s.originRunId?{originRunId:s.originRunId}:{})}},true)
|
|
222
288
|
// A restart between run creation and this cursor write sees the same occurrence ID.
|
|
223
|
-
await atomic(cursor,{next:future})
|
|
289
|
+
await atomic(cursor,{next:future,...(preflight ? {preflight} : {})})
|
|
224
290
|
} catch (error) { console.error('Schedule dispatch failed',s.id,error instanceof Error ? error.message : 'Unknown error') }
|
|
225
291
|
}
|
|
226
292
|
}
|
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
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { presetLabel, presetProvider, type AiPreset, type ExecutionChoice } from './ai.js'
|
|
2
|
+
|
|
3
|
+
// Ordered task setups for one scheduled occurrence. Core advances only on typed
|
|
4
|
+
// evidence that the engine never started work: a missing CLI or a provider
|
|
5
|
+
// rejection before any model output or tool item. Anything else stops the chain.
|
|
6
|
+
export type StartupCategory = 'cli-unavailable' | 'quota' | 'provider-rejected' | 'access-denied'
|
|
7
|
+
export type StartupRejection = { category: StartupCategory; resetAt?: string }
|
|
8
|
+
export type AttemptCategory = StartupCategory | 'after-work-began' | 'uncertain' | 'same-quota-scope'
|
|
9
|
+
export type SetupAttempt = {
|
|
10
|
+
preset: AiPreset
|
|
11
|
+
quotaScope: string
|
|
12
|
+
outcome: 'running' | 'completed' | 'failed' | 'cancelled' | 'skipped'
|
|
13
|
+
category?: AttemptCategory
|
|
14
|
+
resetAt?: string
|
|
15
|
+
exitCode?: number | null
|
|
16
|
+
startedAt?: string
|
|
17
|
+
endedAt?: string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const categories: StartupCategory[] = ['cli-unavailable', 'quota', 'provider-rejected', 'access-denied']
|
|
21
|
+
export const advances = (rejection?: StartupRejection): boolean => Boolean(rejection && rejection.category !== 'access-denied')
|
|
22
|
+
|
|
23
|
+
export const setupCandidates = (execution: ExecutionChoice): AiPreset[] => [execution.preset, ...(execution.fallbacks ?? [])]
|
|
24
|
+
|
|
25
|
+
// Allowance is metered per provider account, not per credential file. Setups on
|
|
26
|
+
// the same native client share a scope; OpenCode/Pi share their upstream
|
|
27
|
+
// provider. A different credential alone never proves independent allowance.
|
|
28
|
+
export const quotaScope = (preset: AiPreset): string => {
|
|
29
|
+
if (['opencode', 'pi'].includes(preset.cli)) {
|
|
30
|
+
const provider = preset.cli === 'pi' ? preset.model?.split('/')[0] : presetProvider(preset)
|
|
31
|
+
return provider ? `provider:${provider}` : preset.cli
|
|
32
|
+
}
|
|
33
|
+
return preset.cli === 'codex-gui' ? 'codex' : preset.cli
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// The next untried setup, skipping those whose quota scope already rejected
|
|
37
|
+
// this occurrence. Each candidate is tried at most once.
|
|
38
|
+
export const nextSetup = (candidates: AiPreset[], attempts: SetupAttempt[]): { skipped: SetupAttempt[]; preset?: AiPreset } => {
|
|
39
|
+
const exhausted = new Set(attempts.filter(a => a.category === 'quota').map(a => a.quotaScope))
|
|
40
|
+
const skipped: SetupAttempt[] = []
|
|
41
|
+
for (const preset of candidates.slice(attempts.length)) {
|
|
42
|
+
const scope = quotaScope(preset)
|
|
43
|
+
if (!exhausted.has(scope)) return { skipped, preset }
|
|
44
|
+
const at = new Date().toISOString()
|
|
45
|
+
skipped.push({ preset, quotaScope: scope, outcome: 'skipped', category: 'same-quota-scope', startedAt: at, endedAt: at })
|
|
46
|
+
}
|
|
47
|
+
return { skipped }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export class SetupsUnavailableError extends Error {}
|
|
51
|
+
|
|
52
|
+
export const unavailableSummary = (attempts: SetupAttempt[]): string =>
|
|
53
|
+
`Every task setup was unavailable: ${attempts.map(a => `${presetLabel(a.preset)} (${a.category}${a.resetAt ? `, resets ${a.resetAt}` : ''})`).join('; ')}`
|
|
54
|
+
|
|
55
|
+
const epochSeconds = (value: unknown): string | undefined =>
|
|
56
|
+
typeof value === 'number' && Number.isSafeInteger(value) && value > 0 ? new Date(value * 1000).toISOString() : undefined
|
|
57
|
+
|
|
58
|
+
const claudeErrors: Record<string, StartupCategory> = {
|
|
59
|
+
rate_limit: 'quota', billing_error: 'quota',
|
|
60
|
+
authentication_failed: 'access-denied', oauth_org_not_allowed: 'access-denied', account_on_hold: 'access-denied',
|
|
61
|
+
verification_required: 'access-denied', cloud_credential_error: 'access-denied',
|
|
62
|
+
overloaded: 'provider-rejected', server_error: 'provider-rejected', model_not_found: 'provider-rejected', invalid_request: 'provider-rejected',
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Codex app-server `codexErrorInfo` of a failed turn.
|
|
66
|
+
export const codexRejection = (info: unknown): StartupCategory | undefined => {
|
|
67
|
+
if (info === 'usageLimitExceeded' || info === 'rateLimitExceeded') return 'quota'
|
|
68
|
+
if (info === 'unauthorized') return 'access-denied'
|
|
69
|
+
if (['serverOverloaded', 'internalServerError', 'badRequest', 'flexUnavailable'].includes(info as string)) return 'provider-rejected'
|
|
70
|
+
if (!info || typeof info !== 'object') return undefined
|
|
71
|
+
const status = (Object.values(info)[0] as { httpStatusCode?: unknown } | undefined)?.httpStatusCode
|
|
72
|
+
if (typeof status !== 'number') return undefined
|
|
73
|
+
return status === 429 ? 'quota' : [401, 403].includes(status) ? 'access-denied' : [400, 404].includes(status) || status >= 500 ? 'provider-rejected' : undefined
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// The latest reset among exhausted Codex rate-limit windows.
|
|
77
|
+
export const codexResetAt = (limits: any): string | undefined => {
|
|
78
|
+
const resets = [limits?.primary, limits?.secondary].filter(w => w && w.usedPercent >= 100 && typeof w.resetsAt === 'number').map(w => w.resetsAt as number)
|
|
79
|
+
return resets.length ? epochSeconds(Math.max(...resets)) : undefined
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export const startupLine = (state: { workBegan: boolean; rejection?: StartupRejection }): string =>
|
|
83
|
+
JSON.stringify({ type: 'ez.startup', workBegan: state.workBegan, ...(state.rejection ? { rejection: state.rejection } : {}) })
|
|
84
|
+
|
|
85
|
+
// Reads structured stdout only: Ez transport summaries (`ez.startup`) and
|
|
86
|
+
// Claude stream-json envelopes. Model text is never inspected. Any event that
|
|
87
|
+
// is not known pre-work metadata counts as work, so ambiguity never advances.
|
|
88
|
+
export const startupObserver = () => {
|
|
89
|
+
let buffer = '', workBegan = false, rejection: StartupRejection | undefined
|
|
90
|
+
const line = (text: string) => {
|
|
91
|
+
if (!text.trim()) return
|
|
92
|
+
let event: any
|
|
93
|
+
try { event = JSON.parse(text) } catch { workBegan = true; return }
|
|
94
|
+
if (event?.type === 'ez.startup') {
|
|
95
|
+
if (event.workBegan === true) workBegan = true
|
|
96
|
+
const r = event.rejection
|
|
97
|
+
if (r && categories.includes(r.category) && (r.resetAt === undefined || Number.isFinite(Date.parse(r.resetAt))))
|
|
98
|
+
rejection = { category: r.category, ...(r.resetAt ? { resetAt: r.resetAt } : {}) }
|
|
99
|
+
return
|
|
100
|
+
}
|
|
101
|
+
if (event?.type === 'thread.started' || event?.type === 'result' ||
|
|
102
|
+
(event?.type === 'system' && event.subtype === 'init')) return
|
|
103
|
+
if (event?.type === 'rate_limit_event') {
|
|
104
|
+
const info = event.rate_limit_info
|
|
105
|
+
if (info?.status === 'rejected') rejection = { category: 'quota', ...(epochSeconds(info.resetsAt) ? { resetAt: epochSeconds(info.resetsAt) } : {}) }
|
|
106
|
+
return
|
|
107
|
+
}
|
|
108
|
+
const category = event?.type === 'assistant' && typeof event.error === 'string' ? claudeErrors[event.error] : undefined
|
|
109
|
+
if (category) rejection = { category, ...(category === 'quota' && rejection?.resetAt ? { resetAt: rejection.resetAt } : {}) }
|
|
110
|
+
else workBegan = true
|
|
111
|
+
}
|
|
112
|
+
return {
|
|
113
|
+
write: (chunk: string) => {
|
|
114
|
+
buffer += chunk
|
|
115
|
+
const lines = buffer.split('\n')
|
|
116
|
+
buffer = lines.pop() ?? ''
|
|
117
|
+
for (const text of lines) line(text)
|
|
118
|
+
},
|
|
119
|
+
result: () => { line(buffer); buffer = ''; return { workBegan, rejection } },
|
|
120
|
+
summary: () => startupLine({ workBegan, rejection }),
|
|
121
|
+
}
|
|
122
|
+
}
|
package/src/setup.ts
CHANGED
|
@@ -9,7 +9,7 @@ import { discoverDefaults } from './client-defaults.js'
|
|
|
9
9
|
import { chatPreset } from './ai.js'
|
|
10
10
|
import { ControlStore } from './control-state.js'
|
|
11
11
|
import { loadControlConfig } from './config.js'
|
|
12
|
-
import { EXECUTOR_REGISTRY, resolveExecutor, executorKey } from './executor.js'
|
|
12
|
+
import { EXECUTOR_REGISTRY, resolveExecutor, executorKey, claudeConfigHome } from './executor.js'
|
|
13
13
|
import { desktopCodexPath } from './desktop-bridge.js'
|
|
14
14
|
|
|
15
15
|
export const findExecutableInPath = async (
|
|
@@ -152,7 +152,7 @@ export const runCli = async (): Promise<void> => {
|
|
|
152
152
|
const config = loadControlConfig()
|
|
153
153
|
await new ControlStore(config.controlDir, config.pairingTtlMs).syncClientPresets(
|
|
154
154
|
chatPreset(await readActiveExecutor(envFilePath)), await discoverDefaults(workspace,
|
|
155
|
-
{ codexHome: path.join(config.controlDir, 'cli', 'codex') }))
|
|
155
|
+
{ codexHome: path.join(config.controlDir, 'cli', 'codex'), claudeHome: claudeConfigHome(config.controlDir) }))
|
|
156
156
|
console.log(JSON.stringify({ workspace, created }))
|
|
157
157
|
return
|
|
158
158
|
}
|
package/src/task-capability.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { execFile } from 'node:child_process'
|
|
1
|
+
import { execFile, spawn } from 'node:child_process'
|
|
2
2
|
import { promisify } from 'node:util'
|
|
3
3
|
import { readFile } from 'node:fs/promises'
|
|
4
4
|
import path from 'node:path'
|
|
5
|
+
import {fileURLToPath} from 'node:url'
|
|
5
6
|
import { executorEnvironment } from './executor.js'
|
|
6
7
|
import type { TaskCapability } from './tasks.js'
|
|
7
8
|
import {attachmentLimit,stageTaskAttachment,type TaskAttachment} from './task-attachments.js'
|
|
@@ -43,3 +44,18 @@ export async function runTaskCapability(toolsHome: string | undefined, capabilit
|
|
|
43
44
|
if (code !== 0 || !stdout.trim()) throw new Error(`Channel capability failed with exit ${code}`)
|
|
44
45
|
return {output:stdout}
|
|
45
46
|
}
|
|
47
|
+
|
|
48
|
+
export async function launchTaskApplication(toolsHome: string | undefined, launch:{command:string;args:string[]}, input:unknown, runId:string):Promise<unknown> {
|
|
49
|
+
const isolated=!!process.env.EZ_PLUGIN_BROKER_SOCKET
|
|
50
|
+
if(!isolated && (!toolsHome || !path.isAbsolute(toolsHome)))throw Error('Installed tool registry required')
|
|
51
|
+
const config=!isolated ? JSON.parse(await readFile(path.join(toolsHome!,'config.json'),'utf8')) : undefined
|
|
52
|
+
// Fixed owner-configured command; literal private stdin, never a shell or model-selected args.
|
|
53
|
+
return new Promise((resolve,reject)=>{
|
|
54
|
+
const child=spawn(isolated ? process.execPath : path.join(toolsHome!,'bin','ez'),isolated ? [fileURLToPath(new URL('../bin/ez',import.meta.url)),'--task-application'] : [launch.command,...launch.args],{env:{...executorEnvironment(),EZ_RUN_ID:runId},...(config ? {cwd:config.workspace} : {}),stdio:['pipe','pipe','pipe']})
|
|
55
|
+
let output='';const timer=setTimeout(()=>{child.kill();reject(Error('Application launch timed out'))},30000)
|
|
56
|
+
child.stdout.on('data',chunk=>{output+=chunk;if(Buffer.byteLength(output)>16000){child.kill();reject(Error('Application launch output too large'))}})
|
|
57
|
+
child.stderr.resume();child.on('error',reject)
|
|
58
|
+
child.on('close',code=>{clearTimeout(timer);try{if(code!==0)throw Error('Application launch failed');const value=JSON.parse(output);const url=new URL(value.launch?.url);if(url.protocol!=='https:' || url.username || url.password || typeof value.launch?.expiresAt!=='number')throw Error('Invalid application launch receipt');resolve(value.launch)}catch(e){reject(e)}})
|
|
59
|
+
child.stdin.end(JSON.stringify(input))
|
|
60
|
+
})
|
|
61
|
+
}
|