mirror of
https://github.com/morluto/rea.git
synced 2026-11-12 01:41:21 +00:00
* fix(desktop): preserve document URLs and value formats * fix(desktop): bound added checks and omit noisy text guards * fix(desktop): exclude unseeded locks from task checks * fix(desktop): preserve draft bounds and launch URLs * fix(desktop): render draft values using their source format * fix(desktop): preserve launch URLs and JSON draft values * fix(desktop): recognize captured file URL documents * fix(desktop): render captured file URLs for runtime home * fix(desktop): distinguish phone numbers from save metadata * fix(desktop): preserve launch URLs and user text * fix(desktop): preserve captured URLs and user text * fix(desktop): preserve local file URL authority * fix(desktop): preserve local file URL authority * fix(desktop): preserve local file URL authority * fix(desktop): render captured file URLs at launch * fix(desktop): classify numeric timestamps using field context * fix(desktop): classify numeric timestamps using field context * fix(desktop): classify numeric timestamps using field context * fix(desktop): retain context for generated draft values * fix(desktop): retain context for generated draft values * fix(desktop): retain context for generated draft values * test(desktop): cover generated IDs in text guards * test(desktop): cover generated IDs in text guards * test(desktop): cover generated IDs in text guards * fix(desktop): isolate launcher URL encoding * fix(desktop): isolate launcher URL encoding * fix(desktop): isolate launcher URL encoding * fix(desktop): isolate launcher URL encoding * fix(desktop): retain clean text in noisy record fields * fix(desktop): classify added line values in one pass * fix(desktop): apply XML quote rules to text drafts * fix(desktop): distinguish XML attributes from quoted text * fix(desktop): preserve parsed XML line context * Format appTaskChecks.ts with the pinned oxfmt Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(desktop): retain partial XML syntax facts * fix(desktop): recognize markdown autolinks in drafts * fix(desktop): reuse export exclusions in task drafts * fix(desktop): report unchecked client build paths * docs(desktop): clarify gzip scrub limits * fix(desktop): reuse export exclusions in task drafts * docs(desktop): keep the judge command on one line Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(desktop): clarify gzip scrub limits * fix(setup): own desktop build process cleanup * fix(doctor): compare desktop settings with resolved defaults * style(desktop): format the noisy-record draft files with the pinned oxfmt Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(desktop): give the root-array draft test its own describe The records describe grew past the 150-line lint limit with it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(doctor): inspect client-recorded desktop runners * fix(doctor): reuse matching runner probes * fix(setup): propagate cancellation to desktop actions * fix(setup): stop Hopper on cancel, never report a cancelled setup ready, and keep a failed action's client setting - The Hopper install gets setup's signal: Linux already honoured it, and the macOS installer now stops its downloads and stops before mounting or copying. - runSetup checks the signal after the final doctor and close, so a cancellation there raises instead of returning ready. - A failed pack or agentenv install no longer writes its archive or runner to client configs, so the setting the client already had stays. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(setup): keep the completed setup result in a cancellation after cleanup A cancellation during the final doctor or host close carried only the applied actions, dropping each client's result and backup path and the doctor report. It now carries the whole result as setup_result, as doctor's cancellation carries its report. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(desktop): move launch execution to boundary suite * fix(desktop): redact build schema failure output * refactor(desktop): normalize schema failure once * fix(desktop): omit unsafe schema parser snippets * fix(desktop): preserve draft change and selected path evidence * test(desktop): group retained dconf note regressions * fix(desktop): respect archive entry format in draft checks * test(desktop): separate unowned-file draft note cases * fix(desktop): preserve explicitly selected journal documents * fix(xml): avoid scanning selected ancestors per tag * fix(desktop): exclude covered text runs from draft counts * fix: preserve text when drafting desktop checks * fix: restore nested desktop state aliases * refactor: share UUID recognition across draft checks * fix: retain multiline XML attribute context * fix: retain relationship identifiers in task drafts * docs: clarify desktop draft identifier handling * test(desktop): type formatting run cases as pairs * fix(desktop): carry document format into selector evaluation * test(desktop): separate markup boundary regression group * fix(desktop): propagate formats through draft selectors * fix(desktop): switch selector format within archive entries * test(desktop): remove duplicate seed filename case * fix(setup): retain diagnostics when cancellation overlaps failure * refactor(setup): keep command cancellation at its owner * test(setup): verify cancellation at the application boundary * test(desktop): pass JSON format in exported index parity * docs(desktop): clarify task checks before scratch export * fix(desktop): respell selected paths in file URLs * test(desktop): group captured document spelling cases * test(desktop): pin reader-free verifier to selector parent * fix(desktop): bound draft preview serialization * fix(desktop): restore selected state aliases * fix(desktop): respect entry format in draft noise * test(desktop): match exported alias evidence at its JSON boundary * fix(desktop): retain app state logs in exported seeds * fix: align desktop numeric seed comparisons * test(desktop): keep one draft exclusion fixture import * test(desktop): retain log eligibility across draft test groups * fix(doctor): resolve empty registered desktop runner * test(desktop): check retained log seed admission * style: fix desktop launch test formatting * style: format native command path schema * fix(desktop): map selected external launch paths * style(desktop): format seed document regressions * test(desktop): group launch URL regressions * fix(desktop): omit redundant nested state aliases * fix(desktop): compare added free text as a whole * fix(desktop): check recompressed seeds as shipped * docs(desktop): clarify recovered seed format * test(desktop): group shipped gzip seed checks * fix: retain quoted identifier text in task drafts * fix: recognize Unicode XML in desktop state readers * fix(desktop): bound nested draft checks * fix(desktop): explain missing arrays in truncated views * fix(desktop): find XML type references in archives * test(desktop): remove duplicated capture assertions * fix(desktop): retain nested omitted text boundaries * style(desktop): format draft value imports * test(desktop): group model and draft value checks * fix(desktop): map captured launch paths once * refactor: remove unused whole-text guard state * refactor: stop passing unused text children * test: align free-text checks with whole-note policy * fix: validate recompressed desktop archive seeds * style(desktop): format archive seed regression * fix(desktop): retain placed-item sibling guards * fix(desktop): preserve archive paths and bound doctor reads * fix(doctor): refuse non-files before descriptor admission * fix(setup): track remaining cleanup resources across retries * test(desktop): specify XML format in heap pairing fixture * fix(desktop): compare screen task views by value * fix(desktop): reject inexact reader numbers * fix(desktop): budget export for both reader passes * fix(desktop): bound reader runner argument size * test(desktop): verify chunked reader payloads and parent output * fix(doctor): query agentenv for model endpoint * style(desktop): format reader payload assertion * fix(doctor): preserve uncertainty for empty endpoint override * fix(desktop): redact runtime schema errors * fix(desktop): normalize captured file URLs * fix(desktop): report seeded files by capture path * fix(desktop): retain startup readiness failure stage * fix(desktop): retain JSON date checks * fix(desktop): preserve archive formatting checks * fix(desktop): preserve explicitly selected journal files * fix(desktop): report build command transport limits * docs(desktop): clarify unreadable dconf versions * test(desktop): remove duplicate history regression * fix(desktop): align XML names with exported reader * fix(desktop): create selected state alias targets * fix(desktop): inspect supported runtime archives * fix(desktop): retain record identity in draft checks * fix(desktop): align timestamp selector digits * fix(desktop): scrub XML independently of reader compatibility * fix(desktop): preserve file aliases and reader diagnostics * fix(desktop): retain remote reader cleanup ownership * style(desktop): format capture review fixes * fix(desktop): retain reader leases across verifier reloads * test(desktop): overlap reader cleanup retries * fix(desktop): run long exports within exec limits --------- Co-authored-by: morluto <76467478+morluto@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
370 lines
12 KiB
TypeScript
370 lines
12 KiB
TypeScript
import { createHash } from "node:crypto";
|
|
import { readFile } from "node:fs/promises";
|
|
import { fileURLToPath } from "node:url";
|
|
import { Buffer } from "node:buffer";
|
|
|
|
import { z } from "zod";
|
|
|
|
import { AnalysisResourceConstraintError } from "../domain/analysisErrorCore.js";
|
|
import type { JsonValue } from "../domain/jsonValue.js";
|
|
import {
|
|
guestFileAnswerBytes,
|
|
guestFileAnswerCeiling,
|
|
guestFileAnswerWorkingBytes,
|
|
type AgentEnvComputer,
|
|
} from "./AgentEnvComputer.js";
|
|
|
|
const CONTROLLER = fileURLToPath(
|
|
new URL("../../bridge/agentenv/capture.py", import.meta.url),
|
|
);
|
|
/** The most file bytes one `data/get` brings back, so a large bundle arrives in several answers. */
|
|
const FETCH_BATCH_BYTES = 8 * 1024 * 1024;
|
|
/** Default time allowed for one REA command in the build computer. */
|
|
export const BUILD_REA_TIMEOUT_S = 1800;
|
|
/**
|
|
* Linux `MAX_ARG_STRLEN` on a 4 KiB page, excluding the NUL the kernel counts.
|
|
* One argument past this fails with E2BIG before the process starts.
|
|
*/
|
|
export const LINUX_SINGLE_ARGUMENT_BYTES = 32 * 4096 - 1;
|
|
/**
|
|
* A usual Linux `ARG_MAX`, leaving room for the environment the kernel counts
|
|
* with the arguments. A larger vector fails with E2BIG before the process starts.
|
|
*/
|
|
export const LINUX_ARGUMENT_VECTOR_BYTES = 2 * 1024 * 1024 - 256 * 1024;
|
|
|
|
const argumentVectorBytes = (argv: readonly string[]): number =>
|
|
argv.reduce((sum, argument) => sum + Buffer.byteLength(argument) + 1, 0);
|
|
|
|
let source: Promise<string> | undefined;
|
|
|
|
const isLowSurrogate = (text: string, index: number): boolean => {
|
|
const code = text.charCodeAt(index);
|
|
return code >= 0xdc00 && code <= 0xdfff;
|
|
};
|
|
|
|
/** Pieces of `text` that each fit in one Linux argument and join back to it. */
|
|
export const controllerArgumentPieces = (text: string): readonly string[] => {
|
|
const pieces: string[] = [];
|
|
let start = 0;
|
|
while (start < text.length) {
|
|
let low = start + 1;
|
|
let high = text.length;
|
|
while (low < high) {
|
|
const mid = Math.ceil((low + high) / 2);
|
|
const bytes = Buffer.byteLength(text.slice(start, mid));
|
|
if (bytes <= LINUX_SINGLE_ARGUMENT_BYTES) low = mid;
|
|
else high = mid - 1;
|
|
}
|
|
let end = low;
|
|
if (end < text.length && isLowSurrogate(text, end)) end -= 1;
|
|
if (end <= start) end = Math.min(text.length, start + 1);
|
|
pieces.push(text.slice(start, end));
|
|
start = end;
|
|
}
|
|
return pieces;
|
|
};
|
|
|
|
/** The computer routes REA's build controller uses, and the control token's redaction for what it reads. */
|
|
export type ControlledComputer = Pick<
|
|
AgentEnvComputer,
|
|
"exec" | "homeFiles" | "redacted"
|
|
>;
|
|
|
|
/**
|
|
* Run one command of REA's build controller in the computer, as its user and
|
|
* in its home, and return the JSON object it answers with. The controller's
|
|
* source travels with the command, so nothing of it is installed.
|
|
*/
|
|
export const runBuildController = async (
|
|
computer: ControlledComputer,
|
|
command:
|
|
| "start"
|
|
| "mark"
|
|
| "stop"
|
|
| "run"
|
|
| "list"
|
|
| "run-begin"
|
|
| "run-poll"
|
|
| "run-cancel",
|
|
argumentsJson: Readonly<Record<string, JsonValue>>,
|
|
options: { readonly timeoutS: number; readonly signal?: AbortSignal },
|
|
): Promise<Readonly<Record<string, unknown>>> => {
|
|
const { timeoutS, signal } = options;
|
|
source ??= readFile(CONTROLLER, "utf8");
|
|
const script = await source;
|
|
const scriptBytes = Buffer.byteLength(script);
|
|
if (scriptBytes > LINUX_SINGLE_ARGUMENT_BYTES)
|
|
throw new AnalysisResourceConstraintError(
|
|
"build controller",
|
|
"transport",
|
|
"The controller source exceeds Linux's per-argument limit.",
|
|
{ actual_bytes: scriptBytes, max_bytes: LINUX_SINGLE_ARGUMENT_BYTES },
|
|
{
|
|
remediationAction:
|
|
"Use a REA runtime whose controller fits the Linux argument limit.",
|
|
retryable: false,
|
|
},
|
|
);
|
|
const argv = [
|
|
"python3",
|
|
"-I",
|
|
"-c",
|
|
script,
|
|
command,
|
|
...controllerArgumentPieces(JSON.stringify(argumentsJson)),
|
|
];
|
|
const vectorBytes = argumentVectorBytes(argv);
|
|
if (vectorBytes > LINUX_ARGUMENT_VECTOR_BYTES)
|
|
throw new AnalysisResourceConstraintError(
|
|
"build controller",
|
|
"transport",
|
|
"The command arguments exceed Linux's transport limit (E2BIG).",
|
|
{ actual_bytes: vectorBytes, max_bytes: LINUX_ARGUMENT_VECTOR_BYTES },
|
|
{
|
|
remediationAction: "Reduce the size of the request, then retry.",
|
|
retryable: false,
|
|
},
|
|
);
|
|
const result = await computer.exec(
|
|
argv,
|
|
// The controller's own waits end first, so it can answer.
|
|
timeoutS + 60,
|
|
signal,
|
|
);
|
|
const line = result.stdout.trim().split("\n").at(-1) ?? "";
|
|
try {
|
|
const parsed = z
|
|
.record(z.string(), z.unknown())
|
|
.safeParse(JSON.parse(line));
|
|
if (parsed.success) return parsed.data;
|
|
} catch {
|
|
// Reported below with what the command wrote.
|
|
}
|
|
throw new Error(
|
|
`REA's build controller (${command}) ${result.timed_out ? "timed out" : `exited ${String(result.exit_code)}`} without answering: ${result.stderr.slice(-2048) || result.stdout.slice(-2048)}`,
|
|
);
|
|
};
|
|
|
|
/** What REA's build controller answers a command with. The wire format stays here. */
|
|
export const answerSchema = z.looseObject({
|
|
error: z.string().optional(),
|
|
/** Why the controller refused a request before acting on it, such as a state path outside the home. */
|
|
refused: z.string().optional(),
|
|
absent: z.boolean().optional(),
|
|
started: z.boolean().optional(),
|
|
pending: z.boolean().optional(),
|
|
finished: z.boolean().optional(),
|
|
cancelled: z.boolean().optional(),
|
|
line: z.string().optional(),
|
|
exited: z.int().optional(),
|
|
timed_out: z.boolean().optional(),
|
|
progress: z.string().optional(),
|
|
output: z.string().optional(),
|
|
exit_code: z.int().nullable().optional(),
|
|
output_bytes: z.int().nonnegative().optional(),
|
|
stdout_bytes: z.int().nonnegative().optional(),
|
|
stderr: z.string().optional(),
|
|
apt: z
|
|
.looseObject({ lists: z.int().nonnegative(), error: z.string().optional() })
|
|
.optional(),
|
|
});
|
|
export type Answer = z.infer<typeof answerSchema>;
|
|
|
|
/** One controller command, its answer parsed. */
|
|
export const controller = async (
|
|
computer: ControlledComputer,
|
|
command: "start" | "mark" | "stop",
|
|
argumentsJson: Readonly<Record<string, JsonValue>>,
|
|
options: { readonly timeoutS: number; readonly signal: AbortSignal },
|
|
): Promise<Answer> =>
|
|
answerSchema.parse(
|
|
await runBuildController(computer, command, argumentsJson, options),
|
|
);
|
|
|
|
/**
|
|
* Run one `rea` command in the computer with its output in a file under the
|
|
* work folder, after writing the files it reads. Preserve the exit status and
|
|
* stderr alongside the output size so callers can diagnose command failures.
|
|
*/
|
|
export const runRea = async (
|
|
computer: ControlledComputer,
|
|
argv: readonly string[],
|
|
options: {
|
|
readonly work: string;
|
|
readonly stdout: string;
|
|
readonly files?: Readonly<Record<string, string>>;
|
|
readonly timeoutS?: number;
|
|
readonly signal: AbortSignal;
|
|
},
|
|
): Promise<{
|
|
readonly stdoutBytes: number;
|
|
readonly exitCode: number | null;
|
|
readonly stderr: string;
|
|
}> => {
|
|
const answer = answerSchema.parse(
|
|
await runBuildController(
|
|
computer,
|
|
"run",
|
|
{
|
|
work: options.work,
|
|
argv: [...argv],
|
|
stdout: options.stdout,
|
|
files: { ...options.files },
|
|
timeout_s: options.timeoutS ?? BUILD_REA_TIMEOUT_S,
|
|
},
|
|
{
|
|
timeoutS: options.timeoutS ?? BUILD_REA_TIMEOUT_S,
|
|
signal: options.signal,
|
|
},
|
|
),
|
|
);
|
|
if (answer.exit_code === undefined || answer.stdout_bytes === undefined)
|
|
throw new Error(
|
|
answer.error ?? `rea ${argv[0] ?? ""} did not finish in the computer.`,
|
|
);
|
|
return {
|
|
stdoutBytes: answer.stdout_bytes,
|
|
exitCode: answer.exit_code,
|
|
stderr: answer.stderr ?? "",
|
|
};
|
|
};
|
|
|
|
const listedSchema = z.object({ listing_bytes: z.int().nonnegative() });
|
|
const listingSchema = z.object({
|
|
files: z.array(
|
|
z.object({
|
|
path: z.string().min(1),
|
|
size: z.int().nonnegative(),
|
|
sha256: z.string().regex(/^[0-9a-f]{64}$/u),
|
|
}),
|
|
),
|
|
skipped: z.array(z.string()),
|
|
});
|
|
|
|
/** One file brought back from the computer, by its path under the fetched folder. */
|
|
export interface FetchedFile {
|
|
readonly path: string;
|
|
readonly bytes: Buffer;
|
|
readonly sha256: string;
|
|
}
|
|
|
|
/**
|
|
* Every regular file under a home folder with its size and digest, and what
|
|
* else the folder holds. The controller writes the list to a file beside the
|
|
* folder and REA reads it through `data/get`, within the size it answers, as
|
|
* it reads REA's own outputs: a large bundle's list outgrows the 1 MiB a
|
|
* command's output is cut at.
|
|
*/
|
|
const listFolder = async (
|
|
computer: ControlledComputer,
|
|
folder: string,
|
|
signal: AbortSignal | undefined,
|
|
): Promise<z.infer<typeof listingSchema>> => {
|
|
const output = `${folder}.listing.json`;
|
|
const { listing_bytes: bytes } = listedSchema.parse(
|
|
await runBuildController(
|
|
computer,
|
|
"list",
|
|
{ root: folder, output },
|
|
{ timeoutS: 300, ...(signal === undefined ? {} : { signal }) },
|
|
),
|
|
);
|
|
const content = (await computer.homeFiles([output], bytes, signal)).get(
|
|
output,
|
|
);
|
|
if (content === undefined) throw new Error(`The computer has no ${output}.`);
|
|
return listingSchema.parse(JSON.parse(content.toString("utf8")));
|
|
};
|
|
|
|
/** One listed file and the answer budget a batch must fit. */
|
|
export interface ListedBatchFile {
|
|
readonly path: string;
|
|
readonly size: number;
|
|
}
|
|
|
|
/**
|
|
* Files fetched together. A batch stops before its raw bytes pass
|
|
* `rawBytes` or its base64 answer, including each file's envelope, passes
|
|
* `answerBytes`.
|
|
*/
|
|
export const batchListedFiles = <File extends ListedBatchFile>(
|
|
files: readonly File[],
|
|
limits: { readonly rawBytes: number; readonly answerBytes: number },
|
|
): { files: File[]; bytes: number }[] => {
|
|
const batches: { files: File[]; bytes: number }[] = [];
|
|
for (const file of files) {
|
|
const last = batches.at(-1);
|
|
const nextBytes = (last?.bytes ?? 0) + file.size;
|
|
const nextCount = (last?.files.length ?? 0) + 1;
|
|
const fits =
|
|
last !== undefined &&
|
|
nextBytes <= limits.rawBytes &&
|
|
guestFileAnswerBytes(nextBytes, nextCount) <= limits.answerBytes;
|
|
if (last === undefined || !fits)
|
|
batches.push({ files: [file], bytes: file.size });
|
|
else {
|
|
last.files.push(file);
|
|
last.bytes = nextBytes;
|
|
}
|
|
}
|
|
return batches;
|
|
};
|
|
|
|
/** The answers for one home folder: files listed, then fetched in batches of about `FETCH_BATCH_BYTES` that also fit the host answer budget. */
|
|
const batched = (files: z.infer<typeof listingSchema>["files"]) =>
|
|
batchListedFiles(files, {
|
|
rawBytes: FETCH_BATCH_BYTES,
|
|
answerBytes: guestFileAnswerWorkingBytes(guestFileAnswerCeiling()),
|
|
});
|
|
|
|
/**
|
|
* Bring every regular file under a home folder of the computer back to REA:
|
|
* listed with sizes and digests in the computer, fetched through `data/get`
|
|
* in answers of at most 8 MiB of files each (a larger file comes alone),
|
|
* each checked against its listing and handed to `sink` before the next
|
|
* answer is asked for, so one answer at a time is held. A link or special
|
|
* file is refused. The value counts the files and bytes handed over.
|
|
*/
|
|
export const fetchHomeFolder = async (
|
|
computer: ControlledComputer,
|
|
folder: string,
|
|
sink: { readonly file: (file: FetchedFile) => Promise<void> },
|
|
signal?: AbortSignal,
|
|
): Promise<{ readonly files: number; readonly bytes: number }> => {
|
|
const listing = await listFolder(computer, folder, signal);
|
|
if (listing.skipped.length > 0)
|
|
throw new Error(
|
|
`${folder} holds entries other than regular files: ${listing.skipped.join(", ")}.`,
|
|
);
|
|
let handed = { files: 0, bytes: 0 };
|
|
for (const { files: batch, bytes } of batched(listing.files)) {
|
|
const files = await computer.homeFiles(
|
|
batch.map((file) => file.path),
|
|
bytes,
|
|
signal,
|
|
);
|
|
for (const file of batch) {
|
|
const content = files.get(file.path);
|
|
const sha256 =
|
|
content === undefined
|
|
? undefined
|
|
: createHash("sha256").update(content).digest("hex");
|
|
if (
|
|
!file.path.startsWith(`${folder}/`) ||
|
|
content?.length !== file.size ||
|
|
sha256 !== file.sha256
|
|
)
|
|
throw new Error(
|
|
`${file.path} is missing from the computer's answer or differs from its listing.`,
|
|
);
|
|
await sink.file({
|
|
path: file.path.slice(folder.length + 1),
|
|
bytes: content,
|
|
sha256,
|
|
});
|
|
handed = { files: handed.files + 1, bytes: handed.bytes + file.size };
|
|
}
|
|
}
|
|
return handed;
|
|
};
|