agent-lookout 0.2.1 → 0.2.3
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 +23 -0
- package/PRIVACY.md +60 -16
- package/README.md +12 -10
- package/SECURITY.md +7 -5
- package/bin/agent-lookout.mjs +49 -24
- package/dist/assets/HistoryPanel-DMnhIoFB.js +1 -0
- package/dist/assets/SessionPanel-BNm9wCzf.js +1 -0
- package/dist/assets/index-Ckda8OV4.js +2 -0
- package/dist/assets/index-DFR77qn5.css +1 -0
- package/dist/cli/agent-lookout.js +4 -4
- package/dist/cli/{chunk-UYGESSPG.js → chunk-4C34YX6S.js} +1 -1
- package/dist/cli/{chunk-XJM5RZNV.js → chunk-54ODUY75.js} +1 -1
- package/dist/cli/{chunk-PQ3B5TN6.js → chunk-GOX5UB5L.js} +4 -0
- package/dist/cli/{mcpServer-CGLIGL4X.js → mcpServer-QQFQR6DC.js} +3 -3
- package/dist/cli/{startCommand-ZV2NQ6YG.js → startCommand-H6O35VYR.js} +1769 -202
- package/dist/index.html +2 -2
- package/docs/API.md +176 -61
- package/docs/GUIDE.md +93 -14
- package/package.json +2 -2
- package/dist/assets/HistoryPanel-Llcz5XPi.js +0 -1
- package/dist/assets/SessionPanel-B4g_6Unu.js +0 -1
- package/dist/assets/index-DRcy1hT5.js +0 -2
- package/dist/assets/index-Dz4q_OYZ.css +0 -1
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import {
|
|
2
2
|
sortSessions
|
|
3
|
-
} from "./chunk-
|
|
3
|
+
} from "./chunk-54ODUY75.js";
|
|
4
4
|
import {
|
|
5
|
+
EVENT_KINDS,
|
|
6
|
+
EVENT_SEVERITIES,
|
|
5
7
|
MAX_NAME_LENGTH,
|
|
6
8
|
MAX_NOTICE_TEXT_LENGTH,
|
|
9
|
+
SESSION_STATUSES,
|
|
7
10
|
agentName,
|
|
8
11
|
changeNotice,
|
|
9
12
|
clean,
|
|
@@ -21,7 +24,7 @@ import {
|
|
|
21
24
|
waitingPhrase,
|
|
22
25
|
waitingText,
|
|
23
26
|
withoutWaitingText
|
|
24
|
-
} from "./chunk-
|
|
27
|
+
} from "./chunk-GOX5UB5L.js";
|
|
25
28
|
|
|
26
29
|
// src/collector/hosts/server.ts
|
|
27
30
|
import { createServer } from "node:http";
|
|
@@ -158,6 +161,7 @@ function sessionChanges(memory, snapshot) {
|
|
|
158
161
|
}
|
|
159
162
|
|
|
160
163
|
// src/core/api.ts
|
|
164
|
+
var CLEAR_HISTORY_ACTION = "clear-history";
|
|
161
165
|
var TERMINAL_JUMP_TIMEOUT_MS = 6e4;
|
|
162
166
|
var JUMP_INTERVAL_MS = 1e3;
|
|
163
167
|
var ACTION_HEADER = "X-Agent-Lookout-Action";
|
|
@@ -212,12 +216,12 @@ function waitBegan(statusSince, seenAt) {
|
|
|
212
216
|
|
|
213
217
|
// src/collector/outbound/outboundChannel.ts
|
|
214
218
|
function createOutboundChannel(options) {
|
|
215
|
-
const { afterMs, waitMessage, overMessage, failure: failure2 } = options;
|
|
219
|
+
const { afterMs, asking, waitMessage, overMessage, failure: failure2 } = options;
|
|
216
220
|
const now = options.now ?? Date.now;
|
|
217
221
|
const wanted = new Set(options.events);
|
|
218
222
|
let memory = EMPTY_CHANGE_MEMORY;
|
|
219
223
|
const over = [];
|
|
220
|
-
const
|
|
224
|
+
const open4 = /* @__PURE__ */ new Map();
|
|
221
225
|
const done = /* @__PURE__ */ new Map();
|
|
222
226
|
let sentAt = [];
|
|
223
227
|
let last = null;
|
|
@@ -238,16 +242,21 @@ function createOutboundChannel(options) {
|
|
|
238
242
|
const at = now();
|
|
239
243
|
const result = sessionChanges(memory, snapshot);
|
|
240
244
|
memory = result.memory;
|
|
241
|
-
for (const id of result.stopped)
|
|
245
|
+
for (const id of result.stopped) open4.delete(id);
|
|
242
246
|
for (const { event, session } of result.changes) {
|
|
243
247
|
if (!wanted.has(event)) continue;
|
|
244
248
|
if (event !== "needs-you") {
|
|
245
|
-
over.push({
|
|
249
|
+
over.push({
|
|
250
|
+
event,
|
|
251
|
+
session: withoutWaitingText(session),
|
|
252
|
+
agent: agentName(session, snapshot.sources),
|
|
253
|
+
seenAt: at
|
|
254
|
+
});
|
|
246
255
|
continue;
|
|
247
256
|
}
|
|
248
257
|
const since = session.statusSince;
|
|
249
258
|
if (since !== null && done.get(session.id) === since) continue;
|
|
250
|
-
|
|
259
|
+
open4.set(session.id, waitBegan(since, at));
|
|
251
260
|
}
|
|
252
261
|
sentAt = sendsInLastHour(sentAt, at);
|
|
253
262
|
while (over.length > 0 && limitLiftsAt(sentAt, at) === null) {
|
|
@@ -255,19 +264,26 @@ function createOutboundChannel(options) {
|
|
|
255
264
|
sentAt.push(at);
|
|
256
265
|
send2(overMessage({ ...facts, now: at }));
|
|
257
266
|
}
|
|
258
|
-
for (const [id, begunAt] of
|
|
267
|
+
for (const [id, begunAt] of open4) {
|
|
259
268
|
const session = snapshot.sessions.find(
|
|
260
269
|
(candidate) => candidate.id === id && candidate.status === "needs-you"
|
|
261
270
|
);
|
|
262
271
|
if (!session || sendTiming(begunAt, afterMs, sentAt, at) !== "send") continue;
|
|
263
|
-
|
|
272
|
+
open4.delete(id);
|
|
264
273
|
sentAt.push(at);
|
|
265
274
|
send2(
|
|
266
|
-
waitMessage({
|
|
275
|
+
waitMessage({
|
|
276
|
+
session: withoutWaitingText(session),
|
|
277
|
+
agent: agentName(session, snapshot.sources),
|
|
278
|
+
// Read now, from this poll's snapshot, and only when it is to go.
|
|
279
|
+
asking: asking ? waitingText(session.waitingText) ?? null : null,
|
|
280
|
+
begunAt,
|
|
281
|
+
now: at
|
|
282
|
+
})
|
|
267
283
|
);
|
|
268
284
|
}
|
|
269
285
|
for (const session of snapshot.sessions) {
|
|
270
|
-
if (session.status === "needs-you" && session.statusSince !== null && !
|
|
286
|
+
if (session.status === "needs-you" && session.statusSince !== null && !open4.has(session.id)) {
|
|
271
287
|
done.set(session.id, session.statusSince);
|
|
272
288
|
}
|
|
273
289
|
}
|
|
@@ -310,6 +326,13 @@ function readEvents(name, value) {
|
|
|
310
326
|
}
|
|
311
327
|
return events;
|
|
312
328
|
}
|
|
329
|
+
function readAsking(name, value) {
|
|
330
|
+
if (value === void 0) return false;
|
|
331
|
+
const said = value.toLowerCase();
|
|
332
|
+
if (said === "on") return true;
|
|
333
|
+
if (said === "off") return false;
|
|
334
|
+
throw new SettingProblem(`${name} must be on or off.`);
|
|
335
|
+
}
|
|
313
336
|
|
|
314
337
|
// src/collector/email/emailSettings.ts
|
|
315
338
|
var EMAIL_TO_ENV = "AGENT_LOOKOUT_EMAIL_TO";
|
|
@@ -317,6 +340,7 @@ var SMTP_URL_ENV = "AGENT_LOOKOUT_SMTP_URL";
|
|
|
317
340
|
var EMAIL_FROM_ENV = "AGENT_LOOKOUT_EMAIL_FROM";
|
|
318
341
|
var EMAIL_AFTER_ENV = "AGENT_LOOKOUT_EMAIL_AFTER";
|
|
319
342
|
var EMAIL_EVENTS_ENV = "AGENT_LOOKOUT_EMAIL_EVENTS";
|
|
343
|
+
var EMAIL_ASKING_ENV = "AGENT_LOOKOUT_EMAIL_ASKING";
|
|
320
344
|
var ADDRESS = /^[A-Za-z0-9.!#$%&'*+/=?^_`{|}~-]+@[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?)+$/;
|
|
321
345
|
var MAX_ADDRESS_LENGTH = 254;
|
|
322
346
|
var HOST_NAME = /^[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)*$/;
|
|
@@ -375,6 +399,7 @@ function readEmailSetup(env) {
|
|
|
375
399
|
const from = valueOf(env, EMAIL_FROM_ENV);
|
|
376
400
|
const after = valueOf(env, EMAIL_AFTER_ENV);
|
|
377
401
|
const events = valueOf(env, EMAIL_EVENTS_ENV);
|
|
402
|
+
const asking = valueOf(env, EMAIL_ASKING_ENV);
|
|
378
403
|
if (to === void 0) return { on: false, problem: null };
|
|
379
404
|
if (url === void 0) return { on: false, problem: `${SMTP_URL_ENV} is not set.` };
|
|
380
405
|
try {
|
|
@@ -385,6 +410,7 @@ function readEmailSetup(env) {
|
|
|
385
410
|
from: from === void 0 ? to : readAddress(EMAIL_FROM_ENV, from),
|
|
386
411
|
events: readEvents(EMAIL_EVENTS_ENV, events),
|
|
387
412
|
afterMs: readAfter(EMAIL_AFTER_ENV, after),
|
|
413
|
+
asking: readAsking(EMAIL_ASKING_ENV, asking),
|
|
388
414
|
server: readServer(url)
|
|
389
415
|
}
|
|
390
416
|
};
|
|
@@ -402,8 +428,10 @@ function maskAddress(address) {
|
|
|
402
428
|
}
|
|
403
429
|
|
|
404
430
|
// src/collector/email/emailMessage.ts
|
|
405
|
-
function emailOf(happened, when, session, agentName2) {
|
|
431
|
+
function emailOf(happened, when, session, agentName2, asking = null) {
|
|
406
432
|
const lines = [`${happened}.`, "", when, ""];
|
|
433
|
+
const said = waitingText(asking);
|
|
434
|
+
if (said) lines.push(`Asking: ${said}`);
|
|
407
435
|
const folder = session.project ? oneLine(session.project) : "";
|
|
408
436
|
if (folder) lines.push(`Folder: ${folder}`);
|
|
409
437
|
const app = surfaceLabel(session.surface);
|
|
@@ -423,7 +451,8 @@ function waitEmail(facts) {
|
|
|
423
451
|
`${oneLine(sessionTitle(session))} ${waitingPhrase(session)}`,
|
|
424
452
|
`It has waited ${durationInWords(now - begunAt)}, since ${clockAt(begunAt, now)}.`,
|
|
425
453
|
session,
|
|
426
|
-
facts.agent
|
|
454
|
+
facts.agent,
|
|
455
|
+
facts.asking
|
|
427
456
|
);
|
|
428
457
|
}
|
|
429
458
|
function overEmail(facts) {
|
|
@@ -443,6 +472,7 @@ function emailOffStatus(problem) {
|
|
|
443
472
|
to: null,
|
|
444
473
|
events: null,
|
|
445
474
|
afterMs: null,
|
|
475
|
+
asking: null,
|
|
446
476
|
problem,
|
|
447
477
|
last: null,
|
|
448
478
|
limitedUntil: null
|
|
@@ -453,6 +483,7 @@ function createEmailNotifications(options) {
|
|
|
453
483
|
const channel = createOutboundChannel({
|
|
454
484
|
events: settings.events,
|
|
455
485
|
afterMs: settings.afterMs,
|
|
486
|
+
asking: settings.asking,
|
|
456
487
|
waitMessage: waitEmail,
|
|
457
488
|
overMessage: overEmail,
|
|
458
489
|
send: (content) => sender.send(content),
|
|
@@ -467,6 +498,7 @@ function createEmailNotifications(options) {
|
|
|
467
498
|
to: maskAddress(settings.to),
|
|
468
499
|
events: [...settings.events],
|
|
469
500
|
afterMs: settings.afterMs,
|
|
501
|
+
asking: settings.asking,
|
|
470
502
|
problem: null,
|
|
471
503
|
last: channel.last(),
|
|
472
504
|
limitedUntil: channel.limitedUntil()
|
|
@@ -476,6 +508,207 @@ function createEmailNotifications(options) {
|
|
|
476
508
|
};
|
|
477
509
|
}
|
|
478
510
|
|
|
511
|
+
// src/collector/history/historyFormat.ts
|
|
512
|
+
var HISTORY_FORMAT_VERSION = 1;
|
|
513
|
+
var MAX_LINE_LENGTH = 8 * 1024;
|
|
514
|
+
var MAX_FIELD_LENGTH = 1024;
|
|
515
|
+
var DAY_MS = 24 * 60 * 60 * 1e3;
|
|
516
|
+
var FILE_NAME = /^v([1-9]\d{0,3})-(\d{4})-(\d{2})-(\d{2})(?:-([1-9]\d{0,3}))?\.jsonl$/;
|
|
517
|
+
function dayOf(at) {
|
|
518
|
+
return new Date(at).toISOString().slice(0, 10);
|
|
519
|
+
}
|
|
520
|
+
function historyFileName(day, part = 1) {
|
|
521
|
+
const suffix = part > 1 ? `-${part}` : "";
|
|
522
|
+
return `v${HISTORY_FORMAT_VERSION}-${day}${suffix}.jsonl`;
|
|
523
|
+
}
|
|
524
|
+
function readHistoryFileName(name) {
|
|
525
|
+
const match = FILE_NAME.exec(name);
|
|
526
|
+
if (!match) return null;
|
|
527
|
+
const [, version, year, month, date, part] = match;
|
|
528
|
+
const day = `${year}-${month}-${date}`;
|
|
529
|
+
const dayStart = Date.parse(`${day}T00:00:00.000Z`);
|
|
530
|
+
if (!Number.isFinite(dayStart) || dayOf(dayStart) !== day) return null;
|
|
531
|
+
return { name, version: Number(version), day, dayStart, part: part ? Number(part) : 1 };
|
|
532
|
+
}
|
|
533
|
+
function byFileOrder(a, b) {
|
|
534
|
+
return a.dayStart - b.dayStart || a.part - b.part;
|
|
535
|
+
}
|
|
536
|
+
function dayEnd(file) {
|
|
537
|
+
return file.dayStart + DAY_MS;
|
|
538
|
+
}
|
|
539
|
+
function recordAt(record) {
|
|
540
|
+
if (record.kind === "point") return record.point.at;
|
|
541
|
+
if (record.kind === "event") return record.event.at;
|
|
542
|
+
return record.at;
|
|
543
|
+
}
|
|
544
|
+
function encodeRecord(record) {
|
|
545
|
+
return `${JSON.stringify(recordValue(record))}
|
|
546
|
+
`;
|
|
547
|
+
}
|
|
548
|
+
function recordValue(record) {
|
|
549
|
+
switch (record.kind) {
|
|
550
|
+
case "start":
|
|
551
|
+
return { start: record.at };
|
|
552
|
+
case "cleared":
|
|
553
|
+
return { cleared: record.at };
|
|
554
|
+
case "point": {
|
|
555
|
+
const { at, needsYou, working, idle, total } = record.point;
|
|
556
|
+
return { point: [at, needsYou, working, idle, total] };
|
|
557
|
+
}
|
|
558
|
+
case "event":
|
|
559
|
+
return { event: eventFields(record.event) };
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
function eventFields(event) {
|
|
563
|
+
const { id, at, sessionId, sessionName: sessionName2, kind, from, to, severity } = event;
|
|
564
|
+
return {
|
|
565
|
+
id,
|
|
566
|
+
at,
|
|
567
|
+
sessionId,
|
|
568
|
+
sessionName: sessionName2,
|
|
569
|
+
kind,
|
|
570
|
+
...from !== void 0 && { from },
|
|
571
|
+
...to !== void 0 && { to },
|
|
572
|
+
severity
|
|
573
|
+
};
|
|
574
|
+
}
|
|
575
|
+
function isRecord(value) {
|
|
576
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
577
|
+
}
|
|
578
|
+
function moment(value) {
|
|
579
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value > 0 ? value : null;
|
|
580
|
+
}
|
|
581
|
+
function count(value) {
|
|
582
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : null;
|
|
583
|
+
}
|
|
584
|
+
function field(value) {
|
|
585
|
+
return typeof value === "string" && value !== "" && value.length <= MAX_FIELD_LENGTH ? value : null;
|
|
586
|
+
}
|
|
587
|
+
function oneOf(list, value) {
|
|
588
|
+
return list.find((item) => item === value) ?? null;
|
|
589
|
+
}
|
|
590
|
+
function readPoint(value) {
|
|
591
|
+
if (!Array.isArray(value) || value.length !== 5) return null;
|
|
592
|
+
const at = moment(value[0]);
|
|
593
|
+
const [needsYou, working, idle, total] = value.slice(1).map(count);
|
|
594
|
+
if (at === null || needsYou == null || working == null || idle == null || total == null) {
|
|
595
|
+
return null;
|
|
596
|
+
}
|
|
597
|
+
return { at, needsYou, working, idle, total };
|
|
598
|
+
}
|
|
599
|
+
function readEvent(value) {
|
|
600
|
+
if (!isRecord(value)) return null;
|
|
601
|
+
const id = field(value.id);
|
|
602
|
+
const at = moment(value.at);
|
|
603
|
+
const sessionId = field(value.sessionId);
|
|
604
|
+
const sessionName2 = field(value.sessionName);
|
|
605
|
+
const kind = oneOf(EVENT_KINDS, value.kind);
|
|
606
|
+
const severity = oneOf(EVENT_SEVERITIES, value.severity);
|
|
607
|
+
if (!id || at === null || !sessionId || !sessionName2 || !kind || !severity) return null;
|
|
608
|
+
const from = oneOf(SESSION_STATUSES, value.from);
|
|
609
|
+
const to = oneOf(SESSION_STATUSES, value.to);
|
|
610
|
+
if (value.from !== void 0 && !from || value.to !== void 0 && !to) return null;
|
|
611
|
+
return {
|
|
612
|
+
id,
|
|
613
|
+
at,
|
|
614
|
+
sessionId,
|
|
615
|
+
sessionName: sessionName2,
|
|
616
|
+
kind,
|
|
617
|
+
...from && { from },
|
|
618
|
+
...to && { to },
|
|
619
|
+
severity
|
|
620
|
+
};
|
|
621
|
+
}
|
|
622
|
+
function parseRecord(line) {
|
|
623
|
+
if (line === "" || line.length > MAX_LINE_LENGTH) return null;
|
|
624
|
+
let value;
|
|
625
|
+
try {
|
|
626
|
+
value = JSON.parse(line);
|
|
627
|
+
} catch {
|
|
628
|
+
return null;
|
|
629
|
+
}
|
|
630
|
+
if (!isRecord(value)) return null;
|
|
631
|
+
if ("point" in value) {
|
|
632
|
+
const point = readPoint(value.point);
|
|
633
|
+
return point && { kind: "point", point };
|
|
634
|
+
}
|
|
635
|
+
if ("event" in value) {
|
|
636
|
+
const event = readEvent(value.event);
|
|
637
|
+
return event && { kind: "event", event };
|
|
638
|
+
}
|
|
639
|
+
if ("start" in value) {
|
|
640
|
+
const at = moment(value.start);
|
|
641
|
+
return at === null ? null : { kind: "start", at };
|
|
642
|
+
}
|
|
643
|
+
if ("cleared" in value) {
|
|
644
|
+
const at = moment(value.cleared);
|
|
645
|
+
return at === null ? null : { kind: "cleared", at };
|
|
646
|
+
}
|
|
647
|
+
return null;
|
|
648
|
+
}
|
|
649
|
+
function restartsAmong(starts, times) {
|
|
650
|
+
const ordered = [...times].sort((a, b) => a - b);
|
|
651
|
+
const restarts = [];
|
|
652
|
+
let next = 0;
|
|
653
|
+
let lastBefore = null;
|
|
654
|
+
for (const at of [...new Set(starts)].sort((a, b) => a - b)) {
|
|
655
|
+
while (next < ordered.length && ordered[next] < at) {
|
|
656
|
+
lastBefore = ordered[next];
|
|
657
|
+
next += 1;
|
|
658
|
+
}
|
|
659
|
+
if (lastBefore !== null) restarts.push({ at, lastBefore });
|
|
660
|
+
}
|
|
661
|
+
return restarts;
|
|
662
|
+
}
|
|
663
|
+
function parseHistoryText(text4) {
|
|
664
|
+
const records = [];
|
|
665
|
+
let skipped = 0;
|
|
666
|
+
for (const line of text4.split("\n")) {
|
|
667
|
+
if (line.trim() === "") continue;
|
|
668
|
+
const record = parseRecord(line.trim());
|
|
669
|
+
if (record) records.push(record);
|
|
670
|
+
else skipped += 1;
|
|
671
|
+
}
|
|
672
|
+
return { records, skipped };
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
// src/collector/history/historyLimits.ts
|
|
676
|
+
var HISTORY_MAX_BYTES = 20 * 1024 * 1024;
|
|
677
|
+
var HISTORY_MAX_FILE_BYTES = 2 * 1024 * 1024;
|
|
678
|
+
var HISTORY_MAX_AGE_MS = 8 * 24 * 60 * 60 * 1e3;
|
|
679
|
+
function memoryOnlyStatus() {
|
|
680
|
+
return {
|
|
681
|
+
where: "memory",
|
|
682
|
+
folder: null,
|
|
683
|
+
bytes: null,
|
|
684
|
+
maxBytes: HISTORY_MAX_BYTES,
|
|
685
|
+
maxAgeMs: HISTORY_MAX_AGE_MS,
|
|
686
|
+
canClear: false,
|
|
687
|
+
problem: null
|
|
688
|
+
};
|
|
689
|
+
}
|
|
690
|
+
function filesToDelete(files, writingTo, incoming, limits) {
|
|
691
|
+
const ordered = [...files].sort(byFileOrder);
|
|
692
|
+
const doomed = [];
|
|
693
|
+
let total = 0;
|
|
694
|
+
const left = [];
|
|
695
|
+
for (const file of ordered) {
|
|
696
|
+
if (file.name !== writingTo && dayEnd(file) + limits.maxAgeMs < limits.now) {
|
|
697
|
+
doomed.push(file.name);
|
|
698
|
+
} else {
|
|
699
|
+
left.push(file);
|
|
700
|
+
total += file.size;
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
for (const file of left) {
|
|
704
|
+
if (total + incoming <= limits.maxBytes) break;
|
|
705
|
+
if (file.name === writingTo) continue;
|
|
706
|
+
doomed.push(file.name);
|
|
707
|
+
total -= file.size;
|
|
708
|
+
}
|
|
709
|
+
return doomed;
|
|
710
|
+
}
|
|
711
|
+
|
|
479
712
|
// src/collector/historyStore.ts
|
|
480
713
|
var HISTORY_CAPACITY = 10800;
|
|
481
714
|
function createHistoryStore(capacity = HISTORY_CAPACITY) {
|
|
@@ -492,6 +725,9 @@ function createHistoryStore(capacity = HISTORY_CAPACITY) {
|
|
|
492
725
|
while (start > 0 && points[start - 1].at >= from) start -= 1;
|
|
493
726
|
return points.slice(start);
|
|
494
727
|
},
|
|
728
|
+
clear() {
|
|
729
|
+
points = [];
|
|
730
|
+
},
|
|
495
731
|
get size() {
|
|
496
732
|
return points.length;
|
|
497
733
|
}
|
|
@@ -609,6 +845,7 @@ function createPoller(options) {
|
|
|
609
845
|
let inFlight = null;
|
|
610
846
|
let timer = null;
|
|
611
847
|
const memory = /* @__PURE__ */ new Map();
|
|
848
|
+
const resumed = /* @__PURE__ */ new Map();
|
|
612
849
|
const overdue = /* @__PURE__ */ new Map();
|
|
613
850
|
const settled = /* @__PURE__ */ new Map();
|
|
614
851
|
function stillWaiting(adapter, since) {
|
|
@@ -625,11 +862,11 @@ function createPoller(options) {
|
|
|
625
862
|
};
|
|
626
863
|
}
|
|
627
864
|
function ask(adapter) {
|
|
628
|
-
const
|
|
629
|
-
if (
|
|
630
|
-
if (
|
|
865
|
+
const open4 = overdue.get(adapter);
|
|
866
|
+
if (open4) {
|
|
867
|
+
if (open4.late === void 0) return Promise.resolve(stillWaiting(adapter, open4.since));
|
|
631
868
|
overdue.delete(adapter);
|
|
632
|
-
return Promise.resolve(
|
|
869
|
+
return Promise.resolve(open4.late);
|
|
633
870
|
}
|
|
634
871
|
const since = now();
|
|
635
872
|
return new Promise((resolve) => {
|
|
@@ -683,9 +920,12 @@ function createPoller(options) {
|
|
|
683
920
|
if (state !== "ok" && state !== "not-set-up") return;
|
|
684
921
|
const id = adapters[index].id;
|
|
685
922
|
let remembered = memory.get(id);
|
|
923
|
+
let lastSeen;
|
|
686
924
|
if (!remembered) {
|
|
687
925
|
remembered = { baselines: /* @__PURE__ */ new Map(), reported: /* @__PURE__ */ new Map() };
|
|
688
926
|
memory.set(id, remembered);
|
|
927
|
+
lastSeen = resumed.get(id);
|
|
928
|
+
resumed.delete(id);
|
|
689
929
|
}
|
|
690
930
|
const { baselines, reported } = remembered;
|
|
691
931
|
const basis = result.basis ?? "";
|
|
@@ -693,6 +933,14 @@ function createPoller(options) {
|
|
|
693
933
|
if (previous) {
|
|
694
934
|
changes.push(...withoutRepeats(diffSessions(previous, result.sessions, at), reported));
|
|
695
935
|
} else {
|
|
936
|
+
if (lastSeen) {
|
|
937
|
+
const known = new Set(lastSeen.map((session) => session.id));
|
|
938
|
+
const before = lastSeen.filter(
|
|
939
|
+
(session) => session.status !== null
|
|
940
|
+
);
|
|
941
|
+
const after = result.sessions.filter((session) => known.has(session.id));
|
|
942
|
+
changes.push(...diffSessions(before, after, at));
|
|
943
|
+
}
|
|
696
944
|
for (const session of result.sessions) {
|
|
697
945
|
if (!reported.has(session.id)) reported.set(session.id, session.status);
|
|
698
946
|
}
|
|
@@ -746,6 +994,24 @@ function createPoller(options) {
|
|
|
746
994
|
timer = null;
|
|
747
995
|
},
|
|
748
996
|
pollOnce,
|
|
997
|
+
resume(kept) {
|
|
998
|
+
const newest2 = /* @__PURE__ */ new Map();
|
|
999
|
+
for (const event of kept) {
|
|
1000
|
+
const held = newest2.get(event.sessionId);
|
|
1001
|
+
if (!held || event.at >= held.at) newest2.set(event.sessionId, event);
|
|
1002
|
+
}
|
|
1003
|
+
resumed.clear();
|
|
1004
|
+
for (const event of newest2.values()) {
|
|
1005
|
+
const status = event.kind === "ended" ? null : event.to ?? null;
|
|
1006
|
+
if (event.kind !== "ended" && status === null) continue;
|
|
1007
|
+
const colon = event.sessionId.indexOf(":");
|
|
1008
|
+
if (colon <= 0) continue;
|
|
1009
|
+
const source = event.sessionId.slice(0, colon);
|
|
1010
|
+
const list = resumed.get(source) ?? [];
|
|
1011
|
+
list.push({ id: event.sessionId, name: event.sessionName, status });
|
|
1012
|
+
resumed.set(source, list);
|
|
1013
|
+
}
|
|
1014
|
+
},
|
|
749
1015
|
getSnapshot() {
|
|
750
1016
|
if (latest) return latest;
|
|
751
1017
|
const at = now();
|
|
@@ -786,10 +1052,13 @@ function lineOf(happened, facts) {
|
|
|
786
1052
|
}
|
|
787
1053
|
function waitPost(facts) {
|
|
788
1054
|
const { begunAt, now } = facts;
|
|
1055
|
+
const asking = waitingText(facts.asking);
|
|
789
1056
|
const session = sessionOf(facts.session, facts.agent);
|
|
790
1057
|
const waitedMs = Math.max(0, now - begunAt);
|
|
1058
|
+
const happened = `${session.name} ${waitingPhrase(facts.session)}`;
|
|
791
1059
|
return {
|
|
792
|
-
text
|
|
1060
|
+
// In `text` it is made safe for Slack with the rest.
|
|
1061
|
+
text: lineOf(asking ? `${happened}: ${asking}` : happened, [
|
|
793
1062
|
formatDuration(waitedMs),
|
|
794
1063
|
session.folder,
|
|
795
1064
|
session.app,
|
|
@@ -797,6 +1066,8 @@ function waitPost(facts) {
|
|
|
797
1066
|
]),
|
|
798
1067
|
event: "needs-you",
|
|
799
1068
|
reason: facts.session.waitingReason ?? "other",
|
|
1069
|
+
// Left out, not null, when it does not go, so a post without it is the post it always was.
|
|
1070
|
+
...asking ? { asking } : {},
|
|
800
1071
|
session,
|
|
801
1072
|
at: new Date(begunAt).toISOString(),
|
|
802
1073
|
waitedSeconds: Math.floor(waitedMs / 1e3)
|
|
@@ -823,6 +1094,7 @@ function webhookOffStatus(problem) {
|
|
|
823
1094
|
host: null,
|
|
824
1095
|
events: null,
|
|
825
1096
|
afterMs: null,
|
|
1097
|
+
asking: null,
|
|
826
1098
|
problem,
|
|
827
1099
|
last: null,
|
|
828
1100
|
limitedUntil: null
|
|
@@ -833,6 +1105,7 @@ function createWebhookNotifications(options) {
|
|
|
833
1105
|
const channel = createOutboundChannel({
|
|
834
1106
|
events: settings.events,
|
|
835
1107
|
afterMs: settings.afterMs,
|
|
1108
|
+
asking: settings.asking,
|
|
836
1109
|
waitMessage: waitPost,
|
|
837
1110
|
overMessage: overPost,
|
|
838
1111
|
send: (post) => sender.send(post),
|
|
@@ -848,6 +1121,7 @@ function createWebhookNotifications(options) {
|
|
|
848
1121
|
host: settings.url.hostname,
|
|
849
1122
|
events: [...settings.events],
|
|
850
1123
|
afterMs: settings.afterMs,
|
|
1124
|
+
asking: settings.asking,
|
|
851
1125
|
problem: null,
|
|
852
1126
|
last: channel.last(),
|
|
853
1127
|
limitedUntil: channel.limitedUntil()
|
|
@@ -859,6 +1133,7 @@ function createWebhookNotifications(options) {
|
|
|
859
1133
|
|
|
860
1134
|
// src/collector/handler.ts
|
|
861
1135
|
var JUMP_PATH = "/api/jump";
|
|
1136
|
+
var CLEAR_HISTORY_PATH = "/api/history/clear";
|
|
862
1137
|
var MAX_HISTORY_WINDOW_MS = HISTORY_CAPACITY * POLL_INTERVAL_MS;
|
|
863
1138
|
var LOOPBACK_HOST = /^(localhost|127\.0\.0\.1|\[::1\])(:\d{1,5})?$/i;
|
|
864
1139
|
var LOOPBACK_ORIGIN = /^https?:\/\/(localhost|127\.0\.0\.1|\[::1\])(:\d{1,5})?$/i;
|
|
@@ -886,6 +1161,66 @@ function refusalFor(req) {
|
|
|
886
1161
|
}
|
|
887
1162
|
return null;
|
|
888
1163
|
}
|
|
1164
|
+
function header(req, name) {
|
|
1165
|
+
const value = req.headers[name.toLowerCase()];
|
|
1166
|
+
return typeof value === "string" ? value : void 0;
|
|
1167
|
+
}
|
|
1168
|
+
function refusal(status, error, headers = {}) {
|
|
1169
|
+
return {
|
|
1170
|
+
status,
|
|
1171
|
+
body: { error },
|
|
1172
|
+
headers: { Connection: "close", ...headers }
|
|
1173
|
+
};
|
|
1174
|
+
}
|
|
1175
|
+
function actionRefusalFor(req, action, maxBodyBytes) {
|
|
1176
|
+
if (req.method !== "POST") {
|
|
1177
|
+
return refusal(405, "This address only answers POST requests.", { Allow: "POST" });
|
|
1178
|
+
}
|
|
1179
|
+
const origin = header(req, "origin");
|
|
1180
|
+
if (origin === void 0 || !isLoopbackOrigin(origin)) {
|
|
1181
|
+
return refusal(403, "This address only acts for the dashboard page served from this machine.");
|
|
1182
|
+
}
|
|
1183
|
+
const site = req.headers["sec-fetch-site"];
|
|
1184
|
+
if (site !== void 0 && site !== "same-origin") {
|
|
1185
|
+
return refusal(403, "This address only acts for the dashboard page served from this machine.");
|
|
1186
|
+
}
|
|
1187
|
+
if (header(req, ACTION_HEADER) !== action) {
|
|
1188
|
+
return refusal(
|
|
1189
|
+
403,
|
|
1190
|
+
`A request to this address must carry the header ${ACTION_HEADER}: ${action}.`
|
|
1191
|
+
);
|
|
1192
|
+
}
|
|
1193
|
+
const contentType = header(req, "content-type")?.split(";", 1)[0]?.trim().toLowerCase();
|
|
1194
|
+
if (contentType !== "application/json") {
|
|
1195
|
+
return refusal(415, "The body must be JSON, sent as application/json.");
|
|
1196
|
+
}
|
|
1197
|
+
const length = header(req, "content-length");
|
|
1198
|
+
if (length !== void 0 && (!/^\d+$/.test(length) || Number(length) > maxBodyBytes)) {
|
|
1199
|
+
return refusal(413, `The body must be no more than ${maxBodyBytes} bytes.`);
|
|
1200
|
+
}
|
|
1201
|
+
return null;
|
|
1202
|
+
}
|
|
1203
|
+
function readRequestBody(req, limit) {
|
|
1204
|
+
return new Promise((resolve) => {
|
|
1205
|
+
const chunks = [];
|
|
1206
|
+
let received = 0;
|
|
1207
|
+
let done = false;
|
|
1208
|
+
const finish = (body) => {
|
|
1209
|
+
if (done) return;
|
|
1210
|
+
done = true;
|
|
1211
|
+
resolve(body);
|
|
1212
|
+
};
|
|
1213
|
+
req.on("data", (chunk) => {
|
|
1214
|
+
if (done) return;
|
|
1215
|
+
received += chunk.byteLength;
|
|
1216
|
+
if (received > limit) finish({ ok: false, tooLarge: true });
|
|
1217
|
+
else chunks.push(chunk);
|
|
1218
|
+
});
|
|
1219
|
+
req.on("end", () => finish({ ok: true, text: Buffer.concat(chunks).toString("utf8") }));
|
|
1220
|
+
req.on("error", () => finish({ ok: false, tooLarge: false }));
|
|
1221
|
+
req.on("close", () => finish({ ok: false, tooLarge: false }));
|
|
1222
|
+
});
|
|
1223
|
+
}
|
|
889
1224
|
function send(res, status, body, headers = {}) {
|
|
890
1225
|
for (const name of res.getHeaderNames()) {
|
|
891
1226
|
if (name.toLowerCase().startsWith("access-control-")) res.removeHeader(name);
|
|
@@ -921,10 +1256,13 @@ function numberParam(value) {
|
|
|
921
1256
|
function createApiHandler(options) {
|
|
922
1257
|
const { version, poller, events, history, notifications, email, webhook, jump } = options;
|
|
923
1258
|
const now = options.now ?? Date.now;
|
|
1259
|
+
const actions = /* @__PURE__ */ new Map();
|
|
1260
|
+
if (jump) actions.set(JUMP_PATH, jump);
|
|
1261
|
+
if (options.clearHistory) actions.set(CLEAR_HISTORY_PATH, options.clearHistory);
|
|
924
1262
|
function route(req, res) {
|
|
925
|
-
const
|
|
926
|
-
if (
|
|
927
|
-
fail(res, 403,
|
|
1263
|
+
const refusal2 = refusalFor(req);
|
|
1264
|
+
if (refusal2) {
|
|
1265
|
+
fail(res, 403, refusal2);
|
|
928
1266
|
return;
|
|
929
1267
|
}
|
|
930
1268
|
let url;
|
|
@@ -933,8 +1271,9 @@ function createApiHandler(options) {
|
|
|
933
1271
|
} catch {
|
|
934
1272
|
url = null;
|
|
935
1273
|
}
|
|
936
|
-
|
|
937
|
-
|
|
1274
|
+
const action = url === null ? void 0 : actions.get(url.pathname);
|
|
1275
|
+
if (action) {
|
|
1276
|
+
action(req).then((answer2) => send(res, answer2.status, answer2.body, answer2.headers)).catch(() => {
|
|
938
1277
|
if (res.headersSent) res.end();
|
|
939
1278
|
else fail(res, 500, "The collector ran into an unexpected problem.");
|
|
940
1279
|
}).catch(() => {
|
|
@@ -988,24 +1327,49 @@ function createApiHandler(options) {
|
|
|
988
1327
|
return;
|
|
989
1328
|
}
|
|
990
1329
|
const windowMs = Math.min(requested ?? DEFAULT_HISTORY_WINDOW_MS, MAX_HISTORY_WINDOW_MS);
|
|
1330
|
+
const held = options.historyKept?.();
|
|
1331
|
+
const since = held?.since ?? { at: poller.startedAt, by: "started" };
|
|
991
1332
|
send(res, 200, {
|
|
992
1333
|
points: history.list(windowMs, now()),
|
|
993
|
-
startedAt: poller.startedAt
|
|
1334
|
+
startedAt: poller.startedAt,
|
|
1335
|
+
since,
|
|
1336
|
+
kept: held?.kept ?? memoryOnlyStatus(),
|
|
1337
|
+
// A restart before where the history now begins was cleared, or let go.
|
|
1338
|
+
restarts: (held?.restarts ?? []).filter((restart) => restart.at > since.at)
|
|
994
1339
|
});
|
|
995
1340
|
return;
|
|
996
1341
|
}
|
|
1342
|
+
case "/api/waits": {
|
|
1343
|
+
if (options.waits) {
|
|
1344
|
+
send(res, 200, options.waits());
|
|
1345
|
+
return;
|
|
1346
|
+
}
|
|
1347
|
+
fail(res, 404, "There is nothing at that address.");
|
|
1348
|
+
return;
|
|
1349
|
+
}
|
|
997
1350
|
default: {
|
|
998
1351
|
fail(res, 404, "There is nothing at that address.");
|
|
999
1352
|
}
|
|
1000
1353
|
}
|
|
1001
1354
|
}
|
|
1002
|
-
|
|
1355
|
+
function answer(req, res) {
|
|
1003
1356
|
try {
|
|
1004
1357
|
route(req, res);
|
|
1005
1358
|
} catch {
|
|
1006
1359
|
if (res.headersSent) res.end();
|
|
1007
1360
|
else fail(res, 500, "The collector ran into an unexpected problem.");
|
|
1008
1361
|
}
|
|
1362
|
+
}
|
|
1363
|
+
return (req, res) => {
|
|
1364
|
+
const reading = options.ready?.() ?? null;
|
|
1365
|
+
if (reading === null) {
|
|
1366
|
+
answer(req, res);
|
|
1367
|
+
return;
|
|
1368
|
+
}
|
|
1369
|
+
void reading.then(
|
|
1370
|
+
() => answer(req, res),
|
|
1371
|
+
() => answer(req, res)
|
|
1372
|
+
);
|
|
1009
1373
|
};
|
|
1010
1374
|
}
|
|
1011
1375
|
|
|
@@ -1223,7 +1587,229 @@ function urlFor(address) {
|
|
|
1223
1587
|
|
|
1224
1588
|
// src/collector/hosts/standalone.ts
|
|
1225
1589
|
import { existsSync } from "node:fs";
|
|
1226
|
-
import
|
|
1590
|
+
import path16 from "node:path";
|
|
1591
|
+
|
|
1592
|
+
// src/core/waits/measured.ts
|
|
1593
|
+
var POLL_GAP_MS = 8e3;
|
|
1594
|
+
function pollRuns(times, starts, gapMs = POLL_GAP_MS) {
|
|
1595
|
+
const ordered = [...times].sort((a, b) => a - b);
|
|
1596
|
+
const breaks = [...starts].sort((a, b) => a - b);
|
|
1597
|
+
const runs = [];
|
|
1598
|
+
let next = 0;
|
|
1599
|
+
for (const at of ordered) {
|
|
1600
|
+
const run = runs[runs.length - 1];
|
|
1601
|
+
let restarted = false;
|
|
1602
|
+
while (next < breaks.length && breaks[next] <= at) {
|
|
1603
|
+
if (run && breaks[next] > run.to) restarted = true;
|
|
1604
|
+
next += 1;
|
|
1605
|
+
}
|
|
1606
|
+
if (run && !restarted && at - run.to <= gapMs) run.to = Math.max(run.to, at);
|
|
1607
|
+
else runs.push({ from: at, to: at });
|
|
1608
|
+
}
|
|
1609
|
+
return runs;
|
|
1610
|
+
}
|
|
1611
|
+
function addPoll(runs, at, fresh = false, gapMs = POLL_GAP_MS) {
|
|
1612
|
+
const run = runs[runs.length - 1];
|
|
1613
|
+
if (run && !fresh && at >= run.to && at - run.to <= gapMs) {
|
|
1614
|
+
run.to = at;
|
|
1615
|
+
} else if (!run || fresh || at > run.to) {
|
|
1616
|
+
runs.push({ from: at, to: at });
|
|
1617
|
+
}
|
|
1618
|
+
}
|
|
1619
|
+
function joined(spans) {
|
|
1620
|
+
const ordered = spans.filter((span) => span.to >= span.from).sort((a, b) => a.from - b.from);
|
|
1621
|
+
const out = [];
|
|
1622
|
+
for (const span of ordered) {
|
|
1623
|
+
const last = out[out.length - 1];
|
|
1624
|
+
if (last && span.from <= last.to) last.to = Math.max(last.to, span.to);
|
|
1625
|
+
else out.push({ ...span });
|
|
1626
|
+
}
|
|
1627
|
+
return out;
|
|
1628
|
+
}
|
|
1629
|
+
function coveredWithin(spans, from, to) {
|
|
1630
|
+
if (to <= from) return 0;
|
|
1631
|
+
let low = 0;
|
|
1632
|
+
let high = spans.length;
|
|
1633
|
+
while (low < high) {
|
|
1634
|
+
const middle = low + high >> 1;
|
|
1635
|
+
if (spans[middle].to <= from) low = middle + 1;
|
|
1636
|
+
else high = middle;
|
|
1637
|
+
}
|
|
1638
|
+
let sum = 0;
|
|
1639
|
+
for (let index = low; index < spans.length; index += 1) {
|
|
1640
|
+
const span = spans[index];
|
|
1641
|
+
if (span.from >= to) break;
|
|
1642
|
+
sum += Math.min(span.to, to) - Math.max(span.from, from);
|
|
1643
|
+
}
|
|
1644
|
+
return sum;
|
|
1645
|
+
}
|
|
1646
|
+
|
|
1647
|
+
// src/core/waits/waitTotals.ts
|
|
1648
|
+
var MAX_WAIT_SESSIONS = 10;
|
|
1649
|
+
var WAIT_DAYS = 7;
|
|
1650
|
+
function isWaitEvent(event) {
|
|
1651
|
+
return event.to === "needs-you" || event.from === "needs-you";
|
|
1652
|
+
}
|
|
1653
|
+
function unendedWaits(events) {
|
|
1654
|
+
const newest2 = /* @__PURE__ */ new Map();
|
|
1655
|
+
for (const event of events) {
|
|
1656
|
+
if (!isWaitEvent(event)) continue;
|
|
1657
|
+
const held = newest2.get(event.sessionId);
|
|
1658
|
+
if (!held || event.at >= held.at) newest2.set(event.sessionId, event);
|
|
1659
|
+
}
|
|
1660
|
+
return [...newest2.values()].filter((event) => event.kind !== "ended" && event.to === "needs-you");
|
|
1661
|
+
}
|
|
1662
|
+
function midnightOf(at) {
|
|
1663
|
+
const date = new Date(at);
|
|
1664
|
+
date.setHours(0, 0, 0, 0);
|
|
1665
|
+
return date.getTime();
|
|
1666
|
+
}
|
|
1667
|
+
function midnightAfter(midnight, days) {
|
|
1668
|
+
const date = new Date(midnight);
|
|
1669
|
+
date.setDate(date.getDate() + days);
|
|
1670
|
+
date.setHours(0, 0, 0, 0);
|
|
1671
|
+
return date.getTime();
|
|
1672
|
+
}
|
|
1673
|
+
function pad2(value) {
|
|
1674
|
+
return String(value).padStart(2, "0");
|
|
1675
|
+
}
|
|
1676
|
+
function localDay(at) {
|
|
1677
|
+
const date = new Date(at);
|
|
1678
|
+
return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`;
|
|
1679
|
+
}
|
|
1680
|
+
function latestAtOrBefore(starts, at, fallback) {
|
|
1681
|
+
let found = fallback;
|
|
1682
|
+
for (const start of starts) {
|
|
1683
|
+
if (start > at) break;
|
|
1684
|
+
found = Math.max(found, start);
|
|
1685
|
+
}
|
|
1686
|
+
return found;
|
|
1687
|
+
}
|
|
1688
|
+
function waitsOf(input) {
|
|
1689
|
+
const { now, since } = input;
|
|
1690
|
+
const starts = [...input.runStarts].sort((a, b) => a - b);
|
|
1691
|
+
const runStart = (at) => latestAtOrBefore(starts, at, since);
|
|
1692
|
+
const waitingNow = new Map(input.waitingNow.map((session) => [session.id, session.name]));
|
|
1693
|
+
const thisRun = runStart(now);
|
|
1694
|
+
const bySession = /* @__PURE__ */ new Map();
|
|
1695
|
+
for (const event of input.events) {
|
|
1696
|
+
if (!isWaitEvent(event) || event.at > now) continue;
|
|
1697
|
+
const own = bySession.get(event.sessionId);
|
|
1698
|
+
if (own) own.push(event);
|
|
1699
|
+
else bySession.set(event.sessionId, [event]);
|
|
1700
|
+
}
|
|
1701
|
+
const waits = [];
|
|
1702
|
+
const names = /* @__PURE__ */ new Map();
|
|
1703
|
+
const unended = /* @__PURE__ */ new Set();
|
|
1704
|
+
for (const [sessionId, own] of bySession) {
|
|
1705
|
+
own.sort((a, b) => a.at - b.at);
|
|
1706
|
+
let began = null;
|
|
1707
|
+
let previous = null;
|
|
1708
|
+
for (const event of own) {
|
|
1709
|
+
if (began === null && event.from === "needs-you") {
|
|
1710
|
+
began = Math.max(runStart(event.at), previous ?? -Infinity);
|
|
1711
|
+
}
|
|
1712
|
+
const waitsAfter = event.kind !== "ended" && event.to === "needs-you";
|
|
1713
|
+
if (began !== null && !waitsAfter) {
|
|
1714
|
+
waits.push({ sessionId, from: began, to: event.at, open: false });
|
|
1715
|
+
began = null;
|
|
1716
|
+
} else if (began === null && waitsAfter) {
|
|
1717
|
+
began = event.at;
|
|
1718
|
+
}
|
|
1719
|
+
previous = event.at;
|
|
1720
|
+
names.set(sessionId, event.sessionName);
|
|
1721
|
+
}
|
|
1722
|
+
if (began !== null) {
|
|
1723
|
+
const open4 = waitingNow.has(sessionId);
|
|
1724
|
+
waits.push({ sessionId, from: began, to: open4 || began >= thisRun ? now : thisRun, open: open4 });
|
|
1725
|
+
unended.add(sessionId);
|
|
1726
|
+
}
|
|
1727
|
+
}
|
|
1728
|
+
for (const [sessionId, name] of input.names ?? []) names.set(sessionId, name);
|
|
1729
|
+
for (const [sessionId, name] of waitingNow) {
|
|
1730
|
+
names.set(sessionId, name);
|
|
1731
|
+
if (unended.has(sessionId)) continue;
|
|
1732
|
+
const own = bySession.get(sessionId);
|
|
1733
|
+
const last = own?.[own.length - 1]?.at ?? -Infinity;
|
|
1734
|
+
waits.push({ sessionId, from: Math.max(thisRun, last), to: now, open: true });
|
|
1735
|
+
}
|
|
1736
|
+
const kept = waits.map((wait) => ({ ...wait, from: Math.max(wait.from, since) })).filter((wait) => wait.to > wait.from);
|
|
1737
|
+
kept.sort((a, b) => a.from - b.from);
|
|
1738
|
+
return { waits: kept, names };
|
|
1739
|
+
}
|
|
1740
|
+
function totalIn(waits, measured, from, to) {
|
|
1741
|
+
let waitedMs = 0;
|
|
1742
|
+
let openMs = 0;
|
|
1743
|
+
let count2 = 0;
|
|
1744
|
+
const bySession = /* @__PURE__ */ new Map();
|
|
1745
|
+
for (const wait of waits) {
|
|
1746
|
+
if (wait.from >= to) break;
|
|
1747
|
+
if (wait.to <= from) continue;
|
|
1748
|
+
const ms = coveredWithin(measured, Math.max(wait.from, from), Math.min(wait.to, to));
|
|
1749
|
+
if (ms <= 0) continue;
|
|
1750
|
+
waitedMs += ms;
|
|
1751
|
+
count2 += 1;
|
|
1752
|
+
if (wait.open) openMs += ms;
|
|
1753
|
+
const own = bySession.get(wait.sessionId) ?? { ms: 0, waits: 0, open: false };
|
|
1754
|
+
own.ms += ms;
|
|
1755
|
+
own.waits += 1;
|
|
1756
|
+
own.open ||= wait.open;
|
|
1757
|
+
bySession.set(wait.sessionId, own);
|
|
1758
|
+
}
|
|
1759
|
+
return {
|
|
1760
|
+
total: { waitedMs, openMs, waits: count2, measuredMs: coveredWithin(measured, from, to) },
|
|
1761
|
+
bySession
|
|
1762
|
+
};
|
|
1763
|
+
}
|
|
1764
|
+
function periodOf(waits, measured, names, from, to) {
|
|
1765
|
+
const { total, bySession } = totalIn(waits, measured, from, to);
|
|
1766
|
+
const days = [];
|
|
1767
|
+
for (let start = from; start < to; ) {
|
|
1768
|
+
const end = Math.min(midnightAfter(midnightOf(start), 1), to);
|
|
1769
|
+
days.push({
|
|
1770
|
+
day: localDay(start),
|
|
1771
|
+
from: start,
|
|
1772
|
+
to: end,
|
|
1773
|
+
...totalIn(waits, measured, start, end).total
|
|
1774
|
+
});
|
|
1775
|
+
start = end;
|
|
1776
|
+
}
|
|
1777
|
+
if (days.length === 0) {
|
|
1778
|
+
days.push({ day: localDay(from), from, to, waitedMs: 0, openMs: 0, waits: 0, measuredMs: 0 });
|
|
1779
|
+
}
|
|
1780
|
+
const sessions = [...bySession].map(([sessionId, own]) => ({
|
|
1781
|
+
sessionId,
|
|
1782
|
+
name: names.get(sessionId) ?? sessionId,
|
|
1783
|
+
waitedMs: own.ms,
|
|
1784
|
+
waits: own.waits,
|
|
1785
|
+
open: own.open
|
|
1786
|
+
})).sort((a, b) => b.waitedMs - a.waitedMs || a.name.localeCompare(b.name));
|
|
1787
|
+
return {
|
|
1788
|
+
from,
|
|
1789
|
+
to,
|
|
1790
|
+
...total,
|
|
1791
|
+
days,
|
|
1792
|
+
sessions: sessions.slice(0, MAX_WAIT_SESSIONS),
|
|
1793
|
+
sessionCount: sessions.length
|
|
1794
|
+
};
|
|
1795
|
+
}
|
|
1796
|
+
function waitTotals(input) {
|
|
1797
|
+
const { now, since } = input;
|
|
1798
|
+
const gapMs = input.gapMs ?? POLL_GAP_MS;
|
|
1799
|
+
const spans = joined(input.measured);
|
|
1800
|
+
const newest2 = spans[spans.length - 1];
|
|
1801
|
+
if (newest2 && newest2.to < now && now - newest2.to <= gapMs && !input.runStarts.some((start) => start > newest2.to && start <= now)) {
|
|
1802
|
+
newest2.to = now;
|
|
1803
|
+
}
|
|
1804
|
+
const measured = spans.map((span) => ({ from: Math.max(span.from, since), to: Math.min(span.to, now) })).filter((span) => span.to > span.from);
|
|
1805
|
+
const { waits, names } = waitsOf(input);
|
|
1806
|
+
const today = midnightOf(now);
|
|
1807
|
+
const weekStart = midnightAfter(today, -(WAIT_DAYS - 1));
|
|
1808
|
+
return {
|
|
1809
|
+
today: periodOf(waits, measured, names, today, now),
|
|
1810
|
+
sevenDays: periodOf(waits, measured, names, weekStart, now)
|
|
1811
|
+
};
|
|
1812
|
+
}
|
|
1227
1813
|
|
|
1228
1814
|
// src/collector/adapters/claude-code/index.ts
|
|
1229
1815
|
import os from "node:os";
|
|
@@ -1893,7 +2479,7 @@ function build(fields, origin, context) {
|
|
|
1893
2479
|
const project = projectOf(fields.cwd);
|
|
1894
2480
|
const surface = mapClaudeCodeSurface(fields.entrypoint);
|
|
1895
2481
|
const mapped = mapClaudeCodeStatus(fields, origin);
|
|
1896
|
-
const
|
|
2482
|
+
const open4 = claudeCodeOpenLink(surface, fields.sessionId);
|
|
1897
2483
|
const startedAt = plausibleTime(fields.startedAt, context.now);
|
|
1898
2484
|
let statusSince = plausibleTime(fields.statusUpdatedAt, context.now);
|
|
1899
2485
|
if (statusSince !== null && startedAt !== null && statusSince < startedAt - WRITE_ORDER_SLACK_MS) {
|
|
@@ -1910,7 +2496,7 @@ function build(fields, origin, context) {
|
|
|
1910
2496
|
...mapped,
|
|
1911
2497
|
startedAt,
|
|
1912
2498
|
statusSince,
|
|
1913
|
-
links:
|
|
2499
|
+
links: open4 ? { open: open4 } : {},
|
|
1914
2500
|
stale: isStale({ status: mapped.status, statusSince }, context.now)
|
|
1915
2501
|
};
|
|
1916
2502
|
if (fields.pid !== void 0) {
|
|
@@ -2022,7 +2608,7 @@ function isMissing(error) {
|
|
|
2022
2608
|
}
|
|
2023
2609
|
|
|
2024
2610
|
// src/collector/adapters/claude-code/transcript/lastAsk.ts
|
|
2025
|
-
function
|
|
2611
|
+
function isRecord2(value) {
|
|
2026
2612
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
2027
2613
|
}
|
|
2028
2614
|
function nonEmpty(value) {
|
|
@@ -2030,8 +2616,8 @@ function nonEmpty(value) {
|
|
|
2030
2616
|
}
|
|
2031
2617
|
function blocksOf(line) {
|
|
2032
2618
|
const message = line.message;
|
|
2033
|
-
if (!
|
|
2034
|
-
return message.content.filter(
|
|
2619
|
+
if (!isRecord2(message) || !Array.isArray(message.content)) return [];
|
|
2620
|
+
return message.content.filter(isRecord2);
|
|
2035
2621
|
}
|
|
2036
2622
|
function linesOf(tail, fromStart) {
|
|
2037
2623
|
const pieces = tail.split("\n");
|
|
@@ -2041,7 +2627,7 @@ function linesOf(tail, fromStart) {
|
|
|
2041
2627
|
if (piece.trim() === "") continue;
|
|
2042
2628
|
try {
|
|
2043
2629
|
const value = JSON.parse(piece);
|
|
2044
|
-
if (
|
|
2630
|
+
if (isRecord2(value)) lines.push(value);
|
|
2045
2631
|
} catch {
|
|
2046
2632
|
}
|
|
2047
2633
|
}
|
|
@@ -2049,7 +2635,7 @@ function linesOf(tail, fromStart) {
|
|
|
2049
2635
|
}
|
|
2050
2636
|
function isPrompt(line) {
|
|
2051
2637
|
if (line.isMeta === true) return false;
|
|
2052
|
-
const message =
|
|
2638
|
+
const message = isRecord2(line.message) ? line.message : null;
|
|
2053
2639
|
if (message === null) return false;
|
|
2054
2640
|
if (typeof message.content === "string") return message.content.trim() !== "";
|
|
2055
2641
|
return blocksOf(line).some((block) => block.type === "text");
|
|
@@ -2060,11 +2646,11 @@ function pendingToolUse(lines) {
|
|
|
2060
2646
|
let movedOn = false;
|
|
2061
2647
|
for (const line of lines) {
|
|
2062
2648
|
if (line.isSidechain === true) continue;
|
|
2063
|
-
const message =
|
|
2649
|
+
const message = isRecord2(line.message) ? line.message : null;
|
|
2064
2650
|
if (line.type === "assistant") {
|
|
2065
2651
|
const messageId = nonEmpty(message?.id);
|
|
2066
|
-
const
|
|
2067
|
-
if (
|
|
2652
|
+
const newest3 = uses.at(-1);
|
|
2653
|
+
if (newest3 !== void 0 && (messageId === null || messageId !== newest3.messageId)) {
|
|
2068
2654
|
movedOn = true;
|
|
2069
2655
|
}
|
|
2070
2656
|
for (const block of blocksOf(line)) {
|
|
@@ -2072,7 +2658,7 @@ function pendingToolUse(lines) {
|
|
|
2072
2658
|
const id = nonEmpty(block.id);
|
|
2073
2659
|
const name = nonEmpty(block.name);
|
|
2074
2660
|
if (id === null || name === null) continue;
|
|
2075
|
-
uses.push({ id, name, input:
|
|
2661
|
+
uses.push({ id, name, input: isRecord2(block.input) ? block.input : {}, messageId });
|
|
2076
2662
|
movedOn = false;
|
|
2077
2663
|
}
|
|
2078
2664
|
} else if (line.type === "user") {
|
|
@@ -2083,10 +2669,10 @@ function pendingToolUse(lines) {
|
|
|
2083
2669
|
if (uses.length > 0 && isPrompt(line)) movedOn = true;
|
|
2084
2670
|
}
|
|
2085
2671
|
}
|
|
2086
|
-
const
|
|
2087
|
-
if (
|
|
2088
|
-
if (
|
|
2089
|
-
return uses.find((use) => use.messageId ===
|
|
2672
|
+
const newest2 = uses.at(-1);
|
|
2673
|
+
if (newest2 === void 0 || movedOn) return null;
|
|
2674
|
+
if (newest2.messageId === null) return answered.has(newest2.id) ? null : newest2;
|
|
2675
|
+
return uses.find((use) => use.messageId === newest2.messageId && !answered.has(use.id)) ?? null;
|
|
2090
2676
|
}
|
|
2091
2677
|
function firstLine(command) {
|
|
2092
2678
|
return command.split(/\r?\n/).find((line) => line.trim() !== "") ?? null;
|
|
@@ -2113,7 +2699,7 @@ function describeToolUse(name, input, cwd) {
|
|
|
2113
2699
|
if (name === "AskUserQuestion") {
|
|
2114
2700
|
const questions = Array.isArray(input.questions) ? input.questions : [];
|
|
2115
2701
|
const first = questions[0];
|
|
2116
|
-
const question =
|
|
2702
|
+
const question = isRecord2(first) ? nonEmpty(first.question) : null;
|
|
2117
2703
|
if (question === null) return null;
|
|
2118
2704
|
return questions.length > 1 ? `${question} (+${questions.length - 1} more)` : question;
|
|
2119
2705
|
}
|
|
@@ -2189,12 +2775,12 @@ function sameStamp(a, b) {
|
|
|
2189
2775
|
return a.size === b.size && a.mtimeMs === b.mtimeMs && a.ino === b.ino;
|
|
2190
2776
|
}
|
|
2191
2777
|
async function readTranscriptTail(file, io, known, maxBytes = TRANSCRIPT_TAIL_BYTES) {
|
|
2192
|
-
const
|
|
2778
|
+
const open4 = await io.openRegular(file);
|
|
2193
2779
|
try {
|
|
2194
|
-
const stamp = stampOf(
|
|
2780
|
+
const stamp = stampOf(open4.info);
|
|
2195
2781
|
if (known !== null && sameStamp(known, stamp)) return { unchanged: true, stamp };
|
|
2196
2782
|
const start = Math.max(0, stamp.size - maxBytes);
|
|
2197
|
-
const bytes2 = await
|
|
2783
|
+
const bytes2 = await open4.read(start, stamp.size - start);
|
|
2198
2784
|
return {
|
|
2199
2785
|
unchanged: false,
|
|
2200
2786
|
stamp,
|
|
@@ -2202,7 +2788,7 @@ async function readTranscriptTail(file, io, known, maxBytes = TRANSCRIPT_TAIL_BY
|
|
|
2202
2788
|
fromStart: start === 0
|
|
2203
2789
|
};
|
|
2204
2790
|
} finally {
|
|
2205
|
-
await
|
|
2791
|
+
await open4.close();
|
|
2206
2792
|
}
|
|
2207
2793
|
}
|
|
2208
2794
|
|
|
@@ -2582,19 +3168,19 @@ var TURN_UNDER_WAY = /* @__PURE__ */ new Set(["task_started", "turn_started"]);
|
|
|
2582
3168
|
var TURN_OVER = /* @__PURE__ */ new Set(["task_complete", "turn_complete", "turn_aborted"]);
|
|
2583
3169
|
function mapCodexStatus(fields) {
|
|
2584
3170
|
const { lastTurn, live } = fields;
|
|
2585
|
-
let
|
|
3171
|
+
let open4;
|
|
2586
3172
|
if (lastTurn === null) {
|
|
2587
|
-
|
|
3173
|
+
open4 = "idle";
|
|
2588
3174
|
} else if (typeof lastTurn !== "string") {
|
|
2589
3175
|
return "unknown";
|
|
2590
3176
|
} else if (TURN_UNDER_WAY.has(lastTurn)) {
|
|
2591
|
-
|
|
3177
|
+
open4 = "working";
|
|
2592
3178
|
} else if (TURN_OVER.has(lastTurn)) {
|
|
2593
|
-
|
|
3179
|
+
open4 = "idle";
|
|
2594
3180
|
} else {
|
|
2595
3181
|
return "unknown";
|
|
2596
3182
|
}
|
|
2597
|
-
return live === false ? "finished" :
|
|
3183
|
+
return live === false ? "finished" : open4;
|
|
2598
3184
|
}
|
|
2599
3185
|
var WRITER_LOCKS_SINCE = [0, 155, 0];
|
|
2600
3186
|
var VERSION = /^(\d{1,6})\.(\d{1,6})\.(\d{1,6})(-[0-9A-Za-z.-]{1,64})?(?:\+[0-9A-Za-z.-]{1,64})?$/;
|
|
@@ -2618,7 +3204,7 @@ var IMPORTED_TURN_ID = /^external-import-turn-\d{1,9}$/;
|
|
|
2618
3204
|
function isImportedCodexTurn(turnId) {
|
|
2619
3205
|
return typeof turnId === "string" && IMPORTED_TURN_ID.test(turnId);
|
|
2620
3206
|
}
|
|
2621
|
-
function
|
|
3207
|
+
function isRecord3(value) {
|
|
2622
3208
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
2623
3209
|
}
|
|
2624
3210
|
var CODEX_DESKTOP_ORIGINATOR = "Codex Desktop";
|
|
@@ -2626,7 +3212,7 @@ function mapCodexSurface(source, originator) {
|
|
|
2626
3212
|
if (originator === CODEX_DESKTOP_ORIGINATOR) return "desktop";
|
|
2627
3213
|
if (source === "cli" || source === "exec") return "terminal";
|
|
2628
3214
|
if (source === "vscode") return "vscode";
|
|
2629
|
-
if (
|
|
3215
|
+
if (isRecord3(source) && source.custom === "chatgpt") return "desktop";
|
|
2630
3216
|
return "unknown";
|
|
2631
3217
|
}
|
|
2632
3218
|
var NOT_A_SESSION_SOURCE = /* @__PURE__ */ new Set(["subagent", "internal", "mcp"]);
|
|
@@ -2637,7 +3223,7 @@ function isCodexSessionSource(source, meta = {}) {
|
|
|
2637
3223
|
return false;
|
|
2638
3224
|
}
|
|
2639
3225
|
if (typeof source === "string") return !NOT_A_SESSION_SOURCE.has(source);
|
|
2640
|
-
if (
|
|
3226
|
+
if (isRecord3(source)) return !Object.keys(source).some((kind) => NOT_A_SESSION_SOURCE.has(kind));
|
|
2641
3227
|
return true;
|
|
2642
3228
|
}
|
|
2643
3229
|
|
|
@@ -2656,7 +3242,7 @@ function parseCodexTime(value) {
|
|
|
2656
3242
|
var LINE_TIME = /^\{"timestamp":"([^"]{1,64})"/;
|
|
2657
3243
|
var TURN_HINT = /"type":"(?:task|turn)_/;
|
|
2658
3244
|
var TURN_TYPE = /^(?:task|turn)_/;
|
|
2659
|
-
function
|
|
3245
|
+
function isRecord4(value) {
|
|
2660
3246
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
2661
3247
|
}
|
|
2662
3248
|
function text3(value) {
|
|
@@ -2667,7 +3253,7 @@ function label(value) {
|
|
|
2667
3253
|
}
|
|
2668
3254
|
function compactSource(value) {
|
|
2669
3255
|
if (typeof value === "string") return text3(value);
|
|
2670
|
-
if (!
|
|
3256
|
+
if (!isRecord4(value)) return void 0;
|
|
2671
3257
|
return Object.fromEntries(
|
|
2672
3258
|
Object.entries(value).map(([kind, detail]) => [
|
|
2673
3259
|
kind,
|
|
@@ -2678,7 +3264,7 @@ function compactSource(value) {
|
|
|
2678
3264
|
function parse(line) {
|
|
2679
3265
|
try {
|
|
2680
3266
|
const value = JSON.parse(line);
|
|
2681
|
-
return
|
|
3267
|
+
return isRecord4(value) ? value : null;
|
|
2682
3268
|
} catch {
|
|
2683
3269
|
return null;
|
|
2684
3270
|
}
|
|
@@ -2686,7 +3272,7 @@ function parse(line) {
|
|
|
2686
3272
|
function readMetaLine(line) {
|
|
2687
3273
|
if (!line.includes('"session_meta"')) return null;
|
|
2688
3274
|
const value = parse(line);
|
|
2689
|
-
if (!value || value.type !== "session_meta" || !
|
|
3275
|
+
if (!value || value.type !== "session_meta" || !isRecord4(value.payload)) return null;
|
|
2690
3276
|
const payload = value.payload;
|
|
2691
3277
|
const meta = {
|
|
2692
3278
|
id: text3(payload.id),
|
|
@@ -2706,7 +3292,7 @@ function readMetaLine(line) {
|
|
|
2706
3292
|
function readTurnLine(line) {
|
|
2707
3293
|
if (!line.includes('"event_msg"') || !TURN_HINT.test(line)) return null;
|
|
2708
3294
|
const value = parse(line);
|
|
2709
|
-
if (!value || value.type !== "event_msg" || !
|
|
3295
|
+
if (!value || value.type !== "event_msg" || !isRecord4(value.payload)) return null;
|
|
2710
3296
|
const turn = value.payload.type;
|
|
2711
3297
|
if (typeof turn !== "string" || !TURN_TYPE.test(turn)) return null;
|
|
2712
3298
|
return {
|
|
@@ -2988,7 +3574,7 @@ function createRolloutFinder(options) {
|
|
|
2988
3574
|
// src/collector/adapters/codex/sessionIndex.ts
|
|
2989
3575
|
var SESSION_INDEX_FILE = "session_index.jsonl";
|
|
2990
3576
|
var SESSION_INDEX_LIMIT_BYTES = 4 * 1024 * 1024;
|
|
2991
|
-
function
|
|
3577
|
+
function isRecord5(value) {
|
|
2992
3578
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
2993
3579
|
}
|
|
2994
3580
|
function parseSessionIndex(content) {
|
|
@@ -3001,7 +3587,7 @@ function parseSessionIndex(content) {
|
|
|
3001
3587
|
} catch {
|
|
3002
3588
|
continue;
|
|
3003
3589
|
}
|
|
3004
|
-
if (!
|
|
3590
|
+
if (!isRecord5(value)) continue;
|
|
3005
3591
|
const { id, thread_name: name } = value;
|
|
3006
3592
|
if (typeof id !== "string" || id === "" || typeof name !== "string") continue;
|
|
3007
3593
|
const trimmed = name.trim();
|
|
@@ -3107,12 +3693,12 @@ async function readWriterLocks(dir, io) {
|
|
|
3107
3693
|
} catch (error) {
|
|
3108
3694
|
return { supported: false, missing: isMissing(error) };
|
|
3109
3695
|
}
|
|
3110
|
-
const
|
|
3696
|
+
const open4 = /* @__PURE__ */ new Set();
|
|
3111
3697
|
for (const name of names) {
|
|
3112
3698
|
const threadId = threadIdOfLock(name);
|
|
3113
|
-
if (threadId !== null)
|
|
3699
|
+
if (threadId !== null) open4.add(threadId);
|
|
3114
3700
|
}
|
|
3115
|
-
return { supported: true, open:
|
|
3701
|
+
return { supported: true, open: open4 };
|
|
3116
3702
|
}
|
|
3117
3703
|
|
|
3118
3704
|
// src/collector/adapters/codex/index.ts
|
|
@@ -3276,7 +3862,7 @@ function createCodexAdapter(options = {}) {
|
|
|
3276
3862
|
const chosen = /* @__PURE__ */ new Map();
|
|
3277
3863
|
const subagentWrites = /* @__PURE__ */ new Map();
|
|
3278
3864
|
for (const ref of [...candidates.values()].sort((a, b) => a.path < b.path ? -1 : 1)) {
|
|
3279
|
-
const
|
|
3865
|
+
const open4 = locked(ref.threadId);
|
|
3280
3866
|
let info;
|
|
3281
3867
|
try {
|
|
3282
3868
|
info = await io.lstat(ref.path);
|
|
@@ -3284,7 +3870,7 @@ function createCodexAdapter(options = {}) {
|
|
|
3284
3870
|
tracked.delete(ref.path);
|
|
3285
3871
|
continue;
|
|
3286
3872
|
}
|
|
3287
|
-
if (info.kind !== "file" || !
|
|
3873
|
+
if (info.kind !== "file" || !open4 && info.mtimeMs < cutoff) {
|
|
3288
3874
|
tracked.delete(ref.path);
|
|
3289
3875
|
continue;
|
|
3290
3876
|
}
|
|
@@ -3307,7 +3893,7 @@ function createCodexAdapter(options = {}) {
|
|
|
3307
3893
|
}
|
|
3308
3894
|
if (state.lastTurnImported) continue;
|
|
3309
3895
|
const at = lastActivity(state, checkedAt);
|
|
3310
|
-
if (!
|
|
3896
|
+
if (!open4 && (at === null || !isWithinRetention(at, checkedAt))) {
|
|
3311
3897
|
tracked.delete(ref.path);
|
|
3312
3898
|
continue;
|
|
3313
3899
|
}
|
|
@@ -3496,8 +4082,8 @@ var STATUS_FILE_CAPABILITIES = {
|
|
|
3496
4082
|
jump: { level: "no", reason: "Nothing in a status file is used to reach a session." },
|
|
3497
4083
|
"quiet-for": { level: "partly", reason: "If the agent writes its file again as it works." }
|
|
3498
4084
|
};
|
|
3499
|
-
function counted(
|
|
3500
|
-
return `${
|
|
4085
|
+
function counted(count2, one, many) {
|
|
4086
|
+
return `${count2} ${count2 === 1 ? one : many}`;
|
|
3501
4087
|
}
|
|
3502
4088
|
function createStatusFileAdapter(options = {}) {
|
|
3503
4089
|
const env = options.env ?? process.env;
|
|
@@ -3540,9 +4126,9 @@ function createStatusFileAdapter(options = {}) {
|
|
|
3540
4126
|
return { health, sessions: [] };
|
|
3541
4127
|
}
|
|
3542
4128
|
async function readOne(file) {
|
|
3543
|
-
let
|
|
4129
|
+
let open4;
|
|
3544
4130
|
try {
|
|
3545
|
-
|
|
4131
|
+
open4 = await io.openRegular(file);
|
|
3546
4132
|
} catch (error) {
|
|
3547
4133
|
if (isMissing(error)) return { kind: "gone" };
|
|
3548
4134
|
const code = error?.code;
|
|
@@ -3551,15 +4137,15 @@ function createStatusFileAdapter(options = {}) {
|
|
|
3551
4137
|
}
|
|
3552
4138
|
const tooLarge = { kind: "refused", why: "is over 16 KB" };
|
|
3553
4139
|
try {
|
|
3554
|
-
if (
|
|
3555
|
-
const bytes2 = await
|
|
4140
|
+
if (open4.info.size > MAX_FILE_BYTES) return tooLarge;
|
|
4141
|
+
const bytes2 = await open4.read(0, MAX_FILE_BYTES + 1);
|
|
3556
4142
|
if (bytes2.byteLength > MAX_FILE_BYTES) return tooLarge;
|
|
3557
4143
|
const parsed = parseStatusFile(decoder2.decode(bytes2));
|
|
3558
|
-
return parsed ? { kind: "read", file: parsed, mtimeMs:
|
|
4144
|
+
return parsed ? { kind: "read", file: parsed, mtimeMs: open4.info.mtimeMs } : { kind: "invalid" };
|
|
3559
4145
|
} catch {
|
|
3560
4146
|
return { kind: "refused", why: "could not be read" };
|
|
3561
4147
|
} finally {
|
|
3562
|
-
await
|
|
4148
|
+
await open4.close().catch(() => {
|
|
3563
4149
|
});
|
|
3564
4150
|
}
|
|
3565
4151
|
}
|
|
@@ -3832,6 +4418,9 @@ function createEventStore(capacity = EVENT_CAPACITY) {
|
|
|
3832
4418
|
}
|
|
3833
4419
|
return newestFirst;
|
|
3834
4420
|
},
|
|
4421
|
+
clear() {
|
|
4422
|
+
events = [];
|
|
4423
|
+
},
|
|
3835
4424
|
get size() {
|
|
3836
4425
|
return events.length;
|
|
3837
4426
|
}
|
|
@@ -3900,21 +4489,21 @@ var MAX_LEVELS = 24;
|
|
|
3900
4489
|
var READ_WAIT_MS = 1e3;
|
|
3901
4490
|
var decoder = new TextDecoder("utf-8", { fatal: true });
|
|
3902
4491
|
async function readSmall(io, file) {
|
|
3903
|
-
let
|
|
4492
|
+
let open4;
|
|
3904
4493
|
try {
|
|
3905
|
-
|
|
4494
|
+
open4 = await io.openRegular(file);
|
|
3906
4495
|
} catch (error) {
|
|
3907
4496
|
return isMissing(error) ? void 0 : null;
|
|
3908
4497
|
}
|
|
3909
4498
|
try {
|
|
3910
|
-
if (
|
|
3911
|
-
const bytes2 = await
|
|
4499
|
+
if (open4.info.size > MAX_GIT_FILE_BYTES) return null;
|
|
4500
|
+
const bytes2 = await open4.read(0, MAX_GIT_FILE_BYTES + 1);
|
|
3912
4501
|
if (bytes2.byteLength > MAX_GIT_FILE_BYTES) return null;
|
|
3913
4502
|
return decoder.decode(bytes2);
|
|
3914
4503
|
} catch {
|
|
3915
4504
|
return null;
|
|
3916
4505
|
} finally {
|
|
3917
|
-
await
|
|
4506
|
+
await open4.close().catch(() => {
|
|
3918
4507
|
});
|
|
3919
4508
|
}
|
|
3920
4509
|
}
|
|
@@ -4016,52 +4605,916 @@ function createBranchFinder(options = {}) {
|
|
|
4016
4605
|
};
|
|
4017
4606
|
}
|
|
4018
4607
|
|
|
4019
|
-
// src/collector/
|
|
4608
|
+
// src/collector/history/clearRoute.ts
|
|
4609
|
+
var MAX_CLEAR_BODY_BYTES = 64;
|
|
4610
|
+
function isEmptyObject(body) {
|
|
4611
|
+
let value;
|
|
4612
|
+
try {
|
|
4613
|
+
value = JSON.parse(body);
|
|
4614
|
+
} catch {
|
|
4615
|
+
return false;
|
|
4616
|
+
}
|
|
4617
|
+
return typeof value === "object" && value !== null && !Array.isArray(value) && Object.keys(value).length === 0;
|
|
4618
|
+
}
|
|
4619
|
+
var failed = (status, reason, error) => ({
|
|
4620
|
+
status,
|
|
4621
|
+
body: { error, reason }
|
|
4622
|
+
});
|
|
4623
|
+
function createClearHistoryRoute(options) {
|
|
4624
|
+
const { keeper, forget } = options;
|
|
4625
|
+
return async function answerClear(req) {
|
|
4626
|
+
const refused = actionRefusalFor(req, CLEAR_HISTORY_ACTION, MAX_CLEAR_BODY_BYTES);
|
|
4627
|
+
if (refused) {
|
|
4628
|
+
req.resume();
|
|
4629
|
+
return refused;
|
|
4630
|
+
}
|
|
4631
|
+
const body = await readRequestBody(req, MAX_CLEAR_BODY_BYTES);
|
|
4632
|
+
if (!body.ok) {
|
|
4633
|
+
req.resume();
|
|
4634
|
+
return body.tooLarge ? refusal(413, `The body must be no more than ${MAX_CLEAR_BODY_BYTES} bytes.`) : refusal(400, "The body could not be read.");
|
|
4635
|
+
}
|
|
4636
|
+
if (!isEmptyObject(body.text)) {
|
|
4637
|
+
return refusal(400, "The body must be an empty JSON object: {}.");
|
|
4638
|
+
}
|
|
4639
|
+
if (keeper === null) {
|
|
4640
|
+
return failed(
|
|
4641
|
+
409,
|
|
4642
|
+
"memory-only",
|
|
4643
|
+
"History is kept in memory only, with AGENT_LOOKOUT_HISTORY=off, so there are no files to clear."
|
|
4644
|
+
);
|
|
4645
|
+
}
|
|
4646
|
+
const outcome = await keeper.clear(forget);
|
|
4647
|
+
if (outcome.ok) {
|
|
4648
|
+
return {
|
|
4649
|
+
status: 200,
|
|
4650
|
+
body: { ok: true, clearedAt: outcome.at }
|
|
4651
|
+
};
|
|
4652
|
+
}
|
|
4653
|
+
return failed(outcome.reason === "not-writing" ? 409 : 500, outcome.reason, outcome.error);
|
|
4654
|
+
};
|
|
4655
|
+
}
|
|
4656
|
+
|
|
4657
|
+
// src/collector/history/historyKeeper.ts
|
|
4020
4658
|
import path13 from "node:path";
|
|
4021
|
-
|
|
4022
|
-
|
|
4023
|
-
|
|
4024
|
-
|
|
4025
|
-
|
|
4026
|
-
|
|
4027
|
-
|
|
4028
|
-
|
|
4659
|
+
|
|
4660
|
+
// src/collector/history/historyFiles.ts
|
|
4661
|
+
import {
|
|
4662
|
+
closeSync,
|
|
4663
|
+
constants as constants4,
|
|
4664
|
+
fstatSync,
|
|
4665
|
+
lstatSync,
|
|
4666
|
+
openSync,
|
|
4667
|
+
readSync,
|
|
4668
|
+
unlinkSync,
|
|
4669
|
+
writeSync
|
|
4670
|
+
} from "node:fs";
|
|
4671
|
+
import { lstat as lstat2, lutimes, mkdir, open as open3, readdir as readdir3, unlink } from "node:fs/promises";
|
|
4672
|
+
function infoOf2(info) {
|
|
4673
|
+
return { kind: info.isFile() ? "file" : "other", size: info.size, mtimeMs: info.mtimeMs };
|
|
4674
|
+
}
|
|
4675
|
+
var FILE_MODE = 384;
|
|
4676
|
+
var FOLDER_MODE = 448;
|
|
4677
|
+
var NO_FOLLOW = (constants4.O_NOFOLLOW ?? 0) | (constants4.O_NONBLOCK ?? 0);
|
|
4678
|
+
var READ = constants4.O_RDONLY | NO_FOLLOW;
|
|
4679
|
+
var APPEND = constants4.O_WRONLY | constants4.O_APPEND | constants4.O_CREAT | NO_FOLLOW;
|
|
4680
|
+
var CREATE = constants4.O_WRONLY | constants4.O_CREAT | constants4.O_EXCL | NO_FOLLOW;
|
|
4681
|
+
function notOrdinary() {
|
|
4682
|
+
return new Error("Not an ordinary file.");
|
|
4683
|
+
}
|
|
4684
|
+
function isMissing2(error) {
|
|
4685
|
+
return error?.code === "ENOENT";
|
|
4686
|
+
}
|
|
4687
|
+
async function readRegular(file, maxBytes) {
|
|
4688
|
+
const handle = await open3(file, READ);
|
|
4689
|
+
try {
|
|
4690
|
+
const info = await handle.stat();
|
|
4691
|
+
if (!info.isFile()) throw notOrdinary();
|
|
4692
|
+
if (info.size > maxBytes) throw new Error("Too large to be a history file.");
|
|
4693
|
+
const buffer = Buffer.alloc(info.size);
|
|
4694
|
+
let filled = 0;
|
|
4695
|
+
while (filled < buffer.byteLength) {
|
|
4696
|
+
const { bytesRead } = await handle.read(buffer, filled, buffer.byteLength - filled, filled);
|
|
4697
|
+
if (bytesRead === 0) break;
|
|
4698
|
+
filled += bytesRead;
|
|
4699
|
+
}
|
|
4700
|
+
return buffer.toString("utf8", 0, filled);
|
|
4701
|
+
} finally {
|
|
4702
|
+
await handle.close();
|
|
4703
|
+
}
|
|
4029
4704
|
}
|
|
4030
|
-
function
|
|
4031
|
-
|
|
4705
|
+
function readRegularNow(file, maxBytes) {
|
|
4706
|
+
const fd = openSync(file, READ);
|
|
4707
|
+
try {
|
|
4708
|
+
const info = fstatSync(fd);
|
|
4709
|
+
if (!info.isFile()) throw notOrdinary();
|
|
4710
|
+
if (info.size > maxBytes) throw new Error("Too large to be a history file.");
|
|
4711
|
+
const buffer = Buffer.alloc(info.size);
|
|
4712
|
+
let filled = 0;
|
|
4713
|
+
while (filled < buffer.byteLength) {
|
|
4714
|
+
const read = readSync(fd, buffer, filled, buffer.byteLength - filled, filled);
|
|
4715
|
+
if (read === 0) break;
|
|
4716
|
+
filled += read;
|
|
4717
|
+
}
|
|
4718
|
+
return buffer.toString("utf8", 0, filled);
|
|
4719
|
+
} finally {
|
|
4720
|
+
closeSync(fd);
|
|
4721
|
+
}
|
|
4032
4722
|
}
|
|
4033
|
-
function
|
|
4034
|
-
|
|
4035
|
-
|
|
4036
|
-
|
|
4037
|
-
|
|
4723
|
+
function writeNow(file, flags, text4) {
|
|
4724
|
+
const fd = openSync(file, flags, FILE_MODE);
|
|
4725
|
+
try {
|
|
4726
|
+
if (!fstatSync(fd).isFile()) throw notOrdinary();
|
|
4727
|
+
writeSync(fd, text4);
|
|
4728
|
+
} finally {
|
|
4729
|
+
closeSync(fd);
|
|
4730
|
+
}
|
|
4038
4731
|
}
|
|
4039
|
-
function
|
|
4040
|
-
const
|
|
4041
|
-
|
|
4042
|
-
|
|
4043
|
-
|
|
4044
|
-
|
|
4045
|
-
|
|
4046
|
-
const parent = table.get(current);
|
|
4047
|
-
if (!parent) return void 0;
|
|
4048
|
-
const app = appOfProgram(parent.path, homeDir);
|
|
4049
|
-
if (app) return { app, tty };
|
|
4050
|
-
if (parent.tty !== own.tty) return void 0;
|
|
4051
|
-
passed.add(current);
|
|
4052
|
-
current = parent.ppid;
|
|
4732
|
+
async function writeTo(file, flags, text4) {
|
|
4733
|
+
const handle = await open3(file, flags, FILE_MODE);
|
|
4734
|
+
try {
|
|
4735
|
+
if (!(await handle.stat()).isFile()) throw notOrdinary();
|
|
4736
|
+
await handle.writeFile(text4, "utf8");
|
|
4737
|
+
} finally {
|
|
4738
|
+
await handle.close();
|
|
4053
4739
|
}
|
|
4054
|
-
return void 0;
|
|
4055
4740
|
}
|
|
4741
|
+
var nodeHistoryFs = {
|
|
4742
|
+
async makeFolder(dir) {
|
|
4743
|
+
await mkdir(dir, { recursive: true, mode: FOLDER_MODE });
|
|
4744
|
+
},
|
|
4745
|
+
list: (dir) => readdir3(dir),
|
|
4746
|
+
lstat: async (file) => infoOf2(await lstat2(file)),
|
|
4747
|
+
lstatNow: (file) => infoOf2(lstatSync(file)),
|
|
4748
|
+
readRegular,
|
|
4749
|
+
readRegularNow,
|
|
4750
|
+
append: (file, text4) => writeTo(file, APPEND, text4),
|
|
4751
|
+
appendNow: (file, text4) => writeNow(file, APPEND, text4),
|
|
4752
|
+
create: (file, text4) => writeTo(file, CREATE, text4),
|
|
4753
|
+
createNow: (file, text4) => writeNow(file, CREATE, text4),
|
|
4754
|
+
async touch(file, at) {
|
|
4755
|
+
const when = new Date(at);
|
|
4756
|
+
await lutimes(file, when, when);
|
|
4757
|
+
},
|
|
4758
|
+
async remove(file) {
|
|
4759
|
+
try {
|
|
4760
|
+
await unlink(file);
|
|
4761
|
+
} catch (error) {
|
|
4762
|
+
if (!isMissing2(error)) throw error;
|
|
4763
|
+
}
|
|
4764
|
+
},
|
|
4765
|
+
removeNow(file) {
|
|
4766
|
+
try {
|
|
4767
|
+
unlinkSync(file);
|
|
4768
|
+
} catch (error) {
|
|
4769
|
+
if (!isMissing2(error)) throw error;
|
|
4770
|
+
}
|
|
4771
|
+
}
|
|
4772
|
+
};
|
|
4056
4773
|
|
|
4057
|
-
// src/collector/
|
|
4058
|
-
|
|
4059
|
-
var
|
|
4060
|
-
var
|
|
4061
|
-
|
|
4062
|
-
|
|
4063
|
-
|
|
4064
|
-
|
|
4774
|
+
// src/collector/history/writerLock.ts
|
|
4775
|
+
import { randomBytes } from "node:crypto";
|
|
4776
|
+
var LOCK_FILE = "writer.lock";
|
|
4777
|
+
var LOCK_STALE_MS = 3e4;
|
|
4778
|
+
var MAX_LOCK_BYTES = 256;
|
|
4779
|
+
var MAX_MARK_LENGTH = 64;
|
|
4780
|
+
function mark(value) {
|
|
4781
|
+
return typeof value === "string" && value !== "" && value.length <= MAX_MARK_LENGTH ? value : null;
|
|
4782
|
+
}
|
|
4783
|
+
function holderIn(text4) {
|
|
4784
|
+
try {
|
|
4785
|
+
const value = JSON.parse(text4);
|
|
4786
|
+
if (typeof value !== "object" || value === null) return null;
|
|
4787
|
+
const fields = value;
|
|
4788
|
+
const pid = validPid(fields.pid);
|
|
4789
|
+
if (pid === void 0) return null;
|
|
4790
|
+
return { pid, token: mark(fields.token), sources: mark(fields.sources) };
|
|
4791
|
+
} catch {
|
|
4792
|
+
return null;
|
|
4793
|
+
}
|
|
4794
|
+
}
|
|
4795
|
+
function createWriterLock(options) {
|
|
4796
|
+
const { fs, file } = options;
|
|
4797
|
+
const pid = options.pid ?? process.pid;
|
|
4798
|
+
const now = options.now ?? Date.now;
|
|
4799
|
+
const isAlive = options.isAlive ?? isProcessAlive;
|
|
4800
|
+
const staleAfterMs = options.staleAfterMs ?? LOCK_STALE_MS;
|
|
4801
|
+
const token = options.token ?? randomBytes(8).toString("hex");
|
|
4802
|
+
const sources = options.sources ?? null;
|
|
4803
|
+
const content = `${JSON.stringify({ pid, token, ...sources !== null && { sources } })}
|
|
4804
|
+
`;
|
|
4805
|
+
let held = false;
|
|
4806
|
+
let heldByOtherSources = false;
|
|
4807
|
+
const isOwn = (holder2) => holder2?.pid === pid && holder2.token === token;
|
|
4808
|
+
function verdict(info, holder2) {
|
|
4809
|
+
heldByOtherSources = false;
|
|
4810
|
+
if (info.kind !== "file") return "leave";
|
|
4811
|
+
const fresh = now() - info.mtimeMs < staleAfterMs;
|
|
4812
|
+
if (!fresh || isOwn(holder2)) return "take-over";
|
|
4813
|
+
if (holder2 === null) return "leave";
|
|
4814
|
+
if (holder2.pid === pid || isAlive(holder2.pid)) {
|
|
4815
|
+
heldByOtherSources = sources !== null && holder2.sources !== null && holder2.sources !== sources;
|
|
4816
|
+
return "leave";
|
|
4817
|
+
}
|
|
4818
|
+
return "take-over";
|
|
4819
|
+
}
|
|
4820
|
+
async function make() {
|
|
4821
|
+
try {
|
|
4822
|
+
await fs.create(file, content);
|
|
4823
|
+
held = true;
|
|
4824
|
+
heldByOtherSources = false;
|
|
4825
|
+
return true;
|
|
4826
|
+
} catch {
|
|
4827
|
+
return false;
|
|
4828
|
+
}
|
|
4829
|
+
}
|
|
4830
|
+
function makeNow() {
|
|
4831
|
+
try {
|
|
4832
|
+
fs.createNow(file, content);
|
|
4833
|
+
held = true;
|
|
4834
|
+
heldByOtherSources = false;
|
|
4835
|
+
return true;
|
|
4836
|
+
} catch {
|
|
4837
|
+
return false;
|
|
4838
|
+
}
|
|
4839
|
+
}
|
|
4840
|
+
async function holder() {
|
|
4841
|
+
try {
|
|
4842
|
+
return holderIn(await fs.readRegular(file, MAX_LOCK_BYTES));
|
|
4843
|
+
} catch {
|
|
4844
|
+
return null;
|
|
4845
|
+
}
|
|
4846
|
+
}
|
|
4847
|
+
function holderNow() {
|
|
4848
|
+
try {
|
|
4849
|
+
return holderIn(fs.readRegularNow(file, MAX_LOCK_BYTES));
|
|
4850
|
+
} catch {
|
|
4851
|
+
return null;
|
|
4852
|
+
}
|
|
4853
|
+
}
|
|
4854
|
+
return {
|
|
4855
|
+
async take() {
|
|
4856
|
+
if (await make()) return true;
|
|
4857
|
+
let info;
|
|
4858
|
+
try {
|
|
4859
|
+
info = await fs.lstat(file);
|
|
4860
|
+
} catch {
|
|
4861
|
+
return make();
|
|
4862
|
+
}
|
|
4863
|
+
if (verdict(info, await holder()) === "leave") return false;
|
|
4864
|
+
try {
|
|
4865
|
+
await fs.remove(file);
|
|
4866
|
+
} catch {
|
|
4867
|
+
return false;
|
|
4868
|
+
}
|
|
4869
|
+
return make();
|
|
4870
|
+
},
|
|
4871
|
+
takeNow() {
|
|
4872
|
+
if (makeNow()) return true;
|
|
4873
|
+
let info;
|
|
4874
|
+
try {
|
|
4875
|
+
info = fs.lstatNow(file);
|
|
4876
|
+
} catch {
|
|
4877
|
+
return makeNow();
|
|
4878
|
+
}
|
|
4879
|
+
if (verdict(info, holderNow()) === "leave") return false;
|
|
4880
|
+
try {
|
|
4881
|
+
fs.removeNow(file);
|
|
4882
|
+
} catch {
|
|
4883
|
+
return false;
|
|
4884
|
+
}
|
|
4885
|
+
return makeNow();
|
|
4886
|
+
},
|
|
4887
|
+
async keep() {
|
|
4888
|
+
if (!held) return false;
|
|
4889
|
+
if (!isOwn(await holder())) {
|
|
4890
|
+
held = false;
|
|
4891
|
+
return false;
|
|
4892
|
+
}
|
|
4893
|
+
try {
|
|
4894
|
+
await fs.touch(file, now());
|
|
4895
|
+
} catch {
|
|
4896
|
+
}
|
|
4897
|
+
return true;
|
|
4898
|
+
},
|
|
4899
|
+
stillHeldNow() {
|
|
4900
|
+
if (!held) return false;
|
|
4901
|
+
if (isOwn(holderNow())) return true;
|
|
4902
|
+
held = false;
|
|
4903
|
+
return false;
|
|
4904
|
+
},
|
|
4905
|
+
releaseNow() {
|
|
4906
|
+
if (!held) return;
|
|
4907
|
+
held = false;
|
|
4908
|
+
if (!isOwn(holderNow())) return;
|
|
4909
|
+
try {
|
|
4910
|
+
fs.removeNow(file);
|
|
4911
|
+
} catch {
|
|
4912
|
+
}
|
|
4913
|
+
},
|
|
4914
|
+
get held() {
|
|
4915
|
+
return held;
|
|
4916
|
+
},
|
|
4917
|
+
get heldByOtherSources() {
|
|
4918
|
+
return heldByOtherSources;
|
|
4919
|
+
}
|
|
4920
|
+
};
|
|
4921
|
+
}
|
|
4922
|
+
|
|
4923
|
+
// src/collector/history/historyKeeper.ts
|
|
4924
|
+
var FLUSH_INTERVAL_MS = 5e3;
|
|
4925
|
+
var RESTORE_DEADLINE_MS = 1e4;
|
|
4926
|
+
var MAX_QUEUED_LINES = 5e4;
|
|
4927
|
+
var MAX_PART = 9999;
|
|
4928
|
+
var FUTURE_SLACK_MS = 6e4;
|
|
4929
|
+
var MAX_RESTARTS = 500;
|
|
4930
|
+
var BEGINNING = {
|
|
4931
|
+
start: "started",
|
|
4932
|
+
cleared: "cleared",
|
|
4933
|
+
point: "trimmed",
|
|
4934
|
+
event: "trimmed"
|
|
4935
|
+
};
|
|
4936
|
+
function byteLength(text4) {
|
|
4937
|
+
return Buffer.byteLength(text4, "utf8");
|
|
4938
|
+
}
|
|
4939
|
+
function isNotOrdinary(error) {
|
|
4940
|
+
const code = error?.code;
|
|
4941
|
+
return code === "ELOOP" || code === "EISDIR" || code === "EMLINK" || code === "ENXIO" || code === "EOPNOTSUPP" || error instanceof Error && error.message === "Not an ordinary file.";
|
|
4942
|
+
}
|
|
4943
|
+
function newest(list, limit) {
|
|
4944
|
+
return list.length > limit ? list.slice(list.length - Math.max(0, limit)) : list;
|
|
4945
|
+
}
|
|
4946
|
+
function createHistoryKeeper(options) {
|
|
4947
|
+
const { dir, folder } = options;
|
|
4948
|
+
const fs = options.fs ?? nodeHistoryFs;
|
|
4949
|
+
const now = options.now ?? Date.now;
|
|
4950
|
+
const flushIntervalMs = options.flushIntervalMs ?? FLUSH_INTERVAL_MS;
|
|
4951
|
+
const restoreDeadlineMs = options.restoreDeadlineMs ?? RESTORE_DEADLINE_MS;
|
|
4952
|
+
const maxBytes = options.maxBytes ?? HISTORY_MAX_BYTES;
|
|
4953
|
+
const maxFileBytes = options.maxFileBytes ?? HISTORY_MAX_FILE_BYTES;
|
|
4954
|
+
const readLimit = Math.floor(maxFileBytes * 1.05);
|
|
4955
|
+
const maxAgeMs = options.maxAgeMs ?? HISTORY_MAX_AGE_MS;
|
|
4956
|
+
const warn = options.warn ?? (() => {
|
|
4957
|
+
});
|
|
4958
|
+
const lock = createWriterLock({
|
|
4959
|
+
fs,
|
|
4960
|
+
file: path13.join(dir, LOCK_FILE),
|
|
4961
|
+
pid: options.pid,
|
|
4962
|
+
now,
|
|
4963
|
+
isAlive: options.isAlive,
|
|
4964
|
+
sources: options.sources ?? null
|
|
4965
|
+
});
|
|
4966
|
+
const files = /* @__PURE__ */ new Map();
|
|
4967
|
+
const blocked = /* @__PURE__ */ new Set();
|
|
4968
|
+
const touched = /* @__PURE__ */ new Set();
|
|
4969
|
+
let queue = [];
|
|
4970
|
+
let startedAt = null;
|
|
4971
|
+
let target = null;
|
|
4972
|
+
let restored = false;
|
|
4973
|
+
let unusable = null;
|
|
4974
|
+
let failing = null;
|
|
4975
|
+
let clearedAt = null;
|
|
4976
|
+
let writer = null;
|
|
4977
|
+
let running = false;
|
|
4978
|
+
let timer = null;
|
|
4979
|
+
let chain = Promise.resolve();
|
|
4980
|
+
function serially(work) {
|
|
4981
|
+
const next = chain.then(work, work);
|
|
4982
|
+
chain = next.catch(() => {
|
|
4983
|
+
});
|
|
4984
|
+
return next;
|
|
4985
|
+
}
|
|
4986
|
+
const full = (name) => path13.join(dir, name);
|
|
4987
|
+
function hold(record) {
|
|
4988
|
+
if (unusable !== null || !running) return;
|
|
4989
|
+
const line = encodeRecord(record);
|
|
4990
|
+
if (line.length > MAX_LINE_LENGTH || byteLength(line) > maxFileBytes) return;
|
|
4991
|
+
queue.push(line);
|
|
4992
|
+
if (queue.length > MAX_QUEUED_LINES) queue = queue.slice(queue.length - MAX_QUEUED_LINES);
|
|
4993
|
+
}
|
|
4994
|
+
function recordsIn(text4, at) {
|
|
4995
|
+
return parseHistoryText(text4).records.filter(
|
|
4996
|
+
(record) => recordAt(record) <= at + FUTURE_SLACK_MS
|
|
4997
|
+
);
|
|
4998
|
+
}
|
|
4999
|
+
async function readAll(limits) {
|
|
5000
|
+
try {
|
|
5001
|
+
await fs.makeFolder(dir);
|
|
5002
|
+
} catch {
|
|
5003
|
+
return `Agent Lookout could not make its history folder, ${folder}, so this run keeps history in memory only.`;
|
|
5004
|
+
}
|
|
5005
|
+
let names;
|
|
5006
|
+
try {
|
|
5007
|
+
names = await fs.list(dir);
|
|
5008
|
+
} catch {
|
|
5009
|
+
return `Agent Lookout could not read its history folder, ${folder}, so this run keeps history in memory only.`;
|
|
5010
|
+
}
|
|
5011
|
+
const ours = names.map(readHistoryFileName).filter((file) => file?.version === HISTORY_FORMAT_VERSION).sort(byFileOrder);
|
|
5012
|
+
const found = /* @__PURE__ */ new Map();
|
|
5013
|
+
const notFiles = /* @__PURE__ */ new Set();
|
|
5014
|
+
const events = /* @__PURE__ */ new Map();
|
|
5015
|
+
const points = [];
|
|
5016
|
+
const starts = [];
|
|
5017
|
+
const times = [];
|
|
5018
|
+
let lastAt = null;
|
|
5019
|
+
const at = now();
|
|
5020
|
+
for (const file of ours) {
|
|
5021
|
+
let info;
|
|
5022
|
+
try {
|
|
5023
|
+
info = await fs.lstat(full(file.name));
|
|
5024
|
+
} catch {
|
|
5025
|
+
continue;
|
|
5026
|
+
}
|
|
5027
|
+
if (info.kind !== "file") {
|
|
5028
|
+
notFiles.add(file.name);
|
|
5029
|
+
continue;
|
|
5030
|
+
}
|
|
5031
|
+
const known = { ...file, size: info.size, first: null };
|
|
5032
|
+
found.set(file.name, known);
|
|
5033
|
+
if (dayEnd(file) + maxAgeMs < at || info.size > readLimit) continue;
|
|
5034
|
+
let text4;
|
|
5035
|
+
try {
|
|
5036
|
+
text4 = await fs.readRegular(full(file.name), readLimit);
|
|
5037
|
+
} catch {
|
|
5038
|
+
continue;
|
|
5039
|
+
}
|
|
5040
|
+
const records = recordsIn(text4, at);
|
|
5041
|
+
const first = records[0];
|
|
5042
|
+
if (first) known.first = { at: recordAt(first), kind: first.kind };
|
|
5043
|
+
for (const record of records) {
|
|
5044
|
+
const recorded = recordAt(record);
|
|
5045
|
+
times.push(recorded);
|
|
5046
|
+
if (lastAt === null || recorded > lastAt) lastAt = recorded;
|
|
5047
|
+
if (record.kind === "event") events.set(record.event.id, record.event);
|
|
5048
|
+
else if (record.kind === "point") points.push(record.point);
|
|
5049
|
+
else if (record.kind === "start") starts.push(record.at);
|
|
5050
|
+
}
|
|
5051
|
+
}
|
|
5052
|
+
const allEvents = [...events.values()].sort((a, b) => a.at - b.at);
|
|
5053
|
+
points.sort((a, b) => a.at - b.at);
|
|
5054
|
+
const allPoints = points.filter((point, index) => point.at !== points[index - 1]?.at);
|
|
5055
|
+
return {
|
|
5056
|
+
history: {
|
|
5057
|
+
events: newest(allEvents, limits.events),
|
|
5058
|
+
points: newest(allPoints, limits.points),
|
|
5059
|
+
restarts: newest(restartsAmong(starts, times), MAX_RESTARTS),
|
|
5060
|
+
lastAt,
|
|
5061
|
+
whole: {
|
|
5062
|
+
waitEvents: allEvents.filter(isWaitEvent),
|
|
5063
|
+
measured: pollRuns(
|
|
5064
|
+
allPoints.map((point) => point.at),
|
|
5065
|
+
starts
|
|
5066
|
+
)
|
|
5067
|
+
}
|
|
5068
|
+
},
|
|
5069
|
+
found,
|
|
5070
|
+
notFiles
|
|
5071
|
+
};
|
|
5072
|
+
}
|
|
5073
|
+
async function firstRecordOf(name, size) {
|
|
5074
|
+
if (size === 0 || size > readLimit) return null;
|
|
5075
|
+
try {
|
|
5076
|
+
const first = recordsIn(await fs.readRegular(full(name), readLimit), now())[0];
|
|
5077
|
+
return first ? { at: recordAt(first), kind: first.kind } : null;
|
|
5078
|
+
} catch {
|
|
5079
|
+
return null;
|
|
5080
|
+
}
|
|
5081
|
+
}
|
|
5082
|
+
async function rescan() {
|
|
5083
|
+
let names;
|
|
5084
|
+
try {
|
|
5085
|
+
names = await fs.list(dir);
|
|
5086
|
+
} catch {
|
|
5087
|
+
return;
|
|
5088
|
+
}
|
|
5089
|
+
const there = /* @__PURE__ */ new Set();
|
|
5090
|
+
for (const name of names) {
|
|
5091
|
+
const file = readHistoryFileName(name);
|
|
5092
|
+
if (file?.version !== HISTORY_FORMAT_VERSION) continue;
|
|
5093
|
+
let info;
|
|
5094
|
+
try {
|
|
5095
|
+
info = await fs.lstat(full(name));
|
|
5096
|
+
} catch {
|
|
5097
|
+
continue;
|
|
5098
|
+
}
|
|
5099
|
+
if (info.kind !== "file") {
|
|
5100
|
+
blocked.add(name);
|
|
5101
|
+
continue;
|
|
5102
|
+
}
|
|
5103
|
+
there.add(name);
|
|
5104
|
+
blocked.delete(name);
|
|
5105
|
+
const known = files.get(name);
|
|
5106
|
+
if (known?.size === info.size) continue;
|
|
5107
|
+
const fresh = known === void 0 || known.first === null || info.size < known.size;
|
|
5108
|
+
const first = fresh ? await firstRecordOf(name, info.size) : known.first;
|
|
5109
|
+
files.set(name, { ...file, size: info.size, first });
|
|
5110
|
+
}
|
|
5111
|
+
for (const name of [...files.keys()]) {
|
|
5112
|
+
if (!there.has(name)) files.delete(name);
|
|
5113
|
+
}
|
|
5114
|
+
}
|
|
5115
|
+
function aimAtNewest() {
|
|
5116
|
+
const day = dayOf(now());
|
|
5117
|
+
let part = 0;
|
|
5118
|
+
for (const file of files.values()) {
|
|
5119
|
+
if (file.day === day && file.part > part) part = file.part;
|
|
5120
|
+
}
|
|
5121
|
+
target = part > 0 ? { day, part } : null;
|
|
5122
|
+
}
|
|
5123
|
+
async function prune(writingTo, incoming) {
|
|
5124
|
+
const doomed = filesToDelete([...files.values()], writingTo, incoming, {
|
|
5125
|
+
now: now(),
|
|
5126
|
+
maxBytes,
|
|
5127
|
+
maxAgeMs
|
|
5128
|
+
});
|
|
5129
|
+
for (const name of doomed) {
|
|
5130
|
+
try {
|
|
5131
|
+
await fs.remove(full(name));
|
|
5132
|
+
} catch {
|
|
5133
|
+
}
|
|
5134
|
+
files.delete(name);
|
|
5135
|
+
}
|
|
5136
|
+
}
|
|
5137
|
+
function* chunks(lines) {
|
|
5138
|
+
const day = dayOf(now());
|
|
5139
|
+
let part = target?.day === day ? target.part : 1;
|
|
5140
|
+
let index = 0;
|
|
5141
|
+
while (index < lines.length && part <= MAX_PART) {
|
|
5142
|
+
const name = historyFileName(day, part);
|
|
5143
|
+
const known = files.get(name);
|
|
5144
|
+
const size = known?.size ?? 0;
|
|
5145
|
+
const lead = size > 0 && !touched.has(name) ? "\n" : "";
|
|
5146
|
+
const room = maxFileBytes - size - lead.length;
|
|
5147
|
+
const firstBytes = byteLength(lines[index]);
|
|
5148
|
+
if (blocked.has(name) || firstBytes > room) {
|
|
5149
|
+
part += 1;
|
|
5150
|
+
continue;
|
|
5151
|
+
}
|
|
5152
|
+
let text4 = lead;
|
|
5153
|
+
let bytes2 = lead.length;
|
|
5154
|
+
let end = index;
|
|
5155
|
+
while (end < lines.length) {
|
|
5156
|
+
const lineBytes = byteLength(lines[end]);
|
|
5157
|
+
if (bytes2 - lead.length + lineBytes > room) break;
|
|
5158
|
+
text4 += lines[end];
|
|
5159
|
+
bytes2 += lineBytes;
|
|
5160
|
+
end += 1;
|
|
5161
|
+
}
|
|
5162
|
+
const outcome = yield { name, text: text4 };
|
|
5163
|
+
if (outcome === "blocked") {
|
|
5164
|
+
blocked.add(name);
|
|
5165
|
+
part += 1;
|
|
5166
|
+
continue;
|
|
5167
|
+
}
|
|
5168
|
+
const record = parseRecord(lines[index].trim());
|
|
5169
|
+
const parsed = readHistoryFileName(name);
|
|
5170
|
+
files.set(name, {
|
|
5171
|
+
...parsed,
|
|
5172
|
+
size: size + bytes2,
|
|
5173
|
+
first: known?.first ?? (record && { at: recordAt(record), kind: record.kind })
|
|
5174
|
+
});
|
|
5175
|
+
touched.add(name);
|
|
5176
|
+
target = { day, part };
|
|
5177
|
+
index = end;
|
|
5178
|
+
}
|
|
5179
|
+
}
|
|
5180
|
+
function wrote() {
|
|
5181
|
+
failing = null;
|
|
5182
|
+
}
|
|
5183
|
+
function writeFailed() {
|
|
5184
|
+
touched.clear();
|
|
5185
|
+
if (failing === null) {
|
|
5186
|
+
failing = `Agent Lookout could not write to its history folder, ${folder}, so new history is kept in memory until it can.`;
|
|
5187
|
+
warn(failing);
|
|
5188
|
+
}
|
|
5189
|
+
}
|
|
5190
|
+
async function writeLines(lines) {
|
|
5191
|
+
if (lines.length === 0) return true;
|
|
5192
|
+
const steps = chunks(lines);
|
|
5193
|
+
let step = steps.next("written");
|
|
5194
|
+
while (!step.done) {
|
|
5195
|
+
const { name, text: text4 } = step.value;
|
|
5196
|
+
await prune(name, byteLength(text4));
|
|
5197
|
+
try {
|
|
5198
|
+
await fs.append(full(name), text4);
|
|
5199
|
+
} catch (error) {
|
|
5200
|
+
if (isNotOrdinary(error)) {
|
|
5201
|
+
step = steps.next("blocked");
|
|
5202
|
+
continue;
|
|
5203
|
+
}
|
|
5204
|
+
writeFailed();
|
|
5205
|
+
return false;
|
|
5206
|
+
}
|
|
5207
|
+
step = steps.next("written");
|
|
5208
|
+
}
|
|
5209
|
+
wrote();
|
|
5210
|
+
return true;
|
|
5211
|
+
}
|
|
5212
|
+
function writeLinesNow(lines) {
|
|
5213
|
+
if (lines.length === 0) return;
|
|
5214
|
+
const steps = chunks(lines);
|
|
5215
|
+
let step = steps.next("written");
|
|
5216
|
+
while (!step.done) {
|
|
5217
|
+
const { name, text: text4 } = step.value;
|
|
5218
|
+
try {
|
|
5219
|
+
fs.appendNow(full(name), text4);
|
|
5220
|
+
} catch (error) {
|
|
5221
|
+
if (isNotOrdinary(error)) {
|
|
5222
|
+
step = steps.next("blocked");
|
|
5223
|
+
continue;
|
|
5224
|
+
}
|
|
5225
|
+
return;
|
|
5226
|
+
}
|
|
5227
|
+
step = steps.next("written");
|
|
5228
|
+
}
|
|
5229
|
+
}
|
|
5230
|
+
function takeQueued() {
|
|
5231
|
+
const lines = startedAt === null ? queue : [encodeRecord({ kind: "start", at: startedAt }), ...queue];
|
|
5232
|
+
queue = [];
|
|
5233
|
+
startedAt = null;
|
|
5234
|
+
return lines;
|
|
5235
|
+
}
|
|
5236
|
+
let pruned = false;
|
|
5237
|
+
let beating = null;
|
|
5238
|
+
function beat() {
|
|
5239
|
+
beating ??= writeBeat().finally(() => {
|
|
5240
|
+
beating = null;
|
|
5241
|
+
});
|
|
5242
|
+
return beating;
|
|
5243
|
+
}
|
|
5244
|
+
function writeBeat() {
|
|
5245
|
+
return serially(async () => {
|
|
5246
|
+
if (!running || unusable !== null) return;
|
|
5247
|
+
const kept = lock.held && await lock.keep();
|
|
5248
|
+
const holds = kept || running && await lock.take();
|
|
5249
|
+
if (!running) {
|
|
5250
|
+
lock.releaseNow();
|
|
5251
|
+
return;
|
|
5252
|
+
}
|
|
5253
|
+
if (!holds) {
|
|
5254
|
+
writer = false;
|
|
5255
|
+
queue = [];
|
|
5256
|
+
startedAt = null;
|
|
5257
|
+
await rescan();
|
|
5258
|
+
return;
|
|
5259
|
+
}
|
|
5260
|
+
if (!kept) {
|
|
5261
|
+
await rescan();
|
|
5262
|
+
touched.clear();
|
|
5263
|
+
aimAtNewest();
|
|
5264
|
+
pruned = false;
|
|
5265
|
+
}
|
|
5266
|
+
writer = true;
|
|
5267
|
+
if (!pruned) {
|
|
5268
|
+
pruned = true;
|
|
5269
|
+
await prune(null, 0);
|
|
5270
|
+
}
|
|
5271
|
+
await writeLines(takeQueued());
|
|
5272
|
+
}).catch(() => {
|
|
5273
|
+
});
|
|
5274
|
+
}
|
|
5275
|
+
return {
|
|
5276
|
+
async restore(limits) {
|
|
5277
|
+
const none = {
|
|
5278
|
+
events: [],
|
|
5279
|
+
points: [],
|
|
5280
|
+
restarts: [],
|
|
5281
|
+
lastAt: null,
|
|
5282
|
+
whole: { waitEvents: [], measured: [] }
|
|
5283
|
+
};
|
|
5284
|
+
if (restored) return none;
|
|
5285
|
+
restored = true;
|
|
5286
|
+
let deadline;
|
|
5287
|
+
const late = new Promise((resolve) => {
|
|
5288
|
+
deadline = setTimeout(() => resolve("late"), restoreDeadlineMs);
|
|
5289
|
+
deadline.unref?.();
|
|
5290
|
+
});
|
|
5291
|
+
const reading = serially(() => readAll(limits)).catch(
|
|
5292
|
+
() => `Agent Lookout could not read its history folder, ${folder}, so this run keeps history in memory only.`
|
|
5293
|
+
);
|
|
5294
|
+
const outcome = await Promise.race([reading, late]);
|
|
5295
|
+
clearTimeout(deadline);
|
|
5296
|
+
if (outcome === "late") {
|
|
5297
|
+
unusable = `Agent Lookout could not read its history folder, ${folder}, in time, so this run keeps history in memory only.`;
|
|
5298
|
+
warn(unusable);
|
|
5299
|
+
return none;
|
|
5300
|
+
}
|
|
5301
|
+
if (typeof outcome === "string") {
|
|
5302
|
+
unusable = outcome;
|
|
5303
|
+
warn(unusable);
|
|
5304
|
+
return none;
|
|
5305
|
+
}
|
|
5306
|
+
for (const [name, file] of outcome.found) files.set(name, file);
|
|
5307
|
+
for (const name of outcome.notFiles) blocked.add(name);
|
|
5308
|
+
aimAtNewest();
|
|
5309
|
+
return outcome.history;
|
|
5310
|
+
},
|
|
5311
|
+
start() {
|
|
5312
|
+
if (running) return;
|
|
5313
|
+
running = true;
|
|
5314
|
+
if (unusable !== null) return;
|
|
5315
|
+
writer = lock.takeNow();
|
|
5316
|
+
pruned = false;
|
|
5317
|
+
startedAt = writer ? now() : null;
|
|
5318
|
+
void beat();
|
|
5319
|
+
timer = setInterval(() => void beat(), flushIntervalMs);
|
|
5320
|
+
timer.unref?.();
|
|
5321
|
+
},
|
|
5322
|
+
addEvents(events) {
|
|
5323
|
+
for (const event of events) hold({ kind: "event", event });
|
|
5324
|
+
},
|
|
5325
|
+
addPoint(point) {
|
|
5326
|
+
hold({ kind: "point", point });
|
|
5327
|
+
},
|
|
5328
|
+
async flush() {
|
|
5329
|
+
if (beating) await beating;
|
|
5330
|
+
await beat();
|
|
5331
|
+
},
|
|
5332
|
+
stop() {
|
|
5333
|
+
if (!running) return;
|
|
5334
|
+
running = false;
|
|
5335
|
+
if (timer) clearInterval(timer);
|
|
5336
|
+
timer = null;
|
|
5337
|
+
if (writer === true && unusable === null && lock.stillHeldNow()) {
|
|
5338
|
+
writeLinesNow(takeQueued());
|
|
5339
|
+
}
|
|
5340
|
+
queue = [];
|
|
5341
|
+
startedAt = null;
|
|
5342
|
+
writer = null;
|
|
5343
|
+
lock.releaseNow();
|
|
5344
|
+
},
|
|
5345
|
+
clear(forget) {
|
|
5346
|
+
return serially(async () => {
|
|
5347
|
+
if (unusable !== null || writer !== true || !lock.held) {
|
|
5348
|
+
return {
|
|
5349
|
+
ok: false,
|
|
5350
|
+
reason: "not-writing",
|
|
5351
|
+
error: "Another copy of Agent Lookout keeps the history, or it cannot be written here."
|
|
5352
|
+
};
|
|
5353
|
+
}
|
|
5354
|
+
let names;
|
|
5355
|
+
try {
|
|
5356
|
+
names = await fs.list(dir);
|
|
5357
|
+
} catch {
|
|
5358
|
+
return { ok: false, reason: "failed", error: "The history folder could not be read." };
|
|
5359
|
+
}
|
|
5360
|
+
let left = 0;
|
|
5361
|
+
for (const name of names) {
|
|
5362
|
+
if (readHistoryFileName(name)?.version !== HISTORY_FORMAT_VERSION) continue;
|
|
5363
|
+
try {
|
|
5364
|
+
const info = await fs.lstat(full(name));
|
|
5365
|
+
if (info.kind !== "file") continue;
|
|
5366
|
+
await fs.remove(full(name));
|
|
5367
|
+
files.delete(name);
|
|
5368
|
+
} catch {
|
|
5369
|
+
left += 1;
|
|
5370
|
+
}
|
|
5371
|
+
}
|
|
5372
|
+
const listed = new Set(names);
|
|
5373
|
+
for (const name of [...files.keys()]) {
|
|
5374
|
+
if (!listed.has(name)) files.delete(name);
|
|
5375
|
+
}
|
|
5376
|
+
touched.clear();
|
|
5377
|
+
aimAtNewest();
|
|
5378
|
+
if (left > 0) {
|
|
5379
|
+
return {
|
|
5380
|
+
ok: false,
|
|
5381
|
+
reason: "failed",
|
|
5382
|
+
error: `${left === 1 ? "One history file" : `${left} history files`} could not be deleted.`
|
|
5383
|
+
};
|
|
5384
|
+
}
|
|
5385
|
+
const at = now();
|
|
5386
|
+
queue = [];
|
|
5387
|
+
startedAt = null;
|
|
5388
|
+
clearedAt = at;
|
|
5389
|
+
forget();
|
|
5390
|
+
await writeLines([encodeRecord({ kind: "cleared", at })]);
|
|
5391
|
+
return { ok: true, at };
|
|
5392
|
+
});
|
|
5393
|
+
},
|
|
5394
|
+
since() {
|
|
5395
|
+
const oldest = [...files.values()].sort(byFileOrder).find((file) => file.first !== null);
|
|
5396
|
+
if (!oldest?.first) return clearedAt === null ? null : { at: clearedAt, by: "cleared" };
|
|
5397
|
+
return { at: oldest.first.at, by: BEGINNING[oldest.first.kind] };
|
|
5398
|
+
},
|
|
5399
|
+
status() {
|
|
5400
|
+
let bytes2 = 0;
|
|
5401
|
+
for (const file of files.values()) bytes2 += file.size;
|
|
5402
|
+
let problem = unusable ?? failing;
|
|
5403
|
+
if (problem === null && writer === false) {
|
|
5404
|
+
problem = lock.heldByOtherSources ? "Another copy of Agent Lookout on this computer is writing the history, and it reads sessions from other folders than this one. This one keeps what it sees in memory, and takes over when that one stops." : "Another copy of Agent Lookout on this computer is writing the history. This one keeps what it sees in memory, and takes over when that one stops.";
|
|
5405
|
+
}
|
|
5406
|
+
return {
|
|
5407
|
+
where: "disk",
|
|
5408
|
+
folder,
|
|
5409
|
+
bytes: bytes2,
|
|
5410
|
+
maxBytes,
|
|
5411
|
+
maxAgeMs,
|
|
5412
|
+
canClear: unusable === null && writer === true && lock.held,
|
|
5413
|
+
problem
|
|
5414
|
+
};
|
|
5415
|
+
}
|
|
5416
|
+
};
|
|
5417
|
+
}
|
|
5418
|
+
function keptEventStore(store, keeper) {
|
|
5419
|
+
return {
|
|
5420
|
+
add(events) {
|
|
5421
|
+
store.add(events);
|
|
5422
|
+
keeper.addEvents(events);
|
|
5423
|
+
},
|
|
5424
|
+
list: (options) => store.list(options),
|
|
5425
|
+
clear: () => store.clear(),
|
|
5426
|
+
get size() {
|
|
5427
|
+
return store.size;
|
|
5428
|
+
}
|
|
5429
|
+
};
|
|
5430
|
+
}
|
|
5431
|
+
function keptHistoryStore(store, keeper) {
|
|
5432
|
+
return {
|
|
5433
|
+
add(point) {
|
|
5434
|
+
store.add(point);
|
|
5435
|
+
keeper.addPoint(point);
|
|
5436
|
+
},
|
|
5437
|
+
list: (windowMs, at) => store.list(windowMs, at),
|
|
5438
|
+
clear: () => store.clear(),
|
|
5439
|
+
get size() {
|
|
5440
|
+
return store.size;
|
|
5441
|
+
}
|
|
5442
|
+
};
|
|
5443
|
+
}
|
|
5444
|
+
|
|
5445
|
+
// src/collector/history/historySettings.ts
|
|
5446
|
+
import { createHash as createHash3 } from "node:crypto";
|
|
5447
|
+
import os5 from "node:os";
|
|
5448
|
+
import path14 from "node:path";
|
|
5449
|
+
var HISTORY_ENV = "AGENT_LOOKOUT_HISTORY";
|
|
5450
|
+
var HISTORY_DIR_ENV = "AGENT_LOOKOUT_HISTORY_DIR";
|
|
5451
|
+
function readHistorySetup(env, homeDir = os5.homedir()) {
|
|
5452
|
+
if (env[HISTORY_ENV]?.trim().toLowerCase() === "off") return { on: false };
|
|
5453
|
+
const override = env[HISTORY_DIR_ENV]?.trim() || void 0;
|
|
5454
|
+
const dir = path14.resolve(override ?? path14.join(homeDir, ".agent-lookout", "history"));
|
|
5455
|
+
return {
|
|
5456
|
+
on: true,
|
|
5457
|
+
dir,
|
|
5458
|
+
folder: tildify(dir, homeDir),
|
|
5459
|
+
sources: sourcesFingerprint(env, homeDir)
|
|
5460
|
+
};
|
|
5461
|
+
}
|
|
5462
|
+
function sourcesFingerprint(env, homeDir = os5.homedir()) {
|
|
5463
|
+
const set = (name) => env[name]?.trim() || void 0;
|
|
5464
|
+
const folders = [
|
|
5465
|
+
path14.resolve(set(CLAUDE_HOME_ENV) ?? path14.join(homeDir, ".claude")),
|
|
5466
|
+
path14.resolve(set(CODEX_HOME_ENV) ?? set(CODEX_OWN_HOME_ENV) ?? path14.join(homeDir, ".codex")),
|
|
5467
|
+
path14.resolve(set(STATUS_DIR_ENV) ?? path14.join(homeDir, ".agent-lookout", "sessions"))
|
|
5468
|
+
];
|
|
5469
|
+
return createHash3("sha256").update(JSON.stringify(folders)).digest("hex").slice(0, 32);
|
|
5470
|
+
}
|
|
5471
|
+
|
|
5472
|
+
// src/collector/terminal/terminalTabs.ts
|
|
5473
|
+
import path15 from "node:path";
|
|
5474
|
+
var TERMINAL_PATH = "/System/Applications/Utilities/Terminal.app/Contents/MacOS/Terminal";
|
|
5475
|
+
var ITERM_PATH = "/Applications/iTerm.app/Contents/MacOS/iTerm2";
|
|
5476
|
+
var ITERM_SERVER_FOLDER = "Library/Application Support/iTerm2";
|
|
5477
|
+
var ITERM_SERVER_NAME = /^iTermServer-[0-9A-Za-z._-]{1,40}$/;
|
|
5478
|
+
var TTY_NAME = /^ttys\d{1,4}$/;
|
|
5479
|
+
var TTY_PATH = /^\/dev\/ttys\d{1,4}$/;
|
|
5480
|
+
function isTtyPath(value) {
|
|
5481
|
+
return typeof value === "string" && TTY_PATH.test(value);
|
|
5482
|
+
}
|
|
5483
|
+
function ttyPathOf(name) {
|
|
5484
|
+
return TTY_NAME.test(name) ? `/dev/${name}` : null;
|
|
5485
|
+
}
|
|
5486
|
+
function appOfProgram(program, homeDir) {
|
|
5487
|
+
if (program === TERMINAL_PATH) return "Terminal";
|
|
5488
|
+
if (program === ITERM_PATH) return "iTerm2";
|
|
5489
|
+
const inServerFolder = path15.dirname(program) === path15.join(homeDir, ITERM_SERVER_FOLDER);
|
|
5490
|
+
return inServerFolder && ITERM_SERVER_NAME.test(path15.basename(program)) ? "iTerm2" : null;
|
|
5491
|
+
}
|
|
5492
|
+
function tabOfProcess(pid, table, homeDir) {
|
|
5493
|
+
const own = table.get(pid);
|
|
5494
|
+
const tty = own ? ttyPathOf(own.tty) : null;
|
|
5495
|
+
if (!own || tty === null) return void 0;
|
|
5496
|
+
const passed = /* @__PURE__ */ new Set([pid]);
|
|
5497
|
+
let current = own.ppid;
|
|
5498
|
+
while (current > 1 && !passed.has(current)) {
|
|
5499
|
+
const parent = table.get(current);
|
|
5500
|
+
if (!parent) return void 0;
|
|
5501
|
+
const app = appOfProgram(parent.path, homeDir);
|
|
5502
|
+
if (app) return { app, tty };
|
|
5503
|
+
if (parent.tty !== own.tty) return void 0;
|
|
5504
|
+
passed.add(current);
|
|
5505
|
+
current = parent.ppid;
|
|
5506
|
+
}
|
|
5507
|
+
return void 0;
|
|
5508
|
+
}
|
|
5509
|
+
|
|
5510
|
+
// src/collector/terminal/focusTab.ts
|
|
5511
|
+
var FOUND = "found";
|
|
5512
|
+
var MISSING = "missing";
|
|
5513
|
+
var TERMINAL_SCRIPT = [
|
|
5514
|
+
"on run argv",
|
|
5515
|
+
"set wanted to item 1 of argv",
|
|
5516
|
+
`if not (application id "com.apple.Terminal" is running) then return "${MISSING}"`,
|
|
5517
|
+
'tell application id "com.apple.Terminal"',
|
|
4065
5518
|
"repeat with w in windows",
|
|
4066
5519
|
"repeat with t in tabs of w",
|
|
4067
5520
|
"if tty of t is wanted then",
|
|
@@ -4237,49 +5690,11 @@ function foundFor(session, panes, tabs) {
|
|
|
4237
5690
|
}
|
|
4238
5691
|
return void 0;
|
|
4239
5692
|
}
|
|
4240
|
-
var
|
|
4241
|
-
status,
|
|
4242
|
-
body: { error },
|
|
4243
|
-
// A refused request's body may not have been read, so its connection is not used again.
|
|
4244
|
-
headers: { Connection: "close", ...headers }
|
|
4245
|
-
});
|
|
4246
|
-
var failed = (status, reason, error, headers = {}) => ({
|
|
5693
|
+
var failed2 = (status, reason, error, headers = {}) => ({
|
|
4247
5694
|
status,
|
|
4248
5695
|
body: { error, reason },
|
|
4249
5696
|
headers
|
|
4250
5697
|
});
|
|
4251
|
-
function header(req, name) {
|
|
4252
|
-
const value = req.headers[name.toLowerCase()];
|
|
4253
|
-
return typeof value === "string" ? value : void 0;
|
|
4254
|
-
}
|
|
4255
|
-
function actionRefusalFor(req, action, maxBodyBytes) {
|
|
4256
|
-
if (req.method !== "POST") {
|
|
4257
|
-
return refuse(405, "This address only answers POST requests.", { Allow: "POST" });
|
|
4258
|
-
}
|
|
4259
|
-
const origin = header(req, "origin");
|
|
4260
|
-
if (origin === void 0 || !isLoopbackOrigin(origin)) {
|
|
4261
|
-
return refuse(403, "This address only acts for the dashboard page served from this machine.");
|
|
4262
|
-
}
|
|
4263
|
-
const site = req.headers["sec-fetch-site"];
|
|
4264
|
-
if (site !== void 0 && site !== "same-origin") {
|
|
4265
|
-
return refuse(403, "This address only acts for the dashboard page served from this machine.");
|
|
4266
|
-
}
|
|
4267
|
-
if (header(req, ACTION_HEADER) !== action) {
|
|
4268
|
-
return refuse(
|
|
4269
|
-
403,
|
|
4270
|
-
`A request to this address must carry the header ${ACTION_HEADER}: ${action}.`
|
|
4271
|
-
);
|
|
4272
|
-
}
|
|
4273
|
-
const contentType = header(req, "content-type")?.split(";", 1)[0]?.trim().toLowerCase();
|
|
4274
|
-
if (contentType !== "application/json") {
|
|
4275
|
-
return refuse(415, "The body must be JSON, sent as application/json.");
|
|
4276
|
-
}
|
|
4277
|
-
const length = header(req, "content-length");
|
|
4278
|
-
if (length !== void 0 && (!/^\d+$/.test(length) || Number(length) > maxBodyBytes)) {
|
|
4279
|
-
return refuse(413, `The body must be no more than ${maxBodyBytes} bytes.`);
|
|
4280
|
-
}
|
|
4281
|
-
return null;
|
|
4282
|
-
}
|
|
4283
5698
|
function jumpRefusalFor(req) {
|
|
4284
5699
|
return actionRefusalFor(req, ACTION, MAX_JUMP_BODY_BYTES);
|
|
4285
5700
|
}
|
|
@@ -4297,27 +5712,6 @@ function sessionIdIn(body) {
|
|
|
4297
5712
|
if (typeof sessionId !== "string" || sessionId === "") return null;
|
|
4298
5713
|
return sessionId.length <= MAX_SESSION_ID_LENGTH ? sessionId : null;
|
|
4299
5714
|
}
|
|
4300
|
-
function readBody(req, limit) {
|
|
4301
|
-
return new Promise((resolve) => {
|
|
4302
|
-
const chunks = [];
|
|
4303
|
-
let received = 0;
|
|
4304
|
-
let done = false;
|
|
4305
|
-
const finish = (body) => {
|
|
4306
|
-
if (done) return;
|
|
4307
|
-
done = true;
|
|
4308
|
-
resolve(body);
|
|
4309
|
-
};
|
|
4310
|
-
req.on("data", (chunk) => {
|
|
4311
|
-
if (done) return;
|
|
4312
|
-
received += chunk.byteLength;
|
|
4313
|
-
if (received > limit) finish({ ok: false, tooLarge: true });
|
|
4314
|
-
else chunks.push(chunk);
|
|
4315
|
-
});
|
|
4316
|
-
req.on("end", () => finish({ ok: true, text: Buffer.concat(chunks).toString("utf8") }));
|
|
4317
|
-
req.on("error", () => finish({ ok: false, tooLarge: false }));
|
|
4318
|
-
req.on("close", () => finish({ ok: false, tooLarge: false }));
|
|
4319
|
-
});
|
|
4320
|
-
}
|
|
4321
5715
|
function createJumpRoute(options) {
|
|
4322
5716
|
const { poller, panes, run, tabs, osascript } = options;
|
|
4323
5717
|
const now = options.now ?? Date.now;
|
|
@@ -4333,41 +5727,41 @@ function createJumpRoute(options) {
|
|
|
4333
5727
|
}
|
|
4334
5728
|
if (outcome.reason === "tab-gone") {
|
|
4335
5729
|
tabs.forget(pid);
|
|
4336
|
-
return
|
|
5730
|
+
return failed2(409, "tab-gone", "That tab has closed.");
|
|
4337
5731
|
}
|
|
4338
5732
|
if (outcome.reason === "not-allowed") {
|
|
4339
|
-
return
|
|
5733
|
+
return failed2(
|
|
4340
5734
|
403,
|
|
4341
5735
|
"not-allowed",
|
|
4342
5736
|
`macOS has not allowed Agent Lookout to control ${tab.app}. Allow it in System Settings, Privacy & Security, Automation.`
|
|
4343
5737
|
);
|
|
4344
5738
|
}
|
|
4345
|
-
return
|
|
5739
|
+
return failed2(500, "failed", `${tab.app} could not be asked to bring the tab forward.`);
|
|
4346
5740
|
}
|
|
4347
5741
|
return async function answerJump(req) {
|
|
4348
|
-
const
|
|
4349
|
-
if (
|
|
5742
|
+
const refused = jumpRefusalFor(req);
|
|
5743
|
+
if (refused) {
|
|
4350
5744
|
req.resume();
|
|
4351
|
-
return
|
|
5745
|
+
return refused;
|
|
4352
5746
|
}
|
|
4353
|
-
const body = await
|
|
5747
|
+
const body = await readRequestBody(req, MAX_JUMP_BODY_BYTES);
|
|
4354
5748
|
if (!body.ok) {
|
|
4355
5749
|
req.resume();
|
|
4356
|
-
return body.tooLarge ?
|
|
5750
|
+
return body.tooLarge ? refusal(413, `The body must be no more than ${MAX_JUMP_BODY_BYTES} bytes.`) : refusal(400, "The body could not be read.");
|
|
4357
5751
|
}
|
|
4358
5752
|
const sessionId = sessionIdIn(body.text);
|
|
4359
5753
|
if (sessionId === null) {
|
|
4360
|
-
return
|
|
5754
|
+
return refusal(400, 'The body must name one session and nothing else: {"sessionId": "..."}.');
|
|
4361
5755
|
}
|
|
4362
5756
|
const session = poller.getSnapshot().sessions.find((candidate) => candidate.id === sessionId);
|
|
4363
5757
|
const found = foundFor(session, panes, tabs);
|
|
4364
5758
|
if (!found) {
|
|
4365
|
-
return
|
|
5759
|
+
return failed2(404, "no-pane", "No tmux pane or terminal tab is known for that session.");
|
|
4366
5760
|
}
|
|
4367
5761
|
const at = now();
|
|
4368
5762
|
const tooSoon = lastAt !== null && at >= lastAt && at - lastAt < JUMP_INTERVAL_MS;
|
|
4369
5763
|
if (underWay || tooSoon) {
|
|
4370
|
-
return
|
|
5764
|
+
return failed2(429, "too-soon", "One jump is made a second. Try again in a moment.", {
|
|
4371
5765
|
"Retry-After": "1"
|
|
4372
5766
|
});
|
|
4373
5767
|
}
|
|
@@ -4388,10 +5782,10 @@ function createJumpRoute(options) {
|
|
|
4388
5782
|
};
|
|
4389
5783
|
}
|
|
4390
5784
|
if (outcome.reason === "failed") {
|
|
4391
|
-
return
|
|
5785
|
+
return failed2(500, "failed", "tmux could not be asked to select the pane.");
|
|
4392
5786
|
}
|
|
4393
5787
|
panes.lookAgain();
|
|
4394
|
-
return outcome.reason === "pane-gone" ?
|
|
5788
|
+
return outcome.reason === "pane-gone" ? failed2(409, "pane-gone", "That pane has closed.") : failed2(409, "tmux-stopped", "tmux is not running.");
|
|
4395
5789
|
} finally {
|
|
4396
5790
|
underWay = false;
|
|
4397
5791
|
}
|
|
@@ -4580,11 +5974,11 @@ function createOsascriptRunner(options) {
|
|
|
4580
5974
|
}
|
|
4581
5975
|
|
|
4582
5976
|
// src/collector/terminal/tabFinder.ts
|
|
4583
|
-
import
|
|
5977
|
+
import os6 from "node:os";
|
|
4584
5978
|
var TAB_LOOK_SOONEST_MS = 5e3;
|
|
4585
5979
|
function createTabFinder(options) {
|
|
4586
5980
|
const off = terminalJumpOff(options.env);
|
|
4587
|
-
const homeDir = options.homeDir ??
|
|
5981
|
+
const homeDir = options.homeDir ?? os6.homedir();
|
|
4588
5982
|
const readProcesses = options.readProcesses ?? readProcessTableWithPs;
|
|
4589
5983
|
const now = options.now ?? Date.now;
|
|
4590
5984
|
const soonestMs = options.soonestMs ?? TAB_LOOK_SOONEST_MS;
|
|
@@ -4746,6 +6140,89 @@ function createTmuxRunner(options) {
|
|
|
4746
6140
|
};
|
|
4747
6141
|
}
|
|
4748
6142
|
|
|
6143
|
+
// src/collector/waits/waitLedger.ts
|
|
6144
|
+
var LEDGER_KEPT_MS = 9 * 24 * 60 * 60 * 1e3;
|
|
6145
|
+
var MAX_EVENTS = 1e5;
|
|
6146
|
+
var MAX_SPANS = 5e4;
|
|
6147
|
+
function createWaitLedger() {
|
|
6148
|
+
let events = [];
|
|
6149
|
+
let spans = [];
|
|
6150
|
+
let fresh = true;
|
|
6151
|
+
function prune(at) {
|
|
6152
|
+
const cutoff = at - LEDGER_KEPT_MS;
|
|
6153
|
+
let firstEvent = 0;
|
|
6154
|
+
while (firstEvent < events.length && events[firstEvent].at < cutoff) {
|
|
6155
|
+
firstEvent += 1;
|
|
6156
|
+
}
|
|
6157
|
+
firstEvent = Math.max(firstEvent, events.length - MAX_EVENTS);
|
|
6158
|
+
if (firstEvent > 0) events = events.slice(firstEvent);
|
|
6159
|
+
let firstSpan = 0;
|
|
6160
|
+
while (firstSpan < spans.length && spans[firstSpan].to < cutoff) firstSpan += 1;
|
|
6161
|
+
firstSpan = Math.max(firstSpan, spans.length - MAX_SPANS);
|
|
6162
|
+
if (firstSpan > 0) spans = spans.slice(firstSpan);
|
|
6163
|
+
}
|
|
6164
|
+
return {
|
|
6165
|
+
restore(seed) {
|
|
6166
|
+
events = seed.waitEvents.filter(isWaitEvent).concat(events);
|
|
6167
|
+
spans = seed.measured.map((span) => ({ ...span })).concat(spans);
|
|
6168
|
+
},
|
|
6169
|
+
beginRun() {
|
|
6170
|
+
fresh = true;
|
|
6171
|
+
},
|
|
6172
|
+
addEvents(added) {
|
|
6173
|
+
for (const event of added) if (isWaitEvent(event)) events.push(event);
|
|
6174
|
+
},
|
|
6175
|
+
addPoint(point) {
|
|
6176
|
+
addPoll(spans, point.at, fresh);
|
|
6177
|
+
fresh = false;
|
|
6178
|
+
prune(point.at);
|
|
6179
|
+
},
|
|
6180
|
+
clear() {
|
|
6181
|
+
events = [];
|
|
6182
|
+
spans = [];
|
|
6183
|
+
fresh = true;
|
|
6184
|
+
},
|
|
6185
|
+
answer({ now, snapshot, since, runStarts, where }) {
|
|
6186
|
+
const totals = waitTotals({
|
|
6187
|
+
events,
|
|
6188
|
+
measured: spans,
|
|
6189
|
+
runStarts,
|
|
6190
|
+
waitingNow: snapshot.sessions.filter((session) => session.status === "needs-you").map((session) => ({ id: session.id, name: session.name })),
|
|
6191
|
+
names: new Map(snapshot.sessions.map((session) => [session.id, session.name])),
|
|
6192
|
+
since: since.at,
|
|
6193
|
+
now
|
|
6194
|
+
});
|
|
6195
|
+
return { at: now, ...totals, since, where };
|
|
6196
|
+
}
|
|
6197
|
+
};
|
|
6198
|
+
}
|
|
6199
|
+
function ledgerEventStore(store, ledger) {
|
|
6200
|
+
return {
|
|
6201
|
+
add(added) {
|
|
6202
|
+
store.add(added);
|
|
6203
|
+
ledger.addEvents(added);
|
|
6204
|
+
},
|
|
6205
|
+
list: (options) => store.list(options),
|
|
6206
|
+
clear: () => store.clear(),
|
|
6207
|
+
get size() {
|
|
6208
|
+
return store.size;
|
|
6209
|
+
}
|
|
6210
|
+
};
|
|
6211
|
+
}
|
|
6212
|
+
function ledgerHistoryStore(store, ledger) {
|
|
6213
|
+
return {
|
|
6214
|
+
add(point) {
|
|
6215
|
+
store.add(point);
|
|
6216
|
+
ledger.addPoint(point);
|
|
6217
|
+
},
|
|
6218
|
+
list: (windowMs, at) => store.list(windowMs, at),
|
|
6219
|
+
clear: () => store.clear(),
|
|
6220
|
+
get size() {
|
|
6221
|
+
return store.size;
|
|
6222
|
+
}
|
|
6223
|
+
};
|
|
6224
|
+
}
|
|
6225
|
+
|
|
4749
6226
|
// src/collector/webhook/webhookSender.ts
|
|
4750
6227
|
var WEBHOOK_TIMEOUT_MS = 1e4;
|
|
4751
6228
|
function postHeaders(body, version) {
|
|
@@ -4845,6 +6322,7 @@ function createHttpSender(settings, options) {
|
|
|
4845
6322
|
var WEBHOOK_URL_ENV = "AGENT_LOOKOUT_WEBHOOK_URL";
|
|
4846
6323
|
var WEBHOOK_EVENTS_ENV = "AGENT_LOOKOUT_WEBHOOK_EVENTS";
|
|
4847
6324
|
var WEBHOOK_AFTER_ENV = "AGENT_LOOKOUT_WEBHOOK_AFTER";
|
|
6325
|
+
var WEBHOOK_ASKING_ENV = "AGENT_LOOKOUT_WEBHOOK_ASKING";
|
|
4848
6326
|
var MAX_URL_LENGTH = 2048;
|
|
4849
6327
|
var URL_UNREADABLE2 = `${WEBHOOK_URL_ENV} could not be read. Copy the whole address, beginning https://.`;
|
|
4850
6328
|
function readUrl(value) {
|
|
@@ -4881,7 +6359,8 @@ function readWebhookSetup(env) {
|
|
|
4881
6359
|
settings: {
|
|
4882
6360
|
url: readUrl(url),
|
|
4883
6361
|
events: readEvents(WEBHOOK_EVENTS_ENV, valueOf(env, WEBHOOK_EVENTS_ENV)),
|
|
4884
|
-
afterMs: readAfter(WEBHOOK_AFTER_ENV, valueOf(env, WEBHOOK_AFTER_ENV))
|
|
6362
|
+
afterMs: readAfter(WEBHOOK_AFTER_ENV, valueOf(env, WEBHOOK_AFTER_ENV)),
|
|
6363
|
+
asking: readAsking(WEBHOOK_ASKING_ENV, valueOf(env, WEBHOOK_ASKING_ENV))
|
|
4885
6364
|
}
|
|
4886
6365
|
};
|
|
4887
6366
|
} catch (error) {
|
|
@@ -4898,8 +6377,28 @@ function createCollector(options) {
|
|
|
4898
6377
|
const now = options.now ?? Date.now;
|
|
4899
6378
|
const env = options.env ?? process.env;
|
|
4900
6379
|
const intervalMs = options.intervalMs ?? POLL_INTERVAL_MS;
|
|
4901
|
-
const
|
|
4902
|
-
const
|
|
6380
|
+
const warn = options.warn ?? console.error;
|
|
6381
|
+
const memoryEvents = createEventStore();
|
|
6382
|
+
const memoryHistory = createHistoryStore();
|
|
6383
|
+
const historySetup = readHistorySetup(env);
|
|
6384
|
+
const keeper = historySetup.on ? createHistoryKeeper({
|
|
6385
|
+
dir: historySetup.dir,
|
|
6386
|
+
folder: historySetup.folder,
|
|
6387
|
+
fs: options.historyFs,
|
|
6388
|
+
flushIntervalMs: options.historyFlushMs,
|
|
6389
|
+
sources: historySetup.sources,
|
|
6390
|
+
now,
|
|
6391
|
+
warn
|
|
6392
|
+
}) : null;
|
|
6393
|
+
const ledger = createWaitLedger();
|
|
6394
|
+
const events = ledgerEventStore(
|
|
6395
|
+
keeper ? keptEventStore(memoryEvents, keeper) : memoryEvents,
|
|
6396
|
+
ledger
|
|
6397
|
+
);
|
|
6398
|
+
const history = ledgerHistoryStore(
|
|
6399
|
+
keeper ? keptHistoryStore(memoryHistory, keeper) : memoryHistory,
|
|
6400
|
+
ledger
|
|
6401
|
+
);
|
|
4903
6402
|
const tmux = options.tmux ?? createTmuxRunner({ env });
|
|
4904
6403
|
const { readProcesses } = options;
|
|
4905
6404
|
const panes = createPaneFinder({
|
|
@@ -4909,7 +6408,6 @@ function createCollector(options) {
|
|
|
4909
6408
|
});
|
|
4910
6409
|
const tabs = createTabFinder({ env, readProcesses: options.readProcesses, now });
|
|
4911
6410
|
const osascript = options.osascript ?? createOsascriptRunner({ env });
|
|
4912
|
-
const warn = options.warn ?? console.error;
|
|
4913
6411
|
const platform = options.platform ?? process.platform;
|
|
4914
6412
|
const notifications = createServerNotifications({
|
|
4915
6413
|
notifier: options.notifier ?? createSystemNotifier({ platform }),
|
|
@@ -4981,15 +6479,84 @@ function createCollector(options) {
|
|
|
4981
6479
|
email: () => email?.status() ?? emailOff,
|
|
4982
6480
|
webhook: () => webhook?.status() ?? webhookOff,
|
|
4983
6481
|
jump: createJumpRoute({ poller, panes, run: tmux, tabs, osascript, now }),
|
|
6482
|
+
clearHistory: createClearHistoryRoute({
|
|
6483
|
+
keeper,
|
|
6484
|
+
forget: () => {
|
|
6485
|
+
memoryEvents.clear();
|
|
6486
|
+
memoryHistory.clear();
|
|
6487
|
+
ledger.clear();
|
|
6488
|
+
restarts = [];
|
|
6489
|
+
lastKept = null;
|
|
6490
|
+
}
|
|
6491
|
+
}),
|
|
6492
|
+
historyKept: () => ({
|
|
6493
|
+
since: keeper?.since() ?? null,
|
|
6494
|
+
kept: keeper?.status() ?? memoryOnlyStatus(),
|
|
6495
|
+
restarts: restartsHeld()
|
|
6496
|
+
}),
|
|
6497
|
+
waits: () => {
|
|
6498
|
+
const since = keeper?.since() ?? { at: poller.startedAt, by: "started" };
|
|
6499
|
+
return ledger.answer({
|
|
6500
|
+
now: now(),
|
|
6501
|
+
snapshot: poller.getSnapshot(),
|
|
6502
|
+
since,
|
|
6503
|
+
runStarts: [since.at, ...restartsHeld().map((restart) => restart.at), poller.startedAt],
|
|
6504
|
+
where: keeper ? "disk" : "memory"
|
|
6505
|
+
});
|
|
6506
|
+
},
|
|
6507
|
+
ready: () => restoring,
|
|
4984
6508
|
now
|
|
4985
6509
|
});
|
|
6510
|
+
let running = false;
|
|
6511
|
+
let restored = keeper === null;
|
|
6512
|
+
let restoring = null;
|
|
6513
|
+
let restarts = [];
|
|
6514
|
+
let lastKept = null;
|
|
6515
|
+
function restartsHeld() {
|
|
6516
|
+
const startedAt = poller.startedAt;
|
|
6517
|
+
if (lastKept === null || lastKept >= startedAt) return restarts;
|
|
6518
|
+
return [...restarts, { at: startedAt, lastBefore: lastKept }];
|
|
6519
|
+
}
|
|
6520
|
+
function begin() {
|
|
6521
|
+
ledger.beginRun();
|
|
6522
|
+
keeper?.start();
|
|
6523
|
+
poller.start();
|
|
6524
|
+
}
|
|
4986
6525
|
return {
|
|
4987
|
-
start
|
|
4988
|
-
|
|
6526
|
+
start() {
|
|
6527
|
+
if (running) return;
|
|
6528
|
+
running = true;
|
|
6529
|
+
if (restored || keeper === null) {
|
|
6530
|
+
begin();
|
|
6531
|
+
return;
|
|
6532
|
+
}
|
|
6533
|
+
restoring = keeper.restore({ events: EVENT_CAPACITY, points: HISTORY_CAPACITY }).then((kept) => {
|
|
6534
|
+
restored = true;
|
|
6535
|
+
memoryEvents.add(kept.events);
|
|
6536
|
+
for (const point of kept.points) memoryHistory.add(point);
|
|
6537
|
+
restarts = kept.restarts;
|
|
6538
|
+
lastKept = kept.lastAt;
|
|
6539
|
+
ledger.restore(kept.whole);
|
|
6540
|
+
poller.resume([...unendedWaits(kept.whole.waitEvents), ...kept.events]);
|
|
6541
|
+
if (running) begin();
|
|
6542
|
+
}).catch(() => {
|
|
6543
|
+
restored = true;
|
|
6544
|
+
if (running) begin();
|
|
6545
|
+
}).finally(() => {
|
|
6546
|
+
restoring = null;
|
|
6547
|
+
});
|
|
6548
|
+
},
|
|
6549
|
+
whenStarted: () => restoring ?? Promise.resolve(),
|
|
6550
|
+
stop() {
|
|
6551
|
+
running = false;
|
|
6552
|
+
poller.stop();
|
|
6553
|
+
keeper?.stop();
|
|
6554
|
+
},
|
|
4989
6555
|
handler,
|
|
4990
6556
|
poller,
|
|
4991
6557
|
email,
|
|
4992
|
-
webhook
|
|
6558
|
+
webhook,
|
|
6559
|
+
history: keeper
|
|
4993
6560
|
};
|
|
4994
6561
|
}
|
|
4995
6562
|
|
|
@@ -5010,7 +6577,7 @@ async function runStandalone(options) {
|
|
|
5010
6577
|
} catch (error) {
|
|
5011
6578
|
return stopWith(error.message);
|
|
5012
6579
|
}
|
|
5013
|
-
if (!existsSync(
|
|
6580
|
+
if (!existsSync(path16.join(distDir, "index.html"))) {
|
|
5014
6581
|
return stopWith(
|
|
5015
6582
|
"The dashboard has not been built yet. Run `npm run build`, then start it again."
|
|
5016
6583
|
);
|