@skanl/brambo-session 0.1.1
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/LICENSE +21 -0
- package/README.md +199 -0
- package/dist/executors.d.ts +146 -0
- package/dist/executors.js +342 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.js +73 -0
- package/dist/methods.d.ts +137 -0
- package/dist/methods.js +264 -0
- package/dist/remote-mcp.d.ts +37 -0
- package/dist/remote-mcp.js +76 -0
- package/dist/run-session.d.ts +300 -0
- package/dist/run-session.js +523 -0
- package/dist/tool-executor.d.ts +4 -0
- package/dist/tool-executor.js +159 -0
- package/dist/usage.d.ts +30 -0
- package/dist/usage.js +110 -0
- package/dist/workspaces.d.ts +63 -0
- package/dist/workspaces.js +126 -0
- package/package.json +59 -0
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { BRAMBO_ERROR_CODES, BramboError, validateSandboxExecutionRequest, validateToolExecutionContext, validateToolInvocationForExecution, validateToolResult } from '@skanl/brambo-contracts';
|
|
2
|
+
const MCP_PROTOCOL_VERSION = '2024-11-05';
|
|
3
|
+
function invalidResponse(message) {
|
|
4
|
+
return new BramboError(BRAMBO_ERROR_CODES.sandboxResponseInvalid, `invalid MCP response: ${message}`);
|
|
5
|
+
}
|
|
6
|
+
function isRecord(value) {
|
|
7
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
8
|
+
}
|
|
9
|
+
function isNonEmptyString(value) {
|
|
10
|
+
return typeof value === 'string' && value.length > 0;
|
|
11
|
+
}
|
|
12
|
+
function validateError(value) {
|
|
13
|
+
if (!isRecord(value) || !Number.isInteger(value['code']) || !isNonEmptyString(value['message'])) {
|
|
14
|
+
throw invalidResponse('error must contain an integer code and non-empty message');
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
function parseResponse(frame, expectedId) {
|
|
18
|
+
let value;
|
|
19
|
+
try {
|
|
20
|
+
value = JSON.parse(frame);
|
|
21
|
+
}
|
|
22
|
+
catch (error) {
|
|
23
|
+
throw invalidResponse(`frame is not JSON (${error instanceof Error ? error.message : 'parse failure'})`);
|
|
24
|
+
}
|
|
25
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value))
|
|
26
|
+
throw invalidResponse('response must be an object');
|
|
27
|
+
const response = value;
|
|
28
|
+
if (response['jsonrpc'] !== '2.0')
|
|
29
|
+
throw invalidResponse("response 'jsonrpc' must be '2.0'");
|
|
30
|
+
if (response['id'] !== expectedId)
|
|
31
|
+
throw invalidResponse(`response id does not match request ${expectedId}`);
|
|
32
|
+
const hasResult = Object.hasOwn(response, 'result');
|
|
33
|
+
const hasError = Object.hasOwn(response, 'error');
|
|
34
|
+
if (hasResult === hasError)
|
|
35
|
+
throw invalidResponse('response must contain exactly one of result or error');
|
|
36
|
+
if (hasError)
|
|
37
|
+
validateError(response['error']);
|
|
38
|
+
return response;
|
|
39
|
+
}
|
|
40
|
+
function validateInitializeResult(value) {
|
|
41
|
+
if (!isRecord(value))
|
|
42
|
+
throw invalidResponse("initialize result must be an object");
|
|
43
|
+
const serverInfo = value['serverInfo'];
|
|
44
|
+
if (!isNonEmptyString(value['protocolVersion']))
|
|
45
|
+
throw invalidResponse("initialize result 'protocolVersion' must be a non-empty string");
|
|
46
|
+
if (!isRecord(value['capabilities']))
|
|
47
|
+
throw invalidResponse("initialize result 'capabilities' must be an object");
|
|
48
|
+
if (!isRecord(serverInfo) || !isNonEmptyString(serverInfo['name']) || !isNonEmptyString(serverInfo['version'])) {
|
|
49
|
+
throw invalidResponse("initialize result 'serverInfo' must contain non-empty name and version strings");
|
|
50
|
+
}
|
|
51
|
+
if (value['instructions'] !== undefined && typeof value['instructions'] !== 'string') {
|
|
52
|
+
throw invalidResponse("initialize result 'instructions' must be a string when present");
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
function validateToolCallResult(value) {
|
|
56
|
+
if (!isRecord(value) || !Array.isArray(value['content']))
|
|
57
|
+
throw invalidResponse("tools/call result 'content' must be an array");
|
|
58
|
+
for (const [index, content] of value['content'].entries()) {
|
|
59
|
+
if (!isRecord(content) || !isNonEmptyString(content['type'])) {
|
|
60
|
+
throw invalidResponse(`tools/call result content[${index}] must contain a non-empty type string`);
|
|
61
|
+
}
|
|
62
|
+
if (content['type'] === 'text' && typeof content['text'] !== 'string') {
|
|
63
|
+
throw invalidResponse(`tools/call result content[${index}] text must be a string`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
if (value['isError'] !== undefined && typeof value['isError'] !== 'boolean') {
|
|
67
|
+
throw invalidResponse("tools/call result 'isError' must be a boolean when present");
|
|
68
|
+
}
|
|
69
|
+
if (value['structuredContent'] !== undefined && !isRecord(value['structuredContent'])) {
|
|
70
|
+
throw invalidResponse("tools/call result 'structuredContent' must be an object when present");
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
function validateResult(method, value) {
|
|
74
|
+
if (method === 'initialize') {
|
|
75
|
+
validateInitializeResult(value);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
if (method === 'tools/call')
|
|
79
|
+
validateToolCallResult(value);
|
|
80
|
+
}
|
|
81
|
+
async function request(stdio, id, method, params, signal) {
|
|
82
|
+
await stdio.sendFrame(JSON.stringify({ jsonrpc: '2.0', id, method, params }), signal);
|
|
83
|
+
const response = parseResponse(await stdio.receiveFrame(signal), id);
|
|
84
|
+
if (Object.hasOwn(response, 'error')) {
|
|
85
|
+
throw new BramboError(BRAMBO_ERROR_CODES.executorRunFailed, `MCP request '${method}' failed`);
|
|
86
|
+
}
|
|
87
|
+
validateResult(method, response['result']);
|
|
88
|
+
return response;
|
|
89
|
+
}
|
|
90
|
+
function mcpResult(session, value) {
|
|
91
|
+
return validateToolResult({
|
|
92
|
+
status: 'ok',
|
|
93
|
+
stdout: JSON.stringify(value),
|
|
94
|
+
stderr: '',
|
|
95
|
+
exitCode: 0,
|
|
96
|
+
enforcement: session.capabilities ?? {
|
|
97
|
+
version: 1,
|
|
98
|
+
providerId: session.providerId,
|
|
99
|
+
enforcement: 'simulated',
|
|
100
|
+
controls: { filesystem: 'none', network: 'none', process: 'none', resources: 'none' },
|
|
101
|
+
},
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
async function executeMcp(session, invocation, context) {
|
|
105
|
+
const requestContext = validateToolExecutionContext(context);
|
|
106
|
+
const sandboxRequest = validateSandboxExecutionRequest({ ...requestContext, argv: [...invocation.tool.argv] });
|
|
107
|
+
if (typeof session.openStdio !== 'function') {
|
|
108
|
+
throw new BramboError(BRAMBO_ERROR_CODES.sandboxUnavailable, `sandbox session '${session.id}' does not expose stdio`);
|
|
109
|
+
}
|
|
110
|
+
const stdio = await session.openStdio(sandboxRequest);
|
|
111
|
+
try {
|
|
112
|
+
await requestMcp(stdio, sandboxRequest.signal);
|
|
113
|
+
const response = await request(stdio, 2, 'tools/call', { name: invocation.tool.name, arguments: invocation.arguments }, sandboxRequest.signal);
|
|
114
|
+
return mcpResult(session, response['result']);
|
|
115
|
+
}
|
|
116
|
+
finally {
|
|
117
|
+
await stdio.close();
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
async function requestMcp(stdio, signal) {
|
|
121
|
+
await request(stdio, 1, 'initialize', {
|
|
122
|
+
protocolVersion: MCP_PROTOCOL_VERSION,
|
|
123
|
+
capabilities: {},
|
|
124
|
+
clientInfo: { name: 'brambo', version: '0.1.0' },
|
|
125
|
+
}, signal);
|
|
126
|
+
await stdio.sendFrame(JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized', params: {} }), signal);
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Binds a caller-owned sandbox session to tool execution. Discovery stays
|
|
130
|
+
* elsewhere: accepting a ToolProvider here would turn listing into authority.
|
|
131
|
+
* This executor never owns or disposes the supplied session.
|
|
132
|
+
*/
|
|
133
|
+
async function executeRemoteMcp(client, invocation, context, session) {
|
|
134
|
+
if (client === undefined)
|
|
135
|
+
throw new BramboError(BRAMBO_ERROR_CODES.sandboxUnavailable, 'remote MCP execution requires an injected client');
|
|
136
|
+
const executionContext = validateToolExecutionContext(context);
|
|
137
|
+
const response = await client.request(invocation.tool.url, 'tools/call', { name: invocation.tool.name, arguments: invocation.arguments }, executionContext.signal);
|
|
138
|
+
return mcpResult(session, response.result);
|
|
139
|
+
}
|
|
140
|
+
export function createToolExecutor(session, remoteMcpClient) {
|
|
141
|
+
return Object.freeze({
|
|
142
|
+
async execute(invocation, context) {
|
|
143
|
+
// Both validations finish before the first await and therefore before the
|
|
144
|
+
// sandbox provider can create or signal a process.
|
|
145
|
+
const executionContext = validateToolExecutionContext(context);
|
|
146
|
+
const tool = validateToolInvocationForExecution(invocation, executionContext);
|
|
147
|
+
if (tool.tool.kind === 'mcp-stdio')
|
|
148
|
+
return executeMcp(session, tool, context);
|
|
149
|
+
if (tool.tool.kind === 'mcp-streamable-http')
|
|
150
|
+
return executeRemoteMcp(remoteMcpClient, tool, context, session);
|
|
151
|
+
const localInvocation = tool;
|
|
152
|
+
const request = validateSandboxExecutionRequest({
|
|
153
|
+
...executionContext,
|
|
154
|
+
argv: [...localInvocation.tool.argv, ...localInvocation.arguments],
|
|
155
|
+
});
|
|
156
|
+
return validateToolResult(await session.execute(request));
|
|
157
|
+
},
|
|
158
|
+
});
|
|
159
|
+
}
|
package/dist/usage.d.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { UsageReport } from '@skanl/brambo-contracts';
|
|
2
|
+
/** Where the observations live: brambo's own directory, one document. */
|
|
3
|
+
export declare function usageObservationsPath(homeDir?: string): string;
|
|
4
|
+
export interface UsageStoreOptions {
|
|
5
|
+
/** Root of the machine scope; `<homeDir>/.brambo` is brambo's own directory. */
|
|
6
|
+
readonly homeDir?: string;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Writes down what one run observed, replacing that executor's previous reading.
|
|
10
|
+
*
|
|
11
|
+
* One reading per executor and no history: the question `brambo status` answers is
|
|
12
|
+
* "how much is left", which only the NEWEST reading answers. A log of past
|
|
13
|
+
* utilisations is a different feature, and nothing reads it.
|
|
14
|
+
*
|
|
15
|
+
* ponytail: read-modify-write, not atomic. Two `brambo run` invocations finishing
|
|
16
|
+
* in the same instant can lose one of the two observations, which costs a stale
|
|
17
|
+
* row until the next run. Upgrade path: write to a sibling temp file and rename,
|
|
18
|
+
* the way `@skanl/brambo-projection` writes ledgers, if concurrent runs become normal.
|
|
19
|
+
*/
|
|
20
|
+
export declare function recordUsageObservation(report: UsageReport, options?: UsageStoreOptions): Promise<void>;
|
|
21
|
+
/**
|
|
22
|
+
* One report per executor brambo ships, in catalogue order. Reads only; it
|
|
23
|
+
* invokes nothing and writes nothing (D6/D7).
|
|
24
|
+
*
|
|
25
|
+
* Three answers, and every one of them is TYPED (AD-5). There is deliberately no
|
|
26
|
+
* fourth answer in which a row is blank or reads `0`: a zero for an executor
|
|
27
|
+
* brambo cannot measure is worse than no row, because it looks like a measurement
|
|
28
|
+
* that was taken.
|
|
29
|
+
*/
|
|
30
|
+
export declare function readUsageReports(options?: UsageStoreOptions): Promise<readonly UsageReport[]>;
|
package/dist/usage.js
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
import { EXECUTOR_CATALOGUE } from '@skanl/brambo-adapter-cli';
|
|
5
|
+
import { USAGE_ABSENCE_REASONS, isUsageReport, usageAbsence } from '@skanl/brambo-contracts';
|
|
6
|
+
// The recorded side of Story M15.A's D7.
|
|
7
|
+
//
|
|
8
|
+
// A quota reading arrives DURING a real invocation, so a report that took one
|
|
9
|
+
// would cost the user the very thing it reports on — and would be unrunnable on
|
|
10
|
+
// exactly the day they most want it. So the run that already paid for the number
|
|
11
|
+
// writes it down, and the report reads what was written. Writing it costs
|
|
12
|
+
// nothing more, and the report answers instantly and offline.
|
|
13
|
+
//
|
|
14
|
+
// What is stored is an OBSERVATION, not a measurement brambo owns: the vendor's
|
|
15
|
+
// own window names, the vendor's own numbers, plus the instant brambo read them.
|
|
16
|
+
// A utilisation is only true as of its reading.
|
|
17
|
+
const STORE_VERSION = 1;
|
|
18
|
+
/** Where the observations live: brambo's own directory, one document. */
|
|
19
|
+
export function usageObservationsPath(homeDir = homedir()) {
|
|
20
|
+
return join(homeDir, '.brambo', 'usage-observations.json');
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* What the document holds, keyed by executor id, or an empty map.
|
|
24
|
+
*
|
|
25
|
+
* Unreadable, unparseable, or stamped with a version this build does not speak
|
|
26
|
+
* all mean the same thing here and it is not a failure: brambo has no observation
|
|
27
|
+
* to report, which `readUsageReports` already states as typed absence. This is a
|
|
28
|
+
* CACHE of readings brambo can take again by running; refusing to answer because
|
|
29
|
+
* of it would be the report failing over its own bookkeeping.
|
|
30
|
+
*/
|
|
31
|
+
async function readStored(path) {
|
|
32
|
+
let text;
|
|
33
|
+
try {
|
|
34
|
+
text = await readFile(path, 'utf8');
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return {};
|
|
38
|
+
}
|
|
39
|
+
let parsed;
|
|
40
|
+
try {
|
|
41
|
+
parsed = JSON.parse(text);
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return {};
|
|
45
|
+
}
|
|
46
|
+
const document = parsed;
|
|
47
|
+
if (document === null || typeof document !== 'object' || document.version !== STORE_VERSION)
|
|
48
|
+
return {};
|
|
49
|
+
const reports = document.reports;
|
|
50
|
+
if (reports === null || typeof reports !== 'object')
|
|
51
|
+
return {};
|
|
52
|
+
const kept = {};
|
|
53
|
+
for (const [executorId, report] of Object.entries(reports)) {
|
|
54
|
+
// Per ENTRY, not per document: one record brambo can no longer understand
|
|
55
|
+
// must not throw away the others beside it.
|
|
56
|
+
if (isUsageReport(report) && report.executorId === executorId)
|
|
57
|
+
kept[executorId] = report;
|
|
58
|
+
}
|
|
59
|
+
return kept;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Writes down what one run observed, replacing that executor's previous reading.
|
|
63
|
+
*
|
|
64
|
+
* One reading per executor and no history: the question `brambo status` answers is
|
|
65
|
+
* "how much is left", which only the NEWEST reading answers. A log of past
|
|
66
|
+
* utilisations is a different feature, and nothing reads it.
|
|
67
|
+
*
|
|
68
|
+
* ponytail: read-modify-write, not atomic. Two `brambo run` invocations finishing
|
|
69
|
+
* in the same instant can lose one of the two observations, which costs a stale
|
|
70
|
+
* row until the next run. Upgrade path: write to a sibling temp file and rename,
|
|
71
|
+
* the way `@skanl/brambo-projection` writes ledgers, if concurrent runs become normal.
|
|
72
|
+
*/
|
|
73
|
+
export async function recordUsageObservation(report, options = {}) {
|
|
74
|
+
const path = usageObservationsPath(options.homeDir);
|
|
75
|
+
const reports = { ...(await readStored(path)), [report.executorId]: report };
|
|
76
|
+
await mkdir(dirname(path), { recursive: true });
|
|
77
|
+
await writeFile(path, `${JSON.stringify({ version: STORE_VERSION, reports }, null, 2)}\n`, 'utf8');
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* One report per executor brambo ships, in catalogue order. Reads only; it
|
|
81
|
+
* invokes nothing and writes nothing (D6/D7).
|
|
82
|
+
*
|
|
83
|
+
* Three answers, and every one of them is TYPED (AD-5). There is deliberately no
|
|
84
|
+
* fourth answer in which a row is blank or reads `0`: a zero for an executor
|
|
85
|
+
* brambo cannot measure is worse than no row, because it looks like a measurement
|
|
86
|
+
* that was taken.
|
|
87
|
+
*/
|
|
88
|
+
export async function readUsageReports(options = {}) {
|
|
89
|
+
const stored = await readStored(usageObservationsPath(options.homeDir));
|
|
90
|
+
return [...EXECUTOR_CATALOGUE.entries()].map(([executorId, shipped]) => {
|
|
91
|
+
// Derived from the trait record rather than from a list of executor names
|
|
92
|
+
// written beside it — the parallel-name-list defect this repo has already
|
|
93
|
+
// shipped once. An executor gains a quota row by declaring a surface.
|
|
94
|
+
if (shipped.traits.output.usageWindows === undefined) {
|
|
95
|
+
return usageAbsence(executorId, USAGE_ABSENCE_REASONS.noUsageSurface, `executor '${executorId}' publishes no usage surface in its output, so brambo has no reading to report for it`);
|
|
96
|
+
}
|
|
97
|
+
return (stored[executorId] ??
|
|
98
|
+
usageAbsence(executorId, USAGE_ABSENCE_REASONS.notObserved,
|
|
99
|
+
// Deliberately does NOT open with brambo's own name. The sentence is a
|
|
100
|
+
// template literal, and packages/cli/test/printed-commands.test.ts reads
|
|
101
|
+
// an opening backtick followed by that name as a COMMAND — so a sentence
|
|
102
|
+
// beginning with it is scanned as a verb that does not exist. Measured:
|
|
103
|
+
// the earlier wording turned this into a fabricated two-word command and
|
|
104
|
+
// reddened that invariant.
|
|
105
|
+
//
|
|
106
|
+
// The command this DOES name is backticked on purpose, so the same
|
|
107
|
+
// invariant dispatches it and E4's exit cannot rot into prose.
|
|
108
|
+
`no usage reading has been recorded for '${executorId}' yet; \`brambo run "<prompt>" --executor ${executorId}\` records one`));
|
|
109
|
+
});
|
|
110
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { ConfigLayer, LayeredConfig } from '@skanl/brambo-kernel';
|
|
2
|
+
import type { WorkspacePlugin } from '@skanl/brambo-workspace-local';
|
|
3
|
+
/** The key inside the `workspace` subtree that names the provider. */
|
|
4
|
+
export declare const WORKSPACE_PROVIDER_CONFIG_KEY = "provider";
|
|
5
|
+
/**
|
|
6
|
+
* Where a project's workspaces live: the `workspace.rootDir` `runSession` seeds,
|
|
7
|
+
* as ONE function rather than two `join` calls.
|
|
8
|
+
*
|
|
9
|
+
* It exists because a second caller arrived. `brambo workspace remove` has to
|
|
10
|
+
* find the ledger and the trees a run created, and a CLI that spelled
|
|
11
|
+
* `.brambo/workspaces` for itself would be a second answer to where brambo's
|
|
12
|
+
* worktrees are — right until a run wrote them somewhere else. The session
|
|
13
|
+
* decides this path; everyone else asks.
|
|
14
|
+
*/
|
|
15
|
+
export declare function worktreeStateDir(projectRoot: string): string;
|
|
16
|
+
/** What brambo mounts when nothing selects otherwise. */
|
|
17
|
+
export declare const DEFAULT_WORKSPACE_PROVIDER_ID = "local";
|
|
18
|
+
/** The `git worktree`-backed provider (`@skanl/brambo-workspace-git-worktree`). */
|
|
19
|
+
export declare const GIT_WORKTREE_PROVIDER_ID = "git-worktree";
|
|
20
|
+
/**
|
|
21
|
+
* What the mount needs that no configuration document supplies.
|
|
22
|
+
*
|
|
23
|
+
* `repoPath` is the repository worktrees are cut from. It is a MOUNT input
|
|
24
|
+
* rather than a config key because it is not a choice a user makes in a
|
|
25
|
+
* document — it is the project the host is already running in, the same `cwd`
|
|
26
|
+
* `workspace.rootDir` is computed from. See the plugin's own note.
|
|
27
|
+
*/
|
|
28
|
+
export interface WorkspaceMountContext {
|
|
29
|
+
readonly repoPath: string;
|
|
30
|
+
}
|
|
31
|
+
/** Every provider id a selection may name, in catalogue order. */
|
|
32
|
+
export declare function availableWorkspaceProviderIds(): readonly string[];
|
|
33
|
+
export interface WorkspaceProviderSelection {
|
|
34
|
+
/** The id that won; always a key of the catalogue. */
|
|
35
|
+
readonly providerId: string;
|
|
36
|
+
/**
|
|
37
|
+
* The layer that supplied it, taken from the layered config's OWN `dump()`.
|
|
38
|
+
* Never recomputed: provenance derived a second time is how a report starts
|
|
39
|
+
* disagreeing with the thing it reports on.
|
|
40
|
+
*/
|
|
41
|
+
readonly layer: ConfigLayer;
|
|
42
|
+
/** Every id a selection may name, for a host that has to render a choice. */
|
|
43
|
+
readonly available: readonly string[];
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* The workspace provider an already-seeded configuration decides, with the layer
|
|
47
|
+
* that decided it. Pure: it reads the composed view and touches no file.
|
|
48
|
+
*
|
|
49
|
+
* Reading the filesystem here is the thing this must not do. `executors.ts`
|
|
50
|
+
* states the rule for its twin: a session primitive whose behaviour depends on
|
|
51
|
+
* files under the running user's home is not usable from a host that already
|
|
52
|
+
* knows what it wants. The caller seeds layers; this reads the composed view.
|
|
53
|
+
*/
|
|
54
|
+
export declare function selectWorkspaceProvider(config: LayeredConfig): WorkspaceProviderSelection;
|
|
55
|
+
/**
|
|
56
|
+
* The plugin for one catalogue id.
|
|
57
|
+
*
|
|
58
|
+
* Separate from the selection so the selection stays a pure value a host can
|
|
59
|
+
* report on without constructing anything — and so there is exactly one place a
|
|
60
|
+
* provider id turns into a plugin. An id the catalogue does not hold is a coded
|
|
61
|
+
* failure, never a fallback.
|
|
62
|
+
*/
|
|
63
|
+
export declare function createSelectedWorkspacePlugin(providerId: string, context: WorkspaceMountContext): WorkspacePlugin;
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
import { BRAMBO_ERROR_CODES, BramboError } from '@skanl/brambo-contracts';
|
|
3
|
+
import { createGitWorktreeWorkspacePlugin } from '@skanl/brambo-workspace-git-worktree';
|
|
4
|
+
import { WORKSPACE_CONFIG_KEY, createWorkspacePlugin } from '@skanl/brambo-workspace-local';
|
|
5
|
+
// Workspace provider SELECTION: which shipped `WorkspaceProvider` this run
|
|
6
|
+
// mounts, decided through the layered configuration brambo already owns.
|
|
7
|
+
//
|
|
8
|
+
// It is the executor selection's twin on purpose (`executors.ts`), down to
|
|
9
|
+
// taking the value and its layer from ONE `dump()` entry. What differs is only
|
|
10
|
+
// the key path: `workspace` is ALREADY an object-namespaced subtree — the
|
|
11
|
+
// session seeds `workspace.rootDir` into it as a layer — so the selection
|
|
12
|
+
// belongs at `workspace.provider` rather than at a second root key that would
|
|
13
|
+
// split one plugin's configuration across two places.
|
|
14
|
+
//
|
|
15
|
+
// Two plugins providing the service `workspace` would be
|
|
16
|
+
// `BRAMBO_KERNEL_SERVICE_CONFLICT`, so this does not compose providers: it
|
|
17
|
+
// CHOOSES one, and the chosen one is the only one registered.
|
|
18
|
+
/** The key inside the `workspace` subtree that names the provider. */
|
|
19
|
+
export const WORKSPACE_PROVIDER_CONFIG_KEY = 'provider';
|
|
20
|
+
/**
|
|
21
|
+
* Where a project's workspaces live: the `workspace.rootDir` `runSession` seeds,
|
|
22
|
+
* as ONE function rather than two `join` calls.
|
|
23
|
+
*
|
|
24
|
+
* It exists because a second caller arrived. `brambo workspace remove` has to
|
|
25
|
+
* find the ledger and the trees a run created, and a CLI that spelled
|
|
26
|
+
* `.brambo/workspaces` for itself would be a second answer to where brambo's
|
|
27
|
+
* worktrees are — right until a run wrote them somewhere else. The session
|
|
28
|
+
* decides this path; everyone else asks.
|
|
29
|
+
*/
|
|
30
|
+
export function worktreeStateDir(projectRoot) {
|
|
31
|
+
return join(projectRoot, '.brambo', 'workspaces');
|
|
32
|
+
}
|
|
33
|
+
/** What brambo mounts when nothing selects otherwise. */
|
|
34
|
+
export const DEFAULT_WORKSPACE_PROVIDER_ID = 'local';
|
|
35
|
+
/** The `git worktree`-backed provider (`@skanl/brambo-workspace-git-worktree`). */
|
|
36
|
+
export const GIT_WORKTREE_PROVIDER_ID = 'git-worktree';
|
|
37
|
+
/**
|
|
38
|
+
* Every workspace provider brambo ships, keyed by the id a document may name.
|
|
39
|
+
*
|
|
40
|
+
* A MAP from id to plugin factory, not a set of ids beside a `switch`. That
|
|
41
|
+
* shape is the one `EXECUTOR_CATALOGUE` arrived at after a parallel name list
|
|
42
|
+
* drifted from the thing it named and shipped an executor nothing ever
|
|
43
|
+
* exercised: here the id list, the closed-catalogue check and the mount are all
|
|
44
|
+
* read out of this one object, so an id cannot exist without a factory and a
|
|
45
|
+
* factory cannot be unreachable by name.
|
|
46
|
+
*/
|
|
47
|
+
const WORKSPACE_PROVIDER_CATALOGUE = new Map([
|
|
48
|
+
// The local plugin takes its `rootDir` from the composed document and needs
|
|
49
|
+
// nothing from the mount, which is why it ignores the context rather than
|
|
50
|
+
// being handed a narrower one.
|
|
51
|
+
[DEFAULT_WORKSPACE_PROVIDER_ID, () => createWorkspacePlugin()],
|
|
52
|
+
[GIT_WORKTREE_PROVIDER_ID, ({ repoPath }) => createGitWorktreeWorkspacePlugin({ repoPath })],
|
|
53
|
+
]);
|
|
54
|
+
/** Every provider id a selection may name, in catalogue order. */
|
|
55
|
+
export function availableWorkspaceProviderIds() {
|
|
56
|
+
return [...WORKSPACE_PROVIDER_CATALOGUE.keys()];
|
|
57
|
+
}
|
|
58
|
+
const DOTTED_KEY = `${WORKSPACE_CONFIG_KEY}.${WORKSPACE_PROVIDER_CONFIG_KEY}`;
|
|
59
|
+
function unusableSelection(detail) {
|
|
60
|
+
// `configurationUnusable` rather than a new code: brambo's own document exists
|
|
61
|
+
// and holds a value brambo cannot use, which is exactly what that code is for
|
|
62
|
+
// (see its note in `@skanl/brambo-contracts`). There is no workspace twin of
|
|
63
|
+
// `BRAMBO_EXECUTOR_NOT_FOUND` and this story does not invent one — the message
|
|
64
|
+
// carries the closed catalogue, which is the half a user acts on.
|
|
65
|
+
return new BramboError(BRAMBO_ERROR_CODES.configurationUnusable, `brambo's '${DOTTED_KEY}' configuration cannot be used: ${detail}`);
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* The catalogue's refusal for a name brambo ships no provider under.
|
|
69
|
+
*
|
|
70
|
+
* ONE spelling, called from the selection and from the mount, because the two
|
|
71
|
+
* would otherwise drift — the failure `EXECUTOR_CATALOGUE`'s own note records.
|
|
72
|
+
*/
|
|
73
|
+
function unknownWorkspaceProvider(providerId) {
|
|
74
|
+
return unusableSelection(`brambo has no workspace provider named '${providerId}'; available providers: ${availableWorkspaceProviderIds().join(', ')}`);
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* The workspace provider an already-seeded configuration decides, with the layer
|
|
78
|
+
* that decided it. Pure: it reads the composed view and touches no file.
|
|
79
|
+
*
|
|
80
|
+
* Reading the filesystem here is the thing this must not do. `executors.ts`
|
|
81
|
+
* states the rule for its twin: a session primitive whose behaviour depends on
|
|
82
|
+
* files under the running user's home is not usable from a host that already
|
|
83
|
+
* knows what it wants. The caller seeds layers; this reads the composed view.
|
|
84
|
+
*/
|
|
85
|
+
export function selectWorkspaceProvider(config) {
|
|
86
|
+
// The value AND its provenance from ONE dump entry, so the two cannot disagree.
|
|
87
|
+
const decided = config
|
|
88
|
+
.dump()
|
|
89
|
+
.find((entry) => entry.path.length === 2 &&
|
|
90
|
+
entry.path[0] === WORKSPACE_CONFIG_KEY &&
|
|
91
|
+
entry.path[1] === WORKSPACE_PROVIDER_CONFIG_KEY);
|
|
92
|
+
if (decided === undefined) {
|
|
93
|
+
// Reachable, unlike its executor twin: `dump()` reports LEAVES, so a
|
|
94
|
+
// document writing an OBJECT at this path leaves no entry here at all. A
|
|
95
|
+
// default taken on this path would mount `local` for a user whose document
|
|
96
|
+
// says something else — silently running somewhere other than where they
|
|
97
|
+
// asked, which is the failure the whole selection exists to remove.
|
|
98
|
+
throw unusableSelection(`no provider is resolvable at this path (a '${DOTTED_KEY}' that is an object rather than one of: ${availableWorkspaceProviderIds().join(', ')} does this)`);
|
|
99
|
+
}
|
|
100
|
+
if (typeof decided.value !== 'string') {
|
|
101
|
+
// Type-checked, never coerced: `String(42)` would turn a typo into the
|
|
102
|
+
// catalogue lookup's problem and report it as an unknown provider name.
|
|
103
|
+
throw unusableSelection(`it must be a string naming one of: ${availableWorkspaceProviderIds().join(', ')}, but the '${decided.layer}' layer supplies ${typeof decided.value}`);
|
|
104
|
+
}
|
|
105
|
+
if (!WORKSPACE_PROVIDER_CATALOGUE.has(decided.value))
|
|
106
|
+
throw unknownWorkspaceProvider(decided.value);
|
|
107
|
+
return {
|
|
108
|
+
providerId: decided.value,
|
|
109
|
+
layer: decided.layer,
|
|
110
|
+
available: availableWorkspaceProviderIds(),
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* The plugin for one catalogue id.
|
|
115
|
+
*
|
|
116
|
+
* Separate from the selection so the selection stays a pure value a host can
|
|
117
|
+
* report on without constructing anything — and so there is exactly one place a
|
|
118
|
+
* provider id turns into a plugin. An id the catalogue does not hold is a coded
|
|
119
|
+
* failure, never a fallback.
|
|
120
|
+
*/
|
|
121
|
+
export function createSelectedWorkspacePlugin(providerId, context) {
|
|
122
|
+
const factory = WORKSPACE_PROVIDER_CATALOGUE.get(providerId);
|
|
123
|
+
if (factory === undefined)
|
|
124
|
+
throw unknownWorkspaceProvider(providerId);
|
|
125
|
+
return factory(context);
|
|
126
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@skanl/brambo-session",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "The brambo session: everything `brambo run` does except argv, JSON and exit codes.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"ai-agent",
|
|
7
|
+
"brambo",
|
|
8
|
+
"session",
|
|
9
|
+
"orchestration",
|
|
10
|
+
"executor"
|
|
11
|
+
],
|
|
12
|
+
"homepage": "https://github.com/SKANL/brambo#readme",
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/SKANL/brambo/issues"
|
|
15
|
+
},
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "git+https://github.com/SKANL/brambo.git",
|
|
19
|
+
"directory": "packages/session"
|
|
20
|
+
},
|
|
21
|
+
"license": "MIT",
|
|
22
|
+
"publishConfig": {
|
|
23
|
+
"access": "public"
|
|
24
|
+
},
|
|
25
|
+
"type": "module",
|
|
26
|
+
"engines": {
|
|
27
|
+
"node": ">=20"
|
|
28
|
+
},
|
|
29
|
+
"exports": {
|
|
30
|
+
".": {
|
|
31
|
+
"brambo-source": "./src/index.ts",
|
|
32
|
+
"types": "./dist/index.d.ts",
|
|
33
|
+
"default": "./dist/index.js"
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
"dependencies": {
|
|
37
|
+
"@skanl/brambo-adapter-cli": "0.1.1",
|
|
38
|
+
"@skanl/brambo-contracts": "0.1.1",
|
|
39
|
+
"@skanl/brambo-kernel": "0.1.1",
|
|
40
|
+
"@skanl/brambo-sandbox": "0.1.1",
|
|
41
|
+
"@skanl/brambo-workspace-git-worktree": "0.1.1",
|
|
42
|
+
"@skanl/brambo-workspace-local": "0.1.1"
|
|
43
|
+
},
|
|
44
|
+
"devDependencies": {
|
|
45
|
+
"@types/node": "^24.13.3",
|
|
46
|
+
"rolldown": "1.2.5",
|
|
47
|
+
"typescript": "~7.0.2",
|
|
48
|
+
"vitest": "^4.1.11"
|
|
49
|
+
},
|
|
50
|
+
"files": [
|
|
51
|
+
"dist"
|
|
52
|
+
],
|
|
53
|
+
"scripts": {
|
|
54
|
+
"typecheck": "tsc --noEmit",
|
|
55
|
+
"test": "vitest run",
|
|
56
|
+
"lint": "eslint .",
|
|
57
|
+
"build": "node ../../scripts/clean-dist.mjs && tsc -p tsconfig.build.json"
|
|
58
|
+
}
|
|
59
|
+
}
|