@femtomc/mu-server 26.2.72 → 26.2.73

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.
@@ -1,6 +1,7 @@
1
1
  import { extname, join, resolve } from "node:path";
2
2
  import { activityRoutes } from "./api/activities.js";
3
3
  import { configRoutes } from "./api/config.js";
4
+ import { contextRoutes } from "./api/context.js";
4
5
  import { controlPlaneRoutes } from "./api/control_plane.js";
5
6
  import { cronRoutes } from "./api/cron.js";
6
7
  import { eventRoutes } from "./api/events.js";
@@ -9,6 +10,8 @@ import { heartbeatRoutes } from "./api/heartbeats.js";
9
10
  import { identityRoutes } from "./api/identities.js";
10
11
  import { issueRoutes } from "./api/issues.js";
11
12
  import { runRoutes } from "./api/runs.js";
13
+ import { cronScheduleInputFromBody, hasCronScheduleInput, parseCronTarget } from "./cron_request.js";
14
+ import { normalizeWakeMode } from "./server_types.js";
12
15
  const DEFAULT_MIME_TYPES = {
13
16
  ".html": "text/html; charset=utf-8",
14
17
  ".js": "text/javascript; charset=utf-8",
@@ -22,6 +25,109 @@ const DEFAULT_MIME_TYPES = {
22
25
  ".woff2": "font/woff2",
23
26
  };
24
27
  const DEFAULT_PUBLIC_DIR = join(new URL(".", import.meta.url).pathname, "..", "public");
28
+ function readTrimmedString(value) {
29
+ return typeof value === "string" ? value.trim() : "";
30
+ }
31
+ function readIntOrNull(value) {
32
+ if (typeof value !== "number" || !Number.isFinite(value) || !Number.isInteger(value)) {
33
+ return null;
34
+ }
35
+ return Math.trunc(value);
36
+ }
37
+ function readFiniteNumberOrNull(value) {
38
+ if (typeof value !== "number" || !Number.isFinite(value)) {
39
+ return null;
40
+ }
41
+ return value;
42
+ }
43
+ function parseOptionalBoolean(value) {
44
+ if (value == null) {
45
+ return { ok: true, value: null };
46
+ }
47
+ if (typeof value === "boolean") {
48
+ return { ok: true, value };
49
+ }
50
+ return { ok: false, value: null };
51
+ }
52
+ function readCommaList(value) {
53
+ if (Array.isArray(value)) {
54
+ return value
55
+ .map((item) => (typeof item === "string" ? item.trim() : ""))
56
+ .filter((item) => item.length > 0);
57
+ }
58
+ if (typeof value === "string") {
59
+ return value
60
+ .split(",")
61
+ .map((item) => item.trim())
62
+ .filter((item) => item.length > 0);
63
+ }
64
+ return [];
65
+ }
66
+ function parseHeartbeatTarget(body) {
67
+ const targetKind = readTrimmedString(body.target_kind).toLowerCase();
68
+ if (targetKind === "run") {
69
+ const jobId = readTrimmedString(body.run_job_id);
70
+ const rootIssueId = readTrimmedString(body.run_root_issue_id);
71
+ if (!jobId && !rootIssueId) {
72
+ return {
73
+ target: null,
74
+ error: "run target requires run_job_id or run_root_issue_id",
75
+ };
76
+ }
77
+ return {
78
+ target: {
79
+ kind: "run",
80
+ job_id: jobId || null,
81
+ root_issue_id: rootIssueId || null,
82
+ },
83
+ error: null,
84
+ };
85
+ }
86
+ if (targetKind === "activity") {
87
+ const activityId = readTrimmedString(body.activity_id);
88
+ if (!activityId) {
89
+ return {
90
+ target: null,
91
+ error: "activity target requires activity_id",
92
+ };
93
+ }
94
+ return {
95
+ target: {
96
+ kind: "activity",
97
+ activity_id: activityId,
98
+ },
99
+ error: null,
100
+ };
101
+ }
102
+ return {
103
+ target: null,
104
+ error: "target_kind must be run or activity",
105
+ };
106
+ }
107
+ function commandProgramFailureStatus(reason) {
108
+ if (reason === "not_found") {
109
+ return 404;
110
+ }
111
+ if (reason === "missing_target" || reason === "invalid_target" || reason === "invalid_schedule") {
112
+ return 400;
113
+ }
114
+ if (reason === "not_running" || reason === "failed") {
115
+ return 409;
116
+ }
117
+ return 400;
118
+ }
119
+ function commandCompletedResponse(headers, targetType, result) {
120
+ return Response.json({
121
+ ok: true,
122
+ result: {
123
+ kind: "completed",
124
+ command: {
125
+ target_type: targetType,
126
+ result,
127
+ },
128
+ },
129
+ }, { headers });
130
+ }
25
131
  export function createServerRequestHandler(deps) {
26
132
  const publicDir = deps.publicDir ?? DEFAULT_PUBLIC_DIR;
27
133
  const mimeTypes = deps.mimeTypes ?? DEFAULT_MIME_TYPES;
@@ -84,10 +190,10 @@ export function createServerRequestHandler(deps) {
84
190
  if (!kind) {
85
191
  return Response.json({ error: "kind is required" }, { status: 400, headers });
86
192
  }
87
- let commandText;
193
+ let commandText = null;
88
194
  switch (kind) {
89
195
  case "run_start": {
90
- const prompt = typeof body.prompt === "string" ? body.prompt.trim() : "";
196
+ const prompt = readTrimmedString(body.prompt);
91
197
  if (!prompt) {
92
198
  return Response.json({ error: "prompt is required for run_start" }, { status: 400, headers });
93
199
  }
@@ -98,7 +204,7 @@ export function createServerRequestHandler(deps) {
98
204
  break;
99
205
  }
100
206
  case "run_resume": {
101
- const rootId = typeof body.root_issue_id === "string" ? body.root_issue_id.trim() : "";
207
+ const rootId = readTrimmedString(body.root_issue_id);
102
208
  const maxSteps = typeof body.max_steps === "number" && Number.isFinite(body.max_steps)
103
209
  ? ` ${Math.max(1, Math.trunc(body.max_steps))}`
104
210
  : "";
@@ -106,7 +212,7 @@ export function createServerRequestHandler(deps) {
106
212
  break;
107
213
  }
108
214
  case "run_interrupt": {
109
- const rootId = typeof body.root_issue_id === "string" ? body.root_issue_id.trim() : "";
215
+ const rootId = readTrimmedString(body.root_issue_id);
110
216
  commandText = `mu! run interrupt${rootId ? ` ${rootId}` : ""}`;
111
217
  break;
112
218
  }
@@ -116,40 +222,434 @@ export function createServerRequestHandler(deps) {
116
222
  case "update":
117
223
  commandText = "/mu update";
118
224
  break;
119
- case "status":
120
- commandText = "/mu status";
121
- break;
122
- case "issue_list":
123
- commandText = "/mu issue list";
124
- break;
125
- case "issue_get": {
126
- const issueId = typeof body.issue_id === "string" ? body.issue_id.trim() : "";
127
- commandText = `/mu issue get${issueId ? ` ${issueId}` : ""}`;
128
- break;
225
+ case "issue_create": {
226
+ const title = readTrimmedString(body.title);
227
+ if (!title) {
228
+ return Response.json({ error: "title is required for issue_create" }, { status: 400, headers });
229
+ }
230
+ const issueBody = typeof body.body === "string" ? body.body : undefined;
231
+ const tags = readCommaList(body.tags);
232
+ const priority = readIntOrNull(body.priority);
233
+ if (body.priority != null && priority == null) {
234
+ return Response.json({ error: "priority must be an integer" }, { status: 400, headers });
235
+ }
236
+ const created = await deps.context.issueStore.create(title, {
237
+ body: issueBody,
238
+ tags: tags.length > 0 ? tags : undefined,
239
+ priority: priority ?? undefined,
240
+ });
241
+ const parentId = readTrimmedString(body.parent_id);
242
+ if (parentId) {
243
+ await deps.context.issueStore.add_dep(created.id, "parent", parentId);
244
+ }
245
+ const issue = parentId ? ((await deps.context.issueStore.get(created.id)) ?? created) : created;
246
+ return commandCompletedResponse(headers, "issue create", { issue });
129
247
  }
130
- case "forum_read": {
131
- const topic = typeof body.topic === "string" ? body.topic.trim() : "";
132
- const limit = typeof body.limit === "number" && Number.isFinite(body.limit)
133
- ? ` ${Math.max(1, Math.trunc(body.limit))}`
134
- : "";
135
- commandText = `/mu forum read${topic ? ` ${topic}` : ""}${limit}`;
136
- break;
248
+ case "issue_update": {
249
+ const id = readTrimmedString(body.id);
250
+ if (!id) {
251
+ return Response.json({ error: "id is required for issue_update" }, { status: 400, headers });
252
+ }
253
+ const current = await deps.context.issueStore.get(id);
254
+ if (!current) {
255
+ return Response.json({ error: "issue not found" }, { status: 404, headers });
256
+ }
257
+ const patch = {};
258
+ if (typeof body.title === "string")
259
+ patch.title = body.title;
260
+ if (typeof body.body === "string")
261
+ patch.body = body.body;
262
+ if (typeof body.status === "string")
263
+ patch.status = body.status;
264
+ if (typeof body.outcome === "string")
265
+ patch.outcome = body.outcome;
266
+ if (body.priority != null) {
267
+ const priority = readIntOrNull(body.priority);
268
+ if (priority == null) {
269
+ return Response.json({ error: "priority must be an integer" }, { status: 400, headers });
270
+ }
271
+ patch.priority = priority;
272
+ }
273
+ if (body.tags != null) {
274
+ patch.tags = readCommaList(body.tags);
275
+ }
276
+ const addTags = readCommaList(body.add_tags);
277
+ const removeTags = readCommaList(body.remove_tags);
278
+ if (addTags.length > 0 || removeTags.length > 0) {
279
+ const baseTags = Array.isArray(patch.tags)
280
+ ? (patch.tags ?? [])
281
+ : Array.isArray(current.tags)
282
+ ? [...current.tags]
283
+ : [];
284
+ const next = new Set(baseTags);
285
+ for (const tag of addTags)
286
+ next.add(tag);
287
+ for (const tag of removeTags)
288
+ next.delete(tag);
289
+ patch.tags = [...next];
290
+ }
291
+ if (Object.keys(patch).length === 0) {
292
+ return Response.json({ error: "issue_update requires at least one patch field" }, { status: 400, headers });
293
+ }
294
+ const issue = await deps.context.issueStore.update(id, patch);
295
+ return commandCompletedResponse(headers, "issue update", { issue });
137
296
  }
138
- case "run_list":
139
- commandText = "/mu run list";
140
- break;
141
- case "run_status": {
142
- const rootId = typeof body.root_issue_id === "string" ? body.root_issue_id.trim() : "";
143
- commandText = `/mu run status${rootId ? ` ${rootId}` : ""}`;
144
- break;
297
+ case "issue_claim": {
298
+ const id = readTrimmedString(body.id);
299
+ if (!id) {
300
+ return Response.json({ error: "id is required for issue_claim" }, { status: 400, headers });
301
+ }
302
+ const claimed = await deps.context.issueStore.claim(id);
303
+ if (!claimed) {
304
+ return Response.json({ error: "failed to claim issue" }, { status: 409, headers });
305
+ }
306
+ const issue = await deps.context.issueStore.get(id);
307
+ if (!issue) {
308
+ return Response.json({ error: "issue not found" }, { status: 404, headers });
309
+ }
310
+ return commandCompletedResponse(headers, "issue claim", { issue });
311
+ }
312
+ case "issue_open": {
313
+ const id = readTrimmedString(body.id);
314
+ if (!id) {
315
+ return Response.json({ error: "id is required for issue_open" }, { status: 400, headers });
316
+ }
317
+ const issue = await deps.context.issueStore.update(id, { status: "open", outcome: null });
318
+ return commandCompletedResponse(headers, "issue open", { issue });
319
+ }
320
+ case "issue_close": {
321
+ const id = readTrimmedString(body.id);
322
+ if (!id) {
323
+ return Response.json({ error: "id is required for issue_close" }, { status: 400, headers });
324
+ }
325
+ const outcome = readTrimmedString(body.outcome) || "success";
326
+ const issue = await deps.context.issueStore.close(id, outcome);
327
+ return commandCompletedResponse(headers, "issue close", { issue });
328
+ }
329
+ case "issue_dep": {
330
+ const srcId = readTrimmedString(body.src_id);
331
+ const dstId = readTrimmedString(body.dst_id);
332
+ const depType = readTrimmedString(body.dep_type) || "blocks";
333
+ if (!srcId || !dstId) {
334
+ return Response.json({ error: "src_id and dst_id are required for issue_dep" }, { status: 400, headers });
335
+ }
336
+ if (depType !== "blocks" && depType !== "parent") {
337
+ return Response.json({ error: "dep_type must be blocks or parent" }, { status: 400, headers });
338
+ }
339
+ await deps.context.issueStore.add_dep(srcId, depType, dstId);
340
+ return commandCompletedResponse(headers, "issue dep", {
341
+ src_id: srcId,
342
+ dst_id: dstId,
343
+ dep_type: depType,
344
+ });
345
+ }
346
+ case "issue_undep": {
347
+ const srcId = readTrimmedString(body.src_id);
348
+ const dstId = readTrimmedString(body.dst_id);
349
+ const depType = readTrimmedString(body.dep_type) || "blocks";
350
+ if (!srcId || !dstId) {
351
+ return Response.json({ error: "src_id and dst_id are required for issue_undep" }, { status: 400, headers });
352
+ }
353
+ if (depType !== "blocks" && depType !== "parent") {
354
+ return Response.json({ error: "dep_type must be blocks or parent" }, { status: 400, headers });
355
+ }
356
+ const ok = await deps.context.issueStore.remove_dep(srcId, depType, dstId);
357
+ return commandCompletedResponse(headers, "issue undep", {
358
+ src_id: srcId,
359
+ dst_id: dstId,
360
+ dep_type: depType,
361
+ ok,
362
+ });
363
+ }
364
+ case "forum_post": {
365
+ const topic = readTrimmedString(body.topic);
366
+ const messageBody = readTrimmedString(body.body);
367
+ if (!topic) {
368
+ return Response.json({ error: "topic is required for forum_post" }, { status: 400, headers });
369
+ }
370
+ if (!messageBody) {
371
+ return Response.json({ error: "body is required for forum_post" }, { status: 400, headers });
372
+ }
373
+ const author = readTrimmedString(body.author) || "operator";
374
+ const message = await deps.context.forumStore.post(topic, messageBody, author);
375
+ return commandCompletedResponse(headers, "forum post", { message });
376
+ }
377
+ case "heartbeat_create": {
378
+ const title = readTrimmedString(body.title);
379
+ if (!title) {
380
+ return Response.json({ error: "title is required for heartbeat_create" }, { status: 400, headers });
381
+ }
382
+ const parsedTarget = parseHeartbeatTarget(body);
383
+ if (!parsedTarget.target) {
384
+ return Response.json({ error: parsedTarget.error ?? "invalid target" }, { status: 400, headers });
385
+ }
386
+ const everyMsRaw = readFiniteNumberOrNull(body.every_ms);
387
+ if (body.every_ms != null && everyMsRaw == null) {
388
+ return Response.json({ error: "every_ms must be a finite number" }, { status: 400, headers });
389
+ }
390
+ if (body.reason != null && typeof body.reason !== "string") {
391
+ return Response.json({ error: "reason must be a string" }, { status: 400, headers });
392
+ }
393
+ if (body.wake_mode != null && typeof body.wake_mode !== "string") {
394
+ return Response.json({ error: "wake_mode must be a string" }, { status: 400, headers });
395
+ }
396
+ const enabled = parseOptionalBoolean(body.enabled);
397
+ if (!enabled.ok) {
398
+ return Response.json({ error: "enabled must be boolean" }, { status: 400, headers });
399
+ }
400
+ if (body.metadata != null &&
401
+ (typeof body.metadata !== "object" || Array.isArray(body.metadata))) {
402
+ return Response.json({ error: "metadata must be an object" }, { status: 400, headers });
403
+ }
404
+ try {
405
+ const program = await deps.heartbeatPrograms.create({
406
+ title,
407
+ target: parsedTarget.target,
408
+ everyMs: everyMsRaw == null ? undefined : Math.max(0, Math.trunc(everyMsRaw)),
409
+ reason: typeof body.reason === "string" ? body.reason : undefined,
410
+ wakeMode: body.wake_mode == null ? undefined : normalizeWakeMode(body.wake_mode),
411
+ enabled: enabled.value ?? undefined,
412
+ metadata: body.metadata,
413
+ });
414
+ return commandCompletedResponse(headers, "heartbeat create", { program });
415
+ }
416
+ catch (err) {
417
+ return Response.json({ error: deps.describeError(err) }, { status: 400, headers });
418
+ }
419
+ }
420
+ case "heartbeat_update": {
421
+ const programId = readTrimmedString(body.program_id);
422
+ if (!programId) {
423
+ return Response.json({ error: "program_id is required for heartbeat_update" }, { status: 400, headers });
424
+ }
425
+ let target;
426
+ if (body.target_kind != null) {
427
+ const parsedTarget = parseHeartbeatTarget(body);
428
+ if (!parsedTarget.target) {
429
+ return Response.json({ error: parsedTarget.error ?? "invalid target" }, { status: 400, headers });
430
+ }
431
+ target = parsedTarget.target;
432
+ }
433
+ const everyMsRaw = readFiniteNumberOrNull(body.every_ms);
434
+ if (body.every_ms != null && everyMsRaw == null) {
435
+ return Response.json({ error: "every_ms must be a finite number" }, { status: 400, headers });
436
+ }
437
+ if (body.reason != null && typeof body.reason !== "string") {
438
+ return Response.json({ error: "reason must be a string" }, { status: 400, headers });
439
+ }
440
+ if (body.wake_mode != null && typeof body.wake_mode !== "string") {
441
+ return Response.json({ error: "wake_mode must be a string" }, { status: 400, headers });
442
+ }
443
+ const enabled = parseOptionalBoolean(body.enabled);
444
+ if (!enabled.ok) {
445
+ return Response.json({ error: "enabled must be boolean" }, { status: 400, headers });
446
+ }
447
+ if (body.metadata != null &&
448
+ (typeof body.metadata !== "object" || Array.isArray(body.metadata))) {
449
+ return Response.json({ error: "metadata must be an object" }, { status: 400, headers });
450
+ }
451
+ try {
452
+ const result = await deps.heartbeatPrograms.update({
453
+ programId,
454
+ title: typeof body.title === "string" ? body.title : undefined,
455
+ target,
456
+ everyMs: everyMsRaw == null ? undefined : Math.max(0, Math.trunc(everyMsRaw)),
457
+ reason: typeof body.reason === "string" ? body.reason : undefined,
458
+ wakeMode: body.wake_mode == null ? undefined : normalizeWakeMode(body.wake_mode),
459
+ enabled: enabled.value ?? undefined,
460
+ metadata: body.metadata,
461
+ });
462
+ if (!result.ok) {
463
+ return Response.json({ error: `heartbeat update failed: ${result.reason ?? "unknown"}` }, { status: commandProgramFailureStatus(result.reason), headers });
464
+ }
465
+ return commandCompletedResponse(headers, "heartbeat update", { program: result.program });
466
+ }
467
+ catch (err) {
468
+ return Response.json({ error: deps.describeError(err) }, { status: 400, headers });
469
+ }
470
+ }
471
+ case "heartbeat_delete": {
472
+ const programId = readTrimmedString(body.program_id);
473
+ if (!programId) {
474
+ return Response.json({ error: "program_id is required for heartbeat_delete" }, { status: 400, headers });
475
+ }
476
+ const result = await deps.heartbeatPrograms.remove(programId);
477
+ if (!result.ok) {
478
+ return Response.json({ error: `heartbeat delete failed: ${result.reason ?? "unknown"}` }, { status: commandProgramFailureStatus(result.reason), headers });
479
+ }
480
+ return commandCompletedResponse(headers, "heartbeat delete", { program: result.program });
481
+ }
482
+ case "heartbeat_trigger": {
483
+ const programId = readTrimmedString(body.program_id);
484
+ if (!programId) {
485
+ return Response.json({ error: "program_id is required for heartbeat_trigger" }, { status: 400, headers });
486
+ }
487
+ if (body.reason != null && typeof body.reason !== "string") {
488
+ return Response.json({ error: "reason must be a string" }, { status: 400, headers });
489
+ }
490
+ const result = await deps.heartbeatPrograms.trigger({
491
+ programId,
492
+ reason: typeof body.reason === "string" ? body.reason : null,
493
+ });
494
+ if (!result.ok) {
495
+ return Response.json({ error: `heartbeat trigger failed: ${result.reason ?? "unknown"}` }, { status: commandProgramFailureStatus(result.reason), headers });
496
+ }
497
+ return commandCompletedResponse(headers, "heartbeat trigger", { program: result.program });
498
+ }
499
+ case "heartbeat_enable":
500
+ case "heartbeat_disable": {
501
+ const programId = readTrimmedString(body.program_id);
502
+ if (!programId) {
503
+ return Response.json({ error: `program_id is required for ${kind}` }, { status: 400, headers });
504
+ }
505
+ const result = await deps.heartbeatPrograms.update({
506
+ programId,
507
+ enabled: kind === "heartbeat_enable",
508
+ });
509
+ if (!result.ok) {
510
+ return Response.json({ error: `${kind} failed: ${result.reason ?? "unknown"}` }, { status: commandProgramFailureStatus(result.reason), headers });
511
+ }
512
+ return commandCompletedResponse(headers, kind.replaceAll("_", " "), { program: result.program });
513
+ }
514
+ case "cron_create": {
515
+ const title = readTrimmedString(body.title);
516
+ if (!title) {
517
+ return Response.json({ error: "title is required for cron_create" }, { status: 400, headers });
518
+ }
519
+ const parsedTarget = parseCronTarget(body);
520
+ if (!parsedTarget.target) {
521
+ return Response.json({ error: parsedTarget.error ?? "invalid target" }, { status: 400, headers });
522
+ }
523
+ if (!hasCronScheduleInput(body)) {
524
+ return Response.json({ error: "schedule is required for cron_create" }, { status: 400, headers });
525
+ }
526
+ if (body.reason != null && typeof body.reason !== "string") {
527
+ return Response.json({ error: "reason must be a string" }, { status: 400, headers });
528
+ }
529
+ if (body.wake_mode != null && typeof body.wake_mode !== "string") {
530
+ return Response.json({ error: "wake_mode must be a string" }, { status: 400, headers });
531
+ }
532
+ const enabled = parseOptionalBoolean(body.enabled);
533
+ if (!enabled.ok) {
534
+ return Response.json({ error: "enabled must be boolean" }, { status: 400, headers });
535
+ }
536
+ if (body.metadata != null &&
537
+ (typeof body.metadata !== "object" || Array.isArray(body.metadata))) {
538
+ return Response.json({ error: "metadata must be an object" }, { status: 400, headers });
539
+ }
540
+ try {
541
+ const program = await deps.cronPrograms.create({
542
+ title,
543
+ target: parsedTarget.target,
544
+ schedule: cronScheduleInputFromBody(body),
545
+ reason: typeof body.reason === "string" ? body.reason : undefined,
546
+ wakeMode: body.wake_mode == null ? undefined : normalizeWakeMode(body.wake_mode),
547
+ enabled: enabled.value ?? undefined,
548
+ metadata: body.metadata,
549
+ });
550
+ return commandCompletedResponse(headers, "cron create", { program });
551
+ }
552
+ catch (err) {
553
+ return Response.json({ error: deps.describeError(err) }, { status: 400, headers });
554
+ }
555
+ }
556
+ case "cron_update": {
557
+ const programId = readTrimmedString(body.program_id);
558
+ if (!programId) {
559
+ return Response.json({ error: "program_id is required for cron_update" }, { status: 400, headers });
560
+ }
561
+ let target;
562
+ if (body.target_kind != null) {
563
+ const parsedTarget = parseCronTarget(body);
564
+ if (!parsedTarget.target) {
565
+ return Response.json({ error: parsedTarget.error ?? "invalid target" }, { status: 400, headers });
566
+ }
567
+ target = parsedTarget.target;
568
+ }
569
+ if (body.reason != null && typeof body.reason !== "string") {
570
+ return Response.json({ error: "reason must be a string" }, { status: 400, headers });
571
+ }
572
+ if (body.wake_mode != null && typeof body.wake_mode !== "string") {
573
+ return Response.json({ error: "wake_mode must be a string" }, { status: 400, headers });
574
+ }
575
+ const enabled = parseOptionalBoolean(body.enabled);
576
+ if (!enabled.ok) {
577
+ return Response.json({ error: "enabled must be boolean" }, { status: 400, headers });
578
+ }
579
+ if (body.metadata != null &&
580
+ (typeof body.metadata !== "object" || Array.isArray(body.metadata))) {
581
+ return Response.json({ error: "metadata must be an object" }, { status: 400, headers });
582
+ }
583
+ try {
584
+ const result = await deps.cronPrograms.update({
585
+ programId,
586
+ title: typeof body.title === "string" ? body.title : undefined,
587
+ reason: typeof body.reason === "string" ? body.reason : undefined,
588
+ wakeMode: body.wake_mode == null ? undefined : normalizeWakeMode(body.wake_mode),
589
+ enabled: enabled.value ?? undefined,
590
+ target,
591
+ schedule: hasCronScheduleInput(body) ? cronScheduleInputFromBody(body) : undefined,
592
+ metadata: body.metadata,
593
+ });
594
+ if (!result.ok) {
595
+ return Response.json({ error: `cron update failed: ${result.reason ?? "unknown"}` }, { status: commandProgramFailureStatus(result.reason), headers });
596
+ }
597
+ return commandCompletedResponse(headers, "cron update", { program: result.program });
598
+ }
599
+ catch (err) {
600
+ return Response.json({ error: deps.describeError(err) }, { status: 400, headers });
601
+ }
602
+ }
603
+ case "cron_delete": {
604
+ const programId = readTrimmedString(body.program_id);
605
+ if (!programId) {
606
+ return Response.json({ error: "program_id is required for cron_delete" }, { status: 400, headers });
607
+ }
608
+ const result = await deps.cronPrograms.remove(programId);
609
+ if (!result.ok) {
610
+ return Response.json({ error: `cron delete failed: ${result.reason ?? "unknown"}` }, { status: commandProgramFailureStatus(result.reason), headers });
611
+ }
612
+ return commandCompletedResponse(headers, "cron delete", { program: result.program });
613
+ }
614
+ case "cron_trigger": {
615
+ const programId = readTrimmedString(body.program_id);
616
+ if (!programId) {
617
+ return Response.json({ error: "program_id is required for cron_trigger" }, { status: 400, headers });
618
+ }
619
+ if (body.reason != null && typeof body.reason !== "string") {
620
+ return Response.json({ error: "reason must be a string" }, { status: 400, headers });
621
+ }
622
+ const result = await deps.cronPrograms.trigger({
623
+ programId,
624
+ reason: typeof body.reason === "string" ? body.reason : null,
625
+ });
626
+ if (!result.ok) {
627
+ return Response.json({ error: `cron trigger failed: ${result.reason ?? "unknown"}` }, { status: commandProgramFailureStatus(result.reason), headers });
628
+ }
629
+ return commandCompletedResponse(headers, "cron trigger", { program: result.program });
630
+ }
631
+ case "cron_enable":
632
+ case "cron_disable": {
633
+ const programId = readTrimmedString(body.program_id);
634
+ if (!programId) {
635
+ return Response.json({ error: `program_id is required for ${kind}` }, { status: 400, headers });
636
+ }
637
+ const result = await deps.cronPrograms.update({
638
+ programId,
639
+ enabled: kind === "cron_enable",
640
+ });
641
+ if (!result.ok) {
642
+ return Response.json({ error: `${kind} failed: ${result.reason ?? "unknown"}` }, { status: commandProgramFailureStatus(result.reason), headers });
643
+ }
644
+ return commandCompletedResponse(headers, kind.replaceAll("_", " "), { program: result.program });
145
645
  }
146
- case "ready":
147
- commandText = "/mu ready";
148
- break;
149
646
  default:
150
647
  return Response.json({ error: `unknown command kind: ${kind}` }, { status: 400, headers });
151
648
  }
152
649
  try {
650
+ if (!commandText) {
651
+ return Response.json({ error: `unknown command kind: ${kind}` }, { status: 400, headers });
652
+ }
153
653
  if (!deps.controlPlaneProxy.submitTerminalCommand) {
154
654
  return Response.json({ error: "control plane not available" }, { status: 503, headers });
155
655
  }
@@ -199,6 +699,9 @@ export function createServerRequestHandler(deps) {
199
699
  });
200
700
  return response;
201
701
  }
702
+ if (path === "/api/context" || path.startsWith("/api/context/")) {
703
+ return contextRoutes(request, url, { context: deps.context, describeError: deps.describeError }, headers);
704
+ }
202
705
  if (path.startsWith("/webhooks/")) {
203
706
  const response = await deps.controlPlaneProxy.handleWebhook(path, request);
204
707
  if (response) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@femtomc/mu-server",
3
- "version": "26.2.72",
3
+ "version": "26.2.73",
4
4
  "description": "HTTP API server for mu status, work items, messaging setup, and web UI.",
5
5
  "keywords": [
6
6
  "mu",
@@ -31,11 +31,10 @@
31
31
  "start": "bun run dist/cli.js"
32
32
  },
33
33
  "dependencies": {
34
- "@femtomc/mu-agent": "26.2.72",
35
- "@femtomc/mu-control-plane": "26.2.72",
36
- "@femtomc/mu-core": "26.2.72",
37
- "@femtomc/mu-forum": "26.2.72",
38
- "@femtomc/mu-issue": "26.2.72",
39
- "@femtomc/mu-orchestrator": "26.2.72"
34
+ "@femtomc/mu-agent": "26.2.73",
35
+ "@femtomc/mu-control-plane": "26.2.73",
36
+ "@femtomc/mu-core": "26.2.73",
37
+ "@femtomc/mu-forum": "26.2.73",
38
+ "@femtomc/mu-issue": "26.2.73"
40
39
  }
41
40
  }