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:
@@ -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)))]
|
||||
|
||||
@@ -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))))))
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
99
src/main/frontend/worker/handler/kernel.cljs
Normal file
99
src/main/frontend/worker/handler/kernel.cljs
Normal 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})
|
||||
149
src/main/frontend/worker/handler/live_notes.cljs
Normal file
149
src/main/frontend/worker/handler/live_notes.cljs
Normal 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}))
|
||||
@@ -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]
|
||||
|
||||
@@ -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)})
|
||||
Reference in New Issue
Block a user