diff --git a/eval/harness/build.mjs b/eval/harness/build.mjs index e39698d..3471046 100644 --- a/eval/harness/build.mjs +++ b/eval/harness/build.mjs @@ -6,12 +6,14 @@ // vanilla arm: `src/main.ts` default-exports `mount(root: HTMLElement)`, called once with the `#app` container // foldkit arm: `src/main.ts` exports Model, init, update and view; booted with Runtime.makeApplication and // Runtime.run, as Foldkit's own `src/entry.ts` does +// contract arm: an Exact app, built by its own `exact.mjs web-build` (harness/contract.mjs) // The app's API is `src/api.js` over `src/flights.js`: every call is a flight the test lands or fails. // Usage: node harness/build.mjs [rx|react] (the arm defaults to what `/arm` says) import { execFileSync } from "node:child_process"; import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import path from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; +import { buildContract } from "./contract.mjs"; const here = path.dirname(fileURLToPath(import.meta.url)); const evalDir = path.resolve(here, ".."); @@ -35,6 +37,7 @@ async function solidPlugin() { export async function build(dir, arm = armOf(dir)) { dir = realpathSync(path.resolve(dir)); + if (arm === "contract") return buildContract(dir); const work = path.join(dir, ".build"); rmSync(work, { recursive: true, force: true }); mkdirSync(work, { recursive: true }); // the app's bare imports resolve from the harness's packages diff --git a/eval/harness/check.mjs b/eval/harness/check.mjs index 45328c3..b7c92fe 100644 --- a/eval/harness/check.mjs +++ b/eval/harness/check.mjs @@ -4,12 +4,14 @@ // solid: tsc --noEmit -p . and eslint (eslint-plugin-solid's v2 config) // vanilla: tsc --noEmit -p . (strict) // foldkit: tsc --noEmit -p . with Foldkit's template tsconfig (its lint, oxlint + effect-tsgo, is not on npm) +// contract: `exact.mjs contract build app.contract`, which also type-checks app.ts // Usage: node harness/check.mjs import { execFileSync } from "node:child_process"; import { existsSync, symlinkSync } from "node:fs"; import path from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import { RXC, armOf } from "./build.mjs"; +import { checkContract } from "./contract.mjs"; const here = path.dirname(fileURLToPath(import.meta.url)); const evalDir = path.resolve(here, ".."); @@ -21,6 +23,7 @@ const run = (cmd, args, cwd) => { export function check(dir) { dir = path.resolve(dir); + if (armOf(dir) === "contract") return checkContract(dir); if (!existsSync(path.join(dir, "node_modules"))) symlinkSync(path.join(evalDir, "node_modules"), path.join(dir, "node_modules")); if (armOf(dir) === "rx") return run(RXC, ["check", "src/main.rx"], dir); const bin = n => path.join(evalDir, "node_modules/.bin", n); diff --git a/eval/harness/contract.mjs b/eval/harness/contract.mjs new file mode 100644 index 0000000..8a89024 --- /dev/null +++ b/eval/harness/contract.mjs @@ -0,0 +1,50 @@ +// The contract arm: an Exact app (https://github.com/expo/exact), `app.contract` for the interface and `app.ts` for its +// data module, which imports the task's `src/api.js`. A desk is the directory `exact new` makes, with the eval's files +// beside it. EXACT2 names the Exact checkout the desk is made from; the desk's own `exact.mjs` keeps that path. +// +// The build is Exact's web JS target, a release build (`exact.mjs web-build --out`). The data module runs in the page's +// realm, so `src/flights.js` puts `__flights` on the page as it does in every other arm, and the driver is unchanged. +// BUN names the Bun to run `exact.mjs` with (default: `bun` on PATH). +import { execFileSync, spawnSync } from "node:child_process"; +import { cpSync, existsSync, readFileSync, rmSync } from "node:fs"; +import path from "node:path"; + +const bun = () => process.env.BUN ?? "bun"; +const exactEnv = () => ({ ...process.env, EXACT_FEEDBACK: "0", NO_COLOR: "1" }); + +export function exact2() { + const dir = process.env.EXACT2; + if (!dir || !existsSync(path.join(dir, "scripts/exact.mjs"))) throw new Error("EXACT2 must name an Exact checkout (its scripts/exact.mjs)"); + return path.resolve(dir); +} + +/** `/dist`, built by the desk's `exact.mjs` (Exact keeps a build inside an app in `dist`, which its source scans + * skip); a compile error is the `compile` stage's output */ +export function buildContract(dir) { + const dist = path.join(dir, "dist"); + const r = spawnSync(bun(), ["exact.mjs", "web-build", "--out", dist], { cwd: dir, env: exactEnv(), encoding: "utf8", maxBuffer: 64 << 20 }); + if (r.status !== 0) return { ok: false, stage: "compile", output: `${r.stdout ?? ""}${r.stderr ?? ""}`.trim() }; + return { ok: true, dist }; +} + +/** the compiler's diagnostics, warnings included, as the agent's `./check` shows them */ +export function checkContract(dir) { + const r = spawnSync(bun(), ["exact.mjs", "contract", "build", "app.contract"], { cwd: dir, env: exactEnv(), encoding: "utf8", maxBuffer: 64 << 20 }); + return { ok: r.status === 0, out: `${r.stdout ?? ""}${r.stderr ?? ""}`.trim() || "ok" }; +} + +/** `exact new `, then the scaffold's own files go in beside it. The placeholder test is removed: `./test` is the + * desk's test command, as in every arm. */ +export function newContractDesk(dir) { + if (existsSync(dir)) throw new Error(`${dir} exists; a contract desk starts from \`exact new\``); + execFileSync(bun(), [path.join(exact2(), "scripts/exact.mjs"), "new", dir], { env: exactEnv(), stdio: "pipe" }); + rmSync(path.join(dir, "app.test.contract"), { force: true }); +} + +/** Exact's one required guide, for the prompt's inline docs */ +export const startHere = () => readFileSync(path.join(exact2(), "docs/start-here.md"), "utf8"); + +/** copies a reference app's files over a desk's placeholders */ +export function copyContractReference(from, dir) { + for (const f of ["app.contract", "app.ts"]) cpSync(path.join(from, f), path.join(dir, f)); +} diff --git a/eval/harness/driver.mjs b/eval/harness/driver.mjs index 47ec5ea..7d0f062 100644 --- a/eval/harness/driver.mjs +++ b/eval/harness/driver.mjs @@ -23,7 +23,13 @@ export async function serve(dist) { } /** CHROMIUM=/path/to/chromium drives a system browser (NixOS and the like, where Playwright's download does not run). */ -export async function launch() { return chromium.launch(process.env.CHROMIUM ? { executablePath: process.env.CHROMIUM } : {}); } +// Without CHROMIUM, a Playwright whose own browser is missing falls back to a preinstalled one (/opt/pw-browsers/chromium, +// as in cloud containers), so an agent's ./test runs there too. +export async function launch() { + if (process.env.CHROMIUM) return chromium.launch({ executablePath: process.env.CHROMIUM }); + try { return await chromium.launch(); } + catch (e) { const alt = "/opt/pw-browsers/chromium"; if (existsSync(alt)) return chromium.launch({ executablePath: alt }); throw e; } +} /** One test: a fresh page on the app, `run(app)` with the helpers below. Returns { ok, error }. */ export async function runTest(browser, url, test) { diff --git a/eval/harness/prompt-arms.json b/eval/harness/prompt-arms.json index 2f18e30..1dab00d 100644 --- a/eval/harness/prompt-arms.json +++ b/eval/harness/prompt-arms.json @@ -58,5 +58,17 @@ "LANG_RULE": "plain TypeScript and the browser DOM, with no UI framework or library: `src/main.ts` must default-export `function mount(root: HTMLElement): void`, which the harness calls once with the page's `#app` element. You may add files under `src/`. No packages are installed for you to use.", "API_TYPES": "`src/api.d.ts`", "CHECKER": "the TypeScript compiler (strict)" + }, + "contract": { + "DOCS_RULE": ", except that you may read Exact's documentation in {{EXACT2}}/docs and look names up with `bun exact.mjs contract vocab ` and `bun exact.mjs docs `", + "LANG_RULE": "Exact, with Contract for the interface and TypeScript for its data module. This directory is an Exact app that `exact new` made from the Exact checkout at {{EXACT2}}; the tests run its web build in a browser. Exact's guide is `{{EXACT2}}/docs/start-here.md`, the only required reading; the rest of `{{EXACT2}}/docs` is lookup. `app.contract` is the interface and `app.ts` its data module, whose sources call the backend: import it with `import * as api from './src/api.js'`. Give an element a test id with `testId=\"…\"`. The only commands that build, test, drive or serve the app are `./check` and `./test` below: do not run the `bun exact.mjs` verbs that do (`web`, `web-build`, `serve`, `test`, `verify`, `agent`, or a platform's).", + "API_TYPES": "`src/api.d.ts`", + "CHECKER": "the Contract compiler (`bun exact.mjs contract build app.contract`), which also type-checks `app.ts`" + }, + "contract-inline": { + "DOCS_RULE": ", except that you may read Exact's documentation in {{EXACT2}}/docs and look names up with `bun exact.mjs contract vocab ` and `bun exact.mjs docs `", + "LANG_RULE": "Exact, with Contract for the interface and TypeScript for its data module. This directory is an Exact app that `exact new` made from the Exact checkout at {{EXACT2}}; the tests run its web build in a browser. Exact's guide, `docs/start-here.md`, is at the end of this message: it is the only required reading, and the rest of `{{EXACT2}}/docs` is lookup. `app.contract` is the interface and `app.ts` its data module, whose sources call the backend: import it with `import * as api from './src/api.js'`. Give an element a test id with `testId=\"…\"`. The only commands that build, test, drive or serve the app are `./check` and `./test` below: do not run the `bun exact.mjs` verbs that do (`web`, `web-build`, `serve`, `test`, `verify`, `agent`, or a platform's).", + "API_TYPES": "`src/api.d.ts`", + "CHECKER": "the Contract compiler (`bun exact.mjs contract build app.contract`), which also type-checks `app.ts`" } } diff --git a/eval/harness/scaffold.mjs b/eval/harness/scaffold.mjs index ffd3211..b8a78c2 100644 --- a/eval/harness/scaffold.mjs +++ b/eval/harness/scaffold.mjs @@ -18,6 +18,7 @@ import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync, symlinkSync, writeFileSync } from "node:fs"; import path from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; +import { copyContractReference, exact2, newContractDesk, startHere } from "./contract.mjs"; const here = path.dirname(fileURLToPath(import.meta.url)); const evalDir = path.resolve(here, ".."); @@ -30,6 +31,7 @@ const statelyDocs = process.env.STATELY_DOCS ?? path.resolve(evalDir, "../../sta export function scaffold(task, arm, dir, { reference = false, brief = null, base = null, inline = false } = {}) { const t = path.join(evalDir, "tasks", task); dir = path.resolve(dir); + if (arm === "contract") newContractDesk(dir); mkdirSync(path.join(dir, "src"), { recursive: true }); mkdirSync(path.join(dir, "tests"), { recursive: true }); writeFileSync(path.join(dir, "arm"), `${arm}\n`); cpSync(path.join(t, brief ? `brief-${brief}.md` : "brief.md"), path.join(dir, "BRIEF.md")); @@ -69,7 +71,8 @@ export function scaffold(task, arm, dir, { reference = false, brief = null, base } // Solid's own docs ship in its package: the agent can read them there before its first build if (arm === "solid" && !existsSync(path.join(dir, "node_modules"))) symlinkSync(path.join(evalDir, "node_modules"), path.join(dir, "node_modules")); - if (reference) cpSync(path.join(evalDir, "reference", task, arm), path.join(dir, "src"), { recursive: true }); + if (reference && arm === "contract") copyContractReference(path.join(evalDir, "reference", task, arm), dir); + else if (reference) cpSync(path.join(evalDir, "reference", task, arm), path.join(dir, "src"), { recursive: true }); if (base) { cpSync(path.join(t, "BASE.md"), path.join(dir, "BASE.md")); // everything the earlier app had (a chain step's base is the previous agent's src/, which may hold more than @@ -85,11 +88,13 @@ export function scaffold(task, arm, dir, { reference = false, brief = null, base export function prompt(arm, dir, { plain = false, change = false, inline = false } = {}) { const docs = inline && arm !== "react"; const fill = { ...JSON.parse(readFileSync(path.join(here, "prompt-arms.json"), "utf8"))[docs ? `${arm}-inline` : arm] }; + if (arm === "contract") fill.EXACT2 = exact2(); fill.BACKEND_NOTE = plain ? "" : " whose answers the tests control"; let text = readFileSync(path.join(here, change ? "prompt-change.md" : "prompt.md"), "utf8"); for (let i = 0; i < 2; i++) for (const k of [...Object.keys(fill), "DIR"]) text = text.replaceAll(`{{${k}}}`, k === "DIR" ? dir : fill[k]); if (docs && arm === "rx") for (const f of ["language.md", "async.md", "reference.md"]) text += `\n---\n\n${readFileSync(path.join(evalDir, "../guide", f), "utf8")}`; if (docs && arm === "foldkit") text += `\n---\n\n${readFileSync(path.join(foldkitRepo, "packages/create-foldkit-app/templates/base/FOLDKIT.md"), "utf8")}`; + if (docs && arm === "contract") text += `\n---\n\n${startHere()}`; if (docs && arm === "xstate") text += `\n---\n\n${readFileSync(path.join(statelyDocs, "content/docs/cheatsheet.mdx"), "utf8").replace(/^---\n[\s\S]*?\n---\n+/, "")}\n---\n\n${readFileSync(path.join(evalDir, "node_modules/@xstate/react/README.md"), "utf8")}`; if (docs && arm === "solid") text += `\n---\n\n${readFileSync(path.join(evalDir, "node_modules/solid-js/skills/solid-2.0/SKILL.md"), "utf8").replace(/^---\n[\s\S]*?\n---\n+/, "")}`; return text; diff --git a/eval/harness/score.mjs b/eval/harness/score.mjs index 02f5ec9..cd97569 100644 --- a/eval/harness/score.mjs +++ b/eval/harness/score.mjs @@ -24,7 +24,7 @@ const round5 = new Set(["desk", "labels", "related", "count", "chain1", "chain2" const rows = []; for (const name of readdirSync(runs).sort()) { const dir = path.join(runs, name); - const m = name.match(/^(\w+)-(rx|react|solid|foldkit|vanilla)-(\d+)$/); + const m = name.match(/^(\w+)-(rx|react|solid|foldkit|vanilla|contract)-(\d+)$/); if (!m) continue; const [, task, arm, n] = m; const alongside = round5.has(task) ? round5Alongside : round4Alongside; diff --git a/eval/reference/desk9/contract/README.md b/eval/reference/desk9/contract/README.md new file mode 100644 index 0000000..e5b419a --- /dev/null +++ b/eval/reference/desk9/contract/README.md @@ -0,0 +1,11 @@ +# desk9 in Contract (Exact) + +Exact team's hand-written reference, from expo/exact `bench/reactivescript/harness.patch` (main 8c742e6a, 2026-11-13). +MIT License, Copyright (c) 2026 650 Industries, Inc. (Expo). + +The `contract` arm (`harness/contract.mjs` and its hooks in build, check, scaffold, prompt-arms and score) comes from +the same patch, ported to this harness. It needs `EXACT2` (an Exact checkout, `bun install` and `cargo fetch` run in +it; the web build compiles Exact's Rust workspace once) and Bun on PATH. + +Graded here on 2026-11-13 (`harness/grade.sh`): 77/77 hidden, 38/38 overlap, 0/20 fuzz, 0 defects, as in Exact's own +harness. diff --git a/eval/reference/desk9/contract/app.contract b/eval/reference/desk9/contract/app.contract new file mode 100644 index 0000000..2c31b03 --- /dev/null +++ b/eval/reference/desk9/contract/app.contract @@ -0,0 +1,401 @@ +// The issue desk: issues, help and settings behind three tabs. +// +// Every resource is the root's, so a page's state lives here too, and opening a page resets it. A resource asks only +// while its page is open and it has something to ask (`when`), and asks again when that turns true; "loading" is +// `not current(…)`, so a refresh keeps what shows. Writes show at once through app.ts's overlay. Each kind of write +// is a `parallel` mutation: every send is asked at once, and each shows until its own reply. + +shape Row + id: number + title: string + assignee: option + +shape IssueList + loaded: bool + rows: Row[] + +shape Detail + found: bool + title: string + body: string + +shape Comment + author: string + text: string + sending: bool + +shape Hit + id: number + name: string + +shape Item + found: bool + name: string + blurb: string + +shape Settings + name: string + theme: string + density: string + saving: bool + +shape Assigned + id: number + assignee: string + +shape Renamed + id: number + title: string + +shape Closed + id: number + +shape Posted + author: string + text: string + +shape Saved + name: string + theme: string + density: string + +fn rowText(r: Row): string = `#${r.id} ${r.title} (${match r.assignee { case some(a) => a, case none => "unassigned" }})` +fn message(f: option): string = match f { case some(x) => x.message, case none => "" } + +component Desk + state page = "issues" + + // The issues page. The tab's count reads the open list, which keeps its last answer while the closed list shows, + // while it loads again, and while another page is open. + state filter = "open" + state user = "guest" + state selected = 0 + state titleDraft = "" + state commentDraft = "" + state labelDraft = "" + + // The help page. + state query = "" + state hit = 0 + + derive onIssues = page == "issues" + derive chosen = onIssues and selected != 0 + + resource openIssues = fetchIssues("open") when onIssues and filter == "open" as shape IssueList else empty() + resource closedIssues = fetchIssues("closed") when onIssues and filter == "closed" as shape IssueList else empty() + resource detail = fetchIssue(selected) when chosen as shape Detail else empty() + resource comments = fetchComments(selected) when chosen as shape Comment[] else empty() + resource labels = fetchLabels(selected) when chosen as shape string[] else empty() + resource related = search(detail.title) when chosen and current(detail) and detail.found as shape Hit[] else empty() + resource results = search(query) when page == "help" and query.length >= 2 as shape Hit[] else empty() + resource item = fetchItem(hit) when page == "help" and hit != 0 as shape Item else empty() + resource settings = loadSettings() when page == "settings" as shape Settings else empty() + + mutation assigned as shape Assigned parallel refreshes openIssues, closedIssues + mutation renamed as shape Renamed parallel refreshes openIssues, closedIssues, detail + mutation closed as shape Closed parallel refreshes openIssues, closedIssues + mutation rowAssigned as shape Assigned parallel refreshes openIssues, closedIssues + mutation rowClosed as shape Closed parallel refreshes openIssues, closedIssues + mutation posted as shape Posted parallel refreshes comments + mutation labelAdded as shape string[] parallel refreshes labels + mutation labelRemoved as shape string[] parallel refreshes labels + mutation saved as shape Saved parallel refreshes settings + + derive open = filter == "open" + derive rows = open ? openIssues.rows : closedIssues.rows + derive listLoading = open ? not current(openIssues) : not current(closedIssues) + derive listError = open ? message(failure(openIssues)) : message(failure(closedIssues)) + derive issuesTab = openIssues.loaded ? `Issues (${openIssues.rows.length})` : "Issues" + derive rowError = message(failure(rowAssigned)) != "" ? message(failure(rowAssigned)) : message(failure(rowClosed)) + derive labelError = message(failure(labelAdded)) != "" ? message(failure(labelAdded)) : message(failure(labelRemoved)) + + action openIssuesPage + if page != "issues" + page = "issues" + filter = "open" + user = "guest" + selected = 0 + titleDraft = "" + commentDraft = "" + labelDraft = "" + assigned = none + renamed = none + closed = none + posted = none + labelAdded = none + labelRemoved = none + rowAssigned = none + rowClosed = none + action openHelpPage + if page != "help" + page = "help" + query = "" + hit = 0 + action openSettingsPage + if page != "settings" + page = "settings" + saved = none + + action setUser(v: string) + user = v + action showOpen + filter = "open" + action showClosed + filter = "closed" + action select(id: number) + selected = id + titleDraft = "" + commentDraft = "" + labelDraft = "" + assigned = none + renamed = none + closed = none + posted = none + labelAdded = none + labelRemoved = none + action retry + refresh detail + + action assign + send assigned = assign(selected, user) + action setTitle(v: string) + titleDraft = v + action rename + if titleDraft != "" + send renamed = renameIssue(selected, titleDraft) + action close + send closed = closeIssue(selected) + action rowAssign(id: number) + send rowAssigned = assign(id, user) + rowClosed = none + action rowClose(id: number) + send rowClosed = closeIssue(id) + rowAssigned = none + + action setComment(v: string) + commentDraft = v + action post + if commentDraft != "" + send posted = addComment(selected, user, commentDraft) + commentDraft = "" + action setLabel(v: string) + labelDraft = v + action addLabel + if labelDraft != "" and not labels.includes(labelDraft) + send labelAdded = addLabel(selected, labelDraft) + labelRemoved = none + labelDraft = "" + action removeLabel(label: string) + send labelRemoved = removeLabel(selected, label) + labelAdded = none + + action openRelated(id: number) + let title = detail.title + page = "help" + query = title + hit = id + + action setQuery(v: string) + query = v + hit = 0 + action pick(id: number) + hit = id + action retryItem + refresh item + + action retryLoad + refresh settings + action save(name: string, theme: string, density: string) + send saved = saveSettings(name, theme, density) + + view + column gap=12 padding=12 + row gap=8 + button press=openIssuesPage testId="tab-issues" + text issuesTab + button press=openHelpPage testId="tab-help" + text "Help" + button press=openSettingsPage testId="tab-settings" + text "Settings" + if page == "issues" + column gap=8 + input value=user input=setUser aria-label="Current user" testId="user" + row gap=8 + button press=showOpen testId="filter-open" + text "open" + button press=showClosed testId="filter-closed" + text "closed" + column gap=4 testId="list" + if listError != "" + text `list error: ${listError}` + else if listLoading + text `loading ${filter} issues` + else + for r in rows key=r.id + row gap=8 + button press=select(r.id) testId="issue" + text rowText(r) + button press=rowAssign(r.id) testId="row-assign" + text `assign to ${user}` + if open + button press=rowClose(r.id) testId="row-close" + text "close" + if rowError != "" + text `row error: ${rowError}` testId="row-error" + column gap=6 testId="detail" + if selected == 0 + text "select an issue" + else + if failed(detail) + text `detail error: ${message(failure(detail))}` + else if not current(detail) + text `loading #${selected}` + else if not detail.found + text `detail error: issue #${selected} not found` + else + text `#${selected} ${detail.title}` role="heading" aria-level=2 + text detail.body + button press=retry testId="retry" + text "retry" + button press=assign testId="assign" + text `assign to ${user}` + if message(failure(assigned)) != "" + text message(failure(assigned)) + row gap=8 + input value=titleDraft input=setTitle aria-label="New title" testId="title-input" + button press=rename testId="rename" + text "rename" + if message(failure(renamed)) != "" + text `rename error: ${message(failure(renamed))}` + button press=close testId="close" + text "close" + if message(failure(closed)) != "" + text `close error: ${message(failure(closed))}` + column gap=4 testId="comments" + if failed(comments) + text `comments error: ${message(failure(comments))}` + else if not current(comments) + text "loading comments" + else + for c, i in comments key=i + text `${c.author}: ${c.text}${c.sending ? " (sending…)" : ""}` testId="comment" + row gap=8 + input value=commentDraft input=setComment aria-label="Comment" testId="comment-input" + button press=post testId="post" + text "post" + if message(failure(posted)) != "" + text `post error: ${message(failure(posted))}` + column gap=4 testId="labels" + if failed(labels) + text `labels error: ${message(failure(labels))}` + else if not current(labels) + text "loading labels" + else + for l in labels key=l + row gap=8 + text l testId="label" + button press=removeLabel(l) testId="remove-label" + text `remove ${l}` + row gap=8 + input value=labelDraft input=setLabel aria-label="Label" testId="label-input" + button press=addLabel testId="add-label" + text "add label" + if labelError != "" + text `label error: ${labelError}` + if current(detail) and detail.found + column gap=4 testId="related" + if failed(related) + text `related help failed: ${message(failure(related))}` + else if not current(related) + text "finding related help" + else if related.length == 0 + text "no related help" + else + for h in related.slice(0, 3) key=h.id + button press=openRelated(h.id) testId="related-hit" + text h.name + if page == "help" + column gap=8 + input value=query input=setQuery aria-label="Search help" testId="query" + column gap=4 testId="results" + if query.length == 1 + text `too short: ${query}` + else if query.length >= 2 + if failed(results) + text `search failed: ${message(failure(results))}` + else + if current(results) + text `${results.length} results for ${query}` + else + text "searching…" + for h in results key=h.id + button press=pick(h.id) testId="hit" + text `${h.id == hit ? "> " : ""}${h.name}` + column gap=4 testId="item" + if hit == 0 + text "pick a result" + else if failed(item) + text `failed: ${message(failure(item))}` + button press=retryItem testId="retry-item" + text "retry" + else if not current(item) + text "loading…" + else if not item.found + text `item ${hit} not found` + else + text item.name role="heading" aria-level=2 + text item.blurb + if page == "settings" + column gap=8 + if not current(settings) + column gap=4 testId="load" + if failed(settings) + text `Could not load settings: ${message(failure(settings))}` + button press=retryLoad testId="retry-load" + text "retry" + else + text "Loading settings…" + else + text `Saved: ${settings.name} · ${settings.theme} · ${settings.density}${settings.saving ? " (saving…)" : ""}` testId="saved" + if message(failure(saved)) != "" + text `Save failed: ${message(failure(saved))}` testId="save-error" + SettingsForm(name0=settings.name, theme0=settings.theme, density0=settings.density, save=save) + +// The draft: filled from the settings loaded when it appears, then the person's own. A save never changes it. +component SettingsForm + props + name0: string + theme0: string + density0: string + save: action + state name = name0 + state theme = theme0 + state density = density0 + derive nameError = name == "" ? "Display name is required" : name.length > 20 ? `Display name must be at most 20 characters, not ${name.length}` : "" + action setName(v: string) + name = v + action light + theme = "light" + action dark + theme = "dark" + action toggleDensity + density = density == "compact" ? "comfortable" : "compact" + action trySave + if nameError == "" + save(name, theme, density) + view + column gap=8 + input value=name input=setName aria-label="Display name" testId="name" + text `${name.length}/20` testId="name-count" + if nameError != "" + text nameError testId="name-error" + row gap=8 + button press=light testId="theme-light" + text "light" + button press=dark testId="theme-dark" + text "dark" + button press=toggleDensity testId="density-toggle" + text density + text `Preview: ${theme} / ${density}` testId="preview" + button press=trySave buttonStyle="filled" testId="save" + text "Save" diff --git a/eval/reference/desk9/contract/app.ts b/eval/reference/desk9/contract/app.ts new file mode 100644 index 0000000..a23f67f --- /dev/null +++ b/eval/reference/desk9/contract/app.ts @@ -0,0 +1,108 @@ +// The desk's data module: each source calls the backend in src/api.js and projects its answer onto the declared shape. +// The overlay shows writes still out over the answers they change. +import type { Answer, Overlay, Result, Sources } from './app.contract.d.ts'; +import * as api from './src/api.js'; + +export const grants = ''; + +type IssueList = Result<'fetchIssues'>; +type Detail = Result<'fetchIssue'>; +type Comments = Result<'fetchComments'>; +type Labels = Result<'fetchLabels'>; +type Settings = Result<'loadSettings'>; + +const sources: Sources = { + fetchIssues: async ([filter]): Promise => { + const rows = await api.fetchIssues(filter as 'open' | 'closed'); + return { loaded: true, rows: rows.map((r) => ({ id: r.id, title: r.title, assignee: r.assignee })) }; + }, + fetchIssue: async ([id]): Promise => { + const d = await api.fetchIssue(id); + return d ? { found: true, title: d.title, body: d.body } : { found: false, title: '', body: '' }; + }, + fetchComments: async ([id]): Promise => + (await api.fetchComments(id)).map((c) => ({ author: c.author, text: c.text, sending: false })), + fetchLabels: async ([id]): Promise => api.fetchLabels(id), + search: async ([q]) => (await api.search(q)).map((h) => ({ id: h.id, name: h.name })), + fetchItem: async ([id]) => { + const item = await api.fetchItem(id); + return item ? { found: true, name: item.name, blurb: item.blurb } : { found: false, name: '', blurb: '' }; + }, + loadSettings: async (): Promise => { + const s = await api.loadSettings(); + return { name: s.name, theme: s.theme, density: s.density, saving: false }; + }, + assign: async ([id, user]) => { + const r = await api.assign(id, user); + return { id: r.id, assignee: r.assignee }; + }, + renameIssue: async ([id, title]) => { + const r = await api.renameIssue(id, title); + return { id: r.id, title: r.title }; + }, + closeIssue: async ([id]) => ({ id: (await api.closeIssue(id)).id }), + addComment: async ([id, author, text]) => { + const c = await api.addComment(id, author, text); + return { author: c.author, text: c.text }; + }, + addLabel: async ([id, label]) => api.addLabel(id, label), + removeLabel: async ([id, label]) => api.removeLabel(id, label), + saveSettings: async ([name, theme, density]) => { + const s = await api.saveSettings({ name, theme: theme as 'light' | 'dark', density: density as 'comfortable' | 'compact' }); + return { name: s.name, theme: s.theme, density: s.density }; + }, +}; +export const answer: Answer = (source, args, store, storage, native) => sources[source](args, store, storage, native); + +// The writes still out, over the answers they change. A write whose reply landed before the answer was asked is in the +// answer already (`answered`), so only the others are laid over it, in the order the runner gives them. +export const overlay: Overlay = (source, args, answer, writes) => { + const out = writes.filter((w) => !w.answered); + const id = args[0]; + switch (source) { + case 'fetchIssues': { + let rows = (answer as IssueList).rows; + for (const w of out) { + const issue = w.args[0] as number; + if (w.source === 'assign') rows = rows.map((r) => (r.id === issue ? { ...r, assignee: w.args[1] as string } : r)); + if (w.source === 'renameIssue') rows = rows.map((r) => (r.id === issue ? { ...r, title: w.args[1] as string } : r)); + if (w.source === 'closeIssue' && id === 'open') rows = rows.filter((r) => r.id !== issue); + } + return { ...(answer as IssueList), rows }; + } + case 'fetchIssue': { + const detail = answer as Detail; + let title = detail.title; + for (const w of out) if (w.source === 'renameIssue' && w.args[0] === id) title = w.args[1] as string; + return detail.found ? { ...detail, title } : undefined; + } + case 'fetchComments': { + const rows = [...(answer as Comments)]; + for (const w of out) { + if (w.source !== 'addComment' || w.args[0] !== id) continue; + rows.push({ author: w.args[1] as string, text: w.args[2] as string, sending: w.reply === undefined }); + } + return rows; + } + case 'fetchLabels': { + let shown = answer as Labels; + for (const w of out) { + if (w.args[0] !== id) continue; + const label = w.args[1] as string; + if (w.source === 'addLabel' && !shown.includes(label)) shown = [...shown, label]; + if (w.source === 'removeLabel') shown = shown.filter((l) => l !== label); + } + return shown; + } + case 'loadSettings': { + let shown = answer as Settings; + for (const w of out) { + const reply = w.reply as { name: string; theme: string; density: string } | undefined; + const [name, theme, density] = w.args as string[]; + shown = reply ? { ...shown, ...reply, saving: false } : { ...shown, name, theme, density, saving: true }; + } + return shown; + } + } + return undefined; +}; diff --git a/eval/results/desk13-probe-2026-11-13/README.md b/eval/results/desk13-probe-2026-11-13/README.md new file mode 100644 index 0000000..617685b --- /dev/null +++ b/eval/results/desk13-probe-2026-11-13/README.md @@ -0,0 +1,87 @@ +# desk9 on the new guide, Opus, 6 apps (2026-11-13) + +Not preregistered. Question: with `guide/async.md`'s two new sections (#58: "lay every view of a write over the +answer", "data belongs with its lifetime"), does Opus get near the hand-written reference's 0? + +**Setup:** Solid `next` + the #4828 fix (b8a8c2805, solidjs/solid#4829), rxc from main at 7e5bc81, desk9 scaffolded +`--brief plain --inline-docs`, Opus via general-purpose agents, graded with `harness/grade.sh`. The reference +(`reference/desk9/rx`) grades 77/77, 38/38, 0/20 on this build. + +## Results + +| app | guide | hidden | overlap | fuzz holes | defects | +| --- | --- | ---: | ---: | ---: | ---: | +| rx-1 | #58 | 76 | 37 | 2 | 4 | +| rx-2 | #58 | 76 | 37 | 0 | 2 | +| rx-3 | #58 | 76 | 37 | 3 | 5 | +| rx-4 | #58 + `visit := -1` | 77 | 37 | 1 | 2 | +| rx-5 | #58 + `visit := -1` | 77 | 37 | 1 | 2 | +| rx-6 | #58 + `visit := -1` | 77 | 36 | 1 | 3 | + +rx-1..3 could not run `./test`: the eval's Playwright wanted its own Chromium build, which this container lacks, and +`./test` did not pass `CHROMIUM`. They wrote blind. `harness/driver.mjs` now falls back to `/opt/pw-browsers/chromium`. +rx-4..6 tested normally. + +## What the defects are + +- **Guide-caused, every app: settings moved into a module.** All six applied "data belongs with its lifetime" to the + settings page too, with `guess saved := next` on the module's question. Reopening settings while a save is out then + waits for the save: the tab switch is held with the reload, so the page never shows "Loading settings…" (overlap + "a save that answers after the page was left"). rx-1..3 also loaded settings at app start (`visit := 0`), one ask + too many (hidden "loads again when reopened"); the `-1` line fixed that for rx-4..6. +- **Guide-caused, rx-4/5/6: the open count moved out of the list's region.** With the data in a module, all three + put the count `effect` beside it (module or App). An effect outside the list's `loading` waits for every re-ask, so + a close guessed while a refresh is out shows in the rows but not in the tab (fuzz rx-4 s13, rx-5 s17, rx-6 s16). + The reference's `OpenCount` sits inside the region. +- **Detail heading blank** (rx-1 s11, s15; rx-3 s15): `heading = detail == null ? "" : titleOf(renaming, …)` reads + `""` while `detail.title` in the same frame reads the answer (probed). Needs a rename guess out, a failed list + re-ask and a close's re-ask; a minimal scenario does not reproduce it. Unresolved: likely Solid. +- rx-3 s11, s19: page hangs (120 s). Not traced. +- rx-6: a row error from a write that answered after the issues page was left shows on its return (agent). + +## Guide change after this probe + +`guide/async.md` "Data belongs with its lifetime" now gives one rule (each value has one lifetime: the visit, the write +or the screen; split a value that would need two) and derives the module list, the save, the guess and the count from it. + +## Rerun on the lifetime rule (rx-7..9) + +| app | hidden | overlap | fuzz holes | defects | +| --- | ---: | ---: | ---: | ---: | +| rx-7 | 75 | 38 | 0 | 2 | +| rx-8 | 77 | 38 | 1 | 1 | +| rx-9 | 77 | 38 | 0 | **0** | + +Mean 1.0 (rx-4..6: 2.33). No settings or count-placement failures: all three kept settings in the page and put the +count inside the list's region. + +- rx-7: the help item's `errored` sits outside its `loading on(selected)`, so a new selection keeps the old failure + or not-found message instead of showing loading (agent; not a lifetime case). +- rx-8 s16: the rows render as empty text (`["","",""]`) after the open list lands, while the tab counts 3. Same + shape as rx-1/rx-3's blank heading: a text node left empty while its value is there. Likely Solid; no minimal repro. + +## The blank text, in plain Solid + +`repro/blank-rows.spec.tsx` (drop into solid-next `packages/web/test/`) reproduces rx-8 s16 in about 50 lines: a +keyed `createProjection` over a memo of an async list, two `createOptimistic` guess lists (assigns, closes), a declined +close among overlapping assigns, then a switch to another list. When the new list's re-ask lands, `For` renders one +`
  • ` per row with no text. It fails on next b8a8c2805 and on cd4de2874 (before #4828). Without the projection +(`For` over the memo) it passes. Errored, `Loading on` and the page remount are not needed. + +## On next f00ce6f52 (with the #4831 fix) + +The repro passes. rx-8 regrades 77/77, 38/38, 0/20: **0 defects** (rx-7..9 now 2, 0, 0). The reference stays at 0. +rx-1 and rx-3 keep their blank detail heading (s11, s15): a plain memo, not a projection, so a separate bug. + +## The blank heading, in plain Solid + +`repro/blank-heading.spec.tsx` reproduces rx-1/rx-3 s15 on next f00ce6f52 in about 45 lines. There is no projection +and no error boundary. Setup: + +- a list memo, and a detail memo that is still loading; +- one `createOptimistic` rename guess, read by both the list's rows and the heading memo; +- another action, out at the same time, that re-asks the list; +- the rename itself, still out. + +When the other action lands (its re-ask goes out), and then the detail lands, the `

    ` renders empty while +`detail.body` beside it shows. Without the other action the test passes. diff --git a/eval/results/desk13-probe-2026-11-13/reference.grade.txt b/eval/results/desk13-probe-2026-11-13/reference.grade.txt new file mode 100644 index 0000000..980f6dc --- /dev/null +++ b/eval/results/desk13-probe-2026-11-13/reference.grade.txt @@ -0,0 +1,6 @@ +== hidden +77/77 passed +== overlap +38/38 passed +== fuzz +ref-desk9: 0/20 seeds found a hole diff --git a/eval/results/desk13-probe-2026-11-13/repro/blank-heading.spec.tsx b/eval/results/desk13-probe-2026-11-13/repro/blank-heading.spec.tsx new file mode 100644 index 0000000..6f06a16 --- /dev/null +++ b/eval/results/desk13-probe-2026-11-13/repro/blank-heading.spec.tsx @@ -0,0 +1,46 @@ +/** + * @jsxImportSource @solidjs/web + * @vitest-environment jsdom + */ +import { test, expect } from "vitest"; +import { For, Loading, Show, action, createOptimistic, createMemo, createSignal, refresh, flush } from "solid-js"; +import { render } from "../src/index.js"; +const tick = async () => { for (let i = 0; i < 4; i++) { await new Promise(r => setTimeout(r, 0)); flush(); } }; +test("a heading that reads a guess shows it when its async source lands", async () => { + const asks: { key: string; res: (v: any) => void }[] = []; + const ask = (key: string) => new Promise(res => asks.push({ key, res })); + const pick = (key: string) => { const i = asks.findIndex(a => a.key === key); if (i < 0) throw new Error("no ask " + key + " in " + asks.map(a => a.key)); return asks.splice(i, 1)[0]; }; + const c = document.body.appendChild(document.createElement("div")); + const frames: string[] = []; const snap = (l: string) => frames.push(`${l.padEnd(18)} ${c.innerHTML} | in the air: ${asks.map(a => a.key)}`); + + const [selected, setSelected] = createSignal(false); + const [renamesT] = createSignal(() => []); + const [renames, guessRename] = createOptimistic(() => renamesT()); + + const list = createMemo(() => ask("list")); + const rows = createMemo(() => (list() as any[]).map(i => ({ id: i.id, title: renames().reduce((t, r) => r, i.title) }))); + const detail = createMemo(() => selected() ? ask("detail") : null); + const heading = createMemo(() => { const d: any = detail(); return d == null ? "" : renames().reduce((t, r) => r, d.title); }); + + const rename = action(function* (title: string) { guessRename(rs => [...rs, title]); yield ask("rename"); refresh(list); refresh(detail); }); + const other = action(function* () { yield ask("other"); refresh(list); }); + + render(() =>
    + loading

    }>
      {(it: any) =>
    • {it.title}
    • }
    + select

    }> + loading detail

    }>

    {heading()}

    {(detail() as any)?.body}

    +
    +
    , c); + + const steps: [string, () => void][] = [ + ["list lands", () => pick("list").res([{ id: 2, title: "Old bug" }])], + ["select", () => setSelected(true)], + ["other action", () => other()], + ["rename to Delta", () => rename("Delta")], + ["other lands", () => pick("other").res({})], // re-asks the list + ["detail lands", () => pick("detail").res({ title: "Old bug", body: "long gone" })], + ]; + for (const [n, f] of steps) { f(); await tick(); snap(n); } + console.log(frames.join("\n")); + expect(c.querySelector("h2")?.textContent).toBe("Delta"); +}); diff --git a/eval/results/desk13-probe-2026-11-13/repro/blank-rows.spec.tsx b/eval/results/desk13-probe-2026-11-13/repro/blank-rows.spec.tsx new file mode 100644 index 0000000..afc462b --- /dev/null +++ b/eval/results/desk13-probe-2026-11-13/repro/blank-rows.spec.tsx @@ -0,0 +1,55 @@ +/** + * @jsxImportSource @solidjs/web + * @vitest-environment jsdom + */ +import { test, expect } from "vitest"; +import { For, Loading, action, createOptimistic, createMemo, createSignal, createProjection, refresh, flush } from "solid-js"; +import { render } from "../src/index.js"; +const tick = async () => { for (let i = 0; i < 4; i++) { await new Promise(r => setTimeout(r, 0)); flush(); } }; +test("rows that land while guesses are out render their text", async () => { + const asks: { key: string; res: (v: any) => void; rej: (e: any) => void }[] = []; + const inAir = new Map>(); + const ask = (key: string) => new Promise((res, rej) => asks.push({ key, res, rej })); + const askQ = (key: string) => { let p = inAir.get(key); if (!p) { p = ask(key).finally(() => inAir.delete(key)); inAir.set(key, p); } return p; }; // a question asked again while in the air joins it + const pick = (key: string) => { const i = asks.findIndex(a => a.key === key); if (i < 0) throw new Error("no ask " + key + " in " + asks.map(a => a.key)); return asks.splice(i, 1)[0]; }; + const c = document.body.appendChild(document.createElement("div")); + const frames: string[] = []; const snap = (l: string) => frames.push(`${l.padEnd(22)} ${c.innerHTML} | in the air: ${asks.map(a => a.key)}`); + + const [filter, setFilter] = createSignal("closed"); + const server = createMemo(() => askQ(filter())); + const [assigningT] = createSignal<{ id: number; user: string }[]>(() => []); + const [assigning, guessAssigning] = createOptimistic(() => assigningT()); + const [closingT] = createSignal(() => []); + const [closing, guessClosing] = createOptimistic(() => closingT()); + const rowsSrc = createMemo(() => (server() as any[]).filter(i => !closing().includes(i.id)) + .map(i => ({ id: i.id, title: i.title, assignee: assigning().reduce((n, x) => x.id === i.id ? x.user : n, i.assignee) }))); + const rows = createProjection(() => rowsSrc() as any, [] as any, { key: "id" } as any); + const showOpen = action(function* () { setFilter("open"); }); + const assignTo = action(function* (id: number, user: string) { + try { guessAssigning(xs => [...xs, { id, user }]); yield ask(`assign:${user}`); refresh(server); } catch (e) {} + }); + const closeOne = action(function* (id: number) { + try { guessClosing(xs => [...xs, id]); yield ask(`close`); refresh(server); } catch (e) {} + }); + const list = () =>
      {(it: any) =>
    • {`#${it.id} ${it.title} (${it.assignee})`}
    • }
    ; + render(() => loading

    }>{list()}
    , c); + + const steps: [string, () => void][] = [ + ["mount", () => {}], + ["closed list lands", () => pick("closed").res([{ id: 2, title: "Old bug", assignee: "eve" }])], + ["assign #2 ann", () => assignTo(2, "ann")], + ["close #2", () => closeOne(2)], + ["assign #2 guest", () => assignTo(2, "guest")], + ["that assign lands", () => pick("assign:guest").res({})], // re-asks the closed list + ["close declined", () => pick("close").rej(new Error("declined"))], + ["assign #2 guest again", () => assignTo(2, "guest")], + ["assign ann lands", () => pick("assign:ann").res({})], // re-asks the closed list (joins the one in the air) + ["switch to open", () => showOpen()], + ["open list lands", () => pick("open").res([{ id: 1, title: "Login fails", assignee: null }, { id: 3, title: "Crash on save", assignee: null }])], + ["second assign lands", () => pick("assign:guest").res({})], // re-asks the open list + ["open list lands again", () => pick("open").res([{ id: 1, title: "Login fails", assignee: null }, { id: 3, title: "Crash on save", assignee: null }])], + ]; + for (const [n, f] of steps) { f(); await tick(); snap(n); } + console.log(frames.join("\n")); + expect(c.textContent).toBe("#1 Login fails (null)#3 Crash on save (null)"); +}); diff --git a/eval/results/desk13-probe-2026-11-13/rx-1.grade.txt b/eval/results/desk13-probe-2026-11-13/rx-1.grade.txt new file mode 100644 index 0000000..a78b2c3 --- /dev/null +++ b/eval/results/desk13-probe-2026-11-13/rx-1.grade.txt @@ -0,0 +1,12 @@ +== hidden +✗ base: tabs: the settings page loads again when reopened, with a fresh draft +76/77 passed +== overlap +✗ base: tabs: a save that answers after the page was left shows the reloaded settings +37/38 passed +== fuzz +desk13-rx-1: 2/20 seeds found a hole + seed 11 step 44: detail title: "#3 Bravofooter says 2019finding related helpretry assign to ", server "Delta" + tab help ; tab issues ; tab help ; land loadSettings: ; tab issues ; filter closed ; tab help ; land fetchIssues:open ; land fetchIssues:closed ; tab issues ; tab help ; tab issues ; focus ; land fetchIssues:open ; select #4 ; rename Alpha ; rename Bravo ; post me too ; post fixed? ; land fetchIssue:4 ; row-assign 0 ; row-close 0 ; select #3 ; focus ; close ; land fetchComments:4 ; row-close 0 + seed 15 step 33: detail title: "#2 long gonefinding related helpretry assign to ann close re", server "Old bug" + land fetchIssues:open ; row-assign 1 ; row-assign 0 ; land assign:3,guest ; land fetchIssues:open ; land loadSettings: ; filter closed ; land assign:1,guest ; land fetchIssues:closed ; user ann ; row-close 0 ; row-close 0 ; row-close 0 ; land closeIssue:2 ; row-close 0 ; select #2 ; land fetchIssues:closed ; assign ; row-close 0 ; land fetchComments:2 ; land closeIssue:2 ; assign ; land fetchI diff --git a/eval/results/desk13-probe-2026-11-13/rx-2.grade.txt b/eval/results/desk13-probe-2026-11-13/rx-2.grade.txt new file mode 100644 index 0000000..dc7efdb --- /dev/null +++ b/eval/results/desk13-probe-2026-11-13/rx-2.grade.txt @@ -0,0 +1,8 @@ +== hidden +✗ base: tabs: the settings page loads again when reopened, with a fresh draft +76/77 passed +== overlap +✗ base: tabs: a save that answers after the page was left shows the reloaded settings +37/38 passed +== fuzz +desk13-rx-2: 0/20 seeds found a hole diff --git a/eval/results/desk13-probe-2026-11-13/rx-3.grade.txt b/eval/results/desk13-probe-2026-11-13/rx-3.grade.txt new file mode 100644 index 0000000..e1aabc5 --- /dev/null +++ b/eval/results/desk13-probe-2026-11-13/rx-3.grade.txt @@ -0,0 +1,14 @@ +== hidden +✗ base: tabs: the settings page loads again when reopened, with a fresh draft +76/77 passed +== overlap +✗ base: tabs: a save that answers after the page was left shows the reloaded settings +37/38 passed +== fuzz +desk13-rx-3: 3/20 seeds found a hole + seed 11 step -1: page crash: session timed out (120 s): the page hangs + + seed 15 step 33: detail title: "#2 long gonefinding related helpretryassign to ann renameclo", server "Old bug" + land fetchIssues:open ; row-assign 1 ; row-assign 0 ; land assign:3,guest ; land fetchIssues:open ; land loadSettings: ; filter closed ; land assign:1,guest ; land fetchIssues:closed ; user ann ; row-close 0 ; row-close 0 ; row-close 0 ; land closeIssue:2 ; row-close 0 ; select #2 ; land fetchIssues:closed ; assign ; row-close 0 ; land fetchComments:2 ; land closeIssue:2 ; assign ; land fetchI + seed 19 step -1: page crash: session timed out (120 s): the page hangs + diff --git a/eval/results/desk13-probe-2026-11-13/rx-4.grade.txt b/eval/results/desk13-probe-2026-11-13/rx-4.grade.txt new file mode 100644 index 0000000..0127383 --- /dev/null +++ b/eval/results/desk13-probe-2026-11-13/rx-4.grade.txt @@ -0,0 +1,9 @@ +== hidden +77/77 passed +== overlap +✗ base: tabs: a save that answers after the page was left shows the reloaded settings +37/38 passed +== fuzz +desk13-rx-4: 1/20 seeds found a hole + seed 13 step 7: count: tab Issues (3), 0 open rows shown + filter closed ; fail fetchIssues:open ; land fetchIssues:closed ; row-assign 0 ; filter open ; land assign:2,guest ; land fetchIssues:open diff --git a/eval/results/desk13-probe-2026-11-13/rx-5.grade.txt b/eval/results/desk13-probe-2026-11-13/rx-5.grade.txt new file mode 100644 index 0000000..5cc165a --- /dev/null +++ b/eval/results/desk13-probe-2026-11-13/rx-5.grade.txt @@ -0,0 +1,9 @@ +== hidden +77/77 passed +== overlap +✗ base: tabs: a save that answers after the page was left shows the reloaded settings +37/38 passed +== fuzz +desk13-rx-5: 1/20 seeds found a hole + seed 17 step 8: count: tab Issues (2), 1 open rows shown + user bob ; land fetchIssues:open ; row-assign 1 ; land assign:3,bob ; land fetchIssues:open ; row-close 2 ; land closeIssue:4 ; row-close 1 diff --git a/eval/results/desk13-probe-2026-11-13/rx-6.grade.txt b/eval/results/desk13-probe-2026-11-13/rx-6.grade.txt new file mode 100644 index 0000000..76aea22 --- /dev/null +++ b/eval/results/desk13-probe-2026-11-13/rx-6.grade.txt @@ -0,0 +1,10 @@ +== hidden +77/77 passed +== overlap +✗ base: tabs: a save that answers after the page was left shows the reloaded settings +✗ rows: a row action that answers after the issues page was left changes nothing on its return +36/38 passed +== fuzz +desk13-rx-6: 1/20 seeds found a hole + seed 16 step 34: count: tab Issues (3), 0 open rows shown + user guest ; fail fetchIssues:open ; focus ; tab help ; tab issues ; filter closed ; land fetchIssues:open ; focus ; land fetchIssues:closed ; select #2 ; land fetchComments:2 ; close ; user ann ; fail closeIssue:2 ; row-assign 0 ; close ; user bob ; user guest ; assign ; land assign:2,guest ; land fetchLabels:2 ; fail closeIssue:2 ; assign ; land assign:2,ann ; focus ; land fetchIssue:2 ; lan diff --git a/eval/results/desk13-probe-2026-11-13/rx-7.grade.txt b/eval/results/desk13-probe-2026-11-13/rx-7.grade.txt new file mode 100644 index 0000000..e7ef0e8 --- /dev/null +++ b/eval/results/desk13-probe-2026-11-13/rx-7.grade.txt @@ -0,0 +1,8 @@ +== hidden +✗ base: help: selecting after a failure shows loading…, not the old failure +✗ base: help: selecting after not found shows loading…, not the old message +75/77 passed +== overlap +38/38 passed +== fuzz +desk13-rx-7: 0/20 seeds found a hole diff --git a/eval/results/desk13-probe-2026-11-13/rx-8.grade.txt b/eval/results/desk13-probe-2026-11-13/rx-8.grade.txt new file mode 100644 index 0000000..443aee1 --- /dev/null +++ b/eval/results/desk13-probe-2026-11-13/rx-8.grade.txt @@ -0,0 +1,8 @@ +== hidden +77/77 passed +== overlap +38/38 passed +== fuzz +desk13-rx-8: 1/20 seeds found a hole + seed 16 step 34: count: tab Issues (3), 0 open rows shown + user guest ; fail fetchIssues:open ; focus ; tab help ; tab issues ; filter closed ; land fetchIssues:open ; focus ; land fetchIssues:closed ; select #2 ; land fetchComments:2 ; close ; user ann ; fail closeIssue:2 ; row-assign 0 ; close ; user bob ; user guest ; assign ; land assign:2,guest ; land fetchLabels:2 ; fail closeIssue:2 ; assign ; land assign:2,ann ; focus ; land fetchIssue:2 ; lan diff --git a/eval/results/desk13-probe-2026-11-13/rx-9.grade.txt b/eval/results/desk13-probe-2026-11-13/rx-9.grade.txt new file mode 100644 index 0000000..40d2a37 --- /dev/null +++ b/eval/results/desk13-probe-2026-11-13/rx-9.grade.txt @@ -0,0 +1,6 @@ +== hidden +77/77 passed +== overlap +38/38 passed +== fuzz +desk13-rx-9: 0/20 seeds found a hole diff --git a/guide/async.md b/guide/async.md index e04289a..4105212 100644 --- a/guide/async.md +++ b/guide/async.md @@ -200,17 +200,29 @@ export action rename(id: string, name: string) { ### Data belongs with its lifetime In .rx, state is not tied to components: a `source` can be declared in a module, a component or a region, and a -component only reads and calls what it is given. So declare data where it lives as long as it is needed. A page's list usually belongs to the page: it loads when the page -opens and goes when it closes. A write in flight lives until the server answers, which can be after its page closed; -the data it refreshes must live at least as long. If it lives in the page, the write's `refresh` re-asks the closed -page's question, and the page opened again keeps the answer it asked for before the write landed, beside views asked -after it that show the write. - -So for the data such a write changes (the list and the item it refreshes), declare it, its writes in flight and its -actions in a module (top-level `source`s and `action`s), and let the page read it and call the actions. The page's own -inputs (a draft, a text field) live as long as the page and stay in it. To make the page start fresh when it opens, -have opening it call an action that resets the module's inputs and moves a counter the question reads -(`source visit := 0`, `items = visit >= 0 ? await loadItems(filter) : []`). +component only reads and calls what it is given. So give each value one lifetime, and declare it where that lifetime +is. There are three: + +- **The visit.** What a page asks for and its inputs: asked when the page opens, gone when it closes. +- **The write.** A write lives until the server answers, which can be after its page closed. Its guess lives exactly + as long, and so does whatever it re-asks. +- **The screen.** What copies what is shown lives where it is shown, inside the region that shows it. + +When one value would need two lifetimes, split it into two values. Each case below follows from that: + +- **A list a write refreshes** is needed by the visit and by the write, so it outlives both: declare it, the writes + and their actions in a module (top-level `source`s and `action`s), and let the page read it. The visit becomes a + counter its question reads, moved by opening the page (`source visit := -1`, + `items = visit >= 0 ? await loadItems(filter) : []`). A module's question is asked when the app starts, so the + counter starts below the guard and the first open asks; start it at `0` only for the page the app opens on. +- **A write whose answer is the new value** (a save that returns the saved settings) re-asks nothing, so its data + needs only the visit and stays in the page. +- **A guess** never goes on the module's question: that would give the question the write's lifetime, and the next + visit's load, with the page switch that asks it, would wait for the write. Guesses go in lists of their own, laid + over the answer (the next section). +- **A count in a tab** copies the rows on screen, so it is an `effect` in a component inside the list's region + (`` under the list's `loading`), not beside the module: outside the region it waits + for every re-ask of the list and misses the guesses the rows already show. ### Data the server owns: three layers