agent-relay-server 0.36.1 → 0.37.0

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 (65) hide show
  1. package/docs/openapi.json +1 -1
  2. package/package.json +1 -1
  3. package/public/assets/{activity-C3mkM6AU.js → activity-BgkmA1lh.js} +2 -2
  4. package/public/assets/{activity-C3mkM6AU.js.map → activity-BgkmA1lh.js.map} +1 -1
  5. package/public/assets/{agents-CAhQO7JH.js → agents-B7HnuAXx.js} +2 -2
  6. package/public/assets/{agents-CAhQO7JH.js.map → agents-B7HnuAXx.js.map} +1 -1
  7. package/public/assets/{analytics-BwihhhNn.js → analytics-0-akxJCJ.js} +2 -2
  8. package/public/assets/{analytics-BwihhhNn.js.map → analytics-0-akxJCJ.js.map} +1 -1
  9. package/public/assets/{automation-BLXToUiU.js → automation-CaE1z_-M.js} +2 -2
  10. package/public/assets/{automation-BLXToUiU.js.map → automation-CaE1z_-M.js.map} +1 -1
  11. package/public/assets/{chat-8iIPyww9.js → chat-BANKUW05.js} +2 -2
  12. package/public/assets/{chat-8iIPyww9.js.map → chat-BANKUW05.js.map} +1 -1
  13. package/public/assets/{formatted-body-impl-BHH0wzY7.js → formatted-body-impl-DExNPNsL.js} +2 -2
  14. package/public/assets/{formatted-body-impl-BHH0wzY7.js.map → formatted-body-impl-DExNPNsL.js.map} +1 -1
  15. package/public/assets/{index-CaauKXl9.js → index-DEZdON6c.js} +5 -5
  16. package/public/assets/{index-CaauKXl9.js.map → index-DEZdON6c.js.map} +1 -1
  17. package/public/assets/{maintenance-9n_rJCHT.js → maintenance-DpTdJxQp.js} +2 -2
  18. package/public/assets/{maintenance-9n_rJCHT.js.map → maintenance-DpTdJxQp.js.map} +1 -1
  19. package/public/assets/{managed-agents-Rp2-xpBx.js → managed-agents-B3df2xfk.js} +2 -2
  20. package/public/assets/{managed-agents-Rp2-xpBx.js.map → managed-agents-B3df2xfk.js.map} +1 -1
  21. package/public/assets/{markdown-preview-impl-YfJsGh6I.js → markdown-preview-impl-D0Zj7c3T.js} +2 -2
  22. package/public/assets/{markdown-preview-impl-YfJsGh6I.js.map → markdown-preview-impl-D0Zj7c3T.js.map} +1 -1
  23. package/public/assets/{memory-BQONtGQS.js → memory-TATN2vZf.js} +2 -2
  24. package/public/assets/{memory-BQONtGQS.js.map → memory-TATN2vZf.js.map} +1 -1
  25. package/public/assets/{messages-DGqpkH72.js → messages-3rS1lxIf.js} +2 -2
  26. package/public/assets/{messages-DGqpkH72.js.map → messages-3rS1lxIf.js.map} +1 -1
  27. package/public/assets/{orchestrators-b8k9QoGv.js → orchestrators-CRIV0g5y.js} +2 -2
  28. package/public/assets/{orchestrators-b8k9QoGv.js.map → orchestrators-CRIV0g5y.js.map} +1 -1
  29. package/public/assets/{overview-DSU_CggA.js → overview-CmHC_5oM.js} +2 -2
  30. package/public/assets/{overview-DSU_CggA.js.map → overview-CmHC_5oM.js.map} +1 -1
  31. package/public/assets/{pairs-DGocNC1U.js → pairs-DBPAhXTI.js} +2 -2
  32. package/public/assets/{pairs-DGocNC1U.js.map → pairs-DBPAhXTI.js.map} +1 -1
  33. package/public/assets/{security-BSh0QxOl.js → security-572X5MNX.js} +2 -2
  34. package/public/assets/{security-BSh0QxOl.js.map → security-572X5MNX.js.map} +1 -1
  35. package/public/assets/{settings-C03CAJgO.js → settings-vTBu8w3O.js} +2 -2
  36. package/public/assets/{settings-C03CAJgO.js.map → settings-vTBu8w3O.js.map} +1 -1
  37. package/public/assets/{tasks-rKbuUPOk.js → tasks-C0bPrDgN.js} +2 -2
  38. package/public/assets/{tasks-rKbuUPOk.js.map → tasks-C0bPrDgN.js.map} +1 -1
  39. package/public/assets/{terminal-viewer-impl-CA8u4jh3.js → terminal-viewer-impl-rVPA6Fsx.js} +2 -2
  40. package/public/assets/{terminal-viewer-impl-CA8u4jh3.js.map → terminal-viewer-impl-rVPA6Fsx.js.map} +1 -1
  41. package/public/assets/{work-queue-DOsA9s4M.js → work-queue-BxkpTt_A.js} +2 -2
  42. package/public/assets/{work-queue-DOsA9s4M.js.map → work-queue-BxkpTt_A.js.map} +1 -1
  43. package/public/index.html +1 -1
  44. package/runner/src/adapter.ts +7 -0
  45. package/scripts/orchestrator-spawn-smoke.ts +65 -33
  46. package/src/agent-ref.ts +28 -1
  47. package/src/bus.ts +51 -35
  48. package/src/compaction-watch.ts +7 -0
  49. package/src/index.ts +23 -6
  50. package/src/lifecycle-manager.ts +33 -71
  51. package/src/mcp.ts +100 -308
  52. package/src/routes/agent-sessions.ts +38 -3
  53. package/src/routes/agents-spawn.ts +43 -175
  54. package/src/routes/commands.ts +7 -19
  55. package/src/routes/messages.ts +24 -87
  56. package/src/security.ts +15 -0
  57. package/src/services/auth-context.ts +109 -0
  58. package/src/services/dispatch-command.ts +60 -0
  59. package/src/services/errors.ts +26 -0
  60. package/src/services/managed-running.ts +130 -0
  61. package/src/services/parity-harness.ts +135 -0
  62. package/src/services/register-agent.ts +74 -0
  63. package/src/services/send-message.ts +159 -0
  64. package/src/services/shutdown-agent.ts +234 -0
  65. package/src/services/spawn-agent.ts +278 -0
