@femtomc/mu-server 26.2.73 → 26.2.74

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 (51) hide show
  1. package/README.md +54 -66
  2. package/dist/api/control_plane.js +56 -0
  3. package/dist/api/cron.js +2 -23
  4. package/dist/api/heartbeats.js +1 -66
  5. package/dist/api/identities.js +3 -2
  6. package/dist/api/runs.js +0 -83
  7. package/dist/api/session_flash.d.ts +60 -0
  8. package/dist/api/session_flash.js +326 -0
  9. package/dist/api/session_turn.d.ts +38 -0
  10. package/dist/api/session_turn.js +423 -0
  11. package/dist/config.d.ts +9 -4
  12. package/dist/config.js +24 -24
  13. package/dist/control_plane.d.ts +2 -16
  14. package/dist/control_plane.js +57 -83
  15. package/dist/control_plane_adapter_registry.d.ts +19 -0
  16. package/dist/control_plane_adapter_registry.js +74 -0
  17. package/dist/control_plane_contract.d.ts +1 -7
  18. package/dist/control_plane_run_queue_coordinator.d.ts +1 -7
  19. package/dist/control_plane_run_queue_coordinator.js +1 -62
  20. package/dist/control_plane_telegram_generation.js +1 -0
  21. package/dist/control_plane_wake_delivery.js +1 -0
  22. package/dist/cron_programs.d.ts +21 -35
  23. package/dist/cron_programs.js +32 -113
  24. package/dist/cron_request.d.ts +0 -6
  25. package/dist/cron_request.js +0 -41
  26. package/dist/heartbeat_programs.d.ts +20 -35
  27. package/dist/heartbeat_programs.js +26 -122
  28. package/dist/index.d.ts +2 -2
  29. package/dist/outbound_delivery_router.d.ts +12 -0
  30. package/dist/outbound_delivery_router.js +29 -0
  31. package/dist/run_supervisor.d.ts +1 -16
  32. package/dist/run_supervisor.js +0 -70
  33. package/dist/server.d.ts +0 -5
  34. package/dist/server.js +95 -127
  35. package/dist/server_program_orchestration.d.ts +4 -19
  36. package/dist/server_program_orchestration.js +49 -200
  37. package/dist/server_routing.d.ts +0 -9
  38. package/dist/server_routing.js +19 -654
  39. package/dist/server_runtime.js +0 -1
  40. package/dist/server_types.d.ts +0 -2
  41. package/dist/server_types.js +0 -7
  42. package/package.json +6 -9
  43. package/dist/api/context.d.ts +0 -5
  44. package/dist/api/context.js +0 -1147
  45. package/dist/api/forum.d.ts +0 -2
  46. package/dist/api/forum.js +0 -75
  47. package/dist/api/issues.d.ts +0 -2
  48. package/dist/api/issues.js +0 -173
  49. package/public/assets/index-CxkevQNh.js +0 -100
  50. package/public/assets/index-D_8anM-D.css +0 -1
  51. package/public/index.html +0 -14
package/README.md CHANGED
@@ -1,6 +1,8 @@
1
1
  # @femtomc/mu-server
2
2
 
3
- HTTP API server for mu. Powers `mu serve`, the web UI, and programmatic status/control endpoints.
3
+ HTTP API server for mu control-plane infrastructure. Powers `mu serve`, messaging frontend transport routes, and control-plane/session coordination endpoints.
4
+
5
+ > Scope note: server-routed business query/mutation gateway endpoints have been removed. Business reads/writes are CLI-first, while long-lived runtime coordination (runs/activities/heartbeats/cron) stays server-owned.
4
6
 
5
7
  ## Installation
6
8
 
@@ -39,8 +41,6 @@ Bun.serve(server);
39
41
  ```json
40
42
  {
41
43
  "repo_root": "/path/to/repo",
42
- "open_count": 10,
43
- "ready_count": 3,
44
44
  "control_plane": {
45
45
  "active": true,
46
46
  "adapters": ["slack"],
@@ -99,79 +99,71 @@ Bun.serve(server);
99
99
  ```
