Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 48 additions & 7 deletions challenges/glitch-invaders/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,16 @@ Incident Console with a stack trace, breadcrumbs, or a performance trace, asks
for the root cause, and applies your chosen patch so you can watch the fix work
in play.

The game always runs in demo mode: the Incident Console shows simulated Sentry
evidence and no telemetry leaves the machine. Live Sentry mode returns once
boothstrap provides sponsor settings; see
[boothstrap.sh issue 519](https://github.com/MLH/boothstrap.sh/issues/519).
The game runs in demo mode unless [live Sentry mode](#live-sentry-mode) is
set up: the Incident Console shows simulated Sentry evidence and no telemetry
leaves the machine.

## Files

| File | Purpose |
| --- | --- |
| `challenge.json`, `plugin.json` | Manifest and Agent Plugin metadata |
| `sentry.json` | Public Sentry settings for live mode: DSN, org slug, project slug |
| `intro.md` | Intro screen text |
| `skills/start-challenge/SKILL.md` | The guide Pi follows |
| `monitor/SKILL.md` | Headless browser monitor skill |
Expand All @@ -40,11 +40,52 @@ which clue helped most and reports `debrief`.
The server listens on port 4373. Set `GLITCH_PORT` to use another port, though
the challenge manifest expects 4373.

## Live Sentry mode

Live mode needs both halves:

- **`sentry.json` filled in.** The DSN, org slug, and project slug are public
values, so they live in the package. While any value is still a
`<placeholder>`, the game runs in demo mode. Once filled in, a malformed value
fails setup naming it. An optional `apiUrl` replaces the Sentry API origin,
`https://sentry.io`; the tests point it at a local stand-in.
- **`SENTRY_AUTH_TOKEN` in the environment.** On a station, the event vault's
`sentry` service sets it to a placeholder that the vault proxy swaps for the
real token. `boothstrap dev` and a standalone run read the real token from
Comment thread
theycallmeswift marked this conversation as resolved.
Resolved
your shell.

In live mode the page reports each failure to the DSN: an error event for the
weapon and the shield, and a span trace for the boss spawn. The page server then
reads Sentry's record of it from the Sentry API (the issue, its stack trace or
breadcrumbs, or the trace's spans) and the Incident Console shows that, with a
link to it in Sentry. The token stays in the page server: it is never served to
the page or written anywhere. The page's CSP lets it contact only this server
and the DSN's ingest origin. If Sentry doesn't accept the report or can't return
it within about 15 seconds, that incident shows simulated evidence with a note
saying so. Setup prints `(live Sentry mode)` after the URL when live mode is on,
and a note when `sentry.json` is filled in but the token is missing.

Because `vault_services` names `sentry`, the event vault must hold a `sentry`
service, or the station lists the challenge as unavailable, on every boothstrap
version:

| Field | Value |
| --- | --- |
| Name | `sentry` |
| Auth | `bearer`, token `SENTRY_AUTH_TOKEN` |
| Host | `sentry.io` |

The token needs the `event:read`, `project:read`, and `org:read` scopes. The
station also needs a boothstrap that sets each `vault_services` credential in
the challenge's hooks. Release `0.1.0` predates that, so on it the challenge gets
no token and runs in demo mode.

## Run without boothstrap

Needs Node 26 and a `GEMINI_API_KEY`. Follow the standalone steps in
boothstrap.sh's `docs/authoring-challenges.md` to install Pi and load the
package as an Agent Plugin, then:
Needs Node 26 and a `GEMINI_API_KEY`; export `SENTRY_AUTH_TOKEN` too for live
mode. Follow the standalone steps in boothstrap.sh's
`docs/authoring-challenges.md` to install Pi and load the package as an Agent
Plugin, then:

```sh
challenge=/absolute/path/to/glitch-invaders
Expand Down
3 changes: 2 additions & 1 deletion challenges/glitch-invaders/challenge.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@
"agent": "pi",
"model": "google/gemini-3.8-flash",
"vault_services": [
"gemini"
"gemini",
"sentry"
],
"points": 150,
"estimated_minutes": 9,
Expand Down
42 changes: 35 additions & 7 deletions challenges/glitch-invaders/game/src/app/app.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@ import { loadBossResources } from '../boss/boss-loader.mjs';
import { BUG_DEFINITIONS } from '../bugs/bug-definitions.mjs';
import { tryRepair } from '../bugs/code-state.mjs';
import { IncidentTracker } from '../bugs/incident-tracker.mjs';
import { LiveEvidenceProvider } from '../diagnostics/live-evidence.mjs';
import { SimulatedEvidenceProvider } from '../diagnostics/simulated-evidence.mjs';
import { GameEngine } from '../engine/game-engine.mjs';
import { createRandom } from '../engine/random.mjs';
import { CanvasRenderer } from '../render/canvas-renderer.mjs';
import { attachBreadcrumbs } from '../sentry/breadcrumbs.mjs';
import { publishState } from '../session/monitor-state.mjs';

import { BossLoadingView } from '../ui/boss-loading.mjs';
Expand Down Expand Up @@ -49,7 +51,8 @@ export class GameApp {
overlays = new OverlayHost(requireElement('overlay-root'));
bossView = new BossLoadingView(requireElement('boss-loading'));
engine;
evidence = new SimulatedEvidenceProvider();
simulated = new SimulatedEvidenceProvider();
live = null;
incidentConsole;
input;
loop;
Expand All @@ -62,10 +65,16 @@ export class GameApp {
pauseOpen = false;
pendingStart = null;

/**
* @param {object} options the page's config, store, and hooks; `reporter` is given only in live
* mode and sends the failures to Sentry.
* @param {object | null} saved the run to resume, if any.
*/
constructor(options, saved) {
this.options = options;
this.now = options.now ?? (() => performance.now());
this.progress = saved ?? createProgress(options.config.attemptId);
const mode = options.reporter === undefined ? 'demo' : 'live';
this.progress = saved ?? createProgress(options.config.attemptId, mode);
this.tracker = new IncidentTracker(this.progress);
this.code = codeForProgress(this.progress);

Expand All @@ -76,6 +85,14 @@ export class GameApp {
now: this.now,
particleScale: options.reducedMotion ? 0.25 : 1,
});
if (options.reporter !== undefined) {
attachBreadcrumbs(this.engine, options.reporter);
this.live = new LiveEvidenceProvider({
reporter: options.reporter,
simulated: this.simulated,
fetchImpl: (input, init) => fetch(input, init),
});
}
this.incidentConsole = new IncidentConsole({
progressOf: (bug) => this.progress.bugs[bug],
markEvidenceViewed: (bug) => this.tracker.markEvidenceViewed(bug),
Expand All @@ -102,6 +119,9 @@ export class GameApp {
begin() {
this.tracker.subscribe((progress) => this.onProgress(progress));
this.engine.on((event) => this.onEvent(event));
if (this.live !== null) {
this.hud.showLiveMode();
}
publishState(document, this.progress);

this.input.attach(document, {
Expand Down Expand Up @@ -314,10 +334,15 @@ export class GameApp {
`${definition.incidentLabel}: ${definition.title}. The Incident Console is open.`,
);

void this.evidence.evidenceFor(encounter).then((evidence) => this.deliverEvidence(evidence));
void (this.live ?? this.simulated)
.evidenceFor(encounter)
.then((evidence) => this.deliverEvidence(evidence));
}

/** A failure was restored from a saved run: reopen the console on its evidence. */
/**
* A failure was restored from a saved run: reopen the console on its evidence. The live capture is
* gone and a second report would be a duplicate, so live mode falls back to simulated evidence.
*/
openRestoredIncident(bug) {
this.input.releaseAll();
this.incidentConsole.open(bug);
Expand All @@ -328,9 +353,12 @@ export class GameApp {

return;
}
void this.evidence
.evidenceFor(canonicalEncounter(bug))
.then((evidence) => this.deliverEvidence(evidence));
const encounter = canonicalEncounter(bug);
const restored =
this.live === null
? this.simulated.evidenceFor(encounter)
: this.live.fallback(encounter, 'restored');
void restored.then((evidence) => this.deliverEvidence(evidence));
}

/** Remember the evidence and show it if its console is still open. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ export const WEAPON_STACK_FRAMES = [
line: 160,
inApp: true,
},
{ functionName: 'GameLoop.frame', file: 'game/src/app/app.mjs', line: 94, inApp: true },
{ functionName: 'GameLoop.frame', file: 'game/src/app/app.mjs', line: 111, inApp: true },
{ functionName: 'requestAnimationFrame', file: '<browser>', line: 0, inApp: false },
];

Expand Down Expand Up @@ -155,27 +155,34 @@ export function evidenceSections(encounter, breadcrumbs, spans) {
},
];
case 'boss':
return [
{
kind: 'waterfall',
title: 'Trace: boss-spawn',
totalMs: spans.reduce((end, span) => Math.max(end, span.startMs + span.durationMs), 0),
spans: [...spans],
},
{
kind: 'context',
title: 'boot_state',
contextName: 'boot_state',
entries: {
animationsLoaded: 'at game start',
spawnBudget: seconds(BOSS_SPAWN_BUDGET_MS),
strategy: 'sequential',
},
},
];
return [waterfallSection(spans), bootStateSection()];
}
}

/** The boss-spawn trace as a waterfall section, its total the end of the last span. */
export function waterfallSection(spans) {
return {
kind: 'waterfall',
title: 'Trace: boss-spawn',
totalMs: spans.reduce((end, span) => Math.max(end, span.startMs + span.durationMs), 0),
spans: [...spans],
};
}

/** The boss failure's context: what the shipped boot code already did and the spawn budget. */
export function bootStateSection() {
return {
kind: 'context',
title: 'boot_state',
contextName: 'boot_state',
entries: {
animationsLoaded: 'at game start',
spawnBudget: seconds(BOSS_SPAWN_BUDGET_MS),
strategy: 'sequential',
},
};
}

/** Format an issue reference for the simulated provider, such as `SIM-WEAPON-1`. */
export function simulatedReference(bug) {
return `SIM-${bug.toUpperCase()}-1`;
Expand Down
56 changes: 56 additions & 0 deletions challenges/glitch-invaders/game/src/diagnostics/live-evidence.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
/**
* Live-mode evidence: report the failure to Sentry, then ask the page server for what Sentry
* recorded. When either step fails, the console still gets the simulated evidence, labelled with
* why, so it is never left blank.
*/

/** How long the page waits for the server, which gives up on Sentry sooner than this. */
const EVIDENCE_TIMEOUT_MS = 20_000;

/** The notes a fallback carries, by what went wrong. */
export const FALLBACK_NOTES = {
unsent: 'Sentry did not accept the report, so this is simulated evidence.',
unavailable: 'Sentry could not return the report in time, so this is simulated evidence.',
restored: 'The page reloaded after this report, so this is simulated evidence.',
};

/** Builds evidence from Sentry's own record of each report. */
export class LiveEvidenceProvider {
/**
* @param {{ reporter: { report: Function }, simulated: { evidenceFor: Function },
* fetchImpl: typeof fetch }} options `fetchImpl` reaches the page server.
*/
constructor(options) {
this.options = options;
}

/** Report `encounter` and fetch Sentry's evidence for it. Never rejects. */
async evidenceFor(encounter) {
const outcome = await this.options.reporter.report(encounter);
if (!outcome.delivered) {
return this.fallback(encounter, 'unsent');
}

try {
const query = new URLSearchParams({ bug: encounter.bug, id: outcome.id });
const response = await this.options.fetchImpl(`/api/sentry-evidence?${query}`, {
cache: 'no-store',
signal: AbortSignal.timeout(EVIDENCE_TIMEOUT_MS),
});
if (!response.ok) {
return this.fallback(encounter, 'unavailable');
}

return await response.json();
} catch {
return this.fallback(encounter, 'unavailable');
}
}

/** The simulated evidence for `encounter`, labelled as a fallback with the note for `reason`. */
async fallback(encounter, reason) {
const simulated = await this.options.simulated.evidenceFor(encounter);

return { ...simulated, source: 'fallback', note: FALLBACK_NOTES[reason] };
}
}
13 changes: 12 additions & 1 deletion challenges/glitch-invaders/game/src/main.mjs
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
/** The page entry: read this attempt's configuration and saved progress, and start the game. */
/**
* The page entry: read this attempt's configuration and saved progress, and start the game, in live
* Sentry mode when the config carries Sentry settings.
*/
import { parseRuntimeConfig } from '../../shared/runtime-config.mjs';
import { GameApp } from './app/app.mjs';
import { SentryReporter } from './sentry/reporter.mjs';
import { HttpProgressStore } from './session/progress-store.mjs';

/** Read `/runtime-config.json`, which the page server writes for every attempt. */
Expand Down Expand Up @@ -56,6 +60,13 @@ async function main() {
store,
reload: () => window.location.reload(),
reducedMotion: window.matchMedia('(prefers-reduced-motion: reduce)').matches,
...(config.sentry === undefined
? {}
: {
reporter: new SentryReporter(config.sentry, config.attemptId, (input, init) =>
fetch(input, init),
),
}),
...(Number.isInteger(seed) && query.has('seed') ? { seed } : {}),
},
saved,
Expand Down
Loading
Loading