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
369 lines
11 KiB
JavaScript
369 lines
11 KiB
JavaScript
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
import { dirname, join, relative, resolve } from "node:path";
|
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
|
|
const scriptDirectory = dirname(fileURLToPath(import.meta.url));
|
|
const defaultWorkspaceRoot = resolve(scriptDirectory, "../..");
|
|
const workspaceRoot = process.env.TUTTI_WORKSPACE_ROOT ?? defaultWorkspaceRoot;
|
|
const ts = await loadTypeScriptModule();
|
|
const agentGuiRoot = resolve(workspaceRoot, "packages/agent/gui");
|
|
const desktopRendererRoot = resolve(
|
|
workspaceRoot,
|
|
"apps/desktop/src/renderer/src/features"
|
|
);
|
|
|
|
const allowedFiles = new Set([
|
|
"packages/agent/gui/host/agentHostApi.ts",
|
|
"packages/agent/gui/shared/roomShare.ts"
|
|
]);
|
|
|
|
const ignoredPathFragments = [
|
|
"/app/preload/types/",
|
|
"/dist/",
|
|
"/host/",
|
|
"/node_modules/",
|
|
"/shared/contracts/dto/"
|
|
];
|
|
|
|
const ignoredFilenamePatterns = [
|
|
/\.spec\.[cm]?[tj]sx?$/,
|
|
/\.test\.[cm]?[tj]sx?$/,
|
|
/vitest\./
|
|
];
|
|
|
|
const forbiddenPatterns = [
|
|
{
|
|
label: "workspaceAgents.*",
|
|
pattern: /\bworkspaceAgents\s*(?:\?\.)?\s*\.\s*[A-Za-z_$][\w$]*/g
|
|
},
|
|
{
|
|
label: "legacy compat host helper",
|
|
pattern:
|
|
/\b(?:listWorkspaceAgentsViaCompatHost|listWorkspaceAgentSessionMessagesViaCompatHost|retainAgentSessionEventsViaCompatHost|releaseAgentSessionEventsViaCompatHost|subscribeAgentSessionEventsViaCompatHost)\b/g
|
|
},
|
|
{
|
|
label: "agentSessions legacy write API",
|
|
pattern:
|
|
/\bagentSessions\s*(?:\?\.)?\s*\.\s*(?:exec|cancel|submitInteractive|pinSession)\b/g
|
|
},
|
|
{
|
|
label: "agentSessions.retainEventStream",
|
|
pattern: /agentSessions\s*(?:\?\.)?\s*\.\s*retainEventStream\b/g
|
|
},
|
|
{
|
|
label: "agentSessions.subscribeEvents",
|
|
pattern: /agentSessions\s*(?:\?\.)?\s*\.\s*subscribeEvents\b/g
|
|
},
|
|
{
|
|
label: "AgentHostWorkspaceAgent*",
|
|
pattern: /\bAgentHostWorkspaceAgent[A-Za-z0-9_]*/g
|
|
},
|
|
{
|
|
label: "legacy workspaceAgentActivityTypes aggregate",
|
|
pattern: /\bworkspaceAgentActivityTypes\b/g
|
|
},
|
|
{
|
|
label: "legacy WorkspaceAgentActivity session mirror",
|
|
pattern: /\bWorkspaceAgentActivity(?:Session|Snapshot|Presence)\b/g
|
|
},
|
|
{
|
|
label: "legacy module-global activity runtime resolver",
|
|
pattern:
|
|
/\b(?:getAgentActivityRuntime|getAgentActivityRuntimeByOrigin|getOptionalAgentActivityRuntime)\b/g
|
|
},
|
|
{
|
|
allowedFiles: new Set([
|
|
"apps/desktop/src/renderer/src/features/workspace-agent/services/internal/workspaceAgentActivityDiagnostics.ts",
|
|
// Side is an explicitly non-canonical transient projection. It consumes
|
|
// the runtime Side envelope directly and must not enter
|
|
// AgentSessionEngine merely to satisfy the durable lifecycle vocabulary
|
|
// guard enforced for ordinary AgentGUI consumers.
|
|
"packages/agent/gui/agentSideConversationController.ts",
|
|
// The Side adapter normalizes the same transient envelope into the pure
|
|
// ephemeral projector before shared conversation rendering.
|
|
"packages/agent/gui/agentSideConversationProjection.ts"
|
|
]),
|
|
label: "deprecated session lifecycle decision read",
|
|
pattern:
|
|
/\b[A-Za-z_$][\w$]*\s*(?:\?\.)?\s*\.\s*(?:currentPhase|effectiveStatus|lifecycleStatus|pendingInteractive|submitAvailability|turnLifecycle|turnPhase)\b/g
|
|
},
|
|
{
|
|
label: "direct AgentSessionEngine entity storage access",
|
|
pattern:
|
|
/\b(?:sessionLifecycle\s*\.\s*(?:sessionsById|turnsById|interactionsById)|pendingIntents\s*\.\s*[A-Za-z_$][\w$]*By[A-Za-z_$][\w$]*|promptQueue\s*\.\s*recordsBySessionId)\b/g
|
|
},
|
|
{
|
|
label: "legacy roomId",
|
|
pattern: /\broomId\b/g,
|
|
scope: "agent-gui-production"
|
|
},
|
|
{
|
|
label: "legacy room-agent naming",
|
|
pattern:
|
|
/\b(?:roomAgents|roomAgent|RoomAgent|AgentHostRoomAgent[A-Za-z0-9_]*)\b/g,
|
|
scope: "agent-gui-production"
|
|
}
|
|
];
|
|
|
|
const violations = [];
|
|
const scannedFiles = [agentGuiRoot, desktopRendererRoot]
|
|
.filter(existsSync)
|
|
.flatMap((scanRoot) => [...walk(scanRoot)])
|
|
.filter((filePath) => {
|
|
const relativePath = relative(workspaceRoot, filePath);
|
|
return isScannedSourceFile(relativePath) && !allowedFiles.has(relativePath);
|
|
});
|
|
const program = ts.createProgram({
|
|
rootNames: scannedFiles,
|
|
options: {
|
|
allowJs: true,
|
|
checkJs: false,
|
|
jsx: ts.JsxEmit.Preserve,
|
|
noResolve: true,
|
|
skipLibCheck: true,
|
|
target: ts.ScriptTarget.Latest
|
|
}
|
|
});
|
|
const typeChecker = program.getTypeChecker();
|
|
|
|
for (const filePath of scannedFiles) {
|
|
const relativePath = relative(workspaceRoot, filePath);
|
|
const source = readFileSync(filePath, "utf8");
|
|
if (relativePath.startsWith("apps/desktop/src/renderer/src/features/")) {
|
|
const sourceFile = program.getSourceFile(filePath);
|
|
if (!sourceFile) {
|
|
throw new Error(`Unable to parse ${relativePath}`);
|
|
}
|
|
for (const index of findDirectSessionEngineSubscriptions(
|
|
sourceFile,
|
|
typeChecker
|
|
)) {
|
|
violations.push({
|
|
column: columnNumber(source, index),
|
|
file: relativePath,
|
|
label: "direct AgentSessionEngine useSyncExternalStore subscription",
|
|
line: lineNumber(source, index)
|
|
});
|
|
}
|
|
}
|
|
for (const rule of forbiddenPatterns) {
|
|
if (!isRuleInScope(rule, relativePath)) {
|
|
continue;
|
|
}
|
|
for (const match of source.matchAll(rule.pattern)) {
|
|
violations.push({
|
|
column: columnNumber(source, match.index ?? 0),
|
|
file: relativePath,
|
|
label: rule.label,
|
|
line: lineNumber(source, match.index ?? 0)
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
if (violations.length > 0) {
|
|
process.stderr.write(
|
|
"Agent consumers must use AgentGUIRuntime and AgentSessionEngine selectors.\n"
|
|
);
|
|
process.stderr.write(
|
|
"Move legacy AgentHostWorkspaceAgent access into host compatibility or projection boundary files.\n\n"
|
|
);
|
|
for (const violation of violations) {
|
|
process.stderr.write(
|
|
`- ${violation.file}:${violation.line}:${violation.column} uses ${violation.label}\n`
|
|
);
|
|
}
|
|
process.exitCode = 1;
|
|
} else {
|
|
console.log("Agent GUI runtime boundary check passed");
|
|
}
|
|
|
|
function* walk(directory) {
|
|
for (const entry of readdirSync(directory)) {
|
|
const filePath = resolve(directory, entry);
|
|
const stat = statSync(filePath);
|
|
if (stat.isDirectory()) {
|
|
yield* walk(filePath);
|
|
continue;
|
|
}
|
|
yield filePath;
|
|
}
|
|
}
|
|
|
|
function isScannedSourceFile(relativePath) {
|
|
if (!/\.[cm]?[tj]sx?$/.test(relativePath)) {
|
|
return false;
|
|
}
|
|
const normalized = `/${relativePath}`;
|
|
if (ignoredPathFragments.some((fragment) => normalized.includes(fragment))) {
|
|
return false;
|
|
}
|
|
return !ignoredFilenamePatterns.some((pattern) => pattern.test(relativePath));
|
|
}
|
|
|
|
function isRuleInScope(rule, relativePath) {
|
|
if (rule.allowedFiles?.has(relativePath)) {
|
|
return false;
|
|
}
|
|
if (rule.scope === "agent-gui-production") {
|
|
return relativePath.startsWith("packages/agent/gui/agent-gui/");
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function findDirectSessionEngineSubscriptions(sourceFile, typeChecker) {
|
|
const positions = [];
|
|
|
|
const visit = (node) => {
|
|
if (
|
|
ts.isCallExpression(node) &&
|
|
isUseSyncExternalStoreCall(node.expression) &&
|
|
node.arguments.some((argument) =>
|
|
expressionDependsOnSessionEngine(
|
|
argument,
|
|
typeChecker,
|
|
new Set(),
|
|
new Set()
|
|
)
|
|
)
|
|
) {
|
|
positions.push(node.expression.getStart(sourceFile));
|
|
}
|
|
ts.forEachChild(node, visit);
|
|
};
|
|
|
|
visit(sourceFile);
|
|
return positions;
|
|
}
|
|
|
|
function isUseSyncExternalStoreCall(expression) {
|
|
return (
|
|
(ts.isIdentifier(expression) &&
|
|
expression.text === "useSyncExternalStore") ||
|
|
(ts.isPropertyAccessExpression(expression) &&
|
|
expression.name.text === "useSyncExternalStore")
|
|
);
|
|
}
|
|
|
|
function expressionDependsOnSessionEngine(
|
|
node,
|
|
typeChecker,
|
|
visitedNodes,
|
|
visitedSymbols
|
|
) {
|
|
if (visitedNodes.has(node)) {
|
|
return false;
|
|
}
|
|
visitedNodes.add(node);
|
|
|
|
if (ts.isCallExpression(node) && isGetSessionEngineCall(node.expression)) {
|
|
return true;
|
|
}
|
|
|
|
if (ts.isIdentifier(node) && !isPropertyNameIdentifier(node)) {
|
|
const symbol = typeChecker.getSymbolAtLocation(node);
|
|
if (symbol && !visitedSymbols.has(symbol)) {
|
|
visitedSymbols.add(symbol);
|
|
for (const declaration of symbol.declarations ?? []) {
|
|
const valueNode = declarationValueNode(declaration);
|
|
if (
|
|
valueNode &&
|
|
expressionDependsOnSessionEngine(
|
|
valueNode,
|
|
typeChecker,
|
|
visitedNodes,
|
|
visitedSymbols
|
|
)
|
|
) {
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
let dependsOnSessionEngine = false;
|
|
ts.forEachChild(node, (child) => {
|
|
if (
|
|
!dependsOnSessionEngine &&
|
|
expressionDependsOnSessionEngine(
|
|
child,
|
|
typeChecker,
|
|
visitedNodes,
|
|
visitedSymbols
|
|
)
|
|
) {
|
|
dependsOnSessionEngine = true;
|
|
}
|
|
});
|
|
return dependsOnSessionEngine;
|
|
}
|
|
|
|
function isGetSessionEngineCall(expression) {
|
|
return (
|
|
(ts.isIdentifier(expression) && expression.text === "getSessionEngine") ||
|
|
(ts.isPropertyAccessExpression(expression) &&
|
|
expression.name.text === "getSessionEngine")
|
|
);
|
|
}
|
|
|
|
function isPropertyNameIdentifier(node) {
|
|
return (
|
|
(ts.isPropertyAccessExpression(node.parent) && node.parent.name === node) ||
|
|
(ts.isPropertyAssignment(node.parent) && node.parent.name === node) ||
|
|
(ts.isMethodDeclaration(node.parent) && node.parent.name === node)
|
|
);
|
|
}
|
|
|
|
function declarationValueNode(declaration) {
|
|
if (
|
|
ts.isVariableDeclaration(declaration) ||
|
|
ts.isBindingElement(declaration) ||
|
|
ts.isParameter(declaration) ||
|
|
ts.isPropertyDeclaration(declaration) ||
|
|
ts.isPropertyAssignment(declaration)
|
|
) {
|
|
return declaration.initializer ?? null;
|
|
}
|
|
if (
|
|
ts.isFunctionDeclaration(declaration) ||
|
|
ts.isMethodDeclaration(declaration) ||
|
|
ts.isGetAccessorDeclaration(declaration)
|
|
) {
|
|
return declaration.body ?? null;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function lineNumber(source, index) {
|
|
return source.slice(0, index).split("\n").length;
|
|
}
|
|
|
|
function columnNumber(source, index) {
|
|
const previousNewline = source.lastIndexOf("\n", index - 1);
|
|
return index - previousNewline;
|
|
}
|
|
|
|
async function loadTypeScriptModule() {
|
|
const candidateRoots = [workspaceRoot, defaultWorkspaceRoot];
|
|
const candidatePaths = candidateRoots.flatMap((root) => [
|
|
join(root, "node_modules/typescript/lib/typescript.js"),
|
|
join(root, "apps/desktop/node_modules/typescript/lib/typescript.js"),
|
|
join(
|
|
root,
|
|
"packages/clients/tuttid-ts/node_modules/typescript/lib/typescript.js"
|
|
)
|
|
]);
|
|
|
|
for (const candidatePath of candidatePaths) {
|
|
if (!existsSync(candidatePath)) {
|
|
continue;
|
|
}
|
|
const module = await import(pathToFileURL(candidatePath).href);
|
|
return module.default ?? module;
|
|
}
|
|
|
|
throw new Error(
|
|
"Unable to locate a TypeScript runtime for check-agent-activity-runtime-boundaries.mjs"
|
|
);
|
|
}
|