commit 4ba1739f51a97b26583712cd414c17129b9e9783 parent e3aeac9a7d10aacf5376ce9e8bbc5648565c4fe1 Author: James Eversole <james@eversole.co> Date: Tue, 13 Oct 2026 14:05:37 -0500 Publish showcase packages and parser demo with clearer playground states Diffstat:
28 files changed, 898 insertions(+), 56 deletions(-)
diff --git a/ext/demo-site/README.md b/ext/demo-site/README.md @@ -62,6 +62,62 @@ adding library pins/assets to this site's existing release/seed pipeline. No library compilation or source dependency resolution happens during site build, seeding, or browser package selection. +### Showcase libraries and parser demo + +The Published packages group also contains these `0.1.0` interfaces: + +- **arboricx-server**: the registry's routes, validation and server function, + packaged as closed trees. Serving needs native sockets/filesystem permissions; + this is a library interface, not a standalone browser IO program. +- **tricu-http**: pure request parsing/response construction plus native socket + handlers. Browser execution does not grant socket access. +- **tricu-intensional-contracts**: structural tree checks and numeric/list + contracts, including powers of two and sortedness. +- **tricu-graphs**: earliest-ready dependency ordering and cycle witnesses for + small caller-validated graphs; not an arbitrary malformed-graph validator. +- **tricu-parser**: the finite-stream combinator protocol, with diagnostics, + consumed/committed flags, repetition, alternatives and rollback. +- **tricu-formatter**: `formatTri`, a conservative equation-head aligner returning + a Result. It is not an AST formatter. Native `tricu format` handles its Result + protocol; applying the bare export does not return a plain string. + +**Parser combinators** under Examples is the separate `parser-demo` package. +Try `"red,green,blue"`, `"red,,blue"`, `"red,"` or `"red green"`. `parseNames` +requires complete input; `parsePrefix` exposes any unconsumed suffix. A comma +commits to another lowercase name. Scalar records show the expected label, +remaining input, consumption and commitment, or the accepted names/count. +This demo has authenticated reference source, but no browser compilation +profile: its native module imports make it **read-only**, not an editable draft. + +To reproduce and stage this cohort locally, first build the Haskell host through +Nix, then run the opt-in bounded preparation: + +```sh +nix build .# +python3 ext/demo-site/prepare-showcase.py result/bin/tricu \ + --directory /tmp/arboricx-showcase --stage-site +``` + +Preparation authenticates retained fixtures and requires all recorded working +sources to match them. Five library interfaces retain exact constructor/child-ID +roots from those accepted artifacts, renamed without evaluation or expansion. +The formatter and parser demo compile with the authenticated shipped stable +compiler and pinned native dependency interfaces. Real native formatter cases +and independently specified complete parser records must pass before staging; +publication and a clean indexed import must agree on every manifest. Each bundle +also must fit the browser's 1 MiB/100,000-node input bounds. Native commands use +2 GiB RTS bounds, per-command deadlines and a ten-minute whole-job deadline. + +The output directory retains commands, source/artifact identities and acceptance +results. `--reuse-directory` permits reuse of its local CAS, while rewriting +sources and rechecking native receipts; it does not trust an old success flag. +Staging is an exclusive local administrator operation, not public-network +publication. Existing release pins cannot be replaced, all assets precede +index discovery, and conflicts fail before staging. Native-prepared catalogue +entries have `preparation: "native"`; `prepare.py` preserves them and +`prepare-index.py` does not assign them a browser compilation environment. +Existing immutable versions and source-only recovery controls are unchanged. + ### Published packages and local drafts The package selector reads `packages.json`, a versioned static index of exact @@ -96,7 +152,13 @@ disclosure instead of dominating the result area. Pure programs do not show an empty IO terminal. Curated input labels describe the published argument order; arbitrary bundles and local drafts retain generic positional labels. -All selected programs open source by default; reading comes before interaction. Published source is read-only until **Edit source** is +All selected programs open their source/details section by default; reading comes before interaction. +Editable demos use **Source and editing / Read and edit source**. Compiled-only +packages instead use **Package details / Compiled package (no editable source)** +and explain that running/exporting remains available but editable source is not +included. Reference source without a supported compiler environment is labelled +**Source (read-only) / Read-only source**, with editing and local compilation +explicitly unavailable. Published source is read-only until **Edit source** is clicked. Opening the editor alone keeps **Run published**: the authenticated bundle is executed, not recompiled. Changing text switches to **Local draft**, invalidates any compiled heap/result, and disables **Run draft** until an explicit @@ -237,7 +299,7 @@ DAG output has a 128 MiB UTF-8 limit. Both traversal and writing are iterative, including deeply nested trees. Stop, reset, replacement, and pane changes retain the existing Worker termination boundary; export allows 120 active seconds. Writers flush bounded text-fragment batches so expanded output does not retain -one array entry per logical node. Loading retains its independent 10-second deadline. +one array entry per logical node. Loading retains its independent 60-second deadline. These output limits remain in force with optional execution limits off. ## Compile source @@ -343,12 +405,21 @@ metadata never set execution budgets. Optional compiler budget defaults: 6m live nodes, no work cutoff, 100k frames, time unlimited. Package defaults: 250k live nodes, 20m work, 50k frames, 10 active seconds. Each compile/run gets its own active deadline; input/step/idle pauses exclude time. Long deadlines -use chunks that fit browser timers. Packages retain an independent 10-second +use chunks that fit browser timers. Packages retain an independent 60-second loading deadline in all profiles. All package profiles enable tracing GC and exclusive owned IO roots; thresholds adapt to small node budgets. Resource-setting changes discard/reload the heap while preserving source and selected inputs. Download/schema/literal/IO/output/handle guards remain. Stop and leaving the pane end running jobs; browser memory restrictions apply. +Loading has a separate 60-second deadline covering Worker startup, transfers, +verification and heap construction, even when optional execution limits are off. +Timeouts identify package loading, draft compilation, program execution, +definition export or bundle download; failures retain their underlying diagnostic +with the operation named. Catalogue-loading failures are also distinguished. +Manual Stop and pane-change cancellation remain cancellation messages rather than +being reported as operation failures. The Playground's **Export this definition** +link follows Interaction trace at the bottom of Try this program. + ## Ordinary package resource and safety boundaries Download and format checks remain enabled when optional execution limits are off. diff --git a/ext/demo-site/bundles/086aabb4b95608428f0cb0776cd408102f6b1ff44aca3f9324c9e3dd9fbebd02.arboricx b/ext/demo-site/bundles/086aabb4b95608428f0cb0776cd408102f6b1ff44aca3f9324c9e3dd9fbebd02.arboricx Binary files differ. diff --git a/ext/demo-site/bundles/7254d123eb4acb98ae4d87f4f3e48b87c2bb7371237b7710ec6bd23c82b9ceed.arboricx b/ext/demo-site/bundles/7254d123eb4acb98ae4d87f4f3e48b87c2bb7371237b7710ec6bd23c82b9ceed.arboricx Binary files differ. diff --git a/ext/demo-site/bundles/a6131b64f1d6eb3765d64da89e238b9c82adf90f47eb777ff4b4bd12e1d955cb.arboricx b/ext/demo-site/bundles/a6131b64f1d6eb3765d64da89e238b9c82adf90f47eb777ff4b4bd12e1d955cb.arboricx Binary files differ. diff --git a/ext/demo-site/bundles/b8e1e83722eb6432dc354506e393129ec2f4742cc1e70695709d0a99fd7ba75b.arboricx b/ext/demo-site/bundles/b8e1e83722eb6432dc354506e393129ec2f4742cc1e70695709d0a99fd7ba75b.arboricx Binary files differ. diff --git a/ext/demo-site/bundles/c5b6b25cbe8d0d0515b483a39a2cb500761d64b05cb801a1249d25be3b8a3bbb.arboricx b/ext/demo-site/bundles/c5b6b25cbe8d0d0515b483a39a2cb500761d64b05cb801a1249d25be3b8a3bbb.arboricx Binary files differ. diff --git a/ext/demo-site/bundles/c7560f717712244810ed3b354912fe8791c2bab5c1df1520813426be01910c2f.arboricx b/ext/demo-site/bundles/c7560f717712244810ed3b354912fe8791c2bab5c1df1520813426be01910c2f.arboricx Binary files differ. diff --git a/ext/demo-site/bundles/ff2bb1bfb5a425eae5db7ecdf2cbaa18f5fe8b164cedc58c529944d532207a2c.arboricx b/ext/demo-site/bundles/ff2bb1bfb5a425eae5db7ecdf2cbaa18f5fe8b164cedc58c529944d532207a2c.arboricx Binary files differ. diff --git a/ext/demo-site/catalogue.json b/ext/demo-site/catalogue.json @@ -414,5 +414,44 @@ } }, "environment": "practical" + }, + { + "id": "parser", + "title": "Parser combinators", + "hash": "c7560f717712244810ed3b354912fe8791c2bab5c1df1520813426be01910c2f", + "source": "!import prelude *\n!import parser *\n!import native_math as N\n!export parseNames\n!export parsePrefix\n\n-- Lowercase names separated by commas. Try \"red,green\", \"red,,blue\",\n-- \"red,\" and \"red green\". A comma commits to another name.\n-- parseNames requires the entire input; parsePrefix exposes any remainder.\n-- These native imports are pinned at preparation time. Source is read-only\n-- here because browser draft compilation does not resolve native modules.\nnameParser = parserLabel \"lowercase name\" (parserSome (parserSatisfy (c : N.inRange c 97 122)))\ncommaParser = parserThen (parserExpect 44) parserCommit\nnamesPrefix = parserSepBy1 nameParser commaParser\nnamesComplete = parserBefore namesPrefix (parserLabel \"end of input\" parserEof)\n\nreport reply = (parserMatchReply\n (diagnostic rest consumed committed : [\n [\"status\" 0 \"error\"]\n [\"expected\" 0 (parserDiagnosticExpected diagnostic)]\n [\"remaining\" 0 rest]\n [\"consumed\" 2 consumed]\n [\"committed\" 2 committed]])\n (names rest consumed committed : [\n [\"status\" 0 \"ok\"]\n [\"names\" 0 (intercalate \", \" names)]\n [\"count\" 1 (length names)]\n [\"remaining\" 0 rest]\n [\"consumed\" 2 consumed]\n [\"committed\" 2 committed]])\n reply)\nparseNames text = report (namesComplete text)\nparsePrefix text = report (namesPrefix text)\n", + "sourceSha256": "3405e18774db8de759b7793880e1568c156d68e5fe4a388e09b95ff301d896f5", + "export": "parseNames", + "arguments": [ + { + "type": "string", + "value": "red,green,blue" + } + ], + "output": "record", + "mode": "pure", + "presets": { + "parseNames": { + "arguments": [ + { + "type": "string", + "value": "red,green,blue" + } + ], + "output": "record", + "mode": "pure" + }, + "parsePrefix": { + "arguments": [ + { + "type": "string", + "value": "red,green,blue" + } + ], + "output": "record", + "mode": "pure" + } + }, + "preparation": "native" } ] diff --git a/ext/demo-site/demos/parser.tri b/ext/demo-site/demos/parser.tri @@ -0,0 +1,33 @@ +!import prelude * +!import parser * +!import native_math as N +!export parseNames +!export parsePrefix + +-- Lowercase names separated by commas. Try "red,green", "red,,blue", +-- "red," and "red green". A comma commits to another name. +-- parseNames requires the entire input; parsePrefix exposes any remainder. +-- These native imports are pinned at preparation time. Source is read-only +-- here because browser draft compilation does not resolve native modules. +nameParser = parserLabel "lowercase name" (parserSome (parserSatisfy (c : N.inRange c 97 122))) +commaParser = parserThen (parserExpect 44) parserCommit +namesPrefix = parserSepBy1 nameParser commaParser +namesComplete = parserBefore namesPrefix (parserLabel "end of input" parserEof) + +report reply = (parserMatchReply + (diagnostic rest consumed committed : [ + ["status" 0 "error"] + ["expected" 0 (parserDiagnosticExpected diagnostic)] + ["remaining" 0 rest] + ["consumed" 2 consumed] + ["committed" 2 committed]]) + (names rest consumed committed : [ + ["status" 0 "ok"] + ["names" 0 (intercalate ", " names)] + ["count" 1 (length names)] + ["remaining" 0 rest] + ["consumed" 2 consumed] + ["committed" 2 committed]]) + reply) +parseNames text = report (namesComplete text) +parsePrefix text = report (namesPrefix text) diff --git a/ext/demo-site/dist/app.js b/ext/demo-site/dist/app.js @@ -5,7 +5,7 @@ import { activePane } from './tabs.js'; import { DRAFT_KEY, readDraft, writeDraft } from './draft-storage.js'; import { readSettings } from './limits-ui.js'; import { ActiveClock } from './clock.js'; -import { PACKAGE_ACTIVE_MS, EXPORT_ACTIVE_MS, activeMilliseconds } from './execution.js'; +import { PACKAGE_ACTIVE_MS, EXPORT_ACTIVE_MS, activeMilliseconds, operationFailure, operationTimeout } from './execution.js'; import { sourceText } from './compiler.js'; import { packageIndex } from './packages.js'; import { bundleHash, fetchBytes } from './bundle.js'; @@ -20,6 +20,7 @@ function el(id) { const sourceEditor = el('source-editor'), compileButton = el('compile'); const restoreButton = el('restore-source'), downloadButton = el('download-bundle'); let editingSource = false; +let sourceAvailable = false; let draft = false, scratch = false, compiled = false, busy = false, originalSource; let compilationStats = '', preferredName = 'id'; let compilerTimer; @@ -34,7 +35,8 @@ const argumentsUI = argumentEditor(argsInput); const mode = el('mode'), steps = el('step-mode'); const runButton = el('run'), stopButton = el('stop'), nextButton = el('next'); let settings = readSettings('package'); -const deadline = new ActiveClock(() => finish('Stopped: active execution time limit exceeded.', true)); +let operation; +const deadline = new ActiveClock(() => finish(operationTimeout(operation ?? 'loading'), true, 'timeout')); const shareButton = el('share'); const treeExport = createTreeExport(message => { if (draft || !loaded || !selection) @@ -46,7 +48,7 @@ const treeExport = createTreeExport(message => { }, name => { exportsInput.value = name; exportsInput.dispatchEvent(new Event('change')); -}, () => finish('Stopped. Export session discarded.', true)); +}, () => finish('Stopped. Export session discarded.', true, 'cancelled')); const clearTreeExport = treeExport.clear; const inputForm = el('input-form'), lineInput = el('line-input'); let entries = [], selected; @@ -59,7 +61,7 @@ let downloadAfterLoad = false; const traceRows = new Map(); function pauseClock() { deadline.pause(); } function clock() { deadline.start(); } -function runClock() { deadline.reset(activeMilliseconds(settings)); clock(); } +function runClock() { operation = 'running'; deadline.reset(activeMilliseconds(settings)); clock(); } function controls(working) { busy = working; programPicker.disabled = busy || entries.length === 0; @@ -83,6 +85,13 @@ function controls(working) { sourceEditor.readOnly = !editingSource && !draft; el('edit-source').disabled = busy; el('edit-source').hidden = originalSource === undefined || editingSource || draft; + const editableSource = !sourceEditor.hidden && (draft || originalSource !== undefined); + el('source-summary').textContent = editableSource ? 'Source and editing' : sourceAvailable ? 'Source (read-only)' : 'Package details'; + el('source-title').textContent = editableSource ? 'Read and edit source' : sourceAvailable ? 'Read-only source' : loaded ? 'Compiled package (no editable source)' : 'Package information'; + el('source-availability').hidden = editableSource || !loaded; + el('source-availability').textContent = sourceAvailable + ? 'Source is shown for reference only. This package does not support editing or local compilation.' + : 'This is a compiled package. Editable source is not included; you can run or export its stored definitions.'; restoreButton.disabled = busy || originalSource === undefined || !draft; downloadButton.disabled = busy || draft || !loaded; el('authorship').textContent = draft ? 'Local draft · your authored code (not published)' : loaded ? 'Published package · verified executable' : 'Published selection · not yet verified'; @@ -111,6 +120,7 @@ function controls(working) { function dispose() { stopCompilerActivity(); pauseClock(); + operation = undefined; worker?.terminate(); worker = undefined; compiled = false; @@ -131,11 +141,13 @@ function clearOutput() { el('trace-empty').hidden = false; lineInput.value = ''; } -function finish(message, error = false) { +function finish(message, error = false, reason = 'failure') { if (error && /Graph allocation limit|evaluation stack limit|Graph stack limit/u.test(message)) message = `Optional memory or evaluation-depth limit reached. Adjust limits under Advanced to continue. Your source is unchanged. ${message}`; else if (error && /Graph fuel limit/u.test(message)) message = `Optional work limit reached. Adjust limits under Advanced to continue. Your source is unchanged. ${message}`; + if (error && reason === 'failure') + message = operationFailure(operation, message); if (!draft && !loaded) el('package-info').textContent = `Package not loaded. ${message}`; if (treeExport.pending || (activePane() === 'export-definition' && !loaded)) @@ -156,6 +168,7 @@ function setPreset(entry, name = entry.export) { mode.value = preset.mode; } function startExport(message, send) { + operation = 'exporting'; treeExport.begin(); controls(true); deadline.reset(EXPORT_ACTIVE_MS); @@ -167,6 +180,7 @@ function newWorker(runAfterLoad, preserveInputs = false, exportAfterLoad) { if (!selection) return; dispose(); + operation = 'loading'; settings = readSettings('package'); deadline.reset(PACKAGE_ACTIVE_MS); automaticRun = runAfterLoad; @@ -186,7 +200,7 @@ function newWorker(runAfterLoad, preserveInputs = false, exportAfterLoad) { if (data.state === 'running') { pending = undefined; clock(); - el('status').textContent = 'Running'; + el('status').textContent = operation === 'loading' ? 'Loading package' : 'Running'; } else { pauseClock(); @@ -202,6 +216,7 @@ function newWorker(runAfterLoad, preserveInputs = false, exportAfterLoad) { if (data.hash !== selection?.hash || draft) break; pauseClock(); + operation = undefined; controls(false); el('status').textContent = 'Ready'; const url = URL.createObjectURL(new Blob([new Uint8Array(data.bytes)], { type: 'application/vnd.arboricx.bundle' })); @@ -237,10 +252,15 @@ function newWorker(runAfterLoad, preserveInputs = false, exportAfterLoad) { sourceEditor.hidden = originalSource === undefined; el('source-notes').hidden = originalSource !== undefined; } + sourceAvailable = data.source !== null; el('source-label').textContent = data.source === null ? 'Package metadata' : 'Curated tricu / stable'; el('source').textContent = data.source ?? `Verified package metadata\n${JSON.stringify(data.metadata, null, 2)}\n\nChoose an export, its arguments and a result interpretation to apply this tree.`; if (downloadAfterLoad) { downloadAfterLoad = false; + pauseClock(); + operation = 'downloading'; + deadline.reset(PACKAGE_ACTIVE_MS); + clock(); send({ type: 'download' }); } else if (exportAfterLoad) { @@ -257,6 +277,7 @@ function newWorker(runAfterLoad, preserveInputs = false, exportAfterLoad) { if (!preserveInputs && selected?.presets?.[exportsInput.value]) setPreset(selected, exportsInput.value); pauseClock(); + operation = undefined; controls(false); el('status').textContent = 'Ready'; } @@ -266,13 +287,15 @@ function newWorker(runAfterLoad, preserveInputs = false, exportAfterLoad) { pauseClock(); if (!treeExport.receive(data, selection?.hash, exportsInput.value)) break; + operation = undefined; controls(false); el('status').textContent = 'Ready'; break; } case 'export-error': pauseClock(); - treeExport.fail(data.message); + treeExport.fail(operationFailure('exporting', data.message)); + operation = undefined; controls(false); el('status').textContent = 'Ready'; break; @@ -329,6 +352,7 @@ function newWorker(runAfterLoad, preserveInputs = false, exportAfterLoad) { } function select(entry, release) { editingSource = false; + sourceAvailable = false; draft = false; scratch = false; originalSource = undefined; @@ -353,6 +377,7 @@ function select(entry, release) { } function invalidateProgram() { editingSource = false; + sourceAvailable = false; draft = false; scratch = false; originalSource = undefined; @@ -393,6 +418,7 @@ function loadBundle(release) { newWorker(false); } catch (error) { + operation = 'loading'; finish(error instanceof Error ? error.message : String(error), true); } } @@ -407,6 +433,7 @@ runButton.onclick = () => { } if (!loaded || !selection) return; + operation = 'running'; clearOutput(); try { const args = argumentsUI.read(), output = outputType(resultCodec.value); @@ -452,7 +479,7 @@ inputForm.onsubmit = event => { }; nextButton.onclick = () => { if (pending?.kind === 'step') resume(); }; -stopButton.onclick = () => finish('Stopped. Session discarded.', true); +stopButton.onclick = () => finish('Stopped. Session discarded.', true, 'cancelled'); el('stop-compile').onclick = stopButton.onclick; el('try-program-link').onclick = () => { el('try-program').open = true; }; el('reset').onclick = () => { dispose(); clearOutput(); el('status').textContent = draft ? 'Ready to compile' : loaded ? 'Ready' : 'Load package'; }; @@ -569,6 +596,7 @@ downloadButton.onclick = () => { if (draft || !loaded || !selection) return; if (worker) { + operation = 'downloading'; controls(true); deadline.reset(PACKAGE_ACTIVE_MS); clock(); @@ -725,6 +753,7 @@ function compileDraft() { return; preferredName = exportsInput.value || preferredName; dispose(); + operation = 'compiling'; clearOutput(); clearTreeExport(); exportsInput.replaceChildren(); @@ -750,6 +779,7 @@ function compileDraft() { case 'compiled': stopCompilerActivity(); pauseClock(); + operation = undefined; compiled = true; for (const name of data.names) { const option = document.createElement('option'); @@ -790,6 +820,7 @@ function compileDraft() { break; case 'done': pauseClock(); + operation = undefined; pending = undefined; inputForm.hidden = true; nextButton.disabled = true; @@ -824,6 +855,7 @@ function compileDraft() { function runDraft() { if (!compiled || !worker) return; + operation = 'running'; try { const args = argumentsUI.read(), output = outputType(resultCodec.value); clearOutput(); @@ -833,8 +865,9 @@ function runDraft() { worker.postMessage({ type: 'run', export: exportsInput.value, arguments: args, output, mode: mode.value, step: steps.checked }); } catch (error) { - el('result').textContent = error instanceof Error ? error.message : String(error); + el('result').textContent = operationFailure(operation, error instanceof Error ? error.message : String(error)); el('result').className = 'error'; + operation = undefined; } } const examples = { @@ -887,6 +920,7 @@ function loadFragment() { draft = false; scratch = false; originalSource = undefined; + sourceAvailable = false; dispose(); clearOutput(); clearTreeExport(); @@ -920,6 +954,7 @@ async function initializePackages() { if (initialized) return; initialized = true; + operation = 'catalogue'; try { if (!globalThis.Worker || !globalThis.crypto?.subtle) throw new Error('Playground needs Workers and a secure context (HTTPS or localhost).'); @@ -947,8 +982,10 @@ async function initializePackages() { releases = packageIndex(JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(indexBytes))); populatePrograms(); recoveryButtons(); - if (!packagePane() || selection || draft) + if (!packagePane() || selection || draft) { + operation = undefined; return; + } if (!loadFragment()) { const initial = entries.find(e => e.id === 'intro') ?? entries[0]; if (!initial) @@ -967,7 +1004,7 @@ window.addEventListener('pane-change', () => { // completed exports. Move the single package chooser rather than mirror inputs. if (worker) { if (busy) - finish('Stopped. Start a new operation in the selected tab.', true); + finish('Stopped. Start a new operation in the selected tab.', true, 'cancelled'); else if (!draft) { dispose(); el('status').textContent = loaded ? 'Ready' : 'Load package'; diff --git a/ext/demo-site/dist/demo-inputs.js b/ext/demo-site/dist/demo-inputs.js @@ -8,6 +8,7 @@ export function demoInputLabels(id, definition) { case 'shipping': return ['Destination', 'Cart subtotal (cents)', 'Package weight (grams)']; case 'events': return definition === 'vendorB' ? ['Event [customer refunded totalCents]'] : ['Event [eventName customer totalCents]']; case 'registration': return ['Name', 'Email', 'Age', 'Terms accepted (t = false, (t t) = true)']; + case 'parser': return ['Comma-separated lowercase names']; case 'migration': return ['Profile [name email]']; default: return []; } diff --git a/ext/demo-site/dist/execution.js b/ext/demo-site/dist/execution.js @@ -1,6 +1,6 @@ import { DEFAULT_BUDGET } from './graph.js'; import { COMPILER_BUDGET } from './compiler.js'; -export const PACKAGE_ACTIVE_MS = 10_000; +export const PACKAGE_ACTIVE_MS = 60_000; export const EXPORT_ACTIVE_MS = 120_000; export const EXECUTION_COLLECTION = Object.freeze({ threshold: 250_000 }); export const PACKAGE_COLLECTION = Object.freeze({ threshold: 100_000 }); @@ -34,3 +34,16 @@ export function activeMilliseconds(settings) { export function collectionPolicy(budget, threshold = EXECUTION_COLLECTION.threshold) { return budget.nodes < 2 ? undefined : { threshold: Math.min(threshold, budget.nodes) }; } +const operationLabels = { + catalogue: 'Program catalogue loading', loading: 'Package loading', + compiling: 'Draft compilation', running: 'Program execution', + exporting: 'Definition export', downloading: 'Bundle download', +}; +export function operationFailure(operation, message) { + return operation ? `${operationLabels[operation]} failed: ${message}` : message; +} +export function operationTimeout(operation) { + return operation === 'loading' || operation === 'downloading' || operation === 'catalogue' + ? `${operationLabels[operation]} timed out. The deadline includes transfer and processing time; try again.` + : `${operationLabels[operation]} stopped: active time limit exceeded.`; +} diff --git a/ext/demo-site/index.html b/ext/demo-site/index.html @@ -39,8 +39,9 @@ <section class="bundle-details" aria-label="Bundle details"><p id="release-info" class="hint">No named release selected.</p><p id="package-info" class="hint">Loading program catalogue…</p></section> </div></div> <div class="program-workspace"> - <details id="source-section" class="source-disclosure" open><summary>Source and editing</summary> - <section class="source-panel compiler-source" aria-labelledby="source-title"><div class="panel-heading"><h3 id="source-title">Read and edit source</h3><span id="source-label">tricu programs</span><button id="edit-source" type="button" disabled>Edit source</button></div> + <details id="source-section" class="source-disclosure" open><summary id="source-summary">Package details</summary> + <section class="source-panel compiler-source" aria-labelledby="source-title"><div class="panel-heading"><h3 id="source-title">Package information</h3><span id="source-label">tricu programs</span><button id="edit-source" type="button" disabled>Edit source</button></div> + <p id="source-availability" class="hint" hidden></p> <p id="compiler-disclosure" class="hint" hidden>Compilation uses a JavaScript Tree Calculus reducer and can take time or extra memory. Use the <a href="https://git.eversole.co/James/tricu/file/docs/build-and-run.md.html">native Haskell tools</a> for larger builds. Set optional limits under Advanced; Stop cancels compilation and keeps your source.</p> <details class="draft-recovery"><summary>Draft recovery</summary><p id="draft-save-status" class="hint" role="status"></p><button type="button" id="recover-draft" hidden>Recover saved draft</button><button type="button" id="download-draft-source" hidden>Download saved source</button><button type="button" id="forget-draft" hidden>Forget saved draft</button></details> <p id="authorship" role="status">Published package</p> @@ -78,15 +79,15 @@ <p>Optional limits: <strong id="package-resource-mode">Off</strong></p> <label><input id="uncapped" type="checkbox" checked aria-describedby="package-resource-warning"> Run without optional limits</label> <p id="package-resource-warning" class="hint">Uncheck to set limits below. Limits can stop a job before it uses more resources; Stop remains available either way.</p> - </div><h3>Session settings</h3><div id="package-limits-defaults"></div><p class="hint">Memory is measured in live tree nodes, not megabytes. Garbage collection stays active with or without these limits. Bundle validation and IO format checks always apply.</p><p class="hint">Package input: 1 MiB bundle and 100,000 stored nodes; loading allows 10 active seconds. IO: up to 128 actions, 256 characters per line and 16,384 output characters. Tree size counts every occurrence, including shared branches.</p></div></details> + </div><h3>Session settings</h3><div id="package-limits-defaults"></div><p class="hint">Memory is measured in live tree nodes, not megabytes. Garbage collection stays active with or without these limits. Bundle validation and IO format checks always apply.</p><p class="hint">Package input: 1 MiB bundle and 100,000 stored nodes; loading allows 60 seconds, including transfer and processing. IO: up to 128 actions, 256 characters per line and 16,384 output characters. Tree size counts every occurrence, including shared branches.</p></div></details> <details id="compiler-settings" class="execution-limits" hidden><summary id="compiler-limits-summary">Optional resource limits</summary><div class="limits-content"><div id="compiler-resource-choice" class="resource-choice" hidden> <p>Optional limits: <strong id="compiler-resource-mode">Off</strong></p> <label><input id="compiler-uncapped" type="checkbox" checked aria-describedby="compiler-resource-warning"> Run without optional limits</label> <p id="compiler-resource-warning" class="hint">Uncheck to set limits below. Limits can stop a job before it uses more resources; Stop remains available either way.</p> </div><h3>Session settings</h3><div id="compiler-limits-defaults"></div><p class="hint">Memory is measured in live tree nodes, not megabytes. Garbage collection stays active with or without these limits. Bundle validation and IO format checks always apply.</p><p class="hint">Source: 16,384 characters and up to 16 definitions. IO uses the same text limits as the playground. Compile once, then run definitions with new arguments. Compilation and each run have separate work and active-time allowances. Enabled limits apply to live nodes and evaluation frames; statistics remain cumulative.</p></div></details> </details> - <p class="hint"><a href="#export-definition" data-open-pane="export-definition">Export this definition →</a></p> <details class="trace-panel"><summary>Interaction trace</summary><section aria-labelledby="trace-title"><div class="panel-heading"><h3 id="trace-title">Interaction tree / execution trace</h3><span>action → response → next action</span></div><p id="trace-empty" class="hint">Choose Text / line IO for an interactive program. Each action, response and continuation appears here.</p><ol id="trace"></ol></section></details> + <p class="hint"><a href="#export-definition" data-open-pane="export-definition">Export this definition →</a></p> </section> </details> </div> @@ -111,7 +112,7 @@ <div class="controls"><button type="button" id="export-tree" class="primary" disabled>Export definition</button><button type="button" id="stop-tree" disabled>Stop</button></div> <p id="export-draft-note" class="hint" hidden>A local draft is selected. Return to Playground and restore the published source to export its stored definitions.</p> <p id="tree-provenance" class="hint" role="status">Choose a package and definition.</p> - <details><summary>Export sizes and formats</summary><p class="hint">Expanded formats allow up to 32 MiB; DAG preserves sharing and allows up to 128 MiB. Exports have two minutes of active time; loading allows 10 seconds. These files contain tree structure, not an Arboricx bundle or the original program source.</p></details> + <details><summary>Export sizes and formats</summary><p class="hint">Expanded formats allow up to 32 MiB; DAG preserves sharing and allows up to 128 MiB. Exports have two minutes of active time; loading allows 60 seconds, including transfer and processing. These files contain tree structure, not an Arboricx bundle or the original program source.</p></details> </section> <section class="source-panel" aria-labelledby="tree-preview-title"> <div class="panel-heading"><h3 id="tree-preview-title">Tree preview</h3><span>Up to 16,384 characters</span></div> diff --git a/ext/demo-site/packages.json b/ext/demo-site/packages.json @@ -146,6 +146,56 @@ "hash": "ce730fa60942d894afd464b3d15d4abdeda73007367c660c67e5dea0093ad38f", "manifest": "45beaf800721509af6d275ebbb4bfb0efb57736ada1cdb3242d9c226d29ecd3c", "title": "Arboricx inspection: validate without execution" + }, + { + "name": "arboricx-server", + "version": "0.1.0", + "hash": "ff2bb1bfb5a425eae5db7ecdf2cbaa18f5fe8b164cedc58c529944d532207a2c", + "manifest": "0ac89333ef54c48442b092cc9edc8d07adb590ea908c214eba644ada3ba4bf04", + "title": "Arboricx registry server (native sockets and filesystem)" + }, + { + "name": "tricu-http", + "version": "0.1.0", + "hash": "b8e1e83722eb6432dc354506e393129ec2f4742cc1e70695709d0a99fd7ba75b", + "manifest": "9705ec1c02fd90b27d77d29058f8290cc6a254acb774a0e27f8f8cf3d2da1ff2", + "title": "HTTP parsing, responses and native socket handlers" + }, + { + "name": "tricu-intensional-contracts", + "version": "0.1.0", + "hash": "c5b6b25cbe8d0d0515b483a39a2cb500761d64b05cb801a1249d25be3b8a3bbb", + "manifest": "9be22e7d68fddc1bbce1454c9fd78cd00eaaa5139a5534148941c893de48cae4", + "title": "Intensional contracts: tree shapes and numeric structure" + }, + { + "name": "tricu-graphs", + "version": "0.1.0", + "hash": "a6131b64f1d6eb3765d64da89e238b9c82adf90f47eb777ff4b4bd12e1d955cb", + "manifest": "64d44bbb8562f95148ad48133fc8d153e5bceb65a34339c68788d036d69551f1", + "title": "Dependency ordering and cycle witnesses (validated small graphs)" + }, + { + "name": "tricu-parser", + "version": "0.1.0", + "hash": "7254d123eb4acb98ae4d87f4f3e48b87c2bb7371237b7710ec6bd23c82b9ceed", + "manifest": "980838310f8ff39925e83ece218810b752df0cbc535f2decf9126dbbcc5d3452", + "title": "Parser combinators: diagnostics, consumption and commitment" + }, + { + "name": "tricu-formatter", + "version": "0.1.0", + "hash": "086aabb4b95608428f0cb0776cd408102f6b1ff44aca3f9324c9e3dd9fbebd02", + "manifest": "d4223c2002a2f77789dc9bf413905689ee0f9e41bbef84367762d5e12ddc0fbe", + "title": "Conservative tricu equation alignment (not an AST printer)" + }, + { + "name": "parser-demo", + "version": "0.1.0", + "hash": "c7560f717712244810ed3b354912fe8791c2bab5c1df1520813426be01910c2f", + "manifest": "e6edf23a694a43f5b476c5fbb303ed722e60ead568388766a99867f588b7272a", + "title": "Parser combinators: complete input, prefixes and committed errors", + "demo": "parser" } ] } diff --git a/ext/demo-site/parser-demo.json b/ext/demo-site/parser-demo.json @@ -0,0 +1,189 @@ +{ + "id": "parser", + "hash": "c7560f717712244810ed3b354912fe8791c2bab5c1df1520813426be01910c2f", + "mode": "pure", + "compilerSha256": "5d36d069562ca9b208cfd0aa8df9f0d3b5c39f8b13e070fb892e21b38e32ffb4", + "sourceSha256": "3405e18774db8de759b7793880e1568c156d68e5fe4a388e09b95ff301d896f5", + "haskellCases": [ + { + "export": "parseNames", + "arguments": [ + "220210202021021021002210202102020210210022020210202021021002202021021020210022102102102020210210022021020202102102100221020210202021021002210202102020210210022021021021020210210022020210210202100220210202020210210022020210210202102100221020210202102102100221020210202021021000" + ], + "input": null, + "terminal": "", + "result": "22221021020202102102100220202102021021021002210202020202102100220202102021021021002210202102021021021002210210202021021021000202221021021021020210210022102102021020210210000222202102102102021021002210202020202102100221020210210202102100221020210202021021002210210202021021021000202220210202021021021002210202102020210210022020210202021021002202021021020210022020202020210022102102102020210210022021020202102102100221020210202021021002210202102020210210022021021021020210210022020210210202100220202020202100220210202020210210022020210210202102100221020210202102102100221020210202021021000022221021020202021021002210210210210202102100221020210202102102100220210210210202102100220202102021021021000221002210210002222021020202102102100221020210202021021002210202102102021021002210202020202102100221020202102021021002202102102102021021002210202021020210210022021021021020210210022102102102020210210002020022221021020202021021002210210210210202102100220210210210202102100221021020202102102100221020210202102102100221020210210202102100221020210202021021002202021020202102100022021002100222210210202020210210022102102102102021021002210202102102021021002210202102102021021002210202021020210210022020210202102102100220202102021021021002210202102020210210022020210202021021000220210021000", + "record": { + "status": "ok", + "names": "red, green, blue", + "count": 3, + "remaining": "", + "consumed": true, + "committed": true + } + }, + { + "export": "parseNames", + "arguments": [ + "220210202021021021002210202102020210210022020210202021021000" + ], + "input": null, + "terminal": "", + "result": "2222102102020210210210022020210202102102100221020202020210210022020210202102102100221020210202102102100221021020202102102100020222102102102102021021002210210202102021021000022220210210210202102100221020202020210210022102021021020210210022102021020202102100221021020202102102100020222021020202102102100221020210202021021002202021020202102100002222102102020202102100221021021021020210210022102021020210210210022021021021020210210022020210202102102100022100221000222202102020210210210022102021020202102100221020210210202102100221020202020210210022102020210202102100220210210210202102100221020202102021021002202102102102021021002210210210202021021000202002222102102020202102100221021021021020210210022021021021020210210022102102020210210210022102021020210210210022102021021020210210022102021020202102100220202102020210210002202100210022221021020202021021002210210210210202102100221020210210202102100221020210210202102100221020202102021021002202021020210210210022020210202102102100221020210202021021002202021020202102100022021002000", + "record": { + "status": "ok", + "names": "red", + "count": 1, + "remaining": "", + "consumed": true, + "committed": false + } + }, + { + "export": "parseNames", + "arguments": [ + "220210202021021021002210202102020210210022020210202021021002202021021020210022020210210202100220210202020210210022020210210202102100221020210202102102100221020210202021021000" + ], + "input": null, + "terminal": "", + "result": "22221021020202102102100220202102021021021002210202020202102100220202102021021021002210202102021021021002210210202021021021000202221020210202021021002202102020210210210022021020202102102100221021021021020210210022021020202102102100002222102021020202102100220202021021021021002202020202102102100221020210202021021002210210202020210210022020210202102102100221020210202021021002202021020202102100020222020210210202102100221021021021020210210022102102102021021021002210202102020210210022021020202102102100221021020202021021002210202020202102100221021020202102102100221020210202021021002202020202021002202102102102021021002210202020202102100221020210210202102100221020210202021021000022220210202021021021002210202102020210210022102021021020210210022102020202021021002210202021020210210022021021021020210210022102020210202102100220210210210202102100221021021020202102100020222020210210202100220210202020210210022020210210202102100221020210202102102100221020210202021021000022221021020202021021002210210210210202102100220210210210202102100221021020202102102100221020210202102102100221020210210202102100221020210202021021002202021020202102100022021002100222210210202020210210022102102102102021021002210202102102021021002210202102102021021002210202021020210210022020210202102102100220202102021021021002210202102020210210022020210202021021000220210021000", + "record": { + "status": "error", + "expected": "lowercase name", + "remaining": ",blue", + "consumed": true, + "committed": true + } + }, + { + "export": "parseNames", + "arguments": [ + "22021020202102102100221020210202021021002202021020202102100220202102102021000" + ], + "input": null, + "terminal": "", + "result": "2222102102020210210210022020210202102102100221020202020210210022020210202102102100221020210202102102100221021020202102102100020222102021020202102100220210202021021021002202102020210210210022102102102102021021002202102020210210210000222210202102020210210022020202102102102100220202020210210210022102021020202102100221021020202021021002202021020210210210022102021020202102100220202102020210210002022202021021020210210022102102102102021021002210210210202102102100221020210202021021002202102020210210210022102102020202102100221020202020210210022102102020210210210022102021020202102100220202020202100220210210210202102100221020202020210210022102021021020210210022102021020202102100002222021020202102102100221020210202021021002210202102102021021002210202020202102100221020202102021021002202102102102021021002210202021020210210022021021021020210210022102102102020210210002020022221021020202021021002210210210210202102100220210210210202102100221021020202102102100221020210202102102100221020210210202102100221020210202021021002202021020202102100022021002100222210210202020210210022102102102102021021002210202102102021021002210202102102021021002210202021020210210022020210202102102100220202102021021021002210202102020210210022020210202021021000220210021000", + "record": { + "status": "error", + "expected": "lowercase name", + "remaining": "", + "consumed": true, + "committed": true + } + }, + { + "export": "parseNames", + "arguments": [ + "0" + ], + "input": null, + "terminal": "", + "result": "22221021020202102102100220202102021021021002210202020202102100220202102021021021002210202102021021021002210210202021021021000202221020210202021021002202102020210210210022021020202102102100221021021021020210210022021020202102102100002222102021020202102100220202021021021021002202020202102102100221020210202021021002210210202020210210022020210202102102100221020210202021021002202021020202102100020222020210210202102100221021021021020210210022102102102021021021002210202102020210210022021020202102102100221021020202021021002210202020202102100221021020202102102100221020210202021021002202020202021002202102102102021021002210202020202102100221020210210202102100221020210202021021000022220210202021021021002210202102020210210022102021021020210210022102020202021021002210202021020210210022021021021020210210022102020210202102100220210210210202102100221021021020202102100020200222210210202020210210022102102102102021021002202102102102021021002210210202021021021002210202102021021021002210202102102021021002210202102020210210022020210202021021000220210020022221021020202021021002210210210210202102100221020210210202102100221020210210202102100221020202102021021002202021020210210210022020210202102102100221020210202021021002202021020202102100022021002000", + "record": { + "status": "error", + "expected": "lowercase name", + "remaining": "", + "consumed": false, + "committed": false + } + }, + { + "export": "parseNames", + "arguments": [ + "22021020202102021002210202102020210210022020210202021021000" + ], + "input": null, + "terminal": "", + "result": "222210210202021021021002202021020210210210022102020202021021002202021020210210210022102021020210210210022102102020210210210002022210202102020210210022021020202102102100220210202021021021002210210210210202102100220210202021021021000022221020210202021021002202020210210210210022020202021021021002210202102020210210022102102020202102100220202102021021021002210202102020210210022020210202021021000202220202102102021021002210210210210202102100221021021020210210210022102021020202102100220210202021021021002210210202020210210022102020202021021002210210202021021021002210202102020210210022020202020210022021021021020210210022102020202021021002210202102102021021002210202102020210210000222202102020210210210022102021020202102100221020210210202102100221020202020210210022102020210202102100220210210210202102100221020202102021021002202102102102021021002210210210202021021000202220210202021020210022102021020202102100220202102020210210000222210210202020210210022102102102102021021002202102102102021021002210210202021021021002210202102021021021002210202102102021021002210202102020210210022020210202021021000220210020022221021020202021021002210210210210202102100221020210210202102100221020210210202102100221020202102021021002202021020210210210022020210202102102100221020210202021021002202021020202102100022021002000", + "record": { + "status": "error", + "expected": "lowercase name", + "remaining": "Red", + "consumed": false, + "committed": false + } + }, + { + "export": "parseNames", + "arguments": [ + "220210202021021021002210202102020210210022020210202021021002202020202021002210210210202021021002202102020210210210022102021020202102100221020210202021021002202102102102021021000" + ], + "input": null, + "terminal": "", + "result": "2222102102020210210210022020210202102102100221020202020210210022020210202102102100221020210202102102100221021020202102102100020222102021020202102100220210202021021021002202102020210210210022102102102102021021002202102020210210210000222210202102020210210022020202102102102100220202020210210210022102021020202102100221021020202021021002202021020210210210022102021020202102100220202102020210210002022210202102020210210022021021021020210210022020210202021021002202020202021002210210210210202102100220210210202021021002202020202021002210202021020210210022021021021020210210022020202021021021002210202102021021021002202021020210210210000222202102020210210210022102021020202102100221020210210202102100221020202020210210022102020210202102100220210210210202102100221020202102021021002202102102102021021002210210210202021021000202220202020202100221021021020202102100220210202021021021002210202102020210210022102021020202102100220210210210202102100002222102102020202102100221021021021020210210022021021021020210210022102102020210210210022102021020210210210022102021021020210210022102021020202102100220202102020210210002202100210022221021020202021021002210210210210202102100221020210210202102100221020210210202102100221020202102021021002202021020210210210022020210202102102100221020210202021021002202021020202102100022021002000", + "record": { + "status": "error", + "expected": "end of input", + "remaining": " green", + "consumed": true, + "committed": false + } + }, + { + "export": "parseNames", + "arguments": [ + "220210202021021021002210202102020210210022020210202021021002202021021020210022102102102020210210022021020202102102100221020210202021021002210202102020210210022021021021020210210022102020202021000" + ], + "input": null, + "terminal": "", + "result": "222210210202021021021002202021020210210210022102020202021021002202021020210210210022102021020210210210022102102020210210210002022210202102020210210022021020202102102100220210202021021021002210210210210202102100220210202021021021000022221020210202021021002202020210210210210022020202021021021002210202102020210210022102102020202102100220202102021021021002210202102020210210022020210202021021000202221020210202021021002202102102102021021002202021020202102100220202020202100221021021021020210210022021021020202102100220202020202100221020202102021021002202102102102021021002202020202102102100221020210202102102100220202102021021021000022220210202021021021002210202102020210210022102021021020210210022102020202021021002210202021020210210022021021021020210210022102020210202102100220210210210202102100221021021020202102100020222102020202021000022221021020202021021002210210210210202102100220210210210202102100221021020202102102100221020210202102102100221020210210202102100221020210202021021002202021020202102100022021002100222210210202020210210022102102102102021021002210202102102021021002210202102102021021002210202021020210210022020210202102102100220202102021021021002210202102020210210022020210202021021000220210021000", + "record": { + "status": "error", + "expected": "end of input", + "remaining": "!", + "consumed": true, + "committed": true + } + }, + { + "export": "parsePrefix", + "arguments": [ + "220210202021021021002210202102020210210022020210202021021002202020202021002210210210202021021002202102020210210210022102021020202102100221020210202021021002202102102102021021000" + ], + "input": null, + "terminal": "", + "result": "2222102102020210210210022020210202102102100221020202020210210022020210202102102100221020210202102102100221021020202102102100020222102102102102021021002210210202102021021000022220210210210202102100221020202020210210022102021021020210210022102021020202102100221021020202102102100020222021020202102102100221020210202021021002202021020202102100002222102102020202102100221021021021020210210022102021020210210210022021021021020210210022020210202102102100022100221000222202102020210210210022102021020202102100221020210210202102100221020202020210210022102020210202102100220210210210202102100221020202102021021002202102102102021021002210210210202021021000202220202020202100221021021020202102100220210202021021021002210202102020210210022102021020202102100220210210210202102100002222102102020202102100221021021021020210210022021021021020210210022102102020210210210022102021020210210210022102021021020210210022102021020202102100220202102020210210002202100210022221021020202021021002210210210210202102100221020210210202102100221020210210202102100221020202102021021002202021020210210210022020210202102102100221020210202021021002202021020202102100022021002000", + "record": { + "status": "ok", + "names": "red", + "count": 1, + "remaining": " green", + "consumed": true, + "committed": false + } + }, + { + "export": "parsePrefix", + "arguments": [ + "220210202021021021002210202102020210210022020210202021021002202021021020210022102102102020210210022021020202102102100221020210202021021002210202102020210210022021021021020210210022102020202021000" + ], + "input": null, + "terminal": "", + "result": "2222102102020210210210022020210202102102100221020202020210210022020210202102102100221020210202102102100221021020202102102100020222102102102102021021002210210202102021021000022220210210210202102100221020202020210210022102021021020210210022102021020202102100221021020202102102100020222021020202102102100221020210202021021002202021020202102100220202102102021002202020202021002210210210202021021002202102020210210210022102021020202102100221020210202021021002202102102102021021000022221021020202021021002210210210210202102100221020210202102102100220210210210202102100220202102021021021000221002202100022220210202021021021002210202102020210210022102021021020210210022102020202021021002210202021020210210022021021021020210210022102020210202102100220210210210202102100221021021020202102100020222102020202021000022221021020202021021002210210210210202102100220210210210202102100221021020202102102100221020210202102102100221020210210202102100221020210202021021002202021020202102100022021002100222210210202020210210022102102102102021021002210202102102021021002210202102102021021002210202021020210210022020210202102102100220202102021021021002210202102020210210022020210202021021000220210021000", + "record": { + "status": "ok", + "names": "red, green", + "count": 2, + "remaining": "!", + "consumed": true, + "committed": true + } + }, + { + "export": "parsePrefix", + "arguments": [ + "22021020202102102100221020210202021021002202021020202102100220202102102021000" + ], + "input": null, + "terminal": "", + "result": "2222102102020210210210022020210202102102100221020202020210210022020210202102102100221020210202102102100221021020202102102100020222102021020202102100220210202021021021002202102020210210210022102102102102021021002202102020210210210000222210202102020210210022020202102102102100220202020210210210022102021020202102100221021020202021021002202021020210210210022102021020202102100220202102020210210002022202021021020210210022102102102102021021002210210210202102102100221020210202021021002202102020210210210022102102020202102100221020202020210210022102102020210210210022102021020202102100220202020202100220210210210202102100221020202020210210022102021021020210210022102021020202102100002222021020202102102100221020210202021021002210202102102021021002210202020202102100221020202102021021002202102102102021021002210202021020210210022021021021020210210022102102102020210210002020022221021020202021021002210210210210202102100220210210210202102100221021020202102102100221020210202102102100221020210210202102100221020210202021021002202021020202102100022021002100222210210202020210210022102102102102021021002210202102102021021002210202102102021021002210202021020210210022020210202102102100220202102021021021002210202102020210210022020210202021021000220210021000", + "record": { + "status": "error", + "expected": "lowercase name", + "remaining": "", + "consumed": true, + "committed": true + } + } + ] +} diff --git a/ext/demo-site/prepare-index.py b/ext/demo-site/prepare-index.py @@ -77,6 +77,12 @@ def main(): previous = json.loads(old_index.read_text())['releases'] if old_index.exists() else [] releases = list(previous) for entry in catalogue: + if entry.get('preparation') == 'native': + if entry['id'] in versions: + raise ValueError('Native demos are released through prepare-showcase.py, not prepare-index.py') + if not any(r.get('demo') == entry['id'] and r['hash'] == entry['hash'] for r in previous): + raise ValueError('Native demo has no accepted release: ' + entry['id']) + continue # Preserve its exact pin and read-only native-module source. # Operator-owned release labels. Refuse changed contents at a label; # add/change a version explicitly rather than silently repointing it. matches = [r for r in previous if r.get('demo') == entry['id'] and r['hash'] == entry['hash']] diff --git a/ext/demo-site/prepare-showcase.py b/ext/demo-site/prepare-showcase.py @@ -0,0 +1,205 @@ +#!/usr/bin/env python3 +"""Opt-in Haskell publication of accepted library roots and a native parser demo. + +No fixture regeneration or public-network access. Requires current sources to +match authenticated retained fixtures. Stages only after bounded acceptance. +""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import re +import subprocess +import sys +import tempfile +import time + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parent.parent +sys.path.insert(0, str(ROOT / 'test')) +from bundle_dag import read, merge_bundles +from fixture_sources import current_mismatches +from shipped_compiler import verify +sys.path.insert(0, str(HERE)) +from prepare import of_string, of_number + +LIBRARIES = [ + ('arboricx-server', 'arboricx_server', 'support', 'Arboricx registry server (native sockets and filesystem)'), + ('tricu-http', 'http', 'support', 'HTTP parsing, responses and native socket handlers'), + ('tricu-intensional-contracts', 'intensional', 'support', 'Intensional contracts: tree shapes and numeric structure'), + ('tricu-graphs', 'graph', 'compiler', 'Dependency ordering and cycle witnesses (validated small graphs)'), + ('tricu-parser', 'parser', 'compiler', 'Parser combinators: diagnostics, consumption and commitment'), +] + + +def parser_cases(): + def success(names, rest='', committed=True): + return dict(status='ok', names=', '.join(names), count=len(names), remaining=rest, + consumed=True, committed=committed) + def failure(expected, rest, consumed, committed): + return dict(status='error', expected=expected, remaining=rest, consumed=consumed, committed=committed) + return [ + ('parseNames', 'red,green,blue', success(['red', 'green', 'blue'])), + ('parseNames', 'red', success(['red'], committed=False)), + ('parseNames', 'red,,blue', failure('lowercase name', ',blue', True, True)), + ('parseNames', 'red,', failure('lowercase name', '', True, True)), + ('parseNames', '', failure('lowercase name', '', False, False)), + ('parseNames', 'Red', failure('lowercase name', 'Red', False, False)), + ('parseNames', 'red green', failure('end of input', ' green', True, False)), + ('parseNames', 'red,green!', failure('end of input', '!', True, True)), + ('parsePrefix', 'red green', success(['red'], ' green', False)), + ('parsePrefix', 'red,green!', success(['red', 'green'], '!')), + ('parsePrefix', 'red,', failure('lowercase name', '', True, True)), + ] + + +def record_tree(record): + def scalar(value): + if isinstance(value, bool): return '10' if value else '0' + if isinstance(value, int): return of_number(value) + return of_string(value) + def items(values): return ''.join('2' + value for value in values) + '0' + return items([items([of_string(key), of_number(2 if isinstance(value, bool) else 1 if isinstance(value, int) else 0), scalar(value)]) + for key, value in record.items()]) + + +def stage(site, artifacts, releases, demo, goldens): + index = json.loads((site / 'packages.json').read_text()) + catalogue = json.loads((site / 'catalogue.json').read_text()) + if index.get('schema') != 1: raise ValueError('Unsupported package index') + keys = [(r['name'], r['version']) for r in index['releases']] + if len(keys) != len(set(keys)): raise ValueError('Duplicate existing release') + ids = [entry['id'] for entry in catalogue] + if len(ids) != len(set(ids)): raise ValueError('Duplicate existing demo') + for release in releases: + previous = next((r for r in index['releases'] if (r['name'], r['version']) == (release['name'], release['version'])), None) + if previous and previous != release: raise ValueError('Immutable release conflict: ' + release['name']) + if not previous: index['releases'].append(release) + previous = next((e for e in catalogue if e['id'] == demo['id']), None) + if previous and previous != demo: raise ValueError('Existing demo differs; release explicitly instead of overwriting') + if not previous: catalogue.append(demo) + for pin, path in artifacts.items(): + data = path.read_bytes() + if hashlib.sha256(data).hexdigest() != pin: raise ValueError('Artifact changed before staging') + destination = site / 'bundles' / (pin + '.arboricx') + if destination.exists() and destination.read_bytes() != data: raise ValueError('Corrupt existing bundle') + # Immutable bytes first, then discovery. Failed staging can leave orphan assets, + # never an index advertising absent bytes. This is an exclusive local operation. + for pin, path in artifacts.items(): + destination = site / 'bundles' / (pin + '.arboricx') + if not destination.exists(): + with destination.open('xb') as output: output.write(path.read_bytes()) + for name, value in [('packages.json', index), ('catalogue.json', catalogue), ('parser-demo.json', goldens)]: + with tempfile.NamedTemporaryFile(dir=site, delete=False) as output: + temporary = Path(output.name) + output.write((json.dumps(value, indent=2) + '\n').encode()) + try: + temporary.chmod(0o644) + os.replace(temporary, site / name) + finally: temporary.unlink(missing_ok=True) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('tricu', type=Path) + parser.add_argument('--directory', type=Path, required=True, help='New retained output directory') + parser.add_argument('--reuse-directory', action='store_true', help='Reuse a local CAS; sources are rewritten and native receipts checked again') + parser.add_argument('--stage-site', action='store_true', help='After acceptance, stage into this static site') + args = parser.parse_args() + metadata = verify() + mismatches = current_mismatches(ROOT / 'test/fixtures', ROOT) + if mismatches: raise ValueError('Current sources differ from retained fixtures: ' + str(mismatches)) + root = args.directory.resolve(); root.mkdir(parents=True, exist_ok=args.reuse_directory) + workspace = root / 'workspace'; workspace.mkdir(exist_ok=args.reuse_directory) + host = args.tricu.resolve(); store = root / 'store' + started = time.monotonic() + report = dict(complete=False, compiler=metadata['sha256'], hostSha256=hashlib.sha256(host.read_bytes()).hexdigest(), + commands=[], releases=[], sourceSha256={}, fixtureSha256={}) + def save(): (root / 'report.json').write_text(json.dumps(report, indent=2) + '\n') + def run(*words, seconds=45, strip=True): + remaining = 600 - (time.monotonic() - started) + if remaining <= 0: raise TimeoutError('Showcase preparation whole-job deadline exceeded') + command = [str(host), *map(str, words), '+RTS', '-N1', '-M2048m', '-RTS'] + before = time.monotonic() + reply = subprocess.run(command, cwd=ROOT, capture_output=True, timeout=min(seconds, remaining)) + report['commands'].append(dict(argv=command[1:], seconds=time.monotonic()-before, code=reply.returncode, + stdout=reply.stdout.decode(), stderr=reply.stderr.decode())) + save() + if reply.returncode: raise RuntimeError(report['commands'][-1]) + return reply.stdout.decode().strip() if strip else reply.stdout.decode() + def imported(bundle, target=store): + reply = run('arboricx', 'import', '--indexed', '--store', target, '--file', bundle) + return re.search(r'Created module alias .* -> ([0-9a-f]{64})', reply)[1] + def extracted(alias, cohort): + original = ROOT / 'test/fixtures' / (cohort + '-library.arboricx') + report['fixtureSha256'][cohort] = hashlib.sha256(original.read_bytes()).hexdigest() + names = [(name, name.split('.', 1)[1]) for name in read(original)[3] if name.startswith(alias + '.')] + if not names: raise ValueError('No retained public roots: ' + alias) + bundle = root / (alias + '.arboricx') + # Exact constructor/child-ID equivalence, without evaluating/unfolding. + merge_bundles(bundle, [(original, names)], prune_unreachable=True) + return bundle + artifacts = {} + def published(name, title, bundle, demo=None): + data = bundle.read_bytes() + if not 0 < len(data) <= 1048576 or len(read(bundle)[2]) > 100000: + raise ValueError('Package exceeds browser input bounds: ' + name) + result = run('package', 'publish', '--root', root / 'registry', '--name', name, + '--version', '0.1.0', '--file', bundle) + _, row = result.splitlines(); actual_name, version, manifest, pin = row.split('\t') + assert actual_name == name and pin == hashlib.sha256(data).hexdigest() + # Independent clean indexed import must produce the advertised interface. + assert imported(bundle, root / 'consumer') == manifest + release = dict(name=name, version=version, hash=pin, manifest=manifest, title=title) + if demo: release['demo'] = demo + artifacts[pin] = bundle; report['releases'].append(release) + return release + for name, alias, cohort, title in LIBRARIES: + published(name, title, extracted(alias, cohort)) + for alias in ['prelude', 'parser', 'source_lexer', 'source_equations', 'source_ast', 'native_math']: + pin = imported(extracted(alias, 'compiler')) + (workspace / (alias + '.tri')).write_text('!export * from ' + pin + '\n') + for alias, source in [('recursion', ROOT / 'lib/recursion/list.tri'), ('formatter', ROOT / 'lib/tools/format-tri.tri'), + ('parser_demo', HERE / 'demos/parser.tri')]: + data = source.read_bytes(); (workspace / (alias + '.tri')).write_bytes(data) + report['sourceSha256'][str(source.relative_to(ROOT))] = hashlib.sha256(data).hexdigest() + (workspace / 'recursion_support.tri').write_text('!import prelude as B\n!export pair\npair = B.pair\n') + (workspace / 'format_support.tri').write_text(''.join('!export * from ' + name + '\n' for name in + ['prelude', 'parser', 'source_lexer', 'source_equations', 'source_ast'])) + compiler = imported(ROOT / metadata['bundle'], root / 'tools') + def compile_unit(alias): + bundle = root / (alias + '.arboricx') + run('arboricx', 'compile', '--file', workspace / (alias + '.tri'), '--store', store, + '--toolchain', compiler, '--tool-store', root / 'tools', '--output', bundle, + '--compile-timeout', '180', seconds=195) + return bundle, imported(bundle) + formatter, formatter_manifest = compile_unit('formatter') + # Independent real formatter cases, no file effects or diverging probe needed. + for label, before, expected in json.loads((ROOT / 'test/formatter_cases.json').read_text()): + input_file = root / 'formatter-input.tri'; input_file.write_text(before) + actual = run('format', input_file, '--formatter', formatter_manifest, '--tool-store', store, strip=False) + assert input_file.read_bytes() == before.encode(), label + assert actual == expected, (label, repr(actual), repr(expected)) + published('tricu-formatter', 'Conservative tricu equation alignment (not an AST printer)', formatter) + parser_bundle, parser_manifest = compile_unit('parser_demo') + goldens = [] + for export, text, expected in parser_cases(): + actual = run('arboricx', 'run', '--store', store, '--module', parser_manifest, + '--name', export, '--arg', text, '--format', 'ternary') + assert actual == record_tree(expected), (export, text, actual[:200], expected) + goldens.append(dict(export=export, arguments=[of_string(text)], input=None, terminal='', result=actual, record=expected)) + release = published('parser-demo', 'Parser combinators: complete input, prefixes and committed errors', parser_bundle, 'parser') + source = (HERE / 'demos/parser.tri').read_bytes() + preset = dict(arguments=[dict(type='string', value='red,green,blue')], output='record', mode='pure') + demo = dict(id='parser', preparation='native', title='Parser combinators', hash=release['hash'], source=source.decode(), + sourceSha256=hashlib.sha256(source).hexdigest(), export='parseNames', **preset, + presets={name: preset for name in ['parseNames', 'parsePrefix']}) + goldens = dict(id='parser', hash=release['hash'], mode='pure', compilerSha256=metadata['sha256'], + sourceSha256=hashlib.sha256(source).hexdigest(), haskellCases=goldens) + report['complete'] = True; report['elapsedSeconds'] = time.monotonic()-started; save() + if args.stage_site: stage(HERE, artifacts, report['releases'], demo, goldens) + print('Accepted', len(report['releases']), 'releases in', round(report['elapsedSeconds'], 1), 'seconds') + + +if __name__ == '__main__': main() diff --git a/ext/demo-site/prepare.py b/ext/demo-site/prepare.py @@ -313,6 +313,8 @@ def main(): for bundle_hash, payload in package_bytes.items(): (directory / (bundle_hash + '.arboricx')).write_bytes(payload) (HERE / 'demos.json').write_text(json.dumps(data, ensure_ascii=False, indent=2) + '\n') + previous = json.loads((HERE / 'catalogue.json').read_text()) if (HERE / 'catalogue.json').exists() else [] + catalogue.extend(entry for entry in previous if entry.get('preparation') == 'native' and entry['id'] not in settings) (HERE / 'catalogue.json').write_text(json.dumps(catalogue, ensure_ascii=False, indent=2) + '\n') print('Published bundles, catalogue and Haskell goldens (no compiler/library fixture refresh).') diff --git a/ext/demo-site/src/app.ts b/ext/demo-site/src/app.ts @@ -5,7 +5,7 @@ import { activePane } from './tabs.js'; import { DRAFT_KEY, readDraft, writeDraft } from './draft-storage.js'; import { readSettings } from './limits-ui.js'; import { ActiveClock } from './clock.js'; -import { PACKAGE_ACTIVE_MS, EXPORT_ACTIVE_MS, activeMilliseconds } from './execution.js'; +import { PACKAGE_ACTIVE_MS, EXPORT_ACTIVE_MS, activeMilliseconds, operationFailure, operationTimeout, type Operation } from './execution.js'; import { sourceText } from './compiler.js'; import { packageIndex, type Release } from './packages.js'; import type { CompilerMessage } from './compiler-protocol.js'; @@ -19,6 +19,7 @@ function el<T extends HTMLElement = HTMLElement>(id: string): T { const sourceEditor = el<HTMLTextAreaElement>('source-editor'), compileButton = el<HTMLButtonElement>('compile'); const restoreButton = el<HTMLButtonElement>('restore-source'), downloadButton = el<HTMLButtonElement>('download-bundle'); let editingSource = false; +let sourceAvailable = false; let draft = false, scratch = false, compiled = false, busy = false, originalSource: string | undefined; let compilationStats = '', preferredName = 'id'; let compilerTimer: ReturnType<typeof setInterval> | undefined; @@ -33,7 +34,8 @@ const argumentsUI = argumentEditor(argsInput); const mode = el<HTMLSelectElement>('mode'), steps = el<HTMLInputElement>('step-mode'); const runButton = el<HTMLButtonElement>('run'), stopButton = el<HTMLButtonElement>('stop'), nextButton = el<HTMLButtonElement>('next'); let settings = readSettings('package'); -const deadline = new ActiveClock(() => finish('Stopped: active execution time limit exceeded.', true)); +let operation: Operation | undefined; +const deadline = new ActiveClock(() => finish(operationTimeout(operation ?? 'loading'), true, 'timeout')); const shareButton = el<HTMLButtonElement>('share'); const treeExport = createTreeExport(message => { if (draft || !loaded || !selection) return; @@ -41,7 +43,7 @@ const treeExport = createTreeExport(message => { else newWorker(false, true, message); }, name => { exportsInput.value = name; exportsInput.dispatchEvent(new Event('change')); -}, () => finish('Stopped. Export session discarded.', true)); +}, () => finish('Stopped. Export session discarded.', true, 'cancelled')); const clearTreeExport = treeExport.clear; const inputForm = el<HTMLFormElement>('input-form'), lineInput = el<HTMLInputElement>('line-input'); let entries: CatalogueEntry[] = [], selected: CatalogueEntry | undefined; @@ -54,7 +56,7 @@ let downloadAfterLoad = false; const traceRows = new Map<number, HTMLLIElement>(); function pauseClock(): void { deadline.pause(); } function clock(): void { deadline.start(); } -function runClock(): void { deadline.reset(activeMilliseconds(settings)); clock(); } +function runClock(): void { operation = 'running'; deadline.reset(activeMilliseconds(settings)); clock(); } function controls(working: boolean): void { busy = working; programPicker.disabled = busy || entries.length === 0; @@ -74,6 +76,13 @@ function controls(working: boolean): void { sourceEditor.disabled = busy; sourceEditor.readOnly = !editingSource && !draft; el<HTMLButtonElement>('edit-source').disabled = busy; el('edit-source').hidden = originalSource === undefined || editingSource || draft; + const editableSource = !sourceEditor.hidden && (draft || originalSource !== undefined); + el('source-summary').textContent = editableSource ? 'Source and editing' : sourceAvailable ? 'Source (read-only)' : 'Package details'; + el('source-title').textContent = editableSource ? 'Read and edit source' : sourceAvailable ? 'Read-only source' : loaded ? 'Compiled package (no editable source)' : 'Package information'; + el('source-availability').hidden = editableSource || !loaded; + el('source-availability').textContent = sourceAvailable + ? 'Source is shown for reference only. This package does not support editing or local compilation.' + : 'This is a compiled package. Editable source is not included; you can run or export its stored definitions.'; restoreButton.disabled = busy || originalSource === undefined || !draft; downloadButton.disabled = busy || draft || !loaded; el('authorship').textContent = draft ? 'Local draft · your authored code (not published)' : loaded ? 'Published package · verified executable' : 'Published selection · not yet verified'; @@ -95,7 +104,7 @@ function controls(working: boolean): void { treeExport.controls(busy, loaded && !draft); } function dispose(): void { - stopCompilerActivity(); pauseClock(); worker?.terminate(); worker = undefined; compiled = false; pending = undefined; + stopCompilerActivity(); pauseClock(); operation = undefined; worker?.terminate(); worker = undefined; compiled = false; pending = undefined; inputForm.hidden = true; nextButton.disabled = true; controls(false); } function clearOutput(): void { @@ -104,9 +113,10 @@ function clearOutput(): void { el('graph-result').textContent = ''; el('graph-result').hidden = true; el('trace').replaceChildren(); traceRows.clear(); el('trace-empty').hidden = false; lineInput.value = ''; } -function finish(message: string, error = false): void { +function finish(message: string, error = false, reason: 'failure' | 'timeout' | 'cancelled' = 'failure'): void { if (error && /Graph allocation limit|evaluation stack limit|Graph stack limit/u.test(message)) message = `Optional memory or evaluation-depth limit reached. Adjust limits under Advanced to continue. Your source is unchanged. ${message}`; else if (error && /Graph fuel limit/u.test(message)) message = `Optional work limit reached. Adjust limits under Advanced to continue. Your source is unchanged. ${message}`; + if (error && reason === 'failure') message = operationFailure(operation, message); if (!draft && !loaded) el('package-info').textContent = `Package not loaded. ${message}`; if (treeExport.pending || (activePane() === 'export-definition' && !loaded)) treeExport.fail(message); dispose(); el('status').textContent = error ? 'Stopped' : 'Finished'; @@ -119,12 +129,12 @@ function setPreset(entry: CatalogueEntry, name = entry.export): void { argsInput.value = JSON.stringify(preset.arguments, null, 2); argumentsUI.refresh(true); resultCodec.value = preset.output; mode.value = preset.mode; } function startExport(message: Extract<ClientMessage, { type: 'export' }>, send: (message: ClientMessage) => void): void { - treeExport.begin(); controls(true); deadline.reset(EXPORT_ACTIVE_MS); clock(); + operation = 'exporting'; treeExport.begin(); controls(true); deadline.reset(EXPORT_ACTIVE_MS); clock(); el('status').textContent = 'Exporting definition'; send(message); } function newWorker(runAfterLoad: boolean, preserveInputs = false, exportAfterLoad?: Extract<ClientMessage, { type: 'export' }>): void { if (!selection) return; - dispose(); settings = readSettings('package'); deadline.reset(PACKAGE_ACTIVE_MS); automaticRun = runAfterLoad; + dispose(); operation = 'loading'; settings = readSettings('package'); deadline.reset(PACKAGE_ACTIVE_MS); automaticRun = runAfterLoad; const current = new Worker(new URL('./worker.js', import.meta.url), { type: 'module' }); worker = current; let currentVerified = false; controls(true); el('status').textContent = 'Loading package'; el('package-info').textContent = 'Verifying package…'; clock(); @@ -133,7 +143,7 @@ function newWorker(runAfterLoad: boolean, preserveInputs = false, exportAfterLoa if (worker !== current) return; switch (data.type) { case 'state': - if (data.state === 'running') { pending = undefined; clock(); el('status').textContent = 'Running'; } + if (data.state === 'running') { pending = undefined; clock(); el('status').textContent = operation === 'loading' ? 'Loading package' : 'Running'; } else { pauseClock(); pending = { kind: data.state, id: data.id }; el('status').textContent = data.state === 'input' ? 'Waiting for input' : 'Paused before action'; @@ -143,7 +153,7 @@ function newWorker(runAfterLoad: boolean, preserveInputs = false, exportAfterLoa break; case 'downloaded': { if (data.hash !== selection?.hash || draft) break; - pauseClock(); controls(false); el('status').textContent = 'Ready'; + pauseClock(); operation = undefined; controls(false); el('status').textContent = 'Ready'; const url = URL.createObjectURL(new Blob([new Uint8Array(data.bytes)], { type: 'application/vnd.arboricx.bundle' })); const anchor = document.createElement('a'); anchor.href = url; anchor.download = `${data.hash}.arboricx`; document.body.append(anchor); anchor.click(); anchor.remove(); @@ -166,21 +176,22 @@ function newWorker(runAfterLoad: boolean, preserveInputs = false, exportAfterLoa sourceEditor.value = originalSource ?? ''; sourceEditor.hidden = originalSource === undefined; el('source-notes').hidden = originalSource !== undefined; } + sourceAvailable = data.source !== null; el('source-label').textContent = data.source === null ? 'Package metadata' : 'Curated tricu / stable'; el('source').textContent = data.source ?? `Verified package metadata\n${JSON.stringify(data.metadata, null, 2)}\n\nChoose an export, its arguments and a result interpretation to apply this tree.`; - if (downloadAfterLoad) { downloadAfterLoad = false; send({ type: 'download' }); } + if (downloadAfterLoad) { downloadAfterLoad = false; pauseClock(); operation = 'downloading'; deadline.reset(PACKAGE_ACTIVE_MS); clock(); send({ type: 'download' }); } else if (exportAfterLoad) { pauseClock(); startExport(exportAfterLoad, send); } else if (automaticRun && savedRun) { pauseClock(); runClock(); automaticRun = false; send(savedRun); } - else { if (!preserveInputs && selected?.presets?.[exportsInput.value]) setPreset(selected, exportsInput.value); pauseClock(); controls(false); el('status').textContent = 'Ready'; } + else { if (!preserveInputs && selected?.presets?.[exportsInput.value]) setPreset(selected, exportsInput.value); pauseClock(); operation = undefined; controls(false); el('status').textContent = 'Ready'; } break; } case 'exported': { pauseClock(); if (!treeExport.receive(data, selection?.hash, exportsInput.value)) break; - controls(false); el('status').textContent = 'Ready'; + operation = undefined; controls(false); el('status').textContent = 'Ready'; break; } - case 'export-error': pauseClock(); treeExport.fail(data.message); controls(false); el('status').textContent = 'Ready'; break; + case 'export-error': pauseClock(); treeExport.fail(operationFailure('exporting', data.message)); operation = undefined; controls(false); el('status').textContent = 'Ready'; break; case 'trace': { el('trace-empty').hidden = true; const event = data.event; if (data.phase === 'before') { @@ -209,7 +220,7 @@ function newWorker(runAfterLoad: boolean, preserveInputs = false, exportAfterLoa send({ type: 'load', ...selection, ...settings }); } function select(entry: CatalogueEntry, release?: Release): void { - editingSource = false; + editingSource = false; sourceAvailable = false; draft = false; scratch = false; originalSource = undefined; downloadAfterLoad = false; dispose(); clearOutput(); clearTreeExport(); selected = entry; loaded = false; el<HTMLDetailsElement>('source-section').open = true; @@ -223,7 +234,7 @@ function select(entry: CatalogueEntry, release?: Release): void { newWorker(false); } function invalidateProgram(): void { - editingSource = false; + editingSource = false; sourceAvailable = false; draft = false; scratch = false; originalSource = undefined; downloadAfterLoad = false; dispose(); clearOutput(); clearTreeExport(); loaded = false; selection = undefined; selected = undefined; preferredExport = undefined; exportsInput.replaceChildren(); treeExport.selection(exportsInput); @@ -243,7 +254,7 @@ function loadBundle(release?: Release): void { const hash = bundleHash(value); selection = { hash }; argsInput.value = '[]'; argumentsUI.refresh(true); resultCodec.value = 'tree'; mode.value = 'pure'; showRelease(release); el('source').textContent = 'Verifying registry package…'; newWorker(false); - } catch (error) { finish(error instanceof Error ? error.message : String(error), true); } + } catch (error) { operation = 'loading'; finish(error instanceof Error ? error.message : String(error), true); } } el<HTMLFormElement>('program-hash').onsubmit = event => { event.preventDefault(); loadBundle(); }; hashInput.oninput = () => { chooseProgram('hash'); invalidateProgram(); }; @@ -251,7 +262,7 @@ exportsInput.onchange = () => { preferredName = exportsInput.value; clearTreeExp runButton.onclick = () => { if (draft) { runDraft(); return; } if (!loaded || !selection) return; - clearOutput(); + operation = 'running'; clearOutput(); try { const args = argumentsUI.read(), output: OutputType = outputType(resultCodec.value); savedRun = { type: 'run', export: exportsInput.value, arguments: args, output, mode: mode.value === 'io' ? 'io' : 'pure', step: steps.checked }; @@ -270,7 +281,7 @@ inputForm.onsubmit = event => { catch (error) { el('result').textContent = error instanceof Error ? error.message : String(error); el('result').className = 'error'; } }; nextButton.onclick = () => { if (pending?.kind === 'step') resume(); }; -stopButton.onclick = () => finish('Stopped. Session discarded.', true); +stopButton.onclick = () => finish('Stopped. Session discarded.', true, 'cancelled'); el<HTMLButtonElement>('stop-compile').onclick = stopButton.onclick; el('try-program-link').onclick = () => { el<HTMLDetailsElement>('try-program').open = true; }; el<HTMLButtonElement>('reset').onclick = () => { dispose(); clearOutput(); el('status').textContent = draft ? 'Ready to compile' : loaded ? 'Ready' : 'Load package'; }; @@ -344,7 +355,7 @@ el<HTMLButtonElement>('load-release').onclick = () => { }; downloadButton.onclick = () => { if (draft || !loaded || !selection) return; - if (worker) { controls(true); deadline.reset(PACKAGE_ACTIVE_MS); clock(); worker.postMessage({ type: 'download' }); } + if (worker) { operation = 'downloading'; controls(true); deadline.reset(PACKAGE_ACTIVE_MS); clock(); worker.postMessage({ type: 'download' }); } else { downloadAfterLoad = true; newWorker(false, true); } }; function recoveryButtons(): void { @@ -427,7 +438,7 @@ compileButton.onclick = () => compileDraft(); function compileDraft(): void { if (!draft) return; preferredName = exportsInput.value || preferredName; - dispose(); clearOutput(); clearTreeExport(); exportsInput.replaceChildren(); + dispose(); operation = 'compiling'; clearOutput(); clearTreeExport(); exportsInput.replaceChildren(); try { sourceText(sourceEditor.value); saveDraft(); @@ -442,7 +453,7 @@ function compileDraft(): void { if (worker !== current) return; switch (data.type) { case 'compiled': - stopCompilerActivity(); pauseClock(); compiled = true; + stopCompilerActivity(); pauseClock(); operation = undefined; compiled = true; for (const name of data.names) { const option = document.createElement('option'); option.value = name; option.textContent = name; exportsInput.append(option); } if (data.names.includes(preferredName)) exportsInput.value = preferredName; controls(false); el('status').textContent = 'Compiled'; @@ -458,7 +469,7 @@ function compileDraft(): void { } case 'output': el('terminal').textContent += data.text; break; case 'done': - pauseClock(); pending = undefined; inputForm.hidden = true; nextButton.disabled = true; controls(false); el('status').textContent = 'Finished'; + pauseClock(); operation = undefined; pending = undefined; inputForm.hidden = true; nextButton.disabled = true; controls(false); el('status').textContent = 'Finished'; if (data.result.type === 'tree' || data.result.type === 'list') { el('result-inspection').hidden = false; el('graph-result').hidden = false; el('graph-result').textContent = JSON.stringify(data.result.value, null, 2); el('result').textContent = `Returned ${data.result.type}: tree graph`; } else el('result').textContent = `Returned ${data.result.type}: ${JSON.stringify(data.result.value)}`; el('compiler-stats').textContent = `${compilationStats} · ${data.stats.work.toLocaleString()} cumulative work · ${Number.isFinite(data.stats.fuel) ? `${data.stats.fuel.toLocaleString()} run work remaining` : 'unlimited run work'}`; @@ -473,11 +484,12 @@ function compileDraft(): void { } function runDraft(): void { if (!compiled || !worker) return; + operation = 'running'; try { const args = argumentsUI.read(), output = outputType(resultCodec.value); clearOutput(); controls(true); runClock(); el('status').textContent = 'Running'; worker.postMessage({ type: 'run', export: exportsInput.value, arguments: args, output, mode: mode.value, step: steps.checked }); - } catch (error) { el('result').textContent = error instanceof Error ? error.message : String(error); el('result').className = 'error'; } + } catch (error) { el('result').textContent = operationFailure(operation, error instanceof Error ? error.message : String(error)); el('result').className = 'error'; operation = undefined; } } const examples = { identity: { name: 'id', source: 'id = (x : x)', args: [{ type: 'number', value: '42' }], output: 'number', mode: 'pure' }, @@ -505,7 +517,7 @@ function loadFragment(): boolean { if (local) select(local); else { chooseProgram('hash'); - showRelease(); draft = false; scratch = false; originalSource = undefined; + showRelease(); draft = false; scratch = false; originalSource = undefined; sourceAvailable = false; dispose(); clearOutput(); clearTreeExport(); loaded = false; selected = undefined; argsInput.value = '[]'; argumentsUI.refresh(true); resultCodec.value = 'tree'; mode.value = 'pure'; selection = { hash }; hashInput.value = hash; newWorker(false); @@ -521,7 +533,7 @@ function packagePane(): boolean { return activePane() === 'playground' || active let initialized = false; async function initializePackages(): Promise<void> { if (initialized) return; - initialized = true; + initialized = true; operation = 'catalogue'; try { if (!globalThis.Worker || !globalThis.crypto?.subtle) throw new Error('Playground needs Workers and a secure context (HTTPS or localhost).'); const bytes = await fetchBytes(new URL('../catalogue.json', import.meta.url), 64_000); @@ -539,7 +551,7 @@ async function initializePackages(): Promise<void> { const indexBytes = await fetchBytes(new URL('../packages.json', import.meta.url), 1_048_576); releases = packageIndex(JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(indexBytes)) as unknown); populatePrograms(); recoveryButtons(); - if (!packagePane() || selection || draft) return; + if (!packagePane() || selection || draft) { operation = undefined; return; } if (!loadFragment()) { const initial = entries.find(e => e.id === 'intro') ?? entries[0]; if (!initial) throw new Error('Empty catalogue'); select(initial); } } catch (error) { initialized = false; loaded = false; finish(error instanceof Error ? error.message : String(error), true); } } @@ -547,7 +559,7 @@ window.addEventListener('pane-change', () => { // Moving between panels cancels active work, but retains verified selection and // completed exports. Move the single package chooser rather than mirror inputs. if (worker) { - if (busy) finish('Stopped. Start a new operation in the selected tab.', true); + if (busy) finish('Stopped. Start a new operation in the selected tab.', true, 'cancelled'); else if (!draft) { dispose(); el('status').textContent = loaded ? 'Ready' : 'Load package'; } } if (packagePane()) { diff --git a/ext/demo-site/src/demo-inputs.ts b/ext/demo-site/src/demo-inputs.ts @@ -8,6 +8,7 @@ export function demoInputLabels(id: string, definition: string): readonly string case 'shipping': return ['Destination', 'Cart subtotal (cents)', 'Package weight (grams)']; case 'events': return definition === 'vendorB' ? ['Event [customer refunded totalCents]'] : ['Event [eventName customer totalCents]']; case 'registration': return ['Name', 'Email', 'Age', 'Terms accepted (t = false, (t t) = true)']; + case 'parser': return ['Comma-separated lowercase names']; case 'migration': return ['Profile [name email]']; default: return []; } diff --git a/ext/demo-site/src/execution.ts b/ext/demo-site/src/execution.ts @@ -1,6 +1,6 @@ import { DEFAULT_BUDGET, type Budget, type CollectionPolicy } from './graph.js'; import { COMPILER_BUDGET } from './compiler.js'; -export const PACKAGE_ACTIVE_MS = 10_000; +export const PACKAGE_ACTIVE_MS = 60_000; export const EXPORT_ACTIVE_MS = 120_000; export const EXECUTION_COLLECTION = Object.freeze({ threshold: 250_000 }); export const PACKAGE_COLLECTION = Object.freeze({ threshold: 100_000 }); @@ -35,3 +35,18 @@ export function activeMilliseconds(settings: ExecutionSettings): number { export function collectionPolicy(budget: Readonly<Budget>, threshold: number = EXECUTION_COLLECTION.threshold): CollectionPolicy | undefined { return budget.nodes < 2 ? undefined : { threshold: Math.min(threshold, budget.nodes) }; } + +export type Operation = 'catalogue' | 'loading' | 'compiling' | 'running' | 'exporting' | 'downloading'; +const operationLabels: Readonly<Record<Operation, string>> = { + catalogue: 'Program catalogue loading', loading: 'Package loading', + compiling: 'Draft compilation', running: 'Program execution', + exporting: 'Definition export', downloading: 'Bundle download', +}; +export function operationFailure(operation: Operation | undefined, message: string): string { + return operation ? `${operationLabels[operation]} failed: ${message}` : message; +} +export function operationTimeout(operation: Operation): string { + return operation === 'loading' || operation === 'downloading' || operation === 'catalogue' + ? `${operationLabels[operation]} timed out. The deadline includes transfer and processing time; try again.` + : `${operationLabels[operation]} stopped: active time limit exceeded.`; +} diff --git a/ext/demo-site/test/browser.mjs b/ext/demo-site/test/browser.mjs @@ -302,6 +302,9 @@ try { }; assert.equal(await evaluate("document.getElementById('source-section').open"),true); assert.equal(await evaluate("document.getElementById('source-editor').readOnly"),true); + assert.equal(await evaluate("document.getElementById('source-summary').textContent"),'Source and editing'); + assert.equal(await evaluate("document.getElementById('source-title').textContent"),'Read and edit source'); + assert.equal(await evaluate("document.getElementById('source-availability').hidden"),true); await assertSourceFill(); assert.deepEqual(await evaluate("({ordered:document.getElementById('source-section').compareDocumentPosition(document.getElementById('try-program'))===Node.DOCUMENT_POSITION_FOLLOWING,traceInside:!!document.querySelector('#try-program .trace-panel'),compileInside:!!document.querySelector('#source-section #compile'),bundleVisible:!!document.querySelector('section.bundle-details'),statusRemoved:!document.getElementById('program-selection-status')})"),{ordered:true,traceInside:true,compileInside:true,bundleVisible:true,statusRemoved:true}); await evaluate("document.querySelector('#source-section>summary').click();document.querySelector('#try-program>summary').click()"); @@ -319,6 +322,32 @@ try { const value=await evaluate("document.getElementById('result').textContent"); assert.ok(value.startsWith('Returned record: '));return JSON.parse(value.slice('Returned record: '.length)); }; + await evaluate("document.getElementById('program-picker').value='demo:parser';document.getElementById('program-picker').dispatchEvent(new Event('change'))");await wait(status('Ready')); + assert.equal(await evaluate("document.getElementById('source-summary').textContent"),'Source (read-only)'); + assert.equal(await evaluate("document.getElementById('source-title').textContent"),'Read-only source'); + assert.equal(await evaluate("document.getElementById('edit-source').hidden"),true); + assert.equal(await evaluate("document.getElementById('compile').hidden"),true); + assert.equal(await evaluate("document.getElementById('source-editor').hidden"),true); + assert.match(await evaluate("document.getElementById('source').textContent"),/parserSepBy1/); + assert.match(await evaluate("document.getElementById('source-availability').textContent"),/reference only/); + await click('run');await wait(status('Finished')); + assert.deepEqual(await resultRecord(),{status:'ok',names:'red, green, blue',count:3,remaining:'',consumed:true,committed:true}); + for(const [definition,input,expected] of [ + ['parseNames','red,,blue',{status:'error',expected:'lowercase name',remaining:',blue',consumed:true,committed:true}], + ['parseNames','red,',{status:'error',expected:'lowercase name',remaining:'',consumed:true,committed:true}], + ['parsePrefix','red green',{status:'ok',names:'red',count:1,remaining:' green',consumed:true,committed:false}], + ]){ + await evaluate(`(()=>{document.getElementById('export').value=${JSON.stringify(definition)};document.getElementById('export').dispatchEvent(new Event('change'));const field=document.querySelector('#argument-fields textarea');field.value=${JSON.stringify(JSON.stringify(input))};field.dispatchEvent(new Event('input'));})()`); + await click('run');await wait(status('Finished'));assert.deepEqual(await resultRecord(),expected); + } + await evaluate("document.getElementById('program-picker').value='package:arboricx-server';document.getElementById('program-picker').dispatchEvent(new Event('change'))"); + await click('load-release');await wait(status('Ready')); + assert.equal(await evaluate("document.getElementById('source-summary').textContent"),'Package details'); + assert.match(await evaluate("document.getElementById('release-info').textContent"),/native sockets and filesystem/); + await evaluate("document.getElementById('export').value='arboricxServer';document.getElementById('export').dispatchEvent(new Event('change'));document.getElementById('arguments').value=JSON.stringify([{type:'string',value:'/unused'},{type:'string',value:'127.0.0.1'},{type:'number',value:'9050'}]);document.getElementById('mode').value='io'"); + await click('run');await wait(status('Stopped')); + assert.match(await evaluate("document.getElementById('result').textContent"),/^Program execution failed: Invalid canonical number/,'native action exports are not standalone browser tricuIO programs'); + console.log('PASS parser demo / complete vs prefix parsing / committed failures / read-only source / native server action is not executable as browser IO'); for(const [demo,exports] of [['shipping',['v1','v2']],['events',['vendorA','vendorB']],['registration',['main']],['migration',['v1','v2']]]){ await evaluate(`document.getElementById('program-picker').value='demo:${demo}';document.getElementById('program-picker').dispatchEvent(new Event('change'))`);await wait(status('Ready')); assert.equal(await evaluate("document.getElementById('source-section').open"),true,'every selected demo starts with readable source'); @@ -544,6 +573,11 @@ try { await evaluate("document.getElementById('program-picker').value='package:tricu-base';document.getElementById('program-picker').dispatchEvent(new Event('change'));document.getElementById('package-version').value='0.1.0'"); await click('load-release');await wait(status('Ready')); assert.equal(await evaluate("document.getElementById('source-editor').hidden"),true,'library release is not a curated editable demo'); + assert.equal(await evaluate("document.getElementById('source-summary').textContent"),'Package details'); + assert.equal(await evaluate("document.getElementById('source-title').textContent"),'Compiled package (no editable source)'); + assert.equal(await evaluate("document.getElementById('edit-source').hidden"),true); + assert.equal(await evaluate("document.getElementById('source-availability').hidden"),false); + assert.match(await evaluate("document.getElementById('source-availability').textContent"),/Editable source is not included/); await evaluate("document.getElementById('export').value='showNumber';document.getElementById('export').dispatchEvent(new Event('change'));document.getElementById('arguments').value='[{\"type\":\"number\",\"value\":\"42\"}]';document.getElementById('output-type').value='string';document.getElementById('mode').value='pure'"); await click('run');await wait(status('Finished')); assert.equal(await evaluate("document.getElementById('result').textContent"),'Returned string: "42"'); @@ -747,6 +781,38 @@ try { assert.equal(await evaluate("document.getElementById('compile').disabled"),false); console.log('PASS compiler syntax/name diagnostics / descriptor and pin integrity / source cap / replacement / stop/reset/profile toggle'); + // Stall real operation messages and advance the clock only at its scheduled + // 60-second boundary, avoiding a minute of test time per load/download case. + const loadTimeoutScript=await send('Page.addScriptToEvaluateOnNewDocument',{source:` + const NativeWorker=globalThis.Worker,nativeTimer=globalThis.setTimeout,nativeNow=performance.now.bind(performance); + let elapsed=0; + Object.defineProperty(performance,'now',{value:()=>nativeNow()+elapsed}); + globalThis.setTimeout=(fn,delay,...args)=>globalThis.timeoutOperation&&delay===60000 + ? nativeTimer(()=>{elapsed+=60000;fn(...args);},50):nativeTimer(fn,delay,...args); + globalThis.Worker=class extends NativeWorker{ + postMessage(message){if(message.type===globalThis.timeoutOperation){this.stalled=true;return;}super.postMessage(message);} + terminate(){if(this.stalled)globalThis.stalledWorkerTerminated=true;super.terminate();} + };`}); + await send('Page.reload');await openPlayground(); + for(const pane of ['tab-playground','tab-export-definition']){ + await click(pane); + await evaluate("globalThis.timeoutOperation='load';globalThis.stalledWorkerTerminated=false;document.getElementById('program-picker').value='demo:greet';document.getElementById('program-picker').dispatchEvent(new Event('change'))"); + await wait(status('Stopped')); + const target=pane==='tab-playground'?'result':'tree-provenance'; + assert.match(await evaluate(`document.getElementById('${target}').textContent`),/^Package loading timed out\./); + assert.equal(await evaluate('globalThis.stalledWorkerTerminated'),true); + assert.equal(await evaluate("document.getElementById('run').disabled"),true); + assert.equal(await evaluate("document.getElementById('export-tree').disabled"),true); + await evaluate("globalThis.timeoutOperation=undefined;document.getElementById('program-picker').value='demo:intro';document.getElementById('program-picker').dispatchEvent(new Event('change'))");await wait(status('Ready')); + } + await click('tab-playground'); + await evaluate("globalThis.timeoutOperation='download';globalThis.stalledWorkerTerminated=false"); + await click('download-bundle');await wait(status('Stopped')); + assert.match(await evaluate("document.getElementById('result').textContent"),/^Bundle download timed out\./); + assert.equal(await evaluate('globalThis.stalledWorkerTerminated'),true); + await send('Page.removeScriptToEvaluateOnNewDocument',{identifier:loadTimeoutScript.identifier}); + console.log('PASS loading deadlines in Playground/Export with guards off / download deadline / phase-specific messages / worker termination'); + // A genuinely CPU-bound Worker gets a finite active timeout. No production hook. const watchdogScript = await send('Page.addScriptToEvaluateOnNewDocument', { source: `const NativeWorker = globalThis.Worker; @@ -760,12 +826,12 @@ try { await send('Page.reload'); await openPlayground(); await evaluate("document.getElementById('uncapped').checked=false;document.getElementById('uncapped').dispatchEvent(new Event('change'))");await wait(status('Ready')); await click('run'); await wait(status('Stopped'), 13); - assert.match(await evaluate("document.getElementById('result').textContent"), /time limit exceeded/); + assert.match(await evaluate("document.getElementById('result').textContent"), /Program execution stopped: active time limit exceeded/); assert.equal(await evaluate('globalThis.testWorkerTerminated'), true); console.log('PASS hard main-page watchdog / termination of CPU-bound Worker'); await evaluate("document.getElementById('package-limit-seconds').value='1';document.querySelector('#playground .limits-editor').requestSubmit()");await wait(status('Ready')); await click('run');await wait(status('Stopped'),3); - assert.match(await evaluate("document.getElementById('result').textContent"),/time limit exceeded/); + assert.match(await evaluate("document.getElementById('result').textContent"),/Program execution stopped: active time limit exceeded/); await evaluate("document.querySelector('#playground .limits-editor button[type=button]').click()");await wait(status('Ready')); console.log('PASS editable package active-second deadline'); @@ -780,7 +846,7 @@ try { await click('export-tree'); await new Promise(resolve=>setTimeout(resolve,11_000)); assert.equal(await evaluate("document.getElementById('stop-tree').disabled"),false,'export remains active beyond old 10-second deadline'); - await wait("document.getElementById('tree-provenance').textContent.includes('time limit exceeded')",115); + await wait("document.getElementById('tree-provenance').textContent.includes('Definition export stopped: active time limit exceeded')",115); assert.equal(await evaluate('globalThis.testWorkerTerminated'),true); assert.equal(await evaluate("document.getElementById('copy-tree').disabled"),true); await click('export-tree'); await click('stop-tree'); @@ -817,7 +883,7 @@ try { console.log('PASS leaving compiler pane terminates active compilation'); await evaluate("document.getElementById('compiler-uncapped').checked=false;document.getElementById('compiler-uncapped').dispatchEvent(new Event('change'));document.getElementById('compiler-limit-seconds').value='1';document.querySelector('#compiler-settings .limits-editor').requestSubmit()"); await click('compile');await wait(compilerStatus('Stopped'),3); - assert.match(await evaluate("document.getElementById('result').textContent"),/time limit exceeded/); + assert.match(await evaluate("document.getElementById('result').textContent"),/Draft compilation stopped: active time limit exceeded/); await evaluate("document.querySelector('#compiler-settings .limits-editor button[type=button]').click()"); assert.equal(await evaluate("document.getElementById('compiler-limit-seconds').value"),'0'); console.log('PASS editable compiler active-second deadline / restore unlimited default'); diff --git a/ext/demo-site/test/execution.test.mjs b/ext/demo-site/test/execution.test.mjs @@ -1,7 +1,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import {Graph} from '../dist/graph.js'; -import {executionSettings,executionBudget,activeMilliseconds,collectionPolicy,defaultLimits} from '../dist/execution.js'; +import {executionSettings,executionBudget,activeMilliseconds,collectionPolicy,defaultLimits,operationFailure,operationTimeout,PACKAGE_ACTIVE_MS} from '../dist/execution.js'; import {ActiveClock} from '../dist/clock.js'; test('editable limits preserve defaults, validated snapshots and adaptive GC',()=>{ @@ -74,3 +74,18 @@ test('operation work resets preserve cumulative counters, handles and memory cap const unlimited=new Graph({fuel:Infinity});unlimited.tick();unlimited.beginOperation(); assert.equal(unlimited.stats.fuel,Infinity);assert.equal(unlimited.stats.work,1); }); + +test('operation failures and deadlines identify the actual phase, not generic execution',()=>{ + assert.equal(PACKAGE_ACTIVE_MS,60000); + const labels={catalogue:'Program catalogue loading',loading:'Package loading',compiling:'Draft compilation',running:'Program execution',exporting:'Definition export',downloading:'Bundle download'}; + for(const [operation,label] of Object.entries(labels)){ + assert.equal(operationFailure(operation,'SHA-256 mismatch'),`${label} failed: SHA-256 mismatch`); + const timeout=operationTimeout(operation); + assert.ok(timeout.startsWith(label)); + if(['catalogue','loading','downloading'].includes(operation)){ + assert.match(timeout,/timed out/);assert.doesNotMatch(timeout,/active time/); + }else assert.match(timeout,/active time limit exceeded/); + } + assert.equal(operationFailure(undefined,'Invalid literal'),'Invalid literal'); + assert.equal(new Set(Object.keys(labels).map(operationTimeout)).size,6); +}); diff --git a/ext/demo-site/test/graph.test.mjs b/ext/demo-site/test/graph.test.mjs @@ -7,6 +7,7 @@ import { natural, readNatural, text, readText, line, literals, makeLiteral, view import { runIO } from '../dist/io.js'; import { decodeBundle, loadBundle } from '../dist/bundle.js'; const data = JSON.parse(await readFile(new URL('../demos.json', import.meta.url))); +data.demos.parser=JSON.parse(await readFile(new URL('../parser-demo.json',import.meta.url))); function host(input = '') { const state = { terminal: '', trace: [] }; return { state, before: async event => state.trace.push(event), after() {}, running() {}, diff --git a/ext/demo-site/test/packages.test.mjs b/ext/demo-site/test/packages.test.mjs @@ -16,6 +16,7 @@ test('named versions and demo environments are validated and pinned',async()=>{ assert.equal(environments.compiler,compiler.hash); for(const entry of catalogue){ const release=releases.find(r=>r.demo===entry.id&&r.hash===entry.hash);assert.ok(release); + if(entry.environment===undefined){assert.equal(entry.preparation,'native');continue;} const {profile}=environmentProfile(environments,entry.environment); if(profile){ const bytes=await read('bundles/'+profile.hash+'.arboricx'); @@ -64,3 +65,35 @@ test('published native libraries have authenticated complete interfaces and no f assert.deepEqual([...loadBundle(new Graph(),decodeBundle(bytes)).keys()].sort(),names.sort()); } }); + +test('showcase packages retain browser-bounded complete library roots and native-only server identity',async()=>{ + const releases=packageIndex(JSON.parse(await read('packages.json'))); + for(const [name,required] of Object.entries({ + 'arboricx-server':['arboricxServer','arboricxRouter','bundleHashValid?'], + 'tricu-http':['parseRequestLine','response','httpHandlerIO'], + 'tricu-intensional-contracts':['fullTree?','powerOfTwo?','sortedList?'], + 'tricu-graphs':['graphSort','graphCycle'], + 'tricu-parser':['parserBind','parserCommit','parserMatchReply','parserEof'], + 'tricu-formatter':['formatTri'], + })){ + const release=releases.find(r=>r.name===name);assert.ok(release);assert.equal(release.demo,undefined); + const bytes=await read('bundles/'+release.hash+'.arboricx'); + assert.equal(createHash('sha256').update(bytes).digest('hex'),release.hash); + assert.ok(bytes.length<=1048576); + const bundle=decodeBundle(bytes);assert.ok(bundle.nodes.length<=100000); + const exports=loadBundle(new Graph(),bundle); + for(const name of required)assert.ok(exports.has(name),name); + if(release.name==='arboricx-server')assert.match(release.title,/native sockets and filesystem/); + } +}); + +test('native parser demo source and goldens are authenticated without claiming a browser compile environment',async()=>{ + const catalogue=JSON.parse(await read('catalogue.json')),demo=catalogue.find(e=>e.id==='parser'); + const goldens=JSON.parse(await read('parser-demo.json')); + assert.equal(demo.preparation,'native');assert.equal(demo.environment,undefined); + assert.equal(createHash('sha256').update(demo.source).digest('hex'),demo.sourceSha256); + assert.equal(demo.source,await read('demos/parser.tri').then(b=>b.toString())); + assert.equal(goldens.hash,demo.hash);assert.equal(goldens.sourceSha256,demo.sourceSha256); + assert.ok(goldens.haskellCases.some(c=>c.record.status==='error'&&c.record.committed)); + assert.ok(goldens.haskellCases.some(c=>c.export==='parsePrefix'&&c.record.status==='ok'&&c.record.remaining)); +}); diff --git a/ext/demo-site/test/showcase-preparation.test.mjs b/ext/demo-site/test/showcase-preparation.test.mjs @@ -0,0 +1,51 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {execFileSync} from 'node:child_process'; +import {fileURLToPath} from 'node:url'; +import {readFile} from 'node:fs/promises'; + +test('showcase staging rejects immutable/duplicate conflicts before discovery or assets',()=>{ + const site=fileURLToPath(new URL('../',import.meta.url)); + const output=execFileSync('python3',['-c',String.raw` +import importlib.util,json,tempfile +from pathlib import Path +site=Path(__import__('sys').argv[1]) +spec=importlib.util.spec_from_file_location('showcase',site/'prepare-showcase.py') +m=importlib.util.module_from_spec(spec);spec.loader.exec_module(m) +index=json.loads((site/'packages.json').read_text()) +releases=[r for r in index['releases'] if r['name'] in ['tricu-graphs','parser-demo']] +demo=next(e for e in json.loads((site/'catalogue.json').read_text()) if e['id']=='parser') +goldens=json.loads((site/'parser-demo.json').read_text()) +artifacts={r['hash']:site/'bundles'/(r['hash']+'.arboricx') for r in releases} +with tempfile.TemporaryDirectory() as tmp: + root=Path(tmp);(root/'bundles').mkdir() + (root/'catalogue.json').write_text('[]\n') + bad=dict(releases[0],hash='0'*64) + for rows in [[bad],[releases[0],releases[0]]]: + before=json.dumps(dict(schema=1,releases=rows)) + (root/'packages.json').write_text(before) + try:m.stage(root,artifacts,releases,demo,goldens) + except ValueError:pass + else:raise AssertionError('conflicting index accepted') + assert (root/'packages.json').read_text()==before + assert (root/'catalogue.json').read_text()=='[]\n' + assert not list((root/'bundles').iterdir()) + (root/'packages.json').write_text(json.dumps(dict(schema=1,releases=[]))) + m.stage(root,artifacts,releases,demo,goldens) + assert json.loads((root/'catalogue.json').read_text())==[demo] + assert len(json.loads((root/'packages.json').read_text())['releases'])==2 + for pin,path in artifacts.items():assert (root/'bundles'/(pin+'.arboricx')).read_bytes()==path.read_bytes() + m.stage(root,artifacts,releases,demo,goldens) # Exact retry is idempotent. + assert len(json.loads((root/'packages.json').read_text())['releases'])==2 +print('accepted') +`,site],{encoding:'utf8',timeout:15000,maxBuffer:100000}); + assert.equal(output.trim(),'accepted'); +}); + +test('historical browser preparation preserves native demos without assigning an editable environment',async()=>{ + const prepare=await readFile(new URL('../prepare.py',import.meta.url),'utf8'); + const index=await readFile(new URL('../prepare-index.py',import.meta.url),'utf8'); + assert.match(prepare,/catalogue\.extend\(entry for entry in previous if entry\.get\('preparation'\) == 'native'/); + assert.match(index,/if entry\.get\('preparation'\) == 'native':[\s\S]*?continue/); + assert.match(index,/Native demo has no accepted release/); +}); diff --git a/ext/demo-site/test/site.test.mjs b/ext/demo-site/test/site.test.mjs @@ -40,7 +40,7 @@ test('shared pane has accessible tabs and execution settings in both runners',as const {executionSettings,executionBudget,PACKAGE_ACTIVE_MS,EXPORT_ACTIVE_MS}=await import('../dist/execution.js'); const budget=executionBudget(executionSettings(undefined,'compiler',true)); assert.equal(budget.fuel,Infinity);assert.equal(budget.nodes,Infinity);assert.equal(budget.stack,Infinity); - assert.equal(PACKAGE_ACTIVE_MS,10000);assert.equal(EXPORT_ACTIVE_MS,120000); + assert.equal(PACKAGE_ACTIVE_MS,60000);assert.equal(EXPORT_ACTIVE_MS,120000); }); test('header keeps Source; footer attribution matches README authors and resources',async()=>{ @@ -120,7 +120,7 @@ test('primary workspace is compact and guards-off defaults stay inside Advanced' for(const id of ['program-release','program-hash'])assert.match(html,new RegExp(`id="${id}"`)); assert.match(html,/Bundle hash \(SHA-256\)/); assert.match(html,/>Load bundle<\/button>/); - const advanced=html.slice(html.indexOf('<details class="run-tools">'),html.indexOf('<p class="hint"><a href="#export-definition"')); + const advanced=html.slice(html.indexOf('<details class="run-tools">'),html.indexOf('<details class="trace-panel"')); for(const id of ['uncapped','compiler-uncapped'])assert.match(advanced,new RegExp(`id="${id}" type="checkbox" checked`)); assert.match(advanced,/id="mode"/); for(const id of ['argument-fields','add-argument','raw-arguments'])assert.match(html,new RegExp(`id="${id}"`)); @@ -138,6 +138,7 @@ test('source-first workspace has full-width source and explicit compilation',asy assert.match(html,/<details id="try-program"[^>]* open>/); assert.ok(html.indexOf('class="trace-panel"')>html.indexOf('id="try-program"')); assert.ok(html.indexOf('class="trace-panel"')<html.indexOf('id="export-definition"')); + assert.ok(html.indexOf('class="trace-panel"')<html.indexOf('>Export this definition →</a>')); assert.match(html,/<details id="source-section"/); assert.match(html,/id="source-editor"[^>]*readonly/); assert.match(html,/id="edit-source"/);