agentic-workflow-manager 9.17.0 → 9.18.0

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.
@@ -0,0 +1,140 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.scaffoldRegistry = scaffoldRegistry;
7
+ // cli/src/commands/registry/scaffold.ts
8
+ // Logic for `awm registry scaffold`, separated from commander wiring.
9
+ const fs_1 = __importDefault(require("fs"));
10
+ const path_1 = __importDefault(require("path"));
11
+ const types_1 = require("../../core/facts/types");
12
+ function writeJson(write, file, value) {
13
+ write(file, JSON.stringify(value, null, 2) + '\n');
14
+ }
15
+ function handbookFor(name) {
16
+ return `# ${name}
17
+
18
+ ## Capabilities
19
+ | id | status | notes |
20
+ |----|--------|-------|
21
+ | facts | active | see ## Capability: facts |
22
+
23
+ ## Capability: facts
24
+
25
+ Declare operator/machine facts in \`facts.json\`. Skills may list \`needs\` in
26
+ frontmatter; values live in the machine-local environment store
27
+ (\`AWM_HOME/environments/\`), never in this registry tree.
28
+
29
+ - Add fact definitions under \`facts\` in \`facts.json\` (\`schemaVersion: 1\`).
30
+ - Keep \`minCliVersion\` in \`awm-registry.json\` at or above the CLI version that
31
+ understands facts.
32
+ - Run \`awm registry doctor\` before publishing — structural failures block; advisories do not.
33
+ `;
34
+ }
35
+ function writeScaffoldTree(root, name, write) {
36
+ writeJson(write, path_1.default.join(root, 'awm-registry.json'), {
37
+ overrides: [],
38
+ minCliVersion: types_1.FACTS_MIN_CLI_VERSION,
39
+ });
40
+ writeJson(write, path_1.default.join(root, 'catalog.json'), {
41
+ version: 1,
42
+ bundles: [],
43
+ });
44
+ const skillsDir = path_1.default.join(root, 'skills');
45
+ fs_1.default.mkdirSync(skillsDir, { recursive: true });
46
+ // Track skills/ in git — an empty dir is dropped on clone and then
47
+ // validateRegistryLayout / awm registry add reject the tree (RF-4.1).
48
+ write(path_1.default.join(skillsDir, '.gitkeep'), '');
49
+ writeJson(write, path_1.default.join(root, 'facts.json'), {
50
+ schemaVersion: 1,
51
+ facts: {},
52
+ });
53
+ write(path_1.default.join(root, 'REGISTRY.md'), handbookFor(name));
54
+ }
55
+ /**
56
+ * Create a minimal registry tree that passes structural `doctorRegistry`.
57
+ * Atomic (RF-4.3): write into a temp sibling, then rename into place; on
58
+ * failure remove only the staging dir so an empty preexisting target stays
59
+ * empty (retryable) and a missing target stays absent.
60
+ */
61
+ function scaffoldRegistry(dir, opts = {}) {
62
+ if (typeof dir !== 'string' || dir.trim() === '') {
63
+ return { ok: false, error: 'scaffold target directory is required' };
64
+ }
65
+ const abs = path_1.default.resolve(dir);
66
+ const write = opts.writeFileSync ?? ((file, data) => fs_1.default.writeFileSync(file, data));
67
+ let targetExistsEmpty = false;
68
+ if (fs_1.default.existsSync(abs)) {
69
+ let stat;
70
+ try {
71
+ stat = fs_1.default.lstatSync(abs);
72
+ }
73
+ catch (e) {
74
+ return {
75
+ ok: false,
76
+ error: `Cannot inspect target ${abs}: ${e instanceof Error ? e.message : String(e)}`,
77
+ };
78
+ }
79
+ if (!stat.isDirectory() || stat.isSymbolicLink()) {
80
+ return { ok: false, error: `Target exists and is not a directory: ${abs}` };
81
+ }
82
+ if (fs_1.default.readdirSync(abs).length > 0) {
83
+ return {
84
+ ok: false,
85
+ error: `Target directory is not empty — refuse to overwrite: ${abs}`,
86
+ };
87
+ }
88
+ targetExistsEmpty = true;
89
+ }
90
+ const parent = path_1.default.dirname(abs);
91
+ let staging;
92
+ try {
93
+ fs_1.default.mkdirSync(parent, { recursive: true });
94
+ staging = fs_1.default.mkdtempSync(path_1.default.join(parent, '.awm-scaffold-'));
95
+ const name = path_1.default.basename(abs) || 'registry';
96
+ // Always stage first so mid-fail never leaves a partial target (RF-4.3).
97
+ writeScaffoldTree(staging, name, write);
98
+ if (targetExistsEmpty) {
99
+ // Never rmdir the empty shell before contents land. Write into abs
100
+ // directly from the validated staging pattern, then drop staging.
101
+ try {
102
+ writeScaffoldTree(abs, name, write);
103
+ }
104
+ catch (writeErr) {
105
+ // Best-effort: remove what we wrote into the preexisting empty
106
+ // dir without deleting the empty shell itself.
107
+ try {
108
+ for (const entry of fs_1.default.readdirSync(abs)) {
109
+ fs_1.default.rmSync(path_1.default.join(abs, entry), { recursive: true, force: true });
110
+ }
111
+ }
112
+ catch {
113
+ // best-effort
114
+ }
115
+ throw writeErr;
116
+ }
117
+ fs_1.default.rmSync(staging, { recursive: true, force: true });
118
+ staging = undefined;
119
+ return { ok: true, dir: abs };
120
+ }
121
+ // Target does not exist: atomic rename of staging into place.
122
+ fs_1.default.renameSync(staging, abs);
123
+ staging = undefined;
124
+ return { ok: true, dir: abs };
125
+ }
126
+ catch (e) {
127
+ if (staging) {
128
+ try {
129
+ fs_1.default.rmSync(staging, { recursive: true, force: true });
130
+ }
131
+ catch {
132
+ // best-effort cleanup; surface the original write error
133
+ }
134
+ }
135
+ return {
136
+ ok: false,
137
+ error: `Scaffold failed: ${e instanceof Error ? e.message : String(e)}`,
138
+ };
139
+ }
140
+ }
@@ -160,6 +160,9 @@ function registerWatchCommand(program) {
160
160
  }
