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.
Files changed (114) hide show
  1. package/LICENSE +92 -0
  2. package/NOTICES +72 -0
  3. package/README.md +124 -0
  4. package/bin/beatrina.mjs +223 -0
  5. package/bin/cli.mjs +80 -0
  6. package/bin/failsafe.mjs +74 -0
  7. package/bin/identity.mjs +45 -0
  8. package/bin/prova-post.mjs +51 -0
  9. package/bin/sessions.mjs +95 -0
  10. package/bin/shortcut.mjs +151 -0
  11. package/bin/update-check.mjs +55 -0
  12. package/bin/upgrade.mjs +76 -0
  13. package/build-info.json +1 -0
  14. package/carmar_V0.8.6.html +1310 -0
  15. package/check/acceptance.mjs +278 -0
  16. package/check/session.mjs +215 -0
  17. package/engines/js/document-exec.mjs +82 -0
  18. package/engines/js/persist.mjs +214 -0
  19. package/engines/js/worker.mjs +424 -0
  20. package/engines/python/adapter.py +577 -0
  21. package/engines/python/analyze.py +814 -0
  22. package/engines/python/bootstrap.py +309 -0
  23. package/engines/python/dataview.py +735 -0
  24. package/engines/python/debugger.py +346 -0
  25. package/engines/python/document_exec.py +158 -0
  26. package/engines/python/engine.json +28 -0
  27. package/engines/python/handoff.py +118 -0
  28. package/engines/python/worker.py +564 -0
  29. package/engines/r/engine.json +25 -0
  30. package/engines/r/handoff.R +92 -0
  31. package/failsafe/ai-policy.R +255 -0
  32. package/failsafe/ai-store.R +373 -0
  33. package/failsafe/cite.R +418 -0
  34. package/failsafe/journal.R +684 -0
  35. package/failsafe/plugins.R +809 -0
  36. package/failsafe/serve.R +5500 -0
  37. package/host/ai-policy.mjs +218 -0
  38. package/host/deployment.mjs +160 -0
  39. package/host/engine-js.mjs +98 -0
  40. package/host/engine-pool.mjs +383 -0
  41. package/host/engine-python.mjs +228 -0
  42. package/host/engine-r.mjs +206 -0
  43. package/host/engine-stdio.mjs +401 -0
  44. package/host/journal-store.mjs +749 -0
  45. package/host/main.mjs +503 -0
  46. package/host/planes/README.md +41 -0
  47. package/host/planes/ai-store.mjs +327 -0
  48. package/host/planes/ai.mjs +467 -0
  49. package/host/planes/analyze.mjs +397 -0
  50. package/host/planes/cite.mjs +517 -0
  51. package/host/planes/files.mjs +0 -0
  52. package/host/planes/jobs.mjs +704 -0
  53. package/host/planes/journal.mjs +53 -0
  54. package/host/planes/latex.mjs +201 -0
  55. package/host/planes/mcp.mjs +493 -0
  56. package/host/planes/pair.mjs +325 -0
  57. package/host/planes/pipe-term.mjs +122 -0
  58. package/host/planes/plugins.mjs +112 -0
  59. package/host/planes/proc-tree.mjs +76 -0
  60. package/host/planes/sessions.mjs +434 -0
  61. package/host/planes/settings.mjs +164 -0
  62. package/host/planes/terminal.mjs +286 -0
  63. package/host/planes/test-file.mjs +80 -0
  64. package/host/planes/update.mjs +214 -0
  65. package/host/plugin-store.mjs +838 -0
  66. package/host/server.mjs +441 -0
  67. package/host/settings.mjs +379 -0
  68. package/host/update-record.mjs +59 -0
  69. package/host/user-dirs.mjs +117 -0
  70. package/host/windows-runtime.mjs +145 -0
  71. package/host/worker-plane.mjs +713 -0
  72. package/host/ws.mjs +190 -0
  73. package/kernel/analyze.R +668 -0
  74. package/kernel/deployment.R +165 -0
  75. package/kernel/examples/NOTICE.md +38 -0
  76. package/kernel/examples/tna-complete-tutorial.Rmd +210 -0
  77. package/kernel/fileio.R +656 -0
  78. package/kernel/index.html +96 -0
  79. package/kernel/job-run.R +391 -0
  80. package/kernel/jobs.R +276 -0
  81. package/kernel/kernel-protocol +1 -0
  82. package/kernel/kernel-version +1 -0
  83. package/kernel/kernel.R +671 -0
  84. package/kernel/knitr-run.R +245 -0
  85. package/kernel/latex.R +609 -0
  86. package/kernel/mcp/carmar-mcp.mjs +516 -0
  87. package/kernel/notebook-page.R +67 -0
  88. package/kernel/plugins/csl/apa/apa.csl +2273 -0
  89. package/kernel/plugins/csl/apa/plugin.json +19 -0
  90. package/kernel/plugins/csl/chicago-author-date/chicago-author-date.csl +4216 -0
  91. package/kernel/plugins/csl/chicago-author-date/plugin.json +19 -0
  92. package/kernel/plugins/csl/harvard-cite-them-right/harvard-cite-them-right.csl +316 -0
  93. package/kernel/plugins/csl/harvard-cite-them-right/plugin.json +19 -0
  94. package/kernel/plugins/csl/ieee/ieee.csl +519 -0
  95. package/kernel/plugins/csl/ieee/plugin.json +19 -0
  96. package/kernel/plugins/csl/modern-language-association/modern-language-association.csl +1184 -0
  97. package/kernel/plugins/csl/modern-language-association/plugin.json +19 -0
  98. package/kernel/plugins/csl/nature/nature.csl +189 -0
  99. package/kernel/plugins/csl/nature/plugin.json +19 -0
  100. package/kernel/plugins/latex/apa7/apa7.json +14 -0
  101. package/kernel/plugins/latex/apa7/plugin.json +19 -0
  102. package/kernel/plugins/latex/elsarticle/elsarticle.json +14 -0
  103. package/kernel/plugins/latex/elsarticle/plugin.json +19 -0
  104. package/kernel/plugins/latex/ieeetran/ieeetran.json +10 -0
  105. package/kernel/plugins/latex/ieeetran/plugin.json +19 -0
  106. package/kernel/project.R +131 -0
  107. package/kernel/settings.R +410 -0
  108. package/kernel/sniff.R +769 -0
  109. package/kernel/worker-boot.R +22 -0
  110. package/kernel/worker.R +3496 -0
  111. package/lib/agent-authoring-contract.js +547 -0
  112. package/lib/cell-kinds.js +108 -0
  113. package/lib/engine-labels.js +324 -0
  114. 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>
@@ -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")