@jc_stack/ez-agents 0.1.0-beta.52 → 0.1.0-beta.58

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.
@@ -0,0 +1,175 @@
1
+ import test from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { execFile } from 'node:child_process'
4
+ import { promisify } from 'node:util'
5
+ import { mkdtemp, readFile, readdir, rm, stat } from 'node:fs/promises'
6
+ import { join } from 'node:path'
7
+ import { tmpdir } from 'node:os'
8
+ import type { Owner } from '../src/control-state.js'
9
+ import { ControlStore } from '../src/control-state.js'
10
+ import { type RunRecord, RunStore } from '../src/runs.js'
11
+ import { type Schedule, Scheduler } from '../src/scheduler.js'
12
+ import { projectRunningWork, formatRunningWork, runningWork } from '../src/running-work.js'
13
+ import { callDeliverySocket, socketPathFor } from '../src/delivery-socket.js'
14
+ import { ownerRun } from './helpers/owner-run.js'
15
+ import { serveTestLedger } from './helpers/ledger.js'
16
+
17
+ const exec = promisify(execFile)
18
+ const owner: Owner = { telegramUserId: 101, telegramChatId: 101, pairedAt: '2026-01-01T00:00:00Z' }
19
+ const now = Date.parse('2026-10-12T16:00:00Z')
20
+ const run = (id: string, change: Partial<RunRecord> = {}): RunRecord => ({
21
+ version: 1, id, chatId: 101, telegramUserId: 101, texts: ['PRIVATE PROMPT'], status: 'running',
22
+ createdAt: '2026-10-12T15:00:00Z', startedAt: '2026-10-12T15:01:00Z', ...change,
23
+ })
24
+ const origin = { id: 'daily', revision: 'revision', dueAt: '2026-10-12T15:00:00Z', pairedAt: owner.pairedAt }
25
+ const execution = { sessionId: 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', preset: { id: 'primary', name: 'Primary', cli: 'codex', model: 'fixture-model' } }
26
+
27
+ test('one view combines foreground, queued and scheduled work with timestamps and honest provenance', () => {
28
+ const foreground = run('foreground', { execution })
29
+ const scheduled = run('scheduled', { scheduled: { ...origin, originRunId: 'finished_origin' }, execution })
30
+ const queued = run('queued', { status: 'queued', startedAt: undefined, scheduled: { ...origin, originRunId: 'foreground' } })
31
+ const definition: Schedule = { version: 1, ...origin, name: 'Daily report', text: 'PRIVATE SCHEDULE TEXT',
32
+ trigger: { at: origin.dueAt }, enabled: true, owner, execution }
33
+ const view = projectRunningWork([queued, scheduled, foreground, run('finished_origin', { status: 'completed' })],
34
+ [{ id: 'delivery', runId: 'foreground', chatId: 101, createdAt: '2026-10-12T15:55:00Z', text: 'PRIVATE MESSAGE', state: 'sent' }], [definition], owner, now)
35
+ assert.deepEqual(view.counts, { running: 2, queued: 1 })
36
+ assert.deepEqual(view.runs.map(item => item.id), ['foreground', 'scheduled', 'queued'])
37
+ const first = view.runs[0], second = view.runs[1], third = view.runs[2]
38
+ assert.equal(first.elapsedSeconds, 3540)
39
+ assert.deepEqual(first.lastObservedActivity, { at: '2026-10-12T15:55:00.000Z', kind: 'delivery-enqueued', ageSeconds: 300 })
40
+ assert.deepEqual(first.scheduledChildren, [{ id: 'queued', status: 'queued' }])
41
+ assert.deepEqual(second.origin, { runId: 'finished_origin', relation: 'schedule-origin', status: 'completed', retained: true })
42
+ assert.equal(second.schedule?.name, 'Daily report')
43
+ assert.equal(third.startedAt, null); assert.equal(third.elapsedSeconds, null); assert.equal(third.queuedSeconds, 3600)
44
+ assert.deepEqual(first.nativeChildren, { state: 'unavailable' })
45
+ const human = formatRunningWork(view), json = JSON.stringify(view)
46
+ assert.match(human, /2 running, 1 queued/); assert.match(human, /Daily report/); assert.match(human, /completed/)
47
+ assert.match(human, /Native children: unavailable/); assert.match(human, /schedule-origin|Schedule origin/)
48
+ assert.ok(!human.includes('PRIVATE') && !json.includes('PRIVATE'))
49
+ })
50
+
51
+ test('old running records remain running, missing/future telemetry stays unknown, terminals are omitted', () => {
52
+ const records = [
53
+ run('stale', { startedAt: '2026-01-02T00:00:00Z' }),
54
+ run('partial', { startedAt: undefined, execution: { ...execution, preset: { ...execution.preset, model: undefined } } }),
55
+ run('future', { createdAt: '2026-10-13T00:00:00Z', startedAt: '2026-10-13T01:00:00Z' }),
56
+ ...(['completed', 'failed', 'cancelled'] as const).map(status => run(status, { status })),
57
+ ]
58
+ const before = structuredClone(records)
59
+ const view = projectRunningWork(records, [], [], owner, now)
60
+ assert.equal(view.counts.running, 3); assert.equal(view.counts.queued, 0)
61
+ assert.equal(view.runs.find(item => item.id === 'stale')?.status, 'running')
62
+ const partial = view.runs.find(item => item.id === 'partial')!
63
+ assert.equal(partial.elapsedSeconds, null); assert.equal(partial.execution?.model, null)
64
+ const future = view.runs.find(item => item.id === 'future')!
65
+ assert.equal(future.elapsedSeconds, null); assert.equal(future.lastObservedActivity, null)
66
+ assert.match(formatRunningWork(view), /silence is not idle or completion/)
67
+ assert.deepEqual(records, before, 'a status read must not reconcile, cancel or complete work')
68
+ })
69
+
70
+ test('fallback metadata uses the latest non-skipped saved setup, and every engine retains its visibility limits', () => {
71
+ const claude = { id: 'fallback', name: 'Fallback', cli: 'claude', model: 'opus' }
72
+ const records = ['codex', 'codex-gui', 'claude', 'grok', 'opencode', 'pi'].map(cli => run(cli, {
73
+ execution: { ...execution, preset: { ...execution.preset, cli, model: cli === 'opencode' ? 'provider/model' : undefined } },
74
+ nativeSessionId: 'PRIVATE NATIVE SESSION', output: 'PRIVATE ENGINE OUTPUT',
75
+ }))
76
+ records.push(run('fallback', { execution: { ...execution, fallbacks: [claude] }, attempts: [
77
+ { preset: execution.preset, quotaScope: 'codex', outcome: 'failed', startedAt: '2026-10-12T15:01:00Z', endedAt: '2026-10-12T15:02:00Z' },
78
+ { preset: claude, quotaScope: 'claude', outcome: 'running', startedAt: '2026-10-12T15:03:00Z' },
79
+ { preset: execution.preset, quotaScope: 'codex', outcome: 'skipped', startedAt: '2026-10-12T15:04:00Z' },
80
+ ] }))
81
+ records.push(run('script', { scheduled: origin, script: { id: 'guard', args: ['PRIVATE ARGUMENT'] } }))
82
+ records.push(run('unknown_engine'))
83
+ const view = projectRunningWork(records, [], [], owner, now)
84
+ for (const cli of ['codex', 'codex-gui', 'claude', 'grok', 'opencode', 'pi']) {
85
+ const item = view.runs.find(item => item.id === cli)!
86
+ assert.equal(item.execution?.cli, cli)
87
+ assert.equal(item.execution?.model, cli === 'opencode' ? 'provider/model' : null)
88
+ assert.deepEqual(item.nativeChildren, { state: 'unavailable' })
89
+ }
90
+ const fallback = view.runs.find(item => item.id === 'fallback')!
91
+ assert.equal(fallback.execution?.cli, 'claude'); assert.equal(fallback.execution?.model, 'opus')
92
+ assert.equal(fallback.execution?.source, 'latest-attempt'); assert.equal(fallback.execution?.attemptOutcome, 'running')
93
+ assert.equal(fallback.lastObservedActivity?.at, '2026-10-12T15:04:00.000Z')
94
+ assert.equal(fallback.lastObservedActivity?.kind, 'setup-skipped')
95
+ assert.equal(view.runs.find(item => item.id === 'script')?.execution, null)
96
+ assert.equal(view.runs.find(item => item.id === 'unknown_engine')?.execution, null)
97
+ assert.ok(!JSON.stringify(view).includes('PRIVATE'))
98
+ })
99
+
100
+ test('scope excludes revoked/unrelated work; missing origins and stale schedule definitions are not invented', () => {
101
+ const view = projectRunningWork([
102
+ run('visible', { scheduled: { ...origin, originRunId: 'other' } }),
103
+ run('other', { chatId: 202, telegramUserId: 202 }),
104
+ run('old', { scheduled: { ...origin, pairedAt: 'old-pairing' } }),
105
+ run('pre-pairing', { createdAt: '2025-12-01T00:00:00Z' }),
106
+ run('external', { external: { sourceId: 'private', bindingId: 'private', eventIds: ['private'] } }),
107
+ run('isolated', { taskId: 'private' }),
108
+ run('app', { application: { bindingId: 'private', scope: 'private', requestId: 'private', inputText: 'PRIVATE APP PROMPT', context: { secret: 'PRIVATE APP CONTEXT' } } }),
109
+ run('channel', { delivery: { bindingId: 'private', scope: 'private' } }),
110
+ ], [], [{ version: 1, id: 'daily', revision: 'new-revision', name: 'Wrong revision', owner } as Schedule], owner, now)
111
+ assert.deepEqual(view.runs.map(item => item.id), ['app', 'channel', 'visible'])
112
+ assert.equal(view.runs.find(item => item.id === 'app')?.kind, 'application-foreground')
113
+ const scheduled = view.runs.find(item => item.id === 'visible')!
114
+ assert.equal(scheduled.schedule?.name, null)
115
+ assert.deepEqual(scheduled.origin, { runId: 'other', relation: 'schedule-origin', status: null, retained: false })
116
+ assert.ok(!JSON.stringify(view).includes('PRIVATE'))
117
+ assert.match(formatRunningWork(view), /status unknown; record unavailable/)
118
+ })
119
+
120
+ async function files(dir: string) {
121
+ const result: Record<string, string> = {}
122
+ for (const name of (await readdir(dir, { recursive: true })).sort())
123
+ if ((await stat(join(dir, name))).isFile()) result[name] = await readFile(join(dir, name), 'utf8')
124
+ return result
125
+ }
126
+
127
+ test('public CLI and socket return sanitized readable/JSON snapshots without changing control files', async t => {
128
+ const dir = await mkdtemp(join(tmpdir(), 'ez-running-work-'))
129
+ const foreground = await ownerRun(dir, 'foreground')
130
+ const ledger = await serveTestLedger(dir)
131
+ t.after(async () => { await ledger.stop(); await rm(dir, { recursive: true, force: true }) })
132
+ const control = new ControlStore(dir, 1000), runs = new RunStore(dir), boundOwner = (await control.status()).owner!
133
+ const definition = await new Scheduler(dir).save({ id: 'daily', name: 'Daily report', text: 'PRIVATE TASK TEXT',
134
+ owner: boundOwner, execution, originRunId: foreground.id, enabled: true, trigger: { at: new Date(Date.now() + 60_000).toISOString() } }, true)
135
+ const scheduled = await runs.create({ id: 'scheduled', chatId: 101, telegramUserId: 101, texts: ['PRIVATE PROMPT'], execution,
136
+ scheduled: { id: definition.id, revision: definition.revision, dueAt: new Date().toISOString(), pairedAt: boundOwner.pairedAt, originRunId: foreground.id } })
137
+ await runs.patch(scheduled.id, { status: 'running', startedAt: new Date().toISOString(), nativeSessionId: 'PRIVATE SESSION', failureReason: 'PRIVATE FAILURE' })
138
+ const before = await files(dir)
139
+ const env = { ...process.env, EZ_CONTROL_DIR: dir, EZ_DELIVERY_SOCKET: ledger.socketPath, EZ_RUN_ID: foreground.id }
140
+ const cli = (...args: string[]) => exec(process.execPath, ['bin/ezenciel-agents-schedule.mjs', 'running', ...args], { env })
141
+ const human = (await cli()).stdout, json = (await cli('--json')).stdout, view = JSON.parse(json)
142
+ assert.match(human, /2 running, 0 queued/); assert.match(human, /Daily report/)
143
+ assert.equal(view.schemaVersion, 1); assert.equal(view.runs.length, 2)
144
+ assert.equal(view.runs.find((item: any) => item.id === 'scheduled').origin.runId, foreground.id)
145
+ assert.ok(!json.includes('PRIVATE') && !human.includes('PRIVATE'))
146
+ assert.deepEqual(await files(dir), before)
147
+ assert.equal((await runs.get('scheduled'))?.status, 'running')
148
+ assert.match((await exec(process.execPath, ['bin/ezenciel-agents-schedule.mjs', '--help'])).stdout, /running \[--json\]/)
149
+ await assert.rejects(cli('--model', 'override'), /accepts only --json/)
150
+ await assert.rejects(cli('unexpected-id'), /accepts only --json/)
151
+ })
152
+
153
+ test('server rejects unauthorized/finished callers, revocation and an unavailable relay instead of reporting idle', async t => {
154
+ const dir = await mkdtemp(join(tmpdir(), 'ez-running-auth-'))
155
+ await ownerRun(dir, 'caller')
156
+ const ledger = await serveTestLedger(dir), runs = new RunStore(dir)
157
+ t.after(async () => { await ledger.stop(); await rm(dir, { recursive: true, force: true }) })
158
+ await ownerRun(dir, 'external', { sourceId: 'test', bindingId: 'binding', eventIds: ['1'] })
159
+ await assert.rejects(callDeliverySocket(socketPathFor(dir), { op: 'runningWork', payload: { callerRunId: 'external' } }), /blocked/)
160
+ await assert.rejects(runningWork(dir, { callerRunId: '../caller' }), /identifier/)
161
+ await runs.patch('caller', { replyOnly: true })
162
+ await assert.rejects(runningWork(dir, { callerRunId: 'caller' }), /Telegram owner/)
163
+ const application = await runs.create({ id: 'application', chatId: 101, telegramUserId: 101, texts: [],
164
+ application: { bindingId: 'private', scope: 'private', requestId: 'private' } })
165
+ await runs.patch(application.id, { status: 'running' })
166
+ await assert.rejects(runningWork(dir, { callerRunId: application.id }), /Telegram owner/)
167
+ await runs.patch('caller', { replyOnly: false, status: 'completed' })
168
+ await assert.rejects(runningWork(dir, { callerRunId: 'caller' }), /No active core run/)
169
+ await new ControlStore(dir, 1000).revokeOwner()
170
+ await assert.rejects(runningWork(dir), /No authorized owner/)
171
+ await ledger.stop()
172
+ await assert.rejects(exec(process.execPath, ['bin/ezenciel-agents-schedule.mjs', 'running', '--json'], {
173
+ env: { ...process.env, EZ_CONTROL_DIR: dir, EZ_DELIVERY_SOCKET: ledger.socketPath, EZ_RUN_ID: '' },
174
+ }), /Delivery relay unavailable/)
175
+ })
@@ -2,7 +2,7 @@ import test from 'node:test'
2
2
  import assert from 'node:assert/strict'
3
3
  import { execFile } from 'node:child_process'
4
4
  import { promisify } from 'node:util'
5
- import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
5
+ import { chown, mkdir, mkdtemp, readdir, rm, stat, symlink, writeFile } from 'node:fs/promises'
6
6
  import { join } from 'node:path'
7
7
  import { tmpdir } from 'node:os'
8
8
  import { fileURLToPath } from 'node:url'
@@ -42,11 +42,12 @@ test('public scheduler CLI saves literal text, reads back, edits, pauses, and re
42
42
  await assert.rejects(exec(process.execPath,[bin,...args],{env}),/exists/)
43
43
  assert.equal(JSON.parse((await exec(process.execPath,[bin,'pause','test'],{env})).stdout).enabled,false)
44
44
  assert.equal(JSON.parse((await exec(process.execPath,[bin,'resume','test'],{env})).stdout).enabled,true)
45
- await exec(process.execPath,[bin,'edit','test','--model','custom-model','--effort','low','--cron','0 9 * * 2','--timezone','Asia/Dubai','--text','Tuesday'],{env})
45
+ const resumed=JSON.parse((await exec(process.execPath,[bin,'show','test'],{env})).stdout)
46
+ await exec(process.execPath,[bin,'edit','test','--expected-revision',resumed.revision,'--model','custom-model','--effort','low','--cron','0 9 * * 2','--timezone','Asia/Dubai','--text','Tuesday'],{env})
46
47
  assert.equal(JSON.parse((await exec(process.execPath,[bin,'show','test'],{env})).stdout).text,'Tuesday')
47
48
  const explicit=JSON.parse((await exec(process.execPath,[bin,'show','test'],{env})).stdout)
48
49
  assert.equal(explicit.execution.preset.model,'custom-model');assert.equal(explicit.execution.preset.effort,'low')
49
- await exec(process.execPath,[bin,'edit','test','--cron','0 9 * * 2','--timezone','Asia/Dubai','--text','Tuesday'],{env})
50
+ await exec(process.execPath,[bin,'edit','test','--expected-revision',explicit.revision,'--cron','0 9 * * 2','--timezone','Asia/Dubai','--text','Tuesday'],{env})
50
51
  assert.deepEqual(JSON.parse((await exec(process.execPath,[bin,'show','test'],{env})).stdout).execution,explicit.execution)
51
52
  const external=await runs.create({chatId:101,telegramUserId:101,texts:[],execution,external:{sourceId:'source',bindingId:'binding',eventIds:['event']}})
52
53
  await runs.patch(external.id,{status:'running'})
@@ -78,17 +79,65 @@ test('edit keeps a paused agent or script schedule paused and an enabled one ena
78
79
  const cli=async(...args:string[])=>JSON.parse((await exec(process.execPath,[bin,...args],{env})).stdout)
79
80
  const tasks={agent:(text:string)=>['--model','gpt-6-astra','--text',text],script:(name:string)=>['--script','guard','--name',name]}
80
81
  for(const [id,task] of Object.entries(tasks)){
81
- await cli('create',id,...task('Original'),'--every-seconds','3600')
82
+ const created=await cli('create',id,...task('Original'),'--every-seconds','3600')
82
83
  await cli('pause',id)
83
84
  // Changing only the instructions, even with --now, must not resume or dispatch it.
84
- const paused=await cli('edit',id,...task('Changed'),'--now')
85
+ const paused=await cli('edit',id,'--expected-revision',created.revision,...task('Changed'),'--now')
85
86
  assert.equal(paused.enabled,false);assert.equal(paused.nextEligibleAt,null)
86
87
  assert.equal((await cli('show',id)).enabled,false)
87
88
  await cli('resume',id)
88
- assert.equal((await cli('edit',id,...task('Again'),'--every-seconds','3600')).enabled,true)
89
+ assert.equal((await cli('edit',id,'--expected-revision',paused.revision,...task('Again'),'--every-seconds','3600')).enabled,true)
89
90
  }
90
91
  })
91
92
 
93
+ test('edit compares the read revision, refuses busy tasks unless staged and needs the relay',async t=>{
94
+ const dir=await mkdtemp(join(tmpdir(),'ez-schedule-safe-edit-'));t.after(()=>rm(dir,{recursive:true,force:true}))
95
+ const ledger=await serveTestLedger(dir);t.after(()=>ledger.stop())
96
+ const control=new ControlStore(dir,1000),runs=new RunStore(dir)
97
+ await control.requestPairing(101,101);await control.approveOwner(101)
98
+ const env={...process.env,EZ_CONTROL_DIR:dir,EZ_RUN_ID:''}
99
+ const cli=async(...args:string[])=>JSON.parse((await exec(process.execPath,[bin,...args],{env})).stdout)
100
+ const task=(text:string)=>['--model','gpt-6-astra','--every-seconds','3600','--text',text]
101
+ assert.match((await exec(process.execPath,[bin,'--help'])).stdout,/edit ID --expected-revision REVISION \[--stage\]/)
102
+ await assert.rejects(exec(process.execPath,[bin,'create','daily','--stage',...task('Original')],{env}),/apply to edit only/)
103
+ const created=await cli('create','daily',...task('Original'))
104
+ await assert.rejects(exec(process.execPath,[bin,'edit','daily',...task('Blind')],{env}),/requires --expected-revision/)
105
+ const edited=await cli('edit','daily','--expected-revision',created.revision,...task('Edited'))
106
+ await assert.rejects(exec(process.execPath,[bin,'edit','daily','--expected-revision',created.revision,...task('Stale')],{env}),/changed since revision/)
107
+ const queued=await runs.create({chatId:101,telegramUserId:101,texts:['[schedule]','Edited'],execution:edited.execution,
108
+ scheduled:{id:'daily',revision:edited.revision,dueAt:new Date().toISOString(),pairedAt:edited.owner.pairedAt}})
109
+ await assert.rejects(exec(process.execPath,[bin,'edit','daily','--expected-revision',edited.revision,...task('Busy')],{env}),/queued or running occurrences \(r_/)
110
+ assert.equal((await cli('show','daily')).revision,edited.revision)
111
+ const staged=await cli('edit','daily','--expected-revision',edited.revision,'--stage',...task('Staged'))
112
+ assert.deepEqual(staged.continuingRunIds,[queued.id]);assert.equal(staged.text,'Staged')
113
+ await ledger.stop()
114
+ await assert.rejects(exec(process.execPath,[bin,'edit','daily','--expected-revision',staged.revision,'--stage',...task('Offline')],{env}),/Relay unavailable/)
115
+ assert.equal((await cli('show','daily')).revision,staged.revision)
116
+ })
117
+
118
+ test('a root operator command acts as the control directory owner and cannot follow a planted symlink',{skip:process.geteuid?.()!==0},async t=>{
119
+ const dir=await mkdtemp(join(tmpdir(),'ez-schedule-root-')),target=await mkdtemp(join(tmpdir(),'ez-root-target-')),runtime=20000
120
+ t.after(()=>Promise.all([rm(dir,{recursive:true,force:true}),rm(target,{recursive:true,force:true})]))
121
+ const control=new ControlStore(dir,1000);await control.requestPairing(101,101);await control.approveOwner(101)
122
+ const ledger=await serveTestLedger(dir);t.after(()=>ledger.stop())
123
+ const workspace=join(dir,'workspace');await mkdir(workspace);await writeFile(join(workspace,'guard.mjs'),'')
124
+ // The runtime user owns its control directory, as the container entrypoint provisions it.
125
+ await chown(dir,runtime,runtime);for(const name of await readdir(dir))await chown(join(dir,name),runtime,runtime)
126
+ await chown(join(workspace,'guard.mjs'),runtime,runtime)
127
+ const env={...process.env,EZ_CONTROL_DIR:dir,EZ_RUN_ID:'',EZ_AGENT_WORKSPACE:workspace}
128
+ const cli=async(...args:string[])=>JSON.parse((await exec(process.execPath,[bin,...args],{env,cwd:workspace})).stdout)
129
+ const task=['--model','gpt-6-astra','--every-seconds','3600','--text','Root operator']
130
+ const created=await cli('create','daily',...task)
131
+ const edited=await cli('edit','daily','--expected-revision',created.revision,...task)
132
+ await cli('script','register','guard','--file','guard.mjs','--interpreter','node')
133
+ for(const path of ['schedules','schedules/daily.json',`schedules/daily.${edited.revision}.cursor`,'scripts/guard.json'])
134
+ assert.equal((await stat(join(dir,path))).uid,runtime,path)
135
+ // An agent can replace its own control subdirectory; root must not write through it.
136
+ await rm(join(dir,'schedules'),{recursive:true});await symlink(target,join(dir,'schedules'))
137
+ await assert.rejects(exec(process.execPath,[bin,'create','other',...task],{env}),/EACCES|permission denied/)
138
+ assert.deepEqual(await readdir(target),[]);assert.equal((await stat(target)).uid,0)
139
+ })
140
+
92
141
  test('executor PATH exposes the extensionless scheduler command',async()=>{
93
142
  const command=fileURLToPath(new URL('../bin/ezenciel-agents-schedule',import.meta.url))
94
143
  assert.match((await exec(command,['--help'])).stdout,/Creates a scheduled task/)
@@ -1,6 +1,6 @@
1
1
  import test from 'node:test'
2
2
  import assert from 'node:assert/strict'
3
- import { mkdtemp, rm, writeFile, readFile, stat } from 'node:fs/promises'
3
+ import { mkdtemp, rm, writeFile, readFile, stat, readdir, utimes } from 'node:fs/promises'
4
4
  import { join } from 'node:path'
5
5
  import { tmpdir } from 'node:os'
6
6
  import { randomUUID } from 'node:crypto'
@@ -178,3 +178,130 @@ test('missing models cannot be saved, dispatched or triggered; legacy settings r
178
178
  assert.equal(run.execution?.preset.model,f.input.execution.preset.model)
179
179
  assert.equal(run.scheduled?.revision,repaired.revision)
180
180
  })
181
+
182
+ // Safe updates: an edit never invalidates an occurrence that is already queued
183
+ // or running, and definition writes serialize with dispatch on the schedule lock.
184
+ test('edit applies only from the expected revision and keeps a concurrent pause',async t=>{
185
+ const f=await fixture(t), s=await f.scheduler.save(f.input,true), list=()=>f.runs.list()
186
+ const first=await f.scheduler.edit({...f.input,text:'First'},s.revision,list)
187
+ await assert.rejects(f.scheduler.edit({...f.input,text:'Stale'},s.revision,list),/changed since revision/)
188
+ assert.equal((await f.scheduler.get(s.id)).text,'First')
189
+ const racing=await Promise.allSettled(['A','B'].map(text=>f.scheduler.edit({...f.input,text},first.revision,list)))
190
+ const won=racing.filter(r=>r.status==='fulfilled')
191
+ assert.equal(won.length,1,'two edits from one revision cannot both apply')
192
+ assert.match(String((racing.find(r=>r.status==='rejected') as PromiseRejectedResult).reason),/changed since revision/)
193
+ const latest=await f.scheduler.get(s.id)
194
+ assert.equal(latest.revision,(won[0] as PromiseFulfilledResult<typeof latest>).value.revision)
195
+ // Pause keeps the revision; an edit read before it must not resume the task.
196
+ await f.scheduler.enable(s.id,false)
197
+ assert.equal((await f.scheduler.edit({...f.input,enabled:true,text:'After pause'},latest.revision,list)).enabled,false)
198
+ assert.deepEqual((await readdir(join(f.dir,'schedules'))).filter(name=>name.endsWith('.lock')),[])
199
+ })
200
+ test('edit refuses a busy task; a staged edit lets queued and running occurrences finish on captured settings',async t=>{
201
+ const f=await fixture(t), list=()=>f.runs.list()
202
+ const s=await f.scheduler.save({...f.input,trigger:{everySeconds:60,start:new Date(f.now).toISOString()}},true)
203
+ await f.scheduler.tick(f.owner,f.runs,f.now)
204
+ const [queued]=await f.runs.list()
205
+ const changed={...f.input,text:'New instructions',trigger:s.trigger,execution:{...f.input.execution,preset:{...f.input.execution.preset,model:'new-model'}}}
206
+ await assert.rejects(f.scheduler.edit(changed,s.revision,list),new RegExp(`queued or running occurrences \\(${queued.id}\\)`))
207
+ assert.equal((await f.scheduler.get(s.id)).revision,s.revision)
208
+ assert.equal(await f.scheduler.current(queued,f.owner),true,'a refused edit leaves the queued occurrence admissible')
209
+ await f.runs.patch(queued.id,{status:'running'})
210
+ const staged=await f.scheduler.edit(changed,s.revision,list,true)
211
+ assert.deepEqual(staged.continuingRunIds,[queued.id])
212
+ // Admission and fallback advancement keep accepting the captured occurrence.
213
+ assert.equal(await f.scheduler.current(queued,f.owner),true)
214
+ const captured=(await f.runs.get(queued.id))!
215
+ assert.equal(captured.texts[1],'Do the work');assert.equal(captured.execution?.preset.model,'fixture-model')
216
+ assert.equal((await f.scheduler.listTasksReadOnly(await f.runs.list()))[0].runState,'running')
217
+ await f.scheduler.tick(f.owner,f.runs,f.now+600000)
218
+ assert.equal((await f.runs.list()).length,1,'the staged revision waits for the running occurrence')
219
+ await f.runs.patch(queued.id,{status:'completed'})
220
+ await f.scheduler.tick(f.owner,f.runs,f.now+600000)
221
+ const next=(await f.runs.list()).find(r=>r.id!==queued.id)!
222
+ assert.equal(next.scheduled?.revision,staged.revision)
223
+ assert.equal(next.texts[1],'New instructions');assert.equal(next.execution?.preset.model,'new-model')
224
+ await f.runs.patch(next.id,{status:'completed'})
225
+ const idle=await f.scheduler.edit({...changed,text:'Idle edit'},staged.revision,list)
226
+ assert.equal(idle.continuingRunIds,undefined)
227
+ assert.equal(await f.scheduler.current(queued,f.owner),false)
228
+ })
229
+ test('a dispatch never lands between an edit check and its write',async t=>{
230
+ const f=await fixture(t), s=await f.scheduler.save(f.input,true)
231
+ // The edit holds the lock while it reads the ledger: a concurrent tick skips the task.
232
+ const applied=await f.scheduler.edit({...f.input,text:'Edited'},s.revision,async()=>{
233
+ await f.scheduler.tick(f.owner,f.runs,f.now)
234
+ return f.runs.list()
235
+ })
236
+ assert.equal((await f.runs.list()).length,0)
237
+ await f.scheduler.tick(f.owner,f.runs,f.now)
238
+ const [run]=await f.runs.list()
239
+ assert.equal(run.scheduled?.revision,applied.revision);assert.equal(run.texts[1],'Edited')
240
+ assert.equal(await f.scheduler.current(run,f.owner),true)
241
+ // The dispatcher holds the lock while it queues: an edit started then sees the occurrence.
242
+ const g=await fixture(t), saved=await g.scheduler.save(g.input,true)
243
+ let editing:Promise<unknown>|undefined, staging:Promise<unknown>|undefined
244
+ const hooked=Object.assign(Object.create(g.runs) as RunStore,{create:async(...args:Parameters<RunStore['create']>)=>{
245
+ editing=g.scheduler.edit({...g.input,text:'Too late'},saved.revision,()=>g.runs.list())
246
+ staging=g.scheduler.edit({...g.input,text:'Staged'},saved.revision,()=>g.runs.list(),true)
247
+ return g.runs.create(...args)
248
+ }})
249
+ await g.scheduler.tick(g.owner,hooked,g.now)
250
+ const results=await Promise.allSettled([editing!,staging!])
251
+ const [dispatched]=await g.runs.list()
252
+ assert.equal(dispatched.scheduled?.revision,saved.revision)
253
+ assert.ok(results.some(r=>r.status==='rejected' && /queued or running|changed since revision/.test(String(r.reason))),'an unstaged edit cannot replace a busy task')
254
+ assert.equal(await g.scheduler.current(dispatched,g.owner),true,'the queued occurrence stays admissible')
255
+ })
256
+ test('concurrent dispatch, trigger and edit never leave an occurrence that admission would cancel',async t=>{
257
+ const f=await fixture(t), jitter=()=>new Promise(resolve=>setTimeout(resolve,Math.random()*4))
258
+ for(let i=0;i<30;i++){
259
+ const id=`race-${i}`, stage=i%2===1
260
+ const s=await f.scheduler.save({...f.input,id},true)
261
+ const [,,edited]=await Promise.allSettled([
262
+ jitter().then(()=>f.scheduler.tick(f.owner,f.runs,f.now)),
263
+ jitter().then(()=>f.scheduler.trigger(id,s.revision,`key-${i}`,f.owner,f.runs)),
264
+ jitter().then(()=>f.scheduler.edit({...f.input,id,text:`Edited ${i}`},s.revision,()=>f.runs.list(),stage)),
265
+ ])
266
+ const occurrences=(await f.runs.list()).filter(r=>r.scheduled?.id===id)
267
+ assert.ok(occurrences.length<=1,'one queued or running occurrence per task')
268
+ for(const run of occurrences)assert.equal(await f.scheduler.current(run,f.owner),true,`${id} ${run.scheduled?.revision} would be cancelled`)
269
+ if(edited.status==='rejected')assert.match(String(edited.reason),/queued or running/)
270
+ else if(!stage)assert.ok(occurrences.every(r=>r.scheduled?.revision===edited.value.revision))
271
+ for(const run of occurrences)await f.runs.patch(run.id,{status:'completed'})
272
+ }
273
+ assert.deepEqual((await readdir(join(f.dir,'schedules'))).filter(name=>name.endsWith('.lock')),[])
274
+ })
275
+ test('a lock left by a crashed writer expires; a live one makes dispatch wait',async t=>{
276
+ const f=await fixture(t), s=await f.scheduler.save(f.input,true), lock=join(f.dir,'schedules',`${s.id}.lock`)
277
+ await writeFile(lock,'')
278
+ await f.scheduler.tick(f.owner,f.runs,f.now)
279
+ assert.equal((await f.runs.list()).length,0)
280
+ const old=new Date(Date.now()-60000);await utimes(lock,old,old)
281
+ await f.scheduler.tick(f.owner,f.runs,f.now)
282
+ assert.equal((await f.runs.list()).length,1)
283
+ await assert.rejects(stat(lock),{code:'ENOENT'})
284
+ })
285
+ test('a holder whose lock expired never releases the next holder',async t=>{
286
+ const f=await fixture(t), lock=(f.scheduler as unknown as {lock:(id:string)=>Promise<()=>Promise<void>>}).lock.bind(f.scheduler)
287
+ const file=join(f.dir,'schedules/test.lock'), first=await lock('test')
288
+ const old=new Date(Date.now()-60000);await utimes(file,old,old)
289
+ const second=await lock('test')
290
+ await first();await stat(file)
291
+ await second();await assert.rejects(stat(file),{code:'ENOENT'})
292
+ })
293
+ test('an occurrence of a removed task does not make its recreated task busy',async t=>{
294
+ const f=await fixture(t), s=await f.scheduler.save(f.input,true)
295
+ await f.scheduler.tick(f.owner,f.runs,f.now)
296
+ const [old]=await f.runs.list()
297
+ await f.scheduler.remove(s.id)
298
+ const again=await f.scheduler.save(f.input,true)
299
+ const edited=await f.scheduler.edit({...f.input,text:'Recreated'},again.revision,()=>f.runs.list(),true)
300
+ assert.equal(edited.continuingRunIds,undefined)
301
+ assert.equal(await f.scheduler.current(old,f.owner),false,'the removed task occurrence stays non-current')
302
+ })
303
+ test('continuing occurrences must be valid record identifiers',async t=>{
304
+ const f=await fixture(t), s=await f.scheduler.save(f.input,true)
305
+ await writeFile(join(f.dir,'schedules/test.json'),JSON.stringify({...s,continuingRunIds:['../escape']}))
306
+ await assert.rejects(f.scheduler.get('test'),/Invalid schedule record/)
307
+ })
@@ -104,7 +104,7 @@ test('agent registers and schedules a script natively; script runs cannot change
104
104
  await f.runs.patch(scriptRun.id, { status: 'running' })
105
105
  const asScript = { env: { ...env, EZ_RUN_ID: scriptRun.id }, cwd: f.workspace }
106
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']])
107
+ ['edit', 'actual-book-freshness-guard', '--expected-revision', schedule.revision, '--script', 'book-freshness', '--now'], ['pause', 'daily'], ['trigger', 'daily', '--key', 'k']])
108
108
  await assert.rejects(exec(process.execPath, [bin, ...args], asScript), /Script runs cannot change scripts or schedules/)
109
109
  assert.equal(JSON.parse((await exec(process.execPath, [bin, 'script', 'show', 'book-freshness'], asScript)).stdout).id, 'book-freshness')
110
110
  // A relative --file resolves from the caller's directory, stored relative to the workspace.
@@ -123,9 +123,9 @@ test('script CLI creates, preserves and explicitly clears preflight gates', asyn
123
123
  const options=['--script','guard','--at','2027-09-09T09:00:00+04:00']
124
124
  const created=await cli('create','gated',...options,'--preflight-file',file)
125
125
  assert.deepEqual(created.preflight,preflight)
126
- const edited=await cli('edit','gated',...options,'--name','Renamed')
126
+ const edited=await cli('edit','gated','--expected-revision',created.revision,...options,'--name','Renamed')
127
127
  assert.deepEqual(edited.preflight,preflight)
128
- const cleared=await cli('edit','gated',...options,'--clear-preflight')
128
+ const cleared=await cli('edit','gated','--expected-revision',edited.revision,...options,'--clear-preflight')
129
129
  assert.equal(cleared.preflight,undefined)
130
130
  })
131
131