@stravica-ai/digital-operator-pa 0.1.1 → 0.1.2

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.
@@ -12,9 +12,26 @@
12
12
  * - A missing or absent operator manifest is not an error.
13
13
  * - A malformed manifest is logged and skipped; the other side still loads.
14
14
  *
15
- * Caching: each load checks both manifest mtimes. The cached merged list is
16
- * returned unchanged if neither file changed since the last load. Missing
17
- * files are tracked with mtime 0 so their appearance triggers a reload.
15
+ * Per-device scoping: after merging, entries whose target page is not
16
+ * materialised on THIS device are dropped (not just warned). The estate runs
17
+ * one shared nav manifest that lists every page across all library repos, but
18
+ * `library-sync` only writes a page file onto a device when the repo that owns
19
+ * it is mounted there. So the presence of the href's target page file IS the
20
+ * per-entry materialisation signal — an hq-only page (e.g. article-ideas,
21
+ * content-calendar, both under operator/extensions/workspace/) is absent on a
22
+ * device that doesn't mount stravica-library-hq, so its nav entry is hidden
23
+ * rather than rendered as a dangling 404 link. This needs no per-entry "owner"
24
+ * field and no coupling to the .library-sync manifest internals: the page file
25
+ * library-sync lays down (or omits) is the source of truth. Hrefs whose shape
26
+ * we can't resolve to a local page (external links, deep paths) are left
27
+ * untouched.
28
+ *
29
+ * Caching: each load checks both manifest mtimes. The cached (merged + scoped)
30
+ * list is returned unchanged if neither file changed since the last load.
31
+ * Missing files are tracked with mtime 0 so their appearance triggers a reload.
32
+ * `library-sync` rewrites the extension nav.json as part of the same mount tree
33
+ * whenever it materialises or removes pages, so a materialisation transition
34
+ * always bumps a manifest mtime and re-triggers scoping.
18
35
  */
19
36
 
20
37
  import { readFileSync, statSync, existsSync } from 'node:fs';
@@ -107,35 +124,50 @@ function mergeItems(coreItems, extItems, warn) {
107
124
  }
108
125
 
109
126
  /**
110
- * Validate that every item's href resolves to an existing page, either under
111
- * APP_DIR or EXT_DIR (for top-level /<name>.html). Non-resolving items are
112
- * kept in the list with a warning so operators can see their work-in-progress.
127
+ * Classify an href by whether its target page is materialised on this device.
128
+ * Returns 'present', 'absent', or 'unresolvable'.
129
+ *
130
+ * - Core-shape paths (/core/tools/workspace/app/<file>) resolve under appDir.
131
+ * - Top-level extension-shape paths (/<file>.html) resolve under extDir or appDir.
132
+ * - Any other shape (external links, deep paths) is 'unresolvable' — we cannot
133
+ * (and should not) judge its local presence, so callers leave it untouched.
113
134
  */
