mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-26 19:22:51 +00:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fd2e761608 | ||
|
|
593a155562 | ||
|
|
806edd800b | ||
|
|
1a02425db4 | ||
|
|
d78de43e4d | ||
|
|
a5f246a18c | ||
|
|
32356db309 |
@@ -1,13 +1,9 @@
|
||||
import fs, { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { open } from "node:fs/promises";
|
||||
import { performance } from "node:perf_hooks";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import pino from "pino";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { createTestPaseoDaemon } from "../../../test-utils/paseo-daemon.js";
|
||||
import type { ClaudeRewindSdk } from "./rewind.js";
|
||||
import { ClaudeAgentClient } from "./agent.js";
|
||||
import { claudeProjectDirSync } from "./project-dir.js";
|
||||
import { withClaudeReplayHistory } from "./transcript-history.js";
|
||||
@@ -36,10 +32,7 @@ describe("ClaudeAgentSession persisted history load", () => {
|
||||
let configDir: string;
|
||||
let records: LogRecord[];
|
||||
|
||||
async function resume(
|
||||
queryFactory?: ClaudeQueryFactory,
|
||||
rewindSdk?: ClaudeRewindSdk,
|
||||
): Promise<AgentSession> {
|
||||
async function resume(queryFactory?: ClaudeQueryFactory): Promise<AgentSession> {
|
||||
const client = new ClaudeAgentClient({
|
||||
logger: pino({ level: "info" }, { write: (line: string) => records.push(JSON.parse(line)) }),
|
||||
queryFactory:
|
||||
@@ -49,36 +42,10 @@ describe("ClaudeAgentSession persisted history load", () => {
|
||||
}),
|
||||
resolveBinary: async () => process.execPath,
|
||||
resolveVersion: async () => "2.1.220",
|
||||
rewindSdk,
|
||||
});
|
||||
return client.resumeSession({ provider: "claude", sessionId: SESSION_ID }, { cwd });
|
||||
}
|
||||
|
||||
function replyFactory(text: string, messageId = "live-reply"): ClaudeQueryFactory {
|
||||
return (() => {
|
||||
const messages = [
|
||||
{ type: "assistant", uuid: messageId, message: { content: text } },
|
||||
{ type: "result", subtype: "success", total_cost_usd: 0 },
|
||||
];
|
||||
return {
|
||||
async next() {
|
||||
const value = messages.shift();
|
||||
return value ? { done: false, value } : { done: true, value: undefined };
|
||||
},
|
||||
close() {},
|
||||
async return() {
|
||||
return { done: true, value: undefined };
|
||||
},
|
||||
async supportedCommands() {
|
||||
return [];
|
||||
},
|
||||
[Symbol.asyncIterator]() {
|
||||
return this;
|
||||
},
|
||||
};
|
||||
}) as ClaudeQueryFactory;
|
||||
}
|
||||
|
||||
function transcriptPath(): string {
|
||||
return path.join(claudeProjectDirSync(cwd, { configDir }), `${SESSION_ID}.jsonl`);
|
||||
}
|
||||
@@ -98,286 +65,6 @@ describe("ClaudeAgentSession persisted history load", () => {
|
||||
rmSync(tempRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("serves unrelated health requests while resuming 32 MiB of ordered history", async () => {
|
||||
const startedAt = performance.now();
|
||||
mkdirSync(path.dirname(transcriptPath()), { recursive: true });
|
||||
const file = await open(transcriptPath(), "w");
|
||||
const text = "x".repeat(16 * 1024);
|
||||
let bytes = 0;
|
||||
let count = 0;
|
||||
try {
|
||||
while (bytes < 32 * 1024 * 1024) {
|
||||
const line =
|
||||
JSON.stringify({ type: "assistant", message: { content: `${count}:${text}` } }) + "\n";
|
||||
bytes += Buffer.byteLength(line);
|
||||
await file.write(line);
|
||||
count++;
|
||||
}
|
||||
} finally {
|
||||
await file.close();
|
||||
}
|
||||
const daemon = await createTestPaseoDaemon();
|
||||
let lastTick = performance.now();
|
||||
let longestGap = 0;
|
||||
const probe = setInterval(() => {
|
||||
const now = performance.now();
|
||||
longestGap = Math.max(longestGap, now - lastTick);
|
||||
lastTick = now;
|
||||
}, 2);
|
||||
let loading = true;
|
||||
let answeredDuringLoad = false;
|
||||
const health = fetch(`http://127.0.0.1:${daemon.port}/api/health`).then(async (response) => {
|
||||
expect(response.status).toBe(200);
|
||||
await response.text();
|
||||
answeredDuringLoad = loading;
|
||||
return undefined;
|
||||
});
|
||||
let session: AgentSession | undefined;
|
||||
try {
|
||||
session = await resume(replyFactory("live after large resume"));
|
||||
let index = 0;
|
||||
for await (const event of session.streamHistory()) {
|
||||
expect(event).toEqual({
|
||||
type: "timeline",
|
||||
provider: "claude",
|
||||
timestamp: undefined,
|
||||
item: { type: "assistant_message", text: `${index}:${text}` },
|
||||
});
|
||||
index++;
|
||||
}
|
||||
loading = false;
|
||||
await health;
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
console.info(
|
||||
JSON.stringify({
|
||||
bytes,
|
||||
count,
|
||||
longestGap,
|
||||
answeredDuringLoad,
|
||||
elapsedMs: performance.now() - startedAt,
|
||||
}),
|
||||
);
|
||||
expect(index).toBe(count);
|
||||
expect(longestGap).toBeLessThan(50);
|
||||
expect(answeredDuringLoad).toBe(true);
|
||||
const live = await collectSessionTurnEvents(session, "follow up");
|
||||
expect(
|
||||
live
|
||||
.filter((event) => event.type === "timeline" && event.item.type === "assistant_message")
|
||||
.map((event) => (event.type === "timeline" ? event.item : null)),
|
||||
).toEqual([
|
||||
{ type: "assistant_message", messageId: "live-reply", text: "live after large resume" },
|
||||
]);
|
||||
} finally {
|
||||
clearInterval(probe);
|
||||
await session?.close();
|
||||
await daemon.close();
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
test("rebinds ordered history before returning from rewind and accepts a live turn", async () => {
|
||||
const entries = [
|
||||
{ type: "user", uuid: "u1", message: { content: "first" } },
|
||||
{ type: "assistant", uuid: "a1", message: { content: "first reply" } },
|
||||
{ type: "user", uuid: "u2", message: { content: "second" } },
|
||||
{ type: "assistant", uuid: "a2", message: { content: "second reply" } },
|
||||
];
|
||||
mkdirSync(path.dirname(transcriptPath()), { recursive: true });
|
||||
writeFileSync(transcriptPath(), entries.map((entry) => JSON.stringify(entry)).join("\n"));
|
||||
const forkId = "rebound-history";
|
||||
const forkFile = path.join(path.dirname(transcriptPath()), `${forkId}.jsonl`);
|
||||
writeFileSync(
|
||||
forkFile,
|
||||
entries
|
||||
.slice(0, 2)
|
||||
.map((entry) => JSON.stringify(entry))
|
||||
.join("\n"),
|
||||
);
|
||||
const forks: unknown[] = [];
|
||||
const late = {
|
||||
type: "assistant",
|
||||
uuid: "retired-reply",
|
||||
message: { content: "retired query" },
|
||||
};
|
||||
let pendingRead: ((value: unknown) => void) | undefined;
|
||||
let releaseRetirement!: () => void;
|
||||
const retirementGate = new Promise<void>((resolve) => {
|
||||
releaseRetirement = resolve;
|
||||
});
|
||||
let retirementStarted!: () => void;
|
||||
const retiring = new Promise<void>((resolve) => {
|
||||
retirementStarted = resolve;
|
||||
});
|
||||
let firstQuery = true;
|
||||
let retired = false;
|
||||
const queryFactory = (() => {
|
||||
if (!firstQuery) return replyFactory("live after rebind")({} as never);
|
||||
firstQuery = false;
|
||||
const messages = [
|
||||
{ type: "assistant", uuid: "before-rebind", message: { content: "before rebind" } },
|
||||
{ type: "result", subtype: "success", total_cost_usd: 0 },
|
||||
];
|
||||
return {
|
||||
async next() {
|
||||
const value = messages.shift();
|
||||
if (value) return { done: false, value };
|
||||
if (retired) return { done: true, value: undefined };
|
||||
return new Promise((resolve) => {
|
||||
pendingRead = resolve;
|
||||
});
|
||||
},
|
||||
close() {
|
||||
retired = true;
|
||||
pendingRead?.({ done: false, value: late });
|
||||
},
|
||||
async return() {
|
||||
retirementStarted();
|
||||
await retirementGate;
|
||||
return { done: true, value: undefined };
|
||||
},
|
||||
async supportedCommands() {
|
||||
return [];
|
||||
},
|
||||
[Symbol.asyncIterator]() {
|
||||
return this;
|
||||
},
|
||||
};
|
||||
}) as ClaudeQueryFactory;
|
||||
const session = await resume(queryFactory, {
|
||||
async forkSession(sessionId, options) {
|
||||
forks.push({ sessionId, ...options });
|
||||
return { sessionId: forkId };
|
||||
},
|
||||
});
|
||||
try {
|
||||
const original = await Array.fromAsync(session.streamHistory());
|
||||
expect(original).toHaveLength(4);
|
||||
await collectSessionTurnEvents(session, "before rewind");
|
||||
const emitted: AgentStreamEvent[] = [];
|
||||
const subscription = session.subscribe((event) => emitted.push(event));
|
||||
let rewindDone = false;
|
||||
const rewind = (async () => {
|
||||
await session.revertConversation!({ messageId: "u2" });
|
||||
rewindDone = true;
|
||||
})();
|
||||
await retiring;
|
||||
let historyDone = false;
|
||||
const reading = (async () => {
|
||||
const history = await Array.fromAsync(session.streamHistory());
|
||||
historyDone = true;
|
||||
return history;
|
||||
})();
|
||||
const readingAgain = Array.fromAsync(session.streamHistory());
|
||||
await Promise.resolve();
|
||||
expect(rewindDone).toBe(false);
|
||||
expect(historyDone).toBe(false);
|
||||
releaseRetirement();
|
||||
await rewind;
|
||||
subscription();
|
||||
expect(retired).toBe(true);
|
||||
expect(
|
||||
emitted.filter(
|
||||
(event) => event.type === "timeline" && event.item.type === "assistant_message",
|
||||
),
|
||||
).toEqual([
|
||||
{
|
||||
type: "timeline",
|
||||
provider: "claude",
|
||||
item: {
|
||||
type: "assistant_message",
|
||||
text: `Claude switched to a new session: ${SESSION_ID} -> ${forkId}`,
|
||||
},
|
||||
},
|
||||
]);
|
||||
expect(forks).toEqual([
|
||||
{ sessionId: SESSION_ID, upToMessageId: "a1", transcriptPath: transcriptPath() },
|
||||
]);
|
||||
expect(session.id).toBe(forkId);
|
||||
const [history, secondRead] = await Promise.all([reading, readingAgain]);
|
||||
expect(history).toEqual(original.slice(0, 2));
|
||||
expect(secondRead).toEqual([]);
|
||||
const live = await collectSessionTurnEvents(session, "continue the fork");
|
||||
expect(
|
||||
live
|
||||
.filter((event) => event.type === "timeline" && event.item.type === "assistant_message")
|
||||
.map((event) => (event.type === "timeline" ? event.item : null)),
|
||||
).toEqual([
|
||||
{ type: "assistant_message", messageId: "live-reply", text: "live after rebind" },
|
||||
]);
|
||||
} finally {
|
||||
releaseRetirement();
|
||||
await session.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps history and live turns available after query retirement fails during rebind", async () => {
|
||||
mkdirSync(path.dirname(transcriptPath()), { recursive: true });
|
||||
writeFileSync(
|
||||
transcriptPath(),
|
||||
[
|
||||
{ type: "user", uuid: "u1", message: { content: "first" } },
|
||||
{ type: "assistant", uuid: "a1", message: { content: "reply" } },
|
||||
{ type: "user", uuid: "u2", message: { content: "second" } },
|
||||
]
|
||||
.map((entry) => JSON.stringify(entry))
|
||||
.join("\n"),
|
||||
);
|
||||
let firstQuery = true;
|
||||
let finishRead: ((value: unknown) => void) | undefined;
|
||||
const queryFactory = ((input) => {
|
||||
const query = replyFactory(
|
||||
firstQuery ? "before retirement" : "still available",
|
||||
firstQuery ? "before-retirement" : "live-reply",
|
||||
)(input);
|
||||
if (!firstQuery) return query;
|
||||
firstQuery = false;
|
||||
let reads = 0;
|
||||
return {
|
||||
...query,
|
||||
async next() {
|
||||
if (++reads <= 2) return query.next();
|
||||
return new Promise((resolve) => {
|
||||
finishRead = resolve;
|
||||
});
|
||||
},
|
||||
close() {
|
||||
finishRead?.({ done: true, value: undefined });
|
||||
throw new Error("retirement failed");
|
||||
},
|
||||
[Symbol.asyncIterator]() {
|
||||
return this;
|
||||
},
|
||||
};
|
||||
}) as ClaudeQueryFactory;
|
||||
const session = await resume(queryFactory, {
|
||||
async forkSession() {
|
||||
return { sessionId: "failed-rebind" };
|
||||
},
|
||||
});
|
||||
try {
|
||||
await Array.fromAsync(session.streamHistory());
|
||||
await collectSessionTurnEvents(session, "before rewind");
|
||||
await expect(session.revertConversation!({ messageId: "u2" })).resolves.toBeUndefined();
|
||||
expect(records).toContainEqual(
|
||||
expect.objectContaining({
|
||||
level: pino.levels.values.warn,
|
||||
msg: "Failed to rebind Claude session",
|
||||
sessionId: "failed-rebind",
|
||||
err: expect.objectContaining({ message: "retirement failed" }),
|
||||
}),
|
||||
);
|
||||
expect(await Array.fromAsync(session.streamHistory())).toEqual([]);
|
||||
const live = await collectSessionTurnEvents(session, "after failed rebind");
|
||||
expect(
|
||||
live
|
||||
.filter((event) => event.type === "timeline" && event.item.type === "assistant_message")
|
||||
.map((event) => (event.type === "timeline" ? event.item : null)),
|
||||
).toEqual([{ type: "assistant_message", messageId: "live-reply", text: "still available" }]);
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
});
|
||||
|
||||
test.each(["file_path", "files"])(
|
||||
"replays %s without probing targets and preserves live existence checks",
|
||||
async (field) => {
|
||||
@@ -613,21 +300,21 @@ describe("ClaudeAgentSession persisted history load", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("loads readable history when the temporary directory is unavailable", async () => {
|
||||
test("loads readable history when the temporary directory is unavailable", () => {
|
||||
const file = transcriptPath();
|
||||
const entry = { type: "assistant", message: { content: [{ type: "text", text: "saved" }] } };
|
||||
mkdirSync(path.dirname(file), { recursive: true });
|
||||
writeFileSync(file, JSON.stringify(entry));
|
||||
const unavailable = path.join(tempRoot, "unavailable-tmp");
|
||||
for (const variable of ["TMPDIR", "TEMP", "TMP"]) vi.stubEnv(variable, unavailable);
|
||||
await withClaudeReplayHistory(file, async (history) => {
|
||||
expect(await Array.fromAsync(history.parentEntries)).toEqual([entry]);
|
||||
withClaudeReplayHistory(file, (history) => {
|
||||
expect([...history.parentEntries]).toEqual([entry]);
|
||||
});
|
||||
});
|
||||
|
||||
test.each(["parent", "child", "workflow"])(
|
||||
"preserves indexed %s history when its transcript disappears between passes",
|
||||
async (target) => {
|
||||
(target) => {
|
||||
const parent = transcriptPath();
|
||||
const sessionDirectory = parent.slice(0, -".jsonl".length);
|
||||
const child = path.join(sessionDirectory, "subagents", "agent-child.jsonl");
|
||||
@@ -647,16 +334,14 @@ describe("ClaudeAgentSession persisted history load", () => {
|
||||
writeFileSync(parent, JSON.stringify(parentEntry));
|
||||
writeFileSync(child, JSON.stringify(childEntry));
|
||||
writeFileSync(workflow, JSON.stringify(childEntry));
|
||||
await withClaudeReplayHistory(parent, async (history) => {
|
||||
withClaudeReplayHistory(parent, (history) => {
|
||||
rmSync({ parent, child, workflow }[target]);
|
||||
// Ownership facts and visible payloads are separate passes over the same source.
|
||||
for (let pass = 0; pass < 2; pass++) {
|
||||
expect(await Array.fromAsync(history.parentEntries)).toEqual([parentEntry]);
|
||||
expect([...history.parentEntries]).toEqual([parentEntry]);
|
||||
expect(history.subagents).toHaveLength(1);
|
||||
expect(await Array.fromAsync(history.subagents[0]!.entries)).toEqual([childEntry]);
|
||||
expect(await Array.fromAsync(history.workflowEntriesByRunId.get("wf_test")!)).toEqual([
|
||||
childEntry,
|
||||
]);
|
||||
expect([...history.subagents[0]!.entries]).toEqual([childEntry]);
|
||||
expect([...history.workflowEntriesByRunId.get("wf_test")!]).toEqual([childEntry]);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { setImmediate } from "node:timers/promises";
|
||||
import { ClaudeRuntimeSafety } from "./runtime-safety.js";
|
||||
import { validateProviderOptions } from "../../provider-options.js";
|
||||
import type { ChildProcess } from "node:child_process";
|
||||
@@ -83,7 +82,7 @@ import {
|
||||
type ClaudeRewindSdk,
|
||||
} from "./rewind.js";
|
||||
import { normalizeProviderReplayTimestamp } from "../../provider-history-timestamps.js";
|
||||
import { claudeConfigDir, claudeProjectDirSync, claudeTranscriptPath } from "./project-dir.js";
|
||||
import { claudeConfigDir, claudeProjectDirSync, claudeTranscriptPathSync } from "./project-dir.js";
|
||||
import { THINKING_APPLIES_NEXT_TURN_NOTICE } from "../../provider-notices.js";
|
||||
import {
|
||||
isProviderImageMarkdown,
|
||||
@@ -1635,7 +1634,7 @@ export class ClaudeAgentClient implements AgentClient {
|
||||
cwd: merged.cwd,
|
||||
};
|
||||
const claudeConfig = this.assertConfig(mergedConfig);
|
||||
const session = new ClaudeAgentSession(claudeConfig, {
|
||||
return new ClaudeAgentSession(claudeConfig, {
|
||||
defaults: this.defaults,
|
||||
runtimeSettings: this.runtimeSettings,
|
||||
handle,
|
||||
@@ -1646,8 +1645,6 @@ export class ClaudeAgentClient implements AgentClient {
|
||||
resolveBinary: this.resolveBinary,
|
||||
rewindSdk: this.rewindSdk,
|
||||
});
|
||||
await session.ensureHistoryReady();
|
||||
return session;
|
||||
}
|
||||
|
||||
async getCatalogCacheKey(_options: FetchCatalogOptions): Promise<string> {
|
||||
@@ -2332,9 +2329,6 @@ class ClaudeAgentSession implements AgentSession {
|
||||
{ type: "provider_subagent" }
|
||||
>[] = [];
|
||||
private historyPending = false;
|
||||
// Readiness never rejects; history/rebind failures are logged at their owning boundary.
|
||||
private historyReady: Promise<void> | null = null;
|
||||
private historySessionId: string | null = null;
|
||||
private replayingHistory = false;
|
||||
private turnState: TurnState = "idle";
|
||||
private nextTurnOrdinal = 1;
|
||||
@@ -2388,7 +2382,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
}
|
||||
this.claudeSessionId = handle.sessionId;
|
||||
this.persistence = handle;
|
||||
this.historySessionId = handle.sessionId;
|
||||
this.loadPersistedHistory(handle.sessionId);
|
||||
} else {
|
||||
this.claudeSessionId = null;
|
||||
this.persistence = null;
|
||||
@@ -2468,7 +2462,6 @@ class ClaudeAgentSession implements AgentSession {
|
||||
prompt: AgentPromptInput,
|
||||
options?: AgentRunOptions,
|
||||
): Promise<{ turnId: string }> {
|
||||
await this.ensureHistoryReady();
|
||||
if (this.closed) {
|
||||
throw new Error("Claude session is closed");
|
||||
}
|
||||
@@ -2639,7 +2632,6 @@ class ClaudeAgentSession implements AgentSession {
|
||||
}
|
||||
|
||||
async *streamHistory(): AsyncGenerator<AgentStreamEvent> {
|
||||
await this.ensureHistoryReady();
|
||||
if (
|
||||
!this.historyPending ||
|
||||
(this.persistedHistory.length === 0 && this.persistedProviderSubagentEvents.length === 0)
|
||||
@@ -2651,9 +2643,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
this.persistedHistory = [];
|
||||
this.persistedProviderSubagentEvents = [];
|
||||
this.historyPending = false;
|
||||
let replayed = 0;
|
||||
for (const entry of history) {
|
||||
if (++replayed % 32 === 0) await setImmediate();
|
||||
yield {
|
||||
type: "timeline",
|
||||
item: entry.item,
|
||||
@@ -2971,7 +2961,6 @@ class ClaudeAgentSession implements AgentSession {
|
||||
"provider.claude.session_close.start",
|
||||
);
|
||||
this.closed = true;
|
||||
await this.ensureHistoryReady();
|
||||
this.rejectAllPendingPermissions(new Error("Claude session closed"));
|
||||
this.cancelCurrentTurn?.();
|
||||
this.subscribers.clear();
|
||||
@@ -3012,7 +3001,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
// (see `claude --help`), so the SDK's persistSession=false is silently dropped
|
||||
// in stream-json mode. Sweep the transcript ourselves so ephemeral runs
|
||||
// (metadata generator, branch-name generator) don't show up as resumable.
|
||||
const historyPath = await this.resolveHistoryPath(this.claudeSessionId);
|
||||
const historyPath = this.resolveHistoryPath(this.claudeSessionId);
|
||||
if (historyPath) {
|
||||
try {
|
||||
await promises.rm(historyPath, { force: true });
|
||||
@@ -3056,7 +3045,6 @@ class ClaudeAgentSession implements AgentSession {
|
||||
}
|
||||
|
||||
async revertConversation(input: { messageId: string }): Promise<void> {
|
||||
await this.ensureHistoryReady();
|
||||
const target = this.resolveConversationRewindTarget(input.messageId);
|
||||
if (target.kind === "fresh-session") {
|
||||
this.startFreshConversationSession();
|
||||
@@ -3066,15 +3054,16 @@ class ClaudeAgentSession implements AgentSession {
|
||||
await revertClaudeConversation({
|
||||
sdk: this.rewindSdk,
|
||||
sessionId: currentSessionId,
|
||||
transcriptPath: currentSessionId ? await this.resolveHistoryPath(currentSessionId) : null,
|
||||
transcriptPath: currentSessionId ? this.resolveHistoryPath(currentSessionId) : null,
|
||||
messageId: target.messageId,
|
||||
resolveMessageId: (messageId) => this.resolveClaudeMessageId(messageId),
|
||||
setSessionId: (sessionId) => this.rebindConversationSession(sessionId),
|
||||
setSessionId: (sessionId) => {
|
||||
this.rebindConversationSession(sessionId);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async revertFiles(input: { messageId: string }): Promise<void> {
|
||||
await this.ensureHistoryReady();
|
||||
const messageId = await this.resolveClaudeMessageId(input.messageId);
|
||||
await revertClaudeFiles({
|
||||
query: await this.ensureQuery(),
|
||||
@@ -3249,51 +3238,38 @@ class ClaudeAgentSession implements AgentSession {
|
||||
return candidates;
|
||||
}
|
||||
|
||||
private rebindConversationSession(sessionId: string): Promise<void> {
|
||||
const previousReady = this.ensureHistoryReady();
|
||||
this.historyReady = (async () => {
|
||||
await previousReady;
|
||||
await this.retireQuery();
|
||||
if (this.closed) return;
|
||||
const oldSessionId = this.claudeSessionId;
|
||||
this.claudeSessionId = sessionId;
|
||||
this.pendingFreshSessionId = null;
|
||||
this.persistence = null;
|
||||
this.cachedRuntimeInfo = null;
|
||||
this.queryRestartNeeded = true;
|
||||
this.persistedHistory = [];
|
||||
this.persistedProviderSubagentEvents = [];
|
||||
this.historyPending = false;
|
||||
this.userMessageIds = [];
|
||||
this.emittedUserMessageIds.clear();
|
||||
this.rewindTurnAnchors.length = 0;
|
||||
this.taskState.reset();
|
||||
this.historySessionId = sessionId;
|
||||
await this.loadPersistedHistory(sessionId);
|
||||
if (this.closed) return;
|
||||
if (oldSessionId && oldSessionId !== sessionId) {
|
||||
this.dispatchEvents([
|
||||
{
|
||||
type: "timeline",
|
||||
provider: "claude",
|
||||
item: this.createClaudeSessionChangedNotice(oldSessionId, sessionId),
|
||||
},
|
||||
{
|
||||
type: "thread_started",
|
||||
provider: "claude",
|
||||
sessionId,
|
||||
},
|
||||
]);
|
||||
}
|
||||
})().catch((error) => {
|
||||
this.logger.warn({ err: error, sessionId }, "Failed to rebind Claude session");
|
||||
});
|
||||
return this.historyReady;
|
||||
private rebindConversationSession(sessionId: string): void {
|
||||
const oldSessionId = this.claudeSessionId;
|
||||
this.claudeSessionId = sessionId;
|
||||
this.pendingFreshSessionId = null;
|
||||
this.persistence = null;
|
||||
this.cachedRuntimeInfo = null;
|
||||
this.queryRestartNeeded = true;
|
||||
this.persistedHistory = [];
|
||||
this.persistedProviderSubagentEvents = [];
|
||||
this.historyPending = false;
|
||||
this.userMessageIds = [];
|
||||
this.emittedUserMessageIds.clear();
|
||||
this.rewindTurnAnchors.length = 0;
|
||||
this.taskState.reset();
|
||||
this.loadPersistedHistory(sessionId);
|
||||
if (oldSessionId && oldSessionId !== sessionId) {
|
||||
this.dispatchEvents([
|
||||
{
|
||||
type: "timeline",
|
||||
provider: "claude",
|
||||
item: this.createClaudeSessionChangedNotice(oldSessionId, sessionId),
|
||||
},
|
||||
{
|
||||
type: "thread_started",
|
||||
provider: "claude",
|
||||
sessionId,
|
||||
},
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
private startFreshConversationSession(): void {
|
||||
this.historySessionId = null;
|
||||
this.historyReady = Promise.resolve();
|
||||
const sessionId = randomUUID();
|
||||
this.claudeSessionId = sessionId;
|
||||
this.pendingFreshSessionId = sessionId;
|
||||
@@ -3414,43 +3390,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
return { kind: "fresh-session" };
|
||||
}
|
||||
|
||||
private async retireQuery(): Promise<void> {
|
||||
const oldQuery = this.query;
|
||||
if (!oldQuery) return;
|
||||
const oldInput = this.input;
|
||||
// Null out query/input BEFORE awaiting the old iterator's return so the
|
||||
// old pump sees this.query !== activeQuery and skips failActiveTurns.
|
||||
this.query = null;
|
||||
this.input = null;
|
||||
this.queryPumpPromise = null;
|
||||
this.queryRestartNeeded = false;
|
||||
// Ending the input retires the process on purpose. Detach first so its
|
||||
// exit is not reported as a crash.
|
||||
const retiredChild = this.childProcess;
|
||||
this.childProcess = null;
|
||||
if (retiredChild) this.failRunningRuntimeTasks();
|
||||
oldInput?.end();
|
||||
oldQuery.close?.();
|
||||
try {
|
||||
await oldQuery.return?.();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
// Tree-kill the old process tree now that the SDK has cleaned up.
|
||||
// If we skip this, MCP children of the previous claude process can
|
||||
// survive as orphans when the session spawns a replacement query.
|
||||
if (retiredChild) {
|
||||
await terminateWithTreeKill(retiredChild, {
|
||||
gracefulTimeoutMs: 2_000,
|
||||
forceTimeoutMs: 2_000,
|
||||
}).catch(() => {
|
||||
/* process may already be dead */
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureQuery(launchMode: PermissionMode = this.currentMode): Promise<Query> {
|
||||
await this.ensureHistoryReady();
|
||||
// Controls use the running turn's query. Pending launch settings take effect
|
||||
// after that turn finishes, when its active query handle has been cleared.
|
||||
if (this.query && (!this.queryRestartNeeded || this.activeForegroundQuery === this.query)) {
|
||||
@@ -3458,7 +3398,37 @@ class ClaudeAgentSession implements AgentSession {
|
||||
}
|
||||
|
||||
if (this.queryRestartNeeded && this.query) {
|
||||
await this.retireQuery();
|
||||
const oldQuery = this.query;
|
||||
const oldInput = this.input;
|
||||
// Null out query/input BEFORE awaiting the old iterator's return so the
|
||||
// old pump sees this.query !== activeQuery and skips failActiveTurns.
|
||||
this.query = null;
|
||||
this.input = null;
|
||||
this.queryPumpPromise = null;
|
||||
this.queryRestartNeeded = false;
|
||||
// Ending the input retires the process on purpose. Detach first so its
|
||||
// exit is not reported as a crash.
|
||||
const retiredChild = this.childProcess;
|
||||
this.childProcess = null;
|
||||
if (retiredChild) this.failRunningRuntimeTasks();
|
||||
oldInput?.end();
|
||||
oldQuery.close?.();
|
||||
try {
|
||||
await oldQuery.return?.();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
// Tree-kill the old process tree now that the SDK has cleaned up.
|
||||
// If we skip this, MCP children of the previous claude process can
|
||||
// survive as orphans when the session spawns a replacement query.
|
||||
if (retiredChild) {
|
||||
await terminateWithTreeKill(retiredChild, {
|
||||
gracefulTimeoutMs: 2_000,
|
||||
forceTimeoutMs: 2_000,
|
||||
}).catch(() => {
|
||||
/* process may already be dead */
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Preserve claudeSessionId across query recreation so buildOptions() passes
|
||||
@@ -4125,15 +4095,13 @@ class ClaudeAgentSession implements AgentSession {
|
||||
);
|
||||
};
|
||||
const handlePumpedMessage = async (message: SDKMessage): Promise<boolean> => {
|
||||
if (!this.ownsQuery(activeQuery)) return true;
|
||||
this.processingSdkMessages++;
|
||||
try {
|
||||
this.runtimeSafety.observe(message);
|
||||
logRawMessage(message);
|
||||
consecutiveInterruptAbortRecoveries = 0;
|
||||
if (await this.handleMissingResumedConversation(message, activeQuery)) return true;
|
||||
if (!this.ownsQuery(activeQuery)) return true;
|
||||
this.routeSdkMessageFromPump(message);
|
||||
await this.routeSdkMessageFromPump(message);
|
||||
return false;
|
||||
} finally {
|
||||
this.processingSdkMessages--;
|
||||
@@ -4253,11 +4221,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
return isClaudeTurnInit(message) && !this.activeForegroundTurnId;
|
||||
}
|
||||
|
||||
private ownsQuery(query: Query): boolean {
|
||||
return !this.closed && this.query === query;
|
||||
}
|
||||
|
||||
private routeSdkMessageFromPump(message: SDKMessage): void {
|
||||
private async routeSdkMessageFromPump(message: SDKMessage): Promise<void> {
|
||||
if (this.consumeSessionStateChange(message)) {
|
||||
return;
|
||||
}
|
||||
@@ -4291,7 +4255,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
"provider.claude.parsed_event",
|
||||
);
|
||||
|
||||
const events = this.buildPumpedMessageEvents(message, identifiers.messageId, turnId);
|
||||
const events = await this.buildPumpedMessageEvents(message, identifiers.messageId, turnId);
|
||||
|
||||
if (events.length === 0) {
|
||||
return;
|
||||
@@ -4326,11 +4290,11 @@ class ClaudeAgentSession implements AgentSession {
|
||||
this.dispatchEvents(events);
|
||||
}
|
||||
|
||||
private buildPumpedMessageEvents(
|
||||
private async buildPumpedMessageEvents(
|
||||
message: SDKMessage,
|
||||
messageIdHint: string | null,
|
||||
turnId: string | null,
|
||||
): AgentStreamEvent[] {
|
||||
): Promise<AgentStreamEvent[]> {
|
||||
const messageEvents = this.translateMessageToEvents(message, {
|
||||
suppressAssistantText: true,
|
||||
suppressReasoning: true,
|
||||
@@ -5308,31 +5272,22 @@ class ClaudeAgentSession implements AgentSession {
|
||||
}
|
||||
}
|
||||
|
||||
ensureHistoryReady(): Promise<void> {
|
||||
if (!this.historyReady) {
|
||||
this.historyReady = this.historySessionId
|
||||
? this.loadPersistedHistory(this.historySessionId)
|
||||
: Promise.resolve();
|
||||
}
|
||||
return this.historyReady;
|
||||
}
|
||||
|
||||
private async loadPersistedHistory(sessionId: string): Promise<void> {
|
||||
private loadPersistedHistory(sessionId: string): void {
|
||||
let historyPath: string | null = null;
|
||||
this.replayingHistory = true;
|
||||
try {
|
||||
this.taskState.reset();
|
||||
historyPath = await this.resolveHistoryPath(sessionId);
|
||||
if (!historyPath || !(await transcriptExists(historyPath))) {
|
||||
historyPath = this.resolveHistoryPath(sessionId);
|
||||
if (!historyPath || !fs.existsSync(historyPath)) {
|
||||
this.logger.info(
|
||||
{ sessionId, cwd: this.config.cwd, historyPath },
|
||||
"No Claude transcript to load history from",
|
||||
);
|
||||
return;
|
||||
}
|
||||
await withClaudeReplayHistory(historyPath, async (history) => {
|
||||
const replay = await this.ingestPersistedSidechains(history);
|
||||
await this.ingestPersistedHistory(history.parentEntries, replay);
|
||||
withClaudeReplayHistory(historyPath, (history) => {
|
||||
const replay = this.ingestPersistedSidechains(history);
|
||||
this.ingestPersistedHistory(history.parentEntries, replay);
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
@@ -5344,12 +5299,12 @@ class ClaudeAgentSession implements AgentSession {
|
||||
}
|
||||
}
|
||||
|
||||
private async ingestPersistedHistory(
|
||||
entries: Iterable<ClaudeHistoryEntry> | AsyncIterable<ClaudeHistoryEntry>,
|
||||
private ingestPersistedHistory(
|
||||
entries: Iterable<ClaudeHistoryEntry>,
|
||||
replay: ClaudeReplayOwnership,
|
||||
): Promise<void> {
|
||||
): void {
|
||||
const timeline: PersistedTimelineEntry[] = [];
|
||||
for await (const entry of entries) {
|
||||
for (const entry of entries) {
|
||||
this.ingestPersistedHistoryEntry(entry, timeline, replay);
|
||||
}
|
||||
|
||||
@@ -5359,33 +5314,27 @@ class ClaudeAgentSession implements AgentSession {
|
||||
}
|
||||
}
|
||||
|
||||
private async ingestPersistedSidechains(
|
||||
history: ClaudeReplayHistory,
|
||||
): Promise<ClaudeReplayOwnership> {
|
||||
const parentFacts = await readClaudeReplayParentFacts(history.parentEntries);
|
||||
private ingestPersistedSidechains(history: ClaudeReplayHistory): ClaudeReplayOwnership {
|
||||
const parentFacts = readClaudeReplayParentFacts(history.parentEntries);
|
||||
|
||||
// Replay produces the same observations the live task protocol produces, then folds them
|
||||
// with the same function, so identity and status are derived once for both paths.
|
||||
const subagents = [];
|
||||
for (const subagent of history.subagents) {
|
||||
subagents.push({
|
||||
const subagentReplay = observeReplaySubagents({
|
||||
subagents: history.subagents.map((subagent) => ({
|
||||
...subagent,
|
||||
parentFacts: await readClaudeReplayParentFacts(subagent.entries),
|
||||
});
|
||||
}
|
||||
const subagentReplay = await observeReplaySubagents({
|
||||
subagents,
|
||||
parentFacts: readClaudeReplayParentFacts(subagent.entries),
|
||||
})),
|
||||
parent: parentFacts,
|
||||
convertEntry: (entry) => this.convertHistoryEntry(entry),
|
||||
});
|
||||
const observations = [
|
||||
...subagentReplay.observations,
|
||||
...(await observeReplayWorkflows({
|
||||
...observeReplayWorkflows({
|
||||
workflows: history.workflows,
|
||||
parentEntries: history.parentEntries,
|
||||
entriesByRunId: history.workflowEntriesByRunId,
|
||||
convertEntry: (entry) => this.convertHistoryEntry(entry),
|
||||
})),
|
||||
}),
|
||||
];
|
||||
const restoredProviderSubagentIds = new Set(
|
||||
observations
|
||||
@@ -5472,10 +5421,10 @@ class ClaudeAgentSession implements AgentSession {
|
||||
}
|
||||
}
|
||||
|
||||
private async resolveHistoryPath(sessionId: string): Promise<string | null> {
|
||||
private resolveHistoryPath(sessionId: string): string | null {
|
||||
const cwd = this.config.cwd;
|
||||
if (!cwd) return null;
|
||||
return claudeTranscriptPath({
|
||||
return claudeTranscriptPathSync({
|
||||
cwd,
|
||||
sessionId,
|
||||
configDir: claudeConfigDir(this.harnessEnvironment),
|
||||
@@ -6242,13 +6191,13 @@ function labelReplayedSubagentCard(
|
||||
* source consumes. The agentId scrape is kept only as a fallback for sessions recorded before
|
||||
* Claude Code wrote the meta sidecar.
|
||||
*/
|
||||
async function readClaudeReplayParentFacts(
|
||||
entries: Iterable<ClaudeHistoryEntry> | AsyncIterable<ClaudeHistoryEntry>,
|
||||
): Promise<ClaudeReplayParentFacts> {
|
||||
function readClaudeReplayParentFacts(
|
||||
entries: Iterable<ClaudeHistoryEntry>,
|
||||
): ClaudeReplayParentFacts {
|
||||
const toolCalls = new Map<string, { title?: string; description?: string }>();
|
||||
const linksByAgentId = new Map<string, { toolCallId: string; failed: boolean }>();
|
||||
const outcomes = new Map<string, { failed: boolean }>();
|
||||
for await (const entry of entries) {
|
||||
for (const entry of entries) {
|
||||
const content = toObjectRecord(entry.message)?.content;
|
||||
if (!Array.isArray(content)) continue;
|
||||
for (const value of content) {
|
||||
@@ -6780,7 +6729,7 @@ async function readClaudeRenamedSessionTitle(input: {
|
||||
}): Promise<string | null> {
|
||||
let content: string;
|
||||
try {
|
||||
content = await fsPromises.readFile(await claudeTranscriptPath(input), "utf8");
|
||||
content = await fsPromises.readFile(claudeTranscriptPathSync(input), "utf8");
|
||||
} catch (error) {
|
||||
// A session with no transcript on disk has no rename to carry over. Every
|
||||
// other read failure is a real fault and stays explicit.
|
||||
@@ -6880,13 +6829,3 @@ function readClaudeCommandLifecycle(message: unknown): ClaudeCommandLifecycle |
|
||||
state: record.state as ClaudeCommandLifecycle["state"],
|
||||
};
|
||||
}
|
||||
|
||||
async function transcriptExists(file: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.promises.access(file);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") return false;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { realpathSync } from "node:fs";
|
||||
import { realpath, access, readdir } from "node:fs/promises";
|
||||
import { existsSync, readdirSync, realpathSync } from "node:fs";
|
||||
import { realpath } from "node:fs/promises";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
@@ -35,21 +35,21 @@ export function claudeProjectDirSync(cwd: string, options?: ClaudeProjectDirOpti
|
||||
// one `cwd` encodes to when the project moved, the daemon migrated, or Claude Code sees the
|
||||
// cwd under another name (a Windows binary under WSL). Returns the expected path when no
|
||||
// transcript exists anywhere.
|
||||
export async function claudeTranscriptPath(input: {
|
||||
export function claudeTranscriptPathSync(input: {
|
||||
cwd: string;
|
||||
sessionId: string;
|
||||
configDir?: string;
|
||||
}): Promise<string> {
|
||||
}): string {
|
||||
const options = { configDir: input.configDir };
|
||||
const fileName = `${input.sessionId}.jsonl`;
|
||||
const expected = join(await claudeProjectDir(input.cwd, options), fileName);
|
||||
if (await exists(expected)) {
|
||||
const expected = join(claudeProjectDirSync(input.cwd, options), fileName);
|
||||
if (existsSync(expected)) {
|
||||
return expected;
|
||||
}
|
||||
const projectsRoot = join(resolveConfigDir(options), "projects");
|
||||
let projectDirs: string[];
|
||||
try {
|
||||
projectDirs = await readdir(projectsRoot);
|
||||
projectDirs = readdirSync(projectsRoot);
|
||||
} catch (error) {
|
||||
if (isMissingDirectoryError(error)) {
|
||||
return expected;
|
||||
@@ -58,23 +58,13 @@ export async function claudeTranscriptPath(input: {
|
||||
}
|
||||
for (const projectDir of projectDirs) {
|
||||
const candidate = join(projectsRoot, projectDir, fileName);
|
||||
if (await exists(candidate)) {
|
||||
if (existsSync(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return expected;
|
||||
}
|
||||
|
||||
async function exists(file: string): Promise<boolean> {
|
||||
try {
|
||||
await access(file);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (isMissingDirectoryError(error)) return false;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function isMissingDirectoryError(error: unknown): boolean {
|
||||
const code = (error as NodeJS.ErrnoException | null)?.code;
|
||||
return code === "ENOENT" || code === "ENOTDIR";
|
||||
|
||||
@@ -76,7 +76,7 @@ export async function revertClaudeConversation(input: {
|
||||
transcriptPath: string | null;
|
||||
messageId: string;
|
||||
resolveMessageId?: (messageId: string) => string | Promise<string>;
|
||||
setSessionId: (sessionId: string) => void | Promise<void>;
|
||||
setSessionId: (sessionId: string) => void;
|
||||
}): Promise<void> {
|
||||
if (!input.sessionId || !input.transcriptPath) {
|
||||
throw new Error("Claude session is not ready for rewind");
|
||||
@@ -86,7 +86,7 @@ export async function revertClaudeConversation(input: {
|
||||
upToMessageId: messageId,
|
||||
transcriptPath: input.transcriptPath,
|
||||
});
|
||||
await input.setSessionId(fork.sessionId);
|
||||
input.setSessionId(fork.sessionId);
|
||||
}
|
||||
|
||||
export async function revertClaudeFiles(input: {
|
||||
@@ -108,7 +108,7 @@ export async function revertClaudeConversationAndFiles(input: {
|
||||
transcriptPath: string | null;
|
||||
messageId: string;
|
||||
resolveMessageId?: (messageId: string) => string | Promise<string>;
|
||||
setSessionId: (sessionId: string) => void | Promise<void>;
|
||||
setSessionId: (sessionId: string) => void;
|
||||
}): Promise<void> {
|
||||
await revertClaudeFiles({
|
||||
query: input.query,
|
||||
|
||||
+262
-306
@@ -45,7 +45,7 @@ function applyToStore(observations: SubagentObservation[]): ProviderSubagentDesc
|
||||
}
|
||||
|
||||
describe("parseClaudeSubagentMeta", () => {
|
||||
it("reads the real sidecar shape", async () => {
|
||||
it("reads the real sidecar shape", () => {
|
||||
expect(
|
||||
parseClaudeSubagentMeta(
|
||||
'{"agentType":"general-purpose","description":"Reply banana","toolUseId":"toolu_013i","spawnDepth":1}',
|
||||
@@ -68,7 +68,7 @@ describe("parseClaudeSubagentMeta", () => {
|
||||
expect(parseClaudeSubagentMeta(contents)).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps the good fields when one field has the wrong type", async () => {
|
||||
it("keeps the good fields when one field has the wrong type", () => {
|
||||
expect(parseClaudeSubagentMeta('{"agentType":"Explore","toolUseId":42}')).toEqual({
|
||||
agentType: "Explore",
|
||||
description: undefined,
|
||||
@@ -79,14 +79,12 @@ describe("parseClaudeSubagentMeta", () => {
|
||||
});
|
||||
|
||||
describe("observeReplaySubagents", () => {
|
||||
it("links a subagent to its Task call through meta.toolUseId", async () => {
|
||||
const observations = (
|
||||
await observeReplaySubagents({
|
||||
subagents: [{ agentId: AGENT_ID, meta: { toolUseId: TOOL_USE_ID }, entries: [] }],
|
||||
parent: parentWithTaskCall(),
|
||||
convertEntry: () => [],
|
||||
})
|
||||
).observations;
|
||||
it("links a subagent to its Task call through meta.toolUseId", () => {
|
||||
const observations = observeReplaySubagents({
|
||||
subagents: [{ agentId: AGENT_ID, meta: { toolUseId: TOOL_USE_ID }, entries: [] }],
|
||||
parent: parentWithTaskCall(),
|
||||
convertEntry: () => [],
|
||||
}).observations;
|
||||
|
||||
expect(observations[0]).toMatchObject({
|
||||
kind: "declared",
|
||||
@@ -97,33 +95,31 @@ describe("observeReplaySubagents", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("replays descendants beneath the sidechain that declared their Task", async () => {
|
||||
it("replays descendants beneath the sidechain that declared their Task", () => {
|
||||
const nestedToolUseId = "toolu_012rzYnFZA";
|
||||
const observations = (
|
||||
await observeReplaySubagents({
|
||||
subagents: [
|
||||
{
|
||||
agentId: AGENT_ID,
|
||||
meta: { toolUseId: TOOL_USE_ID, spawnDepth: 1 },
|
||||
entries: [],
|
||||
parentFacts: {
|
||||
toolCalls: new Map([
|
||||
[nestedToolUseId, { title: "Explore", description: "Nested audit" }],
|
||||
]),
|
||||
linksByAgentId: new Map(),
|
||||
outcomesByToolCallId: new Map([[nestedToolUseId, { failed: false }]]),
|
||||
},
|
||||
const observations = observeReplaySubagents({
|
||||
subagents: [
|
||||
{
|
||||
agentId: AGENT_ID,
|
||||
meta: { toolUseId: TOOL_USE_ID, spawnDepth: 1 },
|
||||
entries: [],
|
||||
parentFacts: {
|
||||
toolCalls: new Map([
|
||||
[nestedToolUseId, { title: "Explore", description: "Nested audit" }],
|
||||
]),
|
||||
linksByAgentId: new Map(),
|
||||
outcomesByToolCallId: new Map([[nestedToolUseId, { failed: false }]]),
|
||||
},
|
||||
{
|
||||
agentId: "a6acb4b898",
|
||||
meta: { toolUseId: nestedToolUseId, agentType: "Explore", spawnDepth: 2 },
|
||||
entries: [],
|
||||
},
|
||||
],
|
||||
parent: parentWithTaskCall(),
|
||||
convertEntry: () => [],
|
||||
})
|
||||
).observations;
|
||||
},
|
||||
{
|
||||
agentId: "a6acb4b898",
|
||||
meta: { toolUseId: nestedToolUseId, agentType: "Explore", spawnDepth: 2 },
|
||||
entries: [],
|
||||
},
|
||||
],
|
||||
parent: parentWithTaskCall(),
|
||||
convertEntry: () => [],
|
||||
}).observations;
|
||||
|
||||
expect(observations).toContainEqual(
|
||||
expect.objectContaining({
|
||||
@@ -134,143 +130,89 @@ describe("observeReplaySubagents", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps a subagent recorded before spawnDepth existed", async () => {
|
||||
const observations = (
|
||||
await observeReplaySubagents({
|
||||
subagents: [{ agentId: AGENT_ID, meta: { toolUseId: TOOL_USE_ID }, entries: [] }],
|
||||
parent: parentWithTaskCall(),
|
||||
convertEntry: () => [],
|
||||
})
|
||||
).observations;
|
||||
it("keeps a subagent recorded before spawnDepth existed", () => {
|
||||
const observations = observeReplaySubagents({
|
||||
subagents: [{ agentId: AGENT_ID, meta: { toolUseId: TOOL_USE_ID }, entries: [] }],
|
||||
parent: parentWithTaskCall(),
|
||||
convertEntry: () => [],
|
||||
}).observations;
|
||||
|
||||
expect(observations[0]).toMatchObject({ kind: "declared", id: TOOL_USE_ID });
|
||||
});
|
||||
|
||||
it("drops a meta-linked subagent when the parent never declared its Task", async () => {
|
||||
const observations = (
|
||||
await observeReplaySubagents({
|
||||
subagents: [
|
||||
{
|
||||
agentId: AGENT_ID,
|
||||
meta: { toolUseId: TOOL_USE_ID, agentType: "Explore", description: "Find the code" },
|
||||
entries: [],
|
||||
},
|
||||
],
|
||||
parent: emptyParent(),
|
||||
convertEntry: () => [],
|
||||
})
|
||||
).observations;
|
||||
it("drops a meta-linked subagent when the parent never declared its Task", () => {
|
||||
const observations = observeReplaySubagents({
|
||||
subagents: [
|
||||
{
|
||||
agentId: AGENT_ID,
|
||||
meta: { toolUseId: TOOL_USE_ID, agentType: "Explore", description: "Find the code" },
|
||||
entries: [],
|
||||
},
|
||||
],
|
||||
parent: emptyParent(),
|
||||
convertEntry: () => [],
|
||||
}).observations;
|
||||
|
||||
expect(observations).toEqual([]);
|
||||
});
|
||||
|
||||
it("falls back to the scraped link when there is no meta file", async () => {
|
||||
it("falls back to the scraped link when there is no meta file", () => {
|
||||
const parent: ClaudeReplayParentFacts = {
|
||||
toolCalls: new Map([[TOOL_USE_ID, { title: "Explore" }]]),
|
||||
linksByAgentId: new Map([[AGENT_ID, { toolCallId: TOOL_USE_ID, failed: false }]]),
|
||||
outcomesByToolCallId: new Map(),
|
||||
};
|
||||
|
||||
const [declared, ...rest] = (
|
||||
await observeReplaySubagents({
|
||||
subagents: [{ agentId: AGENT_ID, meta: null, entries: [] }],
|
||||
parent,
|
||||
convertEntry: () => [],
|
||||
})
|
||||
).observations;
|
||||
const [declared, ...rest] = observeReplaySubagents({
|
||||
subagents: [{ agentId: AGENT_ID, meta: null, entries: [] }],
|
||||
parent,
|
||||
convertEntry: () => [],
|
||||
}).observations;
|
||||
|
||||
expect(declared).toMatchObject({ id: TOOL_USE_ID, toolCallId: TOOL_USE_ID, title: "Explore" });
|
||||
expect(rest.at(-1)).toMatchObject({ kind: "status", status: "completed" });
|
||||
});
|
||||
|
||||
it("drops an unlinkable subagent", async () => {
|
||||
const observations = (
|
||||
await observeReplaySubagents({
|
||||
subagents: [{ agentId: AGENT_ID, meta: null, entries: [] }],
|
||||
parent: emptyParent(),
|
||||
convertEntry: () => [],
|
||||
})
|
||||
).observations;
|
||||
it("drops an unlinkable subagent", () => {
|
||||
const observations = observeReplaySubagents({
|
||||
subagents: [{ agentId: AGENT_ID, meta: null, entries: [] }],
|
||||
parent: emptyParent(),
|
||||
convertEntry: () => [],
|
||||
}).observations;
|
||||
expect(observations).toEqual([]);
|
||||
});
|
||||
|
||||
it("drops a terminal subagent the parent has no Task call for", async () => {
|
||||
it("drops a terminal subagent the parent has no Task call for", () => {
|
||||
// A legacy grandchild: its toolUseId names a Task call made inside a sibling's transcript, so
|
||||
// no tool_result for it can ever reach the parent. Left running, it never stops.
|
||||
const descriptor = applyToStore(
|
||||
(
|
||||
await observeReplaySubagents({
|
||||
subagents: [
|
||||
{
|
||||
agentId: AGENT_ID,
|
||||
meta: { toolUseId: TOOL_USE_ID },
|
||||
entries: [
|
||||
{
|
||||
type: "assistant",
|
||||
timestamp: "2026-07-31T03:41:00.000Z",
|
||||
message: { stop_reason: "end_turn" },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
parent: emptyParent(),
|
||||
convertEntry: () => [],
|
||||
})
|
||||
).observations,
|
||||
observeReplaySubagents({
|
||||
subagents: [
|
||||
{
|
||||
agentId: AGENT_ID,
|
||||
meta: { toolUseId: TOOL_USE_ID },
|
||||
entries: [
|
||||
{
|
||||
type: "assistant",
|
||||
timestamp: "2026-07-31T03:41:00.000Z",
|
||||
message: { stop_reason: "end_turn" },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
parent: emptyParent(),
|
||||
convertEntry: () => [],
|
||||
}).observations,
|
||||
);
|
||||
|
||||
expect(descriptor).toBeNull();
|
||||
});
|
||||
|
||||
it("drops a terminal skill-spawned subagent that has no link at all", async () => {
|
||||
it("drops a terminal skill-spawned subagent that has no link at all", () => {
|
||||
// A /code-review run: no Task tool_use exists to name it, so its sidecar carries only
|
||||
// agentType and its id appears nowhere in the parent transcript.
|
||||
const descriptor = applyToStore(
|
||||
(
|
||||
await observeReplaySubagents({
|
||||
subagents: [
|
||||
{
|
||||
agentId: AGENT_ID,
|
||||
meta: { agentType: "general-purpose" },
|
||||
entries: [
|
||||
{
|
||||
type: "assistant",
|
||||
timestamp: "2026-07-31T03:41:00.000Z",
|
||||
message: { stop_reason: "end_turn" },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
parent: emptyParent(),
|
||||
convertEntry: () => [],
|
||||
})
|
||||
).observations,
|
||||
);
|
||||
|
||||
expect(descriptor).toBeNull();
|
||||
});
|
||||
|
||||
it("reports a scraped link's failure as failed", async () => {
|
||||
const descriptor = applyToStore(
|
||||
(
|
||||
await observeReplaySubagents({
|
||||
subagents: [{ agentId: AGENT_ID, meta: null, entries: [] }],
|
||||
parent: {
|
||||
toolCalls: new Map([[TOOL_USE_ID, { title: "Explore" }]]),
|
||||
linksByAgentId: new Map([[AGENT_ID, { toolCallId: TOOL_USE_ID, failed: true }]]),
|
||||
outcomesByToolCallId: new Map(),
|
||||
},
|
||||
convertEntry: () => [],
|
||||
})
|
||||
).observations,
|
||||
);
|
||||
|
||||
expect(descriptor).toMatchObject({ id: TOOL_USE_ID, status: "failed" });
|
||||
});
|
||||
|
||||
it("drops a non-terminal linkless subagent", async () => {
|
||||
const observations = (
|
||||
await observeReplaySubagents({
|
||||
observeReplaySubagents({
|
||||
subagents: [
|
||||
{
|
||||
agentId: AGENT_ID,
|
||||
@@ -279,81 +221,111 @@ describe("observeReplaySubagents", () => {
|
||||
{
|
||||
type: "assistant",
|
||||
timestamp: "2026-07-31T03:41:00.000Z",
|
||||
message: { stop_reason: null },
|
||||
message: { stop_reason: "end_turn" },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
parent: emptyParent(),
|
||||
convertEntry: () => [],
|
||||
})
|
||||
).observations;
|
||||
}).observations,
|
||||
);
|
||||
|
||||
expect(descriptor).toBeNull();
|
||||
});
|
||||
|
||||
it("reports a scraped link's failure as failed", () => {
|
||||
const descriptor = applyToStore(
|
||||
observeReplaySubagents({
|
||||
subagents: [{ agentId: AGENT_ID, meta: null, entries: [] }],
|
||||
parent: {
|
||||
toolCalls: new Map([[TOOL_USE_ID, { title: "Explore" }]]),
|
||||
linksByAgentId: new Map([[AGENT_ID, { toolCallId: TOOL_USE_ID, failed: true }]]),
|
||||
outcomesByToolCallId: new Map(),
|
||||
},
|
||||
convertEntry: () => [],
|
||||
}).observations,
|
||||
);
|
||||
|
||||
expect(descriptor).toMatchObject({ id: TOOL_USE_ID, status: "failed" });
|
||||
});
|
||||
|
||||
it("drops a non-terminal linkless subagent", () => {
|
||||
const observations = observeReplaySubagents({
|
||||
subagents: [
|
||||
{
|
||||
agentId: AGENT_ID,
|
||||
meta: { agentType: "general-purpose" },
|
||||
entries: [
|
||||
{
|
||||
type: "assistant",
|
||||
timestamp: "2026-07-31T03:41:00.000Z",
|
||||
message: { stop_reason: null },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
parent: emptyParent(),
|
||||
convertEntry: () => [],
|
||||
}).observations;
|
||||
|
||||
expect(observations).toEqual([]);
|
||||
});
|
||||
|
||||
it("finishes a terminal Task child when the parent has not recorded its outcome", async () => {
|
||||
it("finishes a terminal Task child when the parent has not recorded its outcome", () => {
|
||||
const descriptor = applyToStore(
|
||||
(
|
||||
await observeReplaySubagents({
|
||||
subagents: [
|
||||
{
|
||||
agentId: AGENT_ID,
|
||||
meta: { toolUseId: TOOL_USE_ID },
|
||||
entries: [
|
||||
{
|
||||
type: "assistant",
|
||||
timestamp: "2026-07-31T03:41:00.000Z",
|
||||
message: { stop_reason: "end_turn" },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
parent: { ...parentWithTaskCall(), outcomesByToolCallId: new Map() },
|
||||
convertEntry: () => [],
|
||||
})
|
||||
).observations,
|
||||
observeReplaySubagents({
|
||||
subagents: [
|
||||
{
|
||||
agentId: AGENT_ID,
|
||||
meta: { toolUseId: TOOL_USE_ID },
|
||||
entries: [
|
||||
{
|
||||
type: "assistant",
|
||||
timestamp: "2026-07-31T03:41:00.000Z",
|
||||
message: { stop_reason: "end_turn" },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
parent: { ...parentWithTaskCall(), outcomesByToolCallId: new Map() },
|
||||
convertEntry: () => [],
|
||||
}).observations,
|
||||
);
|
||||
|
||||
expect(descriptor).toMatchObject({ status: "completed" });
|
||||
});
|
||||
|
||||
it("leaves a subagent with no recorded outcome running", async () => {
|
||||
const observations = (
|
||||
await observeReplaySubagents({
|
||||
subagents: [{ agentId: AGENT_ID, meta: { toolUseId: TOOL_USE_ID }, entries: [] }],
|
||||
parent: { ...parentWithTaskCall(), outcomesByToolCallId: new Map() },
|
||||
convertEntry: () => [],
|
||||
})
|
||||
).observations;
|
||||
it("leaves a subagent with no recorded outcome running", () => {
|
||||
const observations = observeReplaySubagents({
|
||||
subagents: [{ agentId: AGENT_ID, meta: { toolUseId: TOOL_USE_ID }, entries: [] }],
|
||||
parent: { ...parentWithTaskCall(), outcomesByToolCallId: new Map() },
|
||||
convertEntry: () => [],
|
||||
}).observations;
|
||||
expect(observations.some((o) => o.kind === "status")).toBe(false);
|
||||
});
|
||||
|
||||
it("reports a failed outcome as failed", async () => {
|
||||
const observations = (
|
||||
await observeReplaySubagents({
|
||||
subagents: [{ agentId: AGENT_ID, meta: { toolUseId: TOOL_USE_ID }, entries: [] }],
|
||||
parent: parentWithTaskCall(true),
|
||||
convertEntry: () => [],
|
||||
})
|
||||
).observations;
|
||||
it("reports a failed outcome as failed", () => {
|
||||
const observations = observeReplaySubagents({
|
||||
subagents: [{ agentId: AGENT_ID, meta: { toolUseId: TOOL_USE_ID }, entries: [] }],
|
||||
parent: parentWithTaskCall(true),
|
||||
convertEntry: () => [],
|
||||
}).observations;
|
||||
expect(observations.at(-1)).toMatchObject({ kind: "status", status: "failed" });
|
||||
});
|
||||
|
||||
it("carries entry timestamps onto timeline observations", async () => {
|
||||
const observations = (
|
||||
await observeReplaySubagents({
|
||||
subagents: [
|
||||
{
|
||||
agentId: AGENT_ID,
|
||||
meta: { toolUseId: TOOL_USE_ID },
|
||||
entries: [{ type: "assistant", timestamp: "2026-07-26T06:27:47.034Z" }],
|
||||
},
|
||||
],
|
||||
parent: parentWithTaskCall(),
|
||||
convertEntry: () => [{ type: "reasoning", text: "thinking" }],
|
||||
})
|
||||
).observations;
|
||||
it("carries entry timestamps onto timeline observations", () => {
|
||||
const observations = observeReplaySubagents({
|
||||
subagents: [
|
||||
{
|
||||
agentId: AGENT_ID,
|
||||
meta: { toolUseId: TOOL_USE_ID },
|
||||
entries: [{ type: "assistant", timestamp: "2026-07-26T06:27:47.034Z" }],
|
||||
},
|
||||
],
|
||||
parent: parentWithTaskCall(),
|
||||
convertEntry: () => [{ type: "reasoning", text: "thinking" }],
|
||||
}).observations;
|
||||
|
||||
expect(observations.find((observation) => observation.kind === "timeline")).toMatchObject({
|
||||
kind: "timeline",
|
||||
@@ -364,20 +336,18 @@ describe("observeReplaySubagents", () => {
|
||||
});
|
||||
|
||||
describe("replay runtime", () => {
|
||||
it("reads the model and effort off the subagent's own transcript", async () => {
|
||||
const observations = (
|
||||
await observeReplaySubagents({
|
||||
subagents: [
|
||||
{
|
||||
agentId: AGENT_ID,
|
||||
meta: { toolUseId: TOOL_USE_ID },
|
||||
entries: [{ type: "assistant", effort: "high", message: { model: "claude-opus-5" } }],
|
||||
},
|
||||
],
|
||||
parent: parentWithTaskCall(),
|
||||
convertEntry: () => [],
|
||||
})
|
||||
).observations;
|
||||
it("reads the model and effort off the subagent's own transcript", () => {
|
||||
const observations = observeReplaySubagents({
|
||||
subagents: [
|
||||
{
|
||||
agentId: AGENT_ID,
|
||||
meta: { toolUseId: TOOL_USE_ID },
|
||||
entries: [{ type: "assistant", effort: "high", message: { model: "claude-opus-5" } }],
|
||||
},
|
||||
],
|
||||
parent: parentWithTaskCall(),
|
||||
convertEntry: () => [],
|
||||
}).observations;
|
||||
|
||||
expect(observations.find((o) => o.kind === "subtitle")).toEqual({
|
||||
kind: "subtitle",
|
||||
@@ -386,55 +356,49 @@ describe("replay runtime", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("takes the last observed values when the model changes mid-transcript", async () => {
|
||||
const observations = (
|
||||
await observeReplaySubagents({
|
||||
subagents: [
|
||||
{
|
||||
agentId: AGENT_ID,
|
||||
meta: { toolUseId: TOOL_USE_ID },
|
||||
entries: [
|
||||
{ type: "assistant", effort: "high", message: { model: "claude-opus-5" } },
|
||||
{ type: "assistant", effort: "low", message: { model: "claude-sonnet-5" } },
|
||||
],
|
||||
},
|
||||
],
|
||||
parent: parentWithTaskCall(),
|
||||
convertEntry: () => [],
|
||||
})
|
||||
).observations;
|
||||
it("takes the last observed values when the model changes mid-transcript", () => {
|
||||
const observations = observeReplaySubagents({
|
||||
subagents: [
|
||||
{
|
||||
agentId: AGENT_ID,
|
||||
meta: { toolUseId: TOOL_USE_ID },
|
||||
entries: [
|
||||
{ type: "assistant", effort: "high", message: { model: "claude-opus-5" } },
|
||||
{ type: "assistant", effort: "low", message: { model: "claude-sonnet-5" } },
|
||||
],
|
||||
},
|
||||
],
|
||||
parent: parentWithTaskCall(),
|
||||
convertEntry: () => [],
|
||||
}).observations;
|
||||
|
||||
expect(observations.find((o) => o.kind === "subtitle")).toMatchObject({
|
||||
subtitle: "general-purpose · Sonnet 5 · Low",
|
||||
});
|
||||
});
|
||||
|
||||
it("reports nothing when the transcript never names a model or effort", async () => {
|
||||
const observations = (
|
||||
await observeReplaySubagents({
|
||||
subagents: [{ agentId: AGENT_ID, meta: { toolUseId: TOOL_USE_ID }, entries: [] }],
|
||||
parent: parentWithTaskCall(),
|
||||
convertEntry: () => [],
|
||||
})
|
||||
).observations;
|
||||
it("reports nothing when the transcript never names a model or effort", () => {
|
||||
const observations = observeReplaySubagents({
|
||||
subagents: [{ agentId: AGENT_ID, meta: { toolUseId: TOOL_USE_ID }, entries: [] }],
|
||||
parent: parentWithTaskCall(),
|
||||
convertEntry: () => [],
|
||||
}).observations;
|
||||
expect(observations.some((o) => o.kind === "subtitle")).toBe(false);
|
||||
});
|
||||
|
||||
it("passes through a model the manifest does not know", async () => {
|
||||
it("passes through a model the manifest does not know", () => {
|
||||
// Claude Code is an Anthropic-compatible client; subagents legitimately report other models.
|
||||
const observations = (
|
||||
await observeReplaySubagents({
|
||||
subagents: [
|
||||
{
|
||||
agentId: AGENT_ID,
|
||||
meta: { toolUseId: TOOL_USE_ID },
|
||||
entries: [{ type: "assistant", message: { model: "glm-5.1" } }],
|
||||
},
|
||||
],
|
||||
parent: parentWithTaskCall(),
|
||||
convertEntry: () => [],
|
||||
})
|
||||
).observations;
|
||||
const observations = observeReplaySubagents({
|
||||
subagents: [
|
||||
{
|
||||
agentId: AGENT_ID,
|
||||
meta: { toolUseId: TOOL_USE_ID },
|
||||
entries: [{ type: "assistant", message: { model: "glm-5.1" } }],
|
||||
},
|
||||
],
|
||||
parent: parentWithTaskCall(),
|
||||
convertEntry: () => [],
|
||||
}).observations;
|
||||
expect(observations.find((o) => o.kind === "subtitle")).toMatchObject({
|
||||
subtitle: "general-purpose · glm-5.1",
|
||||
});
|
||||
@@ -442,24 +406,20 @@ describe("replay runtime", () => {
|
||||
});
|
||||
|
||||
describe("replay usage", () => {
|
||||
async function subtitleOf(
|
||||
entries: ClaudeReplayEntry[],
|
||||
): Promise<SubagentObservation | undefined> {
|
||||
return (
|
||||
await observeReplaySubagents({
|
||||
subagents: [{ agentId: AGENT_ID, meta: { toolUseId: TOOL_USE_ID }, entries }],
|
||||
parent: parentWithTaskCall(),
|
||||
convertEntry: () => [],
|
||||
})
|
||||
).observations.find((observation) => observation.kind === "subtitle");
|
||||
function subtitleOf(entries: ClaudeReplayEntry[]): SubagentObservation | undefined {
|
||||
return observeReplaySubagents({
|
||||
subagents: [{ agentId: AGENT_ID, meta: { toolUseId: TOOL_USE_ID }, entries }],
|
||||
parent: parentWithTaskCall(),
|
||||
convertEntry: () => [],
|
||||
}).observations.find((observation) => observation.kind === "subtitle");
|
||||
}
|
||||
|
||||
it("derives the counters Claude Code itself reports, from a real transcript's shape", async () => {
|
||||
it("derives the counters Claude Code itself reports, from a real transcript's shape", () => {
|
||||
// Shape and numbers taken from an on-disk subagent transcript. Claude Code finalizes a
|
||||
// subagent from the LAST assistant entry's usage, so the earlier, smaller turn must not win
|
||||
// and must not be added in.
|
||||
expect(
|
||||
await subtitleOf([
|
||||
subtitleOf([
|
||||
{
|
||||
type: "assistant",
|
||||
timestamp: "2026-07-23T23:18:43.023Z",
|
||||
@@ -502,9 +462,9 @@ describe("replay usage", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("formats tokens for a child that only answered", async () => {
|
||||
it("formats tokens for a child that only answered", () => {
|
||||
expect(
|
||||
await subtitleOf([
|
||||
subtitleOf([
|
||||
{
|
||||
type: "assistant",
|
||||
timestamp: "2026-07-27T16:42:14.698Z",
|
||||
@@ -519,12 +479,12 @@ describe("replay usage", () => {
|
||||
).toMatchObject({ subtitle: "general-purpose · 16.4k tokens" });
|
||||
});
|
||||
|
||||
it("emits nothing for a transcript with no entries", async () => {
|
||||
expect(await subtitleOf([])).toBeUndefined();
|
||||
it("emits nothing for a transcript with no entries", () => {
|
||||
expect(subtitleOf([])).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps duration out of the compact subtitle", async () => {
|
||||
const observation = await subtitleOf([
|
||||
it("keeps duration out of the compact subtitle", () => {
|
||||
const observation = subtitleOf([
|
||||
{
|
||||
type: "assistant",
|
||||
timestamp: "2026-07-27T16:42:14.698Z",
|
||||
@@ -534,8 +494,8 @@ describe("replay usage", () => {
|
||||
expect(observation).toMatchObject({ subtitle: "general-purpose · 10 tokens" });
|
||||
});
|
||||
|
||||
it("omits totalTokens when no entry carries a usage block", async () => {
|
||||
const observation = await subtitleOf([
|
||||
it("omits totalTokens when no entry carries a usage block", () => {
|
||||
const observation = subtitleOf([
|
||||
{ type: "assistant", timestamp: "2026-07-27T16:42:14.698Z", message: { content: [] } },
|
||||
{
|
||||
type: "assistant",
|
||||
@@ -546,9 +506,9 @@ describe("replay usage", () => {
|
||||
expect(observation).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps the last usage-bearing entry when the final assistant entry has none", async () => {
|
||||
it("keeps the last usage-bearing entry when the final assistant entry has none", () => {
|
||||
expect(
|
||||
await subtitleOf([
|
||||
subtitleOf([
|
||||
{
|
||||
type: "assistant",
|
||||
timestamp: "2026-07-27T16:42:14.698Z",
|
||||
@@ -559,36 +519,34 @@ describe("replay usage", () => {
|
||||
).toMatchObject({ subtitle: "general-purpose · 1k tokens" });
|
||||
});
|
||||
|
||||
it("only reports counters the transcript actually shows", async () => {
|
||||
it("only reports counters the transcript actually shows", () => {
|
||||
// A user-only transcript says nothing about cost: no assistant turn, so no tool count either.
|
||||
const observation = await subtitleOf([
|
||||
const observation = subtitleOf([
|
||||
{ type: "user", timestamp: "2026-07-27T16:42:14.698Z", message: { content: "prompt" } },
|
||||
{ type: "user", timestamp: "2026-07-27T16:42:20.000Z", message: { content: "more" } },
|
||||
]);
|
||||
expect(observation).toBeUndefined();
|
||||
});
|
||||
|
||||
it("lands the complete provider-owned subtitle on the descriptor", async () => {
|
||||
it("lands the complete provider-owned subtitle on the descriptor", () => {
|
||||
const store = new ProviderSubagentStore();
|
||||
const observations = (
|
||||
await observeReplaySubagents({
|
||||
subagents: [
|
||||
{
|
||||
agentId: AGENT_ID,
|
||||
meta: { toolUseId: TOOL_USE_ID },
|
||||
entries: [
|
||||
{
|
||||
type: "assistant",
|
||||
timestamp: "2026-07-27T16:42:14.698Z",
|
||||
message: { content: [], usage: { input_tokens: 7 } },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
parent: parentWithTaskCall(),
|
||||
convertEntry: () => [],
|
||||
})
|
||||
).observations;
|
||||
const observations = observeReplaySubagents({
|
||||
subagents: [
|
||||
{
|
||||
agentId: AGENT_ID,
|
||||
meta: { toolUseId: TOOL_USE_ID },
|
||||
entries: [
|
||||
{
|
||||
type: "assistant",
|
||||
timestamp: "2026-07-27T16:42:14.698Z",
|
||||
message: { content: [], usage: { input_tokens: 7 } },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
parent: parentWithTaskCall(),
|
||||
convertEntry: () => [],
|
||||
}).observations;
|
||||
for (const event of foldSubagentObservations(observations)) {
|
||||
store.apply("parent", "claude", event);
|
||||
}
|
||||
@@ -601,7 +559,7 @@ describe("live and replay agree", () => {
|
||||
// The property the design exists to guarantee: one session, two sources, one descriptor.
|
||||
// This is inexpressible against the inference implementation, where the paths share no
|
||||
// vocabulary and each derives identity and status its own way.
|
||||
it("produces identical descriptor state from the wire and from disk", async () => {
|
||||
it("produces identical descriptor state from the wire and from disk", () => {
|
||||
const live = new ClaudeTaskProtocolSource();
|
||||
const liveObservations = [
|
||||
...live.observe({
|
||||
@@ -621,23 +579,21 @@ describe("live and replay agree", () => {
|
||||
} as unknown as SDKMessage),
|
||||
];
|
||||
|
||||
const replayObservations = (
|
||||
await observeReplaySubagents({
|
||||
subagents: [
|
||||
{
|
||||
agentId: AGENT_ID,
|
||||
meta: {
|
||||
toolUseId: TOOL_USE_ID,
|
||||
agentType: "general-purpose",
|
||||
description: "Summarize hover and unistyles docs",
|
||||
},
|
||||
entries: [],
|
||||
const replayObservations = observeReplaySubagents({
|
||||
subagents: [
|
||||
{
|
||||
agentId: AGENT_ID,
|
||||
meta: {
|
||||
toolUseId: TOOL_USE_ID,
|
||||
agentType: "general-purpose",
|
||||
description: "Summarize hover and unistyles docs",
|
||||
},
|
||||
],
|
||||
parent: parentWithTaskCall(),
|
||||
convertEntry: () => [],
|
||||
})
|
||||
).observations;
|
||||
entries: [],
|
||||
},
|
||||
],
|
||||
parent: parentWithTaskCall(),
|
||||
convertEntry: () => [],
|
||||
}).observations;
|
||||
|
||||
const fromWire = applyToStore(liveObservations);
|
||||
const fromDisk = applyToStore(replayObservations);
|
||||
|
||||
@@ -130,7 +130,7 @@ function readUsage(entry: ClaudeReplayEntry): ClaudeSubagentUsage | null {
|
||||
export interface ClaudeReplaySubagentInput {
|
||||
agentId: string;
|
||||
meta: ClaudeSubagentMeta | null;
|
||||
entries: Iterable<ClaudeReplayEntry> | AsyncIterable<ClaudeReplayEntry>;
|
||||
entries: Iterable<ClaudeReplayEntry>;
|
||||
/** Task declarations made by this subagent, used to attach its direct children. */
|
||||
parentFacts?: ClaudeReplayParentFacts;
|
||||
}
|
||||
@@ -250,13 +250,13 @@ function observeSubtitle(
|
||||
};
|
||||
}
|
||||
|
||||
async function observeSubagent(
|
||||
function observeSubagent(
|
||||
subagent: ClaudeReplaySubagentInput,
|
||||
parent: ClaudeReplayParentFacts,
|
||||
convertEntry: (entry: ClaudeReplayEntry) => AgentTimelineItem[],
|
||||
toolOwners: Map<string, string>,
|
||||
parentSubagentId?: string,
|
||||
): Promise<SubagentObservation[]> {
|
||||
): SubagentObservation[] {
|
||||
const link = resolveParentLink(subagent, parent);
|
||||
if (!link) return [];
|
||||
const toolCall = parent.toolCalls.get(link.toolCallId);
|
||||
@@ -269,7 +269,7 @@ async function observeSubagent(
|
||||
let childTerminalStatus: ProviderSubagentStatus | null = null;
|
||||
|
||||
// Consume each source once for presentation and timeline; keep no raw transcript records.
|
||||
for await (const entry of subagent.entries) {
|
||||
for (const entry of subagent.entries) {
|
||||
const timestamp = normalizeProviderReplayTimestamp(entry.timestamp);
|
||||
if (!seenEntry) firstTimestamp = timestamp;
|
||||
seenEntry = true;
|
||||
@@ -320,11 +320,11 @@ function recordReplayToolOwners(
|
||||
}
|
||||
}
|
||||
|
||||
export async function observeReplaySubagents(input: {
|
||||
export function observeReplaySubagents(input: {
|
||||
subagents: readonly ClaudeReplaySubagentInput[];
|
||||
parent: ClaudeReplayParentFacts;
|
||||
convertEntry: (entry: ClaudeReplayEntry) => AgentTimelineItem[];
|
||||
}): Promise<{ observations: SubagentObservation[]; toolOwners: ReadonlyMap<string, string> }> {
|
||||
}): { observations: SubagentObservation[]; toolOwners: ReadonlyMap<string, string> } {
|
||||
const observations: SubagentObservation[] = [];
|
||||
const toolOwners = new Map<string, string>();
|
||||
const unresolved = [...input.subagents].sort(
|
||||
@@ -346,7 +346,7 @@ export async function observeReplaySubagents(input: {
|
||||
const { ownerId, parent, link } = resolved;
|
||||
|
||||
// Only proven descendants may own notifications; ambient sidecars cannot claim them.
|
||||
for (const observation of await observeSubagent(
|
||||
for (const observation of observeSubagent(
|
||||
subagent,
|
||||
parent,
|
||||
input.convertEntry,
|
||||
|
||||
+13
-13
@@ -35,7 +35,7 @@ function parentEntries(
|
||||
}
|
||||
|
||||
describe("Claude workflow replay", () => {
|
||||
it("parses the persisted run summary observed from Claude Code 2.1.220", async () => {
|
||||
it("parses the persisted run summary observed from Claude Code 2.1.220", () => {
|
||||
expect(
|
||||
parseClaudeWorkflowRun(
|
||||
JSON.stringify({
|
||||
@@ -62,8 +62,8 @@ describe("Claude workflow replay", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("rebuilds the same generic completed descriptor through the parent Workflow call", async () => {
|
||||
const observations = await observeReplayWorkflows({
|
||||
it("rebuilds the same generic completed descriptor through the parent Workflow call", () => {
|
||||
const observations = observeReplayWorkflows({
|
||||
workflows: [
|
||||
{
|
||||
runId: RUN_ID,
|
||||
@@ -91,8 +91,8 @@ describe("Claude workflow replay", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("rebuilds a workflow when Claude persists its result as structured text blocks", async () => {
|
||||
const observations = await observeReplayWorkflows({
|
||||
it("rebuilds a workflow when Claude persists its result as structured text blocks", () => {
|
||||
const observations = observeReplayWorkflows({
|
||||
workflows: [{ runId: RUN_ID, summary: "Structured result workflow", status: "completed" }],
|
||||
parentEntries: parentEntries([
|
||||
{
|
||||
@@ -114,7 +114,7 @@ describe("Claude workflow replay", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("replays the workflow result without exposing its script or child prompt", async () => {
|
||||
it("replays the workflow result without exposing its script or child prompt", () => {
|
||||
const workflow = parseClaudeWorkflowRun(
|
||||
JSON.stringify({
|
||||
runId: RUN_ID,
|
||||
@@ -128,7 +128,7 @@ describe("Claude workflow replay", () => {
|
||||
expect(workflow).not.toBeNull();
|
||||
if (!workflow) throw new Error("expected workflow");
|
||||
|
||||
const observations = await observeReplayWorkflows({
|
||||
const observations = observeReplayWorkflows({
|
||||
workflows: [workflow],
|
||||
parentEntries: parentEntries(),
|
||||
});
|
||||
@@ -145,9 +145,9 @@ describe("Claude workflow replay", () => {
|
||||
expect(JSON.stringify(observations)).not.toContain("SECRET CHILD PROMPT");
|
||||
});
|
||||
|
||||
it("does not duplicate a result already present in the child transcript", async () => {
|
||||
it("does not duplicate a result already present in the child transcript", () => {
|
||||
const report = "What Paseo is\nA local-first coding-agent environment.";
|
||||
const observations = await observeReplayWorkflows({
|
||||
const observations = observeReplayWorkflows({
|
||||
workflows: [
|
||||
{ runId: RUN_ID, summary: "Inspect Paseo", status: "completed", result: { report } },
|
||||
],
|
||||
@@ -166,8 +166,8 @@ describe("Claude workflow replay", () => {
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("fails a nonterminal persisted run because its former runtime cannot update it", async () => {
|
||||
const observations = await observeReplayWorkflows({
|
||||
it("fails a nonterminal persisted run because its former runtime cannot update it", () => {
|
||||
const observations = observeReplayWorkflows({
|
||||
workflows: [{ runId: RUN_ID, summary: "Interrupted workflow", status: "running" }],
|
||||
parentEntries: parentEntries(),
|
||||
});
|
||||
@@ -179,9 +179,9 @@ describe("Claude workflow replay", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("drops a run that no parent Workflow tool call declared", async () => {
|
||||
it("drops a run that no parent Workflow tool call declared", () => {
|
||||
expect(
|
||||
await observeReplayWorkflows({
|
||||
observeReplayWorkflows({
|
||||
workflows: [{ runId: RUN_ID, status: "completed" }],
|
||||
parentEntries: [],
|
||||
}),
|
||||
|
||||
+13
-18
@@ -45,17 +45,14 @@ export function parseClaudeWorkflowRun(contents: string): ClaudeWorkflowRun | nu
|
||||
* provider runtime was recreated, so replay terminalizes it as failed instead of resurrecting a
|
||||
* row that can never receive another update.
|
||||
*/
|
||||
export async function observeReplayWorkflows(input: {
|
||||
export function observeReplayWorkflows(input: {
|
||||
workflows: readonly ClaudeWorkflowRun[];
|
||||
parentEntries: Iterable<ClaudeWorkflowParentEntry> | AsyncIterable<ClaudeWorkflowParentEntry>;
|
||||
parentEntries: Iterable<ClaudeWorkflowParentEntry>;
|
||||
/** Repeatable entries in chronological order, supplied by the transcript reader. */
|
||||
entriesByRunId?: ReadonlyMap<
|
||||
string,
|
||||
Iterable<ClaudeReplayEntry> | AsyncIterable<ClaudeReplayEntry>
|
||||
>;
|
||||
entriesByRunId?: ReadonlyMap<string, Iterable<ClaudeReplayEntry>>;
|
||||
convertEntry?: (entry: ClaudeReplayEntry) => AgentTimelineItem[];
|
||||
}): Promise<SubagentObservation[]> {
|
||||
const toolCallIdByRunId = await readWorkflowLinks(input.parentEntries);
|
||||
}): SubagentObservation[] {
|
||||
const toolCallIdByRunId = readWorkflowLinks(input.parentEntries);
|
||||
const observations: SubagentObservation[] = [];
|
||||
|
||||
for (const workflow of input.workflows) {
|
||||
@@ -82,7 +79,7 @@ export async function observeReplayWorkflows(input: {
|
||||
});
|
||||
}
|
||||
|
||||
for (const observation of await observeWorkflowTimeline({
|
||||
for (const observation of observeWorkflowTimeline({
|
||||
id,
|
||||
result: workflow.result,
|
||||
entries: input.entriesByRunId?.get(workflow.runId) ?? [],
|
||||
@@ -118,17 +115,17 @@ export async function observeReplayWorkflows(input: {
|
||||
return observations;
|
||||
}
|
||||
|
||||
async function observeWorkflowTimeline(input: {
|
||||
function observeWorkflowTimeline(input: {
|
||||
id: string;
|
||||
result: unknown;
|
||||
entries: Iterable<ClaudeReplayEntry> | AsyncIterable<ClaudeReplayEntry>;
|
||||
entries: Iterable<ClaudeReplayEntry>;
|
||||
convertEntry?: (entry: ClaudeReplayEntry) => AgentTimelineItem[];
|
||||
finishedAt?: string;
|
||||
}): Promise<SubagentObservation[]> {
|
||||
}): SubagentObservation[] {
|
||||
const observations: SubagentObservation[] = [];
|
||||
const replayedAssistantText = new Set<string>();
|
||||
|
||||
for await (const entry of input.entries) {
|
||||
for (const entry of input.entries) {
|
||||
const timestamp = normalizeProviderReplayTimestamp(entry.timestamp);
|
||||
const items = input.convertEntry?.(entry) ?? [];
|
||||
for (const item of items) {
|
||||
@@ -153,13 +150,11 @@ async function observeWorkflowTimeline(input: {
|
||||
return observations;
|
||||
}
|
||||
|
||||
async function readWorkflowLinks(
|
||||
entries: Iterable<ClaudeWorkflowParentEntry> | AsyncIterable<ClaudeWorkflowParentEntry>,
|
||||
): Promise<Map<string, string>> {
|
||||
function readWorkflowLinks(entries: Iterable<ClaudeWorkflowParentEntry>): Map<string, string> {
|
||||
const workflowToolCallIds = new Set<string>();
|
||||
const toolCallIdByRunId = new Map<string, string>();
|
||||
|
||||
for await (const entry of entries) {
|
||||
for (const entry of entries) {
|
||||
const content = entry.message?.content;
|
||||
if (!Array.isArray(content)) continue;
|
||||
for (const value of content) {
|
||||
@@ -170,7 +165,7 @@ async function readWorkflowLinks(
|
||||
}
|
||||
}
|
||||
|
||||
for await (const entry of entries) {
|
||||
for (const entry of entries) {
|
||||
const content = entry.message?.content;
|
||||
if (!Array.isArray(content)) continue;
|
||||
for (const value of content) {
|
||||
|
||||
@@ -221,11 +221,9 @@ function parseTaskNotificationFromSystemRecord(record: unknown): TaskNotificatio
|
||||
/** Normalize notification identity for one transcript traversal. Queue bookkeeping and delivery
|
||||
* are separate records of the same occurrence; each source counts repeated identical bodies
|
||||
* independently so the second event is not merged with the first. */
|
||||
export async function* assignTaskNotificationReplayIds<T>(
|
||||
entries: AsyncIterable<T>,
|
||||
): AsyncGenerator<T> {
|
||||
export function* assignTaskNotificationReplayIds<T>(entries: Iterable<T>): Generator<T> {
|
||||
const occurrences = new Map<string, Map<string, number>>();
|
||||
for await (const entry of entries) {
|
||||
for (const entry of entries) {
|
||||
const record = TaskNotificationHistoryRecordSchema.safeParse(entry);
|
||||
if (!record.success) {
|
||||
yield entry;
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { assignTaskNotificationReplayIds } from "./task-notification-tool-call.js";
|
||||
import fs, { type FileHandle } from "node:fs/promises";
|
||||
import { setImmediate } from "node:timers/promises";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { normalizeProviderReplayTimestamp } from "../../provider-history-timestamps.js";
|
||||
import {
|
||||
@@ -25,11 +24,11 @@ interface RecordLocation {
|
||||
}
|
||||
|
||||
/** Chunk in bytes so UTF-8 and multi-chunk records are decoded once, at a line boundary. */
|
||||
async function* locatedRecords(
|
||||
function* locatedRecords(
|
||||
file: string,
|
||||
fd: FileHandle,
|
||||
): AsyncGenerator<{ entry: TranscriptEntry; location: RecordLocation }> {
|
||||
const stat = await fd.stat();
|
||||
fd: number,
|
||||
): Generator<{ entry: TranscriptEntry; location: RecordLocation }> {
|
||||
const stat = fs.fstatSync(fd);
|
||||
// Opening a directory succeeds on some platforms; it must never look like empty history.
|
||||
if (stat.isDirectory()) {
|
||||
throw Object.assign(new Error(`EISDIR: illegal operation on a directory, read '${file}'`), {
|
||||
@@ -44,7 +43,7 @@ async function* locatedRecords(
|
||||
let length = 0;
|
||||
while (remaining > 0) {
|
||||
const buffer = Buffer.allocUnsafe(Math.min(64 * 1024, remaining));
|
||||
const { bytesRead: count } = await fd.read(buffer, 0, buffer.length, null);
|
||||
const count = fs.readSync(fd, buffer, 0, buffer.length, null);
|
||||
if (count === 0) break;
|
||||
remaining -= count;
|
||||
let start = 0;
|
||||
@@ -65,8 +64,6 @@ async function* locatedRecords(
|
||||
if (entry) yield { entry, location };
|
||||
start = end + 1;
|
||||
}
|
||||
// Bound parsing work as well as IO; microtasks alone do not service timers or sockets.
|
||||
await setImmediate();
|
||||
if (start < count) {
|
||||
pieces.push(buffer.subarray(start, count));
|
||||
length += count - start;
|
||||
@@ -88,25 +85,20 @@ async function* locatedRecords(
|
||||
/** Indexed sources parse only their own records using descriptors owned by this replay. */
|
||||
function indexedRecords(
|
||||
locations: readonly RecordLocation[],
|
||||
descriptors: ReadonlyMap<string, FileHandle>,
|
||||
): AsyncIterable<TranscriptEntry> {
|
||||
descriptors: ReadonlyMap<string, number>,
|
||||
): Iterable<TranscriptEntry> {
|
||||
return {
|
||||
async *[Symbol.asyncIterator]() {
|
||||
*[Symbol.iterator]() {
|
||||
yield* assignTaskNotificationReplayIds(readEntries());
|
||||
},
|
||||
};
|
||||
async function* readEntries(): AsyncGenerator<TranscriptEntry> {
|
||||
function* readEntries(): Generator<TranscriptEntry> {
|
||||
for (const location of locations) {
|
||||
const fd = descriptors.get(location.file)!;
|
||||
const buffer = Buffer.allocUnsafe(location.length);
|
||||
let read = 0;
|
||||
while (read < buffer.length) {
|
||||
const { bytesRead: count } = await fd.read(
|
||||
buffer,
|
||||
read,
|
||||
buffer.length - read,
|
||||
location.offset + read,
|
||||
);
|
||||
const count = fs.readSync(fd, buffer, read, buffer.length - read, location.offset + read);
|
||||
if (count === 0)
|
||||
throw new Error(`Claude transcript was truncated during replay: ${location.file}`);
|
||||
read += count;
|
||||
@@ -135,9 +127,9 @@ function parseRecord(line: string): TranscriptEntry | null {
|
||||
}
|
||||
}
|
||||
|
||||
async function readOptionalFile(file: string): Promise<string | null> {
|
||||
function readOptionalFile(file: string): string | null {
|
||||
try {
|
||||
return await fs.readFile(file, "utf8");
|
||||
return fs.readFileSync(file, "utf8");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -146,40 +138,31 @@ async function readOptionalFile(file: string): Promise<string | null> {
|
||||
/** Discover sources without retaining transcript contents. Unrelated sidechains stay excluded
|
||||
* by the replay ownership resolver; workflow children retain their separate run ownership. */
|
||||
export interface ClaudeReplayHistory {
|
||||
parentEntries: AsyncIterable<TranscriptEntry>;
|
||||
parentEntries: Iterable<TranscriptEntry>;
|
||||
subagents: {
|
||||
agentId: string;
|
||||
meta: ClaudeSubagentMeta | null;
|
||||
entries: AsyncIterable<TranscriptEntry>;
|
||||
entries: Iterable<TranscriptEntry>;
|
||||
}[];
|
||||
workflows: ClaudeWorkflowRun[];
|
||||
workflowEntriesByRunId: Map<string, AsyncIterable<TranscriptEntry>>;
|
||||
workflowEntriesByRunId: Map<string, Iterable<TranscriptEntry>>;
|
||||
}
|
||||
|
||||
/** Own descriptors through every pass so unlink or rename cannot invalidate discovered history.
|
||||
* Sources must be consumed before the callback returns. No transcript copies are needed. */
|
||||
export async function withClaudeReplayHistory<T>(
|
||||
export function withClaudeReplayHistory<T>(
|
||||
historyPath: string,
|
||||
consume: (history: ClaudeReplayHistory) => Promise<T>,
|
||||
): Promise<T> {
|
||||
const descriptors = new Map<string, FileHandle>();
|
||||
consume: (history: ClaudeReplayHistory) => T,
|
||||
): T {
|
||||
const descriptors = new Map<string, number>();
|
||||
try {
|
||||
return await consume(await indexHistory(historyPath, descriptors));
|
||||
return consume(indexHistory(historyPath, descriptors));
|
||||
} finally {
|
||||
await closeReplaySources(descriptors.values());
|
||||
for (const fd of descriptors.values()) fs.closeSync(fd);
|
||||
}
|
||||
}
|
||||
|
||||
async function closeReplaySources(descriptors: Iterable<FileHandle>): Promise<void> {
|
||||
const results = await Promise.allSettled([...descriptors].map((fd) => fd.close()));
|
||||
const failure = results.find((result) => result.status === "rejected");
|
||||
if (failure) throw failure.reason;
|
||||
}
|
||||
|
||||
async function indexHistory(
|
||||
historyPath: string,
|
||||
descriptors: Map<string, FileHandle>,
|
||||
): Promise<ClaudeReplayHistory> {
|
||||
function indexHistory(historyPath: string, descriptors: Map<string, number>): ClaudeReplayHistory {
|
||||
const sessionDirectory = path.join(
|
||||
path.dirname(historyPath),
|
||||
path.basename(historyPath, ".jsonl"),
|
||||
@@ -191,7 +174,8 @@ async function indexHistory(
|
||||
const directories = [sidechainDirectory];
|
||||
while (directories.length) {
|
||||
const directory = directories.pop()!;
|
||||
for (const entry of await optionalDirectory(directory)) {
|
||||
if (!fs.existsSync(directory)) continue;
|
||||
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
||||
const file = path.join(directory, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
directories.push(file);
|
||||
@@ -210,23 +194,23 @@ async function indexHistory(
|
||||
}
|
||||
const agentId = /^agent-(.+)\.meta\.json$/.exec(entry.name)?.[1];
|
||||
if (!agentId) continue;
|
||||
const contents = await readOptionalFile(file);
|
||||
const contents = readOptionalFile(file);
|
||||
const meta = contents === null ? null : parseClaudeSubagentMeta(contents);
|
||||
if (meta) metaByAgentId.set(agentId, meta);
|
||||
}
|
||||
}
|
||||
const parentLocations: RecordLocation[] = [];
|
||||
const locationsByAgentId = new Map<string, RecordLocation[]>();
|
||||
async function scan(
|
||||
function scan(
|
||||
file: string,
|
||||
visit: (entry: TranscriptEntry, location: RecordLocation) => void,
|
||||
): Promise<void> {
|
||||
const fd = await fs.open(file, "r");
|
||||
): void {
|
||||
const fd = fs.openSync(file, "r");
|
||||
descriptors.set(file, fd);
|
||||
for await (const { entry, location } of locatedRecords(file, fd)) visit(entry, location);
|
||||
for (const { entry, location } of locatedRecords(file, fd)) visit(entry, location);
|
||||
}
|
||||
for (const file of sidechainFiles) {
|
||||
await scan(file, (entry, location) => {
|
||||
scan(file, (entry, location) => {
|
||||
if (file === historyPath && entry.isSidechain !== true) parentLocations.push(location);
|
||||
if (entry.isSidechain !== true || typeof entry.agentId !== "string") return;
|
||||
const locations = locationsByAgentId.get(entry.agentId) ?? [];
|
||||
@@ -234,11 +218,11 @@ async function indexHistory(
|
||||
locationsByAgentId.set(entry.agentId, locations);
|
||||
});
|
||||
}
|
||||
const workflowEntriesByRunId = new Map<string, AsyncIterable<TranscriptEntry>>();
|
||||
const workflowEntriesByRunId = new Map<string, Iterable<TranscriptEntry>>();
|
||||
for (const [runId, files] of workflowFiles) {
|
||||
const locations: RecordLocation[] = [];
|
||||
for (const file of files)
|
||||
await scan(file, (entry, location) => {
|
||||
scan(file, (entry, location) => {
|
||||
if (entry.type !== "user" || isToolResult(entry)) locations.push(location);
|
||||
});
|
||||
locations.sort((a, b) => {
|
||||
@@ -256,28 +240,21 @@ async function indexHistory(
|
||||
meta: metaByAgentId.get(agentId) ?? null,
|
||||
entries: indexedRecords(locations, descriptors),
|
||||
})),
|
||||
workflows: await readWorkflows(sessionDirectory),
|
||||
workflows: readWorkflows(sessionDirectory),
|
||||
workflowEntriesByRunId,
|
||||
};
|
||||
}
|
||||
|
||||
async function readWorkflows(sessionDirectory: string): Promise<ClaudeWorkflowRun[]> {
|
||||
function readWorkflows(sessionDirectory: string): ClaudeWorkflowRun[] {
|
||||
const workflows: ClaudeWorkflowRun[] = [];
|
||||
const workflowDirectory = path.join(sessionDirectory, "workflows");
|
||||
for (const entry of await optionalDirectory(workflowDirectory)) {
|
||||
if (!entry.isFile() || !entry.name.endsWith(".json")) continue;
|
||||
const contents = await readOptionalFile(path.join(workflowDirectory, entry.name));
|
||||
const workflow = contents === null ? null : parseClaudeWorkflowRun(contents);
|
||||
if (workflow) workflows.push(workflow);
|
||||
if (fs.existsSync(workflowDirectory)) {
|
||||
for (const entry of fs.readdirSync(workflowDirectory, { withFileTypes: true })) {
|
||||
if (!entry.isFile() || !entry.name.endsWith(".json")) continue;
|
||||
const contents = readOptionalFile(path.join(workflowDirectory, entry.name));
|
||||
const workflow = contents === null ? null : parseClaudeWorkflowRun(contents);
|
||||
if (workflow) workflows.push(workflow);
|
||||
}
|
||||
}
|
||||
return workflows;
|
||||
}
|
||||
|
||||
async function optionalDirectory(directory: string) {
|
||||
try {
|
||||
return await fs.readdir(directory, { withFileTypes: true });
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import { realpath } from "node:fs";
|
||||
import { readlink } from "node:fs/promises";
|
||||
import { promisify } from "node:util";
|
||||
import pLimit from "p-limit";
|
||||
import { dirname, resolve as resolvePath, sep } from "node:path";
|
||||
import { looksLikeDefiniteWindowsPath, normalizePathPreservingCase } from "../utils/path.js";
|
||||
|
||||
const nativeRealpath = promisify(realpath.native);
|
||||
const jsRealpath = promisify(realpath);
|
||||
const identityIo = pLimit(2);
|
||||
|
||||
/** A subscription owns its lexical namespace, including the OS's real-root alias. */
|
||||
export class GitObservationPaths {
|
||||
private constructor(
|
||||
private readonly root: string,
|
||||
private readonly aliases: string[],
|
||||
private readonly windows: boolean,
|
||||
) {}
|
||||
|
||||
static async create(root: string): Promise<GitObservationPaths> {
|
||||
const windows = looksLikeDefiniteWindowsPath(root);
|
||||
const normalizePath = (value: string) =>
|
||||
windows
|
||||
? normalizePathPreservingCase(value, true).replaceAll("\\", "/").toLowerCase()
|
||||
: normalizePathPreservingCase(value, false);
|
||||
const aliases = new Set([normalizePath(root)]);
|
||||
// Sequential: foreign paths must not fan out into the shared libuv pool.
|
||||
for (const resolve of [nativeRealpath, jsRealpath]) {
|
||||
try {
|
||||
aliases.add(normalizePath(await identityIo(() => resolve(root))));
|
||||
} catch {
|
||||
/* Missing roots retain lexical identity. */
|
||||
}
|
||||
}
|
||||
// A link may name /var/... while realpath returns /private/var/... on macOS.
|
||||
// Retain literal targets too, including chains, without following links in callbacks.
|
||||
let current = root;
|
||||
const visited = new Set<string>();
|
||||
for (let remaining = 40; remaining > 0; remaining--) {
|
||||
const alias = normalizePath(current);
|
||||
if (visited.has(alias)) break;
|
||||
visited.add(alias);
|
||||
aliases.add(alias);
|
||||
try {
|
||||
const target = await identityIo(() => readlink(current));
|
||||
current = resolvePath(dirname(current), target);
|
||||
} catch {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return new GitObservationPaths(root, [...aliases], windows);
|
||||
}
|
||||
|
||||
relative(candidate: string): string | null {
|
||||
const normalized = this.normalizeEventPath(candidate);
|
||||
const comparable = this.windows ? normalized.toLowerCase() : normalized;
|
||||
for (const alias of this.aliases) {
|
||||
if (comparable === alias) return "";
|
||||
const prefix = alias.endsWith("/") ? alias : `${alias}/`;
|
||||
if (comparable.startsWith(prefix)) {
|
||||
// Slice the original spelling, preserving case in ref names on Windows.
|
||||
return normalized.slice(prefix.length);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Compile excluded subtrees in every established root namespace. The event callback
|
||||
// only compares strings; neither an ignored root nor an event needs another realpath.
|
||||
createEventFilter(excludedRoots: Iterable<string>): (candidate: string) => boolean {
|
||||
const excluded: string[] = [];
|
||||
for (const root of excludedRoots) {
|
||||
const relative = this.relative(root);
|
||||
if (relative === null) continue;
|
||||
for (const alias of this.aliases) {
|
||||
const subtree = relative ? `${alias}/${relative}` : alias;
|
||||
excluded.push(this.windows ? subtree.toLowerCase() : subtree);
|
||||
}
|
||||
}
|
||||
const prefixes = excluded.map((root) => `${root}/`);
|
||||
return (candidate) => {
|
||||
const normalized = this.normalizeEventPath(candidate);
|
||||
const comparable = this.windows ? normalized.toLowerCase() : normalized;
|
||||
if (this.aliases.includes(comparable)) return false;
|
||||
for (let i = 0; i < excluded.length; i++) {
|
||||
if (comparable === excluded[i] || comparable.startsWith(prefixes[i])) return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
}
|
||||
|
||||
private normalizeEventPath(candidate: string): string {
|
||||
if (this.windows) return normalizePathPreservingCase(candidate, true).replaceAll("\\", "/");
|
||||
if (candidate.includes("/.") || candidate.includes("//") || candidate.endsWith("/"))
|
||||
return normalizePathPreservingCase(candidate, false);
|
||||
return candidate;
|
||||
}
|
||||
|
||||
matches = (candidate: string): boolean => this.relative(candidate) === "";
|
||||
|
||||
toWatchPath(candidate: string): string {
|
||||
const relative = this.relative(candidate);
|
||||
if (relative === null) return candidate;
|
||||
return relative ? `${this.root}${sep}${relative}` : this.root;
|
||||
}
|
||||
}
|
||||
@@ -548,7 +548,7 @@ test("renames an eligible unnamed branch-off worktree once on first agent contex
|
||||
);
|
||||
|
||||
expect(created.worktree.branchName).toBe("dazzling-yak");
|
||||
expect(readPaseoWorktreeMetadata(created.worktree.worktreePath)).toMatchObject({
|
||||
expect(await readPaseoWorktreeMetadata(created.worktree.worktreePath)).toMatchObject({
|
||||
version: 2,
|
||||
firstAgentBranchAutoName: {
|
||||
status: "pending",
|
||||
@@ -575,7 +575,7 @@ test("renames an eligible unnamed branch-off worktree once on first agent contex
|
||||
branchName: "renamed-from-agent-context",
|
||||
});
|
||||
expect(branchAfterFirst).toBe("renamed-from-agent-context");
|
||||
expect(readPaseoWorktreeMetadata(created.worktree.worktreePath)).toMatchObject({
|
||||
expect(await readPaseoWorktreeMetadata(created.worktree.worktreePath)).toMatchObject({
|
||||
version: 2,
|
||||
firstAgentBranchAutoName: {
|
||||
status: "attempted",
|
||||
@@ -762,7 +762,7 @@ test("leaves the branch alone when generated branch text is invalid", async () =
|
||||
.toString()
|
||||
.trim(),
|
||||
).toBe("dazzling-yak");
|
||||
expect(readPaseoWorktreeMetadata(created.worktree.worktreePath)).toMatchObject({
|
||||
expect(await readPaseoWorktreeMetadata(created.worktree.worktreePath)).toMatchObject({
|
||||
version: 2,
|
||||
firstAgentBranchAutoName: {
|
||||
status: "attempted",
|
||||
@@ -794,7 +794,7 @@ test("does not mark checkout branch worktrees as eligible for first-agent rename
|
||||
// baseRefName must be the repo's default branch, not the checked-out branch
|
||||
// itself — otherwise the diff panel compares the branch against itself and
|
||||
// always shows no changes.
|
||||
expect(readPaseoWorktreeMetadata(created.worktree.worktreePath)).toMatchObject({
|
||||
expect(await readPaseoWorktreeMetadata(created.worktree.worktreePath)).toMatchObject({
|
||||
version: 1,
|
||||
baseRefName: "main",
|
||||
});
|
||||
@@ -831,7 +831,7 @@ test("does not mark GitHub PR checkout worktrees as eligible for first-agent ren
|
||||
createDeps(),
|
||||
);
|
||||
|
||||
expect(readPaseoWorktreeMetadata(created.worktree.worktreePath)).toMatchObject({
|
||||
expect(await readPaseoWorktreeMetadata(created.worktree.worktreePath)).toMatchObject({
|
||||
version: 1,
|
||||
baseRefName: "main",
|
||||
});
|
||||
|
||||
@@ -77,7 +77,7 @@ async function createPaseoWorktreeWithPriority(
|
||||
const workspaceCwdPlan = await planWorkspaceCwdForWorktree(input.cwd, deps.workspaceGitService);
|
||||
const createdWorktree = await createWorktreeCore(input, deps);
|
||||
try {
|
||||
maybeMarkFirstAgentBranchAutoNameEligible({ createdWorktree });
|
||||
await maybeMarkFirstAgentBranchAutoNameEligible({ createdWorktree });
|
||||
const workspaceCwd = mapWorkspaceRelativeCwdToWorktree({
|
||||
relativeWorkspaceCwd: workspaceCwdPlan.relativeWorkspaceCwd,
|
||||
targetWorktreePath: createdWorktree.worktree.worktreePath,
|
||||
@@ -182,9 +182,9 @@ export async function attemptFirstAgentBranchAutoName(options: {
|
||||
return { attempted: false, renamed: false, branchName: null };
|
||||
}
|
||||
|
||||
let metadata: ReturnType<typeof readPaseoWorktreeMetadata>;
|
||||
let metadata: Awaited<ReturnType<typeof readPaseoWorktreeMetadata>>;
|
||||
try {
|
||||
metadata = readPaseoWorktreeMetadata(options.cwd);
|
||||
metadata = await readPaseoWorktreeMetadata(options.cwd);
|
||||
} catch {
|
||||
return { attempted: false, renamed: false, branchName: null };
|
||||
}
|
||||
@@ -199,11 +199,11 @@ export async function attemptFirstAgentBranchAutoName(options: {
|
||||
const getCurrentBranchImpl = options.getCurrentBranch ?? getCurrentBranch;
|
||||
const placeholderBranchName = metadata.firstAgentBranchAutoName.placeholderBranchName;
|
||||
if ((await getCurrentBranchImpl(options.cwd)) !== placeholderBranchName) {
|
||||
markPaseoWorktreeFirstAgentBranchAutoNameAttempted(options.cwd);
|
||||
await markPaseoWorktreeFirstAgentBranchAutoNameAttempted(options.cwd);
|
||||
return { attempted: true, renamed: false, branchName: null };
|
||||
}
|
||||
|
||||
markPaseoWorktreeFirstAgentBranchAutoNameAttempted(options.cwd);
|
||||
await markPaseoWorktreeFirstAgentBranchAutoNameAttempted(options.cwd);
|
||||
|
||||
const branchName = await options.generateBranchNameFromContext({
|
||||
cwd: options.cwd,
|
||||
@@ -264,15 +264,15 @@ async function findAvailableBranchName(options: {
|
||||
return null;
|
||||
}
|
||||
|
||||
function maybeMarkFirstAgentBranchAutoNameEligible(options: {
|
||||
async function maybeMarkFirstAgentBranchAutoNameEligible(options: {
|
||||
createdWorktree: Awaited<ReturnType<typeof createWorktreeCore>>;
|
||||
}): void {
|
||||
}): Promise<void> {
|
||||
const { createdWorktree } = options;
|
||||
if (!createdWorktree.created || createdWorktree.intent.kind !== "branch-off") {
|
||||
return;
|
||||
}
|
||||
|
||||
writePaseoWorktreeFirstAgentBranchAutoNameMetadata(createdWorktree.worktree.worktreePath, {
|
||||
await writePaseoWorktreeFirstAgentBranchAutoNameMetadata(createdWorktree.worktree.worktreePath, {
|
||||
placeholderBranchName: createdWorktree.worktree.branchName,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -9228,8 +9228,8 @@ test("workspace auto-name uses the backing root for a nested worktree", async ()
|
||||
execFileSync("git", ["add", "README.md"], { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["commit", "-m", "initial"], { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["branch", "-M", "placeholder-branch"], { cwd: repoDir, stdio: "pipe" });
|
||||
writePaseoWorktreeMetadata(repoDir, { baseRefName: "main" });
|
||||
writePaseoWorktreeFirstAgentBranchAutoNameMetadata(repoDir, {
|
||||
await writePaseoWorktreeMetadata(repoDir, { baseRefName: "main" });
|
||||
await writePaseoWorktreeFirstAgentBranchAutoNameMetadata(repoDir, {
|
||||
placeholderBranchName: "placeholder-branch",
|
||||
});
|
||||
const workspaceCwd = path.join(repoDir, "packages", "app");
|
||||
@@ -9288,6 +9288,7 @@ test("workspace auto-name uses the backing root for a nested worktree", async ()
|
||||
firstAgentContext: { prompt: "Fix checkout title" },
|
||||
});
|
||||
await vi.runAllTimersAsync();
|
||||
await vi.waitFor(() => expect(emittedCwds).toEqual([workspaceCwd]));
|
||||
|
||||
expect(generateCalls).toBe(1);
|
||||
expect(stored.get(workspace.workspaceId)).toMatchObject({
|
||||
@@ -9299,7 +9300,7 @@ test("workspace auto-name uses the backing root for a nested worktree", async ()
|
||||
.toString()
|
||||
.trim(),
|
||||
).toBe("placeholder-branch");
|
||||
expect(readPaseoWorktreeMetadata(repoDir)).toMatchObject({
|
||||
expect(await readPaseoWorktreeMetadata(repoDir)).toMatchObject({
|
||||
version: 2,
|
||||
firstAgentBranchAutoName: {
|
||||
status: "attempted",
|
||||
|
||||
+3
-3
@@ -347,7 +347,7 @@ async function createBaseRecoveryFixture(baseBranch: string | null) {
|
||||
|
||||
test("preserves ordinary checkout behavior when no separate base was recorded", async () => {
|
||||
const fixture = await createBaseRecoveryFixture(null);
|
||||
writePaseoWorktreeMetadata(fixture.workspace.cwd, { baseRefName: "feature" });
|
||||
await writePaseoWorktreeMetadata(fixture.workspace.cwd, { baseRefName: "feature" });
|
||||
await fixture.archiveAndRemove();
|
||||
await fixture.service.restore(fixture.workspace.workspaceId);
|
||||
expect((await fixture.registry.get(fixture.workspace.workspaceId))?.baseBranch).toBeNull();
|
||||
@@ -430,7 +430,7 @@ test("restores the branch HEAD with a base name when the exact base is missing",
|
||||
});
|
||||
expect(existsSync(fixture.workspace.cwd)).toBe(true);
|
||||
expect((await fixture.registry.get(fixture.workspace.workspaceId))?.archivedAt).toBeNull();
|
||||
const metadata = readPaseoWorktreeMetadata(fixture.workspace.cwd);
|
||||
const metadata = await readPaseoWorktreeMetadata(fixture.workspace.cwd);
|
||||
expect(metadata).toMatchObject({ baseRefName: "main" });
|
||||
expect(metadata?.baseRef).toBeUndefined();
|
||||
expect(
|
||||
@@ -450,7 +450,7 @@ test("restore fetches a deleted local branch from origin and retains its base",
|
||||
await fixture.archiveAndRemove();
|
||||
execFileSync("git", ["branch", "-D", "feature"], { cwd: fixture.repoDir });
|
||||
await fixture.service.restore(fixture.workspace.workspaceId);
|
||||
expect(readPaseoWorktreeMetadata(fixture.workspace.cwd)?.baseRef).toBe("refs/heads/main");
|
||||
expect((await readPaseoWorktreeMetadata(fixture.workspace.cwd))?.baseRef).toBe("refs/heads/main");
|
||||
expect(
|
||||
await getCheckoutStatus(fixture.workspace.cwd, { paseoHome: fixture.paseoHome }),
|
||||
).toMatchObject({
|
||||
|
||||
@@ -493,6 +493,7 @@ async function createRestorableWorkspace(baseRef: string) {
|
||||
commitFile(cwd, "feature.txt", "feature change\n");
|
||||
const head = runGit(cwd, "rev-parse", "HEAD");
|
||||
await client.checkoutRefresh(cwd);
|
||||
expect(await client.getCheckoutStatus(cwd)).toMatchObject({ isPaseoOwnedWorktree: true });
|
||||
const before = await client.getCheckoutDiff(cwd, { mode: "base", baseRef: "main" });
|
||||
expect(before.error).toBeNull();
|
||||
expect(before.files.map((file) => file.path)).toEqual(["feature.txt"]);
|
||||
|
||||
@@ -526,3 +526,183 @@ test("a late Git-ignored tree is pruned while tracked changes still notify consu
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
}, 15_000);
|
||||
|
||||
test("Git refresh, command tracing and 10,000 ignored alias events do no synchronous IO", async () => {
|
||||
const { execFile } = await import("node:child_process");
|
||||
const { promisify } = await import("node:util");
|
||||
const { fileURLToPath } = await import("node:url");
|
||||
const repositoryRoot = fileURLToPath(new URL("../../../../", import.meta.url));
|
||||
const source = `
|
||||
import assert from 'node:assert/strict';
|
||||
import { write } from 'node:fs';
|
||||
import { mkdtemp, mkdir, writeFile, symlink, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { setImmediate as tick } from 'node:timers/promises';
|
||||
import { createHook } from 'node:async_hooks';
|
||||
import pino from 'pino';
|
||||
import { WorkspaceGitServiceImpl } from './packages/server/src/server/workspace-git-service.ts';
|
||||
import { runGitCommand } from './packages/server/src/utils/run-git-command.ts';
|
||||
import { writePaseoWorktreeMetadata } from './packages/server/src/utils/worktree-metadata.ts';
|
||||
const root = await mkdtemp(join(tmpdir(), 'git-async-contract-'));
|
||||
const home = join(root, 'home');
|
||||
const repo = join(root, 'repo');
|
||||
const worktree = join(home, 'worktrees', 'project', 'topic');
|
||||
const alias = join(root, 'alias');
|
||||
const logger = pino({ level: 'trace' }, { write() {} });
|
||||
// Async descriptor writes avoid Windows stderr's synchronous stream API.
|
||||
const marker = (label) => new Promise((resolve, reject) => {
|
||||
write(2, label + '\\n', (error) => error ? reject(error) : resolve());
|
||||
});
|
||||
let callback;
|
||||
let notifications = 0;
|
||||
const service = new WorkspaceGitServiceImpl({ logger, paseoHome: home, deps: {
|
||||
subscribe: async (_root, onChange) => {
|
||||
callback = onChange;
|
||||
return { updateIgnore: async () => {}, unsubscribe: async () => {} };
|
||||
}
|
||||
} });
|
||||
try {
|
||||
await mkdir(repo);
|
||||
await runGitCommand(['init', '-b', 'main'], { cwd: repo });
|
||||
await runGitCommand(['-c', 'user.name=Test', '-c', 'user.email=test@example.com', 'commit', '--allow-empty', '-m', 'base'], { cwd: repo });
|
||||
await mkdir(join(home, 'worktrees', 'project'), { recursive: true });
|
||||
await runGitCommand(['worktree', 'add', '-b', 'topic', worktree], { cwd: repo });
|
||||
await writePaseoWorktreeMetadata(worktree, { baseRefName: 'main' });
|
||||
await writeFile(join(worktree, '.gitignore'), 'ignored/\\n');
|
||||
await mkdir(join(worktree, 'ignored'));
|
||||
await writeFile(join(worktree, 'ignored', 'noise'), 'noise');
|
||||
await symlink(worktree, alias, process.platform === 'win32' ? 'junction' : 'dir');
|
||||
const subscription = await service.requestWorkingTreeWatch(alias, () => { notifications++; });
|
||||
const events = Array.from({ length: process.env.GIT_IO_TRACE ? 2 : 10000 }, (_, i) => ({ type: 'update', path: join(i % 2 ? alias : worktree, 'ignored', 'noise') }));
|
||||
// A shared CI runner can deschedule the child during a wall-clock sample.
|
||||
// Keep the 20ms gate, but take three independent batches and require one
|
||||
// responsive callback/timer pair. All batches must remain filesystem-free.
|
||||
const samples = [];
|
||||
for (let trial = 0; trial < (process.env.GIT_IO_TRACE ? 1 : 3); trial++) {
|
||||
await tick();
|
||||
let fsCalls = 0;
|
||||
const hook = createHook({ init(_id, type) { if (type.startsWith('FSREQ')) fsCalls++; } });
|
||||
await marker('FILTER_START');
|
||||
hook.enable();
|
||||
const started = performance.now();
|
||||
const pulse = new Promise((resolve) => setTimeout(() => resolve(performance.now() - started), 0));
|
||||
callback(null, events);
|
||||
const filterMs = performance.now() - started;
|
||||
hook.disable();
|
||||
const loopGapMs = await pulse;
|
||||
await marker('FILTER_END');
|
||||
assert.equal(fsCalls, 0, "filesystem calls");
|
||||
assert.equal(notifications, 0, "ignored event notification");
|
||||
samples.push({ filterMs, loopGapMs, fsCalls });
|
||||
}
|
||||
console.log(JSON.stringify({ ...samples[0], samples }));
|
||||
assert.ok(process.env.GIT_IO_TRACE || samples.some(({ filterMs, loopGapMs }) => filterMs < 20 && loopGapMs < 20), 'no responsive filter sample: ' + JSON.stringify(samples));
|
||||
// Alias identity remains useful after deletion: no per-event realpath can recover this file.
|
||||
callback(null, [{ type: 'delete', path: join(worktree, 'tracked.txt') }]);
|
||||
assert.equal(notifications, 1);
|
||||
await marker('REFRESH_START');
|
||||
const snapshot = await service.getSnapshot(worktree);
|
||||
assert.equal(snapshot.git.isGit, true);
|
||||
assert.equal(snapshot.git.isPaseoOwnedWorktree, true);
|
||||
await runGitCommand(['status', '--porcelain'], { cwd: alias, logger });
|
||||
await marker('REFRESH_END');
|
||||
subscription.unsubscribe();
|
||||
} finally {
|
||||
await service.dispose();
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
`;
|
||||
for (const tracing of [false, true]) {
|
||||
const { stdout, stderr } = await promisify(execFile)(
|
||||
process.execPath,
|
||||
[
|
||||
"--import",
|
||||
"tsx",
|
||||
...(tracing ? ["--trace-sync-io"] : []),
|
||||
"--input-type=module",
|
||||
"--eval",
|
||||
source,
|
||||
],
|
||||
{
|
||||
cwd: repositoryRoot,
|
||||
env: { ...process.env, GIT_IO_TRACE: tracing ? "1" : "" },
|
||||
timeout: 30_000,
|
||||
maxBuffer: 4 * 1024 * 1024,
|
||||
},
|
||||
).catch((error) => {
|
||||
throw new Error(String(error.stderr).replaceAll(/at .*/g, "[child stack omitted]"));
|
||||
});
|
||||
for (const phase of ["FILTER", "REFRESH"]) {
|
||||
const output = stderr.split(`${phase}_START\n`)[1]?.split(`${phase}_END\n`)[0];
|
||||
expect(output, `${phase}: synchronous filesystem IO`).toBe("");
|
||||
}
|
||||
expect(JSON.parse(stdout.trim().split("\n")[0])).toMatchObject({ fsCalls: 0 });
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
test("Git event identity preserves Windows namespace, case and subtree boundaries", async () => {
|
||||
const { GitObservationPaths } = await import("./git-observation-paths.js");
|
||||
const paths = await GitObservationPaths.create("C:\\paseo-observation-contract");
|
||||
const accept = paths.createEventFilter(["C:\\paseo-observation-contract\\Build"]);
|
||||
expect(paths.relative("\\\\?\\C:\\PASEO-OBSERVATION-CONTRACT\\refs\\heads\\MixedCase")).toBe(
|
||||
"refs/heads/MixedCase",
|
||||
);
|
||||
expect(accept("\\\\?\\C:\\paseo-observation-contract\\build\\noise")).toBe(false);
|
||||
expect(accept("C:\\paseo-observation-contract\\BuildSibling\\file")).toBe(true);
|
||||
});
|
||||
|
||||
test("concurrent alias requests establish one working-tree subscription", async () => {
|
||||
const { symlink } = await import("node:fs/promises");
|
||||
const root = realpathSync(mkdtempSync(path.join(tmpdir(), "git-observation-concurrent-")));
|
||||
const repo = path.join(root, "repo");
|
||||
const alias = path.join(root, "alias");
|
||||
execFileSync("git", ["init", "-b", "main", repo], { stdio: "pipe" });
|
||||
await symlink(repo, alias, process.platform === "win32" ? "junction" : "dir");
|
||||
let subscriptions = 0;
|
||||
const service = new WorkspaceGitServiceImpl({
|
||||
logger: pino({ enabled: false }),
|
||||
paseoHome: path.join(root, "home"),
|
||||
deps: {
|
||||
subscribe: async () => {
|
||||
subscriptions++;
|
||||
return { updateIgnore: async () => {}, unsubscribe: async () => {} };
|
||||
},
|
||||
},
|
||||
});
|
||||
try {
|
||||
const watches = await Promise.all([
|
||||
service.requestWorkingTreeWatch(repo, () => {}),
|
||||
service.requestWorkingTreeWatch(alias, () => {}),
|
||||
]);
|
||||
expect(subscriptions).toBe(1);
|
||||
for (const watch of watches) watch.unsubscribe();
|
||||
} finally {
|
||||
await service.dispose();
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("Git event identity retains a symlink's literal target before canonicalizing its parent", async () => {
|
||||
const { mkdir, symlink } = await import("node:fs/promises");
|
||||
const { GitObservationPaths } = await import("./git-observation-paths.js");
|
||||
const root = realpathSync(mkdtempSync(path.join(tmpdir(), "git-observation-literal-target-")));
|
||||
const repository = path.join(root, "repository");
|
||||
const parentAlias = path.join(root, "parent-alias");
|
||||
const workspaceAlias = path.join(root, "workspace-alias");
|
||||
const literalTarget = path.join(parentAlias, "repository");
|
||||
const linkType = process.platform === "win32" ? "junction" : "dir";
|
||||
try {
|
||||
await mkdir(repository);
|
||||
await symlink(root, parentAlias, linkType);
|
||||
await symlink(literalTarget, workspaceAlias, linkType);
|
||||
const paths = await GitObservationPaths.create(workspaceAlias);
|
||||
const accept = paths.createEventFilter([path.join(workspaceAlias, "ignored")]);
|
||||
for (const spelling of [workspaceAlias, literalTarget, repository]) {
|
||||
expect(paths.relative(path.join(spelling, "deleted.txt"))).toBe("deleted.txt");
|
||||
expect(accept(path.join(spelling, "ignored", "deleted.txt"))).toBe(false);
|
||||
}
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1189,7 +1189,7 @@ describe("WorkspaceGitService checkout observation", () => {
|
||||
]);
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
await vi.waitFor(() => {
|
||||
expect(getCalledCwds(getCheckoutStatus)).toEqual([WORKTREE_A, WORKTREE_B]);
|
||||
expect(getCalledCwds(getCheckoutStatus).sort()).toEqual([WORKTREE_A, WORKTREE_B].sort());
|
||||
});
|
||||
|
||||
first.unsubscribe();
|
||||
@@ -1624,6 +1624,12 @@ describe("WorkspaceGitService checkout observation", () => {
|
||||
const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn());
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(watcher.subscribe).toHaveBeenCalledWith(
|
||||
GIT_DIR,
|
||||
expect.any(Function),
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(getCheckoutStatus).toHaveBeenCalledTimes(1);
|
||||
expect(service.getMetrics().workspaceObservationSetupInFlightCount).toBe(0);
|
||||
expect(service.getMetrics().workspaceRefreshInFlightCount).toBe(0);
|
||||
});
|
||||
@@ -1642,14 +1648,17 @@ describe("WorkspaceGitService checkout observation", () => {
|
||||
[60_000, 6],
|
||||
] as const) {
|
||||
await vi.advanceTimersByTimeAsync(elapsedMs);
|
||||
expect(pollCount()).toBe(expectedPolls);
|
||||
await vi.waitFor(() => {
|
||||
expect(pollCount()).toBe(expectedPolls);
|
||||
expect(service.getMetrics().workspaceRefreshInFlightCount).toBe(0);
|
||||
});
|
||||
}
|
||||
|
||||
isDirty = true;
|
||||
await vi.advanceTimersByTimeAsync(60_000);
|
||||
expect(pollCount()).toBe(7);
|
||||
await vi.waitFor(() => expect(pollCount()).toBe(7));
|
||||
await vi.advanceTimersByTimeAsync(5_000);
|
||||
expect(pollCount()).toBe(8);
|
||||
await vi.waitFor(() => expect(pollCount()).toBe(8));
|
||||
|
||||
subscription.unsubscribe();
|
||||
service.dispose();
|
||||
@@ -1860,8 +1869,10 @@ describe("WorkspaceGitService checkout observation", () => {
|
||||
expect(watcher.subscribe).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(35_000);
|
||||
expect(service.getMetrics().workingTreeWatchSetupInFlightCount).toBe(0);
|
||||
expect(service.getMetrics().workspaceObservationSetupAdmissionActiveCount).toBe(0);
|
||||
await vi.waitFor(() => {
|
||||
expect(service.getMetrics().workingTreeWatchSetupInFlightCount).toBe(0);
|
||||
expect(service.getMetrics().workspaceObservationSetupAdmissionActiveCount).toBe(0);
|
||||
});
|
||||
expect(getWatcherSubscribeCallCount(watcher, REPO_CWD)).toBe(1);
|
||||
|
||||
openedSubscription.resolve({
|
||||
|
||||
@@ -991,7 +991,7 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
|
||||
await vi.advanceTimersByTimeAsync(60_000);
|
||||
await flushPromises();
|
||||
|
||||
expect(subscribe).toHaveBeenCalledTimes(2);
|
||||
await vi.waitFor(() => expect(subscribe).toHaveBeenCalledTimes(2));
|
||||
expect(getCheckoutStatus).toHaveBeenCalledTimes(1);
|
||||
|
||||
subscription.unsubscribe();
|
||||
@@ -2221,7 +2221,7 @@ describe("WorkspaceGitServiceImpl D2 read methods", () => {
|
||||
expect(getCheckoutStatus).toHaveBeenCalledTimes(2);
|
||||
expect(getPullRequestStatus).toHaveBeenCalledTimes(2);
|
||||
|
||||
service.dispose();
|
||||
await service.dispose();
|
||||
});
|
||||
|
||||
test("onWorkspaceStateMayHaveChanged preserves includeForge when a file watcher fires within the debounce window", async () => {
|
||||
|
||||
@@ -42,18 +42,13 @@ import {
|
||||
type ForgeResolver,
|
||||
} from "../services/forge-resolver.js";
|
||||
import { parseGitRevParsePath } from "../utils/git-rev-parse-path.js";
|
||||
import {
|
||||
createRealpathAwarePathMatcher,
|
||||
getRealpathAwareRelativePath,
|
||||
isPathInsideRoot,
|
||||
isRealpathInsideRoot,
|
||||
looksLikeDefiniteWindowsPath,
|
||||
} from "../utils/path.js";
|
||||
import { isPathInsideRoot, looksLikeDefiniteWindowsPath } from "../utils/path.js";
|
||||
import {
|
||||
createRunGitCommand,
|
||||
runGitCommand,
|
||||
type RunGitCommand,
|
||||
} from "../utils/run-git-command.js";
|
||||
import { GitObservationPaths } from "./git-observation-paths.js";
|
||||
import { branchNameFromRef } from "../utils/worktree-metadata.js";
|
||||
import { listPaseoWorktrees, type PaseoWorktreeInfo } from "../utils/worktree.js";
|
||||
import { READ_ONLY_GIT_ENV } from "./checkout-git-utils.js";
|
||||
@@ -417,6 +412,7 @@ class WorkspaceGitWatcherSubscriptionTimeoutError extends Error {
|
||||
}
|
||||
|
||||
interface WorkspaceGitTarget {
|
||||
gitDirPaths: GitObservationPaths | null;
|
||||
cwd: string;
|
||||
listeners: Set<WorkspaceGitListener>;
|
||||
workingTreeWatchTarget: WorkingTreeWatchTarget | null;
|
||||
@@ -443,6 +439,7 @@ interface WorkspaceGitTarget {
|
||||
}
|
||||
|
||||
interface RepoGitTarget {
|
||||
paths: GitObservationPaths;
|
||||
repoGitRoot: string;
|
||||
cwd: string;
|
||||
workspaceKeys: Set<string>;
|
||||
@@ -470,6 +467,8 @@ interface RepoMetadataWorkspaceRefresh {
|
||||
}
|
||||
|
||||
interface WorkingTreeWatchTarget {
|
||||
paths: GitObservationPaths;
|
||||
acceptsEvent: (candidate: string) => boolean;
|
||||
cwd: string;
|
||||
watchPath: string;
|
||||
repoRoot: string | null;
|
||||
@@ -1165,6 +1164,8 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
|
||||
private async resolveWorkingTreeWatchTarget(cwd: string): Promise<WorkingTreeWatchTarget> {
|
||||
const repoRoot = await this.resolveCheckoutWatchRoot(cwd);
|
||||
const cwdPaths = repoRoot ? await GitObservationPaths.create(cwd) : null;
|
||||
this.assertNotDisposed();
|
||||
const targetCwd = repoRoot ?? cwd;
|
||||
const existingTarget = this.workingTreeWatchTargets.get(targetCwd);
|
||||
if (existingTarget) {
|
||||
@@ -1179,7 +1180,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
return target;
|
||||
}
|
||||
|
||||
const watchPath = repoRoot && createRealpathAwarePathMatcher(repoRoot)(cwd) ? cwd : targetCwd;
|
||||
const watchPath = repoRoot && cwdPaths?.matches(repoRoot) ? cwd : targetCwd;
|
||||
const setup = this.createWorkingTreeWatchTarget(targetCwd, watchPath, repoRoot).finally(() => {
|
||||
if (this.workingTreeWatchSetups.get(targetCwd) === setup) {
|
||||
this.workingTreeWatchSetups.delete(targetCwd);
|
||||
@@ -1198,6 +1199,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
|
||||
private createWorkspaceTarget(cwd: string): WorkspaceGitTarget {
|
||||
const target: WorkspaceGitTarget = {
|
||||
gitDirPaths: null,
|
||||
cwd,
|
||||
listeners: new Set(),
|
||||
workingTreeWatchTarget: null,
|
||||
@@ -1299,6 +1301,11 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
}
|
||||
await this.promoteWorkingTreeWatchTarget(workingTreeTarget, facts.worktreeRoot);
|
||||
const gitDir = facts.absoluteGitDir;
|
||||
const gitDirPaths = target.gitDirPaths?.matches(gitDir)
|
||||
? target.gitDirPaths
|
||||
: await GitObservationPaths.create(gitDir);
|
||||
if (!this.isActiveObservedWorkspaceTarget(target)) return;
|
||||
target.gitDirPaths = gitDirPaths;
|
||||
const repoGitRoot = facts.gitCommonDir ?? (await this.resolveWorkspaceGitRefsRoot(gitDir));
|
||||
if (!this.isActiveObservedWorkspaceTarget(target)) {
|
||||
return;
|
||||
@@ -1331,7 +1338,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
const promise = this.deps
|
||||
.getCheckoutSnapshotFacts(target.cwd, context)
|
||||
.then((facts) => {
|
||||
target.latestFacts = facts;
|
||||
if (!target.closed) target.latestFacts = facts;
|
||||
return facts;
|
||||
})
|
||||
.finally(() => {
|
||||
@@ -1359,7 +1366,11 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
const ignoredDirectories = repoRoot
|
||||
? (await this.loadIgnoredDirs(watchPath)).ignored
|
||||
: new Set<string>();
|
||||
const paths = await GitObservationPaths.create(watchPath);
|
||||
this.assertNotDisposed();
|
||||
const target: WorkingTreeWatchTarget = {
|
||||
paths,
|
||||
acceptsEvent: paths.createEventFilter([join(watchPath, ".git"), ...ignoredDirectories]),
|
||||
cwd,
|
||||
watchPath,
|
||||
repoRoot,
|
||||
@@ -1523,15 +1534,14 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
if (watcherErrored) {
|
||||
return;
|
||||
}
|
||||
events = this.relevantWorkingTreeEvents(target, events);
|
||||
if (events.length === 0) return;
|
||||
const discovered = this.noteWorkingTreeDirectories(target, events);
|
||||
if (events.some((event) => basename(event.path) === ".gitignore")) {
|
||||
void this.refreshWorkingTreeIgnoredDirectories(target);
|
||||
} else if (discovered) {
|
||||
this.scheduleWorkingTreeIgnoreRefresh(target);
|
||||
}
|
||||
if (!this.hasRelevantWorkingTreeEvent(target, events)) {
|
||||
return;
|
||||
}
|
||||
this.notifyWorkingTreeChanged(target, "working-tree-watch");
|
||||
},
|
||||
{ ignore },
|
||||
@@ -1763,20 +1773,15 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
}
|
||||
this.removeDeletedKnownDirectories(target, events);
|
||||
const gitDir = join(target.watchPath, ".git");
|
||||
// `matchesWatchPath` is realpath-aware because it compares an event-reported
|
||||
// directory against `target.watchPath` itself — a config-time root that can
|
||||
// legitimately differ from the watcher's reported form (e.g. a symlinked
|
||||
// temp dir). It is now gated behind the plain Set lookup below, so it only
|
||||
// runs for a directory this target has never seen before, not on every
|
||||
// event.
|
||||
const matchesWatchPath = createRealpathAwarePathMatcher(target.watchPath);
|
||||
// Event paths have already been mapped into this subscription's established namespace.
|
||||
const matchesWatchPath = target.paths.matches;
|
||||
// One new directory is enough: the scheduled Git query inventories every
|
||||
// ignored directory that exists by then. Stop before a large event batch
|
||||
// multiplies containment checks across all its new directories.
|
||||
for (const event of events) {
|
||||
const directory = dirname(event.path);
|
||||
// Cheap Set lookup first — the steady-state case where the directory is
|
||||
// already known must never reach the realpath-aware matcher below.
|
||||
// already known do not need another root comparison.
|
||||
if (target.knownDirectories.has(directory) || matchesWatchPath(directory)) {
|
||||
continue;
|
||||
}
|
||||
@@ -1955,28 +1960,22 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
target.pendingKnownDirectories.clear();
|
||||
}
|
||||
|
||||
private hasRelevantWorkingTreeEvent(
|
||||
private relevantWorkingTreeEvents(
|
||||
target: WorkingTreeWatchTarget,
|
||||
events: FileChange[],
|
||||
): boolean {
|
||||
const gitDir = join(target.watchPath, ".git");
|
||||
const matchesWatchPath = createRealpathAwarePathMatcher(target.watchPath);
|
||||
return events.some((event) => {
|
||||
// Directory metadata changes at the watch root do not change Git state.
|
||||
// FSEvents may emit these when every changed descendant was ignored.
|
||||
if (matchesWatchPath(event.path)) {
|
||||
return false;
|
||||
): FileChange[] {
|
||||
const relevant: FileChange[] = [];
|
||||
// Watch backends may report the same path more than once in a batch.
|
||||
const acceptedPaths = new Map<string, boolean>();
|
||||
for (const event of events) {
|
||||
let accepted = acceptedPaths.get(event.path);
|
||||
if (accepted === undefined) {
|
||||
accepted = target.acceptsEvent(event.path);
|
||||
acceptedPaths.set(event.path, accepted);
|
||||
}
|
||||
if (isRealpathInsideRoot(gitDir, event.path)) {
|
||||
return false;
|
||||
}
|
||||
for (const ignoredDirectory of target.ignoredDirectories) {
|
||||
if (isRealpathInsideRoot(ignoredDirectory, event.path)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
if (accepted) relevant.push({ ...event, path: target.paths.toWatchPath(event.path) });
|
||||
}
|
||||
return relevant;
|
||||
}
|
||||
|
||||
private refreshWorkingTreeIgnoredDirectories(target: WorkingTreeWatchTarget): Promise<void> {
|
||||
@@ -2031,6 +2030,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
}
|
||||
if (!this.haveSamePaths(target.ignoredDirectories, ignoredDirectories)) {
|
||||
target.ignoredDirectories = ignoredDirectories;
|
||||
target.acceptsEvent = target.paths.createEventFilter(this.workingTreeIgnoreList(target));
|
||||
this.pruneKnownDirectories(target);
|
||||
}
|
||||
if (target.fallbackPolling) {
|
||||
@@ -2152,7 +2152,15 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
return;
|
||||
}
|
||||
|
||||
const paths = await GitObservationPaths.create(repoGitRoot);
|
||||
if (!this.isActiveObservedWorkspaceTarget(workspaceTarget)) return;
|
||||
const establishedTarget = this.repoTargets.get(repoGitRoot);
|
||||
if (establishedTarget) {
|
||||
establishedTarget.workspaceKeys.add(workspaceTarget.cwd);
|
||||
return;
|
||||
}
|
||||
const repoTarget: RepoGitTarget = {
|
||||
paths,
|
||||
repoGitRoot,
|
||||
cwd: workspaceTarget.cwd,
|
||||
workspaceKeys: new Set([workspaceTarget.cwd]),
|
||||
@@ -2205,10 +2213,11 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
target: RepoGitTarget,
|
||||
options?: { replaceFallback?: boolean },
|
||||
): Promise<boolean> {
|
||||
const paths = target.paths;
|
||||
const ignore = getPrunedGitMetadataPaths("common").map((path) =>
|
||||
join(target.repoGitRoot, path),
|
||||
);
|
||||
const matchesRepoGitRoot = createRealpathAwarePathMatcher(target.repoGitRoot);
|
||||
const matchesRepoGitRoot = paths.matches;
|
||||
const canary = this.deps.createWatcherLivenessCanary(target.repoGitRoot);
|
||||
let openedSubscription: FileObserverSubscription | null = null;
|
||||
let watcherErrored = false;
|
||||
@@ -2223,7 +2232,9 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
const subscription = await this.subscribeWithDeadline(
|
||||
target.repoGitRoot,
|
||||
(error, events) => {
|
||||
const liveEvents = canary.filterEvents(events);
|
||||
const liveEvents = canary.filterEvents(
|
||||
events.map((event) => ({ ...event, path: paths.toWatchPath(event.path) })),
|
||||
);
|
||||
if (error) {
|
||||
if (watcherErrored) {
|
||||
return;
|
||||
@@ -2249,7 +2260,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
const relevantEvents = liveEvents.filter(
|
||||
(event) =>
|
||||
!matchesRepoGitRoot(event.path) &&
|
||||
ignore.every((ignoredPath) => !isRealpathInsideRoot(ignoredPath, event.path)),
|
||||
ignore.every((ignoredPath) => !isPathInsideRoot(ignoredPath, event.path)),
|
||||
);
|
||||
if (relevantEvents.length > 0) {
|
||||
const immediateEvents = target.fetchInFlight
|
||||
@@ -2362,7 +2373,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
events: FileChange[],
|
||||
): Map<string, RepoMetadataWorkspaceRefresh> | null {
|
||||
const refreshes = new Map<string, RepoMetadataWorkspaceRefresh>();
|
||||
const matchesRepoGitRoot = createRealpathAwarePathMatcher(target.repoGitRoot);
|
||||
const matchesRepoGitRoot = target.paths.matches;
|
||||
|
||||
for (const event of events) {
|
||||
if (!this.routeRepoMetadataEvent(target, event, matchesRepoGitRoot, refreshes)) return null;
|
||||
@@ -2377,10 +2388,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
): void {
|
||||
const workspaceKeys = new Set<string>();
|
||||
for (const event of events) {
|
||||
const commonRelativePath = getRealpathAwareRelativePath(
|
||||
target.repoGitRoot,
|
||||
event.path,
|
||||
)?.replaceAll("\\", "/");
|
||||
const commonRelativePath = target.paths.relative(event.path)?.replaceAll("\\", "/");
|
||||
if (commonRelativePath === "config" || commonRelativePath === "info/exclude") {
|
||||
for (const workspaceKey of target.workspaceKeys) workspaceKeys.add(workspaceKey);
|
||||
continue;
|
||||
@@ -2390,8 +2398,10 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
if (
|
||||
facts?.isGit &&
|
||||
facts.absoluteGitDir &&
|
||||
getRealpathAwareRelativePath(facts.absoluteGitDir, event.path)?.replaceAll("\\", "/") ===
|
||||
"config.worktree"
|
||||
this.workspaceTargets
|
||||
.get(workspaceKey)
|
||||
?.gitDirPaths?.relative(event.path)
|
||||
?.replaceAll("\\", "/") === "config.worktree"
|
||||
) {
|
||||
workspaceKeys.add(workspaceKey);
|
||||
}
|
||||
@@ -2407,7 +2417,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
}
|
||||
|
||||
private isFetchRemoteMetadataEvent(target: RepoGitTarget, event: FileChange): boolean {
|
||||
const relativePath = getRealpathAwareRelativePath(target.repoGitRoot, event.path);
|
||||
const relativePath = target.paths.relative(event.path);
|
||||
const effect = classifyGitMetadataPath("common", relativePath ?? "");
|
||||
return (
|
||||
(effect.kind === "ref" && effect.namespace === "remote") ||
|
||||
@@ -2419,12 +2429,12 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
private routeRepoMetadataEvent(
|
||||
target: RepoGitTarget,
|
||||
event: FileChange,
|
||||
matchesRepoGitRoot: ReturnType<typeof createRealpathAwarePathMatcher>,
|
||||
matchesRepoGitRoot: (candidate: string) => boolean,
|
||||
refreshes: Map<string, RepoMetadataWorkspaceRefresh>,
|
||||
): boolean {
|
||||
if (this.routePrivateGitDirEvent(target, event, matchesRepoGitRoot, refreshes)) return true;
|
||||
|
||||
const commonRelativePath = getRealpathAwareRelativePath(target.repoGitRoot, event.path);
|
||||
const commonRelativePath = target.paths.relative(event.path);
|
||||
const effect = classifyGitMetadataPath("common", commonRelativePath ?? "");
|
||||
switch (effect.kind) {
|
||||
case "ignore":
|
||||
@@ -2476,7 +2486,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
private routePrivateGitDirEvent(
|
||||
target: RepoGitTarget,
|
||||
event: FileChange,
|
||||
matchesRepoGitRoot: ReturnType<typeof createRealpathAwarePathMatcher>,
|
||||
matchesRepoGitRoot: (candidate: string) => boolean,
|
||||
refreshes: Map<string, RepoMetadataWorkspaceRefresh>,
|
||||
): boolean {
|
||||
let matched = false;
|
||||
@@ -2485,8 +2495,10 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
if (!facts?.isGit || !facts.absoluteGitDir || matchesRepoGitRoot(facts.absoluteGitDir)) {
|
||||
continue;
|
||||
}
|
||||
const relativePath = getRealpathAwareRelativePath(facts.absoluteGitDir, event.path);
|
||||
if (relativePath === null) continue;
|
||||
const relativePath = this.workspaceTargets
|
||||
.get(workspaceKey)
|
||||
?.gitDirPaths?.relative(event.path);
|
||||
if (relativePath == null) continue;
|
||||
matched = true;
|
||||
const effect = classifyGitMetadataPath("worktree", relativePath);
|
||||
if (effect.kind === "owner") {
|
||||
@@ -2500,7 +2512,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
|
||||
private routeMainCheckoutMetadata(
|
||||
target: RepoGitTarget,
|
||||
matchesRepoGitRoot: ReturnType<typeof createRealpathAwarePathMatcher>,
|
||||
matchesRepoGitRoot: (candidate: string) => boolean,
|
||||
refreshBase: boolean,
|
||||
refreshes: Map<string, RepoMetadataWorkspaceRefresh>,
|
||||
): void {
|
||||
|
||||
@@ -74,7 +74,7 @@ export async function bootstrapWorkspaceRegistries(options: {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
pinPaseoWorktreeBranchIdentityIfMissing(workspace.worktreeRoot, workspace.branch);
|
||||
await pinPaseoWorktreeBranchIdentityIfMissing(workspace.worktreeRoot, workspace.branch);
|
||||
} catch (error) {
|
||||
options.logger.warn(
|
||||
{ err: error, workspaceId: workspace.workspaceId },
|
||||
|
||||
@@ -343,8 +343,8 @@ describe("generateBranchNameFromFirstAgentContext", () => {
|
||||
const repoRoot = createTempDir("paseo-branch-config-");
|
||||
const worktreeRoot = createTempDir("paseo-branch-worktree-");
|
||||
mkdirSync(path.join(worktreeRoot, ".git"));
|
||||
writePaseoWorktreeMetadata(worktreeRoot, { baseRefName: "main" });
|
||||
writePaseoWorktreeFirstAgentBranchAutoNameMetadata(worktreeRoot, {
|
||||
await writePaseoWorktreeMetadata(worktreeRoot, { baseRefName: "main" });
|
||||
await writePaseoWorktreeFirstAgentBranchAutoNameMetadata(worktreeRoot, {
|
||||
placeholderBranchName: "dazzling-yak",
|
||||
});
|
||||
writeConfig(repoRoot, {
|
||||
|
||||
@@ -854,7 +854,7 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => {
|
||||
second.worktree.worktreePath,
|
||||
"remote.paseo-pr-14.url",
|
||||
);
|
||||
const metadata = readPaseoWorktreeMetadata(second.worktree.worktreePath);
|
||||
const metadata = await readPaseoWorktreeMetadata(second.worktree.worktreePath);
|
||||
const facts = await getCheckoutSnapshotFacts(second.worktree.worktreePath, { paseoHome });
|
||||
|
||||
expect(second.worktree.branchName).toBe("feature/gitlab-mr-1");
|
||||
@@ -1113,7 +1113,7 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => {
|
||||
createCoreDeps({ github }),
|
||||
);
|
||||
writeFileSync(
|
||||
getPaseoWorktreeMetadataPath(second.worktree.worktreePath),
|
||||
await getPaseoWorktreeMetadataPath(second.worktree.worktreePath),
|
||||
`${JSON.stringify({ version: 1, baseRefName: "main" }, null, 2)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
|
||||
@@ -172,7 +172,7 @@ describe("listCheckoutCommits", () => {
|
||||
mkdirSync(join(worktreesRoot, "repo-hash"), { recursive: true });
|
||||
git(["worktree", "add", "-b", "feature", worktreeDir], repoDir);
|
||||
commitFile(worktreeDir, "feature.txt", "feature\n", "Feature work");
|
||||
writePaseoWorktreeMetadata(worktreeDir, { baseRefName: "deleted-base" });
|
||||
await writePaseoWorktreeMetadata(worktreeDir, { baseRefName: "deleted-base" });
|
||||
|
||||
const { baseRef, commits } = await listCheckoutCommits({
|
||||
cwd: worktreeDir,
|
||||
|
||||
@@ -2859,7 +2859,7 @@ const x = 1;
|
||||
execFileSync("git", ["worktree", "add", workspaceDir, "contributor/old-change"], {
|
||||
cwd: repoDir,
|
||||
});
|
||||
writePaseoWorktreeMetadata(workspaceDir, {
|
||||
await writePaseoWorktreeMetadata(workspaceDir, {
|
||||
baseRefName: "main",
|
||||
changeRequestLookupTarget: {
|
||||
headRef: "old-change",
|
||||
@@ -2899,7 +2899,7 @@ const x = 1;
|
||||
const workspaceDir = join(paseoHome, "worktrees", "repo", "renamed-by-agent");
|
||||
mkdirSync(join(paseoHome, "worktrees", "repo"), { recursive: true });
|
||||
execFileSync("git", ["worktree", "add", workspaceDir, "placeholder"], { cwd: repoDir });
|
||||
writePaseoWorktreeMetadata(workspaceDir, {
|
||||
await writePaseoWorktreeMetadata(workspaceDir, {
|
||||
baseRefName: "main",
|
||||
changeRequestLookupTarget: {
|
||||
headRef: "placeholder",
|
||||
@@ -2936,7 +2936,7 @@ const x = 1;
|
||||
const workspaceDir = join(paseoHome, "worktrees", "repo", "pinned-worktree");
|
||||
mkdirSync(join(paseoHome, "worktrees", "repo"), { recursive: true });
|
||||
execFileSync("git", ["worktree", "add", workspaceDir, "feature/pinned"], { cwd: repoDir });
|
||||
writePaseoWorktreeMetadata(workspaceDir, {
|
||||
await writePaseoWorktreeMetadata(workspaceDir, {
|
||||
baseRefName: "main",
|
||||
changeRequestLookupTarget: {
|
||||
headRef: "feature/pinned",
|
||||
@@ -2975,7 +2975,7 @@ const x = 1;
|
||||
execFileSync("git", ["worktree", "add", workspaceDir, "contributor/old-change-1"], {
|
||||
cwd: repoDir,
|
||||
});
|
||||
writePaseoWorktreeMetadata(workspaceDir, {
|
||||
await writePaseoWorktreeMetadata(workspaceDir, {
|
||||
baseRefName: "main",
|
||||
changeRequestLookupTarget: {
|
||||
headRef: "old-change",
|
||||
@@ -2997,7 +2997,7 @@ const x = 1;
|
||||
execFileSync("git", ["worktree", "add", workspaceDir, "contributor/old-change"], {
|
||||
cwd: repoDir,
|
||||
});
|
||||
writePaseoWorktreeMetadata(workspaceDir, {
|
||||
await writePaseoWorktreeMetadata(workspaceDir, {
|
||||
baseRefName: "main",
|
||||
changeRequestLookupTarget: {
|
||||
headRef: "old-change",
|
||||
@@ -3062,7 +3062,7 @@ const x = 1;
|
||||
execFileSync("git", ["worktree", "add", workspaceDir, "mixedowner/old-change"], {
|
||||
cwd: repoDir,
|
||||
});
|
||||
writePaseoWorktreeMetadata(workspaceDir, {
|
||||
await writePaseoWorktreeMetadata(workspaceDir, {
|
||||
baseRefName: "main",
|
||||
changeRequestLookupTarget: {
|
||||
headRef: "old-change",
|
||||
@@ -3114,7 +3114,7 @@ const x = 1;
|
||||
execFileSync("git", ["worktree", "add", workspaceDir, "feature/gitlab-mr"], {
|
||||
cwd: repoDir,
|
||||
});
|
||||
writePaseoWorktreeMetadata(workspaceDir, {
|
||||
await writePaseoWorktreeMetadata(workspaceDir, {
|
||||
baseRefName: "main",
|
||||
changeRequestLookupTarget: {
|
||||
headRef: "feature/gitlab-mr",
|
||||
@@ -3128,7 +3128,9 @@ const x = 1;
|
||||
const forge = createGitHubServiceRecordingPullRequestTargets({ requestedTargets });
|
||||
|
||||
await renameCurrentBranch(workspaceCwd, "feature/renamed");
|
||||
expect(readPaseoWorktreeMetadata(workspaceDir)?.changeRequestLookupTarget).toMatchObject({
|
||||
expect(
|
||||
(await readPaseoWorktreeMetadata(workspaceDir))?.changeRequestLookupTarget,
|
||||
).toMatchObject({
|
||||
localBranchName: "feature/renamed",
|
||||
});
|
||||
const renamedFacts = await getCheckoutSnapshotFacts(workspaceDir, { paseoHome });
|
||||
@@ -3164,7 +3166,7 @@ const x = 1;
|
||||
execFileSync("git", ["worktree", "add", workspaceDir, "feature/placeholder"], {
|
||||
cwd: repoDir,
|
||||
});
|
||||
writePaseoWorktreeMetadata(workspaceDir, {
|
||||
await writePaseoWorktreeMetadata(workspaceDir, {
|
||||
baseRefName: "main",
|
||||
changeRequestLookupTarget: {
|
||||
headRef: "feature/placeholder",
|
||||
@@ -3174,7 +3176,7 @@ const x = 1;
|
||||
|
||||
await renameCurrentBranch(workspaceDir, "feature/generated");
|
||||
|
||||
expect(readPaseoWorktreeMetadata(workspaceDir)?.changeRequestLookupTarget).toEqual({
|
||||
expect((await readPaseoWorktreeMetadata(workspaceDir))?.changeRequestLookupTarget).toEqual({
|
||||
headRef: "feature/generated",
|
||||
localBranchName: "feature/generated",
|
||||
});
|
||||
@@ -3951,7 +3953,7 @@ const x = 1;
|
||||
cwd: worktree.worktreePath,
|
||||
});
|
||||
|
||||
const metadataPath = getPaseoWorktreeMetadataPath(worktree.worktreePath);
|
||||
const metadataPath = await getPaseoWorktreeMetadataPath(worktree.worktreePath);
|
||||
rmSync(metadataPath, { force: true });
|
||||
|
||||
const baseDiff = await getCheckoutDiff(worktree.worktreePath, { mode: "base" }, { paseoHome });
|
||||
@@ -3970,7 +3972,7 @@ const x = 1;
|
||||
paseoHome,
|
||||
});
|
||||
|
||||
const metadataPath = getPaseoWorktreeMetadataPath(worktree.worktreePath);
|
||||
const metadataPath = await getPaseoWorktreeMetadataPath(worktree.worktreePath);
|
||||
rmSync(metadataPath, { force: true });
|
||||
|
||||
const status = await getCheckoutStatus(worktree.worktreePath, { paseoHome });
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
import { MAX_RELAY_WEBSOCKET_MESSAGE_BYTES } from "@getpaseo/protocol/websocket-message-limit";
|
||||
import { resolve, dirname, basename } from "path";
|
||||
import { existsSync, realpathSync } from "fs";
|
||||
import { open as openFile, readFile, stat as statFile, mkdtemp, copyFile, rm } from "fs/promises";
|
||||
import {
|
||||
open as openFile,
|
||||
readFile,
|
||||
stat as statFile,
|
||||
mkdtemp,
|
||||
copyFile,
|
||||
realpath,
|
||||
access,
|
||||
rm,
|
||||
} from "fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { setImmediate } from "node:timers/promises";
|
||||
import { TTLCache } from "@isaacs/ttlcache";
|
||||
@@ -1104,7 +1112,7 @@ async function getMainRepoRootFromCommonDir(
|
||||
if (!commonDir) {
|
||||
throw new Error("Not in a git repository");
|
||||
}
|
||||
const normalized = realpathSync(commonDir);
|
||||
const normalized = await realpath(commonDir);
|
||||
|
||||
if (basename(normalized) === ".git") {
|
||||
return dirname(normalized);
|
||||
@@ -1229,7 +1237,7 @@ export async function renameCurrentBranch(
|
||||
|
||||
const currentBranch = await getCurrentBranch(cwd);
|
||||
if (currentBranch) {
|
||||
rebindPaseoWorktreeChangeRequestHint(worktreeRoot, previousBranch, currentBranch);
|
||||
await rebindPaseoWorktreeChangeRequestHint(worktreeRoot, previousBranch, currentBranch);
|
||||
}
|
||||
return { previousBranch, currentBranch };
|
||||
}
|
||||
@@ -1274,8 +1282,8 @@ function storedBaseRefFromMetadata(metadata: PaseoWorktreeMetadata | null): stri
|
||||
return metadata?.baseRef ?? metadata?.baseRefName ?? null;
|
||||
}
|
||||
|
||||
function readPaseoWorktreeBaseRef(worktreeRoot: string): string | null {
|
||||
return storedBaseRefFromMetadata(readPaseoWorktreeMetadata(worktreeRoot));
|
||||
async function readPaseoWorktreeBaseRef(worktreeRoot: string): Promise<string | null> {
|
||||
return storedBaseRefFromMetadata(await readPaseoWorktreeMetadata(worktreeRoot));
|
||||
}
|
||||
|
||||
async function getStoredBaseRefForCwd(
|
||||
@@ -1549,7 +1557,12 @@ async function abortGitPullConflictState(cwd: string): Promise<void> {
|
||||
const rebaseMergePath = resolve(gitDir, "rebase-merge");
|
||||
const rebaseApplyPath = resolve(gitDir, "rebase-apply");
|
||||
|
||||
if (existsSync(mergeHeadPath)) {
|
||||
if (
|
||||
await access(mergeHeadPath).then(
|
||||
() => true,
|
||||
() => false,
|
||||
)
|
||||
) {
|
||||
try {
|
||||
await runGitCommand(["merge", "--abort"], { cwd, timeout: 120_000 });
|
||||
} catch {
|
||||
@@ -1557,7 +1570,16 @@ async function abortGitPullConflictState(cwd: string): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
if (existsSync(rebaseMergePath) || existsSync(rebaseApplyPath)) {
|
||||
if (
|
||||
(await access(rebaseMergePath).then(
|
||||
() => true,
|
||||
() => false,
|
||||
)) ||
|
||||
(await access(rebaseApplyPath).then(
|
||||
() => true,
|
||||
() => false,
|
||||
))
|
||||
) {
|
||||
try {
|
||||
await runGitCommand(["rebase", "--abort"], { cwd, timeout: 120_000 });
|
||||
} catch {
|
||||
@@ -2078,7 +2100,7 @@ export async function getCheckoutSnapshotFacts(
|
||||
}
|
||||
|
||||
const paseoWorktreeMetadata = inspected.paseoWorktree.isPaseoOwnedWorktree
|
||||
? readPaseoWorktreeMetadata(inspected.paseoWorktree.worktreeRoot)
|
||||
? await readPaseoWorktreeMetadata(inspected.paseoWorktree.worktreeRoot)
|
||||
: null;
|
||||
const storedBaseRef = storedBaseRefFromMetadata(paseoWorktreeMetadata);
|
||||
const resolvedBaseRef = storedBaseRef ?? (await resolveBaseRef(cwd, context));
|
||||
|
||||
@@ -107,7 +107,7 @@ export function isRealpathInsideRoot(root: string, candidate: string): boolean {
|
||||
return getRealpathAwareRelativePath(root, candidate) !== null;
|
||||
}
|
||||
|
||||
function getRelativePathInsideRoot(root: string, candidate: string): string | null {
|
||||
export function getRelativePathInsideRoot(root: string, candidate: string): string | null {
|
||||
const compareAsWindows = shouldCompareAsWindows(root, candidate);
|
||||
const platformPath = compareAsWindows ? nodePath.win32 : nodePath.posix;
|
||||
const normalizedRoot = normalizePathForComparison(root, compareAsWindows);
|
||||
@@ -175,7 +175,7 @@ function normalizePathForComparison(value: string, compareAsWindows: boolean): s
|
||||
return compareAsWindows ? normalized.toLowerCase() : normalized;
|
||||
}
|
||||
|
||||
function normalizePathPreservingCase(value: string, compareAsWindows: boolean): string {
|
||||
export function normalizePathPreservingCase(value: string, compareAsWindows: boolean): string {
|
||||
const platformPath = compareAsWindows ? nodePath.win32 : nodePath.posix;
|
||||
const comparableValue = compareAsWindows ? stripWindowsNamespacePrefix(value) : value;
|
||||
const platformNormalized = platformPath.normalize(comparableValue);
|
||||
|
||||
@@ -507,7 +507,6 @@ describe("runGitCommand", () => {
|
||||
command: "git",
|
||||
args: ["status", "--short"],
|
||||
cwd: process.cwd(),
|
||||
cwdExists: true,
|
||||
timeout: 30_000,
|
||||
maxOutputBytes: 20 * 1024 * 1024,
|
||||
acceptExitCodes: [0, 1],
|
||||
@@ -520,7 +519,6 @@ describe("runGitCommand", () => {
|
||||
command: "git",
|
||||
args: ["status", "--short"],
|
||||
cwd: process.cwd(),
|
||||
cwdExists: true,
|
||||
durationMs: expect.any(Number),
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
@@ -530,6 +528,9 @@ describe("runGitCommand", () => {
|
||||
}),
|
||||
"Git command closed",
|
||||
);
|
||||
for (const [record] of trace.mock.calls) {
|
||||
expect(record).not.toHaveProperty("cwdExists");
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects non-zero exit codes that are not accepted and frees the slot", async () => {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { AsyncLocalStorage } from "node:async_hooks";
|
||||
import { existsSync } from "node:fs";
|
||||
import type { Logger } from "pino";
|
||||
import type { ProcessEnvRecord } from "../server/paseo-env.js";
|
||||
import {
|
||||
@@ -311,7 +310,6 @@ function executeGitCommand<Output>(
|
||||
command: "git",
|
||||
args,
|
||||
cwd: options.cwd,
|
||||
cwdExists: existsSync(options.cwd),
|
||||
timeout,
|
||||
maxOutputBytes,
|
||||
acceptExitCodes,
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "fs";
|
||||
import { mkdir, open, realpath, rename, stat, writeFile } from "node:fs/promises";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { constants } from "node:fs";
|
||||
import pLimit from "p-limit";
|
||||
import { isAbsolute, join, resolve } from "path";
|
||||
import { z } from "zod";
|
||||
|
||||
const MAX_WORKTREE_METADATA_BYTES = 64 * 1024;
|
||||
|
||||
const ChangeRequestLookupTargetSchema = z.object({
|
||||
headRef: z.string().min(1),
|
||||
headRepositoryOwner: z.string().min(1).optional(),
|
||||
@@ -90,75 +95,78 @@ function normalizeLegacyGitHubOwnerForBranch(owner: string): string | null {
|
||||
return /^[a-z0-9-]+$/.test(normalized) ? normalized : null;
|
||||
}
|
||||
|
||||
export function rebindPaseoWorktreeChangeRequestHint(
|
||||
export async function rebindPaseoWorktreeChangeRequestHint(
|
||||
worktreeRoot: string,
|
||||
previousBranch: string,
|
||||
currentBranch: string,
|
||||
): boolean {
|
||||
const metadata = readPaseoWorktreeMetadata(worktreeRoot);
|
||||
const target = getPaseoWorktreeChangeRequestHintForBranch(metadata, previousBranch);
|
||||
if (!metadata || !target) {
|
||||
return false;
|
||||
}
|
||||
): Promise<boolean> {
|
||||
return mutateMetadata(worktreeRoot, async (root) => {
|
||||
const metadata = await readMetadataFile(root);
|
||||
const target = getPaseoWorktreeChangeRequestHintForBranch(metadata, previousBranch);
|
||||
if (!metadata || !target) {
|
||||
return false;
|
||||
}
|
||||
|
||||
writePaseoWorktreeMetadataFile(worktreeRoot, {
|
||||
...metadata,
|
||||
changeRequestLookupTarget: {
|
||||
...target,
|
||||
...(target.headRef === previousBranch &&
|
||||
!target.headRepositoryOwner &&
|
||||
target.changeRequestNumber === undefined
|
||||
? { headRef: currentBranch }
|
||||
: {}),
|
||||
localBranchName: currentBranch,
|
||||
},
|
||||
await writePaseoWorktreeMetadataFile(root, {
|
||||
...metadata,
|
||||
changeRequestLookupTarget: {
|
||||
...target,
|
||||
...(target.headRef === previousBranch &&
|
||||
!target.headRepositoryOwner &&
|
||||
target.changeRequestNumber === undefined
|
||||
? { headRef: currentBranch }
|
||||
: {}),
|
||||
localBranchName: currentBranch,
|
||||
},
|
||||
});
|
||||
return true;
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
export function pinPaseoWorktreeBranchIdentityIfMissing(
|
||||
export async function pinPaseoWorktreeBranchIdentityIfMissing(
|
||||
worktreeRoot: string,
|
||||
branch: string,
|
||||
): boolean {
|
||||
const metadata = readPaseoWorktreeMetadata(worktreeRoot);
|
||||
if (!metadata || metadata.changeRequestLookupTarget) {
|
||||
return false;
|
||||
}
|
||||
const target = createPaseoWorktreeChangeRequestHint({
|
||||
headRef: branch,
|
||||
localBranchName: branch,
|
||||
): Promise<boolean> {
|
||||
return mutateMetadata(worktreeRoot, async (root) => {
|
||||
const metadata = await readMetadataFile(root);
|
||||
if (!metadata || metadata.changeRequestLookupTarget) {
|
||||
return false;
|
||||
}
|
||||
const target = createPaseoWorktreeChangeRequestHint({
|
||||
headRef: branch,
|
||||
localBranchName: branch,
|
||||
});
|
||||
await writePaseoWorktreeMetadataFile(root, {
|
||||
...metadata,
|
||||
changeRequestLookupTarget: target,
|
||||
});
|
||||
return true;
|
||||
});
|
||||
writePaseoWorktreeMetadataFile(worktreeRoot, {
|
||||
...metadata,
|
||||
changeRequestLookupTarget: target,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
function getGitDirForWorktreeRoot(worktreeRoot: string): string {
|
||||
async function getGitDirForWorktreeRoot(worktreeRoot: string): Promise<string> {
|
||||
const gitPath = join(worktreeRoot, ".git");
|
||||
if (!existsSync(gitPath)) {
|
||||
throw new Error(`Not a git repository: ${worktreeRoot}`);
|
||||
}
|
||||
|
||||
// In a worktree checkout, `.git` is a file containing `gitdir: <path>`.
|
||||
// In a normal checkout, `.git` is a directory.
|
||||
let info;
|
||||
try {
|
||||
const gitFileContent = readFileSync(gitPath, "utf8");
|
||||
const match = gitFileContent.match(/gitdir:\s*(.+)/);
|
||||
if (match?.[1]) {
|
||||
const raw = match[1].trim();
|
||||
return isAbsolute(raw) ? raw : resolve(worktreeRoot, raw);
|
||||
}
|
||||
} catch {
|
||||
// If `.git` is a directory, readFileSync will throw; fall through.
|
||||
info = await stat(gitPath);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT")
|
||||
throw new Error(`Not a git repository: ${worktreeRoot}`, { cause: error });
|
||||
throw error;
|
||||
}
|
||||
if (info.isDirectory()) return gitPath;
|
||||
const gitFileContent = await readBoundedFile(gitPath);
|
||||
const match = gitFileContent.match(/gitdir:\s*(.+)/);
|
||||
if (match?.[1]) {
|
||||
const raw = match[1].trim();
|
||||
return isAbsolute(raw) ? raw : resolve(worktreeRoot, raw);
|
||||
}
|
||||
|
||||
return gitPath;
|
||||
}
|
||||
|
||||
export function getPaseoWorktreeMetadataPath(worktreeRoot: string): string {
|
||||
const gitDir = getGitDirForWorktreeRoot(worktreeRoot);
|
||||
export async function getPaseoWorktreeMetadataPath(worktreeRoot: string): Promise<string> {
|
||||
const gitDir = await getGitDirForWorktreeRoot(worktreeRoot);
|
||||
return join(gitDir, "paseo", "worktree.json");
|
||||
}
|
||||
|
||||
@@ -213,120 +221,166 @@ function assertValidBaseRef(value: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
export function writePaseoWorktreeMetadata(
|
||||
export async function writePaseoWorktreeMetadata(
|
||||
worktreeRoot: string,
|
||||
options: {
|
||||
baseRefName: string;
|
||||
baseRef?: string;
|
||||
changeRequestLookupTarget?: PaseoWorktreeChangeRequestHint;
|
||||
},
|
||||
): void {
|
||||
const baseRefName = normalizeBaseRefName(options.baseRefName);
|
||||
assertValidBaseRef(baseRefName);
|
||||
const baseRef = options.baseRef?.trim();
|
||||
if (baseRef) {
|
||||
assertValidBaseRef(baseRef);
|
||||
}
|
||||
): Promise<void> {
|
||||
return mutateMetadata(worktreeRoot, async (root) => {
|
||||
const baseRefName = normalizeBaseRefName(options.baseRefName);
|
||||
assertValidBaseRef(baseRefName);
|
||||
const baseRef = options.baseRef?.trim();
|
||||
if (baseRef) {
|
||||
assertValidBaseRef(baseRef);
|
||||
}
|
||||
|
||||
const metadata: PaseoWorktreeMetadata = {
|
||||
version: 1,
|
||||
baseRefName,
|
||||
...(baseRef ? { baseRef } : {}),
|
||||
...(options.changeRequestLookupTarget
|
||||
? { changeRequestLookupTarget: options.changeRequestLookupTarget }
|
||||
: {}),
|
||||
};
|
||||
writePaseoWorktreeMetadataFile(worktreeRoot, metadata);
|
||||
}
|
||||
|
||||
export function writePaseoWorktreeRuntimeMetadata(
|
||||
worktreeRoot: string,
|
||||
options: { worktreePort: number },
|
||||
): void {
|
||||
if (!Number.isInteger(options.worktreePort) || options.worktreePort <= 0) {
|
||||
throw new Error(`Invalid worktree runtime port: ${options.worktreePort}`);
|
||||
}
|
||||
|
||||
const current = readPaseoWorktreeMetadata(worktreeRoot);
|
||||
if (!current) {
|
||||
throw new Error("Cannot persist worktree runtime metadata: missing base metadata");
|
||||
}
|
||||
|
||||
const next: PaseoWorktreeMetadata = {
|
||||
...current,
|
||||
version: 2,
|
||||
runtime: {
|
||||
worktreePort: options.worktreePort,
|
||||
},
|
||||
};
|
||||
writePaseoWorktreeMetadataFile(worktreeRoot, next);
|
||||
}
|
||||
|
||||
export function writePaseoWorktreeFirstAgentBranchAutoNameMetadata(
|
||||
worktreeRoot: string,
|
||||
options: { placeholderBranchName: string },
|
||||
): void {
|
||||
const placeholderBranchName = options.placeholderBranchName.trim();
|
||||
if (!placeholderBranchName) {
|
||||
throw new Error("Placeholder branch name is required");
|
||||
}
|
||||
|
||||
const current = readPaseoWorktreeMetadata(worktreeRoot);
|
||||
if (!current) {
|
||||
throw new Error("Cannot persist first-agent branch auto-name metadata: missing base metadata");
|
||||
}
|
||||
|
||||
writePaseoWorktreeMetadataFile(worktreeRoot, {
|
||||
...current,
|
||||
version: 2,
|
||||
firstAgentBranchAutoName: {
|
||||
status: "pending",
|
||||
placeholderBranchName,
|
||||
},
|
||||
const metadata: PaseoWorktreeMetadata = {
|
||||
version: 1,
|
||||
baseRefName,
|
||||
...(baseRef ? { baseRef } : {}),
|
||||
...(options.changeRequestLookupTarget
|
||||
? { changeRequestLookupTarget: options.changeRequestLookupTarget }
|
||||
: {}),
|
||||
};
|
||||
await writePaseoWorktreeMetadataFile(root, metadata);
|
||||
});
|
||||
}
|
||||
|
||||
export function markPaseoWorktreeFirstAgentBranchAutoNameAttempted(
|
||||
export async function writePaseoWorktreeRuntimeMetadata(
|
||||
worktreeRoot: string,
|
||||
options: { worktreePort: number },
|
||||
): Promise<void> {
|
||||
return mutateMetadata(worktreeRoot, async (root) => {
|
||||
if (!Number.isInteger(options.worktreePort) || options.worktreePort <= 0) {
|
||||
throw new Error(`Invalid worktree runtime port: ${options.worktreePort}`);
|
||||
}
|
||||
|
||||
const current = await readMetadataFile(root);
|
||||
if (!current) {
|
||||
throw new Error("Cannot persist worktree runtime metadata: missing base metadata");
|
||||
}
|
||||
|
||||
const next: PaseoWorktreeMetadata = {
|
||||
...current,
|
||||
version: 2,
|
||||
runtime: {
|
||||
worktreePort: options.worktreePort,
|
||||
},
|
||||
};
|
||||
await writePaseoWorktreeMetadataFile(root, next);
|
||||
});
|
||||
}
|
||||
|
||||
export async function writePaseoWorktreeFirstAgentBranchAutoNameMetadata(
|
||||
worktreeRoot: string,
|
||||
options: { placeholderBranchName: string },
|
||||
): Promise<void> {
|
||||
return mutateMetadata(worktreeRoot, async (root) => {
|
||||
const placeholderBranchName = options.placeholderBranchName.trim();
|
||||
if (!placeholderBranchName) {
|
||||
throw new Error("Placeholder branch name is required");
|
||||
}
|
||||
|
||||
const current = await readMetadataFile(root);
|
||||
if (!current) {
|
||||
throw new Error(
|
||||
"Cannot persist first-agent branch auto-name metadata: missing base metadata",
|
||||
);
|
||||
}
|
||||
|
||||
await writePaseoWorktreeMetadataFile(root, {
|
||||
...current,
|
||||
version: 2,
|
||||
firstAgentBranchAutoName: {
|
||||
status: "pending",
|
||||
placeholderBranchName,
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function markPaseoWorktreeFirstAgentBranchAutoNameAttempted(
|
||||
worktreeRoot: string,
|
||||
options: { attemptedAt?: string } = {},
|
||||
): PaseoWorktreeMetadata | null {
|
||||
const current = readPaseoWorktreeMetadata(worktreeRoot);
|
||||
if (!current || current.version !== 2 || current.firstAgentBranchAutoName?.status !== "pending") {
|
||||
return current;
|
||||
}
|
||||
): Promise<PaseoWorktreeMetadata | null> {
|
||||
return mutateMetadata(worktreeRoot, async (root) => {
|
||||
const current = await readMetadataFile(root);
|
||||
if (
|
||||
!current ||
|
||||
current.version !== 2 ||
|
||||
current.firstAgentBranchAutoName?.status !== "pending"
|
||||
) {
|
||||
return current;
|
||||
}
|
||||
|
||||
const next: PaseoWorktreeMetadata = {
|
||||
...current,
|
||||
firstAgentBranchAutoName: {
|
||||
status: "attempted",
|
||||
placeholderBranchName: current.firstAgentBranchAutoName.placeholderBranchName,
|
||||
attemptedAt: options.attemptedAt ?? new Date().toISOString(),
|
||||
},
|
||||
};
|
||||
writePaseoWorktreeMetadataFile(worktreeRoot, next);
|
||||
return next;
|
||||
const next: PaseoWorktreeMetadata = {
|
||||
...current,
|
||||
firstAgentBranchAutoName: {
|
||||
status: "attempted",
|
||||
placeholderBranchName: current.firstAgentBranchAutoName.placeholderBranchName,
|
||||
attemptedAt: options.attemptedAt ?? new Date().toISOString(),
|
||||
},
|
||||
};
|
||||
await writePaseoWorktreeMetadataFile(root, next);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
export function readPaseoWorktreeMetadata(worktreeRoot: string): PaseoWorktreeMetadata | null {
|
||||
const metadataPath = getPaseoWorktreeMetadataPath(worktreeRoot);
|
||||
if (!existsSync(metadataPath)) {
|
||||
return null;
|
||||
}
|
||||
const parsed = JSON.parse(readFileSync(metadataPath, "utf8"));
|
||||
return PaseoWorktreeMetadataSchema.parse(parsed);
|
||||
export async function readPaseoWorktreeMetadata(
|
||||
worktreeRoot: string,
|
||||
): Promise<PaseoWorktreeMetadata | null> {
|
||||
const key = await resolveMetadataIdentity(worktreeRoot);
|
||||
await metadataMutations.get(key)?.catch(() => {});
|
||||
return metadataIo(() => readMetadataFile(key));
|
||||
}
|
||||
|
||||
export function requirePaseoWorktreeBaseRefName(worktreeRoot: string): string {
|
||||
const metadataPath = getPaseoWorktreeMetadataPath(worktreeRoot);
|
||||
const metadata = readPaseoWorktreeMetadata(worktreeRoot);
|
||||
async function readMetadataFile(worktreeRoot: string): Promise<PaseoWorktreeMetadata | null> {
|
||||
const metadataPath = await getPaseoWorktreeMetadataPath(worktreeRoot);
|
||||
try {
|
||||
return PaseoWorktreeMetadataSchema.parse(JSON.parse(await readBoundedFile(metadataPath)));
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// O_NONBLOCK lets us reject FIFOs by kind without hanging in open(). A fixed read also
|
||||
// caps a regular file that grows between stat and read; readFile's whole-file loop does not.
|
||||
async function readBoundedFile(path: string): Promise<string> {
|
||||
const file = await open(path, constants.O_RDONLY | constants.O_NONBLOCK);
|
||||
try {
|
||||
const info = await file.stat();
|
||||
const limit = MAX_WORKTREE_METADATA_BYTES;
|
||||
if (!info.isFile() || info.size > limit)
|
||||
throw new Error(`Invalid Paseo worktree metadata file: ${path}`);
|
||||
const buffer = Buffer.alloc(limit + 1);
|
||||
let length = 0;
|
||||
while (length < buffer.length) {
|
||||
const { bytesRead } = await file.read(buffer, length, buffer.length - length);
|
||||
if (bytesRead === 0) break;
|
||||
length += bytesRead;
|
||||
}
|
||||
if (length > limit) throw new Error(`Invalid Paseo worktree metadata file: ${path}`);
|
||||
return buffer.toString("utf8", 0, length);
|
||||
} finally {
|
||||
await file.close();
|
||||
}
|
||||
}
|
||||
|
||||
export async function requirePaseoWorktreeBaseRefName(worktreeRoot: string): Promise<string> {
|
||||
const metadataPath = await getPaseoWorktreeMetadataPath(worktreeRoot);
|
||||
const metadata = await readPaseoWorktreeMetadata(worktreeRoot);
|
||||
if (!metadata) {
|
||||
throw new Error(`Missing Paseo worktree base metadata: ${metadataPath}`);
|
||||
}
|
||||
return metadata.baseRefName;
|
||||
}
|
||||
|
||||
export function readPaseoWorktreeRuntimePort(worktreeRoot: string): number | null {
|
||||
const metadata = readPaseoWorktreeMetadata(worktreeRoot);
|
||||
export async function readPaseoWorktreeRuntimePort(worktreeRoot: string): Promise<number | null> {
|
||||
const metadata = await readPaseoWorktreeMetadata(worktreeRoot);
|
||||
if (!metadata) {
|
||||
return null;
|
||||
}
|
||||
@@ -336,13 +390,61 @@ export function readPaseoWorktreeRuntimePort(worktreeRoot: string): number | nul
|
||||
return null;
|
||||
}
|
||||
|
||||
function writePaseoWorktreeMetadataFile(
|
||||
async function writePaseoWorktreeMetadataFile(
|
||||
worktreeRoot: string,
|
||||
metadata: PaseoWorktreeMetadata,
|
||||
): void {
|
||||
const metadataPath = getPaseoWorktreeMetadataPath(worktreeRoot);
|
||||
mkdirSync(join(getGitDirForWorktreeRoot(worktreeRoot), "paseo"), { recursive: true });
|
||||
const tempPath = `${metadataPath}.${process.pid}.${Date.now()}.tmp`;
|
||||
writeFileSync(tempPath, `${JSON.stringify(metadata, null, 2)}\n`, "utf8");
|
||||
renameSync(tempPath, metadataPath);
|
||||
): Promise<void> {
|
||||
const metadataPath = await getPaseoWorktreeMetadataPath(worktreeRoot);
|
||||
const contents = `${JSON.stringify(metadata, null, 2)}\n`;
|
||||
if (Buffer.byteLength(contents) > MAX_WORKTREE_METADATA_BYTES) {
|
||||
throw new Error(`Invalid Paseo worktree metadata file: ${metadataPath}`);
|
||||
}
|
||||
// Never recreate an archived Git directory while a queued mutation is pending.
|
||||
try {
|
||||
await mkdir(join(await getGitDirForWorktreeRoot(worktreeRoot), "paseo"));
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
|
||||
}
|
||||
const tempPath = `${metadataPath}.${randomUUID()}.tmp`;
|
||||
await writeFile(tempPath, contents, "utf8");
|
||||
await rename(tempPath, metadataPath);
|
||||
}
|
||||
|
||||
const metadataIo = pLimit(2);
|
||||
const metadataMutations = new Map<string, Promise<unknown>>();
|
||||
|
||||
// Only share in-flight identity resolution: a replaced symlink must not retain an old owner.
|
||||
const metadataIdentities = new Map<string, Promise<string>>();
|
||||
|
||||
function resolveMetadataIdentity(worktreeRoot: string): Promise<string> {
|
||||
const path = resolve(worktreeRoot);
|
||||
const existing = metadataIdentities.get(path);
|
||||
if (existing) return existing;
|
||||
const identity = metadataIo(async () => {
|
||||
try {
|
||||
return await realpath(path);
|
||||
} catch {
|
||||
return path;
|
||||
}
|
||||
}).finally(() => {
|
||||
if (metadataIdentities.get(path) === identity) metadataIdentities.delete(path);
|
||||
});
|
||||
metadataIdentities.set(path, identity);
|
||||
return identity;
|
||||
}
|
||||
|
||||
async function mutateMetadata<T>(
|
||||
worktreeRoot: string,
|
||||
mutation: (root: string) => Promise<T>,
|
||||
): Promise<T> {
|
||||
const key = await resolveMetadataIdentity(worktreeRoot);
|
||||
const previous = metadataMutations.get(key) ?? Promise.resolve();
|
||||
const pending = previous.catch(() => {}).then(() => metadataIo(() => mutation(key)));
|
||||
metadataMutations.set(key, pending);
|
||||
void pending
|
||||
.finally(() => {
|
||||
if (metadataMutations.get(key) === pending) metadataMutations.delete(key);
|
||||
})
|
||||
.catch(() => {});
|
||||
return pending;
|
||||
}
|
||||
|
||||
@@ -21,7 +21,13 @@ import {
|
||||
type WorktreeConfig,
|
||||
} from "./worktree";
|
||||
import type { PaseoConfig } from "@getpaseo/protocol/paseo-config-schema";
|
||||
import { getPaseoWorktreeMetadataPath, readPaseoWorktreeMetadata } from "./worktree-metadata.js";
|
||||
import {
|
||||
getPaseoWorktreeMetadataPath,
|
||||
readPaseoWorktreeMetadata,
|
||||
writePaseoWorktreeMetadata,
|
||||
writePaseoWorktreeRuntimeMetadata,
|
||||
writePaseoWorktreeFirstAgentBranchAutoNameMetadata,
|
||||
} from "./worktree-metadata.js";
|
||||
import {
|
||||
getCheckoutDiff,
|
||||
getCheckoutStatus,
|
||||
@@ -125,7 +131,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => {
|
||||
expect(result.worktreePath).toBe(join(paseoHome, "worktrees", projectHash, "hello-world"));
|
||||
expect(existsSync(result.worktreePath)).toBe(true);
|
||||
expect(existsSync(join(result.worktreePath, "file.txt"))).toBe(true);
|
||||
const metadataPath = getPaseoWorktreeMetadataPath(result.worktreePath);
|
||||
const metadataPath = await getPaseoWorktreeMetadataPath(result.worktreePath);
|
||||
expect(existsSync(metadataPath)).toBe(true);
|
||||
const metadata = JSON.parse(readFileSync(metadataPath, "utf8"));
|
||||
expect(metadata).toMatchObject({
|
||||
@@ -256,7 +262,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => {
|
||||
cwd: result.worktreePath,
|
||||
});
|
||||
|
||||
const metadataPath = getPaseoWorktreeMetadataPath(result.worktreePath);
|
||||
const metadataPath = await getPaseoWorktreeMetadataPath(result.worktreePath);
|
||||
const metadata = JSON.parse(readFileSync(metadataPath, "utf8"));
|
||||
expect(metadata).toMatchObject({
|
||||
version: 1,
|
||||
@@ -319,7 +325,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => {
|
||||
// baseRefName must be the repo's default branch, not the checked-out branch
|
||||
// itself — otherwise the diff panel compares the branch against itself and
|
||||
// always shows no changes.
|
||||
const metadataPath = getPaseoWorktreeMetadataPath(result.worktreePath);
|
||||
const metadataPath = await getPaseoWorktreeMetadataPath(result.worktreePath);
|
||||
const metadata = JSON.parse(readFileSync(metadataPath, "utf8"));
|
||||
expect(metadata).toMatchObject({
|
||||
version: 1,
|
||||
@@ -370,7 +376,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => {
|
||||
});
|
||||
|
||||
expect(existsSync(result.worktreePath)).toBe(true);
|
||||
const metadataPath = getPaseoWorktreeMetadataPath(result.worktreePath);
|
||||
const metadataPath = await getPaseoWorktreeMetadataPath(result.worktreePath);
|
||||
const metadata = JSON.parse(readFileSync(metadataPath, "utf8"));
|
||||
expect(metadata).toMatchObject({ version: 1, baseRefName: "develop" });
|
||||
});
|
||||
@@ -406,7 +412,9 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => {
|
||||
|
||||
expect(result.branchName).toBe("main-1");
|
||||
expect(existsSync(result.worktreePath)).toBe(true);
|
||||
expect(readPaseoWorktreeMetadata(result.worktreePath)?.changeRequestLookupTarget).toEqual({
|
||||
expect(
|
||||
(await readPaseoWorktreeMetadata(result.worktreePath))?.changeRequestLookupTarget,
|
||||
).toEqual({
|
||||
headRef: "main-1",
|
||||
localBranchName: "main-1",
|
||||
});
|
||||
@@ -459,7 +467,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => {
|
||||
.trim();
|
||||
expect(currentBranch).toBe("user/feature");
|
||||
|
||||
const metadataPath = getPaseoWorktreeMetadataPath(result.worktreePath);
|
||||
const metadataPath = await getPaseoWorktreeMetadataPath(result.worktreePath);
|
||||
const metadata = JSON.parse(readFileSync(metadataPath, "utf8"));
|
||||
expect(metadata).toMatchObject({ baseRefName: "main" });
|
||||
});
|
||||
@@ -566,7 +574,9 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => {
|
||||
"from-origin\n",
|
||||
);
|
||||
expect(
|
||||
JSON.parse(readFileSync(getPaseoWorktreeMetadataPath(originResult.worktreePath), "utf8")),
|
||||
JSON.parse(
|
||||
readFileSync(await getPaseoWorktreeMetadataPath(originResult.worktreePath), "utf8"),
|
||||
),
|
||||
).toMatchObject({ baseRefName: "main" });
|
||||
});
|
||||
|
||||
@@ -607,7 +617,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => {
|
||||
// to the commit the worktree was cut from: "main" resolves local-first, which here is
|
||||
// a different commit than the fork's upstream.
|
||||
expect(
|
||||
JSON.parse(readFileSync(getPaseoWorktreeMetadataPath(result.worktreePath), "utf8")),
|
||||
JSON.parse(readFileSync(await getPaseoWorktreeMetadataPath(result.worktreePath), "utf8")),
|
||||
).toMatchObject({ baseRefName: "main", baseRef: "refs/remotes/upstream/main" });
|
||||
|
||||
// An untouched child must report no work of its own. Comparing against the wrong base
|
||||
@@ -694,7 +704,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => {
|
||||
});
|
||||
|
||||
expect(
|
||||
JSON.parse(readFileSync(getPaseoWorktreeMetadataPath(result.worktreePath), "utf8")),
|
||||
JSON.parse(readFileSync(await getPaseoWorktreeMetadataPath(result.worktreePath), "utf8")),
|
||||
).toMatchObject({
|
||||
baseRefName: "release+hotfix",
|
||||
baseRef: "refs/remotes/upstream/release+hotfix",
|
||||
@@ -1617,3 +1627,104 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(isPlatform("win32"))("async worktree metadata", () => {
|
||||
it("serializes metadata mutations per worktree while another worktree progresses", async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "worktree-metadata-order-"));
|
||||
const first = join(root, "first");
|
||||
const second = join(root, "second");
|
||||
mkdirSync(join(first, ".git"), { recursive: true });
|
||||
mkdirSync(join(second, ".git"), { recursive: true });
|
||||
try {
|
||||
await writePaseoWorktreeMetadata(first, { baseRefName: "main" });
|
||||
await writePaseoWorktreeMetadata(second, { baseRefName: "release" });
|
||||
const order: string[] = [];
|
||||
const runtime = writePaseoWorktreeRuntimeMetadata(first, { worktreePort: 4101 }).then(() =>
|
||||
order.push("first-runtime"),
|
||||
);
|
||||
const unrelated = writePaseoWorktreeRuntimeMetadata(second, { worktreePort: 4102 }).then(() =>
|
||||
order.push("second-runtime"),
|
||||
);
|
||||
const naming = writePaseoWorktreeFirstAgentBranchAutoNameMetadata(first, {
|
||||
placeholderBranchName: "topic",
|
||||
}).then(() => order.push("first-naming"));
|
||||
// A read issued while writes are pending observes their completed snapshot.
|
||||
const snapshot = readPaseoWorktreeMetadata(first);
|
||||
await Promise.all([runtime, unrelated, naming]);
|
||||
expect(order.indexOf("first-runtime")).toBeLessThan(order.indexOf("first-naming"));
|
||||
expect(await snapshot).toMatchObject({
|
||||
baseRefName: "main",
|
||||
runtime: { worktreePort: 4101 },
|
||||
firstAgentBranchAutoName: { status: "pending", placeholderBranchName: "topic" },
|
||||
});
|
||||
expect(await readPaseoWorktreeMetadata(second)).toMatchObject({
|
||||
baseRefName: "release",
|
||||
runtime: { worktreePort: 4102 },
|
||||
});
|
||||
const alias = join(root, "first-alias");
|
||||
symlinkSync(first, alias, "dir");
|
||||
await Promise.all([
|
||||
writePaseoWorktreeRuntimeMetadata(first, { worktreePort: 4103 }),
|
||||
writePaseoWorktreeFirstAgentBranchAutoNameMetadata(alias, {
|
||||
placeholderBranchName: "renamed",
|
||||
}),
|
||||
]);
|
||||
expect(await readPaseoWorktreeMetadata(first)).toMatchObject({
|
||||
runtime: { worktreePort: 4103 },
|
||||
firstAgentBranchAutoName: { placeholderBranchName: "renamed" },
|
||||
});
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("a queued metadata mutation cannot recreate an archived Git directory", async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "worktree-metadata-archive-"));
|
||||
mkdirSync(join(root, ".git"));
|
||||
try {
|
||||
await writePaseoWorktreeMetadata(root, { baseRefName: "main" });
|
||||
const pending = writePaseoWorktreeRuntimeMetadata(root, { worktreePort: 4101 });
|
||||
// The mutation is queued in a microtask, so archive wins this ordering.
|
||||
rmSync(join(root, ".git"), { recursive: true });
|
||||
await expect(pending).rejects.toThrow("Not a git repository");
|
||||
expect(existsSync(join(root, ".git"))).toBe(false);
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("recognizes a removed worktree under a symlinked ownership root", async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "worktree-ownership-alias-"));
|
||||
const actual = join(root, "actual");
|
||||
const alias = join(root, "worktrees");
|
||||
mkdirSync(actual);
|
||||
symlinkSync(actual, alias, "dir");
|
||||
try {
|
||||
const cwd = join(alias, "project", "removed");
|
||||
expect(
|
||||
await isPaseoOwnedWorktreeCwd(cwd, { worktreesRoot: alias, knownGitCommonDir: null }),
|
||||
).toMatchObject({ allowed: true, worktreePath: cwd });
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects oversized metadata and named pipes without reading them whole", async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "worktree-metadata-admission-"));
|
||||
mkdirSync(join(root, ".git", "paseo"), { recursive: true });
|
||||
try {
|
||||
const path = await getPaseoWorktreeMetadataPath(root);
|
||||
writeFileSync(path, JSON.stringify({ version: 1, baseRefName: "x".repeat(64 * 1024) }));
|
||||
await expect(readPaseoWorktreeMetadata(root)).rejects.toThrow(
|
||||
"Invalid Paseo worktree metadata file",
|
||||
);
|
||||
rmSync(path);
|
||||
execFileSync("mkfifo", [path]);
|
||||
await expect(readPaseoWorktreeMetadata(root)).rejects.toThrow(
|
||||
"Invalid Paseo worktree metadata file",
|
||||
);
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
deletePaseoWorktree,
|
||||
isPaseoOwnedWorktreeCwd,
|
||||
mapWorkspaceCwdToWorktree,
|
||||
runWorktreeTeardownCommands,
|
||||
slugify,
|
||||
type CreateWorktreeOptions,
|
||||
type WorktreeConfig,
|
||||
@@ -171,7 +172,15 @@ describe("paseo worktree manager", () => {
|
||||
expect(existsSync(created.worktreePath)).toBe(true);
|
||||
|
||||
const ownership = await isPaseoOwnedWorktreeCwd(created.worktreePath, { paseoHome });
|
||||
expect(ownership.allowed).toBe(true);
|
||||
expect(ownership).toMatchObject({ allowed: true, worktreePath: created.worktreePath });
|
||||
// Windows may report the same root through native 8.3 and JS long spellings.
|
||||
const nativeWorktreePath = realpathSync.native(created.worktreePath);
|
||||
await expect(
|
||||
runWorktreeTeardownCommands({
|
||||
worktreePath: created.worktreePath,
|
||||
teardownCwd: nativeWorktreePath,
|
||||
}),
|
||||
).resolves.toEqual([]);
|
||||
await expect(
|
||||
isPaseoOwnedWorktreeCwd(join(created.worktreePath, "packages", "app"), { paseoHome }),
|
||||
).resolves.toMatchObject({
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { execFile } from "child_process";
|
||||
import { promisify } from "util";
|
||||
import { constants as fsConstants, existsSync, mkdirSync, realpathSync, statSync } from "fs";
|
||||
import { copyFile, rm, stat } from "fs/promises";
|
||||
import { constants as fsConstants, realpath } from "fs";
|
||||
import { copyFile, mkdir, rm, stat } from "fs/promises";
|
||||
import { join, basename, dirname, isAbsolute, resolve, sep } from "path";
|
||||
import net from "node:net";
|
||||
import { createHash } from "node:crypto";
|
||||
import stripAnsi from "strip-ansi";
|
||||
import pLimit from "p-limit";
|
||||
import {
|
||||
buildStringCommandShellInvocation,
|
||||
createStringCommandShellEnv,
|
||||
@@ -37,7 +38,12 @@ import { spawnProcess } from "./spawn.js";
|
||||
import { resolvePaseoHome } from "../server/paseo-home.js";
|
||||
import { createExternalProcessEnv } from "../server/paseo-env.js";
|
||||
import { parseGitRevParsePath, resolveGitRevParsePath } from "./git-rev-parse-path.js";
|
||||
import { expandTilde, getRealpathAwareRelativePath, isPathInsideRoot } from "./path.js";
|
||||
import {
|
||||
expandTilde,
|
||||
getRealpathAwareRelativePath,
|
||||
getRelativePathInsideRoot,
|
||||
isPathInsideRoot,
|
||||
} from "./path.js";
|
||||
import { terminateWithTreeKill } from "./tree-kill.js";
|
||||
|
||||
export { slugify, validateBranchSlug } from "@getpaseo/protocol/branch-slug";
|
||||
@@ -607,7 +613,7 @@ async function assertPortAvailable(port: number): Promise<void> {
|
||||
async function inferRepoRootPathFromWorktreePath(worktreePath: string): Promise<string> {
|
||||
try {
|
||||
const commonDir = await getGitCommonDir(worktreePath);
|
||||
const normalizedCommonDir = normalizePathForOwnership(commonDir);
|
||||
const normalizedCommonDir = await normalizePathForOwnership(commonDir);
|
||||
// Normal repo/worktree: common dir is <repoRoot>/.git
|
||||
if (basename(normalizedCommonDir) === ".git") {
|
||||
return dirname(normalizedCommonDir);
|
||||
@@ -623,12 +629,12 @@ async function inferRepoRootPathFromWorktreePath(worktreePath: string): Promise<
|
||||
});
|
||||
const topLevel = parseGitRevParsePath(stdout);
|
||||
if (topLevel) {
|
||||
return normalizePathForOwnership(topLevel);
|
||||
return await normalizePathForOwnership(topLevel);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return normalizePathForOwnership(worktreePath);
|
||||
return await normalizePathForOwnership(worktreePath);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -711,12 +717,12 @@ export async function resolveWorktreeRuntimeEnv(options: {
|
||||
const branchName =
|
||||
options.branchName ?? (await resolveBranchNameForWorktreePath(options.worktreePath));
|
||||
|
||||
let worktreePort = readPaseoWorktreeRuntimePort(options.worktreePath);
|
||||
let worktreePort = await readPaseoWorktreeRuntimePort(options.worktreePath);
|
||||
if (worktreePort === null) {
|
||||
worktreePort = await getAvailablePort();
|
||||
const metadata = readPaseoWorktreeMetadata(options.worktreePath);
|
||||
const metadata = await readPaseoWorktreeMetadata(options.worktreePath);
|
||||
if (metadata) {
|
||||
writePaseoWorktreeRuntimeMetadata(options.worktreePath, { worktreePort });
|
||||
await writePaseoWorktreeRuntimeMetadata(options.worktreePath, { worktreePort });
|
||||
}
|
||||
} else {
|
||||
await assertPortAvailable(worktreePort);
|
||||
@@ -742,7 +748,7 @@ export async function runWorktreeTeardownCommands(options: {
|
||||
repoRootPath?: string;
|
||||
}): Promise<WorktreeTeardownCommandResult[]> {
|
||||
const teardownCwd = options.teardownCwd ?? options.worktreePath;
|
||||
if (getRealpathAwareRelativePath(options.worktreePath, teardownCwd) === null) {
|
||||
if ((await getOwnershipRelativePath(options.worktreePath, teardownCwd)) === null) {
|
||||
throw new Error(`Worktree teardown cwd is outside the worktree: ${teardownCwd}`);
|
||||
}
|
||||
const teardownCommands = getWorktreeTeardownCommands(teardownCwd);
|
||||
@@ -754,7 +760,7 @@ export async function runWorktreeTeardownCommands(options: {
|
||||
options.repoRootPath ?? (await inferRepoRootPathFromWorktreePath(options.worktreePath));
|
||||
const branchName =
|
||||
options.branchName ?? (await resolveBranchNameForWorktreePath(options.worktreePath));
|
||||
const worktreePort = readPaseoWorktreeRuntimePort(options.worktreePath);
|
||||
const worktreePort = await readPaseoWorktreeRuntimePort(options.worktreePath);
|
||||
|
||||
const teardownEnv: NodeJS.ProcessEnv = createStringCommandShellEnv(
|
||||
createExternalProcessEnv(process.env, {
|
||||
@@ -831,12 +837,12 @@ function deriveShortAlphanumericHash(value: string): string {
|
||||
export async function deriveWorktreeProjectHash(cwd: string): Promise<string> {
|
||||
try {
|
||||
const commonDir = await getGitCommonDir(cwd);
|
||||
const normalizedCommonDir = normalizePathForOwnership(commonDir);
|
||||
const normalizedCommonDir = await normalizePathForOwnership(commonDir);
|
||||
const repoRoot =
|
||||
basename(normalizedCommonDir) === ".git" ? dirname(normalizedCommonDir) : normalizedCommonDir;
|
||||
return deriveShortAlphanumericHash(repoRoot);
|
||||
} catch {
|
||||
return deriveShortAlphanumericHash(normalizePathForOwnership(cwd));
|
||||
return deriveShortAlphanumericHash(await normalizePathForOwnership(cwd));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -904,16 +910,46 @@ export function mapWorkspaceRelativeCwdToWorktree(input: {
|
||||
return mappedCwd;
|
||||
}
|
||||
|
||||
function normalizePathForOwnership(input: string): string {
|
||||
// Keep the former realpathSync spelling on Windows: promises.realpath uses the
|
||||
// native resolver and can return an 8.3 alias instead of the long path.
|
||||
const resolveOwnershipPath = promisify(realpath);
|
||||
const resolveNativeOwnershipPath = promisify(realpath.native);
|
||||
const ownershipIdentityIo = pLimit(2);
|
||||
|
||||
async function normalizePathForOwnership(input: string): Promise<string> {
|
||||
try {
|
||||
return realpathSync(input);
|
||||
return await ownershipIdentityIo(() => resolveOwnershipPath(input));
|
||||
} catch {
|
||||
return resolve(input);
|
||||
}
|
||||
}
|
||||
|
||||
function resolveRepoRootFromGitCommonDir(commonDir: string): string {
|
||||
const normalizedCommonDir = normalizePathForOwnership(commonDir);
|
||||
async function ownershipPathVariants(input: string): Promise<string[]> {
|
||||
const variants = new Set([input, await normalizePathForOwnership(input)]);
|
||||
try {
|
||||
variants.add(await ownershipIdentityIo(() => resolveNativeOwnershipPath(input)));
|
||||
} catch {
|
||||
// Missing descendants still match a lexical or established root spelling.
|
||||
}
|
||||
return [...variants];
|
||||
}
|
||||
|
||||
// Keep lexical variants for missing paths: archive must still recognize a removed
|
||||
// worktree below a symlinked worktrees root, whose full realpath no longer exists.
|
||||
async function getOwnershipRelativePath(root: string, candidate: string): Promise<string | null> {
|
||||
const roots = await ownershipPathVariants(root);
|
||||
const candidates = await ownershipPathVariants(candidate);
|
||||
for (const rootPath of roots) {
|
||||
for (const candidatePath of candidates) {
|
||||
const relative = getRelativePathInsideRoot(rootPath, candidatePath);
|
||||
if (relative !== null) return relative;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function resolveRepoRootFromGitCommonDir(commonDir: string): Promise<string> {
|
||||
const normalizedCommonDir = await normalizePathForOwnership(commonDir);
|
||||
return basename(normalizedCommonDir) === ".git"
|
||||
? dirname(normalizedCommonDir)
|
||||
: normalizedCommonDir;
|
||||
@@ -923,25 +959,25 @@ export async function isPaseoOwnedWorktreeCwd(
|
||||
cwd: string,
|
||||
options?: PaseoWorktreeOwnershipOptions,
|
||||
): Promise<PaseoWorktreeOwnership> {
|
||||
const resolvedCwd = normalizePathForOwnership(cwd);
|
||||
const resolvedCwd = await normalizePathForOwnership(cwd);
|
||||
|
||||
// repoRoot is best-effort: git may be unreachable from the worktree (e.g. a
|
||||
// previous archive attempt removed the admin dir before the working tree
|
||||
// could be fully cleaned up). We still want to allow archiving in that case.
|
||||
let repoRoot: string | undefined;
|
||||
if (options?.knownGitCommonDir) {
|
||||
repoRoot = resolveRepoRootFromGitCommonDir(options.knownGitCommonDir);
|
||||
repoRoot = await resolveRepoRootFromGitCommonDir(options.knownGitCommonDir);
|
||||
} else if (options?.knownGitCommonDir === undefined) {
|
||||
try {
|
||||
const gitCommonDir = await getGitCommonDir(cwd);
|
||||
repoRoot = resolveRepoRootFromGitCommonDir(gitCommonDir);
|
||||
repoRoot = await resolveRepoRootFromGitCommonDir(gitCommonDir);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
const worktreesBaseRoot = resolvePaseoWorktreesBaseRoot(options);
|
||||
const relativePath = getRealpathAwareRelativePath(worktreesBaseRoot, resolvedCwd);
|
||||
const relativePath = await getOwnershipRelativePath(worktreesBaseRoot, resolvedCwd);
|
||||
|
||||
// Ownership is defined by the path living under <worktrees-root>/<hash>/<slug>[/...].
|
||||
// The <hash>/<slug> prefix is Paseo-private — nothing else writes there — so the
|
||||
@@ -1012,9 +1048,9 @@ function parseWorktreeList(output: string): ParsedPaseoWorktreeInfo[] {
|
||||
return entries;
|
||||
}
|
||||
|
||||
function resolveWorktreeCreatedAtIso(worktreePath: string): string {
|
||||
async function resolveWorktreeCreatedAtIso(worktreePath: string): Promise<string> {
|
||||
try {
|
||||
const stats = statSync(worktreePath);
|
||||
const stats = await stat(worktreePath);
|
||||
const birthtimeMs = stats.birthtimeMs;
|
||||
const createdAtMs =
|
||||
Number.isFinite(birthtimeMs) && birthtimeMs > 0 ? birthtimeMs : stats.ctimeMs;
|
||||
@@ -1039,12 +1075,15 @@ export async function listPaseoWorktrees({
|
||||
envOverlay: READ_ONLY_GIT_ENV,
|
||||
});
|
||||
|
||||
return parseWorktreeList(stdout)
|
||||
.map((entry) => Object.assign({}, entry, { path: normalizePathForOwnership(entry.path) }))
|
||||
.filter((entry) => getRealpathAwareRelativePath(projectWorktreesRoot, entry.path) !== null)
|
||||
.map((entry) =>
|
||||
Object.assign({}, entry, { createdAt: resolveWorktreeCreatedAtIso(entry.path) }),
|
||||
);
|
||||
const roots = await ownershipPathVariants(projectWorktreesRoot);
|
||||
const worktrees: PaseoWorktreeInfo[] = [];
|
||||
// Inspect foreign paths sequentially; a large worktree list must not consume the FS pool.
|
||||
for (const entry of parseWorktreeList(stdout)) {
|
||||
const path = await normalizePathForOwnership(entry.path);
|
||||
if (!roots.some((root) => getRelativePathInsideRoot(root, path) !== null)) continue;
|
||||
worktrees.push({ ...entry, path, createdAt: await resolveWorktreeCreatedAtIso(path) });
|
||||
}
|
||||
return worktrees;
|
||||
}
|
||||
|
||||
export interface DeletePaseoWorktreeOptions {
|
||||
@@ -1083,7 +1122,7 @@ export async function deletePaseoWorktree({
|
||||
}
|
||||
|
||||
const requestedPath = worktreePath ?? join(resolvedWorktreesRoot, worktreeSlug!);
|
||||
const resolvedRequested = normalizePathForOwnership(requestedPath);
|
||||
const resolvedRequested = await normalizePathForOwnership(requestedPath);
|
||||
const ownership = await isPaseoOwnedWorktreeCwd(requestedPath, {
|
||||
paseoHome,
|
||||
worktreesRoot: worktreesBaseRoot,
|
||||
@@ -1091,7 +1130,7 @@ export async function deletePaseoWorktree({
|
||||
const resolvedWorktree =
|
||||
ownership.allowed && ownership.worktreePath ? ownership.worktreePath : resolvedRequested;
|
||||
|
||||
const relativeWorktreePath = getRealpathAwareRelativePath(
|
||||
const relativeWorktreePath = await getOwnershipRelativePath(
|
||||
resolvedWorktreesRoot,
|
||||
resolvedWorktree,
|
||||
);
|
||||
@@ -1207,12 +1246,12 @@ export const createWorktree = async ({
|
||||
}: CreateWorktreeOptions): Promise<CreatedWorktree> => {
|
||||
const sourcePlan = await resolveWorktreeSourcePlan({ cwd, source, desiredSlug: worktreeSlug });
|
||||
let worktreePath = join(await getPaseoWorktreesRoot(cwd, paseoHome, worktreesRoot), worktreeSlug);
|
||||
mkdirSync(dirname(worktreePath), { recursive: true });
|
||||
await mkdir(dirname(worktreePath), { recursive: true });
|
||||
|
||||
// Also handle worktree path collision
|
||||
let finalWorktreePath = worktreePath;
|
||||
let pathSuffix = 1;
|
||||
while (existsSync(finalWorktreePath)) {
|
||||
while (await pathExists(finalWorktreePath)) {
|
||||
finalWorktreePath = `${worktreePath}-${pathSuffix}`;
|
||||
pathSuffix++;
|
||||
}
|
||||
@@ -1222,7 +1261,7 @@ export const createWorktree = async ({
|
||||
cwd,
|
||||
timeout: 120_000,
|
||||
});
|
||||
worktreePath = normalizePathForOwnership(finalWorktreePath);
|
||||
worktreePath = await normalizePathForOwnership(finalWorktreePath);
|
||||
|
||||
if (sourcePlan.pushRemote) {
|
||||
await configureWorktreePushRemote({
|
||||
@@ -1239,7 +1278,7 @@ export const createWorktree = async ({
|
||||
});
|
||||
}
|
||||
|
||||
writePaseoWorktreeMetadata(worktreePath, {
|
||||
await writePaseoWorktreeMetadata(worktreePath, {
|
||||
baseRefName: sourcePlan.metadataBaseRefName,
|
||||
...(sourcePlan.metadataBaseRef ? { baseRef: sourcePlan.metadataBaseRef } : {}),
|
||||
...(sourcePlan.changeRequestLookupTarget
|
||||
|
||||
Reference in New Issue
Block a user