@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,155 @@
|
|
|
1
|
+
import { fail } from '../core/domain.js';
|
|
2
|
+
import { capabilityManifest } from '../runtime/manifests.js';
|
|
3
|
+
import { pathResource } from './executor.js';
|
|
4
|
+
// Restores files changed by previous write_file/edit_file Calls. terminal_command_run/git_run are
|
|
5
|
+
// not tracked -- see docs/architecture/harness-level3/09-file-snapshot-storage-decision.md.
|
|
6
|
+
//
|
|
7
|
+
// The file history lives in the Executor, which is where the files are, so this adapter is a thin
|
|
8
|
+
// pass-through: Core decides which Calls may be reverted (Policy, Capability assignment, ownership
|
|
9
|
+
// of the Call ids) and the Executor decides what that means on disk. Core holds no file bytes, no
|
|
10
|
+
// object ids and no ordering logic -- the Executor orders by its own per-Workspace sequence and
|
|
11
|
+
// verifies every path still holds what the Call left behind before writing anything.
|
|
12
|
+
//
|
|
13
|
+
// Registered as a model-visible Capability rather than a maintenance-only Route: an Agent decides
|
|
14
|
+
// when undoing its own previous edits is the right move, the same way it decides to make them.
|
|
15
|
+
export class RevertAdapter {
|
|
16
|
+
store;
|
|
17
|
+
executor;
|
|
18
|
+
id = 'revert';
|
|
19
|
+
constructor(store, executor) {
|
|
20
|
+
this.store = store;
|
|
21
|
+
this.executor = executor;
|
|
22
|
+
}
|
|
23
|
+
async execute(invocation) {
|
|
24
|
+
const input = object(invocation.arguments);
|
|
25
|
+
const callIds = [...new Set(stringArray(input.call_ids))];
|
|
26
|
+
if (callIds.length === 0)
|
|
27
|
+
fail('CALL_ARGUMENTS_INVALID', 422, 'call_ids must not be empty.');
|
|
28
|
+
// The Executor keys history by (workspace_id, call_id) and is only told this Call's Workspace,
|
|
29
|
+
// so a target from another one would come back as absent history rather than as the mistake it
|
|
30
|
+
// is. Resolving the ids here also confirms they are Calls in this Project at all.
|
|
31
|
+
for (const callId of callIds) {
|
|
32
|
+
const target = this.store.getCall(invocation.projectId, callId);
|
|
33
|
+
if (target.workspace_id !== invocation.workspaceId)
|
|
34
|
+
fail('CALL_ARGUMENTS_INVALID', 422, `Call ${callId} belongs to a different Workspace.`);
|
|
35
|
+
}
|
|
36
|
+
const response = object(await this.executor.call('revertCall', {
|
|
37
|
+
workspace_paths: invocation.workspacePaths,
|
|
38
|
+
workspace_id: invocation.workspaceId,
|
|
39
|
+
call_ids: callIds,
|
|
40
|
+
// Reverting over content the recorded Call did not leave behind is refused by default. The
|
|
41
|
+
// Agent has to ask for it, and the Result says which paths were affected either way.
|
|
42
|
+
force: input.force === true,
|
|
43
|
+
full_access: invocation.fullAccess === true,
|
|
44
|
+
call_id: invocation.callId
|
|
45
|
+
}, 120000, invocation.signal));
|
|
46
|
+
const conflicts = array(response.conflicts);
|
|
47
|
+
if (conflicts.length > 0) {
|
|
48
|
+
return {
|
|
49
|
+
content: { reverted_calls: [], conflicts },
|
|
50
|
+
outcome: 'failed',
|
|
51
|
+
errorCode: 'REVERT_CONFLICT'
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
return { content: { reverted_calls: array(response.calls) } };
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
export function revertCapabilities(store, executor) {
|
|
58
|
+
const definition = {
|
|
59
|
+
id: 'revert_call',
|
|
60
|
+
title: 'Revert Call',
|
|
61
|
+
description: 'Restore files to their state before one or more previous write_file or edit_file Calls. Refuses if a file no longer holds what that Call wrote, unless force is set. terminal_command_run and git_run changes cannot be reverted.',
|
|
62
|
+
adapterId: 'revert',
|
|
63
|
+
effect: 'workspace_write',
|
|
64
|
+
loadMode: 'deferred',
|
|
65
|
+
outputLimitBytes: 262144,
|
|
66
|
+
defaultAssignment: 'none',
|
|
67
|
+
inputSchema: {
|
|
68
|
+
type: 'object',
|
|
69
|
+
additionalProperties: false,
|
|
70
|
+
properties: {
|
|
71
|
+
call_ids: { type: 'array', items: { type: 'string', minLength: 1 }, minItems: 1 },
|
|
72
|
+
force: { type: 'boolean' }
|
|
73
|
+
},
|
|
74
|
+
required: ['call_ids']
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
return [{
|
|
78
|
+
definition,
|
|
79
|
+
adapter: new RevertAdapter(store, executor),
|
|
80
|
+
manifest: capabilityManifest(definition),
|
|
81
|
+
claims: (context) => revertClaims(store, context)
|
|
82
|
+
}];
|
|
83
|
+
}
|
|
84
|
+
// The paths a revert will touch are already in Core's own Store: every target is a write_file or
|
|
85
|
+
// edit_file Call whose `path` argument is recorded in its input. So the claim can be narrowed to
|
|
86
|
+
// those paths without asking the Executor, which keeps claim computation synchronous -- Claim
|
|
87
|
+
// admission has to stay in the same critical section as the QUEUED -> EXECUTING CAS.
|
|
88
|
+
//
|
|
89
|
+
// The resource names come from the same helper the original write_file/edit_file Calls used, because
|
|
90
|
+
// a claim only detects a conflict if both sides spell the resource identically.
|
|
91
|
+
//
|
|
92
|
+
// Anything unexpected -- a target that is not a file Call, a missing path, a Call from another
|
|
93
|
+
// Workspace -- returns the global write claim instead of a narrower guess. A revert that claims too
|
|
94
|
+
// little would run concurrently with a write to the same file.
|
|
95
|
+
function revertClaims(store, context) {
|
|
96
|
+
const global = [{ resource: '*', mode: 'write' }];
|
|
97
|
+
const input = record(context.arguments);
|
|
98
|
+
if (input === undefined)
|
|
99
|
+
return global;
|
|
100
|
+
const callIds = input.call_ids;
|
|
101
|
+
if (!Array.isArray(callIds) || callIds.length === 0)
|
|
102
|
+
return global;
|
|
103
|
+
const resources = new Set();
|
|
104
|
+
for (const entry of callIds) {
|
|
105
|
+
if (typeof entry !== 'string' || entry.length === 0)
|
|
106
|
+
return global;
|
|
107
|
+
const call = store.getCall(context.projectId, entry);
|
|
108
|
+
if (call.workspace_id !== context.workspaceId)
|
|
109
|
+
return global;
|
|
110
|
+
if (call.capability_id !== 'write_file' && call.capability_id !== 'edit_file')
|
|
111
|
+
return global;
|
|
112
|
+
// Prefer the path the Executor reported in the Result: that one is realpath-resolved, so a target
|
|
113
|
+
// reached through a symlink, junction or alias yields the same resource name as any other spelling
|
|
114
|
+
// of the same file. The request argument is only a fallback for a Call with no Result, and it is a
|
|
115
|
+
// naive resolve that cannot see through links.
|
|
116
|
+
const path = resultPath(call.result) ?? filePath(call.input);
|
|
117
|
+
if (path === undefined)
|
|
118
|
+
return global;
|
|
119
|
+
resources.add(pathResource(path, context.workspacePaths));
|
|
120
|
+
}
|
|
121
|
+
return [...resources].map((resource) => ({ resource, mode: 'write' }));
|
|
122
|
+
}
|
|
123
|
+
// The Executor's own resolved absolute path for the file it wrote, as returned in the Result.
|
|
124
|
+
function resultPath(value) {
|
|
125
|
+
const path = record(record(value)?.content)?.relative_path;
|
|
126
|
+
return typeof path === 'string' && path.length > 0 ? path : undefined;
|
|
127
|
+
}
|
|
128
|
+
// Mirrors CallRuntime's originalArguments: a Call that went through policy review or user input has
|
|
129
|
+
// its arguments nested under `arguments`.
|
|
130
|
+
function filePath(value) {
|
|
131
|
+
const outer = record(value);
|
|
132
|
+
if (outer === undefined)
|
|
133
|
+
return undefined;
|
|
134
|
+
const inner = record('arguments' in outer && ('answer' in outer || 'prompt' in outer) ? outer.arguments : outer);
|
|
135
|
+
const path = inner?.path;
|
|
136
|
+
return typeof path === 'string' && path.length > 0 ? path : undefined;
|
|
137
|
+
}
|
|
138
|
+
function record(value) {
|
|
139
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value))
|
|
140
|
+
return undefined;
|
|
141
|
+
return value;
|
|
142
|
+
}
|
|
143
|
+
function object(value) {
|
|
144
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value))
|
|
145
|
+
fail('INVALID_INPUT', 400, 'Revert input must be an object.');
|
|
146
|
+
return value;
|
|
147
|
+
}
|
|
148
|
+
function array(value) {
|
|
149
|
+
return Array.isArray(value) ? value : [];
|
|
150
|
+
}
|
|
151
|
+
function stringArray(value) {
|
|
152
|
+
if (!Array.isArray(value) || value.some((entry) => typeof entry !== 'string' || entry.length === 0))
|
|
153
|
+
fail('INVALID_INPUT', 400, 'call_ids must be a string array.');
|
|
154
|
+
return value;
|
|
155
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { type Json } from '../core/domain.js';
|
|
2
|
+
import { type HarnessConfig } from './schema.js';
|
|
3
|
+
export interface ConfigSnapshot {
|
|
4
|
+
readonly config: HarnessConfig;
|
|
5
|
+
/**
|
|
6
|
+
* The file's own parse, before restart-only settings are held back.
|
|
7
|
+
*
|
|
8
|
+
* `config` is the effective config, which keeps the previous value for anything that needs a
|
|
9
|
+
* restart. Rendering the document from that made a written-but-pending change invisible: a client
|
|
10
|
+
* could not see the setting it had just saved, and -- worse -- read/modify/write through the
|
|
11
|
+
* document would have written the file back without it.
|
|
12
|
+
*/
|
|
13
|
+
readonly fileConfig: HarnessConfig;
|
|
14
|
+
readonly digest: string;
|
|
15
|
+
readonly fileDigest: string;
|
|
16
|
+
readonly metadataRevision: number;
|
|
17
|
+
readonly pendingRestart: readonly string[];
|
|
18
|
+
readonly appliedHot: readonly string[];
|
|
19
|
+
readonly error: string | null;
|
|
20
|
+
}
|
|
21
|
+
export interface ConfigDocument {
|
|
22
|
+
readonly content: string | null;
|
|
23
|
+
readonly content_omitted: boolean;
|
|
24
|
+
readonly file_size_bytes: string;
|
|
25
|
+
readonly file_digest: string;
|
|
26
|
+
readonly active_config_digest: string;
|
|
27
|
+
readonly file_valid: boolean;
|
|
28
|
+
readonly validation_errors: readonly Json[];
|
|
29
|
+
readonly applied_hot: readonly string[];
|
|
30
|
+
readonly pending_restart: readonly string[];
|
|
31
|
+
}
|
|
32
|
+
export declare class ConfigRuntime {
|
|
33
|
+
#private;
|
|
34
|
+
readonly path: string;
|
|
35
|
+
constructor(root: string, path?: string);
|
|
36
|
+
get current(): ConfigSnapshot;
|
|
37
|
+
load(): Promise<ConfigSnapshot>;
|
|
38
|
+
parse(source: string): HarnessConfig;
|
|
39
|
+
validate(source: string): {
|
|
40
|
+
readonly valid: boolean;
|
|
41
|
+
readonly normalized_digest: string | null;
|
|
42
|
+
readonly errors: readonly Json[];
|
|
43
|
+
};
|
|
44
|
+
replace(source: string, expectedFileDigest: string): Promise<ConfigSnapshot>;
|
|
45
|
+
start(onChange: (snapshot: ConfigSnapshot) => void): void;
|
|
46
|
+
close(): void;
|
|
47
|
+
publicConfig(): Json;
|
|
48
|
+
/** The file as a client should see it: every saved setting, with secrets redacted. */
|
|
49
|
+
publicFileConfig(): Json;
|
|
50
|
+
document(): ConfigDocument;
|
|
51
|
+
}
|
|
52
|
+
export declare function resolveSecret(value: string | {
|
|
53
|
+
readonly env: string;
|
|
54
|
+
readonly optional?: boolean;
|
|
55
|
+
} | undefined): string | undefined;
|
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { watch } from 'node:fs';
|
|
3
|
+
import { readFile, rename, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { basename, dirname, isAbsolute, normalize, resolve } from 'node:path';
|
|
5
|
+
import { Ajv } from 'ajv';
|
|
6
|
+
import { canonicalize } from 'json-canonicalize';
|
|
7
|
+
import { parse } from 'jsonc-parser';
|
|
8
|
+
import { fail } from '../core/domain.js';
|
|
9
|
+
import { ConfigSchema } from './schema.js';
|
|
10
|
+
export class ConfigRuntime {
|
|
11
|
+
path;
|
|
12
|
+
#validate;
|
|
13
|
+
#snapshot;
|
|
14
|
+
#watcher;
|
|
15
|
+
#reloadTail = Promise.resolve();
|
|
16
|
+
constructor(root, path = 'config/lens.jsonc') {
|
|
17
|
+
this.path = resolve(root, path);
|
|
18
|
+
this.#validate = new Ajv({ allErrors: true, strict: false }).compile(ConfigSchema);
|
|
19
|
+
}
|
|
20
|
+
get current() {
|
|
21
|
+
if (this.#snapshot === undefined)
|
|
22
|
+
fail('CONFIG_UNAVAILABLE', 503, 'Configuration has not been loaded.');
|
|
23
|
+
return this.#snapshot;
|
|
24
|
+
}
|
|
25
|
+
async load() {
|
|
26
|
+
let source;
|
|
27
|
+
try {
|
|
28
|
+
source = await readFile(this.path, 'utf8');
|
|
29
|
+
}
|
|
30
|
+
catch (error) {
|
|
31
|
+
fail('CONFIG_INVALID', 503, 'The configuration file could not be read.', { reason: message(error) });
|
|
32
|
+
}
|
|
33
|
+
const previous = this.#snapshot;
|
|
34
|
+
const fileDigest = hash(source);
|
|
35
|
+
if (previous?.fileDigest === fileDigest)
|
|
36
|
+
return previous;
|
|
37
|
+
const candidate = this.parse(source);
|
|
38
|
+
const config = previous === undefined ? candidate : effective(previous.config, candidate);
|
|
39
|
+
const next = {
|
|
40
|
+
config,
|
|
41
|
+
fileConfig: candidate,
|
|
42
|
+
digest: hash(canonicalize(config)),
|
|
43
|
+
fileDigest,
|
|
44
|
+
metadataRevision: (previous?.metadataRevision ?? 0) + 1,
|
|
45
|
+
pendingRestart: previous === undefined ? [] : restartPending(previous.config, candidate),
|
|
46
|
+
appliedHot: previous === undefined ? [] : appliedHot(previous.config, candidate),
|
|
47
|
+
error: null
|
|
48
|
+
};
|
|
49
|
+
this.#snapshot = next;
|
|
50
|
+
return next;
|
|
51
|
+
}
|
|
52
|
+
parse(source) {
|
|
53
|
+
const errors = [];
|
|
54
|
+
const document = parse(source, errors, { allowTrailingComma: true });
|
|
55
|
+
if (errors.length > 0 || !this.#validate(document)) {
|
|
56
|
+
fail('CONFIG_INVALID', 400, 'The configuration is invalid.', { issues: issues(errors, this.#validate.errors) });
|
|
57
|
+
}
|
|
58
|
+
const semantic = semanticIssues(document);
|
|
59
|
+
if (semantic.length > 0)
|
|
60
|
+
fail('CONFIG_INVALID', 400, 'The configuration is invalid.', { issues: semantic });
|
|
61
|
+
return document;
|
|
62
|
+
}
|
|
63
|
+
validate(source) {
|
|
64
|
+
try {
|
|
65
|
+
const config = this.parse(source);
|
|
66
|
+
return { valid: true, normalized_digest: hash(canonicalize(config)), errors: [] };
|
|
67
|
+
}
|
|
68
|
+
catch (error) {
|
|
69
|
+
return { valid: false, normalized_digest: null, errors: validationDetails(error) };
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
replace(source, expectedFileDigest) {
|
|
73
|
+
if (expectedFileDigest !== this.current.fileDigest)
|
|
74
|
+
fail('CONFIG_CONFLICT', 409, 'The configuration file changed.');
|
|
75
|
+
// Resolved against the file, because the document the client edited was rendered from the file.
|
|
76
|
+
// Using the effective config would fail to restore a secret belonging to a restart-only setting
|
|
77
|
+
// that has been saved but not yet applied -- the placeholder would be written as the value.
|
|
78
|
+
const persisted = restoreSecrets(source, this.current.fileConfig);
|
|
79
|
+
this.parse(persisted);
|
|
80
|
+
const operation = this.#reloadTail.then(async () => {
|
|
81
|
+
const temporary = `${this.path}.${process.pid}.${randomUUID()}.tmp`;
|
|
82
|
+
await writeFile(temporary, persisted, 'utf8');
|
|
83
|
+
await rename(temporary, this.path);
|
|
84
|
+
return this.load();
|
|
85
|
+
});
|
|
86
|
+
this.#reloadTail = operation.then(() => undefined, () => undefined);
|
|
87
|
+
return operation;
|
|
88
|
+
}
|
|
89
|
+
start(onChange) {
|
|
90
|
+
this.#watcher = watch(dirname(this.path), (_event, filename) => {
|
|
91
|
+
if (filename !== null && filename !== basename(this.path))
|
|
92
|
+
return;
|
|
93
|
+
this.#reloadTail = this.#reloadTail.then(async () => {
|
|
94
|
+
try {
|
|
95
|
+
const previous = this.current;
|
|
96
|
+
const next = await this.load();
|
|
97
|
+
if (next !== previous)
|
|
98
|
+
onChange(next);
|
|
99
|
+
}
|
|
100
|
+
catch (error) {
|
|
101
|
+
const current = this.current;
|
|
102
|
+
const source = await readFile(this.path, 'utf8').catch(() => '');
|
|
103
|
+
const next = {
|
|
104
|
+
...current,
|
|
105
|
+
fileDigest: hash(source),
|
|
106
|
+
metadataRevision: current.metadataRevision + 1,
|
|
107
|
+
error: message(error)
|
|
108
|
+
};
|
|
109
|
+
this.#snapshot = next;
|
|
110
|
+
onChange(next);
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
close() {
|
|
116
|
+
this.#watcher?.close();
|
|
117
|
+
this.#watcher = undefined;
|
|
118
|
+
}
|
|
119
|
+
publicConfig() {
|
|
120
|
+
return redact(this.current.config);
|
|
121
|
+
}
|
|
122
|
+
/** The file as a client should see it: every saved setting, with secrets redacted. */
|
|
123
|
+
publicFileConfig() {
|
|
124
|
+
return redact(this.current.fileConfig);
|
|
125
|
+
}
|
|
126
|
+
document() {
|
|
127
|
+
const snapshot = this.current;
|
|
128
|
+
// Rendered from the file, not the effective config, so a restart-only setting that has been
|
|
129
|
+
// saved is visible and survives the next read/modify/write.
|
|
130
|
+
const content = snapshot.error === null ? `${JSON.stringify(this.publicFileConfig(), null, 2)}\n` : null;
|
|
131
|
+
return {
|
|
132
|
+
content,
|
|
133
|
+
content_omitted: content === null,
|
|
134
|
+
// Documented as a decimal string, like the revisions, so a client never has to guess
|
|
135
|
+
// whether a large size arrived as a number.
|
|
136
|
+
file_size_bytes: String(content === null ? 0 : Buffer.byteLength(content)),
|
|
137
|
+
file_digest: snapshot.fileDigest,
|
|
138
|
+
active_config_digest: snapshot.digest,
|
|
139
|
+
file_valid: snapshot.error === null,
|
|
140
|
+
// `SchemaIssue` carries a path and a stable code beside the message.
|
|
141
|
+
validation_errors: snapshot.error === null
|
|
142
|
+
? []
|
|
143
|
+
: [{ path: '/', code: 'CONFIG_INVALID', message: snapshot.error }],
|
|
144
|
+
applied_hot: snapshot.appliedHot,
|
|
145
|
+
pending_restart: snapshot.pendingRestart
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
export function resolveSecret(value) {
|
|
150
|
+
if (typeof value === 'string')
|
|
151
|
+
return value;
|
|
152
|
+
if (value === undefined)
|
|
153
|
+
return undefined;
|
|
154
|
+
const secret = process.env[value.env];
|
|
155
|
+
if ((secret === undefined || secret.length === 0) && value.optional !== true) {
|
|
156
|
+
fail('MODEL_CREDENTIAL_MISSING', 503, `Environment variable ${value.env} is required.`);
|
|
157
|
+
}
|
|
158
|
+
return secret;
|
|
159
|
+
}
|
|
160
|
+
function redact(value, key = '', headerValue = false) {
|
|
161
|
+
if (typeof value === 'string' && (key === 'apiKey' || headerValue))
|
|
162
|
+
return '[redacted]';
|
|
163
|
+
if (Array.isArray(value))
|
|
164
|
+
return value.map((entry) => redact(entry, '', headerValue));
|
|
165
|
+
if (typeof value !== 'object' || value === null)
|
|
166
|
+
return value;
|
|
167
|
+
return Object.fromEntries(Object.entries(value).map(([entryKey, entry]) => [
|
|
168
|
+
entryKey,
|
|
169
|
+
redact(entry, entryKey, headerValue || key === 'headers')
|
|
170
|
+
]));
|
|
171
|
+
}
|
|
172
|
+
function restoreSecrets(source, current) {
|
|
173
|
+
const candidate = parse(source);
|
|
174
|
+
if (typeof candidate !== 'object' || candidate === null || Array.isArray(candidate))
|
|
175
|
+
return source;
|
|
176
|
+
let changed = false;
|
|
177
|
+
const document = candidate;
|
|
178
|
+
const providers = record(document.providers);
|
|
179
|
+
for (const [providerId, provider] of Object.entries(providers ?? {})) {
|
|
180
|
+
const options = record(record(provider)?.options);
|
|
181
|
+
if (options?.apiKey !== '[redacted]')
|
|
182
|
+
continue;
|
|
183
|
+
const existing = current.providers[providerId]?.options.apiKey;
|
|
184
|
+
if (existing !== undefined) {
|
|
185
|
+
options.apiKey = existing;
|
|
186
|
+
changed = true;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
const servers = record(document.mcpServers);
|
|
190
|
+
for (const [serverId, server] of Object.entries(servers ?? {})) {
|
|
191
|
+
const headers = record(record(server)?.headers);
|
|
192
|
+
if (headers === null)
|
|
193
|
+
continue;
|
|
194
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
195
|
+
if (value !== '[redacted]')
|
|
196
|
+
continue;
|
|
197
|
+
const existing = current.mcpServers?.[serverId]?.headers?.[name];
|
|
198
|
+
if (existing !== undefined) {
|
|
199
|
+
headers[name] = existing;
|
|
200
|
+
changed = true;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return changed ? `${JSON.stringify(document, null, 2)}\n` : source;
|
|
205
|
+
}
|
|
206
|
+
function record(value) {
|
|
207
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value) ? value : null;
|
|
208
|
+
}
|
|
209
|
+
function hash(value) {
|
|
210
|
+
return createHash('sha256').update(value).digest('hex');
|
|
211
|
+
}
|
|
212
|
+
function issues(parseErrors, validationErrors) {
|
|
213
|
+
return [
|
|
214
|
+
...parseErrors.map((error) => ({ kind: 'parse', offset: error.offset, length: error.length })),
|
|
215
|
+
...(validationErrors ?? []).map((error) => ({ kind: 'schema', path: error.instancePath, message: error.message ?? 'invalid' }))
|
|
216
|
+
];
|
|
217
|
+
}
|
|
218
|
+
function message(error) {
|
|
219
|
+
return error instanceof Error ? error.message : String(error);
|
|
220
|
+
}
|
|
221
|
+
function validationDetails(error) {
|
|
222
|
+
if (typeof error === 'object' && error !== null && 'details' in error) {
|
|
223
|
+
const details = error.details;
|
|
224
|
+
if (typeof details === 'object' && details !== null && !Array.isArray(details) && 'issues' in details && Array.isArray(details.issues))
|
|
225
|
+
return details.issues;
|
|
226
|
+
}
|
|
227
|
+
return [{ message: message(error) }];
|
|
228
|
+
}
|
|
229
|
+
function semanticIssues(config) {
|
|
230
|
+
const result = [];
|
|
231
|
+
const issue = (path, messageValue) => { result.push({ kind: 'semantic', path, message: messageValue }); };
|
|
232
|
+
const profiles = new Set(Object.keys(config.modelHarnessProfiles ?? {}));
|
|
233
|
+
for (const [profileId, profile] of Object.entries(config.modelHarnessProfiles ?? {})) {
|
|
234
|
+
if (!profile.effort.allowed.includes(profile.effort.default))
|
|
235
|
+
issue(`/modelHarnessProfiles/${profileId}/effort/default`, 'The default effort is not allowed by the profile.');
|
|
236
|
+
}
|
|
237
|
+
for (const [providerId, provider] of Object.entries(config.providers)) {
|
|
238
|
+
if (!/^[a-z0-9][a-z0-9._-]{0,127}$/u.test(providerId))
|
|
239
|
+
issue(`/providers/${providerId}`, 'Provider ID is invalid.');
|
|
240
|
+
if (provider.adapter === '@ai-sdk/openai-compatible' && provider.options.baseURL === undefined)
|
|
241
|
+
issue(`/providers/${providerId}/options/baseURL`, 'OpenAI-compatible providers require baseURL.');
|
|
242
|
+
for (const [modelId, model] of Object.entries(provider.models)) {
|
|
243
|
+
if (modelId.length === 0 || modelId.includes('\n'))
|
|
244
|
+
issue(`/providers/${providerId}/models/${modelId}`, 'Model ID is invalid.');
|
|
245
|
+
if (model.harnessProfile !== undefined && !profiles.has(model.harnessProfile))
|
|
246
|
+
issue(`/providers/${providerId}/models/${modelId}/harnessProfile`, 'Model Harness Profile does not exist.');
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
for (const [roleName, role] of Object.entries(config.roles)) {
|
|
250
|
+
const agentRole = roleName === 'agent' || roleName === 'subagent';
|
|
251
|
+
const model = role.model === null ? undefined : configuredModel(config, role.model);
|
|
252
|
+
if (model === undefined)
|
|
253
|
+
issue(`/roles/${roleName}/model`, 'Role model is not configured.');
|
|
254
|
+
if (role.modelReasoningEffort === null)
|
|
255
|
+
issue(`/roles/${roleName}/modelReasoningEffort`, 'Role reasoning effort is required.');
|
|
256
|
+
else {
|
|
257
|
+
const allowed = model?.harnessProfile === undefined
|
|
258
|
+
? ['none', 'minimal', 'low', 'medium', 'high']
|
|
259
|
+
: config.modelHarnessProfiles?.[model.harnessProfile]?.effort.allowed ?? [];
|
|
260
|
+
if (!allowed.includes(role.modelReasoningEffort))
|
|
261
|
+
issue(`/roles/${roleName}/modelReasoningEffort`, 'Role reasoning effort is not allowed by the Model Harness Profile.');
|
|
262
|
+
}
|
|
263
|
+
if (agentRole && (role.policyPreset === null || config.policyPresets[role.policyPreset] === undefined))
|
|
264
|
+
issue(`/roles/${roleName}/policyPreset`, 'Agent roles require an existing Policy preset.');
|
|
265
|
+
if ((agentRole || roleName === 'compactor') && role.contextStrategy !== 'default')
|
|
266
|
+
issue(`/roles/${roleName}/contextStrategy`, 'The v1 Context Strategy is default.');
|
|
267
|
+
if (roleName === 'compactor' && (role.policyPreset !== null || role.capabilities.length > 0))
|
|
268
|
+
issue(`/roles/${roleName}`, 'Compactor has no Policy or Capabilities.');
|
|
269
|
+
if (roleName === 'auto-review' && (role.policyPreset !== null || role.capabilities.length > 0 || role.contextStrategy !== null))
|
|
270
|
+
issue(`/roles/${roleName}`, 'Auto Review has no Policy, Capabilities or Context Strategy.');
|
|
271
|
+
if (new Set(role.providerRouting.fallback).size !== role.providerRouting.fallback.length)
|
|
272
|
+
issue(`/roles/${roleName}/providerRouting/fallback`, 'Fallback models must be unique.');
|
|
273
|
+
for (const reference of role.providerRouting.fallback) {
|
|
274
|
+
if (configuredModel(config, reference) === undefined)
|
|
275
|
+
issue(`/roles/${roleName}/providerRouting/fallback`, `Fallback model ${reference} is not configured.`);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
for (const [serverId, server] of Object.entries(config.mcpServers ?? {})) {
|
|
279
|
+
let endpoint = null;
|
|
280
|
+
try {
|
|
281
|
+
endpoint = new URL(server.endpoint);
|
|
282
|
+
}
|
|
283
|
+
catch {
|
|
284
|
+
issue(`/mcpServers/${serverId}/endpoint`, 'MCP endpoint must be an absolute URL.');
|
|
285
|
+
}
|
|
286
|
+
if (endpoint !== null && (!['http:', 'https:'].includes(endpoint.protocol) || endpoint.username.length > 0 || endpoint.password.length > 0))
|
|
287
|
+
issue(`/mcpServers/${serverId}/endpoint`, 'MCP endpoint must be HTTP(S) without URL credentials.');
|
|
288
|
+
for (const [name, value] of Object.entries(server.headers ?? {})) {
|
|
289
|
+
const lower = name.toLowerCase();
|
|
290
|
+
if (!/^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/u.test(name) || ['content-type', 'accept', 'mcp-protocol-version'].includes(lower))
|
|
291
|
+
issue(`/mcpServers/${serverId}/headers/${name}`, 'MCP header name is invalid or reserved.');
|
|
292
|
+
if (typeof value === 'string' && hasControl(value))
|
|
293
|
+
issue(`/mcpServers/${serverId}/headers/${name}`, 'MCP header value contains control characters.');
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
if (config.tls.enabled && (config.tls.certFile === null || config.tls.keyFile === null))
|
|
297
|
+
issue('/tls', 'TLS requires certFile and keyFile.');
|
|
298
|
+
const executorTls = config.executor.tls;
|
|
299
|
+
if (executorTls?.enabled === true && ((executorTls.certFile === undefined) !== (executorTls.keyFile === undefined)))
|
|
300
|
+
issue('/executor/tls', 'Executor client certificate and key must be configured together.');
|
|
301
|
+
if (config.historyRetention.purgeMode === 'after_days' && config.historyRetention.purgeArchivedAfterDays === null)
|
|
302
|
+
issue('/historyRetention', 'Automatic purge requires purgeArchivedAfterDays.');
|
|
303
|
+
if (config.snapshot.pageSizeMax > 500 || config.snapshot.pageSizeDefault > config.snapshot.pageSizeMax)
|
|
304
|
+
issue('/snapshot', 'Snapshot page sizes are inconsistent or exceed 500.');
|
|
305
|
+
const projection = config.contextProjection;
|
|
306
|
+
if (projection?.snip !== undefined && projection.snip.retainedTokens > projection.snip.triggerTokens)
|
|
307
|
+
issue('/contextProjection/snip', 'Retained tokens exceed the trigger.');
|
|
308
|
+
if (projection?.microcompact !== undefined && projection.microcompact.projectedTokens > projection.microcompact.triggerTokens)
|
|
309
|
+
issue('/contextProjection/microcompact', 'Projected tokens exceed the trigger.');
|
|
310
|
+
if (projection?.contextCollapse !== undefined && projection.contextCollapse.projectedTokens > projection.contextCollapse.triggerTokens)
|
|
311
|
+
issue('/contextProjection/contextCollapse', 'Projected tokens exceed the trigger.');
|
|
312
|
+
const logPath = config.observability?.logs?.path;
|
|
313
|
+
if (logPath !== undefined && (isAbsolute(logPath) || normalize(logPath).split(/[\\/]/u).includes('..')))
|
|
314
|
+
issue('/observability/logs/path', 'Log path must stay inside the project root.');
|
|
315
|
+
const verification = config.executive.verification.map((entry) => entry.id);
|
|
316
|
+
if (new Set(verification).size !== verification.length)
|
|
317
|
+
issue('/executive/verification', 'Verification IDs must be unique.');
|
|
318
|
+
return result;
|
|
319
|
+
}
|
|
320
|
+
function configuredModel(config, reference) {
|
|
321
|
+
const separator = reference.indexOf('/');
|
|
322
|
+
if (separator <= 0 || separator === reference.length - 1)
|
|
323
|
+
return undefined;
|
|
324
|
+
return config.providers[reference.slice(0, separator)]?.models[reference.slice(separator + 1)];
|
|
325
|
+
}
|
|
326
|
+
function hasControl(value) {
|
|
327
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
328
|
+
const code = value.charCodeAt(index);
|
|
329
|
+
if (code <= 31 || code === 127)
|
|
330
|
+
return true;
|
|
331
|
+
}
|
|
332
|
+
return false;
|
|
333
|
+
}
|
|
334
|
+
function effective(current, candidate) {
|
|
335
|
+
const hot = { ...candidate };
|
|
336
|
+
delete hot.pairPath;
|
|
337
|
+
delete hot.persistence;
|
|
338
|
+
delete hot.observability;
|
|
339
|
+
delete hot.mcpServers;
|
|
340
|
+
delete hot.lspServers;
|
|
341
|
+
return {
|
|
342
|
+
...hot,
|
|
343
|
+
listen: current.listen,
|
|
344
|
+
tls: current.tls,
|
|
345
|
+
database: current.database,
|
|
346
|
+
executor: current.executor,
|
|
347
|
+
artifacts: current.artifacts,
|
|
348
|
+
...(current.pairPath === undefined ? {} : { pairPath: current.pairPath }),
|
|
349
|
+
...(current.persistence === undefined ? {} : { persistence: current.persistence }),
|
|
350
|
+
...(current.observability === undefined ? {} : { observability: current.observability }),
|
|
351
|
+
...(current.mcpServers === undefined ? {} : { mcpServers: current.mcpServers }),
|
|
352
|
+
...(current.lspServers === undefined ? {} : { lspServers: current.lspServers })
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
/**
|
|
356
|
+
* The settings whose values only take effect after a restart, as documented by 02A
|
|
357
|
+
* `pending_restart`. Returns the changed setting names rather than a flag so a client can say
|
|
358
|
+
* which ones are waiting.
|
|
359
|
+
*/
|
|
360
|
+
const RESTART_SETTINGS = ['listen', 'tls', 'database', 'executor', 'artifacts', 'pairPath', 'persistence', 'observability', 'mcpServers', 'lspServers'];
|
|
361
|
+
function restartPending(current, candidate) {
|
|
362
|
+
return RESTART_SETTINGS.filter((key) => JSON.stringify(current[key]) !== JSON.stringify(candidate[key]));
|
|
363
|
+
}
|
|
364
|
+
/** Settings that changed and are live now, i.e. the changed settings that are not restart-only. */
|
|
365
|
+
function appliedHot(current, candidate) {
|
|
366
|
+
const restartOnly = new Set(RESTART_SETTINGS);
|
|
367
|
+
return [...new Set([...Object.keys(current), ...Object.keys(candidate)])]
|
|
368
|
+
.filter((key) => !restartOnly.has(key))
|
|
369
|
+
.filter((key) => JSON.stringify(current[key]) !== JSON.stringify(candidate[key]))
|
|
370
|
+
.sort();
|
|
371
|
+
}
|