@phnx-labs/agents-cli 1.22.31 → 1.22.33
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 +72 -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-browser.js +2 -2
- package/dist/commands/sessions.d.ts +1 -1
- package/dist/commands/sessions.js +66 -22
- 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/session/team-filter.d.ts +11 -0
- package/dist/lib/session/team-filter.js +10 -0
- 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,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
|
+
}
|
package/dist/lib/routines.d.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* run metadata persistence, prompt variable expansion, and one-shot "at" time
|
|
7
7
|
* scheduling.
|
|
8
8
|
*/
|
|
9
|
+
import { type ResolvedExecutionContext, type ProjectResolution, type PlacementMode, type RoutineKind, type ContextFsProbe } from './routine-context.js';
|
|
9
10
|
import type { AgentId } from './types.js';
|
|
10
11
|
import type { LoopConfig } from './loop.js';
|
|
11
12
|
/** Tool/site/directory allow-list for sandboxed job execution. */
|
|
@@ -114,6 +115,32 @@ export interface JobConfig {
|
|
|
114
115
|
prompt: string;
|
|
115
116
|
timezone?: string;
|
|
116
117
|
repo?: string;
|
|
118
|
+
/**
|
|
119
|
+
* Singular execution anchor: the named project (`agents projects`) whose base
|
|
120
|
+
* directory the routine's run lands in. Optional. Metadata-only `projects[]`
|
|
121
|
+
* (below) is NEVER used for execution — this field is. Resolution happens on
|
|
122
|
+
* the execution TARGET (`resolveRoutineExecutionContext`, routine-context.ts),
|
|
123
|
+
* never from the daemon's own cwd: a project with a usable `defaultPath`/`root`
|
|
124
|
+
* gives the base directory; a rootless Linear-imported project gives no base,
|
|
125
|
+
* so a bare relative `cwd` then anchors at the target user's `$HOME`.
|
|
126
|
+
*
|
|
127
|
+
* CLI flag is `--project-anchor` (not `--project`, which is the repeatable
|
|
128
|
+
* grouping-metadata flag that writes `projects[]`). The YAML key is the shorter
|
|
129
|
+
* singular `project` because it is unambiguous there.
|
|
130
|
+
*/
|
|
131
|
+
project?: string;
|
|
132
|
+
/**
|
|
133
|
+
* Portable execution directory for the routine's run. Optional. A relative
|
|
134
|
+
* value resolves under the `project` base when that base is usable, otherwise
|
|
135
|
+
* under the execution target's `$HOME` (so a Linear-imported rootless project
|
|
136
|
+
* can still name `cwd: src/github.com/acme/app`). A `~/`-anchored value is the
|
|
137
|
+
* target's home-relative path; an absolute path under the target home is
|
|
138
|
+
* normalized to the portable `~/…` form on save. An absolute path outside the
|
|
139
|
+
* home is only allowed for local-pinned routines — host/fleet/cloud placement
|
|
140
|
+
* pauses it as non-portable. Supersedes the legacy `remoteCwd`, which the
|
|
141
|
+
* one-shot migration folds into this field.
|
|
142
|
+
*/
|
|
143
|
+
cwd?: string;
|
|
117
144
|
/**
|
|
118
145
|
* Fleet allowlist — restrict this routine to specific devices. When omitted
|
|
119
146
|
* or empty, the routine is unrestricted and fires on every device running the
|
|
@@ -308,6 +335,24 @@ export declare function projectGroupOrder(group: ProjectGroup): number;
|
|
|
308
335
|
* - `"Unknown projects"` — when any entry is no longer a defined project (stale).
|
|
309
336
|
*/
|
|
310
337
|
export declare function computeProjectGroup(projects: string[] | undefined, knownProjectNames: Set<string>): string;
|
|
338
|
+
/** A real-filesystem {@link ContextFsProbe} for readiness checks on this machine. */
|
|
339
|
+
export declare function realFsProbe(): ContextFsProbe;
|
|
340
|
+
/** Classify a routine by its body kind — governs the execution-context fallback rules. */
|
|
341
|
+
export declare function jobRoutineKind(config: Pick<JobConfig, 'agent' | 'workflow' | 'command'>): RoutineKind;
|
|
342
|
+
/**
|
|
343
|
+
* Resolve a routine's execution context (working directory + structural/fs
|
|
344
|
+
* readiness) by bridging its `project`/`cwd` fields into the pure
|
|
345
|
+
* {@link resolveRoutineExecutionContext} resolver. Local placement resolves
|
|
346
|
+
* against this machine's `$HOME` with a real filesystem probe; a caller may
|
|
347
|
+
* inject a different target home / probe (e.g. `null` to defer existence for a
|
|
348
|
+
* remote target).
|
|
349
|
+
*/
|
|
350
|
+
export declare function resolveJobExecutionContext(config: Pick<JobConfig, 'name' | 'project' | 'cwd' | 'agent' | 'workflow' | 'command'>, opts?: {
|
|
351
|
+
targetHome?: string;
|
|
352
|
+
mode?: PlacementMode;
|
|
353
|
+
probe?: ContextFsProbe | null;
|
|
354
|
+
projectResolution?: ProjectResolution;
|
|
355
|
+
}): ResolvedExecutionContext;
|
|
311
356
|
/** Metadata for a single job execution, persisted as JSON in the run directory. */
|
|
312
357
|
export interface RunMeta {
|
|
313
358
|
jobName: string;
|
|
@@ -337,8 +382,41 @@ export interface RunMeta {
|
|
|
337
382
|
* due). Without it a miss leaves no trace at all and the listing keeps
|
|
338
383
|
* showing the previous run's status as if it were current. Written by
|
|
339
384
|
* `claimMissedFire` (catchup.ts), never by the runner.
|
|
385
|
+
*
|
|
386
|
+
* `blocked` and `skipped` are pre-execution terminals that leave a visible
|
|
387
|
+
* record even though no agent process ran (the plan's history contract):
|
|
388
|
+
* - `blocked` — a fire-time readiness rejection (bad context, dead auth,
|
|
389
|
+
* untrusted workspace). No agent process was spawned. Distinct from `failed`,
|
|
390
|
+
* which means a process started and failed.
|
|
391
|
+
* - `skipped` — the attempt lost a claim (`skipReason`): a duplicate schedule
|
|
392
|
+
* slot, an already-active run it would overlap, or a wrong device owner.
|
|
393
|
+
*/
|
|
394
|
+
status: 'running' | 'completed' | 'failed' | 'timeout' | 'missed' | 'blocked' | 'skipped';
|
|
395
|
+
/**
|
|
396
|
+
* How this attempt was triggered. Answers "why did this run exist" for a
|
|
397
|
+
* record that may have no transcript (a blocked/skipped attempt).
|
|
398
|
+
*/
|
|
399
|
+
triggerKind?: 'schedule' | 'catchup' | 'manual' | 'webhook' | 'event';
|
|
400
|
+
/**
|
|
401
|
+
* The scheduler's intended UTC fire time (ISO), for a `schedule`/`catchup`
|
|
402
|
+
* attempt. The atomic single-fire claim keys on (routine, scheduledFor): a
|
|
403
|
+
* duplicate cron delivery for the same slot resolves to this same run rather
|
|
404
|
+
* than launching a second time.
|
|
340
405
|
*/
|
|
341
|
-
|
|
406
|
+
scheduledFor?: string;
|
|
407
|
+
/** Resolved execution context (routine-context.ts), recorded before preflight. */
|
|
408
|
+
project?: string;
|
|
409
|
+
requestedCwd?: string;
|
|
410
|
+
resolvedCwd?: string;
|
|
411
|
+
readiness?: {
|
|
412
|
+
code: string;
|
|
413
|
+
message: string;
|
|
414
|
+
repair?: string;
|
|
415
|
+
};
|
|
416
|
+
/** Why a `skipped` attempt launched nothing. */
|
|
417
|
+
skipReason?: 'duplicate_slot' | 'active_run' | 'wrong_owner';
|
|
418
|
+
/** The run this attempt deferred to (the winning duplicate slot / active run). */
|
|
419
|
+
activeRunId?: string;
|
|
342
420
|
startedAt: string;
|
|
343
421
|
completedAt: string | null;
|
|
344
422
|
exitCode: number | null;
|
|
@@ -629,6 +707,24 @@ export declare function readRunMeta(jobName: string, runId: string): RunMeta | n
|
|
|
629
707
|
export declare function getJobRunsDir(jobName: string): string;
|
|
630
708
|
/** Get the filesystem path for a specific run's directory. */
|
|
631
709
|
export declare function getRunDir(jobName: string, runId: string): string;
|
|
710
|
+
/**
|
|
711
|
+
* The run id a scheduled fire is recorded under — derived from its intended UTC
|
|
712
|
+
* fire time so the SAME slot always maps to the SAME run directory. This is what
|
|
713
|
+
* makes the single-fire claim meaningful: a duplicate cron delivery for one slot
|
|
714
|
+
* computes the same id and loses the atomic `mkdir` claim. Shares the derivation
|
|
715
|
+
* with `missedRunId` (catchup.ts) so a missed-then-caught-up fire and a live fire
|
|
716
|
+
* for the same UTC slot are one record.
|
|
717
|
+
*/
|
|
718
|
+
export declare function slotRunId(scheduledFor: Date | string): string;
|
|
719
|
+
/**
|
|
720
|
+
* Atomically CLAIM a run directory. Returns true on a successful claim, false
|
|
721
|
+
* when the directory already exists (another caller — even in a separate process
|
|
722
|
+
* — owns this (routine, slot) pair). The non-recursive `mkdir` is a single
|
|
723
|
+
* filesystem test-and-set on every POSIX filesystem, the same primitive
|
|
724
|
+
* `claimMissedFire` relies on; it holds across processes where an in-process flag
|
|
725
|
+
* or a released lock cannot.
|
|
726
|
+
*/
|
|
727
|
+
export declare function claimRunSlot(jobName: string, runId: string): boolean;
|
|
632
728
|
/** Discover routine YAML files in a repository's routines/ directory. */
|
|
633
729
|
export declare function discoverJobsFromRepo(repoPath: string): Array<{
|
|
634
730
|
name: string;
|
package/dist/lib/routines.js
CHANGED
|
@@ -11,8 +11,10 @@ import * as path from 'path';
|
|
|
11
11
|
import * as yaml from 'yaml';
|
|
12
12
|
import { Cron } from 'croner';
|
|
13
13
|
import { getRoutinesDir, getSystemRoutinesDir, getRunsDir, ensureAgentsDir, getProjectRoutinesDir } from './state.js';
|
|
14
|
+
import * as os from 'os';
|
|
14
15
|
import { safeJoin, isSafeSegmentName } from './paths.js';
|
|
15
|
-
import { isSafeProjectName } from './projects.js';
|
|
16
|
+
import { isSafeProjectName, loadProjectDef, projectBasePath } from './projects.js';
|
|
17
|
+
import { resolveRoutineExecutionContext, } from './routine-context.js';
|
|
16
18
|
import { atomicWriteFileSync } from './fs-atomic.js';
|
|
17
19
|
import { ALL_AGENT_IDS, ROUTINE_AGENT_IDS } from './agents.js';
|
|
18
20
|
import { machineId, normalizeHost } from './machine-id.js';
|
|
@@ -142,6 +144,65 @@ export function projectGroupOrder(group) {
|
|
|
142
144
|
export function computeProjectGroup(projects, knownProjectNames) {
|
|
143
145
|
return projectGroupTitle(computeProjectGroupKind(projects, knownProjectNames));
|
|
144
146
|
}
|
|
147
|
+
/** A real-filesystem {@link ContextFsProbe} for readiness checks on this machine. */
|
|
148
|
+
export function realFsProbe() {
|
|
149
|
+
return {
|
|
150
|
+
exists: (p) => fs.existsSync(p),
|
|
151
|
+
isDirectory: (p) => { try {
|
|
152
|
+
return fs.statSync(p).isDirectory();
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
return false;
|
|
156
|
+
} },
|
|
157
|
+
isWritable: (p) => { try {
|
|
158
|
+
fs.accessSync(p, fs.constants.W_OK);
|
|
159
|
+
return true;
|
|
160
|
+
}
|
|
161
|
+
catch {
|
|
162
|
+
return false;
|
|
163
|
+
} },
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
/** Classify a routine by its body kind — governs the execution-context fallback rules. */
|
|
167
|
+
export function jobRoutineKind(config) {
|
|
168
|
+
if (config.command)
|
|
169
|
+
return 'command';
|
|
170
|
+
if (config.workflow)
|
|
171
|
+
return 'workflow';
|
|
172
|
+
return 'agent';
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Resolve a routine's execution context (working directory + structural/fs
|
|
176
|
+
* readiness) by bridging its `project`/`cwd` fields into the pure
|
|
177
|
+
* {@link resolveRoutineExecutionContext} resolver. Local placement resolves
|
|
178
|
+
* against this machine's `$HOME` with a real filesystem probe; a caller may
|
|
179
|
+
* inject a different target home / probe (e.g. `null` to defer existence for a
|
|
180
|
+
* remote target).
|
|
181
|
+
*/
|
|
182
|
+
export function resolveJobExecutionContext(config, opts = {}) {
|
|
183
|
+
const targetHome = opts.targetHome ?? os.homedir();
|
|
184
|
+
const mode = opts.mode ?? 'local';
|
|
185
|
+
const probe = opts.probe === null
|
|
186
|
+
? undefined
|
|
187
|
+
: (opts.probe ?? (mode === 'local' ? realFsProbe() : undefined));
|
|
188
|
+
let projectResolution = opts.projectResolution;
|
|
189
|
+
if (config.project !== undefined && projectResolution === undefined) {
|
|
190
|
+
const def = loadProjectDef(config.project);
|
|
191
|
+
projectResolution = def
|
|
192
|
+
? { defined: true, base: projectBasePath(def, true) } // portable (~/) base form
|
|
193
|
+
: { defined: false };
|
|
194
|
+
}
|
|
195
|
+
return resolveRoutineExecutionContext({
|
|
196
|
+
name: config.name,
|
|
197
|
+
project: config.project,
|
|
198
|
+
cwd: config.cwd,
|
|
199
|
+
kind: jobRoutineKind(config),
|
|
200
|
+
mode,
|
|
201
|
+
targetHome,
|
|
202
|
+
projectResolution,
|
|
203
|
+
probe,
|
|
204
|
+
});
|
|
205
|
+
}
|
|
145
206
|
/**
|
|
146
207
|
* Finalize a run record with a terminal status, computing `duration` from
|
|
147
208
|
* `startedAt` and the completion timestamp. Keeps failure-reason population
|
|
@@ -699,6 +760,18 @@ export function validateJob(config) {
|
|
|
699
760
|
if (config.remoteCwd !== undefined && strategy !== 'host' && strategy !== 'fleet') {
|
|
700
761
|
errors.push('remoteCwd only applies to host/fleet-placed routines — set hostStrategy: host|fleet, or drop it');
|
|
701
762
|
}
|
|
763
|
+
if (config.project !== undefined && (typeof config.project !== 'string' || config.project.trim() === '')) {
|
|
764
|
+
errors.push('project (the singular execution anchor) must be a non-empty project name');
|
|
765
|
+
}
|
|
766
|
+
if (config.cwd !== undefined && (typeof config.cwd !== 'string' || config.cwd.trim() === '')) {
|
|
767
|
+
errors.push('cwd (the portable execution directory) must be a non-empty path string');
|
|
768
|
+
}
|
|
769
|
+
// `remoteCwd` is the legacy host-placement path; `cwd` is its canonical
|
|
770
|
+
// replacement. The two split path semantics, so they must never coexist — the
|
|
771
|
+
// one-shot migration folds remoteCwd into cwd, and a conflicting pair pauses.
|
|
772
|
+
if (config.cwd !== undefined && config.remoteCwd !== undefined) {
|
|
773
|
+
errors.push('cwd and remoteCwd both set — remoteCwd is the legacy form of cwd; keep only cwd');
|
|
774
|
+
}
|
|
702
775
|
if (config.source !== undefined) {
|
|
703
776
|
if (!config.source || typeof config.source !== 'object') {
|
|
704
777
|
errors.push('source must be an object');
|
|
@@ -1168,6 +1241,39 @@ export function getJobRunsDir(jobName) {
|
|
|
1168
1241
|
export function getRunDir(jobName, runId) {
|
|
1169
1242
|
return path.join(getJobRunsDir(jobName), runId);
|
|
1170
1243
|
}
|
|
1244
|
+
/**
|
|
1245
|
+
* The run id a scheduled fire is recorded under — derived from its intended UTC
|
|
1246
|
+
* fire time so the SAME slot always maps to the SAME run directory. This is what
|
|
1247
|
+
* makes the single-fire claim meaningful: a duplicate cron delivery for one slot
|
|
1248
|
+
* computes the same id and loses the atomic `mkdir` claim. Shares the derivation
|
|
1249
|
+
* with `missedRunId` (catchup.ts) so a missed-then-caught-up fire and a live fire
|
|
1250
|
+
* for the same UTC slot are one record.
|
|
1251
|
+
*/
|
|
1252
|
+
export function slotRunId(scheduledFor) {
|
|
1253
|
+
const iso = typeof scheduledFor === 'string' ? scheduledFor : scheduledFor.toISOString();
|
|
1254
|
+
return iso.replace(/[:.]/g, '-');
|
|
1255
|
+
}
|
|
1256
|
+
/**
|
|
1257
|
+
* Atomically CLAIM a run directory. Returns true on a successful claim, false
|
|
1258
|
+
* when the directory already exists (another caller — even in a separate process
|
|
1259
|
+
* — owns this (routine, slot) pair). The non-recursive `mkdir` is a single
|
|
1260
|
+
* filesystem test-and-set on every POSIX filesystem, the same primitive
|
|
1261
|
+
* `claimMissedFire` relies on; it holds across processes where an in-process flag
|
|
1262
|
+
* or a released lock cannot.
|
|
1263
|
+
*/
|
|
1264
|
+
export function claimRunSlot(jobName, runId) {
|
|
1265
|
+
const runDir = getRunDir(jobName, runId);
|
|
1266
|
+
fs.mkdirSync(path.dirname(runDir), { recursive: true });
|
|
1267
|
+
try {
|
|
1268
|
+
fs.mkdirSync(runDir); // non-recursive: throws EEXIST if already claimed
|
|
1269
|
+
return true;
|
|
1270
|
+
}
|
|
1271
|
+
catch (err) {
|
|
1272
|
+
if (err.code === 'EEXIST')
|
|
1273
|
+
return false;
|
|
1274
|
+
throw err;
|
|
1275
|
+
}
|
|
1276
|
+
}
|
|
1171
1277
|
/** Discover routine YAML files in a repository's routines/ directory. */
|
|
1172
1278
|
export function discoverJobsFromRepo(repoPath) {
|
|
1173
1279
|
const jobsPath = path.join(repoPath, 'routines');
|
package/dist/lib/runner.d.ts
CHANGED
|
@@ -26,8 +26,22 @@ export interface RunResult {
|
|
|
26
26
|
export declare class RoutineAlreadyRunningError extends Error {
|
|
27
27
|
constructor(jobName: string, runId: string);
|
|
28
28
|
}
|
|
29
|
-
/**
|
|
30
|
-
export
|
|
29
|
+
/** How a routine attempt was triggered, plus the schedule slot it belongs to. */
|
|
30
|
+
export interface RoutineTrigger {
|
|
31
|
+
kind: NonNullable<RunMeta['triggerKind']>;
|
|
32
|
+
/** UTC fire time for a schedule/catchup attempt; keys the single-fire slot claim. */
|
|
33
|
+
scheduledFor?: Date | string;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Working directory for a routine's LOCAL child, resolved from its explicit
|
|
37
|
+
* `project`/`cwd` execution anchor via {@link resolveJobExecutionContext} — never
|
|
38
|
+
* inferred from `repo` (which is external repository identity only) and never the
|
|
39
|
+
* daemon's launch cwd. A command routine with neither field lands in `$HOME`
|
|
40
|
+
* (housekeeping); an unresolved/blocked agent context also falls back to `$HOME`
|
|
41
|
+
* so a caller that reaches this (past the readiness gate) has a valid directory,
|
|
42
|
+
* but the gate should have paused such a routine before it ever spawned.
|
|
43
|
+
*/
|
|
44
|
+
export declare function routineSpawnCwd(config: Pick<JobConfig, 'name' | 'project' | 'cwd' | 'agent' | 'workflow' | 'command'>): string;
|
|
31
45
|
/** Build the full CLI argv for executing a job, applying mode, model, and permission flags. */
|
|
32
46
|
export declare function buildJobCommand(config: JobConfig, resolvedPrompt: string): string[];
|
|
33
47
|
/**
|
|
@@ -86,7 +100,7 @@ export declare function dispatchesViaAgentsRun(config: Pick<JobConfig, 'workflow
|
|
|
86
100
|
* with `agents run`.
|
|
87
101
|
*/
|
|
88
102
|
export declare function buildRoutineSpawnEnv(baseEnv: Record<string, string>, agent: AgentId, version: string | undefined, timezone?: string, overlayHome?: string): Record<string, string>;
|
|
89
|
-
export declare function executeJob(config: JobConfig, deps?: LoopDeps): Promise<RunResult>;
|
|
103
|
+
export declare function executeJob(config: JobConfig, deps?: LoopDeps, trigger?: RoutineTrigger): Promise<RunResult>;
|
|
90
104
|
/**
|
|
91
105
|
* Optional lifecycle callbacks for a detached routine run. The daemon passes an
|
|
92
106
|
* `onFinish` that fires the branded finish/output notification (RUSH-2030) — it
|
|
@@ -99,7 +113,7 @@ export interface RoutineHooks {
|
|
|
99
113
|
onFinish?: (meta: RunMeta) => void;
|
|
100
114
|
}
|
|
101
115
|
/** Spawn a job as a detached process and return immediately with run metadata. */
|
|
102
|
-
export declare function executeJobDetached(config: JobConfig, hooks?: RoutineHooks): Promise<RunMeta>;
|
|
116
|
+
export declare function executeJobDetached(config: JobConfig, hooks?: RoutineHooks, trigger?: RoutineTrigger): Promise<RunMeta>;
|
|
103
117
|
/** Extract the final assistant message from a stream-JSON log file as a markdown report. */
|
|
104
118
|
export declare function extractReport(stdoutPath: string, agentType: AgentId): string | null;
|
|
105
119
|
/** Derive the final status of a detached run by reading the agent's stream-json
|