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
561 lines
15 KiB
JavaScript
561 lines
15 KiB
JavaScript
import { spawn } from "node:child_process";
|
|
import { createHash, randomUUID } from "node:crypto";
|
|
import {
|
|
access,
|
|
cp,
|
|
mkdir,
|
|
mkdtemp,
|
|
readFile,
|
|
rm,
|
|
stat,
|
|
writeFile
|
|
} from "node:fs/promises";
|
|
import { homedir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { setTimeout as delay } from "node:timers/promises";
|
|
import { buildDaemon, stopProcessTree } from "../run-agent-gui-performance.mjs";
|
|
|
|
const hostAccountAuthEnvironment =
|
|
"TUTTI_AGENT_SESSION_REPLAY_HOST_ACCOUNT_AUTH";
|
|
const skipHostAccountAuthEnvironment =
|
|
"TUTTI_AGENT_SESSION_REPLAY_SKIP_HOST_ACCOUNT_AUTH";
|
|
|
|
export const replayListenerInfoPath = (stateDirectory) =>
|
|
join(stateDirectory, "run", "tuttid.listener.json");
|
|
|
|
const timingPrefix = "[tutti-agent-session-replay-timing] ";
|
|
|
|
export function logTiming(phase, durationMs, detail = undefined) {
|
|
const payload = {
|
|
detail: detail ?? null,
|
|
durationMs: Math.max(0, Math.round(durationMs)),
|
|
phase,
|
|
source: "tutti",
|
|
startedAt: new Date(
|
|
Date.now() - Math.max(0, Math.round(durationMs))
|
|
).toISOString()
|
|
};
|
|
process.stderr.write(`${timingPrefix}${JSON.stringify(payload)}\n`);
|
|
}
|
|
|
|
export async function measureTiming(phase, run, detail = undefined) {
|
|
const started = performance.now();
|
|
try {
|
|
return await run();
|
|
} finally {
|
|
logTiming(phase, performance.now() - started, detail);
|
|
}
|
|
}
|
|
|
|
export async function createRuntime(workspaceRoot, mode) {
|
|
return measureTiming(
|
|
"create-runtime",
|
|
async () => {
|
|
const runtimeParent =
|
|
process.env.TUTTI_AGENT_SESSION_REPLAY_RUNTIME_PARENT?.trim() ||
|
|
join(workspaceRoot, ".tmp");
|
|
await mkdir(runtimeParent, { recursive: true });
|
|
const directory = await mkdtemp(
|
|
join(runtimeParent, `agent-session-${mode}-`)
|
|
);
|
|
const stateDirectory = join(directory, "state");
|
|
const userDataDirectory = join(directory, "electron-user-data");
|
|
const daemonPath = join(directory, "tuttid");
|
|
await mkdir(stateDirectory, { recursive: true });
|
|
const preparedDaemonPath =
|
|
process.env.TUTTI_AGENT_SESSION_REPLAY_DAEMON_EXECUTABLE?.trim();
|
|
if (preparedDaemonPath) {
|
|
await access(preparedDaemonPath);
|
|
logTiming("create-runtime.reuse-prepared-daemon", 0, {
|
|
daemonPath: preparedDaemonPath,
|
|
mode
|
|
});
|
|
} else {
|
|
await measureTiming("create-runtime.generate-builtin-apps", () =>
|
|
runCommand("pnpm", ["generate:builtin-apps"], workspaceRoot)
|
|
);
|
|
await measureTiming("create-runtime.build-daemon", () =>
|
|
buildDaemon(daemonPath)
|
|
);
|
|
}
|
|
return {
|
|
daemonPath: preparedDaemonPath || daemonPath,
|
|
directory,
|
|
stateDirectory,
|
|
userDataDirectory
|
|
};
|
|
},
|
|
{ mode }
|
|
);
|
|
}
|
|
|
|
export function managedDesktopLaunch() {
|
|
const command =
|
|
process.env.TUTTI_AGENT_SESSION_REPLAY_ELECTRON_EXECUTABLE?.trim();
|
|
if (!command) {
|
|
throw new Error("managed replay Electron executable is unavailable");
|
|
}
|
|
const entry = process.env.TUTTI_AGENT_SESSION_REPLAY_ELECTRON_ENTRY?.trim();
|
|
return {
|
|
args: entry ? [entry] : [],
|
|
command
|
|
};
|
|
}
|
|
|
|
export function preparedDesktopLaunch() {
|
|
const command =
|
|
process.env.TUTTI_AGENT_SESSION_REPLAY_ELECTRON_EXECUTABLE?.trim();
|
|
if (!command) return undefined;
|
|
const entry = process.env.TUTTI_AGENT_SESSION_REPLAY_ELECTRON_ENTRY?.trim();
|
|
if (!entry) {
|
|
throw new Error("prepared replay Electron entry is unavailable");
|
|
}
|
|
return { args: [entry], command };
|
|
}
|
|
|
|
export async function initializeCleanDatabase(
|
|
workspaceRoot,
|
|
runtime,
|
|
workspaceId,
|
|
{ seedWorkspace = true } = {}
|
|
) {
|
|
return measureTiming(
|
|
"initialize-clean-database",
|
|
async () => {
|
|
const databasePath = join(runtime.stateDirectory, "tuttid.db");
|
|
const templatePath = await ensureMigratedDatabaseTemplate(
|
|
workspaceRoot,
|
|
runtime.daemonPath
|
|
);
|
|
await measureTiming("initialize-clean-database.copy-template", () =>
|
|
cp(templatePath, databasePath)
|
|
);
|
|
await measureTiming(
|
|
"initialize-clean-database.seed",
|
|
() =>
|
|
seedCleanDatabase(
|
|
workspaceRoot,
|
|
databasePath,
|
|
workspaceId,
|
|
seedWorkspace
|
|
),
|
|
{ seedWorkspace }
|
|
);
|
|
// Isolated runtimes start without Tutti account session; reuse the local
|
|
// desktop login when present so connector-market / host-auth paths work.
|
|
const hostAccountStarted = performance.now();
|
|
const hostAccount = await seedHostAccountSession(runtime.stateDirectory);
|
|
logTiming(
|
|
"initialize-clean-database.seed-host-account",
|
|
performance.now() - hostAccountStarted,
|
|
hostAccount
|
|
);
|
|
},
|
|
{ seedWorkspace, workspaceId }
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Copy the developer machine Tutti account session into an isolated replay
|
|
* state dir when available. Missing/invalid host auth is a soft skip so CI
|
|
* and machines without a login still boot.
|
|
*/
|
|
export async function seedHostAccountSession(
|
|
stateDirectory,
|
|
{ env = process.env, homeDirectory = homedir() } = {}
|
|
) {
|
|
if (isTruthyEnv(env[skipHostAccountAuthEnvironment])) {
|
|
return { action: "skipped-disabled" };
|
|
}
|
|
|
|
const sourcePath = resolveHostAccountAuthPath(env, homeDirectory);
|
|
let raw;
|
|
try {
|
|
raw = await readFile(sourcePath, "utf8");
|
|
} catch (error) {
|
|
if (error?.code === "ENOENT") {
|
|
return { action: "skipped-missing", sourcePath };
|
|
}
|
|
throw error;
|
|
}
|
|
|
|
let session;
|
|
try {
|
|
session = JSON.parse(raw);
|
|
} catch {
|
|
return { action: "skipped-invalid", sourcePath };
|
|
}
|
|
if (
|
|
typeof session?.session_id !== "string" ||
|
|
!session.session_id.trim() ||
|
|
typeof session?.cookie !== "string" ||
|
|
!session.cookie.trim()
|
|
) {
|
|
return { action: "skipped-invalid", sourcePath };
|
|
}
|
|
|
|
const accountDirectory = join(stateDirectory, "account");
|
|
const destinationPath = join(accountDirectory, "auth.json");
|
|
await mkdir(accountDirectory, { recursive: true });
|
|
await writeFile(destinationPath, `${JSON.stringify(session, null, 2)}\n`, {
|
|
mode: 0o600
|
|
});
|
|
return { action: "copied", sourcePath, destinationPath };
|
|
}
|
|
|
|
export function resolveHostAccountAuthPath(
|
|
env = process.env,
|
|
homeDirectory = homedir()
|
|
) {
|
|
const override = env[hostAccountAuthEnvironment]?.trim();
|
|
if (override) {
|
|
return override;
|
|
}
|
|
// Replay Desktop always sets TUTTI_ENV=development, whose DefaultStateDir is
|
|
// ~/.tutti-dev — mirror that when picking the host session to reuse.
|
|
return join(homeDirectory, ".tutti-dev", "account", "auth.json");
|
|
}
|
|
|
|
function isTruthyEnv(value) {
|
|
if (value === undefined || value === null) {
|
|
return false;
|
|
}
|
|
const normalized = String(value).trim().toLowerCase();
|
|
return normalized !== "" && normalized !== "0" && normalized !== "false";
|
|
}
|
|
|
|
async function ensureMigratedDatabaseTemplate(workspaceRoot, daemonPath) {
|
|
const daemonStat = await stat(daemonPath);
|
|
// Prepared daemon binaries can change mtime when first executed (or when a
|
|
// dirty warmup slot is rewritten). Prefer an explicit stamp / path+size so
|
|
// consecutive ui-drive cases reuse the migrated template.
|
|
const preparedStamp =
|
|
process.env.TUTTI_AGENT_SESSION_REPLAY_DB_TEMPLATE_STAMP?.trim();
|
|
const stampHasher = createHash("sha1")
|
|
.update(daemonPath)
|
|
.update(String(daemonStat.size));
|
|
if (preparedStamp) {
|
|
stampHasher.update(preparedStamp);
|
|
} else if (
|
|
!process.env.TUTTI_AGENT_SESSION_REPLAY_DAEMON_EXECUTABLE?.trim()
|
|
) {
|
|
stampHasher.update(String(Math.trunc(daemonStat.mtimeMs)));
|
|
}
|
|
const stamp = stampHasher.digest("hex").slice(0, 16);
|
|
const templateRoot =
|
|
process.env.TUTTI_AGENT_SESSION_REPLAY_DB_TEMPLATE_ROOT?.trim() ||
|
|
join(workspaceRoot, ".tmp", "agent-session-replay-db-templates");
|
|
const templateDirectory = join(templateRoot, stamp);
|
|
const templatePath = join(templateDirectory, "tuttid.db");
|
|
try {
|
|
await access(templatePath);
|
|
logTiming("initialize-clean-database.reuse-template", 0, {
|
|
stamp,
|
|
templatePath
|
|
});
|
|
return templatePath;
|
|
} catch {
|
|
// build below
|
|
}
|
|
|
|
await mkdir(templateRoot, { recursive: true });
|
|
let ownsBuild = false;
|
|
try {
|
|
await mkdir(templateDirectory);
|
|
ownsBuild = true;
|
|
} catch (error) {
|
|
if (error?.code !== "EEXIST") throw error;
|
|
await waitForDatabaseTemplate(templatePath);
|
|
logTiming("initialize-clean-database.reuse-template", 0, {
|
|
stamp,
|
|
templatePath,
|
|
waited: true
|
|
});
|
|
return templatePath;
|
|
}
|
|
|
|
return measureTiming(
|
|
"initialize-clean-database.build-template",
|
|
async () => {
|
|
const stagingParent = join(templateRoot, ".staging");
|
|
await mkdir(stagingParent, { recursive: true });
|
|
const stagingDirectory = await mkdtemp(
|
|
join(stagingParent, `migrate-${stamp}-`)
|
|
);
|
|
const stagingStateDirectory = join(stagingDirectory, "state");
|
|
await mkdir(stagingStateDirectory, { recursive: true });
|
|
try {
|
|
await migrateEmptyDatabase(
|
|
workspaceRoot,
|
|
daemonPath,
|
|
stagingStateDirectory
|
|
);
|
|
const stagingDb = join(stagingStateDirectory, "tuttid.db");
|
|
await access(stagingDb);
|
|
await cp(stagingDb, templatePath);
|
|
} catch (error) {
|
|
if (ownsBuild) {
|
|
await rm(templateDirectory, {
|
|
force: true,
|
|
recursive: true,
|
|
maxRetries: 10,
|
|
retryDelay: 200
|
|
});
|
|
}
|
|
throw error;
|
|
} finally {
|
|
await rm(stagingDirectory, {
|
|
force: true,
|
|
recursive: true,
|
|
maxRetries: 10,
|
|
retryDelay: 200
|
|
});
|
|
}
|
|
return templatePath;
|
|
},
|
|
{ stamp }
|
|
);
|
|
}
|
|
|
|
async function waitForDatabaseTemplate(templatePath, timeoutMs = 120_000) {
|
|
const deadline = Date.now() + timeoutMs;
|
|
while (Date.now() < deadline) {
|
|
try {
|
|
await access(templatePath);
|
|
return;
|
|
} catch {
|
|
await delay(100);
|
|
}
|
|
}
|
|
throw new Error(
|
|
`timed out waiting for migrated database template: ${templatePath}`
|
|
);
|
|
}
|
|
|
|
async function migrateEmptyDatabase(workspaceRoot, daemonPath, stateDirectory) {
|
|
const listenerInfoPath = replayListenerInfoPath(stateDirectory);
|
|
const daemon = spawn(daemonPath, [], {
|
|
cwd: workspaceRoot,
|
|
detached: process.platform !== "win32",
|
|
env: {
|
|
...process.env,
|
|
TUTTI_ANALYTICS_DISABLED: "1",
|
|
TUTTI_ENV: "development",
|
|
TUTTI_STATE_DIR: stateDirectory,
|
|
TUTTID_ADDR: "127.0.0.1:0",
|
|
TUTTID_ACCESS_TOKEN: randomUUID()
|
|
},
|
|
stdio: ["ignore", "pipe", "pipe"]
|
|
});
|
|
let daemonStdout = "";
|
|
let daemonStderr = "";
|
|
daemon.stdout.on("data", (chunk) => {
|
|
daemonStdout += chunk.toString();
|
|
});
|
|
daemon.stderr.on("data", (chunk) => {
|
|
daemonStderr += chunk.toString();
|
|
});
|
|
try {
|
|
const deadline = Date.now() + 60_000;
|
|
while (Date.now() < deadline) {
|
|
if (daemon.exitCode !== null || daemon.signalCode !== null) {
|
|
throw new Error(
|
|
`tuttid exited while initializing a clean database: ${(
|
|
daemonStderr.trim() ||
|
|
daemonStdout.trim() ||
|
|
daemon.signalCode ||
|
|
daemon.exitCode
|
|
)
|
|
.toString()
|
|
.slice(-4_000)}`
|
|
);
|
|
}
|
|
try {
|
|
await access(listenerInfoPath);
|
|
break;
|
|
} catch {
|
|
await delay(50);
|
|
}
|
|
}
|
|
await access(listenerInfoPath);
|
|
} finally {
|
|
await stopProcessTree(daemon);
|
|
}
|
|
}
|
|
|
|
async function seedCleanDatabase(
|
|
workspaceRoot,
|
|
databasePath,
|
|
workspaceId,
|
|
seedWorkspace
|
|
) {
|
|
const now = Date.now();
|
|
const workbenchSnapshot = replayWorkbenchSnapshot(
|
|
new Date(now).toISOString()
|
|
);
|
|
await runCommand(
|
|
"sqlite3",
|
|
[
|
|
databasePath,
|
|
`
|
|
PRAGMA foreign_keys = ON;
|
|
${
|
|
seedWorkspace
|
|
? `INSERT INTO workspaces (
|
|
id, name, created_at_unix_ms, updated_at_unix_ms, last_opened_at_unix_ms
|
|
) VALUES (
|
|
'${sqlString(workspaceId)}', 'Replay Scenario', ${now}, ${now}, ${now}
|
|
);`
|
|
: ""
|
|
}
|
|
INSERT INTO desktop_preferences (
|
|
id, locale, theme_source, updated_at_unix_ms
|
|
) VALUES (
|
|
'desktop', 'en', 'system', ${now}
|
|
)
|
|
ON CONFLICT(id) DO NOTHING;
|
|
${
|
|
seedWorkspace
|
|
? `INSERT INTO workspace_workbench_snapshots (
|
|
workspace_id,
|
|
schema_version,
|
|
snapshot_json,
|
|
created_at_unix_ms,
|
|
updated_at_unix_ms
|
|
) VALUES (
|
|
'${sqlString(workspaceId)}',
|
|
${workbenchSnapshot.schemaVersion},
|
|
'${sqlString(JSON.stringify(workbenchSnapshot))}',
|
|
${now},
|
|
${now}
|
|
);`
|
|
: ""
|
|
}
|
|
`
|
|
],
|
|
workspaceRoot
|
|
);
|
|
}
|
|
|
|
export function replayWorkbenchSnapshot(autoOpenedAt) {
|
|
return {
|
|
schemaVersion: 1,
|
|
nodes: [],
|
|
nodeStack: [],
|
|
activeNodeId: null,
|
|
metadata: {
|
|
workspaceOnboarding: {
|
|
autoOpened: true,
|
|
autoOpenedAt,
|
|
schemaVersion: 1
|
|
}
|
|
}
|
|
};
|
|
}
|
|
|
|
export async function enableAgentSessionRecordingFeature(
|
|
databasePath,
|
|
workspaceRoot
|
|
) {
|
|
await runCommand(
|
|
"sqlite3",
|
|
[
|
|
databasePath,
|
|
`
|
|
UPDATE desktop_preferences
|
|
SET feature_flags_json = json_set(
|
|
COALESCE(NULLIF(feature_flags_json, ''), '{}'),
|
|
'$."agent.sessionRecording"',
|
|
json('true')
|
|
)
|
|
WHERE id = 'desktop';
|
|
`
|
|
],
|
|
workspaceRoot
|
|
);
|
|
}
|
|
|
|
export async function enableAgentSessionRecordingTarget(
|
|
databasePath,
|
|
agentTargetId,
|
|
workspaceRoot
|
|
) {
|
|
await runCommand(
|
|
"sqlite3",
|
|
[
|
|
databasePath,
|
|
`
|
|
UPDATE agent_targets
|
|
SET enabled = 1, updated_at_ms = ${Date.now()}
|
|
WHERE id = '${sqlString(agentTargetId)}';
|
|
`
|
|
],
|
|
workspaceRoot
|
|
);
|
|
}
|
|
|
|
export async function setAgentComposerDefaults(
|
|
databasePath,
|
|
agentTargetId,
|
|
{ model, permissionModeId, reasoningEffort, speed },
|
|
workspaceRoot
|
|
) {
|
|
await runCommand(
|
|
"sqlite3",
|
|
[
|
|
databasePath,
|
|
`
|
|
UPDATE desktop_preferences
|
|
SET agent_composer_defaults_by_agent_target_json = json_set(
|
|
COALESCE(NULLIF(agent_composer_defaults_by_agent_target_json, ''), '{}'),
|
|
'$."${sqlString(agentTargetId)}".permissionModeId',
|
|
'${sqlString(permissionModeId)}',
|
|
'$."${sqlString(agentTargetId)}".model',
|
|
'${sqlString(model)}',
|
|
'$."${sqlString(agentTargetId)}".reasoningEffort',
|
|
'${sqlString(reasoningEffort)}',
|
|
'$."${sqlString(agentTargetId)}".speed',
|
|
'${sqlString(speed)}'
|
|
)
|
|
WHERE id = 'desktop';
|
|
`
|
|
],
|
|
workspaceRoot
|
|
);
|
|
}
|
|
|
|
export async function removeRuntime(directory) {
|
|
await rm(directory, {
|
|
recursive: true,
|
|
force: true,
|
|
maxRetries: 10,
|
|
retryDelay: 200
|
|
});
|
|
}
|
|
|
|
async function runCommand(command, args, workspaceRoot) {
|
|
await new Promise((resolveCommand, rejectCommand) => {
|
|
const child = spawn(command, args, {
|
|
cwd: workspaceRoot,
|
|
env: process.env,
|
|
stdio: "inherit"
|
|
});
|
|
child.once("error", rejectCommand);
|
|
child.once("exit", (code, signal) => {
|
|
if (code === 0) {
|
|
resolveCommand();
|
|
return;
|
|
}
|
|
rejectCommand(
|
|
new Error(
|
|
`${command} ${args.join(" ")} failed with ${signal ?? `exit ${code}`}`
|
|
)
|
|
);
|
|
});
|
|
});
|
|
}
|
|
|
|
function sqlString(value) {
|
|
return String(value).replaceAll("'", "''");
|
|
}
|