源码快照 @ 2026-09-01 (source-only, 剔除非源码素材)
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
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
This commit is contained in:
@@ -0,0 +1,47 @@
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { createCassetteHelpers } from "../../../packages/agent/session-replay-runner/src/cassette.mjs";
|
||||
import { loadCassettePolicy } from "../../../packages/agent/session-replay-runner/src/cassette-policy.mjs";
|
||||
import { resolveAgentSessionReplayProjectRoot } from "./project-root.mjs";
|
||||
|
||||
export { resolveAgentSessionReplayProjectRoot } from "./project-root.mjs";
|
||||
export { portableReplayCWDToken } from "../../../packages/agent/session-replay-runner/src/cassette.mjs";
|
||||
|
||||
const scriptDirectory = dirname(fileURLToPath(import.meta.url));
|
||||
const workspaceRoot = resolve(scriptDirectory, "..", "..", "..");
|
||||
|
||||
export const cassettePolicy = await loadCassettePolicy(
|
||||
join(
|
||||
workspaceRoot,
|
||||
"packages",
|
||||
"agent",
|
||||
"session-replay",
|
||||
"cassette-policy.json"
|
||||
)
|
||||
);
|
||||
|
||||
const helpers = createCassetteHelpers(cassettePolicy, {
|
||||
canonicalizeResolvedPaths: true
|
||||
});
|
||||
|
||||
export const verifyCassette = helpers.verifyCassette;
|
||||
export const parseActivityEvents = helpers.parseActivityEvents;
|
||||
export const materializeReplayWorkspaceBlobs =
|
||||
helpers.materializeReplayWorkspaceBlobs;
|
||||
export const resolvePortableActivityEventPayload =
|
||||
helpers.resolvePortableActivityEventPayload;
|
||||
export const loadReplayTurnIdentityPlan = helpers.loadReplayTurnIdentityPlan;
|
||||
export const replayTurnIdentityPlan = helpers.replayTurnIdentityPlan;
|
||||
|
||||
export function replayActionFromManifest(
|
||||
manifest,
|
||||
activityEvents,
|
||||
workspaceId
|
||||
) {
|
||||
return helpers.replayActionFromManifest(
|
||||
manifest,
|
||||
activityEvents,
|
||||
workspaceId,
|
||||
resolveAgentSessionReplayProjectRoot()
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,361 @@
|
||||
import { readFile, readdir } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
const CATALOG_REVISION_RE = /sha256:[0-9a-f]{8,}/g;
|
||||
const BUSINESS_CATALOG_ASSIGNMENT_RE =
|
||||
/businessEventCatalogRevision\s*=\s*"(sha256:[0-9a-f]+)"/;
|
||||
const GO_CATALOG_ASSIGNMENT_RE =
|
||||
/BusinessEventCatalogRevision\s*=\s*"(sha256:[0-9a-f]+)"/;
|
||||
const MISMATCH_LOG_RE =
|
||||
/Event stream catalog revision mismatch\.\s*Expected\s+(sha256:[0-9a-f]+),\s*received\s+(sha256:[0-9a-f]+)/;
|
||||
|
||||
/**
|
||||
* Extract a business-event catalog revision from source, bundle, or binary text.
|
||||
* Prefers explicit assignment forms, then falls back to the first sha256 token.
|
||||
*/
|
||||
export function extractCatalogRevision(text) {
|
||||
if (typeof text !== "string" || text.length === 0) return null;
|
||||
const assigned =
|
||||
text.match(BUSINESS_CATALOG_ASSIGNMENT_RE)?.[1] ??
|
||||
text.match(GO_CATALOG_ASSIGNMENT_RE)?.[1] ??
|
||||
null;
|
||||
if (assigned) return assigned;
|
||||
const matches = text.match(CATALOG_REVISION_RE);
|
||||
return matches?.[0] ?? null;
|
||||
}
|
||||
|
||||
export function formatCatalogMismatchError(input) {
|
||||
const desktop = input.desktopRevision ?? "unknown";
|
||||
const daemon = input.daemonRevision ?? "unknown";
|
||||
const desktopSource = input.desktopSource ?? "desktop";
|
||||
const daemonSource = input.daemonSource ?? "tuttid";
|
||||
const guidance =
|
||||
input.guidance ??
|
||||
"Rebuild desktop (apps/desktop/out) and tuttids from the same commit, " +
|
||||
"or clear stale prepared Electron/out artifacts before recording.";
|
||||
return (
|
||||
`desktop/tuttid event stream catalog mismatch (fail-fast): ` +
|
||||
`${desktopSource}=${desktop}, ${daemonSource}=${daemon}. ` +
|
||||
guidance
|
||||
);
|
||||
}
|
||||
|
||||
export async function readCatalogRevisionFromFile(path) {
|
||||
try {
|
||||
return extractCatalogRevision(await readFile(path, "utf8"));
|
||||
} catch (error) {
|
||||
if (error && error.code === "ENOENT") return null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function readCatalogRevisionFromBinary(path) {
|
||||
try {
|
||||
const buffer = await readFile(path);
|
||||
// Binary may contain multiple sha256 tokens; prefer the business-event one
|
||||
// by scanning latin1 and matching assignment-like nearby text first.
|
||||
const latin1 = buffer.toString("latin1");
|
||||
const assigned = extractCatalogRevisionFromBinaryAssignment(latin1);
|
||||
if (assigned) return assigned;
|
||||
// Fall back: tuttidd embeds the revision as a naked string constant.
|
||||
const matches = latin1.match(CATALOG_REVISION_RE) ?? [];
|
||||
// Prefer 16-hex-nibble revisions used by generate-event-protocol.
|
||||
const short = matches.find((value) => /^sha256:[0-9a-f]{16}$/.test(value));
|
||||
return short ?? matches[0] ?? null;
|
||||
} catch (error) {
|
||||
if (error && error.code === "ENOENT") return null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function extractCatalogRevisionFromBinaryAssignment(text) {
|
||||
const marker = "BusinessEventCatalogRevision";
|
||||
const markerIndex = text.indexOf(marker);
|
||||
if (markerIndex < 0) return null;
|
||||
|
||||
const assignmentIndex = text.indexOf("=", markerIndex + marker.length);
|
||||
if (assignmentIndex < 0 || assignmentIndex - markerIndex > 128) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const quoteIndex = text.indexOf('"', assignmentIndex + 1);
|
||||
if (quoteIndex < 0 || quoteIndex - assignmentIndex > 128) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return text.slice(quoteIndex + 1).match(/^sha256:[0-9a-f]+/)?.[0] ?? null;
|
||||
}
|
||||
|
||||
export async function readCatalogRevisionFromDesktopRendererOut(workspaceRoot) {
|
||||
const assetsDirectory = join(
|
||||
workspaceRoot,
|
||||
"apps",
|
||||
"desktop",
|
||||
"out",
|
||||
"renderer",
|
||||
"assets"
|
||||
);
|
||||
let entries = [];
|
||||
try {
|
||||
entries = await readdir(assetsDirectory);
|
||||
} catch (error) {
|
||||
if (error && error.code === "ENOENT") return null;
|
||||
throw error;
|
||||
}
|
||||
for (const name of entries) {
|
||||
if (!name.endsWith(".js")) continue;
|
||||
const revision = await readCatalogRevisionFromFile(
|
||||
join(assetsDirectory, name)
|
||||
);
|
||||
if (revision) return revision;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function readCatalogRevisionFromEventProtocolSource(
|
||||
workspaceRoot
|
||||
) {
|
||||
return readCatalogRevisionFromFile(
|
||||
join(
|
||||
workspaceRoot,
|
||||
"packages",
|
||||
"events",
|
||||
"protocol",
|
||||
"src",
|
||||
"generated",
|
||||
"registry.ts"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {'out' | 'source' | 'out-then-source' | 'source-then-out'} [prefer]
|
||||
*/
|
||||
export async function resolveDesktopEventStreamCatalogRevision(
|
||||
workspaceRoot,
|
||||
prefer = "out-then-source"
|
||||
) {
|
||||
const readOut = async () => {
|
||||
const renderer =
|
||||
await readCatalogRevisionFromDesktopRendererOut(workspaceRoot);
|
||||
if (renderer) {
|
||||
return { revision: renderer, source: "desktop-out-renderer" };
|
||||
}
|
||||
const main = await readCatalogRevisionFromFile(
|
||||
join(workspaceRoot, "apps", "desktop", "out", "main", "index.js")
|
||||
);
|
||||
if (main) {
|
||||
return { revision: main, source: "desktop-out-main" };
|
||||
}
|
||||
return null;
|
||||
};
|
||||
const readSource = async () => {
|
||||
const protocol =
|
||||
await readCatalogRevisionFromEventProtocolSource(workspaceRoot);
|
||||
if (protocol) {
|
||||
return { revision: protocol, source: "event-protocol-source" };
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
if (prefer === "out") return readOut();
|
||||
if (prefer === "source") return readSource();
|
||||
if (prefer === "source-then-out") {
|
||||
return (await readSource()) ?? (await readOut());
|
||||
}
|
||||
return (await readOut()) ?? (await readSource());
|
||||
}
|
||||
|
||||
export async function resolveDaemonEventStreamCatalogRevision(input) {
|
||||
const daemonPath = input.daemonPath?.trim?.() || input.daemonPath || "";
|
||||
if (daemonPath) {
|
||||
const fromBinary = await readCatalogRevisionFromBinary(daemonPath);
|
||||
if (fromBinary) {
|
||||
return { revision: fromBinary, source: "daemon-binary" };
|
||||
}
|
||||
}
|
||||
const fromGo = await readCatalogRevisionFromFile(
|
||||
join(
|
||||
input.workspaceRoot,
|
||||
"services",
|
||||
"tuttid",
|
||||
"api",
|
||||
"events",
|
||||
"generated",
|
||||
"protocol.gen.go"
|
||||
)
|
||||
);
|
||||
if (fromGo) {
|
||||
return { revision: fromGo, source: "tuttid-protocol-source" };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fail when desktop/tuttid catalogs diverge.
|
||||
* No-ops when either side cannot be resolved (e.g. cold pnpm-dev without out/).
|
||||
*
|
||||
* @param {{ daemonPath?: string, workspaceRoot: string, desktopPrefer?: string }} input
|
||||
*/
|
||||
export async function assertEventStreamCatalogAligned(input) {
|
||||
const desktopPrefer = input.desktopPrefer ?? "out-then-source";
|
||||
const desktop = await resolveDesktopEventStreamCatalogRevision(
|
||||
input.workspaceRoot,
|
||||
desktopPrefer
|
||||
);
|
||||
const daemon = await resolveDaemonEventStreamCatalogRevision({
|
||||
daemonPath: input.daemonPath,
|
||||
workspaceRoot: input.workspaceRoot
|
||||
});
|
||||
if (!desktop || !daemon) return { checked: false, desktop, daemon };
|
||||
if (desktop.revision !== daemon.revision) {
|
||||
throw new Error(
|
||||
formatCatalogMismatchError({
|
||||
daemonRevision: daemon.revision,
|
||||
daemonSource: daemon.source,
|
||||
desktopRevision: desktop.revision,
|
||||
desktopSource: desktop.source
|
||||
})
|
||||
);
|
||||
}
|
||||
return { checked: true, desktop, daemon };
|
||||
}
|
||||
|
||||
/**
|
||||
* Align catalogs for a launch. Stale prepared `apps/desktop/out` automatically
|
||||
* falls back to pnpm-dev-desktop when event-protocol source matches tuttidd.
|
||||
*
|
||||
* Managed launches (must use a fixed Electron binary) cannot fall back — they
|
||||
* still fail fast so the operator rebuilds out/.
|
||||
*/
|
||||
export async function reconcileEventStreamCatalogForLaunch(input) {
|
||||
const preparedElectron = Boolean(input.preparedElectron);
|
||||
const managed = Boolean(input.managed);
|
||||
const daemon = await resolveDaemonEventStreamCatalogRevision({
|
||||
daemonPath: input.daemonPath,
|
||||
workspaceRoot: input.workspaceRoot
|
||||
});
|
||||
if (!daemon) {
|
||||
return {
|
||||
checked: false,
|
||||
fallbackToPnpmDev: false,
|
||||
daemon: null,
|
||||
desktop: null
|
||||
};
|
||||
}
|
||||
|
||||
if (!preparedElectron) {
|
||||
// Vite/dev uses protocol source; ignore stale out/ so we do not false-fail.
|
||||
const result = await assertEventStreamCatalogAligned({
|
||||
daemonPath: input.daemonPath,
|
||||
desktopPrefer: "source-then-out",
|
||||
workspaceRoot: input.workspaceRoot
|
||||
});
|
||||
return { ...result, fallbackToPnpmDev: false };
|
||||
}
|
||||
|
||||
const out = await resolveDesktopEventStreamCatalogRevision(
|
||||
input.workspaceRoot,
|
||||
"out"
|
||||
);
|
||||
if (out && out.revision === daemon.revision) {
|
||||
return {
|
||||
checked: true,
|
||||
desktop: out,
|
||||
daemon,
|
||||
fallbackToPnpmDev: false
|
||||
};
|
||||
}
|
||||
|
||||
const source = await resolveDesktopEventStreamCatalogRevision(
|
||||
input.workspaceRoot,
|
||||
"source"
|
||||
);
|
||||
if (source && source.revision === daemon.revision) {
|
||||
if (managed) {
|
||||
throw new Error(
|
||||
formatCatalogMismatchError({
|
||||
daemonRevision: daemon.revision,
|
||||
daemonSource: daemon.source,
|
||||
desktopRevision: out?.revision ?? "missing",
|
||||
desktopSource: out?.source ?? "desktop-out",
|
||||
guidance:
|
||||
"Managed replay cannot auto-fall back to pnpm-dev-desktop. " +
|
||||
"Rebuild apps/desktop/out (or clear managed Electron env) so " +
|
||||
"renderer matches tuttidd."
|
||||
})
|
||||
);
|
||||
}
|
||||
return {
|
||||
checked: true,
|
||||
desktop: source,
|
||||
daemon,
|
||||
fallbackToPnpmDev: true,
|
||||
message:
|
||||
`stale prepared desktop out catalog ` +
|
||||
`(${out?.source ?? "desktop-out"}=${out?.revision ?? "missing"} vs ` +
|
||||
`${daemon.source}=${daemon.revision}); ` +
|
||||
`auto-falling back to pnpm-dev-desktop (event-protocol source matches)`
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
formatCatalogMismatchError({
|
||||
daemonRevision: daemon.revision,
|
||||
daemonSource: daemon.source,
|
||||
desktopRevision: out?.revision ?? source?.revision ?? "unknown",
|
||||
desktopSource: out?.source ?? source?.source ?? "desktop",
|
||||
guidance:
|
||||
"Neither prepared out/ nor event-protocol source match tuttidd. " +
|
||||
"Run pnpm generate:event-protocol and rebuild desktop + tuttids " +
|
||||
"from the same commit."
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
export function clearPreparedElectronEnv(env = process.env) {
|
||||
delete env.TUTTI_AGENT_SESSION_REPLAY_ELECTRON_EXECUTABLE;
|
||||
delete env.TUTTI_AGENT_SESSION_REPLAY_ELECTRON_ENTRY;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check the live Desktop log once after CDP is ready. The source/artifact
|
||||
* reconciliation above is the authoritative preflight; this snapshot only
|
||||
* turns an already-emitted handshake failure into a useful error. Polling here
|
||||
* has no success signal and used to delay every healthy replay by 8 seconds.
|
||||
*/
|
||||
export async function assertDesktopLogHasNoCatalogMismatch(logPath) {
|
||||
let lastText;
|
||||
try {
|
||||
lastText = await readFile(logPath, "utf8");
|
||||
} catch (error) {
|
||||
if (error && error.code === "ENOENT") return { checked: false };
|
||||
throw error;
|
||||
}
|
||||
|
||||
const match = lastText.match(MISMATCH_LOG_RE);
|
||||
if (match) {
|
||||
throw new Error(
|
||||
formatCatalogMismatchError({
|
||||
daemonRevision: match[2],
|
||||
daemonSource: "tuttid-ready-frame",
|
||||
desktopRevision: match[1],
|
||||
desktopSource: "desktop-event-stream-client"
|
||||
})
|
||||
);
|
||||
}
|
||||
if (
|
||||
lastText.includes("catalog revision mismatch") ||
|
||||
lastText.includes("event_stream.connect_failed")
|
||||
) {
|
||||
throw new Error(
|
||||
formatCatalogMismatchError({
|
||||
daemonRevision: "see desktop.log",
|
||||
daemonSource: "tuttid",
|
||||
desktopRevision: "see desktop.log",
|
||||
desktopSource: "desktop"
|
||||
})
|
||||
);
|
||||
}
|
||||
return { checked: true };
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtemp, mkdir, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
assertDesktopLogHasNoCatalogMismatch,
|
||||
assertEventStreamCatalogAligned,
|
||||
clearPreparedElectronEnv,
|
||||
extractCatalogRevision,
|
||||
formatCatalogMismatchError,
|
||||
readCatalogRevisionFromBinary,
|
||||
readCatalogRevisionFromDesktopRendererOut,
|
||||
readCatalogRevisionFromFile,
|
||||
reconcileEventStreamCatalogForLaunch
|
||||
} from "./event-stream-catalog.mjs";
|
||||
|
||||
test("extractCatalogRevision prefers businessEventCatalogRevision assignment", () => {
|
||||
const text = `
|
||||
const other = "sha256:deadbeefdeadbeef";
|
||||
const businessEventCatalogRevision = "sha256:0413e48c4012324e";
|
||||
`;
|
||||
assert.equal(extractCatalogRevision(text), "sha256:0413e48c4012324e");
|
||||
});
|
||||
|
||||
test("extractCatalogRevision reads Go BusinessEventCatalogRevision", () => {
|
||||
const text = `\tBusinessEventCatalogRevision = "sha256:0413e48c4012324e"\n`;
|
||||
assert.equal(extractCatalogRevision(text), "sha256:0413e48c4012324e");
|
||||
});
|
||||
|
||||
test("assertEventStreamCatalogAligned fails when renderer out and daemon diverge", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "catalog-align-"));
|
||||
const assets = join(root, "apps", "desktop", "out", "renderer", "assets");
|
||||
const daemonDir = join(root, "daemon");
|
||||
await mkdir(assets, { recursive: true });
|
||||
await mkdir(daemonDir, { recursive: true });
|
||||
await writeFile(
|
||||
join(assets, "browserNodeWebviewContext-test.js"),
|
||||
`const businessEventCatalogRevision = "sha256:2e36dcc0d1c65637";\n`
|
||||
);
|
||||
const daemonPath = join(daemonDir, "tuttid");
|
||||
await writeFile(
|
||||
daemonPath,
|
||||
Buffer.from(`xxBusinessEventCatalogRevision="sha256:0413e48c4012324e"yy`)
|
||||
);
|
||||
|
||||
await assert.rejects(
|
||||
() =>
|
||||
assertEventStreamCatalogAligned({
|
||||
daemonPath,
|
||||
workspaceRoot: root
|
||||
}),
|
||||
/event stream catalog mismatch \(fail-fast\)/
|
||||
);
|
||||
});
|
||||
|
||||
test("assertEventStreamCatalogAligned passes when revisions match", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "catalog-align-ok-"));
|
||||
const assets = join(root, "apps", "desktop", "out", "renderer", "assets");
|
||||
const daemonDir = join(root, "daemon");
|
||||
await mkdir(assets, { recursive: true });
|
||||
await mkdir(daemonDir, { recursive: true });
|
||||
await writeFile(
|
||||
join(assets, "browserNodeWebviewContext-test.js"),
|
||||
`const businessEventCatalogRevision = "sha256:0413e48c4012324e";\n`
|
||||
);
|
||||
const daemonPath = join(daemonDir, "tuttid");
|
||||
await writeFile(
|
||||
daemonPath,
|
||||
Buffer.from(`BusinessEventCatalogRevision="sha256:0413e48c4012324e"`)
|
||||
);
|
||||
const result = await assertEventStreamCatalogAligned({
|
||||
daemonPath,
|
||||
workspaceRoot: root
|
||||
});
|
||||
assert.equal(result.checked, true);
|
||||
assert.equal(result.desktop.revision, "sha256:0413e48c4012324e");
|
||||
assert.equal(result.daemon.revision, "sha256:0413e48c4012324e");
|
||||
});
|
||||
|
||||
test("reconcile falls back to pnpm-dev when out is stale but source matches", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "catalog-fallback-"));
|
||||
const assets = join(root, "apps", "desktop", "out", "renderer", "assets");
|
||||
const protocolDir = join(
|
||||
root,
|
||||
"packages",
|
||||
"events",
|
||||
"protocol",
|
||||
"src",
|
||||
"generated"
|
||||
);
|
||||
const daemonDir = join(root, "daemon");
|
||||
await mkdir(assets, { recursive: true });
|
||||
await mkdir(protocolDir, { recursive: true });
|
||||
await mkdir(daemonDir, { recursive: true });
|
||||
await writeFile(
|
||||
join(assets, "browserNodeWebviewContext-test.js"),
|
||||
`const businessEventCatalogRevision = "sha256:2e36dcc0d1c65637";\n`
|
||||
);
|
||||
await writeFile(
|
||||
join(protocolDir, "registry.ts"),
|
||||
`export const businessEventCatalogRevision = "sha256:0413e48c4012324e";\n`
|
||||
);
|
||||
const daemonPath = join(daemonDir, "tuttid");
|
||||
await writeFile(
|
||||
daemonPath,
|
||||
Buffer.from(`BusinessEventCatalogRevision="sha256:0413e48c4012324e"`)
|
||||
);
|
||||
|
||||
const result = await reconcileEventStreamCatalogForLaunch({
|
||||
daemonPath,
|
||||
preparedElectron: true,
|
||||
workspaceRoot: root
|
||||
});
|
||||
assert.equal(result.fallbackToPnpmDev, true);
|
||||
assert.equal(result.desktop.revision, "sha256:0413e48c4012324e");
|
||||
assert.match(result.message, /auto-falling back to pnpm-dev-desktop/);
|
||||
});
|
||||
|
||||
test("reconcile does not fall back for managed launches", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "catalog-managed-"));
|
||||
const assets = join(root, "apps", "desktop", "out", "renderer", "assets");
|
||||
const protocolDir = join(
|
||||
root,
|
||||
"packages",
|
||||
"events",
|
||||
"protocol",
|
||||
"src",
|
||||
"generated"
|
||||
);
|
||||
const daemonDir = join(root, "daemon");
|
||||
await mkdir(assets, { recursive: true });
|
||||
await mkdir(protocolDir, { recursive: true });
|
||||
await mkdir(daemonDir, { recursive: true });
|
||||
await writeFile(
|
||||
join(assets, "browserNodeWebviewContext-test.js"),
|
||||
`const businessEventCatalogRevision = "sha256:2e36dcc0d1c65637";\n`
|
||||
);
|
||||
await writeFile(
|
||||
join(protocolDir, "registry.ts"),
|
||||
`export const businessEventCatalogRevision = "sha256:0413e48c4012324e";\n`
|
||||
);
|
||||
const daemonPath = join(daemonDir, "tuttid");
|
||||
await writeFile(
|
||||
daemonPath,
|
||||
Buffer.from(`BusinessEventCatalogRevision="sha256:0413e48c4012324e"`)
|
||||
);
|
||||
|
||||
await assert.rejects(
|
||||
() =>
|
||||
reconcileEventStreamCatalogForLaunch({
|
||||
daemonPath,
|
||||
managed: true,
|
||||
preparedElectron: true,
|
||||
workspaceRoot: root
|
||||
}),
|
||||
/Managed replay cannot auto-fall back/
|
||||
);
|
||||
});
|
||||
|
||||
test("clearPreparedElectronEnv removes launch env", () => {
|
||||
const env = {
|
||||
TUTTI_AGENT_SESSION_REPLAY_ELECTRON_EXECUTABLE: "/bin/Electron",
|
||||
TUTTI_AGENT_SESSION_REPLAY_ELECTRON_ENTRY: "/apps/desktop",
|
||||
KEEP: "1"
|
||||
};
|
||||
clearPreparedElectronEnv(env);
|
||||
assert.equal(env.TUTTI_AGENT_SESSION_REPLAY_ELECTRON_EXECUTABLE, undefined);
|
||||
assert.equal(env.TUTTI_AGENT_SESSION_REPLAY_ELECTRON_ENTRY, undefined);
|
||||
assert.equal(env.KEEP, "1");
|
||||
});
|
||||
|
||||
test("assertDesktopLogHasNoCatalogMismatch fails fast on handshake error", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "catalog-log-"));
|
||||
const logPath = join(root, "desktop.log");
|
||||
await writeFile(
|
||||
logPath,
|
||||
`time=... msg="terminal diagnostic" details={"error":"Event stream catalog revision mismatch. Expected sha256:2e36dcc0d1c65637, received sha256:0413e48c4012324e."}\n`
|
||||
);
|
||||
await assert.rejects(
|
||||
() => assertDesktopLogHasNoCatalogMismatch(logPath),
|
||||
/fail-fast/
|
||||
);
|
||||
});
|
||||
|
||||
test("assertDesktopLogHasNoCatalogMismatch accepts a clean desktop snapshot", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "catalog-log-clean-"));
|
||||
const logPath = join(root, "desktop.log");
|
||||
await writeFile(logPath, 'time=... msg="desktop app ready"\n');
|
||||
assert.deepEqual(await assertDesktopLogHasNoCatalogMismatch(logPath), {
|
||||
checked: true
|
||||
});
|
||||
});
|
||||
|
||||
test("read helpers tolerate missing artifacts", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "catalog-missing-"));
|
||||
assert.equal(await readCatalogRevisionFromFile(join(root, "nope.ts")), null);
|
||||
assert.equal(await readCatalogRevisionFromBinary(join(root, "nope")), null);
|
||||
assert.equal(await readCatalogRevisionFromDesktopRendererOut(root), null);
|
||||
const result = await assertEventStreamCatalogAligned({
|
||||
daemonPath: join(root, "missing-daemon"),
|
||||
workspaceRoot: root
|
||||
});
|
||||
assert.equal(result.checked, false);
|
||||
});
|
||||
|
||||
test("formatCatalogMismatchError mentions rebuild guidance", () => {
|
||||
const message = formatCatalogMismatchError({
|
||||
daemonRevision: "sha256:aaaa",
|
||||
daemonSource: "daemon-binary",
|
||||
desktopRevision: "sha256:bbbb",
|
||||
desktopSource: "desktop-out-renderer"
|
||||
});
|
||||
assert.match(message, /desktop-out-renderer=sha256:bbbb/);
|
||||
assert.match(message, /daemon-binary=sha256:aaaa/);
|
||||
assert.match(message, /Rebuild desktop/);
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { access, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { isAbsolute, join, resolve } from "node:path";
|
||||
|
||||
const projectRootEnvironment = "TUTTI_AGENT_SESSION_REPLAY_PROJECT_ROOT";
|
||||
const temporaryProjectPrefix = "tutti-agent-session-rec-";
|
||||
|
||||
export function resolveAgentSessionReplayProjectRoot() {
|
||||
const fromEnvironment = process.env[projectRootEnvironment]?.trim();
|
||||
if (fromEnvironment) {
|
||||
if (!isAbsolute(fromEnvironment)) {
|
||||
throw new Error(`${projectRootEnvironment} must be an absolute path`);
|
||||
}
|
||||
return resolve(fromEnvironment);
|
||||
}
|
||||
// Pure helpers may resolve portable paths before the CLI acquires its
|
||||
// run-scoped project. Keep that fallback outside the product checkout.
|
||||
return join(tmpdir(), "tutti-agent-session-rec", `unbound-${process.pid}`);
|
||||
}
|
||||
|
||||
export async function acquireAgentSessionReplayProjectRoot({
|
||||
keepRuntime = false
|
||||
} = {}) {
|
||||
const previous = process.env[projectRootEnvironment];
|
||||
if (previous?.trim()) {
|
||||
const root = resolveAgentSessionReplayProjectRoot();
|
||||
await mkdir(root, { recursive: true });
|
||||
return {
|
||||
owned: false,
|
||||
root,
|
||||
async dispose() {}
|
||||
};
|
||||
}
|
||||
|
||||
const root = await mkdtemp(join(tmpdir(), temporaryProjectPrefix));
|
||||
try {
|
||||
await initializeTemporaryProject(root);
|
||||
} catch (error) {
|
||||
await rm(root, { force: true, recursive: true });
|
||||
throw error;
|
||||
}
|
||||
process.env[projectRootEnvironment] = root;
|
||||
let disposed = false;
|
||||
return {
|
||||
owned: true,
|
||||
root,
|
||||
async dispose() {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
if (previous === undefined) {
|
||||
delete process.env[projectRootEnvironment];
|
||||
} else {
|
||||
process.env[projectRootEnvironment] = previous;
|
||||
}
|
||||
if (!keepRuntime) {
|
||||
await rm(root, { force: true, recursive: true });
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function initializeTemporaryProject(root) {
|
||||
await runGit(root, ["init", "--quiet"]);
|
||||
await writeFile(
|
||||
join(root, "README.md"),
|
||||
[
|
||||
"# Tutti session-replay temporary project",
|
||||
"",
|
||||
"This run-scoped project is intentionally outside the product checkout.",
|
||||
""
|
||||
].join("\n")
|
||||
);
|
||||
await runGit(root, ["add", "README.md"]);
|
||||
await runGit(root, [
|
||||
"-c",
|
||||
"user.name=Tutti Session Replay",
|
||||
"-c",
|
||||
"user.email=session-replay@tutti.invalid",
|
||||
"commit",
|
||||
"--quiet",
|
||||
"-m",
|
||||
"chore: initialize session-replay project"
|
||||
]);
|
||||
await access(join(root, ".git"));
|
||||
}
|
||||
|
||||
function runGit(cwd, args) {
|
||||
return new Promise((resolveRun, rejectRun) => {
|
||||
const child = spawn("git", args, {
|
||||
cwd,
|
||||
env: process.env,
|
||||
stdio: ["ignore", "ignore", "pipe"]
|
||||
});
|
||||
let stderr = "";
|
||||
child.stderr.on("data", (chunk) => {
|
||||
stderr += chunk.toString();
|
||||
});
|
||||
child.once("error", rejectRun);
|
||||
child.once("exit", (code, signal) => {
|
||||
if (code === 0) {
|
||||
resolveRun();
|
||||
return;
|
||||
}
|
||||
rejectRun(
|
||||
new Error(
|
||||
`git ${args.join(" ")} failed (${code ?? signal ?? "unknown"})${
|
||||
stderr.trim() ? `: ${stderr.trim()}` : ""
|
||||
}`
|
||||
)
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { access, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import test from "node:test";
|
||||
import { acquireAgentSessionReplayProjectRoot } from "./project-root.mjs";
|
||||
|
||||
const projectRootEnvironment = "TUTTI_AGENT_SESSION_REPLAY_PROJECT_ROOT";
|
||||
|
||||
test("acquires and removes a run-scoped temporary Git project", async () => {
|
||||
const previous = process.env[projectRootEnvironment];
|
||||
delete process.env[projectRootEnvironment];
|
||||
let project;
|
||||
try {
|
||||
project = await acquireAgentSessionReplayProjectRoot();
|
||||
const root = project.root;
|
||||
assert.equal(project.owned, true);
|
||||
assert.equal(process.env[projectRootEnvironment], root);
|
||||
assert.equal(
|
||||
root.startsWith(join(tmpdir(), "tutti-agent-session-rec-")),
|
||||
true
|
||||
);
|
||||
await access(join(root, ".git"));
|
||||
assert.match(
|
||||
await readFile(join(root, "README.md"), "utf8"),
|
||||
/outside the product checkout/u
|
||||
);
|
||||
|
||||
await project.dispose();
|
||||
project = undefined;
|
||||
await assert.rejects(access(root));
|
||||
} finally {
|
||||
await project?.dispose();
|
||||
restoreProjectRootEnvironment(previous);
|
||||
}
|
||||
});
|
||||
|
||||
test("keep-runtime retains an owned temporary project", async () => {
|
||||
const previous = process.env[projectRootEnvironment];
|
||||
delete process.env[projectRootEnvironment];
|
||||
let project;
|
||||
try {
|
||||
project = await acquireAgentSessionReplayProjectRoot({ keepRuntime: true });
|
||||
await project.dispose();
|
||||
await access(join(project.root, ".git"));
|
||||
} finally {
|
||||
if (project?.root) {
|
||||
await rm(project.root, { force: true, recursive: true });
|
||||
}
|
||||
restoreProjectRootEnvironment(previous);
|
||||
}
|
||||
});
|
||||
|
||||
test("preserves an externally supplied project root", async () => {
|
||||
const previous = process.env[projectRootEnvironment];
|
||||
const parent = await mkdtemp(
|
||||
join(tmpdir(), "tutti-session-replay-external-")
|
||||
);
|
||||
const externalRoot = join(parent, "project");
|
||||
process.env[projectRootEnvironment] = externalRoot;
|
||||
try {
|
||||
const project = await acquireAgentSessionReplayProjectRoot();
|
||||
assert.equal(project.owned, false);
|
||||
assert.equal(project.root, externalRoot);
|
||||
await writeFile(join(project.root, "marker.txt"), "preserve\n");
|
||||
await project.dispose();
|
||||
assert.equal(
|
||||
await readFile(join(project.root, "marker.txt"), "utf8"),
|
||||
"preserve\n"
|
||||
);
|
||||
assert.equal(process.env[projectRootEnvironment], externalRoot);
|
||||
} finally {
|
||||
restoreProjectRootEnvironment(previous);
|
||||
await rm(parent, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
function restoreProjectRootEnvironment(previous) {
|
||||
if (previous === undefined) {
|
||||
delete process.env[projectRootEnvironment];
|
||||
} else {
|
||||
process.env[projectRootEnvironment] = previous;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { realpathSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import {
|
||||
assertForbiddenPathAbsent as assertForbiddenPathAbsentCore,
|
||||
resolveRecordScenarioProject as resolveRecordScenarioProjectCore,
|
||||
seedRecordingUserProject as seedRecordingUserProjectCore,
|
||||
verifyRecordedProjectBindingArtifacts as verifyRecordedProjectBindingArtifactsCore
|
||||
} from "../../../packages/agent/session-replay-runner/src/recording.mjs";
|
||||
import {
|
||||
cassettePolicy,
|
||||
parseActivityEvents,
|
||||
portableReplayCWDToken,
|
||||
resolveAgentSessionReplayProjectRoot
|
||||
} from "./cassette.mjs";
|
||||
|
||||
export { resolveAgentSessionReplayProjectRoot };
|
||||
|
||||
const activityEventsName = cassettePolicy.files.activityEvents.path;
|
||||
const checkpointPlanName = cassettePolicy.files.checkpointPlan.path;
|
||||
const expectedStateName = cassettePolicy.files.expectedState.path;
|
||||
const initialStateName = cassettePolicy.files.initialState.path;
|
||||
|
||||
function canonicalizeProjectPath(path) {
|
||||
const absolute = resolve(String(path ?? "").trim());
|
||||
if (!absolute) return absolute;
|
||||
try {
|
||||
return realpathSync(absolute);
|
||||
} catch {
|
||||
return absolute;
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveRecordScenarioProject(project, replayCWD) {
|
||||
return resolveRecordScenarioProjectCore(project, replayCWD, {
|
||||
canonicalizePath: canonicalizeProjectPath,
|
||||
portableReplayCWDToken
|
||||
});
|
||||
}
|
||||
|
||||
export async function seedRecordingUserProject(databasePath, project) {
|
||||
return seedRecordingUserProjectCore(databasePath, project, {
|
||||
canonicalizePath: canonicalizeProjectPath
|
||||
});
|
||||
}
|
||||
|
||||
export async function verifyRecordedProjectBindingArtifacts(
|
||||
recordingDirectory,
|
||||
portableProjectPath
|
||||
) {
|
||||
return verifyRecordedProjectBindingArtifactsCore(
|
||||
recordingDirectory,
|
||||
portableProjectPath,
|
||||
{
|
||||
activityEventsPath: activityEventsName,
|
||||
checkpointPlanPath: checkpointPlanName,
|
||||
expectedStatePath: expectedStateName,
|
||||
initialStatePath: initialStateName,
|
||||
parseActivityEvents
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export async function assertForbiddenPathAbsent(path, scenarioId) {
|
||||
return assertForbiddenPathAbsentCore(path, scenarioId);
|
||||
}
|
||||
@@ -0,0 +1,560 @@
|
||||
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("'", "''");
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
access,
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
readFile,
|
||||
rm,
|
||||
writeFile
|
||||
} from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import test from "node:test";
|
||||
import {
|
||||
resolveHostAccountAuthPath,
|
||||
seedHostAccountSession
|
||||
} from "./runtime.mjs";
|
||||
|
||||
test("resolveHostAccountAuthPath defaults to ~/.tutti-dev account session", () => {
|
||||
assert.equal(
|
||||
resolveHostAccountAuthPath({}, "/Users/ryan"),
|
||||
join("/Users/ryan", ".tutti-dev", "account", "auth.json")
|
||||
);
|
||||
});
|
||||
|
||||
test("resolveHostAccountAuthPath honors explicit override", () => {
|
||||
assert.equal(
|
||||
resolveHostAccountAuthPath(
|
||||
{
|
||||
TUTTI_AGENT_SESSION_REPLAY_HOST_ACCOUNT_AUTH: " /tmp/custom-auth.json "
|
||||
},
|
||||
"/Users/ryan"
|
||||
),
|
||||
"/tmp/custom-auth.json"
|
||||
);
|
||||
});
|
||||
|
||||
test("seedHostAccountSession copies a valid host login into isolated state", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "replay-host-account-"));
|
||||
try {
|
||||
const home = join(root, "home");
|
||||
const sourceDir = join(home, ".tutti-dev", "account");
|
||||
await mkdir(sourceDir, { recursive: true });
|
||||
const session = {
|
||||
session_id: "session-1",
|
||||
cookie: "session_id=session-1",
|
||||
email: "ryan@example.com",
|
||||
name: "ryan"
|
||||
};
|
||||
await writeFile(join(sourceDir, "auth.json"), JSON.stringify(session));
|
||||
|
||||
const stateDirectory = join(root, "state");
|
||||
const result = await seedHostAccountSession(stateDirectory, {
|
||||
env: {},
|
||||
homeDirectory: home
|
||||
});
|
||||
|
||||
assert.equal(result.action, "copied");
|
||||
const copied = JSON.parse(
|
||||
await readFile(join(stateDirectory, "account", "auth.json"), "utf8")
|
||||
);
|
||||
assert.equal(copied.session_id, "session-1");
|
||||
assert.equal(copied.cookie, "session_id=session-1");
|
||||
assert.equal(copied.email, "ryan@example.com");
|
||||
} finally {
|
||||
await rm(root, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("seedHostAccountSession skips when host login is missing", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "replay-host-account-missing-"));
|
||||
try {
|
||||
const result = await seedHostAccountSession(join(root, "state"), {
|
||||
env: {},
|
||||
homeDirectory: join(root, "empty-home")
|
||||
});
|
||||
assert.equal(result.action, "skipped-missing");
|
||||
await assert.rejects(access(join(root, "state", "account", "auth.json")));
|
||||
} finally {
|
||||
await rm(root, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("seedHostAccountSession skips invalid host login payloads", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "replay-host-account-invalid-"));
|
||||
try {
|
||||
const home = join(root, "home");
|
||||
const sourceDir = join(home, ".tutti-dev", "account");
|
||||
await mkdir(sourceDir, { recursive: true });
|
||||
await writeFile(
|
||||
join(sourceDir, "auth.json"),
|
||||
JSON.stringify({ name: "x" })
|
||||
);
|
||||
|
||||
const result = await seedHostAccountSession(join(root, "state"), {
|
||||
env: {},
|
||||
homeDirectory: home
|
||||
});
|
||||
assert.equal(result.action, "skipped-invalid");
|
||||
} finally {
|
||||
await rm(root, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("seedHostAccountSession can be disabled via env", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "replay-host-account-skip-"));
|
||||
try {
|
||||
const home = join(root, "home");
|
||||
const sourceDir = join(home, ".tutti-dev", "account");
|
||||
await mkdir(sourceDir, { recursive: true });
|
||||
await writeFile(
|
||||
join(sourceDir, "auth.json"),
|
||||
JSON.stringify({
|
||||
session_id: "session-1",
|
||||
cookie: "session_id=session-1"
|
||||
})
|
||||
);
|
||||
|
||||
const result = await seedHostAccountSession(join(root, "state"), {
|
||||
env: { TUTTI_AGENT_SESSION_REPLAY_SKIP_HOST_ACCOUNT_AUTH: "1" },
|
||||
homeDirectory: home
|
||||
});
|
||||
assert.equal(result.action, "skipped-disabled");
|
||||
} finally {
|
||||
await rm(root, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,283 @@
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { setTimeout as delay } from "node:timers/promises";
|
||||
import { CdpClient } from "../capture-electron-trace.mjs";
|
||||
import {
|
||||
configureWaitDiagnostics,
|
||||
evaluate,
|
||||
selectProvider,
|
||||
waitForEvaluation
|
||||
} from "../agent-gui-performance-helpers.mjs";
|
||||
import {
|
||||
reservePort,
|
||||
startDesktop,
|
||||
stopProcessTree,
|
||||
waitForPageWebSocket
|
||||
} from "../run-agent-gui-performance.mjs";
|
||||
import {
|
||||
resolveAgentSessionReplayProjectRoot,
|
||||
resolveRecordScenarioProject,
|
||||
seedRecordingUserProject
|
||||
} from "./recording.mjs";
|
||||
import {
|
||||
createRuntime,
|
||||
enableAgentSessionRecordingFeature,
|
||||
enableAgentSessionRecordingTarget,
|
||||
initializeCleanDatabase,
|
||||
preparedDesktopLaunch,
|
||||
removeRuntime,
|
||||
setAgentComposerDefaults
|
||||
} from "./runtime.mjs";
|
||||
import {
|
||||
bindManagedReplayShutdown as bindManagedReplayShutdownCore,
|
||||
loadUiScenario,
|
||||
managedReplayCheckpointPrefix,
|
||||
managedReplayCompletePrefix,
|
||||
managedReplayFailedPrefix,
|
||||
runUiDriveScenario
|
||||
} from "../../../packages/agent/session-replay-runner/src/index.mjs";
|
||||
|
||||
function bindManagedReplayShutdown(desktop, options = {}) {
|
||||
const { stopDesktop = stopProcessTree, ...rest } = options;
|
||||
return bindManagedReplayShutdownCore(desktop, {
|
||||
...rest,
|
||||
exitOnSignal: rest.exitOnSignal ?? true,
|
||||
stopDesktop
|
||||
});
|
||||
}
|
||||
|
||||
export const uiDriveCompletePrefix = managedReplayCompletePrefix;
|
||||
export const uiDriveFailedPrefix = managedReplayFailedPrefix;
|
||||
export const uiDriveCheckpointPrefix = managedReplayCheckpointPrefix;
|
||||
|
||||
export { loadUiScenario };
|
||||
|
||||
function log(message) {
|
||||
process.stderr.write(`[agent-session-replay] ${message}\n`);
|
||||
}
|
||||
|
||||
async function captureScreenshot(client, outputPath) {
|
||||
const result = await client.send("Page.captureScreenshot", {
|
||||
format: "png",
|
||||
fromSurface: true
|
||||
});
|
||||
await writeFile(outputPath, Buffer.from(result.data, "base64"));
|
||||
}
|
||||
|
||||
async function prepareAgentGuiSurface(client, agentTargetId, timeoutMs) {
|
||||
await waitForEvaluation(
|
||||
client,
|
||||
`(() => {
|
||||
const target = [...document.querySelectorAll('[data-provider-target-id]')]
|
||||
.find((element) => element.dataset.providerTargetId === ${JSON.stringify(agentTargetId)});
|
||||
const dock = document.querySelector(
|
||||
'[data-desktop-dock-anchor-key="agent-gui:unified"] button'
|
||||
);
|
||||
return { ready: Boolean(target) || dock instanceof HTMLButtonElement };
|
||||
})()`,
|
||||
timeoutMs,
|
||||
"AgentGUI surface or dock launcher"
|
||||
);
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let latest;
|
||||
while (Date.now() < deadline) {
|
||||
latest = await evaluate(
|
||||
client,
|
||||
`(() => {
|
||||
const target = [...document.querySelectorAll('[data-provider-target-id]')]
|
||||
.find((element) => element.dataset.providerTargetId === ${JSON.stringify(agentTargetId)});
|
||||
if (
|
||||
target instanceof HTMLButtonElement &&
|
||||
!target.disabled &&
|
||||
target.dataset.disabled !== 'true'
|
||||
) {
|
||||
return { ready: true };
|
||||
}
|
||||
const dock = document.querySelector(
|
||||
'[data-desktop-dock-anchor-key="agent-gui:unified"] button'
|
||||
);
|
||||
if (!(dock instanceof HTMLButtonElement)) {
|
||||
return { ready: false, reason: 'dock-unavailable' };
|
||||
}
|
||||
dock.click();
|
||||
return { ready: false, reason: 'dock-clicked' };
|
||||
})()`
|
||||
);
|
||||
if (latest?.ready) break;
|
||||
await delay(1_000);
|
||||
}
|
||||
if (!latest?.ready) {
|
||||
throw new Error(
|
||||
`timed out waiting for enabled Agent target ${agentTargetId}: ${JSON.stringify(latest)}`
|
||||
);
|
||||
}
|
||||
await selectProvider(client, agentTargetId, timeoutMs);
|
||||
await waitForEvaluation(
|
||||
client,
|
||||
`(() => {
|
||||
const editor = document.querySelector('[data-testid="agent-gui-composer-editor"]');
|
||||
const stop = document.querySelector('[data-testid="agent-gui-composer-stop-active-turn"]');
|
||||
return { ready: editor instanceof HTMLElement && !stop };
|
||||
})()`,
|
||||
timeoutMs,
|
||||
"idle Agent composer"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a pure-UI scenario with step checkpoints (screenshot + programmatic assert).
|
||||
* Does not create Session Replay cassettes.
|
||||
*/
|
||||
export async function uiDriveScenario(options) {
|
||||
const workspaceRoot = options.workspaceRoot;
|
||||
const projectRoot = resolveAgentSessionReplayProjectRoot();
|
||||
const agentTargetId = options.agentTargetId ?? "local:codex";
|
||||
const cassetteId = options.cassetteId ?? `${options.scenario}_ui`;
|
||||
|
||||
return runUiDriveScenario(
|
||||
{
|
||||
...options,
|
||||
agentTargetId,
|
||||
cassetteId
|
||||
},
|
||||
{
|
||||
log,
|
||||
prefixes: {
|
||||
// Tutti historically omitted a ready line for ui-drive.
|
||||
ready: "",
|
||||
complete: uiDriveCompletePrefix,
|
||||
failed: uiDriveFailedPrefix,
|
||||
checkpoint: uiDriveCheckpointPrefix
|
||||
},
|
||||
emitReady() {},
|
||||
captureScreenshot,
|
||||
async prepare({ options: runOptions, scenario }) {
|
||||
const runtime = await createRuntime(workspaceRoot, "ui-drive");
|
||||
const databasePath = join(runtime.stateDirectory, "tuttid.db");
|
||||
const scenarioStallTimeoutMs = runOptions.stallTimeoutMs ?? 60_000;
|
||||
configureWaitDiagnostics({
|
||||
log,
|
||||
stallTimeoutMs: scenarioStallTimeoutMs
|
||||
});
|
||||
const workspaceId = "11111111-1111-4111-8111-111111111111";
|
||||
await initializeCleanDatabase(workspaceRoot, runtime, workspaceId);
|
||||
await enableAgentSessionRecordingFeature(databasePath, workspaceRoot);
|
||||
await enableAgentSessionRecordingTarget(
|
||||
databasePath,
|
||||
agentTargetId,
|
||||
workspaceRoot
|
||||
);
|
||||
let replayComposerDefaults = null;
|
||||
const scenarioState = await scenario.prepare({
|
||||
agentTargetId,
|
||||
workspaceRoot: projectRoot,
|
||||
async selectProject(project) {
|
||||
const projectSelection = resolveRecordScenarioProject(
|
||||
project,
|
||||
projectRoot
|
||||
);
|
||||
await seedRecordingUserProject(databasePath, projectSelection);
|
||||
return projectSelection;
|
||||
},
|
||||
async setComposerDefaults(defaults) {
|
||||
replayComposerDefaults = defaults;
|
||||
await setAgentComposerDefaults(
|
||||
databasePath,
|
||||
agentTargetId,
|
||||
defaults,
|
||||
workspaceRoot
|
||||
);
|
||||
}
|
||||
});
|
||||
return {
|
||||
composerDefaultsMissing: !replayComposerDefaults,
|
||||
databasePath,
|
||||
runtime,
|
||||
scenarioState,
|
||||
surface: "agent-gui"
|
||||
};
|
||||
},
|
||||
async launch({ options: runOptions, prepared }) {
|
||||
const runtime = prepared.runtime;
|
||||
const cdpPort = await reservePort();
|
||||
const logPath = join(runtime.directory, "logs", "desktop.log");
|
||||
await mkdir(join(runtime.directory, "logs"), { recursive: true });
|
||||
const desktopLaunch = preparedDesktopLaunch();
|
||||
const desktop = startDesktop({
|
||||
args: desktopLaunch?.args,
|
||||
cdpPort,
|
||||
command: desktopLaunch?.command,
|
||||
daemonPath: runtime.daemonPath,
|
||||
desktopLogPath: logPath,
|
||||
environment: {},
|
||||
headless: runOptions.headless === true,
|
||||
stateDirectory: runtime.stateDirectory,
|
||||
userDataDirectory: runtime.userDataDirectory
|
||||
});
|
||||
const disposeDesktopShutdown = bindManagedReplayShutdown(desktop);
|
||||
const pageWebSocket = await waitForPageWebSocket(
|
||||
cdpPort,
|
||||
desktop,
|
||||
runOptions.timeoutMs
|
||||
);
|
||||
const pageClient = await CdpClient.connect(pageWebSocket);
|
||||
await pageClient.send("Runtime.enable");
|
||||
await pageClient.send("Page.enable");
|
||||
await prepareAgentGuiSurface(
|
||||
pageClient,
|
||||
agentTargetId,
|
||||
runOptions.timeoutMs
|
||||
);
|
||||
return {
|
||||
desktop,
|
||||
disposeDesktopShutdown,
|
||||
getClient: () => pageClient,
|
||||
pageClient,
|
||||
surface: "agent-gui"
|
||||
};
|
||||
},
|
||||
async buildDriveContext({
|
||||
options: runOptions,
|
||||
prepared,
|
||||
launched,
|
||||
checkpoint
|
||||
}) {
|
||||
const pageClient = launched.getClient();
|
||||
return {
|
||||
checkpoint,
|
||||
client: pageClient,
|
||||
evaluate: (expression, awaitPromise) =>
|
||||
evaluate(pageClient, expression, awaitPromise),
|
||||
scenarioState: prepared.scenarioState,
|
||||
timeoutMs: runOptions.timeoutMs,
|
||||
waitForEvaluation: (expression, label, intervalMs) =>
|
||||
waitForEvaluation(
|
||||
pageClient,
|
||||
expression,
|
||||
runOptions.timeoutMs,
|
||||
label,
|
||||
intervalMs
|
||||
)
|
||||
};
|
||||
},
|
||||
async cleanup({ options: runOptions, prepared, launched }) {
|
||||
launched?.disposeDesktopShutdown?.();
|
||||
if (launched?.pageClient) {
|
||||
try {
|
||||
launched.pageClient.close?.();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
if (launched?.desktop) {
|
||||
await stopProcessTree(launched.desktop).catch(() => {});
|
||||
}
|
||||
if (prepared?.runtime && !runOptions.keepRuntime) {
|
||||
await removeRuntime(prepared.runtime.directory).catch(() => {});
|
||||
} else if (prepared?.runtime) {
|
||||
log(`runtime kept: ${prepared.runtime.directory}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user