@myvillage/cli 1.89.2 → 1.90.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@myvillage/cli",
3
- "version": "1.89.2",
3
+ "version": "1.90.0",
4
4
  "description": "MyVillageOS CLI for community developers",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,350 @@
1
+ // ── Agent log shipper ────────────────────────────────────────────────────────
2
+ // Ships a rolling tail of each leased agent's logs to the control plane, so an
3
+ // agent running on this machine can be read from Studio on another one without
4
+ // SSH.
5
+ //
6
+ // Design notes worth knowing before editing:
7
+ //
8
+ // * Recent, not complete. Only the newest lines are shipped and the server
9
+ // keeps a bounded window per agent. Full history stays here on disk and is
10
+ // read with `myvillage agent logs`. This is what makes the whole thing scale
11
+ // with fleet size instead of uptime.
12
+ //
13
+ // * Batched per machine. One request carries every leased agent's delta, so
14
+ // request rate tracks machine count, not agent count.
15
+ //
16
+ // * Never on the heartbeat. Lease renewal is correctness-critical; log volume
17
+ // must not be able to slow it down or fail it.
18
+ //
19
+ // * Secrets are masked HERE, before anything leaves the box. Tool arguments
20
+ // and results do ship (they are the point), but .env values and the OAuth
21
+ // token never do.
22
+ import fs from 'fs';
23
+ import path from 'path';
24
+ import os from 'os';
25
+ import { getAgentDir } from '../utils/local-agent.js';
26
+ import { getAccessToken } from '../utils/auth.js';
27
+
28
+ const CURSOR_FILE = path.join(os.homedir(), '.myvillage', 'log-cursors.json');
29
+
30
+ /** Per-stream ship caps. Must not exceed the server's retention, or we would be
31
+ * uploading lines it is about to delete. */
32
+ const SHIP_CAP = { ACTIVITY: 300, STDERR: 100 };
33
+
34
+ /** Hard ceiling on one request across all agents; mirrors the server's cap. */
35
+ const MAX_LINES_PER_PUSH = 2000;
36
+
37
+ /** Shortest .env value we will mask. Below this you get false positives that
38
+ * black out ordinary words — a var set to "true" or a family name. */
39
+ const MIN_SECRET_LEN = 8;
40
+
41
+ const DATE_LOG_RE = /^\d{4}-\d{2}-\d{2}\.jsonl$/;
42
+
43
+ // ── Cursor persistence ───────────────────────────────────────────────────────
44
+ // Keyed `${agentProfileId}:${stream}` → { file, offset }. Persisted so a runner
45
+ // restart resumes mid-file instead of re-shipping a tail we already sent.
46
+
47
+ function readCursors() {
48
+ try {
49
+ return JSON.parse(fs.readFileSync(CURSOR_FILE, 'utf-8'));
50
+ } catch {
51
+ return {};
52
+ }
53
+ }
54
+
55
+ function writeCursors(cursors) {
56
+ try {
57
+ fs.mkdirSync(path.dirname(CURSOR_FILE), { recursive: true });
58
+ fs.writeFileSync(CURSOR_FILE, JSON.stringify(cursors, null, 2), { mode: 0o600 });
59
+ } catch {
60
+ /* best-effort: a lost cursor costs one duplicated tail, not correctness */
61
+ }
62
+ }
63
+
64
+ const key = (agentProfileId, stream) => `${agentProfileId}:${stream}`;
65
+
66
+ // ── Redaction ────────────────────────────────────────────────────────────────
67
+
68
+ /**
69
+ * Literal secrets for one agent: its own .env values plus this machine's OAuth
70
+ * token. Values only — keys stay readable, so `GITHUB_TOKEN=••••` still tells
71
+ * you which credential is in play.
72
+ */
73
+ function secretsForAgent(agentName) {
74
+ const out = [];
75
+ try {
76
+ const envPath = path.join(getAgentDir(agentName), '.env');
77
+ const text = fs.readFileSync(envPath, 'utf-8');
78
+ for (const line of text.split('\n')) {
79
+ const trimmed = line.trim();
80
+ if (!trimmed || trimmed.startsWith('#')) continue;
81
+ const eq = trimmed.indexOf('=');
82
+ if (eq <= 0) continue;
83
+ let value = trimmed.slice(eq + 1).trim();
84
+ // Strip matching surrounding quotes so we mask the value, not the quoting.
85
+ if (value.length >= 2 && /^(".*"|'.*')$/s.test(value)) value = value.slice(1, -1);
86
+ if (value.length >= MIN_SECRET_LEN) out.push(value);
87
+ }
88
+ } catch {
89
+ /* no .env, or unreadable — nothing agent-specific to mask */
90
+ }
91
+ try {
92
+ const token = getAccessToken();
93
+ if (token && token.length >= MIN_SECRET_LEN) out.push(token);
94
+ } catch {
95
+ /* not signed in */
96
+ }
97
+ // Longest first, so a value containing another is masked whole.
98
+ return out.sort((a, b) => b.length - a.length);
99
+ }
100
+
101
+ /** Shapes that look like credentials even when we have never seen the value —
102
+ * a token echoed back by a third-party API, for instance. */
103
+ const SECRET_PATTERNS = [
104
+ /\b(sk|pk|rk)-[A-Za-z0-9_-]{16,}\b/g,
105
+ /\bgh[pousr]_[A-Za-z0-9]{16,}\b/g,
106
+ /\bAKIA[0-9A-Z]{12,}\b/g,
107
+ /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/g,
108
+ /\bey[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g, // JWT
109
+ /\b[Bb]earer\s+[A-Za-z0-9._~+/-]{20,}=*/g,
110
+ ];
111
+
112
+ function escapeRegExp(s) {
113
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
114
+ }
115
+
116
+ /**
117
+ * Mask every known secret and credential-shaped token in a line.
118
+ *
119
+ * Runs on every shipped line, so it must be cheap and must never throw — a
120
+ * redaction bug that crashes the shipper would take log visibility down, and one
121
+ * that silently returns the input would leak. On failure we drop the line rather
122
+ * than ship it unmasked.
123
+ */
124
+ export function redactSecrets(line, secrets) {
125
+ let out = line;
126
+ for (const secret of secrets) {
127
+ if (!secret) continue;
128
+ out = out.split(secret).join('••••');
129
+ }
130
+ for (const re of SECRET_PATTERNS) out = out.replace(re, '••••');
131
+ return out;
132
+ }
133
+
134
+ // ── Tailing ──────────────────────────────────────────────────────────────────
135
+
136
+ /** The daemon writes `logs/<YYYY-MM-DD>.jsonl` and rolls over at UTC midnight.
137
+ * ISO names sort chronologically, so lexical max is the current file. */
138
+ function latestActivityFile(logsDir) {
139
+ try {
140
+ const names = fs.readdirSync(logsDir).filter((n) => DATE_LOG_RE.test(n));
141
+ if (names.length === 0) return null;
142
+ names.sort();
143
+ return names[names.length - 1];
144
+ } catch {
145
+ return null;
146
+ }
147
+ }
148
+
149
+ function sourceFileFor(agentName, stream) {
150
+ const logsDir = path.join(getAgentDir(agentName), 'logs');
151
+ if (stream === 'STDERR') {
152
+ const f = path.join(logsDir, 'daemon-stderr.log');
153
+ return fs.existsSync(f) ? { dir: logsDir, name: 'daemon-stderr.log', full: f } : null;
154
+ }
155
+ const name = latestActivityFile(logsDir);
156
+ return name ? { dir: logsDir, name, full: path.join(logsDir, name) } : null;
157
+ }
158
+
159
+ /**
160
+ * Read the lines this agent has produced since our cursor.
161
+ *
162
+ * Resets to the tail (not the top) whenever continuity is broken: a new day's
163
+ * file, a truncated file, or no cursor at all. That is the backlog cap — a
164
+ * machine that was offline for a day ships its recent tail, never a day of
165
+ * backlog, so a fleet reconnecting after an outage cannot stampede the endpoint.
166
+ */
167
+ function readNewLines(agentName, stream, cursor) {
168
+ const src = sourceFileFor(agentName, stream);
169
+ if (!src) return null;
170
+
171
+ let size;
172
+ try {
173
+ size = fs.statSync(src.full).size;
174
+ } catch {
175
+ return null;
176
+ }
177
+
178
+ const continuous =
179
+ cursor && cursor.file === src.name && typeof cursor.offset === 'number' && cursor.offset <= size;
180
+ const start = continuous ? cursor.offset : 0;
181
+ if (continuous && start === size) return null; // nothing new
182
+
183
+ let text = '';
184
+ const len = size - start;
185
+ if (len > 0) {
186
+ let fd;
187
+ try {
188
+ fd = fs.openSync(src.full, 'r');
189
+ const buf = Buffer.alloc(len);
190
+ fs.readSync(fd, buf, 0, len, start);
191
+ text = buf.toString('utf8');
192
+ } catch {
193
+ return null;
194
+ } finally {
195
+ if (fd !== undefined) {
196
+ try { fs.closeSync(fd); } catch { /* ignore */ }
197
+ }
198
+ }
199
+ }
200
+
201
+ let lines = text.split('\n').filter((l) => l.trim().length > 0);
202
+ const cap = SHIP_CAP[stream];
203
+ // On a discontinuity we may be looking at a whole file; keep only the tail.
204
+ if (lines.length > cap) lines = lines.slice(-cap);
205
+
206
+ return { lines, file: src.name, offset: size };
207
+ }
208
+
209
+ /** Pull the line's own timestamp out of the JSONL, so a backlog shipped after a
210
+ * reconnect keeps its real times instead of collapsing onto arrival. */
211
+ function timestampOf(payload) {
212
+ if (payload.charCodeAt(0) !== 123 /* { */) return null;
213
+ try {
214
+ const obj = JSON.parse(payload);
215
+ const ts = obj.ts ?? obj.timestamp ?? obj.time;
216
+ return typeof ts === 'string' ? ts : null;
217
+ } catch {
218
+ return null;
219
+ }
220
+ }
221
+
222
+ // ── Shipper ──────────────────────────────────────────────────────────────────
223
+
224
+ /**
225
+ * Ship one round for every agent this runner holds a lease on.
226
+ *
227
+ * `leases` is the runner's Map of agentProfileId → { name }. Only lease holders
228
+ * may ship, which the server enforces too — the same lease that guarantees
229
+ * single-active execution guarantees a single writer per agent's log.
230
+ *
231
+ * Bootstrap: on the first round after start we send a zero-line batch to learn
232
+ * the server's cursor. If it says this machine wrote it, we adopt the offset and
233
+ * carry on mid-file; otherwise we start from the tail. That is what keeps a
234
+ * runner restart from re-shipping (and thus duplicating) the last few hundred
235
+ * lines it had already sent.
236
+ */
237
+ export async function shipLogsOnce(ctx) {
238
+ if (!ctx.leases || ctx.leases.size === 0) return { pushed: 0 };
239
+
240
+ const cursors = readCursors();
241
+ const bootstrap = !ctx.logShipperBootstrapped;
242
+ const batches = [];
243
+ let budget = MAX_LINES_PER_PUSH;
244
+
245
+ for (const [agentProfileId, lease] of ctx.leases) {
246
+ let secrets = null;
247
+ for (const stream of ['ACTIVITY', 'STDERR']) {
248
+ if (budget <= 0) break;
249
+ const k = key(agentProfileId, stream);
250
+
251
+ // First round: ask for the cursor without shipping anything.
252
+ if (bootstrap && !cursors[k]) {
253
+ batches.push({ agentProfileId, stream, lines: [] });
254
+ continue;
255
+ }
256
+
257
+ const found = readNewLines(lease.name, stream, cursors[k]);
258
+ if (!found || found.lines.length === 0) continue;
259
+
260
+ if (secrets === null) secrets = secretsForAgent(lease.name);
261
+
262
+ const lines = [];
263
+ for (const raw of found.lines) {
264
+ if (budget <= 0) break;
265
+ let payload;
266
+ try {
267
+ payload = redactSecrets(raw, secrets);
268
+ } catch {
269
+ continue; // never ship a line we failed to mask
270
+ }
271
+ lines.push({ payload, ts: timestampOf(raw) });
272
+ budget--;
273
+ }
274
+ if (lines.length === 0) continue;
275
+
276
+ batches.push({
277
+ agentProfileId,
278
+ stream,
279
+ lines,
280
+ sourceFile: found.file,
281
+ sourceOffset: found.offset,
282
+ });
283
+ }
284
+ }
285
+
286
+ if (batches.length === 0) {
287
+ ctx.logShipperBootstrapped = true;
288
+ return { pushed: 0 };
289
+ }
290
+
291
+ const res = await ctx.cp(`/machines/${ctx.machineId}/logs`, {
292
+ method: 'POST',
293
+ body: { agents: batches },
294
+ });
295
+
296
+ // Leave cursors untouched on failure so the next round retries the same
297
+ // range. Re-sending is safe; losing lines is not.
298
+ if (!res.ok) return { pushed: 0, error: res.json?.error || res.status };
299
+
300
+ // Advance our cursors only for batches the server accepted.
301
+ const rejected = new Set(
302
+ (res.json?.data?.cursors || [])
303
+ .filter((c) => c && c.rejected)
304
+ .map((c) => key(c.agentProfileId, c.stream)),
305
+ );
306
+
307
+ let pushed = 0;
308
+ for (const b of batches) {
309
+ const k = key(b.agentProfileId, b.stream);
310
+ if (rejected.has(k)) continue;
311
+ if (b.sourceFile) {
312
+ cursors[k] = { file: b.sourceFile, offset: b.sourceOffset };
313
+ pushed += b.lines.length;
314
+ }
315
+ }
316
+
317
+ // Adopt the server's position for bootstrap batches, but only where this
318
+ // machine is the one that wrote it — another machine's byte offset means
319
+ // nothing against our filesystem.
320
+ if (bootstrap) {
321
+ for (const c of res.json?.data?.cursors || []) {
322
+ if (!c || c.rejected) continue;
323
+ const k = key(c.agentProfileId, c.stream);
324
+ if (cursors[k]) continue;
325
+ if (c.lastMachineId === ctx.machineId && c.lastFile) {
326
+ cursors[k] = { file: c.lastFile, offset: c.lastOffset ?? 0 };
327
+ }
328
+ }
329
+ ctx.logShipperBootstrapped = true;
330
+ }
331
+
332
+ writeCursors(cursors);
333
+ return { pushed };
334
+ }
335
+
336
+ /** Forget an agent's cursors — used when a lease is released, so if the agent
337
+ * comes back here later we re-establish position rather than trusting an offset
338
+ * into a file another machine has since been writing. */
339
+ export function forgetLogCursor(agentProfileId) {
340
+ const cursors = readCursors();
341
+ let changed = false;
342
+ for (const stream of ['ACTIVITY', 'STDERR']) {
343
+ const k = key(agentProfileId, stream);
344
+ if (cursors[k]) {
345
+ delete cursors[k];
346
+ changed = true;
347
+ }
348
+ }
349
+ if (changed) writeCursors(cursors);
350
+ }
@@ -26,11 +26,18 @@ import { getAccessToken, isTokenExpired } from '../utils/auth.js';
26
26
  import { refreshAccessToken } from '../utils/api.js';
27
27
  import { listLocalAgents, readAgentConfig, getAgentDir, writeAgentConfig } from '../utils/local-agent.js';
28
28
  import { startDaemon, stopDaemon, isDaemonRunning, getDaemonInfo } from './daemon.js';
29
+ import { shipLogsOnce, forgetLogCursor } from './log-shipper.js';
29
30
 
30
31
  const RUNNER_STATE_FILE = path.join(os.homedir(), '.myvillage', 'runner.json');
31
32
  const DEFAULT_HEARTBEAT_MS = 20_000;
32
33
  const LEASE_TTL_SECONDS = 90;
33
34
 
35
+ // Log shipping runs on its own clock, faster than reconcile so a remote tail
36
+ // feels live, and in its own loop so a slow or failing log push can never delay
37
+ // a heartbeat. Lease renewal rides the heartbeat, and single-active correctness
38
+ // depends on it — logs must never be able to hold it up.
39
+ const LOG_SHIP_MS = 5_000;
40
+
34
41
  function log(msg, extra) {
35
42
  const line = `[runner] ${new Date().toISOString()} ${msg}`;
36
43
  if (extra !== undefined) console.log(line, extra);
@@ -402,6 +409,10 @@ async function reconcileOnce(ctx) {
402
409
  try { stopDaemon(lease.name); } catch { /* best-effort */ }
403
410
  await ctx.cp(`/agents/${profileId}/lease`, { method: 'POST', body: { action: 'release' } });
404
411
  ctx.leases.delete(profileId);
412
+ // Drop our log position too: if this agent is placed here again later,
413
+ // another machine will have been writing its tail in the meantime, so our
414
+ // old byte offset would be meaningless.
415
+ forgetLogCursor(profileId);
405
416
  log(`stopped + released ${lease.name} (no longer assigned here)`);
406
417
  }
407
418
  }
@@ -449,6 +460,23 @@ export async function runRunner(opts = {}) {
449
460
 
450
461
  const intervalMs = opts.intervalMs || DEFAULT_HEARTBEAT_MS;
451
462
  log(`reconcile loop every ${Math.round(intervalMs / 1000)}s (lease TTL ${LEASE_TTL_SECONDS}s)`);
463
+
464
+ // Independent loop, deliberately not awaited by the reconcile loop below.
465
+ // A log push that hangs or errors must not delay lease renewal.
466
+ const shipLoop = (async () => {
467
+ while (!controller.signal.aborted) {
468
+ await sleep(LOG_SHIP_MS, controller.signal);
469
+ if (controller.signal.aborted) break;
470
+ try {
471
+ await shipLogsOnce(ctx);
472
+ } catch (err) {
473
+ // Logs are best-effort. Cursors only advance on a confirmed write, so a
474
+ // failed round is retried next tick rather than losing lines.
475
+ log('log ship error', err?.message || err);
476
+ }
477
+ }
478
+ })();
479
+
452
480
  while (!controller.signal.aborted) {
453
481
  try {
454
482
  await reconcileOnce(ctx);
@@ -457,5 +485,6 @@ export async function runRunner(opts = {}) {
457
485
  }
458
486
  if (!controller.signal.aborted) await sleep(intervalMs, controller.signal);
459
487
  }
488
+ await shipLoop;
460
489
  log('runner stopped');
461
490
  }
@@ -46,8 +46,23 @@ export function isWithinActiveHours(schedule) {
46
46
  return currentTime >= startTime || currentTime <= endTime;
47
47
  }
48
48
 
49
+ /**
50
+ * Floor on how often an agent may loop, in minutes.
51
+ *
52
+ * These agents act on human-paced events — a knowledge submission, a activity
53
+ * completion, an email — so anything under a minute is burning tokens and log
54
+ * volume against a world that has not changed. It also protects the fleet: log
55
+ * shipping and MVT spend both scale with agents × loops-per-hour, so a single
56
+ * misconfigured 15-second agent multiplied across a large fleet is what turns a
57
+ * comfortable load into an expensive one.
58
+ *
59
+ * Not an error, deliberately. An existing agent set faster than this keeps
60
+ * running, just at the floor, rather than failing to start.
61
+ */
62
+ export const MIN_CHECK_IN_MINUTES = 1;
63
+
49
64
  export function getNextCheckInMs(schedule) {
50
65
  const interval = schedule?.check_in_interval;
51
66
  if (!interval || interval <= 0) return 0; // manual only
52
- return interval * 60 * 1000;
67
+ return Math.max(interval, MIN_CHECK_IN_MINUTES) * 60 * 1000;
53
68
  }