@bevel-software/platform-core-backend 0.30.0 → 0.31.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/agent-guide/access-control.md +49 -4
- package/agent-guide/html-views.md +76 -2
- package/dist/core/catalog-revision.js +3 -3
- package/dist/core/create-core-server.d.ts.map +1 -1
- package/dist/core/create-core-server.js +2 -1
- package/dist/core/create-core-server.js.map +1 -1
- package/dist/core/create-core-services.d.ts.map +1 -1
- package/dist/core/create-core-services.js +4 -2
- package/dist/core/create-core-services.js.map +1 -1
- package/dist/modules/access/access-control.service.d.ts.map +1 -1
- package/dist/modules/access/access-control.service.js +1 -5
- package/dist/modules/access/access-control.service.js.map +1 -1
- package/dist/modules/audit/agent-audit.service.d.ts.map +1 -1
- package/dist/modules/audit/agent-audit.service.js +66 -6
- package/dist/modules/audit/agent-audit.service.js.map +1 -1
- package/dist/modules/audit/audit.contract.d.ts +52 -8
- package/dist/modules/audit/audit.contract.d.ts.map +1 -1
- package/dist/modules/audit/audit.contract.js +37 -1
- package/dist/modules/audit/audit.contract.js.map +1 -1
- package/dist/modules/audit/audit.routes.d.ts +1 -1
- package/dist/modules/audit/audit.routes.d.ts.map +1 -1
- package/dist/modules/audit/audit.routes.js +15 -13
- package/dist/modules/audit/audit.routes.js.map +1 -1
- package/dist/modules/audit/request-audit.d.ts +21 -10
- package/dist/modules/audit/request-audit.d.ts.map +1 -1
- package/dist/modules/audit/request-audit.js +26 -10
- package/dist/modules/audit/request-audit.js.map +1 -1
- package/dist/modules/database/core-schema.d.ts +22 -3
- package/dist/modules/database/core-schema.d.ts.map +1 -1
- package/dist/modules/database/core-schema.js +15 -6
- package/dist/modules/database/core-schema.js.map +1 -1
- package/dist/modules/kb-fs/index.d.ts +1 -0
- package/dist/modules/kb-fs/index.d.ts.map +1 -1
- package/dist/modules/kb-fs/index.js +1 -0
- package/dist/modules/kb-fs/index.js.map +1 -1
- package/dist/modules/kb-fs/locking-filesystem.d.ts.map +1 -1
- package/dist/modules/kb-fs/locking-filesystem.js +3 -12
- package/dist/modules/kb-fs/locking-filesystem.js.map +1 -1
- package/dist/modules/kb-fs/mutex.d.ts +11 -0
- package/dist/modules/kb-fs/mutex.d.ts.map +1 -1
- package/dist/modules/kb-fs/mutex.js +14 -0
- package/dist/modules/kb-fs/mutex.js.map +1 -1
- package/dist/modules/mcp/mcp-auth.middleware.d.ts +14 -0
- package/dist/modules/mcp/mcp-auth.middleware.d.ts.map +1 -1
- package/dist/modules/mcp/mcp-auth.middleware.js +4 -0
- package/dist/modules/mcp/mcp-auth.middleware.js.map +1 -1
- package/dist/modules/mcp/mcp.routes.d.ts +5 -0
- package/dist/modules/mcp/mcp.routes.d.ts.map +1 -1
- package/dist/modules/mcp/mcp.routes.js +67 -0
- package/dist/modules/mcp/mcp.routes.js.map +1 -1
- package/dist/modules/mcp/mcp.service.d.ts +107 -27
- package/dist/modules/mcp/mcp.service.d.ts.map +1 -1
- package/dist/modules/mcp/mcp.service.js +257 -50
- package/dist/modules/mcp/mcp.service.js.map +1 -1
- package/dist/modules/mcp/request-context.d.ts +25 -0
- package/dist/modules/mcp/request-context.d.ts.map +1 -0
- package/dist/modules/mcp/request-context.js +11 -0
- package/dist/modules/mcp/request-context.js.map +1 -0
- package/dist/modules/mcp/shared-definitions.d.ts +56 -0
- package/dist/modules/mcp/shared-definitions.d.ts.map +1 -0
- package/dist/modules/mcp/shared-definitions.js +117 -0
- package/dist/modules/mcp/shared-definitions.js.map +1 -0
- package/dist/modules/mcp/surface-cache.d.ts +70 -0
- package/dist/modules/mcp/surface-cache.d.ts.map +1 -0
- package/dist/modules/mcp/surface-cache.js +104 -0
- package/dist/modules/mcp/surface-cache.js.map +1 -0
- package/dist/modules/tool-helpers/tool-context.d.ts.map +1 -1
- package/dist/modules/tool-helpers/tool-context.js +33 -12
- package/dist/modules/tool-helpers/tool-context.js.map +1 -1
- package/dist/modules/tool-helpers/tool.contract.d.ts +8 -0
- package/dist/modules/tool-helpers/tool.contract.d.ts.map +1 -1
- package/dist/modules/tool-helpers/tool.contract.js.map +1 -1
- package/dist/modules/workflow/agent-tools/change-request-read.tools.d.ts.map +1 -1
- package/dist/modules/workflow/agent-tools/change-request-read.tools.js +3 -14
- package/dist/modules/workflow/agent-tools/change-request-read.tools.js.map +1 -1
- package/dist/modules/workflow/agent-tools/workflow.tools.d.ts +18 -1
- package/dist/modules/workflow/agent-tools/workflow.tools.d.ts.map +1 -1
- package/dist/modules/workflow/agent-tools/workflow.tools.js +453 -2
- package/dist/modules/workflow/agent-tools/workflow.tools.js.map +1 -1
- package/dist/modules/workflow/git/git.service.d.ts +113 -4
- package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
- package/dist/modules/workflow/git/git.service.js +470 -106
- package/dist/modules/workflow/git/git.service.js.map +1 -1
- package/dist/modules/workflow/read-on-branch.d.ts +35 -0
- package/dist/modules/workflow/read-on-branch.d.ts.map +1 -0
- package/dist/modules/workflow/read-on-branch.js +58 -0
- package/dist/modules/workflow/read-on-branch.js.map +1 -0
- package/dist/modules/workflow/workflow.routes.d.ts.map +1 -1
- package/dist/modules/workflow/workflow.routes.js +22 -41
- package/dist/modules/workflow/workflow.routes.js.map +1 -1
- package/dist/modules/workflow/workflow.service.d.ts +48 -8
- package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
- package/dist/modules/workflow/workflow.service.js +163 -16
- package/dist/modules/workflow/workflow.service.js.map +1 -1
- package/dist/modules/workspace/index.d.ts +2 -0
- package/dist/modules/workspace/index.d.ts.map +1 -1
- package/dist/modules/workspace/index.js +4 -0
- package/dist/modules/workspace/index.js.map +1 -1
- package/dist/shared/git.contract.d.ts +7 -0
- package/dist/shared/git.contract.d.ts.map +1 -1
- package/dist/shared/git.contract.js +9 -0
- package/dist/shared/git.contract.js.map +1 -1
- package/dist/shared/no-link-below.d.ts +10 -0
- package/dist/shared/no-link-below.d.ts.map +1 -0
- package/dist/shared/no-link-below.js +30 -0
- package/dist/shared/no-link-below.js.map +1 -0
- package/migrations/0018_agent_events_page.sql +11 -0
- package/migrations/meta/0018_snapshot.json +2429 -0
- package/migrations/meta/_journal.json +8 -1
- package/package.json +3 -3
- package/src/core/catalog-revision.ts +3 -3
- package/src/core/create-core-server.ts +2 -1
- package/src/core/create-core-services.ts +4 -2
- package/src/modules/access/access-control.service.ts +1 -6
- package/src/modules/agent-guide/__tests__/agent-guide.test.ts +22 -1
- package/src/modules/audit/__tests__/agent-audit.service.test.ts +78 -1
- package/src/modules/audit/__tests__/audit.routes.test.ts +43 -5
- package/src/modules/audit/agent-audit.service.ts +66 -6
- package/src/modules/audit/audit.contract.ts +62 -10
- package/src/modules/audit/audit.routes.ts +14 -12
- package/src/modules/audit/request-audit.ts +28 -13
- package/src/modules/database/core-schema.ts +15 -6
- package/src/modules/kb-fs/__tests__/mutex.canonical.test.ts +57 -0
- package/src/modules/kb-fs/index.ts +1 -0
- package/src/modules/kb-fs/locking-filesystem.ts +3 -12
- package/src/modules/kb-fs/mutex.ts +16 -0
- package/src/modules/mcp/__tests__/mcp-routes-harness.ts +7 -1
- package/src/modules/mcp/__tests__/mcp.routes.page.test.ts +115 -0
- package/src/modules/mcp/__tests__/mcp.surface-cache.test.ts +521 -0
- package/src/modules/mcp/__tests__/shared-definitions.test.ts +28 -0
- package/src/modules/mcp/__tests__/surface-cache.test.ts +127 -0
- package/src/modules/mcp/mcp-auth.middleware.ts +21 -0
- package/src/modules/mcp/mcp.routes.ts +68 -0
- package/src/modules/mcp/mcp.service.ts +315 -51
- package/src/modules/mcp/request-context.ts +34 -0
- package/src/modules/mcp/shared-definitions.ts +121 -0
- package/src/modules/mcp/surface-cache.ts +154 -0
- package/src/modules/tool-helpers/tool-context.ts +37 -18
- package/src/modules/tool-helpers/tool.contract.ts +8 -0
- package/src/modules/tool-registry/__tests__/own-tool-schemas.test.ts +1 -1
- package/src/modules/tool-registry/__tests__/tool-description-length.test.ts +1 -1
- package/src/modules/workflow/__tests__/workflow.routes.history-read-gate.test.ts +23 -12
- package/src/modules/workflow/__tests__/workflow.service.facade.test.ts +250 -4
- package/src/modules/workflow/agent-tools/__tests__/workflow.tools.test.ts +464 -4
- package/src/modules/workflow/agent-tools/change-request-read.tools.ts +4 -22
- package/src/modules/workflow/agent-tools/workflow.tools.ts +462 -1
- package/src/modules/workflow/git/__tests__/git.service.diffFileBetweenBranches.test.ts +76 -11
- package/src/modules/workflow/git/__tests__/git.service.mergeIntoBranch.test.ts +721 -0
- package/src/modules/workflow/git/git.service.ts +537 -106
- package/src/modules/workflow/read-on-branch.ts +79 -0
- package/src/modules/workflow/workflow.routes.ts +21 -39
- package/src/modules/workflow/workflow.service.ts +203 -16
- package/src/modules/workspace/index.ts +11 -0
- package/src/shared/__tests__/no-link-below.test.ts +45 -0
- package/src/shared/git.contract.ts +10 -0
- package/src/shared/no-link-below.ts +29 -0
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import type { CallTemplate, Tool as UtcpTool } from '@utcp/sdk';
|
|
3
|
+
import type { CodeModeUtcpClient } from '@utcp/code-mode';
|
|
4
|
+
import type { ProxiedTool } from '@bevel-software/platform-mcp-core';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Tool definitions held once for every cached client that discovered the same
|
|
8
|
+
* ones.
|
|
9
|
+
*
|
|
10
|
+
* Two hundred people whose catalogs are identical would otherwise hold two
|
|
11
|
+
* hundred copies of the same descriptions and schemas: a client's repository
|
|
12
|
+
* keeps what discovery parsed, and every build parses its own. After a build,
|
|
13
|
+
* each manual's discovered tools are swapped for the first copy of identical
|
|
14
|
+
* tools this process has seen, so the descriptions, schemas and call templates
|
|
15
|
+
* are one object graph no matter how many clients point at it. A client keeps
|
|
16
|
+
* only what is its own: the repository's per-tool shells, its variables and
|
|
17
|
+
* its secrets loader.
|
|
18
|
+
*
|
|
19
|
+
* Identity is the CONTENT of the definitions, not the manual's name or its
|
|
20
|
+
* template: the knowledge base's manual is filtered per caller, so two people
|
|
21
|
+
* can get different tools from the same template, and only identical content
|
|
22
|
+
* may be shared. Definitions are tool schemas and call TEMPLATES — `${VAR}`
|
|
23
|
+
* placeholders, never a resolved value — so sharing them shares no secret.
|
|
24
|
+
*
|
|
25
|
+
* Nothing here is read back for correctness: a definition dropped from these
|
|
26
|
+
* maps (they are bounded) only stops the NEXT build from sharing it.
|
|
27
|
+
*/
|
|
28
|
+
export class SharedDefinitions {
|
|
29
|
+
private readonly byContent = new Map<string, unknown>();
|
|
30
|
+
private readonly interfaceCaches = new Map<string, Map<string, string>>();
|
|
31
|
+
|
|
32
|
+
/** `maxEntries` bounds each map; 0 shares nothing. */
|
|
33
|
+
constructor(private readonly maxEntries = 1000) {}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The first-seen copy of `value` (by its JSON), or `value` itself when it is
|
|
37
|
+
* the first. Kinds share no key space: a tool list and a listing could
|
|
38
|
+
* otherwise serialise alike.
|
|
39
|
+
*/
|
|
40
|
+
intern<T>(kind: string, value: T): T {
|
|
41
|
+
const key = `${kind}\u0000${fingerprint(value)}`;
|
|
42
|
+
const held = this.byContent.get(key);
|
|
43
|
+
if (held !== undefined) {
|
|
44
|
+
// Recently used goes last, so the first entry is the one to drop.
|
|
45
|
+
this.byContent.delete(key);
|
|
46
|
+
this.byContent.set(key, held);
|
|
47
|
+
return held as T;
|
|
48
|
+
}
|
|
49
|
+
if (this.maxEntries <= 0) return value;
|
|
50
|
+
bound(this.byContent, this.maxEntries);
|
|
51
|
+
this.byContent.set(key, value);
|
|
52
|
+
return value;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Point `client`'s repository at shared definitions: every manual's tools,
|
|
57
|
+
* and the client's cache of generated TypeScript interfaces.
|
|
58
|
+
*
|
|
59
|
+
* `rawTemplates` are the manuals as fetched, by the name they are registered
|
|
60
|
+
* under. Registration stores the template AFTER variable substitution — for
|
|
61
|
+
* a platform-hosted manual, with the bearer of the request that built the
|
|
62
|
+
* client in its headers. Nothing calls through a stored template (each
|
|
63
|
+
* tool's own template is substituted at call time), but a cached client
|
|
64
|
+
* must hold no request's credential, so the stored template is put back to
|
|
65
|
+
* its placeholders.
|
|
66
|
+
*/
|
|
67
|
+
async share(client: CodeModeUtcpClient, rawTemplates: ReadonlyMap<string, CallTemplate>): Promise<void> {
|
|
68
|
+
const repo = client.config.tool_repository;
|
|
69
|
+
const templates = await repo.getManualCallTemplates();
|
|
70
|
+
const everyTool: UtcpTool[] = [];
|
|
71
|
+
for (const stored of templates) {
|
|
72
|
+
const name = String(stored.name ?? '');
|
|
73
|
+
const manual = await repo.getManual(name);
|
|
74
|
+
if (!manual) continue;
|
|
75
|
+
const tools = this.intern('tools', manual.tools);
|
|
76
|
+
everyTool.push(...tools);
|
|
77
|
+
const raw = rawTemplates.get(name);
|
|
78
|
+
const template = raw ? ({ ...raw, name } as CallTemplate) : stored;
|
|
79
|
+
await repo.saveManual(template, { ...manual, tools });
|
|
80
|
+
}
|
|
81
|
+
// `toolFunctionCache` is filled lazily by `tools_info` and every chain,
|
|
82
|
+
// keyed by tool name and derived from the definitions alone. Shared by
|
|
83
|
+
// every client holding the very same tool set, it is generated once for
|
|
84
|
+
// all of them instead of once per person.
|
|
85
|
+
(client as unknown as { toolFunctionCache: Map<string, string> }).toolFunctionCache = this.interfaceCache(
|
|
86
|
+
fingerprint(everyTool),
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** The flattened catalog a surface lists, shared the same way. */
|
|
91
|
+
listing(tools: ProxiedTool[]): ProxiedTool[] {
|
|
92
|
+
return this.intern('listing', tools);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** The generated-interface cache of one exact tool set. */
|
|
96
|
+
private interfaceCache(toolSet: string): Map<string, string> {
|
|
97
|
+
let cache = this.interfaceCaches.get(toolSet);
|
|
98
|
+
if (cache) {
|
|
99
|
+
this.interfaceCaches.delete(toolSet);
|
|
100
|
+
} else {
|
|
101
|
+
cache = new Map();
|
|
102
|
+
if (this.maxEntries <= 0) return cache;
|
|
103
|
+
bound(this.interfaceCaches, this.maxEntries);
|
|
104
|
+
}
|
|
105
|
+
this.interfaceCaches.set(toolSet, cache);
|
|
106
|
+
return cache;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Make room for one more entry, dropping the least recently used. */
|
|
111
|
+
function bound(map: Map<string, unknown>, maxEntries: number): void {
|
|
112
|
+
while (map.size >= maxEntries) {
|
|
113
|
+
const oldest = map.keys().next().value;
|
|
114
|
+
if (oldest === undefined) break;
|
|
115
|
+
map.delete(oldest);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function fingerprint(value: unknown): string {
|
|
120
|
+
return createHash('sha256').update(JSON.stringify(value)).digest('hex');
|
|
121
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The hosted MCP server's memory of built tool surfaces: one per caller and
|
|
3
|
+
* catalog revision, built once and reused until something it was built from
|
|
4
|
+
* changes.
|
|
5
|
+
*
|
|
6
|
+
* Keyed by whatever the service says identifies a surface — the person, the
|
|
7
|
+
* kind of caller, the key's scope and the catalog revision. A catalog change
|
|
8
|
+
* needs no listener: it is a new revision, so a new key, and the old entry
|
|
9
|
+
* ages out unused. What the revision cannot see (a secret) is evicted
|
|
10
|
+
* explicitly through {@link evictWhere} / {@link clear}.
|
|
11
|
+
*
|
|
12
|
+
* Bounded twice: an entry unused for `idleTtlMs` is dropped, and at most
|
|
13
|
+
* `maxEntries` are held, the least recently used going first. `maxEntries: 0`
|
|
14
|
+
* keeps nothing, which is the per-request build this replaced: the escape
|
|
15
|
+
* hatch if the cache ever has to be switched off.
|
|
16
|
+
*
|
|
17
|
+
* Single-flight: concurrent misses on one key share one build. A build that
|
|
18
|
+
* fails is not remembered, so the next request tries again. A build that was
|
|
19
|
+
* in flight when its key was evicted answers the requests waiting on it but is
|
|
20
|
+
* not kept: it was built from what the eviction said is stale. A build still
|
|
21
|
+
* running after `buildTimeoutMs` is let go the same way, so a discovery that
|
|
22
|
+
* hangs holds up the requests already waiting on it, not every later one.
|
|
23
|
+
*
|
|
24
|
+
* Holds references only. A surface owns no connection (its `mcp` manuals route
|
|
25
|
+
* to the downstream pool, which owns and closes those), so dropping an entry
|
|
26
|
+
* is the whole teardown.
|
|
27
|
+
*/
|
|
28
|
+
export interface SurfaceCacheOptions {
|
|
29
|
+
/** Drop an entry unused for this long. Default one hour. */
|
|
30
|
+
idleTtlMs?: number;
|
|
31
|
+
/** Hold at most this many entries. Default 500; 0 keeps nothing. */
|
|
32
|
+
maxEntries?: number;
|
|
33
|
+
/** Let go of a build still running after this long; the next request starts another. Default two minutes. */
|
|
34
|
+
buildTimeoutMs?: number;
|
|
35
|
+
/** Clock, for tests. */
|
|
36
|
+
now?: () => number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export const SURFACE_IDLE_TTL_MS = 60 * 60 * 1000;
|
|
40
|
+
export const SURFACE_BUILD_TIMEOUT_MS = 2 * 60 * 1000;
|
|
41
|
+
export const SURFACE_MAX_ENTRIES = 500;
|
|
42
|
+
|
|
43
|
+
interface Entry<V> {
|
|
44
|
+
value: Promise<V>;
|
|
45
|
+
startedAt: number;
|
|
46
|
+
lastUsed: number;
|
|
47
|
+
/** An absolute end, for a value that must not live its full idle time (see `maxAgeMs`). */
|
|
48
|
+
expiresAt: number;
|
|
49
|
+
/** False once the entry was evicted while its build was still running. */
|
|
50
|
+
live: boolean;
|
|
51
|
+
settled: boolean;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export class SurfaceCache<V> {
|
|
55
|
+
private readonly entries = new Map<string, Entry<V>>();
|
|
56
|
+
private readonly idleTtlMs: number;
|
|
57
|
+
private readonly maxEntries: number;
|
|
58
|
+
private readonly buildTimeoutMs: number;
|
|
59
|
+
private readonly now: () => number;
|
|
60
|
+
|
|
61
|
+
constructor(opts: SurfaceCacheOptions = {}) {
|
|
62
|
+
this.idleTtlMs = opts.idleTtlMs ?? SURFACE_IDLE_TTL_MS;
|
|
63
|
+
this.maxEntries = opts.maxEntries ?? SURFACE_MAX_ENTRIES;
|
|
64
|
+
this.buildTimeoutMs = opts.buildTimeoutMs ?? SURFACE_BUILD_TIMEOUT_MS;
|
|
65
|
+
this.now = opts.now ?? Date.now;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The value for `key`, built by `build` when none is held. `hit` says
|
|
70
|
+
* whether this call found one (or joined a build already running).
|
|
71
|
+
*
|
|
72
|
+
* `maxAgeMs`, read off a built value, ends that entry early whatever its
|
|
73
|
+
* use: a surface built while one of its manuals was failing is kept only
|
|
74
|
+
* as long as that failure would be remembered anyway.
|
|
75
|
+
*/
|
|
76
|
+
async get(
|
|
77
|
+
key: string,
|
|
78
|
+
build: () => Promise<V>,
|
|
79
|
+
maxAgeMs?: (value: V) => number | undefined,
|
|
80
|
+
): Promise<{ value: V; hit: boolean }> {
|
|
81
|
+
this.sweep();
|
|
82
|
+
const now = this.now();
|
|
83
|
+
const held = this.entries.get(key);
|
|
84
|
+
if (held) {
|
|
85
|
+
held.lastUsed = now;
|
|
86
|
+
// Most recently used goes last, so the first entry is always the one to drop.
|
|
87
|
+
this.entries.delete(key);
|
|
88
|
+
this.entries.set(key, held);
|
|
89
|
+
return { value: await held.value, hit: true };
|
|
90
|
+
}
|
|
91
|
+
if (this.maxEntries <= 0) return { value: await build(), hit: false };
|
|
92
|
+
while (this.entries.size >= this.maxEntries) {
|
|
93
|
+
const oldest = this.entries.keys().next().value;
|
|
94
|
+
if (oldest === undefined) break;
|
|
95
|
+
this.drop(oldest);
|
|
96
|
+
}
|
|
97
|
+
const entry: Entry<V> = {
|
|
98
|
+
value: build(),
|
|
99
|
+
startedAt: now,
|
|
100
|
+
lastUsed: now,
|
|
101
|
+
expiresAt: Number.POSITIVE_INFINITY,
|
|
102
|
+
live: true,
|
|
103
|
+
settled: false,
|
|
104
|
+
};
|
|
105
|
+
this.entries.set(key, entry);
|
|
106
|
+
try {
|
|
107
|
+
const value = await entry.value;
|
|
108
|
+
entry.settled = true;
|
|
109
|
+
const maxAge = maxAgeMs?.(value);
|
|
110
|
+
if (maxAge !== undefined) entry.expiresAt = this.now() + maxAge;
|
|
111
|
+
if (!entry.live) this.forget(key, entry);
|
|
112
|
+
return { value, hit: false };
|
|
113
|
+
} catch (err) {
|
|
114
|
+
this.forget(key, entry);
|
|
115
|
+
throw err;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Drop every entry whose key matches. A build in flight still answers its waiters, and is not kept. */
|
|
120
|
+
evictWhere(predicate: (key: string) => boolean): void {
|
|
121
|
+
for (const key of [...this.entries.keys()]) {
|
|
122
|
+
if (predicate(key)) this.drop(key);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Drop everything. */
|
|
127
|
+
clear(): void {
|
|
128
|
+
this.evictWhere(() => true);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
private drop(key: string): void {
|
|
132
|
+
const entry = this.entries.get(key);
|
|
133
|
+
if (!entry) return;
|
|
134
|
+
entry.live = false;
|
|
135
|
+
this.entries.delete(key);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Remove `entry` only if it is still the one under `key` (a newer build may have taken its place). */
|
|
139
|
+
private forget(key: string, entry: Entry<V>): void {
|
|
140
|
+
if (this.entries.get(key) === entry) this.entries.delete(key);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Drop what has been idle too long, or outlived its own end, and let go of a build that has run too long. */
|
|
144
|
+
private sweep(): void {
|
|
145
|
+
const now = this.now();
|
|
146
|
+
for (const [key, entry] of this.entries) {
|
|
147
|
+
if (!entry.settled) {
|
|
148
|
+
if (now - entry.startedAt >= this.buildTimeoutMs) this.drop(key);
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
if (now - entry.lastUsed >= this.idleTtlMs || now >= entry.expiresAt) this.drop(key);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
@@ -3,6 +3,7 @@ import path from 'node:path';
|
|
|
3
3
|
import { LocalFilesystem } from '@mastra/core/workspace';
|
|
4
4
|
import type { IWorkflowService } from '@bevel-software/platform-shared';
|
|
5
5
|
import { LockingFilesystem } from '../kb-fs/locking-filesystem.js';
|
|
6
|
+
import type { WriteValidator } from '../kb-fs/index.js';
|
|
6
7
|
import { ReadOnlyFilesystem } from '../kb-fs/read-only-filesystem.js';
|
|
7
8
|
import {
|
|
8
9
|
makeAgentRolesYamlWriteValidator,
|
|
@@ -76,6 +77,36 @@ export function createToolContextResolver(deps: ToolContextDeps): ResolveToolCon
|
|
|
76
77
|
if (!user) throw new ToolError('Your account is no longer available.', 401);
|
|
77
78
|
|
|
78
79
|
const { loadActiveGroups } = deps;
|
|
80
|
+
const validatorCache = new Map<string, WriteValidator>();
|
|
81
|
+
/**
|
|
82
|
+
* The agent's pre-disk `roles.yaml` gate for `branch`'s workspace
|
|
83
|
+
* (cached): refuses a write that would leave the file unparseable (an
|
|
84
|
+
* app-wide admin lockout), that creates a role, or that adds a
|
|
85
|
+
* `- group:` entry naming no group — agents manage membership; roles are
|
|
86
|
+
* pre-set. ONE gate for every agent write to the workspace: the file
|
|
87
|
+
* tools take it before a byte lands, and `update_branch` takes it for a
|
|
88
|
+
* resolution's content before the merge.
|
|
89
|
+
*/
|
|
90
|
+
const getWriteValidator = async (branch: string): Promise<WriteValidator> => {
|
|
91
|
+
assertBranchProvided(branch);
|
|
92
|
+
const cached = validatorCache.get(branch);
|
|
93
|
+
if (cached) return cached;
|
|
94
|
+
const ws = await deps.workspaceService.getOrCreateForUser(user, branch);
|
|
95
|
+
const basePath = await deps.workspaceService.getWorkspacePath(ws.id);
|
|
96
|
+
const validator = makeAgentRolesYamlWriteValidator(
|
|
97
|
+
deps.kbDirName,
|
|
98
|
+
() => readTextIfExists(path.join(basePath, deps.kbDirName, ROLES_YAML_BASENAME)),
|
|
99
|
+
loadActiveGroups &&
|
|
100
|
+
(async () => {
|
|
101
|
+
// A source that exists but cannot be read leaves group entries
|
|
102
|
+
// unchecked rather than refusing every one.
|
|
103
|
+
const active = await loadActiveGroups((file) => readTextIfExists(path.join(basePath, deps.kbDirName, file)));
|
|
104
|
+
return active.health.ok ? active : null;
|
|
105
|
+
}),
|
|
106
|
+
);
|
|
107
|
+
validatorCache.set(branch, validator);
|
|
108
|
+
return validator;
|
|
109
|
+
};
|
|
79
110
|
const fsCache = new Map<string, LocalFilesystem>();
|
|
80
111
|
const getFilesystem = async (branch: string): Promise<LocalFilesystem> => {
|
|
81
112
|
// THE choke point: every knowledge-base tool resolves its workspace
|
|
@@ -94,6 +125,7 @@ export function createToolContextResolver(deps: ToolContextDeps): ResolveToolCon
|
|
|
94
125
|
assertBranchProvided(branch);
|
|
95
126
|
const cached = fsCache.get(branch);
|
|
96
127
|
if (cached) return cached;
|
|
128
|
+
// (The write validator below is built through `getWriteValidator`.)
|
|
97
129
|
const ws = await deps.workspaceService.getOrCreateForUser(user, branch);
|
|
98
130
|
const workspaceId = ws.id;
|
|
99
131
|
const basePath = await deps.workspaceService.getWorkspacePath(workspaceId);
|
|
@@ -107,24 +139,10 @@ export function createToolContextResolver(deps: ToolContextDeps): ResolveToolCon
|
|
|
107
139
|
branch: branchForWorkspaceId(workspaceId),
|
|
108
140
|
user,
|
|
109
141
|
kbDirName: deps.kbDirName,
|
|
110
|
-
//
|
|
111
|
-
//
|
|
112
|
-
//
|
|
113
|
-
|
|
114
|
-
// Agents manage membership; roles are pre-set.
|
|
115
|
-
validateWrite: makeAgentRolesYamlWriteValidator(
|
|
116
|
-
deps.kbDirName,
|
|
117
|
-
() => readTextIfExists(path.join(basePath, deps.kbDirName, ROLES_YAML_BASENAME)),
|
|
118
|
-
loadActiveGroups &&
|
|
119
|
-
(async () => {
|
|
120
|
-
// A source that exists but cannot be read leaves group
|
|
121
|
-
// entries unchecked rather than refusing every one.
|
|
122
|
-
const active = await loadActiveGroups((file) =>
|
|
123
|
-
readTextIfExists(path.join(basePath, deps.kbDirName, file)),
|
|
124
|
-
);
|
|
125
|
-
return active.health.ok ? active : null;
|
|
126
|
-
}),
|
|
127
|
-
),
|
|
142
|
+
// The agent's pre-disk roles.yaml gate (`getWriteValidator`):
|
|
143
|
+
// the file tools and `update_branch`'s resolutions take the
|
|
144
|
+
// same one.
|
|
145
|
+
validateWrite: await getWriteValidator(branch),
|
|
128
146
|
creatorAccess: deps.creatorAccess,
|
|
129
147
|
},
|
|
130
148
|
)
|
|
@@ -155,6 +173,7 @@ export function createToolContextResolver(deps: ToolContextDeps): ResolveToolCon
|
|
|
155
173
|
workflowService: deps.workflowService,
|
|
156
174
|
events: deps.events,
|
|
157
175
|
getFilesystem,
|
|
176
|
+
getWriteValidator,
|
|
158
177
|
};
|
|
159
178
|
};
|
|
160
179
|
}
|
|
@@ -2,6 +2,7 @@ import type { AuthUser, IWorkflowService } from '@bevel-software/platform-shared
|
|
|
2
2
|
import type { LocalFilesystem } from '@mastra/core/workspace';
|
|
3
3
|
import type { WorkspaceService } from '../workspace/workspace.service.js';
|
|
4
4
|
import type { WorkflowEventBus } from '../workflow/event-bus.js';
|
|
5
|
+
import type { WriteValidator } from '../kb-fs/index.js';
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* The hosting/execution contract a module works against when it lets `toolHelpers`
|
|
@@ -63,6 +64,13 @@ export interface ToolContext {
|
|
|
63
64
|
events: WorkflowEventBus;
|
|
64
65
|
/** Build the lock-aware filesystem for `branch`'s workspace (cached). The tool supplies `branch` from its own input. */
|
|
65
66
|
getFilesystem(branch: string): Promise<LocalFilesystem>;
|
|
67
|
+
/**
|
|
68
|
+
* The agent's pre-disk write gate for `branch`'s workspace (cached) — the
|
|
69
|
+
* one the filesystem above runs before a byte lands (`roles.yaml`: must
|
|
70
|
+
* parse, no new role, no unknown group). A tool that writes content by
|
|
71
|
+
* another road than that filesystem (a merge resolution) takes it too.
|
|
72
|
+
*/
|
|
73
|
+
getWriteValidator(branch: string): Promise<WriteValidator>;
|
|
66
74
|
}
|
|
67
75
|
|
|
68
76
|
/**
|
|
@@ -59,7 +59,7 @@ const CORE_SERVER_SOURCE = new URL('../../../core/create-core-server.ts', import
|
|
|
59
59
|
const MODULES: ReadonlyArray<{ name: string; register: (registry: ToolRegistry) => void }> = [
|
|
60
60
|
{
|
|
61
61
|
name: 'registerWorkflowTools',
|
|
62
|
-
register: (registry) => registerWorkflowTools(registry, express.Router(), pass, handler, testKbContext(), async () => true),
|
|
62
|
+
register: (registry) => registerWorkflowTools(registry, express.Router(), pass, handler, testKbContext(), async () => true, { canRead: async () => true, canReadAtRef: async () => true, canReadBatch: async () => new Map(), canReadBatchAtRef: async () => new Map(), canWrite: async () => true } as never, { recoveryBotEmail: 'recovery@x', hooks: { runAgentRead: async () => undefined }, notes: { onChange: () => undefined } } as never),
|
|
63
63
|
},
|
|
64
64
|
{
|
|
65
65
|
name: 'registerChangeRequestReadTools',
|
|
@@ -117,7 +117,7 @@ async function hexisTools(skills: Parameters<typeof registerSkillsTools>[4] = em
|
|
|
117
117
|
undefined,
|
|
118
118
|
new WorkerPatternMatcher(),
|
|
119
119
|
);
|
|
120
|
-
registerWorkflowTools(registry, router, toolAuth, toolHandler, kb, async () => true);
|
|
120
|
+
registerWorkflowTools(registry, router, toolAuth, toolHandler, kb, async () => true, { canRead: async () => true, canReadAtRef: async () => true, canReadBatch: async () => new Map(), canReadBatchAtRef: async () => new Map(), canWrite: async () => true } as never, { recoveryBotEmail: 'recovery@x', hooks: { runAgentRead: async () => undefined }, notes: { onChange: () => undefined } } as never);
|
|
121
121
|
registerPluginsTools(registry, kb);
|
|
122
122
|
registerSkillsTools(registry, router, toolAuth, toolHandler, skills);
|
|
123
123
|
registerToolManualsTools(registry, router, toolAuth, toolHandler, emptyManuals, {
|
|
@@ -41,7 +41,12 @@ async function makeHarness(): Promise<Harness> {
|
|
|
41
41
|
const accessControl = { canRead, canReadAtRef } as unknown as IAccessControl;
|
|
42
42
|
const workflow = {
|
|
43
43
|
listChangesForFile: vi.fn(async () => []),
|
|
44
|
-
compareFile: vi.fn(async () =>
|
|
44
|
+
compareFile: vi.fn(async (_w: string, _p: string, from: string, to: string, authorize?: (s: { from: string[]; to: string[] }) => Promise<void>) => {
|
|
45
|
+
// Each branch resolves to its served commit and, for `b`, a second one (a
|
|
46
|
+
// local head past the published tip): the verdict is asked at both.
|
|
47
|
+
await authorize?.({ from: [`sha-of-${from}`], to: [`sha-of-${to}`, `other-sha-of-${to}`] });
|
|
48
|
+
return { diff: '', from: `sha-of-${from}`, to: `sha-of-${to}` };
|
|
49
|
+
}),
|
|
45
50
|
showFileAtChange: vi.fn(async () => ''),
|
|
46
51
|
fileAtChange: vi.fn(async () => ({ baseline: null, current: 'x' })),
|
|
47
52
|
};
|
|
@@ -113,23 +118,29 @@ describe('history routes enforce the read model', () => {
|
|
|
113
118
|
expect(h.workflow.listChangesForFile).not.toHaveBeenCalled();
|
|
114
119
|
});
|
|
115
120
|
|
|
116
|
-
it('compare-file authorizes at
|
|
117
|
-
// The
|
|
118
|
-
//
|
|
121
|
+
it('compare-file authorizes at the two commits the diff was served from, never at a ref', async () => {
|
|
122
|
+
// The diff is made first, pinned to the commit each side was read at,
|
|
123
|
+
// and authorized at those before any of it is answered: a branch ref a
|
|
124
|
+
// fetch moves meanwhile cannot have swapped in content the verdict never
|
|
125
|
+
// saw.
|
|
119
126
|
h = await makeHarness();
|
|
120
127
|
const res = await get(`/compare-file?from=a&to=b&path=${OPEN}`);
|
|
121
128
|
expect(res.status).toBe(200);
|
|
122
129
|
const refs = h.canReadAtRef.mock.calls.map((c) => c[1]).sort();
|
|
123
|
-
expect(refs).toEqual(['
|
|
130
|
+
expect(refs).toEqual(['other-sha-of-b', 'sha-of-a', 'sha-of-b']);
|
|
124
131
|
|
|
125
|
-
// Any
|
|
126
|
-
|
|
127
|
-
|
|
132
|
+
// Any commit a side resolves to denying the read refuses the diff — the
|
|
133
|
+
// served one or the other (a grant at the served commit never outruns a
|
|
134
|
+
// denial at the branch's other tip) — disclosing none of it.
|
|
135
|
+
for (const denying of ['sha-of-a', 'sha-of-b', 'other-sha-of-b']) {
|
|
136
|
+
h.canReadAtRef.mockImplementation(async (_w: string, ref: string) => (ref === denying ? false : true));
|
|
137
|
+
const denied = await get(`/compare-file?from=a&to=b&path=${OPEN}`);
|
|
138
|
+
expect(denied.status, denying).toBe(403);
|
|
139
|
+
expect(await denied.json()).not.toHaveProperty('diff');
|
|
140
|
+
}
|
|
128
141
|
|
|
129
|
-
// A
|
|
130
|
-
h.canReadAtRef.mockImplementation(async (_w: string, ref: string) =>
|
|
131
|
-
ref.endsWith('b') ? null : true,
|
|
132
|
-
);
|
|
142
|
+
// A commit whose rules cannot be read grants nothing: refused.
|
|
143
|
+
h.canReadAtRef.mockImplementation(async (_w: string, ref: string) => (ref === 'sha-of-b' ? null : true));
|
|
133
144
|
expect((await get(`/compare-file?from=a&to=b&path=${OPEN}`)).status).toBe(403);
|
|
134
145
|
});
|
|
135
146
|
|