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
28 changes: 24 additions & 4 deletions apps/server/src/factory/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ const logsFor = (
logs.push(messageText(message));
});

yield* Effect.scoped(
const built = yield* Effect.scoped(
Layer.build(
FactoryRuntime.pipe(
Layer.provide(
Expand All @@ -117,13 +117,16 @@ const logsFor = (
Layer.provide(quietEvents),
),
),
).pipe(Effect.provide(Logger.layer([capture], { mergeWithExisting: false })));
).pipe(
Effect.map((context) => Context.get(context, FactoryLoop).status),
Effect.provide(Logger.layer([capture], { mergeWithExisting: false })),
);

const gates = yield* fs
.readDirectory(join(factoryDir, "gates"))
.pipe(Effect.orElseSucceed(() => []));
const events = scanMachineLog(factoryDir).map((entry) => entry.event);
return { logs, machineConfigPath, gates, events };
return { logs, machineConfigPath, gates, events, status: built };
}),
).pipe(Effect.provide(NodeServices.layer));

Expand All @@ -135,16 +138,26 @@ it.effect("logs that there is no config when the machine file does not exist", (
}),
);

// The status goes to every client that can read, so its reason names no path and no error:
// those stay in the server log.
it.effect("reports itself idle, and why, when there is no config", () =>
Effect.gen(function* () {
const { status } = yield* logsFor(undefined);
assert.deepStrictEqual(status, { state: "idle", reason: "no factory config" });
}),
);

it.effect("logs factory started once for a valid machine file, then runs the first pass", () =>
Effect.gen(function* () {
const { logs, events } = yield* logsFor(VALID);
const { logs, events, status } = yield* logsFor(VALID);
assert.strictEqual(logs[0], "factory started");
// The pass found no gh: the repo's config could not be read, and the pass said so and ended.
assert.ok(
logs.some((line) => line.startsWith("error: a/b: project config not loaded")),
logs.join("\n"),
);
assert.strictEqual(logs.at(-1), "pass done: 0 runs, 0 claimed, 1 errors, 0 need attention");
assert.deepStrictEqual(status, { state: "running" });
assert.deepStrictEqual(events, ["pass_started", "repo_skipped", "pass_finished"]);
}),
);
Expand All @@ -161,6 +174,13 @@ it.effect("logs each config error and does not start for an invalid machine file
}),
);

it.effect("reports itself idle, without the config errors, when the machine file is invalid", () =>
Effect.gen(function* () {
const { status } = yield* logsFor(VALID.replace("model: claude-sonnet-5-5\n", ""));
assert.deepStrictEqual(status, { state: "idle", reason: "factory config failed to load" });
}),
);

// ---- attention ----

/** A process runner that records each call: osascript succeeds and every other command fails to start. */
Expand Down
32 changes: 26 additions & 6 deletions apps/server/src/factory/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,11 +53,31 @@ import {
type IssueSummary,
} from "./tracker/github.ts";

/** The running factory, for the code that wakes it (a thread's run changing, a person's request). */
export class FactoryLoop extends Context.Service<FactoryLoop, Loop>()("t3/factory/FactoryLoop") {}
/**
* What the factory is doing: running, or idle for a reason (no config, or one it cannot read).
* `running` means the pass loop started; it says nothing about whether passes succeed, which the
* server log records. The reason names no path and no error, as every reading client gets it;
* the log has the detail.
*/
export type FactoryStatus =
| { readonly state: "running" }
| { readonly state: "idle"; readonly reason: string };

/**
* The running factory, for the code that wakes it (a thread's run changing, a person's request).
* Its `status` is what `GET /api/factory/status` reports.
*/
export class FactoryLoop extends Context.Service<
FactoryLoop,
Loop & { readonly status: FactoryStatus }
>()("t3/factory/FactoryLoop") {}