100
100
  - Response includes generation metadata and, when telegram generation handling runs, `telegram_generation` lifecycle detail.
101
101
  - `POST /api/control-plane/rollback` - Explicit rollback trigger (same pipeline, reason=`rollback`)
102
+ - `GET /api/control-plane/channels` - Capability/discovery snapshot for mounted adapter channels (route, verification contract, configured/active/frontend flags)
102
103
 
103
- ### Issues
104
+ ### Session Flash Inbox (cross-session context handoff)
104
105
 
105
- - `GET /api/issues` - List issues
106
- - Query params: `?status=open&tag=bug&contains=crash&limit=50`
107
- - `limit` defaults to `200` and is clamped to `<= 200`.
108
- - `GET /api/issues/:id` - Get issue by ID
109
- - `POST /api/issues` - Create new issue
110
- ```json
111
- {
112
- "title": "Issue title",
113
- "body": "Issue description",
114
- "tags": ["bug", "priority", "role:worker"],
115
- "priority": 2
116
- }
117
- ```
118
- - `PATCH /api/issues/:id` - Update issue
119
- - `POST /api/issues/:id/close` - Close issue
106
+ - `POST /api/session-flash` - Create a session-targeted flash message
120
107
  ```json
121
108
  {
122
- "outcome": "success"
109
+ "session_id": "operator-abc123",
110
+ "session_kind": "cp_operator",
111
+ "body": "Use context id ctx-123 for this question",
112
+ "context_ids": ["ctx-123"],
113
+ "source": "neovim"
123
114
  }
124
115
  ```
125
- - `POST /api/issues/:id/claim` - Claim issue (changes status to in_progress)
126
- - `GET /api/issues/ready` - Get ready issues
127
- - Query params: `?root=issue-id&contains=worker&limit=20`
128
- - `limit` defaults to `200` and is clamped to `<= 200`.
129
-
130
- ### Forum
131
-
132
- - `GET /api/forum/topics` - List forum topics
133
- - Query params: `?prefix=issue:&limit=20`
134
- - `limit` defaults to `100` and is clamped to `<= 200`.
135
- - `GET /api/forum/read` - Read messages from topic
136
- - Query params: `?topic=issue:123&limit=50`
137
- - `limit` defaults to `50` and is clamped to `<= 200`.
138
- - `POST /api/forum/post` - Post message to topic
116
+ - `GET /api/session-flash` - List flash messages
117
+ - Query params: `session_id`, `session_kind`, `status=pending|delivered|all`, `contains`, `limit`
118
+ - `GET /api/session-flash/:flash_id` - Get one flash message by id
119
+ - `POST /api/session-flash/ack` - Mark a flash message delivered/acknowledged
120
+
121
+ ### Session Turn Injection (canonical transcript turn)
122
+
123
+ - `POST /api/session-turn` - Run a real turn in an existing target session and return reply + new context cursor
139
124
  ```json
140
125
  {
141
- "topic": "issue:123",
142
- "body": "Message content",
143
- "author": "username"
126
+ "session_id": "operator-abc123",
127
+ "session_kind": "cp_operator",
128
+ "body": "Summarize the last plan and propose next steps.",
129
+ "source": "neovim"
144
130
  }
145
131
  ```
