job-application-agent 3.3.0 → 3.4.1
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/README.md +11 -3
- package/installer/src/installer.mjs +21 -4
- package/job-application-agent/SKILL.md +28 -17
- package/job-application-agent/capabilities.json +5 -0
- package/job-application-agent/references/ANALYTICS.md +24 -7
- package/job-application-agent/references/CLOUD_STATE.md +19 -0
- package/job-application-agent/references/RUNS.md +44 -0
- package/job-application-agent/references/SCHEMAS.md +9 -3
- package/job-application-agent/references/SOURCES.md +1 -1
- package/job-application-agent/references/VPS_CLIENTS.md +33 -0
- package/job-application-agent/scripts/cloud-state-client.mjs +384 -0
- package/job-application-agent/scripts/job-application.mjs +247 -19
- package/job-application-agent/scripts/telemetry-client.mjs +35 -8
- package/job-application-agent/scripts/telemetry-schema.mjs +30 -3
- package/job-application-agent/scripts/version.mjs +1 -1
- package/job-application-agent/tests/cloud-state-client.test.mjs +105 -0
- package/job-application-agent/tests/job-application.test.mjs +96 -0
- package/job-application-agent/tests/linux-secret-service.integration.test.mjs +1 -0
- package/job-application-agent/tests/telemetry-client.test.mjs +91 -2
- package/job-application-agent/tests/telemetry-schema.test.mjs +22 -0
- package/job-application-agent/tests/workflow-state.test.mjs +75 -3
- package/package.json +2 -2
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { appendFile, chmod, mkdir, readFile, rename, stat, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { homedir, platform } from 'node:os';
|
|
4
|
+
import { dirname, join } from 'node:path';
|
|
5
|
+
|
|
6
|
+
export const CLOUD_STREAM_FILES = Object.freeze({
|
|
7
|
+
applications: 'applications.ndjson',
|
|
8
|
+
outcomes: 'outcomes.ndjson',
|
|
9
|
+
rounds: 'rounds.ndjson',
|
|
10
|
+
discovery: 'discovery.ndjson',
|
|
11
|
+
attention: 'attention.ndjson',
|
|
12
|
+
reviews: 'reviews.ndjson',
|
|
13
|
+
friction: 'friction.ndjson',
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
const CLOUD_DOCUMENT_FILES = Object.freeze({
|
|
17
|
+
profile: 'cloud-profile-cache.json',
|
|
18
|
+
autonomy: 'autonomy.json',
|
|
19
|
+
'review-policy': 'review-policy.json',
|
|
20
|
+
'postal-address': 'postal-address.json',
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
const FORBIDDEN_KEYS = /^(password|passwd|cookie|cookies|mfa|mfaCode|totp|ssn|passport|aadhaar|governmentId|governmentID|nationalId|sessionCookie|browserCookies|credential|credentials)$/i;
|
|
24
|
+
|
|
25
|
+
export function defaultCloudConfigPath(home = homedir()) {
|
|
26
|
+
if (platform() === 'darwin') return join(home, 'Library', 'Application Support', 'job-application-agent-cloud', 'config.json');
|
|
27
|
+
if (platform() === 'win32') return join(process.env.LOCALAPPDATA || join(home, 'AppData', 'Local'), 'job-application-agent-cloud', 'config.json');
|
|
28
|
+
return join(process.env.XDG_CONFIG_HOME || join(home, '.config'), 'job-application-agent', 'cloud.json');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function hash(bytes) {
|
|
32
|
+
return createHash('sha256').update(bytes).digest('hex');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function scanForbidden(value, depth = 0) {
|
|
36
|
+
if (depth > 8 || value == null) return null;
|
|
37
|
+
if (Array.isArray(value)) {
|
|
38
|
+
for (const item of value) {
|
|
39
|
+
const found = scanForbidden(item, depth + 1);
|
|
40
|
+
if (found) return found;
|
|
41
|
+
}
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
if (typeof value !== 'object') return null;
|
|
45
|
+
for (const [key, item] of Object.entries(value)) {
|
|
46
|
+
if (FORBIDDEN_KEYS.test(key)) return key;
|
|
47
|
+
const found = scanForbidden(item, depth + 1);
|
|
48
|
+
if (found) return found;
|
|
49
|
+
}
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function ensurePrivateDirectory(path) {
|
|
54
|
+
await mkdir(path, { recursive: true, mode: 0o700 });
|
|
55
|
+
await chmod(path, 0o700);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function privateWrite(path, contents) {
|
|
59
|
+
await ensurePrivateDirectory(dirname(path));
|
|
60
|
+
const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`;
|
|
61
|
+
await writeFile(temporary, contents, { mode: 0o600 });
|
|
62
|
+
await rename(temporary, path);
|
|
63
|
+
await chmod(path, 0o600);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function fileExists(path) {
|
|
67
|
+
try { await stat(path); return true; } catch (error) { if (error.code === 'ENOENT') return false; throw error; }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export async function saveCloudConfig(input, { configPath = defaultCloudConfigPath() } = {}) {
|
|
71
|
+
if (!input || typeof input !== 'object' || Array.isArray(input)) throw new Error('Cloud configuration must be a JSON object.');
|
|
72
|
+
const url = String(input.url ?? '').replace(/\/+$/, '');
|
|
73
|
+
const token = String(input.token ?? '');
|
|
74
|
+
if (!/^https:\/\//i.test(url)) throw new Error('Cloud URL must use HTTPS.');
|
|
75
|
+
if (token.length < 32) throw new Error('Cloud token must be at least 32 characters.');
|
|
76
|
+
const config = {
|
|
77
|
+
version: 2,
|
|
78
|
+
url,
|
|
79
|
+
token,
|
|
80
|
+
...(input.clientId ? { clientId: String(input.clientId) } : {}),
|
|
81
|
+
...(input.clientName ? { clientName: String(input.clientName) } : {}),
|
|
82
|
+
configuredAt: new Date().toISOString(),
|
|
83
|
+
};
|
|
84
|
+
await privateWrite(configPath, `${JSON.stringify(config, null, 2)}\n`);
|
|
85
|
+
return { ...config, token: tokenSuffix(token) };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export async function enableCloudUpdateGuard({ home = homedir(), agentHome = process.env.JOB_APPLICATION_AGENT_HOME } = {}) {
|
|
89
|
+
const root = agentHome || join(home, '.agents');
|
|
90
|
+
const path = join(root, 'job-application-agent', 'install.json');
|
|
91
|
+
let config;
|
|
92
|
+
try { config = JSON.parse(await readFile(path, 'utf8')); }
|
|
93
|
+
catch (error) { if (error.code === 'ENOENT') return { guarded: false, reason: 'managed-install-not-found' }; throw error; }
|
|
94
|
+
const required = new Set(config.requiredCapabilities ?? []);
|
|
95
|
+
required.add('cloud-state-v2');
|
|
96
|
+
await privateWrite(path, `${JSON.stringify({ ...config, requiredCapabilities: [...required].sort() }, null, 2)}\n`);
|
|
97
|
+
return { guarded: true, capability: 'cloud-state-v2' };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function tokenSuffix(token) {
|
|
101
|
+
return token.length >= 4 ? `****${token.slice(-4)}` : '****';
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export async function loadCloudConfig({ configPath = defaultCloudConfigPath(), optional = false } = {}) {
|
|
105
|
+
try {
|
|
106
|
+
const value = JSON.parse(await readFile(configPath, 'utf8'));
|
|
107
|
+
const url = String(value.url ?? '').replace(/\/+$/, '');
|
|
108
|
+
const token = String(value.token ?? '');
|
|
109
|
+
if (!/^https:\/\//i.test(url) || token.length < 24) throw new Error('Cloud configuration is malformed.');
|
|
110
|
+
return { ...value, url, token };
|
|
111
|
+
} catch (error) {
|
|
112
|
+
if (optional && error.code === 'ENOENT') return null;
|
|
113
|
+
if (error.code === 'ENOENT') throw new Error(`Cloud configuration is missing at ${configPath}. Run cloud configure --stdin.`);
|
|
114
|
+
throw error;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function responseError(response) {
|
|
119
|
+
try { return (await response.json()).error ?? `HTTP ${response.status}`; } catch { return `HTTP ${response.status}`; }
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export class CloudStateClient {
|
|
123
|
+
constructor({ stateDir, configPath = defaultCloudConfigPath(), fetchImpl = fetch } = {}) {
|
|
124
|
+
if (!stateDir) throw new Error('stateDir is required.');
|
|
125
|
+
this.stateDir = stateDir;
|
|
126
|
+
this.configPath = configPath;
|
|
127
|
+
this.fetchImpl = fetchImpl;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async config(optional = false) {
|
|
131
|
+
return loadCloudConfig({ configPath: this.configPath, optional });
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async configured() {
|
|
135
|
+
return (await this.config(true))?.version === 2;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async request(path, init = {}) {
|
|
139
|
+
const config = await this.config();
|
|
140
|
+
if (config.version !== 2) throw new Error('Cloud configuration uses the retired whole-file protocol. Run cloud configure --stdin with a v2 client credential.');
|
|
141
|
+
let response;
|
|
142
|
+
try {
|
|
143
|
+
response = await this.fetchImpl(`${config.url}${path}`, {
|
|
144
|
+
...init,
|
|
145
|
+
headers: { authorization: `Bearer ${config.token}`, ...(init.headers ?? {}) },
|
|
146
|
+
});
|
|
147
|
+
} catch (error) {
|
|
148
|
+
throw new Error(`Cloud state unavailable: ${error.message}`);
|
|
149
|
+
}
|
|
150
|
+
if (!response.ok) throw new Error(`Cloud state request failed (${response.status}): ${await responseError(response)}`);
|
|
151
|
+
return response;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async status() {
|
|
155
|
+
const config = await this.config(true);
|
|
156
|
+
if (!config) return { configured: false, backend: 'local', pendingLocalWrites: 0 };
|
|
157
|
+
const pending = await this.pendingWrites();
|
|
158
|
+
const remote = await (await this.request('/v2/status')).json();
|
|
159
|
+
return { configured: true, url: config.url, configuredClient: { id: config.clientId ?? null, name: config.clientName ?? null, token: tokenSuffix(config.token) }, pendingLocalWrites: pending.length, ...remote };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async getDocument(name) {
|
|
163
|
+
const response = await this.request(`/v2/documents/${encodeURIComponent(name)}`);
|
|
164
|
+
return response.json();
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async putDocument(name, value, revision) {
|
|
168
|
+
const forbidden = scanForbidden(value);
|
|
169
|
+
if (forbidden) throw new Error(`Cloud document contains forbidden field: ${forbidden}`);
|
|
170
|
+
const response = await this.request(`/v2/documents/${encodeURIComponent(name)}`, {
|
|
171
|
+
method: 'PUT',
|
|
172
|
+
headers: { 'content-type': 'application/json', 'if-match': String(revision) },
|
|
173
|
+
body: JSON.stringify({ value }),
|
|
174
|
+
});
|
|
175
|
+
return response.json();
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async putDocumentCurrent(name, value) {
|
|
179
|
+
let revision = 0;
|
|
180
|
+
try { revision = (await this.getDocument(name)).revision; }
|
|
181
|
+
catch (error) { if (!/\(404\)/.test(error.message)) throw error; }
|
|
182
|
+
return this.putDocument(name, value, revision);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async listStream(stream) {
|
|
186
|
+
const records = [];
|
|
187
|
+
let cursor = 0;
|
|
188
|
+
do {
|
|
189
|
+
const page = await (await this.request(`/v2/streams/${encodeURIComponent(stream)}?after=${cursor}&limit=1000`)).json();
|
|
190
|
+
records.push(...page.records);
|
|
191
|
+
if (page.nextCursor === cursor || page.records.length === 0) break;
|
|
192
|
+
cursor = page.nextCursor;
|
|
193
|
+
} while (true);
|
|
194
|
+
return records;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async appendRecord(stream, value, { recordKey, idempotencyKey, occurredAt, provenance = 'live', queueOnFailure = false } = {}) {
|
|
198
|
+
const forbidden = scanForbidden(value);
|
|
199
|
+
if (forbidden) throw new Error(`Cloud record contains forbidden field: ${forbidden}`);
|
|
200
|
+
const payload = {
|
|
201
|
+
recordKey: String(recordKey ?? value?.id ?? value?.roundId ?? randomUUID()),
|
|
202
|
+
idempotencyKey: String(idempotencyKey ?? `${stream}:${hash(JSON.stringify(value))}`),
|
|
203
|
+
occurredAt: occurredAt ?? value?.occurredAt ?? value?.submittedAt ?? new Date().toISOString(),
|
|
204
|
+
provenance,
|
|
205
|
+
value,
|
|
206
|
+
};
|
|
207
|
+
try {
|
|
208
|
+
return await (await this.request(`/v2/streams/${encodeURIComponent(stream)}`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(payload) })).json();
|
|
209
|
+
} catch (error) {
|
|
210
|
+
if (!queueOnFailure) throw error;
|
|
211
|
+
await this.queueWrite({ type: 'append-record', stream, payload, queuedAt: new Date().toISOString() });
|
|
212
|
+
return { queued: true, error: 'cloud_unavailable' };
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
async appendRecordBatch(stream, records) {
|
|
217
|
+
if (!Array.isArray(records) || records.length < 1 || records.length > 100) throw new Error('Cloud record batch must contain 1 to 100 records.');
|
|
218
|
+
const response = await this.request(`/v2/streams/${encodeURIComponent(stream)}/batch`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ records }) });
|
|
219
|
+
return response.json();
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
async queueWrite(event) {
|
|
223
|
+
await ensurePrivateDirectory(this.stateDir);
|
|
224
|
+
const path = join(this.stateDir, 'cloud-pending.ndjson');
|
|
225
|
+
await appendFile(path, `${JSON.stringify(event)}\n`, { mode: 0o600 });
|
|
226
|
+
await chmod(path, 0o600);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async pendingWrites() {
|
|
230
|
+
try { return (await readFile(join(this.stateDir, 'cloud-pending.ndjson'), 'utf8')).split('\n').filter(Boolean).map(JSON.parse); }
|
|
231
|
+
catch (error) { if (error.code === 'ENOENT') return []; throw error; }
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
async putFile(name, bytes, revision) {
|
|
235
|
+
const body = Buffer.from(bytes);
|
|
236
|
+
const response = await this.request(`/v2/files/${encodeURIComponent(name)}`, { method: 'PUT', headers: { 'content-type': name.endsWith('.pdf') ? 'application/pdf' : 'application/json', 'if-match': String(revision), 'x-content-sha256': hash(body) }, body });
|
|
237
|
+
return response.json();
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
async putFileCurrent(name, bytes) {
|
|
241
|
+
let revision = 0;
|
|
242
|
+
try {
|
|
243
|
+
const status = await this.status();
|
|
244
|
+
revision = status.files?.find((file) => file.name === name)?.revision ?? 0;
|
|
245
|
+
} catch (error) { if (!/\(404\)/.test(error.message)) throw error; }
|
|
246
|
+
return this.putFile(name, bytes, revision);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
async fetchResume() {
|
|
250
|
+
const response = await this.request('/v2/files/resume.pdf');
|
|
251
|
+
const bytes = Buffer.from(await response.arrayBuffer());
|
|
252
|
+
const expected = response.headers.get('x-sha256');
|
|
253
|
+
const actual = hash(bytes);
|
|
254
|
+
if (!expected || expected !== actual) throw new Error('Cloud resume checksum verification failed.');
|
|
255
|
+
if (!bytes.subarray(0, 4).equals(Buffer.from('%PDF'))) throw new Error('Cloud resume is not a PDF.');
|
|
256
|
+
const path = join(this.stateDir, 'resume.pdf');
|
|
257
|
+
await privateWrite(path, bytes);
|
|
258
|
+
return { path, sha256: actual, bytes: bytes.length };
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
async refreshProfileCache() {
|
|
262
|
+
const document = await this.getDocument('profile');
|
|
263
|
+
await privateWrite(join(this.stateDir, 'cloud-profile-cache.json'), `${JSON.stringify(document.value)}\n`);
|
|
264
|
+
return document.value;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
async refreshDocumentCaches() {
|
|
268
|
+
const refreshed = {};
|
|
269
|
+
for (const [name, filename] of Object.entries(CLOUD_DOCUMENT_FILES)) {
|
|
270
|
+
let document;
|
|
271
|
+
try { document = await this.getDocument(name); }
|
|
272
|
+
catch (error) { if (/\(404\)/.test(error.message)) continue; throw error; }
|
|
273
|
+
await privateWrite(join(this.stateDir, filename), `${JSON.stringify(document.value)}\n`);
|
|
274
|
+
refreshed[name] = { revision: document.revision, updatedAt: document.updatedAt ?? null };
|
|
275
|
+
}
|
|
276
|
+
return refreshed;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
async acquireLease() {
|
|
280
|
+
return (await this.request('/v2/leases/application-run', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ action: 'acquire' }) })).json();
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
async renewLease(leaseId) {
|
|
284
|
+
return (await this.request('/v2/leases/application-run', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ action: 'renew', leaseId }) })).json();
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
async releaseLease(leaseId) {
|
|
288
|
+
return (await this.request('/v2/leases/application-run', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ action: 'release', leaseId }) })).json();
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
async createIntent(value) {
|
|
292
|
+
return (await this.request('/v2/intents', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(value) })).json();
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
async markIntentSentUnverified(intentId, leaseId) {
|
|
296
|
+
return (await this.request(`/v2/intents/${encodeURIComponent(intentId)}/sent-unverified`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ leaseId }) })).json();
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
async confirmIntent(intentId, application, leaseId, idempotencyKey) {
|
|
300
|
+
return (await this.request(`/v2/intents/${encodeURIComponent(intentId)}/confirm`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ application, leaseId, idempotencyKey }) })).json();
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
async reconcile({ dryRun = true, provenance = 'local-reconcile' } = {}) {
|
|
304
|
+
const report = { dryRun, streams: {}, imported: 0, downloaded: 0 };
|
|
305
|
+
for (const [stream, filename] of Object.entries(CLOUD_STREAM_FILES)) {
|
|
306
|
+
const local = await readNdjson(join(this.stateDir, filename));
|
|
307
|
+
const cloudRecords = await this.listStream(stream);
|
|
308
|
+
const cloud = cloudRecords.map((record) => record.value);
|
|
309
|
+
const localCounts = multiset(local);
|
|
310
|
+
const cloudCounts = multiset(cloud);
|
|
311
|
+
const localOnly = multisetDifference(local, cloudCounts);
|
|
312
|
+
const cloudOnly = multisetDifference(cloud, localCounts);
|
|
313
|
+
report.streams[stream] = { localRows: local.length, cloudRows: cloud.length, localOnly: localOnly.length, cloudOnly: cloudOnly.length, unionRows: local.length + cloudOnly.length };
|
|
314
|
+
if (!dryRun) {
|
|
315
|
+
const prepared = localOnly.map(({ value, index }) => ({
|
|
316
|
+
recordKey: String(value?.id ?? value?.roundId ?? value?.applicationId ?? `${stream}-${index}`),
|
|
317
|
+
idempotencyKey: `reconcile:${hash(JSON.stringify(value))}:${index}`,
|
|
318
|
+
occurredAt: value?.occurredAt ?? value?.submittedAt ?? new Date().toISOString(),
|
|
319
|
+
provenance,
|
|
320
|
+
value,
|
|
321
|
+
}));
|
|
322
|
+
for (let offset = 0; offset < prepared.length; offset += 100) {
|
|
323
|
+
const result = await this.appendRecordBatch(stream, prepared.slice(offset, offset + 100));
|
|
324
|
+
report.imported += result.inserted;
|
|
325
|
+
}
|
|
326
|
+
if (cloudOnly.length) {
|
|
327
|
+
await ensurePrivateDirectory(this.stateDir);
|
|
328
|
+
const path = join(this.stateDir, filename);
|
|
329
|
+
for (const { value } of cloudOnly) await appendFile(path, `${JSON.stringify(value)}\n`, { mode: 0o600 });
|
|
330
|
+
await chmod(path, 0o600);
|
|
331
|
+
report.downloaded += cloudOnly.length;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
return report;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
async exportTo(path) {
|
|
339
|
+
const status = await this.status();
|
|
340
|
+
const documents = {};
|
|
341
|
+
for (const item of status.documents ?? []) documents[item.name] = await this.getDocument(item.name);
|
|
342
|
+
const streams = {};
|
|
343
|
+
for (const stream of Object.keys(CLOUD_STREAM_FILES)) streams[stream] = await this.listStream(stream);
|
|
344
|
+
const output = path ?? join(this.stateDir, `cloud-export-${new Date().toISOString().replaceAll(':', '-')}.json`);
|
|
345
|
+
await privateWrite(output, `${JSON.stringify({ version: 1, exportedAt: new Date().toISOString(), backend: status.backend, documents, streams, files: status.files ?? [] })}\n`);
|
|
346
|
+
return { path: output, documents: Object.keys(documents).length, records: Object.values(streams).reduce((sum, rows) => sum + rows.length, 0) };
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
async function readNdjson(path) {
|
|
351
|
+
try { return (await readFile(path, 'utf8')).split('\n').filter(Boolean).map((line) => JSON.parse(line)); }
|
|
352
|
+
catch (error) { if (error.code === 'ENOENT') return []; throw error; }
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function key(value) {
|
|
356
|
+
return JSON.stringify(value);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function multiset(values) {
|
|
360
|
+
const counts = new Map();
|
|
361
|
+
for (const value of values) counts.set(key(value), (counts.get(key(value)) ?? 0) + 1);
|
|
362
|
+
return counts;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function multisetDifference(values, otherCounts) {
|
|
366
|
+
const remaining = new Map(otherCounts);
|
|
367
|
+
const result = [];
|
|
368
|
+
values.forEach((value, index) => {
|
|
369
|
+
const itemKey = key(value);
|
|
370
|
+
const count = remaining.get(itemKey) ?? 0;
|
|
371
|
+
if (count > 0) remaining.set(itemKey, count - 1);
|
|
372
|
+
else result.push({ value, index });
|
|
373
|
+
});
|
|
374
|
+
return result;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
export async function readCachedCloudProfile(stateDir) {
|
|
378
|
+
try { return JSON.parse(await readFile(join(stateDir, 'cloud-profile-cache.json'), 'utf8')); }
|
|
379
|
+
catch (error) { if (error.code === 'ENOENT') return null; throw error; }
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
export function cloudDocumentFile(name) {
|
|
383
|
+
return CLOUD_DOCUMENT_FILES[name] ?? null;
|
|
384
|
+
}
|