@jc_stack/ez-agents 0.1.0-beta.44 → 0.1.0-beta.46
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/AGENTS.md +2 -2
- package/CHANGELOG.md +30 -0
- package/docs/application-channel.md +3 -0
- package/docs/local-qa.md +1 -1
- package/docs/plugin-connection.md +3 -2
- package/docs/plugins.md +18 -0
- package/docs/responsive-channels.md +12 -5
- package/docs/scheduling.md +9 -5
- package/docs/upgrades.md +12 -3
- package/package.json +1 -1
- package/scripts/stage-qa.mjs +2 -2
- package/src/application-channel.ts +3 -1
- package/src/codex-session.ts +7 -1
- package/src/host-executor.ts +22 -8
- package/src/index.ts +4 -3
- package/src/plugins/manager.mjs +22 -20
- package/src/plugins/workspace-lease.mjs +1 -1
- package/src/runs.ts +1 -1
- package/src/updates/binding.mjs +14 -7
- package/src/updates/control.mjs +2 -1
- package/src/updates/runtime.mjs +11 -4
- package/src/updates/supervisor.mjs +11 -7
- package/test/application-controls.test.ts +3 -0
- package/test/codex-session.test.ts +15 -2
- package/test/host-executor.test.ts +19 -5
- package/test/local-qa.test.mjs +2 -2
- package/test/plugin-connection.test.mjs +11 -0
- package/test/plugin-manager.test.mjs +46 -3
- package/test/scheduler-host.test.ts +4 -5
- package/test/scheduler-relay.test.ts +49 -7
- package/test/updates.test.mjs +71 -5
package/AGENTS.md
CHANGED
|
@@ -120,8 +120,8 @@ round merely to seek more criticism, tests or hypothetical edge cases.
|
|
|
120
120
|
- **Rate limits:** Avoid spamming bubbles to respect Telegram’s 1 message/sec per-chat rate limit.
|
|
121
121
|
|
|
122
122
|
## 6. Concurrency & Workspace Invariants
|
|
123
|
-
- **
|
|
124
|
-
-
|
|
123
|
+
- **Responsive owner channels:** One foreground turn and one owner-scheduled turn may run concurrently; each stream stays serial. Separate native sessions isolate history and engine state, while the agent-owned Markdown workspace is shared. The engine coordinates shared file updates and plugins guard canonical record writes. Do not add a second reply agent, scheduler or execution queue.
|
|
124
|
+
- The delivery socket carries every cross-process enqueue/receipt, never control/ files. Host plugin workspace invocations remain excluded until both native turns finish. Scheduled jobs keep separate native CLI sessions and control-side task state. Restricted contact tasks retain their separate authorization boundary. Delegation decisions and goal persistence belong to the agent/executor; there is no automatic planner or canned chat ACK. Production executor runs have no wall-clock timeout; cancellation is explicit.
|
|
125
125
|
|
|
126
126
|
## 7. Fail-Closed Authority (Channel Access ≠ Execution)
|
|
127
127
|
- Incoming messages outside the approved owner binding must **never** spawn the executor. A first DM or group message records a pending request only. Explicitly approved owner groups grant owner access to all human members in that exact chat; bots and anonymous posts are ignored.
|
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,35 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.0-beta.46
|
|
4
|
+
|
|
5
|
+
- Allow private RC/QA candidates to graduate to their matching published beta
|
|
6
|
+
through the existing updater, without permitting a move to an older beta.
|
|
7
|
+
|
|
8
|
+
- Refresh the isolated broker after plugin upgrade or rollback so status reports
|
|
9
|
+
the resulting installed versions rather than its startup inventory. Preserve
|
|
10
|
+
the previous registry snapshot independently during plugin activation.
|
|
11
|
+
Preserve stopped plugins as stopped when Docker retains a created container.
|
|
12
|
+
|
|
13
|
+
- Include the existing Telegram runtime/version summary in authorized private
|
|
14
|
+
channel controls so channel status can show the running relay and host versions.
|
|
15
|
+
|
|
16
|
+
## 0.1.0-beta.45
|
|
17
|
+
|
|
18
|
+
- Keep owner chat responsive while a scheduled native turn runs. Foreground
|
|
19
|
+
turns and scheduled turns each stay serial, with separate native sessions.
|
|
20
|
+
Host workspace invocations remain excluded until both streams finish.
|
|
21
|
+
|
|
22
|
+
- Expose the existing immediate scheduling command in bounded managed guidance;
|
|
23
|
+
scope the owner-requested preference to foreground turns and prevent scheduled
|
|
24
|
+
work from scheduling itself again. Upgrade and rollback render the selected
|
|
25
|
+
package's guidance while preserving agent-owned text.
|
|
26
|
+
- Support real external agent workspaces and private control directories without
|
|
27
|
+
deployment convenience symlinks, including plugin grants and updater backups.
|
|
28
|
+
- Treat a concurrently released workspace lock as busy without adding retries,
|
|
29
|
+
and allow bounded host initialization beyond the former ten-second window.
|
|
30
|
+
- Preserve failed native Codex turn diagnostics for channel delivery instead of
|
|
31
|
+
losing the failure when a native turn completes without response text.
|
|
32
|
+
|
|
3
33
|
## 0.1.0-beta.44
|
|
4
34
|
|
|
5
35
|
- Let isolated native agents read declared installed plugin skills through the
|
|
@@ -58,6 +58,9 @@ the ordinary last-selection-wins behavior. Already-admitted runs keep their
|
|
|
58
58
|
captured conversation and AI. Uncertain control responses require a fresh read,
|
|
59
59
|
not blind repetition of `/new`.
|
|
60
60
|
|
|
61
|
+
The scope-control response includes `software`, the same current runtime/version
|
|
62
|
+
summary used by Telegram status. It is read-only and does not create a session.
|
|
63
|
+
|
|
61
64
|
For private application conversations, `GET /v1/scope-control?scope=<encoded-scope>` returns
|
|
62
65
|
the same catalog and the current scope's public control ID and preset. The scope
|
|
63
66
|
is the application's original admission scope, resolved under its authenticated
|
package/docs/local-qa.md
CHANGED
|
@@ -16,7 +16,7 @@ Commit, push and open a PR first — then stage only from that PR's exact commit
|
|
|
16
16
|
```sh
|
|
17
17
|
node scripts/stage-qa.mjs --source /absolute/feature-checkout \
|
|
18
18
|
--catalog /absolute/deployment/tools/qa --label beta-12 \
|
|
19
|
-
--version 0.1.0-beta.12.
|
|
19
|
+
--version 0.1.0-beta.12.rc.1 --flow /absolute/feature-QA.md
|
|
20
20
|
```
|
|
21
21
|
|
|
22
22
|
`beta-12` is a local catalog label, distinct from public `0.1.0-beta.12`. The
|
|
@@ -64,8 +64,9 @@ environment. Calls have a 30-second timeout and 256 KiB combined output limit;
|
|
|
64
64
|
connection frames are limited to 1 MiB. Connection closure aborts pending calls
|
|
65
65
|
and removes their exact command containers. There is no automatic retry.
|
|
66
66
|
|
|
67
|
-
Arbitrary calls take the same fail-fast workspace lease as
|
|
68
|
-
|
|
67
|
+
Arbitrary calls take the same fail-fast workspace lease as native jobs. Host
|
|
68
|
+
foreground and scheduled turns share that lease until both finish. Native work
|
|
69
|
+
stays pending while a call runs; calls refuse pending/running
|
|
69
70
|
native work. Ordinary CLI calls inside an admitted native job do not reacquire
|
|
70
71
|
the lease. Isolated scheduled work keeps its existing workspace behavior. A stale
|
|
71
72
|
native `workspace-writer.lock` is recovered at host startup only after its host
|
package/docs/plugins.md
CHANGED
|
@@ -157,6 +157,11 @@ snapshots. Reinstallation reuses that data. Plain install does not replace a dif
|
|
|
157
157
|
compatible upgrades under saved policy; see [upgrades](upgrades.md). Manual
|
|
158
158
|
uninstall/reinstall remains available but does not promise schema rollback.
|
|
159
159
|
|
|
160
|
+
Installed package files retain their reviewed modes (0644 for ordinary files,
|
|
161
|
+
0755 for executables and nested directories), including under restrictive umasks
|
|
162
|
+
or inherited default ACLs. The snapshot root remains private (0700); provider
|
|
163
|
+
data and private operation receipts keep their separate permissions.
|
|
164
|
+
|
|
160
165
|
For another reviewed local package, inspect with `plugins inspect <id> --source
|
|
161
166
|
/absolute/source`, then install with that source and the returned `--revision
|
|
162
167
|
sha256:...`. Changed contents fail. To make a reviewed package discoverable without installing,
|
|
@@ -281,6 +286,19 @@ survive command-time Compose regeneration and restarts. Before a routed plugin
|
|
|
281
286
|
update, the host adds the inspected candidate revision to that route; both the
|
|
282
287
|
current and candidate revisions may remain authorized during the transition.
|
|
283
288
|
|
|
289
|
+
An isolated broker mounts `host-executor.json` as a read-only file. After the host
|
|
290
|
+
atomically replaces that configuration, recreate the declared broker service
|
|
291
|
+
through the deployment's bound launcher before activating the candidate:
|
|
292
|
+
`ezenciel-agents-docker up -d --no-deps --no-build --force-recreate plugin-broker`.
|
|
293
|
+
This refreshes the file mount without replacing the native relay or its sessions.
|
|
294
|
+
Host-capable executors read the current host file directly. Verify a native plugin
|
|
295
|
+
call and its provider receipt after the configuration change.
|
|
296
|
+
|
|
297
|
+
Before a data-preserving uninstall, record the host-owned routes and any
|
|
298
|
+
operator-created plugin network's name, labels and IPAM configuration. Restore
|
|
299
|
+
that same network configuration before activation if uninstall removed it;
|
|
300
|
+
private volumes alone do not preserve Docker networks.
|
|
301
|
+
|
|
284
302
|
This is deployment configuration, not a package-descriptor field or an `ez`
|
|
285
303
|
command: agents and plugins cannot request or alter host networks. The host
|
|
286
304
|
installer owns the binding and must ensure the selected service is stopped
|
|
@@ -1,10 +1,17 @@
|
|
|
1
1
|
# Responsive channels
|
|
2
2
|
|
|
3
|
-
Owner input passes unchanged to the selected engine.
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
3
|
+
Owner input passes unchanged to the selected engine. In a foreground owner turn,
|
|
4
|
+
when work can continue independently, the global managed guidance tells the agent
|
|
5
|
+
to prefer an immediate scheduled turn so the foreground channel remains available.
|
|
6
|
+
A scheduled turn executes its assigned work directly instead of scheduling itself
|
|
7
|
+
again. The agent still makes that choice; ez does not infer task duration or
|
|
8
|
+
auto-schedule work. ez queues foreground input while a foreground run is active;
|
|
9
|
+
it does not create a separate busy-reply agent.
|
|
10
|
+
Owner-scheduled turns run independently of the foreground channel stream, so a
|
|
11
|
+
long review does not block chat. Each stream runs one turn at a time. They share
|
|
12
|
+
the agent-owned workspace, but use separate native sessions; session isolation
|
|
13
|
+
does not isolate Markdown files. The agent coordinates shared file updates and
|
|
14
|
+
plugins enforce their canonical record-write contracts.
|
|
8
15
|
|
|
9
16
|
Background tasks receive literal task text. Their native sessions contain no
|
|
10
17
|
generated role instructions or copied identity files; native workspace instructions
|
package/docs/scheduling.md
CHANGED
|
@@ -49,10 +49,14 @@ No identity files or role instructions are generated. Existing workspace Markdow
|
|
|
49
49
|
provides context, including native `AGENTS.md` discovery and relative links; the
|
|
50
50
|
engine chooses what to read. Per-run native state stays under `control/`.
|
|
51
51
|
|
|
52
|
-
One
|
|
53
|
-
queue behind
|
|
54
|
-
|
|
55
|
-
|
|
52
|
+
One foreground turn and one owner-scheduled turn can run at the same time.
|
|
53
|
+
Foreground inputs queue behind foreground work; scheduled turns queue behind
|
|
54
|
+
scheduled work. A long scheduled goal therefore does not block chat. Both streams
|
|
55
|
+
use the existing admission and executor path, with separate native sessions.
|
|
56
|
+
They share the agent-owned Markdown workspace: the agent coordinates shared file
|
|
57
|
+
updates, and plugins enforce canonical record-write guards. The host keeps plugin
|
|
58
|
+
workspace invocations excluded until both native turns finish.
|
|
59
|
+
The agent decides when to send through the message CLI.
|
|
56
60
|
A recurring schedule has at most one pending or active occurrence. Shared
|
|
57
61
|
provider resources still need writer coordination. The former four background
|
|
58
62
|
slots are no longer used for owner schedules.
|
|
@@ -150,7 +154,7 @@ Telegram delivery, and a sleep test does not prove native goal persistence.
|
|
|
150
154
|
|
|
151
155
|
Create a normal recurring schedule with `--every-seconds 900 --when unreviewed-failures --text-file templates/failure-review.md`. The condition advances empty occurrences without launching an executor. It considers only failures belonging to the paired owner. No separate monitor or automatic retry is introduced.
|
|
152
156
|
|
|
153
|
-
`failures [--all] [--limit N]` returns failedAt, reason, exit code, native session, captured error and runtime versions. Capture keeps at most 4 KiB of redacted stderr; historical failures are not backfilled. `run RUN_ID` reads an owned run. `review RUN_ID --failed-at ISO --status resolved|attention --diagnosis TEXT --recovery TEXT --outcome TEXT` records the investigation without rewriting execution history. A stale timestamp is rejected; a later failure needs a new review. Restricted reply, external and isolated-task callers cannot review failures. An attention review is handed off, not repeatedly relaunched; another new failure wakes the next review.
|
|
157
|
+
`failures [--all] [--limit N]` returns failedAt, reason, exit code, native session, captured error and runtime versions. Capture keeps at most 4 KiB of redacted stderr, including a failed native Codex turn's error message when supplied. A failure without error details remains explicitly unknown; historical failures are not backfilled. `run RUN_ID` reads an owned run. `review RUN_ID --failed-at ISO --status resolved|attention --diagnosis TEXT --recovery TEXT --outcome TEXT` records the investigation without rewriting execution history. A stale timestamp is rejected; a later failure needs a new review. Restricted reply, external and isolated-task callers cannot review failures. An attention review is handed off, not repeatedly relaunched; another new failure wakes the next review.
|
|
154
158
|
|
|
155
159
|
The prompt controls diagnosis, authorized recovery and quiet notification behavior. Inspect prior effects and receipts before retrying anything. A failed review run itself remains visible as a new failure for the next occurrence.
|
|
156
160
|
|
package/docs/upgrades.md
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
# Agent-owned software upgrades
|
|
2
2
|
|
|
3
|
+
Private `beta.N.rc.M` and `beta.N.qa.M` candidates may upgrade to the matching
|
|
4
|
+
published `beta.N`, despite SemVer's prerelease ordering. All identity, integrity,
|
|
5
|
+
deployment and owner-policy checks still apply; older betas remain rejected.
|
|
6
|
+
|
|
3
7
|
For an application's independently deployed frontend, backend or embedded gateway,
|
|
4
8
|
see [managed applications](managed-applications.md). They use their existing
|
|
5
9
|
deployment tools and a saved maintenance mandate; this package updater inventories
|
|
@@ -245,10 +249,15 @@ handbook from workspace `AGENTS.md` (and existing `AGENTS.override.md`).
|
|
|
245
249
|
Personal text outside that block is preserved byte-for-byte. Do not reinsert
|
|
246
250
|
coding, KISS, contribution or repair loops into operating minds.
|
|
247
251
|
|
|
248
|
-
The package-owned
|
|
249
|
-
`ez` launcher, `ez --help`,
|
|
250
|
-
|
|
252
|
+
The package-owned guidance is the managed `<!-- ez tools -->` block: the bound
|
|
253
|
+
`ez` launcher, `ez --help`, `ez tools list --details`, and one routing hint for
|
|
254
|
+
foreground owner turns to prefer `ezenciel-agents-schedule create --now` when work
|
|
255
|
+
can continue independently without blocking interaction. The hint tells scheduled
|
|
256
|
+
turns to execute their assigned work directly instead of rescheduling themselves.
|
|
257
|
+
Inventory and full scheduler usage are generated on read. Plugin registry init and update binding refresh that block.
|
|
251
258
|
Malformed markers and symlinks fail visibly rather than overwriting personal work.
|
|
259
|
+
Upgrade and rollback render the block through the exact package root being activated,
|
|
260
|
+
so an already-running package cannot leave stale guidance behind.
|
|
252
261
|
|
|
253
262
|
Codex, Grok and OpenCode discover workspace instructions natively. Claude
|
|
254
263
|
receives the native `--append-system-prompt-file` binding to `AGENTS.md`, alongside
|
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.46",
|
|
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/stage-qa.mjs
CHANGED
|
@@ -8,10 +8,10 @@ import {parseArgs} from 'node:util';
|
|
|
8
8
|
import {digest, extract, version, newer} from '../src/updates/artifact.mjs';
|
|
9
9
|
|
|
10
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.
|
|
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.rc.1 --flow QA.md');
|
|
12
12
|
if(!/^beta-[1-9]\d*$/.test(v.label))throw Error('Label must be beta-N');
|
|
13
13
|
version(v.version);
|
|
14
|
-
if(!/^\d+\.\d+\.\d+-beta\.\d+\.
|
|
14
|
+
if(!/^\d+\.\d+\.\d+-beta\.\d+\.rc\.[1-9]\d*$/.test(v.version))throw Error('Use a distinct private version: X.Y.Z-beta.N.rc.M');
|
|
15
15
|
const source=await fs.realpath(v.source),catalog=path.resolve(v.catalog);
|
|
16
16
|
const run=(cmd,args,cwd=source)=>execFileSync(cmd,args,{cwd,encoding:'utf8',stdio:['ignore','pipe','pipe']}).trim();
|
|
17
17
|
if(run('git',['status','--porcelain']))throw Error('Commit the reviewed source before staging QA');
|
|
@@ -11,6 +11,7 @@ import { MAX_INCOMING_ATTACHMENT_BYTES, stageChatAttachment } from './files.js'
|
|
|
11
11
|
import { assertEffort } from './model-policy.js'
|
|
12
12
|
import { ApprovalStore } from './approval.js'
|
|
13
13
|
import { Tasks } from './tasks.js'
|
|
14
|
+
import { softwareStatus } from './software-status.js'
|
|
14
15
|
import { SpeechCreditsDepletedError } from './audio.js'
|
|
15
16
|
|
|
16
17
|
const hash = (value: string) => createHash('sha256').update(value).digest('hex')
|
|
@@ -72,7 +73,7 @@ export class ApplicationChannel {
|
|
|
72
73
|
private admissions = Promise.resolve()
|
|
73
74
|
readonly bindings: ApplicationBindings
|
|
74
75
|
constructor(private options: {
|
|
75
|
-
controlDir: string; workspace?: string; initial: AiPreset
|
|
76
|
+
controlDir: string; workspace?: string; isolation?: string; initial: AiPreset
|
|
76
77
|
wake: () => void
|
|
77
78
|
cancel: (id: string) => Promise<void>
|
|
78
79
|
speech?: (text: string, language: 'en' | 'es') => Promise<{ buffer: Buffer; mimeType: string }>
|
|
@@ -180,6 +181,7 @@ export class ApplicationChannel {
|
|
|
180
181
|
await this.sharedBinding(bindingId, false)
|
|
181
182
|
return {ai:{...ai, selectedId:session?.preset?.id ?? ai.selectedId,
|
|
182
183
|
presets:session?.preset ? [...ai.presets.filter(item=>item.id!==session.preset!.id),session.preset] : ai.presets},
|
|
184
|
+
software: await softwareStatus(this.options.controlDir, this.options.isolation),
|
|
183
185
|
models, activeSessionId:session?.sessionId ?? null, sessions:session ? [{id:session.sessionId,title:sessionTitle(session),cli:session.cli,archived:false}] : []}
|
|
184
186
|
}
|
|
185
187
|
async changeScopeControls(bindingId: string, scope: string, input: unknown) {
|
package/src/codex-session.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { executionDefaults } from './model-policy.js'
|
|
2
|
+
import { redactFailure } from './failure.js'
|
|
2
3
|
import { spawn, type ChildProcess } from 'node:child_process'
|
|
3
4
|
import { createInterface } from 'node:readline'
|
|
4
5
|
import { fileURLToPath } from 'node:url'
|
|
@@ -62,7 +63,12 @@ export async function runCodexSession(options:Options, io:{launch?:()=>ChildProc
|
|
|
62
63
|
if(message.method==='thread/goal/cleared')settled(null)
|
|
63
64
|
if(message.method==='turn/completed'){
|
|
64
65
|
if(activeTurn===message.params.turn.id)activeTurn=undefined
|
|
65
|
-
if(message.params.turn.status!=='completed'){
|
|
66
|
+
if(message.params.turn.status!=='completed'){
|
|
67
|
+
if(message.params.turn.status==='interrupted')finish(130)
|
|
68
|
+
else fail(new Error(redactFailure(typeof message.params.turn.error?.message==='string'
|
|
69
|
+
? message.params.turn.error.message : 'Native turn failed without error details')))
|
|
70
|
+
return
|
|
71
|
+
}
|
|
66
72
|
// Completion of a turn is not completion of a native goal. The native
|
|
67
73
|
// app-server remains running and owns any automatic next turn.
|
|
68
74
|
void request('thread/goal/get',{threadId}).then(result=>settled(result.goal)).catch(fail)
|
package/src/host-executor.ts
CHANGED
|
@@ -51,7 +51,7 @@ export const serveHostExecutor = async (installation: HostInstallation, signal:
|
|
|
51
51
|
let catalogRefresh: Promise<void> | undefined
|
|
52
52
|
const locks: string[] = []
|
|
53
53
|
const sharedWorkspaces = new Map<HostBinding, string>()
|
|
54
|
-
const activeWorkspaces = new Set<
|
|
54
|
+
const activeWorkspaces = new Map<HostBinding, { lanes: Set<boolean>; release?: () => Promise<void> }>()
|
|
55
55
|
const additionalWorkspaces = new Map<HostBinding, string[]>()
|
|
56
56
|
const catalog = async (agent: HostBinding) => {
|
|
57
57
|
const discovered = await readModels(undefined, undefined, path.join(agent.controlDir, 'cli', 'codex'),
|
|
@@ -158,13 +158,27 @@ export const serveHostExecutor = async (installation: HostInstallation, signal:
|
|
|
158
158
|
}
|
|
159
159
|
const sharedWorkspace=sharedWorkspaces.get(agent)
|
|
160
160
|
const base=path.join(directory,id)
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
if (
|
|
165
|
-
|
|
161
|
+
const scheduled = Boolean(run?.scheduled)
|
|
162
|
+
let workspace = activeWorkspaces.get(agent)
|
|
163
|
+
if (workspace?.lanes.has(scheduled)) continue
|
|
164
|
+
if (!workspace) {
|
|
165
|
+
const release = agent.toolsHome
|
|
166
|
+
? await (await import('./plugins/workspace-lease.mjs')).workspaceLease(agent.toolsHome,{kind:'native',runId:id}) : undefined
|
|
167
|
+
if (agent.toolsHome && !release) continue
|
|
168
|
+
workspace = { lanes: new Set(), release }
|
|
169
|
+
activeWorkspaces.set(agent, workspace)
|
|
170
|
+
}
|
|
171
|
+
workspace.lanes.add(scheduled)
|
|
172
|
+
// Keep plugin workspace invocations excluded until both streams finish.
|
|
173
|
+
const releaseWorkspace = async () => {
|
|
174
|
+
workspace.lanes.delete(scheduled)
|
|
175
|
+
if (!workspace.lanes.size) {
|
|
176
|
+
activeWorkspaces.delete(agent)
|
|
177
|
+
await workspace.release?.()
|
|
178
|
+
}
|
|
179
|
+
}
|
|
166
180
|
try { await rename(base+'.request.json',base+'.running.json') }
|
|
167
|
-
catch (error) {
|
|
181
|
+
catch (error) { await releaseWorkspace(); throw error }
|
|
168
182
|
const task=(async()=>{
|
|
169
183
|
let job: Awaited<ReturnType<typeof startExecutorJob>> | undefined
|
|
170
184
|
let cancellation: ReturnType<typeof setInterval> | undefined
|
|
@@ -220,7 +234,7 @@ export const serveHostExecutor = async (installation: HostInstallation, signal:
|
|
|
220
234
|
await rm(base+'.process.json',{force:true})
|
|
221
235
|
await rm(base+'.cancel',{force:true})
|
|
222
236
|
active.delete(base)
|
|
223
|
-
|
|
237
|
+
await releaseWorkspace()
|
|
224
238
|
}
|
|
225
239
|
})()
|
|
226
240
|
tasks.add(task); void task.finally(()=>tasks.delete(task))
|
package/src/index.ts
CHANGED
|
@@ -100,7 +100,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
100
100
|
}
|
|
101
101
|
|
|
102
102
|
const applicationChannel = new ApplicationChannel({
|
|
103
|
-
controlDir: config.controlDir, workspace: config.workspace, initial: aiMenu.initial,
|
|
103
|
+
controlDir: config.controlDir, workspace: config.workspace, isolation: config.isolation, initial: aiMenu.initial,
|
|
104
104
|
aiControls: aiMenu,
|
|
105
105
|
speech: config.geminiApiKey ? (text, language) => synthesizeSpeech(text, {
|
|
106
106
|
geminiApiKey: config.geminiApiKey,
|
|
@@ -205,8 +205,9 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
205
205
|
return
|
|
206
206
|
}
|
|
207
207
|
if (run.scheduled && !(await scheduler.get(run.scheduled.id)).enabled) return
|
|
208
|
-
//
|
|
209
|
-
|
|
208
|
+
// A long scheduled turn must not occupy the owner's chat stream.
|
|
209
|
+
// Each stream remains serial, with separate native sessions.
|
|
210
|
+
if (run.scheduled ? background.size > 0 : activeChild !== null) return
|
|
210
211
|
let texts = run.texts
|
|
211
212
|
if (run.external) {
|
|
212
213
|
// Availability failures leave durable queued work for a later check.
|
package/src/plugins/manager.mjs
CHANGED
|
@@ -41,16 +41,24 @@ export async function stewardOwned(file) {
|
|
|
41
41
|
const parent = await fs.stat(path.dirname(file));
|
|
42
42
|
await fs.chown(file, parent.uid, parent.gid);
|
|
43
43
|
}
|
|
44
|
-
//
|
|
45
|
-
|
|
46
|
-
|
|
44
|
+
// Native instructions keep only the registry locator and one crucial routing
|
|
45
|
+
// hint. Inventory and full scheduler usage are generated on read.
|
|
46
|
+
export async function toolDiscoveryBlock(home,hostConfig) {
|
|
47
|
+
home=await fs.realpath(home);
|
|
47
48
|
let launcher=path.join(home,'bin','ez');
|
|
48
|
-
const config=await json(path.join(home,'config.json')).catch(error=>{if(error.code==='ENOENT')return undefined;throw error;});
|
|
49
|
+
const config=hostConfig?{hostConfig}:await json(path.join(home,'config.json')).catch(error=>{if(error.code==='ENOENT')return undefined;throw error;});
|
|
49
50
|
if(config?.hostConfig) {
|
|
50
51
|
const host=await json(config.hostConfig);
|
|
51
52
|
if(host.isolation==='isolated') launcher='ez';
|
|
52
53
|
}
|
|
53
54
|
const start='<!-- ez tools: begin -->',end='<!-- ez tools: end -->';
|
|
55
|
+
const block=start+'\nUse `'+launcher+'`; run it with `--help` or `tools list --details` to discover installed capabilities, and read the matching skill when needed.\nKeep the channel responsive: during a foreground owner turn, prefer `ezenciel-agents-schedule create --now ...` when work can continue independently. A `[schedule ...]` turn executes its assigned work directly and must not schedule it again.\n'+end;
|
|
56
|
+
if(Buffer.byteLength(block)>600)throw Error('Tool discovery guidance exceeds the 600-byte core budget');
|
|
57
|
+
return {block,start,end};
|
|
58
|
+
}
|
|
59
|
+
export async function bindToolDiscovery(home,workspace) {
|
|
60
|
+
home=await fs.realpath(home);workspace=await fs.realpath(workspace);
|
|
61
|
+
const {block,start,end}=await toolDiscoveryBlock(home);
|
|
54
62
|
for(const name of ['AGENTS.md','AGENTS.override.md']) {
|
|
55
63
|
const file=path.join(workspace,name);
|
|
56
64
|
const stat=await fs.lstat(file).catch(e=>{if(e.code==='ENOENT')return null;throw e;});
|
|
@@ -59,7 +67,6 @@ export async function bindToolDiscovery(home,workspace) {
|
|
|
59
67
|
const prior=stat?await fs.readFile(file,'utf8'):'';
|
|
60
68
|
const from=prior.indexOf(start),to=prior.indexOf(end);
|
|
61
69
|
if((from<0)!==(to<0)||(from>=0&&(to<from||prior.indexOf(start,from+start.length)>=0||prior.indexOf(end,to+end.length)>=0)))throw Error('Malformed tool discovery block');
|
|
62
|
-
const block=start+'\nInstalled capabilities: `'+launcher+' --help` and `'+launcher+' tools list --details`. Use this bound launcher; read the matching skill when needed.\n'+end;
|
|
63
70
|
const next=from<0?prior+'\n'+block+'\n':prior.slice(0,from)+block+prior.slice(to+end.length);
|
|
64
71
|
if(next===prior)continue;
|
|
65
72
|
const tmp=file+'.'+randomUUID()+'.tmp';
|
|
@@ -226,18 +233,11 @@ async function hostFolderRoots(config, record, home) {
|
|
|
226
233
|
if (!host || typeof host !== 'object' || !Array.isArray(host.agents)) throw Error('Invalid host folder bindings');
|
|
227
234
|
const candidates = host.agents.filter(agent => agent && typeof agent === 'object' && typeof agent.toolsHome === 'string' && realpathSync(agent.toolsHome) === home);
|
|
228
235
|
if (candidates.length !== 1) throw Error('Host folder binding does not belong to this registry');
|
|
229
|
-
const agent = candidates[0]
|
|
236
|
+
const agent = candidates[0];
|
|
230
237
|
if (typeof agent.workspace !== 'string' || typeof agent.controlDir !== 'string' ||
|
|
231
238
|
realpathSync(agent.workspace) !== config.workspace ||
|
|
232
239
|
childOf(hostConfig, config.workspace) || childOf(hostConfig, realpathSync(agent.controlDir)))
|
|
233
240
|
throw Error('Invalid host folder binding deployment');
|
|
234
|
-
// The isolated broker receives only the exact workspace, control directory,
|
|
235
|
-
// tools home, and host config mounts. It must not require the host
|
|
236
|
-
// deployment's convenience mind/control symlinks (or a deployment mount).
|
|
237
|
-
if (process.env.EZ_DOCKER_COMPOSE !== 'standalone' &&
|
|
238
|
-
(realpathSync(path.join(deployment, 'mind')) !== config.workspace ||
|
|
239
|
-
realpathSync(path.join(deployment, 'control')) !== realpathSync(agent.controlDir)))
|
|
240
|
-
throw Error('Invalid host folder binding deployment');
|
|
241
241
|
const configured = agent.pluginFolderRoots;
|
|
242
242
|
if (configured !== undefined && (!configured || typeof configured !== 'object' || Array.isArray(configured)))
|
|
243
243
|
throw Error('Invalid host folder roots');
|
|
@@ -280,7 +280,7 @@ export async function hostNetworkBindings(config, record, home) {
|
|
|
280
280
|
if (!host || typeof host !== 'object' || !Array.isArray(host.agents)) throw Error('Invalid host network bindings');
|
|
281
281
|
const candidates = host.agents.filter(agent => agent && typeof agent === 'object' && typeof agent.toolsHome === 'string' && realpathSync(agent.toolsHome) === home);
|
|
282
282
|
if (candidates.length !== 1) throw Error('Host network binding does not belong to this registry');
|
|
283
|
-
const agent = candidates[0]
|
|
283
|
+
const agent = candidates[0];
|
|
284
284
|
if (typeof agent.workspace !== 'string' || typeof agent.controlDir !== 'string' ||
|
|
285
285
|
realpathSync(agent.workspace) !== config.workspace ||
|
|
286
286
|
childOf(hostConfig, config.workspace) || childOf(hostConfig, realpathSync(agent.controlDir)))
|
|
@@ -292,11 +292,6 @@ export async function hostNetworkBindings(config, record, home) {
|
|
|
292
292
|
if (route === undefined) return [];
|
|
293
293
|
const isolated = process.env.EZ_DOCKER_COMPOSE === 'standalone';
|
|
294
294
|
if (isolated && host.isolation !== 'isolated') throw Error('Isolated broker requires an isolated host binding');
|
|
295
|
-
// Like folder grants, network grants use the exact mounted identities. The
|
|
296
|
-
// broker does not receive the host deployment's convenience symlinks.
|
|
297
|
-
if (!isolated && (realpathSync(path.join(deployment, 'mind')) !== config.workspace ||
|
|
298
|
-
realpathSync(path.join(deployment, 'control')) !== realpathSync(agent.controlDir)))
|
|
299
|
-
throw Error('Invalid host network binding deployment');
|
|
300
295
|
keys(route, ['revisions', 'bindings']);
|
|
301
296
|
const trusted = await snapshot(record.source);
|
|
302
297
|
if (record.revision !== trusted.revision || record.source !== trusted.source ||
|
|
@@ -509,6 +504,7 @@ export async function init(home,workspace,catalogFile,hostConfig,standalone=fals
|
|
|
509
504
|
if(standalone && hostConfig) throw Error('Standalone setup cannot bind a relay host config');
|
|
510
505
|
if(typeof home!=='string'||typeof workspace!=='string'||!path.isAbsolute(home)||!path.isAbsolute(workspace)||/[\r\n\0$:,]/.test(home+workspace)) throw Error('Explicit absolute home/workspace required');
|
|
511
506
|
workspace=await fs.realpath(workspace);await privateDir(home);home=await fs.realpath(home);if(hostConfig)hostConfig=await fs.realpath(hostConfig);
|
|
507
|
+
await toolDiscoveryBlock(home,hostConfig);
|
|
512
508
|
if(await fs.lstat(path.join(home,'registry.json')).catch(()=>null)) throw Error('Registry already exists; refusing replacement');
|
|
513
509
|
catalogFile=path.resolve(catalogFile||fileURLToPath(new URL('../../default-plugins.json',import.meta.url)));
|
|
514
510
|
const sources=await json(catalogFile);
|
|
@@ -557,7 +553,13 @@ export async function install(home,config,name,source,revision) {
|
|
|
557
553
|
const base=path.join(home,'packages',name),target=path.join(base,revision.slice(7));await privateDir(base);
|
|
558
554
|
const stage=path.join(base,`.stage-${randomUUID()}`);await privateDir(stage);
|
|
559
555
|
try {
|
|
560
|
-
for(const [relative,file] of p.files) {
|
|
556
|
+
for(const [relative,file] of p.files) {
|
|
557
|
+
const dest=path.join(stage,relative);await fs.mkdir(path.dirname(dest),{recursive:true,mode:0o755});
|
|
558
|
+
// Package modes are part of the reviewed snapshot; umask/default ACLs
|
|
559
|
+
// must not make Docker COPY inputs unreadable to the runtime user.
|
|
560
|
+
for(let dir=path.dirname(dest);dir!==stage;dir=path.dirname(dir))await fs.chmod(dir,0o755);
|
|
561
|
+
await fs.writeFile(dest,file.data,{mode:file.mode,flag:'wx'});await fs.chmod(dest,file.mode);
|
|
562
|
+
}
|
|
561
563
|
// A previously interrupted install may leave a snapshot. Never silently replace it.
|
|
562
564
|
try { await fs.rename(stage,target); } catch(error) {
|
|
563
565
|
if(!['EEXIST','ENOTEMPTY'].includes(error.code)) throw error;
|
|
@@ -23,7 +23,7 @@ export async function recoverNativeLease(home) {
|
|
|
23
23
|
export async function workspaceLease(home,owner={kind:'plugin'}) {
|
|
24
24
|
const file=path.join(home,'workspace-writer.lock'),temp=file+'.'+randomUUID()+'.tmp';
|
|
25
25
|
await fs.writeFile(temp,JSON.stringify({...owner,pid:process.pid}),{flag:'wx',mode:0o600});
|
|
26
|
-
try {await fs.link(temp,file);}catch(error){if(error.code!=='EEXIST')throw error;
|
|
26
|
+
try {await fs.link(temp,file);}catch(error){if(error.code!=='EEXIST')throw error;let prior;try {prior=await leaseOwner(file);}catch(error){if(error.code==='ENOENT')return undefined;throw error;}if(!prior.alive)throw Error('Stale workspace-writer.lock: inspect stopped owner and command containers, then restart for recovery');return undefined;}
|
|
27
27
|
finally {await fs.rm(temp);}
|
|
28
28
|
return async()=>{await fs.rm(file);};
|
|
29
29
|
}
|
package/src/runs.ts
CHANGED
|
@@ -235,7 +235,7 @@ export class RunStore {
|
|
|
235
235
|
|
|
236
236
|
async nextQueued(background?: boolean): Promise<RunRecord | undefined> {
|
|
237
237
|
const queued = [...runsFor(this.controlDir).values()].sort((a,b)=>a.createdAt.localeCompare(b.createdAt)).filter((run) => run.status === 'queued' && (background === undefined || Boolean(run.scheduled) === background))
|
|
238
|
-
return queued.find(run => !run.taskId) ?? queued[0]
|
|
238
|
+
return queued.find(run => !run.taskId && !run.scheduled) ?? queued.find(run => !run.taskId) ?? queued[0]
|
|
239
239
|
}
|
|
240
240
|
|
|
241
241
|
async deliveryStatus(): Promise<{ failed: number; unknown: number }> {
|
package/src/updates/binding.mjs
CHANGED
|
@@ -1,14 +1,21 @@
|
|
|
1
1
|
import * as fs from 'node:fs/promises';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
-
import { fileURLToPath } from 'node:url';
|
|
4
|
-
import { atomic
|
|
3
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
4
|
+
import { atomic } from '../plugins/manager.mjs';
|
|
5
5
|
import { read } from './control.mjs';
|
|
6
6
|
|
|
7
|
+
async function packageToolDiscovery(packageRoot,home,workspace) {
|
|
8
|
+
const entry=path.join(packageRoot,'src/plugins/manager.mjs');
|
|
9
|
+
const manager=await import(pathToFileURL(entry).href);
|
|
10
|
+
if(typeof manager.bindToolDiscovery!=='function')throw Error('Selected package has no tool discovery binding');
|
|
11
|
+
await manager.bindToolDiscovery(home,workspace);
|
|
12
|
+
}
|
|
13
|
+
|
|
7
14
|
export async function bindUpdates(home,hostConfig,packageRoot=fileURLToPath(new URL('../../',import.meta.url))) {
|
|
8
15
|
home=await fs.realpath(home);hostConfig=await fs.realpath(hostConfig);
|
|
9
|
-
const config=await read(path.join(home,'config.json')),host=await read(hostConfig),deploymentDir=path.dirname(hostConfig);
|
|
10
|
-
if(path.basename(hostConfig)!=='host-executor.json'||host.agents.length!==1||
|
|
11
|
-
if(await fs.realpath(
|
|
16
|
+
const config=await read(path.join(home,'config.json')),host=await read(hostConfig),deploymentDir=path.dirname(hostConfig),agent=host.agents[0];
|
|
17
|
+
if(path.basename(hostConfig)!=='host-executor.json'||host.agents.length!==1||agent.toolsHome!==home||agent.workspace!==config.workspace)throw Error('Updates require this agent registry and its own single-deployment host config');
|
|
18
|
+
if(await fs.realpath(agent.workspace)!==agent.workspace||await fs.realpath(agent.controlDir)!==agent.controlDir)throw Error('Noncanonical deployment state paths');
|
|
12
19
|
packageRoot=await fs.realpath(packageRoot);
|
|
13
20
|
await fs.mkdir(path.join(home,'updates'),{recursive:true,mode:0o700});
|
|
14
21
|
await atomic(path.join(home,'config.json'),{...config,hostConfig,packageRoot:packageRoot.replace(/\/$/,''),deploymentDir});
|
|
@@ -19,8 +26,8 @@ export async function bindUpdates(home,hostConfig,packageRoot=fileURLToPath(new
|
|
|
19
26
|
await fs.writeFile(path.join(bin,'ez'),`#!${process.execPath}\nimport fs from 'node:fs';import {pathToFileURL} from 'node:url';const c=JSON.parse(fs.readFileSync(${configFile}));const m=await import(pathToFileURL(c.packageRoot+'/src/plugins/manager.mjs'));m.main(['--home',${JSON.stringify(home)},...process.argv.slice(2)]).catch(e=>{console.error(e.message);process.exitCode=1});\n`,{mode:0o700});
|
|
20
27
|
for(const [name,entry] of Object.entries(pkg.bin)) {
|
|
21
28
|
const dest=path.join(bin,name);await fs.rm(dest,{force:true});
|
|
22
|
-
await fs.writeFile(dest,`#!${process.execPath}\nimport fs from 'node:fs';import {spawn} from 'node:child_process';const c=JSON.parse(fs.readFileSync(${configFile}));const child=spawn(c.packageRoot+'/'+${JSON.stringify(entry)},process.argv.slice(2),{stdio:'inherit',env:{...process.env,EZ_DEPLOYMENT_DIR:c.deploymentDir,EZ_CONTROL_DIR
|
|
29
|
+
await fs.writeFile(dest,`#!${process.execPath}\nimport fs from 'node:fs';import {spawn} from 'node:child_process';const c=JSON.parse(fs.readFileSync(${configFile}));const child=spawn(c.packageRoot+'/'+${JSON.stringify(entry)},process.argv.slice(2),{stdio:'inherit',env:{...process.env,EZ_DEPLOYMENT_DIR:c.deploymentDir,EZ_CONTROL_DIR:${JSON.stringify(agent.controlDir)}}});for(const s of ['SIGTERM','SIGINT'])process.on(s,()=>child.kill(s));child.on('error',e=>{console.error(e.message);process.exitCode=1});child.on('close',c=>process.exitCode=c??1);\n`,{mode:0o700});
|
|
23
30
|
}
|
|
24
|
-
await
|
|
31
|
+
await packageToolDiscovery(packageRoot,home,config.workspace);
|
|
25
32
|
return {ok:true,home,deploymentDir,packageRoot,policy:'Automatic compatible beta-channel updates by default; saved stable-only or manual policies take precedence. Local candidates require an explicit owner request.'};
|
|
26
33
|
}
|
package/src/updates/control.mjs
CHANGED
|
@@ -10,8 +10,9 @@ export const missing = error => {if(error.code!=='ENOENT')throw error;return nul
|
|
|
10
10
|
export const targetId = value => {if(value!=='main'&&!/^[a-z][a-z0-9-]{0,39}$/.test(value))throw Error('Invalid update target');return value;};
|
|
11
11
|
const jobId=/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/;
|
|
12
12
|
const compatibilityBuild=/^(.+)\.compat\.\d+$/;
|
|
13
|
+
const qaBuild=/^(\d+\.\d+\.\d+-beta\.\d+)\.(?:rc|qa)\.[1-9]\d*$/;
|
|
13
14
|
const upgradeable=(candidate,installed)=>{
|
|
14
|
-
const match=compatibilityBuild.exec(installed);
|
|
15
|
+
const match=compatibilityBuild.exec(installed)||qaBuild.exec(installed);
|
|
15
16
|
return newer(candidate,installed)||Boolean(match&&match[1]===candidate);
|
|
16
17
|
};
|
|
17
18
|
export const updateHome = home => path.join(home,'updates');
|
package/src/updates/runtime.mjs
CHANGED
|
@@ -61,6 +61,10 @@ export async function relayServices(config) {
|
|
|
61
61
|
const env = await fs.readFile(path.join(config.deploymentDir,'docker.env'),'utf8').catch(() => '');
|
|
62
62
|
return parseEnv(env).EZ_EXECUTOR_TRANSPORT === 'local' ? ['relay','plugin-broker'] : ['relay'];
|
|
63
63
|
}
|
|
64
|
+
async function refreshBroker(config, run) {
|
|
65
|
+
if ((await relayServices(config)).includes('plugin-broker'))
|
|
66
|
+
await run('docker', [...relayArgs(config), 'restart', 'plugin-broker']);
|
|
67
|
+
}
|
|
64
68
|
const healthCodes = new Set(['RELAY_UNREADABLE','RELAY_NOT_POLLING','RELAY_STALE','HOST_UNREADABLE','HOST_STALE','PLUGIN_BROKER_UNREADABLE']);
|
|
65
69
|
async function healthEvidence(config,run) {
|
|
66
70
|
const id=(await run('docker',[...relayArgs(config),'ps','-q','relay'])).trim();
|
|
@@ -90,7 +94,7 @@ async function backupVolume(run,record,name,directory) {
|
|
|
90
94
|
// Interruptible work is journaled before any running installation is touched.
|
|
91
95
|
// On restart, recovery restores code/config only, never provider journals.
|
|
92
96
|
export async function perform(home,job,hooks) {
|
|
93
|
-
const run=hooks.execute||execute,{config}=await state(home),dir=jobPath(home,job.id),file=path.join(dir,'job.json');
|
|
97
|
+
const run=hooks.execute||execute,{config,agent}=await state(home),dir=jobPath(home,job.id),file=path.join(dir,'job.json');
|
|
94
98
|
const save=()=>atomic(file,job);
|
|
95
99
|
if(job.status==='applying'||job.recoveryRequested)return recover(home,job,hooks);
|
|
96
100
|
if(job.status!=='queued')throw Error('Job is not queued');
|
|
@@ -129,7 +133,8 @@ export async function perform(home,job,hooks) {
|
|
|
129
133
|
job.rollback={env:envValue(oldEnv,'EZ_RELAY_IMAGE',oldImage),packageRoot:config.packageRoot};await save();
|
|
130
134
|
await run('docker',[...relayArgs(config),'stop','relay']);await hooks.stopHost();
|
|
131
135
|
const backup=path.join(dir,'backup');await fs.mkdir(backup,{mode:0o700});
|
|
132
|
-
|
|
136
|
+
await backupStateDirectory(config.workspace,path.join(backup,'workspace'));
|
|
137
|
+
await backupStateDirectory(agent.controlDir,path.join(backup,'control'));
|
|
133
138
|
for(const name of ['docker.env','host-executor.json','agent.json','purpose.md','relay.env'])await fs.copyFile(path.join(config.deploymentDir,name),path.join(backup,name));
|
|
134
139
|
let env=oldEnv.split(job.previousRoot+path.sep).join(root+path.sep);env=envValue(env,'EZ_RELAY_IMAGE',image);
|
|
135
140
|
await textAtomic(envFile,env);
|
|
@@ -144,8 +149,8 @@ export async function perform(home,job,hooks) {
|
|
|
144
149
|
await checkFolders(config,candidate,home);
|
|
145
150
|
const stage={...candidate,compose:path.join(dir,'compose.json')};await atomic(stage.compose,await compose(config,stage,secrets,home));
|
|
146
151
|
for(const [service,spec] of Object.entries(stage.deployment.services))await run('docker',[...pluginArgs(stage),spec.image?'pull':'build',service]);
|
|
147
|
-
const running=Boolean((await run('docker',[...pluginArgs(old),'ps','-q'])).trim());
|
|
148
|
-
job.rollback={record:old,registry:r,compose:await read(old.compose),running};await save();
|
|
152
|
+
const running=Boolean((await run('docker',[...pluginArgs(old),'ps','--status','running','-q'])).trim());
|
|
153
|
+
job.rollback={record:old,registry:structuredClone(r),compose:await read(old.compose),running};await save();
|
|
149
154
|
await run('docker',[...pluginArgs(old),'stop']);
|
|
150
155
|
const backup=path.join(dir,'backup');await fs.mkdir(backup,{mode:0o700});
|
|
151
156
|
const volumes=new Set(Object.values(old.deployment.services).flatMap(s=>Object.keys(s.volumes||{})));
|
|
@@ -155,6 +160,7 @@ export async function perform(home,job,hooks) {
|
|
|
155
160
|
r.plugins[job.target]=candidate;
|
|
156
161
|
for(const alias of Object.keys(candidate.manifest.commands))r.commands[alias]=job.target;
|
|
157
162
|
await atomic(path.join(home,'registry.json'),r);
|
|
163
|
+
await refreshBroker(config,run);
|
|
158
164
|
job.runtimeVerified=running;
|
|
159
165
|
}
|
|
160
166
|
job.status='completed';job.endedAt=new Date().toISOString();await save();
|
|
@@ -191,6 +197,7 @@ export async function recover(home,job,hooks) {
|
|
|
191
197
|
const b=job.rollback;
|
|
192
198
|
await run('docker',[...pluginArgs(b.record),'stop']);
|
|
193
199
|
await atomic(b.record.compose,b.compose);await atomic(path.join(home,'registry.json'),b.registry);
|
|
200
|
+
await refreshBroker(config,run);
|
|
194
201
|
if(b.running)await run('docker',[...pluginArgs(b.record),'up','-d','--wait','--wait-timeout','90','--no-build']);
|
|
195
202
|
}
|
|
196
203
|
job.status='rolled-back';
|
|
@@ -20,6 +20,16 @@ export function providerEnvironment(host, environment=process.env) {
|
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
const sleep=ms=>new Promise(resolve=>setTimeout(resolve,ms));
|
|
23
|
+
export async function waitForHostHeartbeat(controlDir,child,failure,{attempts=300,intervalMs=100,readHeartbeat=read,wait=sleep}={}) {
|
|
24
|
+
for(let n=0;n<attempts;n++) {
|
|
25
|
+
const error=failure();
|
|
26
|
+
if(error||child.exitCode!==null)throw error||Error('Host transport failed to start');
|
|
27
|
+
const beat=await readHeartbeat(path.join(controlDir,'host-executor/heartbeat.json')).catch(missing);
|
|
28
|
+
if(beat?.pid===child.pid&&Date.now()-beat.at<10000)return;
|
|
29
|
+
await wait(intervalMs);
|
|
30
|
+
}
|
|
31
|
+
throw Error('Host transport heartbeat timeout');
|
|
32
|
+
}
|
|
23
33
|
export async function idle(control) {
|
|
24
34
|
const host=await fs.readdir(path.join(control,'host-executor')).catch(e=>{if(e.code==='ENOENT')return [];throw e;});
|
|
25
35
|
if(host.some(f=>f.endsWith('.running.json')||f.endsWith('.request.json')))return false;
|
|
@@ -67,13 +77,7 @@ export async function supervise(deployment,signal,{discover=check}={}) {
|
|
|
67
77
|
child=spawn(process.execPath,['--import',path.join(root,'node_modules/tsx/dist/loader.mjs'),path.join(root,'src/host-executor.ts'),path.join(deployment,'host-executor.json')],
|
|
68
78
|
{env:{...environment(),...providerEnvironment(host),EZ_HOST_SUPERVISOR_PID:String(process.pid)},stdio:['ignore','inherit','inherit']});
|
|
69
79
|
let error;child.once('error',e=>{error=e;});
|
|
70
|
-
|
|
71
|
-
if(error||child.exitCode!==null)throw error||Error('Host transport failed to start');
|
|
72
|
-
const beat=await read(path.join(agent.controlDir,'host-executor/heartbeat.json')).catch(missing);
|
|
73
|
-
if(beat?.pid===child.pid&&Date.now()-beat.at<10000)return;
|
|
74
|
-
await sleep(100);
|
|
75
|
-
}
|
|
76
|
-
throw Error('Host transport heartbeat timeout');
|
|
80
|
+
await waitForHostHeartbeat(agent.controlDir,child,()=>error);
|
|
77
81
|
};
|
|
78
82
|
const pause=path.join(agent.controlDir,'upgrade-pause.json');
|
|
79
83
|
const beat=setInterval(()=>{void atomic(path.join(directory,'supervisor.json'),{pid:process.pid,at:Date.now()}).catch(()=>{});},1000);
|
|
@@ -8,6 +8,7 @@ import { ApplicationChannel, applicationScope } from '../src/application-channel
|
|
|
8
8
|
import { ControlStore } from '../src/control-state.js'
|
|
9
9
|
import { createAiMenu } from '../src/menu.js'
|
|
10
10
|
import { RunStore } from '../src/runs.js'
|
|
11
|
+
import { softwareStatus } from '../src/software-status.js'
|
|
11
12
|
|
|
12
13
|
test('shared controls validate detected host presets against the host catalog', async t => {
|
|
13
14
|
const root = await mkdtemp(join(tmpdir(), 'ez-host-controls-'))
|
|
@@ -67,6 +68,8 @@ test('private scope reset and model controls preserve running sessions and share
|
|
|
67
68
|
...(body===undefined?{}:{body:JSON.stringify(body)}),
|
|
68
69
|
})
|
|
69
70
|
const before = await (await call()).json() as any
|
|
71
|
+
assert.deepEqual(before.software,await softwareStatus(root))
|
|
72
|
+
assert.match(before.software[0],/^Relay: running · /)
|
|
70
73
|
assert.equal(before.activeSessionId,run.execution!.sessionId)
|
|
71
74
|
assert.equal(JSON.stringify(before).includes('native-private'),false)
|
|
72
75
|
assert.equal((await channel.scopeControls(second.bindingId,'exercise')).activeSessionId,null)
|
|
@@ -3,7 +3,9 @@ import assert from 'node:assert/strict'
|
|
|
3
3
|
import { spawn } from 'node:child_process'
|
|
4
4
|
import { runCodexSession } from '../src/codex-session.js'
|
|
5
5
|
|
|
6
|
-
for(const mode of ['external','goal','long-goal','plain','tool-goal','blocked','disconnect','approval','late-limit','early-limit','early-clear','missing-goal'])test(`native Codex session: ${mode}`,async()=>{
|
|
6
|
+
for(const mode of ['external','goal','long-goal','plain','tool-goal','blocked','disconnect','approval','late-limit','early-limit','early-clear','missing-goal','failed','failed-redacted','failed-no-error','interrupted'])test(`native Codex session: ${mode}`,async(t)=>{
|
|
7
|
+
const diagnostics:string[]=[]
|
|
8
|
+
t.mock.method(console,'error',(...args:unknown[])=>diagnostics.push(args.join(' ')))
|
|
7
9
|
const prompt=mode==='long-goal'?'/goal Complete the research.\n'+'Full workflow context.\n'.repeat(400)+'FINAL_COMPLETION_CRITERION':'test'
|
|
8
10
|
const requests:string[]=[],output:string[]=[]
|
|
9
11
|
const program=`
|
|
@@ -24,6 +26,10 @@ if(q.method==='turn/start'){
|
|
|
24
26
|
send({method:'turn/completed',params:{threadId:'unrelated',turn:{id:'unrelated',status:'completed'}}});start('one');
|
|
25
27
|
if(${JSON.stringify(mode)}==='disconnect')return process.exit(0);
|
|
26
28
|
if(${JSON.stringify(mode)}==='approval')return send({id:999,method:'item/commandExecution/requestApproval',params:{threadId:'native-test'}});
|
|
29
|
+
if(['failed','failed-redacted','failed-no-error','interrupted'].includes(${JSON.stringify(mode)})){
|
|
30
|
+
const message=${JSON.stringify(mode)}==='failed-redacted'?'Native provider request failed: Bearer synthetic-private-token https://user:password@example.test/response?api_key=synthetic-private-key':'Native provider request failed';
|
|
31
|
+
return event('turn/completed',{turn:{id:'one',status:${JSON.stringify(mode)}==='interrupted'?'interrupted':'failed',error:${JSON.stringify(mode)}==='failed-no-error'?null:{message}}});
|
|
32
|
+
}
|
|
27
33
|
if(${JSON.stringify(mode)}==='missing-goal')event('thread/goal/updated',{goal:{status:'active'}});
|
|
28
34
|
end('one');return;
|
|
29
35
|
}
|
|
@@ -57,9 +63,16 @@ if(q.method==='thread/goal/get'){
|
|
|
57
63
|
assert.equal(threadConfig.model_provider,'openrouter')
|
|
58
64
|
assert.deepEqual(threadConfig['model_providers.openrouter'],{name:'OpenRouter',base_url:'https://openrouter.ai/api/v1',env_key:'OPENROUTER_API_KEY',wire_api:'responses',supports_websockets:false})
|
|
59
65
|
}
|
|
60
|
-
assert.equal(result,['external','plain','goal','long-goal','tool-goal'].includes(mode)?0:1)
|
|
66
|
+
assert.equal(result,mode==='interrupted'?130:['external','plain','goal','long-goal','tool-goal'].includes(mode)?0:1)
|
|
61
67
|
assert.equal(requests.filter(x=>x==='turn/start').length,1,'transport must not send goal continuation prompts')
|
|
62
68
|
assert.equal(requests.filter(x=>x==='thread/goal/set').length,0)
|
|
63
69
|
if(mode==='goal')assert.equal(requests.filter(x=>x==='thread/goal/get').length,2,'must wait for the second turn to complete')
|
|
70
|
+
if(mode==='failed' || mode==='failed-redacted')assert.ok(diagnostics.some(line=>line.includes('Native provider request failed')),'terminal native error must reach existing stderr diagnostics')
|
|
71
|
+
if(mode==='failed-redacted'){
|
|
72
|
+
assert.ok(diagnostics.some(line=>line.includes('https://example.test/response')))
|
|
73
|
+
assert.ok(diagnostics.every(line=>!line.includes('synthetic-private') && !line.includes('user:password')))
|
|
74
|
+
}
|
|
75
|
+
if(mode==='failed-no-error')assert.ok(diagnostics.some(line=>line.includes('Native turn failed without error details')))
|
|
76
|
+
if(mode==='interrupted')assert.equal(diagnostics.length,0,'owner interruption is not a provider failure')
|
|
64
77
|
assert.equal(JSON.parse(output[0]).thread_id,'native-test')
|
|
65
78
|
})
|
|
@@ -167,7 +167,7 @@ test('one installed CLI executes two agent bindings with separate minds and sani
|
|
|
167
167
|
let server:Promise<void>|undefined
|
|
168
168
|
try {
|
|
169
169
|
const binary=path.join(root,'cli')
|
|
170
|
-
await writeFile(binary,`#!${process.execPath}\nif(process.env.EZ_RUN_ID
|
|
170
|
+
await writeFile(binary,`#!${process.execPath}\nif(['r_hold','tg_43'].includes(process.env.EZ_RUN_ID))setInterval(()=>{},1000);console.log(JSON.stringify({cwd:process.cwd(),home:process.env.HOME,token:process.env.TELEGRAM_BOT_TOKEN,control:process.env.EZ_CONTROL_DIR,run:process.env.EZ_RUN_ID,repair:process.env.EZ_REPAIR_ENABLED,args:process.argv.slice(2)}));\n`,{mode:0o700})
|
|
171
171
|
await writeFile(path.join(root,'claude'),await readFile(binary),{mode:0o700})
|
|
172
172
|
await writeFile(path.join(root,'codex'),await readFile(binary),{mode:0o700})
|
|
173
173
|
await writeFile(path.join(root,'opencode'),'#!/bin/sh\nexit 0\n',{mode:0o700})
|
|
@@ -284,18 +284,32 @@ test('one installed CLI executes two agent bindings with separate minds and sani
|
|
|
284
284
|
assert.match(output, /"stream":"exit","code":0/)
|
|
285
285
|
return output.trim().split('\n').map(line=>JSON.parse(line))
|
|
286
286
|
}
|
|
287
|
-
//
|
|
288
|
-
//
|
|
287
|
+
// Chat completes while this agent's scheduled turn stays active. A second
|
|
288
|
+
// scheduled turn still waits, and the shared lifecycle lease stays held.
|
|
289
289
|
await completed(otherDirectory,'r_other_shared')
|
|
290
|
+
const chatEvents=await completed(directory,'tg_42')
|
|
290
291
|
assert.ok(await readFile(path.join(directory,'r_schedule_queued.request.json')))
|
|
291
|
-
assert.
|
|
292
|
+
assert.equal(await workspaceLease(agents[0].toolsHome),undefined)
|
|
293
|
+
await ownerRun(agents[0].controlDir,'tg_43')
|
|
294
|
+
await writeFile(path.join(directory,'tg_43.request.json'),JSON.stringify({texts:['Hold chat'],options:{cli:'grok'}}))
|
|
295
|
+
for(let n=0;n<100;n++){try{await readFile(path.join(directory,'tg_43.process.json'));break}catch{await new Promise(r=>setTimeout(r,20))}}
|
|
296
|
+
await readFile(path.join(directory,'tg_43.running.json'))
|
|
292
297
|
assert.doesNotMatch(await readFile(path.join(directory,'r_hold.events'),'utf8'),/"stream":"exit"/)
|
|
293
298
|
await readFile(path.join(directory,'r_hold.running.json'))
|
|
294
299
|
await writeFile(path.join(directory,'r_hold.cancel'),'')
|
|
295
300
|
let heldOutput=''
|
|
296
301
|
for(let n=0;n<200;n++){heldOutput=await readFile(path.join(directory,'r_hold.events'),'utf8');if(heldOutput.includes('"stream":"exit"'))break;await new Promise(r=>setTimeout(r,20))}
|
|
297
302
|
assert.match(heldOutput, /"stream":"exit","code":1/)
|
|
298
|
-
const
|
|
303
|
+
const scheduleEvents=await completed(directory,'r_schedule_queued')
|
|
304
|
+
// When the scheduled lane finishes first, chat still owns the lease.
|
|
305
|
+
assert.equal(await workspaceLease(agents[0].toolsHome),undefined)
|
|
306
|
+
await writeFile(path.join(directory,'tg_43.cancel'),'')
|
|
307
|
+
for(let n=0;n<100;n++){try{await readFile(path.join(directory,'tg_43.running.json'))}catch{break}await new Promise(r=>setTimeout(r,20))}
|
|
308
|
+
// The running marker is removed before the lifecycle lease is released.
|
|
309
|
+
let unlock
|
|
310
|
+
for(let n=0;n<100;n++){unlock=await workspaceLease(agents[0].toolsHome);if(unlock)break;await new Promise(r=>setTimeout(r,20))}
|
|
311
|
+
assert.ok(unlock)
|
|
312
|
+
await unlock()
|
|
299
313
|
const scheduled=JSON.parse(scheduleEvents.filter(e=>e.stream==='stdout').map(e=>e.text).join(''))
|
|
300
314
|
const chat=JSON.parse(chatEvents.filter(e=>e.stream==='stdout').map(e=>e.text).join(''))
|
|
301
315
|
assert.equal(scheduled.cwd,await realpath(agents[0].workspace))
|
package/test/local-qa.test.mjs
CHANGED
|
@@ -20,7 +20,7 @@ test('local QA staging preserves source and rejects label replacement and dirty
|
|
|
20
20
|
git(['init']);git(['add','package.json','feature.txt','run.sh']);
|
|
21
21
|
git(['-c','user.name=QA','-c','user.email=qa@example.invalid','-c','core.hooksPath=/dev/null','-c','commit.gpgsign=false','commit','-m','fixture']);
|
|
22
22
|
const script=fileURLToPath(new URL('../scripts/stage-qa.mjs',import.meta.url));
|
|
23
|
-
const args=[script,'--source',source,'--catalog',catalog,'--label','beta-12','--version','0.1.0-beta.12.
|
|
23
|
+
const args=[script,'--source',source,'--catalog',catalog,'--label','beta-12','--version','0.1.0-beta.12.rc.1','--flow',flow];
|
|
24
24
|
const stage=()=>{const prior=process.umask(0o027);try{return execFileSync(process.execPath,args,{stdio:'pipe'}).toString();}finally{process.umask(prior);}};
|
|
25
25
|
try {
|
|
26
26
|
const result=JSON.parse(stage()),data=await fs.readFile(path.join(result.directory,result.file));
|
|
@@ -28,7 +28,7 @@ test('local QA staging preserves source and rejects label replacement and dirty
|
|
|
28
28
|
assert.equal(git(['status','--porcelain']),'');
|
|
29
29
|
await extract(data,path.join(root,'unpacked'));
|
|
30
30
|
const built=JSON.parse(await fs.readFile(path.join(root,'unpacked/package.json'),'utf8'));
|
|
31
|
-
assert.equal(built.version,'0.1.0-beta.12.
|
|
31
|
+
assert.equal(built.version,'0.1.0-beta.12.rc.1');assert.equal(built.ezQa.commit,result.commit);
|
|
32
32
|
assert.equal(await fs.readFile(path.join(root,'unpacked/feature.txt'),'utf8'),'feature source');
|
|
33
33
|
assert.equal((await fs.stat(path.join(root,'unpacked'))).mode&0o777,0o755);
|
|
34
34
|
assert.equal((await fs.stat(path.join(root,'unpacked/feature.txt'))).mode&0o777,0o644);
|
|
@@ -66,6 +66,17 @@ test('shared workspace lease excludes concurrent writers and refuses queued nati
|
|
|
66
66
|
await fs.mkdir(path.join(dir,'host-executor'));await fs.writeFile(path.join(dir,'host.json'),JSON.stringify({agents:[{toolsHome:dir,workspace:dir,controlDir:dir}]}));await fs.writeFile(path.join(dir,'config.json'),JSON.stringify({hostConfig:path.join(dir,'host.json'),workspace:dir}));await fs.writeFile(path.join(dir,'host-executor','r.request.json'),'{}');await assert.rejects(invokeLease(dir),/pending/);await fs.rm(path.join(dir,'host-executor','r.request.json'));const unlock=await invokeLease(dir);await unlock();
|
|
67
67
|
}finally{await fs.rm(dir,{recursive:true,force:true});}
|
|
68
68
|
});
|
|
69
|
+
test('workspace lease contention tolerates a concurrent owner release',async()=>{
|
|
70
|
+
const dir=await fs.mkdtemp(path.join(os.tmpdir(),'ez-lease-release-'));
|
|
71
|
+
try {
|
|
72
|
+
for(let n=0;n<100;n++) {
|
|
73
|
+
const release=await workspaceLease(dir);assert.ok(release);
|
|
74
|
+
const [,contender]=await Promise.all([release(),workspaceLease(dir)]);
|
|
75
|
+
if(contender)await contender();
|
|
76
|
+
const next=await workspaceLease(dir);assert.ok(next);await next();
|
|
77
|
+
}
|
|
78
|
+
}finally{await fs.rm(dir,{recursive:true,force:true});}
|
|
79
|
+
});
|
|
69
80
|
test('command output bound and timeout remove exact containers without leaking daemon secrets',async()=>{
|
|
70
81
|
const dir=await fs.mkdtemp(path.join(os.tmpdir(),'ez-bound-'));
|
|
71
82
|
try {
|
|
@@ -46,6 +46,21 @@ async function fixture(t) {
|
|
|
46
46
|
const call=(...args)=>exec(process.execPath,[bin,'--home',home,...args],{env});
|
|
47
47
|
return {root,source,home,deploymentDir,workspace,control,hostConfig,fake,log,manifest,deployment,env,call};
|
|
48
48
|
}
|
|
49
|
+
test('install preserves reviewed package modes under a restrictive umask',async t=>{
|
|
50
|
+
const f=await fixture(t);
|
|
51
|
+
await fs.mkdir(path.join(f.source,'nested'));await fs.writeFile(path.join(f.source,'nested','note.md'),'Synthetic');
|
|
52
|
+
const pkg=JSON.parse(await fs.readFile(path.join(f.source,'package.json'),'utf8'));pkg.files.push('nested');
|
|
53
|
+
await fs.writeFile(path.join(f.source,'package.json'),JSON.stringify(pkg));await fs.chmod(path.join(f.source,'client.mjs'),0o755);
|
|
54
|
+
const p=await snapshot(f.source);await init(f.home,f.workspace);
|
|
55
|
+
const argv=['--home',f.home,'plugins','install','sample','--source',f.source,'--revision',p.revision];
|
|
56
|
+
const script=`process.umask(0o077);const m=await import(${JSON.stringify(new URL('../src/plugins/manager.mjs',import.meta.url).href)});await m.main(${JSON.stringify(argv)});`;
|
|
57
|
+
await exec(process.execPath,['--input-type=module','-e',script],{env:f.env});
|
|
58
|
+
const record=JSON.parse(await fs.readFile(path.join(f.home,'registry.json'),'utf8')).plugins.sample;
|
|
59
|
+
for(const [relative,file]of p.files)assert.equal((await fs.stat(path.join(record.source,relative))).mode&0o777,file.mode,relative);
|
|
60
|
+
assert.equal((await fs.stat(path.join(record.source,'nested'))).mode&0o777,0o755);
|
|
61
|
+
assert.equal((await fs.stat(record.source)).mode&0o777,0o700);
|
|
62
|
+
assert.equal((await snapshot(record.source)).revision,p.revision);
|
|
63
|
+
});
|
|
49
64
|
test('persistent connection reuses one container and releases registry lock between frames',async t=>{
|
|
50
65
|
const f=await fixture(t),p=await snapshot(f.source);await init(f.home,f.workspace);await f.call('plugins','install','sample','--source',f.source,'--revision',p.revision);
|
|
51
66
|
await fs.writeFile(path.join(f.fake,'docker'),`#!${process.execPath}\nif(process.argv[2]==='container')process.exit(0);require('readline').createInterface({input:process.stdin}).on('line',line=>{const f=JSON.parse(line);console.log(JSON.stringify(f.coreResponse?{reply:f.coreResponse}:{coreRequest:{id:f.id,method:'tools.list',params:{}}}));});`,{mode:0o700});
|
|
@@ -68,13 +83,15 @@ test('catalog paths resolve relative to the catalog and pin each new agent indep
|
|
|
68
83
|
assert.notEqual(first.sample.revision,next.catalog.sample.revision);
|
|
69
84
|
assert.deepEqual(JSON.parse((await f.call('plugins','available')).stdout),first);
|
|
70
85
|
});
|
|
71
|
-
test('native discovery binding preserves notes and
|
|
86
|
+
test('native discovery binding preserves notes and adds bounded nonblocking routing guidance',async t=>{
|
|
72
87
|
const f=await fixture(t),instructions=path.join(f.workspace,'AGENTS.md'),notes=path.join(f.workspace,'TOOLS.md');
|
|
73
88
|
await fs.writeFile(instructions,'Owner mandate\n');
|
|
74
89
|
await init(f.home,f.workspace);
|
|
75
90
|
await assert.rejects(fs.access(notes),{code:'ENOENT'});
|
|
76
91
|
const first=await fs.readFile(instructions,'utf8');assert(first.startsWith('Owner mandate\n'));assert(first.includes(f.home+'/bin/ez'));
|
|
77
|
-
assert.match(first,/tools list --details/);assert.match(first,/ --
|
|
92
|
+
assert.match(first,/tools list --details/);assert.match(first,/`--help`/);assert.match(first,/during a foreground owner turn, prefer `ezenciel-agents-schedule create --now \.\.\.`/);assert.match(first,/A `\[schedule \.\.\.\]` turn executes its assigned work directly and must not schedule it again/);assert.doesNotMatch(first,/ezenciel-agents-message|KISS/);
|
|
93
|
+
const managed=first.slice(first.indexOf('<!-- ez tools: begin -->'),first.indexOf('<!-- ez tools: end -->')+'<!-- ez tools: end -->'.length);
|
|
94
|
+
assert.ok(Buffer.byteLength(managed)<=600);
|
|
78
95
|
await bindToolDiscovery(f.home,f.workspace);assert.equal(await fs.readFile(instructions,'utf8'),first);
|
|
79
96
|
await fs.writeFile(notes,'Legacy policy');
|
|
80
97
|
await bindToolDiscovery(f.home,f.workspace);assert.equal(await fs.readFile(notes,'utf8'),'Legacy policy');
|
|
@@ -91,7 +108,20 @@ test('isolated discovery uses the relay-bound ez client without exposing toolsHo
|
|
|
91
108
|
const f=await fixture(t);await fs.mkdir(f.home);const workspace=await fs.realpath(f.workspace),controlDir=await fs.realpath(f.control),toolsHome=await fs.realpath(f.home),host={cli:'codex',isolation:'isolated',agents:[{name:'sample',workspace,controlDir,binDir:path.join(f.root,'bin'),toolsHome}]};
|
|
92
109
|
await fs.mkdir(path.join(f.root,'bin'));await fs.writeFile(f.hostConfig,JSON.stringify(host));
|
|
93
110
|
await init(f.home,f.workspace,undefined,f.hostConfig);
|
|
94
|
-
const instructions=await fs.readFile(path.join(f.workspace,'AGENTS.md'),'utf8');assert.match(instructions,/`
|
|
111
|
+
const instructions=await fs.readFile(path.join(f.workspace,'AGENTS.md'),'utf8');assert.match(instructions,/Use `ez`/);assert.match(instructions,/`tools list --details`/);assert.doesNotMatch(instructions,new RegExp(f.home.replace(/[.*+?^${}()|[\]\\]/g,'\\$&')));
|
|
112
|
+
});
|
|
113
|
+
test('discovery rejects an over-budget launcher before rewriting instructions',async t=>{
|
|
114
|
+
const f=await fixture(t),instructions=path.join(f.workspace,'AGENTS.md'),longHome=path.join(f.root,'h'.repeat(200),'n'.repeat(200));
|
|
115
|
+
await fs.mkdir(longHome,{recursive:true});await fs.writeFile(instructions,'Owner mandate\n');
|
|
116
|
+
await assert.rejects(bindToolDiscovery(longHome,f.workspace),/600-byte core budget/);
|
|
117
|
+
assert.equal(await fs.readFile(instructions,'utf8'),'Owner mandate\n');
|
|
118
|
+
});
|
|
119
|
+
test('standalone init rejects over-budget guidance before creating activation state',async t=>{
|
|
120
|
+
const f=await fixture(t),instructions=path.join(f.workspace,'AGENTS.md'),longHome=path.join(f.root,'i'.repeat(200),'j'.repeat(200)),catalog=path.join(f.root,'empty-catalog.json');
|
|
121
|
+
await fs.writeFile(catalog,'{}');await fs.writeFile(instructions,'Owner mandate\n');
|
|
122
|
+
await assert.rejects(initManager(longHome,f.workspace,catalog,undefined,true),/600-byte core budget/);
|
|
123
|
+
assert.deepEqual(await fs.readdir(longHome),[]);
|
|
124
|
+
assert.equal(await fs.readFile(instructions,'utf8'),'Owner mandate\n');
|
|
95
125
|
});
|
|
96
126
|
test('installed snippets follow install, upgrade and uninstall without files or Docker reads',async t=>{
|
|
97
127
|
const f=await fixture(t);await init(f.home,f.workspace);
|
|
@@ -290,6 +320,19 @@ test('isolated broker host binding validates exact mounts without deployment sym
|
|
|
290
320
|
if(previous===undefined)delete process.env.EZ_DOCKER_COMPOSE;else process.env.EZ_DOCKER_COMPOSE=previous;
|
|
291
321
|
}
|
|
292
322
|
});
|
|
323
|
+
test('host-capable binding validates an external real workspace without deployment symlinks',async t=>{
|
|
324
|
+
const f=await fixture(t),p=await snapshot(f.source),workspace=path.join(f.root,'external-workspace');
|
|
325
|
+
await fs.rm(f.workspace,{recursive:true});await fs.mkdir(workspace);await fs.mkdir(f.home);
|
|
326
|
+
const binding={service:'sample',network:'sample_default'};
|
|
327
|
+
await fs.writeFile(f.hostConfig,JSON.stringify({cli:'synthetic',agents:[{name:'sample',workspace,controlDir:f.control,binDir:path.join(f.root,'bin'),toolsHome:f.home,pluginNetworkBindings:{sample:{revisions:[p.revision],bindings:[binding]}}}]}));
|
|
328
|
+
const realWorkspace=await fs.realpath(workspace),config={workspace:realWorkspace,hostConfig:await fs.realpath(f.hostConfig),folders:{sample:[{service:'sample',source:realWorkspace,target:'/data/files'}]}};
|
|
329
|
+
const record={source:p.source,project:'ezp-synthetic',revision:p.revision,manifest:p.manifest,deployment:p.deployment};
|
|
330
|
+
const c=await compose(config,record,{},f.home);
|
|
331
|
+
assert.equal(c.services.sample.volumes.find(volume=>volume.target===realWorkspace).source,realWorkspace);
|
|
332
|
+
assert.equal(c.services.sample.volumes.find(volume=>volume.target==='/data/files').source,realWorkspace);
|
|
333
|
+
const network=Object.values(c.networks).find(candidate=>candidate.name==='sample_default');
|
|
334
|
+
assert.deepEqual(network,{external:true,name:'sample_default'});
|
|
335
|
+
});
|
|
293
336
|
test('isolated broker allows unbound plugins when another plugin has host binding',async t=>{
|
|
294
337
|
const f=await fixture(t),p=await snapshot(f.source),isolated=path.join(f.root,'isolated-other'),workspace=path.join(isolated,'workspace'),control=path.join(isolated,'control'),hostConfig=path.join(isolated,'host-executor.json'),previous=process.env.EZ_DOCKER_COMPOSE;
|
|
295
338
|
await fs.mkdir(f.home,{recursive:true});await fs.mkdir(workspace,{recursive:true});await fs.mkdir(control,{recursive:true});
|
|
@@ -11,7 +11,7 @@ import { RunStore } from '../src/runs.js'
|
|
|
11
11
|
|
|
12
12
|
const until=async(check:()=>Promise<boolean>)=>{for(let n=0;n<250;n++){if(await check())return;await new Promise(r=>setTimeout(r,20))}throw new Error('Host probe timed out')}
|
|
13
13
|
const stdout=(events:string) => events.trim().split('\n').filter(Boolean).map(line=>JSON.parse(line)).filter(event=>event.stream==='stdout').map(event=>event.text).join('')
|
|
14
|
-
test('host transport
|
|
14
|
+
test('host transport keeps chat responsive beside a schedule in the bound mind',async()=>{
|
|
15
15
|
const root=await mkdtemp(join(tmpdir(),'ez-scheduler-host-')),workspace=join(root,'agent'),controlDir=join(root,'control')
|
|
16
16
|
await mkdir(workspace);await mkdir(controlDir)
|
|
17
17
|
await writeFile(join(workspace,'AGENTS.md'),'bound-owner-instructions')
|
|
@@ -32,9 +32,7 @@ test('host transport serializes owner schedules and chat in the bound mind',asyn
|
|
|
32
32
|
await submit(id)
|
|
33
33
|
await until(async()=>Boolean(await exists(id+'.process.json')))
|
|
34
34
|
await submit('tg_1')
|
|
35
|
-
await
|
|
36
|
-
assert.ok(await exists('tg_1.request.json'))
|
|
37
|
-
assert.equal(await exists('tg_1.events'),'')
|
|
35
|
+
await until(async()=>(await exists('tg_1.events')).includes('"stream":"exit","code":0'))
|
|
38
36
|
assert.ok(!(await exists(id+'.events')).includes('"stream":"exit"'))
|
|
39
37
|
await until(async()=>(await exists(id+'.events')).includes('"stream":"stdout"'))
|
|
40
38
|
const scheduled=JSON.parse(stdout(await exists(id+'.events')))
|
|
@@ -47,7 +45,8 @@ test('host transport serializes owner schedules and chat in the bound mind',asyn
|
|
|
47
45
|
assert.ok(!(await exists(id+'.events')).includes('"stream":"exit"'))
|
|
48
46
|
await ownerRun(controlDir,'tg_2')
|
|
49
47
|
await submit('tg_2')
|
|
50
|
-
|
|
48
|
+
await until(async()=>(await exists('tg_2.events')).includes('"stream":"exit","code":0'))
|
|
49
|
+
assert.ok(!(await exists(id+'.events')).includes('"stream":"exit"'))
|
|
51
50
|
await writeFile(join(dir,id+'.cancel'),'')
|
|
52
51
|
await until(async()=>(await exists(id+'.events')).includes('"stream":"exit"'))
|
|
53
52
|
await until(async()=>(await exists('tg_1.events')).includes('"stream":"exit","code":0'))
|
|
@@ -44,7 +44,50 @@ test('a wrapper-shaped run id has no maintenance treatment and a stale notice is
|
|
|
44
44
|
}finally{await relay.stop();await rm(dir,{recursive:true,force:true})}
|
|
45
45
|
})
|
|
46
46
|
|
|
47
|
-
test('
|
|
47
|
+
test('Slack application replies beside a schedule and keeps both streams serial',async()=>{
|
|
48
|
+
const dir=await mkdtemp(join(tmpdir(),'ez-slack-scheduled-')),runs=new RunStore(dir),scheduler=new Scheduler(dir),control=new ControlStore(dir,1000)
|
|
49
|
+
const children=new Map<string,ReturnType<typeof spawn>>()
|
|
50
|
+
const relay=createRelay({workspace:dir,controlDir:dir,pairingTtlMs:1000,executorTimeoutMs:0,executorCli:'grok',telegramBotToken:'fixture'},async(_texts,options)=>{
|
|
51
|
+
const child=spawn(process.execPath,['-e','setInterval(()=>{},1000)'],{detached:process.platform!=='win32'})
|
|
52
|
+
children.set(options.runId,child);await once(child,'spawn')
|
|
53
|
+
if(!options.runId.startsWith('r_schedule_'))await runs.enqueueMessage(options.runId,'Slack reply')
|
|
54
|
+
return {child,cleanup:async()=>{},stdout:''}
|
|
55
|
+
})
|
|
56
|
+
try{
|
|
57
|
+
await control.requestPairing(101,101);await control.approveOwner(101)
|
|
58
|
+
const owner=(await control.status()).owner!,execution=await control.captureChoice(initialPreset('grok'))
|
|
59
|
+
const due=Date.now()+10000
|
|
60
|
+
for(const id of ['first','second'])await scheduler.save({id,name:id,text:'Long work',trigger:{at:new Date(due).toISOString()},enabled:true,owner,execution})
|
|
61
|
+
await scheduler.tick(owner,runs,due);await relay.drainSources()
|
|
62
|
+
const scheduled=(await runs.list()).filter(run=>run.scheduled)
|
|
63
|
+
assert.deepEqual(scheduled.map(run=>run.status),['running','queued'])
|
|
64
|
+
const binding=(await relay.applicationChannel.bindings.register('slack','s'.repeat(48),owner))!
|
|
65
|
+
const scope='slack:T_FIXTURE:C_FIXTURE'
|
|
66
|
+
const first=await relay.applicationChannel.submit(binding.bindingId,{requestId:'event-1',scope,text:'Top twenty'})
|
|
67
|
+
await until(async()=>children.has(first.id));await relay.drainOutbox()
|
|
68
|
+
assert.equal((await relay.applicationChannel.snapshot(binding.bindingId,first.id)).messages[0].text,'Slack reply')
|
|
69
|
+
assert.equal((await runs.get(scheduled[0].id))?.status,'running')
|
|
70
|
+
const second=await relay.applicationChannel.submit(binding.bindingId,{requestId:'event-2',scope,text:'Next question'})
|
|
71
|
+
await relay.drainSources()
|
|
72
|
+
assert.equal((await runs.get(second.id))?.status,'queued')
|
|
73
|
+
assert.equal(children.size,2)
|
|
74
|
+
// Revoking the channel stops only its foreground work; background authority
|
|
75
|
+
// and the original one-occurrence-per-schedule limit remain intact.
|
|
76
|
+
await relay.applicationChannel.bindings.register('slack',null,owner)
|
|
77
|
+
await relay.drainSources()
|
|
78
|
+
await until(async()=> (await runs.get(first.id))?.status==='cancelled')
|
|
79
|
+
await relay.drainSources()
|
|
80
|
+
assert.equal((await runs.get(second.id))?.status,'cancelled')
|
|
81
|
+
assert.equal((await runs.get(scheduled[0].id))?.status,'running')
|
|
82
|
+
assert.equal((await runs.get(scheduled[1].id))?.status,'queued')
|
|
83
|
+
}finally{
|
|
84
|
+
await relay.stop()
|
|
85
|
+
await until(async()=>!(await runs.list()).some(run=>run.status==='running'))
|
|
86
|
+
await rm(dir,{recursive:true,force:true})
|
|
87
|
+
}
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
test('owner chat replies while a scheduled CLI keeps running; targeted cancellation',async()=>{
|
|
48
91
|
const dir=await mkdtemp(join(tmpdir(),'ez-scheduler-relay-')), children:ReturnType<typeof spawn>[]=[]
|
|
49
92
|
const runs=new RunStore(dir),scheduler=new Scheduler(dir),control=new ControlStore(dir,1000)
|
|
50
93
|
const replies:string[]=[], workspaces:string[]=[]
|
|
@@ -67,19 +110,18 @@ test('owner chat queues behind a scheduled CLI in the same workspace; targeted c
|
|
|
67
110
|
const [background]=await runs.list()
|
|
68
111
|
assert.equal(background.status,'running')
|
|
69
112
|
const update:Update={update_id:123,message:{message_id:123,date:0,text:'What is 17 × 19?',from:{id:101,is_bot:false,first_name:'Fixture'},chat:{id:101,type:'private',first_name:'Fixture'}}}
|
|
70
|
-
await relay.bot.handleUpdate(update);await relay.drainInbox(true)
|
|
71
|
-
|
|
113
|
+
await relay.bot.handleUpdate(update);await relay.drainInbox(true)
|
|
114
|
+
await until(async()=>children.length===2)
|
|
115
|
+
await relay.drainOutbox()
|
|
116
|
+
assert.ok(replies.includes('323'))
|
|
72
117
|
assert.equal((await runs.get(background.id))?.status,'running')
|
|
73
118
|
assert.equal(children[0].exitCode,null)
|
|
74
|
-
assert.deepEqual(workspaces,[dir])
|
|
119
|
+
assert.deepEqual(workspaces,[dir,dir])
|
|
75
120
|
// Pausing future dispatch doesn't kill active work; cancelling this run does.
|
|
76
121
|
await scheduler.enable('slow',false);await relay.drainSources()
|
|
77
122
|
assert.equal(children[0].exitCode,null)
|
|
78
123
|
await scheduler.cancel(background.id);await relay.drainSources()
|
|
79
124
|
await until(async()=> (await runs.get(background.id))?.status==='cancelled')
|
|
80
|
-
await until(async()=>children.length===2)
|
|
81
|
-
await relay.drainOutbox()
|
|
82
|
-
assert.ok(replies.includes('323'))
|
|
83
125
|
assert.deepEqual(workspaces,[dir,dir])
|
|
84
126
|
assert.equal((await runs.list()).filter(r=>r.scheduled).length,1)
|
|
85
127
|
}finally{
|
package/test/updates.test.mjs
CHANGED
|
@@ -13,7 +13,7 @@ import { perform, environment, packageManager, backupStateDirectory, execute } f
|
|
|
13
13
|
import { atomic, snapshot, compose, prepareCommand } from '../src/plugins/manager.mjs';
|
|
14
14
|
import { bindUpdates } from '../src/updates/binding.mjs';
|
|
15
15
|
import { status as runtimeStatus } from '../src/updates/status.mjs';
|
|
16
|
-
import { providerEnvironment, queueAutomatic } from '../src/updates/supervisor.mjs';
|
|
16
|
+
import { providerEnvironment, queueAutomatic, waitForHostHeartbeat } from '../src/updates/supervisor.mjs';
|
|
17
17
|
const exec=promisify(execFile);
|
|
18
18
|
|
|
19
19
|
test('structured runtime output excludes warnings but failures retain diagnostics',async()=>{
|
|
@@ -42,6 +42,14 @@ test('supervisor forwards only provider keys declared by the agent installation'
|
|
|
42
42
|
for(const envKey of ['PATH','HOME','CODEX_HOME','NODE_OPTIONS','EZ_CONTROL_DIR','TELEGRAM_BOT_TOKEN'])
|
|
43
43
|
assert.throws(()=>providerEnvironment({agents:[{codexProviders:[{envKey}]}]},{}),/Reserved Codex provider environment key/);
|
|
44
44
|
});
|
|
45
|
+
test('host startup gate tolerates initialization beyond the former ten-second window',async()=>{
|
|
46
|
+
let reads=0;
|
|
47
|
+
await waitForHostHeartbeat('/control',{pid:42,exitCode:null},()=>undefined,{
|
|
48
|
+
attempts:120,intervalMs:0,wait:async()=>{},
|
|
49
|
+
readHeartbeat:async()=>++reads===111?{pid:42,at:Date.now()}:null,
|
|
50
|
+
});
|
|
51
|
+
assert.equal(reads,111);
|
|
52
|
+
});
|
|
45
53
|
const contract=kind=>({protocol:1,kind,stateSchema:1,mainProtocol:1});
|
|
46
54
|
function tar(entries) {
|
|
47
55
|
const chunks=[];
|
|
@@ -56,7 +64,8 @@ async function fixture(t,kind='main') {
|
|
|
56
64
|
const deployment=path.join(root,'agent'),home=path.join(deployment,'tools'),old=path.join(root,'old'),source=path.join(root,'candidate');
|
|
57
65
|
for(const d of [home,old,source,path.join(home,'bin'),path.join(home,'updates'),path.join(deployment,'mind'),path.join(deployment,'control')])await fs.mkdir(d,{recursive:true});
|
|
58
66
|
const pkg={name:'@ez-test/example',version:'0.1.0',packageManager:'pnpm@10.30.3',type:'module',files:['bin','src','docker','compose.yaml','compose.whatsapp.yaml','.dockerignore','Dockerfile'],bin:{example:'bin/example.mjs'},ezRelease:contract(kind)};
|
|
59
|
-
const
|
|
67
|
+
const guidance=text=>`export async function bindToolDiscovery(_home,workspace){const fs=await import('node:fs/promises');await fs.writeFile(workspace+'/AGENTS.md',${JSON.stringify(text+'\n')});}\n`;
|
|
68
|
+
const files={'package.json':JSON.stringify(pkg),'compose.yaml':'services: {}\n','compose.whatsapp.yaml':'services: {}\n','.dockerignore':'','Dockerfile':'FROM scratch AS runtime','docker/pnpm-lock.yaml':'lockfileVersion: 9.0\n','src/host-executor.ts':'','src/plugins/manager.mjs':guidance('old guidance'),'bin/example.mjs':'#!/usr/bin/env node\nconsole.log("example")','bin/ezenciel-agents.mjs':'console.log("0.1.1")'};
|
|
60
69
|
let record,target='main';
|
|
61
70
|
if(kind==='plugin') {
|
|
62
71
|
target='sample';files['ez-plugin.json']=JSON.stringify({schemaVersion:1,id:'sample',version:pkg.version,commands:{sample:{executable:'bin/example.mjs',args:[]}},skills:[]});
|
|
@@ -76,11 +85,19 @@ async function fixture(t,kind='main') {
|
|
|
76
85
|
await atomic(record.compose,await compose(config,record,{},home));
|
|
77
86
|
}
|
|
78
87
|
await atomic(path.join(home,'registry.json'),{schemaVersion:1,owner:home,plugins:record?{sample:record}:{},commands:record?{sample:'sample'}:{}});
|
|
79
|
-
await fs.cp(old,source,{recursive:true});pkg.version='0.1.1';await atomic(path.join(source,'package.json'),pkg);
|
|
88
|
+
await fs.cp(old,source,{recursive:true});pkg.version='0.1.1';await atomic(path.join(source,'package.json'),pkg);await fs.writeFile(path.join(source,'src/plugins/manager.mjs'),guidance('candidate guidance'));
|
|
80
89
|
if(kind==='plugin'){const m=await read(path.join(source,'ez-plugin.json'));m.version=pkg.version;await atomic(path.join(source,'ez-plugin.json'),m);}
|
|
81
90
|
const pack=async()=>{const entries=[];async function walk(dir,prefix=''){for(const e of await fs.readdir(dir,{withFileTypes:true})){const rel=prefix+e.name;if(e.isDirectory())await walk(path.join(dir,e.name),rel+'/');else entries.push(['package/'+rel,await fs.readFile(path.join(dir,e.name))]);}}await walk(source);const file=path.join(root,'candidate.tgz');await fs.writeFile(file,tar(entries));return file;};
|
|
82
91
|
return {root,home,old,source,agent,config,record,target,pack};
|
|
83
92
|
}
|
|
93
|
+
async function externalState(f) {
|
|
94
|
+
const workspace=path.join(f.root,'workspace'),controlDir=path.join(f.root,'control');
|
|
95
|
+
await fs.rename(f.agent.workspace,workspace);await fs.rename(f.agent.controlDir,controlDir);
|
|
96
|
+
f.agent.workspace=workspace;f.agent.controlDir=controlDir;f.config.workspace=workspace;
|
|
97
|
+
await atomic(path.join(f.config.deploymentDir,'host-executor.json'),{cli:'grok',agents:[f.agent]});
|
|
98
|
+
await atomic(path.join(f.home,'config.json'),f.config);
|
|
99
|
+
return f;
|
|
100
|
+
}
|
|
84
101
|
test('root preparation leaves private plugin updates readable by the installer', {
|
|
85
102
|
skip:process.platform!=='linux'||process.geteuid?.()!==0,
|
|
86
103
|
},async t=>{
|
|
@@ -259,6 +276,16 @@ test('compatibility bridge hands off any matching canonical beta release',async
|
|
|
259
276
|
await atomic(path.join(f.source,'package.json'),{...candidate,version:'0.1.0-beta.34'});
|
|
260
277
|
await assert.rejects(eligibility(f.home,'main',f.source,false),/newer/);
|
|
261
278
|
});
|
|
279
|
+
test('private RC and QA builds graduate only to their matching beta release',async t=>{
|
|
280
|
+
const f=await fixture(t),prior=await read(path.join(f.old,'package.json')),candidate=await read(path.join(f.source,'package.json'));
|
|
281
|
+
for(const suffix of ['rc.7','qa.4']) {
|
|
282
|
+
await atomic(path.join(f.old,'package.json'),{...prior,version:'0.1.0-beta.46.'+suffix});
|
|
283
|
+
await atomic(path.join(f.source,'package.json'),{...candidate,version:'0.1.0-beta.46'});
|
|
284
|
+
assert.equal((await eligibility(f.home,'main',f.source,true)).pkg.version,'0.1.0-beta.46');
|
|
285
|
+
await atomic(path.join(f.source,'package.json'),{...candidate,version:'0.1.0-beta.45'});
|
|
286
|
+
await assert.rejects(eligibility(f.home,'main',f.source,false),/newer/);
|
|
287
|
+
}
|
|
288
|
+
});
|
|
262
289
|
test('reviewed Library beta14 command migration is exact and fail-closed',()=>{
|
|
263
290
|
const manifest=(version,document)=>({schemaVersion:1,id:'library',version,description:'library',commands:{library:{executable:'bin/ez-library.mjs',args:[]},'library-query':{executable:'bin/ez-library.mjs',args:[],channelQuery:true},...(document?{'library-document':{executable:'bin/ez-library.mjs',args:[],channelQuery:true}}:{})},skills:['skills/library/SKILL.md']});
|
|
264
291
|
const deployment=(query,document)=>({schemaVersion:3,services:{library:{buildTarget:'runtime',volumes:{data:'/state'},healthcheck:['node','/app/bin/ez-library.mjs','--version'],memoryMiB:4096,cpus:2}},commands:{'library-query':{service:'library',argv:['node','/app/bin/ez-library.mjs',query]},...(document?{'library-document':{service:'library',argv:['node','/app/bin/ez-library.mjs','document']}}:{})},sharedServices:{}});
|
|
@@ -267,12 +294,14 @@ test('reviewed Library beta14 command migration is exact and fail-closed',()=>{
|
|
|
267
294
|
const unsafe=structuredClone(next);unsafe.deployment.services.library.cpus=4;assert.equal(reviewedPluginDeploymentMigration('library',old,unsafe),undefined);
|
|
268
295
|
});
|
|
269
296
|
test('main transaction stages before stopping, pins rollback image, preserves state and rebinds root',async t=>{
|
|
270
|
-
const f=await fixture(t),job=await queued(f),r=runtime(f);
|
|
297
|
+
const f=await externalState(await fixture(t)),job=await queued(f),r=runtime(f);
|
|
271
298
|
await fs.writeFile(path.join(f.agent.workspace,'memory.md'),'retain me');
|
|
272
299
|
// Mutable preparation files cannot alter the verified archive executed later.
|
|
273
300
|
await fs.writeFile(path.join(jobPath(f.home,job.id),'package/bin/example.mjs'),'tampered');
|
|
274
301
|
const result=await perform(f.home,job,r);assert.equal(result.status,'completed');
|
|
275
302
|
assert.equal(await fs.readFile(path.join(f.agent.workspace,'memory.md'),'utf8'),'retain me');
|
|
303
|
+
assert.equal(await fs.readFile(path.join(jobPath(f.home,job.id),'backup/workspace/memory.md'),'utf8'),'retain me');
|
|
304
|
+
await assert.rejects(fs.access(path.join(f.config.deploymentDir,'mind')));
|
|
276
305
|
const active=(await read(path.join(f.home,'config.json'))).packageRoot;assert(active.endsWith('/runtime'));
|
|
277
306
|
assert.notEqual(await fs.readFile(path.join(active,'bin/example.mjs'),'utf8'),'tampered');
|
|
278
307
|
assert(r.calls.findIndex(c=>c.includes('build'))<r.calls.findIndex(c=>c[0]==='stopHost'));
|
|
@@ -281,6 +310,16 @@ test('main transaction stages before stopping, pins rollback image, preserves st
|
|
|
281
310
|
const status=await command(f.home,['status']);assert(!JSON.stringify(status).includes('private-test-token'));assert(!('rollback'in status.jobs[0]));
|
|
282
311
|
assert.equal((await fs.stat(path.join(jobPath(f.home,job.id),'job.json'))).mode&0o777,0o600);
|
|
283
312
|
});
|
|
313
|
+
test('main activation and rollback bind guidance from the selected package root',async t=>{
|
|
314
|
+
const success=await fixture(t);await bindUpdates(success.home,path.join(success.config.deploymentDir,'host-executor.json'),success.old);
|
|
315
|
+
assert.equal(await fs.readFile(path.join(success.agent.workspace,'AGENTS.md'),'utf8'),'old guidance\n');
|
|
316
|
+
assert.equal((await perform(success.home,await queued(success),runtime(success))).status,'completed');
|
|
317
|
+
assert.equal(await fs.readFile(path.join(success.agent.workspace,'AGENTS.md'),'utf8'),'candidate guidance\n');
|
|
318
|
+
|
|
319
|
+
const failed=await fixture(t);await bindUpdates(failed.home,path.join(failed.config.deploymentDir,'host-executor.json'),failed.old);
|
|
320
|
+
assert.equal((await perform(failed.home,await queued(failed),runtime(failed,{fail:(_command,args)=>args.includes('up')}))).status,'rolled-back');
|
|
321
|
+
assert.equal(await fs.readFile(path.join(failed.agent.workspace,'AGENTS.md'),'utf8'),'old guidance\n');
|
|
322
|
+
});
|
|
284
323
|
test('the same archive with different deployment CLI pins gets distinct image bindings',async t=>{
|
|
285
324
|
const tags=[],hashes=[];
|
|
286
325
|
for(const pin of ['0.153.4','0.156.1']) {
|
|
@@ -343,6 +382,33 @@ test('plugin updates register additive command routes on an unchanged deployment
|
|
|
343
382
|
const dispatch=await prepareCommand(f.home,'query',[]);assert.deepEqual(dispatch.argv.slice(-4),['node','sample','/app/bin/example.mjs','query']);
|
|
344
383
|
assert(r.calls.some(call=>call.includes('up')));
|
|
345
384
|
});
|
|
385
|
+
test('isolated plugin replacement refreshes broker version inventory after registry activation',async t=>{
|
|
386
|
+
const f=await fixture(t,'plugin');
|
|
387
|
+
await fs.appendFile(path.join(f.config.deploymentDir,'docker.env'),'EZ_EXECUTOR_TRANSPORT=local\n');
|
|
388
|
+
const job=await queued(f),r=runtime(f),execute=r.execute;
|
|
389
|
+
let observed;
|
|
390
|
+
r.execute=async(command,args,options)=>{
|
|
391
|
+
if(args.includes('restart')&&args.at(-1)==='plugin-broker') observed=(await read(path.join(f.home,'registry.json'))).plugins.sample.manifest.version;
|
|
392
|
+
return execute(command,args,options);
|
|
393
|
+
};
|
|
394
|
+
assert.equal((await perform(f.home,job,r)).status,'completed');
|
|
395
|
+
assert.equal(observed,'0.1.1');
|
|
396
|
+
assert.equal(r.calls.filter(call=>call.includes('restart')).length,1);
|
|
397
|
+
const interrupted=await read(path.join(jobPath(f.home,job.id),'job.json'));interrupted.status='applying';
|
|
398
|
+
assert.equal((await perform(f.home,interrupted,r)).status,'rolled-back');
|
|
399
|
+
assert.equal(observed,'0.1.0');
|
|
400
|
+
assert.equal(r.calls.filter(call=>call.includes('restart')).length,2);
|
|
401
|
+
});
|
|
402
|
+
test('plugin update preserves a created but never running service as stopped',async t=>{
|
|
403
|
+
const f=await fixture(t,'plugin'),job=await queued(f),r=runtime(f),execute=r.execute;
|
|
404
|
+
r.execute=async(command,args,options)=>{
|
|
405
|
+
if(args.includes('ps')) {r.calls.push([command,...args]);return args.includes('--status')?'':'created-container';}
|
|
406
|
+
return execute(command,args,options);
|
|
407
|
+
};
|
|
408
|
+
assert.equal((await perform(f.home,job,r)).status,'completed');
|
|
409
|
+
assert.equal(job.rollback.running,false);
|
|
410
|
+
assert.equal(r.calls.some(call=>call.includes('up')),false);
|
|
411
|
+
});
|
|
346
412
|
test('plugin command additions ignore object key order but reject collisions and changed routes',async t=>{
|
|
347
413
|
const f=await fixture(t,'plugin'),deployment=await read(path.join(f.source,'ez-deployment.json'));
|
|
348
414
|
deployment.services.sample={healthcheck:deployment.services.sample.healthcheck,volumes:deployment.services.sample.volumes,buildTarget:deployment.services.sample.buildTarget};
|
|
@@ -366,7 +432,7 @@ test('interrupted activation recovers previous code; rollback failure is explici
|
|
|
366
432
|
const retried=await read(path.join(jobPath(f.home,interrupted.id),'job.json'));assert.equal((await perform(f.home,retried,r)).status,'rolled-back');
|
|
367
433
|
});
|
|
368
434
|
test('bound dispatch follows active package root and retains private scope',async t=>{
|
|
369
|
-
const f=await fixture(t);
|
|
435
|
+
const f=await externalState(await fixture(t));
|
|
370
436
|
const prior=await fs.readFile(path.join(f.agent.workspace,'TOOLS.md'),'utf8');
|
|
371
437
|
const bound=await bindUpdates(f.home,path.join(f.config.deploymentDir,'host-executor.json'));
|
|
372
438
|
assert.match(bound.policy,/beta-channel/);
|