syndes 0.1.0 → 0.2.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.
- package/README.md +29 -0
- package/analytics/metrics/rework.mjs +8 -2
- package/analytics/metrics/volume.mjs +19 -6
- package/analytics/team.mjs +245 -0
- package/bin/cli.mjs +226 -1
- package/collect/projects.mjs +18 -2
- package/dashboard/api/team.mjs +220 -0
- package/dashboard/router.mjs +7 -0
- package/dashboard/server.mjs +11 -2
- package/dashboard/web/api.js +6 -0
- package/dashboard/web/app.css +27 -1
- package/dashboard/web/app.js +1 -0
- package/dashboard/web/charts.js +191 -0
- package/dashboard/web/ui.js +1 -1
- package/dashboard/web/views/team.js +402 -0
- package/package.json +8 -4
- package/runtime/config.mjs +17 -0
- package/runtime/identity.mjs +96 -0
- package/runtime/paths.mjs +12 -0
- package/runtime/worker.mjs +35 -1
- package/src/briefing.mjs +14 -1
- package/src/doctor.mjs +30 -0
- package/src/term.mjs +42 -3
- package/sync/index.mjs +257 -0
- package/sync/run-sync.mjs +21 -0
- package/sync/share.mjs +143 -0
- package/sync/transport.mjs +203 -0
package/src/doctor.mjs
CHANGED
|
@@ -24,6 +24,8 @@ import { isHeld } from '../runtime/lock.mjs';
|
|
|
24
24
|
import { describe as describeAuth } from '../dashboard/auth.mjs';
|
|
25
25
|
import { probe } from '../notify/index.mjs';
|
|
26
26
|
import { readJson, loadConfig } from '../runtime/config.mjs';
|
|
27
|
+
import { isEnabled as sharingOn, teamConfig, loadState as teamState, readPool, poolRoot } from '../sync/index.mjs';
|
|
28
|
+
import { identity } from '../runtime/identity.mjs';
|
|
27
29
|
import { configFile } from '../runtime/paths.mjs';
|
|
28
30
|
|
|
29
31
|
function versionOf(file) {
|
|
@@ -137,6 +139,34 @@ export async function diagnose({ deep = false } = {}) {
|
|
|
137
139
|
checks.push(note('system unlock', `${lock.system.method} is available here — syndes lock system`));
|
|
138
140
|
}
|
|
139
141
|
|
|
142
|
+
// ── Sharing ───────────────────────────────────────────────────────────────
|
|
143
|
+
if (!sharingOn()) {
|
|
144
|
+
checks.push(note('sharing', 'off — this machine reports only to itself'));
|
|
145
|
+
} else {
|
|
146
|
+
const state = teamState();
|
|
147
|
+
const peers = readPool();
|
|
148
|
+
const mine = peers.find((peer) => peer.deviceId === identity().deviceId);
|
|
149
|
+
|
|
150
|
+
checks.push(state.ok === false
|
|
151
|
+
? fail('pool', `last exchange failed: ${String(state.error ?? 'unknown').slice(0, 80)}`, 'syndes team sync')
|
|
152
|
+
: pass('pool', `${teamConfig().transport} · ${displayPath(poolRoot())}`));
|
|
153
|
+
|
|
154
|
+
checks.push(mine
|
|
155
|
+
? pass('your shelf', `${identity().name} · ${mine.rollups.size} day(s) published`)
|
|
156
|
+
: fail('your shelf', 'nothing of yours is in the pool yet', 'syndes team sync'));
|
|
157
|
+
|
|
158
|
+
// A peer whose shelf has gone quiet is the failure people misread as "they
|
|
159
|
+
// stopped working". Naming it here is cheaper than them guessing.
|
|
160
|
+
const stale = peers.filter((peer) => !peer.isMe && (!peer.lastSeen || Date.now() - peer.lastSeen > 6 * 3_600_000));
|
|
161
|
+
checks.push(stale.length
|
|
162
|
+
? note('peers', `${peers.length - 1} other machine(s), ${stale.length} not synced in 6h: ${stale.map((p) => p.name).join(', ')}`)
|
|
163
|
+
: pass('peers', `${peers.length - 1} other machine(s), all current`));
|
|
164
|
+
|
|
165
|
+
checks.push(note('sharing scope', teamConfig().scope === 'detailed'
|
|
166
|
+
? 'detailed — counts, tokens, hours, project ids and tool mix. Never prompt text.'
|
|
167
|
+
: 'summary — counts, tokens and hours only. No paths, no project ids, no prompt text.'));
|
|
168
|
+
}
|
|
169
|
+
|
|
140
170
|
const config = readJson(configFile);
|
|
141
171
|
if (config.exists && config.error) {
|
|
142
172
|
checks.push(fail('config', `syndes.json does not parse: ${config.error}`, 'fix or delete it; defaults will be used'));
|
package/src/term.mjs
CHANGED
|
@@ -207,10 +207,50 @@ export function beat(ms = 90) {
|
|
|
207
207
|
return new Promise((resolve) => { setTimeout(resolve, ms); });
|
|
208
208
|
}
|
|
209
209
|
|
|
210
|
+
/**
|
|
211
|
+
* Ask, and always come back with something.
|
|
212
|
+
*
|
|
213
|
+
* Two ways a question never gets answered, both of which used to hang forever:
|
|
214
|
+
*
|
|
215
|
+
* • There is no terminal. Every caller of this function is interactive by
|
|
216
|
+
* nature, so with no tty there is nobody to ask — and reading whatever
|
|
217
|
+
* happens to be on a pipe as though a person typed it is worse than not
|
|
218
|
+
* asking, particularly for the PIN prompt. This follows the rule the rest
|
|
219
|
+
* of the codebase already keeps: a probe that cannot answer returns nothing
|
|
220
|
+
* and the caller degrades, rather than "unsupported" and "broken" looking
|
|
221
|
+
* alike. It is also the only DETERMINISTIC answer available. When stdin is
|
|
222
|
+
* /dev/null — which is exactly what npm hands a postinstall — Node emits no
|
|
223
|
+
* `end` and no `close` on it at all, so nothing short of a timeout would
|
|
224
|
+
* ever notice, and a timeout in a prompt is a guess about how fast someone
|
|
225
|
+
* types.
|
|
226
|
+
*
|
|
227
|
+
* • The user pressed ctrl-D at a real prompt. readline's callback never
|
|
228
|
+
* fires, so `close` is what settles it.
|
|
229
|
+
*
|
|
230
|
+
* Left unhandled, either one leaves the promise pending and Node exits mid
|
|
231
|
+
* install with "unsettled top-level await", which reads as a crash with no cause.
|
|
232
|
+
* An unanswered question resolves empty, and every caller already treats empty
|
|
233
|
+
* as "no answer given".
|
|
234
|
+
*/
|
|
210
235
|
export function ask(question, { silent = false } = {}) {
|
|
211
236
|
return new Promise((resolve) => {
|
|
237
|
+
if (!process.stdin.isTTY || process.stdin.readableEnded || process.stdin.destroyed) {
|
|
238
|
+
resolve('');
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
|
|
212
242
|
const rl = createInterface({ input: process.stdin, output: process.stdout, terminal: true });
|
|
213
243
|
|
|
244
|
+
let settled = false;
|
|
245
|
+
const done = (answer) => {
|
|
246
|
+
if (settled) return;
|
|
247
|
+
settled = true;
|
|
248
|
+
try { rl.close(); } catch { /* already closing */ }
|
|
249
|
+
resolve(answer);
|
|
250
|
+
};
|
|
251
|
+
rl.on('close', () => done(''));
|
|
252
|
+
rl.on('error', () => done(''));
|
|
253
|
+
|
|
214
254
|
if (silent) {
|
|
215
255
|
// Echo off by hand: readline has no password mode, and a password printed
|
|
216
256
|
// into a terminal that may be scrolled back or recorded is unacceptable.
|
|
@@ -220,13 +260,12 @@ export function ask(question, { silent = false } = {}) {
|
|
|
220
260
|
rl.question('', (answer) => {
|
|
221
261
|
rl.input.off('data', redraw);
|
|
222
262
|
rl.output.write('\n');
|
|
223
|
-
|
|
224
|
-
resolve(answer);
|
|
263
|
+
done(answer);
|
|
225
264
|
});
|
|
226
265
|
return;
|
|
227
266
|
}
|
|
228
267
|
|
|
229
|
-
rl.question(question, (answer) =>
|
|
268
|
+
rl.question(question, (answer) => done(answer.trim()));
|
|
230
269
|
});
|
|
231
270
|
}
|
|
232
271
|
|
package/sync/index.mjs
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The pool: publish our shelf, read everyone else's.
|
|
3
|
+
*
|
|
4
|
+
* This module is the whole of "sharing", and it is deliberately small because
|
|
5
|
+
* the hard parts were designed away rather than solved:
|
|
6
|
+
*
|
|
7
|
+
* • Nothing is merged. Peer data is never appended to our chain — it is read
|
|
8
|
+
* from the pool and displayed beside ours. The ledger keeps exactly one
|
|
9
|
+
* writer, forever, which is the invariant everything else here rests on.
|
|
10
|
+
* • Nothing conflicts. A device writes only its own shelf (see transport.mjs).
|
|
11
|
+
* • Nothing sensitive travels. The unit of sharing is a sanitised rollup, not
|
|
12
|
+
* a record (see share.mjs).
|
|
13
|
+
*
|
|
14
|
+
* A pull failure is not an error state. The pool is a cache of other people's
|
|
15
|
+
* numbers; when it cannot be reached, the last copy on disk is still true as of
|
|
16
|
+
* its own timestamp, and the UI says how old it is rather than going blank.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { mkdirSync, existsSync, readdirSync } from 'node:fs';
|
|
20
|
+
import { join } from 'node:path';
|
|
21
|
+
import { readFileSync } from 'node:fs';
|
|
22
|
+
import { teamDir, teamDeviceDir, teamStateFile, rollupFile, packageRoot } from '../runtime/paths.mjs';
|
|
23
|
+
import { loadConfig, rawConfig, saveConfig, setPath, readJson } from '../runtime/config.mjs';
|
|
24
|
+
import { identity } from '../runtime/identity.mjs';
|
|
25
|
+
import { loadRollup, coveredDays } from '../analytics/rollup.mjs';
|
|
26
|
+
import { localDay, shiftDay } from '../analytics/ranges.mjs';
|
|
27
|
+
import { sanitise, manifestFor } from './share.mjs';
|
|
28
|
+
import {
|
|
29
|
+
transportFor, listDevices, readJsonFile, writeJsonFile, removeFile,
|
|
30
|
+
} from './transport.mjs';
|
|
31
|
+
import { writeFileSync, renameSync } from 'node:fs';
|
|
32
|
+
import { dirname } from 'node:path';
|
|
33
|
+
import { debug } from '../runtime/log.mjs';
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Where our own shelf lives inside the pool.
|
|
37
|
+
*
|
|
38
|
+
* Takes the root rather than assuming the default working copy: a folder pool
|
|
39
|
+
* IS the shared folder, and publishing into teamDir instead would write a
|
|
40
|
+
* perfectly correct shelf somewhere nobody is reading.
|
|
41
|
+
*/
|
|
42
|
+
function shelfOf(root, deviceId) {
|
|
43
|
+
return teamDeviceDir(root, deviceId);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function teamConfig() {
|
|
47
|
+
return loadConfig().team ?? {};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function isEnabled() {
|
|
51
|
+
const team = teamConfig();
|
|
52
|
+
return Boolean(team.enabled && team.transport);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ── Setup ───────────────────────────────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Join or create a pool.
|
|
59
|
+
*
|
|
60
|
+
* @param {{transport: 'git'|'folder', repo: string, scope?: string}} options
|
|
61
|
+
*/
|
|
62
|
+
export function join_(options) {
|
|
63
|
+
const transport = transportFor(options.transport);
|
|
64
|
+
if (!transport) throw new Error(`unknown transport: ${options.transport}`);
|
|
65
|
+
if (!options.repo) throw new Error('a repository url or folder path is required');
|
|
66
|
+
|
|
67
|
+
const root = options.transport === 'folder' ? options.repo : teamDir;
|
|
68
|
+
mkdirSync(root, { recursive: true });
|
|
69
|
+
|
|
70
|
+
const started = transport.init(root, { repo: options.repo });
|
|
71
|
+
if (!started.ok) throw new Error(started.error ?? 'could not reach the pool');
|
|
72
|
+
|
|
73
|
+
const next = rawConfig();
|
|
74
|
+
setPath(next, 'team.enabled', true);
|
|
75
|
+
setPath(next, 'team.transport', options.transport);
|
|
76
|
+
setPath(next, 'team.repo', options.repo);
|
|
77
|
+
if (options.scope) setPath(next, 'team.scope', options.scope);
|
|
78
|
+
saveConfig(next);
|
|
79
|
+
|
|
80
|
+
return { root, transport: transport.id, describe: transport.describe(root) };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function leave() {
|
|
84
|
+
const next = rawConfig();
|
|
85
|
+
setPath(next, 'team.enabled', false);
|
|
86
|
+
saveConfig(next);
|
|
87
|
+
return true;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** The working copy for the configured transport. A folder pool IS the folder. */
|
|
91
|
+
export function poolRoot() {
|
|
92
|
+
const team = teamConfig();
|
|
93
|
+
return team.transport === 'folder' && team.repo ? team.repo : teamDir;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ── Publishing ──────────────────────────────────────────────────────────────
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Write our sanitised rollups onto our own shelf.
|
|
100
|
+
*
|
|
101
|
+
* Rewrites every day in the window rather than only today: a day that was still
|
|
102
|
+
* open at the last publish has since been finalised, and a stale copy of it in
|
|
103
|
+
* the pool would quietly under-report the person who left their laptop early.
|
|
104
|
+
*
|
|
105
|
+
* @returns {{written: number, removed: number, days: string[]}}
|
|
106
|
+
*/
|
|
107
|
+
export function publish({ root = poolRoot() } = {}) {
|
|
108
|
+
const team = teamConfig();
|
|
109
|
+
const me = identity();
|
|
110
|
+
const scope = team.scope ?? 'summary';
|
|
111
|
+
const keepDays = team.shareDays ?? 45;
|
|
112
|
+
|
|
113
|
+
const shelf = shelfOf(root, me.deviceId);
|
|
114
|
+
const rollups = join(shelf, 'rollup');
|
|
115
|
+
mkdirSync(rollups, { recursive: true });
|
|
116
|
+
|
|
117
|
+
const oldest = shiftDay(localDay(Date.now()), -(keepDays - 1));
|
|
118
|
+
const days = coveredDays().filter((day) => day >= oldest);
|
|
119
|
+
|
|
120
|
+
let written = 0;
|
|
121
|
+
const published = [];
|
|
122
|
+
for (const day of days) {
|
|
123
|
+
const rollup = loadRollup(day);
|
|
124
|
+
if (!rollup) continue;
|
|
125
|
+
const clean = sanitise(rollup, scope);
|
|
126
|
+
if (!clean) continue;
|
|
127
|
+
writeJsonFile(join(rollups, `${day}.json`), clean);
|
|
128
|
+
published.push(day);
|
|
129
|
+
written += 1;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Retire days that have aged out of the window, so a shelf is bounded rather
|
|
133
|
+
// than growing for as long as somebody keeps the tool installed.
|
|
134
|
+
let removed = 0;
|
|
135
|
+
try {
|
|
136
|
+
for (const name of readdirSync(rollups)) {
|
|
137
|
+
const day = name.replace(/\.json$/, '');
|
|
138
|
+
if (name.endsWith('.json') && day < oldest) { removeFile(join(rollups, name)); removed += 1; }
|
|
139
|
+
}
|
|
140
|
+
} catch { /* nothing published yet */ }
|
|
141
|
+
|
|
142
|
+
writeJsonFile(join(shelf, 'meta.json'), manifestFor(me, {
|
|
143
|
+
scope, version: version(), days: published.length,
|
|
144
|
+
}));
|
|
145
|
+
|
|
146
|
+
return { written, removed, days: published };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ── Reading the pool ────────────────────────────────────────────────────────
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Every shelf in the pool, ours included.
|
|
153
|
+
*
|
|
154
|
+
* @returns {{deviceId, name, host, os, scope, lastSeen, isMe, rollups: Map<string,object>}[]}
|
|
155
|
+
*/
|
|
156
|
+
export function readPool({ root = poolRoot(), days = null } = {}) {
|
|
157
|
+
const me = identity();
|
|
158
|
+
const wanted = days ? new Set(days) : null;
|
|
159
|
+
const peers = [];
|
|
160
|
+
|
|
161
|
+
for (const deviceId of listDevices(root)) {
|
|
162
|
+
const shelf = teamDeviceDir(root, deviceId);
|
|
163
|
+
const meta = readJsonFile(join(shelf, 'meta.json')) ?? {};
|
|
164
|
+
const rollups = new Map();
|
|
165
|
+
|
|
166
|
+
let names = [];
|
|
167
|
+
try { names = readdirSync(join(shelf, 'rollup')); } catch { names = []; }
|
|
168
|
+
|
|
169
|
+
for (const name of names) {
|
|
170
|
+
if (!name.endsWith('.json')) continue;
|
|
171
|
+
const day = name.slice(0, -5);
|
|
172
|
+
if (wanted && !wanted.has(day)) continue;
|
|
173
|
+
const rollup = readJsonFile(join(shelf, 'rollup', name));
|
|
174
|
+
if (rollup) rollups.set(day, rollup);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
peers.push({
|
|
178
|
+
deviceId,
|
|
179
|
+
name: meta.name ?? deviceId.slice(0, 6),
|
|
180
|
+
host: meta.host ?? null,
|
|
181
|
+
os: meta.os ?? null,
|
|
182
|
+
scope: meta.scope ?? 'summary',
|
|
183
|
+
lastSeen: meta.lastSeen ?? null,
|
|
184
|
+
isMe: deviceId === me.deviceId,
|
|
185
|
+
rollups,
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Ours first, then most recently seen. A list that reorders itself as people
|
|
190
|
+
// work is unreadable; anchoring self at the top keeps it stable.
|
|
191
|
+
peers.sort((a, b) => Number(b.isMe) - Number(a.isMe) || (b.lastSeen ?? 0) - (a.lastSeen ?? 0));
|
|
192
|
+
return peers;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// ── One cycle ───────────────────────────────────────────────────────────────
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Pull, publish, push. The whole exchange, and the only thing the poller calls.
|
|
199
|
+
*
|
|
200
|
+
* Pull comes first so our push rebases onto whatever arrived while we were away;
|
|
201
|
+
* publish comes second so the thing we push is current rather than one cycle
|
|
202
|
+
* behind, which is what makes the dashboard feel live rather than lagged.
|
|
203
|
+
*/
|
|
204
|
+
export function syncOnce({ push = true } = {}) {
|
|
205
|
+
if (!isEnabled()) return { ok: false, reason: 'sharing is off', at: Date.now() };
|
|
206
|
+
|
|
207
|
+
const team = teamConfig();
|
|
208
|
+
const transport = transportFor(team.transport);
|
|
209
|
+
const root = poolRoot();
|
|
210
|
+
if (!transport) return { ok: false, reason: 'unknown transport', at: Date.now() };
|
|
211
|
+
|
|
212
|
+
const pulled = transport.pull(root, { repo: team.repo });
|
|
213
|
+
const published = publish({ root });
|
|
214
|
+
|
|
215
|
+
let pushed = { ok: true, pushed: false, error: null };
|
|
216
|
+
if (push) {
|
|
217
|
+
pushed = transport.push(root, {
|
|
218
|
+
deviceId: identity().deviceId,
|
|
219
|
+
message: `syndes: ${identity().name} through ${localDay(Date.now())}`,
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const state = {
|
|
224
|
+
ok: pulled.ok && pushed.ok,
|
|
225
|
+
at: Date.now(),
|
|
226
|
+
pulled: pulled.ok,
|
|
227
|
+
pushed: pushed.pushed,
|
|
228
|
+
published: published.written,
|
|
229
|
+
error: pulled.error ?? pushed.error ?? null,
|
|
230
|
+
};
|
|
231
|
+
saveState(state);
|
|
232
|
+
debug('team sync', state);
|
|
233
|
+
return state;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export function loadState() {
|
|
237
|
+
return readJson(teamStateFile).data ?? { ok: null, at: null, error: null };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function saveState(state) {
|
|
241
|
+
try {
|
|
242
|
+
mkdirSync(dirname(teamStateFile), { recursive: true });
|
|
243
|
+
const staging = `${teamStateFile}.tmp`;
|
|
244
|
+
writeFileSync(staging, `${JSON.stringify(state)}\n`);
|
|
245
|
+
renameSync(staging, teamStateFile);
|
|
246
|
+
} catch { /* the sync happened whether or not we could note it */ }
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function version() {
|
|
250
|
+
try {
|
|
251
|
+
return JSON.parse(readFileSync(join(packageRoot, 'package.json'), 'utf8')).version;
|
|
252
|
+
} catch {
|
|
253
|
+
return null;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export { join_ as joinPool, sanitise };
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* One sync cycle, in its own process.
|
|
4
|
+
*
|
|
5
|
+
* git talks to a network and the transport calls it synchronously, which is
|
|
6
|
+
* correct for a CLI and unacceptable inside the dashboard's event loop — a
|
|
7
|
+
* twenty-five second timeout on a slow remote would freeze every other request
|
|
8
|
+
* on the server, including the one drawing the page that asked for the sync.
|
|
9
|
+
*
|
|
10
|
+
* So the poller spawns this instead of calling syncOnce directly. The child
|
|
11
|
+
* blocks; the server does not.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { syncOnce } from './index.mjs';
|
|
15
|
+
|
|
16
|
+
try {
|
|
17
|
+
const result = syncOnce({ push: !process.argv.includes('--no-push') });
|
|
18
|
+
process.stdout.write(JSON.stringify(result));
|
|
19
|
+
} catch (error) {
|
|
20
|
+
process.stdout.write(JSON.stringify({ ok: false, at: Date.now(), error: String(error?.message ?? error) }));
|
|
21
|
+
}
|
package/sync/share.mjs
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What leaves the machine, and what never does.
|
|
3
|
+
*
|
|
4
|
+
* The local ledger holds prompts, file paths and shell commands. A shared pool
|
|
5
|
+
* is, by definition, somewhere other people can read — so the unit of sharing is
|
|
6
|
+
* NOT the ledger. It is the daily rollup, with the identifying fields removed
|
|
7
|
+
* before it is written.
|
|
8
|
+
*
|
|
9
|
+
* That choice is deliberate on three counts:
|
|
10
|
+
* • A rollup is already a count of things, not a copy of them. There is no
|
|
11
|
+
* prompt text in it to leak, because none was ever folded in.
|
|
12
|
+
* • It is a few kilobytes a day, so a poll every thirty seconds is free.
|
|
13
|
+
* • It carries every number the team view needs, so nothing is lost by
|
|
14
|
+
* refusing to ship the records themselves.
|
|
15
|
+
*
|
|
16
|
+
* Scopes:
|
|
17
|
+
* summary (default) counts, tokens, time, hours. No paths, no project ids,
|
|
18
|
+
* no per-tool breakdown.
|
|
19
|
+
* detailed adds project ids and the tool mix, for teams who have agreed to
|
|
20
|
+
* it. Still never prompt text, a file path, a command, or a session
|
|
21
|
+
* id — those are refused at every scope, because a scope is a dial
|
|
22
|
+
* for detail and not a way to opt out of the promise.
|
|
23
|
+
*
|
|
24
|
+
* Sanitisation is SUBTRACTIVE ONLY. A key is dropped, never rewritten, so a
|
|
25
|
+
* reader can always tell "zero" from "not shared". Where a count has to survive
|
|
26
|
+
* an id being removed it is added under a new `…Count` name rather than
|
|
27
|
+
* overwriting the field it came from.
|
|
28
|
+
*
|
|
29
|
+
* Paths may name a nested key with `*` for "every entry", because the leak this
|
|
30
|
+
* module exists to prevent hid one level down: volume.bySource carries a
|
|
31
|
+
* per-agent breakdown, and each of its buckets carried the session ids.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
/** Refused at every scope. Nothing on this list is ever shared. */
|
|
35
|
+
const NEVER = {
|
|
36
|
+
volume: ['files', 'sessions', 'bySource.*.sessions'],
|
|
37
|
+
tools: ['targets', 'commands'],
|
|
38
|
+
rework: ['churn', 'storms'],
|
|
39
|
+
friction: ['byTarget'],
|
|
40
|
+
context: ['sessionsWithCompact'],
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/** Dropped as well at `summary` — useful detail, but it describes your work. */
|
|
44
|
+
const SUMMARY_ONLY = {
|
|
45
|
+
volume: ['projects', 'bySource'],
|
|
46
|
+
tools: ['byTool'],
|
|
47
|
+
friction: ['byTool'],
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* @param {object} rollup from analytics/rollup.mjs buildDay()
|
|
52
|
+
* @param {'summary'|'detailed'} scope
|
|
53
|
+
* @returns {object|null} a rollup of the same shape, with fields removed
|
|
54
|
+
*/
|
|
55
|
+
export function sanitise(rollup, scope = 'summary') {
|
|
56
|
+
if (!rollup?.metrics) return null;
|
|
57
|
+
|
|
58
|
+
const metrics = {};
|
|
59
|
+
for (const [name, metric] of Object.entries(rollup.metrics)) {
|
|
60
|
+
const paths = scope === 'detailed'
|
|
61
|
+
? (NEVER[name] ?? [])
|
|
62
|
+
: [...(NEVER[name] ?? []), ...(SUMMARY_ONLY[name] ?? [])];
|
|
63
|
+
metrics[name] = strip(metric, paths);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Counts survive their ids. "Four sessions" is what the team view divides by
|
|
67
|
+
// and it says nothing about which four.
|
|
68
|
+
if (rollup.metrics.volume) {
|
|
69
|
+
metrics.volume.sessionCount = (rollup.metrics.volume.sessions ?? []).length;
|
|
70
|
+
metrics.volume.projectCount = (rollup.metrics.volume.projects ?? []).length;
|
|
71
|
+
metrics.volume.fileCount = (rollup.metrics.volume.files ?? []).length;
|
|
72
|
+
}
|
|
73
|
+
if (rollup.metrics.context) {
|
|
74
|
+
metrics.context.sessionsWithCompactCount = (rollup.metrics.context.sessionsWithCompact ?? []).length;
|
|
75
|
+
}
|
|
76
|
+
if (rollup.metrics.rework) {
|
|
77
|
+
// The storms themselves name a command and never travel; the count does,
|
|
78
|
+
// because the score reads it and a missing one reads as "no storms".
|
|
79
|
+
metrics.rework.stormCount = (rollup.metrics.rework.storms ?? []).length;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
day: rollup.day,
|
|
84
|
+
version: rollup.version,
|
|
85
|
+
records: rollup.records,
|
|
86
|
+
builtAt: rollup.builtAt,
|
|
87
|
+
final: rollup.final,
|
|
88
|
+
scope,
|
|
89
|
+
metrics,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Remove each path, copying only as deep as a removal actually reaches. */
|
|
94
|
+
function strip(object, paths) {
|
|
95
|
+
if (!object || typeof object !== 'object') return object;
|
|
96
|
+
let out = { ...object };
|
|
97
|
+
for (const path of paths) out = drop(out, path.split('.'));
|
|
98
|
+
return out;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function drop(node, segments) {
|
|
102
|
+
if (!node || typeof node !== 'object') return node;
|
|
103
|
+
const [head, ...rest] = segments;
|
|
104
|
+
|
|
105
|
+
if (!rest.length) {
|
|
106
|
+
if (head === '*') return node;
|
|
107
|
+
const out = { ...node };
|
|
108
|
+
delete out[head];
|
|
109
|
+
return out;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (head === '*') {
|
|
113
|
+
const out = Array.isArray(node) ? [...node] : { ...node };
|
|
114
|
+
for (const key of Object.keys(out)) out[key] = drop(out[key], rest);
|
|
115
|
+
return out;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (!(head in node)) return node;
|
|
119
|
+
return { ...node, [head]: drop(node[head], rest) };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The card a device leaves beside its rollups.
|
|
124
|
+
*
|
|
125
|
+
* Written on every publish rather than once at enrolment, so `lastSeen` is
|
|
126
|
+
* meaningful: a shelf that has stopped being updated is visibly stale rather
|
|
127
|
+
* than silently indistinguishable from a quiet day.
|
|
128
|
+
*/
|
|
129
|
+
export function manifestFor(identity, { scope, version, days }) {
|
|
130
|
+
return {
|
|
131
|
+
deviceId: identity.deviceId,
|
|
132
|
+
name: identity.name,
|
|
133
|
+
host: identity.host,
|
|
134
|
+
os: identity.os,
|
|
135
|
+
scope,
|
|
136
|
+
version,
|
|
137
|
+
days,
|
|
138
|
+
firstSeen: identity.createdAt,
|
|
139
|
+
lastSeen: Date.now(),
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export { NEVER, SUMMARY_ONLY };
|