From 2a172216157f8fcb171da297cc0bad103fb8a8cf Mon Sep 17 00:00:00 2001 From: "Flemming N. Larsen" Date: Sun, 18 Oct 2026 23:08:00 +0200 Subject: [PATCH 1/3] feat: submit through the result form without a credential submit no longer creates issues through the GitHub API. It writes each batch to a plain text file under the state directory, split by serialized size as well as by the limit of sixty results, and prints the result form address with a unique title. A later run finds the posted issue by that title without a token, collects the receipts, and keeps unposted batches retryable without creating duplicates. Launchers, README, SECURITY and CHANGELOG no longer require RUMBLE_CLIENT_TOKEN. Outside contributors cannot create a repository-scoped token, and the API drops the submission label for accounts without push access, so their issues were never processed. The form applies the label itself. Cliewen-Route: direct Cliewen-Recommendation: tracked Cliewen-Override: user chose direct; the submission criteria change is recorded separately in tank-royale and this repository's own corpus is not updated here --- CHANGELOG.md | 4 + README.md | 8 +- SECURITY.md | 2 +- docker/rumble.ps1 | 6 - docker/rumble.sh | 4 - .../client/GitHubIssueOpsTransport.java | 54 ++-- .../rumble/client/IssueOpsSubmission.java | 85 ++++-- .../robocode/rumble/client/RankedJournal.java | 52 ++++ .../robocode/rumble/client/RumbleClient.java | 15 +- .../rumble/client/IssueOpsSubmissionTest.java | 244 +++++++++++------- 10 files changed, 316 insertions(+), 158 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 19de9b1..724b307 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ## [Unreleased] +### Changed + +- `submit` no longer posts result issues through the GitHub API and no longer needs a token. It writes each batch of results to a plain text file under `.rumble-client/submissions` (split by size as well as by the limit of sixty results) and prints the result form address; you paste the file into the form, press Create, and run `submit` again later to collect the receipt. Outside contributors could not create a repository-scoped token and the API dropped the submission label, so their issues were never processed. + ## [0.2.0] - 2026-09-15 ### Fixed diff --git a/README.md b/README.md index f7642d1..007b965 100644 --- a/README.md +++ b/README.md @@ -26,9 +26,9 @@ The Rumble client does not currently take a local bot path as input. Its normal 5. Check the bundled runtimes: `docker/rumble.sh runtimes` 6. Resolve the ranked catalog and prepare the bot cache: `docker/rumble.sh sync rumble-client.json` 7. Run one ranked battle: `docker/rumble.sh run rumble-client.json` -8. Submit pending results: `export RUMBLE_CLIENT_TOKEN=` then `docker/rumble.sh submit rumble-client.json` +8. Prepare pending results for posting: `docker/rumble.sh submit rumble-client.json`. It writes each batch to a plain text file under `.rumble-client/submissions` and prints the result form address. Open the address while signed in to GitHub, paste the whole file into the batch field, press Create, and run `submit` again later to collect the receipt. No token is needed. -On PowerShell, use `docker/rumble.ps1 [config-path] [image]` instead — for example `docker/rumble.ps1 run rumble-client.json`, and set `$env:RUMBLE_CLIENT_TOKEN` before `submit`. +On PowerShell, use `docker/rumble.ps1 [config-path] [image]` instead — for example `docker/rumble.ps1 run rumble-client.json`, and no token is needed for `submit`. Run `docker pull` again to pick up a newer `latest`. To pin a release, pass a version tag as the image argument, for example `docker/rumble.sh run rumble-client.json ghcr.io/robocode-dev/rumble-client:0.1.0`; see [`CHANGELOG.md`](CHANGELOG.md) for what changed in each one. If you want to build the image yourself, see [Building `rumble-client` itself](#building-rumble-client-itself). @@ -128,7 +128,6 @@ Use the launcher scripts for configuration validation, runtime checks, snapshot ./docker/rumble.sh runtimes CONTAINER_ENGINE=podman ./docker/rumble.sh runtimes ./docker/rumble.sh run rumble-client.json -export RUMBLE_CLIENT_TOKEN= ./docker/rumble.sh submit rumble-client.json ``` @@ -138,11 +137,10 @@ export RUMBLE_CLIENT_TOKEN= $env:CONTAINER_ENGINE = 'podman' ./docker/rumble.ps1 runtimes ./docker/rumble.ps1 run rumble-client.json -$env:RUMBLE_CLIENT_TOKEN = '' ./docker/rumble.ps1 submit rumble-client.json ``` -For `validate`, `sync`, `run`, and `submit`, the launcher mounts the configuration file and `.rumble-client` state directory. Keep the state directory writable; it contains the bot cache, journal, and replay evidence. The `runtimes` check uses no network, while `validate`, `sync`, and `run` require network access to synchronize the configured repositories and `submit` requires network access to the GitHub Issues API. Submission forwards `RUMBLE_CLIENT_TOKEN` from the environment and never writes it to disk. +For `validate`, `sync`, `run`, and `submit`, the launcher mounts the configuration file and `.rumble-client` state directory. Keep the state directory writable; it contains the bot cache, journal, and replay evidence. The `runtimes` check uses no network, while `validate`, `sync`, and `run` require network access to synchronize the configured repositories and `submit` requires network access to read public result issues and their receipts. Submission needs no credential: you post each prepared batch yourself through the result form. The image contains a pinned Tank Royale Python API and its runtime dependencies in an image-owned virtual environment; no host Python environment or package installation is required. The normal launcher workflow runs the immutable bot sources prepared from the reviewed catalog. For local bot development before submission, use the Tank Royale GUI rather than the Rumble client image. The lower-level containerized Battle Runner can boot bot roots directly for specialized harnesses: mount Java and Python archives read-only, but copy C# and TypeScript archives into writable container storage before booting them because dependency setup may write files and change permissions. This is especially important for Windows bind mounts, where `chmod` can fail with `EPERM`. diff --git a/SECURITY.md b/SECURITY.md index 3fc8955..8360de7 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -8,7 +8,7 @@ The client treats the remote bot catalog, source trees, engine pin, matchmaking ## Credentials -The submission token belongs only to the submission phase. Use a fine-grained GitHub token limited to read and write Issues access for `robocode-dev/rumble-data`; never grant repository-content, branch, release, package, or Pages write permission. Supply it through `RUMBLE_CLIENT_TOKEN` at runtime and do not store it in configuration or scripts. +Submission needs no credential. The client prepares each batch as plain text and you post it yourself through the result form while signed in to GitHub, so the client never holds a token that could write to a repository. Do not hand the client a GitHub token; if `RUMBLE_CLIENT_TOKEN` is set, it is used only to read public issues with a higher request limit. ## Container execution diff --git a/docker/rumble.ps1 b/docker/rumble.ps1 index 796e5cd..d86cc1b 100644 --- a/docker/rumble.ps1 +++ b/docker/rumble.ps1 @@ -61,12 +61,6 @@ if ($Command -eq 'runtimes') { '--mount', "type=bind,source=${stateDirectory},target=/work/.rumble-client" ) } -if ($Command -eq 'submit') { - if (-not (Test-Path env:RUMBLE_CLIENT_TOKEN) -or [string]::IsNullOrEmpty($env:RUMBLE_CLIENT_TOKEN)) { - throw 'RUMBLE_CLIENT_TOKEN must be set in the environment to submit results' - } - $containerArguments += @('--env', 'RUMBLE_CLIENT_TOKEN') -} $containerArguments += $Image $containerArguments += $clientArguments diff --git a/docker/rumble.sh b/docker/rumble.sh index 8283f8b..815cc71 100755 --- a/docker/rumble.sh +++ b/docker/rumble.sh @@ -51,10 +51,6 @@ state_directory="${config_directory}/.rumble-client" mkdir -p "${state_directory}" env_args="" -if [ "${command_name}" = "submit" ]; then - : "${RUMBLE_CLIENT_TOKEN:?RUMBLE_CLIENT_TOKEN must be set in the environment to submit results}" - env_args="--env RUMBLE_CLIENT_TOKEN" -fi exec "${container_engine}" run --rm --read-only --tmpfs /tmp:rw,nosuid,nodev,size=1g \ --user "$(id -u):$(id -g)" ${userns_args} \ diff --git a/src/main/java/dev/robocode/rumble/client/GitHubIssueOpsTransport.java b/src/main/java/dev/robocode/rumble/client/GitHubIssueOpsTransport.java index 45c7e7b..879a05a 100644 --- a/src/main/java/dev/robocode/rumble/client/GitHubIssueOpsTransport.java +++ b/src/main/java/dev/robocode/rumble/client/GitHubIssueOpsTransport.java @@ -14,40 +14,47 @@ import java.time.Duration; import java.util.ArrayList; import java.util.List; +import java.util.Optional; import java.util.UUID; -/** GitHub REST adapter limited to creating result issues and reading their receipt comments. */ +/** GitHub REST adapter limited to finding result issues and reading their receipt comments. Needs no credential. */ final class GitHubIssueOpsTransport implements IssueOpsTransport { private static final URI API_ROOT = URI.create("https://api.github.com/"); private static final String API_VERSION = "2026-03-10"; + private static final int ISSUE_PAGES = 3; + private final HttpClient client; private final String token; + /** Reads public issues; an optional token only raises the anonymous request limit and is never required. */ GitHubIssueOpsTransport(final String token) { this(HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(20)).build(), token); } GitHubIssueOpsTransport(final HttpClient client, final String token) { - if (token == null || token.isBlank()) { - throw new IllegalArgumentException("RUMBLE_CLIENT_TOKEN must contain an Issues-only GitHub token"); - } this.client = client; - this.token = token; + this.token = token == null || token.isBlank() ? null : token; } @Override - public SubmittedBatch createIssue(final URI repository, final String body, final String title) throws IOException { - final JsonObject request = new JsonObject(); - request.addProperty("title", title); - request.addProperty("body", body); - final JsonArray labels = new JsonArray(); - labels.add("result-submission"); - request.add("labels", labels); - final JsonObject response = request("POST", endpoint(repository, "issues"), request.toString()); - final int issueNumber = response.get("number").getAsInt(); - final String issueUrl = response.get("html_url").getAsString(); - return new SubmittedBatch(issueNumber, issueUrl, battleIds(body)); + public Optional findIssue(final URI repository, final String title, final List battleIds) + throws IOException { + for (int page = 1; page <= ISSUE_PAGES; page++) { + final JsonArray issues = request("GET", endpoint(repository, "issues?state=all&sort=created&direction=desc" + + "&per_page=100&page=" + page), null).getAsJsonArray("comments"); + for (final JsonElement element : issues) { + final JsonObject issue = element.getAsJsonObject(); + if (!issue.has("pull_request") && title.equals(issue.get("title").getAsString())) { + return Optional.of(new SubmittedBatch(issue.get("number").getAsInt(), + issue.get("html_url").getAsString(), battleIds)); + } + } + if (issues.size() < 100) { + break; + } + } + return Optional.empty(); } @Override @@ -69,8 +76,10 @@ public SubmissionStatus status(final URI repository, final SubmittedBatch batch) private JsonObject request(final String method, final URI endpoint, final String body) throws IOException { final HttpRequest.Builder request = HttpRequest.newBuilder(endpoint).timeout(Duration.ofSeconds(30)) .header("Accept", "application/vnd.github+json") - .header("Authorization", "Bearer " + token) .header("X-GitHub-Api-Version", API_VERSION); + if (token != null) { + request.header("Authorization", "Bearer " + token); + } if (body == null) { request.GET(); } else { @@ -99,7 +108,7 @@ private JsonObject request(final String method, final URI endpoint, final String private static URI endpoint(final URI repository, final String suffix) { if (!"github.com".equalsIgnoreCase(repository.getHost())) { - throw new IllegalArgumentException("Issues-only submission requires a github.com canonical repository"); + throw new IllegalArgumentException("Reading submission receipts requires a github.com canonical repository"); } final String[] segments = repository.getPath().replaceFirst("/$", "").replaceFirst("\\.git$", "") .split("/"); @@ -109,15 +118,6 @@ private static URI endpoint(final URI repository, final String suffix) { return API_ROOT.resolve("repos/" + segments[1] + "/" + segments[2] + "/" + suffix); } - private static List battleIds(final String body) { - final int begin = body.indexOf('{'); - final int end = body.lastIndexOf('}'); - final JsonArray results = JsonParser.parseString(body.substring(begin, end + 1)).getAsJsonObject() - .getAsJsonArray("results"); - return results.asList().stream().map(result -> UUID.fromString(result.getAsJsonObject() - .get("battleId").getAsString())).toList(); - } - private static List receipts(final String comment, final String issueUrl) { return comment.lines().map(String::trim).filter(line -> line.endsWith(": accepted")) .map(line -> line.substring(0, line.length() - ": accepted".length())) diff --git a/src/main/java/dev/robocode/rumble/client/IssueOpsSubmission.java b/src/main/java/dev/robocode/rumble/client/IssueOpsSubmission.java index 5bae0b0..67c28f1 100644 --- a/src/main/java/dev/robocode/rumble/client/IssueOpsSubmission.java +++ b/src/main/java/dev/robocode/rumble/client/IssueOpsSubmission.java @@ -5,6 +5,11 @@ import java.io.IOException; import java.net.URI; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Instant; import java.time.Clock; import java.time.format.DateTimeFormatter; import java.util.ArrayList; @@ -12,25 +17,38 @@ import java.util.LinkedHashSet; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.Set; import java.util.UUID; import java.util.stream.Collectors; /** - * Sends bounded result envelopes through the result-data Issues-only inbox and retains them until receipted. + * Prepares bounded result envelopes for the result form of the data repository and retains them until receipted. + * The contributor posts each prepared batch through the form, so no submission credential is needed. */ final class IssueOpsSubmission { private static final int MAX_RESULTS_PER_ISSUE = 60; + /** Serialized size kept well below the issue body limit of 65,536 characters. */ + static final int MAX_BODY_CHARACTERS = 60_000; private final IssueOpsTransport transport; private final Clock clock; + private final Path submissionDirectory; - IssueOpsSubmission(final IssueOpsTransport transport, final Clock clock) { + IssueOpsSubmission(final IssueOpsTransport transport, final Clock clock, final Path submissionDirectory) { this.transport = transport; this.clock = clock; + this.submissionDirectory = submissionDirectory; } SubmissionReport submit(final RankedJournal journal, final RumbleSnapshot snapshot) throws IOException { + for (final PreparedBatch prepared : journal.unpostedPrepared()) { + final Optional posted = transport.findIssue(snapshot.canonicalDataRepository(), + prepared.title(), prepared.battleIds()); + if (posted.isPresent()) { + journal.recordSubmission(posted.get(), prepared.title()); + } + } final List statuses = collectStatuses(journal.unacknowledgedSubmissions(), snapshot); final List receipts = statuses.stream().flatMap(status -> status.receipts().stream()).toList(); journal.acknowledge(receipts); @@ -39,16 +57,34 @@ SubmissionReport submit(final RankedJournal journal, final RumbleSnapshot snapsh final Set inFlight = journal.unacknowledgedSubmissions().stream().flatMap(batch -> batch.battleIds().stream()) .filter(battleId -> !terminal.contains(battleId)) .collect(Collectors.toSet()); + journal.unpostedPrepared().forEach(batch -> inFlight.addAll(batch.battleIds())); final List pending = journal.pending().stream().filter(record -> !inFlight.contains(record.battleId())) .toList(); - final List submitted = new ArrayList<>(); + Instant stamp = clock.instant().truncatedTo(java.time.temporal.ChronoUnit.MILLIS); for (final List batch : batches(pending)) { - final SubmittedBatch issue = transport.createIssue(snapshot.canonicalDataRepository(), envelope(batch), - title(batch.get(0))); - journal.recordSubmission(issue); - submitted.add(issue); + journal.recordPrepared(new PreparedBatch(title(batch.get(0), stamp), envelope(batch), + batch.stream().map(RankedBattleRecord::battleId).toList())); + stamp = stamp.plusMillis(1); + } + final List waiting = new ArrayList<>(); + for (final PreparedBatch prepared : journal.unpostedPrepared()) { + waiting.add(write(prepared, snapshot.canonicalDataRepository())); } - return new SubmissionReport(receipts, submitted); + return new SubmissionReport(receipts, waiting); + } + + private PreparedPost write(final PreparedBatch prepared, final URI repository) throws IOException { + Files.createDirectories(submissionDirectory); + final Path file = submissionDirectory.resolve("submission-" + + prepared.title().replaceAll("[^A-Za-z0-9]+", "-").replaceAll("^-|-$", "") + ".txt"); + Files.writeString(file, prepared.body(), StandardCharsets.UTF_8); + return new PreparedPost(prepared, file, formAddress(repository, prepared.title())); + } + + static String formAddress(final URI repository, final String title) { + final String path = repository.getPath().replaceFirst("/$", "").replaceFirst("\\.git$", ""); + return "https://" + repository.getHost() + path + "/issues/new?template=result-submission.yml&title=" + + URLEncoder.encode(title, StandardCharsets.UTF_8); } private List collectStatuses(final List submitted, @@ -69,15 +105,26 @@ private static List> batches(final List> batches = new ArrayList<>(); for (final List group : grouped.values()) { - for (int start = 0; start < group.size(); start += MAX_RESULTS_PER_ISSUE) { - batches.add(List.copyOf(group.subList(start, Math.min(group.size(), start + MAX_RESULTS_PER_ISSUE)))); + List current = new ArrayList<>(); + for (final RankedBattleRecord record : group) { + final List candidate = new ArrayList<>(current); + candidate.add(record); + if (!current.isEmpty() && (candidate.size() > MAX_RESULTS_PER_ISSUE + || envelope(candidate).length() > MAX_BODY_CHARACTERS)) { + batches.add(List.copyOf(current)); + current = new ArrayList<>(); + } + current.add(record); + } + if (!current.isEmpty()) { + batches.add(List.copyOf(current)); } } return batches; } - private String title(final RankedBattleRecord record) { - return "[result] " + record.client().id() + " " + DateTimeFormatter.ISO_INSTANT.format(clock.instant()); + private static String title(final RankedBattleRecord record, final Instant stamp) { + return "[result] " + record.client().id() + " " + DateTimeFormatter.ISO_INSTANT.format(stamp); } private static String envelope(final Collection records) { @@ -100,17 +147,21 @@ private static String envelope(final Collection records) { } } -/** Reports both newly observed receipts and newly posted issue batches. */ -record SubmissionReport(List receipts, List submitted) { +/** Reports newly observed receipts and the prepared batches still waiting to be posted through the form. */ +record SubmissionReport(List receipts, List waiting) { SubmissionReport { receipts = List.copyOf(receipts); - submitted = List.copyOf(submitted); + waiting = List.copyOf(waiting); } } -/** Issues-only boundary used by the client to post result envelopes and observe receipt comments. */ +/** One prepared batch with the plain-text file to paste and the form address that opens with its title. */ +record PreparedPost(PreparedBatch batch, Path file, String formAddress) { +} + +/** Read-only boundary used by the client to find posted result issues and observe receipt comments. */ interface IssueOpsTransport { - SubmittedBatch createIssue(URI repository, String body, String title) throws IOException; + Optional findIssue(URI repository, String title, List battleIds) throws IOException; SubmissionStatus status(URI repository, SubmittedBatch batch) throws IOException; } diff --git a/src/main/java/dev/robocode/rumble/client/RankedJournal.java b/src/main/java/dev/robocode/rumble/client/RankedJournal.java index 5fb4f61..80a4d45 100644 --- a/src/main/java/dev/robocode/rumble/client/RankedJournal.java +++ b/src/main/java/dev/robocode/rumble/client/RankedJournal.java @@ -30,6 +30,7 @@ final class RankedJournal { private static final String ACKNOWLEDGEMENTS_FILE = "acknowledgements.jsonl"; private static final String QUARANTINE_FILE = "quarantine.jsonl"; private static final String SUBMISSIONS_FILE = "submissions.jsonl"; + private static final String PREPARED_FILE = "prepared.jsonl"; private final Path directory; @@ -55,7 +56,15 @@ void acknowledge(final Collection receipts) throws IOExceptio } void recordSubmission(final SubmittedBatch batch) throws IOException { + recordSubmission(batch, null); + } + + /** Records a posted issue; the title of the prepared batch it answers keeps a rejected batch from hiding a new one. */ + void recordSubmission(final SubmittedBatch batch, final String preparedTitle) throws IOException { final JsonObject entry = new JsonObject(); + if (preparedTitle != null) { + entry.addProperty("title", preparedTitle); + } entry.addProperty("schemaVersion", SCHEMA_VERSION); entry.addProperty("issueNumber", batch.issueNumber()); entry.addProperty("issueUrl", batch.issueUrl()); @@ -65,6 +74,32 @@ void recordSubmission(final SubmittedBatch batch) throws IOException { appendLine(SUBMISSIONS_FILE, entry); } + void recordPrepared(final PreparedBatch batch) throws IOException { + final JsonObject entry = new JsonObject(); + entry.addProperty("schemaVersion", SCHEMA_VERSION); + entry.addProperty("title", batch.title()); + entry.addProperty("body", batch.body()); + final JsonArray battleIds = new JsonArray(); + batch.battleIds().forEach(battleId -> battleIds.add(battleId.toString())); + entry.add("battleIds", battleIds); + appendLine(PREPARED_FILE, entry); + } + + /** Prepared batches whose issue has not been found yet and which still hold pending results. */ + List unpostedPrepared() throws IOException { + final Set pending = pending().stream().map(RankedBattleRecord::battleId) + .collect(java.util.stream.Collectors.toSet()); + final Set postedTitles = new LinkedHashSet<>(); + for (final JsonObject entry : entries(SUBMISSIONS_FILE)) { + if (entry.has("title")) { + postedTitles.add(entry.get("title").getAsString()); + } + } + return entries(PREPARED_FILE).stream().map(RankedJournal::preparedBatch) + .filter(batch -> !postedTitles.contains(batch.title())) + .filter(batch -> batch.battleIds().stream().anyMatch(pending::contains)).toList(); + } + List unacknowledgedSubmissions() throws IOException { final Set pending = pending().stream().map(RankedBattleRecord::battleId) .collect(java.util.stream.Collectors.toSet()); @@ -219,6 +254,16 @@ private static RankedBattleRecord record(final JsonObject json) { requiredString(json, "replayHash", RECORDS_FILE)); } + private static PreparedBatch preparedBatch(final JsonObject json) { + final List battleIds = array(json, "battleIds", PREPARED_FILE).asList().stream() + .map(JsonElement::getAsString).map(UUID::fromString).toList(); + if (battleIds.isEmpty() || new LinkedHashSet<>(battleIds).size() != battleIds.size()) { + throw new IllegalArgumentException("Ranked journal prepared.jsonl has invalid battleIds"); + } + return new PreparedBatch(requiredString(json, "title", PREPARED_FILE), + requiredString(json, "body", PREPARED_FILE), battleIds); + } + private static SubmittedBatch submittedBatch(final JsonObject json) { final int issueNumber = integer(json, "issueNumber", SUBMISSIONS_FILE); if (issueNumber < 1) { @@ -303,3 +348,10 @@ record SubmittedBatch(int issueNumber, String issueUrl, List battleIds) { battleIds = List.copyOf(battleIds); } } + +/** One locally prepared batch the contributor posts through the result form, retained until its issue is found. */ +record PreparedBatch(String title, String body, List battleIds) { + PreparedBatch { + battleIds = List.copyOf(battleIds); + } +} diff --git a/src/main/java/dev/robocode/rumble/client/RumbleClient.java b/src/main/java/dev/robocode/rumble/client/RumbleClient.java index ace13bf..56cc4ee 100644 --- a/src/main/java/dev/robocode/rumble/client/RumbleClient.java +++ b/src/main/java/dev/robocode/rumble/client/RumbleClient.java @@ -96,9 +96,16 @@ static void run(final String[] arguments, final PrintStream output, final Runtim final RankedJournal journal = new RankedJournal(configuration.workDirectory()); final int quarantined = journal.quarantineObsolete(snapshot.engine().behaviorVersion()).size(); final SubmissionReport report = new IssueOpsSubmission(new GitHubIssueOpsTransport( - System.getenv("RUMBLE_CLIENT_TOKEN")), Clock.systemUTC()).submit(journal, snapshot); - output.printf("Observed %d accepted result receipts and created %d Issues-only submission batches.%n", - report.receipts().size(), report.submitted().size()); + System.getenv("RUMBLE_CLIENT_TOKEN")), Clock.systemUTC(), + configuration.workDirectory().resolve("submissions")).submit(journal, snapshot); + output.printf("Observed %d accepted result receipts.%n", report.receipts().size()); + for (final PreparedPost post : report.waiting()) { + output.printf("Batch of %d results is ready to post.%n", post.batch().battleIds().size()); + output.printf(" 1. Open this address while signed in to GitHub: %s%n", post.formAddress()); + output.printf(" 2. Paste the whole content of %s into the batch field and press Create.%n", + post.file()); + output.println(" 3. Run --submit again later to collect the receipt."); + } if (quarantined > 0) { output.printf("Quarantined %d records from an obsolete behavior-version epoch.%n", quarantined); } @@ -132,7 +139,7 @@ private static void printHelp(final PrintStream output) { output.println("Use --check-runtimes to verify native Java, .NET, Python, and Node.js prerequisites."); output.println("Use --sync to validate the current snapshot and prepare its immutable bot cache, in ranked or practice mode."); output.println("Use --run to execute one battle and retain its local replay evidence, in ranked or practice mode."); - output.println("Use --submit to send pending ranked records with the RUMBLE_CLIENT_TOKEN Issues-only credential; ranked mode only."); + output.println("Use --submit to prepare pending ranked records for the result form and collect receipts, with no credential; ranked mode only."); } private static void printRuntimeReport(final RuntimeReport report, final PrintStream output) { diff --git a/src/test/java/dev/robocode/rumble/client/IssueOpsSubmissionTest.java b/src/test/java/dev/robocode/rumble/client/IssueOpsSubmissionTest.java index 392ccaa..0f08ff9 100644 --- a/src/test/java/dev/robocode/rumble/client/IssueOpsSubmissionTest.java +++ b/src/test/java/dev/robocode/rumble/client/IssueOpsSubmissionTest.java @@ -6,107 +6,180 @@ import java.io.IOException; import java.net.URI; +import java.nio.file.Files; import java.nio.file.Path; import java.time.Clock; import java.time.Instant; import java.time.ZoneOffset; import java.util.ArrayList; +import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.Set; import java.util.UUID; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; class IssueOpsSubmissionTest { + private static final Clock CLOCK = Clock.fixed(Instant.parse("2026-08-30T12:00:00Z"), ZoneOffset.UTC); + @TempDir Path temporaryDirectory; @Test - @Tag("RCL-007") - void testRCL007_IntegrationPositive_postsBoundedIssueEnvelopeAndAcknowledgesOnlyReceipt() throws IOException { + @Tag("RCL-013") + void testRCL013_IntegrationPositive_writesPlainTextBatchAndFormAddressWithoutAnyCredential() throws IOException { + final RankedBattleRecord first = record("d5ef6066-6a22-467f-88fe-08ff73540e18"); + final RankedBattleRecord second = record("fc86dfad-f03f-4ca8-b6f2-b8eb2e327a2c"); + final RankedJournal journal = new RankedJournal(temporaryDirectory); + journal.append(first); + journal.append(second); + + final SubmissionReport report = submission(new ContributorIssues()).submit(journal, snapshot()); + + assertEquals(1, report.waiting().size()); + final PreparedPost post = report.waiting().get(0); + final String content = Files.readString(post.file()); + assertTrue(post.file().getFileName().toString().endsWith(".txt")); + assertTrue(content.startsWith("```json")); + assertEquals(1, content.split("```json", -1).length - 1); + assertTrue(content.contains("\"clientId\":\"alice-client\"")); + assertEquals(post.batch().body(), content); + assertTrue(post.formAddress().startsWith( + "https://github.com/example/rumble-data/issues/new?template=result-submission.yml&title=")); + assertTrue(post.formAddress().contains("alice-client")); + assertEquals(List.of(first, second), journal.pending()); + } + + @Test + @Tag("RCL-013") + void testRCL013_IntegrationPositive_findsThePostedIssueAndAcknowledgesOnlyReceiptedResults() throws IOException { final RankedBattleRecord first = record("d5ef6066-6a22-467f-88fe-08ff73540e18"); final RankedBattleRecord second = record("fc86dfad-f03f-4ca8-b6f2-b8eb2e327a2c"); final RankedJournal journal = new RankedJournal(temporaryDirectory); journal.append(first); journal.append(second); - final RecordingTransport transport = new RecordingTransport(first.battleId()); - final IssueOpsSubmission submission = new IssueOpsSubmission(transport, - Clock.fixed(Instant.parse("2026-08-30T12:00:00Z"), ZoneOffset.UTC)); - - final SubmissionReport posted = submission.submit(journal, snapshot()); - final SubmissionReport receipted = submission.submit(journal, snapshot()); - - assertEquals(1, posted.submitted().size()); - assertEquals(1, transport.bodies.size()); - assertTrue(transport.bodies.get(0).startsWith("```json")); - assertTrue(transport.bodies.get(0).contains("\"clientId\":\"alice-client\"")); - assertEquals(List.of(first.battleId()), receipted.receipts().stream().map(SubmissionReceipt::battleId).toList()); + final ContributorIssues issues = new ContributorIssues(); + final IssueOpsSubmission submission = submission(issues); + final PreparedPost post = submission.submit(journal, snapshot()).waiting().get(0); + + issues.post(post.batch().title(), 42, Set.of(first.battleId())); + final SubmissionReport collected = submission.submit(journal, snapshot()); + + assertEquals(List.of(first.battleId()), collected.receipts().stream().map(SubmissionReceipt::battleId).toList()); assertEquals(List.of(second), journal.pending()); + assertTrue(collected.waiting().isEmpty()); + } + + @Test + @Tag("RCL-013") + void testRCL013_IntegrationPositive_splitsByResultCountAndByBodySize() throws IOException { + final RankedJournal journal = new RankedJournal(temporaryDirectory); + for (int index = 0; index < 61; index++) { + journal.append(record(UUID.randomUUID().toString())); + } + final SubmissionReport counted = submission(new ContributorIssues()).submit(journal, snapshot()); + assertEquals(List.of(60, 1), counted.waiting().stream() + .map(post -> post.batch().battleIds().size()).toList()); + + final RankedJournal large = new RankedJournal(temporaryDirectory.resolve("large")); + for (int index = 0; index < 30; index++) { + large.append(record(UUID.randomUUID().toString(), "B".repeat(3000))); + } + final SubmissionReport sized = new IssueOpsSubmission(new ContributorIssues(), CLOCK, + temporaryDirectory.resolve("large-files")).submit(large, snapshot()); + assertTrue(sized.waiting().size() > 1); + assertTrue(sized.waiting().stream().allMatch(post -> post.batch().body().length() + <= IssueOpsSubmission.MAX_BODY_CHARACTERS)); + assertEquals(30, sized.waiting().stream().mapToInt(post -> post.batch().battleIds().size()).sum()); + assertEquals(sized.waiting().size(), sized.waiting().stream() + .map(post -> post.batch().title()).distinct().count()); + } + + @Test + @Tag("RCL-013") + void testRCL013_IntegrationNegative_constructsTheReadOnlyTransportWithoutACredential() { + new GitHubIssueOpsTransport(""); + new GitHubIssueOpsTransport((String) null); } @Test @Tag("RCL-006") - void testRCL006_IntegrationNegative_submissionFailureLeavesEveryRecordRetryable() throws IOException { + void testRCL006_IntegrationNegative_repeatedSubmissionBeforePostingDoesNotCreateDuplicateBatches() throws IOException { final RankedBattleRecord record = record("67158518-7dd0-4d0c-af40-e1981f8a348f"); final RankedJournal journal = new RankedJournal(temporaryDirectory); journal.append(record); - final IssueOpsTransport unavailable = new IssueOpsTransport() { - @Override - public SubmittedBatch createIssue(final URI repository, final String body, final String title) throws IOException { - throw new IOException("offline"); - } + final IssueOpsSubmission submission = submission(new ContributorIssues()); - @Override - public SubmissionStatus status(final URI repository, final SubmittedBatch batch) { - return new SubmissionStatus(batch, List.of(), false); - } - }; + final PreparedPost first = submission.submit(journal, snapshot()).waiting().get(0); + final SubmissionReport again = submission.submit(journal, snapshot()); - assertThrows(IOException.class, () -> new IssueOpsSubmission(unavailable, Clock.systemUTC()).submit(journal, snapshot())); + assertEquals(1, again.waiting().size()); + assertEquals(first.batch().title(), again.waiting().get(0).batch().title()); assertEquals(List.of(record), journal.pending()); } @Test @Tag("RCL-006") - void testRCL006_IntegrationNegative_terminallyRejectedBatchIsRetried() throws IOException { + void testRCL006_IntegrationNegative_interruptedLookupLeavesEveryRecordRetryable() throws IOException { + final RankedBattleRecord record = record("676b8646-5993-4b60-9a1e-6112530d1e8d"); + final RankedJournal journal = new RankedJournal(temporaryDirectory); + journal.append(record); + final ContributorIssues issues = new ContributorIssues(); + final IssueOpsSubmission prepared = submission(issues); + final PreparedPost post = prepared.submit(journal, snapshot()).waiting().get(0); + issues.post(post.batch().title(), 42, Set.of(record.battleId())); + issues.offline = true; + + assertThrows(IOException.class, () -> prepared.submit(journal, snapshot())); + assertEquals(List.of(record), journal.pending()); + + issues.offline = false; + final SubmissionReport recovered = prepared.submit(journal, snapshot()); + assertEquals(1, recovered.receipts().size()); + assertTrue(journal.pending().isEmpty()); + } + + @Test + @Tag("RCL-006") + void testRCL006_IntegrationNegative_terminallyRejectedBatchIsPreparedAgain() throws IOException { final RankedBattleRecord record = record("676b8646-5993-4b60-9a1e-6112530d1e8d"); final RankedJournal journal = new RankedJournal(temporaryDirectory); journal.append(record); journal.recordSubmission(new SubmittedBatch(42, "https://github.com/example/rumble-data/issues/42", List.of(record.battleId()))); - final TerminalRejectionTransport transport = new TerminalRejectionTransport(record.battleId()); + final ContributorIssues issues = new ContributorIssues(); + issues.closedWithoutReceipts.add(42); - final SubmissionReport report = new IssueOpsSubmission(transport, Clock.systemUTC()).submit(journal, snapshot()); + final SubmissionReport report = submission(issues).submit(journal, snapshot()); - assertEquals(1, report.submitted().size()); - assertEquals(1, transport.bodies.size()); - assertTrue(transport.bodies.get(0).contains(record.battleId().toString())); + assertEquals(1, report.waiting().size()); + assertTrue(report.waiting().get(0).batch().body().contains(record.battleId().toString())); assertEquals(List.of(record), journal.pending()); } @Test - @Tag("RCL-007") - void testRCL007_IntegrationPositive_splitsSixtyOneRecordsAcrossBoundedIssueBatches() throws IOException { + @Tag("RCL-006") + void testRCL006_IntegrationNegative_unknownFormTitleDoesNotAcknowledgeAnything() throws IOException { + final RankedBattleRecord record = record("0b1a83ee-8c63-4f43-8a5c-0d3a5a1e9f11"); final RankedJournal journal = new RankedJournal(temporaryDirectory); - for (int index = 0; index < 61; index++) { - journal.append(record(UUID.randomUUID().toString())); - } - final BatchRecordingTransport transport = new BatchRecordingTransport(); + journal.append(record); + final ContributorIssues issues = new ContributorIssues(); + issues.post("[result] someone-else 2026-08-30T12:00:00Z", 7, Set.of(record.battleId())); - final SubmissionReport report = new IssueOpsSubmission(transport, Clock.systemUTC()).submit(journal, snapshot()); + final SubmissionReport report = submission(issues).submit(journal, snapshot()); - assertEquals(2, report.submitted().size()); - assertEquals(List.of(60, 1), transport.bodies.stream() - .map(body -> body.split("\"battleId\"", -1).length - 1).toList()); + assertTrue(report.receipts().isEmpty()); + assertFalse(report.waiting().isEmpty()); + assertEquals(List.of(record), journal.pending()); } - @Test - @Tag("RCL-007") - void testRCL007_IntegrationNegative_rejectsMissingIssuesOnlyCredentialBeforeAnyRequest() { - assertThrows(IllegalArgumentException.class, () -> new GitHubIssueOpsTransport("")); + private IssueOpsSubmission submission(final IssueOpsTransport transport) { + return new IssueOpsSubmission(transport, CLOCK, temporaryDirectory.resolve("submissions")); } private static RumbleSnapshot snapshot() { @@ -117,66 +190,49 @@ private static RumbleSnapshot snapshot() { } private static RankedBattleRecord record(final String battleId) { + return record(battleId, "Alpha"); + } + + private static RankedBattleRecord record(final String battleId, final String botName) { return new RankedBattleRecord(UUID.fromString(battleId), Instant.parse("2026-08-30T12:00:00Z"), new ClientIdentity("alice-client", "0.1.0"), new EngineIdentity(7), "1v1", 35, 800, 600, - 7L, List.of(new RankedParticipant("Alpha", "1.0", false, 1, 35, 35, 0, 0, 0, 0, 0, 35, 0, 0)), + 7L, List.of(new RankedParticipant(botName, "1.0", false, 1, 35, 35, 0, 0, 0, 0, 0, 35, 0, 0)), "sha256:" + "a".repeat(64)); } - private static final class RecordingTransport implements IssueOpsTransport { - private final UUID accepted; - private final List bodies = new ArrayList<>(); - - private RecordingTransport(final UUID accepted) { - this.accepted = accepted; - } - - @Override - public SubmittedBatch createIssue(final URI repository, final String body, final String title) { - bodies.add(body); - return new SubmittedBatch(42, "https://github.com/example/rumble-data/issues/42", List.of(accepted, - UUID.fromString("fc86dfad-f03f-4ca8-b6f2-b8eb2e327a2c"))); - } - - @Override - public SubmissionStatus status(final URI repository, final SubmittedBatch batch) { - return new SubmissionStatus(batch, List.of(new SubmissionReceipt(accepted, batch.issueUrl())), false); - } - } - - private static final class BatchRecordingTransport implements IssueOpsTransport { - private final List bodies = new ArrayList<>(); + /** Models the public issue list: only issues a contributor has actually posted through the form exist. */ + private static final class ContributorIssues implements IssueOpsTransport { + private final Map numbersByTitle = new HashMap<>(); + private final Map> receiptedByNumber = new HashMap<>(); + private final Set closedWithoutReceipts = new java.util.HashSet<>(); + private boolean offline; - @Override - public SubmittedBatch createIssue(final URI repository, final String body, final String title) { - bodies.add(body); - return new SubmittedBatch(bodies.size(), "https://github.com/example/rumble-data/issues/" + bodies.size(), - List.of(UUID.randomUUID())); + void post(final String title, final int number, final Set receipted) { + numbersByTitle.put(title, number); + receiptedByNumber.put(number, receipted); } @Override - public SubmissionStatus status(final URI repository, final SubmittedBatch batch) { - return new SubmissionStatus(batch, List.of(), false); - } - } - - private static final class TerminalRejectionTransport implements IssueOpsTransport { - private final UUID battleId; - private final List bodies = new ArrayList<>(); - - private TerminalRejectionTransport(final UUID battleId) { - this.battleId = battleId; - } - - @Override - public SubmittedBatch createIssue(final URI repository, final String body, final String title) { - bodies.add(body); - return new SubmittedBatch(43, "https://github.com/example/rumble-data/issues/43", List.of(battleId)); + public Optional findIssue(final URI repository, final String title, + final List battleIds) throws IOException { + if (offline) { + throw new IOException("offline"); + } + final Integer number = numbersByTitle.get(title); + return number == null ? Optional.empty() : Optional.of(new SubmittedBatch(number, + "https://github.com/example/rumble-data/issues/" + number, battleIds)); } @Override - public SubmissionStatus status(final URI repository, final SubmittedBatch batch) { - return new SubmissionStatus(batch, List.of(), batch.issueNumber() == 42); + public SubmissionStatus status(final URI repository, final SubmittedBatch batch) throws IOException { + if (offline) { + throw new IOException("offline"); + } + final List receipts = new ArrayList<>(); + for (final UUID battleId : receiptedByNumber.getOrDefault(batch.issueNumber(), Set.of())) { + receipts.add(new SubmissionReceipt(battleId, batch.issueUrl())); + } + return new SubmissionStatus(batch, receipts, closedWithoutReceipts.contains(batch.issueNumber())); } } } From c53fc7358b3e78f26023eb24cd3afe4b1ac68a7a Mon Sep 17 00:00:00 2001 From: "Flemming N. Larsen" Date: Mon, 19 Oct 2026 19:20:11 +0200 Subject: [PATCH 2/3] fix: verify browser submission receipts and preserve retry state Correlate form issues by registered account and full envelope, paginate public reads, and verify receipt claims against canonical raw facts or rollups. Keep open retries in flight across restarts and retain oversized records with an explicit error. Cliewen-Route: simple --- README.md | 6 +- docs/architecture/README.md | 4 +- docs/design/README.md | 9 +- .../client/CanonicalPublishedResults.java | 75 +++++++++ .../client/GitHubIssueOpsTransport.java | 65 ++++++-- .../rumble/client/IssueOpsSubmission.java | 36 +++- .../robocode/rumble/client/RankedJournal.java | 5 + .../client/CanonicalPublishedResultsTest.java | 69 ++++++++ .../client/GitHubIssueOpsTransportTest.java | 155 ++++++++++++++++++ .../rumble/client/IssueOpsSubmissionTest.java | 86 +++++++++- 10 files changed, 473 insertions(+), 37 deletions(-) create mode 100644 src/main/java/dev/robocode/rumble/client/CanonicalPublishedResults.java create mode 100644 src/test/java/dev/robocode/rumble/client/CanonicalPublishedResultsTest.java create mode 100644 src/test/java/dev/robocode/rumble/client/GitHubIssueOpsTransportTest.java diff --git a/README.md b/README.md index 007b965..6eba3f5 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ The Rumble Client lets your computer contribute ranked battles to the Tank Royal This project is part of the [Tank Royale](https://github.com/robocode-dev/tank-royale) ecosystem; the client's public contracts (configuration, journal, and submission format) are documented there. -For the complete newcomer-friendly walkthrough, including registration and token setup, read [Run ranked Rumble battles](https://robocode.dev/rumble/client-guide). This README is the technical reference for the source checkout. +For the complete newcomer-friendly walkthrough, including registration, read [Run ranked Rumble battles](https://robocode.dev/rumble/client-guide). This README is the technical reference for the source checkout. ## Quickstart (Docker, recommended) @@ -34,7 +34,7 @@ Run `docker pull` again to pick up a newer `latest`. To pin a release, pass a ve Every command runs the container read-only, with capabilities dropped and resource limits applied. Only `runtimes` blocks network access outright; `validate`, `sync`, `run`, and `submit` use normal outbound network because synchronization and submission need it, and `run` re-synchronizes the ranked snapshot before executing a battle. -The client tracks posted batches locally and only drops them once their receipt comment appears on the closed issue; retrying an already-accepted submission is acknowledged idempotently rather than double-submitted. +The client retains each result until it observes an acceptance receipt from the ingestion workflow and verifies the identical fact in the canonical repository, including compacted monthly rollups. It correlates a posted form with the registered account, prepared title and JSON batch. An open retry remains in flight even when an earlier attempt was rejected. Network failures and API rate limits leave the journal pending; run `submit` again later with the same state directory. A single result exceeding the batch size limit produces an error and remains pending. ## Building `rumble-client` itself @@ -95,7 +95,7 @@ The build produces native ZIP and TAR archives under `build/distributions/`. Run The container and native preflight currently target Java 25, .NET 10, Python 3.14, and Node.js 24 (Node.js installer 24.21.0). This block is refreshed by the scheduled runtime update workflow. -The client validates configuration and can synchronize the current ranked input snapshot. Run `./gradlew run --args="--validate-config"` to check local settings, then run `./gradlew run --args="--sync"` to resolve the canonical data repository, validate its engine pin, catalog, client registration, and matchmaking advice, and prepare an immutable bot cache at the catalog's exact source commit. Every cached source tree is checked against its catalog SHA-256 before it can be used. Ranked battle selection uses a recorded random seed, prioritizes under-sampled pairings involving `myBots`, and falls back to distinct active catalog bots when no advice is available. Each game type declares how many bots one catalog entry expands to, so TwinDuel selects two team entries for its four pinned participants while `1v1` and melee select individual bots, and a selection never contains two entries that share a member bot. Run `./gradlew run --args="--run"` to execute one pinned ranked battle through Battle Runner and retain its replay evidence locally. Run `./gradlew run --args="--submit"` to post pending records through the `rumble-data` issue inbox. It reads `RUMBLE_CLIENT_TOKEN` only at runtime; use a GitHub fine-grained token limited to read and write Issues access for that repository. The client records posted batches locally and removes records only after their result-data receipt comments appear. See [Docker and Podman container image](#docker-and-podman-container-image) below for the isolated multi-runtime container. +The client validates configuration and can synchronize the current ranked input snapshot. Run `./gradlew run --args="--validate-config"` to check local settings, then run `./gradlew run --args="--sync"` to resolve the canonical data repository, validate its engine pin, catalog, client registration, and matchmaking advice, and prepare an immutable bot cache at the catalog's exact source commit. Every cached source tree is checked against its catalog SHA-256 before it can be used. Ranked battle selection uses a recorded random seed, prioritizes under-sampled pairings involving `myBots`, and falls back to distinct active catalog bots when no advice is available. Each game type declares how many bots one catalog entry expands to, so TwinDuel selects two team entries for its four pinned participants while `1v1` and melee select individual bots, and a selection never contains two entries that share a member bot. Run `./gradlew run --args="--run"` to execute one pinned ranked battle through Battle Runner and retain its replay evidence locally. Run `./gradlew run --args="--submit"` to prepare plain text batches for the `rumble-data` result form and collect receipts after posting them. No token is required; an optional `RUMBLE_CLIENT_TOKEN` is used only for read-only API requests and can raise the anonymous request limit. Results remain pending until a workflow receipt and the identical canonical fact are observed. See [Docker and Podman container image](#docker-and-podman-container-image) below for the isolated multi-runtime container. ## Configuration diff --git a/docs/architecture/README.md b/docs/architecture/README.md index 7378f62..5cebeed 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -4,7 +4,7 @@ The Rumble Client is a Java 17 command-line application packaged as a container ## Boundaries and actors -- Contributors provide a JSON configuration, local state storage, and an optional GitHub token for result submission. +- Contributors provide a JSON configuration and local state storage, and post prepared result batches through the GitHub form. Reading public receipts requires no token; an optional token can raise the API request limit. - The Tank Royale data repository supplies the canonical client configuration, engine pin, ranked catalog, matchmaking advice, and bot source archives. - The Battle Runner executes the selected bots and produces battle and replay output; the client consumes its published 1.3.1 Maven artifact. Container builds also check out the pinned Tank Royale source to package the matching Python API and schema and to build sample bots for smoke verification. - GitHub Issues provide the result inbox and receipt comments used by submission reconciliation. @@ -18,7 +18,7 @@ The Rumble Client is a Java 17 command-line application packaged as a container - `RumbleSynchronizer`, `RepositoryReader`, `GitRepositoryReader`, `RumbleSnapshotParser`, `SourceTreeHash`, and `BotCachePreparer` resolve the canonical snapshot and prepare an immutable local bot cache. - `RankedBattleSelector`, `RankedBattleRecord`, `RankedBattleExecution`, and `RankedJournal` select under-sampled matchups and persist battle state and evidence. - `RunnerBattleExecutor` adapts the selected battle to the Tank Royale Battle Runner. -- `IssueOpsSubmission` and `GitHubIssueOpsTransport` submit pending batches and remove them only after receipt comments confirm acceptance. +- `IssueOpsSubmission` prepares pending batches for the contributor to post through the form. `GitHubIssueOpsTransport` reads matching issues and workflow receipts; `CanonicalPublishedResults` checks identical canonical raw facts or rollups through `RepositoryReader` before journal acknowledgement. - `docker/rumble.sh` and `docker/rumble.ps1` are the supported operational boundary for containerized validation, synchronization, execution, and submission. ```mermaid diff --git a/docs/design/README.md b/docs/design/README.md index 6eb2c23..7a0f515 100644 --- a/docs/design/README.md +++ b/docs/design/README.md @@ -11,9 +11,10 @@ flowchart TD Cache --> Select[Select ranked matchup with recorded seed] Select --> Run[Run through Battle Runner] Run --> Journal[Persist journal and replay evidence] - Journal --> Submit[Submit pending batch] - Submit --> Receipt[Poll for receipt comment] - Receipt -->|accepted| Complete[Remove acknowledged local records] + Journal --> Submit[Prepare plain text batch and form link] + Submit --> Form[Contributor posts through GitHub form] + Form --> Receipt[Read workflow receipt and verify published fact] + Receipt -->|accepted and identical fact published| Complete[Acknowledge local records] Receipt -->|missing or retryable| Submit ``` @@ -21,7 +22,7 @@ Synchronization reads repository contracts and materializes an immutable cache a Battle execution is delegated to the pinned Tank Royale Battle Runner. The container image carries the supported Java, .NET, Python, and Node.js runtimes; launcher scripts provide the isolation and resource limits needed for reviewed-but-untrusted bot archives. The runtime preflight reports missing prerequisites without installing them. -The local work directory is the durable boundary for the bot cache, journal, and replay evidence. Submission reads pending records, sends batches through the GitHub Issues transport, and retains them until a matching result-data receipt comment is observed. Re-sending an already accepted batch is treated as idempotent, allowing interrupted sessions to resume without knowingly duplicating results. +The local work directory is the durable boundary for the bot cache, journal, replay evidence and prepared submissions. Submission writes bounded plain text batches and short form links; the contributor posts each batch while signed in to GitHub. The read-only Issues transport scans the registered account's issues across pages and correlates the prepared title and JSON envelope. Receipt collection accepts comments from the GitHub Actions bot and independently checks each result against a fresh canonical repository checkout, reading raw facts or compacted monthly rollups and matching the submitted account and complete payload. Records remain pending through network errors, API limits and delayed publication. In-flight state is calculated per issue attempt so an old rejection cannot hide a newer open retry; prepared titles remain unique across restarts, including retries within the same clock millisecond. A single oversized record fails preparation while remaining in the journal. Server-side idempotency protects already published facts when a submission is repeated. diff --git a/src/main/java/dev/robocode/rumble/client/CanonicalPublishedResults.java b/src/main/java/dev/robocode/rumble/client/CanonicalPublishedResults.java new file mode 100644 index 0000000..14c4b27 --- /dev/null +++ b/src/main/java/dev/robocode/rumble/client/CanonicalPublishedResults.java @@ -0,0 +1,75 @@ +package dev.robocode.rumble.client; + +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; + +import java.io.IOException; +import java.net.URI; +import java.nio.file.NoSuchFileException; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.UUID; + +/** Confirms a receipt against the identical fact in a fresh canonical repository revision. */ +final class CanonicalPublishedResults implements PublishedResults { + private static final DateTimeFormatter MONTH = DateTimeFormatter.ofPattern("uuuu/MM").withZone(ZoneOffset.UTC); + private final RepositoryReader repositories; + + CanonicalPublishedResults(final RepositoryReader repositories) { + this.repositories = repositories; + } + + @Override + public Set find(final URI repository, final List records, final String account) + throws IOException { + final Set published = new HashSet<>(); + try (var checkout = repositories.checkout(repository)) { + final Set months = new HashSet<>(); + for (final RankedBattleRecord record : records) { + months.add(MONTH.format(record.completedAt())); + } + final List facts = new ArrayList<>(); + for (final String month : months) { + try { + for (final String path : checkout.listFiles("results/raw/" + month)) { + if (path.endsWith(".json")) { + facts.add(JsonParser.parseString(checkout.read(path)).getAsJsonObject()); + } + } + } catch (NoSuchFileException missing) { + // Compacted months have a rollup instead of individual raw facts. + } + try { + final JsonObject rollup = JsonParser.parseString(checkout.read( + "results/rollups/" + month + ".json")).getAsJsonObject(); + rollup.getAsJsonArray("results").forEach(item -> facts.add(item.getAsJsonObject())); + } catch (NoSuchFileException missing) { + // Current months normally have no rollup yet. + } + } + for (final RankedBattleRecord record : records) { + final JsonObject expected = RankedJournal.recordJson(record); + expected.remove("schemaVersion"); + for (final JsonObject fact : facts) { + if (fact.has("submittedBy") && account.equalsIgnoreCase(fact.get("submittedBy").getAsString()) + && expected.entrySet().stream().allMatch(entry -> entry.getValue().equals(fact.get(entry.getKey())))) { + published.add(record.battleId()); + break; + } + } + } + } catch (RuntimeException exception) { + throw new IOException("Canonical published results contain invalid JSON", exception); + } + return Set.copyOf(published); + } +} + +@FunctionalInterface +interface PublishedResults { + Set find(URI repository, List records, String account) throws IOException; +} diff --git a/src/main/java/dev/robocode/rumble/client/GitHubIssueOpsTransport.java b/src/main/java/dev/robocode/rumble/client/GitHubIssueOpsTransport.java index 879a05a..fe4d32f 100644 --- a/src/main/java/dev/robocode/rumble/client/GitHubIssueOpsTransport.java +++ b/src/main/java/dev/robocode/rumble/client/GitHubIssueOpsTransport.java @@ -7,6 +7,7 @@ import java.io.IOException; import java.net.URI; +import java.net.URLEncoder; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; @@ -16,16 +17,18 @@ import java.util.List; import java.util.Optional; import java.util.UUID; +import java.util.regex.Pattern; /** GitHub REST adapter limited to finding result issues and reading their receipt comments. Needs no credential. */ final class GitHubIssueOpsTransport implements IssueOpsTransport { private static final URI API_ROOT = URI.create("https://api.github.com/"); private static final String API_VERSION = "2026-03-10"; - private static final int ISSUE_PAGES = 3; + private static final Pattern JSON_BLOCK = Pattern.compile("```json\\s*\\n(.*?)\\n```", Pattern.DOTALL); private final HttpClient client; private final String token; + private final URI apiRoot; /** Reads public issues; an optional token only raises the anonymous request limit and is never required. */ GitHubIssueOpsTransport(final String token) { @@ -33,21 +36,29 @@ final class GitHubIssueOpsTransport implements IssueOpsTransport { } GitHubIssueOpsTransport(final HttpClient client, final String token) { + this(client, token, API_ROOT); + } + + GitHubIssueOpsTransport(final HttpClient client, final String token, final URI apiRoot) { this.client = client; this.token = token == null || token.isBlank() ? null : token; + this.apiRoot = apiRoot; } @Override - public Optional findIssue(final URI repository, final String title, final List battleIds) + public Optional findIssue(final URI repository, final PreparedBatch prepared, final String account) throws IOException { - for (int page = 1; page <= ISSUE_PAGES; page++) { + for (int page = 1; ; page++) { final JsonArray issues = request("GET", endpoint(repository, "issues?state=all&sort=created&direction=desc" + + "&creator=" + URLEncoder.encode(account, StandardCharsets.UTF_8) + "&per_page=100&page=" + page), null).getAsJsonArray("comments"); for (final JsonElement element : issues) { final JsonObject issue = element.getAsJsonObject(); - if (!issue.has("pull_request") && title.equals(issue.get("title").getAsString())) { + if (!issue.has("pull_request") && prepared.title().equals(issue.get("title").getAsString()) + && issue.has("user") && account.equalsIgnoreCase(issue.getAsJsonObject("user").get("login").getAsString()) + && sameEnvelope(issue.get("body"), prepared.body())) { return Optional.of(new SubmittedBatch(issue.get("number").getAsInt(), - issue.get("html_url").getAsString(), battleIds)); + issue.get("html_url").getAsString(), prepared.battleIds())); } } if (issues.size() < 100) { @@ -59,13 +70,24 @@ public Optional findIssue(final URI repository, final String tit @Override public SubmissionStatus status(final URI repository, final SubmittedBatch batch) throws IOException { - final JsonArray comments = request("GET", endpoint(repository, "issues/" + batch.issueNumber() - + "/comments?per_page=100"), null).getAsJsonArray("comments"); final List receipts = new ArrayList<>(); - for (final JsonElement comment : comments) { - final JsonElement body = comment.getAsJsonObject().get("body"); - if (body != null && body.isJsonPrimitive()) { - receipts.addAll(receipts(body.getAsString(), batch.issueUrl())); + for (int page = 1; ; page++) { + final JsonArray comments = request("GET", endpoint(repository, "issues/" + batch.issueNumber() + + "/comments?per_page=100&page=" + page), null).getAsJsonArray("comments"); + for (final JsonElement element : comments) { + final JsonObject comment = element.getAsJsonObject(); + final JsonElement body = comment.get("body"); + final JsonObject user = comment.getAsJsonObject("user"); + // The ingestion workflow publishes these receipts only after pushing canonical facts. + if (user != null && user.has("id") && user.get("id").getAsLong() == 41898282L + && "github-actions[bot]".equals(user.get("login").getAsString()) + && "Bot".equals(user.get("type").getAsString()) + && body != null && body.isJsonPrimitive()) { + receipts.addAll(receipts(body.getAsString(), batch.issueUrl())); + } + } + if (comments.size() < 100) { + break; } } final JsonObject issue = request("GET", endpoint(repository, "issues/" + batch.issueNumber()), null); @@ -106,7 +128,7 @@ private JsonObject request(final String method, final URI endpoint, final String } } - private static URI endpoint(final URI repository, final String suffix) { + private URI endpoint(final URI repository, final String suffix) { if (!"github.com".equalsIgnoreCase(repository.getHost())) { throw new IllegalArgumentException("Reading submission receipts requires a github.com canonical repository"); } @@ -115,7 +137,24 @@ private static URI endpoint(final URI repository, final String suffix) { if (segments.length != 3 || segments[1].isBlank() || segments[2].isBlank()) { throw new IllegalArgumentException("Canonical repository must identify a GitHub owner and repository"); } - return API_ROOT.resolve("repos/" + segments[1] + "/" + segments[2] + "/" + suffix); + return apiRoot.resolve("repos/" + segments[1] + "/" + segments[2] + "/" + suffix); + } + + private static boolean sameEnvelope(final JsonElement body, final String preparedBody) { + if (body == null || body.isJsonNull() || !body.isJsonPrimitive()) { + return false; + } + try { + final var actual = JSON_BLOCK.matcher(body.getAsString()); + final var expected = JSON_BLOCK.matcher(preparedBody); + if (!actual.find() || !expected.find()) { + return false; + } + final JsonElement envelope = JsonParser.parseString(actual.group(1)); + return !actual.find() && envelope.equals(JsonParser.parseString(expected.group(1))); + } catch (RuntimeException exception) { + return false; + } } private static List receipts(final String comment, final String issueUrl) { diff --git a/src/main/java/dev/robocode/rumble/client/IssueOpsSubmission.java b/src/main/java/dev/robocode/rumble/client/IssueOpsSubmission.java index 67c28f1..b309666 100644 --- a/src/main/java/dev/robocode/rumble/client/IssueOpsSubmission.java +++ b/src/main/java/dev/robocode/rumble/client/IssueOpsSubmission.java @@ -34,34 +34,50 @@ final class IssueOpsSubmission { private final IssueOpsTransport transport; private final Clock clock; private final Path submissionDirectory; + private final PublishedResults publishedResults; IssueOpsSubmission(final IssueOpsTransport transport, final Clock clock, final Path submissionDirectory) { + this(transport, clock, submissionDirectory, new CanonicalPublishedResults(new GitRepositoryReader())); + } + + IssueOpsSubmission(final IssueOpsTransport transport, final Clock clock, final Path submissionDirectory, + final PublishedResults publishedResults) { this.transport = transport; this.clock = clock; this.submissionDirectory = submissionDirectory; + this.publishedResults = publishedResults; } SubmissionReport submit(final RankedJournal journal, final RumbleSnapshot snapshot) throws IOException { for (final PreparedBatch prepared : journal.unpostedPrepared()) { final Optional posted = transport.findIssue(snapshot.canonicalDataRepository(), - prepared.title(), prepared.battleIds()); + prepared, snapshot.registration().account()); if (posted.isPresent()) { journal.recordSubmission(posted.get(), prepared.title()); } } final List statuses = collectStatuses(journal.unacknowledgedSubmissions(), snapshot); - final List receipts = statuses.stream().flatMap(status -> status.receipts().stream()).toList(); + final Set claimed = statuses.stream().flatMap(status -> status.receipts().stream()) + .map(SubmissionReceipt::battleId).collect(Collectors.toSet()); + final Set published = claimed.isEmpty() ? Set.of() : publishedResults.find( + snapshot.canonicalDataRepository(), journal.pending().stream() + .filter(record -> claimed.contains(record.battleId())).toList(), snapshot.registration().account()); + final List receipts = statuses.stream().flatMap(status -> status.receipts().stream()) + .filter(receipt -> published.contains(receipt.battleId())).toList(); journal.acknowledge(receipts); - final Set terminal = statuses.stream().filter(SubmissionStatus::terminal) - .flatMap(status -> status.batch().battleIds().stream()).collect(Collectors.toSet()); - final Set inFlight = journal.unacknowledgedSubmissions().stream().flatMap(batch -> batch.battleIds().stream()) - .filter(battleId -> !terminal.contains(battleId)) + final Set inFlight = statuses.stream().filter(status -> !status.terminal() + || status.receipts().stream().anyMatch(receipt -> !published.contains(receipt.battleId()))) + .flatMap(status -> status.batch().battleIds().stream()) .collect(Collectors.toSet()); journal.unpostedPrepared().forEach(batch -> inFlight.addAll(batch.battleIds())); final List pending = journal.pending().stream().filter(record -> !inFlight.contains(record.battleId())) .toList(); Instant stamp = clock.instant().truncatedTo(java.time.temporal.ChronoUnit.MILLIS); + final Set titles = journal.preparedTitles(); for (final List batch : batches(pending)) { + while (titles.contains(title(batch.get(0), stamp))) { + stamp = stamp.plusMillis(1); + } journal.recordPrepared(new PreparedBatch(title(batch.get(0), stamp), envelope(batch), batch.stream().map(RankedBattleRecord::battleId).toList())); stamp = stamp.plusMillis(1); @@ -99,7 +115,7 @@ private List collectStatuses(final List submit return statuses; } - private static List> batches(final List records) { + private static List> batches(final List records) throws IOException { final Map> grouped = records.stream() .collect(Collectors.groupingBy(RankedBattleRecord::client, java.util.LinkedHashMap::new, Collectors.toList())); @@ -107,6 +123,10 @@ private static List> batches(final List group : grouped.values()) { List current = new ArrayList<>(); for (final RankedBattleRecord record : group) { + if (envelope(List.of(record)).length() > MAX_BODY_CHARACTERS) { + throw new IOException("Result " + record.battleId() + " exceeds the submission body limit of " + + MAX_BODY_CHARACTERS + " characters; the record remains pending"); + } final List candidate = new ArrayList<>(current); candidate.add(record); if (!current.isEmpty() && (candidate.size() > MAX_RESULTS_PER_ISSUE @@ -161,7 +181,7 @@ record PreparedPost(PreparedBatch batch, Path file, String formAddress) { /** Read-only boundary used by the client to find posted result issues and observe receipt comments. */ interface IssueOpsTransport { - Optional findIssue(URI repository, String title, List battleIds) throws IOException; + Optional findIssue(URI repository, PreparedBatch prepared, String account) throws IOException; SubmissionStatus status(URI repository, SubmittedBatch batch) throws IOException; } diff --git a/src/main/java/dev/robocode/rumble/client/RankedJournal.java b/src/main/java/dev/robocode/rumble/client/RankedJournal.java index 80a4d45..7f49663 100644 --- a/src/main/java/dev/robocode/rumble/client/RankedJournal.java +++ b/src/main/java/dev/robocode/rumble/client/RankedJournal.java @@ -85,6 +85,11 @@ void recordPrepared(final PreparedBatch batch) throws IOException { appendLine(PREPARED_FILE, entry); } + Set preparedTitles() throws IOException { + return entries(PREPARED_FILE).stream().map(entry -> requiredString(entry, "title", PREPARED_FILE)) + .collect(java.util.stream.Collectors.toSet()); + } + /** Prepared batches whose issue has not been found yet and which still hold pending results. */ List unpostedPrepared() throws IOException { final Set pending = pending().stream().map(RankedBattleRecord::battleId) diff --git a/src/test/java/dev/robocode/rumble/client/CanonicalPublishedResultsTest.java b/src/test/java/dev/robocode/rumble/client/CanonicalPublishedResultsTest.java new file mode 100644 index 0000000..cc66d4c --- /dev/null +++ b/src/test/java/dev/robocode/rumble/client/CanonicalPublishedResultsTest.java @@ -0,0 +1,69 @@ +package dev.robocode.rumble.client; + +import com.google.gson.JsonObject; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.net.URI; +import java.nio.file.NoSuchFileException; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; + +@Tag("RCL-006") +class CanonicalPublishedResultsTest { + private static final URI REPOSITORY = URI.create("https://github.com/example/rumble-data"); + private static final RankedBattleRecord RECORD = IssueOpsSubmissionTest.record("676b8646-5993-4b60-9a1e-6112530d1e8d"); + + @Test + void confirmsIdenticalRawFactOrCompactedRollup() throws IOException { + final Map files = new HashMap<>(); + files.put("results/raw/2026/08/hash.json", fact().toString()); + assertEquals(Set.of(RECORD.battleId()), verifier(files).find(REPOSITORY, List.of(RECORD), "alice")); + files.clear(); + files.put("results/rollups/2026/08.json", "{\"results\":[" + fact() + "]}"); + assertEquals(Set.of(RECORD.battleId()), verifier(files).find(REPOSITORY, List.of(RECORD), "alice")); + } + + @Test + void missingForeignOrChangedFactCannotAcknowledgeRecord() throws IOException { + final Map files = new HashMap<>(); + assertTrue(verifier(files).find(REPOSITORY, List.of(RECORD), "alice").isEmpty()); + final JsonObject foreign = fact(); + foreign.addProperty("submittedBy", "mallory"); + files.put("results/raw/2026/08/hash.json", foreign.toString()); + assertTrue(verifier(files).find(REPOSITORY, List.of(RECORD), "alice").isEmpty()); + final JsonObject changed = fact(); + changed.addProperty("selectionSeed", 999); + files.put("results/raw/2026/08/hash.json", changed.toString()); + assertTrue(verifier(files).find(REPOSITORY, List.of(RECORD), "alice").isEmpty()); + } + + private static JsonObject fact() { + final JsonObject fact = RankedJournal.recordJson(RECORD); + fact.remove("schemaVersion"); + fact.addProperty("submittedBy", "alice"); + fact.addProperty("payloadHash", "sha256:hash"); + return fact; + } + + private static CanonicalPublishedResults verifier(final Map files) { + return new CanonicalPublishedResults(repository -> new RepositoryReader.RepositoryCheckout() { + public URI repository() { return repository; } + public String revision() { return "a".repeat(40); } + public String read(final String path) throws IOException { + if (!files.containsKey(path)) { throw new NoSuchFileException(path); } + return files.get(path); + } + public List listFiles(final String directory) { + return files.keySet().stream().filter(path -> path.startsWith(directory + "/")).toList(); + } + public void close() { } + }); + } +} diff --git a/src/test/java/dev/robocode/rumble/client/GitHubIssueOpsTransportTest.java b/src/test/java/dev/robocode/rumble/client/GitHubIssueOpsTransportTest.java new file mode 100644 index 0000000..9e1a630 --- /dev/null +++ b/src/test/java/dev/robocode/rumble/client/GitHubIssueOpsTransportTest.java @@ -0,0 +1,155 @@ +package dev.robocode.rumble.client; + +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import com.sun.net.httpserver.HttpServer; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.net.InetSocketAddress; +import java.net.URI; +import java.net.http.HttpClient; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import java.util.function.Function; + +import static org.junit.jupiter.api.Assertions.*; + +@Tag("RCL-006") +class GitHubIssueOpsTransportTest { + private static final URI REPOSITORY = URI.create("https://github.com/example/rumble-data"); + private static final UUID ID = UUID.fromString("676b8646-5993-4b60-9a1e-6112530d1e8d"); + private static final PreparedBatch PREPARED = new PreparedBatch("[result] alice-client stamp", + "```json\n{\"results\":[{\"battleId\":\"" + ID + "\"}]}\n```", List.of(ID)); + + @Test + void anonymousDiscoveryContinuesPastThreePagesAndChecksAccountAndEnvelope() throws Exception { + final List requests = new ArrayList<>(); + try (final Api api = new Api(path -> { + requests.add(path); + final JsonArray issues = new JsonArray(); + if (!path.contains("&page=4")) { + issues.add(issue("mallory", PREPARED.body())); + issues.add(issue("alice", "```json\n{}\n```")); + final JsonObject pullRequest = issue("alice", PREPARED.body()); + pullRequest.add("pull_request", new JsonObject()); + issues.add(pullRequest); + while (issues.size() < 100) { + issues.add(issue("alice", null)); + } + } else { + issues.add(issue("alice", "### Batch\n\n" + PREPARED.body())); + } + return issues.toString(); + })) { + final SubmittedBatch batch = api.transport().findIssue(REPOSITORY, PREPARED, "alice").orElseThrow(); + assertEquals(42, batch.issueNumber()); + assertEquals(List.of(ID), batch.battleIds()); + assertEquals(4, requests.size()); + assertTrue(requests.stream().allMatch(path -> path.contains("creator=alice"))); + assertTrue(api.authorizations.stream().allMatch(value -> value == null)); + } + } + + @Test + void onlyWorkflowBotReceiptsAreAcceptedIncludingOnLaterCommentPages() throws Exception { + try (final Api api = new Api(path -> { + if (!path.contains("comments")) { + return "{\"state\":\"closed\"}"; + } + final JsonArray comments = new JsonArray(); + if (path.contains("&page=1")) { + for (int index = 0; index < 100; index++) { + comments.add(comment("alice", 123, "User")); + } + } else { + comments.add(comment("github-actions[bot]", 123, "Bot")); + comments.add(comment("github-actions[bot]", 41898282, "Bot")); + } + return comments.toString(); + })) { + final SubmissionStatus status = api.transport().status(REPOSITORY, batch()); + assertEquals(List.of(new SubmissionReceipt(ID, batch().issueUrl())), status.receipts()); + assertTrue(status.terminal()); + } + } + + @Test + void ordinaryAcceptedLookingCommentDoesNotProduceReceipt() throws Exception { + try (final Api api = new Api(path -> path.contains("comments") + ? "[" + comment("alice", 123, "User") + "]" : "{\"state\":\"open\"}")) { + assertTrue(api.transport().status(REPOSITORY, batch()).receipts().isEmpty()); + } + } + + @Test + void anonymousRateLimitFailsExplicitlyAndCanBeRetried() throws Exception { + try (final Api api = new Api(path -> "[]")) { + api.status = 403; + final IOException failure = assertThrows(IOException.class, + () -> api.transport().findIssue(REPOSITORY, PREPARED, "alice")); + assertTrue(failure.getMessage().contains("403")); + api.status = 200; + assertTrue(api.transport().findIssue(REPOSITORY, PREPARED, "alice").isEmpty()); + } + } + + private static SubmittedBatch batch() { + return new SubmittedBatch(42, REPOSITORY + "/issues/42", List.of(ID)); + } + + private static JsonObject issue(final String account, final String body) { + final JsonObject issue = new JsonObject(); + issue.addProperty("title", PREPARED.title()); + issue.addProperty("number", 42); + issue.addProperty("html_url", batch().issueUrl()); + issue.addProperty("body", body); + final JsonObject user = new JsonObject(); + user.addProperty("login", account); + issue.add("user", user); + return issue; + } + + private static JsonObject comment(final String login, final long id, final String type) { + final JsonObject comment = new JsonObject(); + comment.addProperty("body", "Processed result batch:\n\n```text\n" + ID + ": accepted\n```"); + final JsonObject user = new JsonObject(); + user.addProperty("login", login); + user.addProperty("id", id); + user.addProperty("type", type); + comment.add("user", user); + return comment; + } + + private static final class Api implements AutoCloseable { + private final HttpServer server; + private final List authorizations = new ArrayList<>(); + private volatile int status = 200; + + Api(final Function response) throws IOException { + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/", exchange -> { + authorizations.add(exchange.getRequestHeaders().getFirst("Authorization")); + final byte[] bytes = response.apply(exchange.getRequestURI().toString()).getBytes(StandardCharsets.UTF_8); + exchange.sendResponseHeaders(status, bytes.length); + try (var output = exchange.getResponseBody()) { + output.write(bytes); + } + }); + server.start(); + } + + GitHubIssueOpsTransport transport() { + return new GitHubIssueOpsTransport(HttpClient.newHttpClient(), null, + URI.create("http://127.0.0.1:" + server.getAddress().getPort() + "/")); + } + + @Override + public void close() { + server.stop(0); + } + } +} diff --git a/src/test/java/dev/robocode/rumble/client/IssueOpsSubmissionTest.java b/src/test/java/dev/robocode/rumble/client/IssueOpsSubmissionTest.java index 0f08ff9..5c350fc 100644 --- a/src/test/java/dev/robocode/rumble/client/IssueOpsSubmissionTest.java +++ b/src/test/java/dev/robocode/rumble/client/IssueOpsSubmissionTest.java @@ -91,7 +91,7 @@ void testRCL013_IntegrationPositive_splitsByResultCountAndByBodySize() throws IO large.append(record(UUID.randomUUID().toString(), "B".repeat(3000))); } final SubmissionReport sized = new IssueOpsSubmission(new ContributorIssues(), CLOCK, - temporaryDirectory.resolve("large-files")).submit(large, snapshot()); + temporaryDirectory.resolve("large-files"), IssueOpsSubmissionTest::published).submit(large, snapshot()); assertTrue(sized.waiting().size() > 1); assertTrue(sized.waiting().stream().allMatch(post -> post.batch().body().length() <= IssueOpsSubmission.MAX_BODY_CHARACTERS)); @@ -178,8 +178,80 @@ void testRCL006_IntegrationNegative_unknownFormTitleDoesNotAcknowledgeAnything() assertEquals(List.of(record), journal.pending()); } + @Test + @Tag("RCL-006") + void rejectedAttemptDoesNotHideOpenRetryAcrossRestarts() throws IOException { + final RankedBattleRecord record = record(UUID.randomUUID().toString()); + new RankedJournal(temporaryDirectory).append(record); + final ContributorIssues issues = new ContributorIssues(); + final PreparedPost first = submission(issues).submit(new RankedJournal(temporaryDirectory), snapshot()).waiting().get(0); + issues.post(first.batch().title(), 42, Set.of()); + issues.closedWithoutReceipts.add(42); + final PreparedPost retry = submission(issues).submit(new RankedJournal(temporaryDirectory), snapshot()).waiting().get(0); + assertFalse(first.batch().title().equals(retry.batch().title())); + issues.post(retry.batch().title(), 43, Set.of()); + for (int restart = 0; restart < 3; restart++) { + assertTrue(submission(issues).submit(new RankedJournal(temporaryDirectory), snapshot()).waiting().isEmpty()); + assertEquals(List.of(record), new RankedJournal(temporaryDirectory).pending()); + } + issues.post(retry.batch().title(), 43, Set.of(record.battleId())); + assertEquals(1, submission(issues).submit(new RankedJournal(temporaryDirectory), snapshot()).receipts().size()); + assertTrue(new RankedJournal(temporaryDirectory).pending().isEmpty()); + } + + @Test + @Tag("RCL-006") + void receiptWithoutPublishedFactRetainsRecordAndDoesNotPrepareAnotherBatch() throws IOException { + final RankedBattleRecord record = record(UUID.randomUUID().toString()); + final RankedJournal journal = new RankedJournal(temporaryDirectory); + journal.append(record); + final ContributorIssues issues = new ContributorIssues(); + final PreparedPost post = submission(issues).submit(journal, snapshot()).waiting().get(0); + issues.post(post.batch().title(), 42, Set.of(record.battleId())); + issues.closedWithoutReceipts.add(42); + final IssueOpsSubmission missing = new IssueOpsSubmission(issues, CLOCK, temporaryDirectory.resolve("submissions"), + (repository, records, account) -> Set.of()); + final SubmissionReport report = missing.submit(journal, snapshot()); + assertTrue(report.receipts().isEmpty()); + assertTrue(report.waiting().isEmpty()); + assertEquals(List.of(record), journal.pending()); + assertEquals(1, submission(issues).submit(journal, snapshot()).receipts().size()); + } + + @Test + @Tag("RCL-006") + void interruptedPublicationCheckRetainsRecordForReceiptRecovery() throws IOException { + final RankedBattleRecord record = record(UUID.randomUUID().toString()); + final RankedJournal journal = new RankedJournal(temporaryDirectory); + journal.append(record); + final ContributorIssues issues = new ContributorIssues(); + final PreparedPost post = submission(issues).submit(journal, snapshot()).waiting().get(0); + issues.post(post.batch().title(), 42, Set.of(record.battleId())); + final IssueOpsSubmission offline = new IssueOpsSubmission(issues, CLOCK, temporaryDirectory.resolve("submissions"), + (repository, records, account) -> { throw new IOException("offline"); }); + assertThrows(IOException.class, () -> offline.submit(journal, snapshot())); + assertEquals(List.of(record), new RankedJournal(temporaryDirectory).pending()); + assertEquals(1, submission(issues).submit(new RankedJournal(temporaryDirectory), snapshot()).receipts().size()); + } + + @Test + @Tag("RCL-013") + void singleOversizedRecordFailsWithoutLosingPendingRecords() throws IOException { + final RankedBattleRecord record = record(UUID.randomUUID().toString(), "B".repeat(60_000)); + final RankedJournal journal = new RankedJournal(temporaryDirectory); + journal.append(record); + final IOException error = assertThrows(IOException.class, () -> submission(new ContributorIssues()).submit(journal, snapshot())); + assertTrue(error.getMessage().contains(record.battleId().toString())); + assertEquals(List.of(record), journal.pending()); + assertTrue(journal.unpostedPrepared().isEmpty()); + } + private IssueOpsSubmission submission(final IssueOpsTransport transport) { - return new IssueOpsSubmission(transport, CLOCK, temporaryDirectory.resolve("submissions")); + return new IssueOpsSubmission(transport, CLOCK, temporaryDirectory.resolve("submissions"), IssueOpsSubmissionTest::published); + } + + private static Set published(final URI repository, final List records, final String account) { + return records.stream().map(RankedBattleRecord::battleId).collect(java.util.stream.Collectors.toSet()); } private static RumbleSnapshot snapshot() { @@ -189,7 +261,7 @@ private static RumbleSnapshot snapshot() { new ClientRegistration("alice", "alice-client"), Map.of()); } - private static RankedBattleRecord record(final String battleId) { + static RankedBattleRecord record(final String battleId) { return record(battleId, "Alpha"); } @@ -213,14 +285,14 @@ void post(final String title, final int number, final Set receipted) { } @Override - public Optional findIssue(final URI repository, final String title, - final List battleIds) throws IOException { + public Optional findIssue(final URI repository, final PreparedBatch prepared, + final String account) throws IOException { if (offline) { throw new IOException("offline"); } - final Integer number = numbersByTitle.get(title); + final Integer number = numbersByTitle.get(prepared.title()); return number == null ? Optional.empty() : Optional.of(new SubmittedBatch(number, - "https://github.com/example/rumble-data/issues/" + number, battleIds)); + "https://github.com/example/rumble-data/issues/" + number, prepared.battleIds())); } @Override From b98afc7f7449802479a434be31b61c0e5207747d Mon Sep 17 00:00:00 2001 From: "Flemming N. Larsen" Date: Mon, 19 Oct 2026 19:22:35 +0200 Subject: [PATCH 3/3] fix: compare submission selection seeds without numeric rounding Cliewen-Route: simple --- .../client/CanonicalPublishedResults.java | 2 +- .../client/GitHubIssueOpsTransport.java | 5 ++-- .../robocode/rumble/client/JsonContract.java | 29 +++++++++++++++++++ .../rumble/client/JsonContractTest.java | 21 ++++++++++++++ 4 files changed, 54 insertions(+), 3 deletions(-) create mode 100644 src/test/java/dev/robocode/rumble/client/JsonContractTest.java diff --git a/src/main/java/dev/robocode/rumble/client/CanonicalPublishedResults.java b/src/main/java/dev/robocode/rumble/client/CanonicalPublishedResults.java index 14c4b27..a8ea954 100644 --- a/src/main/java/dev/robocode/rumble/client/CanonicalPublishedResults.java +++ b/src/main/java/dev/robocode/rumble/client/CanonicalPublishedResults.java @@ -56,7 +56,7 @@ public Set find(final URI repository, final List recor expected.remove("schemaVersion"); for (final JsonObject fact : facts) { if (fact.has("submittedBy") && account.equalsIgnoreCase(fact.get("submittedBy").getAsString()) - && expected.entrySet().stream().allMatch(entry -> entry.getValue().equals(fact.get(entry.getKey())))) { + && expected.entrySet().stream().allMatch(entry -> JsonContract.sameValue(entry.getValue(), fact.get(entry.getKey())))) { published.add(record.battleId()); break; } diff --git a/src/main/java/dev/robocode/rumble/client/GitHubIssueOpsTransport.java b/src/main/java/dev/robocode/rumble/client/GitHubIssueOpsTransport.java index fe4d32f..27e6e4d 100644 --- a/src/main/java/dev/robocode/rumble/client/GitHubIssueOpsTransport.java +++ b/src/main/java/dev/robocode/rumble/client/GitHubIssueOpsTransport.java @@ -25,6 +25,7 @@ final class GitHubIssueOpsTransport implements IssueOpsTransport { private static final String API_VERSION = "2026-03-10"; private static final Pattern JSON_BLOCK = Pattern.compile("```json\\s*\\n(.*?)\\n```", Pattern.DOTALL); + private static final long INGESTION_BOT_ID = 41898282L; private final HttpClient client; private final String token; @@ -79,7 +80,7 @@ public SubmissionStatus status(final URI repository, final SubmittedBatch batch) final JsonElement body = comment.get("body"); final JsonObject user = comment.getAsJsonObject("user"); // The ingestion workflow publishes these receipts only after pushing canonical facts. - if (user != null && user.has("id") && user.get("id").getAsLong() == 41898282L + if (user != null && user.has("id") && user.get("id").getAsLong() == INGESTION_BOT_ID && "github-actions[bot]".equals(user.get("login").getAsString()) && "Bot".equals(user.get("type").getAsString()) && body != null && body.isJsonPrimitive()) { @@ -151,7 +152,7 @@ private static boolean sameEnvelope(final JsonElement body, final String prepare return false; } final JsonElement envelope = JsonParser.parseString(actual.group(1)); - return !actual.find() && envelope.equals(JsonParser.parseString(expected.group(1))); + return !actual.find() && JsonContract.sameValue(envelope, JsonParser.parseString(expected.group(1))); } catch (RuntimeException exception) { return false; } diff --git a/src/main/java/dev/robocode/rumble/client/JsonContract.java b/src/main/java/dev/robocode/rumble/client/JsonContract.java index bff7249..74ecc51 100644 --- a/src/main/java/dev/robocode/rumble/client/JsonContract.java +++ b/src/main/java/dev/robocode/rumble/client/JsonContract.java @@ -23,6 +23,35 @@ private JsonContract(final JsonObject object, final String document) { this.document = document; } + /** Structural equality without rounding 64-bit selection seeds through floating point. */ + static boolean sameValue(final JsonElement expected, final JsonElement actual) { + if (expected == null || actual == null) { + return expected == actual; + } + if (expected.isJsonObject() && actual.isJsonObject()) { + final JsonObject object = expected.getAsJsonObject(); + return object.size() == actual.getAsJsonObject().size() && object.entrySet().stream() + .allMatch(entry -> sameValue(entry.getValue(), actual.getAsJsonObject().get(entry.getKey()))); + } + if (expected.isJsonArray() && actual.isJsonArray()) { + final JsonArray array = expected.getAsJsonArray(); + if (array.size() != actual.getAsJsonArray().size()) { + return false; + } + for (int index = 0; index < array.size(); index++) { + if (!sameValue(array.get(index), actual.getAsJsonArray().get(index))) { + return false; + } + } + return true; + } + if (expected.isJsonPrimitive() && actual.isJsonPrimitive() + && expected.getAsJsonPrimitive().isNumber() && actual.getAsJsonPrimitive().isNumber()) { + return expected.getAsBigDecimal().compareTo(actual.getAsBigDecimal()) == 0; + } + return expected.equals(actual); + } + static JsonContract parse(final String json, final String document) { try { final JsonElement root = JsonParser.parseString(json); diff --git a/src/test/java/dev/robocode/rumble/client/JsonContractTest.java b/src/test/java/dev/robocode/rumble/client/JsonContractTest.java new file mode 100644 index 0000000..e6e759c --- /dev/null +++ b/src/test/java/dev/robocode/rumble/client/JsonContractTest.java @@ -0,0 +1,21 @@ +package dev.robocode.rumble.client; + +import com.google.gson.JsonParser; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@Tag("RCL-006") +class JsonContractTest { + @Test + void envelopeComparisonDistinguishesAdjacentLargeSelectionSeeds() { + final String expected = "{\"results\":[{\"selectionSeed\":5888892092719975281}]}"; + final String changed = "{\"results\":[{\"selectionSeed\":5888892092719975282}]}"; + assertFalse(JsonContract.sameValue(JsonParser.parseString(expected), JsonParser.parseString(changed))); + assertTrue(JsonContract.sameValue(JsonParser.parseString(expected), JsonParser.parseString(expected))); + assertTrue(JsonContract.sameValue(JsonParser.parseString("{\"b\":2,\"a\":1}"), + JsonParser.parseString("{\"a\":1,\"b\":2}"))); + } +}