@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.
- package/package.json +1 -1
- package/src/active-extension-writer.js +284 -4
- package/src/cross-orchestrator.js +164 -2
- package/src/dashboard-aggregator.js +165 -0
- package/src/dashboard-charts.js +239 -0
- package/src/dashboard-client-planning.html +273 -0
- package/src/dashboard-client.html +213 -1
- package/src/dashboard-server.js +186 -0
- package/src/dispatch/active-cli.js +141 -0
- package/src/dispatch/extension.js +40 -0
- package/src/dispatch/quota-cli.js +42 -0
- package/src/dispatch/registry-cli.js +339 -0
- package/src/dispatch/signer-cli.js +311 -0
- package/src/dispatch/wave-cli.js +128 -0
- package/src/extension-manifest-schema.js +25 -0
- package/src/extension-permission-check.mjs +61 -0
- package/src/extension-quota-tracker.js +305 -0
- package/src/extension-registry-ws.js +347 -0
- package/src/extension-registry.js +819 -149
- package/src/extension-signer.js +105 -0
- package/src/fs-lock.js +205 -0
- package/src/hardware-signer.js +493 -0
- package/src/ide-detect.js +122 -0
- package/src/orchestrator/review.js +101 -0
- package/src/orchestrator/status-protocol.js +168 -0
- package/src/orchestrator/verification-gate.js +97 -0
- package/src/orchestrator/wave-state.js +255 -0
- package/src/runtime-mediator.js +31 -0
- package/src/server.js +180 -18
- package/src/swarm-config.js +32 -8
|
@@ -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
|
+
}
|