@jc_stack/ez-agents 0.1.0-beta.18 → 0.1.0-beta.21
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 +5 -0
- package/AGENTS.md +6 -0
- package/CHANGELOG.md +30 -0
- package/CONTRIBUTING.md +29 -3
- package/Dockerfile +6 -0
- package/README.md +8 -2
- package/bin/ezenciel-agents-watch.mjs +8 -0
- package/compose.workforce-watch.yaml +33 -0
- package/docker/healthcheck.mjs +11 -4
- package/docs/architecture/ai-selection.md +18 -9
- package/docs/docker-runtime.md +7 -5
- package/docs/plugin-catalog.md +1 -0
- package/docs/plugins.md +34 -0
- package/docs/responsive-channels.md +57 -0
- package/docs/scheduling.md +6 -4
- package/docs/setup.md +10 -6
- package/docs/workforce-watch.md +101 -0
- package/package.json +4 -2
- package/src/agent-guidance.ts +4 -0
- package/src/ai.ts +14 -6
- package/src/control-state.ts +5 -3
- package/src/desktop-bridge.ts +4 -2
- package/src/executor.ts +4 -2
- package/src/host-executor-client.ts +7 -1
- package/src/index.ts +58 -18
- package/src/menu.ts +18 -12
- package/src/model-policy.ts +8 -5
- package/src/plugins/manager.mjs +70 -2
- package/src/reply-context.ts +7 -3
- package/src/reply-executor.ts +2 -1
- package/src/reply-mcp.ts +1 -1
- package/src/runs.ts +0 -13
- package/src/schedule-cli.ts +1 -1
- package/src/scheduled-tasks.ts +33 -0
- package/src/scheduler.ts +11 -2
- package/src/setup.ts +2 -2
- package/src/task-executor.ts +2 -1
- package/src/updates/runtime.mjs +15 -2
- package/src/workforce-watch-cli.ts +14 -0
- package/src/workforce-watch.ts +155 -0
- package/templates/agent-guidance.md +24 -0
- package/templates/chat-guidance.md +23 -0
- package/test/agent-guidance.test.ts +28 -0
- package/test/ai.test.ts +80 -1
- package/test/event-sources.test.ts +4 -0
- package/test/failure.test.ts +40 -8
- package/test/host-executor.test.ts +16 -0
- package/test/intake-relay.test.ts +15 -3
- package/test/model-policy.test.ts +9 -1
- package/test/plugin-manager.test.mjs +49 -0
- package/test/reply.test.ts +22 -0
- package/test/runs.test.ts +7 -0
- package/test/schedule-cli.test.ts +2 -0
- package/test/scheduled-tasks.test.ts +43 -0
- package/test/updates.test.mjs +15 -0
- package/test/workforce-watch.test.ts +180 -0
package/test/failure.test.ts
CHANGED
|
@@ -17,7 +17,10 @@ import { TelegramSource } from '../src/telegram-source.js'
|
|
|
17
17
|
import { packageVersion } from '../src/version.js'
|
|
18
18
|
import type { Update } from 'grammy/types'
|
|
19
19
|
const exec=promisify(execFile),bin=fileURLToPath(new URL('../bin/ezenciel-agents-schedule.mjs',import.meta.url))
|
|
20
|
-
|
|
20
|
+
// This integration test starts subprocesses, persists their receipts, and invokes
|
|
21
|
+
// the schedule CLI. Give a busy CI worker time to settle without changing the
|
|
22
|
+
// production recovery deadline.
|
|
23
|
+
const until=async(check:()=>Promise<boolean>,timeoutMs=15_000)=>{const deadline=Date.now()+timeoutMs;while(!(await check())){if(Date.now()>=deadline)throw new Error(`Timed out after ${timeoutMs}ms`);await new Promise(r=>setTimeout(r,20))}}
|
|
21
24
|
|
|
22
25
|
test('failure evidence is bounded and redacts configured credentials, headers, tokens, URLs and keys',()=>{
|
|
23
26
|
const token='123456789:abcdefghijklmnopqrstuvwxyz123456789'
|
|
@@ -137,7 +140,30 @@ test('relay shutdown waits for executor cleanup and final run state', async () =
|
|
|
137
140
|
} finally {release();await relay.stop();await rm(dir,{recursive:true,force:true})}
|
|
138
141
|
})
|
|
139
142
|
|
|
140
|
-
|
|
143
|
+
test('a fresh relay initializes its bot identity before polling', async () => {
|
|
144
|
+
const dir=await mkdtemp(join(tmpdir(),'ez-cold-start-'))
|
|
145
|
+
const relay=createRelay({workspace:dir,controlDir:dir,pairingTtlMs:1000,executorTimeoutMs:0,executorCli:'grok',telegramBotToken:'fixture'},async()=>{throw new Error('No executor expected')})
|
|
146
|
+
const methods:string[]=[]
|
|
147
|
+
relay.bot.api.config.use(async(_prev,method,_payload,signal)=>{
|
|
148
|
+
methods.push(method)
|
|
149
|
+
if(method==='getMe') return {ok:true,result:{id:999,is_bot:true,first_name:'Fixture',username:'fixture_bot'}} as any
|
|
150
|
+
if(method==='getUpdates') {
|
|
151
|
+
if(signal && !signal.aborted) await new Promise<void>(resolve=>signal.addEventListener('abort',()=>resolve(),{once:true}))
|
|
152
|
+
return {ok:true,result:[]} as any
|
|
153
|
+
}
|
|
154
|
+
return {ok:true,result:true} as any
|
|
155
|
+
})
|
|
156
|
+
const started=relay.start()
|
|
157
|
+
try {
|
|
158
|
+
await until(async()=>methods.includes('getUpdates'))
|
|
159
|
+
assert.equal(relay.bot.isRunning(),true)
|
|
160
|
+
assert.equal(methods.filter(method=>method==='getMe').length,1)
|
|
161
|
+
assert.ok(methods.indexOf('getMe')<methods.indexOf('getUpdates'))
|
|
162
|
+
} finally {await relay.stop();await started;await rm(dir,{recursive:true,force:true})}
|
|
163
|
+
assert.equal(relay.bot.isRunning(),false)
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
for (const cleanupFails of [false,true]) test(`polling conflict preserves work until an explicit shutdown (cleanup fails: ${cleanupFails})`, async t => {
|
|
141
167
|
const dir=await mkdtemp(join(tmpdir(),'ez-polling-conflict-')),control=new ControlStore(dir,1000),runs=new RunStore(dir)
|
|
142
168
|
let release!:()=>void,entered!:()=>void,releaseDelivery!:()=>void,sending!:()=>void,child:ReturnType<typeof spawn>|undefined,polls=0
|
|
143
169
|
const gate=new Promise<void>(resolve=>{release=resolve}),cleaning=new Promise<void>(resolve=>{entered=resolve})
|
|
@@ -170,20 +196,26 @@ for (const cleanupFails of [false,true]) test(`fatal polling conflict waits for
|
|
|
170
196
|
const delivery=relay.drainOutbox()
|
|
171
197
|
await deliveryStarted
|
|
172
198
|
let finished=false
|
|
173
|
-
const start=
|
|
174
|
-
await
|
|
199
|
+
const start=relay.start().finally(()=>{finished=true})
|
|
200
|
+
await until(async()=>polls===1)
|
|
201
|
+
assert.equal(sourceStops,0,'a polling conflict must not stop the relay')
|
|
202
|
+
assert.equal((await runs.get('tg_92'))?.status,'running')
|
|
203
|
+
assert.ok(child && child.exitCode===null && child.signalCode===null)
|
|
175
204
|
const stopping=relay.stop()
|
|
176
205
|
assert.equal(relay.stop(),stopping,'concurrent stop calls share one promise')
|
|
177
206
|
const stopped=cleanupFails?assert.rejects(stopping,/Synthetic shutdown failure/):stopping
|
|
178
|
-
assert.equal(finished,false,'
|
|
207
|
+
assert.equal(finished,false,'relay start must wait for the explicit shutdown')
|
|
208
|
+
await cleaning
|
|
179
209
|
release()
|
|
180
210
|
await until(async()=>(await runs.get('tg_92'))?.status!=='running')
|
|
181
|
-
assert.equal(finished,false,'
|
|
182
|
-
releaseDelivery();await delivery
|
|
211
|
+
assert.equal(finished,false,'shutdown must wait for the in-flight delivery receipt')
|
|
212
|
+
releaseDelivery();await delivery
|
|
213
|
+
if(cleanupFails) await assert.rejects(start,/Synthetic shutdown failure/);else await start
|
|
214
|
+
await stopped
|
|
183
215
|
assert.equal(relay.stop(),stopping,'finished shutdown remains idempotent')
|
|
184
216
|
assert.equal(sourceStops,1)
|
|
185
217
|
assert.deepEqual(JSON.parse(await readFile(join(dir,'outbox',`${item.id}.sent.json`),'utf8')).receipt.messageIds,[1])
|
|
186
|
-
assert.equal(polls,1,'a conflict must not start another polling loop')
|
|
218
|
+
assert.equal(polls,1,'a conflict must not start another polling loop before the retry delay')
|
|
187
219
|
assert.equal(relay.bot.isRunning(),false)
|
|
188
220
|
assert.ok(child && (child.exitCode!==null || child.signalCode!==null))
|
|
189
221
|
assert.notEqual((await runs.get('tg_92'))?.status,'running')
|
|
@@ -160,6 +160,22 @@ test('client tolerates missing heartbeat and consumes completion before checking
|
|
|
160
160
|
} finally {client.kill();await closed;await rm(root,{recursive:true,force:true})}
|
|
161
161
|
})
|
|
162
162
|
|
|
163
|
+
test('client records a relay interruption before exiting 130',async()=>{
|
|
164
|
+
const root=await mkdtemp(path.join(tmpdir(),'ez-host-interrupt-'))
|
|
165
|
+
const directory=path.join(root,'host-executor');await mkdir(directory)
|
|
166
|
+
const client=spawn(process.execPath,['--import',fileURLToPath(new URL('../node_modules/tsx/dist/loader.mjs',import.meta.url)),fileURLToPath(new URL('../src/host-executor-client.ts',import.meta.url)),root,'tg_97'],{stdio:['pipe','pipe','pipe']})
|
|
167
|
+
let stderr='';client.stderr.on('data',chunk=>stderr+=chunk);client.stdout.resume()
|
|
168
|
+
client.stdin.end(JSON.stringify({texts:['test'],options:{}}))
|
|
169
|
+
try {
|
|
170
|
+
for(let n=0;n<100;n++){try{await readFile(path.join(directory,'tg_97.request.json'));break}catch{await new Promise(r=>setTimeout(r,20))}}
|
|
171
|
+
const closed=new Promise<number|null>(resolve=>client.once('close',resolve))
|
|
172
|
+
client.kill('SIGTERM')
|
|
173
|
+
assert.equal(await closed,130)
|
|
174
|
+
assert.equal(await readFile(path.join(directory,'tg_97.cancel'),'utf8'),'')
|
|
175
|
+
assert.match(stderr,/Host executor client interrupted by SIGTERM/)
|
|
176
|
+
} finally {if(client.exitCode===null && client.signalCode===null)client.kill();await rm(root,{recursive:true,force:true})}
|
|
177
|
+
})
|
|
178
|
+
|
|
163
179
|
test('client cancels on stale or invalid heartbeat instead of waiting indefinitely',async()=>{
|
|
164
180
|
for(const heartbeat of [{at:Date.now()-60000},{at:'invalid'}]) {
|
|
165
181
|
const root=await mkdtemp(path.join(tmpdir(),'ez-heartbeat-invalid-'))
|
|
@@ -201,7 +201,7 @@ test('owner group discovery routes only to the private chat and rechecks identit
|
|
|
201
201
|
} finally { await f.close() }
|
|
202
202
|
})
|
|
203
203
|
|
|
204
|
-
test('four-item menu is owner-only;
|
|
204
|
+
test('four-item menu is owner-only; available AI choices work and forged/stale buttons cannot change settings', async () => {
|
|
205
205
|
const f = await fixture()
|
|
206
206
|
const callback = (id: number, data: string, user = 101): Update => ({
|
|
207
207
|
update_id: id,
|
|
@@ -220,11 +220,23 @@ test('four-item menu is owner-only; saved AI buttons work and forged/stale butto
|
|
|
220
220
|
await f.relay.bot.handleUpdate(callback(4, 'ai:forged'))
|
|
221
221
|
assert.equal(await store.getActiveSession(), null)
|
|
222
222
|
await f.relay.bot.handleUpdate(callback(5, pick))
|
|
223
|
+
if (!(await store.getActiveSession())) {
|
|
224
|
+
let useNow = f.keyboards.at(-1)!.flat().find((button) => button.text === 'Use now')?.callback_data
|
|
225
|
+
if (!useNow) {
|
|
226
|
+
await f.relay.bot.handleUpdate(callback(6, f.keyboards.at(-1)!.flat()[0].callback_data))
|
|
227
|
+
useNow = f.keyboards.at(-1)!.flat().find((button) => button.text === 'Use now')!.callback_data
|
|
228
|
+
}
|
|
229
|
+
await f.relay.bot.handleUpdate(callback(7, useNow))
|
|
230
|
+
}
|
|
223
231
|
assert.ok(await store.getActiveSession())
|
|
224
|
-
await f.relay.bot.handleUpdate(callback(
|
|
232
|
+
await f.relay.bot.handleUpdate(callback(8, pick))
|
|
225
233
|
assert.match(f.replies.at(-1)!, /Menu expired/)
|
|
226
|
-
await f.relay.bot.handleUpdate(message(
|
|
234
|
+
await f.relay.bot.handleUpdate(message(9, '/settings'))
|
|
227
235
|
assert.match(f.replies.at(-1)!, /Default for new conversations/)
|
|
236
|
+
await f.relay.bot.handleUpdate(message(8, '/status'))
|
|
237
|
+
assert.ok(f.keyboards.at(-1)!.flat().some((button) => button.text === 'Scheduled tasks'))
|
|
238
|
+
await f.relay.bot.handleUpdate(callback(9, 'menu:scheduled-tasks'))
|
|
239
|
+
assert.match(f.replies.at(-1)!, /No scheduled tasks for this owner/)
|
|
228
240
|
assert.equal(f.launched.length, 0)
|
|
229
241
|
} finally { await f.close() }
|
|
230
242
|
})
|
|
@@ -11,7 +11,7 @@ import { taskArguments } from '../src/task-executor.js'
|
|
|
11
11
|
import { runCodexSession } from '../src/codex-session.js'
|
|
12
12
|
import { runDesktopTurn } from '../src/desktop-bridge.js'
|
|
13
13
|
|
|
14
|
-
test('all model selections and launches reject effort above high before spawning', async () => {
|
|
14
|
+
test('all non-Luna model selections and launches reject effort above high before spawning', async () => {
|
|
15
15
|
for (const cli of ['codex', 'codex-gui', 'grok', 'claude', 'opencode', 'agy']) {
|
|
16
16
|
for (const effort of ['xhigh', 'max', 'ultra', 'unknown']) {
|
|
17
17
|
const preset = { id:'blocked', name:'Blocked', cli, model:'any-model', effort }
|
|
@@ -23,6 +23,14 @@ test('all model selections and launches reject effort above high before spawning
|
|
|
23
23
|
await assert.rejects(runDesktopTurn({workspace:'/unused',controlDir:'/unused',binDir:'/unused',runId:'unused',prompt:'',effort:'ultra'}), /capped at high/)
|
|
24
24
|
})
|
|
25
25
|
|
|
26
|
+
test('Codex Luna accepts xhigh while every other model and CLI remains capped', async () => {
|
|
27
|
+
const luna = { id:'luna', name:'Luna', cli:'codex', model:'gpt-5.6-luna', effort:'xhigh' }
|
|
28
|
+
await validateSelection(luna, [{ cli:'codex', model:'gpt-5.6-luna', name:'Luna', efforts:['high','xhigh'] }], async () => true)
|
|
29
|
+
assert.deepEqual(executionDefaults('codex', { model:'gpt-5.6-luna', effort:'xhigh' }), { model:'gpt-5.6-luna', effort:'xhigh' })
|
|
30
|
+
assert.throws(() => executionDefaults('grok', { model:'gpt-5.6-luna', effort:'xhigh' }), /capped at high/)
|
|
31
|
+
assert.throws(() => executionDefaults('codex', { model:'gpt-5.6-terra', effort:'xhigh' }), /capped at high/)
|
|
32
|
+
})
|
|
33
|
+
|
|
26
34
|
test('restricted tasks pin Terra high, preserve explicit choices and reject higher effort', () => {
|
|
27
35
|
const args = taskArguments('/unused', ['broker'], 'prompt')
|
|
28
36
|
assert.equal(args[args.indexOf('--model')+1], 'gpt-5.6-terra')
|
|
@@ -225,3 +225,52 @@ test('standalone rejects relay binding and preserves literal plugin arguments ac
|
|
|
225
225
|
await fs.writeFile(path.join(f.home,'config.json'),JSON.stringify({schemaVersion:1,workspace:f.workspace,catalog:{},deploymentDir:'/missing'}));
|
|
226
226
|
await assert.rejects(exec(launcher,['status']),/deployment-bound/); // never hide a broken relay binding
|
|
227
227
|
});
|
|
228
|
+
|
|
229
|
+
test('existing folders are read-only, persistent and fail closed when missing', async t => {
|
|
230
|
+
const f=await fixture(t);await init(f.home,f.workspace);
|
|
231
|
+
const inspected=JSON.parse((await f.call('plugins','inspect','sample','--source',f.source)).stdout);
|
|
232
|
+
await f.call('plugins','install','sample','--source',f.source,'--revision',inspected.revision);
|
|
233
|
+
const source=await fs.realpath(f.workspace);
|
|
234
|
+
await f.call('plugins','folder-bind','sample','--service','sample','--source',source,'--target','/data/files');
|
|
235
|
+
const config=JSON.parse(await fs.readFile(path.join(f.home,'config.json'),'utf8'));
|
|
236
|
+
assert.deepEqual(config.folders.sample,[{service:'sample',source,target:'/data/files'}]);
|
|
237
|
+
const c=JSON.parse(await fs.readFile(path.join(f.home,'packages/sample/compose.json'),'utf8'));
|
|
238
|
+
assert.deepEqual(c.services.sample.volumes.find(v=>v.target==='/data/files'),{type:'bind',source,target:'/data/files',read_only:true,bind:{create_host_path:false}});
|
|
239
|
+
await assert.rejects(f.call('plugins','folder-bind','sample','--service','sample','--source',source,'--target','/data'),/child of a declared volume|Overlapping/);
|
|
240
|
+
await assert.rejects(f.call('plugins','folder-bind','sample','--service','sample','--source',source,'--target','/data/files/child'),/Overlapping/);
|
|
241
|
+
await assert.rejects(f.call('plugins','folder-bind','sample','--service','sample','--source',await fs.realpath(f.home),'--target','/data/private'),/private plugin state/);
|
|
242
|
+
await assert.rejects(f.call('plugins','folder-bind','sample','--service','sample','--source','/','--target','/data/root'),/private plugin state/);
|
|
243
|
+
await f.call('plugins','start','sample');
|
|
244
|
+
await fs.rename(f.workspace,f.workspace+'-moved');
|
|
245
|
+
await assert.rejects(f.call('plugins','start','sample'),/ENOENT/);
|
|
246
|
+
await assert.rejects(f.call('sample','read'),/ENOENT/);
|
|
247
|
+
await f.call('plugins','folder-unbind','sample','--service','sample','--target','/data/files');
|
|
248
|
+
assert.deepEqual(JSON.parse((await f.call('plugins','folders','sample')).stdout),[]);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
test('folder bindings survive compatible descriptors and reject incompatible updates',async t=>{
|
|
252
|
+
const f=await fixture(t);const p=await snapshot(f.source);
|
|
253
|
+
const record={...p,project:'ezp-test-sample'};
|
|
254
|
+
const config={workspace:f.workspace,folders:{sample:[{service:'sample',source:f.workspace,target:'/data/files'}]}};
|
|
255
|
+
assert.equal(compose(config,record).services.sample.volumes.find(v=>v.target==='/data/files').read_only,true);
|
|
256
|
+
const next=structuredClone(record);next.deployment.services.sample.volumes={data:'/new-state'};
|
|
257
|
+
assert.throws(()=>compose(config,next),/child of a declared volume/);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
test('folder rebind rejects every live project container including one-shots',async t=>{
|
|
261
|
+
const f=await fixture(t);await init(f.home,f.workspace);const p=await snapshot(f.source);
|
|
262
|
+
await f.call('plugins','install','sample','--source',f.source,'--revision',p.revision);
|
|
263
|
+
await fs.writeFile(path.join(f.fake,'docker'),`#!${process.execPath}\nif(process.argv[2]==='ps')console.log('paused-or-restarting-or-one-shot');\n`,{mode:0o700});
|
|
264
|
+
await assert.rejects(f.call('plugins','folder-bind','sample','--service','sample','--source',await fs.realpath(f.workspace),'--target','/data/files'),/Stop the plugin/);
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
test('registered calls honor registry lock and regenerate stale Compose from current folders',async t=>{
|
|
268
|
+
const f=await fixture(t);await init(f.home,f.workspace);const p=await snapshot(f.source);
|
|
269
|
+
await f.call('plugins','install','sample','--source',f.source,'--revision',p.revision);
|
|
270
|
+
await f.call('plugins','folder-bind','sample','--service','sample','--source',await fs.realpath(f.workspace),'--target','/data/files');
|
|
271
|
+
const file=path.join(f.home,'packages/sample/compose.json');await fs.writeFile(file,'{}');
|
|
272
|
+
await f.call('sample','read');
|
|
273
|
+
assert.equal(JSON.parse(await fs.readFile(file,'utf8')).services.sample.volumes.find(v=>v.target==='/data/files').read_only,true);
|
|
274
|
+
await fs.writeFile(path.join(f.home,'registry.lock'),'test');
|
|
275
|
+
await assert.rejects(f.call('sample','read'),/busy|EEXIST|locked/i);
|
|
276
|
+
});
|
package/test/reply.test.ts
CHANGED
|
@@ -129,3 +129,25 @@ test('a parallel reply delivered during a normal turn is retained for the follow
|
|
|
129
129
|
assert.deepEqual(await parallelReplyHistory(root,current),[])
|
|
130
130
|
}finally{await rm(root,{recursive:true,force:true})}
|
|
131
131
|
})
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
test('reply handoff accepts independent worker choices and rejects invalid or unauthorized overrides', async () => {
|
|
135
|
+
const root=await mkdtemp(join(tmpdir(),'ez-reply-worker-')), runs=new RunStore(root)
|
|
136
|
+
try {
|
|
137
|
+
await ownerRun(root,'owner')
|
|
138
|
+
await runs.create({id:'tg_10',chatId:101,telegramUserId:101,texts:['Analyze the report'],execution:{sessionId:'c5dd1edc-be24-47b8-a579-0bc70f44cf43',preset:{id:'chat',name:'Chat',cli:'codex',model:'gpt-5.6-sol',effort:'medium'}}})
|
|
139
|
+
await runs.patch('tg_10',{status:'running',replyOnly:true})
|
|
140
|
+
for (const args of [{model:42}, {model:'bad model'}, {effort:'ultra'}, {effort:'invalid'}, {cli:'claude'}])
|
|
141
|
+
await assert.rejects(replyCall(root,'tg_10',root,'defer',{text:'Analyze and verify the result',...args}))
|
|
142
|
+
await assert.rejects(replyCall(root,'tg_10',root,'send',{text:'Hello',model:'gpt-6-astra'}),/Unexpected/)
|
|
143
|
+
await replyCall(root,'tg_10',root,'defer',{text:'Analyze and verify the result',model:'gpt-6-astra',effort:'high'})
|
|
144
|
+
const file=join(root,'schedules','s_reply_tg_10.json')
|
|
145
|
+
const saved=JSON.parse(await readFile(file,'utf8'))
|
|
146
|
+
assert.equal(saved.execution.preset.model,'gpt-6-astra')
|
|
147
|
+
assert.equal(saved.execution.preset.effort,'high')
|
|
148
|
+
await replyCall(root,'tg_10',root,'defer',{text:'retry',model:'gpt-5.6-sol',effort:'low'})
|
|
149
|
+
assert.deepEqual(JSON.parse(await readFile(file,'utf8')),saved)
|
|
150
|
+
await new ControlStore(root,900000).revokeOwner()
|
|
151
|
+
await assert.rejects(replyCall(root,'tg_10',root,'defer',{text:'after revocation',model:'gpt-6-astra'}),/owner-mismatch/)
|
|
152
|
+
} finally { await rm(root,{recursive:true,force:true}) }
|
|
153
|
+
})
|
package/test/runs.test.ts
CHANGED
|
@@ -22,6 +22,13 @@ test('creates a queued run and binds chat id outside the workspace', async () =>
|
|
|
22
22
|
assert.equal((await store.get(created.id))?.chatId, 101)
|
|
23
23
|
}))
|
|
24
24
|
|
|
25
|
+
test('running does not reap a process from another executor namespace', async () => fixture(async (store) => {
|
|
26
|
+
const created = await store.create({ chatId: 101, telegramUserId: 101, texts: ['host-backed'] })
|
|
27
|
+
await store.patch(created.id, { status: 'running', pid: 999_999_999 })
|
|
28
|
+
assert.equal((await store.running())?.id, created.id)
|
|
29
|
+
assert.equal((await store.get(created.id))?.status, 'running')
|
|
30
|
+
}))
|
|
31
|
+
|
|
25
32
|
test('ez message writes an outbox item for the bound run, not a telegram send', async () => fixture(async (store) => {
|
|
26
33
|
const created = await store.create({ chatId: 9, telegramUserId: 9, texts: ['hello'] })
|
|
27
34
|
await store.patch(created.id, { status: 'running' })
|
|
@@ -24,6 +24,8 @@ test('public scheduler CLI saves literal text, reads back, edits, pauses, and re
|
|
|
24
24
|
assert.equal(saved.text,args.at(-1));assert.equal(saved.execution.preset.cli,'codex')
|
|
25
25
|
assert.equal(saved.execution.preset.model,'gpt-5.6-terra');assert.equal(saved.execution.preset.effort,'high')
|
|
26
26
|
await assert.rejects(exec(process.execPath,[bin,'create','blocked','--at','2027-09-09T09:00:00+04:00','--text','test','--effort','xhigh'],{env}),/capped at high/)
|
|
27
|
+
const luna=JSON.parse((await exec(process.execPath,[bin,'create','luna','--at','2027-09-10T09:00:00+04:00','--text','Luna xhigh task','--model','gpt-5.6-luna','--effort','xhigh'],{env})).stdout)
|
|
28
|
+
assert.equal(luna.execution.preset.model,'gpt-5.6-luna');assert.equal(luna.execution.preset.effort,'xhigh')
|
|
27
29
|
assert.equal(saved.nextEligibleAt,'2027-09-09T05:00:00.000Z')
|
|
28
30
|
await assert.rejects(exec(process.execPath,[bin,...args],{env}),/exists/)
|
|
29
31
|
assert.equal(JSON.parse((await exec(process.execPath,[bin,'pause','test'],{env})).stdout).enabled,false)
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import test from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { access, mkdtemp, readFile, readdir, rm } from 'node:fs/promises'
|
|
4
|
+
import { randomUUID } from 'node:crypto'
|
|
5
|
+
import { join } from 'node:path'
|
|
6
|
+
import { tmpdir } from 'node:os'
|
|
7
|
+
import { Scheduler } from '../src/scheduler.js'
|
|
8
|
+
import { scheduledTasksText } from '../src/scheduled-tasks.js'
|
|
9
|
+
|
|
10
|
+
const owner = { telegramUserId: 101, telegramChatId: 101, pairedAt: '2026-09-11T00:00:00.000Z' }
|
|
11
|
+
const execution = { sessionId: randomUUID(), preset: { id: 'fixture', name: 'Fixture', cli: 'codex' } }
|
|
12
|
+
|
|
13
|
+
test('scheduled task view is read-only, owner-bound, and shows stored task contents', async (t) => {
|
|
14
|
+
const dir = await mkdtemp(join(tmpdir(), 'ez-scheduled-tasks-'))
|
|
15
|
+
t.after(() => rm(dir, { recursive: true, force: true }))
|
|
16
|
+
const scheduler = new Scheduler(dir)
|
|
17
|
+
|
|
18
|
+
assert.deepEqual(await scheduler.listReadOnly(), [])
|
|
19
|
+
await assert.rejects(access(join(dir, 'schedules')), /ENOENT/)
|
|
20
|
+
|
|
21
|
+
await scheduler.save({
|
|
22
|
+
id: 'owner-task', name: 'Daily report', text: 'Read the ledger and send the owner a concise report.', owner, execution, enabled: true,
|
|
23
|
+
trigger: { cron: '0 9 * * 1-5', timezone: 'Asia/Dubai', start: '2026-01-01T00:00:00.000Z' },
|
|
24
|
+
})
|
|
25
|
+
await scheduler.save({
|
|
26
|
+
id: 'other-task', name: 'Other owner task', text: 'This must never be visible.',
|
|
27
|
+
owner: { ...owner, telegramUserId: 202, telegramChatId: 202 }, execution, enabled: true,
|
|
28
|
+
trigger: { at: '2027-01-01T00:00:00.000Z' },
|
|
29
|
+
})
|
|
30
|
+
const scheduleDir = join(dir, 'schedules')
|
|
31
|
+
const before = await readFile(join(scheduleDir, 'owner-task.json'), 'utf8')
|
|
32
|
+
const entries = await readdir(scheduleDir)
|
|
33
|
+
const text = scheduledTasksText(await scheduler.listReadOnly(), owner, Date.parse('2026-09-11T00:00:00.000Z'))
|
|
34
|
+
|
|
35
|
+
assert.match(text, /Title: Daily report/)
|
|
36
|
+
assert.match(text, /Instructions:\nRead the ledger and send the owner a concise report\./)
|
|
37
|
+
assert.match(text, /Timing: Cron 0 9 \* \* 1-5 · Asia\/Dubai/)
|
|
38
|
+
assert.match(text, /State: Scheduled/)
|
|
39
|
+
assert.match(text, /Next run: 2026-09-11T05:00:00.000Z/)
|
|
40
|
+
assert.doesNotMatch(text, /Other owner task|This must never be visible/)
|
|
41
|
+
assert.equal(await readFile(join(scheduleDir, 'owner-task.json'), 'utf8'), before)
|
|
42
|
+
assert.deepEqual(await readdir(scheduleDir), entries)
|
|
43
|
+
})
|
package/test/updates.test.mjs
CHANGED
|
@@ -86,6 +86,16 @@ test('status distinguishes installed, running, legacy and stale main versions wi
|
|
|
86
86
|
const result=await exec(process.execPath,[new URL('../bin/ezenciel-agents-tools.mjs',import.meta.url).pathname,'--home',f.home,'status']);
|
|
87
87
|
assert.equal(JSON.parse(result.stdout).main.installedVersion,'0.1.0');
|
|
88
88
|
});
|
|
89
|
+
test('relay healthcheck emits bounded predicate evidence without state contents',async t=>{
|
|
90
|
+
const root=await fs.realpath(await fs.mkdtemp(path.join(tmpdir(),'ez-healthcheck-')));t.after(()=>fs.rm(root,{recursive:true,force:true}));
|
|
91
|
+
const relay=path.join(root,'relay'),host=path.join(root,'host');await fs.mkdir(relay,{recursive:true});await fs.mkdir(path.join(host,'host-executor'),{recursive:true});
|
|
92
|
+
const check=async()=>exec(process.execPath,[new URL('../docker/healthcheck.mjs',import.meta.url).pathname],{env:{...process.env,EZ_HEALTH_RELAY_CONTROL_DIR:relay,EZ_EXECUTOR_TRANSPORT:'host',EZ_CONTROL_DIR:host}});
|
|
93
|
+
await fs.writeFile(path.join(relay,'heartbeat.json'),JSON.stringify({polling:true,at:Date.now()}));await fs.writeFile(path.join(host,'host-executor/heartbeat.json'),JSON.stringify({at:Date.now()}));await check();
|
|
94
|
+
await fs.writeFile(path.join(relay,'heartbeat.json'),JSON.stringify({polling:false,at:Date.now(),private:'must-not-appear'}));
|
|
95
|
+
await assert.rejects(check(),error=>{assert.match(error.stderr,/EZ_HEALTH_RELAY_NOT_POLLING/);assert.doesNotMatch(error.stderr,/must-not-appear/);return true;});
|
|
96
|
+
await fs.writeFile(path.join(relay,'heartbeat.json'),JSON.stringify({polling:true,at:Date.now()}));await fs.writeFile(path.join(host,'host-executor/heartbeat.json'),JSON.stringify({at:Date.now()-20000}));
|
|
97
|
+
await assert.rejects(check(),error=>{assert.match(error.stderr,/EZ_HEALTH_HOST_STALE/);return true;});
|
|
98
|
+
});
|
|
89
99
|
test('plugin status verifies images and reports stopped, mismatched and unreachable runtimes honestly',async t=>{
|
|
90
100
|
const f=await fixture(t,'plugin'),id='a'.repeat(64),image='sha256:'+'b'.repeat(64);
|
|
91
101
|
for(const mode of ['running','ndjson','stopped','missing','mismatched','offline']) {
|
|
@@ -157,6 +167,11 @@ test('failed preparation never stops runtime; failed activation rolls back code
|
|
|
157
167
|
else assert((await fs.readFile(path.join(f.config.deploymentDir,'docker.env'),'utf8')).includes('sha256:'+'a'.repeat(64)));
|
|
158
168
|
}
|
|
159
169
|
});
|
|
170
|
+
test('main health-gate rollback retains only the recognized sanitized predicate',async t=>{
|
|
171
|
+
const f=await fixture(t),job=await queued(f),base=runtime(f);let failed=false;
|
|
172
|
+
const r={...base,execute:async(c,a,o)=>{if(a.includes('up')&&!failed){failed=true;throw Error('container relay is unhealthy');}return a.includes('ps')?'a'.repeat(64):a[0]==='inspect'&&a[2]==='{{json .State.Health}}'?JSON.stringify({Log:[{Output:'EZ_HEALTH_HOST_STALE\n'}]}):base.execute(c,a,o);}};
|
|
173
|
+
const result=await perform(f.home,job,r);assert.equal(result.status,'rolled-back');assert.match(result.error,/health=host-stale/);assert.doesNotMatch(result.error,/private-test-token/);
|
|
174
|
+
});
|
|
160
175
|
test('plugin transaction preserves named volumes, backs up stopped data and rolls back failed health',async t=>{
|
|
161
176
|
for(const failed of [false,true]) {
|
|
162
177
|
const f=await fixture(t,'plugin'),job=await queued(f),r=runtime(f,{fail:(_c,a)=>failed&&a.includes('up')});
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import test from 'node:test'
|
|
3
|
+
import { mkdtemp, rm } from 'node:fs/promises'
|
|
4
|
+
import { tmpdir } from 'node:os'
|
|
5
|
+
import { join } from 'node:path'
|
|
6
|
+
import { WorkforceWatch, WorkforceWatchServer } from '../src/workforce-watch.js'
|
|
7
|
+
|
|
8
|
+
const fixture = async () => {
|
|
9
|
+
const stateDir = await mkdtemp(join(tmpdir(), 'workforce-watch-'))
|
|
10
|
+
let now = 0
|
|
11
|
+
const notices: string[] = []
|
|
12
|
+
const watch = new WorkforceWatch({ stateDir, enrollmentToken: 'fleet-secret', now: () => now, notify: async text => { notices.push(text) } })
|
|
13
|
+
return { stateDir, watch, notices, advance: (ms: number) => { now += ms }, close: () => rm(stateDir, { recursive: true, force: true }) }
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
test('worker enrollment, terminal alert context, and sustained recovery preserve only redacted state', async () => {
|
|
17
|
+
const f = await fixture()
|
|
18
|
+
try {
|
|
19
|
+
await assert.rejects(() => f.watch.enroll('wrong', { workerId: 'stocks-production', checkInSeconds: 10, graceSeconds: 0 }), /Unauthorized/)
|
|
20
|
+
const enrolled = await f.watch.enroll('fleet-secret', { workerId: 'stocks-production', checkInSeconds: 10, graceSeconds: 0, runbookUrl: 'https://runbooks.example/stocks' })
|
|
21
|
+
await assert.rejects(() => f.watch.checkIn('stocks-production', 'wrong', { status: 'ok' }), /Unauthorized/)
|
|
22
|
+
await f.watch.checkIn(enrolled.workerId, enrolled.workerToken, { status: 'failed', terminal: true, activity: 'IBKR session probe', error: 'No broker server time', runId: 'daily-42', logsHint: 'journalctl --user -u ez-stocks.service --since 30m' })
|
|
23
|
+
assert.match(f.notices[0], /IBKR session probe/)
|
|
24
|
+
assert.match(f.notices[0], /No broker server time/)
|
|
25
|
+
const state = await f.watch.inspect('stocks-production') as Record<string, unknown>
|
|
26
|
+
assert.equal('tokenHash' in state, false)
|
|
27
|
+
assert.deepEqual((state.history as Array<Record<string, unknown>>).at(-1)?.runId, 'daily-42')
|
|
28
|
+
await f.watch.checkIn(enrolled.workerId, enrolled.workerToken, { status: 'ok', activity: 'retry one' })
|
|
29
|
+
await f.watch.checkIn(enrolled.workerId, enrolled.workerToken, { status: 'ok', activity: 'retry two' })
|
|
30
|
+
assert.equal(f.notices.length, 2)
|
|
31
|
+
assert.match(f.notices[1], /recovered/)
|
|
32
|
+
} finally { await f.close() }
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
test('a missed check-in alerts once and requires clean check-ins to recover', async () => {
|
|
36
|
+
const f = await fixture()
|
|
37
|
+
try {
|
|
38
|
+
const enrolled = await f.watch.enroll('fleet-secret', { workerId: 'annie-pa', checkInSeconds: 10, graceSeconds: 2, severity: 'warning' })
|
|
39
|
+
f.advance(12_001)
|
|
40
|
+
await f.watch.evaluate(); await f.watch.evaluate()
|
|
41
|
+
assert.equal(f.notices.length, 1)
|
|
42
|
+
assert.match(f.notices[0], /missed check-in/)
|
|
43
|
+
await f.watch.checkIn(enrolled.workerId, enrolled.workerToken, { status: 'ok', activity: 'relay available' })
|
|
44
|
+
assert.equal(f.notices.length, 1)
|
|
45
|
+
await f.watch.checkIn(enrolled.workerId, enrolled.workerToken, { status: 'ok', activity: 'relay available' })
|
|
46
|
+
assert.equal(f.notices.length, 2)
|
|
47
|
+
} finally { await f.close() }
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
test('PagerDuty uses one worker-specific deduplication key and resolves only a triggered incident', async () => {
|
|
51
|
+
const stateDir = await mkdtemp(join(tmpdir(), 'workforce-watch-'))
|
|
52
|
+
const pages: Array<{ action: string; dedupKey: string; customDetails: Record<string, string> }> = []
|
|
53
|
+
const watch = new WorkforceWatch({ stateDir, enrollmentToken: 'fleet-secret', page: async event => { pages.push(event) } })
|
|
54
|
+
try {
|
|
55
|
+
const enrolled = await watch.enroll('fleet-secret', { workerId: 'stocks', checkInSeconds: 10, graceSeconds: 0, severity: 'critical' })
|
|
56
|
+
await watch.checkIn(enrolled.workerId, enrolled.workerToken, { status: 'failed', terminal: true, activity: 'broker probe', error: 'gateway unavailable', logsHint: 'journalctl -u stocks' })
|
|
57
|
+
await watch.checkIn(enrolled.workerId, enrolled.workerToken, { status: 'ok' })
|
|
58
|
+
await watch.checkIn(enrolled.workerId, enrolled.workerToken, { status: 'ok' })
|
|
59
|
+
assert.deepEqual(pages.map(page => [page.action, page.dedupKey]), [['trigger', 'ez:workforce:stocks'], ['resolve', 'ez:workforce:stocks']])
|
|
60
|
+
assert.equal(pages[0]?.customDetails.error, 'gateway unavailable')
|
|
61
|
+
} finally { await rm(stateDir, { recursive: true, force: true }) }
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
test('PagerDuty trigger is not duplicated when supplemental Telegram delivery fails', async () => {
|
|
65
|
+
const stateDir = await mkdtemp(join(tmpdir(), 'workforce-watch-'))
|
|
66
|
+
const pages: string[] = []
|
|
67
|
+
const watch = new WorkforceWatch({ stateDir, enrollmentToken: 'fleet-secret', page: async event => { pages.push(event.action) }, notify: async () => { throw new Error('Telegram unavailable') } })
|
|
68
|
+
try {
|
|
69
|
+
const enrolled = await watch.enroll('fleet-secret', { workerId: 'aifit', checkInSeconds: 10, graceSeconds: 0 })
|
|
70
|
+
await assert.rejects(() => watch.checkIn(enrolled.workerId, enrolled.workerToken, { status: 'failed', terminal: true }), /Telegram unavailable/)
|
|
71
|
+
await assert.rejects(() => watch.evaluate(), /Telegram unavailable/)
|
|
72
|
+
assert.deepEqual(pages, ['trigger'])
|
|
73
|
+
} finally { await rm(stateDir, { recursive: true, force: true }) }
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
test('a failed recovery notification stays pending and is retried on evaluation', async () => {
|
|
77
|
+
const stateDir = await mkdtemp(join(tmpdir(), 'workforce-watch-'))
|
|
78
|
+
let now = 0, failRecovery = true
|
|
79
|
+
const notices: string[] = []
|
|
80
|
+
const watch = new WorkforceWatch({ stateDir, enrollmentToken: 'fleet-secret', now: () => now, notify: async text => {
|
|
81
|
+
if (text.includes('recovered') && failRecovery) throw new Error('Telegram unavailable')
|
|
82
|
+
notices.push(text)
|
|
83
|
+
} })
|
|
84
|
+
try {
|
|
85
|
+
const enrolled = await watch.enroll('fleet-secret', { workerId: 'dusk-dune', checkInSeconds: 10, graceSeconds: 0 })
|
|
86
|
+
await watch.checkIn(enrolled.workerId, enrolled.workerToken, { status: 'failed', terminal: true })
|
|
87
|
+
await watch.checkIn(enrolled.workerId, enrolled.workerToken, { status: 'ok' })
|
|
88
|
+
await assert.rejects(() => watch.checkIn(enrolled.workerId, enrolled.workerToken, { status: 'ok' }), /Telegram unavailable/)
|
|
89
|
+
assert.ok((await watch.inspect('dusk-dune') as { incident?: unknown }).incident)
|
|
90
|
+
failRecovery = false; now += 1
|
|
91
|
+
await watch.evaluate()
|
|
92
|
+
assert.equal((await watch.inspect('dusk-dune') as { incident?: unknown }).incident, undefined)
|
|
93
|
+
assert.equal(notices.length, 2)
|
|
94
|
+
} finally { await rm(stateDir, { recursive: true, force: true }) }
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
test('an incident whose opening alert never delivered resolves quietly', async () => {
|
|
98
|
+
const stateDir = await mkdtemp(join(tmpdir(), 'workforce-watch-'))
|
|
99
|
+
const notices: string[] = []
|
|
100
|
+
const watch = new WorkforceWatch({ stateDir, enrollmentToken: 'fleet-secret', notify: async text => {
|
|
101
|
+
if (text.includes('alert')) throw new Error('Telegram unavailable')
|
|
102
|
+
notices.push(text)
|
|
103
|
+
} })
|
|
104
|
+
try {
|
|
105
|
+
const enrolled = await watch.enroll('fleet-secret', { workerId: 'jc-stack', checkInSeconds: 10, graceSeconds: 0 })
|
|
106
|
+
await assert.rejects(() => watch.checkIn(enrolled.workerId, enrolled.workerToken, { status: 'failed', terminal: true }), /Telegram unavailable/)
|
|
107
|
+
await assert.rejects(() => watch.checkIn(enrolled.workerId, enrolled.workerToken, { status: 'ok' }), /Telegram unavailable/)
|
|
108
|
+
await watch.checkIn(enrolled.workerId, enrolled.workerToken, { status: 'ok' })
|
|
109
|
+
assert.deepEqual(notices, [])
|
|
110
|
+
assert.equal((await watch.inspect('jc-stack') as { incident?: unknown }).incident, undefined)
|
|
111
|
+
} finally { await rm(stateDir, { recursive: true, force: true }) }
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
test('an enrollment-authorized token rotation invalidates the prior worker secret', async () => {
|
|
115
|
+
const f = await fixture()
|
|
116
|
+
try {
|
|
117
|
+
const enrolled = await f.watch.enroll('fleet-secret', { workerId: 'aifit', checkInSeconds: 10, graceSeconds: 0 })
|
|
118
|
+
await assert.rejects(() => f.watch.rotate('wrong', 'aifit'), /Unauthorized/)
|
|
119
|
+
const rotated = await f.watch.rotate('fleet-secret', 'aifit')
|
|
120
|
+
await assert.rejects(() => f.watch.checkIn('aifit', enrolled.workerToken, { status: 'ok' }), /Unauthorized/)
|
|
121
|
+
await f.watch.checkIn('aifit', rotated.workerToken, { status: 'ok' })
|
|
122
|
+
} finally { await f.close() }
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
test('HTTP enrollment and check-in endpoints reject secrets not owned by the caller', async () => {
|
|
126
|
+
const f = await fixture(), server = new WorkforceWatchServer(f.watch, 'fleet-secret', () => {})
|
|
127
|
+
try {
|
|
128
|
+
await server.listen(0, '127.0.0.1')
|
|
129
|
+
const base = `http://127.0.0.1:${server.port()}`
|
|
130
|
+
assert.equal((await fetch(`${base}/v1/workers`)).status, 401)
|
|
131
|
+
const enrollment = await fetch(`${base}/v1/enroll`, { method: 'POST', headers: { authorization: 'Bearer fleet-secret', 'content-type': 'application/json' }, body: JSON.stringify({ workerId: 'ez-cto', checkInSeconds: 10, graceSeconds: 0 }) })
|
|
132
|
+
assert.equal(enrollment.status, 201)
|
|
133
|
+
const { workerToken } = await enrollment.json() as { workerToken: string }
|
|
134
|
+
assert.equal((await fetch(`${base}/v1/workers/ez-cto/check-in`, { method: 'POST', headers: { authorization: 'Bearer wrong', 'content-type': 'application/json' }, body: '{"status":"ok"}' })).status, 401)
|
|
135
|
+
assert.equal((await fetch(`${base}/v1/workers/ez-cto/check-in`, { method: 'POST', headers: { authorization: `Bearer ${workerToken}`, 'content-type': 'application/json' }, body: '{"status":"ok","activity":"relay started"}' })).status, 200)
|
|
136
|
+
const rotation = await fetch(`${base}/v1/workers/ez-cto/rotate`, { method: 'POST', headers: { authorization: 'Bearer fleet-secret' } })
|
|
137
|
+
assert.equal(rotation.status, 200)
|
|
138
|
+
const rotated = await rotation.json() as { workerToken: string }
|
|
139
|
+
assert.equal((await fetch(`${base}/v1/workers/ez-cto/check-in`, { method: 'POST', headers: { authorization: `Bearer ${workerToken}`, 'content-type': 'application/json' }, body: '{"status":"ok"}' })).status, 401)
|
|
140
|
+
assert.equal((await fetch(`${base}/v1/workers/ez-cto/check-in`, { method: 'POST', headers: { authorization: `Bearer ${rotated.workerToken}`, 'content-type': 'application/json' }, body: '{"status":"ok"}' })).status, 200)
|
|
141
|
+
} finally { await server.close(); await f.close() }
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
for (const recoveredChannel of ['pagerduty', 'telegram'] as const) {
|
|
145
|
+
for (const relapse of ['terminal', 'failed', 'missed'] as const) {
|
|
146
|
+
test(`a ${relapse} relapse reopens only the recovered ${recoveredChannel} channel after restart`, async () => {
|
|
147
|
+
const stateDir = await mkdtemp(join(tmpdir(), 'workforce-relapse-'))
|
|
148
|
+
let now = 0, failRecovery = true
|
|
149
|
+
const pages: string[] = [], notices: string[] = []
|
|
150
|
+
const options = { stateDir, enrollmentToken: 'synthetic', now: () => now,
|
|
151
|
+
page: async (event: { action: string }) => {
|
|
152
|
+
if (event.action === 'resolve' && recoveredChannel === 'telegram' && failRecovery) throw new Error('recovery unavailable')
|
|
153
|
+
pages.push(event.action)
|
|
154
|
+
},
|
|
155
|
+
notify: async (message: string) => {
|
|
156
|
+
if (message.includes('recovered') && recoveredChannel === 'pagerduty' && failRecovery) throw new Error('recovery unavailable')
|
|
157
|
+
notices.push(message.includes('recovered') ? 'resolve' : 'trigger')
|
|
158
|
+
} }
|
|
159
|
+
try {
|
|
160
|
+
let watch = new WorkforceWatch(options)
|
|
161
|
+
const worker = await watch.enroll('synthetic', { workerId: 'synthetic-worker', checkInSeconds: 10, graceSeconds: 0 })
|
|
162
|
+
await watch.checkIn(worker.workerId, worker.workerToken, { status: 'failed', terminal: true })
|
|
163
|
+
await watch.checkIn(worker.workerId, worker.workerToken, { status: 'ok' })
|
|
164
|
+
await assert.rejects(() => watch.checkIn(worker.workerId, worker.workerToken, { status: 'ok' }), /recovery unavailable/)
|
|
165
|
+
watch = new WorkforceWatch(options)
|
|
166
|
+
now = relapse === 'missed' ? 10_001 : 1
|
|
167
|
+
if (relapse === 'missed') await watch.evaluate()
|
|
168
|
+
else await watch.checkIn(worker.workerId, worker.workerToken, { status: 'failed', terminal: relapse === 'terminal' })
|
|
169
|
+
assert.deepEqual(pages, recoveredChannel === 'pagerduty' ? ['trigger', 'resolve', 'trigger'] : ['trigger'])
|
|
170
|
+
assert.deepEqual(notices, recoveredChannel === 'telegram' ? ['trigger', 'resolve', 'trigger'] : ['trigger'])
|
|
171
|
+
failRecovery = false
|
|
172
|
+
await watch.checkIn(worker.workerId, worker.workerToken, { status: 'ok' })
|
|
173
|
+
await watch.checkIn(worker.workerId, worker.workerToken, { status: 'ok' })
|
|
174
|
+
assert.equal((await watch.inspect(worker.workerId) as { incident?: unknown }).incident, undefined)
|
|
175
|
+
assert.equal(pages.at(-1), 'resolve')
|
|
176
|
+
assert.equal(notices.at(-1), 'resolve')
|
|
177
|
+
} finally { await rm(stateDir, { recursive: true, force: true }) }
|
|
178
|
+
})
|
|
179
|
+
}
|
|
180
|
+
}
|