flower-trellis 0.6.4 → 0.6.6

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.
Files changed (54) hide show
  1. package/README.md +20 -1
  2. package/enhancements/0.6/overrides/bundles/control-plane-integrity.json +2 -0
  3. package/enhancements/0.6/overrides/bundles/intent-routing.json +2 -1
  4. package/enhancements/0.6/overrides/bundles/task-reference-contract.json +24 -0
  5. package/enhancements/0.6/overrides/conflicts.json +37 -4
  6. package/enhancements/0.6/overrides/patches/scripts/task-current-query-contract/patch.json +19 -0
  7. package/enhancements/0.6/overrides/patches/scripts/task-current-query-contract/task-current-content.py +39 -0
  8. package/enhancements/0.6/overrides/patches/scripts/task-current-query-contract/task-current-selector.py +38 -0
  9. package/enhancements/0.6/overrides/patches/scripts/task-reference-resolution/patch.json +20 -0
  10. package/enhancements/0.6/overrides/patches/scripts/task-reference-resolution/task-reference-content.py +64 -0
  11. package/enhancements/0.6/overrides/patches/scripts/task-reference-resolution/task-reference-selector.py +2 -0
  12. package/enhancements/0.6/overrides/patches/skills/trellis-brainstorm/planning-handoff/content.md +2 -4
  13. package/enhancements/0.6/overrides/patches/skills/trellis-brainstorm/planning-handoff/planning-contract-content.md +1 -1
  14. package/enhancements/0.6/overrides/patches/skills/trellis-brainstorm/planning-handoff/readiness-content.md +2 -2
  15. package/enhancements/0.6/overrides/patches/skills/trellis-brainstorm/planning-handoff/review-content.md +2 -2
  16. package/enhancements/0.6/scripts/decision_log.py +29 -24
  17. package/enhancements/0.6/scripts/task_progress.py +54 -13
  18. package/enhancements/0.6/scripts/untracked_flow.py +13 -5
  19. package/enhancements/MANIFEST.json +9 -2
  20. package/package.json +4 -4
  21. package/src/assets/flower_session_start.py +195 -0
  22. package/src/assets/flower_telemetry_hook.py +98 -0
  23. package/src/builtin-plugins/skill-garden/content-adapter.js +15 -1
  24. package/src/cli.js +3 -1
  25. package/src/commands/init.js +36 -1
  26. package/src/commands/plugin-remote.js +61 -14
  27. package/src/commands/plugin.js +14 -1
  28. package/src/commands/self-check.js +20 -0
  29. package/src/commands/self-update.js +38 -3
  30. package/src/commands/telemetry.js +30 -1
  31. package/src/commands/uninstall.js +20 -0
  32. package/src/commands/update-check.js +19 -0
  33. package/src/commands/update.js +52 -9
  34. package/src/commands/worktree.js +114 -0
  35. package/src/lib/cli-args.js +10 -0
  36. package/src/lib/developer.js +3 -2
  37. package/src/lib/flower-assets.js +21 -6
  38. package/src/lib/platform-patch-adapters.js +46 -8
  39. package/src/lib/telemetry-context.js +275 -0
  40. package/src/lib/telemetry-files.js +144 -0
  41. package/src/lib/telemetry-operation.js +84 -0
  42. package/src/lib/telemetry-queue.js +310 -0
  43. package/src/lib/telemetry-sender.js +7 -0
  44. package/src/lib/telemetry.js +12 -361
  45. package/src/patches/bundles/flower-platform-integration.json +4 -1
  46. package/src/patches/platforms/claude/session-start-hooks/patch.json +28 -0
  47. package/src/patches/platforms/claude/telemetry-hooks/patch.json +59 -0
  48. package/src/patches/platforms/codex/session-start-hooks/patch.json +3 -2
  49. package/src/patches/platforms/codex/telemetry-hooks/patch.json +59 -0
  50. package/src/plugin/application-service.js +5 -22
  51. package/src/plugin/contracts.js +1 -0
  52. package/src/plugin/lock-reachability.js +37 -0
  53. package/src/plugin/schemas/project-files.js +1 -0
  54. package/src/plugin/sources/gitlab-provider.js +50 -9