161
161
  else {
162
162
  process.stdout.write(`awm watch: ${outcome === 'stopped' ? 'detenido' : 'track congelado'}; ciclo ${final.cycleState ?? final.state} — sin certificacion de cierre\n`);
163
+ // exitCode only — never process.exit here: in-process tests call parseAsync
164
+ // inside Jest; a hard exit kills the worker (CI exit 130 on every OS).
165
+ // exitCode=130 is intentional Darwin CI flake harden (reopened-196), not operator-environments scope.
163
166
  if (outcome === 'stopped')
164
167
  process.exitCode = 130;
165
168
  else if (outcome === 'complete' || final.state !== 'present')
@@ -0,0 +1,122 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.CONTEXT_INJECT_MAX_CHARS = void 0;
4
+ exports.statusFor = statusFor;
5
+ exports.buildInjection = buildInjection;
6
+ /** Fixed budget for `inject: context` fresh values (RF-3.2). */
7
+ exports.CONTEXT_INJECT_MAX_CHARS = 2048;
8
+ function assertValidNow(now) {
9
+ if (!(now instanceof Date) || !Number.isFinite(now.getTime())) {
10
+ throw new Error('now must be a Date with a finite getTime()');
11
+ }
12
+ }
13
+ function effectiveStatus(definitionDurability, stored, now) {
14
+ if (!stored)
15
+ return 'missing';
16
+ if (stored.status === 'missing')
17
+ return 'missing';
18
+ if (stored.status === 'stale')
19
+ return 'stale';
20
+ // Durable never time-stales (RF-2.3).
21
+ if (definitionDurability === 'durable') {
22
+ return stored.status === 'fresh' ? 'fresh' : stored.status;
23
+ }
24
+ // Volatile: fail closed — missing/invalid expiresAt or now >= expiresAt → stale (RF-2.4).
25
+ if (typeof stored.expiresAt !== 'string' || stored.expiresAt.trim() === '') {
26
+ return 'stale';
27
+ }
28
+ const expiresMs = Date.parse(stored.expiresAt);
29
+ if (!Number.isFinite(expiresMs) || now.getTime() >= expiresMs) {
30
+ return 'stale';
31
+ }
32
+ return stored.status === 'fresh' ? 'fresh' : stored.status;
33
+ }
34
+ /**
35
+ * Per-catalog-id status against the store. Missing → `missing` with no default (RF-2.5).
36
+ */
37
+ function statusFor(catalog, store, now = new Date()) {
38
+ if (!catalog || typeof catalog !== 'object' || !catalog.facts) {
39
+ throw new Error('statusFor: catalog with facts is required');
40
+ }
41
+ if (!store || typeof store !== 'object') {
42
+ throw new Error('statusFor: store must be an object');
43
+ }
44
+ assertValidNow(now);
45
+ const out = {};
46
+ for (const [id, def] of Object.entries(catalog.facts)) {
47
+ out[id] = effectiveStatus(def.durability, store[id], now);
48
+ }
49
+ return out;
50
+ }
51
+ /**
52
+ * Build injection payload. Default / explicit `reference` exposes path + ids only (RF-3.1).
53
+ * `context` includes fresh `inject: context` values under budget; overflow degrades (RF-3.2).
54
+ */
55
+ function buildInjection(opts) {
56
+ if (!opts || typeof opts !== 'object') {
57
+ throw new Error('buildInjection: options object is required');
58
+ }
59
+ if (!opts.catalog?.facts) {
60
+ throw new Error('buildInjection: catalog with facts is required');
61
+ }
62
+ if (typeof opts.storePath !== 'string' || opts.storePath.trim() === '') {
63
+ throw new Error('buildInjection: storePath must be a non-empty string');
64
+ }
65
+ if (!opts.store || typeof opts.store !== 'object') {
66
+ throw new Error('buildInjection: store must be an object');
67
+ }
68
+ const rawInject = opts.inject ?? 'reference';
69
+ // Unknown/typo must not fall through into context (fail-open). Prefer reference (RNF-2).
70
+ const inject = rawInject === 'reference' || rawInject === 'context' ? rawInject : 'reference';
71
+ const maxChars = opts.maxChars ?? exports.CONTEXT_INJECT_MAX_CHARS;
72
+ if (typeof maxChars !== 'number' || !Number.isFinite(maxChars) || maxChars < 0) {
73
+ throw new Error('buildInjection: maxChars must be a non-negative finite number');
74
+ }
75
+ const now = opts.now ?? new Date();
76
+ assertValidNow(now);
77
+ if (opts.factIds !== undefined) {
78
+ if (!Array.isArray(opts.factIds) || opts.factIds.some((id) => typeof id !== 'string' || id === '')) {
79
+ throw new Error('buildInjection: factIds must be an array of non-empty strings');
80
+ }
81
+ }
82
+ const factIds = opts.factIds ?? Object.keys(opts.catalog.facts);
83
+ const statuses = statusFor(opts.catalog, opts.store, now);
84
+ const referenceResult = () => ({
85
+ mode: 'reference',
86
+ storePath: opts.storePath,
87
+ factIds: [...factIds],
88
+ });
89
+ if (inject !== 'context') {
90
+ return referenceResult();
91
+ }
92
+ // Context mode: only fresh values whose catalog inject is `context` (RF-3.2).
93
+ const contextValues = {};
94
+ let totalChars = 0;
95
+ for (const id of factIds) {
96
+ const def = opts.catalog.facts[id];
97
+ if (!def || def.inject !== 'context')
98
+ continue;
99
+ if (statuses[id] !== 'fresh')
100
+ continue;
101
+ const stored = opts.store[id];
102
+ if (!stored || typeof stored.value !== 'string')
103
+ continue;
104
+ totalChars += stored.value.length;
105
+ contextValues[id] = stored.value;
106
+ }
107
+ if (Object.keys(contextValues).length === 0) {
108
+ return referenceResult();
109
+ }
110
+ if (totalChars > maxChars) {
111
+ return {
112
+ ...referenceResult(),
113
+ warning: `Context inject budget exceeded (${totalChars} > ${maxChars} chars); degraded to reference`,
114
+ };
115
+ }
116
+ return {
117
+ mode: 'context',
118
+ storePath: opts.storePath,
119
+ factIds: [...factIds],
120
+ values: contextValues,
121
+ };
122
+ }
@@ -0,0 +1,323 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.EnvironmentStoreError = void 0;
7
+ exports.environmentsRoot = environmentsRoot;
8
+ exports.envDir = envDir;
9
+ exports.valuesPath = valuesPath;
10
+ exports.getActiveEnvironmentName = getActiveEnvironmentName;
11
+ exports.setActiveEnvironmentName = setActiveEnvironmentName;
12
+ exports.readValues = readValues;
13
+ exports.isInsideRegistryTree = isInsideRegistryTree;
14
+ exports.assertNotInsideRegistryTree = assertNotInsideRegistryTree;
15
+ exports.writeEnvValue = writeEnvValue;
16
+ exports.writeFact = writeFact;
17
+ const fs_1 = __importDefault(require("fs"));
18
+ const path_1 = __importDefault(require("path"));
19
+ const atomic_file_1 = require("../atomic-file");
20
+ const paths_1 = require("../paths");
21
+ const registries_1 = require("../registries");
22
+ class EnvironmentStoreError extends Error {
23
+ constructor(message) {
24
+ super(message);
25
+ this.name = 'EnvironmentStoreError';
26
+ }
27
+ }
28
+ exports.EnvironmentStoreError = EnvironmentStoreError;
29
+ const DEFAULT_ENV_NAME = 'default';
30
+ const META_FILE = 'meta.json';
31
+ const VALUES_FILE = 'values.json';
32
+ function isRecord(value) {
33
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
34
+ }
35
+ /** Root of machine-local environment stores: `AWM_HOME/environments`. */
36
+ function environmentsRoot() {
37
+ return path_1.default.join((0, paths_1.awmHome)(), 'environments');
38
+ }
39
+ function envDir(name) {
40
+ assertEnvName(name);
41
+ return path_1.default.join(environmentsRoot(), name);
42
+ }
43
+ function valuesPath(name) {
44
+ return path_1.default.join(envDir(name), VALUES_FILE);
45
+ }
46
+ function metaPath() {
47
+ return path_1.default.join(environmentsRoot(), META_FILE);
48
+ }
49
+ function assertEnvName(name) {
50
+ if (typeof name !== 'string' || name.trim() === '') {
51
+ throw new EnvironmentStoreError('environment name must be a non-empty string');
52
+ }
53
+ if (name.includes('\0')) {
54
+ throw new EnvironmentStoreError(`Invalid environment name ${JSON.stringify(name)}: NUL byte`);
55
+ }
56
+ const lower = name.toLowerCase();
57
+ if (lower === META_FILE.toLowerCase() || lower === VALUES_FILE.toLowerCase()) {
58
+ throw new EnvironmentStoreError(`Invalid environment name ${JSON.stringify(name)}: reserved store filename`);
59
+ }
60
+ if (name.includes('/') || name.includes('\\') || name.includes('..') || name === '.' || name === '..') {
61
+ throw new EnvironmentStoreError(`Invalid environment name ${JSON.stringify(name)}`);
62
+ }
63
+ }
64
+ function readMeta() {
65
+ const file = metaPath();
66
+ if (!fs_1.default.existsSync(file))
67
+ return null;
68
+ let raw;
69
+ try {
70
+ raw = JSON.parse(fs_1.default.readFileSync(file, 'utf-8'));
71
+ }
72
+ catch (e) {
73
+ throw new EnvironmentStoreError(`Invalid environments meta at ${file}: ${e instanceof Error ? e.message : String(e)}`);
74
+ }
75
+ if (!isRecord(raw) || typeof raw.active !== 'string' || raw.active.trim() === '') {
76
+ throw new EnvironmentStoreError(`Invalid environments meta at ${file}: expected { active: string }`);
77
+ }
78
+ return { active: raw.active };
79
+ }
80
+ function writeMeta(meta) {
81
+ const file = metaPath();
82
+ assertNotInsideRegistryTree(file);
83
+ (0, atomic_file_1.writeFileAtomic)(file, JSON.stringify(meta, null, 2) + '\n');
84
+ }
85
+ /** Exactly one active environment per machine; default name `default` (RF-2.2). */
86
+ function getActiveEnvironmentName() {
87
+ const meta = readMeta();
88
+ if (!meta)
89
+ return DEFAULT_ENV_NAME;
90
+ assertEnvName(meta.active);
91
+ return meta.active;
92
+ }
93
+ function setActiveEnvironmentName(name) {
94
+ assertEnvName(name);
95
+ writeMeta({ active: name });
96
+ }
97
+ function parseStoredEntry(id, raw) {
98
+ if (!isRecord(raw)) {
99
+ throw new EnvironmentStoreError(`values.${id} must be an object`);
100
+ }
101
+ if (typeof raw.value !== 'string') {
102
+ throw new EnvironmentStoreError(`values.${id}.value must be a string`);
103
+ }
104
+ if (raw.status !== 'fresh' && raw.status !== 'stale' && raw.status !== 'missing') {
105
+ throw new EnvironmentStoreError(`values.${id}.status must be fresh|stale|missing`);
106
+ }
107
+ if (typeof raw.updatedAt !== 'string') {
108
+ throw new EnvironmentStoreError(`values.${id}.updatedAt must be a string`);
109
+ }
110
+ const entry = {
111
+ value: raw.value,
112
+ status: raw.status,
113
+ updatedAt: raw.updatedAt,
114
+ };
115
+ if (raw.expiresAt !== undefined) {
116
+ if (typeof raw.expiresAt !== 'string') {
117
+ throw new EnvironmentStoreError(`values.${id}.expiresAt must be a string`);
118
+ }
119
+ entry.expiresAt = raw.expiresAt;
120
+ }
121
+ return entry;
122
+ }
123
+ function readValues(name) {
124
+ const file = valuesPath(name);
125
+ if (!fs_1.default.existsSync(file))
126
+ return {};
127
+ let raw;
128
+ try {
129
+ raw = JSON.parse(fs_1.default.readFileSync(file, 'utf-8'));
130
+ }
131
+ catch (e) {
132
+ throw new EnvironmentStoreError(`Invalid values.json at ${file}: ${e instanceof Error ? e.message : String(e)}`);
133
+ }
134
+ if (!isRecord(raw)) {
135
+ throw new EnvironmentStoreError(`values.json at ${file} must be a JSON object`);
136
+ }
137
+ const out = Object.create(null);
138
+ for (const [id, entry] of Object.entries(raw)) {
139
+ if (id === '__proto__' || id === 'constructor' || id === 'prototype') {
140
+ throw new EnvironmentStoreError(`values.json at ${file}: forbidden fact id ${JSON.stringify(id)}`);
141
+ }
142
+ out[id] = parseStoredEntry(id, entry);
143
+ }
144
+ return out;
145
+ }
146
+ /**
147
+ * Resolve a path through any intermediate symlinks (including when the leaf does
148
+ * not exist yet). Walks up to the nearest existing ancestor, realpaths it, then
149
+ * re-appends missing segments — same spirit as sensors/ledger realpath containment.
150
+ */
151
+ function resolvePathForContainment(targetPath) {
152
+ const absolute = path_1.default.resolve(targetPath);
153
+ const missing = [];
154
+ let cursor = absolute;
155
+ while (!fs_1.default.existsSync(cursor)) {
156
+ const parent = path_1.default.dirname(cursor);
157
+ if (parent === cursor)
158
+ break;
159
+ missing.unshift(path_1.default.basename(cursor));
160
+ cursor = parent;
161
+ }
162
+ let realBase;
163
+ try {
164
+ realBase = fs_1.default.realpathSync.native(cursor);
165
+ }
166
+ catch {
167
+ realBase = cursor;
168
+ }
169
+ return missing.length === 0 ? realBase : path_1.default.join(realBase, ...missing);
170
+ }
171
+ function isPathInsideRoot(candidate, root) {
172
+ if ((0, paths_1.sameExistingPath)(candidate, root))
173
+ return true;
174
+ const rel = path_1.default.relative(root, candidate);
175
+ if (rel === '')
176
+ return true;
177
+ if (rel === '..')
178
+ return false;
179
+ if (!rel.startsWith(`..${path_1.default.sep}`) && !rel.startsWith('..') && !path_1.default.isAbsolute(rel))
180
+ return true;
181
+ const prefix = root.endsWith(path_1.default.sep) ? root : root + path_1.default.sep;
182
+ return candidate === root || candidate.startsWith(prefix);
183
+ }
184
+ /**
185
+ * True when `targetPath` (after resolving symlinks) is the same as or nested under
186
+ * any registry content root. Catches AWM_HOME/environments → registry symlink (RNF-1).
187
+ *
188
+ * Always unions `contentRoots()` with any explicit `registryRoots`. An empty
189
+ * explicit array no longer disables checks — callers that need extra roots pass
190
+ * them; they cannot opt out of installed content roots.
191
+ */
192
+ function isInsideRegistryTree(targetPath, registryRoots) {
193
+ if (typeof targetPath !== 'string' || targetPath.trim() === '') {
194
+ throw new EnvironmentStoreError('target path must be a non-empty string');
195
+ }
196
+ const lexicalTarget = path_1.default.resolve(targetPath);
197
+ const realTarget = resolvePathForContainment(targetPath);
198
+ const roots = [...(0, registries_1.contentRoots)(), ...(registryRoots ?? [])];
199
+ for (const root of roots) {
200
+ if (typeof root !== 'string' || root.trim() === '')
201
+ continue;
202
+ const lexicalRoot = path_1.default.resolve(root);
203
+ const realRoot = resolvePathForContainment(root);
204
+ if (isPathInsideRoot(realTarget, realRoot))
205
+ return true;
206
+ // Also check lexical forms so non-existent trees still refuse obvious nesting.
207
+ if (isPathInsideRoot(lexicalTarget, lexicalRoot))
208
+ return true;
209
+ }
210
+ return false;
211
+ }
212
+ function assertNotInsideRegistryTree(targetPath, registryRoots) {
213
+ if (isInsideRegistryTree(targetPath, registryRoots)) {
214
+ throw new EnvironmentStoreError(`Refusing to write environment store inside a registry tree: ${targetPath} (RNF-1)`);
215
+ }
216
+ }
217
+ /**
218
+ * Atomic write of a values.json document. Refuses targets under registry roots (RNF-1).
219
+ */
220
+ function writeEnvValue(targetPath, values, opts = {}) {
221
+ if (typeof targetPath !== 'string' || targetPath.trim() === '') {
222
+ throw new EnvironmentStoreError('target path must be a non-empty string');
223
+ }
224
+ if (!isRecord(values)) {
225
+ throw new EnvironmentStoreError('values must be an object');
226
+ }
227
+ for (const [id, entry] of Object.entries(values)) {
228
+ parseStoredEntry(id, entry);
229
+ }
230
+ // RNF-1: refuse registry-tree targets. Callers that need environmentsRoot placement
231
+ // (writeFact) pass valuesPath(name); this helper does not re-check environmentsRoot.
232
+ assertNotInsideRegistryTree(targetPath, opts.registryRoots);
233
+ (0, atomic_file_1.writeFileAtomic)(targetPath, JSON.stringify(values, null, 2) + '\n');
234
+ }
235
+ function validateValue(definition, value) {
236
+ if (definition.valueType === 'path') {
237
+ if (value.trim() === '') {
238
+ throw new EnvironmentStoreError('path value must be a non-empty string');
239
+ }
240
+ }
241
+ if (definition.valueType === 'enum') {
242
+ const allowed = definition.values ?? [];
243
+ if (!allowed.includes(value)) {
244
+ throw new EnvironmentStoreError(`enum value ${JSON.stringify(value)} is not one of: ${allowed.join(', ')}`);
245
+ }
246
+ }
247
+ }
248
+ function withExpiresAt(entry, definition) {
249
+ if (!definition || definition.durability !== 'volatile') {
250
+ return {
251
+ value: entry.value,
252
+ status: entry.status,
253
+ updatedAt: entry.updatedAt,
254
+ };
255
+ }
256
+ if (typeof definition.ttl !== 'number' || !Number.isFinite(definition.ttl) || definition.ttl <= 0) {
257
+ throw new EnvironmentStoreError('volatile fact definition requires a finite ttl > 0');
258
+ }
259
+ const ttlSeconds = definition.ttl;
260
+ const updatedMs = Date.parse(entry.updatedAt);
261
+ if (!Number.isFinite(updatedMs)) {
262
+ throw new EnvironmentStoreError(`updatedAt must be a valid ISO timestamp, got ${JSON.stringify(entry.updatedAt)}`);
263
+ }
264
+ let expiresAt;
265
+ try {
266
+ expiresAt = new Date(updatedMs + ttlSeconds * 1000).toISOString();
267
+ }
268
+ catch (e) {
269
+ if (e instanceof RangeError) {
270
+ throw new EnvironmentStoreError(`volatile expiresAt out of range for updatedAt=${JSON.stringify(entry.updatedAt)} ttl=${ttlSeconds}`);
271
+ }
272
+ throw e;
273
+ }
274
+ return { ...entry, expiresAt };
275
+ }
276
+ /**
277
+ * Write one fact into `environments/<name>/values.json` (RF-2.1). Atomic rename.
278
+ * When `catalog` is provided, unknown ids error and list `consultedRoots`.
279
+ */
280
+ function writeFact(name, id, entry, opts = {}) {
281
+ assertEnvName(name);
282
+ if (typeof id !== 'string' || id.trim() === '') {
283
+ throw new EnvironmentStoreError('fact id must be a non-empty string');
284
+ }
285
+ if (!isRecord(entry) || typeof entry.value !== 'string') {
286
+ throw new EnvironmentStoreError('fact entry.value must be a string');
287
+ }
288
+ if (entry.status !== 'fresh' && entry.status !== 'stale' && entry.status !== 'missing') {
289
+ throw new EnvironmentStoreError('fact entry.status must be fresh|stale|missing');
290
+ }
291
+ if (typeof entry.updatedAt !== 'string') {
292
+ throw new EnvironmentStoreError('fact entry.updatedAt must be a string');
293
+ }
294
+ let definition = opts.definition;
295
+ if (opts.catalog) {
296
+ // Own keys only — prototype members (toString, …) are not catalog facts.
297
+ if (!Object.hasOwn(opts.catalog.facts, id)) {
298
+ const roots = opts.consultedRoots?.length
299
+ ? opts.consultedRoots.join(', ')
300
+ : '(no catalog roots)';
301
+ throw new EnvironmentStoreError(`Unknown fact id ${JSON.stringify(id)}; consulted: ${roots}`);
302
+ }
303
+ definition = definition ?? opts.catalog.facts[id];
304
+ }
305
+ if (definition) {
306
+ validateValue(definition, entry.value);
307
+ }
308
+ const nextEntry = withExpiresAt({
309
+ value: entry.value,
310
+ status: entry.status,
311
+ updatedAt: entry.updatedAt,
312
+ ...(entry.expiresAt !== undefined ? { expiresAt: entry.expiresAt } : {}),
313
+ }, definition);
314
+ const file = valuesPath(name);
315
+ assertNotInsideRegistryTree(file, opts.registryRoots);
316
+ const current = readValues(name);
317
+ const next = { ...current, [id]: nextEntry };
318
+ writeEnvValue(file, next, { registryRoots: opts.registryRoots });
319
+ // First write materializes the single active env as the env being written (RF-2.2).
320
+ if (!readMeta()) {
321
+ writeMeta({ active: name });
322
+ }
323
+ }