/** A loop that does nothing: the factory has no config, or one it cannot read. */
const idle: Loop = { wake: Effect.void, awaitPass: Effect.never };
const idle = (reason: string): FactoryLoop["Service"] => ({
wake: Effect.void,
awaitPass: Effect.never,
status: { state: "idle", reason },
});

/**
* How long an issue must have sat in `factory:triage` or `factory:building` with no assignee
Expand Down Expand Up @@ -373,12 +393,12 @@ const startFactory = (options: RuntimeOptions) =>
run: null,
message: `machine config not loaded: ${loaded.failure.errors.map(formatConfigError).join("; ")}`,
});
return idle;
return idle("factory config failed to load");
}
yield* attention.clear((item) => item.id === CONFIG_ITEM_ID);
if (Option.isNone(loaded.success)) {
yield* Effect.logInfo(`no factory config at ${machineConfigPath}`);
return idle;
return idle("no factory config");
}
const machine = loaded.success.value;
yield* attention.setNotify(machine.notify);
Expand Down Expand Up @@ -410,7 +430,7 @@ const startFactory = (options: RuntimeOptions) =>
}).pipe(Effect.ensuring(refreshOwned(factoryDir, owned)));
const loop = yield* runLoop(onePass, machine.pollSeconds);
yield* Effect.forkScoped(wakeOnOwnedThreads(owned, loop, options.restartDelay ?? "5 seconds"));
return loop;
return { ...loop, status: { state: "running" } } satisfies FactoryLoop["Service"];
});

/**
Expand Down
56 changes: 56 additions & 0 deletions apps/server/src/factory/t3/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { EnvironmentAuthenticatedPrincipal, type AuthEnvironmentScope } from "@t3tools/contracts";
import * as Effect from "effect/Effect";
import { HttpServerRequest, type HttpServerResponse } from "effect/http";
import * as HttpRouter from "effect/http/HttpRouter";

import * as EnvironmentAuth from "../../auth/EnvironmentAuth.ts";
import * as AuthHttp from "../../auth/http.ts";

/**
* The factory's routes behind T3's authentication. The factory does not parse tokens or
* cookies: T3 does, through `EnvironmentAuth.authenticateHttpRequest`, the same call the
* middleware of its own HTTP API makes (`AuthHttp.layerAuthenticatedAuth`), so paired, relay and
* tunnel sessions are accepted or refused exactly as there. That middleware is typed against
* HttpApi endpoints and cannot wrap a plain router route, so this does what upstream's own plain
* OTLP proxy route does (`authenticateRawRouteWithScope` in `http.ts`). The contract error classes render themselves, so a failing route
* answers 401, 403 or 500 JSON without a handler.
*/

/**
* Runs `handler` for an authenticated request whose session holds `scope`. A request without a
* session fails 401 and one without the scope fails 403. The handler may read
* `EnvironmentAuthenticatedPrincipal`.
*/
export const withScope = <E, R>(
scope: AuthEnvironmentScope,
handler: Effect.Effect<HttpServerResponse.HttpServerResponse, E, R>,
) =>
Effect.gen(function* () {
const request = yield* HttpServerRequest.HttpServerRequest;
const serverAuth = yield* EnvironmentAuth.EnvironmentAuth;
const session = yield* serverAuth.authenticateHttpRequest(request).pipe(
Effect.catchIf(EnvironmentAuth.isServerAuthCredentialError, (error) =>
AuthHttp.failEnvironmentAuthInvalid(
EnvironmentAuth.serverAuthCredentialReason(error),
EnvironmentAuth.serverAuthDpopFailureReason(error),
),
),
Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) =>
AuthHttp.failEnvironmentInternal("internal_error", error),
),
);
return yield* Effect.andThen(AuthHttp.requireEnvironmentScope(scope), handler).pipe(
Effect.provideService(EnvironmentAuthenticatedPrincipal, {
...session,
scopes: new Set(session.scopes),
}),
);
});

