@femtomc/mu-server 26.2.40 โ†’ 26.2.42

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.
@@ -0,0 +1,81 @@
1
+ import { ActivityHeartbeatScheduler } from "./heartbeat_scheduler.js";
2
+ export type ControlPlaneActivityStatus = "running" | "completed" | "failed" | "cancelled";
3
+ export type ControlPlaneActivitySnapshot = {
4
+ activity_id: string;
5
+ kind: string;
6
+ title: string;
7
+ status: ControlPlaneActivityStatus;
8
+ heartbeat_every_ms: number;
9
+ heartbeat_count: number;
10
+ last_heartbeat_at_ms: number | null;
11
+ last_heartbeat_reason: string | null;
12
+ last_progress: string | null;
13
+ final_message: string | null;
14
+ metadata: Record<string, unknown>;
15
+ source: "api" | "command" | "system";
16
+ started_at_ms: number;
17
+ updated_at_ms: number;
18
+ finished_at_ms: number | null;
19
+ };
20
+ export type ControlPlaneActivityEventKind = "activity_started" | "activity_progress" | "activity_heartbeat" | "activity_completed" | "activity_failed" | "activity_cancelled";
21
+ export type ControlPlaneActivityEvent = {
22
+ seq: number;
23
+ ts_ms: number;
24
+ kind: ControlPlaneActivityEventKind;
25
+ message: string;
26
+ activity: ControlPlaneActivitySnapshot;
27
+ };
28
+ export type ControlPlaneActivityMutationResult = {
29
+ ok: boolean;
30
+ reason: "not_found" | "not_running" | "missing_target" | null;
31
+ activity: ControlPlaneActivitySnapshot | null;
32
+ };
33
+ export type ControlPlaneActivitySupervisorOpts = {
34
+ nowMs?: () => number;
35
+ heartbeatScheduler?: ActivityHeartbeatScheduler;
36
+ defaultHeartbeatEveryMs?: number;
37
+ maxHistory?: number;
38
+ maxEventsPerActivity?: number;
39
+ onEvent?: (event: ControlPlaneActivityEvent) => void | Promise<void>;
40
+ };
41
+ export declare class ControlPlaneActivitySupervisor {
42
+ #private;
43
+ constructor(opts?: ControlPlaneActivitySupervisorOpts);
44
+ start(opts: {
45
+ title: string;
46
+ kind?: string;
47
+ heartbeatEveryMs?: number;
48
+ metadata?: Record<string, unknown>;
49
+ source?: "api" | "command" | "system";
50
+ }): ControlPlaneActivitySnapshot;
51
+ list(opts?: {
52
+ status?: ControlPlaneActivityStatus;
53
+ kind?: string;
54
+ limit?: number;
55
+ }): ControlPlaneActivitySnapshot[];
56
+ get(activityId: string): ControlPlaneActivitySnapshot | null;
57
+ events(activityId: string, opts?: {
58
+ limit?: number;
59
+ }): ControlPlaneActivityEvent[] | null;
60
+ progress(opts: {
61
+ activityId?: string | null;
62
+ message?: string | null;
63
+ }): ControlPlaneActivityMutationResult;
64
+ heartbeat(opts: {
65
+ activityId?: string | null;
66
+ reason?: string | null;
67
+ }): ControlPlaneActivityMutationResult;
68
+ complete(opts: {
69
+ activityId?: string | null;
70
+ message?: string | null;
71
+ }): ControlPlaneActivityMutationResult;
72
+ fail(opts: {
73
+ activityId?: string | null;
74
+ message?: string | null;
75
+ }): ControlPlaneActivityMutationResult;
76
+ cancel(opts: {
77
+ activityId?: string | null;
78
+ message?: string | null;
79
+ }): ControlPlaneActivityMutationResult;
80
+ stop(): void;
81
+ }
@@ -0,0 +1,306 @@
1
+ import { ActivityHeartbeatScheduler } from "./heartbeat_scheduler.js";
2
+ const DEFAULT_HEARTBEAT_EVERY_MS = 15_000;
3
+ function defaultNowMs() {
4
+ return Date.now();
5
+ }
6
+ function normalizeKind(value) {
7
+ const trimmed = value?.trim();
8
+ if (!trimmed) {
9
+ return "generic";
10
+ }
11
+ return trimmed.toLowerCase();
12
+ }
13
+ function normalizeTitle(value) {
14
+ const trimmed = value.trim();
15
+ if (trimmed.length === 0) {
16
+ throw new Error("activity_title_required");
17
+ }
18
+ return trimmed;
19
+ }
20
+ function normalizeHeartbeatEveryMs(value, fallback) {
21
+ if (typeof value === "number" && Number.isFinite(value)) {
22
+ return Math.max(0, Math.trunc(value));
23
+ }
24
+ if (typeof value === "string" && /^\d+$/.test(value.trim())) {
25
+ return Math.max(0, Number.parseInt(value.trim(), 10));
26
+ }
27
+ return fallback;
28
+ }
29
+ function toSafeMetadata(value) {
30
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
31
+ return {};
32
+ }
33
+ return { ...value };
34
+ }
35
+ function pushBounded(items, value, max) {
36
+ items.push(value);
37
+ if (items.length <= max) {
38
+ return;
39
+ }
40
+ items.splice(0, items.length - max);
41
+ }
42
+ function elapsedSeconds(snapshot, nowMs) {
43
+ return Math.max(0, Math.trunc((nowMs - snapshot.started_at_ms) / 1_000));
44
+ }
45
+ export class ControlPlaneActivitySupervisor {
46
+ #nowMs;
47
+ #heartbeatScheduler;
48
+ #ownsHeartbeatScheduler;
49
+ #defaultHeartbeatEveryMs;
50
+ #maxHistory;
51
+ #maxEventsPerActivity;
52
+ #onEvent;
53
+ #activities = new Map();
54
+ #seq = 0;
55
+ #counter = 0;
56
+ constructor(opts = {}) {
57
+ this.#nowMs = opts.nowMs ?? defaultNowMs;
58
+ this.#heartbeatScheduler = opts.heartbeatScheduler ?? new ActivityHeartbeatScheduler();
59
+ this.#ownsHeartbeatScheduler = !opts.heartbeatScheduler;
60
+ this.#defaultHeartbeatEveryMs = Math.max(0, Math.trunc(opts.defaultHeartbeatEveryMs ?? DEFAULT_HEARTBEAT_EVERY_MS));
61
+ this.#maxHistory = Math.max(20, Math.trunc(opts.maxHistory ?? 200));
62
+ this.#maxEventsPerActivity = Math.max(20, Math.trunc(opts.maxEventsPerActivity ?? 400));
63
+ this.#onEvent = opts.onEvent ?? null;
64
+ }
65
+ #nextActivityId() {
66
+ this.#counter += 1;
67
+ return `activity-${this.#counter.toString(36)}-${crypto.randomUUID().slice(0, 8)}`;
68
+ }
69
+ #snapshot(activity) {
70
+ return {
71
+ ...activity.snapshot,
72
+ metadata: { ...activity.snapshot.metadata },
73
+ };
74
+ }
75
+ #touch(activity) {
76
+ activity.snapshot.updated_at_ms = Math.trunc(this.#nowMs());
77
+ }
78
+ #emit(kind, activity, message) {
79
+ const event = {
80
+ seq: ++this.#seq,
81
+ ts_ms: Math.trunc(this.#nowMs()),
82
+ kind,
83
+ message,
84
+ activity: this.#snapshot(activity),
85
+ };
86
+ pushBounded(activity.events, event, this.#maxEventsPerActivity);
87
+ if (!this.#onEvent) {
88
+ return;
89
+ }
90
+ void Promise.resolve(this.#onEvent(event)).catch(() => {
91
+ // Do not crash on notifier failures.
92
+ });
93
+ }
94
+ #pruneHistory() {
95
+ const rows = [...this.#activities.values()].sort((a, b) => {
96
+ if (a.snapshot.started_at_ms !== b.snapshot.started_at_ms) {
97
+ return b.snapshot.started_at_ms - a.snapshot.started_at_ms;
98
+ }
99
+ return a.snapshot.activity_id.localeCompare(b.snapshot.activity_id);
100
+ });
101
+ let kept = 0;
102
+ for (const row of rows) {
103
+ if (row.snapshot.status === "running") {
104
+ kept += 1;
105
+ continue;
106
+ }
107
+ kept += 1;
108
+ if (kept <= this.#maxHistory) {
109
+ continue;
110
+ }
111
+ this.#heartbeatScheduler.unregister(row.snapshot.activity_id);
112
+ this.#activities.delete(row.snapshot.activity_id);
113
+ }
114
+ }
115
+ #resolveActivity(activityIdRaw) {
116
+ const id = activityIdRaw?.trim() ?? "";
117
+ if (id.length === 0) {
118
+ return null;
119
+ }
120
+ return this.#activities.get(id) ?? null;
121
+ }
122
+ #heartbeatMessage(activity) {
123
+ const nowMs = Math.trunc(this.#nowMs());
124
+ const elapsed = elapsedSeconds(activity.snapshot, nowMs);
125
+ const progress = activity.snapshot.last_progress ? ` ยท ${activity.snapshot.last_progress}` : "";
126
+ return `โฑ ${activity.snapshot.title} running for ${elapsed}s${progress}`;
127
+ }
128
+ #emitHeartbeat(activity, reason) {
129
+ activity.snapshot.heartbeat_count += 1;
130
+ activity.snapshot.last_heartbeat_at_ms = Math.trunc(this.#nowMs());
131
+ activity.snapshot.last_heartbeat_reason = reason?.trim() || "requested";
132
+ this.#touch(activity);
133
+ this.#emit("activity_heartbeat", activity, this.#heartbeatMessage(activity));
134
+ }
135
+ #registerHeartbeat(activity) {
136
+ if (activity.snapshot.heartbeat_every_ms <= 0) {
137
+ return;
138
+ }
139
+ this.#heartbeatScheduler.register({
140
+ activityId: activity.snapshot.activity_id,
141
+ everyMs: activity.snapshot.heartbeat_every_ms,
142
+ handler: async ({ reason }) => {
143
+ if (activity.snapshot.status !== "running") {
144
+ return { status: "skipped", reason: "not_running" };
145
+ }
146
+ this.#emitHeartbeat(activity, reason);
147
+ return { status: "ran" };
148
+ },
149
+ });
150
+ }
151
+ start(opts) {
152
+ const nowMs = Math.trunc(this.#nowMs());
153
+ const snapshot = {
154
+ activity_id: this.#nextActivityId(),
155
+ kind: normalizeKind(opts.kind),
156
+ title: normalizeTitle(opts.title),
157
+ status: "running",
158
+ heartbeat_every_ms: normalizeHeartbeatEveryMs(opts.heartbeatEveryMs, this.#defaultHeartbeatEveryMs),
159
+ heartbeat_count: 0,
160
+ last_heartbeat_at_ms: null,
161
+ last_heartbeat_reason: null,
162
+ last_progress: null,
163
+ final_message: null,
164
+ metadata: toSafeMetadata(opts.metadata),
165
+ source: opts.source ?? "api",
166
+ started_at_ms: nowMs,
167
+ updated_at_ms: nowMs,
168
+ finished_at_ms: null,
169
+ };
170
+ const activity = {
171
+ snapshot,
172
+ events: [],
173
+ };
174
+ this.#activities.set(snapshot.activity_id, activity);
175
+ this.#registerHeartbeat(activity);
176
+ this.#emit("activity_started", activity, `๐Ÿš€ Started activity ${snapshot.title} (${snapshot.activity_id}).`);
177
+ return this.#snapshot(activity);
178
+ }
179
+ list(opts = {}) {
180
+ const limit = Math.max(1, Math.min(500, Math.trunc(opts.limit ?? 100)));
181
+ const kind = opts.kind?.trim().toLowerCase() || null;
182
+ return [...this.#activities.values()]
183
+ .filter((activity) => {
184
+ if (opts.status && activity.snapshot.status !== opts.status) {
185
+ return false;
186
+ }
187
+ if (kind && activity.snapshot.kind !== kind) {
188
+ return false;
189
+ }
190
+ return true;
191
+ })
192
+ .sort((a, b) => {
193
+ if (a.snapshot.started_at_ms !== b.snapshot.started_at_ms) {
194
+ return b.snapshot.started_at_ms - a.snapshot.started_at_ms;
195
+ }
196
+ return a.snapshot.activity_id.localeCompare(b.snapshot.activity_id);
197
+ })
198
+ .slice(0, limit)
199
+ .map((activity) => this.#snapshot(activity));
200
+ }
201
+ get(activityId) {
202
+ const activity = this.#resolveActivity(activityId);
203
+ return activity ? this.#snapshot(activity) : null;
204
+ }
205
+ events(activityId, opts = {}) {
206
+ const activity = this.#resolveActivity(activityId);
207
+ if (!activity) {
208
+ return null;
209
+ }
210
+ const limit = Math.max(1, Math.min(2_000, Math.trunc(opts.limit ?? 200)));
211
+ return activity.events.slice(-limit).map((event) => ({
212
+ ...event,
213
+ activity: {
214
+ ...event.activity,
215
+ metadata: { ...event.activity.metadata },
216
+ },
217
+ }));
218
+ }
219
+ progress(opts) {
220
+ const activity = this.#resolveActivity(opts.activityId);
221
+ if (!opts.activityId?.trim()) {
222
+ return { ok: false, reason: "missing_target", activity: null };
223
+ }
224
+ if (!activity) {
225
+ return { ok: false, reason: "not_found", activity: null };
226
+ }
227
+ if (activity.snapshot.status !== "running") {
228
+ return { ok: false, reason: "not_running", activity: this.#snapshot(activity) };
229
+ }
230
+ const message = opts.message?.trim() || "progress updated";
231
+ activity.snapshot.last_progress = message;
232
+ this.#touch(activity);
233
+ this.#emit("activity_progress", activity, `๐Ÿ“ˆ ${message}`);
234
+ return { ok: true, reason: null, activity: this.#snapshot(activity) };
235
+ }
236
+ heartbeat(opts) {
237
+ const activityId = opts.activityId?.trim() || "";
238
+ if (activityId.length === 0) {
239
+ return { ok: false, reason: "missing_target", activity: null };
240
+ }
241
+ const activity = this.#activities.get(activityId) ?? null;
242
+ if (!activity) {
243
+ return { ok: false, reason: "not_found", activity: null };
244
+ }
245
+ if (activity.snapshot.status !== "running") {
246
+ return { ok: false, reason: "not_running", activity: this.#snapshot(activity) };
247
+ }
248
+ const reason = opts.reason?.trim() || "manual";
249
+ if (this.#heartbeatScheduler.has(activityId)) {
250
+ this.#heartbeatScheduler.requestNow(activityId, { reason, coalesceMs: 0 });
251
+ }
252
+ else {
253
+ this.#emitHeartbeat(activity, reason);
254
+ }
255
+ return { ok: true, reason: null, activity: this.#snapshot(activity) };
256
+ }
257
+ #finish(opts) {
258
+ const activityId = opts.activityId?.trim() || "";
259
+ if (activityId.length === 0) {
260
+ return { ok: false, reason: "missing_target", activity: null };
261
+ }
262
+ const activity = this.#activities.get(activityId) ?? null;
263
+ if (!activity) {
264
+ return { ok: false, reason: "not_found", activity: null };
265
+ }
266
+ if (activity.snapshot.status !== "running") {
267
+ return { ok: false, reason: "not_running", activity: this.#snapshot(activity) };
268
+ }
269
+ activity.snapshot.status = opts.status;
270
+ activity.snapshot.final_message = opts.message?.trim() || null;
271
+ activity.snapshot.finished_at_ms = Math.trunc(this.#nowMs());
272
+ activity.snapshot.updated_at_ms = activity.snapshot.finished_at_ms;
273
+ this.#heartbeatScheduler.unregister(activity.snapshot.activity_id);
274
+ switch (opts.status) {
275
+ case "completed":
276
+ this.#emit("activity_completed", activity, `โœ… Activity completed: ${activity.snapshot.title}${activity.snapshot.final_message ? ` ยท ${activity.snapshot.final_message}` : ""}`);
277
+ break;
278
+ case "failed":
279
+ this.#emit("activity_failed", activity, `โŒ Activity failed: ${activity.snapshot.title}${activity.snapshot.final_message ? ` ยท ${activity.snapshot.final_message}` : ""}`);
280
+ break;
281
+ case "cancelled":
282
+ this.#emit("activity_cancelled", activity, `๐Ÿ›‘ Activity cancelled: ${activity.snapshot.title}${activity.snapshot.final_message ? ` ยท ${activity.snapshot.final_message}` : ""}`);
283
+ break;
284
+ }
285
+ this.#pruneHistory();
286
+ return { ok: true, reason: null, activity: this.#snapshot(activity) };
287
+ }
288
+ complete(opts) {
289
+ return this.#finish({ ...opts, status: "completed" });
290
+ }
291
+ fail(opts) {
292
+ return this.#finish({ ...opts, status: "failed" });
293
+ }
294
+ cancel(opts) {
295
+ return this.#finish({ ...opts, status: "cancelled" });
296
+ }
297
+ stop() {
298
+ for (const activity of this.#activities.values()) {
299
+ this.#heartbeatScheduler.unregister(activity.snapshot.activity_id);
300
+ }
301
+ if (this.#ownsHeartbeatScheduler) {
302
+ this.#heartbeatScheduler.stop();
303
+ }
304
+ this.#activities.clear();
305
+ }
306
+ }
@@ -1,6 +1,8 @@
1
1
  import { type MessagingOperatorBackend, MessagingOperatorRuntime } from "@femtomc/mu-agent";
2
2
  import { type Channel } from "@femtomc/mu-control-plane";
3
3
  import { type MuConfig } from "./config.js";
4
+ import { type ControlPlaneRunHeartbeatResult, type ControlPlaneRunInterruptResult, type ControlPlaneRunSnapshot, type ControlPlaneRunTrace } from "./run_supervisor.js";
5
+ import type { ActivityHeartbeatScheduler } from "./heartbeat_scheduler.js";
4
6
  export type ActiveAdapter = {
5
7
  name: Channel;
6
8
  route: string;
@@ -8,6 +10,32 @@ export type ActiveAdapter = {
8
10
  export type ControlPlaneHandle = {
9
11
  activeAdapters: ActiveAdapter[];
10
12
  handleWebhook(path: string, req: Request): Promise<Response | null>;
13
+ listRuns?(opts?: {
14
+ status?: string;
15
+ limit?: number;
16
+ }): Promise<ControlPlaneRunSnapshot[]>;
17
+ getRun?(idOrRoot: string): Promise<ControlPlaneRunSnapshot | null>;
18
+ startRun?(opts: {
19
+ prompt: string;
20
+ maxSteps?: number;
21
+ }): Promise<ControlPlaneRunSnapshot>;
22
+ resumeRun?(opts: {
23
+ rootIssueId: string;
24
+ maxSteps?: number;
25
+ }): Promise<ControlPlaneRunSnapshot>;
26
+ interruptRun?(opts: {
27
+ jobId?: string | null;
28
+ rootIssueId?: string | null;
29
+ }): Promise<ControlPlaneRunInterruptResult>;
30
+ heartbeatRun?(opts: {
31
+ jobId?: string | null;
32
+ rootIssueId?: string | null;
33
+ reason?: string | null;
34
+ }): Promise<ControlPlaneRunHeartbeatResult>;
35
+ traceRun?(opts: {
36
+ idOrRoot: string;
37
+ limit?: number;
38
+ }): Promise<ControlPlaneRunTrace | null>;
11
39
  stop(): Promise<void>;
12
40
  };
13
41
  export type ControlPlaneConfig = MuConfig["control_plane"];
@@ -24,11 +52,30 @@ type DetectedAdapter = {
24
52
  botUsername: string | null;
25
53
  };
26
54
  export declare function detectAdapters(config: ControlPlaneConfig): DetectedAdapter[];
55
+ export type TelegramSendMessagePayload = {
56
+ chat_id: string;
57
+ text: string;
58
+ parse_mode?: "Markdown";
59
+ disable_web_page_preview?: boolean;
60
+ };
61
+ /**
62
+ * Telegram supports a markdown dialect that uses single markers for emphasis.
63
+ * Normalize the most common LLM/GitHub-style markers (`**bold**`, `__italic__`, headings)
64
+ * while preserving fenced code blocks verbatim.
65
+ */
66
+ export declare function renderTelegramMarkdown(text: string): string;
67
+ export declare function containsTelegramMathNotation(text: string): boolean;
68
+ export declare function buildTelegramSendMessagePayload(opts: {
69
+ chatId: string;
70
+ text: string;
71
+ richFormatting: boolean;
72
+ }): TelegramSendMessagePayload;
27
73
  export type BootstrapControlPlaneOpts = {
28
74
  repoRoot: string;
29
75
  config?: ControlPlaneConfig;
30
76
  operatorRuntime?: MessagingOperatorRuntime | null;
31
77
  operatorBackend?: MessagingOperatorBackend;
78
+ heartbeatScheduler?: ActivityHeartbeatScheduler;
32
79
  };
33
80
  export declare function bootstrapControlPlane(opts: BootstrapControlPlaneOpts): Promise<ControlPlaneHandle | null>;
34
81
  export {};