agent-relay-server 0.32.2 → 0.32.4

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 (42) hide show
  1. package/package.json +2 -2
  2. package/public/assets/display-JI19Vc7L.js.map +1 -1
  3. package/src/branch-landed.ts +38 -2
  4. package/src/cli.ts +3 -3
  5. package/src/maintenance.ts +21 -21
  6. package/src/mcp.ts +2 -2
  7. package/src/ratchet-files.ts +37 -0
  8. package/src/routes/_shared.ts +376 -0
  9. package/src/routes/activity.ts +61 -0
  10. package/src/routes/agent-profiles.ts +47 -0
  11. package/src/routes/agent-sessions.ts +488 -0
  12. package/src/routes/agents-spawn.ts +274 -0
  13. package/src/routes/agents.ts +251 -0
  14. package/src/routes/artifacts.ts +226 -0
  15. package/src/routes/automations.ts +83 -0
  16. package/src/routes/commands.ts +317 -0
  17. package/src/routes/config.ts +66 -0
  18. package/src/routes/connectors.ts +108 -0
  19. package/src/routes/inbox.ts +142 -0
  20. package/src/routes/index.ts +293 -0
  21. package/src/routes/insights.ts +81 -0
  22. package/src/routes/integrations.ts +592 -0
  23. package/src/routes/memory.ts +337 -0
  24. package/src/routes/messages.ts +529 -0
  25. package/src/routes/orchestrator-bootstrap.ts +100 -0
  26. package/src/routes/orchestrator-proxy.ts +160 -0
  27. package/src/routes/orchestrator.ts +490 -0
  28. package/src/routes/pairs.ts +197 -0
  29. package/src/routes/provider-config.ts +112 -0
  30. package/src/routes/recipes.ts +113 -0
  31. package/src/routes/spawn-policy.ts +231 -0
  32. package/src/routes/spec.ts +54 -0
  33. package/src/routes/sse.ts +9 -0
  34. package/src/routes/stats.ts +32 -0
  35. package/src/routes/steward.ts +45 -0
  36. package/src/routes/tasks.ts +174 -0
  37. package/src/routes/tokens.ts +311 -0
  38. package/src/routes/workspaces.ts +364 -0
  39. package/src/routes.ts +3 -6822
  40. package/src/validation.ts +134 -0
  41. package/src/workspace-actions.ts +7 -1
  42. package/src/workspace-merge.ts +12 -1
@@ -1,9 +1,9 @@
1
1
  import { emitRelayEvent } from "./events";
2
2
  import { getNotificationsConfig } from "./config-store";
3
3
  import { notifySystemMessage } from "./notify";
4
- import { listAgents } from "./db";
4
+ import { createActivityEvent, listAgents, updateWorkspaceStatus } from "./db";
5
5
  import { isAgentOnline } from "./agent-ref";
6
- import type { AgentCard, WorkspaceRecord } from "./types";
6
+ import type { AgentCard, WorkspaceMergePreview, WorkspaceRecord } from "./types";
7
7
 