146
-
147
- ### Context Retrieval (Cross-store historical memory)
148
-
149
- - `GET /api/context/search` - Search across `.mu` history stores
150
- - Query params: `query`/`q`, `limit`, `source`/`sources`, `issue_id`, `run_id`, `session_id`,
151
- `conversation_key`, `channel`, `channel_tenant_id`, `channel_conversation_id`, `actor_binding_id`,
152
- `topic`, `author`, `role`, `since`, `until`.
153
- - `GET /api/context/timeline` - Ordered timeline view anchored to a scope
154
- - Requires at least one anchor filter: `conversation_key`, `issue_id`, `run_id`, `session_id`, `topic`, or `channel`.
155
- - Supports `order=asc|desc` and same filters as search.
156
- - `GET /api/context/stats` - Source-level cardinality/text-size stats for indexed context items.
157
-
158
- Context source kinds:
159
-
160
- - `issues`, `forum`, `events`
161
- - `cp_commands`, `cp_outbox`, `cp_adapter_audit`, `cp_operator_turns`, `cp_telegram_ingress`
162
- - `operator_sessions`, `cp_operator_sessions`
132
+ - Optional overrides: `session_file`, `session_dir`, `provider`, `model`, `thinking`, `extension_profile`
133
+ - Response includes: `turn.reply`, `turn.context_entry_id`, `turn.session_file`
134
+
135
+ ### Control-plane Coordination Endpoints
136
+
137
+ - Runs:
138
+ - `GET /api/runs`
139
+ - `POST /api/runs/start`
140
+ - `POST /api/runs/resume`
141
+ - `POST /api/runs/interrupt`
142
+ - `GET /api/runs/:id`
143
+ - `GET /api/runs/:id/trace`
144
+ - Scheduling + orchestration:
145
+ - `GET|POST|PATCH|DELETE /api/heartbeats...`
146
+ - `GET|POST|PATCH|DELETE /api/cron...`
147
+ - `GET|POST /api/activities...`
148
+ - Heartbeat/cron ticks dispatch operator wake turns and broadcast the resulting operator reply.
149
+ - Identity bindings:
150
+ - `GET /api/identities`
151
+ - `POST /api/identities/link`
152
+ - `POST /api/identities/unlink`
153
+ - Observability:
154
+ - `GET /api/events`
155
+ - `GET /api/events/tail`
163
156
 
164
157
  ## Running the Server
165
158
 
166
- ### With Web UI (Recommended)
159
+ ### With terminal operator session (recommended)
167
160
 
168
- The easiest way to run the server with the bundled web interface (and default terminal operator session):
161
+ The easiest way to run the server with the default terminal operator session:
169
162
 
170
163
  ```bash
171
164
  # From any mu repository
172
- mu serve # API + web UI + terminal operator session
173
- mu serve --no-open # Skip browser auto-open (headless/SSH)
174
- mu serve --port 8080 # Custom shared API/web UI port
165
+ mu serve # API + terminal operator session
166
+ mu serve --port 8080 # Custom API/operator port
175
167
  ```
176
168
 
177
169
  Type `/exit` (or press Ctrl+C) to stop both the operator session and server.
@@ -215,13 +207,9 @@ bun run start
215
207
  ## Architecture
216
208
 
217
209
  The server uses:
218
- - Filesystem-backed JSONL stores (FsJsonlStore)
219
- - IssueStore and ForumStore from mu packages
210
+ - Filesystem-backed JSONL event storage (FsJsonlStore)
220
211
  - Bun's built-in HTTP server
221
- - Simple REST-style JSON API
212
+ - Control-plane adapter/webhook transport + session coordination routes
222
213
  - Generation-supervised control-plane hot reload lifecycle (see `docs/adr-0001-control-plane-hot-reload.md`)
223
214
 
