Publish Claude Code Binaries / Stage and publish binaries (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-arm64 (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build linux-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build windows-amd64 (push) Canceled after 0s
Sync Tutti Agent Skills / sync (push) Canceled after 0s
Windows Agent Adapters / Test Windows agent adapters (push) Canceled after 0s
Windows Daemon Adapters / Test Windows daemon adapters (push) Canceled after 0s
Publish Tutti App Runtime / Publish runtime catalog (push) Canceled after 0s
917 lines
29 KiB
JavaScript
917 lines
29 KiB
JavaScript
#!/usr/bin/env node
|
|
import { spawn } from "node:child_process";
|
|
import { once } from "node:events";
|
|
import { createWriteStream } from "node:fs";
|
|
import {
|
|
access,
|
|
mkdir,
|
|
mkdtemp,
|
|
readFile,
|
|
rm,
|
|
writeFile
|
|
} from "node:fs/promises";
|
|
import { createServer } from "node:net";
|
|
import { homedir } from "node:os";
|
|
import { basename, dirname, join, resolve } from "node:path";
|
|
import { setTimeout as delay } from "node:timers/promises";
|
|
import { fileURLToPath } from "node:url";
|
|
import {
|
|
CdpClient,
|
|
defaultTraceCategories,
|
|
resolveBrowserWebSocketUrl,
|
|
resolvePageWebSocketUrl,
|
|
writeStreamToFile
|
|
} from "./capture-electron-trace.mjs";
|
|
import {
|
|
analyzeElectronTrace,
|
|
renderElectronTraceMarkdown
|
|
} from "./analyze-electron-trace.mjs";
|
|
import { ensurePreparedDesktopLaunch } from "./prepared-desktop-launch.mjs";
|
|
import {
|
|
agentGuiPerformanceScenarios,
|
|
resolveAgentGuiPerformanceScenario
|
|
} from "./agent-gui-performance-scenarios.mjs";
|
|
import { startAllProcessTimeProfile } from "./all-process-time-profile.mjs";
|
|
|
|
const scriptDirectory = dirname(fileURLToPath(import.meta.url));
|
|
const workspaceRoot = resolve(scriptDirectory, "..", "..");
|
|
const desktopReadyTimeoutMs = 120_000;
|
|
const scenarioReadyTimeoutMs = 60_000;
|
|
const desktopStartupFailurePrefix = "[tutti-desktop-startup-failed] ";
|
|
const recoverableQueueTables = [
|
|
"workspace_agent_runtime_operation_events",
|
|
"workspace_agent_runtime_operations",
|
|
"workspace_agent_goal_reconcile_inbox",
|
|
"workspace_agent_goal_control_operations",
|
|
"workspace_agent_submit_claims"
|
|
];
|
|
|
|
if (isMainModule()) {
|
|
main(process.argv.slice(2)).catch((error) => {
|
|
process.stderr.write(`[perf:agent-gui] ${error.stack ?? error.message}\n`);
|
|
process.exitCode = 1;
|
|
});
|
|
}
|
|
|
|
export async function main(argv) {
|
|
const options = parseArgs(argv);
|
|
if (options.help) {
|
|
printUsage();
|
|
return;
|
|
}
|
|
if (options.listScenarios) {
|
|
process.stdout.write(
|
|
`${agentGuiPerformanceScenarios.map((scenario) => scenario.id).join("\n")}\n`
|
|
);
|
|
return;
|
|
}
|
|
const scenario = resolveAgentGuiPerformanceScenario(options.scenario);
|
|
const outputDirectory = resolve(
|
|
options.outputDirectory ?? defaultOutputDirectory(scenario.id, new Date())
|
|
);
|
|
const runtimeParent = join(workspaceRoot, ".tmp");
|
|
await mkdir(runtimeParent, { recursive: true });
|
|
await mkdir(outputDirectory, { recursive: true });
|
|
const runtimeDirectory = await mkdtemp(
|
|
join(runtimeParent, "agent-gui-perf-runtime-")
|
|
);
|
|
const stateDirectory = join(runtimeDirectory, "state");
|
|
const userDataDirectory = join(runtimeDirectory, "electron-user-data");
|
|
const databasePath = join(stateDirectory, "tuttid.db");
|
|
const daemonPath = join(runtimeDirectory, "tuttid");
|
|
const tracePath = join(outputDirectory, "trace.json");
|
|
const reportJSONPath = join(outputDirectory, "report.json");
|
|
const reportMarkdownPath = join(outputDirectory, "report.md");
|
|
const desktopLogPath = join(outputDirectory, "desktop.log");
|
|
const timeProfilePath = join(outputDirectory, "time-profile.trace");
|
|
let desktopProcess = null;
|
|
let pageClient = null;
|
|
let browserClient = null;
|
|
let timeProfileCapture = null;
|
|
let primaryError = null;
|
|
|
|
log(`source snapshot: ${basename(options.sourceDatabase)}`);
|
|
log(`output: ${outputDirectory}`);
|
|
try {
|
|
await access(options.sourceDatabase);
|
|
await mkdir(stateDirectory, { recursive: true });
|
|
await snapshotSQLiteDatabase(options.sourceDatabase, databasePath);
|
|
const snapshotInfo = await prepareDatabaseSnapshot(databasePath);
|
|
const scenarioSnapshot = scenario.prepareSnapshot
|
|
? await scenario.prepareSnapshot({
|
|
databasePath,
|
|
runtimeDirectory,
|
|
sqliteExec,
|
|
sqliteJSON,
|
|
workspaceRoot
|
|
})
|
|
: null;
|
|
log(
|
|
`snapshot ready: ${snapshotInfo.sessionCount} sessions, ${snapshotInfo.projectCount} projects`
|
|
);
|
|
|
|
await buildDaemon(daemonPath);
|
|
const cdpPort = await reservePort();
|
|
const desktopLaunch = await ensurePreparedDesktopLaunch({
|
|
log,
|
|
workspaceRoot
|
|
});
|
|
desktopProcess = startDesktop({
|
|
...desktopLaunch,
|
|
cdpPort,
|
|
daemonPath,
|
|
desktopLogPath,
|
|
environment: scenarioSnapshot?.environment,
|
|
stateDirectory,
|
|
userDataDirectory
|
|
});
|
|
const pageWebSocket = await waitForPageWebSocket(
|
|
cdpPort,
|
|
desktopProcess,
|
|
desktopReadyTimeoutMs
|
|
);
|
|
pageClient = await CdpClient.connect(pageWebSocket);
|
|
await pageClient.send("Runtime.enable");
|
|
browserClient = await CdpClient.connect(
|
|
await resolveBrowserWebSocketUrl(cdpPort)
|
|
);
|
|
const context = {
|
|
browserClient,
|
|
outputDirectory,
|
|
pageClient,
|
|
scenarioData: scenarioSnapshot?.data ?? null,
|
|
targetID: targetIDFromWebSocket(pageWebSocket)
|
|
};
|
|
const prepared = await scenario.prepare(context, {
|
|
fromTargetID: options.fromTargetID,
|
|
toTargetID: options.toTargetID,
|
|
timeoutMs: scenarioReadyTimeoutMs
|
|
});
|
|
log(`scenario ${scenario.id}: ${scenario.describe(prepared)}`);
|
|
|
|
if (options.allProcessTimeProfile) {
|
|
log("starting all-process Time Profiler");
|
|
timeProfileCapture = await startAllProcessTimeProfile({
|
|
cwd: workspaceRoot,
|
|
outputPath: timeProfilePath,
|
|
timeoutMs: 30_000
|
|
});
|
|
}
|
|
|
|
await browserClient.send("Tracing.start", {
|
|
categories: defaultTraceCategories,
|
|
options: "sampling-frequency=10000",
|
|
transferMode: "ReturnAsStream"
|
|
});
|
|
|
|
let scenarioResult;
|
|
let scenarioError = null;
|
|
let captureError = null;
|
|
try {
|
|
scenarioResult = await scenario.execute(context, prepared, {
|
|
timeoutMs: scenarioReadyTimeoutMs
|
|
});
|
|
} catch (error) {
|
|
scenarioError = error;
|
|
} finally {
|
|
try {
|
|
await stopTrace(browserClient, tracePath);
|
|
} catch (error) {
|
|
captureError = error;
|
|
}
|
|
if (timeProfileCapture) {
|
|
try {
|
|
await timeProfileCapture.stop();
|
|
} catch (error) {
|
|
captureError ??= error;
|
|
}
|
|
}
|
|
}
|
|
if (scenarioError) throw scenarioError;
|
|
if (captureError) throw captureError;
|
|
|
|
const summary = await analyzeElectronTrace({
|
|
tracePath,
|
|
scenario: scenario.id,
|
|
startMarker: scenario.markers.start,
|
|
endMarker: scenario.markers.end,
|
|
milestones: scenario.milestones,
|
|
minimumLongEventMs: options.minimumLongEventMs,
|
|
profileFunctionNames: scenario.profileFunctionNames,
|
|
sourceRoot: workspaceRoot
|
|
});
|
|
summary.run = {
|
|
...scenario.summarize(prepared, scenarioResult),
|
|
sourceDatabase: basename(options.sourceDatabase),
|
|
sourceSessionCount: snapshotInfo.sessionCount,
|
|
sourceProjectCount: snapshotInfo.projectCount,
|
|
clearedRecoveryRows: snapshotInfo.clearedRecoveryRows
|
|
};
|
|
if (timeProfileCapture) {
|
|
summary.run.details.push({
|
|
label: "All-process Time Profiler",
|
|
value: basename(timeProfilePath)
|
|
});
|
|
}
|
|
applyScenarioAssessment(summary, scenario.assessTrace?.(summary));
|
|
await writeFile(reportJSONPath, `${JSON.stringify(summary, null, 2)}\n`);
|
|
await writeFile(
|
|
reportMarkdownPath,
|
|
renderRunMarkdown(
|
|
summary,
|
|
renderElectronTraceMarkdown(summary, { sourceRoot: workspaceRoot })
|
|
)
|
|
);
|
|
|
|
log(
|
|
`${summary.mode}: ${summary.renders.componentMarkers} component markers, ${summary.renders.radixFamilyMarkers} Radix-family markers, ${summary.timing.longTaskCount} renderer long tasks`
|
|
);
|
|
log(`report: ${reportMarkdownPath}`);
|
|
log(`trace: ${tracePath}`);
|
|
if (timeProfileCapture) log(`time profile: ${timeProfilePath}`);
|
|
const failureReasons = performanceRunFailureReasons(summary);
|
|
if (failureReasons.length > 0) {
|
|
throw new Error(
|
|
`${scenario.id} performance gate failed: ${failureReasons.join(", ")}`
|
|
);
|
|
}
|
|
} catch (error) {
|
|
primaryError = error;
|
|
throw error;
|
|
} finally {
|
|
pageClient?.close();
|
|
browserClient?.close();
|
|
if (timeProfileCapture) {
|
|
try {
|
|
await timeProfileCapture.stop();
|
|
} catch (error) {
|
|
if (!primaryError) process.exitCode = 1;
|
|
log(`Time Profiler cleanup warning: ${error.message}`);
|
|
}
|
|
}
|
|
if (desktopProcess) {
|
|
await stopProcessTree(desktopProcess);
|
|
}
|
|
if (!options.keepRuntime) {
|
|
try {
|
|
await rm(runtimeDirectory, {
|
|
recursive: true,
|
|
force: true,
|
|
maxRetries: 10,
|
|
retryDelay: 200
|
|
});
|
|
} catch (error) {
|
|
if (!primaryError) process.exitCode = 1;
|
|
log(`cleanup warning: ${error.message}`);
|
|
}
|
|
} else {
|
|
log(`runtime kept: ${runtimeDirectory}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
export async function snapshotSQLiteDatabase(sourcePath, destinationPath) {
|
|
const escapedDestination = destinationPath.replaceAll("'", "''");
|
|
await runCommand("sqlite3", [
|
|
sourcePath,
|
|
"PRAGMA query_only=ON;",
|
|
".timeout 10000",
|
|
`.backup '${escapedDestination}'`
|
|
]);
|
|
}
|
|
|
|
export async function prepareDatabaseSnapshot(databasePath) {
|
|
const counts = await sqliteJSON(
|
|
databasePath,
|
|
`
|
|
SELECT
|
|
(SELECT COUNT(*) FROM workspace_agent_sessions) AS sessionCount,
|
|
(SELECT COUNT(*) FROM user_projects) AS projectCount;
|
|
`
|
|
);
|
|
const tableRows = await sqliteJSON(
|
|
databasePath,
|
|
`SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name;`
|
|
);
|
|
const existingTables = new Set(tableRows.map((row) => row.name));
|
|
if (existingTables.has("agent_store_schema_migrations")) {
|
|
const migrationRows = await sqliteJSON(
|
|
databasePath,
|
|
`SELECT id FROM agent_store_schema_migrations ORDER BY id;`
|
|
);
|
|
const migrationSource = await readFile(
|
|
join(workspaceRoot, "packages", "agent", "store-sqlite", "migrations.go"),
|
|
"utf8"
|
|
);
|
|
const unknownMigrationIDs = findUnknownAgentTargetMigrationIDs(
|
|
migrationRows.map((row) => row.id),
|
|
migrationSource
|
|
);
|
|
if (unknownMigrationIDs.length > 0) {
|
|
throw new Error(
|
|
`source dev DB has newer Agent target migrations (${unknownMigrationIDs.join(", ")}); SQLite migrations are forward-only, so switch to a compatible checkout or pass --source-db for a compatible snapshot`
|
|
);
|
|
}
|
|
}
|
|
const queueCounts = {};
|
|
for (const table of recoverableQueueTables) {
|
|
if (!existingTables.has(table)) continue;
|
|
const rows = await sqliteJSON(
|
|
databasePath,
|
|
`SELECT COUNT(*) AS count FROM ${table};`
|
|
);
|
|
queueCounts[table] = Number(rows[0]?.count ?? 0);
|
|
}
|
|
|
|
const startupRows = await sqliteJSON(
|
|
databasePath,
|
|
`
|
|
SELECT workspace_id AS workspaceId, snapshot_json AS snapshotJSON
|
|
FROM workspace_workbench_snapshots
|
|
WHERE workspace_id = (
|
|
SELECT id
|
|
FROM workspaces
|
|
ORDER BY COALESCE(last_opened_at_unix_ms, 0) DESC, updated_at_unix_ms DESC, id ASC
|
|
LIMIT 1
|
|
)
|
|
LIMIT 1;
|
|
`
|
|
);
|
|
const startup = startupRows[0];
|
|
if (!startup?.snapshotJSON) {
|
|
throw new Error("startup workspace has no durable Workbench snapshot");
|
|
}
|
|
const preparedSnapshot = prepareWorkbenchSnapshotForPerformance(
|
|
JSON.parse(startup.snapshotJSON)
|
|
);
|
|
const deleteStatements = recoverableQueueTables
|
|
.filter((table) => existingTables.has(table))
|
|
.map((table) => `DELETE FROM ${table};`)
|
|
.join("\n");
|
|
try {
|
|
await sqliteExec(
|
|
databasePath,
|
|
`
|
|
PRAGMA foreign_keys = ON;
|
|
${deleteStatements}
|
|
UPDATE workspace_workbench_snapshots
|
|
SET snapshot_json = '${sqlString(JSON.stringify(preparedSnapshot))}'
|
|
WHERE workspace_id = '${sqlString(startup.workspaceId)}';
|
|
`
|
|
);
|
|
} catch (error) {
|
|
throw new Error(`failed to sanitize isolated database: ${error.message}`);
|
|
}
|
|
|
|
return {
|
|
sessionCount: Number(counts[0]?.sessionCount ?? 0),
|
|
projectCount: Number(counts[0]?.projectCount ?? 0),
|
|
clearedRecoveryRows: Object.values(queueCounts).reduce(
|
|
(total, count) => total + count,
|
|
0
|
|
)
|
|
};
|
|
}
|
|
|
|
export function findUnknownAgentTargetMigrationIDs(
|
|
appliedMigrationIDs,
|
|
migrationSource
|
|
) {
|
|
const knownMigrationIDs = new Set(
|
|
[
|
|
...migrationSource.matchAll(/const schemaMigration\w+\s*=\s*"([^"]+)"/gu)
|
|
].map((match) => match[1])
|
|
);
|
|
return appliedMigrationIDs
|
|
.filter(
|
|
(migrationID) =>
|
|
migrationID.startsWith("agent_targets_") &&
|
|
!knownMigrationIDs.has(migrationID)
|
|
)
|
|
.sort();
|
|
}
|
|
|
|
export function prepareWorkbenchSnapshotForPerformance(snapshot) {
|
|
if (
|
|
!snapshot ||
|
|
typeof snapshot !== "object" ||
|
|
!Array.isArray(snapshot.nodes)
|
|
) {
|
|
throw new Error("invalid durable Workbench snapshot");
|
|
}
|
|
const nodes = snapshot.nodes.map((node) => structuredClone(node));
|
|
const candidates = nodes.filter((node) => node?.data?.typeId === "agent-gui");
|
|
const selected =
|
|
candidates.find((node) => node.id === snapshot.activeNodeId) ??
|
|
candidates.find((node) => node.isMinimized !== true) ??
|
|
candidates[0];
|
|
if (!selected) {
|
|
throw new Error("startup Workbench snapshot has no AgentGUI node");
|
|
}
|
|
const existingState =
|
|
selected.data?.snapshotNodeState &&
|
|
typeof selected.data.snapshotNodeState === "object"
|
|
? selected.data.snapshotNodeState
|
|
: {};
|
|
selected.data = {
|
|
...selected.data,
|
|
snapshotNodeState: {
|
|
...existingState,
|
|
conversationRailCollapsed: false,
|
|
lastActiveAgentSessionId: null,
|
|
lastActiveAgentSessionIdByAgentTargetId: {}
|
|
}
|
|
};
|
|
selected.isMinimized = false;
|
|
const nodeStack = Array.isArray(snapshot.nodeStack)
|
|
? snapshot.nodeStack.filter((nodeID) => nodeID !== selected.id)
|
|
: [];
|
|
nodeStack.push(selected.id);
|
|
return {
|
|
...snapshot,
|
|
nodes,
|
|
nodeStack,
|
|
activeNodeId: selected.id
|
|
};
|
|
}
|
|
|
|
export function applyScenarioAssessment(summary, assessment) {
|
|
if (!assessment) return summary;
|
|
summary.run.assertions.push(...(assessment.assertions ?? []));
|
|
summary.run.details.push(...(assessment.details ?? []));
|
|
const failedAssertions = summary.run.assertions.filter(
|
|
(assertion) => !assertion.passed
|
|
);
|
|
summary.run.outcome = failedAssertions.length === 0 ? "passed" : "failed";
|
|
summary.mode = "scenario-thresholds";
|
|
summary.verdict =
|
|
failedAssertions.length === 0
|
|
? {
|
|
status: "passed",
|
|
reason: `${summary.run.assertions.length} scenario assertions passed`
|
|
}
|
|
: {
|
|
status: "failed",
|
|
reason: `${failedAssertions.length} of ${summary.run.assertions.length} scenario assertions failed`
|
|
};
|
|
return summary;
|
|
}
|
|
|
|
export function performanceRunFailureReasons(summary) {
|
|
const failedAssertions = (summary.run?.assertions ?? [])
|
|
.filter((assertion) => !assertion.passed)
|
|
.map((assertion) => assertion.name);
|
|
if (failedAssertions.length > 0) {
|
|
return failedAssertions;
|
|
}
|
|
if (summary.verdict?.status === "failed") {
|
|
return [summary.verdict.reason ?? "trace assessment failed"];
|
|
}
|
|
return [];
|
|
}
|
|
|
|
export async function buildDaemon(outputPath) {
|
|
log("building isolated tuttid");
|
|
await runCommand(
|
|
"go",
|
|
["build", "-buildvcs=false", "-o", outputPath, "."],
|
|
join(workspaceRoot, "services", "tuttid")
|
|
);
|
|
}
|
|
|
|
export function startDesktop(input) {
|
|
const headless = input.headless !== false;
|
|
const command = input.command ?? "pnpm";
|
|
const args = input.args ?? ["dev:desktop"];
|
|
const launchMode = input.launchMode ? `${input.launchMode} ` : "";
|
|
log(
|
|
`starting ${headless ? "headless " : ""}${launchMode}isolated Desktop on CDP ${input.cdpPort}`
|
|
);
|
|
const logStream = createWriteStream(input.desktopLogPath, { flags: "w" });
|
|
const environment = { ...process.env };
|
|
if (input.launchMode === "prebuilt") {
|
|
delete environment.ELECTRON_ENTRY;
|
|
delete environment.ELECTRON_RENDERER_URL;
|
|
delete environment.NODE_ENV_ELECTRON_VITE;
|
|
}
|
|
const child = spawn(command, args, {
|
|
cwd: workspaceRoot,
|
|
detached: process.platform !== "win32",
|
|
env: {
|
|
...environment,
|
|
...input.environment,
|
|
TUTTI_ANALYTICS_DISABLED: "1",
|
|
TUTTI_DESKTOP_LOG_OUTPUT: "tee",
|
|
...(headless ? { TUTTI_DESKTOP_PERFORMANCE_HEADLESS: "1" } : {}),
|
|
TUTTI_DESKTOP_USER_DATA_DIR: input.userDataDirectory,
|
|
TUTTI_ELECTRON_JS_FLAGS: "--max-old-space-size=8192",
|
|
TUTTI_ELECTRON_REMOTE_DEBUGGING_PORT: String(input.cdpPort),
|
|
TUTTI_ENV: "development",
|
|
TUTTI_STATE_DIR: input.stateDirectory,
|
|
TUTTID_ADDR: "127.0.0.1:0",
|
|
TUTTID_BIN: input.daemonPath,
|
|
TUTTID_LOG_OUTPUT: "tee",
|
|
VITE_TUTTI_REACT_PROFILER: "0",
|
|
VITE_TUTTI_WHY_DID_YOU_RENDER: "0"
|
|
},
|
|
stdio: ["ignore", "pipe", "pipe"]
|
|
});
|
|
const tail = createBoundedLogTail();
|
|
for (const stream of [child.stdout, child.stderr]) {
|
|
stream?.on("data", (chunk) => {
|
|
logStream.write(chunk);
|
|
tail.append(chunk.toString());
|
|
});
|
|
}
|
|
child.once("close", () => logStream.end());
|
|
child.performanceLogTail = tail;
|
|
// Detached Electron descendants may leave the process group; stopProcessTree
|
|
// sweeps leftovers by isolated user-data / state dir only (not shared TUTTID_BIN).
|
|
child.userDataDirectory = input.userDataDirectory;
|
|
child.stateDirectory = input.stateDirectory;
|
|
child.daemonPath = input.daemonPath;
|
|
return child;
|
|
}
|
|
|
|
export async function waitForPageWebSocket(port, child, timeoutMs) {
|
|
const deadline = Date.now() + timeoutMs;
|
|
let lastError;
|
|
while (Date.now() < deadline) {
|
|
if (child.exitCode !== null || child.signalCode !== null) {
|
|
const startupFailure = parseDesktopStartupFailure(
|
|
child.performanceLogTail.read()
|
|
);
|
|
if (startupFailure) {
|
|
throw new Error(startupFailure.message, {
|
|
...(startupFailure.cause ? { cause: startupFailure.cause } : {})
|
|
});
|
|
}
|
|
throw new Error(
|
|
`Desktop exited before CDP became ready\n${child.performanceLogTail.read()}`
|
|
);
|
|
}
|
|
try {
|
|
return await resolvePageWebSocketUrl(port);
|
|
} catch (error) {
|
|
lastError = error;
|
|
}
|
|
await delay(250);
|
|
}
|
|
throw new Error(
|
|
`timed out waiting for Desktop CDP: ${lastError?.message ?? "unknown error"}\n${child.performanceLogTail.read()}`
|
|
);
|
|
}
|
|
|
|
export function parseDesktopStartupFailure(log) {
|
|
const lines = String(log).split(/\r?\n/u);
|
|
for (let index = lines.length - 1; index >= 0; index -= 1) {
|
|
const line = lines[index]?.trim() ?? "";
|
|
const marker = line.indexOf(desktopStartupFailurePrefix);
|
|
if (marker < 0) continue;
|
|
try {
|
|
const parsed = JSON.parse(
|
|
line.slice(marker + desktopStartupFailurePrefix.length)
|
|
);
|
|
if (typeof parsed?.message !== "string" || !parsed.message.trim()) {
|
|
continue;
|
|
}
|
|
const cause =
|
|
typeof parsed.cause?.code === "string" &&
|
|
parsed.cause.code.trim() &&
|
|
typeof parsed.cause?.message === "string" &&
|
|
parsed.cause.message.trim()
|
|
? {
|
|
code: parsed.cause.code.trim(),
|
|
message: parsed.cause.message.trim()
|
|
}
|
|
: null;
|
|
return {
|
|
...(cause ? { cause } : {}),
|
|
message: parsed.message.trim()
|
|
};
|
|
} catch {
|
|
// Ignore malformed protocol lines and retain the ordinary log fallback.
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
async function stopTrace(client, tracePath) {
|
|
const complete = client.waitForEvent("Tracing.tracingComplete");
|
|
await client.send("Tracing.end");
|
|
const event = await complete;
|
|
const stream = event.params?.stream;
|
|
if (!stream) {
|
|
throw new Error("Tracing.tracingComplete did not include a stream handle");
|
|
}
|
|
await writeStreamToFile(client, stream, tracePath);
|
|
}
|
|
|
|
async function sqliteJSON(databasePath, sql) {
|
|
const output = await runCommand("sqlite3", ["-json", databasePath, sql]);
|
|
return output.trim() ? JSON.parse(output) : [];
|
|
}
|
|
|
|
async function sqliteExec(databasePath, sql) {
|
|
await runCommand("sqlite3", [databasePath, sql]);
|
|
}
|
|
|
|
function runCommand(command, args, cwd = workspaceRoot) {
|
|
return new Promise((resolveCommand, rejectCommand) => {
|
|
const child = spawn(command, args, {
|
|
cwd,
|
|
env: process.env,
|
|
stdio: ["ignore", "pipe", "pipe"]
|
|
});
|
|
let stdout = "";
|
|
let stderr = "";
|
|
child.stdout.on("data", (chunk) => {
|
|
stdout += chunk.toString();
|
|
});
|
|
child.stderr.on("data", (chunk) => {
|
|
stderr += chunk.toString();
|
|
});
|
|
child.once("error", rejectCommand);
|
|
child.once("exit", (code, signal) => {
|
|
if (code === 0) {
|
|
resolveCommand(stdout);
|
|
} else {
|
|
rejectCommand(
|
|
new Error(
|
|
`${command} failed (${code ?? signal ?? "unknown"})${stderr.trim() ? `: ${stderr.trim()}` : ""}`
|
|
)
|
|
);
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
export async function reservePort() {
|
|
const server = createServer();
|
|
await new Promise((resolveListen, rejectListen) => {
|
|
server.once("error", rejectListen);
|
|
server.listen(0, "127.0.0.1", resolveListen);
|
|
});
|
|
const address = server.address();
|
|
const port = typeof address === "object" && address ? address.port : null;
|
|
await new Promise((resolveClose) => server.close(resolveClose));
|
|
if (!port) throw new Error("failed to reserve a CDP port");
|
|
return port;
|
|
}
|
|
|
|
export async function stopProcessTree(child) {
|
|
if (child?.pid && child.exitCode === null && child.signalCode === null) {
|
|
if (process.platform !== "win32") {
|
|
try {
|
|
process.kill(-child.pid, "SIGINT");
|
|
} catch {
|
|
try {
|
|
child.kill("SIGINT");
|
|
} catch {
|
|
// Already gone.
|
|
}
|
|
}
|
|
} else {
|
|
try {
|
|
child.kill("SIGINT");
|
|
} catch {
|
|
// Already gone.
|
|
}
|
|
}
|
|
await Promise.race([onceExit(child), delay(5_000)]);
|
|
if (child.exitCode === null && child.signalCode === null) {
|
|
if (process.platform !== "win32") {
|
|
try {
|
|
process.kill(-child.pid, "SIGKILL");
|
|
} catch {
|
|
try {
|
|
child.kill("SIGKILL");
|
|
} catch {
|
|
// Already gone.
|
|
}
|
|
}
|
|
} else {
|
|
try {
|
|
child.kill("SIGKILL");
|
|
} catch {
|
|
// Already gone.
|
|
}
|
|
}
|
|
await Promise.race([onceExit(child), delay(2_000)]);
|
|
}
|
|
}
|
|
// Detached Electron/tuttid can survive process-group signals; sweep by the
|
|
// isolated paths this Desktop was launched with.
|
|
await sweepDetachedDesktopLeftovers(child);
|
|
}
|
|
|
|
async function sweepDetachedDesktopLeftovers(child) {
|
|
if (process.platform === "win32" || typeof child !== "object" || !child) {
|
|
return;
|
|
}
|
|
// Only match run-isolated paths. Never use daemonPath: parallel record/replay
|
|
// workers share a warmed tuttids binary (TUTTID_BIN), so sweeping by that path
|
|
// SIGTERMs every concurrent managed tuttids when one Desktop exits.
|
|
const markers = [child.userDataDirectory, child.stateDirectory].filter(
|
|
(value) => typeof value === "string" && value.trim()
|
|
);
|
|
if (markers.length === 0) return;
|
|
const pids = await listPidsMatchingMarkers(markers);
|
|
for (const pid of pids) {
|
|
if (pid === process.pid || pid === child.pid) continue;
|
|
try {
|
|
process.kill(pid, "SIGTERM");
|
|
} catch {
|
|
// Already gone.
|
|
}
|
|
}
|
|
if (pids.length === 0) return;
|
|
await delay(500);
|
|
for (const pid of pids) {
|
|
if (pid === process.pid || pid === child.pid) continue;
|
|
try {
|
|
process.kill(pid, 0);
|
|
process.kill(pid, "SIGKILL");
|
|
} catch {
|
|
// Already gone.
|
|
}
|
|
}
|
|
}
|
|
|
|
async function listPidsMatchingMarkers(markers) {
|
|
const child = spawn("ps", ["-axo", "pid=,command="], {
|
|
stdio: ["ignore", "pipe", "ignore"]
|
|
});
|
|
let stdout = "";
|
|
child.stdout.setEncoding("utf8");
|
|
child.stdout.on("data", (chunk) => {
|
|
stdout += chunk;
|
|
});
|
|
try {
|
|
await once(child, "close");
|
|
} catch {
|
|
return [];
|
|
}
|
|
const pids = [];
|
|
for (const line of stdout.split("\n")) {
|
|
const trimmed = line.trim();
|
|
if (!trimmed) continue;
|
|
const match = /^(\d+)\s+(.*)$/.exec(trimmed);
|
|
if (!match) continue;
|
|
const pid = Number.parseInt(match[1], 10);
|
|
const command = match[2];
|
|
if (!Number.isSafeInteger(pid) || pid <= 0) continue;
|
|
if (!markers.some((marker) => command.includes(marker))) continue;
|
|
// Only sweep Tutti desktop / daemon leftovers, not unrelated tools that
|
|
// happen to mention a path in their argv.
|
|
if (
|
|
!/Electron|tuttid|electron-vite|dev:desktop/i.test(command) &&
|
|
!command.includes("electron-user-data")
|
|
) {
|
|
continue;
|
|
}
|
|
pids.push(pid);
|
|
}
|
|
return pids;
|
|
}
|
|
|
|
function onceExit(child) {
|
|
return new Promise((resolveExit) => child.once("exit", resolveExit));
|
|
}
|
|
|
|
function createBoundedLogTail(limit = 20_000) {
|
|
let value = "";
|
|
return {
|
|
append(chunk) {
|
|
value = (value + chunk).slice(-limit);
|
|
},
|
|
read() {
|
|
return value;
|
|
}
|
|
};
|
|
}
|
|
|
|
function renderRunMarkdown(summary, traceMarkdown) {
|
|
const runLines = [
|
|
"## Scenario contract",
|
|
"",
|
|
`- Outcome: ${summary.run.outcome}`,
|
|
`- Source DB snapshot: ${summary.run.sourceDatabase}`,
|
|
`- Source sessions: ${summary.run.sourceSessionCount}`,
|
|
`- Source projects: ${summary.run.sourceProjectCount}`,
|
|
...summary.run.details.map(
|
|
(detail) => `- ${detail.label}: ${detail.value}`
|
|
),
|
|
`- Stability criterion: ${summary.run.stabilityCriterion}`,
|
|
`- Recovery rows removed from isolated copy: ${summary.run.clearedRecoveryRows}`,
|
|
"",
|
|
"| Assertion | Result |",
|
|
"| --- | --- |",
|
|
...summary.run.assertions.map(
|
|
(assertion) =>
|
|
`| ${assertion.name} | ${assertion.passed ? "pass" : "fail"} |`
|
|
),
|
|
""
|
|
].join("\n");
|
|
return traceMarkdown.replace(
|
|
"## Phase timings",
|
|
`${runLines}\n## Phase timings`
|
|
);
|
|
}
|
|
|
|
function sqlString(value) {
|
|
return String(value).replaceAll("'", "''");
|
|
}
|
|
|
|
function defaultOutputDirectory(scenarioID, date) {
|
|
const stamp = date
|
|
.toISOString()
|
|
.replaceAll(":", "")
|
|
.replaceAll("-", "")
|
|
.replace(/\.\d{3}Z$/u, "Z");
|
|
return join(workspaceRoot, ".tmp", "perf", "agent-gui", scenarioID, stamp);
|
|
}
|
|
|
|
function parseArgs(argv) {
|
|
const options = {
|
|
sourceDatabase: join(homedir(), ".tutti-dev", "tuttid.db"),
|
|
minimumLongEventMs: 16,
|
|
scenario: "provider-switch"
|
|
};
|
|
for (let index = 0; index < argv.length; index += 1) {
|
|
const arg = argv[index];
|
|
if (arg === "--") continue;
|
|
if (arg === "--help" || arg === "-h") {
|
|
options.help = true;
|
|
} else if (arg === "--source-db") {
|
|
options.sourceDatabase = resolve(requiredValue(argv, (index += 1), arg));
|
|
} else if (arg === "--output") {
|
|
options.outputDirectory = requiredValue(argv, (index += 1), arg);
|
|
} else if (arg === "--scenario") {
|
|
options.scenario = requiredValue(argv, (index += 1), arg);
|
|
} else if (arg === "--list-scenarios") {
|
|
options.listScenarios = true;
|
|
} else if (arg === "--from-target-id") {
|
|
options.fromTargetID = requiredValue(argv, (index += 1), arg);
|
|
} else if (arg === "--to-target-id") {
|
|
options.toTargetID = requiredValue(argv, (index += 1), arg);
|
|
} else if (arg === "--min-ms") {
|
|
options.minimumLongEventMs = positiveNumber(
|
|
requiredValue(argv, (index += 1), arg),
|
|
arg
|
|
);
|
|
} else if (arg === "--keep-runtime") {
|
|
options.keepRuntime = true;
|
|
} else if (arg === "--all-process-time-profile") {
|
|
options.allProcessTimeProfile = true;
|
|
} else {
|
|
throw new Error(`unknown option: ${arg}`);
|
|
}
|
|
}
|
|
return options;
|
|
}
|
|
|
|
function requiredValue(argv, index, option) {
|
|
const value = argv[index];
|
|
if (!value || value.startsWith("--")) {
|
|
throw new Error(`${option} requires a value`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function positiveNumber(value, option) {
|
|
const number = Number(value);
|
|
if (!Number.isFinite(number) || number <= 0) {
|
|
throw new Error(`${option} must be a positive number`);
|
|
}
|
|
return number;
|
|
}
|
|
|
|
function isMainModule() {
|
|
return process.argv[1]
|
|
? fileURLToPath(import.meta.url) === resolve(process.argv[1])
|
|
: false;
|
|
}
|
|
|
|
function targetIDFromWebSocket(webSocketURL) {
|
|
const targetID = new URL(webSocketURL).pathname
|
|
.split("/")
|
|
.filter(Boolean)
|
|
.at(-1);
|
|
if (!targetID) throw new Error("could not resolve CDP page target id");
|
|
return targetID;
|
|
}
|
|
|
|
function log(message) {
|
|
process.stderr.write(`[perf:agent-gui] ${message}\n`);
|
|
}
|
|
|
|
function printUsage() {
|
|
process.stdout.write(
|
|
`Run an isolated, report-only AgentGUI performance scenario.\n\nUsage:\n pnpm perf:agent-gui\n pnpm perf:agent-gui -- --scenario session-switch\n pnpm perf:agent-gui -- --list-scenarios\n\nOptions:\n --scenario <id> Scenario. Default: provider-switch\n --list-scenarios Print available scenario ids\n --source-db <path> Source dev DB. Default: ~/.tutti-dev/tuttid.db\n --output <directory> Report/trace output directory under .tmp by default\n --from-target-id <id> Source Agent target for provider scenarios\n --to-target-id <id> Target Agent target for provider scenarios\n --min-ms <milliseconds> Long-event threshold. Default: 16\n --all-process-time-profile\n Also record macOS Time Profiler for all processes\n --keep-runtime Keep isolated DB and Electron userData for debugging\n`
|
|
);
|
|
process.stdout.write(
|
|
"\n--min-ms applies to renderer-main RunTask duration, not all trace events.\n"
|
|
);
|
|
}
|