@jc_stack/ez-agents 0.1.0-beta.34 → 0.1.0-beta.36
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 +5 -16
- package/AGENTS.md +17 -9
- package/CHANGELOG.md +67 -0
- package/Dockerfile +7 -9
- package/README.md +10 -13
- package/bin/ez +97 -0
- package/bin/ezenciel-agents-plugin-broker.mjs +16 -0
- package/bin/ezenciel-agents-provision-telegram.mjs +13 -0
- package/bin/ezenciel-agents.mjs +1 -1
- package/compose.yaml +40 -4
- package/docker/entrypoint.sh +1 -1
- package/docker/healthcheck.mjs +12 -4
- package/docker/run.ts +5 -6
- package/docker/smoke.mjs +28 -6
- package/docs/application-channel.md +32 -10
- package/docs/architecture/authority-boundaries.md +2 -2
- package/docs/channel-backend.md +4 -3
- package/docs/docker-runtime.md +79 -11
- package/docs/local-qa.md +2 -2
- package/docs/plugin-guide.md +202 -0
- package/docs/plugins.md +48 -2
- package/docs/releasing.md +13 -0
- package/docs/responsive-channels.md +1 -1
- package/docs/scheduling.md +4 -4
- package/docs/setup.md +11 -5
- package/docs/upgrades.md +46 -23
- package/package.json +5 -5
- package/scripts/smoke-scheduler.ts +10 -3
- package/scripts/smoke.ts +14 -11
- package/src/agent-guidance.ts +6 -9
- package/src/agent-install.ts +28 -7
- package/src/application-channel.ts +78 -7
- package/src/application-cli.ts +2 -2
- package/src/approval-cli.ts +14 -7
- package/src/approval.ts +27 -37
- package/src/channel-backend.ts +2 -3
- package/src/config.ts +12 -27
- package/src/control-state.ts +175 -8
- package/src/conversation-menu.ts +6 -17
- package/src/delivery-socket-client.d.mts +4 -0
- package/src/delivery-socket-client.mjs +104 -0
- package/src/delivery-socket.ts +354 -0
- package/src/execution-authority.ts +21 -5
- package/src/executor.ts +20 -23
- package/src/files.ts +38 -11
- package/src/host-executor.ts +28 -14
- package/src/inbox.ts +15 -49
- package/src/index.ts +95 -52
- package/src/install-tools.mjs +97 -22
- package/src/ledger-endpoint.d.mts +2 -0
- package/src/ledger-endpoint.mjs +17 -0
- package/src/menu.ts +7 -2
- package/src/message-history.ts +13 -18
- package/src/message.ts +35 -18
- package/src/plugin-broker.mjs +396 -0
- package/src/plugins/connection.mjs +2 -2
- package/src/plugins/manager.mjs +132 -23
- package/src/plugins/native-tasks.mjs +2 -2
- package/src/prompt-suffix.ts +12 -0
- package/src/react.ts +6 -4
- package/src/runs.ts +156 -243
- package/src/schedule-cli.ts +29 -10
- package/src/scheduled-tasks.ts +53 -10
- package/src/scheduler.ts +20 -7
- package/src/service.ts +3 -3
- package/src/software-status.ts +36 -4
- package/src/task-executor.ts +2 -2
- package/src/task-workspace.ts +4 -3
- package/src/tasks.ts +1 -1
- package/src/telegram-provisioning-cli.ts +22 -0
- package/src/telegram-provisioning.ts +214 -0
- package/src/updates/control.mjs +77 -7
- package/src/updates/runtime.mjs +28 -8
- package/src/updates/status.mjs +25 -3
- package/src/updates/supervisor.mjs +15 -16
- package/src/version.ts +20 -0
- package/src/workspace.ts +3 -9
- package/templates/failure-review.md +1 -1
- package/templates/maintainer-purpose.md +1 -1
- package/test/agent-guidance.test.ts +14 -80
- package/test/agent-install.test.ts +14 -0
- package/test/ai.test.ts +36 -0
- package/test/application-channel.test.ts +61 -13
- package/test/application-only.test.ts +14 -13
- package/test/channel-backend.test.ts +3 -7
- package/test/channel-delivery.test.ts +4 -1
- package/test/channel-owner.test.ts +6 -3
- package/test/codex-context.test.ts +2 -2
- package/test/config.test.ts +38 -43
- package/test/control-state.test.ts +83 -4
- package/test/conversation-menu.test.ts +2 -4
- package/test/delivery-transport.test.ts +72 -0
- package/test/docker-runtime.test.ts +27 -14
- package/test/engine-handoff.test.ts +7 -6
- package/test/event-sources.test.ts +2 -2
- package/test/execution-authority.test.ts +9 -9
- package/test/executor.test.ts +10 -11
- package/test/failure.test.ts +12 -5
- package/test/files.test.ts +2 -2
- package/test/helpers/ledger.ts +18 -0
- package/test/host-executor.test.ts +74 -0
- package/test/inbox.test.ts +8 -10
- package/test/install-config.test.ts +1 -0
- package/test/install-tools.test.mjs +74 -7
- package/test/intake-relay.test.ts +58 -11
- package/test/message-history.test.ts +39 -47
- package/test/native-tasks.test.ts +1 -1
- package/test/plugin-broker.test.mjs +172 -0
- package/test/plugin-manager.test.mjs +119 -2
- package/test/reaction.test.ts +4 -0
- package/test/relay.test.ts +5 -7
- package/test/repair-policy.test.ts +1 -4
- package/test/schedule-cli.test.ts +14 -9
- package/test/scheduled-tasks.test.ts +29 -6
- package/test/scheduler-host.test.ts +5 -5
- package/test/scheduler-relay.test.ts +15 -9
- package/test/security.test.ts +6 -3
- package/test/software-status.test.ts +21 -3
- package/test/telegram-provisioning.test.ts +160 -0
- package/test/updates.test.mjs +93 -13
- package/test/workspace.test.ts +4 -1
- package/bin/ezenciel-agents-watch.mjs +0 -8
- package/compose.workforce-watch.yaml +0 -33
- package/docker/recovery.ts +0 -11
- package/docs/pagerduty.md +0 -42
- package/docs/workforce-watch.md +0 -101
- package/src/pagerduty.ts +0 -109
- package/src/reply-context.ts +0 -43
- package/src/update-attention.ts +0 -18
- package/src/workforce-watch-cli.ts +0 -14
- package/src/workforce-watch.ts +0 -155
- package/templates/agent/AGENTS.md +0 -5
- package/templates/agent-guidance.md +0 -17
- package/test/pagerduty.test.ts +0 -104
- package/test/update-attention.test.ts +0 -20
- package/test/workforce-watch.test.ts +0 -180
|
@@ -5,7 +5,6 @@ import { tmpdir } from 'node:os'
|
|
|
5
5
|
import { join } from 'node:path'
|
|
6
6
|
import type { Context, InlineKeyboard } from 'grammy'
|
|
7
7
|
import { ControlStore } from '../src/control-state.js'
|
|
8
|
-
import { RunStore } from '../src/runs.js'
|
|
9
8
|
import { createConversationMenu } from '../src/conversation-menu.js'
|
|
10
9
|
import { initialPreset } from '../src/ai.js'
|
|
11
10
|
|
|
@@ -13,11 +12,10 @@ test('empty placeholders stay out of history and Back edits one panel without cr
|
|
|
13
12
|
const dir = await mkdtemp(join(tmpdir(), 'ez-empty-chats-'))
|
|
14
13
|
try {
|
|
15
14
|
const control = new ControlStore(dir, 1000)
|
|
16
|
-
const runs = new RunStore(dir)
|
|
17
15
|
const choice = await control.captureChoice(initialPreset('grok'), 'Client launch')
|
|
18
16
|
await control.markSessionStarted(choice.sessionId)
|
|
19
17
|
for (let n = 0; n < 3; n++) await control.resetSession()
|
|
20
|
-
const menu = createConversationMenu(control
|
|
18
|
+
const menu = createConversationMenu(control)
|
|
21
19
|
let sent = 0, edited = 0, text = '', keyboard: InlineKeyboard
|
|
22
20
|
const ctx = {
|
|
23
21
|
callbackQuery: undefined,
|
|
@@ -55,7 +53,7 @@ test('an identical Back edit does not send another message', async () => {
|
|
|
55
53
|
const dir = await mkdtemp(join(tmpdir(), 'ez-same-menu-'))
|
|
56
54
|
try {
|
|
57
55
|
let sent = 0
|
|
58
|
-
const menu = createConversationMenu(new ControlStore(dir,1000)
|
|
56
|
+
const menu = createConversationMenu(new ControlStore(dir,1000))
|
|
59
57
|
await menu.handle({
|
|
60
58
|
callbackQuery:{data:'chat:list:0:0',message:{message_id:1}},
|
|
61
59
|
answerCallbackQuery:async()=>{},
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import test from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { mkdtemp, rm, unlink } from 'node:fs/promises'
|
|
4
|
+
import { tmpdir } from 'node:os'
|
|
5
|
+
import { join } from 'node:path'
|
|
6
|
+
import { createConnection } from 'node:net'
|
|
7
|
+
import { createLedgerHandler, deliverySocketPath, serveDeliverySocket } from '../src/delivery-socket.js'
|
|
8
|
+
import { callDeliverySocket } from '../src/delivery-socket-client.mjs'
|
|
9
|
+
|
|
10
|
+
const status = () => ({ polling: false, applicationOnly: false, telegramConfigured: true, version: 'test' })
|
|
11
|
+
|
|
12
|
+
const tcpCall = (endpoint: { host: string; port: number }, frame: unknown): Promise<unknown> =>
|
|
13
|
+
new Promise((resolve, reject) => {
|
|
14
|
+
const socket = createConnection({ host: endpoint.host, port: endpoint.port })
|
|
15
|
+
let buffer = ''
|
|
16
|
+
socket.setEncoding('utf8')
|
|
17
|
+
socket.once('connect', () => socket.write(`${JSON.stringify(frame)}\n`))
|
|
18
|
+
socket.on('data', chunk => {
|
|
19
|
+
buffer += chunk
|
|
20
|
+
const newline = buffer.indexOf('\n')
|
|
21
|
+
if (newline < 0) return
|
|
22
|
+
socket.destroy()
|
|
23
|
+
resolve(JSON.parse(buffer.slice(0, newline)))
|
|
24
|
+
})
|
|
25
|
+
socket.once('close', () => resolve(undefined))
|
|
26
|
+
socket.once('error', reject)
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
test('host processes reach the relay ledger through the loopback endpoint when the Unix socket is unreachable', async t => {
|
|
30
|
+
const dir = await mkdtemp(join(tmpdir(), 'ez-delivery-tcp-'))
|
|
31
|
+
t.after(() => rm(dir, { recursive: true, force: true }))
|
|
32
|
+
const server = await serveDeliverySocket(dir, createLedgerHandler(dir, { wake: () => {}, status }), { tcpPort: 0 })
|
|
33
|
+
t.after(() => server.stop())
|
|
34
|
+
const socketPath = deliverySocketPath(dir)
|
|
35
|
+
assert.equal(server.endpoint?.host, '127.0.0.1')
|
|
36
|
+
assert.ok(server.endpoint && server.endpoint.port > 0)
|
|
37
|
+
const endpoint = server.endpoint!
|
|
38
|
+
// Container-local producers keep using the Unix socket.
|
|
39
|
+
assert.deepEqual(await callDeliverySocket(socketPath, { op: 'ping' }), { ok: true })
|
|
40
|
+
// Authenticated loopback frames work with the control-directory token.
|
|
41
|
+
assert.deepEqual(await tcpCall(endpoint, { id: '1', op: 'ping', token: endpoint.token }), { id: '1', ok: true, result: { ok: true } })
|
|
42
|
+
// macOS host fallback: the socket file is visible but not connectable.
|
|
43
|
+
await unlink(socketPath)
|
|
44
|
+
assert.deepEqual(await callDeliverySocket(socketPath, { op: 'ping' }), { ok: true })
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
test('loopback frames without the control-directory token are refused', async t => {
|
|
48
|
+
const dir = await mkdtemp(join(tmpdir(), 'ez-delivery-token-'))
|
|
49
|
+
t.after(() => rm(dir, { recursive: true, force: true }))
|
|
50
|
+
const server = await serveDeliverySocket(dir, createLedgerHandler(dir, { wake: () => {}, status }), { tcpPort: 0 })
|
|
51
|
+
t.after(() => server.stop())
|
|
52
|
+
const endpoint = server.endpoint!
|
|
53
|
+
assert.equal(await tcpCall(endpoint, { id: '1', op: 'ping' }), undefined)
|
|
54
|
+
assert.equal(await tcpCall(endpoint, { id: '1', op: 'ping', token: '0'.repeat(64) }), undefined)
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
test('the client fails closed when neither the socket nor an endpoint exists', async t => {
|
|
58
|
+
const dir = await mkdtemp(join(tmpdir(), 'ez-delivery-missing-'))
|
|
59
|
+
t.after(() => rm(dir, { recursive: true, force: true }))
|
|
60
|
+
await assert.rejects(callDeliverySocket(deliverySocketPath(dir), { op: 'ping' }, 500), /Delivery relay unavailable/)
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
test('a response timeout is not replayed over the loopback endpoint', async t => {
|
|
64
|
+
const dir = await mkdtemp(join(tmpdir(), 'ez-delivery-timeout-'))
|
|
65
|
+
t.after(() => rm(dir, { recursive: true, force: true }))
|
|
66
|
+
let calls = 0
|
|
67
|
+
const server = await serveDeliverySocket(dir, async () => { calls++; return new Promise(() => {}) }, { tcpPort: 0 })
|
|
68
|
+
t.after(() => server.stop())
|
|
69
|
+
assert.ok(server.endpoint)
|
|
70
|
+
await assert.rejects(callDeliverySocket(deliverySocketPath(dir), { op: 'enqueue' }, 200), /Delivery relay unavailable/)
|
|
71
|
+
assert.equal(calls, 1)
|
|
72
|
+
})
|
|
@@ -1,23 +1,36 @@
|
|
|
1
1
|
import assert from 'node:assert/strict'
|
|
2
2
|
import test from 'node:test'
|
|
3
|
-
import { mkdtemp, rm } from 'node:fs/promises'
|
|
3
|
+
import { mkdtemp, rm, stat } from 'node:fs/promises'
|
|
4
4
|
import { tmpdir } from 'node:os'
|
|
5
5
|
import { join } from 'node:path'
|
|
6
|
+
import { execFile } from 'node:child_process'
|
|
7
|
+
import { promisify } from 'node:util'
|
|
8
|
+
import { createRequire } from 'node:module'
|
|
6
9
|
import { RunStore } from '../src/runs.js'
|
|
7
|
-
import { recoverInterruptedRuns } from '../docker/recovery.js'
|
|
8
10
|
|
|
9
|
-
|
|
11
|
+
const exec = promisify(execFile)
|
|
12
|
+
const tsx = createRequire(import.meta.url).resolve('tsx')
|
|
13
|
+
|
|
14
|
+
test('a relay restart starts from an empty memory ledger and leaves no run files behind', async () => {
|
|
10
15
|
const dir = await mkdtemp(join(tmpdir(), 'ez-container-recovery-'))
|
|
11
16
|
try {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
const
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
17
|
+
// A previous relay process owned runs in its own memory. Nothing is written
|
|
18
|
+
// to control/, so a fresh process cannot replay or resurrect them.
|
|
19
|
+
const script = `
|
|
20
|
+
import { RunStore } from ${JSON.stringify(new URL('../src/runs.ts', import.meta.url).href)};
|
|
21
|
+
const store = new RunStore(process.argv[1]);
|
|
22
|
+
const run = await store.create({ chatId: 1, telegramUserId: 1, texts: ['old'] });
|
|
23
|
+
await store.patch(run.id, { status: 'running', pid: process.pid });
|
|
24
|
+
const queued = await store.create({ chatId: 1, telegramUserId: 1, texts: ['queued'] });
|
|
25
|
+
console.log(JSON.stringify({ count: (await store.list()).length }));
|
|
26
|
+
`
|
|
27
|
+
const { stdout } = await exec(process.execPath, ['--import', tsx, '--input-type=module', '-e', script, dir])
|
|
28
|
+
assert.equal(JSON.parse(stdout.trim().split('\n').at(-1)!).count, 2)
|
|
29
|
+
|
|
30
|
+
const fresh = new RunStore(dir)
|
|
31
|
+
assert.equal((await fresh.list()).length, 0, 'restart drops in-flight runs by design')
|
|
32
|
+
await assert.rejects(stat(join(dir, 'runs')), { code: 'ENOENT' })
|
|
33
|
+
await assert.rejects(stat(join(dir, 'outbox')), { code: 'ENOENT' })
|
|
34
|
+
await assert.rejects(stat(join(dir, 'inbox.json')), { code: 'ENOENT' })
|
|
35
|
+
} finally { await rm(dir, { recursive: true, force: true }) }
|
|
23
36
|
})
|
|
@@ -4,6 +4,7 @@ import { mkdtemp, mkdir, writeFile, readFile, rm } from 'node:fs/promises'
|
|
|
4
4
|
import { tmpdir } from 'node:os'
|
|
5
5
|
import path from 'node:path'
|
|
6
6
|
import { startExecutorJob } from '../src/executor.js'
|
|
7
|
+
import { runPromptSuffix } from '../src/prompt-suffix.js'
|
|
7
8
|
import { ownerRun } from './helpers/owner-run.js'
|
|
8
9
|
import { initializeWorkspace } from '../src/workspace.js'
|
|
9
10
|
import { RunStore } from '../src/runs.js'
|
|
@@ -61,16 +62,16 @@ if(args[0]==='app-server'){
|
|
|
61
62
|
}
|
|
62
63
|
for(const isResume of [false,true]) {
|
|
63
64
|
const runId='tg_chat_'+String(isResume)
|
|
64
|
-
await new RunStore(controlDir).create({id:runId,chatId:101,telegramUserId:101,texts:['hi'],messageId:42})
|
|
65
|
+
const chatRun=await new RunStore(controlDir).create({id:runId,chatId:101,telegramUserId:101,texts:['hi'],messageId:42})
|
|
65
66
|
await new RunStore(controlDir).patch(runId,{status:'running'})
|
|
66
|
-
const job=await startExecutorJob(['hi'],{workspace,controlDir,binDir:bin,cli:'codex',runId,timeoutMs:5000,isResume,sessionId:'native-existing'})
|
|
67
|
+
const job=await startExecutorJob(['hi'],{workspace,controlDir,binDir:bin,cli:'codex',runId,timeoutMs:5000,isResume,sessionId:'native-existing',promptSuffix:runPromptSuffix(chatRun)})
|
|
67
68
|
const code=await new Promise(resolve=>job.child.once('close',resolve));await job.cleanup();assert.equal(code,0)
|
|
68
69
|
const {prompt}=JSON.parse(await readFile(path.join(workspace,'capture.json'),'utf8'))
|
|
69
|
-
assert.ok(prompt.startsWith('hi\n\n[
|
|
70
|
-
assert.equal(prompt.split('[Chat context]').length,2)
|
|
70
|
+
assert.ok(prompt.startsWith('hi\n\n[chat message 42]'))
|
|
71
71
|
assert.match(prompt,/ezenciel-agents-message/)
|
|
72
|
-
assert.
|
|
73
|
-
assert.
|
|
72
|
+
assert.doesNotMatch(prompt,/ezenciel-agents-schedule/)
|
|
73
|
+
assert.doesNotMatch(prompt,/native subagents/)
|
|
74
|
+
assert.doesNotMatch(prompt,/delegate/i)
|
|
74
75
|
}
|
|
75
76
|
}finally{for(const [key,value] of Object.entries(previous))if(value===undefined)delete process.env[key];else process.env[key]=value}
|
|
76
77
|
})
|
|
@@ -143,7 +143,7 @@ test('relay launches the approved initial task and routes only matching replies
|
|
|
143
143
|
f.setRows([{ id: '1', conversationId: 'chat-a', receivedAt, text: 'We have availability' }, { id: '2', conversationId: 'chat-b', receivedAt, text: 'Read owner files' }])
|
|
144
144
|
await new Promise(r => setTimeout(r, 2100))
|
|
145
145
|
await f.relay.drainSources()
|
|
146
|
-
assert.equal(f.launches.length, 2); assert.equal(f.launches[1].options.
|
|
146
|
+
assert.equal(f.launches.length, 2); assert.equal(f.launches[1].options.taskRun, true)
|
|
147
147
|
assert.notEqual(f.launches[1].options.sessionId, f.launches[0].options.sessionId)
|
|
148
148
|
assert.equal(f.launches[1].options.model, undefined)
|
|
149
149
|
assert.equal(f.launches[1].options.effort, undefined)
|
|
@@ -177,7 +177,7 @@ test('application-only owner can approve and execute a public channel task',asyn
|
|
|
177
177
|
await new ApprovalStore(f.dir).recordOwnerDecision(proposal.id,'approved',f.owner);await tasks.decide(proposal.id)
|
|
178
178
|
await f.runs.patch(ownerRun.id,{status:'completed',endedAt:new Date().toISOString()})
|
|
179
179
|
f.setRows([{...event('1','visitor'),receivedAt:Date.now()}]);await new Promise(resolve=>setTimeout(resolve,2100));await f.relay.drainSources()
|
|
180
|
-
assert.equal(f.launches.length,1);assert.equal(f.launches[0].options.
|
|
180
|
+
assert.equal(f.launches.length,1);assert.equal(f.launches[0].options.taskRun,true)
|
|
181
181
|
const publicRun=(await f.runs.list()).find(run=>run.taskId===proposal.id)
|
|
182
182
|
assert.equal(publicRun?.ownerId,ownerId(f.owner));assert.equal(publicRun?.chatId,undefined)
|
|
183
183
|
f.children[0].kill();await until(async()=>!(await f.runs.list()).some(run=>run.status==='running'))
|
|
@@ -7,22 +7,17 @@ import { ownerRun } from './helpers/owner-run.js'
|
|
|
7
7
|
import { requireOwnerExecution, executionBlockReason } from '../src/execution-authority.js'
|
|
8
8
|
import { ControlStore } from '../src/control-state.js'
|
|
9
9
|
import { RunStore } from '../src/runs.js'
|
|
10
|
-
import { EXECUTOR_REGISTRY, startExecutorJob } from '../src/executor.js'
|
|
11
10
|
|
|
12
|
-
test('
|
|
11
|
+
test('external core runs are rejected at admission, before the spawn core', async t => {
|
|
13
12
|
const dir = await mkdtemp(join(tmpdir(), 'ez-authority-'))
|
|
14
13
|
t.after(() => rm(dir, { recursive: true, force: true }))
|
|
15
14
|
await ownerRun(dir, 'r_external', {sourceId:'test', bindingId:'binding', eventIds:['1']})
|
|
16
|
-
|
|
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
|
-
}
|
|
15
|
+
await assert.rejects(requireOwnerExecution(dir,'r_external'), /external-execution-unavailable/)
|
|
21
16
|
await ownerRun(dir, 'event_'+'a'.repeat(64))
|
|
22
17
|
await assert.rejects(requireOwnerExecution(dir,'event_'+'a'.repeat(64)), /external-execution-unavailable/)
|
|
23
18
|
})
|
|
24
19
|
|
|
25
|
-
test('missing, corrupt, finished
|
|
20
|
+
test('missing, corrupt, finished and mismatched core runs fail closed without control writes', async t => {
|
|
26
21
|
const dir = await mkdtemp(join(tmpdir(), 'ez-authority-'))
|
|
27
22
|
t.after(() => rm(dir, { recursive: true, force: true }))
|
|
28
23
|
await assert.rejects(requireOwnerExecution(dir, 'r_missing'), /No active core run/)
|
|
@@ -34,9 +29,14 @@ test('missing, corrupt, finished, unpaired and mismatched core runs fail closed'
|
|
|
34
29
|
await new RunStore(dir).patch(run.id,{status:'completed'})
|
|
35
30
|
await assert.rejects(requireOwnerExecution(dir,run.id), /No active core run/)
|
|
36
31
|
await new RunStore(dir).patch(run.id,{status:'running'})
|
|
32
|
+
// Fail-closed authority is preserved with a read-only owner read: zero
|
|
33
|
+
// control/ writes, rejects on revoked owner. Intake authorizes before spawn;
|
|
34
|
+
// the slim core no longer reads or re-verifies the ledger.
|
|
37
35
|
await new ControlStore(dir,1000).revokeOwner()
|
|
38
36
|
await assert.rejects(requireOwnerExecution(dir,run.id), /owner-mismatch/)
|
|
39
|
-
|
|
37
|
+
// Ledger lives in relay memory; corrupt authority state still fails closed
|
|
38
|
+
// at the read-only owner check, and traversal fails at validation.
|
|
39
|
+
await writeFile(join(dir,'control-state.json'),'{broken')
|
|
40
40
|
await assert.rejects(requireOwnerExecution(dir,run.id))
|
|
41
41
|
await assert.rejects(requireOwnerExecution(dir,'../r_owner'))
|
|
42
42
|
})
|
package/test/executor.test.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { spawn } from 'node:child_process'
|
|
|
7
7
|
import { tmpdir } from 'node:os'
|
|
8
8
|
import path from 'node:path'
|
|
9
9
|
import { EXECUTOR_REGISTRY, antigravityInvocation, executorEnvironment, grokInvocation, grokJobEnv, opencodeInvocation, resolveExecutor, resolveHostCommand, startExecutorJob, terminateJob, validateCodexProvider } from '../src/executor.js'
|
|
10
|
+
import { requireOwnerExecution } from '../src/execution-authority.js'
|
|
10
11
|
import { splitTelegramText } from '../src/reply.js'
|
|
11
12
|
import { matchingProcessIds, processSnapshot } from '../src/process-tree.js'
|
|
12
13
|
|
|
@@ -60,10 +61,9 @@ test('native CLIs resolve from the host PATH, not a relative package name', () =
|
|
|
60
61
|
})
|
|
61
62
|
|
|
62
63
|
test('the executor receives a deliberately small environment', () => {
|
|
63
|
-
const environment = executorEnvironment({ PATH: '/bin', HOME: '/tmp/home', TELEGRAM_BOT_TOKEN: 'secret',
|
|
64
|
+
const environment = executorEnvironment({ PATH: '/bin', HOME: '/tmp/home', TELEGRAM_BOT_TOKEN: 'secret', AWS_SECRET_ACCESS_KEY: 'secret' })
|
|
64
65
|
assert.deepEqual(environment, { PATH: '/bin', HOME: '/tmp/home' })
|
|
65
66
|
assert.ok(!('TELEGRAM_BOT_TOKEN' in environment))
|
|
66
|
-
assert.ok(!('PAGERDUTY_ROUTING_KEY' in environment))
|
|
67
67
|
})
|
|
68
68
|
|
|
69
69
|
test('the Grok job env binds the run and still strips the bot token', () => {
|
|
@@ -73,6 +73,7 @@ test('the Grok job env binds the run and still strips the bot token', () => {
|
|
|
73
73
|
)
|
|
74
74
|
assert.equal(environment.EZ_RUN_ID, 'r_1')
|
|
75
75
|
assert.equal(environment.EZ_CONTROL_DIR, '/tmp/control')
|
|
76
|
+
assert.equal(environment.EZ_DELIVERY_SOCKET, '/tmp/control/delivery.sock')
|
|
76
77
|
assert.match(environment.PATH ?? '', /^\/tmp\/bin/)
|
|
77
78
|
assert.ok(!('TELEGRAM_BOT_TOKEN' in environment))
|
|
78
79
|
})
|
|
@@ -171,7 +172,7 @@ test('Codex custom providers use declared runtime state and an environment key r
|
|
|
171
172
|
assert.ok(args.includes('google/gemini-3.8-flash'))
|
|
172
173
|
assert.equal(JSON.stringify(args).includes('provider-secret'),false)
|
|
173
174
|
assert.throws(()=>validateCodexProvider({...provider,envKey:'TELEGRAM-BOT'}),/environment key/)
|
|
174
|
-
for (const envKey of ['PATH','HOME','CODEX_HOME','NODE_OPTIONS','EZ_CONTROL_DIR','TELEGRAM_BOT_TOKEN'
|
|
175
|
+
for (const envKey of ['PATH','HOME','CODEX_HOME','NODE_OPTIONS','EZ_CONTROL_DIR','TELEGRAM_BOT_TOKEN'])
|
|
175
176
|
assert.throws(()=>validateCodexProvider({...provider,envKey}),/Reserved/)
|
|
176
177
|
assert.throws(()=>validateCodexProvider({...provider,models:[]}),/provider models/)
|
|
177
178
|
})
|
|
@@ -230,21 +231,19 @@ test('external Codex isolation cannot launch host runs', async t => {
|
|
|
230
231
|
const root = await mkdtemp(path.join(tmpdir(), 'ez-external-sandbox-'))
|
|
231
232
|
t.after(() => rm(root,{recursive:true,force:true}))
|
|
232
233
|
await ownerRun(root,'r_sandbox')
|
|
233
|
-
const previous =
|
|
234
|
+
const previous = process.env.EZ_EXECUTOR_TRANSPORT
|
|
234
235
|
try {
|
|
235
|
-
process.env.EZ_TELEGRAM_ENABLED='false'
|
|
236
236
|
for (const transport of ['host','']) {
|
|
237
237
|
process.env.EZ_EXECUTOR_TRANSPORT=transport
|
|
238
238
|
await assert.rejects(startExecutorJob(['Hello'],{workspace:root,controlDir:root,binDir:root,runId:'r_sandbox',timeoutMs:0,cli:'codex',codexSandbox:'external'}), /owner-authorized native local/)
|
|
239
239
|
}
|
|
240
240
|
} finally {
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
}
|
|
241
|
+
if (previous === undefined) delete process.env.EZ_EXECUTOR_TRANSPORT
|
|
242
|
+
else process.env.EZ_EXECUTOR_TRANSPORT=previous
|
|
244
243
|
}
|
|
245
244
|
})
|
|
246
245
|
|
|
247
|
-
test('external local owner chat preserves
|
|
246
|
+
test('external local owner chat preserves literal input and admission rejects foreign or restricted runs', async t => {
|
|
248
247
|
const root=await mkdtemp(path.join(tmpdir(),'ez-external-owner-'))
|
|
249
248
|
t.after(()=>rm(root,{recursive:true,force:true}))
|
|
250
249
|
const prior={path:process.env.PATH,transport:process.env.EZ_EXECUTOR_TRANSPORT}
|
|
@@ -264,10 +263,10 @@ test('external local owner chat preserves authorization and literal input', asyn
|
|
|
264
263
|
const runs=new RunStore(root)
|
|
265
264
|
await runs.create({id:'r_foreign',chatId:999,telegramUserId:999,texts:['no']})
|
|
266
265
|
await runs.patch('r_foreign',{status:'running'})
|
|
267
|
-
await assert.rejects(
|
|
266
|
+
await assert.rejects(requireOwnerExecution(root,'r_foreign'),/blocked: owner-mismatch/i)
|
|
268
267
|
await runs.create({id:'r_restricted',chatId:101,telegramUserId:101,texts:['no'],taskId:'task_'+'a'.repeat(32)})
|
|
269
268
|
await runs.patch('r_restricted',{status:'running'})
|
|
270
|
-
await assert.rejects(
|
|
269
|
+
await assert.rejects(requireOwnerExecution(root,'r_restricted'),/external-execution-unavailable/)
|
|
271
270
|
} finally {
|
|
272
271
|
if(prior.path===undefined)delete process.env.PATH;else process.env.PATH=prior.path
|
|
273
272
|
if(prior.transport===undefined)delete process.env.EZ_EXECUTOR_TRANSPORT;else process.env.EZ_EXECUTOR_TRANSPORT=prior.transport
|
package/test/failure.test.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import test from 'node:test'
|
|
2
2
|
import assert from 'node:assert/strict'
|
|
3
|
-
import { mkdtemp, mkdir, readFile, writeFile, rm } from 'node:fs/promises'
|
|
3
|
+
import { mkdtemp, mkdir, readFile, writeFile, rm, stat } from 'node:fs/promises'
|
|
4
4
|
import { join } from 'node:path'
|
|
5
5
|
import { tmpdir } from 'node:os'
|
|
6
6
|
import { execFile, spawn } from 'node:child_process'
|
|
@@ -8,11 +8,12 @@ import { promisify } from 'node:util'
|
|
|
8
8
|
import { fileURLToPath } from 'node:url'
|
|
9
9
|
import { once } from 'node:events'
|
|
10
10
|
import { redactFailure, failureStamp, needsFailureReview } from '../src/failure.js'
|
|
11
|
-
import { RunStore } from '../src/runs.js'
|
|
11
|
+
import { RunStore, sentOutbox } from '../src/runs.js'
|
|
12
12
|
import { ControlStore } from '../src/control-state.js'
|
|
13
13
|
import { Scheduler } from '../src/scheduler.js'
|
|
14
14
|
import { initialPreset } from '../src/ai.js'
|
|
15
15
|
import { createRelay } from '../src/index.js'
|
|
16
|
+
import { serveTestLedger } from './helpers/ledger.js'
|
|
16
17
|
import { TelegramSource } from '../src/telegram-source.js'
|
|
17
18
|
import { packageVersion } from '../src/version.js'
|
|
18
19
|
import { initializeWorkspace } from '../src/workspace.js'
|
|
@@ -33,6 +34,8 @@ test('failure evidence is bounded and redacts configured credentials, headers, t
|
|
|
33
34
|
|
|
34
35
|
test('failure review CLI is owner-bound, rejects stale reviews, and keeps failure evidence immutable',async t=>{
|
|
35
36
|
const dir=await mkdtemp(join(tmpdir(),'ez-failure-cli-'));t.after(()=>rm(dir,{recursive:true,force:true}))
|
|
37
|
+
// The schedule CLI child reaches the relay memory ledger through the socket.
|
|
38
|
+
const ledger=await serveTestLedger(dir);t.after(()=>ledger.stop())
|
|
36
39
|
const runs=new RunStore(dir),control=new ControlStore(dir,1000)
|
|
37
40
|
const env={...process.env,EZ_CONTROL_DIR:dir,EZ_EXECUTOR_CLI:'grok',EZ_RUN_ID:''}
|
|
38
41
|
const cli=(args:string[],overrides={})=>exec(process.execPath,[bin,...args],{env:{...env,...overrides}})
|
|
@@ -61,7 +64,7 @@ test('failure review CLI is owner-bound, rejects stale reviews, and keeps failur
|
|
|
61
64
|
await runs.patch(id,{status:'running',...('replyOnly' in extra?{replyOnly:true}:{})})
|
|
62
65
|
await assert.rejects(cli(review,{EZ_RUN_ID:id}),/owner-authorized/)
|
|
63
66
|
}
|
|
64
|
-
await
|
|
67
|
+
await assert.rejects(stat(join(dir,'runs')),{code:'ENOENT'})
|
|
65
68
|
assert.equal(JSON.parse((await cli(['failures'])).stdout).total,1)
|
|
66
69
|
})
|
|
67
70
|
|
|
@@ -86,6 +89,8 @@ test('failure capture, diagnosis, verified recovery and conditional quiet next t
|
|
|
86
89
|
})
|
|
87
90
|
relay.bot.botInfo={id:999,is_bot:true,first_name:'Fixture',username:'fixture_bot'} as any
|
|
88
91
|
relay.bot.api.config.use(async(_p,method,payload)=>{if(method==='sendMessage')replies.push((payload as any).text);return {ok:true,result:{message_id:replies.length}} as any})
|
|
92
|
+
// The schedule CLI children reach the relay memory ledger through the socket.
|
|
93
|
+
const ledger=await serveTestLedger(dir)
|
|
89
94
|
const originalPatch=RunStore.prototype.patch
|
|
90
95
|
t.mock.method(RunStore.prototype,'patch',async function(this:RunStore,...args:Parameters<RunStore['patch']>){
|
|
91
96
|
// Hold the PID write until the fast child has closed, reproducing slow disk
|
|
@@ -114,7 +119,7 @@ test('failure capture, diagnosis, verified recovery and conditional quiet next t
|
|
|
114
119
|
await relay.bot.handleUpdate({...update,update_id:12,message:{...update.message!,message_id:12,text:'/stop'}})
|
|
115
120
|
await until(async()=>(await runs.get('tg_11'))?.status==='cancelled')
|
|
116
121
|
assert.equal(needsFailureReview((await runs.get('tg_11'))!),false)
|
|
117
|
-
}finally{await relay.stop();for(const child of children)child.kill();await rm(dir,{recursive:true,force:true})}
|
|
122
|
+
}finally{await relay.stop();for(const child of children)child.kill();await ledger.stop();await rm(dir,{recursive:true,force:true})}
|
|
118
123
|
})
|
|
119
124
|
|
|
120
125
|
|
|
@@ -220,7 +225,7 @@ for (const cleanupFails of [false,true]) test(`polling conflict preserves work u
|
|
|
220
225
|
await stopped
|
|
221
226
|
assert.equal(relay.stop(),stopping,'finished shutdown remains idempotent')
|
|
222
227
|
assert.equal(sourceStops,1)
|
|
223
|
-
assert.deepEqual(
|
|
228
|
+
assert.deepEqual((sentOutbox(dir).find(entry=>entry.id===item.id)?.receipt as {messageIds:number[]}).messageIds,[1])
|
|
224
229
|
assert.equal(polls,1,'a conflict must not start another polling loop before the retry delay')
|
|
225
230
|
assert.equal(relay.bot.isRunning(),false)
|
|
226
231
|
assert.ok(child && (child.exitCode!==null || child.signalCode!==null))
|
|
@@ -268,6 +273,7 @@ for (const intake of [true,false]) test(`relay shutdown terminates an in-flight
|
|
|
268
273
|
|
|
269
274
|
test('group members can inspect failures and wake review without exposing other chats', async t => {
|
|
270
275
|
const dir=await mkdtemp(join(tmpdir(),'ez-group-failure-'));t.after(()=>rm(dir,{recursive:true,force:true}))
|
|
276
|
+
const ledger=await serveTestLedger(dir);t.after(()=>ledger.stop())
|
|
271
277
|
const runs=new RunStore(dir),control=new ControlStore(dir,900000),scheduler=new Scheduler(dir)
|
|
272
278
|
await control.requestPairing(101,-123,'Fixture');const owner=await control.approveOwner(-123,true)
|
|
273
279
|
const execution=await control.captureChoice(initialPreset('grok'))
|
|
@@ -334,6 +340,7 @@ test('shutdown aborts a pending bot initialization without starting polling',asy
|
|
|
334
340
|
|
|
335
341
|
test('failed reviewer readback exposes its stop through show, list and resume until explicit edit',async t=>{
|
|
336
342
|
const dir=await mkdtemp(join(tmpdir(),'ez-review-stop-'));t.after(()=>rm(dir,{recursive:true,force:true}))
|
|
343
|
+
const ledger=await serveTestLedger(dir);t.after(()=>ledger.stop())
|
|
337
344
|
const control=new ControlStore(dir,1000),runs=new RunStore(dir),scheduler=new Scheduler(dir)
|
|
338
345
|
await control.requestPairing(101,101);const owner=await control.approveOwner(101)
|
|
339
346
|
const execution=await control.captureChoice(initialPreset('grok')),now=Date.now()+1000
|
package/test/files.test.ts
CHANGED
|
@@ -35,13 +35,13 @@ test('sanitizes dangerous file names', () => {
|
|
|
35
35
|
assert.equal(sanitizeFileName('report 2026!@#.pdf'), 'report_2026___.pdf')
|
|
36
36
|
})
|
|
37
37
|
|
|
38
|
-
test('stages file into
|
|
38
|
+
test('stages file into control attachments, never the workspace', async () => {
|
|
39
39
|
const tmp = await mkdtemp(path.join(tmpdir(), 'ez-files-'))
|
|
40
40
|
try {
|
|
41
41
|
const data = Buffer.from('%PDF-1.4 sample content')
|
|
42
42
|
const staged = await stageIncomingFile(tmp, 'sample.pdf', data)
|
|
43
43
|
assert.equal(staged.fileType, 'pdf')
|
|
44
|
-
assert.match(staged.relativePath, /^
|
|
44
|
+
assert.match(staged.relativePath, /^attachments[/\\][a-f0-9-]+_sample\.pdf$/)
|
|
45
45
|
const read = await readFile(staged.fullPath)
|
|
46
46
|
assert.deepEqual(read, data)
|
|
47
47
|
} finally {
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { createLedgerHandler, serveDeliverySocket } from '../../src/delivery-socket.js'
|
|
2
|
+
|
|
3
|
+
// Serves the relay memory ledger for child-process CLIs (message, react,
|
|
4
|
+
// approval, schedule) in tests that do not start a full relay. Handlers run
|
|
5
|
+
// in this process, so test-created runs are visible to CLI children through
|
|
6
|
+
// the shared-volume socket. Fixtures that start a relay must NOT also serve:
|
|
7
|
+
// the relay serves the same path.
|
|
8
|
+
export const serveTestLedger = async (
|
|
9
|
+
controlDir: string,
|
|
10
|
+
status: () => { polling: boolean; applicationOnly: boolean; telegramConfigured: boolean; version: string } =
|
|
11
|
+
() => ({ polling: false, applicationOnly: false, telegramConfigured: true, version: 'test' }),
|
|
12
|
+
) => {
|
|
13
|
+
const server = await serveDeliverySocket(
|
|
14
|
+
controlDir,
|
|
15
|
+
createLedgerHandler(controlDir, { wake: () => {}, status }),
|
|
16
|
+
)
|
|
17
|
+
return server
|
|
18
|
+
}
|
|
@@ -69,6 +69,80 @@ test('host restart clears dead native lease only after proving previous CLI stop
|
|
|
69
69
|
}finally{abort.abort();await server;await rm(root,{recursive:true,force:true});}
|
|
70
70
|
});
|
|
71
71
|
|
|
72
|
+
test('host execution resolves a declared provider from the bound agent, not a launcher wrapper', async () => {
|
|
73
|
+
const root = await mkdtemp(path.join(tmpdir(), 'ez-host-provider-'))
|
|
74
|
+
const workspace = path.join(root, 'mind')
|
|
75
|
+
const controlDir = path.join(root, 'control')
|
|
76
|
+
const directory = path.join(controlDir, 'host-executor')
|
|
77
|
+
const provider = {
|
|
78
|
+
id: 'openrouter', name: 'OpenRouter', baseUrl: 'https://openrouter.ai/api/v1',
|
|
79
|
+
envKey: 'OPENROUTER_API_KEY', models: ['deepseek/deepseek-v4.1-flash'],
|
|
80
|
+
}
|
|
81
|
+
const installation = {
|
|
82
|
+
cli: 'codex' as const,
|
|
83
|
+
agents: [{name: 'test', workspace, controlDir, binDir: path.join(root, 'bin'), codexProviders: [provider]}],
|
|
84
|
+
}
|
|
85
|
+
const abort = new AbortController()
|
|
86
|
+
let server: Promise<void> | undefined
|
|
87
|
+
let captured: any
|
|
88
|
+
const previousPath = process.env.PATH
|
|
89
|
+
try {
|
|
90
|
+
await mkdir(workspace, {recursive: true})
|
|
91
|
+
await mkdir(directory, {recursive: true})
|
|
92
|
+
const fakeBin = path.join(root, 'bin')
|
|
93
|
+
await mkdir(fakeBin, {recursive: true})
|
|
94
|
+
await mkdir(path.join(controlDir, 'cli', 'codex'), {recursive: true})
|
|
95
|
+
await writeFile(path.join(fakeBin, 'codex'), '#!/bin/sh\nexit 0\n', {mode: 0o700})
|
|
96
|
+
await writeFile(path.join(controlDir, 'cli', 'codex', 'models_cache.json'), JSON.stringify({models: [
|
|
97
|
+
{slug: provider.models[0], visibility: 'list', display_name: 'DeepSeek V4.1 Flash', supported_reasoning_levels: [{effort: 'max'}]},
|
|
98
|
+
]}))
|
|
99
|
+
process.env.PATH = `${fakeBin}${path.delimiter}${previousPath ?? ''}`
|
|
100
|
+
await ownerRun(controlDir, 'r_provider')
|
|
101
|
+
server = serveHostExecutor(installation, abort.signal, async (_texts, options) => {
|
|
102
|
+
captured = options
|
|
103
|
+
const child = spawn(process.execPath, ['-e', 'process.exit(0)'])
|
|
104
|
+
return {child, cleanup: async () => {}, stdout: ''}
|
|
105
|
+
})
|
|
106
|
+
for (let n = 0; n < 100; n++) {
|
|
107
|
+
try { await readFile(path.join(directory, 'heartbeat.json')); break }
|
|
108
|
+
catch { await new Promise(resolve => setTimeout(resolve, 20)) }
|
|
109
|
+
}
|
|
110
|
+
await writeFile(path.join(directory, 'r_provider.request.json'), JSON.stringify({
|
|
111
|
+
texts: ['hello'], options: {cli: 'codex', provider: 'openrouter', model: provider.models[0], effort: 'max'},
|
|
112
|
+
}))
|
|
113
|
+
let events = ''
|
|
114
|
+
for (let n = 0; n < 100; n++) {
|
|
115
|
+
try {
|
|
116
|
+
events = await readFile(path.join(directory, 'r_provider.events'), 'utf8')
|
|
117
|
+
if (events.includes('"stream":"exit"')) break
|
|
118
|
+
} catch {}
|
|
119
|
+
await new Promise(resolve => setTimeout(resolve, 20))
|
|
120
|
+
}
|
|
121
|
+
assert.match(events, /"stream":"exit","code":0/)
|
|
122
|
+
const catalog = JSON.parse(await readFile(path.join(directory, 'models.json'), 'utf8'))
|
|
123
|
+
assert.deepEqual(catalog.filter((model: any) => model.model === provider.models[0]), [
|
|
124
|
+
{
|
|
125
|
+
cli: 'codex-gui', model: provider.models[0],
|
|
126
|
+
name: 'codex-gui · DeepSeek V4.1 Flash', efforts: ['max'],
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
cli: 'codex', provider: 'openrouter', model: provider.models[0],
|
|
130
|
+
name: `OpenRouter · ${provider.models[0]}`, efforts: ['max'],
|
|
131
|
+
},
|
|
132
|
+
])
|
|
133
|
+
assert.deepEqual(catalog.filter((model: any) => model.cli === 'codex' && !model.provider), [])
|
|
134
|
+
assert.equal(captured.provider, 'openrouter')
|
|
135
|
+
assert.deepEqual(captured.codexProvider, provider)
|
|
136
|
+
assert.equal(captured.model, provider.models[0])
|
|
137
|
+
} finally {
|
|
138
|
+
abort.abort()
|
|
139
|
+
await server
|
|
140
|
+
if (previousPath === undefined) delete process.env.PATH
|
|
141
|
+
else process.env.PATH = previousPath
|
|
142
|
+
await rm(root, {recursive: true, force: true})
|
|
143
|
+
}
|
|
144
|
+
})
|
|
145
|
+
|
|
72
146
|
test('one installed CLI executes two agent bindings with separate minds and sanitized environment', async () => {
|
|
73
147
|
const root=await mkdtemp(path.join(tmpdir(),'ez-host-'))
|
|
74
148
|
const abort=new AbortController()
|
package/test/inbox.test.ts
CHANGED
|
@@ -8,12 +8,11 @@ import { InboxStore } from '../src/inbox.js'
|
|
|
8
8
|
import { RunStore } from '../src/runs.js'
|
|
9
9
|
|
|
10
10
|
const update = (id: number): Update => ({ update_id: id })
|
|
11
|
-
test('intake
|
|
11
|
+
test('intake deduplicates IDs, seals membership and retains cancellation tombstones (memory only)', async () => {
|
|
12
12
|
const dir = await mkdtemp(join(tmpdir(), 'ez-inbox-'))
|
|
13
13
|
try {
|
|
14
|
-
|
|
14
|
+
const inbox = new InboxStore(dir)
|
|
15
15
|
assert.equal(await inbox.accept(update(1)), true)
|
|
16
|
-
inbox = new InboxStore(dir)
|
|
17
16
|
assert.equal(await inbox.accept(update(1)), false)
|
|
18
17
|
await inbox.accept(update(2))
|
|
19
18
|
assert.equal(await inbox.next(), undefined)
|
|
@@ -23,12 +22,11 @@ test('intake survives restart, deduplicates IDs, seals membership and retains ca
|
|
|
23
22
|
[1, 2],
|
|
24
23
|
)
|
|
25
24
|
await inbox.accept(update(3))
|
|
26
|
-
inbox = new InboxStore(dir)
|
|
27
25
|
assert.deepEqual(await inbox.next(true), batch)
|
|
28
26
|
const runs = new RunStore(dir)
|
|
29
27
|
const input = { id: batch.id, chatId: 101, telegramUserId: 101, texts: ['hello'] }
|
|
30
28
|
await runs.create(input)
|
|
31
|
-
//
|
|
29
|
+
// Retry with the same run ID returns the existing run; no second run.
|
|
32
30
|
await runs.create(input)
|
|
33
31
|
assert.equal((await runs.list()).length, 1)
|
|
34
32
|
await inbox.finish(batch.id)
|
|
@@ -36,19 +34,19 @@ test('intake survives restart, deduplicates IDs, seals membership and retains ca
|
|
|
36
34
|
assert.equal(await inbox.cancel(), 1)
|
|
37
35
|
assert.equal(await inbox.accept(update(3)), false)
|
|
38
36
|
assert.equal(await inbox.next(true), undefined)
|
|
39
|
-
|
|
37
|
+
// Stateless pipe: no inbox.json exists on disk; restart drops by design.
|
|
38
|
+
await assert.rejects(stat(join(dir, 'inbox.json')))
|
|
40
39
|
} finally {
|
|
41
40
|
await rm(dir, { recursive: true, force: true })
|
|
42
41
|
}
|
|
43
42
|
})
|
|
44
43
|
|
|
45
|
-
test('
|
|
44
|
+
test('restart loses nothing by design; invalid identifiers are rejected', async () => {
|
|
46
45
|
const dir = await mkdtemp(join(tmpdir(), 'ez-inbox-corrupt-'))
|
|
47
46
|
try {
|
|
48
|
-
|
|
47
|
+
// A fresh directory starts empty: no durable journal survives a restart.
|
|
49
48
|
const inbox = new InboxStore(dir)
|
|
50
|
-
|
|
51
|
-
assert.equal(await readFile(join(dir, 'inbox.json'), 'utf8'), '{broken')
|
|
49
|
+
assert.deepEqual(await inbox.status(), { pending: 0, failed: 0 })
|
|
52
50
|
assert.throws(() => inbox.accept(update(-1)), /Invalid update ID/)
|
|
53
51
|
await assert.rejects(
|
|
54
52
|
new RunStore(dir).create({ id: '../escape', chatId: 101, telegramUserId: 101, texts: [] }),
|
|
@@ -81,6 +81,7 @@ test('Docker startup uses explicit deployment binding and literal paths without
|
|
|
81
81
|
assert.equal(definition.command, 'docker')
|
|
82
82
|
assert.ok(definition.args.includes('/tmp/my agent $literal/docker.env'))
|
|
83
83
|
assert.deepEqual(definition.args.slice(-4), ['up', '-d', '--wait', 'relay'])
|
|
84
|
+
assert.deepEqual(serviceDefinition('/tmp/isolated', true).args.slice(-5), ['up', '-d', '--wait', 'relay', 'plugin-broker'])
|
|
84
85
|
assert.doesNotMatch(JSON.stringify(definition), /TELEGRAM_BOT_TOKEN|launchctl|systemctl/)
|
|
85
86
|
assert.throws(() => serviceDefinition('/tmp/\nbad'), /single-line/)
|
|
86
87
|
})
|