Compare commits

..
50 changed files with 661 additions and 1468 deletions

No files matched your search

-5
View File
@@ -262,11 +262,6 @@ in the bundle differs from what the export writes for it. A reset
relaunches the app, and REA waits until the computer is ready again before relaunches the app, and REA waits until the computer is ready again before
answering, sending progress notifications while it waits. answering, sending progress notifications while it waits.
Re-export bundles whose generated verifier uses an older evaluator before
witnessing them with current REA. The verifier must match the current export
exactly; re-exporting uses the existing capture Evidence and task inputs,
which remain unchanged.
Only a check that fails on what the app's view shows counts as a failure. A Only a check that fails on what the app's view shows counts as a failure. A
check that fails because its file or table could not be read, such as an check that fails because its file or table could not be read, such as an
SQLite file locked past the reader's wait, or because what it looks for may SQLite file locked past the reader's wait, or because what it looks for may
@@ -1,38 +0,0 @@
import { chmod, mkdir, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { expect, it } from "vitest";
import { createTestTempDirectory } from "../../../tests/fixtures/temporaryDirectory.js";
import { seededLockFiles } from "./DesktopBuildBundle.js";
it.skipIf(process.platform === "win32" || process.getuid?.() === 0)(
"keeps lock inspection failures separate from empty and partially examined seeds",
async () => {
const root = await createTestTempDirectory("rea-seeded-locks-");
expect(await seededLockFiles(root)).toEqual({ locks: [], unreadable: [] });
const envs = join(root, "envs");
const denied = join(envs, "denied", "seed");
const available = join(envs, "available", "seed");
await mkdir(denied, { recursive: true });
await mkdir(available, { recursive: true });
await writeFile(join(available, "SingletonLock"), "controlled lock");
await chmod(denied, 0);
try {
const found = await seededLockFiles(root);
expect(found.locks).toEqual(["envs/available/seed/SingletonLock"]);
expect(found.unreadable).toEqual([
{ path: denied, reason: expect.stringContaining("EACCES") },
]);
} finally {
await chmod(denied, 0o700);
}
await chmod(envs, 0);
try {
expect(await seededLockFiles(root)).toEqual({
locks: [],
unreadable: [{ path: envs, reason: expect.stringContaining("EACCES") }],
});
} finally {
await chmod(envs, 0o700);
}
},
);
+6 -44
View File
@@ -20,9 +20,6 @@ import {
import { isLockFile } from "../../domain/desktop/appExportUnowned.js"; import { isLockFile } from "../../domain/desktop/appExportUnowned.js";
import { AGENTENV_PROVIDER } from "../../desktop/AgentEnvComputerHelper.js"; import { AGENTENV_PROVIDER } from "../../desktop/AgentEnvComputerHelper.js";
import { PrivateRuntimeRoot } from "../../process/PrivateRuntimeRoot.js"; import { PrivateRuntimeRoot } from "../../process/PrivateRuntimeRoot.js";
import type { ArtifactResourceScope } from "../../artifacts/ArtifactResourceScope.js";
import { bindArtifactFileCleanup } from "../../artifacts/ArtifactFileOperation.js";
import { ProviderAdapterError } from "../../domain/providerAdapterError.js";
const START = "start_build_computer"; const START = "start_build_computer";
const MAX_KEY_BYTES = 1024 * 1024; const MAX_KEY_BYTES = 1024 * 1024;
@@ -79,7 +76,6 @@ const readAptKey = async (path: string): Promise<Buffer> => {
const writeBuildBundle = async ( const writeBuildBundle = async (
request: BuildRequest, request: BuildRequest,
payload: ReaPayload, payload: ReaPayload,
resources: ArtifactResourceScope,
): Promise<{ ): Promise<{
readonly workspace: PrivateRuntimeRoot; readonly workspace: PrivateRuntimeRoot;
readonly bundle: string; readonly bundle: string;
@@ -107,23 +103,7 @@ const writeBuildBundle = async (
} }
return { workspace, bundle, name }; return { workspace, bundle, name };
} catch (cause: unknown) { } catch (cause: unknown) {
const released = await resources.release({ await workspace.close();
kind: "private-runtime-root",
resource: workspace.path,
root: workspace,
});
if (released.kind === "failed") {
const cleanup = resources.cleanupFailure({ kind: "failed", cause }, [
released,
]);
const error = new ProviderAdapterError(AGENTENV_PROVIDER, START, {
cause,
userMessage: cause instanceof Error ? cause.message : String(cause),
cleanup: cleanup.cleanup,
});
bindArtifactFileCleanup(cleanup, error);
throw error;
}
throw cause; throw cause;
} }
}; };
@@ -135,8 +115,7 @@ const writeBuildBundle = async (
export const writeRequestBundle = async ( export const writeRequestBundle = async (
request: BuildRequest, request: BuildRequest,
environment: Readonly<NodeJS.ProcessEnv>, environment: Readonly<NodeJS.ProcessEnv>,
resources: ArtifactResourceScope, ) => writeBuildBundle(request, await reaPayload(environment));
) => writeBuildBundle(request, await reaPayload(environment), resources);
/** /**
* The lock files in a bundle folder's seeds, as `envs/<name>/seed/<home * The lock files in a bundle folder's seeds, as `envs/<name>/seed/<home
@@ -146,33 +125,16 @@ export const writeRequestBundle = async (
*/ */
export const seededLockFiles = async ( export const seededLockFiles = async (
bundle: string, bundle: string,
): Promise<{ ): Promise<readonly string[]> => {
readonly locks: readonly string[];
readonly unreadable: readonly {
readonly path: string;
readonly reason: string;
}[];
}> => {
const environments = join(bundle, "envs"); const environments = join(bundle, "envs");
const unreadable: { path: string; reason: string }[] = []; const names = await readdir(environments).catch(() => []);
const unavailable = (path: string, cause: unknown) => {
if (!(cause instanceof Error && "code" in cause && cause.code === "ENOENT"))
unreadable.push({
path,
reason: cause instanceof Error ? cause.message : String(cause),
});
return [];
};
const names = await readdir(environments).catch((cause: unknown) =>
unavailable(environments, cause),
);
const locks: string[] = []; const locks: string[] = [];
for (const name of names) { for (const name of names) {
const seed = join(environments, name, "seed"); const seed = join(environments, name, "seed");
const entries = await readdir(seed, { const entries = await readdir(seed, {
recursive: true, recursive: true,
withFileTypes: true, withFileTypes: true,
}).catch((cause: unknown) => unavailable(seed, cause)); }).catch(() => []);
const homes = new Set( const homes = new Set(
entries entries
.filter((entry) => entry.isFile()) .filter((entry) => entry.isFile())
@@ -185,5 +147,5 @@ export const seededLockFiles = async (
for (const home of homes) for (const home of homes)
if (isLockFile(home, [], homes)) locks.push(`envs/${name}/seed/${home}`); if (isLockFile(home, [], homes)) locks.push(`envs/${name}/seed/${home}`);
} }
return { locks: locks.sort(), unreadable }; return locks.sort();
}; };
@@ -1,157 +1,11 @@
import { expect, it, vi } from "vitest"; import { expect, it, vi } from "vitest";
import { access, chmod, mkdir, writeFile } from "node:fs/promises"; import { AgentEnvComputer } from "../../desktop/AgentEnvComputer.js";
import { join } from "node:path";
import { createTestTempDirectory } from "../../../tests/fixtures/temporaryDirectory.js";
import { request } from "../../domain/desktop/appBuild.fixture.js";
import { PrivateRuntimeRoot } from "../../process/PrivateRuntimeRoot.js";
import {
AgentEnvComputer,
ComputerStartFailure,
} from "../../desktop/AgentEnvComputer.js";
import { ComputerHelper } from "../../desktop/AgentEnvComputerHelper.js"; import { ComputerHelper } from "../../desktop/AgentEnvComputerHelper.js";
import { ScreenJudgeProcess } from "../../desktop/ScreenJudgeProcess.js"; import { ScreenJudgeProcess } from "../../desktop/ScreenJudgeProcess.js";
import { BuildComputerLongRuns } from "../../desktop/BuildComputerLongRuns.js"; import { BuildComputerLongRuns } from "../../desktop/BuildComputerLongRuns.js";
import { DesktopBuildComputers } from "./DesktopBuildComputers.js"; import { DesktopBuildComputers } from "./DesktopBuildComputers.js";
import { DesktopRemoteTaskReaders } from "./DesktopRemoteTaskReaders.js"; import { DesktopRemoteTaskReaders } from "./DesktopRemoteTaskReaders.js";
it("resolves the original failed start's cleanup after its retained computer closes", async () => {
const release = vi
.fn()
.mockResolvedValueOnce({
verified: false,
reason: "container busy",
resources: ["sandbox"],
})
.mockResolvedValue({ verified: true, teardown: "agentenv" });
const spawn = vi.spyOn(ComputerHelper, "spawn").mockResolvedValue({
release,
output: () => undefined,
} as never);
const connect = vi
.spyOn(AgentEnvComputer, "connect")
.mockRejectedValue(new Error("readiness failed"));
const computers = new DesktopBuildComputers({
REA_AGENT_ENV_COMMAND: "/runner",
REA_DOCKER_COMMAND: "/docker",
});
try {
const started = await computers.start(
{ source: { kind: "bundle", path: "/bundle" } },
new AbortController().signal,
);
if (started.ok) throw new Error("Expected failed start");
const id = started.error.diagnostics?.computer_id;
if (typeof id !== "string")
throw new Error("Expected retained computer id");
expect(started.error.cleanupIncomplete).toBe(true);
expect(started.error.cleanupResources).toEqual(["sandbox"]);
expect((await computers.close(id)).ok).toBe(true);
expect(started.error.cleanupIncomplete).toBe(false);
expect(started.error.cleanupResources).toEqual([]);
expect(started.error.userMessage).toBe("readiness failed");
} finally {
await computers.closeAll();
spawn.mockRestore();
connect.mockRestore();
}
});
it.skipIf(process.platform === "win32" || process.getuid?.() === 0)(
"preserves a readiness failure while reporting that seed locks could not be checked",
async () => {
const bundle = await createTestTempDirectory("rea-unreadable-build-seed-");
const envs = join(bundle, "envs");
await mkdir(envs);
await chmod(envs, 0);
const original = new ComputerStartFailure(
"Application readiness failed",
"Repair the app",
{ reason: "not_ready" },
);
const spawn = vi.spyOn(ComputerHelper, "spawn").mockResolvedValue({
release: async () => ({ verified: true, teardown: "agentenv" }),
output: () => undefined,
} as never);
const connect = vi
.spyOn(AgentEnvComputer, "connect")
.mockRejectedValue(original);
const computers = new DesktopBuildComputers({
REA_AGENT_ENV_COMMAND: "/runner",
REA_DOCKER_COMMAND: "/docker",
});
try {
const started = await computers.start(
{ source: { kind: "bundle", path: bundle } },
new AbortController().signal,
);
expect(started.ok).toBe(false);
if (started.ok) throw new Error("Expected readiness failure");
expect(started.error.userMessage).toContain(original.message);
expect(started.error.userMessage).toContain("could not check");
expect(started.error.cause).toMatchObject({
reason: "not_ready",
cause: original,
});
} finally {
await chmod(envs, 0o700);
await computers.closeAll();
spawn.mockRestore();
connect.mockRestore();
}
},
);
it("retains a failed bundle workspace and its write error until shutdown retries cleanup", async () => {
const folder = await createTestTempDirectory("rea-build-bundle-cleanup-");
const payload = join(folder, "payload.tar");
await writeFile(payload, "controlled payload");
const create = PrivateRuntimeRoot.create.bind(PrivateRuntimeRoot);
let workspace: PrivateRuntimeRoot | undefined;
const created = vi
.spyOn(PrivateRuntimeRoot, "create")
.mockImplementation(async (options) => {
workspace = await create(options);
await writeFile(join(workspace.path, "bundle"), "blocks bundle creation");
vi.spyOn(workspace, "close").mockRejectedValueOnce(
new Error("workspace removal failed"),
);
return workspace;
});
const computers = new DesktopBuildComputers({
REA_AGENT_ENV_COMMAND: "/runner",
REA_DOCKER_COMMAND: "/docker",
REA_BUILD_COMPUTER_REA_ARCHIVE: payload,
});
try {
const started = await computers.start(
{
source: request({
kind: "archive",
package: "mousepad",
extra_packages: [],
}),
},
new AbortController().signal,
);
expect(started.ok).toBe(false);
if (started.ok || workspace === undefined)
throw new Error("Expected bundle creation failure");
expect(started.error.userMessage).toContain("ENOTDIR");
expect(started.error.cleanup?.reason).toContain("workspace removal failed");
expect(started.error.cleanupResources).toEqual([workspace.path]);
await expect(access(workspace.path)).resolves.toBeUndefined();
await computers.closeAll();
await expect(access(workspace.path)).rejects.toMatchObject({
code: "ENOENT",
});
expect(started.error.cleanupIncomplete).toBe(false);
} finally {
created.mockRestore();
await computers.closeAll().catch(() => computers.closeAll());
await workspace?.close();
}
});
it.each([false, true])( it.each([false, true])(
"releases the computer even if judge cleanup fails (computer also fails: %s)", "releases the computer even if judge cleanup fails (computer also fails: %s)",
async (computerFails) => { async (computerFails) => {
@@ -106,23 +106,11 @@ interface HeldComputer {
readonly turns: DesktopBuildTurns; readonly turns: DesktopBuildTurns;
readonly cleanup: { readonly cleanup: {
resources: readonly string[]; resources: readonly string[];
errors: Set<WeakRef<AnalysisError>>; errors: Set<WeakRef<ProviderCleanupError>>;
}; };
closing: boolean; closing: boolean;
} }
/** Bind every returned teardown failure to the computer retained for retry. */
const bindCleanupFailure = (held: HeldComputer, error: AnalysisError): void => {
error.bindCleanupResourceOwner({
get cleanupResources() {
return held.cleanup.resources;
},
});
for (const reference of held.cleanup.errors)
if (reference.deref() === undefined) held.cleanup.errors.delete(reference);
held.cleanup.errors.add(new WeakRef(error));
};
/** What the build step tools work on: a live computer, its capture, its witness and the turns they take. */ /** What the build step tools work on: a live computer, its capture, its witness and the turns they take. */
export interface BuildComputerSteps { export interface BuildComputerSteps {
readonly computer: AgentEnvComputer; readonly computer: AgentEnvComputer;
@@ -337,21 +325,13 @@ const withSeededLocks = async (
): Promise<unknown> => { ): Promise<unknown> => {
if (source.kind !== "bundle" || !computerFailedOnReadiness(cause)) if (source.kind !== "bundle" || !computerFailedOnReadiness(cause))
return cause; return cause;
const { locks, unreadable } = await seededLockFiles(source.path); const locks = await seededLockFiles(source.path);
const notices = [ return locks.length === 0
...(locks.length === 0 ? [] : [seededLockStartNotice(locks)]),
...(unreadable.length === 0
? []
: [
`REA could not check these seed folders for lock files: ${unreadable.map(({ path, reason }) => `${path}: ${reason}`).join("; ")}. Other lock files remain unknown.`,
]),
];
return notices.length === 0
? cause ? cause
: new ComputerStartFailure( : new ComputerStartFailure(
`${cause.message} ${notices.join(" ")}`, `${cause.message} ${seededLockStartNotice(locks)}`,
cause.remediation, cause.remediation,
{ cause, reason: cause.reason }, { cause: cause.cause, reason: cause.reason },
); );
}; };
@@ -486,7 +466,7 @@ export class DesktopBuildComputers {
target = target =
source.kind === "bundle" source.kind === "bundle"
? { bundle: source.path, name: source.path } ? { bundle: source.path, name: source.path }
: await writeRequestBundle(source, environment, this.#work.resources); : await writeRequestBundle(source, environment);
} catch (cause: unknown) { } catch (cause: unknown) {
return err(failure(START, cause, signal)); return err(failure(START, cause, signal));
} }
@@ -565,16 +545,9 @@ export class DesktopBuildComputers {
const failed = signal.aborted const failed = signal.aborted
? cause ? cause
: await withSeededLocks(source, cause); : await withSeededLocks(source, cause);
const error = startFailure(failed, signal, owner?.output(), { return err(
id, startFailure(failed, signal, owner?.output(), { id, release }),
release, );
});
const retained = this.#computers.get(id);
if (!release.verified && retained !== undefined) {
retained.cleanup.resources = release.resources;
bindCleanupFailure(retained, error);
}
return err(error);
} }
} }
@@ -726,7 +699,15 @@ export class DesktopBuildComputers {
{ reason: release.reason }, { reason: release.reason },
{ operation: CLOSE, remediationAction: release.reason }, { operation: CLOSE, remediationAction: release.reason },
); );
bindCleanupFailure(held, failure); failure.bindCleanupResourceOwner({
get cleanupResources() {
return held.cleanup.resources;
},
});
for (const reference of held.cleanup.errors)
if (reference.deref() === undefined)
held.cleanup.errors.delete(reference);
held.cleanup.errors.add(new WeakRef(failure));
return err(failure); return err(failure);
} }
@@ -1,7 +1,3 @@
import * as buildEnvironment from "../../desktop/BuildComputerEnvironment.js";
import { ArtifactResourceScope } from "../../artifacts/ArtifactResourceScope.js";
import { renderSmokeTask } from "../../domain/desktop/appExport.js";
import { MEASURE_BUILD_TASK_CONTRACT } from "../../contracts/desktopBuildMeasureContract.js";
import { import {
guestFileAnswerCeiling, guestFileAnswerCeiling,
guestFileAnswerWorkingBytes, guestFileAnswerWorkingBytes,
@@ -22,11 +18,7 @@ import {
import { createTestTempDirectory } from "../../../tests/fixtures/temporaryDirectory.js"; import { createTestTempDirectory } from "../../../tests/fixtures/temporaryDirectory.js";
import type { SolveReport } from "../../desktop/AgentEnvSolveHelper.js"; import type { SolveReport } from "../../desktop/AgentEnvSolveHelper.js";
import { import { measureBuildTask, type Solver } from "./DesktopBuildSolves.js";
measureBuildTask,
measureInFreshComputers,
type Solver,
} from "./DesktopBuildSolves.js";
/** A bundle holding stand-in steps and verifiers for these tasks, and a README. */ /** A bundle holding stand-in steps and verifiers for these tasks, and a README. */
const bundleWith = async (...tasks: string[]) => { const bundleWith = async (...tasks: string[]) => {
@@ -430,124 +422,3 @@ it("retains completed solves when the destination becomes unreadable before publ
"invalid", "invalid",
); );
}); });
it.each(["../../outside", "nested/task", "nested\\task", ".", ".."])(
"refuses invalid selected task names before any file read or solve: %s",
async (task) => {
const read = vi
.spyOn(hostFiles, "readHostFile")
.mockRejectedValue(new Error("invalid task must not reach filesystem"));
const solve = vi.fn<Solver>();
try {
const result = await measureBuildTask(
request("/unused-bundle", task),
solve,
new AbortController().signal,
);
expect(result).toMatchObject({
ok: false,
error: { _tag: "AnalysisInputError", issues: [{ path: ["task"] }] },
});
expect(read).not.toHaveBeenCalled();
expect(solve).not.toHaveBeenCalled();
} finally {
read.mockRestore();
}
},
);
it("keeps the portable smoke name selectable independently of authoring reservations", async () => {
const bundle = await bundleWith("smoke");
const { solver, calls } = counting();
const result = await measureBuildTask(
request(bundle, "smoke"),
solver,
new AbortController().signal,
);
expect(result.ok).toBe(true);
expect(calls).toEqual(["smoke", "smoke", "smoke"]);
});
it("refuses invalid measurement identity before resolving the fresh-computer environment", async () => {
const environment = vi
.spyOn(buildEnvironment, "resolveBuildComputerEnvironment")
.mockRejectedValue(
new Error("invalid task must not resolve an environment"),
);
const read = vi.spyOn(hostFiles, "readHostFile");
const resources = new ArtifactResourceScope();
try {
const result = await measureInFreshComputers(
request("/unused-bundle", "../../outside"),
{
environment: {},
defaults: {},
retainCleanup: () => undefined,
resources,
},
new AbortController().signal,
);
expect(result).toMatchObject({
ok: false,
error: { _tag: "AnalysisInputError", issues: [{ path: ["task"] }] },
});
expect(environment).not.toHaveBeenCalled();
expect(read).not.toHaveBeenCalled();
} finally {
environment.mockRestore();
read.mockRestore();
await resources.close();
}
});
it("advertises the same portable lookup names without changing their spelling", () => {
for (const task of ["add-eggs", "123-task", "a".repeat(63), "smoke"])
expect(
MEASURE_BUILD_TASK_CONTRACT.inputSchema.parse(
request("/unused-bundle", task),
).task,
).toBe(task);
for (const task of [
"../../outside",
"nested/task",
"nested\\task",
".",
"..",
"con",
"123",
"a".repeat(64),
])
expect(
MEASURE_BUILD_TASK_CONTRACT.inputSchema.safeParse(
request("/unused-bundle", task),
).success,
).toBe(false);
});
it("keeps a generated deploy-only smoke task inapplicable without its measurement verifier", async () => {
const bundle = await createTestTempDirectory("measured-smoke-");
await mkdir(join(bundle, "tasks"));
await mkdir(join(bundle, "artifacts", "check_app"), { recursive: true });
await writeFile(
join(bundle, "tasks", "smoke.json"),
renderSmokeTask({ name: "app" }),
);
await writeFile(
join(bundle, "artifacts", "check_app", "verify.py"),
"# generated deployment check\n",
);
const solve = vi.fn<Solver>();
const result = await measureBuildTask(
request(bundle, "smoke"),
solve,
new AbortController().signal,
);
expect(result).toMatchObject({
ok: false,
error: {
_tag: "AnalysisInputError",
userMessage: expect.stringContaining("artifacts/smoke-check/verify.py"),
},
});
expect(solve).not.toHaveBeenCalled();
});
+10 -37
View File
@@ -1,7 +1,3 @@
import {
parsePortableBundleName,
PORTABLE_BUNDLE_NAME_CONSTRAINT,
} from "../../domain/portableBundleName.js";
import { createHash } from "node:crypto"; import { createHash } from "node:crypto";
import { ArtifactResourceScope } from "../../artifacts/ArtifactResourceScope.js"; import { ArtifactResourceScope } from "../../artifacts/ArtifactResourceScope.js";
import { import {
@@ -93,24 +89,6 @@ const refused = (userMessage: string, remediationAction: string) =>
conformsToInputContract: true, conformsToInputContract: true,
}); });
/** Refuse malformed lookup identity before reading files or resolving a solver. */
const admitTaskName = (name: string): Result<string, AnalysisError> => {
const parsed = parsePortableBundleName(name);
if (parsed.ok) return parsed;
const message = `Task ${JSON.stringify(name)} must be ${PORTABLE_BUNDLE_NAME_CONSTRAINT}.`;
return err(
new AnalysisInputError(
MEASURE,
{
userMessage: message,
remediationAction:
"Select the exact portable task name written under the bundle's tasks directory.",
},
[{ path: ["task"], reason: "invalid_value", message }],
),
);
};
/** The records a difficulty file holds, each read only as far as the README names it. */ /** The records a difficulty file holds, each read only as far as the README names it. */
const recordsSchema = z.looseObject({ const recordsSchema = z.looseObject({
tasks: z.record( tasks: z.record(
@@ -293,16 +271,13 @@ export const measureBuildTask = async (
progress: ProgressReporter = silentProgressReporter, progress: ProgressReporter = silentProgressReporter,
resources: ArtifactResourceScope = new ArtifactResourceScope(), resources: ArtifactResourceScope = new ArtifactResourceScope(),
): Promise<Result<MeasuredTask, AnalysisError>> => { ): Promise<Result<MeasuredTask, AnalysisError>> => {
const taskName = admitTaskName(request.task);
if (!taskName.ok) return taskName;
const task = taskName.value;
const solves: SolveRecord[] = []; const solves: SolveRecord[] = [];
const retainSolves = (failure: AnalysisError): AnalysisError => { const retainSolves = (failure: AnalysisError): AnalysisError => {
if (solves.length > 0) if (solves.length > 0)
failure.retainPartialObservation({ failure.retainPartialObservation({
kind: "partial-task-measurement", kind: "partial-task-measurement",
bundle: request.bundle, bundle: request.bundle,
task: task, task: request.task,
solves: [...solves], solves: [...solves],
}); });
return failure; return failure;
@@ -310,7 +285,7 @@ export const measureBuildTask = async (
let agentenv: string | null = null; let agentenv: string | null = null;
let stopped: StopReason = "decided"; let stopped: StopReason = "decided";
try { try {
const digest = await taskDigest(request.bundle, task, signal); const digest = await taskDigest(request.bundle, request.task, signal);
if (!digest.ok) return digest; if (!digest.ok) return digest;
// Refuse records REA could not keep before any solve is paid for. // Refuse records REA could not keep before any solve is paid for.
const records = await readRecords(request.bundle, signal); const records = await readRecords(request.bundle, signal);
@@ -326,15 +301,15 @@ export const measureBuildTask = async (
phase: MEASURE, phase: MEASURE,
completed: solves.length, completed: solves.length,
total: null, total: null,
message: `solve ${String(solves.length + 1)} of ${task} with ${request.model}`, message: `solve ${String(solves.length + 1)} of ${request.task} with ${request.model}`,
}) })
.catch(() => undefined); .catch(() => undefined);
const report = await solver({ ...request, task }, signal); const report = await solver(request, signal);
if (!("outcome" in report)) if (!("outcome" in report))
return err( return err(
retainSolves( retainSolves(
refused( refused(
`A solve of ${task} did not run: ${report.error}`, `A solve of ${request.task} did not run: ${report.error}`,
"Fix what the error names, such as the model endpoint agentenv needs (LITELLM_BASE_URL and LITELLM_API_KEY where REA starts, or [model] in .agentenv/config.toml), and measure again.", "Fix what the error names, such as the model endpoint agentenv needs (LITELLM_BASE_URL and LITELLM_API_KEY where REA starts, or [model] in .agentenv/config.toml), and measure again.",
), ),
), ),
@@ -354,7 +329,7 @@ export const measureBuildTask = async (
}); });
const file = await recordMeasure( const file = await recordMeasure(
request.bundle, request.bundle,
task, request.task,
record, record,
signal, signal,
resources, resources,
@@ -363,7 +338,7 @@ export const measureBuildTask = async (
const { versions: _versions, ...reported } = record; const { versions: _versions, ...reported } = record;
return ok({ return ok({
bundle: request.bundle, bundle: request.bundle,
task: task, task: request.task,
...reported, ...reported,
difficulty_file: file.value, difficulty_file: file.value,
}); });
@@ -399,7 +374,7 @@ export const measureBuildTask = async (
cleanup, cleanup,
}) })
: new ProviderAdapterError(AGENTENV_PROVIDER, MEASURE, { : new ProviderAdapterError(AGENTENV_PROVIDER, MEASURE, {
userMessage: `Measuring ${task} could not close an input file.`, userMessage: `Measuring ${request.task} could not close an input file.`,
cause, cause,
cleanup, cleanup,
}); });
@@ -412,7 +387,7 @@ export const measureBuildTask = async (
: signal.aborted : signal.aborted
? new AnalysisCancelledError(MEASURE, { cause: signal.reason }) ? new AnalysisCancelledError(MEASURE, { cause: signal.reason })
: new ProviderAdapterError(AGENTENV_PROVIDER, MEASURE, { : new ProviderAdapterError(AGENTENV_PROVIDER, MEASURE, {
userMessage: `Measuring ${task} failed after ${String(solves.length)} solve(s): ${errorText(cause)}`, userMessage: `Measuring ${request.task} failed after ${String(solves.length)} solve(s): ${errorText(cause)}`,
remediationAction: remediationAction:
"Check the bundle files and host I/O error, then retry or retain the completed solve results.", "Check the bundle files and host I/O error, then retry or retain the completed solve results.",
cause, cause,
@@ -433,15 +408,13 @@ export const measureInFreshComputers = async (
signal: AbortSignal, signal: AbortSignal,
progress?: ProgressReporter, progress?: ProgressReporter,
): Promise<Result<MeasuredTask, AnalysisError>> => { ): Promise<Result<MeasuredTask, AnalysisError>> => {
const taskName = admitTaskName(request.task);
if (!taskName.ok) return taskName;
const environment = await resolveBuildComputerEnvironment( const environment = await resolveBuildComputerEnvironment(
host.environment, host.environment,
process.platform, process.platform,
host.defaults, host.defaults,
); );
return measureBuildTask( return measureBuildTask(
{ ...request, task: taskName.value }, request,
(solve, turn) => (solve, turn) =>
runSolveHelper({ runSolveHelper({
...solve, ...solve,
@@ -1,4 +1,4 @@
import { access, mkdir, stat, writeFile } from "node:fs/promises"; import { mkdir, stat, writeFile } from "node:fs/promises";
import { join } from "node:path"; import { join } from "node:path";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
@@ -14,6 +14,7 @@ import {
renderTaskVerifier, renderTaskVerifier,
taskVerifierArtifact, taskVerifierArtifact,
} from "../../domain/desktop/appTaskPython.js"; } from "../../domain/desktop/appTaskPython.js";
import { isPortableBundleName } from "../environment/environmentBundle.js";
import { createTestTempDirectory } from "../../../tests/fixtures/temporaryDirectory.js"; import { createTestTempDirectory } from "../../../tests/fixtures/temporaryDirectory.js";
import { BuildComputerActionLog } from "./DesktopBuildActionLog.js"; import { BuildComputerActionLog } from "./DesktopBuildActionLog.js";
import { import {
@@ -51,77 +52,6 @@ const witnessAt = async (bundle: string) =>
); );
describe("bundle task reads", () => { describe("bundle task reads", () => {
it("keeps the selected record directory through failed-save retries", async () => {
const directory = await createTestTempDirectory("witness-original-");
const other = await createTestTempDirectory("witness-other-");
let endAttempts = 0;
const witness = new DesktopBuildWitness(
{
...computer,
appData: async () => [
{
kind: "data",
data: {
state: {
"Documents/today.txt": {
format: "text",
view: { lines: ["eggs"], line_count: 1, truncated: false },
error: null,
},
},
},
},
],
startRecording: async () => ({
recording_id: "rec",
started_at: "2026-11-10T00:00:00.000Z",
}),
endRecording: async () => {
if (++endAttempts === 1) throw new Error("recording busy");
return { recording_id: "rec", segments: [] };
},
recordingSegments: async () => [],
},
new BuildComputerActionLog(),
{ ...SETTLE_TIMING, windowMs: 0 },
);
const signal = new AbortController().signal;
const first = await witness.check(
{ task, stage: "seed", directory },
signal,
);
expect(first.ok).toBe(false);
if (first.ok) throw new Error("Expected recording failure");
expect(first.error.userMessage).toContain("recording busy");
const refused = await witness.check(
{ task, stage: "seed", directory: other },
signal,
);
expect(refused.ok).toBe(false);
if (refused.ok) throw new Error("Expected directory refusal");
expect(refused.error.userMessage).toContain(directory);
expect(endAttempts).toBe(1);
await expect(access(join(directory, "witness.json"))).rejects.toThrow();
await expect(access(join(other, "witness.json"))).rejects.toThrow();
const saved = await witness.check(
{ task, stage: "seed", directory },
signal,
);
expect(saved.ok).toBe(true);
if (!saved.ok) throw saved.error;
expect(saved.value.witness?.record?.path).toBe(
join(directory, "witness.json"),
);
const savedAgain = await witness.check(
{ task, stage: "seed", directory: other },
signal,
);
expect(savedAgain.ok).toBe(false);
expect(endAttempts).toBe(2);
});
it("returns a cancelled result when reading the bundle's task is aborted", async () => { it("returns a cancelled result when reading the bundle's task is aborted", async () => {
const bundle = await createTestTempDirectory("witness-bundle-"); const bundle = await createTestTempDirectory("witness-bundle-");
const directory = await createTestTempDirectory("witness-record-"); const directory = await createTestTempDirectory("witness-record-");
@@ -157,7 +87,7 @@ describe("bundle task reads", () => {
it("reads a task whose prompt is larger than the old 1 MiB file cap", async () => { it("reads a task whose prompt is larger than the old 1 MiB file cap", async () => {
const prompt = `Add ${"eggs ".repeat(Math.ceil((1.1 * 1024 * 1024) / 5))}end`; const prompt = `Add ${"eggs ".repeat(Math.ceil((1.1 * 1024 * 1024) / 5))}end`;
const given = { ...task, prompt }; const given = { ...task, prompt };
const parsed = parseDesktopTask(given); const parsed = parseDesktopTask(given, isPortableBundleName);
expect(parsed.ok).toBe(true); expect(parsed.ok).toBe(true);
if (!parsed.ok) return; if (!parsed.ok) return;
const large = parsed.value; const large = parsed.value;
+11 -27
View File
@@ -35,6 +35,7 @@ import type {
RecordingManifest, RecordingManifest,
} from "../../desktop/AgentEnvComputer.js"; } from "../../desktop/AgentEnvComputer.js";
import { AGENTENV_PROVIDER } from "../../desktop/AgentEnvComputerHelper.js"; import { AGENTENV_PROVIDER } from "../../desktop/AgentEnvComputerHelper.js";
import { isPortableBundleName } from "../environment/environmentBundle.js";
import { import {
silentProgressReporter, silentProgressReporter,
type ProgressReporter, type ProgressReporter,
@@ -103,18 +104,6 @@ const refused = (userMessage: string, remediationAction: string) =>
const taskDigest = (task: DesktopTask) => const taskDigest = (task: DesktopTask) =>
createHash("sha256").update(JSON.stringify(task)).digest("hex"); createHash("sha256").update(JSON.stringify(task)).digest("hex");
/** A retry cannot redirect an ended witness to another folder. */
const recordDirectoryRefusal = (
expected: string,
requested: string | undefined,
): AnalysisError | undefined =>
requested !== undefined && requested !== expected
? refused(
`This witness's record belongs to ${expected}, not ${requested}.`,
"Ask again without directory or with the original folder; another witness needs a fresh computer.",
)
: undefined;
/** Whether a folder can take the record: absent, or empty. */ /** Whether a folder can take the record: absent, or empty. */
const usableFolder = async (directory: string): Promise<boolean> => const usableFolder = async (directory: string): Promise<boolean> =>
readdir(directory).then( readdir(directory).then(
@@ -239,7 +228,7 @@ export class DesktopBuildWitness {
signal: AbortSignal, signal: AbortSignal,
progress: ProgressReporter = silentProgressReporter, progress: ProgressReporter = silentProgressReporter,
): Promise<Result<CheckedTask, AnalysisError>> { ): Promise<Result<CheckedTask, AnalysisError>> {
const parsed = parseDesktopTask(input.task); const parsed = parseDesktopTask(input.task, isPortableBundleName);
if (!parsed.ok) if (!parsed.ok)
return err( return err(
refused( refused(
@@ -249,15 +238,8 @@ export class DesktopBuildWitness {
); );
const task = parsed.value; const task = parsed.value;
const { stage } = input; const { stage } = input;
if (stage !== undefined && this.#witness?.state === "saving") { if (stage !== undefined && this.#witness?.state === "saving")
const directoryRefusal = recordDirectoryRefusal( return this.#resave(this.#witness, task, stage, signal);
this.#witness.directory,
input.directory,
);
return directoryRefusal === undefined
? this.#resave(this.#witness, task, stage, signal)
: err(directoryRefusal);
}
const saved = const saved =
stage === undefined stage === undefined
? undefined ? undefined
@@ -457,11 +439,13 @@ export class DesktopBuildWitness {
) )
return undefined; return undefined;
// A folder the record was not saved to gets nothing written, so it is refused. // A folder the record was not saved to gets nothing written, so it is refused.
const directoryRefusal = recordDirectoryRefusal( if (directory !== undefined && directory !== witness.directory)
witness.directory, return err(
directory, refused(
); `This witness's record was saved to ${witness.directory}, not ${directory}.`,
if (directoryRefusal !== undefined) return err(directoryRefusal); "Ask again without directory or with the folder it was saved to; another witness needs a fresh computer.",
),
);
return ok({ return ok({
...counted(task, filed), ...counted(task, filed),
witness: summaryOf(filed, null, witness.record), witness: summaryOf(filed, null, witness.record),
@@ -21,17 +21,20 @@ const manifest: RecordingManifest = {
}; };
const witness = async (): Promise<EndedWitness> => { const witness = async (): Promise<EndedWitness> => {
const parsed = parseDesktopTask({ const parsed = parseDesktopTask(
name: "add-eggs", {
prompt: "Add eggs.", name: "add-eggs",
checks: [ prompt: "Add eggs.",
{ checks: [
kind: "lines_present", {
file: "Documents/today.txt", kind: "lines_present",
lines: ["eggs"], file: "Documents/today.txt",
}, lines: ["eggs"],
], },
}); ],
},
(name) => /^[a-z0-9]+(?:-[a-z0-9]+)*$/u.test(name),
);
if (!parsed.ok) throw new Error(parsed.error); if (!parsed.ok) throw new Error(parsed.error);
return { return {
task: parsed.value, task: parsed.value,
@@ -19,7 +19,7 @@ import {
withExtraPackages, withExtraPackages,
} from "../../domain/desktop/appExportInstall.js"; } from "../../domain/desktop/appExportInstall.js";
import type { DesktopApplicationCapture } from "../../domain/desktop/desktopCapture.js"; import type { DesktopApplicationCapture } from "../../domain/desktop/desktopCapture.js";
import { isPortableBundleName } from "../../domain/portableBundleName.js"; import { isPortableBundleName } from "../environment/environmentBundle.js";
const OPERATION = "export_desktop_application"; const OPERATION = "export_desktop_application";
const MAX_DOWNLOAD_INSTALL_BYTES = 64 * 1024; const MAX_DOWNLOAD_INSTALL_BYTES = 64 * 1024;
@@ -189,37 +189,3 @@ it("does not start a second cleanup while a timed-out cleanup may still be runni
expect(calls).toBe(1); expect(calls).toBe(1);
expect(owner.cleanupResources).toHaveLength(2); expect(owner.cleanupResources).toHaveLength(2);
}); });
it.each([
{ ...success, exit_code: 4, stderr: "reader failed" },
{ ...success, stdout: "invalid reader answer" },
])(
"preserves earlier views and absences when a later runner fails after settling (%#)",
async (failed) => {
let readers = 0;
const owner = new DesktopRemoteTaskReaders(async (argv) => {
if (argv[3] === PYTHON_READER_CLEANUP) return success;
readers += 1;
if (readers === 1) return success;
if (readers === 2)
return { ...success, stdout: JSON.stringify({ absent: true }) };
return failed;
});
const failure = await owner
.read(
[reader, { ...reader, id: "absent" }, { ...reader, id: "failed" }],
new AbortController().signal,
)
.catch((error: unknown) => error);
expect(failure).toMatchObject({
operation: "check_build_task",
diagnostics: {
completed_reader_views: {
note: { format: "json", view: { value: 7 } },
},
completed_reader_absent: ["absent"],
},
});
expect(owner.cleanupResources).toEqual([]);
},
);
@@ -14,7 +14,6 @@ import {
type ReaderLease, type ReaderLease,
} from "../../domain/desktop/appTaskReaders.js"; } from "../../domain/desktop/appTaskReaders.js";
import { ProviderCleanupError } from "../../domain/providerCleanupError.js"; import { ProviderCleanupError } from "../../domain/providerCleanupError.js";
import { ProviderAdapterError } from "../../domain/providerAdapterError.js";
import { import {
AGENTENV_PROVIDER, AGENTENV_PROVIDER,
type ComputerRelease, type ComputerRelease,
@@ -138,12 +137,7 @@ export class DesktopRemoteTaskReaders {
signal.aborted ? this.#cancelled(signal, views, absent) : undefined, signal.aborted ? this.#cancelled(signal, views, absent) : undefined,
); );
if (signal.aborted) throw this.#cancelled(signal, views, absent); if (signal.aborted) throw this.#cancelled(signal, views, absent);
if (failure !== undefined) if (failure !== undefined) throw failure;
throw new ProviderAdapterError(AGENTENV_PROVIDER, "check_build_task", {
cause: failure,
userMessage: `The task reader could not complete: ${failure instanceof Error ? failure.message : String(failure)}`,
diagnostics: this.#facts(views, absent),
});
} }
return views; return views;
} }
+5 -1
View File
@@ -45,6 +45,7 @@ import { EvidenceFileError } from "../../domain/evidenceErrors.js";
import { err } from "../../domain/result.js"; import { err } from "../../domain/result.js";
import { describeValidationFailure } from "../../domain/validationFailure.js"; import { describeValidationFailure } from "../../domain/validationFailure.js";
import { isWithinAny } from "../../domain/desktop/desktopStateDiff.js"; import { isWithinAny } from "../../domain/desktop/desktopStateDiff.js";
import { isPortableBundleName } from "../environment/environmentBundle.js";
import { readJsonFile } from "../JsonFiles.js"; import { readJsonFile } from "../JsonFiles.js";
import { desktopOperationFailure } from "./DesktopCaptureRefusals.js"; import { desktopOperationFailure } from "./DesktopCaptureRefusals.js";
import { refuse } from "./DesktopExportInstall.js"; import { refuse } from "./DesktopExportInstall.js";
@@ -662,7 +663,10 @@ export const admitDesktopTasks = async (
): Promise<AdmittedTasks | undefined> => { ): Promise<AdmittedTasks | undefined> => {
const tasks: DesktopTask[] = []; const tasks: DesktopTask[] = [];
for (const path of given.paths) { for (const path of given.paths) {
const parsed = parseDesktopTask(await readTaskFile(path, signal)); const parsed = parseDesktopTask(
await readTaskFile(path, signal),
isPortableBundleName,
);
if (!parsed.ok) if (!parsed.ok)
throw refuse( throw refuse(
`The task file ${path} is refused: ${parsed.error}`, `The task file ${path} is refused: ${parsed.error}`,
@@ -166,34 +166,3 @@ it.each([
expect((failure as Error).cause).toBe(reason); expect((failure as Error).cause).toBe(reason);
}, },
); );
it("preserves completed views and source absences when a later source read fails", async () => {
const sourceFailure = new Error("cannot read the third retained file");
const failure = await readerViewsOfBytes(
["first", "absent", "third"].map((id) => ({
id,
file: `${id}.txt`,
argv: ["reader", "{file}"],
output: "json" as const,
})),
async ({ id }) => {
if (id === "third") throw sourceFailure;
return id === "absent"
? { kind: "absent" }
: { kind: "bytes", bytes: Buffer.from("input") };
},
async () => ({
exit_code: 0,
stdout: JSON.stringify({ view: { format: "json", view: { value: 7 } } }),
stderr: "",
timed_out: false,
}),
).catch((cause: unknown) => cause);
expect(failure).toMatchObject({
cause: sourceFailure,
diagnostics: {
completed_reader_views: { first: { format: "json", view: { value: 7 } } },
completed_reader_absent: ["absent"],
},
});
});
+3 -15
View File
@@ -88,25 +88,13 @@ export const readerViewsOfBytes = async (
if (readers.length > 0) await processWorkerCleanup.retry(); if (readers.length > 0) await processWorkerCleanup.retry();
const absent: string[] = []; const absent: string[] = [];
for (const reader of readers) { for (const reader of readers) {
let file: ReaderFileBytes; const file = await bytesOf(reader);
try { if (file.kind === "absent") continue;
file = await bytesOf(reader);
} catch (cause: unknown) {
throw readerFailure(cause, { views, absent });
}
if (file.kind === "absent") {
absent.push(reader.id);
continue;
}
if (file.kind === "error") { if (file.kind === "error") {
views.set(reader.id, { format: null, view: null, error: file.error }); views.set(reader.id, { format: null, view: null, error: file.error });
continue; continue;
} }
const folder = await mkdtemp(join(tmpdir(), "rea-reader-bytes-")).catch( const folder = await mkdtemp(join(tmpdir(), "rea-reader-bytes-"));
(cause: unknown) => {
throw readerFailure(cause, { views, absent });
},
);
let failure: { readonly cause: unknown } | undefined; let failure: { readonly cause: unknown } | undefined;
try { try {
const path = join(folder, reader.file.split("/").at(-1) ?? "file"); const path = join(folder, reader.file.split("/").at(-1) ?? "file");
@@ -1,7 +1,3 @@
import {
isPortableBundleName,
PORTABLE_BUNDLE_NAME_CONSTRAINT,
} from "../../domain/portableBundleName.js";
import { setImmediate } from "node:timers/promises"; import { setImmediate } from "node:timers/promises";
import type { AnalysisError } from "../../domain/analysisErrorBase.js"; import type { AnalysisError } from "../../domain/analysisErrorBase.js";
@@ -19,7 +15,9 @@ import type { EnvironmentBuildReport } from "../../domain/environmentBundleExpor
import { err, ok, type Result } from "../../domain/result.js"; import { err, ok, type Result } from "../../domain/result.js";
import { inspectAnalysisView } from "../analysisView/AnalysisViewService.js"; import { inspectAnalysisView } from "../analysisView/AnalysisViewService.js";
import { import {
BUNDLE_NAME_CONSTRAINT,
ENVIRONMENT_EXPORT_OPERATION, ENVIRONMENT_EXPORT_OPERATION,
isPortableBundleName,
planEnvironmentBundle, planEnvironmentBundle,
registryPackageSource, registryPackageSource,
type EnvironmentPackageSource, type EnvironmentPackageSource,
@@ -192,7 +190,7 @@ const unnamed = (path: string): AnalysisInputError =>
new AnalysisInputError( new AnalysisInputError(
ENVIRONMENT_BUILD_OPERATION, ENVIRONMENT_BUILD_OPERATION,
{ {
userMessage: `The capture's origin is a loopback or IP address, and ${path === "" ? "retained Evidence has no file name to take one from" : `${path} yields no portable environment name (${PORTABLE_BUNDLE_NAME_CONSTRAINT})`}; name the environment.`, userMessage: `The capture's origin is a loopback or IP address, and ${path === "" ? "retained Evidence has no file name to take one from" : `${path} yields no portable environment name (${BUNDLE_NAME_CONSTRAINT})`}; name the environment.`,
}, },
[ [
{ {
@@ -1,4 +1,3 @@
import { isPortableBundleName } from "../../domain/portableBundleName.js";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { timedCaptureEvidence } from "../../../tests/fixtures/analysisView.js"; import { timedCaptureEvidence } from "../../../tests/fixtures/analysisView.js";
@@ -16,6 +15,7 @@ import { PACKAGE_METADATA } from "../../generatedPackageMetadata.js";
import { import {
BUNDLE_BASE_IMAGE, BUNDLE_BASE_IMAGE,
bundleDockerfile, bundleDockerfile,
isPortableBundleName,
ENVIRONMENT_EXPORT_OPERATION, ENVIRONMENT_EXPORT_OPERATION,
planEnvironmentBundle, planEnvironmentBundle,
registryPackageSource, registryPackageSource,
@@ -1,8 +1,4 @@
import { import { normalizeHttpHostname } from "../../domain/httpHostname.js";
parsePortableBundleName,
PORTABLE_BUNDLE_NAME_PATTERN,
PORTABLE_BUNDLE_NAME_MAX_LENGTH,
} from "../../domain/portableBundleName.js";
import { PRODUCT_IDENTITY } from "../../identity.js"; import { PRODUCT_IDENTITY } from "../../identity.js";
import { PACKAGE_METADATA } from "../../generatedPackageMetadata.js"; import { PACKAGE_METADATA } from "../../generatedPackageMetadata.js";
import type { AnalysisError } from "../../domain/analysisErrorBase.js"; import type { AnalysisError } from "../../domain/analysisErrorBase.js";
@@ -31,6 +27,18 @@ export const BUNDLE_ALLOWED_HOSTS = "127.0.0.1,localhost";
/** Name of the packed CLI inside the env folder when a tarball is exported. */ /** Name of the packed CLI inside the env folder when a tarball is exported. */
export const BUNDLE_TARBALL_NAME = "rea-agents.tgz"; export const BUNDLE_TARBALL_NAME = "rea-agents.tgz";
/** Folder and env id rule: a kebab-case label the agentenv framework accepts as a local id. */
const BUNDLE_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u;
// A compose service name is one DNS label, whose wire limit is 63 octets.
const BUNDLE_NAME_MAX_LENGTH = 63;
export const BUNDLE_NAME_CONSTRAINT = `a kebab-case label of at most ${String(BUNDLE_NAME_MAX_LENGTH)} characters, excluding Windows device names, that remains unchanged as an HTTP service hostname`;
/** Win32 reserves these device names for files and folders, so the env folder can never take one. */
const WINDOWS_RESERVED_NAME = /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])$/u;
/** Whether a name can be the env folder and env id: the checks the export applies. */
export const isPortableBundleName = (name: string): boolean =>
validateBundleName(name).ok;
/** Where the Dockerfile installs the CLI from. */ /** Where the Dockerfile installs the CLI from. */
export type EnvironmentPackageSource = export type EnvironmentPackageSource =
| { readonly kind: "registry"; readonly specifier: string } | { readonly kind: "registry"; readonly specifier: string }
@@ -87,13 +95,13 @@ const nameError = (name: string): AnalysisInputError =>
new AnalysisInputError( new AnalysisInputError(
ENVIRONMENT_EXPORT_OPERATION, ENVIRONMENT_EXPORT_OPERATION,
{ {
userMessage: `The bundle name ${JSON.stringify(name)} is not a portable kebab-case label of at most ${String(PORTABLE_BUNDLE_NAME_MAX_LENGTH)} characters.`, userMessage: `The bundle name ${JSON.stringify(name)} is not a portable kebab-case label of at most ${String(BUNDLE_NAME_MAX_LENGTH)} characters.`,
}, },
[ [
{ {
path: ["name"], path: ["name"],
reason: "invalid_format", reason: "invalid_format",
expected: PORTABLE_BUNDLE_NAME_PATTERN.source, expected: BUNDLE_NAME.source,
message: message:
"The bundle name is the env folder and env id; use lowercase letters, digits and single hyphens, excluding Windows device names.", "The bundle name is the env folder and env id; use lowercase letters, digits and single hyphens, excluding Windows device names.",
}, },
@@ -120,26 +128,27 @@ const reservedNameError = (name: string): AnalysisInputError =>
const validateBundleName = ( const validateBundleName = (
name: string, name: string,
): Result<string, AnalysisInputError> => { ): Result<string, AnalysisInputError> => {
const parsed = parsePortableBundleName(name); if (!BUNDLE_NAME.test(name) || name.length > BUNDLE_NAME_MAX_LENGTH)
if (parsed.ok) return parsed; return err(nameError(name));
if (parsed.error === "format") return err(nameError(name)); if (WINDOWS_RESERVED_NAME.test(name)) return err(reservedNameError(name));
if (parsed.error === "reserved") return err(reservedNameError(name)); if (normalizeHttpHostname(name) !== name)
return err( return err(
new AnalysisInputError( new AnalysisInputError(
ENVIRONMENT_EXPORT_OPERATION, ENVIRONMENT_EXPORT_OPERATION,
{
userMessage: `The bundle name ${JSON.stringify(name)} cannot be used unchanged as an HTTP host name. Choose a name such as replay-1.`,
},
[
{ {
path: ["name"], userMessage: `The bundle name ${JSON.stringify(name)} cannot be used unchanged as an HTTP host name. Choose a name such as replay-1.`,
reason: "invalid_value",
message:
"The service name must remain a hostname rather than be rejected or interpreted as an IP address.",
}, },
], [
), {
); path: ["name"],
reason: "invalid_value",
message:
"The service name must remain a hostname rather than be rejected or interpreted as an IP address.",
},
],
),
);
return ok(name);
}; };
const installLine = (source: EnvironmentPackageSource): string => const installLine = (source: EnvironmentPackageSource): string =>
@@ -20,6 +20,11 @@ it("reads a Python 3.9 reference-flagged PYZ table", () => {
]); ]);
}); });
it("reads marshal one-byte string tags as Latin-1", () => {
for (const hex of ["6101000000e9", "4101000000e9", "7a01e9", "5a01e9"])
expect(readMarshalSubset(bytes(hex))).toEqual({ ok: true, value: "é" });
});
it("decodes CPython marshal Unicode with surrogatepass", () => { it("decodes CPython marshal Unicode with surrogatepass", () => {
expect( expect(
readMarshalSubset( readMarshalSubset(
+2 -8
View File
@@ -168,14 +168,8 @@ const readAsciiBytes = (
): Result<string, PyInstallerFailure> => { ): Result<string, PyInstallerFailure> => {
const bytes = readBytes(cursor, length); const bytes = readBytes(cursor, length);
if (!bytes.ok) return bytes; if (!bytes.ok) return bytes;
if (bytes.value.some((byte) => byte > 0x7f)) // These tags use CPython's one-byte Unicode constructor, not an ASCII check.
return err( return ok(bytes.value.toString("latin1"));
pyInstallerFailure(
"format",
"PYZ marshal ASCII string contains a non-ASCII byte",
),
);
return ok(bytes.value.toString("ascii"));
}; };
const readUtf8 = ( const readUtf8 = (
+4 -4
View File
@@ -1,4 +1,3 @@
import { portableBundleNameSchema } from "../domain/portableBundleName.js";
import { z } from "zod"; import { z } from "zod";
import { absoluteLocalPathStringSchema } from "../domain/localPath.js"; import { absoluteLocalPathStringSchema } from "../domain/localPath.js";
@@ -43,9 +42,10 @@ export const MEASURE_BUILD_TASK_CONTRACT = {
bundle: absoluteLocalPathStringSchema.describe( bundle: absoluteLocalPathStringSchema.describe(
"The exported bundle's folder on this host.", "The exported bundle's folder on this host.",
), ),
task: portableBundleNameSchema.describe( task: z
"The task's name, as its tasks/<name>.json names it.", .string()
), .min(1)
.describe("The task's name, as its tasks/<name>.json names it."),
model: z model: z
.string() .string()
.min(1) .min(1)
@@ -1,7 +1,6 @@
import { execFile } from "node:child_process"; import { execFile } from "node:child_process";
import { constants } from "node:fs"; import { constants } from "node:fs";
import { import {
chmod,
mkdir, mkdir,
open, open,
readFile, readFile,
@@ -68,75 +67,6 @@ const readFacts = async (
} }
}; };
it.skipIf(process.platform === "win32" || process.getuid?.() === 0)(
"keeps disk and archive permission failures distinct from missing paths",
async () => {
const root = await createTestTempDirectory("rea-doctor-disk-permission-");
const docker = join(root, "docker");
const denied = join(root, "denied");
const target = join(denied, "docker");
const archive = join(denied, "runtime.tar.xz");
await program(docker, "exit 0");
await mkdir(target, { recursive: true });
await writeFile(archive, "unreadable archive");
const resources = new ArtifactResourceScope();
const factsAt = (directory: string, selectedArchive: string) =>
readDesktopBuildFacts({
environment: {
HOME: root,
PATH: "",
REA_DOCKER_COMMAND: docker,
REA_BUILD_COMPUTER_REA_ARCHIVE: selectedArchive,
},
platform: process.platform,
resources,
signal: new AbortController().signal,
run: async (_command, args) => {
const stdout = args[0] === "info" ? `${directory}\n` : "";
return {
stdout,
stderr: "",
stdoutBytes: Buffer.byteLength(stdout),
stderrBytes: 0,
};
},
clientIds: [],
registrations: async () => [],
});
await chmod(denied, 0);
try {
const facts = await factsAt(target, archive);
expect(facts.disk).toMatchObject({
path: target,
error: expect.stringContaining("EACCES"),
});
expect(facts.disk).not.toHaveProperty("freeBytes");
expect(facts.archive).toMatchObject({
path: archive,
status: "unknown",
detail: expect.stringContaining("EACCES"),
});
const missingArchive = join(root, "missing", "runtime.tar.xz");
const missing = await factsAt(
join(root, "missing", "docker"),
missingArchive,
);
expect(missing.disk).toMatchObject({
path: root,
freeBytes: expect.any(Number),
});
expect(missing.disk).not.toHaveProperty("error");
expect(missing.archive).toMatchObject({
path: missingArchive,
status: "missing",
});
} finally {
await chmod(denied, 0o700);
await resources.close();
}
},
);
it.skipIf(process.platform === "win32")( it.skipIf(process.platform === "win32")(
"refuses a FIFO model configuration without waiting for a writer", "refuses a FIFO model configuration without waiting for a writer",
async () => { async () => {
+7 -18
View File
@@ -1,6 +1,6 @@
import { constants } from "node:fs"; import { constants } from "node:fs";
import { access, stat, statfs } from "node:fs/promises"; import { access, stat, statfs } from "node:fs/promises";
import { dirname, isAbsolute } from "node:path"; import { dirname, isAbsolute, join } from "node:path";
import { z } from "zod"; import { z } from "zod";
@@ -31,9 +31,6 @@ import {
const DEFAULT_DOCKER_ROOT = "/var/lib/docker"; const DEFAULT_DOCKER_ROOT = "/var/lib/docker";
const CONFIG_BYTES = 1024 * 1024; const CONFIG_BYTES = 1024 * 1024;
const missingPath = (cause: unknown): boolean =>
cause instanceof Error && "code" in cause && cause.code === "ENOENT";
/** Inspect the same tar compression formats as the build's Python extractor without extracting files. */ /** Inspect the same tar compression formats as the build's Python extractor without extracting files. */
const ARCHIVE_VERSION = String.raw` const ARCHIVE_VERSION = String.raw`
import json, pathlib, sys, tarfile import json, pathlib, sys, tarfile
@@ -238,7 +235,7 @@ const freeSpace = async (path: string): Promise<DesktopBuildFacts["disk"]> => {
return { path: candidate, freeBytes: fs.bavail * fs.bsize }; return { path: candidate, freeBytes: fs.bavail * fs.bsize };
} catch (cause: unknown) { } catch (cause: unknown) {
const parent = dirname(candidate); const parent = dirname(candidate);
if (parent === candidate || !missingPath(cause)) if (parent === candidate)
return { return {
path, path,
error: cause instanceof Error ? cause.message : String(cause), error: cause instanceof Error ? cause.message : String(cause),
@@ -268,19 +265,11 @@ const archiveFact = async (
source === "cache" source === "cache"
? defaultBuildComputerArchive(input.environment, input.platform) ? defaultBuildComputerArchive(input.environment, input.platform)
: (selected as string); : (selected as string);
try { const file = await stat(path).then(
if (!(await stat(path)).isFile()) (entry) => entry.isFile(),
return { path, source, status: "missing" }; () => false,
} catch (cause: unknown) { );
return missingPath(cause) if (!file) return { path, source, status: "missing" };
? { path, source, status: "missing" }
: {
path,
source,
status: "unknown",
detail: `REA could not inspect ${path}: ${cause instanceof Error ? cause.message : String(cause)}`,
};
}
if (source === "cache") return { path, source, status: "current" }; if (source === "cache") return { path, source, status: "current" };
const python = await pythonForArchive(); const python = await pythonForArchive();
if (python === undefined) if (python === undefined)
+2 -2
View File
@@ -1,4 +1,3 @@
import { isPortableBundleName } from "../portableBundleName.js";
import { z } from "zod"; import { z } from "zod";
import { jsonValueSchema, type JsonValue } from "../jsonValue.js"; import { jsonValueSchema, type JsonValue } from "../jsonValue.js";
@@ -404,11 +403,12 @@ const screenProblem = (
/** Parse a task file and settle its check ids, or say what is wrong with it. */ /** Parse a task file and settle its check ids, or say what is wrong with it. */
export const parseDesktopTask = ( export const parseDesktopTask = (
value: unknown, value: unknown,
isPortableName: (name: string) => boolean,
): Result<DesktopTask, string> => { ): Result<DesktopTask, string> => {
const parsed = desktopTaskSchema.safeParse(value); const parsed = desktopTaskSchema.safeParse(value);
if (!parsed.success) return err(describeValidationFailure(parsed.error)); if (!parsed.success) return err(describeValidationFailure(parsed.error));
const { name, prompt, checks, readers } = parsed.data; const { name, prompt, checks, readers } = parsed.data;
if (!isPortableBundleName(name) || name === "smoke") if (!isPortableName(name) || name === "smoke")
return err( return err(
`name ${JSON.stringify(name)} must be a portable bundle name other than smoke: lowercase letters, digits and hyphens.`, `name ${JSON.stringify(name)} must be a portable bundle name other than smoke: lowercase letters, digits and hyphens.`,
); );
+31 -20
View File
@@ -13,6 +13,8 @@ import { elementText } from "./viewPointer.js";
import { redactSecretsInJson } from "./buildSecrets.js"; import { redactSecretsInJson } from "./buildSecrets.js";
const portable = (name: string) => /^[a-z0-9]+(?:-[a-z0-9]+)*$/u.test(name);
it("redacts full check values before shortening descriptions without changing the verdict", () => { it("redacts full check values before shortening descriptions without changing the verdict", () => {
const secret = "e-Plum7-Kite-Throwaway-42"; const secret = "e-Plum7-Kite-Throwaway-42";
const actual = `${"x".repeat(180)}${secret}`; const actual = `${"x".repeat(180)}${secret}`;
@@ -51,7 +53,10 @@ const expectOutcome = (
}; };
const task = (checks: readonly unknown[]) => const task = (checks: readonly unknown[]) =>
parseDesktopTask({ name: "edit-notes", prompt: "Edit my notes.", checks }); parseDesktopTask(
{ name: "edit-notes", prompt: "Edit my notes.", checks },
portable,
);
/** Task changes the parser refuses, each with part of its message. */ /** Task changes the parser refuses, each with part of its message. */
const REFUSALS: readonly (readonly [Record<string, unknown>, string])[] = [ const REFUSALS: readonly (readonly [Record<string, unknown>, string])[] = [
@@ -1066,19 +1071,22 @@ describe("evaluateTaskChecks whole text", () => {
describe("parseDesktopTask", () => { describe("parseDesktopTask", () => {
it("settles ids and keeps the prompt trimmed", () => { it("settles ids and keeps the prompt trimmed", () => {
const parsed = parseDesktopTask({ const parsed = parseDesktopTask(
name: "add-milk", {
prompt: " Add milk.\n", name: "add-milk",
checks: [ prompt: " Add milk.\n",
{ kind: "file_present", file: "Documents/todo.txt" }, checks: [
{ { kind: "file_present", file: "Documents/todo.txt" },
id: "milk", {
kind: "lines_present", id: "milk",
file: "Documents/todo.txt", kind: "lines_present",
lines: ["milk"], file: "Documents/todo.txt",
}, lines: ["milk"],
], },
}); ],
},
portable,
);
expect(parsed).toMatchObject({ expect(parsed).toMatchObject({
ok: true, ok: true,
@@ -1090,12 +1098,15 @@ describe("parseDesktopTask", () => {
}); });
it.each(REFUSALS)("refuses %j", (change, message) => { it.each(REFUSALS)("refuses %j", (change, message) => {
const parsed = parseDesktopTask({ const parsed = parseDesktopTask(
name: "add-milk", {
prompt: "Add milk.", name: "add-milk",
checks: [{ kind: "file_present", file: "a" }], prompt: "Add milk.",
...change, checks: [{ kind: "file_present", file: "a" }],
}); ...change,
},
portable,
);
expect(parsed.ok).toBe(false); expect(parsed.ok).toBe(false);
if (!parsed.ok) expect(parsed.error).toContain(message); if (!parsed.ok) expect(parsed.error).toContain(message);
+7 -30
View File
@@ -3,7 +3,6 @@ import { isNameLikeKey } from "./attributeRank.js";
import type { DesktopStateDifference } from "./stateChanges.js"; import type { DesktopStateDifference } from "./stateChanges.js";
import { elementText, isXmlElement, resolvePointer } from "./viewPointer.js"; import { elementText, isXmlElement, resolvePointer } from "./viewPointer.js";
import { selectorOf } from "./viewSelector.js"; import { selectorOf } from "./viewSelector.js";
import { zipEntryAt } from "./zipView.js";
/** A file's views at its two ends. */ /** A file's views at its two ends. */
interface Ends { interface Ends {
@@ -11,12 +10,6 @@ interface Ends {
readonly final: JsonValue | null; readonly final: JsonValue | null;
} }
/** XML identifiers and field mirrors belong to one document, including within an archive. */
const documentAt = (view: JsonValue | null, pointer: string) => {
const entry = zipEntryAt(view, pointer);
return typeof entry === "object" ? entry : view;
};
/** The value of the name-like attribute a pointer's last step names its element by, as `label` for `[name=elementInformation][@name=label]`. */ /** The value of the name-like attribute a pointer's last step names its element by, as `label` for `[name=elementInformation][@name=label]`. */
const fieldKeyOf = (element: string) => const fieldKeyOf = (element: string) =>
selectorOf( selectorOf(
@@ -45,35 +38,22 @@ export const mirrorsOf = (
return element !== pointer && return element !== pointer &&
typeof before === "string" && typeof before === "string" &&
typeof after === "string" typeof after === "string"
? [ ? [{ pointer, element, before, after }]
{
pointer,
element,
before,
after,
document: documentAt(ends.final, pointer),
},
]
: []; : [];
}); });
// The keys of the changed texts' elements, by what each was and became. // The keys of the changed texts' elements, by what each was and became.
const keys = new Map< const keys = new Map<string, { pointer: string; key: string }[]>();
string, for (const { pointer, element, before, after } of texts) {
{ pointer: string; key: string; document: ReturnType<typeof documentAt> }[]
>();
for (const { pointer, element, before, after, document } of texts) {
const key = fieldKeyOf(element); const key = fieldKeyOf(element);
const alike = JSON.stringify([before, after]); const alike = JSON.stringify([before, after]);
if (key === undefined) continue; if (key === undefined) continue;
const group = keys.get(alike) ?? []; const group = keys.get(alike) ?? [];
group.push({ pointer, key, document }); group.push({ pointer, key });
keys.set(alike, group); keys.set(alike, group);
} }
const mirrored = new Set( const mirrored = new Set(
texts.flatMap((mirror) => { texts.flatMap((mirror) => {
const sources = keys const sources = keys.get(JSON.stringify([mirror.before, mirror.after]));
.get(JSON.stringify([mirror.before, mirror.after]))
?.filter(({ document }) => document === mirror.document);
const record = sources?.some(({ pointer }) => pointer !== mirror.pointer) const record = sources?.some(({ pointer }) => pointer !== mirror.pointer)
? resolvePointer( ? resolvePointer(
ends.final, ends.final,
@@ -156,16 +136,13 @@ export const idReferencesOf = (
) => { ) => {
const added = change.added.flatMap((pointer) => { const added = change.added.flatMap((pointer) => {
const element = resolvePointer(ends.final, pointer, "xml"); const element = resolvePointer(ends.final, pointer, "xml");
return isXmlElement(element) return isXmlElement(element) ? [{ pointer, element }] : [];
? [{ pointer, element, document: documentAt(ends.final, pointer) }]
: [];
}); });
const mirrors = added.flatMap(({ pointer, element, document }) => { const mirrors = added.flatMap(({ pointer, element }) => {
const [key = ""] = Object.keys(element.attributes); const [key = ""] = Object.keys(element.attributes);
return isIdReference(element) && return isIdReference(element) &&
added.some( added.some(
(record) => (record) =>
record.document === document &&
record.element !== element && record.element !== element &&
!isIdReference(record.element) && !isIdReference(record.element) &&
record.element.attributes[key] === element.attributes[key], record.element.attributes[key] === element.attributes[key],
@@ -1,7 +1,6 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import type { JsonValue } from "../jsonValue.js"; import type { JsonValue } from "../jsonValue.js";
import { evaluateTaskChecks } from "./appTaskChecks.js";
import { import {
draftOf, draftOf,
passing, passing,
@@ -777,109 +776,3 @@ describe("draftTaskFiles when a sibling lock changed", () => {
expect(lock?.suggested.length ?? 0).toBeGreaterThan(0); expect(lock?.suggested.length ?? 0).toBeGreaterThan(0);
}); });
}); });
it("keeps an added ID-only member when another XML archive entry independently adds the same ID", () => {
const archive = (added: boolean) => ({
entries: [
{
name: "records.xml",
size: 1,
sha256: added ? "end" : "seed",
format: "xml",
view: xml(
added
? '<doc><record id="6"><title>New record</title></record></doc>'
: "<doc/>",
),
view_error: null,
},
{
name: "other.xml",
size: 1,
sha256: added ? "end" : "seed",
format: "xml",
view: xml(added ? '<doc><entry id="6"/></doc>' : "<doc/>"),
view_error: null,
},
],
entry_count: 2,
truncated: false,
});
const file = "Documents/project.zip";
const [drafted] = draftOf({
[file]: statePath("document", "zip", archive(false), archive(true)),
});
expect(
drafted?.values.find(({ pointer }) => pointer.includes("[name=other.xml]"))
?.note,
).toBeUndefined();
const checks = (drafted?.suggested ?? []).filter(
(check) => "pointer" in check && check.pointer.includes("[name=other.xml]"),
);
expect(
evaluateTaskChecks(
checks.map((check, index) => ({ ...check, id: `c${index}` })),
new Map([[file, { format: "zip", view: archive(false) }]]),
).map(({ passed }) => passed),
).toContain(false);
expect(
evaluateTaskChecks(
checks.map((check, index) => ({ ...check, id: `c${index}` })),
new Map([[file, { format: "zip", view: archive(true) }]]),
).every(({ passed }) => passed),
).toBe(true);
});
it("keeps changed display text when its matching label belongs to another XML archive entry", () => {
const archive = (text: string) => ({
entries: [
{
name: "source.xml",
size: 1,
sha256: text,
format: "xml",
view: xml(
`<doc><record><field name="function">Independent function</field><field name="label">${text}</field></record></doc>`,
),
view_error: null,
},
{
name: "display.xml",
size: 1,
sha256: text,
format: "xml",
view: xml(
`<doc><record><display><text>${text}</text><info_name>label</info_name></display></record></doc>`,
),
view_error: null,
},
],
entry_count: 2,
truncated: false,
});
const file = "Documents/project.zip";
const [drafted] = draftOf({
[file]: statePath("document", "zip", archive("Old"), archive("New")),
});
expect(
drafted?.values.find(({ pointer }) =>
pointer.includes("[name=display.xml]"),
)?.note,
).toBeUndefined();
const checks = (drafted?.suggested ?? []).filter(
(check) =>
"pointer" in check && check.pointer.includes("[name=display.xml]"),
);
expect(
evaluateTaskChecks(
checks.map((check, index) => ({ ...check, id: `c${index}` })),
new Map([[file, { format: "zip", view: archive("Old") }]]),
).map(({ passed }) => passed),
).toContain(false);
expect(
evaluateTaskChecks(
checks.map((check, index) => ({ ...check, id: `c${index}` })),
new Map([[file, { format: "zip", view: archive("New") }]]),
).every(({ passed }) => passed),
).toBe(true);
});
-148
View File
@@ -1,148 +0,0 @@
import { describe, expect, it } from "vitest";
import type { JsonValue } from "../jsonValue.js";
import { evaluateTaskChecks } from "./appTaskChecks.js";
import { draftOf, statePath } from "./appTaskDraft.fixture.js";
const FILE = "Documents/notes.db";
const table = (body: string, truncated = false) => ({
columns: ["id", "body"],
primary_key: ["id"],
rows: [[1, body]],
truncated,
});
const database = (tables: JsonValue, tables_truncated = false) => ({
tables,
tables_truncated,
});
const draft = (before: JsonValue, after: JsonValue) =>
draftOf({ [FILE]: statePath("document", "sqlite", before, after) })[0];
describe("SQLite draft row boundaries", () => {
it.each([
database({ notes: table("kept", true) }),
database({ notes: { error: "permission denied" } }),
database({}, true),
])(
"keeps unknown baseline rows separate from known empty tables: %j",
(before) => {
const after = database({ notes: table("kept"), other: table("new") });
const file = draft(before, after);
expect(file).toBeDefined();
expect(
file?.suggested.some(
(check) => "table" in check && check.table === "notes",
),
).toBe(false);
// The complete inventory can still establish that another table is new.
expect(
file?.suggested.some(
(check) => "table" in check && check.table === "other",
),
).toBe(before.tables_truncated === false);
},
);
it("retains checks for changes in fully read sibling tables", () => {
const before = database({
notes: table("kept", true),
other: table("old"),
});
const after = database({ notes: table("kept"), other: table("new") });
const checks = draft(before, after)?.suggested ?? [];
expect(checks).toEqual([
{
kind: "row_present",
file: FILE,
table: "other",
where: { id: 1, body: "new" },
},
]);
for (const [view, passed] of [
[before, false],
[after, true],
] as const)
expect(
evaluateTaskChecks(
checks.map((check) => ({ ...check, id: "changed" })),
new Map([[FILE, { format: "sqlite", view }]]),
).map((result) => result.passed),
).toEqual([passed]);
});
it("suggests only predicates that distinguish keyed rows after timestamp omission", () => {
const saved = (stamp: string) =>
database({
notes: {
columns: ["id", "body", "saved"],
primary_key: ["id"],
rows: [[1, "kept", stamp]],
truncated: false,
},
});
expect(
draft(saved("2026-11-04 09:00:00"), saved("2026-11-04 10:00:00"))
?.suggested ?? [],
).toEqual([]);
});
it("compares projected predicates against siblings when timestamp keys are omitted", () => {
const rows = (first: string) =>
database({
notes: {
columns: ["stamp", "body"],
primary_key: ["stamp"],
rows: [
["2026-11-04 09:00:00", first],
["2026-11-04 10:00:00", "new"],
],
truncated: false,
},
});
const before = rows("old");
const after = rows("new");
expect(draft(before, after)?.suggested ?? []).toEqual([]);
});
it("keeps a newly created SQLite file's presence check", () => {
const after = database({ notes: table("new") });
const file = draftOf({
[FILE]: statePath("document", "sqlite", null, after),
})[0];
expect(file?.suggested).toEqual([{ kind: "file_present", file: FILE }]);
});
it("compares keyed rows by column names through a table rebuild", () => {
const before = database({ notes: table("kept") });
const reordered = (body: string) =>
database({
notes: {
columns: ["body", "id"],
primary_key: ["id"],
rows: [[body, 1]],
truncated: false,
},
});
expect(draft(before, reordered("kept"))?.suggested ?? []).toEqual([]);
const after = reordered("edited");
const checks = draft(before, after)?.suggested ?? [];
expect(checks).toEqual([
{
kind: "row_present",
file: FILE,
table: "notes",
where: { body: "edited", id: 1 },
},
]);
for (const [view, passed] of [
[before, false],
[after, true],
] as const)
expect(
evaluateTaskChecks(
checks.map((check) => ({ ...check, id: "changed" })),
new Map([[FILE, { format: "sqlite", view }]]),
).map((result) => result.passed),
).toEqual([passed]);
});
});
+18 -40
View File
@@ -18,14 +18,11 @@ interface TableView {
readonly rows: readonly (readonly JsonValue[])[]; readonly rows: readonly (readonly JsonValue[])[];
} }
/** Keep unread tables distinct from absence in a complete table inventory. */ const tableViews = (version: Version): ReadonlyMap<string, TableView> => {
const tableViews = (version: Version) => { const tables = new Map<string, TableView>();
const tables = new Map<string, TableView | undefined>();
const view = version.present ? version.view : null; const view = version.present ? version.view : null;
if (!isRecord(view) || !isRecord(view.tables)) if (!isRecord(view) || !isRecord(view.tables)) return tables;
return { tables, complete: false };
for (const [name, table] of Object.entries(view.tables)) { for (const [name, table] of Object.entries(view.tables)) {
tables.set(name, undefined);
if (!isRecord(table) || table.truncated !== false) continue; if (!isRecord(table) || table.truncated !== false) continue;
const strings = (value: JsonValue | undefined) => const strings = (value: JsonValue | undefined) =>
Array.isArray(value) Array.isArray(value)
@@ -37,7 +34,7 @@ const tableViews = (version: Version) => {
rows: Array.isArray(table.rows) ? table.rows.filter(Array.isArray) : [], rows: Array.isArray(table.rows) ? table.rows.filter(Array.isArray) : [],
}); });
} }
return { tables, complete: view.tables_truncated === false }; return tables;
}; };
/** Column values of a row, without volatile values or the columns left out. */ /** Column values of a row, without volatile values or the columns left out. */
@@ -113,37 +110,16 @@ export const rowSuggestions = (
): Suggestion[] => { ): Suggestion[] => {
const before = tableViews(baseline); const before = tableViews(baseline);
const suggestions: Suggestion[] = []; const suggestions: Suggestion[] = [];
for (const [name, table] of tableViews(final).tables) { const push = (
if (table === undefined) continue; kind: "row_present" | "row_absent",
const previousTable = before.tables.get(name); table: string,
if ( where: Record<string, JsonValue>,
previousTable === undefined && ) => {
(before.tables.has(name) || !before.complete) if (Object.keys(where).length > 0)
) suggestions.push({ kind, file, table, where });
continue; };
const old = previousTable ?? { ...table, rows: [] }; for (const [name, table] of tableViews(final)) {
// Judge the predicate after volatile values are omitted, using the same const old = before.get(name) ?? { ...table, rows: [] };
// matching semantics as the evaluator rather than raw row positions.
const push = (
kind: "row_present" | "row_absent",
where: Record<string, JsonValue>,
previousRow?: readonly JsonValue[],
) => {
if (Object.keys(where).length === 0) return;
// A paired row is sufficient only while the predicate retains its
// complete key. Omitted timestamp keys can let siblings match too.
const previousRows =
previousRow !== undefined &&
old.key.every((column) => Object.hasOwn(where, column))
? [previousRow]
: old.rows;
if (
kind === "row_present"
? countTaskRows(old.columns, previousRows, where) === 0
: countTaskRows(table.columns, table.rows, where) === 0
)
suggestions.push({ kind, file, table: name, where });
};
const oldRows = keyedRows(old); const oldRows = keyedRows(old);
const newRows = keyedRows(table); const newRows = keyedRows(table);
for (const [id, row] of newRows) { for (const [id, row] of newRows) {
@@ -151,11 +127,13 @@ export const rowSuggestions = (
if (previous === undefined) if (previous === undefined)
push( push(
"row_present", "row_present",
name,
rowWhere(table, row, (column) => !table.key.includes(column)), rowWhere(table, row, (column) => !table.key.includes(column)),
); );
else else if (JSON.stringify(previous) !== JSON.stringify(row))
push( push(
"row_present", "row_present",
name,
rowWhere( rowWhere(
table, table,
row, row,
@@ -164,13 +142,13 @@ export const rowSuggestions = (
JSON.stringify(row[table.columns.indexOf(column)]) !== JSON.stringify(row[table.columns.indexOf(column)]) !==
JSON.stringify(previous[old.columns.indexOf(column)]), JSON.stringify(previous[old.columns.indexOf(column)]),
), ),
previous,
); );
} }
for (const [id, row] of oldRows) for (const [id, row] of oldRows)
if (!newRows.has(id)) if (!newRows.has(id))
push( push(
"row_absent", "row_absent",
name,
rowWhere(old, row, (column) => old.key.includes(column)), rowWhere(old, row, (column) => old.key.includes(column)),
); );
suggestions.push(...unkeyedRowSuggestions(file, name, old, table)); suggestions.push(...unkeyedRowSuggestions(file, name, old, table));
@@ -157,9 +157,7 @@ describe("draftTaskFiles values", () => {
["removed", `${paragraph}/2`, "by_position"], ["removed", `${paragraph}/2`, "by_position"],
]); ]);
}); });
});
describe("draftTaskFiles values budgets", () => {
it("lists at most 20 values and 8 KB a file and 24 KB a draft, saying when it left any out", () => { it("lists at most 20 values and 8 KB a file and 24 KB a draft, saying when it left any out", () => {
const items = (count: number, value: (index: number) => string) => const items = (count: number, value: (index: number) => string) =>
`<doc>${Array.from({ length: count }, (_, index) => `<p id="${String(index)}" v="${value(index)}"/>`).join("")}</doc>`; `<doc>${Array.from({ length: count }, (_, index) => `<p id="${String(index)}" v="${value(index)}"/>`).join("")}</doc>`;
@@ -196,47 +194,6 @@ describe("draftTaskFiles values budgets", () => {
drafted[0]?.values.length ?? 0, drafted[0]?.values.length ?? 0,
); );
}); });
it.each([
{ files: 1, width: 1989, exact: true, cut: false },
{ files: 1, width: 1989, exact: false, cut: true },
{ files: 4, width: 1477, exact: true, cut: false },
{ files: 4, width: 1477, exact: false, cut: true },
])(
"includes serialized array delimiters in the values budget ($files files, exact: $exact)",
({ files, width, exact, cut }) => {
const keys = Array.from(
{ length: 4 },
(_, index) =>
`${String(index)}${"a".repeat(width - (exact && index === 0 ? 1 : 0))}`,
);
const drafted = draftOf(
Object.fromEntries(
Array.from({ length: files }, (_, index) => [
`settings-${String(index)}.json`,
statePath(
"application_state",
"json",
Object.fromEntries(keys.map((key) => [key, "x"])),
Object.fromEntries(keys.map((key) => [key, "y"])),
),
]),
),
);
const sizes = drafted.map(
(file) => new TextEncoder().encode(JSON.stringify(file.values)).length,
);
expect(sizes.every((size) => size <= 8192)).toBe(true);
expect(sizes.reduce((sum, size) => sum + size, 0)).toBeLessThanOrEqual(
24576,
);
expect(drafted.some((file) => file.values_cut)).toBe(cut);
if (exact)
expect(sizes.reduce((sum, size) => sum + size, 0)).toBe(
files === 1 ? 8192 : 24576,
);
},
);
}); });
it("preserves JSON objects whose keys resemble XML and archive views", () => { it("preserves JSON objects whose keys resemble XML and archive views", () => {
+4 -5
View File
@@ -554,9 +554,9 @@ export const draftValues = (
.map((pointer) => entryOf(context, references, pointer, "removed")), .map((pointer) => entryOf(context, references, pointer, "removed")),
].toSorted((left, right) => rank(left) - rank(right)); ].toSorted((left, right) => rank(left) - rank(right));
const values: DraftValue[] = []; const values: DraftValue[] = [];
let bytes = 2; // The serialized values array's brackets. let bytes = 0;
for (const entry of entries) { for (const entry of entries) {
const size = byteLength(entry) + (values.length === 0 ? 0 : 1); const size = byteLength(entry);
if ( if (
values.length >= VALUE_LIMITS.entries || values.length >= VALUE_LIMITS.entries ||
bytes + size > VALUE_LIMITS.fileBytes bytes + size > VALUE_LIMITS.fileBytes
@@ -606,12 +606,11 @@ export const withinDraftBudget = <
>( >(
files: readonly File[], files: readonly File[],
): File[] => { ): File[] => {
// Reserve every file's array brackets before assigning entry bytes in file order. let left = VALUE_LIMITS.draftBytes;
let left = VALUE_LIMITS.draftBytes - files.length * 2;
return files.map((file) => { return files.map((file) => {
const kept: DraftValue[] = []; const kept: DraftValue[] = [];
for (const entry of file.values) { for (const entry of file.values) {
const size = byteLength(entry) + (kept.length === 0 ? 0 : 1); const size = byteLength(entry);
if (size > left) break; if (size > left) break;
kept.push(entry); kept.push(entry);
left -= size; left -= size;
+140 -3
View File
@@ -492,9 +492,146 @@ async def _settled_views(url):
return views, True, False return views, True, False
`; `;
/** Match the current generated verifier exactly before witnessing its task. Older evaluators require a new export. */ /** Where the verifier's evaluator begins. The task description is everything before it. */
export const sameTaskVerifier = (hosted: string, bundled: string): boolean => const VERIFIER_EVALUATOR = "\nMISSING = object()\n";
hosted.includes("\nMISSING = object()\n") && hosted === bundled; /** The index helper this REA ships, and the one an older export wrote. The older one also accepts a leading zero. */
const CURRENT_INDEX = 'INDEX = re.compile(r"0|[1-9][0-9]*")';
const OLDER_INDEX = 'INDEX = re.compile(r"[0-9]+")';
/** A pointer segment the older index helper reads as an array index and this one does not, such as `01`. */
const LEADING_ZERO_SEGMENT = /^0\d+$/u;
/**
* The task a verifier states: the app, the checks and the settle timing,
* without the evaluator that runs them.
*/
export const taskVerifierDescription = (source: string): string | undefined => {
const at = source.indexOf(VERIFIER_EVALUATOR);
return at < 0 ? undefined : source.slice(0, at + 1);
};
/** The evaluator, from `MISSING` through `verify`, or undefined when that marker is absent. */
const verifierEvaluator = (source: string): string | undefined => {
const at = source.indexOf(VERIFIER_EVALUATOR);
return at < 0 ? undefined : source.slice(at);
};
/** Read one Python literal that `pythonLiteral` wrote, from `source` at `index`. */
const readPythonLiteral = (
source: string,
index: number,
): { readonly value: JsonValue; readonly index: number } | undefined => {
let cursor = index;
while (cursor < source.length && /\s/u.test(source[cursor] ?? ""))
cursor += 1;
const character = source[cursor];
if (source.startsWith("None", cursor))
return { value: null, index: cursor + 4 };
if (source.startsWith("True", cursor))
return { value: true, index: cursor + 4 };
if (source.startsWith("False", cursor))
return { value: false, index: cursor + 5 };
if (character === '"' || character === "'") {
let end = cursor + 1;
let escaped = false;
while (end < source.length) {
const next = source[end] ?? "";
if (escaped) escaped = false;
else if (next === "\\") escaped = true;
else if (next === character) break;
end += 1;
}
if (source[end] !== character) return undefined;
const token = source.slice(cursor, end + 1);
const value =
character === '"'
? (JSON.parse(token) as string)
: token.slice(1, -1).replaceAll("\\'", "'");
return { value, index: end + 1 };
}
if (character === "[") {
const items: JsonValue[] = [];
cursor += 1;
while (cursor < source.length) {
while (cursor < source.length && /\s/u.test(source[cursor] ?? ""))
cursor += 1;
if (source[cursor] === "]") return { value: items, index: cursor + 1 };
const item = readPythonLiteral(source, cursor);
if (item === undefined) return undefined;
items.push(item.value);
cursor = item.index;
while (cursor < source.length && /\s/u.test(source[cursor] ?? ""))
cursor += 1;
if (source[cursor] === ",") cursor += 1;
}
return undefined;
}
if (character === "{") {
const record: Record<string, JsonValue> = {};
cursor += 1;
while (cursor < source.length) {
while (cursor < source.length && /\s/u.test(source[cursor] ?? ""))
cursor += 1;
if (source[cursor] === "}") return { value: record, index: cursor + 1 };
const key = readPythonLiteral(source, cursor);
if (key === undefined || typeof key.value !== "string") return undefined;
cursor = key.index;
while (cursor < source.length && /\s/u.test(source[cursor] ?? ""))
cursor += 1;
if (source[cursor] !== ":") return undefined;
const item = readPythonLiteral(source, cursor + 1);
if (item === undefined) return undefined;
record[key.value] = item.value;
cursor = item.index;
while (cursor < source.length && /\s/u.test(source[cursor] ?? ""))
cursor += 1;
if (source[cursor] === ",") cursor += 1;
}
return undefined;
}
const number = /^-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?/u.exec(
source.slice(cursor),
);
if (number?.[0] === undefined) return undefined;
return { value: Number(number[0]), index: cursor + number[0].length };
};
/** Pointers of the checks a verifier states. A pointer inside a check's value is not one. */
const describedPointers = (description: string): readonly string[] => {
const at = description.indexOf("CHECKS = ");
if (at < 0) return [];
const parsed = readPythonLiteral(description, at + "CHECKS = ".length);
if (parsed === undefined || !Array.isArray(parsed.value)) return [];
return parsed.value.flatMap((check) =>
typeof check === "object" &&
check !== null &&
!Array.isArray(check) &&
typeof check.pointer === "string"
? [check.pointer]
: [],
);
};
/**
* Whether two verifiers grade the same task the same way. The description must
* match. The evaluator must be this REA's, or the older index helper alone
* when no pointer segment is a leading-zero number, which that helper would
* index and this one would not.
*/
export const sameTaskVerifier = (hosted: string, bundled: string): boolean => {
const expected = taskVerifierDescription(hosted);
const found = taskVerifierDescription(bundled);
if (expected === undefined || expected !== found) return false;
const hostedBody = verifierEvaluator(hosted);
const bundledBody = verifierEvaluator(bundled);
if (hostedBody === undefined || bundledBody === undefined) return false;
if (hostedBody === bundledBody) return true;
const indexed = describedPointers(expected).some((pointer) =>
pointer.split("/").some((segment) => LEADING_ZERO_SEGMENT.test(segment)),
);
return (
bundledBody === hostedBody.replace(CURRENT_INDEX, OLDER_INDEX) && !indexed
);
};
/** The verifier artifact: a screen-judged task's judge, with the build's examples, or else reads of the app's state through its data/get once its files settle and the task's checks on them. */ /** The verifier artifact: a screen-judged task's judge, with the build's examples, or else reads of the app's state through its data/get once its files settle and the task's checks on them. */
export const renderTaskVerifier = ( export const renderTaskVerifier = (
@@ -18,19 +18,120 @@ const task: DesktopTask = {
}; };
describe("sameTaskVerifier", () => { describe("sameTaskVerifier", () => {
it("accepts the current generated verifier and rejects changed checks or evaluator code", () => { it("accepts an older evaluator of the same checks and rejects a different task", () => {
const hosted = renderTaskVerifier(task, "notes"); const hosted = renderTaskVerifier(task, "notes");
expect(sameTaskVerifier(hosted, hosted)).toBe(true); const older = hosted.replace(
for (const bundled of [ 'INDEX = re.compile(r"0|[1-9][0-9]*")',
hosted.replace('"eggs"', '"milk"'), 'INDEX = re.compile(r"[0-9]+")',
hosted.replace( );
'INDEX = re.compile(r"0|[1-9][0-9]*")',
'INDEX = re.compile(r"[0-9]+")', expect(older).not.toBe(hosted);
expect(sameTaskVerifier(hosted, older)).toBe(true);
expect(sameTaskVerifier(hosted, hosted.replace('"eggs"', '"milk"'))).toBe(
false,
);
expect(
sameTaskVerifier(hosted, older.replace("def _shown", "def _show")),
).toBe(false);
expect(
sameTaskVerifier(
hosted,
hosted.replace(/\nasync def verify[\s\S]*$/u, "\n"),
), ),
hosted.replace("def _shown", "def _show"), ).toBe(false);
hosted.replace(/\nasync def verify[\s\S]*$/u, "\n"), });
])
expect(sameTaskVerifier(hosted, bundled)).toBe(false); it("rejects the older index helper when a path segment has a leading zero", () => {
expect(sameTaskVerifier("not a verifier", "not a verifier")).toBe(false); const indexed: DesktopTask = {
...task,
checks: [
{
id: "line",
kind: "value_absent",
file: "Documents/today.txt",
pointer: "/lines/01",
},
],
};
const hosted = renderTaskVerifier(indexed, "notes");
const older = hosted.replace(
'INDEX = re.compile(r"0|[1-9][0-9]*")',
'INDEX = re.compile(r"[0-9]+")',
);
expect(sameTaskVerifier(hosted, older)).toBe(false);
expect(sameTaskVerifier(hosted, hosted)).toBe(true);
});
it("accepts the older index helper when a leading zero is only in a file name or a URL", () => {
const named: DesktopTask = {
...task,
checks: [
{
id: "guide",
kind: "text_contains",
file: "Documents/01-notes.xml",
pointer: "/root",
value: "https://example.com/01-guide",
},
],
};
const hosted = renderTaskVerifier(named, "notes");
const older = hosted.replace(
'INDEX = re.compile(r"0|[1-9][0-9]*")',
'INDEX = re.compile(r"[0-9]+")',
);
expect(sameTaskVerifier(hosted, older)).toBe(true);
});
it("accepts the older index helper when a leading zero is only inside a check value", () => {
const stored: DesktopTask = {
...task,
checks: [
{
id: "settings",
kind: "value_equals",
file: "Documents/settings.json",
pointer: "/settings",
value: { pointer: "/lines/01" },
},
],
};
const hosted = renderTaskVerifier(stored, "notes");
const older = hosted.replace(
'INDEX = re.compile(r"0|[1-9][0-9]*")',
'INDEX = re.compile(r"[0-9]+")',
);
expect(sameTaskVerifier(hosted, older)).toBe(true);
});
it("rejects the older index helper when a brace inside an earlier check's text hides a leading-zero pointer", () => {
const indexed: DesktopTask = {
...task,
checks: [
{
id: "brace",
kind: "value_equals",
file: "Documents/today.txt",
pointer: "/lines/0",
value: "{",
},
{
id: "line",
kind: "value_absent",
file: "Documents/today.txt",
pointer: "/lines/01",
},
],
};
const hosted = renderTaskVerifier(indexed, "notes");
const older = hosted.replace(
'INDEX = re.compile(r"0|[1-9][0-9]*")',
'INDEX = re.compile(r"[0-9]+")',
);
expect(sameTaskVerifier(hosted, older)).toBe(false);
}); });
}); });
+11 -7
View File
@@ -8,6 +8,7 @@ import {
readerCommand, readerCommand,
} from "./appTaskReaders.js"; } from "./appTaskReaders.js";
const portable = (name: string) => /^[a-z0-9]+(?:-[a-z0-9]+)*$/u.test(name);
const POSTER = "Documents/poster.xcf"; const POSTER = "Documents/poster.xcf";
const LAYERS = { const LAYERS = {
@@ -26,13 +27,16 @@ const LAYER_CHECK = {
}; };
const parse = (change: Record<string, unknown>) => const parse = (change: Record<string, unknown>) =>
parseDesktopTask({ parseDesktopTask(
name: "rename-layer", {
prompt: "In GIMP, rename the Headline layer to Summer Sale and save it.", name: "rename-layer",
checks: [LAYER_CHECK], prompt: "In GIMP, rename the Headline layer to Summer Sale and save it.",
readers: [LAYERS], checks: [LAYER_CHECK],
...change, readers: [LAYERS],
}); ...change,
},
portable,
);
/** Task changes the parser refuses for its readers, each with part of its message. */ /** Task changes the parser refuses for its readers, each with part of its message. */
const REFUSALS: readonly (readonly [Record<string, unknown>, string])[] = [ const REFUSALS: readonly (readonly [Record<string, unknown>, string])[] = [
+40 -56
View File
@@ -53,6 +53,7 @@ POLL_S = 0.05
DRAIN_S = 2.0 DRAIN_S = 2.0
MAX_LINES = 5000 MAX_LINES = 5000
MAX_SAFE_INTEGER = 2**53 - 1 MAX_SAFE_INTEGER = 2**53 - 1
LINE_BREAK = re.compile(r"\r?\n")
SESSION = ("DISPLAY", "WAYLAND_DISPLAY", "XAUTHORITY") SESSION = ("DISPLAY", "WAYLAND_DISPLAY", "XAUTHORITY")
NEVER = ("DBUS_SESSION_BUS_ADDRESS", "SESSION_MANAGER", "XDG_RUNTIME_DIR") NEVER = ("DBUS_SESSION_BUS_ADDRESS", "SESSION_MANAGER", "XDG_RUNTIME_DIR")
FRESH = (("XDG_CONFIG_HOME", ".config"), ("XDG_DATA_HOME", ".local/share"), ("XDG_CACHE_HOME", ".cache"), FRESH = (("XDG_CONFIG_HOME", ".config"), ("XDG_DATA_HOME", ".local/share"), ("XDG_CACHE_HOME", ".cache"),
@@ -166,14 +167,7 @@ def _run(arguments: dict, source: str, work: str) -> dict:
return {"error": f"the reader ran past its {timeout} s{_tail(stderr)}"} return {"error": f"the reader ran past its {timeout} s{_tail(stderr)}"}
if process.returncode != 0: if process.returncode != 0:
return {"error": f"the reader exited {process.returncode}{_tail(stderr)}"} return {"error": f"the reader exited {process.returncode}{_tail(stderr)}"}
# Unmarked startup text is not observation data. Decode only marked rows, lines = [line[len(MARKER):] for line in LINE_BREAK.split(printed.decode("utf-8", "replace")) if line.startswith(MARKER)]
# without replacing malformed bytes with characters the reader never gave.
try:
marker = MARKER.encode("ascii")
lines = [line[len(marker):].removesuffix(b"\r").decode("utf-8")
for line in printed.split(b"\n") if line.startswith(marker)]
except UnicodeDecodeError:
return {"error": "the reader's marked output is not valid UTF-8"}
if not lines: if not lines:
return {"error": f"the reader printed no line starting with {json.dumps(MARKER)}{_tail(stderr)}"} return {"error": f"the reader printed no line starting with {json.dumps(MARKER)}{_tail(stderr)}"}
return _view(arguments["output"], lines) return _view(arguments["output"], lines)
@@ -187,9 +181,9 @@ def main(arguments: dict) -> int:
except (FileNotFoundError, NotADirectoryError): except (FileNotFoundError, NotADirectoryError):
return _say({"absent": True}) return _say({"absent": True})
except OSError as error: except OSError as error:
return _say({"error": f"cannot inspect the reader's source: {error.strerror or error}"}) return _say({"error": f"could not inspect the reader's file: {error.strerror or error}"})
if not stat.S_ISREG(mode): if not stat.S_ISREG(mode):
return _say({"error": "the reader's source is not a regular file"}) return _say({"absent": True})
# A remote caller owns this exact folder before starting the command, and # A remote caller owns this exact folder before starting the command, and
# removes it only after the command has settled. # removes it only after the command has settled.
lease = arguments.get("lease") lease = arguments.get("lease")
@@ -403,56 +397,46 @@ async def _reader_views(env_url, card):
if protocol.find_extension(card, EXEC) is None: if protocol.find_extension(card, EXEC) is None:
raise RuntimeError(f"the computer at {env_url} offers no {EXEC}, which runs the task's readers") raise RuntimeError(f"the computer at {env_url} offers no {EXEC}, which runs the task's readers")
await _cleanup_readers(env_url, card) await _cleanup_readers(env_url, card)
views, absent = {}, [] views = {}
try: for reader in READERS:
for reader in READERS: token = uuid.uuid4().hex
token = uuid.uuid4().hex lease = {"path": f"/tmp/rea-reader-{token}", "token": token}
lease = {"path": f"/tmp/rea-reader-{token}", "token": token} arguments = json.loads("".join(reader["arguments"]))
arguments = json.loads("".join(reader["arguments"])) arguments["lease"] = lease
arguments["lease"] = lease encoded = json.dumps(arguments)
encoded = json.dumps(arguments) params = {"argv": ["python3", "-I", "-c", READER_RUNNER,
params = {"argv": ["python3", "-I", "-c", READER_RUNNER, *(encoded[i:i + 8192] for i in range(0, len(encoded), 8192))],
*(encoded[i:i + 8192] for i in range(0, len(encoded), 8192))], "request_id": f"rea-reader-{token}",
"request_id": f"rea-reader-{token}", "timeout_s": reader["timeout_s"] + RUNNER_SLACK_S}
"timeout_s": reader["timeout_s"] + RUNNER_SLACK_S} task = _owned_task(protocol.invoke_extension(
task = _owned_task(protocol.invoke_extension( env_url, card, EXEC, params, method="run", timeout=reader["timeout_s"] + 2 * RUNNER_SLACK_S))
env_url, card, EXEC, params, method="run", timeout=reader["timeout_s"] + 2 * RUNNER_SLACK_S)) _PENDING_READERS[token] = {"env_url": env_url, "lease": lease, "task": task,
_PENDING_READERS[token] = {"env_url": env_url, "lease": lease, "task": task, "request_id": params["request_id"], "settled": False, "abandoned": False}
"request_id": params["request_id"], "settled": False, "abandoned": False} try:
try: answer = await asyncio.shield(task)
answer = await asyncio.shield(task) except BaseException as error:
except BaseException as error: _PENDING_READERS[token]["abandoned"] = True
_PENDING_READERS[token]["abandoned"] = True error.add_note(f"Reader command {params['request_id']} and folder {lease['path']} remain owned for retry.")
error.add_note(f"Reader command {params['request_id']} and folder {lease['path']} remain owned for retry.") raise
raise if not isinstance(answer, dict) or answer.get("timed_out"):
if not isinstance(answer, dict) or answer.get("timed_out"): _PENDING_READERS[token]["abandoned"] = True
_PENDING_READERS[token]["abandoned"] = True raise RuntimeError(f"Reader {params['request_id']} may still be running; retained {lease['path']}")
raise RuntimeError(f"Reader {params['request_id']} may still be running; retained {lease['path']}") _PENDING_READERS[token]["settled"] = True
_PENDING_READERS[token]["settled"] = True try:
failure = None view = _reader_answer(reader["id"], answer)
try: if view is not None:
view = _reader_answer(reader["id"], answer) views[reader["id"]] = view
if view is None: finally:
absent.append(reader["id"])
else:
views[reader["id"]] = view
except BaseException as error:
failure = error
try: try:
await _cleanup_readers(env_url, card, token) await _cleanup_readers(env_url, card, token)
except BaseException as error: except asyncio.CancelledError as error:
_PENDING_READERS[token]["abandoned"] = True _PENDING_READERS[token]["abandoned"] = True
if failure is not None: error.add_note(f"Reader cleanup remains owned for {lease['path']}; completed reader views: {json.dumps(views)}")
failure.add_note(f"Reader cleanup remains owned for {lease['path']}: {type(error).__name__}: {error}")
raise failure from error
error.add_note(f"Reader cleanup remains owned for {lease['path']}")
raise raise
if failure is not None: except Exception as error:
raise failure _PENDING_READERS[token]["abandoned"] = True
return views raise RuntimeError(f"{error}; completed reader views: {json.dumps(views)}") from error
except BaseException as error: return views
error.add_note(f"completed reader views: {json.dumps(views)}; completed reader absent: {json.dumps(absent)}")
raise
def evaluate_with_readers(checks: list, views: dict, readers: dict) -> list: def evaluate_with_readers(checks: list, views: dict, readers: dict) -> list:
+11 -7
View File
@@ -10,19 +10,23 @@ import {
screenExamplesOf, screenExamplesOf,
} from "./appTaskScreen.js"; } from "./appTaskScreen.js";
const portable = (name: string) => /^[a-z0-9]+(?:-[a-z0-9]+)*$/u.test(name);
const SHOWN = { const SHOWN = {
id: "shown", id: "shown",
kind: "screen_judge", kind: "screen_judge",
question: "Does the calculator's display show the result 8.568?", question: "Does the calculator's display show the result 8.568?",
}; };
const parse = (change: Record<string, unknown>) => const parse = (change: Record<string, unknown>) =>
parseDesktopTask({ parseDesktopTask(
name: "tip-total", {
prompt: name: "tip-total",
"In Calculator, work out 18% of 47.60 and leave the result showing.", prompt:
checks: [SHOWN], "In Calculator, work out 18% of 47.60 and leave the result showing.",
...change, checks: [SHOWN],
}); ...change,
},
portable,
);
const EXAMPLES = { fail: "c2VlZA==", pass: "ZW5k" }; const EXAMPLES = { fail: "c2VlZA==", pass: "ZW5k" };
describe("screen_judge checks", () => { describe("screen_judge checks", () => {
-12
View File
@@ -188,18 +188,6 @@ describe("build secrets in escaped text", () => {
[{ handle: "xml", value: marked }], [{ handle: "xml", value: marked }],
), ),
).toBe('<p title="<secret:xml>">'); ).toBe('<p title="<secret:xml>">');
const xmlValue = 'e-a&b<c>d"-Fake';
const xmlSecrets = [{ handle: "xml", value: xmlValue }];
for (const encoded of [
"e-a&amp;b&lt;c>d&quot;-Fake",
"e-a&amp;b&lt;c&gt;d&quot;-Fake",
]) {
const document = `<p title="${encoded}">`;
expect(secretsIn(Buffer.from(document), xmlSecrets)).toEqual(["xml"]);
expect(redactSecrets(document, xmlSecrets)).toBe(
'<p title="<secret:xml>">',
);
}
expect(redactSecrets("text… unrelated", secrets)).toBe("text… unrelated"); expect(redactSecrets("text… unrelated", secrets)).toBe("text… unrelated");
const quoted = 'e-say"hi"\\now-Fake'; const quoted = 'e-say"hi"\\now-Fake';
expect( expect(
+2 -5
View File
@@ -74,13 +74,10 @@ const textSpellings = (value: string): readonly string[] => {
const form = new URLSearchParams([["value", value]]) const form = new URLSearchParams([["value", value]])
.toString() .toString()
.slice("value=".length); .slice("value=".length);
const escapedXml = xmlEscaped(value);
const xml = [ const xml = [
value, value,
...[escapedXml, escapedXml.replaceAll(">", "&gt;")].flatMap((text) => [ xmlEscaped(value),
text, xmlEscaped(value).replaceAll('"', "&quot;"),
text.replaceAll('"', "&quot;"),
]),
]; ];
return [ return [
...new Set([ ...new Set([
@@ -32,7 +32,6 @@ describe("the adaptive band", () => {
["3 of 3", "too_easy", [pass, pass, pass]], ["3 of 3", "too_easy", [pass, pass, pass]],
["1 of 3", "undecided", [pass, miss, miss]], ["1 of 3", "undecided", [pass, miss, miss]],
["2 of 3", "undecided", [pass, pass, miss]], ["2 of 3", "undecided", [pass, pass, miss]],
["0 of 5", "too_hard", [miss, miss, miss, miss, miss]],
["1 of 5", "in_band", [pass, miss, miss, miss, miss]], ["1 of 5", "in_band", [pass, miss, miss, miss, miss]],
["3 of 5", "in_band", [pass, pass, miss, pass, miss]], ["3 of 5", "in_band", [pass, pass, miss, pass, miss]],
["4 of 5", "too_easy", [pass, pass, miss, pass, pass]], ["4 of 5", "too_easy", [pass, pass, miss, pass, pass]],
+1 -5
View File
@@ -57,11 +57,7 @@ const counts = (solves: readonly SolveRecord[]) => {
export const bandOf = (solves: readonly SolveRecord[]): Band => { export const bandOf = (solves: readonly SolveRecord[]): Band => {
const { done, passes } = counts(solves); const { done, passes } = counts(solves);
if (done >= ALL_SOLVES) if (done >= ALL_SOLVES)
return passes === 0 return passes <= MOST_BAND_PASSES ? "in_band" : "too_easy";
? "too_hard"
: passes <= MOST_BAND_PASSES
? "in_band"
: "too_easy";
if (done >= FIRST_SOLVES && passes === 0) return "too_hard"; if (done >= FIRST_SOLVES && passes === 0) return "too_hard";
if (done >= FIRST_SOLVES && passes === done) return "too_easy"; if (done >= FIRST_SOLVES && passes === done) return "too_easy";
return "undecided"; return "undecided";
-38
View File
@@ -1,38 +0,0 @@
import { z } from "zod";
import { normalizeHttpHostname } from "./httpHostname.js";
import { err, ok, type Result } from "./result.js";
/** Agentenv folder and local-id spelling, also used for authored task files. */
export const PORTABLE_BUNDLE_NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u;
// A compose service name is one DNS label, whose wire limit is 63 octets.
export const PORTABLE_BUNDLE_NAME_MAX_LENGTH = 63;
export const PORTABLE_BUNDLE_NAME_CONSTRAINT = `a kebab-case label of at most ${String(PORTABLE_BUNDLE_NAME_MAX_LENGTH)} characters, excluding Windows device names, that remains unchanged as an HTTP service hostname`;
const WINDOWS_RESERVED_NAME = /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])$/u;
export type PortableBundleNameFailure = "format" | "reserved" | "hostname";
/** Admit the exact source name without normalizing it into another identity. */
export const parsePortableBundleName = (
name: string,
): Result<string, PortableBundleNameFailure> => {
if (
!PORTABLE_BUNDLE_NAME_PATTERN.test(name) ||
name.length > PORTABLE_BUNDLE_NAME_MAX_LENGTH
)
return err("format");
if (WINDOWS_RESERVED_NAME.test(name)) return err("reserved");
if (normalizeHttpHostname(name) !== name) return err("hostname");
return ok(name);
};
/** Boolean admission for callers that do not project a refusal. */
export const isPortableBundleName = (name: string): boolean =>
parsePortableBundleName(name).ok;
/** Lookup names share portable identity; authoring reservations are separate. */
export const portableBundleNameSchema = z
.string()
.regex(PORTABLE_BUNDLE_NAME_PATTERN)
.max(PORTABLE_BUNDLE_NAME_MAX_LENGTH)
.refine(isPortableBundleName, `Expected ${PORTABLE_BUNDLE_NAME_CONSTRAINT}`);
+54
View File
@@ -407,6 +407,30 @@ it("charges long digits to the string budget and keeps large previews bounded",
); );
}); });
it("reads marshal one-byte string tags as Latin-1 with Unicode identity", () => {
for (const tag of [0x61, 0x41, 0x7a, 0x5a]) {
const encoded = Buffer.concat([
Buffer.from([tag]),
tag === 0x61 || tag === 0x41 ? u32(1) : Buffer.from([1]),
Buffer.from([0xe9]),
]);
const sameUnicode = marshalUnicode(Buffer.from("é", "utf8"));
const set = Buffer.concat([
Buffer.from([0x3c]),
u32(2),
encoded,
sameUnicode,
]);
const report = root(parse(withConsts([encoded, set])));
expect(report.consts[0]).toMatchObject({
kind: "str",
value: "é",
bytes: 1,
});
expect(report.consts[1]).toEqual({ kind: "set", length: 1 });
}
});
it("decodes marshal strings with CPython surrogatepass rules", () => { it("decodes marshal strings with CPython surrogatepass rules", () => {
const report = parse( const report = parse(
withConsts([marshalUnicode(Buffer.from("efbbbf73eda080efbbbf", "hex"))]), withConsts([marshalUnicode(Buffer.from("efbbbf73eda080efbbbf", "hex"))]),
@@ -1534,6 +1558,36 @@ it("hashes a long TYPE_REF tuple chain without recursing", () => {
expect(pythonPycSchema.safeParse(report).success).toBe(true); expect(pythonPycSchema.safeParse(report).success).toBe(true);
}); });
it("hashes shallow TYPE_REF code chains without using the JavaScript stack", () => {
const links = 3000;
const codes = Array.from({ length: links }, (_, index) =>
marshalCode("py311", {
flagged: true,
name: "f",
consts: index === 0 ? [Buffer.from([0x4e])] : [marshalRef(index - 1)],
}),
);
const bytes = Buffer.concat([
pythonPycHeader(3531),
marshalCode("py311", {
constsObject: Buffer.concat([
Buffer.from([0x28]),
u32(links + 1),
...codes,
Buffer.from([0x3c]),
u32(1),
marshalRef(links - 1),
]),
}),
]);
const report = parseWith(bytes, { max_objects: 65_536 });
expect(root(report).consts.at(-1)).toEqual({ kind: "set", length: 1 });
expect(report.coverage).toMatchObject({
status: "complete",
code_objects: links + 1,
});
});
it("rejects code CPython treats as corrupt instead of complete coverage", () => { it("rejects code CPython treats as corrupt instead of complete coverage", () => {
const file = ( const file = (
layout: "py37" | "py38" | "py311", layout: "py37" | "py38" | "py311",
+83 -17
View File
@@ -332,7 +332,7 @@ class MarshalReader {
readonly #codeKeys = new WeakMap<CodeNode, string>(); readonly #codeKeys = new WeakMap<CodeNode, string>();
readonly #codeIdentities = new WeakMap<CodeNode, number>(); readonly #codeIdentities = new WeakMap<CodeNode, number>();
readonly #codeEquality = new WeakMap<CodeNode, CodeEquality>(); readonly #codeEquality = new WeakMap<CodeNode, CodeEquality>();
/** Python hashability per code object. False while that check is in progress. */ /** Completed Python hashability checks per code object. */
readonly #codeHashable = new WeakMap<CodeNode, boolean>(); readonly #codeHashable = new WeakMap<CodeNode, boolean>();
/** Tuples whose items are still being read; CPython cannot hash them yet. */ /** Tuples whose items are still being read; CPython cannot hash them yet. */
readonly #readingTuples = new WeakSet<MarshalValue>(); readonly #readingTuples = new WeakSet<MarshalValue>();
@@ -436,13 +436,19 @@ class MarshalReader {
}; };
case TYPE_INTERNED: case TYPE_INTERNED:
case TYPE_UNICODE: case TYPE_UNICODE:
return this.utf8(this.nonNegativeInt32("Marshal string length"), false); return this.string(
this.nonNegativeInt32("Marshal string length"),
"utf8",
);
case TYPE_ASCII: case TYPE_ASCII:
case TYPE_ASCII_INTERNED: case TYPE_ASCII_INTERNED:
return this.utf8(this.nonNegativeInt32("Marshal string length"), true); return this.string(
this.nonNegativeInt32("Marshal string length"),
"latin1",
);
case TYPE_SHORT_ASCII: case TYPE_SHORT_ASCII:
case TYPE_SHORT_ASCII_INTERNED: case TYPE_SHORT_ASCII_INTERNED:
return this.utf8(this.u8(), true); return this.string(this.u8(), "latin1");
case TYPE_TUPLE: case TYPE_TUPLE:
return this.sequenceValue( return this.sequenceValue(
"tuple", "tuple",
@@ -1309,13 +1315,70 @@ class MarshalReader {
#isCodeHashable(node: CodeNode): boolean { #isCodeHashable(node: CodeNode): boolean {
const known = this.#codeHashable.get(node); const known = this.#codeHashable.get(node);
if (known !== undefined) return known; if (known !== undefined) return known;
this.#codeHashable.set(node, false); const frames: { value: MarshalValue; finish: boolean }[] = [
const material = { value: { kind: "code", value: node }, finish: false },
this.#codeEquality.get(node) ?? ];
malformed("Code object is missing equality material."); const gray = new Set<MarshalValue | CodeNode>();
const hashable = this.#equalityKey(material.constsTuple, "hash") !== null; const complete = new Set<MarshalValue>();
this.#codeHashable.set(node, hashable); const reject = (): false => {
return hashable; this.#codeHashable.set(node, false);
return false;
};
while (frames.length > 0) {
this.#takeKeyStep();
const frame = frames.pop()!;
const value = frame.value;
const identity = value.kind === "code" ? value.value : value;
if (frame.finish) {
gray.delete(identity);
if (value.kind === "code") {
const material =
this.#codeEquality.get(value.value) ??
malformed("Code object is missing equality material.");
// Child code checks are now complete, so the existing tuple key
// walker preserves its equality and expansion budgets without recursion.
const hashable =
this.#equalityKey(material.constsTuple, "hash") !== null;
this.#codeHashable.set(value.value, hashable);
if (!hashable) return reject();
} else complete.add(value);
continue;
}
if (value.kind === "code") {
const cached = this.#codeHashable.get(value.value);
if (cached === false) return reject();
if (cached === true) continue;
} else {
const cached = this.#keyCache.get(value);
if (cached === null) return reject();
if (cached !== undefined || complete.has(value)) continue;
}
if (
gray.has(identity) ||
this.#readingTuples.has(value) ||
value.kind === "list" ||
value.kind === "set" ||
value.kind === "dict" ||
value.kind === "null"
)
return reject();
const children =
value.kind === "code"
? [
(
this.#codeEquality.get(value.value) ??
malformed("Code object is missing equality material.")
).constsTuple,
]
: value.kind === "tuple" || value.kind === "frozenset"
? value.items
: [];
gray.add(identity);
frames.push({ value, finish: true });
for (let index = children.length - 1; index >= 0; index -= 1)
frames.push({ value: children[index]!, finish: false });
}
return true;
} }
#codeIdentity(node: CodeNode): number { #codeIdentity(node: CodeNode): number {
@@ -1492,17 +1555,20 @@ class MarshalReader {
); );
} }
utf8(length: number, ascii: boolean): MarshalValue { string(length: number, encoding: "latin1" | "utf8"): MarshalValue {
const bytes = this.blob(length); const bytes = this.blob(length);
if (ascii) { if (encoding === "latin1") {
if (bytes.some((byte) => byte > 0x7f)) // CPython reads the ASCII-tagged forms as one-byte Unicode, even when
malformed("Marshal ASCII string contains a non-ASCII byte."); // high bytes appear. Identity must agree with the UTF-8 string form.
const value = bytes.toString("latin1");
return { return {
kind: "str", kind: "str",
value: bytes.toString("latin1"), value,
previewEnd: Math.min(length, PYTHON_PYC_CONST_PREVIEW_LIMIT), previewEnd: Math.min(length, PYTHON_PYC_CONST_PREVIEW_LIMIT),
bytes: length, bytes: length,
raw: bytes, raw: bytes.some((byte) => byte > 0x7f)
? Buffer.from(value, "utf8")
: bytes,
}; };
} }
const decoded = decodeMarshalUtf8(bytes, PYTHON_PYC_CONST_PREVIEW_LIMIT); const decoded = decodeMarshalUtf8(bytes, PYTHON_PYC_CONST_PREVIEW_LIMIT);
+2 -1
View File
@@ -4,6 +4,7 @@ import { join } from "node:path";
import { expect, it } from "vitest"; import { expect, it } from "vitest";
import { isPortableBundleName } from "../../../src/application/environment/environmentBundle.js";
import { toolContract } from "../../../src/contracts/toolContracts.js"; import { toolContract } from "../../../src/contracts/toolContracts.js";
import { parseDesktopTask } from "../../../src/domain/desktop/appTask.js"; import { parseDesktopTask } from "../../../src/domain/desktop/appTask.js";
import { import {
@@ -39,7 +40,7 @@ const README = "# Notes computer app\n\n## Task `set-year`\n\nThe task.\n";
/** A bundle holding TASK as the export writes it, with its README. */ /** A bundle holding TASK as the export writes it, with its README. */
const bundleWithTask = async () => { const bundleWithTask = async () => {
const parsed = parseDesktopTask(TASK); const parsed = parseDesktopTask(TASK, isPortableBundleName);
if (!parsed.ok) throw new Error(parsed.error); if (!parsed.ok) throw new Error(parsed.error);
const bundle = await createTestTempDirectory("measured-bundle-"); const bundle = await createTestTempDirectory("measured-bundle-");
const steps = renderTaskSteps(parsed.value, "notes"); const steps = renderTaskSteps(parsed.value, "notes");
+2 -1
View File
@@ -12,6 +12,7 @@ import { gzipSync } from "node:zlib";
import type { CallToolResult } from "@modelcontextprotocol/server"; import type { CallToolResult } from "@modelcontextprotocol/server";
import { expect, it, vi } from "vitest"; import { expect, it, vi } from "vitest";
import { isPortableBundleName } from "../../../src/application/environment/environmentBundle.js";
import { toolContract } from "../../../src/contracts/toolContracts.js"; import { toolContract } from "../../../src/contracts/toolContracts.js";
import { parseDesktopTask } from "../../../src/domain/desktop/appTask.js"; import { parseDesktopTask } from "../../../src/domain/desktop/appTask.js";
import { import {
@@ -141,7 +142,7 @@ const lines = (...text: string[]) => ({
/** A bundle folder holding TASK as the export writes it for the app. */ /** A bundle folder holding TASK as the export writes it for the app. */
const taskBundle = async (app: string) => { const taskBundle = async (app: string) => {
const parsed = parseDesktopTask(TASK); const parsed = parseDesktopTask(TASK, isPortableBundleName);
if (!parsed.ok) throw new Error(parsed.error); if (!parsed.ok) throw new Error(parsed.error);
const bundle = await bundleFolder(); const bundle = await bundleFolder();
const verifier = join( const verifier = join(
+7 -10
View File
@@ -8,6 +8,7 @@ import type {
BuildSettle, BuildSettle,
SettleClock, SettleClock,
} from "../../../src/application/desktop/DesktopBuildSettle.js"; } from "../../../src/application/desktop/DesktopBuildSettle.js";
import { isPortableBundleName } from "../../../src/application/environment/environmentBundle.js";
import { toolContract } from "../../../src/contracts/toolContracts.js"; import { toolContract } from "../../../src/contracts/toolContracts.js";
import type { SettleTiming } from "../../../src/domain/desktop/appSettle.js"; import type { SettleTiming } from "../../../src/domain/desktop/appSettle.js";
import { parseDesktopTask } from "../../../src/domain/desktop/appTask.js"; import { parseDesktopTask } from "../../../src/domain/desktop/appTask.js";
@@ -57,7 +58,7 @@ const lines = (...text: string[]) => ({
/** A bundle folder holding `task` as the export writes it for the app. */ /** A bundle folder holding `task` as the export writes it for the app. */
const taskBundle = async (task: unknown, app: string) => { const taskBundle = async (task: unknown, app: string) => {
const parsed = parseDesktopTask(task); const parsed = parseDesktopTask(task, isPortableBundleName);
if (!parsed.ok) throw new Error(parsed.error); if (!parsed.ok) throw new Error(parsed.error);
const bundle = await bundleFolder(); const bundle = await bundleFolder();
const verifier = join( const verifier = join(
@@ -515,15 +516,11 @@ it("ends the witness when the seed already passes, and refuses its later stages"
parsed(await check({ task: passing, stage: "seed", directory })).witness, parsed(await check({ task: passing, stage: "seed", directory })).witness,
).toEqual(seed.witness); ).toEqual(seed.witness);
const elsewhere = `${directory}-elsewhere`; const elsewhere = `${directory}-elsewhere`;
const refusal = parseMcpToolError( expect(
await check({ task: passing, stage: "seed", directory: elsewhere }), parseMcpToolError(
).error; await check({ task: passing, stage: "seed", directory: elsewhere }),
expect(refusal).toMatchObject({ ).error.message,
code: "invalid_request", ).toContain(`saved to ${directory}, not ${elsewhere}`);
message: expect.stringContaining(
`record belongs to ${directory}, not ${elsewhere}`,
),
});
await act("type", { text: "eggs\n" }); await act("type", { text: "eggs\n" });
expect( expect(
parseMcpToolError(await check({ task: passing, stage: "after_edit" })).error parseMcpToolError(await check({ task: passing, stage: "after_edit" })).error
@@ -157,17 +157,11 @@ async def run():
second = asyncio.create_task(other.verify("http://mcp", "http://computer:8080")) second = asyncio.create_task(other.verify("http://mcp", "http://computer:8080"))
results, _ = await asyncio.gather(first, second) results, _ = await asyncio.gather(first, second)
return results, first_error return results, first_error
if scenario.get("report_failure"):
try:
await module.verify("http://mcp", "http://computer:8080")
except BaseException as error:
return [], first_error or str(error) + " ".join(getattr(error, "__notes__", ()))
raise AssertionError("the controlled reader failure did not occur")
results = await module.verify("http://mcp", "http://computer:8080") results = await module.verify("http://mcp", "http://computer:8080")
return results, first_error return results, first_error
results, first_error = asyncio.run(run()) results, first_error = asyncio.run(run())
print(json.dumps({"called": called, "reads": reads, "ended": clock[0], "results": results, "execs": execs, "first_error": first_error, "pending_readers": len(module._PENDING_READERS) if hasattr(module, "_PENDING_READERS") else 0})) print(json.dumps({"called": called, "reads": reads, "ended": clock[0], "results": results, "execs": execs, "first_error": first_error}))
`; `;
type VerifierOutput = { type VerifierOutput = {
@@ -176,7 +170,6 @@ type VerifierOutput = {
reads: number[]; reads: number[];
ended: number; ended: number;
first_error: string | null; first_error: string | null;
pending_readers: number;
results: { results: {
id: string; id: string;
result: boolean; result: boolean;
@@ -201,7 +194,6 @@ const runVerifier = async (
readonly reloadVerifier?: boolean; readonly reloadVerifier?: boolean;
readonly concurrent?: boolean; readonly concurrent?: boolean;
readonly parallelRetry?: boolean; readonly parallelRetry?: boolean;
readonly reportFailure?: boolean;
} = {}, } = {},
): Promise<VerifierOutput> => { ): Promise<VerifierOutput> => {
const folder = await createTestTempDirectory("desktop-task-python-"); const folder = await createTestTempDirectory("desktop-task-python-");
@@ -240,7 +232,6 @@ const runVerifier = async (
reload_verifier: computer.reloadVerifier, reload_verifier: computer.reloadVerifier,
concurrent: computer.concurrent, concurrent: computer.concurrent,
parallel_retry: computer.parallelRetry, parallel_retry: computer.parallelRetry,
report_failure: computer.reportFailure,
}), }),
encoding: "utf8", encoding: "utf8",
}), }),
@@ -645,60 +636,3 @@ it.each([
).rejects.toThrow(message); ).rejects.toThrow(message);
}, },
); );
it.each([
{ transport: false, pending: 0 },
{ transport: true, pending: 1 },
])(
"preserves completed views and absence when a later reader fails (transport: $transport)",
async ({ transport, pending }) => {
const output = await runVerifier(READER_CHECKS, [[0, CHECK_VIEWS]], [], {
readers: [READERS[0]!, READERS[3]!, READERS[2]!],
answers: {
layers: ANSWERS.layers,
gone: ANSWERS.gone,
...(transport ? {} : { broken: "broken runner answer" }),
},
reportFailure: true,
});
expect(output.first_error).toContain("broken");
const facts =
/completed reader views: (.*); completed reader absent: (.*)$/u.exec(
output.first_error ?? "",
);
expect(facts).not.toBeNull();
expect(JSON.parse(facts![1]!)).toEqual({
layers: (ANSWERS.layers as { view: unknown }).view,
});
expect(JSON.parse(facts![2]!)).toEqual(["gone"]);
expect(output.pending_readers).toBe(pending);
},
);
it.each([false, true])(
"keeps a malformed reader answer primary alongside cleanup and retry (retry: %s)",
async (retry) => {
const output = await runVerifier(READER_CHECKS, [[0, CHECK_VIEWS]], [], {
readers: [READERS[2]!],
answers: { broken: "invalid runner payload" },
cleanupFailures: 1,
reportFailure: true,
retry,
});
expect(output.first_error).toContain("did not answer for broken");
expect(output.first_error).toContain("invalid runner payload");
expect(output.first_error).toContain("permission denied");
expect(output.first_error).toContain("/tmp/rea-reader-");
expect(output.pending_readers).toBe(retry ? 0 : 1);
if (retry) {
const cleanups = output.execs.filter(({ request_id }) =>
String(request_id).startsWith("rea-reader-cleanup-"),
);
expect(cleanups).toHaveLength(3);
const lease = (entry: Record<string, unknown>) =>
JSON.parse((entry.argv as string[])[4]!);
expect(lease(cleanups[1]!)).toEqual(lease(cleanups[0]!));
expect(lease(cleanups[2]!)).not.toEqual(lease(cleanups[0]!));
}
},
);
@@ -35,7 +35,7 @@ const expectUnread = async (source: string) => {
expect(view).toMatchObject({ expect(view).toMatchObject({
format: null, format: null,
view: null, view: null,
error: expect.stringContaining("cannot inspect the reader's source"), error: expect.stringContaining("could not inspect the reader's file"),
}); });
if (view === undefined) throw new Error("inspection failure became absence"); if (view === undefined) throw new Error("inspection failure became absence");
const [outcome] = evaluateTaskChecks( const [outcome] = evaluateTaskChecks(
@@ -1,5 +1,5 @@
import { execFile } from "node:child_process"; import { execFile } from "node:child_process";
import { access, chmod, mkdir, readFile, writeFile } from "node:fs/promises"; import { access, mkdir, readFile, writeFile } from "node:fs/promises";
import { dirname, join } from "node:path"; import { dirname, join } from "node:path";
import { promisify } from "node:util"; import { promisify } from "node:util";
@@ -209,66 +209,6 @@ it("answers absent for a file the home does not hold, without running the reader
await expect(access(join(home, "ran"))).rejects.toThrow(); await expect(access(join(home, "ran"))).rejects.toThrow();
}); });
it.skipIf(process.platform === "win32" || process.getuid?.() === 0)(
"keeps an inaccessible source unknown instead of answering absent",
async () => {
const { home, app } = await computer();
const documents = join(home, "Documents");
await chmod(documents, 0);
try {
const view = readerAnswerView(await run(home, reporting(app)));
expect(view).toMatchObject({ format: null, view: null });
expect(view?.error).toContain("cannot inspect");
} finally {
await chmod(documents, 0o700);
}
},
);
it.each(["json", "lines"] as const)(
"refuses malformed UTF-8 in marked %s output while preserving literal replacement characters",
async (output) => {
const { home } = await computer();
const reader = (bytes: string): DesktopTaskReader => ({
id: "encoding",
file: NOTE,
argv: [
"python3",
"-I",
"-c",
`import sys; sys.stdout.buffer.write(${bytes})`,
"{file}",
],
output,
});
const malformed = readerAnswerView(
await run(home, reader(String.raw`b'REA {"value":"\xff"}\n'`)),
);
expect(malformed).toMatchObject({ format: null, view: null });
expect(malformed?.error).toContain("UTF-8");
const valid = readerAnswerView(
await run(
home,
reader(
String.raw`b'\xff ignored banner\nREA {"value":"\xef\xbf\xbd"}\r\n'`,
),
),
);
expect(valid).toMatchObject(
output === "json"
? { format: "json", view: { value: "\ufffd" } }
: {
format: "text",
view: {
lines: ['{"value":"\ufffd"}'],
line_count: 1,
truncated: false,
},
},
);
},
);
it.each([ it.each([
[ [
"exits non-zero", "exits non-zero",