/** A route on T3's router that needs a session holding `scope`. */
export const route = <E, R>(
method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE",
path: `/${string}`,
scope: AuthEnvironmentScope,
handler: Effect.Effect<HttpServerResponse.HttpServerResponse, E, R>,
) => HttpRouter.add(method, path, withScope(scope, handler));
201 changes: 201 additions & 0 deletions apps/server/src/factory/t3/http.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
import * as NodeServices from "@effect/platform-node/NodeServices";
import {
type AuthEnvironmentScope,
AuthOrchestrationReadScope,
AuthSettingsWriteScope,
} from "@t3tools/contracts";
import { assert, it } from "@effect/vitest";
import * as Context from "effect/Context";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as TestClock from "effect/testing/TestClock";
import { HttpServerResponse } from "effect/http";
import * as Etag from "effect/http/Etag";
import * as HttpPlatform from "effect/http/HttpPlatform";
import * as HttpRouter from "effect/http/HttpRouter";
import { describe } from "vite-plus/test";

import * as EnvironmentAuth from "../../auth/EnvironmentAuth.ts";
import * as ServerSecretStore from "../../auth/ServerSecretStore.ts";
import * as ServerConfig from "../../config.ts";
import * as ServerEnvironment from "../../environment/ServerEnvironment.ts";
import * as SqlitePersistence from "../../persistence/Sqlite.ts";
import { FactoryLoop, type FactoryStatus } from "../index.ts";
import { route } from "./auth.ts";
import { FactoryRoutes, STATUS_PATH } from "./http.ts";

const TEST_ROUTE_PATH = "/api/factory/test/settings";

/** A route that needs a scope a standard client lacks, to prove the scope check. */
const testOnlyRoute = route(
"GET",
TEST_ROUTE_PATH,
AuthSettingsWriteScope,
Effect.succeed(HttpServerResponse.empty({ status: 204 })),
);

const layerEnvironmentAuth = EnvironmentAuth.layer.pipe(
Layer.provide(SqlitePersistence.layerMemory),
Layer.provide(ServerSecretStore.layer),
Layer.provide(ServerEnvironment.layerIdentity),
Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-factory-http-test-" })),
Layer.provide(NodeServices.layer),
);

const layerRoutes = Layer.mergeAll(FactoryRoutes, testOnlyRoute).pipe(
Layer.provideMerge(
HttpPlatform.layer.pipe(
Layer.provideMerge(NodeServices.layer),
Layer.provideMerge(Etag.layerWeak),
),
),
);

const loopWith = (status: FactoryStatus) =>
FactoryLoop.of({ wake: Effect.void, awaitPass: Effect.never, status });

/** Runs `use` against the factory's routes behind T3's auth, with a loop reporting `status`. */
const serving = <A>(
status: FactoryStatus,
use: (
get: (path: string, token?: string) => Promise<Response>,
tokens: { readonly read: string; readonly settingsWrite: string },
) => Promise<A>,
) =>
Effect.gen(function* () {
const auth = yield* EnvironmentAuth.EnvironmentAuth;
const context = Context.make(EnvironmentAuth.EnvironmentAuth, auth).pipe(
Context.add(FactoryLoop, loopWith(status)),
);
// Issued on the live clock: the handler checks expiry on it, not on the test clock.
const issue = (scope: AuthEnvironmentScope) =>
auth.issueSession({ scopes: [scope] }).pipe(
TestClock.withLive,
Effect.map((session) => session.token),
);
const tokens = {
read: yield* issue(AuthOrchestrationReadScope),
settingsWrite: yield* issue(AuthSettingsWriteScope),
};
return yield* Effect.acquireUseRelease(
Effect.sync(() => HttpRouter.toWebHandler(layerRoutes, { disableLogger: true })),
({ handler }) =>
Effect.tryPromise(() =>
use(
(path, token) =>
handler(
new Request(`http://127.0.0.1${path}`, {
headers: token === undefined ? {} : { authorization: `Bearer ${token}` },
}),
context,
),
tokens,
),
),
({ dispose }) => Effect.promise(dispose),
);
}).pipe(Effect.provide(layerEnvironmentAuth));

