vibeusage 0.2.8
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/LICENSE +21 -0
- package/README.md +182 -0
- package/README.zh-CN.md +182 -0
- package/bin/tracker.js +28 -0
- package/package.json +46 -0
- package/src/cli.js +64 -0
- package/src/commands/diagnostics.js +39 -0
- package/src/commands/init.js +798 -0
- package/src/commands/status.js +155 -0
- package/src/commands/sync.js +479 -0
- package/src/commands/uninstall.js +153 -0
- package/src/lib/browser-auth.js +175 -0
- package/src/lib/claude-config.js +190 -0
- package/src/lib/cli-ui.js +179 -0
- package/src/lib/codex-config.js +224 -0
- package/src/lib/debug-flags.js +9 -0
- package/src/lib/diagnostics.js +190 -0
- package/src/lib/fs.js +62 -0
- package/src/lib/gemini-config.js +284 -0
- package/src/lib/init-flow.js +48 -0
- package/src/lib/insforge-client.js +75 -0
- package/src/lib/insforge.js +23 -0
- package/src/lib/opencode-config.js +98 -0
- package/src/lib/progress.js +77 -0
- package/src/lib/prompt.js +20 -0
- package/src/lib/rollout.js +1317 -0
- package/src/lib/tracker-paths.js +66 -0
- package/src/lib/upload-throttle.js +129 -0
- package/src/lib/uploader.js +116 -0
- package/src/lib/vibescore-api.js +222 -0
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
const os = require('node:os');
|
|
2
|
+
const path = require('node:path');
|
|
3
|
+
const fs = require('node:fs/promises');
|
|
4
|
+
|
|
5
|
+
const { readJson } = require('../lib/fs');
|
|
6
|
+
const { readCodexNotify, readEveryCodeNotify } = require('../lib/codex-config');
|
|
7
|
+
const { isClaudeHookConfigured, buildClaudeHookCommand } = require('../lib/claude-config');
|
|
8
|
+
const {
|
|
9
|
+
resolveGeminiConfigDir,
|
|
10
|
+
resolveGeminiSettingsPath,
|
|
11
|
+
isGeminiHookConfigured,
|
|
12
|
+
buildGeminiHookCommand
|
|
13
|
+
} = require('../lib/gemini-config');
|
|
14
|
+
const { resolveOpencodeConfigDir, isOpencodePluginInstalled } = require('../lib/opencode-config');
|
|
15
|
+
const { normalizeState: normalizeUploadState } = require('../lib/upload-throttle');
|
|
16
|
+
const { collectTrackerDiagnostics } = require('../lib/diagnostics');
|
|
17
|
+
const { resolveTrackerPaths } = require('../lib/tracker-paths');
|
|
18
|
+
|
|
19
|
+
async function cmdStatus(argv = []) {
|
|
20
|
+
const opts = parseArgs(argv);
|
|
21
|
+
if (opts.diagnostics) {
|
|
22
|
+
const diagnostics = await collectTrackerDiagnostics();
|
|
23
|
+
process.stdout.write(JSON.stringify(diagnostics, null, 2) + '\n');
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const home = os.homedir();
|
|
28
|
+
const { trackerDir, binDir } = await resolveTrackerPaths({ home, migrate: true });
|
|
29
|
+
const configPath = path.join(trackerDir, 'config.json');
|
|
30
|
+
const queuePath = path.join(trackerDir, 'queue.jsonl');
|
|
31
|
+
const queueStatePath = path.join(trackerDir, 'queue.state.json');
|
|
32
|
+
const cursorsPath = path.join(trackerDir, 'cursors.json');
|
|
33
|
+
const notifySignalPath = path.join(trackerDir, 'notify.signal');
|
|
34
|
+
const throttlePath = path.join(trackerDir, 'sync.throttle');
|
|
35
|
+
const uploadThrottlePath = path.join(trackerDir, 'upload.throttle.json');
|
|
36
|
+
const autoRetryPath = path.join(trackerDir, 'auto.retry.json');
|
|
37
|
+
const codexHome = process.env.CODEX_HOME || path.join(home, '.codex');
|
|
38
|
+
const codexConfigPath = path.join(codexHome, 'config.toml');
|
|
39
|
+
const codeHome = process.env.CODE_HOME || path.join(home, '.code');
|
|
40
|
+
const codeConfigPath = path.join(codeHome, 'config.toml');
|
|
41
|
+
const claudeSettingsPath = path.join(home, '.claude', 'settings.json');
|
|
42
|
+
const geminiConfigDir = resolveGeminiConfigDir({ home, env: process.env });
|
|
43
|
+
const geminiSettingsPath = resolveGeminiSettingsPath({ configDir: geminiConfigDir });
|
|
44
|
+
const opencodeConfigDir = resolveOpencodeConfigDir({ home, env: process.env });
|
|
45
|
+
const notifyPath = path.join(binDir, 'notify.cjs');
|
|
46
|
+
const claudeHookCommand = buildClaudeHookCommand(notifyPath);
|
|
47
|
+
const geminiHookCommand = buildGeminiHookCommand(notifyPath);
|
|
48
|
+
|
|
49
|
+
const config = await readJson(configPath);
|
|
50
|
+
const cursors = await readJson(cursorsPath);
|
|
51
|
+
const queueState = (await readJson(queueStatePath)) || { offset: 0 };
|
|
52
|
+
const uploadThrottle = normalizeUploadState(await readJson(uploadThrottlePath));
|
|
53
|
+
const autoRetry = await readJson(autoRetryPath);
|
|
54
|
+
|
|
55
|
+
const queueSize = await safeStatSize(queuePath);
|
|
56
|
+
const pendingBytes = Math.max(0, queueSize - (queueState.offset || 0));
|
|
57
|
+
|
|
58
|
+
const lastNotify = (await safeReadText(notifySignalPath))?.trim() || null;
|
|
59
|
+
const lastNotifySpawn = parseEpochMsToIso((await safeReadText(throttlePath))?.trim() || null);
|
|
60
|
+
|
|
61
|
+
const codexNotify = await readCodexNotify(codexConfigPath);
|
|
62
|
+
const notifyConfigured = Array.isArray(codexNotify) && codexNotify.length > 0;
|
|
63
|
+
const everyCodeNotify = await readEveryCodeNotify(codeConfigPath);
|
|
64
|
+
const everyCodeConfigured = Array.isArray(everyCodeNotify) && everyCodeNotify.length > 0;
|
|
65
|
+
const claudeHookConfigured = await isClaudeHookConfigured({
|
|
66
|
+
settingsPath: claudeSettingsPath,
|
|
67
|
+
hookCommand: claudeHookCommand
|
|
68
|
+
});
|
|
69
|
+
const geminiHookConfigured = await isGeminiHookConfigured({
|
|
70
|
+
settingsPath: geminiSettingsPath,
|
|
71
|
+
hookCommand: geminiHookCommand
|
|
72
|
+
});
|
|
73
|
+
const opencodePluginConfigured = await isOpencodePluginInstalled({ configDir: opencodeConfigDir });
|
|
74
|
+
|
|
75
|
+
const lastUpload = uploadThrottle.lastSuccessMs
|
|
76
|
+
? parseEpochMsToIso(uploadThrottle.lastSuccessMs)
|
|
77
|
+
: typeof queueState.updatedAt === 'string'
|
|
78
|
+
? queueState.updatedAt
|
|
79
|
+
: null;
|
|
80
|
+
const nextUpload = parseEpochMsToIso(uploadThrottle.nextAllowedAtMs || null);
|
|
81
|
+
const backoffUntil = parseEpochMsToIso(uploadThrottle.backoffUntilMs || null);
|
|
82
|
+
const lastUploadError = uploadThrottle.lastError
|
|
83
|
+
? `${uploadThrottle.lastErrorAt || 'unknown'} ${uploadThrottle.lastError}`
|
|
84
|
+
: null;
|
|
85
|
+
const autoRetryAt = parseEpochMsToIso(autoRetry?.retryAtMs || null);
|
|
86
|
+
const autoRetryLine = autoRetryAt
|
|
87
|
+
? `- Auto retry after: ${autoRetryAt} (${autoRetry?.reason || 'scheduled'}, pending ${Number(
|
|
88
|
+
autoRetry?.pendingBytes || 0
|
|
89
|
+
)} bytes)`
|
|
90
|
+
: null;
|
|
91
|
+
|
|
92
|
+
process.stdout.write(
|
|
93
|
+
[
|
|
94
|
+
'Status:',
|
|
95
|
+
`- Base URL: ${config?.baseUrl || 'unset'}`,
|
|
96
|
+
`- Device token: ${config?.deviceToken ? 'set' : 'unset'}`,
|
|
97
|
+
`- Queue: ${pendingBytes} bytes pending`,
|
|
98
|
+
`- Last parse: ${cursors?.updatedAt || 'never'}`,
|
|
99
|
+
`- Last notify: ${lastNotify || 'never'}`,
|
|
100
|
+
`- Last notify-triggered sync: ${lastNotifySpawn || 'never'}`,
|
|
101
|
+
`- Last upload: ${lastUpload || 'never'}`,
|
|
102
|
+
`- Next upload after: ${nextUpload || 'never'}`,
|
|
103
|
+
`- Backoff until: ${backoffUntil || 'never'}`,
|
|
104
|
+
lastUploadError ? `- Last upload error: ${lastUploadError}` : null,
|
|
105
|
+
autoRetryLine,
|
|
106
|
+
`- Codex notify: ${notifyConfigured ? JSON.stringify(codexNotify) : 'unset'}`,
|
|
107
|
+
`- Every Code notify: ${everyCodeConfigured ? JSON.stringify(everyCodeNotify) : 'unset'}`,
|
|
108
|
+
`- Claude hooks: ${claudeHookConfigured ? 'set' : 'unset'}`,
|
|
109
|
+
`- Gemini hooks: ${geminiHookConfigured ? 'set' : 'unset'}`,
|
|
110
|
+
`- Opencode plugin: ${opencodePluginConfigured ? 'set' : 'unset'}`,
|
|
111
|
+
''
|
|
112
|
+
]
|
|
113
|
+
.filter(Boolean)
|
|
114
|
+
.join('\n')
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function parseArgs(argv) {
|
|
119
|
+
const out = { diagnostics: false };
|
|
120
|
+
|
|
121
|
+
for (let i = 0; i < argv.length; i++) {
|
|
122
|
+
const a = argv[i];
|
|
123
|
+
if (a === '--diagnostics' || a === '--json') out.diagnostics = true;
|
|
124
|
+
else throw new Error(`Unknown option: ${a}`);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return out;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function safeStatSize(p) {
|
|
131
|
+
try {
|
|
132
|
+
const st = await fs.stat(p);
|
|
133
|
+
return st.size || 0;
|
|
134
|
+
} catch (_e) {
|
|
135
|
+
return 0;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async function safeReadText(p) {
|
|
140
|
+
try {
|
|
141
|
+
return await fs.readFile(p, 'utf8');
|
|
142
|
+
} catch (_e) {
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function parseEpochMsToIso(v) {
|
|
148
|
+
const ms = Number(v);
|
|
149
|
+
if (!Number.isFinite(ms) || ms <= 0) return null;
|
|
150
|
+
const d = new Date(ms);
|
|
151
|
+
if (Number.isNaN(d.getTime())) return null;
|
|
152
|
+
return d.toISOString();
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
module.exports = { cmdStatus };
|
|
@@ -0,0 +1,479 @@
|
|
|
1
|
+
const os = require('node:os');
|
|
2
|
+
const path = require('node:path');
|
|
3
|
+
const fs = require('node:fs/promises');
|
|
4
|
+
const cp = require('node:child_process');
|
|
5
|
+
|
|
6
|
+
const { ensureDir, readJson, writeJson, openLock } = require('../lib/fs');
|
|
7
|
+
const {
|
|
8
|
+
listRolloutFiles,
|
|
9
|
+
listClaudeProjectFiles,
|
|
10
|
+
listGeminiSessionFiles,
|
|
11
|
+
listOpencodeMessageFiles,
|
|
12
|
+
parseRolloutIncremental,
|
|
13
|
+
parseClaudeIncremental,
|
|
14
|
+
parseGeminiIncremental,
|
|
15
|
+
parseOpencodeIncremental
|
|
16
|
+
} = require('../lib/rollout');
|
|
17
|
+
const { drainQueueToCloud } = require('../lib/uploader');
|
|
18
|
+
const { createProgress, renderBar, formatNumber, formatBytes } = require('../lib/progress');
|
|
19
|
+
const { syncHeartbeat } = require('../lib/vibescore-api');
|
|
20
|
+
const {
|
|
21
|
+
DEFAULTS: UPLOAD_DEFAULTS,
|
|
22
|
+
normalizeState: normalizeUploadState,
|
|
23
|
+
decideAutoUpload,
|
|
24
|
+
recordUploadSuccess,
|
|
25
|
+
recordUploadFailure
|
|
26
|
+
} = require('../lib/upload-throttle');
|
|
27
|
+
const { resolveTrackerPaths } = require('../lib/tracker-paths');
|
|
28
|
+
|
|
29
|
+
async function cmdSync(argv) {
|
|
30
|
+
const opts = parseArgs(argv);
|
|
31
|
+
const home = os.homedir();
|
|
32
|
+
const { trackerDir } = await resolveTrackerPaths({ home, migrate: true });
|
|
33
|
+
|
|
34
|
+
await ensureDir(trackerDir);
|
|
35
|
+
|
|
36
|
+
const lockPath = path.join(trackerDir, 'sync.lock');
|
|
37
|
+
const lock = await openLock(lockPath, { quietIfLocked: opts.auto });
|
|
38
|
+
if (!lock) return;
|
|
39
|
+
|
|
40
|
+
let progress = null;
|
|
41
|
+
try {
|
|
42
|
+
progress = !opts.auto ? createProgress({ stream: process.stdout }) : null;
|
|
43
|
+
const configPath = path.join(trackerDir, 'config.json');
|
|
44
|
+
const cursorsPath = path.join(trackerDir, 'cursors.json');
|
|
45
|
+
const queuePath = path.join(trackerDir, 'queue.jsonl');
|
|
46
|
+
const queueStatePath = path.join(trackerDir, 'queue.state.json');
|
|
47
|
+
const uploadThrottlePath = path.join(trackerDir, 'upload.throttle.json');
|
|
48
|
+
|
|
49
|
+
const config = await readJson(configPath);
|
|
50
|
+
const cursors = (await readJson(cursorsPath)) || { version: 1, files: {}, updatedAt: null };
|
|
51
|
+
const uploadThrottle = normalizeUploadState(await readJson(uploadThrottlePath));
|
|
52
|
+
let uploadThrottleState = uploadThrottle;
|
|
53
|
+
|
|
54
|
+
const codexHome = process.env.CODEX_HOME || path.join(home, '.codex');
|
|
55
|
+
const codeHome = process.env.CODE_HOME || path.join(home, '.code');
|
|
56
|
+
const claudeProjectsDir = path.join(home, '.claude', 'projects');
|
|
57
|
+
const geminiHome = process.env.GEMINI_HOME || path.join(home, '.gemini');
|
|
58
|
+
const geminiTmpDir = path.join(geminiHome, 'tmp');
|
|
59
|
+
const xdgDataHome = process.env.XDG_DATA_HOME || path.join(home, '.local', 'share');
|
|
60
|
+
const opencodeHome = process.env.OPENCODE_HOME || path.join(xdgDataHome, 'opencode');
|
|
61
|
+
const opencodeStorageDir = path.join(opencodeHome, 'storage');
|
|
62
|
+
|
|
63
|
+
const sources = [
|
|
64
|
+
{ source: 'codex', sessionsDir: path.join(codexHome, 'sessions') },
|
|
65
|
+
{ source: 'every-code', sessionsDir: path.join(codeHome, 'sessions') }
|
|
66
|
+
];
|
|
67
|
+
|
|
68
|
+
const rolloutFiles = [];
|
|
69
|
+
const seenSessions = new Set();
|
|
70
|
+
for (const entry of sources) {
|
|
71
|
+
if (seenSessions.has(entry.sessionsDir)) continue;
|
|
72
|
+
seenSessions.add(entry.sessionsDir);
|
|
73
|
+
const files = await listRolloutFiles(entry.sessionsDir);
|
|
74
|
+
for (const filePath of files) {
|
|
75
|
+
rolloutFiles.push({ path: filePath, source: entry.source });
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (progress?.enabled) {
|
|
80
|
+
progress.start(`Parsing ${renderBar(0)} 0/${formatNumber(rolloutFiles.length)} files | buckets 0`);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const parseResult = await parseRolloutIncremental({
|
|
84
|
+
rolloutFiles,
|
|
85
|
+
cursors,
|
|
86
|
+
queuePath,
|
|
87
|
+
onProgress: (p) => {
|
|
88
|
+
if (!progress?.enabled) return;
|
|
89
|
+
const pct = p.total > 0 ? p.index / p.total : 1;
|
|
90
|
+
progress.update(
|
|
91
|
+
`Parsing ${renderBar(pct)} ${formatNumber(p.index)}/${formatNumber(p.total)} files | buckets ${formatNumber(
|
|
92
|
+
p.bucketsQueued
|
|
93
|
+
)}`
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
const claudeFiles = await listClaudeProjectFiles(claudeProjectsDir);
|
|
99
|
+
let claudeResult = { filesProcessed: 0, eventsAggregated: 0, bucketsQueued: 0 };
|
|
100
|
+
if (claudeFiles.length > 0) {
|
|
101
|
+
if (progress?.enabled) {
|
|
102
|
+
progress.start(`Parsing Claude ${renderBar(0)} 0/${formatNumber(claudeFiles.length)} files | buckets 0`);
|
|
103
|
+
}
|
|
104
|
+
claudeResult = await parseClaudeIncremental({
|
|
105
|
+
projectFiles: claudeFiles,
|
|
106
|
+
cursors,
|
|
107
|
+
queuePath,
|
|
108
|
+
onProgress: (p) => {
|
|
109
|
+
if (!progress?.enabled) return;
|
|
110
|
+
const pct = p.total > 0 ? p.index / p.total : 1;
|
|
111
|
+
progress.update(
|
|
112
|
+
`Parsing Claude ${renderBar(pct)} ${formatNumber(p.index)}/${formatNumber(p.total)} files | buckets ${formatNumber(
|
|
113
|
+
p.bucketsQueued
|
|
114
|
+
)}`
|
|
115
|
+
);
|
|
116
|
+
},
|
|
117
|
+
source: 'claude'
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const geminiFiles = await listGeminiSessionFiles(geminiTmpDir);
|
|
122
|
+
let geminiResult = { filesProcessed: 0, eventsAggregated: 0, bucketsQueued: 0 };
|
|
123
|
+
if (geminiFiles.length > 0) {
|
|
124
|
+
if (progress?.enabled) {
|
|
125
|
+
progress.start(`Parsing Gemini ${renderBar(0)} 0/${formatNumber(geminiFiles.length)} files | buckets 0`);
|
|
126
|
+
}
|
|
127
|
+
geminiResult = await parseGeminiIncremental({
|
|
128
|
+
sessionFiles: geminiFiles,
|
|
129
|
+
cursors,
|
|
130
|
+
queuePath,
|
|
131
|
+
onProgress: (p) => {
|
|
132
|
+
if (!progress?.enabled) return;
|
|
133
|
+
const pct = p.total > 0 ? p.index / p.total : 1;
|
|
134
|
+
progress.update(
|
|
135
|
+
`Parsing Gemini ${renderBar(pct)} ${formatNumber(p.index)}/${formatNumber(p.total)} files | buckets ${formatNumber(
|
|
136
|
+
p.bucketsQueued
|
|
137
|
+
)}`
|
|
138
|
+
);
|
|
139
|
+
},
|
|
140
|
+
source: 'gemini'
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const opencodeFiles = await listOpencodeMessageFiles(opencodeStorageDir);
|
|
145
|
+
let opencodeResult = { filesProcessed: 0, eventsAggregated: 0, bucketsQueued: 0 };
|
|
146
|
+
if (opencodeFiles.length > 0) {
|
|
147
|
+
if (progress?.enabled) {
|
|
148
|
+
progress.start(`Parsing Opencode ${renderBar(0)} 0/${formatNumber(opencodeFiles.length)} files | buckets 0`);
|
|
149
|
+
}
|
|
150
|
+
opencodeResult = await parseOpencodeIncremental({
|
|
151
|
+
messageFiles: opencodeFiles,
|
|
152
|
+
cursors,
|
|
153
|
+
queuePath,
|
|
154
|
+
onProgress: (p) => {
|
|
155
|
+
if (!progress?.enabled) return;
|
|
156
|
+
const pct = p.total > 0 ? p.index / p.total : 1;
|
|
157
|
+
progress.update(
|
|
158
|
+
`Parsing Opencode ${renderBar(pct)} ${formatNumber(p.index)}/${formatNumber(
|
|
159
|
+
p.total
|
|
160
|
+
)} files | buckets ${formatNumber(p.bucketsQueued)}`
|
|
161
|
+
);
|
|
162
|
+
},
|
|
163
|
+
source: 'opencode'
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
cursors.updatedAt = new Date().toISOString();
|
|
168
|
+
await writeJson(cursorsPath, cursors);
|
|
169
|
+
|
|
170
|
+
progress?.stop();
|
|
171
|
+
|
|
172
|
+
const deviceToken = config?.deviceToken || process.env.VIBEUSAGE_DEVICE_TOKEN || process.env.VIBESCORE_DEVICE_TOKEN || null;
|
|
173
|
+
const baseUrl = config?.baseUrl ||
|
|
174
|
+
process.env.VIBEUSAGE_INSFORGE_BASE_URL ||
|
|
175
|
+
process.env.VIBESCORE_INSFORGE_BASE_URL ||
|
|
176
|
+
'https://5tmappuk.us-east.insforge.app';
|
|
177
|
+
|
|
178
|
+
let uploadResult = null;
|
|
179
|
+
let uploadAttempted = false;
|
|
180
|
+
if (deviceToken) {
|
|
181
|
+
const beforeState = (await readJson(queueStatePath)) || { offset: 0 };
|
|
182
|
+
const queueSize = await safeStatSize(queuePath);
|
|
183
|
+
const pendingBytes = Math.max(0, queueSize - Number(beforeState.offset || 0));
|
|
184
|
+
let maxBatches = opts.auto ? 3 : opts.drain ? 10_000 : 10;
|
|
185
|
+
let batchSize = UPLOAD_DEFAULTS.batchSize;
|
|
186
|
+
let allowUpload = pendingBytes > 0;
|
|
187
|
+
let autoDecision = null;
|
|
188
|
+
|
|
189
|
+
if (opts.auto) {
|
|
190
|
+
autoDecision = decideAutoUpload({
|
|
191
|
+
nowMs: Date.now(),
|
|
192
|
+
pendingBytes,
|
|
193
|
+
state: uploadThrottle
|
|
194
|
+
});
|
|
195
|
+
allowUpload = allowUpload && autoDecision.allowed;
|
|
196
|
+
maxBatches = autoDecision.allowed ? autoDecision.maxBatches : 0;
|
|
197
|
+
batchSize = autoDecision.batchSize;
|
|
198
|
+
if (!autoDecision.allowed && pendingBytes > 0 && autoDecision.blockedUntilMs > 0) {
|
|
199
|
+
const reason = deriveAutoSkipReason({ decision: autoDecision, state: uploadThrottle });
|
|
200
|
+
await scheduleAutoRetry({
|
|
201
|
+
trackerDir,
|
|
202
|
+
retryAtMs: autoDecision.blockedUntilMs,
|
|
203
|
+
reason,
|
|
204
|
+
pendingBytes,
|
|
205
|
+
source: 'auto-throttled'
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (progress?.enabled && pendingBytes > 0 && allowUpload) {
|
|
211
|
+
const pct = queueSize > 0 ? Number(beforeState.offset || 0) / queueSize : 0;
|
|
212
|
+
progress.start(
|
|
213
|
+
`Uploading ${renderBar(pct)} ${formatBytes(Number(beforeState.offset || 0))}/${formatBytes(queueSize)} | inserted 0 skipped 0`
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (allowUpload && maxBatches > 0) {
|
|
218
|
+
uploadAttempted = true;
|
|
219
|
+
try {
|
|
220
|
+
uploadResult = await drainQueueToCloud({
|
|
221
|
+
baseUrl,
|
|
222
|
+
deviceToken,
|
|
223
|
+
queuePath,
|
|
224
|
+
queueStatePath,
|
|
225
|
+
maxBatches,
|
|
226
|
+
batchSize,
|
|
227
|
+
onProgress: (u) => {
|
|
228
|
+
if (!progress?.enabled) return;
|
|
229
|
+
const pct = u.queueSize > 0 ? u.offset / u.queueSize : 1;
|
|
230
|
+
progress.update(
|
|
231
|
+
`Uploading ${renderBar(pct)} ${formatBytes(u.offset)}/${formatBytes(u.queueSize)} | inserted ${formatNumber(
|
|
232
|
+
u.inserted
|
|
233
|
+
)} skipped ${formatNumber(u.skipped)}`
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
});
|
|
237
|
+
if (uploadResult.attempted > 0) {
|
|
238
|
+
const next = recordUploadSuccess({ nowMs: Date.now(), state: uploadThrottleState });
|
|
239
|
+
uploadThrottleState = next;
|
|
240
|
+
await writeJson(uploadThrottlePath, next);
|
|
241
|
+
}
|
|
242
|
+
} catch (e) {
|
|
243
|
+
const next = recordUploadFailure({ nowMs: Date.now(), state: uploadThrottleState, error: e });
|
|
244
|
+
uploadThrottleState = next;
|
|
245
|
+
await writeJson(uploadThrottlePath, next);
|
|
246
|
+
if (opts.auto && pendingBytes > 0) {
|
|
247
|
+
const retryAtMs = Math.max(next.nextAllowedAtMs || 0, next.backoffUntilMs || 0);
|
|
248
|
+
if (retryAtMs > 0) {
|
|
249
|
+
await scheduleAutoRetry({
|
|
250
|
+
trackerDir,
|
|
251
|
+
retryAtMs,
|
|
252
|
+
reason: 'backoff',
|
|
253
|
+
pendingBytes,
|
|
254
|
+
source: 'auto-error'
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
throw e;
|
|
259
|
+
}
|
|
260
|
+
} else {
|
|
261
|
+
uploadResult = { inserted: 0, skipped: 0 };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
progress?.stop();
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const afterState = (await readJson(queueStatePath)) || { offset: 0 };
|
|
268
|
+
const queueSize = await safeStatSize(queuePath);
|
|
269
|
+
const pendingBytes = Math.max(0, queueSize - Number(afterState.offset || 0));
|
|
270
|
+
|
|
271
|
+
if (pendingBytes <= 0) {
|
|
272
|
+
await clearAutoRetry(trackerDir);
|
|
273
|
+
} else if (opts.auto && uploadAttempted) {
|
|
274
|
+
const retryAtMs = Number(uploadThrottleState?.nextAllowedAtMs || 0);
|
|
275
|
+
if (retryAtMs > Date.now()) {
|
|
276
|
+
await scheduleAutoRetry({
|
|
277
|
+
trackerDir,
|
|
278
|
+
retryAtMs,
|
|
279
|
+
reason: 'backlog',
|
|
280
|
+
pendingBytes,
|
|
281
|
+
source: 'auto-backlog'
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
await maybeSendHeartbeat({
|
|
287
|
+
baseUrl,
|
|
288
|
+
deviceToken,
|
|
289
|
+
trackerDir,
|
|
290
|
+
uploadResult,
|
|
291
|
+
pendingBytes
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
if (!opts.auto) {
|
|
295
|
+
const totalParsed =
|
|
296
|
+
parseResult.filesProcessed +
|
|
297
|
+
claudeResult.filesProcessed +
|
|
298
|
+
geminiResult.filesProcessed +
|
|
299
|
+
opencodeResult.filesProcessed;
|
|
300
|
+
const totalBuckets =
|
|
301
|
+
parseResult.bucketsQueued +
|
|
302
|
+
claudeResult.bucketsQueued +
|
|
303
|
+
geminiResult.bucketsQueued +
|
|
304
|
+
opencodeResult.bucketsQueued;
|
|
305
|
+
process.stdout.write(
|
|
306
|
+
[
|
|
307
|
+
'Sync finished:',
|
|
308
|
+
`- Parsed files: ${totalParsed}`,
|
|
309
|
+
`- New 30-min buckets queued: ${totalBuckets}`,
|
|
310
|
+
deviceToken
|
|
311
|
+
? `- Uploaded: ${uploadResult.inserted} inserted, ${uploadResult.skipped} skipped`
|
|
312
|
+
: '- Uploaded: skipped (no device token)',
|
|
313
|
+
deviceToken && pendingBytes > 0 && !opts.drain
|
|
314
|
+
? `- Remaining: ${formatBytes(pendingBytes)} pending (run sync again, or use --drain)`
|
|
315
|
+
: null,
|
|
316
|
+
''
|
|
317
|
+
]
|
|
318
|
+
.filter(Boolean)
|
|
319
|
+
.join('\n')
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
} finally {
|
|
323
|
+
progress?.stop();
|
|
324
|
+
await lock.release();
|
|
325
|
+
await fs.unlink(lockPath).catch(() => {});
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function parseArgs(argv) {
|
|
330
|
+
const out = {
|
|
331
|
+
auto: false,
|
|
332
|
+
fromNotify: false,
|
|
333
|
+
fromRetry: false,
|
|
334
|
+
drain: false
|
|
335
|
+
};
|
|
336
|
+
for (let i = 0; i < argv.length; i++) {
|
|
337
|
+
const a = argv[i];
|
|
338
|
+
if (a === '--auto') out.auto = true;
|
|
339
|
+
else if (a === '--from-notify') out.fromNotify = true;
|
|
340
|
+
else if (a === '--from-retry') out.fromRetry = true;
|
|
341
|
+
else if (a === '--drain') out.drain = true;
|
|
342
|
+
else throw new Error(`Unknown option: ${a}`);
|
|
343
|
+
}
|
|
344
|
+
return out;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
module.exports = { cmdSync };
|
|
348
|
+
|
|
349
|
+
async function safeStatSize(p) {
|
|
350
|
+
try {
|
|
351
|
+
const st = await fs.stat(p);
|
|
352
|
+
return st && st.isFile() ? st.size : 0;
|
|
353
|
+
} catch (_e) {
|
|
354
|
+
return 0;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
async function maybeSendHeartbeat({ baseUrl, deviceToken, trackerDir, uploadResult, pendingBytes }) {
|
|
359
|
+
if (!deviceToken || !uploadResult) return;
|
|
360
|
+
if (pendingBytes > 0) return;
|
|
361
|
+
if (Number(uploadResult.inserted || 0) !== 0) return;
|
|
362
|
+
|
|
363
|
+
const heartbeatPath = path.join(trackerDir, 'sync.heartbeat.json');
|
|
364
|
+
const heartbeatState = await readJson(heartbeatPath);
|
|
365
|
+
const lastPingAt = Date.parse(heartbeatState?.lastPingAt || '');
|
|
366
|
+
const nowMs = Date.now();
|
|
367
|
+
if (Number.isFinite(lastPingAt) && nowMs - lastPingAt < HEARTBEAT_MIN_INTERVAL_MS) return;
|
|
368
|
+
|
|
369
|
+
try {
|
|
370
|
+
await syncHeartbeat({ baseUrl, deviceToken });
|
|
371
|
+
await writeJson(heartbeatPath, {
|
|
372
|
+
lastPingAt: new Date(nowMs).toISOString(),
|
|
373
|
+
minIntervalMinutes: HEARTBEAT_MIN_INTERVAL_MINUTES
|
|
374
|
+
});
|
|
375
|
+
} catch (_e) {
|
|
376
|
+
// best-effort heartbeat; ignore failures
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function deriveAutoSkipReason({ decision, state }) {
|
|
381
|
+
if (!decision || decision.reason !== 'throttled') return decision?.reason || 'unknown';
|
|
382
|
+
const backoffUntilMs = Number(state?.backoffUntilMs || 0);
|
|
383
|
+
const nextAllowedAtMs = Number(state?.nextAllowedAtMs || 0);
|
|
384
|
+
if (backoffUntilMs > 0 && backoffUntilMs >= nextAllowedAtMs) return 'backoff';
|
|
385
|
+
return 'throttled';
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
async function scheduleAutoRetry({ trackerDir, retryAtMs, reason, pendingBytes, source }) {
|
|
389
|
+
const retryMs = coerceRetryMs(retryAtMs);
|
|
390
|
+
if (!retryMs) return { scheduled: false, retryAtMs: 0 };
|
|
391
|
+
|
|
392
|
+
const retryPath = path.join(trackerDir, AUTO_RETRY_FILENAME);
|
|
393
|
+
const nowMs = Date.now();
|
|
394
|
+
const existing = await readJson(retryPath);
|
|
395
|
+
const existingMs = coerceRetryMs(existing?.retryAtMs);
|
|
396
|
+
if (existingMs && existingMs >= retryMs - 1000) {
|
|
397
|
+
return { scheduled: false, retryAtMs: existingMs };
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
const payload = {
|
|
401
|
+
version: 1,
|
|
402
|
+
retryAtMs: retryMs,
|
|
403
|
+
retryAt: new Date(retryMs).toISOString(),
|
|
404
|
+
reason: typeof reason === 'string' && reason.length > 0 ? reason : 'throttled',
|
|
405
|
+
pendingBytes: Math.max(0, Number(pendingBytes || 0)),
|
|
406
|
+
scheduledAt: new Date(nowMs).toISOString(),
|
|
407
|
+
source: typeof source === 'string' ? source : 'auto'
|
|
408
|
+
};
|
|
409
|
+
|
|
410
|
+
await writeJson(retryPath, payload);
|
|
411
|
+
|
|
412
|
+
const delayMs = Math.min(AUTO_RETRY_MAX_DELAY_MS, Math.max(0, retryMs - nowMs));
|
|
413
|
+
if (delayMs <= 0) return { scheduled: false, retryAtMs: retryMs };
|
|
414
|
+
if (process.env.VIBEUSAGE_AUTO_RETRY_NO_SPAWN === '1' || process.env.VIBESCORE_AUTO_RETRY_NO_SPAWN === '1') {
|
|
415
|
+
return { scheduled: false, retryAtMs: retryMs };
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
spawnAutoRetryProcess({
|
|
419
|
+
retryPath,
|
|
420
|
+
trackerBinPath: path.join(trackerDir, 'app', 'bin', 'tracker.js'),
|
|
421
|
+
fallbackPkg: 'vibeusage',
|
|
422
|
+
delayMs
|
|
423
|
+
});
|
|
424
|
+
return { scheduled: true, retryAtMs: retryMs };
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
async function clearAutoRetry(trackerDir) {
|
|
428
|
+
const retryPath = path.join(trackerDir, AUTO_RETRY_FILENAME);
|
|
429
|
+
await fs.unlink(retryPath).catch(() => {});
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function spawnAutoRetryProcess({ retryPath, trackerBinPath, fallbackPkg, delayMs }) {
|
|
433
|
+
const script = buildAutoRetryScript({ retryPath, trackerBinPath, fallbackPkg, delayMs });
|
|
434
|
+
try {
|
|
435
|
+
const child = cp.spawn(process.execPath, ['-e', script], {
|
|
436
|
+
detached: true,
|
|
437
|
+
stdio: 'ignore',
|
|
438
|
+
env: process.env
|
|
439
|
+
});
|
|
440
|
+
child.unref();
|
|
441
|
+
} catch (_e) {}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
function buildAutoRetryScript({ retryPath, trackerBinPath, fallbackPkg, delayMs }) {
|
|
445
|
+
return `'use strict';\n` +
|
|
446
|
+
`const fs = require('node:fs');\n` +
|
|
447
|
+
`const cp = require('node:child_process');\n` +
|
|
448
|
+
`const retryPath = ${JSON.stringify(retryPath)};\n` +
|
|
449
|
+
`const trackerBinPath = ${JSON.stringify(trackerBinPath)};\n` +
|
|
450
|
+
`const fallbackPkg = ${JSON.stringify(fallbackPkg)};\n` +
|
|
451
|
+
`const delayMs = ${Math.max(0, Math.floor(delayMs || 0))};\n` +
|
|
452
|
+
`setTimeout(() => {\n` +
|
|
453
|
+
` let retryAtMs = 0;\n` +
|
|
454
|
+
` try {\n` +
|
|
455
|
+
` const raw = fs.readFileSync(retryPath, 'utf8');\n` +
|
|
456
|
+
` retryAtMs = Number(JSON.parse(raw).retryAtMs || 0);\n` +
|
|
457
|
+
` } catch (_) {}\n` +
|
|
458
|
+
` if (!retryAtMs || Date.now() + 1000 < retryAtMs) process.exit(0);\n` +
|
|
459
|
+
` const argv = ['sync', '--auto', '--from-retry'];\n` +
|
|
460
|
+
` const cmd = fs.existsSync(trackerBinPath)\n` +
|
|
461
|
+
` ? [process.execPath, trackerBinPath, ...argv]\n` +
|
|
462
|
+
` : ['npx', '--yes', fallbackPkg, ...argv];\n` +
|
|
463
|
+
` try {\n` +
|
|
464
|
+
` const child = cp.spawn(cmd[0], cmd.slice(1), { detached: true, stdio: 'ignore', env: process.env });\n` +
|
|
465
|
+
` child.unref();\n` +
|
|
466
|
+
` } catch (_) {}\n` +
|
|
467
|
+
`}, delayMs);\n`;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
function coerceRetryMs(v) {
|
|
471
|
+
const n = Number(v);
|
|
472
|
+
if (!Number.isFinite(n) || n <= 0) return 0;
|
|
473
|
+
return Math.floor(n);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
const HEARTBEAT_MIN_INTERVAL_MINUTES = 30;
|
|
477
|
+
const HEARTBEAT_MIN_INTERVAL_MS = HEARTBEAT_MIN_INTERVAL_MINUTES * 60 * 1000;
|
|
478
|
+
const AUTO_RETRY_FILENAME = 'auto.retry.json';
|
|
479
|
+
const AUTO_RETRY_MAX_DELAY_MS = 2 * 60 * 60 * 1000;
|