agent-relay-server 0.44.0 → 0.46.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 +92 -1
- package/package.json +2 -2
- package/public/assets/display-ConJ9cJB.js.map +1 -1
- package/src/db/blackboard.ts +171 -0
- package/src/db/index.ts +1 -0
- package/src/db/migrations.ts +17 -0
- package/src/db/schema.ts +15 -0
- package/src/mcp-blackboard-tools.ts +131 -0
- package/src/mcp-plan-tools.ts +2 -0
- package/src/mcp.ts +3 -0
- package/src/routes/blackboard.ts +54 -0
- package/src/routes/index.ts +3 -0
- package/src/security.ts +2 -0
- package/src/services/plan-graph.ts +33 -13
- package/src/token-db.ts +3 -3
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { getDb, ValidationError } from "./connection.ts";
|
|
3
|
+
import { parseJson } from "../utils";
|
|
4
|
+
import type { BlackboardEntry, BlackboardEntryKind, BlackboardEntryStatus } from "../types";
|
|
5
|
+
|
|
6
|
+
const BLACKBOARD_KINDS = ["decision", "fact", "ruled_out"] as const;
|
|
7
|
+
const BLACKBOARD_STATUSES = ["active", "superseded", "retracted"] as const;
|
|
8
|
+
|
|
9
|
+
interface BlackboardEntryRow {
|
|
10
|
+
id: string;
|
|
11
|
+
team_id: string;
|
|
12
|
+
kind: BlackboardEntryKind;
|
|
13
|
+
title: string;
|
|
14
|
+
body: string | null;
|
|
15
|
+
status: BlackboardEntryStatus;
|
|
16
|
+
supersedes: string | null;
|
|
17
|
+
tags: string | null;
|
|
18
|
+
author_id: string;
|
|
19
|
+
created_at: number;
|
|
20
|
+
updated_at: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface WriteBlackboardEntryInput {
|
|
24
|
+
teamId: string;
|
|
25
|
+
kind: BlackboardEntryKind;
|
|
26
|
+
title: string;
|
|
27
|
+
body?: string;
|
|
28
|
+
tags?: string[];
|
|
29
|
+
authorId: string;
|
|
30
|
+
supersedes?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function blackboardId(): string {
|
|
34
|
+
return `bb_${randomUUID().replace(/-/g, "").slice(0, 16)}`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function rowToEntry(row: BlackboardEntryRow): BlackboardEntry {
|
|
38
|
+
const tags = parseJson<string[]>(row.tags, []).filter((tag) => typeof tag === "string");
|
|
39
|
+
return {
|
|
40
|
+
id: row.id,
|
|
41
|
+
teamId: row.team_id,
|
|
42
|
+
kind: row.kind,
|
|
43
|
+
title: row.title,
|
|
44
|
+
...(row.body ? { body: row.body } : {}),
|
|
45
|
+
status: row.status,
|
|
46
|
+
...(row.supersedes ? { supersedes: row.supersedes } : {}),
|
|
47
|
+
tags,
|
|
48
|
+
authorId: row.author_id,
|
|
49
|
+
createdAt: row.created_at,
|
|
50
|
+
updatedAt: row.updated_at,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function assertKind(kind: BlackboardEntryKind): void {
|
|
55
|
+
if (!BLACKBOARD_KINDS.includes(kind)) {
|
|
56
|
+
throw new ValidationError(`kind must be one of: ${BLACKBOARD_KINDS.join(", ")}`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function assertStatus(status: BlackboardEntryStatus): void {
|
|
61
|
+
if (!BLACKBOARD_STATUSES.includes(status)) {
|
|
62
|
+
throw new ValidationError(`status must be one of: ${BLACKBOARD_STATUSES.join(", ")}`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function escapeLike(value: string): string {
|
|
67
|
+
return value.replace(/[\\%_]/g, (char) => `\\${char}`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function writeEntry(input: WriteBlackboardEntryInput): BlackboardEntry {
|
|
71
|
+
assertKind(input.kind);
|
|
72
|
+
const id = blackboardId();
|
|
73
|
+
const now = Date.now();
|
|
74
|
+
getDb().transaction(() => {
|
|
75
|
+
if (input.supersedes) {
|
|
76
|
+
const previous = getEntry(input.supersedes);
|
|
77
|
+
if (!previous) throw new ValidationError(`blackboard entry ${input.supersedes} not found`);
|
|
78
|
+
if (previous.teamId !== input.teamId) throw new ValidationError("superseded entry belongs to another team");
|
|
79
|
+
if (previous.status === "retracted") throw new ValidationError("cannot supersede a retracted entry");
|
|
80
|
+
getDb().query("UPDATE blackboard_entries SET status = 'superseded', updated_at = ? WHERE id = ?")
|
|
81
|
+
.run(now, input.supersedes);
|
|
82
|
+
}
|
|
83
|
+
getDb().query(`
|
|
84
|
+
INSERT INTO blackboard_entries (id, team_id, kind, title, body, status, supersedes, tags, author_id, created_at, updated_at)
|
|
85
|
+
VALUES (?, ?, ?, ?, ?, 'active', ?, ?, ?, ?, ?)
|
|
86
|
+
`).run(
|
|
87
|
+
id,
|
|
88
|
+
input.teamId,
|
|
89
|
+
input.kind,
|
|
90
|
+
input.title,
|
|
91
|
+
input.body ?? null,
|
|
92
|
+
input.supersedes ?? null,
|
|
93
|
+
input.tags?.length ? JSON.stringify([...new Set(input.tags)]) : null,
|
|
94
|
+
input.authorId,
|
|
95
|
+
now,
|
|
96
|
+
now,
|
|
97
|
+
);
|
|
98
|
+
})();
|
|
99
|
+
return getEntry(id)!;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function getEntry(id: string): BlackboardEntry | null {
|
|
103
|
+
const row = getDb().query("SELECT * FROM blackboard_entries WHERE id = ?").get(id) as BlackboardEntryRow | undefined;
|
|
104
|
+
return row ? rowToEntry(row) : null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function getEntryChain(id: string): BlackboardEntry[] {
|
|
108
|
+
const chain: BlackboardEntry[] = [];
|
|
109
|
+
const seen = new Set<string>();
|
|
110
|
+
let current = getEntry(id);
|
|
111
|
+
while (current && !seen.has(current.id)) {
|
|
112
|
+
chain.push(current);
|
|
113
|
+
seen.add(current.id);
|
|
114
|
+
current = current.supersedes ? getEntry(current.supersedes) : null;
|
|
115
|
+
}
|
|
116
|
+
return chain;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function listEntries(
|
|
120
|
+
teamId: string,
|
|
121
|
+
opts: { kind?: BlackboardEntryKind; includeInactive?: boolean } = {},
|
|
122
|
+
): BlackboardEntry[] {
|
|
123
|
+
if (opts.kind) assertKind(opts.kind);
|
|
124
|
+
const conditions = ["team_id = ?"];
|
|
125
|
+
const params: string[] = [teamId];
|
|
126
|
+
if (opts.kind) {
|
|
127
|
+
conditions.push("kind = ?");
|
|
128
|
+
params.push(opts.kind);
|
|
129
|
+
}
|
|
130
|
+
if (!opts.includeInactive) conditions.push("status = 'active'");
|
|
131
|
+
const rows = getDb().query(`
|
|
132
|
+
SELECT * FROM blackboard_entries
|
|
133
|
+
WHERE ${conditions.join(" AND ")}
|
|
134
|
+
ORDER BY updated_at DESC, created_at DESC, id DESC
|
|
135
|
+
`).all(...params) as BlackboardEntryRow[];
|
|
136
|
+
return rows.map(rowToEntry);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function searchEntries(
|
|
140
|
+
teamId: string,
|
|
141
|
+
query: string,
|
|
142
|
+
opts: { kind?: BlackboardEntryKind } = {},
|
|
143
|
+
): BlackboardEntry[] {
|
|
144
|
+
if (opts.kind) assertKind(opts.kind);
|
|
145
|
+
const like = `%${escapeLike(query)}%`;
|
|
146
|
+
const conditions = [
|
|
147
|
+
"team_id = ?",
|
|
148
|
+
"status = 'active'",
|
|
149
|
+
"(title LIKE ? ESCAPE '\\' OR body LIKE ? ESCAPE '\\' OR tags LIKE ? ESCAPE '\\')",
|
|
150
|
+
];
|
|
151
|
+
const params: string[] = [teamId, like, like, like];
|
|
152
|
+
if (opts.kind) {
|
|
153
|
+
conditions.push("kind = ?");
|
|
154
|
+
params.push(opts.kind);
|
|
155
|
+
}
|
|
156
|
+
const rows = getDb().query(`
|
|
157
|
+
SELECT * FROM blackboard_entries
|
|
158
|
+
WHERE ${conditions.join(" AND ")}
|
|
159
|
+
ORDER BY updated_at DESC, created_at DESC, id DESC
|
|
160
|
+
`).all(...params) as BlackboardEntryRow[];
|
|
161
|
+
return rows.map(rowToEntry);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function retractEntry(id: string): BlackboardEntry | null {
|
|
165
|
+
const existing = getEntry(id);
|
|
166
|
+
if (!existing) return null;
|
|
167
|
+
assertStatus("retracted");
|
|
168
|
+
getDb().query("UPDATE blackboard_entries SET status = 'retracted', updated_at = ? WHERE id = ?")
|
|
169
|
+
.run(Date.now(), id);
|
|
170
|
+
return getEntry(id);
|
|
171
|
+
}
|
package/src/db/index.ts
CHANGED
package/src/db/migrations.ts
CHANGED
|
@@ -302,6 +302,23 @@ export function applyMigrations(): void {
|
|
|
302
302
|
`);
|
|
303
303
|
getDb().run("CREATE INDEX IF NOT EXISTS idx_teams_coordinator ON teams(coordinator_id)");
|
|
304
304
|
getDb().run("CREATE INDEX IF NOT EXISTS idx_teams_status ON teams(status)");
|
|
305
|
+
getDb().run(`
|
|
306
|
+
CREATE TABLE IF NOT EXISTS blackboard_entries (
|
|
307
|
+
id TEXT PRIMARY KEY,
|
|
308
|
+
team_id TEXT NOT NULL,
|
|
309
|
+
kind TEXT NOT NULL,
|
|
310
|
+
title TEXT NOT NULL,
|
|
311
|
+
body TEXT,
|
|
312
|
+
status TEXT NOT NULL DEFAULT 'active',
|
|
313
|
+
supersedes TEXT,
|
|
314
|
+
tags TEXT,
|
|
315
|
+
author_id TEXT NOT NULL,
|
|
316
|
+
created_at INTEGER NOT NULL,
|
|
317
|
+
updated_at INTEGER NOT NULL
|
|
318
|
+
)
|
|
319
|
+
`);
|
|
320
|
+
getDb().run("CREATE INDEX IF NOT EXISTS idx_blackboard_team ON blackboard_entries(team_id)");
|
|
321
|
+
getDb().run("CREATE INDEX IF NOT EXISTS idx_blackboard_team_kind ON blackboard_entries(team_id, kind)");
|
|
305
322
|
getDb().run(`
|
|
306
323
|
CREATE TABLE IF NOT EXISTS plans (
|
|
307
324
|
id TEXT PRIMARY KEY, thread_id INTEGER NOT NULL, team_id TEXT, channel TEXT, version INTEGER NOT NULL DEFAULT 1,
|
package/src/db/schema.ts
CHANGED
|
@@ -171,6 +171,21 @@ export function initDb(path: string = "agent-relay.db"): Database {
|
|
|
171
171
|
);
|
|
172
172
|
CREATE INDEX IF NOT EXISTS idx_teams_coordinator ON teams(coordinator_id);
|
|
173
173
|
CREATE INDEX IF NOT EXISTS idx_teams_status ON teams(status);
|
|
174
|
+
CREATE TABLE IF NOT EXISTS blackboard_entries (
|
|
175
|
+
id TEXT PRIMARY KEY,
|
|
176
|
+
team_id TEXT NOT NULL,
|
|
177
|
+
kind TEXT NOT NULL,
|
|
178
|
+
title TEXT NOT NULL,
|
|
179
|
+
body TEXT,
|
|
180
|
+
status TEXT NOT NULL DEFAULT 'active',
|
|
181
|
+
supersedes TEXT,
|
|
182
|
+
tags TEXT,
|
|
183
|
+
author_id TEXT NOT NULL,
|
|
184
|
+
created_at INTEGER NOT NULL,
|
|
185
|
+
updated_at INTEGER NOT NULL
|
|
186
|
+
);
|
|
187
|
+
CREATE INDEX IF NOT EXISTS idx_blackboard_team ON blackboard_entries(team_id);
|
|
188
|
+
CREATE INDEX IF NOT EXISTS idx_blackboard_team_kind ON blackboard_entries(team_id, kind);
|
|
174
189
|
CREATE TABLE IF NOT EXISTS plans (
|
|
175
190
|
id TEXT PRIMARY KEY, thread_id INTEGER NOT NULL, team_id TEXT, channel TEXT, version INTEGER NOT NULL DEFAULT 1,
|
|
176
191
|
title TEXT, objective TEXT, nodes TEXT NOT NULL DEFAULT '[]', edges TEXT NOT NULL DEFAULT '[]',
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { getAgent, getEntry, getEntryChain, listEntries, retractEntry, searchEntries, ValidationError, writeEntry } from "./db";
|
|
2
|
+
import { McpNotFoundError } from "./mcp-errors";
|
|
3
|
+
import { authContextFromMcp } from "./services/auth-context";
|
|
4
|
+
import { cleanString, cleanStringArray, optionalEnum } from "./validation";
|
|
5
|
+
import type { BlackboardEntryKind } from "./types";
|
|
6
|
+
|
|
7
|
+
const BLACKBOARD_KINDS = ["decision", "fact", "ruled_out"] as const;
|
|
8
|
+
|
|
9
|
+
export const BLACKBOARD_TOOLS = [
|
|
10
|
+
{
|
|
11
|
+
name: "relay_blackboard_write",
|
|
12
|
+
description: "Create a team-scoped blackboard entry. The caller's token identity determines the team and author.",
|
|
13
|
+
requiredScopes: ["blackboard:write"],
|
|
14
|
+
inputSchema: {
|
|
15
|
+
type: "object",
|
|
16
|
+
properties: {
|
|
17
|
+
kind: { type: "string", enum: BLACKBOARD_KINDS },
|
|
18
|
+
title: { type: "string" },
|
|
19
|
+
body: { type: "string" },
|
|
20
|
+
tags: { type: "array", items: { type: "string" } },
|
|
21
|
+
supersedes: { type: "string" },
|
|
22
|
+
},
|
|
23
|
+
required: ["kind", "title"],
|
|
24
|
+
additionalProperties: false,
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
name: "relay_blackboard_get",
|
|
29
|
+
description: "Fetch one blackboard entry and its supersession chain for the caller's team.",
|
|
30
|
+
requiredScopes: ["blackboard:read"],
|
|
31
|
+
inputSchema: {
|
|
32
|
+
type: "object",
|
|
33
|
+
properties: { id: { type: "string" } },
|
|
34
|
+
required: ["id"],
|
|
35
|
+
additionalProperties: false,
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
name: "relay_blackboard_read",
|
|
40
|
+
description: "Read the caller team's active blackboard working set, optionally filtered by kind.",
|
|
41
|
+
requiredScopes: ["blackboard:read"],
|
|
42
|
+
inputSchema: {
|
|
43
|
+
type: "object",
|
|
44
|
+
properties: { kind: { type: "string", enum: BLACKBOARD_KINDS } },
|
|
45
|
+
additionalProperties: false,
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
name: "relay_blackboard_search",
|
|
50
|
+
description: "Search active blackboard entries for the caller's team by title, body, or tags.",
|
|
51
|
+
requiredScopes: ["blackboard:read"],
|
|
52
|
+
inputSchema: {
|
|
53
|
+
type: "object",
|
|
54
|
+
properties: {
|
|
55
|
+
query: { type: "string" },
|
|
56
|
+
kind: { type: "string", enum: BLACKBOARD_KINDS },
|
|
57
|
+
},
|
|
58
|
+
required: ["query"],
|
|
59
|
+
additionalProperties: false,
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
name: "relay_blackboard_retract",
|
|
64
|
+
description: "Retract a blackboard entry for the caller's team without deleting its ledger row.",
|
|
65
|
+
requiredScopes: ["blackboard:write"],
|
|
66
|
+
inputSchema: {
|
|
67
|
+
type: "object",
|
|
68
|
+
properties: { id: { type: "string" } },
|
|
69
|
+
required: ["id"],
|
|
70
|
+
additionalProperties: false,
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
] satisfies Array<{ name: string; description: string; requiredScopes: string[]; inputSchema: Record<string, unknown> }>;
|
|
74
|
+
|
|
75
|
+
type McpAuthLike = Parameters<typeof authContextFromMcp>[0];
|
|
76
|
+
|
|
77
|
+
function callerTeam(auth: McpAuthLike): { agentId: string; teamId: string } {
|
|
78
|
+
const ctx = authContextFromMcp(auth);
|
|
79
|
+
if (!ctx.callerAgentId) throw new ValidationError("blackboard operations require a single caller agent identity");
|
|
80
|
+
const agent = getAgent(ctx.callerAgentId);
|
|
81
|
+
if (!agent?.teamId) throw new ValidationError("caller has no team");
|
|
82
|
+
return { agentId: ctx.callerAgentId, teamId: agent.teamId };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function cleanKind(value: unknown): BlackboardEntryKind | undefined {
|
|
86
|
+
return optionalEnum(value, "kind", BLACKBOARD_KINDS) as BlackboardEntryKind | undefined;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function ensureSameTeam(entryId: string, teamId: string): void {
|
|
90
|
+
const entry = getEntry(entryId);
|
|
91
|
+
if (!entry) throw new McpNotFoundError("blackboard entry not found");
|
|
92
|
+
if (entry.teamId !== teamId) throw new McpNotFoundError("blackboard entry not found");
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function relayBlackboardTool(auth: McpAuthLike, name: string, args: Record<string, unknown>): Record<string, unknown> {
|
|
96
|
+
const { agentId, teamId } = callerTeam(auth);
|
|
97
|
+
if (name === "relay_blackboard_write") {
|
|
98
|
+
const supersedes = cleanString(args.supersedes, "supersedes", { max: 120 });
|
|
99
|
+
const kind = cleanKind(args.kind);
|
|
100
|
+
if (!kind) throw new ValidationError("kind required");
|
|
101
|
+
if (supersedes) ensureSameTeam(supersedes, teamId);
|
|
102
|
+
return writeEntry({
|
|
103
|
+
teamId,
|
|
104
|
+
authorId: agentId,
|
|
105
|
+
kind,
|
|
106
|
+
title: cleanString(args.title, "title", { required: true, max: 240 })!,
|
|
107
|
+
body: cleanString(args.body, "body", { max: 16_000 }),
|
|
108
|
+
tags: cleanStringArray(args.tags, "tags", { itemMax: 80, maxItems: 32 }),
|
|
109
|
+
supersedes,
|
|
110
|
+
}) as unknown as Record<string, unknown>;
|
|
111
|
+
}
|
|
112
|
+
if (name === "relay_blackboard_get") {
|
|
113
|
+
const id = cleanString(args.id, "id", { required: true, max: 120 })!;
|
|
114
|
+
ensureSameTeam(id, teamId);
|
|
115
|
+
return { entry: getEntry(id), chain: getEntryChain(id) };
|
|
116
|
+
}
|
|
117
|
+
if (name === "relay_blackboard_read") {
|
|
118
|
+
return { entries: listEntries(teamId, { kind: cleanKind(args.kind) }) };
|
|
119
|
+
}
|
|
120
|
+
if (name === "relay_blackboard_search") {
|
|
121
|
+
return { entries: searchEntries(teamId, cleanString(args.query, "query", { required: true, max: 240 })!, { kind: cleanKind(args.kind) }) };
|
|
122
|
+
}
|
|
123
|
+
if (name === "relay_blackboard_retract") {
|
|
124
|
+
const id = cleanString(args.id, "id", { required: true, max: 120 })!;
|
|
125
|
+
ensureSameTeam(id, teamId);
|
|
126
|
+
const entry = retractEntry(id);
|
|
127
|
+
if (!entry) throw new McpNotFoundError("blackboard entry not found");
|
|
128
|
+
return entry as unknown as Record<string, unknown>;
|
|
129
|
+
}
|
|
130
|
+
throw new ValidationError(`unknown tool: ${name}`);
|
|
131
|
+
}
|
package/src/mcp-plan-tools.ts
CHANGED
|
@@ -28,6 +28,7 @@ const PLAN_NODE_SCHEMA = {
|
|
|
28
28
|
lane: { type: "string" },
|
|
29
29
|
status: { type: "string", enum: PLAN_STATUSES },
|
|
30
30
|
statusSource: { type: "string", enum: ["manual", "bound"] },
|
|
31
|
+
blackboardRefs: { type: "array", items: { type: "string" } },
|
|
31
32
|
},
|
|
32
33
|
required: ["id", "label"],
|
|
33
34
|
additionalProperties: false,
|
|
@@ -75,6 +76,7 @@ export const PLAN_TOOLS = [
|
|
|
75
76
|
lane: { oneOf: [{ type: "string" }, { type: "null" }] },
|
|
76
77
|
status: { type: "string", enum: PLAN_STATUSES },
|
|
77
78
|
statusSource: { type: "string", enum: ["manual", "bound"] },
|
|
79
|
+
blackboardRefs: { oneOf: [{ type: "array", items: { type: "string" } }, { type: "null" }] },
|
|
78
80
|
},
|
|
79
81
|
required: ["planId", "nodeId"],
|
|
80
82
|
additionalProperties: false,
|
package/src/mcp.ts
CHANGED
|
@@ -48,6 +48,7 @@ import { sendMessageService } from "./services/send-message";
|
|
|
48
48
|
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
|
+
import { BLACKBOARD_TOOLS, relayBlackboardTool } from "./mcp-blackboard-tools";
|
|
51
52
|
import type { ActivityKind, ArtifactKind, ArtifactSensitivity, AttachmentRef, Command, SendMessageInput, Message, SpawnApprovalMode, SpawnProvider, WorkspaceAutoMergePolicy, WorkspaceMergeStrategy, WorkspaceMode, WorkspaceRecord } from "./types";
|
|
52
53
|
import { LAND_STRATEGIES, applyWorkspaceAction, waitForWorkspaceStatus, type WorkspaceAction } from "./workspace-actions";
|
|
53
54
|
import { AUTO_MERGE_POLICIES } from "./workspace-merge";
|
|
@@ -460,6 +461,7 @@ const TOOLS: ToolDefinition[] = [
|
|
|
460
461
|
},
|
|
461
462
|
...TEAM_TOOLS,
|
|
462
463
|
...PLAN_TOOLS,
|
|
464
|
+
...BLACKBOARD_TOOLS,
|
|
463
465
|
];
|
|
464
466
|
|
|
465
467
|
export async function postMcp(req: Request): Promise<Response> {
|
|
@@ -573,6 +575,7 @@ async function callTool(auth: McpAuthContext, params: unknown): Promise<Record<s
|
|
|
573
575
|
else if (name === "relay_workspace_land") result = relayWorkspaceMutation(auth, "merge", args);
|
|
574
576
|
else if (name.startsWith("relay_team_")) result = relayTeamTool(auth, name, args);
|
|
575
577
|
else if (name.startsWith("relay_plan_")) result = relayPlanTool(auth, name, args);
|
|
578
|
+
else if (name.startsWith("relay_blackboard_")) result = relayBlackboardTool(auth, name, args);
|
|
576
579
|
else throw new ValidationError(`unknown tool: ${name}`);
|
|
577
580
|
|
|
578
581
|
auditToolCall(auth, name, "ok", result);
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { getAgent, getEntry, getEntryChain, listEntries } from "../db";
|
|
2
|
+
import { authContextFromRequest } from "../services/auth-context";
|
|
3
|
+
import { cleanString, optionalEnum } from "../validation";
|
|
4
|
+
import { error, json, type Handler } from "./_shared";
|
|
5
|
+
import type { BlackboardEntryKind } from "../types";
|
|
6
|
+
|
|
7
|
+
const BLACKBOARD_KINDS = ["decision", "fact", "ruled_out"] as const;
|
|
8
|
+
|
|
9
|
+
function isAdminContext(ctx: ReturnType<typeof authContextFromRequest>): boolean {
|
|
10
|
+
return ctx.scopes.includes("*") || ctx.scopes.includes("admin:*");
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function routeTeamId(req: Request): string | Response {
|
|
14
|
+
const ctx = authContextFromRequest(req);
|
|
15
|
+
if (ctx.component && ctx.callerAgentId) {
|
|
16
|
+
const callerId = ctx.callerAgentId;
|
|
17
|
+
const agent = getAgent(callerId);
|
|
18
|
+
return agent?.teamId ?? error("caller has no team", 403);
|
|
19
|
+
}
|
|
20
|
+
if (ctx.component && !isAdminContext(ctx)) return error("caller has no team", 403);
|
|
21
|
+
const teamId = cleanString(new URL(req.url).searchParams.get("team") ?? undefined, "team", { required: true, max: 120 });
|
|
22
|
+
return teamId!;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function routeKind(req: Request): BlackboardEntryKind | undefined {
|
|
26
|
+
return optionalEnum(new URL(req.url).searchParams.get("kind") ?? undefined, "kind", BLACKBOARD_KINDS) as BlackboardEntryKind | undefined;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export const getBlackboardRoute: Handler = (req) => {
|
|
30
|
+
try {
|
|
31
|
+
const teamId = routeTeamId(req);
|
|
32
|
+
if (teamId instanceof Response) return teamId;
|
|
33
|
+
return json({ entries: listEntries(teamId, { kind: routeKind(req) }) });
|
|
34
|
+
} catch (err) {
|
|
35
|
+
return error(err instanceof Error ? err.message : "blackboard route failed");
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export const getBlackboardEntryRoute: Handler = (req, params) => {
|
|
40
|
+
try {
|
|
41
|
+
const id = cleanString(params.id, "id", { required: true, max: 120 })!;
|
|
42
|
+
const entry = getEntry(id);
|
|
43
|
+
if (!entry) return error("blackboard entry not found", 404);
|
|
44
|
+
const ctx = authContextFromRequest(req);
|
|
45
|
+
if (ctx.component && ctx.callerAgentId) {
|
|
46
|
+
const agent = getAgent(ctx.callerAgentId);
|
|
47
|
+
if (!agent?.teamId || agent.teamId !== entry.teamId) return error("blackboard entry not found", 404);
|
|
48
|
+
}
|
|
49
|
+
if (ctx.component && !ctx.callerAgentId && !isAdminContext(ctx)) return error("blackboard entry not found", 404);
|
|
50
|
+
return json({ entry, chain: getEntryChain(id) });
|
|
51
|
+
} catch (err) {
|
|
52
|
+
return error(err instanceof Error ? err.message : "blackboard route failed");
|
|
53
|
+
}
|
|
54
|
+
};
|
package/src/routes/index.ts
CHANGED
|
@@ -27,6 +27,7 @@ import { getInsightsConfigRoute, getInsightsObservationsRoute, postInsightsObser
|
|
|
27
27
|
import { getOrchestratorDirectories, getOrchestratorFileRead, getOrchestratorFileStat, getOrchestratorFilesList, getOrchestratorLogs, getOrchestratorProviders, getOrchestratorSessions, getOrchestratorTerminal, getOrchestratorVersion, getOrchestratorWorkspaceProbe, postOrchestratorCreateDirectory, postOrchestratorTerminalInput, postOrchestratorTerminalResize } from "./orchestrator-proxy";
|
|
28
28
|
import { getPairById, getPairs, postAcceptPair, postHangupPair, postPair, postPairMessage, postRejectPair } from "./pairs";
|
|
29
29
|
import { getPlanByIdRoute, getPlansRoute } from "./plans";
|
|
30
|
+
import { getBlackboardEntryRoute, getBlackboardRoute } from "./blackboard";
|
|
30
31
|
import { getRecipeByName, getRecipeInstanceArtifacts, getRecipeInstanceById, getRecipeInstances, getRecipes, postRecipeInstanceArtifacts, postRecipeStart, postRecipeStop } from "./recipes";
|
|
31
32
|
import { getStewardConfigRoute, getWorkspaceConfigRoute, putStewardConfigRoute, putWorkspaceConfigRoute } from "./steward";
|
|
32
33
|
import { getTaskById, getTaskEvents, getTasks, patchTaskStatus, postClaimTask, postRenewTaskClaim } from "./tasks";
|
|
@@ -267,6 +268,8 @@ const routes: Route[] = [
|
|
|
267
268
|
route("POST", "/api/pairs/:id/messages", postPairMessage),
|
|
268
269
|
route("GET", "/api/plans", getPlansRoute),
|
|
269
270
|
route("GET", "/api/plans/:id", getPlanByIdRoute),
|
|
271
|
+
route("GET", "/api/blackboard", getBlackboardRoute),
|
|
272
|
+
route("GET", "/api/blackboard/:id", getBlackboardEntryRoute),
|
|
270
273
|
|
|
271
274
|
route("GET", "/api/spec", getApiSpec),
|
|
272
275
|
route("GET", "/api/docs", getApiDocs),
|
package/src/security.ts
CHANGED
|
@@ -205,6 +205,7 @@ export function requiredScopeFor(method: string, pathname: string): string | nul
|
|
|
205
205
|
if (pathname.startsWith("/api/tasks")) return method === "GET" ? "task:read" : "task:write";
|
|
206
206
|
if (pathname.startsWith("/api/pairs")) return method === "GET" ? "pairs:read" : "pairs:write";
|
|
207
207
|
if (pathname.startsWith("/api/teams")) return method === "GET" ? "teams:read" : "teams:write";
|
|
208
|
+
if (pathname.startsWith("/api/blackboard")) return method === "GET" ? "blackboard:read" : "blackboard:write";
|
|
208
209
|
// Insights config (the feature toggle) stays admin-only via the default; only the
|
|
209
210
|
// mechanical observation feed is writable by lower-privilege callers.
|
|
210
211
|
if (pathname === "/api/insights/observations") return method === "GET" ? "insights:read" : "insights:write";
|
|
@@ -280,6 +281,7 @@ export function requiredComponentScopeFor(method: string, pathname: string): str
|
|
|
280
281
|
if (pathname.startsWith("/api/orchestrators")) return method === "GET" ? "agent:read" : "command:write";
|
|
281
282
|
if (pathname.startsWith("/api/plans")) return method === "GET" ? "plans:read" : "plans:write";
|
|
282
283
|
if (pathname.startsWith("/api/teams")) return method === "GET" ? "teams:read" : "teams:write";
|
|
284
|
+
if (pathname.startsWith("/api/blackboard")) return method === "GET" ? "blackboard:read" : "blackboard:write";
|
|
283
285
|
// The Runner posts the #184 context-gathering signal here (source:"server") via its
|
|
284
286
|
// provider token. Without this case the path fell through to the system:admin default
|
|
285
287
|
// below and every observation was 403-dropped — the whole Insights feed stayed empty.
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { isRecord } from "agent-relay-sdk";
|
|
2
|
-
import { createPlanRow, deletePlan, getAgent, getPlan, getPlanByTeam, getPlanByThread, planThreadExists, replacePlan, ValidationError } from "../db";
|
|
2
|
+
import { createPlanRow, deletePlan, getAgent, getEntry, getPlan, getPlanByTeam, getPlanByThread, planThreadExists, replacePlan, ValidationError } from "../db";
|
|
3
3
|
import { emitRelayEvent } from "../events";
|
|
4
|
-
import { cleanString, optionalEnum } from "../validation";
|
|
4
|
+
import { cleanString, cleanStringArray, optionalEnum } from "../validation";
|
|
5
5
|
import type { AuthContext } from "./auth-context";
|
|
6
6
|
import type { Plan, PlanEdge, PlanNode, PlanOwnerRef, PlanStatus } from "../types";
|
|
7
7
|
|
|
@@ -16,6 +16,7 @@ export interface PlanNodeInput {
|
|
|
16
16
|
status?: unknown;
|
|
17
17
|
statusSource?: unknown;
|
|
18
18
|
refs?: unknown;
|
|
19
|
+
blackboardRefs?: unknown;
|
|
19
20
|
}
|
|
20
21
|
|
|
21
22
|
export interface PlanNodePatch {
|
|
@@ -26,6 +27,7 @@ export interface PlanNodePatch {
|
|
|
26
27
|
status?: unknown;
|
|
27
28
|
statusSource?: unknown;
|
|
28
29
|
refs?: unknown;
|
|
30
|
+
blackboardRefs?: unknown;
|
|
29
31
|
}
|
|
30
32
|
|
|
31
33
|
export interface PlanCreateInput {
|
|
@@ -73,30 +75,47 @@ function cleanRefs(value: unknown): PlanNode["refs"] | undefined {
|
|
|
73
75
|
throw new ValidationError("refs are populated by live binding in slice c");
|
|
74
76
|
}
|
|
75
77
|
|
|
78
|
+
function cleanBlackboardRefs(value: unknown, planTeamId?: string): string[] | undefined {
|
|
79
|
+
const refs = cleanStringArray(value, "node.blackboardRefs", { itemMax: 120, maxItems: 100 });
|
|
80
|
+
if (!refs?.length) return refs;
|
|
81
|
+
if (!planTeamId) throw new ValidationError("plan has no team; cannot reference blackboard entries");
|
|
82
|
+
for (const ref of refs) {
|
|
83
|
+
const entry = getEntry(ref);
|
|
84
|
+
if (!entry || entry.teamId !== planTeamId) throw new ValidationError(`blackboard entry not found for plan team: ${ref}`);
|
|
85
|
+
}
|
|
86
|
+
return refs;
|
|
87
|
+
}
|
|
88
|
+
|
|
76
89
|
function cleanStatusSource(value: unknown): "manual" {
|
|
77
90
|
const statusSource = optionalEnum(value, "statusSource", ["manual", "bound"] as const, "manual");
|
|
78
91
|
if (statusSource !== "manual") throw new ValidationError("statusSource must be manual in this slice");
|
|
79
92
|
return "manual";
|
|
80
93
|
}
|
|
81
94
|
|
|
82
|
-
function cleanNode(input: PlanNodeInput): PlanNode {
|
|
95
|
+
function cleanNode(input: PlanNodeInput, planTeamId?: string): PlanNode {
|
|
83
96
|
if (!isRecord(input)) throw new ValidationError("node must be an object");
|
|
97
|
+
const scopeBlurb = cleanString(input.scopeBlurb, "node.scopeBlurb", { max: 2000 });
|
|
98
|
+
const ownerRef = cleanOwnerRef(input.ownerRef);
|
|
99
|
+
const lane = cleanString(input.lane, "node.lane", { max: 120 });
|
|
100
|
+
const refs = cleanRefs(input.refs);
|
|
101
|
+
const blackboardRefs = cleanBlackboardRefs(input.blackboardRefs, planTeamId);
|
|
84
102
|
return {
|
|
85
103
|
id: cleanString(input.id, "node.id", { required: true, max: 120 })!,
|
|
86
104
|
label: cleanString(input.label, "node.label", { required: true, max: 240 })!,
|
|
87
|
-
...(
|
|
88
|
-
...(
|
|
89
|
-
...(
|
|
105
|
+
...(scopeBlurb ? { scopeBlurb } : {}),
|
|
106
|
+
...(ownerRef ? { ownerRef } : {}),
|
|
107
|
+
...(lane ? { lane } : {}),
|
|
90
108
|
status: optionalEnum(input.status, "node.status", PLAN_STATUSES, "pending") as PlanStatus,
|
|
91
109
|
statusSource: cleanStatusSource(input.statusSource),
|
|
92
|
-
...(
|
|
110
|
+
...(refs ? { refs } : {}),
|
|
111
|
+
...(blackboardRefs ? { blackboardRefs } : {}),
|
|
93
112
|
};
|
|
94
113
|
}
|
|
95
114
|
|
|
96
|
-
function cleanNodes(value: unknown): PlanNode[] {
|
|
115
|
+
function cleanNodes(value: unknown, planTeamId?: string): PlanNode[] {
|
|
97
116
|
if (value === undefined || value === null) return [];
|
|
98
117
|
if (!Array.isArray(value)) throw new ValidationError("nodes must be an array");
|
|
99
|
-
const nodes = value.map((item) => cleanNode(item as PlanNodeInput));
|
|
118
|
+
const nodes = value.map((item) => cleanNode(item as PlanNodeInput, planTeamId));
|
|
100
119
|
assertUnique(nodes.map((node) => node.id), "node id");
|
|
101
120
|
return nodes;
|
|
102
121
|
}
|
|
@@ -181,12 +200,12 @@ export function createPlan(input: PlanCreateInput, ctx: AuthContext): Plan {
|
|
|
181
200
|
const threadId = cleanThreadId(input.threadId);
|
|
182
201
|
if (!planThreadExists(threadId)) throw new ValidationError(`thread ${threadId} not found`);
|
|
183
202
|
const channel = cleanString(input.channel, "channel", { max: 120 });
|
|
184
|
-
const nodes = cleanNodes(input.nodes);
|
|
185
|
-
const edges = cleanEdges(input.edges);
|
|
186
|
-
validateEdges(nodes, edges);
|
|
187
203
|
const actor = actorId(ctx);
|
|
188
204
|
const explicitTeamId = cleanString(input.teamId, "teamId", { max: 120 });
|
|
189
205
|
const teamId = explicitTeamId ?? (input.teamId === undefined || input.teamId === null ? getAgent(actor)?.teamId : undefined);
|
|
206
|
+
const nodes = cleanNodes(input.nodes, teamId);
|
|
207
|
+
const edges = cleanEdges(input.edges);
|
|
208
|
+
validateEdges(nodes, edges);
|
|
190
209
|
const plan = createPlanRow({
|
|
191
210
|
threadId,
|
|
192
211
|
teamId,
|
|
@@ -203,7 +222,7 @@ export function createPlan(input: PlanCreateInput, ctx: AuthContext): Plan {
|
|
|
203
222
|
|
|
204
223
|
export function addPlanNode(planId: string, nodeInput: PlanNodeInput, ctx: AuthContext): Plan {
|
|
205
224
|
const plan = existingPlan(planId);
|
|
206
|
-
const node = cleanNode(nodeInput);
|
|
225
|
+
const node = cleanNode(nodeInput, plan.teamId);
|
|
207
226
|
if (plan.nodes.some((item) => item.id === node.id)) throw new ValidationError(`node ${node.id} already exists`);
|
|
208
227
|
const updated = replacePlan({ ...plan, nodes: [...plan.nodes, node] }, actorId(ctx));
|
|
209
228
|
emitPlan("plan.changed", updated, actorId(ctx));
|
|
@@ -223,6 +242,7 @@ export function updatePlanNode(planId: string, nodeId: string, patch: PlanNodePa
|
|
|
223
242
|
...(patch.lane !== undefined ? optionalProp("lane", cleanString(patch.lane, "lane", { max: 120 })) : {}),
|
|
224
243
|
...(patch.status !== undefined ? { status: optionalEnum(patch.status, "status", PLAN_STATUSES) as PlanStatus } : {}),
|
|
225
244
|
...(patch.refs !== undefined ? optionalProp("refs", cleanRefs(patch.refs)) : {}),
|
|
245
|
+
...(patch.blackboardRefs !== undefined ? optionalProp("blackboardRefs", cleanBlackboardRefs(patch.blackboardRefs, plan.teamId)) : {}),
|
|
226
246
|
statusSource: "manual",
|
|
227
247
|
};
|
|
228
248
|
const updated = replacePlan({ ...plan, nodes: plan.nodes.map((node) => node.id === nodeId ? next : node) }, actorId(ctx));
|
package/src/token-db.ts
CHANGED
|
@@ -57,7 +57,7 @@ const BUILT_IN_PROFILES: Array<Omit<TokenProfile, "createdAt" | "updatedAt">> =
|
|
|
57
57
|
name: "Provider Agent",
|
|
58
58
|
description: "Coding-agent runtime access for messages, commands, tasks, and scoped memory reads.",
|
|
59
59
|
role: "provider",
|
|
60
|
-
scope: ["agent:read", "agent:write", "message:read", "message:send", "command:read", "command:write", "task:read", "task:write", "memory:read", "artifact:read", "artifact:write", "mcp:use", "plans:read", "plans:write", "teams:read", "teams:write", "insights:write"],
|
|
60
|
+
scope: ["agent:read", "agent:write", "message:read", "message:send", "command:read", "command:write", "task:read", "task:write", "memory:read", "artifact:read", "artifact:write", "mcp:use", "plans:read", "plans:write", "teams:read", "teams:write", "blackboard:read", "blackboard:write", "insights:write"],
|
|
61
61
|
ttlSeconds: 24 * 60 * 60,
|
|
62
62
|
builtIn: true,
|
|
63
63
|
createdBy: "system",
|
|
@@ -67,7 +67,7 @@ const BUILT_IN_PROFILES: Array<Omit<TokenProfile, "createdAt" | "updatedAt">> =
|
|
|
67
67
|
name: "Provider Child Agent",
|
|
68
68
|
description: "Delegated child-agent runtime access, constrained to its parent and spawn request.",
|
|
69
69
|
role: "provider",
|
|
70
|
-
scope: ["agent:read", "agent:write", "message:read", "message:send", "command:read", "command:write", "task:read", "task:write", "memory:read", "artifact:read", "artifact:write", "mcp:use", "teams:read", "insights:write"],
|
|
70
|
+
scope: ["agent:read", "agent:write", "message:read", "message:send", "command:read", "command:write", "task:read", "task:write", "memory:read", "artifact:read", "artifact:write", "mcp:use", "teams:read", "blackboard:read", "blackboard:write", "insights:write"],
|
|
71
71
|
constraints: { canDelegate: false },
|
|
72
72
|
ttlSeconds: 2 * 60 * 60,
|
|
73
73
|
builtIn: true,
|
|
@@ -78,7 +78,7 @@ const BUILT_IN_PROFILES: Array<Omit<TokenProfile, "createdAt" | "updatedAt">> =
|
|
|
78
78
|
name: "Provider Interactive Agent",
|
|
79
79
|
description: "User-launched provider runtime access constrained to its own agent and cwd for long interactive sessions.",
|
|
80
80
|
role: "provider",
|
|
81
|
-
scope: ["agent:read", "agent:write", "message:read", "message:send", "command:read", "command:write", "task:read", "task:write", "memory:read", "artifact:read", "artifact:write", "mcp:use", "teams:read", "insights:write"],
|
|
81
|
+
scope: ["agent:read", "agent:write", "message:read", "message:send", "command:read", "command:write", "task:read", "task:write", "memory:read", "artifact:read", "artifact:write", "mcp:use", "teams:read", "blackboard:read", "blackboard:write", "insights:write"],
|
|
82
82
|
constraints: { terminalAttach: false, logsRead: false, canDelegate: false },
|
|
83
83
|
ttlSeconds: 30 * 24 * 60 * 60,
|
|
84
84
|
builtIn: true,
|