@harperfast/harper 5.2.0-alpha.5 → 5.2.0-alpha.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/agent/agent.ts +152 -10
- package/agent/bestPractices.ts +58 -0
- package/agent/mcpTools.ts +122 -0
- package/agent/operations.ts +10 -1
- package/agent/registryTools.ts +117 -0
- package/agent/session.ts +16 -7
- package/agent/tools/fsTools.ts +74 -27
- package/agent/tools/inspectorTool.ts +459 -0
- package/agent/toolset.ts +27 -7
- package/agent/types.ts +2 -0
- package/components/Application.ts +566 -85
- package/components/OptionsWatcher.ts +62 -2
- package/components/Scope.ts +31 -8
- package/components/componentLoader.ts +5 -1
- package/components/componentSecrets.ts +514 -55
- package/components/deploymentRecorder.ts +7 -6
- package/components/gitCredentialHelper.js +115 -0
- package/components/gitCredentialServer.ts +251 -0
- package/components/mcp/tools/application.ts +197 -23
- package/components/mcp/tools/operations.ts +1 -1
- package/components/mcp/tools/schemas/derive.ts +6 -2
- package/components/operations.js +21 -19
- package/components/operationsValidation.js +86 -21
- package/components/secretOperations.ts +110 -30
- package/config/harperConfigEnvVars.ts +90 -5
- package/config-root.schema.json +4 -0
- package/dist/agent/agent.d.ts +24 -3
- package/dist/agent/agent.js +172 -10
- package/dist/agent/agent.js.map +1 -1
- package/dist/agent/bestPractices.d.ts +24 -0
- package/dist/agent/bestPractices.js +60 -0
- package/dist/agent/bestPractices.js.map +1 -0
- package/dist/agent/mcpTools.d.ts +24 -0
- package/dist/agent/mcpTools.js +115 -0
- package/dist/agent/mcpTools.js.map +1 -0
- package/dist/agent/operations.js +10 -1
- package/dist/agent/operations.js.map +1 -1
- package/dist/agent/registryTools.d.ts +45 -0
- package/dist/agent/registryTools.js +113 -0
- package/dist/agent/registryTools.js.map +1 -0
- package/dist/agent/session.js +16 -7
- package/dist/agent/session.js.map +1 -1
- package/dist/agent/tools/fsTools.js +70 -28
- package/dist/agent/tools/fsTools.js.map +1 -1
- package/dist/agent/tools/inspectorTool.d.ts +40 -0
- package/dist/agent/tools/inspectorTool.js +428 -0
- package/dist/agent/tools/inspectorTool.js.map +1 -0
- package/dist/agent/toolset.d.ts +16 -6
- package/dist/agent/toolset.js +17 -7
- package/dist/agent/toolset.js.map +1 -1
- package/dist/agent/types.d.ts +2 -0
- package/dist/components/Application.d.ts +73 -18
- package/dist/components/Application.js +475 -64
- package/dist/components/Application.js.map +1 -1
- package/dist/components/OptionsWatcher.d.ts +1 -1
- package/dist/components/OptionsWatcher.js +65 -2
- package/dist/components/OptionsWatcher.js.map +1 -1
- package/dist/components/Scope.d.ts +8 -6
- package/dist/components/Scope.js +22 -6
- package/dist/components/Scope.js.map +1 -1
- package/dist/components/componentLoader.js +5 -1
- package/dist/components/componentLoader.js.map +1 -1
- package/dist/components/componentSecrets.d.ts +33 -7
- package/dist/components/componentSecrets.js +473 -59
- package/dist/components/componentSecrets.js.map +1 -1
- package/dist/components/deploymentRecorder.d.ts +2 -2
- package/dist/components/deploymentRecorder.js +1 -1
- package/dist/components/deploymentRecorder.js.map +1 -1
- package/dist/components/gitCredentialHelper.d.ts +1 -0
- package/dist/components/gitCredentialHelper.js +113 -0
- package/dist/components/gitCredentialHelper.js.map +1 -0
- package/dist/components/gitCredentialServer.d.ts +33 -0
- package/dist/components/gitCredentialServer.js +236 -0
- package/dist/components/gitCredentialServer.js.map +1 -0
- package/dist/components/mcp/tools/application.d.ts +19 -0
- package/dist/components/mcp/tools/application.js +180 -22
- package/dist/components/mcp/tools/application.js.map +1 -1
- package/dist/components/mcp/tools/operations.d.ts +13 -0
- package/dist/components/mcp/tools/operations.js +1 -0
- package/dist/components/mcp/tools/operations.js.map +1 -1
- package/dist/components/mcp/tools/schemas/derive.js +6 -2
- package/dist/components/mcp/tools/schemas/derive.js.map +1 -1
- package/dist/components/operations.js +23 -21
- package/dist/components/operations.js.map +1 -1
- package/dist/components/operationsValidation.js +84 -21
- package/dist/components/operationsValidation.js.map +1 -1
- package/dist/components/secretOperations.d.ts +33 -11
- package/dist/components/secretOperations.js +90 -26
- package/dist/components/secretOperations.js.map +1 -1
- package/dist/config/harperConfigEnvVars.d.ts +21 -0
- package/dist/config/harperConfigEnvVars.js +95 -5
- package/dist/config/harperConfigEnvVars.js.map +1 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.js +14 -1
- package/dist/index.js.map +1 -1
- package/dist/resources/Resource.d.ts +19 -0
- package/dist/resources/Resource.js +93 -2
- package/dist/resources/Resource.js.map +1 -1
- package/dist/resources/Table.d.ts +9 -0
- package/dist/resources/Table.js +256 -50
- package/dist/resources/Table.js.map +1 -1
- package/dist/resources/analytics/read.js +28 -25
- package/dist/resources/analytics/read.js.map +1 -1
- package/dist/resources/defineResource.d.ts +180 -0
- package/dist/resources/defineResource.js +505 -0
- package/dist/resources/defineResource.js.map +1 -0
- package/dist/resources/defineTable.d.ts +221 -0
- package/dist/resources/defineTable.js +227 -0
- package/dist/resources/defineTable.js.map +1 -0
- package/dist/resources/jsonSchemaTypes.d.ts +2 -0
- package/dist/resources/jsonSchemaTypes.js +12 -4
- package/dist/resources/jsonSchemaTypes.js.map +1 -1
- package/dist/resources/openApi.js +69 -13
- package/dist/resources/openApi.js.map +1 -1
- package/dist/resources/search.js +6 -8
- package/dist/resources/search.js.map +1 -1
- package/dist/server/graphqlQuerying.js +4 -2
- package/dist/server/graphqlQuerying.js.map +1 -1
- package/dist/server/http.d.ts +12 -0
- package/dist/server/http.js +35 -15
- package/dist/server/http.js.map +1 -1
- package/dist/server/serverHelpers/serverUtilities.js +8 -5
- package/dist/server/serverHelpers/serverUtilities.js.map +1 -1
- package/dist/server/serverHelpers/uwsServer.js +23 -0
- package/dist/server/serverHelpers/uwsServer.js.map +1 -1
- package/dist/utility/errors/hdbError.d.ts +21 -0
- package/dist/utility/errors/hdbError.js +23 -1
- package/dist/utility/errors/hdbError.js.map +1 -1
- package/dist/validation/configValidator.js +22 -6
- package/dist/validation/configValidator.js.map +1 -1
- package/index.ts +33 -0
- package/npm-shrinkwrap.json +9197 -15066
- package/package.json +3 -1
- package/resources/DESIGN.md +42 -15
- package/resources/Resource.ts +99 -2
- package/resources/Table.ts +275 -71
- package/resources/analytics/read.ts +30 -25
- package/resources/defineResource.ts +651 -0
- package/resources/defineTable.ts +407 -0
- package/resources/jsonSchemaTypes.ts +12 -4
- package/resources/openApi.ts +68 -16
- package/resources/search.ts +5 -8
- package/server/graphqlQuerying.ts +4 -2
- package/server/http.ts +40 -16
- package/server/serverHelpers/serverUtilities.ts +19 -6
- package/server/serverHelpers/uwsServer.ts +24 -0
- package/studio/web/assets/{Chat-Cv_2paZE.js → Chat-CTjtL8Z4.js} +2 -2
- package/studio/web/assets/{Chat-Cv_2paZE.js.map → Chat-CTjtL8Z4.js.map} +1 -1
- package/studio/web/assets/{FloatingChat-CPyPIcVR.js → FloatingChat-CafHR4Ur.js} +4 -4
- package/studio/web/assets/{FloatingChat-CPyPIcVR.js.map → FloatingChat-CafHR4Ur.js.map} +1 -1
- package/studio/web/assets/{applications-C0jT2vdZ.js → applications-Buh_q0Vj.js} +2 -2
- package/studio/web/assets/{applications-C0jT2vdZ.js.map → applications-Buh_q0Vj.js.map} +1 -1
- package/studio/web/assets/{index-D_GKOkhn.js → index-0hXeECkS.js} +6 -6
- package/studio/web/assets/{index-D_GKOkhn.js.map → index-0hXeECkS.js.map} +1 -1
- package/studio/web/assets/{index.lazy-CAmCIA65.js → index.lazy-B00B7VBT.js} +4 -4
- package/studio/web/assets/{index.lazy-CAmCIA65.js.map → index.lazy-B00B7VBT.js.map} +1 -1
- package/studio/web/assets/{profile-CaF-4aZe.js → profile-Cg2wwYPn.js} +2 -2
- package/studio/web/assets/{profile-CaF-4aZe.js.map → profile-Cg2wwYPn.js.map} +1 -1
- package/studio/web/assets/{setComponentFile-DLW1DHCt.js → setComponentFile-DCaDIvyB.js} +2 -2
- package/studio/web/assets/{setComponentFile-DLW1DHCt.js.map → setComponentFile-DCaDIvyB.js.map} +1 -1
- package/studio/web/assets/{setup-TGCErIhq.js → setup-CAVcAQjK.js} +2 -2
- package/studio/web/assets/{setup-TGCErIhq.js.map → setup-CAVcAQjK.js.map} +1 -1
- package/studio/web/assets/{status-DG0Maoao.js → status-BRXorNdD.js} +2 -2
- package/studio/web/assets/{status-DG0Maoao.js.map → status-BRXorNdD.js.map} +1 -1
- package/studio/web/assets/{swagger-ui-react-8T4SgQ1m.js → swagger-ui-react-Dy1D62vO.js} +2 -2
- package/studio/web/assets/{swagger-ui-react-8T4SgQ1m.js.map → swagger-ui-react-Dy1D62vO.js.map} +1 -1
- package/studio/web/assets/{tsMode-DJ6Sl24Q.js → tsMode-A8gbL74v.js} +2 -2
- package/studio/web/assets/{tsMode-DJ6Sl24Q.js.map → tsMode-A8gbL74v.js.map} +1 -1
- package/studio/web/assets/{useEntityRestURL-JO2mfWTQ.js → useEntityRestURL-cDodrVcQ.js} +2 -2
- package/studio/web/assets/{useEntityRestURL-JO2mfWTQ.js.map → useEntityRestURL-cDodrVcQ.js.map} +1 -1
- package/studio/web/index.html +1 -1
- package/utility/errors/hdbError.ts +32 -0
- package/validation/configValidator.ts +23 -6
package/agent/tools/fsTools.ts
CHANGED
|
@@ -23,13 +23,49 @@ const DEFAULT_TAIL_LINES = 200;
|
|
|
23
23
|
const TAIL_READ_BYTES = 1 * 1024 * 1024; // 1 MiB — enough for thousands of normal log lines
|
|
24
24
|
|
|
25
25
|
type Access = 'read' | 'write';
|
|
26
|
+
type Scope = 'components' | 'logs' | 'config';
|
|
26
27
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
28
|
+
// The three filesystem scopes the agent can address. Exposed to the model as a `root` enum on the
|
|
29
|
+
// fs tools so it names the scope explicitly instead of guessing an absolute path — which is what led
|
|
30
|
+
// the model to invent literal `componentsRoot/…` prefixes. `components` is the only writable scope.
|
|
31
|
+
const SCOPES: Scope[] = ['components', 'logs', 'config'];
|
|
32
|
+
const SCOPE_DESCRIPTION =
|
|
33
|
+
"Which scope the `path` is relative to: 'components' (app source directory, the only writable scope), " +
|
|
34
|
+
"'logs' (read-only), or 'config' (read-only). Defaults to 'components'.";
|
|
35
|
+
|
|
36
|
+
function scopeRoot(scopes: AgentScopes, scope: Scope): string {
|
|
37
|
+
switch (scope) {
|
|
38
|
+
case 'components':
|
|
39
|
+
return scopes.componentsRoot;
|
|
40
|
+
case 'logs':
|
|
41
|
+
return scopes.logDir;
|
|
42
|
+
case 'config':
|
|
43
|
+
return scopes.configDir;
|
|
44
|
+
default:
|
|
45
|
+
throw new Error(`Unknown fs root '${scope}'. Use one of: ${SCOPES.join(', ')}.`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Coerce/validate a tool's `root` argument, defaulting to the writable components scope. */
|
|
50
|
+
function normalizeScope(root: unknown): Scope {
|
|
51
|
+
if (root == null) return 'components';
|
|
52
|
+
if (typeof root !== 'string' || !SCOPES.includes(root as Scope))
|
|
53
|
+
throw new Error(`Invalid fs root '${String(root)}'. Use one of: ${SCOPES.join(', ')}.`);
|
|
54
|
+
return root as Scope;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function resolveScoped(scopes: AgentScopes, scope: Scope, path: string, access: Access): Promise<string> {
|
|
58
|
+
// Writes are confined to the components (app) scope; logs and config are observation-only.
|
|
59
|
+
if (access === 'write' && scope !== 'components') {
|
|
60
|
+
throw new Error(`Cannot write to the '${scope}' scope; writes are limited to 'components'.`);
|
|
61
|
+
}
|
|
62
|
+
const root = scopeRoot(scopes, scope);
|
|
63
|
+
// Paths are relative to the chosen scope root. Rejecting absolute paths removes the ambiguity that
|
|
64
|
+
// led the model to guess literal prefixes, and keeps resolution a single unambiguous join.
|
|
65
|
+
if (isAbsolute(path)) {
|
|
66
|
+
throw new Error(`Path must be relative to the '${scope}' root, not absolute: ${path}`);
|
|
32
67
|
}
|
|
68
|
+
const absolute = resolve(root, path);
|
|
33
69
|
// Reject a symlink leaf. `safeRealPath` resolves existing symlinks via `realpath` (so a link to
|
|
34
70
|
// an out-of-scope *existing* file is caught by the isInside check below) — but a link whose
|
|
35
71
|
// target does NOT exist makes `realpath` throw, and the fallback returns the link's own in-scope
|
|
@@ -46,11 +82,9 @@ async function resolveScoped(scopes: AgentScopes, path: string, access: Access):
|
|
|
46
82
|
if ((err as NodeJS.ErrnoException)?.code !== 'ENOENT') throw err;
|
|
47
83
|
}
|
|
48
84
|
const realAbsolute = await safeRealPath(absolute);
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
}
|
|
53
|
-
throw new Error(`Path is outside the agent's ${access} scope: ${path}`);
|
|
85
|
+
const realRoot = await safeRealPath(root);
|
|
86
|
+
if (isInside(realAbsolute, realRoot)) return realAbsolute;
|
|
87
|
+
throw new Error(`Path is outside the agent's '${scope}' scope: ${path}`);
|
|
54
88
|
}
|
|
55
89
|
|
|
56
90
|
async function safeRealPath(p: string): Promise<string> {
|
|
@@ -77,17 +111,18 @@ function isInside(child: string, parent: string): boolean {
|
|
|
77
111
|
export const readFileTool: AgentTool = {
|
|
78
112
|
def: {
|
|
79
113
|
name: 'read_file',
|
|
80
|
-
description: 'Read a UTF-8 text file
|
|
114
|
+
description: 'Read a UTF-8 text file from the components, logs, or config scope.',
|
|
81
115
|
parameters: {
|
|
82
116
|
type: 'object',
|
|
83
117
|
properties: {
|
|
84
|
-
|
|
118
|
+
root: { type: 'string', enum: SCOPES, description: SCOPE_DESCRIPTION },
|
|
119
|
+
path: { type: 'string', description: 'Path relative to the chosen root, e.g. "my_app/schema.graphql".' },
|
|
85
120
|
},
|
|
86
121
|
required: ['path'],
|
|
87
122
|
},
|
|
88
123
|
},
|
|
89
124
|
handler: async (args: any, ctx: AgentToolContext) => {
|
|
90
|
-
const path = await resolveScoped(ctx.scopes, args.path, 'read');
|
|
125
|
+
const path = await resolveScoped(ctx.scopes, normalizeScope(args.root), args.path, 'read');
|
|
91
126
|
const st = await stat(path);
|
|
92
127
|
if (st.size > MAX_READ_BYTES) {
|
|
93
128
|
throw new Error(`File ${path} exceeds ${MAX_READ_BYTES}-byte read cap (size ${st.size})`);
|
|
@@ -100,11 +135,15 @@ export const readFileTool: AgentTool = {
|
|
|
100
135
|
export const writeFileTool: AgentTool = {
|
|
101
136
|
def: {
|
|
102
137
|
name: 'write_file',
|
|
103
|
-
description:
|
|
138
|
+
description:
|
|
139
|
+
'Write a UTF-8 text file into the components (app source) scope — the only writable scope. Creates parent directories as needed.',
|
|
104
140
|
parameters: {
|
|
105
141
|
type: 'object',
|
|
106
142
|
properties: {
|
|
107
|
-
path: {
|
|
143
|
+
path: {
|
|
144
|
+
type: 'string',
|
|
145
|
+
description: 'Path relative to the components directory, e.g. "my_app/schema.graphql".',
|
|
146
|
+
},
|
|
108
147
|
content: { type: 'string', description: 'UTF-8 file contents.' },
|
|
109
148
|
},
|
|
110
149
|
required: ['path', 'content'],
|
|
@@ -115,7 +154,7 @@ export const writeFileTool: AgentTool = {
|
|
|
115
154
|
if (Buffer.byteLength(content, 'utf8') > MAX_WRITE_BYTES) {
|
|
116
155
|
throw new Error(`Write exceeds ${MAX_WRITE_BYTES}-byte cap`);
|
|
117
156
|
}
|
|
118
|
-
const path = await resolveScoped(ctx.scopes, args.path, 'write');
|
|
157
|
+
const path = await resolveScoped(ctx.scopes, 'components', args.path, 'write');
|
|
119
158
|
await mkdir(dirname(path), { recursive: true });
|
|
120
159
|
await writeFile(path, content, 'utf8');
|
|
121
160
|
return { path, bytesWritten: Buffer.byteLength(content, 'utf8') };
|
|
@@ -126,17 +165,20 @@ export const writeFileTool: AgentTool = {
|
|
|
126
165
|
export const listDirTool: AgentTool = {
|
|
127
166
|
def: {
|
|
128
167
|
name: 'list_dir',
|
|
129
|
-
description: 'List the immediate entries in a directory within
|
|
168
|
+
description: 'List the immediate entries in a directory within the components, logs, or config scope.',
|
|
130
169
|
parameters: {
|
|
131
170
|
type: 'object',
|
|
132
171
|
properties: {
|
|
133
|
-
|
|
172
|
+
root: { type: 'string', enum: SCOPES, description: SCOPE_DESCRIPTION },
|
|
173
|
+
path: {
|
|
174
|
+
type: 'string',
|
|
175
|
+
description: 'Directory relative to the chosen root. Omit or "" for the root itself.',
|
|
176
|
+
},
|
|
134
177
|
},
|
|
135
|
-
required: ['path'],
|
|
136
178
|
},
|
|
137
179
|
},
|
|
138
180
|
handler: async (args: any, ctx: AgentToolContext) => {
|
|
139
|
-
const path = await resolveScoped(ctx.scopes, args.path, 'read');
|
|
181
|
+
const path = await resolveScoped(ctx.scopes, normalizeScope(args.root), args.path ?? '', 'read');
|
|
140
182
|
const entries = await readdir(path, { withFileTypes: true });
|
|
141
183
|
return {
|
|
142
184
|
path,
|
|
@@ -151,20 +193,24 @@ export const listDirTool: AgentTool = {
|
|
|
151
193
|
export const grepFilesTool: AgentTool = {
|
|
152
194
|
def: {
|
|
153
195
|
name: 'grep_files',
|
|
154
|
-
description: 'Search recursively
|
|
196
|
+
description: 'Search recursively within a scope for a regex pattern. Returns matched lines.',
|
|
155
197
|
parameters: {
|
|
156
198
|
type: 'object',
|
|
157
199
|
properties: {
|
|
158
|
-
root: { type: 'string',
|
|
200
|
+
root: { type: 'string', enum: SCOPES, description: SCOPE_DESCRIPTION },
|
|
201
|
+
path: {
|
|
202
|
+
type: 'string',
|
|
203
|
+
description: 'Subdirectory relative to the chosen root to search under. Omit or "" for the whole scope.',
|
|
204
|
+
},
|
|
159
205
|
pattern: { type: 'string', description: 'JavaScript-compatible regular expression source.' },
|
|
160
206
|
flags: { type: 'string', description: 'Regex flags (default: "i").' },
|
|
161
207
|
maxResults: { type: 'integer', minimum: 1, maximum: MAX_GREP_RESULTS },
|
|
162
208
|
},
|
|
163
|
-
required: ['
|
|
209
|
+
required: ['pattern'],
|
|
164
210
|
},
|
|
165
211
|
},
|
|
166
212
|
handler: async (args: any, ctx: AgentToolContext) => {
|
|
167
|
-
const root = await resolveScoped(ctx.scopes, args.root, 'read');
|
|
213
|
+
const root = await resolveScoped(ctx.scopes, normalizeScope(args.root), args.path ?? '', 'read');
|
|
168
214
|
// Cap pattern length. A maliciously crafted regex (e.g. nested quantifiers) can backtrack
|
|
169
215
|
// catastrophically and block the main thread; JS has no native per-match timeout. The agent
|
|
170
216
|
// is super_user-gated so this is self-inflicted DoS rather than a privilege boundary, but a
|
|
@@ -204,18 +250,19 @@ export const grepFilesTool: AgentTool = {
|
|
|
204
250
|
export const tailFileTool: AgentTool = {
|
|
205
251
|
def: {
|
|
206
252
|
name: 'tail_file',
|
|
207
|
-
description: 'Return the last N lines of a UTF-8 file. Useful for log tails.',
|
|
253
|
+
description: 'Return the last N lines of a UTF-8 file. Useful for log tails (root: "logs").',
|
|
208
254
|
parameters: {
|
|
209
255
|
type: 'object',
|
|
210
256
|
properties: {
|
|
211
|
-
|
|
257
|
+
root: { type: 'string', enum: SCOPES, description: SCOPE_DESCRIPTION },
|
|
258
|
+
path: { type: 'string', description: 'Path relative to the chosen root, e.g. "hdb.log".' },
|
|
212
259
|
lines: { type: 'integer', minimum: 1, maximum: 5000 },
|
|
213
260
|
},
|
|
214
261
|
required: ['path'],
|
|
215
262
|
},
|
|
216
263
|
},
|
|
217
264
|
handler: async (args: any, ctx: AgentToolContext) => {
|
|
218
|
-
const path = await resolveScoped(ctx.scopes, args.path, 'read');
|
|
265
|
+
const path = await resolveScoped(ctx.scopes, normalizeScope(args.root), args.path, 'read');
|
|
219
266
|
const wanted = Math.min(args.lines ?? DEFAULT_TAIL_LINES, 5000);
|
|
220
267
|
// Read only the trailing TAIL_READ_BYTES — a multi-GB log file otherwise OOMs the process.
|
|
221
268
|
const st = await stat(path);
|
|
@@ -0,0 +1,459 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* V8 inspector (CDP) tools for the built-in agent (#626).
|
|
3
|
+
*
|
|
4
|
+
* The agent runs on the main thread; these tools attach over the Chrome
|
|
5
|
+
* DevTools Protocol to *worker* thread inspector ports so it can evaluate,
|
|
6
|
+
* set breakpoints/logpoints, and CPU-profile a worker without stalling the
|
|
7
|
+
* thread it runs on. A worker's debug port is `threads_debug_startingPort +
|
|
8
|
+
* workerIndex` (see `server/threads/threadServer.js`); attaching therefore
|
|
9
|
+
* requires `threads_debug: true` and a configured `threads_debug_startingPort`.
|
|
10
|
+
*
|
|
11
|
+
* Attaching to the main thread (`workerIndex < 0`) is rejected — that's the
|
|
12
|
+
* agent's own thread, and a self-attach + breakpoint would deadlock. This is
|
|
13
|
+
* the operator-only counterpart to the app-developer agent, which runs on a
|
|
14
|
+
* worker and must never inspector-attach to itself.
|
|
15
|
+
*
|
|
16
|
+
* Safety against wedging a live worker: every connection registers a
|
|
17
|
+
* `Debugger.paused` handler that logs a stack snapshot and immediately resumes,
|
|
18
|
+
* so a breakpoint hit is observable (via the Harper log) but never leaves the
|
|
19
|
+
* worker paused. CDP calls carry a per-call abort signal + timeout so an
|
|
20
|
+
* unresponsive worker can't hang the agent loop.
|
|
21
|
+
*
|
|
22
|
+
* Dependencies (debug config + live worker count) are injected so the tool is
|
|
23
|
+
* unit-testable without booting a server.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import WebSocket from 'ws';
|
|
27
|
+
import harperLogger from '../../utility/logging/harper_logger.ts';
|
|
28
|
+
import type { AgentTool, AgentToolContext } from '../types.ts';
|
|
29
|
+
|
|
30
|
+
const log = harperLogger.loggerWithTag('agent');
|
|
31
|
+
|
|
32
|
+
const DEFAULT_CALL_TIMEOUT_MS = 30_000;
|
|
33
|
+
const MAX_TCP_PORT = 65535;
|
|
34
|
+
|
|
35
|
+
export interface InspectorDeps {
|
|
36
|
+
/** Whether `threads_debug` is enabled. */
|
|
37
|
+
debugEnabled: boolean;
|
|
38
|
+
/** `threads_debug_startingPort`, or undefined when not configured. */
|
|
39
|
+
startingPort: number | undefined;
|
|
40
|
+
/** Inspector host (`threads_debug_host`), defaulting to 127.0.0.1. */
|
|
41
|
+
host: string;
|
|
42
|
+
/** Live worker-slot count, for range-checking `workerIndex`. */
|
|
43
|
+
getWorkerCount: () => number;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
interface CdpSession {
|
|
47
|
+
send(method: string, params?: object, signal?: AbortSignal): Promise<any>;
|
|
48
|
+
on(event: string, cb: (params: any) => void): void;
|
|
49
|
+
close(): void;
|
|
50
|
+
title?: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// One live CDP connection per worker debug port, reused across tool calls. Keyed by port and
|
|
54
|
+
// storing the *promise* so concurrent opens dedupe onto a single connection.
|
|
55
|
+
const sessions = new Map<number, Promise<CdpSession>>();
|
|
56
|
+
|
|
57
|
+
/** Strictly parse a worker index — reject `""`/`null`/`false`/`[]`, which `Number()` would coerce to 0. */
|
|
58
|
+
function toWorkerIndex(raw: unknown): number {
|
|
59
|
+
if (typeof raw === 'number' && Number.isInteger(raw)) return raw;
|
|
60
|
+
if (typeof raw === 'string' && raw.trim() !== '' && Number.isInteger(Number(raw))) return Number(raw);
|
|
61
|
+
throw new Error('workerIndex must be an integer');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Resolve the worker's inspector port, enforcing the safety envelope:
|
|
66
|
+
* debug enabled, a starting port configured, and an in-range worker index that
|
|
67
|
+
* is NOT the main thread (`< 0`).
|
|
68
|
+
*/
|
|
69
|
+
function resolvePort(deps: InspectorDeps, workerIndex: number): number {
|
|
70
|
+
if (!deps.debugEnabled) {
|
|
71
|
+
throw new Error('threads_debug is not enabled; set threads_debug: true to allow inspector attach');
|
|
72
|
+
}
|
|
73
|
+
if (deps.startingPort == null) {
|
|
74
|
+
throw new Error('threads_debug_startingPort is not configured; per-worker inspector ports are unavailable');
|
|
75
|
+
}
|
|
76
|
+
if (workerIndex < 0) {
|
|
77
|
+
throw new Error('cannot attach to the main thread (the agent runs there); specify a worker index >= 0');
|
|
78
|
+
}
|
|
79
|
+
const count = deps.getWorkerCount();
|
|
80
|
+
if (workerIndex >= count) throw new Error(`no worker at index ${workerIndex} (worker count is ${count})`);
|
|
81
|
+
const port = deps.startingPort + workerIndex;
|
|
82
|
+
if (port > MAX_TCP_PORT) throw new Error(`resolved inspector port ${port} exceeds ${MAX_TCP_PORT}`);
|
|
83
|
+
return port;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function fetchWebSocketUrl(
|
|
87
|
+
host: string,
|
|
88
|
+
port: number,
|
|
89
|
+
signal?: AbortSignal
|
|
90
|
+
): Promise<{ wsUrl: string; title?: string }> {
|
|
91
|
+
const res = await fetch(`http://${host}:${port}/json/list`, { signal });
|
|
92
|
+
if (!res.ok) throw new Error(`inspector /json/list on ${host}:${port} returned ${res.status}`);
|
|
93
|
+
const targets = (await res.json()) as Array<{ webSocketDebuggerUrl?: string; title?: string }>;
|
|
94
|
+
const target = Array.isArray(targets) ? targets.find((t) => t.webSocketDebuggerUrl) : undefined;
|
|
95
|
+
if (!target?.webSocketDebuggerUrl) {
|
|
96
|
+
throw new Error(`no debuggable target at ${host}:${port} (is the worker inspector open?)`);
|
|
97
|
+
}
|
|
98
|
+
return { wsUrl: target.webSocketDebuggerUrl, title: target.title };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function openCdp(wsUrl: string, opts: { signal?: AbortSignal; onClose: () => void }): Promise<CdpSession> {
|
|
102
|
+
return new Promise((resolve, reject) => {
|
|
103
|
+
const ws = new WebSocket(wsUrl);
|
|
104
|
+
let opened = false;
|
|
105
|
+
let nextId = 1;
|
|
106
|
+
const pending = new Map<number, { resolve: (v: any) => void; reject: (e: Error) => void }>();
|
|
107
|
+
const listeners = new Map<string, Set<(params: any) => void>>();
|
|
108
|
+
|
|
109
|
+
const failPending = (err: Error) => {
|
|
110
|
+
for (const p of pending.values()) p.reject(err);
|
|
111
|
+
pending.clear();
|
|
112
|
+
};
|
|
113
|
+
// The connect signal guards ONLY the handshake — once open, per-call signals take over, so
|
|
114
|
+
// aborting the first caller must not tear down the shared connection.
|
|
115
|
+
const onConnectAbort = () => {
|
|
116
|
+
try {
|
|
117
|
+
ws.terminate();
|
|
118
|
+
} catch {
|
|
119
|
+
/* already gone */
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
if (opts.signal) {
|
|
123
|
+
if (opts.signal.aborted) {
|
|
124
|
+
onConnectAbort();
|
|
125
|
+
return reject(new Error('aborted'));
|
|
126
|
+
}
|
|
127
|
+
opts.signal.addEventListener('abort', onConnectAbort, { once: true });
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
ws.on('message', (data: WebSocket.RawData) => {
|
|
131
|
+
let msg: any;
|
|
132
|
+
try {
|
|
133
|
+
msg = JSON.parse(data.toString());
|
|
134
|
+
} catch {
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
if (msg.id != null && pending.has(msg.id)) {
|
|
138
|
+
const p = pending.get(msg.id)!;
|
|
139
|
+
pending.delete(msg.id);
|
|
140
|
+
if (msg.error) p.reject(new Error(msg.error.message ?? 'CDP error'));
|
|
141
|
+
else p.resolve(msg.result);
|
|
142
|
+
} else if (msg.method) {
|
|
143
|
+
for (const cb of listeners.get(msg.method) ?? []) cb(msg.params);
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
ws.on('error', (err: Error) => {
|
|
147
|
+
if (!opened) reject(err);
|
|
148
|
+
failPending(err instanceof Error ? err : new Error(String(err)));
|
|
149
|
+
});
|
|
150
|
+
ws.on('close', () => {
|
|
151
|
+
opts.signal?.removeEventListener('abort', onConnectAbort);
|
|
152
|
+
if (!opened) reject(new Error('CDP connection closed before it opened'));
|
|
153
|
+
failPending(new Error('CDP connection closed'));
|
|
154
|
+
opts.onClose();
|
|
155
|
+
});
|
|
156
|
+
ws.on('open', () => {
|
|
157
|
+
opened = true;
|
|
158
|
+
opts.signal?.removeEventListener('abort', onConnectAbort);
|
|
159
|
+
const session: CdpSession = {
|
|
160
|
+
send(method, params, signal) {
|
|
161
|
+
return new Promise((res, rej) => {
|
|
162
|
+
if (signal?.aborted) return rej(new Error('aborted'));
|
|
163
|
+
const id = nextId++;
|
|
164
|
+
const timer = setTimeout(() => {
|
|
165
|
+
if (pending.delete(id)) rej(new Error(`CDP ${method} timed out after ${DEFAULT_CALL_TIMEOUT_MS}ms`));
|
|
166
|
+
}, DEFAULT_CALL_TIMEOUT_MS);
|
|
167
|
+
const onAbort = () => {
|
|
168
|
+
if (pending.delete(id)) {
|
|
169
|
+
clearTimeout(timer);
|
|
170
|
+
rej(new Error('aborted'));
|
|
171
|
+
}
|
|
172
|
+
};
|
|
173
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
174
|
+
pending.set(id, {
|
|
175
|
+
resolve: (v) => {
|
|
176
|
+
clearTimeout(timer);
|
|
177
|
+
signal?.removeEventListener('abort', onAbort);
|
|
178
|
+
res(v);
|
|
179
|
+
},
|
|
180
|
+
reject: (e) => {
|
|
181
|
+
clearTimeout(timer);
|
|
182
|
+
signal?.removeEventListener('abort', onAbort);
|
|
183
|
+
rej(e);
|
|
184
|
+
},
|
|
185
|
+
});
|
|
186
|
+
try {
|
|
187
|
+
ws.send(JSON.stringify({ id, method, params: params ?? {} }));
|
|
188
|
+
} catch (e) {
|
|
189
|
+
if (pending.delete(id)) {
|
|
190
|
+
clearTimeout(timer);
|
|
191
|
+
rej(e as Error);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
},
|
|
196
|
+
on(event, cb) {
|
|
197
|
+
let set = listeners.get(event);
|
|
198
|
+
if (!set) listeners.set(event, (set = new Set()));
|
|
199
|
+
set.add(cb);
|
|
200
|
+
},
|
|
201
|
+
close() {
|
|
202
|
+
try {
|
|
203
|
+
ws.close();
|
|
204
|
+
} catch {
|
|
205
|
+
/* already closing */
|
|
206
|
+
}
|
|
207
|
+
},
|
|
208
|
+
};
|
|
209
|
+
// A breakpoint hit pauses the worker; log a stack snapshot and resume immediately so the
|
|
210
|
+
// worker is never left wedged. Fires only when Debugger is enabled (i.e. a breakpoint was set).
|
|
211
|
+
session.on('Debugger.paused', (params: any) => {
|
|
212
|
+
const frames = (params?.callFrames ?? [])
|
|
213
|
+
.slice(0, 5)
|
|
214
|
+
.map((f: any) => `${f.functionName || '(anonymous)'}@${f.url}:${f.location?.lineNumber}`);
|
|
215
|
+
log.info?.(`[agent breakpoint hit] reason=${params?.reason} ${frames.join(' <- ')}`);
|
|
216
|
+
session.send('Debugger.resume').catch(() => {});
|
|
217
|
+
});
|
|
218
|
+
resolve(session);
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function sessionFor(port: number, host: string, signal?: AbortSignal): Promise<CdpSession> {
|
|
224
|
+
const existing = sessions.get(port);
|
|
225
|
+
if (existing) return existing;
|
|
226
|
+
const promise = (async () => {
|
|
227
|
+
const { wsUrl, title } = await fetchWebSocketUrl(host, port, signal);
|
|
228
|
+
const session = await openCdp(wsUrl, {
|
|
229
|
+
signal,
|
|
230
|
+
// Evict only if this exact promise is still the cached one — a stale close from a replaced
|
|
231
|
+
// connection must not drop a newer active session.
|
|
232
|
+
onClose: () => {
|
|
233
|
+
if (sessions.get(port) === promise) sessions.delete(port);
|
|
234
|
+
},
|
|
235
|
+
});
|
|
236
|
+
session.title = title;
|
|
237
|
+
return session;
|
|
238
|
+
})();
|
|
239
|
+
// Drop a failed connect from the cache so the next call retries instead of re-awaiting a rejection.
|
|
240
|
+
promise.catch(() => {
|
|
241
|
+
if (sessions.get(port) === promise) sessions.delete(port);
|
|
242
|
+
});
|
|
243
|
+
sessions.set(port, promise);
|
|
244
|
+
return promise;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function sleep(ms: number, signal?: AbortSignal): Promise<void> {
|
|
248
|
+
return new Promise((resolve, reject) => {
|
|
249
|
+
if (signal?.aborted) return reject(new Error('aborted'));
|
|
250
|
+
const timer = setTimeout(resolve, ms);
|
|
251
|
+
signal?.addEventListener(
|
|
252
|
+
'abort',
|
|
253
|
+
() => {
|
|
254
|
+
clearTimeout(timer);
|
|
255
|
+
reject(new Error('aborted'));
|
|
256
|
+
},
|
|
257
|
+
{ once: true }
|
|
258
|
+
);
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** Reduce a CDP CPU profile to the hottest functions by self time — the raw profile is megabytes. */
|
|
263
|
+
export function summarizeProfile(profile: any, topN: number): object {
|
|
264
|
+
const nodes: any[] = profile?.nodes ?? [];
|
|
265
|
+
const byId = new Map(nodes.map((n) => [n.id, n]));
|
|
266
|
+
const samples: number[] = profile?.samples ?? [];
|
|
267
|
+
const deltas: number[] = profile?.timeDeltas ?? [];
|
|
268
|
+
const selfUs = new Map<number, number>();
|
|
269
|
+
for (let i = 0; i < samples.length; i++) {
|
|
270
|
+
const id = samples[i];
|
|
271
|
+
selfUs.set(id, (selfUs.get(id) ?? 0) + (deltas[i] ?? 0));
|
|
272
|
+
}
|
|
273
|
+
const topFunctions = [...selfUs.entries()]
|
|
274
|
+
.map(([id, us]) => {
|
|
275
|
+
const cf = byId.get(id)?.callFrame ?? {};
|
|
276
|
+
return {
|
|
277
|
+
function: cf.functionName || '(anonymous)',
|
|
278
|
+
url: cf.url,
|
|
279
|
+
line: cf.lineNumber,
|
|
280
|
+
selfMs: +(us / 1000).toFixed(1),
|
|
281
|
+
};
|
|
282
|
+
})
|
|
283
|
+
.sort((a, b) => b.selfMs - a.selfMs)
|
|
284
|
+
.slice(0, topN);
|
|
285
|
+
const totalMs = deltas.reduce((a, b) => a + b, 0) / 1000;
|
|
286
|
+
return { totalMs: +totalMs.toFixed(1), sampleCount: samples.length, topFunctions };
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const workerIndexProp = { type: 'integer', minimum: 0, description: '0-based worker thread index' };
|
|
290
|
+
|
|
291
|
+
export function buildInspectorTools(deps: InspectorDeps): AgentTool[] {
|
|
292
|
+
const attach: AgentTool = {
|
|
293
|
+
def: {
|
|
294
|
+
name: 'inspector_attach',
|
|
295
|
+
description:
|
|
296
|
+
'Attach the V8 inspector to a worker thread so it can be evaluated, breakpointed, or CPU-profiled. Requires threads_debug + threads_debug_startingPort. Rejects the main thread.',
|
|
297
|
+
parameters: { type: 'object', properties: { workerIndex: workerIndexProp }, required: ['workerIndex'] },
|
|
298
|
+
},
|
|
299
|
+
handler: async (args: any, ctx: AgentToolContext) => {
|
|
300
|
+
const workerIndex = toWorkerIndex(args.workerIndex);
|
|
301
|
+
const port = resolvePort(deps, workerIndex);
|
|
302
|
+
const session = await sessionFor(port, deps.host, ctx.signal);
|
|
303
|
+
await session.send('Runtime.enable', {}, ctx.signal);
|
|
304
|
+
await session.send('Debugger.enable', {}, ctx.signal);
|
|
305
|
+
return { attached: true, workerIndex, port, target: session.title };
|
|
306
|
+
},
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
const evaluate: AgentTool = {
|
|
310
|
+
def: {
|
|
311
|
+
name: 'inspector_evaluate',
|
|
312
|
+
description:
|
|
313
|
+
'Evaluate a JavaScript expression in a worker thread and return the result. Runs arbitrary code in the worker.',
|
|
314
|
+
parameters: {
|
|
315
|
+
type: 'object',
|
|
316
|
+
properties: {
|
|
317
|
+
workerIndex: workerIndexProp,
|
|
318
|
+
expression: { type: 'string' },
|
|
319
|
+
awaitPromise: { type: 'boolean', description: 'Await the result if it is a promise.' },
|
|
320
|
+
},
|
|
321
|
+
required: ['workerIndex', 'expression'],
|
|
322
|
+
},
|
|
323
|
+
},
|
|
324
|
+
destructive: true, // arbitrary in-worker code execution
|
|
325
|
+
handler: async (args: any, ctx: AgentToolContext) => {
|
|
326
|
+
const port = resolvePort(deps, toWorkerIndex(args.workerIndex));
|
|
327
|
+
const session = await sessionFor(port, deps.host, ctx.signal);
|
|
328
|
+
await session.send('Runtime.enable', {}, ctx.signal);
|
|
329
|
+
const res = await session.send(
|
|
330
|
+
'Runtime.evaluate',
|
|
331
|
+
{ expression: String(args.expression), returnByValue: true, awaitPromise: !!args.awaitPromise },
|
|
332
|
+
ctx.signal
|
|
333
|
+
);
|
|
334
|
+
if (res.exceptionDetails) {
|
|
335
|
+
throw new Error(res.exceptionDetails.exception?.description ?? res.exceptionDetails.text ?? 'evaluation threw');
|
|
336
|
+
}
|
|
337
|
+
return { value: res.result?.value, type: res.result?.type };
|
|
338
|
+
},
|
|
339
|
+
};
|
|
340
|
+
|
|
341
|
+
const setBreakpoint: AgentTool = {
|
|
342
|
+
def: {
|
|
343
|
+
name: 'inspector_set_breakpoint',
|
|
344
|
+
description:
|
|
345
|
+
'Set a breakpoint by script URL + line in a worker. lineNumber is 0-based (CDP). On hit, a stack snapshot is written to the Harper log and the worker is auto-resumed (it is NOT left paused).',
|
|
346
|
+
parameters: {
|
|
347
|
+
type: 'object',
|
|
348
|
+
properties: {
|
|
349
|
+
workerIndex: workerIndexProp,
|
|
350
|
+
url: { type: 'string', description: 'Exact script URL (usually file:///…) to break in.' },
|
|
351
|
+
lineNumber: { type: 'integer', minimum: 0, description: '0-based line number.' },
|
|
352
|
+
columnNumber: { type: 'integer', minimum: 0 },
|
|
353
|
+
condition: { type: 'string', description: 'Optional JS condition; breaks only when it is truthy.' },
|
|
354
|
+
},
|
|
355
|
+
required: ['workerIndex', 'url', 'lineNumber'],
|
|
356
|
+
},
|
|
357
|
+
},
|
|
358
|
+
destructive: true, // runs a condition in the worker; a hit briefly pauses it before auto-resume
|
|
359
|
+
handler: async (args: any, ctx: AgentToolContext) => {
|
|
360
|
+
const port = resolvePort(deps, toWorkerIndex(args.workerIndex));
|
|
361
|
+
const session = await sessionFor(port, deps.host, ctx.signal);
|
|
362
|
+
await session.send('Debugger.enable', {}, ctx.signal);
|
|
363
|
+
const res = await session.send(
|
|
364
|
+
'Debugger.setBreakpointByUrl',
|
|
365
|
+
{
|
|
366
|
+
url: String(args.url),
|
|
367
|
+
lineNumber: Number(args.lineNumber),
|
|
368
|
+
...(args.columnNumber != null ? { columnNumber: Number(args.columnNumber) } : {}),
|
|
369
|
+
...(args.condition ? { condition: String(args.condition) } : {}),
|
|
370
|
+
},
|
|
371
|
+
ctx.signal
|
|
372
|
+
);
|
|
373
|
+
return { breakpointId: res.breakpointId, locations: res.locations };
|
|
374
|
+
},
|
|
375
|
+
};
|
|
376
|
+
|
|
377
|
+
const setLogpoint: AgentTool = {
|
|
378
|
+
def: {
|
|
379
|
+
name: 'inspector_set_logpoint',
|
|
380
|
+
description:
|
|
381
|
+
'Set a non-pausing logpoint: logs an expression each time the line is hit, without stopping the worker. lineNumber is 0-based. Runs the expression in the worker.',
|
|
382
|
+
parameters: {
|
|
383
|
+
type: 'object',
|
|
384
|
+
properties: {
|
|
385
|
+
workerIndex: workerIndexProp,
|
|
386
|
+
url: { type: 'string', description: 'Exact script URL (usually file:///…).' },
|
|
387
|
+
lineNumber: { type: 'integer', minimum: 0, description: '0-based line number.' },
|
|
388
|
+
logExpression: { type: 'string', description: 'JS expression whose value is logged on each hit.' },
|
|
389
|
+
},
|
|
390
|
+
required: ['workerIndex', 'url', 'lineNumber', 'logExpression'],
|
|
391
|
+
},
|
|
392
|
+
},
|
|
393
|
+
destructive: true, // runs an arbitrary expression in the worker on every hit
|
|
394
|
+
handler: async (args: any, ctx: AgentToolContext) => {
|
|
395
|
+
const port = resolvePort(deps, toWorkerIndex(args.workerIndex));
|
|
396
|
+
const session = await sessionFor(port, deps.host, ctx.signal);
|
|
397
|
+
await session.send('Debugger.enable', {}, ctx.signal);
|
|
398
|
+
// A logpoint is a breakpoint whose condition logs and returns false, so it never pauses. The
|
|
399
|
+
// expression is JSON-encoded into a string and eval'd (not spliced as raw code), so it can't
|
|
400
|
+
// break out of the wrapper to force a pause; a stray pause would auto-resume anyway (see
|
|
401
|
+
// openCdp). Direct eval keeps the paused frame's locals in scope.
|
|
402
|
+
const condition = `(function(){try{console.log('[agent logpoint]',eval(${JSON.stringify(
|
|
403
|
+
String(args.logExpression)
|
|
404
|
+
)}));}catch(e){}return false;})()`;
|
|
405
|
+
const res = await session.send(
|
|
406
|
+
'Debugger.setBreakpointByUrl',
|
|
407
|
+
{ url: String(args.url), lineNumber: Number(args.lineNumber), condition },
|
|
408
|
+
ctx.signal
|
|
409
|
+
);
|
|
410
|
+
return { breakpointId: res.breakpointId, locations: res.locations };
|
|
411
|
+
},
|
|
412
|
+
};
|
|
413
|
+
|
|
414
|
+
const profileCpu: AgentTool = {
|
|
415
|
+
def: {
|
|
416
|
+
name: 'inspector_profile_cpu',
|
|
417
|
+
description: 'Record a CPU profile of a worker for durationMs and return the hottest functions by self time.',
|
|
418
|
+
parameters: {
|
|
419
|
+
type: 'object',
|
|
420
|
+
properties: {
|
|
421
|
+
workerIndex: workerIndexProp,
|
|
422
|
+
durationMs: { type: 'integer', minimum: 100, maximum: 60000 },
|
|
423
|
+
topN: {
|
|
424
|
+
type: 'integer',
|
|
425
|
+
minimum: 1,
|
|
426
|
+
maximum: 50,
|
|
427
|
+
description: 'How many hot functions to return (default 15).',
|
|
428
|
+
},
|
|
429
|
+
},
|
|
430
|
+
required: ['workerIndex', 'durationMs'],
|
|
431
|
+
},
|
|
432
|
+
},
|
|
433
|
+
handler: async (args: any, ctx: AgentToolContext) => {
|
|
434
|
+
const port = resolvePort(deps, toWorkerIndex(args.workerIndex));
|
|
435
|
+
const duration = Math.min(Math.max(Number(args.durationMs), 100), 60000);
|
|
436
|
+
const session = await sessionFor(port, deps.host, ctx.signal);
|
|
437
|
+
await session.send('Profiler.enable', {}, ctx.signal);
|
|
438
|
+
await session.send('Profiler.start', {}, ctx.signal);
|
|
439
|
+
let stopped: any;
|
|
440
|
+
try {
|
|
441
|
+
await sleep(duration, ctx.signal);
|
|
442
|
+
} finally {
|
|
443
|
+
// Always stop + disable the profiler even if aborted, so we don't leave it running on the worker.
|
|
444
|
+
stopped = await session.send('Profiler.stop').catch(() => undefined);
|
|
445
|
+
await session.send('Profiler.disable').catch(() => {});
|
|
446
|
+
}
|
|
447
|
+
if (!stopped?.profile) throw new Error('profiling produced no profile');
|
|
448
|
+
return summarizeProfile(stopped.profile, args.topN ? Number(args.topN) : 15);
|
|
449
|
+
},
|
|
450
|
+
};
|
|
451
|
+
|
|
452
|
+
return [attach, evaluate, setBreakpoint, setLogpoint, profileCpu];
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/** Close all live inspector connections (agent shutdown / test teardown). Handles in-flight opens. */
|
|
456
|
+
export function _closeInspectorSessions(): void {
|
|
457
|
+
for (const promise of sessions.values()) promise.then((s) => s.close()).catch(() => {});
|
|
458
|
+
sessions.clear();
|
|
459
|
+
}
|