@lensagent/core 0.1.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/LICENSE +21 -0
- package/README.md +48 -0
- package/config/lens.example.jsonc +92 -0
- package/config/pair.example.json +3 -0
- package/dist/adapters/executor.d.ts +35 -0
- package/dist/adapters/executor.js +296 -0
- package/dist/adapters/lsp.d.ts +18 -0
- package/dist/adapters/lsp.js +218 -0
- package/dist/adapters/mcp.d.ts +32 -0
- package/dist/adapters/mcp.js +283 -0
- package/dist/adapters/revert.d.ts +12 -0
- package/dist/adapters/revert.js +155 -0
- package/dist/config/runtime.d.ts +55 -0
- package/dist/config/runtime.js +371 -0
- package/dist/config/schema.d.ts +292 -0
- package/dist/config/schema.js +177 -0
- package/dist/contracts/adapter.d.ts +28 -0
- package/dist/contracts/adapter.js +8 -0
- package/dist/contracts/capability.d.ts +41 -0
- package/dist/contracts/capability.js +1 -0
- package/dist/contracts/domain.d.ts +61 -0
- package/dist/contracts/domain.js +1 -0
- package/dist/contracts/index.d.ts +5 -0
- package/dist/contracts/index.js +5 -0
- package/dist/contracts/model.d.ts +77 -0
- package/dist/contracts/model.js +1 -0
- package/dist/contracts/ports.d.ts +14 -0
- package/dist/contracts/ports.js +14 -0
- package/dist/core/access.d.ts +24 -0
- package/dist/core/access.js +205 -0
- package/dist/core/codec.d.ts +33 -0
- package/dist/core/codec.js +105 -0
- package/dist/core/domain.d.ts +13 -0
- package/dist/core/domain.js +24 -0
- package/dist/core/host.d.ts +19 -0
- package/dist/core/host.js +249 -0
- package/dist/core/identity.d.ts +19 -0
- package/dist/core/identity.js +54 -0
- package/dist/entry/bun.d.ts +1 -0
- package/dist/entry/bun.js +2 -0
- package/dist/entry/node.d.ts +2 -0
- package/dist/entry/node.js +3 -0
- package/dist/entry/rebuild.d.ts +1 -0
- package/dist/entry/rebuild.js +47 -0
- package/dist/entry/run.d.ts +1 -0
- package/dist/entry/run.js +43 -0
- package/dist/execution/lsp-process.d.ts +14 -0
- package/dist/execution/lsp-process.js +234 -0
- package/dist/http/server.d.ts +34 -0
- package/dist/http/server.js +1332 -0
- package/dist/launch.d.ts +3 -0
- package/dist/launch.js +4 -0
- package/dist/runtime/agent.d.ts +35 -0
- package/dist/runtime/agent.js +853 -0
- package/dist/runtime/artifacts.d.ts +15 -0
- package/dist/runtime/artifacts.js +72 -0
- package/dist/runtime/cache.d.ts +20 -0
- package/dist/runtime/cache.js +44 -0
- package/dist/runtime/calls.d.ts +21 -0
- package/dist/runtime/calls.js +130 -0
- package/dist/runtime/capabilities.d.ts +58 -0
- package/dist/runtime/capabilities.js +196 -0
- package/dist/runtime/cleanup.d.ts +7 -0
- package/dist/runtime/cleanup.js +33 -0
- package/dist/runtime/context.d.ts +34 -0
- package/dist/runtime/context.js +169 -0
- package/dist/runtime/diagnostics.d.ts +8 -0
- package/dist/runtime/diagnostics.js +71 -0
- package/dist/runtime/events.d.ts +22 -0
- package/dist/runtime/events.js +131 -0
- package/dist/runtime/kernel-capabilities.d.ts +32 -0
- package/dist/runtime/kernel-capabilities.js +200 -0
- package/dist/runtime/manifests.d.ts +14 -0
- package/dist/runtime/manifests.js +117 -0
- package/dist/runtime/model.d.ts +20 -0
- package/dist/runtime/model.js +241 -0
- package/dist/runtime/observability.d.ts +22 -0
- package/dist/runtime/observability.js +104 -0
- package/dist/runtime/policy.d.ts +8 -0
- package/dist/runtime/policy.js +31 -0
- package/dist/runtime/profile.d.ts +8 -0
- package/dist/runtime/profile.js +52 -0
- package/dist/runtime/projects.d.ts +40 -0
- package/dist/runtime/projects.js +115 -0
- package/dist/runtime/results.d.ts +7 -0
- package/dist/runtime/results.js +55 -0
- package/dist/runtime/review.d.ts +9 -0
- package/dist/runtime/review.js +54 -0
- package/dist/runtime/scheduler.d.ts +15 -0
- package/dist/runtime/scheduler.js +85 -0
- package/dist/runtime/skills.d.ts +2 -0
- package/dist/runtime/skills.js +83 -0
- package/dist/runtime/tokenizer.d.ts +8 -0
- package/dist/runtime/tokenizer.js +30 -0
- package/dist/store/database.d.ts +3 -0
- package/dist/store/database.js +49 -0
- package/dist/store/event-log.d.ts +19 -0
- package/dist/store/event-log.js +61 -0
- package/dist/store/schema.d.ts +1 -0
- package/dist/store/schema.js +347 -0
- package/dist/store/store.d.ts +344 -0
- package/dist/store/store.js +2554 -0
- package/package.json +73 -0
- package/proto/executor.proto +314 -0
|
@@ -0,0 +1,1332 @@
|
|
|
1
|
+
import { applyPurge } from '../runtime/cleanup.js';
|
|
2
|
+
import Fastify from 'fastify';
|
|
3
|
+
import { digest, expectedRevision, integer, jsonValue, optionalText, record, text } from '../core/codec.js';
|
|
4
|
+
import { LensError, fail } from '../core/domain.js';
|
|
5
|
+
import { ExecutorCallError } from '../adapters/executor.js';
|
|
6
|
+
import { resolveReasoningEffort } from '../runtime/profile.js';
|
|
7
|
+
import { componentIdentity } from '../core/identity.js';
|
|
8
|
+
export function createServer(dependencies) {
|
|
9
|
+
const app = Fastify({ logger: false, bodyLimit: 4 * 1024 * 1024, requestIdHeader: 'x-correlation-id', ajv: { customOptions: { removeAdditional: false } }, ...(dependencies.https === undefined ? {} : { https: dependencies.https }) });
|
|
10
|
+
const contexts = new WeakMap();
|
|
11
|
+
const starts = new WeakMap();
|
|
12
|
+
const specs = [];
|
|
13
|
+
app.setErrorHandler((error, request, reply) => {
|
|
14
|
+
const failure = error instanceof LensError ? error
|
|
15
|
+
: typeof error === 'object' && error !== null && 'validation' in error ? new LensError('SCHEMA_INVALID', 400, 'The request does not match the Route Schema.', schemaDetails(error))
|
|
16
|
+
: fastifyRequestError(error) ?? new LensError('INTERNAL_ERROR', 500, 'An internal error occurred.');
|
|
17
|
+
if (failure.code === 'INTERNAL_ERROR') {
|
|
18
|
+
dependencies.observability.record('http', 'unhandled', 'failed', 0, {
|
|
19
|
+
error_name: error instanceof Error ? error.name : typeof error,
|
|
20
|
+
error_code: typeof error === 'object' && error !== null && 'code' in error && typeof error.code === 'string' ? error.code : null
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
void reply.status(failure.status).send({
|
|
24
|
+
error: { code: failure.code, message: failure.message, retryable: retryableFailure(failure.status), details: failure.details },
|
|
25
|
+
correlation_id: request.id
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
app.addHook('onRequest', async (request) => {
|
|
29
|
+
starts.set(request, performance.now());
|
|
30
|
+
if (!request.url.startsWith('/api/v1/'))
|
|
31
|
+
return;
|
|
32
|
+
const readiness = dependencies.ready();
|
|
33
|
+
if (readiness.mode !== 'full')
|
|
34
|
+
fail('RUNTIME_NOT_READY', 503, 'Lens Core is not ready.', { blockers: readiness.blockers });
|
|
35
|
+
const ingress = await dependencies.pair.authorize(request.headers, dependencies.config.current.config.tls.enabled ? 'https' : 'http', request.id);
|
|
36
|
+
const projectId = resolveProjectId(request, dependencies.defaultProjectId);
|
|
37
|
+
// Name the Project that was not found. An anonymous 404 here is indistinguishable from a
|
|
38
|
+
// missing Route, and the id is supplied by the caller in a header, so echoing it back is
|
|
39
|
+
// the fastest way to see a stale or malformed scope.
|
|
40
|
+
try {
|
|
41
|
+
dependencies.store.getProject(projectId);
|
|
42
|
+
}
|
|
43
|
+
catch (error) {
|
|
44
|
+
if (error instanceof LensError && error.code === 'PROJECT_NOT_FOUND') {
|
|
45
|
+
fail('PROJECT_NOT_FOUND', 404, `Project ${projectId} does not exist.`, { project_id: projectId });
|
|
46
|
+
}
|
|
47
|
+
throw error;
|
|
48
|
+
}
|
|
49
|
+
dependencies.runtime(projectId);
|
|
50
|
+
rejectReservedFields(request.body);
|
|
51
|
+
contexts.set(request, { ingress, projectId });
|
|
52
|
+
});
|
|
53
|
+
// Every JSON envelope carries the correlation id. 02a-http-route-schemas.md defines
|
|
54
|
+
// SuccessEnvelope/ErrorEnvelope as `{ data | error, correlation_id }`, and clients use it
|
|
55
|
+
// to tie a response back to a request. Injected here rather than in each handler so a new
|
|
56
|
+
// Route cannot forget it: handlers keep returning plain `{ data }`.
|
|
57
|
+
app.addHook('preSerialization', async (request, _reply, payload) => {
|
|
58
|
+
if (typeof payload !== 'object' || payload === null || Array.isArray(payload))
|
|
59
|
+
return payload;
|
|
60
|
+
const envelope = payload;
|
|
61
|
+
if (!('data' in envelope) && !('error' in envelope))
|
|
62
|
+
return payload;
|
|
63
|
+
const normalized = 'data' in envelope ? { ...envelope, data: wireShape(envelope.data) } : envelope;
|
|
64
|
+
if (typeof normalized.correlation_id === 'string')
|
|
65
|
+
return normalized;
|
|
66
|
+
return { ...normalized, correlation_id: request.id };
|
|
67
|
+
});
|
|
68
|
+
app.addHook('onResponse', (request, reply, done) => {
|
|
69
|
+
const started = starts.get(request) ?? performance.now();
|
|
70
|
+
// The caller's requested scope is logged on failures. Without it a 404 on a Project-scoped
|
|
71
|
+
// Route is indistinguishable from a missing Route, and the scope arrives in a header the
|
|
72
|
+
// Server cannot otherwise account for after the fact.
|
|
73
|
+
// The scope can arrive as a header or, for the SSE Route, as a query parameter.
|
|
74
|
+
const headerScope = request.headers['x-lens-project-id'];
|
|
75
|
+
const queryScope = request.query.project_id;
|
|
76
|
+
const scope = typeof headerScope === 'string' ? headerScope : typeof queryScope === 'string' ? queryScope : undefined;
|
|
77
|
+
dependencies.observability.record('http', routePath(request), reply.statusCode < 400 ? 'success' : 'failed', performance.now() - started, {
|
|
78
|
+
status: reply.statusCode,
|
|
79
|
+
...(reply.statusCode >= 400 && typeof scope === 'string' ? { requested_project_id: scope } : {})
|
|
80
|
+
});
|
|
81
|
+
done();
|
|
82
|
+
});
|
|
83
|
+
const route = (method, path, handler) => {
|
|
84
|
+
const normalizedMethod = method.toLowerCase();
|
|
85
|
+
specs.push({ method: normalizedMethod, path });
|
|
86
|
+
const bodySchema = requestBody(normalizedMethod, path);
|
|
87
|
+
app.route({ method, url: path, handler, ...(bodySchema === null ? {} : { schema: { body: bodySchema } }) });
|
|
88
|
+
};
|
|
89
|
+
const project = (request) => {
|
|
90
|
+
const context = contexts.get(request);
|
|
91
|
+
if (context === undefined)
|
|
92
|
+
fail('TRUSTED_INGRESS_REQUIRED', 500, 'Trusted ingress is missing.');
|
|
93
|
+
return context.projectId;
|
|
94
|
+
};
|
|
95
|
+
const ingressCommand = (request, operation, payload, missingCode) => {
|
|
96
|
+
const context = contexts.get(request);
|
|
97
|
+
if (context === undefined)
|
|
98
|
+
fail('TRUSTED_INGRESS_REQUIRED', 500, 'Trusted ingress is missing.');
|
|
99
|
+
const key = idempotencyKey(request, missingCode);
|
|
100
|
+
return {
|
|
101
|
+
clientId: context.ingress.clientId,
|
|
102
|
+
key,
|
|
103
|
+
operation,
|
|
104
|
+
digest: digest({ version: 'wire-request-v1', client_id: context.ingress.clientId, project_id: context.projectId, operation, payload })
|
|
105
|
+
};
|
|
106
|
+
};
|
|
107
|
+
route('GET', '/health/live', () => ({ data: { status: 'live', instance_epoch: dependencies.events.instanceEpoch, ...componentSummary() } }));
|
|
108
|
+
route('GET', '/health/ready', (_request, reply) => {
|
|
109
|
+
const ready = dependencies.ready();
|
|
110
|
+
// The identity fields repeat in the failure envelope on purpose. A manager distinguishing an
|
|
111
|
+
// unhealthy Core from a foreign program on the configured port has only this response to work
|
|
112
|
+
// from, and readiness failure is exactly when it needs the distinction: without the identity
|
|
113
|
+
// in the 503 the two look alike, and offering a hard kill on that basis can terminate an
|
|
114
|
+
// unrelated process. `pid` is what the endpoint-owner lookup verifies against.
|
|
115
|
+
if (ready.mode !== 'full')
|
|
116
|
+
return reply.status(503).send({ error: { code: 'RUNTIME_NOT_READY', message: 'Lens Core is not ready.', details: { blockers: ready.blockers, ...componentSummary() } } });
|
|
117
|
+
return {
|
|
118
|
+
data: {
|
|
119
|
+
status: 'ready',
|
|
120
|
+
...componentSummary(),
|
|
121
|
+
state_revision: String(dependencies.store.stateRevision()),
|
|
122
|
+
metadata_revision: String(metadataRevision(dependencies)),
|
|
123
|
+
config_digest: dependencies.config.current.digest,
|
|
124
|
+
pair_digest: dependencies.pair.pairDigest,
|
|
125
|
+
runtime_contract_digest: dependencies.runtime(dependencies.defaultProjectId).contractDigest(),
|
|
126
|
+
pending_restart: dependencies.config.current.pendingRestart,
|
|
127
|
+
access_contract: accessContract(dependencies)
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
});
|
|
131
|
+
/**
|
|
132
|
+
* Graceful stop as a component control path rather than an OS signal, which is what an external
|
|
133
|
+
* manager is expected to use on both Windows and macOS.
|
|
134
|
+
*
|
|
135
|
+
* Deliberately outside `/api/v1/`, so the `onRequest` hook applies neither the readiness gate
|
|
136
|
+
* nor the pair whitelist: stopping a Core that is unready is the main case, and the caller is a
|
|
137
|
+
* process manager, not a paired Lens client.
|
|
138
|
+
*
|
|
139
|
+
* That combination is also why the remote address is checked. `listen.host` may be a LAN
|
|
140
|
+
* address, and an unauthenticated route that ends the process would then be a remote kill
|
|
141
|
+
* switch reachable by anyone on the network.
|
|
142
|
+
*/
|
|
143
|
+
route('POST', '/health/shutdown', (request, reply) => {
|
|
144
|
+
requireLoopback(request);
|
|
145
|
+
// Acknowledge first, tear down after the response has actually left the socket. The manager
|
|
146
|
+
// observes Stopped by polling, so a dropped acknowledgement is indistinguishable from a Route
|
|
147
|
+
// that does not exist, and the reported failure would send it to Force Kill instead.
|
|
148
|
+
reply.raw.once('finish', () => { dependencies.requestShutdown(); });
|
|
149
|
+
return reply.status(202).send({ data: { status: 'stopping', ...componentSummary() } });
|
|
150
|
+
});
|
|
151
|
+
route('GET', '/api/v1/openapi.json', () => openapi(specs));
|
|
152
|
+
route('GET', '/api/v1/config', () => ({ data: dependencies.config.document(), meta: configMeta(dependencies) }));
|
|
153
|
+
route('POST', '/api/v1/config/validate', (request) => {
|
|
154
|
+
const source = text(body(request).content, 'content', 4 * 1024 * 1024);
|
|
155
|
+
return { data: dependencies.config.validate(source) };
|
|
156
|
+
});
|
|
157
|
+
route('PUT', '/api/v1/config', async (request) => {
|
|
158
|
+
const input = body(request);
|
|
159
|
+
await dependencies.config.replace(text(input.content, 'content', 4 * 1024 * 1024), text(input.expected_file_digest, 'expected_file_digest'));
|
|
160
|
+
return { data: dependencies.config.document(), meta: configMeta(dependencies) };
|
|
161
|
+
});
|
|
162
|
+
route('GET', '/api/v1/config/options', (request) => ({ data: configOptions(dependencies, project(request)) }));
|
|
163
|
+
route('GET', '/api/v1/capabilities', (request) => {
|
|
164
|
+
// 02A: Page<CapabilityCatalogEntry>. A bare array shares no shape with that, so a client
|
|
165
|
+
// reading `items` got undefined and `items.length` threw during render. The catalog is not
|
|
166
|
+
// paginated in practice, so the envelope reports the whole set in one page.
|
|
167
|
+
return { data: wholePage(dependencies.runtime(project(request)).registry.catalog(), dependencies) };
|
|
168
|
+
});
|
|
169
|
+
route('GET', '/api/v1/capabilities/:capability_id', (request) => ({ data: dependencies.runtime(project(request)).registry.get(param(request, 'capability_id')) }));
|
|
170
|
+
route('GET', '/api/v1/projects', (request) => ({ data: dependencies.store.listProjects(page(request), archiveMode(request)) }));
|
|
171
|
+
route('GET', '/api/v1/projects/browse', async (request, reply) => ({ data: await dependencies.executor.browse(optionalText(query(request).path, 'path') ?? process.cwd(), clientSignal(request, reply)) }));
|
|
172
|
+
route('POST', '/api/v1/projects/browse/open', async (request, reply) => ({ data: await dependencies.executor.open(paths(body(request).workspace_paths), text(body(request).path, 'path'), clientSignal(request, reply)) }));
|
|
173
|
+
route('POST', '/api/v1/projects/choose-folder', async (request, reply) => ({ data: await dependencies.executor.chooseFolder(optionalText(body(request).initial_path, 'initial_path'), clientSignal(request, reply)) }));
|
|
174
|
+
route('POST', '/api/v1/projects', async (request, reply) => {
|
|
175
|
+
const input = body(request);
|
|
176
|
+
const workspacePaths = paths(input.workspace_paths ?? input.paths);
|
|
177
|
+
const validation = await validateWorkspacePaths(dependencies, workspacePaths, input.create_if_missing === true);
|
|
178
|
+
return reply.status(201).send({ data: dependencies.store.createProject(text(input.name, 'name', 256), validatedWorkspacePaths(validation)) });
|
|
179
|
+
});
|
|
180
|
+
route('GET', '/api/v1/projects/:project_id', (request) => ({ data: dependencies.store.projectDetail(param(request, 'project_id')) }));
|
|
181
|
+
route('PATCH', '/api/v1/projects/:project_id', (request) => ({ data: dependencies.store.updateProject(param(request, 'project_id'), expectedRevision(body(request).expected_revision), text(body(request).name, 'name', 256)) }));
|
|
182
|
+
route('POST', '/api/v1/projects/:project_id/archive', (request) => ({ data: dependencies.store.archiveProject(param(request, 'project_id'), expectedRevision(body(request).expected_revision), true) }));
|
|
183
|
+
route('POST', '/api/v1/projects/:project_id/restore', (request) => ({ data: dependencies.store.archiveProject(param(request, 'project_id'), expectedRevision(body(request).expected_revision), false) }));
|
|
184
|
+
route('GET', '/api/v1/projects/:project_id/workspaces', (request) => ({ data: wholePage(dependencies.store.listWorkspaces(param(request, 'project_id')), dependencies) }));
|
|
185
|
+
route('POST', '/api/v1/projects/:project_id/workspaces', async (request, reply) => {
|
|
186
|
+
const projectId = param(request, 'project_id');
|
|
187
|
+
const input = body(request);
|
|
188
|
+
const workspacePaths = paths(input.paths);
|
|
189
|
+
const validation = await validateWorkspacePaths(dependencies, workspacePaths, input.create_if_missing === true);
|
|
190
|
+
const workspace = dependencies.store.createWorkspace(projectId, text(input.name, 'name', 256), validatedWorkspacePaths(validation), input.is_default === true);
|
|
191
|
+
dependencies.refreshProject(projectId);
|
|
192
|
+
return reply.status(201).send({ data: workspace });
|
|
193
|
+
});
|
|
194
|
+
route('GET', '/api/v1/projects/:project_id/workspaces/:workspace_id', (request) => ({ data: dependencies.store.getWorkspace(param(request, 'project_id'), param(request, 'workspace_id')) }));
|
|
195
|
+
route('PATCH', '/api/v1/projects/:project_id/workspaces/:workspace_id', async (request) => {
|
|
196
|
+
const projectId = param(request, 'project_id');
|
|
197
|
+
const input = body(request);
|
|
198
|
+
const workspacePaths = paths(input.paths);
|
|
199
|
+
const validation = await validateWorkspacePaths(dependencies, workspacePaths, input.create_if_missing === true);
|
|
200
|
+
const workspace = dependencies.store.updateWorkspace(projectId, param(request, 'workspace_id'), expectedRevision(input.expected_revision), text(input.name, 'name', 256), validatedWorkspacePaths(validation), input.is_default === true);
|
|
201
|
+
dependencies.refreshProject(projectId);
|
|
202
|
+
return { data: workspace };
|
|
203
|
+
});
|
|
204
|
+
route('DELETE', '/api/v1/projects/:project_id/workspaces/:workspace_id', (request) => {
|
|
205
|
+
const projectId = param(request, 'project_id');
|
|
206
|
+
dependencies.store.deleteWorkspace(projectId, param(request, 'workspace_id'), queryRevision(request));
|
|
207
|
+
dependencies.refreshProject(projectId);
|
|
208
|
+
return { data: { ok: true } };
|
|
209
|
+
});
|
|
210
|
+
route('GET', '/api/v1/projects/:project_id/tasks', (request) => ({ data: dependencies.store.listTasks(param(request, 'project_id'), page(request), query(request).archived === 'all') }));
|
|
211
|
+
route('POST', '/api/v1/projects/:project_id/tasks', (request, reply) => reply.status(201).send({ data: dependencies.store.createTask(param(request, 'project_id'), text(body(request).title, 'title', 256)) }));
|
|
212
|
+
route('GET', '/api/v1/tasks', (request) => ({ data: dependencies.store.listAllTasks(project(request), page(request), query(request).archived === 'all') }));
|
|
213
|
+
route('POST', '/api/v1/tasks', (request, reply) => reply.status(201).send({ data: dependencies.store.createTask(project(request), text(body(request).title, 'title', 256)) }));
|
|
214
|
+
route('GET', '/api/v1/tasks/:task_id', (request) => ({ data: dependencies.store.taskDetail(project(request), param(request, 'task_id')) }));
|
|
215
|
+
route('PATCH', '/api/v1/tasks/:task_id', (request) => ({ data: dependencies.store.updateTask(project(request), param(request, 'task_id'), expectedRevision(body(request).expected_revision), text(body(request).title, 'title', 256)) }));
|
|
216
|
+
route('POST', '/api/v1/tasks/:task_id/archive', (request) => ({ data: dependencies.store.archiveTask(project(request), param(request, 'task_id'), expectedRevision(body(request).expected_revision), true) }));
|
|
217
|
+
route('POST', '/api/v1/tasks/:task_id/restore', (request) => ({ data: dependencies.store.archiveTask(project(request), param(request, 'task_id'), expectedRevision(body(request).expected_revision), false) }));
|
|
218
|
+
route('DELETE', '/api/v1/tasks/:task_id', async (request) => {
|
|
219
|
+
const purged = dependencies.store.deleteTask(project(request), param(request, 'task_id'), queryRevision(request));
|
|
220
|
+
await applyPurge(purged, dependencies.artifacts, dependencies.executor);
|
|
221
|
+
return { data: { ok: true } };
|
|
222
|
+
});
|
|
223
|
+
route('GET', '/api/v1/tasks/:task_id/sessions', (request) => ({ data: dependencies.store.listSessions(project(request), param(request, 'task_id'), page(request), query(request).archived === 'all') }));
|
|
224
|
+
route('POST', '/api/v1/tasks/:task_id/sessions', (request, reply) => {
|
|
225
|
+
const projectId = project(request);
|
|
226
|
+
const taskId = param(request, 'task_id');
|
|
227
|
+
const input = body(request);
|
|
228
|
+
const title = text(input.title, 'title', 256);
|
|
229
|
+
const defaults = agentDefaults(dependencies, projectId, 'agent', input);
|
|
230
|
+
const workspaceId = optionalText(input.workspace_id, 'workspace_id') ?? defaultWorkspaceId(dependencies.store, projectId);
|
|
231
|
+
const initialPrompt = optionalText(input.initial_prompt, 'initial_prompt');
|
|
232
|
+
const initialMessage = optionalText(input.initial_message, 'initial_message');
|
|
233
|
+
const payload = {
|
|
234
|
+
task_id: taskId,
|
|
235
|
+
title,
|
|
236
|
+
workspace_id: workspaceId,
|
|
237
|
+
model: defaults.model,
|
|
238
|
+
model_reasoning_effort: defaults.modelReasoningEffort,
|
|
239
|
+
policy_preset: defaults.policyPreset,
|
|
240
|
+
initial_prompt: initialPrompt ?? null,
|
|
241
|
+
initial_message: initialMessage ?? null
|
|
242
|
+
};
|
|
243
|
+
const command = ingressCommand(request, 'session.create', payload, 'SESSION_REQUEST_KEY_REQUIRED');
|
|
244
|
+
const created = dependencies.store.createSessionWithAgent(projectId, {
|
|
245
|
+
taskId,
|
|
246
|
+
title,
|
|
247
|
+
workspaceId,
|
|
248
|
+
agentName: optionalText(input.agent_name, 'agent_name', 256) ?? title,
|
|
249
|
+
...defaults,
|
|
250
|
+
...(initialPrompt === undefined ? {} : { initialPrompt }),
|
|
251
|
+
...(initialMessage === undefined ? {} : { initialMessage }),
|
|
252
|
+
ingress: command
|
|
253
|
+
});
|
|
254
|
+
const agent = jsonObject(created.agent) ? created.agent : null;
|
|
255
|
+
if (agent !== null && typeof agent.id === 'string' && created.run !== null)
|
|
256
|
+
dependencies.runtime(projectId).agents.wake(projectId, agent.id);
|
|
257
|
+
return reply.status(201).send({ data: created });
|
|
258
|
+
});
|
|
259
|
+
route('GET', '/api/v1/tasks/:task_id/agents', (request) => ({ data: wholePage(dependencies.store.listAgents(project(request), 'task_id', param(request, 'task_id')), dependencies) }));
|
|
260
|
+
route('POST', '/api/v1/tasks/:task_id/agents', (request, reply) => reply.status(201).send({ data: createAgent(dependencies, project(request), param(request, 'task_id'), body(request)) }));
|
|
261
|
+
route('GET', '/api/v1/sessions/:session_id', (request) => ({ data: dependencies.store.sessionDetail(project(request), param(request, 'session_id')) }));
|
|
262
|
+
route('PATCH', '/api/v1/sessions/:session_id', (request) => ({ data: dependencies.store.updateSession(project(request), param(request, 'session_id'), expectedRevision(body(request).expected_revision), text(body(request).title, 'title', 256)) }));
|
|
263
|
+
route('POST', '/api/v1/sessions/:session_id/archive', (request) => ({ data: dependencies.store.archiveSession(project(request), param(request, 'session_id'), expectedRevision(body(request).expected_revision), true) }));
|
|
264
|
+
route('POST', '/api/v1/sessions/:session_id/restore', (request) => ({ data: dependencies.store.archiveSession(project(request), param(request, 'session_id'), expectedRevision(body(request).expected_revision), false) }));
|
|
265
|
+
route('GET', '/api/v1/sessions/:session_id/snapshot', (request) => ({ data: dependencies.store.sessionSnapshot(project(request), param(request, 'session_id')) }));
|
|
266
|
+
route('GET', '/api/v1/sessions/:session_id/timeline', (request) => ({ data: dependencies.store.sessionTimeline(project(request), param(request, 'session_id'), page(request)) }));
|
|
267
|
+
route('GET', '/api/v1/sessions/:session_id/agents', (request) => ({ data: wholePage(dependencies.store.listAgents(project(request), 'session_id', param(request, 'session_id')), dependencies) }));
|
|
268
|
+
route('POST', '/api/v1/sessions/:session_id/agents', (request, reply) => {
|
|
269
|
+
const session = dependencies.store.getSession(project(request), param(request, 'session_id'));
|
|
270
|
+
return reply.status(201).send({ data: createAgent(dependencies, project(request), stringValue(session.task_id), { ...body(request), session_id: session.id }) });
|
|
271
|
+
});
|
|
272
|
+
route('GET', '/api/v1/agents/:agent_id', (request) => ({ data: dependencies.store.agentDetail(project(request), param(request, 'agent_id')) }));
|
|
273
|
+
route('PATCH', '/api/v1/agents/:agent_id', (request) => {
|
|
274
|
+
const input = body(request);
|
|
275
|
+
const current = dependencies.store.getAgent(project(request), param(request, 'agent_id'));
|
|
276
|
+
return { data: dependencies.store.updateAgent(project(request), param(request, 'agent_id'), expectedRevision(input.expected_revision), {
|
|
277
|
+
name: optionalText(input.name, 'name', 256) ?? stringValue(current.name),
|
|
278
|
+
model: optionalText(input.model_reference, 'model_reference') ?? stringValue(current.model),
|
|
279
|
+
modelReasoningEffort: optionalText(input.model_reasoning_effort, 'model_reasoning_effort') ?? stringValue(current.model_reasoning_effort),
|
|
280
|
+
policyPreset: optionalText(input.policy_preset, 'policy_preset') ?? stringValue(current.policy_preset)
|
|
281
|
+
}) };
|
|
282
|
+
});
|
|
283
|
+
route('POST', '/api/v1/agents/:agent_id/messages', (request, reply) => {
|
|
284
|
+
const agentId = param(request, 'agent_id');
|
|
285
|
+
const content = text(body(request).text, 'text');
|
|
286
|
+
const command = ingressCommand(request, 'message.create', { agent_id: agentId, content }, 'MESSAGE_REQUEST_KEY_REQUIRED');
|
|
287
|
+
const created = dependencies.store.enqueueMessage(project(request), agentId, content, undefined, command);
|
|
288
|
+
dependencies.runtime(project(request)).agents.wake(project(request), agentId);
|
|
289
|
+
return reply.status(202).send({ data: created });
|
|
290
|
+
});
|
|
291
|
+
route('POST', '/api/v1/agents/:agent_id/cancel', (request) => {
|
|
292
|
+
dependencies.runtime(project(request)).agents.cancel(project(request), param(request, 'agent_id'));
|
|
293
|
+
return { data: { ok: true } };
|
|
294
|
+
});
|
|
295
|
+
route('POST', '/api/v1/agents/:agent_id/retry', (request, reply) => {
|
|
296
|
+
const agentId = param(request, 'agent_id');
|
|
297
|
+
const run = dependencies.store.retryAgent(project(request), agentId);
|
|
298
|
+
dependencies.runtime(project(request)).agents.wake(project(request), agentId);
|
|
299
|
+
return reply.status(202).send({ data: run });
|
|
300
|
+
});
|
|
301
|
+
route('GET', '/api/v1/messages/:message_id', (request) => ({ data: dependencies.store.getMessage(project(request), param(request, 'message_id')) }));
|
|
302
|
+
route('GET', '/api/v1/pending-messages/:pending_message_id', (request) => ({ data: dependencies.store.getPending(project(request), param(request, 'pending_message_id')) }));
|
|
303
|
+
route('GET', '/api/v1/agents/:agent_id/pending-messages', (request) => ({ data: dependencies.store.listPending(project(request), param(request, 'agent_id'), page(request)) }));
|
|
304
|
+
route('PATCH', '/api/v1/pending-messages/:pending_message_id', (request) => ({ data: dependencies.store.updatePending(project(request), param(request, 'pending_message_id'), expectedRevision(body(request).expected_revision), text(body(request).text, 'text')) }));
|
|
305
|
+
route('DELETE', '/api/v1/pending-messages/:pending_message_id', (request) => {
|
|
306
|
+
dependencies.store.deletePending(project(request), param(request, 'pending_message_id'), queryRevision(request));
|
|
307
|
+
return { data: { ok: true } };
|
|
308
|
+
});
|
|
309
|
+
route('POST', '/api/v1/agents/:agent_id/pending-messages/reorder', (request) => ({ data: dependencies.store.reorderPending(project(request), param(request, 'agent_id'), strings(body(request).ids, 'ids')) }));
|
|
310
|
+
route('GET', '/api/v1/agents/:agent_id/capabilities', (request) => ({ data: dependencies.store.listCapabilities(project(request), param(request, 'agent_id')) }));
|
|
311
|
+
route('POST', '/api/v1/agents/:agent_id/capabilities/refresh', (request) => ({ data: refreshCapabilities(dependencies, project(request), param(request, 'agent_id')) }));
|
|
312
|
+
route('POST', '/api/v1/agents/:agent_id/capabilities/load', (request) => ({ data: dependencies.store.setCapabilityLoaded(project(request), param(request, 'agent_id'), text(body(request).capability_id, 'capability_id'), true) }));
|
|
313
|
+
route('POST', '/api/v1/agents/:agent_id/capabilities/unload', (request) => ({ data: dependencies.store.setCapabilityLoaded(project(request), param(request, 'agent_id'), text(body(request).capability_id, 'capability_id'), false) }));
|
|
314
|
+
route('GET', '/api/v1/agents/:agent_id/capabilities/:capability_id', (request) => ({ data: { assignment: dependencies.store.getCapabilityAssignment(project(request), param(request, 'agent_id'), param(request, 'capability_id')), definition: dependencies.runtime(project(request)).registry.get(param(request, 'capability_id')) } }));
|
|
315
|
+
route('GET', '/api/v1/agents/:agent_id/runs', (request) => ({ data: dependencies.store.listRuns(project(request), param(request, 'agent_id'), page(request)) }));
|
|
316
|
+
route('GET', '/api/v1/runs/:run_id', (request) => ({ data: dependencies.store.runDetail(project(request), param(request, 'run_id')) }));
|
|
317
|
+
route('GET', '/api/v1/runs/:run_id/steps', (request) => ({ data: dependencies.store.listSteps(project(request), param(request, 'run_id'), page(request)) }));
|
|
318
|
+
route('GET', '/api/v1/runs/:run_id/timeline', (request) => ({ data: dependencies.store.runTimeline(project(request), param(request, 'run_id'), page(request)) }));
|
|
319
|
+
route('GET', '/api/v1/steps/:step_id', (request) => ({ data: dependencies.store.getStep(project(request), param(request, 'step_id')) }));
|
|
320
|
+
route('GET', '/api/v1/steps/:step_id/output', (request) => ({ data: dependencies.store.stepOutput(project(request), param(request, 'step_id')) }));
|
|
321
|
+
route('GET', '/api/v1/steps/:step_id/goal', (request) => {
|
|
322
|
+
const runId = stringValue(dependencies.store.getStep(project(request), param(request, 'step_id')).run_id);
|
|
323
|
+
return { data: dependencies.store.getRunGoal(project(request), runId) };
|
|
324
|
+
});
|
|
325
|
+
route('GET', '/api/v1/agents/:agent_id/model-attempts', (request) => ({ data: dependencies.store.listAttempts(project(request), 'agent_id', param(request, 'agent_id'), page(request)) }));
|
|
326
|
+
route('GET', '/api/v1/steps/:step_id/model-attempts', (request) => ({ data: dependencies.store.listAttempts(project(request), 'step_id', param(request, 'step_id'), page(request)) }));
|
|
327
|
+
route('GET', '/api/v1/model-attempts/:attempt_id', (request) => ({ data: dependencies.store.getAttempt(project(request), param(request, 'attempt_id')) }));
|
|
328
|
+
route('GET', '/api/v1/steps/:step_id/partitions', (request) => ({ data: dependencies.store.listPartitions(project(request), param(request, 'step_id'), page(request)) }));
|
|
329
|
+
route('GET', '/api/v1/steps/:step_id/calls', (request) => ({ data: dependencies.store.listCalls(project(request), page(request), { stepId: param(request, 'step_id') }) }));
|
|
330
|
+
route('GET', '/api/v1/agents/:agent_id/context', (request) => ({ data: dependencies.store.listContext(project(request), param(request, 'agent_id'), page(request), pageDirection(request)) }));
|
|
331
|
+
route('GET', '/api/v1/context/:context_item_id', (request) => ({ data: dependencies.store.getContextItem(project(request), param(request, 'context_item_id')) }));
|
|
332
|
+
route('PATCH', '/api/v1/context/:context_item_id', (request) => ({ data: dependencies.store.updateContextItem(project(request), param(request, 'context_item_id'), expectedRevision(body(request).expected_revision), text(body(request).body_text, 'body_text')) }));
|
|
333
|
+
route('GET', '/api/v1/agents/:agent_id/context/override-targets', (request) => ({ data: dependencies.store.contextSource(project(request), param(request, 'agent_id')).items }));
|
|
334
|
+
route('GET', '/api/v1/agents/:agent_id/context-overrides', (request) => ({ data: dependencies.store.listOverrides(project(request), param(request, 'agent_id'), page(request)) }));
|
|
335
|
+
route('POST', '/api/v1/context-overrides', (request, reply) => reply.status(201).send({ data: dependencies.store.createOverride(project(request), text(body(request).agent_id, 'agent_id'), text(body(request).target_kind, 'target_kind'), text(body(request).target_id, 'target_id'), text(body(request).instructions, 'instructions')) }));
|
|
336
|
+
route('GET', '/api/v1/context-overrides/:context_override_id', (request) => ({ data: dependencies.store.getOverride(project(request), param(request, 'context_override_id')) }));
|
|
337
|
+
route('PUT', '/api/v1/context-overrides/:context_override_id', (request) => ({ data: dependencies.store.updateOverride(project(request), param(request, 'context_override_id'), expectedRevision(body(request).expected_revision), text(body(request).instructions, 'instructions')) }));
|
|
338
|
+
route('DELETE', '/api/v1/context-overrides/:context_override_id', (request) => {
|
|
339
|
+
dependencies.store.deleteOverride(project(request), param(request, 'context_override_id'), queryRevision(request));
|
|
340
|
+
return { data: { ok: true } };
|
|
341
|
+
});
|
|
342
|
+
route('POST', '/api/v1/agents/:agent_id/context/visibility', (request) => ({ data: dependencies.store.setContextVisibility(project(request), param(request, 'agent_id'), strings(body(request).turn_group_ids, 'turn_group_ids'), body(request).visible === true, expectedRevision(body(request).expected_context_revision)) }));
|
|
343
|
+
route('POST', '/api/v1/agents/:agent_id/context/reorder', (request) => ({ data: dependencies.store.reorderContext(project(request), param(request, 'agent_id'), strings(body(request).ordered_turn_group_ids, 'ordered_turn_group_ids'), expectedRevision(body(request).expected_context_revision)) }));
|
|
344
|
+
route('POST', '/api/v1/agents/:agent_id/context/projection/reset', (request) => ({ data: dependencies.store.resetProjection(project(request), param(request, 'agent_id'), expectedRevision(body(request).expected_context_revision), body(request).stages === undefined ? undefined : strings(body(request).stages, 'stages')) }));
|
|
345
|
+
route('POST', '/api/v1/agents/:agent_id/context/preview', (request) => {
|
|
346
|
+
const agent = dependencies.store.getAgent(project(request), param(request, 'agent_id'));
|
|
347
|
+
const model = dependencies.models.resolve(stringValue(agent.model));
|
|
348
|
+
const prepared = dependencies.runtime(project(request)).context.prepare(project(request), param(request, 'agent_id'), dependencies.config.current.config, model.contextWindowTokens, model.profile);
|
|
349
|
+
if (prepared.sourceRevision !== expectedRevision(body(request).expected_context_revision))
|
|
350
|
+
fail('REVISION_CONFLICT', 409, 'The Context changed before preview.');
|
|
351
|
+
return { data: prepared };
|
|
352
|
+
});
|
|
353
|
+
route('POST', '/api/v1/agents/:agent_id/context/compact', async (request, reply) => ({ data: await dependencies.runtime(project(request)).agents.compact(project(request), param(request, 'agent_id'), expectedRevision(body(request).expected_context_revision), optionalText(body(request).pivot_turn_group_id, 'pivot_turn_group_id'), clientSignal(request, reply)) }));
|
|
354
|
+
route('POST', '/api/v1/agents/:agent_id/context/fork', (request, reply) => {
|
|
355
|
+
const projectId = project(request);
|
|
356
|
+
const source = dependencies.store.getAgent(projectId, param(request, 'agent_id'));
|
|
357
|
+
const input = body(request);
|
|
358
|
+
const model = optionalText(input.model_reference, 'model_reference') ?? stringValue(source.model);
|
|
359
|
+
const effort = optionalText(input.model_reasoning_effort, 'model_reasoning_effort') ?? stringValue(source.model_reasoning_effort);
|
|
360
|
+
resolveReasoningEffort(dependencies.models.resolve(model).profile, effort);
|
|
361
|
+
return reply.status(201).send({ data: dependencies.store.createContextFork(projectId, param(request, 'agent_id'), {
|
|
362
|
+
throughTurnGroupId: text(input.through_turn_group_id, 'through_turn_group_id'),
|
|
363
|
+
expectedContextRevision: expectedRevision(input.expected_context_revision),
|
|
364
|
+
name: text(input.name, 'name', 256),
|
|
365
|
+
model,
|
|
366
|
+
modelReasoningEffort: effort
|
|
367
|
+
}) });
|
|
368
|
+
});
|
|
369
|
+
route('GET', '/api/v1/agents/:agent_id/context/turn-groups', (request) => ({ data: dependencies.store.listTurnGroups(project(request), param(request, 'agent_id'), page(request)) }));
|
|
370
|
+
route('GET', '/api/v1/agents/:agent_id/context/projection/decisions', (request) => ({ data: dependencies.store.listProjectionDecisions(project(request), param(request, 'agent_id'), page(request)) }));
|
|
371
|
+
route('GET', '/api/v1/calls', (request) => ({ data: dependencies.store.listCalls(project(request), page(request), { ...(typeof query(request).state === 'string' ? { state: query(request).state } : {}) }) }));
|
|
372
|
+
route('POST', '/api/v1/calls', (request, reply) => {
|
|
373
|
+
const input = body(request);
|
|
374
|
+
const capabilityId = text(input.capability_id, 'capability_id');
|
|
375
|
+
const workspaceId = text(input.workspace_id, 'workspace_id');
|
|
376
|
+
const argumentsValue = jsonValue(input.arguments ?? {});
|
|
377
|
+
const command = ingressCommand(request, 'call.create', {
|
|
378
|
+
capability_id: capabilityId,
|
|
379
|
+
workspace_id: workspaceId,
|
|
380
|
+
agent_id: typeof input.agent_id === 'string' ? input.agent_id : null,
|
|
381
|
+
arguments: argumentsValue
|
|
382
|
+
}, 'DIRECT_REQUEST_KEY_REQUIRED');
|
|
383
|
+
const replay = dependencies.store.ingressReplay(command);
|
|
384
|
+
if (replay !== null)
|
|
385
|
+
return reply.status(202).send({ data: replay });
|
|
386
|
+
const effect = dependencies.runtime(project(request)).registry.effect(capabilityId, argumentsValue);
|
|
387
|
+
const call = dependencies.store.createCall(project(request), {
|
|
388
|
+
workspaceId,
|
|
389
|
+
...(typeof input.agent_id === 'string' ? { agentId: input.agent_id } : {}),
|
|
390
|
+
source: 'user',
|
|
391
|
+
capabilityId,
|
|
392
|
+
arguments: argumentsValue,
|
|
393
|
+
effect,
|
|
394
|
+
state: 'QUEUED',
|
|
395
|
+
ingress: command
|
|
396
|
+
});
|
|
397
|
+
dependencies.runtime(project(request)).calls.dispatch(project(request), stringValue(call.id));
|
|
398
|
+
// `createCall` answers the Runtime's flat row; CALL-02 is documented to answer a `CallSummary`.
|
|
399
|
+
return reply.status(202).send({ data: dependencies.store.callSummary(project(request), stringValue(call.id)) });
|
|
400
|
+
});
|
|
401
|
+
route('GET', '/api/v1/calls/:call_id', (request) => ({ data: dependencies.store.callDetail(project(request), param(request, 'call_id')) }));
|
|
402
|
+
// 02A `CallDecisionResponse` carries a `CallSummary`; the Store and the Runtime both answer the
|
|
403
|
+
// flat Runtime row, so the projection happens here, at the wire boundary.
|
|
404
|
+
const decisionResponse = (request, decision) => ({
|
|
405
|
+
call: dependencies.store.callSummary(project(request), stringValue(decision.call.id)),
|
|
406
|
+
result: decision.result
|
|
407
|
+
});
|
|
408
|
+
for (const decision of ['approve', 'reject', 'cancel']) {
|
|
409
|
+
route('POST', `/api/v1/calls/:call_id/${decision}`, (request) => {
|
|
410
|
+
const callId = param(request, 'call_id');
|
|
411
|
+
const revision = expectedRevision(body(request).expected_revision);
|
|
412
|
+
if (decision === 'cancel') {
|
|
413
|
+
return { data: decisionResponse(request, dependencies.runtime(project(request)).calls.cancel(project(request), callId, revision)) };
|
|
414
|
+
}
|
|
415
|
+
const result = dependencies.store.decideCall(project(request), callId, decision, revision);
|
|
416
|
+
if (result.call.state === 'QUEUED')
|
|
417
|
+
dependencies.runtime(project(request)).calls.dispatch(project(request), callId);
|
|
418
|
+
return { data: decisionResponse(request, result) };
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
route('POST', '/api/v1/calls/:call_id/answer', (request) => {
|
|
422
|
+
const callId = param(request, 'call_id');
|
|
423
|
+
const result = dependencies.store.answerCall(project(request), callId, expectedRevision(body(request).expected_revision), jsonValue(body(request).answer));
|
|
424
|
+
if (result.call.state === 'QUEUED')
|
|
425
|
+
dependencies.runtime(project(request)).calls.dispatch(project(request), callId);
|
|
426
|
+
return { data: decisionResponse(request, result) };
|
|
427
|
+
});
|
|
428
|
+
route('GET', '/api/v1/results/:result_id', (request) => ({ data: dependencies.store.resultDocument(project(request), param(request, 'result_id')) }));
|
|
429
|
+
route('POST', '/api/v1/results/:result_id/include', (request, reply) => reply.status(201).send({ data: dependencies.store.includeResult(project(request), param(request, 'result_id'), text(body(request).agent_id, 'agent_id'), expectedRevision(body(request).expected_context_revision)) }));
|
|
430
|
+
route('GET', '/api/v1/artifacts', (request) => ({ data: dependencies.store.listArtifacts(project(request), page(request)) }));
|
|
431
|
+
route('GET', '/api/v1/artifacts/:artifact_id/metadata', (request) => ({ data: dependencies.store.getArtifact(project(request), param(request, 'artifact_id')) }));
|
|
432
|
+
route('GET', '/api/v1/artifacts/:artifact_id', async (request, reply) => {
|
|
433
|
+
const artifact = dependencies.store.getArtifact(project(request), param(request, 'artifact_id'));
|
|
434
|
+
const bytes = await dependencies.artifacts.read(artifact);
|
|
435
|
+
const range = byteRange(request.headers.range, bytes.byteLength);
|
|
436
|
+
reply.type(stringValue(artifact.media_type))
|
|
437
|
+
.header('accept-ranges', 'bytes')
|
|
438
|
+
.header('etag', `"${stringValue(artifact.sha256)}"`);
|
|
439
|
+
if (range === null)
|
|
440
|
+
return reply.header('content-length', bytes.byteLength).send(bytes);
|
|
441
|
+
const content = bytes.subarray(range.start, range.end + 1);
|
|
442
|
+
return reply.status(206)
|
|
443
|
+
.header('content-length', content.byteLength)
|
|
444
|
+
.header('content-range', `bytes ${range.start}-${range.end}/${bytes.byteLength}`)
|
|
445
|
+
.send(content);
|
|
446
|
+
});
|
|
447
|
+
route('POST', '/api/v1/context-items/batch', (request) => ({ data: batchRead(request, 'context_item_ids', (id) => dependencies.store.getContextItem(project(request), id), dependencies) }));
|
|
448
|
+
route('POST', '/api/v1/messages/batch', (request) => ({ data: batchRead(request, 'message_ids', (id) => dependencies.store.getMessage(project(request), id), dependencies) }));
|
|
449
|
+
route('POST', '/api/v1/results/batch', (request) => ({ data: batchRead(request, 'result_ids', (id) => dependencies.store.resultDocument(project(request), id), dependencies) }));
|
|
450
|
+
route('POST', '/api/v1/calls/batch', (request) => ({ data: batchRead(request, 'call_ids', (id) => dependencies.store.callDetail(project(request), id), dependencies) }));
|
|
451
|
+
// What revert_call would do to these Calls, without doing it. POST because the input is a set of
|
|
452
|
+
// Call ids; it writes nothing. The answer is a preview: any later write can invalidate it, and the
|
|
453
|
+
// revert re-checks for itself, so it must not be treated as a promise that the revert will succeed.
|
|
454
|
+
route('POST', '/api/v1/calls/revert-preview', async (request) => {
|
|
455
|
+
const projectId = project(request);
|
|
456
|
+
const callIds = [...new Set(strings(body(request).call_ids, 'call_ids'))];
|
|
457
|
+
if (callIds.length === 0)
|
|
458
|
+
fail('INVALID_INPUT', 400, 'call_ids must not be empty.');
|
|
459
|
+
const workspaceIds = new Set(callIds.map((callId) => {
|
|
460
|
+
const value = dependencies.store.getCall(projectId, callId).workspace_id;
|
|
461
|
+
if (typeof value !== 'string')
|
|
462
|
+
fail('DATABASE_CORRUPT', 503, 'A Call is missing its Workspace.');
|
|
463
|
+
return value;
|
|
464
|
+
}));
|
|
465
|
+
// The Executor keys history by (workspace_id, call_id), so one request covers one Workspace.
|
|
466
|
+
// Rejecting here turns what would otherwise look like missing history into a clear error.
|
|
467
|
+
if (workspaceIds.size > 1)
|
|
468
|
+
fail('INVALID_INPUT', 400, 'call_ids must all belong to one Workspace.');
|
|
469
|
+
const workspaceId = [...workspaceIds][0] ?? '';
|
|
470
|
+
const paths = dependencies.store.projectWorkspacePaths(projectId, workspaceId);
|
|
471
|
+
return { data: await dependencies.executor.describeCallHistory(workspaceId, callIds, paths, false) };
|
|
472
|
+
});
|
|
473
|
+
route('POST', '/api/v1/agents/:agent_id/subagents', async (request, reply) => {
|
|
474
|
+
const projectId = project(request);
|
|
475
|
+
const parentId = param(request, 'agent_id');
|
|
476
|
+
const input = body(request);
|
|
477
|
+
const defaults = agentDefaults(dependencies, projectId, 'subagent', input);
|
|
478
|
+
const message = optionalText(input.message ?? input.initial_message, 'message');
|
|
479
|
+
const command = ingressCommand(request, 'subagent.create', {
|
|
480
|
+
parent_agent_id: parentId,
|
|
481
|
+
name: text(input.name, 'name', 256),
|
|
482
|
+
model: defaults.model,
|
|
483
|
+
model_reasoning_effort: defaults.modelReasoningEffort,
|
|
484
|
+
policy_preset: defaults.policyPreset,
|
|
485
|
+
message: message ?? null
|
|
486
|
+
}, 'IDEMPOTENCY_KEY_INVALID');
|
|
487
|
+
const forkContext = input.fork_context;
|
|
488
|
+
if (forkContext !== 'full' && forkContext !== 'compact' && forkContext !== 'none')
|
|
489
|
+
fail('INVALID_INPUT', 400, 'fork_context is required.');
|
|
490
|
+
const created = await dependencies.runtime(projectId).agents.createSubagent(projectId, {
|
|
491
|
+
parentAgentId: parentId,
|
|
492
|
+
name: text(input.name, 'name', 256),
|
|
493
|
+
forkContext,
|
|
494
|
+
model: defaults.model,
|
|
495
|
+
modelReasoningEffort: defaults.modelReasoningEffort,
|
|
496
|
+
...(typeof input.initial_prompt === 'string' ? { initialPrompt: input.initial_prompt } : {}),
|
|
497
|
+
...(message === undefined ? {} : { initialMessage: message }),
|
|
498
|
+
ingress: command
|
|
499
|
+
}, clientSignal(request, reply));
|
|
500
|
+
return reply.status(201).send({ data: created });
|
|
501
|
+
});
|
|
502
|
+
route('POST', '/api/v1/agents/:agent_id/subagent-messages', (request, reply) => {
|
|
503
|
+
const projectId = project(request);
|
|
504
|
+
const parentId = param(request, 'agent_id');
|
|
505
|
+
const input = body(request);
|
|
506
|
+
const target = text(input.target_agent_id, 'target_agent_id');
|
|
507
|
+
const content = text(input.message, 'message');
|
|
508
|
+
const command = ingressCommand(request, 'subagent.message', { parent_agent_id: parentId, target_agent_id: target, content }, 'IDEMPOTENCY_KEY_INVALID');
|
|
509
|
+
const result = dependencies.store.enqueueSubagentMessage(projectId, parentId, target, content, command);
|
|
510
|
+
dependencies.runtime(projectId).agents.wake(projectId, target);
|
|
511
|
+
return reply.status(202).send({ data: result });
|
|
512
|
+
});
|
|
513
|
+
route('POST', '/api/v1/agents/:agent_id/subagent-broadcasts', (request, reply) => {
|
|
514
|
+
const projectId = project(request);
|
|
515
|
+
const parentId = param(request, 'agent_id');
|
|
516
|
+
const content = text(body(request).message, 'message');
|
|
517
|
+
const command = ingressCommand(request, 'subagent.broadcast', { parent_agent_id: parentId, content }, 'IDEMPOTENCY_KEY_INVALID');
|
|
518
|
+
const created = dependencies.store.broadcastSubagentMessage(projectId, parentId, content, command);
|
|
519
|
+
const parent = dependencies.store.getAgent(projectId, parentId);
|
|
520
|
+
for (const target of dependencies.store.listAgents(projectId, 'task_id', stringValue(parent.task_id))) {
|
|
521
|
+
if (target.parent_agent_id === parentId)
|
|
522
|
+
dependencies.runtime(projectId).agents.wake(projectId, stringValue(target.id));
|
|
523
|
+
}
|
|
524
|
+
return reply.status(202).send({ data: created });
|
|
525
|
+
});
|
|
526
|
+
route('GET', '/api/v1/snapshot', (request) => {
|
|
527
|
+
const projectId = project(request);
|
|
528
|
+
const snapshot = dependencies.store.snapshot(projectId, dependencies.config.current.config.snapshot.maxRuntimeEntities);
|
|
529
|
+
return {
|
|
530
|
+
data: {
|
|
531
|
+
instance_epoch: dependencies.events.instanceEpoch,
|
|
532
|
+
state_revision: String(dependencies.store.stateRevision()),
|
|
533
|
+
generated_at: new Date().toISOString(),
|
|
534
|
+
...runtimeMeta(dependencies, projectId),
|
|
535
|
+
...snapshot
|
|
536
|
+
},
|
|
537
|
+
meta: runtimeMeta(dependencies, projectId)
|
|
538
|
+
};
|
|
539
|
+
});
|
|
540
|
+
route('GET', '/api/v1/events', async (request, reply) => streamEvents(dependencies, contexts.get(request), reply, request));
|
|
541
|
+
route('GET', '/api/v1/runtime/diagnostics', () => ({ data: { readiness: dependencies.ready(), config: configMeta(dependencies), state_revision: dependencies.store.stateRevision(), storage: dependencies.store.storageStats() } }));
|
|
542
|
+
// The three diagnostic windows are documented as RuntimeRecordWindow<T>, not bare arrays.
|
|
543
|
+
// They are bounded in-process buffers with no replay, so `next_sequence` is the highest
|
|
544
|
+
// sequence returned and `has_more` reports whether the window filled the requested limit.
|
|
545
|
+
route('GET', '/api/v1/runtime/logs', (request) => ({ data: recordWindow(dependencies.observability.logs(page(request).limit), page(request).limit) }));
|
|
546
|
+
route('GET', '/api/v1/runtime/agent-ops', (request) => ({ data: recordWindow(dependencies.observability.agentOps(page(request).limit), page(request).limit) }));
|
|
547
|
+
route('GET', '/api/v1/runtime/traces', (request) => ({ data: recordWindow(dependencies.observability.traces(page(request).limit), page(request).limit) }));
|
|
548
|
+
route('GET', '/api/v1/runtime/metrics', (_request, reply) => reply.type('text/plain; version=0.0.4').send(dependencies.observability.metrics()));
|
|
549
|
+
route('GET', '/api/v1/runtime/operations', (request) => ({ data: { calls: dependencies.store.listCalls(project(request), { limit: dependencies.config.current.config.snapshot.pageSizeMax }, { state: 'RUNNING' }) } }));
|
|
550
|
+
route('POST', '/api/v1/runtime/maintenance/run', async (request) => {
|
|
551
|
+
const purged = dependencies.store.purgeArchived(project(request), optionalText(body(request).before, 'before') ?? new Date().toISOString());
|
|
552
|
+
const files = await applyPurge(purged, dependencies.artifacts, dependencies.executor);
|
|
553
|
+
// purged_calls is an instruction for applyPurge, not part of the Route response.
|
|
554
|
+
return { data: { purged_tasks: purged.purged_tasks, orphaned_artifact_paths: purged.orphaned_artifact_paths, files } };
|
|
555
|
+
});
|
|
556
|
+
route('GET', '/api/v1/runtime/storage', () => ({ data: dependencies.store.storageStats() }));
|
|
557
|
+
app.setNotFoundHandler((_request, reply) => reply.status(404).send({ error: { code: 'ROUTE_NOT_FOUND', message: 'The requested Route does not exist.', details: {} } }));
|
|
558
|
+
return app;
|
|
559
|
+
}
|
|
560
|
+
function createAgent(dependencies, projectId, taskId, input) {
|
|
561
|
+
const roleName = input.parent_agent_id === undefined ? 'agent' : 'subagent';
|
|
562
|
+
const defaults = agentDefaults(dependencies, projectId, roleName, input);
|
|
563
|
+
const sessionId = typeof input.session_id === 'string'
|
|
564
|
+
? input.session_id
|
|
565
|
+
: stringValue(dependencies.store.listSessions(projectId, taskId, { limit: 1 }).items[0]?.id ?? dependencies.store.createSession(projectId, taskId, 'Session').id);
|
|
566
|
+
return dependencies.store.createAgent(projectId, {
|
|
567
|
+
taskId,
|
|
568
|
+
sessionId,
|
|
569
|
+
workspaceId: optionalText(input.workspace_id, 'workspace_id') ?? defaultWorkspaceId(dependencies.store, projectId),
|
|
570
|
+
...(typeof input.parent_agent_id === 'string' ? { parentAgentId: input.parent_agent_id } : {}),
|
|
571
|
+
name: text(input.name, 'name', 256),
|
|
572
|
+
...defaults
|
|
573
|
+
});
|
|
574
|
+
}
|
|
575
|
+
function agentDefaults(dependencies, projectId, roleName, input) {
|
|
576
|
+
const role = dependencies.config.current.config.roles[roleName];
|
|
577
|
+
if (role.model === null || role.modelReasoningEffort === null || role.policyPreset === null) {
|
|
578
|
+
fail('CONFIG_INVALID', 503, `Role ${roleName} requires model, modelReasoningEffort and policyPreset.`);
|
|
579
|
+
}
|
|
580
|
+
const model = typeof input.model_reference === 'string' ? input.model_reference : typeof input.model === 'string' ? input.model : role.model;
|
|
581
|
+
const modelReasoningEffort = typeof input.model_reasoning_effort === 'string' ? input.model_reasoning_effort : role.modelReasoningEffort;
|
|
582
|
+
const policyPreset = typeof input.policy_preset === 'string' ? input.policy_preset : role.policyPreset;
|
|
583
|
+
const preset = dependencies.config.current.config.policyPresets[policyPreset];
|
|
584
|
+
if (preset === undefined)
|
|
585
|
+
fail('POLICY_PRESET_MISSING', 409, 'The selected Policy preset does not exist.');
|
|
586
|
+
const resolved = dependencies.models.resolve(model);
|
|
587
|
+
resolveReasoningEffort(resolved.profile, modelReasoningEffort);
|
|
588
|
+
const registry = dependencies.runtime(projectId).registry;
|
|
589
|
+
const capabilityIds = [...new Set([
|
|
590
|
+
...registry.list().filter((definition) => definition.defaultAssignment === 'all_new_agents').map((definition) => definition.id),
|
|
591
|
+
...role.capabilities
|
|
592
|
+
])];
|
|
593
|
+
return {
|
|
594
|
+
model,
|
|
595
|
+
modelReasoningEffort,
|
|
596
|
+
policyPreset,
|
|
597
|
+
capabilities: Object.fromEntries(capabilityIds.map((capabilityId) => {
|
|
598
|
+
const definition = registry.get(capabilityId);
|
|
599
|
+
return [capabilityId, preset.capabilities[capabilityId] ?? (definition.effect === 'read_only' ? 'read_only' : 'ask')];
|
|
600
|
+
})),
|
|
601
|
+
loadedCapabilities: capabilityIds.filter((capabilityId) => registry.get(capabilityId).loadMode === 'eager')
|
|
602
|
+
};
|
|
603
|
+
}
|
|
604
|
+
function defaultWorkspaceId(store, projectId) {
|
|
605
|
+
const workspaces = store.listWorkspaces(projectId);
|
|
606
|
+
const workspace = workspaces.find((entry) => entry.is_default === true) ?? workspaces[0];
|
|
607
|
+
if (workspace === undefined)
|
|
608
|
+
fail('WORKSPACE_NOT_FOUND', 404, 'The Project has no Workspace.');
|
|
609
|
+
return stringValue(workspace.id);
|
|
610
|
+
}
|
|
611
|
+
function refreshCapabilities(dependencies, projectId, agentId) {
|
|
612
|
+
const agent = dependencies.store.getAgent(projectId, agentId);
|
|
613
|
+
const role = agent.parent_agent_id === null ? dependencies.config.current.config.roles.agent : dependencies.config.current.config.roles.subagent;
|
|
614
|
+
const presetName = stringValue(agent.policy_preset);
|
|
615
|
+
const policies = dependencies.config.current.config.policyPresets[presetName]?.capabilities ?? {};
|
|
616
|
+
const current = dependencies.store.listCapabilities(projectId, agentId);
|
|
617
|
+
const registry = dependencies.runtime(projectId).registry;
|
|
618
|
+
const capabilityIds = [...new Set([
|
|
619
|
+
...registry.list().filter((definition) => definition.defaultAssignment === 'all_new_agents').map((definition) => definition.id),
|
|
620
|
+
...role.capabilities
|
|
621
|
+
])];
|
|
622
|
+
const capabilities = Object.fromEntries(capabilityIds.map((capabilityId) => {
|
|
623
|
+
const definition = registry.get(capabilityId);
|
|
624
|
+
const existing = current.find((entry) => entry.capability_id === capabilityId);
|
|
625
|
+
const existingPolicy = typeof existing?.policy === 'string' ? existing.policy : undefined;
|
|
626
|
+
return [capabilityId, policies[capabilityId] ?? existingPolicy ?? (definition.effect === 'read_only' ? 'read_only' : 'ask')];
|
|
627
|
+
}));
|
|
628
|
+
const loaded = Object.keys(capabilities).filter((capabilityId) => {
|
|
629
|
+
const existing = current.find((entry) => entry.capability_id === capabilityId);
|
|
630
|
+
return existing?.loaded === true || registry.get(capabilityId).loadMode === 'eager';
|
|
631
|
+
});
|
|
632
|
+
return dependencies.store.replaceCapabilities(projectId, agentId, capabilities, loaded);
|
|
633
|
+
}
|
|
634
|
+
async function streamEvents(dependencies, context, reply, request) {
|
|
635
|
+
if (context === undefined)
|
|
636
|
+
fail('TRUSTED_INGRESS_REQUIRED', 500, 'Trusted ingress is missing.');
|
|
637
|
+
const subscription = dependencies.events.subscribe(context.ingress.clientId, context.projectId);
|
|
638
|
+
reply.hijack();
|
|
639
|
+
reply.raw.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache, no-transform', connection: 'keep-alive' });
|
|
640
|
+
const controller = new AbortController();
|
|
641
|
+
request.raw.once('close', () => controller.abort());
|
|
642
|
+
let sequence = 0;
|
|
643
|
+
/**
|
|
644
|
+
* Writes one SSE frame. `kind` and `occurred_at` go in the JSON body, not only in the
|
|
645
|
+
* `event:` line: RuntimeEventBase (07 分册) defines them as part of the wire envelope, and
|
|
646
|
+
* a consumer reading the body cannot dispatch on an event name it never sees. Transport
|
|
647
|
+
* framing and payload have to agree.
|
|
648
|
+
*/
|
|
649
|
+
const send = (kind, data) => {
|
|
650
|
+
sequence += 1;
|
|
651
|
+
const envelope = {
|
|
652
|
+
kind,
|
|
653
|
+
sequence,
|
|
654
|
+
subscription_id: subscription.id,
|
|
655
|
+
instance_epoch: dependencies.events.instanceEpoch,
|
|
656
|
+
occurred_at: new Date().toISOString()
|
|
657
|
+
};
|
|
658
|
+
const value = jsonObject(data) ? { ...envelope, ...data, kind, sequence } : { data, ...envelope };
|
|
659
|
+
reply.raw.write(`id: ${sequence}\nevent: ${kind}\ndata: ${JSON.stringify(value)}\n\n`);
|
|
660
|
+
};
|
|
661
|
+
// Handshake and keepalive are notices carrying a `code`, not event kinds of their own.
|
|
662
|
+
send('runtime.notice', {
|
|
663
|
+
code: 'subscription.ready',
|
|
664
|
+
subscription_id: subscription.id,
|
|
665
|
+
instance_epoch: dependencies.events.instanceEpoch,
|
|
666
|
+
state_revision: String(dependencies.store.stateRevision()),
|
|
667
|
+
...runtimeMeta(dependencies, context.projectId)
|
|
668
|
+
});
|
|
669
|
+
const heartbeat = setInterval(() => send('runtime.notice', { code: 'heartbeat', state_revision: String(dependencies.store.stateRevision()), ...runtimeMeta(dependencies, context.projectId) }), dependencies.config.current.config.sse.heartbeatIntervalMs);
|
|
670
|
+
try {
|
|
671
|
+
for (;;) {
|
|
672
|
+
const event = await subscription.next(controller.signal);
|
|
673
|
+
if (event === null)
|
|
674
|
+
break;
|
|
675
|
+
send(event.type, {
|
|
676
|
+
event_id: event.id,
|
|
677
|
+
project_id: event.projectId,
|
|
678
|
+
state_revision: String(event.stateRevision),
|
|
679
|
+
metadata_revision: String(event.metadataRevision),
|
|
680
|
+
occurred_at: event.occurredAt,
|
|
681
|
+
...(jsonObject(event.payload) ? event.payload : { payload: event.payload })
|
|
682
|
+
});
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
finally {
|
|
686
|
+
clearInterval(heartbeat);
|
|
687
|
+
subscription.close();
|
|
688
|
+
reply.raw.end();
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
function runtimeMeta(dependencies, projectId) {
|
|
692
|
+
return {
|
|
693
|
+
metadata_revision: String(metadataRevision(dependencies)),
|
|
694
|
+
config_digest: dependencies.config.current.digest,
|
|
695
|
+
pair_digest: dependencies.pair.pairDigest,
|
|
696
|
+
runtime_contract_digest: dependencies.runtime(projectId).contractDigest(),
|
|
697
|
+
access_contract: accessContract(dependencies)
|
|
698
|
+
};
|
|
699
|
+
}
|
|
700
|
+
/**
|
|
701
|
+
* 02A `AccessContractSummary`. `mode` reports the listener reach, which is what a client needs in
|
|
702
|
+
* order to explain why a request was refused: the pair file gates every non-health Route, so a
|
|
703
|
+
* loopback-only listener and a LAN listener refuse different callers.
|
|
704
|
+
*/
|
|
705
|
+
function accessContract(dependencies) {
|
|
706
|
+
const host = dependencies.config.current.config.listen.host;
|
|
707
|
+
const loopback = host === '127.0.0.1' || host === '::1' || host === 'localhost';
|
|
708
|
+
return {
|
|
709
|
+
provider: 'core',
|
|
710
|
+
mode: loopback ? 'loopback' : 'lan',
|
|
711
|
+
compatibility_deviations: []
|
|
712
|
+
};
|
|
713
|
+
}
|
|
714
|
+
/**
|
|
715
|
+
* Component identity for the Health Routes: which program answered, which independent software
|
|
716
|
+
* version it is, which integer protocol versions it speaks and which OS process serves the port.
|
|
717
|
+
*
|
|
718
|
+
* All four come from process-wide facts, so they are read here rather than threaded through
|
|
719
|
+
* `Dependencies`. `protocols` is compared by strict equality against the adjacent component and is
|
|
720
|
+
* unrelated to `runtime_contract_digest`, which stays a runtime synchronization value.
|
|
721
|
+
*/
|
|
722
|
+
function componentSummary() {
|
|
723
|
+
const identity = componentIdentity();
|
|
724
|
+
return {
|
|
725
|
+
component: identity.component,
|
|
726
|
+
software_version: identity.softwareVersion,
|
|
727
|
+
protocols: { ...identity.protocols },
|
|
728
|
+
pid: process.pid
|
|
729
|
+
};
|
|
730
|
+
}
|
|
731
|
+
function requireLoopback(request) {
|
|
732
|
+
// The raw socket address, not `request.ip`: the latter honours forwarded headers under
|
|
733
|
+
// `trustProxy`, and a caller-supplied header must never be able to satisfy the one check
|
|
734
|
+
// standing between a LAN listener and process termination.
|
|
735
|
+
if (!loopbackAddress(request.socket.remoteAddress)) {
|
|
736
|
+
fail('SHUTDOWN_FORBIDDEN', 403, 'Shutdown may only be requested from the local machine.');
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
function loopbackAddress(address) {
|
|
740
|
+
// Absent for a non-IP socket. Refusing is the safe answer: the caller cannot be shown to be local.
|
|
741
|
+
if (address === undefined)
|
|
742
|
+
return false;
|
|
743
|
+
// A dual-stack listener reports an IPv4 peer as `::ffff:127.0.0.1`.
|
|
744
|
+
const normalized = address.startsWith('::ffff:') ? address.slice('::ffff:'.length) : address;
|
|
745
|
+
return normalized === '::1' || /^127\./.test(normalized);
|
|
746
|
+
}
|
|
747
|
+
/**
|
|
748
|
+
* CORE-15. 02A defines this as the catalog a client needs to offer model, reasoning-effort,
|
|
749
|
+
* Runtime Role and Policy choices: `models`, `model_profiles`, `roles`, `policy_presets` and
|
|
750
|
+
* `effect_scope_descriptions`, plus the digest and catalog revision it was derived from.
|
|
751
|
+
*
|
|
752
|
+
* The previous implementation returned three flat string arrays (`providers`, `policy_presets`,
|
|
753
|
+
* `capabilities`) instead, which shares only a field *name* with the contract and none of its
|
|
754
|
+
* shape. A client reading `roles` or `models` got `undefined`, and calling `.find` on it threw
|
|
755
|
+
* during render.
|
|
756
|
+
*
|
|
757
|
+
* Every value is derived from the validated config document, so this adds no new source of
|
|
758
|
+
* truth. Reasoning efforts follow the same rule the config validator applies: a Model Harness
|
|
759
|
+
* Profile constrains them when one is attached, otherwise the full ladder is allowed.
|
|
760
|
+
*/
|
|
761
|
+
const EFFORT_LADDER = ['none', 'minimal', 'low', 'medium', 'high'];
|
|
762
|
+
const EFFECT_SCOPE_DESCRIPTIONS = {
|
|
763
|
+
read_only: 'Reads Workspace state without changing it.',
|
|
764
|
+
workspace_write: 'Creates or modifies files inside the Workspace.',
|
|
765
|
+
external: 'Reaches a network or another external system.',
|
|
766
|
+
full: 'Runs an arbitrary command with the account\'s own permissions. Not sandboxed.'
|
|
767
|
+
};
|
|
768
|
+
const POLICY_VALUE_DESCRIPTIONS = {
|
|
769
|
+
read_only: 'Allowed when the Capability only reads.',
|
|
770
|
+
workspace_write: 'Allowed to write inside the Workspace.',
|
|
771
|
+
external: 'Allowed to reach external systems.',
|
|
772
|
+
ask: 'Requires explicit approval for each Call.',
|
|
773
|
+
auto_approve: 'Approved automatically after Policy review.',
|
|
774
|
+
full_access: 'Allowed without Workspace containment.'
|
|
775
|
+
};
|
|
776
|
+
function configOptions(dependencies, projectId) {
|
|
777
|
+
const config = dependencies.config.current.config;
|
|
778
|
+
const profiles = config.modelHarnessProfiles ?? {};
|
|
779
|
+
const efforts = (harnessProfile) => harnessProfile === undefined
|
|
780
|
+
? EFFORT_LADDER
|
|
781
|
+
: profiles[harnessProfile]?.effort.allowed ?? [];
|
|
782
|
+
const models = Object.entries(config.providers).flatMap(([providerId, provider]) => Object.entries(provider.models).map(([modelId, model]) => {
|
|
783
|
+
const allowed = efforts(model.harnessProfile);
|
|
784
|
+
return {
|
|
785
|
+
model_reference: `${providerId}/${modelId}`,
|
|
786
|
+
allowed_reasoning_efforts: allowed,
|
|
787
|
+
default_reasoning_effort: model.harnessProfile === undefined
|
|
788
|
+
? 'medium'
|
|
789
|
+
: profiles[model.harnessProfile]?.effort.default ?? allowed[0] ?? 'medium',
|
|
790
|
+
harness_profile_id: model.harnessProfile ?? '',
|
|
791
|
+
harness_profile_version: model.harnessProfile === undefined ? 0 : profiles[model.harnessProfile]?.version ?? 0,
|
|
792
|
+
context_window_tokens: model.contextWindowTokens,
|
|
793
|
+
usage_reliable: model.usageReliable ?? null,
|
|
794
|
+
cache_supported: model.cacheSupported ?? null
|
|
795
|
+
};
|
|
796
|
+
}));
|
|
797
|
+
return {
|
|
798
|
+
config_digest: dependencies.config.current.digest,
|
|
799
|
+
catalog_revision: String(metadataRevision(dependencies)),
|
|
800
|
+
models,
|
|
801
|
+
model_profiles: Object.entries(profiles).map(([id, profile]) => ({
|
|
802
|
+
id,
|
|
803
|
+
version: profile.version,
|
|
804
|
+
allowed_reasoning_efforts: profile.effort.allowed,
|
|
805
|
+
default_reasoning_effort: profile.effort.default,
|
|
806
|
+
reasoning_state_round_trip: profile.semantics.reasoningStateRoundTrip,
|
|
807
|
+
thinking_continuation: profile.semantics.thinkingContinuation,
|
|
808
|
+
structured_output: profile.semantics.structuredOutput,
|
|
809
|
+
cache_enabled_by_default: profile.cache.enabledByDefault
|
|
810
|
+
})),
|
|
811
|
+
roles: Object.entries(config.roles).map(([role, value]) => ({
|
|
812
|
+
role,
|
|
813
|
+
default_model_reference: value.model ?? '',
|
|
814
|
+
default_reasoning_effort: value.modelReasoningEffort ?? '',
|
|
815
|
+
fallback_model_references: value.providerRouting.fallback,
|
|
816
|
+
policy_preset: value.policyPreset
|
|
817
|
+
})),
|
|
818
|
+
policy_presets: Object.entries(config.policyPresets).map(([name, preset]) => ({
|
|
819
|
+
name,
|
|
820
|
+
values: preset.capabilities,
|
|
821
|
+
value_descriptions: POLICY_VALUE_DESCRIPTIONS
|
|
822
|
+
})),
|
|
823
|
+
effect_scope_descriptions: EFFECT_SCOPE_DESCRIPTIONS,
|
|
824
|
+
// Retained alongside the contract fields: existing callers read these, and dropping them
|
|
825
|
+
// would trade one breakage for another.
|
|
826
|
+
providers: Object.keys(config.providers),
|
|
827
|
+
capabilities: dependencies.runtime(projectId).registry.list().map((entry) => entry.id)
|
|
828
|
+
};
|
|
829
|
+
}
|
|
830
|
+
/**
|
|
831
|
+
* Fastify's validation output, reduced to the fields a client can act on. Without this a schema
|
|
832
|
+
* rejection reported only that the request did not match, which gave no way to tell which field
|
|
833
|
+
* was at fault -- the reason a documented-but-unlisted property looked like a client bug.
|
|
834
|
+
*/
|
|
835
|
+
function schemaDetails(error) {
|
|
836
|
+
const validation = 'validation' in error ? error.validation : undefined;
|
|
837
|
+
if (!Array.isArray(validation))
|
|
838
|
+
return {};
|
|
839
|
+
return {
|
|
840
|
+
issues: validation.slice(0, 10).map((issue) => {
|
|
841
|
+
const entry = typeof issue === 'object' && issue !== null ? issue : {};
|
|
842
|
+
return {
|
|
843
|
+
path: typeof entry.instancePath === 'string' && entry.instancePath.length > 0 ? entry.instancePath : '/',
|
|
844
|
+
rule: typeof entry.keyword === 'string' ? entry.keyword : 'unknown',
|
|
845
|
+
message: typeof entry.message === 'string' ? entry.message : 'The value is not valid.',
|
|
846
|
+
...(typeof entry.params === 'object' && entry.params !== null
|
|
847
|
+
? { property: entry.params.additionalProperty ?? null }
|
|
848
|
+
: {})
|
|
849
|
+
};
|
|
850
|
+
})
|
|
851
|
+
};
|
|
852
|
+
}
|
|
853
|
+
/**
|
|
854
|
+
* Workspace path validation lives in the Executor, which is the only component that may resolve a
|
|
855
|
+
* path. A refusal is the client's problem (`WORKSPACE_PATH_INVALID`, 422, permanent); only a
|
|
856
|
+
* failure to answer is the Executor's (`EXECUTOR_UNAVAILABLE`, 503, retryable). Collapsing both
|
|
857
|
+
* into the latter told the user to retry a path that would always be rejected, and hid the reason.
|
|
858
|
+
*/
|
|
859
|
+
async function validateWorkspacePaths(dependencies, workspacePaths, createIfMissing) {
|
|
860
|
+
try {
|
|
861
|
+
return await dependencies.executor.validatePaths(workspacePaths, undefined, createIfMissing);
|
|
862
|
+
}
|
|
863
|
+
catch (error) {
|
|
864
|
+
if (error instanceof ExecutorCallError && error.refused) {
|
|
865
|
+
fail('WORKSPACE_PATH_INVALID', 422, error.message, { workspace_paths: [...workspacePaths] });
|
|
866
|
+
}
|
|
867
|
+
fail('EXECUTOR_UNAVAILABLE', 503, 'Executor could not validate the Workspace paths.');
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
/**
|
|
871
|
+
* Wraps a collection the Store returns whole in the documented `Page<T>` envelope. These
|
|
872
|
+
* collections are bounded by their parent (a Project's Workspaces, a Task's Agents), so there is
|
|
873
|
+
* no cursor to hand out; the shape still has to match, because clients read `items`.
|
|
874
|
+
*/
|
|
875
|
+
function wholePage(items, dependencies) {
|
|
876
|
+
return {
|
|
877
|
+
items: items,
|
|
878
|
+
next_cursor: null,
|
|
879
|
+
collection_revision: String(dependencies.store.stateRevision())
|
|
880
|
+
};
|
|
881
|
+
}
|
|
882
|
+
/** 02A `ProjectPageQuery.archive`. An unrecognised value falls back to the documented default. */
|
|
883
|
+
function archiveMode(request) {
|
|
884
|
+
const value = query(request).archive;
|
|
885
|
+
return value === 'archived' || value === 'all' ? value : 'active';
|
|
886
|
+
}
|
|
887
|
+
/** 02A `ContextPageQuery.direction`; anything unrecognised falls back to ordinal order. */
|
|
888
|
+
function pageDirection(request) {
|
|
889
|
+
return query(request).direction === 'backward' ? 'backward' : 'forward';
|
|
890
|
+
}
|
|
891
|
+
/** The documented id field for a batch read Route, which names the entity rather than using `ids`. */
|
|
892
|
+
function batchIdField(path) {
|
|
893
|
+
if (path === '/api/v1/context-items/batch')
|
|
894
|
+
return 'context_item_ids';
|
|
895
|
+
if (path === '/api/v1/messages/batch')
|
|
896
|
+
return 'message_ids';
|
|
897
|
+
if (path === '/api/v1/results/batch')
|
|
898
|
+
return 'result_ids';
|
|
899
|
+
return 'call_ids';
|
|
900
|
+
}
|
|
901
|
+
/**
|
|
902
|
+
* 02A `BatchReadResponse<T>`. An id that does not resolve belongs in `missing_ids` rather than
|
|
903
|
+
* failing the whole batch: the caller asked about a set, and one deleted member should not cost it
|
|
904
|
+
* the rest.
|
|
905
|
+
*/
|
|
906
|
+
function batchRead(request, field, read, dependencies) {
|
|
907
|
+
const items = [];
|
|
908
|
+
const missing = [];
|
|
909
|
+
for (const id of strings(body(request)[field], field)) {
|
|
910
|
+
try {
|
|
911
|
+
items.push(read(id));
|
|
912
|
+
}
|
|
913
|
+
catch (error) {
|
|
914
|
+
if (error instanceof LensError && error.status === 404)
|
|
915
|
+
missing.push(id);
|
|
916
|
+
else
|
|
917
|
+
throw error;
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
return {
|
|
921
|
+
items,
|
|
922
|
+
missing_ids: missing,
|
|
923
|
+
state_revision: String(dependencies.store.stateRevision()),
|
|
924
|
+
generated_at: new Date().toISOString()
|
|
925
|
+
};
|
|
926
|
+
}
|
|
927
|
+
function recordWindow(items, limit) {
|
|
928
|
+
return {
|
|
929
|
+
items: items,
|
|
930
|
+
next_sequence: items.at(-1)?.sequence ?? null,
|
|
931
|
+
has_more: items.length >= limit
|
|
932
|
+
};
|
|
933
|
+
}
|
|
934
|
+
function configMeta(dependencies) {
|
|
935
|
+
return {
|
|
936
|
+
config_digest: dependencies.config.current.digest,
|
|
937
|
+
file_digest: dependencies.config.current.fileDigest,
|
|
938
|
+
metadata_revision: String(metadataRevision(dependencies)),
|
|
939
|
+
pending_restart: dependencies.config.current.pendingRestart,
|
|
940
|
+
error: dependencies.config.current.error
|
|
941
|
+
};
|
|
942
|
+
}
|
|
943
|
+
function metadataRevision(dependencies) {
|
|
944
|
+
return dependencies.config.current.metadataRevision + dependencies.pair.metadataRevision;
|
|
945
|
+
}
|
|
946
|
+
function openapi(specs) {
|
|
947
|
+
const paths = {};
|
|
948
|
+
for (const spec of specs) {
|
|
949
|
+
const path = spec.path.replace(/:([a-zA-Z0-9_]+)/g, '{$1}');
|
|
950
|
+
const current = paths[path];
|
|
951
|
+
const operations = {};
|
|
952
|
+
if (current !== undefined && jsonObject(current))
|
|
953
|
+
Object.assign(operations, current);
|
|
954
|
+
const body = requestBody(spec.method, spec.path);
|
|
955
|
+
paths[path] = {
|
|
956
|
+
...operations,
|
|
957
|
+
[spec.method]: {
|
|
958
|
+
operationId: operationId(spec.method, spec.path),
|
|
959
|
+
parameters: parameters(spec.method, spec.path),
|
|
960
|
+
...(body === null ? {} : { requestBody: { required: true, content: { 'application/json': { schema: body } } } }),
|
|
961
|
+
responses: responses(spec.method, spec.path)
|
|
962
|
+
}
|
|
963
|
+
};
|
|
964
|
+
}
|
|
965
|
+
return {
|
|
966
|
+
openapi: '3.1.0',
|
|
967
|
+
info: { title: 'Lens Core API', version: '1.0.0' },
|
|
968
|
+
paths,
|
|
969
|
+
components: {
|
|
970
|
+
schemas: {
|
|
971
|
+
Success: {
|
|
972
|
+
type: 'object',
|
|
973
|
+
required: ['data'],
|
|
974
|
+
properties: { data: {}, meta: { type: 'object', additionalProperties: true } },
|
|
975
|
+
additionalProperties: false
|
|
976
|
+
},
|
|
977
|
+
Error: {
|
|
978
|
+
type: 'object',
|
|
979
|
+
required: ['error'],
|
|
980
|
+
properties: {
|
|
981
|
+
error: {
|
|
982
|
+
type: 'object',
|
|
983
|
+
required: ['code', 'message', 'details'],
|
|
984
|
+
properties: { code: { type: 'string' }, message: { type: 'string' }, details: {} },
|
|
985
|
+
additionalProperties: false
|
|
986
|
+
},
|
|
987
|
+
request_id: { type: 'string' }
|
|
988
|
+
},
|
|
989
|
+
additionalProperties: false
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
};
|
|
994
|
+
}
|
|
995
|
+
const stringSchema = { type: 'string', minLength: 1 };
|
|
996
|
+
const revisionSchema = { type: 'integer', minimum: 0 };
|
|
997
|
+
const stringArraySchema = { type: 'array', minItems: 1, items: stringSchema };
|
|
998
|
+
const booleanSchema = { type: 'boolean' };
|
|
999
|
+
function requestBody(method, path) {
|
|
1000
|
+
if (method === 'get' || method === 'delete')
|
|
1001
|
+
return null;
|
|
1002
|
+
// Takes no body, and gets no body schema either, so a bodyless `POST` from a process manager is
|
|
1003
|
+
// not rejected before it reaches the handler.
|
|
1004
|
+
if (path === '/health/shutdown')
|
|
1005
|
+
return null;
|
|
1006
|
+
if (path === '/api/v1/config/validate')
|
|
1007
|
+
return objectSchema({ content: { type: 'string', minLength: 1 } }, ['content']);
|
|
1008
|
+
if (path === '/api/v1/config')
|
|
1009
|
+
return objectSchema({ content: { type: 'string', minLength: 1 }, expected_file_digest: stringSchema }, ['content', 'expected_file_digest']);
|
|
1010
|
+
if (path === '/api/v1/projects')
|
|
1011
|
+
return objectSchema({ name: stringSchema, workspace_paths: stringArraySchema, create_if_missing: booleanSchema }, ['name', 'workspace_paths']);
|
|
1012
|
+
if (path === '/api/v1/projects/:project_id')
|
|
1013
|
+
return objectSchema({ name: stringSchema, expected_revision: revisionSchema }, ['name', 'expected_revision']);
|
|
1014
|
+
if (path === '/api/v1/projects/browse/open')
|
|
1015
|
+
return objectSchema({ workspace_paths: stringArraySchema, path: stringSchema }, ['workspace_paths', 'path']);
|
|
1016
|
+
if (path === '/api/v1/projects/choose-folder')
|
|
1017
|
+
return objectSchema({ initial_path: stringSchema });
|
|
1018
|
+
if (path === '/api/v1/calls/revert-preview')
|
|
1019
|
+
return objectSchema({ call_ids: stringArraySchema }, ['call_ids']);
|
|
1020
|
+
if (/\/projects\/:project_id\/workspaces(?:\/:workspace_id)?$/.test(path))
|
|
1021
|
+
return objectSchema({ name: stringSchema, paths: stringArraySchema, is_default: booleanSchema, create_if_missing: booleanSchema, expected_revision: revisionSchema }, method === 'patch' ? ['name', 'paths', 'expected_revision'] : ['name', 'paths']);
|
|
1022
|
+
if (path.endsWith('/archive') || path.endsWith('/restore'))
|
|
1023
|
+
return objectSchema({ expected_revision: revisionSchema }, ['expected_revision']);
|
|
1024
|
+
if (path === '/api/v1/tasks' || path === '/api/v1/projects/:project_id/tasks')
|
|
1025
|
+
return objectSchema({ title: stringSchema }, ['title']);
|
|
1026
|
+
if (path === '/api/v1/tasks/:task_id')
|
|
1027
|
+
return objectSchema({ title: stringSchema, expected_revision: revisionSchema }, ['title', 'expected_revision']);
|
|
1028
|
+
if (path === '/api/v1/tasks/:task_id/sessions')
|
|
1029
|
+
return objectSchema({
|
|
1030
|
+
title: stringSchema,
|
|
1031
|
+
workspace_id: stringSchema,
|
|
1032
|
+
agent_name: stringSchema,
|
|
1033
|
+
model_reference: stringSchema,
|
|
1034
|
+
model_reasoning_effort: stringSchema,
|
|
1035
|
+
policy_preset: stringSchema,
|
|
1036
|
+
initial_prompt: { type: 'string' },
|
|
1037
|
+
initial_message: { type: 'string' }
|
|
1038
|
+
}, ['title']);
|
|
1039
|
+
if (path === '/api/v1/sessions/:session_id')
|
|
1040
|
+
return objectSchema({ title: stringSchema, expected_revision: revisionSchema }, ['title', 'expected_revision']);
|
|
1041
|
+
if (path === '/api/v1/tasks/:task_id/agents' || path === '/api/v1/sessions/:session_id/agents')
|
|
1042
|
+
return agentBody();
|
|
1043
|
+
if (path === '/api/v1/agents/:agent_id')
|
|
1044
|
+
return objectSchema({ name: stringSchema, model_reference: stringSchema, model_reasoning_effort: stringSchema, policy_preset: stringSchema, expected_revision: revisionSchema }, ['expected_revision']);
|
|
1045
|
+
if (path === '/api/v1/agents/:agent_id/messages')
|
|
1046
|
+
return objectSchema({ text: stringSchema }, ['text']);
|
|
1047
|
+
if (path === '/api/v1/agents/:agent_id/cancel' || path.endsWith('/retry') || path.endsWith('/capabilities/refresh'))
|
|
1048
|
+
return null;
|
|
1049
|
+
if (path === '/api/v1/pending-messages/:pending_message_id')
|
|
1050
|
+
return objectSchema({ text: stringSchema, expected_revision: revisionSchema }, ['text', 'expected_revision']);
|
|
1051
|
+
if (path.endsWith('/pending-messages/reorder'))
|
|
1052
|
+
return objectSchema({ ids: stringArraySchema }, ['ids']);
|
|
1053
|
+
if (path.endsWith('/capabilities/load') || path.endsWith('/capabilities/unload'))
|
|
1054
|
+
return objectSchema({ capability_id: stringSchema }, ['capability_id']);
|
|
1055
|
+
if (path === '/api/v1/context/:context_item_id')
|
|
1056
|
+
return objectSchema({ body_text: stringSchema, expected_revision: revisionSchema }, ['body_text', 'expected_revision']);
|
|
1057
|
+
if (path === '/api/v1/context-overrides')
|
|
1058
|
+
return objectSchema({ agent_id: stringSchema, target_kind: { enum: ['agent', 'message', 'result', 'artifact'] }, target_id: stringSchema, instructions: stringSchema }, ['agent_id', 'target_kind', 'target_id', 'instructions']);
|
|
1059
|
+
if (path === '/api/v1/context-overrides/:context_override_id')
|
|
1060
|
+
return objectSchema({ instructions: stringSchema, expected_revision: revisionSchema }, ['instructions', 'expected_revision']);
|
|
1061
|
+
if (path.endsWith('/context/visibility'))
|
|
1062
|
+
return objectSchema({ turn_group_ids: stringArraySchema, visible: { type: 'boolean' }, expected_context_revision: revisionSchema }, ['turn_group_ids', 'visible', 'expected_context_revision']);
|
|
1063
|
+
if (path.endsWith('/context/reorder'))
|
|
1064
|
+
return objectSchema({ ordered_turn_group_ids: stringArraySchema, expected_context_revision: revisionSchema }, ['ordered_turn_group_ids', 'expected_context_revision']);
|
|
1065
|
+
if (path.endsWith('/context/projection/reset'))
|
|
1066
|
+
return objectSchema({
|
|
1067
|
+
expected_context_revision: revisionSchema,
|
|
1068
|
+
stages: { type: 'array', minItems: 1, uniqueItems: true, items: { type: 'string', enum: ['tool_result_budget', 'snip', 'microcompact', 'context_collapse'] } }
|
|
1069
|
+
}, ['expected_context_revision']);
|
|
1070
|
+
if (path.endsWith('/context/preview'))
|
|
1071
|
+
return objectSchema({ expected_context_revision: revisionSchema }, ['expected_context_revision']);
|
|
1072
|
+
if (path.endsWith('/context/compact'))
|
|
1073
|
+
return objectSchema({ expected_context_revision: revisionSchema, pivot_turn_group_id: stringSchema }, ['expected_context_revision']);
|
|
1074
|
+
if (path.endsWith('/context/fork'))
|
|
1075
|
+
return objectSchema({
|
|
1076
|
+
through_turn_group_id: stringSchema,
|
|
1077
|
+
expected_context_revision: revisionSchema,
|
|
1078
|
+
name: stringSchema,
|
|
1079
|
+
model_reference: stringSchema,
|
|
1080
|
+
model_reasoning_effort: stringSchema
|
|
1081
|
+
}, ['through_turn_group_id', 'expected_context_revision', 'name']);
|
|
1082
|
+
if (path === '/api/v1/calls')
|
|
1083
|
+
return objectSchema({ workspace_id: stringSchema, agent_id: stringSchema, capability_id: stringSchema, arguments: {} }, ['workspace_id', 'capability_id']);
|
|
1084
|
+
if (/\/calls\/:call_id\/(?:approve|reject|cancel)$/.test(path))
|
|
1085
|
+
return objectSchema({ expected_revision: revisionSchema }, ['expected_revision']);
|
|
1086
|
+
if (path.endsWith('/calls/:call_id/answer'))
|
|
1087
|
+
return objectSchema({ expected_revision: revisionSchema, answer: {} }, ['expected_revision', 'answer']);
|
|
1088
|
+
if (path.endsWith('/results/:result_id/include'))
|
|
1089
|
+
return objectSchema({ agent_id: stringSchema, expected_context_revision: revisionSchema }, ['agent_id', 'expected_context_revision']);
|
|
1090
|
+
if (path.endsWith('/batch')) {
|
|
1091
|
+
const field = batchIdField(path);
|
|
1092
|
+
return objectSchema({ [field]: stringArraySchema }, [field]);
|
|
1093
|
+
}
|
|
1094
|
+
if (path.endsWith('/subagents'))
|
|
1095
|
+
return objectSchema({
|
|
1096
|
+
name: stringSchema,
|
|
1097
|
+
fork_context: { type: 'string', enum: ['full', 'compact', 'none'] },
|
|
1098
|
+
model_reference: stringSchema,
|
|
1099
|
+
model_reasoning_effort: stringSchema,
|
|
1100
|
+
initial_prompt: { type: 'string' },
|
|
1101
|
+
initial_message: { type: 'string' },
|
|
1102
|
+
message: { type: 'string' }
|
|
1103
|
+
}, ['name', 'fork_context']);
|
|
1104
|
+
if (path.endsWith('/subagent-messages'))
|
|
1105
|
+
return objectSchema({ target_agent_id: stringSchema, message: stringSchema }, ['target_agent_id', 'message']);
|
|
1106
|
+
if (path.endsWith('/subagent-broadcasts'))
|
|
1107
|
+
return objectSchema({ message: stringSchema }, ['message']);
|
|
1108
|
+
if (path.endsWith('/runtime/maintenance/run'))
|
|
1109
|
+
return objectSchema({ before: stringSchema });
|
|
1110
|
+
return objectSchema({});
|
|
1111
|
+
}
|
|
1112
|
+
function agentBody() {
|
|
1113
|
+
return objectSchema({
|
|
1114
|
+
name: stringSchema,
|
|
1115
|
+
workspace_id: stringSchema,
|
|
1116
|
+
model_reference: stringSchema,
|
|
1117
|
+
model_reasoning_effort: stringSchema,
|
|
1118
|
+
policy_preset: stringSchema,
|
|
1119
|
+
message: { type: 'string' }
|
|
1120
|
+
}, ['name']);
|
|
1121
|
+
}
|
|
1122
|
+
function objectSchema(properties, required = []) {
|
|
1123
|
+
return { type: 'object', properties, required, additionalProperties: false };
|
|
1124
|
+
}
|
|
1125
|
+
function parameters(method, path) {
|
|
1126
|
+
const result = [];
|
|
1127
|
+
for (const match of path.matchAll(/:([a-zA-Z0-9_]+)/g)) {
|
|
1128
|
+
result.push({ name: match[1] ?? '', in: 'path', required: true, schema: stringSchema });
|
|
1129
|
+
}
|
|
1130
|
+
if (path.startsWith('/api/v1/')) {
|
|
1131
|
+
result.push({ name: 'X-Lens-Client-Kind', in: 'header', required: true, schema: { type: 'string', enum: ['native', 'browser'] } }, { name: 'X-Lens-Client-Mac', in: 'header', required: false, schema: stringSchema }, { name: 'X-Lens-Hardware-Id', in: 'header', required: false, schema: stringSchema }, { name: 'X-Lens-Browser-Fingerprint', in: 'header', required: false, schema: stringSchema }, { name: 'X-Lens-Project-Id', in: 'header', required: false, schema: stringSchema });
|
|
1132
|
+
}
|
|
1133
|
+
if (method === 'get' && (/\/tasks$|\/sessions$|\/runs$|\/steps$|\/calls$|\/artifacts$|\/context$|\/timeline$|\/model-attempts$|\/partitions$|\/turn-groups$|\/decisions$|\/logs$|\/traces$|\/agent-ops$/.test(path))) {
|
|
1134
|
+
result.push({ name: 'cursor', in: 'query', required: false, schema: stringSchema }, { name: 'limit', in: 'query', required: false, schema: { type: 'integer', minimum: 1, maximum: 500 } });
|
|
1135
|
+
}
|
|
1136
|
+
if (method === 'delete')
|
|
1137
|
+
result.push({ name: 'expected_revision', in: 'query', required: true, schema: revisionSchema });
|
|
1138
|
+
if (idempotentRoute(method, path))
|
|
1139
|
+
result.push({ name: 'Idempotency-Key', in: 'header', required: true, schema: { type: 'string', pattern: '^[A-Za-z0-9._:-]{1,128}$' } });
|
|
1140
|
+
return result;
|
|
1141
|
+
}
|
|
1142
|
+
function idempotentRoute(method, path) {
|
|
1143
|
+
return method === 'post' && (path === '/api/v1/tasks/:task_id/sessions'
|
|
1144
|
+
|| path === '/api/v1/agents/:agent_id/messages'
|
|
1145
|
+
|| path === '/api/v1/calls'
|
|
1146
|
+
|| path.endsWith('/subagents')
|
|
1147
|
+
|| path.endsWith('/subagent-messages')
|
|
1148
|
+
|| path.endsWith('/subagent-broadcasts'));
|
|
1149
|
+
}
|
|
1150
|
+
function responses(method, path) {
|
|
1151
|
+
if (path === '/api/v1/events')
|
|
1152
|
+
return { '200': { description: 'Server-sent RuntimeEvent stream', content: { 'text/event-stream': { schema: { type: 'string' } } } }, ...errorResponses() };
|
|
1153
|
+
if (path.endsWith('/runtime/metrics'))
|
|
1154
|
+
return { '200': { description: 'Prometheus metrics', content: { 'text/plain': { schema: { type: 'string' } } } }, ...errorResponses() };
|
|
1155
|
+
if (path === '/api/v1/artifacts/:artifact_id') {
|
|
1156
|
+
const content = { 'application/octet-stream': { schema: { type: 'string', contentEncoding: 'binary' } } };
|
|
1157
|
+
return {
|
|
1158
|
+
'200': { description: 'Artifact bytes', content },
|
|
1159
|
+
'206': { description: 'Artifact byte range', content },
|
|
1160
|
+
...errorResponses()
|
|
1161
|
+
};
|
|
1162
|
+
}
|
|
1163
|
+
const status = method === 'post' && (/\/projects$|\/workspaces$|\/tasks$|\/sessions$|\/agents$|\/subagents$|\/context-overrides$|\/context\/fork$|\/results\/:result_id\/include$/.test(path)) ? '201'
|
|
1164
|
+
: method === 'post' && (path === '/health/shutdown' || path.endsWith('/messages') || path === '/api/v1/calls' || path.endsWith('/subagent-messages') || path.endsWith('/subagent-broadcasts')) ? '202'
|
|
1165
|
+
: '200';
|
|
1166
|
+
return { [status]: { description: 'Success', content: { 'application/json': { schema: { '$ref': '#/components/schemas/Success' } } } }, ...errorResponses() };
|
|
1167
|
+
}
|
|
1168
|
+
function errorResponses() {
|
|
1169
|
+
const response = { description: 'Error', content: { 'application/json': { schema: { '$ref': '#/components/schemas/Error' } } } };
|
|
1170
|
+
return Object.fromEntries(['400', '403', '404', '409', '413', '416', '422', '428', '500', '502', '503'].map((status) => [status, response]));
|
|
1171
|
+
}
|
|
1172
|
+
function operationId(method, path) {
|
|
1173
|
+
return `${method}_${path.replace(/^\/api\/v1\//, '').replace(/^\//, '').replace(/[:/.-]+/g, '_') || 'root'}`;
|
|
1174
|
+
}
|
|
1175
|
+
function fastifyRequestError(error) {
|
|
1176
|
+
if (typeof error !== 'object' || error === null || !('statusCode' in error))
|
|
1177
|
+
return null;
|
|
1178
|
+
const status = error.statusCode;
|
|
1179
|
+
if (typeof status !== 'number' || status < 400 || status >= 500)
|
|
1180
|
+
return null;
|
|
1181
|
+
const code = 'code' in error && typeof error.code === 'string' ? error.code : 'INVALID_REQUEST';
|
|
1182
|
+
return new LensError(code, status, 'The request could not be parsed.');
|
|
1183
|
+
}
|
|
1184
|
+
function clientSignal(request, reply) {
|
|
1185
|
+
const controller = new AbortController();
|
|
1186
|
+
const abort = () => controller.abort();
|
|
1187
|
+
const dispose = () => {
|
|
1188
|
+
request.raw.off('aborted', abort);
|
|
1189
|
+
reply.raw.off('close', abort);
|
|
1190
|
+
};
|
|
1191
|
+
request.raw.once('aborted', abort);
|
|
1192
|
+
reply.raw.once('close', abort);
|
|
1193
|
+
reply.raw.once('finish', dispose);
|
|
1194
|
+
return controller.signal;
|
|
1195
|
+
}
|
|
1196
|
+
function byteRange(value, size) {
|
|
1197
|
+
if (value === undefined)
|
|
1198
|
+
return null;
|
|
1199
|
+
if (Array.isArray(value))
|
|
1200
|
+
fail('RANGE_NOT_SATISFIABLE', 416, 'Only one byte range is supported.', { size });
|
|
1201
|
+
const match = /^bytes=(\d*)-(\d*)$/.exec(value);
|
|
1202
|
+
if (match === null || (match[1] === '' && match[2] === ''))
|
|
1203
|
+
fail('RANGE_NOT_SATISFIABLE', 416, 'The byte range is invalid.', { size });
|
|
1204
|
+
const first = match[1] === '' ? null : Number(match[1]);
|
|
1205
|
+
const last = match[2] === '' ? null : Number(match[2]);
|
|
1206
|
+
if ((first !== null && !Number.isSafeInteger(first)) || (last !== null && !Number.isSafeInteger(last)) || (first === null && last === 0) || size === 0) {
|
|
1207
|
+
fail('RANGE_NOT_SATISFIABLE', 416, 'The byte range is invalid.', { size });
|
|
1208
|
+
}
|
|
1209
|
+
if (first === null)
|
|
1210
|
+
return { start: Math.max(0, size - (last ?? 0)), end: size - 1 };
|
|
1211
|
+
if (first >= size || (last !== null && last < first))
|
|
1212
|
+
fail('RANGE_NOT_SATISFIABLE', 416, 'The byte range is outside the Artifact.', { size });
|
|
1213
|
+
return { start: first, end: last === null ? size - 1 : Math.min(last, size - 1) };
|
|
1214
|
+
}
|
|
1215
|
+
function resolveProjectId(request, fallback) {
|
|
1216
|
+
const params = request.params;
|
|
1217
|
+
const queryValue = request.query;
|
|
1218
|
+
const header = request.headers['x-lens-project-id'];
|
|
1219
|
+
const value = params.project_id ?? queryValue.project_id ?? (Array.isArray(header) ? header[0] : header) ?? fallback;
|
|
1220
|
+
return text(value, 'project_id');
|
|
1221
|
+
}
|
|
1222
|
+
/**
|
|
1223
|
+
* Compatibility layer between the Store's row shape and the documented wire contract.
|
|
1224
|
+
*
|
|
1225
|
+
* 02A defines pagination as `next_cursor`/`collection_revision` and every entity summary as
|
|
1226
|
+
* `<entity>_id` (`ProjectSummary.project_id` and so on). The Store hands back raw SQLite rows,
|
|
1227
|
+
* whose primary key column is `id`, and the page helper emits camelCase, so responses have
|
|
1228
|
+
* never matched those schemas. A client written against the documents reads `project_id`,
|
|
1229
|
+
* gets `undefined`, and sends `X-Lens-Project-Id: undefined` — which 404s every subsequent
|
|
1230
|
+
* request.
|
|
1231
|
+
*
|
|
1232
|
+
* This normalises at the response boundary so all Routes are covered at once. It is a shim:
|
|
1233
|
+
* the durable fix is for each Route to return its documented field names, or for the Store to
|
|
1234
|
+
* map rows to wire records. Both `id` and `<entity>_id` are emitted so existing readers of
|
|
1235
|
+
* `id` keep working.
|
|
1236
|
+
*
|
|
1237
|
+
* Free-form subtrees are left byte-identical. Capability `inputSchema`/`outputSchema` are JSON
|
|
1238
|
+
* Schema, and Call input, Result content, error details and model payloads are caller or model
|
|
1239
|
+
* data; renaming keys inside them would corrupt meaning rather than translate it.
|
|
1240
|
+
*/
|
|
1241
|
+
const OPAQUE_KEYS = new Set([
|
|
1242
|
+
'inputSchema', 'outputSchema', 'input_schema', 'output_schema',
|
|
1243
|
+
'input', 'content', 'details', 'payload', 'preparation', 'usage', 'metadata',
|
|
1244
|
+
'answer', 'prompt', 'arguments', 'structured', 'document', 'config'
|
|
1245
|
+
]);
|
|
1246
|
+
// Core mints identifiers as `<entity>_<uuid>`, so the documented field name is derivable from
|
|
1247
|
+
// the value itself rather than from a per-Route table.
|
|
1248
|
+
const PREFIXED_ID = /^([a-z][a-z0-9]*)_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/u;
|
|
1249
|
+
function wireShape(value) {
|
|
1250
|
+
if (Array.isArray(value))
|
|
1251
|
+
return value.map((entry) => wireShape(entry));
|
|
1252
|
+
if (typeof value !== 'object' || value === null)
|
|
1253
|
+
return value;
|
|
1254
|
+
const source = value;
|
|
1255
|
+
const result = {};
|
|
1256
|
+
for (const [key, entry] of Object.entries(source)) {
|
|
1257
|
+
const name = key === 'nextCursor' ? 'next_cursor' : key === 'collectionRevision' ? 'collection_revision' : key;
|
|
1258
|
+
result[name] = OPAQUE_KEYS.has(key) ? entry : wireShape(entry);
|
|
1259
|
+
}
|
|
1260
|
+
if (typeof source.id === 'string') {
|
|
1261
|
+
const prefix = PREFIXED_ID.exec(source.id)?.[1];
|
|
1262
|
+
if (prefix !== undefined && !(`${prefix}_id` in result))
|
|
1263
|
+
result[`${prefix}_id`] = source.id;
|
|
1264
|
+
}
|
|
1265
|
+
return result;
|
|
1266
|
+
}
|
|
1267
|
+
// Transient conditions the caller may retry unchanged: not-ready, timeouts, upstream
|
|
1268
|
+
// failures and rate limits. A 4xx is the caller's own request and is not retryable.
|
|
1269
|
+
function retryableFailure(status) {
|
|
1270
|
+
return status === 408 || status === 429 || status >= 500;
|
|
1271
|
+
}
|
|
1272
|
+
function routePath(request) {
|
|
1273
|
+
return request.routeOptions.url ?? request.url.split('?')[0] ?? 'unknown';
|
|
1274
|
+
}
|
|
1275
|
+
function idempotencyKey(request, missingCode) {
|
|
1276
|
+
const value = request.headers['idempotency-key'];
|
|
1277
|
+
if (value === undefined)
|
|
1278
|
+
fail(missingCode, 428, 'Idempotency-Key is required.');
|
|
1279
|
+
if (typeof value !== 'string' || !/^[A-Za-z0-9._:-]{1,128}$/.test(value)) {
|
|
1280
|
+
fail('IDEMPOTENCY_KEY_INVALID', 400, 'Idempotency-Key is invalid.');
|
|
1281
|
+
}
|
|
1282
|
+
return value;
|
|
1283
|
+
}
|
|
1284
|
+
function body(request) {
|
|
1285
|
+
return record(request.body, 'body');
|
|
1286
|
+
}
|
|
1287
|
+
function query(request) {
|
|
1288
|
+
return request.query;
|
|
1289
|
+
}
|
|
1290
|
+
function queryRevision(request) {
|
|
1291
|
+
const value = query(request).expected_revision;
|
|
1292
|
+
return expectedRevision(typeof value === 'string' ? Number(value) : value);
|
|
1293
|
+
}
|
|
1294
|
+
function param(request, name) {
|
|
1295
|
+
return text(request.params[name], name, 256);
|
|
1296
|
+
}
|
|
1297
|
+
function page(request) {
|
|
1298
|
+
const values = query(request);
|
|
1299
|
+
const cursor = optionalText(values.cursor, 'cursor', 4096);
|
|
1300
|
+
const rawLimit = values.limit === undefined ? 100 : typeof values.limit === 'string' ? Number(values.limit) : values.limit;
|
|
1301
|
+
const limit = Math.min(integer(rawLimit, 'limit', 1), 500);
|
|
1302
|
+
return { ...(cursor === undefined ? {} : { cursor }), limit };
|
|
1303
|
+
}
|
|
1304
|
+
function paths(value) {
|
|
1305
|
+
return strings(value, 'paths');
|
|
1306
|
+
}
|
|
1307
|
+
function validatedWorkspacePaths(value) {
|
|
1308
|
+
if (!jsonObject(value))
|
|
1309
|
+
fail('WORKSPACE_INVALID', 400, 'Executor rejected the Workspace paths.');
|
|
1310
|
+
return strings(value.resolved_paths, 'resolved_paths');
|
|
1311
|
+
}
|
|
1312
|
+
function strings(value, field) {
|
|
1313
|
+
if (!Array.isArray(value) || value.length === 0 || value.some((entry) => typeof entry !== 'string' || entry.length === 0))
|
|
1314
|
+
fail('INVALID_INPUT', 400, `${field} must be a non-empty string array.`);
|
|
1315
|
+
return value;
|
|
1316
|
+
}
|
|
1317
|
+
function stringValue(value) {
|
|
1318
|
+
if (typeof value !== 'string')
|
|
1319
|
+
fail('DATABASE_CORRUPT', 503, 'A stored string field is invalid.');
|
|
1320
|
+
return value;
|
|
1321
|
+
}
|
|
1322
|
+
function rejectReservedFields(value) {
|
|
1323
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value))
|
|
1324
|
+
return;
|
|
1325
|
+
for (const key of ['source', 'role', 'authority', 'client_id']) {
|
|
1326
|
+
if (key in value)
|
|
1327
|
+
fail('RESERVED_FIELD', 400, `Request field ${key} is reserved.`);
|
|
1328
|
+
}
|
|
1329
|
+
}
|
|
1330
|
+
function jsonObject(value) {
|
|
1331
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
1332
|
+
}
|