@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.
- package/.dockerignore +1 -0
- package/.env.example +1 -1
- package/AGENTS.md +10 -1
- package/CHANGELOG.md +22 -0
- package/CONTRIBUTING.md +3 -0
- package/README.md +112 -10
- package/SECURITY.md +7 -1
- package/bin/ezenciel-agents-schedule +2 -0
- package/bin/ezenciel-agents-schedule.mjs +16 -0
- package/bin/ezenciel-agents-task +2 -0
- package/bin/ezenciel-agents-task.mjs +16 -0
- package/compose.yaml +2 -1
- package/docker/recovery.ts +2 -2
- package/docker/run.ts +2 -2
- package/docs/architecture/authority-boundaries.md +114 -12
- package/docs/architecture/event-sources.md +12 -7
- package/docs/channel-backend.md +36 -0
- package/docs/local-qa.md +45 -0
- package/docs/plugin-catalog.md +54 -0
- package/docs/plugin-contributions.md +3 -0
- package/docs/plugins.md +49 -0
- package/docs/scheduling.md +127 -0
- package/docs/selective-monitoring.md +106 -0
- package/docs/setup.md +7 -0
- package/docs/standalone-cli.md +62 -0
- package/package.json +7 -2
- package/scripts/smoke-scheduler.ts +90 -0
- package/scripts/stage-qa.mjs +42 -0
- package/src/channel-backend.ts +46 -0
- package/src/codex-session.ts +96 -0
- package/src/config.ts +6 -1
- package/src/desktop-bridge.ts +29 -11
- package/src/execution-authority.ts +24 -0
- package/src/executor.ts +66 -15
- package/src/host-executor.ts +30 -10
- package/src/inbox.ts +4 -0
- package/src/index.ts +130 -34
- package/src/plugins/exposure.mjs +13 -0
- package/src/plugins/manager.mjs +27 -12
- package/src/process-tree.ts +33 -0
- package/src/runs.ts +50 -17
- package/src/schedule-cli.ts +69 -0
- package/src/schedule-time.ts +85 -0
- package/src/scheduler.ts +121 -0
- package/src/source-cli.ts +1 -1
- package/src/task-cli.ts +16 -0
- package/src/task-executor.ts +63 -0
- package/src/task-mcp.ts +36 -0
- package/src/task-rpc.ts +45 -0
- package/src/task-workspace.ts +22 -0
- package/src/tasks.ts +192 -0
- package/src/updates/binding.mjs +1 -0
- package/src/updates/status.mjs +7 -1
- package/templates/agent/TOOLS.md +54 -1
- package/templates/standalone-tools.md +20 -0
- package/test/channel-backend.test.ts +100 -0
- package/test/codex-context.test.ts +36 -1
- package/test/codex-session.test.ts +49 -0
- package/test/config.test.ts +2 -2
- package/test/desktop-bridge.test.ts +19 -0
- package/test/event-sources.test.ts +47 -11
- package/test/execution-authority.test.ts +42 -0
- package/test/executor.test.ts +42 -1
- package/test/helpers/owner-run.ts +13 -0
- package/test/host-executor.test.ts +9 -3
- package/test/local-qa.test.mjs +38 -0
- package/test/plugin-manager.test.mjs +70 -1
- package/test/schedule-cli.test.ts +49 -0
- package/test/scheduler-host.test.ts +55 -0
- package/test/scheduler-relay.test.ts +67 -0
- package/test/scheduler.test.ts +104 -0
- package/test/task-native.test.ts +87 -0
- 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
|
|
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,
|
|
61
|
-
|
|
62
|
-
assert.equal(
|
|
63
|
-
assert.equal(
|
|
64
|
-
|
|
65
|
-
assert.equal(f.
|
|
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()
|
|
68
|
-
|
|
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
|
+
})
|
package/test/executor.test.ts
CHANGED
|
@@ -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)),
|
|
72
|
-
assert.equal(
|
|
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
|
+
})
|