224
- All data is persisted to `.mu/` directory:
225
- - `.mu/issues.jsonl` - Issue data
226
- - `.mu/forum.jsonl` - Forum messages
227
- - `.mu/events.jsonl` - Event log
215
+ Control-plane/coordination data is persisted to `.mu/` (for example `.mu/events.jsonl` and `.mu/control-plane/*`).
@@ -1,3 +1,35 @@
1
+ import { CONTROL_PLANE_CHANNEL_ADAPTER_SPECS } from "@femtomc/mu-control-plane";
2
+ function asRecord(value) {
3
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
4
+ return null;
5
+ }
6
+ return value;
7
+ }
8
+ function activeChannelsFromStatus(status) {
9
+ const record = asRecord(status);
10
+ const adapters = Array.isArray(record?.adapters) ? record.adapters : [];
11
+ const set = new Set();
12
+ for (const adapter of adapters) {
13
+ if (typeof adapter === "string" && adapter.trim().length > 0) {
14
+ set.add(adapter.trim());
15
+ }
16
+ }
17
+ return set;
18
+ }
19
+ function configuredForChannel(config, channel) {
20
+ switch (channel) {
21
+ case "slack":
22
+ return typeof config.control_plane.adapters.slack.signing_secret === "string";
23
+ case "discord":
24
+ return typeof config.control_plane.adapters.discord.signing_secret === "string";
25
+ case "telegram":
26
+ return typeof config.control_plane.adapters.telegram.webhook_secret === "string";
27
+ case "neovim":
28
+ return typeof config.control_plane.adapters.neovim.shared_secret === "string";
29
+ default:
30
+ return false;
31
+ }
32
+ }
1
33
  export async function controlPlaneRoutes(request, url, deps, headers) {
2
34
  const path = url.pathname;
3
35
  if (path === "/api/control-plane/reload") {
@@ -24,5 +56,29 @@ export async function controlPlaneRoutes(request, url, deps, headers) {
24
56
  const result = await deps.reloadControlPlane("rollback");
25
57
  return Response.json(result, { status: result.ok ? 200 : 500, headers });
26
58
  }
59
+ if (path === "/api/control-plane/channels") {
60
+ if (request.method !== "GET") {
61
+ return Response.json({ error: "Method Not Allowed" }, { status: 405, headers });
62
+ }
63
+ const [config, status] = await Promise.all([deps.loadConfigFromDisk(), Promise.resolve(deps.getControlPlaneStatus())]);
64
+ const activeChannels = activeChannelsFromStatus(status);
65
+ const channels = CONTROL_PLANE_CHANNEL_ADAPTER_SPECS.map((spec) => ({
66
+ channel: spec.channel,
67
+ route: spec.route,
68
+ ingress_payload: spec.ingress_payload,
69
+ verification: spec.verification,
70
+ ack_format: spec.ack_format,
71
+ delivery_semantics: spec.delivery_semantics,
72
+ deferred_delivery: spec.deferred_delivery,
73
+ configured: configuredForChannel(config, spec.channel),
74
+ active: activeChannels.has(spec.channel),
75
+ frontend: spec.channel === "neovim",
76
+ }));
77
+ return Response.json({
78
+ ok: true,
79
+ generated_at_ms: Date.now(),
80
+ channels,
81
+ }, { headers });
82
+ }
27
83
  return Response.json({ error: "Not Found" }, { status: 404, headers });
28
84
  }
package/dist/api/cron.js CHANGED
@@ -1,5 +1,4 @@
1
- import { cronScheduleInputFromBody, hasCronScheduleInput, parseCronTarget, } from "../cron_request.js";
2
- import { normalizeWakeMode } from "../server_types.js";
1
+ import { cronScheduleInputFromBody, hasCronScheduleInput } from "../cron_request.js";
3
2
  export async function cronRoutes(request, url, deps, headers) {
4
3
  const path = url.pathname;
5
4
  if (path === "/api/cron/status") {
@@ -15,15 +14,13 @@ export async function cronRoutes(request, url, deps, headers) {
15
14
  }
16
15
  const enabledRaw = url.searchParams.get("enabled")?.trim().toLowerCase();
17
16
  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
17
  const scheduleKindRaw = url.searchParams.get("schedule_kind")?.trim().toLowerCase();
21
18
  const scheduleKind = scheduleKindRaw === "at" || scheduleKindRaw === "every" || scheduleKindRaw === "cron"
22
19
  ? scheduleKindRaw
23
20
  : undefined;
24
21
  const limitRaw = url.searchParams.get("limit");
25
22
  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 });
23
+ const programs = await deps.cronPrograms.list({ enabled, scheduleKind, limit });
27
24
  return Response.json({ count: programs.length, programs }, { headers });
28
25
  }
