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
150 changes: 150 additions & 0 deletions apps/server/src/devStacks/DevStackPolicy.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import { DEV_STACK_SCHEMA_VERSION, type DevStackEntry } from "@t3tools/contracts";
import { describe, expect, it } from "@effect/vitest";
import {
DEFAULT_CONSUMERS,
DEFAULT_IDLE_MINUTES,
decide,
entryPortsFor,
isActive,
resolvePolicy,
} from "./DevStackPolicy.ts";

const ROOT = "/workspaces/example/feature";

const entry = (processes: DevStackEntry["processes"]): DevStackEntry => ({
schema: DEV_STACK_SCHEMA_VERSION,
project: "example",
worktree: "bdb56cccb27eb374",
root: ROOT,
instance: "primary",
processes,
});

const browserStack = entry([
{ role: "api", pid: 1, port: 21000, cwd: "api" },
{ role: "frontend", pid: 2, port: 22000, cwd: "frontend" },
]);
const apiOnlyStack = entry([{ role: "api", pid: 1, port: 21000, cwd: "api" }]);

const idle = { establishedPorts: new Set<number>(), consumerCwds: [] };
const policy = resolvePolicy({ entryRoles: ["frontend", "api"] });

describe("resolvePolicy", () => {
it("applies the documented defaults when a repository declares nothing", () => {
expect(resolvePolicy(undefined)).toEqual({
idleMs: DEFAULT_IDLE_MINUTES * 60_000,
consumers: DEFAULT_CONSUMERS,
entryRoles: [],
});
});

it("takes the repository's declaration over the defaults", () => {
expect(resolvePolicy({ idleMinutes: 45, consumers: ["cypress"], entryRoles: ["web"] })).toEqual(
{
idleMs: 45 * 60_000,
consumers: ["cypress"],
entryRoles: ["web"],
},
);
});
});

describe("entryPortsFor", () => {
it("watches the frontend of a browser stack, not the API behind it", () => {
expect(entryPortsFor(browserStack, ["frontend", "api"])).toEqual([22000]);
});

it("falls through to the API when no frontend is running", () => {
expect(entryPortsFor(apiOnlyStack, ["frontend", "api"])).toEqual([21000]);
});

it("watches every port when the repository declared no entry roles", () => {
expect(entryPortsFor(browserStack, [])).toEqual([21000, 22000]);
});

it("watches nothing when declared roles match no running process", () => {
expect(entryPortsFor(browserStack, ["worker"])).toEqual([]);
});
});

describe("isActive", () => {
it("ignores a connection to the API of a browser stack", () => {
expect(isActive(browserStack, policy, { ...idle, establishedPorts: new Set([21000]) })).toBe(
false,
);
});

it("counts a connection to the entry port", () => {
expect(isActive(browserStack, policy, { ...idle, establishedPorts: new Set([22000]) })).toBe(
true,
);
});

it("counts a consumer working anywhere inside the worktree", () => {
expect(isActive(browserStack, policy, { ...idle, consumerCwds: [`${ROOT}/e2e`] })).toBe(true);
expect(isActive(browserStack, policy, { ...idle, consumerCwds: [ROOT] })).toBe(true);
});

it("does not mistake a sibling worktree sharing a path prefix for a consumer", () => {
expect(isActive(browserStack, policy, { ...idle, consumerCwds: [`${ROOT}-other/e2e`] })).toBe(
false,
);
});
});

describe("decide", () => {
const base = {
entry: browserStack,
policy,
observation: idle,
anyProcessAlive: true,
rootExists: true,
lastSeenMs: null as number | null,
now: 1_000_000_000,
};

it("prunes bookkeeping when every process is already gone", () => {
expect(decide({ ...base, anyProcessAlive: false })).toEqual({
_tag: "Prune",
reason: "no-live-processes",
});
});

it("prefers pruning over reaping when the worktree is gone but so are the processes", () => {
expect(decide({ ...base, anyProcessAlive: false, rootExists: false })._tag).toBe("Prune");
});

it("reaps a live stack whose worktree was removed, whatever the clock says", () => {
expect(decide({ ...base, rootExists: false, lastSeenMs: base.now })).toEqual({
_tag: "Reap",
reason: "worktree-removed",
});
});

it("starts the clock the first time it sees a stack rather than reaping it", () => {
expect(decide(base)).toEqual({ _tag: "StartClock" });
});

it("keeps a stack that is still inside its idle window", () => {
const lastSeenMs = base.now - (policy.idleMs - 60_000);
expect(decide({ ...base, lastSeenMs })).toEqual({
_tag: "Keep",
idleMs: policy.idleMs - 60_000,
});
});

it("reaps once the idle window is exceeded", () => {
const lastSeenMs = base.now - (policy.idleMs + 60_000);
expect(decide({ ...base, lastSeenMs })).toEqual({
_tag: "Reap",
reason: "idle",
idleMs: policy.idleMs + 60_000,
});
});

it("refreshes an active stack instead of ageing it out", () => {
const observation = { ...idle, establishedPorts: new Set([22000]) };
const lastSeenMs = base.now - (policy.idleMs + 60_000);
expect(decide({ ...base, observation, lastSeenMs })).toEqual({ _tag: "Active" });
});
});
104 changes: 104 additions & 0 deletions apps/server/src/devStacks/DevStackPolicy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
// @effect-diagnostics nodeBuiltinImport:off
import type { DevStackEntry, T3ProjectFileDevStacks } from "@t3tools/contracts";
import * as NodePath from "node:path";

