mercury-agent 0.23.2 → 0.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/container/Dockerfile +1 -1
- package/container/Dockerfile.base +1 -1
- package/container/agent-package.json +1 -1
- package/docs/configuration.md +60 -0
- package/docs/container-lifecycle.md +27 -15
- package/docs/extensions.md +36 -0
- package/docs/goals/rehearsal-bench/roadmap.md +1 -1
- package/docs/goals/release-gate/roadmap.md +8 -8
- package/docs/goals/whatsapp-bot-hardening/open-threads.md +18 -0
- package/docs/goals/whatsapp-bot-hardening/roadmap.md +1 -1
- package/docs/graceful-shutdown.md +21 -5
- package/docs/pending-verification.md +195 -4
- package/docs/plans/{bugs-0915 → archive/2026-09-15-bugs-0915}/decisions.md +1 -0
- package/docs/plans/{bugs-0915 → archive/2026-09-15-bugs-0915}/plan.md +2 -2
- package/docs/plans/archive/2026-09-15-bugs-0915/sessions/S1.md +56 -0
- package/docs/plans/archive/2026-09-15-bugs-0915/sessions/S2.md +51 -0
- package/docs/plans/archive/2026-09-15-bugs-0915/sessions/S3.md +60 -0
- package/docs/plans/archive/2026-09-15-bugs-0915/sessions/S4.md +46 -0
- package/docs/plans/archive/2026-09-22-p0-bugs-0922/briefs/S1.md +60 -0
- package/docs/plans/archive/2026-09-22-p0-bugs-0922/briefs/S2.md +60 -0
- package/docs/plans/archive/2026-09-22-p0-bugs-0922/briefs/S3.md +58 -0
- package/docs/plans/archive/2026-09-22-p0-bugs-0922/briefs/S4.md +58 -0
- package/docs/plans/archive/2026-09-22-p0-bugs-0922/decisions.md +8 -0
- package/docs/plans/archive/2026-09-22-p0-bugs-0922/plan.md +102 -0
- package/docs/plans/archive/2026-09-22-p0-bugs-0922/sessions/S1.md +29 -0
- package/docs/plans/archive/2026-09-22-p0-bugs-0922/sessions/S2.md +29 -0
- package/docs/plans/archive/2026-09-22-p0-bugs-0922/sessions/S3.md +30 -0
- package/docs/plans/archive/2026-09-22-p0-bugs-0922/sessions/S4.md +32 -0
- package/docs/plans/bench-patterns-0915/briefs/S10.md +72 -0
- package/docs/plans/bench-patterns-0915/briefs/S11.md +95 -0
- package/docs/plans/bench-patterns-0915/briefs/S12.md +87 -0
- package/docs/plans/bench-patterns-0915/briefs/S9.md +81 -0
- package/docs/plans/bench-patterns-0915/decisions.md +7 -1
- package/docs/plans/bench-patterns-0915/plan.md +92 -8
- package/docs/plans/bench-patterns-0915/sessions/S10.md +40 -0
- package/docs/plans/bench-patterns-0915/sessions/S11.md +53 -0
- package/docs/plans/bench-patterns-0915/sessions/S12.md +44 -0
- package/docs/plans/bench-patterns-0915/sessions/S4.md +29 -0
- package/docs/plans/bench-patterns-0915/sessions/S5.md +30 -0
- package/docs/plans/bench-patterns-0915/sessions/S6.md +46 -0
- package/docs/plans/bench-patterns-0915/sessions/S7.md +30 -0
- package/docs/plans/bench-patterns-0915/sessions/S8.md +30 -0
- package/docs/plans/bench-patterns-0915/sessions/S9.md +2 -0
- package/docs/plans/flat-0916/briefs/S1.md +55 -0
- package/docs/plans/flat-0916/briefs/S2.md +55 -0
- package/docs/plans/flat-0916/decisions.md +8 -0
- package/docs/plans/flat-0916/plan.md +88 -0
- package/docs/plans/p0-plan-0922/briefs/S1.md +60 -0
- package/docs/plans/p0-plan-0922/briefs/S2.md +54 -0
- package/docs/plans/p0-plan-0922/briefs/S3.md +58 -0
- package/docs/plans/p0-plan-0922/briefs/S4.md +54 -0
- package/docs/plans/p0-plan-0922/briefs/S5.md +79 -0
- package/docs/plans/p0-plan-0922/briefs/S6.md +77 -0
- package/docs/plans/p0-plan-0922/briefs/S7.md +79 -0
- package/docs/plans/p0-plan-0922/briefs/S8.md +69 -0
- package/docs/plans/p0-plan-0922/decisions.md +9 -0
- package/docs/plans/p0-plan-0922/plan.md +215 -0
- package/docs/plans/p0-plan-0922/sessions/S1.md +22 -0
- package/docs/plans/p0-plan-0922/sessions/S2.md +22 -0
- package/docs/plans/p0-plan-0922/sessions/S3.md +22 -0
- package/docs/plans/p0-plan-0922/sessions/S4.md +22 -0
- package/docs/plans/p0-plan-0922/sessions/S5.md +44 -0
- package/docs/plans/p0-plan-0922/sessions/S6.md +60 -0
- package/docs/plans/p0-plan-0922/sessions/S7.md +69 -0
- package/docs/plans/sensitive-bugs-0922/briefs/S1.md +63 -0
- package/docs/plans/sensitive-bugs-0922/briefs/S2.md +62 -0
- package/docs/plans/sensitive-bugs-0922/briefs/S3.md +61 -0
- package/docs/plans/sensitive-bugs-0922/briefs/S4.md +61 -0
- package/docs/plans/sensitive-bugs-0922/briefs/S5.md +62 -0
- package/docs/plans/sensitive-bugs-0922/decisions.md +9 -0
- package/docs/plans/sensitive-bugs-0922/plan.md +147 -0
- package/docs/release-gate.md +1 -1
- package/docs/smoke-fixtures.md +9 -0
- package/examples/extensions/feed-watch/fixtures.ts +210 -0
- package/examples/extensions/feed-watch/index.ts +140 -9
- package/examples/extensions/office-365-mail/README.md +132 -0
- package/examples/extensions/office-365-mail/index.ts +170 -0
- package/examples/extensions/office-365-mail/lib/graph.ts +1031 -0
- package/examples/extensions/office-365-mail/scripts/login.ts +252 -0
- package/examples/extensions/office-365-mail/skill/SKILL.md +94 -0
- package/examples/profiles/football-reporter/AGENTS.md +15 -2
- package/examples/profiles/football-reporter/config.yaml +5 -1
- package/examples/profiles/tel-aviv-flat-search/AGENTS.md +5 -1
- package/package.json +10 -6
- package/resources/pi-extensions/subagent/index.ts +38 -42
- package/resources/pi-extensions/subagent/outcome.ts +28 -0
- package/src/adapters/whatsapp-thread.ts +12 -0
- package/src/agent/container-entry.ts +40 -7
- package/src/agent/container-env.ts +26 -0
- package/src/agent/container-error.ts +28 -0
- package/src/agent/container-runner.ts +50 -14
- package/src/audit/ledger-queries.ts +35 -3
- package/src/bridges/slack.ts +4 -1
- package/src/bridges/teams.ts +55 -15
- package/src/bridges/telegram.ts +3 -1
- package/src/cli/mercury.ts +46 -0
- package/src/cli/mrctl-http.ts +25 -0
- package/src/cli/mrctl.ts +13 -14
- package/src/config.ts +70 -34
- package/src/core/api.ts +51 -29
- package/src/core/boot-reconcile.ts +168 -0
- package/src/core/chat-run-journal.ts +383 -0
- package/src/core/debounce.ts +97 -1
- package/src/core/handler.ts +292 -44
- package/src/core/instance-lock.ts +159 -0
- package/src/core/operator-alerts.ts +80 -10
- package/src/core/pending-ingress.ts +298 -0
- package/src/core/routes/dashboard.ts +28 -6
- package/src/core/routes/media.ts +22 -0
- package/src/core/routes/shadow.ts +28 -3
- package/src/core/runtime.ts +139 -4
- package/src/core/simulate-ingress.ts +5 -2
- package/src/core/space-queue.ts +38 -9
- package/src/core/storage-cleanup.ts +16 -8
- package/src/core/system-messages.ts +50 -0
- package/src/core/task-scheduler.ts +150 -14
- package/src/dashboard/index.html +2 -0
- package/src/main.ts +121 -2
- package/src/ops/shadow-service.ts +113 -1
- package/src/ops/shadow-snapshot.ts +126 -0
- package/src/preflight/checks/roundtrip.ts +50 -39
- package/src/smoke/cli.ts +24 -4
- package/src/storage/db.ts +613 -89
- package/src/types.ts +20 -3
- /package/docs/plans/{bugs-0915 → archive/2026-09-15-bugs-0915}/briefs/S1.md +0 -0
- /package/docs/plans/{bugs-0915 → archive/2026-09-15-bugs-0915}/briefs/S2.md +0 -0
- /package/docs/plans/{bugs-0915 → archive/2026-09-15-bugs-0915}/briefs/S3.md +0 -0
- /package/docs/plans/{bugs-0915 → archive/2026-09-15-bugs-0915}/briefs/S4.md +0 -0
|
@@ -40,6 +40,7 @@ import {
|
|
|
40
40
|
formatSystemMessage,
|
|
41
41
|
type MessageLocale,
|
|
42
42
|
resolveLocale,
|
|
43
|
+
type SystemMessageKey,
|
|
43
44
|
} from "./system-messages.js";
|
|
44
45
|
|
|
45
46
|
/**
|
|
@@ -94,17 +95,29 @@ export interface MissedRunsAlert {
|
|
|
94
95
|
name: string | null;
|
|
95
96
|
/** Who created the task — the alert's first-choice recipient. */
|
|
96
97
|
createdBy: string;
|
|
97
|
-
/**
|
|
98
|
+
/**
|
|
99
|
+
* How many occurrences the host slept through. May be 0 when the report is
|
|
100
|
+
* only about an interrupted run; `missed + interrupted` is always >= 1.
|
|
101
|
+
*/
|
|
98
102
|
missed: number;
|
|
103
|
+
/**
|
|
104
|
+
* 1 when the occurrence at `oldest` was not missed but *interrupted*: it
|
|
105
|
+
* started and a restart, crash or host death cut it off. Not a failure — the
|
|
106
|
+
* report says "interrupted by a restart" rather than "missed" for it.
|
|
107
|
+
*/
|
|
108
|
+
interrupted: 0 | 1;
|
|
99
109
|
/**
|
|
100
110
|
* True when the count is a floor rather than a total — the scheduler's walk
|
|
101
111
|
* hit its scan limit. Rendered as "at least N" so a lower bound is never
|
|
102
112
|
* presented as a fact.
|
|
103
113
|
*/
|
|
104
114
|
capped: boolean;
|
|
105
|
-
/**
|
|
115
|
+
/**
|
|
116
|
+
* Epoch ms of the oldest occurrence that did not run to completion — the
|
|
117
|
+
* interrupted one when `interrupted` is 1, else the oldest missed one.
|
|
118
|
+
*/
|
|
106
119
|
oldest: number;
|
|
107
|
-
/** Epoch ms of the newest
|
|
120
|
+
/** Epoch ms of the newest occurrence that did not run to completion. */
|
|
108
121
|
newest: number;
|
|
109
122
|
/** The task's timezone, so the alert names times the way the task is written. */
|
|
110
123
|
timezone: string | null;
|
|
@@ -512,6 +525,58 @@ export function createOperatorAlerter(
|
|
|
512
525
|
newest: formatOccurrence(alert.newest, alert.timezone),
|
|
513
526
|
});
|
|
514
527
|
|
|
528
|
+
/**
|
|
529
|
+
* Which sentence a report gets. Three shapes: only missed (the host slept
|
|
530
|
+
* through it), only interrupted (a restart cut the run off — not a failure,
|
|
531
|
+
* and not "missed"), or both (cut off, then down for more occurrences).
|
|
532
|
+
* Each in a long form and a list line, each by policy.
|
|
533
|
+
*/
|
|
534
|
+
const missedRunKey = (
|
|
535
|
+
alert: MissedRunsAlert,
|
|
536
|
+
form: "single" | "line",
|
|
537
|
+
): SystemMessageKey => {
|
|
538
|
+
const caughtUp = alert.policy === "caught-up";
|
|
539
|
+
const shape =
|
|
540
|
+
alert.interrupted === 0
|
|
541
|
+
? "missed"
|
|
542
|
+
: alert.missed === 0
|
|
543
|
+
? "interrupted"
|
|
544
|
+
: "interrupted_then_missed";
|
|
545
|
+
const keys: Record<
|
|
546
|
+
typeof shape,
|
|
547
|
+
Record<typeof form, [SystemMessageKey, SystemMessageKey]>
|
|
548
|
+
> = {
|
|
549
|
+
missed: {
|
|
550
|
+
single: ["alert_missed_runs_skipped", "alert_missed_runs_caught_up"],
|
|
551
|
+
line: [
|
|
552
|
+
"alert_missed_runs_line_skipped",
|
|
553
|
+
"alert_missed_runs_line_caught_up",
|
|
554
|
+
],
|
|
555
|
+
},
|
|
556
|
+
interrupted: {
|
|
557
|
+
single: [
|
|
558
|
+
"alert_interrupted_run_skipped",
|
|
559
|
+
"alert_interrupted_run_caught_up",
|
|
560
|
+
],
|
|
561
|
+
line: [
|
|
562
|
+
"alert_missed_runs_line_interrupted_skipped",
|
|
563
|
+
"alert_missed_runs_line_interrupted_caught_up",
|
|
564
|
+
],
|
|
565
|
+
},
|
|
566
|
+
interrupted_then_missed: {
|
|
567
|
+
single: [
|
|
568
|
+
"alert_interrupted_then_missed_skipped",
|
|
569
|
+
"alert_interrupted_then_missed_caught_up",
|
|
570
|
+
],
|
|
571
|
+
line: [
|
|
572
|
+
"alert_missed_runs_line_interrupted_then_missed_skipped",
|
|
573
|
+
"alert_missed_runs_line_interrupted_then_missed_caught_up",
|
|
574
|
+
],
|
|
575
|
+
},
|
|
576
|
+
};
|
|
577
|
+
return keys[shape][form][caughtUp ? 1 : 0];
|
|
578
|
+
};
|
|
579
|
+
|
|
515
580
|
/**
|
|
516
581
|
* One message for everything this recipient lost. A single task keeps the
|
|
517
582
|
* long form, which has room to explain the policy; several become a list,
|
|
@@ -527,9 +592,7 @@ export function createOperatorAlerter(
|
|
|
527
592
|
if (reports.length === 1) {
|
|
528
593
|
return formatSystemMessage(
|
|
529
594
|
locale,
|
|
530
|
-
first
|
|
531
|
-
? "alert_missed_runs_caught_up"
|
|
532
|
-
: "alert_missed_runs_skipped",
|
|
595
|
+
missedRunKey(first, "single"),
|
|
533
596
|
missedRunFields(first, locale),
|
|
534
597
|
);
|
|
535
598
|
}
|
|
@@ -537,9 +600,7 @@ export function createOperatorAlerter(
|
|
|
537
600
|
.map((report) =>
|
|
538
601
|
formatSystemMessage(
|
|
539
602
|
locale,
|
|
540
|
-
report
|
|
541
|
-
? "alert_missed_runs_line_caught_up"
|
|
542
|
-
: "alert_missed_runs_line_skipped",
|
|
603
|
+
missedRunKey(report, "line"),
|
|
543
604
|
missedRunFields(report, locale),
|
|
544
605
|
),
|
|
545
606
|
)
|
|
@@ -809,13 +870,22 @@ export function createOperatorAlerter(
|
|
|
809
870
|
for (const report of reports) {
|
|
810
871
|
const recipient = sender ? resolveMissedRunRecipient(report) : null;
|
|
811
872
|
if (!recipient) {
|
|
873
|
+
const what = [
|
|
874
|
+
report.interrupted ? "had a run interrupted by a restart" : "",
|
|
875
|
+
report.missed > 0
|
|
876
|
+
? `missed ${report.capped ? "at least " : ""}${report.missed} scheduled run(s)`
|
|
877
|
+
: "",
|
|
878
|
+
]
|
|
879
|
+
.filter(Boolean)
|
|
880
|
+
.join(" and ");
|
|
812
881
|
logger.error(
|
|
813
|
-
`
|
|
882
|
+
`Task ${report.taskId} ${what} and could not tell anyone — ${sender ? "neither the task's creator nor its space has a conversation to deliver into" : "no message sender is bound yet"}. The only record is this log line.`,
|
|
814
883
|
{
|
|
815
884
|
taskId: report.taskId,
|
|
816
885
|
spaceId: report.spaceId,
|
|
817
886
|
createdBy: report.createdBy,
|
|
818
887
|
missed: report.missed,
|
|
888
|
+
interrupted: report.interrupted,
|
|
819
889
|
capped: report.capped,
|
|
820
890
|
oldest: new Date(report.oldest).toISOString(),
|
|
821
891
|
newest: new Date(report.newest).toISOString(),
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Debounced chat messages that survive a restart.
|
|
3
|
+
*
|
|
4
|
+
* `MessageDebouncer` holds inbound messages in memory until its idle timer
|
|
5
|
+
* fires, and the user message is first written to `messages` only when the
|
|
6
|
+
* run starts. A restart used to drop every held message with no trace at all:
|
|
7
|
+
* the sender believed the bot saw it, and the bot never did.
|
|
8
|
+
*
|
|
9
|
+
* Flushing them at shutdown cannot work — the queue is cancelled and the
|
|
10
|
+
* containers killed before shutdown hooks run, inside a 10 s budget — so they
|
|
11
|
+
* are **persisted** at shutdown (`pending_ingress`, one row per debounce key)
|
|
12
|
+
* and **replayed** by the next boot through the handler's live dispatch path.
|
|
13
|
+
*
|
|
14
|
+
* The boot side is the shape of every other kind of interrupted work (see
|
|
15
|
+
* `boot-reconcile.ts`): {@link pendingIngressReconciler} runs on the one boot
|
|
16
|
+
* pass, before any adapter, and drops what is too old or unreadable;
|
|
17
|
+
* {@link replayPendingIngress} runs after the adapters and hands each live row
|
|
18
|
+
* to its handler. The row stays in the table between the two, so a crash in
|
|
19
|
+
* that window replays it on the next boot instead of losing it.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { logger } from "../logger.js";
|
|
23
|
+
import type { Db, PendingIngressRecord } from "../storage/db.js";
|
|
24
|
+
import type { IngressMessage } from "../types.js";
|
|
25
|
+
import type { BootReconciler, InterruptedWork } from "./boot-reconcile.js";
|
|
26
|
+
|
|
27
|
+
export const PENDING_INGRESS_KIND = "pending_ingress";
|
|
28
|
+
|
|
29
|
+
export type PendingReason =
|
|
30
|
+
/** Waiting in a debounce batch for its idle timer. */
|
|
31
|
+
| "batch"
|
|
32
|
+
/** Parked behind an in-flight run for the same key. */
|
|
33
|
+
| "queued-behind-run"
|
|
34
|
+
/** Reached the handler after shutdown had begun. */
|
|
35
|
+
| "arrived-during-shutdown";
|
|
36
|
+
|
|
37
|
+
const PENDING_REASONS: ReadonlySet<string> = new Set<PendingReason>([
|
|
38
|
+
"batch",
|
|
39
|
+
"queued-behind-run",
|
|
40
|
+
"arrived-during-shutdown",
|
|
41
|
+
]);
|
|
42
|
+
|
|
43
|
+
export interface PendingIngressRow {
|
|
44
|
+
id: number;
|
|
45
|
+
platform: string;
|
|
46
|
+
/** For logs and operator queries; replay re-derives the key from the messages. */
|
|
47
|
+
debounceKey: string;
|
|
48
|
+
spaceId: string;
|
|
49
|
+
reason: PendingReason;
|
|
50
|
+
/** In arrival order, NOT merged. */
|
|
51
|
+
ingresses: IngressMessage[];
|
|
52
|
+
savedAt: number;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Saved longer ago than this at boot → dropped and logged, not replayed. */
|
|
56
|
+
export const PENDING_INGRESS_MAX_AGE_MS = 15 * 60_000; // owner decision 2026-09-22
|
|
57
|
+
|
|
58
|
+
export function encodePendingIngress(
|
|
59
|
+
row: Omit<PendingIngressRow, "id">,
|
|
60
|
+
): Omit<PendingIngressRecord, "id"> {
|
|
61
|
+
return {
|
|
62
|
+
platform: row.platform,
|
|
63
|
+
debounceKey: row.debounceKey,
|
|
64
|
+
spaceId: row.spaceId,
|
|
65
|
+
reason: row.reason,
|
|
66
|
+
ingressesJson: JSON.stringify(row.ingresses),
|
|
67
|
+
savedAt: row.savedAt,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function isIngress(value: unknown): value is IngressMessage {
|
|
72
|
+
if (typeof value !== "object" || value === null) return false;
|
|
73
|
+
const m = value as Record<string, unknown>;
|
|
74
|
+
return (
|
|
75
|
+
typeof m.platform === "string" &&
|
|
76
|
+
typeof m.spaceId === "string" &&
|
|
77
|
+
typeof m.threadId === "string" &&
|
|
78
|
+
m.threadId.length > 0 &&
|
|
79
|
+
typeof m.conversationExternalId === "string" &&
|
|
80
|
+
typeof m.callerId === "string" &&
|
|
81
|
+
typeof m.text === "string" &&
|
|
82
|
+
typeof m.isDM === "boolean" &&
|
|
83
|
+
typeof m.isReplyToBot === "boolean" &&
|
|
84
|
+
Array.isArray(m.attachments)
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The stored row, or null when it cannot be replayed safely. Every message
|
|
90
|
+
* must carry its own thread (the reply target is read off the message, never
|
|
91
|
+
* off the row) and all of them must belong to the row's platform and space.
|
|
92
|
+
*/
|
|
93
|
+
export function decodePendingIngress(
|
|
94
|
+
record: PendingIngressRecord,
|
|
95
|
+
): PendingIngressRow | null {
|
|
96
|
+
if (!PENDING_REASONS.has(record.reason)) return null;
|
|
97
|
+
let parsed: unknown;
|
|
98
|
+
try {
|
|
99
|
+
parsed = JSON.parse(record.ingressesJson);
|
|
100
|
+
} catch {
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
if (!Array.isArray(parsed) || parsed.length === 0) return null;
|
|
104
|
+
if (!parsed.every(isIngress)) return null;
|
|
105
|
+
const ingresses = parsed as IngressMessage[];
|
|
106
|
+
if (
|
|
107
|
+
!ingresses.every(
|
|
108
|
+
(m) =>
|
|
109
|
+
m.platform === record.platform &&
|
|
110
|
+
m.spaceId === record.spaceId &&
|
|
111
|
+
m.threadId === ingresses[0].threadId &&
|
|
112
|
+
m.callerId === ingresses[0].callerId,
|
|
113
|
+
)
|
|
114
|
+
) {
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
return {
|
|
118
|
+
id: record.id,
|
|
119
|
+
platform: record.platform,
|
|
120
|
+
debounceKey: record.debounceKey,
|
|
121
|
+
spaceId: record.spaceId,
|
|
122
|
+
reason: record.reason as PendingReason,
|
|
123
|
+
ingresses,
|
|
124
|
+
savedAt: record.savedAt,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export interface PendingIngressReplayer {
|
|
129
|
+
replay(row: PendingIngressRow): void;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** The `detail` of a `pending_ingress` item — counts and keys, never text. */
|
|
133
|
+
export interface PendingIngressDetail {
|
|
134
|
+
platform: string;
|
|
135
|
+
key: string;
|
|
136
|
+
reason: PendingReason;
|
|
137
|
+
messages: number;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function preview(record: PendingIngressRecord): string {
|
|
141
|
+
return record.ingressesJson.slice(0, 80);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Boot pass: every row a previous process saved (`savedAt < bootAt`) is either
|
|
146
|
+
* dropped — unreadable (error), or saved more than
|
|
147
|
+
* {@link PENDING_INGRESS_MAX_AGE_MS} before this boot (warn: the chat has moved
|
|
148
|
+
* on and a late answer would be noise) — or returned as a live item. Live rows
|
|
149
|
+
* are left in the table; {@link replayPendingIngress} claims them.
|
|
150
|
+
*/
|
|
151
|
+
export const pendingIngressReconciler: BootReconciler = {
|
|
152
|
+
name: PENDING_INGRESS_KIND,
|
|
153
|
+
reconcile({ db, bootAt }) {
|
|
154
|
+
const live: InterruptedWork[] = [];
|
|
155
|
+
let stale = 0;
|
|
156
|
+
let malformed = 0;
|
|
157
|
+
for (const record of db.listPendingIngress()) {
|
|
158
|
+
if (record.savedAt >= bootAt) continue;
|
|
159
|
+
const row = decodePendingIngress(record);
|
|
160
|
+
if (!row) {
|
|
161
|
+
if (db.claimPendingIngress(record.id)) {
|
|
162
|
+
malformed++;
|
|
163
|
+
logger.error("Boot: pending ingress row is malformed; dropped", {
|
|
164
|
+
id: record.id,
|
|
165
|
+
key: record.debounceKey,
|
|
166
|
+
reason: record.reason,
|
|
167
|
+
preview: preview(record),
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
const ageMs = bootAt - row.savedAt;
|
|
173
|
+
if (ageMs > PENDING_INGRESS_MAX_AGE_MS) {
|
|
174
|
+
if (db.claimPendingIngress(row.id)) {
|
|
175
|
+
stale++;
|
|
176
|
+
logger.warn("Boot: pending ingress too old to answer; dropped", {
|
|
177
|
+
key: row.debounceKey,
|
|
178
|
+
reason: row.reason,
|
|
179
|
+
count: row.ingresses.length,
|
|
180
|
+
ageMs,
|
|
181
|
+
textPreview: row.ingresses[0].text.slice(0, 80),
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
live.push({
|
|
187
|
+
kind: PENDING_INGRESS_KIND,
|
|
188
|
+
id: row.id,
|
|
189
|
+
spaceId: row.spaceId,
|
|
190
|
+
startedAt: row.savedAt,
|
|
191
|
+
detail: {
|
|
192
|
+
platform: row.platform,
|
|
193
|
+
key: row.debounceKey,
|
|
194
|
+
reason: row.reason,
|
|
195
|
+
messages: row.ingresses.length,
|
|
196
|
+
} satisfies PendingIngressDetail,
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
if (stale > 0 || malformed > 0) {
|
|
200
|
+
logger.info("Boot: pending ingress dropped", { stale, malformed });
|
|
201
|
+
}
|
|
202
|
+
return live;
|
|
203
|
+
},
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
export interface PendingIngressReplayCounts {
|
|
207
|
+
replayed: number;
|
|
208
|
+
/** No handler for the row's platform: kept, aged out by a later boot. */
|
|
209
|
+
unowned: number;
|
|
210
|
+
/** A shadow: nothing replayed, rows untouched. */
|
|
211
|
+
skipped: number;
|
|
212
|
+
/** Row gone or unreadable by the time of replay (claimed elsewhere). */
|
|
213
|
+
missing: number;
|
|
214
|
+
/** Claimed, and the handler threw: the one loss, logged in full. */
|
|
215
|
+
failed: number;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* After the adapters: hand each live `pending_ingress` item back to the
|
|
220
|
+
* handler for its platform, oldest first. A row is dispatched only after
|
|
221
|
+
* `claimPendingIngress` returned true, so it is answered at most once.
|
|
222
|
+
*
|
|
223
|
+
* `shadow` is the literal `config.shadow`: a shadow replays nothing and leaves
|
|
224
|
+
* every row in place.
|
|
225
|
+
*/
|
|
226
|
+
export function replayPendingIngress(opts: {
|
|
227
|
+
items: readonly InterruptedWork[];
|
|
228
|
+
db: Db;
|
|
229
|
+
handlers: ReadonlyMap<string, PendingIngressReplayer>;
|
|
230
|
+
shadow: boolean;
|
|
231
|
+
}): PendingIngressReplayCounts {
|
|
232
|
+
const { db, handlers, shadow } = opts;
|
|
233
|
+
const items = opts.items.filter((i) => i.kind === PENDING_INGRESS_KIND);
|
|
234
|
+
const counts: PendingIngressReplayCounts = {
|
|
235
|
+
replayed: 0,
|
|
236
|
+
unowned: 0,
|
|
237
|
+
skipped: 0,
|
|
238
|
+
missing: 0,
|
|
239
|
+
failed: 0,
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
if (shadow) {
|
|
243
|
+
counts.skipped = items.length;
|
|
244
|
+
logger.info("Boot: pending ingress", { ...counts, shadow: true });
|
|
245
|
+
return counts;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
for (const item of items) {
|
|
249
|
+
const record = db.getPendingIngress(item.id);
|
|
250
|
+
const row = record ? decodePendingIngress(record) : null;
|
|
251
|
+
if (!row) {
|
|
252
|
+
counts.missing++;
|
|
253
|
+
logger.warn("Boot: pending ingress row vanished before replay", {
|
|
254
|
+
id: item.id,
|
|
255
|
+
spaceId: item.spaceId,
|
|
256
|
+
});
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const handler = handlers.get(row.platform);
|
|
261
|
+
if (!handler) {
|
|
262
|
+
counts.unowned++;
|
|
263
|
+
logger.warn("Boot: pending ingress for an unconfigured platform; kept", {
|
|
264
|
+
platform: row.platform,
|
|
265
|
+
key: row.debounceKey,
|
|
266
|
+
count: row.ingresses.length,
|
|
267
|
+
});
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (!db.claimPendingIngress(row.id)) {
|
|
272
|
+
counts.missing++;
|
|
273
|
+
logger.info("Boot: pending ingress already claimed elsewhere", {
|
|
274
|
+
id: row.id,
|
|
275
|
+
key: row.debounceKey,
|
|
276
|
+
});
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
try {
|
|
280
|
+
handler.replay(row);
|
|
281
|
+
counts.replayed++;
|
|
282
|
+
} catch (err) {
|
|
283
|
+
// Claimed and not handed over: the one place a message can still be
|
|
284
|
+
// lost, so it is named in full.
|
|
285
|
+
counts.failed++;
|
|
286
|
+
logger.error("Boot: pending ingress replay threw; messages lost", {
|
|
287
|
+
key: row.debounceKey,
|
|
288
|
+
reason: row.reason,
|
|
289
|
+
count: row.ingresses.length,
|
|
290
|
+
textPreview: row.ingresses[0].text.slice(0, 80),
|
|
291
|
+
error: err instanceof Error ? err.message : String(err),
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
logger.info("Boot: pending ingress", counts);
|
|
297
|
+
return counts;
|
|
298
|
+
}
|
|
@@ -50,6 +50,16 @@ const SKIP_REASON_LABEL: Record<SkipReason, string> = {
|
|
|
50
50
|
"no-index": "no index.ts",
|
|
51
51
|
};
|
|
52
52
|
|
|
53
|
+
/**
|
|
54
|
+
* The two ledger states a run can be in without having an outcome. One
|
|
55
|
+
* rendering each, shared by the task list and the run history, so the two
|
|
56
|
+
* cannot drift into saying different things about the same row.
|
|
57
|
+
*/
|
|
58
|
+
const RUNNING_BADGE =
|
|
59
|
+
'<span class="badge blue" title="In flight now.">running</span>';
|
|
60
|
+
const INTERRUPTED_BADGE =
|
|
61
|
+
'<span class="badge amber" title="The process stopped mid-run (restart, crash, or host death). Not a failure.">interrupted</span>';
|
|
62
|
+
|
|
53
63
|
const VOICE_TRANSCRIBE_EXT = "voice-transcribe";
|
|
54
64
|
const VT_KEY = {
|
|
55
65
|
provider: `${VOICE_TRANSCRIBE_EXT}.provider`,
|
|
@@ -1786,12 +1796,18 @@ export function createDashboardRoutes(ctx: DashboardContext) {
|
|
|
1786
1796
|
// `failed` still comes from the snapshot, so the badge means the
|
|
1787
1797
|
// same thing it did before the ledger existed. `quiet` is the new
|
|
1788
1798
|
// answer — a run that succeeded and deliberately said nothing.
|
|
1799
|
+
// `running` / `interrupted` come from the newest row, which the
|
|
1800
|
+
// snapshot (last *completed* attempt) never reflects.
|
|
1789
1801
|
const outcomeBadge =
|
|
1790
1802
|
t.lastStatus === "error"
|
|
1791
1803
|
? ' <span class="badge">failed</span>'
|
|
1792
|
-
: lastRun?.
|
|
1793
|
-
?
|
|
1794
|
-
: ""
|
|
1804
|
+
: lastRun?.status === "running"
|
|
1805
|
+
? ` ${RUNNING_BADGE}`
|
|
1806
|
+
: lastRun?.status === "interrupted"
|
|
1807
|
+
? ` ${INTERRUPTED_BADGE}`
|
|
1808
|
+
: lastRun?.outcome === "no_update"
|
|
1809
|
+
? ' <span class="badge muted">quiet</span>'
|
|
1810
|
+
: "";
|
|
1795
1811
|
return `
|
|
1796
1812
|
<tr data-space-id="${escapeHtml(t.spaceId || "")}">
|
|
1797
1813
|
<td class="mono">#${t.id}</td>
|
|
@@ -1901,6 +1917,8 @@ export function createDashboardRoutes(ctx: DashboardContext) {
|
|
|
1901
1917
|
|
|
1902
1918
|
const outcomeCell = (run: (typeof runs)[number]): string => {
|
|
1903
1919
|
if (run.status === "error") return '<span class="badge">failed</span>';
|
|
1920
|
+
if (run.status === "running") return RUNNING_BADGE;
|
|
1921
|
+
if (run.status === "interrupted") return INTERRUPTED_BADGE;
|
|
1904
1922
|
if (run.outcome === "no_update")
|
|
1905
1923
|
return '<span class="badge muted">quiet</span>';
|
|
1906
1924
|
if (run.outcome === "posted")
|
|
@@ -1913,9 +1931,13 @@ export function createDashboardRoutes(ctx: DashboardContext) {
|
|
|
1913
1931
|
? runs
|
|
1914
1932
|
.map((run) => {
|
|
1915
1933
|
// A clock moved backwards mid-run makes this negative. Say
|
|
1916
|
-
// nothing rather than report a negative duration as fact.
|
|
1917
|
-
|
|
1918
|
-
|
|
1934
|
+
// nothing rather than report a negative duration as fact. No
|
|
1935
|
+
// finish time — still running, or interrupted by a hard kill —
|
|
1936
|
+
// has no duration either.
|
|
1937
|
+
const ms =
|
|
1938
|
+
run.finishedAt === null ? null : run.finishedAt - run.startedAt;
|
|
1939
|
+
const duration =
|
|
1940
|
+
ms !== null && ms >= 0 ? `${Math.round(ms / 1000)}s` : "—";
|
|
1919
1941
|
return `
|
|
1920
1942
|
<tr>
|
|
1921
1943
|
<td class="muted">${formatRelativeTime(run.startedAt)}</td>
|
package/src/core/routes/media.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { readdir } from "node:fs/promises";
|
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { Hono } from "hono";
|
|
5
5
|
import { checkPerm, type Env, getApiCtx, getAuth } from "../api-types.js";
|
|
6
|
+
import { getStorageInfo } from "./storage.js";
|
|
6
7
|
|
|
7
8
|
export const media = new Hono<Env>();
|
|
8
9
|
|
|
@@ -50,6 +51,27 @@ media.post("/purge", async (c) => {
|
|
|
50
51
|
});
|
|
51
52
|
});
|
|
52
53
|
|
|
54
|
+
/**
|
|
55
|
+
* GET /media/storage — disk, database and per-space media usage, for `mrctl disk`.
|
|
56
|
+
*
|
|
57
|
+
* The caller-token route to what `/api/console/storage` serves the operator,
|
|
58
|
+
* byte for byte (same helper, same inputs). Gated on `media.purge` because
|
|
59
|
+
* the response lists EVERY space's inbox/outbox: it is a host-wide view, the
|
|
60
|
+
* one an operator reads before a purge, and `media.purge` is admin-only by
|
|
61
|
+
* default. Members could read it before only because every container held
|
|
62
|
+
* the operator secret.
|
|
63
|
+
*/
|
|
64
|
+
media.get("/storage", async (c) => {
|
|
65
|
+
const denied = checkPerm(c, "media.purge");
|
|
66
|
+
if (denied) return denied;
|
|
67
|
+
const { config } = getApiCtx(c);
|
|
68
|
+
const info = await getStorageInfo({
|
|
69
|
+
spacesDir: config.spacesDir,
|
|
70
|
+
dbPath: config.dbPath,
|
|
71
|
+
});
|
|
72
|
+
return c.json(info);
|
|
73
|
+
});
|
|
74
|
+
|
|
53
75
|
/** Remove all files inside a directory (non-recursive into subdirs). Returns count of removed entries. */
|
|
54
76
|
async function purgeDir(dir: string): Promise<number> {
|
|
55
77
|
let entries: string[];
|
|
@@ -53,12 +53,36 @@ import {
|
|
|
53
53
|
/** The two members the route uses; a test hands in a fake with exactly these. */
|
|
54
54
|
export type ShadowSimulateSeam = Pick<IngressSimulator, "check" | "simulate">;
|
|
55
55
|
|
|
56
|
+
/**
|
|
57
|
+
* `Bun.serve`'s default `idleTimeout`, in ms: a connection with no bytes in
|
|
58
|
+
* either direction for this long is closed by the server itself, with
|
|
59
|
+
* `[Bun.serve]: request timed out after 10 seconds` in the journal. None of
|
|
60
|
+
* the three `Bun.serve` sites (`main.ts` TCP, `main.ts` unix socket,
|
|
61
|
+
* `smoke/boot.ts`) sets the knob, so this is the number the route streams
|
|
62
|
+
* against. `tests/shadow-route.test.ts` pins it to the installed bun-types.
|
|
63
|
+
*/
|
|
64
|
+
export const BUN_SERVE_DEFAULT_IDLE_TIMEOUT_MS = 10_000;
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Default gap between `: keepalive` comments on the simulate stream. Well
|
|
68
|
+
* under the idle timeout, not just under it: the host runs on one thread,
|
|
69
|
+
* and a tick delayed by a blocking Docker or child-process call still has to
|
|
70
|
+
* land before Bun's clock runs out. The first "idle proxy" that closed this
|
|
71
|
+
* stream was `Bun.serve` — the default was 15 s against its 10 s.
|
|
72
|
+
*/
|
|
73
|
+
export const DEFAULT_SHADOW_KEEPALIVE_MS = 4_000;
|
|
74
|
+
|
|
56
75
|
export interface ShadowRouteOptions {
|
|
57
76
|
/** Undefined when the simulator failed to construct at boot: every call is a 503. */
|
|
58
77
|
simulator: ShadowSimulateSeam | undefined;
|
|
59
78
|
/** `config.shadowApiSecret`. Undefined ⇒ every call is a 503. */
|
|
60
79
|
secret: string | undefined;
|
|
61
|
-
/**
|
|
80
|
+
/**
|
|
81
|
+
* Milliseconds between `: keepalive` comments while a run is in flight.
|
|
82
|
+
* Default `DEFAULT_SHADOW_KEEPALIVE_MS`; must stay well under
|
|
83
|
+
* `BUN_SERVE_DEFAULT_IDLE_TIMEOUT_MS` (the test pins the default at half
|
|
84
|
+
* or less) or the server closes the stream before the first comment.
|
|
85
|
+
*/
|
|
62
86
|
keepaliveMs?: number;
|
|
63
87
|
}
|
|
64
88
|
|
|
@@ -319,7 +343,7 @@ async function parseSimulateRequest(
|
|
|
319
343
|
|
|
320
344
|
export function createShadowRoute(opts: ShadowRouteOptions): Hono {
|
|
321
345
|
const app = new Hono();
|
|
322
|
-
const keepaliveMs = opts.keepaliveMs ??
|
|
346
|
+
const keepaliveMs = opts.keepaliveMs ?? DEFAULT_SHADOW_KEEPALIVE_MS;
|
|
323
347
|
|
|
324
348
|
app.post("/simulate", async (c) => {
|
|
325
349
|
// Auth first, before the body is read: the secret is the shadow's own,
|
|
@@ -432,7 +456,8 @@ export function createShadowRoute(opts: ShadowRouteOptions): Hono {
|
|
|
432
456
|
return tail;
|
|
433
457
|
};
|
|
434
458
|
// Comments are SSE's idle signal; a container run is minutes and a
|
|
435
|
-
// silent stream is what an idle proxy closes.
|
|
459
|
+
// silent stream is what an idle proxy closes — `Bun.serve` itself
|
|
460
|
+
// first, at `BUN_SERVE_DEFAULT_IDLE_TIMEOUT_MS`.
|
|
436
461
|
const keepalive = setInterval(() => {
|
|
437
462
|
if (gone || stream.closed) return;
|
|
438
463
|
void stream.write(": keepalive\n\n").catch(() => {
|