Skip to content

Commit 8ded756

Browse files
authored
refactor: add transcript reader seam (#89121)
Merged via squash. Prepared head SHA: 7ea7ea4 Co-authored-by: jalehman <[email protected]> Co-authored-by: jalehman <[email protected]> Reviewed-by: @jalehman
1 parent f00de6b commit 8ded756

40 files changed

Lines changed: 1405 additions & 287 deletions

.github/workflows/ci.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1365,6 +1365,8 @@ jobs:
13651365
boundary_shard: 2/4,3/4,4/4
13661366
- check_name: check-session-accessor-boundary
13671367
group: session-accessor-boundary
1368+
- check_name: check-session-transcript-reader-boundary
1369+
group: session-transcript-reader-boundary
13681370
- check_name: check-additional-extension-channels
13691371
group: extension-channels
13701372
- check_name: check-additional-extension-bundled
@@ -1520,6 +1522,9 @@ jobs:
15201522
run_check "lint:tmp:session-accessor-boundary" pnpm run lint:tmp:session-accessor-boundary
15211523
fi
15221524
;;
1525+
session-transcript-reader-boundary)
1526+
run_check "lint:tmp:session-transcript-reader-boundary" pnpm run lint:tmp:session-transcript-reader-boundary
1527+
;;
15231528
extension-channels)
15241529
run_check "lint:extensions:channels" pnpm run lint:extensions:channels
15251530
;;

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1598,6 +1598,7 @@
15981598
"lint:tmp:no-raw-channel-fetch": "node scripts/check-no-raw-channel-fetch.mjs",
15991599
"lint:tmp:no-raw-http2-imports": "node scripts/check-no-raw-http2-imports.mjs",
16001600
"lint:tmp:session-accessor-boundary": "node scripts/check-session-accessor-boundary.mjs",
1601+
"lint:tmp:session-transcript-reader-boundary": "node scripts/check-session-transcript-reader-boundary.mjs",
16011602
"lint:tmp:tsgo-core-boundary": "node scripts/check-tsgo-core-boundary.mjs",
16021603
"lint:ui:no-raw-window-open": "node scripts/check-no-raw-window-open.mjs",
16031604
"lint:web-fetch-provider-boundaries": "node scripts/check-web-fetch-provider-boundaries.mjs",
Lines changed: 259 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,259 @@
1+
#!/usr/bin/env node
2+
3+
import path from "node:path";
4+
import ts from "typescript";
5+
import {
6+
collectFileViolations,
7+
resolveRepoRoot,
8+
resolveSourceRoots,
9+
runAsScript,
10+
toLine,
11+
unwrapExpression,
12+
} from "./lib/ts-guard-utils.mjs";
13+
14+
const legacyTranscriptReaderModules = new Set([
15+
"../gateway/session-utils.js",
16+
"../gateway/session-utils.fs.js",
17+
"../../gateway/session-utils.js",
18+
"../../gateway/session-utils.fs.js",
19+
"./session-utils.js",
20+
"./session-utils.fs.js",
21+
"../session-utils.js",
22+
"../session-utils.fs.js",
23+
]);
24+
25+
const transcriptReaderNames = new Set([
26+
"attachOpenClawTranscriptMeta",
27+
"capArrayByJsonBytes",
28+
"readFirstUserMessageFromTranscript",
29+
"readLatestRecentSessionUsageFromTranscriptAsync",
30+
"readLatestSessionUsageFromTranscript",
31+
"readLatestSessionUsageFromTranscriptAsync",
32+
"readRecentSessionMessages",
33+
"readRecentSessionMessagesAsync",
34+
"readRecentSessionMessagesWithStats",
35+
"readRecentSessionMessagesWithStatsAsync",
36+
"readRecentSessionTranscriptLines",
37+
"readRecentSessionUsageFromTranscript",
38+
"readRecentSessionUsageFromTranscriptAsync",
39+
"readSessionMessageByIdAsync",
40+
"readSessionMessageCount",
41+
"readSessionMessageCountAsync",
42+
"readSessionMessages",
43+
"readSessionMessagesAsync",
44+
"readSessionMessagesWithSourceAsync",
45+
"readSessionPreviewItemsFromTranscript",
46+
"readSessionTitleFieldsFromTranscript",
47+
"readSessionTitleFieldsFromTranscriptAsync",
48+
"visitSessionMessages",
49+
"visitSessionMessagesAsync",
50+
]);
51+
52+
export const migratedSessionTranscriptReaderFiles = new Set([
53+
"src/agents/main-session-restart-recovery.ts",
54+
"src/agents/subagent-announce-output.test.ts",
55+
"src/agents/subagent-announce-output.ts",
56+
"src/agents/subagent-announce.runtime.ts",
57+
"src/agents/subagent-orphan-recovery.test.ts",
58+
"src/agents/subagent-orphan-recovery.ts",
59+
"src/agents/tools/embedded-gateway-stub.runtime.ts",
60+
"src/agents/tools/embedded-gateway-stub.test.ts",
61+
"src/agents/tools/embedded-gateway-stub.ts",
62+
"src/agents/tools/sessions-history-tool.ts",
63+
"src/agents/tools/sessions-list-tool.ts",
64+
"src/gateway/cli-session-history.claude.ts",
65+
"src/gateway/gateway-models.profiles.live.test.ts",
66+
"src/gateway/managed-image-attachments.test.ts",
67+
"src/gateway/managed-image-attachments.ts",
68+
"src/gateway/server-methods/artifacts.test.ts",
69+
"src/gateway/server-methods/artifacts.ts",
70+
"src/gateway/server-methods/chat.ts",
71+
"src/gateway/server-methods/sessions-files.test.ts",
72+
"src/gateway/server-methods/sessions-files.ts",
73+
"src/gateway/server-methods/sessions.ts",
74+
"src/gateway/server-session-events.ts",
75+
"src/gateway/session-history-state.test.ts",
76+
"src/gateway/session-history-state.ts",
77+
"src/gateway/session-reset-service.ts",
78+
"src/gateway/session-utils.ts",
79+
"src/gateway/sessions-history-http.revocation.test.ts",
80+
"src/gateway/sessions-history-http.ts",
81+
"src/status/status-message.ts",
82+
"src/tui/embedded-backend.test.ts",
83+
"src/tui/embedded-backend.ts",
84+
]);
85+
86+
function normalizeRelativePath(filePath) {
87+
return filePath.replaceAll(path.sep, "/");
88+
}
89+
90+
function importedModuleName(node) {
91+
return node.moduleSpecifier && ts.isStringLiteral(node.moduleSpecifier)
92+
? node.moduleSpecifier.text
93+
: null;
94+
}
95+
96+
function bindingName(node) {
97+
if (node.propertyName && ts.isIdentifier(node.propertyName)) {
98+
return node.propertyName.text;
99+
}
100+
if (ts.isIdentifier(node.name)) {
101+
return node.name.text;
102+
}
103+
return null;
104+
}
105+
106+
function destructuresLegacyNamespace(node, legacyNamespaces) {
107+
const pattern = node.parent;
108+
const declaration = pattern?.parent;
109+
if (
110+
!pattern ||
111+
!ts.isObjectBindingPattern(pattern) ||
112+
!declaration ||
113+
!ts.isVariableDeclaration(declaration) ||
114+
!declaration.initializer
115+
) {
116+
return false;
117+
}
118+
119+
const initializer = unwrapExpression(declaration.initializer);
120+
return ts.isIdentifier(initializer) && legacyNamespaces.has(initializer.text);
121+
}
122+
123+
export function findSessionTranscriptReaderBoundaryViolations(content, fileName = "source.ts") {
124+
const sourceFile = ts.createSourceFile(fileName, content, ts.ScriptTarget.Latest, true);
125+
const violations = [];
126+
const legacyNamespaces = new Set();
127+
128+
const visit = (node) => {
129+
if (ts.isImportDeclaration(node)) {
130+
const moduleName = importedModuleName(node);
131+
const namedBindings = node.importClause?.namedBindings;
132+
if (moduleName && legacyTranscriptReaderModules.has(moduleName) && namedBindings) {
133+
if (ts.isNamedImports(namedBindings)) {
134+
for (const specifier of namedBindings.elements) {
135+
const importedName = specifier.propertyName?.text ?? specifier.name.text;
136+
if (transcriptReaderNames.has(importedName)) {
137+
violations.push({
138+
line: toLine(sourceFile, specifier),
139+
reason: `imports transcript reader "${importedName}" from legacy module "${moduleName}"`,
140+
});
141+
}
142+
}
143+
} else if (ts.isNamespaceImport(namedBindings)) {
144+
legacyNamespaces.add(namedBindings.name.text);
145+
}
146+
}
147+
}
148+
149+
if (ts.isExportDeclaration(node)) {
150+
const moduleName = importedModuleName(node);
151+
if (moduleName && legacyTranscriptReaderModules.has(moduleName)) {
152+
const exportClause = node.exportClause;
153+
if (!exportClause) {
154+
violations.push({
155+
line: toLine(sourceFile, node),
156+
reason: `re-exports transcript readers from legacy module "${moduleName}"`,
157+
});
158+
} else if (ts.isNamedExports(exportClause)) {
159+
for (const specifier of exportClause.elements) {
160+
const exportedName = specifier.propertyName?.text ?? specifier.name.text;
161+
if (transcriptReaderNames.has(exportedName)) {
162+
violations.push({
163+
line: toLine(sourceFile, specifier),
164+
reason: `re-exports transcript reader "${exportedName}" from legacy module "${moduleName}"`,
165+
});
166+
}
167+
}
168+
} else if (ts.isNamespaceExport(exportClause)) {
169+
violations.push({
170+
line: toLine(sourceFile, exportClause),
171+
reason: `re-exports transcript reader namespace from legacy module "${moduleName}"`,
172+
});
173+
}
174+
}
175+
}
176+
177+
if (ts.isBindingElement(node)) {
178+
const name = bindingName(node);
179+
if (
180+
name &&
181+
transcriptReaderNames.has(name) &&
182+
destructuresLegacyNamespace(node, legacyNamespaces)
183+
) {
184+
violations.push({
185+
line: toLine(sourceFile, node),
186+
reason: `aliases legacy transcript reader "${name}"`,
187+
});
188+
}
189+
}
190+
191+
if (ts.isPropertyAccessExpression(node)) {
192+
const receiver = unwrapExpression(node.expression);
193+
if (
194+
ts.isIdentifier(receiver) &&
195+
legacyNamespaces.has(receiver.text) &&
196+
transcriptReaderNames.has(node.name.text)
197+
) {
198+
violations.push({
199+
line: toLine(sourceFile, node.name),
200+
reason: `references legacy transcript reader "${node.name.text}"`,
201+
});
202+
}
203+
}
204+
205+
if (
206+
ts.isElementAccessExpression(node) &&
207+
ts.isIdentifier(unwrapExpression(node.expression)) &&
208+
legacyNamespaces.has(unwrapExpression(node.expression).text) &&
209+
ts.isStringLiteral(node.argumentExpression) &&
210+
transcriptReaderNames.has(node.argumentExpression.text)
211+
) {
212+
violations.push({
213+
line: toLine(sourceFile, node.argumentExpression),
214+
reason: `references legacy transcript reader "${node.argumentExpression.text}"`,
215+
});
216+
}
217+
218+
ts.forEachChild(node, visit);
219+
};
220+
221+
visit(sourceFile);
222+
return violations;
223+
}
224+
225+
export async function main() {
226+
const repoRoot = resolveRepoRoot(import.meta.url);
227+
const sourceRoots = resolveSourceRoots(repoRoot, [
228+
"src/agents",
229+
"src/gateway",
230+
"src/status",
231+
"src/tui",
232+
]);
233+
const violations = await collectFileViolations({
234+
repoRoot,
235+
sourceRoots,
236+
includeTests: true,
237+
skipFile: (filePath) =>
238+
!migratedSessionTranscriptReaderFiles.has(
239+
normalizeRelativePath(path.relative(repoRoot, filePath)),
240+
),
241+
findViolations: findSessionTranscriptReaderBoundaryViolations,
242+
});
243+
244+
if (violations.length === 0) {
245+
console.log("session transcript reader boundary guard passed.");
246+
return;
247+
}
248+
249+
console.error("Found legacy transcript reader usage in migrated files:");
250+
for (const violation of violations) {
251+
console.error(`- ${violation.path}:${violation.line}: ${violation.reason}`);
252+
}
253+
console.error(
254+
"Use src/gateway/session-transcript-readers.ts for migrated transcript reader paths. Expand this ratchet only after a slice migrates more files.",
255+
);
256+
process.exit(1);
257+
}
258+
259+
runAsScript(import.meta.url, main);