/**
* The decision half of the dev stack sweep, kept free of the filesystem, /proc,
* and signals so it can be tested directly rather than through a live stack.
*/

export const DEFAULT_IDLE_MINUTES = 20;
export const DEFAULT_CONSUMERS: ReadonlyArray<string> = Object.freeze(["playwright", "vitest"]);

export interface DevStackPolicy {
readonly idleMs: number;
readonly consumers: ReadonlyArray<string>;
readonly entryRoles: ReadonlyArray<string>;
}

/**
* `entryRoles` has no safe generic default, so an absent one means "watch every
* port in the stack". That over-detects activity, which is the right way to be
* wrong: a repository that has not declared its entry role keeps a stack alive
* too long rather than losing one mid-run.
*/
export const resolvePolicy = (declared: T3ProjectFileDevStacks | undefined): DevStackPolicy => ({
idleMs: (declared?.idleMinutes ?? DEFAULT_IDLE_MINUTES) * 60_000,
consumers: declared?.consumers ?? DEFAULT_CONSUMERS,
entryRoles: declared?.entryRoles ?? [],
});

/**
* Ports a consumer would connect to. With `entryRoles` declared we watch the
* first role actually present, most specific first — a frontend usually holds
* keep-alive connections to its own API, so watching the API port too would read
* the stack as busy for as long as the frontend is up.
*/
export const entryPortsFor = (
entry: DevStackEntry,
entryRoles: ReadonlyArray<string>,
): ReadonlyArray<number> => {
const portsOf = (role: string) =>
entry.processes
.filter((process) => process.role === role && process.port !== undefined)
.map((process) => process.port as number);
for (const role of entryRoles) {
const ports = portsOf(role);
if (ports.length > 0) return ports;
}
if (entryRoles.length > 0) return [];
return entry.processes.flatMap((process) => (process.port === undefined ? [] : [process.port]));
};

const within = (child: string, parent: string) =>
child === parent || child.startsWith(`${parent}${NodePath.sep}`);

export interface ActivityObservation {
readonly establishedPorts: ReadonlySet<number>;
/** Working directories of live processes whose command line matched a consumer pattern. */
readonly consumerCwds: ReadonlyArray<string>;
}

export const isActive = (
entry: DevStackEntry,
policy: DevStackPolicy,
observation: ActivityObservation,
): boolean => {
for (const port of entryPortsFor(entry, policy.entryRoles)) {
if (observation.establishedPorts.has(port)) return true;
}
return observation.consumerCwds.some((cwd) => within(cwd, entry.root));
};

export type SweepDecision =
| { readonly _tag: "Active" }
/** First sighting starts the clock — a cold boot plus a build can outlast the window. */
| { readonly _tag: "StartClock" }
| { readonly _tag: "Keep"; readonly idleMs: number }
| { readonly _tag: "Reap"; readonly reason: "idle"; readonly idleMs: number }
| { readonly _tag: "Reap"; readonly reason: "worktree-removed" }
| { readonly _tag: "Prune"; readonly reason: "no-live-processes" };

export interface SweepInput {
readonly entry: DevStackEntry;
readonly policy: DevStackPolicy;
readonly observation: ActivityObservation;
readonly anyProcessAlive: boolean;
readonly rootExists: boolean;
/** mtime of the sibling `.seen` marker, or null when the sweep has never seen this stack. */
readonly lastSeenMs: number | null;
readonly now: number;
}

export const decide = (input: SweepInput): SweepDecision => {
// Nothing left to signal: a reboot, an OOM, or a manual kill already took the
// processes and only the bookkeeping survived.
if (!input.anyProcessAlive) return { _tag: "Prune", reason: "no-live-processes" };
if (!input.rootExists) return { _tag: "Reap", reason: "worktree-removed" };
if (isActive(input.entry, input.policy, input.observation)) return { _tag: "Active" };
if (input.lastSeenMs === null) return { _tag: "StartClock" };
const idleMs = input.now - input.lastSeenMs;
return idleMs > input.policy.idleMs
? { _tag: "Reap", reason: "idle", idleMs }
: { _tag: "Keep", idleMs };
};
Loading
Loading