@@ -0,0 +1,60 @@
1
+ // Transport convergence (epic #342, slice #346) — the command-dispatch service.
2
+ //
3
+ // ONE home for "a command was dispatched" — previously inlined in HTTP `postCommand`
4
+ // (routes/commands.ts) and bus `handleCommandFrame`'s create branch (bus.ts), each with
5
+ // its OWN copy of the authorization-resource builder (one used `cleanString`, the other
6
+ // `stringValue` — a silent drift waiting to happen) and its own emit call. Transports now
7
+ // shrink to: parse wire → `authContextFrom*` → `dispatchCommand` → serialize. The parity
8
+ // harness asserts HTTP and bus produce a byte-identical command row + emitted event here.
9
+ //
10
+ // NOTE: the MCP command paths (agent.spawn / agent.shutdown) build *typed* commands and are
11
+ // owned by the spawn (#349) / shutdown (#347) slices — they are NOT generic dispatch and stay
12
+ // out of this service.
13
+ import { isRecord, stringValue } from "agent-relay-sdk";
14
+ import { createCommand } from "../commands-db";
15
+ import { emitCommandEvent } from "../command-events";
16
+ import { isComponentAuthorizedFor } from "../security";
17
+ import { ServiceAuthError } from "./errors";
18
+ import type { AuthContext } from "./auth-context";
19
+ import type { Command, CreateCommandInput } from "../types";
20
+
21
+ /** THE single home for the command authorization resource. Was duplicated in
22
+ * routes/commands.ts and bus.ts (diverging on `cleanString` vs `stringValue`) plus their
23
+ * two command-UPDATE paths — all four now import this. `stringValue` (trim + drop-empty)
24
+ * is the convergent form; the values only feed `constraintsAllow` target matching. */
25
+ export function commandAuthorizationResource(input: Pick<CreateCommandInput, "target" | "params">) {
26
+ const params = isRecord(input.params) ? input.params : {};
27
+ return {
28
+ target: input.target,
29
+ agentId: stringValue(params.agentId) ?? input.target,
30
+ policyName: stringValue(params.policyName),
31
+ orchestratorId: stringValue(params.orchestratorId),
32
+ cwd: stringValue(params.cwd),
33
+ spawnRequestId: stringValue(params.spawnRequestId),
34
+ };
35
+ }
36
+
37
+ /** Authorize a command dispatch from the unified auth context. Mirrors BOTH old paths
38
+ * exactly: only a COMPONENT token carries per-resource constraints, so the gate is a no-op
39
+ * for admin/system/integration principals (which already cleared the coarse `command:write`
40
+ * scope gate at the transport edge — HTTP middleware `isScopedRequestAuthorized`, bus
41
+ * `withRegistered`). HTTP `authorizeRoute` and bus `busCommandAuthorized` both reduce to this:
42
+ * "no component token ⇒ allow; else `isComponentAuthorizedFor(command:write, resource)`". */
43
+ function assertCommandAuthorized(ctx: AuthContext, input: CreateCommandInput): void {
44
+ if (!ctx.component) return;
45
+ const ok = isComponentAuthorizedFor(ctx.component, {
46
+ scope: "command:write",
47
+ resource: commandAuthorizationResource(input),
48
+ });
49
+ if (!ok) throw new ServiceAuthError("component token lacks command scope");
50
+ }
51
+
52
+ export function dispatchCommand(input: CreateCommandInput, ctx: AuthContext): Command {
53
+ assertCommandAuthorized(ctx, input);
54
+ const command = createCommand(input);
55
+ // A freshly created command is always `pending`; emit the literal "command.requested"
56
+ // (the relay auto-fans-out the follow-up "command.dispatched"). HTTP used the status-derived
57
+ // `emitCommand`, bus used `emitCommandEvent(_, "command.requested")` — identical for pending.
58
+ emitCommandEvent(command, "command.requested");
59
+ return command;
60
+ }
@@ -0,0 +1,26 @@
1
+ // Transport convergence (epic #342) — service-layer error types.
2
+ //
3
+ // The domain services in `src/services/` are transport-agnostic: they never build a
4
+ // Response / a bus result frame / an MCP error. They THROW these typed errors and each
5
+ // transport adapter maps them to its own wire shape (HTTP status, bus result, MCP error).
6
+ // One throw, three faithful translations — so an authorization failure can never drift
7
+ // into "403 on HTTP but silently succeeds on the bus".
8
+
9
+ /** The caller's token is valid but lacks the scope/constraints for this operation.
10
+ * HTTP → 403, bus → command-result "rejected", MCP → auth error. */
11
+ export class ServiceAuthError extends Error {
12
+ constructor(message: string) {
13
+ super(message);
14
+ this.name = "ServiceAuthError";
15
+ }
16
+ }
17
+
18
+ /** A direct target reference matched more than one agent — the relay can't decide who to send
19
+ * to, so it rejects rather than guessing (categorically different from a not_found, which is
20
+ * stored for later). HTTP → 409, MCP → invalid-params. */
21
+ export class AmbiguousTargetError extends Error {
22
+ constructor(message: string) {
23
+ super(message);
24
+ this.name = "AmbiguousTargetError";
25
+ }
26
+ }
@@ -0,0 +1,130 @@
1
+ // Transport convergence (epic #342) — the "managed agent came up running" core.
2
+ //
3
+ // ONE home for the state-transition + queue-flush that previously had three
4
+ // separately-authored copies (HTTP `postAgent`, bus `onAgentRegistered`,
5
+ // orchestrator-report `onOrchestratorManagedAgentsReported`). Those copies had
6
+ // already drifted — backoffUntil cleared in 2 of 3, `message.new` emitted in 1 of 3,
7
+ // two different managed-state emitters, `healthySince` reset-vs-preserve split.
8
+ // This function is the single source of truth; the parity harness
9
+ // (register-agent.parity.test.ts) asserts every transport produces identical
10
+ // state + events through it.
11
+ //
12
+ // Composed by `registerAgent` (HTTP + bus registration) and called directly by the
13
+ // orchestrator-report projection in lifecycle-manager. It does NOT upsert the agent
14
+ // row — the agent registers itself over HTTP/bus; this only reconciles managed state.
15
+ import { createActivityEvent, resolveQueuedPolicyMessages } from "../db";
16
+ import { getManagedAgentState, updateManagedAgentState } from "../config-store";
17
+ import { emitRelayEvent } from "../events";
18
+ import { emitMessageAvailable, emitMessageDeliveryUpdated, emitNewMessage } from "../sse";
19
+ import type { AuthContext } from "./auth-context";
20
+ import type { ManagedAgentState } from "../types";
21
+
22
+ export interface ManagedRunningInput {
23
+ policyName?: string;
24
+ spawnRequestId?: string;
25
+ agentId: string;
26
+ tmuxSession?: string | null;
27
+ /** Only the orchestrator-report path carries workspace identity. */
28
+ workspace?: { id?: string; worktreePath?: string; branch?: string };
29
+ }
30
+
31
+ /**
32
+ * Reconcile a managed agent to "running" and flush its queued policy messages.
33
+ * No-op for non-managed agents (no policy/spawnRequestId) and for a report that
34
+ * doesn't match the policy's live spawn — plain agents register through here too
35
+ * and must never touch managed state.
36
+ *
37
+ * Unified semantics (the drift reconciliation, per #345 strict-event-identity):
38
+ * - `backoffUntil` is ALWAYS cleared on the transition to running.
39
+ * - `healthySince` is set fresh when ENTERING running (from starting/backoff) and
40
+ * PRESERVED on a re-register while already running — so a heartbeat re-register
41
+ * doesn't reset the backoff-reset health clock, but a recovery does.
42
+ * - the queue flush ALWAYS emits `message.available` + `message.new` + `message.delivery_updated`.
43
+ * - the managed-state change emits via `emitManagedState` (source "lifecycle-manager"
44
+ * + an activity-feed transition row), never the source-"server" `emitManagedAgentStateChanged`.
45
+ */
46
+ export function markManagedAgentRunning(
47
+ input: ManagedRunningInput,
48
+ _ctx: AuthContext,
49
+ opts: { now?: () => number } = {},
50
+ ): void {
51
+ if (!input.policyName || !input.spawnRequestId) return;
52
+ const state = getManagedAgentState(input.policyName);
53
+ if (!state || state.spawnRequestId !== input.spawnRequestId) return;
54
+ const now = opts.now ?? (() => Date.now());
55
+
56
+ const fromState = state.status;
57
+ const next = updateManagedAgentState(input.policyName, {
58
+ status: "running",
59
+ agentId: input.agentId || state.agentId,
60
+ tmuxSession: input.tmuxSession ?? state.tmuxSession,
61
+ ...(input.workspace?.id !== undefined ? { workspaceId: input.workspace.id } : {}),
62
+ ...(input.workspace?.worktreePath !== undefined ? { workspacePath: input.workspace.worktreePath } : {}),
63
+ ...(input.workspace?.branch !== undefined ? { workspaceBranch: input.workspace.branch } : {}),
64
+ healthySince: fromState === "running" ? (state.healthySince ?? now()) : now(),
65
+ backoffUntil: undefined,
66
+ lastError: undefined,
67
+ });
68
+ if (next) emitManagedState(next, fromState);
69
+
70
+ const available = resolveQueuedPolicyMessages(input.policyName, input.agentId || state.agentId || "");
71
+ if (available.length) {
72
+ emitMessageAvailable(input.policyName, input.agentId || state.agentId || "", available);
73
+ for (const message of available) {
74
+ emitNewMessage(message);
75
+ // queued → pending flips delivery_status; the dashboard dedups message.new by
76
+ // id, so without an explicit delivery_updated the badge stays stale until the
77
+ // next poll (#265).
78
+ emitMessageDeliveryUpdated(message);
79
+ }
80
+ }
81
+ }
82
+
83
+ // --- Managed-state emitter (single home, shared with lifecycle-manager) -------
84
+ // Extracted from LifecycleManager.emitState so registration and the lifecycle
85
+ // reconciler emit the managed-state change identically: `policy.state.changed`
86
+ // from source "lifecycle-manager" PLUS an activity-feed transition row on a real
87
+ // from→to status change. Replaces the HTTP path's source-"server" emitter for
88
+ // the came-up-running op.
89
+ let transitionSeq = 0;
90
+
91
+ export function emitManagedState(
92
+ state: ManagedAgentState,
93
+ fromState: string | undefined,
94
+ reason?: string,
95
+ now: () => number = () => Date.now(),
96
+ ): void {
97
+ emitRelayEvent({
98
+ type: "policy.state.changed",
99
+ source: "lifecycle-manager",
100
+ subject: state.policyName,
101
+ data: state as unknown as Record<string, unknown>,
102
+ });
103
+ if (fromState !== state.status) recordManagedTransition(state, fromState, reason, now);
104
+ }
105
+
106
+ function recordManagedTransition(
107
+ state: ManagedAgentState,
108
+ fromState: string | undefined,
109
+ reason: string | undefined,
110
+ now: () => number,
111
+ ): void {
112
+ const why = reason ?? state.lastError ?? undefined;
113
+ createActivityEvent({
114
+ clientId: `lifecycle-${state.policyName}-${state.status}-${now()}-${++transitionSeq}`,
115
+ kind: "state",
116
+ title: `${state.policyName}: ${fromState ?? "—"} → ${state.status}`,
117
+ body: why,
118
+ icon: "ti-arrows-exchange",
119
+ view: "managed-agents",
120
+ agentId: state.agentId,
121
+ metadata: {
122
+ source: "lifecycle-manager",
123
+ policyName: state.policyName,
124
+ spawnRequestId: state.spawnRequestId,
125
+ fromState: fromState ?? null,
126
+ toState: state.status,
127
+ reason: why ?? null,
128
+ },
129
+ });
130
+ }
@@ -0,0 +1,135 @@
1
+ // Transport-convergence parity harness (epic #342, slice #350).
2
+ //
3
+ // A transport-agnostic engine for proving that ONE logical operation produces
4
+ // IDENTICAL resulting DB state + emitted events through EVERY transport it can be
5
+ // driven by (HTTP routes, WS bus, orchestrator-report projection, MCP …). Written
6
+ // against the code as it stands, it goes RED on real drift; after the operation is
7
+ // collapsed onto a single service it goes GREEN — and stays a ratchet against any
8
+ // future re-divergence (sibling to duplication-ratchet.test.ts / file-size-ratchet.test.ts).
9
+ //
10
+ // Adding a new operation (shutdownAgent, spawnAgent, send-message) is dropping in a
11
+ // new `ParityOp` descriptor — reset/seed/snapshot + a map of transports — NOT writing
12
+ // a new framework. The engine here never changes.
13
+ import { subscribeRelayEvents } from "../events";
14
+
15
+ /** An emitted relay event, normalized to the fields parity compares — volatile
16
+ * `seq`/`timestamp` are deliberately dropped so only behavior, not ordering noise,
17
+ * is asserted. */
18
+ export interface CapturedEvent {
19
+ type: string;
20
+ source: string;
21
+ subject?: string;
22
+ data: Record<string, unknown>;
23
+ }
24
+
25
+ /** Everything one transport run produced: the events it emitted, a snapshot of the
26
+ * resulting DB state (op-defined), and any side-effect probe values (state that an
27
+ * op mutates without emitting an event, e.g. a cleared watch). */
28
+ export interface Capture<S> {
29
+ events: CapturedEvent[];
30
+ state: S;
31
+ probes: Record<string, unknown>;
32
+ }
33
+
34
+ /** One way to drive the operation. `invoke` performs the whole transport round-trip
35
+ * (incl. starting/stopping any server it needs) and resolves once the operation has
36
+ * settled so the harness can snapshot. Keep the abstraction swappable: a later slice
37
+ * can replace a direct-call transport with an end-to-end HTTP one without reshaping
38
+ * the harness. */
39
+ export interface ParityTransport<C> {
40
+ id: string;
41
+ invoke(ctx: C): Promise<void>;
42
+ }
43
+
44
+ /** The contract every shared operation implements. `C` is the per-run scenario
45
+ * (knobs like policy name / re-register flags); `S` is the comparable DB snapshot. */
46
+ export interface ParityOp<C, S> {
47
+ name: string;
48
+ /** Fresh DB + reset any in-memory singletons so runs don't leak into each other. */
49
+ reset(): void | Promise<void>;
50
+ /** Common preconditions shared by all transports (orchestrator, managed state, …). */
51
+ seed(ctx: C): void | Promise<void>;
52
+ /** The resulting DB state subset parity compares. */
53
+ snapshot(ctx: C): S;
54
+ /** Optional side-effect probes (no emitted event) — e.g. "was the watch cleared". */
55
+ probes?(ctx: C): Record<string, unknown>;
56
+ transports: Record<string, ParityTransport<C>>;
57
+ }
58
+
59
+ /** Drive one transport in isolation and capture everything it produced. */
60
+ export async function captureTransport<C, S>(
61
+ op: ParityOp<C, S>,
62
+ transportId: string,
63
+ ctx: C,
64
+ ): Promise<Capture<S>> {
65
+ const transport = op.transports[transportId];
66
+ if (!transport) throw new Error(`parity op "${op.name}" has no transport "${transportId}"`);
67
+ await op.reset();
68
+ await op.seed(ctx);
69
+ const events: CapturedEvent[] = [];
70
+ const off = subscribeRelayEvents((e) =>
71
+ events.push({ type: e.type, source: e.source, subject: e.subject, data: e.data }),
72
+ );
73
+ try {
74
+ await transport.invoke(ctx);
75
+ } finally {
76
+ off();
77
+ }
78
+ return { events, state: op.snapshot(ctx), probes: op.probes?.(ctx) ?? {} };
79
+ }
80
+
81
+ /** Drive every named transport (each in its own fresh, seeded world) and return the
82
+ * captures keyed by transport id. Runs sequentially — each transport owns the DB. */
83
+ export async function captureAll<C, S>(
84
+ op: ParityOp<C, S>,
85
+ transportIds: string[],
86
+ ctx: C,
87
+ ): Promise<Map<string, Capture<S>>> {
88
+ const out = new Map<string, Capture<S>>();
89
+ for (const id of transportIds) out.set(id, await captureTransport(op, id, ctx));
90
+ return out;
91
+ }
92
+
93
+ // --- comparison helpers (used by the per-op parity tests) --------------------
94
+
95
+ /** Multiset of emitted events keyed `type|source`, optionally filtered (e.g. to one
96
+ * facet's event family). The key includes `source` so the "two emitters of one
97
+ * broadcast" drift (sse `server` vs lifecycle `lifecycle-manager`) surfaces. */
98
+ export function eventKeyCounts(
99
+ events: CapturedEvent[],
100
+ filter?: (e: CapturedEvent) => boolean,
101
+ ): Record<string, number> {
102
+ const counts: Record<string, number> = {};
103
+ for (const e of events) {
104
+ if (filter && !filter(e)) continue;
105
+ const key = `${e.type}|${e.source}`;
106
+ counts[key] = (counts[key] ?? 0) + 1;
107
+ }
108
+ return counts;
109
+ }
110
+
111
+ /** True if any captured event matches type (and optional predicate on its data). */
112
+ export function hasEvent(
113
+ cap: Capture<unknown>,
114
+ type: string,
115
+ where?: (e: CapturedEvent) => boolean,
116
+ ): boolean {
117
+ return cap.events.some((e) => e.type === type && (!where || where(e)));
118
+ }
119
+
120
+ /** Project each capture through `project` and return the distinct JSON shapes seen,
121
+ * with the transport ids that produced each. One entry ⇒ parity; more ⇒ drift, and
122
+ * the map shows exactly which transports diverged and how. */
123
+ export function projectionGroups<S>(
124
+ captures: Map<string, Capture<S>>,
125
+ project: (cap: Capture<S>) => unknown,
126
+ ): Map<string, string[]> {
127
+ const groups = new Map<string, string[]>();
128
+ for (const [id, cap] of captures) {
129
+ const key = JSON.stringify(project(cap));
130
+ const ids = groups.get(key) ?? [];
131
+ ids.push(id);
132
+ groups.set(key, ids);
133
+ }
134
+ return groups;
135
+ }
@@ -0,0 +1,74 @@
1
+ // Transport convergence (epic #342, slice #345) — the agent-registration service.
2
+ //
3
+ // ONE home for "an agent registered" — previously inlined in HTTP `postAgent` and
4
+ // split across bus `handleRegister` + `lifecycle.onAgentRegistered`. Transports now
5
+ // shrink to: parse wire → `authContextFrom*` → `registerAgent` → serialize. The
6
+ // parity harness asserts HTTP and bus produce byte-identical state + events here.
7
+ //
8
+ // Owns ALL policy + side effects of registration: authoritative lineage, the
9
+ // managed came-up-running reconcile (delegated to managed-running.ts), the single
10
+ // status broadcast, the single timeline note, the spawned-child parent-wake, and
11
+ // the first-registration audit row.
12
+ import { createActivityEvent, getAgent, upsertAgent } from "../db";
13
+ import { emitAgentStatus } from "../sse";
14
+ import { noteAgentTimelineEvent } from "../compaction-watch";
15
+ import { notifyAgentReady } from "../agent-lifecycle-events";
16
+ import { resolveSpawnLineage } from "../security";
17
+ import { markManagedAgentRunning } from "./managed-running";
18
+ import type { AuthContext } from "./auth-context";
19
+ import type { AgentCard, RegisterAgentInput } from "../types";
20
+
21
+ function metaStr(meta: Record<string, unknown> | undefined, key: string): string | undefined {
22
+ const value = meta?.[key];
23
+ return typeof value === "string" && value.length > 0 ? value : undefined;
24
+ }
25
+
26
+ export function registerAgent(input: RegisterAgentInput, ctx: AuthContext): AgentCard {
27
+ // Lineage is authoritative from the registering token's signed constraints — never
28
+ // the client-sent body (a child can't forge its own parent). Set by relay at spawn.
29
+ const lineage = resolveSpawnLineage(ctx.constraints);
30
+ if (lineage) input.spawnedBy = lineage;
31
+
32
+ const existing = getAgent(input.id);
33
+ const agent = upsertAgent(input);
34
+
35
+ // Managed came-up-running reconcile — no-op for non-managed agents (the guard lives
36
+ // in markManagedAgentRunning). Clears backoff, flushes the policy queue, transitions
37
+ // managed state to running.
38
+ markManagedAgentRunning(
39
+ {
40
+ policyName: metaStr(agent.meta, "policyName"),
41
+ spawnRequestId: metaStr(agent.meta, "spawnRequestId"),
42
+ agentId: agent.id,
43
+ tmuxSession: metaStr(agent.meta, "tmuxSession"),
44
+ },
45
+ ctx,
46
+ );
47
+
48
+ // ONE status broadcast (branch-state-enriched, #236), ONE timeline note. A real
49
+ // PreCompact/SessionStart hook arrives as a latched meta.timelineEvent — clears any
50
+ // pending stall watch; the timestamp guard inside ignores stale latched events.
51
+ emitAgentStatus(agent.id);
52
+ noteAgentTimelineEvent(agent.id, agent.meta?.timelineEvent);
53
+
54
+ // #308/#351 — a spawned child registering already-ready (the common isolated-worktree
55
+ // case) wakes its block-polling parent. Fire on the first-ever ready, regardless of
56
+ // transport. notifyAgentReady no-ops for non-spawned agents and dedups repeats.
57
+ if (agent.ready && !existing?.ready) notifyAgentReady(agent.id);
58
+
59
+ if (!existing) {
60
+ createActivityEvent({
61
+ clientId: `server-agent-${agent.id}-registered`,
62
+ kind: "state",
63
+ title: "Agent registered",
64
+ body: agent.name,
65
+ meta: agent.id,
66
+ icon: "ti-robot",
67
+ view: "agents",
68
+ agentId: agent.id,
69
+ metadata: { actor: ctx.actor.id, actorKind: ctx.actor.kind },
70
+ });
71
+ }
72
+
73
+ return agent;
74
+ }
@@ -0,0 +1,159 @@
1
+ // Transport convergence (epic #342, slice #346) — the message-send service.
2
+ //
3
+ // ONE home for "an agent sent a message" — previously inlined in HTTP `postMessage`
4
+ // (routes/messages.ts) and split across MCP `relaySendMessage` + `relayReply` (mcp.ts),
5
+ // which had drifted on: caller-identity (HTTP trusted the wire `from`; MCP overrode it with
6
+ // the token identity), target resolution (HTTP STORED an unknown target / 422-rejected an
7
+ // offline one; MCP THREW on unknown / stored the offline), the authorization TARGET (HTTP
8
+ // authorized the RAW ref, MCP the RESOLVED canonical id — so a bare ref a constraint allowed
9
+ // could pass one transport and 403 the other), and side effects (HTTP emitted + woke an
10
+ // on-demand policy; MCP did neither). Transports now shrink to: parse wire → build a
11
+ // SendMessageInput → `authContextFrom*` → `sendMessageService` → serialize.
12
+ //
13
+ // CONVERGED RULES (the parity test is the ratchet):
14
+ // from — `ctx.callerAgentId` (the resolved agent behind the token) WINS over any wire
15
+ // `from`; the wire value is a fallback only for identity-less tokens.
16
+ // resolution — resolve FIRST, then authorize the canonical id. Ambiguous → reject. Unknown /
17
+ // offline → STORE (store-ahead) with a truthful DeliveryReceipt (delivered:false);
18
+ // the receipt, not a transport-specific rejection, is the single source of
19
+ // delivery truth. [Decision flagged to coordinator; see #346.]
20
+ // side effects— emit (queued vs new) + on-demand policy wake, on EVERY transport.
21
+ //
22
+ // OUT OF SCOPE (transport-edge, excluded from the parity facet): automatic memory injection
23
+ // (needs request-derived context) and per-transport audit (HTTP domain row vs MCP tool-call row).
24
+ import { isMechanicalMessageKind, isRecord, isReservedAgentId } from "agent-relay-sdk";
25
+ import { ValidationError, getMessage, listAgents, sendMessageWithResult } from "../db";
26
+ import { planSend, type DeliveryReceipt } from "../agent-ref";
27
+ import { emitMessageQueued, emitNewMessage } from "../sse";
28
+ import { getLifecycleManager } from "../lifecycle-manager";
29
+ import { isComponentAuthorizedFor, isIntegrationAllowed } from "../security";
30
+ import { AmbiguousTargetError, ServiceAuthError } from "./errors";
31
+ import type { AuthContext } from "./auth-context";
32
+ import type { IntegrationTokenConfig } from "../config";
33
+ import type { Message, SendMessageInput } from "../types";
34
+
35
+ export interface SendMessageResult {
36
+ message: Message;
37
+ created: boolean;
38
+ /** Synchronous delivery receipt (live recipients / queued / store-ahead). */
39
+ receipt: DeliveryReceipt;
40
+ }
41
+
42
+ export interface SendMessageOptions {
43
+ /** The integration token when the MCP caller authenticated with one. AuthContext stays lean
44
+ * (foundation contract) — the integration-specific target policy rides here, not on the ctx. */
45
+ integration?: IntegrationTokenConfig | null;
46
+ }
47
+
48
+ /** `from` resolves from the token identity: `callerAgentId` (the resolved agent behind the
49
+ * token) cannot be spoofed and WINS over any wire `from`. The wire value is a fallback only
50
+ * for identity-less tokens (admin/server/integration/multi-agent).
51
+ *
52
+ * EXCEPTION — the reserved-sink observability lane (#284): a mechanical post to user/system is
53
+ * the runner reporting an agent's turn ON ITS BEHALF, so the wire `from` (the reported agent) is
54
+ * authoritative there and the token's own identity must NOT override it. This is the relay's own
55
+ * lane (not agent-directed), the agentId authz predicate is dropped for it too, and it preserves
56
+ * the pre-convergence behavior — a steward/runner token managing other agents can still mirror. */
57
+ function resolveFrom(input: SendMessageInput, ctx: AuthContext, reservedSink: boolean): string {
58
+ const from = reservedSink ? (input.from || ctx.callerAgentId) : (ctx.callerAgentId ?? input.from);
59
+ if (!from) throw new ValidationError("from is required");
60
+ return from;
61
+ }
62
+
63
+ /** Reply routing: when `to` is omitted and `replyTo` is set, auto-route to the parent's sender,
64
+ * inherit its channel, and propagate channel replyContext. ONE home — was HTTP's applyReplyRouting
65
+ * and MCP's replyContext, behaviorally identical. */
66
+ function applyReplyRouting(input: SendMessageInput): void {
67
+ if (input.to || !input.replyTo) return;
68
+ const parent = getMessage(input.replyTo);
69
+ if (!parent) return;
70
+ input.to = parent.from;
71
+ if (!input.channel && parent.channel) input.channel = parent.channel;
72
+ const parentPayload = parent.payload ?? {};
73
+ if (parentPayload.schema === "agent-relay.channel.v1" || parentPayload.conversation) {
74
+ const replyContext: Record<string, unknown> = {};
75
+ if (parent.channel) replyContext.channelId = parent.channel;
76
+ if (isRecord(parentPayload.conversation)) replyContext.conversationId = parentPayload.conversation.id;
77
+ if (isRecord(parentPayload.event)) replyContext.parentEventId = parentPayload.event.id;
78
+ if (parentPayload.source) replyContext.source = parentPayload.source;
79
+ input.payload = { ...input.payload, replyContext };
80
+ }
81
+ }
82
+
83
+ /** Resolve the target to its canonical id and produce a synchronous receipt. MUTATES `input.to`
84
+ * to the rewritten canonical id (so poll-time exact matching works). Ambiguous → reject; unknown
85
+ * → store-ahead (verbatim `to`, receipt says not-yet-reachable); offline/direct/fanout → adopt the
86
+ * receipt (which already reports offline as delivered:false — no separate offline rejection). */
87
+ function resolveSendTarget(input: SendMessageInput, from: string): DeliveryReceipt {
88
+ // Mechanical lifecycle/observability kinds (system/control/session) are the reserved-sink lane,
89
+ // not agent-directed routing — they pass through unresolved.
90
+ if (isMechanicalMessageKind(input.kind)) {
91
+ return { delivered: true, expectReply: false, recipients: input.to ? [input.to] : [] };
92
+ }
93
+ // excludeId: a bare ref must never resolve back to its own author — that self-loop silently
94
+ // swallows the message (the reddit-briefing → telegram bridge break, #290).
95
+ const plan = planSend(input.to, listAgents(), { excludeId: from });
96
+ // Ambiguous is a real "can't decide" → reject on every transport (categorically unlike
97
+ // not_found, which stores). HTTP → 409, MCP → invalid-params.
98
+ if (plan.kind === "ambiguous") throw new AmbiguousTargetError(plan.message);
99
+ if (plan.kind === "not_found") {
100
+ // Store-ahead: keep `to` verbatim; delivered when that id registers and polls. The receipt
101
+ // names the ref so the caller knows nobody is reachable yet (the single delivery-truth).
102
+ return { delivered: false, expectReply: false, recipients: [], reason: plan.message };
103
+ }
104
+ // direct (incl. a single OFFLINE match — receipt already carries reason "recipient offline")
105
+ // / fanout / passthrough: adopt the canonical (possibly rewritten) id + the receipt.
106
+ input.to = plan.to;
107
+ return plan.receipt;
108
+ }
109
+
110
+ /** Authorize the send against the unified context. Scope (`message:send`) is gated at the
111
+ * transport edge (HTTP middleware, MCP `hasAnyScope`); this is the RESOURCE/constraint gate.
112
+ * Only component + integration tokens carry constraints, so it's a no-op for admin/server. */
113
+ function authorizeSend(input: SendMessageInput, ctx: AuthContext, opts: SendMessageOptions, reservedSink: boolean): void {
114
+ // A mechanical post to a reserved sink (user/system) is the relay's own observability lane
115
+ // (#284): a managed token's recipient constraints must NOT gate it, or constrained tokens
116
+ // (telegram policy, codex steward) 403 → the runner outbox retries 12× → poisons the record →
117
+ // the dashboard silently loses the turn. Drop the target/agentId predicate; keep the channel.
118
+ const resource = reservedSink
119
+ ? { channel: input.channel }
120
+ : { target: input.to, channel: input.channel, agentId: input.from };
121
+ if (ctx.component && !isComponentAuthorizedFor(ctx.component, { scope: "message:send", resource })) {
122
+ throw new ServiceAuthError("component token cannot send to this target");
123
+ }
124
+ if (opts.integration && !isIntegrationAllowed(opts.integration, { target: input.to, channel: input.channel })) {
125
+ throw new ServiceAuthError("integration token cannot target this message");
126
+ }
127
+ }
128
+
129
+ export function sendMessageService(
130
+ input: SendMessageInput,
131
+ ctx: AuthContext,
132
+ opts: SendMessageOptions = {},
133
+ ): SendMessageResult {
134
+ applyReplyRouting(input);
135
+ if (!input.to) throw new ValidationError("to is required (or provide replyTo to auto-route)");
136
+ // The reserved-sink observability lane (#284) governs both `from` resolution (wire `from` is
137
+ // authoritative — the runner reports an agent's turn) and authorization (drop the recipient
138
+ // predicate). Compute once, after reply-routing has settled `to`.
139
+ const reservedSink = isMechanicalMessageKind(input.kind) && isReservedAgentId(input.to);
140
+ input.from = resolveFrom(input, ctx, reservedSink);
141
+ // Resolve BEFORE authorize so both run against the canonical id (kills the HTTP-authorizes-raw
142
+ // vs MCP-authorizes-resolved drift).
143
+ const receipt = resolveSendTarget(input, input.from);
144
+ authorizeSend(input, ctx, opts, reservedSink);
145
+
146
+ const result = sendMessageWithResult(input);
147
+ if (result.created) {
148
+ if (result.message.deliveryStatus === "queued") {
149
+ emitMessageQueued(result.message);
150
+ // Wake an on-demand managed policy that has no live agent attached yet.
151
+ if (result.message.to?.startsWith("policy:")) {
152
+ getLifecycleManager().onMessageForPolicy(result.message.to.slice("policy:".length));
153
+ }
154
+ } else {
155
+ emitNewMessage(result.message);
156
+ }
157
+ }
158
+ return { message: result.message, created: result.created, receipt };
159
+ }