import { FSWatcher } from 'fs'; import { Team, TeamMember, Project, ScheduledCheck, ScheduledMessage, MessageDeliveryLog } from '../../types/index.js'; import { type AgentStatus, type WorkingStatus, type RuntimeType } from '../../constants.js'; /** * Storage-level events emitted after a team is persisted or removed. * Subscribers (e.g. {@link TeamTriggerReconciler}) use these to converge * side systems without pulling StorageService into a cross-cutting tangle. */ export type StorageEvent = { kind: 'team-saved'; team: Team; /** * True when this save created the team's config file (first save); * false on updates. Lets subscribers react to "a new team exists" * without treating every status write as a creation. */ created: boolean; } | { kind: 'team-deleted'; teamId: string; } /** * The team was moved to another machine (crewly#1189). Local schedulers must * let go of it like a deleted team, but Slack and Cloud must NOT treat it as * a delete: its channel stays and its agent apps now belong to the target. */ | { kind: 'team-moved-away'; teamId: string; }; export type StorageListener = (event: StorageEvent) => Promise | void; /** Options of {@link StorageService.saveProject}. */ export interface SaveProjectOptions { /** `'replace'`: this write owns `ticketAutopilot` and may change it; otherwise the stored settings are kept */ ticketAutopilot?: 'replace'; /** Who writes, for the log (default: the calling function, from the stack) */ caller?: string; } export declare class StorageService { private static instance; private static instanceHome; private crewlyHome; /** @deprecated Use teamsDir instead - kept for migration */ private teamsFile; /** Directory containing individual team files */ private teamsDir; /** Orchestrator status file */ private orchestratorFile; private projectsFile; private runtimeFile; private scheduledMessagesFile; private deliveryLogsFile; private recurringChecksFile; private oneTimeChecksFile; private logger; /** Flag to track if migration has been performed */ private migrationDone; /** Debounce timer for teams backup to avoid I/O storms */ private backupDebounceTimer; private readonly BACKUP_DEBOUNCE_MS; /** * Listeners registered via {@link onStorageEvent}. Empty by default so * existing call-sites pay zero cost; listeners are fired-and-forgotten * to keep save/delete latency unchanged. */ private listeners; constructor(crewlyHome?: string); /** * Get singleton instance of StorageService to prevent multiple instances * from interfering with each other's file operations */ static getInstance(crewlyHome?: string): StorageService; /** * Clear singleton instance (useful for testing) */ static clearInstance(): void; /** * Get the crewly home directory path. * Used by SchedulerService to locate orphaned temp files for cleanup (#217). * * @returns Absolute path to the crewly home directory (e.g. ~/.crewly) */ getCrewlyHome(): string; /** * Boot-time state invariant check (C1 of the 2026-05-03 persistence-fix * spec). Compares the current on-disk team count to the latest backup * snapshot. If the live state is empty but a non-empty backup exists, * the invariant has been violated — the backend is refusing to boot * rather than serving an "empty teams" UI that the user might "fix" * by re-creating teams against the wiped state. * * Operators can override via `CREWLY_FORCE_EMPTY_BOOT=1` env var for * legitimate first-boot or explicit-reset scenarios. * * Best-effort: failures inside the backup-status read path log a * warning and do NOT block boot — the invariant should never become a * boot-time hard dependency itself. * * @throws {StateInvariantViolation} when live teams are empty but the * most-recent backup snapshot has data, and the override env var * is not set. */ verifyStateInvariantOnBoot(): Promise; /** * Ensures the crewly home and teams directories exist, creating them if necessary */ private ensureDirectories; /** * Get the directory path for a team. * * @param teamId - The team ID * @returns Path to the team directory */ getTeamDir(teamId: string): string; /** * Get the prompts directory path for a team. * * @param teamId - The team ID * @returns Path to the team's prompts directory */ getTeamPromptsDir(teamId: string): string; /** * Get the prompt file path for a team member. * * @param teamId - The team ID * @param memberId - The member ID * @returns Path to the member's prompt file */ getMemberPromptPath(teamId: string, memberId: string): string; /** * Get the orchestrator prompt file path. * * @returns Path to the orchestrator's prompt file */ getOrchestratorPromptPath(): string; /** * Migrate from old storage formats to new directory structure. * Handles: teams.json -> teams/{team-id}/config.json * teams/{team-id}.json -> teams/{team-id}/config.json * teams/orchestrator.json -> teams/orchestrator/config.json */ private migrateFromLegacyTeamsFile; /** * Migrate flat team files (teams/{team-id}.json) to directory structure (teams/{team-id}/config.json). */ private migrateFlatTeamFiles; /** * Migrate a team to the new directory structure. * Creates teams/{team-id}/config.json and teams/{team-id}/prompts/{member-id}.md */ private migrateTeamToDirectory; /** * Creates default orchestrator configuration object * @returns Default orchestrator settings with inactive status */ private createDefaultOrchestrator; /** * Ensures a storage file exists and is valid JSON, creating or recovering it if needed * @param filePath - Path to the storage file * @param defaultContent - Default content to use if file needs to be created/recovered */ private ensureFile; /** * Get all teams from team directories. * Each team is stored as teams/{teamId}/config.json for isolation. * * @returns Array of all teams */ /** * Give every existing member its permanent agent id (one-time migration, * idempotent): the session it runs under now, else the one its current * name derives. Nothing that already points at an agent changes. * * @returns Number of members that got an id */ ensureAgentIds(): Promise; getTeams(): Promise; /** * Save a team to its directory structure. * Each team is stored as teams/{teamId}/config.json for isolation and resilience. * Member prompts are saved to teams/{teamId}/prompts/{memberId}.md * * @param team - The team to save */ saveTeam(team: Team): Promise; /** * Save a member's prompt file. * * @param teamId - The team ID * @param memberId - The member ID * @param prompt - The prompt content */ saveMemberPrompt(teamId: string, memberId: string, prompt: string): Promise; /** * Get a member's prompt from file. * * @param teamId - The team ID * @param memberId - The member ID * @returns The prompt content or null if not found */ getMemberPrompt(teamId: string, memberId: string): Promise; /** * Find a team member by their ID across all teams. * * @param memberId - Member UUID * @returns Member object or null if not found */ getMemberById(memberId: string): Promise; /** * Find a team member by their session name. * * A stopped member has an empty stored `sessionName` (the team controller * clears it on stop), so the name it *would* run under is derived with * the controller's formula as a second pass — otherwise activate-on-send * (Slack DM / @-mention of an idle agent) cannot find the member to start. * * @param sessionName - The session name to search for * @returns Object with team and member, or null if not found */ findMemberBySessionName(sessionName: string): Promise<{ team: Team; member: TeamMember; } | null>; /** * Save the orchestrator's prompt file. * * @param prompt - The prompt content */ saveOrchestratorPrompt(prompt: string): Promise; /** * Get the orchestrator's prompt from file. * * @returns The prompt content or null if not found */ getOrchestratorPrompt(): Promise; /** * Delete a team by removing its directory. * * @param id - The team ID to delete */ deleteTeam(id: string): Promise; /** * Remove a team from this machine because it moved to another one * (crewly#1189). Unlike {@link deleteTeam} this announces `team-moved-away`, * not `team-deleted`: the Slack channel is kept and the team's agent apps are * not removed from Cloud (they now belong to the target machine). * * @param id - The team ID */ detachTeamForMove(id: string): Promise; /** * Register a listener for storage lifecycle events. Returns an unsubscribe * function. Listeners are invoked fire-and-forget; errors are logged and * never propagated to the save/delete caller. * * @param listener - Callback invoked for every saved/deleted team * @returns Unsubscribe function */ onStorageEvent(listener: StorageListener): () => void; /** * Fire all registered listeners without awaiting their completion. Any * error inside a listener is isolated to that listener. */ private emit; getProjects(): Promise; addProject(projectPath: string): Promise; /** * Save one project record. * * Writes are serialised and re-read the file inside the lock, and the * stored `ticketAutopilot` settings survive any caller that does not own * them: a record loaded earlier (before an await, or at boot) must not * write an old budget back over the owner's newer one (crewly#1099). Only * the autopilot passes `ticketAutopilot: 'replace'`. Every change to the * autopilot's daily budget is logged with its caller and old → new value. * * @param project - The record to write * @param opts - `ticketAutopilot: 'replace'` lets this write change the autopilot settings; `caller` names who writes (for the log) */ saveProject(project: Project, opts?: SaveProjectOptions): Promise; deleteProject(id: string): Promise; watchProject(projectPath: string): FSWatcher; getRuntimeState(): Promise; saveRuntimeState(state: any): Promise; /** * Create template files for a new project */ private createProjectTemplateFiles; getScheduledMessages(): Promise; saveScheduledMessage(scheduledMessage: ScheduledMessage): Promise; getScheduledMessage(id: string): Promise; deleteScheduledMessage(id: string): Promise; /** * Get all persisted recurring checks. * * @returns Array of persisted ScheduledCheck entries */ getRecurringChecks(): Promise; /** * Save or update a recurring check entry on disk. * * @param check - The ScheduledCheck to persist */ saveRecurringCheck(check: ScheduledCheck): Promise; /** * Delete a persisted recurring check by ID. * * @param id - The check ID to delete * @returns true if the check was found and deleted */ deleteRecurringCheck(id: string): Promise; /** * Clear all persisted recurring checks. */ clearRecurringChecks(): Promise; /** * Get all persisted one-time checks. * * @returns Array of persisted ScheduledCheck entries */ getOneTimeChecks(): Promise; /** * Save or update a one-time check entry on disk. * * @param check - The ScheduledCheck to persist */ saveOneTimeCheck(check: ScheduledCheck): Promise; /** * Delete a persisted one-time check by ID. * * @param id - The check ID to delete * @returns true if the check was found and deleted */ deleteOneTimeCheck(id: string): Promise; /** * Clear all persisted one-time checks. */ clearOneTimeChecks(): Promise; getDeliveryLogs(): Promise; saveDeliveryLog(log: MessageDeliveryLog): Promise; clearDeliveryLogs(): Promise; /** * Get orchestrator status from dedicated orchestrator.json file. * * @returns Orchestrator status object or null if not found */ getOrchestratorStatus(): Promise<{ sessionName: string; agentStatus: AgentStatus; workingStatus: WorkingStatus; runtimeType: RuntimeType; modelId?: string; reasoningEffort?: string; readyAt?: string; createdAt: string; updatedAt: string; } | null>; /** * Update agent status for orchestrator or any team member. * Orchestrator status is stored in teams/orchestrator.json. * Team member status is stored in the respective team's file. * * @param sessionName - Session name of the agent (CREWLY_CONSTANTS.SESSIONS.ORCHESTRATOR_NAME for orchestrator) * @param status - New agent status (CREWLY_CONSTANTS.AGENT_STATUSES.INACTIVE | CREWLY_CONSTANTS.AGENT_STATUSES.ACTIVATING | CREWLY_CONSTANTS.AGENT_STATUSES.ACTIVE) */ updateAgentStatus(sessionName: string, status: AgentStatus, dropoutReason?: TeamMember['dropoutReason']): Promise; /** * Update team member runtime type */ updateTeamMemberRuntimeType(teamId: string, memberId: string, runtimeType: RuntimeType): Promise; /** * Update orchestrator runtime type in teams/orchestrator.json. * * @param runtimeType - The runtime type to set */ updateOrchestratorRuntimeType(runtimeType: RuntimeType): Promise; /** * Update orchestrator model ID in teams/orchestrator.json. * * The orchestrator's `modelId` controls which AI model the in-process * Crewly Agent runtime uses (e.g. `deepseek/deepseek-chat`, * `google/gemini-3-flash-preview`). Pass `undefined` to clear the field * and fall back to {@link CREWLY_AGENT_DEFAULTS}.DEFAULT_MODEL. * * Other orchestrator fields (runtimeType, agentStatus, workingStatus) are * preserved by reading the existing config first and merging. * * @param modelId - Format: "provider/modelId" for crewly-agent (e.g. "google/gemini-3-flash-preview"), * the harness's own model name for PTY runtimes (e.g. "opus", "gpt-5.6-sol"), or undefined to clear * @param reasoningEffort - Optional effort level (Claude Code / Codex); `''` clears it, omitted leaves it untouched */ updateOrchestratorModelId(modelId: string | undefined, reasoningEffort?: string): Promise; /** * Update the teams backup file asynchronously with debouncing. * * When many teams are saved in rapid succession (e.g. ActivityMonitor * marking 14 stale agents), each saveTeam() call used to trigger a * full getTeams() + backup, flooding the libuv thread pool with I/O * and starving other requests. Debouncing collapses the burst into a * single backup operation. */ private updateTeamsBackup; /** * @deprecated Use updateAgentStatus instead */ updateOrchestratorStatus(status: string): Promise; /** * Record a successful orchestrator registration: flips `agentStatus` to * `active` AND stamps `readyAt`, exactly as regular team members get on * `POST /api/teams/members/register`. Before this, the orchestrator's * `readyAt` stayed `null` forever and misled anything using it as a * readiness signal (server-install finding 6). * * @param readyAt - ISO timestamp to record (defaults to now) */ markOrchestratorRegistered(readyAt?: string): Promise; } //# sourceMappingURL=storage.service.d.ts.map