@ijfw/memory-server 1.4.1 → 1.4.4

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,128 @@
1
+ /**
2
+ * dispatch/wave-cli.js — IJFW v1.4.4 / N9 wave-status CLI handlers.
3
+ *
4
+ * Frozen export contract (v1.4.3 dispatch module convention):
5
+ * export const handlers = { '<subcommand>': async (args, ctx) => ({ ok, output?, error? }) };
6
+ * export const subcommandHelp = { '<subcommand>': 'one-line description' };
7
+ *
8
+ * Subcommands owned by this module:
9
+ * - wave-status [<id>|latest]
10
+ * - wave-list
11
+ *
12
+ * Reads via mcp-server/src/orchestrator/wave-state.js (W10-A0). Read-only,
13
+ * snapshot-based per lock-in #31 — no daemon, no subscriptions.
14
+ */
15
+
16
+ import { readdir, stat } from 'node:fs/promises';
17
+ import { join } from 'node:path';
18
+
19
+ import { readWaveState } from '../orchestrator/wave-state.js';
20
+
21
+ const WAVE_DIR_PREFIX = 'wave-';
22
+
23
+ function tokenize(args) {
24
+ if (Array.isArray(args)) return args.filter((x) => x !== undefined && x !== null);
25
+ if (typeof args !== 'string') return [];
26
+ return args.split(/\s+/).filter(Boolean);
27
+ }
28
+
29
+ async function listWaveEntries(projectRoot) {
30
+ const ijfwDir = join(projectRoot, '.ijfw');
31
+ let entries = [];
32
+ try {
33
+ entries = await readdir(ijfwDir, { withFileTypes: true });
34
+ } catch (err) {
35
+ if (err.code === 'ENOENT') return [];
36
+ throw err;
37
+ }
38
+ const waves = [];
39
+ for (const ent of entries) {
40
+ if (!ent.isDirectory() || !ent.name.startsWith(WAVE_DIR_PREFIX)) continue;
41
+ const id = ent.name.slice(WAVE_DIR_PREFIX.length);
42
+ if (!id) continue;
43
+ const dir = join(ijfwDir, ent.name);
44
+ let mtimeMs = 0;
45
+ try {
46
+ const s = await stat(dir);
47
+ mtimeMs = s.mtimeMs;
48
+ } catch {
49
+ // tolerate vanished dirs
50
+ }
51
+ waves.push({ id, dir, mtimeMs });
52
+ }
53
+ return waves;
54
+ }
55
+
56
+ async function resolveLatestWaveId(projectRoot) {
57
+ const waves = await listWaveEntries(projectRoot);
58
+ if (waves.length === 0) return null;
59
+ waves.sort((a, b) => b.mtimeMs - a.mtimeMs);
60
+ return waves[0].id;
61
+ }
62
+
63
+ function renderStateForTerminal({ waveId, frontmatter, body }) {
64
+ const lines = [];
65
+ lines.push(`Wave: ${waveId}`);
66
+ for (const [key, val] of Object.entries(frontmatter || {})) {
67
+ if (key === 'wave_id') continue;
68
+ if (Array.isArray(val)) {
69
+ lines.push(`${key}: [${val.join(', ')}]`);
70
+ } else {
71
+ lines.push(`${key}: ${val}`);
72
+ }
73
+ }
74
+ if (body && body.trim()) {
75
+ lines.push('');
76
+ lines.push('--- notes ---');
77
+ lines.push(body.trim());
78
+ }
79
+ return lines.join('\n');
80
+ }
81
+
82
+ export const handlers = {
83
+ 'wave-status': async (args, ctx) => {
84
+ const tokens = tokenize(args);
85
+ const projectRoot = (ctx && ctx.projectRoot) || process.cwd();
86
+ let waveId = tokens[0];
87
+ if (!waveId || waveId === 'latest') {
88
+ waveId = await resolveLatestWaveId(projectRoot);
89
+ if (!waveId) {
90
+ return { ok: false, output: 'No waves found in .ijfw/wave-*/' };
91
+ }
92
+ }
93
+ const state = await readWaveState(waveId, projectRoot);
94
+ if (!state) {
95
+ return { ok: false, output: `Wave ${waveId} not found` };
96
+ }
97
+ return {
98
+ ok: true,
99
+ output: renderStateForTerminal({
100
+ waveId,
101
+ frontmatter: state.frontmatter,
102
+ body: state.body,
103
+ }),
104
+ };
105
+ },
106
+
107
+ 'wave-list': async (_args, ctx) => {
108
+ const projectRoot = (ctx && ctx.projectRoot) || process.cwd();
109
+ const waves = await listWaveEntries(projectRoot);
110
+ if (waves.length === 0) {
111
+ return { ok: true, output: '(no waves)' };
112
+ }
113
+ waves.sort((a, b) => b.mtimeMs - a.mtimeMs);
114
+ const rows = [];
115
+ for (const { id } of waves) {
116
+ const state = await readWaveState(id, projectRoot);
117
+ const status = state?.frontmatter?.status ?? '?';
118
+ const createdAt = state?.frontmatter?.created_at ?? '';
119
+ rows.push(`${id}\t${status}\t${createdAt}`);
120
+ }
121
+ return { ok: true, output: rows.join('\n') };
122
+ },
123
+ };
124
+
125
+ export const subcommandHelp = {
126
+ 'wave-status': 'wave-status [<id>|latest] — print live state of a wave',
127
+ 'wave-list': 'wave-list — list all known waves (newest first)',
128
+ };
@@ -297,5 +297,30 @@ export function validateExtensionManifest(obj) {
297
297
  }
