mirror of
https://github.com/zernie/vigiles.git
synced 2026-09-14 20:53:57 +08:00
900dc7e06a
Three exported symbols were named for something other than what they are, and
two mechanisms were doing one job.
RENAMES (old names kept one major, @deprecated, removed one major AFTER this one):
claude() → instructionFile() compiles CLAUDE.md AND AGENTS.md, so a
name from one harness was never right
instructions`` → prose`` builds a prose FRAGMENT; the plural read
as "the instruction file", which is what
instructionFile() builds
agent() → experimental_agent() the shape is not settled, and the tag was
never there (git log -S empty)
SkillSpec.result: → postcondition: `result` already meant the subagent output
contract one screen down; the doc comment
had to spend a line disambiguating them
The alias window is not politeness. Measured: the previous rename shipped with
ZERO overlap (18.1.1 old names only, 19.0.0 new only). A container rollback put
the old node_modules under new sources, PreToolUse stopped loading, and a
PreToolUse that does not load denies EVERY Bash command — including the one that
fixes it. One window per major makes that pair unrepresentable.
SUBPATH RETIRED. vigiles/experimental is gone; its members moved to the door
their feature already uses. Of two mechanisms for one promise the subpath was the
weaker: it marks the import line, out of view by the time anyone reads the call.
Measured on the only user-facing example — with the prefix aliased away at
import, the marker survived at 0 of 5 call sites. So the prefix stays, the advice
to alias it away became an explicit prohibition, and the six define* entry points
are DECLARED prefixed rather than gaining it at re-export.
That de-aliasing is what unblocked moving the naming check into ESLint, and the
ORDER was load-bearing: measured before it, dropping the public/internal
exemption produced 14 findings, all from the re-export indirection. Measured
after, across all of src/ with no exemption: 22 tagged declarations, 0
unprefixed. The rule turns on silent.
EXPORT CLEANUP. vigiles/linting export * → curated, 103 → 79 symbols; its header
claimed to be curated with an `export *` one line below, and claimed the builders
were also at the package root (191 exports there, zero matches). ClaudeTool and
HookEvent deleted — zero references, and both had drifted off the dialect they
claimed to mirror; HookEvent listed 5 events, two of which do not exist, against
the 31 the vendor documents.
DOCS. experimental.md deleted and distributed per feature: the skill half into
skills.md §Status, the hook half into a new compiled-hooks.md §Status, and the
emit half — 111 of its 177 lines, a topic doc wearing a stability page's clothes
— into docs/emit-channel.md, which gains the "which channel when" comparison it
never had. The one cross-cutting sentence went to the README.
TWO GATES WERE GREEN AND WRONG, both found by this work:
- api:check reported "verified for 11 entries — no drift" while reading
dist/experimental.d.ts from a PREVIOUS build (tsc does not clean dist/) for a
source file that no longer existed. Now asserted against package.json
exports, with a mutation proving exit 1 on a phantom entry.
- check-export-prefixes caught MY error: the R3 services first landed on
vigiles/eval, where `paid_` means "this call can bill you" — and
startServices takes a ContainerRuntime and calls no model. Naming it
paid_experimental_startServices would have redefined paid_ as "expensive in
some sense". They went to the package root.
SEVENTEEN review findings landed on this branch, all real. Two mechanical
closures came out of them: src/example-imports.test.ts resolves every relative
import under examples/ instead of matching a string, and the naming rule now
ENUMERATES the three export paths it visits rather than describing its coverage
in the abstract. One attempt is recorded as REFUTED: teaching the doc gate to
flag our own exports as free variables measured 66 findings across 93 blocks, so
that gate's original scoping was right.
BREAKING CHANGE: vigiles/experimental is removed. Its exports are unchanged in
name and behaviour and are now reached from `vigiles` (the emit channel, the R3
disposable-service tier). Every renamed symbol keeps a working @deprecated alias
for this major.
250 lines
11 KiB
TypeScript
250 lines
11 KiB
TypeScript
/**
|
|
* vigiles — R3 disposable-service tier (⚠️ EXPERIMENTAL / UNSTABLE).
|
|
*
|
|
* ─────────────────────────────────────────────────────────────────────────────
|
|
* EXPERIMENTAL: this surface is a DRAFT. Import it from `vigiles`,
|
|
* NOT from a stable subpath. It is NOT covered by the stability guarantee and
|
|
* may change shape or be removed WITHOUT a major-version bump. Do not build a
|
|
* production workflow on it yet. See docs/measuring-skills.md § Experimental.
|
|
* ─────────────────────────────────────────────────────────────────────────────
|
|
*
|
|
* WHAT THIS IS. The free tiers (`runHook`, `measureTriggerRate`, `runEval`)
|
|
* cover a skill's OUTPUT and its side-effect SAFETY (did it call / not call a
|
|
* tool, write / not write a file) with no container. What they do NOT do is let
|
|
* a skill actually PERFORM a side effect against a real service and verify the
|
|
* resulting state — apply a migration to a real Postgres and check the row
|
|
* landed. That is the R3 rung (see research/eval-coverage-and-isolation.md): the
|
|
* REAL system's semantics are the thing under test, so it can't be faked.
|
|
*
|
|
* THE POSTURE. vigiles COMPOSES with a throwaway container; it does not reinvent
|
|
* the sandbox. A {@link ServiceSpec} declares a disposable service; the injected
|
|
* {@link ContainerRuntime} port starts it, and it is force-removed on teardown.
|
|
*
|
|
* ⚠️ SAFETY — the isolation is the DISPOSABLE CONTAINER, nothing more. A skill is
|
|
* model-driven, so the MODEL chooses the actions: it can do anything the run
|
|
* ENVIRONMENT allows. vigiles creates and destroys the container; it does NOT, in
|
|
* this tier, confine the skill's filesystem or block its network. So an R3 run is
|
|
* only as safe as the environment you run it in:
|
|
* - run it in a DISPOSABLE environment — a CI job, a throwaway container/VM, or
|
|
* a dev box with NO production access;
|
|
* - point the task at the disposable service's connection string ONLY;
|
|
* - keep real credentials OUT of the run (prod `DATABASE_URL`, cloud keys,
|
|
* `~/.ssh`) — pair it with the eval tier's `ephemeralEnv` (throwaway HOME +
|
|
* cleared env) to scrub them so the model has no real keys to misuse.
|
|
* Treat it like running an untrusted script. A future increment adds an egress
|
|
* wall (the skill reaches only the model + the service); until then that job is
|
|
* the operator's. See docs/measuring-skills.md § Experimental.
|
|
*
|
|
* WHAT SHIPS TODAY vs LATER. Today: the TYPES, the {@link ContainerRuntime} port,
|
|
* the pure {@link experimental_startServices} / {@link experimental_withServices}
|
|
* orchestration, and a Docker backend (`src/services-docker.ts`). Deferred: the
|
|
* `runEval` / `measureArms` `services` option + `ctx.service(name)`, per-trial
|
|
* reset via an eval-loop hook, and the egress wall. Requires Docker (Linux-first).
|
|
* It is an explicit opt-in and NEVER part of `vigiles audit` (audit stays
|
|
* side-effect-free).
|
|
*
|
|
* @experimental
|
|
* @module vigiles (services)
|
|
*/
|
|
|
|
/**
|
|
* How a service signals it is ready to accept work — polled by the
|
|
* {@link ContainerRuntime} before {@link ContainerRuntime.start} resolves, so a
|
|
* consumer never races a half-booted service.
|
|
*
|
|
* @experimental
|
|
*/
|
|
export type ServiceReady =
|
|
| { readonly tcp: number } // a port accepts a TCP connection
|
|
| { readonly log: RegExp } // a line matches in the container's stdout/stderr
|
|
| { readonly exec: string }; // a command exits 0 inside the container (e.g. `pg_isready`)
|
|
|
|
/**
|
|
* When a fresh container is provisioned. `"per-trial"` (the DEFAULT) gives each
|
|
* eval trial a clean service so trial N can never observe trial N-1's writes —
|
|
* the repeatability property that makes a side-effecting eval trustworthy.
|
|
* `"per-arm"` shares one container across an arm's trials: faster, but only sound
|
|
* when the task is idempotent or reads only.
|
|
*
|
|
* @experimental
|
|
*/
|
|
export type ServiceReset = "per-trial" | "per-arm";
|
|
|
|
/**
|
|
* A disposable service the agent's task may act against — declared per arm/spec
|
|
* on the (future) `services` eval option.
|
|
*
|
|
* @experimental
|
|
*/
|
|
export interface ServiceSpec {
|
|
/** Container image, e.g. `"postgres:16"` or `"redis:7"`. */
|
|
readonly image: string;
|
|
/** Environment for the container (e.g. `POSTGRES_PASSWORD`). */
|
|
readonly env?: Readonly<Record<string, string>>;
|
|
/**
|
|
* The port to publish + reach — the one `ServiceHandle.port` reports and
|
|
* `endpoints` lists. v0 supports a SINGLE port (the common disposable-service
|
|
* case: a DB, a cache); multi-port publishing is deferred rather than half-done
|
|
* (a declared-but-unexposed secondary port would be a misleading state).
|
|
*/
|
|
readonly port?: number;
|
|
/** Readiness gate — start() resolves only once this passes. */
|
|
readonly ready?: ServiceReady;
|
|
/**
|
|
* A one-shot command run INSIDE the fresh container after it is ready and
|
|
* before the agent runs — e.g. `"psql -U postgres -d app -f schema.sql"`. Its
|
|
* output is recorded once; it is NOT model-synthesized.
|
|
*/
|
|
readonly seed?: string;
|
|
/** Provisioning cadence — {@link ServiceReset}. Defaults to `"per-trial"`. */
|
|
readonly reset?: ServiceReset;
|
|
}
|
|
|
|
/**
|
|
* A started service, handed to `measure((ctx) => …)` as `ctx.service(name)` so a
|
|
* check can inspect the REAL resulting state.
|
|
*
|
|
* NOTE: {@link ServiceHandle.exec} is SYNCHRONOUS on purpose — the eval `measure`
|
|
* callback is synchronous (it blocks via `spawnSync`, like `judge()`), so state
|
|
* inspection must be sync too. `exec` is the ONLY inspection primitive by design:
|
|
* it shells the service's own CLI (`psql`, `redis-cli`, …) so vigiles never takes
|
|
* a per-service client dependency.
|
|
*
|
|
* @experimental
|
|
*/
|
|
export interface ServiceHandle {
|
|
/** Host the agent reaches the service on (from inside the run environment). */
|
|
readonly host: string;
|
|
/** The published host port — `undefined` for a service that exposes none. */
|
|
readonly port?: number;
|
|
/**
|
|
* A connection URL when the runtime can form one; omitted otherwise (build your
|
|
* own from `host` + `port` — the scheme differs per service, so no generic URL).
|
|
*/
|
|
readonly url?: string;
|
|
/** Run a command inside the container and read its result (synchronous). */
|
|
exec(command: string): {
|
|
readonly stdout: string;
|
|
readonly stderr: string;
|
|
readonly code: number;
|
|
};
|
|
}
|
|
|
|
/**
|
|
* A live set of started services + the teardown that disposes them. Returned by
|
|
* {@link experimental_startServices}; the caller wires `endpoints` into the run's
|
|
* egress allowlist and maps `handles` into the agent's env / into `measure`.
|
|
*
|
|
* @experimental
|
|
*/
|
|
export interface ServiceSession {
|
|
/** The started services, keyed by the name declared in the `services` map. */
|
|
readonly handles: Readonly<Record<string, ServiceHandle>>;
|
|
/**
|
|
* `host:port` endpoints for the started services — where to point your task.
|
|
* Intended to ALSO pin an egress allowlist (composing with `src/egress.ts`),
|
|
* but that wall is a FUTURE hardening: the eval tier does NOT apply it today, so
|
|
* right now these just identify the services. Do not read them as a network
|
|
* confinement guarantee — see the SAFETY note in this module's header.
|
|
*/
|
|
readonly endpoints: readonly string[];
|
|
/** Stop + remove every started container. Always await this in a `finally`. */
|
|
teardown(): Promise<void>;
|
|
}
|
|
|
|
/**
|
|
* The container-lifecycle PORT — the seam a concrete backend implements (raw
|
|
* `docker` is the planned v0 default; podman / testcontainers / the macOS
|
|
* os-isolation backend swap in by injection, never a conditional). Kept
|
|
* harness-agnostic and dependency-free at the core, exactly like the five
|
|
* harness ports: the abstraction exists so a second backend is a NEW OBJECT, not
|
|
* an edit threaded through the orchestrator.
|
|
*
|
|
* `start` is responsible for HONOURING {@link ServiceSpec.ready} + running
|
|
* {@link ServiceSpec.seed}, so it resolves only with a ready, seeded service.
|
|
*
|
|
* @experimental
|
|
*/
|
|
export interface ContainerRuntime {
|
|
/** Backend name for diagnostics, e.g. `"docker"`. */
|
|
readonly name: string;
|
|
/** Whether this backend can actually run here (e.g. `docker info` succeeds). */
|
|
available(): boolean | Promise<boolean>;
|
|
/** Provision + ready + seed one service, returning its handle. */
|
|
start(name: string, spec: ServiceSpec): Promise<ServiceHandle>;
|
|
/** Stop + remove a started service. */
|
|
stop(handle: ServiceHandle): Promise<void>;
|
|
}
|
|
|
|
/**
|
|
* Start every declared service against an injected {@link ContainerRuntime},
|
|
* returning a {@link ServiceSession}. Pure orchestration over the port — no
|
|
* `docker` is imported here, so this is fully unit-testable with a fake runtime
|
|
* (the `decideSandbox`-is-pure / `runSandboxed`-is-real split). If any service
|
|
* fails to start, the ones already up are torn down before the error propagates,
|
|
* so a partial failure never leaks a container.
|
|
*
|
|
* A Docker-backed `ContainerRuntime` and the `runEval` / `measureArms` wiring are
|
|
* the next increment — until then this is the primitive you compose by hand:
|
|
* start → pin egress to `session.endpoints` → run → read `session.handles` in
|
|
* `measure` → `await session.teardown()`.
|
|
*
|
|
* @experimental — surface may change without a major-version bump.
|
|
*/
|
|
export async function experimental_startServices(
|
|
services: Readonly<Record<string, ServiceSpec>>,
|
|
runtime: ContainerRuntime,
|
|
): Promise<ServiceSession> {
|
|
const handles: Record<string, ServiceHandle> = {};
|
|
const endpoints: string[] = [];
|
|
const started: ServiceHandle[] = [];
|
|
const stopAll = async (): Promise<void> => {
|
|
await Promise.allSettled(started.map((h) => runtime.stop(h)));
|
|
};
|
|
try {
|
|
for (const [name, spec] of Object.entries(services)) {
|
|
const handle = await runtime.start(name, spec);
|
|
handles[name] = handle;
|
|
started.push(handle);
|
|
// Only a service that published a port has a reachable endpoint.
|
|
if (handle.port !== undefined) {
|
|
endpoints.push(`${handle.host}:${handle.port}`);
|
|
}
|
|
}
|
|
} catch (err) {
|
|
await stopAll();
|
|
throw err;
|
|
}
|
|
return { handles, endpoints, teardown: stopAll };
|
|
}
|
|
|
|
/**
|
|
* Run `fn` with the declared services up, disposing them afterwards — even if
|
|
* `fn` throws. The scope-guard form of {@link experimental_startServices}: it
|
|
* removes the manual `try/finally` so a `measureArms` / `measure` call can be
|
|
* wrapped in one line and its containers are always cleaned up.
|
|
*
|
|
* ⚠️ Read the SAFETY note in this module's header first — the container is the
|
|
* only isolation; keep real credentials out of the run.
|
|
*
|
|
* LIFECYCLE NOTE (honest): the services live for the WHOLE `fn` — i.e. per-RUN,
|
|
* not per-trial. An eval that mutates service state across trials should make its
|
|
* task self-contained (e.g. `drop … if exists; create; migrate`) or run
|
|
* `trials: 1`. True per-trial reset needs an eval-loop hook and is the next
|
|
* increment (research/r3-disposable-services.md).
|
|
*
|
|
* @experimental — surface may change without a major-version bump.
|
|
*/
|
|
export async function experimental_withServices<T>(
|
|
services: Readonly<Record<string, ServiceSpec>>,
|
|
runtime: ContainerRuntime,
|
|
fn: (session: ServiceSession) => Promise<T>,
|
|
): Promise<T> {
|
|
const session = await experimental_startServices(services, runtime);
|
|
try {
|
|
return await fn(session);
|
|
} finally {
|
|
await session.teardown();
|
|
}
|
|
}
|