@stravica-ai/digital-operator-pa 0.1.3 → 0.1.5
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.
- package/dist/engineering-pa-core/src/desk/team-scope.mjs +105 -17
- package/dist/engineering-pa-scaffold/src/generator.mjs +5 -0
- package/dist/engineering-pa-scaffold/templates/CLAUDE.md.template +1 -1
- package/dist/engineering-pa-workspace/README.md +6 -1
- package/dist/engineering-pa-workspace/src/nav.mjs +179 -86
- package/dist/engineering-pa-workspace/src/server.mjs +20 -2
- package/package.json +1 -1
|
@@ -33,6 +33,7 @@
|
|
|
33
33
|
// not silently start hiding projects on a package bump.
|
|
34
34
|
|
|
35
35
|
import { readFile } from 'node:fs/promises';
|
|
36
|
+
import { readFileSync } from 'node:fs';
|
|
36
37
|
import { join } from 'node:path';
|
|
37
38
|
|
|
38
39
|
/**
|
|
@@ -93,6 +94,83 @@ export function filterProjectsByTeam(projects, opts = {}) {
|
|
|
93
94
|
return projects.filter((p) => isOwnProject(p, set));
|
|
94
95
|
}
|
|
95
96
|
|
|
97
|
+
/**
|
|
98
|
+
* Normalise a parsed preferences.json object (or null when the file was
|
|
99
|
+
* missing/malformed) into the effective team-scope config. Shared by the
|
|
100
|
+
* async and sync readers so both derive `teamIdentities`, `scopeEnabled`
|
|
101
|
+
* and `team` identically.
|
|
102
|
+
*
|
|
103
|
+
* `team` is the DEVICE's display team id (e.g. "hq"), a distinct namespace
|
|
104
|
+
* from `teamIdentities` (agent/role identities): teams are the named
|
|
105
|
+
* library-repo teams the workspace nav scopes display by (see nav.mjs).
|
|
106
|
+
* Absent/blank -> null, which means "do not team-scope nav" (fail open,
|
|
107
|
+
* backwards compatible: pre-team devices show every nav fragment).
|
|
108
|
+
*/
|
|
109
|
+
/**
|
|
110
|
+
* Read + JSON-parse a file, returning the parsed value or null on any error
|
|
111
|
+
* (missing file, unreadable, malformed JSON). Shared by the base + overlay
|
|
112
|
+
* reads so both fail-open identically.
|
|
113
|
+
*/
|
|
114
|
+
function readJsonSync(path) {
|
|
115
|
+
try {
|
|
116
|
+
return JSON.parse(readFileSync(path, 'utf8'));
|
|
117
|
+
} catch {
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function readJsonAsync(path) {
|
|
123
|
+
try {
|
|
124
|
+
return JSON.parse(await readFile(path, 'utf8'));
|
|
125
|
+
} catch {
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Shallow-merge the optional device-local overlay
|
|
132
|
+
* (operator/config/preferences.local.json) OVER the parsed base
|
|
133
|
+
* preferences.json.
|
|
134
|
+
*
|
|
135
|
+
* The overlay is device-local state that must NOT round-trip through
|
|
136
|
+
* library-sync. preferences.json is down-path-materialised from a shared
|
|
137
|
+
* canonical source (see the estate's .library-sync manifest — the barry
|
|
138
|
+
* `config/` mount), so a device-scoped field (e.g. `team`) written into the
|
|
139
|
+
* synced file would be clobbered on the next cycle, and a canonical edit
|
|
140
|
+
* would stamp one device's identity onto every device. The overlay lives
|
|
141
|
+
* outside every mount source, so down-path reconciliation never tracks or
|
|
142
|
+
* deletes it (computeDeletions is manifest-driven — it only removes
|
|
143
|
+
* previously-materialised files), and it is gitignored so estate git-sync
|
|
144
|
+
* never carries it between hosts (see scaffold buildGitignore).
|
|
145
|
+
*
|
|
146
|
+
* Precedence: overlay keys win; keys the overlay omits fall through to the
|
|
147
|
+
* base. An absent/malformed overlay leaves the base exactly as-is, so a
|
|
148
|
+
* device with no overlay behaves identically to pre-overlay releases.
|
|
149
|
+
*
|
|
150
|
+
* `base`/`overlay` are already-parsed values (a plain object, or null when
|
|
151
|
+
* the file was missing/malformed). Returns null only when BOTH are absent,
|
|
152
|
+
* preserving the "no config at all -> fail-open defaults" path through
|
|
153
|
+
* normaliseTeamScopeConfig.
|
|
154
|
+
*/
|
|
155
|
+
function mergePreferences(base, overlay) {
|
|
156
|
+
const b = base && typeof base === 'object' && !Array.isArray(base) ? base : null;
|
|
157
|
+
const o = overlay && typeof overlay === 'object' && !Array.isArray(overlay) ? overlay : null;
|
|
158
|
+
if (!b && !o) return null;
|
|
159
|
+
return { ...(b || {}), ...(o || {}) };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function normaliseTeamScopeConfig(config) {
|
|
163
|
+
const c = config && typeof config === 'object' ? config : {};
|
|
164
|
+
const paName = c.paName || c.agentName || 'pa';
|
|
165
|
+
const scopeEnabled = c.scopeProjectsToTeam === true;
|
|
166
|
+
const configured = Array.isArray(c.teamIdentities)
|
|
167
|
+
? c.teamIdentities.filter((x) => typeof x === 'string' && x.length > 0)
|
|
168
|
+
: null;
|
|
169
|
+
const team = typeof c.team === 'string' && c.team.length > 0 ? c.team : null;
|
|
170
|
+
const teamIdentities = [...new Set([paName, ...(configured || [])])];
|
|
171
|
+
return { paName, teamIdentities, scopeEnabled, team };
|
|
172
|
+
}
|
|
173
|
+
|
|
96
174
|
/**
|
|
97
175
|
* Read the team-scoping config out of operator/config/preferences.json.
|
|
98
176
|
* Falls back to disabled scoping + no identity if the file is missing or
|
|
@@ -104,26 +182,36 @@ export function filterProjectsByTeam(projects, opts = {}) {
|
|
|
104
182
|
* even when the configured list omits it (documented belt-and-braces: the
|
|
105
183
|
* local agent can never scope out its own directly-assigned projects).
|
|
106
184
|
*
|
|
185
|
+
* `team` is the device's display team id (or null); see
|
|
186
|
+
* normaliseTeamScopeConfig above.
|
|
187
|
+
*
|
|
107
188
|
* @param {string} repoRoot
|
|
108
|
-
* @returns {Promise<{ paName: string, teamIdentities: string[], scopeEnabled: boolean }>}
|
|
189
|
+
* @returns {Promise<{ paName: string, teamIdentities: string[], scopeEnabled: boolean, team: string|null }>}
|
|
109
190
|
*/
|
|
110
191
|
export async function readTeamScopeConfig(repoRoot) {
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
192
|
+
// Base (synced) preferences, then the device-local overlay merged OVER it.
|
|
193
|
+
// Missing/malformed either file -> null for that layer -> defaults.
|
|
194
|
+
const base = await readJsonAsync(join(repoRoot, 'operator', 'config', 'preferences.json'));
|
|
195
|
+
const overlay = await readJsonAsync(join(repoRoot, 'operator', 'config', 'preferences.local.json'));
|
|
196
|
+
return normaliseTeamScopeConfig(mergePreferences(base, overlay));
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Synchronous mirror of {@link readTeamScopeConfig}. The workspace nav loader
|
|
201
|
+
* (nav.mjs) is a synchronous, mtime-cached read surface, so it resolves the
|
|
202
|
+
* device team through this variant rather than awaiting the async reader.
|
|
203
|
+
* Same fail-open posture: a missing/malformed file yields disabled scoping
|
|
204
|
+
* and a null team.
|
|
205
|
+
*
|
|
206
|
+
* @param {string} repoRoot
|
|
207
|
+
* @returns {{ paName: string, teamIdentities: string[], scopeEnabled: boolean, team: string|null }}
|
|
208
|
+
*/
|
|
209
|
+
export function readTeamScopeConfigSync(repoRoot) {
|
|
210
|
+
// Base (synced) preferences, then the device-local overlay merged OVER it.
|
|
211
|
+
// Missing/malformed either file -> null for that layer -> defaults.
|
|
212
|
+
const base = readJsonSync(join(repoRoot, 'operator', 'config', 'preferences.json'));
|
|
213
|
+
const overlay = readJsonSync(join(repoRoot, 'operator', 'config', 'preferences.local.json'));
|
|
214
|
+
return normaliseTeamScopeConfig(mergePreferences(base, overlay));
|
|
127
215
|
}
|
|
128
216
|
|
|
129
217
|
/**
|
|
@@ -157,6 +157,11 @@ export function buildGitignore() {
|
|
|
157
157
|
'# Workspace runtime',
|
|
158
158
|
'.ws.pid',
|
|
159
159
|
'',
|
|
160
|
+
'# Device-local preferences overlay — per-host identity (e.g. `team`)',
|
|
161
|
+
'# shallow-merged OVER the synced preferences.json. Device-local by',
|
|
162
|
+
'# nature, so it is never committed or synced between hosts.',
|
|
163
|
+
'operator/config/preferences.local.json',
|
|
164
|
+
'',
|
|
160
165
|
].join('\n');
|
|
161
166
|
}
|
|
162
167
|
|
|
@@ -184,7 +184,7 @@ Stage observations, update journal, update work items, update spawn-log outcomes
|
|
|
184
184
|
| Operator knowledge | `operator/knowledge/` |
|
|
185
185
|
| Platform core docs | `@stravica-ai/engineering-pa-core/docs/` |
|
|
186
186
|
| Platform core knowledge (bucket scaffolds) | `@stravica-ai/engineering-pa-core/knowledge/` |
|
|
187
|
-
| Config / preferences | `operator/config/preferences.json` |
|
|
187
|
+
| Config / preferences | `operator/config/preferences.json` (+ device-local `preferences.local.json` overlay) |
|
|
188
188
|
| PA run folder | `agents/{PA_NAME}/` |
|
|
189
189
|
| Autonomy envelope | `agents/{PA_NAME}/autonomy-envelope.md` |
|
|
190
190
|
| Work items | `work/w-YYYY-MM-DD-NNN.json` |
|
|
@@ -138,6 +138,8 @@ The main sidebar is driven by a declarative manifest rather than hard-coded mark
|
|
|
138
138
|
|
|
139
139
|
**Extension manifest (optional):** `operator/extensions/workspace/nav.json`. Same shape, merged after core. Missing file is not an error.
|
|
140
140
|
|
|
141
|
+
**Team fragments (optional):** alongside `nav.json`, both dirs may carry `nav.<team>.json` files (e.g. `nav.hq.json`, `nav.wsd.json`). These are composed together, base `nav.json` first then team fragments alphabetically. They exist because the extension dir is materialised from multiple library repos merged by file path — a single shared `nav.json` collides (highest-precedence repo wins the whole file, dropping the other teams' entries), whereas distinctly-named fragments don't collide, so every team's entries materialise side by side. Which of them a device *displays* is then decided by team scoping (below).
|
|
142
|
+
|
|
141
143
|
**Shape:**
|
|
142
144
|
|
|
143
145
|
```json
|
|
@@ -158,10 +160,13 @@ The main sidebar is driven by a declarative manifest rather than hard-coded mark
|
|
|
158
160
|
| `href` | yes | Navigation target. Extension pages typically use `/<name>.html`; core pages use `/core/tools/workspace/app/<name>.html`. |
|
|
159
161
|
| `section` | no | Section header. Defaults to `Navigation`. Any other string creates a new section appended after the core sections (in manifest order). |
|
|
160
162
|
| `aliases` | no | Additional paths that should highlight this nav item when loaded (e.g. `project-detail.html` highlights `Projects`). |
|
|
163
|
+
| `team` | no | The team this entry belongs to (used by team scoping — see below). Resolution order: this inline field > the `nav.<team>.json` filename it lives in > `null` (untagged). Untagged entries are shown on every device. |
|
|
161
164
|
|
|
162
165
|
**Endpoint:** `GET /__api/nav` returns `{ "items": [...] }` — the merged manifest in final order. The shell fetches this on load and renders the sidebar dynamically. Read-through cached with mtime invalidation; no restart needed after editing a manifest. If the endpoint fails, the shell falls back to a minimal Home-only nav rather than rendering a white page.
|
|
163
166
|
|
|
164
|
-
**Missing pages:** if an `href` doesn't resolve to a real page (neither a core page nor an extension overlay), a warning
|
|
167
|
+
**Missing pages:** if an `href` doesn't resolve to a real page (neither a core page nor an extension overlay), the item is dropped with a `[nav] hidden:` warning — `library-sync` only writes a page file when the owning repo is mounted here, so page-presence is the per-entry materialisation signal (external/deep hrefs whose shape can't be resolved are left untouched).
|
|
168
|
+
|
|
169
|
+
**Team scoping (display):** a device declares its display team in `operator/config/preferences.json:team` (e.g. `"team": "hq"`), reusing the same team-scope config the project surfaces read (`readTeamScopeConfig` in `packages/core/src/desk/team-scope.mjs`). The default view keeps entries whose team matches the device team **or** are untagged (`null`), and hides other teams' — so a device that is *entitled* to another team's tooling (its pages are on disk) still only *shows* its own team's nav. This is the entitlement-vs-display split. Defaults are fail-open and backwards compatible: a device with no `team`, or nav that carries no team tags, renders unfiltered exactly as before. `team` is resolved once at server start (a change is a restart, like the port beside it). Escape hatches mirror the desk/projects convention: `GET /__api/nav?allTeams=1` bypasses scoping entirely, and `?team=<id>` is a strict single-team inspection filter (untagged excluded).
|
|
165
170
|
|
|
166
171
|
**Example:** copy `core/tools/workspace/nav.json.example` to `operator/extensions/workspace/nav.json` and adjust for your pages. A worked demo:
|
|
167
172
|
|
|
@@ -1,53 +1,114 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Workspace nav manifest loader.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* the
|
|
6
|
-
*
|
|
4
|
+
* Composes the sidebar nav from a set of manifest FRAGMENTS discovered on
|
|
5
|
+
* disk, then scopes the result two ways: by page presence (per-device
|
|
6
|
+
* materialisation) and by team (per-device display).
|
|
7
7
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* -
|
|
13
|
-
* -
|
|
8
|
+
* Fragments
|
|
9
|
+
* ---------
|
|
10
|
+
* Each of the two source dirs (core app dir, operator extension dir) may
|
|
11
|
+
* contribute:
|
|
12
|
+
* - `nav.json` — the base fragment, entries team = null (untagged).
|
|
13
|
+
* - `nav.<team>.json` — a team fragment; every entry inherits team
|
|
14
|
+
* `<team>` from the filename unless it carries its
|
|
15
|
+
* own `"team"` field.
|
|
16
|
+
* Fragments are read in a deterministic order (base `nav.json` first, then
|
|
17
|
+
* team fragments alphabetically) so composition is stable across devices.
|
|
14
18
|
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
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.
|
|
19
|
+
* Why fragments instead of a single extension nav.json: the operator
|
|
20
|
+
* extension dir is materialised from MULTIPLE library repos (company, hq,
|
|
21
|
+
* wsd) merged by file path. A single shared `nav.json` collides — the
|
|
22
|
+
* highest-precedence repo's copy wins the whole file and silently drops the
|
|
23
|
+
* other teams' entries (this is exactly the HQ regression that motivated
|
|
24
|
+
* this change: wsd's nav.json won and HQ's article-ideas/content-calendar
|
|
25
|
+
* vanished from the sidebar). Distinctly-named `nav.<team>.json` fragments do
|
|
26
|
+
* not collide, so every team's entries materialise side by side; team
|
|
27
|
+
* scoping (below) then decides which to DISPLAY on a given device.
|
|
28
28
|
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
* `
|
|
33
|
-
*
|
|
34
|
-
*
|
|
29
|
+
* Team declaration + resolution order for each entry:
|
|
30
|
+
* 1. the entry's own `"team"` field (string), if present;
|
|
31
|
+
* 2. else the team encoded in the fragment filename (`nav.<team>.json`);
|
|
32
|
+
* 3. else `null` (untagged — belongs to no team, always shown).
|
|
33
|
+
*
|
|
34
|
+
* Team scoping (display)
|
|
35
|
+
* ----------------------
|
|
36
|
+
* The device declares its team via the `team` key in
|
|
37
|
+
* `operator/config/preferences.json` — or, preferred, the device-local
|
|
38
|
+
* `operator/config/preferences.local.json` overlay shallow-merged over it
|
|
39
|
+
* (device identity is device-local, and preferences.json is synced from a
|
|
40
|
+
* shared canonical source; see readTeamScopeConfig in team-scope.mjs). It is
|
|
41
|
+
* resolved by the server through readTeamScopeConfigSync and passed in as
|
|
42
|
+
* `opts.team`. The default scoped view keeps entries whose team is the
|
|
43
|
+
* device team OR untagged (null). Untagged-always-shown keeps core nav and
|
|
44
|
+
* shared/company pages visible everywhere and makes the whole mechanism
|
|
45
|
+
* backwards compatible: a device with no `team` configured, or fragments
|
|
46
|
+
* that carry no team, are shown unfiltered exactly as before. `?allTeams=1`
|
|
47
|
+
* (opts.allTeams) bypasses scoping; `?team=<id>` (opts.requestedTeam) is a
|
|
48
|
+
* strict single-team inspection filter, mirroring the desk/projects
|
|
49
|
+
* team-scope convention.
|
|
50
|
+
*
|
|
51
|
+
* Page presence (materialisation)
|
|
52
|
+
* -------------------------------
|
|
53
|
+
* Independent of team scoping and applied first: an entry whose resolvable
|
|
54
|
+
* target page is not present on disk is dropped (library-sync only writes a
|
|
55
|
+
* page file when the owning repo is mounted here, so page-presence is the
|
|
56
|
+
* per-entry materialisation signal). Hrefs whose shape we can't resolve to a
|
|
57
|
+
* local page (external links, deep paths) are left untouched. Entitlement
|
|
58
|
+
* (which pages materialise) and display (which team's nav shows) are now
|
|
59
|
+
* separate axes: a wsd-entitled HQ device has wsd pages ON DISK but, being an
|
|
60
|
+
* HQ device, does not SHOW wsd nav entries.
|
|
61
|
+
*
|
|
62
|
+
* Caching: the merged + team-tagged + page-scoped list is device-team
|
|
63
|
+
* independent, so it is cached against a signature over every discovered
|
|
64
|
+
* fragment's mtime (a new/removed fragment or a content change bumps it). The
|
|
65
|
+
* per-request team filter is applied on top of the cached list and, when it
|
|
66
|
+
* is a pass-through (allTeams, or no device team and no requested team),
|
|
67
|
+
* returns the cached array by reference.
|
|
35
68
|
*/
|
|
36
69
|
|
|
37
|
-
import { readFileSync, statSync, existsSync } from 'node:fs';
|
|
70
|
+
import { readFileSync, statSync, existsSync, readdirSync } from 'node:fs';
|
|
38
71
|
import { join } from 'node:path';
|
|
39
72
|
|
|
40
73
|
const CACHE = {
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
items: null,
|
|
74
|
+
signature: null,
|
|
75
|
+
tagged: null,
|
|
44
76
|
};
|
|
45
77
|
|
|
78
|
+
const FRAGMENT_RE = /^nav(?:\.([A-Za-z0-9_-]+))?\.json$/;
|
|
79
|
+
|
|
46
80
|
function mtime(path) {
|
|
47
81
|
try { return statSync(path).mtimeMs; } catch { return 0; }
|
|
48
82
|
}
|
|
49
83
|
|
|
50
|
-
|
|
84
|
+
/**
|
|
85
|
+
* Discover nav fragments in a dir. Returns an ordered list of
|
|
86
|
+
* { path, team, source } — base `nav.json` (team null) first, then
|
|
87
|
+
* `nav.<team>.json` fragments sorted by team. A missing dir yields [].
|
|
88
|
+
*/
|
|
89
|
+
function discoverFragments(dir, sourceLabel) {
|
|
90
|
+
if (!dir || !existsSync(dir)) return [];
|
|
91
|
+
let names;
|
|
92
|
+
try {
|
|
93
|
+
names = readdirSync(dir);
|
|
94
|
+
} catch {
|
|
95
|
+
return [];
|
|
96
|
+
}
|
|
97
|
+
const base = [];
|
|
98
|
+
const teamed = [];
|
|
99
|
+
for (const name of names) {
|
|
100
|
+
const m = name.match(FRAGMENT_RE);
|
|
101
|
+
if (!m) continue;
|
|
102
|
+
const team = m[1] || null;
|
|
103
|
+
const entry = { path: join(dir, name), team, source: sourceLabel + ' nav manifest' };
|
|
104
|
+
if (team === null) base.push(entry);
|
|
105
|
+
else teamed.push(entry);
|
|
106
|
+
}
|
|
107
|
+
teamed.sort((a, b) => (a.team < b.team ? -1 : a.team > b.team ? 1 : 0));
|
|
108
|
+
return base.concat(teamed);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function validateItem(raw, source, fragmentTeam, warn) {
|
|
51
112
|
if (!raw || typeof raw !== 'object') {
|
|
52
113
|
warn('[nav] warning: ' + source + ' contains a non-object entry, skipping');
|
|
53
114
|
return null;
|
|
@@ -69,6 +130,8 @@ function validateItem(raw, source, warn) {
|
|
|
69
130
|
label: raw.label,
|
|
70
131
|
href: raw.href,
|
|
71
132
|
section: typeof raw.section === 'string' && raw.section ? raw.section : 'Workspace',
|
|
133
|
+
// Resolution order: inline entry.team > fragment-filename team > null.
|
|
134
|
+
team: typeof raw.team === 'string' && raw.team ? raw.team : fragmentTeam,
|
|
72
135
|
};
|
|
73
136
|
if (Array.isArray(raw.aliases)) {
|
|
74
137
|
item.aliases = raw.aliases.filter(function(a) { return typeof a === 'string' && a; });
|
|
@@ -78,7 +141,8 @@ function validateItem(raw, source, warn) {
|
|
|
78
141
|
return item;
|
|
79
142
|
}
|
|
80
143
|
|
|
81
|
-
function
|
|
144
|
+
function readFragment(fragment, warn) {
|
|
145
|
+
const { path, team, source } = fragment;
|
|
82
146
|
if (!existsSync(path)) return [];
|
|
83
147
|
let raw;
|
|
84
148
|
try {
|
|
@@ -93,31 +157,30 @@ function readManifest(path, source, warn) {
|
|
|
93
157
|
}
|
|
94
158
|
const items = [];
|
|
95
159
|
for (let i = 0; i < raw.length; i++) {
|
|
96
|
-
const item = validateItem(raw[i], source, warn);
|
|
160
|
+
const item = validateItem(raw[i], source, team, warn);
|
|
97
161
|
if (item) items.push(item);
|
|
98
162
|
}
|
|
99
163
|
return items;
|
|
100
164
|
}
|
|
101
165
|
|
|
102
166
|
/**
|
|
103
|
-
*
|
|
104
|
-
* the
|
|
105
|
-
*
|
|
106
|
-
* manifest order.
|
|
167
|
+
* Fold `additions` into a copy of `base`. An addition whose id already exists
|
|
168
|
+
* replaces the existing entry in place (preserving order); a new id is
|
|
169
|
+
* appended. `warn` fires on each collision — pass a no-op to fold silently.
|
|
107
170
|
*/
|
|
108
|
-
function
|
|
109
|
-
const merged =
|
|
171
|
+
function foldItems(base, additions, warn) {
|
|
172
|
+
const merged = base.slice();
|
|
110
173
|
const indexById = new Map();
|
|
111
174
|
for (let i = 0; i < merged.length; i++) {
|
|
112
175
|
indexById.set(merged[i].id, i);
|
|
113
176
|
}
|
|
114
|
-
for (const
|
|
115
|
-
if (indexById.has(
|
|
116
|
-
warn('[nav] warning: extension nav id "' +
|
|
117
|
-
merged[indexById.get(
|
|
177
|
+
for (const add of additions) {
|
|
178
|
+
if (indexById.has(add.id)) {
|
|
179
|
+
warn('[nav] warning: extension nav id "' + add.id + '" shadows core item');
|
|
180
|
+
merged[indexById.get(add.id)] = add;
|
|
118
181
|
} else {
|
|
119
|
-
indexById.set(
|
|
120
|
-
merged.push(
|
|
182
|
+
indexById.set(add.id, merged.length);
|
|
183
|
+
merged.push(add);
|
|
121
184
|
}
|
|
122
185
|
}
|
|
123
186
|
return merged;
|
|
@@ -126,11 +189,6 @@ function mergeItems(coreItems, extItems, warn) {
|
|
|
126
189
|
/**
|
|
127
190
|
* Classify an href by whether its target page is materialised on this device.
|
|
128
191
|
* 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.
|
|
134
192
|
*/
|
|
135
193
|
function pagePresence(href, appDir, extDir) {
|
|
136
194
|
const corePrefix = '/core/tools/workspace/app/';
|
|
@@ -150,13 +208,8 @@ function pagePresence(href, appDir, extDir) {
|
|
|
150
208
|
}
|
|
151
209
|
|
|
152
210
|
/**
|
|
153
|
-
*
|
|
154
|
-
*
|
|
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.
|
|
211
|
+
* Drop any entry whose target page is resolvable-in-shape but absent from
|
|
212
|
+
* disk (its owning library repo is not materialised here), logging why.
|
|
160
213
|
*/
|
|
161
214
|
function scopeToMaterialised(items, appDir, extDir, warn) {
|
|
162
215
|
const kept = [];
|
|
@@ -171,52 +224,92 @@ function scopeToMaterialised(items, appDir, extDir, warn) {
|
|
|
171
224
|
}
|
|
172
225
|
|
|
173
226
|
/**
|
|
174
|
-
*
|
|
175
|
-
*
|
|
227
|
+
* Apply the per-request team filter to the (cached, device-independent)
|
|
228
|
+
* tagged list. Returns the input array BY REFERENCE when the filter is a
|
|
229
|
+
* pass-through, so the loader preserves cache identity for the common case.
|
|
230
|
+
*
|
|
231
|
+
* Precedence (mirrors filterProjectsByTeam):
|
|
232
|
+
* 1. requestedTeam (?team=<id>) — strict: only entries whose team matches
|
|
233
|
+
* exactly. Untagged entries are excluded from this narrow inspection view.
|
|
234
|
+
* 2. allTeams (?allTeams=1) — bypass, return everything.
|
|
235
|
+
* 3. no device team — bypass, return everything (backwards compatible).
|
|
236
|
+
* 4. default — entries whose team is the device team OR untagged (null).
|
|
237
|
+
*/
|
|
238
|
+
function applyTeamFilter(items, opts) {
|
|
239
|
+
const requestedTeam = opts.requestedTeam || null;
|
|
240
|
+
if (requestedTeam) {
|
|
241
|
+
return items.filter(function(i) { return i.team === requestedTeam; });
|
|
242
|
+
}
|
|
243
|
+
if (opts.allTeams) return items;
|
|
244
|
+
|
|
245
|
+
const deviceTeam = opts.team;
|
|
246
|
+
const teamSet = deviceTeam == null
|
|
247
|
+
? null
|
|
248
|
+
: (deviceTeam instanceof Set
|
|
249
|
+
? deviceTeam
|
|
250
|
+
: new Set(Array.isArray(deviceTeam) ? deviceTeam : [deviceTeam]));
|
|
251
|
+
if (!teamSet || teamSet.size === 0) return items;
|
|
252
|
+
|
|
253
|
+
return items.filter(function(i) { return i.team == null || teamSet.has(i.team); });
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Returns the composed, scoped nav items array.
|
|
176
258
|
*
|
|
177
259
|
* @param {object} opts
|
|
178
260
|
* @param {string} opts.appDir absolute path to core app dir
|
|
179
261
|
* @param {string} [opts.extDir] absolute path to operator extension dir
|
|
262
|
+
* @param {string|string[]|Set<string>} [opts.team] device display team(s);
|
|
263
|
+
* absent/null => nav shown unfiltered (backwards compatible)
|
|
264
|
+
* @param {boolean} [opts.allTeams] bypass team scoping (?allTeams=1)
|
|
265
|
+
* @param {string} [opts.requestedTeam] strict single-team filter (?team=<id>)
|
|
180
266
|
* @param {function(string):void} [opts.warn] warning sink (defaults to console.warn)
|
|
181
|
-
* @returns {Array<{id:string,label:string,href:string,section:string,aliases:string[]}>}
|
|
267
|
+
* @returns {Array<{id:string,label:string,href:string,section:string,team:string|null,aliases:string[]}>}
|
|
182
268
|
*/
|
|
183
269
|
export function loadNav(opts) {
|
|
184
270
|
const appDir = opts.appDir;
|
|
185
271
|
const extDir = opts.extDir || null;
|
|
186
272
|
const warn = opts.warn || function(m) { console.warn(m); };
|
|
187
273
|
|
|
188
|
-
const
|
|
189
|
-
const extManifest = extDir ? join(extDir, 'nav.json') : null;
|
|
274
|
+
const fragments = discoverFragments(appDir, 'core').concat(discoverFragments(extDir, 'extension'));
|
|
190
275
|
|
|
191
|
-
|
|
192
|
-
|
|
276
|
+
// Signature: every fragment path + mtime, sorted. A content change, a new
|
|
277
|
+
// fragment appearing, or a fragment removed all change the signature.
|
|
278
|
+
const signature = fragments
|
|
279
|
+
.map(function(f) { return f.path + ':' + mtime(f.path); })
|
|
280
|
+
.sort()
|
|
281
|
+
.join('|');
|
|
193
282
|
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
CACHE.
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
return
|
|
200
|
-
}
|
|
283
|
+
let tagged;
|
|
284
|
+
if (CACHE.tagged !== null && CACHE.signature === signature) {
|
|
285
|
+
tagged = CACHE.tagged;
|
|
286
|
+
} else {
|
|
287
|
+
const coreFragments = fragments.filter(function(f) { return f.source.indexOf('core') === 0; });
|
|
288
|
+
const extFragments = fragments.filter(function(f) { return f.source.indexOf('core') !== 0; });
|
|
201
289
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
290
|
+
let coreItems = [];
|
|
291
|
+
for (const f of coreFragments) coreItems = foldItems(coreItems, readFragment(f, warn), function() {});
|
|
292
|
+
let extItems = [];
|
|
293
|
+
for (const f of extFragments) extItems = foldItems(extItems, readFragment(f, warn), function() {});
|
|
294
|
+
|
|
295
|
+
const merged = foldItems(coreItems, extItems, warn);
|
|
296
|
+
tagged = scopeToMaterialised(merged, appDir, extDir, warn);
|
|
297
|
+
|
|
298
|
+
CACHE.signature = signature;
|
|
299
|
+
CACHE.tagged = tagged;
|
|
300
|
+
}
|
|
208
301
|
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
302
|
+
return applyTeamFilter(tagged, {
|
|
303
|
+
team: opts.team,
|
|
304
|
+
allTeams: opts.allTeams === true,
|
|
305
|
+
requestedTeam: opts.requestedTeam || null,
|
|
306
|
+
});
|
|
213
307
|
}
|
|
214
308
|
|
|
215
309
|
/**
|
|
216
310
|
* Test-only helper: clear the in-memory cache so reloads re-read manifests.
|
|
217
311
|
*/
|
|
218
312
|
export function _resetNavCache() {
|
|
219
|
-
CACHE.
|
|
220
|
-
CACHE.
|
|
221
|
-
CACHE.items = null;
|
|
313
|
+
CACHE.signature = null;
|
|
314
|
+
CACHE.tagged = null;
|
|
222
315
|
}
|
|
@@ -26,6 +26,7 @@ import { resolve, join, extname } from 'node:path';
|
|
|
26
26
|
import { fileURLToPath } from 'node:url';
|
|
27
27
|
import { handleApi } from './api.mjs';
|
|
28
28
|
import { loadNav } from './nav.mjs';
|
|
29
|
+
import { readTeamScopeConfigSync } from '../../engineering-pa-core/src/desk/team-scope.mjs';
|
|
29
30
|
import { startPoller as startStatusMonPoller } from './status-mon/poller.mjs';
|
|
30
31
|
|
|
31
32
|
const __dirname = fileURLToPath(new URL('.', import.meta.url));
|
|
@@ -73,6 +74,14 @@ const warnAlways = console.warn.bind(console);
|
|
|
73
74
|
const EXT_DIR_PATH = join(REPO_ROOT, 'operator', 'extensions', 'workspace');
|
|
74
75
|
const EXT_DIR = existsSync(EXT_DIR_PATH) ? EXT_DIR_PATH : null;
|
|
75
76
|
|
|
77
|
+
// --- Device display team ---
|
|
78
|
+
// The nav sidebar is scoped to the device's team (operator/config/
|
|
79
|
+
// preferences.json:team), reusing the same team-scope config the project
|
|
80
|
+
// surfaces use (PR #87). null => nav shown unfiltered (backwards compatible).
|
|
81
|
+
// Resolved once at startup; a `team` change is a workspace restart, same as
|
|
82
|
+
// the port it sits beside in preferences.json.
|
|
83
|
+
const DEVICE_TEAM = readTeamScopeConfigSync(REPO_ROOT).team;
|
|
84
|
+
|
|
76
85
|
// --- Args ---
|
|
77
86
|
const args = process.argv.slice(2);
|
|
78
87
|
let port = 4200;
|
|
@@ -315,10 +324,19 @@ const server = createServer(async (req, res) => {
|
|
|
315
324
|
const extRoute = extRoutes.find(r => r.method === req.method && r.pathname === pathname);
|
|
316
325
|
if (extRoute) { extRoute.handler(req, res, url, REPO_ROOT); return; }
|
|
317
326
|
|
|
318
|
-
// GET /__api/nav -- merged core + extension nav manifest
|
|
327
|
+
// GET /__api/nav -- merged core + extension nav manifest, team-scoped to
|
|
328
|
+
// this device. ?allTeams=1 bypasses scoping; ?team=<id> is a strict
|
|
329
|
+
// single-team inspection filter (mirrors the desk/projects convention).
|
|
319
330
|
if (req.method === 'GET' && pathname === '/__api/nav') {
|
|
320
331
|
try {
|
|
321
|
-
const items = loadNav({
|
|
332
|
+
const items = loadNav({
|
|
333
|
+
appDir: APP_DIR,
|
|
334
|
+
extDir: EXT_DIR,
|
|
335
|
+
team: DEVICE_TEAM,
|
|
336
|
+
allTeams: url.searchParams.get('allTeams') === '1',
|
|
337
|
+
requestedTeam: url.searchParams.get('team'),
|
|
338
|
+
warn: warn,
|
|
339
|
+
});
|
|
322
340
|
const body = JSON.stringify({ items: items });
|
|
323
341
|
res.writeHead(200, {
|
|
324
342
|
'Content-Type': 'application/json',
|
package/package.json
CHANGED