@myvillage/cli 1.89.1 → 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
|
@@ -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
|
+
}
|
|
@@ -121,6 +121,19 @@ export async function agentLoop(agentName, { signal }) {
|
|
|
121
121
|
// max_tokens_per_loop and the rate limiter regardless of this ceiling.
|
|
122
122
|
const maxSteps = config.brain?.max_steps_per_loop || 25;
|
|
123
123
|
|
|
124
|
+
// Refresh BEFORE the first MCP connect. Whoever spawned us put a snapshot of
|
|
125
|
+
// MYVILLAGE_ACCESS_TOKEN in our env, and the MCP client bakes it into its
|
|
126
|
+
// Authorization header at construction with no refresh path of its own. The
|
|
127
|
+
// per-iteration self-heal below only runs once we're already in the loop, so
|
|
128
|
+
// without this the very first connect uses a possibly-dead token and comes up
|
|
129
|
+
// with zero tools. Doing it here covers every start path (fleet runner,
|
|
130
|
+
// Studio's local Start, `myvillage agent start`) in one place.
|
|
131
|
+
try {
|
|
132
|
+
if (isTokenExpired()) await refreshAccessToken();
|
|
133
|
+
const startupToken = getAccessToken();
|
|
134
|
+
if (startupToken) process.env.MYVILLAGE_ACCESS_TOKEN = startupToken;
|
|
135
|
+
} catch { /* fall through with whatever we were handed */ }
|
|
136
|
+
|
|
124
137
|
// Initialize MCP tools
|
|
125
138
|
let tools;
|
|
126
139
|
try {
|
|
@@ -183,8 +196,20 @@ export async function agentLoop(agentName, { signal }) {
|
|
|
183
196
|
// 401s until the daemon is restarted. Re-read credentials each loop and
|
|
184
197
|
// swap the MCP client when the token changes.
|
|
185
198
|
try {
|
|
199
|
+
// refreshAccessToken() RETURNS false on failure, it does not throw, so the
|
|
200
|
+
// catch below never fires for a dead refresh token. Check the return value
|
|
201
|
+
// or the whole failure is silent: no mcp_reconnected, no error, just an
|
|
202
|
+
// agent that 401s on every call for the rest of its life.
|
|
186
203
|
if (isTokenExpired()) {
|
|
187
|
-
await refreshAccessToken();
|
|
204
|
+
const refreshed = await refreshAccessToken();
|
|
205
|
+
if (!refreshed) {
|
|
206
|
+
logActivity(agentDir, {
|
|
207
|
+
type: 'error',
|
|
208
|
+
error:
|
|
209
|
+
'Could not refresh the MyVillageOS sign-in on this machine. Tools and the model will keep ' +
|
|
210
|
+
'returning 401 until you run `myvillage login` here.',
|
|
211
|
+
});
|
|
212
|
+
}
|
|
188
213
|
}
|
|
189
214
|
const currentToken = getAccessToken();
|
|
190
215
|
if (currentToken && currentToken !== process.env.MYVILLAGE_ACCESS_TOKEN) {
|
|
@@ -22,14 +22,22 @@ import os from 'os';
|
|
|
22
22
|
import fs from 'fs';
|
|
23
23
|
import path from 'path';
|
|
24
24
|
import { getConfig } from '../utils/config.js';
|
|
25
|
-
import { getAccessToken } from '../utils/auth.js';
|
|
25
|
+
import { getAccessToken, isTokenExpired } from '../utils/auth.js';
|
|
26
|
+
import { refreshAccessToken } from '../utils/api.js';
|
|
26
27
|
import { listLocalAgents, readAgentConfig, getAgentDir, writeAgentConfig } from '../utils/local-agent.js';
|
|
27
28
|
import { startDaemon, stopDaemon, isDaemonRunning, getDaemonInfo } from './daemon.js';
|
|
29
|
+
import { shipLogsOnce, forgetLogCursor } from './log-shipper.js';
|
|
28
30
|
|
|
29
31
|
const RUNNER_STATE_FILE = path.join(os.homedir(), '.myvillage', 'runner.json');
|
|
30
32
|
const DEFAULT_HEARTBEAT_MS = 20_000;
|
|
31
33
|
const LEASE_TTL_SECONDS = 90;
|
|
32
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
|
+
|
|
33
41
|
function log(msg, extra) {
|
|
34
42
|
const line = `[runner] ${new Date().toISOString()} ${msg}`;
|
|
35
43
|
if (extra !== undefined) console.log(line, extra);
|
|
@@ -75,13 +83,29 @@ function makeClient(machineKey, machineId) {
|
|
|
75
83
|
};
|
|
76
84
|
}
|
|
77
85
|
|
|
86
|
+
/**
|
|
87
|
+
* Read the signed-in user's OAuth bearer, refreshing it first when it is stale.
|
|
88
|
+
*
|
|
89
|
+
* Every headless machine needs this. On a desktop, Studio's own API traffic
|
|
90
|
+
* keeps the stored token fresh as a side effect; on a Pi nothing else uses the
|
|
91
|
+
* OAuth credential (the runner itself authenticates with its machine API key),
|
|
92
|
+
* so the token on disk simply expires and stays expired. Handing that dead
|
|
93
|
+
* token to a registration call or to a spawned daemon is a guaranteed 401.
|
|
94
|
+
*
|
|
95
|
+
* Returns null when there is no usable credential at all.
|
|
96
|
+
*/
|
|
97
|
+
async function currentUserToken() {
|
|
98
|
+
if (isTokenExpired()) await refreshAccessToken();
|
|
99
|
+
return getAccessToken();
|
|
100
|
+
}
|
|
101
|
+
|
|
78
102
|
/**
|
|
79
103
|
* One-shot client authenticated as the signed-in *user* (OAuth bearer). Used
|
|
80
104
|
* only to register a machine and receive its minted credential.
|
|
81
105
|
*/
|
|
82
|
-
function makeUserClient() {
|
|
106
|
+
async function makeUserClient() {
|
|
83
107
|
const { networkBaseUrl } = getConfig();
|
|
84
|
-
const token =
|
|
108
|
+
const token = await currentUserToken();
|
|
85
109
|
if (!token) return null;
|
|
86
110
|
return async function cp(pathname, { method = 'GET', body } = {}) {
|
|
87
111
|
const headers = { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` };
|
|
@@ -198,9 +222,13 @@ function indexLocalAgentsByProfileId() {
|
|
|
198
222
|
return map;
|
|
199
223
|
}
|
|
200
224
|
|
|
201
|
-
function startAgent(name, config, machineId, leaseEpoch) {
|
|
202
|
-
|
|
225
|
+
async function startAgent(name, config, machineId, leaseEpoch) {
|
|
226
|
+
// Refresh before snapshotting. The daemon inherits this env and bakes the
|
|
227
|
+
// token into its MCP Authorization header at construction, so a stale value
|
|
228
|
+
// here means the agent comes up with zero tools and no model access.
|
|
229
|
+
const token = await currentUserToken();
|
|
203
230
|
if (token) process.env.MYVILLAGE_ACCESS_TOKEN = token;
|
|
231
|
+
else log(`no usable sign-in on this machine — ${name} will start unauthenticated; run \`myvillage login\``);
|
|
204
232
|
if (config.man?.agent_id) process.env.MYVILLAGE_AGENT_ID = config.man.agent_id;
|
|
205
233
|
if (config.man?.village_agent_id) process.env.MYVILLAGE_VILLAGE_AGENT_ID = config.man.village_agent_id;
|
|
206
234
|
process.env.MYVILLAGE_MACHINE_ID = machineId;
|
|
@@ -262,7 +290,7 @@ async function resolveMachineIdentity(opts) {
|
|
|
262
290
|
return { machineId, machineKey: saved.machineKey };
|
|
263
291
|
}
|
|
264
292
|
|
|
265
|
-
const userClient = makeUserClient();
|
|
293
|
+
const userClient = await makeUserClient();
|
|
266
294
|
if (!userClient) {
|
|
267
295
|
throw new Error(
|
|
268
296
|
'Not signed in. Run `myvillage login` on this machine (or pass --key with a machine:runner API key).',
|
|
@@ -370,7 +398,7 @@ async function reconcileOnce(ctx) {
|
|
|
370
398
|
continue;
|
|
371
399
|
}
|
|
372
400
|
|
|
373
|
-
const pid = startAgent(entry.name, entry.config, ctx.machineId, epoch);
|
|
401
|
+
const pid = await startAgent(entry.name, entry.config, ctx.machineId, epoch);
|
|
374
402
|
ctx.leases.set(a.agentProfileId, { name: entry.name, epoch });
|
|
375
403
|
log(`started ${entry.name} (pid ${pid}, epoch ${epoch})`);
|
|
376
404
|
}
|
|
@@ -381,6 +409,10 @@ async function reconcileOnce(ctx) {
|
|
|
381
409
|
try { stopDaemon(lease.name); } catch { /* best-effort */ }
|
|
382
410
|
await ctx.cp(`/agents/${profileId}/lease`, { method: 'POST', body: { action: 'release' } });
|
|
383
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);
|
|
384
416
|
log(`stopped + released ${lease.name} (no longer assigned here)`);
|
|
385
417
|
}
|
|
386
418
|
}
|
|
@@ -428,6 +460,23 @@ export async function runRunner(opts = {}) {
|
|
|
428
460
|
|
|
429
461
|
const intervalMs = opts.intervalMs || DEFAULT_HEARTBEAT_MS;
|
|
430
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
|
+
|
|
431
480
|
while (!controller.signal.aborted) {
|
|
432
481
|
try {
|
|
433
482
|
await reconcileOnce(ctx);
|
|
@@ -436,5 +485,6 @@ export async function runRunner(opts = {}) {
|
|
|
436
485
|
}
|
|
437
486
|
if (!controller.signal.aborted) await sleep(intervalMs, controller.signal);
|
|
438
487
|
}
|
|
488
|
+
await shipLoop;
|
|
439
489
|
log('runner stopped');
|
|
440
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
|
}
|
|
@@ -771,7 +771,29 @@ export async function agentLogsCommand(name, options = {}) {
|
|
|
771
771
|
console.log('');
|
|
772
772
|
}
|
|
773
773
|
|
|
774
|
+
// Calendar day of the last entry printed, so we only emit a date header when
|
|
775
|
+
// the day actually changes. readAgentLogs walks the per-day files newest-first
|
|
776
|
+
// and keeps accumulating until it hits `limit`, so one invocation routinely
|
|
777
|
+
// spans midnight. Without a header, yesterday's 10:21 and today's 10:21 render
|
|
778
|
+
// identically and there is no way to tell a live agent from a stale log.
|
|
779
|
+
let lastPrintedDay = null;
|
|
780
|
+
|
|
781
|
+
function printDayHeader(ts) {
|
|
782
|
+
const d = new Date(ts);
|
|
783
|
+
if (Number.isNaN(d.getTime())) return;
|
|
784
|
+
const day = d.toLocaleDateString(undefined, {
|
|
785
|
+
weekday: 'short',
|
|
786
|
+
year: 'numeric',
|
|
787
|
+
month: 'short',
|
|
788
|
+
day: 'numeric',
|
|
789
|
+
});
|
|
790
|
+
if (day === lastPrintedDay) return;
|
|
791
|
+
lastPrintedDay = day;
|
|
792
|
+
console.log(` ${chalk.dim(`── ${day} ──`)}`);
|
|
793
|
+
}
|
|
794
|
+
|
|
774
795
|
function printLogEntry(entry) {
|
|
796
|
+
printDayHeader(entry.ts);
|
|
775
797
|
const time = brand.teal(new Date(entry.ts).toLocaleTimeString());
|
|
776
798
|
const type = entry.type || 'info';
|
|
777
799
|
|
package/src/utils/auth.js
CHANGED
|
@@ -100,7 +100,15 @@ export function getAccessToken() {
|
|
|
100
100
|
|
|
101
101
|
export function isTokenExpired() {
|
|
102
102
|
const creds = loadCredentials();
|
|
103
|
-
|
|
103
|
+
// Nothing stored (or nothing decryptable): not "expired", just signed out.
|
|
104
|
+
// Callers treat a null token as unauthenticated on their own.
|
|
105
|
+
if (!creds?.access_token) return false;
|
|
106
|
+
// Fail closed. A credential with no recorded expiry cannot be proven valid,
|
|
107
|
+
// so treat it as expired and let the refresh path replace it — which also
|
|
108
|
+
// writes a real expires_at, so this self-corrects after one call. Returning
|
|
109
|
+
// false here meant such a token was considered valid forever: a long-lived
|
|
110
|
+
// agent daemon would 401 on every request and never once try to refresh.
|
|
111
|
+
if (!creds.expires_at) return true;
|
|
104
112
|
// Consider expired if within 60 seconds of expiry
|
|
105
113
|
return Date.now() >= (creds.expires_at - 60000);
|
|
106
114
|
}
|