298
298
  }
299
299
 
300
+ // === B15: publisher_key_backend ===
301
+ if (obj.publisher_key_backend !== undefined) {
302
+ if (obj.publisher_key_backend !== 'software' && obj.publisher_key_backend !== 'ssh-agent') {
303
+ errors.push("publisher_key_backend: must be 'software' or 'ssh-agent'");
304
+ }
305
+ }
306
+
307
+ // === B16: quotas ===
308
+ if (obj.quotas !== undefined) {
309
+ if (obj.quotas === null || typeof obj.quotas !== 'object' || Array.isArray(obj.quotas)) {
310
+ errors.push('quotas: must be an object');
311
+ } else {
312
+ const ALLOWED_DIMS = ['max_files_written', 'max_bytes_written', 'max_wall_clock_ms'];
313
+ for (const [k, v] of Object.entries(obj.quotas)) {
314
+ if (!ALLOWED_DIMS.includes(k)) {
315
+ // forward-compat: unknown quota dimensions ignored with warning (no error push)
316
+ continue;
317
+ }
318
+ if (!Number.isInteger(v) || v <= 0) {
319
+ errors.push(`quotas.${k}: must be a positive integer`);
320
+ }
321
+ }
322
+ }
323
+ }
324
+
300
325
  return { valid: errors.length === 0, errors };
301
326
  }
@@ -14,6 +14,9 @@
14
14
  import { readFile, appendFile, mkdir } from 'node:fs/promises';
15
15
  import { homedir } from 'node:os';
16
16
  import { join } from 'node:path';
17
+ // B16/SEC-H-01 — quota enforcement on tier-2 hook side. Mirrors the tier-1
18
+ // gate in server.js so both paths converge on the same counters.
19
+ import { checkAndIncrement as quotaCheckAndIncrement } from './extension-quota-tracker.js';
17
20
 
