@stravica-ai/digital-operator-pa 0.1.16 → 0.1.18
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-cli/bin/estate-sync +0 -0
- package/dist/engineering-pa-cli/bin/launch +0 -0
- package/dist/engineering-pa-cli/bin/pa +0 -0
- package/dist/engineering-pa-cli/bin/pa-coordinator-hook +0 -0
- package/dist/engineering-pa-cli/bin/research +0 -0
- package/dist/engineering-pa-cli/bin/scheduler +0 -0
- package/dist/engineering-pa-cli/bin/secrets +0 -0
- package/dist/engineering-pa-cli/bin/smoke +0 -0
- package/dist/engineering-pa-cli/bin/sync +0 -0
- package/dist/engineering-pa-cli/docs/gsc.md +14 -0
- package/dist/engineering-pa-cli/scripts/migrate-doc-paths.mjs +0 -0
- package/dist/engineering-pa-cli/src/agent/cli.mjs +6 -11
- package/dist/engineering-pa-cli/src/coordinator-hook/self.mjs +4 -4
- package/dist/engineering-pa-cli/src/environment/cli.mjs +4 -6
- package/dist/engineering-pa-cli/src/environment/probes.mjs +3 -5
- package/dist/engineering-pa-cli/src/gsc/auth.mjs +37 -15
- package/dist/engineering-pa-cli/src/gsc/cloudflare.mjs +16 -7
- package/dist/engineering-pa-cli/src/pa/cli.mjs +3 -5
- package/dist/engineering-pa-cli/src/pa/session.mjs +24 -1
- package/dist/engineering-pa-cli/src/pa/work-cli.mjs +5 -7
- package/dist/engineering-pa-core/bin/desk +0 -0
- package/dist/engineering-pa-core/bin/distill +0 -0
- package/dist/engineering-pa-core/bin/memory-sync +0 -0
- package/dist/engineering-pa-core/bin/slice +0 -0
- package/dist/engineering-pa-core/src/desk/team-scope.mjs +9 -64
- package/dist/engineering-pa-core/src/preferences.mjs +133 -0
- package/dist/engineering-pa-migrate-inspect/bin/migrate-inspect +0 -0
- package/dist/engineering-pa-scaffold/bin/pa-init +0 -0
- package/dist/engineering-pa-workspace/src/api.mjs +11 -18
- package/dist/engineering-pa-workspace/src/install-daemon.mjs +6 -5
- package/dist/engineering-pa-workspace/src/server.mjs +6 -5
- package/dist/engineering-pa-workspace/src/status-mon/poller.mjs +4 -11
- package/dist/engineering-pa-workspace/src/status-mon/run.mjs +30 -16
- package/dist/engineering-pa-workspace/src/ws.mjs +5 -4
- package/package.json +6 -5
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
@@ -37,10 +37,20 @@ Two npm packages (declared in `@stravica-ai/digital-operator-pa`):
|
|
|
37
37
|
|
|
38
38
|
5. **Store the SA key in the SOPS vault**:
|
|
39
39
|
```bash
|
|
40
|
+
# Legacy single-file vault:
|
|
40
41
|
pnpm exec secrets set GSC_SERVICE_ACCOUNT_KEY -
|
|
42
|
+
# Scoped-vault estate (workspace or canonical layout):
|
|
43
|
+
pnpm exec secrets set --scope <scope> GSC_SERVICE_ACCOUNT_KEY -
|
|
41
44
|
# paste the entire JSON contents, then Ctrl-D
|
|
42
45
|
```
|
|
43
46
|
|
|
47
|
+
On scoped-vault hosts, `pa gsc` reads the key from whichever pot
|
|
48
|
+
`secrets get` would use — i.e. it honours (in order) `SECRETS_DEFAULT_SCOPE`,
|
|
49
|
+
the `defaultScope` in `~/.config/stravica-pa/defaults.json`, or the
|
|
50
|
+
calling PA's `identity.json` — before falling back to the legacy
|
|
51
|
+
single-file vault. Store the key in the pot the host is already
|
|
52
|
+
configured to read; no `pa gsc`-specific configuration is needed.
|
|
53
|
+
|
|
44
54
|
6. **Store Cloudflare credentials** (also one-time). Two key shapes are
|
|
45
55
|
supported; pick whichever fits your setup:
|
|
46
56
|
|
|
@@ -70,6 +80,10 @@ Two npm packages (declared in `@stravica-ai/digital-operator-pa`):
|
|
|
70
80
|
no `--account` flag is passed and the `CLOUDFLARE_PAGES_*` unsuffixed
|
|
71
81
|
pair is absent. Don't rely on it — set the `CLOUDFLARE_PAGES_*` keys.
|
|
72
82
|
|
|
83
|
+
Same scoped-vault note as the SA key: on scoped hosts these live in
|
|
84
|
+
whichever pot `secrets get` reads (see step 5); use `secrets set --scope
|
|
85
|
+
<scope> ...` to store them there.
|
|
86
|
+
|
|
73
87
|
## Per-domain setup (each time you onboard a new property)
|
|
74
88
|
|
|
75
89
|
7. **Grant the service-account access to the GSC property.** Until the
|
|
File without changes
|
|
@@ -29,6 +29,7 @@ import { hostname as osHostname } from 'node:os';
|
|
|
29
29
|
import { dirname, join } from 'node:path';
|
|
30
30
|
|
|
31
31
|
import { loadBundledManifestSync } from '../../../engineering-pa-core/src/agent-baseline/manifest.mjs';
|
|
32
|
+
import { readPreferencesSync } from '../../../engineering-pa-core/src/preferences.mjs';
|
|
32
33
|
|
|
33
34
|
import { auditManifest } from './audit.mjs';
|
|
34
35
|
import { getRepoRoot } from '../pa/repo-root.mjs';
|
|
@@ -201,12 +202,9 @@ function isStale(cache, stalenessMinutes) {
|
|
|
201
202
|
|
|
202
203
|
function readStalenessMinutes(repoRoot) {
|
|
203
204
|
try {
|
|
204
|
-
const
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
const value = prefs?.agentAudit?.cacheStalenessMinutes;
|
|
208
|
-
if (typeof value === 'number' && value > 0) return value;
|
|
209
|
-
}
|
|
205
|
+
const prefs = readPreferencesSync(repoRoot);
|
|
206
|
+
const value = prefs?.agentAudit?.cacheStalenessMinutes;
|
|
207
|
+
if (typeof value === 'number' && value > 0) return value;
|
|
210
208
|
} catch {
|
|
211
209
|
// fall through
|
|
212
210
|
}
|
|
@@ -215,11 +213,8 @@ function readStalenessMinutes(repoRoot) {
|
|
|
215
213
|
|
|
216
214
|
function readPaName(repoRoot) {
|
|
217
215
|
try {
|
|
218
|
-
const
|
|
219
|
-
if (
|
|
220
|
-
const prefs = JSON.parse(readFileSync(prefsPath, 'utf8'));
|
|
221
|
-
if (typeof prefs.paName === 'string' && prefs.paName.length > 0) return prefs.paName;
|
|
222
|
-
}
|
|
216
|
+
const prefs = readPreferencesSync(repoRoot);
|
|
217
|
+
if (prefs && typeof prefs.paName === 'string' && prefs.paName.length > 0) return prefs.paName;
|
|
223
218
|
} catch {
|
|
224
219
|
// fall through
|
|
225
220
|
}
|
|
@@ -11,15 +11,15 @@
|
|
|
11
11
|
//
|
|
12
12
|
// Returns a string paName, or null when unresolved.
|
|
13
13
|
|
|
14
|
-
import {
|
|
15
|
-
import { join } from 'node:path';
|
|
14
|
+
import { readPreferences } from '../../../engineering-pa-core/src/preferences.mjs';
|
|
16
15
|
|
|
17
16
|
export async function resolveSelfAgent(repoRoot, env = process.env) {
|
|
18
17
|
if (env.PA_SELF_AGENT) return env.PA_SELF_AGENT;
|
|
19
18
|
if (!repoRoot) return null;
|
|
20
19
|
try {
|
|
21
|
-
|
|
22
|
-
|
|
20
|
+
// Overlay-aware: preferences.local.json shallow-merges OVER the shared
|
|
21
|
+
// preferences.json so a device-local paName wins for the coordinator hook.
|
|
22
|
+
const config = await readPreferences(repoRoot);
|
|
23
23
|
if (config && typeof config.paName === 'string' && config.paName.trim()) {
|
|
24
24
|
return config.paName.trim();
|
|
25
25
|
}
|
|
@@ -22,6 +22,7 @@ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
|
22
22
|
import { join, dirname } from 'node:path';
|
|
23
23
|
import { pathToFileURL } from 'node:url';
|
|
24
24
|
import { getRepoRoot } from '../pa/repo-root.mjs';
|
|
25
|
+
import { readPreferencesSync } from '../../../engineering-pa-core/src/preferences.mjs';
|
|
25
26
|
import {
|
|
26
27
|
probePlatform,
|
|
27
28
|
probeRuntimes,
|
|
@@ -252,12 +253,9 @@ function isStale(cache, stalenessMinutes) {
|
|
|
252
253
|
|
|
253
254
|
function readStalenessMinutes(repoRoot) {
|
|
254
255
|
try {
|
|
255
|
-
const
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
const value = prefs?.environment?.cacheStalenessMinutes;
|
|
259
|
-
if (typeof value === 'number' && value > 0) return value;
|
|
260
|
-
}
|
|
256
|
+
const prefs = readPreferencesSync(repoRoot);
|
|
257
|
+
const value = prefs?.environment?.cacheStalenessMinutes;
|
|
258
|
+
if (typeof value === 'number' && value > 0) return value;
|
|
261
259
|
} catch {
|
|
262
260
|
// fall through
|
|
263
261
|
}
|
|
@@ -10,6 +10,7 @@ import { arch, platform, release, homedir } from 'node:os';
|
|
|
10
10
|
import { basename, join, resolve, dirname } from 'node:path';
|
|
11
11
|
import { resolve4 } from 'node:dns/promises';
|
|
12
12
|
import { fileURLToPath } from 'node:url';
|
|
13
|
+
import { readPreferencesSync } from '../../../engineering-pa-core/src/preferences.mjs';
|
|
13
14
|
|
|
14
15
|
const execFileP = promisify(execFile);
|
|
15
16
|
|
|
@@ -234,11 +235,8 @@ export async function probeWorkspace(repoRoot) {
|
|
|
234
235
|
|
|
235
236
|
function readWorkspacePort(repoRoot) {
|
|
236
237
|
try {
|
|
237
|
-
const
|
|
238
|
-
if (
|
|
239
|
-
const prefs = JSON.parse(readFileSync(prefsPath, 'utf8'));
|
|
240
|
-
if (typeof prefs.workspacePort === 'number') return prefs.workspacePort;
|
|
241
|
-
}
|
|
238
|
+
const prefs = readPreferencesSync(repoRoot);
|
|
239
|
+
if (prefs && typeof prefs.workspacePort === 'number') return prefs.workspacePort;
|
|
242
240
|
} catch {
|
|
243
241
|
// fall through
|
|
244
242
|
}
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
// override and never touch the network. The SA-key read also accepts an
|
|
9
9
|
// override so tests can pass in a synthetic key without going through SOPS.
|
|
10
10
|
|
|
11
|
-
import {
|
|
11
|
+
import { resolveCommandPaths, checkVault, secretsFileExists, sopsDecrypt, VaultError } from '../secrets/vault.mjs';
|
|
12
12
|
|
|
13
13
|
// Read-only scope: searchanalytics.query + sites.list + sitemaps.list.
|
|
14
14
|
// Read-write scope: sites.add, sitemaps.submit, site verification.
|
|
@@ -25,9 +25,26 @@ const DEFAULT_VAULT_KEY = 'GSC_SERVICE_ACCOUNT_KEY';
|
|
|
25
25
|
/**
|
|
26
26
|
* Load the service-account JSON object from the SOPS vault.
|
|
27
27
|
*
|
|
28
|
+
* Vault path resolution goes through `resolveCommandPaths` — the same
|
|
29
|
+
* resolver `secrets get`/`set` use — so this honours all the same layouts
|
|
30
|
+
* without duplicating logic: an explicit `overrides.paths` (from a
|
|
31
|
+
* pre-resolved `--scope`/`--file`) is a pass-through; otherwise the
|
|
32
|
+
* default-scope resolver runs (SECRETS_DEFAULT_SCOPE env, defaultScope in
|
|
33
|
+
* ~/.config/stravica-pa/defaults.json, or the calling PA's identity.json)
|
|
34
|
+
* and points at a scoped `.vault/scopes/<scope>.enc.json`; if nothing is
|
|
35
|
+
* configured anywhere it falls back to the legacy single-file vault.
|
|
36
|
+
*
|
|
37
|
+
* A stderr `note: writing to '<scope>' pot ...` line is printed by the
|
|
38
|
+
* resolver on the default-scope path — same behaviour as `secrets get`,
|
|
39
|
+
* kept intentionally consistent so operators see the same routing signal
|
|
40
|
+
* from every consumer of the vault.
|
|
41
|
+
*
|
|
28
42
|
* @param {object} overrides
|
|
29
|
-
* @param {object} [overrides.paths] Vault paths (from
|
|
43
|
+
* @param {object} [overrides.paths] Vault paths already resolved (e.g. from --scope upstream).
|
|
30
44
|
* @param {string} [overrides.vaultKey] Vault key name (default GSC_SERVICE_ACCOUNT_KEY).
|
|
45
|
+
* @param {string} [overrides.cwd] Test seam — cwd used for workspace resolution.
|
|
46
|
+
* @param {object} [overrides.env] Test seam — env used for scope-default resolution.
|
|
47
|
+
* @param {string} [overrides.home] Test seam — home used for keyfile + defaults.json lookup.
|
|
31
48
|
* @param {string} [overrides.serviceAccountKey]
|
|
32
49
|
* Raw SA JSON string. If provided, skips vault entirely (testing).
|
|
33
50
|
* @returns {Promise<object>} Parsed service-account JSON.
|
|
@@ -39,34 +56,39 @@ export async function loadServiceAccountKey(overrides = {}) {
|
|
|
39
56
|
: overrides.serviceAccountKey;
|
|
40
57
|
}
|
|
41
58
|
|
|
42
|
-
const paths = overrides
|
|
59
|
+
const paths = resolveCommandPaths(overrides);
|
|
43
60
|
const vaultKey = overrides.vaultKey || DEFAULT_VAULT_KEY;
|
|
44
61
|
|
|
45
62
|
const state = checkVault(paths);
|
|
46
63
|
if (!state.ok) {
|
|
64
|
+
// Print then throw — main() suppresses messages for errors that carry
|
|
65
|
+
// an exitCode (the throw-site is expected to have reported), so
|
|
66
|
+
// omitting the console.error would exit silently through the pa gsc
|
|
67
|
+
// binary.
|
|
68
|
+
console.error(state.message);
|
|
47
69
|
throw new VaultError(state.message, state.exitCode ?? 1);
|
|
48
70
|
}
|
|
49
71
|
if (!secretsFileExists(paths)) {
|
|
50
|
-
|
|
51
|
-
`
|
|
52
|
-
|
|
53
|
-
);
|
|
72
|
+
const message = `No secrets file at ${paths.secretsFile}.` + (paths.scoped
|
|
73
|
+
? ` Store the service-account JSON with:\n secrets set --scope ${paths.scope} ${vaultKey} -\n(then paste the JSON, then Ctrl-D)`
|
|
74
|
+
: " Run 'secrets init' first.");
|
|
75
|
+
console.error(message);
|
|
76
|
+
throw new VaultError(message, 1);
|
|
54
77
|
}
|
|
55
78
|
const data = await sopsDecrypt(paths, paths.secretsFile);
|
|
56
79
|
if (!(vaultKey in data)) {
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
);
|
|
80
|
+
const scopeHint = paths.scoped ? ` --scope ${paths.scope}` : '';
|
|
81
|
+
const message = `Vault key "${vaultKey}" not found in ${paths.secretsFile}. Store the service-account JSON with:\n secrets set${scopeHint} ${vaultKey} -\n(then paste the JSON, then Ctrl-D)`;
|
|
82
|
+
console.error(message);
|
|
83
|
+
throw new VaultError(message, 1);
|
|
61
84
|
}
|
|
62
85
|
const raw = data[vaultKey];
|
|
63
86
|
try {
|
|
64
87
|
return typeof raw === 'string' ? JSON.parse(raw) : raw;
|
|
65
88
|
} catch (e) {
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
);
|
|
89
|
+
const message = `Vault key "${vaultKey}" is not valid JSON: ${e.message}`;
|
|
90
|
+
console.error(message);
|
|
91
|
+
throw new VaultError(message, 2);
|
|
70
92
|
}
|
|
71
93
|
}
|
|
72
94
|
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
//
|
|
25
25
|
// `fetchImpl` and `now` are injectable for tests.
|
|
26
26
|
|
|
27
|
-
import {
|
|
27
|
+
import { resolveCommandPaths, checkVault, secretsFileExists, sopsDecrypt, VaultError } from '../secrets/vault.mjs';
|
|
28
28
|
|
|
29
29
|
const CF_API = 'https://api.cloudflare.com/client/v4';
|
|
30
30
|
|
|
@@ -122,14 +122,23 @@ export async function loadCloudflareCreds(overrides = {}) {
|
|
|
122
122
|
if (overrides.vaultData) {
|
|
123
123
|
data = overrides.vaultData;
|
|
124
124
|
} else {
|
|
125
|
-
|
|
125
|
+
// resolveCommandPaths honours --scope/--file (via a pre-resolved
|
|
126
|
+
// overrides.paths), the SECRETS_DEFAULT_SCOPE env, defaults.json's
|
|
127
|
+
// defaultScope, and the calling PA's identity.json, before falling
|
|
128
|
+
// back to the legacy single-file vault. Same routing as `secrets get`
|
|
129
|
+
// and `pa gsc`'s SA-key loader — one resolver, one routing signal.
|
|
130
|
+
const paths = resolveCommandPaths(overrides);
|
|
126
131
|
const state = checkVault(paths);
|
|
127
|
-
if (!state.ok)
|
|
132
|
+
if (!state.ok) {
|
|
133
|
+
console.error(state.message);
|
|
134
|
+
throw new VaultError(state.message, state.exitCode ?? 1);
|
|
135
|
+
}
|
|
128
136
|
if (!secretsFileExists(paths)) {
|
|
129
|
-
|
|
130
|
-
`
|
|
131
|
-
|
|
132
|
-
);
|
|
137
|
+
const message = `No secrets file at ${paths.secretsFile}.` + (paths.scoped
|
|
138
|
+
? ` Store Cloudflare credentials with 'secrets set --scope ${paths.scope} <KEY> <value>'.`
|
|
139
|
+
: " Run 'secrets init' first.");
|
|
140
|
+
console.error(message);
|
|
141
|
+
throw new VaultError(message, 1);
|
|
133
142
|
}
|
|
134
143
|
data = await sopsDecrypt(paths, paths.secretsFile);
|
|
135
144
|
}
|
|
@@ -10,6 +10,7 @@ import { dirname, join, resolve } from 'node:path';
|
|
|
10
10
|
import { fileURLToPath } from 'node:url';
|
|
11
11
|
import { run as runDesk } from '../../../engineering-pa-core/src/desk/cli.mjs';
|
|
12
12
|
import { PA_PACKAGE_NAME } from '../../../engineering-pa-core/src/config/pa-package-name.mjs';
|
|
13
|
+
import { readPreferencesSync } from '../../../engineering-pa-core/src/preferences.mjs';
|
|
13
14
|
import { main as runMigrateInspect } from '../../../engineering-pa-migrate-inspect/src/cli.mjs';
|
|
14
15
|
|
|
15
16
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
@@ -198,11 +199,8 @@ async function doStatus(opts = {}) {
|
|
|
198
199
|
function resolveWorkspacePort(repoRoot, opts = {}) {
|
|
199
200
|
if (opts.workspacePort) return opts.workspacePort;
|
|
200
201
|
try {
|
|
201
|
-
const
|
|
202
|
-
if (
|
|
203
|
-
const prefs = JSON.parse(readFileSync(prefsPath, 'utf8'));
|
|
204
|
-
if (typeof prefs.workspacePort === 'number') return prefs.workspacePort;
|
|
205
|
-
}
|
|
202
|
+
const prefs = readPreferencesSync(repoRoot);
|
|
203
|
+
if (prefs && typeof prefs.workspacePort === 'number') return prefs.workspacePort;
|
|
206
204
|
} catch { /* fall through to default */ }
|
|
207
205
|
return 4200;
|
|
208
206
|
}
|
|
@@ -21,6 +21,8 @@ import { readFile, stat } from 'node:fs/promises';
|
|
|
21
21
|
import { spawn } from 'node:child_process';
|
|
22
22
|
import { dirname, join, resolve } from 'node:path';
|
|
23
23
|
|
|
24
|
+
import { mergePreferences } from '../../../engineering-pa-core/src/preferences.mjs';
|
|
25
|
+
|
|
24
26
|
/**
|
|
25
27
|
* Resolve the operator repo root with the priority documented in the brief:
|
|
26
28
|
* 1. process.env.DAVE_REPO_ROOT (if set and the directory exists)
|
|
@@ -93,12 +95,22 @@ export class SessionError extends Error {
|
|
|
93
95
|
* and confirm `agents/<paName>/` exists. Returns a plan object describing
|
|
94
96
|
* what the verb is about to do — `runSession` then either execs `claude`
|
|
95
97
|
* or hands the plan to a test seam.
|
|
98
|
+
*
|
|
99
|
+
* The optional device-local overlay `operator/config/preferences.local.json`
|
|
100
|
+
* shallow-merges OVER the base so a device-scoped paName can win on this
|
|
101
|
+
* host without editing the shared file (part of w-2026-08-07-002's overlay
|
|
102
|
+
* sweep). An absent/malformed overlay is silently ignored so behaviour
|
|
103
|
+
* without an overlay is byte-identical to the pre-overlay implementation;
|
|
104
|
+
* every error path (missing/malformed/non-object/missing-paName) still
|
|
105
|
+
* points the operator at the SHARED base file, which is the one they need
|
|
106
|
+
* to fix to make this device bootable at all.
|
|
96
107
|
*/
|
|
97
108
|
export async function buildSessionPlan(repoRoot, seam = {}) {
|
|
98
109
|
const readFileImpl = seam.readFile ?? readFile;
|
|
99
110
|
const pathExists = seam.pathExists ?? defaultPathExists;
|
|
100
111
|
|
|
101
112
|
const preferencesPath = join(repoRoot, 'operator', 'config', 'preferences.json');
|
|
113
|
+
const overlayPath = join(repoRoot, 'operator', 'config', 'preferences.local.json');
|
|
102
114
|
|
|
103
115
|
let raw;
|
|
104
116
|
try {
|
|
@@ -133,7 +145,18 @@ export async function buildSessionPlan(repoRoot, seam = {}) {
|
|
|
133
145
|
);
|
|
134
146
|
}
|
|
135
147
|
|
|
136
|
-
|
|
148
|
+
// Device-local overlay: fail-open. Missing file (ENOENT) OR malformed JSON
|
|
149
|
+
// OR non-object shape leaves the merged view equal to the base.
|
|
150
|
+
let overlay = null;
|
|
151
|
+
try {
|
|
152
|
+
const overlayRaw = await readFileImpl(overlayPath, 'utf8');
|
|
153
|
+
try {
|
|
154
|
+
overlay = JSON.parse(overlayRaw);
|
|
155
|
+
} catch { overlay = null; }
|
|
156
|
+
} catch { overlay = null; }
|
|
157
|
+
const merged = mergePreferences(prefs, overlay) || prefs;
|
|
158
|
+
|
|
159
|
+
const paName = merged.paName;
|
|
137
160
|
if (typeof paName !== 'string' || paName.trim().length === 0) {
|
|
138
161
|
throw new SessionError(
|
|
139
162
|
`preferences.json at ${preferencesPath} is missing required field \`paName\`.`,
|
|
@@ -8,8 +8,7 @@
|
|
|
8
8
|
// PA name (for --mine resolution) is read from
|
|
9
9
|
// operator/config/preferences.json. Falls back to 'pa' if missing.
|
|
10
10
|
|
|
11
|
-
import {
|
|
12
|
-
import { join } from 'node:path';
|
|
11
|
+
import { readPreferences } from '../../../engineering-pa-core/src/preferences.mjs';
|
|
13
12
|
|
|
14
13
|
import { createWork, setWorkStatus, listWork } from './work.mjs';
|
|
15
14
|
import { getRepoRoot } from './repo-root.mjs';
|
|
@@ -80,11 +79,10 @@ function parseFlags(args) {
|
|
|
80
79
|
async function getPaName() {
|
|
81
80
|
try {
|
|
82
81
|
const repoRoot = await getRepoRoot();
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
);
|
|
87
|
-
const config = JSON.parse(raw);
|
|
82
|
+
// Overlay-aware: preferences.local.json shallow-merges OVER the shared
|
|
83
|
+
// preferences.json so any device-local paName wins on this device.
|
|
84
|
+
const config = await readPreferences(repoRoot);
|
|
85
|
+
if (!config) return 'pa';
|
|
88
86
|
return config.paName || config.agentName || 'pa';
|
|
89
87
|
} catch {
|
|
90
88
|
return 'pa';
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
@@ -32,9 +32,11 @@
|
|
|
32
32
|
// desk/pa (show everything). This is load-bearing — existing installs must
|
|
33
33
|
// not silently start hiding projects on a package bump.
|
|
34
34
|
|
|
35
|
-
import {
|
|
36
|
-
|
|
37
|
-
|
|
35
|
+
import { readPreferences, readPreferencesSync, mergePreferences } from '../preferences.mjs';
|
|
36
|
+
|
|
37
|
+
// Re-export the merge helper so callers that still reach for it through
|
|
38
|
+
// team-scope keep working. Preferences.mjs is the single source of truth.
|
|
39
|
+
export { mergePreferences };
|
|
38
40
|
|
|
39
41
|
/**
|
|
40
42
|
* Resolve the agent identity a project is scoped to, or null if the
|
|
@@ -106,59 +108,6 @@ export function filterProjectsByTeam(projects, opts = {}) {
|
|
|
106
108
|
* Absent/blank -> null, which means "do not team-scope nav" (fail open,
|
|
107
109
|
* backwards compatible: pre-team devices show every nav fragment).
|
|
108
110
|
*/
|
|
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
111
|
function normaliseTeamScopeConfig(config) {
|
|
163
112
|
const c = config && typeof config === 'object' ? config : {};
|
|
164
113
|
const paName = c.paName || c.agentName || 'pa';
|
|
@@ -189,11 +138,9 @@ function normaliseTeamScopeConfig(config) {
|
|
|
189
138
|
* @returns {Promise<{ paName: string, teamIdentities: string[], scopeEnabled: boolean, team: string|null }>}
|
|
190
139
|
*/
|
|
191
140
|
export async function readTeamScopeConfig(repoRoot) {
|
|
192
|
-
// Base
|
|
141
|
+
// Base + device-local overlay via the shared overlay-aware reader.
|
|
193
142
|
// Missing/malformed either file -> null for that layer -> defaults.
|
|
194
|
-
|
|
195
|
-
const overlay = await readJsonAsync(join(repoRoot, 'operator', 'config', 'preferences.local.json'));
|
|
196
|
-
return normaliseTeamScopeConfig(mergePreferences(base, overlay));
|
|
143
|
+
return normaliseTeamScopeConfig(await readPreferences(repoRoot));
|
|
197
144
|
}
|
|
198
145
|
|
|
199
146
|
/**
|
|
@@ -207,11 +154,9 @@ export async function readTeamScopeConfig(repoRoot) {
|
|
|
207
154
|
* @returns {{ paName: string, teamIdentities: string[], scopeEnabled: boolean, team: string|null }}
|
|
208
155
|
*/
|
|
209
156
|
export function readTeamScopeConfigSync(repoRoot) {
|
|
210
|
-
// Base
|
|
157
|
+
// Base + device-local overlay via the shared overlay-aware reader.
|
|
211
158
|
// Missing/malformed either file -> null for that layer -> defaults.
|
|
212
|
-
|
|
213
|
-
const overlay = readJsonSync(join(repoRoot, 'operator', 'config', 'preferences.local.json'));
|
|
214
|
-
return normaliseTeamScopeConfig(mergePreferences(base, overlay));
|
|
159
|
+
return normaliseTeamScopeConfig(readPreferencesSync(repoRoot));
|
|
215
160
|
}
|
|
216
161
|
|
|
217
162
|
/**
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// preferences.mjs — overlay-aware reader for the operator preferences file.
|
|
2
|
+
//
|
|
3
|
+
// The estate ships a shared `operator/config/preferences.json` (down-path-
|
|
4
|
+
// materialised from a canonical source via library-sync) plus an optional
|
|
5
|
+
// device-local overlay at `operator/config/preferences.local.json` that
|
|
6
|
+
// shallow-merges OVER the base. The overlay lives outside every mount source
|
|
7
|
+
// so down-path reconciliation never tracks it, and it is gitignored so
|
|
8
|
+
// estate git-sync never carries it between hosts.
|
|
9
|
+
//
|
|
10
|
+
// This module is the ONE place every consumer reaches for merged
|
|
11
|
+
// preferences. Before this module existed, ~15 consumer sites (workspace
|
|
12
|
+
// API surfaces, workspace server bind, CLI verbs, environment probes,
|
|
13
|
+
// coordinator-hook) each read `preferences.json` directly with
|
|
14
|
+
// `readFileSync`/`readFile`, which ignored the overlay and made every
|
|
15
|
+
// device-local identity field (paName, pa.*, workspacePort) invisible on
|
|
16
|
+
// every surface except the two `desk/team-scope` readers that had the
|
|
17
|
+
// overlay baked in. See w-2026-08-07-002 and d-2026-08-07-005 for the
|
|
18
|
+
// decision to sweep every reader onto a single overlay-aware helper.
|
|
19
|
+
//
|
|
20
|
+
// Merge semantics: SHALLOW. Overlay keys win at the top level; keys the
|
|
21
|
+
// overlay omits fall through to the base. Deep-merge was considered and
|
|
22
|
+
// rejected at ratification — the existing team-scope reader is shallow,
|
|
23
|
+
// every existing overlay caller already assumes shallow, and deep-merge
|
|
24
|
+
// changes the behaviour of nested-object fields (e.g. `pa: { name, role,
|
|
25
|
+
// roleTitle }`) in ways that would silently reshape callers if we upgraded
|
|
26
|
+
// under them. If a caller needs deep-merge for one field it can compose
|
|
27
|
+
// itself; the shared helper stays predictable.
|
|
28
|
+
//
|
|
29
|
+
// Fail-open posture: an absent or malformed EITHER file yields null for
|
|
30
|
+
// that layer, exactly as before. A device with no overlay reads
|
|
31
|
+
// byte-identically to pre-overlay releases; a device with a malformed
|
|
32
|
+
// overlay falls back to base-only rather than erroring; a device with
|
|
33
|
+
// neither file yields null (the "no config at all" signal every caller
|
|
34
|
+
// already handles).
|
|
35
|
+
|
|
36
|
+
import { readFile } from 'node:fs/promises';
|
|
37
|
+
import { readFileSync } from 'node:fs';
|
|
38
|
+
import { join } from 'node:path';
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Path to the shared preferences file for a given repo root.
|
|
42
|
+
* Exported so callers that still need the raw path (e.g. `pa session`'s
|
|
43
|
+
* error messages) can compute it without duplicating the join.
|
|
44
|
+
*
|
|
45
|
+
* @param {string} repoRoot
|
|
46
|
+
* @returns {string}
|
|
47
|
+
*/
|
|
48
|
+
export function preferencesPath(repoRoot) {
|
|
49
|
+
return join(repoRoot, 'operator', 'config', 'preferences.json');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Path to the device-local overlay file for a given repo root.
|
|
54
|
+
* @param {string} repoRoot
|
|
55
|
+
* @returns {string}
|
|
56
|
+
*/
|
|
57
|
+
export function preferencesOverlayPath(repoRoot) {
|
|
58
|
+
return join(repoRoot, 'operator', 'config', 'preferences.local.json');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Shallow-merge the optional device-local overlay OVER the parsed base
|
|
63
|
+
* preferences. Both inputs are already-parsed values (a plain object, or
|
|
64
|
+
* null when the file was missing/malformed). Non-object / array inputs are
|
|
65
|
+
* treated as null (fail-open). Returns null only when BOTH are absent,
|
|
66
|
+
* preserving the "no config at all" signal every caller already handles.
|
|
67
|
+
*
|
|
68
|
+
* @param {object|null} base
|
|
69
|
+
* @param {object|null} overlay
|
|
70
|
+
* @returns {object|null}
|
|
71
|
+
*/
|
|
72
|
+
export function mergePreferences(base, overlay) {
|
|
73
|
+
const b = base && typeof base === 'object' && !Array.isArray(base) ? base : null;
|
|
74
|
+
const o = overlay && typeof overlay === 'object' && !Array.isArray(overlay) ? overlay : null;
|
|
75
|
+
if (!b && !o) return null;
|
|
76
|
+
return { ...(b || {}), ...(o || {}) };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Read + JSON-parse a file, returning the parsed value or null on any error
|
|
81
|
+
* (missing file, unreadable, malformed JSON). Shared by the base + overlay
|
|
82
|
+
* reads so both fail-open identically.
|
|
83
|
+
*/
|
|
84
|
+
function readJsonSync(path) {
|
|
85
|
+
try {
|
|
86
|
+
return JSON.parse(readFileSync(path, 'utf8'));
|
|
87
|
+
} catch {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function readJsonAsync(path) {
|
|
93
|
+
try {
|
|
94
|
+
return JSON.parse(await readFile(path, 'utf8'));
|
|
95
|
+
} catch {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Read the merged preferences for a repo root: the shared base file
|
|
102
|
+
* (`operator/config/preferences.json`) with the device-local overlay
|
|
103
|
+
* (`operator/config/preferences.local.json`) shallow-merged OVER it.
|
|
104
|
+
*
|
|
105
|
+
* Returns the merged plain object, or null when NEITHER file is
|
|
106
|
+
* present/parseable. A malformed either file is treated as absent for
|
|
107
|
+
* that layer (fail-open). This is deliberately a plain reader — callers
|
|
108
|
+
* that need typed shapes (e.g. `desk/team-scope` normalising for scoping)
|
|
109
|
+
* layer their own extraction on top.
|
|
110
|
+
*
|
|
111
|
+
* @param {string} repoRoot
|
|
112
|
+
* @returns {Promise<object|null>}
|
|
113
|
+
*/
|
|
114
|
+
export async function readPreferences(repoRoot) {
|
|
115
|
+
const base = await readJsonAsync(preferencesPath(repoRoot));
|
|
116
|
+
const overlay = await readJsonAsync(preferencesOverlayPath(repoRoot));
|
|
117
|
+
return mergePreferences(base, overlay);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Synchronous mirror of {@link readPreferences}. Used by hot boot paths
|
|
122
|
+
* (workspace server bind, workspace API handlers, environment probes)
|
|
123
|
+
* where the async surface would force a whole call chain to become async
|
|
124
|
+
* for no functional gain.
|
|
125
|
+
*
|
|
126
|
+
* @param {string} repoRoot
|
|
127
|
+
* @returns {object|null}
|
|
128
|
+
*/
|
|
129
|
+
export function readPreferencesSync(repoRoot) {
|
|
130
|
+
const base = readJsonSync(preferencesPath(repoRoot));
|
|
131
|
+
const overlay = readJsonSync(preferencesOverlayPath(repoRoot));
|
|
132
|
+
return mergePreferences(base, overlay);
|
|
133
|
+
}
|
|
File without changes
|
|
File without changes
|
|
@@ -9,6 +9,7 @@ import { hostname } from 'node:os';
|
|
|
9
9
|
import { getSecretsStatus, setSecret, MISSING_TARGET_ERROR } from './secrets-api.mjs';
|
|
10
10
|
import { detect as schedulerDetect, list as schedulerList } from '../../engineering-pa-core/src/scheduler/index.mjs';
|
|
11
11
|
import { filterProjectsByTeam, readTeamScopeConfig } from '../../engineering-pa-core/src/desk/team-scope.mjs';
|
|
12
|
+
import { readPreferencesSync } from '../../engineering-pa-core/src/preferences.mjs';
|
|
12
13
|
|
|
13
14
|
/**
|
|
14
15
|
* handleApi -- route handler for all API endpoints.
|
|
@@ -63,11 +64,10 @@ export function handleApi(req, res, url, repoRoot, deps = {}) {
|
|
|
63
64
|
// GET /__api/config
|
|
64
65
|
if (method === 'GET' && pathname === '/__api/config') {
|
|
65
66
|
try {
|
|
66
|
-
const prefsFile = join(repoRoot, 'operator', 'config', 'preferences.json');
|
|
67
67
|
let operator = { name: 'Operator', role: 'Operator' };
|
|
68
68
|
let pa = { name: 'PA', role: 'PA' };
|
|
69
|
-
|
|
70
|
-
|
|
69
|
+
const prefs = readPreferencesSync(repoRoot);
|
|
70
|
+
if (prefs) {
|
|
71
71
|
if (prefs.operator && prefs.operator.name) operator = prefs.operator;
|
|
72
72
|
if (prefs.pa && prefs.pa.name) pa = prefs.pa;
|
|
73
73
|
}
|
|
@@ -855,9 +855,8 @@ function buildOverview(projects, work, repoRoot) {
|
|
|
855
855
|
let paName = null;
|
|
856
856
|
let paRoleTitle = null;
|
|
857
857
|
try {
|
|
858
|
-
const
|
|
859
|
-
if (
|
|
860
|
-
const prefs = JSON.parse(readFileSync(prefsFile, 'utf-8'));
|
|
858
|
+
const prefs = readPreferencesSync(repoRoot);
|
|
859
|
+
if (prefs) {
|
|
861
860
|
paName = prefs.paName || null;
|
|
862
861
|
if (prefs.pa && typeof prefs.pa.roleTitle === 'string' && prefs.pa.roleTitle.length) {
|
|
863
862
|
paRoleTitle = prefs.pa.roleTitle;
|
|
@@ -970,12 +969,9 @@ async function buildGitOverview(repoRoot) {
|
|
|
970
969
|
// just come back empty and the page renders the user's own repos only.
|
|
971
970
|
let orgs = [];
|
|
972
971
|
try {
|
|
973
|
-
const
|
|
974
|
-
if (
|
|
975
|
-
|
|
976
|
-
if (Array.isArray(prefs.gitOrgs)) {
|
|
977
|
-
orgs = prefs.gitOrgs.filter(function(o) { return typeof o === 'string' && o.length > 0; });
|
|
978
|
-
}
|
|
972
|
+
const prefs = readPreferencesSync(repoRoot);
|
|
973
|
+
if (prefs && Array.isArray(prefs.gitOrgs)) {
|
|
974
|
+
orgs = prefs.gitOrgs.filter(function(o) { return typeof o === 'string' && o.length > 0; });
|
|
979
975
|
}
|
|
980
976
|
} catch { /* non-fatal */ }
|
|
981
977
|
|
|
@@ -1975,12 +1971,9 @@ export function buildNextSession(repoRoot) {
|
|
|
1975
1971
|
// 3. 'dave' (canonical default for legacy deployments)
|
|
1976
1972
|
let paName = null;
|
|
1977
1973
|
try {
|
|
1978
|
-
const
|
|
1979
|
-
if (
|
|
1980
|
-
|
|
1981
|
-
if (prefs && typeof prefs.paName === 'string' && prefs.paName.length) {
|
|
1982
|
-
paName = prefs.paName;
|
|
1983
|
-
}
|
|
1974
|
+
const prefs = readPreferencesSync(repoRoot);
|
|
1975
|
+
if (prefs && typeof prefs.paName === 'string' && prefs.paName.length) {
|
|
1976
|
+
paName = prefs.paName;
|
|
1984
1977
|
}
|
|
1985
1978
|
} catch { /* non-fatal */ }
|
|
1986
1979
|
|
|
@@ -27,10 +27,11 @@
|
|
|
27
27
|
*/
|
|
28
28
|
|
|
29
29
|
import { execSync } from 'node:child_process';
|
|
30
|
-
import {
|
|
30
|
+
import { writeFileSync, mkdirSync, existsSync, unlinkSync, realpathSync } from 'node:fs';
|
|
31
31
|
import { homedir, platform } from 'node:os';
|
|
32
32
|
import { dirname, join, resolve } from 'node:path';
|
|
33
33
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
34
|
+
import { readPreferencesSync } from '../../engineering-pa-core/src/preferences.mjs';
|
|
34
35
|
|
|
35
36
|
const __filename = fileURLToPath(import.meta.url);
|
|
36
37
|
const __dirname = dirname(__filename);
|
|
@@ -55,7 +56,6 @@ function findRepoRoot(start) {
|
|
|
55
56
|
const envRoot = process.env.DAVE_REPO_ROOT || process.env.PA_REPO_ROOT;
|
|
56
57
|
const REPO_ROOT = envRoot ? resolve(envRoot) : (findRepoRoot(process.cwd()) || process.cwd());
|
|
57
58
|
const SERVER_SCRIPT = join(__dirname, 'server.mjs');
|
|
58
|
-
const PREFS_FILE = join(REPO_ROOT, 'operator', 'config', 'preferences.json');
|
|
59
59
|
|
|
60
60
|
const LABEL = 'com.dave.workspace';
|
|
61
61
|
const UNIT_NAME = 'dave-workspace.service';
|
|
@@ -80,7 +80,8 @@ function defaultLogPaths() {
|
|
|
80
80
|
/**
|
|
81
81
|
* Resolve the workspace port at install time. Precedence:
|
|
82
82
|
* 1. CLI --port flag
|
|
83
|
-
* 2. operator/config/preferences.json workspacePort
|
|
83
|
+
* 2. operator/config/preferences.json workspacePort (with device-local
|
|
84
|
+
* preferences.local.json overlay applied)
|
|
84
85
|
* 3. default 4200
|
|
85
86
|
* @param {string|undefined} flagPort
|
|
86
87
|
* @returns {number}
|
|
@@ -94,8 +95,8 @@ export function resolvePort(flagPort) {
|
|
|
94
95
|
return parsed;
|
|
95
96
|
}
|
|
96
97
|
try {
|
|
97
|
-
const prefs =
|
|
98
|
-
if (prefs.workspacePort) return prefs.workspacePort;
|
|
98
|
+
const prefs = readPreferencesSync(REPO_ROOT);
|
|
99
|
+
if (prefs && prefs.workspacePort) return prefs.workspacePort;
|
|
99
100
|
} catch { /* fall through to default */ }
|
|
100
101
|
return 4200;
|
|
101
102
|
}
|
|
@@ -20,13 +20,14 @@
|
|
|
20
20
|
|
|
21
21
|
import { createServer } from 'node:http';
|
|
22
22
|
import { readFile, stat, readdir } from 'node:fs/promises';
|
|
23
|
-
import {
|
|
23
|
+
import { existsSync, realpathSync } from 'node:fs';
|
|
24
24
|
import { execSync } from 'node:child_process';
|
|
25
25
|
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
29
|
import { readTeamScopeConfigSync } from '../../engineering-pa-core/src/desk/team-scope.mjs';
|
|
30
|
+
import { readPreferencesSync } from '../../engineering-pa-core/src/preferences.mjs';
|
|
30
31
|
import { startPoller as startStatusMonPoller } from './status-mon/poller.mjs';
|
|
31
32
|
|
|
32
33
|
const __dirname = fileURLToPath(new URL('.', import.meta.url));
|
|
@@ -89,11 +90,11 @@ let autoOpen = true;
|
|
|
89
90
|
let timeoutMinutes = 120;
|
|
90
91
|
let noTimeout = false;
|
|
91
92
|
|
|
92
|
-
// Read configured port from preferences if available
|
|
93
|
+
// Read configured port from preferences if available (device-local overlay
|
|
94
|
+
// wins over the shared base — see @stravica-ai/engineering-pa-core/src/preferences.mjs).
|
|
93
95
|
try {
|
|
94
|
-
const
|
|
95
|
-
|
|
96
|
-
if (prefs.workspacePort) port = prefs.workspacePort;
|
|
96
|
+
const prefs = readPreferencesSync(REPO_ROOT);
|
|
97
|
+
if (prefs && prefs.workspacePort) port = prefs.workspacePort;
|
|
97
98
|
} catch { /* no prefs yet — use default */ }
|
|
98
99
|
|
|
99
100
|
// CLI flags override preferences
|
|
@@ -12,20 +12,11 @@
|
|
|
12
12
|
|
|
13
13
|
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
|
|
14
14
|
import { dirname, join } from 'node:path';
|
|
15
|
+
import { readPreferencesSync } from '../../../engineering-pa-core/src/preferences.mjs';
|
|
15
16
|
import { poll, resolveSources, mergeWithPrevious } from './poll.mjs';
|
|
16
17
|
|
|
17
18
|
const DEFAULT_INTERVAL_MS = 60_000;
|
|
18
19
|
|
|
19
|
-
function readPreferences(repoRoot) {
|
|
20
|
-
try {
|
|
21
|
-
const prefsPath = join(repoRoot, 'operator', 'config', 'preferences.json');
|
|
22
|
-
if (!existsSync(prefsPath)) return null;
|
|
23
|
-
return JSON.parse(readFileSync(prefsPath, 'utf-8'));
|
|
24
|
-
} catch {
|
|
25
|
-
return null;
|
|
26
|
-
}
|
|
27
|
-
}
|
|
28
|
-
|
|
29
20
|
function readState(statePath) {
|
|
30
21
|
try {
|
|
31
22
|
if (!existsSync(statePath)) return null;
|
|
@@ -68,7 +59,9 @@ export function startPoller(options) {
|
|
|
68
59
|
async function runOnce() {
|
|
69
60
|
if (stopped) return null;
|
|
70
61
|
try {
|
|
71
|
-
|
|
62
|
+
// Overlay-aware read: any device-scoped statusMonSources override in
|
|
63
|
+
// preferences.local.json shallow-merges OVER the shared base.
|
|
64
|
+
const prefs = readPreferencesSync(repoRoot);
|
|
72
65
|
const sources = resolveSources(prefs);
|
|
73
66
|
const previous = readState(statePath);
|
|
74
67
|
const fresh = await poll({ sources, fetcher, nowMs: Date.now() });
|
|
@@ -23,10 +23,11 @@
|
|
|
23
23
|
* Exit codes: 0 ok | 1 usage | 2 fetch failure (all sources) | 3 write failure
|
|
24
24
|
*/
|
|
25
25
|
|
|
26
|
-
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
|
|
26
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync, realpathSync } from 'node:fs';
|
|
27
27
|
import { dirname, join, resolve } from 'node:path';
|
|
28
28
|
import { fileURLToPath } from 'node:url';
|
|
29
29
|
|
|
30
|
+
import { readPreferencesSync } from '../../../engineering-pa-core/src/preferences.mjs';
|
|
30
31
|
import { poll, resolveSources, mergeWithPrevious } from './poll.mjs';
|
|
31
32
|
|
|
32
33
|
const __filename = fileURLToPath(import.meta.url);
|
|
@@ -81,23 +82,15 @@ function readPreviousState(statePath) {
|
|
|
81
82
|
}
|
|
82
83
|
}
|
|
83
84
|
|
|
84
|
-
function readPreferences(repoRoot) {
|
|
85
|
-
try {
|
|
86
|
-
const prefsPath = join(repoRoot, 'operator', 'config', 'preferences.json');
|
|
87
|
-
if (!existsSync(prefsPath)) return null;
|
|
88
|
-
return JSON.parse(readFileSync(prefsPath, 'utf-8'));
|
|
89
|
-
} catch {
|
|
90
|
-
return null;
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
|
|
94
85
|
async function main() {
|
|
95
86
|
const parsed = parseArgs(process.argv.slice(2));
|
|
96
87
|
if (!parsed) { printHelp(); process.exit(1); }
|
|
97
88
|
if (parsed.help) { printHelp(); process.exit(0); }
|
|
98
89
|
|
|
99
90
|
const repoRoot = findRepoRoot();
|
|
100
|
-
|
|
91
|
+
// Overlay-aware read: preferences.local.json shallow-merges OVER
|
|
92
|
+
// preferences.json so any device-scoped statusMonSources override wins.
|
|
93
|
+
const prefs = readPreferencesSync(repoRoot);
|
|
101
94
|
const sources = resolveSources(prefs);
|
|
102
95
|
const outPath = parsed.outPath || join(repoRoot, '.state', 'status-mon.json');
|
|
103
96
|
|
|
@@ -127,7 +120,28 @@ async function main() {
|
|
|
127
120
|
process.exit(allFailed && !state.lastSuccessfulPollAt ? 2 : 0);
|
|
128
121
|
}
|
|
129
122
|
|
|
130
|
-
main()
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
123
|
+
// Entry-point guard: only run main() when this file is the process entry
|
|
124
|
+
// point. Under the single-bundle rewriter, dist-imports.test.mjs dynamically
|
|
125
|
+
// imports every "rewritten" module (one that crosses into another bundled
|
|
126
|
+
// package) to walk the ESM graph; before this file added its own
|
|
127
|
+
// cross-package import it stayed off that list, so the top-level
|
|
128
|
+
// main().catch() was tolerated. Rebuilding without a guard now runs main()
|
|
129
|
+
// during that dynamic import and the parseArgs sees the outer test process's
|
|
130
|
+
// argv. Matches the isMainModule pattern already used by ws.mjs /
|
|
131
|
+
// server.mjs / install-daemon.mjs (realpath both sides for pnpm-hoisted
|
|
132
|
+
// layouts where argv[1] is a symlink).
|
|
133
|
+
const isMainModule = (() => {
|
|
134
|
+
if (!process.argv[1]) return false;
|
|
135
|
+
try {
|
|
136
|
+
return fileURLToPath(import.meta.url) === realpathSync(resolve(process.argv[1]));
|
|
137
|
+
} catch {
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
140
|
+
})();
|
|
141
|
+
|
|
142
|
+
if (isMainModule) {
|
|
143
|
+
main().catch((err) => {
|
|
144
|
+
process.stderr.write(`unexpected: ${err && err.stack ? err.stack : err}\n`);
|
|
145
|
+
process.exit(1);
|
|
146
|
+
});
|
|
147
|
+
}
|
|
@@ -20,6 +20,7 @@ import { join, resolve, dirname } from 'node:path';
|
|
|
20
20
|
import { fileURLToPath } from 'node:url';
|
|
21
21
|
import { platform } from 'node:os';
|
|
22
22
|
import { get } from 'node:http';
|
|
23
|
+
import { readPreferencesSync } from '../../engineering-pa-core/src/preferences.mjs';
|
|
23
24
|
import {
|
|
24
25
|
detectSupervisor,
|
|
25
26
|
readSupervisorState,
|
|
@@ -36,7 +37,6 @@ const SERVER_SCRIPT = join(__dirname, 'server.mjs');
|
|
|
36
37
|
// .ws.pid / .ws.log are never written to during the subprocess test runs.
|
|
37
38
|
const PID_FILE = process.env.WS_PID_FILE || join(REPO_ROOT, '.ws.pid');
|
|
38
39
|
const LOG_FILE = process.env.WS_LOG_FILE || join(REPO_ROOT, '.ws.log');
|
|
39
|
-
const PREFS_FILE = join(REPO_ROOT, 'operator', 'config', 'preferences.json');
|
|
40
40
|
|
|
41
41
|
// Test-only override: lets e2e tests assert cross-platform parity by forcing
|
|
42
42
|
// the supervisor probe to behave as if it were running on a different OS.
|
|
@@ -75,10 +75,11 @@ function resolvePort() {
|
|
|
75
75
|
// 1. Default
|
|
76
76
|
let port = 4200;
|
|
77
77
|
|
|
78
|
-
// 2. Preferences override
|
|
78
|
+
// 2. Preferences override — device-local overlay wins over the shared base
|
|
79
|
+
// (see @stravica-ai/engineering-pa-core/src/preferences.mjs).
|
|
79
80
|
try {
|
|
80
|
-
const prefs =
|
|
81
|
-
if (prefs.workspacePort) port = prefs.workspacePort;
|
|
81
|
+
const prefs = readPreferencesSync(REPO_ROOT);
|
|
82
|
+
if (prefs && prefs.workspacePort) port = prefs.workspacePort;
|
|
82
83
|
} catch { /* no prefs — use default */ }
|
|
83
84
|
|
|
84
85
|
// 3. CLI flag override
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stravica-ai/digital-operator-pa",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "Digital Operator PA
|
|
3
|
+
"version": "0.1.18",
|
|
4
|
+
"description": "Digital Operator PA \u2014 single-bundled public package surface (bins + library) for the platform.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./index.mjs",
|
|
7
7
|
"exports": {
|
|
@@ -49,10 +49,11 @@
|
|
|
49
49
|
"engines": {
|
|
50
50
|
"node": ">=24"
|
|
51
51
|
},
|
|
52
|
-
"//": "test pins --test-isolation=none: default process-isolation mode silently undercounts (drops passed tests from totals) when a test awaits async work while stdout is patched by a capture() helper — see packages/cli/test/secrets. Counts must never lie.",
|
|
53
52
|
"scripts": {
|
|
54
53
|
"build": "node build.mjs",
|
|
54
|
+
"prepack": "node build.mjs",
|
|
55
55
|
"test": "node --test --test-isolation=none test/*.test.mjs",
|
|
56
56
|
"dry-publish": "node build.mjs && node scripts/dry-publish.mjs"
|
|
57
|
-
}
|
|
58
|
-
|
|
57
|
+
},
|
|
58
|
+
"//": "test pins --test-isolation=none: default process-isolation mode silently undercounts (drops passed tests from totals) when a test awaits async work while stdout is patched by a capture() helper \u2014 see packages/cli/test/secrets. Counts must never lie."
|
|
59
|
+
}
|