@stravica-ai/digital-operator-pa 0.1.26 → 0.1.28

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.
Files changed (49) hide show
  1. package/bin/library +12 -0
  2. package/bin/library-tunnel +12 -0
  3. package/dist/engineering-pa-cli/bin/estate-sync +0 -0
  4. package/dist/engineering-pa-cli/bin/launch +0 -0
  5. package/dist/engineering-pa-cli/bin/pa +0 -0
  6. package/dist/engineering-pa-cli/bin/pa-coordinator-hook +0 -0
  7. package/dist/engineering-pa-cli/bin/research +0 -0
  8. package/dist/engineering-pa-cli/bin/scheduler +0 -0
  9. package/dist/engineering-pa-cli/bin/secrets +0 -0
  10. package/dist/engineering-pa-cli/bin/smoke +0 -0
  11. package/dist/engineering-pa-cli/bin/sync +0 -0
  12. package/dist/engineering-pa-cli/scripts/migrate-doc-paths.mjs +0 -0
  13. package/dist/engineering-pa-cli/src/knowledge/context.mjs +2 -2
  14. package/dist/engineering-pa-cli/src/pa/session.mjs +8 -7
  15. package/dist/engineering-pa-core/bin/desk +0 -0
  16. package/dist/engineering-pa-core/bin/distill +0 -0
  17. package/dist/engineering-pa-core/bin/memory-sync +0 -0
  18. package/dist/engineering-pa-core/bin/slice +0 -0
  19. package/dist/engineering-pa-core/src/library-config.mjs +154 -2
  20. package/dist/engineering-pa-library-cli/README.md +64 -0
  21. package/dist/engineering-pa-library-cli/bin/library +12 -0
  22. package/dist/engineering-pa-library-cli/package.json +21 -0
  23. package/dist/engineering-pa-library-cli/src/ack.mjs +64 -0
  24. package/dist/engineering-pa-library-cli/src/cli.mjs +142 -0
  25. package/dist/engineering-pa-library-cli/src/config.mjs +239 -0
  26. package/dist/engineering-pa-library-cli/src/envelope.mjs +142 -0
  27. package/dist/engineering-pa-library-cli/src/http.mjs +156 -0
  28. package/dist/engineering-pa-library-cli/src/pull-clones.mjs +74 -0
  29. package/dist/engineering-pa-library-cli/src/verbs/flush.mjs +136 -0
  30. package/dist/engineering-pa-library-cli/src/verbs/get.mjs +45 -0
  31. package/dist/engineering-pa-library-cli/src/verbs/put.mjs +69 -0
  32. package/dist/engineering-pa-library-cli/src/verbs/search.mjs +98 -0
  33. package/dist/engineering-pa-library-cli/src/verbs/status.mjs +124 -0
  34. package/dist/engineering-pa-library-tunnel/README.md +84 -0
  35. package/dist/engineering-pa-library-tunnel/bin/library-tunnel +12 -0
  36. package/dist/engineering-pa-library-tunnel/package.json +18 -0
  37. package/dist/engineering-pa-library-tunnel/src/check-verb.mjs +50 -0
  38. package/dist/engineering-pa-library-tunnel/src/check.mjs +102 -0
  39. package/dist/engineering-pa-library-tunnel/src/cli.mjs +155 -0
  40. package/dist/engineering-pa-library-tunnel/src/config.mjs +117 -0
  41. package/dist/engineering-pa-library-tunnel/src/install.mjs +106 -0
  42. package/dist/engineering-pa-library-tunnel/src/plist.mjs +119 -0
  43. package/dist/engineering-pa-library-tunnel/src/status.mjs +93 -0
  44. package/dist/engineering-pa-library-tunnel/src/uninstall.mjs +48 -0
  45. package/dist/engineering-pa-migrate-inspect/bin/migrate-inspect +0 -0
  46. package/dist/engineering-pa-scaffold/bin/pa-init +0 -0
  47. package/dist/engineering-pa-workspace/src/ws.mjs +0 -0
  48. package/index.mjs +4 -0
  49. package/package.json +9 -7
