@jc_stack/ez-agents 0.1.0-beta.12 → 0.1.0-beta.18
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 +4 -0
- package/.env.example +16 -1
- package/AGENTS.md +16 -4
- package/CHANGELOG.md +71 -0
- package/CONTRIBUTING.md +37 -4
- package/README.md +114 -9
- 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 +10 -2
- package/docker/recovery.ts +2 -2
- package/docker/run.ts +2 -2
- package/docs/architecture/ai-selection.md +8 -0
- package/docs/architecture/authority-boundaries.md +137 -12
- package/docs/architecture/event-sources.md +12 -7
- package/docs/architecture/telegram-intake.md +1 -1
- package/docs/channel-backend.md +36 -0
- package/docs/docker-runtime.md +35 -0
- package/docs/host-service.md +19 -0
- package/docs/local-qa.md +45 -0
- package/docs/pagerduty.md +42 -0
- package/docs/plugin-catalog.md +71 -0
- package/docs/plugin-contributions.md +12 -0
- package/docs/plugins.md +61 -1
- package/docs/releasing.md +20 -9
- package/docs/repair.md +41 -0
- package/docs/scheduling.md +153 -0
- package/docs/selective-monitoring.md +114 -0
- package/docs/setup.md +46 -0
- package/docs/standalone-cli.md +62 -0
- package/docs/trusted-publishing.md +140 -0
- package/docs/upgrades.md +24 -4
- package/package.json +12 -4
- package/scripts/generate-publish-caller.mjs +60 -0
- package/scripts/smoke-busy-reply.ts +58 -0
- package/scripts/smoke-scheduler.ts +90 -0
- package/scripts/stage-qa.mjs +42 -0
- package/scripts/trusted-beta.mjs +289 -0
- package/src/agent-guidance.ts +5 -0
- package/src/ai-cli.ts +2 -1
- package/src/ai.ts +15 -5
- package/src/channel-backend.ts +46 -0
- package/src/client-defaults.ts +29 -13
- package/src/codex-session.ts +98 -0
- package/src/config.ts +35 -2
- package/src/control-state.ts +24 -7
- package/src/desktop-bridge.ts +37 -12
- package/src/event-sources.ts +2 -1
- package/src/execution-authority.ts +25 -0
- package/src/executor.ts +97 -21
- package/src/failure.ts +32 -0
- package/src/host-executor.ts +48 -19
- package/src/identity.ts +8 -3
- package/src/inbox.ts +11 -3
- package/src/index.ts +315 -91
- package/src/install-tools.mjs +2 -2
- package/src/menu.ts +6 -4
- package/src/model-policy.ts +15 -0
- package/src/owner.ts +3 -3
- package/src/pagerduty.ts +109 -0
- package/src/plugins/exposure.mjs +13 -0
- package/src/plugins/manager.mjs +74 -20
- package/src/plugins/shared.mjs +76 -0
- package/src/process-tree.ts +33 -0
- package/src/repair-policy.ts +13 -0
- package/src/reply-context.ts +67 -0
- package/src/reply-executor.ts +54 -0
- package/src/reply-mcp.ts +23 -0
- package/src/runs.ts +63 -19
- package/src/schedule-cli.ts +98 -0
- package/src/schedule-time.ts +85 -0
- package/src/scheduler.ts +130 -0
- package/src/setup.ts +2 -1
- package/src/software-status.ts +5 -5
- package/src/source-cli.ts +1 -1
- package/src/task-cli.ts +16 -0
- package/src/task-executor.ts +65 -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 +210 -0
- package/src/telegram-source.ts +94 -0
- package/src/updates/artifact.mjs +16 -0
- package/src/updates/binding.mjs +4 -1
- package/src/updates/control.mjs +4 -4
- package/src/updates/runtime.mjs +3 -1
- package/src/updates/status.mjs +7 -1
- package/templates/agent/AGENTS.md +10 -2
- package/templates/agent/TOOLS.md +60 -1
- package/templates/agent-guidance.md +13 -0
- package/templates/failure-review.md +9 -0
- package/templates/maintainer-purpose.md +15 -0
- package/templates/standalone-tools.md +20 -0
- package/templates/updates.md +2 -2
- package/test/agent-guidance.test.ts +110 -0
- package/test/ai-cli.test.ts +7 -6
- package/test/ai.test.ts +41 -0
- package/test/busy-reply-relay.test.ts +41 -0
- package/test/channel-backend.test.ts +100 -0
- package/test/client-defaults.test.ts +37 -5
- package/test/codex-context.test.ts +39 -1
- package/test/codex-session.test.ts +51 -0
- package/test/config.test.ts +31 -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 +53 -2
- package/test/failure.test.ts +250 -0
- package/test/group-owner.test.ts +36 -0
- package/test/helpers/owner-run.ts +13 -0
- package/test/host-executor.test.ts +47 -10
- package/test/intake-relay.test.ts +141 -4
- package/test/local-qa.test.mjs +38 -0
- package/test/model-policy.test.ts +61 -0
- package/test/pagerduty.test.ts +104 -0
- package/test/plugin-manager.test.mjs +73 -3
- package/test/relay.test.ts +2 -2
- package/test/repair-policy.test.ts +23 -0
- package/test/reply.test.ts +131 -0
- package/test/schedule-cli.test.ts +55 -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/shared-services.test.mjs +98 -0
- package/test/software-status.test.ts +5 -5
- package/test/task-native.test.ts +87 -0
- package/test/tasks.test.ts +187 -0
- package/test/telegram-source.test.ts +75 -0
- package/test/trusted-beta.test.mjs +224 -0
- package/test/updates.test.mjs +35 -3
package/docs/upgrades.md
CHANGED
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
Available in this beta. Earlier main upgrade/rollback VM QA passed; final-release
|
|
4
4
|
fresh-host/reboot and live plugin upgrade acceptance remain pending. npm
|
|
5
|
-
publication is not required to test this feature.
|
|
6
|
-
automatic channel
|
|
5
|
+
publication is not required to test this feature. The beta channel is the default
|
|
6
|
+
automatic channel for core and plugins without a saved policy. Existing explicit
|
|
7
|
+
stable or manual policies are preserved. The owner may select either per target.
|
|
7
8
|
The main target and installed plugins version independently.
|
|
8
9
|
|
|
9
10
|
The agent owns release review, policy decisions and communication. The host
|
|
@@ -109,8 +110,8 @@ live. A healthy container alone does not prove a Telegram or plugin reply.
|
|
|
109
110
|
|
|
110
111
|
```sh
|
|
111
112
|
ez updates check
|
|
112
|
-
ez updates policy main # defaults: automatic,
|
|
113
|
-
ez updates policy whatsapp
|
|
113
|
+
ez updates policy main # defaults: automatic, beta
|
|
114
|
+
ez updates policy whatsapp stable # opt into stable-only updates
|
|
114
115
|
ez updates policy main manual # disable unattended upgrades
|
|
115
116
|
ez updates prepare main --version 0.1.0-beta.4
|
|
116
117
|
# Or a local candidate, independently of npm:
|
|
@@ -191,3 +192,22 @@ changes also need supervisor restart and requesting-process-exit tests. Run
|
|
|
191
192
|
`node docker/upgrade-smoke.mjs` with a local `EZ_WHATSAPP_SOURCE` containing the
|
|
192
193
|
WhatsApp fixture. The smoke uses synthetic transport only. VM installation,
|
|
193
194
|
agent-led upgrades, restart and real account acceptance remain separate QA gates.
|
|
195
|
+
|
|
196
|
+
Beta policy discovers the newer of npm latest and the legacy beta tag. Stable-only
|
|
197
|
+
policy selects non-deprecated stable versions, even when latest is a prerelease.
|
|
198
|
+
No eligible version is reported as available:null with newer:false. Older installed
|
|
199
|
+
updaters need an exact-version core update to adopt this discovery behavior.
|
|
200
|
+
|
|
201
|
+
## Shared agent guidance
|
|
202
|
+
|
|
203
|
+
Ez includes `templates/agent-guidance.md` from the running package in every
|
|
204
|
+
owner-worker prompt, including resumed CLI and desktop conversations and scheduled
|
|
205
|
+
owner work. After the runtime upgrades, the next turn receives the new guidance.
|
|
206
|
+
An already running turn keeps its original prompt. Restricted contact tasks and
|
|
207
|
+
reply-only workers retain their separate, bounded instructions.
|
|
208
|
+
|
|
209
|
+
Keep general operating defaults in this package-owned file. Keep agent purpose,
|
|
210
|
+
preferences and local conventions in the workspace's `AGENTS.md`, `SOUL.md`,
|
|
211
|
+
`USER.md` and memory files; upgrades preserve them. Shared guidance does not
|
|
212
|
+
grant permissions, and explicit owner instructions take precedence over its
|
|
213
|
+
defaults within existing execution permissions.
|
package/package.json
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jc_stack/ez-agents",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
3
|
+
"version": "0.1.0-beta.18",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
|
-
"description": "A
|
|
6
|
+
"description": "A lightweight foundation for persistent business AI assistants using existing AI harnesses, workspaces and plugins.",
|
|
7
7
|
"packageManager": "pnpm@10.30.3",
|
|
8
8
|
"bin": {
|
|
9
|
+
"ezenciel-agents-schedule": "bin/ezenciel-agents-schedule.mjs",
|
|
9
10
|
"ezenciel-agents-install": "bin/ezenciel-agents-install",
|
|
10
11
|
"ezenciel-agents": "bin/ezenciel-agents.mjs",
|
|
11
12
|
"ezenciel-agents-owner": "bin/ezenciel-agents-owner.mjs",
|
|
@@ -13,6 +14,7 @@
|
|
|
13
14
|
"ezenciel-agents-react": "bin/ezenciel-agents-react.mjs",
|
|
14
15
|
"ezenciel-agents-approval": "bin/ezenciel-agents-approval.mjs",
|
|
15
16
|
"ezenciel-agents-setup": "bin/ezenciel-agents-setup.mjs",
|
|
17
|
+
"ezenciel-agents-task": "bin/ezenciel-agents-task.mjs",
|
|
16
18
|
"ezenciel-agents-source": "bin/ezenciel-agents-source.mjs",
|
|
17
19
|
"ezenciel-agents-docker": "bin/ezenciel-agents-docker",
|
|
18
20
|
"ezenciel-agents-create": "bin/ezenciel-agents-create",
|
|
@@ -36,18 +38,23 @@
|
|
|
36
38
|
"docker",
|
|
37
39
|
"compose.whatsapp.yaml",
|
|
38
40
|
"scripts/smoke.ts",
|
|
41
|
+
"scripts/smoke-scheduler.ts",
|
|
39
42
|
"LICENSE",
|
|
40
43
|
"CHANGELOG.md",
|
|
41
44
|
"THIRD_PARTY_NOTICES.md",
|
|
42
45
|
"scripts/release-check.mjs",
|
|
46
|
+
"scripts/stage-qa.mjs",
|
|
43
47
|
"test",
|
|
44
48
|
"tsconfig.json",
|
|
45
49
|
"scripts/assert-local-registry.mjs",
|
|
46
|
-
".dockerignore"
|
|
50
|
+
".dockerignore",
|
|
51
|
+
"scripts/trusted-beta.mjs",
|
|
52
|
+
"scripts/generate-publish-caller.mjs",
|
|
53
|
+
"scripts/smoke-busy-reply.ts"
|
|
47
54
|
],
|
|
48
55
|
"publishConfig": {
|
|
49
56
|
"access": "public",
|
|
50
|
-
"tag": "
|
|
57
|
+
"tag": "latest"
|
|
51
58
|
},
|
|
52
59
|
"scripts": {
|
|
53
60
|
"setup": "tsx --env-file-if-exists=.env src/setup.ts",
|
|
@@ -59,6 +66,7 @@
|
|
|
59
66
|
"build": "tsc --noEmit",
|
|
60
67
|
"test": "tsx --test test/*.test.ts test/*.test.mjs",
|
|
61
68
|
"verify": "pnpm test && pnpm build",
|
|
69
|
+
"smoke:scheduler": "tsx scripts/smoke-scheduler.ts",
|
|
62
70
|
"smoke": "./bin/ezenciel-agents-docker run --rm --no-deps relay smoke",
|
|
63
71
|
"prepublishOnly": "npm run verify",
|
|
64
72
|
"registry:up": "docker compose -f registry/docker-compose.yml up -d",
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Print a reviewed caller. Does not write repositories or enroll npm trust.
|
|
3
|
+
import assert from 'node:assert/strict';
|
|
4
|
+
import { pathToFileURL } from 'node:url';
|
|
5
|
+
|
|
6
|
+
export function generateCaller({ repository, packageName, publisherSha, checks }) {
|
|
7
|
+
assert.match(repository, /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/, 'Invalid repository');
|
|
8
|
+
assert.match(packageName, /^@[a-z0-9_-]+\/[a-z0-9_.-]+$/, 'Expected scoped npm package');
|
|
9
|
+
assert.match(publisherSha, /^[a-f0-9]{40}$/, 'Pin the reviewed shared publisher to a full commit SHA');
|
|
10
|
+
assert(Array.isArray(checks) && checks.length > 0 && checks.every(c => typeof c === 'string' && /^[A-Za-z0-9 (),_.-]+$/.test(c)), 'Required CI check names are mandatory');
|
|
11
|
+
const core = repository === 'jdorado/ez-agents';
|
|
12
|
+
const reference = core ? './.github/workflows/npm-beta-shared.yml' : `jdorado/ez-agents/.github/workflows/npm-beta-shared.yml@${publisherSha}`;
|
|
13
|
+
return `# Generated by scripts/generate-publish-caller.mjs; review changes before merging.
|
|
14
|
+
name: Publish verified npm beta
|
|
15
|
+
on:
|
|
16
|
+
workflow_dispatch:
|
|
17
|
+
inputs:
|
|
18
|
+
release-id:
|
|
19
|
+
description: 'Numeric ID of the staged draft prerelease'
|
|
20
|
+
required: true
|
|
21
|
+
type: string
|
|
22
|
+
version:
|
|
23
|
+
description: 'Reviewed beta version staged as draft release vVERSION'
|
|
24
|
+
required: true
|
|
25
|
+
type: string
|
|
26
|
+
source-sha:
|
|
27
|
+
description: 'Full tested commit SHA; must be current main'
|
|
28
|
+
required: true
|
|
29
|
+
type: string
|
|
30
|
+
artifact-sha256:
|
|
31
|
+
description: 'SHA-256 of the exact independently verified candidate.tgz'
|
|
32
|
+
required: true
|
|
33
|
+
type: string
|
|
34
|
+
permissions: {}
|
|
35
|
+
jobs:
|
|
36
|
+
publish:
|
|
37
|
+
if: github.repository == '${repository}' && github.ref == 'refs/heads/main'
|
|
38
|
+
permissions:
|
|
39
|
+
contents: write
|
|
40
|
+
checks: read
|
|
41
|
+
actions: read
|
|
42
|
+
pull-requests: read
|
|
43
|
+
id-token: write
|
|
44
|
+
uses: ${reference}
|
|
45
|
+
with:
|
|
46
|
+
package: '${packageName}'
|
|
47
|
+
publisher-sha: ${core ? '${{ github.sha }}' : `'${publisherSha}'`}
|
|
48
|
+
required-checks: '${JSON.stringify(checks)}'
|
|
49
|
+
release-id: \${{ inputs.release-id }}
|
|
50
|
+
version: \${{ inputs.version }}
|
|
51
|
+
source-sha: \${{ inputs.source-sha }}
|
|
52
|
+
artifact-sha256: \${{ inputs.artifact-sha256 }}
|
|
53
|
+
`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
57
|
+
const [repository, packageName, publisherSha, checksJson, ...extra] = process.argv.slice(2);
|
|
58
|
+
if (!checksJson || extra.length) throw new Error('Usage: node scripts/generate-publish-caller.mjs OWNER/REPO @SCOPE/PACKAGE SHARED_COMMIT_SHA \'["required CI name"]\'');
|
|
59
|
+
process.stdout.write(generateCaller({ repository, packageName, publisherSha, checks: JSON.parse(checksJson) }));
|
|
60
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// Real restricted Codex reply while a synthetic writer stays active. No Telegram network.
|
|
2
|
+
import { mkdtemp, mkdir, writeFile, symlink } from 'node:fs/promises'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
import { tmpdir } from 'node:os'
|
|
5
|
+
import { spawn } from 'node:child_process'
|
|
6
|
+
import { createRelay } from '../src/index.js'
|
|
7
|
+
import { RunStore } from '../src/runs.js'
|
|
8
|
+
import { ControlStore } from '../src/control-state.js'
|
|
9
|
+
import { initialPreset } from '../src/ai.js'
|
|
10
|
+
import { startExecutorJob } from '../src/executor.js'
|
|
11
|
+
import { serveHostExecutor } from '../src/host-executor.js'
|
|
12
|
+
import { fileURLToPath } from 'node:url'
|
|
13
|
+
import { initializeWorkspace } from '../src/workspace.js'
|
|
14
|
+
import type { Update } from 'grammy/types'
|
|
15
|
+
if (process.argv.includes('--host')) {
|
|
16
|
+
const root=process.argv[process.argv.indexOf('--host')+1], abort=new AbortController()
|
|
17
|
+
process.once('SIGTERM',()=>abort.abort())
|
|
18
|
+
await serveHostExecutor({cli:'codex',agents:[{name:'fixture',workspace:join(root,'mind'),controlDir:join(root,'control'),binDir:fileURLToPath(new URL('../bin',import.meta.url)),sharedWorkspace:join(root,'mind')}]},abort.signal,async(texts,options)=>{
|
|
19
|
+
if((await new RunStore(options.controlDir).get(options.runId))?.replyOnly)return startExecutorJob(texts,options)
|
|
20
|
+
const child=spawn(process.execPath,['-e','setInterval(()=>{},1000)'],{detached:true,stdio:['pipe','pipe','pipe']})
|
|
21
|
+
return {child,stdout:'',cleanup:async()=>{}}
|
|
22
|
+
})
|
|
23
|
+
process.exit(0)
|
|
24
|
+
}
|
|
25
|
+
const root=await mkdtemp(join(tmpdir(),'ez-busy-reply-')), workspace=join(root,'mind'), controlDir=join(root,'control')
|
|
26
|
+
await initializeWorkspace(workspace);await mkdir(controlDir,{recursive:true})
|
|
27
|
+
if(process.env.EZ_REPLY_QA_AUTH){await mkdir(join(controlDir,'cli','codex'),{recursive:true});await symlink(process.env.EZ_REPLY_QA_AUTH,join(controlDir,'cli','codex','auth.json'))}
|
|
28
|
+
const hostMode=process.argv.includes('--transport')
|
|
29
|
+
const hostEnvironment={...process.env};delete hostEnvironment.EZ_EXECUTOR_TRANSPORT
|
|
30
|
+
const host=hostMode?spawn(process.execPath,['--import',fileURLToPath(new URL('../node_modules/tsx/dist/loader.mjs',import.meta.url)),fileURLToPath(import.meta.url),'--host',root],{env:hostEnvironment,stdio:['ignore','inherit','inherit']}):undefined
|
|
31
|
+
if(hostMode)process.env.EZ_EXECUTOR_TRANSPORT='host'
|
|
32
|
+
const control=new ControlStore(controlDir,1000),runs=new RunStore(controlDir)
|
|
33
|
+
await control.requestPairing(101,101);await control.approveOwner(101)
|
|
34
|
+
let writer:any,replyEvents=''
|
|
35
|
+
const relay=createRelay({workspace,controlDir,pairingTtlMs:1000,executorTimeoutMs:0,executorCli:'codex',telegramBotToken:'fixture'},async(texts,options)=>{
|
|
36
|
+
if(hostMode)return startExecutorJob(texts,options)
|
|
37
|
+
const run=await runs.get(options.runId)
|
|
38
|
+
if(run?.replyOnly){const job=await startExecutorJob(texts,options);job.child.stdout?.on('data',c=>{replyEvents+=c});return job}
|
|
39
|
+
const child=spawn(process.execPath,['-e','setInterval(()=>{},1000)'],{stdio:['pipe','pipe','pipe']});writer=child
|
|
40
|
+
return {child,stdout:'',cleanup:async()=>{}}
|
|
41
|
+
})
|
|
42
|
+
relay.bot.botInfo={id:999,is_bot:true,first_name:'Fixture',username:'fixture_bot'} as any
|
|
43
|
+
const replies:string[]=[]
|
|
44
|
+
relay.bot.api.config.use(async(_p,method,payload)=>{if(method==='sendMessage')replies.push((payload as any).text);return {ok:true,result:method==='sendMessage'?{message_id:replies.length,date:0,chat:{id:101,type:'private'},text:(payload as any).text}:true} as any})
|
|
45
|
+
const msg=(id:number,text:string):Update=>({update_id:id,message:{message_id:id,date:0,text,from:{id:101,is_bot:false,first_name:'Fixture'},chat:{id:101,type:'private',first_name:'Fixture'}}})
|
|
46
|
+
try{
|
|
47
|
+
await relay.bot.handleUpdate(msg(1,'Long work'));await relay.drainInbox(true)
|
|
48
|
+
await relay.bot.handleUpdate(msg(2,'What is running? Also calculate 17 times 19. Use your available reply tools. Do not queue any work.'));await relay.drainInbox(true)
|
|
49
|
+
const started=Date.now()
|
|
50
|
+
while(!replies.length && Date.now()-started<120000){await relay.drainOutbox();await new Promise(r=>setTimeout(r,250))}
|
|
51
|
+
if(!replies.some(s=>s.includes('323')))throw new Error('No verified arithmetic reply: '+JSON.stringify(replies))
|
|
52
|
+
if((await runs.get('tg_1'))?.status!=='running')throw new Error('Writer stopped')
|
|
53
|
+
while((await runs.get('tg_2'))?.status==='running' && Date.now()-started<120000)await new Promise(r=>setTimeout(r,250))
|
|
54
|
+
if((await runs.get('tg_2'))?.status!=='completed')throw new Error('Reply did not finish successfully')
|
|
55
|
+
const reply=await runs.get('tg_2');if(!reply?.replyOnly)throw new Error('No restricted reply lane')
|
|
56
|
+
await writeFile(join(root,'evidence.json'),JSON.stringify({replyMs:Date.now()-started,replies,writerRunning:(await runs.get('tg_1'))?.status==='running',replyEvents},null,2))
|
|
57
|
+
console.log(JSON.stringify({root,replyMs:Date.now()-started,replies,writerRunning:true}))
|
|
58
|
+
}finally{await relay.stop();writer?.kill();host?.kill();delete process.env.EZ_EXECUTOR_TRANSPORT}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// Opt-in token-consuming probe. Real executor and relay handlers; synthetic Telegram provider.
|
|
2
|
+
// Run: node --import tsx scripts/smoke-scheduler.ts [seconds=1860] [cli=codex]
|
|
3
|
+
import { mkdtemp, writeFile, readFile, readdir } from 'node:fs/promises'
|
|
4
|
+
import { tmpdir } from 'node:os'
|
|
5
|
+
import { join } from 'node:path'
|
|
6
|
+
import { createRelay } from '../src/index.js'
|
|
7
|
+
import { ControlStore } from '../src/control-state.js'
|
|
8
|
+
import { Scheduler } from '../src/scheduler.js'
|
|
9
|
+
import { RunStore } from '../src/runs.js'
|
|
10
|
+
import { initialPreset } from '../src/ai.js'
|
|
11
|
+
import { initializeWorkspace } from '../src/workspace.js'
|
|
12
|
+
import type { Update } from 'grammy/types'
|
|
13
|
+
|
|
14
|
+
const duration=Number(process.argv[2] || 1860),cli=process.argv[3] || 'codex'
|
|
15
|
+
const nativeGoal=process.argv[4]==='goal'
|
|
16
|
+
if(!Number.isSafeInteger(duration) || duration<30)throw new Error('Duration must be at least 30 seconds')
|
|
17
|
+
const root=await mkdtemp(join(tmpdir(),'ez-scheduler-smoke-')),workspace=join(root,'agent'),controlDir=join(root,'control')
|
|
18
|
+
await initializeWorkspace(workspace)
|
|
19
|
+
const control=new ControlStore(controlDir,1000),scheduler=new Scheduler(controlDir),runs=new RunStore(controlDir)
|
|
20
|
+
await control.requestPairing(101,101);await control.approveOwner(101)
|
|
21
|
+
const owner=(await control.status()).owner!,execution=await control.captureChoice(initialPreset(cli))
|
|
22
|
+
const replies:{at:number;text:string}[]=[]
|
|
23
|
+
const relay=createRelay({workspace,controlDir,pairingTtlMs:1000,executorTimeoutMs:0,executorCli:cli,telegramBotToken:'fixture'})
|
|
24
|
+
relay.bot.botInfo={id:999,is_bot:true,first_name:'Fixture',username:'fixture_bot'} as typeof relay.bot.botInfo
|
|
25
|
+
relay.bot.api.config.use(async(_previous,method,payload)=>{
|
|
26
|
+
if(method==='sendMessage') {const reply={at:Date.now(),text:(payload as {text:string}).text};replies.push(reply);console.log(JSON.stringify({reply}))}
|
|
27
|
+
return {ok:true,result:{message_id:replies.length}} as never
|
|
28
|
+
})
|
|
29
|
+
const due=Date.now()+2000
|
|
30
|
+
const text=nativeGoal
|
|
31
|
+
? `/goal Native multi-turn continuation QA: create phase1.txt containing ONE and phase2.txt containing TWO, then finished.txt containing DONE and deliver BACKGROUND_DONE. In the FIRST turn write only phase1.txt and END with final response FIRST_TURN_DONE, leaving this native goal active. Do not write phase2 or finished.txt or send BACKGROUND_DONE in the first turn. On a subsequent native automatic continuation, run a terminal sleep for ${duration} seconds and wait for it, write phase2.txt and finished.txt, read and verify all three files, send BACKGROUND_DONE through ezenciel-agents-message, verify delivery, and mark this goal complete. No extra schedule, user prompt or custom continuation loop. Keep progress.md updated.`
|
|
32
|
+
: `This is an authorized synthetic test. In your task directory write progress.md with WAITING. Run a terminal sleep for ${duration} seconds and wait for that command to finish. Then write finished.txt containing DONE and use ezenciel-agents-message --text 'BACKGROUND_DONE'. Do not reschedule or finish early. No external services are needed.`
|
|
33
|
+
await scheduler.save({id:'sleep',name:'Long-running synthetic QA',text,trigger:{at:new Date(due).toISOString()},enabled:true,owner,execution})
|
|
34
|
+
let draining=false
|
|
35
|
+
const tick=setInterval(()=>{if(!draining){draining=true;void relay.drainSources().then(()=>relay.drainOutbox()).catch(console.error).finally(()=>{draining=false})}},250)
|
|
36
|
+
const until=async(check:()=>Promise<boolean>,seconds:number)=>{
|
|
37
|
+
const deadline=Date.now()+seconds*1000
|
|
38
|
+
while(!await check()){if(Date.now()>deadline)throw new Error('Probe timed out');await new Promise(r=>setTimeout(r,250))}
|
|
39
|
+
}
|
|
40
|
+
console.log(JSON.stringify({root,duration,cli}))
|
|
41
|
+
try{
|
|
42
|
+
await until(async()=>(await runs.list()).some(r=>r.scheduled && r.status==='running'),60)
|
|
43
|
+
const [background]=(await runs.list()).filter(r=>r.scheduled)
|
|
44
|
+
const message:Update={update_id:10,message:{message_id:10,date:0,text:"What is 17 times 19? Reply with the number using ezenciel-agents-message. This is a local test with a synthetic delivery provider.",from:{id:101,is_bot:false,first_name:'Fixture'},chat:{id:101,type:'private',first_name:'Fixture'}}}
|
|
45
|
+
const askedAt=Date.now();await relay.bot.handleUpdate(message);await relay.drainInbox(true)
|
|
46
|
+
await until(async()=>replies.some(r=>/323/.test(r.text)),180)
|
|
47
|
+
if((await runs.get(background.id))?.status!=='running')throw new Error('Background stopped before chat reply')
|
|
48
|
+
console.log(JSON.stringify({chatReplyMs:Date.now()-askedAt,backgroundState:'running'}))
|
|
49
|
+
if(duration>360){
|
|
50
|
+
await until(async()=>Date.now()-askedAt>360000,duration)
|
|
51
|
+
const followup={...message,update_id:11,message:{...message.message!,message_id:11,text:'Check ezenciel-agents-schedule runs. If the background task is actually running, send BACKGROUND_STATUS_RUNNING followed by its exact run ID. Otherwise report the problem.'}} as Update
|
|
52
|
+
const before=replies.length;await relay.bot.handleUpdate(followup);await relay.drainInbox(true)
|
|
53
|
+
await until(async()=>replies.slice(before).some(r=>r.text.includes('BACKGROUND_STATUS_RUNNING') && r.text.includes(background.id)),180)
|
|
54
|
+
if((await runs.get(background.id))?.status!=='running')throw new Error('Background stopped at six-minute check')
|
|
55
|
+
console.log(JSON.stringify({sixMinuteCheck:'passed',backgroundState:'running'}))
|
|
56
|
+
}
|
|
57
|
+
await until(async()=> (await runs.get(background.id))?.status!=='running',duration+180)
|
|
58
|
+
await relay.drainOutbox()
|
|
59
|
+
const status=(await runs.get(background.id))?.status
|
|
60
|
+
if(status!=='completed' || replies.filter(r=>r.text.includes('BACKGROUND_DONE')).length!==1)throw new Error('Missing completed run or exactly one delivered result')
|
|
61
|
+
const finished=await readFile(join(workspace,'work/tasks',background.id,'finished.txt'),'utf8')
|
|
62
|
+
if(finished.trim()!=='DONE')throw new Error('Missing finished.txt artifact')
|
|
63
|
+
if(nativeGoal){
|
|
64
|
+
for(const [file,expected] of [['phase1.txt','ONE'],['phase2.txt','TWO']])
|
|
65
|
+
if((await readFile(join(workspace,'work/tasks',background.id,file),'utf8')).trim()!==expected)throw new Error(`Missing native goal artifact: ${file}`)
|
|
66
|
+
}
|
|
67
|
+
const record=(await runs.get(background.id))!
|
|
68
|
+
if(Date.parse(record.endedAt!)-Date.parse(record.startedAt!) < duration*1000)throw new Error('Worker completed before requested duration')
|
|
69
|
+
let goalEvidence:unknown
|
|
70
|
+
if(nativeGoal){
|
|
71
|
+
const home=join(controlDir,'cli/codex/tasks',background.id),sessionId=record.nativeSessionId
|
|
72
|
+
if(!sessionId)throw new Error('Missing native session ID')
|
|
73
|
+
const files=await readdir(join(home,'sessions'),{recursive:true})
|
|
74
|
+
const file=files.find(f=>f.endsWith(`${sessionId}.jsonl`))
|
|
75
|
+
if(!file)throw new Error('Missing native transcript')
|
|
76
|
+
const events=(await readFile(join(home,'sessions',file),'utf8')).trim().split('\n').map(line=>JSON.parse(line)).filter(e=>e.type==='event_msg')
|
|
77
|
+
const turns=events.filter(e=>e.payload.type==='task_complete')
|
|
78
|
+
if(turns.length<2 || !turns[0].payload.last_agent_message?.includes('FIRST_TURN_DONE'))throw new Error('Native multi-turn continuation was not verified')
|
|
79
|
+
// Read Codex-owned evidence only; Ez never creates or manages this database.
|
|
80
|
+
const {DatabaseSync}=await import('node:sqlite'),db=new DatabaseSync(join(home,'goals_1.sqlite'),{readOnly:true})
|
|
81
|
+
try{
|
|
82
|
+
const goal=db.prepare('SELECT status FROM thread_goals WHERE thread_id = ?').get(sessionId)
|
|
83
|
+
if(goal?.status!=='complete')throw new Error('Native goal did not complete')
|
|
84
|
+
goalEvidence={status:goal.status,completedTurns:turns.map(e=>e.payload.turn_id)}
|
|
85
|
+
}finally{db.close()}
|
|
86
|
+
}
|
|
87
|
+
const evidence={duration,cli,nativeGoal,goalEvidence,status,replies,run:await runs.get(background.id)}
|
|
88
|
+
await writeFile(join(root,'evidence.json'),JSON.stringify(evidence,null,2),{mode:0o600})
|
|
89
|
+
console.log(JSON.stringify({passed:true,evidence:join(root,'evidence.json')}))
|
|
90
|
+
}finally{clearInterval(tick);while(draining)await new Promise(r=>setTimeout(r,50));await relay.stop()}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Developer packaging only. The existing agent-owned updater installs the result.
|
|
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 {parseArgs} from 'node:util';
|
|
8
|
+
import {digest, extract, version, newer} from '../src/updates/artifact.mjs';
|
|
9
|
+
|
|
10
|
+
const {values:v}=parseArgs({options:Object.fromEntries(['source','catalog','label','version','flow'].map(k=>[k,{type:'string'}]))});
|
|
11
|
+
if(!['source','catalog','label','version','flow'].every(k=>v[k]))throw Error('Required: --source CHECKOUT --catalog DIRECTORY --label beta-12 --version 0.1.0-beta.12.qa.1 --flow QA.md');
|
|
12
|
+
if(!/^beta-[1-9]\d*$/.test(v.label))throw Error('Label must be beta-N');
|
|
13
|
+
version(v.version);
|
|
14
|
+
if(!/^\d+\.\d+\.\d+-beta\.\d+\.qa\.[1-9]\d*$/.test(v.version))throw Error('Use a distinct private version: X.Y.Z-beta.N.qa.BUILD');
|
|
15
|
+
const source=await fs.realpath(v.source),catalog=path.resolve(v.catalog);
|
|
16
|
+
const run=(cmd,args,cwd=source)=>execFileSync(cmd,args,{cwd,encoding:'utf8',stdio:['ignore','pipe','pipe']}).trim();
|
|
17
|
+
if(run('git',['status','--porcelain']))throw Error('Commit the reviewed source before staging QA');
|
|
18
|
+
const commit=run('git',['rev-parse','HEAD']),flow=await fs.readFile(v.flow,'utf8');
|
|
19
|
+
if(!flow.trim())throw Error('A feature QA flow is required');
|
|
20
|
+
await fs.mkdir(catalog,{recursive:true,mode:0o700});
|
|
21
|
+
const destination=path.join(catalog,v.label);
|
|
22
|
+
if(await fs.lstat(destination).then(()=>true,err=>{if(err.code==='ENOENT')return false;throw err;}))throw Error('QA label already exists; never replace a build. Choose the next beta label.');
|
|
23
|
+
const temp=await fs.mkdtemp(path.join(tmpdir(),'ez-stage-qa-'));
|
|
24
|
+
const staged=await fs.mkdtemp(path.join(catalog,'.staging-'));
|
|
25
|
+
try {
|
|
26
|
+
const pack=(cwd,out)=>JSON.parse(run('npm',['pack','--ignore-scripts','--json','--pack-destination',out],cwd))[0];
|
|
27
|
+
const original=pack(source,temp);
|
|
28
|
+
await extract(await fs.readFile(path.join(temp,original.filename)),path.join(temp,'source'));
|
|
29
|
+
const root=path.join(temp,'source'),pkg=JSON.parse(await fs.readFile(path.join(root,'package.json'),'utf8'));
|
|
30
|
+
if(pkg.ezRelease?.kind!=='main')throw Error('This staging command accepts the main package only');
|
|
31
|
+
if(!newer(v.version,pkg.version))throw Error('Private QA version must be newer than source package version');
|
|
32
|
+
pkg.version=v.version;pkg.ezQa={label:v.label,commit,private:true};
|
|
33
|
+
await fs.writeFile(path.join(root,'package.json'),JSON.stringify(pkg,null,2)+'\n');
|
|
34
|
+
const packed=pack(root,staged),artifact=await fs.readFile(path.join(staged,packed.filename));
|
|
35
|
+
const receipt={label:v.label,version:v.version,package:pkg.name,commit,sha256:digest(artifact),file:packed.filename,private:true};
|
|
36
|
+
await fs.writeFile(path.join(staged,'manifest.json'),JSON.stringify(receipt,null,2)+'\n',{mode:0o600});
|
|
37
|
+
await fs.writeFile(path.join(staged,'QA.md'),flow,{mode:0o600});
|
|
38
|
+
await fs.chmod(path.join(staged,packed.filename),0o600);
|
|
39
|
+
// The published directory is nonempty, so competing staging cannot replace it.
|
|
40
|
+
await fs.rename(staged,destination);
|
|
41
|
+
console.log(JSON.stringify({...receipt,directory:destination},null,2));
|
|
42
|
+
} finally {await fs.rm(temp,{recursive:true,force:true});await fs.rm(staged,{recursive:true,force:true});}
|