8
8
  export interface BranchLandedInput {
9
9
  /**
@@ -113,6 +113,42 @@ export function notifyBranchLanded(input: BranchLandedInput): void {
113
113
  }
114
114
  }
115
115
 
116
+ /**
117
+ * Finalize a workspace whose work landed in base out-of-band — a pr-strategy land whose
118
+ * PR merged (by anyone, incl. `gh pr merge`), or a squash/cherry-pick already in base
119
+ * (#304). Marks it terminal `merged`, records the merge SHA, and fires the same
120
+ * branch.landed push the local land path emits — a pr land never runs through the
121
+ * workspace.merge result handler, so without this the author never learns it shipped.
122
+ * Lives here (not maintenance.ts) so the giant doesn't grow and land-notify has one home.
123
+ */
124
+ export function reconcileLandedWorkspace(ws: WorkspaceRecord, preview: WorkspaceMergePreview): void {
125
+ const sha = typeof preview.prMergeSha === "string" ? preview.prMergeSha : undefined;
126
+ const via = preview.prMerged === true ? "pr" : "git";
127
+ updateWorkspaceStatus(ws.id, "merged", {
128
+ autoMerged: true,
129
+ mergedFromStatus: ws.status,
130
+ landedDetectedAt: Date.now(),
131
+ landedVia: via,
132
+ ...(sha ? { landedSha: sha } : {}),
133
+ autoConflict: false,
134
+ });
135
+ createActivityEvent({
136
+ clientId: "server-workspace-" + ws.id + "-merged-" + Date.now(),
137
+ kind: "state",
138
+ title: "Workspace work landed in base",
139
+ body: `${ws.branch ?? ws.id} is ${via === "pr" ? "merged on the remote (PR)" : "already merged into base"} ${preview.baseRef ? `(${preview.baseRef})` : ""} — marking merged`,
140
+ meta: ws.branch ?? ws.id,
141
+ icon: "ti-git-merge",
142
+ view: "orchestrators",
143
+ metadata: { source: "server", maintenanceJobId: "workspace-conflict-scan", workspaceId: ws.id, fromStatus: ws.status, ...(sha ? { landedSha: sha } : {}) },
144
+ });
145
+ try {
146
+ notifyBranchLanded({ workspace: ws, mergedSha: sha, pushed: true });
147
+ } catch {
148
+ // Notification is best-effort; the merged status + activity event still stand.
149
+ }
150
+ }
151
+
116
152
  // An agent is "on `main`" when its registered cwd equals the repo's main checkout — i.e. it
117
153
  // works in the base, not an isolated worktree. Excludes the author, pseudo agents (system/
118
154
  // user), channels, and offline sessions.
package/src/cli.ts CHANGED
@@ -1617,7 +1617,7 @@ async function handleWorkspaceCommand(args: string[]): Promise<void> {
1617
1617
  throw new Error(WORKSPACE_USAGE);
1618
1618
  }
1619
1619
 
1620
- let id = currentWorkspaceId();
1620
+ let id = currentWorkspaceId(), idExplicit = false; // idExplicit: --id was passed, not the ambient default (#307)
1621
1621
  let strategy: string | undefined;
1622
1622
  let purpose: string | undefined;
1623
1623
  let repo: string | undefined;
@@ -1628,7 +1628,7 @@ async function handleWorkspaceCommand(args: string[]): Promise<void> {
1628
1628
  let timeoutSeconds: number | undefined;
1629
1629
  for (let i = 1; i < args.length; i++) {
1630
1630
  const arg = args[i];
1631
- if (arg === "--id" && i + 1 < args.length) id = args[++i];
1631
+ if (arg === "--id" && i + 1 < args.length) { id = args[++i]; idExplicit = true; }
1632
1632
  else if (arg === "--strategy" && i + 1 < args.length) strategy = args[++i];
1633
1633
  else if (arg === "--purpose" && i + 1 < args.length) purpose = args[++i];
1634
1634
  else if (arg === "--repo" && i + 1 < args.length) repo = args[++i];
@@ -1651,7 +1651,7 @@ async function handleWorkspaceCommand(args: string[]): Promise<void> {
1651
1651
  }
1652
1652
 
1653
1653
  if (action === "cleanup-stale") {
1654
- const result = await apiRequest("POST", "/api/workspaces/actions/cleanup-stale", { repoRoot: repo, dryRun: !execute });
1654
+ const result = await apiRequest("POST", "/api/workspaces/actions/cleanup-stale", { repoRoot: repo, dryRun: !execute, ...(idExplicit && id ? { workspaceId: id } : {}) });
1655
1655
  console.log(JSON.stringify(result, null, 2));
1656
1656
  return;
1657
1657
  }
@@ -33,6 +33,7 @@ import {
33
33
  } from "./db";
34
34
  import type { WorkspaceMergePreview, WorkspaceRecord, WorkspaceStatus } from "./types";
35
35
  import { requestWorkspaceMerge } from "./workspace-merge";
36
+ import { reconcileLandedWorkspace } from "./branch-landed";
36
37
  import { workspaceActiveClaim } from "./workspace-claim";
37
38
  import { reapOrphanedWorktrees } from "./workspace-orphans";
38
39
  import { deriveBranchState, READY_TO_LAND_STATUSES, TERMINAL_WORKSPACE_STATUSES } from "./workspace-phase";
@@ -434,8 +435,11 @@ function workspacePathWithinBase(path: string | undefined, baseDir: string | und
434
435
  return rel === "" || (!!rel && !rel.startsWith("..") && !isAbsolute(rel));
435
436
  }
436
437
 
437
- async function fetchHostMergePreview(apiUrl: string, workspace: WorkspaceRecord): Promise<WorkspaceMergePreview | { available: false } | null> {
438
- const query = new URLSearchParams({ path: workspace.worktreePath, checkPr: "1" });
438
+ async function fetchHostMergePreview(apiUrl: string, workspace: WorkspaceRecord, opts: { checkPr?: boolean } = {}): Promise<WorkspaceMergePreview | { available: false } | null> {
439
+ // `checkPr` costs a `gh` round-trip, so the caller opts in only where PR-merge ground
440
+ // truth is actionable (reconcile/land candidates), not for every badge-probe (#304).
441
+ const checkPr = opts.checkPr !== false;
442
+ const query = new URLSearchParams({ path: workspace.worktreePath, ...(checkPr ? { checkPr: "1" } : {}) });
439
443
  if (workspace.baseRef) query.set("baseRef", workspace.baseRef);
440
444
  if (workspace.baseSha) query.set("baseSha", workspace.baseSha);
441
445
  const headers: Record<string, string> = {};
@@ -577,10 +581,12 @@ async function scanWorkspaceConflicts(): Promise<Record<string, unknown>> {
577
581
  for (const ws of candidates) {
578
582
  const orch = orchestrators.find((candidate) => workspacePathWithinBase(ws.sourceCwd, candidate.baseDir));
579
583
  if (!orch?.apiUrl) continue;
580
- const preview = await fetchHostMergePreview(orch.apiUrl, ws);
584
+ // Spend the `gh` PR-state round-trip only on reconcile-eligible rows; active/ready
585
+ // are excluded from reconcile, so their scan stays git-only (#304).
586
+ const preview = await fetchHostMergePreview(orch.apiUrl, ws, { checkPr: LANDED_RECONCILE_STATUSES.has(ws.status) });
581
587
  if (!preview || (preview as { available?: false }).available === false) continue;
582
588
  const p = preview as WorkspaceMergePreview;
583
- if (p.error || p.missing || p.conflict === undefined) continue;
589
+ if (p.error || p.missing) continue;
584
590
 
585
591
  const meta = ws.metadata as Record<string, unknown>;
586
592
 
@@ -605,29 +611,23 @@ async function scanWorkspaceConflicts(): Promise<Record<string, unknown>> {
605
611
  // merge_planned forever otherwise, and the conflict scan can even pin a
606
612
  // landed branch to `conflict`). Reconcile to the terminal `merged` status so
607
613
  // the dashboard stops showing it as unmerged and GC prunes it on schedule.
614
+ // This runs BEFORE the conflict-undefined skip below: a PR merged via a regular
615
+ // merge commit makes the branch an ancestor (ahead=0 → no-op → conflict comes
616
+ // back undefined), which the skip would otherwise drop, stranding the row at
617
+ // merge_planned — the exact #304 stall.
618
+ // Reconcile + finalize (record SHA, fire branch.landed) in branch-landed.ts so the
619
+ // giant doesn't grow (#291) and land-notify stays single-homed (#304).
608
620
  const landed = p.landed === true || p.prMerged === true;
609
621
  if (landed && LANDED_RECONCILE_STATUSES.has(ws.status)) {
610
- updateWorkspaceStatus(ws.id, "merged", {
611
- autoMerged: true,
612
- mergedFromStatus: ws.status,
613
- landedDetectedAt: Date.now(),
614
- landedVia: p.prMerged === true ? "pr" : "git",
615
- autoConflict: false,
616
- });
622
+ reconcileLandedWorkspace(ws, p);
617
623
  merged.push(ws.id);
618
- createActivityEvent({
619
- clientId: "server-workspace-" + ws.id + "-merged-" + Date.now(),
620
- kind: "state",
621
- title: "Workspace work landed in base",
622
- body: `${ws.branch ?? ws.id} is ${p.prMerged === true ? "merged on the remote (PR)" : "already merged into base"} ${p.baseRef ? `(${p.baseRef})` : ""} — marking merged`,
623
- meta: ws.branch ?? ws.id,
624
- icon: "ti-git-merge",
625
- view: "orchestrators",
626
- metadata: { source: "server", maintenanceJobId: "workspace-conflict-scan", workspaceId: ws.id, fromStatus: ws.status },
627
- });
628
624
  continue;
629
625
  }
630
626
 
627
+ // Past here we act on the conflict signal — skip when the host couldn't assess it
628
+ // (undefined): never flag/clear a conflict on incomplete data.
629
+ if (p.conflict === undefined) continue;
630
+
631
631
  if (p.conflict === true && ws.status !== "conflict") {
632
632
  updateWorkspaceStatus(ws.id, "conflict", {
633
633
  autoConflict: true,
package/src/mcp.ts CHANGED
@@ -45,7 +45,7 @@ import {
45
45
  isIntegrationAllowed,
46
46
  } from "./security";
47
47
  import type { ActivityKind, AgentCard, ArtifactKind, ArtifactSensitivity, AttachmentRef, Command, SendMessageInput, Message, SpawnApprovalMode, SpawnProvider, WorkspaceMergeStrategy, WorkspaceRecord } from "./types";
48
- import { applyWorkspaceAction, waitForWorkspaceStatus, type WorkspaceAction } from "./workspace-actions";
48
+ import { LAND_STRATEGIES, applyWorkspaceAction, waitForWorkspaceStatus, type WorkspaceAction } from "./workspace-actions";
49
49
  import { describeWorkspacePhase, landReceipt, readyContract, worktreeMcpInstructions } from "./workspace-phase";
50
50
  import { type ProviderEffort } from "agent-relay-sdk/provider-catalog";
51
51
  import { errMessage, isRecord, SPAWN_PROVIDERS, APPROVAL_MODES, VALID_EFFORTS } from "agent-relay-sdk";
@@ -1011,7 +1011,7 @@ function relayWorkspaceMutation(auth: McpAuthContext, action: WorkspaceAction, a
1011
1011
  action,
1012
1012
  agentId: callerAgentId(auth) ?? auth.actor,
1013
1013
  detail: optionalString(args.detail, "detail", 4000),
1014
- strategy: action === "merge" ? (optionalEnum(args.strategy, "strategy", ["pr", "rebase-ff", "auto"] as const) as WorkspaceMergeStrategy | undefined) : undefined,
1014
+ strategy: action === "merge" ? (optionalEnum(args.strategy, "strategy", LAND_STRATEGIES) as WorkspaceMergeStrategy | undefined) : undefined,
1015
1015
  deleteBranch: action === "merge" ? optionalBoolean(args.deleteBranch, "deleteBranch") : undefined,
1016
1016
  prTitle: optionalString(args.prTitle, "prTitle", 240),
1017
1017
  prBody: optionalString(args.prBody, "prBody", 8000),
@@ -0,0 +1,37 @@
1
+ // Shared file enumeration for the ratchet tests (duplication + file-size).
2
+ //
3
+ // Kept in ONE place so the two ratchets can never drift on *which* files they
4
+ // scan — a file-size ratchet that duplicated its own harness would be the very
5
+ // thing it exists to prevent (see issue #300). Both `duplication-ratchet.test.ts`
6
+ // and `file-size-ratchet.test.ts` import from here.
7
+
8
+ import { Glob } from "bun";
9
+
10
+ // Source roots scanned by the ratchets. Mirror real package layout; generated
11
+ // and bundled output (public/, dist/, node_modules/) is excluded below.
12
+ export const RATCHET_ROOTS = [
13
+ "src",
14
+ "sdk/src",
15
+ "client",
16
+ "orchestrator/src",
17
+ "runner/src",
18
+ "connectors",
19
+ "dashboard/src",
20
+ "scripts",
21
+ "examples",
22
+ ];
23
+
24
+ // All tracked first-party .ts/.tsx source files, excluding tests, type
25
+ // declarations, and build output. Paths are returned relative to repo root.
26
+ export function ratchetSourceFiles(): string[] {
27
+ const files: string[] = [];
28
+ for (const root of RATCHET_ROOTS) {
29
+ const glob = new Glob("**/*.{ts,tsx}");
30
+ for (const rel of glob.scanSync({ cwd: root, onlyFiles: true })) {
31
+ if (rel.includes("node_modules") || rel.includes("dist/")) continue;
32
+ if (rel.endsWith(".test.ts") || rel.endsWith(".test.tsx") || rel.endsWith(".d.ts")) continue;
33
+ files.push(`${root}/${rel}`);
34
+ }
35
+ }
36
+ return files;
37
+ }
@@ -0,0 +1,376 @@
1
+ // Auto-split from routes.ts (#299). Domain: _shared.
2
+ import { MAX_BODY_BYTES, getIntegrationTokens } from "../config";
3
+ import { MemoryBrokerContractError } from "../memory-broker-contract";
4
+ import { ValidationError, createActivityEvent, createCallbackDelivery, finishCallbackDelivery, getAgent, getTask, normalizeReactionEmoji, sendMessageWithResult } from "../db";
5
+ import { cleanEpoch, cleanString, optionalEnum } from "../validation";
6
+ import { emitActivityEvent, emitMessageQueued, emitNewMessage } from "../sse";
7
+ import { emitRelayEvent } from "../events";
8
+ import { errMessage, isRecord } from "agent-relay-sdk";
9
+ import { generateSpawnRequestId } from "../spawn-command";
10
+ import { getComponentAuth, getIntegrationAuth, isAuthorized, isRequestAuthorizedFor } from "../security";
11
+ import { readBodyBytes } from "../http-body";
12
+ import { type ActivityEventInput, type AgentSessionGuard, type ArtifactKind, type AttachmentRef, type Command, type MemoryBrokerContext, type Message } from "../types";
13
+
14
+ export type Handler = (
15
+ req: Request,
16
+ params: Record<string, string>
17
+ ) => Response | Promise<Response>;
18
+
19
+ export const json = (data: unknown, status = 200) =>
20
+ Response.json(data, { status });
21
+
22
+ export const error = (msg: string, status = 400) =>
23
+ json({ error: msg }, status);
24
+
25
+ export function authorizeRoute(req: Request, check: Parameters<typeof isRequestAuthorizedFor>[1]): Response | null {
26
+ if (!hasPresentedCredential(req)) return null;
27
+ return isRequestAuthorizedFor(req, check) ? null : error("forbidden", 403);
28
+ }
29
+
30
+ function hasPresentedCredential(req: Request): boolean {
31
+ return Boolean(req.headers.get("authorization") || req.headers.get("x-agent-relay-token") || new URL(req.url).searchParams.get("token"));
32
+ }
33
+
34
+ export function authAuditMetadata(req: Request): Record<string, unknown> {
35
+ const component = getComponentAuth(req);
36
+ if (component) {
37
+ return {
38
+ auth: {
39
+ type: "component",
40
+ sub: component.sub,
41
+ role: component.role,
42
+ jti: component.jti,
43
+ scope: component.scope,
44
+ constraints: component.constraints,
45
+ },
46
+ };
47
+ }
48
+ const integration = getIntegrationAuth(req);
49
+ if (integration) {
50
+ return {
51
+ auth: {
52
+ type: "integration",
53
+ name: integration.name,
54
+ scopes: integration.scopes,
55
+ targets: integration.targets,
56
+ channels: integration.channels,
57
+ },
58
+ };
59
+ }
60
+ return hasPresentedCredential(req) ? { auth: { type: "root" } } : {};
61
+ }
62
+
63
+ export function isRootCredentialRequest(req: Request): boolean {
64
+ return hasPresentedCredential(req) && isAuthorized(req) && !getComponentAuth(req) && !getIntegrationAuth(req);
65
+ }
66
+
67
+ function sanitizeAttribution(raw: unknown): string | undefined {
68
+ if (typeof raw !== "string") return undefined;
69
+ // eslint-disable-next-line no-control-regex
70
+ const cleaned = raw.replace(/[\x00-\x1f\x7f]/g, "").trim();
71
+ if (!cleaned) return undefined;
72
+ return cleaned.length > 120 ? cleaned.slice(0, 120) : cleaned;
73
+ }
74
+
75
+ export function dashboardAttribution(req: Request, surface?: unknown): Record<string, unknown> {
76
+ const out: Record<string, unknown> = {};
77
+ const client = sanitizeAttribution(req.headers.get("x-dashboard-client-id"));
78
+ const session = sanitizeAttribution(req.headers.get("x-dashboard-session-id"));
79
+ const view = sanitizeAttribution(req.headers.get("x-dashboard-view"));
80
+ const component = isRecord(surface) ? sanitizeAttribution(surface.component) : undefined;
81
+ if (client) out.client = client;
82
+ if (session) out.session = session;
83
+ if (view) out.view = view;
84
+ if (component) out.component = component;
85
+ if (Object.keys(out).length === 0) return {};
86
+ out.route = `${req.method} ${new URL(req.url).pathname}`;
87
+ return { surface: out };
88
+ }
89
+
90
+ type ParseBodyResult<T> =
91
+ | { ok: true; body: T | null }
92
+ | { ok: false; status: number; error: string };
93
+
94
+ export async function parseBody<T>(req: Request): Promise<ParseBodyResult<T>> {
95
+ if (!req.body) return { ok: true, body: null };
96
+ const read = await readBodyBytes(req.body, MAX_BODY_BYTES);
97
+ if (!read.ok) return { ok: false, status: read.status, error: read.error };
98
+ if (read.bytes.byteLength === 0) return { ok: true, body: null };
99
+ try {
100
+ const decoded = new TextDecoder().decode(read.bytes);
101
+ return { ok: true, body: JSON.parse(decoded) as T };
102
+ } catch {
103
+ return { ok: false, status: 400, error: "invalid JSON body" };
104
+ }
105
+ }
106
+
107
+ export function parseId(raw: string | undefined): number | null {
108
+ if (!raw) return null;
109
+ const n = Number(raw);
110
+ if (!Number.isInteger(n) || n <= 0 || n > Number.MAX_SAFE_INTEGER) return null;
111
+ return n;
112
+ }
113
+
114
+ export function parseQueryInt(
115
+ raw: string | null,
116
+ opts: { min: number; max: number }
117
+ ): number | null {
118
+ if (raw === null) return null;
119
+ if (!/^-?\d+$/.test(raw)) return Number.NaN;
120
+
121
+ const n = Number(raw);
122
+ if (!Number.isSafeInteger(n)) return Number.NaN;
123
+ if (n < opts.min || n > opts.max) return Number.NaN;
124
+ return n;
125
+ }
126
+
127
+ export const VALID_AGENT_STATUSES = ["online", "idle", "busy", "stale", "offline"] as const;
128
+
129
+ export const VALID_AGENT_ACTIONS = ["restart", "shutdown", "reconnect", "compact", "clearContext", "resume", "interrupt"] as const;
130
+
131
+ export const VALID_TASK_STATUSES = ["open", "claimed", "in_progress", "blocked", "orphaned", "done", "failed", "canceled"] as const;
132
+
133
+ export const VALID_ARTIFACT_KINDS = ["image", "audio", "video", "document", "archive", "other"] as const;
134
+
135
+ export const VALID_ARTIFACT_ROLES = ["media", "patch", "report", "log", "output", "input"] as const;
136
+
137
+ export function normalizeAgentSessionGuard(req: Request, body: unknown): AgentSessionGuard | undefined {
138
+ const record = isRecord(body) ? body : {};
139
+ const headerInstance = req.headers.get("x-agent-relay-instance-id") ?? undefined;
140
+ const headerEpoch = req.headers.get("x-agent-relay-epoch") ?? undefined;
141
+ const instanceId = cleanString(headerInstance ?? record.instanceId, "instanceId", { max: 200 });
142
+ const rawEpoch = headerEpoch === undefined ? record.epoch : Number(headerEpoch);
143
+ const epoch = cleanEpoch(rawEpoch, "epoch");
144
+
145
+ if (!instanceId && epoch === undefined) return undefined;
146
+ if (!instanceId) throw new ValidationError("instanceId required when epoch is provided");
147
+ return { instanceId, epoch };
148
+ }
149
+
150
+ export function agentSessionStatus(errorMessage: string | undefined): number {
151
+ if (errorMessage === "agent not found") return 404;
152
+ if (errorMessage === "stale agent instance") return 409;
153
+ return 400;
154
+ }
155
+
156
+ export function withPayloadAttachments(
157
+ payload: Record<string, unknown>,
158
+ attachments: Array<Record<string, unknown>> | undefined,
159
+ ): Record<string, unknown> {
160
+ if (!attachments) return payload;
161
+ if (payload.attachments !== undefined) {
162
+ throw new ValidationError("attachments must be provided either top-level or in payload.attachments, not both");
163
+ }
164
+ return { ...payload, attachments };
165
+ }
166
+
167
+ export function validateChannelAttachmentSourceRef(ref: unknown, field: string): void {
168
+ if (!isRecord(ref)) throw new ValidationError(`${field} must be an object`);
169
+ const type = cleanString(ref.type, `${field}.type`, { required: true, max: 40 });
170
+ if (type !== "relay-blob" && type !== "external-url" && type !== "channel-file") {
171
+ throw new ValidationError(`${field}.type must be relay-blob, external-url, or channel-file`);
172
+ }
173
+ if (type === "external-url") cleanString(ref.url, `${field}.url`, { required: true, max: 2048 });
174
+ if (type === "relay-blob" || type === "channel-file") cleanString(ref.id, `${field}.id`, { required: true, max: 400 });
175
+ if (type === "channel-file") {
176
+ cleanString(ref.provider, `${field}.provider`, { max: 80 });
177
+ cleanString(ref.uniqueId, `${field}.uniqueId`, { max: 400 });
178
+ }
179
+ }
180
+
181
+ export function cleanAttachmentRefs(value: unknown, field = "attachments"): AttachmentRef[] | undefined {
182
+ if (value === undefined || value === null) return undefined;
183
+ if (!Array.isArray(value)) throw new ValidationError(`${field} must be an array`);
184
+ return value.map((item, index) => {
185
+ if (!isRecord(item)) throw new ValidationError(`${field}[${index}] must be an object`);
186
+ const ref = item.ref;
187
+ if (ref !== undefined) validateChannelAttachmentSourceRef(ref, `${field}[${index}].ref`);
188
+ const normalizedRef = isRecord(ref) ? { ref: { ...ref } as AttachmentRef["ref"] } : {};
189
+ return {
190
+ artifactId: cleanString(item.artifactId, `${field}[${index}].artifactId`, { required: true, max: 120 })!,
191
+ kind: optionalEnum(item.kind, `${field}[${index}].kind`, VALID_ARTIFACT_KINDS) as ArtifactKind | undefined,
192
+ role: optionalEnum(item.role, `${field}[${index}].role`, VALID_ARTIFACT_ROLES) as "media" | "patch" | "report" | "log" | "output" | "input" | undefined,
193
+ title: cleanString(item.title, `${field}[${index}].title`, { max: 240 }),
194
+ ...normalizedRef,
195
+ ...(isRecord(item.metadata) ? { metadata: item.metadata } : {}),
196
+ };
197
+ });
198
+ }
199
+
200
+ export function metaString(meta: Record<string, unknown> | undefined, key: string): string | undefined {
201
+ const value = meta?.[key];
202
+ return typeof value === "string" && value.trim() ? value : undefined;
203
+ }
204
+
205
+ export async function dispatchTaskCallbacks(taskId: number, eventType: string): Promise<void> {
206
+ const task = getTask(taskId);
207
+ if (!task) return;
208
+ const requestedTarget = typeof task.metadata?.relayRequestedTarget === "string" ? task.metadata.relayRequestedTarget : undefined;
209
+ const integrations = getIntegrationTokens()
210
+ .filter((integration) => integration.name === task.source)
211
+ .filter((integration) => integration.callbackUrl)
212
+ .filter((integration) => !integration.targets?.length || integration.targets.includes(task.target) || Boolean(requestedTarget && integration.targets.includes(requestedTarget)))
213
+ .filter((integration) => !integration.channels?.length || !task.channel || integration.channels.includes(task.channel));
214
+
215
+ for (const integration of integrations) {
216
+ const payload = { event: eventType, task };
217
+ const deliveryId = createCallbackDelivery(task.id, integration.callbackUrl!, eventType, payload);
218
+ void postCallback(deliveryId, integration.callbackUrl!, payload);
219
+ }
220
+ }
221
+
222
+ async function postCallback(deliveryId: number, url: string, payload: unknown): Promise<void> {
223
+ const controller = new AbortController();
224
+ const timeout = setTimeout(() => controller.abort(), 3000);
225
+ try {
226
+ const response = await fetch(url, {
227
+ method: "POST",
228
+ headers: { "Content-Type": "application/json" },
229
+ body: JSON.stringify(payload),
230
+ signal: controller.signal,
231
+ });
232
+ finishCallbackDelivery(deliveryId, response.ok, response.ok ? undefined : `${response.status} ${response.statusText}`);
233
+ } catch (e) {
234
+ finishCallbackDelivery(deliveryId, false, errMessage(e));
235
+ } finally {
236
+ clearTimeout(timeout);
237
+ }
238
+ }
239
+
240
+ export function auditEvent(input: ActivityEventInput): void {
241
+ try {
242
+ const event = createActivityEvent({
243
+ ...input,
244
+ metadata: { source: "server", ...(input.metadata ?? {}) },
245
+ });
246
+ emitActivityEvent(event);
247
+ } catch {
248
+ // Audit trail writes must never block relay behavior.
249
+ }
250
+ }
251
+
252
+ export function memoryContext(req: Request): MemoryBrokerContext {
253
+ const component = getComponentAuth(req);
254
+ if (component) {
255
+ return { now: Date.now(), actor: component.sub, scopes: component.scope, relayUrl: new URL(req.url).origin };
256
+ }
257
+ const integration = getIntegrationAuth(req);
258
+ if (integration) {
259
+ return { now: Date.now(), actor: integration.name, scopes: integration.scopes, relayUrl: new URL(req.url).origin };
260
+ }
261
+ return { now: Date.now(), actor: "server", scopes: ["*"], relayUrl: new URL(req.url).origin };
262
+ }
263
+
264
+ export function memoryErrorResponse(e: unknown): Response {
265
+ if (e instanceof ValidationError || e instanceof MemoryBrokerContractError) return error(e.message, 400);
266
+ throw e;
267
+ }
268
+
269
+ export type AgentControlAction = (typeof VALID_AGENT_ACTIONS)[number];
270
+
271
+ export function agentControlActionIcon(action: AgentControlAction): string {
272
+ if (action === "shutdown") return "ti-power";
273
+ if (action === "compact") return "ti-compress";
274
+ if (action === "clearContext") return "ti-eraser";
275
+ if (action === "resume") return "ti-player-play";
276
+ if (action === "interrupt") return "ti-player-stop";
277
+ return "ti-refresh";
278
+ }
279
+
280
+ export function cleanJsonArray(value: unknown, field: string): unknown[] | undefined {
281
+ if (value === undefined || value === null) return undefined;
282
+ if (!Array.isArray(value)) throw new ValidationError(`${field} must be an array`);
283
+ if (JSON.stringify(value).length > 65_536) throw new ValidationError(`${field} is too large`);
284
+ return value;
285
+ }
286
+
287
+ export function cleanSafeNumber(value: unknown): number | undefined {
288
+ return typeof value === "number" && Number.isFinite(value) ? value : undefined;
289
+ }
290
+
291
+ function commandEventType(command: Command): string {
292
+ if (command.status === "pending") return "command.requested";
293
+ return `command.${command.status}`;
294
+ }
295
+
296
+ export function emitCommand(command: Command): void {
297
+ emitRelayEvent({
298
+ type: commandEventType(command),
299
+ source: command.source,
300
+ subject: command.id,
301
+ data: { command },
302
+ });
303
+ }
304
+
305
+ export function normalizeConfigPathParam(raw: string | undefined, field: string): string {
306
+ return cleanString(raw, field, { required: true, max: 240 })!;
307
+ }
308
+
309
+ export function spawnRequestId(): string {
310
+ return generateSpawnRequestId();
311
+ }
312
+
313
+ export function reactionEmoji(value: unknown, field = "emoji"): string {
314
+ const emoji = cleanString(value, field, { required: true, max: 64 })!;
315
+ const normalized = normalizeReactionEmoji(emoji);
316
+ if (!normalized.trim()) throw new ValidationError(`${field} required`);
317
+ return normalized;
318
+ }
319
+
320
+ function reactionActorView(actorId: string): Record<string, unknown> {
321
+ const agent = getAgent(actorId);
322
+ return {
323
+ id: actorId,
324
+ kind: actorId === "user" ? "human" : agent?.kind === "channel" ? "channel" : "agent",
325
+ displayName: agent?.label ?? agent?.name ?? actorId,
326
+ };
327
+ }
328
+
329
+ function reactionNotificationSender(parent: Message, actorId: string): string {
330
+ if (getAgent(actorId)) return actorId;
331
+ return parent.channel && getAgent(parent.channel) ? parent.channel : "user";
332
+ }
333
+
334
+ export function sendReactionNotificationToAuthor(parent: Message, actorId: string, emoji: string, action: "add" | "remove"): void {
335
+ if (parent.from === "user" || parent.from === "system") return;
336
+ const targetAgent = getAgent(parent.from);
337
+ if (!targetAgent || targetAgent.kind === "channel") return;
338
+ const from = reactionNotificationSender(parent, actorId);
339
+ if (from === parent.from) return;
340
+ const now = Date.now();
341
+ const actor = reactionActorView(actorId);
342
+ const verb = action === "remove" ? "removed reaction" : "reacted";
343
+ const result = sendMessageWithResult({
344
+ from,
345
+ to: parent.from,
346
+ kind: "system",
347
+ body: `${String(actor.displayName ?? actorId)} ${verb} ${emoji} to message #${parent.id}.`,
348
+ replyTo: parent.id,
349
+ idempotencyKey: `reaction-notify:${parent.id}:${actorId}:${emoji}:${action}:${now}`,
350
+ payload: {
351
+ schema: "agent-relay.reaction.v1",
352
+ reactionNotification: true,
353
+ event: {
354
+ id: `relay:${parent.id}:reaction-notify:${actorId}:${emoji}:${action}:${now}`,
355
+ type: "message.reaction",
356
+ ts: new Date(now).toISOString(),
357
+ },
358
+ reaction: {
359
+ emoji,
360
+ action,
361
+ actor,
362
+ target: {
363
+ relayMessageId: parent.id,
364
+ from: parent.from,
365
+ to: parent.to,
366
+ kind: parent.kind,
367
+ bodyPreview: parent.body.length > 240 ? `${parent.body.slice(0, 240)}\n[truncated]` : parent.body,
368
+ },
369
+ },
370
+ },
371
+ });
372
+ if (result.created) {
373
+ if (result.message.deliveryStatus === "queued") emitMessageQueued(result.message);
374
+ else emitNewMessage(result.message);
375
+ }
376
+ }
@@ -0,0 +1,61 @@
1
+ // Auto-split from routes.ts (#299). Domain: activity.
2
+ import { ValidationError, createActivityEvent, listActivityEvents } from "../db";
3
+ import { cleanMeta, cleanOperatorId, cleanPositiveId, cleanString, optionalEnum } from "../validation";
4
+ import { emitActivityEvent } from "../sse";
5
+ import { error, json, parseBody, parseQueryInt, type Handler } from "./_shared";
6
+ import { isRecord } from "agent-relay-sdk";
7
+ import { type ActivityEventInput, type ActivityKind } from "../types";
8
+
9
+ const VALID_ACTIVITY_KINDS = ["message", "reply", "question", "operator", "pair", "task", "state"] as const;
10
+
11
+ function normalizeActivityInput(body: unknown): ActivityEventInput {
12
+ if (!isRecord(body)) throw new ValidationError("JSON object body required");
13
+ return {
14
+ operatorId: cleanOperatorId(body.operatorId),
15
+ clientId: cleanString(body.clientId, "clientId", { max: 240 }),
16
+ kind: optionalEnum(body.kind, "kind", VALID_ACTIVITY_KINDS, "operator") as ActivityKind,
17
+ title: cleanString(body.title, "title", { required: true, max: 200 })!,
18
+ body: cleanString(body.body, "body", { max: 1000 }),
19
+ meta: cleanString(body.meta, "meta", { max: 500 }),
20
+ icon: cleanString(body.icon, "icon", { max: 80 }),
21
+ view: cleanString(body.view, "view", { max: 80 }),
22
+ peer: cleanString(body.peer, "peer", { max: 200 }),
23
+ messageId: cleanPositiveId(body.messageId, "messageId"),
24
+ pairId: cleanString(body.pairId, "pairId", { max: 120 }),
25
+ taskId: cleanPositiveId(body.taskId, "taskId"),
26
+ agentId: cleanString(body.agentId, "agentId", { max: 200 }),
27
+ metadata: cleanMeta(body.metadata),
28
+ };
29
+ }
30
+
31
+ export const getActivityEvents: Handler = (req) => {
32
+ const url = new URL(req.url);
33
+ try {
34
+ const limitRaw = parseQueryInt(url.searchParams.get("limit"), { min: 1, max: 500 });
35
+ if (Number.isNaN(limitRaw)) return error("limit must be an integer between 1 and 500");
36
+ const sinceRaw = parseQueryInt(url.searchParams.get("since"), { min: 0, max: Number.MAX_SAFE_INTEGER });
37
+ if (Number.isNaN(sinceRaw)) return error("since must be a non-negative integer");
38
+ return json(listActivityEvents({
39
+ operatorId: cleanString(url.searchParams.get("operatorId") ?? undefined, "operatorId", { max: 200 }),
40
+ agentId: cleanString(url.searchParams.get("agentId") ?? undefined, "agentId", { max: 200 }),
41
+ limit: limitRaw ?? 200,
42
+ since: sinceRaw ?? undefined,
43
+ }));
44
+ } catch (e) {
45
+ if (e instanceof ValidationError) return error(e.message, 400);
46
+ throw e;
47
+ }
48
+ };
49
+
50
+ export const postActivityEvent: Handler = async (req) => {
51
+ const parsed = await parseBody<unknown>(req);
52
+ if (!parsed.ok) return error(parsed.error, parsed.status);
53
+ try {
54
+ const event = createActivityEvent(normalizeActivityInput(parsed.body));
55
+ emitActivityEvent(event);
56
+ return json(event, 201);
57
+ } catch (e) {
58
+ if (e instanceof ValidationError) return error(e.message, 400);
59
+ throw e;
60
+ }
61
+ };