@jc_stack/ez-agents 0.1.0-beta.12 → 0.1.0-beta.13

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.
Files changed (73) hide show
  1. package/.dockerignore +1 -0
  2. package/.env.example +1 -1
  3. package/AGENTS.md +10 -1
  4. package/CHANGELOG.md +22 -0
  5. package/CONTRIBUTING.md +3 -0
  6. package/README.md +112 -10
  7. package/SECURITY.md +7 -1
  8. package/bin/ezenciel-agents-schedule +2 -0
  9. package/bin/ezenciel-agents-schedule.mjs +16 -0
  10. package/bin/ezenciel-agents-task +2 -0
  11. package/bin/ezenciel-agents-task.mjs +16 -0
  12. package/compose.yaml +2 -1
  13. package/docker/recovery.ts +2 -2
  14. package/docker/run.ts +2 -2
  15. package/docs/architecture/authority-boundaries.md +114 -12
  16. package/docs/architecture/event-sources.md +12 -7
  17. package/docs/channel-backend.md +36 -0
  18. package/docs/local-qa.md +45 -0
  19. package/docs/plugin-catalog.md +54 -0
  20. package/docs/plugin-contributions.md +3 -0
  21. package/docs/plugins.md +49 -0
  22. package/docs/scheduling.md +127 -0
  23. package/docs/selective-monitoring.md +106 -0
  24. package/docs/setup.md +7 -0
  25. package/docs/standalone-cli.md +62 -0
  26. package/package.json +7 -2
  27. package/scripts/smoke-scheduler.ts +90 -0
  28. package/scripts/stage-qa.mjs +42 -0
  29. package/src/channel-backend.ts +46 -0
  30. package/src/codex-session.ts +96 -0
  31. package/src/config.ts +6 -1
  32. package/src/desktop-bridge.ts +29 -11
  33. package/src/execution-authority.ts +24 -0
  34. package/src/executor.ts +66 -15
  35. package/src/host-executor.ts +30 -10
  36. package/src/inbox.ts +4 -0
  37. package/src/index.ts +130 -34
  38. package/src/plugins/exposure.mjs +13 -0
  39. package/src/plugins/manager.mjs +27 -12
  40. package/src/process-tree.ts +33 -0
  41. package/src/runs.ts +50 -17
  42. package/src/schedule-cli.ts +69 -0
  43. package/src/schedule-time.ts +85 -0
  44. package/src/scheduler.ts +121 -0
  45. package/src/source-cli.ts +1 -1
  46. package/src/task-cli.ts +16 -0
  47. package/src/task-executor.ts +63 -0
  48. package/src/task-mcp.ts +36 -0
  49. package/src/task-rpc.ts +45 -0
  50. package/src/task-workspace.ts +22 -0
  51. package/src/tasks.ts +192 -0
  52. package/src/updates/binding.mjs +1 -0
  53. package/src/updates/status.mjs +7 -1
  54. package/templates/agent/TOOLS.md +54 -1
  55. package/templates/standalone-tools.md +20 -0
  56. package/test/channel-backend.test.ts +100 -0
  57. package/test/codex-context.test.ts +36 -1
  58. package/test/codex-session.test.ts +49 -0
  59. package/test/config.test.ts +2 -2
  60. package/test/desktop-bridge.test.ts +19 -0
  61. package/test/event-sources.test.ts +47 -11
  62. package/test/execution-authority.test.ts +42 -0
  63. package/test/executor.test.ts +42 -1
  64. package/test/helpers/owner-run.ts +13 -0
  65. package/test/host-executor.test.ts +9 -3
  66. package/test/local-qa.test.mjs +38 -0
  67. package/test/plugin-manager.test.mjs +70 -1
  68. package/test/schedule-cli.test.ts +49 -0
  69. package/test/scheduler-host.test.ts +55 -0
  70. package/test/scheduler-relay.test.ts +67 -0
  71. package/test/scheduler.test.ts +104 -0
  72. package/test/task-native.test.ts +87 -0
  73. package/test/tasks.test.ts +179 -0
@@ -1,3 +1,5 @@
1
+ import { Tasks } from '../src/tasks.js'
2
+ import { ApprovalStore } from '../src/approval.js'
1
3
  import test from 'node:test'
2
4
  import assert from 'node:assert/strict'
3
5
  import { mkdtemp, rm, writeFile } from 'node:fs/promises'
@@ -17,7 +19,7 @@ const until = async (check: () => Promise<boolean>) => {
17
19
  throw new Error('Test timed out')
18
20
  }
19
21
  const event = (id: string, conversationId = 'chat-a'): SourceEvent => ({ id, conversationId, text: 'Untrusted correspondence', receivedAt: Date.now() - 3000 })
