editmamei 1.5.0 → 1.5.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 +1 -0
- package/dist/bin/editmamei-core-darwin-arm64 +0 -0
- package/dist/bin/editmamei-core-darwin-x64 +0 -0
- package/dist/bin/editmamei-core-win-x64.exe +0 -0
- package/dist/core/server.js +48 -7
- package/dist/kernel/module-lifecycle.js +69 -9
- package/dist/license/advisory.js +109 -0
- package/dist/license/entitlement.js +34 -6
- package/dist/license/polar-client.js +50 -7
- package/dist/license/store.js +59 -0
- package/dist/skills/editmamei-skill.zip +0 -0
- package/dist/telemetry/client.js +55 -17
- package/dist/telemetry/events.js +5 -0
- package/dist/telemetry/outbox.js +43 -18
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -150,6 +150,7 @@ The source in this repository is the same code published to npm, so none of the
|
|
|
150
150
|
- **Install:** [docs/installation.md](docs/installation.md)
|
|
151
151
|
- **Getting started:** [docs/getting-started.md](docs/getting-started.md)
|
|
152
152
|
- **FAQ:** [docs/faq.md](docs/faq.md)
|
|
153
|
+
- **Troubleshooting:** [docs/troubleshooting.md](docs/troubleshooting.md)
|
|
153
154
|
- **Pro features:** [docs/pro-features.md](docs/pro-features.md)
|
|
154
155
|
- **Roadmap:** [docs/roadmap.md](docs/roadmap.md)
|
|
155
156
|
- **Bugs and feature requests:** [the issue tracker](https://github.com/editmamei/editmamei/issues). If something's broken, ask your assistant to "report a problem" (or run `editmamei report`) to drop an anonymized diagnostic bundle in your Downloads folder, then attach it to the issue.
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/dist/core/server.js
CHANGED
|
@@ -19,6 +19,7 @@ import { createHash } from 'node:crypto';
|
|
|
19
19
|
import { GoSnippetClient, coreBinaryName } from '../api/snippet-client.js';
|
|
20
20
|
import { isProEntitled } from '../license/entitlement.js';
|
|
21
21
|
import { createPingLicenseRefresher } from '../license/ping-refresh.js';
|
|
22
|
+
import { licenseAdvisory } from '../license/advisory.js';
|
|
22
23
|
import { runScript } from '../utils/run-script.js';
|
|
23
24
|
import { listTemplates } from '../utils/template-storage.js';
|
|
24
25
|
import { toolErrorResult } from '../utils/tool-helpers.js';
|
|
@@ -84,7 +85,12 @@ export class EditmameiServer {
|
|
|
84
85
|
updateNoticeShown = false;
|
|
85
86
|
snippetClient = new GoSnippetClient();
|
|
86
87
|
refreshLicenseOnPing = createPingLicenseRefresher();
|
|
88
|
+
moduleUpdateFailed = false;
|
|
89
|
+
licenseAdvisorySource;
|
|
90
|
+
licenseStoreOptions;
|
|
91
|
+
licenseAdvisoryShown = false;
|
|
87
92
|
constructor(opts = {}) {
|
|
93
|
+
this.licenseStoreOptions = opts.licenseStore ?? {};
|
|
88
94
|
this.logger = new Logger('EditmameiServer');
|
|
89
95
|
this.session = new Session();
|
|
90
96
|
this.sessionLog = new SessionLog(this.session.getSessionId());
|
|
@@ -189,9 +195,29 @@ export class EditmameiServer {
|
|
|
189
195
|
capRoots: caps?.roots != null,
|
|
190
196
|
});
|
|
191
197
|
};
|
|
198
|
+
this.licenseAdvisorySource =
|
|
199
|
+
opts.licenseAdvisory ??
|
|
200
|
+
((advisoryOpts) => {
|
|
201
|
+
if (process.env.VITEST !== undefined || process.env.NODE_ENV === 'test')
|
|
202
|
+
return null;
|
|
203
|
+
return licenseAdvisory(advisoryOpts);
|
|
204
|
+
});
|
|
192
205
|
this.registerTools();
|
|
193
206
|
this.setupHandlers();
|
|
194
207
|
}
|
|
208
|
+
buildLicenseAdvisoryNote() {
|
|
209
|
+
if (this.licenseAdvisoryShown)
|
|
210
|
+
return { note: '', stamp: () => { } };
|
|
211
|
+
const text = this.licenseAdvisorySource({ moduleUpdateFailed: this.moduleUpdateFailed });
|
|
212
|
+
if (!text)
|
|
213
|
+
return { note: '', stamp: () => { } };
|
|
214
|
+
return {
|
|
215
|
+
note: ' ' + text,
|
|
216
|
+
stamp: () => {
|
|
217
|
+
this.licenseAdvisoryShown = true;
|
|
218
|
+
},
|
|
219
|
+
};
|
|
220
|
+
}
|
|
195
221
|
registerTools() {
|
|
196
222
|
this.toolRegistry.register('ps_ping', {
|
|
197
223
|
tool: {
|
|
@@ -274,9 +300,14 @@ export class EditmameiServer {
|
|
|
274
300
|
this.moduleLifecycle = new ModuleLifecycle({
|
|
275
301
|
toolRegistry: this.toolRegistry,
|
|
276
302
|
logger: this.logger,
|
|
303
|
+
licenseStore: this.licenseStoreOptions,
|
|
277
304
|
assertToolsClassified: () => this.assertToolsClassified(),
|
|
278
305
|
classifyTool: (name) => this.classifyTool(name),
|
|
279
|
-
onModuleUpdate: (outcome) =>
|
|
306
|
+
onModuleUpdate: (outcome) => {
|
|
307
|
+
if (outcome === 'failed')
|
|
308
|
+
this.moduleUpdateFailed = true;
|
|
309
|
+
this.telemetry.setModuleUpdate(outcome);
|
|
310
|
+
},
|
|
280
311
|
});
|
|
281
312
|
const proModule = this.moduleLifecycle.resolveProModule();
|
|
282
313
|
this.kernel = new Kernel({
|
|
@@ -425,6 +456,11 @@ export class EditmameiServer {
|
|
|
425
456
|
if (this.updateCheck)
|
|
426
457
|
await this.updateCheck;
|
|
427
458
|
const update = await this.buildUpdateNotice();
|
|
459
|
+
const advisory = this.buildLicenseAdvisoryNote();
|
|
460
|
+
const withAdvisory = (text) => {
|
|
461
|
+
advisory.stamp();
|
|
462
|
+
return text + advisory.note;
|
|
463
|
+
};
|
|
428
464
|
const connection = this.session.getConnection();
|
|
429
465
|
let version = 'Unknown';
|
|
430
466
|
let actionSetsCount = 0;
|
|
@@ -451,8 +487,8 @@ export class EditmameiServer {
|
|
|
451
487
|
content: [
|
|
452
488
|
{
|
|
453
489
|
type: 'text',
|
|
454
|
-
text: 'Photoshop does not appear to be running. Start Photoshop, then call ps_ping again.' +
|
|
455
|
-
update.note,
|
|
490
|
+
text: withAdvisory('Photoshop does not appear to be running. Start Photoshop, then call ps_ping again.' +
|
|
491
|
+
update.note),
|
|
456
492
|
},
|
|
457
493
|
],
|
|
458
494
|
structuredContent: {
|
|
@@ -478,7 +514,7 @@ export class EditmameiServer {
|
|
|
478
514
|
content: [
|
|
479
515
|
{
|
|
480
516
|
type: 'text',
|
|
481
|
-
text: (reason ?? 'Photoshop did not respond') + update.note,
|
|
517
|
+
text: withAdvisory((reason ?? 'Photoshop did not respond') + update.note),
|
|
482
518
|
},
|
|
483
519
|
],
|
|
484
520
|
structuredContent: {
|
|
@@ -501,7 +537,12 @@ export class EditmameiServer {
|
|
|
501
537
|
this.lastDocDepth = null;
|
|
502
538
|
this.lastDocMode = null;
|
|
503
539
|
return {
|
|
504
|
-
content: [
|
|
540
|
+
content: [
|
|
541
|
+
{
|
|
542
|
+
type: 'text',
|
|
543
|
+
text: withAdvisory('Photoshop did not respond' + update.note),
|
|
544
|
+
},
|
|
545
|
+
],
|
|
505
546
|
structuredContent: {
|
|
506
547
|
connected: false,
|
|
507
548
|
update_available: this.updateInfo,
|
|
@@ -567,11 +608,11 @@ export class EditmameiServer {
|
|
|
567
608
|
content: [
|
|
568
609
|
{
|
|
569
610
|
type: 'text',
|
|
570
|
-
text: `Connected to Photoshop (v${version}). ` +
|
|
611
|
+
text: withAdvisory(`Connected to Photoshop (v${version}). ` +
|
|
571
612
|
`${actionSetsCount} custom action set(s), ${userTemplates} saved template(s), ` +
|
|
572
613
|
`${openDocuments.length} open document(s)${openDocuments.length ? ': ' + openDocuments.join(', ') : ''}` +
|
|
573
614
|
`${degradedNote}.` +
|
|
574
|
-
update.note,
|
|
615
|
+
update.note),
|
|
575
616
|
},
|
|
576
617
|
],
|
|
577
618
|
structuredContent: {
|
|
@@ -3,12 +3,18 @@ import { pathToFileURL } from 'node:url';
|
|
|
3
3
|
import { existsSync } from 'node:fs';
|
|
4
4
|
import { EDITION } from '../edition.js';
|
|
5
5
|
import { resolveProBinaryPath } from '../api/snippet-client.js';
|
|
6
|
-
import { isProEntitled } from '../license/entitlement.js';
|
|
6
|
+
import { isProEntitled, REFRESH_AFTER_MS } from '../license/entitlement.js';
|
|
7
|
+
import { licenseAdvisory, PRO_RESTART_TO_LOAD } from '../license/advisory.js';
|
|
8
|
+
import { toolsInTier } from '../core/tool-tiers.js';
|
|
7
9
|
import { loadVerifiedModule, readInstalledModule, installedPath, PRO_SKU, } from '../delivery/store.js';
|
|
8
10
|
import { provisionModules, compareVersions, VERSION_RE, } from '../delivery/provision.js';
|
|
9
|
-
import { readLicense } from '../license/store.js';
|
|
11
|
+
import { readLicense, readCheckState, updateCheckState, } from '../license/store.js';
|
|
10
12
|
import { HOST_MIN_ABI, KERNEL_ABI } from './host-api.js';
|
|
11
13
|
import { VERSION } from '../version.js';
|
|
14
|
+
export const MODULE_FRESH_AFTER_MS = REFRESH_AFTER_MS;
|
|
15
|
+
export const MODULE_RETRY_AFTER_FAILURE_MS = 15 * 60_000;
|
|
16
|
+
const PRO_STUB_DESCRIPTION = 'Unavailable right now: this Editmamei Pro tool needs an active license on this machine. ' +
|
|
17
|
+
'Call it to get the reason and how to restore it, then relay that to the user.';
|
|
12
18
|
export function classifyModuleOutcome(inputs) {
|
|
13
19
|
if (inputs.proModuleLoaded && inputs.skipReason === null)
|
|
14
20
|
return 'loaded';
|
|
@@ -26,6 +32,9 @@ export class ModuleLifecycle {
|
|
|
26
32
|
constructor(deps) {
|
|
27
33
|
this.deps = deps;
|
|
28
34
|
}
|
|
35
|
+
get store() {
|
|
36
|
+
return this.deps.licenseStore ?? {};
|
|
37
|
+
}
|
|
29
38
|
get proModule() {
|
|
30
39
|
return this._proModule;
|
|
31
40
|
}
|
|
@@ -36,7 +45,7 @@ export class ModuleLifecycle {
|
|
|
36
45
|
this.kernel = kernel;
|
|
37
46
|
}
|
|
38
47
|
resolveProModule() {
|
|
39
|
-
if (isProEntitled()) {
|
|
48
|
+
if (isProEntitled(this.store)) {
|
|
40
49
|
const verified = loadVerifiedModule(PRO_SKU);
|
|
41
50
|
if (verified) {
|
|
42
51
|
const installed = readInstalledModule(PRO_SKU);
|
|
@@ -71,8 +80,10 @@ export class ModuleLifecycle {
|
|
|
71
80
|
return null;
|
|
72
81
|
}
|
|
73
82
|
async loadModules() {
|
|
74
|
-
if (!this._proModule)
|
|
83
|
+
if (!this._proModule) {
|
|
84
|
+
this.registerLapsedProStubs();
|
|
75
85
|
return;
|
|
86
|
+
}
|
|
76
87
|
if (this._proModule.abi !== null && this._proModule.abi < HOST_MIN_ABI) {
|
|
77
88
|
this.deps.logger.warn(`Pro module (abi ${this._proModule.abi}) is older than this host requires ` +
|
|
78
89
|
`(min abi ${HOST_MIN_ABI}) — booting Community; will re-provision in the background.`);
|
|
@@ -128,8 +139,46 @@ export class ModuleLifecycle {
|
|
|
128
139
|
this._moduleSkipReason = 'incompatible';
|
|
129
140
|
}
|
|
130
141
|
}
|
|
142
|
+
registerLapsedProStubs() {
|
|
143
|
+
if (readLicense(this.store) === null)
|
|
144
|
+
return;
|
|
145
|
+
if (isProEntitled(this.store))
|
|
146
|
+
return;
|
|
147
|
+
const names = [];
|
|
148
|
+
for (const name of toolsInTier('pro')) {
|
|
149
|
+
try {
|
|
150
|
+
this.deps.classifyTool(name);
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
this.deps.logger.warn(`Skipping Pro stub for unclassified tool '${name}'.`);
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
names.push(name);
|
|
157
|
+
this.deps.toolRegistry.register(name, {
|
|
158
|
+
tool: {
|
|
159
|
+
name,
|
|
160
|
+
description: PRO_STUB_DESCRIPTION,
|
|
161
|
+
inputSchema: { type: 'object', properties: {} },
|
|
162
|
+
annotations: { title: name, readOnlyHint: true, idempotentHint: true },
|
|
163
|
+
},
|
|
164
|
+
handler: async () => ({
|
|
165
|
+
content: [
|
|
166
|
+
{
|
|
167
|
+
type: 'text',
|
|
168
|
+
text: isProEntitled(this.store)
|
|
169
|
+
? PRO_RESTART_TO_LOAD
|
|
170
|
+
: (licenseAdvisory(this.store) ?? PRO_RESTART_TO_LOAD),
|
|
171
|
+
},
|
|
172
|
+
],
|
|
173
|
+
isError: true,
|
|
174
|
+
}),
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
this.deps.logger.info(`Pro is not unlocked on this machine — registered ${names.length} Pro tool name(s) ` +
|
|
178
|
+
`as explain-only stubs so the reason is answerable.`);
|
|
179
|
+
}
|
|
131
180
|
computeModuleStatus() {
|
|
132
|
-
if (!readLicense())
|
|
181
|
+
if (!readLicense(this.store))
|
|
133
182
|
return null;
|
|
134
183
|
const installed = readInstalledModule(PRO_SKU);
|
|
135
184
|
return {
|
|
@@ -137,7 +186,7 @@ export class ModuleLifecycle {
|
|
|
137
186
|
outcome: classifyModuleOutcome({
|
|
138
187
|
proModuleLoaded: this._proModule !== null,
|
|
139
188
|
skipReason: this._moduleSkipReason,
|
|
140
|
-
entitled: isProEntitled(),
|
|
189
|
+
entitled: isProEntitled(this.store),
|
|
141
190
|
}),
|
|
142
191
|
module_version: installed?.version ?? null,
|
|
143
192
|
abi: installed?.abi ?? null,
|
|
@@ -147,7 +196,7 @@ export class ModuleLifecycle {
|
|
|
147
196
|
const reason = this._moduleSkipReason;
|
|
148
197
|
if (reason === null)
|
|
149
198
|
return;
|
|
150
|
-
const license = readLicense();
|
|
199
|
+
const license = readLicense(this.store);
|
|
151
200
|
if (!license) {
|
|
152
201
|
this.deps.logger.warn('A Pro module was skipped but no cached license was found — staying Community. ' +
|
|
153
202
|
'Run `editmamei activate <key>` to restore Pro.');
|
|
@@ -209,14 +258,22 @@ export class ModuleLifecycle {
|
|
|
209
258
|
async ensureEntitledModuleFresh(delivery = {}) {
|
|
210
259
|
if (this._moduleSkipReason !== null)
|
|
211
260
|
return;
|
|
212
|
-
if (!isProEntitled())
|
|
261
|
+
if (!isProEntitled(this.store))
|
|
213
262
|
return;
|
|
214
263
|
const underTest = process.env.VITEST !== undefined || process.env.NODE_ENV === 'test';
|
|
215
264
|
if (underTest && !delivery.fetchImpl)
|
|
216
265
|
return;
|
|
217
|
-
const license = readLicense();
|
|
266
|
+
const license = readLicense(this.store);
|
|
218
267
|
if (!license)
|
|
219
268
|
return;
|
|
269
|
+
const now = (delivery.now ?? Date.now)();
|
|
270
|
+
const freshAfterMs = delivery.freshAfterMs ?? MODULE_FRESH_AFTER_MS;
|
|
271
|
+
const retryAfter = readCheckState(this.store).module_retry_after;
|
|
272
|
+
if (retryAfter !== undefined && now < retryAfter && retryAfter - now <= freshAfterMs)
|
|
273
|
+
return;
|
|
274
|
+
const alreadyInstalled = readInstalledModule(PRO_SKU) !== null;
|
|
275
|
+
const failureWaitMs = alreadyInstalled ? freshAfterMs : MODULE_RETRY_AFTER_FAILURE_MS;
|
|
276
|
+
updateCheckState({ module_retry_after: now + failureWaitMs }, this.store);
|
|
220
277
|
let outcome = null;
|
|
221
278
|
try {
|
|
222
279
|
const prov = await provisionModules(license.key, {
|
|
@@ -249,6 +306,9 @@ export class ModuleLifecycle {
|
|
|
249
306
|
`${err instanceof Error ? err.message : String(err)}`);
|
|
250
307
|
outcome = 'failed';
|
|
251
308
|
}
|
|
309
|
+
if (outcome !== 'failed') {
|
|
310
|
+
updateCheckState({ module_retry_after: now + freshAfterMs }, this.store);
|
|
311
|
+
}
|
|
252
312
|
if (outcome !== null) {
|
|
253
313
|
try {
|
|
254
314
|
this.deps.onModuleUpdate?.(outcome);
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { readLicense, readCheckState, } from './store.js';
|
|
2
|
+
import { evaluateEntitlement, GRACE_MS, REFRESH_AFTER_MS, } from './entitlement.js';
|
|
3
|
+
export const PRO_RESTART_TO_LOAD = 'Your Pro license is active again, but this session started without it. ' +
|
|
4
|
+
'Restart your MCP client to load the Pro tools.';
|
|
5
|
+
function isoDay(ms) {
|
|
6
|
+
if (!Number.isFinite(ms))
|
|
7
|
+
return null;
|
|
8
|
+
let iso;
|
|
9
|
+
try {
|
|
10
|
+
iso = new Date(ms).toISOString();
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
return /^\d{4}-/.test(iso) ? iso.slice(0, 10) : null;
|
|
16
|
+
}
|
|
17
|
+
export function licenseAdvisory(opts = {}) {
|
|
18
|
+
const now = (opts.now ?? Date.now)();
|
|
19
|
+
const rec = readLicense(opts);
|
|
20
|
+
if (!rec)
|
|
21
|
+
return null;
|
|
22
|
+
const entitlement = evaluateEntitlement(rec, now);
|
|
23
|
+
const last = Date.parse(rec.last_validated_at);
|
|
24
|
+
const stale = !Number.isFinite(last) || now - last > REFRESH_AFTER_MS;
|
|
25
|
+
const retryAfter = readCheckState(opts).validate_retry_after;
|
|
26
|
+
const checksFailing = retryAfter !== undefined && retryAfter > now;
|
|
27
|
+
if (entitlement.entitled && !(stale && checksFailing) && opts.moduleUpdateFailed !== true) {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
return advisoryText(rec, entitlement, now, {
|
|
31
|
+
checksFailing,
|
|
32
|
+
checksNotCompleting: stale && checksFailing,
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
const NOT_UNLOCKING = 'Pro is not unlocking.';
|
|
36
|
+
const RESTART_LOOP_CAUSE_AND_FIX = 'Most likely cause: this client is restarting faster than the license check can finish. ' +
|
|
37
|
+
'Fix: quit the client fully, wait a minute, then start it once and leave it running.';
|
|
38
|
+
const GIVE_IT_TIME = 'Pro may take a few hours to come back rather than returning at the next start, so leave ' +
|
|
39
|
+
'the client alone instead of restarting it again.';
|
|
40
|
+
function waitCaveat(facts) {
|
|
41
|
+
return facts.checksFailing ? ` ${GIVE_IT_TIME}` : '';
|
|
42
|
+
}
|
|
43
|
+
const RESTART_TO_LOAD = 'restart your MCP client, because the Pro tools only load when it starts';
|
|
44
|
+
const MODULE_UPDATE_FAILED = 'Pro is unlocked, but an update to its module did not finish. The version already ' +
|
|
45
|
+
'installed keeps working and the update is tried again on its own, so there is nothing to ' +
|
|
46
|
+
'do now. If the Pro tools do stop working, run `editmamei repair` in a terminal and then ' +
|
|
47
|
+
'restart your MCP client.';
|
|
48
|
+
const SUPPORT_TAIL = 'If that does not bring Pro back, run `editmamei license` in a terminal and send the output ' +
|
|
49
|
+
'to support@editmamei.com.';
|
|
50
|
+
function checkInWindow(rec, now, entitled) {
|
|
51
|
+
const last = Date.parse(rec.last_validated_at);
|
|
52
|
+
const graceEndsAt = last + GRACE_MS;
|
|
53
|
+
const lastDay = isoDay(last);
|
|
54
|
+
const graceDay = isoDay(graceEndsAt);
|
|
55
|
+
if (lastDay === null || graceDay === null)
|
|
56
|
+
return '';
|
|
57
|
+
return entitled
|
|
58
|
+
? ` Your license last checked in on ${lastDay}, and Pro locks on ` +
|
|
59
|
+
`${graceDay} if it cannot check in before then.`
|
|
60
|
+
: ` Your license last checked in on ${lastDay} and the offline grace window ` +
|
|
61
|
+
`${graceEndsAt <= now ? 'closed' : 'closes'} on ${graceDay}.`;
|
|
62
|
+
}
|
|
63
|
+
function endedSentence(rec) {
|
|
64
|
+
const day = rec.expires_at === null ? null : isoDay(Date.parse(rec.expires_at));
|
|
65
|
+
return day === null
|
|
66
|
+
? 'This license has an end date that has passed, and restarting on its own will not extend it.'
|
|
67
|
+
: `This license ended on ${day}, and restarting on its own will not extend it.`;
|
|
68
|
+
}
|
|
69
|
+
function clockIsBehind(rec, now) {
|
|
70
|
+
const last = Date.parse(rec.last_validated_at);
|
|
71
|
+
return Number.isFinite(last) && last + GRACE_MS >= now;
|
|
72
|
+
}
|
|
73
|
+
const CLOCK_BEHIND = `${NOT_UNLOCKING} This machine's clock is set earlier than a date Editmamei has already ` +
|
|
74
|
+
'recorded here, usually because it was once set ahead, so the license stored on it cannot be ' +
|
|
75
|
+
'read as current. Fix: correct the system clock, ' +
|
|
76
|
+
'then, while connected to the internet, run `editmamei deactivate` followed by ' +
|
|
77
|
+
`\`editmamei activate YOUR-KEY\` in a terminal and ${RESTART_TO_LOAD}. Running activate on ` +
|
|
78
|
+
`its own is not enough here, because it keeps the stored record. ${SUPPORT_TAIL}`;
|
|
79
|
+
function advisoryText(rec, entitlement, now, facts) {
|
|
80
|
+
if (entitlement.entitled) {
|
|
81
|
+
if (!facts.checksNotCompleting)
|
|
82
|
+
return MODULE_UPDATE_FAILED;
|
|
83
|
+
return ('Pro is unlocked, but its background license and update checks are not completing.' +
|
|
84
|
+
`${checkInWindow(rec, now, true)} ${RESTART_LOOP_CAUSE_AND_FIX}` +
|
|
85
|
+
`${waitCaveat(facts)} ${SUPPORT_TAIL}`);
|
|
86
|
+
}
|
|
87
|
+
switch (entitlement.reason) {
|
|
88
|
+
case 'revoked':
|
|
89
|
+
case 'disabled':
|
|
90
|
+
return (`${NOT_UNLOCKING} This license is no longer active, and restarting on its own will not ` +
|
|
91
|
+
'bring it back. Fix: check your subscription status. Once it is running again, run ' +
|
|
92
|
+
'`editmamei license` in a terminal to re-check the license and update this machine, ' +
|
|
93
|
+
`then ${RESTART_TO_LOAD}. If it should be running already, send that output to ` +
|
|
94
|
+
'support@editmamei.com.');
|
|
95
|
+
case 'expired':
|
|
96
|
+
return (`${NOT_UNLOCKING} ${endedSentence(rec)} Fix: renew it, then run ` +
|
|
97
|
+
`\`editmamei activate YOUR-KEY\` in a terminal and ${RESTART_TO_LOAD}. If the renewal ` +
|
|
98
|
+
'has already gone through, run `editmamei license` instead to re-check the license and ' +
|
|
99
|
+
'update this machine, then restart the client; if Pro is still missing, send that ' +
|
|
100
|
+
'output to support@editmamei.com.');
|
|
101
|
+
case 'grace-expired':
|
|
102
|
+
case 'granted':
|
|
103
|
+
case 'no-license':
|
|
104
|
+
if (clockIsBehind(rec, now))
|
|
105
|
+
return CLOCK_BEHIND;
|
|
106
|
+
return (`${NOT_UNLOCKING}${checkInWindow(rec, now, false)} ${RESTART_LOOP_CAUSE_AND_FIX}` +
|
|
107
|
+
`${waitCaveat(facts)} ${SUPPORT_TAIL}`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
import { resolvePolarConfig } from './config.js';
|
|
2
2
|
import { computeDeviceHash } from './device-hash.js';
|
|
3
|
-
import { readLicense, writeLicense, clearLicense, nextHighWaterMark, } from './store.js';
|
|
4
|
-
import { PolarLicenseClient, PolarLicenseError } from './polar-client.js';
|
|
3
|
+
import { readLicense, writeLicense, clearLicense, readCheckState, updateCheckState, nextHighWaterMark, } from './store.js';
|
|
4
|
+
import { PolarLicenseClient, PolarLicenseError, unrefSleep, } from './polar-client.js';
|
|
5
5
|
import { Logger } from '../utils/logger.js';
|
|
6
6
|
const logger = new Logger('License');
|
|
7
7
|
export const GRACE_MS = 7 * 24 * 60 * 60 * 1000;
|
|
8
8
|
export const REFRESH_AFTER_MS = 24 * 60 * 60 * 1000;
|
|
9
9
|
export const EXPIRED_REFRESH_TIMEOUT_MS = 5_000;
|
|
10
10
|
export const CLOCK_SKEW_TOLERANCE_MS = 24 * 60 * 60 * 1000;
|
|
11
|
+
export const VALIDATE_BACKOFF_MIN_MS = 60_000;
|
|
12
|
+
export const VALIDATE_BACKOFF_DEFAULT_MS = 15 * 60_000;
|
|
13
|
+
export const VALIDATE_BACKOFF_MAX_MS = 6 * 60 * 60 * 1000;
|
|
11
14
|
export function evaluateEntitlement(rec, now) {
|
|
12
15
|
if (!rec)
|
|
13
16
|
return { entitled: false, reason: 'no-license' };
|
|
@@ -37,7 +40,18 @@ function makeClient(ops) {
|
|
|
37
40
|
throw new PolarLicenseError(`Licensing is not configured for the '${cfg.env}' environment. ` +
|
|
38
41
|
`Set EDITMAMEI_POLAR_ENV=sandbox to test against the sandbox org.`, 0, 'not_configured');
|
|
39
42
|
}
|
|
40
|
-
return {
|
|
43
|
+
return {
|
|
44
|
+
polar: new PolarLicenseClient(cfg, ops.fetchImpl ?? defaultFetch, ops.client ?? {}),
|
|
45
|
+
cfg,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
function noteValidateFailure(err, ops) {
|
|
49
|
+
if (err instanceof PolarLicenseError && err.code === 'not_configured')
|
|
50
|
+
return;
|
|
51
|
+
const now = (ops.now ?? Date.now)();
|
|
52
|
+
const supplied = err instanceof PolarLicenseError ? err.retryAfterMs : undefined;
|
|
53
|
+
const waitMs = Math.min(Math.max(supplied ?? VALIDATE_BACKOFF_DEFAULT_MS, VALIDATE_BACKOFF_MIN_MS), VALIDATE_BACKOFF_MAX_MS);
|
|
54
|
+
updateCheckState({ validate_retry_after: now + waitMs }, ops);
|
|
41
55
|
}
|
|
42
56
|
export function isProEntitled(ops = {}) {
|
|
43
57
|
return evaluateEntitlement(readLicense(ops), Date.now()).entitled;
|
|
@@ -70,6 +84,7 @@ export async function activate(key, ops = {}) {
|
|
|
70
84
|
high_water_mark: nextHighWaterMark(existing, nowMs),
|
|
71
85
|
};
|
|
72
86
|
writeLicense(rec, ops);
|
|
87
|
+
updateCheckState({ validate_retry_after: null }, ops);
|
|
73
88
|
return rec;
|
|
74
89
|
}
|
|
75
90
|
export async function refreshIfStale(ops = {}) {
|
|
@@ -89,16 +104,28 @@ export async function refreshIfStale(ops = {}) {
|
|
|
89
104
|
const clockSkewed = age < -CLOCK_SKEW_TOLERANCE_MS;
|
|
90
105
|
if (!clockSkewed && age <= REFRESH_AFTER_MS)
|
|
91
106
|
return;
|
|
107
|
+
const backoffUntil = readCheckState(ops).validate_retry_after;
|
|
108
|
+
if (backoffUntil !== undefined) {
|
|
109
|
+
const waitMs = backoffUntil - now;
|
|
110
|
+
if (waitMs > 0 && waitMs <= VALIDATE_BACKOFF_MAX_MS)
|
|
111
|
+
return;
|
|
112
|
+
updateCheckState({ validate_retry_after: null }, ops);
|
|
113
|
+
}
|
|
114
|
+
const bootOps = {
|
|
115
|
+
...ops,
|
|
116
|
+
client: { sleep: unrefSleep, ...ops.client },
|
|
117
|
+
};
|
|
92
118
|
if (clockSkewed || age <= GRACE_MS) {
|
|
93
|
-
refresh(
|
|
119
|
+
refresh(bootOps).catch((err) => {
|
|
120
|
+
noteValidateFailure(err, ops);
|
|
94
121
|
logger.warn(`Background license refresh failed (grace covers offline use): ` +
|
|
95
122
|
`${err instanceof Error ? err.message : String(err)}`);
|
|
96
123
|
});
|
|
97
124
|
return;
|
|
98
125
|
}
|
|
99
126
|
const timeoutMs = ops.expiredRefreshTimeoutMs ?? EXPIRED_REFRESH_TIMEOUT_MS;
|
|
100
|
-
const attempt = refresh(
|
|
101
|
-
attempt.catch(() =>
|
|
127
|
+
const attempt = refresh(bootOps);
|
|
128
|
+
attempt.catch((err) => noteValidateFailure(err, ops));
|
|
102
129
|
let timer;
|
|
103
130
|
try {
|
|
104
131
|
const winner = await Promise.race([
|
|
@@ -138,6 +165,7 @@ export async function refresh(ops = {}) {
|
|
|
138
165
|
high_water_mark: nextHighWaterMark(rec, nowMs),
|
|
139
166
|
};
|
|
140
167
|
writeLicense(updated, ops);
|
|
168
|
+
updateCheckState({ validate_retry_after: null }, ops);
|
|
141
169
|
return updated;
|
|
142
170
|
}
|
|
143
171
|
export async function deactivate(ops = {}) {
|
|
@@ -1,26 +1,35 @@
|
|
|
1
1
|
export class PolarLicenseError extends Error {
|
|
2
2
|
httpStatus;
|
|
3
3
|
code;
|
|
4
|
-
|
|
4
|
+
retryAfterMs;
|
|
5
|
+
constructor(message, httpStatus, code, retryAfterMs) {
|
|
5
6
|
super(message);
|
|
6
7
|
this.httpStatus = httpStatus;
|
|
7
8
|
this.code = code;
|
|
9
|
+
this.retryAfterMs = retryAfterMs;
|
|
8
10
|
this.name = 'PolarLicenseError';
|
|
9
11
|
}
|
|
10
12
|
}
|
|
11
13
|
const USER_AGENT = 'editmamei-license-client/1';
|
|
14
|
+
const DEFAULT_RETRY = { attempts: 3, baseDelayMs: 1000, maxDelayMs: 65_000 };
|
|
15
|
+
const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
16
|
+
export const unrefSleep = (ms) => new Promise((resolve) => {
|
|
17
|
+
const timer = setTimeout(resolve, ms);
|
|
18
|
+
timer.unref?.();
|
|
19
|
+
});
|
|
12
20
|
export class PolarLicenseClient {
|
|
13
21
|
cfg;
|
|
14
22
|
fetchImpl;
|
|
15
|
-
|
|
23
|
+
retry;
|
|
24
|
+
sleep;
|
|
25
|
+
constructor(cfg, fetchImpl, opts = {}) {
|
|
16
26
|
this.cfg = cfg;
|
|
17
27
|
this.fetchImpl = fetchImpl;
|
|
28
|
+
this.retry = { ...DEFAULT_RETRY, ...opts.retry };
|
|
29
|
+
this.sleep = opts.sleep ?? defaultSleep;
|
|
18
30
|
}
|
|
19
31
|
async validate(key) {
|
|
20
|
-
return this.post('/customer-portal/license-keys/validate', {
|
|
21
|
-
key,
|
|
22
|
-
organization_id: this.cfg.organizationId,
|
|
23
|
-
});
|
|
32
|
+
return this.post('/customer-portal/license-keys/validate', { key, organization_id: this.cfg.organizationId }, { retry: true });
|
|
24
33
|
}
|
|
25
34
|
async activate(key, label) {
|
|
26
35
|
return this.post('/customer-portal/license-keys/activate', {
|
|
@@ -36,7 +45,38 @@ export class PolarLicenseClient {
|
|
|
36
45
|
activation_id: activationId,
|
|
37
46
|
});
|
|
38
47
|
}
|
|
39
|
-
async post(path, body) {
|
|
48
|
+
async post(path, body, opts = {}) {
|
|
49
|
+
const once = () => this.postOnce(path, body);
|
|
50
|
+
return opts.retry === true ? this.withRetry(once) : once();
|
|
51
|
+
}
|
|
52
|
+
async withRetry(fn) {
|
|
53
|
+
const attempts = Math.max(1, this.retry.attempts);
|
|
54
|
+
let lastErr;
|
|
55
|
+
for (let attempt = 1; attempt <= attempts; attempt++) {
|
|
56
|
+
try {
|
|
57
|
+
return await fn();
|
|
58
|
+
}
|
|
59
|
+
catch (err) {
|
|
60
|
+
lastErr = err;
|
|
61
|
+
const transient = err instanceof PolarLicenseError && (err.code === 'network' || err.code === 'transient');
|
|
62
|
+
if (!transient || attempt === attempts)
|
|
63
|
+
throw err;
|
|
64
|
+
const retryAfter = err instanceof PolarLicenseError ? err.retryAfterMs : undefined;
|
|
65
|
+
const delay = retryAfter ?? this.retry.baseDelayMs * 2 ** (attempt - 1);
|
|
66
|
+
const wait = Math.min(Math.max(delay, this.retry.baseDelayMs), this.retry.maxDelayMs);
|
|
67
|
+
await this.sleep(wait);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
throw lastErr;
|
|
71
|
+
}
|
|
72
|
+
retryAfterMsOf(res) {
|
|
73
|
+
const raw = res.headers?.get('retry-after');
|
|
74
|
+
if (!raw)
|
|
75
|
+
return undefined;
|
|
76
|
+
const secs = Number(raw);
|
|
77
|
+
return Number.isFinite(secs) && secs >= 0 ? Math.round(secs * 1000) : undefined;
|
|
78
|
+
}
|
|
79
|
+
async postOnce(path, body) {
|
|
40
80
|
let res;
|
|
41
81
|
try {
|
|
42
82
|
res = await this.fetchImpl(`${this.cfg.baseUrl}${path}`, {
|
|
@@ -57,6 +97,9 @@ export class PolarLicenseClient {
|
|
|
57
97
|
'`editmamei deactivate` on one of them, or remove a device in your account portal ' +
|
|
58
98
|
"(the 'Manage' link in your purchase email).", 403, 'seat_limit_reached');
|
|
59
99
|
}
|
|
100
|
+
if (res.status === 429 || res.status >= 500) {
|
|
101
|
+
throw new PolarLicenseError(`License check could not complete (HTTP ${res.status}).`, res.status, 'transient', this.retryAfterMsOf(res));
|
|
102
|
+
}
|
|
60
103
|
throw new PolarLicenseError(`License check failed (HTTP ${res.status}).`, res.status, 'invalid_license');
|
|
61
104
|
}
|
|
62
105
|
}
|
package/dist/license/store.js
CHANGED
|
@@ -4,6 +4,7 @@ import { settingsDir } from '../core/settings.js';
|
|
|
4
4
|
import { Logger } from '../utils/logger.js';
|
|
5
5
|
const logger = new Logger('License');
|
|
6
6
|
const LICENSE_FILENAME = 'license.json';
|
|
7
|
+
const CHECK_STATE_FILENAME = 'license-check.json';
|
|
7
8
|
export function licensePath(opts = {}) {
|
|
8
9
|
return join(settingsDir(opts), LICENSE_FILENAME);
|
|
9
10
|
}
|
|
@@ -56,4 +57,62 @@ export function clearLicense(opts = {}) {
|
|
|
56
57
|
const path = licensePath(opts);
|
|
57
58
|
if (existsSync(path))
|
|
58
59
|
rmSync(path, { force: true });
|
|
60
|
+
clearCheckState(opts);
|
|
61
|
+
}
|
|
62
|
+
export function checkStatePath(opts = {}) {
|
|
63
|
+
return join(settingsDir(opts), CHECK_STATE_FILENAME);
|
|
64
|
+
}
|
|
65
|
+
export function readCheckState(opts = {}) {
|
|
66
|
+
const path = checkStatePath(opts);
|
|
67
|
+
if (!existsSync(path))
|
|
68
|
+
return {};
|
|
69
|
+
let parsed;
|
|
70
|
+
try {
|
|
71
|
+
parsed = JSON.parse(readFileSync(path, 'utf8'));
|
|
72
|
+
}
|
|
73
|
+
catch (err) {
|
|
74
|
+
logger.debug(`license-check.json unreadable (ignoring): ${err instanceof Error ? err.message : String(err)}`);
|
|
75
|
+
return {};
|
|
76
|
+
}
|
|
77
|
+
if (typeof parsed !== 'object' || parsed === null)
|
|
78
|
+
return {};
|
|
79
|
+
const raw = parsed;
|
|
80
|
+
const out = {};
|
|
81
|
+
if (typeof raw.validate_retry_after === 'number' && Number.isFinite(raw.validate_retry_after)) {
|
|
82
|
+
out.validate_retry_after = raw.validate_retry_after;
|
|
83
|
+
}
|
|
84
|
+
if (typeof raw.module_retry_after === 'number' && Number.isFinite(raw.module_retry_after)) {
|
|
85
|
+
out.module_retry_after = raw.module_retry_after;
|
|
86
|
+
}
|
|
87
|
+
return out;
|
|
88
|
+
}
|
|
89
|
+
export function updateCheckState(patch, opts = {}) {
|
|
90
|
+
const next = { ...readCheckState(opts) };
|
|
91
|
+
for (const [key, value] of Object.entries(patch)) {
|
|
92
|
+
if (value === null)
|
|
93
|
+
delete next[key];
|
|
94
|
+
else
|
|
95
|
+
next[key] = value;
|
|
96
|
+
}
|
|
97
|
+
const path = checkStatePath(opts);
|
|
98
|
+
const dir = dirname(path);
|
|
99
|
+
try {
|
|
100
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
101
|
+
const tmp = join(dir, `.license-check.${process.pid}.tmp`);
|
|
102
|
+
writeFileSync(tmp, JSON.stringify(next, null, 2) + '\n', { encoding: 'utf8', mode: 0o600 });
|
|
103
|
+
renameSync(tmp, path);
|
|
104
|
+
}
|
|
105
|
+
catch (err) {
|
|
106
|
+
logger.debug(`license-check.json not written (ignoring): ${err instanceof Error ? err.message : String(err)}`);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
export function clearCheckState(opts = {}) {
|
|
110
|
+
const path = checkStatePath(opts);
|
|
111
|
+
try {
|
|
112
|
+
if (existsSync(path))
|
|
113
|
+
rmSync(path, { force: true });
|
|
114
|
+
}
|
|
115
|
+
catch (err) {
|
|
116
|
+
logger.debug(`license-check.json not removed (ignoring): ${err instanceof Error ? err.message : String(err)}`);
|
|
117
|
+
}
|
|
59
118
|
}
|
|
Binary file
|
package/dist/telemetry/client.js
CHANGED
|
@@ -7,7 +7,7 @@ import { READ_ONLY_TOOLS, KEPT_WORK_TOOLS, nodeMajor, archToken, osMajor, boundM
|
|
|
7
7
|
import { buildClientConnected, buildDiagnosticEvent, buildModuleStatus, buildSessionStart, buildSessionSummary, buildUsageEvent, dayBucket, normalizeDayBucket, isContentSafe, PS_VERSION_UNKNOWN, } from './events.js';
|
|
8
8
|
import { sanitizeMessage, sanitizeSnippet, sanitizeStderrTail } from './sanitize.js';
|
|
9
9
|
import { httpTransport, resolveEndpoint } from './transport.js';
|
|
10
|
-
import { appendOutboxSync, clearOutbox, clearSessionState,
|
|
10
|
+
import { appendOutboxSync, clearOutbox, clearSessionState, readOutboxWithDiscards, readSessionState, rewriteOutbox, writeSessionStateSync, } from './outbox.js';
|
|
11
11
|
const MAX_BATCH_SIZE = 100;
|
|
12
12
|
const MAX_QUEUE_SIZE = 500;
|
|
13
13
|
const DEFAULT_FLUSH_INTERVAL_MS = 5 * 60_000;
|
|
@@ -43,6 +43,9 @@ export class TelemetryClient {
|
|
|
43
43
|
editsOk = 0;
|
|
44
44
|
keptWork = 0;
|
|
45
45
|
droppedEvents = 0;
|
|
46
|
+
droppedOutbox = 0;
|
|
47
|
+
droppedUnsafe = 0;
|
|
48
|
+
usageCallsSent = 0;
|
|
46
49
|
behindLatest = null;
|
|
47
50
|
moduleUpdate = 'none';
|
|
48
51
|
installAssets = {};
|
|
@@ -156,6 +159,9 @@ export class TelemetryClient {
|
|
|
156
159
|
kept_work: this.keptWork,
|
|
157
160
|
...(this.behindLatest !== null ? { behind_latest: this.behindLatest } : {}),
|
|
158
161
|
dropped_events: this.droppedEvents,
|
|
162
|
+
dropped_outbox: this.droppedOutbox,
|
|
163
|
+
dropped_unsafe: this.droppedUnsafe,
|
|
164
|
+
usage_calls_sent: this.usageCallsSent,
|
|
159
165
|
...(moduleStatus !== null ? { module_update: this.moduleUpdate } : {}),
|
|
160
166
|
...(this.installAssets.templates_saved !== undefined
|
|
161
167
|
? { templates_saved: this.installAssets.templates_saved }
|
|
@@ -241,15 +247,18 @@ export class TelemetryClient {
|
|
|
241
247
|
async flush() {
|
|
242
248
|
if (!this.active || this.queue.length === 0)
|
|
243
249
|
return;
|
|
244
|
-
const
|
|
250
|
+
const picked = this.restampPsVersion(this.queue.splice(0, this.maxBatchSize));
|
|
251
|
+
const batch = picked.filter(isContentSafe);
|
|
252
|
+
this.droppedUnsafe += picked.length - batch.length;
|
|
245
253
|
if (batch.length === 0)
|
|
246
254
|
return;
|
|
247
255
|
try {
|
|
248
256
|
await this.transport(this.endpoint, JSON.stringify({ events: batch }));
|
|
257
|
+
this.usageCallsSent += countUsageCalls(batch);
|
|
249
258
|
}
|
|
250
259
|
catch (err) {
|
|
251
260
|
this.logger.debug(`telemetry flush failed, persisting ${batch.length} event(s) to outbox: ${errMsg(err)}`);
|
|
252
|
-
appendOutboxSync(batch, this.outboxOpts);
|
|
261
|
+
this.droppedOutbox += appendOutboxSync(batch, this.outboxOpts);
|
|
253
262
|
}
|
|
254
263
|
}
|
|
255
264
|
shutdown() {
|
|
@@ -264,16 +273,26 @@ export class TelemetryClient {
|
|
|
264
273
|
}
|
|
265
274
|
if (!this.active)
|
|
266
275
|
return;
|
|
267
|
-
if (this.
|
|
268
|
-
this.
|
|
276
|
+
if (this.queue.length > 0) {
|
|
277
|
+
const picked = this.restampPsVersion(this.queue.splice(0));
|
|
278
|
+
const batch = picked.filter(isContentSafe);
|
|
279
|
+
this.droppedUnsafe += picked.length - batch.length;
|
|
280
|
+
this.droppedOutbox += appendOutboxSync(batch, this.outboxOpts);
|
|
281
|
+
}
|
|
282
|
+
const hasLossToReport = this.droppedEvents > 0 || this.droppedOutbox > 0 || this.droppedUnsafe > 0;
|
|
283
|
+
if (this.settings.telemetry.usage && (this.toolCallCount > 0 || hasLossToReport)) {
|
|
284
|
+
const summary = buildSessionSummary(this.dims, {
|
|
269
285
|
tool_call_count: this.toolCallCount,
|
|
270
286
|
distinct_tools: this.distinctTools.size,
|
|
271
287
|
any_failures: this.anyFailures,
|
|
272
288
|
...this.summaryFields(),
|
|
273
|
-
}, this.ensureStartDayBucket(), this.now())
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
289
|
+
}, this.ensureStartDayBucket(), this.now());
|
|
290
|
+
if (isContentSafe(summary)) {
|
|
291
|
+
const lost = appendOutboxSync([summary], this.outboxOpts);
|
|
292
|
+
if (lost > 0) {
|
|
293
|
+
this.logger.debug(`outbox discarded ${lost} event(s) while writing the session summary`);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
277
296
|
}
|
|
278
297
|
clearSessionState(this.outboxOpts);
|
|
279
298
|
}
|
|
@@ -286,7 +305,8 @@ export class TelemetryClient {
|
|
|
286
305
|
appendOutboxSync([summaryFromState(stale)], this.outboxOpts);
|
|
287
306
|
}
|
|
288
307
|
clearSessionState(this.outboxOpts);
|
|
289
|
-
const pending =
|
|
308
|
+
const { events: pending, discarded } = readOutboxWithDiscards(this.outboxOpts);
|
|
309
|
+
this.droppedOutbox += discarded;
|
|
290
310
|
if (pending.length === 0) {
|
|
291
311
|
clearOutbox(this.outboxOpts);
|
|
292
312
|
return;
|
|
@@ -295,22 +315,27 @@ export class TelemetryClient {
|
|
|
295
315
|
clearOutbox(this.outboxOpts);
|
|
296
316
|
return;
|
|
297
317
|
}
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
318
|
+
const sendable = pending.filter(isContentSafe);
|
|
319
|
+
this.droppedUnsafe += pending.length - sendable.length;
|
|
320
|
+
let settled = 0;
|
|
321
|
+
for (let i = 0; i < sendable.length; i += this.maxBatchSize) {
|
|
322
|
+
const batch = sendable.slice(i, i + this.maxBatchSize);
|
|
303
323
|
try {
|
|
304
324
|
await this.transport(this.endpoint, JSON.stringify({ events: batch }));
|
|
325
|
+
this.usageCallsSent += countUsageCalls(batch);
|
|
305
326
|
}
|
|
306
327
|
catch (err) {
|
|
307
328
|
this.logger.debug(`startup outbox flush failed: ${errMsg(err)}`);
|
|
308
|
-
allAccepted = false;
|
|
309
329
|
break;
|
|
310
330
|
}
|
|
331
|
+
settled = i + batch.length;
|
|
311
332
|
}
|
|
312
|
-
if (
|
|
333
|
+
if (settled >= sendable.length) {
|
|
313
334
|
clearOutbox(this.outboxOpts);
|
|
335
|
+
}
|
|
336
|
+
else {
|
|
337
|
+
rewriteOutbox(sendable.slice(settled), this.outboxOpts);
|
|
338
|
+
}
|
|
314
339
|
}
|
|
315
340
|
catch (err) {
|
|
316
341
|
this.logger.debug(`startup outbox flush error: ${errMsg(err)}`);
|
|
@@ -342,6 +367,9 @@ function summaryFromState(s) {
|
|
|
342
367
|
...(s.kept_work !== undefined ? { kept_work: s.kept_work } : {}),
|
|
343
368
|
...(s.behind_latest !== undefined ? { behind_latest: s.behind_latest } : {}),
|
|
344
369
|
...(s.dropped_events !== undefined ? { dropped_events: s.dropped_events } : {}),
|
|
370
|
+
...(s.dropped_outbox !== undefined ? { dropped_outbox: s.dropped_outbox } : {}),
|
|
371
|
+
...(s.dropped_unsafe !== undefined ? { dropped_unsafe: s.dropped_unsafe } : {}),
|
|
372
|
+
...(s.usage_calls_sent !== undefined ? { usage_calls_sent: s.usage_calls_sent } : {}),
|
|
345
373
|
...(s.module_update !== undefined ? { module_update: s.module_update } : {}),
|
|
346
374
|
...(templatesSaved !== null ? { templates_saved: templatesSaved } : {}),
|
|
347
375
|
...(actionSets !== null ? { action_sets: actionSets } : {}),
|
|
@@ -350,6 +378,16 @@ function summaryFromState(s) {
|
|
|
350
378
|
function errMsg(err) {
|
|
351
379
|
return err instanceof Error ? err.message : String(err);
|
|
352
380
|
}
|
|
381
|
+
function countUsageCalls(events) {
|
|
382
|
+
let n = 0;
|
|
383
|
+
for (const event of events) {
|
|
384
|
+
if (event.type !== 'usage')
|
|
385
|
+
continue;
|
|
386
|
+
const count = event.count;
|
|
387
|
+
n += typeof count === 'number' && Number.isInteger(count) && count > 0 ? count : 1;
|
|
388
|
+
}
|
|
389
|
+
return n;
|
|
390
|
+
}
|
|
353
391
|
function clampCount(n) {
|
|
354
392
|
if (!Number.isFinite(n))
|
|
355
393
|
return null;
|
package/dist/telemetry/events.js
CHANGED
|
@@ -61,6 +61,11 @@ export function buildSessionSummary(dims, summary, tsBucket, now) {
|
|
|
61
61
|
...(summary.kept_work !== undefined ? { kept_work: summary.kept_work } : {}),
|
|
62
62
|
...(summary.behind_latest !== undefined ? { behind_latest: summary.behind_latest } : {}),
|
|
63
63
|
...(summary.dropped_events !== undefined ? { dropped_events: summary.dropped_events } : {}),
|
|
64
|
+
...(summary.dropped_outbox !== undefined ? { dropped_outbox: summary.dropped_outbox } : {}),
|
|
65
|
+
...(summary.dropped_unsafe !== undefined ? { dropped_unsafe: summary.dropped_unsafe } : {}),
|
|
66
|
+
...(summary.usage_calls_sent !== undefined
|
|
67
|
+
? { usage_calls_sent: summary.usage_calls_sent }
|
|
68
|
+
: {}),
|
|
64
69
|
...(summary.module_update !== undefined ? { module_update: summary.module_update } : {}),
|
|
65
70
|
...(summary.templates_saved !== undefined ? { templates_saved: summary.templates_saved } : {}),
|
|
66
71
|
...(summary.action_sets !== undefined ? { action_sets: summary.action_sets } : {}),
|
package/dist/telemetry/outbox.js
CHANGED
|
@@ -6,8 +6,8 @@ const logger = new Logger('TelemetryOutbox');
|
|
|
6
6
|
const DIRNAME = '.editmamei';
|
|
7
7
|
const OUTBOX_FILENAME = 'telemetry-outbox.ndjson';
|
|
8
8
|
const SESSION_STATE_FILENAME = 'telemetry-session.json';
|
|
9
|
-
export const MAX_OUTBOX_EVENTS =
|
|
10
|
-
const MAX_OUTBOX_BYTES = 2_000_000;
|
|
9
|
+
export const MAX_OUTBOX_EVENTS = 5_000;
|
|
10
|
+
export const MAX_OUTBOX_BYTES = 2_000_000;
|
|
11
11
|
function baseDir(opts) {
|
|
12
12
|
return opts.dir ?? join(homedir(), DIRNAME);
|
|
13
13
|
}
|
|
@@ -22,27 +22,31 @@ function ensureDir(path) {
|
|
|
22
22
|
}
|
|
23
23
|
export function appendOutboxSync(events, opts = {}) {
|
|
24
24
|
if (events.length === 0)
|
|
25
|
-
return;
|
|
25
|
+
return 0;
|
|
26
26
|
const path = outboxPath(opts);
|
|
27
|
+
let discarded = 0;
|
|
27
28
|
try {
|
|
28
29
|
ensureDir(path);
|
|
29
30
|
if (existsSync(path) && statSync(path).size > MAX_OUTBOX_BYTES) {
|
|
30
|
-
compactOutbox(opts);
|
|
31
|
+
discarded += compactOutbox(opts);
|
|
31
32
|
}
|
|
32
33
|
const lines = events.map((e) => JSON.stringify(e)).join('\n') + '\n';
|
|
33
34
|
appendFileSync(path, lines, { encoding: 'utf8', mode: 0o600 });
|
|
34
35
|
}
|
|
35
36
|
catch (err) {
|
|
36
37
|
logger.debug(`outbox append dropped ${events.length} event(s): ${errMsg(err)}`);
|
|
38
|
+
discarded += events.length;
|
|
37
39
|
}
|
|
40
|
+
return discarded;
|
|
38
41
|
}
|
|
39
|
-
export function
|
|
42
|
+
export function readOutboxWithDiscards(opts = {}) {
|
|
40
43
|
const path = outboxPath(opts);
|
|
41
44
|
if (!existsSync(path))
|
|
42
|
-
return [];
|
|
45
|
+
return { events: [], discarded: 0 };
|
|
43
46
|
try {
|
|
44
47
|
const raw = readFileSync(path, 'utf8');
|
|
45
48
|
const events = [];
|
|
49
|
+
let discarded = 0;
|
|
46
50
|
for (const line of raw.split('\n')) {
|
|
47
51
|
const trimmed = line.trim();
|
|
48
52
|
if (trimmed.length === 0)
|
|
@@ -51,17 +55,23 @@ export function readOutbox(opts = {}) {
|
|
|
51
55
|
events.push(JSON.parse(trimmed));
|
|
52
56
|
}
|
|
53
57
|
catch {
|
|
58
|
+
discarded += 1;
|
|
54
59
|
}
|
|
55
60
|
}
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
: events;
|
|
61
|
+
if (events.length > MAX_OUTBOX_EVENTS) {
|
|
62
|
+
const over = events.length - MAX_OUTBOX_EVENTS;
|
|
63
|
+
return { events: events.slice(over), discarded: discarded + over };
|
|
64
|
+
}
|
|
65
|
+
return { events, discarded };
|
|
59
66
|
}
|
|
60
67
|
catch (err) {
|
|
61
68
|
logger.debug(`outbox read failed: ${errMsg(err)}`);
|
|
62
|
-
return [];
|
|
69
|
+
return { events: [], discarded: 0 };
|
|
63
70
|
}
|
|
64
71
|
}
|
|
72
|
+
export function readOutbox(opts = {}) {
|
|
73
|
+
return readOutboxWithDiscards(opts).events;
|
|
74
|
+
}
|
|
65
75
|
export function clearOutbox(opts = {}) {
|
|
66
76
|
try {
|
|
67
77
|
rmSync(outboxPath(opts), { force: true });
|
|
@@ -70,24 +80,39 @@ export function clearOutbox(opts = {}) {
|
|
|
70
80
|
logger.debug(`outbox clear failed: ${errMsg(err)}`);
|
|
71
81
|
}
|
|
72
82
|
}
|
|
73
|
-
function
|
|
74
|
-
|
|
83
|
+
export function rewriteOutbox(events, opts = {}) {
|
|
84
|
+
if (events.length === 0) {
|
|
85
|
+
clearOutbox(opts);
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
75
88
|
const path = outboxPath(opts);
|
|
76
89
|
try {
|
|
77
|
-
|
|
78
|
-
clearOutbox(opts);
|
|
79
|
-
return;
|
|
80
|
-
}
|
|
90
|
+
ensureDir(path);
|
|
81
91
|
const tmp = join(dirname(path), `.outbox.${process.pid}.tmp`);
|
|
82
|
-
writeFileSync(tmp,
|
|
92
|
+
writeFileSync(tmp, events.map((e) => JSON.stringify(e)).join('\n') + '\n', {
|
|
83
93
|
encoding: 'utf8',
|
|
84
94
|
mode: 0o600,
|
|
85
95
|
});
|
|
86
96
|
renameSync(tmp, path);
|
|
87
97
|
}
|
|
88
98
|
catch (err) {
|
|
89
|
-
logger.debug(`outbox
|
|
99
|
+
logger.debug(`outbox rewrite failed: ${errMsg(err)}`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
function compactOutbox(opts = {}) {
|
|
103
|
+
const { events, discarded } = readOutboxWithDiscards(opts);
|
|
104
|
+
const target = MAX_OUTBOX_BYTES / 2;
|
|
105
|
+
let bytes = 0;
|
|
106
|
+
let firstKept = Math.max(events.length - 1, 0);
|
|
107
|
+
for (let i = events.length - 1; i >= 0; i--) {
|
|
108
|
+
bytes += Buffer.byteLength(JSON.stringify(events[i]), 'utf8') + 1;
|
|
109
|
+
if (bytes > target)
|
|
110
|
+
break;
|
|
111
|
+
firstKept = i;
|
|
90
112
|
}
|
|
113
|
+
const kept = events.slice(firstKept);
|
|
114
|
+
rewriteOutbox(kept, opts);
|
|
115
|
+
return discarded + (events.length - kept.length);
|
|
91
116
|
}
|
|
92
117
|
export function writeSessionStateSync(state, opts = {}) {
|
|
93
118
|
const path = sessionStatePath(opts);
|
package/dist/version.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const VERSION = '1.5.
|
|
1
|
+
export const VERSION = '1.5.1';
|
package/package.json
CHANGED