@herdctl/core 5.5.0 → 5.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/config/__tests__/merge.test.js +1 -1
- package/dist/config/__tests__/merge.test.js.map +1 -1
- package/dist/config/index.d.ts +1 -1
- package/dist/config/index.d.ts.map +1 -1
- package/dist/config/index.js +3 -1
- package/dist/config/index.js.map +1 -1
- package/dist/config/schema.d.ts +10 -2
- package/dist/config/schema.d.ts.map +1 -1
- package/dist/config/schema.js +6 -2
- package/dist/config/schema.js.map +1 -1
- package/dist/distribution/__tests__/agent-discovery.test.d.ts +7 -0
- package/dist/distribution/__tests__/agent-discovery.test.d.ts.map +1 -0
- package/dist/distribution/__tests__/agent-discovery.test.js +443 -0
- package/dist/distribution/__tests__/agent-discovery.test.js.map +1 -0
- package/dist/distribution/__tests__/agent-info.test.d.ts +7 -0
- package/dist/distribution/__tests__/agent-info.test.d.ts.map +1 -0
- package/dist/distribution/__tests__/agent-info.test.js +568 -0
- package/dist/distribution/__tests__/agent-info.test.js.map +1 -0
- package/dist/distribution/__tests__/agent-remover.test.d.ts +7 -0
- package/dist/distribution/__tests__/agent-remover.test.d.ts.map +1 -0
- package/dist/distribution/__tests__/agent-remover.test.js +498 -0
- package/dist/distribution/__tests__/agent-remover.test.js.map +1 -0
- package/dist/distribution/__tests__/agent-repo-metadata.test.d.ts +5 -0
- package/dist/distribution/__tests__/agent-repo-metadata.test.d.ts.map +1 -0
- package/dist/distribution/__tests__/agent-repo-metadata.test.js +500 -0
- package/dist/distribution/__tests__/agent-repo-metadata.test.js.map +1 -0
- package/dist/distribution/__tests__/env-scanner.test.d.ts +5 -0
- package/dist/distribution/__tests__/env-scanner.test.d.ts.map +1 -0
- package/dist/distribution/__tests__/env-scanner.test.js +576 -0
- package/dist/distribution/__tests__/env-scanner.test.js.map +1 -0
- package/dist/distribution/__tests__/file-installer.test.d.ts +7 -0
- package/dist/distribution/__tests__/file-installer.test.d.ts.map +1 -0
- package/dist/distribution/__tests__/file-installer.test.js +714 -0
- package/dist/distribution/__tests__/file-installer.test.js.map +1 -0
- package/dist/distribution/__tests__/fleet-config-updater.test.d.ts +7 -0
- package/dist/distribution/__tests__/fleet-config-updater.test.d.ts.map +1 -0
- package/dist/distribution/__tests__/fleet-config-updater.test.js +531 -0
- package/dist/distribution/__tests__/fleet-config-updater.test.js.map +1 -0
- package/dist/distribution/__tests__/installation-metadata.test.d.ts +2 -0
- package/dist/distribution/__tests__/installation-metadata.test.d.ts.map +1 -0
- package/dist/distribution/__tests__/installation-metadata.test.js +292 -0
- package/dist/distribution/__tests__/installation-metadata.test.js.map +1 -0
- package/dist/distribution/__tests__/integration.test.d.ts +10 -0
- package/dist/distribution/__tests__/integration.test.d.ts.map +1 -0
- package/dist/distribution/__tests__/integration.test.js +522 -0
- package/dist/distribution/__tests__/integration.test.js.map +1 -0
- package/dist/distribution/__tests__/repository-fetcher.test.d.ts +5 -0
- package/dist/distribution/__tests__/repository-fetcher.test.d.ts.map +1 -0
- package/dist/distribution/__tests__/repository-fetcher.test.js +386 -0
- package/dist/distribution/__tests__/repository-fetcher.test.js.map +1 -0
- package/dist/distribution/__tests__/repository-validator.test.d.ts +7 -0
- package/dist/distribution/__tests__/repository-validator.test.d.ts.map +1 -0
- package/dist/distribution/__tests__/repository-validator.test.js +447 -0
- package/dist/distribution/__tests__/repository-validator.test.js.map +1 -0
- package/dist/distribution/__tests__/source-specifier.test.d.ts +5 -0
- package/dist/distribution/__tests__/source-specifier.test.d.ts.map +1 -0
- package/dist/distribution/__tests__/source-specifier.test.js +533 -0
- package/dist/distribution/__tests__/source-specifier.test.js.map +1 -0
- package/dist/distribution/agent-discovery.d.ts +81 -0
- package/dist/distribution/agent-discovery.d.ts.map +1 -0
- package/dist/distribution/agent-discovery.js +264 -0
- package/dist/distribution/agent-discovery.js.map +1 -0
- package/dist/distribution/agent-info.d.ts +86 -0
- package/dist/distribution/agent-info.d.ts.map +1 -0
- package/dist/distribution/agent-info.js +225 -0
- package/dist/distribution/agent-info.js.map +1 -0
- package/dist/distribution/agent-remover.d.ts +83 -0
- package/dist/distribution/agent-remover.d.ts.map +1 -0
- package/dist/distribution/agent-remover.js +222 -0
- package/dist/distribution/agent-remover.js.map +1 -0
- package/dist/distribution/agent-repo-metadata.d.ts +181 -0
- package/dist/distribution/agent-repo-metadata.d.ts.map +1 -0
- package/dist/distribution/agent-repo-metadata.js +143 -0
- package/dist/distribution/agent-repo-metadata.js.map +1 -0
- package/dist/distribution/env-scanner.d.ts +78 -0
- package/dist/distribution/env-scanner.d.ts.map +1 -0
- package/dist/distribution/env-scanner.js +144 -0
- package/dist/distribution/env-scanner.js.map +1 -0
- package/dist/distribution/file-installer.d.ts +80 -0
- package/dist/distribution/file-installer.d.ts.map +1 -0
- package/dist/distribution/file-installer.js +268 -0
- package/dist/distribution/file-installer.js.map +1 -0
- package/dist/distribution/fleet-config-updater.d.ts +96 -0
- package/dist/distribution/fleet-config-updater.d.ts.map +1 -0
- package/dist/distribution/fleet-config-updater.js +266 -0
- package/dist/distribution/fleet-config-updater.js.map +1 -0
- package/dist/distribution/index.d.ts +23 -0
- package/dist/distribution/index.d.ts.map +1 -0
- package/dist/distribution/index.js +42 -0
- package/dist/distribution/index.js.map +1 -0
- package/dist/distribution/installation-metadata.d.ts +191 -0
- package/dist/distribution/installation-metadata.d.ts.map +1 -0
- package/dist/distribution/installation-metadata.js +100 -0
- package/dist/distribution/installation-metadata.js.map +1 -0
- package/dist/distribution/repository-fetcher.d.ts +104 -0
- package/dist/distribution/repository-fetcher.d.ts.map +1 -0
- package/dist/distribution/repository-fetcher.js +246 -0
- package/dist/distribution/repository-fetcher.js.map +1 -0
- package/dist/distribution/repository-validator.d.ts +86 -0
- package/dist/distribution/repository-validator.d.ts.map +1 -0
- package/dist/distribution/repository-validator.js +296 -0
- package/dist/distribution/repository-validator.js.map +1 -0
- package/dist/distribution/source-specifier.d.ts +106 -0
- package/dist/distribution/source-specifier.d.ts.map +1 -0
- package/dist/distribution/source-specifier.js +247 -0
- package/dist/distribution/source-specifier.js.map +1 -0
- package/dist/fleet-manager/errors.d.ts +15 -0
- package/dist/fleet-manager/errors.d.ts.map +1 -1
- package/dist/fleet-manager/errors.js +16 -0
- package/dist/fleet-manager/errors.js.map +1 -1
- package/dist/fleet-manager/fleet-manager.d.ts.map +1 -1
- package/dist/fleet-manager/fleet-manager.js +31 -9
- package/dist/fleet-manager/fleet-manager.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/runner/message-processor.d.ts.map +1 -1
- package/dist/runner/message-processor.js +7 -2
- package/dist/runner/message-processor.js.map +1 -1
- package/dist/runner/runtime/container-manager.js +1 -1
- package/dist/runner/runtime/container-manager.js.map +1 -1
- package/dist/scheduler/errors.d.ts +15 -0
- package/dist/scheduler/errors.d.ts.map +1 -1
- package/dist/scheduler/schedule-runner.d.ts.map +1 -1
- package/dist/scheduler/schedule-runner.js +6 -5
- package/dist/scheduler/schedule-runner.js.map +1 -1
- package/dist/state/__tests__/jsonl-parser.test.d.ts +5 -0
- package/dist/state/__tests__/jsonl-parser.test.d.ts.map +1 -0
- package/dist/state/__tests__/jsonl-parser.test.js +307 -0
- package/dist/state/__tests__/jsonl-parser.test.js.map +1 -0
- package/dist/state/__tests__/session-attribution.test.d.ts +2 -0
- package/dist/state/__tests__/session-attribution.test.d.ts.map +1 -0
- package/dist/state/__tests__/session-attribution.test.js +567 -0
- package/dist/state/__tests__/session-attribution.test.js.map +1 -0
- package/dist/state/__tests__/session-discovery.test.d.ts +2 -0
- package/dist/state/__tests__/session-discovery.test.d.ts.map +1 -0
- package/dist/state/__tests__/session-discovery.test.js +941 -0
- package/dist/state/__tests__/session-discovery.test.js.map +1 -0
- package/dist/state/__tests__/session-metadata.test.d.ts +2 -0
- package/dist/state/__tests__/session-metadata.test.d.ts.map +1 -0
- package/dist/state/__tests__/session-metadata.test.js +422 -0
- package/dist/state/__tests__/session-metadata.test.js.map +1 -0
- package/dist/state/__tests__/tool-parsing.test.d.ts +5 -0
- package/dist/state/__tests__/tool-parsing.test.d.ts.map +1 -0
- package/dist/state/__tests__/tool-parsing.test.js +315 -0
- package/dist/state/__tests__/tool-parsing.test.js.map +1 -0
- package/dist/state/index.d.ts +5 -0
- package/dist/state/index.d.ts.map +1 -1
- package/dist/state/index.js +10 -0
- package/dist/state/index.js.map +1 -1
- package/dist/state/jsonl-parser.d.ts +115 -0
- package/dist/state/jsonl-parser.d.ts.map +1 -0
- package/dist/state/jsonl-parser.js +437 -0
- package/dist/state/jsonl-parser.js.map +1 -0
- package/dist/state/session-attribution.d.ts +35 -0
- package/dist/state/session-attribution.d.ts.map +1 -0
- package/dist/state/session-attribution.js +179 -0
- package/dist/state/session-attribution.js.map +1 -0
- package/dist/state/session-discovery.d.ts +188 -0
- package/dist/state/session-discovery.d.ts.map +1 -0
- package/dist/state/session-discovery.js +513 -0
- package/dist/state/session-discovery.js.map +1 -0
- package/dist/state/session-metadata.d.ts +186 -0
- package/dist/state/session-metadata.d.ts.map +1 -0
- package/dist/state/session-metadata.js +297 -0
- package/dist/state/session-metadata.js.map +1 -0
- package/dist/state/tool-parsing.d.ts +88 -0
- package/dist/state/tool-parsing.d.ts.map +1 -0
- package/dist/state/tool-parsing.js +199 -0
- package/dist/state/tool-parsing.js.map +1 -0
- package/package.json +1 -1
|
@@ -0,0 +1,941 @@
|
|
|
1
|
+
import { mkdir, realpath, rm, utimes, writeFile } from "node:fs/promises";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
// =============================================================================
|
|
6
|
+
// Mocks
|
|
7
|
+
// =============================================================================
|
|
8
|
+
// Mock session-attribution
|
|
9
|
+
vi.mock("../session-attribution.js", () => ({
|
|
10
|
+
buildAttributionIndex: vi.fn(),
|
|
11
|
+
}));
|
|
12
|
+
// Mock jsonl-parser
|
|
13
|
+
vi.mock("../jsonl-parser.js", () => ({
|
|
14
|
+
extractLastSummary: vi.fn(),
|
|
15
|
+
extractSessionMetadata: vi.fn(),
|
|
16
|
+
extractSessionUsage: vi.fn(),
|
|
17
|
+
isSidechainSession: vi.fn().mockResolvedValue(false),
|
|
18
|
+
parseSessionMessages: vi.fn(),
|
|
19
|
+
}));
|
|
20
|
+
// Mock session-metadata - use a class for proper constructor behavior
|
|
21
|
+
const mockGetCustomName = vi.fn().mockResolvedValue(undefined);
|
|
22
|
+
const mockGetAutoName = vi.fn().mockResolvedValue(undefined);
|
|
23
|
+
const mockBatchSetAutoNames = vi.fn().mockResolvedValue(undefined);
|
|
24
|
+
vi.mock("../session-metadata.js", () => {
|
|
25
|
+
return {
|
|
26
|
+
SessionMetadataStore: class MockSessionMetadataStore {
|
|
27
|
+
getCustomName = mockGetCustomName;
|
|
28
|
+
getAutoName = mockGetAutoName;
|
|
29
|
+
batchSetAutoNames = mockBatchSetAutoNames;
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
});
|
|
33
|
+
import { extractLastSummary, extractSessionMetadata, extractSessionUsage, isSidechainSession, parseSessionMessages, } from "../jsonl-parser.js";
|
|
34
|
+
// Import after mocks
|
|
35
|
+
import { buildAttributionIndex } from "../session-attribution.js";
|
|
36
|
+
import { SessionDiscoveryService } from "../session-discovery.js";
|
|
37
|
+
const mockBuildAttributionIndex = vi.mocked(buildAttributionIndex);
|
|
38
|
+
const mockExtractLastSummary = vi.mocked(extractLastSummary);
|
|
39
|
+
const mockExtractSessionMetadata = vi.mocked(extractSessionMetadata);
|
|
40
|
+
const mockExtractSessionUsage = vi.mocked(extractSessionUsage);
|
|
41
|
+
const mockIsSidechainSession = vi.mocked(isSidechainSession);
|
|
42
|
+
const mockParseSessionMessages = vi.mocked(parseSessionMessages);
|
|
43
|
+
// =============================================================================
|
|
44
|
+
// Test Helpers
|
|
45
|
+
// =============================================================================
|
|
46
|
+
/**
|
|
47
|
+
* Create a temporary directory with a unique name
|
|
48
|
+
*/
|
|
49
|
+
async function createTempDir(prefix) {
|
|
50
|
+
const baseDir = join(tmpdir(), `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
|
51
|
+
await mkdir(baseDir, { recursive: true });
|
|
52
|
+
// Resolve to real path to handle macOS /var -> /private/var symlink
|
|
53
|
+
return await realpath(baseDir);
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Create an empty .jsonl session file
|
|
57
|
+
*/
|
|
58
|
+
async function createSessionFile(dir, sessionId) {
|
|
59
|
+
await writeFile(join(dir, `${sessionId}.jsonl`), "");
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Create a default mock attribution index
|
|
63
|
+
*/
|
|
64
|
+
function createMockAttributionIndex(overrides) {
|
|
65
|
+
const agentName = overrides?.defaultAgentName ?? "my-agent";
|
|
66
|
+
const defaultGetAttribute = (sessionId) => ({
|
|
67
|
+
origin: "native",
|
|
68
|
+
agentName,
|
|
69
|
+
triggerType: undefined,
|
|
70
|
+
});
|
|
71
|
+
const getAttribute = overrides?.getAttribute ?? defaultGetAttribute;
|
|
72
|
+
return {
|
|
73
|
+
getAttribute,
|
|
74
|
+
getAttributes: (ids) => new Map(ids.map((id) => [id, getAttribute(id)])),
|
|
75
|
+
size: 0,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
// =============================================================================
|
|
79
|
+
// Tests
|
|
80
|
+
// =============================================================================
|
|
81
|
+
describe("SessionDiscoveryService", () => {
|
|
82
|
+
let tempClaudeHome;
|
|
83
|
+
let tempStateDir;
|
|
84
|
+
beforeEach(async () => {
|
|
85
|
+
tempClaudeHome = await createTempDir("claude-home-test");
|
|
86
|
+
tempStateDir = await createTempDir("state-dir-test");
|
|
87
|
+
// Set up default attribution mock
|
|
88
|
+
mockBuildAttributionIndex.mockResolvedValue(createMockAttributionIndex());
|
|
89
|
+
// Reset metadata store mocks to default
|
|
90
|
+
mockGetCustomName.mockReset();
|
|
91
|
+
mockGetCustomName.mockResolvedValue(undefined);
|
|
92
|
+
mockGetAutoName.mockReset();
|
|
93
|
+
mockGetAutoName.mockResolvedValue(undefined);
|
|
94
|
+
mockBatchSetAutoNames.mockReset();
|
|
95
|
+
mockBatchSetAutoNames.mockResolvedValue(undefined);
|
|
96
|
+
// Reset JSONL parser mocks
|
|
97
|
+
mockExtractLastSummary.mockReset();
|
|
98
|
+
mockExtractLastSummary.mockResolvedValue(undefined);
|
|
99
|
+
});
|
|
100
|
+
afterEach(async () => {
|
|
101
|
+
await rm(tempClaudeHome, { recursive: true, force: true });
|
|
102
|
+
await rm(tempStateDir, { recursive: true, force: true });
|
|
103
|
+
vi.restoreAllMocks();
|
|
104
|
+
});
|
|
105
|
+
// ===========================================================================
|
|
106
|
+
// getAgentSessions
|
|
107
|
+
// ===========================================================================
|
|
108
|
+
describe("getAgentSessions", () => {
|
|
109
|
+
it("returns sessions from agent's working directory, sorted by mtime descending", async () => {
|
|
110
|
+
// Create projects directory structure
|
|
111
|
+
const workingDir = "/Users/ed/Code/myproject";
|
|
112
|
+
const encodedPath = "-Users-ed-Code-myproject";
|
|
113
|
+
const projectDir = join(tempClaudeHome, "projects", encodedPath);
|
|
114
|
+
await mkdir(projectDir, { recursive: true });
|
|
115
|
+
// Create session files with different mtimes
|
|
116
|
+
const now = Date.now();
|
|
117
|
+
await createSessionFile(projectDir, "session-older");
|
|
118
|
+
await createSessionFile(projectDir, "session-newer");
|
|
119
|
+
// Set mtimes explicitly
|
|
120
|
+
const olderTime = new Date(now - 10000);
|
|
121
|
+
const newerTime = new Date(now);
|
|
122
|
+
await utimes(join(projectDir, "session-older.jsonl"), olderTime, olderTime);
|
|
123
|
+
await utimes(join(projectDir, "session-newer.jsonl"), newerTime, newerTime);
|
|
124
|
+
const service = new SessionDiscoveryService({
|
|
125
|
+
claudeHomePath: tempClaudeHome,
|
|
126
|
+
stateDir: tempStateDir,
|
|
127
|
+
});
|
|
128
|
+
const sessions = await service.getAgentSessions("my-agent", workingDir, false);
|
|
129
|
+
expect(sessions).toHaveLength(2);
|
|
130
|
+
// Newest first
|
|
131
|
+
expect(sessions[0].sessionId).toBe("session-newer");
|
|
132
|
+
expect(sessions[1].sessionId).toBe("session-older");
|
|
133
|
+
});
|
|
134
|
+
it("returns empty array when projects directory doesn't exist", async () => {
|
|
135
|
+
const service = new SessionDiscoveryService({
|
|
136
|
+
claudeHomePath: tempClaudeHome,
|
|
137
|
+
stateDir: tempStateDir,
|
|
138
|
+
});
|
|
139
|
+
// Directory doesn't exist at all
|
|
140
|
+
const sessions = await service.getAgentSessions("my-agent", "/nonexistent/path", false);
|
|
141
|
+
expect(sessions).toEqual([]);
|
|
142
|
+
});
|
|
143
|
+
it("returns empty array when no .jsonl files in directory", async () => {
|
|
144
|
+
const workingDir = "/Users/ed/Code/emptyproject";
|
|
145
|
+
const encodedPath = "-Users-ed-Code-emptyproject";
|
|
146
|
+
const projectDir = join(tempClaudeHome, "projects", encodedPath);
|
|
147
|
+
await mkdir(projectDir, { recursive: true });
|
|
148
|
+
// Create a non-.jsonl file
|
|
149
|
+
await writeFile(join(projectDir, "readme.txt"), "hello");
|
|
150
|
+
const service = new SessionDiscoveryService({
|
|
151
|
+
claudeHomePath: tempClaudeHome,
|
|
152
|
+
stateDir: tempStateDir,
|
|
153
|
+
});
|
|
154
|
+
const sessions = await service.getAgentSessions("my-agent", workingDir, false);
|
|
155
|
+
expect(sessions).toEqual([]);
|
|
156
|
+
});
|
|
157
|
+
it("includes attribution data from the attribution index", async () => {
|
|
158
|
+
const workingDir = "/Users/ed/Code/myproject";
|
|
159
|
+
const encodedPath = "-Users-ed-Code-myproject";
|
|
160
|
+
const projectDir = join(tempClaudeHome, "projects", encodedPath);
|
|
161
|
+
await mkdir(projectDir, { recursive: true });
|
|
162
|
+
await createSessionFile(projectDir, "session-abc");
|
|
163
|
+
// Set up attribution mock to return discord origin attributed to the requested agent
|
|
164
|
+
mockBuildAttributionIndex.mockResolvedValue(createMockAttributionIndex({
|
|
165
|
+
getAttribute: (sessionId) => ({
|
|
166
|
+
origin: "discord",
|
|
167
|
+
agentName: "my-agent",
|
|
168
|
+
triggerType: "discord",
|
|
169
|
+
}),
|
|
170
|
+
}));
|
|
171
|
+
const service = new SessionDiscoveryService({
|
|
172
|
+
claudeHomePath: tempClaudeHome,
|
|
173
|
+
stateDir: tempStateDir,
|
|
174
|
+
});
|
|
175
|
+
const sessions = await service.getAgentSessions("my-agent", workingDir, false);
|
|
176
|
+
expect(sessions[0].origin).toBe("discord");
|
|
177
|
+
expect(sessions[0].agentName).toBe("my-agent");
|
|
178
|
+
});
|
|
179
|
+
it("includes custom name from metadata store", async () => {
|
|
180
|
+
const workingDir = "/Users/ed/Code/myproject";
|
|
181
|
+
const encodedPath = "-Users-ed-Code-myproject";
|
|
182
|
+
const projectDir = join(tempClaudeHome, "projects", encodedPath);
|
|
183
|
+
await mkdir(projectDir, { recursive: true });
|
|
184
|
+
await createSessionFile(projectDir, "session-abc");
|
|
185
|
+
// Set up metadata store mock to return a custom name
|
|
186
|
+
mockGetCustomName.mockResolvedValue("My Custom Session");
|
|
187
|
+
const service = new SessionDiscoveryService({
|
|
188
|
+
claudeHomePath: tempClaudeHome,
|
|
189
|
+
stateDir: tempStateDir,
|
|
190
|
+
});
|
|
191
|
+
const sessions = await service.getAgentSessions("my-agent", workingDir, false);
|
|
192
|
+
expect(sessions[0].customName).toBe("My Custom Session");
|
|
193
|
+
expect(mockGetCustomName).toHaveBeenCalledWith("my-agent", "session-abc");
|
|
194
|
+
});
|
|
195
|
+
it("sets resumable: true for non-Docker agents", async () => {
|
|
196
|
+
const workingDir = "/Users/ed/Code/myproject";
|
|
197
|
+
const encodedPath = "-Users-ed-Code-myproject";
|
|
198
|
+
const projectDir = join(tempClaudeHome, "projects", encodedPath);
|
|
199
|
+
await mkdir(projectDir, { recursive: true });
|
|
200
|
+
await createSessionFile(projectDir, "session-abc");
|
|
201
|
+
const service = new SessionDiscoveryService({
|
|
202
|
+
claudeHomePath: tempClaudeHome,
|
|
203
|
+
stateDir: tempStateDir,
|
|
204
|
+
});
|
|
205
|
+
const sessions = await service.getAgentSessions("my-agent", workingDir, false);
|
|
206
|
+
expect(sessions[0].resumable).toBe(true);
|
|
207
|
+
});
|
|
208
|
+
it("sets resumable: false for Docker agents", async () => {
|
|
209
|
+
const workingDir = "/Users/ed/Code/myproject";
|
|
210
|
+
const encodedPath = "-Users-ed-Code-myproject";
|
|
211
|
+
const projectDir = join(tempClaudeHome, "projects", encodedPath);
|
|
212
|
+
await mkdir(projectDir, { recursive: true });
|
|
213
|
+
await createSessionFile(projectDir, "session-abc");
|
|
214
|
+
const service = new SessionDiscoveryService({
|
|
215
|
+
claudeHomePath: tempClaudeHome,
|
|
216
|
+
stateDir: tempStateDir,
|
|
217
|
+
});
|
|
218
|
+
const sessions = await service.getAgentSessions("my-agent", workingDir, true);
|
|
219
|
+
expect(sessions[0].resumable).toBe(false);
|
|
220
|
+
});
|
|
221
|
+
it("cache behavior: second call within TTL uses cache (doesn't re-readdir)", async () => {
|
|
222
|
+
const workingDir = "/Users/ed/Code/myproject";
|
|
223
|
+
const encodedPath = "-Users-ed-Code-myproject";
|
|
224
|
+
const projectDir = join(tempClaudeHome, "projects", encodedPath);
|
|
225
|
+
await mkdir(projectDir, { recursive: true });
|
|
226
|
+
await createSessionFile(projectDir, "session-abc");
|
|
227
|
+
const service = new SessionDiscoveryService({
|
|
228
|
+
claudeHomePath: tempClaudeHome,
|
|
229
|
+
stateDir: tempStateDir,
|
|
230
|
+
cacheTtlMs: 5000, // 5 second TTL
|
|
231
|
+
});
|
|
232
|
+
// First call
|
|
233
|
+
const sessions1 = await service.getAgentSessions("my-agent", workingDir, false);
|
|
234
|
+
expect(sessions1).toHaveLength(1);
|
|
235
|
+
// Add a new session file
|
|
236
|
+
await createSessionFile(projectDir, "session-def");
|
|
237
|
+
// Second call within TTL - should return cached result
|
|
238
|
+
const sessions2 = await service.getAgentSessions("my-agent", workingDir, false);
|
|
239
|
+
expect(sessions2).toHaveLength(1); // Still 1, from cache
|
|
240
|
+
});
|
|
241
|
+
it("cache behavior: after TTL expires, re-reads directory", async () => {
|
|
242
|
+
const workingDir = "/Users/ed/Code/myproject";
|
|
243
|
+
const encodedPath = "-Users-ed-Code-myproject";
|
|
244
|
+
const projectDir = join(tempClaudeHome, "projects", encodedPath);
|
|
245
|
+
await mkdir(projectDir, { recursive: true });
|
|
246
|
+
await createSessionFile(projectDir, "session-abc");
|
|
247
|
+
const service = new SessionDiscoveryService({
|
|
248
|
+
claudeHomePath: tempClaudeHome,
|
|
249
|
+
stateDir: tempStateDir,
|
|
250
|
+
cacheTtlMs: 50, // Very short TTL for testing
|
|
251
|
+
});
|
|
252
|
+
// First call
|
|
253
|
+
const sessions1 = await service.getAgentSessions("my-agent", workingDir, false);
|
|
254
|
+
expect(sessions1).toHaveLength(1);
|
|
255
|
+
// Add a new session file
|
|
256
|
+
await createSessionFile(projectDir, "session-def");
|
|
257
|
+
// Wait for TTL to expire
|
|
258
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
259
|
+
// Third call after TTL - should read new file
|
|
260
|
+
const sessions3 = await service.getAgentSessions("my-agent", workingDir, false);
|
|
261
|
+
expect(sessions3).toHaveLength(2);
|
|
262
|
+
});
|
|
263
|
+
it("preview field is always undefined (lazy loading)", async () => {
|
|
264
|
+
const workingDir = "/Users/ed/Code/myproject";
|
|
265
|
+
const encodedPath = "-Users-ed-Code-myproject";
|
|
266
|
+
const projectDir = join(tempClaudeHome, "projects", encodedPath);
|
|
267
|
+
await mkdir(projectDir, { recursive: true });
|
|
268
|
+
await createSessionFile(projectDir, "session-abc");
|
|
269
|
+
const service = new SessionDiscoveryService({
|
|
270
|
+
claudeHomePath: tempClaudeHome,
|
|
271
|
+
stateDir: tempStateDir,
|
|
272
|
+
});
|
|
273
|
+
const sessions = await service.getAgentSessions("my-agent", workingDir, false);
|
|
274
|
+
expect(sessions[0].preview).toBeUndefined();
|
|
275
|
+
});
|
|
276
|
+
it("only returns sessions attributed to the requested agent", async () => {
|
|
277
|
+
const workingDir = "/Users/ed/Code/myproject";
|
|
278
|
+
const encodedPath = "-Users-ed-Code-myproject";
|
|
279
|
+
const projectDir = join(tempClaudeHome, "projects", encodedPath);
|
|
280
|
+
await mkdir(projectDir, { recursive: true });
|
|
281
|
+
await createSessionFile(projectDir, "session-abc");
|
|
282
|
+
// Attribution points to a different agent
|
|
283
|
+
mockBuildAttributionIndex.mockResolvedValue(createMockAttributionIndex({
|
|
284
|
+
getAttribute: () => ({
|
|
285
|
+
origin: "web",
|
|
286
|
+
agentName: "other-agent",
|
|
287
|
+
triggerType: "web",
|
|
288
|
+
}),
|
|
289
|
+
}));
|
|
290
|
+
const service = new SessionDiscoveryService({
|
|
291
|
+
claudeHomePath: tempClaudeHome,
|
|
292
|
+
stateDir: tempStateDir,
|
|
293
|
+
});
|
|
294
|
+
// Requesting sessions for "my-agent" should not include sessions attributed to "other-agent"
|
|
295
|
+
const sessions = await service.getAgentSessions("my-agent", workingDir, false);
|
|
296
|
+
expect(sessions).toHaveLength(0);
|
|
297
|
+
});
|
|
298
|
+
it("excludes unattributed sessions from per-agent results", async () => {
|
|
299
|
+
const workingDir = "/Users/ed/Code/myproject";
|
|
300
|
+
const encodedPath = "-Users-ed-Code-myproject";
|
|
301
|
+
const projectDir = join(tempClaudeHome, "projects", encodedPath);
|
|
302
|
+
await mkdir(projectDir, { recursive: true });
|
|
303
|
+
await createSessionFile(projectDir, "session-abc");
|
|
304
|
+
// Attribution has no agentName (native CLI session)
|
|
305
|
+
mockBuildAttributionIndex.mockResolvedValue(createMockAttributionIndex({
|
|
306
|
+
getAttribute: () => ({
|
|
307
|
+
origin: "native",
|
|
308
|
+
agentName: undefined,
|
|
309
|
+
triggerType: undefined,
|
|
310
|
+
}),
|
|
311
|
+
}));
|
|
312
|
+
const service = new SessionDiscoveryService({
|
|
313
|
+
claudeHomePath: tempClaudeHome,
|
|
314
|
+
stateDir: tempStateDir,
|
|
315
|
+
});
|
|
316
|
+
// Unattributed sessions should not appear in per-agent results
|
|
317
|
+
const sessions = await service.getAgentSessions("my-agent", workingDir, false);
|
|
318
|
+
expect(sessions).toHaveLength(0);
|
|
319
|
+
});
|
|
320
|
+
});
|
|
321
|
+
// ===========================================================================
|
|
322
|
+
// getAllSessions
|
|
323
|
+
// ===========================================================================
|
|
324
|
+
describe("getAllSessions", () => {
|
|
325
|
+
it("returns directory groups for all project directories", async () => {
|
|
326
|
+
// Create multiple project directories
|
|
327
|
+
const projectDir1 = join(tempClaudeHome, "projects", "-Users-ed-Code-project1");
|
|
328
|
+
const projectDir2 = join(tempClaudeHome, "projects", "-Users-ed-Code-project2");
|
|
329
|
+
await mkdir(projectDir1, { recursive: true });
|
|
330
|
+
await mkdir(projectDir2, { recursive: true });
|
|
331
|
+
await createSessionFile(projectDir1, "session-a");
|
|
332
|
+
await createSessionFile(projectDir2, "session-b");
|
|
333
|
+
const service = new SessionDiscoveryService({
|
|
334
|
+
claudeHomePath: tempClaudeHome,
|
|
335
|
+
stateDir: tempStateDir,
|
|
336
|
+
});
|
|
337
|
+
const groups = await service.getAllSessions([]);
|
|
338
|
+
expect(groups).toHaveLength(2);
|
|
339
|
+
expect(groups.map((g) => g.encodedPath).sort()).toEqual([
|
|
340
|
+
"-Users-ed-Code-project1",
|
|
341
|
+
"-Users-ed-Code-project2",
|
|
342
|
+
]);
|
|
343
|
+
});
|
|
344
|
+
it("matches agent directories to fleet agents by encoded path", async () => {
|
|
345
|
+
const projectDir = join(tempClaudeHome, "projects", "-Users-ed-Code-myproject");
|
|
346
|
+
await mkdir(projectDir, { recursive: true });
|
|
347
|
+
await createSessionFile(projectDir, "session-a");
|
|
348
|
+
const service = new SessionDiscoveryService({
|
|
349
|
+
claudeHomePath: tempClaudeHome,
|
|
350
|
+
stateDir: tempStateDir,
|
|
351
|
+
});
|
|
352
|
+
const groups = await service.getAllSessions([
|
|
353
|
+
{
|
|
354
|
+
name: "my-fleet/my-agent",
|
|
355
|
+
workingDirectory: "/Users/ed/Code/myproject",
|
|
356
|
+
dockerEnabled: false,
|
|
357
|
+
},
|
|
358
|
+
]);
|
|
359
|
+
expect(groups).toHaveLength(1);
|
|
360
|
+
expect(groups[0].agentName).toBe("my-fleet/my-agent");
|
|
361
|
+
});
|
|
362
|
+
it("filters out temp directories (paths starting with /tmp/)", async () => {
|
|
363
|
+
// Create a temp-like directory
|
|
364
|
+
const tempProjectDir = join(tempClaudeHome, "projects", "-tmp-test-project");
|
|
365
|
+
await mkdir(tempProjectDir, { recursive: true });
|
|
366
|
+
await createSessionFile(tempProjectDir, "session-temp");
|
|
367
|
+
// Create a normal directory
|
|
368
|
+
const normalProjectDir = join(tempClaudeHome, "projects", "-Users-ed-Code-normal");
|
|
369
|
+
await mkdir(normalProjectDir, { recursive: true });
|
|
370
|
+
await createSessionFile(normalProjectDir, "session-normal");
|
|
371
|
+
const service = new SessionDiscoveryService({
|
|
372
|
+
claudeHomePath: tempClaudeHome,
|
|
373
|
+
stateDir: tempStateDir,
|
|
374
|
+
});
|
|
375
|
+
const groups = await service.getAllSessions([]);
|
|
376
|
+
// Only the normal directory should be returned
|
|
377
|
+
expect(groups).toHaveLength(1);
|
|
378
|
+
expect(groups[0].encodedPath).toBe("-Users-ed-Code-normal");
|
|
379
|
+
});
|
|
380
|
+
it("filters out temp directories (paths containing /var/folders/)", async () => {
|
|
381
|
+
// Create a var/folders-like directory
|
|
382
|
+
const varFoldersDir = join(tempClaudeHome, "projects", "-var-folders-ab-cd-T-test");
|
|
383
|
+
await mkdir(varFoldersDir, { recursive: true });
|
|
384
|
+
await createSessionFile(varFoldersDir, "session-temp");
|
|
385
|
+
// Create a normal directory
|
|
386
|
+
const normalProjectDir = join(tempClaudeHome, "projects", "-Users-ed-Code-normal");
|
|
387
|
+
await mkdir(normalProjectDir, { recursive: true });
|
|
388
|
+
await createSessionFile(normalProjectDir, "session-normal");
|
|
389
|
+
const service = new SessionDiscoveryService({
|
|
390
|
+
claudeHomePath: tempClaudeHome,
|
|
391
|
+
stateDir: tempStateDir,
|
|
392
|
+
});
|
|
393
|
+
const groups = await service.getAllSessions([]);
|
|
394
|
+
// Only the normal directory should be returned
|
|
395
|
+
expect(groups).toHaveLength(1);
|
|
396
|
+
expect(groups[0].encodedPath).toBe("-Users-ed-Code-normal");
|
|
397
|
+
});
|
|
398
|
+
it("returns empty array when projects directory doesn't exist", async () => {
|
|
399
|
+
// Don't create the projects directory
|
|
400
|
+
const service = new SessionDiscoveryService({
|
|
401
|
+
claudeHomePath: tempClaudeHome,
|
|
402
|
+
stateDir: tempStateDir,
|
|
403
|
+
});
|
|
404
|
+
const groups = await service.getAllSessions([]);
|
|
405
|
+
expect(groups).toEqual([]);
|
|
406
|
+
});
|
|
407
|
+
it("sorts groups by most recent session mtime", async () => {
|
|
408
|
+
const now = Date.now();
|
|
409
|
+
// Create project directories with sessions at different times
|
|
410
|
+
const olderProjectDir = join(tempClaudeHome, "projects", "-Users-ed-Code-older");
|
|
411
|
+
const newerProjectDir = join(tempClaudeHome, "projects", "-Users-ed-Code-newer");
|
|
412
|
+
await mkdir(olderProjectDir, { recursive: true });
|
|
413
|
+
await mkdir(newerProjectDir, { recursive: true });
|
|
414
|
+
await createSessionFile(olderProjectDir, "session-old");
|
|
415
|
+
await createSessionFile(newerProjectDir, "session-new");
|
|
416
|
+
// Set mtimes
|
|
417
|
+
const olderTime = new Date(now - 10000);
|
|
418
|
+
const newerTime = new Date(now);
|
|
419
|
+
await utimes(join(olderProjectDir, "session-old.jsonl"), olderTime, olderTime);
|
|
420
|
+
await utimes(join(newerProjectDir, "session-new.jsonl"), newerTime, newerTime);
|
|
421
|
+
const service = new SessionDiscoveryService({
|
|
422
|
+
claudeHomePath: tempClaudeHome,
|
|
423
|
+
stateDir: tempStateDir,
|
|
424
|
+
});
|
|
425
|
+
const groups = await service.getAllSessions([]);
|
|
426
|
+
expect(groups).toHaveLength(2);
|
|
427
|
+
// Newest first
|
|
428
|
+
expect(groups[0].encodedPath).toBe("-Users-ed-Code-newer");
|
|
429
|
+
expect(groups[1].encodedPath).toBe("-Users-ed-Code-older");
|
|
430
|
+
});
|
|
431
|
+
it("skips directories with no .jsonl files", async () => {
|
|
432
|
+
// Create a project directory with only non-jsonl files
|
|
433
|
+
const emptyProjectDir = join(tempClaudeHome, "projects", "-Users-ed-Code-empty");
|
|
434
|
+
await mkdir(emptyProjectDir, { recursive: true });
|
|
435
|
+
await writeFile(join(emptyProjectDir, "readme.txt"), "hello");
|
|
436
|
+
// Create a normal project directory
|
|
437
|
+
const normalProjectDir = join(tempClaudeHome, "projects", "-Users-ed-Code-normal");
|
|
438
|
+
await mkdir(normalProjectDir, { recursive: true });
|
|
439
|
+
await createSessionFile(normalProjectDir, "session-a");
|
|
440
|
+
const service = new SessionDiscoveryService({
|
|
441
|
+
claudeHomePath: tempClaudeHome,
|
|
442
|
+
stateDir: tempStateDir,
|
|
443
|
+
});
|
|
444
|
+
const groups = await service.getAllSessions([]);
|
|
445
|
+
expect(groups).toHaveLength(1);
|
|
446
|
+
expect(groups[0].encodedPath).toBe("-Users-ed-Code-normal");
|
|
447
|
+
});
|
|
448
|
+
it("skips non-directory entries in projects folder", async () => {
|
|
449
|
+
// Create a file in the projects directory
|
|
450
|
+
const projectsDir = join(tempClaudeHome, "projects");
|
|
451
|
+
await mkdir(projectsDir, { recursive: true });
|
|
452
|
+
await writeFile(join(projectsDir, "some-file.txt"), "hello");
|
|
453
|
+
// Create a normal project directory
|
|
454
|
+
const normalProjectDir = join(projectsDir, "-Users-ed-Code-normal");
|
|
455
|
+
await mkdir(normalProjectDir, { recursive: true });
|
|
456
|
+
await createSessionFile(normalProjectDir, "session-a");
|
|
457
|
+
const service = new SessionDiscoveryService({
|
|
458
|
+
claudeHomePath: tempClaudeHome,
|
|
459
|
+
stateDir: tempStateDir,
|
|
460
|
+
});
|
|
461
|
+
const groups = await service.getAllSessions([]);
|
|
462
|
+
expect(groups).toHaveLength(1);
|
|
463
|
+
expect(groups[0].encodedPath).toBe("-Users-ed-Code-normal");
|
|
464
|
+
});
|
|
465
|
+
it("sets resumable based on agent dockerEnabled", async () => {
|
|
466
|
+
const projectDir = join(tempClaudeHome, "projects", "-Users-ed-Code-myproject");
|
|
467
|
+
await mkdir(projectDir, { recursive: true });
|
|
468
|
+
await createSessionFile(projectDir, "session-a");
|
|
469
|
+
const service = new SessionDiscoveryService({
|
|
470
|
+
claudeHomePath: tempClaudeHome,
|
|
471
|
+
stateDir: tempStateDir,
|
|
472
|
+
});
|
|
473
|
+
const groups = await service.getAllSessions([
|
|
474
|
+
{
|
|
475
|
+
name: "docker-agent",
|
|
476
|
+
workingDirectory: "/Users/ed/Code/myproject",
|
|
477
|
+
dockerEnabled: true,
|
|
478
|
+
},
|
|
479
|
+
]);
|
|
480
|
+
expect(groups[0].sessions[0].resumable).toBe(false);
|
|
481
|
+
});
|
|
482
|
+
it("defaults resumable to true for unmatched directories", async () => {
|
|
483
|
+
const projectDir = join(tempClaudeHome, "projects", "-Users-ed-Code-myproject");
|
|
484
|
+
await mkdir(projectDir, { recursive: true });
|
|
485
|
+
await createSessionFile(projectDir, "session-a");
|
|
486
|
+
const service = new SessionDiscoveryService({
|
|
487
|
+
claudeHomePath: tempClaudeHome,
|
|
488
|
+
stateDir: tempStateDir,
|
|
489
|
+
});
|
|
490
|
+
// No matching agents
|
|
491
|
+
const groups = await service.getAllSessions([]);
|
|
492
|
+
expect(groups[0].sessions[0].resumable).toBe(true);
|
|
493
|
+
});
|
|
494
|
+
it("decodes workingDirectory from encoded path", async () => {
|
|
495
|
+
const projectDir = join(tempClaudeHome, "projects", "-Users-ed-Code-myproject");
|
|
496
|
+
await mkdir(projectDir, { recursive: true });
|
|
497
|
+
await createSessionFile(projectDir, "session-a");
|
|
498
|
+
const service = new SessionDiscoveryService({
|
|
499
|
+
claudeHomePath: tempClaudeHome,
|
|
500
|
+
stateDir: tempStateDir,
|
|
501
|
+
});
|
|
502
|
+
const groups = await service.getAllSessions([]);
|
|
503
|
+
expect(groups[0].workingDirectory).toBe("/Users/ed/Code/myproject");
|
|
504
|
+
});
|
|
505
|
+
});
|
|
506
|
+
// ===========================================================================
|
|
507
|
+
// Delegation methods
|
|
508
|
+
// ===========================================================================
|
|
509
|
+
describe("getSessionMessages", () => {
|
|
510
|
+
it("delegates to parseSessionMessages", async () => {
|
|
511
|
+
const mockMessages = [
|
|
512
|
+
{ role: "user", content: "Hello", timestamp: "2024-01-15T10:00:00Z" },
|
|
513
|
+
];
|
|
514
|
+
mockParseSessionMessages.mockResolvedValue(mockMessages);
|
|
515
|
+
const service = new SessionDiscoveryService({
|
|
516
|
+
claudeHomePath: tempClaudeHome,
|
|
517
|
+
stateDir: tempStateDir,
|
|
518
|
+
});
|
|
519
|
+
const result = await service.getSessionMessages("/Users/ed/Code/myproject", "session-abc");
|
|
520
|
+
expect(result).toEqual(mockMessages);
|
|
521
|
+
expect(mockParseSessionMessages).toHaveBeenCalled();
|
|
522
|
+
});
|
|
523
|
+
});
|
|
524
|
+
describe("getSessionMetadata", () => {
|
|
525
|
+
it("delegates to extractSessionMetadata", async () => {
|
|
526
|
+
const mockMetadata = {
|
|
527
|
+
sessionId: "session-abc",
|
|
528
|
+
firstMessagePreview: "Hello world",
|
|
529
|
+
gitBranch: "main",
|
|
530
|
+
claudeCodeVersion: "1.0.0",
|
|
531
|
+
messageCount: 10,
|
|
532
|
+
firstMessageAt: "2024-01-15T10:00:00Z",
|
|
533
|
+
lastMessageAt: "2024-01-15T11:00:00Z",
|
|
534
|
+
summary: undefined,
|
|
535
|
+
isSidechain: false,
|
|
536
|
+
};
|
|
537
|
+
mockExtractSessionMetadata.mockResolvedValue(mockMetadata);
|
|
538
|
+
const service = new SessionDiscoveryService({
|
|
539
|
+
claudeHomePath: tempClaudeHome,
|
|
540
|
+
stateDir: tempStateDir,
|
|
541
|
+
});
|
|
542
|
+
const result = await service.getSessionMetadata("/Users/ed/Code/myproject", "session-abc");
|
|
543
|
+
expect(result).toEqual(mockMetadata);
|
|
544
|
+
expect(mockExtractSessionMetadata).toHaveBeenCalled();
|
|
545
|
+
});
|
|
546
|
+
it("caches metadata on subsequent calls", async () => {
|
|
547
|
+
// Clear the mock call count before this test
|
|
548
|
+
mockExtractSessionMetadata.mockClear();
|
|
549
|
+
const mockMetadata = {
|
|
550
|
+
sessionId: "session-abc",
|
|
551
|
+
firstMessagePreview: "Hello",
|
|
552
|
+
gitBranch: undefined,
|
|
553
|
+
claudeCodeVersion: undefined,
|
|
554
|
+
messageCount: 1,
|
|
555
|
+
firstMessageAt: "2024-01-15T10:00:00Z",
|
|
556
|
+
lastMessageAt: "2024-01-15T10:00:00Z",
|
|
557
|
+
summary: undefined,
|
|
558
|
+
isSidechain: false,
|
|
559
|
+
};
|
|
560
|
+
mockExtractSessionMetadata.mockResolvedValue(mockMetadata);
|
|
561
|
+
const service = new SessionDiscoveryService({
|
|
562
|
+
claudeHomePath: tempClaudeHome,
|
|
563
|
+
stateDir: tempStateDir,
|
|
564
|
+
});
|
|
565
|
+
// First call
|
|
566
|
+
await service.getSessionMetadata("/Users/ed/Code/myproject", "session-abc");
|
|
567
|
+
// Second call
|
|
568
|
+
await service.getSessionMetadata("/Users/ed/Code/myproject", "session-abc");
|
|
569
|
+
// Should only be called once due to caching
|
|
570
|
+
expect(mockExtractSessionMetadata).toHaveBeenCalledTimes(1);
|
|
571
|
+
});
|
|
572
|
+
});
|
|
573
|
+
describe("getSessionUsage", () => {
|
|
574
|
+
it("delegates to extractSessionUsage", async () => {
|
|
575
|
+
const mockUsage = {
|
|
576
|
+
inputTokens: 1000,
|
|
577
|
+
turnCount: 5,
|
|
578
|
+
hasData: true,
|
|
579
|
+
};
|
|
580
|
+
mockExtractSessionUsage.mockResolvedValue(mockUsage);
|
|
581
|
+
const service = new SessionDiscoveryService({
|
|
582
|
+
claudeHomePath: tempClaudeHome,
|
|
583
|
+
stateDir: tempStateDir,
|
|
584
|
+
});
|
|
585
|
+
const result = await service.getSessionUsage("/Users/ed/Code/myproject", "session-abc");
|
|
586
|
+
expect(result).toEqual(mockUsage);
|
|
587
|
+
expect(mockExtractSessionUsage).toHaveBeenCalled();
|
|
588
|
+
});
|
|
589
|
+
});
|
|
590
|
+
// ===========================================================================
|
|
591
|
+
// Cache invalidation
|
|
592
|
+
// ===========================================================================
|
|
593
|
+
describe("invalidateCache", () => {
|
|
594
|
+
it("invalidateCache() with no args clears all caches", async () => {
|
|
595
|
+
const workingDir = "/Users/ed/Code/myproject";
|
|
596
|
+
const encodedPath = "-Users-ed-Code-myproject";
|
|
597
|
+
const projectDir = join(tempClaudeHome, "projects", encodedPath);
|
|
598
|
+
await mkdir(projectDir, { recursive: true });
|
|
599
|
+
await createSessionFile(projectDir, "session-abc");
|
|
600
|
+
const service = new SessionDiscoveryService({
|
|
601
|
+
claudeHomePath: tempClaudeHome,
|
|
602
|
+
stateDir: tempStateDir,
|
|
603
|
+
cacheTtlMs: 60000, // Long TTL
|
|
604
|
+
});
|
|
605
|
+
// Populate caches
|
|
606
|
+
await service.getAgentSessions("my-agent", workingDir, false);
|
|
607
|
+
// Add a new session
|
|
608
|
+
await createSessionFile(projectDir, "session-def");
|
|
609
|
+
// Verify cache is still returning old data
|
|
610
|
+
const beforeInvalidate = await service.getAgentSessions("my-agent", workingDir, false);
|
|
611
|
+
expect(beforeInvalidate).toHaveLength(1);
|
|
612
|
+
// Invalidate all caches
|
|
613
|
+
service.invalidateCache();
|
|
614
|
+
// Should now see new session
|
|
615
|
+
const afterInvalidate = await service.getAgentSessions("my-agent", workingDir, false);
|
|
616
|
+
expect(afterInvalidate).toHaveLength(2);
|
|
617
|
+
});
|
|
618
|
+
it("invalidateCache(workingDirectory) clears only that directory's cache", async () => {
|
|
619
|
+
const workingDir1 = "/Users/ed/Code/project1";
|
|
620
|
+
const workingDir2 = "/Users/ed/Code/project2";
|
|
621
|
+
const encodedPath1 = "-Users-ed-Code-project1";
|
|
622
|
+
const encodedPath2 = "-Users-ed-Code-project2";
|
|
623
|
+
const projectDir1 = join(tempClaudeHome, "projects", encodedPath1);
|
|
624
|
+
const projectDir2 = join(tempClaudeHome, "projects", encodedPath2);
|
|
625
|
+
await mkdir(projectDir1, { recursive: true });
|
|
626
|
+
await mkdir(projectDir2, { recursive: true });
|
|
627
|
+
await createSessionFile(projectDir1, "session-a");
|
|
628
|
+
await createSessionFile(projectDir2, "session-b");
|
|
629
|
+
// Map sessions to their owning agents so the attribution filter passes
|
|
630
|
+
const sessionAgentMap = {
|
|
631
|
+
"session-a": "agent-1",
|
|
632
|
+
"session-a2": "agent-1",
|
|
633
|
+
"session-b": "agent-2",
|
|
634
|
+
"session-b2": "agent-2",
|
|
635
|
+
};
|
|
636
|
+
mockBuildAttributionIndex.mockResolvedValue(createMockAttributionIndex({
|
|
637
|
+
getAttribute: (sessionId) => ({
|
|
638
|
+
origin: "native",
|
|
639
|
+
agentName: sessionAgentMap[sessionId],
|
|
640
|
+
triggerType: undefined,
|
|
641
|
+
}),
|
|
642
|
+
}));
|
|
643
|
+
const service = new SessionDiscoveryService({
|
|
644
|
+
claudeHomePath: tempClaudeHome,
|
|
645
|
+
stateDir: tempStateDir,
|
|
646
|
+
cacheTtlMs: 60000,
|
|
647
|
+
});
|
|
648
|
+
// Populate caches for both directories
|
|
649
|
+
await service.getAgentSessions("agent-1", workingDir1, false);
|
|
650
|
+
await service.getAgentSessions("agent-2", workingDir2, false);
|
|
651
|
+
// Add new sessions to both
|
|
652
|
+
await createSessionFile(projectDir1, "session-a2");
|
|
653
|
+
await createSessionFile(projectDir2, "session-b2");
|
|
654
|
+
// Invalidate only project1's cache
|
|
655
|
+
service.invalidateCache(workingDir1);
|
|
656
|
+
// Project1 should see new session
|
|
657
|
+
const sessions1 = await service.getAgentSessions("agent-1", workingDir1, false);
|
|
658
|
+
expect(sessions1).toHaveLength(2);
|
|
659
|
+
// Project2 should still return cached (1 session)
|
|
660
|
+
const sessions2 = await service.getAgentSessions("agent-2", workingDir2, false);
|
|
661
|
+
expect(sessions2).toHaveLength(1);
|
|
662
|
+
});
|
|
663
|
+
});
|
|
664
|
+
// ===========================================================================
|
|
665
|
+
// Edge cases
|
|
666
|
+
// ===========================================================================
|
|
667
|
+
describe("edge cases", () => {
|
|
668
|
+
it("handles Windows-style encoded paths", async () => {
|
|
669
|
+
// Windows path: C:\Users\ed\Code\myproject encodes to C:-Users-ed-Code-myproject
|
|
670
|
+
const projectDir = join(tempClaudeHome, "projects", "C:-Users-ed-Code-myproject");
|
|
671
|
+
await mkdir(projectDir, { recursive: true });
|
|
672
|
+
await createSessionFile(projectDir, "session-a");
|
|
673
|
+
const service = new SessionDiscoveryService({
|
|
674
|
+
claudeHomePath: tempClaudeHome,
|
|
675
|
+
stateDir: tempStateDir,
|
|
676
|
+
});
|
|
677
|
+
const groups = await service.getAllSessions([]);
|
|
678
|
+
expect(groups).toHaveLength(1);
|
|
679
|
+
expect(groups[0].workingDirectory).toBe("C:/Users/ed/Code/myproject");
|
|
680
|
+
});
|
|
681
|
+
it("handles file being deleted between readdir and stat", async () => {
|
|
682
|
+
const workingDir = "/Users/ed/Code/myproject";
|
|
683
|
+
const encodedPath = "-Users-ed-Code-myproject";
|
|
684
|
+
const projectDir = join(tempClaudeHome, "projects", encodedPath);
|
|
685
|
+
await mkdir(projectDir, { recursive: true });
|
|
686
|
+
// Create two sessions
|
|
687
|
+
await createSessionFile(projectDir, "session-a");
|
|
688
|
+
await createSessionFile(projectDir, "session-b");
|
|
689
|
+
const service = new SessionDiscoveryService({
|
|
690
|
+
claudeHomePath: tempClaudeHome,
|
|
691
|
+
stateDir: tempStateDir,
|
|
692
|
+
});
|
|
693
|
+
// This should handle the race condition gracefully
|
|
694
|
+
const sessions = await service.getAgentSessions("my-agent", workingDir, false);
|
|
695
|
+
expect(sessions.length).toBeGreaterThanOrEqual(0);
|
|
696
|
+
});
|
|
697
|
+
it("uses attribution index cache within TTL", async () => {
|
|
698
|
+
// Clear the mock call count before this test
|
|
699
|
+
mockBuildAttributionIndex.mockClear();
|
|
700
|
+
const workingDir = "/Users/ed/Code/myproject";
|
|
701
|
+
const encodedPath = "-Users-ed-Code-myproject";
|
|
702
|
+
const projectDir = join(tempClaudeHome, "projects", encodedPath);
|
|
703
|
+
await mkdir(projectDir, { recursive: true });
|
|
704
|
+
await createSessionFile(projectDir, "session-a");
|
|
705
|
+
mockBuildAttributionIndex.mockResolvedValue(createMockAttributionIndex({ defaultAgentName: "agent" }));
|
|
706
|
+
const service = new SessionDiscoveryService({
|
|
707
|
+
claudeHomePath: tempClaudeHome,
|
|
708
|
+
stateDir: tempStateDir,
|
|
709
|
+
cacheTtlMs: 5000,
|
|
710
|
+
});
|
|
711
|
+
// First call
|
|
712
|
+
await service.getAgentSessions("agent", workingDir, false);
|
|
713
|
+
// Second call
|
|
714
|
+
await service.getAgentSessions("agent", workingDir, false);
|
|
715
|
+
// Attribution index should only be built once
|
|
716
|
+
expect(mockBuildAttributionIndex).toHaveBeenCalledTimes(1);
|
|
717
|
+
});
|
|
718
|
+
it("refreshes attribution index after TTL expires", async () => {
|
|
719
|
+
// Clear the mock call count before this test
|
|
720
|
+
mockBuildAttributionIndex.mockClear();
|
|
721
|
+
const workingDir = "/Users/ed/Code/myproject";
|
|
722
|
+
const encodedPath = "-Users-ed-Code-myproject";
|
|
723
|
+
const projectDir = join(tempClaudeHome, "projects", encodedPath);
|
|
724
|
+
await mkdir(projectDir, { recursive: true });
|
|
725
|
+
await createSessionFile(projectDir, "session-a");
|
|
726
|
+
mockBuildAttributionIndex.mockResolvedValue(createMockAttributionIndex({ defaultAgentName: "agent" }));
|
|
727
|
+
const service = new SessionDiscoveryService({
|
|
728
|
+
claudeHomePath: tempClaudeHome,
|
|
729
|
+
stateDir: tempStateDir,
|
|
730
|
+
cacheTtlMs: 50, // Very short TTL
|
|
731
|
+
});
|
|
732
|
+
// First call
|
|
733
|
+
await service.getAgentSessions("agent", workingDir, false);
|
|
734
|
+
// Wait for TTL to expire
|
|
735
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
736
|
+
// Invalidate directory cache so it forces a re-read
|
|
737
|
+
service.invalidateCache(workingDir);
|
|
738
|
+
// Second call after TTL
|
|
739
|
+
await service.getAgentSessions("agent", workingDir, false);
|
|
740
|
+
// Attribution index should be rebuilt
|
|
741
|
+
expect(mockBuildAttributionIndex).toHaveBeenCalledTimes(2);
|
|
742
|
+
});
|
|
743
|
+
it("uses 'adhoc' key for metadata lookups on unattributed directories", async () => {
|
|
744
|
+
const projectDir = join(tempClaudeHome, "projects", "-Users-ed-Code-unmatched");
|
|
745
|
+
await mkdir(projectDir, { recursive: true });
|
|
746
|
+
await createSessionFile(projectDir, "session-a");
|
|
747
|
+
const service = new SessionDiscoveryService({
|
|
748
|
+
claudeHomePath: tempClaudeHome,
|
|
749
|
+
stateDir: tempStateDir,
|
|
750
|
+
});
|
|
751
|
+
// No matching agent
|
|
752
|
+
const groups = await service.getAllSessions([]);
|
|
753
|
+
// Custom name should still be looked up using "adhoc" key
|
|
754
|
+
expect(groups[0].sessions[0].customName).toBeUndefined();
|
|
755
|
+
// The metadata store should be called with "adhoc" key for unattributed sessions
|
|
756
|
+
expect(mockGetCustomName).toHaveBeenCalledWith("adhoc", "session-a");
|
|
757
|
+
});
|
|
758
|
+
it("gets custom name for directories with matching agent", async () => {
|
|
759
|
+
const projectDir = join(tempClaudeHome, "projects", "-Users-ed-Code-matched");
|
|
760
|
+
await mkdir(projectDir, { recursive: true });
|
|
761
|
+
await createSessionFile(projectDir, "session-a");
|
|
762
|
+
mockGetCustomName.mockResolvedValue("Custom Name");
|
|
763
|
+
const service = new SessionDiscoveryService({
|
|
764
|
+
claudeHomePath: tempClaudeHome,
|
|
765
|
+
stateDir: tempStateDir,
|
|
766
|
+
});
|
|
767
|
+
const groups = await service.getAllSessions([
|
|
768
|
+
{
|
|
769
|
+
name: "my-agent",
|
|
770
|
+
workingDirectory: "/Users/ed/Code/matched",
|
|
771
|
+
dockerEnabled: false,
|
|
772
|
+
},
|
|
773
|
+
]);
|
|
774
|
+
expect(groups[0].sessions[0].customName).toBe("Custom Name");
|
|
775
|
+
expect(mockGetCustomName).toHaveBeenCalledWith("my-agent", "session-a");
|
|
776
|
+
});
|
|
777
|
+
it("returns correct sessionCount in directory groups", async () => {
|
|
778
|
+
const projectDir = join(tempClaudeHome, "projects", "-Users-ed-Code-multi");
|
|
779
|
+
await mkdir(projectDir, { recursive: true });
|
|
780
|
+
await createSessionFile(projectDir, "session-a");
|
|
781
|
+
await createSessionFile(projectDir, "session-b");
|
|
782
|
+
await createSessionFile(projectDir, "session-c");
|
|
783
|
+
const service = new SessionDiscoveryService({
|
|
784
|
+
claudeHomePath: tempClaudeHome,
|
|
785
|
+
stateDir: tempStateDir,
|
|
786
|
+
});
|
|
787
|
+
const groups = await service.getAllSessions([]);
|
|
788
|
+
expect(groups[0].sessionCount).toBe(3);
|
|
789
|
+
expect(groups[0].sessions).toHaveLength(3);
|
|
790
|
+
});
|
|
791
|
+
});
|
|
792
|
+
// ===========================================================================
|
|
793
|
+
// autoName caching
|
|
794
|
+
// ===========================================================================
|
|
795
|
+
describe("autoName caching", () => {
|
|
796
|
+
it("includes autoName field in discovered sessions from getAgentSessions", async () => {
|
|
797
|
+
const workingDir = "/Users/ed/Code/myproject";
|
|
798
|
+
const encodedPath = "-Users-ed-Code-myproject";
|
|
799
|
+
const projectDir = join(tempClaudeHome, "projects", encodedPath);
|
|
800
|
+
await mkdir(projectDir, { recursive: true });
|
|
801
|
+
await createSessionFile(projectDir, "session-abc");
|
|
802
|
+
// Mock cache miss then extraction returns a summary
|
|
803
|
+
mockGetAutoName.mockResolvedValue(undefined);
|
|
804
|
+
mockExtractLastSummary.mockResolvedValue("Auto-generated session name");
|
|
805
|
+
const service = new SessionDiscoveryService({
|
|
806
|
+
claudeHomePath: tempClaudeHome,
|
|
807
|
+
stateDir: tempStateDir,
|
|
808
|
+
});
|
|
809
|
+
const sessions = await service.getAgentSessions("my-agent", workingDir, false);
|
|
810
|
+
expect(sessions[0].autoName).toBe("Auto-generated session name");
|
|
811
|
+
});
|
|
812
|
+
it("uses cached autoName when cache is valid", async () => {
|
|
813
|
+
const workingDir = "/Users/ed/Code/myproject";
|
|
814
|
+
const encodedPath = "-Users-ed-Code-myproject";
|
|
815
|
+
const projectDir = join(tempClaudeHome, "projects", encodedPath);
|
|
816
|
+
await mkdir(projectDir, { recursive: true });
|
|
817
|
+
await createSessionFile(projectDir, "session-abc");
|
|
818
|
+
// Mock cache hit - return cached value with mtime in the future to ensure validity
|
|
819
|
+
mockGetAutoName.mockResolvedValue({
|
|
820
|
+
autoName: "Cached Auto Name",
|
|
821
|
+
autoNameMtime: "2099-01-01T00:00:00.000Z",
|
|
822
|
+
});
|
|
823
|
+
const service = new SessionDiscoveryService({
|
|
824
|
+
claudeHomePath: tempClaudeHome,
|
|
825
|
+
stateDir: tempStateDir,
|
|
826
|
+
});
|
|
827
|
+
const sessions = await service.getAgentSessions("my-agent", workingDir, false);
|
|
828
|
+
expect(sessions[0].autoName).toBe("Cached Auto Name");
|
|
829
|
+
// Should not have called extractLastSummary since cache was valid
|
|
830
|
+
expect(mockExtractLastSummary).not.toHaveBeenCalled();
|
|
831
|
+
});
|
|
832
|
+
it("re-extracts autoName when cache is stale", async () => {
|
|
833
|
+
const workingDir = "/Users/ed/Code/myproject";
|
|
834
|
+
const encodedPath = "-Users-ed-Code-myproject";
|
|
835
|
+
const projectDir = join(tempClaudeHome, "projects", encodedPath);
|
|
836
|
+
await mkdir(projectDir, { recursive: true });
|
|
837
|
+
await createSessionFile(projectDir, "session-abc");
|
|
838
|
+
// Mock cache miss (old mtime)
|
|
839
|
+
mockGetAutoName.mockResolvedValue({
|
|
840
|
+
autoName: "Old Cached Name",
|
|
841
|
+
autoNameMtime: "1990-01-01T00:00:00.000Z",
|
|
842
|
+
});
|
|
843
|
+
mockExtractLastSummary.mockResolvedValue("Fresh Extracted Name");
|
|
844
|
+
const service = new SessionDiscoveryService({
|
|
845
|
+
claudeHomePath: tempClaudeHome,
|
|
846
|
+
stateDir: tempStateDir,
|
|
847
|
+
});
|
|
848
|
+
const sessions = await service.getAgentSessions("my-agent", workingDir, false);
|
|
849
|
+
expect(sessions[0].autoName).toBe("Fresh Extracted Name");
|
|
850
|
+
expect(mockExtractLastSummary).toHaveBeenCalled();
|
|
851
|
+
});
|
|
852
|
+
it("batch writes autoName updates for getAgentSessions", async () => {
|
|
853
|
+
const workingDir = "/Users/ed/Code/myproject";
|
|
854
|
+
const encodedPath = "-Users-ed-Code-myproject";
|
|
855
|
+
const projectDir = join(tempClaudeHome, "projects", encodedPath);
|
|
856
|
+
await mkdir(projectDir, { recursive: true });
|
|
857
|
+
await createSessionFile(projectDir, "session-1");
|
|
858
|
+
await createSessionFile(projectDir, "session-2");
|
|
859
|
+
// Mock cache miss for both — use implementation that returns based on path
|
|
860
|
+
mockGetAutoName.mockResolvedValue(undefined);
|
|
861
|
+
mockExtractLastSummary.mockImplementation(async (filePath) => {
|
|
862
|
+
if (filePath.includes("session-1"))
|
|
863
|
+
return "Session 1 Name";
|
|
864
|
+
if (filePath.includes("session-2"))
|
|
865
|
+
return "Session 2 Name";
|
|
866
|
+
return undefined;
|
|
867
|
+
});
|
|
868
|
+
const service = new SessionDiscoveryService({
|
|
869
|
+
claudeHomePath: tempClaudeHome,
|
|
870
|
+
stateDir: tempStateDir,
|
|
871
|
+
});
|
|
872
|
+
await service.getAgentSessions("my-agent", workingDir, false);
|
|
873
|
+
// Should have called batchSetAutoNames once with both sessions
|
|
874
|
+
expect(mockBatchSetAutoNames).toHaveBeenCalledTimes(1);
|
|
875
|
+
expect(mockBatchSetAutoNames).toHaveBeenCalledWith("my-agent", expect.arrayContaining([
|
|
876
|
+
expect.objectContaining({ sessionId: "session-1", autoName: "Session 1 Name" }),
|
|
877
|
+
expect.objectContaining({ sessionId: "session-2", autoName: "Session 2 Name" }),
|
|
878
|
+
]));
|
|
879
|
+
});
|
|
880
|
+
it("does not batch write when all autoNames are from cache", async () => {
|
|
881
|
+
const workingDir = "/Users/ed/Code/myproject";
|
|
882
|
+
const encodedPath = "-Users-ed-Code-myproject";
|
|
883
|
+
const projectDir = join(tempClaudeHome, "projects", encodedPath);
|
|
884
|
+
await mkdir(projectDir, { recursive: true });
|
|
885
|
+
await createSessionFile(projectDir, "session-abc");
|
|
886
|
+
// Mock cache hit
|
|
887
|
+
mockGetAutoName.mockResolvedValue({
|
|
888
|
+
autoName: "Cached Name",
|
|
889
|
+
autoNameMtime: "2099-01-01T00:00:00.000Z",
|
|
890
|
+
});
|
|
891
|
+
const service = new SessionDiscoveryService({
|
|
892
|
+
claudeHomePath: tempClaudeHome,
|
|
893
|
+
stateDir: tempStateDir,
|
|
894
|
+
});
|
|
895
|
+
await service.getAgentSessions("my-agent", workingDir, false);
|
|
896
|
+
// Should not have called batchSetAutoNames
|
|
897
|
+
expect(mockBatchSetAutoNames).not.toHaveBeenCalled();
|
|
898
|
+
});
|
|
899
|
+
it("uses 'adhoc' key for autoName caching on unattributed sessions in getAllSessions", async () => {
|
|
900
|
+
const projectDir = join(tempClaudeHome, "projects", "-Users-ed-Code-unattributed");
|
|
901
|
+
await mkdir(projectDir, { recursive: true });
|
|
902
|
+
await createSessionFile(projectDir, "session-abc");
|
|
903
|
+
// Mock cache miss
|
|
904
|
+
mockGetAutoName.mockResolvedValue(undefined);
|
|
905
|
+
mockExtractLastSummary.mockResolvedValue("Unattributed Session Name");
|
|
906
|
+
const service = new SessionDiscoveryService({
|
|
907
|
+
claudeHomePath: tempClaudeHome,
|
|
908
|
+
stateDir: tempStateDir,
|
|
909
|
+
});
|
|
910
|
+
const groups = await service.getAllSessions([]);
|
|
911
|
+
expect(groups[0].sessions[0].autoName).toBe("Unattributed Session Name");
|
|
912
|
+
// Should use "adhoc" key for unattributed sessions
|
|
913
|
+
expect(mockGetAutoName).toHaveBeenCalledWith("adhoc", "session-abc");
|
|
914
|
+
expect(mockBatchSetAutoNames).toHaveBeenCalledWith("adhoc", expect.arrayContaining([
|
|
915
|
+
expect.objectContaining({
|
|
916
|
+
sessionId: "session-abc",
|
|
917
|
+
autoName: "Unattributed Session Name",
|
|
918
|
+
}),
|
|
919
|
+
]));
|
|
920
|
+
});
|
|
921
|
+
it("returns undefined autoName when session has no summary", async () => {
|
|
922
|
+
const workingDir = "/Users/ed/Code/myproject";
|
|
923
|
+
const encodedPath = "-Users-ed-Code-myproject";
|
|
924
|
+
const projectDir = join(tempClaudeHome, "projects", encodedPath);
|
|
925
|
+
await mkdir(projectDir, { recursive: true });
|
|
926
|
+
await createSessionFile(projectDir, "session-abc");
|
|
927
|
+
// Mock cache miss and no summary
|
|
928
|
+
mockGetAutoName.mockResolvedValue(undefined);
|
|
929
|
+
mockExtractLastSummary.mockResolvedValue(undefined);
|
|
930
|
+
const service = new SessionDiscoveryService({
|
|
931
|
+
claudeHomePath: tempClaudeHome,
|
|
932
|
+
stateDir: tempStateDir,
|
|
933
|
+
});
|
|
934
|
+
const sessions = await service.getAgentSessions("my-agent", workingDir, false);
|
|
935
|
+
expect(sessions[0].autoName).toBeUndefined();
|
|
936
|
+
// Should not batch write when there's nothing to write
|
|
937
|
+
expect(mockBatchSetAutoNames).not.toHaveBeenCalled();
|
|
938
|
+
});
|
|
939
|
+
});
|
|
940
|
+
});
|
|
941
|
+
//# sourceMappingURL=session-discovery.test.js.map
|