@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
@@ -0,0 +1,2 @@
1
+ import type { ServerRoutingDependencies } from "../server_routing.js";
2
+ export declare function heartbeatRoutes(request: Request, url: URL, deps: ServerRoutingDependencies, headers: Headers): Promise<Response>;
@@ -0,0 +1,211 @@
1
+ import { normalizeWakeMode } from "../server_types.js";
2
+ export async function heartbeatRoutes(request, url, deps, headers) {
3
+ const path = url.pathname;
4
+ if (path === "/api/heartbeats") {
5
+ if (request.method !== "GET") {
6
+ return Response.json({ error: "Method Not Allowed" }, { status: 405, headers });
7
+ }
8
+ const enabledRaw = url.searchParams.get("enabled")?.trim().toLowerCase();
9
+ 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
+ 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 programs = await deps.heartbeatPrograms.list({ enabled, targetKind, limit });
15
+ return Response.json({ count: programs.length, programs }, { headers });
16
+ }
17
+ if (path === "/api/heartbeats/create") {
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 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
+ const everyMs = typeof body.every_ms === "number" && Number.isFinite(body.every_ms)
60
+ ? Math.max(0, Math.trunc(body.every_ms))
61
+ : undefined;
62
+ const reason = typeof body.reason === "string" ? body.reason.trim() : undefined;
63
+ const wakeMode = normalizeWakeMode(body.wake_mode);
64
+ const enabled = typeof body.enabled === "boolean" ? body.enabled : undefined;
65
+ try {
66
+ const program = await deps.heartbeatPrograms.create({
67
+ title,
68
+ target,
69
+ everyMs,
70
+ reason,
71
+ wakeMode,
72
+ enabled,
73
+ metadata: body.metadata && typeof body.metadata === "object" && !Array.isArray(body.metadata)
74
+ ? body.metadata
75
+ : undefined,
76
+ });
77
+ return Response.json({ ok: true, program }, { status: 201, headers });
78
+ }
79
+ catch (err) {
80
+ return Response.json({ error: deps.describeError(err) }, { status: 400, headers });
81
+ }
82
+ }
83
+ if (path === "/api/heartbeats/update") {
84
+ if (request.method !== "POST") {
85
+ return Response.json({ error: "Method Not Allowed" }, { status: 405, headers });
86
+ }
87
+ let body;
88
+ try {
89
+ body = (await request.json());
90
+ }
91
+ catch {
92
+ return Response.json({ error: "invalid json body" }, { status: 400, headers });
93
+ }
94
+ const programId = typeof body.program_id === "string" ? body.program_id.trim() : "";
95
+ if (!programId) {
96
+ return Response.json({ error: "program_id is required" }, { status: 400, headers });
97
+ }
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
+ try {
129
+ const result = await deps.heartbeatPrograms.update({
130
+ programId,
131
+ title: typeof body.title === "string" ? body.title : undefined,
132
+ target,
133
+ everyMs: typeof body.every_ms === "number" && Number.isFinite(body.every_ms)
134
+ ? Math.max(0, Math.trunc(body.every_ms))
135
+ : undefined,
136
+ reason: typeof body.reason === "string" ? body.reason : undefined,
137
+ wakeMode,
138
+ enabled: typeof body.enabled === "boolean" ? body.enabled : undefined,
139
+ metadata: body.metadata && typeof body.metadata === "object" && !Array.isArray(body.metadata)
140
+ ? body.metadata
141
+ : undefined,
142
+ });
143
+ if (result.ok) {
144
+ return Response.json(result, { headers });
145
+ }
146
+ return Response.json(result, { status: result.reason === "not_found" ? 404 : 400, headers });
147
+ }
148
+ catch (err) {
149
+ return Response.json({ error: deps.describeError(err) }, { status: 400, headers });
150
+ }
151
+ }
152
+ if (path === "/api/heartbeats/delete") {
153
+ if (request.method !== "POST") {
154
+ return Response.json({ error: "Method Not Allowed" }, { status: 405, headers });
155
+ }
156
+ let body;
157
+ try {
158
+ body = (await request.json());
159
+ }
160
+ catch {
161
+ return Response.json({ error: "invalid json body" }, { status: 400, headers });
162
+ }
163
+ const programId = typeof body.program_id === "string" ? body.program_id.trim() : "";
164
+ if (!programId) {
165
+ return Response.json({ error: "program_id is required" }, { status: 400, headers });
166
+ }
167
+ const result = await deps.heartbeatPrograms.remove(programId);
168
+ return Response.json(result, { status: result.ok ? 200 : result.reason === "not_found" ? 404 : 400, headers });
169
+ }
170
+ if (path === "/api/heartbeats/trigger") {
171
+ if (request.method !== "POST") {
172
+ return Response.json({ error: "Method Not Allowed" }, { status: 405, headers });
173
+ }
174
+ let body;
175
+ try {
176
+ body = (await request.json());
177
+ }
178
+ catch {
179
+ return Response.json({ error: "invalid json body" }, { status: 400, headers });
180
+ }
181
+ const result = await deps.heartbeatPrograms.trigger({
182
+ programId: typeof body.program_id === "string" ? body.program_id : null,
183
+ reason: typeof body.reason === "string" ? body.reason : null,
184
+ });
185
+ if (result.ok) {
186
+ return Response.json(result, { headers });
187
+ }
188
+ if (result.reason === "missing_target") {
189
+ return Response.json(result, { status: 400, headers });
190
+ }
191
+ if (result.reason === "not_found") {
192
+ return Response.json(result, { status: 404, headers });
193
+ }
194
+ return Response.json(result, { status: 409, headers });
195
+ }
196
+ if (path.startsWith("/api/heartbeats/")) {
197
+ if (request.method !== "GET") {
198
+ return Response.json({ error: "Method Not Allowed" }, { status: 405, headers });
199
+ }
200
+ const id = decodeURIComponent(path.slice("/api/heartbeats/".length)).trim();
201
+ if (!id) {
202
+ return Response.json({ error: "missing program id" }, { status: 400, headers });
203
+ }
204
+ const program = await deps.heartbeatPrograms.get(id);
205
+ if (!program) {
206
+ return Response.json({ error: "program not found" }, { status: 404, headers });
207
+ }
208
+ return Response.json(program, { headers });
209
+ }
210
+ return Response.json({ error: "Not Found" }, { status: 404, headers });
211
+ }
@@ -0,0 +1,2 @@
1
+ import type { ServerRoutingDependencies } from "../server_routing.js";
2
+ export declare function identityRoutes(request: Request, url: URL, deps: ServerRoutingDependencies, headers: Headers): Promise<Response>;
@@ -0,0 +1,103 @@
1
+ import { getControlPlanePaths, IdentityStore, ROLE_SCOPES, } from "@femtomc/mu-control-plane";
2
+ export async function identityRoutes(request, url, deps, headers) {
3
+ const path = url.pathname;
4
+ const cpPaths = getControlPlanePaths(deps.context.repoRoot);
5
+ const identityStore = new IdentityStore(cpPaths.identitiesPath);
6
+ await identityStore.load();
7
+ if (path === "/api/identities") {
8
+ if (request.method !== "GET") {
9
+ return Response.json({ error: "Method Not Allowed" }, { status: 405, headers });
10
+ }
11
+ const includeInactive = url.searchParams.get("include_inactive")?.trim().toLowerCase() === "true";
12
+ const bindings = identityStore.listBindings({ includeInactive });
13
+ return Response.json({ count: bindings.length, bindings }, { headers });
14
+ }
15
+ if (path === "/api/identities/link") {
16
+ if (request.method !== "POST") {
17
+ return Response.json({ error: "Method Not Allowed" }, { status: 405, headers });
18
+ }
19
+ let body;
20
+ try {
21
+ body = (await request.json());
22
+ }
23
+ catch {
24
+ return Response.json({ error: "invalid json body" }, { status: 400, headers });
25
+ }
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 });
29
+ }
30
+ const actorId = typeof body.actor_id === "string" ? body.actor_id.trim() : "";
31
+ if (!actorId) {
32
+ return Response.json({ error: "actor_id is required" }, { status: 400, headers });
33
+ }
34
+ const tenantId = typeof body.tenant_id === "string" ? body.tenant_id.trim() : "";
35
+ if (!tenantId) {
36
+ return Response.json({ error: "tenant_id is required" }, { status: 400, headers });
37
+ }
38
+ const roleKey = typeof body.role === "string" ? body.role.trim() : "operator";
39
+ const roleScopes = ROLE_SCOPES[roleKey];
40
+ if (!roleScopes) {
41
+ return Response.json({ error: `invalid role: ${roleKey} (operator, contributor, viewer)` }, { status: 400, headers });
42
+ }
43
+ const bindingId = typeof body.binding_id === "string" && body.binding_id.trim().length > 0
44
+ ? body.binding_id.trim()
45
+ : `bind-${crypto.randomUUID()}`;
46
+ const operatorId = typeof body.operator_id === "string" && body.operator_id.trim().length > 0
47
+ ? body.operator_id.trim()
48
+ : "default";
49
+ const decision = await identityStore.link({
50
+ bindingId,
51
+ operatorId,
52
+ channel: channel,
53
+ channelTenantId: tenantId,
54
+ channelActorId: actorId,
55
+ scopes: [...roleScopes],
56
+ });
57
+ switch (decision.kind) {
58
+ case "linked":
59
+ return Response.json({ ok: true, kind: "linked", binding: decision.binding }, { status: 201, headers });
60
+ case "binding_exists":
61
+ return Response.json({ ok: false, kind: "binding_exists", binding: decision.binding }, { status: 409, headers });
62
+ case "principal_already_linked":
63
+ return Response.json({ ok: false, kind: "principal_already_linked", binding: decision.binding }, { status: 409, headers });
64
+ }
65
+ }
66
+ if (path === "/api/identities/unlink") {
67
+ if (request.method !== "POST") {
68
+ return Response.json({ error: "Method Not Allowed" }, { status: 405, headers });
69
+ }
70
+ let body;
71
+ try {
72
+ body = (await request.json());
73
+ }
74
+ catch {
75
+ return Response.json({ error: "invalid json body" }, { status: 400, headers });
76
+ }
77
+ const bindingId = typeof body.binding_id === "string" ? body.binding_id.trim() : "";
78
+ if (!bindingId) {
79
+ return Response.json({ error: "binding_id is required" }, { status: 400, headers });
80
+ }
81
+ const actorBindingId = typeof body.actor_binding_id === "string" ? body.actor_binding_id.trim() : "";
82
+ if (!actorBindingId) {
83
+ return Response.json({ error: "actor_binding_id is required" }, { status: 400, headers });
84
+ }
85
+ const reason = typeof body.reason === "string" ? body.reason.trim() : null;
86
+ const decision = await identityStore.unlinkSelf({
87
+ bindingId,
88
+ actorBindingId,
89
+ reason: reason || null,
90
+ });
91
+ switch (decision.kind) {
92
+ case "unlinked":
93
+ return Response.json({ ok: true, kind: "unlinked", binding: decision.binding }, { headers });
94
+ case "not_found":
95
+ return Response.json({ ok: false, kind: "not_found" }, { status: 404, headers });
96
+ case "invalid_actor":
97
+ return Response.json({ ok: false, kind: "invalid_actor" }, { status: 403, headers });
98
+ case "already_inactive":
99
+ return Response.json({ ok: false, kind: "already_inactive", binding: decision.binding }, { status: 409, headers });
100
+ }
101
+ }
102
+ return Response.json({ error: "Not Found" }, { status: 404, headers });
103
+ }
@@ -1,3 +1,58 @@
1
+ import { DEFAULT_ISSUE_QUERY_LIMIT, ISSUE_STATUS_VALUES, IssueStoreNotFoundError, IssueStoreValidationError, normalizeIssueContainsFilter, normalizeIssueQueryLimit, } from "@femtomc/mu-issue";
2
+ const ISSUE_STATUS_SET = new Set(ISSUE_STATUS_VALUES);
3
+ function normalizeIssueId(value) {
4
+ try {
5
+ return decodeURIComponent(value).trim();
6
+ }
7
+ catch (cause) {
8
+ throw new IssueStoreValidationError("invalid issue id encoding", { cause });
9
+ }
10
+ }
11
+ function normalizeIssueStatusFilter(value) {
12
+ if (value == null) {
13
+ return undefined;
14
+ }
15
+ const normalized = value.trim();
16
+ if (normalized.length === 0) {
17
+ return undefined;
18
+ }
19
+ if (!ISSUE_STATUS_SET.has(normalized)) {
20
+ throw new IssueStoreValidationError(`invalid issue status filter: ${normalized}`);
21
+ }
22
+ return normalized;
23
+ }
24
+ async function readJsonBody(request) {
25
+ let body;
26
+ try {
27
+ body = await request.json();
28
+ }
29
+ catch {
30
+ throw new IssueStoreValidationError("invalid json body");
31
+ }
32
+ if (!body || typeof body !== "object" || Array.isArray(body)) {
33
+ throw new IssueStoreValidationError("json body must be an object");
34
+ }
35
+ return body;
36
+ }
37
+ function errorResponse(status, message) {
38
+ return new Response(JSON.stringify({ error: message }), {
39
+ status,
40
+ headers: { "Content-Type": "application/json" },
41
+ });
42
+ }
43
+ function mapIssueRouteError(error) {
44
+ if (error instanceof IssueStoreNotFoundError) {
45
+ return errorResponse(404, error.message);
46
+ }
47
+ if (error instanceof IssueStoreValidationError) {
48
+ return errorResponse(400, error.message);
49
+ }
50
+ if (error instanceof Error && error.name === "ZodError") {
51
+ return errorResponse(400, error.message);
52
+ }
53
+ console.error("Issue API error:", error);
54
+ return errorResponse(500, error instanceof Error ? error.message : "Internal server error");
55
+ }
1
56
  export async function issueRoutes(request, context) {
2
57
  const url = new URL(request.url);
3
58
  const path = url.pathname.replace("/api/issues", "") || "/";
@@ -5,37 +60,63 @@ export async function issueRoutes(request, context) {
5
60
  try {
6
61
  // List issues - GET /api/issues
7
62
  if (path === "/" && method === "GET") {
8
- const status = url.searchParams.get("status");
9
- const tag = url.searchParams.get("tag");
10
- const issues = await context.issueStore.list({
11
- status: status,
12
- tag: tag || undefined,
63
+ const status = normalizeIssueStatusFilter(url.searchParams.get("status"));
64
+ const tag = url.searchParams.get("tag")?.trim() || undefined;
65
+ const contains = normalizeIssueContainsFilter(url.searchParams.get("contains"));
66
+ const limit = normalizeIssueQueryLimit(url.searchParams.get("limit"), {
67
+ defaultLimit: DEFAULT_ISSUE_QUERY_LIMIT,
13
68
  });
69
+ const issues = await context.issueStore.list({ status, tag, contains, limit: limit ?? undefined });
14
70
  return Response.json(issues);
15
71
  }
16
72
  // Get ready issues - GET /api/issues/ready
17
73
  if (path === "/ready" && method === "GET") {
18
- const root = url.searchParams.get("root");
19
- const issues = await context.issueStore.ready(root || undefined);
74
+ const root = url.searchParams.get("root")?.trim() || undefined;
75
+ const contains = normalizeIssueContainsFilter(url.searchParams.get("contains"));
76
+ const limit = normalizeIssueQueryLimit(url.searchParams.get("limit"), {
77
+ defaultLimit: DEFAULT_ISSUE_QUERY_LIMIT,
78
+ });
79
+ const issues = await context.issueStore.ready(root, { contains, limit: limit ?? undefined });
20
80
  return Response.json(issues);
21
81
  }
22
82
  // Get single issue - GET /api/issues/:id
23
83
  if (path.startsWith("/") && method === "GET") {
24
- const id = path.slice(1);
25
- if (id) {
26
- const issue = await context.issueStore.get(id);
27
- if (!issue) {
28
- return new Response("Issue not found", { status: 404 });
29
- }
30
- return Response.json(issue);
84
+ const id = normalizeIssueId(path.slice(1));
85
+ if (id.length === 0) {
86
+ return errorResponse(400, "issue id is required");
31
87
  }
88
+ const issue = await context.issueStore.get(id);
89
+ if (!issue) {
90
+ return errorResponse(404, "issue not found");
91
+ }
92
+ return Response.json(issue);
32
93
  }
33
94
  // Create issue - POST /api/issues
34
95
  if (path === "/" && method === "POST") {
35
- const body = (await request.json());
36
- const { title, body: issueBody, tags, priority } = body;
96
+ const body = await readJsonBody(request);
97
+ const title = typeof body.title === "string" ? body.title.trim() : "";
37
98
  if (!title) {
38
- return new Response("Title is required", { status: 400 });
99
+ return errorResponse(400, "title is required");
100
+ }
101
+ const issueBody = body.body == null ? undefined : typeof body.body === "string" ? body.body : undefined;
102
+ if (body.body != null && issueBody == null) {
103
+ return errorResponse(400, "body must be a string when provided");
104
+ }
105
+ let tags;
106
+ if (body.tags != null) {
107
+ if (!Array.isArray(body.tags) || !body.tags.every((tag) => typeof tag === "string")) {
108
+ return errorResponse(400, "tags must be a string[] when provided");
109
+ }
110
+ tags = body.tags.map((tag) => tag.trim());
111
+ }
112
+ let priority;
113
+ if (body.priority != null) {
114
+ if (typeof body.priority !== "number" ||
115
+ !Number.isFinite(body.priority) ||
116
+ !Number.isInteger(body.priority)) {
117
+ return errorResponse(400, "priority must be an integer when provided");
118
+ }
119
+ priority = body.priority;
39
120
  }
40
121
  const issue = await context.issueStore.create(title, {
41
122
  body: issueBody,
@@ -46,41 +127,47 @@ export async function issueRoutes(request, context) {
46
127
  }
47
128
  // Update issue - PATCH /api/issues/:id
48
129
  if (path.startsWith("/") && method === "PATCH") {
49
- const id = path.slice(1);
50
- if (id) {
51
- const body = (await request.json());
52
- const issue = await context.issueStore.update(id, body);
53
- return Response.json(issue);
130
+ const id = normalizeIssueId(path.slice(1));
131
+ if (id.length === 0) {
132
+ return errorResponse(400, "issue id is required");
54
133
  }
134
+ const body = await readJsonBody(request);
135
+ const issue = await context.issueStore.update(id, body);
136
+ return Response.json(issue);
55
137
  }
56
138
  // Close issue - POST /api/issues/:id/close
57
139
  if (path.endsWith("/close") && method === "POST") {
58
- const id = path.slice(1, -6); // Remove leading / and trailing /close
59
- const body = (await request.json());
60
- const { outcome } = body;
140
+ const id = normalizeIssueId(path.slice(1, -"/close".length));
141
+ if (id.length === 0) {
142
+ return errorResponse(400, "issue id is required");
143
+ }
144
+ const body = await readJsonBody(request);
145
+ const outcome = typeof body.outcome === "string" ? body.outcome.trim() : "";
61
146
  if (!outcome) {
62
- return new Response("Outcome is required", { status: 400 });
147
+ return errorResponse(400, "outcome is required");
63
148
  }
64
149
  const issue = await context.issueStore.close(id, outcome);
65
150
  return Response.json(issue);
66
151
  }
67
152
  // Claim issue - POST /api/issues/:id/claim
68
153
  if (path.endsWith("/claim") && method === "POST") {
69
- const id = path.slice(1, -6); // Remove leading / and trailing /claim
154
+ const id = normalizeIssueId(path.slice(1, -"/claim".length));
155
+ if (id.length === 0) {
156
+ return errorResponse(400, "issue id is required");
157
+ }
70
158
  const success = await context.issueStore.claim(id);
71
159
  if (!success) {
72
- return new Response("Failed to claim issue", { status: 409 });
160
+ return errorResponse(409, "failed to claim issue");
73
161
  }
74
162
  const issue = await context.issueStore.get(id);
163
+ if (!issue) {
164
+ return errorResponse(404, "issue not found");
165
+ }
75
166
  return Response.json(issue);
76
167
  }
77
168
  return new Response("Not Found", { status: 404 });
78
169
  }
79
170
  catch (error) {
80
- console.error("Issue API error:", error);
81
- return new Response(JSON.stringify({ error: error instanceof Error ? error.message : "Internal server error" }), {
82
- status: 500,
83
- headers: { "Content-Type": "application/json" },
84
- });
171
+ return mapIssueRouteError(error);
85
172
  }
86
173
  }
@@ -0,0 +1,2 @@
1
+ import type { ServerRoutingDependencies } from "../server_routing.js";
2
+ export declare function runRoutes(request: Request, url: URL, deps: ServerRoutingDependencies, headers: Headers): Promise<Response>;