Releases: Automattic/newspack-nodes
v0.18.3
Fixed
- The Topology dashboard resolves a
Topic's{partition}template instead of rendering the literal token. The graph view recognized only the<partition>(angle) token, so a multi-partitionTopicvertex (firehose.p{partition}— e.g. the aggregator's hub fan-in) showed the literal token with "No segments" rather than grouping into onefirehoseentity with its concrete per-partition rows. It now matches both the<partition>and{partition}tokens.
Removed
- Dead
Topology_Registry::basenames_for()(and its test) — superseded by the layout-agnosticresolved_resource_dirs()(which the GC and dashboard already use). It had no production caller; the<partition>-only, Partition-only regex was a stale parser.
v0.18.2
Changed
Command_Interpreter_Nodenow logs unauthorized commands instead of failing silently. A rejected command emits a rate-limitedWARNING: unauthorized: <verb> - TM_COMMAND from: <path> payload: ...audit line (viadrop_message). This surfaces cross-session REPL/IPC traffic — e.g. acmdissued in one pivoted session reaching another session's interpreter, where the auth gate correctly refuses it. Rejection behavior is unchanged (still refused with aTM_COMMAND|TM_ERRORreply); this only adds the audit log.
v0.18.1
Fixed
wp nodes clino longer dumps other sessions' IPC (including browser traffic) into a pivoted REPL. The reply path read the shared worker output partition with aConsumertargeting_output, andConsumer::forward_line()unconditionally overwrites each emitted message's TO with its target — so every session's replies were rewritten to_outputand the Dumper's per-PIDto_filtercould no longer drop the ones addressed elsewhere. The output-IPC consumer now sinks through a plainNode, which stamps TO from its target only when TO is empty (soft route), so each reply keeps its own TO and other sessions are filtered out. A regression test pins theConsumer(unconditional) vsNode(soft) TO behavior.
Security
- Pinned the
@babel/core(≤7.29.0) andjs-yaml(≤4.1.1) transitive dev dependencies to patched versions (^7.29.6/^4.2.0) viaoverrides, clearing the Dependabot advisories that had no auto-fix PR.
Changed
- Release CI now builds on Node 24 / npm 11 (was Node 20 / npm 10), matching contributors' toolchain so committed lockfiles stay in sync.
v0.18.0
Added
- Consumer gains an opt-in
line_mode(config verbset_line_mode) that forwards one line per poll instead of a whole read-block. A Consumer whose sink does heavy per-message work (e.g. an LLM enrich) would otherwise drain a full 64 KB block in onefire(), freezing the worker's heartbeat through the burst; line mode spreads it across drain cycles so the worker stays live. Enable it in a topology before the first poll:cmd <consumer>:config set_line_mode. Default (batch) behavior is unchanged —poll_activestill pipelines a read every tick; line mode reads only once the buffer is dry of complete lines. Internallydrain_buffer()is one offset-scanning pass capped byline_mode(1 line) or unbounded (batch), a single O(n) scan that also fixes a latent batch-mode cursor drift on an empty (\n\n) line (the oldrtrim+explodedropped the empty line's byte, mis-aligning the next read), andforward_line()is the per-line emit seam.
Changed
Tailis line-only and overrides just theforward_line()emit seam, reusing Consumer's buffer/cursor scan. Tail's duplicated drain loop is gone — it customizes only how a line is emitted (rawTM_BYTESTREAMbytes vs an unpacked Message). The unusedblock-buffered/binarybuffer_modes (set by no.tsl— only tests) are removed, and a Tail now supportsline_modefor free. The inherited overflow guard logs the real runtime node class (Tail:/Consumer:).
Changed
-
arguments()parsing is centralized inSchema_Reflection::parse_schema_args()— a missing token takes the arg's schemadefault, or throws if the arg isrequired. This retires ADR-11's empty-string short-circuit (the recurring footgun where every config-bearingarguments()override had to mirrorif ( '' === $args ) return;or derive filesystem-root junk like/p0). The parser now records the raw string into$this->arguments(sodump_config()still round-trips) and is the single source of truth for defaults. Behavior change: a baremake_node <Type> <name>of a node with arequiredarg (Partitiondir, Consumersource_dir, Topicdir_template, Hookhook_name) now throws at construction (fail fast and loud) instead of yielding an unconfigured node that derives garbage.Topic'snum_partitionsmoved fromrequiredtodefault 1(belt-and-suspenders behind the usual<config:num_partitions>token). ADR-11 and its AGENTS.md row are updated to match. -
Service CIs are no longer draggable in the topology console palette, but keep their inspector verb buttons. Service CIs are mounted into the request graph (never
make_node'd), so dragging one from the palette only mints a stray duplicate. The palette now skips theServicecategory, while the class stays in the Classes_CI catalog — so selecting a mounted Service CI in the canvas still renders its command/request buttons in the inspector (which resolves verbs viacatalog.find( shell_name ), independent of the palette). This is the palette-only hide thatcategory: 'Hidden'couldn't give (Hidden drops the class from the catalog entirely, killing the inspector buttons too).
Added
-
The build kit externalizes
@wordpress/blocksand@wordpress/block-library. Added both to the kit'sWP_EXTERNALSallow-list (global: window.wp.blocks/window.wp.blockLibrary, handleswp-blocks/wp-block-library), so a dashboard bundle that uses the editor's block APIs (e.g.pasteHandler,registerCoreBlocks) reuses WordPress's own enqueued registry instead of bundling a duplicate. Additive — only affects bundles that import those packages. -
The DevTools hub tabs are deep-linkable, and Raw Logs can deep-link a selected log. The hub page slug is now
newspack-nodes-hub(wasnewspack-nodes-topology;Admin::TOPOLOGY_MENU_SLUG→Admin::HUB_MENU_SLUG), and each hub tab has a distinct URL:?page=newspack-nodes-hub&tab=console|&tab=topologies|&tab=raw-logs.DevtoolsTabHostgained an opt-insyncUrlprop (the hub passes it; the floating overlay and every other consumer stay URL-free): it reads the initial tab from?tab=<slug>and mirrors the active tab back viahistory.replaceState(no reload, no back-button spam), preserving sibling params. Tab descriptors gained an optionalslug(defaults to the tab id). Raw Logs reads?log=<name>once on its first non-empty catalog to seed the selection (never clobbering a later user pick) and writes?log=when you choose a log. The Topology Manager's console links now point at?page=newspack-nodes-hub&tab=console&topology=<name>. No backward-compat redirect for the old slug. Each tab declares the query param it owns (Consoletopology, Raw Logslog); switching tabs drops the other tabs' params, so the URL only ever carries the active tab's.
Fixed
-
Topology Manager headings align, and the redundant per-partition "stalled" pill is gone. The per-partition stalled pill duplicated both the partition badge and the right-hand health rollup (three "stalled"s); it's removed, leaving the health rollup as the single stalled indicator. The per-partition cluster and the health field now have fixed-width slots so the badges line up across rows.
-
Node::set_state()no longer emits a PHP 8.4 deprecation. Its$payloadparameter was implicitly nullable (string $payload = null); it's now explicitly?string $payload = null. -
The debug overlay behaves when you switch DevTools hub tabs. Each hub tab now persists its OWN overlay canvas layout — the node-position key is per-tab (
…:hub:<tab>) instead of one shared…:hub:localkey that loaded one tab's positions as garbage anchors on every other tab (this happened even with the overlay closed during the switch). And the overlay's graph follows the active tab on a switch instead of collapsing to_outputor freezing on the previous tab. The destructive auto-resync (aresetGraph()on the mount bump that tore down the freshly-mounted tab's nodes) is removed; the build-delegated mount bump is kept but now only makes the overlay rebuild its_metadatapoll on the fresh backbone — the previous code left that poll hitchhiking the torn-down_router, so the graph froze on the old tab (clicking Reset Graph + Reset Layout was the manual workaround). The window position stays global (only node positions are per-tab); the manual chips still work. -
Switching to the Console tab with the debug overlay open no longer white-screens. It threw
node name collision: _output already registered: the Console registers a_outputnode in its mount effect, while the overlay (gated off the Console tab, but via a one-commit-lateuseEffect) still held its own render-registered_output.DevtoolsTabHost's tab button now reports the tab change to the host SYNCHRONOUSLY (in the click handler, not only the follow-up effect), so React batches it: the overlay unmounts in the SAME commit the Console mounts, and React runs the overlay's_output-removal (passive cleanup) before the Console's_output-registration (passive effect) — they never coexist. -
Saving the Nodes Runtime settings no longer deactivates every topology. The active-topology set (
newspack_nodes_topologies) was a registered settings-group option that the settings page never rendered (the Topology Manager is the activation UI). WordPress'soptions.phpsanitizes every registered option in the group from$_POSTon Save, and an absent one ran throughsanitize_topologies(null)→[], wiping the active set on every Save. The field is now overlay-only (ui: false): still loaded + autoloaded for the per-request config overlay, but outside the settings group and the reset surface, so Save (and Reset) can't touch it. The activate/deactivate verbs +Supervisor::check_configkeep the conflict protection the form sanitizer used to add, so the now-obsoleteAdmin::sanitize_topologiesis removed. -
Deactivating ALL topologies at once now stops every running worker instead of leaving them up for ~10 more minutes. When the operator deactivates the whole fleet (e.g. toggling every topology off in the Topology Manager),
check_config()short-circuited on the empty active set beforereconcile_lock_dirs()— the established stop path — so no worker ever got a restart flag and each self-respawned until its lock aged out. The supervisor now drains every worker lock dir (*.p<N>.lock.d, leavingsupervisor.lock.duntouched) before exiting when it previously had an active fleet. Cold start (nothing was ever active) still exits quietly and drops no flags. -
The Topology Manager's supervisor restart button matches the per-topology restart buttons. It shared the worker-tree's
worker-restart-btnstyle (smaller, muted, different shape) while sitting directly above the per-topology↻controls; it now uses the samenodes-tm__restartstyle, and the now-unused.worker-restart-btnrule is removed. -
Topology names get a fixed-width label column so their status pills line up. The per-topology heading laid the name and pills in a flex row with no reserved label width, so the
P0/P1/badge pills started at a different x on every row (name length drove it); amin-widthon.nodes-tm__namealigns them into a consistent column. -
The
example-ai-newsletterdemo now reads its ownexample-scoredlog, isolated from a real plugin. Its topology wrote the scored partition/consumer to the barescored.p*path andInsights_CI_Demoread the same — but that path is substrate-global, so the demo dashboard showed whatever real plugin'sscoredlog happened to be populated (e.g. the productnewspack-ai-newsletter's), not its own deterministic data. The demo's scored partition, consumer offsets, and the demo CI's glob are now namespaced toexample-scored.p*(itsdigest.mdwas already namespaced), so the example is self-contained. -
**SSE ingress rejects malformed typel...
v0.17.0
Added
-
Eslint rule banning the deprecated
isSmallButton prop.react/forbid-component-propsrejectsisSmall(deprecated in WP 6.2 forsize="small") at the JSX-attribute level. This repo has no current usage, but it owns the canonical shared JS the sibling plugins inline via@newspack-nodes/shared— the same rule now guards all three repos so the family can't drift back. -
Live-mode targets editor in the topology console + debug overlay. The edit-mode targets UI (target chips with a clearable
×+ "+ add target…" dropdown) now renders in the Inspector's live/view branch too, so an operator can disconnect a running node's targets — previously only live connect (via canvas drag) was possible. The chip×dispatches a runtimedisconnect_node <node> <target>; the dropdown reuses the existingconnect_node. Edits mutate the running node'stargetonly — no.tslpersistence (that stays edit-mode's job). The editor reads the node's full uncollapsedtargets(not the headOf-collapsed / registration-polluted graph edges) so a path target disconnects by its exact value, and is hidden for reserved nodes. NewuseGraphHandlers.onRemoveEdge(mirrorsonConnect); no new runtime verbs. -
send_structshell builtin (PHPShell_Node+ JSShellNode).send_struct <path> <json>sends aTM_STRUCTmessage whose VALUE is the parsed JSON to<path>— the structured-data counterpart tosend(which sends aTM_BYTESTREAMstring). Single-quote the JSON so the quote-aware tokenizer keeps it as one token (send_struct echo '{ "foo": 23, "bar": 42 }'), exactly as Tachikoma'ssend_hashdid. Invalid JSON surfaces the decoder's error and sends nothing (the builtin runs in the Shell, before the command-interpreter's central catch, so it reports the error itself). Renamed from Tachikoma'ssend_hash/TM_STORABLEtosend_struct/TM_STRUCTto match our wire vocabulary.
Changed
- JS base
Nodeargumentsis the trivial Tachikoma getter/setter, mirroring PHP. The baseset argumentsnow only stores the raw string — it no longer auto-walksnodeSchema().argumentsonto declared properties. The positional walk moved to an exportedparseSchemaArgs( node, args )helper (the JS analog of PHP'sSchema_Reflection::parse_schema_args), which a node opts into from its own setter override;SseConnectorNodenow does so explicitly. This matches PHPNode::arguments()(trivial) + theSchema_Reflectiontrait, closing a JS/PHP fidelity gap. An emptyargumentsstring is now a no-op (PHP-faithful) rather than applying schema defaults;Timer_Nodeis unaffected (itsinterval_mscomes fromsetTimer(), not the walk). Hook_Nodepasses the message VALUE to the WordPress hook, not the whole positional envelope.do_action()/apply_filters()now receivemessage[VALUE](the payload) instead of the 7-field message array, so hook callbacks work with the data directly. In filter mode theapply_filters()return is always adopted as the newVALUE, andTYPEis set from its shape — a list-array return marks the messageTM_STRUCT, anything elseTM_BYTESTREAM(the prior "drop a non-list return and forward the previous message with a warning" behavior is gone). Hook_Node also forwards viaparent::fill()now, so it participates in the uniformtarget→TOstamping contract. Callbacks registered on a Hook_Node's hook must take/return the payload, not a[TYPE, …, VALUE]array.
Fixed
SseConnectorNodedrops frames from a closed (or reopened-past) stream. Themsg/heartbeatlisteners now bail when their EventSource is no longer the connector's current one, so a late frame delivered afterclose()— a teardown race, or a test double that retains listeners — never reaches the torn-down sink. Without the guard the forwarded frame hit a sink-less node andfill()threw (Node.fill requires a wired sink); graph teardown is now race-safe.- JS runtime missing
TM_NOREPLYreply-control flag (PHP/JS fidelity drift). The browser substrate'smessage.jslackedTM_NOREPLY = 512and the JSShellNode/CommandInterpreterNodehad nowant_replymachinery — so the JS side could never suppress a reply the way the PHP runtime does for script/topology-load commands. Ported the PHP behavior:message.jsnow exportsTM_NOREPLY = 512;ShellNodegains awantReply()accessor (defaulttrue) and astampNoreply()step that ORsTM_NOREPLYonto builtTM_COMMANDmessages (inparse()andsendCommand()) when reply is unwanted;CommandInterpreterNode._respond()now suppresses the routed reply for aTM_NOREPLYcommand, surfacing only an error to stderr — mirroring PHPCommand_Interpreter_Node::interpret(). Hook_Node::fill()counted each message twice. The switch toparent::fill()(which incrementscounter) left a redundant local++$this->counter; removed so the node counts each message once.
v0.16.2
Changed
SseConnectorNode(_sse) now tracks stream liveness, and the Raw Logs dashboard reads it for "Xs ago". The connector — the one node that sees every inbound frame — stamps a publiclastEventTimeon eachmsgAND on the server's idleheartbeatevent (snooped, not routed, so the topology-console transcript is unaffected), and clears it onclose(). The Raw Logs viewer sources its "Xs ago" staleness from_sse.lastEventTimeinstead of row arrivals, so an idle-but-healthy stream resets the counter on each heartbeat instead of climbing like a dead connection; a real drop (no heartbeats) leaves it frozen and "ago" climbs as the intended warning. Sibling application dashboards (event-logger-nodes) read the same_sse.lastEventTime.
v0.16.1
Changed
Job_Worker_Nodedispatches on the entry-levelkfield, nottype. jobs.log / jobintake.log entries carry the job kind (job|remote_job) underk— the same firehose category fieldJob_Intakewrites verbatim,Job_Routercarries through, and the hub'sStream_Mergerrewritesjob→remote_job— so the executor readskto pick the local vs. remote handler map. This makes the kind field uniform end-to-end (firehose category → jobintake → jobs.log → worker) with no rename at any hop, and fixes jobintake-sourced jobs being silently dropped when a topology wiresjobintake:consumerstraight tojobs:partition(bypassing the router, e.g. event-logger-nodes'combinedtopology). Any plugin that hand-builds a job entry must key the kind ask, nottype.
v0.16.0
Added
Partition_Node::void_warranty()— lifts the 4 KB PIPE_BUF write cap WITHOUT acquiring the per-partition exclusivity lock; the no-lock sibling ofallow_large_writes(). The caller asserts it is the partition's sole writer (e.g. a worker that already owns the topology lock); concurrent writers + this = silent torn-write corruption, so the name is deliberately alarming.dump_configround-trips the distinct verb. The lock-acquiringallow_large_writes()remains for cross-process write targets.Consumer_Nodesnapshots a named node's state into the offsetlog (Tachikoma'scache_type=snapshot).set_snapshot_node()— a:configverb — co-commits{offset, cache}as ONE record on each checkpoint, taking the node's duck-typedsave_state(), and restores it viarestore_state()on respawn. So a stateful node like the request-builder resumes its in-flight cache aligned with the resumed read offset — no separate state file, no offset/cache drift. The offsetlog isvoid_warranty'd (the worker is its sole writer); a missing snapshot node logs loudly rather than dropping state silently.Topology_Registry::find_conflicts()/write_set()— detect when two enabled topologies would write the same file (a data partition or a Consumer offsetlog) and corrupt it. The write-set is parsed frommake_node Partition/make_node Topicpaths (both append to the log at their path arg) +make_node Consumeroffsetlog args; conflicts are reported as topology pairs + the shared resource.- Write-conflict enforcement at both gates.
Admin::sanitize_topologies()rejects the whole topology selection when the enabled set has a write-conflict — it raises a settings error naming the conflicting pairs and keeps the previously-saved set rather than persisting a config that corrupts its own logs. The supervisor re-checks the active set incheck_config()and refuses to spawn any worker (exits loudly; the cron retries each minute) when the set conflicts — a second line of defense for a config-FILE override that bypasses the admin UI. Together these replace the cross-process protection the per-partitionallow_large_writeslock used to provide, letting worker-output partitions drop the lock forvoid_warranty. - Registration edges on the topology canvas.
dump_metadatanow emits a per-noderegistrationsfield — the node-nameregister()listeners, keyed by event — via the newNode::registered_listeners()accessor, andparseMetadataturns each into a dashed, informational canvas edge from emitter to listener (SchematicCanvasis-registration: dotted sage, event-name hover tooltip, no edit-mode hit-target). Event wiring that was previously invisible is now drawn. Edges appear only between two visible nodes: a registration whose emitter is hidden scaffolding (e.g. every Timer'sTIMERsubscription to_router) draws nothing, sinceparseMetadataalready skips scaffolding. Both producers — PHPcmd_dump_metadataand JSdumpMetadataPayload— emit the field only when non-empty, keeping them byte-identical (PHP[]vs JS{}). register/unregisterREPL verbs (both the worker-tier and in-browser interpreters) — Tachikoma'sregister <source name> <target name> <event>/unregister <source name> <target name> <event>.registerwirestargetas a node-name listener foreventonsource(source->register( event, target ));unregisterdrops it. The means to create the registration edges live from the console — the siblings ofconnect_node/disconnect_node. Validation matches the reference (source + target must exist forregister;unregisterskips the target check so a vanished target can still be cleared), and registering an event the source hasn't declared surfaces as a command error.
Changed
- Timer scheduling state moved onto
Timer_Nodeitself. A timer now carries its owninterval_ms/oneshot/next_fire/active/fire_countas public properties, andEvent_Framework::set_timer()takes just the node (set_timer( Timer_Node $node )) — the framework reads the cadence off the node and stamps the first fire.Timer_Node::set_key()is folded into akey()getter/setter, andis_active()/fire_count()are dropped in favor of the public$active/$fire_count. Partition's heartbeat timer arms viaarguments()+key()to match. The Router-hitchhike preconditions stay fail-loud: no-argset_timer()throws if the timer is unnamed or no_routeris present (now covered by tests asserting each message). - SSE drain test seam is the
check_slotClosure, notset_test_mode()/set_test_iterations(). Those helpers — and the bounded-iteration counter insiderun_stream_loop()— are removed; tests bound the loop by assigning a counting closure to the production slot-liveness seamSSE_Out_Node::$check_slot. Production behavior is unchanged:connection_aborted()plus the real slot check still terminate the stream. Consumer_Nodereads one block per poll (drain-then-read), not all segments at once.poll()now dispatches through a$poll_cbfunction pointer (Tachikoma's$self->{fill}): each tick drains the already-buffered block, then reads at most oneREAD_BLOCK_BYTES(64 KB) block viaget_batch(), then yields the event loop — mirroring Tachikomafire()+Partition::process_get, so a worker draining a backlog can't monopolize the drain loop.publish_position()is throttled to once perPUBLISH_INTERVAL_S(1 s) instead of every tick. The internal$line_remainderbecame$buffer(read-ahead). The removed publicMAX_POLL_BYTESconstant is replaced byREAD_BLOCK_BYTES;Reqgrep_Command(event-logger-nodes) carries its ownREAD_CHUNK_BYTESinstead.Classes_CI_NodeextendsService_CI_Nodelike the other service interpreters. Its singlelistverb is now declared innode_schema()carrying its handler, and the base constructor derives the dispatch table — replacing the bespoke__construct()+command_table(). Behavior is unchanged (same catalog output); it just stops being the one service CI that hand-rolled its own command table.
Fixed
-
Consumer snapshot restore is order-independent — a forward-referenced snapshot node no longer discards its cache on every restart.
set_snapshot_node()now only records the name; the offsetlog read andrestore_state()are deferred to the firstpoll()(a newpoll_initphase) which runs inside the drain loop, after the whole topology graph is built. A per-node-serialized topology that emitsset_snapshot_node request-builderbeforemake_node request-builderpreviously logged "snapshot node missing or has no restore_state(); discarding restored cache" and dropped the request-builder's in-flight LRU cache on every worker recycle; the restore now finds the by-then-built node and resumes its state aligned with the resumed read offset.arguments()no longer does offsetlog I/O at construction; the IPC-input Consumer seeksnext_offset('end')at build andpoll_initoverrides with the durable checkpoint when one exists (resume wins). -
Topology console Connect↔Disconnect toggle flips again (and works for the in-browser JS tee).
dump_metadatanow stamps a reserved_header.pwdcarrying the requesting session's reply pivot — the reverse_cwd, i.e. the inbound message FROM, which is the exact target aconnect_nodewith no target stores on a Tee. Both tiers emit it: the worker'scmd_dump_metadatafrom the envelope FROM (full snapshot only), the in-browserdumpMetadataPayloadas the bare_output.parseMetadataexposes it asgraph.pwd(and preserves each node's FULLtargets, since canvas edges head-collapse every session's pivot to one shared_repl), and the Inspector toggles on an exactnode.targets.includes(parsed.pwd). Previously the matcher reconstructed a hardcoded path whose middle segment (_http) the v0.15.0_Noderename changed to_output, so it never matched and the button was stuck on "Connect". Two further mismatches are reconciled: thepwdarrives ending in the POLLING node's reply segment (…/_metadata, since the canvas polls FROM_metadata) but a tail target ends in the shell's_output, socanonicalReplyPivot()forces the final segment to_output(used by both the matcher and the optimistic patch); anddumpMetadataPayloadnow reports the SHELL name (Tee, stripping_Node) like the worker does, so the in-browser tee's Connect button doesn't vanish on the next poll under aTeeNodeclass. -
Live-graph gestures reflect immediately instead of waiting out the ~5s metadata poll. The Tee tail/disconnect optimistic patches were wrong —
tailreplaced the whole fan-out array with the string_output,disconnectcleared ALL of a Tee's edges. They now append/remove only this session'spwdin the array (preserving the Tee's other edges), the Trace button optimistically patchesdebug_stateso it flips at once, andaugmentWithVirtualEdgespreserves the graph'spwdso the toggle survives verb-arg edge synthesis. -
Timers initialize
next_firewhen armed.Event_Framework::set_timer()now stampsnext_fire = now + interval_ms/1000. Without it a timer fired immediately on the first drain pass and the next-wait calculation went negative, busy-looping instead of sleeping the interval (an idle SSE stream emitted no heartbeats). Partition's heartbeat interval is computed withintdiv()so a non-÷3stale_timeoutcan't produce a non-integer string the Timer argument validator would reject. -
Node-name event dispatch delivers directly instead of re-routing through
_router.notify()'s node-name path resolved the listener viaCore::node( $listener )and then ALSO stampedTO = listener, so theTM_INFOwas re-routed through_routeron top of the directfill(). Across an SSE pivot that re-route lands on an endpoint where neither the listener n...
v0.15.1
Added
- Supervisor cron diagnostics now cover late
schedule_eventvetoes too. WordPress reports those asschedule_event_false, but by the time a late filter sees the failure the event object has been replaced by a falsy value; Nodes now remembers the supervisor event at the start of theschedule_eventchain and logs the callback chain if a later callback vetoes it.
Changed
- Substrate runtime wiring is no longer built at plugin-file scope. The node-class namespaces (for
make_node), the<config:…>token namespace, the stock-topology dir, and the sharedCore::$memdhandle moved out ofnewspack-nodes.phpfile scope into the idempotentBootstrap::ensure_runtime_wired(), called lazily from the entry points that actually use the node graph / cache:rest_api_init(priority 1, before route callbacks), the admin and WP-CLI blocks, and the supervisor cron tick. A plain frontend page view touches none of these, so it no longer autoloads the Config System /Command_Interpreter_Node/Topology_Registryor opens a\Memcachedconnection it never uses — cutting substrate plugin-load from ~1.6ms to ~0.24ms (the per-request hot path the v0.13.0 Config System had regressed).get_topology_catalog()self-wires, so the catalog can't be read partially built. - Removed the internal
newspack_nodes/enable_supervisorfilter (renamed fromnewspack_nodes/enable_logging), which only ever existed to support tests — no config field, no production caller. The supervisor enable gate is now theBootstrap::$supervisor_enabled_overridetest seam with the same default-on behavior;is_logging_enabled()is renamedis_supervisor_enabled(). Construction of theSupervisoris injectable via the newBootstrap::$supervisor_factorytest seam. - Raise the declared PHP floor to 8.2, matching production syntax already used by the substrate (
File_Writertrait constants, plus PHP 8.1array_is_list()calls), and align the bundled example/plugin-writing guide with that floor.
v0.15.0
Security
dump_nodenow redacts credentials for every node, not just the one that remembered to.Node::dump_node()reflects every property, so any node holding a secret printed it raw to the REPL (dump_node my_node) and into logs — a credential-disclosure vector. Redaction was bolted onto a singleRemote_Source_Node::dump_node()override (it scrubbedauth_password/auth_token); every other node was unprotected. The base now redacts any non-empty property whose name reads as a credential (password,passwd,secret,token,credential,api_key,private_key— deliberately not bareauth, soauth_usernameandauthorizesurvive), replacing the value with[REDACTED]. The bespokeRemote_Source_Nodeoverride is removed (the base covers its fields). Empty secrets stay visible as''so an operator can tell a credential is unset.
Changed
Nodesheds two god-object concerns into opt-in traits;arguments()is now the trivial Tachikoma getter/setter. BaseNode::arguments()no longer walksnode_schema()['arguments']— it just stores and returns the raw string (if ( @_ ) { $self->{arguments} = shift }), matching the reference. The schema-reflection machinery (the positional-arg walk + the{name}:configinterpreter auto-wire) moved into a newSchema_Reflectiontrait,used only by the ~11 nodes that actually parse args or auto-wire a:configsibling. The fail-loud durable-write primitive (write_all()+ thewrite_failurescounter +MAX_WRITE_ATTEMPTS) moved into a newFile_Writertrait,used only byLogandPartition. A logic node (Tee, Echo, Hook, Router…) no longer inherits a schema walker, an interpreter auto-wirer, or anfwriteretry loop it never uses. Arg-parsing nodes call$this->parse_schema_args( $args )from their ownarguments()override; the nodes with:configcommand handlers (Partition, Request_Builder, Stream_Merger, Flame_Builder, plus the bundlednewspack-ai-newsletterexample nodes) call$this->auto_wire_interpreter()in their ctor. Single-arg / no-:confignodes (Timer, Cache_Warmer_Tick, Health_Check_Tick, Job_Router) parse inline and carry neither trait. Behavior is unchanged;dump_configround-trips identically.write_failuresnow appears indump_nodefor file-writing nodes only (not every node).as_string()andhas_value()moved fromNodetoCore. They're generic scalar/presence helpers used well beyond Node (Message-field coercion, Perl-length()-style presence checks); they live onCorenow. Callers useCore::as_string()/Core::has_value(). Another step toward a leanNode.- Nodes now resolves the
@newspack-nodes/{shared,debug-overlay}aliases in its own build/jest/eslint, and dogfoods them. The three@newspack-nodes/*aliases are the public JS consumption surface, but onlyruntimewas wired in nodes' ownbuild.mjs/jest.config.js—sharedanddebug-overlayexisted only in consumer builds, so nodes' own dashboards reached intosrc/shared/via relative paths (../../shared/hooks/...) that a third party reading nodes as the reference can't copy. Both aliases are now wired intobuild.mjs(esbuild),jest.config.js(moduleNameMapper), and.eslintrc.js(core-modules + no-unresolved ignore), all pointing at nodes' own canonicalsrc/(it is the home — no sibling fallback), and the 10 cross-packageshared/imports were rewritten to@newspack-nodes/shared/…so nodes imports shared code through the exact path consumers use.
Fixed
- Parallel test runs no longer delete each other's live temp dirs (a real coverage-suite flake).
make_temp_dir()usedsys_get_temp_dir() . '/' . $prefix . uniqid()— bareuniqid()is microtime-based and collides across the parallel processesrun-coveragespawns (nodes + ELN + pyrobase at once), and the nodes and ELN suites both defaulted to thenewspack-nodes-test-prefix (ELN inherits the helper) and bothrm -rf /tmp/newspack-nodes-test-*in theirrun-coverage.sh— so each suite deleted the other's in-flight Partition segment dirs mid-run (surfacing as e.g.rotate_segmentadopting segment 0 instead of 1). Now:make_temp_dir()is PID + more-entropy unique; ELN defaults to its ownnewspack-event-logger-nodes-test-prefix and purges only that; and the baseTestCase::tearDown()auto-removes every dir it handed out (test-only; a temp dir is only temporary if someone deletes it). - Failed log writes are no longer silently swallowed (disk full = data loss with no signal).
Log_Node::fill()ignored itsfwrite()return value entirely, andPartition_Nodehad a separateloop_fwrite()that did retry/return on a stall but whose callers just dropped the batch with only a rate-limited print — two divergent write paths, one of which lost firehose lines on a full disk with zero trace. Both now route through one sharedNode::write_all()primitive (the substrate's single durable-write seam): it retries short writes up toMAX_WRITE_ATTEMPTS, and on a genuine stall (ENOSPC/ broken pipe) increments a newwrite_failurescounter — surfaced indump_nodefor every node — and emits one loud, rate-limited line naming the path. The happy path is unchanged (a singlefwrite, no extra work).Log_Nodealso no longer advances its rotation size counter or triggers a rotate on a write that didn't land, and the companion-index write is covered by the same primitive. Consumers (event-logger-nodes, pyrobase, nuclear-gyrobase) inherit the fix throughLog_Manager→Log/Partition. - Lock steal is now atomic and no longer sleeps in request scope.
Lock_Node::acquire()'s orphan-takeover path had two flaws. (1) It judged an orphan (lock dir with no heartbeat — owner crashed betweenmkdirand the heartbeat write) by callingsleep(ORPHAN_GRACE_S)insideacquire(), which runs in request scope (SSE / CLI), stalling those requests a full second. It now judges by the dir's own mtime (time() - filemtime(dir) >= ORPHAN_GRACE_S) with no blocking — a real orphan's dir mtime stays at its creation time because nothing is written into the dir before the heartbeat. (2) The takeover itself wasforce_release_at()thenmkdir()— two racers could both pass the staleness check, each delete the other's freshly-written heartbeat, and both believe they hold the lock (a reachable partition double-writer). Takeover now goes throughsteal_atomically(), whichrename()s the dir aside (directory rename is atomic, so exactly one racer wins) before recreating it; the single-holder guarantee rests onmkdir-on-existing failing. Companion:Supervisor::reconcile_lock_dirs()now reaps leaked*.lock.d.stealing.*scratch dirs older thanSTALE_TIMEOUT(a process killed in the two-syscall steal window would otherwise leak one with nothing to clean it up). - Consumer recovers when its cursor segment is wiped and recreated smaller. A full retention sweep deletes every segment of a log and the Partition writer restarts numbering at 0 — but the durable offsetlog survives, so a Consumer restoring its checkpoint could land with
cursor_offfar past EOF of the recreated segment.poll()only handled the deleted-segment case (cursor id missing from the segment list), so the consumer waited forever for the file to grow back past the stale offset; in production this silently wedgedjobs:consumer(evTemplate and remote-manager jobs piled up injobs.log, unexecuted) while the firehose consumer recovered only because its cursor id happened to no longer exist. Cursor recovery now lives in one sharednormalize_cursor(): a missing cursor segment rewinds to the oldest segment, and a cursor (plus pending partial line) past the EOF of a recreated segment rewinds to offset 0. Companion fix:GET_LAGcomputed lag from the raw cursor, so both recovery cases read asbytes_behind=0/caught_up=true— masking a wedged consumer as healthy; it now normalizes first and reports the replaypoll()will actually do.