@kitkat-vn/kitcode 0.3.0-staging.15 → 0.3.0-staging.16
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 +5 -0
- package/bin/kitcode.mjs +56 -4
- package/package.json +2 -2
- package/src/companion-window.mjs +4 -2
- package/src/hook-prompt.mjs +9 -0
- package/src/milestones-reached.mjs +8 -3
- package/src/reward.mjs +40 -12
- package/src/tracking-config.mjs +501 -0
package/README.md
CHANGED
|
@@ -252,6 +252,7 @@ KITCODE_HOOKS_OFF=1
|
|
|
252
252
|
~/.kitcode/state.json Projects and their snapshots, onboarding prefs, focus time, equals ledger
|
|
253
253
|
~/.kitcode/seen-lines Every line KitCode has seen, as hashes: a line earns once
|
|
254
254
|
~/.kitcode/install-id A random id the claim bundle carries, made by the tracker
|
|
255
|
+
~/.kitcode/tracking-config.json The campaign's targets and end, as the tracker last took them from the server
|
|
255
256
|
~/.kitcode/tracker.json Background tracker PID/host/port metadata
|
|
256
257
|
~/.kitcode/tracker.log The background tracker's log
|
|
257
258
|
~/.kitcode/hook.log Hook errors, when logging succeeds
|
|
@@ -271,6 +272,10 @@ By default KitCode does not send:
|
|
|
271
272
|
|
|
272
273
|
The local API returns totals only: active/idle time, project count, commit count, change batches, counted `=`, and reward progress, with the tracker's health. The claim bundle, which it gives an allowed origin alone, adds when each milestone was reached, the CLI's and the counting rules' versions, and the install id, which names no person, machine or project.
|
|
273
274
|
|
|
275
|
+
KitCode connects to kitcode.vn about once an hour to download the campaign's current targets and end date. It sends nothing about you, your computer or your code, and the server does not record the address of this request.
|
|
276
|
+
|
|
277
|
+
Only the background tracker connects, about an hour apart and once when your computer wakes: one request to the dashboard's address plus `/v1/tracking-config`, with no cookie, install id or version. It takes the answer only if KitCode's claim server signed it, and otherwise keeps the targets it had; the hook and the commands never connect. The staging package connects to its staging site instead, and a copy of KitCode run from its repository connects to nothing. If the campaign has ended, by your computer's clock, the hook adds nothing to your prompts and `kitcode summary` says so; your totals are kept.
|
|
278
|
+
|
|
274
279
|
## API
|
|
275
280
|
|
|
276
281
|
```txt
|
package/bin/kitcode.mjs
CHANGED
|
@@ -53,6 +53,7 @@ import {
|
|
|
53
53
|
updateTrackerRecord,
|
|
54
54
|
} from '../src/tracker-instance.mjs';
|
|
55
55
|
import {askTrackerToStop, watchForStopRequest} from '../src/tracker-stop.mjs';
|
|
56
|
+
import {startTrackingConfigLoop, TRACKING_CONFIG_DISPLAY_PATH, trackingConfigUrl} from '../src/tracking-config.mjs';
|
|
56
57
|
import {normalizeSetupPlatform} from '../src/onboarding-platform.mjs';
|
|
57
58
|
import {uninstallKitCode} from '../src/uninstall.mjs';
|
|
58
59
|
|
|
@@ -288,8 +289,11 @@ function formatDuration(seconds) {
|
|
|
288
289
|
// showing a 10–30 % code was what cleared one; with the codes gone nothing does (docs/reward-rules.md §7). Since 2026-10-22
|
|
289
290
|
// the milestones are those of the cycle counted in, which it names from cycle 2 on, when it began (docs/reward-rules.md §2,
|
|
290
291
|
// "Cycles"); cycle 1 reads as it always has.
|
|
291
|
-
|
|
292
|
-
|
|
292
|
+
//
|
|
293
|
+
// Since 2026-10-23 (tracking/2-cli; the plan's D4) a campaign that has ended, by this computer's clock and the end the targets in force
|
|
294
|
+
// give (src/tracking-config.mjs), is said so in the plan's words, and no next milestone is named: the summary has none. Counting, and
|
|
295
|
+
// what was reached, are as before.
|
|
296
|
+
function printRewardSummary(reward = getDiskRewardSummary()) {
|
|
293
297
|
const next = reward.nextBreak;
|
|
294
298
|
const ready = reward.milestones.filter((milestone) => milestone.status === 'ready' && !milestone.redeemed);
|
|
295
299
|
|
|
@@ -298,6 +302,10 @@ function printRewardSummary() {
|
|
|
298
302
|
console.log(` active time: ${formatDuration(reward.earnedSeconds)}`);
|
|
299
303
|
console.log(` campaign progress: ${reward.progressPercent}%`);
|
|
300
304
|
|
|
305
|
+
if (reward.campaign?.ended) {
|
|
306
|
+
console.log(` ${campaignEndedWords(reward.campaign)}`);
|
|
307
|
+
}
|
|
308
|
+
|
|
301
309
|
if (Number.isSafeInteger(reward.cycle) && reward.cycle > 1) {
|
|
302
310
|
console.log(` cycle: ${reward.cycle}, its milestones counted from ${reward.cycleStartedAt}`);
|
|
303
311
|
}
|
|
@@ -323,6 +331,41 @@ function printAwards() {
|
|
|
323
331
|
const status = milestone.status === 'redeemed' ? 'ready' : milestone.status;
|
|
324
332
|
console.log(` ${milestone.percent}% milestone: ${status} (${milestoneProgressPercent(reward, milestone)}%)`);
|
|
325
333
|
}
|
|
334
|
+
|
|
335
|
+
if (reward.campaign?.ended) {
|
|
336
|
+
console.log(` ${campaignEndedWords(reward.campaign)}`);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// An instant in Vietnam time, UTC+7 all year, as "YYYY-MM-DD HH:MM": the campaign's end is set so (docs/backend.md,
|
|
341
|
+
// VOUCHER_CAMPAIGN_END), and the plan's words name its date there.
|
|
342
|
+
function vietnamTime(instant) {
|
|
343
|
+
return new Date(Date.parse(instant) + 7 * 60 * 60 * 1000).toISOString().slice(0, 16).replace('T', ' ');
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// The plan's words for a campaign that has ended (D4 of docs/plans/2026-10-22-server-tracking-config.md).
|
|
347
|
+
function campaignEndedWords(campaign) {
|
|
348
|
+
return `KitCode's campaign ended on ${vietnamTime(campaign.endsAt).slice(0, 10)} (Vietnam time). Your totals are kept; no more vouchers can be claimed.`;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// Which targets KitCode counts by, and where they came from (src/tracking-config.mjs), so that a fallback never reads as the healthy
|
|
352
|
+
// path; and the campaign's end. From the summary this command builds: the last good copy on disk, as the prompt hook reads it.
|
|
353
|
+
function targetsLine(targets) {
|
|
354
|
+
if (targets.source === 'bundled') {
|
|
355
|
+
return trackingConfigUrl() === null
|
|
356
|
+
? 'the ones this KitCode bundles; this build never asks a server for them'
|
|
357
|
+
: `the ones this KitCode bundles, since ${TRACKING_CONFIG_DISPLAY_PATH} holds no good copy of the server's yet`;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
return `the server's, issued ${targets.issuedAt}${targets.source === 'saved' ? `, from the copy in ${TRACKING_CONFIG_DISPLAY_PATH}` : ''}`;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function campaignLine(campaign) {
|
|
364
|
+
if (campaign.endsAt === null) {
|
|
365
|
+
return 'no end set';
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
return campaign.ended ? `ended on ${vietnamTime(campaign.endsAt).slice(0, 10)} (Vietnam time)` : `ends ${vietnamTime(campaign.endsAt)} (Vietnam time)`;
|
|
326
369
|
}
|
|
327
370
|
|
|
328
371
|
async function printStatus(options) {
|
|
@@ -343,7 +386,10 @@ async function printStatus(options) {
|
|
|
343
386
|
}).join(', ')}; see ${TRACKER_LOG_PATH}`);
|
|
344
387
|
}
|
|
345
388
|
console.log(` added projects: ${totals.trackingProjects}`);
|
|
346
|
-
|
|
389
|
+
const reward = getDiskRewardSummary();
|
|
390
|
+
console.log(` targets: ${targetsLine(reward.targets)}`);
|
|
391
|
+
console.log(` campaign: ${campaignLine(reward.campaign)}`);
|
|
392
|
+
printRewardSummary(reward);
|
|
347
393
|
}
|
|
348
394
|
|
|
349
395
|
|
|
@@ -934,6 +980,10 @@ async function runDaemon(options, status = createStatusReporter()) {
|
|
|
934
980
|
return {projects: Object.values(runtime.state.projects).filter((project) => project.active).length, tracking: current.tracking, problems: current.problems.map((problem) => problem.code)};
|
|
935
981
|
},
|
|
936
982
|
});
|
|
983
|
+
// The campaign's targets and end, from the server, in a build that carries the claim backend's key (src/tracking-config.mjs):
|
|
984
|
+
// a random 5 to 120 s from now, then whenever the last try is over an hour old, and at once after a sleep. The tracker alone
|
|
985
|
+
// fetches; whatever happens to a fetch stays in tracker.log, and the tracker counts by the targets it had.
|
|
986
|
+
const trackingConfig = startTrackingConfigLoop({log});
|
|
937
987
|
const app = createServer(runtime, VERSION, {diagnostics});
|
|
938
988
|
let cleanup = async () => {};
|
|
939
989
|
let server = null;
|
|
@@ -973,7 +1023,7 @@ async function runDaemon(options, status = createStatusReporter()) {
|
|
|
973
1023
|
report('error', `Stopped: ${error.message}`);
|
|
974
1024
|
cleanup({persist: false});
|
|
975
1025
|
recordEvent('tracker-stopped', {reason: 'state-unreadable', uptimeSeconds: uptimeSeconds()}, {source: 'tracker'});
|
|
976
|
-
diagnostics.stop().finally(() => exit(1));
|
|
1026
|
+
Promise.allSettled([trackingConfig.stop(), diagnostics.stop()]).finally(() => exit(1));
|
|
977
1027
|
},
|
|
978
1028
|
});
|
|
979
1029
|
|
|
@@ -990,6 +1040,7 @@ async function runDaemon(options, status = createStatusReporter()) {
|
|
|
990
1040
|
|
|
991
1041
|
log.info(`stopped (${signal})`);
|
|
992
1042
|
recordEvent('tracker-stopped', {reason: signal === 'stop request' ? 'stop-request' : 'signal', uptimeSeconds: uptimeSeconds()}, {source: 'tracker'});
|
|
1043
|
+
await trackingConfig.stop();
|
|
993
1044
|
await diagnostics.stop();
|
|
994
1045
|
exit(0);
|
|
995
1046
|
};
|
|
@@ -1005,6 +1056,7 @@ async function runDaemon(options, status = createStatusReporter()) {
|
|
|
1005
1056
|
await cleanup().catch(() => {});
|
|
1006
1057
|
releaseTracker(process.pid);
|
|
1007
1058
|
recordTrackerNotStarted('tracker', error.code === 'EADDRINUSE' ? 'port-in-use' : 'start-failed', error);
|
|
1059
|
+
await trackingConfig.stop();
|
|
1008
1060
|
await diagnostics.stop({finalSend: false});
|
|
1009
1061
|
|
|
1010
1062
|
if (error.code === 'EADDRINUSE') {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kitkat-vn/kitcode",
|
|
3
|
-
"version": "0.3.0-staging.
|
|
3
|
+
"version": "0.3.0-staging.16",
|
|
4
4
|
"description": "Local aggregate activity server for KitCode.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -37,5 +37,5 @@
|
|
|
37
37
|
"optionalDependencies": {
|
|
38
38
|
"electron": "^43.0.0"
|
|
39
39
|
},
|
|
40
|
-
"gitHead": "
|
|
40
|
+
"gitHead": "1e310fb2aaf244ede82956f5f6046ea9f52a7eb1"
|
|
41
41
|
}
|
package/src/companion-window.mjs
CHANGED
|
@@ -35,7 +35,7 @@ ${COMPANION_HIDE_CSS}
|
|
|
35
35
|
const API_BASE=${JSON.stringify(apiBase)};const brandLabel=document.getElementById('brandLabel'),brandSecond=document.getElementById('brandSecond'),percent=document.getElementById('percent'),countValue=document.getElementById('countValue'),workedValue=document.getElementById('workedValue'),idleValue=document.getElementById('idleValue'),progressFill=document.getElementById('progressFill'),main=document.getElementById('main'),dashboardButton=document.getElementById('dashboardButton');
|
|
36
36
|
function formatDuration(seconds){const totalSeconds=Math.max(0,Math.floor(Number(seconds)||0));if(totalSeconds<60)return totalSeconds+'s';const hours=Math.floor(totalSeconds/3600),minutes=Math.floor((totalSeconds%3600)/60);if(hours>0)return minutes>0?hours+'h '+minutes+'m':hours+'h';return minutes+'m'}
|
|
37
37
|
function updateProgress(value){progressFill.style.width=Math.max(0,Math.min(100,Number(value)||0))+'%'}
|
|
38
|
-
function render(summary){const reward=summary?.reward||{},global=summary?.global||{},value=Math.max(0,Math.min(100,Math.floor(Number(reward.progressPercent)||0))),cycle=Number.isSafeInteger(reward.cycle)&&reward.cycle>1?reward.cycle:null;brandSecond.textContent=cycle===null?'COUNTER':'CYCLE '+cycle;brandLabel.setAttribute('aria-label',cycle===null?'KitCode Counter':'KitCode cycle '+cycle);percent.textContent=value+'%';countValue.textContent=String(Math.max(0,Math.floor(Number(reward.totalEquals)||0)));workedValue.textContent=formatDuration(global.totalActiveSeconds);idleValue.textContent=formatDuration(global.totalIdleSeconds);updateProgress(value);main.classList.remove('offline')}
|
|
38
|
+
function render(summary){const reward=summary?.reward||{},global=summary?.global||{},value=Math.max(0,Math.min(100,Math.floor(Number(reward.progressPercent)||0))),cycle=Number.isSafeInteger(reward.cycle)&&reward.cycle>1?reward.cycle:null,ended=reward.campaign?.ended===true;brandSecond.textContent=ended?'ENDED':cycle===null?'COUNTER':'CYCLE '+cycle;brandLabel.setAttribute('aria-label',ended?'KitCode campaign ended':cycle===null?'KitCode Counter':'KitCode cycle '+cycle);percent.textContent=value+'%';countValue.textContent=String(Math.max(0,Math.floor(Number(reward.totalEquals)||0)));workedValue.textContent=formatDuration(global.totalActiveSeconds);idleValue.textContent=formatDuration(global.totalIdleSeconds);updateProgress(value);main.classList.remove('offline')}
|
|
39
39
|
function offline(){percent.textContent='--';countValue.textContent='--';workedValue.textContent='--';idleValue.textContent='--';updateProgress(0);main.classList.add('offline')}
|
|
40
40
|
async function sync(){try{const response=await fetch(API_BASE+'/api/summary');if(!response.ok)throw new Error();render(await response.json())}catch{offline()}}
|
|
41
41
|
const hideButton=document.getElementById('hideButton');function hideCompanion(){try{const result=window.kitcodeCompanion?.hide?.();if(result&&typeof result.catch==='function')result.catch(()=>window.close());if(!result)window.close()}catch{window.close()}}
|
|
@@ -47,7 +47,9 @@ sync();const events=new EventSource(API_BASE+'/api/events');events.addEventListe
|
|
|
47
47
|
|
|
48
48
|
// Since 2026-10-22 its label names the cycle counted in from cycle 2 on, "KITCODE CYCLE 2" where it says "KITCODE COUNTER",
|
|
49
49
|
// on the same two lines, so that the counters keep their room (docs/reward-rules.md §2, "Cycles"); its percent is that
|
|
50
|
-
// cycle's, as the summary's is.
|
|
50
|
+
// cycle's, as the summary's is. Since 2026-10-23 (tracking/2-cli; D4 of docs/plans/2026-10-22-server-tracking-config.md) it
|
|
51
|
+
// reads "KITCODE ENDED" once the summary says the campaign has ended, `reward.campaign.ended`, whatever the cycle; the counters
|
|
52
|
+
// go on, as counting does.
|
|
51
53
|
//
|
|
52
54
|
// The button opens the dashboard, where each milestone reached shows its message: "See more". Until 2026-10-01 it was
|
|
53
55
|
// "Show code", and lit while a 10–30 % code was ready to show there; the break codes are gone (the operator's decision
|
package/src/hook-prompt.mjs
CHANGED
|
@@ -7,6 +7,7 @@ import {getDiskRewardSummary, getReadyUnannouncedTiers, markTiersAnnounced} from
|
|
|
7
7
|
import {STORE_DIR, StateLockError, withStateLock} from './store.mjs';
|
|
8
8
|
import {isProcessAlive, readTrackerRecord} from './tracker-instance.mjs';
|
|
9
9
|
import {problemSentences, TRACKER_STOPPED} from './tracker-problems.mjs';
|
|
10
|
+
import {campaignEnded, trackingConfigInForce} from './tracking-config.mjs';
|
|
10
11
|
|
|
11
12
|
export const HOOK_LOG_PATH = path.join(STORE_DIR, 'hook.log');
|
|
12
13
|
|
|
@@ -209,6 +210,14 @@ export async function runPromptHook({source}) {
|
|
|
209
210
|
return;
|
|
210
211
|
}
|
|
211
212
|
|
|
213
|
+
// After the campaign's end, by this computer's clock (since 2026-10-23, tracking/2-cli; D4 of
|
|
214
|
+
// docs/plans/2026-10-22-server-tracking-config.md), the hook adds nothing: no progress, no milestone, no notification, and nothing
|
|
215
|
+
// written. The end is the last good copy's, read from disk with its bounds checked and no signature: the hook never fetches
|
|
216
|
+
// (src/tracking-config.mjs; hard rule 7). Counting goes on in the tracker, so an end moved later brings every word back.
|
|
217
|
+
if (campaignEnded(trackingConfigInForce())) {
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
|
|
212
221
|
const {reward, announced} = collectPromptProgress(source);
|
|
213
222
|
|
|
214
223
|
if (announced.length === 0) {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import {equalsLedgerFrom} from './equals-ledger.mjs';
|
|
2
2
|
import {focusTimeFrom} from './focus-clock.mjs';
|
|
3
3
|
import {readMilestoneRecord} from './milestone-record.mjs';
|
|
4
|
-
import {decideMilestones, MILESTONE_PERCENTS, REWARD_TIERS} from './reward.mjs';
|
|
4
|
+
import {decideMilestones, MILESTONE_PERCENTS, milestonesOf, REWARD_TIERS} from './reward.mjs';
|
|
5
5
|
|
|
6
6
|
// When each milestone first became ready (docs/reward-rules.md §6.1), for the claim bundle. Kept in
|
|
7
7
|
// state.json, where the tracker writes the progress it belongs to, as `milestonesReached`:
|
|
@@ -63,7 +63,12 @@ function redeemedIn(stored) {
|
|
|
63
63
|
// `focusSeconds` is the tracker's own clock, when it folds a scan between two saves, ahead of the file's by up to one
|
|
64
64
|
// save. One write reaches one milestone at most: the next counts from that write's totals, so nothing it credited counts
|
|
65
65
|
// toward the next. A milestone reached with no totals known, and freshSince not yet written, has it written now.
|
|
66
|
-
|
|
66
|
+
//
|
|
67
|
+
// Since 2026-10-23 (tracking/2-cli) a milestone is found reached against the targets in force (reward.mjs milestonesOf;
|
|
68
|
+
// tracking-config.mjs), or `config`'s, a tracking config, when one is given: the record keeps every milestone it holds whatever
|
|
69
|
+
// the targets do, so a raise takes nothing reached away, and a target lowered under what the milestone in progress earned is
|
|
70
|
+
// recorded reached by this write.
|
|
71
|
+
export function noteMilestonesReached(stored, now = new Date(Date.now()), {focusSeconds, config} = {}) {
|
|
67
72
|
const record = stored.milestonesReached;
|
|
68
73
|
const read = readMilestoneRecord(record, MILESTONE_PERCENTS);
|
|
69
74
|
|
|
@@ -72,7 +77,7 @@ export function noteMilestonesReached(stored, now = new Date(Date.now()), {focus
|
|
|
72
77
|
}
|
|
73
78
|
|
|
74
79
|
const totals = totalsIn(stored, focusSeconds);
|
|
75
|
-
const decided = decideMilestones({totals, record, redeemed: redeemedIn(stored)});
|
|
80
|
+
const decided = decideMilestones({totals, record, redeemed: redeemedIn(stored), ...(config === undefined ? {} : {milestones: milestonesOf(config)})});
|
|
76
81
|
const at = now.toISOString();
|
|
77
82
|
const atTotals = {at, focusSeconds: totals.seconds, equals: totals.equals};
|
|
78
83
|
|
package/src/reward.mjs
CHANGED
|
@@ -5,6 +5,7 @@ import {nextBreakReminder} from './next-break-reminder.mjs';
|
|
|
5
5
|
import {KITCODE_DISPLAY_MILESTONES, KITCODE_REWARD_TIERS} from './integration-spec.mjs';
|
|
6
6
|
import {readMilestoneRecord} from './milestone-record.mjs';
|
|
7
7
|
import {loadState, updateState} from './store.mjs';
|
|
8
|
+
import {campaignEnded, trackingConfigInForce} from './tracking-config.mjs';
|
|
8
9
|
|
|
9
10
|
export const REWARD_TIERS = KITCODE_REWARD_TIERS;
|
|
10
11
|
export const CAMPAIGN_MILESTONES = KITCODE_DISPLAY_MILESTONES;
|
|
@@ -18,15 +19,26 @@ export const MILESTONE_PERCENTS = REWARD_MILESTONES.map((milestone) => milestone
|
|
|
18
19
|
// rewardSettings.requiredSeconds in state.json. A requiredSeconds an older KitCode stored stays in the
|
|
19
20
|
// file, unread, as the removed requiredEquals does. Since 2026-10-20 each milestone names its own time in the table
|
|
20
21
|
// (integration-spec.mjs), where it was this × its percent: the same numbers in the tester table, which held the operator's
|
|
21
|
-
// from 2026-10-21 to 2026-10-22, 100 %'s 8 h, and since then its own again, 100 %'s 3600 s.
|
|
22
|
+
// from 2026-10-21 to 2026-10-22, 100 %'s 8 h, and since then its own again, 100 %'s 3600 s. The = targets are the table's too; no
|
|
23
|
+
// setting of the user's changes either (the --reward-equals setting, which gated nothing, was removed on 2026-09-24). Since
|
|
24
|
+
// 2026-10-23 these are the bundled table's, the last of the three fallbacks: the summary's requiredSeconds and requiredEquals are
|
|
25
|
+
// the last milestone's in force (milestonesOf, below), which the server's tracking config may set (tracking-config.mjs).
|
|
22
26
|
const LAST_MILESTONE = REWARD_MILESTONES[REWARD_MILESTONES.length - 1];
|
|
23
27
|
export const REWARD_SECONDS = LAST_MILESTONE.requiredSeconds;
|
|
24
|
-
// The = the last milestone needs. The = targets are the table's; no setting changes them (the
|
|
25
|
-
// --reward-equals setting, which gated nothing, was removed on 2026-09-24).
|
|
26
|
-
const FINAL_EQUALS = LAST_MILESTONE.requiredEquals;
|
|
27
28
|
|
|
28
29
|
const VALID_TIER_PERCENTS = new Set(REWARD_TIERS.map((tier) => tier.percent));
|
|
29
30
|
|
|
31
|
+
// The milestones, with the targets of `config`, a tracking config (tracking-config.mjs): by default the one in force, the
|
|
32
|
+
// document the tracker took from the server, its last good copy, or the table this KitCode bundles, the REWARD_MILESTONES above.
|
|
33
|
+
// Since 2026-10-23 (tracking/2-cli; docs/plans/2026-10-22-server-tracking-config.md) every milestone is decided against the
|
|
34
|
+
// targets in force at the moment it is decided: a milestone reached stays reached, whatever the targets do after (the record says
|
|
35
|
+
// so); the one in progress keeps what it earned since it began, and is reached once that meets the targets now in force, so a
|
|
36
|
+
// target lowered under what it earned reaches it at the next write, and one raised only lowers its percent. The percents are the
|
|
37
|
+
// contract's, 30, 50 and 100, and 30 % the one tier, as before.
|
|
38
|
+
export function milestonesOf(config = trackingConfigInForce()) {
|
|
39
|
+
return config.milestones.map(({percent, focusSeconds, equals}) => ({percent, requiredSeconds: focusSeconds, requiredEquals: equals, rewardBacked: VALID_TIER_PERCENTS.has(percent)}));
|
|
40
|
+
}
|
|
41
|
+
|
|
30
42
|
function isObject(value) {
|
|
31
43
|
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
32
44
|
}
|
|
@@ -201,7 +213,12 @@ function stackedReached(totals, redeemed) {
|
|
|
201
213
|
// stacked; otherwise each entry {percent, reached, recorded, countedFrom, end}, `countedFrom` the totals the milestone counts
|
|
202
214
|
// from, or null for one that has not begun, or whose start is not known; `end` the totals it was reached at, null when not
|
|
203
215
|
// known; and `cycle` {number, startedAt, start}, the cycle counted in, when it began (null for cycle 1) and its start totals.
|
|
204
|
-
|
|
216
|
+
//
|
|
217
|
+
// Since 2026-10-23 `milestones` gives the targets, milestonesOf's: by default those in force (tracking-config.mjs). What was reached
|
|
218
|
+
// before the record began, and a record not what KitCode writes, are read as the milestones stacked by the table this KitCode bundles,
|
|
219
|
+
// as before, never by the targets in force: they were reached under a KitCode's own table, and the server's raise must not take away
|
|
220
|
+
// what the record's first write would keep (a ruling of tracking/2-cli, docs/reward-rules.md §2).
|
|
221
|
+
export function decideMilestones({totals, record, redeemed = new Set(), milestones = milestonesOf()}) {
|
|
205
222
|
const read = readMilestoneRecord(record, MILESTONE_PERCENTS);
|
|
206
223
|
|
|
207
224
|
if (read.state === 'damaged') {
|
|
@@ -222,7 +239,7 @@ export function decideMilestones({totals, record, redeemed = new Set()}) {
|
|
|
222
239
|
let start = later ? {...current.start} : {seconds: 0, equals: 0};
|
|
223
240
|
let startKnown = true;
|
|
224
241
|
|
|
225
|
-
for (const milestone of
|
|
242
|
+
for (const milestone of milestones) {
|
|
226
243
|
const entry = before.get(milestone.percent);
|
|
227
244
|
|
|
228
245
|
if (start === null) {
|
|
@@ -282,15 +299,22 @@ function timeLeft(totals, milestones) {
|
|
|
282
299
|
// voucher (decideMilestones), and `cycleStartedAt`, when a later one began, null for cycle 1; both null for a record that is
|
|
283
300
|
// not what KitCode writes. Its milestones and tiers are that cycle's: in a later one, none is `redeemed`, a code an earlier
|
|
284
301
|
// KitCode recorded as shown being cycle 1's.
|
|
285
|
-
|
|
302
|
+
//
|
|
303
|
+
// Since 2026-10-23 (tracking/2-cli; docs/plans/2026-10-22-server-tracking-config.md) its targets are those of `config`, a tracking
|
|
304
|
+
// config (tracking-config.mjs), by default the one in force, and the summary says which: `targets`, {source, issuedAt}, `source`
|
|
305
|
+
// 'server', 'saved' or 'bundled', `issuedAt` the document's, null for the bundled table; and `campaign`, {endsAt, ended}, the end
|
|
306
|
+
// the config gives, an instant or null, and whether it has passed by `now`, the user's clock. Once it has, `nextBreak` is null:
|
|
307
|
+
// the campaign names no next milestone, though counting goes on, and a milestone reached is reached as before.
|
|
308
|
+
export function buildRewardSummary({earnedSeconds, totalEquals, ledger, record, config = trackingConfigInForce(), now = Date.now()}) {
|
|
309
|
+
const targets = milestonesOf(config);
|
|
286
310
|
const records = tierRecordMap(ledger);
|
|
287
311
|
const normalizedEarnedSeconds = Math.floor(Math.max(0, Number(earnedSeconds) || 0));
|
|
288
312
|
const normalizedTotalEquals = Math.max(0, Number(totalEquals) || 0);
|
|
289
313
|
const totals = {seconds: normalizedEarnedSeconds, equals: normalizedTotalEquals};
|
|
290
314
|
const redeemed = new Set(REWARD_TIERS.filter((tier) => records.get(tier.percent)?.redeemed_at).map((tier) => tier.percent));
|
|
291
|
-
const decided = decideMilestones({totals, record, redeemed});
|
|
315
|
+
const decided = decideMilestones({totals, record, redeemed, milestones: targets});
|
|
292
316
|
const laterCycle = !decided.stacked && decided.cycle.number > 1;
|
|
293
|
-
const milestones =
|
|
317
|
+
const milestones = targets.map((milestone, index) => {
|
|
294
318
|
const record = records.get(milestone.percent);
|
|
295
319
|
const isRedeemed = milestone.rewardBacked && Boolean(record?.redeemed_at) && !laterCycle;
|
|
296
320
|
const unlocked = decided.stacked ? decided.reached.has(milestone.percent) : decided.entries[index].reached;
|
|
@@ -319,14 +343,18 @@ export function buildRewardSummary({earnedSeconds, totalEquals, ledger, record})
|
|
|
319
343
|
status: milestone.status,
|
|
320
344
|
}));
|
|
321
345
|
|
|
346
|
+
const last = targets[targets.length - 1];
|
|
347
|
+
const ended = campaignEnded(config, now);
|
|
322
348
|
const summary = {
|
|
323
|
-
requiredSeconds:
|
|
324
|
-
requiredEquals:
|
|
349
|
+
requiredSeconds: last.requiredSeconds,
|
|
350
|
+
requiredEquals: last.requiredEquals,
|
|
325
351
|
earnedSeconds: normalizedEarnedSeconds,
|
|
326
352
|
totalEquals: normalizedTotalEquals,
|
|
327
353
|
timeLeftSeconds: timeLeft(totals, milestones),
|
|
328
354
|
cycle: decided.stacked ? null : decided.cycle.number,
|
|
329
355
|
cycleStartedAt: decided.stacked ? null : decided.cycle.startedAt,
|
|
356
|
+
targets: {source: config.source ?? 'bundled', issuedAt: config.issuedAt ?? null},
|
|
357
|
+
campaign: {endsAt: config.campaignEndsAt ?? null, ended},
|
|
330
358
|
tiers,
|
|
331
359
|
milestones,
|
|
332
360
|
};
|
|
@@ -335,7 +363,7 @@ export function buildRewardSummary({earnedSeconds, totalEquals, ledger, record})
|
|
|
335
363
|
// §10). `progress` is the same number as a fraction; until 2026-09-24 it was time alone.
|
|
336
364
|
summary.progressPercent = breakProgressPercent(summary);
|
|
337
365
|
summary.progress = summary.progressPercent / 100;
|
|
338
|
-
summary.nextBreak = nextBreakReminder(summary);
|
|
366
|
+
summary.nextBreak = ended ? null : nextBreakReminder(summary);
|
|
339
367
|
return summary;
|
|
340
368
|
}
|
|
341
369
|
|
|
@@ -0,0 +1,501 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import {writeFileAtomically} from './atomic-file.mjs';
|
|
5
|
+
import {cycleReceiptKey} from './cycle-receipt.mjs';
|
|
6
|
+
import {CYCLE_RECEIPT_PUBLIC_KEY, DASHBOARD_URL, KITCODE_DISPLAY_MILESTONES, KITCODE_REWARD_TIERS} from './integration-spec.mjs';
|
|
7
|
+
import {STORE_DIR} from './store.mjs';
|
|
8
|
+
|
|
9
|
+
// The campaign's targets and its end, from the server (docs/plans/2026-10-22-server-tracking-config.md, THE TRACKER; docs/backend.md,
|
|
10
|
+
// "The tracking config"; docs/reward-rules.md §2). The claim backend serves one signed document at the dashboard's address plus
|
|
11
|
+
// /v1/tracking-config: the six milestone targets in force and when the campaign ends. A tracker of a build that carries the claim
|
|
12
|
+
// backend's public key fetches it about once an hour, checks its signature, and counts by it; so a change of targets reaches every
|
|
13
|
+
// install within the hour, with no new version of KitCode to install. Nothing else fetches it: never the prompt hook, a command or a
|
|
14
|
+
// window (hard rule 7). The repository's own build carries no key, and so never fetches, and counts by the table it bundles (R22.1).
|
|
15
|
+
//
|
|
16
|
+
// <payload>.<signature>
|
|
17
|
+
//
|
|
18
|
+
// `payload` is base64url (no padding) of the UTF-8 JSON {"v":1,"issuedAt":…,"milestones":[{"percent":30,"focusSeconds":…,"equals":…},
|
|
19
|
+
// {"percent":50,…},{"percent":100,…}],"campaignEndsAt":…|null}, those four fields alone; `signature`, base64url of the 64-byte Ed25519
|
|
20
|
+
// signature of the ASCII text `kitcode-tracking-config-1.` followed by `payload` as it stands, with the cycle receipts' key
|
|
21
|
+
// (cycle-receipt.mjs), whose text is `kitcode-cycle-receipt-1.`: a receipt never passes for a config, or a config for a receipt. The
|
|
22
|
+
// contract is packages/claim-contract/tracking-config.mjs, which only tests read; this reader is the CLI's own, held to the
|
|
23
|
+
// contract's fixtures by test/tracking-config.test.mjs, problem for problem, in the contract's order.
|
|
24
|
+
//
|
|
25
|
+
// What the tracker took is kept in memory, and in ~/.kitcode/tracking-config.json, its last good copy: written whole, 0600, never
|
|
26
|
+
// state.json (hard rule 4), and never deleted or written over by anything but a later good document. The targets in force are,
|
|
27
|
+
// in order: the copy the tracker took this run, the last good copy on disk, then the table this KitCode bundles
|
|
28
|
+
// (integration-spec.mjs). The prompt hook and the commands read the copy on disk and check its shape and bounds, never its
|
|
29
|
+
// signature, which only the tracker checks, as it fetched it; and `targets.source` says which of the three is in force, so that a
|
|
30
|
+
// fallback never looks like the healthy path.
|
|
31
|
+
|
|
32
|
+
export const TRACKING_CONFIG_CONTEXT = 'kitcode-tracking-config-1.';
|
|
33
|
+
export const TRACKING_CONFIG_PATH = path.join(STORE_DIR, 'tracking-config.json');
|
|
34
|
+
export const TRACKING_CONFIG_DISPLAY_PATH = '~/.kitcode/tracking-config.json';
|
|
35
|
+
// The longest document read, in characters, and the most of a body the tracker reads, in bytes: 2 KiB. A document is about 430.
|
|
36
|
+
export const MAX_TRACKING_CONFIG_LENGTH = 2048;
|
|
37
|
+
// The bounds (the operator's decision D2 of 2026-10-22): the percents 30, 50 and 100, in that order; each milestone's focus time
|
|
38
|
+
// 60 to 172,800 s and its = 1 to 100,000; each milestone's targets at least the one before's.
|
|
39
|
+
export const TRACKING_CONFIG_PERCENTS = Object.freeze([30, 50, 100]);
|
|
40
|
+
export const TRACKING_CONFIG_BOUNDS = Object.freeze({
|
|
41
|
+
focusSeconds: Object.freeze({min: 60, max: 172_800}),
|
|
42
|
+
equals: Object.freeze({min: 1, max: 100_000}),
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
// When the tracker fetches: a random moment 5 to 120 s after it starts, so that a fleet started together does not ask at once;
|
|
46
|
+
// then whenever its last try is over an hour old, which it checks every minute; and at once when it finds the computer has slept,
|
|
47
|
+
// a check that came three minutes or more after the one before. 10 s for the whole answer, its body included.
|
|
48
|
+
export const FIRST_FETCH_MS = Object.freeze({min: 5_000, max: 120_000});
|
|
49
|
+
export const FETCH_AGAIN_AFTER_MS = 60 * 60 * 1000;
|
|
50
|
+
export const CHECK_EVERY_MS = 60 * 1000;
|
|
51
|
+
export const WOKE_AFTER_MS = 3 * CHECK_EVERY_MS;
|
|
52
|
+
export const FETCH_TIMEOUT_MS = 10_000;
|
|
53
|
+
|
|
54
|
+
const FILE_MODE = 0o600;
|
|
55
|
+
const PAYLOAD_KEYS = ['v', 'issuedAt', 'milestones', 'campaignEndsAt'];
|
|
56
|
+
const MILESTONE_KEYS = ['percent', 'focusSeconds', 'equals'];
|
|
57
|
+
const DOCUMENT_TEXT = /^([A-Za-z0-9_-]+)\.([A-Za-z0-9_-]{86})$/u;
|
|
58
|
+
const INSTANT = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/u;
|
|
59
|
+
const SILENT_LOG = {info() {}, warn() {}, error() {}};
|
|
60
|
+
|
|
61
|
+
function isObject(value) {
|
|
62
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function hasExactly(value, keys) {
|
|
66
|
+
return isObject(value) && Object.keys(value).length === keys.length && keys.every((key) => Object.hasOwn(value, key));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function isInstantText(value) {
|
|
70
|
+
return typeof value === 'string' && INSTANT.test(value);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function isRealInstant(value) {
|
|
74
|
+
const time = Date.parse(value);
|
|
75
|
+
return Number.isFinite(time) && new Date(time).toISOString() === value;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function within(value, {min, max}) {
|
|
79
|
+
return value >= min && value <= max;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Whether `words`, a payload as JSON.parse reads it, is a tracking config: {ok: true, config: {issuedAt, milestones, campaignEndsAt}},
|
|
83
|
+
// a copy of its words, or {ok: false, problem}, the contract's: 'version', a `v` present and not 1, a later version, refused whole;
|
|
84
|
+
// 'unreadable', not the contract's shape, or an instant that is not a real one; 'bounds', outside the bounds above. Pure. What the
|
|
85
|
+
// tracker holds a signed document to, and what the hook and the commands hold the last good copy's words to.
|
|
86
|
+
export function judgeTrackingConfig(words) {
|
|
87
|
+
if (!isObject(words)) {
|
|
88
|
+
return {ok: false, problem: 'unreadable'};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (Object.hasOwn(words, 'v') && words.v !== 1) {
|
|
92
|
+
return {ok: false, problem: 'version'};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const shaped = hasExactly(words, PAYLOAD_KEYS)
|
|
96
|
+
&& isInstantText(words.issuedAt)
|
|
97
|
+
&& (words.campaignEndsAt === null || isInstantText(words.campaignEndsAt))
|
|
98
|
+
&& Array.isArray(words.milestones) && words.milestones.length === TRACKING_CONFIG_PERCENTS.length
|
|
99
|
+
&& words.milestones.every((milestone) => hasExactly(milestone, MILESTONE_KEYS) && MILESTONE_KEYS.every((key) => Number.isInteger(milestone[key])));
|
|
100
|
+
|
|
101
|
+
if (!shaped || !isRealInstant(words.issuedAt) || (words.campaignEndsAt !== null && !isRealInstant(words.campaignEndsAt))) {
|
|
102
|
+
return {ok: false, problem: 'unreadable'};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const inBounds = words.milestones.every((milestone, index) => {
|
|
106
|
+
const before = words.milestones[index - 1];
|
|
107
|
+
|
|
108
|
+
return milestone.percent === TRACKING_CONFIG_PERCENTS[index]
|
|
109
|
+
&& within(milestone.focusSeconds, TRACKING_CONFIG_BOUNDS.focusSeconds)
|
|
110
|
+
&& within(milestone.equals, TRACKING_CONFIG_BOUNDS.equals)
|
|
111
|
+
&& (before === undefined || (milestone.focusSeconds >= before.focusSeconds && milestone.equals >= before.equals));
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
if (!inBounds) {
|
|
115
|
+
return {ok: false, problem: 'bounds'};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
ok: true,
|
|
120
|
+
config: {
|
|
121
|
+
issuedAt: words.issuedAt,
|
|
122
|
+
milestones: words.milestones.map(({percent, focusSeconds, equals}) => ({percent, focusSeconds, equals})),
|
|
123
|
+
campaignEndsAt: words.campaignEndsAt,
|
|
124
|
+
},
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// The public key a build carries: the cycle receipts' own (integration-spec.mjs, CYCLE_RECEIPT_PUBLIC_KEY). Null when it is not one.
|
|
129
|
+
export function trackingConfigKey(text = CYCLE_RECEIPT_PUBLIC_KEY) {
|
|
130
|
+
return cycleReceiptKey(text);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Reads `text` with `publicKey` (a KeyObject, trackingConfigKey's): {ok: true, config}, or {ok: false, problem}, the first of the
|
|
134
|
+
// contract's, in its order: 'oversized', longer than MAX_TRACKING_CONFIG_LENGTH; 'unreadable', not two base64url parts joined by a
|
|
135
|
+
// dot; 'signature', not signed over the context and the payload with the key's pair, checked over the bytes as they came, before
|
|
136
|
+
// anything in them is read; then judgeTrackingConfig's; and 'stale', an issuedAt no later than `after`, the issuedAt of the last
|
|
137
|
+
// document taken: an older one replayed, or the same one again.
|
|
138
|
+
export function readTrackingConfig(text, publicKey, {after = null} = {}) {
|
|
139
|
+
if (after !== null && !(isInstantText(after) && isRealInstant(after))) {
|
|
140
|
+
throw new TypeError(`after must be an instant, as toISOString writes it, or null: ${String(after)}`);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (typeof text !== 'string') {
|
|
144
|
+
return {ok: false, problem: 'unreadable'};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (text.length > MAX_TRACKING_CONFIG_LENGTH) {
|
|
148
|
+
return {ok: false, problem: 'oversized'};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const parts = DOCUMENT_TEXT.exec(text);
|
|
152
|
+
|
|
153
|
+
if (parts === null) {
|
|
154
|
+
return {ok: false, problem: 'unreadable'};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const [, payload, signature] = parts;
|
|
158
|
+
let signed;
|
|
159
|
+
|
|
160
|
+
try {
|
|
161
|
+
signed = crypto.verify(null, Buffer.from(`${TRACKING_CONFIG_CONTEXT}${payload}`, 'ascii'), publicKey, Buffer.from(signature, 'base64url'));
|
|
162
|
+
} catch {
|
|
163
|
+
signed = false;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (!signed) {
|
|
167
|
+
return {ok: false, problem: 'signature'};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
let words;
|
|
171
|
+
|
|
172
|
+
try {
|
|
173
|
+
words = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'));
|
|
174
|
+
} catch {
|
|
175
|
+
return {ok: false, problem: 'unreadable'};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const judged = judgeTrackingConfig(words);
|
|
179
|
+
|
|
180
|
+
if (!judged.ok) {
|
|
181
|
+
return judged;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (after !== null && Date.parse(judged.config.issuedAt) <= Date.parse(after)) {
|
|
185
|
+
return {ok: false, problem: 'stale'};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return judged;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Where this build fetches: DASHBOARD_URL plus v1/tracking-config, an https URL, or, for this repository's tests, an http one on this
|
|
192
|
+
// machine; and only in a build that carries a key. Null otherwise: such a build never fetches, and never reads a copy either.
|
|
193
|
+
export function trackingConfigUrl({dashboardUrl = DASHBOARD_URL, publicKey = CYCLE_RECEIPT_PUBLIC_KEY} = {}) {
|
|
194
|
+
if (trackingConfigKey(publicKey) === null || typeof dashboardUrl !== 'string') {
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
try {
|
|
199
|
+
const url = new URL('v1/tracking-config', dashboardUrl);
|
|
200
|
+
const loopback = url.hostname === '127.0.0.1' || url.hostname === 'localhost' || url.hostname === '[::1]';
|
|
201
|
+
return url.protocol === 'https:' || (url.protocol === 'http:' && loopback) ? url : null;
|
|
202
|
+
} catch {
|
|
203
|
+
return null;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// --- The targets in force ----------------------------------------------------------------------------------------------------
|
|
208
|
+
|
|
209
|
+
// The table this KitCode bundles, in the document's words: the milestone table in force (integration-spec.mjs, MILESTONE_TABLE).
|
|
210
|
+
export const BUNDLED_TRACKING_CONFIG = Object.freeze({
|
|
211
|
+
issuedAt: null,
|
|
212
|
+
milestones: Object.freeze([...KITCODE_REWARD_TIERS, ...KITCODE_DISPLAY_MILESTONES]
|
|
213
|
+
.map(({percent, requiredSeconds, requiredEquals}) => Object.freeze({percent, focusSeconds: requiredSeconds, equals: requiredEquals}))),
|
|
214
|
+
campaignEndsAt: null,
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
// What the tracker took this run: {config, document}. Only the tracker's loop sets it, once a document is taken; nothing else in
|
|
218
|
+
// any process does.
|
|
219
|
+
let held = null;
|
|
220
|
+
// The last good copy as last read, by the file's identity, so that a summary built every second reads the file again only once it
|
|
221
|
+
// has changed.
|
|
222
|
+
let savedRead = {identity: null, copy: null};
|
|
223
|
+
|
|
224
|
+
function fileIdentity(filePath) {
|
|
225
|
+
try {
|
|
226
|
+
const stat = fs.statSync(filePath);
|
|
227
|
+
return `${stat.ino}:${stat.size}:${stat.mtimeMs}`;
|
|
228
|
+
} catch {
|
|
229
|
+
return 'missing';
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// The last good copy on disk, {document, issuedAt, milestones, campaignEndsAt, receivedAt}, its words checked against the shape and
|
|
234
|
+
// the bounds, not its signature; null when there is none, or it cannot be read, or its words are not a tracking config's. Never
|
|
235
|
+
// throws, never writes: a copy that is not one is left as it is, and the bundled table counts.
|
|
236
|
+
export function readSavedTrackingConfig() {
|
|
237
|
+
const identity = fileIdentity(TRACKING_CONFIG_PATH);
|
|
238
|
+
|
|
239
|
+
if (identity === savedRead.identity) {
|
|
240
|
+
return savedRead.copy;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
let copy = null;
|
|
244
|
+
|
|
245
|
+
try {
|
|
246
|
+
const stored = JSON.parse(fs.readFileSync(TRACKING_CONFIG_PATH, 'utf8'));
|
|
247
|
+
const judged = isObject(stored)
|
|
248
|
+
? judgeTrackingConfig({v: 1, issuedAt: stored.issuedAt, milestones: stored.milestones, campaignEndsAt: stored.campaignEndsAt})
|
|
249
|
+
: {ok: false};
|
|
250
|
+
|
|
251
|
+
if (judged.ok) {
|
|
252
|
+
copy = {
|
|
253
|
+
document: typeof stored.document === 'string' ? stored.document : null,
|
|
254
|
+
...judged.config,
|
|
255
|
+
receivedAt: typeof stored.receivedAt === 'string' ? stored.receivedAt : null,
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
} catch {
|
|
259
|
+
copy = null;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
savedRead = {identity, copy};
|
|
263
|
+
return copy;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Writes the last good copy: the document as it came, its words, and when this tracker took it. Whole, 0600; throws what the write
|
|
267
|
+
// throws, and the copy before stays as it was.
|
|
268
|
+
export function saveTrackingConfig({config, document}, receivedAt = new Date()) {
|
|
269
|
+
fs.mkdirSync(STORE_DIR, {recursive: true});
|
|
270
|
+
const copy = {document, issuedAt: config.issuedAt, milestones: config.milestones, campaignEndsAt: config.campaignEndsAt, receivedAt: receivedAt.toISOString()};
|
|
271
|
+
writeFileAtomically(TRACKING_CONFIG_PATH, `${JSON.stringify(copy, null, 2)}\n`, {mode: FILE_MODE});
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Whether this build fetches, worked out once: the key and the address are the build's own, and never change while it runs.
|
|
275
|
+
let fetches;
|
|
276
|
+
|
|
277
|
+
function buildFetches() {
|
|
278
|
+
fetches ??= trackingConfigUrl() !== null;
|
|
279
|
+
return fetches;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// The targets and the end in force now: {source, issuedAt, milestones, campaignEndsAt}, `source` 'server' for the document this
|
|
283
|
+
// tracker took this run, 'saved' for the last good copy, 'bundled' for the table this KitCode bundles; `milestones` in the
|
|
284
|
+
// document's words, {percent, focusSeconds, equals}. A build that never fetches counts by the bundled table alone.
|
|
285
|
+
export function trackingConfigInForce() {
|
|
286
|
+
if (!buildFetches()) {
|
|
287
|
+
return {source: 'bundled', ...BUNDLED_TRACKING_CONFIG};
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
if (held !== null) {
|
|
291
|
+
return {source: 'server', ...held.config};
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const saved = readSavedTrackingConfig();
|
|
295
|
+
|
|
296
|
+
if (saved !== null) {
|
|
297
|
+
return {source: 'saved', issuedAt: saved.issuedAt, milestones: saved.milestones, campaignEndsAt: saved.campaignEndsAt};
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
return {source: 'bundled', ...BUNDLED_TRACKING_CONFIG};
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Whether the campaign has ended by `now`, the user's clock: an end set, and reached.
|
|
304
|
+
export function campaignEnded(config, now = Date.now()) {
|
|
305
|
+
return typeof config?.campaignEndsAt === 'string' && Date.parse(config.campaignEndsAt) <= now;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// For this repository's tests alone: forgets what this process took, as a tracker that has just started.
|
|
309
|
+
export function forgetHeldTrackingConfig() {
|
|
310
|
+
held = null;
|
|
311
|
+
savedRead = {identity: null, copy: null};
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// --- The fetch ---------------------------------------------------------------------------------------------------------------
|
|
315
|
+
|
|
316
|
+
// The body, at most `limit` bytes, or null past it: a length declared above it is refused before a byte is read, and a body
|
|
317
|
+
// that grows past it is cancelled as it does.
|
|
318
|
+
async function readLimited(response, limit) {
|
|
319
|
+
const declared = Number(response.headers.get('content-length'));
|
|
320
|
+
|
|
321
|
+
if (Number.isFinite(declared) && declared > limit) {
|
|
322
|
+
await response.body?.cancel().catch(() => {});
|
|
323
|
+
return null;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
if (!response.body) {
|
|
327
|
+
return '';
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const reader = response.body.getReader();
|
|
331
|
+
const chunks = [];
|
|
332
|
+
let size = 0;
|
|
333
|
+
|
|
334
|
+
for (;;) {
|
|
335
|
+
const {done, value} = await reader.read();
|
|
336
|
+
|
|
337
|
+
if (done) {
|
|
338
|
+
break;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
size += value.byteLength;
|
|
342
|
+
|
|
343
|
+
if (size > limit) {
|
|
344
|
+
await reader.cancel().catch(() => {});
|
|
345
|
+
return null;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
chunks.push(value);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
return Buffer.concat(chunks).toString('utf8');
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// One try: one GET of `url`, with no redirect followed, no cookie, and nothing of this install, the user or this KitCode's version
|
|
355
|
+
// in it; `timeoutMs` for the whole answer. The document is judged against what this tracker holds, `current`, {config, document}
|
|
356
|
+
// or null. Returns {outcome: 'taken', config, document}; {outcome: 'unchanged'}, the very document `current` holds, fetched again;
|
|
357
|
+
// {outcome: 'refused', problem, status?}, an answer that is not a document to take, for the first of: 'status', not 200 (a
|
|
358
|
+
// redirect among them); 'oversized', a body over 2 KiB; the reader's problems; or {outcome: 'failed', problem}: 'timeout', or
|
|
359
|
+
// 'unreachable'. Never throws.
|
|
360
|
+
export async function fetchTrackingConfig({url, publicKey, current = null, fetchImpl = globalThis.fetch, timeoutMs = FETCH_TIMEOUT_MS, signal} = {}) {
|
|
361
|
+
const deadline = AbortSignal.timeout(timeoutMs);
|
|
362
|
+
const abort = signal ? AbortSignal.any([signal, deadline]) : deadline;
|
|
363
|
+
|
|
364
|
+
try {
|
|
365
|
+
const response = await fetchImpl(url, {method: 'GET', redirect: 'manual', credentials: 'omit', signal: abort});
|
|
366
|
+
|
|
367
|
+
if (response.status !== 200) {
|
|
368
|
+
await response.body?.cancel().catch(() => {});
|
|
369
|
+
return {outcome: 'refused', problem: 'status', status: response.status};
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
const text = await readLimited(response, MAX_TRACKING_CONFIG_LENGTH);
|
|
373
|
+
|
|
374
|
+
if (text === null) {
|
|
375
|
+
return {outcome: 'refused', problem: 'oversized'};
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
if (current !== null && text === current.document) {
|
|
379
|
+
return {outcome: 'unchanged'};
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
const read = readTrackingConfig(text, publicKey, {after: current?.config.issuedAt ?? null});
|
|
383
|
+
return read.ok ? {outcome: 'taken', config: read.config, document: text} : {outcome: 'refused', problem: read.problem};
|
|
384
|
+
} catch (error) {
|
|
385
|
+
return {outcome: 'failed', problem: deadline.aborted || error?.name === 'TimeoutError' ? 'timeout' : 'unreachable'};
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// What a try did, for tracker.log: fixed words, the address and the instants alone.
|
|
390
|
+
function describeTry(result, url, inForce) {
|
|
391
|
+
const kept = `KitCode keeps the targets it had (${inForce.source}${inForce.issuedAt ? `, issued ${inForce.issuedAt}` : ''})`;
|
|
392
|
+
|
|
393
|
+
switch (result.outcome) {
|
|
394
|
+
case 'taken':
|
|
395
|
+
return `took the campaign's targets issued ${result.config.issuedAt} from ${url}${result.config.campaignEndsAt ? `, the campaign ending ${result.config.campaignEndsAt}` : ', no end set'}.`;
|
|
396
|
+
case 'unchanged':
|
|
397
|
+
return `the campaign's targets from ${url} are unchanged (issued ${inForce.issuedAt}).`;
|
|
398
|
+
case 'refused':
|
|
399
|
+
return `refused the answer from ${url} (${result.problem}${result.status === undefined ? '' : ` ${result.status}`}); ${kept}.`;
|
|
400
|
+
default:
|
|
401
|
+
return `could not fetch the campaign's targets from ${url} (${result.problem}); ${kept}.`;
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// While the tracker runs (bin/kitcode.mjs, runDaemon): the fetches, as above, each try alone, and none of them able to stop, block or
|
|
406
|
+
// slow the tracker; every failure stays here, in tracker.log. A document taken is saved, then held: the tracker counts by it at once.
|
|
407
|
+
// In a build that never fetches it does nothing. `now`, the wall clock, which tells a sleep from a minute; `random`, for the first
|
|
408
|
+
// fetch's moment. Returns {stop, tryNow}: `stop` ends the loop and any try under way; `tryNow`, a try at once, for tests.
|
|
409
|
+
export function startTrackingConfigLoop({log = SILENT_LOG, fetchImpl = globalThis.fetch, now = () => Date.now(), random = Math.random, url = trackingConfigUrl(), publicKey = trackingConfigKey(), timeoutMs = FETCH_TIMEOUT_MS} = {}) {
|
|
410
|
+
if (url === null || publicKey === null) {
|
|
411
|
+
return {stop: async () => {}, tryNow: async () => ({outcome: 'disabled'})};
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
const controller = new AbortController();
|
|
415
|
+
let lastTry = null;
|
|
416
|
+
let lastCheck = now();
|
|
417
|
+
let trying = null;
|
|
418
|
+
let stopped = false;
|
|
419
|
+
|
|
420
|
+
// The copy this try is judged against: what this run took, or the last good copy, whose issuedAt a document must be later than.
|
|
421
|
+
const current = () => {
|
|
422
|
+
if (held !== null) {
|
|
423
|
+
return held;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
const saved = readSavedTrackingConfig();
|
|
427
|
+
return saved === null ? null : {config: {issuedAt: saved.issuedAt, milestones: saved.milestones, campaignEndsAt: saved.campaignEndsAt}, document: saved.document};
|
|
428
|
+
};
|
|
429
|
+
|
|
430
|
+
const attempt = async () => {
|
|
431
|
+
const before = current();
|
|
432
|
+
const result = await fetchTrackingConfig({url, publicKey, current: before, fetchImpl, timeoutMs, signal: controller.signal});
|
|
433
|
+
|
|
434
|
+
if (result.outcome === 'taken' || result.outcome === 'unchanged') {
|
|
435
|
+
const taken = result.outcome === 'taken' ? {config: result.config, document: result.document} : before;
|
|
436
|
+
const saved = readSavedTrackingConfig();
|
|
437
|
+
|
|
438
|
+
// Saved first, unless the copy on disk is this one already; held whatever the save does, so that the tracker counts by what
|
|
439
|
+
// it checked, and a save that failed is tried again at the next try.
|
|
440
|
+
if (saved === null || saved.document !== taken.document) {
|
|
441
|
+
try {
|
|
442
|
+
saveTrackingConfig(taken, new Date(now()));
|
|
443
|
+
} catch (error) {
|
|
444
|
+
log.warn(`KitCode could not save the campaign's targets to ${TRACKING_CONFIG_DISPLAY_PATH} (${error?.code ?? error?.name ?? 'error'}); it counts by them until it stops, and tries again at its next fetch.`);
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
held = taken;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
(result.outcome === 'taken' ? log.info : result.outcome === 'unchanged' ? () => {} : log.warn)(describeTry(result, url.href, trackingConfigInForce()));
|
|
452
|
+
return result;
|
|
453
|
+
};
|
|
454
|
+
|
|
455
|
+
const tryNow = () => {
|
|
456
|
+
if (trying !== null) {
|
|
457
|
+
return trying;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
lastTry = now();
|
|
461
|
+
trying = attempt().catch(() => ({outcome: 'failed', problem: 'error'})).finally(() => {
|
|
462
|
+
trying = null;
|
|
463
|
+
});
|
|
464
|
+
return trying;
|
|
465
|
+
};
|
|
466
|
+
|
|
467
|
+
const first = setTimeout(() => {
|
|
468
|
+
if (!stopped && lastTry === null) {
|
|
469
|
+
tryNow();
|
|
470
|
+
}
|
|
471
|
+
}, FIRST_FETCH_MS.min + Math.floor(random() * (FIRST_FETCH_MS.max - FIRST_FETCH_MS.min + 1)));
|
|
472
|
+
|
|
473
|
+
const check = setInterval(() => {
|
|
474
|
+
const at = now();
|
|
475
|
+
const woke = at - lastCheck >= WOKE_AFTER_MS;
|
|
476
|
+
lastCheck = at;
|
|
477
|
+
|
|
478
|
+
if (stopped || trying !== null) {
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// Before the first try, only a wake brings it forward. After it: a try over an hour old, a wake, or a clock moved back past it.
|
|
483
|
+
if (lastTry === null ? woke : woke || at - lastTry > FETCH_AGAIN_AFTER_MS || at < lastTry) {
|
|
484
|
+
tryNow();
|
|
485
|
+
}
|
|
486
|
+
}, CHECK_EVERY_MS);
|
|
487
|
+
|
|
488
|
+
first.unref?.();
|
|
489
|
+
check.unref?.();
|
|
490
|
+
|
|
491
|
+
return {
|
|
492
|
+
tryNow,
|
|
493
|
+
async stop() {
|
|
494
|
+
stopped = true;
|
|
495
|
+
clearTimeout(first);
|
|
496
|
+
clearInterval(check);
|
|
497
|
+
controller.abort();
|
|
498
|
+
await trying?.catch(() => {});
|
|
499
|
+
},
|
|
500
|
+
};
|
|
501
|
+
}
|