114
- function warnOnMissingHrefs(items, appDir, extDir, warn) {
135
+ function pagePresence(href, appDir, extDir) {
136
+ const corePrefix = '/core/tools/workspace/app/';
137
+ if (href.indexOf(corePrefix) === 0) {
138
+ const name = href.slice(corePrefix.length).split('?')[0].split('#')[0];
139
+ if (!name) return 'unresolvable';
140
+ return existsSync(join(appDir, name)) ? 'present' : 'absent';
141
+ }
142
+ const topMatch = href.match(/^\/([^/?#]+\.html)(?:[?#].*)?$/);
143
+ if (topMatch) {
144
+ const name = topMatch[1];
145
+ const inExt = extDir && existsSync(join(extDir, name));
146
+ const inApp = existsSync(join(appDir, name));
147
+ return (inExt || inApp) ? 'present' : 'absent';
148
+ }
149
+ return 'unresolvable';
150
+ }
151
+
152
+ /**
153
+ * Scope the merged list to this device: drop any entry whose target page is
154
+ * resolvable-in-shape but absent from disk (i.e. its owning library repo is not
155
+ * materialised here), logging why. Entries whose page is present, or whose href
156
+ * shape we can't resolve to a local page, are kept. This is what makes a shared
157
+ * estate-wide nav manifest render cleanly per-device without hand-editing:
158
+ * hq-only entries vanish on non-HQ devices because their pages were never
159
+ * written there.
160
+ */
161
+ function scopeToMaterialised(items, appDir, extDir, warn) {
162
+ const kept = [];
115
163
  for (const item of items) {
116
- const href = item.href;
117
- // Core-shape paths: /core/tools/workspace/app/<file>
118
- const corePrefix = '/core/tools/workspace/app/';
119
- if (href.indexOf(corePrefix) === 0) {
120
- const name = href.slice(corePrefix.length).split('?')[0].split('#')[0];
121
- if (!existsSync(join(appDir, name))) {
122
- warn('[nav] warning: nav item "' + item.id + '" points at missing page ' + href);
123
- }
164
+ if (pagePresence(item.href, appDir, extDir) === 'absent') {
165
+ warn('[nav] hidden: nav item "' + item.id + '" page not materialised on this device (' + item.href + ')');
124
166
  continue;
125
167
  }
126
- // Top-level extension-shape: /<file>.html
127
- const topMatch = href.match(/^\/([^/?#]+\.html)(?:[?#].*)?$/);
128
- if (topMatch) {
129
- const name = topMatch[1];
130
- const inExt = extDir && existsSync(join(extDir, name));
131
- const inApp = existsSync(join(appDir, name));
132
- if (!inExt && !inApp) {
133
- warn('[nav] warning: nav item "' + item.id + '" points at missing page ' + href);
134
- }
135
- continue;
136
- }
137
- // Any other shape we leave alone — might be an external link or a deep core path.
168
+ kept.push(item);
138
169
  }
170
+ return kept;
139
171
  }
140
172
 
141
173
  /**
@@ -172,12 +204,12 @@ export function loadNav(opts) {
172
204
  ? readManifest(extManifest, 'extension nav manifest', warn)
173
205
  : [];
174
206
  const merged = mergeItems(coreItems, extItems, warn);
175
- warnOnMissingHrefs(merged, appDir, extDir, warn);
207
+ const scoped = scopeToMaterialised(merged, appDir, extDir, warn);
176
208
 
177
209
  CACHE.coreMtimeMs = coreMtime;
178
210
  CACHE.extMtimeMs = extMtime;
179
- CACHE.items = merged;
180
- return merged;
211
+ CACHE.items = scoped;
212
+ return scoped;
181
213
  }
182
214
 
183
215
  /**
@@ -60,6 +60,11 @@ const APP_DIR = resolve(__dirname, '..', 'app');
60
60
  const DEBUG = process.env.WS_DEBUG === '1';
61
61
  const log = DEBUG ? console.log.bind(console) : function () {};
62
62
  const warn = DEBUG ? console.warn.bind(console) : function () {};
63
+ // Always-on warning sink. Most diagnostics stay silent unless WS_DEBUG=1, but
64
+ // failures that silently break operator-visible surface area (e.g. a whole
65
+ // extension failing to load, presenting as a vague UI outage) must ALWAYS be
66
+ // visible so the root cause is diagnosable without re-running under WS_DEBUG.
67
+ const warnAlways = console.warn.bind(console);
63
68
 
64
69
  // --- Extension dir ---
65
70
  // Convention: operator/extensions/workspace/ overlays core app pages and
@@ -461,7 +466,11 @@ async function loadExtensions() {
461
466
  });
462
467
  }
463
468
  } catch (e) {
464
- warn('[workspace] Extension api.mjs failed to load: ' + e.message);
469
+ // Always warn — a failed extension import takes out every route the
470
+ // extension registers, which surfaces to the operator as a broken UI with
471
+ // no obvious cause. Name the extension and the error; keep the server
472
+ // alive (a bad extension must not take down the whole workspace).
473
+ warnAlways('[workspace] Extension failed to load: ' + extApiPath + ' — ' + (e && e.stack ? e.stack : e));
465
474
  }
466
475
  }
467
476
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stravica-ai/digital-operator-pa",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "Digital Operator PA — single-bundled public package surface (bins + library) for the platform.",
5
5
  "type": "module",
6
6
  "main": "./index.mjs",