Add Claude observability tracing and diagnostics UI

This commit is contained in:
2026-02-24 12:50:31 -05:00
parent 6863c1da0b
commit 691591d279
22 changed files with 1898 additions and 32 deletions

View File

@@ -190,6 +190,9 @@ function createActorSecurityContext(input: {
type: `security.${event.type}`,
severity: mapSecurityAuditSeverity(event),
message: toSecurityAuditMessage(event),
...(event.sessionId ? { sessionId: event.sessionId } : {}),
...(event.nodeId ? { nodeId: event.nodeId } : {}),
...(typeof event.attempt === "number" ? { attempt: event.attempt } : {}),
metadata: toSecurityAuditMetadata(event),
});
};

View File

@@ -107,6 +107,8 @@ export type ResolvedExecutionContext = {
export type ActorExecutionInput = {
sessionId: string;
attempt: number;
depth: number;
node: PipelineNode;
prompt: string;
context: NodeExecutionContext;
@@ -912,6 +914,8 @@ export class PipelineExecutor {
const result = await this.invokeActorExecutor({
sessionId,
attempt,
depth: recursiveDepth,
node,
prompt,
context,
@@ -1065,6 +1069,8 @@ export class PipelineExecutor {
private async invokeActorExecutor(input: {
sessionId: string;
attempt: number;
depth: number;
node: PipelineNode;
prompt: string;
context: NodeExecutionContext;
@@ -1077,12 +1083,17 @@ export class PipelineExecutor {
return await input.executor({
sessionId: input.sessionId,
attempt: input.attempt,
depth: input.depth,
node: input.node,
prompt: input.prompt,
context: input.context,
signal: input.signal,
executionContext: input.executionContext,
mcp: this.buildActorMcpContext({
sessionId: input.sessionId,
nodeId: input.node.id,
attempt: input.attempt,
executionContext: input.executionContext,
prompt: input.prompt,
}),
@@ -1207,6 +1218,9 @@ export class PipelineExecutor {
}
private buildActorMcpContext(input: {
sessionId: string;
nodeId: string;
attempt: number;
executionContext: ResolvedExecutionContext;
prompt: string;
}): ActorExecutionMcpContext {
@@ -1261,7 +1275,12 @@ export class PipelineExecutor {
};
const createToolPermissionHandler = (): ActorToolPermissionHandler =>
this.createToolPermissionHandler(executionContext.allowedTools);
this.createToolPermissionHandler({
allowedTools: executionContext.allowedTools,
sessionId: input.sessionId,
nodeId: input.nodeId,
attempt: input.attempt,
});
return {
allowedTools: [...executionContext.allowedTools],
@@ -1273,10 +1292,20 @@ export class PipelineExecutor {
};
}
private createToolPermissionHandler(allowedTools: readonly string[]): ActorToolPermissionHandler {
const allowset = new Set(allowedTools);
private createToolPermissionHandler(input: {
allowedTools: readonly string[];
sessionId: string;
nodeId: string;
attempt: number;
}): ActorToolPermissionHandler {
const allowset = new Set(input.allowedTools);
const rulesEngine = this.securityContext?.rulesEngine;
const toolPolicy = toAllowedToolPolicy(allowedTools);
const toolPolicy = toAllowedToolPolicy(input.allowedTools);
const toolAuditContext = {
sessionId: input.sessionId,
nodeId: input.nodeId,
attempt: input.attempt,
};
return async (toolName, _input, options) => {
const toolUseID = options.toolUseID;
@@ -1295,6 +1324,7 @@ export class PipelineExecutor {
rulesEngine?.assertToolInvocationAllowed({
tool: candidates[0] ?? toolName,
toolClearance: toolPolicy,
context: toolAuditContext,
});
return {
behavior: "deny",
@@ -1307,6 +1337,7 @@ export class PipelineExecutor {
rulesEngine?.assertToolInvocationAllowed({
tool: allowMatch,
toolClearance: toolPolicy,
context: toolAuditContext,
});
return {

View File

@@ -76,6 +76,11 @@ type GitExecutionResult = {
stderr: string;
};
type GitWorktreeRecord = {
path: string;
branchRef?: string;
};
function toErrorMessage(error: unknown): string {
if (error instanceof Error) {
return error.message;
@@ -198,6 +203,40 @@ function toStringLines(value: string): string[] {
.filter((line) => line.length > 0);
}
function parseGitWorktreeRecords(value: string): GitWorktreeRecord[] {
const lines = value.split("\n");
const records: GitWorktreeRecord[] = [];
let current: GitWorktreeRecord | undefined;
for (const line of lines) {
if (!line.trim()) {
if (current) {
records.push(current);
current = undefined;
}
continue;
}
if (line.startsWith("worktree ")) {
if (current) {
records.push(current);
}
current = {
path: line.slice("worktree ".length).trim(),
};
continue;
}
if (line.startsWith("branch ") && current) {
current.branchRef = line.slice("branch ".length).trim();
}
}
if (current) {
records.push(current);
}
return records;
}
export class FileSystemSessionMetadataStore {
private readonly stateRoot: string;
@@ -383,11 +422,44 @@ export class SessionWorktreeManager {
const worktreePath = maybeExisting
? assertAbsolutePath(maybeExisting, "existingWorktreePath")
: this.resolveTaskWorktreePath(input.sessionId, input.taskId);
const branchName = this.resolveTaskBranchName(input.sessionId, input.taskId);
const attachedWorktree = await this.findWorktreePathForBranch(baseWorkspacePath, branchName);
if (attachedWorktree && attachedWorktree !== worktreePath) {
throw new Error(
`Task branch "${branchName}" is already attached to worktree "${attachedWorktree}", ` +
`expected "${worktreePath}".`,
);
}
if (!(await pathExists(worktreePath))) {
await runGit(["-C", baseWorkspacePath, "worktree", "prune", "--expire", "now"]);
}
if (!(await pathExists(worktreePath))) {
await mkdir(dirname(worktreePath), { recursive: true });
const branchName = this.resolveTaskBranchName(input.sessionId, input.taskId);
await runGit(["-C", baseWorkspacePath, "worktree", "add", "-B", branchName, worktreePath, "HEAD"]);
const addResult = await runGitWithResult([
"-C",
baseWorkspacePath,
"worktree",
"add",
"-B",
branchName,
worktreePath,
"HEAD",
]);
if (addResult.exitCode !== 0) {
const attachedAfterFailure = await this.findWorktreePathForBranch(baseWorkspacePath, branchName);
if (attachedAfterFailure === worktreePath && (await pathExists(worktreePath))) {
return {
taskWorktreePath: worktreePath,
};
}
throw new Error(
`git -C ${baseWorkspacePath} worktree add -B ${branchName} ${worktreePath} HEAD failed: ` +
`${toGitFailureMessage(addResult)}`,
);
}
}
return {
@@ -687,4 +759,25 @@ export class SessionWorktreeManager {
const mergeBase = result.stdout.trim();
return mergeBase || undefined;
}
private async findWorktreePathForBranch(
repoPath: string,
branchName: string,
): Promise<string | undefined> {
const branchRef = `refs/heads/${branchName}`;
const records = await this.listWorktreeRecords(repoPath);
const matched = records.find((record) => record.branchRef === branchRef);
if (!matched) {
return undefined;
}
return resolve(matched.path);
}
private async listWorktreeRecords(repoPath: string): Promise<GitWorktreeRecord[]> {
const result = await runGitWithResult(["-C", repoPath, "worktree", "list", "--porcelain"]);
if (result.exitCode !== 0) {
return [];
}
return parseGitWorktreeRecords(result.stdout);
}
}