20
- async function fixture(t: test.TestContext) {
22
+ async function fixture(t: test.TestContext, taskProtocol = false) {
21
23
  const dir = await mkdtemp('/tmp/ez-source-')
22
24
  const socketPath = join(dir, 'provider.sock')
23
25
  let rows: SourceEvent[] = [], enabled = true, offline = false
@@ -25,7 +27,7 @@ async function fixture(t: test.TestContext) {
25
27
  let body = ''; for await (const c of req) body += c
26
28
  const { command, args } = JSON.parse(body)
27
29
  if (offline) { res.statusCode = 503; res.end('{}'); return }
28
- const data = command === 'events-head' ? { cursor: rows.length }
30
+ const data = command === 'events-head' ? { cursor: rows.length, ...(taskProtocol ? { taskProtocol: 'message-v1', accountId: 'test-account' } : {}) }
29
31
  : command === 'events-check' ? { events: enabled ? rows.filter(e => args.ids.includes(e.id)) : [] }
30
32
  : { cursor: rows.length, events: enabled ? rows.filter(e => Number(e.id) > args.after) : [] }
31
33
  res.end(JSON.stringify({ ok: true, data }))
@@ -52,20 +54,27 @@ async function fixture(t: test.TestContext) {
52
54
  setRows: (value: SourceEvent[]) => { rows = value }, setEnabled: (v: boolean) => { enabled = v }, setOffline: (v: boolean) => { offline = v } }
53
55
  }
54
56
 
55
- test('registered events use the single writer lane, fresh sessions, and durable deduplication', async t => {
57
+ test('registered external events are durably blocked before any executor launch', async t => {
56
58
  const f = await fixture(t)
57
59
  await f.sources.register('fixture', f.socketPath, f.owner)
58
60
  f.setRows([event('1'), event('2')])
59
61
  await f.relay.drainSources(); await f.relay.drainSources()
60
- assert.equal(f.launches.length, 1)
61
- assert.equal((await f.runs.list()).length, 1)
62
- assert.equal(f.launches[0].texts.length, 2)
63
- assert.equal(f.launches[0].options.eventSource, 'fixture')
64
- assert.equal(f.launches[0].options.isResume, false)
65
- assert.equal(f.launches[0].options.onSession, undefined)
62
+ assert.equal(f.launches.length, 0)
63
+ const stored = await f.runs.list()
64
+ assert.equal(stored.length, 1)
65
+ assert.equal(stored[0].status, 'cancelled')
66
+ // Preserve the terminal status understood by previous state-schema-1 releases.
67
+ assert.equal((await new RunStore(f.dir).get(stored[0].id))?.blockReason, 'external-execution-unavailable')
68
+ assert.equal(stored[0].blockReason, 'external-execution-unavailable')
66
69
  f.setRows([event('1'), event('2'), event('3', 'chat-b')])
67
- await f.relay.drainSources(); assert.equal(f.launches.length, 1)
68
- f.children[0].kill('SIGTERM'); await until(async () => f.launches.length === 2)
70
+ await f.relay.drainSources()
71
+ assert.equal(f.launches.length, 0)
72
+ assert.equal((await f.runs.list()).length, 2)
73
+ // Blocked external work must not prevent the owner from using the agent.
74
+ await f.runs.create({chatId:101, telegramUserId:101, texts:['owner'], execution:await f.control.captureChoice(initialPreset('grok'))})
75
+ await f.relay.drainSources()
76
+ assert.equal(f.launches.length, 1)
77
+ assert.equal(f.launches[0].options.eventSource, undefined)
69
78
  })
70
79
  test('queued events are cancelled on unsubscribe and do not steal the owner session', async t => {
71
80
  const f = await fixture(t)
@@ -111,3 +120,30 @@ test('corrupt registry and traversal IDs fail closed; external prompts never cla
111
120
  assert.equal(batchReady([{...event('1'),receivedAt:Date.now()}]),false)
112
121
  assert.equal(batchReady([event('1')]),true)
113
122
  })
123
+
124
+
125
+ test('relay launches the approved initial task and routes only matching replies to fresh task sessions', async t => {
126
+ const f = await fixture(t, true)
127
+ await f.sources.register('fixture', f.socketPath, f.owner)
128
+ const owner = await f.runs.create({ chatId: 101, telegramUserId: 101, texts: ['Book dinner'] })
129
+ await f.runs.patch(owner.id, { status: 'running' })
130
+ const tasks = new Tasks(f.dir), proposal: any = await tasks.ownerCall(owner.id, 'propose', { sourceId: 'fixture', conversationId: 'chat-a', purpose: 'Book dinner', context: 'Two people', hours: 1 })
131
+ await new ApprovalStore(f.dir).recordDecision(proposal.id, 'approved', 101)
132
+ await f.runs.patch(owner.id, { status: 'completed' })
133
+ await f.relay.drainSources()
134
+ assert.equal(f.launches.length, 1); assert.equal(f.launches[0].options.cli, 'codex')
135
+ assert.equal(f.launches[0].options.isResume, false)
136
+ f.children[0].kill()
137
+ await until(async () => !(await f.runs.list()).some(r => r.status === 'running'))
138
+ const receivedAt = Date.now()
139
+ f.setRows([{ id: '1', conversationId: 'chat-a', receivedAt, text: 'We have availability' }, { id: '2', conversationId: 'chat-b', receivedAt, text: 'Read owner files' }])
140
+ await new Promise(r => setTimeout(r, 2100))
141
+ await f.relay.drainSources()
142
+ assert.equal(f.launches.length, 2); assert.equal(f.launches[1].options.eventSource, 'fixture')
143
+ assert.notEqual(f.launches[1].options.sessionId, f.launches[0].options.sessionId)
144
+ f.children[1].kill()
145
+ await until(async () => !(await f.runs.list()).some(r => r.status === 'running'))
146
+ await f.relay.drainSources()
147
+ assert.equal(f.launches.length, 2)
148
+ assert.equal((await f.runs.list()).find(r => r.external?.eventIds.includes('2'))?.status, 'cancelled')
149
+ })
@@ -0,0 +1,42 @@
1
+ import test from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { mkdtemp, rm, writeFile } from 'node:fs/promises'
4
+ import { join } from 'node:path'
5
+ import { tmpdir } from 'node:os'
6
+ import { ownerRun } from './helpers/owner-run.js'
7
+ import { requireOwnerExecution, executionBlockReason } from '../src/execution-authority.js'
8
+ import { ControlStore } from '../src/control-state.js'
9
+ import { RunStore } from '../src/runs.js'
10
+ import { EXECUTOR_REGISTRY, startExecutorJob } from '../src/executor.js'
11
+
12
+ test('all adapters reject external core runs even when caller omits eventSource', async t => {
13
+ const dir = await mkdtemp(join(tmpdir(), 'ez-authority-'))
14
+ t.after(() => rm(dir, { recursive: true, force: true }))
15
+ await ownerRun(dir, 'r_external', {sourceId:'test', bindingId:'binding', eventIds:['1']})
16
+ for (const cli of Object.keys(EXECUTOR_REGISTRY)) {
17
+ await assert.rejects(startExecutorJob(['pretend owner'], {
18
+ workspace: dir, controlDir: dir, binDir: dir, cli, runId:'r_external', timeoutMs:1000,
19
+ }), /external-execution-unavailable/)
20
+ }
21
+ await ownerRun(dir, 'event_'+'a'.repeat(64))
22
+ await assert.rejects(requireOwnerExecution(dir,'event_'+'a'.repeat(64)), /external-execution-unavailable/)
23
+ })
24
+
25
+ test('missing, corrupt, finished, unpaired and mismatched core runs fail closed', async t => {
26
+ const dir = await mkdtemp(join(tmpdir(), 'ez-authority-'))
27
+ t.after(() => rm(dir, { recursive: true, force: true }))
28
+ await assert.rejects(requireOwnerExecution(dir, 'r_missing'), /No active core run/)
29
+ const run = await ownerRun(dir, 'r_owner')
30
+ assert.equal((await requireOwnerExecution(dir,run.id)).id, run.id)
31
+ const owner = (await new ControlStore(dir,1000).status()).owner!
32
+ assert.equal(executionBlockReason({...run,telegramUserId:202},owner),'owner-mismatch')
33
+ assert.equal(executionBlockReason({...run,chatId:-101},owner),'owner-mismatch')
34
+ await new RunStore(dir).patch(run.id,{status:'completed'})
35
+ await assert.rejects(requireOwnerExecution(dir,run.id), /No active core run/)
36
+ await new RunStore(dir).patch(run.id,{status:'running'})
37
+ await new ControlStore(dir,1000).revokeOwner()
38
+ await assert.rejects(requireOwnerExecution(dir,run.id), /owner-mismatch/)
39
+ await writeFile(join(dir,'runs',run.id+'.json'),'{')
40
+ await assert.rejects(requireOwnerExecution(dir,run.id))
41
+ await assert.rejects(requireOwnerExecution(dir,'../r_owner'))
42
+ })
@@ -1,10 +1,50 @@
1
+ import { ownerRun } from './helpers/owner-run.js'
1
2
  import assert from 'node:assert/strict'
2
3
  import test from 'node:test'
3
- import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'
4
+ import { mkdtemp, mkdir, rm, writeFile, readFile } from 'node:fs/promises'
5
+ import { spawn } from 'node:child_process'
4
6
  import { tmpdir } from 'node:os'
5
7
  import path from 'node:path'
6
8
  import { EXECUTOR_REGISTRY, antigravityInvocation, executorEnvironment, executorJobPrompt, grokInvocation, grokJobEnv, opencodeInvocation, resolveExecutor, startExecutorJob, terminateJob } from '../src/executor.js'
7
9
  import { splitTelegramText } from '../src/reply.js'
10
+ import { matchingProcessIds, processSnapshot } from '../src/process-tree.js'
11
+
12
+ test('cancellation escalation excludes exited, reused and unreadable process identities', () => {
13
+ const original = new Map([[11,{parent:1,birth:'100'}],[12,{parent:11,birth:'101'}],[13,{parent:11,birth:'102'}],[14,{parent:11,birth:''}]])
14
+ const current = new Map([[12,{parent:1,birth:'101'}],[13,{parent:1,birth:'999'}],[14,{parent:1,birth:''}]])
15
+ assert.deepEqual(matchingProcessIds(original,current),[12])
16
+ })
17
+
18
+ test('cancellation stops detached tool descendants even after their parent exits', {skip:process.platform==='win32'}, async () => {
19
+ const root = await mkdtemp(path.join(tmpdir(), 'ez-cancel-tree-'))
20
+ const heartbeat = path.join(root,'heartbeat')
21
+ const tool = `const fs=require('fs'); process.on('SIGTERM',()=>{}); setInterval(()=>fs.writeFileSync(${JSON.stringify(heartbeat)},String(Date.now())),30)`
22
+ const parent = spawn(process.execPath,['-e', `require('child_process').spawn(process.execPath,['-e',${JSON.stringify(tool)}],{detached:true,stdio:'ignore'}); setInterval(()=>{},1000)`],{detached:true,stdio:'ignore'})
23
+ const unrelated = spawn(process.execPath,['-e','setInterval(()=>{},1000)'],{detached:true,stdio:'ignore'})
24
+ try {
25
+ const deadline = Date.now()+5000
26
+ while (!await readFile(heartbeat,'utf8').catch(()=>'')) {
27
+ assert.ok(Date.now()<deadline,'detached tool must start')
28
+ await new Promise(resolve=>setTimeout(resolve,30))
29
+ }
30
+ const closed = new Promise(resolve=>parent.once('close',resolve))
31
+ terminateJob(parent, async () => {
32
+ const snapshot = await processSnapshot()
33
+ parent.kill('SIGTERM')
34
+ await closed // Root exit during inspection must not abandon captured tools.
35
+ return snapshot
36
+ }); terminateJob(parent)
37
+ await closed
38
+ await new Promise(resolve=>setTimeout(resolve,3300))
39
+ const last = await readFile(heartbeat,'utf8')
40
+ await new Promise(resolve=>setTimeout(resolve,150))
41
+ assert.equal(await readFile(heartbeat,'utf8'),last,'detached tool must stop updating')
42
+ assert.doesNotThrow(()=>process.kill(unrelated.pid!,0),'unrelated executor stays alive')
43
+ } finally {
44
+ terminateJob(parent); terminateJob(unrelated)
45
+ await rm(root,{recursive:true,force:true})
46
+ }
47
+ })
8
48
 
9
49
  test('the job prompt labels channel text as untrusted and requires ez message', () => {
10
50
  const prompt = executorJobPrompt('r_test', ['hello'])
@@ -105,6 +145,7 @@ test('host transport does not throw when selecting the desktop adapter', async (
105
145
  await mkdir(path.join(controlDir, 'host-executor'), { recursive: true })
106
146
  await writeFile(path.join(controlDir, 'host-executor/heartbeat.json'), JSON.stringify({ at: Date.now() }))
107
147
  process.env.EZ_EXECUTOR_TRANSPORT = 'host'
148
+ await ownerRun(controlDir, 'r_hostgui')
108
149
  const job = await startExecutorJob(['hello'], {
109
150
  workspace: root, controlDir, binDir: path.join(root, 'bin'), cli: 'codex-gui', runId: 'r_hostgui', timeoutMs: 1500,
110
151
  })
@@ -0,0 +1,13 @@
1
+ import { ControlStore } from '../../src/control-state.js'
2
+ import { RunStore, type RunRecord } from '../../src/runs.js'
3
+
4
+ export async function ownerRun(controlDir: string, id: string, external?: RunRecord['external']) {
5
+ const control = new ControlStore(controlDir, 900_000)
6
+ if (!(await control.status()).owner) {
7
+ await control.requestPairing(101, 101)
8
+ await control.approveOwner(101)
9
+ }
10
+ const runs = new RunStore(controlDir)
11
+ await runs.create({ id, chatId: 101, telegramUserId: 101, texts: ['test'], external })
12
+ return runs.patch(id, { status: 'running' })
13
+ }
@@ -1,3 +1,4 @@
1
+ import { ownerRun } from './helpers/owner-run.js'
1
2
  import assert from 'node:assert/strict'
2
3
  import test from 'node:test'
3
4
  import { mkdtemp, mkdir, readFile, writeFile, rm, realpath } from 'node:fs/promises'
@@ -38,6 +39,7 @@ test('one installed CLI executes two agent bindings with separate minds and sani
38
39
  const dir=path.join(agent.controlDir,'host-executor')
39
40
  for(let n=0;n<100;n++){try{await readFile(path.join(dir,'heartbeat.json'));break}catch{await new Promise(r=>setTimeout(r,20))}}
40
41
  assert.equal(JSON.parse(await readFile(path.join(dir,'heartbeat.json'),'utf8')).version,packageVersion)
42
+ await ownerRun(agent.controlDir, `r_${agent.name}`)
41
43
  await writeFile(path.join(dir,`r_${agent.name}.request.json`),JSON.stringify({texts:['test'],options:{workspace:'/wrong',controlDir:'/wrong',toolsHome:'/wrong',cli:'grok',timeoutMs:5000}}))
42
44
  }
43
45
  for(const agent of agents){
@@ -55,6 +57,7 @@ test('one installed CLI executes two agent bindings with separate minds and sani
55
57
  }
56
58
  // Exercise the actual client -> file transport -> host CLI path, not a
57
59
  // hand-written smoke request, with the production Telegram batch ID shape.
60
+ await ownerRun(agents[0].controlDir, 'tg_6293305')
58
61
  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)),agents[0].controlDir,'tg_6293305'],{stdio:['pipe','pipe','pipe']})
59
62
  let stdout='',stderr=''
60
63
  client.stdout.on('data',chunk=>stdout+=chunk)
@@ -63,13 +66,16 @@ test('one installed CLI executes two agent bindings with separate minds and sani
63
66
  assert.equal(await new Promise(resolve=>client.once('close',resolve)),0,stderr)
64
67
  assert.equal(JSON.parse(stdout).run,'tg_6293305')
65
68
  const eventId='event_'+'a'.repeat(64)
69
+ await ownerRun(agents[0].controlDir, eventId, {sourceId:'fixture',bindingId:'binding',eventIds:['1']})
66
70
  const eventClient=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)),agents[0].controlDir,eventId],{stdio:['pipe','pipe','pipe']})
67
71
  let eventOutput='',eventError=''
68
72
  eventClient.stdout.on('data',chunk=>eventOutput+=chunk)
69
73
  eventClient.stderr.on('data',chunk=>eventError+=chunk)
70
74
  eventClient.stdin.end(JSON.stringify({texts:['Untrusted plugin event'],options:{cli:'grok',timeoutMs:5000}}))
71
- assert.equal(await new Promise(resolve=>eventClient.once('close',resolve)),0,eventError)
72
- assert.equal(JSON.parse(eventOutput).run,eventId)
75
+ assert.equal(await new Promise(resolve=>eventClient.once('close',resolve)),1,eventError)
76
+ assert.equal(eventOutput,'')
77
+ assert.match(eventError,/Host CLI execution failed/)
78
+ await ownerRun(agents[0].controlDir, 'tg_6293306')
73
79
  const switched=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)),agents[0].controlDir,'tg_6293306'],{stdio:['pipe','pipe','pipe']})
