prolog-notebook 0.1.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +233 -0
- package/README.md +131 -28
- package/bin/prolog-notebook.mjs +206 -0
- package/package.json +19 -4
- package/src/browser.js +252 -16
- package/src/build-info.js +94 -0
- package/src/build-info.json +5 -0
- package/src/clauses.js +236 -0
- package/src/engine.js +367 -18
- package/src/export.js +126 -0
- package/src/format.js +649 -0
- package/src/node.js +14 -4
- package/src/notebook.css +322 -1
- package/src/notebook.js +1261 -56
- package/src/page.js +85 -0
- package/src/render.js +367 -0
- package/src/run.js +128 -0
- package/src/session.js +227 -0
- package/src/version.js +51 -0
- package/src/worker.js +77 -0
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// The command line. Thin on purpose: argument parsing, files, and words for a
|
|
3
|
+
// terminal. Everything it does lives in src/run.js and src/export.js, so a VS
|
|
4
|
+
// Code "run all" and a future --check get the same behaviour without going
|
|
5
|
+
// through a shell (869ectt38, 869ectt3e).
|
|
6
|
+
import { createRequire } from 'node:module';
|
|
7
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
8
|
+
import { basename } from 'node:path';
|
|
9
|
+
import { parse, NotebookError } from '../src/format.js';
|
|
10
|
+
import { prologVersion } from '../src/engine.js';
|
|
11
|
+
import { buildLine, currentBuild } from '../src/build-info.js';
|
|
12
|
+
import { banner } from '../src/version.js';
|
|
13
|
+
import { exportSource } from '../src/export.js';
|
|
14
|
+
import { runNotebook, DEFAULT_LIMIT } from '../src/run.js';
|
|
15
|
+
|
|
16
|
+
// The engine is imported WHERE IT IS USED, never at the top. src/node.js pulls in
|
|
17
|
+
// 5.9 MB of WebAssembly at module scope, so a static import here would mean that
|
|
18
|
+
// `--help` on a broken install fails before it can print anything — and the two
|
|
19
|
+
// commands most likely to be typed at a broken install are --help and --version.
|
|
20
|
+
const engine = () => import('../src/node.js');
|
|
21
|
+
|
|
22
|
+
// `prolog-notebook run --stdout file | head` closes the pipe while we are still
|
|
23
|
+
// writing to it. That is the reader using the shell correctly, not an error, and
|
|
24
|
+
// a command that answers it with an unhandled EPIPE and a stack trace is
|
|
25
|
+
// complaining about being used properly.
|
|
26
|
+
for (const stream of [process.stdout, process.stderr]) {
|
|
27
|
+
stream.on('error', (e) => {
|
|
28
|
+
if (e.code !== 'EPIPE') throw e;
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Only for swipl-wasm's own version: everything about THIS package is in
|
|
33
|
+
// src/version.js, where a page can import it too.
|
|
34
|
+
const require = createRequire(import.meta.url);
|
|
35
|
+
|
|
36
|
+
const USAGE = `prolog-notebook — Jupyter-style notebooks for Prolog
|
|
37
|
+
|
|
38
|
+
prolog-notebook run <file.prolog.md>... run every cell, write the answers back
|
|
39
|
+
|
|
40
|
+
Options
|
|
41
|
+
--limit <n> solutions to take from one query before stopping (default ${DEFAULT_LIMIT})
|
|
42
|
+
--stdout print the result instead of writing the file
|
|
43
|
+
--quiet report only failures
|
|
44
|
+
--version version, engine and copyright
|
|
45
|
+
-h, --help this
|
|
46
|
+
|
|
47
|
+
A query that stops at the limit is written without a terminator, which is the
|
|
48
|
+
format's way of saying the search was never exhausted. Nothing is invented.
|
|
49
|
+
`;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* A runaway goal hangs this process — the engine is in-process here, so there is
|
|
53
|
+
* no thread left to notice (869ejgyax). Stated rather than implied, because the
|
|
54
|
+
* moment this runs a file someone else wrote it stops being an annoyance.
|
|
55
|
+
*/
|
|
56
|
+
const RUNAWAY_WARNING = 'note: a non-terminating goal will hang this command; it has no timeout yet (869ejgyax)';
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Who this is, and — the part that is not on anyone's disk — which Prolog it
|
|
60
|
+
* will run your chapters with.
|
|
61
|
+
*
|
|
62
|
+
* THE ENGINE LINE EARNS ITS 59 MILLISECONDS. swipl-wasm's own version says
|
|
63
|
+
* nothing about SWI's: 8.0.4 ships 10.1.10. A notebook's saved answers are only
|
|
64
|
+
* true of the engine that produced them, so this is the one fact here that a
|
|
65
|
+
* reader could not have looked up.
|
|
66
|
+
*
|
|
67
|
+
* An engine that will not load is REPORTED, not fatal. "I cannot start Prolog"
|
|
68
|
+
* is exactly what someone running --version to diagnose a broken install needs
|
|
69
|
+
* to be told, and exiting non-zero would hide it behind a shell error.
|
|
70
|
+
*/
|
|
71
|
+
async function version() {
|
|
72
|
+
// The same line the page shows in its panel: src/version.js, imported by both.
|
|
73
|
+
const lines = [banner()];
|
|
74
|
+
try {
|
|
75
|
+
const { createSession } = await engine();
|
|
76
|
+
const swipl = await prologVersion(await createSession());
|
|
77
|
+
const wasm = `swipl-wasm ${require('swipl-wasm/package.json').version}`;
|
|
78
|
+
lines.push(swipl ? `Powered by SWI-Prolog ${swipl}, ${wasm}` : `Powered by ${wasm}`);
|
|
79
|
+
} catch (e) {
|
|
80
|
+
// Not "powered by" anything, so it does not say so. Someone running this to
|
|
81
|
+
// find out why nothing works needs the reason, not a formula.
|
|
82
|
+
lines.push(`SWI-Prolog could not be started: ${e.message}`);
|
|
83
|
+
}
|
|
84
|
+
// Omitted rather than guessed at when there is neither a baked file nor a git
|
|
85
|
+
// repository — a line that says "unknown" three times is worse than no line.
|
|
86
|
+
lines.push(buildLine(currentBuild()));
|
|
87
|
+
// The blank line is deliberate: this is a banner, and a banner that runs into
|
|
88
|
+
// the next shell prompt reads as an error message.
|
|
89
|
+
return `${lines.join('\n')}\n\n`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function main(argv) {
|
|
93
|
+
const args = argv.slice(2);
|
|
94
|
+
if (!args.length || args.includes('-h') || args.includes('--help')) {
|
|
95
|
+
process.stdout.write(USAGE);
|
|
96
|
+
return 0;
|
|
97
|
+
}
|
|
98
|
+
if (args.includes('--version') || args.includes('-V')) {
|
|
99
|
+
process.stdout.write(await version());
|
|
100
|
+
return 0;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const command = args.shift();
|
|
104
|
+
if (command !== 'run') {
|
|
105
|
+
process.stderr.write(`unknown command "${command}"\n\n${USAGE}`);
|
|
106
|
+
return 2;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const options = { limit: DEFAULT_LIMIT, stdout: false, quiet: false };
|
|
110
|
+
const files = [];
|
|
111
|
+
while (args.length) {
|
|
112
|
+
const arg = args.shift();
|
|
113
|
+
if (arg === '--limit') {
|
|
114
|
+
const value = Number(args.shift());
|
|
115
|
+
if (!Number.isInteger(value) || value < 1) {
|
|
116
|
+
process.stderr.write('--limit takes a positive whole number\n');
|
|
117
|
+
return 2;
|
|
118
|
+
}
|
|
119
|
+
options.limit = value;
|
|
120
|
+
} else if (arg === '--stdout') options.stdout = true;
|
|
121
|
+
else if (arg === '--quiet') options.quiet = true;
|
|
122
|
+
else if (arg.startsWith('-')) {
|
|
123
|
+
process.stderr.write(`unknown option "${arg}"\n\n${USAGE}`);
|
|
124
|
+
return 2;
|
|
125
|
+
} else files.push(arg);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (!files.length) {
|
|
129
|
+
process.stderr.write('run needs at least one file\n');
|
|
130
|
+
return 2;
|
|
131
|
+
}
|
|
132
|
+
if (!options.quiet) process.stderr.write(`${RUNAWAY_WARNING}\n`);
|
|
133
|
+
|
|
134
|
+
// One engine for the whole invocation, restarted between files. A notebook is
|
|
135
|
+
// a world of its own — one cell is one virtual file, and two chapters may
|
|
136
|
+
// define the same predicate — so carrying clauses across would let a file pass
|
|
137
|
+
// because of what the file before it happened to load.
|
|
138
|
+
const { createSession } = await engine();
|
|
139
|
+
const session = await createSession();
|
|
140
|
+
let status = 0;
|
|
141
|
+
|
|
142
|
+
for (const file of files) {
|
|
143
|
+
await session.restart();
|
|
144
|
+
status = Math.max(status, await runFile(file, session, options));
|
|
145
|
+
}
|
|
146
|
+
return status;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function runFile(file, session, options) {
|
|
150
|
+
const name = basename(file);
|
|
151
|
+
let notebook;
|
|
152
|
+
let source;
|
|
153
|
+
try {
|
|
154
|
+
source = readFileSync(file, 'utf8');
|
|
155
|
+
notebook = parse(source);
|
|
156
|
+
} catch (e) {
|
|
157
|
+
// The parser's line numbers are the file's own, so its message is already
|
|
158
|
+
// the most useful thing anyone could say here.
|
|
159
|
+
process.stderr.write(`${file}: ${e instanceof NotebookError ? e.message : e.message}\n`);
|
|
160
|
+
return 1;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const { edits, failures, warnings } = await runNotebook(notebook, session, {
|
|
164
|
+
limit: options.limit,
|
|
165
|
+
onCell: (event) => {
|
|
166
|
+
if (options.quiet) return;
|
|
167
|
+
if (event.kind === 'program') {
|
|
168
|
+
process.stderr.write(` ${event.ok ? '✓' : '✗'} ${event.id}\n`);
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
const answers = event.error
|
|
172
|
+
? `error: ${event.error}`
|
|
173
|
+
: `${event.solutions.length} solution${event.solutions.length === 1 ? '' : 's'}`
|
|
174
|
+
+ (event.truncated ? ` (stopped at ${options.limit}, not exhausted)` : '');
|
|
175
|
+
process.stderr.write(` ${event.error ? '✗' : '✓'} ${event.id} — ${answers}\n`);
|
|
176
|
+
},
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
for (const warning of warnings) process.stderr.write(` ! ${warning.id}: ${warning.text}\n`);
|
|
180
|
+
|
|
181
|
+
if (failures.length) {
|
|
182
|
+
// NOTHING IS WRITTEN when a program cell failed to load. Every answer below
|
|
183
|
+
// it was produced against a chapter that does not exist, and writing those
|
|
184
|
+
// into the file would publish them as though they did.
|
|
185
|
+
for (const failure of failures) {
|
|
186
|
+
process.stderr.write(`${file}: cell ${failure.id} did not load: ${failure.error}\n`);
|
|
187
|
+
}
|
|
188
|
+
process.stderr.write(`${name}: not written\n`);
|
|
189
|
+
return 1;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const text = exportSource(notebook, edits);
|
|
193
|
+
if (options.stdout) {
|
|
194
|
+
process.stdout.write(text);
|
|
195
|
+
return 0;
|
|
196
|
+
}
|
|
197
|
+
if (text === source) {
|
|
198
|
+
if (!options.quiet) process.stderr.write(`${name}: unchanged\n`);
|
|
199
|
+
return 0;
|
|
200
|
+
}
|
|
201
|
+
writeFileSync(file, text);
|
|
202
|
+
if (!options.quiet) process.stderr.write(`${name}: written\n`);
|
|
203
|
+
return 0;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
process.exitCode = await main(process.argv);
|
package/package.json
CHANGED
|
@@ -1,16 +1,27 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "prolog-notebook",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Jupyter-style notebooks for Prolog. Runs in the browser, installs nothing.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/node.js",
|
|
7
|
+
"bin": {
|
|
8
|
+
"prolog-notebook": "./bin/prolog-notebook.mjs"
|
|
9
|
+
},
|
|
7
10
|
"exports": {
|
|
8
11
|
".": "./src/node.js",
|
|
9
12
|
"./browser": "./src/browser.js",
|
|
10
13
|
"./engine": "./src/engine.js",
|
|
11
|
-
"./
|
|
14
|
+
"./format": "./src/format.js",
|
|
15
|
+
"./notebook.css": "./src/notebook.css",
|
|
16
|
+
"./page": "./src/page.js",
|
|
17
|
+
"./render": "./src/render.js",
|
|
18
|
+
"./session": "./src/session.js",
|
|
19
|
+
"./worker": "./src/worker.js",
|
|
20
|
+
"./run": "./src/run.js",
|
|
21
|
+
"./export": "./src/export.js"
|
|
12
22
|
},
|
|
13
23
|
"files": [
|
|
24
|
+
"bin",
|
|
14
25
|
"src",
|
|
15
26
|
"README.md",
|
|
16
27
|
"LICENSE",
|
|
@@ -18,7 +29,7 @@
|
|
|
18
29
|
],
|
|
19
30
|
"scripts": {
|
|
20
31
|
"test": "node --test test/*.test.mjs",
|
|
21
|
-
"
|
|
32
|
+
"dev": "python3 -m http.server 8777",
|
|
22
33
|
"prepublishOnly": "npm test"
|
|
23
34
|
},
|
|
24
35
|
"keywords": [
|
|
@@ -45,9 +56,13 @@
|
|
|
45
56
|
"node": ">=18"
|
|
46
57
|
},
|
|
47
58
|
"dependencies": {
|
|
48
|
-
"
|
|
59
|
+
"markdown-it": "^15.0.0",
|
|
60
|
+
"swipl-wasm": "8.0.7"
|
|
49
61
|
},
|
|
50
62
|
"publishConfig": {
|
|
51
63
|
"access": "public"
|
|
64
|
+
},
|
|
65
|
+
"devDependencies": {
|
|
66
|
+
"jsdom": "^29.1.1"
|
|
52
67
|
}
|
|
53
68
|
}
|
package/src/browser.js
CHANGED
|
@@ -1,25 +1,261 @@
|
|
|
1
|
-
// Browser entry point. The
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
|
|
1
|
+
// Browser entry point. The engine runs in a Web Worker, so a query that never
|
|
2
|
+
// terminates costs the reader a click on Stop rather than the whole tab.
|
|
3
|
+
//
|
|
4
|
+
// The 5.9 MB swipl bundle is still fetched by the page's own URL rather than by a
|
|
5
|
+
// bundler — the worker is told where to find it.
|
|
6
|
+
import { ConsultLog, defaultCellName, unconsult } from './session.js';
|
|
5
7
|
|
|
6
8
|
export * from './engine.js';
|
|
9
|
+
export { ConsultLog } from './session.js';
|
|
7
10
|
|
|
8
|
-
|
|
11
|
+
const DEFAULT_SWIPL_URL = new URL(
|
|
12
|
+
'../node_modules/swipl-wasm/dist/swipl/swipl-bundle.js',
|
|
13
|
+
import.meta.url
|
|
14
|
+
).href;
|
|
9
15
|
|
|
10
16
|
/**
|
|
11
|
-
*
|
|
12
|
-
*
|
|
17
|
+
* A session whose engine lives in a worker.
|
|
18
|
+
*
|
|
19
|
+
* Every method returns a promise. `abort()` terminates the worker outright and
|
|
20
|
+
* replays the consult log into a new one, which is the only thing that works: a
|
|
21
|
+
* thread blocked inside WASM cannot be asked politely to stop.
|
|
13
22
|
*/
|
|
14
|
-
export
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
23
|
+
export class WorkerSession {
|
|
24
|
+
#worker = null;
|
|
25
|
+
#pending = new Map();
|
|
26
|
+
#nextId = 1;
|
|
27
|
+
#booting = null;
|
|
28
|
+
/** The one query allowed to hold a frame. See supersede(). */
|
|
29
|
+
#open = null;
|
|
30
|
+
|
|
31
|
+
constructor({ workerUrl, swiplUrl = DEFAULT_SWIPL_URL, engineUrl, options = {} } = {}) {
|
|
32
|
+
this.workerUrl = workerUrl ?? new URL('./worker.js', import.meta.url).href;
|
|
33
|
+
this.swiplUrl = swiplUrl;
|
|
34
|
+
// The worker imports the engine itself, so it needs an absolute URL: a
|
|
35
|
+
// relative specifier would resolve against the worker script, which may have
|
|
36
|
+
// been served from anywhere.
|
|
37
|
+
this.engineUrl = engineUrl ?? new URL('./engine.js', import.meta.url).href;
|
|
38
|
+
this.options = options;
|
|
39
|
+
this.log = new ConsultLog();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Boot the worker and the engine inside it. Idempotent. */
|
|
43
|
+
async start() {
|
|
44
|
+
if (this.#worker) return this;
|
|
45
|
+
if (this.#booting) return this.#booting;
|
|
46
|
+
this.#booting = (async () => {
|
|
47
|
+
this.#spawn();
|
|
48
|
+
await this.#send('boot', {
|
|
49
|
+
swiplUrl: this.swiplUrl,
|
|
50
|
+
engineUrl: this.engineUrl,
|
|
51
|
+
options: this.options,
|
|
52
|
+
});
|
|
53
|
+
this.#booting = null;
|
|
54
|
+
return this;
|
|
55
|
+
})();
|
|
56
|
+
return this.#booting;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async consult(text, name = defaultCellName()) {
|
|
60
|
+
await this.start();
|
|
61
|
+
const result = await this.#send('consult', { text, name });
|
|
62
|
+
if (result.ok) this.log.record(name, text);
|
|
63
|
+
return result;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Open a query. Nothing runs until the first `next()` or `all()`, so opening
|
|
68
|
+
* one is always safe even if the goal is a disaster.
|
|
69
|
+
*/
|
|
70
|
+
query(goal) {
|
|
71
|
+
return new WorkerQuery(this, goal);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* ONE OPEN SEQUENCE PER SESSION, and the reason is not tidiness.
|
|
76
|
+
*
|
|
77
|
+
* SWI keeps open queries on a stack and swipl-wasm enforces it: stepping or
|
|
78
|
+
* closing anything but the innermost throws "Attempt to access not innermost
|
|
79
|
+
* query". A page cannot promise the order — the order is whatever the reader
|
|
80
|
+
* clicks — so the constraint is met by construction instead: there is never
|
|
81
|
+
* more than one open query, which means the one being closed is always the
|
|
82
|
+
* innermost, which means the close is always legal (869epzqpc).
|
|
83
|
+
*
|
|
84
|
+
* Called at the moment a frame is about to be opened, never when the query
|
|
85
|
+
* OBJECT is made: a cell whose Run fails before it ever steps must not end
|
|
86
|
+
* someone else's sequence for nothing.
|
|
87
|
+
*
|
|
88
|
+
* @internal
|
|
89
|
+
*/
|
|
90
|
+
async supersede() {
|
|
91
|
+
const previous = this.#open;
|
|
92
|
+
this.#open = null;
|
|
93
|
+
if (!previous) return;
|
|
94
|
+
await previous.close({ superseded: true });
|
|
95
|
+
// Said only after the frame is actually gone, so a listener that starts a new
|
|
96
|
+
// query cannot race the close it was told about.
|
|
97
|
+
previous.onSuperseded?.();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** @internal a query's frame is gone — exhausted, closed, or died with the engine. */
|
|
101
|
+
release(query) {
|
|
102
|
+
if (this.#open === query) this.#open = null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** @internal a query has just taken the session's one frame. */
|
|
106
|
+
hold(query) {
|
|
107
|
+
this.#open = query;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Take one cell's clauses back out. See unconsult() in session.js. */
|
|
111
|
+
async unconsult(name) {
|
|
112
|
+
return unconsult(this, name);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Throw the engine away and rebuild it from the consult log.
|
|
117
|
+
*
|
|
118
|
+
* Terminating is not a last resort here, it is the mechanism: it reclaims the
|
|
119
|
+
* whole WASM heap as well as the stuck goal, so a memory blow-up and an
|
|
120
|
+
* infinite loop have the same cure.
|
|
121
|
+
*
|
|
122
|
+
* A `:- dynamic` cell's assert/retract state does not survive this, which is
|
|
123
|
+
* already the documented behaviour of "restart engine and run all"
|
|
124
|
+
* (format §8) rather than a new surprise.
|
|
125
|
+
*/
|
|
126
|
+
async restart() {
|
|
127
|
+
this.#teardown(new Error('aborted'));
|
|
128
|
+
// Every frame died with the worker, so nothing is holding the session's.
|
|
129
|
+
this.#open = null;
|
|
130
|
+
await this.start();
|
|
131
|
+
for (const { name, text } of this.log) {
|
|
132
|
+
await this.#send('consult', { text, name });
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Stop whatever is running. Identical to restart() here, and named separately
|
|
138
|
+
* because the two are different intentions: one rescues a page, the other
|
|
139
|
+
* throws away assert/retract state deliberately.
|
|
140
|
+
*/
|
|
141
|
+
async abort() {
|
|
142
|
+
return this.restart();
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async close() {
|
|
146
|
+
this.#teardown(new Error('session closed'));
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
#spawn() {
|
|
150
|
+
// Classic, not module: swipl-wasm has no ESM entry, and importScripts is the
|
|
151
|
+
// only way to get its global. See the comment at the top of worker.js.
|
|
152
|
+
this.#worker = new Worker(this.workerUrl);
|
|
153
|
+
this.#worker.onmessage = ({ data }) => {
|
|
154
|
+
const entry = this.#pending.get(data.id);
|
|
155
|
+
if (!entry) return;
|
|
156
|
+
this.#pending.delete(data.id);
|
|
157
|
+
if (data.ok) entry.resolve(data.value);
|
|
158
|
+
else entry.reject(new Error(data.error));
|
|
159
|
+
};
|
|
160
|
+
this.#worker.onerror = (event) => {
|
|
161
|
+
this.#teardown(new Error(event.message ?? 'worker failed'));
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
#teardown(reason) {
|
|
166
|
+
this.#open = null;
|
|
167
|
+
this.#worker?.terminate();
|
|
168
|
+
this.#worker = null;
|
|
169
|
+
this.#booting = null;
|
|
170
|
+
// Anything still waiting will never hear back, so say so rather than leaving
|
|
171
|
+
// a promise dangling forever — a silent hang is what this whole change exists
|
|
172
|
+
// to remove.
|
|
173
|
+
for (const { reject } of this.#pending.values()) reject(reason);
|
|
174
|
+
this.#pending.clear();
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
#send(op, args = {}) {
|
|
178
|
+
if (!this.#worker) return Promise.reject(new Error('worker is not running'));
|
|
179
|
+
const id = this.#nextId++;
|
|
180
|
+
return new Promise((resolve, reject) => {
|
|
181
|
+
this.#pending.set(id, { resolve, reject });
|
|
182
|
+
this.#worker.postMessage({ id, op, ...args });
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** @internal */
|
|
187
|
+
send(op, args) {
|
|
188
|
+
return this.#send(op, args);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
class WorkerQuery {
|
|
193
|
+
#qid = null;
|
|
194
|
+
|
|
195
|
+
constructor(session, goal) {
|
|
196
|
+
this.session = session;
|
|
197
|
+
this.goal = goal;
|
|
198
|
+
this.done = false;
|
|
199
|
+
// Ended by another query taking the session's one frame, rather than by its
|
|
200
|
+
// own search finishing. Kept apart from `done` because only one of the two
|
|
201
|
+
// may ever be written down as an exhausted search (format §6).
|
|
202
|
+
this.superseded = false;
|
|
203
|
+
/** Set by the caller to hear that its sequence was closed for another one. */
|
|
204
|
+
this.onSuperseded = null;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
async #open() {
|
|
208
|
+
if (this.#qid === null) {
|
|
209
|
+
await this.session.start();
|
|
210
|
+
// Before the frame exists, never after: once a second query is open the
|
|
211
|
+
// first is no longer innermost and can never be closed at all.
|
|
212
|
+
await this.session.supersede();
|
|
213
|
+
this.#qid = await this.session.send('open', { goal: this.goal });
|
|
214
|
+
this.session.hold(this);
|
|
21
215
|
}
|
|
22
|
-
|
|
216
|
+
return this.#qid;
|
|
23
217
|
}
|
|
24
|
-
|
|
218
|
+
|
|
219
|
+
async next() {
|
|
220
|
+
if (this.done) return this.superseded ? { done: true, superseded: true } : { done: true };
|
|
221
|
+
const qid = await this.#open();
|
|
222
|
+
const result = await this.session.send('next', { qid });
|
|
223
|
+
// The worker forgets a query that reports done — swipl-wasm has closed it —
|
|
224
|
+
// so the frame is already back and nothing here needs to ask for it.
|
|
225
|
+
if (result.done) this.#finish();
|
|
226
|
+
return result;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async all(limit) {
|
|
230
|
+
if (this.done) return { solutions: [], truncated: false };
|
|
231
|
+
const qid = await this.#open();
|
|
232
|
+
this.#finish();
|
|
233
|
+
return this.session.send('all', { qid, limit });
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
async close({ superseded = false } = {}) {
|
|
237
|
+
if (superseded) this.superseded = true;
|
|
238
|
+
if (this.#qid === null || this.done) {
|
|
239
|
+
this.#finish();
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
this.#finish();
|
|
243
|
+
await this.session.send('close', { qid: this.#qid });
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
#finish() {
|
|
247
|
+
this.done = true;
|
|
248
|
+
this.session.release(this);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
let shared = null;
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Boot (once) and return the shared session for this page.
|
|
256
|
+
* @returns {Promise<WorkerSession>}
|
|
257
|
+
*/
|
|
258
|
+
export function createSession(options = {}) {
|
|
259
|
+
if (!shared) shared = new WorkerSession(options).start();
|
|
260
|
+
return shared;
|
|
25
261
|
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// Which copy of this you are actually running.
|
|
2
|
+
//
|
|
3
|
+
// A version number answers "which release"; it does not answer "which of the
|
|
4
|
+
// four things on this machine claiming to be 0.2.0". A published install, a
|
|
5
|
+
// checkout with the branch still on it, and an npm-linked working copy with
|
|
6
|
+
// uncommitted edits are three different programs, and a bug report against the
|
|
7
|
+
// wrong one costs an afternoon.
|
|
8
|
+
//
|
|
9
|
+
// TWO STATES, NAMED, because they are not the same claim:
|
|
10
|
+
//
|
|
11
|
+
// Build ccf8e5b, committed 2026-08-30, packaged 2026-08-30
|
|
12
|
+
// Working copy ccf8e5b (modified), committed 2026-08-30
|
|
13
|
+
//
|
|
14
|
+
// The first is baked in by the release workflow just before publish — git exists
|
|
15
|
+
// there and does not exist inside an installed package. The second is read from
|
|
16
|
+
// git at run time, and says `(modified)` when the tree has edits, because a bare
|
|
17
|
+
// SHA over a dirty tree names a program that nobody has.
|
|
18
|
+
//
|
|
19
|
+
// NOTHING IS BUILT HERE. The package is plain ES modules, published as written,
|
|
20
|
+
// so "packaged" is the honest word for the third date — there is no compiler and
|
|
21
|
+
// no output to date-stamp. A working copy has no packaging time at all, which is
|
|
22
|
+
// why the second state has two fields rather than three.
|
|
23
|
+
import { execFileSync } from 'node:child_process';
|
|
24
|
+
import { readFileSync } from 'node:fs';
|
|
25
|
+
|
|
26
|
+
/** Where prepack leaves the facts. Inside `src`, so `files` already ships it. */
|
|
27
|
+
const BAKED = new URL('./build-info.json', import.meta.url);
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The provenance line, or null when nothing is known.
|
|
31
|
+
*
|
|
32
|
+
* Pure, so both states can be tested without a filesystem or a git repository.
|
|
33
|
+
*
|
|
34
|
+
* @param {{commit: string, committed: string, packaged?: string, modified?: boolean}|null} info
|
|
35
|
+
* @returns {string|null}
|
|
36
|
+
*/
|
|
37
|
+
export function buildLine(info) {
|
|
38
|
+
if (!info?.commit) return null;
|
|
39
|
+
if (info.packaged) {
|
|
40
|
+
return `Build ${info.commit}, committed ${info.committed}, packaged ${info.packaged}`;
|
|
41
|
+
}
|
|
42
|
+
return `Working copy ${info.commit}${info.modified ? ' (modified)' : ''}, committed ${info.committed}`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* What this copy is, from whichever of the two sources exists.
|
|
47
|
+
*
|
|
48
|
+
* Null rather than a guess when neither does — a tarball built before any of
|
|
49
|
+
* this existed, or a source tree with no history. A line that says "unknown"
|
|
50
|
+
* three times is worse than no line.
|
|
51
|
+
*
|
|
52
|
+
* @returns {{commit: string, committed: string, packaged?: string, modified?: boolean}|null}
|
|
53
|
+
*/
|
|
54
|
+
export function currentBuild() {
|
|
55
|
+
try {
|
|
56
|
+
const baked = JSON.parse(readFileSync(BAKED, 'utf8'));
|
|
57
|
+
if (baked?.commit) return baked;
|
|
58
|
+
} catch {
|
|
59
|
+
// No file, or a damaged one. Either way git is the better authority here.
|
|
60
|
+
}
|
|
61
|
+
return fromGit();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Ask git, which is only there in a working copy.
|
|
66
|
+
*
|
|
67
|
+
* `execFileSync` with fixed arguments and no shell. It costs about ten
|
|
68
|
+
* milliseconds and only ever runs in development, where the alternative is a
|
|
69
|
+
* command that cannot tell you which of your own commits it is.
|
|
70
|
+
*/
|
|
71
|
+
function fromGit() {
|
|
72
|
+
const root = new URL('..', import.meta.url);
|
|
73
|
+
const git = (...args) => execFileSync('git', ['-C', root.pathname, ...args], {
|
|
74
|
+
encoding: 'utf8',
|
|
75
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
76
|
+
}).trim();
|
|
77
|
+
try {
|
|
78
|
+
const [commit, committed] = git('log', '-1', '--format=%h %cs').split(' ');
|
|
79
|
+
return { commit, committed, modified: git('status', '--porcelain') !== '' };
|
|
80
|
+
} catch {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The facts, as prepack writes them. Exported so the script that runs at pack
|
|
87
|
+
* time and the code that reads the result agree on the shape.
|
|
88
|
+
*
|
|
89
|
+
* @param {{commit: string, committed: string}} head
|
|
90
|
+
* @param {Date} [now]
|
|
91
|
+
*/
|
|
92
|
+
export function bakedFrom(head, now = new Date()) {
|
|
93
|
+
return { ...head, packaged: now.toISOString().slice(0, 10) };
|
|
94
|
+
}
|