agent-relay-server 0.49.0 → 0.51.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 +42 -1
- package/package.json +2 -2
- package/public/assets/PlanGraphPanel-BnYmYHnY.js +2 -0
- package/public/assets/PlanGraphPanel-BnYmYHnY.js.map +1 -0
- package/public/assets/{activity--wk4dICK.js → activity-Bt-j5suN.js} +2 -2
- package/public/assets/{activity--wk4dICK.js.map → activity-Bt-j5suN.js.map} +1 -1
- package/public/assets/{agent-profiles-D_GQ78i3.js → agent-profiles-BhVEehP8.js} +2 -2
- package/public/assets/{agent-profiles-D_GQ78i3.js.map → agent-profiles-BhVEehP8.js.map} +1 -1
- package/public/assets/{agents-CrGzl-aX.js → agents-MmlXjZ8S.js} +2 -2
- package/public/assets/{agents-CrGzl-aX.js.map → agents-MmlXjZ8S.js.map} +1 -1
- package/public/assets/{analytics-CvZ_3LoL.js → analytics-dzycu7KU.js} +2 -2
- package/public/assets/{analytics-CvZ_3LoL.js.map → analytics-dzycu7KU.js.map} +1 -1
- package/public/assets/{automation-D-p56w9w.js → automation-DKmzNNAm.js} +2 -2
- package/public/assets/{automation-D-p56w9w.js.map → automation-DKmzNNAm.js.map} +1 -1
- package/public/assets/{branch-state-badge-B8h3vcwK.js → branch-state-badge-Dp6Jk4LH.js} +2 -2
- package/public/assets/{branch-state-badge-B8h3vcwK.js.map → branch-state-badge-Dp6Jk4LH.js.map} +1 -1
- package/public/assets/{channels-K5yks-NP.js → channels-CmfUfiV0.js} +2 -2
- package/public/assets/{channels-K5yks-NP.js.map → channels-CmfUfiV0.js.map} +1 -1
- package/public/assets/chat-DKPT2jJj.js +2 -0
- package/public/assets/chat-DKPT2jJj.js.map +1 -0
- package/public/assets/{connectors-fy5L_B2T.js → connectors-Bf74bw_9.js} +2 -2
- package/public/assets/{connectors-fy5L_B2T.js.map → connectors-Bf74bw_9.js.map} +1 -1
- package/public/assets/display-CXhnBwGf.js.map +1 -1
- package/public/assets/{formatted-body-impl-BRKtV_Ec.js → formatted-body-impl-Bzso46lB.js} +2 -2
- package/public/assets/{formatted-body-impl-BRKtV_Ec.js.map → formatted-body-impl-Bzso46lB.js.map} +1 -1
- package/public/assets/index-C0aWKi0g.css +2 -0
- package/public/assets/{index-C6HByEQl.js → index-uABGD_MX.js} +6 -6
- package/public/assets/{index-C6HByEQl.js.map → index-uABGD_MX.js.map} +1 -1
- package/public/assets/{insights-DFs8q-45.js → insights-BczKqmA4.js} +2 -2
- package/public/assets/{insights-DFs8q-45.js.map → insights-BczKqmA4.js.map} +1 -1
- package/public/assets/{integrations-Bg_JSXvm.js → integrations-By3hMfqC.js} +2 -2
- package/public/assets/{integrations-Bg_JSXvm.js.map → integrations-By3hMfqC.js.map} +1 -1
- package/public/assets/{maintenance-DHZOmgPn.js → maintenance-A3dR4k8v.js} +2 -2
- package/public/assets/{maintenance-DHZOmgPn.js.map → maintenance-A3dR4k8v.js.map} +1 -1
- package/public/assets/{managed-agents-DmSyKkG1.js → managed-agents-65TJRBdv.js} +2 -2
- package/public/assets/{managed-agents-DmSyKkG1.js.map → managed-agents-65TJRBdv.js.map} +1 -1
- package/public/assets/{markdown-preview-impl-C1D7h9Rg.js → markdown-preview-impl-pevH3csB.js} +2 -2
- package/public/assets/{markdown-preview-impl-C1D7h9Rg.js.map → markdown-preview-impl-pevH3csB.js.map} +1 -1
- package/public/assets/{memory-p2-2Ondq.js → memory-B3CXqWQf.js} +2 -2
- package/public/assets/{memory-p2-2Ondq.js.map → memory-B3CXqWQf.js.map} +1 -1
- package/public/assets/{messages-vytFTjcd.js → messages-BV48hQsO.js} +2 -2
- package/public/assets/{messages-vytFTjcd.js.map → messages-BV48hQsO.js.map} +1 -1
- package/public/assets/{orchestrators-CQNQYFnL.js → orchestrators-CPU7U-s7.js} +2 -2
- package/public/assets/{orchestrators-CQNQYFnL.js.map → orchestrators-CPU7U-s7.js.map} +1 -1
- package/public/assets/{overview-CurMUxSA.js → overview-mR70u7ea.js} +2 -2
- package/public/assets/{overview-CurMUxSA.js.map → overview-mR70u7ea.js.map} +1 -1
- package/public/assets/{pairs-CSga0gZ4.js → pairs-B8KJc296.js} +2 -2
- package/public/assets/{pairs-CSga0gZ4.js.map → pairs-B8KJc296.js.map} +1 -1
- package/public/assets/{security-BFmvd8TS.js → security-Dc74nqW8.js} +2 -2
- package/public/assets/{security-BFmvd8TS.js.map → security-Dc74nqW8.js.map} +1 -1
- package/public/assets/{select-U0nqw_nv.js → select-kIw3Jm8E.js} +2 -2
- package/public/assets/{select-U0nqw_nv.js.map → select-kIw3Jm8E.js.map} +1 -1
- package/public/assets/{settings-D_1nlrGB.js → settings-XrFz0Q3b.js} +2 -2
- package/public/assets/{settings-D_1nlrGB.js.map → settings-XrFz0Q3b.js.map} +1 -1
- package/public/assets/store-BLZA3u7Y.js +9 -0
- package/public/assets/store-BLZA3u7Y.js.map +1 -0
- package/public/assets/{tasks-TnEbfohb.js → tasks-TY2QGHHg.js} +2 -2
- package/public/assets/{tasks-TnEbfohb.js.map → tasks-TY2QGHHg.js.map} +1 -1
- package/public/assets/teams-DMQN2gCU.js +2 -0
- package/public/assets/teams-DMQN2gCU.js.map +1 -0
- package/public/assets/{terminal-viewer-impl-DwuhfM71.js → terminal-viewer-impl-D3du9G9E.js} +2 -2
- package/public/assets/{terminal-viewer-impl-DwuhfM71.js.map → terminal-viewer-impl-D3du9G9E.js.map} +1 -1
- package/public/assets/{work-queue-D1iqCla1.js → work-queue-B2nZp_vj.js} +2 -2
- package/public/assets/{work-queue-D1iqCla1.js.map → work-queue-B2nZp_vj.js.map} +1 -1
- package/public/assets/{workspaces-BRVqBWat.js → workspaces-PdCwiSQx.js} +2 -2
- package/public/assets/{workspaces-BRVqBWat.js.map → workspaces-PdCwiSQx.js.map} +1 -1
- package/public/index.html +3 -3
- package/src/bus.ts +2 -2
- package/src/config-store.ts +8 -16
- package/src/db/agents.ts +6 -2
- package/src/db/mappers.ts +1 -0
- package/src/db/migrations.ts +5 -2
- package/src/db/plans.ts +11 -0
- package/src/db/schema.ts +1 -1
- package/src/db/teams.ts +120 -0
- package/src/index.ts +2 -0
- package/src/maintenance.ts +3 -3
- package/src/mcp-agent-action.ts +45 -0
- package/src/mcp.ts +8 -10
- package/src/routes/index.ts +2 -1
- package/src/routes/teams.ts +14 -2
- package/src/services/agent-action.ts +138 -0
- package/src/services/plan-graph.ts +49 -11
- package/src/services/plan-live-bindings.ts +88 -0
- package/src/services/spawn-agent.ts +5 -1
- package/src/services/transient-agent-reaper.ts +108 -0
- package/src/spawn-command.ts +2 -0
- package/public/assets/chat-AcrAMzhN.js +0 -2
- package/public/assets/chat-AcrAMzhN.js.map +0 -1
- package/public/assets/index-B8ND8v33.css +0 -2
- package/public/assets/store-qcA2XXVy.js +0 -9
- package/public/assets/store-qcA2XXVy.js.map +0 -1
- package/public/assets/teams-D5GY1lKs.js +0 -2
- package/public/assets/teams-D5GY1lKs.js.map +0 -1
package/src/db/schema.ts
CHANGED
|
@@ -104,7 +104,7 @@ export function initDb(path: string = "agent-relay.db"): Database {
|
|
|
104
104
|
epoch INTEGER NOT NULL DEFAULT 0,
|
|
105
105
|
provider_capabilities TEXT,
|
|
106
106
|
context_state TEXT,
|
|
107
|
-
meta TEXT NOT NULL DEFAULT '{}',
|
|
107
|
+
meta TEXT NOT NULL DEFAULT '{}', lifecycle TEXT NOT NULL DEFAULT 'persistent',
|
|
108
108
|
spawned_by TEXT,
|
|
109
109
|
team_id TEXT,
|
|
110
110
|
last_seen INTEGER NOT NULL,
|
package/src/db/teams.ts
CHANGED
|
@@ -31,6 +31,46 @@ export interface TeamDissolveInput {
|
|
|
31
31
|
landsClean?: boolean;
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
type OutcomeBucket = NonNullable<Team["outcomeStatus"]> | "unknown";
|
|
35
|
+
|
|
36
|
+
interface TeamOutcomeRow {
|
|
37
|
+
id: string;
|
|
38
|
+
title: string | null;
|
|
39
|
+
outcome_status: Team["outcomeStatus"] | null;
|
|
40
|
+
outcome_summary: string | null;
|
|
41
|
+
composition: string | null;
|
|
42
|
+
wall_clock_ms: number | null;
|
|
43
|
+
dissolved_at: number | null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface TeamOutcomeCompositionAggregate {
|
|
47
|
+
shape: string;
|
|
48
|
+
members: Array<{ provider: string; model: string }>;
|
|
49
|
+
count: number;
|
|
50
|
+
successCount: number;
|
|
51
|
+
successRate: number;
|
|
52
|
+
avgWallClockMs: number | null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface TeamOutcomeRecent {
|
|
56
|
+
id: string;
|
|
57
|
+
title?: string;
|
|
58
|
+
outcome: Team["outcomeStatus"] | null;
|
|
59
|
+
outcomeSummary?: string;
|
|
60
|
+
durationMs: number | null;
|
|
61
|
+
memberCount: number;
|
|
62
|
+
dissolvedAt: number | null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface TeamOutcomeAggregates {
|
|
66
|
+
totals: {
|
|
67
|
+
total: number;
|
|
68
|
+
byOutcomeStatus: Record<OutcomeBucket, number>;
|
|
69
|
+
};
|
|
70
|
+
compositionShapes: TeamOutcomeCompositionAggregate[];
|
|
71
|
+
recent: TeamOutcomeRecent[];
|
|
72
|
+
}
|
|
73
|
+
|
|
34
74
|
export function teamId(): string {
|
|
35
75
|
return `team_${randomUUID().replace(/-/g, "").slice(0, 16)}`;
|
|
36
76
|
}
|
|
@@ -153,6 +193,86 @@ function compositionForMembers(members: AgentCard[]): string {
|
|
|
153
193
|
})));
|
|
154
194
|
}
|
|
155
195
|
|
|
196
|
+
function compositionShape(composition: string | null): { key: string; members: Array<{ provider: string; model: string }>; memberCount: number } {
|
|
197
|
+
if (!composition) return { key: "empty", members: [], memberCount: 0 };
|
|
198
|
+
try {
|
|
199
|
+
const parsed = JSON.parse(composition) as unknown;
|
|
200
|
+
if (!Array.isArray(parsed)) return { key: "unknown/unknown", members: [{ provider: "unknown", model: "unknown" }], memberCount: 0 };
|
|
201
|
+
const seen = new Map<string, { provider: string; model: string }>();
|
|
202
|
+
for (const raw of parsed) {
|
|
203
|
+
const member = raw && typeof raw === "object" ? raw as Record<string, unknown> : {};
|
|
204
|
+
const provider = typeof member.provider === "string" && member.provider.trim() ? member.provider.trim() : "unknown";
|
|
205
|
+
const model = typeof member.model === "string" && member.model.trim() ? member.model.trim() : "unknown";
|
|
206
|
+
seen.set(`${provider}/${model}`, { provider, model });
|
|
207
|
+
}
|
|
208
|
+
const members = [...seen.values()].sort((a, b) => a.provider.localeCompare(b.provider) || a.model.localeCompare(b.model));
|
|
209
|
+
return { key: members.length ? members.map((member) => `${member.provider}/${member.model}`).join(", ") : "empty", members, memberCount: parsed.length };
|
|
210
|
+
} catch {
|
|
211
|
+
return { key: "unknown/unknown", members: [{ provider: "unknown", model: "unknown" }], memberCount: 0 };
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export function getTeamOutcomeAggregates(input: { recentLimit?: number } = {}): TeamOutcomeAggregates {
|
|
216
|
+
const recentLimit = Math.max(1, Math.min(50, input.recentLimit ?? 12));
|
|
217
|
+
const rows = getDb().query(`
|
|
218
|
+
SELECT id, title, outcome_status, outcome_summary, composition, wall_clock_ms, dissolved_at
|
|
219
|
+
FROM teams
|
|
220
|
+
WHERE status = 'dissolved'
|
|
221
|
+
ORDER BY dissolved_at DESC, updated_at DESC, id DESC
|
|
222
|
+
`).all() as TeamOutcomeRow[];
|
|
223
|
+
|
|
224
|
+
const byOutcomeStatus: Record<OutcomeBucket, number> = { success: 0, partial: 0, failed: 0, unknown: 0 };
|
|
225
|
+
const byShape = new Map<string, {
|
|
226
|
+
members: Array<{ provider: string; model: string }>;
|
|
227
|
+
count: number;
|
|
228
|
+
successCount: number;
|
|
229
|
+
durationTotal: number;
|
|
230
|
+
durationCount: number;
|
|
231
|
+
}>();
|
|
232
|
+
const recent: TeamOutcomeRecent[] = [];
|
|
233
|
+
|
|
234
|
+
for (const row of rows) {
|
|
235
|
+
byOutcomeStatus[row.outcome_status ?? "unknown"] += 1;
|
|
236
|
+
const shape = compositionShape(row.composition);
|
|
237
|
+
const aggregate = byShape.get(shape.key) ?? { members: shape.members, count: 0, successCount: 0, durationTotal: 0, durationCount: 0 };
|
|
238
|
+
aggregate.count += 1;
|
|
239
|
+
if (row.outcome_status === "success") aggregate.successCount += 1;
|
|
240
|
+
if (row.wall_clock_ms !== null) {
|
|
241
|
+
aggregate.durationTotal += row.wall_clock_ms;
|
|
242
|
+
aggregate.durationCount += 1;
|
|
243
|
+
}
|
|
244
|
+
byShape.set(shape.key, aggregate);
|
|
245
|
+
if (recent.length < recentLimit) {
|
|
246
|
+
recent.push({
|
|
247
|
+
id: row.id,
|
|
248
|
+
...(row.title ? { title: row.title } : {}),
|
|
249
|
+
outcome: row.outcome_status,
|
|
250
|
+
...(row.outcome_summary ? { outcomeSummary: row.outcome_summary } : {}),
|
|
251
|
+
durationMs: row.wall_clock_ms,
|
|
252
|
+
memberCount: shape.memberCount,
|
|
253
|
+
dissolvedAt: row.dissolved_at,
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const compositionShapes = [...byShape.entries()]
|
|
259
|
+
.map(([shape, aggregate]) => ({
|
|
260
|
+
shape,
|
|
261
|
+
members: aggregate.members,
|
|
262
|
+
count: aggregate.count,
|
|
263
|
+
successCount: aggregate.successCount,
|
|
264
|
+
successRate: aggregate.count === 0 ? 0 : aggregate.successCount / aggregate.count,
|
|
265
|
+
avgWallClockMs: aggregate.durationCount === 0 ? null : Math.round(aggregate.durationTotal / aggregate.durationCount),
|
|
266
|
+
}))
|
|
267
|
+
.sort((a, b) => b.count - a.count || b.successRate - a.successRate || a.shape.localeCompare(b.shape));
|
|
268
|
+
|
|
269
|
+
return {
|
|
270
|
+
totals: { total: rows.length, byOutcomeStatus },
|
|
271
|
+
compositionShapes,
|
|
272
|
+
recent,
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
|
|
156
276
|
export function dissolveTeam(teamId: string, input: TeamDissolveInput = {}): Team {
|
|
157
277
|
const existing = getTeam(teamId);
|
|
158
278
|
if (!existing) throw new ValidationError(`team ${teamId} not found`);
|
package/src/index.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { getLifecycleManager } from "./lifecycle-manager";
|
|
|
10
10
|
import { getCompactionWatch } from "./compaction-watch";
|
|
11
11
|
import { getConfig, setConfig } from "./config-store";
|
|
12
12
|
import { startConnectorStatusPoller } from "./connectors";
|
|
13
|
+
import { startPlanLiveBindings } from "./services/plan-live-bindings";
|
|
13
14
|
import { resolve, sep } from "path";
|
|
14
15
|
import { gzipSync, brotliCompressSync, constants as zlibConstants } from "node:zlib";
|
|
15
16
|
import {
|
|
@@ -73,6 +74,7 @@ function startServer(): void {
|
|
|
73
74
|
|
|
74
75
|
assertSafeNetworkConfig(HOST);
|
|
75
76
|
initDb(DB_PATH);
|
|
77
|
+
startPlanLiveBindings();
|
|
76
78
|
getLifecycleManager().start();
|
|
77
79
|
getCompactionWatch().start();
|
|
78
80
|
reconcileRelayVersion();
|
package/src/maintenance.ts
CHANGED
|
@@ -62,6 +62,7 @@ import {
|
|
|
62
62
|
import { notifySystemMessage } from "./notify";
|
|
63
63
|
import { pruneExpiredTokenRecords } from "./token-db";
|
|
64
64
|
import { dissolveTeamById } from "./services/team";
|
|
65
|
+
import { reapTransientAgents, resetTransientTrackerForTests } from "./services/transient-agent-reaper";
|
|
65
66
|
import type { Command, MaintenanceJob, MaintenanceJobRun } from "./types";
|
|
66
67
|
|
|
67
68
|
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
@@ -134,9 +135,7 @@ const orphanReapEnabled = () => process.env.AGENT_RELAY_ORPHAN_REAP !== "0";
|
|
|
134
135
|
// orchestratorId + session identity -> when we first saw it orphaned (and last reaped).
|
|
135
136
|
const orphanTracker = new Map<string, { firstOrphanedAt: number; lastReapAt?: number }>();
|
|
136
137
|
|
|
137
|
-
export function resetOrphanTrackerForTests(): void {
|
|
138
|
-
orphanTracker.clear();
|
|
139
|
-
}
|
|
138
|
+
export function resetOrphanTrackerForTests(): void { orphanTracker.clear(); resetTransientTrackerForTests(); }
|
|
140
139
|
|
|
141
140
|
interface MaintenanceJobDefinition {
|
|
142
141
|
id: string;
|
|
@@ -300,6 +299,7 @@ const definitions: MaintenanceJobDefinition[] = [
|
|
|
300
299
|
runOnStart: false,
|
|
301
300
|
handler: reapOrphanedSessions,
|
|
302
301
|
},
|
|
302
|
+
{ id: "transient-agent-reaper", title: "Transient agent reaper", description: "Cleanly shut down transient spawned agents once they are idle and any isolated branch work has landed.", intervalMs: REAP_INTERVAL_MS, runOnStart: false, handler: reapTransientAgents },
|
|
303
303
|
{
|
|
304
304
|
id: "team-reap",
|
|
305
305
|
title: "Team lifecycle close-out",
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { cleanEnum, cleanString } from "./validation";
|
|
2
|
+
import { AgentActionTargetError, dispatchAgentAction, type AgentAction } from "./services/agent-action";
|
|
3
|
+
import { authContextFromMcp } from "./services/auth-context";
|
|
4
|
+
import { ShutdownAuthError, ShutdownTargetError } from "./services/shutdown-agent";
|
|
5
|
+
import { McpAuthError, McpNotFoundError } from "./mcp-errors";
|
|
6
|
+
|
|
7
|
+
type McpAuthLike = {
|
|
8
|
+
actor: string;
|
|
9
|
+
kind: "component" | "integration" | "server";
|
|
10
|
+
scopes?: string[];
|
|
11
|
+
component?: Parameters<typeof authContextFromMcp>[0]["component"];
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export const AGENT_ACTION_TOOL = {
|
|
15
|
+
name: "relay_agent_action",
|
|
16
|
+
description: "Compact, clear context, or directly inject a prompt into one of your live spawned children. Gated exactly like relay_shutdown_agent: an agent caller may target only its OWN spawned children by agentId; admin tokens keep full reach.",
|
|
17
|
+
requiredScopes: ["command:shutdown"],
|
|
18
|
+
inputSchema: {
|
|
19
|
+
type: "object",
|
|
20
|
+
properties: {
|
|
21
|
+
agentId: { type: "string" },
|
|
22
|
+
action: { type: "string", enum: ["compact", "clearContext", "inject"] },
|
|
23
|
+
message: { type: "string", description: "Required when action is inject." },
|
|
24
|
+
},
|
|
25
|
+
required: ["agentId", "action"],
|
|
26
|
+
additionalProperties: false,
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export function relayAgentAction(auth: McpAuthLike, args: Record<string, unknown>): Record<string, unknown> {
|
|
31
|
+
try {
|
|
32
|
+
const result = dispatchAgentAction({
|
|
33
|
+
agentId: cleanString(args.agentId, "agentId", { required: true, max: 240 })!,
|
|
34
|
+
action: cleanEnum(args.action, "action", ["compact", "clearContext", "inject"] as const) as AgentAction,
|
|
35
|
+
message: cleanString(args.message, "message", { max: 16_000 }),
|
|
36
|
+
requestedVia: "mcp",
|
|
37
|
+
requestedBy: auth.actor,
|
|
38
|
+
}, authContextFromMcp(auth));
|
|
39
|
+
return { ok: true, action: result.action, command: result.command };
|
|
40
|
+
} catch (e) {
|
|
41
|
+
if (e instanceof ShutdownAuthError) throw new McpAuthError(e.message);
|
|
42
|
+
if (e instanceof ShutdownTargetError || e instanceof AgentActionTargetError) throw new McpNotFoundError(e.message);
|
|
43
|
+
throw e;
|
|
44
|
+
}
|
|
45
|
+
}
|
package/src/mcp.ts
CHANGED
|
@@ -49,21 +49,17 @@ import { ServiceAuthError } from "./services/errors";
|
|
|
49
49
|
import { PLAN_TOOLS, relayPlanTool } from "./mcp-plan-tools";
|
|
50
50
|
import { TEAM_TOOLS, relayTeamTool } from "./mcp-team-tools";
|
|
51
51
|
import { BLACKBOARD_TOOLS, relayBlackboardTool } from "./mcp-blackboard-tools";
|
|
52
|
-
import
|
|
52
|
+
import { AGENT_ACTION_TOOL, relayAgentAction } from "./mcp-agent-action";
|
|
53
|
+
import type { ActivityKind, AgentLifecycle, ArtifactKind, ArtifactSensitivity, AttachmentRef, Command, SendMessageInput, Message, SpawnApprovalMode, SpawnProvider, WorkspaceAutoMergePolicy, WorkspaceMergeStrategy, WorkspaceMode, WorkspaceRecord } from "./types";
|
|
53
54
|
import { LAND_STRATEGIES, applyWorkspaceAction, waitForWorkspaceStatus, type WorkspaceAction } from "./workspace-actions";
|
|
54
55
|
import { AUTO_MERGE_POLICIES } from "./workspace-merge";
|
|
55
56
|
import { describeWorkspacePhase, landReceipt, readyContract, worktreeMcpInstructions } from "./workspace-phase";
|
|
56
57
|
import { type ProviderEffort } from "agent-relay-sdk/provider-catalog";
|
|
57
|
-
import { errMessage, isRecord, stringValue, SPAWN_PROVIDERS, APPROVAL_MODES, VALID_EFFORTS, VALID_WORKSPACE_MODES } from "agent-relay-sdk";
|
|
58
|
+
import { errMessage, isRecord, stringValue, SPAWN_PROVIDERS, APPROVAL_MODES, VALID_AGENT_LIFECYCLES, VALID_EFFORTS, VALID_WORKSPACE_MODES } from "agent-relay-sdk";
|
|
58
59
|
|
|
59
60
|
type JsonRpcId = string | number | null;
|
|
60
61
|
|
|
61
|
-
type JsonRpcRequest = {
|
|
62
|
-
jsonrpc?: "2.0";
|
|
63
|
-
id?: JsonRpcId;
|
|
64
|
-
method?: string;
|
|
65
|
-
params?: unknown;
|
|
66
|
-
};
|
|
62
|
+
type JsonRpcRequest = { jsonrpc?: "2.0"; id?: JsonRpcId; method?: string; params?: unknown };
|
|
67
63
|
|
|
68
64
|
type McpAuthContext = {
|
|
69
65
|
actor: string;
|
|
@@ -92,8 +88,6 @@ const VALID_ARTIFACT_KINDS = ["image", "audio", "video", "document", "archive",
|
|
|
92
88
|
const VALID_ARTIFACT_SENSITIVITIES = ["public", "normal", "sensitive", "secret"] as const;
|
|
93
89
|
const VALID_ARTIFACT_ROLES = ["media", "patch", "report", "log", "output", "input"] as const;
|
|
94
90
|
const VALID_ARTIFACT_ENTITY_TYPES = ["message", "task", "recipeRun", "recipeStep", "channelEvent"] as const;
|
|
95
|
-
|
|
96
|
-
|
|
97
91
|
const TOOLS: ToolDefinition[] = [
|
|
98
92
|
{
|
|
99
93
|
name: "relay_send_message",
|
|
@@ -320,6 +314,7 @@ const TOOLS: ToolDefinition[] = [
|
|
|
320
314
|
providerArgs: { type: "array", items: { type: "string" } },
|
|
321
315
|
policyName: { type: "string" },
|
|
322
316
|
spawnRequestId: { type: "string" },
|
|
317
|
+
lifecycle: { type: "string", enum: VALID_AGENT_LIFECYCLES, description: "Lifecycle mode. `persistent` keeps the agent until explicitly stopped. `transient` lets Relay auto-shutdown the child after it is idle and, for isolated workers, after its branch has landed. Default: persistent." },
|
|
323
318
|
workspaceMode: { type: "string", enum: VALID_WORKSPACE_MODES, description: "Git workspace for the worker. `isolated` = its own worktree on an `agent/session-*` branch off main that auto-lands when ready (delegate a unit of work, get a branch worker). `shared` = edits your live checkout (a sidecar). `inherit` = let the orchestrator decide. DEFAULT for agent-initiated spawns is `isolated` so a delegated worker lands to main instead of editing your tree; pass `shared`/`inherit` to override." },
|
|
324
319
|
waitForRegistrationMs: { type: "integer", minimum: 0, maximum: 30000, description: "How long to wait for the spawned agent to register before returning, so the response carries its resolved agent id (default 8000; 0 = return immediately with just spawnRequestId)." },
|
|
325
320
|
},
|
|
@@ -355,6 +350,7 @@ const TOOLS: ToolDefinition[] = [
|
|
|
355
350
|
additionalProperties: false,
|
|
356
351
|
},
|
|
357
352
|
},
|
|
353
|
+
AGENT_ACTION_TOOL,
|
|
358
354
|
{
|
|
359
355
|
name: "relay_workspace_status",
|
|
360
356
|
description: "Get your isolated workspace's lifecycle status (active/ready/conflict/review_requested/merged/...). With wait:true this BLOCKS until the status changes — use it after relay_workspace_ready to wait for the auto-merge to land your branch on main (status leaves 'ready' → merged/recycled-to-active with a fresh rebased branch) or to surface a conflict/review_requested the steward couldn't auto-resolve. Defaults to the workspace you own; pass workspaceId to target another.",
|
|
@@ -566,6 +562,7 @@ async function callTool(auth: McpAuthContext, params: unknown): Promise<Record<s
|
|
|
566
562
|
else if (name === "relay_spawn_agent") result = await relaySpawnAgent(auth, args);
|
|
567
563
|
else if (name === "relay_spawn_targets") result = relaySpawnTargets(auth);
|
|
568
564
|
else if (name === "relay_shutdown_agent") result = relayShutdownAgent(auth, args);
|
|
565
|
+
else if (name === "relay_agent_action") result = relayAgentAction(auth, args);
|
|
569
566
|
else if (name === "relay_workspace_status") result = await relayWorkspaceStatus(auth, args);
|
|
570
567
|
else if (name === "relay_workspace_list") result = relayWorkspaceList(auth, args);
|
|
571
568
|
else if (name === "relay_workspace_ready") result = relayWorkspaceMutation(auth, "ready", args);
|
|
@@ -866,6 +863,7 @@ async function relaySpawnAgent(auth: McpAuthContext, args: Record<string, unknow
|
|
|
866
863
|
effort: optionalEnum(args.effort, "effort", VALID_EFFORTS) as ProviderEffort | undefined,
|
|
867
864
|
approvalMode: optionalEnum(args.approvalMode, "approvalMode", APPROVAL_MODES) as SpawnApprovalMode | undefined,
|
|
868
865
|
workspaceMode: optionalEnum(args.workspaceMode, "workspaceMode", VALID_WORKSPACE_MODES) as WorkspaceMode | undefined,
|
|
866
|
+
lifecycle: (optionalEnum(args.lifecycle, "lifecycle", VALID_AGENT_LIFECYCLES) as AgentLifecycle | undefined) ?? "persistent",
|
|
869
867
|
label: optionalString(args.label, "label", 120),
|
|
870
868
|
policyName: optionalString(args.policyName, "policyName", 120),
|
|
871
869
|
profile: optionalString(args.profile, "profile", 120),
|
package/src/routes/index.ts
CHANGED
|
@@ -31,7 +31,7 @@ import { getBlackboardEntryRoute, getBlackboardRoute } from "./blackboard";
|
|
|
31
31
|
import { getRecipeByName, getRecipeInstanceArtifacts, getRecipeInstanceById, getRecipeInstances, getRecipes, postRecipeInstanceArtifacts, postRecipeStart, postRecipeStop } from "./recipes";
|
|
32
32
|
import { getStewardConfigRoute, getWorkspaceConfigRoute, putStewardConfigRoute, putWorkspaceConfigRoute } from "./steward";
|
|
33
33
|
import { getTaskById, getTaskEvents, getTasks, patchTaskStatus, postClaimTask, postRenewTaskClaim } from "./tasks";
|
|
34
|
-
import { getMyTeamRoute, getTeamsRoute, postMyTeamDissolveRoute, putMyTeamBriefRoute } from "./teams";
|
|
34
|
+
import { getMyTeamRoute, getTeamOutcomesRoute, getTeamsRoute, postMyTeamDissolveRoute, putMyTeamBriefRoute } from "./teams";
|
|
35
35
|
import { postTeamDissolveByIdRoute } from "./teams";
|
|
36
36
|
import { postMcp } from "../mcp";
|
|
37
37
|
import { postOrchestratorBootstrap, postOrchestratorBootstrapExchange } from "./orchestrator-bootstrap";
|
|
@@ -139,6 +139,7 @@ const routes: Route[] = [
|
|
|
139
139
|
|
|
140
140
|
route("POST", "/api/system/broadcast", postSystemBroadcast),
|
|
141
141
|
route("GET", "/api/teams", getTeamsRoute),
|
|
142
|
+
route("GET", "/api/teams/outcomes", getTeamOutcomesRoute),
|
|
142
143
|
route("GET", "/api/teams/me", getMyTeamRoute),
|
|
143
144
|
route("PUT", "/api/teams/me/brief", putMyTeamBriefRoute),
|
|
144
145
|
route("POST", "/api/teams/me/dissolve", postMyTeamDissolveRoute),
|
package/src/routes/teams.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { McpAuthError, McpNotFoundError } from "../mcp-errors";
|
|
2
2
|
import { authContextFromRequest } from "../services/auth-context";
|
|
3
|
-
import { getTeam, listAllTeams, ValidationError } from "../db";
|
|
3
|
+
import { getTeam, getTeamOutcomeAggregates, listAllTeams, ValidationError } from "../db";
|
|
4
4
|
import { dissolveCallerTeam, dissolveTeamById, getCallerTeam, setCallerTeamBrief, TeamAuthError, TeamNotFoundError, type TeamDissolveServiceInput } from "../services/team";
|
|
5
5
|
import { cleanString, optionalEnum } from "../validation";
|
|
6
|
-
import { authorizeRoute, error, json, parseBody, type Handler } from "./_shared";
|
|
6
|
+
import { authorizeRoute, error, json, parseBody, parseQueryInt, type Handler } from "./_shared";
|
|
7
7
|
import type { Team } from "../types";
|
|
8
8
|
|
|
9
9
|
const OUTCOME_STATUSES = ["success", "partial", "failed"] as const;
|
|
@@ -50,6 +50,18 @@ export const getTeamsRoute: Handler = (req) => {
|
|
|
50
50
|
}
|
|
51
51
|
};
|
|
52
52
|
|
|
53
|
+
export const getTeamOutcomesRoute: Handler = (req) => {
|
|
54
|
+
const denied = authorizeRoute(req, { scope: "system:admin" });
|
|
55
|
+
if (denied) return denied;
|
|
56
|
+
try {
|
|
57
|
+
const recentLimit = parseQueryInt(new URL(req.url).searchParams.get("recentLimit"), { min: 1, max: 50, clamp: true }) ?? undefined;
|
|
58
|
+
if (Number.isNaN(recentLimit)) return error("invalid recentLimit", 400);
|
|
59
|
+
return json(getTeamOutcomeAggregates({ recentLimit }));
|
|
60
|
+
} catch (err) {
|
|
61
|
+
return routeError(err);
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
|
|
53
65
|
export const putMyTeamBriefRoute: Handler = async (req) => {
|
|
54
66
|
const parsed = await parseBody<Record<string, unknown>>(req);
|
|
55
67
|
if (!parsed.ok) return error(parsed.error, parsed.status);
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { createCommand } from "../commands-db";
|
|
2
|
+
import { ValidationError, createActivityEvent, getAgent, sendMessage } from "../db";
|
|
3
|
+
import { emitCommandEvent } from "../command-events";
|
|
4
|
+
import { emitActivityEvent, emitNewMessage } from "../sse";
|
|
5
|
+
import { authorizeShutdown, ShutdownAuthError, ShutdownTargetError } from "./shutdown-agent";
|
|
6
|
+
import type { AuthContext } from "./auth-context";
|
|
7
|
+
import type { AgentCard, Command } from "../types";
|
|
8
|
+
|
|
9
|
+
export type AgentAction = "compact" | "clearContext" | "inject";
|
|
10
|
+
|
|
11
|
+
export interface AgentActionInput {
|
|
12
|
+
agentId: string;
|
|
13
|
+
action: AgentAction;
|
|
14
|
+
message?: string;
|
|
15
|
+
requestedVia: "mcp";
|
|
16
|
+
requestedBy?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface AgentActionResult {
|
|
20
|
+
action: AgentAction;
|
|
21
|
+
command: Command;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export class AgentActionTargetError extends Error {}
|
|
25
|
+
|
|
26
|
+
function commandType(action: AgentAction): "agent.compact" | "agent.clearContext" | "prompt.inject" {
|
|
27
|
+
if (action === "compact") return "agent.compact";
|
|
28
|
+
if (action === "clearContext") return "agent.clearContext";
|
|
29
|
+
return "prompt.inject";
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function title(action: AgentAction): string {
|
|
33
|
+
if (action === "compact") return "Agent compaction requested";
|
|
34
|
+
if (action === "clearContext") return "Agent context clear requested";
|
|
35
|
+
return "Agent prompt injected";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function icon(action: AgentAction): string {
|
|
39
|
+
if (action === "compact") return "ti-fold";
|
|
40
|
+
if (action === "clearContext") return "ti-clear-all";
|
|
41
|
+
return "ti-message-bolt";
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function compactMemoryParams(agent: AgentCard): Record<string, unknown> {
|
|
45
|
+
const alwaysReload = agent.tags
|
|
46
|
+
.filter((tag) => tag.startsWith("memory-reload:"))
|
|
47
|
+
.map((tag) => tag.slice("memory-reload:".length).trim())
|
|
48
|
+
.filter(Boolean);
|
|
49
|
+
if (!alwaysReload.length) return {};
|
|
50
|
+
return { memory: { alwaysReload } };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function assertSupported(agent: AgentCard, action: AgentAction): void {
|
|
54
|
+
if (agent.id === "user" || agent.id === "system" || agent.meta?.kind === "channel" || agent.tags.includes("channel")) {
|
|
55
|
+
throw new ValidationError("agent cannot receive lifecycle actions");
|
|
56
|
+
}
|
|
57
|
+
if (agent.status === "offline") throw new ValidationError("agent is offline");
|
|
58
|
+
if (action === "compact" && agent.providerCapabilities?.context?.compact !== true) {
|
|
59
|
+
throw new ValidationError("agent does not support compact");
|
|
60
|
+
}
|
|
61
|
+
if (action === "clearContext" && agent.providerCapabilities?.context?.clear !== true) {
|
|
62
|
+
throw new ValidationError("agent does not support clearContext");
|
|
63
|
+
}
|
|
64
|
+
if (action === "inject" && agent.providerCapabilities?.liveSession?.inject !== true) {
|
|
65
|
+
throw new ValidationError("agent does not support inject");
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function sessionMessageForInject(agentId: string, ctx: AuthContext, body: string) {
|
|
70
|
+
const from = ctx.callerAgentId && getAgent(ctx.callerAgentId) ? ctx.callerAgentId : "system";
|
|
71
|
+
const message = sendMessage({
|
|
72
|
+
from,
|
|
73
|
+
to: agentId,
|
|
74
|
+
kind: "session",
|
|
75
|
+
body,
|
|
76
|
+
});
|
|
77
|
+
emitNewMessage(message);
|
|
78
|
+
return message;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function dispatchAgentAction(input: AgentActionInput, ctx: AuthContext): AgentActionResult {
|
|
82
|
+
const agent = getAgent(input.agentId);
|
|
83
|
+
if (!agent) throw new AgentActionTargetError(`agent ${input.agentId} not found`);
|
|
84
|
+
|
|
85
|
+
try {
|
|
86
|
+
authorizeShutdown(ctx, { agentId: input.agentId, requestedVia: input.requestedVia });
|
|
87
|
+
} catch (e) {
|
|
88
|
+
if (e instanceof ShutdownAuthError || e instanceof ShutdownTargetError) throw e;
|
|
89
|
+
throw e;
|
|
90
|
+
}
|
|
91
|
+
assertSupported(agent, input.action);
|
|
92
|
+
|
|
93
|
+
const body = input.action === "inject"
|
|
94
|
+
? (input.message?.trim() || (() => { throw new ValidationError("message is required for inject"); })())
|
|
95
|
+
: undefined;
|
|
96
|
+
const sessionMessage = body ? sessionMessageForInject(agent.id, ctx, body) : undefined;
|
|
97
|
+
const command = createCommand({
|
|
98
|
+
type: commandType(input.action),
|
|
99
|
+
source: "system",
|
|
100
|
+
target: agent.id,
|
|
101
|
+
correlationId: typeof agent.meta?.spawnRequestId === "string" ? agent.meta.spawnRequestId : undefined,
|
|
102
|
+
params: {
|
|
103
|
+
action: input.action,
|
|
104
|
+
agentId: agent.id,
|
|
105
|
+
...(typeof agent.meta?.sessionName === "string" ? { sessionName: agent.meta.sessionName } : {}),
|
|
106
|
+
...(typeof agent.meta?.tmuxSession === "string" ? { tmuxSession: agent.meta.tmuxSession } : {}),
|
|
107
|
+
...(typeof agent.meta?.policyName === "string" ? { policyName: agent.meta.policyName } : {}),
|
|
108
|
+
...(typeof agent.meta?.spawnRequestId === "string" ? { spawnRequestId: agent.meta.spawnRequestId } : {}),
|
|
109
|
+
...(input.action === "compact" ? compactMemoryParams(agent) : {}),
|
|
110
|
+
...(body ? { body, messageId: sessionMessage!.id } : {}),
|
|
111
|
+
requestedBy: input.requestedBy ?? ctx.callerAgentId ?? ctx.actor.id,
|
|
112
|
+
requestedVia: input.requestedVia,
|
|
113
|
+
requestedAt: Date.now(),
|
|
114
|
+
},
|
|
115
|
+
});
|
|
116
|
+
emitCommandEvent(command, "command.requested");
|
|
117
|
+
const event = createActivityEvent({
|
|
118
|
+
clientId: `server-agent-${agent.id}-action-${input.action}-${command.id}`,
|
|
119
|
+
kind: "state",
|
|
120
|
+
title: title(input.action),
|
|
121
|
+
body: input.action,
|
|
122
|
+
meta: agent.id,
|
|
123
|
+
icon: icon(input.action),
|
|
124
|
+
view: "agents",
|
|
125
|
+
agentId: agent.id,
|
|
126
|
+
messageId: sessionMessage?.id,
|
|
127
|
+
metadata: {
|
|
128
|
+
action: input.action,
|
|
129
|
+
commandId: command.id,
|
|
130
|
+
actor: ctx.actor.id,
|
|
131
|
+
actorKind: ctx.actor.kind,
|
|
132
|
+
requestedVia: input.requestedVia,
|
|
133
|
+
source: "server",
|
|
134
|
+
},
|
|
135
|
+
});
|
|
136
|
+
emitActivityEvent(event);
|
|
137
|
+
return { action: input.action, command };
|
|
138
|
+
}
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { isRecord } from "agent-relay-sdk";
|
|
2
|
-
import { createPlanRow, deletePlan, getAgent, getEntry, getPlan, getPlanByTeam, getPlanByThread, planThreadExists, replacePlan, ValidationError } from "../db";
|
|
2
|
+
import { createPlanRow, deletePlan, getAgent, getEntry, getPlan, getPlanByTeam, getPlanByThread, listPlansWithBoundOwner, planThreadExists, replacePlan, ValidationError } from "../db";
|
|
3
3
|
import { emitRelayEvent } from "../events";
|
|
4
4
|
import { cleanString, cleanStringArray, optionalEnum } from "../validation";
|
|
5
5
|
import type { AuthContext } from "./auth-context";
|
|
6
|
-
import type { Plan, PlanEdge, PlanNode, PlanOwnerRef, PlanStatus } from "../types";
|
|
6
|
+
import type { AgentCard, Plan, PlanEdge, PlanNode, PlanOwnerRef, PlanStatus } from "../types";
|
|
7
7
|
|
|
8
8
|
export const PLAN_STATUSES = ["pending", "in-progress", "landed", "blocked"] as const;
|
|
9
9
|
|
|
@@ -86,10 +86,22 @@ function cleanBlackboardRefs(value: unknown, planTeamId?: string): string[] | un
|
|
|
86
86
|
return refs;
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
-
function cleanStatusSource(value: unknown): "
|
|
89
|
+
function cleanStatusSource(value: unknown): PlanNode["statusSource"] {
|
|
90
90
|
const statusSource = optionalEnum(value, "statusSource", ["manual", "bound"] as const, "manual");
|
|
91
|
-
|
|
92
|
-
|
|
91
|
+
return statusSource;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function boundStatusForAgent(agent: Pick<AgentCard, "status"> | null | undefined): PlanStatus | undefined {
|
|
95
|
+
if (!agent) return undefined;
|
|
96
|
+
if (agent.status === "busy") return "in-progress";
|
|
97
|
+
return "pending";
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function applyBoundStatus(node: PlanNode): PlanNode {
|
|
101
|
+
if (node.statusSource !== "bound") return node;
|
|
102
|
+
const agentId = node.ownerRef?.agentId;
|
|
103
|
+
if (!agentId) throw new ValidationError("bound plan nodes require ownerRef.agentId");
|
|
104
|
+
return { ...node, status: boundStatusForAgent(getAgent(agentId)) ?? "pending" };
|
|
93
105
|
}
|
|
94
106
|
|
|
95
107
|
function cleanNode(input: PlanNodeInput, planTeamId?: string): PlanNode {
|
|
@@ -99,17 +111,18 @@ function cleanNode(input: PlanNodeInput, planTeamId?: string): PlanNode {
|
|
|
99
111
|
const lane = cleanString(input.lane, "node.lane", { max: 120 });
|
|
100
112
|
const refs = cleanRefs(input.refs);
|
|
101
113
|
const blackboardRefs = cleanBlackboardRefs(input.blackboardRefs, planTeamId);
|
|
102
|
-
|
|
114
|
+
const statusSource = cleanStatusSource(input.statusSource);
|
|
115
|
+
return applyBoundStatus({
|
|
103
116
|
id: cleanString(input.id, "node.id", { required: true, max: 120 })!,
|
|
104
117
|
label: cleanString(input.label, "node.label", { required: true, max: 240 })!,
|
|
105
118
|
...(scopeBlurb ? { scopeBlurb } : {}),
|
|
106
119
|
...(ownerRef ? { ownerRef } : {}),
|
|
107
120
|
...(lane ? { lane } : {}),
|
|
108
121
|
status: optionalEnum(input.status, "node.status", PLAN_STATUSES, "pending") as PlanStatus,
|
|
109
|
-
statusSource
|
|
122
|
+
statusSource,
|
|
110
123
|
...(refs ? { refs } : {}),
|
|
111
124
|
...(blackboardRefs ? { blackboardRefs } : {}),
|
|
112
|
-
};
|
|
125
|
+
});
|
|
113
126
|
}
|
|
114
127
|
|
|
115
128
|
function cleanNodes(value: unknown, planTeamId?: string): PlanNode[] {
|
|
@@ -233,8 +246,8 @@ export function updatePlanNode(planId: string, nodeId: string, patch: PlanNodePa
|
|
|
233
246
|
const plan = existingPlan(planId);
|
|
234
247
|
const existing = plan.nodes.find((node) => node.id === nodeId);
|
|
235
248
|
if (!existing) throw new ValidationError(`node ${nodeId} not found`);
|
|
236
|
-
|
|
237
|
-
const
|
|
249
|
+
const statusSource = patch.statusSource !== undefined ? cleanStatusSource(patch.statusSource) : existing.statusSource;
|
|
250
|
+
const base: PlanNode = {
|
|
238
251
|
...existing,
|
|
239
252
|
...(patch.label !== undefined ? { label: cleanString(patch.label, "label", { required: true, max: 240 })! } : {}),
|
|
240
253
|
...(patch.scopeBlurb !== undefined ? optionalProp("scopeBlurb", cleanString(patch.scopeBlurb, "scopeBlurb", { max: 2000 })) : {}),
|
|
@@ -243,8 +256,9 @@ export function updatePlanNode(planId: string, nodeId: string, patch: PlanNodePa
|
|
|
243
256
|
...(patch.status !== undefined ? { status: optionalEnum(patch.status, "status", PLAN_STATUSES) as PlanStatus } : {}),
|
|
244
257
|
...(patch.refs !== undefined ? optionalProp("refs", cleanRefs(patch.refs)) : {}),
|
|
245
258
|
...(patch.blackboardRefs !== undefined ? optionalProp("blackboardRefs", cleanBlackboardRefs(patch.blackboardRefs, plan.teamId)) : {}),
|
|
246
|
-
statusSource
|
|
259
|
+
statusSource,
|
|
247
260
|
};
|
|
261
|
+
const next = applyBoundStatus(base);
|
|
248
262
|
const updated = replacePlan({ ...plan, nodes: plan.nodes.map((node) => node.id === nodeId ? next : node) }, actorId(ctx));
|
|
249
263
|
emitPlan("plan.changed", updated, actorId(ctx));
|
|
250
264
|
return updated;
|
|
@@ -294,6 +308,30 @@ export function deletePlanGraph(planId: string, ctx: AuthContext): { id: string;
|
|
|
294
308
|
return { id: plan.id, deleted: true };
|
|
295
309
|
}
|
|
296
310
|
|
|
311
|
+
export function updateBoundPlanNodesForAgent(agentId: string, status: PlanStatus, actor = "server"): Plan[] {
|
|
312
|
+
const updated: Plan[] = [];
|
|
313
|
+
for (const plan of listPlansWithBoundOwner(agentId)) {
|
|
314
|
+
let changed = false;
|
|
315
|
+
const nodes = plan.nodes.map((node) => {
|
|
316
|
+
if (node.statusSource !== "bound" || node.ownerRef?.agentId !== agentId) return node;
|
|
317
|
+
if (node.status === "landed") return node;
|
|
318
|
+
if (node.status === status) return node;
|
|
319
|
+
changed = true;
|
|
320
|
+
return { ...node, status };
|
|
321
|
+
});
|
|
322
|
+
if (!changed) continue;
|
|
323
|
+
const next = replacePlan({ ...plan, nodes }, actor);
|
|
324
|
+
emitPlan("plan.changed", next, actor);
|
|
325
|
+
updated.push(next);
|
|
326
|
+
}
|
|
327
|
+
return updated;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
export function updateBoundPlanNodesFromAgent(agentId: string, actor = "server"): Plan[] {
|
|
331
|
+
const status = boundStatusForAgent(getAgent(agentId));
|
|
332
|
+
return status ? updateBoundPlanNodesForAgent(agentId, status, actor) : [];
|
|
333
|
+
}
|
|
334
|
+
|
|
297
335
|
export function getPlanGraph(input: { planId?: unknown; threadId?: unknown; teamId?: unknown; channel?: unknown }): Plan | null {
|
|
298
336
|
const planId = cleanString(input.planId, "planId", { max: 120 });
|
|
299
337
|
if (planId) return getPlan(planId);
|