@jc_stack/ez-agents 0.1.0-beta.19 → 0.1.0-beta.23
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/CHANGELOG.md +30 -0
- package/docker/healthcheck.mjs +11 -4
- package/docs/architecture/ai-selection.md +4 -2
- package/docs/docker-runtime.md +7 -5
- package/docs/trusted-publishing.md +38 -2
- package/package.json +1 -1
- package/scripts/trusted-beta.mjs +9 -4
- package/src/index.ts +1 -1
- package/src/menu.ts +14 -8
- package/src/updates/control.mjs +6 -2
- package/src/updates/runtime.mjs +13 -1
- package/templates/agent-guidance.md +13 -0
- package/test/agent-guidance.test.ts +13 -0
- package/test/ai.test.ts +40 -0
- package/test/failure.test.ts +27 -1
- package/test/intake-relay.test.ts +11 -3
- package/test/trusted-beta.test.mjs +60 -0
- package/test/updates.test.mjs +21 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,35 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.0-beta.23
|
|
4
|
+
|
|
5
|
+
- Identify failed publisher API reads and missing release tags without exposing
|
|
6
|
+
credentials or signed asset URLs. Document verified tag/artifact staging and
|
|
7
|
+
recovery before a fresh publication attempt.
|
|
8
|
+
- Include beta.22 incomplete-update-receipt recovery; the earlier unpublished
|
|
9
|
+
candidate remains preserved. Existing beta acceptance limits remain.
|
|
10
|
+
|
|
11
|
+
## 0.1.0-beta.22
|
|
12
|
+
|
|
13
|
+
- Ignore incomplete or malformed update-receipt directories until a valid,
|
|
14
|
+
atomically committed `job.json` exists. They have no activation authority and
|
|
15
|
+
must not take the host executor offline.
|
|
16
|
+
|
|
17
|
+
## 0.1.0-beta.21
|
|
18
|
+
|
|
19
|
+
- Initialize fresh Telegram bots before reading their identity. Beta.19 could
|
|
20
|
+
retry forever before polling, causing main upgrades to fail their health gate.
|
|
21
|
+
- Restore the unchanged deployment layout required for upgrades from beta.18
|
|
22
|
+
and beta.19; extending health grace does not fix the initialization failure.
|
|
23
|
+
- Include beta.20 health diagnostics. Preserve the earlier immutable unpublished
|
|
24
|
+
candidate; existing beta acceptance limits remain.
|
|
25
|
+
|
|
26
|
+
## 0.1.0-beta.20
|
|
27
|
+
|
|
28
|
+
- Retain a bounded, content-free health predicate in a failed main-upgrade
|
|
29
|
+
receipt before rollback replaces the candidate relay. This distinguishes relay
|
|
30
|
+
polling and host-executor heartbeat failures without persisting control-state,
|
|
31
|
+
provider, environment, or Docker diagnostic content.
|
|
32
|
+
|
|
3
33
|
## 0.1.0-beta.19
|
|
4
34
|
|
|
5
35
|
- Preserve host-backed run completion and delivery evidence while the relay
|
package/docker/healthcheck.mjs
CHANGED
|
@@ -1,8 +1,15 @@
|
|
|
1
1
|
import { readFileSync } from 'node:fs';
|
|
2
|
-
const
|
|
3
|
-
|
|
2
|
+
const relayControl = process.env.EZ_HEALTH_RELAY_CONTROL_DIR || '/state/control';
|
|
3
|
+
const fail = code => { process.stderr.write(`EZ_HEALTH_${code}\n`); process.exit(1); };
|
|
4
|
+
let value;
|
|
5
|
+
try { value = JSON.parse(readFileSync(relayControl + '/heartbeat.json', 'utf8')); }
|
|
6
|
+
catch { fail('RELAY_UNREADABLE'); }
|
|
7
|
+
if (!value.polling) fail('RELAY_NOT_POLLING');
|
|
8
|
+
if (!Number.isFinite(value.at) || Date.now() - value.at > 20000) fail('RELAY_STALE');
|
|
4
9
|
|
|
5
10
|
if (process.env.EZ_EXECUTOR_TRANSPORT === 'host') {
|
|
6
|
-
|
|
7
|
-
|
|
11
|
+
let host;
|
|
12
|
+
try { host = JSON.parse(readFileSync(process.env.EZ_CONTROL_DIR + '/host-executor/heartbeat.json', 'utf8')); }
|
|
13
|
+
catch { fail('HOST_UNREADABLE'); }
|
|
14
|
+
if (!Number.isFinite(host.at) || Date.now() - host.at > 15000) fail('HOST_STALE');
|
|
8
15
|
}
|
|
@@ -28,8 +28,10 @@ offer their own default only in this slice. Refresh by opening the native client
|
|
|
28
28
|
the relay does not install models, manage subscriptions or guess aliases.
|
|
29
29
|
|
|
30
30
|
Setup initialization and relay startup seed one default choice per installed client.
|
|
31
|
-
|
|
32
|
-
|
|
31
|
+
Choose AI opens the available installed-model catalog directly; Settings keeps
|
|
32
|
+
saved choices and Refresh available AIs repeats default discovery. Active/default
|
|
33
|
+
presets and queued snapshots are preserved; discovery only refreshes unused
|
|
34
|
+
detected entries.
|
|
33
35
|
Codex uses its native `config/read` interface; Grok reads its documented user
|
|
34
36
|
model/effort settings (or `models` for the default model). Claude reads user and
|
|
35
37
|
workspace JSON settings; OpenCode reports resolved config. Unknown defaults and
|
package/docs/docker-runtime.md
CHANGED
|
@@ -61,11 +61,13 @@ Live smoke uses the same host CLI and requires an actual Telegram receipt.
|
|
|
61
61
|
Restart preserves pairing and files. One kernel lock excludes relay/smoke
|
|
62
62
|
writers; exit 73 means a writer is active. Do not delete its lock to bypass it.
|
|
63
63
|
Health requires recent polling and host-transport heartbeats, not just a process.
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
64
|
+
Relay replacement retains the existing 30-second startup grace and deployment
|
|
65
|
+
layout, so compatible releases remain eligible for agent-owned upgrades.
|
|
66
|
+
An interrupted Telegram poller keeps authorized work and in-flight outbox writes
|
|
67
|
+
alive while it retries intake. A `409 Conflict` still requires the operator to
|
|
68
|
+
stop the competing poller; it remains unhealthy after the startup grace. Pending
|
|
69
|
+
and uncertain deliveries keep their existing outbox/receipt semantics; executor
|
|
70
|
+
stdout is not replayed as a reply.
|
|
69
71
|
Signal and fatal-error paths share one shutdown; a secondary cleanup error is
|
|
70
72
|
reported without replacing the original startup/polling failure.
|
|
71
73
|
|
|
@@ -65,8 +65,28 @@ jobs from its latest attempt. Tag, PR and other workflow runs cannot shadow it;
|
|
|
65
65
|
failed, pending or incomplete main CI cannot fall back to an older success.
|
|
66
66
|
|
|
67
67
|
Create the `vVERSION` tag at that exact source commit and a **draft prerelease**
|
|
68
|
-
with these assets
|
|
69
|
-
`
|
|
68
|
+
with these assets under existing release authority. **Creating a draft release
|
|
69
|
+
with `--target` does not create a Git tag.** Create and push the tag explicitly,
|
|
70
|
+
then use `--verify-tag` to prevent staging against a missing remote tag:
|
|
71
|
+
|
|
72
|
+
```sh
|
|
73
|
+
# VERSION and SOURCE_SHA identify the reviewed, current-main candidate.
|
|
74
|
+
git tag "v$VERSION" "$SOURCE_SHA"
|
|
75
|
+
git push origin "refs/tags/v$VERSION"
|
|
76
|
+
git ls-remote origin "refs/tags/v$VERSION"
|
|
77
|
+
# Copy the already tested bytes, without rebuilding, to this exact basename.
|
|
78
|
+
cp /absolute/tested-package.tgz /absolute/release/candidate.tgz
|
|
79
|
+
gh release create "v$VERSION" --repo OWNER/REPO --verify-tag --draft --prerelease \
|
|
80
|
+
--title "v$VERSION" --notes-file /absolute/release/notes.md \
|
|
81
|
+
/absolute/release/candidate.tgz /absolute/release/release-receipt.json
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
If the tag already exists, read and verify its commit (peel annotated tags) rather
|
|
85
|
+
than recreating or force-pushing it. Read back the draft by numeric ID and check
|
|
86
|
+
asset names and downloaded SHA-256 before dispatch. `npm pack`'s default filename
|
|
87
|
+
is not the publisher's asset name; GitHub asset labels do not rename the asset.
|
|
88
|
+
Use `gh release upload` only to add a missing asset, never `--clobber` to replace
|
|
89
|
+
candidate bytes or receipts. The required assets are:
|
|
70
90
|
|
|
71
91
|
- `candidate.tgz`: the exact Mac-tested bytes from `npm pack --ignore-scripts`.
|
|
72
92
|
Do not rebuild it on Actions.
|
|
@@ -114,6 +134,22 @@ identity on the release PR. A failed command after the publish call may mean npm
|
|
|
114
134
|
accepted it: inspect registry state first. A rerun may verify an existing exact
|
|
115
135
|
version; if the version is absent it refuses a second write. Reconcile first,
|
|
116
136
|
then create a fresh authorized dispatch if appropriate. Never repeat or overwrite that version or silently repair tags.
|
|
137
|
+
When validation fails, use the logged GitHub API path to identify the missing
|
|
138
|
+
input. A tag lookup 404 is not evidence of a draft-release permission problem.
|
|
139
|
+
A draft/asset 404 can mean absent evidence or insufficient access; verify with
|
|
140
|
+
the existing authorized identity before diagnosing credentials. Do not broaden
|
|
141
|
+
token permissions based on a generic 404.
|
|
142
|
+
|
|
143
|
+
For a failure before the publish job starts, confirm that job was skipped and
|
|
144
|
+
read npm for the exact version. If absent, repair missing staging inputs against
|
|
145
|
+
the same approved commit/bytes, verify tag, asset names and digest, and create a
|
|
146
|
+
fresh dispatch. If main or the package changes, prepare a new reviewed version;
|
|
147
|
+
preserve the old draft instead of moving its tag or replacing its artifact.
|
|
148
|
+
If any npm write may have started, use exact registry/artifact reconciliation
|
|
149
|
+
above first. Publication recovery never means rolling back a running agent.
|
|
150
|
+
Runtime upgrades and `failed`/`rolled-back` versus `recovery-required` recovery
|
|
151
|
+
follow [upgrades](upgrades.md) under the installation's saved policy.
|
|
152
|
+
|
|
117
153
|
Missing trust or registry access is an external dependency, not a reason to use
|
|
118
154
|
a token workaround.
|
|
119
155
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jc_stack/ez-agents",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
3
|
+
"version": "0.1.0-beta.23",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "A lightweight foundation for persistent business AI assistants using existing AI harnesses, workspaces and plugins.",
|
package/scripts/trusted-beta.mjs
CHANGED
|
@@ -87,8 +87,8 @@ export function tarManifest(path) {
|
|
|
87
87
|
return JSON.parse(execFileSync('tar', ['-xOf', path, 'package/package.json'], { ...options, maxBuffer: 1024 * 1024 }));
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
-
async function responseBytes(response, max = MAX_ARTIFACT) {
|
|
91
|
-
assert(response.ok, `HTTP ${response.status} while reading
|
|
90
|
+
async function responseBytes(response, max = MAX_ARTIFACT, context = 'release evidence') {
|
|
91
|
+
assert(response.ok, `HTTP ${response.status} while reading ${context}`);
|
|
92
92
|
assert(Number(response.headers.get('content-length') || 0) <= max, 'Response too large');
|
|
93
93
|
let size = 0; const chunks = [];
|
|
94
94
|
for await (const chunk of response.body) { size += chunk.length; assert(size <= max, 'Response too large'); chunks.push(chunk); }
|
|
@@ -98,6 +98,8 @@ async function responseBytes(response, max = MAX_ARTIFACT) {
|
|
|
98
98
|
export function githubClient(token, fetcher = fetch) {
|
|
99
99
|
return async (path, binary = false) => {
|
|
100
100
|
assert(path.startsWith('/repos/'), 'Invalid GitHub API path');
|
|
101
|
+
// Identify the failed read without logging tokens, response bodies or signed URLs.
|
|
102
|
+
const context = `GitHub GET ${path.split('?')[0]}`;
|
|
101
103
|
const response = await fetcher(`https://api.github.com${path}`, {
|
|
102
104
|
headers: { Accept: binary ? 'application/octet-stream' : 'application/vnd.github+json', ...(token ? { Authorization: `Bearer ${token}` } : {}), 'X-GitHub-Api-Version': '2022-11-28' },
|
|
103
105
|
redirect: 'manual', signal: AbortSignal.timeout(30_000),
|
|
@@ -105,9 +107,12 @@ export function githubClient(token, fetcher = fetch) {
|
|
|
105
107
|
if (binary && [301, 302, 303, 307, 308].includes(response.status)) {
|
|
106
108
|
const location = new URL(response.headers.get('location'));
|
|
107
109
|
assert(location.protocol === 'https:' && (location.hostname === 'release-assets.githubusercontent.com' || location.hostname === 'objects.githubusercontent.com'), 'Unexpected asset redirect');
|
|
108
|
-
return responseBytes(await fetcher(location, { signal: AbortSignal.timeout(60_000), redirect: 'error' }));
|
|
110
|
+
return responseBytes(await fetcher(location, { signal: AbortSignal.timeout(60_000), redirect: 'error' }), MAX_ARTIFACT, `${context} asset download`);
|
|
109
111
|
}
|
|
110
|
-
|
|
112
|
+
if (response.status === 404 && path.includes('/git/ref/tags/')) {
|
|
113
|
+
throw new Error(`HTTP 404 while reading ${context}: release tag is missing or inaccessible; a draft release does not create its Git tag. Verify the remote tag at the reviewed source before dispatch.`);
|
|
114
|
+
}
|
|
115
|
+
const bytes = await responseBytes(response, binary ? MAX_ARTIFACT : 8 * 1024 * 1024, context);
|
|
111
116
|
return binary ? bytes : JSON.parse(bytes.toString());
|
|
112
117
|
};
|
|
113
118
|
}
|
package/src/index.ts
CHANGED
|
@@ -1029,7 +1029,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
1029
1029
|
await bot.api.deleteWebhook({ drop_pending_updates: false })
|
|
1030
1030
|
await bot.api.setMyCommands(commands)
|
|
1031
1031
|
await bot.api.setMyCommands(commands, { scope: { type: 'all_private_chats' } })
|
|
1032
|
-
|
|
1032
|
+
await bot.init()
|
|
1033
1033
|
await bot.start({
|
|
1034
1034
|
drop_pending_updates: false,
|
|
1035
1035
|
onStart: (botInfo) => console.log(`✓ Bot @${botInfo.username} polling for messages...`),
|
package/src/menu.ts
CHANGED
|
@@ -53,9 +53,14 @@ export const createAiMenu = (control: ControlStore, cli: string, catalog = readM
|
|
|
53
53
|
: 'Selected for this conversation. Queued work unchanged.'}`)
|
|
54
54
|
}
|
|
55
55
|
const list = async (ctx: Context, settings = false) => {
|
|
56
|
+
if (!settings) {
|
|
57
|
+
const models = await catalog()
|
|
58
|
+
if (models.length) return available(ctx, 0, models)
|
|
59
|
+
}
|
|
56
60
|
const state = await control.aiState(initial)
|
|
57
61
|
const keyboard = new InlineKeyboard()
|
|
58
|
-
|
|
62
|
+
const presets = settings ? state.presets : state.presets.filter((preset) => preset.id === initial.id)
|
|
63
|
+
for (const preset of presets) button(keyboard,
|
|
59
64
|
`${preset.id === (settings ? state.defaultId : state.selectedId) ? '✓ ' : ''}${preset.name}`,
|
|
60
65
|
async (next) => {
|
|
61
66
|
if (settings) {
|
|
@@ -64,16 +69,17 @@ export const createAiMenu = (control: ControlStore, cli: string, catalog = readM
|
|
|
64
69
|
await next.reply(`Default: ${preset.name}. Applies to new conversations only.`)
|
|
65
70
|
} else await choose(next, preset)
|
|
66
71
|
})
|
|
67
|
-
button(keyboard, '
|
|
68
|
-
|
|
72
|
+
button(keyboard, 'Browse available models', (next) => available(next))
|
|
73
|
+
button(keyboard, 'Refresh available AIs', async (next) => {
|
|
69
74
|
await refresh()
|
|
70
75
|
await list(next, true)
|
|
71
76
|
})
|
|
72
|
-
await ctx.reply(settings
|
|
73
|
-
|
|
77
|
+
await ctx.reply(settings
|
|
78
|
+
? 'Default for new conversations\nChoose a saved AI. Current work will not change.'
|
|
79
|
+
: 'Choose AI\nNo client catalog available. Showing the current client setup only.', { reply_markup: keyboard })
|
|
74
80
|
}
|
|
75
|
-
const available = async (ctx: Context, page = 0) => {
|
|
76
|
-
const models = await catalog()
|
|
81
|
+
const available = async (ctx: Context, page = 0, listed?: ModelChoice[]) => {
|
|
82
|
+
const models = listed ?? await catalog()
|
|
77
83
|
const keyboard = new InlineKeyboard()
|
|
78
84
|
for (const model of models.slice(page * 8, page * 8 + 8)) {
|
|
79
85
|
button(keyboard, `${model.cli} · ${model.name}`, async (next) => {
|
|
@@ -86,7 +92,7 @@ export const createAiMenu = (control: ControlStore, cli: string, catalog = readM
|
|
|
86
92
|
if (page > 0) button(keyboard, 'Previous', (next) => available(next, page - 1))
|
|
87
93
|
if (models.length > (page + 1) * 8) button(keyboard, 'Next', (next) => available(next, page + 1))
|
|
88
94
|
await ctx.reply(models.length
|
|
89
|
-
? '
|
|
95
|
+
? 'Choose AI\nAvailable models are populated automatically from the installed clients. Grok/Codex use their local catalog; other clients use their own default. Choosing one saves it; it does not switch AI.'
|
|
90
96
|
: 'No client catalog available. Open the installed CLI once, then try again.', { reply_markup: keyboard })
|
|
91
97
|
}
|
|
92
98
|
const save = async (ctx: Context, model: ModelChoice, effort?: string) => {
|
package/src/updates/control.mjs
CHANGED
|
@@ -7,6 +7,7 @@ import { digest, extract, newer, compatible, version, releaseContract, registryV
|
|
|
7
7
|
export const read = async file => JSON.parse(await fs.readFile(file,'utf8'));
|
|
8
8
|
export const missing = error => {if(error.code!=='ENOENT')throw error;return null;};
|
|
9
9
|
export const targetId = value => {if(value!=='main'&&!/^[a-z][a-z0-9-]{0,39}$/.test(value))throw Error('Invalid update target');return value;};
|
|
10
|
+
const jobId=/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/;
|
|
10
11
|
export const updateHome = home => path.join(home,'updates');
|
|
11
12
|
export async function state(home) {
|
|
12
13
|
const config=await read(path.join(home,'config.json'));
|
|
@@ -77,12 +78,15 @@ export async function prepare(home,target,{file,release}) {
|
|
|
77
78
|
} catch(error){await fs.rm(dir,{recursive:true,force:true});throw error;}
|
|
78
79
|
}
|
|
79
80
|
export function jobPath(home,id) {
|
|
80
|
-
if(
|
|
81
|
+
if(!jobId.test(id))throw Error('Invalid upgrade job ID');
|
|
81
82
|
return path.join(updateHome(home),id);
|
|
82
83
|
}
|
|
83
84
|
export async function jobs(home) {
|
|
84
85
|
const entries=await fs.readdir(updateHome(home),{withFileTypes:true}).catch(error=>{if(error.code==='ENOENT')return [];throw error;});
|
|
85
|
-
|
|
86
|
+
// A job becomes visible only when its receipt is atomically committed. A crash
|
|
87
|
+
// before that point leaves no activation authority and must not stop the host.
|
|
88
|
+
const found=await Promise.all(entries.filter(e=>e.isDirectory()&&jobId.test(e.name)).map(e=>read(path.join(jobPath(home,e.name),'job.json')).catch(missing)));
|
|
89
|
+
return found.filter(Boolean);
|
|
86
90
|
}
|
|
87
91
|
async function requireSupervisor(directory) {
|
|
88
92
|
const h=await read(path.join(directory,'supervisor.json'));
|
package/src/updates/runtime.mjs
CHANGED
|
@@ -43,6 +43,14 @@ export async function packageManager(root,run=execute) {
|
|
|
43
43
|
throw Error(`Upgrade prerequisite unavailable: ${required}. ${failures.join('; ')}. Check the host supervisor service PATH (shell aliases do not count). Reuse its installed pnpm or Corepack; expose the launcher directory to that service and restart it after the current turn. If neither exists, provision the pinned manager first. Do not substitute npm install or reinstall the agent. After repair, prepare/apply a new job when status is failed; recover is only for recovery-required.`);
|
|
44
44
|
}
|
|
45
45
|
export const relayArgs = config => ['compose','--env-file',path.join(config.deploymentDir,'docker.env')];
|
|
46
|
+
const healthCodes = new Set(['RELAY_UNREADABLE','RELAY_NOT_POLLING','RELAY_STALE','HOST_UNREADABLE','HOST_STALE']);
|
|
47
|
+
async function healthEvidence(config,run) {
|
|
48
|
+
const id=(await run('docker',[...relayArgs(config),'ps','-q','relay'])).trim();
|
|
49
|
+
if(!/^[a-f0-9]{12,64}$/i.test(id))return '';
|
|
50
|
+
const raw=await run('docker',['inspect','--format','{{json .State.Health}}',id]);
|
|
51
|
+
const health=JSON.parse(raw),entry=health?.Log?.at(-1),match=typeof entry?.Output==='string'&&entry.Output.match(/^EZ_HEALTH_([A-Z_]+)\s*$/);
|
|
52
|
+
return match&&healthCodes.has(match[1])?` (health=${match[1].toLowerCase().replaceAll('_','-')})`:'';
|
|
53
|
+
}
|
|
46
54
|
function envValue(text,key,value) {
|
|
47
55
|
if(/[\r\n\0']/.test(value))throw Error('Unsafe deployment value');
|
|
48
56
|
const line=`${key}='${value}'`;
|
|
@@ -118,7 +126,11 @@ export async function perform(home,job,hooks) {
|
|
|
118
126
|
job.runtimeVerified=running;
|
|
119
127
|
}
|
|
120
128
|
job.status='completed';job.endedAt=new Date().toISOString();await save();return job;
|
|
121
|
-
}catch(error){
|
|
129
|
+
}catch(error){
|
|
130
|
+
let message=error.message;
|
|
131
|
+
if(job.target==='main'&&job.rollback&&/is unhealthy/.test(message))message+=await healthEvidence(config,run).catch(()=> '');
|
|
132
|
+
job.error=message;await save();return recover(home,job,hooks);
|
|
133
|
+
}
|
|
122
134
|
}
|
|
123
135
|
export async function recover(home,job,hooks) {
|
|
124
136
|
const run=hooks.execute||execute,{config}=await state(home);
|
|
@@ -12,6 +12,19 @@ delegation, model, and effort from the task—not a fixed routing rule. Keep one
|
|
|
12
12
|
writer per workspace; the primary agent owns integration, verification, and
|
|
13
13
|
external actions.
|
|
14
14
|
|
|
15
|
+
## AI selection
|
|
16
|
+
|
|
17
|
+
An explicit owner request to change this conversation's AI or reasoning effort
|
|
18
|
+
is a supported Ez control, not a request to edit the host Codex configuration,
|
|
19
|
+
inspect a native session record, or restart the runtime. Run
|
|
20
|
+
`ezenciel-agents-ai list`, then select only a returned choice with
|
|
21
|
+
`ezenciel-agents-ai select --cli <cli> --model <model> --effort <effort>`.
|
|
22
|
+
This changes subsequent owner messages only; a running or queued job retains
|
|
23
|
+
its captured choice, and the installation default is unchanged. Switching CLI
|
|
24
|
+
starts a fresh native conversation while preserving the workspace. Report the
|
|
25
|
+
confirmed selected choice from the command output; do not infer it from a
|
|
26
|
+
host-level setting or the current native session.
|
|
27
|
+
|
|
15
28
|
## Telegram replies
|
|
16
29
|
|
|
17
30
|
Use the messaging CLI for the current run's source chat, normally the paired
|
|
@@ -45,6 +45,19 @@ test('shared guidance teaches source-chat delivery and real Telegram line breaks
|
|
|
45
45
|
assert.ok(shared.includes('ezenciel-agents-message --text-file ./work/reply.md'))
|
|
46
46
|
})
|
|
47
47
|
|
|
48
|
+
test('shared guidance makes owner AI selection a relay control, not host configuration', async () => {
|
|
49
|
+
const shared = await readFile(sharedGuidancePath, 'utf8')
|
|
50
|
+
for (const prompt of [
|
|
51
|
+
executorJobPrompt('tg_owner', ['change to Terra medium']),
|
|
52
|
+
desktopJobPrompt('tg_owner_gui', ['change to Terra medium'], undefined, '/tmp/bin', '/tmp/control'),
|
|
53
|
+
]) {
|
|
54
|
+
assert.ok(prompt.includes('`ezenciel-agents-ai list`'))
|
|
55
|
+
assert.ok(prompt.includes('`ezenciel-agents-ai select --cli <cli> --model <model> --effort <effort>`'))
|
|
56
|
+
assert.ok(prompt.includes('not a request to edit the host Codex configuration'))
|
|
57
|
+
assert.match(prompt, /a running or queued job retains\s+its captured choice/)
|
|
58
|
+
}
|
|
59
|
+
})
|
|
60
|
+
|
|
48
61
|
test('package guidance resolution ignores a workspace shadow file', async () => {
|
|
49
62
|
const root = path.join(tmpdir(), `ez-guidance-${randomUUID()}`)
|
|
50
63
|
await mkdir(root, { recursive: true })
|
package/test/ai.test.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { tmpdir } from 'node:os'
|
|
|
5
5
|
import { join } from 'node:path'
|
|
6
6
|
import { ControlStore } from '../src/control-state.js'
|
|
7
7
|
import { initialPreset, chatPreset, readModels, isPreset } from '../src/ai.js'
|
|
8
|
+
import { createAiMenu } from '../src/menu.js'
|
|
8
9
|
import { EXECUTOR_REGISTRY, nativeSessionId } from '../src/executor.js'
|
|
9
10
|
import { InboxStore } from '../src/inbox.js'
|
|
10
11
|
import type { Update } from 'grammy/types'
|
|
@@ -92,6 +93,45 @@ test('model catalog projects native metadata only, excluding hidden entries and
|
|
|
92
93
|
} finally { await rm(home, { recursive: true, force: true }) }
|
|
93
94
|
})
|
|
94
95
|
|
|
96
|
+
test('Choose AI opens the available installed-model catalog without an Add AI step', async () => {
|
|
97
|
+
const dir = await mkdtemp(join(tmpdir(), 'ez-ai-menu-'))
|
|
98
|
+
try {
|
|
99
|
+
const menu = createAiMenu(new ControlStore(dir, 1000), 'grok', async () => [{
|
|
100
|
+
cli: 'codex', model: 'fixture-model', name: 'Fixture', efforts: ['medium'],
|
|
101
|
+
}])
|
|
102
|
+
let reply = ''
|
|
103
|
+
let keyboard: { inline_keyboard?: Array<Array<{ text: string }>> } | undefined
|
|
104
|
+
await menu.list({ reply: async (text: string, options?: { reply_markup?: unknown }) => {
|
|
105
|
+
reply = text
|
|
106
|
+
keyboard = options?.reply_markup as typeof keyboard
|
|
107
|
+
return {} as never
|
|
108
|
+
} } as never)
|
|
109
|
+
assert.match(reply, /Available models are populated automatically/)
|
|
110
|
+
assert.deepEqual(keyboard?.inline_keyboard?.flat().map((button) => button.text), ['codex · Fixture'])
|
|
111
|
+
} finally { await rm(dir, { recursive: true, force: true }) }
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
test('Choose AI does not expose saved model choices without a catalog to validate them', async () => {
|
|
115
|
+
const dir = await mkdtemp(join(tmpdir(), 'ez-ai-menu-empty-'))
|
|
116
|
+
try {
|
|
117
|
+
const store = new ControlStore(dir, 1000)
|
|
118
|
+
await store.aiState(initialPreset('grok'))
|
|
119
|
+
await store.savePreset({ id: 'saved', name: 'Saved model', cli: 'codex', model: 'fixture-model', effort: 'medium' })
|
|
120
|
+
await store.savePreset({ id: 'saved-default', name: 'Saved client default', cli: 'claude' })
|
|
121
|
+
const menu = createAiMenu(store, 'grok', async () => [])
|
|
122
|
+
let reply = ''
|
|
123
|
+
let keyboard: { inline_keyboard?: Array<Array<{ text: string }>> } | undefined
|
|
124
|
+
await menu.list({ reply: async (text: string, options?: { reply_markup?: unknown }) => {
|
|
125
|
+
reply = text
|
|
126
|
+
keyboard = options?.reply_markup as typeof keyboard
|
|
127
|
+
return {} as never
|
|
128
|
+
} } as never)
|
|
129
|
+
assert.match(reply, /current client setup only/)
|
|
130
|
+
assert.ok(!keyboard?.inline_keyboard?.flat().some((button) => button.text.includes('Saved model')))
|
|
131
|
+
assert.ok(!keyboard?.inline_keyboard?.flat().some((button) => button.text.includes('Saved client default')))
|
|
132
|
+
} finally { await rm(dir, { recursive: true, force: true }) }
|
|
133
|
+
})
|
|
134
|
+
|
|
95
135
|
test('model catalog can read an agent-bound Codex home', async () => {
|
|
96
136
|
const home = await mkdtemp(join(tmpdir(), 'ez-catalog-home-'))
|
|
97
137
|
const codexHome = await mkdtemp(join(tmpdir(), 'ez-catalog-codex-'))
|
package/test/failure.test.ts
CHANGED
|
@@ -17,7 +17,10 @@ import { TelegramSource } from '../src/telegram-source.js'
|
|
|
17
17
|
import { packageVersion } from '../src/version.js'
|
|
18
18
|
import type { Update } from 'grammy/types'
|
|
19
19
|
const exec=promisify(execFile),bin=fileURLToPath(new URL('../bin/ezenciel-agents-schedule.mjs',import.meta.url))
|
|
20
|
-
|
|
20
|
+
// This integration test starts subprocesses, persists their receipts, and invokes
|
|
21
|
+
// the schedule CLI. Give a busy CI worker time to settle without changing the
|
|
22
|
+
// production recovery deadline.
|
|
23
|
+
const until=async(check:()=>Promise<boolean>,timeoutMs=15_000)=>{const deadline=Date.now()+timeoutMs;while(!(await check())){if(Date.now()>=deadline)throw new Error(`Timed out after ${timeoutMs}ms`);await new Promise(r=>setTimeout(r,20))}}
|
|
21
24
|
|
|
22
25
|
test('failure evidence is bounded and redacts configured credentials, headers, tokens, URLs and keys',()=>{
|
|
23
26
|
const token='123456789:abcdefghijklmnopqrstuvwxyz123456789'
|
|
@@ -137,6 +140,29 @@ test('relay shutdown waits for executor cleanup and final run state', async () =
|
|
|
137
140
|
} finally {release();await relay.stop();await rm(dir,{recursive:true,force:true})}
|
|
138
141
|
})
|
|
139
142
|
|
|
143
|
+
test('a fresh relay initializes its bot identity before polling', async () => {
|
|
144
|
+
const dir=await mkdtemp(join(tmpdir(),'ez-cold-start-'))
|
|
145
|
+
const relay=createRelay({workspace:dir,controlDir:dir,pairingTtlMs:1000,executorTimeoutMs:0,executorCli:'grok',telegramBotToken:'fixture'},async()=>{throw new Error('No executor expected')})
|
|
146
|
+
const methods:string[]=[]
|
|
147
|
+
relay.bot.api.config.use(async(_prev,method,_payload,signal)=>{
|
|
148
|
+
methods.push(method)
|
|
149
|
+
if(method==='getMe') return {ok:true,result:{id:999,is_bot:true,first_name:'Fixture',username:'fixture_bot'}} as any
|
|
150
|
+
if(method==='getUpdates') {
|
|
151
|
+
if(signal && !signal.aborted) await new Promise<void>(resolve=>signal.addEventListener('abort',()=>resolve(),{once:true}))
|
|
152
|
+
return {ok:true,result:[]} as any
|
|
153
|
+
}
|
|
154
|
+
return {ok:true,result:true} as any
|
|
155
|
+
})
|
|
156
|
+
const started=relay.start()
|
|
157
|
+
try {
|
|
158
|
+
await until(async()=>methods.includes('getUpdates'))
|
|
159
|
+
assert.equal(relay.bot.isRunning(),true)
|
|
160
|
+
assert.equal(methods.filter(method=>method==='getMe').length,1)
|
|
161
|
+
assert.ok(methods.indexOf('getMe')<methods.indexOf('getUpdates'))
|
|
162
|
+
} finally {await relay.stop();await started;await rm(dir,{recursive:true,force:true})}
|
|
163
|
+
assert.equal(relay.bot.isRunning(),false)
|
|
164
|
+
})
|
|
165
|
+
|
|
140
166
|
for (const cleanupFails of [false,true]) test(`polling conflict preserves work until an explicit shutdown (cleanup fails: ${cleanupFails})`, async t => {
|
|
141
167
|
const dir=await mkdtemp(join(tmpdir(),'ez-polling-conflict-')),control=new ControlStore(dir,1000),runs=new RunStore(dir)
|
|
142
168
|
let release!:()=>void,entered!:()=>void,releaseDelivery!:()=>void,sending!:()=>void,child:ReturnType<typeof spawn>|undefined,polls=0
|
|
@@ -201,7 +201,7 @@ test('owner group discovery routes only to the private chat and rechecks identit
|
|
|
201
201
|
} finally { await f.close() }
|
|
202
202
|
})
|
|
203
203
|
|
|
204
|
-
test('four-item menu is owner-only;
|
|
204
|
+
test('four-item menu is owner-only; available AI choices work and forged/stale buttons cannot change settings', async () => {
|
|
205
205
|
const f = await fixture()
|
|
206
206
|
const callback = (id: number, data: string, user = 101): Update => ({
|
|
207
207
|
update_id: id,
|
|
@@ -220,10 +220,18 @@ test('four-item menu is owner-only; saved AI buttons work and forged/stale butto
|
|
|
220
220
|
await f.relay.bot.handleUpdate(callback(4, 'ai:forged'))
|
|
221
221
|
assert.equal(await store.getActiveSession(), null)
|
|
222
222
|
await f.relay.bot.handleUpdate(callback(5, pick))
|
|
223
|
+
if (!(await store.getActiveSession())) {
|
|
224
|
+
let useNow = f.keyboards.at(-1)!.flat().find((button) => button.text === 'Use now')?.callback_data
|
|
225
|
+
if (!useNow) {
|
|
226
|
+
await f.relay.bot.handleUpdate(callback(6, f.keyboards.at(-1)!.flat()[0].callback_data))
|
|
227
|
+
useNow = f.keyboards.at(-1)!.flat().find((button) => button.text === 'Use now')!.callback_data
|
|
228
|
+
}
|
|
229
|
+
await f.relay.bot.handleUpdate(callback(7, useNow))
|
|
230
|
+
}
|
|
223
231
|
assert.ok(await store.getActiveSession())
|
|
224
|
-
await f.relay.bot.handleUpdate(callback(
|
|
232
|
+
await f.relay.bot.handleUpdate(callback(8, pick))
|
|
225
233
|
assert.match(f.replies.at(-1)!, /Menu expired/)
|
|
226
|
-
await f.relay.bot.handleUpdate(message(
|
|
234
|
+
await f.relay.bot.handleUpdate(message(9, '/settings'))
|
|
227
235
|
assert.match(f.replies.at(-1)!, /Default for new conversations/)
|
|
228
236
|
await f.relay.bot.handleUpdate(message(8, '/status'))
|
|
229
237
|
assert.ok(f.keyboards.at(-1)!.flat().some((button) => button.text === 'Scheduled tasks'))
|
|
@@ -222,3 +222,63 @@ test('workflow runs and attempt jobs are paginated before selecting required evi
|
|
|
222
222
|
[jobsPath.replace('&page=1', '&page=2')]: { jobs: [job] },
|
|
223
223
|
}));
|
|
224
224
|
});
|
|
225
|
+
|
|
226
|
+
test('GitHub errors identify the failed read without exposing credentials or signed URLs', async () => {
|
|
227
|
+
const missing = githubClient('private-token', async () => new Response('private-body', { status: 404 }));
|
|
228
|
+
await assert.rejects(missing('/repos/jdorado/ez-agents/git/ref/tags/v1.2.3-beta.1'), /GitHub GET .*\/git\/ref\/tags\/v1\.2\.3-beta\.1: release tag is missing or inaccessible/);
|
|
229
|
+
await assert.rejects(missing('/repos/jdorado/ez-agents/releases/123'), error => {
|
|
230
|
+
assert.match(error.message, /HTTP 404.*GitHub GET .*\/releases\/123/);
|
|
231
|
+
assert.doesNotMatch(error.message, /private-token|private-body|release tag/);
|
|
232
|
+
return true;
|
|
233
|
+
});
|
|
234
|
+
let request = 0;
|
|
235
|
+
const download = githubClient('private-token', async () => request++ === 0
|
|
236
|
+
? new Response('', { status: 302, headers: { location: 'https://release-assets.githubusercontent.com/file?signature=private-signature' } })
|
|
237
|
+
: new Response('private-body', { status: 403 }));
|
|
238
|
+
await assert.rejects(download('/repos/jdorado/ez-agents/releases/assets/456', true), error => {
|
|
239
|
+
assert.match(error.message, /HTTP 403.*\/releases\/assets\/456 asset download/);
|
|
240
|
+
assert.doesNotMatch(error.message, /private-token|private-body|private-signature|signature=/);
|
|
241
|
+
return true;
|
|
242
|
+
});
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
test('staging recovery requires a real source tag and canonical asset before validating exact bytes', async t => {
|
|
246
|
+
const { validate } = await import('../scripts/trusted-beta.mjs');
|
|
247
|
+
const { readFile } = await import('node:fs/promises');
|
|
248
|
+
const dir = await mkdtemp(join(tmpdir(), 'beta-staging-recovery-'));
|
|
249
|
+
t.after(() => rm(dir, { recursive: true, force: true }));
|
|
250
|
+
await mkdir(join(dir, 'package'));
|
|
251
|
+
await writeFile(join(dir, 'package/package.json'), JSON.stringify(manifest));
|
|
252
|
+
execFileSync('tar', ['-czf', join(dir, 'candidate.tgz'), '-C', dir, 'package/package.json']);
|
|
253
|
+
const bytes = await readFile(join(dir, 'candidate.tgz'));
|
|
254
|
+
const candidateEnv = { ...env, RELEASE_SHA256: sha256(bytes) };
|
|
255
|
+
let tagExists = false, assetName = 'jc_stack-ez-agents-1.2.3-beta.1.tgz';
|
|
256
|
+
const reads = [];
|
|
257
|
+
const source = sourceApi();
|
|
258
|
+
const api = async path => {
|
|
259
|
+
reads.push(path);
|
|
260
|
+
if (path.includes('/git/ref/tags/') && !tagExists) {
|
|
261
|
+
return githubClient('test', async () => new Response('', { status: 404 }))(path);
|
|
262
|
+
}
|
|
263
|
+
if (path.endsWith('/releases/123')) return { id: 123, draft: true, prerelease: true, tag_name: `v${expected.version}`, assets: [
|
|
264
|
+
{ id: 456, name: assetName, state: 'uploaded' }, { id: 457, name: 'release-receipt.json', state: 'uploaded' },
|
|
265
|
+
] };
|
|
266
|
+
if (path.endsWith('/releases/assets/456')) return bytes;
|
|
267
|
+
if (path.endsWith('/releases/assets/457')) return Buffer.from(JSON.stringify({ ...receipt, sha256: candidateEnv.RELEASE_SHA256 }));
|
|
268
|
+
if (path.endsWith('/pulls/41')) return { merged: true, base: { repo: { full_name: expected.repository }, ref: 'main' }, merge_commit_sha: expected.sourceSha };
|
|
269
|
+
return source(path);
|
|
270
|
+
};
|
|
271
|
+
const output = join(dir, 'validated');
|
|
272
|
+
await assert.rejects(validate(output, candidateEnv, api), /release tag is missing/);
|
|
273
|
+
assert.ok(!reads.some(path => path.includes('/releases/')));
|
|
274
|
+
tagExists = true;
|
|
275
|
+
await assert.rejects(validate(output, candidateEnv, api), /Missing or ambiguous asset: candidate.tgz/);
|
|
276
|
+
assert.ok(!reads.some(path => path.includes('/releases/assets/')));
|
|
277
|
+
assetName = 'candidate.tgz';
|
|
278
|
+
await assert.rejects(validate(output, { ...candidateEnv, RELEASE_SHA256: '0'.repeat(64) }, api), /SHA256 mismatch/);
|
|
279
|
+
const result = await validate(output, candidateEnv, api);
|
|
280
|
+
assert.equal(result.sha256, sha256(bytes));
|
|
281
|
+
assert.deepEqual(await readFile(join(output, 'candidate.tgz')), bytes);
|
|
282
|
+
// Retry cannot silently replace an existing validated bundle.
|
|
283
|
+
await assert.rejects(validate(output, candidateEnv, api), /EEXIST/);
|
|
284
|
+
});
|
package/test/updates.test.mjs
CHANGED
|
@@ -7,7 +7,7 @@ import { gzipSync } from 'node:zlib';
|
|
|
7
7
|
import { spawn, execFile } from 'node:child_process';
|
|
8
8
|
import { promisify } from 'node:util';
|
|
9
9
|
import { extract, digest, version, newer, compatible } from '../src/updates/artifact.mjs';
|
|
10
|
-
import { prepare, submit, command, read, jobPath, eligibility } from '../src/updates/control.mjs';
|
|
10
|
+
import { prepare, submit, command, read, jobPath, eligibility, jobs } from '../src/updates/control.mjs';
|
|
11
11
|
import { perform, environment, packageManager } from '../src/updates/runtime.mjs';
|
|
12
12
|
import { atomic, snapshot, compose } from '../src/plugins/manager.mjs';
|
|
13
13
|
import { bindUpdates } from '../src/updates/binding.mjs';
|
|
@@ -86,6 +86,16 @@ test('status distinguishes installed, running, legacy and stale main versions wi
|
|
|
86
86
|
const result=await exec(process.execPath,[new URL('../bin/ezenciel-agents-tools.mjs',import.meta.url).pathname,'--home',f.home,'status']);
|
|
87
87
|
assert.equal(JSON.parse(result.stdout).main.installedVersion,'0.1.0');
|
|
88
88
|
});
|
|
89
|
+
test('relay healthcheck emits bounded predicate evidence without state contents',async t=>{
|
|
90
|
+
const root=await fs.realpath(await fs.mkdtemp(path.join(tmpdir(),'ez-healthcheck-')));t.after(()=>fs.rm(root,{recursive:true,force:true}));
|
|
91
|
+
const relay=path.join(root,'relay'),host=path.join(root,'host');await fs.mkdir(relay,{recursive:true});await fs.mkdir(path.join(host,'host-executor'),{recursive:true});
|
|
92
|
+
const check=async()=>exec(process.execPath,[new URL('../docker/healthcheck.mjs',import.meta.url).pathname],{env:{...process.env,EZ_HEALTH_RELAY_CONTROL_DIR:relay,EZ_EXECUTOR_TRANSPORT:'host',EZ_CONTROL_DIR:host}});
|
|
93
|
+
await fs.writeFile(path.join(relay,'heartbeat.json'),JSON.stringify({polling:true,at:Date.now()}));await fs.writeFile(path.join(host,'host-executor/heartbeat.json'),JSON.stringify({at:Date.now()}));await check();
|
|
94
|
+
await fs.writeFile(path.join(relay,'heartbeat.json'),JSON.stringify({polling:false,at:Date.now(),private:'must-not-appear'}));
|
|
95
|
+
await assert.rejects(check(),error=>{assert.match(error.stderr,/EZ_HEALTH_RELAY_NOT_POLLING/);assert.doesNotMatch(error.stderr,/must-not-appear/);return true;});
|
|
96
|
+
await fs.writeFile(path.join(relay,'heartbeat.json'),JSON.stringify({polling:true,at:Date.now()}));await fs.writeFile(path.join(host,'host-executor/heartbeat.json'),JSON.stringify({at:Date.now()-20000}));
|
|
97
|
+
await assert.rejects(check(),error=>{assert.match(error.stderr,/EZ_HEALTH_HOST_STALE/);return true;});
|
|
98
|
+
});
|
|
89
99
|
test('plugin status verifies images and reports stopped, mismatched and unreachable runtimes honestly',async t=>{
|
|
90
100
|
const f=await fixture(t,'plugin'),id='a'.repeat(64),image='sha256:'+'b'.repeat(64);
|
|
91
101
|
for(const mode of ['running','ndjson','stopped','missing','mismatched','offline']) {
|
|
@@ -157,6 +167,11 @@ test('failed preparation never stops runtime; failed activation rolls back code
|
|
|
157
167
|
else assert((await fs.readFile(path.join(f.config.deploymentDir,'docker.env'),'utf8')).includes('sha256:'+'a'.repeat(64)));
|
|
158
168
|
}
|
|
159
169
|
});
|
|
170
|
+
test('main health-gate rollback retains only the recognized sanitized predicate',async t=>{
|
|
171
|
+
const f=await fixture(t),job=await queued(f),base=runtime(f);let failed=false;
|
|
172
|
+
const r={...base,execute:async(c,a,o)=>{if(a.includes('up')&&!failed){failed=true;throw Error('container relay is unhealthy');}return a.includes('ps')?'a'.repeat(64):a[0]==='inspect'&&a[2]==='{{json .State.Health}}'?JSON.stringify({Log:[{Output:'EZ_HEALTH_HOST_STALE\n'}]}):base.execute(c,a,o);}};
|
|
173
|
+
const result=await perform(f.home,job,r);assert.equal(result.status,'rolled-back');assert.match(result.error,/health=host-stale/);assert.doesNotMatch(result.error,/private-test-token/);
|
|
174
|
+
});
|
|
160
175
|
test('plugin transaction preserves named volumes, backs up stopped data and rolls back failed health',async t=>{
|
|
161
176
|
for(const failed of [false,true]) {
|
|
162
177
|
const f=await fixture(t,'plugin'),job=await queued(f),r=runtime(f,{fail:(_c,a)=>failed&&a.includes('up')});
|
|
@@ -237,6 +252,11 @@ test('missing or broken managers fail with repair guidance before installing or
|
|
|
237
252
|
|
|
238
253
|
for(const provider of ['pnpm','corepack']) test(`supervisor with only ${provider} drains work, replaces host PID and recovers after restart`,async t=>{
|
|
239
254
|
const f=await fixture(t),fake=path.join(f.root,'fake');await fs.mkdir(fake);
|
|
255
|
+
// A process may die after creating a job directory but before atomically
|
|
256
|
+
// committing its receipt. That directory must not block transport startup.
|
|
257
|
+
await fs.mkdir(path.join(f.home,'updates','d18e847a-bb59-49c6-96f3-27fdc43ca44f'));
|
|
258
|
+
await fs.mkdir(path.join(f.home,'updates','a'.repeat(36)));
|
|
259
|
+
assert.deepEqual(await jobs(f.home),[]);
|
|
240
260
|
const hostCode=`import fs from 'node:fs';import path from 'node:path';const c=JSON.parse(fs.readFileSync(process.argv[2])).agents[0];const d=path.join(c.controlDir,'host-executor');fs.mkdirSync(d,{recursive:true});const beat=()=>{fs.writeFileSync(path.join(d,'heartbeat.json'),JSON.stringify({pid:process.pid,at:Date.now()}));};beat();const timer=setInterval(()=>{try{process.kill(Number(process.env.EZ_HOST_SUPERVISOR_PID),0)}catch{process.exit(0)}beat()},100);process.on('SIGTERM',()=>{clearInterval(timer);process.exit(0)});`;
|
|
241
261
|
for(const dir of [f.old,f.source]) {
|
|
242
262
|
await fs.mkdir(path.join(dir,'node_modules/tsx/dist'),{recursive:true});await fs.writeFile(path.join(dir,'node_modules/tsx/dist/loader.mjs'),'');
|