@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,22 @@
|
|
|
1
|
+
import test from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import {mkdtempSync,writeFileSync,openSync,closeSync,rmSync} from 'node:fs'
|
|
4
|
+
import {spawnSync} from 'node:child_process'
|
|
5
|
+
import {join} from 'node:path'
|
|
6
|
+
import {loadConfig} from '../src/config.js'
|
|
7
|
+
|
|
8
|
+
test('installed private descriptor admits speech selectors and keeps speech keys out of the executor', () => {
|
|
9
|
+
const dir=mkdtempSync('/tmp/ez-speech-env-'),file=join(dir,'relay.env'),key='synthetic-private-speech-marker'
|
|
10
|
+
try {
|
|
11
|
+
writeFileSync(file,`OPENROUTER_API_KEY=${key}\nEZ_SPEECH_PROVIDER=invalid-private-selector\nEZ_SPEECH_MODEL=fish-audio/s2.1-pro-free:free\n`,{mode:0o600})
|
|
12
|
+
let fd=openSync(file,'r')
|
|
13
|
+
const invalid=spawnSync(process.execPath,['--import','tsx','docker/run.ts','start'],{encoding:'utf8',timeout:15000,env:{PATH:process.env.PATH,EZ_APPLICATION_PORT:'8787'},stdio:['ignore','pipe','pipe',fd]});closeSync(fd)
|
|
14
|
+
assert.equal(invalid.status,1);assert.match(invalid.stderr,/Invalid EZ_SPEECH_PROVIDER/);assert.ok(!invalid.stderr.includes(key))
|
|
15
|
+
writeFileSync(file,`OPENROUTER_API_KEY=${key}\nEZ_SPEECH_PROVIDER=openrouter\nEZ_SPEECH_MODEL=fish-audio/s2.1-pro-free:free\n`,{mode:0o600})
|
|
16
|
+
fd=openSync(file,'r')
|
|
17
|
+
const child=spawnSync(process.execPath,['--import','tsx','docker/run.ts','exec',process.execPath,'-e',"if(process.env.OPENROUTER_API_KEY||process.env.EZ_SPEECH_PROVIDER)process.exit(91);console.log('isolated')"],{encoding:'utf8',timeout:15000,env:{PATH:process.env.PATH},stdio:['ignore','pipe','pipe',fd]});closeSync(fd)
|
|
18
|
+
assert.equal(child.status,0,child.stderr);assert.equal(child.stdout.trim(),'isolated')
|
|
19
|
+
const config=loadConfig({EZ_APPLICATION_PORT:'8787',OPENROUTER_API_KEY:key,EZ_SPEECH_PROVIDER:'openrouter',EZ_SPEECH_MODEL:'fish-audio/s2.1-pro-free:free'})
|
|
20
|
+
assert.equal(config.openrouterApiKey,key);assert.equal(config.speechProvider,'openrouter');assert.equal(config.speechModel,'fish-audio/s2.1-pro-free:free')
|
|
21
|
+
} finally {rmSync(dir,{recursive:true,force:true})}
|
|
22
|
+
})
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import test from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { createHash } from 'node:crypto'
|
|
4
|
+
import { mkdtemp, rm } from 'node:fs/promises'
|
|
5
|
+
import { join } from 'node:path'
|
|
6
|
+
import { tmpdir } from 'node:os'
|
|
7
|
+
import { execFile } from 'node:child_process'
|
|
8
|
+
import { promisify } from 'node:util'
|
|
9
|
+
import { ownerRun } from './helpers/owner-run.js'
|
|
10
|
+
import { serveTestLedger } from './helpers/ledger.js'
|
|
11
|
+
import { RunStore } from '../src/runs.js'
|
|
12
|
+
import { ControlStore } from '../src/control-state.js'
|
|
13
|
+
import { runEvidence, projectRunEvidence } from '../src/run-evidence.js'
|
|
14
|
+
|
|
15
|
+
async function fixture(t: test.TestContext) {
|
|
16
|
+
const dir = await mkdtemp(join(tmpdir(), 'ez-evidence-'))
|
|
17
|
+
await ownerRun(dir, 'owner_run')
|
|
18
|
+
const ledger = await serveTestLedger(dir)
|
|
19
|
+
t.after(async () => { await ledger.stop(); await rm(dir, { recursive: true, force: true }) })
|
|
20
|
+
return { dir, runs: new RunStore(dir), owner: (await new ControlStore(dir, 1000).status()).owner! }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
test('CLI projects operational proof without sessions, failure prose or delivered text', async t => {
|
|
24
|
+
const { dir, runs } = await fixture(t)
|
|
25
|
+
const secret = 'private-session-or-token'
|
|
26
|
+
await runs.patch('owner_run', { nativeSessionId: secret, failureReason: secret,
|
|
27
|
+
failure: { error: secret, relayVersion: '0.1.0-beta.47' } })
|
|
28
|
+
const sent = await runs.enqueueMessage('owner_run', 'private financial report')
|
|
29
|
+
await runs.claimOutbox(sent.id); await runs.markOutboxSent(sent.id, [7])
|
|
30
|
+
const uncertain = await runs.enqueueMessage('owner_run', 'uncertain report')
|
|
31
|
+
await runs.claimOutbox(uncertain.id); await runs.failOutbox(uncertain.id, secret, true)
|
|
32
|
+
const control = await new ControlStore(dir, 1000).status()
|
|
33
|
+
const { stdout } = await promisify(execFile)(process.execPath, ['bin/ezenciel-agents-schedule.mjs', 'evidence'], {
|
|
34
|
+
env: { ...process.env, EZ_CONTROL_DIR: dir, EZ_RUN_ID: 'owner_run' },
|
|
35
|
+
})
|
|
36
|
+
const result = JSON.parse(stdout)
|
|
37
|
+
assert.equal(result.runs.length, 1)
|
|
38
|
+
assert.ok(!stdout.includes(secret) && !stdout.includes('private financial report') && !stdout.includes('uncertain report'))
|
|
39
|
+
const receipts = result.runs[0].deliveries
|
|
40
|
+
assert.equal(receipts.find((r: any) => r.id === sent.id).receiptVerified, true)
|
|
41
|
+
assert.equal(receipts.find((r: any) => r.id === sent.id).content.sha256, createHash('sha256').update('private financial report').digest('hex'))
|
|
42
|
+
assert.equal(receipts.find((r: any) => r.id === uncertain.id).state, 'unknown')
|
|
43
|
+
assert.equal(receipts.find((r: any) => r.id === uncertain.id).receiptVerified, false)
|
|
44
|
+
assert.equal(result.runs[0].toolDelegationMetadata.state, 'unavailable')
|
|
45
|
+
assert.deepEqual(await new ControlStore(dir, 1000).status(), control)
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
test('pages require an unchanged snapshot and exact identity', async t => {
|
|
49
|
+
const { dir, runs } = await fixture(t)
|
|
50
|
+
await ownerRun(dir, 'second_run')
|
|
51
|
+
const first = await runEvidence(dir, { limit: 1 })
|
|
52
|
+
assert.equal(first.coverage.total, 2)
|
|
53
|
+
assert.equal(first.coverage.nextOffset, 1)
|
|
54
|
+
const second = await runEvidence(dir, { limit: 1, offset: 1, expected: first.snapshotSha256 })
|
|
55
|
+
assert.equal(second.coverage.nextOffset, null)
|
|
56
|
+
assert.notEqual(second.runs[0].id, first.runs[0].id)
|
|
57
|
+
await assert.rejects(runEvidence(dir, { offset: 1 }), /requires/)
|
|
58
|
+
await runs.patch('second_run', { status: 'completed' })
|
|
59
|
+
await assert.rejects(runEvidence(dir, { offset: 1, expected: first.snapshotSha256 }), /changed/)
|
|
60
|
+
await assert.rejects(runEvidence(dir, { runId: '../owner_run' }), /identifier/)
|
|
61
|
+
for (const options of [{ limit: 101 }, { offset: -1 }, { offset: 0.5 }, { expected: 'bad' }])
|
|
62
|
+
await assert.rejects(runEvidence(dir, options))
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
test('revoked owners, restricted callers and unrelated bindings are excluded', async t => {
|
|
66
|
+
const { dir, runs, owner } = await fixture(t)
|
|
67
|
+
const stranger = await runs.create({ id: 'other_owner', chatId: 202, telegramUserId: 202, texts: ['secret'] })
|
|
68
|
+
const external = await ownerRun(dir, 'external', { sourceId: 'test', bindingId: 'binding', eventIds: ['1'] })
|
|
69
|
+
const old = { ...(await runs.get('owner_run'))!, id: 'old_run', scheduled: { id: 'schedule', revision: 'rev', dueAt: '2026-01-01T00:00:00Z', pairedAt: 'old-pairing' } }
|
|
70
|
+
const app = { ...(await runs.get('owner_run'))!, id: 'app_run', delivery: { bindingId: 'private', scope: 'private' } }
|
|
71
|
+
assert.deepEqual(projectRunEvidence([stranger, external, old, app], [], owner), [])
|
|
72
|
+
await assert.rejects(runEvidence(dir, { callerRunId: 'external' }), /blocked/)
|
|
73
|
+
await runs.patch('owner_run', { replyOnly: true })
|
|
74
|
+
await assert.rejects(runEvidence(dir, { callerRunId: 'owner_run' }), /Telegram owner/)
|
|
75
|
+
await assert.rejects(runEvidence(dir, { runId: 'other_owner' }), /Unknown/)
|
|
76
|
+
await new ControlStore(dir, 1000).revokeOwner()
|
|
77
|
+
await assert.rejects(runEvidence(dir), /No authorized owner/)
|
|
78
|
+
})
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import test from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import {mkdtemp,rm,readFile} from 'node:fs/promises'
|
|
4
|
+
import {join} from 'node:path'
|
|
5
|
+
import {tmpdir} from 'node:os'
|
|
6
|
+
import {randomUUID} from 'node:crypto'
|
|
7
|
+
import {Scheduler} from '../src/scheduler.js'
|
|
8
|
+
import {RunStore} from '../src/runs.js'
|
|
9
|
+
import {validPreflight,combinePreflight} from '../src/schedule-preflight.js'
|
|
10
|
+
const packet=(eligible:boolean,fingerprint='a'.repeat(64))=>({eligible,fingerprint,count:eligible?1:0,observedAt:new Date().toISOString()})
|
|
11
|
+
test('preflight skips empty/unchanged work, admits saved worker once, and fails closed',async t=>{
|
|
12
|
+
const dir=await mkdtemp(join(tmpdir(),'schedule-preflight-'));t.after(()=>rm(dir,{recursive:true,force:true}))
|
|
13
|
+
let result=packet(false),failed=false
|
|
14
|
+
const scheduler=new Scheduler(dir,async()=>{if(failed)throw Error('Provider unavailable');return result}),runs=new RunStore(dir)
|
|
15
|
+
const owner={telegramUserId:42,telegramChatId:42,pairedAt:new Date().toISOString()},start=Date.now()+10000
|
|
16
|
+
const input={id:'preflight',name:'Check',text:'Do role work',enabled:true,owner,execution:{sessionId:randomUUID(),preset:{id:'test',name:'test',cli:'codex',model:'fixture-model'}},trigger:{everySeconds:60,start:new Date(start).toISOString()},preflight:{on:'changed' as const,checks:[{alias:'provider-work',args:['--field','Role=RoleX']}]}}
|
|
17
|
+
const s=await scheduler.save(input)
|
|
18
|
+
await scheduler.tick(owner,runs,start);assert.equal((await runs.list()).length,0)
|
|
19
|
+
result=packet(true,'b'.repeat(64));await scheduler.tick(owner,runs,start+60000)
|
|
20
|
+
const [run]=await runs.list();assert.deepEqual(run.execution,s.execution)
|
|
21
|
+
await scheduler.tick(owner,runs,start+120000);assert.equal((await runs.list()).length,1)
|
|
22
|
+
await runs.patch(run.id,{status:'completed'});await scheduler.tick(owner,runs,start+180000);assert.equal((await runs.list()).length,1)
|
|
23
|
+
failed=true;await scheduler.tick(owner,runs,start+240000);assert.equal((await runs.list()).length,1);assert.equal((await scheduler.preflightReceipt(s)).state,'unavailable')
|
|
24
|
+
failed=false;result=packet(false);await assert.rejects(()=>scheduler.trigger(s.id,s.revision,'empty',owner,runs),/no eligible/)
|
|
25
|
+
result=packet(true,'c'.repeat(64));await scheduler.tick(owner,runs,start+300000);assert.equal((await runs.list()).length,2)
|
|
26
|
+
})
|
|
27
|
+
test('only bounded literal commands and fresh typed packets enter admission',()=>{
|
|
28
|
+
assert.equal(validPreflight({on:'eligible',checks:[{alias:'queue',args:['x']}] }),true)
|
|
29
|
+
assert.equal(validPreflight({on:'eligible',checks:[{alias:'queue',args:['x\nwrite']}] }),false)
|
|
30
|
+
assert.throws(()=>combinePreflight([{schemaVersion:1,...packet(true),eligible:'true'}]),/Invalid/)
|
|
31
|
+
assert.throws(()=>combinePreflight([{schemaVersion:1,...packet(true),observedAt:'2000-01-01T00:00:00Z'}]),/stale/)
|
|
32
|
+
assert.equal(combinePreflight([{schemaVersion:1,...packet(false)},{schemaVersion:1,...packet(true)}]).eligible,true)
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
test('manual preflight before the first tick preserves future recurrence',async t=>{
|
|
36
|
+
const dir=await mkdtemp(join(tmpdir(),'schedule-preflight-'));t.after(()=>rm(dir,{recursive:true,force:true}))
|
|
37
|
+
const scheduler=new Scheduler(dir,async()=>packet(true)),runs=new RunStore(dir)
|
|
38
|
+
const owner={telegramUserId:42,telegramChatId:42,pairedAt:new Date().toISOString()}
|
|
39
|
+
const s=await scheduler.save({id:'manual',name:'Manual',text:'Work',enabled:true,owner,
|
|
40
|
+
execution:{sessionId:randomUUID(),preset:{id:'test',name:'test',cli:'codex',model:'fixture-model'}},
|
|
41
|
+
trigger:{everySeconds:60,start:new Date(Date.now()+60000).toISOString()},
|
|
42
|
+
preflight:{on:'eligible',checks:[{alias:'provider-work',args:[]}]}})
|
|
43
|
+
const next=await scheduler.pendingOccurrence(s)
|
|
44
|
+
const manual=await scheduler.trigger(s.id,s.revision,'first',owner,runs)
|
|
45
|
+
assert.equal(await scheduler.pendingOccurrence(s),next)
|
|
46
|
+
await runs.patch(manual.id,{status:'completed'})
|
|
47
|
+
await scheduler.tick(owner,runs,next!)
|
|
48
|
+
assert.equal((await runs.list()).length,2)
|
|
49
|
+
})
|
|
@@ -42,6 +42,10 @@ test('host transport keeps chat responsive beside a schedule in the bound mind',
|
|
|
42
42
|
await runs.patch(id,{status:'running'})
|
|
43
43
|
await submit(id)
|
|
44
44
|
await until(async()=>Boolean(await exists(id+'.process.json')))
|
|
45
|
+
const parallel='r_schedule_parallel'
|
|
46
|
+
await runs.create({id:parallel,chatId:101,telegramUserId:101,texts:['parallel'],execution:{sessionId:randomUUID(),preset:{id:'fixture',name:'Fixture',cli:'grok',model:'fixture-model'}},scheduled:{id:'parallel',revision:'v',dueAt:new Date().toISOString(),pairedAt:'paired'}})
|
|
47
|
+
await runs.patch(parallel,{status:'running'});await submit(parallel)
|
|
48
|
+
await until(async()=>Boolean(await exists(parallel+'.process.json')))
|
|
45
49
|
await submit('tg_1')
|
|
46
50
|
await until(async()=>(await exists('tg_1.events')).includes('"stream":"exit","code":0'))
|
|
47
51
|
assert.ok(!(await exists(id+'.events')).includes('"stream":"exit"'))
|
|
@@ -60,6 +64,9 @@ test('host transport keeps chat responsive beside a schedule in the bound mind',
|
|
|
60
64
|
assert.ok(!(await exists(id+'.events')).includes('"stream":"exit"'))
|
|
61
65
|
await writeFile(join(dir,id+'.cancel'),'')
|
|
62
66
|
await until(async()=>(await exists(id+'.events')).includes('"stream":"exit"'))
|
|
67
|
+
assert.ok(!(await exists(parallel+'.events')).includes('"stream":"exit"'))
|
|
68
|
+
await writeFile(join(dir,parallel+'.cancel'),'')
|
|
69
|
+
await until(async()=>(await exists(parallel+'.events')).includes('"stream":"exit"'))
|
|
63
70
|
await until(async()=>(await exists('tg_1.events')).includes('"stream":"exit","code":0'))
|
|
64
71
|
await until(async()=>(await exists('tg_2.events')).includes('"stream":"exit","code":0'))
|
|
65
72
|
const main=JSON.parse(stdout(await exists('tg_1.events')))
|
|
@@ -32,7 +32,7 @@ test('executor admission rejects a legacy queued schedule with no model before s
|
|
|
32
32
|
scheduled:{id:saved.id,revision:saved.revision,dueAt:new Date(due).toISOString(),pairedAt:owner.pairedAt}})
|
|
33
33
|
await relay.drainSources()
|
|
34
34
|
assert.equal((await runs.get(run.id))?.status,'failed');assert.equal(launches,0)
|
|
35
|
-
assert.equal((await scheduler.get(saved.id)).execution
|
|
35
|
+
assert.equal((await scheduler.get(saved.id)).execution!.preset.model,'saved-model')
|
|
36
36
|
}finally{await relay.stop();await rm(dir,{recursive:true,force:true})}
|
|
37
37
|
})
|
|
38
38
|
|
|
@@ -89,10 +89,10 @@ test('a wrapper-shaped run id has no maintenance treatment and a stale notice is
|
|
|
89
89
|
}finally{await relay.stop();await rm(dir,{recursive:true,force:true})}
|
|
90
90
|
})
|
|
91
91
|
|
|
92
|
-
test('Slack application replies beside
|
|
92
|
+
test('Slack application replies beside schedules configured to stay serial',async()=>{
|
|
93
93
|
const dir=await mkdtemp(join(tmpdir(),'ez-slack-scheduled-')),runs=new RunStore(dir),scheduler=new Scheduler(dir),control=new ControlStore(dir,1000)
|
|
94
94
|
const children=new Map<string,ReturnType<typeof spawn>>()
|
|
95
|
-
const relay=createRelay({workspace:dir,controlDir:dir,pairingTtlMs:1000,executorTimeoutMs:0,executorCli:'grok',telegramBotToken:'fixture'},async(_texts,options)=>{
|
|
95
|
+
const relay=createRelay({workspace:dir,controlDir:dir,pairingTtlMs:1000,executorTimeoutMs:0,executorCli:'grok',telegramBotToken:'fixture',scheduledConcurrency:1},async(_texts,options)=>{
|
|
96
96
|
const child=spawn(process.execPath,['-e','setInterval(()=>{},1000)'],{detached:process.platform!=='win32'})
|
|
97
97
|
children.set(options.runId,child);await once(child,'spawn')
|
|
98
98
|
if(!options.runId.startsWith('r_schedule_'))await runs.enqueueMessage(options.runId,'Slack reply')
|
|
@@ -176,3 +176,38 @@ test('owner chat replies while a scheduled CLI keeps running; targeted cancellat
|
|
|
176
176
|
await rm(dir,{recursive:true,force:true})
|
|
177
177
|
}
|
|
178
178
|
})
|
|
179
|
+
|
|
180
|
+
for(const limit of [undefined,2])test(`scheduled admission bounds independent native jobs at ${limit ?? 6} and releases a cancelled slot`,async()=>{
|
|
181
|
+
const dir=await mkdtemp(join(tmpdir(),'ez-parallel-schedules-')),control=new ControlStore(dir,1000),runs=new RunStore(dir),scheduler=new Scheduler(dir)
|
|
182
|
+
const children=new Map<string,ReturnType<typeof spawn>>(),optionsSeen:ExecutorOptions[]=[]
|
|
183
|
+
const relay=createRelay({workspace:dir,controlDir:dir,pairingTtlMs:1000,executorTimeoutMs:0,executorCli:'grok',telegramBotToken:'fixture',scheduledConcurrency:limit},async(_texts,options)=>{
|
|
184
|
+
optionsSeen.push(options)
|
|
185
|
+
const child=spawn(process.execPath,['-e','setInterval(()=>{},1000)'],{detached:process.platform!=='win32'})
|
|
186
|
+
children.set(options.runId,child);await once(child,'spawn');return {child,cleanup:async()=>{},stdout:''}
|
|
187
|
+
})
|
|
188
|
+
try{
|
|
189
|
+
await control.requestPairing(101,101);await control.approveOwner(101)
|
|
190
|
+
const owner=(await control.status()).owner!,execution=await control.captureChoice({...initialPreset('grok'),model:'saved-model',effort:'medium'})
|
|
191
|
+
const jobs: Awaited<ReturnType<Scheduler['trigger']>>[]=[]
|
|
192
|
+
for(let n=0;n<(limit ?? 6)+1;n++){
|
|
193
|
+
const saved=await scheduler.save({id:`job${n}`,name:`Job${n}`,text:'Finite test',owner,execution,enabled:true,trigger:{at:'2027-01-01T00:00:00Z'}},true)
|
|
194
|
+
jobs.push(await scheduler.trigger(saved.id,saved.revision,`key${n}`,owner,runs))
|
|
195
|
+
}
|
|
196
|
+
await Promise.all([relay.drainSources(),relay.drainSources()])
|
|
197
|
+
assert.equal((await runs.list()).filter(r=>r.status==='running').length,limit ?? 6)
|
|
198
|
+
assert.equal((await runs.get(jobs.at(-1)!.id))?.status,'queued')
|
|
199
|
+
assert.equal(new Set(optionsSeen.map(o=>o.sessionId)).size,limit ?? 6)
|
|
200
|
+
for(const options of optionsSeen){assert.equal(options.model,'saved-model');assert.equal(options.effort,'medium');assert.equal(options.nativeSession,true);assert.equal(options.isResume,false)}
|
|
201
|
+
const foreground=await runs.create({id:'tg_parallel_chat',chatId:101,telegramUserId:101,texts:['Owner chat'],execution})
|
|
202
|
+
await relay.drainSources();assert.equal((await runs.get(foreground.id))?.status,'running')
|
|
203
|
+
assert.equal(children.size,(limit ?? 6)+1)
|
|
204
|
+
await scheduler.cancel(jobs[0].id);await relay.drainSources()
|
|
205
|
+
await until(async()=> (await runs.get(jobs[0].id))?.status==='cancelled')
|
|
206
|
+
await relay.drainSources();assert.equal((await runs.get(jobs.at(-1)!.id))?.status,'running')
|
|
207
|
+
for(const job of jobs.slice(1))assert.equal((await runs.get(job.id))?.status,'running')
|
|
208
|
+
}finally{
|
|
209
|
+
await relay.stop()
|
|
210
|
+
await until(async()=>!(await runs.list()).some(r=>r.status==='running'))
|
|
211
|
+
await rm(dir,{recursive:true,force:true})
|
|
212
|
+
}
|
|
213
|
+
})
|
package/test/scheduler.test.ts
CHANGED
|
@@ -167,7 +167,7 @@ test('missing models cannot be saved, dispatched or triggered; legacy settings r
|
|
|
167
167
|
const file=join(f.dir,'schedules/test.json')
|
|
168
168
|
await writeFile(file,JSON.stringify({...saved,execution:missing.execution}))
|
|
169
169
|
const legacy=await f.scheduler.get(saved.id)
|
|
170
|
-
assert.equal(legacy.execution
|
|
170
|
+
assert.equal(legacy.execution!.preset.model,undefined)
|
|
171
171
|
await f.scheduler.tick(f.owner,f.runs,f.now)
|
|
172
172
|
assert.equal((await f.runs.list()).length,0)
|
|
173
173
|
assert.equal(await f.scheduler.pendingOccurrence(legacy),next,'blocked dispatch must preserve the pending occurrence')
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import test from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { execFile, spawn } from 'node:child_process'
|
|
4
|
+
import { promisify } from 'node:util'
|
|
5
|
+
import { once } from 'node:events'
|
|
6
|
+
import { chmod, mkdir, mkdtemp, readFile, rm, symlink, writeFile, access } from 'node:fs/promises'
|
|
7
|
+
import { join } from 'node:path'
|
|
8
|
+
import { tmpdir } from 'node:os'
|
|
9
|
+
import { fileURLToPath } from 'node:url'
|
|
10
|
+
import { createRelay } from '../src/index.js'
|
|
11
|
+
import { ControlStore } from '../src/control-state.js'
|
|
12
|
+
import { RunStore, type RunRecord } from '../src/runs.js'
|
|
13
|
+
import { Scheduler } from '../src/scheduler.js'
|
|
14
|
+
import { Scripts } from '../src/scripts.js'
|
|
15
|
+
import { initialPreset } from '../src/ai.js'
|
|
16
|
+
import { startExecutorJob, type ExecutorOptions } from '../src/executor.js'
|
|
17
|
+
import { serveTestLedger } from './helpers/ledger.js'
|
|
18
|
+
|
|
19
|
+
const exec = promisify(execFile), bin = fileURLToPath(new URL('../bin/ezenciel-agents-schedule.mjs', import.meta.url))
|
|
20
|
+
const until = async (check: () => Promise<boolean>, tries = 400) => { for (let i = 0; i < tries; i++) { if (await check()) return; await new Promise(r => setTimeout(r, 25)) } throw new Error('Timed out') }
|
|
21
|
+
const alive = (pid: number) => { try { process.kill(pid, 0); return true } catch { return false } }
|
|
22
|
+
|
|
23
|
+
// The script records only what it was given; it never prints secrets.
|
|
24
|
+
const fixture = `import { writeFileSync } from 'node:fs'
|
|
25
|
+
import { spawn } from 'node:child_process'
|
|
26
|
+
const mode = process.argv[2]
|
|
27
|
+
const env = Object.fromEntries(Object.entries(process.env).filter(([k]) => k.startsWith('EZ_')))
|
|
28
|
+
writeFileSync('out-' + process.env.EZ_RUN_ID + '.json', JSON.stringify({ argv: process.argv.slice(2), env,
|
|
29
|
+
leaked: ['TELEGRAM_BOT_TOKEN', 'EZ_TEST_SECRET', 'OPENAI_API_KEY'].filter(k => process.env[k] !== undefined) }))
|
|
30
|
+
console.log('routine check ok token=abcdefghijklmnopqrstuvwxyz0123456789ABCDEFGHIJKLMNOP')
|
|
31
|
+
console.error('diagnostic line')
|
|
32
|
+
if (mode === 'hang') {
|
|
33
|
+
const child = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { stdio: 'ignore' })
|
|
34
|
+
writeFileSync('child-' + process.env.EZ_RUN_ID + '.pid', String(child.pid))
|
|
35
|
+
setInterval(() => {}, 1000)
|
|
36
|
+
}
|
|
37
|
+
if (mode === 'fail') process.exit(3)
|
|
38
|
+
`
|
|
39
|
+
|
|
40
|
+
const setup = async () => {
|
|
41
|
+
const root = await mkdtemp(join(tmpdir(), 'ez-script-schedules-'))
|
|
42
|
+
const dir = join(root, 'control'), workspace = join(root, 'workspace'), fakeBin = join(root, 'bin')
|
|
43
|
+
await Promise.all([mkdir(dir), mkdir(join(workspace, 'scripts'), { recursive: true }), mkdir(fakeBin)])
|
|
44
|
+
await writeFile(join(workspace, 'scripts', 'guard.mjs'), fixture)
|
|
45
|
+
// Any native engine launch would leave this marker: script runs must not start one.
|
|
46
|
+
for (const engine of ['codex', 'claude', 'grok']) {
|
|
47
|
+
await writeFile(join(fakeBin, engine), `#!/bin/sh\necho "$0" >> ${JSON.stringify(join(root, 'llm-invocations'))}\n`)
|
|
48
|
+
await chmod(join(fakeBin, engine), 0o755)
|
|
49
|
+
}
|
|
50
|
+
const control = new ControlStore(dir, 1000)
|
|
51
|
+
await control.requestPairing(101, 101); await control.approveOwner(101)
|
|
52
|
+
const owner = (await control.status()).owner!
|
|
53
|
+
return { root, dir, workspace, fakeBin, control, owner, runs: new RunStore(dir), scheduler: new Scheduler(dir), scripts: new Scripts(dir) }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
test('script registration confines the entry point, rejects shell strings and records the entry hash', async t => {
|
|
57
|
+
const f = await setup(); t.after(() => rm(f.root, { recursive: true, force: true }))
|
|
58
|
+
const base = { id: 'guard', owner: f.owner, workspace: f.workspace, interpreter: 'node', args: [], timeoutSeconds: 60 }
|
|
59
|
+
const saved = await f.scripts.save({ ...base, entry: 'scripts/guard.mjs' }, true)
|
|
60
|
+
assert.equal(saved.entry, join('scripts', 'guard.mjs')); assert.match(saved.sha256, /^[a-f0-9]{64}$/)
|
|
61
|
+
await assert.rejects(f.scripts.save({ ...base, entry: 'scripts/guard.mjs' }, true), /already registered/)
|
|
62
|
+
await writeFile(join(f.root, 'outside.mjs'), 'process.exit(0)')
|
|
63
|
+
await assert.rejects(f.scripts.save({ ...base, id: 'escape', entry: '../outside.mjs' }, true), /inside the agent workspace/)
|
|
64
|
+
await symlink(join(f.root, 'outside.mjs'), join(f.workspace, 'link.mjs'))
|
|
65
|
+
await assert.rejects(f.scripts.save({ ...base, id: 'escape', entry: 'link.mjs' }, true), /inside the agent workspace/)
|
|
66
|
+
await assert.rejects(f.scripts.save({ ...base, id: 'shell', entry: 'scripts/guard.mjs', interpreter: 'node -e "x"' }, true), /not a shell command/)
|
|
67
|
+
await assert.rejects(f.scripts.save({ ...base, id: 'shell', entry: 'scripts/guard.mjs', interpreter: 'sh;rm' }, true), /not a shell command/)
|
|
68
|
+
await assert.rejects(f.scripts.save({ ...base, id: 'missing', entry: 'scripts/guard.mjs', interpreter: 'no-such-interpreter-ez' }, true), /not installed/)
|
|
69
|
+
await assert.rejects(f.scripts.save({ ...base, id: 'Bad ID', entry: 'scripts/guard.mjs' }, true), /Script ID/)
|
|
70
|
+
// An interpreter inside the workspace would be unhashed code.
|
|
71
|
+
await writeFile(join(f.workspace, 'run.sh'), '#!/bin/sh\n'); await chmod(join(f.workspace, 'run.sh'), 0o755)
|
|
72
|
+
await assert.rejects(f.scripts.save({ ...base, id: 'local', entry: 'scripts/guard.mjs', interpreter: join(f.workspace, 'run.sh') }, true), /outside the agent workspace/)
|
|
73
|
+
await assert.rejects(f.scripts.save({ ...base, id: 'local', entry: 'scripts/guard.mjs', interpreter: 'run.sh', pathValue: '.' }, true), /not installed/)
|
|
74
|
+
// Another owner's registration cannot be scheduled.
|
|
75
|
+
const other = { ...f.owner, generation: '00000000-0000-4000-8000-000000000000', pairedAt: new Date(Date.parse(f.owner.pairedAt) + 1000).toISOString() }
|
|
76
|
+
await assert.rejects(f.scheduler.save({ id: 'stranger', name: 'x', text: '', owner: other, enabled: true, trigger: { at: '2027-01-01T00:00:00Z' }, script: { id: 'guard', args: [] } }, true), /outside this owner binding/)
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
test('agent registers and schedules a script natively; script runs cannot change scripts or schedules', async t => {
|
|
80
|
+
const f = await setup(); t.after(() => rm(f.root, { recursive: true, force: true }))
|
|
81
|
+
const ledger = await serveTestLedger(f.dir); t.after(() => ledger.stop())
|
|
82
|
+
const execution = await f.control.captureChoice(initialPreset('codex'))
|
|
83
|
+
const turn = await f.runs.create({ chatId: 101, telegramUserId: 101, texts: ['register it'], execution })
|
|
84
|
+
await f.runs.patch(turn.id, { status: 'running' })
|
|
85
|
+
const env = { ...process.env, EZ_CONTROL_DIR: f.dir, EZ_RUN_ID: turn.id, EZ_AGENT_WORKSPACE: f.workspace }
|
|
86
|
+
const cli = async (...args: string[]) => JSON.parse((await exec(process.execPath, [bin, ...args], { env, cwd: f.workspace })).stdout)
|
|
87
|
+
const registered = await cli('script', 'register', 'book-freshness', '--file', 'scripts/guard.mjs', '--interpreter', 'node', '--timeout-seconds', '120')
|
|
88
|
+
assert.equal(registered.interpreter, 'node'); assert.equal(registered.timeoutSeconds, 120)
|
|
89
|
+
await assert.rejects(cli('create', 'bad', '--script', 'book-freshness', '--model', 'gpt-6-astra', '--now'), /run no model/)
|
|
90
|
+
await assert.rejects(cli('create', 'bad', '--script', 'unknown', '--now'), /not registered/)
|
|
91
|
+
const schedule = await cli('create', 'actual-book-freshness-guard', '--script', 'book-freshness', '--arg=--quiet', '--cron', '15 * * * *', '--timezone', 'Asia/Dubai')
|
|
92
|
+
assert.equal(schedule.executionType, 'script'); assert.deepEqual(schedule.script, { id: 'book-freshness', args: ['--quiet'] })
|
|
93
|
+
assert.equal(schedule.execution, undefined); assert.equal(schedule.text, '')
|
|
94
|
+
const shown = await cli('script', 'show', 'book-freshness')
|
|
95
|
+
assert.equal(shown.matchesRegistration, true); assert.deepEqual(shown.schedules, ['actual-book-freshness-guard'])
|
|
96
|
+
await assert.rejects(cli('script', 'remove', 'book-freshness'), /used by schedules/)
|
|
97
|
+
// An existing agent schedule remains readable beside it.
|
|
98
|
+
const agent = await cli('create', 'daily', '--text', 'Prepare the report', '--model', 'gpt-6-astra', '--at', '2027-09-09T09:00:00+04:00')
|
|
99
|
+
assert.equal(agent.executionType, 'agent'); assert.equal(agent.execution.preset.model, 'gpt-6-astra')
|
|
100
|
+
|
|
101
|
+
const scriptRun = await f.runs.create({ chatId: 101, telegramUserId: 101, texts: ['[schedule]'],
|
|
102
|
+
script: { id: 'book-freshness', args: [], revision: registered.revision, sha256: registered.sha256 },
|
|
103
|
+
scheduled: { id: 'actual-book-freshness-guard', revision: schedule.revision, dueAt: new Date().toISOString(), pairedAt: f.owner.pairedAt } })
|
|
104
|
+
await f.runs.patch(scriptRun.id, { status: 'running' })
|
|
105
|
+
const asScript = { env: { ...env, EZ_RUN_ID: scriptRun.id }, cwd: f.workspace }
|
|
106
|
+
for (const args of [['script', 'update', 'book-freshness'], ['script', 'register', 'other', '--file', 'scripts/guard.mjs', '--interpreter', 'node'],
|
|
107
|
+
['edit', 'actual-book-freshness-guard', '--script', 'book-freshness', '--now'], ['pause', 'daily'], ['trigger', 'daily', '--key', 'k']])
|
|
108
|
+
await assert.rejects(exec(process.execPath, [bin, ...args], asScript), /Script runs cannot change scripts or schedules/)
|
|
109
|
+
assert.equal(JSON.parse((await exec(process.execPath, [bin, 'script', 'show', 'book-freshness'], asScript)).stdout).id, 'book-freshness')
|
|
110
|
+
// A relative --file resolves from the caller's directory, stored relative to the workspace.
|
|
111
|
+
const nested = JSON.parse((await exec(process.execPath, [bin, 'script', 'register', 'nested', '--file', 'guard.mjs', '--interpreter', 'node'], { env, cwd: join(f.workspace, 'scripts') })).stdout)
|
|
112
|
+
assert.equal(nested.entry, join('scripts', 'guard.mjs'))
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
test('script CLI creates, preserves and explicitly clears preflight gates', async t => {
|
|
116
|
+
const f=await setup();t.after(()=>rm(f.root,{recursive:true,force:true}))
|
|
117
|
+
const ledger=await serveTestLedger(f.dir);t.after(()=>ledger.stop())
|
|
118
|
+
await f.scripts.save({id:'guard',owner:f.owner,workspace:f.workspace,entry:'scripts/guard.mjs',interpreter:'node',args:[],timeoutSeconds:60},true)
|
|
119
|
+
const preflight={on:'eligible',checks:[{alias:'provider-work',args:[]}]},file=join(f.root,'preflight.json')
|
|
120
|
+
await writeFile(file,JSON.stringify(preflight))
|
|
121
|
+
const env={...process.env,EZ_CONTROL_DIR:f.dir,EZ_RUN_ID:'',EZ_AGENT_WORKSPACE:f.workspace}
|
|
122
|
+
const cli=async(...args:string[])=>JSON.parse((await exec(process.execPath,[bin,...args],{env,cwd:f.workspace})).stdout)
|
|
123
|
+
const options=['--script','guard','--at','2027-09-09T09:00:00+04:00']
|
|
124
|
+
const created=await cli('create','gated',...options,'--preflight-file',file)
|
|
125
|
+
assert.deepEqual(created.preflight,preflight)
|
|
126
|
+
const edited=await cli('edit','gated',...options,'--name','Renamed')
|
|
127
|
+
assert.deepEqual(edited.preflight,preflight)
|
|
128
|
+
const cleared=await cli('edit','gated',...options,'--clear-preflight')
|
|
129
|
+
assert.equal(cleared.preflight,undefined)
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
test('script schedules run with zero LLM invocations, exact revision receipts, isolation and fail-safe integrity', async t => {
|
|
133
|
+
const f = await setup(); t.after(() => rm(f.root, { recursive: true, force: true }))
|
|
134
|
+
const savedEnv = { PATH: process.env.PATH, TELEGRAM_BOT_TOKEN: process.env.TELEGRAM_BOT_TOKEN, EZ_TEST_SECRET: process.env.EZ_TEST_SECRET }
|
|
135
|
+
process.env.PATH = `${f.fakeBin}:${process.env.PATH}`; process.env.TELEGRAM_BOT_TOKEN = '123456:secret-token-value-for-tests-only'; process.env.EZ_TEST_SECRET = 'do-not-inherit'
|
|
136
|
+
t.after(() => { for (const [k, v] of Object.entries(savedEnv)) { if (v === undefined) delete process.env[k]; else process.env[k] = v } })
|
|
137
|
+
const agentLaunches: ExecutorOptions[] = []
|
|
138
|
+
const relay = createRelay({ workspace: f.workspace, controlDir: f.dir, pairingTtlMs: 1000, executorTimeoutMs: 0, executorCli: 'codex', telegramBotToken: 'fixture' },
|
|
139
|
+
async (texts, options) => {
|
|
140
|
+
if (options.script) return startExecutorJob(texts, options)
|
|
141
|
+
agentLaunches.push(options)
|
|
142
|
+
const child = spawn(process.execPath, ['-e', 'setTimeout(()=>{},10)'], { detached: true }); await once(child, 'spawn')
|
|
143
|
+
return { child, cleanup: async () => {}, stdout: '' }
|
|
144
|
+
})
|
|
145
|
+
relay.bot.api.config.use(async () => ({ ok: true, result: { message_id: 42 } }) as never)
|
|
146
|
+
t.after(() => relay.stop())
|
|
147
|
+
const registration = await f.scripts.save({ id: 'book-freshness', owner: f.owner, workspace: f.workspace, entry: 'scripts/guard.mjs', interpreter: 'node', args: ['ok'], timeoutSeconds: 60 }, true)
|
|
148
|
+
const schedule = await f.scheduler.save({ id: 'guard', name: 'Guard', text: '', owner: f.owner, enabled: true, trigger: { cron: '15 * * * *', timezone: 'Asia/Dubai', start: new Date().toISOString() }, script: { id: 'book-freshness', args: ['extra'] } }, true)
|
|
149
|
+
const finished = (id: string) => until(async () => ['completed', 'failed', 'cancelled'].includes((await f.runs.get(id))?.status ?? ''))
|
|
150
|
+
const output = async (run: RunRecord) => JSON.parse(await readFile(join(f.workspace, `out-${run.id}.json`), 'utf8'))
|
|
151
|
+
|
|
152
|
+
// Manual trigger.
|
|
153
|
+
const manual = await f.scheduler.trigger(schedule.id, schedule.revision, 'smoke-1', f.owner, f.runs)
|
|
154
|
+
await relay.drainSources(); await finished(manual.id)
|
|
155
|
+
const done = (await f.runs.get(manual.id))!
|
|
156
|
+
assert.equal(done.status, 'completed'); assert.equal(done.exitCode, 0); assert.equal(done.execution, undefined)
|
|
157
|
+
assert.deepEqual(done.script, { id: 'book-freshness', args: ['extra'], revision: registration.revision, sha256: registration.sha256 })
|
|
158
|
+
assert.ok(done.startedAt && done.endedAt && done.createdAt <= done.startedAt)
|
|
159
|
+
assert.match(done.output!, /routine check ok/); assert.match(done.output!, /diagnostic line/); assert.doesNotMatch(done.output!, /abcdefghijklmnopqrstuvwxyz0123456789ABCDEF/)
|
|
160
|
+
const seen = await output(done)
|
|
161
|
+
assert.deepEqual(seen.argv, ['ok', 'extra']); assert.deepEqual(seen.leaked, [])
|
|
162
|
+
assert.equal(seen.env.EZ_RUN_ID, manual.id); assert.equal(seen.env.EZ_SCHEDULE_ID, 'guard'); assert.equal(seen.env.EZ_SCHEDULE_REVISION, schedule.revision)
|
|
163
|
+
assert.equal(seen.env.EZ_DUE_AT, done.scheduled!.dueAt); assert.equal(seen.env.EZ_SCRIPT_REVISION, registration.revision); assert.equal(seen.env.EZ_SCRIPT_SHA256, registration.sha256)
|
|
164
|
+
assert.equal((await relay.drainOutbox(), (await f.runs.pendingOutbox()).length), 0)
|
|
165
|
+
|
|
166
|
+
// Natural occurrence through the existing tick.
|
|
167
|
+
const due = await f.scheduler.pendingOccurrence(schedule)
|
|
168
|
+
await f.scheduler.tick(f.owner, f.runs, due!)
|
|
169
|
+
const natural = (await f.runs.list()).find(r => r.id !== manual.id && r.scheduled?.id === 'guard')!
|
|
170
|
+
assert.equal(natural.scheduled!.dueAt, new Date(due!).toISOString())
|
|
171
|
+
await relay.drainSources(); await finished(natural.id)
|
|
172
|
+
assert.equal((await f.runs.get(natural.id))?.status, 'completed')
|
|
173
|
+
await assert.rejects(access(join(f.root, 'llm-invocations')))
|
|
174
|
+
assert.equal(agentLaunches.length, 0)
|
|
175
|
+
|
|
176
|
+
// Changed entry-point bytes refuse to run until an explicit update.
|
|
177
|
+
await writeFile(join(f.workspace, 'scripts', 'guard.mjs'), fixture + '\n// edited\n')
|
|
178
|
+
const tampered = await f.scheduler.trigger(schedule.id, schedule.revision, 'tampered', f.owner, f.runs)
|
|
179
|
+
await relay.drainSources(); await finished(tampered.id)
|
|
180
|
+
const refused = (await f.runs.get(tampered.id))!
|
|
181
|
+
assert.equal(refused.status, 'failed'); assert.match(refused.failure!.error, /entry point changed since registration/)
|
|
182
|
+
await assert.rejects(readFile(join(f.workspace, `out-${tampered.id}.json`)))
|
|
183
|
+
const updated = await f.scripts.save({ id: 'book-freshness', owner: f.owner, workspace: f.workspace, entry: 'scripts/guard.mjs', interpreter: 'node', args: ['hang'], timeoutSeconds: 60 }, false)
|
|
184
|
+
assert.notEqual(updated.sha256, registration.sha256)
|
|
185
|
+
|
|
186
|
+
// Cancellation stops the script and its children; overlap is refused meanwhile.
|
|
187
|
+
const long = await f.scheduler.trigger(schedule.id, schedule.revision, 'long', f.owner, f.runs)
|
|
188
|
+
await relay.drainSources()
|
|
189
|
+
const childPid = Number(await until(async () => { try { await access(join(f.workspace, `child-${long.id}.pid`)); return true } catch { return false } })
|
|
190
|
+
.then(() => readFile(join(f.workspace, `child-${long.id}.pid`), 'utf8')))
|
|
191
|
+
assert.ok(alive(childPid))
|
|
192
|
+
assert.equal((await f.runs.get(long.id))?.script?.revision, updated.revision)
|
|
193
|
+
await assert.rejects(f.scheduler.trigger(schedule.id, schedule.revision, 'overlap', f.owner, f.runs), /queued or running/)
|
|
194
|
+
const before = (await f.runs.list()).length
|
|
195
|
+
await f.scheduler.tick(f.owner, f.runs, (await f.scheduler.pendingOccurrence(schedule))! + 3600_000)
|
|
196
|
+
assert.equal((await f.runs.list()).length, before)
|
|
197
|
+
await f.scheduler.cancel(long.id); await relay.drainSources(); await finished(long.id)
|
|
198
|
+
assert.equal((await f.runs.get(long.id))?.status, 'cancelled')
|
|
199
|
+
await until(async () => !alive(childPid))
|
|
200
|
+
|
|
201
|
+
// A bounded timeout terminates the process; it is a failure, never retried.
|
|
202
|
+
await f.scripts.save({ id: 'book-freshness', owner: f.owner, workspace: f.workspace, entry: 'scripts/guard.mjs', interpreter: 'node', args: ['hang'], timeoutSeconds: 1 }, false)
|
|
203
|
+
const slow = await f.scheduler.trigger(schedule.id, schedule.revision, 'slow', f.owner, f.runs)
|
|
204
|
+
await relay.drainSources(); await finished(slow.id)
|
|
205
|
+
const timedOut = (await f.runs.get(slow.id))!
|
|
206
|
+
assert.equal(timedOut.status, 'failed'); assert.equal(timedOut.failureReason, 'script-timeout'); assert.equal(timedOut.timedOut, true)
|
|
207
|
+
const slowChild = Number(await readFile(join(f.workspace, `child-${slow.id}.pid`), 'utf8'))
|
|
208
|
+
await until(async () => !alive(slowChild))
|
|
209
|
+
await relay.drainSources()
|
|
210
|
+
assert.equal((await f.runs.list()).filter(r => r.scheduled?.id === 'guard' && r.status === 'queued').length, 0)
|
|
211
|
+
|
|
212
|
+
// An existing LLM schedule still launches its native engine settings.
|
|
213
|
+
const execution = await f.control.captureChoice({ ...initialPreset('codex'), model: 'gpt-6-astra' })
|
|
214
|
+
const agent = await f.scheduler.save({ id: 'report', name: 'Report', text: 'Prepare the report', owner: f.owner, execution, enabled: true, trigger: { at: '2027-01-01T00:00:00Z' } }, true)
|
|
215
|
+
const agentRun = await f.scheduler.trigger(agent.id, agent.revision, 'agent', f.owner, f.runs)
|
|
216
|
+
await relay.drainSources(); await finished(agentRun.id)
|
|
217
|
+
assert.equal((await f.runs.get(agentRun.id))?.status, 'completed')
|
|
218
|
+
assert.equal(agentLaunches.length, 1); assert.equal(agentLaunches[0].model, 'gpt-6-astra'); assert.equal(agentLaunches[0].nativeSession, true)
|
|
219
|
+
})
|