@@ -0,0 +1,239 @@
1
+ // config.mjs — read + validate the `library.*` block in
2
+ // operator/config/preferences.json (with the standard preferences.local.json
3
+ // overlay applied on top).
4
+ //
5
+ // Spec §5.1 (remote-library-spec.md):
6
+ // - mode is EXPLICIT (`library.mode`), never auto-detected
7
+ // - mode=remote with missing endpoint or key REFUSES to boot; there is
8
+ // NO silent fallback to SIMPLE (falling back is a data-loss path)
9
+ //
10
+ // Implementation notes:
11
+ // - The reader mirrors the platform's overlay-aware `readPreferences`
12
+ // shape (shallow merge; overlay wins; missing/malformed = null)
13
+ // rather than importing it, so the tool has no runtime coupling to
14
+ // the installed `@stravica-ai/digital-operator-pa` package layout.
15
+ // - Consumers pass `{ repoRoot }` or the resolved paths; both surfaces
16
+ // are seams for tests.
17
+
18
+ import { readFileSync } from 'node:fs';
19
+ import { resolve, join, dirname } from 'node:path';
20
+ import { fileURLToPath } from 'node:url';
21
+ import { execFileSync } from 'node:child_process';
22
+
23
+ const __dirname = dirname(fileURLToPath(import.meta.url));
24
+
25
+ // operator/extensions/tools/library-cli/src/ → up five levels to the repo root.
26
+ export const DEFAULT_REPO_ROOT = resolve(__dirname, '..', '..', '..', '..', '..');
27
+
28
+ export class LibraryConfigError extends Error {
29
+ constructor(message, code) {
30
+ super(message);
31
+ this.name = 'LibraryConfigError';
32
+ this.code = code;
33
+ this.exitCode = 1; // usage / configuration error (tooling exit-code contract)
34
+ }
35
+ }
36
+
37
+ const VALID_MODES = new Set(['simple', 'remote']);
38
+ const DEFAULT_MODE = 'simple';
39
+
40
+ function readJsonObject(path) {
41
+ try {
42
+ const parsed = JSON.parse(readFileSync(path, 'utf8'));
43
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null;
44
+ } catch {
45
+ return null;
46
+ }
47
+ }
48
+
49
+ /**
50
+ * Merge base + optional device-local overlay preferences, shallow.
51
+ * Fail-open: missing/malformed either file contributes nothing.
52
+ *
53
+ * @param {object} paths
54
+ * @param {string} paths.base preferences.json path
55
+ * @param {string} paths.local preferences.local.json path
56
+ * @returns {object} merged preferences (empty object if both absent)
57
+ */
58
+ export function readMergedPreferences({ base, local }) {
59
+ const b = readJsonObject(base) || {};
60
+ const o = readJsonObject(local) || {};
61
+ return { ...b, ...o };
62
+ }
63
+
64
+ /**
65
+ * Resolve the library subsystem config from a merged preferences object.
66
+ * Returns `{ mode, remoteEndpoint?, remoteKey? }` on success.
67
+ * Throws {@link LibraryConfigError} on invalid shape or on
68
+ * `mode=remote` with missing required fields (spec §5.1 refuse-boot).
69
+ *
70
+ * @param {object} preferences merged preferences (base + overlay)
71
+ * @returns {{mode: 'simple'|'remote', remoteEndpoint?: string, remoteKey?: string}}
72
+ */
73
+ export function resolveLibraryConfig(preferences) {
74
+ const raw = preferences && typeof preferences === 'object' ? preferences.library : null;
75
+ const block = raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : {};
76
+
77
+ const mode = block.mode ?? DEFAULT_MODE;
78
+
79
+ if (!VALID_MODES.has(mode)) {
80
+ throw new LibraryConfigError(
81
+ `library.mode must be "simple" or "remote", got ${JSON.stringify(mode)}`,
82
+ 'invalid-mode',
83
+ );
84
+ }
85
+
86
+ if (mode === 'simple') {
87
+ return { mode };
88
+ }
89
+
90
+ // mode === 'remote'; validate required fields per spec §5.1.
91
+ const missing = [];
92
+ const endpoint = block.remoteEndpoint;
93
+ const key = block.remoteKey;
94
+ if (typeof endpoint !== 'string' || endpoint.trim() === '') missing.push('library.remoteEndpoint');
95
+ if (typeof key !== 'string' || key.trim() === '') missing.push('library.remoteKey');
96
+ if (missing.length > 0) {
97
+ throw new LibraryConfigError(
98
+ `library.mode="remote" but required field(s) missing/empty: ${missing.join(', ')}. ` +
99
+ `Refusing to boot the library subsystem (spec §5.1: no silent fallback to SIMPLE).`,
100
+ 'remote-config-missing',
101
+ );
102
+ }
103
+
104
+ // Shape check on remoteKey (`<subkeyId>:<secret>`) is cheap and catches
105
+ // a common paste mistake without over-validating (the server does the
106
+ // authoritative check).
107
+ if (!/^[^:\s]+:[^\s]+$/.test(key)) {
108
+ throw new LibraryConfigError(
109
+ `library.remoteKey must be of shape "<subkeyId>:<secret>" (no whitespace, colon separator).`,
110
+ 'remote-key-shape',
111
+ );
112
+ }
113
+
114
+ return { mode, remoteEndpoint: endpoint, remoteKey: key };
115
+ }
116
+
117
+ /**
118
+ * Full-scope vault reference form for `library.remoteKey`, per Barry's
119
+ * ruling on w-2026-07-15-023: `<scope>/<KEY_NAME>` (e.g.
120
+ * `hq-estate/REMOTE_LIBRARY_SUBKEY_DAVE`). Kept separate from the
121
+ * literal-subkey shape (`<subkeyId>:<secret>`) so the shape validator
122
+ * downstream still refuses garbage. Any value that neither matches this
123
+ * ref shape nor the literal shape is refused by resolveLibraryConfig.
124
+ */
125
+ const REMOTE_KEY_REF_RE = /^([a-z0-9-]+)\/([A-Z][A-Z0-9_]*)$/;
126
+
127
+ /**
128
+ * Resolve a `library.remoteKey` value that is a full-scope vault
129
+ * reference into the underlying literal `<subkeyId>:<secret>` by calling
130
+ * the estate `secrets` CLI. Literal values (containing a colon) and
131
+ * anything else pass through untouched; the caller-side shape validator
132
+ * is authoritative on shape.
133
+ *
134
+ * Canonical stravica-operator-barry cannot carry the literal subkey
135
+ * (sync-cycle would round-trip a plaintext secret through a git-tracked
136
+ * file). The reference lives in canonical; the vault holds the value;
137
+ * this function is the bridge. See w-2026-08-21-005 for the incident
138
+ * that made this the durable pattern.
139
+ *
140
+ * @param {string} refOrLiteral the raw remoteKey value from preferences
141
+ * @param {object} [opts]
142
+ * @param {string} [opts.repoRoot] defaults to DEFAULT_REPO_ROOT
143
+ * @param {(args:{scope:string,key:string,repoRoot:string})=>string} [opts.secretsGet]
144
+ * injectable resolver (tests)
145
+ * @returns {string} the resolved literal (never printed)
146
+ */
147
+ export function resolveRemoteKeyRef(
148
+ refOrLiteral,
149
+ { repoRoot = DEFAULT_REPO_ROOT, secretsGet = defaultSecretsGet } = {},
150
+ ) {
151
+ if (typeof refOrLiteral !== 'string') return refOrLiteral;
152
+ const m = REMOTE_KEY_REF_RE.exec(refOrLiteral);
153
+ if (!m) return refOrLiteral;
154
+ const [, scope, key] = m;
155
+ let resolved;
156
+ try {
157
+ resolved = secretsGet({ scope, key, repoRoot });
158
+ } catch (err) {
159
+ throw new LibraryConfigError(
160
+ `library.remoteKey vault reference "${refOrLiteral}" failed to resolve ` +
161
+ `(scope=${scope} key=${key}): ${err && err.message ? err.message : err}. ` +
162
+ `Fix: pnpm exec secrets set ${key} --scope ${scope} <value>`,
163
+ 'remote-key-ref-unresolved',
164
+ );
165
+ }
166
+ if (typeof resolved !== 'string' || resolved.length === 0) {
167
+ throw new LibraryConfigError(
168
+ `library.remoteKey vault reference "${refOrLiteral}" resolved to empty value ` +
169
+ `(scope=${scope} key=${key}). ` +
170
+ `Fix: pnpm exec secrets set ${key} --scope ${scope} <value>`,
171
+ 'remote-key-ref-unresolved',
172
+ );
173
+ }
174
+ // secrets get can leave a trailing newline depending on subshell; strip
175
+ // whitespace once at the boundary so the shape validator sees the
176
+ // clean payload.
177
+ return resolved.replace(/\s+$/, '');
178
+ }
179
+
180
+ /**
181
+ * Default vault fetcher: invokes the `secrets` bin from the installed
182
+ * `@stravica-ai/digital-operator-pa` package under `repoRoot`. Uses
183
+ * direct `node <bin>` invocation (not `pnpm exec`) to sidestep the
184
+ * pnpm-11 stdout footgun that has bitten sam-tasks and others.
185
+ * Value is never logged.
186
+ */
187
+ function defaultSecretsGet({ scope, key, repoRoot }) {
188
+ const secretsBin = join(
189
+ repoRoot,
190
+ 'node_modules',
191
+ '@stravica-ai',
192
+ 'digital-operator-pa',
193
+ 'bin',
194
+ 'secrets',
195
+ );
196
+ const out = execFileSync('node', [secretsBin, 'get', key, '--scope', scope], {
197
+ cwd: repoRoot,
198
+ encoding: 'utf8',
199
+ stdio: ['ignore', 'pipe', 'pipe'],
200
+ });
201
+ return out;
202
+ }
203
+
204
+ /**
205
+ * Read + resolve in one call. Returns `{ mode, ...remoteFields, repoRoot,
206
+ * preferences }` on success or throws {@link LibraryConfigError}. The
207
+ * `preferences` field is the full merged object so callers can pluck
208
+ * neighbours (paName, pullClonesRoot, workspacePort) without a second read.
209
+ *
210
+ * Also resolves a `library.remoteKey` that carries the full-scope vault
211
+ * reference form (`<scope>/<KEY_NAME>`) into its underlying literal via
212
+ * `resolveRemoteKeyRef`. Literal values pass through unchanged.
213
+ *
214
+ * @param {object} [opts]
215
+ * @param {string} [opts.repoRoot] defaults to DEFAULT_REPO_ROOT
216
+ * @returns {{mode: 'simple'|'remote', remoteEndpoint?: string, remoteKey?: string, repoRoot: string, preferences: object}}
217
+ */
218
+ export function loadLibraryConfig({ repoRoot = DEFAULT_REPO_ROOT } = {}) {
219
+ const base = join(repoRoot, 'operator', 'config', 'preferences.json');
220
+ const local = join(repoRoot, 'operator', 'config', 'preferences.local.json');
221
+ const preferences = readMergedPreferences({ base, local });
222
+ // Vault-ref resolution runs BEFORE resolveLibraryConfig's shape check
223
+ // so a ref value never reaches the `<subkeyId>:<secret>` validator.
224
+ // The check on the returned literal still fires and refuses garbage.
225
+ if (
226
+ preferences &&
227
+ preferences.library &&
228
+ preferences.library.mode === 'remote' &&
229
+ typeof preferences.library.remoteKey === 'string'
230
+ ) {
231
+ const original = preferences.library.remoteKey;
232
+ const resolved = resolveRemoteKeyRef(original, { repoRoot });
233
+ if (resolved !== original) {
234
+ preferences.library = { ...preferences.library, remoteKey: resolved };
235
+ }
236
+ }
237
+ const resolved = resolveLibraryConfig(preferences);
238
+ return { ...resolved, repoRoot, preferences };
239
+ }
@@ -0,0 +1,142 @@
1
+ // envelope.mjs — build an EventInput envelope (spec §3.1) for one file.
2
+ //
3
+ // The server assigns `eventId` and `receivedAt`; the client contributes
4
+ // everything else. `contentSha` is computed over the canonical payload
5
+ // (spec §4.2 idempotency + item-4 build) so a retry of an identical
6
+ // event dedups on `clientEventId` and the sha matches the server's
7
+ // canonical hash.
8
+ //
9
+ // The kind registry from `@stravica-ai/remote-library` is the strict
10
+ // enumeration; unknown kinds are refused at ingest with 422 anyway,
11
+ // but we reject them locally too so an operator gets a clean error
12
+ // without a round-trip.
13
+
14
+ import { createHash, randomUUID } from 'node:crypto';
15
+ import { hostname } from 'node:os';
16
+ import { relative, isAbsolute } from 'node:path';
17
+
18
+ import { EVENT_KINDS, canonicalJson } from '@stravica-ai/remote-library';
19
+
20
+ export const KNOWN_KINDS = new Set(EVENT_KINDS);
21
+
22
+ /**
23
+ * Compute the sha256 hash of the canonical JSON form of a payload.
24
+ * The `sha256:` prefix matches the server's contentSha convention.
25
+ *
26
+ * @param {object} payload
27
+ * @returns {string} e.g. "sha256:abc123..."
28
+ */
29
+ export function contentShaOf(payload) {
30
+ const canonical = canonicalJson(payload);
31
+ return `sha256:${createHash('sha256').update(canonical).digest('hex')}`;
32
+ }
33
+
34
+ /**
35
+ * Guess the event kind from a staging file path when the operator did
36
+ * not supply one via `--kind`. Falls back to
37
+ * `knowledge.observation.staged` because the two verbs that need a
38
+ * guess (`put` on a staging file, `flush`) both target that kind. If
39
+ * the file lives outside a staging tree the caller must pass an
40
+ * explicit `--kind` and this function refuses.
41
+ *
42
+ * @param {string} sourcePath absolute or repo-relative
43
+ * @returns {string|null}
44
+ */
45
+ export function guessKindFromPath(sourcePath) {
46
+ if (typeof sourcePath !== 'string') return null;
47
+ if (/agents\/[^/]+\/staging\//.test(sourcePath)) return 'knowledge.observation.staged';
48
+ return null;
49
+ }
50
+
51
+ /**
52
+ * Build a `knowledge.observation.staged` envelope for a staging file.
53
+ * The payload carries `path` (destination hint — the worker's apply
54
+ * function decides the actual write path) and the raw markdown body.
55
+ * `frontmatter` is left absent; the worker parses it if present in the
56
+ * body.
57
+ *
58
+ * @param {object} args
59
+ * @param {string} args.repoRoot
60
+ * @param {string} args.sourcePath absolute or repo-relative
61
+ * @param {string} args.content
62
+ * @param {object} args.producer { operator, agent, host?, session?, subkeyId }
63
+ * @param {string} [args.kind] overrides guess; must be in KNOWN_KINDS
64
+ * @param {string} [args.tier] default "auto" sentinel (spec §2.3)
65
+ * @param {'staging'|'flush'|'role-output'|'manual'|'synthesis'} [args.sourceKind]
66
+ * default "manual"; `library flush` passes "flush"
67
+ * @param {() => Date} [args.clock] test seam
68
+ * @returns {object} EventInput ready for POST /events
69
+ */
70
+ export function buildEnvelope({
71
+ repoRoot,
72
+ sourcePath,
73
+ content,
74
+ producer,
75
+ kind,
76
+ tier = 'auto',
77
+ sourceKind = 'manual',
78
+ clock = () => new Date(),
79
+ }) {
80
+ const relPath = sourcePath
81
+ ? isAbsolute(sourcePath) ? relative(repoRoot, sourcePath) : sourcePath
82
+ : undefined;
83
+
84
+ const resolvedKind = kind ?? guessKindFromPath(relPath ?? sourcePath ?? '') ?? null;
85
+ if (!resolvedKind) {
86
+ const err = new Error(
87
+ `cannot infer event kind from path ${JSON.stringify(sourcePath)}; pass --kind explicitly.`,
88
+ );
89
+ err.exitCode = 1;
90
+ throw err;
91
+ }
92
+ if (!KNOWN_KINDS.has(resolvedKind)) {
93
+ const err = new Error(
94
+ `unknown event kind ${JSON.stringify(resolvedKind)}; known: ${[...KNOWN_KINDS].join(', ')}`,
95
+ );
96
+ err.exitCode = 1;
97
+ throw err;
98
+ }
99
+
100
+ const now = clock();
101
+ const payload = {
102
+ path: relPath ?? null,
103
+ content,
104
+ };
105
+
106
+ const envelope = {
107
+ clientEventId: randomUUID(),
108
+ occurredAt: now.toISOString(),
109
+ producer: {
110
+ operator: producer.operator,
111
+ agent: producer.agent,
112
+ host: producer.host ?? hostname(),
113
+ subkeyId: producer.subkeyId,
114
+ ...(producer.session ? { session: producer.session } : {}),
115
+ },
116
+ kind: resolvedKind,
117
+ tier,
118
+ provenance: {
119
+ sourceKind,
120
+ ...(relPath ? { sourcePath: relPath } : {}),
121
+ },
122
+ payload,
123
+ contentSha: contentShaOf(payload),
124
+ };
125
+
126
+ return envelope;
127
+ }
128
+
129
+ /**
130
+ * Extract subkeyId from `library.remoteKey` (`<subkeyId>:<secret>`).
131
+ * @param {string} remoteKey
132
+ * @returns {string}
133
+ */
134
+ export function subkeyIdFromRemoteKey(remoteKey) {
135
+ const idx = remoteKey.indexOf(':');
136
+ if (idx <= 0) {
137
+ const err = new Error('library.remoteKey shape invalid; expected "<subkeyId>:<secret>"');
138
+ err.exitCode = 1;
139
+ throw err;
140
+ }
141
+ return remoteKey.slice(0, idx);
142
+ }
@@ -0,0 +1,156 @@
1
+ // http.mjs — thin fetch-based ingest client for the remote-library service.
2
+ //
3
+ // Chose to write a local client rather than extract one from the ingest
4
+ // package: the server surface at `Stravica/remote-library` currently
5
+ // exports `createIngestServer` but no client-side helper, and touching
6
+ // that repo is out of scope for item 14 (see the item-14 dispatch brief,
7
+ // §5 Boundaries). Follow-up: consider promoting a shared client into the
8
+ // package's public surface (`@stravica-ai/remote-library`) once the
9
+ // interface stabilises across `put`, `flush`, and any future producers.
10
+ //
11
+ // Auth (spec §3.4, main ruling 2026-08-20 option b):
12
+ // - `X-Subkey: <subkeyId>:<secret>` — mandatory, from library.remoteKey.
13
+ // - No `Authorization` header. Ingest authenticates by subkey alone;
14
+ // the install key is the server-side provisioning root only and
15
+ // plays no part in wire-side auth. Previous interim env var
16
+ // `REMOTE_LIBRARY_INSTALL_KEY` was removed by the same ruling.
17
+ //
18
+ // Retry policy (spec §7.7): three attempts, jittered backoff, total
19
+ // budget ~5s. On timeouts and 5xx only — 4xx failures are surfaced
20
+ // immediately so a bad envelope does not burn retry budget.
21
+
22
+ const DEFAULT_ATTEMPTS = 3;
23
+ const DEFAULT_BACKOFF_MS = 250;
24
+ const DEFAULT_TIMEOUT_MS = 4000;
25
+ const JSON_CONTENT_TYPE = 'application/json';
26
+
27
+ /**
28
+ * Build request headers for the ingest API. Subkey-only auth per
29
+ * main ruling 2026-08-20 option b.
30
+ *
31
+ * @param {string} remoteKey `<subkeyId>:<secret>`
32
+ * @returns {Record<string, string>}
33
+ */
34
+ export function buildIngestHeaders(remoteKey) {
35
+ return {
36
+ 'Content-Type': JSON_CONTENT_TYPE,
37
+ 'X-Subkey': remoteKey,
38
+ };
39
+ }
40
+
41
+ function sleep(ms) {
42
+ return new Promise((r) => setTimeout(r, ms));
43
+ }
44
+
45
+ /**
46
+ * POST /events with a single-event or batch body. Returns the parsed
47
+ * JSON response on any completed request (2xx or 4xx); the caller
48
+ * inspects the shape. Throws only when every retry attempt failed
49
+ * with a transport-class error (network unreachable, DNS, timeout).
50
+ *
51
+ * @param {object} opts
52
+ * @param {string} opts.endpoint Full base URL of the ingest API (e.g. https://ops-01.internal)
53
+ * @param {string} opts.remoteKey
54
+ * @param {object} opts.body JSON-serialisable body
55
+ * @param {number} [opts.attempts] default 3
56
+ * @param {number} [opts.backoffMs] default 250 (jittered)
57
+ * @param {number} [opts.timeoutMs] default 4000
58
+ * @param {typeof fetch} [opts.fetchImpl] test seam
59
+ * @returns {Promise<{httpStatus: number, body: unknown}>}
60
+ */
61
+ export async function postEvents({
62
+ endpoint,
63
+ remoteKey,
64
+ body,
65
+ attempts = DEFAULT_ATTEMPTS,
66
+ backoffMs = DEFAULT_BACKOFF_MS,
67
+ timeoutMs = DEFAULT_TIMEOUT_MS,
68
+ fetchImpl = globalThis.fetch,
69
+ }) {
70
+ const url = joinUrl(endpoint, '/events');
71
+ const headers = buildIngestHeaders(remoteKey);
72
+ const payload = JSON.stringify(body);
73
+
74
+ let lastErr;
75
+ for (let attempt = 1; attempt <= attempts; attempt += 1) {
76
+ const ac = new AbortController();
77
+ const timer = setTimeout(() => ac.abort(), timeoutMs);
78
+ try {
79
+ const res = await fetchImpl(url, { method: 'POST', headers, body: payload, signal: ac.signal });
80
+ clearTimeout(timer);
81
+ // Retry 5xx (server transient); return 2xx and 4xx to caller.
82
+ if (res.status >= 500 && attempt < attempts) {
83
+ await sleep(jitter(backoffMs * attempt));
84
+ continue;
85
+ }
86
+ let parsed = null;
87
+ const text = await res.text();
88
+ if (text.length > 0) {
89
+ try { parsed = JSON.parse(text); } catch { parsed = { rawBody: text }; }
90
+ }
91
+ return { httpStatus: res.status, body: parsed };
92
+ } catch (err) {
93
+ clearTimeout(timer);
94
+ lastErr = err;
95
+ if (attempt < attempts) {
96
+ await sleep(jitter(backoffMs * attempt));
97
+ continue;
98
+ }
99
+ }
100
+ }
101
+ const err = new Error(`ingest unreachable at ${url}: ${lastErr?.message ?? lastErr}`);
102
+ err.cause = lastErr;
103
+ err.exitCode = 3; // transport / IO failure (tooling exit-code contract)
104
+ throw err;
105
+ }
106
+
107
+ /**
108
+ * GET /status from the service. Returns `{httpStatus, body}` when the
109
+ * call completes (including 5xx). Throws with exitCode=3 on transport
110
+ * failure (no retries — status is a read; the caller decides).
111
+ *
112
+ * @param {object} opts
113
+ * @param {string} opts.endpoint
114
+ * @param {string} opts.remoteKey auth header (server may or may not require)
115
+ * @param {number} [opts.timeoutMs] default 2000
116
+ * @param {typeof fetch} [opts.fetchImpl]
117
+ * @returns {Promise<{httpStatus: number, body: unknown}>}
118
+ */
119
+ export async function getStatus({
120
+ endpoint,
121
+ remoteKey,
122
+ timeoutMs = 2000,
123
+ fetchImpl = globalThis.fetch,
124
+ }) {
125
+ const url = joinUrl(endpoint, '/status');
126
+ const headers = buildIngestHeaders(remoteKey);
127
+ delete headers['Content-Type']; // GET has no body
128
+ const ac = new AbortController();
129
+ const timer = setTimeout(() => ac.abort(), timeoutMs);
130
+ try {
131
+ const res = await fetchImpl(url, { method: 'GET', headers, signal: ac.signal });
132
+ clearTimeout(timer);
133
+ let parsed = null;
134
+ const text = await res.text();
135
+ if (text.length > 0) {
136
+ try { parsed = JSON.parse(text); } catch { parsed = { rawBody: text }; }
137
+ }
138
+ return { httpStatus: res.status, body: parsed };
139
+ } catch (err) {
140
+ clearTimeout(timer);
141
+ const e = new Error(`status endpoint unreachable at ${url}: ${err?.message ?? err}`);
142
+ e.cause = err;
143
+ e.exitCode = 3;
144
+ throw e;
145
+ }
146
+ }
147
+
148
+ function jitter(ms) {
149
+ return Math.round(ms * (0.5 + Math.random()));
150
+ }
151
+
152
+ function joinUrl(base, path) {
153
+ const b = base.replace(/\/+$/, '');
154
+ const p = path.startsWith('/') ? path : `/${path}`;
155
+ return `${b}${p}`;
156
+ }
@@ -0,0 +1,74 @@
1
+ // pull-clones.mjs — resolve the local pull-clone root for `library get`
2
+ // and `library search`.
3
+ //
4
+ // Spec §3.3 says "read from the local pulled clone (no service round-
5
+ // trip)" but does NOT pin where the clones live on an operator host.
6
+ // The interim contract is:
7
+ //
8
+ // 1. `LIBRARY_PULL_CLONES_ROOT` env var (highest precedence — deploy hook)
9
+ // 2. `library.pullClonesRoot` in preferences.json / preferences.local.json
10
+ // 3. `<HOME>/stravica-library-clones/` (default)
11
+ //
12
+ // Reads accept `<tier-slug>/<relative-path>` — the tier slug matches
13
+ // the git repo names in spec §2.1 (e.g. `stravica-library-hq`,
14
+ // `stravica-operator-barry`), OR a bare relative path if the caller
15
+ // already resolved the tier.
16
+ //
17
+ // FOLLOW-UP (surfaced in the seat return): `library.pullClonesRoot`
18
+ // stretches the "three settled fields" surface from the item-14 brief;
19
+ // main to ratify whether it stays as an operator preference or migrates
20
+ // to environment-only.
21
+
22
+ import { existsSync, statSync } from 'node:fs';
23
+ import { homedir } from 'node:os';
24
+ import { join, resolve } from 'node:path';
25
+
26
+ export const DEFAULT_PULL_CLONES_ROOT = join(homedir(), 'stravica-library-clones');
27
+
28
+ /**
29
+ * Resolve the pull-clones root directory in precedence order.
30
+ * @param {object} [opts]
31
+ * @param {object} [opts.preferences] merged preferences
32
+ * @param {Record<string,string>} [opts.env] defaults to process.env
33
+ * @returns {string}
34
+ */
35
+ export function resolvePullClonesRoot({ preferences, env = process.env } = {}) {
36
+ const fromEnv = env.LIBRARY_PULL_CLONES_ROOT;
37
+ if (typeof fromEnv === 'string' && fromEnv.trim() !== '') return resolve(fromEnv);
38
+ const fromPrefs = preferences?.library?.pullClonesRoot;
39
+ if (typeof fromPrefs === 'string' && fromPrefs.trim() !== '') return resolve(fromPrefs);
40
+ return DEFAULT_PULL_CLONES_ROOT;
41
+ }
42
+
43
+ /**
44
+ * Given a caller-supplied path, produce the resolved absolute path
45
+ * under the pull-clones root. Enforces containment (no `..` escape).
46
+ *
47
+ * @param {object} opts
48
+ * @param {string} opts.root
49
+ * @param {string} opts.relPath e.g. "stravica-library-hq/patterns/foo.md"
50
+ * @returns {string}
51
+ */
52
+ export function resolveClonePath({ root, relPath }) {
53
+ const absRoot = resolve(root);
54
+ const absPath = resolve(absRoot, relPath);
55
+ if (!absPath.startsWith(absRoot + '/') && absPath !== absRoot) {
56
+ const err = new Error(`path escapes pull-clones root: ${relPath}`);
57
+ err.exitCode = 1;
58
+ throw err;
59
+ }
60
+ return absPath;
61
+ }
62
+
63
+ /**
64
+ * True iff the pull-clones root exists on disk.
65
+ * @param {string} root
66
+ * @returns {boolean}
67
+ */
68
+ export function pullClonesRootExists(root) {
69
+ try {
70
+ return existsSync(root) && statSync(root).isDirectory();
71
+ } catch {
72
+ return false;
73
+ }
74
+ }