const idle: FactoryStatus = { state: "idle", reason: "no factory config" };

describe("GET /api/factory/status", () => {
it("is served at the path the Factory page calls", () => {
assert.strictEqual(STATUS_PATH, "/api/factory/status");
});

it.effect("answers 401 without credentials", () =>
serving(idle, async (get) => {
const response = await get("/api/factory/status");
assert.strictEqual(response.status, 401);
assert.strictEqual(((await response.json()) as { code: string }).code, "auth_invalid");
}),
);

it.effect("answers 401 for a token that is not a session", () =>
serving(idle, async (get) => {
const response = await get(STATUS_PATH, "not-a-session-token");
assert.strictEqual(response.status, 401);
}),
);

it.effect("needs orchestration:read, the scope every reading client holds", () =>
serving(idle, async (get, tokens) => {
const response = await get(STATUS_PATH, tokens.settingsWrite);
assert.strictEqual(response.status, 403);
assert.strictEqual(((await response.json()) as { code: string }).code, "insufficient_scope");
}),
);

it.effect("reports an idle factory and why", () =>
serving(idle, async (get, tokens) => {
const response = await get("/api/factory/status", tokens.read);
assert.strictEqual(response.status, 200);
assert.deepStrictEqual(await response.json(), idle);
}),
);

it.effect("reports a running factory with no reason", () =>
serving({ state: "running" }, async (get, tokens) => {
const response = await get(STATUS_PATH, tokens.read);
assert.strictEqual(response.status, 200);
assert.deepStrictEqual(await response.json(), { state: "running" });
}),
);
});

describe("an auth failure inside T3", () => {
it.effect("answers 500 internal_error, not 401, and never runs the handler", () => {
let handled = false;
const failingRoute = route(
"GET",
"/api/factory/test/internal",
AuthOrchestrationReadScope,
Effect.sync(() => {
handled = true;
return HttpServerResponse.empty({ status: 204 });
}),
);
return Effect.gen(function* () {
const auth = yield* EnvironmentAuth.EnvironmentAuth;
const context = Context.make(EnvironmentAuth.EnvironmentAuth, {
...auth,
authenticateHttpRequest: () =>
Effect.fail(
new EnvironmentAuth.ServerAuthSessionCredentialValidationError({
cause: new Error("session store unreadable"),
}),
),
}).pipe(Context.add(FactoryLoop, loopWith(idle)));
const layer = failingRoute.pipe(Layer.provideMerge(layerRoutes));
const response = yield* Effect.acquireUseRelease(
Effect.sync(() => HttpRouter.toWebHandler(layer, { disableLogger: true })),
({ handler }) =>
Effect.promise(() =>
handler(new Request("http://127.0.0.1/api/factory/test/internal"), context),
),
({ dispose }) => Effect.promise(dispose),
);
assert.strictEqual(response.status, 500);
const body = (yield* Effect.promise(() => response.json())) as { code: string };
assert.strictEqual(body.code, "internal_error");
assert.isFalse(handled);
}).pipe(Effect.provide(layerEnvironmentAuth));
});
});

describe("the scope a route requires", () => {
it.effect("answers 403 to a session without it", () =>
serving(idle, async (get, tokens) => {
const response = await get(TEST_ROUTE_PATH, tokens.read);
assert.strictEqual(response.status, 403);
assert.strictEqual(((await response.json()) as { code: string }).code, "insufficient_scope");
}),
);

it.effect("lets a session with it through", () =>
serving(idle, async (get, tokens) => {
const response = await get(TEST_ROUTE_PATH, tokens.settingsWrite);
assert.strictEqual(response.status, 204);
}),
);
});
Loading