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.
- package/package.json +2 -2
- package/public/assets/display-JI19Vc7L.js.map +1 -1
- package/src/branch-landed.ts +38 -2
- package/src/cli.ts +3 -3
- package/src/maintenance.ts +21 -21
- package/src/mcp.ts +2 -2
- package/src/ratchet-files.ts +37 -0
- package/src/routes/_shared.ts +376 -0
- package/src/routes/activity.ts +61 -0
- package/src/routes/agent-profiles.ts +47 -0
- package/src/routes/agent-sessions.ts +488 -0
- package/src/routes/agents-spawn.ts +274 -0
- package/src/routes/agents.ts +251 -0
- package/src/routes/artifacts.ts +226 -0
- package/src/routes/automations.ts +83 -0
- package/src/routes/commands.ts +317 -0
- package/src/routes/config.ts +66 -0
- package/src/routes/connectors.ts +108 -0
- package/src/routes/inbox.ts +142 -0
- package/src/routes/index.ts +293 -0
- package/src/routes/insights.ts +81 -0
- package/src/routes/integrations.ts +592 -0
- package/src/routes/memory.ts +337 -0
- package/src/routes/messages.ts +529 -0
- package/src/routes/orchestrator-bootstrap.ts +100 -0
- package/src/routes/orchestrator-proxy.ts +160 -0
- package/src/routes/orchestrator.ts +490 -0
- package/src/routes/pairs.ts +197 -0
- package/src/routes/provider-config.ts +112 -0
- package/src/routes/recipes.ts +113 -0
- package/src/routes/spawn-policy.ts +231 -0
- package/src/routes/spec.ts +54 -0
- package/src/routes/sse.ts +9 -0
- package/src/routes/stats.ts +32 -0
- package/src/routes/steward.ts +45 -0
- package/src/routes/tasks.ts +174 -0
- package/src/routes/tokens.ts +311 -0
- package/src/routes/workspaces.ts +364 -0
- package/src/routes.ts +3 -6822
- package/src/validation.ts +134 -0
- package/src/workspace-actions.ts +7 -1
- package/src/workspace-merge.ts +12 -1
package/src/branch-landed.ts
CHANGED
|
@@ -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
|
}
|
package/src/maintenance.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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",
|
|
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
|
+
};
|