@@ -0,0 +1,310 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import crypto from "node:crypto";
4
+ import { spawn } from "node:child_process";
5
+ import { fileURLToPath } from "node:url";
6
+ import { ProjectStore } from "../plugin/state/project-store.js";
7
+ import { SKILL_GARDEN_PLUGIN_ID } from "../builtin-plugins/skill-garden/provider.js";
8
+ import { flowerVersion, trellisVersion } from "./versions.js";
9
+ import { readUpdateCheck } from "./manifest.js";
10
+ import { buildTelemetryPayload, isWithinInterval, readProjectVersions, resolveDeveloperName, withTelemetryState, FLOWER_TELEMETRY_ENDPOINT } from "./telemetry-context.js";
11
+ import { readTelemetryJson, writeTelemetryJson, telemetryDirectory, telemetryQueueDirectory } from "./telemetry-files.js";
12
+
13
+ const EVENT_FILE = /^event-[0-9a-f-]{36}\.json$/;
14
+ const MAX_AGE = 72 * 3600000;
15
+ const VERSION = /^[0-9A-Za-z][0-9A-Za-z.+_-]{0,63}$/;
16
+ const REASONS = new Set(["invalid_time", "invalid_event", "event_too_large", "event_conflict", "invalid_batch"]);
17
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
18
+ const BASE_FIELDS = ["event_id", "event", "observed_at", "source_kind", "ai_platform", "platform", "arch", "flower_version", "bundled_trellis_version", "project_trellis_version", "installed_skill_garden_version", "developer_name"];
19
+ const OP_FIELDS = ["operation", "outcome", "error_category", "failure_stage", "duration_ms", "duration_kind"];
20
+
21
+ /** 本地白名单用于拒绝损坏队列,不把磁盘额外字段发送到服务端。
22
+ * @param {object} payload 载荷
23
+ * @returns {boolean} 是否可发送
24
+ */
25
+ function validPayload(payload) {
26
+ if (!payload || typeof payload !== "object" || !UUID.test(payload.event_id || "") || !Number.isFinite(Date.parse(payload.observed_at))) return false;
27
+ const operation = payload.event === "operation_completed";
28
+ const fields = operation ? [...BASE_FIELDS, ...OP_FIELDS] : BASE_FIELDS;
29
+ if (Object.keys(payload).some(key => !fields.includes(key)) || fields.some(key => !Object.hasOwn(payload, key))) return false;
30
+ if (!VERSION.test(payload.flower_version || "") || ["platform", "arch"].some(key => !/^[a-z0-9][a-z0-9_-]{0,31}$/.test(payload[key] || ""))) return false;
31
+ if (["bundled_trellis_version", "project_trellis_version", "installed_skill_garden_version"].some(key => payload[key] !== null && (typeof payload[key] !== "string" || !VERSION.test(payload[key])))) return false;
32
+ if (payload.developer_name !== null && (typeof payload.developer_name !== "string" || payload.developer_name.length > 100)) return false;
33
+ if (operation) {
34
+ if (payload.source_kind !== "cli" || payload.ai_platform !== null || !["init", "update", "self_update", "plugin_add"].includes(payload.operation)
35
+ || !["success", "failure", "cancelled"].includes(payload.outcome)) return false;
36
+ if (payload.outcome === "failure" ? !["precondition", "network", "permission", "conflict", "upstream", "io", "unknown"].includes(payload.error_category)
37
+ || !["prepare", "resolve", "authorize", "upstream", "apply", "recover", "unknown"].includes(payload.failure_stage)
38
+ : payload.error_category !== null || payload.failure_stage !== null) return false;
39
+ if (!["execution", "elapsed", "unavailable"].includes(payload.duration_kind) || (payload.duration_kind === "unavailable" ? payload.duration_ms !== null : !Number.isSafeInteger(payload.duration_ms) || payload.duration_ms < 0)) return false;
40
+ } else if (payload.event !== "activity_daily" || payload.source_kind !== "ai_hook" || !["claude", "codex"].includes(payload.ai_platform)) return false;
41
+ return Buffer.byteLength(JSON.stringify(payload)) <= 4096;
42
+ }
43
+
44
+ /** 重读 v1 快照时重新检查白名单,损坏文件留在本地。
45
+ * @param {object} entry 快照封套
46
+ * @returns {boolean} 是否可发送
47
+ */
48
+ function validLegacyEntry(entry) {
49
+ const p = entry?.payload;
50
+ const fields = ["schema_version", "device_id", "event", "flower_version", "bundled_trellis_version", "project_flower_version", "project_trellis_version", "developer_name", "platform", "arch", "client_time"];
51
+ if (!p || !UUID.test(entry.id || "") || !Number.isFinite(Date.parse(entry.queuedAt)) || p.schema_version !== 1 || !UUID.test(p.device_id || "")
52
+ || !["version_check", "init_completed", "update_completed"].includes(p.event) || Object.keys(p).some(key => !fields.includes(key))) return false;
53
+ return validPayload({ event_id: entry.id, event: "activity_daily", source_kind: "ai_hook", ai_platform: "claude", observed_at: p.client_time,
54
+ flower_version: p.flower_version, bundled_trellis_version: p.bundled_trellis_version,
55
+ project_trellis_version: p.project_trellis_version, installed_skill_garden_version: p.project_flower_version,
56
+ developer_name: p.developer_name, platform: p.platform, arch: p.arch });
57
+ }
58
+
59
+ /** 读取经过结构检查的诊断与日提示,损坏时交由调用方静默降级。
60
+ * @param {string} directory 用户遥测目录
61
+ * @returns {object} 元数据
62
+ */
63
+ function metadata(directory) {
64
+ const value = readTelemetryJson(path.join(directory, "meta.json"), { hints: {}, pending: 0, attempts: 0, dropped: 0 });
65
+ if (!value || typeof value !== "object" || Array.isArray(value) || !value.hints || typeof value.hints !== "object"
66
+ || Array.isArray(value.hints) || ["pending", "attempts", "dropped"].some(key => !Number.isSafeInteger(value[key]) || value[key] < 0)
67
+ || (value.nextRetryAt && !Number.isFinite(Date.parse(value.nextRetryAt)))
68
+ || Object.entries(value.hints).some(([key, hint]) => !/^\d{4}-\d{2}-\d{2}:(claude|codex)$/.test(key) || !UUID.test(hint?.event_id || "") || typeof hint.delivered !== "boolean")) throw new Error("遥测元数据损坏");
69
+ return value;
70
+ }
71
+
72
+ /** 读取有界事件文件;无效文件保留作本地诊断,不发送未知内容。
73
+ * @param {string} directory 目录
74
+ * @returns {object[]} 按入队时刻排序的记录
75
+ */
76
+ function entries(directory) {
77
+ telemetryDirectory(directory, false);
78
+ if (!fs.existsSync(directory)) return [];
79
+ return fs.readdirSync(directory).filter(name => EVENT_FILE.test(name)).map(name => {
80
+ const entry = readTelemetryJson(path.join(directory, name), null, 8192);
81
+ if (!entry || !UUID.test(entry.deviceId || "") || !validPayload(entry.payload) || !Number.isFinite(Date.parse(entry.queuedAt)) || name !== `event-${entry.payload?.event_id}.json`) throw new Error("遥测队列损坏");
82
+ return { ...entry, name };
83
+ }).sort((a, b) => a.queuedAt.localeCompare(b.queuedAt) || a.name.localeCompare(b.name));
84
+ }
85
+
86
+ /** 清理过期与被淘汰记录,并使尚未送达的日提示失效。
87
+ * @param {string} directory 目录
88
+ * @param {object} meta 元数据
89
+ * @param {number} now 时间戳
90
+ * @param {number} reserve 预留队列位置
91
+ * @returns {object[]} 剩余记录
92
+ */
93
+ function prune(directory, meta, now, reserve = 0) {
94
+ const records = entries(directory);
95
+ const remove = records.filter(entry => now - Date.parse(entry.queuedAt) >= MAX_AGE);
96
+ const kept = records.filter(entry => !remove.includes(entry));
97
+ remove.push(...kept.splice(0, Math.max(0, kept.length - (200 - reserve))));
98
+ for (const entry of remove) {
99
+ fs.unlinkSync(path.join(directory, entry.name));
100
+ meta.dropped += 1;
101
+ for (const [key, hint] of Object.entries(meta.hints)) if (hint.event_id === entry.payload.event_id && !hint.delivered) delete meta.hints[key];
102
+ }
103
+ for (const key of Object.keys(meta.hints)) if (Date.parse(key.slice(0, 10)) < now - 4 * 86400000) delete meta.hints[key];
104
+ const pendingIds = new Set(kept.map(entry => entry.payload.event_id));
105
+ for (const [key, hint] of Object.entries(meta.hints)) if (!hint.delivered && !pendingIds.has(hint.event_id)) delete meta.hints[key];
106
+ return kept;
107
+ }
108
+
109
+ /** 捕获白名单运行和目标版本;项目路径仅用于本地读取。
110
+ * @param {string} target 项目
111
+ * @param {object} state 唯一身份
112
+ * @param {object} options 环境与开始时版本
113
+ * @returns {object} 白名单快照
114
+ */
115
+ export function telemetrySnapshot(target, state, options = {}) {
116
+ let installed = null;
117
+ try { installed = new ProjectStore(target).readLock()?.plugins.find(({ id }) => id === SKILL_GARDEN_PLUGIN_ID)?.version || null; } catch { /* 缺失版本明确为空。 */ }
118
+ const bundled = options.bundledVersion || trellisVersion();
119
+ return { platform: process.platform, arch: process.arch, flower_version: options.runtimeVersion || flowerVersion(),
120
+ bundled_trellis_version: VERSION.test(bundled) ? bundled : null,
121
+ project_trellis_version: readProjectVersions(target).trellis,
122
+ installed_skill_garden_version: VERSION.test(installed || "") ? installed : null,
123
+ developer_name: resolveDeveloperName(target, { env: options.env, developerName: state.developerName }) };
124
+ }
125
+
126
+ /** 按需启动单次独立 sender;不继承标准流,也不让子进程阻止 CLI 退出。
127
+ * @param {object} options 环境与启动依赖
128
+ * @returns {void}
129
+ */
130
+ export function launchTelemetrySender(options = {}) {
131
+ const env = options.env || process.env;
132
+ if (env.FLOWER_NO_TELEMETRY) return;
133
+ try {
134
+ if (options.launch) { options.launch(); return; }
135
+ const child = spawn(process.execPath, [fileURLToPath(new URL("./telemetry-sender.js", import.meta.url))], {
136
+ env, detached: true, stdio: "ignore", windowsHide: true,
137
+ });
138
+ child.on("error", () => {});
139
+ child.unref();
140
+ } catch { /* 启动失败留待后续活动再次唤起。 */ }
141
+ }
142
+
143
+ /** 将事件可靠写入用户队列后才发布日去重提示。
144
+ * @param {string} target 项目
145
+ * @param {object} event 受控事件字段
146
+ * @param {object} options 环境和依赖
147
+ * @returns {{status:string}} 本地结果
148
+ */
149
+ export function queueTelemetryEvent(target, event, options = {}) {
150
+ try {
151
+ const now = options.now || new Date();
152
+ const directory = telemetryQueueDirectory(options.env);
153
+ let wake = false;
154
+ const result = withTelemetryState((state, save) => {
155
+ if (!state.enabled) return { status: "disabled" };
156
+ const meta = metadata(directory);
157
+ let records = prune(directory, meta, now.getTime());
158
+ const key = event.event === "activity_daily" ? `${now.toISOString().slice(0, 10)}:${event.ai_platform}` : null;
159
+ if (key && !["claude", "codex"].includes(event.ai_platform)) return { status: "invalid_event" };
160
+ wake = (!meta.nextRetryAt || Date.parse(meta.nextRetryAt) <= now.getTime()) && (!meta.lease || meta.lease.until <= now.getTime());
161
+ if (key && meta.hints[key]) {
162
+ meta.pending = records.length + (fs.existsSync(path.join(directory, "legacy.json")) ? 1 : 0);
163
+ writeTelemetryJson(path.join(directory, "meta.json"), meta);
164
+ wake &&= meta.pending > 0;
165
+ return { status: "duplicate" };
166
+ }
167
+ const payload = { event_id: event.event_id || crypto.randomUUID(), event: event.event,
168
+ observed_at: now.toISOString(), source_kind: key ? "ai_hook" : "cli", ai_platform: key ? event.ai_platform : null,
169
+ ...telemetrySnapshot(target, state, options) };
170
+ if (!key) for (const field of ["operation", "outcome", "error_category", "failure_stage", "duration_ms", "duration_kind"]) payload[field] = event[field];
171
+ if (!validPayload(payload)) return { status: "invalid_event" };
172
+ const file = path.join(directory, `event-${payload.event_id}.json`);
173
+ if (fs.existsSync(file)) return { status: "duplicate" };
174
+ records = prune(directory, meta, now.getTime(), 1);
175
+ save({ developerName: payload.developer_name });
176
+ writeTelemetryJson(file, { deviceId: state.deviceId, queuedAt: now.toISOString(), payload });
177
+ if (key) meta.hints[key] = { event_id: payload.event_id, delivered: false };
178
+ meta.pending = records.length + 1 + (fs.existsSync(path.join(directory, "legacy.json")) ? 1 : 0);
179
+ writeTelemetryJson(path.join(directory, "meta.json"), meta);
180
+ return { status: "queued" };
181
+ }, { ...options, create: true });
182
+ if (wake) launchTelemetrySender(options);
183
+ return result;
184
+ } catch { return { status: "failed" }; }
185
+ }
186
+
187
+ /** v1 仍保留原采集口径和节流,网络改由同一 sender 执行。
188
+ * @param {string} target 项目
189
+ * @param {string} event 旧事件
190
+ * @param {object} options 依赖
191
+ * @returns {object} 入队结果
192
+ */
193
+ export function queueLegacyTelemetry(target, event, options = {}) {
194
+ const now = options.now || new Date();
195
+ const directory = telemetryQueueDirectory(options.env);
196
+ const result = withTelemetryState((state, save) => {
197
+ if (!state.enabled) return { status: "disabled" };
198
+ const payload = buildTelemetryPayload(target, event, { ...options, deviceId: state.deviceId, developerName: state.developerName, now });
199
+ if (!payload) return { status: "missing_developer" };
200
+ if (!options.force && isWithinInterval(state, now, readUpdateCheck(path.resolve(target)).intervalHours)) return { status: "throttled" };
201
+ const meta = metadata(directory);
202
+ const records = prune(directory, meta, now.getTime());
203
+ save({ developerName: payload.developer_name, lastAttemptAt: now.toISOString() });
204
+ writeTelemetryJson(path.join(directory, "legacy.json"), { id: crypto.randomUUID(), queuedAt: now.toISOString(), payload });
205
+ meta.pending = records.length + 1;
206
+ writeTelemetryJson(path.join(directory, "meta.json"), meta);
207
+ return { status: "queued" };
208
+ }, { ...options, create: true });
209
+ if (result.status === "queued") launchTelemetrySender(options);
210
+ return result;
211
+ }
212
+
213
+ /** 只读展示本地队列诊断,不创建身份、锁或网络请求。
214
+ * @param {object} options 环境
215
+ * @returns {object} 诊断
216
+ */
217
+ export function telemetryQueueStatus(options = {}) {
218
+ try {
219
+ const directory = telemetryQueueDirectory(options.env);
220
+ return { ...metadata(directory), pending: entries(directory).length + (readTelemetryJson(path.join(directory, "legacy.json")) ? 1 : 0) };
221
+ } catch { return { pending: null, diagnostic: "corrupt_queue" }; }
222
+ }
223
+
224
+ /** 单次发送最多一批;网络之外的状态更新均共锁并复核禁用。
225
+ * @param {object} options 环境、HTTP 和测试时钟
226
+ * @returns {Promise<object>} 发送结果
227
+ */
228
+ export async function flushTelemetryQueue(options = {}) {
229
+ const env = options.env || process.env;
230
+ if (env.FLOWER_NO_TELEMETRY) return { status: "disabled_by_env" };
231
+ const now = options.now || new Date();
232
+ const directory = telemetryQueueDirectory(env);
233
+ const token = crypto.randomUUID();
234
+ let batch, legacy, deviceId;
235
+ try {
236
+ const claimed = withTelemetryState(state => {
237
+ if (!state.enabled) return { status: "disabled" };
238
+ const meta = metadata(directory);
239
+ if ((meta.lease && meta.lease.until > now.getTime()) || Date.parse(meta.nextRetryAt) > now.getTime()) return { status: "deferred" };
240
+ batch = prune(directory, meta, now.getTime()).filter(entry => entry.deviceId === state.deviceId).slice(0, 20);
241
+ // 4 KiB 单事件之外 envelope 也占字节,批次总量留出余量。
242
+ while (batch.length && Buffer.byteLength(JSON.stringify(batch.map(entry => entry.payload))) > 63000) batch.pop();
243
+ legacy = batch.length ? null : readTelemetryJson(path.join(directory, "legacy.json"));
244
+ if (legacy && (!validLegacyEntry(legacy) || legacy.payload.device_id !== state.deviceId)) throw new Error("兼容遥测队列损坏");
245
+ if (legacy && now.getTime() - Date.parse(legacy.queuedAt) >= MAX_AGE) { fs.unlinkSync(path.join(directory, "legacy.json")); legacy = null; meta.dropped += 1; }
246
+ if (!batch.length && !legacy) return { status: "empty" };
247
+ deviceId = state.deviceId;
248
+ meta.lease = { token, until: now.getTime() + 15000 };
249
+ meta.nextRetryAt = new Date(now.getTime() + 60000).toISOString();
250
+ writeTelemetryJson(path.join(directory, "meta.json"), meta);
251
+ return { status: "claimed" };
252
+ }, { env });
253
+ if (claimed.status !== "claimed") return claimed;
254
+ let response, receipts, diagnostic;
255
+ const controller = new AbortController();
256
+ const timer = setTimeout(() => controller.abort(), options.timeoutMs ?? 10000);
257
+ try {
258
+ // 紧靠请求发起再次读取开关;已发出的请求无法由后来的 disable 撤回。
259
+ const allowed = withTelemetryState(state => state.enabled && state.deviceId === deviceId, { env });
260
+ if (allowed !== true) return { status: "disabled" };
261
+ response = await (options.fetch || fetch)(options.endpoint || `${FLOWER_TELEMETRY_ENDPOINT}${legacy ? "" : "/events"}`, {
262
+ method: "POST", headers: { "Content-Type": "application/json" }, signal: controller.signal,
263
+ body: JSON.stringify(legacy ? legacy.payload : { schema_version: 2, device_id: deviceId, events: batch.map(entry => entry.payload) }),
264
+ });
265
+ if (response.ok) {
266
+ if (!legacy) receipts = (await response.json()).results;
267
+ if (!legacy && !Array.isArray(receipts)) throw new Error("invalid_response");
268
+ } else diagnostic = response.status === 429 ? "rate_limited" : response.status === 400 || response.status === 413 ? "invalid_batch" : "server_unavailable";
269
+ } catch { diagnostic = controller.signal.aborted ? "timeout" : "network"; }
270
+ finally { clearTimeout(timer); }
271
+ return withTelemetryState((state, save) => {
272
+ if (!state.enabled || state.deviceId !== deviceId) return { status: "disabled" };
273
+ const meta = metadata(directory);
274
+ if (meta.lease?.token !== token) return { status: "superseded" };
275
+ let acknowledged = 0;
276
+ if (legacy && (response?.ok || diagnostic === "invalid_batch")) {
277
+ if (readTelemetryJson(path.join(directory, "legacy.json"))?.id === legacy.id) fs.unlinkSync(path.join(directory, "legacy.json"));
278
+ if (response?.ok) { save({ lastSuccessAt: now.toISOString() }); acknowledged = 1; }
279
+ }
280
+ if (!legacy) for (const entry of batch) {
281
+ const receipt = receipts?.find(item => item?.event_id === entry.payload.event_id);
282
+ const accepted = ["accepted", "duplicate"].includes(receipt?.status);
283
+ const rejected = receipt?.status === "rejected" && REASONS.has(receipt.reason);
284
+ if (accepted || rejected || diagnostic === "invalid_batch") {
285
+ fs.rmSync(path.join(directory, entry.name), { force: true });
286
+ for (const [key, hint] of Object.entries(meta.hints)) if (hint.event_id === entry.payload.event_id) {
287
+ if (accepted) hint.delivered = true; else delete meta.hints[key];
288
+ }
289
+ if (accepted) acknowledged += 1;
290
+ else diagnostic = receipt?.reason || "invalid_batch";
291
+ }
292
+ }
293
+ meta.pending = entries(directory).length + (fs.existsSync(path.join(directory, "legacy.json")) ? 1 : 0);
294
+ meta.lease = null;
295
+ meta.attempts = acknowledged ? 0 : Math.min(meta.attempts + 1, 10);
296
+ let delay = Math.min(3600000, 60000 * 2 ** Math.max(0, meta.attempts - 1) * (1 + Math.random() * 0.2));
297
+ const retry = response?.headers?.get?.("Retry-After");
298
+ if (retry) {
299
+ const retryDelay = /^\d+$/.test(retry) ? Number(retry) * 1000 : Date.parse(retry) - now.getTime();
300
+ if (Number.isFinite(retryDelay) && retryDelay > 0) delay = Math.max(delay, Math.min(retryDelay, 86400000));
301
+ }
302
+ meta.nextRetryAt = meta.pending ? new Date(now.getTime() + delay).toISOString() : null;
303
+ if (acknowledged && !legacy) meta.lastSuccessAt = now.toISOString();
304
+ if (diagnostic) { meta.diagnostic = diagnostic; meta.lastFailureAt = now.toISOString(); }
305
+ else if (!acknowledged) meta.diagnostic = "invalid_response";
306
+ writeTelemetryJson(path.join(directory, "meta.json"), meta);
307
+ return { status: acknowledged ? "reported" : "failed" };
308
+ }, { env });
309
+ } catch { return { status: "failed" }; }
310
+ }
@@ -0,0 +1,7 @@
1
+ import { flushTelemetryQueue } from "./telemetry-queue.js";
2
+
3
+ // 只执行一次请求,即使 DNS/TLS 或子依赖未正常释放资源也必须结束后台进程。
4
+ const deadline = setTimeout(() => process.exit(0), 15000);
5
+ try { await flushTelemetryQueue(); }
6
+ catch { /* 遥测不得污染宿主终端。 */ }
7
+ finally { clearTimeout(deadline); process.exit(0); }