export interface ProjectInstance { pid: number; registeredAt: string; } export interface ProjectRegistryEntry { projectId: string; projectPath: string; workflowRootPath: string; projectName: string; instances: ProjectInstance[]; } export interface RegisterProjectOptions { workflowRootPath?: string; projectName?: string; } /** * Maximum number of paths kept in the identity-fallback warn-once ledger. * * Same bound and same reason as `ROOT_SELECTION_CACHE_LIMIT` in * `tools/root-selection.ts`: the ledger is keyed by a path that can arrive from * outside — the dashboard's manual-add route calls {@link generateProjectId} * with whatever the user typed — so an unbounded ledger leaks an entry per * distinct bad path for the lifetime of the process. Eviction is FIFO (a `Map` * iterates in insertion order, so the first key is the oldest), which costs at * most a repeated log line for a path that has aged out, never a wrong id. */ export declare const IDENTITY_FALLBACK_LEDGER_LIMIT = 32; /** Test-only: current number of paths in the identity-fallback warn ledger. */ export declare function _identityFallbackLedgerSize(): number; /** Test-only: clears the identity-fallback warn ledger. */ export declare function _resetIdentityFallbackLedger(): void; /** * Generate a stable projectId from a path. * The path is `realpath`-normalized first; the id is a SHA-1 hash of the * normalized string, encoded as base64url and truncated to 16 characters. * * **The normalization is inside this function, deliberately** (requirement * 1.10). Normalizing at the register site only would be worse than not * normalizing at all: the server unregisters by path, so a symlinked workspace * would register under the physical id and unregister under the link id, * stranding its entry forever. Inside, every call site normalizes by * construction — including the dashboard's manual-add path * (`project-manager.ts:263`), which never goes through `registerProject`'s * resolution at all. * * **The cost.** This was a pure hash of its argument; it is now * filesystem-dependent — one `realpathSync` per call, and a value that can * change if the filesystem does. Both are accepted knowingly: * * - The syscall is per *call*, not per registry entry. {@link readRegistry} * does not call this function (it uses the ids already stored as map keys), * so reading a registry of N entries still costs zero `realpath` calls. The * per-request read paths the dashboard exercises pay one syscall each. * - The value can move when a directory is removed, because `realpath` then * fails and the deterministic fallback returns the un-normalized absolute * path — a different string, hence a different id. That is precisely why * requirement 1.13 forbids recomputing an id in order to unregister: * {@link unregisterProjectById} takes the id cached at registration. */ export declare function generateProjectId(projectPath: string): string; /** * Build display name for a workspace. * - Main repo: "repo" * - Worktree: "repo · worktree" */ export declare function generateProjectDisplayName(workspacePath: string, workflowRootPath: string): string; export declare class ProjectRegistry { private registryPath; private registryDir; private lockPath; private needsInitialization; constructor(); /** * Ensure the registry directory exists */ private ensureRegistryDir; /** * Read the registry file with atomic operations * Returns a map keyed by projectId * * **This method normalizes on write only. It does NOT `realpath` stored paths * on read** — it applies `resolve()`, exactly as before, which is pure and * touches no filesystem. Requirement 1.11's "identity and stored path are the * same spelling" is established in {@link registerProjectLocked}, where both * come off one {@link normalizeIdentityPath} call. The alternative — * `realpath`-ing every stored path on every read — was considered and * rejected; the two differ observably, so the choice is recorded here: * * - **Entries written before this change** carry a link spelling under a link * spelling's id. `realpath`-on-read would rewrite the *value* to the * physical path while the map *key* stayed the link-spelling id, because * ids are stored data and are not recomputed on read. That makes identity * and stored path disagree — the exact condition 1.11 exists to forbid — * and it makes it worse, not better. Genuinely repairing such an entry * means re-keying the map, which is a migration; a read path must not * silently rewrite identities. Left alone, the entry is replaced the next * time that workspace registers, and `cleanupStaleProjects` removes the old * one once its instances die. * - **A removed worktree** (`git worktree remove`) has no `realpath`, so * `realpath`-on-read would return whatever the stored string resolves to * and callers would see the served spelling depend on filesystem state at * read time. Normalizing on write means a read returns the same string * before and after the directory disappears, which is what lets the * dashboard list and serve the surviving projects unchanged. * - **Cost**: reads are the hot path (every dashboard request, every registry * watcher event), and `realpath`-on-read is O(entries) synchronous syscalls * per read on a path that today performs none — and blocks the event loop * if one of those paths sits on an unresponsive mount. */ private readRegistry; /** * Write the registry file atomically */ private writeRegistry; /** * Check if a process is still running * Note: When running in Docker with path translation, we can't check host PIDs, * so we assume processes are alive if path translation is enabled. */ private isProcessAlive; /** * Register a project in the global registry * Self-healing: If a project exists with dead PIDs, cleans them up and adds new PID * Multi-instance: Allows unlimited MCP server instances per project * * The read-modify-write runs under an exclusive lock (requirement 6.1): with * per-worktree identity, N worktrees compute N ids, so two servers starting * together each read a registry without the other and the second write erases * the first. If the lock cannot be acquired within its budget the server logs * prominently and continues UNREGISTERED (requirement 6.4) — this call is * awaited before the MCP transport connects, so throwing kills the handshake. */ registerProject(projectPath: string, pid: number, options?: RegisterProjectOptions): Promise; /** * The registry read-modify-write. Callers must hold the registry lock. * * Both stored paths are `realpath`-normalized, not merely `resolve`d * (requirement 1.11): the id below is computed from `workspacePath`, so * storing a different spelling of the same directory would put the symlink * spelling into every downstream consumer — the spawn cwd, the git cwd and * the containment base — while identity used the physical one. * * `workflowRootPath` is normalized the same way, and not as an afterthought: * {@link generateProjectDisplayName} decides between "repo" and * "repo · worktree" by comparing the two strings, so normalizing one and not * the other would name a symlinked main checkout as if it were a worktree of * itself. It is also the root the dashboard runs git in. */ private registerProjectLocked; /** * Unregister a project from the global registry by path * If pid is provided, only removes that specific instance * If no pid provided, removes the entire project (backwards compat) * * **Prefer {@link unregisterProjectById} with the id cached at registration** * (requirement 1.13). This overload recomputes the id from the path, and * `realpath` fails once the directory is gone — so after `git worktree * remove` the recomputed id is the fallback's id, not the one the entry was * written under, and the entry is never deleted. That is a real shutdown * ordering, not a hypothetical: the worktree can be removed while the server * that registered it is still running. */ unregisterProject(projectPath: string, pid?: number): Promise; /** * Unregister a project by projectId * If pid is provided, only removes that specific instance * If no pid provided, removes the entire project * * The optional-pid convention is the same one {@link unregisterProject} * carries, deliberately: a second method name for "by id, but only this * instance" would leave two near-identical removal paths to keep in step. */ unregisterProjectById(projectId: string, pid?: number): Promise; /** * Get all active projects from the registry */ getAllProjects(): Promise; /** * Get a specific project by path */ getProject(projectPath: string): Promise; /** * Get a specific project by projectId */ getProjectById(projectId: string): Promise; /** * Clean up stale instances (where the process is no longer running) * Projects with no live instances are removed entirely * Returns the count of removed instances */ cleanupStaleProjects(): Promise; /** * Check if a project is registered by path */ isProjectRegistered(projectPath: string): Promise; /** * Get the registry file path for watching */ getRegistryPath(): string; } //# sourceMappingURL=project-registry.d.ts.map