29
26
  if (path === "/api/cron/create") {
@@ -41,24 +38,17 @@ export async function cronRoutes(request, url, deps, headers) {
41
38
  if (!title) {
42
39
  return Response.json({ error: "title is required" }, { status: 400, headers });
43
40
  }
44
- const parsedTarget = parseCronTarget(body);
45
- if (!parsedTarget.target) {
46
- return Response.json({ error: parsedTarget.error ?? "invalid target" }, { status: 400, headers });
47
- }
48
41
  if (!hasCronScheduleInput(body)) {
49
42
  return Response.json({ error: "schedule is required" }, { status: 400, headers });
50
43
  }
51
44
  const schedule = cronScheduleInputFromBody(body);
52
45
  const reason = typeof body.reason === "string" ? body.reason.trim() : undefined;
53
- const wakeMode = normalizeWakeMode(body.wake_mode);
54
46
  const enabled = typeof body.enabled === "boolean" ? body.enabled : undefined;
55
47
  try {
56
48
  const program = await deps.cronPrograms.create({
57
49
  title,
58
- target: parsedTarget.target,
59
50
  schedule,
60
51
  reason,
61
- wakeMode,
62
52
  enabled,
63
53
  metadata: body.metadata && typeof body.metadata === "object" && !Array.isArray(body.metadata)
64
54
  ? body.metadata
@@ -85,24 +75,13 @@ export async function cronRoutes(request, url, deps, headers) {
85
75
  if (!programId) {
86
76
  return Response.json({ error: "program_id is required" }, { status: 400, headers });
87
77
  }
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
78
  const schedule = hasCronScheduleInput(body) ? cronScheduleInputFromBody(body) : undefined;
97
- const wakeMode = Object.hasOwn(body, "wake_mode") ? normalizeWakeMode(body.wake_mode) : undefined;
98
79
  try {
99
80
  const result = await deps.cronPrograms.update({
100
81
  programId,
101
82
  title: typeof body.title === "string" ? body.title : undefined,
102
83
  reason: typeof body.reason === "string" ? body.reason : undefined,
103
- wakeMode,
104
84
  enabled: typeof body.enabled === "boolean" ? body.enabled : undefined,
105
- target,
106
85
  schedule,
107
86
  metadata: body.metadata && typeof body.metadata === "object" && !Array.isArray(body.metadata)
108
87
  ? body.metadata
@@ -1,4 +1,3 @@
1
- import { normalizeWakeMode } from "../server_types.js";
2
1
  export async function heartbeatRoutes(request, url, deps, headers) {
3
2
  const path = url.pathname;
4
3
  if (path === "/api/heartbeats") {
@@ -7,11 +6,9 @@ export async function heartbeatRoutes(request, url, deps, headers) {
7
6
  }
8
7
  const enabledRaw = url.searchParams.get("enabled")?.trim().toLowerCase();
9
8
  const enabled = enabledRaw === "true" ? true : enabledRaw === "false" ? false : undefined;
10
- const targetKindRaw = url.searchParams.get("target_kind")?.trim().toLowerCase();
11
- const targetKind = targetKindRaw === "run" || targetKindRaw === "activity" ? targetKindRaw : undefined;
12
9
  const limitRaw = url.searchParams.get("limit");
13
10
  const limit = limitRaw && /^\d+$/.test(limitRaw) ? Math.max(1, Math.min(500, Number.parseInt(limitRaw, 10))) : undefined;
14
- const programs = await deps.heartbeatPrograms.list({ enabled, targetKind, limit });
11
+ const programs = await deps.heartbeatPrograms.list({ enabled, limit });
15
12
  return Response.json({ count: programs.length, programs }, { headers });
16
13
  }
17
14
  if (path === "/api/heartbeats/create") {
@@ -29,46 +26,16 @@ export async function heartbeatRoutes(request, url, deps, headers) {
29
26
  if (!title) {
30
27
  return Response.json({ error: "title is required" }, { status: 400, headers });
31
28
  }
32
- const targetKind = typeof body.target_kind === "string" ? body.target_kind.trim().toLowerCase() : "";
33
- let target = null;
34
- if (targetKind === "run") {
35
- const jobId = typeof body.run_job_id === "string" ? body.run_job_id.trim() : "";
36
- const rootIssueId = typeof body.run_root_issue_id === "string" ? body.run_root_issue_id.trim() : "";
37
- if (!jobId && !rootIssueId) {
38
- return Response.json({ error: "run target requires run_job_id or run_root_issue_id" }, { status: 400, headers });
39
- }
40
- target = {
41
- kind: "run",
42
- job_id: jobId || null,
43
- root_issue_id: rootIssueId || null,
44
- };
45
- }
46
- else if (targetKind === "activity") {
47
- const activityId = typeof body.activity_id === "string" ? body.activity_id.trim() : "";
48
- if (!activityId) {
49
- return Response.json({ error: "activity target requires activity_id" }, { status: 400, headers });
50
- }
51
- target = {
52
- kind: "activity",
53
- activity_id: activityId,
54
- };
55
- }
56
- else {
57
- return Response.json({ error: "target_kind must be run or activity" }, { status: 400, headers });
58
- }
59
29
  const everyMs = typeof body.every_ms === "number" && Number.isFinite(body.every_ms)
60
30
  ? Math.max(0, Math.trunc(body.every_ms))
61
31
  : undefined;
62
32
  const reason = typeof body.reason === "string" ? body.reason.trim() : undefined;
63
- const wakeMode = normalizeWakeMode(body.wake_mode);
64
33
  const enabled = typeof body.enabled === "boolean" ? body.enabled : undefined;
65
34
  try {
66
35
  const program = await deps.heartbeatPrograms.create({
67
36
  title,
68
- target,
69
37
  everyMs,
70
38
  reason,
71
- wakeMode,
72
39
  enabled,
73
40
  metadata: body.metadata && typeof body.metadata === "object" && !Array.isArray(body.metadata)
74
41
  ? body.metadata
@@ -95,46 +62,14 @@ export async function heartbeatRoutes(request, url, deps, headers) {
95
62
  if (!programId) {
96
63
  return Response.json({ error: "program_id is required" }, { status: 400, headers });
97
64
  }
98
- let target;
99
- if (typeof body.target_kind === "string") {
100
- const targetKind = body.target_kind.trim().toLowerCase();
101
- if (targetKind === "run") {
102
- const jobId = typeof body.run_job_id === "string" ? body.run_job_id.trim() : "";
103
- const rootIssueId = typeof body.run_root_issue_id === "string" ? body.run_root_issue_id.trim() : "";
104
- if (!jobId && !rootIssueId) {
105
- return Response.json({ error: "run target requires run_job_id or run_root_issue_id" }, { status: 400, headers });
106
- }
107
- target = {
108
- kind: "run",
109
- job_id: jobId || null,
110
- root_issue_id: rootIssueId || null,
111
- };
112
- }
113
- else if (targetKind === "activity") {
114
- const activityId = typeof body.activity_id === "string" ? body.activity_id.trim() : "";
115
- if (!activityId) {
116
- return Response.json({ error: "activity target requires activity_id" }, { status: 400, headers });
117
- }
118
- target = {
119
- kind: "activity",
120
- activity_id: activityId,
121
- };
122
- }
123
- else {
124
- return Response.json({ error: "target_kind must be run or activity" }, { status: 400, headers });
125
- }
126
- }
127
- const wakeMode = Object.hasOwn(body, "wake_mode") ? normalizeWakeMode(body.wake_mode) : undefined;
128
65
  try {
129
66
  const result = await deps.heartbeatPrograms.update({
130
67
  programId,
131
68
  title: typeof body.title === "string" ? body.title : undefined,
132
- target,
133
69
  everyMs: typeof body.every_ms === "number" && Number.isFinite(body.every_ms)
134
70
  ? Math.max(0, Math.trunc(body.every_ms))
135
71
  : undefined,
136
72
  reason: typeof body.reason === "string" ? body.reason : undefined,
137
- wakeMode,
138
73
  enabled: typeof body.enabled === "boolean" ? body.enabled : undefined,
139
74
  metadata: body.metadata && typeof body.metadata === "object" && !Array.isArray(body.metadata)
140
75
  ? body.metadata
@@ -24,8 +24,9 @@ export async function identityRoutes(request, url, deps, headers) {
24
24
  return Response.json({ error: "invalid json body" }, { status: 400, headers });
25
25
  }
26
26
  const channel = typeof body.channel === "string" ? body.channel.trim() : "";
27
- if (!channel || (channel !== "slack" && channel !== "discord" && channel !== "telegram")) {
28
- return Response.json({ error: "channel is required (slack, discord, telegram)" }, { status: 400, headers });
27
+ if (!channel ||
28
+ (channel !== "slack" && channel !== "discord" && channel !== "telegram" && channel !== "neovim")) {
29
+ return Response.json({ error: "channel is required (slack, discord, telegram, neovim)" }, { status: 400, headers });
29
30
  }
30
31
  const actorId = typeof body.actor_id === "string" ? body.actor_id.trim() : "";
31
32
  if (!actorId) {
package/dist/api/runs.js CHANGED
@@ -1,4 +1,3 @@
1
- import { normalizeWakeMode } from "../server_types.js";
2
1
  export async function runRoutes(request, url, deps, headers) {
3
2
  const path = url.pathname;
4
3
  if (path === "/api/runs") {
@@ -34,16 +33,6 @@ export async function runRoutes(request, url, deps, headers) {
34
33
  if (!run) {
35
34
  return Response.json({ error: "run supervisor unavailable" }, { status: 503, headers });
36
35
  }
37
- await deps.registerAutoRunHeartbeatProgram(run).catch(async (error) => {
38
- await deps.context.eventLog.emit("run.auto_heartbeat.lifecycle", {
39
- source: "mu-server.runs",
40
- payload: {
41
- action: "register_failed",
42
- run_job_id: run.job_id,
43
- error: deps.describeError(error),
44
- },
45
- });
46
- });
47
36
  return Response.json({ ok: true, run }, { status: 201, headers });
48
37
  }
49
38
  catch (err) {
@@ -73,16 +62,6 @@ export async function runRoutes(request, url, deps, headers) {
73
62
  if (!run) {
74
63
  return Response.json({ error: "run supervisor unavailable" }, { status: 503, headers });
75
64
  }
76
- await deps.registerAutoRunHeartbeatProgram(run).catch(async (error) => {
77
- await deps.context.eventLog.emit("run.auto_heartbeat.lifecycle", {
78
- source: "mu-server.runs",
79
- payload: {
80
- action: "register_failed",
81
- run_job_id: run.job_id,
82
- error: deps.describeError(error),
83
- },
84
- });
85
- });
86
65
  return Response.json({ ok: true, run }, { status: 201, headers });
87
66
  }
88
67
  catch (err) {
@@ -109,61 +88,8 @@ export async function runRoutes(request, url, deps, headers) {
109
88
  if (!result) {
110
89
  return Response.json({ error: "run supervisor unavailable" }, { status: 503, headers });
111
90
  }
112
- if (!result.ok && result.reason === "not_running" && result.run) {
113
- await deps.disableAutoRunHeartbeatProgram({
114
- jobId: result.run.job_id,
115
- status: result.run.status,
116
- reason: "interrupt_not_running",
117
- }).catch(() => {
118
- // best effort cleanup only
119
- });
120
- }
121
91
  return Response.json(result, { status: result.ok ? 200 : 404, headers });
122
92
  }
123
- if (path === "/api/runs/heartbeat") {
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 rootIssueId = typeof body.root_issue_id === "string" ? body.root_issue_id.trim() : null;
135
- const jobId = typeof body.job_id === "string" ? body.job_id.trim() : null;
136
- const reason = typeof body.reason === "string" ? body.reason.trim() : null;
137
- const wakeMode = normalizeWakeMode(body.wake_mode);
138
- const result = await deps.controlPlaneProxy.heartbeatRun?.({
139
- rootIssueId,
140
- jobId,
141
- reason,
142
- wakeMode,
143
- });
144
- if (!result) {
145
- return Response.json({ error: "run supervisor unavailable" }, { status: 503, headers });
146
- }
147
- if (!result.ok && result.reason === "not_running" && result.run) {
148
- await deps.disableAutoRunHeartbeatProgram({
149
- jobId: result.run.job_id,
150
- status: result.run.status,
151
- reason: "run_not_running",
152
- }).catch(() => {
153
- // best effort cleanup only
154
- });
155
- }
156
- if (result.ok) {
157
- return Response.json(result, { status: 200, headers });
158
- }
159
- if (result.reason === "missing_target") {
160
- return Response.json(result, { status: 400, headers });
161
- }
162
- if (result.reason === "not_running") {
163
- return Response.json(result, { status: 409, headers });
164
- }
165
- return Response.json(result, { status: 404, headers });
166
- }
167
93
  if (path.startsWith("/api/runs/")) {
168
94
  const rest = path.slice("/api/runs/".length);
169
95
  const [rawId, maybeSub] = rest.split("/");
@@ -192,15 +118,6 @@ export async function runRoutes(request, url, deps, headers) {
192
118
  if (!run) {
193
119
  return Response.json({ error: "run not found" }, { status: 404, headers });
194
120
  }
195
- if (run.status !== "running") {
196
- await deps.disableAutoRunHeartbeatProgram({
197
- jobId: run.job_id,
198
- status: run.status,
199
- reason: "run_terminal_snapshot",
200
- }).catch(() => {
201
- // best effort cleanup only
202
- });
203
- }
204
121
  return Response.json(run, { headers });
205
122
  }
206
123
  return Response.json({ error: "Not Found" }, { status: 404, headers });
@@ -0,0 +1,60 @@
1
+ import type { ServerRoutingDependencies } from "../server_routing.js";
2
+ export type SessionFlashStatus = "pending" | "delivered";
3
+ export type SessionFlashRecord = {
4
+ flash_id: string;
5
+ created_at_ms: number;
6
+ session_id: string;
7
+ session_kind: string | null;
8
+ body: string;
9
+ context_ids: string[];
10
+ source: string | null;
11
+ metadata: Record<string, unknown>;
12
+ from: {
13
+ channel: string | null;
14
+ channel_tenant_id: string | null;
15
+ channel_conversation_id: string | null;
16
+ actor_binding_id: string | null;
17
+ };
18
+ status: SessionFlashStatus;
19
+ delivered_at_ms: number | null;
20
+ delivered_by: string | null;
21
+ delivery_note: string | null;
22
+ };
23
+ export declare function getSessionFlashPath(repoRoot: string): string;
24
+ export declare function listSessionFlashRecords(opts: {
25
+ repoRoot: string;
26
+ sessionId?: string | null;
27
+ sessionKind?: string | null;
28
+ status?: SessionFlashStatus | "all";
29
+ contains?: string | null;
30
+ limit?: number;
31
+ }): Promise<SessionFlashRecord[]>;
32
+ export declare function getSessionFlashRecord(opts: {
33
+ repoRoot: string;
34
+ flashId: string;
35
+ }): Promise<SessionFlashRecord | null>;
36
+ export declare function createSessionFlashRecord(opts: {
37
+ repoRoot: string;
38
+ sessionId: string;
39
+ body: string;
40
+ sessionKind?: string | null;
41
+ contextIds?: string[];
42
+ source?: string | null;
43
+ metadata?: Record<string, unknown>;
44
+ from?: {
45
+ channel?: string | null;
46
+ channel_tenant_id?: string | null;
47
+ channel_conversation_id?: string | null;
48
+ actor_binding_id?: string | null;
49
+ };
50
+ nowMs?: number;
51
+ }): Promise<SessionFlashRecord>;
52
+ export declare function ackSessionFlashRecord(opts: {
53
+ repoRoot: string;
54
+ flashId: string;
55
+ sessionId?: string | null;
56
+ deliveredBy?: string | null;
57
+ note?: string | null;
58
+ nowMs?: number;
59
+ }): Promise<SessionFlashRecord | null>;
60
+ export declare function sessionFlashRoutes(request: Request, url: URL, deps: ServerRoutingDependencies, headers: Headers): Promise<Response>;