18
21
  async function emitEvent(home, extensionName, toolName, allowed, reason) {
19
22
  try {
@@ -44,6 +47,21 @@ try {
44
47
  process.exit(1);
45
48
  }
46
49
 
50
+ // B18 — surface cross-IDE divergence as a non-blocking stderr warning.
51
+ // Mirrors runtime-mediator.maybeWarnDivergence on the tier-2 hook side.
52
+ try {
53
+ const { detectCrossIdeDivergence } = await import('./active-extension-writer.js');
54
+ const verdict = await detectCrossIdeDivergence({ homeDir: home });
55
+ if (verdict && verdict.divergent) {
56
+ const age = typeof verdict.age_seconds === 'number' ? `${verdict.age_seconds}s ago` : 'unknown time ago';
57
+ process.stderr.write(
58
+ `[ijfw] active extension last activated by '${verdict.last_writer}' ${age}; this IDE is '${verdict.current_ide}'\n`,
59
+ );
60
+ }
61
+ } catch {
62
+ // Best-effort: divergence detection never blocks the hook.
63
+ }
64
+
47
65
  const chunks = [];
48
66
  for await (const c of process.stdin) chunks.push(c);
49
67
  const payload_str = chunks.join('');
@@ -75,5 +93,48 @@ if (readTools.has(tool) && !has(reads, `tool:${tool.toLowerCase()}`) && !has(rea
75
93
  await emitEvent(home, active.name, tool, false, reason);
76
94
  process.exit(1);
77
95
  }
96
+
97
+ // B16: quota enforcement on tier-2 hook side. Permission has passed; if the
98
+ // active extension declared quotas, check the relevant dimension.
99
+ const quotas = (active && typeof active.quotas === 'object' && active.quotas) ? active.quotas : null;
100
+ if (quotas && writeTools.has(tool)) {
101
+ const lc = tool.toLowerCase();
102
+ // files_written dimension for Edit/Write/NotebookEdit/Bash.
103
+ if (typeof quotas.max_files_written === 'number' && quotas.max_files_written > 0) {
104
+ const filePath = (req.tool_input && (req.tool_input.file_path || req.tool_input.path || req.tool_input.notebook_path)) || null;
105
+ const r = await quotaCheckAndIncrement(active.name, 'files_written', 1, quotas.max_files_written, { homeDir: home, path: typeof filePath === 'string' ? filePath : null });
106
+ if (!r.allowed) {
107
+ process.stderr.write(`[ijfw] extension '${active.name}' exceeded quota files_written (${r.current + 1}/${r.limit})\n`);
108
+ await emitEvent(home, active.name, tool, false, `quota:files_written ${r.current + 1}/${r.limit}`);
109
+ process.exit(1);
110
+ }
111
+ }
112
+ if (typeof quotas.max_bytes_written === 'number' && quotas.max_bytes_written > 0) {
113
+ let bytes = 0;
114
+ try {
115
+ const ti = req.tool_input || {};
116
+ if (typeof ti.content === 'string') bytes += ti.content.length;
117
+ if (typeof ti.new_string === 'string') bytes += ti.new_string.length;
118
+ if (typeof ti.command === 'string' && lc === 'bash') bytes += ti.command.length;
119
+ } catch { /* defensive */ }
120
+ if (bytes > 0) {
121
+ const r = await quotaCheckAndIncrement(active.name, 'bytes_written', bytes, quotas.max_bytes_written, { homeDir: home });
122
+ if (!r.allowed) {
123
+ process.stderr.write(`[ijfw] extension '${active.name}' exceeded quota bytes_written (${r.current + bytes}/${r.limit})\n`);
124
+ await emitEvent(home, active.name, tool, false, `quota:bytes_written ${r.current + bytes}/${r.limit}`);
125
+ process.exit(1);
126
+ }
127
+ }
128
+ }
129
+ }
130
+ if (quotas && typeof quotas.max_wall_clock_ms === 'number' && quotas.max_wall_clock_ms > 0) {
131
+ const r = await quotaCheckAndIncrement(active.name, 'wall_clock_ms', 0, quotas.max_wall_clock_ms, { homeDir: home });
132
+ if (!r.allowed) {
133
+ process.stderr.write(`[ijfw] extension '${active.name}' exceeded quota wall_clock_ms (${r.current}/${r.limit})\n`);
134
+ await emitEvent(home, active.name, tool, false, `quota:wall_clock_ms ${r.current}/${r.limit}`);
135
+ process.exit(1);
136
+ }
137
+ }
138
+
78
139
  await emitEvent(home, active.name, tool, true);
79
140
  process.exit(0);
@@ -0,0 +1,305 @@
1
+ /**
2
+ * extension-quota-tracker.js — IJFW v1.4.3 W9-A3 (B16)
3
+ *
4
+ * Per-extension resource quotas. Counter state lives at
5
+ * `~/.ijfw/state/extension-quotas.json`. Every read-modify-write goes through
6
+ * `withFsLock(~/.ijfw/state/extension-quotas.json.lock, fn, { staleMs })` so
7
+ * cross-process tool invocations cannot race the counter (SEC-H-01).
8
+ *
9
+ * "Session" semantics (SEC-M-02): one activation = one quota window. Counters
10
+ * reset on `activate <name>` AND on `deactivate`. NO cumulative state across
11
+ * activate/deactivate boundaries — a re-activated extension gets a clean slate.
12
+ *
13
+ * Wall-clock dimension (SEC-M-02): never incremented; computed on each check
14
+ * as `Date.now() - activated_at`.
15
+ *
16
+ * Threat boundary (ARCH-M-01): API-level accounting, NOT OS-level resource
17
+ * limits. See docs/EXTENSION-SECURITY.md.
18
+ *
19
+ * Frozen contract: `getQuotaUsage` return shape is the integration point with
20
+ * B19 dashboard. See docstring on that function.
21
+ */
22
+
23
+ import { readFile, writeFile, mkdir, rename } from 'node:fs/promises';
24
+ import { homedir } from 'node:os';
25
+ import { join, dirname } from 'node:path';
26
+ import { randomBytes } from 'node:crypto';
27
+
28
+ import { withFsLock } from './fs-lock.js';
29
+
30
+ const STATE_REL = ['.ijfw', 'state', 'extension-quotas.json'];
31
+
32
+ // R12-L-02 — acquireTimeoutMs sizing rationale.
33
+ //
34
+ // Default fs-lock timeout is 5s. That ceiling is wrong for THIS lock for a
35
+ // specific, measured reason: the quota tracker is the single chokepoint for
36
+ // every concurrent tool dispatch on a session. With BACKOFF_MAX_MS=250ms in
37
+ // fs-lock.js and ~100 contending Promise.all callers, the worst-case wait
38
+ // approaches `100 * 250ms = 25s`. The 100-parallel correctness test in
39
+ // test-extension-quota-tracker.js exercises exactly this; we pin
40
+ // QUOTA_LOCK_TIMEOUT_MS=30_000 with one second of margin.
41
+ //
42
+ // staleMs stays at 30_000 (crash recovery, unrelated to acquisition latency).
43
+ //
44
+ // Audit history: codex+gemini R12 flagged the literal 30_000 as "too long for
45
+ // hot path". The audit was treating the timeout as the *typical* wait when in
46
+ // reality it's the worst-case ceiling under a workload the test suite
47
+ // explicitly covers. Lowering it broke that test (see commits 45389ff +
48
+ // a996abd in the R12-fix branch).
49
+ const QUOTA_LOCK_TIMEOUT_MS = 30_000;
50
+ const QUOTA_LOCK_STALE_MS = 30_000;
51
+
52
+ /** Public dimensions. Manifest-side names are `max_<dim>`. */
53
+ export const QUOTA_DIMENSIONS = Object.freeze([
54
+ 'files_written',
55
+ 'bytes_written',
56
+ 'wall_clock_ms',
57
+ ]);
58
+
59
+ function homeFromOpts(opts) {
60
+ if (opts && opts.homeDir) return opts.homeDir;
61
+ return process.env.HOME || process.env.USERPROFILE || homedir();
62
+ }
63
+
64
+ function statePath(home) {
65
+ return join(home, ...STATE_REL);
66
+ }
67
+
68
+ function lockPath(home) {
69
+ return statePath(home) + '.lock';
70
+ }
71
+
72
+ /**
73
+ * Ensure ~/.ijfw/state exists before withFsLock tries to mkdir the lock
74
+ * directory inside it. Cheap idempotent — first caller creates, subsequent
75
+ * callers no-op. Without this, the first quota call on a fresh HOME would
76
+ * ENOENT (parent missing).
77
+ */
78
+ async function ensureStateDir(home) {
79
+ await mkdir(dirname(statePath(home)), { recursive: true });
80
+ }
81
+
82
+ /**
83
+ * Read raw quota state from disk. Returns `{}` when missing or unparseable.
84
+ * Caller is responsible for holding `withFsLock` for R/M/W flows.
85
+ */
86
+ export async function readQuotaState(home) {
87
+ const h = home || homeFromOpts({});
88
+ try {
89
+ const raw = await readFile(statePath(h), 'utf8');
90
+ const parsed = JSON.parse(raw);
91
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
92
+ return parsed;
93
+ }
94
+ return {};
95
+ } catch {
96
+ return {};
97
+ }
98
+ }
99
+
100
+ /**
101
+ * Atomic tmp+rename write of the entire quota state file. MUST be called
102
+ * inside `withFsLock`.
103
+ */
104
+ export async function writeQuotaState(home, state) {
105
+ const h = home || homeFromOpts({});
106
+ const path = statePath(h);
107
+ await mkdir(dirname(path), { recursive: true });
108
+ const tmp = `${path}.tmp.${randomBytes(4).toString('hex')}`;
109
+ await writeFile(tmp, JSON.stringify(state, null, 2) + '\n', 'utf8');
110
+ await rename(tmp, path);
111
+ }
112
+
113
+ function emptyExt() {
114
+ return {
115
+ files_written: { current: 0, writes_by_path: {} },
116
+ bytes_written: { current: 0 },
117
+ activated_at: null,
118
+ };
119
+ }
120
+
121
+ function ensureExt(state, extName) {
122
+ if (!state[extName]) state[extName] = emptyExt();
123
+ // Defensive: fill missing sub-fields so callers in older state files don't
124
+ // crash. Reading is permissive; writing is canonical.
125
+ if (!state[extName].files_written) state[extName].files_written = { current: 0, writes_by_path: {} };
126
+ if (!state[extName].files_written.writes_by_path) state[extName].files_written.writes_by_path = {};
127
+ if (typeof state[extName].files_written.current !== 'number') state[extName].files_written.current = 0;
128
+ if (!state[extName].bytes_written) state[extName].bytes_written = { current: 0 };
129
+ if (typeof state[extName].bytes_written.current !== 'number') state[extName].bytes_written.current = 0;
130
+ if (state[extName].activated_at === undefined) state[extName].activated_at = null;
131
+ return state[extName];
132
+ }
133
+
134
+ /**
135
+ * checkAndIncrement(extName, dimension, increment, limit, opts)
136
+ *
137
+ * For `files_written`: `opts.path` provides the absolute path being written;
138
+ * tracker deduplicates so writing the same file 10 times = 1 toward the cap.
139
+ *
140
+ * For `wall_clock_ms`: never incremented; the check compares
141
+ * `Date.now() - activated_at` against `limit`. `increment` is ignored.
142
+ *
143
+ * Returns { allowed, current, limit, reason? }. When `limit` is null/undefined
144
+ * (no quota declared), returns allowed=true and the current observed value.
145
+ */
146
+ export async function checkAndIncrement(extName, dimension, increment, limit, opts = {}) {
147
+ if (typeof extName !== 'string' || !extName) {
148
+ return { allowed: false, current: 0, limit: limit ?? null, reason: 'invalid ext name' };
149
+ }
150
+ if (!QUOTA_DIMENSIONS.includes(dimension)) {
151
+ return { allowed: false, current: 0, limit: limit ?? null, reason: `unknown dimension: ${dimension}` };
152
+ }
153
+ const home = homeFromOpts(opts);
154
+
155
+ // Back-compat: no limit declared = no enforcement. Still record the
156
+ // increment so getQuotaUsage reflects activity. EXCEPT wall_clock_ms which
157
+ // is computed-not-stored, so skip the R/M/W entirely.
158
+ const limitIsNull = limit === null || limit === undefined;
159
+
160
+ await ensureStateDir(home);
161
+
162
+ if (dimension === 'wall_clock_ms') {
163
+ // Read-only path.
164
+ return await withFsLock(lockPath(home), async () => {
165
+ const state = await readQuotaState(home);
166
+ const ext = state[extName];
167
+ if (!ext || !ext.activated_at) {
168
+ // Not active — wall clock is 0.
169
+ return { allowed: true, current: 0, limit: limitIsNull ? null : limit };
170
+ }
171
+ const activatedMs = Date.parse(ext.activated_at);
172
+ const elapsed = Number.isFinite(activatedMs) ? Math.max(0, Date.now() - activatedMs) : 0;
173
+ if (!limitIsNull && elapsed > limit) {
174
+ return {
175
+ allowed: false,
176
+ current: elapsed,
177
+ limit,
178
+ reason: `wall_clock_ms ${elapsed} > limit ${limit}`,
179
+ };
180
+ }
181
+ return { allowed: true, current: elapsed, limit: limitIsNull ? null : limit };
182
+ }, { staleMs: QUOTA_LOCK_STALE_MS, acquireTimeoutMs: QUOTA_LOCK_TIMEOUT_MS });
183
+ }
184
+
185
+ return await withFsLock(lockPath(home), async () => {
186
+ const state = await readQuotaState(home);
187
+ const ext = ensureExt(state, extName);
188
+
189
+ let nextCurrent;
190
+ if (dimension === 'files_written') {
191
+ const path = opts && typeof opts.path === 'string' ? opts.path : null;
192
+ if (path && ext.files_written.writes_by_path[path]) {
193
+ // Already counted — dedupe.
194
+ nextCurrent = ext.files_written.current;
195
+ } else {
196
+ nextCurrent = ext.files_written.current + (Number.isFinite(increment) ? increment : 1);
197
+ }
198
+
199
+ // Enforcement check BEFORE persisting the increment.
200
+ if (!limitIsNull && nextCurrent > limit) {
201
+ return {
202
+ allowed: false,
203
+ current: ext.files_written.current,
204
+ limit,
205
+ reason: `files_written ${nextCurrent} > limit ${limit}`,
206
+ };
207
+ }
208
+
209
+ ext.files_written.current = nextCurrent;
210
+ if (path) ext.files_written.writes_by_path[path] = true;
211
+ } else {
212
+ // bytes_written
213
+ const inc = Number.isFinite(increment) ? increment : 0;
214
+ nextCurrent = ext.bytes_written.current + inc;
215
+ if (!limitIsNull && nextCurrent > limit) {
216
+ return {
217
+ allowed: false,
218
+ current: ext.bytes_written.current,
219
+ limit,
220
+ reason: `bytes_written ${nextCurrent} > limit ${limit}`,
221
+ };
222
+ }
223
+ ext.bytes_written.current = nextCurrent;
224
+ }
225
+
226
+ await writeQuotaState(home, state);
227
+ return { allowed: true, current: nextCurrent, limit: limitIsNull ? null : limit };
228
+ }, { staleMs: QUOTA_LOCK_STALE_MS, acquireTimeoutMs: QUOTA_LOCK_TIMEOUT_MS });
229
+ }
230
+
231
+ /**
232
+ * resetExtensionQuotas(extName, opts) — clears all counters for one extension.
233
+ * Called on `activate` (clears stale prior-session state) AND on `deactivate`.
234
+ *
235
+ * `opts.activated_at` may be passed to stamp the new activation window. When
236
+ * omitted, the entry is removed entirely (deactivate semantics).
237
+ */
238
+ export async function resetExtensionQuotas(extName, opts = {}) {
239
+ if (typeof extName !== 'string' || !extName) return;
240
+ const home = homeFromOpts(opts);
241
+ await ensureStateDir(home);
242
+ await withFsLock(lockPath(home), async () => {
243
+ const state = await readQuotaState(home);
244
+ if (opts && typeof opts.activated_at === 'string') {
245
+ state[extName] = emptyExt();
246
+ state[extName].activated_at = opts.activated_at;
247
+ } else {
248
+ delete state[extName];
249
+ }
250
+ await writeQuotaState(home, state);
251
+ }, { staleMs: QUOTA_LOCK_STALE_MS, acquireTimeoutMs: QUOTA_LOCK_TIMEOUT_MS });
252
+ }
253
+
254
+ /**
255
+ * getQuotaUsage(extName, opts) — frozen B19 contract.
256
+ *
257
+ * Shape:
258
+ * {
259
+ * ext_name: string,
260
+ * activated_at: ISO string | null,
261
+ * dimensions: {
262
+ * files_written: { current: number, limit: number | null },
263
+ * bytes_written: { current: number, limit: number | null },
264
+ * wall_clock_ms: { current: number, limit: number | null }
265
+ * }
266
+ * }
267
+ *
268
+ * `limit === null` → no quota declared for that dimension (chart renders
269
+ * "unlimited"). Limits are sourced from `opts.limits` when provided (the
270
+ * dashboard reads the active extension's manifest and passes them in);
271
+ * otherwise null.
272
+ *
273
+ * For an extName with no recorded activity: returns the shape with zeros and
274
+ * null limits. Never throws on missing extension.
275
+ */
276
+ export async function getQuotaUsage(extName, opts = {}) {
277
+ const home = homeFromOpts(opts);
278
+ const limits = (opts && opts.limits) || {};
279
+ await ensureStateDir(home);
280
+
281
+ return await withFsLock(lockPath(home), async () => {
282
+ const state = await readQuotaState(home);
283
+ const ext = state[extName] || null;
284
+ const filesCurrent = ext && ext.files_written ? (ext.files_written.current || 0) : 0;
285
+ const bytesCurrent = ext && ext.bytes_written ? (ext.bytes_written.current || 0) : 0;
286
+ let wallCurrent = 0;
287
+ if (ext && ext.activated_at) {
288
+ const t = Date.parse(ext.activated_at);
289
+ if (Number.isFinite(t)) wallCurrent = Math.max(0, Date.now() - t);
290
+ }
291
+ const limitOrNull = (k) => {
292
+ const v = limits[k];
293
+ return typeof v === 'number' && Number.isFinite(v) ? v : null;
294
+ };
295
+ return {
296
+ ext_name: extName,
297
+ activated_at: ext && ext.activated_at ? ext.activated_at : null,
298
+ dimensions: {
299
+ files_written: { current: filesCurrent, limit: limitOrNull('max_files_written') },
300
+ bytes_written: { current: bytesCurrent, limit: limitOrNull('max_bytes_written') },
301
+ wall_clock_ms: { current: wallCurrent, limit: limitOrNull('max_wall_clock_ms') },
302
+ },
303
+ };
304
+ }, { staleMs: QUOTA_LOCK_STALE_MS, acquireTimeoutMs: QUOTA_LOCK_TIMEOUT_MS });
305
+ }