@lensagent/core 0.1.1 → 0.1.2
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/README.md +63 -4
- package/config/lens.example.jsonc +36 -13
- package/dist/adapters/executor-router.d.ts +20 -0
- package/dist/adapters/executor-router.js +86 -0
- package/dist/adapters/executor.d.ts +9 -3
- package/dist/adapters/executor.js +196 -61
- package/dist/adapters/lsp.d.ts +2 -1
- package/dist/adapters/lsp.js +98 -41
- package/dist/adapters/mcp.js +52 -17
- package/dist/adapters/revert.d.ts +5 -4
- package/dist/adapters/revert.js +6 -64
- package/dist/adapters/web.d.ts +9 -0
- package/dist/adapters/web.js +244 -0
- package/dist/config/files.d.ts +14 -0
- package/dist/config/files.js +57 -0
- package/dist/config/runtime.d.ts +3 -4
- package/dist/config/runtime.js +116 -97
- package/dist/config/schema.d.ts +56 -10
- package/dist/config/schema.js +48 -8
- package/dist/contracts/capability.d.ts +27 -1
- package/dist/contracts/domain.d.ts +1 -0
- package/dist/contracts/model.d.ts +33 -6
- package/dist/contracts/review.d.ts +17 -0
- package/dist/contracts/review.js +1 -0
- package/dist/core/access-policy.d.ts +40 -0
- package/dist/core/access-policy.js +68 -0
- package/dist/core/access.d.ts +30 -2
- package/dist/core/access.js +159 -26
- package/dist/core/context-boundaries.d.ts +25 -0
- package/dist/core/context-boundaries.js +106 -0
- package/dist/core/context.d.ts +3 -0
- package/dist/core/context.js +11 -0
- package/dist/core/host.js +261 -173
- package/dist/entry/pair.d.ts +1 -0
- package/dist/entry/pair.js +14 -0
- package/dist/entry/run.js +1 -1
- package/dist/execution/lsp-process.d.ts +3 -2
- package/dist/execution/lsp-process.js +46 -28
- package/dist/http/server.d.ts +5 -0
- package/dist/http/server.js +262 -80
- package/dist/runtime/agent.d.ts +9 -4
- package/dist/runtime/agent.js +361 -286
- package/dist/runtime/artifacts.d.ts +13 -1
- package/dist/runtime/artifacts.js +137 -10
- package/dist/runtime/cache.d.ts +59 -16
- package/dist/runtime/cache.js +124 -43
- package/dist/runtime/calls.d.ts +1 -1
- package/dist/runtime/calls.js +164 -48
- package/dist/runtime/capabilities.d.ts +2 -2
- package/dist/runtime/capabilities.js +4 -5
- package/dist/runtime/cleanup.d.ts +5 -2
- package/dist/runtime/cleanup.js +48 -17
- package/dist/runtime/compaction.d.ts +9 -0
- package/dist/runtime/compaction.js +52 -0
- package/dist/runtime/context.d.ts +8 -4
- package/dist/runtime/context.js +115 -128
- package/dist/runtime/events.d.ts +7 -0
- package/dist/runtime/events.js +83 -0
- package/dist/runtime/images.d.ts +14 -0
- package/dist/runtime/images.js +82 -0
- package/dist/runtime/kernel-capabilities.d.ts +7 -1
- package/dist/runtime/kernel-capabilities.js +146 -68
- package/dist/runtime/model-audit.d.ts +9 -0
- package/dist/runtime/model-audit.js +39 -0
- package/dist/runtime/model.d.ts +109 -4
- package/dist/runtime/model.js +368 -143
- package/dist/runtime/observability.d.ts +1 -0
- package/dist/runtime/observability.js +60 -15
- package/dist/runtime/policy.d.ts +1 -1
- package/dist/runtime/policy.js +2 -2
- package/dist/runtime/profile.d.ts +2 -1
- package/dist/runtime/profile.js +12 -15
- package/dist/runtime/projects.d.ts +3 -1
- package/dist/runtime/projects.js +38 -17
- package/dist/runtime/ptc-worker.d.ts +1 -0
- package/dist/runtime/ptc-worker.js +97 -0
- package/dist/runtime/ptc.d.ts +16 -0
- package/dist/runtime/ptc.js +128 -0
- package/dist/runtime/results.d.ts +5 -4
- package/dist/runtime/results.js +29 -51
- package/dist/runtime/review.d.ts +4 -1
- package/dist/runtime/review.js +101 -22
- package/dist/runtime/scheduler.js +1 -0
- package/dist/runtime/skills.js +34 -5
- package/dist/runtime/task-audit.d.ts +21 -0
- package/dist/runtime/task-audit.js +122 -0
- package/dist/runtime/time.d.ts +9 -0
- package/dist/runtime/time.js +18 -0
- package/dist/runtime/tokenizer.d.ts +2 -0
- package/dist/runtime/tokenizer.js +26 -1
- package/dist/store/database.js +26 -1
- package/dist/store/event-log.d.ts +1 -0
- package/dist/store/event-log.js +22 -5
- package/dist/store/schema.d.ts +1 -1
- package/dist/store/schema.js +50 -1
- package/dist/store/store.d.ts +65 -19
- package/dist/store/store.js +986 -188
- package/package.json +14 -6
- package/proto/executor.proto +69 -55
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { fail } from './domain.js';
|
|
2
|
+
/** Cuts are positions between messages. IDs are scoped to a batch, not the session. */
|
|
3
|
+
export function contextBoundaries(messages, unsettledItemIds = []) {
|
|
4
|
+
const unsettled = new Set(unsettledItemIds);
|
|
5
|
+
const legalCuts = [0];
|
|
6
|
+
const protectedRanges = [];
|
|
7
|
+
const violations = [];
|
|
8
|
+
const pending = new Map();
|
|
9
|
+
let start = -1;
|
|
10
|
+
let executionPending = false;
|
|
11
|
+
for (const [index, message] of messages.entries()) {
|
|
12
|
+
if (unsettled.has(message.contextItemId ?? ''))
|
|
13
|
+
executionPending = true;
|
|
14
|
+
if (message.toolCalls !== undefined) {
|
|
15
|
+
if (message.role !== 'assistant' || message.toolCalls.length === 0 || pending.size > 0) {
|
|
16
|
+
violations.push({ index, reason: 'Invalid or overlapping tool call batch.' });
|
|
17
|
+
}
|
|
18
|
+
if (start < 0)
|
|
19
|
+
start = index;
|
|
20
|
+
for (const call of message.toolCalls) {
|
|
21
|
+
if (pending.has(call.id))
|
|
22
|
+
violations.push({ index, reason: `Duplicate tool call ${call.id}.` });
|
|
23
|
+
pending.set(call.id, call.name);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
else if (message.role === 'tool') {
|
|
27
|
+
const name = pending.get(message.toolCallId ?? '');
|
|
28
|
+
if (name === undefined || name !== message.toolName) {
|
|
29
|
+
violations.push({ index, reason: 'Orphan, duplicate, or mismatched tool result.' });
|
|
30
|
+
}
|
|
31
|
+
else
|
|
32
|
+
pending.delete(message.toolCallId);
|
|
33
|
+
}
|
|
34
|
+
else if (pending.size > 0) {
|
|
35
|
+
violations.push({ index, reason: 'A non-result message interrupts a tool batch.' });
|
|
36
|
+
}
|
|
37
|
+
if (pending.size === 0 && !executionPending) {
|
|
38
|
+
if (start >= 0)
|
|
39
|
+
protectedRanges.push({ start, end: index + 1, complete: true });
|
|
40
|
+
start = -1;
|
|
41
|
+
legalCuts.push(index + 1);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
if (start >= 0 || executionPending)
|
|
45
|
+
protectedRanges.push({ start: start < 0 ? (legalCuts.at(-1) ?? 0) : start, end: messages.length, complete: false });
|
|
46
|
+
return { legalCuts, protectedRanges, violations };
|
|
47
|
+
}
|
|
48
|
+
export function assertContextBoundaries(messages, options = {}) {
|
|
49
|
+
const boundaries = contextBoundaries(messages, options.unsettledItemIds);
|
|
50
|
+
if (boundaries.violations.length > 0 || (options.allowOpenTail !== true && boundaries.legalCuts.at(-1) !== messages.length)) {
|
|
51
|
+
fail('CONTEXT_BOUNDARY_INVALID', 409, 'Context contains an invalid or unfinished protocol group.', {
|
|
52
|
+
legal_cuts: boundaries.legalCuts,
|
|
53
|
+
violations: boundaries.violations,
|
|
54
|
+
protected_ranges: boundaries.protectedRanges
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
return boundaries;
|
|
58
|
+
}
|
|
59
|
+
/** Preserve source membership, even if two different batches reuse the same wire IDs. */
|
|
60
|
+
export function assertContextTransition(before, after, unsettledItemIds) {
|
|
61
|
+
const boundaries = assertContextBoundaries(before, { allowOpenTail: true, unsettledItemIds });
|
|
62
|
+
const positions = new Map(after.map((message, index) => [message.contextItemId, index]));
|
|
63
|
+
for (const range of boundaries.protectedRanges) {
|
|
64
|
+
const members = before.slice(range.start, range.end).map((message) => message.contextItemId);
|
|
65
|
+
const retained = members.map((id) => positions.get(id));
|
|
66
|
+
if (range.complete && retained.every((position) => position === undefined))
|
|
67
|
+
continue;
|
|
68
|
+
const start = retained[0];
|
|
69
|
+
if (start === undefined || retained.some((position, index) => position !== start + index)) {
|
|
70
|
+
fail('CONTEXT_BOUNDARY_INVALID', 409, 'Context operation must preserve or remove the whole protocol group in source order.', {
|
|
71
|
+
member_ids: members.map((id) => id ?? ''), complete: range.complete
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
export function contextToolCalls(content) {
|
|
77
|
+
let value;
|
|
78
|
+
try {
|
|
79
|
+
value = JSON.parse(content);
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
fail('CONTEXT_BOUNDARY_INVALID', 409, 'Invalid tool batch JSON.');
|
|
83
|
+
}
|
|
84
|
+
if (!Array.isArray(value) || value.length === 0)
|
|
85
|
+
fail('CONTEXT_BOUNDARY_INVALID', 409, 'A tool batch must contain calls.');
|
|
86
|
+
return value.map((entry) => {
|
|
87
|
+
if (typeof entry !== 'object' || entry === null || !('id' in entry) || typeof entry.id !== 'string' || entry.id.length === 0 || !('name' in entry) || typeof entry.name !== 'string' || entry.name.length === 0 || !('arguments' in entry)) {
|
|
88
|
+
fail('CONTEXT_BOUNDARY_INVALID', 409, 'Invalid tool call identity or arguments.');
|
|
89
|
+
}
|
|
90
|
+
return { id: entry.id, name: entry.name, arguments: entry.arguments };
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
/** Included results without native tool identity are evidence, not protocol responses. */
|
|
94
|
+
export function boundaryMessages(items) {
|
|
95
|
+
return items.map((item) => {
|
|
96
|
+
if (typeof item.id !== 'string' || typeof item.content !== 'string')
|
|
97
|
+
fail('CONTEXT_BOUNDARY_INVALID', 409, 'Invalid Context item identity or content.');
|
|
98
|
+
const base = { contextItemId: item.id, content: item.content };
|
|
99
|
+
if (item.kind === 'tool_calls')
|
|
100
|
+
return { ...base, role: 'assistant', toolCalls: contextToolCalls(base.content) };
|
|
101
|
+
if (item.kind === 'result' && typeof item.tool_call_id === 'string' && typeof item.tool_name === 'string') {
|
|
102
|
+
return { ...base, role: 'tool', toolCallId: item.tool_call_id, toolName: item.tool_name };
|
|
103
|
+
}
|
|
104
|
+
return { ...base, role: item.role === 'assistant' || item.role === 'system' ? item.role : 'user' };
|
|
105
|
+
});
|
|
106
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/** Full history remains stored; only the latest checkpoint and live tail are active. */
|
|
2
|
+
export function activeContextItems(items) {
|
|
3
|
+
const visible = items.filter((item) => item.visible === true);
|
|
4
|
+
const checkpoint = visible.findLast((item) => item.kind === 'compaction' && item.locked !== true);
|
|
5
|
+
const prefixes = visible.filter((item) => item.role === 'system' && item.kind === 'message');
|
|
6
|
+
return [
|
|
7
|
+
...prefixes,
|
|
8
|
+
...(checkpoint === undefined ? [] : [checkpoint]),
|
|
9
|
+
...visible.filter((item) => item.locked !== true && item.kind !== 'compaction' && !prefixes.includes(item))
|
|
10
|
+
];
|
|
11
|
+
}
|
package/dist/core/host.js
CHANGED
|
@@ -10,15 +10,18 @@ import { EventBus } from '../runtime/events.js';
|
|
|
10
10
|
import { Scheduler } from '../runtime/scheduler.js';
|
|
11
11
|
import { ArtifactStore } from '../runtime/artifacts.js';
|
|
12
12
|
import { AiModelGateway } from '../runtime/model.js';
|
|
13
|
+
import { ModelAuditLog } from '../runtime/model-audit.js';
|
|
14
|
+
import { TaskAuditReader } from '../runtime/task-audit.js';
|
|
13
15
|
import { AutoReviewer } from '../runtime/review.js';
|
|
14
16
|
import { ProjectRuntimes } from '../runtime/projects.js';
|
|
15
17
|
import { Observability } from '../runtime/observability.js';
|
|
16
18
|
import { adapterTarget, assertManifestSet, coreManifests } from '../runtime/manifests.js';
|
|
17
|
-
import { ExecutorClient
|
|
19
|
+
import { ExecutorClient } from '../adapters/executor.js';
|
|
20
|
+
import { ExecutorRouter } from '../adapters/executor-router.js';
|
|
18
21
|
import { mcpCapabilities } from '../adapters/mcp.js';
|
|
19
22
|
import { LspPool, lspCapabilities } from '../adapters/lsp.js';
|
|
20
23
|
import { revertCapabilities } from '../adapters/revert.js';
|
|
21
|
-
import { applyPurge } from '../runtime/cleanup.js';
|
|
24
|
+
import { applyPurge, commitPurge } from '../runtime/cleanup.js';
|
|
22
25
|
import { createServer } from '../http/server.js';
|
|
23
26
|
export async function launchCore(options = {}) {
|
|
24
27
|
const root = resolve(options.installRoot ?? process.cwd());
|
|
@@ -26,189 +29,273 @@ export async function launchCore(options = {}) {
|
|
|
26
29
|
await config.load();
|
|
27
30
|
const target = adapterTarget(options.runtime ?? 'node');
|
|
28
31
|
assertManifestSet(coreManifests(config.current.config), target);
|
|
29
|
-
const
|
|
30
|
-
const observability = new Observability(root, config.current.config.observability);
|
|
31
|
-
const events = new EventBus(id('instance'), config.current.config.sse, () => config.current.metadataRevision + pair.metadataRevision);
|
|
32
|
-
const pair = new PairStore(root, config.current.config.pairPath ?? 'config/pair.json', (clientId) => events.revoke(clientId));
|
|
32
|
+
const rollback = [];
|
|
33
33
|
try {
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
const eventLog = eventLogConfig?.enabled === true ? new EventLog(root, eventLogConfig.path, eventLogConfig.fsync) : undefined;
|
|
42
|
-
await eventLog?.load();
|
|
43
|
-
const store = new Store(openDatabase(root, config.current.config.database.path), events, eventLog);
|
|
44
|
-
const interrupted = store.recover();
|
|
45
|
-
if (interrupted.length > 0)
|
|
46
|
-
observability.record('store', 'recovery', 'success', 0, { interrupted: interrupted.length });
|
|
47
|
-
// Not `root`. The app root is where config/lens.jsonc holds the Provider API keys and where
|
|
48
|
-
// config/pair.json holds the client allowlist, so making it the default Workspace put both inside
|
|
49
|
-
// the one directory an Agent's file Capabilities are allowed to reach: `read_file` would reach the
|
|
50
|
-
// credentials, and `write_file` on the pair file would let an Agent mark its own client verified.
|
|
51
|
-
// A dedicated subdirectory keeps a working default without making the containment boundary and the
|
|
52
|
-
// secret store the same path.
|
|
53
|
-
const defaultWorkspace = resolve(root, 'workspace');
|
|
54
|
-
await mkdir(defaultWorkspace, { recursive: true });
|
|
55
|
-
const defaultProject = store.defaultProject() ?? store.createProject('Project', [defaultWorkspace]);
|
|
56
|
-
const defaultProjectId = string(defaultProject.id);
|
|
57
|
-
const executor = await ExecutorClient.create(root, config);
|
|
58
|
-
try {
|
|
59
|
-
await executor.health();
|
|
60
|
-
}
|
|
61
|
-
catch (error) {
|
|
62
|
-
blockers.add('executor');
|
|
63
|
-
observability.record('executor', 'health', 'failed', 0, { error_code: errorCode(error) });
|
|
64
|
-
}
|
|
65
|
-
const scheduler = new Scheduler(config.current.config.concurrency);
|
|
66
|
-
const artifacts = new ArtifactStore(root, config.current.config.artifacts.directory, store, config.current.config.persistence?.contentStore?.mode ?? 'filesystem');
|
|
67
|
-
const models = new AiModelGateway(config, target);
|
|
68
|
-
const reviewer = new AutoReviewer(models, config);
|
|
69
|
-
const lsp = new LspPool(config.current.config.lspServers ?? {});
|
|
70
|
-
const sharedCapabilities = [
|
|
71
|
-
...executorCapabilities(executor),
|
|
72
|
-
...lspCapabilities(config.current.config.lspServers ?? {}, lsp),
|
|
73
|
-
...revertCapabilities(store, executor)
|
|
74
|
-
];
|
|
75
|
-
// An MCP server that cannot be reached loses its own tools and nothing else. It used to add a
|
|
76
|
-
// startup blocker, which put the whole runtime into health_only: one unreachable optional
|
|
77
|
-
// integration returned 503 from readiness and no client could sync. 02A also fixes the readiness
|
|
78
|
-
// blocker codes at PAIR_FILE_INVALID, MIGRATION_FAILED, RECOVERY_INCOMPLETE and STORE_UNAVAILABLE,
|
|
79
|
-
// so `mcp:<id>` was never a documented reason to be unready.
|
|
80
|
-
const degradedMcpServers = [];
|
|
81
|
-
for (const [serverId, serverConfig] of Object.entries(config.current.config.mcpServers ?? {})) {
|
|
34
|
+
const blockers = new Set();
|
|
35
|
+
const observability = new Observability(root, config.current.config.observability);
|
|
36
|
+
rollback.push(() => observability.close());
|
|
37
|
+
const events = new EventBus(id('instance'), config.current.config.sse, () => config.current.metadataRevision + pair.metadataRevision);
|
|
38
|
+
rollback.push(() => events.close());
|
|
39
|
+
const pair = new PairStore(root, config.current.config.pairPath ?? 'config/pair.json', (clientId) => events.revoke(clientId));
|
|
40
|
+
rollback.push(() => pair.close(), () => config.close());
|
|
82
41
|
try {
|
|
83
|
-
|
|
42
|
+
await pair.load();
|
|
84
43
|
}
|
|
85
44
|
catch (error) {
|
|
86
|
-
|
|
87
|
-
observability.record('
|
|
45
|
+
blockers.add('pair');
|
|
46
|
+
observability.record('access', 'pair_load', 'failed', 0, { error_code: errorCode(error) });
|
|
88
47
|
}
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
config
|
|
102
|
-
pair,
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
48
|
+
const eventLogConfig = config.current.config.persistence?.eventLog;
|
|
49
|
+
const eventLog = eventLogConfig?.enabled === true ? new EventLog(root, eventLogConfig.path, eventLogConfig.fsync) : undefined;
|
|
50
|
+
if (eventLog !== undefined)
|
|
51
|
+
rollback.push(() => eventLog.close());
|
|
52
|
+
await eventLog?.load();
|
|
53
|
+
const database = openDatabase(root, config.current.config.database.path);
|
|
54
|
+
rollback.push(() => { if (database.open)
|
|
55
|
+
database.close(); });
|
|
56
|
+
const store = new Store(database, events, eventLog, observability);
|
|
57
|
+
const interrupted = store.recover();
|
|
58
|
+
if (interrupted.length > 0)
|
|
59
|
+
observability.record('store', 'recovery', 'success', 0, { interrupted: interrupted.length });
|
|
60
|
+
// Not `root`. The app root is where config/lens.jsonc holds the Provider API keys and where
|
|
61
|
+
// config/pair.json holds the client allowlist, so making it the default Workspace put both inside
|
|
62
|
+
// the one directory an Agent's file Capabilities are allowed to reach: `read_file` would reach the
|
|
63
|
+
// credentials, and `edit_file` on the pair file would let an Agent mark its own client verified.
|
|
64
|
+
// A dedicated subdirectory keeps a working default without making the containment boundary and the
|
|
65
|
+
// secret store the same path.
|
|
66
|
+
const defaultWorkspace = resolve(root, 'workspace');
|
|
67
|
+
await mkdir(defaultWorkspace, { recursive: true });
|
|
68
|
+
const defaultProject = store.defaultProject() ?? store.createProject('Project', [defaultWorkspace]);
|
|
69
|
+
const defaultProjectId = string(defaultProject.id);
|
|
70
|
+
const executor = await ExecutorClient.create(root, config);
|
|
71
|
+
rollback.push(() => executor.close());
|
|
72
|
+
const localConfig = config.current.config.localExecutor;
|
|
73
|
+
if (localConfig && !/^(localhost|127\.0\.0\.1|\[::1\]):\d+$/u.test(localConfig.address))
|
|
74
|
+
throw new Error('localExecutor must use a loopback address.');
|
|
75
|
+
const localExecutor = localConfig ? await ExecutorClient.create(root, config, { ...localConfig, sharedFilesystem: true }) : undefined;
|
|
76
|
+
if (localExecutor)
|
|
77
|
+
rollback.push(() => localExecutor.close());
|
|
78
|
+
const additionalExecutors = new Map();
|
|
79
|
+
for (const [id, endpoint] of Object.entries(config.current.config.executors ?? {})) {
|
|
80
|
+
const client = await ExecutorClient.create(root, config, endpoint);
|
|
81
|
+
rollback.push(() => client.close());
|
|
82
|
+
additionalExecutors.set(id, client);
|
|
83
|
+
}
|
|
84
|
+
const executorRouter = new ExecutorRouter(store, executor, localExecutor, additionalExecutors);
|
|
85
|
+
try {
|
|
86
|
+
await executor.health();
|
|
87
|
+
await localExecutor?.health();
|
|
88
|
+
}
|
|
89
|
+
catch (error) {
|
|
90
|
+
blockers.add('executor');
|
|
91
|
+
observability.record('executor', 'health', 'failed', 0, { error_code: errorCode(error) });
|
|
92
|
+
}
|
|
93
|
+
const scheduler = new Scheduler(config.current.config.concurrency);
|
|
94
|
+
rollback.push(() => scheduler.close());
|
|
95
|
+
const artifacts = new ArtifactStore(root, config.current.config.artifacts.directory, store, config.current.config.persistence?.contentStore?.mode ?? 'filesystem');
|
|
96
|
+
const modelAudit = new ModelAuditLog(resolve(root, 'audit', `${id('model-audit')}.jsonl`));
|
|
97
|
+
const models = new AiModelGateway(config, target, artifacts, modelAudit);
|
|
98
|
+
const reviewer = new AutoReviewer(models, config);
|
|
99
|
+
if (config.current.config.executor.sharedFilesystem === true) {
|
|
100
|
+
let cursor;
|
|
101
|
+
do {
|
|
102
|
+
const page = store.listProjects({ ...(cursor === undefined ? {} : { cursor }), limit: 500 }, 'all');
|
|
103
|
+
for (const project of page.items) {
|
|
104
|
+
try {
|
|
105
|
+
await executor.verifySharedPaths(store.listWorkspaces(string(project.id)).filter(workspace => workspace.executor_id === 'remote').flatMap(workspace => Array.isArray(workspace.paths) ? workspace.paths.filter((path) => typeof path === 'string') : []));
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
observability.record('executor', 'shared_workspace', 'failed', 0, { error_code: 'SHARED_WORKSPACE_REQUIRED' });
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
cursor = page.nextCursor ?? undefined;
|
|
112
|
+
} while (cursor !== undefined);
|
|
113
|
+
}
|
|
114
|
+
const lsp = new LspPool(config.current.config.lspServers ?? {}, async (paths) => {
|
|
115
|
+
if (config.current.config.executor.sharedFilesystem !== true)
|
|
116
|
+
throw new Error('Local LSP requires executor.sharedFilesystem and a verified shared Workspace.');
|
|
117
|
+
await executor.verifySharedPaths(paths);
|
|
120
118
|
});
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
119
|
+
rollback.push(() => lsp.close());
|
|
120
|
+
const sharedCapabilities = [
|
|
121
|
+
...executorRouter.capabilities(),
|
|
122
|
+
...lspCapabilities(config.current.config.lspServers ?? {}, lsp),
|
|
123
|
+
...revertCapabilities(store, executorRouter)
|
|
124
|
+
];
|
|
125
|
+
// An MCP server that cannot be reached loses its own tools and nothing else. It used to add a
|
|
126
|
+
// startup blocker, which put the whole runtime into health_only: one unreachable optional
|
|
127
|
+
// integration returned 503 from readiness and no client could sync. 02A also fixes the readiness
|
|
128
|
+
// blocker codes at PAIR_FILE_INVALID, MIGRATION_FAILED, RECOVERY_INCOMPLETE and STORE_UNAVAILABLE,
|
|
129
|
+
// so `mcp:<id>` was never a documented reason to be unready.
|
|
130
|
+
const degradedMcpServers = [];
|
|
131
|
+
for (const [serverId, serverConfig] of Object.entries(config.current.config.mcpServers ?? {})) {
|
|
132
|
+
try {
|
|
133
|
+
sharedCapabilities.push(...await mcpCapabilities(serverId, serverConfig));
|
|
134
|
+
}
|
|
135
|
+
catch (error) {
|
|
136
|
+
degradedMcpServers.push(serverId);
|
|
137
|
+
observability.record('mcp', 'discover', 'failed', 0, { server_id: serverId, error_code: errorCode(error) });
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
if (localExecutor) {
|
|
141
|
+
let cursor;
|
|
142
|
+
do {
|
|
143
|
+
const page = store.listProjects({ ...(cursor === undefined ? {} : { cursor }), limit: 500 }, 'all');
|
|
144
|
+
for (const project of page.items) {
|
|
145
|
+
const paths = store.listWorkspaces(string(project.id)).filter(workspace => workspace.executor_id === 'local').flatMap(workspace => Array.isArray(workspace.paths) ? workspace.paths.filter((path) => typeof path === 'string') : []);
|
|
146
|
+
try {
|
|
147
|
+
await localExecutor.verifySharedPaths(paths);
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
observability.record('executor', 'local_shared_workspace', 'failed', 0, { error_code: 'SHARED_WORKSPACE_REQUIRED' });
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
cursor = page.nextCursor ?? undefined;
|
|
154
|
+
} while (cursor !== undefined);
|
|
155
|
+
}
|
|
156
|
+
for (const [executorId, client] of additionalExecutors) {
|
|
157
|
+
if (client.endpoint.sharedFilesystem !== true)
|
|
158
|
+
continue;
|
|
159
|
+
let cursor;
|
|
160
|
+
do {
|
|
161
|
+
const page = store.listProjects({ ...(cursor === undefined ? {} : { cursor }), limit: 500 }, 'all');
|
|
162
|
+
for (const project of page.items) {
|
|
163
|
+
const paths = store.listWorkspaces(string(project.id)).filter(workspace => workspace.executor_id === executorId).flatMap(workspace => Array.isArray(workspace.paths) ? workspace.paths.filter((path) => typeof path === 'string') : []);
|
|
164
|
+
try {
|
|
165
|
+
await client.verifySharedPaths(paths);
|
|
166
|
+
}
|
|
167
|
+
catch {
|
|
168
|
+
observability.record('executor', 'shared_workspace', 'failed', 0, { error_code: 'SHARED_WORKSPACE_REQUIRED', executor_id: executorId });
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
cursor = page.nextCursor ?? undefined;
|
|
172
|
+
} while (cursor !== undefined);
|
|
173
|
+
}
|
|
174
|
+
const runtimes = new ProjectRuntimes(store, config, scheduler, artifacts, models, reviewer, sharedCapabilities, target, events, defaultProjectId, (path, executorId) => executorRouter.endpoint(executorId ?? 'remote').sharedWorkspace(path));
|
|
175
|
+
rollback.push(() => runtimes.close());
|
|
176
|
+
runtimes.get(defaultProjectId);
|
|
177
|
+
runtimes.startIdleEviction();
|
|
178
|
+
// Graceful stop arrives as a request on the Server's control path, not as an OS signal, so the
|
|
179
|
+
// runtime needs a way to hand that request outwards. The Promise is created before the Server so
|
|
180
|
+
// the Route can never fire into an unset resolver.
|
|
181
|
+
let signalShutdown = () => undefined;
|
|
182
|
+
const shutdownRequested = new Promise((resolve) => { signalShutdown = resolve; });
|
|
183
|
+
const https = await httpsOptions(root, config);
|
|
184
|
+
const server = createServer({
|
|
185
|
+
...(https === undefined ? {} : { https }),
|
|
186
|
+
config,
|
|
187
|
+
pair,
|
|
188
|
+
store,
|
|
189
|
+
events,
|
|
190
|
+
runtime: (projectId) => runtimes.get(projectId),
|
|
191
|
+
refreshProject: (projectId) => runtimes.refresh(projectId),
|
|
192
|
+
models,
|
|
193
|
+
taskAudit: new TaskAuditReader(resolve(root, 'audit')),
|
|
194
|
+
artifacts,
|
|
195
|
+
observability,
|
|
196
|
+
executor,
|
|
197
|
+
executorRouter,
|
|
198
|
+
...(localExecutor ? { localExecutor } : {}),
|
|
199
|
+
defaultProjectId,
|
|
200
|
+
ready: () => {
|
|
201
|
+
const current = [...blockers, ...(eventLog?.failed === true ? ['event_log'] : [])];
|
|
202
|
+
return { mode: current.length === 0 ? 'full' : 'health_only', blockers: current };
|
|
203
|
+
},
|
|
204
|
+
requestShutdown: () => { signalShutdown(); }
|
|
134
205
|
});
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
206
|
+
rollback.push(() => server.close());
|
|
207
|
+
await server.listen({ ...config.current.config.listen });
|
|
208
|
+
for (const serverId of degradedMcpServers) {
|
|
209
|
+
events.emit(defaultProjectId, store.stateRevision(), 'runtime.notice', {
|
|
210
|
+
code: 'mcp_server_unavailable',
|
|
211
|
+
server_id: serverId
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
pair.start((error) => {
|
|
215
|
+
blockers.add('pair');
|
|
216
|
+
observability.record('access', 'pair_reload', 'failed', 0, { error_code: errorCode(error) });
|
|
217
|
+
events.emit(defaultProjectId, store.stateRevision(), 'config.changed', { component: 'pair', error: error.message });
|
|
218
|
+
}, () => { blockers.delete('pair'); });
|
|
219
|
+
config.start((snapshot) => {
|
|
220
|
+
observability.record('config', 'reload', snapshot.error === null ? 'success' : 'failed', 0, { error_code: snapshot.error === null ? null : 'CONFIG_INVALID' });
|
|
221
|
+
scheduler.updateLimits(snapshot.config.concurrency);
|
|
222
|
+
events.updateLimits(snapshot.config.sse);
|
|
223
|
+
events.emit(defaultProjectId, store.stateRevision(), 'config.changed', {
|
|
224
|
+
config_digest: snapshot.digest,
|
|
225
|
+
file_digest: snapshot.fileDigest,
|
|
226
|
+
pending_restart: snapshot.pendingRestart,
|
|
227
|
+
error: snapshot.error
|
|
228
|
+
});
|
|
139
229
|
});
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
230
|
+
const maintenanceTimer = setInterval(() => {
|
|
231
|
+
void runRetention(store, artifacts, executorRouter, config).catch((error) => {
|
|
232
|
+
observability.record('maintenance', 'retention', 'failed', 0, { error_code: errorCode(error) });
|
|
233
|
+
});
|
|
234
|
+
}, 60 * 60 * 1000);
|
|
235
|
+
maintenanceTimer.unref();
|
|
236
|
+
rollback.push(() => clearInterval(maintenanceTimer));
|
|
237
|
+
let closing;
|
|
238
|
+
return {
|
|
239
|
+
url: `${config.current.config.tls.enabled ? 'https' : 'http'}://${config.current.config.listen.host}:${config.current.config.listen.port}`,
|
|
240
|
+
shutdownRequested,
|
|
241
|
+
close: () => {
|
|
242
|
+
if (closing !== undefined)
|
|
243
|
+
return closing;
|
|
244
|
+
const serverClosing = server.close();
|
|
245
|
+
void serverClosing.catch(() => undefined);
|
|
246
|
+
closing = closeResources([
|
|
247
|
+
() => { clearInterval(maintenanceTimer); config.close(); pair.close(); },
|
|
248
|
+
() => runtimes.close(),
|
|
249
|
+
() => scheduler.close(),
|
|
250
|
+
() => events.close(),
|
|
251
|
+
() => lsp.close(),
|
|
252
|
+
() => executor.close(),
|
|
253
|
+
() => localExecutor?.close(),
|
|
254
|
+
...[...additionalExecutors.values()].map(client => () => client.close()),
|
|
255
|
+
() => serverClosing,
|
|
256
|
+
() => store.close(),
|
|
257
|
+
() => observability.close()
|
|
258
|
+
], config.current.config.shutdown?.graceMs ?? 30_000).catch((error) => {
|
|
259
|
+
server.server.closeAllConnections();
|
|
260
|
+
throw error;
|
|
261
|
+
});
|
|
148
262
|
return closing;
|
|
149
|
-
closing = (async () => {
|
|
150
|
-
const graceMs = config.current.config.shutdown?.graceMs ?? 30_000;
|
|
151
|
-
const ordered = (async () => {
|
|
152
|
-
const serverClosing = server.close();
|
|
153
|
-
clearInterval(maintenanceTimer);
|
|
154
|
-
config.close();
|
|
155
|
-
pair.close();
|
|
156
|
-
await runtimes.close();
|
|
157
|
-
scheduler.close();
|
|
158
|
-
events.close();
|
|
159
|
-
await lsp.close();
|
|
160
|
-
executor.close();
|
|
161
|
-
await serverClosing;
|
|
162
|
-
await observability.close();
|
|
163
|
-
await store.close();
|
|
164
|
-
})();
|
|
165
|
-
// Bounded, because an unbounded wait here is worse than an imperfect stop. A stage that
|
|
166
|
-
// never settles -- a Worker ignoring cancellation, an LSP child not exiting -- would strand
|
|
167
|
-
// the process with no way out but a hard kill, and a hard kill discards this whole ordered
|
|
168
|
-
// sequence. Section 10 already leaves unconfirmable objects to Recovery and SQLite/WAL is
|
|
169
|
-
// crash-safe, so returning after the grace period loses no committed state.
|
|
170
|
-
await withinGrace(ordered, graceMs, observability);
|
|
171
|
-
})();
|
|
172
|
-
return closing;
|
|
173
|
-
}
|
|
174
|
-
};
|
|
175
|
-
}
|
|
176
|
-
/**
|
|
177
|
-
* Waits for the ordered shutdown, but not forever.
|
|
178
|
-
*
|
|
179
|
-
* The diagnostic is recorded the moment the grace period expires rather than afterwards, because
|
|
180
|
-
* `observability.close()` is itself one of the last stages: recording later could be writing to a
|
|
181
|
-
* sink that the sequence has already closed.
|
|
182
|
-
*/
|
|
183
|
-
async function withinGrace(ordered, graceMs, observability) {
|
|
184
|
-
let timer;
|
|
185
|
-
const expired = new Promise((resolve) => {
|
|
186
|
-
timer = setTimeout(() => {
|
|
187
|
-
try {
|
|
188
|
-
observability.record('runtime', 'shutdown', 'failed', graceMs, { reason: 'grace_expired', grace_ms: graceMs });
|
|
189
|
-
}
|
|
190
|
-
catch {
|
|
191
|
-
// A sink that is already gone must not turn a slow stop into a crash.
|
|
192
263
|
}
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
timer.unref();
|
|
197
|
-
});
|
|
198
|
-
// Settled either way, so a stage that rejects after the grace period cannot surface as an
|
|
199
|
-
// unhandled rejection once nothing is awaiting it any more.
|
|
200
|
-
const settled = ordered.then(() => 'closed', (error) => {
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
catch (error) {
|
|
201
267
|
try {
|
|
202
|
-
|
|
268
|
+
await closeResources(rollback.reverse(), config.current.config.shutdown?.graceMs ?? 30_000);
|
|
203
269
|
}
|
|
204
|
-
catch {
|
|
205
|
-
|
|
270
|
+
catch (cleanupError) {
|
|
271
|
+
throw new AggregateError([error, cleanupError], 'Core startup failed and cleanup was incomplete.', { cause: error });
|
|
206
272
|
}
|
|
207
|
-
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
273
|
+
throw error;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
async function closeResources(actions, graceMs) {
|
|
277
|
+
const deadline = Date.now() + graceMs;
|
|
278
|
+
const failures = [];
|
|
279
|
+
for (const action of actions) {
|
|
280
|
+
let timer;
|
|
281
|
+
try {
|
|
282
|
+
await Promise.race([
|
|
283
|
+
Promise.resolve().then(action),
|
|
284
|
+
new Promise((_resolve, reject) => {
|
|
285
|
+
timer = setTimeout(() => reject(new Error('Core shutdown grace period expired.')), Math.max(0, deadline - Date.now()));
|
|
286
|
+
})
|
|
287
|
+
]);
|
|
288
|
+
}
|
|
289
|
+
catch (error) {
|
|
290
|
+
failures.push(error);
|
|
291
|
+
}
|
|
292
|
+
finally {
|
|
293
|
+
if (timer !== undefined)
|
|
294
|
+
clearTimeout(timer);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
if (failures.length > 0)
|
|
298
|
+
throw new AggregateError(failures, 'Core cleanup failed or timed out.');
|
|
212
299
|
}
|
|
213
300
|
async function runRetention(store, artifacts, executor, config) {
|
|
214
301
|
const retention = config.current.config.historyRetention;
|
|
@@ -226,7 +313,8 @@ async function runRetention(store, artifacts, executor, config) {
|
|
|
226
313
|
cursor = page.nextCursor ?? undefined;
|
|
227
314
|
} while (cursor !== undefined);
|
|
228
315
|
for (const projectId of projectIds) {
|
|
229
|
-
await
|
|
316
|
+
await commitPurge(executor, stateId => store.purgeArchived(projectId, before, stateId));
|
|
317
|
+
await applyPurge(projectId, store, artifacts, executor);
|
|
230
318
|
}
|
|
231
319
|
}
|
|
232
320
|
async function httpsOptions(root, config) {
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { parseArgs } from 'node:util';
|
|
2
|
+
import { ConfigRuntime } from '../config/runtime.js';
|
|
3
|
+
import { PairStore } from '../core/access.js';
|
|
4
|
+
const args = process.argv.slice(2);
|
|
5
|
+
if (args[0] === '--')
|
|
6
|
+
args.shift();
|
|
7
|
+
const { values } = parseArgs({ args, options: { client: { type: 'string' }, config: { type: 'string' }, 'strong-trust': { type: 'boolean', default: false } } });
|
|
8
|
+
if (!values.client)
|
|
9
|
+
throw new Error('Supply --client with the identity shown by the client after its first connection.');
|
|
10
|
+
const config = new ConfigRuntime(process.cwd(), values.config);
|
|
11
|
+
await config.load();
|
|
12
|
+
const pair = new PairStore(process.cwd(), config.current.config.pairPath ?? 'config/pair.json', () => { });
|
|
13
|
+
await pair.load();
|
|
14
|
+
process.stdout.write(`${await pair.issueCredential(values.client, values['strong-trust'])}\n`);
|
package/dist/entry/run.js
CHANGED
|
@@ -11,7 +11,7 @@ function listenForShutdown(server) {
|
|
|
11
11
|
if (closing)
|
|
12
12
|
return;
|
|
13
13
|
closing = true;
|
|
14
|
-
void server.close().
|
|
14
|
+
void server.close().then(() => process.exit(0), () => process.exit(1));
|
|
15
15
|
};
|
|
16
16
|
// The graceful stop a process manager is expected to use: a request on the Server's control
|
|
17
17
|
// path, not a signal. The signal handlers stay for an interactive terminal and for anything that
|