@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.
- package/bin/library +12 -0
- package/bin/library-tunnel +12 -0
- 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/scripts/migrate-doc-paths.mjs +0 -0
- package/dist/engineering-pa-cli/src/knowledge/context.mjs +2 -2
- package/dist/engineering-pa-cli/src/pa/session.mjs +8 -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/library-config.mjs +154 -2
- package/dist/engineering-pa-library-cli/README.md +64 -0
- package/dist/engineering-pa-library-cli/bin/library +12 -0
- package/dist/engineering-pa-library-cli/package.json +21 -0
- package/dist/engineering-pa-library-cli/src/ack.mjs +64 -0
- package/dist/engineering-pa-library-cli/src/cli.mjs +142 -0
- package/dist/engineering-pa-library-cli/src/config.mjs +239 -0
- package/dist/engineering-pa-library-cli/src/envelope.mjs +142 -0
- package/dist/engineering-pa-library-cli/src/http.mjs +156 -0
- package/dist/engineering-pa-library-cli/src/pull-clones.mjs +74 -0
- package/dist/engineering-pa-library-cli/src/verbs/flush.mjs +136 -0
- package/dist/engineering-pa-library-cli/src/verbs/get.mjs +45 -0
- package/dist/engineering-pa-library-cli/src/verbs/put.mjs +69 -0
- package/dist/engineering-pa-library-cli/src/verbs/search.mjs +98 -0
- package/dist/engineering-pa-library-cli/src/verbs/status.mjs +124 -0
- package/dist/engineering-pa-library-tunnel/README.md +84 -0
- package/dist/engineering-pa-library-tunnel/bin/library-tunnel +12 -0
- package/dist/engineering-pa-library-tunnel/package.json +18 -0
- package/dist/engineering-pa-library-tunnel/src/check-verb.mjs +50 -0
- package/dist/engineering-pa-library-tunnel/src/check.mjs +102 -0
- package/dist/engineering-pa-library-tunnel/src/cli.mjs +155 -0
- package/dist/engineering-pa-library-tunnel/src/config.mjs +117 -0
- package/dist/engineering-pa-library-tunnel/src/install.mjs +106 -0
- package/dist/engineering-pa-library-tunnel/src/plist.mjs +119 -0
- package/dist/engineering-pa-library-tunnel/src/status.mjs +93 -0
- package/dist/engineering-pa-library-tunnel/src/uninstall.mjs +48 -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/ws.mjs +0 -0
- package/index.mjs +4 -0
- 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
|
+
}
|