import { Bundle, BundleId, BundleJobSpec, Capability, NodeId, NodeInventory, OnRuntimeFailure } from "./types"; /** @public */ export interface NodeView { nodeId: NodeId; poolName: string; /** * Which tier within the pool this node belongs to. Set from * `tags["tier"]` at provision time for elastic tiers. Pre-registered * cluster nodes carry no tier tag (undefined) and are treated as * belonging to their pool's `fixed` tier — see `nodeMatchesTier`. */ tierName?: string; inventory: NodeInventory; /** Jobs currently running on this node (or pending placement onto it). */ runningJobs: RunningJob[]; /** Topology key for the resilience sameAz rule. Cluster pools can leave undefined. */ az?: string; /** * Cloud / failure-domain key for the resilience sameCloud rule — the * node's provider (e.g. "aws" / "oci" / "cluster"). Undefined when * unknown; sameCloud then can't be evaluated against this node. */ cloud?: string; /** Hot spare flag (Phase C). Currently unused by hard-constraint filters. */ isHotSpare: boolean; /** * Phase G — the container images this node advertises holding, as * observed (the worker reports its local image store in its inventory). * Warmth is measured against this, never declared: a node that just ran * `abc` is warm for `abc` without anyone saying so. Absent/empty ⇒ * unknown, which scores as cold rather than as warm. */ heldImages?: HeldImage[]; /** * Phase G — product-template names this node is RESERVED for. Set on a * hot spare from its `HotSpareConfig`. Reservation is who may take the * node; warmth is what is on its disk. Two properties, never one flag. */ reservedFor?: string[]; /** * Phase G — the reservation is exclusive: while this node is still * unconsumed reserve, only a job whose template is in `reservedFor` may * place onto it (unless the placement carries `overrideReservation`). * Default (absent/false) is non-exclusive: anyone may take it, and the * reserved templates merely score it first. */ exclusiveReservation?: boolean; /** * A node still launching (machine-types.md Part B): its inventory is the * machine type's DECLARED shape, synthesized by AutoManager from the * in-flight provision. Passes the normal filter chain, carries a scoring * penalty so real capacity wins when both fit, and counts against tier * caps. Placement onto it defers job dispatch until the node arrives. */ prospective?: boolean; } /** * Phase G — one container image, either as a product template needs it or * as a node holds it. `digest` is the real identity; `repoTag` is the * fallback the Manager has to match on when the template's images have not * been resolved to digests (the tar is opaque to Manager, so the image set * is supplied by the embedder and may name only `repository:tag`). * * @public */ export interface ImageRef { /** `repository:tag` exactly as written. */ repoTag?: string; /** Content digest, `sha256:...`, when known. */ digest?: string; } /** * Phase G — an image a node advertises holding. One digest may carry * several `repository:tag` names on the same node. * * @public */ export interface HeldImage { digest?: string; repoTags?: string[]; } /** * Phase G — what fraction of the images this job needs the node already * holds, in [0,1]. Digest match first (the honest identity); when the * template's image set was not resolved to digests, `repository:tag` is * the fallback — a tag can be re-pushed under the same name, so a tag * match is a good guess about the pull cost, not a guarantee of identity. * * An unknown image set (empty `images`), or a node that advertises nothing, * scores 0: no warmth signal, no bias. That is what every caller sees until * the worker actually advertises its image store. * * @public */ export declare function warmth(node: NodeView, images: ImageRef[]): number; /** @public */ export interface RunningJob { bundleId: BundleId; replicaIndex: number; jobName: string; /** The occupant's spec declares exclusive node allocation — no other * job may place onto this node (its own co-located peers excepted). */ exclusive?: boolean; } /** * A pool is an ordered list of capacity tiers. Placement walks the tiers * in order (cheapest first) and takes the first that can satisfy the job * — the tier order *is* the cost cascade (no cost weight in v1). The * homogeneous single-cloud pool of before is just a one-tier pool. * * @public */ export interface PlacementPool { name: string; /** Ordered cheapest → most expensive. Placement tries them in order. */ tiers: PlacementTier[]; /** * Default true: nodes' security groups are reconciled at runtime as * jobs with extra SGs come and go (worker-security-groups.md). false * fixes each node's SG set at provision time — the opt-out is stamped * onto provisioned nodes as `tags["dynamicSecurityGroups"] = "false"`, * which the Manager's reconcile paths honour. */ dynamicSecurityGroups?: boolean; } /** * One homogeneous capacity segment within a pool: a single cloud/cluster * kind + region with its own scale-out behaviour and candidate shapes. * * @public */ export interface PlacementTier { /** Unique within the pool. Tagged onto provisioned nodes as `tags["tier"]`. */ name: string; /** * Cloud / local-servers kind. Used by AutoManager to decide which gRPC * call to make when provisioning (createAwsNode vs createOciNode vs * local-servers-tier placement, which uses startJob against an existing * registered server). */ kind: "aws" | "oci" | "local-servers"; /** * For aws/oci elastic tiers: which region/availability-domain to * provision into. Cluster tiers leave undefined. */ region?: string; packingStrategy: "binpack" | "spread"; scaleOut: "elastic" | "fixed"; /** * For elastic tiers: shapes that can be provisioned on demand. For * fixed tiers: typically empty (the tier consists of registered * cluster nodes already in `inventory`). */ candidateInstanceTypes: InstanceTypeOption[]; /** * How long ahead of a scheduled job's `startDateTime` AutoManager * should kick off placement, so the worker is ready to run by the * requested time. Cluster tiers place against an existing running * node (~instant) so the default is 0. Cloud tiers have to boot a * fresh instance — defaults to 5 minutes; operators set per-tier * when their boot time differs. AutoManager picks the maximum across * the bundle pool's tiers (worst case — guarantees readiness * regardless of which tier the placement engine settles on). */ placementLeadMs?: number; /** * How nodes in this tier are purchased. `"spot"` requests interruptible * spot-market capacity; `"reserved"` is an on-demand launch the operator * knows is covered by a standing reservation (same AWS call as on-demand, * but capped — see `maxNodes`). Defaults to `"on-demand"`. */ launchMode?: LaunchMode; /** * The root EBS volume of nodes this tier provisions (aws tiers only). * Unset ⇒ the Manager's default, 100 GiB gp2. The size is where this * matters: it is the node's image store, and a tier warmed for many * templates (`warmFor`) holds more images than a bare one. */ rootVolume?: RootVolume; /** * Failure-domain reliability of this tier's capacity. Defaults to * `"interruptible"` for spot, `"durable"` otherwise. Read by the * resilience engine — an interruptible-tier primary with no durable * backup is flagged degraded. */ reliability?: Reliability; /** * Cap on the number of nodes this tier may hold (tier-wide, across * bundles). The cost cascade spills to the next tier once the cap is * reached. Used mainly for reserved tiers — exceeding the reservation * count just bills as on-demand, defeating the point. Unset ⇒ no cap. */ maxNodes?: number; /** * How long an in-flight provision may stay prospective before it is * abandoned and its jobs re-placed (machine-types.md Part B). Default: * max(placementLeadMs, 5 min) × 2. */ provisionTimeoutMs?: number; } /** @public */ export type LaunchMode = "on-demand" | "spot" | "reserved"; /** An AWS node's root volume: the size and EBS type it is launched with. @public */ export interface RootVolume { sizeGib: number; /** Defaults to "gp2". */ volumeType?: "gp2" | "gp3" | "io1" | "io2"; /** Provisioned IOPS, for the types that take one (gp3, io1, io2). */ iops?: number; } /** @public */ export type Reliability = "durable" | "interruptible"; /** The tier's launch mode, defaulting to on-demand. @public */ export declare function tierLaunchMode(tier: PlacementTier): LaunchMode; /** The tier's reliability — explicit, else interruptible for spot. @public */ export declare function tierReliability(tier: PlacementTier): Reliability; /** How long an in-flight provision may stay prospective before it is * abandoned: generous relative to expected boot time so a slow image pull * doesn't orphan jobs, bounded so jobs never wait forever on a node that * is not coming. @public */ export declare function provisionTimeoutMs(tier: PlacementTier): number; /** Default placement lead time by tier kind. Cluster tiers place * against existing running nodes so we don't need lead. Cloud tiers * default to 5 minutes — typical EC2/OCI instance boot + image pull * budget; operators can override per-tier via PlacementTier. */ export declare function defaultPlacementLeadMs(kind: PlacementTier["kind"]): number; /** * Whether a node belongs to (pool, tier). Elastic nodes carry an explicit * `tierName` (set from `tags["tier"]` at provision). Pre-registered cluster * nodes carry no tier tag and are taken to belong to the pool's `fixed` * tier — so a pool must have at most one fixed tier (enforced in * settingsValidation) for this to be unambiguous. * * @public */ export declare function nodeMatchesTier(n: NodeView, pool: PlacementPool, tier: PlacementTier): boolean; /** @public */ export interface InstanceTypeOption { instanceType: string; totalCapacity: number; totalCores: number; capabilities: Capability[]; /** GPUs we could provision a node with — for feasibility check. */ gpus?: { totalCapacity: number; model?: string; }[]; } /** @public */ export interface RecoveryContext { /** NodeIds the placement engine must skip (e.g., recently failed). */ excludeNodes: Set; /** * The recovering job's failure policy, which is what decides how the * reserve is treated for this placement: * - `"avoidSpare"` — an unconsumed spare ranks below every ordinary * node (PlacementKey.avoidedSpare), so a routine failure does not * consume a reserve that exists for the workloads which asked for one; * - `"preferSpare"` — among otherwise-equal candidates an unconsumed * spare ranks first (PlacementKey.preferredSpare). Warmth outranks * this: what `preferSpare` reaches for is a node the job starts on * quickly, and spare-ness was only ever a proxy for that (fix-list 9); * - `"drop"` / undefined — no opinion about spares (a planned migration * passes undefined: the reserve is protected by reservation there). */ onRuntimeFailure?: OnRuntimeFailure; } /** @public */ export interface PlacementInput { bundle: Bundle; job: BundleJobSpec; replicaIndex: number; inventory: NodeView[]; pools: Map; recoveryContext?: RecoveryContext; /** * Phase G — the images this job's product template needs, so candidates * can be scored by `warmth`. Absent/empty ⇒ warmth is unknown and every * node scores 0 for it, which is exactly the behaviour before Phase G. */ jobImages?: ImageRef[]; /** * Phase G — the job's product-template name, for reservation matching * (`NodeView.reservedFor`). Absent ⇒ the job matches no reservation, so * every exclusive reserve is closed to it. */ templateName?: string; /** * Phase G — ignore exclusive reservations for this one placement. The * operator's explicit "yes, take the reserved spare" on a launch or a * migrate. Does not affect the `avoidSpare` penalty, which is a * different question. */ overrideReservation?: boolean; /** * Hard node pin (AutoPlacement.target_node) — "this node or nowhere", * the API-only escape hatch for cable-bound hardware. Every other node * filters out as wrong-target-node; the target itself still runs the * full constraint chain so the real blocker (port conflict, capacity) * surfaces in the trace. A pinned job never provisions a fresh node. * Label-matched capability requirements are the preferred targeting. */ targetNode?: NodeId; } /** @public */ /** Which anti-affinity flag(s) a backup placement couldn't honour. */ export type ResilienceFlag = "sameNode" | "sameAz" | "sameCloud"; /** * Set on a backup placement that landed inside a failure domain its * resilience policy asked to avoid (best-effort: no compliant node had * capacity). The engine places it anyway and surfaces this so the * operator knows the bundle's DR posture is currently reduced. * * @public */ export interface ResilienceDegradation { violated: ResilienceFlag[]; /** * Set when the primary landed on interruptible (spot) capacity with no * durable backup — a single interruption takes the workload down. Surfaced * so the operator can add a durable backup. */ interruptiblePrimary?: boolean; } /** @public */ export type PlacementResult = { kind: "place"; nodeId: NodeId; gpuIndex?: number; pool: string; tier: string; degraded?: ResilienceDegradation; } | { kind: "provision"; pool: string; tier: string; instanceType: string; launchMode: LaunchMode; /** * The device (index into the shape's `gpus`, which is how the * prospective node view numbers them) the triggering job will take * on the new node, when it needs one — so its claim reserves that * device from the moment the provision is bound, exactly as a * `place` decision's does. */ gpuIndex?: number; degraded?: ResilienceDegradation; } | { kind: "failure"; reason: PlacementFailureReason; }; /** @public */ export interface PlacementFailureReason { code: "noCapacityInAnyPool" | "noConfiguredPools"; triedPools: string[]; } /** * Structured trace of a placement decision — one entry per pool * attempted, with per-node filter outcomes and resulting scores. Used * for "why did my job land here?" debugging. * * @public */ export interface PlacementTrace { attempts: PoolAttempt[]; /** Final decision kind, mirroring PlacementResult.kind. */ decision: "place" | "provision" | "failure"; } /** @public */ export interface PoolAttempt { pool: string; /** Which tier of the pool this attempt covers. */ tier: string; /** Total nodes considered in this pool before any filtering. */ initialCandidates: number; /** Why each non-matching node was filtered out. Indexed by nodeId. */ filtered: { nodeId: NodeId; reason: FilterReason; }[]; /** * Nodes that survived all filters, winner first. `key` is what ranked * them (compare component by component, see PlacementKey); `score` is a * scalar rendering of the same key for displays that expect one number * — it is NOT what decides the order and cannot always separate two * keys that the tuple does. */ scored: { nodeId: NodeId; score: number; key: PlacementKey; }[]; /** What this pool attempt produced. */ outcome: { kind: "placed"; nodeId: NodeId; gpuIndex?: number; } | { kind: "provisioned"; instanceType: string; } | { kind: "exhausted"; } | { kind: "no-instance-type"; }; } /** @public */ export type FilterReason = "wrong-pool" | "wrong-tier" | "excluded-recovery" | "wrong-target-node" | "unreachable" | "cordoned" | "missing-capability" | "insufficient-capacity" | "insufficient-cores" | "port-conflict" | "no-fitting-gpu" | "node-not-empty" | "node-hosts-exclusive" | "reserved-for-other-template" | "fails-intra-replica" | "fails-intra-az"; /** * Core placement function. Pure — no side effects, no I/O. * * @public */ export declare function place(input: PlacementInput): PlacementResult; /** * Placement with a structured trace of every pool attempted and every * node's filter outcome. AutoManager logs the trace via debuglog when * placing; tests use it to assert on the precise reason a candidate * was rejected. * * @public */ export declare function placeWithTrace(input: PlacementInput): { result: PlacementResult; trace: PlacementTrace; }; /** * Phase G — is this node exclusively reserved AGAINST this job? * * Only an UNCONSUMED reserve is a reserve. A spare that already carries a * job has been spent; keeping its reservation would strand the rest of the * node's capacity for ever, and the guarantee the operator asked for was * "one warm node ready for xyz", not "this machine belongs to xyz until it * dies". The same `runningJobs.length === 0` test that decides the * avoid-spare penalty decides this, and for the same reason. * * A job with no template name matches no reservation — an exclusive reserve * is closed to it, which is the conservative answer. */ /** * A spare that is still a reserve: tagged `spare` AND carrying nothing. The * daemon drops the tag the moment a job is bound to the node and announces * it (`nodeTagsUpdated`), so the tag alone is the truth once that lands; * the `runningJobs` check covers the window before it does, and also * counts jobs AutoManager has placed but that have yet to appear in any * report, so two placements in quick succession cannot both be told the * same spare is still pristine. Every consumer used to re-derive "is it * still one" for itself, and one of them — the prefer bonus — forgot, so * `preferSpare` biased toward a spare already carrying a job as strongly * as toward a pristine one (task 90, fix-list 14). */ export declare function isUnconsumedSpare(n: NodeView): boolean; /** * The ranking key of a candidate node. Compared lexicographically by * `comparePlacementKeys`, first component most significant: * * 1. `resilienceViolations` — fewer first. A backup lands outside the * primary's `forbid` domains whenever any node there fits; best-effort, * so a violating node still places (flagged degraded) when none does. * 2. `prospective` — a node that has booted before one that has not. A * still-launching node is preferable only to provisioning another. * 3. `avoidedSpare` — set when the policy in force says avoidSpare and the * node is an unconsumed spare: the reserve exists for the workloads that * asked for it, and "who the node is for" outranks how fast it starts. * Recovery reads the job's `onRuntimeFailure`; a first launch reads the * bundle's `onLaunch` (default avoidSpare; `allowSpare` merely lifts * this, it adds no preference). Soft by construction: ranking only * orders nodes that MATCHED, and provisioning runs only when nothing * did, so avoiding a spare can never cause a provision — deliberately, * because a job waiting minutes for a fresh node beside an idle spare is * the worse failure of the two. * 4. `warmth` — higher first (Phase G: a job launched in 7 s on a node that * held its images and 64–118 s on one that did not). * 5. `reservationMatch` — a node reserved for this job's template, while * it is still a reserve (carrying nothing). A spent reserve is an * ordinary node here, exactly as it is for the exclusive filter. * 6. `preferredSpare` — set when the policy says preferSpare and the node * is an unconsumed spare: what is left of "prefer the spare" once * warmth has spoken (fix-list 9, 14). * 7. `packing` — higher first: the used fraction under binpack, its * negation under spread. * 8. `sameAz` — the node shares an AZ with a same-replica peer (soft * intraReplica sameAz; `hard` is a filter, `off` never sets this). * 9. nodeId — applied by the caller for determinism. * * @public */ export interface PlacementKey { resilienceViolations: number; prospective: boolean; avoidedSpare: boolean; warmth: number; reservationMatch: boolean; preferredSpare: boolean; packing: number; sameAz: boolean; } /** Negative when `a` ranks before `b`; 0 when the tuples are equal. @public */ export declare function comparePlacementKeys(a: PlacementKey, b: PlacementKey): number; //# sourceMappingURL=placement.d.ts.map