@femtomc/mu-server 26.2.69 → 26.2.71

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.
Files changed (58) hide show
  1. package/README.md +7 -3
  2. package/dist/api/activities.d.ts +2 -0
  3. package/dist/api/activities.js +160 -0
  4. package/dist/api/config.d.ts +2 -0
  5. package/dist/api/config.js +45 -0
  6. package/dist/api/control_plane.d.ts +2 -0
  7. package/dist/api/control_plane.js +28 -0
  8. package/dist/api/cron.d.ts +2 -0
  9. package/dist/api/cron.js +182 -0
  10. package/dist/api/events.js +77 -19
  11. package/dist/api/forum.js +52 -18
  12. package/dist/api/heartbeats.d.ts +2 -0
  13. package/dist/api/heartbeats.js +211 -0
  14. package/dist/api/identities.d.ts +2 -0
  15. package/dist/api/identities.js +103 -0
  16. package/dist/api/issues.js +120 -33
  17. package/dist/api/runs.d.ts +2 -0
  18. package/dist/api/runs.js +207 -0
  19. package/dist/cli.js +58 -3
  20. package/dist/config.d.ts +4 -21
  21. package/dist/config.js +24 -75
  22. package/dist/control_plane.d.ts +7 -114
  23. package/dist/control_plane.js +238 -654
  24. package/dist/control_plane_bootstrap_helpers.d.ts +16 -0
  25. package/dist/control_plane_bootstrap_helpers.js +85 -0
  26. package/dist/control_plane_contract.d.ts +176 -0
  27. package/dist/control_plane_contract.js +1 -0
  28. package/dist/control_plane_reload.d.ts +63 -0
  29. package/dist/control_plane_reload.js +525 -0
  30. package/dist/control_plane_run_outbox.d.ts +7 -0
  31. package/dist/control_plane_run_outbox.js +52 -0
  32. package/dist/control_plane_run_queue_coordinator.d.ts +48 -0
  33. package/dist/control_plane_run_queue_coordinator.js +327 -0
  34. package/dist/control_plane_telegram_generation.d.ts +27 -0
  35. package/dist/control_plane_telegram_generation.js +520 -0
  36. package/dist/control_plane_wake_delivery.d.ts +50 -0
  37. package/dist/control_plane_wake_delivery.js +123 -0
  38. package/dist/cron_request.d.ts +8 -0
  39. package/dist/cron_request.js +65 -0
  40. package/dist/index.d.ts +7 -2
  41. package/dist/index.js +4 -1
  42. package/dist/run_queue.d.ts +95 -0
  43. package/dist/run_queue.js +817 -0
  44. package/dist/run_supervisor.d.ts +20 -0
  45. package/dist/run_supervisor.js +25 -1
  46. package/dist/server.d.ts +12 -49
  47. package/dist/server.js +365 -2128
  48. package/dist/server_program_orchestration.d.ts +38 -0
  49. package/dist/server_program_orchestration.js +254 -0
  50. package/dist/server_routing.d.ts +31 -0
  51. package/dist/server_routing.js +230 -0
  52. package/dist/server_runtime.d.ts +30 -0
  53. package/dist/server_runtime.js +43 -0
  54. package/dist/server_types.d.ts +3 -0
  55. package/dist/server_types.js +16 -0
  56. package/dist/session_lifecycle.d.ts +11 -0
  57. package/dist/session_lifecycle.js +149 -0
  58. package/package.json +7 -6
package/README.md CHANGED
@@ -103,7 +103,8 @@ Bun.serve(server);
103
103
  ### Issues
104
104
 
105
105
  - `GET /api/issues` - List issues
106
- - Query params: `?status=open&tag=bug`
106
+ - Query params: `?status=open&tag=bug&contains=crash&limit=50`
107
+ - `limit` defaults to `200` and is clamped to `<= 200`.
107
108
  - `GET /api/issues/:id` - Get issue by ID
108
109
  - `POST /api/issues` - Create new issue
109
110
  ```json
@@ -123,14 +124,17 @@ Bun.serve(server);
123
124
  ```
124
125
  - `POST /api/issues/:id/claim` - Claim issue (changes status to in_progress)
125
126
  - `GET /api/issues/ready` - Get ready issues
126
- - Query param: `?root=issue-id`
127
+ - Query params: `?root=issue-id&contains=worker&limit=20`
128
+ - `limit` defaults to `200` and is clamped to `<= 200`.
127
129
 