scripts/lib/ts-guard-utils.mjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,7 @@ export async function collectTypeScriptFilesFromRoots(sourceRoots, options = {})
124124
*/
125125
export async function collectFileViolations(params) {
126126
const files = await collectTypeScriptFilesFromRoots(params.sourceRoots, {
127+
includeTests: params.includeTests,
127128
extraTestSuffixes: params.extraTestSuffixes,
128129
});
129130

src/agents/main-session-restart-recovery.ts

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,15 +19,20 @@ import {
1919
} from "../config/sessions.js";
2020
import type { OpenClawConfig } from "../config/types.openclaw.js";
2121
import { callGateway } from "../gateway/call.js";
22-
import { readSessionMessagesAsync } from "../gateway/session-utils.fs.js";
22+
import { readSessionMessagesAsync } from "../gateway/session-transcript-readers.js";
2323
import { resolveGatewaySessionStoreTarget } from "../gateway/session-utils.js";
2424
import {
2525
getAgentEventLifecycleGeneration,
2626
listAgentRunsForSession,
2727
} from "../infra/agent-events.js";
2828
import { createSubsystemLogger } from "../logging/subsystem.js";
2929
import { CommandLane } from "../process/lanes.js";
30-
import { isAcpSessionKey, isCronSessionKey, isSubagentSessionKey } from "../routing/session-key.js";
30+
import {
31+
isAcpSessionKey,
32+
isCronSessionKey,
33+
isSubagentSessionKey,
34+
resolveAgentIdFromSessionKey,
35+
} from "../routing/session-key.js";
3136
import { resolveSendPolicy } from "../sessions/send-policy.js";
3237
import {
3338
deliveryContextFromSession,
@@ -785,9 +790,12 @@ async function recoverStore(params: {
785790
let messages: unknown[];
786791
try {
787792
messages = await readSessionMessagesAsync(
788-
entry.sessionId,
789-
params.storePath,
790-
entry.sessionFile,
793+
{
794+
agentId: resolveAgentIdFromSessionKey(sessionKey),
795+
sessionFile: entry.sessionFile,
796+
sessionId: entry.sessionId,
797+
storePath: params.storePath,
798+
},
791799
{
792800
mode: "recent",
793801
maxMessages: 20,

src/agents/subagent-announce-output.test.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -216,9 +216,10 @@ describe("readSubagentOutput", () => {
216216
}),
217217
).resolves.toBe("fresh recovered output");
218218
expect(deps.readSessionMessagesAsync).toHaveBeenCalledWith(
219-
"agent:main:subagent:child",
220-
undefined,
221-
"/tmp/openclaw-internal-run.jsonl",
219+
{
220+
sessionFile: "/tmp/openclaw-internal-run.jsonl",
221+
sessionId: "agent:main:subagent:child",
222+
},
222223
{ mode: "recent", maxMessages: 100, maxBytes: 1024 * 1024 },
223224
);
224225
expect(deps.callGateway).not.toHaveBeenCalled();

src/agents/subagent-announce-output.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -207,9 +207,10 @@ export async function readSubagentOutput(
207207
let messages: unknown[] | undefined;
208208
if (options?.sessionFile) {
209209
const transcriptMessages = await subagentAnnounceOutputDeps.readSessionMessagesAsync(
210-
sessionKey,
211-
undefined,
212-
options.sessionFile,
210+
{
211+
sessionFile: options.sessionFile,
212+
sessionId: sessionKey,
213+
},
213214
{
214215
mode: "recent",
215216
maxMessages: 100,

src/agents/subagent-announce.runtime.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ export {
1212
resolveStorePath,
1313
} from "../config/sessions.js";
1414
export { callGateway } from "../gateway/call.js";
15-
export { readSessionMessagesAsync } from "../gateway/session-utils.fs.js";
15+
export { readSessionMessagesAsync } from "../gateway/session-transcript-readers.js";
1616
export { dispatchGatewayMethodInProcess } from "../gateway/server-plugins.js";
1717
export {
1818
isEmbeddedAgentRunActive,

src/agents/subagent-orphan-recovery.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
44
import * as sessions from "../config/sessions.js";
55
import * as gateway from "../gateway/call.js";
6-
import * as sessionUtils from "../gateway/session-utils.fs.js";
6+
import * as sessionUtils from "../gateway/session-transcript-readers.js";
77
import { resolveInternalSessionEffectsTranscriptPath } from "./internal-session-effects.js";
88
import * as announceDelivery from "./subagent-announce-delivery.js";
99
import {
@@ -32,7 +32,7 @@ vi.mock("../gateway/call.js", () => ({
3232
callGateway: vi.fn(async () => ({ runId: "test-run-id" })),
3333
}));
3434

35-
vi.mock("../gateway/session-utils.fs.js", () => ({
35+
vi.mock("../gateway/session-transcript-readers.js", () => ({
3636
readSessionMessagesAsync: vi.fn(async () => []),
3737
}));
3838

0 commit comments

Comments
 (0)