@myvillage/cli 2.13.1 → 2.14.0
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/package.json +1 -1
- package/src/agent-runtime/runner.js +7 -62
- package/src/commands/agent-local.js +48 -13
- package/src/commands/runner.js +111 -0
- package/src/index.js +21 -1
- package/src/utils/agent-config-sync.js +79 -0
- package/src/utils/api.js +8 -0
- package/src/utils/runner-service.js +230 -0
package/package.json
CHANGED
|
@@ -24,10 +24,10 @@ import path from 'path';
|
|
|
24
24
|
import { getConfig } from '../utils/config.js';
|
|
25
25
|
import { getAccessToken, isTokenExpired } from '../utils/auth.js';
|
|
26
26
|
import { refreshAccessToken } from '../utils/api.js';
|
|
27
|
-
import { listLocalAgents, readAgentConfig, getAgentDir
|
|
27
|
+
import { listLocalAgents, readAgentConfig, getAgentDir } from '../utils/local-agent.js';
|
|
28
28
|
import { startDaemon, stopDaemon, isDaemonRunning, getDaemonInfo } from './daemon.js';
|
|
29
29
|
import { shipLogsOnce, forgetLogCursor } from './log-shipper.js';
|
|
30
|
-
import {
|
|
30
|
+
import { writePublishedAgentConfig, readSyncedVersion } from '../utils/agent-config-sync.js';
|
|
31
31
|
|
|
32
32
|
const RUNNER_STATE_FILE = path.join(os.homedir(), '.myvillage', 'runner.json');
|
|
33
33
|
const DEFAULT_HEARTBEAT_MS = 20_000;
|
|
@@ -128,20 +128,6 @@ async function request(networkBaseUrl, pathname, method, body, headers) {
|
|
|
128
128
|
|
|
129
129
|
// ── Config sync (materialise an assigned agent from the control plane) ───────
|
|
130
130
|
|
|
131
|
-
// Records which published config version this machine last wrote, so we only
|
|
132
|
-
// re-write files when the definition actually changed.
|
|
133
|
-
function syncedVersionPath(agentDir) {
|
|
134
|
-
return path.join(agentDir, '.config-version');
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
function readSyncedVersion(agentDir) {
|
|
138
|
-
try {
|
|
139
|
-
return Number(fs.readFileSync(syncedVersionPath(agentDir), 'utf8').trim()) || 0;
|
|
140
|
-
} catch {
|
|
141
|
-
return 0;
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
|
|
145
131
|
/**
|
|
146
132
|
* Write an assigned agent's files from its published definition, creating the
|
|
147
133
|
* agent directory if this machine has never run it. Returns true when the agent
|
|
@@ -167,53 +153,12 @@ async function syncAgentConfig(ctx, assignment) {
|
|
|
167
153
|
log(`could not fetch config for ${name} (${res.status}) — leaving local files alone`);
|
|
168
154
|
return exists;
|
|
169
155
|
}
|
|
170
|
-
const
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
}
|
|
176
|
-
if (typeof c.toolsYaml === 'string' && c.toolsYaml) {
|
|
177
|
-
fs.writeFileSync(path.join(agentDir, 'tools.yaml'), c.toolsYaml);
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
// The trained models this agent was given travel with its config.
|
|
181
|
-
if (c.models && typeof c.models === 'object') {
|
|
182
|
-
for (const [slug, entry] of Object.entries(c.models)) {
|
|
183
|
-
const result = writeSyncedModel(slug, entry);
|
|
184
|
-
if (result === 'written') log(`synced model ${slug} for ${name}`);
|
|
185
|
-
else if (result === 'kept-local') log(`kept this machine's own model ${slug} for ${name}`);
|
|
186
|
-
else if (result === 'invalid') log(`skipped model ${slug} for ${name}: its files didn't check out`);
|
|
187
|
-
}
|
|
156
|
+
const { models } = writePublishedAgentConfig(name, { ...res.json.data, version: configVersion });
|
|
157
|
+
for (const { slug, result } of models) {
|
|
158
|
+
if (result === 'written') log(`synced model ${slug} for ${name}`);
|
|
159
|
+
else if (result === 'kept-local') log(`kept this machine's own model ${slug} for ${name}`);
|
|
160
|
+
else if (result === 'invalid') log(`skipped model ${slug} for ${name}: its files didn't check out`);
|
|
188
161
|
}
|
|
189
|
-
|
|
190
|
-
// Compose agent.config.yaml from server-known identity + published schedule
|
|
191
|
-
// and brain, preserving anything already set locally that the control plane
|
|
192
|
-
// does not own.
|
|
193
|
-
let existingCfg = {};
|
|
194
|
-
try { existingCfg = readAgentConfig(name) || {}; } catch { /* first sync */ }
|
|
195
|
-
const cfg = {
|
|
196
|
-
...existingCfg,
|
|
197
|
-
name,
|
|
198
|
-
display_name: c.displayName || existingCfg.display_name || name,
|
|
199
|
-
description: c.description ?? existingCfg.description ?? '',
|
|
200
|
-
man: {
|
|
201
|
-
...(existingCfg.man || {}),
|
|
202
|
-
agent_id: c.agentId,
|
|
203
|
-
...(c.villageAgentId ? { village_agent_id: c.villageAgentId } : {}),
|
|
204
|
-
handle: c.handle || name,
|
|
205
|
-
},
|
|
206
|
-
...(c.schedule ? { schedule: c.schedule } : {}),
|
|
207
|
-
...(c.brain ? { brain: c.brain } : {}),
|
|
208
|
-
};
|
|
209
|
-
writeAgentConfig(name, cfg);
|
|
210
|
-
|
|
211
|
-
// Keep an empty .env so the daemon's loader and 0600 expectations hold, but
|
|
212
|
-
// never overwrite secrets a person put here.
|
|
213
|
-
const envPath = path.join(agentDir, '.env');
|
|
214
|
-
if (!fs.existsSync(envPath)) fs.writeFileSync(envPath, '', { mode: 0o600 });
|
|
215
|
-
|
|
216
|
-
fs.writeFileSync(syncedVersionPath(agentDir), String(configVersion));
|
|
217
162
|
log(`synced ${name} config v${configVersion}${exists ? '' : ' (new on this machine)'}`);
|
|
218
163
|
return true;
|
|
219
164
|
}
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
listCommunities,
|
|
16
16
|
getAgentActivity,
|
|
17
17
|
listMyAgents,
|
|
18
|
+
getAgentPublishedConfig,
|
|
18
19
|
listMyUnlinkedAgentProfiles,
|
|
19
20
|
createVillageAgent as apiCreateVillageAgent,
|
|
20
21
|
listAgentTasks,
|
|
@@ -45,6 +46,7 @@ import {
|
|
|
45
46
|
readAgentLogs,
|
|
46
47
|
getLogFilePath,
|
|
47
48
|
} from '../utils/local-agent.js';
|
|
49
|
+
import { writePublishedAgentConfig } from '../utils/agent-config-sync.js';
|
|
48
50
|
import { scaffoldAgent, TOOL_CATALOG, resolveCatalogEntry } from '../utils/agent-scaffolder.js';
|
|
49
51
|
import { loadAgentRecipe, listAgentRecipes } from '../utils/agent-recipes.js';
|
|
50
52
|
import { formatLocalAgentList, formatLocalAgentStatus } from '../utils/formatters.js';
|
|
@@ -1315,12 +1317,12 @@ export async function agentRememberCommand(name, text, options = {}) {
|
|
|
1315
1317
|
// already has the agent — this command scaffolds the missing local config
|
|
1316
1318
|
// (~/.myvillage/agents/<handle>/) so `agent start` can launch the daemon.
|
|
1317
1319
|
//
|
|
1318
|
-
// What pulls down: the network identity (id, handle, displayName)
|
|
1319
|
-
//
|
|
1320
|
-
//
|
|
1321
|
-
//
|
|
1322
|
-
//
|
|
1323
|
-
//
|
|
1320
|
+
// What pulls down: the network identity (id, handle, displayName), a link to
|
|
1321
|
+
// the existing VillageAgent task queue, and — when the agent has a published
|
|
1322
|
+
// definition (Studio publishes on every save) — its prompt.md, tools.yaml,
|
|
1323
|
+
// schedule, model settings and trained models.
|
|
1324
|
+
// What doesn't: API keys and other .env secrets. Those stay machine-local.
|
|
1325
|
+
// An agent that was never published falls back to the scaffold defaults.
|
|
1324
1326
|
|
|
1325
1327
|
async function fetchMyAgentByHandle(handle) {
|
|
1326
1328
|
const result = await listMyAgents({ includeVillageAgent: true });
|
|
@@ -1365,8 +1367,17 @@ export async function agentPullCommand(handle, options = {}) {
|
|
|
1365
1367
|
return;
|
|
1366
1368
|
}
|
|
1367
1369
|
|
|
1368
|
-
//
|
|
1369
|
-
//
|
|
1370
|
+
// The published definition, if there is one. A failure here is not fatal:
|
|
1371
|
+
// the scaffold defaults still boot.
|
|
1372
|
+
let published = null;
|
|
1373
|
+
try {
|
|
1374
|
+
const c = await getAgentPublishedConfig(profile.id);
|
|
1375
|
+
if (c && c.version > 0) published = c;
|
|
1376
|
+
} catch { /* fall back to defaults */ }
|
|
1377
|
+
|
|
1378
|
+
// Scaffold first so the directory has everything `start` expects (routines/,
|
|
1379
|
+
// logs/, .env); the published definition is then written over the defaults.
|
|
1380
|
+
// Default scaffold: just the always-on MyVillage MCP tool.
|
|
1370
1381
|
const agentDir = getAgentDir(agentName);
|
|
1371
1382
|
try {
|
|
1372
1383
|
scaffoldAgent(agentDir, {
|
|
@@ -1394,6 +1405,16 @@ export async function agentPullCommand(handle, options = {}) {
|
|
|
1394
1405
|
}
|
|
1395
1406
|
writeAgentConfig(agentName, cfg);
|
|
1396
1407
|
|
|
1408
|
+
let models = [];
|
|
1409
|
+
if (published) {
|
|
1410
|
+
try {
|
|
1411
|
+
({ models } = writePublishedAgentConfig(agentName, published));
|
|
1412
|
+
} catch (err) {
|
|
1413
|
+
spinner.warn(`Pulled ${handle}, but its saved setup couldn't be written: ${err.message}`);
|
|
1414
|
+
published = null;
|
|
1415
|
+
}
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1397
1418
|
spinner.succeed(`Pulled ${handle} to ~/.myvillage/agents/${agentName}/`);
|
|
1398
1419
|
|
|
1399
1420
|
console.log('');
|
|
@@ -1404,11 +1425,25 @@ export async function agentPullCommand(handle, options = {}) {
|
|
|
1404
1425
|
} else {
|
|
1405
1426
|
console.log(` - ${chalk.yellow('No VillageAgent shim found — one will be created on first start.')}`);
|
|
1406
1427
|
}
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1428
|
+
if (published) {
|
|
1429
|
+
const brain = published.brain;
|
|
1430
|
+
console.log(` - Saved setup v${published.version}: ${published.promptMd ? 'prompt.md, ' : ''}${published.toolsYaml ? 'tools.yaml, ' : ''}schedule & model settings`);
|
|
1431
|
+
if (brain?.model) console.log(` - Model: ${brain.provider ? `${brain.provider} / ` : ''}${brain.model}`);
|
|
1432
|
+
const written = models.filter(m => m.result === 'written' || m.result === 'kept-local').map(m => m.slug);
|
|
1433
|
+
if (written.length) console.log(` - Trained models: ${written.join(', ')}`);
|
|
1434
|
+
const skipped = models.filter(m => m.result === 'invalid').map(m => m.slug);
|
|
1435
|
+
if (skipped.length) console.log(` - ${chalk.yellow(`Skipped models whose files didn't check out: ${skipped.join(', ')}`)}`);
|
|
1436
|
+
console.log('');
|
|
1437
|
+
console.log(brand.teal(' Not included:'));
|
|
1438
|
+
console.log(` - ${brand.gold('.env')} secrets (API keys etc.) stay on the machine they were set on.`);
|
|
1439
|
+
} else {
|
|
1440
|
+
console.log('');
|
|
1441
|
+
console.log(brand.teal(' No saved setup on the network yet, so these are defaults:'));
|
|
1442
|
+
console.log(` - ${brand.gold('prompt.md')} (system prompt — generic template)`);
|
|
1443
|
+
console.log(` - ${brand.gold('tools.yaml')} (only the MyVillage tool is enabled)`);
|
|
1444
|
+
console.log(` - ${brand.gold('agent.config.yaml')} (anthropic + claude-sonnet by default)`);
|
|
1445
|
+
console.log(` Open the agent in Studio and save once on the original machine to publish it.`);
|
|
1446
|
+
}
|
|
1412
1447
|
console.log('');
|
|
1413
1448
|
console.log(brand.teal(' Next:'));
|
|
1414
1449
|
console.log(` ${brand.gold(`myvillage agent edit ${agentName}`)} Customize prompt / tools`);
|
package/src/commands/runner.js
CHANGED
|
@@ -3,6 +3,15 @@
|
|
|
3
3
|
*/
|
|
4
4
|
import { createRequire } from 'module';
|
|
5
5
|
import { runRunner, readRunnerState } from '../agent-runtime/runner.js';
|
|
6
|
+
import { getAccessToken } from '../utils/auth.js';
|
|
7
|
+
import {
|
|
8
|
+
describeRunnerService,
|
|
9
|
+
stageRunnerService,
|
|
10
|
+
runnerServiceStatus,
|
|
11
|
+
canSudo,
|
|
12
|
+
runWithSudo,
|
|
13
|
+
shellLine,
|
|
14
|
+
} from '../utils/runner-service.js';
|
|
6
15
|
|
|
7
16
|
function getCliVersion() {
|
|
8
17
|
try {
|
|
@@ -37,4 +46,106 @@ export function runnerStatusCommand() {
|
|
|
37
46
|
}
|
|
38
47
|
console.log(`Machine: ${state.name} (${state.machineId})`);
|
|
39
48
|
console.log(`Registered: ${state.registeredAt}`);
|
|
49
|
+
try {
|
|
50
|
+
const spec = describeRunnerService();
|
|
51
|
+
const { installed, running } = runnerServiceStatus(spec);
|
|
52
|
+
const label = !installed ? 'not installed (run `myvillage runner install-service`)' : running ? 'installed, running' : 'installed, not running';
|
|
53
|
+
console.log(`Service: ${label}`);
|
|
54
|
+
if (installed) console.log(`Log: ${spec.logFile}`);
|
|
55
|
+
} catch { /* unsupported platform: nothing to report */ }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function printCommands(commands, spec) {
|
|
59
|
+
for (const [i, argv] of commands.entries()) {
|
|
60
|
+
const note = spec.platform === 'launchd' && argv[1] === 'bootout' && i === 0 ? ' # fine if it says not found' : '';
|
|
61
|
+
console.log(` ${shellLine(argv)}${note}`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function runnerInstallServiceCommand(options = {}) {
|
|
66
|
+
let spec;
|
|
67
|
+
try {
|
|
68
|
+
spec = describeRunnerService({
|
|
69
|
+
name: options.name,
|
|
70
|
+
kind: options.kind,
|
|
71
|
+
capacity: options.capacity,
|
|
72
|
+
interval: options.interval,
|
|
73
|
+
});
|
|
74
|
+
} catch (err) {
|
|
75
|
+
console.error(err.message);
|
|
76
|
+
process.exit(1);
|
|
77
|
+
}
|
|
78
|
+
if (process.getuid?.() === 0) {
|
|
79
|
+
console.error('Run this as the account the runner should run as, not as root. It will show or run the sudo steps itself.');
|
|
80
|
+
process.exit(1);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (options.print) {
|
|
84
|
+
console.log(spec.content);
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// The service can't answer a login prompt, so the machine needs a credential
|
|
89
|
+
// before it boots unattended: a saved runner key, or a signed-in user.
|
|
90
|
+
const state = readRunnerState();
|
|
91
|
+
if (!state?.machineKey && !getAccessToken() && !process.env.MYVILLAGE_MACHINE_KEY) {
|
|
92
|
+
console.error('This machine has no runner credential yet. Run `myvillage login` (or `myvillage runner start` once) first.');
|
|
93
|
+
process.exit(1);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const copy = stageRunnerService(spec);
|
|
97
|
+
console.log(`Runner service for "${spec.user}" (${spec.platform}):`);
|
|
98
|
+
console.log(` service file ${spec.staged} (copy: ${copy})`);
|
|
99
|
+
console.log(` installs to ${spec.target}`);
|
|
100
|
+
console.log(` log ${spec.logFile}`);
|
|
101
|
+
if (state?.pid) {
|
|
102
|
+
console.log('');
|
|
103
|
+
console.log(`Note: a runner is already running here (pid ${state.pid}). Stop it (Ctrl-C) once the service is up,`);
|
|
104
|
+
console.log('or the two will compete for the same agents.');
|
|
105
|
+
}
|
|
106
|
+
console.log('');
|
|
107
|
+
|
|
108
|
+
if (!options.yes && !canSudo()) {
|
|
109
|
+
console.log("This account isn't an admin, so an admin needs to run these once:");
|
|
110
|
+
printCommands(spec.install, spec);
|
|
111
|
+
console.log('');
|
|
112
|
+
console.log('Then check it with `myvillage runner status`.');
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
console.log('Installing (sudo may ask for your password):');
|
|
117
|
+
printCommands(spec.install, spec);
|
|
118
|
+
const res = runWithSudo(spec.install, { ignoreFirstFailure: spec.platform === 'launchd' });
|
|
119
|
+
if (!res.ok) {
|
|
120
|
+
console.error(`\nFailed at: ${res.failed}`);
|
|
121
|
+
process.exit(1);
|
|
122
|
+
}
|
|
123
|
+
const { running } = runnerServiceStatus(spec);
|
|
124
|
+
console.log(running
|
|
125
|
+
? '\nThe runner is installed and running. It will start at boot, before anyone logs in.'
|
|
126
|
+
: `\nInstalled, but it isn't running yet. Check ${spec.logFile}.`);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function runnerUninstallServiceCommand(options = {}) {
|
|
130
|
+
let spec;
|
|
131
|
+
try {
|
|
132
|
+
spec = describeRunnerService();
|
|
133
|
+
} catch (err) {
|
|
134
|
+
console.error(err.message);
|
|
135
|
+
process.exit(1);
|
|
136
|
+
}
|
|
137
|
+
if (!options.yes && !canSudo()) {
|
|
138
|
+
console.log("This account isn't an admin, so an admin needs to run these to remove the runner service:");
|
|
139
|
+
printCommands(spec.uninstall, spec);
|
|
140
|
+
console.log('Agents already running keep running until stopped (`myvillage agent stop <name>`).');
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
console.log('Removing the runner service (sudo may ask for your password):');
|
|
144
|
+
printCommands(spec.uninstall, spec);
|
|
145
|
+
const res = runWithSudo(spec.uninstall);
|
|
146
|
+
if (!res.ok) {
|
|
147
|
+
console.error(`\nFailed at: ${res.failed}`);
|
|
148
|
+
process.exit(1);
|
|
149
|
+
}
|
|
150
|
+
console.log('\nRemoved. Agents already running keep running until stopped (`myvillage agent stop <name>`).');
|
|
40
151
|
}
|
package/src/index.js
CHANGED
|
@@ -82,7 +82,12 @@ import {
|
|
|
82
82
|
agentRememberCommand,
|
|
83
83
|
agentRecipesListCommand,
|
|
84
84
|
} from './commands/agent-local.js';
|
|
85
|
-
import {
|
|
85
|
+
import {
|
|
86
|
+
runnerStartCommand,
|
|
87
|
+
runnerStatusCommand,
|
|
88
|
+
runnerInstallServiceCommand,
|
|
89
|
+
runnerUninstallServiceCommand,
|
|
90
|
+
} from './commands/runner.js';
|
|
86
91
|
import {
|
|
87
92
|
agentRegisterClientCommand,
|
|
88
93
|
agentListClientsCommand,
|
|
@@ -1115,6 +1120,21 @@ export function run() {
|
|
|
1115
1120
|
.command('status')
|
|
1116
1121
|
.description("Show this machine's runner registration")
|
|
1117
1122
|
.action(runnerStatusCommand);
|
|
1123
|
+
runnerCmd
|
|
1124
|
+
.command('install-service')
|
|
1125
|
+
.description('Start the runner at boot as this user (launchd on macOS, systemd on Linux)')
|
|
1126
|
+
.option('--name <name>', 'Machine name (default: the name it registered with)')
|
|
1127
|
+
.option('--kind <kind>', 'Machine kind: laptop|mac|pi|spark|cloud|other')
|
|
1128
|
+
.option('--capacity <n>', 'Soft cap on concurrently-run agents')
|
|
1129
|
+
.option('--interval <sec>', 'Reconcile/heartbeat interval in seconds')
|
|
1130
|
+
.option('--print', 'Only print the service file; install nothing')
|
|
1131
|
+
.option('--yes', 'Run the sudo steps even if this account does not look like an admin')
|
|
1132
|
+
.action(runnerInstallServiceCommand);
|
|
1133
|
+
runnerCmd
|
|
1134
|
+
.command('uninstall-service')
|
|
1135
|
+
.description('Stop the runner service and remove it')
|
|
1136
|
+
.option('--yes', 'Run the sudo steps even if this account does not look like an admin')
|
|
1137
|
+
.action(runnerUninstallServiceCommand);
|
|
1118
1138
|
|
|
1119
1139
|
program.parse();
|
|
1120
1140
|
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// Write an agent's published definition (GET /agents/:id/config) to its local
|
|
2
|
+
// directory. Shared by the fleet runner, which keeps assigned agents in sync,
|
|
3
|
+
// and `agent pull`, which rehydrates an agent on a new machine.
|
|
4
|
+
//
|
|
5
|
+
// `.env` is never written from the server: secrets stay per-machine until they
|
|
6
|
+
// can be envelope-encrypted (see AgentConfig in the ed-platform schema).
|
|
7
|
+
|
|
8
|
+
import fs from 'fs';
|
|
9
|
+
import path from 'path';
|
|
10
|
+
import { readAgentConfig, getAgentDir, writeAgentConfig } from './local-agent.js';
|
|
11
|
+
import { writeSyncedModel } from './agent-models.js';
|
|
12
|
+
|
|
13
|
+
// Records which published config version this machine last wrote, so the
|
|
14
|
+
// runner only re-writes files when the definition actually changed.
|
|
15
|
+
export function syncedVersionPath(agentDir) {
|
|
16
|
+
return path.join(agentDir, '.config-version');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function readSyncedVersion(agentDir) {
|
|
20
|
+
try {
|
|
21
|
+
return Number(fs.readFileSync(syncedVersionPath(agentDir), 'utf8').trim()) || 0;
|
|
22
|
+
} catch {
|
|
23
|
+
return 0;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Materialise a published config `c` into ~/.myvillage/agents/<name>/.
|
|
29
|
+
* Anything already set locally that the control plane does not own is kept.
|
|
30
|
+
* Returns { models: [{ slug, result }] } so callers can report what happened.
|
|
31
|
+
*/
|
|
32
|
+
export function writePublishedAgentConfig(name, c) {
|
|
33
|
+
const agentDir = getAgentDir(name);
|
|
34
|
+
fs.mkdirSync(agentDir, { recursive: true });
|
|
35
|
+
|
|
36
|
+
if (typeof c.promptMd === 'string' && c.promptMd) {
|
|
37
|
+
fs.writeFileSync(path.join(agentDir, 'prompt.md'), c.promptMd);
|
|
38
|
+
}
|
|
39
|
+
if (typeof c.toolsYaml === 'string' && c.toolsYaml) {
|
|
40
|
+
fs.writeFileSync(path.join(agentDir, 'tools.yaml'), c.toolsYaml);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// The trained models this agent was given travel with its config.
|
|
44
|
+
const models = [];
|
|
45
|
+
if (c.models && typeof c.models === 'object') {
|
|
46
|
+
for (const [slug, entry] of Object.entries(c.models)) {
|
|
47
|
+
models.push({ slug, result: writeSyncedModel(slug, entry) });
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Compose agent.config.yaml from server-known identity + published schedule
|
|
52
|
+
// and brain, preserving anything already set locally that the control plane
|
|
53
|
+
// does not own.
|
|
54
|
+
let existingCfg = {};
|
|
55
|
+
try { existingCfg = readAgentConfig(name) || {}; } catch { /* first sync */ }
|
|
56
|
+
const cfg = {
|
|
57
|
+
...existingCfg,
|
|
58
|
+
name,
|
|
59
|
+
display_name: c.displayName || existingCfg.display_name || name,
|
|
60
|
+
description: c.description ?? existingCfg.description ?? '',
|
|
61
|
+
man: {
|
|
62
|
+
...(existingCfg.man || {}),
|
|
63
|
+
agent_id: c.agentId,
|
|
64
|
+
...(c.villageAgentId ? { village_agent_id: c.villageAgentId } : {}),
|
|
65
|
+
handle: c.handle || name,
|
|
66
|
+
},
|
|
67
|
+
...(c.schedule ? { schedule: c.schedule } : {}),
|
|
68
|
+
...(c.brain ? { brain: c.brain } : {}),
|
|
69
|
+
};
|
|
70
|
+
writeAgentConfig(name, cfg);
|
|
71
|
+
|
|
72
|
+
// Keep an empty .env so the daemon's loader and 0600 expectations hold, but
|
|
73
|
+
// never overwrite secrets a person put here.
|
|
74
|
+
const envPath = path.join(agentDir, '.env');
|
|
75
|
+
if (!fs.existsSync(envPath)) fs.writeFileSync(envPath, '', { mode: 0o600 });
|
|
76
|
+
|
|
77
|
+
if (c.version) fs.writeFileSync(syncedVersionPath(agentDir), String(c.version));
|
|
78
|
+
return { models };
|
|
79
|
+
}
|
package/src/utils/api.js
CHANGED
|
@@ -302,6 +302,14 @@ export async function listMyAgents(options = {}) {
|
|
|
302
302
|
return response.data;
|
|
303
303
|
}
|
|
304
304
|
|
|
305
|
+
// The agent's published definition (prompt, tools, schedule, model settings,
|
|
306
|
+
// trained models). Studio republishes it on every save.
|
|
307
|
+
export async function getAgentPublishedConfig(id) {
|
|
308
|
+
const client = getNetworkClient();
|
|
309
|
+
const response = await client.get(`/agents/${id}/config`);
|
|
310
|
+
return response.data?.data ?? response.data;
|
|
311
|
+
}
|
|
312
|
+
|
|
305
313
|
export async function getAgent(id) {
|
|
306
314
|
const client = getNetworkClient();
|
|
307
315
|
const response = await client.get(`/agents/${id}`);
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
// Install the fleet runner as a boot-time service, so a machine keeps running
|
|
2
|
+
// its assigned agents after a reboot or power cut with nobody logged in.
|
|
3
|
+
//
|
|
4
|
+
// macOS → a LaunchDaemon in /Library/LaunchDaemons that runs as this user
|
|
5
|
+
// Linux → a systemd unit in /etc/systemd/system that runs as this user
|
|
6
|
+
//
|
|
7
|
+
// Both are system-level on purpose: a per-user LaunchAgent or user unit only
|
|
8
|
+
// runs while someone is logged in. Installing one needs an admin, and the
|
|
9
|
+
// account that runs agents usually isn't one, so the service file is staged
|
|
10
|
+
// somewhere an admin can read it and the exact sudo commands are printed (or
|
|
11
|
+
// run, when this account can sudo).
|
|
12
|
+
|
|
13
|
+
import fs from 'fs';
|
|
14
|
+
import os from 'os';
|
|
15
|
+
import path from 'path';
|
|
16
|
+
import { spawnSync } from 'child_process';
|
|
17
|
+
import { fileURLToPath } from 'url';
|
|
18
|
+
|
|
19
|
+
const LOG_FILE = path.join(os.homedir(), '.myvillage', 'logs', 'runner.log');
|
|
20
|
+
|
|
21
|
+
// The CLI's own entry point, resolved inside the installed package so it keeps
|
|
22
|
+
// working across `npm update -g` (the package directory doesn't move).
|
|
23
|
+
function cliEntryPath() {
|
|
24
|
+
return fs.realpathSync(fileURLToPath(new URL('../../bin/myvillage.js', import.meta.url)));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Prefer a stable `node` on PATH over process.execPath when they are the same
|
|
28
|
+
// binary: Homebrew's execPath points into a versioned Cellar dir that vanishes
|
|
29
|
+
// on the next `brew upgrade`, while /opt/homebrew/bin/node follows it.
|
|
30
|
+
function stableNodePath() {
|
|
31
|
+
const exec = fs.realpathSync(process.execPath);
|
|
32
|
+
for (const dir of (process.env.PATH || '').split(path.delimiter)) {
|
|
33
|
+
if (!dir) continue;
|
|
34
|
+
const candidate = path.join(dir, 'node');
|
|
35
|
+
try {
|
|
36
|
+
if (fs.realpathSync(candidate) === exec) return candidate;
|
|
37
|
+
} catch { /* not here */ }
|
|
38
|
+
}
|
|
39
|
+
return process.execPath;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function servicePath(nodePath) {
|
|
43
|
+
const dirs = [path.dirname(nodePath)];
|
|
44
|
+
if (process.platform === 'darwin') dirs.push('/opt/homebrew/bin', '/opt/homebrew/sbin');
|
|
45
|
+
dirs.push('/usr/local/bin', '/usr/bin', '/bin', '/usr/sbin', '/sbin');
|
|
46
|
+
return [...new Set(dirs)].join(':');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function runnerArgs(opts) {
|
|
50
|
+
const args = ['runner', 'start'];
|
|
51
|
+
if (opts.name) args.push('--name', String(opts.name));
|
|
52
|
+
if (opts.kind) args.push('--kind', String(opts.kind));
|
|
53
|
+
if (opts.capacity) args.push('--capacity', String(opts.capacity));
|
|
54
|
+
if (opts.interval) args.push('--interval', String(opts.interval));
|
|
55
|
+
return args;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const xml = (s) => String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
59
|
+
|
|
60
|
+
// systemd splits ExecStart on whitespace; quote anything that needs it.
|
|
61
|
+
const sdQuote = (s) => (/^[A-Za-z0-9_@%+=:,./-]+$/.test(s) ? s : `"${String(s).replace(/(["\\])/g, '\\$1')}"`);
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Describe the service for this machine and user. Pure apart from resolving
|
|
65
|
+
* paths, so it can be inspected (`--print`) before anything is installed.
|
|
66
|
+
*/
|
|
67
|
+
export function describeRunnerService(opts = {}) {
|
|
68
|
+
const user = os.userInfo().username;
|
|
69
|
+
const home = os.homedir();
|
|
70
|
+
const nodePath = stableNodePath();
|
|
71
|
+
const cliPath = cliEntryPath();
|
|
72
|
+
const program = [nodePath, cliPath, ...runnerArgs(opts)];
|
|
73
|
+
const env = { HOME: home, USER: user, LOGNAME: user, PATH: servicePath(nodePath), LANG: 'en_US.UTF-8' };
|
|
74
|
+
|
|
75
|
+
if (process.platform === 'darwin') {
|
|
76
|
+
const label = `ai.myvillage.runner.${user}`;
|
|
77
|
+
const target = `/Library/LaunchDaemons/${label}.plist`;
|
|
78
|
+
const content = `<?xml version="1.0" encoding="UTF-8"?>
|
|
79
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
80
|
+
<plist version="1.0">
|
|
81
|
+
<dict>
|
|
82
|
+
<key>Label</key>
|
|
83
|
+
<string>${xml(label)}</string>
|
|
84
|
+
<key>UserName</key>
|
|
85
|
+
<string>${xml(user)}</string>
|
|
86
|
+
<key>ProgramArguments</key>
|
|
87
|
+
<array>
|
|
88
|
+
${program.map((a) => `\t\t<string>${xml(a)}</string>`).join('\n')}
|
|
89
|
+
</array>
|
|
90
|
+
<key>WorkingDirectory</key>
|
|
91
|
+
<string>${xml(home)}</string>
|
|
92
|
+
<key>EnvironmentVariables</key>
|
|
93
|
+
<dict>
|
|
94
|
+
${Object.entries(env).map(([k, v]) => `\t\t<key>${xml(k)}</key>\n\t\t<string>${xml(v)}</string>`).join('\n')}
|
|
95
|
+
</dict>
|
|
96
|
+
<key>RunAtLoad</key>
|
|
97
|
+
<true/>
|
|
98
|
+
<key>KeepAlive</key>
|
|
99
|
+
<true/>
|
|
100
|
+
<key>ThrottleInterval</key>
|
|
101
|
+
<integer>10</integer>
|
|
102
|
+
<!-- Agent daemons outlive a runner restart; the runner re-adopts them. -->
|
|
103
|
+
<key>AbandonProcessGroup</key>
|
|
104
|
+
<true/>
|
|
105
|
+
<key>StandardOutPath</key>
|
|
106
|
+
<string>${xml(LOG_FILE)}</string>
|
|
107
|
+
<key>StandardErrorPath</key>
|
|
108
|
+
<string>${xml(LOG_FILE)}</string>
|
|
109
|
+
</dict>
|
|
110
|
+
</plist>
|
|
111
|
+
`;
|
|
112
|
+
const staged = `/Users/Shared/${label}.plist`;
|
|
113
|
+
return {
|
|
114
|
+
platform: 'launchd',
|
|
115
|
+
user,
|
|
116
|
+
label,
|
|
117
|
+
target,
|
|
118
|
+
staged,
|
|
119
|
+
content,
|
|
120
|
+
logFile: LOG_FILE,
|
|
121
|
+
install: [
|
|
122
|
+
['launchctl', 'bootout', `system/${label}`], // ignore failure: not loaded yet
|
|
123
|
+
['install', '-m', '644', '-o', 'root', '-g', 'wheel', staged, target],
|
|
124
|
+
['launchctl', 'bootstrap', 'system', target],
|
|
125
|
+
],
|
|
126
|
+
uninstall: [
|
|
127
|
+
['launchctl', 'bootout', `system/${label}`],
|
|
128
|
+
['rm', '-f', target],
|
|
129
|
+
],
|
|
130
|
+
statusCommand: ['launchctl', 'print', `system/${label}`],
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (process.platform === 'linux') {
|
|
135
|
+
const unit = `myvillage-runner-${user}.service`;
|
|
136
|
+
const target = `/etc/systemd/system/${unit}`;
|
|
137
|
+
const content = `[Unit]
|
|
138
|
+
Description=MyVillage fleet runner (${user})
|
|
139
|
+
After=network-online.target
|
|
140
|
+
Wants=network-online.target
|
|
141
|
+
|
|
142
|
+
[Service]
|
|
143
|
+
Type=simple
|
|
144
|
+
User=${user}
|
|
145
|
+
WorkingDirectory=${home}
|
|
146
|
+
${Object.entries(env).map(([k, v]) => `Environment=${sdQuote(`${k}=${v}`)}`).join('\n')}
|
|
147
|
+
ExecStart=${program.map(sdQuote).join(' ')}
|
|
148
|
+
Restart=always
|
|
149
|
+
RestartSec=10
|
|
150
|
+
# Agent daemons outlive a runner restart; the runner re-adopts them.
|
|
151
|
+
KillMode=process
|
|
152
|
+
StandardOutput=append:${LOG_FILE}
|
|
153
|
+
StandardError=append:${LOG_FILE}
|
|
154
|
+
|
|
155
|
+
[Install]
|
|
156
|
+
WantedBy=multi-user.target
|
|
157
|
+
`;
|
|
158
|
+
const staged = path.join('/tmp', unit);
|
|
159
|
+
return {
|
|
160
|
+
platform: 'systemd',
|
|
161
|
+
user,
|
|
162
|
+
label: unit,
|
|
163
|
+
target,
|
|
164
|
+
staged,
|
|
165
|
+
content,
|
|
166
|
+
logFile: LOG_FILE,
|
|
167
|
+
install: [
|
|
168
|
+
['install', '-m', '644', '-o', 'root', '-g', 'root', staged, target],
|
|
169
|
+
['systemctl', 'daemon-reload'],
|
|
170
|
+
['systemctl', 'enable', unit],
|
|
171
|
+
['systemctl', 'restart', unit],
|
|
172
|
+
],
|
|
173
|
+
uninstall: [
|
|
174
|
+
['systemctl', 'disable', '--now', unit],
|
|
175
|
+
['rm', '-f', target],
|
|
176
|
+
['systemctl', 'daemon-reload'],
|
|
177
|
+
],
|
|
178
|
+
statusCommand: ['systemctl', 'is-active', unit],
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
throw new Error(`Installing the runner as a service isn't supported on ${process.platform} yet (macOS and Linux only).`);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Write the service file where an admin (including another account) can read it. */
|
|
186
|
+
export function stageRunnerService(spec) {
|
|
187
|
+
fs.mkdirSync(path.dirname(spec.logFile), { recursive: true });
|
|
188
|
+
fs.writeFileSync(spec.staged, spec.content, { mode: 0o644 });
|
|
189
|
+
fs.chmodSync(spec.staged, 0o644);
|
|
190
|
+
// Keep a private copy next to the runner's state for reference.
|
|
191
|
+
const copy = path.join(os.homedir(), '.myvillage', 'service', path.basename(spec.target));
|
|
192
|
+
fs.mkdirSync(path.dirname(copy), { recursive: true });
|
|
193
|
+
fs.writeFileSync(copy, spec.content, { mode: 0o644 });
|
|
194
|
+
return copy;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export const shellLine = (argv) => `sudo ${argv.map((a) => (/^[A-Za-z0-9_@%+=:,./-]+$/.test(a) ? a : `'${a.replace(/'/g, `'\\''`)}'`)).join(' ')}`;
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Can this account run admin commands? macOS: member of `admin`. Linux: member
|
|
201
|
+
* of `sudo` or `wheel`, or root. A guess, not a guarantee; sudo still decides.
|
|
202
|
+
*/
|
|
203
|
+
export function canSudo() {
|
|
204
|
+
if (process.getuid?.() === 0) return true;
|
|
205
|
+
const res = spawnSync('id', ['-Gn'], { encoding: 'utf8' });
|
|
206
|
+
const groups = (res.stdout || '').trim().split(/\s+/);
|
|
207
|
+
return groups.includes(process.platform === 'darwin' ? 'admin' : 'sudo') || groups.includes('wheel');
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Run each command under sudo with the terminal attached (sudo may prompt). */
|
|
211
|
+
export function runWithSudo(commands, { ignoreFirstFailure = false } = {}) {
|
|
212
|
+
for (const [i, argv] of commands.entries()) {
|
|
213
|
+
const res = spawnSync('sudo', argv, { stdio: 'inherit' });
|
|
214
|
+
if (res.status !== 0 && !(ignoreFirstFailure && i === 0)) {
|
|
215
|
+
return { ok: false, failed: shellLine(argv) };
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
return { ok: true };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Installed and running? Never needs sudo. */
|
|
222
|
+
export function runnerServiceStatus(spec) {
|
|
223
|
+
const installed = fs.existsSync(spec.target);
|
|
224
|
+
const res = spawnSync(spec.statusCommand[0], spec.statusCommand.slice(1), { encoding: 'utf8' });
|
|
225
|
+
const out = `${res.stdout || ''}${res.stderr || ''}`;
|
|
226
|
+
const running = spec.platform === 'launchd'
|
|
227
|
+
? res.status === 0 && /\bstate = running\b/.test(out)
|
|
228
|
+
: res.status === 0 && out.trim() === 'active';
|
|
229
|
+
return { installed, running };
|
|
230
|
+
}
|