Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,21 @@ export {
COMPILATION_API_EXIT_CODES,
} from './types';

export { metric, findAuthoringBundle, readTranscriptEntries } from './utils';
export {
metric,
findAuthoringBundle,
readTranscriptEntries,
createPreviewSessionCache,
validatePreviewSession,
removePreviewSessionCache,
getCachedPreviewSessionIds,
getCurrentPreviewSessionId,
listCachedPreviewSessions,
type SessionType,
type PreviewSessionMeta,
type CachedPreviewSessionInfo,
type CachedPreviewSessionEntry,
} from './utils';
export { Agent, AgentCreateLifecycleStages, type AgentInstance } from './agent';
export { AgentTester } from './agentTester';
export { AgentTest, AgentTestCreateLifecycleStages } from './agentTest';
Expand Down
252 changes: 251 additions & 1 deletion src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
* limitations under the License.
*/
import { existsSync, readdirSync, statSync } from 'node:fs';
import { mkdir, appendFile, readFile, writeFile, readdir, stat } from 'node:fs/promises';
import { mkdir, appendFile, readFile, rename, writeFile, readdir, stat, unlink } from 'node:fs/promises';
import * as path from 'node:path';
import { Connection, Logger, SfError, SfProject } from '@salesforce/core';
import { NamedUserJwtResponse, type PlannerResponse, PreviewMetadata } from './types';
Expand Down Expand Up @@ -888,3 +888,253 @@ export const readTranscriptEntries = async (agentId: string, sessionId: string):
return [];
}
};

// ====================================================
// Preview Session Store
// ====================================================

const SESSION_META_FILE = 'session-meta.json';
const SESSION_INDEX_FILE = 'index.json';

export type SessionType = 'simulated' | 'live' | 'published';
export type PreviewSessionMeta = { displayName?: string; timestamp?: string; sessionType?: SessionType };
type PreviewSessionIndex = Array<{
sessionId: string;
displayName?: string;
timestamp?: string;
sessionType?: SessionType;
}>;

async function readPreviewSessionIndex(indexPath: string): Promise<PreviewSessionIndex> {
try {
const raw = await readFile(indexPath, 'utf-8');
return JSON.parse(raw) as PreviewSessionIndex;
} catch {
return [];
}
}

/**
* Atomically read-modify-write the preview sessions index.
* Writes to a temp file then renames to avoid partial writes and reduce
* the window for concurrent-write races (last writer wins, no silent drops).
* Propagates errors so callers are aware of index failures.
*/
async function updatePreviewSessionIndex(
indexPath: string,
updater: (index: PreviewSessionIndex) => PreviewSessionIndex
): Promise<void> {
const index = await readPreviewSessionIndex(indexPath);
const updated = updater(index);
const tmpPath = `${indexPath}.tmp`;
await writeFile(tmpPath, JSON.stringify(updated, null, 2), 'utf-8');
await rename(tmpPath, indexPath);
}

/**
* Save a marker so send/end can validate that the session was started for this agent.
* Caller must have started the session (agent has sessionId set). Uses agent.getHistoryDir() for the path.
* Pass displayName (authoring bundle name or production agent API name) so "agent preview sessions" can show it.
*/
export async function createPreviewSessionCache(
agent: { getHistoryDir: () => Promise<string> },
options?: { displayName?: string; sessionType?: SessionType }
): Promise<void> {
const historyDir = await agent.getHistoryDir();
const metaPath = path.join(historyDir, SESSION_META_FILE);
const meta: PreviewSessionMeta = {
displayName: options?.displayName,
timestamp: new Date().toISOString(),
sessionType: options?.sessionType,
};
await writeFile(metaPath, JSON.stringify(meta), 'utf-8');

// Update the sessions index for ordered browsing
const sessionId = path.basename(historyDir);
const sessionsDir = path.dirname(historyDir);
const indexPath = path.join(sessionsDir, SESSION_INDEX_FILE);
await updatePreviewSessionIndex(indexPath, (index) => {
if (!index.some((e) => e.sessionId === sessionId)) {
index.push({
sessionId,
displayName: meta.displayName,
timestamp: meta.timestamp,
sessionType: meta.sessionType,
});
}
return index;
});
}

/**
* Validate that the session was started for this agent (marker file exists in agent's history dir for current sessionId).
* Caller must set sessionId on the agent (agent.setSessionId) before calling.
* Throws SfError if the session marker is not found.
*/
export async function validatePreviewSession(agent: { getHistoryDir: () => Promise<string> }): Promise<void> {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do we need to update something on the Agents class so that we can just pass one in here, instead of just an object with getHistoryDir method defined?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

agentBase.ts already imports from utils.ts (getHistoryDir, SessionHistoryBuffer, TranscriptEntry), so importing AgentBase back into utils.ts would create a circular dependency. The structural type ({ getHistoryDir: () => Promise<string> }) avoids the cycle and still accepts any AgentBase subclass at the call sites. Happy to restructure if you have a preferred way to break the cycle (e.g. a separate types.ts for the interface), but wanted to flag the constraint.

const historyDir = await agent.getHistoryDir();
const metaPath = path.join(historyDir, SESSION_META_FILE);
try {
await readFile(metaPath, 'utf-8');
} catch (error) {
throw SfError.create({
message: 'No preview session found for this session ID. Run "sf agent preview start" first.',
name: 'PreviewSessionNotFound',
cause: error,
});
}
}

