@phnx-labs/agents-cli 1.22.31 → 1.22.32
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 +66 -0
- package/README.md +8 -2
- package/dist/bin/agents +0 -0
- package/dist/commands/daemon.js +52 -12
- package/dist/commands/doctor.d.ts +19 -0
- package/dist/commands/doctor.js +119 -17
- package/dist/commands/routines.js +164 -36
- package/dist/commands/sessions.d.ts +1 -1
- package/dist/commands/sessions.js +44 -10
- package/dist/commands/update.d.ts +2 -0
- package/dist/commands/update.js +148 -0
- package/dist/index.js +3 -1
- package/dist/lib/catchup.js +4 -1
- package/dist/lib/daemon.d.ts +17 -0
- package/dist/lib/daemon.js +69 -3
- package/dist/lib/devices/doctor-findings.d.ts +7 -2
- package/dist/lib/devices/doctor-findings.js +53 -2
- package/dist/lib/devices/doctor-overview-cache.d.ts +7 -0
- package/dist/lib/devices/doctor-overview-cache.js +15 -0
- package/dist/lib/devices/fleet-divergence.d.ts +11 -0
- package/dist/lib/devices/fleet-divergence.js +6 -0
- package/dist/lib/devices/fleet-inventory.js +16 -2
- package/dist/lib/drift.d.ts +6 -1
- package/dist/lib/drift.js +9 -0
- package/dist/lib/hooks/cache.js +20 -1
- package/dist/lib/hooks.d.ts +91 -1
- package/dist/lib/hooks.js +289 -3
- package/dist/lib/hosts/passthrough.js +3 -0
- package/dist/lib/installations/index.d.ts +14 -0
- package/dist/lib/installations/index.js +14 -0
- package/dist/lib/installations/resolve.d.ts +43 -0
- package/dist/lib/installations/resolve.js +93 -0
- package/dist/lib/installations/store.d.ts +56 -0
- package/dist/lib/installations/store.js +196 -0
- package/dist/lib/installations/strategies.d.ts +73 -0
- package/dist/lib/installations/strategies.js +293 -0
- package/dist/lib/installations/types.d.ts +78 -0
- package/dist/lib/installations/types.js +8 -0
- package/dist/lib/installations/update.d.ts +40 -0
- package/dist/lib/installations/update.js +131 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/migrate.d.ts +27 -0
- package/dist/lib/migrate.js +112 -2
- package/dist/lib/routine-context.d.ts +144 -0
- package/dist/lib/routine-context.js +268 -0
- package/dist/lib/routine-readiness.d.ts +47 -0
- package/dist/lib/routine-readiness.js +239 -0
- package/dist/lib/routines.d.ts +97 -1
- package/dist/lib/routines.js +107 -1
- package/dist/lib/runner.d.ts +18 -4
- package/dist/lib/runner.js +291 -98
- package/dist/lib/scheduler.d.ts +7 -1
- package/dist/lib/scheduler.js +5 -2
- package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
- package/dist/lib/self-heal/checks/hook-runtime.d.ts +2 -0
- package/dist/lib/self-heal/checks/hook-runtime.js +16 -0
- package/dist/lib/self-heal/registry.js +5 -2
- package/dist/lib/self-heal/types.d.ts +1 -1
- package/dist/lib/session/state.js +4 -1
- package/dist/lib/startup/command-registry.d.ts +1 -0
- package/dist/lib/startup/command-registry.js +2 -0
- package/dist/lib/versions.d.ts +24 -0
- package/dist/lib/versions.js +49 -16
- package/package.json +2 -2
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Routine execution context + readiness resolution.
|
|
3
|
+
*
|
|
4
|
+
* A scheduled routine has to run *somewhere*. This module is the single,
|
|
5
|
+
* target-aware answer to "which directory does this routine's run land in, and
|
|
6
|
+
* is the chosen harness able to start there?" — computed for the eventual
|
|
7
|
+
* execution TARGET, never from the daemon process's own cwd.
|
|
8
|
+
*
|
|
9
|
+
* Two layers, both pure of global state (every input is injected, so a test
|
|
10
|
+
* exercises the real code path against real temp directories rather than a mock):
|
|
11
|
+
*
|
|
12
|
+
* - {@link resolveRoutineExecutionContext} — resolve the working directory from
|
|
13
|
+
* the routine's singular `project` anchor and/or portable `cwd`, following the
|
|
14
|
+
* locked resolution table (see below), and verify the structural + filesystem
|
|
15
|
+
* readiness of that directory (existence, portability, writability, cloud
|
|
16
|
+
* portability). This layer owns the *context* readiness codes.
|
|
17
|
+
* - {@link evaluateRoutineReadiness} — take a resolved context and layer the
|
|
18
|
+
* *harness/target* readiness codes (agent installed, Codex workspace trust,
|
|
19
|
+
* live auth, target reachability) via injected probes.
|
|
20
|
+
*
|
|
21
|
+
* Resolution table (target `$HOME` = the execution device's home):
|
|
22
|
+
*
|
|
23
|
+
* | project | cwd | resolved dir | readiness |
|
|
24
|
+
* |---------|----------------|-------------------------|-----------|
|
|
25
|
+
* | usable | — | project base | continue |
|
|
26
|
+
* | usable | relative | base + cwd (inside base)| continue if inside base + exists |
|
|
27
|
+
* | rootless| relative | $HOME + cwd | continue if exists |
|
|
28
|
+
* | — | relative | $HOME + cwd | continue if exists |
|
|
29
|
+
* | — | ~/… | $HOME-relative | continue if exists |
|
|
30
|
+
* | — | abs under home | normalized to ~/… | continue |
|
|
31
|
+
* | — | abs outside home| local-pinned only | pause (cwd_not_portable) for host/fleet/cloud |
|
|
32
|
+
* | named+unusable | — | no fallback | pause (project_path_missing) |
|
|
33
|
+
* | — | — (agent/workflow) | no implicit home | pause (execution_context_missing) |
|
|
34
|
+
* | — | — (command) | $HOME | continue (housekeeping) |
|
|
35
|
+
*/
|
|
36
|
+
import * as path from 'path';
|
|
37
|
+
// --- target-aware path helpers (do NOT use project-root.ts's local-HOME-bound
|
|
38
|
+
// forms: resolution must root at the execution target's home, not this box's) ---
|
|
39
|
+
/**
|
|
40
|
+
* Path flavour of the EXECUTION TARGET, inferred from its own home string.
|
|
41
|
+
*
|
|
42
|
+
* The target home belongs to whichever machine will run the routine, which need
|
|
43
|
+
* not be this one — a Windows box can schedule onto a Linux target. Joining with
|
|
44
|
+
* the LOCAL separator therefore built `\home\user\svc` for a POSIX target (and
|
|
45
|
+
* would build `C:/Users/x/svc` the other way), so these helpers key off the home
|
|
46
|
+
* path's shape instead of `process.platform`. Same-platform behaviour is
|
|
47
|
+
* unchanged; only the cross-platform case is fixed.
|
|
48
|
+
*/
|
|
49
|
+
function targetPath(home) {
|
|
50
|
+
return /^[A-Za-z]:[\\/]/.test(home) || home.includes('\\') ? path.win32 : path.posix;
|
|
51
|
+
}
|
|
52
|
+
/** Expand a leading `~`/`$HOME` against the target home; pass other values through. */
|
|
53
|
+
export function expandTargetHome(home, p) {
|
|
54
|
+
if (p === '~' || p === '$HOME')
|
|
55
|
+
return home;
|
|
56
|
+
const tp = targetPath(home);
|
|
57
|
+
if (p.startsWith('~/'))
|
|
58
|
+
return tp.join(home, p.slice(2));
|
|
59
|
+
if (p.startsWith('$HOME/'))
|
|
60
|
+
return tp.join(home, p.slice('$HOME/'.length));
|
|
61
|
+
return p;
|
|
62
|
+
}
|
|
63
|
+
/** Rewrite an absolute path under the target home to its portable `~/…` form; pass others through. */
|
|
64
|
+
export function toTargetPortable(home, abs) {
|
|
65
|
+
const tp = targetPath(home);
|
|
66
|
+
const rel = tp.relative(home, abs);
|
|
67
|
+
if (rel === '')
|
|
68
|
+
return '~';
|
|
69
|
+
if (!rel.startsWith('..') && !tp.isAbsolute(rel))
|
|
70
|
+
return '~/' + rel.split(tp.sep).join('/');
|
|
71
|
+
return abs;
|
|
72
|
+
}
|
|
73
|
+
/** True for a bare relative path (not absolute, not home-anchored). */
|
|
74
|
+
export function isBareRelative(p) {
|
|
75
|
+
return !path.isAbsolute(p) && !p.startsWith('~') && !p.startsWith('$HOME');
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* True when `child` is `base` or strictly beneath it (no `..` escape).
|
|
79
|
+
*
|
|
80
|
+
* Compares in the BASE's own path flavour (see {@link targetPath}) — both
|
|
81
|
+
* arguments are target-side paths, and comparing a POSIX pair with Windows
|
|
82
|
+
* semantics (or the reverse) answers about the wrong filesystem.
|
|
83
|
+
*/
|
|
84
|
+
function isInside(baseAbs, childAbs) {
|
|
85
|
+
const tp = targetPath(baseAbs);
|
|
86
|
+
const rel = tp.relative(baseAbs, childAbs);
|
|
87
|
+
return rel === '' || (!rel.startsWith('..') && !tp.isAbsolute(rel));
|
|
88
|
+
}
|
|
89
|
+
function pause(ctx, readiness) {
|
|
90
|
+
return { ...ctx, ready: false, readiness };
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Resolve the working directory a routine's run lands in and verify its
|
|
94
|
+
* structural + filesystem readiness for the given placement. Pure of global
|
|
95
|
+
* state — every dependency (target home, project resolution, filesystem probe)
|
|
96
|
+
* is injected.
|
|
97
|
+
*/
|
|
98
|
+
export function resolveRoutineExecutionContext(input) {
|
|
99
|
+
const { project, cwd, kind, mode, targetHome, projectResolution, probe } = input;
|
|
100
|
+
const requestedCwd = cwd;
|
|
101
|
+
const base = { project, requestedCwd, targetHome };
|
|
102
|
+
const hasProjectBinding = projectResolution?.defined === true;
|
|
103
|
+
// Finalize a resolved portable dir: run the cloud/portability/filesystem gates
|
|
104
|
+
// and return either a ready context or a paused one.
|
|
105
|
+
const finalize = (portable, missingCode) => {
|
|
106
|
+
const absoluteCwd = expandTargetHome(targetHome, portable);
|
|
107
|
+
const ctx = { ...base, resolvedCwd: portable, absoluteCwd };
|
|
108
|
+
// Cloud: a filesystem-only cwd (no project/repo binding) has no provider
|
|
109
|
+
// repository to map onto. A project binding selects the provider repo.
|
|
110
|
+
if (mode === 'cloud' && !hasProjectBinding) {
|
|
111
|
+
return pause(ctx, {
|
|
112
|
+
code: 'cloud_context_unsupported',
|
|
113
|
+
message: `a bare cwd has no cloud repository to run in — bind a project/repo or run '${input.name ?? 'this routine'}' locally`,
|
|
114
|
+
repair: `agents routines edit ${input.name ?? '<name>'} --project-anchor <name>`,
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
if (probe) {
|
|
118
|
+
if (!probe.exists(absoluteCwd)) {
|
|
119
|
+
return pause(ctx, {
|
|
120
|
+
code: missingCode,
|
|
121
|
+
message: missingCode === 'project_path_missing'
|
|
122
|
+
? `project base directory does not exist on the target: ${portable}`
|
|
123
|
+
: `execution directory does not exist on the target: ${portable}`,
|
|
124
|
+
repair: `mkdir -p ${portable}`,
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
if (!probe.isDirectory(absoluteCwd)) {
|
|
128
|
+
return pause(ctx, {
|
|
129
|
+
code: 'cwd_not_directory',
|
|
130
|
+
message: `execution path is not a directory: ${portable}`,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
if (!probe.isWritable(absoluteCwd)) {
|
|
134
|
+
return pause(ctx, {
|
|
135
|
+
code: 'workspace_not_writable',
|
|
136
|
+
message: `execution directory is not writable: ${portable}`,
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return { ...ctx, ready: true };
|
|
141
|
+
};
|
|
142
|
+
// 1. Explicit project anchor.
|
|
143
|
+
if (project !== undefined) {
|
|
144
|
+
if (!projectResolution || projectResolution.defined === false) {
|
|
145
|
+
return pause(base, {
|
|
146
|
+
code: 'project_not_found',
|
|
147
|
+
message: `project '${project}' is not defined`,
|
|
148
|
+
repair: `agents projects add ${project} --root <path>`,
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
const projBase = projectResolution.base;
|
|
152
|
+
if (projBase) {
|
|
153
|
+
// Usable base path.
|
|
154
|
+
if (cwd === undefined) {
|
|
155
|
+
return finalize(projBase, 'project_path_missing');
|
|
156
|
+
}
|
|
157
|
+
if (isBareRelative(cwd)) {
|
|
158
|
+
const baseAbs = expandTargetHome(targetHome, projBase);
|
|
159
|
+
// Target-side join: `path.resolve` would use this host's separator and,
|
|
160
|
+
// for a target home this host does not consider absolute, prepend the
|
|
161
|
+
// local process cwd.
|
|
162
|
+
const joinedAbs = targetPath(targetHome).resolve(baseAbs, cwd);
|
|
163
|
+
if (!isInside(baseAbs, joinedAbs)) {
|
|
164
|
+
return pause(base, {
|
|
165
|
+
code: 'cwd_not_portable',
|
|
166
|
+
message: `cwd '${cwd}' escapes the project base '${projBase}' — a project-relative cwd must stay inside it`,
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
return finalize(toTargetPortable(targetHome, joinedAbs), 'cwd_missing');
|
|
170
|
+
}
|
|
171
|
+
// Absolute or home-anchored cwd alongside a project: fall through to the
|
|
172
|
+
// cwd-first handling below (the project base is not the anchor then).
|
|
173
|
+
}
|
|
174
|
+
else if (cwd === undefined) {
|
|
175
|
+
// Rootless project (Linear import), no cwd: there is nothing to run in.
|
|
176
|
+
return pause(base, {
|
|
177
|
+
code: 'project_path_missing',
|
|
178
|
+
message: `project '${project}' has no checkout path — give it a cwd (anchored at the target home) or set the project's root`,
|
|
179
|
+
repair: `agents routines edit ${input.name ?? '<name>'} --cwd <path>`,
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
// Rootless project + a cwd, or usable project + a non-relative cwd: the cwd
|
|
183
|
+
// itself is the anchor. A bare relative cwd anchors at the target home.
|
|
184
|
+
}
|
|
185
|
+
// 2. cwd without a (usable relative-anchoring) project.
|
|
186
|
+
if (cwd !== undefined) {
|
|
187
|
+
if (cwd.startsWith('~') || cwd.startsWith('$HOME')) {
|
|
188
|
+
const abs = expandTargetHome(targetHome, cwd);
|
|
189
|
+
return finalize(toTargetPortable(targetHome, abs), 'cwd_missing');
|
|
190
|
+
}
|
|
191
|
+
if (path.isAbsolute(cwd)) {
|
|
192
|
+
const resolved = path.resolve(cwd);
|
|
193
|
+
if (isInside(targetHome, resolved)) {
|
|
194
|
+
// Normalize an absolute-under-home path to its portable form on save.
|
|
195
|
+
return finalize(toTargetPortable(targetHome, resolved), 'cwd_missing');
|
|
196
|
+
}
|
|
197
|
+
// Absolute path outside the target home: only a local-pinned routine can
|
|
198
|
+
// use it; host/fleet/cloud placement cannot carry a non-portable path.
|
|
199
|
+
if (mode === 'local') {
|
|
200
|
+
return finalize(resolved, 'cwd_missing');
|
|
201
|
+
}
|
|
202
|
+
return pause(base, {
|
|
203
|
+
code: 'cwd_not_portable',
|
|
204
|
+
message: `absolute cwd '${cwd}' is outside the target home and cannot travel to ${mode} placement — use a home-relative path`,
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
// Bare relative cwd (no usable project base): anchor at the target home,
|
|
208
|
+
// in the target's own path flavour (see the joinedAbs note above).
|
|
209
|
+
return finalize(toTargetPortable(targetHome, targetPath(targetHome).resolve(targetHome, cwd)), 'cwd_missing');
|
|
210
|
+
}
|
|
211
|
+
// 3. Neither field.
|
|
212
|
+
if (kind === 'command') {
|
|
213
|
+
// Command routines are deterministic housekeeping — the target home is a
|
|
214
|
+
// safe implicit cwd, so a version-check / notify routine keeps working.
|
|
215
|
+
return finalize(toTargetPortable(targetHome, targetHome), 'cwd_missing');
|
|
216
|
+
}
|
|
217
|
+
return pause(base, {
|
|
218
|
+
code: 'execution_context_missing',
|
|
219
|
+
message: `routine '${input.name ?? ''}' has no project or cwd — an agent/workflow routine needs an explicit execution directory`,
|
|
220
|
+
repair: `agents routines edit ${input.name ?? '<name>'} --project-anchor <name> # or --cwd <path>`,
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* Layer the harness/target readiness codes onto a resolved execution context.
|
|
225
|
+
* Context blockers short-circuit (no point probing auth for a routine that has
|
|
226
|
+
* no directory to run in). Every probe is optional and injected; an omitted
|
|
227
|
+
* probe is treated as "not applicable / passes" so a caller only pays for the
|
|
228
|
+
* checks it wires up.
|
|
229
|
+
*/
|
|
230
|
+
export function evaluateRoutineReadiness(context, probes = {}, opts = {}) {
|
|
231
|
+
if (!context.ready) {
|
|
232
|
+
return { context, ready: false, readiness: context.readiness };
|
|
233
|
+
}
|
|
234
|
+
if (probes.targetReachable && !probes.targetReachable()) {
|
|
235
|
+
return withBlocker(context, {
|
|
236
|
+
code: 'target_unreachable',
|
|
237
|
+
message: 'the execution target is not reachable',
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
if (probes.agentInstalled && !probes.agentInstalled()) {
|
|
241
|
+
return withBlocker(context, {
|
|
242
|
+
code: 'agent_unavailable',
|
|
243
|
+
message: `no usable version of ${opts.agent ?? 'the agent'} is installed on the target`,
|
|
244
|
+
repair: opts.agent ? `agents add ${opts.agent}@<version>` : undefined,
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
if (probes.codexTrusted && context.absoluteCwd && !probes.codexTrusted(context.absoluteCwd)) {
|
|
248
|
+
return withBlocker(context, {
|
|
249
|
+
code: 'codex_workspace_untrusted',
|
|
250
|
+
message: `Codex will not start in an untrusted workspace: ${context.resolvedCwd}`,
|
|
251
|
+
repair: `trust the workspace (add it to Codex's trusted projects) — the routine never uses --skip-git-repo-check`,
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
if (probes.authOk) {
|
|
255
|
+
const verdict = probes.authOk();
|
|
256
|
+
if (!verdict.ok) {
|
|
257
|
+
return withBlocker(context, {
|
|
258
|
+
code: 'agent_auth_failed',
|
|
259
|
+
message: `the selected account failed a live auth check${verdict.reason ? `: ${verdict.reason}` : ''}`,
|
|
260
|
+
repair: opts.agent ? `agents run ${opts.agent} -- login` : 'log the account back in',
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
return { context, ready: true };
|
|
265
|
+
}
|
|
266
|
+
function withBlocker(context, readiness) {
|
|
267
|
+
return { context: { ...context, ready: false, readiness }, ready: false, readiness };
|
|
268
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Activation readiness for a routine, composed from the target-aware execution
|
|
3
|
+
* context ({@link resolveJobExecutionContext}) plus the harness/target checks a
|
|
4
|
+
* caller can perform on this box (agent installed). This is the gate `add`,
|
|
5
|
+
* `edit`, `doctor`, and `resume` all run before activating a routine: ready →
|
|
6
|
+
* active, any proven blocker → saved paused with a stable code + repair command.
|
|
7
|
+
*
|
|
8
|
+
* Structural context readiness is synchronous for the scheduler. Interactive
|
|
9
|
+
* add/edit/doctor/resume additionally call the live variant below, which probes
|
|
10
|
+
* authentication and Codex's native workspace-trust record before activation.
|
|
11
|
+
*/
|
|
12
|
+
import type { JobConfig } from './routines.js';
|
|
13
|
+
import { type RoutineReadinessResult } from './routine-context.js';
|
|
14
|
+
/**
|
|
15
|
+
* Evaluate whether a routine is ready to activate on this box. `probeAgent`
|
|
16
|
+
* defaults to "is a version of the routine's agent resolvable" via
|
|
17
|
+
* {@link resolveVersion}; pass a stub in tests to exercise the availability path
|
|
18
|
+
* without an installed harness.
|
|
19
|
+
*/
|
|
20
|
+
export declare function evaluateActivationReadiness(config: JobConfig, deps?: {
|
|
21
|
+
probeAgent?: (agent: string) => boolean;
|
|
22
|
+
}): RoutineReadinessResult;
|
|
23
|
+
/** One-line human summary of a blocked readiness result, with its repair. */
|
|
24
|
+
export declare function formatReadinessBlocker(result: RoutineReadinessResult): string;
|
|
25
|
+
interface RemoteProjectDefinition {
|
|
26
|
+
name: string;
|
|
27
|
+
root?: string;
|
|
28
|
+
defaultPath?: string;
|
|
29
|
+
}
|
|
30
|
+
/** Parse the target HOME sentinel and the project catalog returned by one SSH call. */
|
|
31
|
+
export declare function parseRemoteProjectSnapshot(stdout: string): {
|
|
32
|
+
home: string;
|
|
33
|
+
projects: RemoteProjectDefinition[];
|
|
34
|
+
} | undefined;
|
|
35
|
+
/** Build a target-native probe that creates and removes a file in the workspace. */
|
|
36
|
+
export declare function buildRemoteWorkspaceProbe(cwd: string, windows: boolean): string;
|
|
37
|
+
/** A successful probe must contain the exact requested postcondition, not merely exit zero. */
|
|
38
|
+
export declare function probeOutputHasSentinel(stdout: string, sentinel: string): boolean;
|
|
39
|
+
/**
|
|
40
|
+
* Interactive setup/repair readiness. Unlike the scheduler's deterministic
|
|
41
|
+
* structural gate, this completes a real local auth request and reads Codex's
|
|
42
|
+
* native trust record before add/edit/resume can activate the definition.
|
|
43
|
+
*/
|
|
44
|
+
export declare function evaluateActivationReadinessLive(config: JobConfig): Promise<RoutineReadinessResult>;
|
|
45
|
+
/** Resolve and probe the actual SSH target used by a host-placed routine. */
|
|
46
|
+
export declare function evaluateHostActivationReadiness(config: JobConfig): Promise<RoutineReadinessResult>;
|
|
47
|
+
export {};
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Activation readiness for a routine, composed from the target-aware execution
|
|
3
|
+
* context ({@link resolveJobExecutionContext}) plus the harness/target checks a
|
|
4
|
+
* caller can perform on this box (agent installed). This is the gate `add`,
|
|
5
|
+
* `edit`, `doctor`, and `resume` all run before activating a routine: ready →
|
|
6
|
+
* active, any proven blocker → saved paused with a stable code + repair command.
|
|
7
|
+
*
|
|
8
|
+
* Structural context readiness is synchronous for the scheduler. Interactive
|
|
9
|
+
* add/edit/doctor/resume additionally call the live variant below, which probes
|
|
10
|
+
* authentication and Codex's native workspace-trust record before activation.
|
|
11
|
+
*/
|
|
12
|
+
import * as fs from 'fs';
|
|
13
|
+
import * as path from 'path';
|
|
14
|
+
import * as TOML from 'smol-toml';
|
|
15
|
+
import { resolveJobExecutionContext, resolveHostStrategy } from './routines.js';
|
|
16
|
+
import { evaluateRoutineReadiness } from './routine-context.js';
|
|
17
|
+
import { getVersionHomePath, resolveVersion } from './versions.js';
|
|
18
|
+
import { probeLocalFleetAuth } from './auth-health.js';
|
|
19
|
+
import { resolveHostRunTarget } from './hosts/run-target.js';
|
|
20
|
+
import { hostIdentityArgs, sshTargetFor } from './hosts/types.js';
|
|
21
|
+
import { probeHost } from './hosts/ready.js';
|
|
22
|
+
import { sshExec, shellQuote } from './ssh-exec.js';
|
|
23
|
+
import { encodePowershell, powershellQuote, POWERSHELL_PROGRESS_SILENCE } from './hosts/remote-cmd.js';
|
|
24
|
+
/**
|
|
25
|
+
* Evaluate whether a routine is ready to activate on this box. `probeAgent`
|
|
26
|
+
* defaults to "is a version of the routine's agent resolvable" via
|
|
27
|
+
* {@link resolveVersion}; pass a stub in tests to exercise the availability path
|
|
28
|
+
* without an installed harness.
|
|
29
|
+
*/
|
|
30
|
+
export function evaluateActivationReadiness(config, deps = {}) {
|
|
31
|
+
const strategy = resolveHostStrategy(config);
|
|
32
|
+
const mode = strategy;
|
|
33
|
+
// Local placement inspects this box's filesystem; a remote/cloud target defers
|
|
34
|
+
// existence (its filesystem is unreachable here) and checks portability only.
|
|
35
|
+
const context = resolveJobExecutionContext(config, {
|
|
36
|
+
mode,
|
|
37
|
+
probe: mode === 'local' ? undefined : null,
|
|
38
|
+
});
|
|
39
|
+
const probeAgent = deps.probeAgent ?? ((agent) => resolveVersion(agent) !== undefined);
|
|
40
|
+
return evaluateRoutineReadiness(context, {
|
|
41
|
+
// Command routines have no agent to install; workflow routines dispatch
|
|
42
|
+
// through `agents run` (claude under the hood) and are checked at run time.
|
|
43
|
+
agentInstalled: config.agent && !config.workflow && !config.command
|
|
44
|
+
? () => probeAgent(config.agent)
|
|
45
|
+
: undefined,
|
|
46
|
+
}, { agent: config.agent });
|
|
47
|
+
}
|
|
48
|
+
/** One-line human summary of a blocked readiness result, with its repair. */
|
|
49
|
+
export function formatReadinessBlocker(result) {
|
|
50
|
+
if (result.ready || !result.readiness)
|
|
51
|
+
return 'ready';
|
|
52
|
+
const { code, message, repair } = result.readiness;
|
|
53
|
+
return `${code}: ${message}${repair ? `\n repair: ${repair}` : ''}`;
|
|
54
|
+
}
|
|
55
|
+
/** Parse the target HOME sentinel and the project catalog returned by one SSH call. */
|
|
56
|
+
export function parseRemoteProjectSnapshot(stdout) {
|
|
57
|
+
const lines = stdout.split(/\r?\n/);
|
|
58
|
+
const homeLine = lines.shift();
|
|
59
|
+
if (!homeLine?.startsWith('__HOME__'))
|
|
60
|
+
return undefined;
|
|
61
|
+
const home = homeLine.slice('__HOME__'.length);
|
|
62
|
+
if (!home)
|
|
63
|
+
return undefined;
|
|
64
|
+
try {
|
|
65
|
+
const projects = JSON.parse(lines.join('\n'));
|
|
66
|
+
if (!Array.isArray(projects))
|
|
67
|
+
return undefined;
|
|
68
|
+
if (!projects.every((entry) => entry && typeof entry === 'object' && typeof entry.name === 'string')) {
|
|
69
|
+
return undefined;
|
|
70
|
+
}
|
|
71
|
+
return { home, projects: projects };
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
return undefined;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
/** Build a target-native probe that creates and removes a file in the workspace. */
|
|
78
|
+
export function buildRemoteWorkspaceProbe(cwd, windows) {
|
|
79
|
+
if (windows) {
|
|
80
|
+
return `powershell -NoProfile -EncodedCommand ${encodePowershell(`$d=${powershellQuote(cwd)}; if (-not (Test-Path -LiteralPath $d -PathType Container)) { exit 1 }; $p=Join-Path $d ([IO.Path]::GetRandomFileName()); try { New-Item -ItemType File -Path $p -ErrorAction Stop | Out-Null; Remove-Item -LiteralPath $p -Force -ErrorAction Stop; exit 0 } catch { exit 1 }`)}`;
|
|
81
|
+
}
|
|
82
|
+
const pattern = path.posix.join(cwd, '.agents-routine-readiness.XXXXXX');
|
|
83
|
+
return `probe_path=$(mktemp ${shellQuote(pattern)}) && rm -f "$probe_path"`;
|
|
84
|
+
}
|
|
85
|
+
/** A successful probe must contain the exact requested postcondition, not merely exit zero. */
|
|
86
|
+
export function probeOutputHasSentinel(stdout, sentinel) {
|
|
87
|
+
const containsExact = (value) => {
|
|
88
|
+
if (value === sentinel)
|
|
89
|
+
return true;
|
|
90
|
+
if (Array.isArray(value))
|
|
91
|
+
return value.some(containsExact);
|
|
92
|
+
if (value && typeof value === 'object')
|
|
93
|
+
return Object.values(value).some(containsExact);
|
|
94
|
+
return false;
|
|
95
|
+
};
|
|
96
|
+
return stdout.split(/\r?\n/).some((line) => {
|
|
97
|
+
const trimmed = line.trim();
|
|
98
|
+
if (trimmed === sentinel)
|
|
99
|
+
return true;
|
|
100
|
+
try {
|
|
101
|
+
return containsExact(JSON.parse(trimmed));
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
function codexWorkspaceTrusted(version, cwd) {
|
|
109
|
+
try {
|
|
110
|
+
const configPath = path.join(getVersionHomePath('codex', version), '.codex', 'config.toml');
|
|
111
|
+
const parsed = TOML.parse(fs.readFileSync(configPath, 'utf-8'));
|
|
112
|
+
const projects = parsed.projects;
|
|
113
|
+
return Object.entries(projects ?? {}).some(([root, project]) => {
|
|
114
|
+
if (project.trust_level !== 'trusted')
|
|
115
|
+
return false;
|
|
116
|
+
const relative = path.relative(root, cwd);
|
|
117
|
+
return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Interactive setup/repair readiness. Unlike the scheduler's deterministic
|
|
126
|
+
* structural gate, this completes a real local auth request and reads Codex's
|
|
127
|
+
* native trust record before add/edit/resume can activate the definition.
|
|
128
|
+
*/
|
|
129
|
+
export async function evaluateActivationReadinessLive(config) {
|
|
130
|
+
const structural = evaluateActivationReadiness(config);
|
|
131
|
+
if (!structural.ready)
|
|
132
|
+
return structural;
|
|
133
|
+
const mode = resolveHostStrategy(config);
|
|
134
|
+
if (mode === 'host' && config.host)
|
|
135
|
+
return evaluateHostActivationReadiness(config);
|
|
136
|
+
if (mode !== 'local' || !config.agent || config.workflow || config.command)
|
|
137
|
+
return structural;
|
|
138
|
+
const version = resolveVersion(config.agent);
|
|
139
|
+
if (!version)
|
|
140
|
+
return structural;
|
|
141
|
+
const context = resolveJobExecutionContext(config, { mode: 'local' });
|
|
142
|
+
let authVerdict;
|
|
143
|
+
const rows = await probeLocalFleetAuth({ agents: [config.agent] });
|
|
144
|
+
const row = rows.find((candidate) => candidate.version === version);
|
|
145
|
+
const accepted = new Set(['live', 'rate_limited', 'unverified']);
|
|
146
|
+
authVerdict = row && accepted.has(row.health.verdict)
|
|
147
|
+
? { ok: true }
|
|
148
|
+
: { ok: false, reason: row?.health.verdict ?? 'unconfigured' };
|
|
149
|
+
return evaluateRoutineReadiness(context, {
|
|
150
|
+
agentInstalled: () => true,
|
|
151
|
+
...(config.agent === 'codex' && context.absoluteCwd
|
|
152
|
+
? { codexTrusted: () => codexWorkspaceTrusted(version, context.absoluteCwd) }
|
|
153
|
+
: {}),
|
|
154
|
+
authOk: () => authVerdict,
|
|
155
|
+
}, { agent: config.agent });
|
|
156
|
+
}
|
|
157
|
+
/** Resolve and probe the actual SSH target used by a host-placed routine. */
|
|
158
|
+
export async function evaluateHostActivationReadiness(config) {
|
|
159
|
+
const host = await resolveHostRunTarget(config.host);
|
|
160
|
+
const target = sshTargetFor(host);
|
|
161
|
+
const identity = hostIdentityArgs(host);
|
|
162
|
+
if (!probeHost(target, host.os, identity).reachable) {
|
|
163
|
+
return evaluateRoutineReadiness(resolveJobExecutionContext(config, { mode: 'host', probe: null }), {
|
|
164
|
+
targetReachable: () => false,
|
|
165
|
+
}, { agent: config.agent });
|
|
166
|
+
}
|
|
167
|
+
const windows = host.os?.toLowerCase().includes('win') ?? false;
|
|
168
|
+
const homeCommand = windows
|
|
169
|
+
? `powershell -NoProfile -EncodedCommand ${encodePowershell(`${POWERSHELL_PROGRESS_SILENCE}; Write-Output ("__HOME__" + $HOME); & agents projects list --json`)}`
|
|
170
|
+
: `bash -lc ${shellQuote('printf "__HOME__%s\\n" "$HOME"; agents projects list --json')}`;
|
|
171
|
+
const projectResult = sshExec(target, homeCommand, { timeoutMs: 20_000, extraSshArgs: identity });
|
|
172
|
+
if (projectResult.code !== 0) {
|
|
173
|
+
return evaluateRoutineReadiness(resolveJobExecutionContext(config, { mode: 'host', probe: null }), {
|
|
174
|
+
targetReachable: () => false,
|
|
175
|
+
}, { agent: config.agent });
|
|
176
|
+
}
|
|
177
|
+
const snapshot = parseRemoteProjectSnapshot(projectResult.stdout);
|
|
178
|
+
if (!snapshot) {
|
|
179
|
+
return evaluateRoutineReadiness(resolveJobExecutionContext(config, { mode: 'host', probe: null }), {
|
|
180
|
+
targetReachable: () => false,
|
|
181
|
+
}, { agent: config.agent });
|
|
182
|
+
}
|
|
183
|
+
const targetHome = snapshot.home;
|
|
184
|
+
const defs = snapshot.projects;
|
|
185
|
+
const def = config.project ? defs.find((candidate) => candidate.name === config.project) : undefined;
|
|
186
|
+
const projectResolution = config.project
|
|
187
|
+
? (def ? { defined: true, base: def.defaultPath ?? def.root } : { defined: false })
|
|
188
|
+
: undefined;
|
|
189
|
+
const unprobed = resolveJobExecutionContext(config, { mode: 'host', targetHome, probe: null, projectResolution });
|
|
190
|
+
if (!unprobed.ready || !unprobed.absoluteCwd)
|
|
191
|
+
return { context: unprobed, ready: false, readiness: unprobed.readiness };
|
|
192
|
+
const check = buildRemoteWorkspaceProbe(unprobed.absoluteCwd, windows);
|
|
193
|
+
const fsResult = sshExec(target, check, { timeoutMs: 12_000, extraSshArgs: identity });
|
|
194
|
+
if (fsResult.code !== 0) {
|
|
195
|
+
return evaluateRoutineReadiness({ ...unprobed, ready: false, readiness: {
|
|
196
|
+
code: 'workspace_not_writable',
|
|
197
|
+
message: `the execution directory is missing or not writable on ${host.name}: ${unprobed.resolvedCwd}`,
|
|
198
|
+
} });
|
|
199
|
+
}
|
|
200
|
+
if (config.agent && !config.workflow && !config.command) {
|
|
201
|
+
if (config.agent === 'codex') {
|
|
202
|
+
const args = ['run', 'codex', 'Reply with exactly ROUTINE_READY', '--mode', 'plan', '--timeout', '45s', '--json'];
|
|
203
|
+
const command = windows
|
|
204
|
+
? `powershell -NoProfile -EncodedCommand ${encodePowershell(`${POWERSHELL_PROGRESS_SILENCE}; Set-Location -LiteralPath ${powershellQuote(unprobed.absoluteCwd)}; & agents ${args.map(powershellQuote).join(' ')}`)}`
|
|
205
|
+
: `cd ${shellQuote(unprobed.absoluteCwd)} && agents ${args.map(shellQuote).join(' ')}`;
|
|
206
|
+
const probe = sshExec(target, command, { timeoutMs: 60_000, extraSshArgs: identity });
|
|
207
|
+
if (probe.code !== 0 || !probeOutputHasSentinel(probe.stdout, 'ROUTINE_READY')) {
|
|
208
|
+
const detail = `${probe.stderr}\n${probe.stdout}`.trim();
|
|
209
|
+
const untrusted = detail.includes('trusted directory') || detail.includes('trusted workspace');
|
|
210
|
+
return evaluateRoutineReadiness(unprobed, {
|
|
211
|
+
...(untrusted ? { codexTrusted: () => false } : { authOk: () => ({ ok: false, reason: detail || 'probe failed' }) }),
|
|
212
|
+
}, { agent: config.agent });
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
else {
|
|
216
|
+
const pingArgs = ['devices', 'ping', '--local', '--json'];
|
|
217
|
+
const command = windows
|
|
218
|
+
? `powershell -NoProfile -EncodedCommand ${encodePowershell(`${POWERSHELL_PROGRESS_SILENCE}; & agents ${pingArgs.map(powershellQuote).join(' ')}`)}`
|
|
219
|
+
: `agents ${pingArgs.map(shellQuote).join(' ')}`;
|
|
220
|
+
const probe = sshExec(target, command, { timeoutMs: 30_000, extraSshArgs: identity });
|
|
221
|
+
let verdict = 'error';
|
|
222
|
+
if (probe.code === 0) {
|
|
223
|
+
try {
|
|
224
|
+
const payload = JSON.parse(probe.stdout);
|
|
225
|
+
verdict = payload.rows?.find((row) => row.agent === config.agent)?.health.verdict ?? 'unconfigured';
|
|
226
|
+
}
|
|
227
|
+
catch {
|
|
228
|
+
verdict = 'error';
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
if (!new Set(['live', 'rate_limited', 'unverified']).has(verdict)) {
|
|
232
|
+
return evaluateRoutineReadiness(unprobed, {
|
|
233
|
+
authOk: () => ({ ok: false, reason: verdict }),
|
|
234
|
+
}, { agent: config.agent });
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
return { context: unprobed, ready: true };
|
|
239
|
+
}
|