Group the sources into aliased modules and lint with the published plugins
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
import { expect, test } from "vite-plus/test";
|
||||
import { mkdir, readFile, rm, stat, symlink, writeFile } from "node:fs/promises";
|
||||
import { expect, test } from "vite-plus/test";
|
||||
|
||||
import { currentGid, currentUid, IS_WINDOWS, makeTempDir } from "#testing";
|
||||
|
||||
import {
|
||||
codexAuthentication,
|
||||
@@ -9,7 +11,6 @@ import {
|
||||
retryInvalidToken,
|
||||
stageCodexAuth,
|
||||
} from "./auth.ts";
|
||||
import { currentGid, currentUid, IS_WINDOWS, makeTempDir } from "#testing";
|
||||
|
||||
test("retries once after an invalid refresh token", async () => {
|
||||
let attempts = 0;
|
||||
@@ -104,7 +105,7 @@ test("re-logs in after the job 9837 revoked-session stderr", async () => {
|
||||
test("recognizes a rejected Claude OAuth token", () => {
|
||||
expect(
|
||||
isClaudeAuthFailure({
|
||||
api_error_status: 401,
|
||||
apiErrorStatus: 401,
|
||||
result: "Failed to authenticate. API Error: 401 OAuth access token is invalid.",
|
||||
}),
|
||||
).toBe(true);
|
||||
@@ -113,7 +114,7 @@ test("recognizes a rejected Claude OAuth token", () => {
|
||||
result: "OAuth token has expired. Please obtain a new token or refresh your existing token.",
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(isClaudeAuthFailure({ api_error_status: 429, result: "API Error: 429" })).toBe(false);
|
||||
expect(isClaudeAuthFailure({ apiErrorStatus: 429, result: "API Error: 429" })).toBe(false);
|
||||
});
|
||||
|
||||
test("selects API-key login only for a configured Codex token", () => {
|
||||
@@ -12,10 +12,18 @@ import {
|
||||
} from "node:fs/promises";
|
||||
import { dirname, join } from "node:path";
|
||||
|
||||
import { isNotFound } from "./errors.ts";
|
||||
import { isObject } from "./json.ts";
|
||||
import { isNotFound, isObject } from "#common";
|
||||
|
||||
export type CodexAuthentication = { kind: "api-key"; token: string } | { kind: "persistent" };
|
||||
interface CodexApiKey {
|
||||
kind: "api-key";
|
||||
token: string;
|
||||
}
|
||||
|
||||
interface CodexPersistent {
|
||||
kind: "persistent";
|
||||
}
|
||||
|
||||
export type CodexAuthentication = CodexApiKey | CodexPersistent;
|
||||
|
||||
export function codexAuthentication(token?: string): CodexAuthentication {
|
||||
return token === undefined ? { kind: "persistent" } : { kind: "api-key", token };
|
||||
@@ -34,21 +42,27 @@ export function isCodexAuthFailure(error: string): boolean {
|
||||
// stream-json result, e.g. "Failed to authenticate. API Error: 401 OAuth
|
||||
// access token is invalid." with api_error_status 401.
|
||||
// The fields of Claude Code's stream-json `result` event that tell a sign-in
|
||||
// failure apart, under their own names.
|
||||
export type ClaudeFailureFields = Partial<Record<"api_error_status", number | null>> & {
|
||||
// failure apart, as `apiErrorStatus` and `result`.
|
||||
export interface ClaudeFailureFields {
|
||||
apiErrorStatus?: number;
|
||||
result?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export function isClaudeAuthFailure(result: ClaudeFailureFields): boolean {
|
||||
return (
|
||||
result.api_error_status === 401 ||
|
||||
result.apiErrorStatus === 401 ||
|
||||
/failed to authenticate|oauth (?:access )?token (?:is invalid|has expired)|invalid api key|authentication_error|please run \/login|not logged in/iu.test(
|
||||
result.result ?? "",
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export async function retryInvalidToken<T extends { code: number; error: string }>(
|
||||
export interface CommandResult {
|
||||
code: number;
|
||||
error: string;
|
||||
}
|
||||
|
||||
export async function retryInvalidToken<T extends CommandResult>(
|
||||
attempt: () => Promise<T>,
|
||||
relogin: () => Promise<void>,
|
||||
): Promise<T> {
|
||||
@@ -0,0 +1,17 @@
|
||||
// Agent command-line specifics: Codex and Claude Code sign-in handling and
|
||||
// OpenCode's arguments and output.
|
||||
export {
|
||||
codexAuthentication,
|
||||
isClaudeAuthFailure,
|
||||
isCodexAuthFailure,
|
||||
persistCodexAuth,
|
||||
retryInvalidToken,
|
||||
stageCodexAuth,
|
||||
} from "./auth.ts";
|
||||
export type { ClaudeFailureFields, CodexAuthentication, CommandResult } from "./auth.ts";
|
||||
export {
|
||||
collectOpenCodeOutput,
|
||||
openCodeArgs,
|
||||
openCodeError,
|
||||
parseOpenCodeEvents,
|
||||
} from "./opencode.ts";
|
||||
@@ -1,11 +1,13 @@
|
||||
import { assert, expect, test } from "vite-plus/test";
|
||||
|
||||
import { AgentAuthError } from "#profiles";
|
||||
|
||||
import {
|
||||
collectOpenCodeOutput,
|
||||
openCodeArgs,
|
||||
openCodeError,
|
||||
parseOpenCodeEvents,
|
||||
} from "./opencode.ts";
|
||||
import { AgentAuthError } from "./profiles.ts";
|
||||
|
||||
test("builds a non-interactive auto-approved OpenCode command", () => {
|
||||
expect(openCodeArgs("openrouter/qwen/qwen3.8-27b", "Review this")).toStrictEqual([
|
||||
@@ -99,5 +101,5 @@ test("surfaces an OpenCode error event instead of empty output", () => {
|
||||
expect(() => parseOpenCodeEvents(openRouterError(429, "Rate limit exceeded"))).toThrow(
|
||||
"API error 429",
|
||||
);
|
||||
expect(openCodeError('{"type":"text","part":{"text":"ok"}}')).toBe(undefined);
|
||||
expect(openCodeError('{"type":"text","part":{"text":"ok"}}')).toBeNull();
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { isObject } from "./json.ts";
|
||||
import { AgentAuthError } from "./profiles.ts";
|
||||
import { isObject } from "#common";
|
||||
import { AgentAuthError } from "#profiles";
|
||||
|
||||
export function openCodeArgs(model: string, prompt: string): string[] {
|
||||
return ["run", "--format", "json", "--auto", "--model", model, prompt];
|
||||
@@ -7,13 +7,21 @@ export function openCodeArgs(model: string, prompt: string): string[] {
|
||||
|
||||
type ChunkWriter = (chunk: Uint8Array) => void | Promise<void>;
|
||||
|
||||
interface OpenCodeOutput {
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
}
|
||||
|
||||
export async function collectOpenCodeOutput(
|
||||
stdout: AsyncIterable<Uint8Array>,
|
||||
stderr: AsyncIterable<Uint8Array>,
|
||||
writeStdout: ChunkWriter,
|
||||
writeStderr: ChunkWriter,
|
||||
): Promise<{ stdout: string; stderr: string }> {
|
||||
const collect = async (stream: AsyncIterable<Uint8Array>, write: ChunkWriter) => {
|
||||
): Promise<OpenCodeOutput> {
|
||||
const collect = async (
|
||||
stream: AsyncIterable<Uint8Array>,
|
||||
write: ChunkWriter,
|
||||
): Promise<string> => {
|
||||
const chunks: Uint8Array[] = [];
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(chunk);
|
||||
@@ -38,8 +46,8 @@ export async function collectOpenCodeOutput(
|
||||
// OpenCode reports provider failures, including a rejected OpenRouter key
|
||||
// (401 "User not found."), as a JSON error event on stdout and exits 1 with an
|
||||
// empty stderr.
|
||||
export function openCodeError(output: string): Error | undefined {
|
||||
let failure: Error | undefined;
|
||||
export function openCodeError(output: string): Error | null {
|
||||
let failure: Error | null = null;
|
||||
for (const line of output.split("\n")) {
|
||||
if (line.trim() === "") continue;
|
||||
let event: unknown;
|
||||
@@ -69,7 +77,7 @@ export function openCodeError(output: string): Error | undefined {
|
||||
}
|
||||
|
||||
export function parseOpenCodeEvents(output: string): string {
|
||||
let result: string | undefined;
|
||||
let result: string | null = null;
|
||||
for (const line of output.split("\n")) {
|
||||
if (line.trim() === "") continue;
|
||||
const event: unknown = JSON.parse(line);
|
||||
@@ -79,7 +87,7 @@ export function parseOpenCodeEvents(output: string): string {
|
||||
const { text } = part;
|
||||
if (typeof text === "string" && text.trim() !== "") result = text;
|
||||
}
|
||||
if (result === undefined) {
|
||||
if (result === null) {
|
||||
throw openCodeError(output) ?? new Error("OpenCode produced no text output");
|
||||
}
|
||||
return result;
|
||||
@@ -0,0 +1,4 @@
|
||||
// Helpers every phase shares: error and JSON value checks.
|
||||
export { isNotFound } from "./errors.ts";
|
||||
export { isMapping, isObject } from "./json.ts";
|
||||
export type { JsonObject } from "./json.ts";
|
||||
@@ -1,5 +1,5 @@
|
||||
// Type guards for values read from JSON, which arrive as `unknown`.
|
||||
export type JsonObject = Record<string, unknown>;
|
||||
export type JsonObject = NodeJS.Dict<unknown>;
|
||||
|
||||
// Any non-null object, arrays included: reading a missing key yields undefined.
|
||||
export function isObject(value: unknown): value is JsonObject {
|
||||
+20
-10
@@ -1,13 +1,12 @@
|
||||
import { chmod, mkdir, rm, stat, symlink, writeFile } from "node:fs/promises";
|
||||
|
||||
import { assert, expect, test } from "vite-plus/test";
|
||||
|
||||
import { isNotFound } from "./errors.ts";
|
||||
import { postRuntime } from "./post.ts";
|
||||
import type { PostContext } from "./post.ts";
|
||||
import type { RuntimeManifest, RuntimeOutcome } from "./runtime.ts";
|
||||
import { isNotFound } from "#common";
|
||||
import type { RuntimeFailure, RuntimeManifest, RuntimeOutcome } from "#runtime";
|
||||
import { currentGid, currentUid, IS_WINDOWS, makeTempDir } from "#testing";
|
||||
|
||||
import { type Fetcher, type PostContext, postRuntime } from "./post.ts";
|
||||
|
||||
const context: PostContext = {
|
||||
apiUrl: "https://gitea.example/api/v1",
|
||||
repository: "owner/repo",
|
||||
@@ -23,7 +22,18 @@ interface Fixture {
|
||||
outcomeFile: string;
|
||||
}
|
||||
|
||||
type FixtureSource = Extract<RuntimeOutcome, { ok: false }> | { ok: true; body: string };
|
||||
interface FixtureSuccess {
|
||||
ok: true;
|
||||
body: string;
|
||||
}
|
||||
|
||||
type FixtureSource = RuntimeFailure | FixtureSuccess;
|
||||
|
||||
// One request the fake Gitea received.
|
||||
interface RecordedRequest {
|
||||
url: string;
|
||||
init: RequestInit;
|
||||
}
|
||||
|
||||
async function writePrivate(path: string, text: string): Promise<void> {
|
||||
await writeFile(path, text, { mode: 0o600 });
|
||||
@@ -32,8 +42,8 @@ async function writePrivate(path: string, text: string): Promise<void> {
|
||||
|
||||
async function writeArtifacts(
|
||||
outcome: RuntimeOutcome,
|
||||
review: string | undefined,
|
||||
anchors: unknown,
|
||||
review?: string,
|
||||
anchors?: unknown,
|
||||
): Promise<void> {
|
||||
if (!outcome.ok) return;
|
||||
if (review !== undefined) await writePrivate(outcome.reviewPath, review);
|
||||
@@ -107,7 +117,7 @@ function requestBody(init?: RequestInit): unknown {
|
||||
}
|
||||
|
||||
// A fake Gitea that records each request body and answers with `status`.
|
||||
function recordingFetch(bodies: unknown[], status = 201): typeof fetch {
|
||||
function recordingFetch(bodies: unknown[], status = 201): Fetcher {
|
||||
return (_url, init) => {
|
||||
bodies.push(requestBody(init));
|
||||
return Promise.resolve(new Response(null, { status }));
|
||||
@@ -116,7 +126,7 @@ function recordingFetch(bodies: unknown[], status = 201): typeof fetch {
|
||||
|
||||
test("posts a successful issue result and removes runtime credentials", async () => {
|
||||
const result = await fixture({ ok: true, body: "done" });
|
||||
const requests: { url: unknown; init: RequestInit | undefined }[] = [];
|
||||
const requests: RecordedRequest[] = [];
|
||||
|
||||
await postRuntime(result.manifestPath, context, (url, init) => {
|
||||
requests.push({ url, init });
|
||||
|
||||
+8
-8
@@ -3,12 +3,9 @@ import { lstat, readFile, rm } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import process from "node:process";
|
||||
|
||||
import { isNotFound } from "./errors.ts";
|
||||
import { isObject } from "./json.ts";
|
||||
import { failureFooter, withProfile } from "./profiles.ts";
|
||||
import type { FailureReason, ProfileFailure } from "./profiles.ts";
|
||||
import { readRuntimeManifest } from "./runtime.ts";
|
||||
import type { RuntimeManifest, RuntimeOutcome } from "./runtime.ts";
|
||||
import { isNotFound, isObject } from "#common";
|
||||
import { failureFooter, type FailureReason, type ProfileFailure, withProfile } from "#profiles";
|
||||
import { readRuntimeManifest, type RuntimeManifest, type RuntimeOutcome } from "#runtime";
|
||||
|
||||
export interface PostContext {
|
||||
apiUrl: string;
|
||||
@@ -26,7 +23,7 @@ interface Anchor {
|
||||
|
||||
const ANSI = new RegExp(`${String.fromCodePoint(27)}\\[[0-9;]*[A-Za-z]`, "gu");
|
||||
const stripAnsi = (text: string): string => text.replace(ANSI, "");
|
||||
const VERDICTS: Record<string, [event: string, mark: string]> = {
|
||||
const VERDICTS: NodeJS.Dict<[event: string, mark: string]> = {
|
||||
Approved: ["APPROVED", "✅"],
|
||||
"Changes requested": ["REQUEST_CHANGES", "🛑"],
|
||||
};
|
||||
@@ -37,6 +34,9 @@ const isFailureReason = (value: unknown): value is FailureReason => FAILURE_REAS
|
||||
const failureComment = (failures: ProfileFailure[]): string =>
|
||||
`AI bot failed.\n\n${failureFooter(failures)}`;
|
||||
|
||||
// The `fetch` subset the reviewer posts through, which tests replace.
|
||||
export type Fetcher = (url: string, init: RequestInit) => Promise<Response>;
|
||||
|
||||
class GiteaRequestError extends Error {}
|
||||
|
||||
function parseAnchors(text: string, sourcePath: string): Anchor[] {
|
||||
@@ -137,7 +137,7 @@ async function readAgentArtifact(
|
||||
export async function postRuntime(
|
||||
manifestPath: string,
|
||||
context: PostContext,
|
||||
fetcher: typeof fetch = fetch,
|
||||
fetcher: Fetcher = fetch,
|
||||
): Promise<void> {
|
||||
const manifest = await readRuntimeManifest(manifestPath);
|
||||
try {
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
import { appendFile, rm } from "node:fs/promises";
|
||||
import process from "node:process";
|
||||
|
||||
import { prepareRuntime } from "./runtime.ts";
|
||||
import { prepareRuntime } from "#runtime";
|
||||
|
||||
const env = (name: string): string => {
|
||||
const value = process.env[name];
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { spawn } from "node:child_process";
|
||||
|
||||
import { expect, test } from "vite-plus/test";
|
||||
|
||||
import { isNotFound } from "./errors.ts";
|
||||
import { isNotFound } from "#common";
|
||||
|
||||
import { exitCode } from "./child.ts";
|
||||
|
||||
test("reports the exit code", async () => {
|
||||
@@ -0,0 +1,4 @@
|
||||
// Child processes: waiting on and echoing them, and the isolated profile
|
||||
// user's processes and files.
|
||||
export { echo, exitCode } from "./child.ts";
|
||||
export { killProfileProcesses, readProfileFile } from "./isolation.ts";
|
||||
@@ -1,14 +1,14 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import type { SpawnSyncReturns } from "node:child_process";
|
||||
import { expect, test } from "vite-plus/test";
|
||||
import { spawnSync, type SpawnSyncReturns } from "node:child_process";
|
||||
import { chmod, chown, mkdir, readFile, rm, symlink, writeFile } from "node:fs/promises";
|
||||
import process from "node:process";
|
||||
import { expect, test } from "vite-plus/test";
|
||||
|
||||
import { isNotFound } from "./errors.ts";
|
||||
import { isRunnableProfileProcess, killProfileProcesses, readProfileFile } from "./isolation.ts";
|
||||
import type { RuntimeProfile } from "./runtime.ts";
|
||||
import { isNotFound } from "#common";
|
||||
import type { RuntimeProfile } from "#runtime";
|
||||
import { currentUid, IS_ROOT, IS_WINDOWS, makeTempDir } from "#testing";
|
||||
|
||||
import { isRunnableProfileProcess, killProfileProcesses, readProfileFile } from "./isolation.ts";
|
||||
|
||||
const profile = (uid: number, home: string): RuntimeProfile => ({
|
||||
index: uid - 20_000,
|
||||
type: "claude",
|
||||
@@ -25,7 +25,7 @@ function runAs(
|
||||
selected: RuntimeProfile,
|
||||
command: string,
|
||||
args: string[],
|
||||
env?: Record<string, string>,
|
||||
env?: NodeJS.ProcessEnv,
|
||||
): SpawnSyncReturns<string> {
|
||||
return spawnSync(command, args, {
|
||||
cwd: "/",
|
||||
@@ -2,8 +2,8 @@ import { lstat, readdir, readFile } from "node:fs/promises";
|
||||
import process from "node:process";
|
||||
import { setTimeout as sleep } from "node:timers/promises";
|
||||
|
||||
import { isNotFound } from "./errors.ts";
|
||||
import type { RuntimeProfile } from "./runtime.ts";
|
||||
import { isNotFound } from "#common";
|
||||
import type { RuntimeProfile } from "#runtime";
|
||||
|
||||
export function isRunnableProfileProcess(status: string, uid: number): boolean {
|
||||
const owner = /^Uid:\s+(\d+)/mu.exec(status)?.[1];
|
||||
@@ -0,0 +1,12 @@
|
||||
// Bot profiles: their footers, failure reports, ordered runs, and the
|
||||
// environment each agent gets.
|
||||
export {
|
||||
AgentAuthError,
|
||||
agentEnvironment,
|
||||
failureFooter,
|
||||
profileFooter,
|
||||
runProfiles,
|
||||
sanitizedEnvironment,
|
||||
withProfile,
|
||||
} from "./profiles.ts";
|
||||
export type { BotProfile, FailureReason, ProfileFailure, ProfileIdentity } from "./profiles.ts";
|
||||
@@ -1,4 +1,5 @@
|
||||
import { expect, test } from "vite-plus/test";
|
||||
|
||||
import {
|
||||
AgentAuthError,
|
||||
agentEnvironment,
|
||||
@@ -1,22 +1,32 @@
|
||||
export interface BotProfile {
|
||||
// The agent and model a profile runs, which every report names.
|
||||
export interface ProfileIdentity {
|
||||
type: "claude" | "codex" | "opencode";
|
||||
model: string;
|
||||
}
|
||||
|
||||
export interface BotProfile extends ProfileIdentity {
|
||||
token?: string;
|
||||
}
|
||||
|
||||
export function profileFooter(profile: Pick<BotProfile, "type" | "model">): string {
|
||||
export function profileFooter(profile: ProfileIdentity): string {
|
||||
return `> Profile: \`${profile.type}\` · \`${profile.model}\``;
|
||||
}
|
||||
|
||||
export function withProfile(body: string, profile: Pick<BotProfile, "type" | "model">): string {
|
||||
export function withProfile(body: string, profile: ProfileIdentity): string {
|
||||
return `${body}\n\n${profileFooter(profile)}`;
|
||||
}
|
||||
|
||||
export type FailureReason = "sign-in failed" | "usage exhausted" | "failed";
|
||||
|
||||
export type ProfileFailure = Pick<BotProfile, "type" | "model"> & {
|
||||
export interface ProfileFailure extends ProfileIdentity {
|
||||
reason: FailureReason;
|
||||
};
|
||||
}
|
||||
|
||||
// Where a profile's isolated user keeps its home.
|
||||
interface ProfileHome {
|
||||
type: ProfileIdentity["type"];
|
||||
home: string;
|
||||
}
|
||||
|
||||
// Raised once an agent's credentials are rejected and any re-login is spent.
|
||||
export class AgentAuthError extends Error {}
|
||||
@@ -41,7 +51,7 @@ export function failureFooter(failures: ProfileFailure[]): string {
|
||||
|
||||
// Tries the profiles from `index` on, moving on only after a sign-in failure
|
||||
// or exhausted usage, and records every profile it tried.
|
||||
async function attemptFrom<T, P extends Pick<BotProfile, "type" | "model">>(
|
||||
async function attemptFrom<T, P extends ProfileIdentity>(
|
||||
profiles: P[],
|
||||
attempt: (profile: P) => Promise<T>,
|
||||
failures: ProfileFailure[],
|
||||
@@ -62,7 +72,7 @@ async function attemptFrom<T, P extends Pick<BotProfile, "type" | "model">>(
|
||||
}
|
||||
}
|
||||
|
||||
export function runProfiles<T, P extends Pick<BotProfile, "type" | "model">>(
|
||||
export function runProfiles<T, P extends ProfileIdentity>(
|
||||
profiles: P[],
|
||||
attempt: (profile: P) => Promise<T>,
|
||||
failures: ProfileFailure[] = [],
|
||||
@@ -70,8 +80,8 @@ export function runProfiles<T, P extends Pick<BotProfile, "type" | "model">>(
|
||||
return attemptFrom(profiles, attempt, failures, 0);
|
||||
}
|
||||
|
||||
export function sanitizedEnvironment(environment: Record<string, string>): Record<string, string> {
|
||||
const result: Record<string, string> = {};
|
||||
export function sanitizedEnvironment(environment: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
|
||||
const result: NodeJS.ProcessEnv = {};
|
||||
for (const name of [
|
||||
"PATH",
|
||||
"HOME",
|
||||
@@ -105,10 +115,10 @@ export function sanitizedEnvironment(environment: Record<string, string>): Recor
|
||||
}
|
||||
|
||||
export function agentEnvironment(
|
||||
environment: Record<string, string>,
|
||||
profile: Pick<BotProfile, "type"> & { home: string },
|
||||
environment: NodeJS.ProcessEnv,
|
||||
profile: ProfileHome,
|
||||
token?: string,
|
||||
): Record<string, string> {
|
||||
): NodeJS.ProcessEnv {
|
||||
const result = sanitizedEnvironment(environment);
|
||||
result["HOME"] = profile.home;
|
||||
if (profile.type === "codex") result["CODEX_HOME"] = `${profile.home}/.codex`;
|
||||
+45
-43
@@ -10,46 +10,51 @@ import { finished } from "node:stream/promises";
|
||||
import { setTimeout as sleep } from "node:timers/promises";
|
||||
|
||||
import {
|
||||
type ClaudeFailureFields,
|
||||
type CodexAuthentication,
|
||||
codexAuthentication,
|
||||
collectOpenCodeOutput,
|
||||
type CommandResult,
|
||||
isClaudeAuthFailure,
|
||||
isCodexAuthFailure,
|
||||
persistCodexAuth,
|
||||
retryInvalidToken,
|
||||
stageCodexAuth,
|
||||
} from "./auth.ts";
|
||||
import type { ClaudeFailureFields, CodexAuthentication } from "./auth.ts";
|
||||
import { echo, exitCode } from "./child.ts";
|
||||
import { killProfileProcesses, readProfileFile } from "./isolation.ts";
|
||||
import { isObject } from "./json.ts";
|
||||
import {
|
||||
collectOpenCodeOutput,
|
||||
openCodeArgs,
|
||||
openCodeError,
|
||||
parseOpenCodeEvents,
|
||||
} from "./opencode.ts";
|
||||
persistCodexAuth,
|
||||
retryInvalidToken,
|
||||
stageCodexAuth,
|
||||
} from "#agents";
|
||||
import { isObject } from "#common";
|
||||
import { echo, exitCode, killProfileProcesses, readProfileFile } from "#processes";
|
||||
import {
|
||||
AgentAuthError,
|
||||
agentEnvironment,
|
||||
type ProfileFailure,
|
||||
type ProfileIdentity,
|
||||
runProfiles,
|
||||
sanitizedEnvironment,
|
||||
withProfile,
|
||||
} from "./profiles.ts";
|
||||
import type { ProfileFailure } from "./profiles.ts";
|
||||
import { readRuntimeManifest } from "./runtime.ts";
|
||||
import type { RuntimeOutcome, RuntimeProfile } from "./runtime.ts";
|
||||
} from "#profiles";
|
||||
import { readRuntimeManifest, type RuntimeOutcome, type RuntimeProfile } from "#runtime";
|
||||
|
||||
interface AttemptResult {
|
||||
body: string;
|
||||
reviewPath: string;
|
||||
anchorsPath: string;
|
||||
profile: Pick<RuntimeProfile, "type" | "model">;
|
||||
profile: ProfileIdentity;
|
||||
}
|
||||
|
||||
// Claude Code's stream-json `result` event, in its own field names.
|
||||
type ClaudeResult = ClaudeFailureFields &
|
||||
Partial<Record<"is_error", boolean>> & {
|
||||
subtype: string;
|
||||
};
|
||||
// Claude Code's stream-json `result` event, with camelCase field names.
|
||||
interface ClaudeResult extends ClaudeFailureFields {
|
||||
isError?: boolean;
|
||||
subtype: string;
|
||||
}
|
||||
|
||||
interface RenderedPrompt {
|
||||
prompt: string;
|
||||
reviewPath: string;
|
||||
anchorsPath: string;
|
||||
}
|
||||
|
||||
const env = (name: string): string => {
|
||||
const value = process.env[name];
|
||||
@@ -57,8 +62,8 @@ const env = (name: string): string => {
|
||||
return value;
|
||||
};
|
||||
|
||||
function definedEnvironment(): Record<string, string> {
|
||||
const result: Record<string, string> = {};
|
||||
function definedEnvironment(): NodeJS.ProcessEnv {
|
||||
const result: NodeJS.ProcessEnv = {};
|
||||
for (const [name, value] of Object.entries(process.env)) {
|
||||
if (value !== undefined) {
|
||||
result[name] = value;
|
||||
@@ -112,8 +117,8 @@ async function configureGitAuthor(): Promise<void> {
|
||||
await runRoot("git", ["config", "user.email", user["email"]]);
|
||||
}
|
||||
|
||||
async function readProfileToken(profile: RuntimeProfile): Promise<string | undefined> {
|
||||
const token = profile.tokenFile === null ? undefined : await readFile(profile.tokenFile, "utf8");
|
||||
async function readProfileToken(profile: RuntimeProfile): Promise<string | null> {
|
||||
const token = profile.tokenFile === null ? null : await readFile(profile.tokenFile, "utf8");
|
||||
return token;
|
||||
}
|
||||
|
||||
@@ -159,9 +164,7 @@ function formatComment(comment: unknown): string {
|
||||
return `## ${String(login)} at ${String(createdAt)}\n\n${String(body)}\n`;
|
||||
}
|
||||
|
||||
async function renderPrompt(
|
||||
profile: RuntimeProfile,
|
||||
): Promise<{ prompt: string; reviewPath: string; anchorsPath: string }> {
|
||||
async function renderPrompt(profile: RuntimeProfile): Promise<RenderedPrompt> {
|
||||
const reviewPath = await makeProfileFile(profile, "review.md");
|
||||
const anchorsPath = await makeProfileFile(profile, "anchors.json");
|
||||
const comments: unknown = await (
|
||||
@@ -170,7 +173,7 @@ async function renderPrompt(
|
||||
if (!Array.isArray(comments)) {
|
||||
throw new TypeError("comments: unexpected Gitea response");
|
||||
}
|
||||
const values: Record<string, string> = {
|
||||
const values: NodeJS.ProcessEnv = {
|
||||
COMMENT: env("COMMENT"),
|
||||
ISSUE_COMMENTS: comments.map((comment: unknown) => formatComment(comment)).join("\n"),
|
||||
CODE_RULES: await (await gitea(RULES_PATH)).text(),
|
||||
@@ -217,7 +220,7 @@ async function runClaude(profile: RuntimeProfile, token: string, prompt: string)
|
||||
},
|
||||
);
|
||||
const status = exitCode(claude);
|
||||
let result: ClaudeResult | undefined;
|
||||
let result: ClaudeResult | null = null;
|
||||
let error = "";
|
||||
const copyError = (async (): Promise<void> => {
|
||||
claude.stderr.setEncoding("utf8");
|
||||
@@ -243,18 +246,17 @@ async function runClaude(profile: RuntimeProfile, token: string, prompt: string)
|
||||
if (event["type"] === "result") {
|
||||
const { is_error: isError, result: answer, subtype, api_error_status: apiStatus } = event;
|
||||
result = {
|
||||
is_error: typeof isError === "boolean" ? isError : undefined,
|
||||
isError: typeof isError === "boolean" ? isError : undefined,
|
||||
result: typeof answer === "string" ? answer : undefined,
|
||||
subtype: String(subtype),
|
||||
api_error_status:
|
||||
typeof apiStatus === "number" || apiStatus === null ? apiStatus : undefined,
|
||||
apiErrorStatus: typeof apiStatus === "number" ? apiStatus : undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const [code] = await Promise.all([status, copyError]);
|
||||
if (result?.is_error === true) {
|
||||
if (result?.isError === true) {
|
||||
const failure = new Error(result.result ?? `claude ended with ${result.subtype}`);
|
||||
if (isClaudeAuthFailure(result)) {
|
||||
throw new AgentAuthError("claude sign-in failed", { cause: failure });
|
||||
@@ -287,7 +289,7 @@ async function pollForInstructions(
|
||||
|
||||
async function codexDeviceLogin(
|
||||
profile: RuntimeProfile,
|
||||
environment: Record<string, string>,
|
||||
environment: NodeJS.ProcessEnv,
|
||||
): Promise<void> {
|
||||
const login = spawn("codex", ["login", "--device-auth"], {
|
||||
cwd: WORKSPACE,
|
||||
@@ -316,7 +318,7 @@ async function codexDeviceLogin(
|
||||
|
||||
async function codexApiLogin(
|
||||
profile: RuntimeProfile,
|
||||
environment: Record<string, string>,
|
||||
environment: NodeJS.ProcessEnv,
|
||||
token: string,
|
||||
): Promise<void> {
|
||||
const login = spawn("codex", ["login", "--with-api-key"], {
|
||||
@@ -363,7 +365,7 @@ async function runCodex(
|
||||
if ((await exitCode(check)) !== 0) await codexDeviceLogin(profile, environment);
|
||||
}
|
||||
const outputPath = await makeProfileFile(profile, "result.md");
|
||||
const attempt = async (): Promise<{ code: number; error: string }> => {
|
||||
const attempt = async (): Promise<CommandResult> => {
|
||||
const codex = spawn(
|
||||
"codex",
|
||||
[
|
||||
@@ -440,12 +442,12 @@ async function runOpenCode(
|
||||
|
||||
async function runProfile(profile: RuntimeProfile): Promise<AttemptResult> {
|
||||
const token = await readProfileToken(profile);
|
||||
const authentication = profile.type === "codex" ? codexAuthentication(token) : null;
|
||||
const authentication = profile.type === "codex" ? codexAuthentication(token ?? undefined) : null;
|
||||
let stagedCodexAuth: string | null = null;
|
||||
let body: string | undefined;
|
||||
let body: string | null = null;
|
||||
let resultPath: string | null = null;
|
||||
let reviewPath: string | undefined;
|
||||
let anchorsPath: string | undefined;
|
||||
let reviewPath: string | null = null;
|
||||
let anchorsPath: string | null = null;
|
||||
try {
|
||||
if (authentication?.kind === "persistent") {
|
||||
stagedCodexAuth = await stageCodexAuth(CODEX_AUTH, profile.home, profile.uid, profile.gid);
|
||||
@@ -460,7 +462,7 @@ async function runProfile(profile: RuntimeProfile): Promise<AttemptResult> {
|
||||
resultPath = await runCodex(
|
||||
profile,
|
||||
rendered.prompt,
|
||||
authentication ?? codexAuthentication(token),
|
||||
authentication ?? codexAuthentication(token ?? undefined),
|
||||
);
|
||||
} else {
|
||||
body = await runOpenCode(profile, token ?? "", rendered.prompt);
|
||||
@@ -472,7 +474,7 @@ async function runProfile(profile: RuntimeProfile): Promise<AttemptResult> {
|
||||
}
|
||||
}
|
||||
if (resultPath !== null) body = await readProfileFile(profile, resultPath);
|
||||
if (body === undefined || reviewPath === undefined || anchorsPath === undefined) {
|
||||
if (body === null || reviewPath === null || anchorsPath === null) {
|
||||
throw new Error("agent attempt produced no result");
|
||||
}
|
||||
return {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { expect, test } from "vite-plus/test";
|
||||
|
||||
import { decryptCredential, encryptCredential, parseCredentials } from "./credentials.ts";
|
||||
|
||||
const MASTER_KEY = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8";
|
||||
@@ -1,9 +1,15 @@
|
||||
import { webcrypto } from "node:crypto";
|
||||
|
||||
import { isMapping } from "./json.ts";
|
||||
import type { BotProfile } from "./profiles.ts";
|
||||
import { isMapping } from "#common";
|
||||
import type { BotProfile } from "#profiles";
|
||||
|
||||
const DEFAULT_MODELS: Record<BotProfile["type"], string> = {
|
||||
interface DefaultModels {
|
||||
claude: string;
|
||||
codex: string;
|
||||
opencode: string;
|
||||
}
|
||||
|
||||
const DEFAULT_MODELS: DefaultModels = {
|
||||
claude: "claude-sonnet-5",
|
||||
codex: "gpt-5.6-terra",
|
||||
opencode: "openrouter/qwen/qwen3.8-27b",
|
||||
@@ -0,0 +1,10 @@
|
||||
// The runtime the prepare phase writes for the later phases: decrypted
|
||||
// credentials laid out per profile, described by a manifest.
|
||||
export { prepareRuntime, readRuntimeManifest } from "./runtime.ts";
|
||||
export type {
|
||||
RuntimeFailure,
|
||||
RuntimeManifest,
|
||||
RuntimeOutcome,
|
||||
RuntimeProfile,
|
||||
RuntimeSuccess,
|
||||
} from "./runtime.ts";
|
||||
@@ -2,13 +2,12 @@ import { spawnSync } from "node:child_process";
|
||||
import { chmod, readdir, readFile, rm, stat, writeFile } from "node:fs/promises";
|
||||
import process from "node:process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { assert, expect, test } from "vite-plus/test";
|
||||
|
||||
import { prepareRuntime } from "./runtime.ts";
|
||||
import type { RuntimeProfile } from "./runtime.ts";
|
||||
import { IS_ROOT, makeTempDir } from "#testing";
|
||||
|
||||
import { prepareRuntime, type RuntimeProfile } from "./runtime.ts";
|
||||
|
||||
const MASTER_KEY = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8";
|
||||
const ENCRYPTED = "v1:ICEiIyQlJicoKSor:oV_FAgnsN3p1FyegJxwGVdyjfyPio6qDeSUwbA";
|
||||
|
||||
@@ -82,7 +81,7 @@ test.skipIf(!IS_ROOT)("removes prepared credentials when output publication fail
|
||||
);
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[fileURLToPath(new URL("prepare.ts", import.meta.url))],
|
||||
[fileURLToPath(new URL("../prepare.ts", import.meta.url))],
|
||||
{
|
||||
env: {
|
||||
ACTION_PATH: action,
|
||||
@@ -1,19 +1,20 @@
|
||||
import { chmod, chown, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { isMapping } from "#common";
|
||||
import type { ProfileFailure, ProfileIdentity } from "#profiles";
|
||||
|
||||
import { parseCredentials } from "./credentials.ts";
|
||||
import { isMapping } from "./json.ts";
|
||||
import type { BotProfile, ProfileFailure } from "./profiles.ts";
|
||||
|
||||
const FIRST_PROFILE_UID = 20_000;
|
||||
|
||||
export type RuntimeProfile = Pick<BotProfile, "type" | "model"> & {
|
||||
export interface RuntimeProfile extends ProfileIdentity {
|
||||
index: number;
|
||||
uid: number;
|
||||
gid: number;
|
||||
home: string;
|
||||
tokenFile: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
export interface RuntimeManifest {
|
||||
runtimeDir: string;
|
||||
@@ -23,15 +24,20 @@ export interface RuntimeManifest {
|
||||
profiles: RuntimeProfile[];
|
||||
}
|
||||
|
||||
export type RuntimeOutcome =
|
||||
| {
|
||||
ok: true;
|
||||
body: string;
|
||||
reviewPath: string;
|
||||
anchorsPath: string;
|
||||
profile: Pick<RuntimeProfile, "type" | "model">;
|
||||
}
|
||||
| { ok: false; failures: ProfileFailure[] };
|
||||
export interface RuntimeSuccess {
|
||||
ok: true;
|
||||
body: string;
|
||||
reviewPath: string;
|
||||
anchorsPath: string;
|
||||
profile: ProfileIdentity;
|
||||
}
|
||||
|
||||
export interface RuntimeFailure {
|
||||
ok: false;
|
||||
failures: ProfileFailure[];
|
||||
}
|
||||
|
||||
export type RuntimeOutcome = RuntimeSuccess | RuntimeFailure;
|
||||
|
||||
async function writePrivate(path: string, value: string, uid: number, gid: number): Promise<void> {
|
||||
await writeFile(path, value, { mode: 0o600 });
|
||||
@@ -38,7 +38,7 @@ export interface CommandResult {
|
||||
export async function runCommand(
|
||||
command: string,
|
||||
args: string[],
|
||||
env: Record<string, string>,
|
||||
env: NodeJS.ProcessEnv,
|
||||
): Promise<CommandResult> {
|
||||
const child = spawn(command, args, { env, stdio: ["ignore", "ignore", "pipe"] });
|
||||
const [stderr, closed] = await Promise.all([text(child.stderr), once(child, "close")]);
|
||||
|
||||
Reference in New Issue
Block a user