beatrina 0.8.6
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/LICENSE +92 -0
- package/NOTICES +72 -0
- package/README.md +124 -0
- package/bin/beatrina.mjs +223 -0
- package/bin/cli.mjs +80 -0
- package/bin/failsafe.mjs +74 -0
- package/bin/identity.mjs +45 -0
- package/bin/prova-post.mjs +51 -0
- package/bin/sessions.mjs +95 -0
- package/bin/shortcut.mjs +151 -0
- package/bin/update-check.mjs +55 -0
- package/bin/upgrade.mjs +76 -0
- package/build-info.json +1 -0
- package/carmar_V0.8.6.html +1310 -0
- package/check/acceptance.mjs +278 -0
- package/check/session.mjs +215 -0
- package/engines/js/document-exec.mjs +82 -0
- package/engines/js/persist.mjs +214 -0
- package/engines/js/worker.mjs +424 -0
- package/engines/python/adapter.py +577 -0
- package/engines/python/analyze.py +814 -0
- package/engines/python/bootstrap.py +309 -0
- package/engines/python/dataview.py +735 -0
- package/engines/python/debugger.py +346 -0
- package/engines/python/document_exec.py +158 -0
- package/engines/python/engine.json +28 -0
- package/engines/python/handoff.py +118 -0
- package/engines/python/worker.py +564 -0
- package/engines/r/engine.json +25 -0
- package/engines/r/handoff.R +92 -0
- package/failsafe/ai-policy.R +255 -0
- package/failsafe/ai-store.R +373 -0
- package/failsafe/cite.R +418 -0
- package/failsafe/journal.R +684 -0
- package/failsafe/plugins.R +809 -0
- package/failsafe/serve.R +5500 -0
- package/host/ai-policy.mjs +218 -0
- package/host/deployment.mjs +160 -0
- package/host/engine-js.mjs +98 -0
- package/host/engine-pool.mjs +383 -0
- package/host/engine-python.mjs +228 -0
- package/host/engine-r.mjs +206 -0
- package/host/engine-stdio.mjs +401 -0
- package/host/journal-store.mjs +749 -0
- package/host/main.mjs +503 -0
- package/host/planes/README.md +41 -0
- package/host/planes/ai-store.mjs +327 -0
- package/host/planes/ai.mjs +467 -0
- package/host/planes/analyze.mjs +397 -0
- package/host/planes/cite.mjs +517 -0
- package/host/planes/files.mjs +0 -0
- package/host/planes/jobs.mjs +704 -0
- package/host/planes/journal.mjs +53 -0
- package/host/planes/latex.mjs +201 -0
- package/host/planes/mcp.mjs +493 -0
- package/host/planes/pair.mjs +325 -0
- package/host/planes/pipe-term.mjs +122 -0
- package/host/planes/plugins.mjs +112 -0
- package/host/planes/proc-tree.mjs +76 -0
- package/host/planes/sessions.mjs +434 -0
- package/host/planes/settings.mjs +164 -0
- package/host/planes/terminal.mjs +286 -0
- package/host/planes/test-file.mjs +80 -0
- package/host/planes/update.mjs +214 -0
- package/host/plugin-store.mjs +838 -0
- package/host/server.mjs +441 -0
- package/host/settings.mjs +379 -0
- package/host/update-record.mjs +59 -0
- package/host/user-dirs.mjs +117 -0
- package/host/windows-runtime.mjs +145 -0
- package/host/worker-plane.mjs +713 -0
- package/host/ws.mjs +190 -0
- package/kernel/analyze.R +668 -0
- package/kernel/deployment.R +165 -0
- package/kernel/examples/NOTICE.md +38 -0
- package/kernel/examples/tna-complete-tutorial.Rmd +210 -0
- package/kernel/fileio.R +656 -0
- package/kernel/index.html +96 -0
- package/kernel/job-run.R +391 -0
- package/kernel/jobs.R +276 -0
- package/kernel/kernel-protocol +1 -0
- package/kernel/kernel-version +1 -0
- package/kernel/kernel.R +671 -0
- package/kernel/knitr-run.R +245 -0
- package/kernel/latex.R +609 -0
- package/kernel/mcp/carmar-mcp.mjs +516 -0
- package/kernel/notebook-page.R +67 -0
- package/kernel/plugins/csl/apa/apa.csl +2273 -0
- package/kernel/plugins/csl/apa/plugin.json +19 -0
- package/kernel/plugins/csl/chicago-author-date/chicago-author-date.csl +4216 -0
- package/kernel/plugins/csl/chicago-author-date/plugin.json +19 -0
- package/kernel/plugins/csl/harvard-cite-them-right/harvard-cite-them-right.csl +316 -0
- package/kernel/plugins/csl/harvard-cite-them-right/plugin.json +19 -0
- package/kernel/plugins/csl/ieee/ieee.csl +519 -0
- package/kernel/plugins/csl/ieee/plugin.json +19 -0
- package/kernel/plugins/csl/modern-language-association/modern-language-association.csl +1184 -0
- package/kernel/plugins/csl/modern-language-association/plugin.json +19 -0
- package/kernel/plugins/csl/nature/nature.csl +189 -0
- package/kernel/plugins/csl/nature/plugin.json +19 -0
- package/kernel/plugins/latex/apa7/apa7.json +14 -0
- package/kernel/plugins/latex/apa7/plugin.json +19 -0
- package/kernel/plugins/latex/elsarticle/elsarticle.json +14 -0
- package/kernel/plugins/latex/elsarticle/plugin.json +19 -0
- package/kernel/plugins/latex/ieeetran/ieeetran.json +10 -0
- package/kernel/plugins/latex/ieeetran/plugin.json +19 -0
- package/kernel/project.R +131 -0
- package/kernel/settings.R +410 -0
- package/kernel/sniff.R +769 -0
- package/kernel/worker-boot.R +22 -0
- package/kernel/worker.R +3496 -0
- package/lib/agent-authoring-contract.js +547 -0
- package/lib/cell-kinds.js +108 -0
- package/lib/engine-labels.js +324 -0
- package/package.json +32 -0
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<meta charset="utf-8">
|
|
3
|
+
<title>CarmaR kernel spike</title>
|
|
4
|
+
<style>
|
|
5
|
+
:root { color-scheme: light dark; }
|
|
6
|
+
body { font: 14px/1.5 ui-sans-serif, system-ui, sans-serif; margin: 0; padding: 1.5rem;
|
|
7
|
+
max-width: 60rem; }
|
|
8
|
+
h1 { font-size: 1rem; font-weight: 600; margin: 0 0 1rem; letter-spacing: .01em; }
|
|
9
|
+
#status { font-variant-numeric: tabular-nums; opacity: .7; font-size: .85rem; }
|
|
10
|
+
textarea { width: 100%; min-height: 7rem; font: 13px/1.5 ui-monospace, monospace;
|
|
11
|
+
padding: .6rem; box-sizing: border-box; }
|
|
12
|
+
.row { display: flex; gap: .5rem; margin: .6rem 0; align-items: center; }
|
|
13
|
+
button { font: inherit; padding: .35rem .9rem; }
|
|
14
|
+
pre { font: 12px/1.5 ui-monospace, monospace; padding: .8rem; margin: 0;
|
|
15
|
+
background: color-mix(in srgb, currentColor 6%, transparent);
|
|
16
|
+
border-radius: 4px; max-height: 26rem; overflow: auto; white-space: pre-wrap; }
|
|
17
|
+
.stderr, .warning { color: #b45309; }
|
|
18
|
+
.error { color: #b91c1c; }
|
|
19
|
+
.meta { opacity: .55; }
|
|
20
|
+
table { border-collapse: collapse; margin: .5rem 0; font-size: 12px; display: block;
|
|
21
|
+
overflow-x: auto; max-width: 100%; }
|
|
22
|
+
th, td { border: 1px solid color-mix(in srgb, currentColor 20%, transparent);
|
|
23
|
+
padding: .15rem .5rem; text-align: left; white-space: nowrap; }
|
|
24
|
+
th { font-weight: 600; opacity: .8; }
|
|
25
|
+
img { display: block; margin: .5rem 0; border-radius: 3px; }
|
|
26
|
+
</style>
|
|
27
|
+
|
|
28
|
+
<h1>CarmaR kernel spike <span id="status">connecting…</span></h1>
|
|
29
|
+
|
|
30
|
+
<textarea id="src">for (i in 1:20) { cat("tick", i, "\n"); flush(stdout()); Sys.sleep(0.5) }</textarea>
|
|
31
|
+
|
|
32
|
+
<div class="row">
|
|
33
|
+
<button id="run">Run</button>
|
|
34
|
+
<button id="stop">Stop</button>
|
|
35
|
+
<span class="meta">Stop sends SIGINT to the worker — the session survives.</span>
|
|
36
|
+
</div>
|
|
37
|
+
|
|
38
|
+
<pre id="out"></pre>
|
|
39
|
+
|
|
40
|
+
<script>
|
|
41
|
+
const params = new URLSearchParams(location.search);
|
|
42
|
+
const ws = new WebSocket(`ws://${location.host}/ws`);
|
|
43
|
+
const out = document.getElementById('out');
|
|
44
|
+
const status = document.getElementById('status');
|
|
45
|
+
let n = 0;
|
|
46
|
+
|
|
47
|
+
const write = (text, cls) => {
|
|
48
|
+
const span = document.createElement('span');
|
|
49
|
+
if (cls) span.className = cls;
|
|
50
|
+
span.textContent = text + '\n';
|
|
51
|
+
out.appendChild(span);
|
|
52
|
+
out.scrollTop = out.scrollHeight;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
ws.onopen = () => { status.textContent = 'connected'; };
|
|
56
|
+
ws.onclose = () => { status.textContent = 'disconnected'; };
|
|
57
|
+
ws.onmessage = (ev) => {
|
|
58
|
+
const msg = JSON.parse(ev.data);
|
|
59
|
+
if (msg.type === 'ready') status.textContent = `ready — ${msg.r} (pid ${msg.pid})`;
|
|
60
|
+
else if (msg.type === 'stdout') write(msg.text);
|
|
61
|
+
else if (msg.type === 'stderr') write(msg.text, 'stderr');
|
|
62
|
+
else if (msg.type === 'stream') write(`${msg.kind}: ${msg.text}`, msg.kind);
|
|
63
|
+
else if (msg.type === 'plot') {
|
|
64
|
+
const img = new Image();
|
|
65
|
+
img.src = `data:${msg.mime};base64,${msg.data}`;
|
|
66
|
+
img.style.maxWidth = '100%';
|
|
67
|
+
out.appendChild(img);
|
|
68
|
+
}
|
|
69
|
+
else if (msg.type === 'dataframe') {
|
|
70
|
+
const table = document.createElement('table');
|
|
71
|
+
const head = table.insertRow();
|
|
72
|
+
msg.columns.forEach((c, i) => {
|
|
73
|
+
const th = document.createElement('th');
|
|
74
|
+
th.textContent = `${c} (${msg.types[i]})`;
|
|
75
|
+
head.appendChild(th);
|
|
76
|
+
});
|
|
77
|
+
const rows = Array.isArray(msg.rows) ? msg.rows : [msg.rows];
|
|
78
|
+
rows.slice(0, 20).forEach((r) => {
|
|
79
|
+
const tr = table.insertRow();
|
|
80
|
+
msg.columns.forEach((c) => { tr.insertCell().textContent = r[c]; });
|
|
81
|
+
});
|
|
82
|
+
out.appendChild(table);
|
|
83
|
+
write(`${msg.nrow} × ${msg.ncol}${msg.truncated ? ` — showing first ${rows.length}` : ''}`, 'meta');
|
|
84
|
+
}
|
|
85
|
+
else if (msg.type === 'done') write(`— ${msg.status}${msg.message ? ': ' + msg.message : ''}`,
|
|
86
|
+
msg.status === 'ok' ? 'meta' : 'error');
|
|
87
|
+
else if (msg.type === 'worker-died') status.textContent = 'worker died';
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
document.getElementById('run').onclick = () => {
|
|
91
|
+
ws.send(JSON.stringify({ type: 'exec', id: `c${++n}`, source: document.getElementById('src').value }));
|
|
92
|
+
};
|
|
93
|
+
document.getElementById('stop').onclick = () => {
|
|
94
|
+
ws.send(JSON.stringify({ type: 'interrupt' }));
|
|
95
|
+
};
|
|
96
|
+
</script>
|
package/kernel/job-run.R
ADDED
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
#!/usr/bin/env Rscript
|
|
2
|
+
#
|
|
3
|
+
# job-run.R — one development task, in its own R process.
|
|
4
|
+
#
|
|
5
|
+
# Spawned by the supervisor through the ordinary `kernel_start()` path, so it
|
|
6
|
+
# inherits the things that took a while to get right there: a UTF-8 child
|
|
7
|
+
# locale whatever the launcher had, an R environment scrubbed of the
|
|
8
|
+
# supervisor's own R_HOME/R_LIBS (which otherwise aim the child at the wrong
|
|
9
|
+
# tree), and `kernel_poll`'s bounded drain, so a check that prints a megabyte
|
|
10
|
+
# cannot starve the event loop.
|
|
11
|
+
#
|
|
12
|
+
# ── this process evaluates; that is the point, and the boundary ───────────
|
|
13
|
+
#
|
|
14
|
+
# analyze.R may never evaluate user source, because a second evaluating
|
|
15
|
+
# session would have no Stop button and no session identity. This one may,
|
|
16
|
+
# and has neither problem, because it is NOT a session: it is one task, it
|
|
17
|
+
# owns no workspace anybody can see, it is killed by PID, and when it dies
|
|
18
|
+
# nothing the user typed goes with it. `devtools::check()` is going to
|
|
19
|
+
# evaluate the package's code no matter which process asks; the decision here
|
|
20
|
+
# is only that the process is not the one holding the notebook's variables.
|
|
21
|
+
#
|
|
22
|
+
# ── nothing from the wire is parsed ───────────────────────────────────────
|
|
23
|
+
#
|
|
24
|
+
# The task name arrives in the environment and is matched against a `switch`
|
|
25
|
+
# whose branches are literal calls. The root and the filter are passed as
|
|
26
|
+
# ARGUMENTS to those calls, never pasted into an expression. There is no
|
|
27
|
+
# `parse(text=)`, no `eval(parse(...))`, and none may be added — the same
|
|
28
|
+
# rule analyze.R carries, for a different reason: there, so nothing runs;
|
|
29
|
+
# here, so only the six things named below can.
|
|
30
|
+
|
|
31
|
+
args <- commandArgs(trailingOnly = TRUE)
|
|
32
|
+
sentinel <- args[1]
|
|
33
|
+
if (is.na(sentinel) || !nzchar(sentinel)) {
|
|
34
|
+
stop("job-run.R needs a sentinel argument", call. = FALSE)
|
|
35
|
+
}
|
|
36
|
+
suppressPackageStartupMessages(library(jsonlite))
|
|
37
|
+
here <- dirname(normalizePath(sub("^--file=", "", grep("^--file=", commandArgs(FALSE), value = TRUE)[1L])))
|
|
38
|
+
source(file.path(here, "kernel.R"))
|
|
39
|
+
source(file.path(here, "jobs.R"))
|
|
40
|
+
source(file.path(here, "latex.R"))
|
|
41
|
+
compile_identity <- NULL
|
|
42
|
+
|
|
43
|
+
#' Write one control frame: the session's random sentinel, then compact JSON.
|
|
44
|
+
#' Identical framing to worker.R and analyze.R, so kernel_poll needs no new case.
|
|
45
|
+
#'
|
|
46
|
+
#' The job's own id is stamped HERE rather than added by the supervisor on the
|
|
47
|
+
#' way past, and that is a deliberate consequence of the frame-fidelity rule:
|
|
48
|
+
#' a frame the supervisor has to rewrite is a frame it has to re-encode, and
|
|
49
|
+
#' jsonlite's defaults round numbers to four digits, render an absent field as
|
|
50
|
+
#' a truthy `{}` and collapse a one-element array to a scalar. A frame that
|
|
51
|
+
#' already carries its id can be forwarded as BYTES.
|
|
52
|
+
#'
|
|
53
|
+
#' @param obj A list to serialise.
|
|
54
|
+
#' @return Invisibly NULL.
|
|
55
|
+
emit <- function(obj) {
|
|
56
|
+
frame <- c(list(type = obj$type, id = job_id), compile_identity, obj[setdiff(names(obj), "type")])
|
|
57
|
+
cat(sentinel, toJSON(frame, auto_unbox = TRUE, null = "null", na = "null", digits = NA), "\n", sep = "")
|
|
58
|
+
flush(stdout())
|
|
59
|
+
invisible(NULL)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
job_id <- Sys.getenv("CARMAR_JOB_ID", "job")
|
|
63
|
+
task <- Sys.getenv("CARMAR_JOB_TASK", "")
|
|
64
|
+
root <- Sys.getenv("CARMAR_JOB_ROOT", "")
|
|
65
|
+
target <- Sys.getenv("CARMAR_JOB_TARGET", "")
|
|
66
|
+
filter <- Sys.getenv("CARMAR_JOB_FILTER", "")
|
|
67
|
+
format <- Sys.getenv("CARMAR_JOB_FORMAT", "")
|
|
68
|
+
|
|
69
|
+
# The supervisor validated all of this in jobs.R before spawning. Re-checking
|
|
70
|
+
# here is not redundant: this file is also runnable by hand, and a task that
|
|
71
|
+
# trusted its environment would be a very quiet way to run the wrong thing.
|
|
72
|
+
KNOWN <- names(JOB_TASKS)
|
|
73
|
+
if (!task %in% KNOWN) {
|
|
74
|
+
emit(list(type = "job", event = "done", ok = FALSE,
|
|
75
|
+
error = sprintf("unknown task '%s'", task)))
|
|
76
|
+
quit(status = 2L, save = "no")
|
|
77
|
+
}
|
|
78
|
+
if (!nzchar(root) || !dir.exists(root)) {
|
|
79
|
+
emit(list(type = "job", event = "done", ok = FALSE,
|
|
80
|
+
error = sprintf("'%s' is not a folder", root)))
|
|
81
|
+
quit(status = 2L, save = "no")
|
|
82
|
+
}
|
|
83
|
+
# A render acts on a FILE. Checking only the folder would let a task start
|
|
84
|
+
# against a document that is not there and fail somewhere less legible.
|
|
85
|
+
if (task %in% c("render", "latex") && (!nzchar(target) || !file.exists(target))) {
|
|
86
|
+
emit(list(type = "job", event = "done", ok = FALSE,
|
|
87
|
+
error = sprintf("'%s' is not a file", target)))
|
|
88
|
+
quit(status = 2L, save = "no")
|
|
89
|
+
}
|
|
90
|
+
if (identical(task, "latex")) {
|
|
91
|
+
prepared <- tryCatch({
|
|
92
|
+
identity_text <- Sys.getenv("CARMAR_LATEX_IDENTITY", "")
|
|
93
|
+
identity <- if (nzchar(identity_text)) jsonlite::fromJSON(identity_text, simplifyVector = FALSE) else NULL
|
|
94
|
+
spec <- job_spec(task, target, list(engine = Sys.getenv("CARMAR_LATEX_ENGINE", "auto"),
|
|
95
|
+
timeout = suppressWarnings(as.numeric(Sys.getenv("CARMAR_LATEX_TIMEOUT", as.character(LATEX_TIMEOUT)))),
|
|
96
|
+
fetch = identical(Sys.getenv("CARMAR_LATEX_FETCH", ""), "1"), identity = identity))
|
|
97
|
+
if (!isTRUE(spec$ok)) stop(spec$error, call. = FALSE)
|
|
98
|
+
compile_identity <- identity
|
|
99
|
+
spec
|
|
100
|
+
}, error = function(e) e)
|
|
101
|
+
if (inherits(prepared, "error")) {
|
|
102
|
+
emit(list(type = "job", event = "done", ok = FALSE, error = conditionMessage(prepared)))
|
|
103
|
+
quit(status = 2L, save = "no")
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
#' Refuse early and in words when the task's package is missing.
|
|
108
|
+
#' @param pkg Package name the task needs.
|
|
109
|
+
#' @return Invisibly TRUE, or quits with a `done` frame.
|
|
110
|
+
need <- function(pkg) {
|
|
111
|
+
if (requireNamespace(pkg, quietly = TRUE)) return(invisible(TRUE))
|
|
112
|
+
emit(list(type = "job", event = "done", ok = FALSE,
|
|
113
|
+
error = sprintf("this task needs the '%s' package, which is not installed. install.packages(\"%s\")",
|
|
114
|
+
pkg, pkg)))
|
|
115
|
+
quit(status = 3L, save = "no")
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
# ── structured test results ────────────────────────────────────────────────
|
|
119
|
+
#
|
|
120
|
+
# The reason `test` gets its own extraction rather than a log the UI greps:
|
|
121
|
+
# Stage 5's exit criterion is that a failure NAVIGATES to exact source, and a
|
|
122
|
+
# regex over testthat's printed output cannot do that reliably — the format is
|
|
123
|
+
# a reporter's presentation choice and changes between versions.
|
|
124
|
+
#
|
|
125
|
+
# Measured instead (testthat 3.3.2, against a synthetic package): every
|
|
126
|
+
# expectation carries `$srcref`, an integer srcref whose first element is the
|
|
127
|
+
# line and whose "srcfile" attribute holds the file name. Failures at lines
|
|
128
|
+
# 2/6/10/14 of the fixture reported exactly 2/6/10/14. That is a field, not a
|
|
129
|
+
# printed string, so it is what this reads.
|
|
130
|
+
|
|
131
|
+
#' One expectation's status, from its class.
|
|
132
|
+
#' @param e A testthat expectation condition.
|
|
133
|
+
#' @return One of "success", "failure", "error", "skip", "warning".
|
|
134
|
+
expectation_status <- function(e) {
|
|
135
|
+
cls <- class(e)
|
|
136
|
+
if (inherits(e, "expectation_success")) "success"
|
|
137
|
+
else if (inherits(e, "expectation_failure")) "failure"
|
|
138
|
+
else if (inherits(e, "expectation_error")) "error"
|
|
139
|
+
else if (inherits(e, "expectation_skip")) "skip"
|
|
140
|
+
else if (inherits(e, "expectation_warning")) "warning"
|
|
141
|
+
else sub("^expectation_", "", cls[[1L]])
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
# A failing suite can hold thousands of expectations, and every one of them
|
|
145
|
+
# would ride the socket. Bounded — and the bound is REPORTED, because a list
|
|
146
|
+
# that silently stops looks exactly like a suite that stopped failing.
|
|
147
|
+
MAX_REPORTED <- 200L
|
|
148
|
+
|
|
149
|
+
#' Flatten testthat's nested results into one tidy row per problem.
|
|
150
|
+
#' @param results A `testthat_results` object.
|
|
151
|
+
#' @return list(rows, counts, truncated)
|
|
152
|
+
collect_results <- function(results) {
|
|
153
|
+
counts <- c(success = 0L, failure = 0L, error = 0L, skip = 0L, warning = 0L)
|
|
154
|
+
rows <- list()
|
|
155
|
+
# Nested lists of conditions: this walks blocks and their expectations. It
|
|
156
|
+
# is a loop rather than an apply because it accumulates into two different
|
|
157
|
+
# places (a tally and a bounded list) and stops adding to one of them.
|
|
158
|
+
for (block in results) {
|
|
159
|
+
for (e in block$results) {
|
|
160
|
+
status <- expectation_status(e)
|
|
161
|
+
if (status %in% names(counts)) counts[[status]] <- counts[[status]] + 1L
|
|
162
|
+
if (identical(status, "success")) next
|
|
163
|
+
if (length(rows) >= MAX_REPORTED) next
|
|
164
|
+
srcref <- e$srcref
|
|
165
|
+
line <- if (is.null(srcref)) NA_integer_ else as.integer(srcref)[1L]
|
|
166
|
+
file <- if (is.null(srcref)) NA_character_ else {
|
|
167
|
+
srcfile <- attr(srcref, "srcfile")
|
|
168
|
+
if (is.null(srcfile)) NA_character_ else (srcfile$filename %||% NA_character_)
|
|
169
|
+
}
|
|
170
|
+
name <- if (is.na(file)) (block$file %||% NA_character_) else basename(file)
|
|
171
|
+
# testthat's srcref carries only a BASENAME, and a basename is not
|
|
172
|
+
# something an editor can open. The full path is resolved here, where
|
|
173
|
+
# the filesystem actually is, and only when the file is really there —
|
|
174
|
+
# a path that does not exist is worse than none, because the UI would
|
|
175
|
+
# offer a link that opens an empty buffer.
|
|
176
|
+
full <- if (is.na(name)) NA_character_ else file.path(root, "tests", "testthat", name)
|
|
177
|
+
if (!is.na(full) && !file.exists(full)) full <- NA_character_
|
|
178
|
+
rows[[length(rows) + 1L]] <- list(
|
|
179
|
+
status = status,
|
|
180
|
+
test = block$test %||% NA_character_,
|
|
181
|
+
file = name,
|
|
182
|
+
path = full,
|
|
183
|
+
line = line,
|
|
184
|
+
message = paste(utils::head(strsplit(conditionMessage(e), "\n", fixed = TRUE)[[1L]], 12L),
|
|
185
|
+
collapse = "\n")
|
|
186
|
+
)
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
problems <- sum(counts[c("failure", "error", "skip", "warning")])
|
|
190
|
+
list(rows = rows, counts = as.list(counts),
|
|
191
|
+
truncated = max(0L, problems - length(rows)))
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
run_tests <- function() {
|
|
195
|
+
need("testthat")
|
|
196
|
+
results <- testthat::test_local(
|
|
197
|
+
root,
|
|
198
|
+
filter = if (nzchar(filter)) filter else NULL,
|
|
199
|
+
reporter = "progress",
|
|
200
|
+
stop_on_failure = FALSE
|
|
201
|
+
)
|
|
202
|
+
collected <- collect_results(results)
|
|
203
|
+
emit(list(type = "job", event = "tests",
|
|
204
|
+
# I() so a single result stays an ARRAY on the wire. jsonlite
|
|
205
|
+
# collapses a length-1 list to a scalar otherwise, and the browser
|
|
206
|
+
# would iterate the characters of a string.
|
|
207
|
+
rows = I(collected$rows), counts = collected$counts,
|
|
208
|
+
truncated = collected$truncated,
|
|
209
|
+
root = root))
|
|
210
|
+
# A suite with failures is a completed job that found problems, not a job
|
|
211
|
+
# that failed to run. The distinction is the whole reason `ok` and the
|
|
212
|
+
# counts are separate fields.
|
|
213
|
+
invisible(collected)
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
# ── rendering a document ───────────────────────────────────────────────────
|
|
217
|
+
#
|
|
218
|
+
# ── finding quarto is the whole problem, and it is not `Sys.which` ────────
|
|
219
|
+
#
|
|
220
|
+
# Measured on a normal macOS machine with RStudio installed: `quarto` is NOT
|
|
221
|
+
# on PATH, `quarto::quarto_path()` returns NULL, and a perfectly good Quarto
|
|
222
|
+
# 1.9.37 sits inside RStudio.app. A renderer that asked PATH would have told
|
|
223
|
+
# that user Quarto was not installed while it was on their disk and working.
|
|
224
|
+
#
|
|
225
|
+
# So this is a ladder, in the same spirit as detect_rscript() in kernel.R:
|
|
226
|
+
# an explicit override, then what RStudio itself sets, then the R package's
|
|
227
|
+
# own answer, then PATH, then the bundles we know about by name.
|
|
228
|
+
|
|
229
|
+
#' Locate a quarto binary.
|
|
230
|
+
#' @return Path to quarto, or "" when there is none.
|
|
231
|
+
find_quarto <- function() {
|
|
232
|
+
explicit <- Sys.getenv("CARMAR_QUARTO", "")
|
|
233
|
+
if (nzchar(explicit) && file.exists(explicit)) return(explicit)
|
|
234
|
+
# RStudio and Positron both export this into the sessions they start.
|
|
235
|
+
from_env <- Sys.getenv("QUARTO_PATH", "")
|
|
236
|
+
if (nzchar(from_env) && file.exists(from_env)) return(from_env)
|
|
237
|
+
if (requireNamespace("quarto", quietly = TRUE)) {
|
|
238
|
+
found <- tryCatch(quarto::quarto_path(), error = function(e) NULL)
|
|
239
|
+
if (!is.null(found) && nzchar(found) && file.exists(found)) return(found)
|
|
240
|
+
}
|
|
241
|
+
on_path <- unname(Sys.which("quarto"))
|
|
242
|
+
if (nzchar(on_path)) return(on_path)
|
|
243
|
+
bundles <- c(
|
|
244
|
+
"/Applications/RStudio.app/Contents/Resources/app/quarto/bin/quarto",
|
|
245
|
+
"/Applications/Positron.app/Contents/Resources/app/quarto/bin/quarto",
|
|
246
|
+
"/usr/local/bin/quarto", "/opt/homebrew/bin/quarto",
|
|
247
|
+
"C:/Program Files/RStudio/resources/app/bin/quarto/bin/quarto.exe",
|
|
248
|
+
"C:/Program Files/Quarto/bin/quarto.exe"
|
|
249
|
+
)
|
|
250
|
+
hit <- Find(file.exists, bundles)
|
|
251
|
+
if (is.null(hit)) "" else hit
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
#' Which renderer for this document?
|
|
255
|
+
#'
|
|
256
|
+
#' `.qmd` is Quarto's format and rmarkdown cannot read it, so there is no
|
|
257
|
+
#' choice to make. `.Rmd` and `.md` go to rmarkdown when it is installed —
|
|
258
|
+
#' Quarto renders them too, but with Quarto's semantics, and silently
|
|
259
|
+
#' changing how someone's existing .Rmd builds is not an improvement the
|
|
260
|
+
#' reader asked for.
|
|
261
|
+
#'
|
|
262
|
+
#' @param path The document.
|
|
263
|
+
#' @return "quarto" or "rmarkdown".
|
|
264
|
+
pick_renderer <- function(path) {
|
|
265
|
+
ext <- tolower(sub("^.*\\.", "", basename(path)))
|
|
266
|
+
if (identical(ext, "qmd")) return("quarto")
|
|
267
|
+
if (requireNamespace("rmarkdown", quietly = TRUE)) "rmarkdown" else "quarto"
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
#' Run a command, streaming its output live AND keeping it.
|
|
271
|
+
#'
|
|
272
|
+
#' `system2(stdout = "")` streams but keeps nothing; `system2(stdout = TRUE)`
|
|
273
|
+
#' keeps everything but delivers it only at the end — and a render is exactly
|
|
274
|
+
#' the case where a reader wants to watch. Polling processx gives both, which
|
|
275
|
+
#' matters because quarto reports where it put the file on a line of ordinary
|
|
276
|
+
#' output rather than in an exit code.
|
|
277
|
+
#'
|
|
278
|
+
#' @param bin Executable.
|
|
279
|
+
#' @param args Character vector of arguments — an argument VECTOR, never a
|
|
280
|
+
#' shell string, so a path with a space or a quote in it is one argument.
|
|
281
|
+
#' @return list(status, lines)
|
|
282
|
+
run_streaming <- function(bin, args, wd = NULL, timeout = Inf, env = NULL) {
|
|
283
|
+
proc <- processx::process$new(bin, args, wd = wd, env = env, stdout = "|", stderr = "2>&1", supervise = TRUE, encoding = "UTF-8")
|
|
284
|
+
on.exit(kernel_kill_tree(list(proc = proc)), add = TRUE)
|
|
285
|
+
kept <- ""; truncated <- FALSE; timed_out <- FALSE; started_at <- Sys.time()
|
|
286
|
+
keep <- function(text) {
|
|
287
|
+
if (!nzchar(text)) return(invisible(NULL))
|
|
288
|
+
cat(text, sep = ""); flush(stdout())
|
|
289
|
+
kept <<- paste0(kept, text)
|
|
290
|
+
if (nchar(kept) > 1000000L) { kept <<- substring(kept, nchar(kept) - 999999L); truncated <<- TRUE }
|
|
291
|
+
}
|
|
292
|
+
repeat {
|
|
293
|
+
proc$poll_io(100L)
|
|
294
|
+
keep(proc$read_output(65536L))
|
|
295
|
+
if (as.numeric(difftime(Sys.time(), started_at, units = "secs")) >= timeout) { timed_out <- TRUE; break }
|
|
296
|
+
if (!proc$is_alive()) {
|
|
297
|
+
repeat {
|
|
298
|
+
proc$poll_io(0L)
|
|
299
|
+
leftover <- proc$read_output(65536L)
|
|
300
|
+
keep(leftover)
|
|
301
|
+
if (!nzchar(leftover)) break
|
|
302
|
+
}
|
|
303
|
+
break
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
list(status = if (timed_out) NA_integer_ else proc$get_exit_status(),
|
|
307
|
+
lines = strsplit(kept, "\n", fixed = TRUE)[[1L]], timed_out = timed_out, truncated = truncated)
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
#' Render one document, and report where the output actually went.
|
|
311
|
+
#'
|
|
312
|
+
#' The output path is REPORTED, never guessed. rmarkdown::render returns an
|
|
313
|
+
#' absolute path (measured). Quarto prints "Output created: <path>", relative
|
|
314
|
+
#' to the document's own directory — also measured, including the case that
|
|
315
|
+
#' matters: rendering a full path from an unrelated working directory still
|
|
316
|
+
#' puts the output beside the source and still reports it relatively. Either
|
|
317
|
+
#' way the file is checked to exist before it is reported, for the same
|
|
318
|
+
#' reason a test failure only offers a link to a file that is really there.
|
|
319
|
+
render_document <- function() {
|
|
320
|
+
renderer <- pick_renderer(target)
|
|
321
|
+
produced <- NA_character_
|
|
322
|
+
|
|
323
|
+
if (identical(renderer, "rmarkdown")) {
|
|
324
|
+
need("rmarkdown")
|
|
325
|
+
out <- rmarkdown::render(
|
|
326
|
+
target,
|
|
327
|
+
output_format = if (nzchar(format)) format else NULL,
|
|
328
|
+
knit_root_dir = dirname(target)
|
|
329
|
+
)
|
|
330
|
+
if (length(out) >= 1L && nzchar(out[[1L]])) produced <- out[[1L]]
|
|
331
|
+
} else {
|
|
332
|
+
bin <- find_quarto()
|
|
333
|
+
if (!nzchar(bin)) {
|
|
334
|
+
stop("Quarto is not installed, or CarmaR could not find it. Install Quarto, ",
|
|
335
|
+
"or set CARMAR_QUARTO to the binary.", call. = FALSE)
|
|
336
|
+
}
|
|
337
|
+
args <- c("render", target)
|
|
338
|
+
if (nzchar(format)) args <- c(args, "--to", format)
|
|
339
|
+
result <- run_streaming(bin, args)
|
|
340
|
+
if (!identical(as.integer(result$status), 0L)) {
|
|
341
|
+
stop(sprintf("quarto render exited %s", result$status), call. = FALSE)
|
|
342
|
+
}
|
|
343
|
+
hits <- grep("^Output created:", trimws(result$lines), value = TRUE)
|
|
344
|
+
if (length(hits)) {
|
|
345
|
+
reported <- trimws(sub("^Output created:", "", trimws(hits[[length(hits)]])))
|
|
346
|
+
# Relative to the SOURCE directory, not this process's cwd.
|
|
347
|
+
candidate <- if (grepl("^(/|[A-Za-z]:)", reported)) reported
|
|
348
|
+
else file.path(dirname(target), reported)
|
|
349
|
+
produced <- candidate
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
if (!is.na(produced) && file.exists(produced)) {
|
|
354
|
+
produced <- normalizePath(produced, mustWork = FALSE)
|
|
355
|
+
} else {
|
|
356
|
+
# It rendered but we could not confirm where. Saying nothing is right;
|
|
357
|
+
# naming a file that may not be there is not.
|
|
358
|
+
produced <- NA_character_
|
|
359
|
+
}
|
|
360
|
+
emit(list(type = "job", event = "output", renderer = renderer,
|
|
361
|
+
path = produced, format = if (nzchar(format)) format else NA_character_))
|
|
362
|
+
invisible(produced)
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
started <- Sys.time()
|
|
366
|
+
emit(list(type = "job", event = "started", task = task, root = root, pid = Sys.getpid()))
|
|
367
|
+
|
|
368
|
+
outcome <- tryCatch({
|
|
369
|
+
switch(task,
|
|
370
|
+
load = { need("pkgload"); pkgload::load_all(root, quiet = FALSE); NULL },
|
|
371
|
+
document = { need("devtools"); devtools::document(root); NULL },
|
|
372
|
+
test = run_tests(),
|
|
373
|
+
check = { need("devtools"); devtools::check(root, quiet = FALSE); NULL },
|
|
374
|
+
build = { need("devtools"); devtools::build(root); NULL },
|
|
375
|
+
install = { need("devtools"); devtools::install(root, quick = TRUE, upgrade = "never"); NULL },
|
|
376
|
+
render = render_document(),
|
|
377
|
+
latex = latex_compile(target, prepared$env[["CARMAR_LATEX_ENGINE"]],
|
|
378
|
+
as.numeric(prepared$env[["CARMAR_LATEX_TIMEOUT"]]), job_id, run_streaming, emit,
|
|
379
|
+
fetch = identical(prepared$env[["CARMAR_LATEX_FETCH"]], "1"))
|
|
380
|
+
)
|
|
381
|
+
list(ok = TRUE, error = "")
|
|
382
|
+
}, error = function(e) {
|
|
383
|
+
# Reported, never swallowed: the message is the job's result, and the
|
|
384
|
+
# non-zero exit below is what the supervisor's `done` frame carries.
|
|
385
|
+
list(ok = FALSE, error = conditionMessage(e))
|
|
386
|
+
})
|
|
387
|
+
|
|
388
|
+
emit(list(type = "job", event = "done", ok = isTRUE(outcome$ok),
|
|
389
|
+
error = outcome$error %||% "",
|
|
390
|
+
elapsed = round(as.numeric(difftime(Sys.time(), started, units = "secs")), 2)))
|
|
391
|
+
quit(status = if (isTRUE(outcome$ok)) 0L else 1L, save = "no")
|