Fase B8+B9: stateful code kernel and live notes bound to external URLs

B8 - executable code blocks with a real kernel:
- worker/handler/kernel.cljs: one persistent SCI interpreter session per
  page, reused across block evaluations so defs/state survive between runs.
  Deliberately scoped to the same privileges the existing client-side
  single-shot evaluator already had (no shell/fs access, no call-api).
- Run button + result display wired into block.cljs's code-block UI.
- New :logseq.property.code/result and /executed-at properties (65.38).

B9 - live notes: pages tagged #LiveNote that fetch external content on a
refresh schedule and render it via the render-resource pipeline:
- worker/handler/live_notes.cljs: per-block credential storage, scheduled
  refresh (Node-only), thread-apis for manual refresh and auth.
- worker/handler/render_resource/live_note.cljs: reads back fetched content,
  watching [:entity block-uuid] for reactive invalidation.
- New :logseq.class/LiveNote + :logseq.property.livenote/* properties (65.39).

Bugs found and fixed along the way while actually exercising page creation
through page-handler/create! for the first time in a test (previously only
pure-fn tests existed for CalDAV/Event/Contact):
- logseq-property-namespaces (property.cljs) was missing the citation/event/
  contact/caldav/livenote namespaces, causing auto-create-ontology to treat
  already-built-in properties as new user idents.
- outliner/page.cljs's build-page-tx always added the generic #Page tag even
  when the caller tagged with a built-in class that extends #Page, which
  malli-schema rejects as conflicting; it also lost the property type when
  building ref-typed property values for new pages, breaking non-:default
  types like :number.
- entity-util/page? never recognized Event/Contact/LiveNote as pages (only
  Journal/Whiteboard/Tag/Property/Page had dedicated recognition), so pages
  tagged with those classes alone weren't treated as pages anywhere in the
  app; added built-in-page-child? alongside the existing per-class checks.
- caldav.cljs's upsert-page! passed a bare keyword as a tag where an entity
  was required, and two error logs passed raw error objects to glogi that
  triggered runaway recursion in this test environment; both fixed.
This commit is contained in:
2026-08-26 22:41:23 +02:00
parent 0158430489
commit d8996bd5e1
18 changed files with 698 additions and 13 deletions

View File

@@ -205,7 +205,14 @@
:logseq.property.caldav/uid
:logseq.property.caldav/href
:logseq.property.caldav/etag]
:fix add-calendar-view-type-closed-value}]])
:fix add-calendar-view-type-closed-value}]
["65.38" {:properties [:logseq.property.code/result :logseq.property.code/executed-at]}]
["65.39" {:classes [:logseq.class/LiveNote]
:properties [:logseq.property.livenote/url
:logseq.property.livenote/refresh-minutes
:logseq.property.livenote/content
:logseq.property.livenote/last-fetched-at
:logseq.property.livenote/last-error]}]])
(let [[major minor] (last (sort (map (comp (juxt :major :minor) db-schema/parse-schema-version first)
schema-version->updates)))]

View File

@@ -18,6 +18,8 @@
[frontend.worker.handler.export]
[frontend.worker.handler.flashcard]
[frontend.worker.handler.graph]
[frontend.worker.handler.kernel :as kernel-handler]
[frontend.worker.handler.live-notes :as live-notes-handler]
[frontend.worker.handler.markdown]
[frontend.worker.handler.maintenance]
[frontend.worker.handler.page]
@@ -366,6 +368,8 @@
(js/clearInterval timer))
(swap! *client-ops-cleanup-timers dissoc repo)
(caldav-handler/stop-sync! repo)
(kernel-handler/clear-repo-sessions! repo)
(live-notes-handler/stop-refresh! repo)
(swap! *sqlite-conns dissoc repo)
(when-let [vector-index (worker-state/get-vector-index repo)]
(when-let [close-fn (:close! vector-index)]
@@ -651,6 +655,7 @@
(db-listener/listen-db-changes! repo conn)
(caldav-handler/maybe-start-sync! repo)
(live-notes-handler/maybe-start-refresh! repo)
nil))))))

View File

@@ -371,7 +371,7 @@
(when (seq tx) (ldb/transact! conn tx))
(:block/uuid existing))
(let [[_title page-uuid] (page-handler/create! conn title
{:tags [tag-ident]
{:tags [(d/entity db [:db/ident tag-ident])]
:properties (clean-properties properties)})]
page-uuid))))
@@ -521,7 +521,7 @@
(p/catch (fn [error]
(swap! *sync-status assoc repo {:last-sync-at (js/Date.now())
:last-sync-error (str error)})
(log/error :caldav/sync-failed {:repo repo :error error})
(log/error :caldav/sync-failed {:repo repo :error (str error)})
{:status :error :error (str error)}))))))))
(defn- node-runtime?
@@ -548,7 +548,7 @@
(swap! *sync-intervals assoc repo
(js/setInterval #(<sync! repo) sync-interval-ms)))))
(p/catch (fn [error]
(log/error :caldav/maybe-start-sync-failed {:repo repo :error error}))))))
(log/error :caldav/maybe-start-sync-failed {:repo repo :error (str error)}))))))
(def-thread-api :thread-api/caldav-connect!
[repo config]

View File

@@ -0,0 +1,99 @@
(ns frontend.worker.handler.kernel
"A stateful SCI kernel for executable code blocks: one interpreter context
per page, reused across block evaluations on that page so `def`s and
other side effects persist between runs -- unlike the plain, stateless
per-call eval in frontend.extensions.sci that the block editor already
does for inline `:results` display.
Isolation boundary, chosen deliberately instead of guessed at: this does
NOT expand what code can do beyond what SCI already exposed to the
client-side single-shot evaluator -- no shell/process execution, no
filesystem access, no arbitrary JS interop beyond the small curated
`user` namespace below (a strict subset of frontend.extensions.sci's,
with the browser-only `call-api` binding dropped). Running the kernel in
the daemon rather than the browser tab is what makes results consistent
across clients and lets a session survive a page staying open elsewhere;
it is not a new sandbox. A real subprocess-based sandbox (per the plan's
own \"definir el limite antes de implementar\") is future work, not
something to fake here."
(:require [datascript.core :as d]
[frontend.common.thread-api :refer [def-thread-api]]
[frontend.worker.state :as worker-state]
[lambdaisland.glogi :as log]
[logseq.db :as ldb]
[sci.core :as sci]))
(def ^:private sum (partial apply +))
(defn- average
[coll]
(/ (reduce + coll) (count coll)))
(def ^:private default-user-namespace
{'sum sum
'average average
'parseFloat js/parseFloat
'isNaN js/isNaN
'log js/console.log
'pprint pr-str})
(defonce ^:private *sessions (atom {}))
(defn- session-key
[repo page-uuid]
[repo page-uuid])
(defn- get-or-create-session!
[repo page-uuid]
(let [k (session-key repo page-uuid)]
(or (get @*sessions k)
(let [ctx (sci/init {:namespaces {'user default-user-namespace}})]
(swap! *sessions assoc k ctx)
ctx))))
(defn restart-session!
[repo page-uuid]
(swap! *sessions dissoc (session-key repo page-uuid)))
(defn clear-repo-sessions!
[repo]
(swap! *sessions
(fn [sessions]
(into {} (remove (fn [[[r _page-uuid] _ctx]] (= r repo))) sessions))))
(defn- eval-code
[ctx code]
(try
{:value (sci/eval-string* ctx code)}
(catch :default e
{:error (or (.-message e) (str e))})))
(defn eval-block!
"Evaluates `code` in the persistent kernel session for `page-uuid`, then
persists the formatted result onto `block-uuid` as
:logseq.property.code/result (prefixed with \"Error: \" on failure)."
[repo page-uuid block-uuid code]
(if-let [conn (worker-state/get-datascript-conn repo)]
(let [ctx (get-or-create-session! repo page-uuid)
{:keys [value error]} (eval-code ctx code)
result-text (if error (str "Error: " error) (pr-str value))
block (d/entity @conn [:block/uuid block-uuid])]
(if block
(do
(ldb/transact! conn
[[:db/add (:db/id block) :logseq.property.code/result result-text]
[:db/add (:db/id block) :logseq.property.code/executed-at (js/Date.now())]])
{:result result-text :error? (boolean error)})
(do
(log/warn :kernel/eval-missing-block {:repo repo :block-uuid block-uuid})
{:result nil :error? true})))
{:result nil :error? true}))
(def-thread-api :thread-api/kernel-eval!
[repo page-uuid block-uuid code]
(eval-block! repo page-uuid block-uuid code))
(def-thread-api :thread-api/kernel-restart!
[repo page-uuid]
(restart-session! repo page-uuid)
{:status :restarted})

View File

@@ -0,0 +1,149 @@
(ns frontend.worker.handler.live-notes
"Live notes: pages tagged :logseq.class/LiveNote whose content is fetched
from an external URL on a refresh schedule.
Native, not a plugin: the plugin API's fenced-code-renderer + exper_request
pattern only runs in Electron and has no refresh scheduler or secure
credential store. This follows the render-resource guidance instead --
a background poller `transact!`s fetched content into the graph as a
normal property, and the existing render-resource/affected-keys pipeline
(see render_resource/live_note.cljs) picks up the change and pushes it to
clients with zero bespoke plumbing on the read side.
Node-only, like search embeddings and CalDAV: outbound fetch and the
platform keychain only exist in the daemon runtime."
(:require [datascript.core :as d]
[frontend.common.thread-api :refer [def-thread-api]]
[frontend.worker.platform :as platform]
[frontend.worker.state :as worker-state]
[lambdaisland.glogi :as log]
[logseq.db :as ldb]
[promesa.core :as p]))
(def ^:private default-refresh-minutes 60)
(def ^:private min-refresh-minutes 5)
(def ^:private max-content-length 8000)
(def ^:private tick-interval-ms (* 5 60 1000))
(defn- node-runtime?
[]
(= :node (platform/env-flag (platform/current) :runtime)))
(defn- auth-secret-key
[repo block-uuid]
(str "live-note:" repo ":" block-uuid))
(defn <save-auth!
[repo block-uuid auth-header]
(platform/save-secret-text! (platform/current) (auth-secret-key repo block-uuid) auth-header))
(defn <read-auth
[repo block-uuid]
(platform/read-secret-text (platform/current) (auth-secret-key repo block-uuid)))
(defn <delete-auth!
[repo block-uuid]
(platform/delete-secret-text! (platform/current) (auth-secret-key repo block-uuid)))
(defn effective-refresh-minutes
[refresh-minutes]
(max min-refresh-minutes (or refresh-minutes default-refresh-minutes)))
(defn due?
"Whether a live note is due for a refresh: never fetched, or its own
refresh interval has elapsed since the last fetch."
[now-ms last-fetched-at refresh-minutes]
(or (not (number? last-fetched-at))
(>= (- now-ms last-fetched-at)
(* (effective-refresh-minutes refresh-minutes) 60 1000))))
(defn truncate-content
[text]
(when (string? text)
(if (> (count text) max-content-length)
(str (subs text 0 max-content-length) "\n...(truncated)")
text)))
(defn- <fetch-content!
[fetch-fn url auth-header]
(-> (fetch-fn url (clj->js (cond-> {:method "GET"}
auth-header (assoc :headers {"Authorization" auth-header}))))
(p/then (fn [resp]
(if (.-ok resp)
(.text resp)
(p/rejected (ex-info (str "HTTP " (.-status resp)) {:status (.-status resp)})))))))
(defn- <refresh-page!
[conn fetch-fn repo page]
(let [url (:logseq.property.livenote/url page)]
(-> (p/let [auth-header (<read-auth repo (:block/uuid page))
text (<fetch-content! fetch-fn url auth-header)]
(ldb/transact! conn
[[:db/add (:db/id page) :logseq.property.livenote/content (truncate-content text)]
[:db/add (:db/id page) :logseq.property.livenote/last-fetched-at (js/Date.now())]
[:db/retract (:db/id page) :logseq.property.livenote/last-error]]))
(p/catch (fn [error]
(log/warn :live-notes/refresh-failed
{:page-uuid (str (:block/uuid page)) :error (str error)})
(ldb/transact! conn
[[:db/add (:db/id page) :logseq.property.livenote/last-fetched-at (js/Date.now())]
[:db/add (:db/id page) :logseq.property.livenote/last-error (str error)]]))))))
(defn- live-note-pages
"Guarded on the class existing: an empty graph (skips built-in initial
data) or a graph created before this migration step has no
:logseq.class/LiveNote ident, and an :avet lookup on a non-existent ident
throws rather than returning no results."
[db]
(when (d/entity db :logseq.class/LiveNote)
(->> (d/datoms db :avet :block/tags :logseq.class/LiveNote)
(map (fn [datom] (d/entity db (:e datom))))
(remove #(or (:logseq.property/deleted-at %) (ldb/hidden? %)))
(filter :logseq.property.livenote/url))))
(defn- due-pages
[now-ms pages]
(filter (fn [page]
(due? now-ms
(:logseq.property.livenote/last-fetched-at page)
(:logseq.property.livenote/refresh-minutes page)))
pages))
(defn <tick!
([repo] (<tick! repo js/fetch))
([repo fetch-fn]
(if-let [conn (worker-state/get-datascript-conn repo)]
(let [due (due-pages (js/Date.now()) (live-note-pages @conn))]
(p/all (map #(<refresh-page! conn fetch-fn repo %) due)))
(p/resolved []))))
(defonce ^:private *intervals (atom {}))
(defn stop-refresh!
[repo]
(when-let [interval-id (get @*intervals repo)]
(js/clearInterval interval-id))
(swap! *intervals dissoc repo))
(defn maybe-start-refresh!
[repo]
(when (node-runtime?)
(stop-refresh! repo)
(<tick! repo)
(swap! *intervals assoc repo (js/setInterval #(<tick! repo) tick-interval-ms))))
(def-thread-api :thread-api/live-note-refresh-now!
[repo block-uuid]
(if-let [conn (worker-state/get-datascript-conn repo)]
(if-let [page (d/entity @conn [:block/uuid block-uuid])]
(p/let [_ (<refresh-page! conn js/fetch repo page)]
{:status :ok})
(p/resolved {:status :not-found}))
(p/resolved {:status :not-found})))
(def-thread-api :thread-api/live-note-set-auth!
[repo block-uuid auth-header]
(p/let [_ (if (seq auth-header)
(<save-auth! repo block-uuid auth-header)
(<delete-auth! repo block-uuid))]
{:status :ok}))

View File

@@ -4,6 +4,7 @@
[frontend.worker.handler.block :as block-handler]
[frontend.worker.handler.render-resource.basic :as basic]
[frontend.worker.handler.render-resource.common :as common]
[frontend.worker.handler.render-resource.live-note :as live-note]
[frontend.worker.handler.render-resource.property :as property]
[frontend.worker.handler.render-resource.query :as query]
[frontend.worker.handler.render-resource.similar :as similar]
@@ -16,6 +17,7 @@
view/resource-renderers
query/resource-renderers
similar/resource-renderers
live-note/resource-renderers
{:block-sync-conflicts
(common/renderer nil
(fn [_db resource-key _runtime]

View File

@@ -0,0 +1,20 @@
(ns frontend.worker.handler.render-resource.live-note
"Live note content renderer resource. The content itself is fetched and
transacted by frontend.worker.handler.live-notes; this renderer only
reads it back out and declares the watch key that makes it reactive --
see render_affected_keys.cljs's generic per-entity [:entity uuid] key,
emitted whenever any of that entity's attributes change in a tx."
(:require [frontend.worker.handler.render-resource.common :as common]))
(defn- live-note-content
[db resource-key _runtime]
(let [block-uuid (second resource-key)
page (common/entity-by-uuid! db :block-uuid block-uuid)]
[#{[:entity block-uuid]}
{:url (:logseq.property.livenote/url page)
:content (:logseq.property.livenote/content page)
:last-fetched-at (:logseq.property.livenote/last-fetched-at page)
:last-error (:logseq.property.livenote/last-error page)}]))
(def resource-renderers
{:live-note-content (common/renderer 2 live-note-content)})