@phnx-labs/agents-cli 1.20.76 → 1.20.77
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 +162 -0
- package/dist/bin/agents +0 -0
- package/dist/commands/exec.js +58 -16
- package/dist/commands/routines.js +271 -13
- package/dist/commands/secrets.js +59 -17
- package/dist/commands/sessions-browser.js +11 -1
- package/dist/commands/sessions-picker.d.ts +14 -1
- package/dist/commands/sessions-picker.js +168 -15
- package/dist/commands/sessions.d.ts +23 -4
- package/dist/commands/sessions.js +128 -38
- package/dist/commands/ssh.d.ts +13 -0
- package/dist/commands/ssh.js +39 -2
- package/dist/index.js +2 -1
- package/dist/lib/activity.d.ts +5 -4
- package/dist/lib/activity.js +450 -36
- package/dist/lib/cloud/session-index.js +2 -2
- package/dist/lib/daemon.d.ts +10 -1
- package/dist/lib/daemon.js +99 -12
- package/dist/lib/devices/fleet.d.ts +16 -1
- package/dist/lib/devices/fleet.js +10 -2
- package/dist/lib/events.d.ts +1 -1
- package/dist/lib/exec.d.ts +16 -0
- package/dist/lib/exec.js +27 -4
- package/dist/lib/feed.d.ts +7 -1
- package/dist/lib/feed.js +96 -6
- package/dist/lib/heal.d.ts +7 -4
- package/dist/lib/heal.js +10 -22
- package/dist/lib/hosts/dispatch.d.ts +9 -0
- package/dist/lib/hosts/dispatch.js +24 -4
- package/dist/lib/hosts/passthrough.js +7 -2
- package/dist/lib/hosts/remote-cmd.d.ts +11 -0
- package/dist/lib/hosts/remote-cmd.js +31 -8
- package/dist/lib/hosts/remote-session-id.d.ts +45 -0
- package/dist/lib/hosts/remote-session-id.js +84 -0
- package/dist/lib/hosts/run-target.d.ts +4 -0
- package/dist/lib/hosts/run-target.js +5 -1
- package/dist/lib/hosts/session-index.js +3 -3
- package/dist/lib/menubar/install-menubar.d.ts +9 -0
- package/dist/lib/menubar/install-menubar.js +14 -0
- package/dist/lib/menubar/notify-desktop.d.ts +44 -0
- package/dist/lib/menubar/notify-desktop.js +78 -0
- package/dist/lib/overdue.d.ts +4 -5
- package/dist/lib/overdue.js +8 -41
- package/dist/lib/routine-notify.d.ts +76 -0
- package/dist/lib/routine-notify.js +190 -0
- package/dist/lib/routines-placement.d.ts +38 -0
- package/dist/lib/routines-placement.js +79 -0
- package/dist/lib/routines-project.d.ts +97 -0
- package/dist/lib/routines-project.js +349 -0
- package/dist/lib/routines.d.ts +68 -0
- package/dist/lib/routines.js +74 -7
- package/dist/lib/runner.d.ts +12 -2
- package/dist/lib/runner.js +150 -25
- package/dist/lib/sandbox.js +10 -0
- package/dist/lib/secrets/account-token.d.ts +20 -0
- package/dist/lib/secrets/account-token.js +64 -0
- package/dist/lib/secrets/agent.d.ts +9 -4
- package/dist/lib/secrets/agent.js +30 -20
- package/dist/lib/secrets/bundles.d.ts +20 -6
- package/dist/lib/secrets/bundles.js +70 -34
- package/dist/lib/secrets/index.d.ts +11 -2
- package/dist/lib/secrets/index.js +21 -9
- package/dist/lib/secrets/session-store.d.ts +6 -2
- package/dist/lib/secrets/session-store.js +65 -15
- package/dist/lib/secrets/unlock-hints.d.ts +27 -0
- package/dist/lib/secrets/unlock-hints.js +36 -0
- package/dist/lib/session/active.d.ts +10 -0
- package/dist/lib/session/active.js +67 -4
- package/dist/lib/session/db.d.ts +6 -0
- package/dist/lib/session/db.js +83 -6
- package/dist/lib/session/discover.d.ts +13 -1
- package/dist/lib/session/discover.js +91 -3
- package/dist/lib/session/parse.js +11 -2
- package/dist/lib/session/prompt.d.ts +7 -0
- package/dist/lib/session/prompt.js +37 -0
- package/dist/lib/session/provenance.d.ts +7 -0
- package/dist/lib/session/render.js +18 -16
- package/dist/lib/session/state.d.ts +4 -0
- package/dist/lib/session/state.js +93 -11
- package/dist/lib/session/types.d.ts +25 -1
- package/dist/lib/session/types.js +8 -0
- package/dist/lib/state.d.ts +7 -3
- package/dist/lib/state.js +20 -5
- package/dist/lib/types.d.ts +10 -0
- package/package.json +1 -1
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Project-level routine opt-in, source tracking, and user-layer sync.
|
|
3
|
+
*
|
|
4
|
+
* Project YAML under `<project>/.agents/routines/*.yml` is inspection-only by
|
|
5
|
+
* default (a cloned public repo must never auto-fire agent prompts). After an
|
|
6
|
+
* explicit opt-in, routines are materialised into `~/.agents/routines/` with
|
|
7
|
+
* `source:` provenance so the daemon — which only loads user + system layers —
|
|
8
|
+
* can fire them. `syncProjectRoutines` refreshes the user-layer copies when
|
|
9
|
+
* project YAML changes (also invoked on daemon SIGHUP).
|
|
10
|
+
*/
|
|
11
|
+
import * as fs from 'fs';
|
|
12
|
+
import * as os from 'os';
|
|
13
|
+
import * as path from 'path';
|
|
14
|
+
import * as yaml from 'yaml';
|
|
15
|
+
import { execFileSync } from 'child_process';
|
|
16
|
+
import { getProjectAgentsDir, getProjectRoutinesDir, readMeta, updateMeta, ensureAgentsDir, } from './state.js';
|
|
17
|
+
import { readJob, writeJob, deleteJob, listJobs, validateJob, resolveHostStrategy, placementRequiresFiringPin, } from './routines.js';
|
|
18
|
+
import { parseOwnerRepoFromRemote } from './registry.js';
|
|
19
|
+
import { machineId } from './machine-id.js';
|
|
20
|
+
import { isSafeSegmentName } from './paths.js';
|
|
21
|
+
/** Expand `~/…` and resolve to an absolute path. */
|
|
22
|
+
export function expandProjectPath(p) {
|
|
23
|
+
const trimmed = p.trim();
|
|
24
|
+
if (trimmed.startsWith('~/') || trimmed === '~') {
|
|
25
|
+
return path.resolve(trimmed.replace(/^~(?=$|[/\\])/, os.homedir()));
|
|
26
|
+
}
|
|
27
|
+
return path.resolve(trimmed);
|
|
28
|
+
}
|
|
29
|
+
/** Store paths home-relative when under $HOME so they travel across machines. */
|
|
30
|
+
export function displayProjectPath(abs) {
|
|
31
|
+
const home = os.homedir();
|
|
32
|
+
const resolved = path.resolve(abs);
|
|
33
|
+
if (resolved === home)
|
|
34
|
+
return '~';
|
|
35
|
+
if (resolved.startsWith(home + path.sep)) {
|
|
36
|
+
return '~' + resolved.slice(home.length);
|
|
37
|
+
}
|
|
38
|
+
return resolved;
|
|
39
|
+
}
|
|
40
|
+
/** Project roots currently opted into daemon firing (absolute paths). */
|
|
41
|
+
export function listEnabledProjectRoots() {
|
|
42
|
+
const meta = readMeta();
|
|
43
|
+
const raw = meta.routines?.projects ?? [];
|
|
44
|
+
const out = [];
|
|
45
|
+
const seen = new Set();
|
|
46
|
+
for (const p of raw) {
|
|
47
|
+
if (typeof p !== 'string' || p.trim() === '')
|
|
48
|
+
continue;
|
|
49
|
+
const abs = expandProjectPath(p);
|
|
50
|
+
if (seen.has(abs))
|
|
51
|
+
continue;
|
|
52
|
+
seen.add(abs);
|
|
53
|
+
out.push(abs);
|
|
54
|
+
}
|
|
55
|
+
return out;
|
|
56
|
+
}
|
|
57
|
+
/** True when this project root is on the opt-in allowlist. */
|
|
58
|
+
export function isProjectRoutinesEnabled(projectRoot) {
|
|
59
|
+
const abs = expandProjectPath(projectRoot);
|
|
60
|
+
return listEnabledProjectRoots().some((p) => p === abs);
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* True when the project's own `agents.yaml` opts into project routines:
|
|
64
|
+
* `routines: { enable: true }`. This is an additional source of opt-in that
|
|
65
|
+
* still requires the project to be present on disk; it does not auto-enable
|
|
66
|
+
* every clone — the project must declare it, and `sync` / `enable-project`
|
|
67
|
+
* still materialises consent into the user layer.
|
|
68
|
+
*/
|
|
69
|
+
export function projectAgentsYamlEnablesRoutines(projectRoot) {
|
|
70
|
+
const agentsYaml = path.join(expandProjectPath(projectRoot), 'agents.yaml');
|
|
71
|
+
if (!fs.existsSync(agentsYaml))
|
|
72
|
+
return false;
|
|
73
|
+
try {
|
|
74
|
+
const parsed = yaml.parse(fs.readFileSync(agentsYaml, 'utf-8'));
|
|
75
|
+
if (!parsed || typeof parsed !== 'object')
|
|
76
|
+
return false;
|
|
77
|
+
const routines = parsed.routines;
|
|
78
|
+
if (!routines || typeof routines !== 'object')
|
|
79
|
+
return false;
|
|
80
|
+
return routines.enable === true;
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
/** Resolve the project root (parent of `.agents/`) from a cwd, or null. */
|
|
87
|
+
export function resolveProjectRoot(cwd = process.cwd()) {
|
|
88
|
+
const agentsDir = getProjectAgentsDir(cwd);
|
|
89
|
+
if (!agentsDir)
|
|
90
|
+
return null;
|
|
91
|
+
return path.dirname(agentsDir);
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Opt a project root into daemon firing. Returns true when newly added,
|
|
95
|
+
* false when it was already enabled.
|
|
96
|
+
*/
|
|
97
|
+
export function enableProjectRoutines(projectRoot) {
|
|
98
|
+
const abs = expandProjectPath(projectRoot);
|
|
99
|
+
const stored = displayProjectPath(abs);
|
|
100
|
+
let added = false;
|
|
101
|
+
updateMeta((meta) => {
|
|
102
|
+
const projects = [...(meta.routines?.projects ?? [])];
|
|
103
|
+
const already = projects.some((p) => expandProjectPath(p) === abs);
|
|
104
|
+
if (already)
|
|
105
|
+
return meta;
|
|
106
|
+
projects.push(stored);
|
|
107
|
+
added = true;
|
|
108
|
+
return {
|
|
109
|
+
...meta,
|
|
110
|
+
routines: {
|
|
111
|
+
...(meta.routines ?? {}),
|
|
112
|
+
projects,
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
});
|
|
116
|
+
return added;
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Remove a project root from the opt-in allowlist. Optionally deletes the
|
|
120
|
+
* user-layer copies that were materialised from it.
|
|
121
|
+
*/
|
|
122
|
+
export function disableProjectRoutines(projectRoot, opts = {}) {
|
|
123
|
+
const abs = expandProjectPath(projectRoot);
|
|
124
|
+
let removed = false;
|
|
125
|
+
updateMeta((meta) => {
|
|
126
|
+
const projects = meta.routines?.projects ?? [];
|
|
127
|
+
const next = projects.filter((p) => expandProjectPath(p) !== abs);
|
|
128
|
+
if (next.length === projects.length)
|
|
129
|
+
return meta;
|
|
130
|
+
removed = true;
|
|
131
|
+
return {
|
|
132
|
+
...meta,
|
|
133
|
+
routines: {
|
|
134
|
+
...(meta.routines ?? {}),
|
|
135
|
+
projects: next,
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
});
|
|
139
|
+
const deletedJobs = [];
|
|
140
|
+
if (opts.removeSynced) {
|
|
141
|
+
for (const job of listJobs()) {
|
|
142
|
+
if (job.source?.kind === 'project' && expandProjectPath(job.source.projectPath) === abs) {
|
|
143
|
+
if (deleteJob(job.name))
|
|
144
|
+
deletedJobs.push(job.name);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return { removed, deletedJobs };
|
|
149
|
+
}
|
|
150
|
+
/** Git provenance for a project root (best-effort; never throws). */
|
|
151
|
+
export function readProjectGitSource(projectRoot) {
|
|
152
|
+
const abs = expandProjectPath(projectRoot);
|
|
153
|
+
const out = {};
|
|
154
|
+
try {
|
|
155
|
+
const remote = execFileSync('git', ['-C', abs, 'remote', 'get-url', 'origin'], {
|
|
156
|
+
encoding: 'utf-8',
|
|
157
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
158
|
+
timeout: 5_000,
|
|
159
|
+
}).trim();
|
|
160
|
+
const repo = parseOwnerRepoFromRemote(remote);
|
|
161
|
+
if (repo)
|
|
162
|
+
out.repo = repo;
|
|
163
|
+
}
|
|
164
|
+
catch { /* no origin */ }
|
|
165
|
+
try {
|
|
166
|
+
out.branch = execFileSync('git', ['-C', abs, 'rev-parse', '--abbrev-ref', 'HEAD'], {
|
|
167
|
+
encoding: 'utf-8',
|
|
168
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
169
|
+
timeout: 5_000,
|
|
170
|
+
}).trim();
|
|
171
|
+
if (out.branch === 'HEAD')
|
|
172
|
+
delete out.branch; // detached
|
|
173
|
+
}
|
|
174
|
+
catch { /* not a git repo */ }
|
|
175
|
+
try {
|
|
176
|
+
out.commit = execFileSync('git', ['-C', abs, 'rev-parse', '--short', 'HEAD'], {
|
|
177
|
+
encoding: 'utf-8',
|
|
178
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
179
|
+
timeout: 5_000,
|
|
180
|
+
}).trim();
|
|
181
|
+
}
|
|
182
|
+
catch { /* ignore */ }
|
|
183
|
+
return out;
|
|
184
|
+
}
|
|
185
|
+
/** List project routine YAML files (name + absolute path). */
|
|
186
|
+
export function listProjectRoutineFiles(projectRoot) {
|
|
187
|
+
const routinesDir = getProjectRoutinesDir(expandProjectPath(projectRoot));
|
|
188
|
+
if (!routinesDir || !fs.existsSync(routinesDir))
|
|
189
|
+
return [];
|
|
190
|
+
return fs.readdirSync(routinesDir)
|
|
191
|
+
.filter((f) => f.endsWith('.yml') || f.endsWith('.yaml'))
|
|
192
|
+
.map((f) => ({
|
|
193
|
+
name: f.replace(/\.ya?ml$/, ''),
|
|
194
|
+
path: path.join(routinesDir, f),
|
|
195
|
+
}))
|
|
196
|
+
.filter((e) => isSafeSegmentName(e.name));
|
|
197
|
+
}
|
|
198
|
+
function readProjectJobFile(filePath) {
|
|
199
|
+
try {
|
|
200
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
201
|
+
const parsed = yaml.parse(content);
|
|
202
|
+
if (!parsed || typeof parsed !== 'object')
|
|
203
|
+
return null;
|
|
204
|
+
if (Object.prototype.hasOwnProperty.call(parsed, 'device'))
|
|
205
|
+
return null;
|
|
206
|
+
return {
|
|
207
|
+
mode: 'auto',
|
|
208
|
+
effort: 'auto',
|
|
209
|
+
timeout: '10m',
|
|
210
|
+
enabled: true,
|
|
211
|
+
...parsed,
|
|
212
|
+
name: parsed.name || path.basename(filePath).replace(/\.ya?ml$/, ''),
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
catch {
|
|
216
|
+
return null;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* Materialise one project's routines into the user layer.
|
|
221
|
+
* - Overwrites user copies that already carry matching `source.projectPath`
|
|
222
|
+
* - Never clobbers a hand-authored user routine (no source / different source)
|
|
223
|
+
* - Removes user copies from this project whose YAML disappeared
|
|
224
|
+
* - Auto-pins `devices` for host/fleet/cloud placement when unset
|
|
225
|
+
*/
|
|
226
|
+
export function syncProjectRoutines(projectRoot) {
|
|
227
|
+
ensureAgentsDir();
|
|
228
|
+
const abs = expandProjectPath(projectRoot);
|
|
229
|
+
const git = readProjectGitSource(abs);
|
|
230
|
+
const source = {
|
|
231
|
+
kind: 'project',
|
|
232
|
+
projectPath: abs,
|
|
233
|
+
...(git.repo ? { repo: git.repo } : {}),
|
|
234
|
+
...(git.branch ? { branch: git.branch } : {}),
|
|
235
|
+
...(git.commit ? { commit: git.commit } : {}),
|
|
236
|
+
};
|
|
237
|
+
const result = {
|
|
238
|
+
projectRoot: abs,
|
|
239
|
+
synced: [],
|
|
240
|
+
skipped: [],
|
|
241
|
+
removed: [],
|
|
242
|
+
errors: [],
|
|
243
|
+
};
|
|
244
|
+
const files = listProjectRoutineFiles(abs);
|
|
245
|
+
const seenNames = new Set();
|
|
246
|
+
for (const file of files) {
|
|
247
|
+
seenNames.add(file.name);
|
|
248
|
+
const job = readProjectJobFile(file.path);
|
|
249
|
+
if (!job) {
|
|
250
|
+
result.errors.push({ name: file.name, error: 'unreadable or invalid YAML' });
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
const existing = readJob(file.name);
|
|
254
|
+
if (existing) {
|
|
255
|
+
const existingSource = existing.source;
|
|
256
|
+
const fromThisProject = existingSource?.kind === 'project'
|
|
257
|
+
&& expandProjectPath(existingSource.projectPath) === abs;
|
|
258
|
+
if (!fromThisProject) {
|
|
259
|
+
result.skipped.push({
|
|
260
|
+
name: file.name,
|
|
261
|
+
reason: existingSource
|
|
262
|
+
? `user-layer routine already exists from another source (${existingSource.projectPath})`
|
|
263
|
+
: 'user-layer routine already exists (hand-authored); not overwriting without source match',
|
|
264
|
+
});
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
// Preserve user-layer devices pin when project YAML omits it (same overlay
|
|
268
|
+
// semantics as listJobs project discovery).
|
|
269
|
+
if (job.devices === undefined && existing.devices && existing.devices.length > 0) {
|
|
270
|
+
job.devices = existing.devices;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
// Placement that leaves the firing machine must pin devices to avoid
|
|
274
|
+
// every fleet daemon dispatching once.
|
|
275
|
+
const strategy = resolveHostStrategy(job);
|
|
276
|
+
if (placementRequiresFiringPin(strategy) && (!job.devices || job.devices.length === 0)) {
|
|
277
|
+
job.devices = [machineId()];
|
|
278
|
+
}
|
|
279
|
+
// Surface repo for list/display when project has a GitHub origin.
|
|
280
|
+
if (git.repo && !job.repo)
|
|
281
|
+
job.repo = git.repo;
|
|
282
|
+
job.source = source;
|
|
283
|
+
job.name = file.name;
|
|
284
|
+
const errors = validateJob(job);
|
|
285
|
+
if (errors.length > 0) {
|
|
286
|
+
result.errors.push({ name: file.name, error: errors.join('; ') });
|
|
287
|
+
continue;
|
|
288
|
+
}
|
|
289
|
+
try {
|
|
290
|
+
writeJob(job);
|
|
291
|
+
result.synced.push(file.name);
|
|
292
|
+
}
|
|
293
|
+
catch (err) {
|
|
294
|
+
result.errors.push({ name: file.name, error: err.message });
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
// Drop user-layer copies from this project that no longer exist in project YAML.
|
|
298
|
+
for (const job of listJobs()) {
|
|
299
|
+
if (job.source?.kind !== 'project')
|
|
300
|
+
continue;
|
|
301
|
+
if (expandProjectPath(job.source.projectPath) !== abs)
|
|
302
|
+
continue;
|
|
303
|
+
if (seenNames.has(job.name))
|
|
304
|
+
continue;
|
|
305
|
+
if (deleteJob(job.name))
|
|
306
|
+
result.removed.push(job.name);
|
|
307
|
+
}
|
|
308
|
+
return result;
|
|
309
|
+
}
|
|
310
|
+
/**
|
|
311
|
+
* Sync every project root on the user allowlist (`meta.routines.projects`),
|
|
312
|
+
* plus any explicit `extraRoots`. Project `agents.yaml` `routines.enable`
|
|
313
|
+
* alone never opts a path in — enable-project is required.
|
|
314
|
+
*/
|
|
315
|
+
export function syncAllProjectRoutines(opts = {}) {
|
|
316
|
+
const roots = new Set(listEnabledProjectRoots());
|
|
317
|
+
for (const r of opts.extraRoots ?? [])
|
|
318
|
+
roots.add(expandProjectPath(r));
|
|
319
|
+
const projects = [];
|
|
320
|
+
const missing = [];
|
|
321
|
+
for (const root of roots) {
|
|
322
|
+
if (!fs.existsSync(root)) {
|
|
323
|
+
missing.push(root);
|
|
324
|
+
continue;
|
|
325
|
+
}
|
|
326
|
+
projects.push(syncProjectRoutines(root));
|
|
327
|
+
}
|
|
328
|
+
return { projects, missing };
|
|
329
|
+
}
|
|
330
|
+
/**
|
|
331
|
+
* Discover project routines at cwd for the setup UX. Returns null when no
|
|
332
|
+
* project `.agents/routines` dir exists.
|
|
333
|
+
*/
|
|
334
|
+
export function discoverProjectRoutinesAt(cwd = process.cwd()) {
|
|
335
|
+
const projectRoot = resolveProjectRoot(cwd);
|
|
336
|
+
if (!projectRoot)
|
|
337
|
+
return null;
|
|
338
|
+
const routinesDir = getProjectRoutinesDir(projectRoot);
|
|
339
|
+
if (!routinesDir || !fs.existsSync(routinesDir))
|
|
340
|
+
return null;
|
|
341
|
+
const files = listProjectRoutineFiles(projectRoot);
|
|
342
|
+
if (files.length === 0)
|
|
343
|
+
return null;
|
|
344
|
+
return {
|
|
345
|
+
projectRoot,
|
|
346
|
+
files,
|
|
347
|
+
enabled: isProjectRoutinesEnabled(projectRoot) || projectAgentsYamlEnablesRoutines(projectRoot),
|
|
348
|
+
};
|
|
349
|
+
}
|
package/dist/lib/routines.d.ts
CHANGED
|
@@ -14,6 +14,29 @@ export interface JobAllowConfig {
|
|
|
14
14
|
sites?: string[];
|
|
15
15
|
dirs?: string[];
|
|
16
16
|
}
|
|
17
|
+
/**
|
|
18
|
+
* Where a routine's job body executes when the daemon fires it.
|
|
19
|
+
* Distinct from `devices` (which daemon may *fire*) and from the CLI `--host`
|
|
20
|
+
* remote-management passthrough (manage routines *on* another machine).
|
|
21
|
+
*/
|
|
22
|
+
export type HostStrategy = 'local' | 'host' | 'fleet' | 'cloud';
|
|
23
|
+
export declare const HOST_STRATEGIES: readonly HostStrategy[];
|
|
24
|
+
/**
|
|
25
|
+
* Provenance for a routine that was materialised from a project
|
|
26
|
+
* (`.agents/routines/*.yml` synced into the user layer after opt-in).
|
|
27
|
+
*/
|
|
28
|
+
export interface JobSource {
|
|
29
|
+
/** Always `project` today; reserved for future layers. */
|
|
30
|
+
kind: 'project';
|
|
31
|
+
/** Absolute path to the project root that owns the YAML. */
|
|
32
|
+
projectPath: string;
|
|
33
|
+
/** GitHub `owner/repo` when the project has a GitHub origin remote. */
|
|
34
|
+
repo?: string;
|
|
35
|
+
/** Branch at last sync (when known). */
|
|
36
|
+
branch?: string;
|
|
37
|
+
/** Short commit SHA at last sync (when known). */
|
|
38
|
+
commit?: string;
|
|
39
|
+
}
|
|
17
40
|
/** GitHub webhook events a routine can be triggered by. */
|
|
18
41
|
export type GithubTriggerEvent = 'pull_request' | 'push' | 'issue_comment' | 'workflow_run';
|
|
19
42
|
/** Canonical set of accepted GitHub trigger events — single source for validation. */
|
|
@@ -102,10 +125,33 @@ export interface JobConfig {
|
|
|
102
125
|
* `host` says where the dispatched run EXECUTES. CLI flag: `--run-on`
|
|
103
126
|
* (`--host` on routines commands already means "manage routines on that
|
|
104
127
|
* machine" via the remote passthrough).
|
|
128
|
+
*
|
|
129
|
+
* When `hostStrategy` is set, it owns placement semantics; `host` is then
|
|
130
|
+
* only required for `hostStrategy: host` (or when strategy is inferred from
|
|
131
|
+
* a bare `host:` field for back-compat).
|
|
105
132
|
*/
|
|
106
133
|
host?: string;
|
|
134
|
+
/**
|
|
135
|
+
* Where the job body should run when the daemon fires it.
|
|
136
|
+
* - `local` — on the firing machine (default / current behavior)
|
|
137
|
+
* - `host` — on the named `host` over SSH (maps to `--run-on`)
|
|
138
|
+
* - `fleet` — pick one online registered device per run (no cross-device
|
|
139
|
+
* double-fire; the firing pin stays on `devices`)
|
|
140
|
+
* - `cloud` — dispatch via the agent's native cloud provider
|
|
141
|
+
*
|
|
142
|
+
* CLI flag: `--placement` (not `--host`, which is the remote-management
|
|
143
|
+
* passthrough). Omitted strategy falls back to `host` when `host:` is set,
|
|
144
|
+
* otherwise `local`.
|
|
145
|
+
*/
|
|
146
|
+
hostStrategy?: HostStrategy;
|
|
107
147
|
/** Working directory on the host for `host:`-placed runs. */
|
|
108
148
|
remoteCwd?: string;
|
|
149
|
+
/**
|
|
150
|
+
* Provenance for routines materialised from a project
|
|
151
|
+
* (`<project>/.agents/routines/*.yml` → user-layer copy after opt-in).
|
|
152
|
+
* Absent for hand-authored user/system routines.
|
|
153
|
+
*/
|
|
154
|
+
source?: JobSource;
|
|
109
155
|
variables?: Record<string, string>;
|
|
110
156
|
sandbox?: boolean;
|
|
111
157
|
allow?: JobAllowConfig;
|
|
@@ -166,6 +212,10 @@ export interface RunMeta {
|
|
|
166
212
|
/** The host-task sidecar id backing a `host:` run; the daemon monitor
|
|
167
213
|
* finalizes the run by reconciling it against the remote `.exit`. */
|
|
168
214
|
hostTaskId?: string;
|
|
215
|
+
/** Cloud provider task id when `hostStrategy: cloud` dispatched the run. */
|
|
216
|
+
cloudTaskId?: string;
|
|
217
|
+
/** Cloud provider id when the run was cloud-dispatched. */
|
|
218
|
+
cloudProvider?: string;
|
|
169
219
|
}
|
|
170
220
|
/**
|
|
171
221
|
* Finalize a run record with a terminal status, computing `duration` from
|
|
@@ -184,6 +234,24 @@ export declare function finalizeRunMeta(meta: RunMeta, status: RunMeta['status']
|
|
|
184
234
|
* catchup/overdue, manual run) gates on this.
|
|
185
235
|
*/
|
|
186
236
|
export declare function jobRunsOnThisDevice(config: Pick<JobConfig, 'devices'>): boolean;
|
|
237
|
+
/**
|
|
238
|
+
* Resolve the effective host strategy for a job.
|
|
239
|
+
* Bare `host:` without an explicit strategy implies `host` (back-compat with
|
|
240
|
+
* pre-hostStrategy YAML). Otherwise default to `local`.
|
|
241
|
+
*/
|
|
242
|
+
export declare function resolveHostStrategy(config: Pick<JobConfig, 'hostStrategy' | 'host'>): HostStrategy;
|
|
243
|
+
/**
|
|
244
|
+
* Parse a CLI `--placement` value into a HostStrategy, or null when empty.
|
|
245
|
+
* Throws a human-readable Error for unknown values.
|
|
246
|
+
*/
|
|
247
|
+
export declare function parseHostStrategy(raw: string | undefined | null): HostStrategy | null;
|
|
248
|
+
/**
|
|
249
|
+
* Strategies that dispatch the job body off the firing machine. Without a
|
|
250
|
+
* `devices` pin every daemon in the fleet would fire and each would dispatch
|
|
251
|
+
* once — N× duplicate runs. Callers pin to this machine when the user did not
|
|
252
|
+
* set an explicit allowlist.
|
|
253
|
+
*/
|
|
254
|
+
export declare function placementRequiresFiringPin(strategy: HostStrategy): boolean;
|
|
187
255
|
/** Human presentation of a device-affinity mismatch for commands and runner. */
|
|
188
256
|
export interface JobEligibilityResult {
|
|
189
257
|
/** Full human message, e.g. "Job 'NAME' can only run on: a, b". */
|
package/dist/lib/routines.js
CHANGED
|
@@ -15,6 +15,7 @@ import { safeJoin, isSafeSegmentName } from './paths.js';
|
|
|
15
15
|
import { atomicWriteFileSync } from './fs-atomic.js';
|
|
16
16
|
import { ALL_AGENT_IDS } from './agents.js';
|
|
17
17
|
import { machineId, normalizeHost } from './machine-id.js';
|
|
18
|
+
export const HOST_STRATEGIES = ['local', 'host', 'fleet', 'cloud'];
|
|
18
19
|
/** Canonical set of accepted GitHub trigger events — single source for validation. */
|
|
19
20
|
export const GITHUB_TRIGGER_EVENTS = [
|
|
20
21
|
'pull_request',
|
|
@@ -84,6 +85,39 @@ export function jobRunsOnThisDevice(config) {
|
|
|
84
85
|
const self = machineId();
|
|
85
86
|
return config.devices.some((d) => normalizeHost(d) === self);
|
|
86
87
|
}
|
|
88
|
+
/**
|
|
89
|
+
* Resolve the effective host strategy for a job.
|
|
90
|
+
* Bare `host:` without an explicit strategy implies `host` (back-compat with
|
|
91
|
+
* pre-hostStrategy YAML). Otherwise default to `local`.
|
|
92
|
+
*/
|
|
93
|
+
export function resolveHostStrategy(config) {
|
|
94
|
+
if (config.hostStrategy)
|
|
95
|
+
return config.hostStrategy;
|
|
96
|
+
if (config.host)
|
|
97
|
+
return 'host';
|
|
98
|
+
return 'local';
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Parse a CLI `--placement` value into a HostStrategy, or null when empty.
|
|
102
|
+
* Throws a human-readable Error for unknown values.
|
|
103
|
+
*/
|
|
104
|
+
export function parseHostStrategy(raw) {
|
|
105
|
+
if (raw === undefined || raw === null || raw === '')
|
|
106
|
+
return null;
|
|
107
|
+
const v = raw.trim().toLowerCase();
|
|
108
|
+
if (HOST_STRATEGIES.includes(v))
|
|
109
|
+
return v;
|
|
110
|
+
throw new Error(`Invalid placement '${raw}'. Use one of: ${HOST_STRATEGIES.join(', ')}`);
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Strategies that dispatch the job body off the firing machine. Without a
|
|
114
|
+
* `devices` pin every daemon in the fleet would fire and each would dispatch
|
|
115
|
+
* once — N× duplicate runs. Callers pin to this machine when the user did not
|
|
116
|
+
* set an explicit allowlist.
|
|
117
|
+
*/
|
|
118
|
+
export function placementRequiresFiringPin(strategy) {
|
|
119
|
+
return strategy === 'host' || strategy === 'fleet' || strategy === 'cloud';
|
|
120
|
+
}
|
|
87
121
|
/**
|
|
88
122
|
* Return null when the job may run here; otherwise return a structured,
|
|
89
123
|
* human-friendly eligibility failure. Centralizes the message/suggestion
|
|
@@ -419,24 +453,48 @@ export function validateJob(config) {
|
|
|
419
453
|
if (config.device !== undefined) {
|
|
420
454
|
errors.push('singular "device" key is no longer supported — replace with devices: [<name>] (an array)');
|
|
421
455
|
}
|
|
456
|
+
if (config.hostStrategy !== undefined) {
|
|
457
|
+
if (!HOST_STRATEGIES.includes(config.hostStrategy)) {
|
|
458
|
+
errors.push(`hostStrategy must be one of: ${HOST_STRATEGIES.join(', ')}`);
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
const strategy = resolveHostStrategy(config);
|
|
422
462
|
if (config.host !== undefined) {
|
|
423
463
|
if (typeof config.host !== 'string' || config.host.trim() === '') {
|
|
424
464
|
errors.push('host must be a non-empty machine name (a registered host, device, capability tag, or user@host)');
|
|
425
465
|
}
|
|
426
|
-
|
|
427
|
-
|
|
466
|
+
}
|
|
467
|
+
if (strategy === 'host' && (!config.host || config.host.trim() === '')) {
|
|
468
|
+
errors.push("hostStrategy: host requires host: (set via --run-on or host: in YAML)");
|
|
469
|
+
}
|
|
470
|
+
// Remote placement (host/fleet/cloud) can't carry workflow/loop/command yet —
|
|
471
|
+
// those live on the firing machine.
|
|
472
|
+
if (strategy === 'host' || strategy === 'fleet' || strategy === 'cloud') {
|
|
428
473
|
if (config.workflow) {
|
|
429
|
-
errors.push(
|
|
474
|
+
errors.push(`${strategy} placement can't be combined with workflow: yet — run the workflow locally or convert it to a plain prompt`);
|
|
430
475
|
}
|
|
431
476
|
if (config.loop) {
|
|
432
|
-
errors.push(
|
|
477
|
+
errors.push(`${strategy} placement can't be combined with loop: yet (the loop driver and its signal files live on the firing machine)`);
|
|
433
478
|
}
|
|
434
479
|
if (config.command) {
|
|
435
|
-
errors.push(
|
|
480
|
+
errors.push(`${strategy} placement can't be combined with command: yet (a plain shell command has no agent to place remotely)`);
|
|
436
481
|
}
|
|
437
482
|
}
|
|
438
|
-
if (config.remoteCwd !== undefined &&
|
|
439
|
-
errors.push('remoteCwd only applies to host
|
|
483
|
+
if (config.remoteCwd !== undefined && strategy !== 'host' && strategy !== 'fleet') {
|
|
484
|
+
errors.push('remoteCwd only applies to host/fleet-placed routines — set hostStrategy: host|fleet, or drop it');
|
|
485
|
+
}
|
|
486
|
+
if (config.source !== undefined) {
|
|
487
|
+
if (!config.source || typeof config.source !== 'object') {
|
|
488
|
+
errors.push('source must be an object');
|
|
489
|
+
}
|
|
490
|
+
else {
|
|
491
|
+
if (config.source.kind !== 'project') {
|
|
492
|
+
errors.push("source.kind must be 'project'");
|
|
493
|
+
}
|
|
494
|
+
if (typeof config.source.projectPath !== 'string' || config.source.projectPath.trim() === '') {
|
|
495
|
+
errors.push('source.projectPath must be a non-empty absolute path');
|
|
496
|
+
}
|
|
497
|
+
}
|
|
440
498
|
}
|
|
441
499
|
if (config.devices !== undefined) {
|
|
442
500
|
if (!Array.isArray(config.devices)) {
|
|
@@ -451,6 +509,15 @@ export function validateJob(config) {
|
|
|
451
509
|
}
|
|
452
510
|
}
|
|
453
511
|
}
|
|
512
|
+
// Off-box placement without a devices pin fires on every fleet daemon and
|
|
513
|
+
// each dispatches once (RUSH-1980). Enforce the pin at validation so hand
|
|
514
|
+
// edits and devices --clear cannot re-open the hole.
|
|
515
|
+
if (placementRequiresFiringPin(strategy)) {
|
|
516
|
+
if (!config.devices || config.devices.length === 0) {
|
|
517
|
+
errors.push(`hostStrategy: ${strategy} requires devices: [<name>] to pin which daemon may fire ` +
|
|
518
|
+
'(otherwise every fleet daemon dispatches once). Pin to one machine, e.g. devices: [this-host]');
|
|
519
|
+
}
|
|
520
|
+
}
|
|
454
521
|
return errors;
|
|
455
522
|
}
|
|
456
523
|
/** Validate a job trigger block, returning a list of human-readable errors. */
|
package/dist/lib/runner.d.ts
CHANGED
|
@@ -77,9 +77,19 @@ export declare function buildRoutineSpawnEnv(baseEnv: Record<string, string>, ag
|
|
|
77
77
|
* failover across healthy same-agent accounts (RUSH-1016).
|
|
78
78
|
*/
|
|
79
79
|
export declare function executeJob(config: JobConfig, deps?: LoopDeps): Promise<RunResult>;
|
|
80
|
+
/**
|
|
81
|
+
* Optional lifecycle callbacks for a detached routine run. The daemon passes an
|
|
82
|
+
* `onFinish` that fires the branded finish/output notification (RUSH-2030) — it
|
|
83
|
+
* runs from the in-process settle() when the child exits, the only seam that
|
|
84
|
+
* observes the live running→terminal transition (the monitor tick would already
|
|
85
|
+
* see a finalized record and skip it). Never let a hook throw into finalization.
|
|
86
|
+
*/
|
|
87
|
+
export interface RoutineHooks {
|
|
88
|
+
/** Called once with the finalized meta when the run reaches a terminal state. */
|
|
89
|
+
onFinish?: (meta: RunMeta) => void;
|
|
90
|
+
}
|
|
80
91
|
/** Spawn a job as a detached process and return immediately with run metadata. */
|
|
81
|
-
|
|
82
|
-
export declare function executeJobDetached(config: JobConfig): Promise<RunMeta>;
|
|
92
|
+
export declare function executeJobDetached(config: JobConfig, hooks?: RoutineHooks): Promise<RunMeta>;
|
|
83
93
|
/** Extract the final assistant message from a stream-JSON log file as a markdown report. */
|
|
84
94
|
export declare function extractReport(stdoutPath: string, agentType: AgentId): string | null;
|
|
85
95
|
/** Scan all runs marked "running" and finalize any whose process has exited. */
|