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.
- package/docs/openapi.json +1 -1
- package/package.json +1 -1
- package/public/assets/{activity-C3mkM6AU.js → activity-BgkmA1lh.js} +2 -2
- package/public/assets/{activity-C3mkM6AU.js.map → activity-BgkmA1lh.js.map} +1 -1
- package/public/assets/{agents-CAhQO7JH.js → agents-B7HnuAXx.js} +2 -2
- package/public/assets/{agents-CAhQO7JH.js.map → agents-B7HnuAXx.js.map} +1 -1
- package/public/assets/{analytics-BwihhhNn.js → analytics-0-akxJCJ.js} +2 -2
- package/public/assets/{analytics-BwihhhNn.js.map → analytics-0-akxJCJ.js.map} +1 -1
- package/public/assets/{automation-BLXToUiU.js → automation-CaE1z_-M.js} +2 -2
- package/public/assets/{automation-BLXToUiU.js.map → automation-CaE1z_-M.js.map} +1 -1
- package/public/assets/{chat-8iIPyww9.js → chat-BANKUW05.js} +2 -2
- package/public/assets/{chat-8iIPyww9.js.map → chat-BANKUW05.js.map} +1 -1
- package/public/assets/{formatted-body-impl-BHH0wzY7.js → formatted-body-impl-DExNPNsL.js} +2 -2
- package/public/assets/{formatted-body-impl-BHH0wzY7.js.map → formatted-body-impl-DExNPNsL.js.map} +1 -1
- package/public/assets/{index-CaauKXl9.js → index-DEZdON6c.js} +5 -5
- package/public/assets/{index-CaauKXl9.js.map → index-DEZdON6c.js.map} +1 -1
- package/public/assets/{maintenance-9n_rJCHT.js → maintenance-DpTdJxQp.js} +2 -2
- package/public/assets/{maintenance-9n_rJCHT.js.map → maintenance-DpTdJxQp.js.map} +1 -1
- package/public/assets/{managed-agents-Rp2-xpBx.js → managed-agents-B3df2xfk.js} +2 -2
- package/public/assets/{managed-agents-Rp2-xpBx.js.map → managed-agents-B3df2xfk.js.map} +1 -1
- package/public/assets/{markdown-preview-impl-YfJsGh6I.js → markdown-preview-impl-D0Zj7c3T.js} +2 -2
- package/public/assets/{markdown-preview-impl-YfJsGh6I.js.map → markdown-preview-impl-D0Zj7c3T.js.map} +1 -1
- package/public/assets/{memory-BQONtGQS.js → memory-TATN2vZf.js} +2 -2
- package/public/assets/{memory-BQONtGQS.js.map → memory-TATN2vZf.js.map} +1 -1
- package/public/assets/{messages-DGqpkH72.js → messages-3rS1lxIf.js} +2 -2
- package/public/assets/{messages-DGqpkH72.js.map → messages-3rS1lxIf.js.map} +1 -1
- package/public/assets/{orchestrators-b8k9QoGv.js → orchestrators-CRIV0g5y.js} +2 -2
- package/public/assets/{orchestrators-b8k9QoGv.js.map → orchestrators-CRIV0g5y.js.map} +1 -1
- package/public/assets/{overview-DSU_CggA.js → overview-CmHC_5oM.js} +2 -2
- package/public/assets/{overview-DSU_CggA.js.map → overview-CmHC_5oM.js.map} +1 -1
- package/public/assets/{pairs-DGocNC1U.js → pairs-DBPAhXTI.js} +2 -2
- package/public/assets/{pairs-DGocNC1U.js.map → pairs-DBPAhXTI.js.map} +1 -1
- package/public/assets/{security-BSh0QxOl.js → security-572X5MNX.js} +2 -2
- package/public/assets/{security-BSh0QxOl.js.map → security-572X5MNX.js.map} +1 -1
- package/public/assets/{settings-C03CAJgO.js → settings-vTBu8w3O.js} +2 -2
- package/public/assets/{settings-C03CAJgO.js.map → settings-vTBu8w3O.js.map} +1 -1
- package/public/assets/{tasks-rKbuUPOk.js → tasks-C0bPrDgN.js} +2 -2
- package/public/assets/{tasks-rKbuUPOk.js.map → tasks-C0bPrDgN.js.map} +1 -1
- package/public/assets/{terminal-viewer-impl-CA8u4jh3.js → terminal-viewer-impl-rVPA6Fsx.js} +2 -2
- package/public/assets/{terminal-viewer-impl-CA8u4jh3.js.map → terminal-viewer-impl-rVPA6Fsx.js.map} +1 -1
- package/public/assets/{work-queue-DOsA9s4M.js → work-queue-BxkpTt_A.js} +2 -2
- package/public/assets/{work-queue-DOsA9s4M.js.map → work-queue-BxkpTt_A.js.map} +1 -1
- package/public/index.html +1 -1
- package/runner/src/adapter.ts +7 -0
- package/scripts/orchestrator-spawn-smoke.ts +65 -33
- package/src/agent-ref.ts +28 -1
- package/src/bus.ts +51 -35
- package/src/compaction-watch.ts +7 -0
- package/src/index.ts +23 -6
- package/src/lifecycle-manager.ts +33 -71
- package/src/mcp.ts +100 -308
- package/src/routes/agent-sessions.ts +38 -3
- package/src/routes/agents-spawn.ts +43 -175
- package/src/routes/commands.ts +7 -19
- package/src/routes/messages.ts +24 -87
- package/src/security.ts +15 -0
- package/src/services/auth-context.ts +109 -0
- package/src/services/dispatch-command.ts +60 -0
- package/src/services/errors.ts +26 -0
- package/src/services/managed-running.ts +130 -0
- package/src/services/parity-harness.ts +135 -0
- package/src/services/register-agent.ts +74 -0
- package/src/services/send-message.ts +159 -0
- package/src/services/shutdown-agent.ts +234 -0
- 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
|
+
}
|