/**
* Remove the session marker so this session is no longer considered "active" for send/end without --session-id.
* Call after ending the session. Caller must set sessionId on the agent before calling.
*/
export async function removePreviewSessionCache(agent: { getHistoryDir: () => Promise<string> }): Promise<void> {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it seems like a growing issue that we're putting these file manipulator methods in a utils file... we probably need to figure out a better, and more accessible way to manage all of the different files... if you want to try, that'd be great, but we can also create a new WI to consolidate

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed — utils.ts is getting unwieldy. I'll create a follow-up WI to consolidate the file I/O helpers into a dedicated module so this doesn't keep growing.

const historyDir = await agent.getHistoryDir();
const metaPath = path.join(historyDir, SESSION_META_FILE);
try {
await unlink(metaPath);
} catch {
// already removed or never created
}

// Remove entry from the sessions index
const sessionId = path.basename(historyDir);
const sessionsDir = path.dirname(historyDir);
const indexPath = path.join(sessionsDir, SESSION_INDEX_FILE);
await updatePreviewSessionIndex(indexPath, (index) => index.filter((e) => e.sessionId !== sessionId));
}

/**
* List session IDs that have a cache marker (started via "agent preview start") for this agent.
* Uses project path and agent's storage ID to find .sfdx/agents/<agentId>/sessions/<sessionId>/session-meta.json.
*/
export async function getCachedPreviewSessionIds(
project: SfProject,
agent: { getAgentIdForStorage: () => string | Promise<string> }
): Promise<string[]> {
const agentId = await agent.getAgentIdForStorage();
const base = path.join(project.getPath(), '.sfdx');
const sessionsDir = path.join(base, 'agents', agentId, 'sessions');
const sessionIds: string[] = [];
try {
const entries = await readdir(sessionsDir, { withFileTypes: true });
const dirs = entries.filter((e) => e.isDirectory()).map((e) => e.name);
const hasMarker = await Promise.all(
dirs.map(async (name) => {
try {
await readFile(path.join(sessionsDir, name, SESSION_META_FILE), 'utf-8');
return true;
} catch {
return false;
}
})
);
dirs.forEach((name, i) => {
if (hasMarker[i]) sessionIds.push(name);
});
} catch {
// sessions dir missing or unreadable
}
return sessionIds;
}

/**
* Return the single "current" session ID when safe: exactly one cached session for this agent.
* Returns undefined when there are zero or multiple sessions (caller should require --session-id).
*/
export async function getCurrentPreviewSessionId(
project: SfProject,
agent: { getAgentIdForStorage: () => string | Promise<string> }
): Promise<string | undefined> {
const ids = await getCachedPreviewSessionIds(project, agent);
return ids.length === 1 ? ids[0] : undefined;
}

export type CachedPreviewSessionInfo = { sessionId: string; timestamp?: string; sessionType?: SessionType };
export type CachedPreviewSessionEntry = {
agentId: string;
displayName?: string;
sessions: CachedPreviewSessionInfo[];
};

/**
* List all cached preview sessions in the project, grouped by agent ID.
* displayName (when present in session-meta.json) is the authoring bundle name or production agent API name for display.
* Use this to show users which sessions exist so they can end or clean up.
*/
export async function listCachedPreviewSessions(project: SfProject): Promise<CachedPreviewSessionEntry[]> {
const base = path.join(project.getPath(), '.sfdx', 'agents');
const result: CachedPreviewSessionEntry[] = [];
try {
const agentDirs = await readdir(base, { withFileTypes: true });
const entries = await Promise.all(
agentDirs
.filter((ent) => ent.isDirectory())
.map(async (ent) => {
const agentId = ent.name;
const sessionsDir = path.join(base, agentId, 'sessions');
let sessions: CachedPreviewSessionInfo[] = [];
let displayName: string | undefined;
try {
// Prefer the index for ordered, metadata-rich results
const index = await readPreviewSessionIndex(path.join(sessionsDir, SESSION_INDEX_FILE));
if (index.length > 0) {
// Verify each indexed session still has its marker file (guard against manual cleanup)
const verified = await Promise.all(
index.map(async (entry) => {
try {
await readFile(path.join(sessionsDir, entry.sessionId, SESSION_META_FILE), 'utf-8');
return entry;
} catch {
return null;
}
})
);
sessions = verified
.filter((e): e is PreviewSessionIndex[number] => e !== null)
.map(({ sessionId, timestamp, sessionType }) => ({ sessionId, timestamp, sessionType }));
displayName = index.find((e) => e.displayName !== undefined)?.displayName;
} else {
// Fallback: scan directories (no index yet, e.g. sessions started before this feature)
const sessionDirs = await readdir(sessionsDir, { withFileTypes: true });
const sessionInfos = await Promise.all(
sessionDirs
.filter((s) => s.isDirectory())
.map(async (s): Promise<(CachedPreviewSessionInfo & { displayName?: string }) | null> => {
try {
const raw = await readFile(path.join(sessionsDir, s.name, SESSION_META_FILE), 'utf-8');
const meta = JSON.parse(raw) as PreviewSessionMeta;
return {
sessionId: s.name,
timestamp: meta.timestamp,
sessionType: meta.sessionType,
displayName: meta.displayName,
};
} catch {
return null;
}
})
);
const validSessions = sessionInfos.filter(
(s): s is CachedPreviewSessionInfo & { displayName?: string } => s !== null
);
sessions = validSessions.map(({ sessionId, timestamp, sessionType }) => ({
sessionId,
timestamp,
sessionType,
}));
displayName = validSessions[0]?.displayName;
}
} catch {
// no sessions dir or unreadable
}
return { agentId, displayName, sessions };
})
);
result.push(...entries.filter((e) => e.sessions.length > 0));
} catch {
// no agents dir or unreadable
}
return result;
}
Loading
Loading