128
130
  ### Forum
129
131
 
130
132
  - `GET /api/forum/topics` - List forum topics
131
- - Query param: `?prefix=issue:`
133
+ - Query params: `?prefix=issue:&limit=20`
134
+ - `limit` defaults to `100` and is clamped to `<= 200`.
132
135
  - `GET /api/forum/read` - Read messages from topic
133
136
  - Query params: `?topic=issue:123&limit=50`
137
+ - `limit` defaults to `50` and is clamped to `<= 200`.
134
138
  - `POST /api/forum/post` - Post message to topic
135
139
  ```json
136
140
  {
@@ -0,0 +1,2 @@
1
+ import type { ServerRoutingDependencies } from "../server_routing.js";
2
+ export declare function activityRoutes(request: Request, url: URL, deps: ServerRoutingDependencies, headers: Headers): Promise<Response>;
@@ -0,0 +1,160 @@
1
+ export async function activityRoutes(request, url, deps, headers) {
2
+ const path = url.pathname;
3
+ if (path === "/api/activities") {
4
+ if (request.method !== "GET") {
5
+ return Response.json({ error: "Method Not Allowed" }, { status: 405, headers });
6
+ }
7
+ const statusRaw = url.searchParams.get("status")?.trim().toLowerCase();
8
+ const status = statusRaw === "running" || statusRaw === "completed" || statusRaw === "failed" || statusRaw === "cancelled"
9
+ ? statusRaw
10
+ : undefined;
11
+ const kind = url.searchParams.get("kind")?.trim() || undefined;
12
+ const limitRaw = url.searchParams.get("limit");
13
+ const limit = limitRaw && /^\d+$/.test(limitRaw) ? Math.max(1, Math.min(500, Number.parseInt(limitRaw, 10))) : undefined;
14
+ const activities = deps.activitySupervisor.list({ status, kind, limit });
15
+ return Response.json({ count: activities.length, activities }, { headers });
16
+ }
17
+ if (path === "/api/activities/start") {
18
+ if (request.method !== "POST") {
19
+ return Response.json({ error: "Method Not Allowed" }, { status: 405, headers });
20
+ }
21
+ let body;
22
+ try {
23
+ body = (await request.json());
24
+ }
25
+ catch {
26
+ return Response.json({ error: "invalid json body" }, { status: 400, headers });
27
+ }
28
+ const title = typeof body.title === "string" ? body.title.trim() : "";
29
+ if (!title) {
30
+ return Response.json({ error: "title is required" }, { status: 400, headers });
31
+ }
32
+ const kind = typeof body.kind === "string" ? body.kind.trim() : undefined;
33
+ const heartbeatEveryMs = typeof body.heartbeat_every_ms === "number" && Number.isFinite(body.heartbeat_every_ms)
34
+ ? Math.max(0, Math.trunc(body.heartbeat_every_ms))
35
+ : undefined;
36
+ const source = body.source === "api" || body.source === "command" || body.source === "system" ? body.source : "api";
37
+ try {
38
+ const activity = deps.activitySupervisor.start({
39
+ title,
40
+ kind,
41
+ heartbeatEveryMs,
42
+ metadata: body.metadata ?? undefined,
43
+ source,
44
+ });
45
+ return Response.json({ ok: true, activity }, { status: 201, headers });
46
+ }
47
+ catch (err) {
48
+ return Response.json({ error: deps.describeError(err) }, { status: 400, headers });
49
+ }
50
+ }
51
+ if (path === "/api/activities/progress") {
52
+ if (request.method !== "POST") {
53
+ return Response.json({ error: "Method Not Allowed" }, { status: 405, headers });
54
+ }
55
+ let body;
56
+ try {
57
+ body = (await request.json());
58
+ }
59
+ catch {
60
+ return Response.json({ error: "invalid json body" }, { status: 400, headers });
61
+ }
62
+ const result = deps.activitySupervisor.progress({
63
+ activityId: typeof body.activity_id === "string" ? body.activity_id : null,
64
+ message: typeof body.message === "string" ? body.message : null,
65
+ });
66
+ if (result.ok) {
67
+ return Response.json(result, { headers });
68
+ }
69
+ if (result.reason === "missing_target") {
70
+ return Response.json(result, { status: 400, headers });
71
+ }
72
+ if (result.reason === "not_running") {
73
+ return Response.json(result, { status: 409, headers });
74
+ }
75
+ return Response.json(result, { status: 404, headers });
76
+ }
77
+ if (path === "/api/activities/heartbeat") {
78
+ if (request.method !== "POST") {
79
+ return Response.json({ error: "Method Not Allowed" }, { status: 405, headers });
80
+ }
81
+ let body;
82
+ try {
83
+ body = (await request.json());
84
+ }
85
+ catch {
86
+ return Response.json({ error: "invalid json body" }, { status: 400, headers });
87
+ }
88
+ const result = deps.activitySupervisor.heartbeat({
89
+ activityId: typeof body.activity_id === "string" ? body.activity_id : null,
90
+ reason: typeof body.reason === "string" ? body.reason : null,
91
+ });
92
+ if (result.ok) {
93
+ return Response.json(result, { headers });
94
+ }
95
+ if (result.reason === "missing_target") {
96
+ return Response.json(result, { status: 400, headers });
97
+ }
98
+ if (result.reason === "not_running") {
99
+ return Response.json(result, { status: 409, headers });
100
+ }
101
+ return Response.json(result, { status: 404, headers });
102
+ }
103
+ if (path === "/api/activities/complete" || path === "/api/activities/fail" || path === "/api/activities/cancel") {
104
+ if (request.method !== "POST") {
105
+ return Response.json({ error: "Method Not Allowed" }, { status: 405, headers });
106
+ }
107
+ let body;
108
+ try {
109
+ body = (await request.json());
110
+ }
111
+ catch {
112
+ return Response.json({ error: "invalid json body" }, { status: 400, headers });
113
+ }
114
+ const activityId = typeof body.activity_id === "string" ? body.activity_id : null;
115
+ const message = typeof body.message === "string" ? body.message : null;
116
+ const result = path === "/api/activities/complete"
117
+ ? deps.activitySupervisor.complete({ activityId, message })
118
+ : path === "/api/activities/fail"
119
+ ? deps.activitySupervisor.fail({ activityId, message })
120
+ : deps.activitySupervisor.cancel({ activityId, message });
121
+ if (result.ok) {
122
+ return Response.json(result, { headers });
123
+ }
124
+ if (result.reason === "missing_target") {
125
+ return Response.json(result, { status: 400, headers });
126
+ }
127
+ if (result.reason === "not_running") {
128
+ return Response.json(result, { status: 409, headers });
129
+ }
130
+ return Response.json(result, { status: 404, headers });
131
+ }
132
+ if (path.startsWith("/api/activities/")) {
133
+ if (request.method !== "GET") {
134
+ return Response.json({ error: "Method Not Allowed" }, { status: 405, headers });
135
+ }
136
+ const rest = path.slice("/api/activities/".length);
137
+ const [rawId, maybeSub] = rest.split("/");
138
+ const activityId = decodeURIComponent(rawId ?? "").trim();
139
+ if (activityId.length === 0) {
140
+ return Response.json({ error: "missing activity id" }, { status: 400, headers });
141
+ }
142
+ if (maybeSub === "events") {
143
+ const limitRaw = url.searchParams.get("limit");
144
+ const limit = limitRaw && /^\d+$/.test(limitRaw)
145
+ ? Math.max(1, Math.min(2_000, Number.parseInt(limitRaw, 10)))
146
+ : undefined;
147
+ const events = deps.activitySupervisor.events(activityId, { limit });
148
+ if (!events) {
149
+ return Response.json({ error: "activity not found" }, { status: 404, headers });
150
+ }
151
+ return Response.json({ count: events.length, events }, { headers });
152
+ }
153
+ const activity = deps.activitySupervisor.get(activityId);
154
+ if (!activity) {
155
+ return Response.json({ error: "activity not found" }, { status: 404, headers });
156
+ }
157
+ return Response.json(activity, { headers });
158
+ }
159
+ return Response.json({ error: "Not Found" }, { status: 404, headers });
160
+ }
@@ -0,0 +1,2 @@
1
+ import type { ServerRoutingDependencies } from "../server_routing.js";
2
+ export declare function configRoutes(request: Request, _url: URL, deps: ServerRoutingDependencies, headers: Headers): Promise<Response>;
@@ -0,0 +1,45 @@
1
+ import { applyMuConfigPatch, getMuConfigPath, muConfigPresence, redactMuConfigSecrets, } from "../config.js";
2
+ export async function configRoutes(request, _url, deps, headers) {
3
+ if (request.method === "GET") {
4
+ try {
5
+ const config = await deps.loadConfigFromDisk();
6
+ return Response.json({
7
+ repo_root: deps.context.repoRoot,
8
+ config_path: getMuConfigPath(deps.context.repoRoot),
9
+ config: redactMuConfigSecrets(config),
10
+ presence: muConfigPresence(config),
11
+ }, { headers });
12
+ }
13
+ catch (err) {
14
+ return Response.json({ error: `failed to read config: ${deps.describeError(err)}` }, { status: 500, headers });
15
+ }
16
+ }
17
+ if (request.method === "POST") {
18
+ let body;
19
+ try {
20
+ body = (await request.json());
21
+ }
22
+ catch {
23
+ return Response.json({ error: "invalid json body" }, { status: 400, headers });
24
+ }
25
+ if (!body || !("patch" in body)) {
26
+ return Response.json({ error: "missing patch payload" }, { status: 400, headers });
27
+ }
28
+ try {
29
+ const base = await deps.loadConfigFromDisk();
30
+ const next = applyMuConfigPatch(base, body.patch);
31
+ const configPath = await deps.writeConfig(deps.context.repoRoot, next);
32
+ return Response.json({
33
+ ok: true,
34
+ repo_root: deps.context.repoRoot,
35
+ config_path: configPath,
36
+ config: redactMuConfigSecrets(next),
37
+ presence: muConfigPresence(next),
38
+ }, { headers });
39
+ }
40
+ catch (err) {
41
+ return Response.json({ error: `failed to write config: ${deps.describeError(err)}` }, { status: 500, headers });
42
+ }
43
+ }
44
+ return Response.json({ error: "Method Not Allowed" }, { status: 405, headers });
45
+ }
@@ -0,0 +1,2 @@
1
+ import type { ServerRoutingDependencies } from "../server_routing.js";
2
+ export declare function controlPlaneRoutes(request: Request, url: URL, deps: ServerRoutingDependencies, headers: Headers): Promise<Response>;
@@ -0,0 +1,28 @@
1
+ export async function controlPlaneRoutes(request, url, deps, headers) {
2
+ const path = url.pathname;
3
+ if (path === "/api/control-plane/reload") {
4
+ if (request.method !== "POST") {
5
+ return Response.json({ error: "Method Not Allowed" }, { status: 405, headers });
6
+ }
7
+ let reason = "api_control_plane_reload";
8
+ try {
9
+ const body = (await request.json());
10
+ if (typeof body.reason === "string" && body.reason.trim().length > 0) {
11
+ reason = body.reason.trim();
12
+ }
13
+ }
14
+ catch {
15
+ // ignore invalid body for reason
16
+ }
17
+ const result = await deps.reloadControlPlane(reason);
18
+ return Response.json(result, { status: result.ok ? 200 : 500, headers });
19
+ }
20
+ if (path === "/api/control-plane/rollback") {
21
+ if (request.method !== "POST") {
22
+ return Response.json({ error: "Method Not Allowed" }, { status: 405, headers });
23
+ }
24
+ const result = await deps.reloadControlPlane("rollback");
25
+ return Response.json(result, { status: result.ok ? 200 : 500, headers });
26
+ }
27
+ return Response.json({ error: "Not Found" }, { status: 404, headers });
28
+ }
@@ -0,0 +1,2 @@
1
+ import type { ServerRoutingDependencies } from "../server_routing.js";
2
+ export declare function cronRoutes(request: Request, url: URL, deps: ServerRoutingDependencies, headers: Headers): Promise<Response>;
@@ -0,0 +1,182 @@
1
+ import { cronScheduleInputFromBody, hasCronScheduleInput, parseCronTarget, } from "../cron_request.js";
2
+ import { normalizeWakeMode } from "../server_types.js";
3
+ export async function cronRoutes(request, url, deps, headers) {
4
+ const path = url.pathname;
5
+ if (path === "/api/cron/status") {
6
+ if (request.method !== "GET") {
7
+ return Response.json({ error: "Method Not Allowed" }, { status: 405, headers });
8
+ }
9
+ const status = await deps.cronPrograms.status();
10
+ return Response.json(status, { headers });
11
+ }
12
+ if (path === "/api/cron") {
13
+ if (request.method !== "GET") {
14
+ return Response.json({ error: "Method Not Allowed" }, { status: 405, headers });
15
+ }
16
+ const enabledRaw = url.searchParams.get("enabled")?.trim().toLowerCase();
17
+ const enabled = enabledRaw === "true" ? true : enabledRaw === "false" ? false : undefined;
18
+ const targetKindRaw = url.searchParams.get("target_kind")?.trim().toLowerCase();
19
+ const targetKind = targetKindRaw === "run" || targetKindRaw === "activity" ? targetKindRaw : undefined;
20
+ const scheduleKindRaw = url.searchParams.get("schedule_kind")?.trim().toLowerCase();
21
+ const scheduleKind = scheduleKindRaw === "at" || scheduleKindRaw === "every" || scheduleKindRaw === "cron"
22
+ ? scheduleKindRaw
23
+ : undefined;
24
+ const limitRaw = url.searchParams.get("limit");
25
+ const limit = limitRaw && /^\d+$/.test(limitRaw) ? Math.max(1, Math.min(500, Number.parseInt(limitRaw, 10))) : undefined;
26
+ const programs = await deps.cronPrograms.list({ enabled, targetKind, scheduleKind, limit });
27
+ return Response.json({ count: programs.length, programs }, { headers });
28
+ }
29
+ if (path === "/api/cron/create") {
30
+ if (request.method !== "POST") {
31
+ return Response.json({ error: "Method Not Allowed" }, { status: 405, headers });
32
+ }
33
+ let body;
34
+ try {
35
+ body = (await request.json());
36
+ }
37
+ catch {
38
+ return Response.json({ error: "invalid json body" }, { status: 400, headers });
39
+ }
40
+ const title = typeof body.title === "string" ? body.title.trim() : "";
41
+ if (!title) {
42
+ return Response.json({ error: "title is required" }, { status: 400, headers });
43
+ }
44
+ const parsedTarget = parseCronTarget(body);
45
+ if (!parsedTarget.target) {
46
+ return Response.json({ error: parsedTarget.error ?? "invalid target" }, { status: 400, headers });
47
+ }
48
+ if (!hasCronScheduleInput(body)) {
49
+ return Response.json({ error: "schedule is required" }, { status: 400, headers });
50
+ }
51
+ const schedule = cronScheduleInputFromBody(body);
52
+ const reason = typeof body.reason === "string" ? body.reason.trim() : undefined;
53
+ const wakeMode = normalizeWakeMode(body.wake_mode);
54
+ const enabled = typeof body.enabled === "boolean" ? body.enabled : undefined;
55
+ try {
56
+ const program = await deps.cronPrograms.create({
57
+ title,
58
+ target: parsedTarget.target,
59
+ schedule,
60
+ reason,
61
+ wakeMode,
62
+ enabled,
63
+ metadata: body.metadata && typeof body.metadata === "object" && !Array.isArray(body.metadata)
64
+ ? body.metadata
65
+ : undefined,
66
+ });
67
+ return Response.json({ ok: true, program }, { status: 201, headers });
68
+ }
69
+ catch (err) {
70
+ return Response.json({ error: deps.describeError(err) }, { status: 400, headers });
71
+ }
72
+ }
73
+ if (path === "/api/cron/update") {
74
+ if (request.method !== "POST") {
75
+ return Response.json({ error: "Method Not Allowed" }, { status: 405, headers });
76
+ }
77
+ let body;
78
+ try {
79
+ body = (await request.json());
80
+ }
81
+ catch {
82
+ return Response.json({ error: "invalid json body" }, { status: 400, headers });
83
+ }
84
+ const programId = typeof body.program_id === "string" ? body.program_id.trim() : "";
85
+ if (!programId) {
86
+ return Response.json({ error: "program_id is required" }, { status: 400, headers });
87
+ }
88
+ let target;
89
+ if (typeof body.target_kind === "string") {
90
+ const parsedTarget = parseCronTarget(body);
91
+ if (!parsedTarget.target) {
92
+ return Response.json({ error: parsedTarget.error ?? "invalid target" }, { status: 400, headers });
93
+ }
94
+ target = parsedTarget.target;
95
+ }
96
+ const schedule = hasCronScheduleInput(body) ? cronScheduleInputFromBody(body) : undefined;
97
+ const wakeMode = Object.hasOwn(body, "wake_mode") ? normalizeWakeMode(body.wake_mode) : undefined;
98
+ try {
99
+ const result = await deps.cronPrograms.update({
100
+ programId,
101
+ title: typeof body.title === "string" ? body.title : undefined,
102
+ reason: typeof body.reason === "string" ? body.reason : undefined,
103
+ wakeMode,
104
+ enabled: typeof body.enabled === "boolean" ? body.enabled : undefined,
105
+ target,
106
+ schedule,
107
+ metadata: body.metadata && typeof body.metadata === "object" && !Array.isArray(body.metadata)
108
+ ? body.metadata
109
+ : undefined,
110
+ });
111
+ if (result.ok) {
112
+ return Response.json(result, { headers });
113
+ }
114
+ if (result.reason === "not_found") {
115
+ return Response.json(result, { status: 404, headers });
116
+ }
117
+ return Response.json(result, { status: 400, headers });
118
+ }
119
+ catch (err) {
120
+ return Response.json({ error: deps.describeError(err) }, { status: 400, headers });
121
+ }
122
+ }
123
+ if (path === "/api/cron/delete") {
124
+ if (request.method !== "POST") {
125
+ return Response.json({ error: "Method Not Allowed" }, { status: 405, headers });
126
+ }
127
+ let body;
128
+ try {
129
+ body = (await request.json());
130
+ }
131
+ catch {
132
+ return Response.json({ error: "invalid json body" }, { status: 400, headers });
133
+ }
134
+ const programId = typeof body.program_id === "string" ? body.program_id.trim() : "";
135
+ if (!programId) {
136
+ return Response.json({ error: "program_id is required" }, { status: 400, headers });
137
+ }
138
+ const result = await deps.cronPrograms.remove(programId);
139
+ return Response.json(result, { status: result.ok ? 200 : result.reason === "not_found" ? 404 : 400, headers });
140
+ }
141
+ if (path === "/api/cron/trigger") {
142
+ if (request.method !== "POST") {
143
+ return Response.json({ error: "Method Not Allowed" }, { status: 405, headers });
144
+ }
145
+ let body;
146
+ try {
147
+ body = (await request.json());
148
+ }
149
+ catch {
150
+ return Response.json({ error: "invalid json body" }, { status: 400, headers });
151
+ }
152
+ const result = await deps.cronPrograms.trigger({
153
+ programId: typeof body.program_id === "string" ? body.program_id : null,
154
+ reason: typeof body.reason === "string" ? body.reason : null,
155
+ });
156
+ if (result.ok) {
157
+ return Response.json(result, { headers });
158
+ }
159
+ if (result.reason === "missing_target") {
160
+ return Response.json(result, { status: 400, headers });
161
+ }
162
+ if (result.reason === "not_found") {
163
+ return Response.json(result, { status: 404, headers });
164
+ }
165
+ return Response.json(result, { status: 409, headers });
166
+ }
167
+ if (path.startsWith("/api/cron/")) {
168
+ if (request.method !== "GET") {
169
+ return Response.json({ error: "Method Not Allowed" }, { status: 405, headers });
170
+ }
171
+ const id = decodeURIComponent(path.slice("/api/cron/".length)).trim();
172
+ if (!id) {
173
+ return Response.json({ error: "missing program id" }, { status: 400, headers });
174
+ }
175
+ const program = await deps.cronPrograms.get(id);
176
+ if (!program) {
177
+ return Response.json({ error: "program not found" }, { status: 404, headers });
178
+ }
179
+ return Response.json(program, { headers });
180
+ }
181
+ return Response.json({ error: "Not Found" }, { status: 404, headers });
182
+ }
@@ -1,3 +1,67 @@
1
+ function trimOrNull(value) {
2
+ if (value == null) {
3
+ return null;
4
+ }
5
+ const trimmed = value.trim();
6
+ return trimmed.length > 0 ? trimmed : null;
7
+ }
8
+ function previewText(value) {
9
+ if (typeof value === "string") {
10
+ return value;
11
+ }
12
+ if (value == null) {
13
+ return "";
14
+ }
15
+ try {
16
+ return JSON.stringify(value);
17
+ }
18
+ catch {
19
+ return String(value);
20
+ }
21
+ }
22
+ function includeByContains(event, contains) {
23
+ if (!contains) {
24
+ return true;
25
+ }
26
+ const needle = contains.toLowerCase();
27
+ const haystack = [
28
+ event.type ?? "",
29
+ event.source ?? "",
30
+ event.issue_id ?? "",
31
+ event.run_id ?? "",
32
+ previewText(event.payload),
33
+ ]
34
+ .join("\n")
35
+ .toLowerCase();
36
+ return haystack.includes(needle);
37
+ }
38
+ function applyEventFilters(events, filters) {
39
+ return events
40
+ .filter((event) => (filters.type ? event.type === filters.type : true))
41
+ .filter((event) => (filters.source ? event.source === filters.source : true))
42
+ .filter((event) => (filters.issueId ? event.issue_id === filters.issueId : true))
43
+ .filter((event) => (filters.runId ? event.run_id === filters.runId : true))
44
+ .filter((event) => {
45
+ if (filters.sinceMs == null) {
46
+ return true;
47
+ }
48
+ if (typeof event.ts_ms !== "number") {
49
+ return false;
50
+ }
51
+ return event.ts_ms >= filters.sinceMs;
52
+ })
53
+ .filter((event) => includeByContains(event, filters.contains));
54
+ }
55
+ function parseSinceMs(value) {
56
+ if (value == null || value.trim().length === 0) {
57
+ return null;
58
+ }
59
+ const parsed = Number.parseInt(value, 10);
60
+ if (!Number.isFinite(parsed)) {
61
+ return null;
62
+ }
63
+ return parsed;
64
+ }
1
65
  export async function eventRoutes(request, context) {
2
66
  const url = new URL(request.url);
3
67
  const path = url.pathname.replace("/api/events", "") || "/";
@@ -6,31 +70,25 @@ export async function eventRoutes(request, context) {
6
70
  return new Response("Method Not Allowed", { status: 405 });
7
71
  }
8
72
  try {
9
- const allEvents = await context.eventsStore.read();
73
+ const allEvents = (await context.eventsStore.read());
74
+ const filters = {
75
+ type: trimOrNull(url.searchParams.get("type")),
76
+ source: trimOrNull(url.searchParams.get("source")),
77
+ issueId: trimOrNull(url.searchParams.get("issue_id")),
78
+ runId: trimOrNull(url.searchParams.get("run_id")),
79
+ sinceMs: parseSinceMs(url.searchParams.get("since")),
80
+ contains: trimOrNull(url.searchParams.get("contains")),
81
+ };
10
82
  // Tail - GET /api/events/tail?n=50
11
83
  if (path === "/tail") {
12
84
  const n = Math.min(Math.max(1, parseInt(url.searchParams.get("n") ?? "50", 10) || 50), 500);
13
- return Response.json(allEvents.slice(-n));
85
+ const filtered = applyEventFilters(allEvents, filters);
86
+ return Response.json(filtered.slice(-n));
14
87
  }
15
- // Query - GET /api/events?type=...&source=...&since=...&limit=50
88
+ // Query - GET /api/events?type=...&source=...&issue_id=...&run_id=...&since=...&contains=...&limit=50
16
89
  if (path === "/") {
17
- const typeFilter = url.searchParams.get("type");
18
- const sourceFilter = url.searchParams.get("source");
19
- const sinceRaw = url.searchParams.get("since");
20
90
  const limit = Math.min(Math.max(1, parseInt(url.searchParams.get("limit") ?? "50", 10) || 50), 500);
21
- let filtered = allEvents;
22
- if (typeFilter) {
23
- filtered = filtered.filter((e) => e.type === typeFilter);
24
- }
25
- if (sourceFilter) {
26
- filtered = filtered.filter((e) => e.source === sourceFilter);
27
- }
28
- if (sinceRaw) {
29
- const sinceMs = parseInt(sinceRaw, 10);
30
- if (!Number.isNaN(sinceMs)) {
31
- filtered = filtered.filter((e) => e.ts_ms >= sinceMs);
32
- }
33
- }
91
+ const filtered = applyEventFilters(allEvents, filters);
34
92
  return Response.json(filtered.slice(-limit));
35
93
  }
36
94
  return new Response("Not Found", { status: 404 });
package/dist/api/forum.js CHANGED
@@ -1,3 +1,33 @@
1
+ import { DEFAULT_FORUM_TOPICS_LIMIT, ForumStoreValidationError, normalizeForumPrefix, normalizeForumReadLimit, normalizeForumTopic, normalizeForumTopicsLimit, } from "@femtomc/mu-forum";
2
+ async function readJsonBody(request) {
3
+ let body;
4
+ try {
5
+ body = await request.json();
6
+ }
7
+ catch {
8
+ throw new ForumStoreValidationError("invalid json body");
9
+ }
10
+ if (!body || typeof body !== "object" || Array.isArray(body)) {
11
+ throw new ForumStoreValidationError("json body must be an object");
12
+ }
13
+ return body;
14
+ }
15
+ function errorResponse(status, message) {
16
+ return new Response(JSON.stringify({ error: message }), {
17
+ status,
18
+ headers: { "Content-Type": "application/json" },
19
+ });
20
+ }
21
+ function mapForumRouteError(error) {
22
+ if (error instanceof ForumStoreValidationError) {
23
+ return errorResponse(400, error.message);
24
+ }
25
+ if (error instanceof Error && error.name === "ZodError") {
26
+ return errorResponse(400, error.message);
27
+ }
28
+ console.error("Forum API error:", error);
29
+ return errorResponse(500, error instanceof Error ? error.message : "Internal server error");
30
+ }
1
31
  export async function forumRoutes(request, context) {
2
32
  const url = new URL(request.url);
3
33
  const path = url.pathname.replace("/api/forum", "") || "/";
@@ -5,37 +35,41 @@ export async function forumRoutes(request, context) {
5
35
  try {
6
36
  // List topics - GET /api/forum/topics
7
37
  if (path === "/topics" && method === "GET") {
8
- const prefix = url.searchParams.get("prefix");
9
- const topics = await context.forumStore.topics(prefix);
38
+ const prefix = normalizeForumPrefix(url.searchParams.get("prefix"));
39
+ const limit = normalizeForumTopicsLimit(url.searchParams.get("limit"), {
40
+ defaultLimit: DEFAULT_FORUM_TOPICS_LIMIT,
41
+ });
42
+ const topics = await context.forumStore.topics(prefix, { limit });
10
43
  return Response.json(topics);
11
44
  }
12
45
  // Read messages - GET /api/forum/read
13
46
  if (path === "/read" && method === "GET") {
14
- const topic = url.searchParams.get("topic");
15
- const limit = url.searchParams.get("limit");
16
- if (!topic) {
17
- return new Response("Topic is required", { status: 400 });
18
- }
19
- const messages = await context.forumStore.read(topic, limit ? parseInt(limit, 10) : 50);
47
+ const topic = normalizeForumTopic(url.searchParams.get("topic"));
48
+ const limit = normalizeForumReadLimit(url.searchParams.get("limit"));
49
+ const messages = await context.forumStore.read(topic, limit);
20
50
  return Response.json(messages);
21
51
  }
22
52
  // Post message - POST /api/forum/post
23
53
  if (path === "/post" && method === "POST") {
24
- const body = (await request.json());
25
- const { topic, body: messageBody, author } = body;
26
- if (!topic || !messageBody) {
27
- return new Response("Topic and body are required", { status: 400 });
54
+ const body = await readJsonBody(request);
55
+ const topic = normalizeForumTopic(body.topic);
56
+ if (typeof body.body !== "string" || body.body.trim().length === 0) {
57
+ return errorResponse(400, "body is required");
58
+ }
59
+ const messageBody = body.body;
60
+ let author = "system";
61
+ if (body.author != null) {
62
+ if (typeof body.author !== "string" || body.author.trim().length === 0) {
63
+ return errorResponse(400, "author must be a non-empty string when provided");
64
+ }
65
+ author = body.author.trim();
28
66
  }
29
- const message = await context.forumStore.post(topic, messageBody, author || "system");
67
+ const message = await context.forumStore.post(topic, messageBody, author);
30
68
  return Response.json(message, { status: 201 });
31
69
  }
32
70
  return new Response("Not Found", { status: 404 });
33
71
  }
34
72
  catch (error) {
35
- console.error("Forum API error:", error);
36
- return new Response(JSON.stringify({ error: error instanceof Error ? error.message : "Internal server error" }), {
37
- status: 500,
38
- headers: { "Content-Type": "application/json" },
39
- });
73
+ return mapForumRouteError(error);
40
74
  }
41
75
  }