/** * Workflow registry (workstream W2's backend slice). * * Discovers orchestration projects by scanning a directory tree (bounded by * depth, by directories visited, and by the scan root's own repository) for * `sapiom.json` marker files, tracks * manually-connected paths, and persists the combined list to * HARNESS_PATHS.workflows. Exposes an * Express router implementing the /api/workflows surface from * src/shared/types.ts; the integrator mounts it (and express.json()) on the * shared app. */ import { type Router as ExpressRouter } from "express"; import { type WorkflowInfo as PublicWorkflowInfo } from "../shared/types.js"; import { AgentProjectScanBudget, type AgentProjectWalkOptions } from "./agent-project-discovery.js"; import { AgentSourceDiscovery, AgentSourceScanBudget } from "./agent-source-discovery.js"; /** What one scan of a root turned up, and how far it can be trusted. */ export interface AgentProjectScanResult { /** Projects discovered on this pass. */ found: WorkflowInfo[]; /** Subtrees left opaque by a transient filesystem error. */ unreconciledRoots: string[]; /** Existing entrypoints proven to export no supported agent definition. */ notAgentRoots: string[]; /** Symlink entries the no-follow policy deliberately left opaque. */ opaqueRoots: string[]; /** * Valid marker/source projects whose descendants were deliberately not * traversed. The project root itself is proven, but everything below it is * outside this scan's reconciliation envelope. */ discoveredStopRoots: string[]; /** Bounded analyzer path observations, including deterministic absences. */ sourceObservations: readonly { candidateRoot: string; workspaceRoot: string; paths: readonly string[]; }[]; /** * Nested repository checkouts the walk declined to enter (see * {@link isForeignRepositoryRoot}). A scan that comes back short is then able * to say WHY it came back short, which is the difference between "there are * no agents there" and "there is another repo there and you did not ask for * it". The server logs the count on every scan. */ repositoryBoundaries: string[]; /** The traversal allowance this scan spent — `visited`, `envelopeDepth`. */ budget: AgentProjectScanBudget; /** Syntax modules charged even on an LRU hit. */ sourceBudget: AgentSourceScanBudget; /** False when any source candidate or the shared source budget was incomplete. */ sourceDiscoveryComplete: boolean; } /** * Scans `root` for agent projects, bounded by depth, by directories visited, * AND by the repository the root belongs to (core/agent-project-discovery.ts * owns that policy and the numbers behind it). A directory carrying a marker is * registered and not descended into; a directory that is a repository checkout * of its own is reported as a boundary and not descended into either, so one * scan registers one project tree rather than every neighbouring clone. * * Every directory touched is confined to `root` (the tree the caller asked to * scan): the walk starts at `root` itself and only ever descends into a direct * child, so no crafted entry name can walk the scan outside `root` — and * `resolveWithinRoot` re-asserts that at the sink, local to each fs read. * Symlinked entries report `isDirectory() === false` (withFileTypes uses raw * dirent info, not a followed stat) and so are never descended into either, * which is what makes a symlink cycle terminate. * * Exported so the perf benchmark can measure the real scanner against an * explicit budget rather than a copy of its traversal. */ export declare function scanAgentProjects(root: string, budget?: AgentProjectScanBudget, options?: AgentProjectWalkOptions, sourceDiscovery?: AgentSourceDiscovery, sourceBudget?: AgentSourceScanBudget): Promise; /** Fresh, process-local proof controlling legacy source inspection. */ export type WorkflowIdentityEvidence = "marker" | "source" | "not-agent" | "unknown"; /** Registry-only syntax/marker evidence. Never serialize this row directly. */ export interface RegistryWorkflowInfo extends PublicWorkflowInfo { sourceDefinitionName?: string | null; markerPresent?: true; } type WorkflowInfo = RegistryWorkflowInfo; export interface WorkflowRegistryScanResult { found: WorkflowInfo[]; repositoryBoundaries: string[]; budget: AgentProjectScanBudget; sourceBudget: AgentSourceScanBudget; status: "complete" | "degraded"; changed: boolean; generation: number; } export declare class WorkflowRegistry { private readonly registryPath; private readonly sourceDiscovery; private readonly persistenceTestHooks; private workflows; private loaded; private loadPromise; /** Serializes mutations so concurrent prune/scan/connectPath calls can't * interleave and drop entries from the persisted file. Mirrors the pattern * used by SessionManager.persist() (session-manager.ts:278,851-853). */ private writeQueue; /** Epoch ms of the last confirmed-missing sweep — see LAZY_PRUNE_INTERVAL_MS. */ private lastPruneAt; private readonly discoveryStatusByRoot; private readonly canonicalScopeByLexicalRoot; private readonly canonicalWorkflowRootByPath; private readonly identityEvidenceByCanonicalRoot; private readonly sourceObservationsByCanonicalRoot; private inventoryGeneration; private discoveryEpoch; private discoveryLifetime; private retired; private activeRename; /** A failed compensating write leaves disk behind the accepted in-memory * snapshot. The next otherwise-no-op scan must still repair the file. */ private persistedSnapshotOutOfSync; private readonly dirtyEpochByCanonicalRoot; private cachedRootsForRows; private removePrivateStateForRows; constructor(registryPath?: string, sourceDiscovery?: AgentSourceDiscovery, persistenceTestHooks?: { afterPrimaryRename?: () => void | Promise; }); private ensureLoaded; private persist; private writeSnapshot; /** Retires discovery work owned by a shutting-down server instance. */ retirePendingDiscovery(): Promise; /** Chains `run` onto the write queue so concurrent mutations never * interleave — a failed run never poisons later ones. */ private enqueue; private discoveryStatusForCanonicalRoot; discoveryStatus(root: string): Promise<"complete" | "degraded">; inventorySnapshot(root: string): Promise<{ workflows: readonly WorkflowInfo[]; status: "complete" | "degraded"; generation: number; canonicalScopeRoot: string; canonicalWorkflowRoots: readonly { workflowPath: string; canonicalRoot: string; identityEvidence: WorkflowIdentityEvidence; }[]; sourceObservations: readonly { candidateRoot: string; workspaceRoot: string; paths: readonly string[]; }[]; }>; private cachedCanonicalRoot; private wasDirtiedSince; /** * Synchronous fail-closed invalidation for a raw watcher signal. The server * calls this before any asynchronous reconciliation or graph work, so stale * marker proof cannot authorize source inspection while an edit is pending. * Registry load is completed during server boot; callers before then simply * get the conservative no-op and the later scan establishes evidence. */ markDiscoveryDirty(root: string): boolean; /** * The registry as it stands — and the point at which stale entries actually * leave. A read runs {@link prune} when one is due * (LAZY_PRUNE_INTERVAL_MS), so an agent whose directory the user deleted * disappears from `GET /api/workflows` on its own rather than waiting for the * next server boot. Cheap by construction: one `stat` per entry, throttled, * and the file is only rewritten when something was actually dropped. */ list(): Promise; /** * Drops entries whose `path` no longer exists on disk and persists the * result — users delete projects, and a crashed run can leave a temp * directory registered. Deliberately narrow: only a confirmed-missing * path (ENOENT/ENOTDIR) is pruned. A directory that exists but is * merely unbuilt, unreadable (permissions), or temporarily unstattable * stays registered. Returns what was pruned so the caller can log it. */ prune(): Promise; /** * `budget` is overridable so a caller (and the tests that pin this rule) can * scan with a narrower allowance than the shipped default; the reconciliation * envelope follows whatever the scan actually managed to cover. * * Scans `root`, refreshes discovered projects, and removes scan-sourced rows * in this scan's traversal envelope when their marker is confirmed gone or * invalid. Rows beneath a temporarily unreadable directory survive this pass, * as do rows below the depth the scan actually completed when the node budget * cut it short; manually connected rows remain until their path itself is * pruned. */ scanDetailed(root: string, budget?: AgentProjectScanBudget, sourceBudget?: AgentSourceScanBudget): Promise; scan(root: string, budget?: AgentProjectScanBudget): Promise; /** Registers an arbitrary path (the "+ Connect" flow); marker is optional at connect time. */ connectPath(inputPath: string): Promise; } /** * The subset of {@link WorkflowRegistry} the workflows router depends on. Typed * structurally so a caller can pass a wrapper (e.g. one that enriches `list()` * with resolved slugs) without an unsafe cast — a missing method is then a * compile error, not a runtime crash. */ /** * What a requested scan found, AND what it deliberately declined to enter. * * The second half exists because the two are only honest together. A scan stops * at a foreign repository root, so pointing it at a folder that is not itself a * repo but holds several clones finds NOTHING while several agents sit on disk — * and a UI told only `found: []` will state, in good faith, that the folder is * empty and offer to create the *first* agent in it. `repositoryBoundaries` * turns that false statement into the true one: these checkouts were not * searched, open one as its own project. */ export interface WorkflowScanOutcome { found: WorkflowInfo[]; /** Absolute paths of checkouts the walk stopped at rather than entering. */ repositoryBoundaries: string[]; } export interface WorkflowRegistryLike { list(): Promise; scan(root: string): Promise; /** * The same scan, reporting the boundaries it stopped at. Optional so an * embedder implementing this interface keeps compiling; the route degrades to * `scan` with an empty boundary list, which is the pre-existing behaviour. */ scanWithBoundaries?(root: string): Promise; connectPath(inputPath: string): Promise; } export declare function createWorkflowsRouter(registry: WorkflowRegistryLike): ExpressRouter; export {}; //# sourceMappingURL=workflow-registry.d.ts.map