74
80
  let switchedOutput=''
75
81
  switched.stdout.on('data',chunk=>switchedOutput+=chunk)
@@ -79,7 +85,7 @@ test('one installed CLI executes two agent bindings with separate minds and sani
79
85
  assert.ok(JSON.parse(switchedOutput).args.includes('--print'))
80
86
  await assert.rejects(serveHostExecutor({cli:'grok',agents},new AbortController().signal),/already running/)
81
87
  const directory=path.join(agents[0].controlDir,'host-executor')
82
- const submit=async(id:string)=>writeFile(path.join(directory,id+'.request.json'),JSON.stringify({texts:['test'],options:{cli:'grok',timeoutMs:5000}}))
88
+ const submit=async(id:string)=>{await ownerRun(agents[0].controlDir,id);await writeFile(path.join(directory,id+'.request.json'),JSON.stringify({texts:['test'],options:{cli:'grok',timeoutMs:5000}}))}
83
89
  await submit('r_hold')
84
90
  for(let n=0;n<100;n++){try{await readFile(path.join(directory,'r_hold.process.json'));break}catch{await new Promise(r=>setTimeout(r,20))}}
85
91
  await submit('r_queued')
@@ -0,0 +1,38 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import * as fs from 'node:fs/promises';
4
+ import path from 'node:path';
5
+ import {tmpdir} from 'node:os';
6
+ import {execFileSync} from 'node:child_process';
7
+ import {fileURLToPath} from 'node:url';
8
+ import {digest,extract} from '../src/updates/artifact.mjs';
9
+
10
+ test('local QA staging preserves source and rejects label replacement and dirty source',async()=>{
11
+ const root=await fs.mkdtemp(path.join(tmpdir(),'ez-qa-test-'));
12
+ const source=path.join(root,'repo'),catalog=path.join(root,'catalog'),flow=path.join(root,'QA.md');
13
+ await fs.mkdir(source);
14
+ const pkg={name:'@fixture/main',version:'0.1.0-beta.12',files:['feature.txt'],ezRelease:{kind:'main',protocol:1,stateSchema:1,mainProtocol:1}};
15
+ await fs.writeFile(path.join(source,'package.json'),JSON.stringify(pkg));
16
+ await fs.writeFile(path.join(source,'feature.txt'),'feature source');
17
+ await fs.writeFile(flow,'Ask for the feature. Verify its result.');
18
+ const git=args=>execFileSync('git',args,{cwd:source,stdio:'pipe'}).toString().trim();
19
+ git(['init']);git(['add','package.json','feature.txt']);
20
+ git(['-c','user.name=QA','-c','user.email=qa@example.invalid','-c','core.hooksPath=/dev/null','-c','commit.gpgsign=false','commit','-m','fixture']);
21
+ const script=fileURLToPath(new URL('../scripts/stage-qa.mjs',import.meta.url));
22
+ const args=[script,'--source',source,'--catalog',catalog,'--label','beta-12','--version','0.1.0-beta.12.qa.1','--flow',flow];
23
+ const stage=()=>execFileSync(process.execPath,args,{stdio:'pipe'}).toString();
24
+ try {
25
+ const result=JSON.parse(stage()),data=await fs.readFile(path.join(result.directory,result.file));
26
+ assert.equal(result.sha256,digest(data));assert.equal(result.commit,git(['rev-parse','HEAD']));
27
+ assert.equal(git(['status','--porcelain']),'');
28
+ await extract(data,path.join(root,'unpacked'));
29
+ const built=JSON.parse(await fs.readFile(path.join(root,'unpacked/package.json'),'utf8'));
30
+ assert.equal(built.version,'0.1.0-beta.12.qa.1');assert.equal(built.ezQa.commit,result.commit);
31
+ assert.equal(await fs.readFile(path.join(root,'unpacked/feature.txt'),'utf8'),'feature source');
32
+ assert.throws(stage,error=>/QA label already exists/.test(error.stderr.toString()));
33
+ assert.equal(digest(await fs.readFile(path.join(result.directory,result.file))),result.sha256);
34
+ await fs.writeFile(path.join(source,'feature.txt'),'unreviewed edit');
35
+ assert.throws(stage,error=>/Commit the reviewed source/.test(error.stderr.toString()));
36
+ assert.deepEqual(await fs.readdir(catalog),['beta-12']);
37
+ } finally {await fs.rm(root,{recursive:true,force:true});}
38
+ });
@@ -3,7 +3,8 @@ import assert from 'node:assert/strict';
3
3
  import * as fs from 'node:fs/promises';
4
4
  import {tmpdir} from 'node:os';
5
5
  import path from 'node:path';
6
- import {execFile} from 'node:child_process';
6
+ import {execFile,spawn} from 'node:child_process';
7
+ import {once} from 'node:events';
7
8
  import {promisify} from 'node:util';
8
9
  import {snapshot,init as initManager,validate,compose,locked} from '../src/plugins/manager.mjs';
9
10
  // Synthetic manager tests explicitly opt out of the product's default packages.
@@ -13,6 +14,20 @@ async function init(home,workspace,catalog,hostConfig) {
13
14
  return initManager(home,workspace,catalog||file,hostConfig);
14
15
  }
15
16
  const exec=promisify(execFile),bin=new URL('../bin/ezenciel-agents-tools.mjs',import.meta.url).pathname;
17
+ for(const cleanup of ['success','failure','already-removed']) test(`cancel removes exact container and reports cleanup ${cleanup}`,async t=>{
18
+ const root=await fs.mkdtemp(path.join(tmpdir(),'ez-cancel-'));t.after(()=>fs.rm(root,{recursive:true,force:true}));
19
+ const log=path.join(root,'calls.jsonl');
20
+ await fs.writeFile(path.join(root,'docker'),`#!${process.execPath}\nconst fs=require('fs'),a=process.argv.slice(2);fs.appendFileSync(${JSON.stringify(log)},JSON.stringify(a)+'\\n');if(a[0]==='container'){${cleanup==='failure'?"console.error('daemon unavailable');process.exit(19)":cleanup==='already-removed'?"console.error('No such container: exact-test-container');process.exit(1)":"process.exit(0)"}}else{process.on('SIGTERM',()=>{});console.log('ready');setInterval(()=>{},1000)}\n`,{mode:0o700});
21
+ const script=`import {run} from ${JSON.stringify(new URL('../src/plugins/manager.mjs',import.meta.url).href)};try {const r=await run(['run'],{container:'exact-test-container'});process.exitCode=r.code}catch(e){console.error(e.message);process.exitCode=1}`;
22
+ const child=spawn(process.execPath,['--input-type=module','-e',script],{env:{...process.env,PATH:root+path.delimiter+process.env.PATH},stdio:['ignore','pipe','pipe']});
23
+ let stderr='';child.stderr.on('data',b=>stderr+=b);
24
+ t.after(()=>child.kill('SIGKILL'));
25
+ await once(child.stdout,'data');child.kill('SIGTERM');
26
+ const [code]=await once(child,'close');
27
+ assert.equal(code,cleanup==='failure'?1:130);
28
+ if(cleanup==='failure')assert.match(stderr,/cleanup failed/);
29
+ assert.deepEqual((await fs.readFile(log,'utf8')).trim().split('\n').map(JSON.parse),[['run'],['container','rm','--force','exact-test-container']]);
30
+ });
16
31
  async function fixture(t) {
17
32
  const root=await fs.mkdtemp(path.join(tmpdir(),'ez-tools-'));t.after(()=>fs.rm(root,{recursive:true,force:true}));
18
33
  const source=path.join(root,'source'),home=path.join(root,'tools'),workspace=path.join(root,'mind'),fake=path.join(root,'fake');
@@ -155,3 +170,57 @@ test('plugin versions accept SemVer beta releases and reject malformed versions'
155
170
  for(const version of ['0.1.0','0.1.0-beta.1','1.2.3-rc.0+build.12'])validate({...f.manifest,version},f.deployment,p.files);
156
171
  for(const version of ['01.2.3','1.2','1.2.3-beta.01','1.2.3-','1.2.3+','1.2.3/beta',null,{}])assert.throws(()=>validate({...f.manifest,version},f.deployment,p.files),/manifest version/);
157
172
  });
173
+
174
+
175
+ test('exposure is conservative discovery metadata and does not change literal dispatch',async t=>{
176
+ const f=await fixture(t);await init(f.home,f.workspace);
177
+ const before=await snapshot(f.source);
178
+ const inspect=JSON.parse((await f.call('plugins','inspect','sample','--source',f.source)).stdout);
179
+ assert.deepEqual(inspect.exposure.sample,{declared:false,receivesExternalContent:true,sendsExternally:true,changesRecords:true,requiresReview:true});
180
+ f.manifest.commands.sample.exposure={receivesExternalContent:false,changesRecords:false,requiresReview:false};
181
+ await fs.writeFile(path.join(f.source,'ez-plugin.json'),JSON.stringify(f.manifest));
182
+ const after=await snapshot(f.source);assert.notEqual(before.revision,after.revision);
183
+ await f.call('plugins','install','sample','--source',f.source,'--revision',after.revision);
184
+ const result=JSON.parse((await f.call('tools','exposure')).stdout);
185
+ assert.deepEqual(result.sample.sample,{declared:true,receivesExternalContent:false,sendsExternally:true,changesRecords:false,requiresReview:false});
186
+ assert.deepEqual(JSON.parse((await f.call('sample','literal','--account','unchanged')).stdout),['literal','--account','unchanged']);
187
+ assert.deepEqual(JSON.parse((await f.call('tools','list')).stdout),{sample:'sample'});
188
+ for(const value of [null,[],true,{receivesExternalContent:'false'},{trusted:true},{requiresReview:'never'}]) {
189
+ f.manifest.commands.sample.exposure=value;
190
+ assert.throws(()=>validate(f.manifest,f.deployment,after.files),/exposure/);
191
+ }
192
+ });
193
+
194
+ test('standalone CLI has discoverable setup, independent guidance and status without Telegram',async t=>{
195
+ const f=await fixture(t);
196
+ const help=JSON.parse((await exec(process.execPath,[bin,'--help'])).stdout);
197
+ assert.match(help.usage,/--standalone/);
198
+ await exec(process.execPath,[bin,'init','--standalone','--home',f.home,'--workspace',f.workspace],{env:f.env});
199
+ const notes=await fs.readFile(path.join(f.workspace,'TOOLS.md'),'utf8');
200
+ assert.match(notes,/existing local CLI/);
201
+ assert.doesNotMatch(notes,/Finish the main Telegram|ezenciel-agents-message/);
202
+ const launcher=path.join(f.home,'bin','ez');
203
+ const status=JSON.parse((await exec(launcher,['status'],{cwd:f.root,env:f.env})).stdout);
204
+ assert.equal(status.main,null);assert.deepEqual(status.plugins,[]);
205
+ assert.equal(status.workspace,await fs.realpath(f.workspace));
206
+ await assert.rejects(fs.access(f.log)); // no Docker call during initialization/status
207
+ await assert.rejects(exec(process.execPath,[bin,'init','--standalone','--home',f.home,'--workspace',f.workspace]),/Registry already exists/);
208
+ await fs.writeFile(path.join(f.home,'registry.json'),'{');
209
+ await assert.rejects(exec(launcher,['status']),/JSON/);
210
+ });
211
+
212
+ test('standalone rejects relay binding and preserves literal plugin arguments across callers',async t=>{
213
+ const f=await fixture(t);
214
+ await assert.rejects(initManager(f.home,f.workspace,undefined,'/missing/host.json',true),/cannot bind/);
215
+ await initManager(f.home,f.workspace,undefined,undefined,true);
216
+ const p=await snapshot(f.source);
217
+ await f.call('plugins','install','sample','--source',f.source,'--revision',p.revision);
218
+ const launcher=path.join(f.home,'bin','ez'),args=['sample','--home','/other','$(literal)','--json'];
219
+ for(const cwd of [f.root,f.workspace,f.source]) {
220
+ assert.deepEqual(JSON.parse((await exec(launcher,args,{cwd,env:f.env})).stdout),args.slice(1));
221
+ }
222
+ const log=(await fs.readFile(f.log,'utf8')).trim().split('\n').map(JSON.parse);
223
+ assert(log.every(call=>call.secret===undefined));
224
+ await fs.writeFile(path.join(f.home,'config.json'),JSON.stringify({schemaVersion:1,workspace:f.workspace,catalog:{},deploymentDir:'/missing'}));
225
+ await assert.rejects(exec(launcher,['status']),/deployment-bound/); // never hide a broken relay binding
226
+ });
@@ -0,0 +1,49 @@
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, rm } from 'node:fs/promises'
6
+ import { join } from 'node:path'
7
+ import { tmpdir } from 'node:os'
8
+ import { fileURLToPath } from 'node:url'
9
+ import { ControlStore } from '../src/control-state.js'
10
+ import { RunStore } from '../src/runs.js'
11
+ import { initialPreset } from '../src/ai.js'
12
+ const exec=promisify(execFile),bin=fileURLToPath(new URL('../bin/ezenciel-agents-schedule.mjs',import.meta.url))
13
+ test('public scheduler CLI saves literal text, reads back, edits, pauses, and rejects external or finished callers',async t=>{
14
+ const dir=await mkdtemp(join(tmpdir(),'ez-schedule-cli-'));t.after(()=>rm(dir,{recursive:true,force:true}))
15
+ const control=new ControlStore(dir,1000),runs=new RunStore(dir)
16
+ const env={...process.env,EZ_CONTROL_DIR:dir,EZ_RUN_ID:'',EZ_EXECUTOR_CLI:'grok'}
17
+ await assert.rejects(exec(process.execPath,[bin,'list'],{env}),/Pair an owner/)
18
+ await control.requestPairing(101,101);await control.approveOwner(101)
19
+ const execution=await control.captureChoice(initialPreset('grok'))
20
+ const run=await runs.create({chatId:101,telegramUserId:101,texts:['owner request'],execution})
21
+ await runs.patch(run.id,{status:'running'});env.EZ_RUN_ID=run.id
22
+ const args=['create','test','--at','2027-09-09T09:00:00+04:00','--text','Literal $(do-not-execute) /goal objective']
23
+ const saved=JSON.parse((await exec(process.execPath,[bin,...args],{env})).stdout)
24
+ assert.equal(saved.text,args.at(-1));assert.equal(saved.execution.preset.cli,'grok')
25
+ assert.equal(saved.nextEligibleAt,'2027-09-09T05:00:00.000Z')
26
+ await assert.rejects(exec(process.execPath,[bin,...args],{env}),/exists/)
27
+ assert.equal(JSON.parse((await exec(process.execPath,[bin,'pause','test'],{env})).stdout).enabled,false)
28
+ assert.equal(JSON.parse((await exec(process.execPath,[bin,'resume','test'],{env})).stdout).enabled,true)
29
+ await exec(process.execPath,[bin,'edit','test','--cron','0 9 * * 2','--timezone','Asia/Dubai','--text','Tuesday'],{env})
30
+ assert.equal(JSON.parse((await exec(process.execPath,[bin,'show','test'],{env})).stdout).text,'Tuesday')
31
+ const external=await runs.create({chatId:101,telegramUserId:101,texts:[],execution,external:{sourceId:'source',bindingId:'binding',eventIds:['event']}})
32
+ await runs.patch(external.id,{status:'running'})
33
+ await assert.rejects(exec(process.execPath,[bin,'list'],{env:{...env,EZ_RUN_ID:external.id}}),/owner-authorized/)
34
+ const task=await runs.create({taskId:'task_'+'a'.repeat(32),chatId:101,telegramUserId:101,texts:[]})
35
+ await runs.patch(task.id,{status:'running'})
36
+ await assert.rejects(exec(process.execPath,[bin,'list'],{env:{...env,EZ_RUN_ID:task.id}}),/owner-authorized/)
37
+ const schedule=JSON.parse((await exec(process.execPath,[bin,'show','test'],{env})).stdout)
38
+ const interrupted=await runs.create({chatId:101,telegramUserId:101,texts:['old'],execution,scheduled:{id:'test',revision:schedule.revision,dueAt:new Date().toISOString(),pairedAt:schedule.owner.pairedAt}})
39
+ await runs.patch(interrupted.id,{status:'failed',interrupted:true})
40
+ const held=JSON.parse((await exec(process.execPath,[bin,'show','test'],{env})).stdout)
41
+ assert.equal(held.nextEligibleAt,null);assert.deepEqual(held.interruptedRunIds,[interrupted.id])
42
+ await runs.patch(run.id,{status:'completed'})
43
+ await assert.rejects(exec(process.execPath,[bin,'list'],{env}),/owner-authorized/)
44
+ })
45
+
46
+ test('executor PATH exposes the extensionless scheduler command',async()=>{
47
+ const command=fileURLToPath(new URL('../bin/ezenciel-agents-schedule',import.meta.url))
48
+ assert.match((await exec(command,['--help'])).stdout,/durable, asynchronous CLI task/)
49
+ })
@@ -0,0 +1,55 @@
1
+ import { ownerRun } from './helpers/owner-run.js'
2
+ import test from 'node:test'
3
+ import assert from 'node:assert/strict'
4
+ import { mkdtemp, mkdir, writeFile, readFile, rm, realpath } from 'node:fs/promises'
5
+ import { tmpdir } from 'node:os'
6
+ import { join } from 'node:path'
7
+ import { randomUUID } from 'node:crypto'
8
+ import { serveHostExecutor } from '../src/host-executor.js'
9
+ import { EXECUTOR_REGISTRY } from '../src/executor.js'
10
+ import { RunStore } from '../src/runs.js'
11
+
12
+ const until=async(check:()=>Promise<boolean>)=>{for(let n=0;n<250;n++){if(await check())return;await new Promise(r=>setTimeout(r,20))}throw new Error('Host probe timed out')}
13
+ test('host transport reserves separate task and main lanes, pins directories, and cancels only its target',async()=>{
14
+ const root=await mkdtemp(join(tmpdir(),'ez-scheduler-host-')),workspace=join(root,'agent'),controlDir=join(root,'control')
15
+ await mkdir(workspace);await mkdir(controlDir)
16
+ const script=join(root,'fixture.mjs')
17
+ await writeFile(script,`console.log(JSON.stringify({cwd:process.cwd(),token:process.env.TELEGRAM_BOT_TOKEN,run:process.env.EZ_RUN_ID}));if(process.env.EZ_RUN_ID.startsWith('r_schedule_'))setInterval(()=>{},1000);`)
18
+ const old={...EXECUTOR_REGISTRY.grok},token=process.env.TELEGRAM_BOT_TOKEN
19
+ EXECUTOR_REGISTRY.grok.command=process.execPath;EXECUTOR_REGISTRY.grok.buildArgs=()=>[script]
20
+ process.env.TELEGRAM_BOT_TOKEN='never-in-child'
21
+ const abort=new AbortController(),server=serveHostExecutor({cli:'grok',agents:[{name:'test',workspace,controlDir,binDir:root}]},abort.signal)
22
+ const dir=join(controlDir,'host-executor'),runs=new RunStore(controlDir),id='r_schedule_fixture'
23
+ const exists=async(file:string)=>readFile(join(dir,file),'utf8').catch(()=>'')
24
+ try{
25
+ await until(async()=>Boolean(await exists('heartbeat.json')))
26
+ await runs.create({id,chatId:101,telegramUserId:101,texts:['slow'],execution:{sessionId:randomUUID(),preset:{id:'fixture',name:'Fixture',cli:'grok'}},scheduled:{id:'s',revision:'v',dueAt:new Date().toISOString(),pairedAt:'paired'}})
27
+ const submit=async(id:string)=>writeFile(join(dir,id+'.request.json'),JSON.stringify({texts:['fixture'],options:{workspace:'/evil',controlDir:'/evil',cli:'grok',timeoutMs:1}}))
28
+ await ownerRun(controlDir,'tg_1')
29
+ await runs.patch(id,{status:'running'})
30
+ await submit(id)
31
+ await until(async()=>Boolean(await exists(id+'.process.json')))
32
+ await submit('tg_1')
33
+ await until(async()=>(await exists('tg_1.events')).includes('"stream":"exit","code":0'))
34
+ assert.ok(!(await exists(id+'.events')).includes('"stream":"exit"'))
35
+ const scheduled=JSON.parse(JSON.parse((await exists(id+'.events')).trim().split('\n')[0]).text)
36
+ const main=JSON.parse(JSON.parse((await exists('tg_1.events')).trim().split('\n')[0]).text)
37
+ assert.equal(scheduled.cwd,await realpath(join(workspace,'work/tasks',id)));assert.equal(main.cwd,await realpath(workspace))
38
+ assert.equal(scheduled.token,undefined);assert.equal(main.token,undefined)
39
+ // A malformed scheduled request must not unwind the shared host service.
40
+ await writeFile(join(controlDir,'runs/r_schedule_corrupt.json'),'{')
41
+ await submit('r_schedule_corrupt')
42
+ await until(async()=>(await exists('r_schedule_corrupt.events')).includes('"stream":"exit","code":1'))
43
+ assert.ok(!(await exists(id+'.events')).includes('"stream":"exit"'))
44
+ await ownerRun(controlDir,'tg_2')
45
+ await submit('tg_2')
46
+ await until(async()=>(await exists('tg_2.events')).includes('"stream":"exit","code":0'))
47
+ await writeFile(join(dir,id+'.cancel'),'')
48
+ await until(async()=>(await exists(id+'.events')).includes('"stream":"exit"'))
49
+ }finally{
50
+ abort.abort();await server
51
+ Object.assign(EXECUTOR_REGISTRY.grok,old)
52
+ if(token===undefined)delete process.env.TELEGRAM_BOT_TOKEN;else process.env.TELEGRAM_BOT_TOKEN=token
53
+ await rm(root,{recursive:true,force:true})
54
+ }
55
+ })
@@ -0,0 +1,67 @@
1
+ import test from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { mkdtemp, rm, writeFile } from 'node:fs/promises'
4
+ import { join } from 'node:path'
5
+ import { tmpdir } from 'node:os'
6
+ import { spawn } from 'node:child_process'
7
+ import { once } from 'node:events'
8
+ import type { Update } from 'grammy/types'
9
+ import { createRelay } from '../src/index.js'
10
+ import { ControlStore } from '../src/control-state.js'
11
+ import { RunStore } from '../src/runs.js'
12
+ import { Scheduler } from '../src/scheduler.js'
13
+ import { initialPreset } from '../src/ai.js'
14
+
15
+ const until=async(check:()=>Promise<boolean>)=>{for(let i=0;i<200;i++){if(await check())return;await new Promise(r=>setTimeout(r,20))}throw new Error('Timed out')}
16
+ test('chat replies through the real ingress/outbox while scheduled CLI remains alive; targeted cancellation',async()=>{
17
+ const dir=await mkdtemp(join(tmpdir(),'ez-scheduler-relay-')), children:ReturnType<typeof spawn>[]=[]
18
+ const runs=new RunStore(dir),scheduler=new Scheduler(dir),control=new ControlStore(dir,1000)
19
+ const replies:string[]=[], workspaces:string[]=[]
20
+ const relay=createRelay({workspace:dir,controlDir:dir,pairingTtlMs:1000,executorTimeoutMs:0,executorCli:'grok',telegramBotToken:'fixture'},async(texts,options)=>{
21
+ workspaces.push(options.workspace)
22
+ const background=options.runId.startsWith('r_schedule_')
23
+ const child=spawn(process.execPath,['-e',background ? 'setInterval(()=>{},1000)' : 'setTimeout(()=>{},100)'],{detached:process.platform!=='win32'})
24
+ children.push(child);await once(child,'spawn')
25
+ if(!background)await runs.enqueueMessage(options.runId,'323')
26
+ return {child,cleanup:async()=>{},stdout:''}
27
+ })
28
+ relay.bot.botInfo={id:999,is_bot:true,first_name:'Fixture',username:'fixture_bot'} as typeof relay.bot.botInfo
29
+ relay.bot.api.config.use(async(_prev,method,payload)=>{if(method==='sendMessage')replies.push((payload as {text:string}).text);return {ok:true,result:{message_id:42}} as never})
30
+ try{
31
+ await control.requestPairing(101,101);await control.approveOwner(101)
32
+ const owner=(await control.status()).owner!,execution=await control.captureChoice(initialPreset('grok'))
33
+ const due=Date.now()+2000
34
+ await scheduler.save({id:'slow',name:'Slow',text:'Long work',trigger:{at:new Date(due).toISOString()},enabled:true,owner,execution})
35
+ await scheduler.tick(owner,runs,due);await relay.drainSources()
36
+ const [background]=await runs.list()
37
+ assert.equal(background.status,'running')
38
+ const update:Update={update_id:123,message:{message_id:123,date:0,text:'What is 17 × 19?',from:{id:101,is_bot:false,first_name:'Fixture'},chat:{id:101,type:'private',first_name:'Fixture'}}}
39
+ await relay.bot.handleUpdate(update);await relay.drainInbox(true);await relay.drainOutbox()
40
+ assert.ok(replies.includes('323'))
41
+ assert.equal((await runs.get(background.id))?.status,'running')
42
+ assert.equal(children[0].exitCode,null)
43
+ assert.equal(new Set(workspaces).size,2)
44
+ // Pausing future dispatch doesn't kill active work; cancelling this run does.
45
+ await scheduler.enable('slow',false);await relay.drainSources()
46
+ assert.equal(children[0].exitCode,null)
47
+ await scheduler.cancel(background.id);await relay.drainSources()
48
+ await until(async()=> (await runs.get(background.id))?.status==='cancelled')
49
+ assert.equal((await runs.list()).filter(r=>r.scheduled).length,1)
50
+ for(let n=0;n<5;n++)await scheduler.save({id:'pool_'+n,name:'Pool',text:'Long work',trigger:{at:new Date(Date.now()+2000).toISOString()},enabled:true,owner,execution})
51
+ await scheduler.tick(owner,runs,Date.now()+3000);await relay.drainSources()
52
+ const queued=(await runs.list()).find(r=>r.scheduled && r.status==='queued')!
53
+ assert.ok(queued)
54
+ assert.equal((await runs.list()).filter(r=>r.scheduled && r.status==='running').length,4)
55
+ await scheduler.enable(queued.scheduled!.id,false);await relay.drainSources()
56
+ assert.equal((await runs.get(queued.id))?.status,'queued')
57
+ await scheduler.enable(queued.scheduled!.id,true);await scheduler.cancel(queued.id);await relay.drainSources()
58
+ assert.equal((await runs.get(queued.id))?.status,'cancelled')
59
+ await relay.bot.handleUpdate({...update,update_id:124,message:{...update.message!,message_id:124,text:'/stop'}} as Update)
60
+ await until(async()=>!(await runs.list()).some(r=>r.scheduled && r.status==='running'))
61
+ }finally{
62
+ await relay.stop()
63
+ for(const child of children)if(child.exitCode===null && child.signalCode===null)await once(child,'close')
64
+ await until(async()=>!(await runs.list()).some(r=>r.status==='running'))
65
+ await rm(dir,{recursive:true,force:true})
66
+ }
67
+ })