agent-relay-server 0.57.2 → 0.58.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 +151 -1
- package/package.json +2 -2
- package/public/assets/display-CketawEF.js.map +1 -1
- package/src/db/index.ts +2 -0
- package/src/db/project-entries.ts +270 -0
- package/src/db/projects.ts +304 -0
- package/src/db/schema.ts +50 -0
- package/src/db/teams.ts +13 -2
- package/src/mcp-project-tools.ts +352 -0
- package/src/mcp.ts +3 -0
- package/src/project-ingest.ts +186 -0
- package/src/routes/index.ts +5 -0
- package/src/routes/projects.ts +55 -0
- package/src/runtime-tokens.ts +6 -0
- package/src/security.ts +4 -0
- package/src/services/spawn-agent.ts +7 -1
- package/src/services/team.ts +5 -1
- package/src/token-db.ts +3 -3
package/src/db/index.ts
CHANGED
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { getDb, ValidationError } from "./connection.ts";
|
|
3
|
+
import { getEntry as getBlackboardEntry, listEntries as listBlackboardEntries } from "./blackboard.ts";
|
|
4
|
+
import { parseJson } from "../utils";
|
|
5
|
+
import { PROJECT_ENTRY_KINDS, PROJECT_ENTRY_SOURCES, type BlackboardEntryKind, type ProjectEntry, type ProjectEntryKind, type ProjectEntrySource, type ProjectEntryStatus } from "../types";
|
|
6
|
+
|
|
7
|
+
// Project-entry CRUD (#405) — a near-exact copy of src/db/blackboard.ts re-scoped
|
|
8
|
+
// from team_id → project_id, plus the source/origin_ref provenance columns. Kept as
|
|
9
|
+
// a parallel copy (not folded with blackboard) because the two scopes have different
|
|
10
|
+
// lifetimes and column sets; the duplication is intentional and ratchet-noted.
|
|
11
|
+
|
|
12
|
+
const PROJECT_ENTRY_STATUSES = ["active", "superseded", "retracted"] as const;
|
|
13
|
+
|
|
14
|
+
interface ProjectEntryRow {
|
|
15
|
+
id: string;
|
|
16
|
+
project_id: string;
|
|
17
|
+
kind: ProjectEntryKind;
|
|
18
|
+
title: string;
|
|
19
|
+
body: string | null;
|
|
20
|
+
status: ProjectEntryStatus;
|
|
21
|
+
supersedes: string | null;
|
|
22
|
+
tags: string | null;
|
|
23
|
+
author_id: string;
|
|
24
|
+
source: ProjectEntrySource;
|
|
25
|
+
origin_ref: string | null;
|
|
26
|
+
created_at: number;
|
|
27
|
+
updated_at: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface WriteProjectEntryInput {
|
|
31
|
+
projectId: string;
|
|
32
|
+
kind: ProjectEntryKind;
|
|
33
|
+
title: string;
|
|
34
|
+
body?: string;
|
|
35
|
+
tags?: string[];
|
|
36
|
+
authorId: string;
|
|
37
|
+
supersedes?: string;
|
|
38
|
+
source?: ProjectEntrySource;
|
|
39
|
+
originRef?: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function projectEntryId(): string {
|
|
43
|
+
return `pe_${randomUUID().replace(/-/g, "").slice(0, 16)}`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function rowToEntry(row: ProjectEntryRow): ProjectEntry {
|
|
47
|
+
const tags = parseJson<string[]>(row.tags, []).filter((tag) => typeof tag === "string");
|
|
48
|
+
return {
|
|
49
|
+
id: row.id,
|
|
50
|
+
projectId: row.project_id,
|
|
51
|
+
kind: row.kind,
|
|
52
|
+
title: row.title,
|
|
53
|
+
...(row.body ? { body: row.body } : {}),
|
|
54
|
+
status: row.status,
|
|
55
|
+
...(row.supersedes ? { supersedes: row.supersedes } : {}),
|
|
56
|
+
tags,
|
|
57
|
+
authorId: row.author_id,
|
|
58
|
+
source: row.source,
|
|
59
|
+
...(row.origin_ref ? { originRef: row.origin_ref } : {}),
|
|
60
|
+
createdAt: row.created_at,
|
|
61
|
+
updatedAt: row.updated_at,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function assertKind(kind: ProjectEntryKind): void {
|
|
66
|
+
if (!PROJECT_ENTRY_KINDS.includes(kind)) {
|
|
67
|
+
throw new ValidationError(`kind must be one of: ${PROJECT_ENTRY_KINDS.join(", ")}`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function assertSource(source: ProjectEntrySource): void {
|
|
72
|
+
if (!PROJECT_ENTRY_SOURCES.includes(source)) {
|
|
73
|
+
throw new ValidationError(`source must be one of: ${PROJECT_ENTRY_SOURCES.join(", ")}`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function assertStatus(status: ProjectEntryStatus): void {
|
|
78
|
+
if (!PROJECT_ENTRY_STATUSES.includes(status)) {
|
|
79
|
+
throw new ValidationError(`status must be one of: ${PROJECT_ENTRY_STATUSES.join(", ")}`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function escapeLike(value: string): string {
|
|
84
|
+
return value.replace(/[\\%_]/g, (char) => `\\${char}`);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function writeProjectEntry(input: WriteProjectEntryInput): ProjectEntry {
|
|
88
|
+
assertKind(input.kind);
|
|
89
|
+
const source = input.source ?? "manual";
|
|
90
|
+
assertSource(source);
|
|
91
|
+
const id = projectEntryId();
|
|
92
|
+
const now = Date.now();
|
|
93
|
+
getDb().transaction(() => {
|
|
94
|
+
if (input.supersedes) {
|
|
95
|
+
const previous = getProjectEntry(input.supersedes);
|
|
96
|
+
if (!previous) throw new ValidationError(`project entry ${input.supersedes} not found`);
|
|
97
|
+
if (previous.projectId !== input.projectId) throw new ValidationError("superseded entry belongs to another project");
|
|
98
|
+
if (previous.status === "retracted") throw new ValidationError("cannot supersede a retracted entry");
|
|
99
|
+
getDb().query("UPDATE project_entries SET status = 'superseded', updated_at = ? WHERE id = ?")
|
|
100
|
+
.run(now, input.supersedes);
|
|
101
|
+
}
|
|
102
|
+
getDb().query(`
|
|
103
|
+
INSERT INTO project_entries (id, project_id, kind, title, body, status, supersedes, tags, author_id, source, origin_ref, created_at, updated_at)
|
|
104
|
+
VALUES (?, ?, ?, ?, ?, 'active', ?, ?, ?, ?, ?, ?, ?)
|
|
105
|
+
`).run(
|
|
106
|
+
id,
|
|
107
|
+
input.projectId,
|
|
108
|
+
input.kind,
|
|
109
|
+
input.title,
|
|
110
|
+
input.body ?? null,
|
|
111
|
+
input.supersedes ?? null,
|
|
112
|
+
input.tags?.length ? JSON.stringify([...new Set(input.tags)]) : null,
|
|
113
|
+
input.authorId,
|
|
114
|
+
source,
|
|
115
|
+
input.originRef ?? null,
|
|
116
|
+
now,
|
|
117
|
+
now,
|
|
118
|
+
);
|
|
119
|
+
})();
|
|
120
|
+
return getProjectEntry(id)!;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function getProjectEntry(id: string): ProjectEntry | null {
|
|
124
|
+
const row = getDb().query("SELECT * FROM project_entries WHERE id = ?").get(id) as ProjectEntryRow | undefined;
|
|
125
|
+
return row ? rowToEntry(row) : null;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function getProjectEntryChain(id: string): ProjectEntry[] {
|
|
129
|
+
const chain: ProjectEntry[] = [];
|
|
130
|
+
const seen = new Set<string>();
|
|
131
|
+
let current = getProjectEntry(id);
|
|
132
|
+
while (current && !seen.has(current.id)) {
|
|
133
|
+
chain.push(current);
|
|
134
|
+
seen.add(current.id);
|
|
135
|
+
current = current.supersedes ? getProjectEntry(current.supersedes) : null;
|
|
136
|
+
}
|
|
137
|
+
return chain;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function listProjectEntries(
|
|
141
|
+
projectId: string,
|
|
142
|
+
opts: { kind?: ProjectEntryKind; includeInactive?: boolean } = {},
|
|
143
|
+
): ProjectEntry[] {
|
|
144
|
+
if (opts.kind) assertKind(opts.kind);
|
|
145
|
+
const conditions = ["project_id = ?"];
|
|
146
|
+
const params: string[] = [projectId];
|
|
147
|
+
if (opts.kind) {
|
|
148
|
+
conditions.push("kind = ?");
|
|
149
|
+
params.push(opts.kind);
|
|
150
|
+
}
|
|
151
|
+
if (!opts.includeInactive) conditions.push("status = 'active'");
|
|
152
|
+
const rows = getDb().query(`
|
|
153
|
+
SELECT * FROM project_entries
|
|
154
|
+
WHERE ${conditions.join(" AND ")}
|
|
155
|
+
ORDER BY updated_at DESC, created_at DESC, id DESC
|
|
156
|
+
`).all(...params) as ProjectEntryRow[];
|
|
157
|
+
return rows.map(rowToEntry);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function searchProjectEntries(
|
|
161
|
+
projectId: string,
|
|
162
|
+
query: string,
|
|
163
|
+
opts: { kind?: ProjectEntryKind } = {},
|
|
164
|
+
): ProjectEntry[] {
|
|
165
|
+
if (opts.kind) assertKind(opts.kind);
|
|
166
|
+
const like = `%${escapeLike(query)}%`;
|
|
167
|
+
const conditions = [
|
|
168
|
+
"project_id = ?",
|
|
169
|
+
"status = 'active'",
|
|
170
|
+
"(title LIKE ? ESCAPE '\\' OR body LIKE ? ESCAPE '\\' OR tags LIKE ? ESCAPE '\\')",
|
|
171
|
+
];
|
|
172
|
+
const params: string[] = [projectId, like, like, like];
|
|
173
|
+
if (opts.kind) {
|
|
174
|
+
conditions.push("kind = ?");
|
|
175
|
+
params.push(opts.kind);
|
|
176
|
+
}
|
|
177
|
+
const rows = getDb().query(`
|
|
178
|
+
SELECT * FROM project_entries
|
|
179
|
+
WHERE ${conditions.join(" AND ")}
|
|
180
|
+
ORDER BY updated_at DESC, created_at DESC, id DESC
|
|
181
|
+
`).all(...params) as ProjectEntryRow[];
|
|
182
|
+
return rows.map(rowToEntry);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export function retractProjectEntry(id: string): ProjectEntry | null {
|
|
186
|
+
const existing = getProjectEntry(id);
|
|
187
|
+
if (!existing) return null;
|
|
188
|
+
assertStatus("retracted");
|
|
189
|
+
getDb().query("UPDATE project_entries SET status = 'retracted', updated_at = ? WHERE id = ?")
|
|
190
|
+
.run(Date.now(), id);
|
|
191
|
+
return getProjectEntry(id);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// --- blackboard → project promotion (#404): the lossy→durable lift ---------------
|
|
195
|
+
// The ONLY bridge between the ephemeral team blackboard and the durable project ledger.
|
|
196
|
+
// Promotion is ALWAYS explicit — nothing here is wired into blackboard writes or team
|
|
197
|
+
// dissolve; an agent/operator decides what graduates. Blackboard kinds map to project
|
|
198
|
+
// kinds 1:1 except `ruled_out`, which becomes a durable `rule` (a "don't do X" lands as
|
|
199
|
+
// a standing rule). `memory` has no blackboard source — it is project-native only.
|
|
200
|
+
const BLACKBOARD_TO_PROJECT_KIND: Record<BlackboardEntryKind, ProjectEntryKind> = {
|
|
201
|
+
decision: "decision",
|
|
202
|
+
fact: "fact",
|
|
203
|
+
ruled_out: "rule",
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
export interface PromoteBlackboardEntryInput {
|
|
207
|
+
blackboardEntryId: string;
|
|
208
|
+
projectId: string;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Find the active project entry already promoted from `originRef` into `projectId`,
|
|
212
|
+
* if any — the anchor for idempotent re-promotion. */
|
|
213
|
+
function activePromotedEntry(projectId: string, originRef: string): ProjectEntry | null {
|
|
214
|
+
const row = getDb().query(`
|
|
215
|
+
SELECT * FROM project_entries
|
|
216
|
+
WHERE project_id = ? AND origin_ref = ? AND source = 'promoted' AND status = 'active'
|
|
217
|
+
ORDER BY created_at DESC, id DESC
|
|
218
|
+
LIMIT 1
|
|
219
|
+
`).get(projectId, originRef) as ProjectEntryRow | undefined;
|
|
220
|
+
return row ? rowToEntry(row) : null;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Copy an active blackboard finding into the durable project ledger as a `promoted`
|
|
224
|
+
* entry whose `origin_ref` back-references the `bb_…` id. Preserves title/body/tags/author.
|
|
225
|
+
* Validates the source is `active` (superseded/retracted findings cannot be promoted).
|
|
226
|
+
*
|
|
227
|
+
* Double-promote is IDEMPOTENT-BY-SUPERSEDE: re-promoting the same `bb_…` into the same
|
|
228
|
+
* project supersedes the prior promoted copy rather than duplicating it, so the durable
|
|
229
|
+
* ledger holds at most one active entry per promoted finding while keeping the full
|
|
230
|
+
* supersede chain for audit. */
|
|
231
|
+
export function promoteBlackboardEntry(input: PromoteBlackboardEntryInput): ProjectEntry {
|
|
232
|
+
const source = getBlackboardEntry(input.blackboardEntryId);
|
|
233
|
+
if (!source) throw new ValidationError(`blackboard entry ${input.blackboardEntryId} not found`);
|
|
234
|
+
if (source.status !== "active") {
|
|
235
|
+
throw new ValidationError(`blackboard entry ${source.id} is ${source.status}; only active entries can be promoted`);
|
|
236
|
+
}
|
|
237
|
+
const existing = activePromotedEntry(input.projectId, source.id);
|
|
238
|
+
return writeProjectEntry({
|
|
239
|
+
projectId: input.projectId,
|
|
240
|
+
kind: BLACKBOARD_TO_PROJECT_KIND[source.kind],
|
|
241
|
+
title: source.title,
|
|
242
|
+
...(source.body ? { body: source.body } : {}),
|
|
243
|
+
...(source.tags.length ? { tags: source.tags } : {}),
|
|
244
|
+
authorId: source.authorId,
|
|
245
|
+
source: "promoted",
|
|
246
|
+
originRef: source.id,
|
|
247
|
+
...(existing ? { supersedes: existing.id } : {}),
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export interface UnpromotedBlackboardSummary {
|
|
252
|
+
count: number;
|
|
253
|
+
entries: Array<{ id: string; kind: BlackboardEntryKind; title: string }>;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** Active blackboard findings for a team that have NOT been promoted into any project
|
|
257
|
+
* (no active `promoted` project entry back-references them). Surfaced at team dissolve so
|
|
258
|
+
* the operator/agent can choose to lift them rather than silently losing them. */
|
|
259
|
+
export function unpromotedBlackboardEntries(teamId: string): UnpromotedBlackboardSummary {
|
|
260
|
+
const active = listBlackboardEntries(teamId);
|
|
261
|
+
const promotedRefs = new Set(
|
|
262
|
+
(getDb().query(
|
|
263
|
+
"SELECT DISTINCT origin_ref FROM project_entries WHERE source = 'promoted' AND status = 'active' AND origin_ref IS NOT NULL",
|
|
264
|
+
).all() as Array<{ origin_ref: string }>).map((row) => row.origin_ref),
|
|
265
|
+
);
|
|
266
|
+
const entries = active
|
|
267
|
+
.filter((entry) => !promotedRefs.has(entry.id))
|
|
268
|
+
.map((entry) => ({ id: entry.id, kind: entry.kind, title: entry.title }));
|
|
269
|
+
return { count: entries.length, entries };
|
|
270
|
+
}
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { getDb, ValidationError } from "./connection.ts";
|
|
3
|
+
import { listProjectEntries } from "./project-entries.ts";
|
|
4
|
+
import { isPathWithinBase } from "../utils";
|
|
5
|
+
import { PROJECT_EDGE_KINDS, type Project, type ProjectEdge, type ProjectEdgeKind, type ProjectEntryKind, type ProjectRoot, type ResolvedProjectEntry } from "../types";
|
|
6
|
+
|
|
7
|
+
// Projects entity CRUD + roots + cwd→project resolution (#405). Templated on
|
|
8
|
+
// src/db/teams.ts; the durable analog of a team. project_entries CRUD lives in
|
|
9
|
+
// the sibling src/db/project-entries.ts (templated on blackboard.ts).
|
|
10
|
+
|
|
11
|
+
interface ProjectRow {
|
|
12
|
+
id: string;
|
|
13
|
+
slug: string;
|
|
14
|
+
title: string | null;
|
|
15
|
+
description: string | null;
|
|
16
|
+
tenant_id: string | null;
|
|
17
|
+
status: Project["status"];
|
|
18
|
+
created_at: number;
|
|
19
|
+
updated_at: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface ProjectRootRow {
|
|
23
|
+
project_id: string;
|
|
24
|
+
root_path: string;
|
|
25
|
+
created_at: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function projectId(): string {
|
|
29
|
+
return `project_${randomUUID().replace(/-/g, "").slice(0, 16)}`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function rowToProject(row: ProjectRow): Project {
|
|
33
|
+
return {
|
|
34
|
+
id: row.id,
|
|
35
|
+
slug: row.slug,
|
|
36
|
+
...(row.title ? { title: row.title } : {}),
|
|
37
|
+
...(row.description ? { description: row.description } : {}),
|
|
38
|
+
...(row.tenant_id ? { tenantId: row.tenant_id } : {}),
|
|
39
|
+
status: row.status,
|
|
40
|
+
createdAt: row.created_at,
|
|
41
|
+
updatedAt: row.updated_at,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function rowToRoot(row: ProjectRootRow): ProjectRoot {
|
|
46
|
+
return { projectId: row.project_id, rootPath: row.root_path, createdAt: row.created_at };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function getProject(id: string): Project | null {
|
|
50
|
+
const row = getDb().query("SELECT * FROM projects WHERE id = ?").get(id) as ProjectRow | undefined;
|
|
51
|
+
return row ? rowToProject(row) : null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function getProjectBySlug(slug: string): Project | null {
|
|
55
|
+
const row = getDb().query("SELECT * FROM projects WHERE slug = ?").get(slug) as ProjectRow | undefined;
|
|
56
|
+
return row ? rowToProject(row) : null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Resolve a project by id first, then by slug — the form MCP/route callers pass. */
|
|
60
|
+
export function resolveProject(ref: string): Project | null {
|
|
61
|
+
return getProject(ref) ?? getProjectBySlug(ref);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function listProjects(opts: { includeArchived?: boolean } = {}): Project[] {
|
|
65
|
+
const where = opts.includeArchived ? "" : "WHERE status = 'active' ";
|
|
66
|
+
const rows = getDb()
|
|
67
|
+
.query(`SELECT * FROM projects ${where}ORDER BY created_at ASC, id ASC`)
|
|
68
|
+
.all() as ProjectRow[];
|
|
69
|
+
return rows.map(rowToProject);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function rootsForProject(id: string): ProjectRoot[] {
|
|
73
|
+
const rows = getDb()
|
|
74
|
+
.query("SELECT * FROM project_roots WHERE project_id = ? ORDER BY root_path ASC")
|
|
75
|
+
.all(id) as ProjectRootRow[];
|
|
76
|
+
return rows.map(rowToRoot);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface EnsureProjectInput {
|
|
80
|
+
slug: string;
|
|
81
|
+
title?: string;
|
|
82
|
+
description?: string;
|
|
83
|
+
tenantId?: string;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Get-or-create a project by slug. The create-on-first-use analog of ensureTeam;
|
|
87
|
+
* updates title/description/tenant on an existing project only when provided. */
|
|
88
|
+
export function ensureProject(input: EnsureProjectInput): Project {
|
|
89
|
+
const slug = input.slug.trim();
|
|
90
|
+
if (!slug) throw new ValidationError("slug required");
|
|
91
|
+
const existing = getProjectBySlug(slug);
|
|
92
|
+
const now = Date.now();
|
|
93
|
+
if (existing) {
|
|
94
|
+
if (input.title !== undefined || input.description !== undefined || input.tenantId !== undefined) {
|
|
95
|
+
getDb().query(`
|
|
96
|
+
UPDATE projects
|
|
97
|
+
SET title = ?, description = ?, tenant_id = ?, updated_at = ?
|
|
98
|
+
WHERE id = ?
|
|
99
|
+
`).run(
|
|
100
|
+
input.title ?? existing.title ?? null,
|
|
101
|
+
input.description ?? existing.description ?? null,
|
|
102
|
+
input.tenantId ?? existing.tenantId ?? null,
|
|
103
|
+
now,
|
|
104
|
+
existing.id,
|
|
105
|
+
);
|
|
106
|
+
return getProject(existing.id)!;
|
|
107
|
+
}
|
|
108
|
+
return existing;
|
|
109
|
+
}
|
|
110
|
+
const id = projectId();
|
|
111
|
+
getDb().query(`
|
|
112
|
+
INSERT INTO projects (id, slug, title, description, tenant_id, status, created_at, updated_at)
|
|
113
|
+
VALUES (?, ?, ?, ?, ?, 'active', ?, ?)
|
|
114
|
+
`).run(id, slug, input.title ?? null, input.description ?? null, input.tenantId ?? null, now, now);
|
|
115
|
+
return getProject(id)!;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Register a root path for a project (idempotent — UNIQUE(project_id, root_path)). */
|
|
119
|
+
export function addProjectRoot(id: string, rootPath: string): ProjectRoot[] {
|
|
120
|
+
const project = getProject(id);
|
|
121
|
+
if (!project) throw new ValidationError(`project ${id} not found`);
|
|
122
|
+
const path = rootPath.trim();
|
|
123
|
+
if (!path) throw new ValidationError("rootPath required");
|
|
124
|
+
getDb().query(`
|
|
125
|
+
INSERT INTO project_roots (project_id, root_path, created_at)
|
|
126
|
+
VALUES (?, ?, ?)
|
|
127
|
+
ON CONFLICT(project_id, root_path) DO NOTHING
|
|
128
|
+
`).run(id, path, Date.now());
|
|
129
|
+
return rootsForProject(id);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** cwd→project binding: the project whose any root_path contains `cwd`. Uses the
|
|
133
|
+
* shared containment helper (resolve+relative, never startsWith). When more than one
|
|
134
|
+
* root matches, the most specific (longest root_path) wins. Returns null gracefully
|
|
135
|
+
* when no root contains the cwd. */
|
|
136
|
+
export function resolveProjectForCwd(cwd: string): Project | null {
|
|
137
|
+
if (!cwd) return null;
|
|
138
|
+
const rows = getDb()
|
|
139
|
+
.query("SELECT project_id, root_path, created_at FROM project_roots")
|
|
140
|
+
.all() as ProjectRootRow[];
|
|
141
|
+
let best: { projectId: string; len: number } | null = null;
|
|
142
|
+
for (const row of rows) {
|
|
143
|
+
if (isPathWithinBase(cwd, row.root_path) && (!best || row.root_path.length > best.len)) {
|
|
144
|
+
best = { projectId: row.project_id, len: row.root_path.length };
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return best ? getProject(best.projectId) : null;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ── Composition graph (#406) ────────────────────────────────────────────────
|
|
151
|
+
// project_edges models two relations, asymmetric by design:
|
|
152
|
+
// • contains — umbrella → sub-project hierarchy. INHERITS: resolving a
|
|
153
|
+
// project's entries pulls in its transitive contains-ANCESTORS.
|
|
154
|
+
// • depends_on — reference-only. A project's deps are exposed for explicit
|
|
155
|
+
// lookup but NEVER auto-inherited (a dependency's internal
|
|
156
|
+
// rules must not flood every consuming agent).
|
|
157
|
+
// Cycles are rejected per-kind on insert (addEdge); the traversal helpers also
|
|
158
|
+
// carry a visited-set so a manually-corrupted graph can't loop them.
|
|
159
|
+
|
|
160
|
+
interface ProjectEdgeRow {
|
|
161
|
+
from_project: string;
|
|
162
|
+
to_project: string;
|
|
163
|
+
kind: ProjectEdgeKind;
|
|
164
|
+
created_at: number;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function rowToEdge(row: ProjectEdgeRow): ProjectEdge {
|
|
168
|
+
return { fromProject: row.from_project, toProject: row.to_project, kind: row.kind, createdAt: row.created_at };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function assertEdgeKind(kind: ProjectEdgeKind): void {
|
|
172
|
+
if (!PROJECT_EDGE_KINDS.includes(kind)) {
|
|
173
|
+
throw new ValidationError(`kind must be one of: ${PROJECT_EDGE_KINDS.join(", ")}`);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Can `start` reach `target` following same-kind edges in the from→to direction?
|
|
178
|
+
* Used by addEdge to reject an edge that would close a cycle. Visited-set bounded. */
|
|
179
|
+
function reachableSameKind(start: string, target: string, kind: ProjectEdgeKind): boolean {
|
|
180
|
+
const stack = [start];
|
|
181
|
+
const seen = new Set<string>();
|
|
182
|
+
while (stack.length) {
|
|
183
|
+
const node = stack.pop()!;
|
|
184
|
+
if (node === target) return true;
|
|
185
|
+
if (seen.has(node)) continue;
|
|
186
|
+
seen.add(node);
|
|
187
|
+
const rows = getDb()
|
|
188
|
+
.query("SELECT to_project FROM project_edges WHERE from_project = ? AND kind = ?")
|
|
189
|
+
.all(node, kind) as Array<{ to_project: string }>;
|
|
190
|
+
for (const row of rows) stack.push(row.to_project);
|
|
191
|
+
}
|
|
192
|
+
return false;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Add a graph edge `from`→`to` of `kind`. Both projects must exist. Rejects a
|
|
196
|
+
* self-loop and any edge that would create a cycle within the same kind (so the
|
|
197
|
+
* contains hierarchy stays a DAG). Idempotent — UNIQUE(from, to, kind). */
|
|
198
|
+
export function addEdge(fromProject: string, toProject: string, kind: ProjectEdgeKind): ProjectEdge {
|
|
199
|
+
assertEdgeKind(kind);
|
|
200
|
+
if (fromProject === toProject) throw new ValidationError("an edge cannot point a project at itself");
|
|
201
|
+
if (!getProject(fromProject)) throw new ValidationError(`project ${fromProject} not found`);
|
|
202
|
+
if (!getProject(toProject)) throw new ValidationError(`project ${toProject} not found`);
|
|
203
|
+
// A cycle forms iff `to` can already reach `from` along same-kind edges; adding
|
|
204
|
+
// from→to would then close the loop.
|
|
205
|
+
if (reachableSameKind(toProject, fromProject, kind)) {
|
|
206
|
+
throw new ValidationError(`edge ${fromProject} -> ${toProject} (${kind}) would create a cycle`);
|
|
207
|
+
}
|
|
208
|
+
getDb().query(`
|
|
209
|
+
INSERT INTO project_edges (from_project, to_project, kind, created_at)
|
|
210
|
+
VALUES (?, ?, ?, ?)
|
|
211
|
+
ON CONFLICT(from_project, to_project, kind) DO NOTHING
|
|
212
|
+
`).run(fromProject, toProject, kind, Date.now());
|
|
213
|
+
return { fromProject, toProject, kind, createdAt: Date.now() };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Remove a graph edge. Returns true when a row was deleted, false when absent. */
|
|
217
|
+
export function removeEdge(fromProject: string, toProject: string, kind: ProjectEdgeKind): boolean {
|
|
218
|
+
assertEdgeKind(kind);
|
|
219
|
+
const result = getDb()
|
|
220
|
+
.query("DELETE FROM project_edges WHERE from_project = ? AND to_project = ? AND kind = ?")
|
|
221
|
+
.run(fromProject, toProject, kind);
|
|
222
|
+
return result.changes > 0;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** All edges incident to a project — both outgoing (project is `from`) and
|
|
226
|
+
* incoming (project is `to`), across both kinds. The visibility surface that lets
|
|
227
|
+
* a caller see its umbrella(s) and its dependencies. */
|
|
228
|
+
export function edgesFor(projectId: string): ProjectEdge[] {
|
|
229
|
+
const rows = getDb()
|
|
230
|
+
.query("SELECT * FROM project_edges WHERE from_project = ? OR to_project = ? ORDER BY kind ASC, created_at ASC")
|
|
231
|
+
.all(projectId, projectId) as ProjectEdgeRow[];
|
|
232
|
+
return rows.map(rowToEdge);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** Transitive `contains` ancestors of a project (the umbrellas that contain it,
|
|
236
|
+
* directly or via intermediate umbrellas), nearest-first (BFS). Self is excluded. */
|
|
237
|
+
export function ancestors(projectId: string): string[] {
|
|
238
|
+
const order: string[] = [];
|
|
239
|
+
const seen = new Set<string>([projectId]);
|
|
240
|
+
let frontier = [projectId];
|
|
241
|
+
while (frontier.length) {
|
|
242
|
+
const next: string[] = [];
|
|
243
|
+
for (const node of frontier) {
|
|
244
|
+
const rows = getDb()
|
|
245
|
+
.query("SELECT from_project FROM project_edges WHERE to_project = ? AND kind = 'contains'")
|
|
246
|
+
.all(node) as Array<{ from_project: string }>;
|
|
247
|
+
for (const row of rows) {
|
|
248
|
+
if (seen.has(row.from_project)) continue;
|
|
249
|
+
seen.add(row.from_project);
|
|
250
|
+
order.push(row.from_project);
|
|
251
|
+
next.push(row.from_project);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
frontier = next;
|
|
255
|
+
}
|
|
256
|
+
return order;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** Direct `depends_on` targets of a project. NOT transitive — dependencies are a
|
|
260
|
+
* reference edge, surfaced for explicit opt-in lookup, never an inheritance chain. */
|
|
261
|
+
export function dependencies(projectId: string): string[] {
|
|
262
|
+
const rows = getDb()
|
|
263
|
+
.query("SELECT to_project FROM project_edges WHERE from_project = ? AND kind = 'depends_on' ORDER BY created_at ASC")
|
|
264
|
+
.all(projectId) as Array<{ to_project: string }>;
|
|
265
|
+
return rows.map((row) => row.to_project);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
export interface ResolveProjectEntriesOptions {
|
|
269
|
+
kind?: ProjectEntryKind;
|
|
270
|
+
includeInactive?: boolean;
|
|
271
|
+
/** When true (default), merge in entries from transitive `contains` ancestors,
|
|
272
|
+
* each tagged `inherited: true`. When false, only the project's own entries. */
|
|
273
|
+
includeInherited?: boolean;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** Cascade resolution — THE behavioral core of the composition graph.
|
|
277
|
+
*
|
|
278
|
+
* Returns a project's own entries PLUS, when `includeInherited` (the default), the
|
|
279
|
+
* entries of its transitive `contains` ancestors, each tagged with `inherited`.
|
|
280
|
+
*
|
|
281
|
+
* MERGE SEMANTICS: own ⊕ ancestors is a UNION, not an override — a project and its
|
|
282
|
+
* umbrella can both hold a `rule` and both are returned (same-kind = merge). Order
|
|
283
|
+
* is most-specific-first: the project's own entries lead, then ancestors
|
|
284
|
+
* nearest-first; if a consumer ever needs precedence, more-specific/nearer (and,
|
|
285
|
+
* within a project, more-recent) wins by appearing first.
|
|
286
|
+
*
|
|
287
|
+
* ASYMMETRY: only `contains` ancestors inherit. `depends_on` targets are NEVER
|
|
288
|
+
* folded in here — pull a dependency's entries explicitly via its own project ref. */
|
|
289
|
+
export function resolveProjectEntries(
|
|
290
|
+
projectId: string,
|
|
291
|
+
opts: ResolveProjectEntriesOptions = {},
|
|
292
|
+
): ResolvedProjectEntry[] {
|
|
293
|
+
const listOpts = { kind: opts.kind, includeInactive: opts.includeInactive };
|
|
294
|
+
const resolved: ResolvedProjectEntry[] = listProjectEntries(projectId, listOpts).map(
|
|
295
|
+
(entry) => ({ ...entry, inherited: false }),
|
|
296
|
+
);
|
|
297
|
+
if (opts.includeInherited === false) return resolved;
|
|
298
|
+
for (const ancestorId of ancestors(projectId)) {
|
|
299
|
+
for (const entry of listProjectEntries(ancestorId, listOpts)) {
|
|
300
|
+
resolved.push({ ...entry, inherited: true });
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
return resolved;
|
|
304
|
+
}
|
package/src/db/schema.ts
CHANGED
|
@@ -187,6 +187,56 @@ export function initDb(path: string = "agent-relay.db"): Database {
|
|
|
187
187
|
);
|
|
188
188
|
CREATE INDEX IF NOT EXISTS idx_blackboard_team ON blackboard_entries(team_id);
|
|
189
189
|
CREATE INDEX IF NOT EXISTS idx_blackboard_team_kind ON blackboard_entries(team_id, kind);
|
|
190
|
+
-- Projects (#403/#405) — the durable knowledge scope. projects/project_roots/project_entries
|
|
191
|
+
-- re-scope the teams/blackboard_entries pair above (team_id → project_id); project_entries
|
|
192
|
+
-- adds source/origin_ref so a promoted or ingested entry tracks where it came from.
|
|
193
|
+
CREATE TABLE IF NOT EXISTS projects (
|
|
194
|
+
id TEXT PRIMARY KEY,
|
|
195
|
+
slug TEXT NOT NULL UNIQUE,
|
|
196
|
+
title TEXT,
|
|
197
|
+
description TEXT,
|
|
198
|
+
tenant_id TEXT,
|
|
199
|
+
status TEXT NOT NULL DEFAULT 'active',
|
|
200
|
+
created_at INTEGER NOT NULL,
|
|
201
|
+
updated_at INTEGER NOT NULL
|
|
202
|
+
);
|
|
203
|
+
CREATE TABLE IF NOT EXISTS project_roots (
|
|
204
|
+
project_id TEXT NOT NULL,
|
|
205
|
+
root_path TEXT NOT NULL,
|
|
206
|
+
created_at INTEGER NOT NULL,
|
|
207
|
+
UNIQUE(project_id, root_path)
|
|
208
|
+
);
|
|
209
|
+
CREATE INDEX IF NOT EXISTS idx_project_roots_project ON project_roots(project_id);
|
|
210
|
+
CREATE TABLE IF NOT EXISTS project_entries (
|
|
211
|
+
id TEXT PRIMARY KEY,
|
|
212
|
+
project_id TEXT NOT NULL,
|
|
213
|
+
kind TEXT NOT NULL,
|
|
214
|
+
title TEXT NOT NULL,
|
|
215
|
+
body TEXT,
|
|
216
|
+
status TEXT NOT NULL DEFAULT 'active',
|
|
217
|
+
supersedes TEXT,
|
|
218
|
+
tags TEXT,
|
|
219
|
+
author_id TEXT NOT NULL,
|
|
220
|
+
source TEXT NOT NULL DEFAULT 'manual',
|
|
221
|
+
origin_ref TEXT,
|
|
222
|
+
created_at INTEGER NOT NULL,
|
|
223
|
+
updated_at INTEGER NOT NULL
|
|
224
|
+
);
|
|
225
|
+
CREATE INDEX IF NOT EXISTS idx_project_entries_project ON project_entries(project_id);
|
|
226
|
+
CREATE INDEX IF NOT EXISTS idx_project_entries_project_kind ON project_entries(project_id, kind);
|
|
227
|
+
-- Project composition graph (#406). kind is 'contains' (umbrella→sub-project, INHERITS)
|
|
228
|
+
-- or 'depends_on' (reference-only, never inherits). Cycles are rejected on insert in
|
|
229
|
+
-- src/db/projects.ts (addEdge). Both-direction indexes: from_project for descend/deps,
|
|
230
|
+
-- to_project for the ancestors() walk.
|
|
231
|
+
CREATE TABLE IF NOT EXISTS project_edges (
|
|
232
|
+
from_project TEXT NOT NULL,
|
|
233
|
+
to_project TEXT NOT NULL,
|
|
234
|
+
kind TEXT NOT NULL,
|
|
235
|
+
created_at INTEGER NOT NULL,
|
|
236
|
+
UNIQUE(from_project, to_project, kind)
|
|
237
|
+
);
|
|
238
|
+
CREATE INDEX IF NOT EXISTS idx_project_edges_from ON project_edges(from_project, kind);
|
|
239
|
+
CREATE INDEX IF NOT EXISTS idx_project_edges_to ON project_edges(to_project, kind);
|
|
190
240
|
CREATE TABLE IF NOT EXISTS plans (
|
|
191
241
|
id TEXT PRIMARY KEY, thread_id INTEGER NOT NULL, team_id TEXT, channel TEXT, version INTEGER NOT NULL DEFAULT 1,
|
|
192
242
|
title TEXT, objective TEXT, nodes TEXT NOT NULL DEFAULT '[]', edges TEXT NOT NULL DEFAULT '[]',
|
package/src/db/teams.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto";
|
|
|
2
2
|
import { getAgent, setAgentTeam } from "./agents.ts";
|
|
3
3
|
import { getDb, ValidationError } from "./connection.ts";
|
|
4
4
|
import { rowToAgent } from "./mappers.ts";
|
|
5
|
+
import { unpromotedBlackboardEntries, type UnpromotedBlackboardSummary } from "./project-entries.ts";
|
|
5
6
|
import type { AgentCard, Team } from "../types";
|
|
6
7
|
|
|
7
8
|
interface TeamRow {
|
|
@@ -273,10 +274,20 @@ export function getTeamOutcomeAggregates(input: { recentLimit?: number } = {}):
|
|
|
273
274
|
};
|
|
274
275
|
}
|
|
275
276
|
|
|
276
|
-
export
|
|
277
|
+
export interface TeamDissolveResult {
|
|
278
|
+
team: Team;
|
|
279
|
+
/** Active blackboard findings not yet lifted into a project — surfaced (not auto-promoted)
|
|
280
|
+
* so the operator/agent can decide what graduates to durable project knowledge (#404). */
|
|
281
|
+
unpromotedBlackboard: UnpromotedBlackboardSummary;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
export function dissolveTeam(teamId: string, input: TeamDissolveInput = {}): TeamDissolveResult {
|
|
277
285
|
const existing = getTeam(teamId);
|
|
278
286
|
if (!existing) throw new ValidationError(`team ${teamId} not found`);
|
|
279
287
|
const members = getTeamRoster(teamId);
|
|
288
|
+
// Capture unpromoted findings BEFORE the row flips to dissolved — the blackboard rows
|
|
289
|
+
// survive dissolve, but reading the summary here keeps the surface atomic with the event.
|
|
290
|
+
const unpromotedBlackboard = unpromotedBlackboardEntries(teamId);
|
|
280
291
|
const now = Date.now();
|
|
281
292
|
getDb().query(`
|
|
282
293
|
UPDATE teams
|
|
@@ -299,5 +310,5 @@ export function dissolveTeam(teamId: string, input: TeamDissolveInput = {}): Tea
|
|
|
299
310
|
now,
|
|
300
311
|
teamId,
|
|
301
312
|
);
|
|
302
|
-
return getTeam(teamId)
|
|
313
|
+
return { team: getTeam(teamId)!, unpromotedBlackboard };
|
|
303
314
|
}
|